djogi 0.1.0-alpha.17

Model-first web framework for Rust — web-framework-agnostic core; Axum integration opt-in via the `axum` feature flag
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
//! Canonical version slug + filename naming for composed migrations.
//! Two responsibilities:
//! 1. **Version IDs.** A composed migration is named
//!    `V<YYYYMMDDHHMMSS>__<sanitized-slug>` (e.g.
//!    `V20260425010203__add_users`). The `V`-prefixed timestamp is the
//!    sortable version key; the slug is the operator-facing name. Two
//!    composes against the same descriptor inventory + snapshot
//!    produce identical SQL because the timestamp is taken from the
//!    caller (compose-time clock, not differ-time clock) and the rest
//!    of the lowering is deterministic — passing the same instant
//!    through this module twice yields the same version ID.
//! 2. **Filename slugs.** The on-disk migration file is
//!    `<version>__<slug>.sdjql` (up side) and `<version>__<slug>.down.sdjql`
//!    (down side). The slug is sanitised down to a strict identifier
//!    grammar so tooling — file globbing, `git diff`, commit messages
//!    never has to worry about whitespace, punctuation, or non-ASCII
//!    bytes inside a migration filename.
//! # Slug grammar (no regex)
//! Per the project-wide no-regex rule, the sanitiser walks the input
//! byte-by-byte and applies these rules:
//! - Each input byte is lowercased if it's `b'A'..=b'Z'`.
//! - ASCII alphanumerics (`b'0'..=b'9'`, `b'a'..=b'z'`) pass through
//!   unchanged.
//! - Whitespace bytes (`b' '`, `b'\t'`, `b'-'`) collapse to a single
//!   `b'_'`.
//! - Underscores pass through.
//! - Every other byte (punctuation, multi-byte UTF-8) is dropped.
//! - Repeated underscores collapse to one.
//! - Leading and trailing underscores are trimmed.
//! - The first byte must be `b'_'` or `u8::is_ascii_alphabetic`. If
//!   the first surviving byte is a digit, the slug is prefixed with
//!   `m_` so the resulting identifier is a valid Postgres-style name.
//! - The total length is capped at 63 bytes (matching the Postgres
//!   identifier byte limit). Longer slugs are truncated; the
//!   timestamp prefix lives in a separate field so the slug truncation
//!   does not collide with the version key.
//!   Rules are spelled out in plain English here and implemented with
//!   byte-level checks below — no regex notation.
//! # Determinism
//! The sanitiser is a pure function of its byte input. The version-ID
//! constructor is a pure function of an `OffsetDateTime` (caller
//! supplies the instant). Two callers passing the same input produce
//! the same output, on every platform, with no environmental
//! dependencies.

use time::OffsetDateTime;

/// Maximum byte length of the sanitised slug (Postgres identifier
/// limit). Operators occasionally pass long migration descriptions;
/// we keep the prefix-plus-slug fits-in-one-filename invariant.
pub const MAX_SLUG_LEN: usize = 63;

/// File extension for composed migration SQL files.
/// `.sdjql` — a framework-specific extension that discourages rote manual
/// execution via `psql`. AI agents pattern-match on `.sql`; using a custom
/// extension breaks that chain. The scanner (`scan_filesystem_with_files`)
/// rejects legacy schema migration `.sql` artifacts with a diagnostic; seed SQL
/// files remain `.sql` and are handled by the seed subsystem.
pub const MIGRATION_FILE_EXT: &str = ".sdjql";

/// Byte length of [`MIGRATION_FILE_EXT`] including the leading dot.
/// Used by stem extraction to avoid magic numbers.
pub const MIGRATION_FILE_EXT_LEN: usize = MIGRATION_FILE_EXT.len();

/// Suffix for the down-side extension (`.down.sdjql`).
pub(crate) const MIGRATION_DOWN_SUFFIX: &str = ".down.sdjql";

/// Derive the ancillary transactional outbox table for a model table.
///
/// This is the single source of truth for the `{parent}_outbox`
/// convention used across projection, verify, runtime outbox writes,
/// and refresh polling so those sites cannot drift.
pub(crate) fn outbox_table_name(parent_table: &str) -> String {
    format!("{parent_table}_outbox")
}

/// Prefix used to make a slug identifier-safe when its first byte
/// would otherwise be a digit. Two letters so the result still fits
/// `MAX_SLUG_LEN`.
const DIGIT_LEAD_PREFIX: &str = "m_";

/// Sanitise an operator-supplied migration name into a strict slug.
/// Empty input — and input that sanitises to an empty byte sequence
/// returns the literal `migration` so the caller still receives a
/// well-formed filename component. Operators who want a more specific
/// slug pass `--name`; the empty-fallback exists so a forgotten
/// `--name` flag doesn't produce a malformed filename.
pub fn sanitize_slug(input: &str) -> String {
    let mut out: Vec<u8> = Vec::with_capacity(input.len().min(MAX_SLUG_LEN));
    let mut last_was_underscore = true; // skip leading underscores
    for byte in input.bytes() {
        let mapped: Option<u8> = match byte {
            // ASCII uppercase → lowercase.
            b'A'..=b'Z' => Some(byte + 32),
            // Lowercase ASCII letters — passthrough.
            b'a'..=b'z' => Some(byte),
            // ASCII digits — passthrough.
            b'0'..=b'9' => Some(byte),
            // Underscore — passthrough.
            b'_' => Some(b'_'),
            // Whitespace / dash — collapse to underscore.
            b' ' | b'\t' | b'\n' | b'\r' | b'-' | b'.' => Some(b'_'),
            // Everything else (punctuation, high bytes from multi-
            // byte UTF-8) — drop.
            _ => None,
        };
        let Some(b) = mapped else {
            continue;
        };
        if b == b'_' {
            if last_was_underscore {
                continue;
            }
            last_was_underscore = true;
            out.push(b);
        } else {
            last_was_underscore = false;
            out.push(b);
        }
        // Cap the working length here to avoid runaway buffers on
        // pathological multi-megabyte inputs. We cap at MAX_SLUG_LEN
        // *plus* one to leave room for the `m_` prefix below; the
        // final truncation happens after that check.
        if out.len() >= MAX_SLUG_LEN + DIGIT_LEAD_PREFIX.len() {
            break;
        }
    }
    // Trim trailing underscore.
    while out.last() == Some(&b'_') {
        out.pop();
    }
    if out.is_empty() {
        return "migration".to_string();
    }
    // First byte rule — prefix with `m_` if leading byte is a digit.
    if let Some(&first) = out.first()
        && first.is_ascii_digit()
    {
        let mut prefixed: Vec<u8> = Vec::with_capacity(out.len() + DIGIT_LEAD_PREFIX.len());
        prefixed.extend_from_slice(DIGIT_LEAD_PREFIX.as_bytes());
        prefixed.extend_from_slice(&out);
        out = prefixed;
    }
    // Length cap — final truncation.
    if out.len() > MAX_SLUG_LEN {
        out.truncate(MAX_SLUG_LEN);
        // Truncation may have left a trailing underscore; trim again.
        while out.last() == Some(&b'_') {
            out.pop();
        }
    }
    // Safe: every surviving byte is ASCII per the byte rules above.
    String::from_utf8(out).expect("sanitiser only emits ASCII")
}

/// Construct a `V<YYYYMMDDHHMMSS>` timestamp prefix from an instant.
/// `instant` is taken as a parameter so callers can pin a deterministic
/// value (tests) or pass `OffsetDateTime::now_utc()` (production
/// compose). The output is exactly 15 ASCII bytes (`V` plus 14 digits).
pub fn version_prefix(instant: OffsetDateTime) -> String {
    let utc = instant.to_offset(time::UtcOffset::UTC);
    // Manual zero-padded format — keeps us off `format_description`
    // round-trips and guarantees byte-stable output.
    let mut s = String::with_capacity(15);
    s.push('V');
    push_pad4(&mut s, utc.year() as u32);
    push_pad2(&mut s, utc.month() as u8 as u32);
    push_pad2(&mut s, utc.day() as u32);
    push_pad2(&mut s, utc.hour() as u32);
    push_pad2(&mut s, utc.minute() as u32);
    push_pad2(&mut s, utc.second() as u32);
    s
}

/// Combine a version prefix and a slug into the canonical version ID
/// (e.g. `V20260425010203__add_users`).
pub fn version_id(prefix: &str, slug: &str) -> String {
    let mut s = String::with_capacity(prefix.len() + 2 + slug.len());
    s.push_str(prefix);
    s.push_str("__");
    s.push_str(slug);
    s
}

/// Filename for the up-side migration — `<version>.sdjql`.
/// The version is already `V<ts>__<slug>`; we deliberately keep the up
/// file name flat (no `.up.sdjql`) so the most common artifact reads
/// like a normal migration file. The down side gets the explicit suffix.
pub fn up_filename(version: &str) -> String {
    format!("{version}{ext}", ext = MIGRATION_FILE_EXT)
}

/// Filename for the down-side migration — `<version>.down.sdjql`.
pub fn down_filename(version: &str) -> String {
    format!("{version}{suffix}", suffix = MIGRATION_DOWN_SUFFIX)
}

/// Filename for the per-app pending JSON — `<app>.json`.
/// The pending JSON is keyed by app, not by version, because there
/// can be at most one pending compose per `(database, app)` at a time.
/// Re-running compose with the same `--name` overwrites this file
/// atomically (per).
pub fn pending_json_filename(app_label: &str) -> String {
    if app_label.is_empty() {
        // Synthetic global bucket — use a stable token instead of an
        // empty filename so file-system tooling does not choke.
        "_global_.json".to_string()
    } else {
        format!("{app_label}.json")
    }
}

/// Filename for the Phase 0 pending JSON — `<version>.json`.
/// Phase 0 uses its own namespace so it cannot collide with the normal
/// global bucket's `_global_.json` pending file.
pub fn phase_zero_pending_json_filename(version: &str) -> String {
    format!("{version}.json")
}

fn push_pad4(s: &mut String, n: u32) {
    let _ = std::fmt::Write::write_fmt(s, format_args!("{n:04}"));
}

fn push_pad2(s: &mut String, n: u32) {
    let _ = std::fmt::Write::write_fmt(s, format_args!("{n:02}"));
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Replacement for `time::macros::datetime!` (feature-gated and
    /// not enabled in djogi's `time` dep). Builds a UTC instant from
    /// explicit components.
    fn at(year: i32, month: u8, day: u8, hour: u8, minute: u8, second: u8) -> OffsetDateTime {
        let date = time::Date::from_calendar_date(year, time::Month::try_from(month).unwrap(), day)
            .unwrap();
        let time = time::Time::from_hms(hour, minute, second).unwrap();
        date.with_time(time).assume_utc()
    }

    #[test]
    fn empty_input_produces_migration_fallback() {
        assert_eq!(sanitize_slug(""), "migration");
        assert_eq!(sanitize_slug("   "), "migration");
        assert_eq!(sanitize_slug("___"), "migration");
        assert_eq!(sanitize_slug("!!!"), "migration");
    }

    #[test]
    fn ascii_passthrough() {
        assert_eq!(sanitize_slug("add_users"), "add_users");
        assert_eq!(sanitize_slug("AddUsers"), "addusers");
    }

    #[test]
    fn whitespace_collapses_to_single_underscore() {
        assert_eq!(sanitize_slug("add users"), "add_users");
        assert_eq!(sanitize_slug("add   users"), "add_users");
        assert_eq!(sanitize_slug("add\tusers"), "add_users");
        assert_eq!(sanitize_slug("add-users"), "add_users");
    }

    #[test]
    fn punctuation_dropped() {
        assert_eq!(sanitize_slug("add!@#$%^&*users"), "addusers");
        assert_eq!(sanitize_slug("add(users)"), "addusers");
    }

    #[test]
    fn multibyte_utf8_dropped() {
        // Each non-ASCII byte (high bit set) is dropped; ASCII bytes
        // pass the byte-rule filter unchanged. Multi-underscore runs
        // collapse to one. The exact ASCII-letter survivors depend on
        // which bytes the input string actually carries — we rely on
        // the well-known UTF-8 spelling of the Polish input below.
        assert_eq!(sanitize_slug("add_zażółć_gęślą_jaźń"), "add_za_gl_ja");
        // All non-ASCII input → migration fallback.
        assert_eq!(sanitize_slug("Привет"), "migration");
    }

    #[test]
    fn leading_digits_prefixed_with_m() {
        assert_eq!(sanitize_slug("123_things"), "m_123_things");
    }

    #[test]
    fn leading_trailing_underscore_trimmed() {
        assert_eq!(sanitize_slug("___add_users___"), "add_users");
    }

    #[test]
    fn length_cap_at_63_bytes() {
        // 80 characters of `a` becomes 63.
        let long = "a".repeat(80);
        let s = sanitize_slug(&long);
        assert_eq!(s.len(), MAX_SLUG_LEN);
        assert!(s.bytes().all(|b| b == b'a'));
    }

    #[test]
    fn length_cap_does_not_leave_trailing_underscore() {
        // A long input whose 63rd byte is `_` should trim to 62.
        let mut input = String::new();
        for _ in 0..62 {
            input.push('a');
        }
        input.push_str("____");
        let s = sanitize_slug(&input);
        // Final byte must not be underscore.
        assert!(!s.ends_with('_'), "got {s:?}");
        assert_eq!(s.len(), 62);
    }

    #[test]
    fn version_prefix_byte_stable() {
        let when = at(2026, 4, 25, 1, 2, 3);
        assert_eq!(version_prefix(when), "V20260425010203");
    }

    #[test]
    fn version_prefix_pads_single_digit_components() {
        let when = at(2026, 1, 2, 3, 4, 5);
        assert_eq!(version_prefix(when), "V20260102030405");
    }

    #[test]
    fn version_prefix_lowest_minute() {
        let when = at(2026, 12, 31, 23, 59, 59);
        assert_eq!(version_prefix(when), "V20261231235959");
    }

    #[test]
    fn version_id_concatenates_with_double_underscore() {
        let v = version_id("V20260425010203", "add_users");
        assert_eq!(v, "V20260425010203__add_users");
    }

    #[test]
    fn up_and_down_filenames() {
        let v = "V20260425010203__add_users";
        assert_eq!(up_filename(v), "V20260425010203__add_users.sdjql");
        assert_eq!(down_filename(v), "V20260425010203__add_users.down.sdjql",);
    }

    #[test]
    fn migration_file_ext_constant_has_expected_value() {
        assert_eq!(MIGRATION_FILE_EXT, ".sdjql");
        assert_eq!(MIGRATION_FILE_EXT_LEN, 6); // includes dot
    }

    #[test]
    fn up_filename_uses_sdjql_extension() {
        let v = "V20260425010203__add_users";
        assert_eq!(up_filename(v), "V20260425010203__add_users.sdjql");
    }

    #[test]
    fn down_filename_uses_down_sdjql_extension() {
        let v = "V20260425010203__add_users";
        assert_eq!(down_filename(v), "V20260425010203__add_users.down.sdjql");
    }

    #[test]
    fn down_side_contains_dot_down_marker() {
        let v = "V20260425010203__add_users";
        let down = down_filename(v);
        assert!(
            down.contains(".down."),
            "down filename must contain .down. marker: {down}"
        );
    }

    #[test]
    fn pending_json_global_bucket_uses_stable_token() {
        assert_eq!(pending_json_filename(""), "_global_.json");
        assert_eq!(pending_json_filename("billing"), "billing.json");
    }

    #[test]
    fn phase_zero_pending_json_uses_version_filename() {
        assert_eq!(
            phase_zero_pending_json_filename("V00000000000000__phase_zero_bootstrap"),
            "V00000000000000__phase_zero_bootstrap.json"
        );
    }

    #[test]
    fn sanitize_byte_stable_on_repeat() {
        // Determinism test — two calls produce identical bytes.
        let a = sanitize_slug("Add some VERY long migration name -- with junk!!!!");
        let b = sanitize_slug("Add some VERY long migration name -- with junk!!!!");
        assert_eq!(a, b);
    }

    #[test]
    fn outbox_table_name_appends_suffix() {
        assert_eq!(outbox_table_name("messages"), "messages_outbox");
        assert_eq!(outbox_table_name("invoices"), "invoices_outbox");
    }

    #[test]
    fn outbox_table_name_matches_projection_convention() {
        let parent = "custom_widgets";
        assert_eq!(outbox_table_name(parent), format!("{parent}_outbox"));
    }

    #[test]
    fn outbox_table_name_matches_runtime_convention() {
        let parent = "events";
        let historical = format!("{parent}_outbox");
        assert_eq!(outbox_table_name(parent), historical);
    }
}