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}