forgedb_types/lib.rs
1//! ForgeDB Types
2//!
3//! Core type definitions for ForgeDB schemas and generated code.
4//!
5//! # Overview
6//!
7//! This crate provides type definitions that match ForgeDB's schema language types,
8//! enabling type-safe serialization, validation, and storage operations. It is a
9//! foundational crate used by generated code and other ForgeDB runtime libraries.
10//!
11//! # Architecture
12//!
13//! The crate is designed around two key concepts:
14//!
15//! - **Primitive Types**: Direct mappings of ForgeDB schema types to Rust types
16//! - **Generic Value Enum**: Runtime type information for heterogeneous data
17//!
18//! All types are designed for zero or minimal overhead with `#[repr(transparent)]`
19//! for wrapper types and efficient serialization using Serde derive macros.
20//!
21//! # Supported Types
22//!
23//! | ForgeDB Type | Rust Type | Description |
24//! |--------------|-----------|-------------|
25//! | `u32` | `u32` | 32-bit unsigned integer |
26//! | `u64` | `u64` | 64-bit unsigned integer |
27//! | `i32` | `i32` | 32-bit signed integer |
28//! | `i64` | `i64` | 64-bit signed integer |
29//! | `f64` | `f64` | 64-bit floating point |
30//! | `bool` | `bool` | Boolean value |
31//! | `string` | `String` | UTF-8 encoded text |
32//! | `string(N)` / `string(N!)` | `String`, or [`InlineStr<N>`](InlineStr) as a **key** | Fixed-slot inline text (#238); a `Copy` key type when the field is an identity or a foreign key to one (#252) |
33//! | `uuid` | [`Uuid`] | Universally unique identifier |
34//! | `timestamp` | [`Timestamp`] | Instant, as **microseconds** since the Unix epoch (#254) |
35//!
36//! # Examples
37//!
38//! ## Basic Usage
39//!
40//! ```rust
41//! use forgedb_types::{Value, Timestamp, Uuid};
42//!
43//! // Create a timestamp from the current time
44//! let ts = Timestamp::now();
45//! println!("Current timestamp: {}", ts); // RFC 3339
46//!
47//! // Work with generic values
48//! let values = vec![
49//! Value::I32(42),
50//! Value::String("hello".to_string()),
51//! Value::Uuid(Uuid::new_v4()),
52//! ];
53//!
54//! // Serialize to JSON
55//! let json = serde_json::to_string(&values[0]).unwrap();
56//! ```
57//!
58//! ## Type Conversions
59//!
60//! ```rust
61//! use forgedb_types::Value;
62//!
63//! // Convenient From implementations
64//! let val: Value = 42_i32.into();
65//! let val: Value = "hello".into();
66//!
67//! // Type checking
68//! if val.is_numeric() {
69//! println!("This is a numeric value");
70//! }
71//! ```
72//!
73//! # Public API
74//!
75//! ## Core Types
76//!
77//! - [`Timestamp`] - Wrapper around `i64` microseconds since the Unix epoch
78//! - [`InlineStr`] - A `Copy`, fixed-capacity string; the backing type of a
79//! `string(N)` identity (#252), and of any foreign key pointing at one (#266)
80//! - [`InlineStrError`] - Why a `&str` did not fit an [`InlineStr`]
81//! - [`Value`] - Enum that can hold any ForgeDB primitive type
82//! - [`Uuid`] - Re-exported from the `uuid` crate
83//!
84//! ## Key Methods
85//!
86//! - `Timestamp::now()` - Get current timestamp
87//! - `Timestamp::from_micros(i64)` / `as_micros()` - The canonical unit
88//! - `Timestamp::to_rfc3339()` / `from_rfc3339(&str)` - The canonical wire form
89//! - `InlineStr::try_from(&str)` / `as_str()` - The key type's bound and its text
90//! - `Value::type_name()` - Get type name string
91//! - `Value::is_numeric()` - Check if numeric type
92//!
93//! # Related Crates
94//!
95//! - [`forgedb-storage`](../forgedb_storage) - Uses these types for columnar storage
96//! - [`forgedb-parser`](../forgedb_parser) - Parses schemas into these types
97//!
98//! # See Also
99//!
100//! - [README](./README.md) for detailed documentation and usage examples
101//! - [uuid crate documentation](https://docs.rs/uuid) for UUID operations
102
103use serde::{Deserialize, Serialize};
104use std::time::{SystemTime, UNIX_EPOCH};
105
106/// Re-export uuid for convenience
107pub use uuid::Uuid;
108
109/// Microseconds elapsed since the Unix epoch (January 1, 1970 00:00:00 UTC).
110///
111/// # The unit is microseconds (#254)
112///
113/// Before v0.4.0 this held whole **seconds**. It now holds **microseconds**, and
114/// microseconds are the one canonical on-disk unit regardless of a field's
115/// declared `timestamp(s|ms|us)` precision — a `timestamp(ms)` field simply
116/// stores millisecond-aligned microsecond values. Storing each field in its own
117/// unit would make a `Timestamp` value unit-ambiguous at runtime, which is only
118/// resolvable with a fatter value (a layout change) or a `Timestamp<const U>`
119/// (generated signature churn everywhere).
120///
121/// `i64` microseconds spans ±292,277 years, which is why microseconds rather
122/// than nanoseconds are the floor: nanoseconds would cap the type at 1678–2262,
123/// putting a birth date or a long-dated bond maturity out of range.
124///
125/// # The wire form is RFC 3339, not a number
126///
127/// [`Display`], [`FromStr`](std::str::FromStr) and serde all speak RFC 3339 —
128/// one rendering everywhere, so the create handler's `id.to_string()` and a REST
129/// path parameter's `parse()` agree with no generated codec. Output is fixed
130/// (**exactly 6 fractional digits, always `Z`**, including for a `timestamp(s)`
131/// field: `Display` is on the type and cannot see a field's precision); input is
132/// lenient (fewer or more fractional digits, lowercase `t`/`z`, any numeric
133/// offset).
134///
135/// A number would have been a *silent* break — a client reading seconds would
136/// start reading microseconds and display 1970 forever. A client expecting a
137/// number and receiving a string fails immediately and visibly.
138///
139/// # Range
140///
141/// The rendering has no failure mode, so the **type's** range is RFC 3339's year
142/// range 0000–9999 ([`is_rfc3339_representable`](Timestamp::is_rfc3339_representable)),
143/// enforced by the generated write path rather than here. Values outside it
144/// still render and re-parse losslessly (with an extended year), so no data is
145/// trapped; they are simply refused at the boundary.
146///
147/// # Examples
148///
149/// ```rust
150/// use forgedb_types::Timestamp;
151///
152/// let ts = Timestamp::from_micros(1_775_000_000_123_456);
153/// assert_eq!(ts.to_rfc3339(), "2026-03-31T23:33:20.123456Z");
154/// assert_eq!(ts.as_micros(), 1_775_000_000_123_456);
155/// assert_eq!("2026-03-31T23:33:20.123456Z".parse::<Timestamp>().unwrap(), ts);
156/// ```
157#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
158pub struct Timestamp(i64);
159
160/// Microseconds in one day — the quantum the civil-date split works in.
161const US_PER_DAY: i64 = 86_400_000_000;
162/// Microseconds in one second.
163const US_PER_SEC: i64 = 1_000_000;
164
165/// `0000-01-01T00:00:00Z`, the first instant RFC 3339 can render.
166const RFC3339_MIN_US: i64 = -62_167_219_200_000_000;
167/// `9999-12-31T23:59:59.999999Z`, the last one.
168const RFC3339_MAX_US: i64 = 253_402_300_799_999_999;
169
170impl Timestamp {
171 /// Creates a timestamp from microseconds since the Unix epoch.
172 ///
173 /// ```rust
174 /// use forgedb_types::Timestamp;
175 /// assert_eq!(Timestamp::from_micros(1_234_567_890).as_micros(), 1_234_567_890);
176 /// ```
177 #[must_use]
178 pub fn from_micros(micros: i64) -> Self {
179 Timestamp(micros)
180 }
181
182 /// Returns the timestamp as microseconds since the Unix epoch.
183 #[must_use]
184 pub fn as_micros(&self) -> i64 {
185 self.0
186 }
187
188 /// Returns the current timestamp, in microseconds.
189 ///
190 /// ```rust
191 /// use forgedb_types::Timestamp;
192 /// assert!(Timestamp::now().as_micros() > 1_577_836_800_000_000);
193 /// ```
194 #[must_use]
195 pub fn now() -> Self {
196 // Saturate rather than panic if the system clock is set before the Unix
197 // epoch: a library constructor should not crash on a misconfigured clock.
198 let micros = SystemTime::now()
199 .duration_since(UNIX_EPOCH)
200 .map(|d| i64::try_from(d.as_micros()).unwrap_or(i64::MAX))
201 .unwrap_or(0);
202 Timestamp(micros)
203 }
204
205 /// Renders the instant as RFC 3339: exactly 6 fractional digits, always `Z`.
206 ///
207 /// Total by construction — this is what [`Display`] calls, and `Display`
208 /// cannot fail. A value outside RFC 3339's year range renders with an
209 /// extended year (`+10000-…` / `-0001-…`) so the round-trip stays lossless;
210 /// such a value is refused at the write boundary instead, via
211 /// [`is_rfc3339_representable`](Self::is_rfc3339_representable).
212 #[must_use]
213 pub fn to_rfc3339(&self) -> String {
214 // Floor division, not truncation: for a pre-epoch value the day index
215 // must round *down* and the remainder stay non-negative, or the clock
216 // fields come out negative.
217 let days = self.0.div_euclid(US_PER_DAY);
218 let rem = self.0.rem_euclid(US_PER_DAY);
219 let (y, m, d) = civil_from_days(days);
220 let (h, min, s, us) = (
221 rem / 3_600_000_000,
222 (rem / 60_000_000) % 60,
223 (rem / US_PER_SEC) % 60,
224 rem % US_PER_SEC,
225 );
226 let year = if (0..=9999).contains(&y) {
227 format!("{y:04}")
228 } else if y < 0 {
229 format!("-{:04}", -y)
230 } else {
231 format!("+{y}")
232 };
233 format!("{year}-{m:02}-{d:02}T{h:02}:{min:02}:{s:02}.{us:06}Z")
234 }
235
236 /// Parses an RFC 3339 instant. Lenient about everything the rendering fixes:
237 /// any number of fractional digits (sub-microsecond digits are **truncated**,
238 /// since the stored unit is the finest thing the value can mean), a lowercase
239 /// `t`/`z` separator, and any numeric offset (`±HH:MM` / `±HHMM` / `±HH`).
240 ///
241 /// An offset is applied, not recorded: the stored value is an instant.
242 pub fn from_rfc3339(s: &str) -> std::result::Result<Self, TimestampParseError> {
243 parse_rfc3339(s).map(Timestamp).ok_or(TimestampParseError)
244 }
245
246 /// Floors the value to a multiple of `quantum_us` (res 5): a user-supplied
247 /// value finer than a field's declared precision is *recorded* coarsely
248 /// rather than refused, because rejecting a valid measurement for being too
249 /// precise only exports the flooring to every client.
250 ///
251 /// **Floor, never truncate toward zero.** Truncation rounds a pre-epoch value
252 /// *forward* in time (`-0.5s` → `0s`), which breaks monotonicity across 1970
253 /// — two instants an hour apart could quantize to the same value from
254 /// opposite sides, or swap order.
255 ///
256 /// A `quantum_us` of `0` or less is the identity (there is nothing to floor
257 /// to), so this never divides by zero on a caller's behalf.
258 #[must_use]
259 pub fn floor_to_micros(&self, quantum_us: i64) -> Self {
260 if quantum_us <= 1 {
261 return *self;
262 }
263 Timestamp(self.0.div_euclid(quantum_us) * quantum_us)
264 }
265
266 /// Whether the value renders inside RFC 3339's year range (0000–9999).
267 ///
268 /// `i64` microseconds spans ±292,277 years, so the wire form is narrower than
269 /// the storage type. Since [`Display`] cannot fail, the narrowing is enforced
270 /// as a validity constraint at the write boundary (a 422) rather than as a
271 /// rendering error — and this predicate is what the generated write path
272 /// checks.
273 #[must_use]
274 pub fn is_rfc3339_representable(&self) -> bool {
275 (RFC3339_MIN_US..=RFC3339_MAX_US).contains(&self.0)
276 }
277}
278
279/// Why an RFC 3339 string could not be read as a [`Timestamp`].
280///
281/// Deliberately opaque: this reaches users through a 400/422 on a REST path
282/// segment or query parameter, where the useful information is "that is not an
283/// RFC 3339 instant", not which of fourteen sub-fields was malformed.
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub struct TimestampParseError;
286
287impl std::fmt::Display for TimestampParseError {
288 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289 f.write_str("not an RFC 3339 timestamp (expected e.g. 2026-03-31T23:33:20.123456Z)")
290 }
291}
292
293impl std::error::Error for TimestampParseError {}
294
295impl std::fmt::Display for Timestamp {
296 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297 f.write_str(&self.to_rfc3339())
298 }
299}
300
301impl std::str::FromStr for Timestamp {
302 type Err = TimestampParseError;
303 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
304 Timestamp::from_rfc3339(s)
305 }
306}
307
308impl Serialize for Timestamp {
309 fn serialize<S: serde::Serializer>(&self, ser: S) -> std::result::Result<S::Ok, S::Error> {
310 ser.serialize_str(&self.to_rfc3339())
311 }
312}
313
314impl<'de> Deserialize<'de> for Timestamp {
315 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> std::result::Result<Self, D::Error> {
316 let s = <std::borrow::Cow<'de, str>>::deserialize(de)?;
317 Timestamp::from_rfc3339(&s).map_err(serde::de::Error::custom)
318 }
319}
320
321// ---- RFC 3339 <-> micros, hand-rolled ---------------------------------------
322//
323// `forgedb-types` is linked by every generated project and must build for
324// `wasm32-unknown-unknown`, so its dependency list is deliberately two crates
325// long. Pulling `chrono` in for two functions would add a large transitive
326// surface and a wasm feature-flag question for no gain: the civil-date
327// conversion below is Howard Hinnant's, exact over the whole `i64` micros range,
328// and about sixty lines.
329
330/// Days since 1970-01-01 for a proleptic-Gregorian civil date.
331fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
332 let y = if m <= 2 { y - 1 } else { y };
333 let era = (if y >= 0 { y } else { y - 399 }) / 400;
334 let yoe = y - era * 400; // [0, 399]
335 let mp = if m > 2 { m - 3 } else { m + 9 }; // [0, 11]
336 let doy = (153 * mp + 2) / 5 + d - 1; // [0, 365]
337 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
338 era * 146097 + doe - 719468
339}
340
341/// The proleptic-Gregorian civil date for a day index since 1970-01-01.
342fn civil_from_days(z: i64) -> (i64, i64, i64) {
343 let z = z + 719468;
344 let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
345 let doe = z - era * 146097; // [0, 146096]
346 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
347 let y = yoe + era * 400;
348 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
349 let mp = (5 * doy + 2) / 153; // [0, 11]
350 let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
351 let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
352 (if m <= 2 { y + 1 } else { y }, m, d)
353}
354
355/// Days in `m` of year `y`, for the calendar-validity check. A parse that
356/// skipped this would silently accept `2026-02-30` and normalize it to March 2 —
357/// a wrong date accepted quietly, which is the failure class this issue is about.
358fn days_in_month(y: i64, m: i64) -> i64 {
359 match m {
360 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
361 4 | 6 | 9 | 11 => 30,
362 2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29,
363 2 => 28,
364 _ => 0,
365 }
366}
367
368/// `Some(micros)` for a well-formed RFC 3339 instant, `None` otherwise. Returns
369/// an `Option` rather than panicking anywhere: every caller is a REST boundary.
370fn parse_rfc3339(s: &str) -> Option<i64> {
371 let b = s.as_bytes();
372 let mut i = 0usize;
373
374 // Year: an optional sign then at least four digits (exactly four unless the
375 // year is signed — the extended form only ever comes back from our own
376 // rendering of an out-of-range value).
377 let neg_year = match b.first() {
378 Some(b'-') => {
379 i = 1;
380 true
381 }
382 Some(b'+') => {
383 i = 1;
384 false
385 }
386 _ => false,
387 };
388 let year_start = i;
389 while i < b.len() && b[i].is_ascii_digit() {
390 i += 1;
391 }
392 let year_digits = i - year_start;
393 if year_digits < 4 || (year_digits > 4 && year_start == 0) {
394 return None;
395 }
396 let year: i64 = s[year_start..i].parse().ok()?;
397 let year = if neg_year { -year } else { year };
398
399 let month = take_fixed(b, &mut i, b'-', 2)?;
400 let day = take_fixed(b, &mut i, b'-', 2)?;
401
402 // Date/time separator: `T`, `t`, or a space (RFC 3339 §5.6's NOTE).
403 if i >= b.len() || !matches!(b[i], b'T' | b't' | b' ') {
404 return None;
405 }
406 i += 1;
407
408 let hour = take_digits(b, &mut i, 2)?;
409 let minute = take_fixed(b, &mut i, b':', 2)?;
410 let second = take_fixed(b, &mut i, b':', 2)?;
411
412 // Calendar and clock validity. A leap second (`:60`) is refused rather than
413 // clamped: there is no microsecond value for it, and silently mapping it to
414 // `:59.999999` would move the instant.
415 if !(1..=12).contains(&month)
416 || day < 1
417 || day > days_in_month(year, month)
418 || hour > 23
419 || minute > 59
420 || second > 59
421 {
422 return None;
423 }
424
425 // Fraction: at least one digit if the `.` is present. Digits past the sixth
426 // are truncated (the stored unit is the finest meaning available).
427 let mut micros_of_second = 0i64;
428 if i < b.len() && b[i] == b'.' {
429 i += 1;
430 let start = i;
431 while i < b.len() && b[i].is_ascii_digit() {
432 i += 1;
433 }
434 if i == start {
435 return None;
436 }
437 let digits = &s[start..(start + 6).min(i)];
438 micros_of_second = digits.parse::<i64>().ok()? * 10i64.pow(6 - digits.len() as u32);
439 }
440
441 // Offset: `Z`/`z`, or a signed `HH`, `HHMM`, `HH:MM`. Required — a bare local
442 // time is not an instant, so it is not a Timestamp.
443 let offset_seconds = match b.get(i) {
444 Some(b'Z') | Some(b'z') => {
445 i += 1;
446 0
447 }
448 Some(c @ (b'+' | b'-')) => {
449 let sign = if *c == b'-' { -1 } else { 1 };
450 i += 1;
451 let oh = take_digits(b, &mut i, 2)?;
452 let om = if i < b.len() {
453 if b[i] == b':' {
454 i += 1;
455 }
456 if i < b.len() && b[i].is_ascii_digit() {
457 take_digits(b, &mut i, 2)?
458 } else {
459 return None;
460 }
461 } else {
462 0
463 };
464 if oh > 23 || om > 59 {
465 return None;
466 }
467 sign * (oh * 3600 + om * 60)
468 }
469 _ => return None,
470 };
471 // Trailing anything (including a space) is a malformed instant, not a
472 // forgivable one — a lenient tail is how `"2026-…Z garbage"` gets accepted.
473 if i != b.len() {
474 return None;
475 }
476
477 let days = days_from_civil(year, month, day);
478 let secs_of_day = hour * 3600 + minute * 60 + second - offset_seconds;
479 days.checked_mul(US_PER_DAY)?
480 .checked_add(secs_of_day.checked_mul(US_PER_SEC)?)?
481 .checked_add(micros_of_second)
482}
483
484/// Consume exactly `n` ASCII digits at `i`, advancing it.
485fn take_digits(b: &[u8], i: &mut usize, n: usize) -> Option<i64> {
486 if *i + n > b.len() {
487 return None;
488 }
489 let mut v: i64 = 0;
490 for k in 0..n {
491 let c = b[*i + k];
492 if !c.is_ascii_digit() {
493 return None;
494 }
495 v = v * 10 + i64::from(c - b'0');
496 }
497 *i += n;
498 Some(v)
499}
500
501/// Consume a fixed separator then exactly `n` digits.
502fn take_fixed(b: &[u8], i: &mut usize, sep: u8, n: usize) -> Option<i64> {
503 if b.get(*i) != Some(&sep) {
504 return None;
505 }
506 *i += 1;
507 take_digits(b, i, n)
508}
509
510impl From<i64> for Timestamp {
511 /// Microseconds since the epoch, matching [`Timestamp::from_micros`].
512 fn from(micros: i64) -> Self {
513 Timestamp(micros)
514 }
515}
516
517impl From<Timestamp> for i64 {
518 fn from(ts: Timestamp) -> Self {
519 ts.0
520 }
521}
522
523/// A generic value type that can hold any ForgeDB primitive type
524///
525/// This enum represents all primitive types supported by ForgeDB schemas,
526/// providing a type-safe way to work with heterogeneous data.
527///
528/// # Examples
529///
530/// ```rust
531/// use forgedb_types::{Value, Uuid};
532///
533/// let int_val = Value::I32(42);
534/// let uint_val = Value::U64(1_000_000_000_u64);
535/// let str_val = Value::String("hello".to_string());
536/// let uuid_val = Value::Uuid(Uuid::new_v4());
537///
538/// // Serialize to JSON
539/// let json = serde_json::to_string(&int_val).unwrap();
540/// ```
541#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
542#[serde(tag = "type", content = "value")]
543pub enum Value {
544 /// 32-bit unsigned integer
545 U32(u32),
546 /// 64-bit unsigned integer — stored losslessly; `u64` values above `i64::MAX`
547 /// cannot be represented by the signed `I64` variant without truncation
548 U64(u64),
549 /// 32-bit signed integer
550 I32(i32),
551 /// 64-bit signed integer
552 I64(i64),
553 /// 64-bit floating point number
554 F64(f64),
555 /// Boolean value
556 Bool(bool),
557 /// UTF-8 encoded string
558 String(String),
559 /// Universally unique identifier
560 Uuid(Uuid),
561 /// An instant (microseconds since the Unix epoch); JSON form is RFC 3339
562 Timestamp(Timestamp),
563}
564
565impl Value {
566 /// Returns the type name of this value
567 ///
568 /// # Examples
569 ///
570 /// ```rust
571 /// use forgedb_types::Value;
572 ///
573 /// let val = Value::I32(42);
574 /// assert_eq!(val.type_name(), "i32");
575 ///
576 /// let val = Value::U64(u64::MAX);
577 /// assert_eq!(val.type_name(), "u64");
578 /// ```
579 #[must_use]
580 pub fn type_name(&self) -> &'static str {
581 match self {
582 Value::U32(_) => "u32",
583 Value::U64(_) => "u64",
584 Value::I32(_) => "i32",
585 Value::I64(_) => "i64",
586 Value::F64(_) => "f64",
587 Value::Bool(_) => "bool",
588 Value::String(_) => "string",
589 Value::Uuid(_) => "uuid",
590 Value::Timestamp(_) => "timestamp",
591 }
592 }
593
594 /// Returns true if this value is a numeric type (u32, u64, i32, i64, or f64)
595 ///
596 /// # Examples
597 ///
598 /// ```rust
599 /// use forgedb_types::Value;
600 ///
601 /// assert!(Value::U32(10).is_numeric());
602 /// assert!(Value::U64(u64::MAX).is_numeric());
603 /// assert!(Value::I32(42).is_numeric());
604 /// assert!(Value::F64(3.14).is_numeric());
605 /// assert!(!Value::String("hello".to_string()).is_numeric());
606 /// ```
607 #[must_use]
608 pub fn is_numeric(&self) -> bool {
609 matches!(
610 self,
611 Value::U32(_) | Value::U64(_) | Value::I32(_) | Value::I64(_) | Value::F64(_)
612 )
613 }
614
615 /// Returns true if this value is a string
616 ///
617 /// # Examples
618 ///
619 /// ```rust
620 /// use forgedb_types::Value;
621 ///
622 /// assert!(Value::String("hello".to_string()).is_string());
623 /// assert!(!Value::I32(42).is_string());
624 /// ```
625 #[must_use]
626 pub fn is_string(&self) -> bool {
627 matches!(self, Value::String(_))
628 }
629}
630
631// Implement From for convenient value construction
632impl From<u32> for Value {
633 fn from(v: u32) -> Self {
634 Value::U32(v)
635 }
636}
637
638impl From<u64> for Value {
639 fn from(v: u64) -> Self {
640 Value::U64(v)
641 }
642}
643
644impl From<i32> for Value {
645 fn from(v: i32) -> Self {
646 Value::I32(v)
647 }
648}
649
650impl From<i64> for Value {
651 fn from(v: i64) -> Self {
652 Value::I64(v)
653 }
654}
655
656impl From<f64> for Value {
657 fn from(v: f64) -> Self {
658 Value::F64(v)
659 }
660}
661
662impl From<bool> for Value {
663 fn from(v: bool) -> Self {
664 Value::Bool(v)
665 }
666}
667
668impl From<String> for Value {
669 fn from(v: String) -> Self {
670 Value::String(v)
671 }
672}
673
674impl From<&str> for Value {
675 fn from(v: &str) -> Self {
676 Value::String(v.to_string())
677 }
678}
679
680impl From<Uuid> for Value {
681 fn from(v: Uuid) -> Self {
682 Value::Uuid(v)
683 }
684}
685
686impl From<Timestamp> for Value {
687 fn from(v: Timestamp) -> Self {
688 Value::Timestamp(v)
689 }
690}
691
692
693// ---- InlineStr: the `Copy` fixed-capacity string key (#252) ------------------
694
695/// A `Copy`, fixed-capacity UTF-8 string holding at most `BYTES` bytes.
696///
697/// # Why it exists
698///
699/// The generated code passes a model's identity **by value**, repeatedly —
700/// `get(id)`, `delete(id)`, relation resolution, index-key construction, the
701/// live-query delta enum. That is sound for `Uuid`/`u32`/`u64`/[`Timestamp`]
702/// because they are `Copy`; `String` is not, so `id: string` produced 31
703/// move/borrow errors across a single model. Making the key type `Copy` is far
704/// smaller than making every id-consuming path stop copying, and it is what
705/// `string(N)` / `string(N!)` identities are built on (#252).
706///
707/// # Class-1 substrate
708///
709/// It knows nothing about schemas, identities or URLs. In particular the
710/// URL-path-segment alphabet a *string identity's value* must obey (#252 res 4)
711/// is **not** enforced here — that rule applies because a field is an identity,
712/// which is schema knowledge, so the check is emitted into the generated write
713/// path (res 7). What this type enforces is only the capacity bound.
714///
715/// # Parameterized by bytes, declared in characters
716///
717/// `BYTES` is a byte capacity, not a character count: the substrate cannot
718/// compute `[u8; CHARS * 4]` without the unstable `generic_const_exprs`, so the
719/// generator emits the constant. For an identity that mapping is the identity
720/// function — `string(26)` and `string(26!)` both give `InlineStr<26>`, because
721/// the column is one byte per character (#238) and `@utf8` on an identity is a
722/// validation error (#252 res 3). The `4N` form survives for non-identity
723/// columns, which is why the parameter stays in bytes.
724///
725/// # Nothing here is derived except `Clone, Copy`
726///
727/// | impl | why it is hand-written |
728/// |---|---|
729/// | `PartialEq`/`Eq`/`PartialOrd`/`Ord`/`Hash` | defined on [`Self::as_str`]; the bytes past `len` are not part of the value (#252 res 8) |
730/// | `Serialize`/`Deserialize` | a JSON **string**. serde's *derive* has per-length array impls that stop at 32 — the #243 defect (`char(N)` above 32 could not be indexed). Deriving here would compile for `InlineStr<26>` and fail for `InlineStr<64>` |
731/// | `Debug` | a derive would print `BYTES` integers into every log line and panic message |
732///
733/// # Examples
734///
735/// ```rust
736/// use forgedb_types::InlineStr;
737///
738/// let ulid: InlineStr<26> = "01JQZ8Y7X6W5V4T3S2R1Q0P9NM".try_into().unwrap();
739/// assert_eq!(ulid.len(), 26);
740/// assert_eq!(ulid.to_string(), "01JQZ8Y7X6W5V4T3S2R1Q0P9NM");
741///
742/// // The capacity is a bound, never a truncation.
743/// assert!(InlineStr::<4>::try_from("hello").is_err());
744/// ```
745#[derive(Clone, Copy)]
746pub struct InlineStr<const BYTES: usize> {
747 buf: [u8; BYTES],
748 /// Bytes of `buf` that are in use.
749 ///
750 /// `u16` rather than `u8` because `InlineStr` is not identity-only: #256 uses
751 /// it for bounded string *index keys*, and a non-identity `string(255) @utf8`
752 /// column reaches `BYTES = 1020`. For an identity `BYTES <= 255` always.
753 len: u16,
754}
755
756/// Why a `&str` did not fit an [`InlineStr`].
757///
758/// Carries both numbers so the generated 422 can name the bound without
759/// re-deriving it from the schema.
760#[derive(Debug, Clone, Copy, PartialEq, Eq)]
761pub struct InlineStrError {
762 /// The length of the offered value, in bytes.
763 pub got_bytes: usize,
764 /// The capacity of the target `InlineStr`, in bytes.
765 pub capacity: usize,
766}
767
768impl std::fmt::Display for InlineStrError {
769 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
770 write!(
771 f,
772 "value is {} bytes, but the column holds at most {}",
773 self.got_bytes, self.capacity
774 )
775 }
776}
777
778impl std::error::Error for InlineStrError {}
779
780impl<const BYTES: usize> InlineStr<BYTES> {
781 /// The byte capacity, as a constant.
782 pub const CAPACITY: usize = BYTES;
783
784 /// Tripped at monomorphization if a width could not record its own length.
785 /// A `u16` `len` caps the type at 65535 bytes, well above the 1020 that
786 /// `string(255) @utf8` reaches — this exists so the cap is a compile error
787 /// rather than a silent truncating `as u16`.
788 const _LEN_FITS: () = assert!(
789 BYTES <= u16::MAX as usize,
790 "InlineStr records its length in a u16, so BYTES must be at most 65535",
791 );
792
793 /// The text, borrowed.
794 ///
795 /// Infallible: UTF-8 is checked at construction, and construction is the only
796 /// writer of `buf`.
797 #[must_use]
798 pub fn as_str(&self) -> &str {
799 // SAFETY: `buf` is private and every constructor copies a whole `&str`
800 // into it — the over-capacity case is *rejected* rather than truncated,
801 // so a partial UTF-8 sequence can never be stored. `buf[..len]` is
802 // therefore always a complete, valid UTF-8 sequence.
803 unsafe { std::str::from_utf8_unchecked(&self.buf[..self.len as usize]) }
804 }
805
806 /// The used bytes, borrowed. Excludes the unused tail.
807 #[must_use]
808 pub fn as_bytes(&self) -> &[u8] {
809 &self.buf[..self.len as usize]
810 }
811
812 /// Length in **bytes** (matching `str::len`), not in characters.
813 #[must_use]
814 pub fn len(&self) -> usize {
815 self.len as usize
816 }
817
818 /// Whether the value is the empty string.
819 ///
820 /// The empty string is a legal `InlineStr` — it is the [`Default`], which the
821 /// generated id field's `#[serde(default)]` needs — and is rejected as a
822 /// *key* at write time (#252 res 5), not here.
823 #[must_use]
824 pub fn is_empty(&self) -> bool {
825 self.len == 0
826 }
827}
828
829impl<const BYTES: usize> Default for InlineStr<BYTES> {
830 /// The empty string.
831 ///
832 /// Every generated model struct carries `#[serde(default)]` on its id field
833 /// so a create body may omit it. That is safe against res 5 because the empty
834 /// key is rejected at *write*: the default exists for the
835 /// deserialize-then-overwrite path to land in.
836 fn default() -> Self {
837 Self {
838 buf: [0u8; BYTES],
839 len: 0,
840 }
841 }
842}
843
844impl<const BYTES: usize> TryFrom<&str> for InlineStr<BYTES> {
845 type Error = InlineStrError;
846
847 /// Copy `s` in whole, or fail.
848 ///
849 /// **Never truncates.** A silently shortened key writes a row under an id the
850 /// caller never asked for, and the caller's own copy of the id then resolves
851 /// to nothing.
852 ///
853 /// The unused tail is zeroed. Nothing depends on it — [`Self::as_str`] slices
854 /// to `len`, and comparison is defined on the text — but it makes two equal
855 /// values byte-identical, which is what lets an inline column file stay
856 /// byte-reproducible for the same logical content (#238).
857 fn try_from(s: &str) -> Result<Self, Self::Error> {
858 let () = Self::_LEN_FITS;
859 let b = s.as_bytes();
860 if b.len() > BYTES {
861 return Err(InlineStrError {
862 got_bytes: b.len(),
863 capacity: BYTES,
864 });
865 }
866 let mut buf = [0u8; BYTES];
867 buf[..b.len()].copy_from_slice(b);
868 Ok(Self {
869 buf,
870 len: b.len() as u16,
871 })
872 }
873}
874
875impl<const BYTES: usize> TryFrom<&String> for InlineStr<BYTES> {
876 type Error = InlineStrError;
877 fn try_from(s: &String) -> Result<Self, Self::Error> {
878 Self::try_from(s.as_str())
879 }
880}
881
882impl<const BYTES: usize> std::str::FromStr for InlineStr<BYTES> {
883 type Err = InlineStrError;
884
885 /// Identical to [`TryFrom<&str>`]. Present because axum's `Path<T>` extractor
886 /// resolves a route segment through `FromStr`, which is what makes
887 /// `/docs/{id}` work for a string-keyed model with nothing generated.
888 fn from_str(s: &str) -> Result<Self, Self::Err> {
889 Self::try_from(s)
890 }
891}
892
893impl<const BYTES: usize> std::ops::Deref for InlineStr<BYTES> {
894 type Target = str;
895
896 /// So the generated validation, filter and sort code keeps comparing against
897 /// `&str` unchanged.
898 fn deref(&self) -> &str {
899 self.as_str()
900 }
901}
902
903impl<const BYTES: usize> AsRef<str> for InlineStr<BYTES> {
904 fn as_ref(&self) -> &str {
905 self.as_str()
906 }
907}
908
909// Comparison and hashing are defined on the TEXT, never on the buffer (#252
910// res 8). The bytes past `len` are not part of the value: two values built from
911// the same `&str` through different paths must be equal and must hash equal, and
912// comparing `buf` wholesale would make that depend on what happened to be in the
913// tail. This is a `HashMap` key type — `id_to_row`, `id_versions`, and #266's
914// junction traversal indexes — so getting it wrong produces lookup misses rather
915// than compile errors. The zeroed tail in the constructors is defence in depth,
916// not the mechanism.
917impl<const BYTES: usize> PartialEq for InlineStr<BYTES> {
918 fn eq(&self, other: &Self) -> bool {
919 self.as_str() == other.as_str()
920 }
921}
922
923impl<const BYTES: usize> Eq for InlineStr<BYTES> {}
924
925impl<const BYTES: usize> std::hash::Hash for InlineStr<BYTES> {
926 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
927 self.as_str().hash(state);
928 }
929}
930
931impl<const BYTES: usize> Ord for InlineStr<BYTES> {
932 /// Lexicographic by bytes, exactly as `str` orders — so an index range scan
933 /// over a string key (#256) returns rows in the order the key text implies.
934 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
935 self.as_str().cmp(other.as_str())
936 }
937}
938
939impl<const BYTES: usize> PartialOrd for InlineStr<BYTES> {
940 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
941 Some(self.cmp(other))
942 }
943}
944
945// Cross-type comparison against the borrowed and owned string types, so a
946// generated filter can compare a key column against a parsed query parameter
947// without either side spelling a conversion.
948impl<const BYTES: usize> PartialEq<str> for InlineStr<BYTES> {
949 fn eq(&self, other: &str) -> bool {
950 self.as_str() == other
951 }
952}
953
954impl<const BYTES: usize> PartialEq<&str> for InlineStr<BYTES> {
955 fn eq(&self, other: &&str) -> bool {
956 self.as_str() == *other
957 }
958}
959
960impl<const BYTES: usize> PartialEq<String> for InlineStr<BYTES> {
961 fn eq(&self, other: &String) -> bool {
962 self.as_str() == other.as_str()
963 }
964}
965
966impl<const BYTES: usize> PartialEq<InlineStr<BYTES>> for str {
967 fn eq(&self, other: &InlineStr<BYTES>) -> bool {
968 self == other.as_str()
969 }
970}
971
972impl<const BYTES: usize> PartialEq<InlineStr<BYTES>> for String {
973 fn eq(&self, other: &InlineStr<BYTES>) -> bool {
974 self.as_str() == other.as_str()
975 }
976}
977
978impl<const BYTES: usize> std::fmt::Display for InlineStr<BYTES> {
979 /// The text. This is what the generated create handler's `id.to_string()`
980 /// and the index key's `write!` produce, and what makes `/docs/{id}`
981 /// resolvable.
982 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
983 f.write_str(self.as_str())
984 }
985}
986
987impl<const BYTES: usize> std::fmt::Debug for InlineStr<BYTES> {
988 /// Renders as a quoted string, like `str` — a derived `Debug` would print
989 /// `BYTES` integers into every log line and every panic message.
990 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
991 std::fmt::Debug::fmt(self.as_str(), f)
992 }
993}
994
995impl<const BYTES: usize> Serialize for InlineStr<BYTES> {
996 /// A JSON **string**, at every width.
997 ///
998 /// Do NOT replace this with `#[derive(Serialize)]`. serde's derive would
999 /// serialize the `[u8; BYTES]` field, and its array impls stop at 32 — so a
1000 /// derive compiles for `InlineStr<26>` and fails to compile for
1001 /// `InlineStr<64>`. That is the #243 defect (`char(N)` above 32 could not be
1002 /// indexed) designed out at the type level rather than re-hit per width.
1003 fn serialize<S: serde::Serializer>(&self, ser: S) -> std::result::Result<S::Ok, S::Error> {
1004 ser.serialize_str(self.as_str())
1005 }
1006}
1007
1008impl<'de, const BYTES: usize> Deserialize<'de> for InlineStr<BYTES> {
1009 /// From a JSON string, rejecting an over-capacity value rather than
1010 /// truncating it — see [`Serialize`] for why this is not a derive.
1011 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> std::result::Result<Self, D::Error> {
1012 let s = <std::borrow::Cow<'de, str>>::deserialize(de)?;
1013 Self::try_from(s.as_ref()).map_err(serde::de::Error::custom)
1014 }
1015}
1016
1017#[cfg(test)]
1018mod inline_str_internal_tests {
1019 use super::InlineStr;
1020 use std::collections::hash_map::DefaultHasher;
1021 use std::hash::{Hash, Hasher};
1022
1023 fn hash_of<T: Hash>(v: &T) -> u64 {
1024 let mut h = DefaultHasher::new();
1025 v.hash(&mut h);
1026 h.finish()
1027 }
1028
1029 /// Res 8's tail clause, which cannot be written as an integration test: the
1030 /// public API has no way to produce a dirty tail (every constructor copies a
1031 /// whole `&str` into a zeroed buffer), by design. So the guard reaches
1032 /// through the private fields and builds one directly.
1033 ///
1034 /// Without this, `#[derive(PartialEq, Hash)]` would pass every other
1035 /// scenario — with a zeroed tail a buffer-wise comparison agrees with a
1036 /// text-wise one — and res 8 would be an unguarded claim.
1037 #[test]
1038 fn inline_str_tail_is_not_part_of_the_value() {
1039 let clean = InlineStr::<16> {
1040 buf: *b"ab\0\0\0\0\0\0\0\0\0\0\0\0\0\0",
1041 len: 2,
1042 };
1043 let dirty = InlineStr::<16> {
1044 buf: *b"abZZZZZZZZZZZZZZ",
1045 len: 2,
1046 };
1047
1048 assert_eq!(clean.as_str(), "ab");
1049 assert_eq!(dirty.as_str(), "ab");
1050 assert_eq!(clean, dirty, "the tail must not participate in equality");
1051 assert_eq!(
1052 hash_of(&clean),
1053 hash_of(&dirty),
1054 "the tail must not participate in the hash"
1055 );
1056 assert_eq!(
1057 clean.cmp(&dirty),
1058 std::cmp::Ordering::Equal,
1059 "the tail must not participate in the ordering"
1060 );
1061 }
1062
1063 /// The same clause for `Ord` across two different values, where a buffer-wise
1064 /// comparison would produce the *wrong* answer rather than merely a
1065 /// different-looking one: `"ab"` with a `Z` tail sorts above `"abc"` on bytes,
1066 /// but below it on text.
1067 #[test]
1068 fn inline_str_ordering_ignores_a_dirty_tail() {
1069 let ab_dirty = InlineStr::<8> {
1070 buf: *b"abZZZZZZ",
1071 len: 2,
1072 };
1073 let abc = InlineStr::<8> {
1074 buf: *b"abc\0\0\0\0\0",
1075 len: 3,
1076 };
1077 assert!(ab_dirty < abc);
1078 }
1079}