vortex_edition/lib.rs
1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! Definitions of Vortex *editions*: named, frozen sets of components that a writer may put
5//! in a file, carrying a forever read-compatibility guarantee.
6//!
7//! Editions live on the session, like encodings do: [`EditionSession`] holds the registered
8//! editions and [`EnabledEditions`] selects which of them a writer may emit. Declarations
9//! are plain constants — an [`EditionId`] plus an [`Edition`] record, and one
10//! [`EditionInclusion`] per member stating that it is a member of an edition *and every
11//! later edition of the same family*. Any crate can register declarations into a session,
12//! so inclusions can live next to the component they describe.
13//!
14//! Every membership is typed by a [`ComponentKind`], and members are resolved one kind at a
15//! time with [`EditionSessionExt::enabled_component_ids`]: the file writer restricts the
16//! arrays, layouts, extension dtypes, and aggregates it writes from separate id sets, never one
17//! untyped set.
18//!
19//! An edition is a **draft** until its [`Edition::min_vortex_version`] is recorded —
20//! recording it is the act of freezing. The per-edition member sets are computed from the
21//! registered declarations by [`EditionSession::components_in`], and correctness is enforced
22//! by unit tests: [`EditionSession::validate`] checks a whole registry, and
23//! [`test_harness::validate_edition`] validates one edition's constraints — call it once in
24//! the `#[cfg(test)]` module of each edition definition.
25//!
26//! The first-party edition declarations live in the public `vortex` crate, which registers
27//! and enables them on the default session. See the published spec at
28//! <https://docs.vortex.dev/specs/editions.html>.
29
30mod session;
31pub mod test_harness;
32#[cfg(test)]
33mod tests;
34
35use std::error::Error;
36use std::fmt;
37use std::fmt::Debug;
38use std::fmt::Display;
39use std::fmt::Formatter;
40
41pub use session::EditionSession;
42pub use session::EditionSessionExt;
43pub use session::EnabledEditions;
44use vortex_session::registry::Id;
45
46/// The identifier of an edition, e.g. `core2026.07.0`.
47///
48/// The `family` names an independently versioned, additive group of components (`core` is the
49/// set the default writer emits). The date components record when the edition was frozen and
50/// order editions chronologically *within* a family; there is no ordering across families.
51#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
52pub struct EditionId {
53 /// The edition family, e.g. `core`.
54 pub family: &'static str,
55 /// Year the edition was cut.
56 pub year: u16,
57 /// Month the edition was cut.
58 pub month: u8,
59 /// Distinguishes editions cut in the same month; normally `0`.
60 pub version: u8,
61}
62
63impl EditionId {
64 /// Create an edition identifier. Validated by [`EditionId::validate`], which
65 /// [`test_harness::validate_edition`] exercises
66 /// per edition in unit tests.
67 pub const fn new(family: &'static str, year: u16, month: u8, version: u8) -> Self {
68 Self {
69 family,
70 year,
71 month,
72 version,
73 }
74 }
75
76 /// Returns true if `self` is the same edition as `other` or an earlier edition of the
77 /// same family. Editions of different families are never ordered.
78 pub fn is_at_or_before(&self, other: &EditionId) -> bool {
79 self.family == other.family
80 && (self.year, self.month, self.version) <= (other.year, other.month, other.version)
81 }
82
83 /// Validate the identifier's form: a non-empty lowercase family, a four-digit year,
84 /// and a month in 01-12. Checked for every declared edition by
85 /// [`EditionSession::validate`] and per edition by
86 /// [`test_harness::validate_edition`].
87 pub fn validate(&self) -> Result<(), EditionError> {
88 if self.family.is_empty() || !self.family.chars().all(|c| c.is_ascii_lowercase()) {
89 return Err(EditionError::new(format!(
90 "edition {self} must have a non-empty lowercase family, e.g. `core`"
91 )));
92 }
93 if !(1000..=9999).contains(&self.year) {
94 return Err(EditionError::new(format!(
95 "edition {self} must have a four-digit year"
96 )));
97 }
98 if !(1..=12).contains(&self.month) {
99 return Err(EditionError::new(format!(
100 "edition {self} must have a month in 01-12"
101 )));
102 }
103 Ok(())
104 }
105}
106
107impl Display for EditionId {
108 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
109 write!(
110 f,
111 "{}{}.{:02}.{}",
112 self.family, self.year, self.month, self.version
113 )
114 }
115}
116
117/// The kind of component an edition membership covers.
118///
119/// Ids are unique per kind, not globally: a layout named `vortex.flat` and an array named
120/// `vortex.flat` are different members. Every membership records its kind, and the writer
121/// resolves one kind at a time, so the set restricting written arrays never restricts
122/// written layouts. Further kinds (scalar functions, say) can be added the same way.
123#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
124pub enum ComponentKind {
125 /// An array encoding, e.g. `vortex.alp`, registered in the session's array registry.
126 Array,
127 /// A layout encoding, e.g. `vortex.flat`, registered in the session's layout registry.
128 Layout,
129 /// An extension dtype, e.g. `vortex.timestamp`, registered in the session's dtype registry.
130 DType,
131 /// An aggregate function, e.g. `vortex.min`, written into zone maps and registered in
132 /// the session's aggregate function registry.
133 Aggregate,
134}
135
136impl Display for ComponentKind {
137 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
138 f.write_str(match self {
139 Self::Array => "array",
140 Self::Layout => "layout",
141 Self::DType => "dtype",
142 Self::Aggregate => "aggregate",
143 })
144 }
145}
146
147/// An edition: a named set of components with a read-compatibility guarantee, registered with
148/// [`EditionSession::declare_edition`]. The set itself is computed from the registered
149/// [`EditionInclusion`]s by [`EditionSession::components_in`].
150#[derive(Clone, Copy, Debug)]
151pub struct Edition {
152 /// The edition identifier. Also carries the freeze date: `core2026.07.0` freezes in
153 /// 2026-07.
154 pub id: EditionId,
155 /// The minimum Vortex version whose reader supports every member of this edition.
156 ///
157 /// Recording this is the act of freezing: an edition with `None` is a **draft** — being
158 /// assembled, carrying no guarantee, free to change, never the default write target.
159 /// Validated against the members' [`EditionInclusion::required_vortex_release`] values:
160 /// no member may require a version newer than the edition declares.
161 pub min_vortex_version: Option<&'static str>,
162}
163
164impl Edition {
165 /// A draft is an edition whose `min_vortex_version` has not been recorded yet.
166 pub fn is_draft(&self) -> bool {
167 self.min_vortex_version.is_none()
168 }
169}
170
171/// Declares that a component is a member of an edition — and of every later edition of the
172/// same family. Registered with [`EditionSession::declare_inclusion`].
173#[derive(Clone, Copy, Debug)]
174pub struct EditionInclusion {
175 /// What the membership covers. Ids are unique per kind, so this is part of the
176 /// member's identity, not a label.
177 pub kind: ComponentKind,
178 /// The interned component id, e.g. `vortex.alp`.
179 pub component_id: Id,
180 /// The first edition this component is a member of.
181 pub since: EditionId,
182 /// The earliest Vortex release able to read and execute this component, recorded from
183 /// evidence (e.g. compat-fixture history). `None` until recorded.
184 pub required_vortex_release: Option<&'static str>,
185}
186
187/// A source of a component id for edition declarations.
188///
189/// Implemented for raw id strings (`"vortex.alp"`) and interned [`Id`]s here; encoding
190/// vtables implement it where they are defined, so a declaration can name the vtable
191/// (`&Primitive`) instead of spelling its id. The id alone does not say what kind of
192/// component it names — [`EditionMember`] pairs it with a [`ComponentKind`].
193pub trait AsComponentId: Debug + Send + Sync {
194 /// The interned component id.
195 fn component_id(&self) -> Id;
196}
197
198impl AsComponentId for str {
199 #[expect(
200 clippy::disallowed_methods,
201 reason = "interning a dynamic component id at declaration time"
202 )]
203 fn component_id(&self) -> Id {
204 Id::new(self)
205 }
206}
207
208impl AsComponentId for Id {
209 fn component_id(&self) -> Id {
210 *self
211 }
212}
213
214// `str` is unsized and cannot be a trait object, so declaration blocks name components as
215// `&"vortex.alp"` through this impl.
216impl AsComponentId for &'static str {
217 fn component_id(&self) -> Id {
218 (**self).component_id()
219 }
220}
221
222/// A component that joins an edition, named by id string or vtable and tagged with the kind
223/// of registry it belongs to. Built with the per-kind constructors, so a declaration reads
224/// as `EditionMember::array(&"vortex.alp")`.
225#[derive(Clone, Copy, Debug)]
226pub struct EditionMember {
227 /// What kind of component this is.
228 pub kind: ComponentKind,
229 /// The component, named by id string or by vtable.
230 pub component: &'static dyn AsComponentId,
231}
232
233impl EditionMember {
234 /// An array encoding member, e.g. `vortex.alp`.
235 pub const fn array(component: &'static dyn AsComponentId) -> Self {
236 Self {
237 kind: ComponentKind::Array,
238 component,
239 }
240 }
241
242 /// A layout member, e.g. `vortex.flat`.
243 pub const fn layout(component: &'static dyn AsComponentId) -> Self {
244 Self {
245 kind: ComponentKind::Layout,
246 component,
247 }
248 }
249
250 /// An extension dtype member, e.g. `vortex.timestamp`.
251 pub const fn dtype(component: &'static dyn AsComponentId) -> Self {
252 Self {
253 kind: ComponentKind::DType,
254 component,
255 }
256 }
257
258 /// An aggregate function member, e.g. `vortex.min`.
259 pub const fn aggregate(component: &'static dyn AsComponentId) -> Self {
260 Self {
261 kind: ComponentKind::Aggregate,
262 component,
263 }
264 }
265}
266
267/// Declares an edition together with the components that join the family at it, in one
268/// block. Registered with [`EditionSession::declare`], which derives each member's
269/// membership (`since` = the declared edition) from the block structure.
270#[derive(Clone, Copy, Debug)]
271pub struct EditionDeclaration {
272 /// The edition being declared.
273 pub edition: Edition,
274 /// The components that join the family at this edition, each tagged with its
275 /// [`ComponentKind`]. Members of earlier editions are inherited and never restated.
276 pub added: &'static [EditionMember],
277}
278
279impl EditionInclusion {
280 /// Declare that a component of `kind` is a member of `since` and every later edition of
281 /// the same family. The component can be named by id string or by vtable.
282 pub fn new<C: AsComponentId + ?Sized>(
283 kind: ComponentKind,
284 component: &C,
285 since: EditionId,
286 ) -> Self {
287 Self {
288 kind,
289 component_id: component.component_id(),
290 since,
291 required_vortex_release: None,
292 }
293 }
294
295 /// Declare that an array encoding is a member of `since` and every later edition of the
296 /// same family.
297 pub fn array<C: AsComponentId + ?Sized>(encoding: &C, since: EditionId) -> Self {
298 Self::new(ComponentKind::Array, encoding, since)
299 }
300
301 /// Declare that an extension dtype is a member of `since` and every later edition of the
302 /// same family.
303 pub fn dtype<C: AsComponentId + ?Sized>(dtype: &C, since: EditionId) -> Self {
304 Self::new(ComponentKind::DType, dtype, since)
305 }
306
307 /// Validate the declaration's form: a lowercase `namespace.name` component id and, if
308 /// recorded, a well-formed `major.minor.patch` release. Checked for every declared
309 /// inclusion by [`EditionSession::validate`].
310 pub fn validate(&self) -> Result<(), EditionError> {
311 let id = self.component_id.as_str();
312 let well_formed = !id.starts_with('.')
313 && !id.ends_with('.')
314 && id.contains('.')
315 && id
316 .chars()
317 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "._-".contains(c));
318 if !well_formed {
319 return Err(EditionError::new(format!(
320 "invalid {} id {id:?}: expected lowercase `namespace.name`, e.g. `vortex.alp`",
321 self.kind
322 )));
323 }
324 if let Some(release) = self.required_vortex_release
325 && parse_release(release).is_none()
326 {
327 return Err(EditionError::new(format!(
328 "{} {id} declares malformed required_vortex_release {release:?}",
329 self.kind
330 )));
331 }
332 Ok(())
333 }
334}
335
336/// Parse a `major.minor.patch` release string into a comparable key.
337pub(crate) fn parse_release(release: &str) -> Option<Vec<u64>> {
338 let parts: Vec<u64> = release
339 .split('.')
340 .map(|part| part.parse::<u64>().ok())
341 .collect::<Option<_>>()?;
342 (parts.len() == 3).then_some(parts)
343}
344
345/// Error raised when edition declarations are inconsistent.
346#[derive(Debug)]
347pub struct EditionError(String);
348
349impl EditionError {
350 /// Create an error with the given message.
351 pub fn new(msg: impl Into<String>) -> Self {
352 Self(msg.into())
353 }
354}
355
356impl Display for EditionError {
357 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
358 f.write_str(&self.0)
359 }
360}
361
362impl Error for EditionError {}