primitive-admin 1.0.44 → 1.0.46

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 (81) hide show
  1. package/README.md +43 -0
  2. package/assets/skill/skills/primitive-platform/SKILL.md +85 -26
  3. package/dist/bin/primitive.js +6 -0
  4. package/dist/bin/primitive.js.map +1 -1
  5. package/dist/src/commands/analytics.js +16 -16
  6. package/dist/src/commands/analytics.js.map +1 -1
  7. package/dist/src/commands/apps.js +14 -14
  8. package/dist/src/commands/apps.js.map +1 -1
  9. package/dist/src/commands/auth.js +70 -20
  10. package/dist/src/commands/auth.js.map +1 -1
  11. package/dist/src/commands/blob-buckets.js +11 -11
  12. package/dist/src/commands/blob-buckets.js.map +1 -1
  13. package/dist/src/commands/catalog.js +17 -17
  14. package/dist/src/commands/catalog.js.map +1 -1
  15. package/dist/src/commands/collection-type-configs.js +5 -5
  16. package/dist/src/commands/collection-type-configs.js.map +1 -1
  17. package/dist/src/commands/collections.js +6 -6
  18. package/dist/src/commands/collections.js.map +1 -1
  19. package/dist/src/commands/comparisons.js +6 -6
  20. package/dist/src/commands/comparisons.js.map +1 -1
  21. package/dist/src/commands/cron-triggers.js +17 -17
  22. package/dist/src/commands/cron-triggers.js.map +1 -1
  23. package/dist/src/commands/database-types.js +13 -13
  24. package/dist/src/commands/database-types.js.map +1 -1
  25. package/dist/src/commands/databases.js +266 -8
  26. package/dist/src/commands/databases.js.map +1 -1
  27. package/dist/src/commands/email-templates.js +6 -6
  28. package/dist/src/commands/email-templates.js.map +1 -1
  29. package/dist/src/commands/env.js +6 -6
  30. package/dist/src/commands/env.js.map +1 -1
  31. package/dist/src/commands/group-type-configs.js +6 -6
  32. package/dist/src/commands/group-type-configs.js.map +1 -1
  33. package/dist/src/commands/groups.js +7 -7
  34. package/dist/src/commands/groups.js.map +1 -1
  35. package/dist/src/commands/init.js +175 -144
  36. package/dist/src/commands/init.js.map +1 -1
  37. package/dist/src/commands/integrations.js +31 -21
  38. package/dist/src/commands/integrations.js.map +1 -1
  39. package/dist/src/commands/prompts.js +17 -16
  40. package/dist/src/commands/prompts.js.map +1 -1
  41. package/dist/src/commands/rule-sets.js +8 -8
  42. package/dist/src/commands/rule-sets.js.map +1 -1
  43. package/dist/src/commands/sync.js +1054 -284
  44. package/dist/src/commands/sync.js.map +1 -1
  45. package/dist/src/commands/tokens.js +9 -9
  46. package/dist/src/commands/tokens.js.map +1 -1
  47. package/dist/src/commands/users.js +44 -3
  48. package/dist/src/commands/users.js.map +1 -1
  49. package/dist/src/commands/webhooks.js +18 -18
  50. package/dist/src/commands/webhooks.js.map +1 -1
  51. package/dist/src/commands/workflows.js +285 -63
  52. package/dist/src/commands/workflows.js.map +1 -1
  53. package/dist/src/lib/api-client.js +273 -72
  54. package/dist/src/lib/api-client.js.map +1 -1
  55. package/dist/src/lib/db-codegen/dbFingerprint.js +17 -0
  56. package/dist/src/lib/db-codegen/dbFingerprint.js.map +1 -0
  57. package/dist/src/lib/db-codegen/dbGenerator.js +255 -0
  58. package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -0
  59. package/dist/src/lib/db-codegen/dbNaming.js +104 -0
  60. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -0
  61. package/dist/src/lib/db-codegen/dbTemplates.js +138 -0
  62. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -0
  63. package/dist/src/lib/db-codegen/dbTsTypes.js +61 -0
  64. package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -0
  65. package/dist/src/lib/migration-nag.js +163 -0
  66. package/dist/src/lib/migration-nag.js.map +1 -0
  67. package/dist/src/lib/output.js +58 -6
  68. package/dist/src/lib/output.js.map +1 -1
  69. package/dist/src/lib/refresh-admin-credentials.js +103 -0
  70. package/dist/src/lib/refresh-admin-credentials.js.map +1 -0
  71. package/dist/src/lib/template.js +80 -1
  72. package/dist/src/lib/template.js.map +1 -1
  73. package/dist/src/lib/toml-database-config.js +565 -0
  74. package/dist/src/lib/toml-database-config.js.map +1 -0
  75. package/dist/src/lib/toml-params-validator.js +183 -0
  76. package/dist/src/lib/toml-params-validator.js.map +1 -0
  77. package/dist/src/lib/workflow-fragments.js +121 -0
  78. package/dist/src/lib/workflow-fragments.js.map +1 -0
  79. package/dist/src/lib/workflow-toml-validator.js +343 -0
  80. package/dist/src/lib/workflow-toml-validator.js.map +1 -0
  81. package/package.json +2 -1
@@ -3,9 +3,13 @@ import { join, basename } from "path";
3
3
  import { createHash } from "crypto";
4
4
  import * as TOML from "@iarna/toml";
5
5
  import { lookup as mimeLookup } from "mime-types";
6
- import { ApiClient, ConflictError } from "../lib/api-client.js";
6
+ import { ApiClient, ApiError, ConflictError, SchemaRequiredError, OperationRefError, SchemaBreaksOpsError, SchemaHasUncheckableOpsError, TomlParseError, OpsExistError, } from "../lib/api-client.js";
7
+ import { buildDatabaseTypeTomlData, detectExistingOperationForms, normalizeOperationFromToml, normalizeSubscriptionFromToml, SubscriptionAccessKeyConflictError, } from "../lib/toml-database-config.js";
8
+ import { validateOperations, formatIssue, } from "../lib/toml-params-validator.js";
7
9
  import { getServerUrl, resolveAppId } from "../lib/config.js";
8
10
  import { resolveSyncDir, isAutoResolvedSyncDir, checkLegacySyncMigration, } from "../lib/sync-paths.js";
11
+ import { validateWorkflowToml, formatWorkflowTomlErrors, } from "../lib/workflow-toml-validator.js";
12
+ import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
9
13
  import { success, error, info, warn, keyValue, json, divider, } from "../lib/output.js";
10
14
  import chalk from "chalk";
11
15
  function ensureDir(dirPath) {
@@ -29,6 +33,31 @@ function saveSyncState(configDir, state) {
29
33
  const syncFile = join(configDir, ".primitive-sync.json");
30
34
  writeFileSync(syncFile, JSON.stringify(state, null, 2));
31
35
  }
36
+ /**
37
+ * Wrap a server-side error so the printed message identifies which entity
38
+ * was in flight. Used by every entity create/update/delete call site in the
39
+ * push loops (issue #684).
40
+ *
41
+ * Format: `Failed to ${action} ${kind} ${key}: ${serverMessage}`
42
+ *
43
+ * - `ConflictError` is rethrown unchanged so the existing
44
+ * `ConflictError → conflicts[]` accumulator in each loop still fires.
45
+ * - Other errors are rewrapped as a new `ApiError` carrying the wrapped
46
+ * message + the original `details[]` and `statusCode`.
47
+ */
48
+ export function wrapEntityError(err, action, kind, key) {
49
+ if (err instanceof ConflictError) {
50
+ return err;
51
+ }
52
+ const original = err instanceof Error ? err.message : String(err?.message ?? err);
53
+ const wrappedMessage = `Failed to ${action} ${kind} ${key}: ${original}`;
54
+ if (err instanceof ApiError) {
55
+ return new ApiError(wrappedMessage, err.statusCode, err.code, err.details);
56
+ }
57
+ // Non-ApiError (e.g. network failure, JSON parse error) — preserve cause.
58
+ const wrapped = new Error(wrappedMessage);
59
+ return wrapped;
60
+ }
32
61
  export function computeFileHash(filePath) {
33
62
  const content = readFileSync(filePath);
34
63
  return createHash("sha256").update(content).digest("hex");
@@ -38,6 +67,55 @@ export function shouldPushFile(filePath, storedHash) {
38
67
  return true;
39
68
  return computeFileHash(filePath) !== storedHash;
40
69
  }
70
+ /**
71
+ * Canonical-JSON serializer for hash computation.
72
+ *
73
+ * Sorts object keys at every level so two semantically-equivalent JSON
74
+ * blobs produce the same string. Arrays preserve order (step order
75
+ * matters in workflows). Used by `computeExpandedContentHash`.
76
+ */
77
+ function canonicalJsonStringify(value) {
78
+ if (value === null || typeof value !== "object") {
79
+ return JSON.stringify(value);
80
+ }
81
+ if (Array.isArray(value)) {
82
+ return "[" + value.map((v) => canonicalJsonStringify(v)).join(",") + "]";
83
+ }
84
+ const keys = Object.keys(value).sort();
85
+ return ("{" +
86
+ keys
87
+ .map((k) => JSON.stringify(k) + ":" + canonicalJsonStringify(value[k]))
88
+ .join(",") +
89
+ "}");
90
+ }
91
+ /**
92
+ * Hash the *expanded* (post-fragment-splice) content of a workflow TOML.
93
+ *
94
+ * The raw-file hash (`computeFileHash`) misses fragment-only edits: if a
95
+ * workflow uses `include = ["x"]` and only `workflow-fragments/x.toml`
96
+ * changes, the workflow file is byte-identical and shouldPushFile would
97
+ * skip the push, leaving the server with stale expanded steps.
98
+ *
99
+ * This helper hashes the parsed-and-expanded data instead, so fragment
100
+ * edits change the hash and force a re-push. Pass the already-parsed
101
+ * `tomlData` (from `parseTomlFile`) — that function runs the expander,
102
+ * so `tomlData` is the canonical expanded shape the server will see.
103
+ */
104
+ export function computeExpandedContentHash(parsed) {
105
+ const canonical = canonicalJsonStringify(parsed);
106
+ return createHash("sha256").update(canonical).digest("hex");
107
+ }
108
+ /**
109
+ * Variant of `shouldPushFile` for workflow files: compares the stored hash
110
+ * against the hash of the *expanded* content rather than the raw file
111
+ * bytes. Required so that edits to included fragments invalidate the
112
+ * push-skip cache for any workflow that references them.
113
+ */
114
+ export function shouldPushExpandedFile(parsed, storedHash) {
115
+ if (!storedHash)
116
+ return true;
117
+ return computeExpandedContentHash(parsed) !== storedHash;
118
+ }
41
119
  // TOML serialization helpers
42
120
  function serializeAppSettings(settings) {
43
121
  const data = {
@@ -224,6 +302,12 @@ function serializeWorkflow(workflow, draft, configs) {
224
302
  perUserMaxRunning: workflow.perUserMaxRunning,
225
303
  perUserMaxQueued: workflow.perUserMaxQueued,
226
304
  dequeueOrder: workflow.dequeueOrder,
305
+ // Sync-callable flags (#807): always emit both so a fresh pull → push
306
+ // cycle round-trips them. The GET response normalizes these to booleans
307
+ // (requiresClientApply defaults true, syncCallable defaults false), so
308
+ // they're safe to coerce here.
309
+ requiresClientApply: workflow.requiresClientApply !== false,
310
+ syncCallable: workflow.syncCallable === true,
227
311
  // Schemas are at workflow level
228
312
  inputSchema: workflow.inputSchema ? JSON.stringify(workflow.inputSchema) : undefined,
229
313
  outputSchema: workflow.outputSchema ? JSON.stringify(workflow.outputSchema) : undefined,
@@ -241,32 +325,20 @@ function serializeWorkflow(workflow, draft, configs) {
241
325
  return TOML.stringify(data);
242
326
  }
243
327
  // Database config serialization helpers
244
- function serializeDatabaseType(typeConfig, operations, ruleSetIdToName) {
245
- const ruleSetName = typeConfig.ruleSetId ? (ruleSetIdToName.get(typeConfig.ruleSetId) || "") : "";
246
- const data = {
247
- type: {
248
- databaseType: typeConfig.databaseType,
249
- ruleSetName: ruleSetName,
250
- },
251
- };
252
- if (typeConfig.metadataAccess) {
253
- // Serialize under the new user-facing key. The legacy `metadataAccess`
254
- // key is still accepted on read (see parseDatabaseTypeToml below).
255
- data.type.celContextAccess = typeConfig.metadataAccess;
256
- }
257
- if (typeConfig.triggers) {
258
- data.triggers = typeConfig.triggers;
259
- }
260
- if (operations.length > 0) {
261
- data.operations = operations.map((op) => ({
262
- name: op.name,
263
- type: op.type,
264
- modelName: op.modelName,
265
- access: op.access,
266
- definition: typeof op.definition === "object" ? JSON.stringify(op.definition) : op.definition,
267
- ...(op.params ? { params: typeof op.params === "object" ? JSON.stringify(op.params) : op.params } : {}),
268
- }));
269
- }
328
+ //
329
+ // Native-TOML form (issue #752): when emitting a database-type config, we
330
+ // prefer nested `[operations.definition]` tables and `[[operations.params]]`
331
+ // rows over JSON-strings stuffed into a single field. The form is sticky
332
+ // per file — if the existing file uses the legacy JSON-string form, we
333
+ // preserve it so we don't generate surprise diffs. New files default to
334
+ // native. Un-TOMLable shapes (null, mixed-type arrays) fall back to JSON
335
+ // string per field with a log message.
336
+ //
337
+ // Issue #666: when the type has a stored `schema` (raw TOML of `[models.*]`
338
+ // blocks), `buildDatabaseTypeTomlData` parses + merges it into the same TOML
339
+ // object so the result round-trips as `[models.<Name>.fields.<field>]`.
340
+ function serializeDatabaseType(typeConfig, operations, ruleSetIdToName, options = {}) {
341
+ const data = buildDatabaseTypeTomlData(typeConfig, operations, ruleSetIdToName, options);
270
342
  return TOML.stringify(data);
271
343
  }
272
344
  function serializeEmailTemplate(template) {
@@ -345,18 +417,58 @@ export function parseDatabaseTypeToml(tomlData) {
345
417
  else if (typeSection.metadataAccess) {
346
418
  typeConfig.metadataAccess = typeSection.metadataAccess;
347
419
  }
420
+ if (typeSection.defaultAccess !== undefined) {
421
+ typeConfig.defaultAccess = typeSection.defaultAccess;
422
+ }
423
+ // autoPopulatedFields (issue #750) — pass through unchanged. Both the
424
+ // shorthand (`ownerId = "user.userId"`) and the verbose object form
425
+ // (`updatedBy = { value = "user.userId", on = ["create","update"] }`) are
426
+ // accepted; the server validates and normalizes at write time.
427
+ if (typeSection.autoPopulatedFields !== undefined) {
428
+ typeConfig.autoPopulatedFields = typeSection.autoPopulatedFields;
429
+ }
430
+ // timestamps (issue #748) — inline-table form
431
+ // (`timestamps = { create = "createdAt", update = "modifiedAt" }`) passes
432
+ // through unchanged; the server validates and normalizes. Either lifecycle
433
+ // key may be omitted, and an optional `models = [...]` filter rides on the
434
+ // same object.
435
+ if (typeSection.timestamps !== undefined) {
436
+ typeConfig.timestamps = typeSection.timestamps;
437
+ }
348
438
  if (tomlData.triggers) {
349
439
  typeConfig.triggers = tomlData.triggers;
350
440
  }
351
- const operations = (tomlData.operations || []).map((op) => ({
352
- name: op.name,
353
- type: op.type,
354
- modelName: op.modelName,
355
- access: op.access,
356
- definition: typeof op.definition === "string" ? JSON.parse(op.definition) : op.definition,
357
- params: op.params ? (typeof op.params === "string" ? JSON.parse(op.params) : op.params) : null,
358
- }));
359
- return { typeConfig, operations };
441
+ // Issue #666: extract the `[models.*]` subtree (if present) and re-serialize
442
+ // it to a TOML string for the server's `schema` field.
443
+ //
444
+ // We always set `schema` — either to the serialized TOML string or to
445
+ // `null` — so the sync push diff can detect "models removed from local
446
+ // file" and forward `schema: null` to the server. Without this, removing
447
+ // `[models.*]` blocks would silently no-op and the next pull would
448
+ // resurrect the stale server schema, making schema deletion via `sync push`
449
+ // impossible (codex review on PR #766, [P2]).
450
+ let schemaSerialized = null;
451
+ if (tomlData.models && typeof tomlData.models === "object") {
452
+ try {
453
+ schemaSerialized = TOML.stringify({ models: tomlData.models });
454
+ }
455
+ catch {
456
+ // Fall through — server-side parser will fail with a clear
457
+ // TOML_PARSE_ERROR if we somehow produced an invalid round-trip.
458
+ }
459
+ }
460
+ typeConfig.schema = schemaSerialized;
461
+ // `normalizeOperationFromToml` accepts both forms:
462
+ // - legacy: `definition = '{...}'` (JSON string), `params = '{...}'`
463
+ // - native: `[operations.definition]` table, `[[operations.params]]` rows
464
+ // and returns the JS shape the server expects.
465
+ const operations = (tomlData.operations || []).map((op) => normalizeOperationFromToml(op));
466
+ // Subscriptions (issue #803): `[[subscriptions]]` array-of-tables.
467
+ // `normalizeSubscriptionFromToml` maps `accessRule`→`access` (throwing on a
468
+ // conflicting both-present declaration) and forwards `select`/`emit`/`params`
469
+ // in the wire shape. Until #803 this block was dropped at parse time.
470
+ const subscriptions = (tomlData.subscriptions || []).map((sub) => normalizeSubscriptionFromToml(sub));
471
+ return { typeConfig, operations, subscriptions };
360
472
  }
361
473
  function parseRuleSetToml(tomlData) {
362
474
  const ruleSetSection = tomlData.ruleSet || {};
@@ -397,9 +509,16 @@ export function parseCollectionTypeConfigToml(tomlData) {
397
509
  return result;
398
510
  }
399
511
  // Parsing helpers
512
+ //
513
+ // All 20+ TOML parse sites in this file route through `parseTomlFile()`.
514
+ // Workflow fragment expansion is wired in here so every push path (workflows,
515
+ // prompts, tests, etc.) gets it for free. For non-workflow TOMLs the
516
+ // expander is a no-op: if the parsed result has no `include` key, it returns
517
+ // the original object untouched.
400
518
  function parseTomlFile(filePath) {
401
519
  const content = readFileSync(filePath, "utf-8");
402
- return TOML.parse(content);
520
+ const parsed = TOML.parse(content);
521
+ return expandWorkflowTomlData(parsed, filePath);
403
522
  }
404
523
  /**
405
524
  * Paginate through a list endpoint, collecting all items.
@@ -974,10 +1093,20 @@ Directory Structure:
974
1093
  client.listGroupTypeConfigs(resolvedAppId).catch(() => []),
975
1094
  client.listCollectionTypeConfigs(resolvedAppId).catch(() => []),
976
1095
  ]);
977
- // Fetch operations for each database type
1096
+ // Fetch operations + subscriptions for each database type. Issue #803:
1097
+ // subscriptions are pulled symmetrically with operations so a
1098
+ // pull → push cycle round-trips `[[subscriptions]]` blocks without a
1099
+ // spurious diff. The list endpoint already excludes archived rows.
978
1100
  const databaseTypesWithOps = await Promise.all((Array.isArray(databaseTypeConfigsResult) ? databaseTypeConfigsResult : []).map(async (typeConfig) => {
979
- const ops = await client.listDatabaseTypeOperations(resolvedAppId, typeConfig.databaseType).catch(() => []);
980
- return { typeConfig, operations: Array.isArray(ops) ? ops : [] };
1101
+ const [ops, subs] = await Promise.all([
1102
+ client.listDatabaseTypeOperations(resolvedAppId, typeConfig.databaseType).catch(() => []),
1103
+ client.listDatabaseTypeSubscriptions(resolvedAppId, typeConfig.databaseType).catch(() => []),
1104
+ ]);
1105
+ return {
1106
+ typeConfig,
1107
+ operations: Array.isArray(ops) ? ops : [],
1108
+ subscriptions: Array.isArray(subs) ? subs : [],
1109
+ };
981
1110
  }));
982
1111
  // Ensure directories exist
983
1112
  ensureDir(configDir);
@@ -1087,11 +1216,16 @@ Directory Structure:
1087
1216
  const filename = `${workflow.workflowKey}.toml`;
1088
1217
  const filePath = join(configDir, "workflows", filename);
1089
1218
  writeFileSync(filePath, serializeWorkflow(workflow, draft, configs || []));
1219
+ // Hash the expanded (post-fragment-splice) form so subsequent pushes
1220
+ // can detect fragment-only edits. See `computeExpandedContentHash`.
1221
+ // Pulled workflows never carry `include`, so the expander is a
1222
+ // no-op here — but we use the same canonical-JSON hash function so
1223
+ // the push-side comparison stays consistent.
1090
1224
  workflowEntities[workflow.workflowKey] = {
1091
1225
  id: workflow.workflowId,
1092
1226
  modifiedAt: workflow.modifiedAt || new Date().toISOString(),
1093
1227
  activeConfigId: workflow.activeConfigId,
1094
- contentHash: computeFileHash(filePath),
1228
+ contentHash: computeExpandedContentHash(parseTomlFile(filePath)),
1095
1229
  };
1096
1230
  info(` Wrote workflows/${filename}`);
1097
1231
  }
@@ -1164,21 +1298,48 @@ Directory Structure:
1164
1298
  }
1165
1299
  // Write database types
1166
1300
  const databaseTypeEntities = {};
1167
- for (const { typeConfig, operations } of databaseTypesWithOps) {
1301
+ for (const { typeConfig, operations, subscriptions } of databaseTypesWithOps) {
1168
1302
  const filename = `${typeConfig.databaseType}.toml`;
1169
1303
  const filePath = join(configDir, "database-types", filename);
1170
- writeFileSync(filePath, serializeDatabaseType(typeConfig, operations, ruleSetIdToName));
1304
+ // Preserve the existing file's per-op form (issue #752): if the
1305
+ // file already exists, read it raw and detect which operations
1306
+ // currently use JSON-string vs native nested-table form.
1307
+ const hints = existsSync(filePath)
1308
+ ? detectExistingOperationForms(readFileSync(filePath, "utf-8"))
1309
+ : undefined;
1310
+ writeFileSync(filePath, serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
1311
+ hints,
1312
+ defaultForm: "native",
1313
+ logger: (msg) => info(` ${msg}`),
1314
+ // Issue #803: emit `[[subscriptions]]` blocks so a pull → push
1315
+ // cycle round-trips subscriptions without a spurious diff.
1316
+ subscriptions,
1317
+ }));
1171
1318
  const opsEntities = {};
1172
1319
  for (const op of operations) {
1173
1320
  opsEntities[op.name] = {
1174
1321
  modifiedAt: op.modifiedAt || new Date().toISOString(),
1175
1322
  };
1176
1323
  }
1324
+ // Track per-subscription modifiedAt in SyncState (issue #803),
1325
+ // exactly like operations, so the next push skips unchanged
1326
+ // subscriptions and detects removals.
1327
+ const subsEntities = {};
1328
+ for (const sub of subscriptions || []) {
1329
+ if (!sub?.subscriptionKey)
1330
+ continue;
1331
+ subsEntities[sub.subscriptionKey] = {
1332
+ modifiedAt: sub.modifiedAt || new Date().toISOString(),
1333
+ };
1334
+ }
1177
1335
  databaseTypeEntities[typeConfig.databaseType] = {
1178
1336
  databaseType: typeConfig.databaseType,
1179
1337
  modifiedAt: typeConfig.modifiedAt || new Date().toISOString(),
1180
1338
  operations: Object.keys(opsEntities).length > 0 ? opsEntities : undefined,
1339
+ subscriptions: Object.keys(subsEntities).length > 0 ? subsEntities : undefined,
1181
1340
  contentHash: computeFileHash(filePath),
1341
+ hasSchema: typeof typeConfig.schema === "string" &&
1342
+ typeConfig.schema.trim().length > 0,
1182
1343
  };
1183
1344
  info(` Wrote database-types/${filename}`);
1184
1345
  }
@@ -1277,6 +1438,7 @@ Directory Structure:
1277
1438
  .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
1278
1439
  .option("--dry-run", "Show what would be changed without applying")
1279
1440
  .option("--force", "Overwrite remote even if modified since last pull")
1441
+ .option("--accept-warnings", "Commit schema diffs that have operations with dynamic refs (issue #666 SCHEMA_HAS_UNCHECKABLE_OPS escape hatch)")
1280
1442
  .action(async (appId, options) => {
1281
1443
  const resolvedAppId = resolveAppId(appId, options);
1282
1444
  const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
@@ -1310,6 +1472,17 @@ Directory Structure:
1310
1472
  const changes = [];
1311
1473
  let skippedCount = 0;
1312
1474
  const conflicts = [];
1475
+ // Issue #666: per-class buckets for schema-feature errors. Each entry
1476
+ // groups failures by the code so the end-of-run report can print
1477
+ // them once with the friendly hint.
1478
+ const schemaErrors = {
1479
+ schemaRequired: [],
1480
+ opRefs: [],
1481
+ schemaBreaks: [],
1482
+ uncheckableOps: [],
1483
+ tomlParse: [],
1484
+ opsExist: [],
1485
+ };
1313
1486
  // Track name→ID mappings for resolving cross-references during push
1314
1487
  const ruleSetNameToId = new Map();
1315
1488
  const promptKeyToId = new Map();
@@ -1408,7 +1581,7 @@ Directory Structure:
1408
1581
  });
1409
1582
  }
1410
1583
  else {
1411
- throw err;
1584
+ throw wrapEntityError(err, "update", "rule set", fileKey);
1412
1585
  }
1413
1586
  }
1414
1587
  }
@@ -1419,12 +1592,18 @@ Directory Structure:
1419
1592
  // Create new rule set
1420
1593
  changes.push({ type: "rule-set", action: "create", key: fileKey });
1421
1594
  if (!options.dryRun) {
1422
- const created = await client.createRuleSet(resolvedAppId, {
1423
- name: ruleSetData.name,
1424
- resourceType: ruleSetData.resourceType,
1425
- rules: ruleSetData.rules,
1426
- description: ruleSetData.description,
1427
- });
1595
+ let created;
1596
+ try {
1597
+ created = await client.createRuleSet(resolvedAppId, {
1598
+ name: ruleSetData.name,
1599
+ resourceType: ruleSetData.resourceType,
1600
+ rules: ruleSetData.rules,
1601
+ description: ruleSetData.description,
1602
+ });
1603
+ }
1604
+ catch (err) {
1605
+ throw wrapEntityError(err, "create", "rule set", fileKey);
1606
+ }
1428
1607
  info(` Created rule set: ${fileKey}`);
1429
1608
  if (syncState && created?.ruleSetId) {
1430
1609
  if (!syncState.entities.ruleSets) {
@@ -1494,7 +1673,7 @@ Directory Structure:
1494
1673
  });
1495
1674
  }
1496
1675
  else {
1497
- throw err;
1676
+ throw wrapEntityError(err, "update", "integration", key);
1498
1677
  }
1499
1678
  }
1500
1679
  }
@@ -1502,18 +1681,23 @@ Directory Structure:
1502
1681
  else {
1503
1682
  changes.push({ type: "integration", action: "create", key });
1504
1683
  if (!options.dryRun) {
1505
- const created = await client.createIntegration(resolvedAppId, payload);
1506
- info(` Created integration: ${key}`);
1507
- // Add new entity to sync state
1508
- if (syncState && created?.integrationId && created?.modifiedAt) {
1509
- if (!syncState.entities.integrations) {
1510
- syncState.entities.integrations = {};
1511
- }
1512
- syncState.entities.integrations[key] = {
1513
- id: created.integrationId,
1514
- modifiedAt: created.modifiedAt,
1515
- contentHash: computeFileHash(filePath),
1516
- };
1684
+ try {
1685
+ const created = await client.createIntegration(resolvedAppId, payload);
1686
+ info(` Created integration: ${key}`);
1687
+ // Add new entity to sync state
1688
+ if (syncState && created?.integrationId && created?.modifiedAt) {
1689
+ if (!syncState.entities.integrations) {
1690
+ syncState.entities.integrations = {};
1691
+ }
1692
+ syncState.entities.integrations[key] = {
1693
+ id: created.integrationId,
1694
+ modifiedAt: created.modifiedAt,
1695
+ contentHash: computeFileHash(filePath),
1696
+ };
1697
+ }
1698
+ }
1699
+ catch (err) {
1700
+ throw wrapEntityError(err, "create", "integration", key);
1517
1701
  }
1518
1702
  }
1519
1703
  }
@@ -1580,7 +1764,7 @@ Directory Structure:
1580
1764
  });
1581
1765
  }
1582
1766
  else {
1583
- throw err;
1767
+ throw wrapEntityError(err, "update", "webhook", key);
1584
1768
  }
1585
1769
  }
1586
1770
  }
@@ -1588,17 +1772,22 @@ Directory Structure:
1588
1772
  else {
1589
1773
  changes.push({ type: "webhook", action: "create", key });
1590
1774
  if (!options.dryRun) {
1591
- const created = await client.createWebhook(resolvedAppId, payload);
1592
- info(` Created webhook: ${key}`);
1593
- if (syncState && created?.webhookId && created?.modifiedAt) {
1594
- if (!syncState.entities.webhooks) {
1595
- syncState.entities.webhooks = {};
1596
- }
1597
- syncState.entities.webhooks[key] = {
1598
- id: created.webhookId,
1599
- modifiedAt: created.modifiedAt,
1600
- contentHash: computeFileHash(filePath),
1601
- };
1775
+ try {
1776
+ const created = await client.createWebhook(resolvedAppId, payload);
1777
+ info(` Created webhook: ${key}`);
1778
+ if (syncState && created?.webhookId && created?.modifiedAt) {
1779
+ if (!syncState.entities.webhooks) {
1780
+ syncState.entities.webhooks = {};
1781
+ }
1782
+ syncState.entities.webhooks[key] = {
1783
+ id: created.webhookId,
1784
+ modifiedAt: created.modifiedAt,
1785
+ contentHash: computeFileHash(filePath),
1786
+ };
1787
+ }
1788
+ }
1789
+ catch (err) {
1790
+ throw wrapEntityError(err, "create", "webhook", key);
1602
1791
  }
1603
1792
  }
1604
1793
  }
@@ -1648,24 +1837,29 @@ Directory Structure:
1648
1837
  }
1649
1838
  }
1650
1839
  catch (err) {
1651
- throw err;
1840
+ throw wrapEntityError(err, "update", "cron trigger", key);
1652
1841
  }
1653
1842
  }
1654
1843
  }
1655
1844
  else {
1656
1845
  changes.push({ type: "cron-trigger", action: "create", key });
1657
1846
  if (!options.dryRun) {
1658
- const created = await client.createCronTrigger(resolvedAppId, payload);
1659
- info(` Created cron trigger: ${key}`);
1660
- if (syncState && created?.triggerId && created?.modifiedAt) {
1661
- if (!syncState.entities.cronTriggers) {
1662
- syncState.entities.cronTriggers = {};
1663
- }
1664
- syncState.entities.cronTriggers[key] = {
1665
- id: created.triggerId,
1666
- modifiedAt: created.modifiedAt,
1667
- contentHash: computeFileHash(filePath),
1668
- };
1847
+ try {
1848
+ const created = await client.createCronTrigger(resolvedAppId, payload);
1849
+ info(` Created cron trigger: ${key}`);
1850
+ if (syncState && created?.triggerId && created?.modifiedAt) {
1851
+ if (!syncState.entities.cronTriggers) {
1852
+ syncState.entities.cronTriggers = {};
1853
+ }
1854
+ syncState.entities.cronTriggers[key] = {
1855
+ id: created.triggerId,
1856
+ modifiedAt: created.modifiedAt,
1857
+ contentHash: computeFileHash(filePath),
1858
+ };
1859
+ }
1860
+ }
1861
+ catch (err) {
1862
+ throw wrapEntityError(err, "create", "cron trigger", key);
1669
1863
  }
1670
1864
  }
1671
1865
  }
@@ -1743,7 +1937,7 @@ Directory Structure:
1743
1937
  }
1744
1938
  }
1745
1939
  else {
1746
- throw err;
1940
+ throw wrapEntityError(err, "create", "blob bucket", key);
1747
1941
  }
1748
1942
  }
1749
1943
  }
@@ -1857,7 +2051,7 @@ Directory Structure:
1857
2051
  });
1858
2052
  }
1859
2053
  else {
1860
- throw err;
2054
+ throw wrapEntityError(err, "update", "prompt", key);
1861
2055
  }
1862
2056
  }
1863
2057
  }
@@ -1867,65 +2061,70 @@ Directory Structure:
1867
2061
  const firstConfig = configs[0] || {};
1868
2062
  changes.push({ type: "prompt", action: "create", key });
1869
2063
  if (!options.dryRun) {
1870
- const created = await client.createPrompt(resolvedAppId, {
1871
- promptKey: key,
1872
- displayName: prompt.displayName || key,
1873
- description: prompt.description,
1874
- provider: firstConfig.provider || "openrouter",
1875
- model: firstConfig.model || "google/gemini-2.0-flash-001",
1876
- systemPrompt: firstConfig.systemPrompt,
1877
- userPromptTemplate: firstConfig.userPromptTemplate || "{{ input }}",
1878
- temperature: firstConfig.temperature,
1879
- maxTokens: firstConfig.maxTokens,
1880
- outputFormat: firstConfig.outputFormat,
1881
- inputSchema: prompt.inputSchema,
1882
- });
1883
- info(` Created prompt: ${key}`);
1884
- // Add new entity to sync state
1885
- if (syncState && created?.promptId && created?.modifiedAt) {
1886
- if (!syncState.entities.prompts) {
1887
- syncState.entities.prompts = {};
1888
- }
1889
- syncState.entities.prompts[key] = {
1890
- id: created.promptId,
1891
- modifiedAt: created.modifiedAt,
1892
- contentHash: computeFileHash(filePath),
1893
- };
1894
- }
1895
- // Track prompt key→ID and config name→ID
1896
- if (created?.promptId) {
1897
- promptKeyToId.set(key, created.promptId);
1898
- }
1899
- if (created?.configs) {
1900
- for (const config of created.configs) {
1901
- promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
2064
+ try {
2065
+ const created = await client.createPrompt(resolvedAppId, {
2066
+ promptKey: key,
2067
+ displayName: prompt.displayName || key,
2068
+ description: prompt.description,
2069
+ provider: firstConfig.provider || "openrouter",
2070
+ model: firstConfig.model || "google/gemini-2.0-flash-001",
2071
+ systemPrompt: firstConfig.systemPrompt,
2072
+ userPromptTemplate: firstConfig.userPromptTemplate || "{{ input }}",
2073
+ temperature: firstConfig.temperature,
2074
+ maxTokens: firstConfig.maxTokens,
2075
+ outputFormat: firstConfig.outputFormat,
2076
+ inputSchema: prompt.inputSchema,
2077
+ });
2078
+ info(` Created prompt: ${key}`);
2079
+ // Add new entity to sync state
2080
+ if (syncState && created?.promptId && created?.modifiedAt) {
2081
+ if (!syncState.entities.prompts) {
2082
+ syncState.entities.prompts = {};
2083
+ }
2084
+ syncState.entities.prompts[key] = {
2085
+ id: created.promptId,
2086
+ modifiedAt: created.modifiedAt,
2087
+ contentHash: computeFileHash(filePath),
2088
+ };
1902
2089
  }
1903
- }
1904
- // Create additional configs (configs[1..n]) that weren't included in the initial create
1905
- if (created?.promptId && configs.length > 1) {
1906
- for (let i = 1; i < configs.length; i++) {
1907
- const extraConfig = configs[i];
1908
- const extraCreated = await client.createPromptConfig(resolvedAppId, created.promptId, {
1909
- configName: extraConfig.name || `config-${i + 1}`,
1910
- description: extraConfig.description,
1911
- provider: extraConfig.provider || firstConfig.provider || "openrouter",
1912
- model: extraConfig.model || firstConfig.model || "google/gemini-2.0-flash-001",
1913
- systemPrompt: extraConfig.systemPrompt,
1914
- userPromptTemplate: extraConfig.userPromptTemplate,
1915
- temperature: extraConfig.temperature,
1916
- maxTokens: extraConfig.maxTokens,
1917
- outputFormat: extraConfig.outputFormat,
1918
- });
1919
- if (extraCreated?.configId) {
1920
- const configName = extraConfig.name || `config-${i + 1}`;
1921
- promptConfigNameToId.set(`${key}#${configName}`, extraCreated.configId);
2090
+ // Track prompt key→ID and config name→ID
2091
+ if (created?.promptId) {
2092
+ promptKeyToId.set(key, created.promptId);
2093
+ }
2094
+ if (created?.configs) {
2095
+ for (const config of created.configs) {
2096
+ promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
1922
2097
  }
1923
- // Activate this config if it was the active one
1924
- if (extraConfig.isActive && extraCreated?.configId) {
1925
- await client.activatePromptConfig(resolvedAppId, created.promptId, extraCreated.configId);
2098
+ }
2099
+ // Create additional configs (configs[1..n]) that weren't included in the initial create
2100
+ if (created?.promptId && configs.length > 1) {
2101
+ for (let i = 1; i < configs.length; i++) {
2102
+ const extraConfig = configs[i];
2103
+ const extraCreated = await client.createPromptConfig(resolvedAppId, created.promptId, {
2104
+ configName: extraConfig.name || `config-${i + 1}`,
2105
+ description: extraConfig.description,
2106
+ provider: extraConfig.provider || firstConfig.provider || "openrouter",
2107
+ model: extraConfig.model || firstConfig.model || "google/gemini-2.0-flash-001",
2108
+ systemPrompt: extraConfig.systemPrompt,
2109
+ userPromptTemplate: extraConfig.userPromptTemplate,
2110
+ temperature: extraConfig.temperature,
2111
+ maxTokens: extraConfig.maxTokens,
2112
+ outputFormat: extraConfig.outputFormat,
2113
+ });
2114
+ if (extraCreated?.configId) {
2115
+ const configName = extraConfig.name || `config-${i + 1}`;
2116
+ promptConfigNameToId.set(`${key}#${configName}`, extraCreated.configId);
2117
+ }
2118
+ // Activate this config if it was the active one
2119
+ if (extraConfig.isActive && extraCreated?.configId) {
2120
+ await client.activatePromptConfig(resolvedAppId, created.promptId, extraCreated.configId);
2121
+ }
1926
2122
  }
2123
+ info(` Created ${configs.length - 1} additional config(s) for prompt: ${key}`);
1927
2124
  }
1928
- info(` Created ${configs.length - 1} additional config(s) for prompt: ${key}`);
2125
+ }
2126
+ catch (err) {
2127
+ throw wrapEntityError(err, "create", "prompt", key);
1929
2128
  }
1930
2129
  }
1931
2130
  }
@@ -1938,13 +2137,27 @@ Directory Structure:
1938
2137
  for (const file of files) {
1939
2138
  const filePath = join(workflowsDir, file);
1940
2139
  const tomlData = parseTomlFile(filePath);
2140
+ // Issue #685: reject misnested headers (e.g.
2141
+ // [steps.<id>.request]) before pushing. The runtime silently
2142
+ // ignores fields outside the allowlist, so this is the only
2143
+ // place to catch the footgun. Validation happens BEFORE the
2144
+ // skip-if-unchanged check so a previously-pushed-but-broken
2145
+ // file gets a clear diagnostic on every push attempt.
2146
+ const tomlErrors = validateWorkflowToml(tomlData);
2147
+ if (tomlErrors.length > 0) {
2148
+ error(formatWorkflowTomlErrors(filePath, tomlErrors));
2149
+ process.exit(1);
2150
+ }
1941
2151
  const workflow = tomlData.workflow || {};
1942
2152
  const key = workflow.key || basename(file, ".toml");
1943
2153
  const steps = tomlData.steps || [];
1944
2154
  const existingId = syncState?.entities?.workflows?.[key]?.id;
1945
2155
  const existingActiveConfigId = syncState?.entities?.workflows?.[key]?.activeConfigId;
1946
- // Skip if file hasn't changed since last sync
1947
- if (!options.force && existingId && !shouldPushFile(filePath, syncState?.entities?.workflows?.[key]?.contentHash)) {
2156
+ // Skip if file hasn't changed since last sync. Use the expanded
2157
+ // content hash (post fragment splice) so that edits to included
2158
+ // `workflow-fragments/*.toml` files invalidate the push-skip cache
2159
+ // for any workflow that references them. See `computeExpandedContentHash`.
2160
+ if (!options.force && existingId && !shouldPushExpandedFile(tomlData, syncState?.entities?.workflows?.[key]?.contentHash)) {
1948
2161
  skippedCount++;
1949
2162
  // Only fetch config name→ID mappings if test cases exist for this workflow
1950
2163
  const workflowTestsDir = getTestsDir(configDir, "workflow", key);
@@ -1966,7 +2179,21 @@ Directory Structure:
1966
2179
  ? undefined
1967
2180
  : syncState?.entities?.workflows?.[key]?.modifiedAt;
1968
2181
  try {
1969
- // Update workflow metadata and schemas
2182
+ // Update workflow metadata and schemas.
2183
+ //
2184
+ // Sync-callable ordering (#807, codex review): the metadata
2185
+ // PATCH must NOT carry `syncCallable: true` here. The server
2186
+ // re-validates `syncCallable: true` against the workflow's
2187
+ // CURRENTLY-active server steps (`loadCurrentActiveSteps`),
2188
+ // not the steps being pushed in the same sync. So when a
2189
+ // single TOML edit both enables `syncCallable` and replaces
2190
+ // sync-incompatible active steps with compatible ones, a
2191
+ // combined metadata-first PATCH would be rejected against the
2192
+ // stale steps. Defer `syncCallable` to a second PATCH issued
2193
+ // AFTER the config/step update lands, so it validates against
2194
+ // the new (compatible) active steps. `requiresClientApply`
2195
+ // has no step-dependent validation, so it stays in the first
2196
+ // PATCH unchanged.
1970
2197
  const updated = await client.updateWorkflow(resolvedAppId, existingId, {
1971
2198
  name: workflow.name,
1972
2199
  description: workflow.description,
@@ -1975,10 +2202,23 @@ Directory Structure:
1975
2202
  perUserMaxRunning: workflow.perUserMaxRunning,
1976
2203
  perUserMaxQueued: workflow.perUserMaxQueued,
1977
2204
  dequeueOrder: workflow.dequeueOrder,
2205
+ // Sync-callable flags (#807): pass through as-is. An absent
2206
+ // TOML key is `undefined` (dropped by JSON.stringify, so the
2207
+ // server's hasOwnProperty guard leaves the value untouched);
2208
+ // an explicit `false` is preserved as a meaningful value.
2209
+ requiresClientApply: workflow.requiresClientApply,
1978
2210
  inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
1979
2211
  outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : null,
1980
2212
  }, expectedModifiedAt);
1981
- // Update active configuration steps (or draft for legacy)
2213
+ // Track the latest workflow `modifiedAt` for the sync-state
2214
+ // write below. The first PATCH bumps it; the `syncCallable`
2215
+ // PATCH (if any) bumps it again. The config/step update does
2216
+ // NOT touch the workflow definition's `modifiedAt`.
2217
+ let latestModifiedAt = updated?.workflow?.modifiedAt;
2218
+ // Update active configuration steps (or draft for legacy).
2219
+ // Issue #687: name the slot we touched so the dev-loop
2220
+ // user can confirm before previewing.
2221
+ let updateSlotLabel = "active config";
1982
2222
  if (existingActiveConfigId) {
1983
2223
  await client.updateWorkflowConfig(resolvedAppId, existingId, existingActiveConfigId, {
1984
2224
  steps,
@@ -1990,12 +2230,31 @@ Directory Structure:
1990
2230
  steps,
1991
2231
  inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
1992
2232
  });
2233
+ updateSlotLabel = "draft (legacy)";
1993
2234
  }
1994
- info(` Updated workflow: ${key}`);
1995
- // Update sync state with new modifiedAt
1996
- if (syncState?.entities?.workflows?.[key] && updated?.workflow?.modifiedAt) {
1997
- syncState.entities.workflows[key].modifiedAt = updated.workflow.modifiedAt;
1998
- syncState.entities.workflows[key].contentHash = computeFileHash(filePath);
2235
+ // Second PATCH: apply `syncCallable` now that the new steps
2236
+ // are active (#807). Only sent when the TOML actually carries
2237
+ // the key an absent key stays `undefined` and we skip the
2238
+ // call entirely, preserving the no-clobber discipline. Chain
2239
+ // `expectedModifiedAt` off the first PATCH's returned value so
2240
+ // optimistic concurrency stays intact (the step update above
2241
+ // doesn't change the workflow definition's `modifiedAt`).
2242
+ if (workflow.syncCallable !== undefined) {
2243
+ const syncCallableUpdated = await client.updateWorkflow(resolvedAppId, existingId, { syncCallable: workflow.syncCallable },
2244
+ // Mirror the first PATCH's concurrency posture: `--force`
2245
+ // skips the check (undefined), otherwise reuse the fresh
2246
+ // `modifiedAt` from the first PATCH.
2247
+ options.force ? undefined : latestModifiedAt);
2248
+ if (syncCallableUpdated?.workflow?.modifiedAt) {
2249
+ latestModifiedAt = syncCallableUpdated.workflow.modifiedAt;
2250
+ }
2251
+ }
2252
+ info(` Updated workflow: ${key} (${updateSlotLabel})`);
2253
+ // Update sync state with new modifiedAt. Store the *expanded*
2254
+ // content hash so future fragment-only edits are detected.
2255
+ if (syncState?.entities?.workflows?.[key] && latestModifiedAt) {
2256
+ syncState.entities.workflows[key].modifiedAt = latestModifiedAt;
2257
+ syncState.entities.workflows[key].contentHash = computeExpandedContentHash(tomlData);
1999
2258
  }
2000
2259
  // Fetch full workflow to get config name→ID mappings
2001
2260
  // (updateWorkflow response doesn't include configs)
@@ -2016,7 +2275,7 @@ Directory Structure:
2016
2275
  });
2017
2276
  }
2018
2277
  else {
2019
- throw err;
2278
+ throw wrapEntityError(err, "update", "workflow", key);
2020
2279
  }
2021
2280
  }
2022
2281
  }
@@ -2025,63 +2284,74 @@ Directory Structure:
2025
2284
  // Create new workflow (automatically creates default config)
2026
2285
  changes.push({ type: "workflow", action: "create", key });
2027
2286
  if (!options.dryRun) {
2028
- const created = await client.createWorkflow(resolvedAppId, {
2029
- workflowKey: key,
2030
- name: workflow.name || key,
2031
- description: workflow.description,
2032
- steps,
2033
- inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : undefined,
2034
- outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : undefined,
2035
- accessRule: workflow.accessRule || undefined,
2036
- perUserMaxRunning: workflow.perUserMaxRunning,
2037
- perUserMaxQueued: workflow.perUserMaxQueued,
2038
- dequeueOrder: workflow.dequeueOrder,
2039
- });
2040
- info(` Created workflow: ${key}`);
2041
- // Add new entity to sync state (including activeConfigId)
2042
- if (syncState && created?.workflow?.workflowId && created?.workflow?.modifiedAt) {
2043
- if (!syncState.entities.workflows) {
2044
- syncState.entities.workflows = {};
2045
- }
2046
- syncState.entities.workflows[key] = {
2047
- id: created.workflow.workflowId,
2048
- modifiedAt: created.workflow.modifiedAt,
2049
- activeConfigId: created.workflow.activeConfigId,
2050
- contentHash: computeFileHash(filePath),
2051
- };
2052
- }
2053
- // Track config name→ID mappings
2054
- if (created?.configs) {
2055
- for (const config of created.configs) {
2056
- workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
2287
+ try {
2288
+ const created = await client.createWorkflow(resolvedAppId, {
2289
+ workflowKey: key,
2290
+ name: workflow.name || key,
2291
+ description: workflow.description,
2292
+ steps,
2293
+ inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : undefined,
2294
+ outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : undefined,
2295
+ accessRule: workflow.accessRule || undefined,
2296
+ perUserMaxRunning: workflow.perUserMaxRunning,
2297
+ perUserMaxQueued: workflow.perUserMaxQueued,
2298
+ dequeueOrder: workflow.dequeueOrder,
2299
+ // Sync-callable flags (#807): pass through as-is. An absent
2300
+ // TOML key is `undefined` (dropped by JSON.stringify), so the
2301
+ // server applies its defaults (requiresClientApply=true,
2302
+ // syncCallable=false); an explicit value is honored.
2303
+ requiresClientApply: workflow.requiresClientApply,
2304
+ syncCallable: workflow.syncCallable,
2305
+ });
2306
+ info(` Created workflow: ${key}`);
2307
+ // Add new entity to sync state (including activeConfigId)
2308
+ if (syncState && created?.workflow?.workflowId && created?.workflow?.modifiedAt) {
2309
+ if (!syncState.entities.workflows) {
2310
+ syncState.entities.workflows = {};
2311
+ }
2312
+ syncState.entities.workflows[key] = {
2313
+ id: created.workflow.workflowId,
2314
+ modifiedAt: created.workflow.modifiedAt,
2315
+ activeConfigId: created.workflow.activeConfigId,
2316
+ contentHash: computeExpandedContentHash(tomlData),
2317
+ };
2057
2318
  }
2058
- }
2059
- const workflowId = created?.workflow?.workflowId;
2060
- const tomlConfigs = tomlData.configs || [];
2061
- // Create additional workflow configs (configs[1..n]) beyond the default
2062
- if (workflowId && tomlConfigs.length > 1) {
2063
- for (let i = 1; i < tomlConfigs.length; i++) {
2064
- const extraConfig = tomlConfigs[i];
2065
- const extraCreated = await client.createWorkflowConfig(resolvedAppId, workflowId, {
2066
- configName: extraConfig.name || `config-${i + 1}`,
2067
- description: extraConfig.description,
2068
- steps,
2069
- });
2070
- if (extraCreated?.configId) {
2071
- const configName = extraConfig.name || `config-${i + 1}`;
2072
- workflowConfigNameToId.set(`${key}#${configName}`, extraCreated.configId);
2319
+ // Track config name→ID mappings
2320
+ if (created?.configs) {
2321
+ for (const config of created.configs) {
2322
+ workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
2073
2323
  }
2074
2324
  }
2075
- info(` Created ${tomlConfigs.length - 1} additional config(s) for workflow: ${key}`);
2076
- }
2077
- // Activate the correct config based on activeConfigName from TOML
2078
- if (workflowId && workflow.activeConfigName) {
2079
- const activeConfigId = workflowConfigNameToId.get(`${key}#${workflow.activeConfigName}`);
2080
- if (activeConfigId && activeConfigId !== created?.workflow?.activeConfigId) {
2081
- await client.activateWorkflowConfig(resolvedAppId, workflowId, activeConfigId);
2082
- info(` Activated config "${workflow.activeConfigName}" for workflow: ${key}`);
2325
+ const workflowId = created?.workflow?.workflowId;
2326
+ const tomlConfigs = tomlData.configs || [];
2327
+ // Create additional workflow configs (configs[1..n]) beyond the default
2328
+ if (workflowId && tomlConfigs.length > 1) {
2329
+ for (let i = 1; i < tomlConfigs.length; i++) {
2330
+ const extraConfig = tomlConfigs[i];
2331
+ const extraCreated = await client.createWorkflowConfig(resolvedAppId, workflowId, {
2332
+ configName: extraConfig.name || `config-${i + 1}`,
2333
+ description: extraConfig.description,
2334
+ steps,
2335
+ });
2336
+ if (extraCreated?.configId) {
2337
+ const configName = extraConfig.name || `config-${i + 1}`;
2338
+ workflowConfigNameToId.set(`${key}#${configName}`, extraCreated.configId);
2339
+ }
2340
+ }
2341
+ info(` Created ${tomlConfigs.length - 1} additional config(s) for workflow: ${key}`);
2342
+ }
2343
+ // Activate the correct config based on activeConfigName from TOML
2344
+ if (workflowId && workflow.activeConfigName) {
2345
+ const activeConfigId = workflowConfigNameToId.get(`${key}#${workflow.activeConfigName}`);
2346
+ if (activeConfigId && activeConfigId !== created?.workflow?.activeConfigId) {
2347
+ await client.activateWorkflowConfig(resolvedAppId, workflowId, activeConfigId);
2348
+ info(` Activated config "${workflow.activeConfigName}" for workflow: ${key}`);
2349
+ }
2083
2350
  }
2084
2351
  }
2352
+ catch (err) {
2353
+ throw wrapEntityError(err, "create", "workflow", key);
2354
+ }
2085
2355
  }
2086
2356
  }
2087
2357
  }
@@ -2092,11 +2362,49 @@ Directory Structure:
2092
2362
  const files = readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml"));
2093
2363
  for (const file of files) {
2094
2364
  const filePath = join(dbTypesDir, file);
2365
+ const rawToml = readFileSync(filePath, "utf-8");
2095
2366
  const tomlData = parseTomlFile(filePath);
2096
- const { typeConfig, operations } = parseDatabaseTypeToml(tomlData);
2367
+ let typeConfig;
2368
+ let operations;
2369
+ let subscriptions;
2370
+ try {
2371
+ ({ typeConfig, operations, subscriptions } =
2372
+ parseDatabaseTypeToml(tomlData));
2373
+ }
2374
+ catch (err) {
2375
+ // The only structured parse error today is the
2376
+ // `accessRule`/`access` conflict in a `[[subscriptions]]` block
2377
+ // (issue #803). Surface it with the file name and abort the
2378
+ // push rather than crashing with an opaque stack.
2379
+ if (err instanceof SubscriptionAccessKeyConflictError) {
2380
+ error(` ${file}: ${err.message}`);
2381
+ error(`Aborting push: subscription \`access\`/\`accessRule\` conflict in ${file}.`);
2382
+ process.exit(1);
2383
+ }
2384
+ throw err;
2385
+ }
2097
2386
  const dbType = typeConfig.databaseType || basename(file, ".toml");
2098
2387
  // Resolve ruleSetName → ruleSetId if using key-based reference
2099
2388
  resolveRuleSetReference(typeConfig, ruleSetNameToId, `database type ${dbType}`);
2389
+ // $params validator (issue #752): every `$params.X` reference
2390
+ // inside `definition` must correspond to a declared param in
2391
+ // `[[operations.params]]`. Catches typos like `$params.proectId`
2392
+ // at push time with the TOML file:line of the offending op block.
2393
+ const validation = validateOperations({
2394
+ filePath,
2395
+ rawToml,
2396
+ operations,
2397
+ });
2398
+ for (const w of validation.warnings) {
2399
+ warn(` ${formatIssue(w)}`);
2400
+ }
2401
+ if (validation.errors.length > 0) {
2402
+ for (const e of validation.errors) {
2403
+ error(` ${formatIssue(e)}`);
2404
+ }
2405
+ error(`Aborting push: ${validation.errors.length} unresolved $params reference(s) in ${file}.`);
2406
+ process.exit(1);
2407
+ }
2100
2408
  const existingEntry = syncState?.entities?.databaseTypes?.[dbType];
2101
2409
  // Skip if file hasn't changed since last sync
2102
2410
  if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
@@ -2120,13 +2428,44 @@ Directory Structure:
2120
2428
  updateData.triggers = typeConfig.triggers || null;
2121
2429
  if ("metadataAccess" in typeConfig)
2122
2430
  updateData.metadataAccess = typeConfig.metadataAccess || null;
2431
+ if ("defaultAccess" in typeConfig)
2432
+ updateData.defaultAccess = typeConfig.defaultAccess || null;
2433
+ if ("autoPopulatedFields" in typeConfig) {
2434
+ updateData.autoPopulatedFields =
2435
+ typeConfig.autoPopulatedFields || null;
2436
+ }
2437
+ if ("timestamps" in typeConfig) {
2438
+ updateData.timestamps = typeConfig.timestamps || null;
2439
+ }
2440
+ // Issue #666: forward `schema` when the local TOML declares
2441
+ // one (a set/update), OR when the server had one at last
2442
+ // sync and the local file no longer does (a deletion —
2443
+ // `schema: null` clears it server-side; codex review gap on
2444
+ // PR #766). When the type never had a schema and still
2445
+ // doesn't, omit it so an operations-only edit doesn't
2446
+ // register as an empty type-level update (issue #369).
2447
+ const localHasSchema = typeof typeConfig.schema === "string" &&
2448
+ typeConfig.schema.trim().length > 0;
2449
+ if (localHasSchema) {
2450
+ updateData.schema = typeConfig.schema;
2451
+ }
2452
+ else if (existingEntry.hasSchema) {
2453
+ updateData.schema = null;
2454
+ }
2123
2455
  if (Object.keys(updateData).length > 0) {
2124
2456
  changes.push({ type: "database-type", action: "update", key: dbType });
2125
- const updated = await client.updateDatabaseTypeConfig(resolvedAppId, dbType, updateData, expectedModifiedAt);
2457
+ const updated = await client.updateDatabaseTypeConfig(resolvedAppId, dbType, updateData, expectedModifiedAt, {
2458
+ dryRun: !!options.dryRun,
2459
+ acceptWarnings: !!options.acceptWarnings,
2460
+ });
2126
2461
  info(` Updated database type: ${dbType}`);
2127
2462
  if (syncState?.entities?.databaseTypes?.[dbType] && updated?.modifiedAt) {
2128
2463
  syncState.entities.databaseTypes[dbType].modifiedAt = updated.modifiedAt;
2129
2464
  syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
2465
+ syncState.entities.databaseTypes[dbType].hasSchema =
2466
+ "schema" in updateData
2467
+ ? updateData.schema !== null
2468
+ : syncState.entities.databaseTypes[dbType].hasSchema;
2130
2469
  }
2131
2470
  }
2132
2471
  }
@@ -2139,8 +2478,41 @@ Directory Structure:
2139
2478
  localModifiedAt: expectedModifiedAt || "unknown",
2140
2479
  });
2141
2480
  }
2481
+ else if (err instanceof SchemaBreaksOpsError) {
2482
+ schemaErrors.schemaBreaks.push({
2483
+ type: "database-type",
2484
+ key: dbType,
2485
+ operations: err.operations,
2486
+ message: err.message,
2487
+ });
2488
+ }
2489
+ else if (err instanceof SchemaHasUncheckableOpsError) {
2490
+ schemaErrors.uncheckableOps.push({
2491
+ type: "database-type",
2492
+ key: dbType,
2493
+ operations: err.operations,
2494
+ message: err.message,
2495
+ });
2496
+ }
2497
+ else if (err instanceof TomlParseError) {
2498
+ schemaErrors.tomlParse.push({
2499
+ type: "database-type",
2500
+ key: dbType,
2501
+ line: err.line,
2502
+ column: err.column,
2503
+ message: err.message,
2504
+ });
2505
+ }
2506
+ else if (err instanceof OpsExistError) {
2507
+ schemaErrors.opsExist.push({
2508
+ type: "database-type",
2509
+ key: dbType,
2510
+ opCount: err.opCount,
2511
+ message: err.message,
2512
+ });
2513
+ }
2142
2514
  else {
2143
- throw err;
2515
+ throw wrapEntityError(err, "update", "database type", dbType);
2144
2516
  }
2145
2517
  }
2146
2518
  }
@@ -2148,7 +2520,11 @@ Directory Structure:
2148
2520
  // In dry-run mode, still report the change iff we would actually PATCH.
2149
2521
  const wouldUpdate = "ruleSetId" in typeConfig ||
2150
2522
  "triggers" in typeConfig ||
2151
- "metadataAccess" in typeConfig;
2523
+ "metadataAccess" in typeConfig ||
2524
+ "defaultAccess" in typeConfig ||
2525
+ "autoPopulatedFields" in typeConfig ||
2526
+ "timestamps" in typeConfig ||
2527
+ "schema" in typeConfig;
2152
2528
  if (wouldUpdate) {
2153
2529
  changes.push({ type: "database-type", action: "update", key: dbType });
2154
2530
  }
@@ -2167,17 +2543,37 @@ Directory Structure:
2167
2543
  createData.triggers = typeConfig.triggers;
2168
2544
  if (typeConfig.metadataAccess)
2169
2545
  createData.metadataAccess = typeConfig.metadataAccess;
2170
- const created = await client.createDatabaseTypeConfig(resolvedAppId, createData);
2171
- info(` Created database type: ${dbType}`);
2172
- if (syncState) {
2173
- if (!syncState.entities.databaseTypes) {
2174
- syncState.entities.databaseTypes = {};
2546
+ if (typeConfig.defaultAccess)
2547
+ createData.defaultAccess = typeConfig.defaultAccess;
2548
+ if (typeConfig.autoPopulatedFields)
2549
+ createData.autoPopulatedFields = typeConfig.autoPopulatedFields;
2550
+ if (typeConfig.timestamps)
2551
+ createData.timestamps = typeConfig.timestamps;
2552
+ // Issue #666 addendum A2: forward `schema` on POST so a
2553
+ // single sync push can land both the type + its schema in
2554
+ // one round-trip (instead of POST → PATCH). The op-edit gate
2555
+ // itself no longer requires a schema on a fresh type, but
2556
+ // landing the schema up front keeps subsequent op-pushes
2557
+ // validating against the intended shape.
2558
+ if (typeConfig.schema)
2559
+ createData.schema = typeConfig.schema;
2560
+ try {
2561
+ const created = await client.createDatabaseTypeConfig(resolvedAppId, createData);
2562
+ info(` Created database type: ${dbType}`);
2563
+ if (syncState) {
2564
+ if (!syncState.entities.databaseTypes) {
2565
+ syncState.entities.databaseTypes = {};
2566
+ }
2567
+ syncState.entities.databaseTypes[dbType] = {
2568
+ databaseType: dbType,
2569
+ modifiedAt: created?.modifiedAt || new Date().toISOString(),
2570
+ contentHash: computeFileHash(filePath),
2571
+ hasSchema: !!createData.schema,
2572
+ };
2175
2573
  }
2176
- syncState.entities.databaseTypes[dbType] = {
2177
- databaseType: dbType,
2178
- modifiedAt: created?.modifiedAt || new Date().toISOString(),
2179
- contentHash: computeFileHash(filePath),
2180
- };
2574
+ }
2575
+ catch (err) {
2576
+ throw wrapEntityError(err, "create", "database type", dbType);
2181
2577
  }
2182
2578
  }
2183
2579
  }
@@ -2195,6 +2591,10 @@ Directory Structure:
2195
2591
  : existingOp.modifiedAt;
2196
2592
  try {
2197
2593
  const updated = await client.updateDatabaseTypeOperation(resolvedAppId, dbType, op.name, {
2594
+ // Include `type` so the server can detect/apply
2595
+ // type-in-place changes (issue #692). When the type
2596
+ // matches the stored value, this is a no-op.
2597
+ type: op.type,
2198
2598
  modelName: op.modelName,
2199
2599
  access: op.access,
2200
2600
  definition: op.definition,
@@ -2214,8 +2614,23 @@ Directory Structure:
2214
2614
  localModifiedAt: expectedOpModifiedAt || "unknown",
2215
2615
  });
2216
2616
  }
2617
+ else if (err instanceof SchemaRequiredError) {
2618
+ schemaErrors.schemaRequired.push({
2619
+ type: "operation",
2620
+ key: `${dbType}/${op.name}`,
2621
+ message: err.message,
2622
+ });
2623
+ }
2624
+ else if (err instanceof OperationRefError) {
2625
+ schemaErrors.opRefs.push({
2626
+ type: "operation",
2627
+ key: `${dbType}/${op.name}`,
2628
+ refs: err.refs,
2629
+ message: err.message,
2630
+ });
2631
+ }
2217
2632
  else {
2218
- throw err;
2633
+ throw wrapEntityError(err, "update", "operation", `${dbType}/${op.name}`);
2219
2634
  }
2220
2635
  }
2221
2636
  }
@@ -2224,22 +2639,44 @@ Directory Structure:
2224
2639
  // Create new operation
2225
2640
  changes.push({ type: "operation", action: "create", key: `${dbType}/${op.name}` });
2226
2641
  if (!options.dryRun) {
2227
- const created = await client.createDatabaseTypeOperation(resolvedAppId, dbType, {
2228
- name: op.name,
2229
- type: op.type,
2230
- modelName: op.modelName,
2231
- access: op.access,
2232
- definition: op.definition,
2233
- params: op.params,
2234
- });
2235
- info(` Created operation: ${dbType}/${op.name}`);
2236
- if (syncState?.entities?.databaseTypes?.[dbType]) {
2237
- if (!syncState.entities.databaseTypes[dbType].operations) {
2238
- syncState.entities.databaseTypes[dbType].operations = {};
2642
+ try {
2643
+ const created = await client.createDatabaseTypeOperation(resolvedAppId, dbType, {
2644
+ name: op.name,
2645
+ type: op.type,
2646
+ modelName: op.modelName,
2647
+ access: op.access,
2648
+ definition: op.definition,
2649
+ params: op.params,
2650
+ });
2651
+ info(` Created operation: ${dbType}/${op.name}`);
2652
+ if (syncState?.entities?.databaseTypes?.[dbType]) {
2653
+ if (!syncState.entities.databaseTypes[dbType].operations) {
2654
+ syncState.entities.databaseTypes[dbType].operations = {};
2655
+ }
2656
+ syncState.entities.databaseTypes[dbType].operations[op.name] = {
2657
+ modifiedAt: created?.modifiedAt || new Date().toISOString(),
2658
+ };
2659
+ }
2660
+ }
2661
+ catch (err) {
2662
+ if (err instanceof SchemaRequiredError) {
2663
+ schemaErrors.schemaRequired.push({
2664
+ type: "operation",
2665
+ key: `${dbType}/${op.name}`,
2666
+ message: err.message,
2667
+ });
2668
+ }
2669
+ else if (err instanceof OperationRefError) {
2670
+ schemaErrors.opRefs.push({
2671
+ type: "operation",
2672
+ key: `${dbType}/${op.name}`,
2673
+ refs: err.refs,
2674
+ message: err.message,
2675
+ });
2676
+ }
2677
+ else {
2678
+ throw wrapEntityError(err, "create", "operation", `${dbType}/${op.name}`);
2239
2679
  }
2240
- syncState.entities.databaseTypes[dbType].operations[op.name] = {
2241
- modifiedAt: created?.modifiedAt || new Date().toISOString(),
2242
- };
2243
2680
  }
2244
2681
  }
2245
2682
  }
@@ -2249,14 +2686,128 @@ Directory Structure:
2249
2686
  if (!tomlOpNames.has(existingOpName)) {
2250
2687
  changes.push({ type: "operation", action: "delete", key: `${dbType}/${existingOpName}` });
2251
2688
  if (!options.dryRun) {
2252
- await client.deleteDatabaseTypeOperation(resolvedAppId, dbType, existingOpName);
2253
- info(` Deleted operation: ${dbType}/${existingOpName}`);
2254
- if (syncState?.entities?.databaseTypes?.[dbType]?.operations) {
2255
- delete syncState.entities.databaseTypes[dbType].operations[existingOpName];
2689
+ try {
2690
+ await client.deleteDatabaseTypeOperation(resolvedAppId, dbType, existingOpName);
2691
+ info(` Deleted operation: ${dbType}/${existingOpName}`);
2692
+ if (syncState?.entities?.databaseTypes?.[dbType]?.operations) {
2693
+ delete syncState.entities.databaseTypes[dbType].operations[existingOpName];
2694
+ }
2695
+ }
2696
+ catch (err) {
2697
+ throw wrapEntityError(err, "delete", "operation", `${dbType}/${existingOpName}`);
2698
+ }
2699
+ }
2700
+ }
2701
+ }
2702
+ // Process subscriptions for this type (issue #803). Mirrors the
2703
+ // operations reconcile above, keyed on `subscriptionKey`:
2704
+ // create new keys, PUT changed/existing keys, delete keys present
2705
+ // in SyncState but absent from the TOML. Server errors (409 key
2706
+ // collision, `#`-in-key 400, max-20 400, missing-required 400)
2707
+ // are surfaced cleanly via `wrapEntityError` — named, not a crash.
2708
+ const existingSubs = syncState?.entities?.databaseTypes?.[dbType]?.subscriptions || {};
2709
+ const tomlSubKeys = new Set(subscriptions.map((sub) => sub.subscriptionKey));
2710
+ for (const sub of subscriptions) {
2711
+ const subKey = sub.subscriptionKey;
2712
+ const existingSub = existingSubs[subKey];
2713
+ if (existingSub) {
2714
+ // Update existing subscription (PUT — idempotent).
2715
+ changes.push({ type: "subscription", action: "update", key: `${dbType}/${subKey}` });
2716
+ if (!options.dryRun) {
2717
+ try {
2718
+ const updated = await client.updateDatabaseTypeSubscription(resolvedAppId, dbType, subKey, {
2719
+ displayName: sub.displayName,
2720
+ modelName: sub.modelName,
2721
+ filter: sub.filter,
2722
+ access: sub.access,
2723
+ description: sub.description,
2724
+ select: sub.select,
2725
+ emit: sub.emit,
2726
+ params: sub.params,
2727
+ status: sub.status,
2728
+ });
2729
+ info(` Updated subscription: ${dbType}/${subKey}`);
2730
+ // Gate the SyncState write on a server-returned
2731
+ // `modifiedAt`, mirroring the operations precedent above
2732
+ // (don't fall back to a fresh local timestamp).
2733
+ if (syncState?.entities?.databaseTypes?.[dbType]?.subscriptions?.[subKey] && updated?.modifiedAt) {
2734
+ syncState.entities.databaseTypes[dbType].subscriptions[subKey].modifiedAt =
2735
+ updated.modifiedAt;
2736
+ }
2737
+ }
2738
+ catch (err) {
2739
+ throw wrapEntityError(err, "update", "subscription", `${dbType}/${subKey}`);
2740
+ }
2741
+ }
2742
+ }
2743
+ else {
2744
+ // Create new subscription (POST).
2745
+ changes.push({ type: "subscription", action: "create", key: `${dbType}/${subKey}` });
2746
+ if (!options.dryRun) {
2747
+ try {
2748
+ const created = await client.createDatabaseTypeSubscription(resolvedAppId, dbType, {
2749
+ subscriptionKey: subKey,
2750
+ displayName: sub.displayName,
2751
+ modelName: sub.modelName,
2752
+ filter: sub.filter,
2753
+ access: sub.access,
2754
+ description: sub.description,
2755
+ select: sub.select,
2756
+ emit: sub.emit,
2757
+ params: sub.params,
2758
+ status: sub.status,
2759
+ });
2760
+ info(` Created subscription: ${dbType}/${subKey}`);
2761
+ if (syncState?.entities?.databaseTypes?.[dbType]) {
2762
+ if (!syncState.entities.databaseTypes[dbType].subscriptions) {
2763
+ syncState.entities.databaseTypes[dbType].subscriptions = {};
2764
+ }
2765
+ syncState.entities.databaseTypes[dbType].subscriptions[subKey] = {
2766
+ modifiedAt: created?.modifiedAt || new Date().toISOString(),
2767
+ };
2768
+ }
2769
+ }
2770
+ catch (err) {
2771
+ throw wrapEntityError(err, "create", "subscription", `${dbType}/${subKey}`);
2256
2772
  }
2257
2773
  }
2258
2774
  }
2259
2775
  }
2776
+ // Delete subscriptions that were removed from TOML (hard-delete on
2777
+ // current `main`, PR #787 — frees the key for reuse). A 409 on
2778
+ // recreate is impossible here since we delete first; the pull-side
2779
+ // "active only" filter (controller `list` excludes archived)
2780
+ // agrees because hard-delete leaves no archived row behind.
2781
+ for (const existingSubKey of Object.keys(existingSubs)) {
2782
+ if (!tomlSubKeys.has(existingSubKey)) {
2783
+ changes.push({ type: "subscription", action: "delete", key: `${dbType}/${existingSubKey}` });
2784
+ if (!options.dryRun) {
2785
+ try {
2786
+ await client.deleteDatabaseTypeSubscription(resolvedAppId, dbType, existingSubKey);
2787
+ info(` Deleted subscription: ${dbType}/${existingSubKey}`);
2788
+ if (syncState?.entities?.databaseTypes?.[dbType]?.subscriptions) {
2789
+ delete syncState.entities.databaseTypes[dbType].subscriptions[existingSubKey];
2790
+ }
2791
+ }
2792
+ catch (err) {
2793
+ throw wrapEntityError(err, "delete", "subscription", `${dbType}/${existingSubKey}`);
2794
+ }
2795
+ }
2796
+ }
2797
+ }
2798
+ // Refresh the file's content hash after processing operations +
2799
+ // subscriptions (issue #803, behavior g). The type-config update
2800
+ // block only refreshes the hash when a type-level field changed
2801
+ // (its `updateData` was non-empty); an operations- or
2802
+ // subscriptions-only edit left a stale hash, so the next push
2803
+ // would re-PUT every op/sub instead of skipping the unchanged
2804
+ // file. Recompute here so a truly-unchanged subsequent push skips
2805
+ // the whole file via `shouldPushFile`. `--force` bypasses that
2806
+ // skip, so forced re-pushes are unaffected.
2807
+ if (!options.dryRun && syncState?.entities?.databaseTypes?.[dbType]) {
2808
+ syncState.entities.databaseTypes[dbType].contentHash =
2809
+ computeFileHash(filePath);
2810
+ }
2260
2811
  }
2261
2812
  }
2262
2813
  // Process group type configs
@@ -2304,7 +2855,7 @@ Directory Structure:
2304
2855
  });
2305
2856
  }
2306
2857
  else {
2307
- throw err;
2858
+ throw wrapEntityError(err, "update", "group type config", groupType);
2308
2859
  }
2309
2860
  }
2310
2861
  }
@@ -2313,20 +2864,25 @@ Directory Structure:
2313
2864
  // Create new group type config
2314
2865
  changes.push({ type: "group-type-config", action: "create", key: groupType });
2315
2866
  if (!options.dryRun) {
2316
- const created = await client.createGroupTypeConfig(resolvedAppId, {
2317
- groupType,
2318
- ruleSetId: configData.ruleSetId || undefined,
2319
- autoAddCreator: configData.autoAddCreator,
2320
- });
2321
- info(` Created group type config: ${groupType}`);
2322
- if (syncState) {
2323
- if (!syncState.entities.groupTypeConfigs) {
2324
- syncState.entities.groupTypeConfigs = {};
2867
+ try {
2868
+ const created = await client.createGroupTypeConfig(resolvedAppId, {
2869
+ groupType,
2870
+ ruleSetId: configData.ruleSetId || undefined,
2871
+ autoAddCreator: configData.autoAddCreator,
2872
+ });
2873
+ info(` Created group type config: ${groupType}`);
2874
+ if (syncState) {
2875
+ if (!syncState.entities.groupTypeConfigs) {
2876
+ syncState.entities.groupTypeConfigs = {};
2877
+ }
2878
+ syncState.entities.groupTypeConfigs[groupType] = {
2879
+ modifiedAt: created?.modifiedAt || new Date().toISOString(),
2880
+ contentHash: computeFileHash(filePath),
2881
+ };
2325
2882
  }
2326
- syncState.entities.groupTypeConfigs[groupType] = {
2327
- modifiedAt: created?.modifiedAt || new Date().toISOString(),
2328
- contentHash: computeFileHash(filePath),
2329
- };
2883
+ }
2884
+ catch (err) {
2885
+ throw wrapEntityError(err, "create", "group type config", groupType);
2330
2886
  }
2331
2887
  }
2332
2888
  }
@@ -2376,7 +2932,7 @@ Directory Structure:
2376
2932
  });
2377
2933
  }
2378
2934
  else {
2379
- throw err;
2935
+ throw wrapEntityError(err, "update", "collection type config", collectionType);
2380
2936
  }
2381
2937
  }
2382
2938
  }
@@ -2385,19 +2941,24 @@ Directory Structure:
2385
2941
  // Create new collection type config
2386
2942
  changes.push({ type: "collection-type-config", action: "create", key: collectionType });
2387
2943
  if (!options.dryRun) {
2388
- const created = await client.createCollectionTypeConfig(resolvedAppId, {
2389
- collectionType,
2390
- ruleSetId: configData.ruleSetId || undefined,
2391
- });
2392
- info(` Created collection type config: ${collectionType}`);
2393
- if (syncState) {
2394
- if (!syncState.entities.collectionTypeConfigs) {
2395
- syncState.entities.collectionTypeConfigs = {};
2944
+ try {
2945
+ const created = await client.createCollectionTypeConfig(resolvedAppId, {
2946
+ collectionType,
2947
+ ruleSetId: configData.ruleSetId || undefined,
2948
+ });
2949
+ info(` Created collection type config: ${collectionType}`);
2950
+ if (syncState) {
2951
+ if (!syncState.entities.collectionTypeConfigs) {
2952
+ syncState.entities.collectionTypeConfigs = {};
2953
+ }
2954
+ syncState.entities.collectionTypeConfigs[collectionType] = {
2955
+ modifiedAt: created?.modifiedAt || new Date().toISOString(),
2956
+ contentHash: computeFileHash(filePath),
2957
+ };
2396
2958
  }
2397
- syncState.entities.collectionTypeConfigs[collectionType] = {
2398
- modifiedAt: created?.modifiedAt || new Date().toISOString(),
2399
- contentHash: computeFileHash(filePath),
2400
- };
2959
+ }
2960
+ catch (err) {
2961
+ throw wrapEntityError(err, "create", "collection type config", collectionType);
2401
2962
  }
2402
2963
  }
2403
2964
  }
@@ -2478,33 +3039,118 @@ Directory Structure:
2478
3039
  console.log(` ${color(change.action)} ${change.type}: ${change.key}`);
2479
3040
  }
2480
3041
  }
2481
- else if (conflicts.length > 0) {
3042
+ else if (conflicts.length > 0 ||
3043
+ schemaErrors.schemaRequired.length > 0 ||
3044
+ schemaErrors.opRefs.length > 0 ||
3045
+ schemaErrors.schemaBreaks.length > 0 ||
3046
+ schemaErrors.uncheckableOps.length > 0 ||
3047
+ schemaErrors.tomlParse.length > 0 ||
3048
+ schemaErrors.opsExist.length > 0) {
2482
3049
  // Handle conflicts
2483
- console.log();
2484
- warn(`${conflicts.length} conflict(s) detected:`);
2485
- console.log();
2486
- for (const conflict of conflicts) {
2487
- console.log(` ${chalk.red("CONFLICT")} ${conflict.type}: ${chalk.bold(conflict.key)}`);
2488
- console.log(` Local last sync: ${chalk.dim(conflict.localModifiedAt)}`);
2489
- console.log(` Server modified: ${chalk.yellow(conflict.serverModifiedAt)}`);
3050
+ if (conflicts.length > 0) {
3051
+ console.log();
3052
+ warn(`${conflicts.length} conflict(s) detected:`);
3053
+ console.log();
3054
+ for (const conflict of conflicts) {
3055
+ console.log(` ${chalk.red("CONFLICT")} ${conflict.type}: ${chalk.bold(conflict.key)}`);
3056
+ console.log(` Local last sync: ${chalk.dim(conflict.localModifiedAt)}`);
3057
+ console.log(` Server modified: ${chalk.yellow(conflict.serverModifiedAt)}`);
3058
+ }
3059
+ console.log();
3060
+ warn("These entities were modified on the server since your last pull.");
3061
+ info("Options:");
3062
+ console.log(` 1. Run ${chalk.cyan("primitive sync pull")} to get latest changes`);
3063
+ console.log(` 2. Run ${chalk.cyan("primitive sync push --force")} to overwrite server`);
3064
+ console.log();
3065
+ }
3066
+ // Report schema-feature errors (issue #666).
3067
+ if (schemaErrors.schemaRequired.length > 0) {
3068
+ console.log();
3069
+ warn(`${schemaErrors.schemaRequired.length} operation push(es) blocked: type has no schema set.`);
3070
+ for (const e of schemaErrors.schemaRequired) {
3071
+ const type = e.key.split("/")[0];
3072
+ console.log(` ${chalk.red("SCHEMA_REQUIRED")} ${e.key}`);
3073
+ console.log(` Run ${chalk.cyan(`primitive databases schema generate ${type}`)} to scaffold one, then retry.`);
3074
+ }
3075
+ console.log();
3076
+ }
3077
+ if (schemaErrors.opRefs.length > 0) {
3078
+ console.log();
3079
+ warn(`${schemaErrors.opRefs.length} operation push(es) blocked: unresolved references.`);
3080
+ for (const e of schemaErrors.opRefs) {
3081
+ console.log(` ${chalk.red("OPERATION_REFERENCES_UNDEFINED")} ${e.key}`);
3082
+ for (const ref of e.refs) {
3083
+ console.log(` - ${chalk.yellow(ref.ref)} at ${chalk.dim(ref.location)}`);
3084
+ }
3085
+ }
3086
+ console.log();
3087
+ }
3088
+ if (schemaErrors.schemaBreaks.length > 0) {
3089
+ console.log();
3090
+ warn(`${schemaErrors.schemaBreaks.length} schema push(es) blocked: existing operations would break.`);
3091
+ for (const e of schemaErrors.schemaBreaks) {
3092
+ console.log(` ${chalk.red("SCHEMA_BREAKS_OPERATIONS")} ${e.key}`);
3093
+ for (const op of e.operations) {
3094
+ console.log(` ${chalk.bold(op.operation)}`);
3095
+ for (const ref of op.refs) {
3096
+ console.log(` - ${chalk.yellow(ref.ref)} at ${chalk.dim(ref.location)}`);
3097
+ }
3098
+ }
3099
+ }
3100
+ console.log();
3101
+ }
3102
+ if (schemaErrors.uncheckableOps.length > 0) {
3103
+ console.log();
3104
+ warn(`${schemaErrors.uncheckableOps.length} schema push(es) blocked: operations with dynamic refs.`);
3105
+ for (const e of schemaErrors.uncheckableOps) {
3106
+ console.log(` ${chalk.red("SCHEMA_HAS_UNCHECKABLE_OPS")} ${e.key}`);
3107
+ for (const op of e.operations) {
3108
+ console.log(` ${chalk.bold(op.operation)}`);
3109
+ for (const loc of op.locations) {
3110
+ console.log(` - ${chalk.dim(loc)}`);
3111
+ }
3112
+ }
3113
+ }
3114
+ info(`Re-run with ${chalk.cyan("--accept-warnings")} to commit anyway.`);
3115
+ console.log();
3116
+ }
3117
+ if (schemaErrors.tomlParse.length > 0) {
3118
+ console.log();
3119
+ warn(`${schemaErrors.tomlParse.length} TOML parse error(s):`);
3120
+ for (const e of schemaErrors.tomlParse) {
3121
+ const loc = e.line ? ` (line ${e.line}${e.column ? `, col ${e.column}` : ""})` : "";
3122
+ console.log(` ${chalk.red("TOML_PARSE_ERROR")} ${e.key}${loc}`);
3123
+ console.log(` ${e.message}`);
3124
+ }
3125
+ console.log();
3126
+ }
3127
+ if (schemaErrors.opsExist.length > 0) {
3128
+ console.log();
3129
+ warn(`${schemaErrors.opsExist.length} schema delete(s) blocked: operations still registered.`);
3130
+ for (const e of schemaErrors.opsExist) {
3131
+ console.log(` ${chalk.red("OPS_EXIST")} ${e.key} — ${e.opCount} op(s)`);
3132
+ console.log(` Delete or migrate the affected operations before clearing the schema.`);
3133
+ }
3134
+ console.log();
2490
3135
  }
2491
- console.log();
2492
- warn("These entities were modified on the server since your last pull.");
2493
- info("Options:");
2494
- console.log(` 1. Run ${chalk.cyan("primitive sync pull")} to get latest changes`);
2495
- console.log(` 2. Run ${chalk.cyan("primitive sync push --force")} to overwrite server`);
2496
- console.log();
2497
3136
  // Update sync state for non-conflicting changes
2498
3137
  if (syncState) {
2499
3138
  syncState.lastSyncedAt = new Date().toISOString();
2500
3139
  saveSyncState(configDir, syncState);
2501
3140
  }
2502
- const successCount = changes.length - conflicts.length;
3141
+ const totalBlocked = conflicts.length +
3142
+ schemaErrors.schemaRequired.length +
3143
+ schemaErrors.opRefs.length +
3144
+ schemaErrors.schemaBreaks.length +
3145
+ schemaErrors.uncheckableOps.length +
3146
+ schemaErrors.tomlParse.length +
3147
+ schemaErrors.opsExist.length;
3148
+ const successCount = changes.length - totalBlocked;
2503
3149
  if (successCount > 0) {
2504
- success(`Pushed ${successCount} change(s). ${conflicts.length} conflict(s) skipped.`);
3150
+ success(`Pushed ${successCount} change(s). ${totalBlocked} blocked.`);
2505
3151
  }
2506
3152
  else {
2507
- error(`Push failed: ${conflicts.length} conflict(s). No changes applied.`);
3153
+ error(`Push failed: ${totalBlocked} blocked. No changes applied.`);
2508
3154
  }
2509
3155
  process.exit(1);
2510
3156
  }
@@ -2532,6 +3178,12 @@ Directory Structure:
2532
3178
  }
2533
3179
  }
2534
3180
  error(err.message);
3181
+ // Print structured server-side validation details (issue #684).
3182
+ if (err instanceof ApiError && Array.isArray(err.details) && err.details.length > 0) {
3183
+ for (const detail of err.details) {
3184
+ console.error(` - ${typeof detail === "string" ? detail : JSON.stringify(detail)}`);
3185
+ }
3186
+ }
2535
3187
  process.exit(1);
2536
3188
  }
2537
3189
  });
@@ -2852,5 +3504,123 @@ Directory Structure:
2852
3504
  process.exit(1);
2853
3505
  }
2854
3506
  });
3507
+ // Migrate-toml (issue #752): bulk-rewrite database-type TOML files from
3508
+ // the legacy JSON-string form for `definition`/`params` to the native
3509
+ // nested-table form. Idempotent: ops already in native form are left as
3510
+ // is; ops that are un-TOMLable (mixed-type arrays, null values) fall
3511
+ // back to JSON-string per field with a log message.
3512
+ sync
3513
+ .command("migrate-toml")
3514
+ .description("Rewrite database-type TOML files to native [operations.definition] / [[operations.params]] form")
3515
+ .argument("[app-id]", "App ID (uses current app if not specified)")
3516
+ .option("--app <app-id>", "App ID")
3517
+ .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
3518
+ .option("--dry-run", "Show what would change without writing files")
3519
+ .action(async (appId, options) => {
3520
+ // Resolve appId so we land in the per-env sync dir even when the user
3521
+ // didn't pass `--dir`. The app ID itself isn't used for any API calls
3522
+ // — `migrate-toml` is a purely local rewrite.
3523
+ const resolvedAppId = resolveAppId(appId, options);
3524
+ const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
3525
+ if (isAutoResolvedSyncDir(options.dir)) {
3526
+ info(`Using per-environment sync directory: ${configDir}`);
3527
+ }
3528
+ if (!existsSync(configDir)) {
3529
+ error(`Config directory not found: ${configDir}`);
3530
+ process.exit(1);
3531
+ }
3532
+ const dbTypesDir = join(configDir, "database-types");
3533
+ if (!existsSync(dbTypesDir)) {
3534
+ info("No database-types/ directory found; nothing to migrate.");
3535
+ return;
3536
+ }
3537
+ const files = readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml"));
3538
+ if (files.length === 0) {
3539
+ info("No database-type TOML files found; nothing to migrate.");
3540
+ return;
3541
+ }
3542
+ // Hydrate ruleSetIdToName from local sync state so files that
3543
+ // reference a rule set via the legacy `ruleSetId = "01..."` form
3544
+ // round-trip with the correct `ruleSetName`. Without this, any user
3545
+ // who runs migrate-toml against a file with a rule-set assignment
3546
+ // would silently lose that reference (review feedback r3246633010).
3547
+ //
3548
+ // Files that use the modern key-based `ruleSetName = "..."` form
3549
+ // don't need the map at all — `parseDatabaseTypeToml` stores the
3550
+ // value in `typeConfig._ruleSetName` and the serializer now prefers
3551
+ // that. The map is only load-bearing for the legacy ID-based form.
3552
+ const ruleSetIdToName = new Map();
3553
+ const migrateSyncState = loadSyncState(configDir);
3554
+ if (migrateSyncState?.entities?.ruleSets) {
3555
+ for (const [fileKey, entry] of Object.entries(migrateSyncState.entities.ruleSets)) {
3556
+ if (entry && typeof entry === "object" && "id" in entry && entry.id) {
3557
+ // fileKey is the sanitized rule-set name (see sync pull at
3558
+ // sync.ts:1385). It's the same shape the server returned and
3559
+ // matches what a TOML file's ruleSetName field would carry.
3560
+ ruleSetIdToName.set(entry.id, fileKey);
3561
+ }
3562
+ }
3563
+ }
3564
+ let migratedCount = 0;
3565
+ let unchangedCount = 0;
3566
+ for (const file of files) {
3567
+ const filePath = join(dbTypesDir, file);
3568
+ const rawBefore = readFileSync(filePath, "utf-8");
3569
+ const tomlData = parseTomlFile(filePath);
3570
+ // `accessRule`/`access` conflicts surface as a clear error here too,
3571
+ // rather than silently dropping the field during a migrate rewrite.
3572
+ let typeConfig;
3573
+ let operations;
3574
+ let subscriptions;
3575
+ try {
3576
+ ({ typeConfig, operations, subscriptions } =
3577
+ parseDatabaseTypeToml(tomlData));
3578
+ }
3579
+ catch (err) {
3580
+ if (err instanceof SubscriptionAccessKeyConflictError) {
3581
+ error(` ${file}: ${err.message}`);
3582
+ error(`Aborting migrate-toml: subscription \`access\`/\`accessRule\` conflict in ${file}.`);
3583
+ process.exit(1);
3584
+ }
3585
+ throw err;
3586
+ }
3587
+ // Force-native: ignore existing hints so every TOMLable field
3588
+ // ends up in nested-table form. Subscriptions are preserved on the
3589
+ // rewrite (issue #803) so migrate-toml never drops them.
3590
+ const rewritten = serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
3591
+ defaultForm: "native",
3592
+ logger: (msg) => info(` ${msg}`),
3593
+ subscriptions,
3594
+ });
3595
+ if (rewritten === rawBefore) {
3596
+ unchangedCount++;
3597
+ continue;
3598
+ }
3599
+ if (options.dryRun) {
3600
+ info(`Would migrate database-types/${file}`);
3601
+ }
3602
+ else {
3603
+ writeFileSync(filePath, rewritten);
3604
+ info(`Migrated database-types/${file}`);
3605
+ // Update content hash in sync state if we have an entry for it.
3606
+ const syncState = loadSyncState(configDir);
3607
+ const dbType = typeConfig.databaseType || basename(file, ".toml");
3608
+ if (syncState?.entities?.databaseTypes?.[dbType]) {
3609
+ syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
3610
+ saveSyncState(configDir, syncState);
3611
+ }
3612
+ }
3613
+ migratedCount++;
3614
+ }
3615
+ divider();
3616
+ keyValue("Migrated", migratedCount);
3617
+ keyValue("Already native (unchanged)", unchangedCount);
3618
+ if (options.dryRun) {
3619
+ info("Dry-run only — no files were modified.");
3620
+ }
3621
+ else if (migratedCount > 0) {
3622
+ success(`Rewrote ${migratedCount} TOML file(s) to native form.`);
3623
+ }
3624
+ });
2855
3625
  }
2856
3626
  //# sourceMappingURL=sync.js.map