primitive-admin 1.1.0-alpha.47 → 1.1.0-alpha.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/bin/primitive.js +2 -0
  2. package/dist/bin/primitive.js.map +1 -1
  3. package/dist/src/commands/apps.js +54 -2
  4. package/dist/src/commands/apps.js.map +1 -1
  5. package/dist/src/commands/databases.js +31 -10
  6. package/dist/src/commands/databases.js.map +1 -1
  7. package/dist/src/commands/documents.js +77 -0
  8. package/dist/src/commands/documents.js.map +1 -1
  9. package/dist/src/commands/guides.d.ts +15 -12
  10. package/dist/src/commands/guides.js +22 -15
  11. package/dist/src/commands/guides.js.map +1 -1
  12. package/dist/src/commands/settings.d.ts +15 -0
  13. package/dist/src/commands/settings.js +102 -0
  14. package/dist/src/commands/settings.js.map +1 -0
  15. package/dist/src/commands/sync-app-settings.d.ts +105 -0
  16. package/dist/src/commands/sync-app-settings.js +339 -0
  17. package/dist/src/commands/sync-app-settings.js.map +1 -0
  18. package/dist/src/commands/sync.d.ts +34 -19
  19. package/dist/src/commands/sync.js +373 -200
  20. package/dist/src/commands/sync.js.map +1 -1
  21. package/dist/src/commands/workflows.js +32 -10
  22. package/dist/src/commands/workflows.js.map +1 -1
  23. package/dist/src/lib/api-client.d.ts +2 -0
  24. package/dist/src/lib/api-client.js +6 -0
  25. package/dist/src/lib/api-client.js.map +1 -1
  26. package/dist/src/lib/app-settings-descriptor.d.ts +108 -0
  27. package/dist/src/lib/app-settings-descriptor.js +250 -0
  28. package/dist/src/lib/app-settings-descriptor.js.map +1 -0
  29. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +15 -0
  30. package/dist/src/lib/codegen-shared/generatedFiles.js +27 -10
  31. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -1
  32. package/dist/src/lib/db-codegen/dbGenerator.d.ts +11 -83
  33. package/dist/src/lib/db-codegen/dbGenerator.js +93 -556
  34. package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
  35. package/dist/src/lib/db-codegen/dbNaming.d.ts +15 -7
  36. package/dist/src/lib/db-codegen/dbNaming.js +25 -13
  37. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
  38. package/dist/src/lib/db-codegen/dbTemplates.d.ts +55 -4
  39. package/dist/src/lib/db-codegen/dbTemplates.js +169 -40
  40. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
  41. package/dist/src/lib/db-codegen/dbTypeIR.d.ts +146 -0
  42. package/dist/src/lib/db-codegen/dbTypeIR.js +517 -0
  43. package/dist/src/lib/db-codegen/dbTypeIR.js.map +1 -0
  44. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.d.ts +101 -0
  45. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js +200 -0
  46. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js.map +1 -0
  47. package/dist/src/lib/swift-codegen/dbGenerator.d.ts +68 -0
  48. package/dist/src/lib/swift-codegen/dbGenerator.js +380 -0
  49. package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -0
  50. package/dist/src/lib/swift-codegen/dbSwiftTypes.d.ts +42 -0
  51. package/dist/src/lib/swift-codegen/dbSwiftTypes.js +100 -0
  52. package/dist/src/lib/swift-codegen/dbSwiftTypes.js.map +1 -0
  53. package/dist/src/lib/swift-codegen/generator.d.ts +84 -0
  54. package/dist/src/lib/swift-codegen/generator.js +178 -0
  55. package/dist/src/lib/swift-codegen/generator.js.map +1 -0
  56. package/dist/src/lib/swift-codegen/schemaToSwift.d.ts +72 -0
  57. package/dist/src/lib/swift-codegen/schemaToSwift.js +644 -0
  58. package/dist/src/lib/swift-codegen/schemaToSwift.js.map +1 -0
  59. package/dist/src/lib/swift-codegen/swiftNaming.d.ts +85 -0
  60. package/dist/src/lib/swift-codegen/swiftNaming.js +198 -0
  61. package/dist/src/lib/swift-codegen/swiftNaming.js.map +1 -0
  62. package/dist/src/lib/sync-resource-types.d.ts +225 -0
  63. package/dist/src/lib/sync-resource-types.js +394 -0
  64. package/dist/src/lib/sync-resource-types.js.map +1 -0
  65. package/dist/src/lib/template.d.ts +1 -1
  66. package/dist/src/lib/template.js +6 -3
  67. package/dist/src/lib/template.js.map +1 -1
  68. package/dist/src/lib/workflow-codegen/generator.d.ts +18 -5
  69. package/dist/src/lib/workflow-codegen/generator.js +116 -41
  70. package/dist/src/lib/workflow-codegen/generator.js.map +1 -1
  71. package/dist/src/lib/workflow-codegen/invokerIR.d.ts +85 -0
  72. package/dist/src/lib/workflow-codegen/invokerIR.js +71 -0
  73. package/dist/src/lib/workflow-codegen/invokerIR.js.map +1 -0
  74. package/package.json +4 -2
@@ -6,605 +6,142 @@
6
6
  * then either (a) writes them, or (b) compares them against on-disk for
7
7
  * `--check`. No commander dependency, no `process.exit`.
8
8
  *
9
- * Reuses the codegen-v2 machinery:
10
- * - `loadSchemaFromTomlString` (the shared `[models.*]` parser) for record
11
- * interfaces — the database-type `[models.*]` blocks round-trip through
12
- * the same loader and the same `FieldType` vocabulary.
13
- * - The banner + fingerprint header pattern and stale-output cleanup.
14
- *
15
- * Adds the OPERATIONS half (op-params interfaces + per-op result aliases)
16
- * that codegen-v2 has no analog for. A result alias is derived from `op.type`
17
- * and, where the shape is derivable, the op's parsed `definition`: a query op's
18
- * `projection` (#1439), a mutation op's step kinds (#1438), and a pipeline op's
19
- * `return` step (#1437). This file resolves those into the small descriptors
20
- * the templates consume; the templates stay pure string-emitters.
9
+ * The op/record RESOLUTION (parse, schema load, required-ness inference, op
10
+ * params, and per-op result descriptors) lives in the shared, naming-neutral
11
+ * `describeDbType` IR (`dbTypeIR.ts`) — the SAME source the Swift renderer
12
+ * (`swift-codegen/dbGenerator.ts`) consumes (issue #1547 binding 5). This file
13
+ * is now purely the TS *rendering* half: it applies TS identifier naming
14
+ * (`resolveRecordInterfaceName`, `opParamsInterfaceName`, …) to that IR and
15
+ * emits `*.generated.ts` via the pure `dbTemplates` string-emitters. The
16
+ * emitted bytes are unchanged from the former inline implementation.
21
17
  */
22
18
  import * as path from "path";
23
- import { parseConfigToml, stringifyConfigToml } from "../config-toml.js";
24
- import { loadSchemaFromTomlString } from "js-bao";
25
- import { parseDatabaseTypeToml } from "../../commands/sync.js";
26
19
  import { writeOrCheckGeneratedFiles, } from "../codegen-shared/generatedFiles.js";
27
- import { fingerprintToml } from "./dbFingerprint.js";
28
- import { tsTypeForDbFieldType } from "./dbTsTypes.js";
29
- import { resolveRecordInterfaceName, opParamsInterfaceName, opResultAliasName, opsFactoryName, opPropertyKey, } from "./dbNaming.js";
30
- import { renderHeader, renderRecordInterface, renderOpParamsInterface, renderOpResultAlias, renderOpsFactory, RESULT_ALIASES_BLOCK, } from "./dbTemplates.js";
20
+ import { describeDbType, } from "./dbTypeIR.js";
21
+ import { resolveRecordInterfaceName, opParamsInterfaceName, opResultAliasName, opsFactoryName, subscriptionsFactoryName, objectPropertyKey, } from "./dbNaming.js";
22
+ import { renderHeader, renderRecordInterface, renderOpParamsInterface, renderOpResultAlias, renderFactories, RESULT_ALIASES_BLOCK, } from "./dbTemplates.js";
23
+ export { inferArrayParamElementTypes, inferRequiredRecordFields, } from "./dbTypeIR.js";
31
24
  const GENERATED_FILE_SUFFIX = ".generated.ts";
32
25
  /**
33
- * Op-param types that declare a scalar value (#1488). A param carrying one of
34
- * these explicit declarations is validated by the runtime as that scalar, so
35
- * array-element inference must not rewrite it to an array type in the generated
36
- * interface. Keep in step with the runtime's scalar coercion list
37
- * (`MODEL_PARAM_SCALAR_TYPES` in `database-operations-controller.ts`).
26
+ * Map the naming-neutral pipeline-return IR to the interface-named descriptor
27
+ * the TS `renderOpResultAlias` template consumes.
38
28
  */
39
- const SCALAR_PARAM_TYPES = new Set(["string", "number", "boolean", "integer"]);
29
+ function mapPipelineReturn(ir, recordInterfaceByModel) {
30
+ switch (ir.kind) {
31
+ case "query":
32
+ return {
33
+ kind: "query",
34
+ recordInterfaceName: ir.modelName
35
+ ? recordInterfaceByModel[ir.modelName] ?? null
36
+ : null,
37
+ projection: ir.projection,
38
+ };
39
+ case "count":
40
+ return { kind: "count" };
41
+ case "aggregate":
42
+ return { kind: "aggregate" };
43
+ case "opaque":
44
+ default:
45
+ return { kind: "opaque" };
46
+ }
47
+ }
40
48
  /**
41
49
  * Render the in-memory `.generated.ts` content for a single database-type
42
50
  * TOML. Exported so tests can assert on the rendered string directly.
43
51
  */
44
52
  export function renderDbTypeFile(input) {
45
- const { tomlContent } = input;
46
- const fingerprint = fingerprintToml(tomlContent);
47
- // Parse the raw TOML once for the [type] / [[operations]] data, and route
48
- // the [models.*] subtree through the shared js-bao loader (same path the
49
- // server uses to validate the schema).
50
- const tomlData = parseConfigToml(tomlContent);
51
- const { typeConfig, operations } = parseDatabaseTypeToml(tomlData);
52
- // 1. Record interfaces (read shape) from [models.*].
53
- const schemas = tomlData.models
54
- ? loadSchemaFromTomlString(stringifyConfigToml({ models: tomlData.models }), {
55
- strict: false,
56
- })
57
- : [];
58
- const stampedFields = collectStampedFields(typeConfig);
59
- // model name → set of record fields the operation params prove are always
60
- // supplied at create time (every `save` op that sets the field binds it to a
61
- // `required: true` param). OR'd with the schema-level `required` flag below;
62
- // inference only ever ADDS requiredness, never removes it. (#842)
63
- const inferredRequired = inferRequiredRecordFields(operations);
64
- // model name → resolved record interface name (used by query result aliases).
53
+ const ir = describeDbType(input);
54
+ // model name → resolved record interface name (used by query result aliases),
55
+ // with the same collision guard the inline implementation had.
65
56
  const recordInterfaceByModel = {};
66
- // model name → the field names that appear on its record interface (declared
67
- // fields + server-stamped fields). Used to guard projection `Pick`
68
- // against keys that are not members of the interface (#1439).
69
- const recordFieldsByModel = new Map();
70
- // model name → declared field name → field type. Feeds the `$in`/`$nin`
71
- // array-param element-type inference (#1488) below.
72
- const fieldTypesByModel = new Map();
73
- for (const schema of schemas) {
74
- const ftypes = new Map();
75
- for (const [fname, fopts] of Object.entries(schema.fields)) {
76
- const t = fopts?.type;
77
- if (typeof t === "string")
78
- ftypes.set(fname, t);
79
- }
80
- fieldTypesByModel.set(schema.name, ftypes);
81
- }
82
57
  const seenInterfaceNames = new Map(); // interfaceName → modelName
83
- for (const schema of schemas) {
84
- const ifaceName = resolveRecordInterfaceName(schema.name, schema.options?.className);
58
+ for (const rec of ir.records) {
59
+ const ifaceName = resolveRecordInterfaceName(rec.modelName, rec.className);
85
60
  const prior = seenInterfaceNames.get(ifaceName);
86
- if (prior !== undefined && prior !== schema.name) {
61
+ if (prior !== undefined && prior !== rec.modelName) {
87
62
  throw new Error(`Record-interface name collision: models "${prior}" and ` +
88
- `"${schema.name}" both produce interface "${ifaceName}". Add a ` +
63
+ `"${rec.modelName}" both produce interface "${ifaceName}". Add a ` +
89
64
  `class_name override to one of them.`);
90
65
  }
91
- seenInterfaceNames.set(ifaceName, schema.name);
92
- recordInterfaceByModel[schema.name] = ifaceName;
93
- const memberNames = new Set(Object.keys(schema.fields));
94
- for (const stamped of stampedFields)
95
- memberNames.add(stamped);
96
- recordFieldsByModel.set(schema.name, memberNames);
66
+ seenInterfaceNames.set(ifaceName, rec.modelName);
67
+ recordInterfaceByModel[rec.modelName] = ifaceName;
97
68
  }
98
69
  const blocks = [];
99
- blocks.push(renderHeader(fingerprint));
70
+ blocks.push(renderHeader(ir.fingerprint));
100
71
  blocks.push(RESULT_ALIASES_BLOCK.trimEnd());
101
72
  // Record interfaces, sorted by interface name for deterministic output.
102
- const sortedSchemas = [...schemas].sort((a, b) => recordInterfaceByModel[a.name].localeCompare(recordInterfaceByModel[b.name]));
103
- for (const schema of sortedSchemas) {
104
- const inferredForModel = inferredRequired.get(schema.name)?.fields;
105
- const fields = Object.entries(schema.fields).map(([name, opts]) => ({
106
- name,
107
- type: opts.type,
108
- required: opts.required === true ||
109
- (opts.autoAssign === true && name === "id") ||
110
- inferredForModel?.has(name) === true,
111
- // Allowed-value set for string fields → string-literal union (#843).
112
- // Threaded through the shared loader's FieldOptions.enum; validated
113
- // (non-empty, all-string, string-only) by parseFieldOptions upstream.
114
- enum: opts.enum,
115
- }));
73
+ const sortedRecords = [...ir.records].sort((a, b) => recordInterfaceByModel[a.modelName].localeCompare(recordInterfaceByModel[b.modelName]));
74
+ for (const rec of sortedRecords) {
116
75
  blocks.push(renderRecordInterface({
117
- interfaceName: recordInterfaceByModel[schema.name],
118
- modelName: schema.name,
119
- fields,
120
- stampedFields,
76
+ interfaceName: recordInterfaceByModel[rec.modelName],
77
+ modelName: rec.modelName,
78
+ fields: rec.fields,
79
+ stampedFields: ir.stampedFields,
121
80
  }));
122
81
  }
123
- // Operation interfaces + result aliases, sorted by op name.
124
- const sortedOps = [...operations]
125
- .filter((op) => op && typeof op.name === "string")
126
- .sort((a, b) => a.name.localeCompare(b.name));
127
- // Descriptors for the typed-ops factory (#1441), collected in the same
128
- // name-sorted order so the factory's methods are deterministic.
82
+ // Operation interfaces + result aliases, already sorted by op name in the IR.
83
+ // Descriptors for the typed-ops factory (#1441), collected in the same order.
129
84
  const factoryOps = [];
130
- for (const op of sortedOps) {
131
- const inferredArrays = inferArrayParamElementTypes(op, fieldTypesByModel);
132
- const params = paramsToFields(op.params, inferredArrays);
85
+ for (const op of ir.ops) {
133
86
  blocks.push(renderOpParamsInterface({
134
- interfaceName: opParamsInterfaceName(op.name),
135
- opName: op.name,
136
- params,
87
+ interfaceName: opParamsInterfaceName(op.opName),
88
+ opName: op.opName,
89
+ params: op.params,
137
90
  }));
138
91
  factoryOps.push({
139
- propertyKey: opPropertyKey(op.name),
140
- opName: op.name,
141
- paramsInterfaceName: opParamsInterfaceName(op.name),
142
- resultAliasName: opResultAliasName(op.name),
143
- hasParams: params.length > 0,
92
+ propertyKey: objectPropertyKey(op.opName),
93
+ opName: op.opName,
94
+ paramsInterfaceName: opParamsInterfaceName(op.opName),
95
+ resultAliasName: opResultAliasName(op.opName),
96
+ hasParams: op.hasParams,
144
97
  });
145
- const recordInterfaceName = op.type === "query" && op.modelName
146
- ? recordInterfaceByModel[op.modelName] ?? null
98
+ const recordInterfaceName = op.resultModelName
99
+ ? recordInterfaceByModel[op.resultModelName] ?? null
147
100
  : null;
148
- // Query ops: narrow the record type by the op's static projection (#1439).
149
- const projection = op.type === "query"
150
- ? classifyProjection(op.definition?.projection, (op.modelName && recordFieldsByModel.get(op.modelName)) ||
151
- new Set())
152
- : undefined;
153
- // Pipeline ops: resolve the `return`-step result shape (#1437).
154
- const pipelineReturn = op.type === "pipeline"
155
- ? resolvePipelineReturn(op.definition, recordInterfaceByModel, recordFieldsByModel)
101
+ const pipelineReturn = op.pipelineReturn
102
+ ? mapPipelineReturn(op.pipelineReturn, recordInterfaceByModel)
156
103
  : undefined;
157
104
  blocks.push(renderOpResultAlias({
158
- aliasName: opResultAliasName(op.name),
159
- opName: op.name,
160
- opType: op.type,
105
+ aliasName: opResultAliasName(op.opName),
106
+ opName: op.opName,
107
+ opType: op.opType,
161
108
  recordInterfaceName,
162
- projection,
109
+ projection: op.projection,
163
110
  pipelineReturn,
164
111
  }));
165
112
  }
166
- // Typed-ops factory (#1441): one method per op pairing `<Op>Params` in with
167
- // `<Op>Result` out. Omitted entirely for a type with no operations.
168
- if (factoryOps.length > 0) {
169
- blocks.push(renderOpsFactory({
170
- factoryName: opsFactoryName(input.databaseType),
113
+ // Typed-subscriptions descriptors (#1544 Phase 2): map each subscription's
114
+ // model to its record interface (or a `Pick` when it declares `select`). The
115
+ // IR already validated that every model resolves and every select field is a
116
+ // known member, so this mapping never fails.
117
+ const factorySubscriptions = ir.subscriptions.map((sub) => {
118
+ const interfaceName = recordInterfaceByModel[sub.modelName];
119
+ const eventType = sub.select && sub.select.length > 0
120
+ ? `Pick<${interfaceName}, ${sub.select
121
+ .map((f) => JSON.stringify(f))
122
+ .join(" | ")}>`
123
+ : interfaceName;
124
+ return {
125
+ propertyKey: objectPropertyKey(sub.subscriptionKey),
126
+ subscriptionKey: sub.subscriptionKey,
127
+ eventType,
128
+ };
129
+ });
130
+ // Typed-ops factory (#1441) + typed-subscriptions factory (#1544 Phase 2),
131
+ // sharing one structural client interface. Omitted entirely for a type with
132
+ // neither ops nor subscriptions.
133
+ if (factoryOps.length > 0 || factorySubscriptions.length > 0) {
134
+ blocks.push(renderFactories({
171
135
  databaseType: input.databaseType,
136
+ opsFactoryName: opsFactoryName(input.databaseType),
137
+ subscriptionsFactoryName: subscriptionsFactoryName(input.databaseType),
172
138
  ops: factoryOps,
139
+ subscriptions: factorySubscriptions,
173
140
  }));
174
141
  }
175
142
  // Single trailing newline; blocks separated by a blank line.
176
143
  return blocks.join("\n\n") + "\n";
177
144
  }
178
- /**
179
- * Classify a query op's static `projection` into how it narrows the record type
180
- * (#1439). Kept private to the generator: the returned `{ kind, fields }` shape
181
- * is what the template needs, and the field-guard / `id`-folding / sorting live
182
- * here so the template stays a pure string-emitter.
183
- *
184
- * `recordFieldNames` is the set of members on the record interface (declared +
185
- * stamped). A projected key that is not a member (a nested `"a.b"` path, or a
186
- * field absent from the schema) makes the whole projection fall back to
187
- * `Partial<M>` rather than emit an invalid `Pick`. Reused for a pipeline
188
- * op's returning `query` step (#1437).
189
- *
190
- * Rules (projection values are validated server-side to be `1` or `0`, mixing
191
- * rejected — codegen runs pre-push, so it still guards):
192
- * - absent / not an object / empty `{}` → `full` (whole record `M`).
193
- * - all `1` (inclusion), keys all known → `pick` (with `id` folded in,
194
- * since inclusion always retains
195
- * `id` at runtime).
196
- * - all `0` (exclusion), keys all known → `partial`. The DO runtime does
197
- * not strip excluded fields yet, so
198
- * an `Omit` would hide fields that
199
- * are still returned (#1439/Codex-P2).
200
- * - mixed / dynamic / non-`1|0` / unknown key → `partial` (honest fallback).
201
- */
202
- function classifyProjection(projection, recordFieldNames) {
203
- if (!projection ||
204
- typeof projection !== "object" ||
205
- Array.isArray(projection)) {
206
- return { kind: "full" };
207
- }
208
- const entries = Object.entries(projection);
209
- if (entries.length === 0)
210
- return { kind: "full" };
211
- let allInclude = true;
212
- let allExclude = true;
213
- const included = [];
214
- const excluded = [];
215
- for (const [field, val] of entries) {
216
- if (val === 1) {
217
- included.push(field);
218
- allExclude = false;
219
- }
220
- else if (val === 0) {
221
- excluded.push(field);
222
- allInclude = false;
223
- }
224
- else {
225
- // A dynamic (`$params.x`) or otherwise non-`1|0` value — can't resolve
226
- // statically. Fall back rather than emit garbage.
227
- allInclude = false;
228
- allExclude = false;
229
- }
230
- }
231
- if (allInclude && included.length > 0) {
232
- if (!included.every((f) => recordFieldNames.has(f)))
233
- return { kind: "partial" };
234
- // Inclusion always retains `id` at runtime (the DO unshifts it), so fold it
235
- // into the union when the model has an `id` field.
236
- const fields = new Set(included);
237
- if (recordFieldNames.has("id"))
238
- fields.add("id");
239
- return { kind: "pick", fields: [...fields].sort() };
240
- }
241
- if (allExclude && excluded.length > 0) {
242
- // Exclusion projection (e.g. `{ secret: 0 }`). The DO query runtime does
243
- // NOT actually strip excluded fields today: `JsonQueryTranslator` selects
244
- // `_data` for excludes and `_handleQuery` merges the full row via
245
- // `_parseRow`, so the excluded field is still present in the response.
246
- // Emitting `Omit<M, ...>` would hide fields that ARE returned and break
247
- // existing TS callers after regeneration, so type these as `Partial<M>`
248
- // until the runtime filters them (#1439 / Codex-P2).
249
- return { kind: "partial" };
250
- }
251
- return { kind: "partial" };
252
- }
253
- /**
254
- * Resolve a pipeline op's `return`-step result shape (#1437). The runtime
255
- * flattens a returning pipeline to the returned step's shape, so codegen types
256
- * it as that step's result. `return: "all"`, no `return`, or any step the
257
- * generator can't resolve fall back to `opaque` → the generic `PipelineResult`.
258
- * Never throws — one odd op must not break codegen for the whole file.
259
- */
260
- function resolvePipelineReturn(definition, recordInterfaceByModel, recordFieldsByModel) {
261
- if (!definition || typeof definition !== "object")
262
- return { kind: "opaque" };
263
- const def = definition;
264
- const ret = def.return ?? "all";
265
- if (ret === "all" || typeof ret !== "string")
266
- return { kind: "opaque" };
267
- if (!Array.isArray(def.steps))
268
- return { kind: "opaque" };
269
- const step = def.steps.find((s) => s && typeof s === "object" && s.name === ret);
270
- if (!step)
271
- return { kind: "opaque" };
272
- if (step.type === "query") {
273
- const modelName = typeof step.modelName === "string" ? step.modelName : undefined;
274
- const recordInterfaceName = modelName
275
- ? recordInterfaceByModel[modelName] ?? null
276
- : null;
277
- const fieldNames = (modelName && recordFieldsByModel.get(modelName)) || new Set();
278
- const projection = classifyProjection(step.projection, fieldNames);
279
- return { kind: "query", recordInterfaceName, projection };
280
- }
281
- if (step.type === "count")
282
- return { kind: "count" };
283
- if (step.type === "aggregate")
284
- return { kind: "aggregate" };
285
- return { kind: "opaque" };
286
- }
287
- /**
288
- * Collect the server-stamped field names from the parsed [type] config:
289
- * timestamps (`create`/`update` field names) + auto-populated field names
290
- * (the keys of the `autoPopulatedFields` map). These appear on the READ
291
- * record interface as OPTIONAL props.
292
- */
293
- function collectStampedFields(typeConfig) {
294
- const names = [];
295
- const ts = typeConfig?.timestamps;
296
- if (ts && typeof ts === "object") {
297
- if (typeof ts.create === "string")
298
- names.push(ts.create);
299
- if (typeof ts.update === "string")
300
- names.push(ts.update);
301
- }
302
- const apf = typeConfig?.autoPopulatedFields;
303
- if (apf && typeof apf === "object") {
304
- for (const key of Object.keys(apf))
305
- names.push(key);
306
- }
307
- // De-dup, preserve first-seen order.
308
- return [...new Set(names)];
309
- }
310
- /**
311
- * Convert the normalized op `params` map
312
- * (`{ name: { type, required, enum?, items? } }`) into a sorted list of
313
- * `ParamField`. Sorted by name for deterministic output. A string param's
314
- * `enum` (#861) is carried through so the emitter can render a string-literal
315
- * union.
316
- *
317
- * `inferredArrayElements` (#1488, Phase 1) maps a param bound to a `$in`/`$nin`
318
- * filter operator to its inferred scalar element TS type (or the `"unknown"`
319
- * sentinel → `unknown[]`). Inference only ever NARROWS a param the emitter
320
- * would otherwise widen: an explicit `type = "array"` declaration (Phase 2)
321
- * always wins, and a param absent from the map keeps its declared-type mapping.
322
- */
323
- function paramsToFields(params, inferredArrayElements) {
324
- if (!params || typeof params !== "object")
325
- return [];
326
- return Object.entries(params)
327
- .map(([name, def]) => {
328
- const rawType = def?.type;
329
- const declaredType = rawType ?? "object";
330
- const declaredItems = def?.items;
331
- let type = declaredType;
332
- let items = declaredItems;
333
- // Apply inference only to untyped/`object` params — the declaration is
334
- // otherwise authoritative (behavior 8). A param explicitly declared as a
335
- // scalar (`string`/`number`/`boolean`/`integer`) is validated by runtime
336
- // `execute` against that scalar type; a param declared with a model type
337
- // is treated as model-typed by the runtime `coerceParam`, which rejects
338
- // arrays with "must be an object"; and an explicit `array` already has its
339
- // element type. Rewriting any of those to an inferred array would emit a
340
- // type the server is guaranteed to reject. Only untyped/`object` params
341
- // (which the emitter would otherwise widen to `Record<string, unknown>`)
342
- // are eligible. Since `declaredType = rawType ?? "object"`, `=== "object"`
343
- // matches both a missing `type` and an explicit `object` declaration.
344
- const inferred = inferredArrayElements?.get(name);
345
- if (declaredType === "object" && inferred !== undefined) {
346
- type = "array";
347
- // The `"unknown"` sentinel means "array, element unresolved": leave
348
- // `items` undefined so the emitter renders `unknown[]`.
349
- items = inferred === "unknown" ? undefined : inferred;
350
- }
351
- return {
352
- name,
353
- type,
354
- required: def?.required === true,
355
- // Allowed-value set for a string param → string-literal union (#861).
356
- // `normalizeOperationFromToml` preserves `enum` upstream via its
357
- // `...rest` spread; carry it through here so it reaches the emitter.
358
- enum: def?.enum,
359
- items,
360
- };
361
- })
362
- .sort((a, b) => a.name.localeCompare(b.name));
363
- }
364
- /**
365
- * Infer the scalar element TS type for op params consumed by a `$in`/`$nin`
366
- * filter operator (#1488, Phase 1). Codegen-only: a param compared with
367
- * `$in`/`$nin` against a model field is definitionally an array, so the
368
- * typed-ops factory (#1441) should emit `<elem>[]` rather than widening an
369
- * untyped/object param to `Record<string, unknown>` (which `string[]` is not
370
- * assignable to).
371
- *
372
- * Kept a small local helper next to {@link inferRequiredRecordFields} rather
373
- * than a shared cross-runtime analysis pass (sponsor decision on #1488).
374
- *
375
- * Returns a map of param name → element TS type. The `"unknown"` sentinel means
376
- * "emit `unknown[]`" — used when the compared field is missing from the model
377
- * schema (or the op has no resolvable model), when the same param is bound to
378
- * fields of differing element types, or when the param is also used as a scalar
379
- * elsewhere (conservative widen, mirroring #845's disagreement rule / #1488
380
- * Fork 4). A param absent from the map is not consumed by any array operator
381
- * and keeps its declared-type mapping (inference never widens such a param).
382
- */
383
- export function inferArrayParamElementTypes(op, fieldTypesByModel) {
384
- const result = new Map();
385
- if (!op || typeof op !== "object")
386
- return result;
387
- const def = op.definition;
388
- if (!def || typeof def !== "object")
389
- return result;
390
- // Element TS types seen for each param via `$in`/`$nin` (deduped). A param
391
- // also seen as a scalar reference is widened to `unknown[]`.
392
- const arrayElems = new Map();
393
- const scalarRefs = new Set();
394
- const addArrayElem = (param, elem) => {
395
- let s = arrayElems.get(param);
396
- if (!s) {
397
- s = new Set();
398
- arrayElems.set(param, s);
399
- }
400
- s.add(elem);
401
- };
402
- // A bare `$params.<name>` string → the param name, else null.
403
- const paramRef = (v) => {
404
- if (typeof v !== "string")
405
- return null;
406
- const m = v.match(/^\$params\.([A-Za-z0-9_]+)$/);
407
- return m ? m[1] : null;
408
- };
409
- // Scalar element TS type for `field` of the current model, or `"unknown"`.
410
- // A `stringset` field's ELEMENT is a string (#1488, e7) — do NOT reuse
411
- // `tsTypeForDbFieldType`, which maps `stringset → string[]` and would emit
412
- // `string[][]` for the param.
413
- const elemTypeForField = (field, fields) => {
414
- const ftype = fields?.get(field);
415
- if (ftype === undefined)
416
- return "unknown"; // missing field / no schema (e2)
417
- if (ftype === "stringset")
418
- return "string";
419
- const ts = tsTypeForDbFieldType(ftype);
420
- return ts === "string" || ts === "number" || ts === "boolean"
421
- ? ts
422
- : "unknown";
423
- };
424
- const modelFields = (name) => typeof name === "string" ? fieldTypesByModel.get(name) : undefined;
425
- // Walk a filter object, resolving field conditions against `fields`.
426
- const walkFilter = (filter, fields) => {
427
- if (!filter || typeof filter !== "object" || Array.isArray(filter))
428
- return;
429
- for (const [key, value] of Object.entries(filter)) {
430
- if (key === "$and" || key === "$or") {
431
- if (Array.isArray(value)) {
432
- for (const sub of value)
433
- walkFilter(sub, fields);
434
- }
435
- continue;
436
- }
437
- // `$not` is a logical operator too, but (unlike `$and`/`$or`) it wraps a
438
- // SINGLE nested condition object rather than an array — recurse into it so
439
- // an `$in`/`$nin` inside a negated filter still infers its element type.
440
- // Mirrors the operation grammar walker in
441
- // `src/app-api/services/walk-operation.ts` (`walkFilterFields`), which
442
- // treats `$and`/`$or`/`$not` as the recursing logical operators; the two
443
- // walk the same op-definition filter grammar and must agree.
444
- if (key === "$not") {
445
- walkFilter(value, fields);
446
- continue;
447
- }
448
- // Field condition: `key` is a field name; `value` is a scalar (shorthand
449
- // equality) or an operator map (`{ $in: ..., $eq: ... }`).
450
- if (value && typeof value === "object" && !Array.isArray(value)) {
451
- for (const [operator, opVal] of Object.entries(value)) {
452
- const p = paramRef(opVal);
453
- if (!p)
454
- continue;
455
- if (operator === "$in" || operator === "$nin") {
456
- addArrayElem(p, elemTypeForField(key, fields));
457
- }
458
- else {
459
- // A scalar operator ($eq/$ne/$gt/…) binds a scalar param.
460
- scalarRefs.add(p);
461
- }
462
- }
463
- }
464
- else {
465
- const p = paramRef(value);
466
- if (p)
467
- scalarRefs.add(p);
468
- }
469
- }
470
- };
471
- // Top-level query filter.
472
- walkFilter(def.filter, modelFields(op.modelName));
473
- // `applyToQuery` ops hold their matching query under `definition.source`
474
- // (`source.filter`), which resolves against the op's own model unless the
475
- // source names a different one. Without this walk, an `$in`/`$nin` against an
476
- // object/untyped param inside `source.filter` stays widened to
477
- // `Record<string, unknown>` — the original array-param bug (#1488).
478
- if (def.source && typeof def.source === "object") {
479
- walkFilter(def.source.filter, modelFields(def.source.modelName ?? op.modelName));
480
- }
481
- // Mutation inner operations — each may carry its own filter + modelName.
482
- if (Array.isArray(def.operations)) {
483
- for (const inner of def.operations) {
484
- if (inner && typeof inner === "object") {
485
- walkFilter(inner.filter, modelFields(inner.modelName ?? op.modelName));
486
- }
487
- }
488
- }
489
- // Pipeline steps — each may carry its own filter + modelName.
490
- if (Array.isArray(def.steps)) {
491
- for (const step of def.steps) {
492
- if (step && typeof step === "object") {
493
- walkFilter(step.filter, modelFields(step.modelName ?? op.modelName));
494
- }
495
- }
496
- }
497
- for (const [param, elems] of arrayElems) {
498
- if (scalarRefs.has(param) || elems.size !== 1) {
499
- // Used as both array and scalar, or bound to differing element types.
500
- result.set(param, "unknown");
501
- }
502
- else {
503
- result.set(param, [...elems][0]);
504
- }
505
- }
506
- return result;
507
- }
508
- /**
509
- * Infer which record fields are always supplied at create time, by reading the
510
- * `save` operation params (issue #842).
511
- *
512
- * A field is promoted to required for a model only if:
513
- * - at least one `save` (create) op writes it from a bare `$params.X`
514
- * binding, AND
515
- * - EVERY `save` op that writes the field binds it to a param declared
516
- * `required: true` (the "ALL writers agree" rule).
517
- *
518
- * Deliberately conservative:
519
- * - `patch` (and every non-`save` inner op) is excluded — partial updates
520
- * don't guarantee creation-time presence.
521
- * - whole-object saves (`data = "$params.X"`, a string rather than a
522
- * per-field map) carry no field-level mapping, so no field can be promoted
523
- * from them (Q4 skip).
524
- * - only an EXACT top-level bare `$params.X` value counts (the whole binding
525
- * value must match `^\$params\.X$`, and `X` must be a declared top-level
526
- * param). A literal, computed, partially-interpolated value, or a nested
527
- * path (`$params.obj.sub`) never promotes a field — a nested sub-property
528
- * may be absent even when the top-level object param is required. This is
529
- * deliberately stricter than the CLI's lenient `collectParamRefs`
530
- * first-segment rule, which is fine for its validation use but would
531
- * wrongly promote nested bindings here.
532
- * - inner ops are matched to the record model by the inner op's own
533
- * `modelName` when present, falling back to the enclosing operation's
534
- * `modelName`.
535
- *
536
- * Pure: no I/O, no mutation of the input. The caller OR's the result with the
537
- * schema-level `required` flag, so inference only ever ADDS requiredness.
538
- */
539
- export function inferRequiredRecordFields(operations) {
540
- // model → field → running verdict. `true` = every save op seen so far that
541
- // sets the field bound it to a required param; `false` = at least one save op
542
- // set it from an optional/absent param (sticky — never recovers).
543
- const verdicts = new Map();
544
- for (const op of operations ?? []) {
545
- if (!op || op.type !== "mutation")
546
- continue;
547
- const innerOps = op.definition?.operations;
548
- if (!Array.isArray(innerOps))
549
- continue;
550
- const params = op.params && typeof op.params === "object" ? op.params : {};
551
- for (const inner of innerOps) {
552
- if (!inner || inner.op !== "save")
553
- continue;
554
- // Whole-object save (`data` is a string $params ref) has no per-field
555
- // map — skip (Q4).
556
- const data = inner.data;
557
- if (!data || typeof data !== "object" || Array.isArray(data))
558
- continue;
559
- const modelName = inner.modelName ?? op.modelName;
560
- if (typeof modelName !== "string" || modelName === "")
561
- continue;
562
- let fieldVerdicts = verdicts.get(modelName);
563
- if (!fieldVerdicts) {
564
- fieldVerdicts = new Map();
565
- verdicts.set(modelName, fieldVerdicts);
566
- }
567
- for (const [field, value] of Object.entries(data)) {
568
- // Only an EXACT top-level bare binding (`$params.X`, no further `.sub`
569
- // path) counts. `collectParamRefs` is intentionally lenient — it
570
- // returns the FIRST dotted segment, so `$params.profile.name` yields
571
- // `["profile"]` and would wrongly promote a field that actually reads
572
- // a nested sub-property that may be absent. We match the whole value
573
- // against `^\$params\.X$` here instead, leaving `collectParamRefs`
574
- // untouched for its validation use. Partial interpolation
575
- // (`prefix-$params.X`), literals, and nested paths all fail this match
576
- // and therefore never promote a field.
577
- const match = typeof value === "string"
578
- ? value.match(/^\$params\.([A-Za-z0-9_]+)$/)
579
- : null;
580
- if (!match) {
581
- // Not an exact top-level bare param binding — this save op cannot
582
- // prove the field required. Mark it not-required for this writer.
583
- fieldVerdicts.set(field, false);
584
- continue;
585
- }
586
- const paramName = match[1];
587
- // The matched name must resolve to a declared top-level param.
588
- const required = params[paramName]?.required === true;
589
- const prior = fieldVerdicts.get(field);
590
- // ALL-writers semantics: stays `true` only while every save writer
591
- // agrees; one disagreeing writer flips it to `false` permanently.
592
- fieldVerdicts.set(field, prior === false ? false : required);
593
- }
594
- }
595
- }
596
- const result = new Map();
597
- for (const [modelName, fieldVerdicts] of verdicts) {
598
- const fields = new Set();
599
- for (const [field, verdict] of fieldVerdicts) {
600
- if (verdict)
601
- fields.add(field);
602
- }
603
- if (fields.size > 0)
604
- result.set(modelName, { fields });
605
- }
606
- return result;
607
- }
608
145
  /**
609
146
  * Run codegen end-to-end across one or more database-type TOML inputs. The
610
147
  * disk lifecycle (write / `--check` / stale cleanup) is delegated to the