Skip to main content

cratefield_client_ts/
contract.rs

1//! Reads the `tables` module's entry out of a `/__surface` document into
2//! the small model the emitter renders.
3//!
4//! The reading is deliberately literal: the routes, the audience and the
5//! schema are taken from the actions as the contract publishes them, not
6//! reconstructed from what the Tables module happens to serve today. That
7//! is what makes the compile-time absence work — a table the contract
8//! publishes no `delete` for gets no `delete` method, because the client
9//! never asked the contract for one.
10
11use serde_json::Value;
12
13use cratefield_core::{Audience, SurfaceDocument};
14
15use crate::GenerateError;
16
17/// The module whose contract the client speaks: the Tables module's mount
18/// name (`crates/manifest/src/generate_tables.rs`, `MODULE_NAME`).
19pub const TABLES_MODULE: &str = "tables";
20
21/// One of the five verbs the Tables module publishes, as the action names
22/// spell them (`list-note`, `delete-note`, ...).
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub(crate) enum Verb {
25    /// `list-{t}` — the page.
26    List,
27    /// `read-{t}` — one row by key.
28    Read,
29    /// `create-{t}` — a write.
30    Create,
31    /// `replace-{t}` — a write that names a row.
32    Replace,
33    /// `delete-{t}` — a write that names a row.
34    Delete,
35}
36
37impl Verb {
38    /// The method the verb is served by, so a drifted contract is caught
39    /// rather than silently mis-typed.
40    fn method(self) -> &'static str {
41        match self {
42            Verb::List | Verb::Read => "GET",
43            Verb::Create => "POST",
44            Verb::Replace => "PUT",
45            Verb::Delete => "DELETE",
46        }
47    }
48}
49
50/// One column's type, normalized from the JSON Schema shape the Tables
51/// module publishes for it (`crates/tables/src/json_schema.rs`). The
52/// shapes are closed: the module emits exactly these, so anything else
53/// reads as [`ColumnKind::Opaque`] rather than as a guess.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum ColumnKind {
56    /// `{"type": "string"}` — with optional `minLength`/`maxLength`, an
57    /// optional `format` of `"email"` or `"uri"`, and the `not`/`pattern`
58    /// ban on U+0000. A `timestamp` (`format: "date-time"`) and a `uuid`
59    /// (`format: "uuid"`) normalize here too: the wire type is a string
60    /// either way.
61    Text,
62    /// `{"type": "integer"}`, with optional `minimum`/`maximum`.
63    Integer,
64    /// `{"type": "number"}`, with optional `minimum`/`maximum`.
65    Real,
66    /// `{"type": "boolean"}`.
67    Boolean,
68    /// A closed list of literals (`"enum"`), their JSON text forms, with
69    /// the `null` member left out — nullability lives on
70    /// [`ColumnModel::nullable`].
71    Enum(Vec<String>),
72    /// The schema names no type at all: the `json` column's own shape
73    /// ("any JSON value", nothing to narrow), or a shape this generator
74    /// cannot read. TypeScript gets `unknown`, and the wire gets no
75    /// filter, because equality on the incomparable is a lie.
76    Opaque,
77}
78
79/// One column of a declared table, as the contract's row schema
80/// describes it.
81// Five flags, and each one is read straight off the schema as a
82// separate fact of it (nullability, `required`, the primary-key
83// marker, filter-ability, sort-ability); no enum can carry the five
84// at once, which is why the model stays a flag set.
85#[allow(clippy::struct_excessive_bools)]
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct ColumnModel {
88    /// The wire key, as the schema spells it.
89    pub name: String,
90    /// The normalized type, from the schema's `type`/`enum` shape.
91    pub kind: ColumnKind,
92    /// Whether the schema says the column can hold `null`: a `type`
93    /// array carrying `"null"`, or an `enum` listing it.
94    pub nullable: bool,
95    /// Whether the schema lists the column under `required`.
96    pub required: bool,
97    /// Whether the schema marks the column as the table's primary key —
98    /// by a `description` that begins with the words `Primary key of`
99    /// followed by the table's name, the one way
100    /// `cratefield_tables::json_schema` marks it.
101    pub is_primary_key: bool,
102    /// Whether the page's query accepts an equality filter on the
103    /// column: every kind but [`ColumnKind::Opaque`].
104    pub filterable: bool,
105    /// Whether the page may be sorted by the column: the schema-required
106    /// columns and the primary key.
107    pub sortable: bool,
108}
109
110/// One declared table, as the contract publishes it.
111// Five flags, each an independent fact of the contract: whether a row
112// schema travelled at all, and which of the four non-page verbs the
113// entry publishes. Every combination is legal wire shape, so an enum
114// could not say it without lying about one.
115#[allow(clippy::struct_excessive_bools)]
116#[derive(Debug, Clone)]
117pub struct TableModel {
118    /// The table's name, from the actions that name it.
119    pub name: String,
120    /// The audience every action of the table is served to. A surface
121    /// says a credential is needed and nothing more — `owner` and
122    /// `tenant-members` both publish as `Subject`.
123    pub audience: Audience,
124    /// The page's path as published (`/note`).
125    pub list_path: Option<String>,
126    /// The single-row path as published (`/note/{key}`); `None` exactly
127    /// when the contract publishes no single-row action, which is a
128    /// composite-key table.
129    pub key_path: Option<String>,
130    /// The row schema the contract carries on `create-{t}` (falling back
131    /// to `replace-{t}`'s). `None` when the contract publishes none — a
132    /// read-only table's entry carries no schema anywhere, which is the
133    /// known gap.
134    pub schema: Option<Value>,
135    /// The row's columns, read out of [`TableModel::schema`], in the
136    /// order the schema lists them — the declaration's order. Empty
137    /// whenever the contract carries no schema, which is a fact about
138    /// the contract, not an error.
139    pub columns: Vec<ColumnModel>,
140    /// The column the schema marks as the single primary key — the one
141    /// a single-row path substitutes. `None` when the contract carries
142    /// no schema (a read-only table's known gap), when the schema marks
143    /// several columns (a composite key, which cannot travel one path
144    /// segment), or when the table has no single-row routes to substitute
145    /// it into.
146    pub primary_key: Option<ColumnModel>,
147    /// Whether the contract says anything at all about the row's shape:
148    /// `true` exactly when a schema travelled. A read-only table
149    /// publishes `list` and `read` and no write, and the write is where
150    /// a row schema travels — so its shape is unknown and the generated
151    /// client says so rather than guessing.
152    pub row_shape_known: bool,
153    pub has_read: bool,
154    pub has_create: bool,
155    pub has_replace: bool,
156    pub has_delete: bool,
157}
158
159/// The Tables contract, extracted.
160#[derive(Debug, Clone)]
161pub struct Contract {
162    /// The venture the client speaks for.
163    pub venture: String,
164    /// Where the Tables module is mounted (`/v1/tables`): every action
165    /// path is relative to this.
166    pub mount: String,
167    /// The tables, in the order the contract introduces them.
168    pub tables: Vec<TableModel>,
169}
170
171/// Whether a table name is safe to interpolate into generated TypeScript:
172/// lowercase snake-case — `[a-z][a-z0-9_]*`, no double or trailing
173/// underscore. Duplicated from `cratefield_tables::schema::is_identifier`
174/// (`crates/tables/src/schema.rs:894`), the source of truth a venture's
175/// declaration is held to, so a name the Tables module serves always
176/// passes and anything else in a `/__surface` document is a forgery or a
177/// drift. Not depended on: `cratefield-tables` pulls `sea-query` for its
178/// DDL, and this crate stays serde-only so it stays wasm-clean — the
179/// integration test pins the two rules to the same verdicts instead.
180fn is_table_name(name: &str) -> bool {
181    let mut chars = name.chars();
182    let Some(first) = chars.next() else {
183        return false;
184    };
185    if !first.is_ascii_lowercase() {
186        return false;
187    }
188    if name.ends_with('_') || name.contains("__") {
189        return false;
190    }
191    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
192}
193
194/// Splits an action name into its verb and table (`list-note`).
195fn split_action(name: &str) -> Option<(Verb, &str)> {
196    let (verb, table) = name.split_once('-')?;
197    let verb = match verb {
198        "list" => Verb::List,
199        "read" => Verb::Read,
200        "create" => Verb::Create,
201        "replace" => Verb::Replace,
202        "delete" => Verb::Delete,
203        _ => return None,
204    };
205    if table.is_empty() {
206        return None;
207    }
208    Some((verb, table))
209}
210
211/// The single-row path of one action: `{table}/{placeholder}`, where the
212/// placeholder is the one segment the key is substituted into. Checked
213/// rather than assumed — a client that pastes a drifted path into its own
214/// URL would answer 404s the contract never promised.
215fn key_path_of(table: &str, path: &str, action: &str) -> Result<String, GenerateError> {
216    let prefix = format!("/{table}/");
217    let placeholder =
218        path.strip_prefix(prefix.as_str())
219            .ok_or_else(|| GenerateError::UnreadableAction {
220                action: action.to_owned(),
221                why: format!("its single-row path `{path}` must start with `{prefix}`"),
222            })?;
223    let one_segment = !placeholder.is_empty()
224        && !placeholder.contains('/')
225        && placeholder.starts_with('{')
226        && placeholder.ends_with('}');
227    if !one_segment {
228        return Err(GenerateError::UnreadableAction {
229            action: action.to_owned(),
230            why: format!(
231                "its single-row path `{path}` must end with one `{{key}}` placeholder \
232                 segment after `{table}`"
233            ),
234        });
235    }
236    Ok(path.to_owned())
237}
238
239/// The page path of one action: `/{table}`, and nothing else.
240fn list_path_of(table: &str, path: &str, action: &str) -> Result<String, GenerateError> {
241    if path != format!("/{table}") {
242        return Err(GenerateError::UnreadableAction {
243            action: action.to_owned(),
244            why: format!("its page path `{path}` must be exactly `/{table}`"),
245        });
246    }
247    Ok(path.to_owned())
248}
249
250/// How the Tables module marks a primary-key column in the schema it
251/// publishes (`crates/tables/src/json_schema.rs`): a `description` of
252/// the form ``Primary key of `{table}`.``, the table's own name spelled
253/// inside the backticks. Matched by prefix rather than as a whole
254/// sentence, so the marker survives a table name this generator never
255/// guessed.
256const PK_MARKER: &str = "Primary key of `";
257
258/// Whether the schema marks this column as the table's primary key.
259/// Shared with the emitter (`crate::emitter`), which reads the same
260/// marker — one check, so the two readers cannot drift.
261pub(crate) fn is_primary_key_marker(column_schema: &Value) -> bool {
262    column_schema
263        .get("description")
264        .and_then(Value::as_str)
265        .is_some_and(|description| description.starts_with(PK_MARKER))
266}
267
268/// Whether the schema says the column can hold `null`: a type array
269/// carrying `"null"`, or an `enum` listing it. A column with no `type`
270/// at all (a `json` column) carries no nullability either — a null and
271/// a missing key are the same absence to the validator.
272///
273/// Shared with the emitter (`crate::emitter`): the one reader of
274/// nullability, so the row types and the sort union cannot disagree with
275/// the model about a nullable enum.
276pub(crate) fn column_nullable(column_schema: &Value) -> bool {
277    let type_nullable = match column_schema.get("type") {
278        Some(Value::Array(kinds)) => kinds.iter().any(|kind| kind == "null"),
279        Some(Value::String(kind)) => kind == "null",
280        _ => false,
281    };
282    // An `enum` lists its own null too: the Tables module publishes a
283    // nullable enum as `{"type": "string", "enum": [..., null]}`, so the
284    // enum is checked even where a `type` travels alongside it.
285    type_nullable
286        || column_schema
287            .get("enum")
288            .and_then(Value::as_array)
289            .is_some_and(|members| members.iter().any(Value::is_null))
290}
291
292/// The base type one JSON Schema `type` value names.
293fn scalar_kind(kind: &str) -> ColumnKind {
294    match kind {
295        "string" => ColumnKind::Text,
296        "integer" => ColumnKind::Integer,
297        "number" => ColumnKind::Real,
298        "boolean" => ColumnKind::Boolean,
299        _ => ColumnKind::Opaque,
300    }
301}
302
303/// Removes duplicates keeping the first occurrence, in order.
304/// `Vec::dedup` only removes *adjacent* duplicates, so a non-adjacent
305/// repeat would reach a generated union twice; and the order is part of
306/// the deterministic output, so no sorting either.
307pub(crate) fn dedup_keep_first<T: Clone + PartialEq>(values: Vec<T>) -> Vec<T> {
308    let mut kept: Vec<T> = Vec::with_capacity(values.len());
309    for value in values {
310        if !kept.contains(&value) {
311            kept.push(value);
312        }
313    }
314    kept
315}
316
317/// A column's kind: its `enum` as a literal list when there is one, its
318/// `type` otherwise, and [`ColumnKind::Opaque`] when the schema names
319/// neither — a `json` column, deliberately, and anything else this
320/// generator cannot narrow.
321fn column_kind_of(column_schema: &Value) -> ColumnKind {
322    if let Some(members) = column_schema.get("enum").and_then(Value::as_array) {
323        let literals: Vec<String> = members
324            .iter()
325            .filter(|member| !member.is_null())
326            .map(|member| match member {
327                Value::String(text) => text.clone(),
328                other => other.to_string(),
329            })
330            .collect();
331        return ColumnKind::Enum(dedup_keep_first(literals));
332    }
333    match column_schema.get("type") {
334        Some(Value::String(kind)) => scalar_kind(kind),
335        Some(Value::Array(kinds)) => kinds
336            .iter()
337            .filter_map(Value::as_str)
338            .find(|kind| *kind != "null")
339            .map_or(ColumnKind::Opaque, scalar_kind),
340        // A non-string, non-array `type` (or no `type` at all) names
341        // nothing this generator narrows: `Opaque`, not a guess.
342        _ => ColumnKind::Opaque,
343    }
344}
345
346/// One column, normalized from its schema shape and the table's
347/// `required` list.
348fn column_of(name: &str, column_schema: &Value, required: bool) -> ColumnModel {
349    let kind = column_kind_of(column_schema);
350    let is_primary_key = is_primary_key_marker(column_schema);
351    let filterable = !matches!(kind, ColumnKind::Opaque);
352    ColumnModel {
353        name: name.to_owned(),
354        kind,
355        nullable: column_nullable(column_schema),
356        required,
357        is_primary_key,
358        filterable,
359        sortable: required || is_primary_key,
360    }
361}
362
363/// Reads the row shape out of the schema the write actions carry, into
364/// [`TableModel::columns`] and [`TableModel::primary_key`]. A table the
365/// contract carries no schema for keeps an empty column list and
366/// `row_shape_known: false` — the read-only table's known gap, recorded
367/// rather than errored on.
368fn normalize(table: &mut TableModel) {
369    let Some(schema) = table.schema.as_ref() else {
370        return;
371    };
372    table.row_shape_known = true;
373    let required: Vec<&str> = schema
374        .get("required")
375        .and_then(Value::as_array)
376        .map(|values| values.iter().filter_map(Value::as_str).collect())
377        .unwrap_or_default();
378    let Some(properties) = schema.get("properties").and_then(Value::as_object) else {
379        return;
380    };
381    table.columns = properties
382        .iter()
383        .map(|(name, column_schema)| {
384            column_of(name, column_schema, required.contains(&name.as_str()))
385        })
386        .collect();
387    // The single-row routes are exactly where a key is substituted, and
388    // one path segment holds one column's value: a composite key cannot
389    // travel it, so the contract publishes no `read`/`replace`/`delete`
390    // and the model carries no key either. One marked column with the
391    // routes present is the key; anything else is none.
392    let mut marked = table.columns.iter().filter(|c| c.is_primary_key);
393    let key = match (marked.next(), marked.next()) {
394        (Some(single), None) => Some(single.clone()),
395        _ => None,
396    };
397    if table.key_path.is_some() {
398        table.primary_key = key;
399    }
400}
401
402/// Extracts the Tables contract from a `/__surface` document.
403///
404/// # Errors
405///
406/// [`GenerateError::UnsupportedSurfaceApi`] when the document speaks a
407/// different contract version, [`GenerateError::NoTablesModule`] when the
408/// document declares no `tables` module,
409/// [`GenerateError::UnknownAction`] / [`GenerateError::UnreadableAction`]
410/// when a Tables action falls outside the verb vocabulary, and
411/// [`GenerateError::UnsupportedTableName`] when a table's name is not a
412/// safe identifier.
413///
414/// # Panics
415///
416/// Never on any input: the one `expect` guards a lookup that the line
417/// above it just satisfied (a table pushed when no action had named it
418/// yet), so it is unreachable for every document.
419pub fn extract(document: &SurfaceDocument) -> Result<Contract, GenerateError> {
420    if document.surface_api != cratefield_core::SURFACE_API {
421        return Err(GenerateError::UnsupportedSurfaceApi {
422            found: document.surface_api,
423            expected: cratefield_core::SURFACE_API,
424        });
425    }
426    let module = document
427        .modules
428        .iter()
429        .find(|module| module.name == TABLES_MODULE)
430        .ok_or_else(|| GenerateError::NoTablesModule {
431            modules: document
432                .modules
433                .iter()
434                .map(|module| module.name.clone())
435                .collect(),
436        })?;
437
438    let mut tables: Vec<TableModel> = Vec::new();
439    for action in &module.surface.actions {
440        let (verb, table_name) =
441            split_action(&action.name).ok_or_else(|| GenerateError::UnknownAction {
442                action: action.name.clone(),
443            })?;
444        // The name reaches the generated TypeScript as a property name and
445        // inside doc comments, and the document is untrusted input, so it
446        // is held to the declaration rule before anything else reads it.
447        if !is_table_name(table_name) {
448            return Err(GenerateError::UnsupportedTableName {
449                table: table_name.to_owned(),
450            });
451        }
452        // The audience is a fact about the table and every action of one
453        // table carries the same one, so the action that first names the
454        // table decides what the generated doc comments say.
455        if tables.iter_mut().all(|table| table.name != table_name) {
456            tables.push(TableModel {
457                name: table_name.to_owned(),
458                audience: action.audience,
459                list_path: None,
460                key_path: None,
461                schema: None,
462                columns: Vec::new(),
463                primary_key: None,
464                row_shape_known: false,
465                has_read: false,
466                has_create: false,
467                has_replace: false,
468                has_delete: false,
469            });
470        }
471        let table = tables
472            .iter_mut()
473            .find(|table| table.name == table_name)
474            .expect("the table was pushed on the line above when it was missing");
475
476        let method = action.method.as_str();
477        if method != verb.method() {
478            return Err(GenerateError::UnreadableAction {
479                action: action.name.clone(),
480                why: format!(
481                    "its verb is served by `{}`, and the contract carries `{}`",
482                    verb.method(),
483                    method
484                ),
485            });
486        }
487        match verb {
488            Verb::List => {
489                table.list_path = Some(list_path_of(table_name, &action.path, &action.name)?);
490            }
491            Verb::Read => {
492                table.key_path = Some(key_path_of(table_name, &action.path, &action.name)?);
493                table.has_read = true;
494            }
495            Verb::Create => {
496                table.has_create = true;
497                // The create body is the row schema — the contract's own
498                // words for what a row is. `replace` carries the same
499                // one; take whichever arrives, create first.
500                if let Some(schema) = &action.input {
501                    table.schema = Some(schema.as_value().clone());
502                }
503            }
504            Verb::Replace => {
505                table.has_replace = true;
506                if table.schema.is_none()
507                    && let Some(schema) = &action.input
508                {
509                    table.schema = Some(schema.as_value().clone());
510                }
511            }
512            Verb::Delete => table.has_delete = true,
513        }
514    }
515
516    for table in &mut tables {
517        normalize(table);
518    }
519
520    Ok(Contract {
521        venture: document.venture.name.clone(),
522        mount: format!("/v1/{}", module.name),
523        tables,
524    })
525}