primitive-admin 1.0.44 → 1.0.45

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 (71) 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 +132 -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 +803 -275
  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 +273 -63
  52. package/dist/src/commands/workflows.js.map +1 -1
  53. package/dist/src/lib/api-client.js +240 -72
  54. package/dist/src/lib/api-client.js.map +1 -1
  55. package/dist/src/lib/migration-nag.js +163 -0
  56. package/dist/src/lib/migration-nag.js.map +1 -0
  57. package/dist/src/lib/output.js +58 -6
  58. package/dist/src/lib/output.js.map +1 -1
  59. package/dist/src/lib/refresh-admin-credentials.js +103 -0
  60. package/dist/src/lib/refresh-admin-credentials.js.map +1 -0
  61. package/dist/src/lib/template.js +80 -1
  62. package/dist/src/lib/template.js.map +1 -1
  63. package/dist/src/lib/toml-database-config.js +384 -0
  64. package/dist/src/lib/toml-database-config.js.map +1 -0
  65. package/dist/src/lib/toml-params-validator.js +183 -0
  66. package/dist/src/lib/toml-params-validator.js.map +1 -0
  67. package/dist/src/lib/workflow-fragments.js +121 -0
  68. package/dist/src/lib/workflow-fragments.js.map +1 -0
  69. package/dist/src/lib/workflow-toml-validator.js +328 -0
  70. package/dist/src/lib/workflow-toml-validator.js.map +1 -0
  71. package/package.json +1 -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, } 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 = {
@@ -241,32 +319,20 @@ function serializeWorkflow(workflow, draft, configs) {
241
319
  return TOML.stringify(data);
242
320
  }
243
321
  // 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
- }
322
+ //
323
+ // Native-TOML form (issue #752): when emitting a database-type config, we
324
+ // prefer nested `[operations.definition]` tables and `[[operations.params]]`
325
+ // rows over JSON-strings stuffed into a single field. The form is sticky
326
+ // per file — if the existing file uses the legacy JSON-string form, we
327
+ // preserve it so we don't generate surprise diffs. New files default to
328
+ // native. Un-TOMLable shapes (null, mixed-type arrays) fall back to JSON
329
+ // string per field with a log message.
330
+ //
331
+ // Issue #666: when the type has a stored `schema` (raw TOML of `[models.*]`
332
+ // blocks), `buildDatabaseTypeTomlData` parses + merges it into the same TOML
333
+ // object so the result round-trips as `[models.<Name>.fields.<field>]`.
334
+ function serializeDatabaseType(typeConfig, operations, ruleSetIdToName, options = {}) {
335
+ const data = buildDatabaseTypeTomlData(typeConfig, operations, ruleSetIdToName, options);
270
336
  return TOML.stringify(data);
271
337
  }
272
338
  function serializeEmailTemplate(template) {
@@ -345,17 +411,44 @@ export function parseDatabaseTypeToml(tomlData) {
345
411
  else if (typeSection.metadataAccess) {
346
412
  typeConfig.metadataAccess = typeSection.metadataAccess;
347
413
  }
414
+ if (typeSection.defaultAccess !== undefined) {
415
+ typeConfig.defaultAccess = typeSection.defaultAccess;
416
+ }
417
+ // autoPopulatedFields (issue #750) — pass through unchanged. Both the
418
+ // shorthand (`ownerId = "user.userId"`) and the verbose object form
419
+ // (`updatedBy = { value = "user.userId", on = ["create","update"] }`) are
420
+ // accepted; the server validates and normalizes at write time.
421
+ if (typeSection.autoPopulatedFields !== undefined) {
422
+ typeConfig.autoPopulatedFields = typeSection.autoPopulatedFields;
423
+ }
348
424
  if (tomlData.triggers) {
349
425
  typeConfig.triggers = tomlData.triggers;
350
426
  }
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
- }));
427
+ // Issue #666: extract the `[models.*]` subtree (if present) and re-serialize
428
+ // it to a TOML string for the server's `schema` field.
429
+ //
430
+ // We always set `schema` — either to the serialized TOML string or to
431
+ // `null` — so the sync push diff can detect "models removed from local
432
+ // file" and forward `schema: null` to the server. Without this, removing
433
+ // `[models.*]` blocks would silently no-op and the next pull would
434
+ // resurrect the stale server schema, making schema deletion via `sync push`
435
+ // impossible (codex review on PR #766, [P2]).
436
+ let schemaSerialized = null;
437
+ if (tomlData.models && typeof tomlData.models === "object") {
438
+ try {
439
+ schemaSerialized = TOML.stringify({ models: tomlData.models });
440
+ }
441
+ catch {
442
+ // Fall through — server-side parser will fail with a clear
443
+ // TOML_PARSE_ERROR if we somehow produced an invalid round-trip.
444
+ }
445
+ }
446
+ typeConfig.schema = schemaSerialized;
447
+ // `normalizeOperationFromToml` accepts both forms:
448
+ // - legacy: `definition = '{...}'` (JSON string), `params = '{...}'`
449
+ // - native: `[operations.definition]` table, `[[operations.params]]` rows
450
+ // and returns the JS shape the server expects.
451
+ const operations = (tomlData.operations || []).map((op) => normalizeOperationFromToml(op));
359
452
  return { typeConfig, operations };
360
453
  }
361
454
  function parseRuleSetToml(tomlData) {
@@ -397,9 +490,16 @@ export function parseCollectionTypeConfigToml(tomlData) {
397
490
  return result;
398
491
  }
399
492
  // Parsing helpers
493
+ //
494
+ // All 20+ TOML parse sites in this file route through `parseTomlFile()`.
495
+ // Workflow fragment expansion is wired in here so every push path (workflows,
496
+ // prompts, tests, etc.) gets it for free. For non-workflow TOMLs the
497
+ // expander is a no-op: if the parsed result has no `include` key, it returns
498
+ // the original object untouched.
400
499
  function parseTomlFile(filePath) {
401
500
  const content = readFileSync(filePath, "utf-8");
402
- return TOML.parse(content);
501
+ const parsed = TOML.parse(content);
502
+ return expandWorkflowTomlData(parsed, filePath);
403
503
  }
404
504
  /**
405
505
  * Paginate through a list endpoint, collecting all items.
@@ -1087,11 +1187,16 @@ Directory Structure:
1087
1187
  const filename = `${workflow.workflowKey}.toml`;
1088
1188
  const filePath = join(configDir, "workflows", filename);
1089
1189
  writeFileSync(filePath, serializeWorkflow(workflow, draft, configs || []));
1190
+ // Hash the expanded (post-fragment-splice) form so subsequent pushes
1191
+ // can detect fragment-only edits. See `computeExpandedContentHash`.
1192
+ // Pulled workflows never carry `include`, so the expander is a
1193
+ // no-op here — but we use the same canonical-JSON hash function so
1194
+ // the push-side comparison stays consistent.
1090
1195
  workflowEntities[workflow.workflowKey] = {
1091
1196
  id: workflow.workflowId,
1092
1197
  modifiedAt: workflow.modifiedAt || new Date().toISOString(),
1093
1198
  activeConfigId: workflow.activeConfigId,
1094
- contentHash: computeFileHash(filePath),
1199
+ contentHash: computeExpandedContentHash(parseTomlFile(filePath)),
1095
1200
  };
1096
1201
  info(` Wrote workflows/${filename}`);
1097
1202
  }
@@ -1167,7 +1272,17 @@ Directory Structure:
1167
1272
  for (const { typeConfig, operations } of databaseTypesWithOps) {
1168
1273
  const filename = `${typeConfig.databaseType}.toml`;
1169
1274
  const filePath = join(configDir, "database-types", filename);
1170
- writeFileSync(filePath, serializeDatabaseType(typeConfig, operations, ruleSetIdToName));
1275
+ // Preserve the existing file's per-op form (issue #752): if the
1276
+ // file already exists, read it raw and detect which operations
1277
+ // currently use JSON-string vs native nested-table form.
1278
+ const hints = existsSync(filePath)
1279
+ ? detectExistingOperationForms(readFileSync(filePath, "utf-8"))
1280
+ : undefined;
1281
+ writeFileSync(filePath, serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
1282
+ hints,
1283
+ defaultForm: "native",
1284
+ logger: (msg) => info(` ${msg}`),
1285
+ }));
1171
1286
  const opsEntities = {};
1172
1287
  for (const op of operations) {
1173
1288
  opsEntities[op.name] = {
@@ -1179,6 +1294,8 @@ Directory Structure:
1179
1294
  modifiedAt: typeConfig.modifiedAt || new Date().toISOString(),
1180
1295
  operations: Object.keys(opsEntities).length > 0 ? opsEntities : undefined,
1181
1296
  contentHash: computeFileHash(filePath),
1297
+ hasSchema: typeof typeConfig.schema === "string" &&
1298
+ typeConfig.schema.trim().length > 0,
1182
1299
  };
1183
1300
  info(` Wrote database-types/${filename}`);
1184
1301
  }
@@ -1277,6 +1394,7 @@ Directory Structure:
1277
1394
  .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
1278
1395
  .option("--dry-run", "Show what would be changed without applying")
1279
1396
  .option("--force", "Overwrite remote even if modified since last pull")
1397
+ .option("--accept-warnings", "Commit schema diffs that have operations with dynamic refs (issue #666 SCHEMA_HAS_UNCHECKABLE_OPS escape hatch)")
1280
1398
  .action(async (appId, options) => {
1281
1399
  const resolvedAppId = resolveAppId(appId, options);
1282
1400
  const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
@@ -1310,6 +1428,17 @@ Directory Structure:
1310
1428
  const changes = [];
1311
1429
  let skippedCount = 0;
1312
1430
  const conflicts = [];
1431
+ // Issue #666: per-class buckets for schema-feature errors. Each entry
1432
+ // groups failures by the code so the end-of-run report can print
1433
+ // them once with the friendly hint.
1434
+ const schemaErrors = {
1435
+ schemaRequired: [],
1436
+ opRefs: [],
1437
+ schemaBreaks: [],
1438
+ uncheckableOps: [],
1439
+ tomlParse: [],
1440
+ opsExist: [],
1441
+ };
1313
1442
  // Track name→ID mappings for resolving cross-references during push
1314
1443
  const ruleSetNameToId = new Map();
1315
1444
  const promptKeyToId = new Map();
@@ -1408,7 +1537,7 @@ Directory Structure:
1408
1537
  });
1409
1538
  }
1410
1539
  else {
1411
- throw err;
1540
+ throw wrapEntityError(err, "update", "rule set", fileKey);
1412
1541
  }
1413
1542
  }
1414
1543
  }
@@ -1419,12 +1548,18 @@ Directory Structure:
1419
1548
  // Create new rule set
1420
1549
  changes.push({ type: "rule-set", action: "create", key: fileKey });
1421
1550
  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
- });
1551
+ let created;
1552
+ try {
1553
+ created = await client.createRuleSet(resolvedAppId, {
1554
+ name: ruleSetData.name,
1555
+ resourceType: ruleSetData.resourceType,
1556
+ rules: ruleSetData.rules,
1557
+ description: ruleSetData.description,
1558
+ });
1559
+ }
1560
+ catch (err) {
1561
+ throw wrapEntityError(err, "create", "rule set", fileKey);
1562
+ }
1428
1563
  info(` Created rule set: ${fileKey}`);
1429
1564
  if (syncState && created?.ruleSetId) {
1430
1565
  if (!syncState.entities.ruleSets) {
@@ -1494,7 +1629,7 @@ Directory Structure:
1494
1629
  });
1495
1630
  }
1496
1631
  else {
1497
- throw err;
1632
+ throw wrapEntityError(err, "update", "integration", key);
1498
1633
  }
1499
1634
  }
1500
1635
  }
@@ -1502,18 +1637,23 @@ Directory Structure:
1502
1637
  else {
1503
1638
  changes.push({ type: "integration", action: "create", key });
1504
1639
  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
- };
1640
+ try {
1641
+ const created = await client.createIntegration(resolvedAppId, payload);
1642
+ info(` Created integration: ${key}`);
1643
+ // Add new entity to sync state
1644
+ if (syncState && created?.integrationId && created?.modifiedAt) {
1645
+ if (!syncState.entities.integrations) {
1646
+ syncState.entities.integrations = {};
1647
+ }
1648
+ syncState.entities.integrations[key] = {
1649
+ id: created.integrationId,
1650
+ modifiedAt: created.modifiedAt,
1651
+ contentHash: computeFileHash(filePath),
1652
+ };
1653
+ }
1654
+ }
1655
+ catch (err) {
1656
+ throw wrapEntityError(err, "create", "integration", key);
1517
1657
  }
1518
1658
  }
1519
1659
  }
@@ -1580,7 +1720,7 @@ Directory Structure:
1580
1720
  });
1581
1721
  }
1582
1722
  else {
1583
- throw err;
1723
+ throw wrapEntityError(err, "update", "webhook", key);
1584
1724
  }
1585
1725
  }
1586
1726
  }
@@ -1588,17 +1728,22 @@ Directory Structure:
1588
1728
  else {
1589
1729
  changes.push({ type: "webhook", action: "create", key });
1590
1730
  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
- };
1731
+ try {
1732
+ const created = await client.createWebhook(resolvedAppId, payload);
1733
+ info(` Created webhook: ${key}`);
1734
+ if (syncState && created?.webhookId && created?.modifiedAt) {
1735
+ if (!syncState.entities.webhooks) {
1736
+ syncState.entities.webhooks = {};
1737
+ }
1738
+ syncState.entities.webhooks[key] = {
1739
+ id: created.webhookId,
1740
+ modifiedAt: created.modifiedAt,
1741
+ contentHash: computeFileHash(filePath),
1742
+ };
1743
+ }
1744
+ }
1745
+ catch (err) {
1746
+ throw wrapEntityError(err, "create", "webhook", key);
1602
1747
  }
1603
1748
  }
1604
1749
  }
@@ -1648,24 +1793,29 @@ Directory Structure:
1648
1793
  }
1649
1794
  }
1650
1795
  catch (err) {
1651
- throw err;
1796
+ throw wrapEntityError(err, "update", "cron trigger", key);
1652
1797
  }
1653
1798
  }
1654
1799
  }
1655
1800
  else {
1656
1801
  changes.push({ type: "cron-trigger", action: "create", key });
1657
1802
  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
- };
1803
+ try {
1804
+ const created = await client.createCronTrigger(resolvedAppId, payload);
1805
+ info(` Created cron trigger: ${key}`);
1806
+ if (syncState && created?.triggerId && created?.modifiedAt) {
1807
+ if (!syncState.entities.cronTriggers) {
1808
+ syncState.entities.cronTriggers = {};
1809
+ }
1810
+ syncState.entities.cronTriggers[key] = {
1811
+ id: created.triggerId,
1812
+ modifiedAt: created.modifiedAt,
1813
+ contentHash: computeFileHash(filePath),
1814
+ };
1815
+ }
1816
+ }
1817
+ catch (err) {
1818
+ throw wrapEntityError(err, "create", "cron trigger", key);
1669
1819
  }
1670
1820
  }
1671
1821
  }
@@ -1743,7 +1893,7 @@ Directory Structure:
1743
1893
  }
1744
1894
  }
1745
1895
  else {
1746
- throw err;
1896
+ throw wrapEntityError(err, "create", "blob bucket", key);
1747
1897
  }
1748
1898
  }
1749
1899
  }
@@ -1857,7 +2007,7 @@ Directory Structure:
1857
2007
  });
1858
2008
  }
1859
2009
  else {
1860
- throw err;
2010
+ throw wrapEntityError(err, "update", "prompt", key);
1861
2011
  }
1862
2012
  }
1863
2013
  }
@@ -1867,65 +2017,70 @@ Directory Structure:
1867
2017
  const firstConfig = configs[0] || {};
1868
2018
  changes.push({ type: "prompt", action: "create", key });
1869
2019
  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);
2020
+ try {
2021
+ const created = await client.createPrompt(resolvedAppId, {
2022
+ promptKey: key,
2023
+ displayName: prompt.displayName || key,
2024
+ description: prompt.description,
2025
+ provider: firstConfig.provider || "openrouter",
2026
+ model: firstConfig.model || "google/gemini-2.0-flash-001",
2027
+ systemPrompt: firstConfig.systemPrompt,
2028
+ userPromptTemplate: firstConfig.userPromptTemplate || "{{ input }}",
2029
+ temperature: firstConfig.temperature,
2030
+ maxTokens: firstConfig.maxTokens,
2031
+ outputFormat: firstConfig.outputFormat,
2032
+ inputSchema: prompt.inputSchema,
2033
+ });
2034
+ info(` Created prompt: ${key}`);
2035
+ // Add new entity to sync state
2036
+ if (syncState && created?.promptId && created?.modifiedAt) {
2037
+ if (!syncState.entities.prompts) {
2038
+ syncState.entities.prompts = {};
2039
+ }
2040
+ syncState.entities.prompts[key] = {
2041
+ id: created.promptId,
2042
+ modifiedAt: created.modifiedAt,
2043
+ contentHash: computeFileHash(filePath),
2044
+ };
1902
2045
  }
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);
2046
+ // Track prompt key→ID and config name→ID
2047
+ if (created?.promptId) {
2048
+ promptKeyToId.set(key, created.promptId);
2049
+ }
2050
+ if (created?.configs) {
2051
+ for (const config of created.configs) {
2052
+ promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
1922
2053
  }
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);
2054
+ }
2055
+ // Create additional configs (configs[1..n]) that weren't included in the initial create
2056
+ if (created?.promptId && configs.length > 1) {
2057
+ for (let i = 1; i < configs.length; i++) {
2058
+ const extraConfig = configs[i];
2059
+ const extraCreated = await client.createPromptConfig(resolvedAppId, created.promptId, {
2060
+ configName: extraConfig.name || `config-${i + 1}`,
2061
+ description: extraConfig.description,
2062
+ provider: extraConfig.provider || firstConfig.provider || "openrouter",
2063
+ model: extraConfig.model || firstConfig.model || "google/gemini-2.0-flash-001",
2064
+ systemPrompt: extraConfig.systemPrompt,
2065
+ userPromptTemplate: extraConfig.userPromptTemplate,
2066
+ temperature: extraConfig.temperature,
2067
+ maxTokens: extraConfig.maxTokens,
2068
+ outputFormat: extraConfig.outputFormat,
2069
+ });
2070
+ if (extraCreated?.configId) {
2071
+ const configName = extraConfig.name || `config-${i + 1}`;
2072
+ promptConfigNameToId.set(`${key}#${configName}`, extraCreated.configId);
2073
+ }
2074
+ // Activate this config if it was the active one
2075
+ if (extraConfig.isActive && extraCreated?.configId) {
2076
+ await client.activatePromptConfig(resolvedAppId, created.promptId, extraCreated.configId);
2077
+ }
1926
2078
  }
2079
+ info(` Created ${configs.length - 1} additional config(s) for prompt: ${key}`);
1927
2080
  }
1928
- info(` Created ${configs.length - 1} additional config(s) for prompt: ${key}`);
2081
+ }
2082
+ catch (err) {
2083
+ throw wrapEntityError(err, "create", "prompt", key);
1929
2084
  }
1930
2085
  }
1931
2086
  }
@@ -1938,13 +2093,27 @@ Directory Structure:
1938
2093
  for (const file of files) {
1939
2094
  const filePath = join(workflowsDir, file);
1940
2095
  const tomlData = parseTomlFile(filePath);
2096
+ // Issue #685: reject misnested headers (e.g.
2097
+ // [steps.<id>.request]) before pushing. The runtime silently
2098
+ // ignores fields outside the allowlist, so this is the only
2099
+ // place to catch the footgun. Validation happens BEFORE the
2100
+ // skip-if-unchanged check so a previously-pushed-but-broken
2101
+ // file gets a clear diagnostic on every push attempt.
2102
+ const tomlErrors = validateWorkflowToml(tomlData);
2103
+ if (tomlErrors.length > 0) {
2104
+ error(formatWorkflowTomlErrors(filePath, tomlErrors));
2105
+ process.exit(1);
2106
+ }
1941
2107
  const workflow = tomlData.workflow || {};
1942
2108
  const key = workflow.key || basename(file, ".toml");
1943
2109
  const steps = tomlData.steps || [];
1944
2110
  const existingId = syncState?.entities?.workflows?.[key]?.id;
1945
2111
  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)) {
2112
+ // Skip if file hasn't changed since last sync. Use the expanded
2113
+ // content hash (post fragment splice) so that edits to included
2114
+ // `workflow-fragments/*.toml` files invalidate the push-skip cache
2115
+ // for any workflow that references them. See `computeExpandedContentHash`.
2116
+ if (!options.force && existingId && !shouldPushExpandedFile(tomlData, syncState?.entities?.workflows?.[key]?.contentHash)) {
1948
2117
  skippedCount++;
1949
2118
  // Only fetch config name→ID mappings if test cases exist for this workflow
1950
2119
  const workflowTestsDir = getTestsDir(configDir, "workflow", key);
@@ -1978,7 +2147,10 @@ Directory Structure:
1978
2147
  inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
1979
2148
  outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : null,
1980
2149
  }, expectedModifiedAt);
1981
- // Update active configuration steps (or draft for legacy)
2150
+ // Update active configuration steps (or draft for legacy).
2151
+ // Issue #687: name the slot we touched so the dev-loop
2152
+ // user can confirm before previewing.
2153
+ let updateSlotLabel = "active config";
1982
2154
  if (existingActiveConfigId) {
1983
2155
  await client.updateWorkflowConfig(resolvedAppId, existingId, existingActiveConfigId, {
1984
2156
  steps,
@@ -1990,12 +2162,14 @@ Directory Structure:
1990
2162
  steps,
1991
2163
  inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
1992
2164
  });
2165
+ updateSlotLabel = "draft (legacy)";
1993
2166
  }
1994
- info(` Updated workflow: ${key}`);
1995
- // Update sync state with new modifiedAt
2167
+ info(` Updated workflow: ${key} (${updateSlotLabel})`);
2168
+ // Update sync state with new modifiedAt. Store the *expanded*
2169
+ // content hash so future fragment-only edits are detected.
1996
2170
  if (syncState?.entities?.workflows?.[key] && updated?.workflow?.modifiedAt) {
1997
2171
  syncState.entities.workflows[key].modifiedAt = updated.workflow.modifiedAt;
1998
- syncState.entities.workflows[key].contentHash = computeFileHash(filePath);
2172
+ syncState.entities.workflows[key].contentHash = computeExpandedContentHash(tomlData);
1999
2173
  }
2000
2174
  // Fetch full workflow to get config name→ID mappings
2001
2175
  // (updateWorkflow response doesn't include configs)
@@ -2016,7 +2190,7 @@ Directory Structure:
2016
2190
  });
2017
2191
  }
2018
2192
  else {
2019
- throw err;
2193
+ throw wrapEntityError(err, "update", "workflow", key);
2020
2194
  }
2021
2195
  }
2022
2196
  }
@@ -2025,63 +2199,68 @@ Directory Structure:
2025
2199
  // Create new workflow (automatically creates default config)
2026
2200
  changes.push({ type: "workflow", action: "create", key });
2027
2201
  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);
2202
+ try {
2203
+ const created = await client.createWorkflow(resolvedAppId, {
2204
+ workflowKey: key,
2205
+ name: workflow.name || key,
2206
+ description: workflow.description,
2207
+ steps,
2208
+ inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : undefined,
2209
+ outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : undefined,
2210
+ accessRule: workflow.accessRule || undefined,
2211
+ perUserMaxRunning: workflow.perUserMaxRunning,
2212
+ perUserMaxQueued: workflow.perUserMaxQueued,
2213
+ dequeueOrder: workflow.dequeueOrder,
2214
+ });
2215
+ info(` Created workflow: ${key}`);
2216
+ // Add new entity to sync state (including activeConfigId)
2217
+ if (syncState && created?.workflow?.workflowId && created?.workflow?.modifiedAt) {
2218
+ if (!syncState.entities.workflows) {
2219
+ syncState.entities.workflows = {};
2220
+ }
2221
+ syncState.entities.workflows[key] = {
2222
+ id: created.workflow.workflowId,
2223
+ modifiedAt: created.workflow.modifiedAt,
2224
+ activeConfigId: created.workflow.activeConfigId,
2225
+ contentHash: computeExpandedContentHash(tomlData),
2226
+ };
2057
2227
  }
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);
2228
+ // Track config name→ID mappings
2229
+ if (created?.configs) {
2230
+ for (const config of created.configs) {
2231
+ workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
2073
2232
  }
2074
2233
  }
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}`);
2234
+ const workflowId = created?.workflow?.workflowId;
2235
+ const tomlConfigs = tomlData.configs || [];
2236
+ // Create additional workflow configs (configs[1..n]) beyond the default
2237
+ if (workflowId && tomlConfigs.length > 1) {
2238
+ for (let i = 1; i < tomlConfigs.length; i++) {
2239
+ const extraConfig = tomlConfigs[i];
2240
+ const extraCreated = await client.createWorkflowConfig(resolvedAppId, workflowId, {
2241
+ configName: extraConfig.name || `config-${i + 1}`,
2242
+ description: extraConfig.description,
2243
+ steps,
2244
+ });
2245
+ if (extraCreated?.configId) {
2246
+ const configName = extraConfig.name || `config-${i + 1}`;
2247
+ workflowConfigNameToId.set(`${key}#${configName}`, extraCreated.configId);
2248
+ }
2249
+ }
2250
+ info(` Created ${tomlConfigs.length - 1} additional config(s) for workflow: ${key}`);
2251
+ }
2252
+ // Activate the correct config based on activeConfigName from TOML
2253
+ if (workflowId && workflow.activeConfigName) {
2254
+ const activeConfigId = workflowConfigNameToId.get(`${key}#${workflow.activeConfigName}`);
2255
+ if (activeConfigId && activeConfigId !== created?.workflow?.activeConfigId) {
2256
+ await client.activateWorkflowConfig(resolvedAppId, workflowId, activeConfigId);
2257
+ info(` Activated config "${workflow.activeConfigName}" for workflow: ${key}`);
2258
+ }
2083
2259
  }
2084
2260
  }
2261
+ catch (err) {
2262
+ throw wrapEntityError(err, "create", "workflow", key);
2263
+ }
2085
2264
  }
2086
2265
  }
2087
2266
  }
@@ -2092,11 +2271,31 @@ Directory Structure:
2092
2271
  const files = readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml"));
2093
2272
  for (const file of files) {
2094
2273
  const filePath = join(dbTypesDir, file);
2274
+ const rawToml = readFileSync(filePath, "utf-8");
2095
2275
  const tomlData = parseTomlFile(filePath);
2096
2276
  const { typeConfig, operations } = parseDatabaseTypeToml(tomlData);
2097
2277
  const dbType = typeConfig.databaseType || basename(file, ".toml");
2098
2278
  // Resolve ruleSetName → ruleSetId if using key-based reference
2099
2279
  resolveRuleSetReference(typeConfig, ruleSetNameToId, `database type ${dbType}`);
2280
+ // $params validator (issue #752): every `$params.X` reference
2281
+ // inside `definition` must correspond to a declared param in
2282
+ // `[[operations.params]]`. Catches typos like `$params.proectId`
2283
+ // at push time with the TOML file:line of the offending op block.
2284
+ const validation = validateOperations({
2285
+ filePath,
2286
+ rawToml,
2287
+ operations,
2288
+ });
2289
+ for (const w of validation.warnings) {
2290
+ warn(` ${formatIssue(w)}`);
2291
+ }
2292
+ if (validation.errors.length > 0) {
2293
+ for (const e of validation.errors) {
2294
+ error(` ${formatIssue(e)}`);
2295
+ }
2296
+ error(`Aborting push: ${validation.errors.length} unresolved $params reference(s) in ${file}.`);
2297
+ process.exit(1);
2298
+ }
2100
2299
  const existingEntry = syncState?.entities?.databaseTypes?.[dbType];
2101
2300
  // Skip if file hasn't changed since last sync
2102
2301
  if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
@@ -2120,13 +2319,41 @@ Directory Structure:
2120
2319
  updateData.triggers = typeConfig.triggers || null;
2121
2320
  if ("metadataAccess" in typeConfig)
2122
2321
  updateData.metadataAccess = typeConfig.metadataAccess || null;
2322
+ if ("defaultAccess" in typeConfig)
2323
+ updateData.defaultAccess = typeConfig.defaultAccess || null;
2324
+ if ("autoPopulatedFields" in typeConfig) {
2325
+ updateData.autoPopulatedFields =
2326
+ typeConfig.autoPopulatedFields || null;
2327
+ }
2328
+ // Issue #666: forward `schema` when the local TOML declares
2329
+ // one (a set/update), OR when the server had one at last
2330
+ // sync and the local file no longer does (a deletion —
2331
+ // `schema: null` clears it server-side; codex review gap on
2332
+ // PR #766). When the type never had a schema and still
2333
+ // doesn't, omit it so an operations-only edit doesn't
2334
+ // register as an empty type-level update (issue #369).
2335
+ const localHasSchema = typeof typeConfig.schema === "string" &&
2336
+ typeConfig.schema.trim().length > 0;
2337
+ if (localHasSchema) {
2338
+ updateData.schema = typeConfig.schema;
2339
+ }
2340
+ else if (existingEntry.hasSchema) {
2341
+ updateData.schema = null;
2342
+ }
2123
2343
  if (Object.keys(updateData).length > 0) {
2124
2344
  changes.push({ type: "database-type", action: "update", key: dbType });
2125
- const updated = await client.updateDatabaseTypeConfig(resolvedAppId, dbType, updateData, expectedModifiedAt);
2345
+ const updated = await client.updateDatabaseTypeConfig(resolvedAppId, dbType, updateData, expectedModifiedAt, {
2346
+ dryRun: !!options.dryRun,
2347
+ acceptWarnings: !!options.acceptWarnings,
2348
+ });
2126
2349
  info(` Updated database type: ${dbType}`);
2127
2350
  if (syncState?.entities?.databaseTypes?.[dbType] && updated?.modifiedAt) {
2128
2351
  syncState.entities.databaseTypes[dbType].modifiedAt = updated.modifiedAt;
2129
2352
  syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
2353
+ syncState.entities.databaseTypes[dbType].hasSchema =
2354
+ "schema" in updateData
2355
+ ? updateData.schema !== null
2356
+ : syncState.entities.databaseTypes[dbType].hasSchema;
2130
2357
  }
2131
2358
  }
2132
2359
  }
@@ -2139,8 +2366,41 @@ Directory Structure:
2139
2366
  localModifiedAt: expectedModifiedAt || "unknown",
2140
2367
  });
2141
2368
  }
2369
+ else if (err instanceof SchemaBreaksOpsError) {
2370
+ schemaErrors.schemaBreaks.push({
2371
+ type: "database-type",
2372
+ key: dbType,
2373
+ operations: err.operations,
2374
+ message: err.message,
2375
+ });
2376
+ }
2377
+ else if (err instanceof SchemaHasUncheckableOpsError) {
2378
+ schemaErrors.uncheckableOps.push({
2379
+ type: "database-type",
2380
+ key: dbType,
2381
+ operations: err.operations,
2382
+ message: err.message,
2383
+ });
2384
+ }
2385
+ else if (err instanceof TomlParseError) {
2386
+ schemaErrors.tomlParse.push({
2387
+ type: "database-type",
2388
+ key: dbType,
2389
+ line: err.line,
2390
+ column: err.column,
2391
+ message: err.message,
2392
+ });
2393
+ }
2394
+ else if (err instanceof OpsExistError) {
2395
+ schemaErrors.opsExist.push({
2396
+ type: "database-type",
2397
+ key: dbType,
2398
+ opCount: err.opCount,
2399
+ message: err.message,
2400
+ });
2401
+ }
2142
2402
  else {
2143
- throw err;
2403
+ throw wrapEntityError(err, "update", "database type", dbType);
2144
2404
  }
2145
2405
  }
2146
2406
  }
@@ -2148,7 +2408,10 @@ Directory Structure:
2148
2408
  // In dry-run mode, still report the change iff we would actually PATCH.
2149
2409
  const wouldUpdate = "ruleSetId" in typeConfig ||
2150
2410
  "triggers" in typeConfig ||
2151
- "metadataAccess" in typeConfig;
2411
+ "metadataAccess" in typeConfig ||
2412
+ "defaultAccess" in typeConfig ||
2413
+ "autoPopulatedFields" in typeConfig ||
2414
+ "schema" in typeConfig;
2152
2415
  if (wouldUpdate) {
2153
2416
  changes.push({ type: "database-type", action: "update", key: dbType });
2154
2417
  }
@@ -2167,17 +2430,35 @@ Directory Structure:
2167
2430
  createData.triggers = typeConfig.triggers;
2168
2431
  if (typeConfig.metadataAccess)
2169
2432
  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 = {};
2433
+ if (typeConfig.defaultAccess)
2434
+ createData.defaultAccess = typeConfig.defaultAccess;
2435
+ if (typeConfig.autoPopulatedFields)
2436
+ createData.autoPopulatedFields = typeConfig.autoPopulatedFields;
2437
+ // Issue #666 addendum A2: forward `schema` on POST so a
2438
+ // single sync push can land both the type + its schema in
2439
+ // one round-trip (instead of POST → PATCH). The op-edit gate
2440
+ // itself no longer requires a schema on a fresh type, but
2441
+ // landing the schema up front keeps subsequent op-pushes
2442
+ // validating against the intended shape.
2443
+ if (typeConfig.schema)
2444
+ createData.schema = typeConfig.schema;
2445
+ try {
2446
+ const created = await client.createDatabaseTypeConfig(resolvedAppId, createData);
2447
+ info(` Created database type: ${dbType}`);
2448
+ if (syncState) {
2449
+ if (!syncState.entities.databaseTypes) {
2450
+ syncState.entities.databaseTypes = {};
2451
+ }
2452
+ syncState.entities.databaseTypes[dbType] = {
2453
+ databaseType: dbType,
2454
+ modifiedAt: created?.modifiedAt || new Date().toISOString(),
2455
+ contentHash: computeFileHash(filePath),
2456
+ hasSchema: !!createData.schema,
2457
+ };
2175
2458
  }
2176
- syncState.entities.databaseTypes[dbType] = {
2177
- databaseType: dbType,
2178
- modifiedAt: created?.modifiedAt || new Date().toISOString(),
2179
- contentHash: computeFileHash(filePath),
2180
- };
2459
+ }
2460
+ catch (err) {
2461
+ throw wrapEntityError(err, "create", "database type", dbType);
2181
2462
  }
2182
2463
  }
2183
2464
  }
@@ -2195,6 +2476,10 @@ Directory Structure:
2195
2476
  : existingOp.modifiedAt;
2196
2477
  try {
2197
2478
  const updated = await client.updateDatabaseTypeOperation(resolvedAppId, dbType, op.name, {
2479
+ // Include `type` so the server can detect/apply
2480
+ // type-in-place changes (issue #692). When the type
2481
+ // matches the stored value, this is a no-op.
2482
+ type: op.type,
2198
2483
  modelName: op.modelName,
2199
2484
  access: op.access,
2200
2485
  definition: op.definition,
@@ -2214,8 +2499,23 @@ Directory Structure:
2214
2499
  localModifiedAt: expectedOpModifiedAt || "unknown",
2215
2500
  });
2216
2501
  }
2502
+ else if (err instanceof SchemaRequiredError) {
2503
+ schemaErrors.schemaRequired.push({
2504
+ type: "operation",
2505
+ key: `${dbType}/${op.name}`,
2506
+ message: err.message,
2507
+ });
2508
+ }
2509
+ else if (err instanceof OperationRefError) {
2510
+ schemaErrors.opRefs.push({
2511
+ type: "operation",
2512
+ key: `${dbType}/${op.name}`,
2513
+ refs: err.refs,
2514
+ message: err.message,
2515
+ });
2516
+ }
2217
2517
  else {
2218
- throw err;
2518
+ throw wrapEntityError(err, "update", "operation", `${dbType}/${op.name}`);
2219
2519
  }
2220
2520
  }
2221
2521
  }
@@ -2224,22 +2524,44 @@ Directory Structure:
2224
2524
  // Create new operation
2225
2525
  changes.push({ type: "operation", action: "create", key: `${dbType}/${op.name}` });
2226
2526
  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 = {};
2527
+ try {
2528
+ const created = await client.createDatabaseTypeOperation(resolvedAppId, dbType, {
2529
+ name: op.name,
2530
+ type: op.type,
2531
+ modelName: op.modelName,
2532
+ access: op.access,
2533
+ definition: op.definition,
2534
+ params: op.params,
2535
+ });
2536
+ info(` Created operation: ${dbType}/${op.name}`);
2537
+ if (syncState?.entities?.databaseTypes?.[dbType]) {
2538
+ if (!syncState.entities.databaseTypes[dbType].operations) {
2539
+ syncState.entities.databaseTypes[dbType].operations = {};
2540
+ }
2541
+ syncState.entities.databaseTypes[dbType].operations[op.name] = {
2542
+ modifiedAt: created?.modifiedAt || new Date().toISOString(),
2543
+ };
2544
+ }
2545
+ }
2546
+ catch (err) {
2547
+ if (err instanceof SchemaRequiredError) {
2548
+ schemaErrors.schemaRequired.push({
2549
+ type: "operation",
2550
+ key: `${dbType}/${op.name}`,
2551
+ message: err.message,
2552
+ });
2553
+ }
2554
+ else if (err instanceof OperationRefError) {
2555
+ schemaErrors.opRefs.push({
2556
+ type: "operation",
2557
+ key: `${dbType}/${op.name}`,
2558
+ refs: err.refs,
2559
+ message: err.message,
2560
+ });
2561
+ }
2562
+ else {
2563
+ throw wrapEntityError(err, "create", "operation", `${dbType}/${op.name}`);
2239
2564
  }
2240
- syncState.entities.databaseTypes[dbType].operations[op.name] = {
2241
- modifiedAt: created?.modifiedAt || new Date().toISOString(),
2242
- };
2243
2565
  }
2244
2566
  }
2245
2567
  }
@@ -2249,10 +2571,15 @@ Directory Structure:
2249
2571
  if (!tomlOpNames.has(existingOpName)) {
2250
2572
  changes.push({ type: "operation", action: "delete", key: `${dbType}/${existingOpName}` });
2251
2573
  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];
2574
+ try {
2575
+ await client.deleteDatabaseTypeOperation(resolvedAppId, dbType, existingOpName);
2576
+ info(` Deleted operation: ${dbType}/${existingOpName}`);
2577
+ if (syncState?.entities?.databaseTypes?.[dbType]?.operations) {
2578
+ delete syncState.entities.databaseTypes[dbType].operations[existingOpName];
2579
+ }
2580
+ }
2581
+ catch (err) {
2582
+ throw wrapEntityError(err, "delete", "operation", `${dbType}/${existingOpName}`);
2256
2583
  }
2257
2584
  }
2258
2585
  }
@@ -2304,7 +2631,7 @@ Directory Structure:
2304
2631
  });
2305
2632
  }
2306
2633
  else {
2307
- throw err;
2634
+ throw wrapEntityError(err, "update", "group type config", groupType);
2308
2635
  }
2309
2636
  }
2310
2637
  }
@@ -2313,20 +2640,25 @@ Directory Structure:
2313
2640
  // Create new group type config
2314
2641
  changes.push({ type: "group-type-config", action: "create", key: groupType });
2315
2642
  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 = {};
2643
+ try {
2644
+ const created = await client.createGroupTypeConfig(resolvedAppId, {
2645
+ groupType,
2646
+ ruleSetId: configData.ruleSetId || undefined,
2647
+ autoAddCreator: configData.autoAddCreator,
2648
+ });
2649
+ info(` Created group type config: ${groupType}`);
2650
+ if (syncState) {
2651
+ if (!syncState.entities.groupTypeConfigs) {
2652
+ syncState.entities.groupTypeConfigs = {};
2653
+ }
2654
+ syncState.entities.groupTypeConfigs[groupType] = {
2655
+ modifiedAt: created?.modifiedAt || new Date().toISOString(),
2656
+ contentHash: computeFileHash(filePath),
2657
+ };
2325
2658
  }
2326
- syncState.entities.groupTypeConfigs[groupType] = {
2327
- modifiedAt: created?.modifiedAt || new Date().toISOString(),
2328
- contentHash: computeFileHash(filePath),
2329
- };
2659
+ }
2660
+ catch (err) {
2661
+ throw wrapEntityError(err, "create", "group type config", groupType);
2330
2662
  }
2331
2663
  }
2332
2664
  }
@@ -2376,7 +2708,7 @@ Directory Structure:
2376
2708
  });
2377
2709
  }
2378
2710
  else {
2379
- throw err;
2711
+ throw wrapEntityError(err, "update", "collection type config", collectionType);
2380
2712
  }
2381
2713
  }
2382
2714
  }
@@ -2385,19 +2717,24 @@ Directory Structure:
2385
2717
  // Create new collection type config
2386
2718
  changes.push({ type: "collection-type-config", action: "create", key: collectionType });
2387
2719
  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 = {};
2720
+ try {
2721
+ const created = await client.createCollectionTypeConfig(resolvedAppId, {
2722
+ collectionType,
2723
+ ruleSetId: configData.ruleSetId || undefined,
2724
+ });
2725
+ info(` Created collection type config: ${collectionType}`);
2726
+ if (syncState) {
2727
+ if (!syncState.entities.collectionTypeConfigs) {
2728
+ syncState.entities.collectionTypeConfigs = {};
2729
+ }
2730
+ syncState.entities.collectionTypeConfigs[collectionType] = {
2731
+ modifiedAt: created?.modifiedAt || new Date().toISOString(),
2732
+ contentHash: computeFileHash(filePath),
2733
+ };
2396
2734
  }
2397
- syncState.entities.collectionTypeConfigs[collectionType] = {
2398
- modifiedAt: created?.modifiedAt || new Date().toISOString(),
2399
- contentHash: computeFileHash(filePath),
2400
- };
2735
+ }
2736
+ catch (err) {
2737
+ throw wrapEntityError(err, "create", "collection type config", collectionType);
2401
2738
  }
2402
2739
  }
2403
2740
  }
@@ -2478,33 +2815,118 @@ Directory Structure:
2478
2815
  console.log(` ${color(change.action)} ${change.type}: ${change.key}`);
2479
2816
  }
2480
2817
  }
2481
- else if (conflicts.length > 0) {
2818
+ else if (conflicts.length > 0 ||
2819
+ schemaErrors.schemaRequired.length > 0 ||
2820
+ schemaErrors.opRefs.length > 0 ||
2821
+ schemaErrors.schemaBreaks.length > 0 ||
2822
+ schemaErrors.uncheckableOps.length > 0 ||
2823
+ schemaErrors.tomlParse.length > 0 ||
2824
+ schemaErrors.opsExist.length > 0) {
2482
2825
  // 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)}`);
2826
+ if (conflicts.length > 0) {
2827
+ console.log();
2828
+ warn(`${conflicts.length} conflict(s) detected:`);
2829
+ console.log();
2830
+ for (const conflict of conflicts) {
2831
+ console.log(` ${chalk.red("CONFLICT")} ${conflict.type}: ${chalk.bold(conflict.key)}`);
2832
+ console.log(` Local last sync: ${chalk.dim(conflict.localModifiedAt)}`);
2833
+ console.log(` Server modified: ${chalk.yellow(conflict.serverModifiedAt)}`);
2834
+ }
2835
+ console.log();
2836
+ warn("These entities were modified on the server since your last pull.");
2837
+ info("Options:");
2838
+ console.log(` 1. Run ${chalk.cyan("primitive sync pull")} to get latest changes`);
2839
+ console.log(` 2. Run ${chalk.cyan("primitive sync push --force")} to overwrite server`);
2840
+ console.log();
2841
+ }
2842
+ // Report schema-feature errors (issue #666).
2843
+ if (schemaErrors.schemaRequired.length > 0) {
2844
+ console.log();
2845
+ warn(`${schemaErrors.schemaRequired.length} operation push(es) blocked: type has no schema set.`);
2846
+ for (const e of schemaErrors.schemaRequired) {
2847
+ const type = e.key.split("/")[0];
2848
+ console.log(` ${chalk.red("SCHEMA_REQUIRED")} ${e.key}`);
2849
+ console.log(` Run ${chalk.cyan(`primitive databases schema generate ${type}`)} to scaffold one, then retry.`);
2850
+ }
2851
+ console.log();
2852
+ }
2853
+ if (schemaErrors.opRefs.length > 0) {
2854
+ console.log();
2855
+ warn(`${schemaErrors.opRefs.length} operation push(es) blocked: unresolved references.`);
2856
+ for (const e of schemaErrors.opRefs) {
2857
+ console.log(` ${chalk.red("OPERATION_REFERENCES_UNDEFINED")} ${e.key}`);
2858
+ for (const ref of e.refs) {
2859
+ console.log(` - ${chalk.yellow(ref.ref)} at ${chalk.dim(ref.location)}`);
2860
+ }
2861
+ }
2862
+ console.log();
2863
+ }
2864
+ if (schemaErrors.schemaBreaks.length > 0) {
2865
+ console.log();
2866
+ warn(`${schemaErrors.schemaBreaks.length} schema push(es) blocked: existing operations would break.`);
2867
+ for (const e of schemaErrors.schemaBreaks) {
2868
+ console.log(` ${chalk.red("SCHEMA_BREAKS_OPERATIONS")} ${e.key}`);
2869
+ for (const op of e.operations) {
2870
+ console.log(` ${chalk.bold(op.operation)}`);
2871
+ for (const ref of op.refs) {
2872
+ console.log(` - ${chalk.yellow(ref.ref)} at ${chalk.dim(ref.location)}`);
2873
+ }
2874
+ }
2875
+ }
2876
+ console.log();
2877
+ }
2878
+ if (schemaErrors.uncheckableOps.length > 0) {
2879
+ console.log();
2880
+ warn(`${schemaErrors.uncheckableOps.length} schema push(es) blocked: operations with dynamic refs.`);
2881
+ for (const e of schemaErrors.uncheckableOps) {
2882
+ console.log(` ${chalk.red("SCHEMA_HAS_UNCHECKABLE_OPS")} ${e.key}`);
2883
+ for (const op of e.operations) {
2884
+ console.log(` ${chalk.bold(op.operation)}`);
2885
+ for (const loc of op.locations) {
2886
+ console.log(` - ${chalk.dim(loc)}`);
2887
+ }
2888
+ }
2889
+ }
2890
+ info(`Re-run with ${chalk.cyan("--accept-warnings")} to commit anyway.`);
2891
+ console.log();
2892
+ }
2893
+ if (schemaErrors.tomlParse.length > 0) {
2894
+ console.log();
2895
+ warn(`${schemaErrors.tomlParse.length} TOML parse error(s):`);
2896
+ for (const e of schemaErrors.tomlParse) {
2897
+ const loc = e.line ? ` (line ${e.line}${e.column ? `, col ${e.column}` : ""})` : "";
2898
+ console.log(` ${chalk.red("TOML_PARSE_ERROR")} ${e.key}${loc}`);
2899
+ console.log(` ${e.message}`);
2900
+ }
2901
+ console.log();
2902
+ }
2903
+ if (schemaErrors.opsExist.length > 0) {
2904
+ console.log();
2905
+ warn(`${schemaErrors.opsExist.length} schema delete(s) blocked: operations still registered.`);
2906
+ for (const e of schemaErrors.opsExist) {
2907
+ console.log(` ${chalk.red("OPS_EXIST")} ${e.key} — ${e.opCount} op(s)`);
2908
+ console.log(` Delete or migrate the affected operations before clearing the schema.`);
2909
+ }
2910
+ console.log();
2490
2911
  }
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
2912
  // Update sync state for non-conflicting changes
2498
2913
  if (syncState) {
2499
2914
  syncState.lastSyncedAt = new Date().toISOString();
2500
2915
  saveSyncState(configDir, syncState);
2501
2916
  }
2502
- const successCount = changes.length - conflicts.length;
2917
+ const totalBlocked = conflicts.length +
2918
+ schemaErrors.schemaRequired.length +
2919
+ schemaErrors.opRefs.length +
2920
+ schemaErrors.schemaBreaks.length +
2921
+ schemaErrors.uncheckableOps.length +
2922
+ schemaErrors.tomlParse.length +
2923
+ schemaErrors.opsExist.length;
2924
+ const successCount = changes.length - totalBlocked;
2503
2925
  if (successCount > 0) {
2504
- success(`Pushed ${successCount} change(s). ${conflicts.length} conflict(s) skipped.`);
2926
+ success(`Pushed ${successCount} change(s). ${totalBlocked} blocked.`);
2505
2927
  }
2506
2928
  else {
2507
- error(`Push failed: ${conflicts.length} conflict(s). No changes applied.`);
2929
+ error(`Push failed: ${totalBlocked} blocked. No changes applied.`);
2508
2930
  }
2509
2931
  process.exit(1);
2510
2932
  }
@@ -2532,6 +2954,12 @@ Directory Structure:
2532
2954
  }
2533
2955
  }
2534
2956
  error(err.message);
2957
+ // Print structured server-side validation details (issue #684).
2958
+ if (err instanceof ApiError && Array.isArray(err.details) && err.details.length > 0) {
2959
+ for (const detail of err.details) {
2960
+ console.error(` - ${typeof detail === "string" ? detail : JSON.stringify(detail)}`);
2961
+ }
2962
+ }
2535
2963
  process.exit(1);
2536
2964
  }
2537
2965
  });
@@ -2852,5 +3280,105 @@ Directory Structure:
2852
3280
  process.exit(1);
2853
3281
  }
2854
3282
  });
3283
+ // Migrate-toml (issue #752): bulk-rewrite database-type TOML files from
3284
+ // the legacy JSON-string form for `definition`/`params` to the native
3285
+ // nested-table form. Idempotent: ops already in native form are left as
3286
+ // is; ops that are un-TOMLable (mixed-type arrays, null values) fall
3287
+ // back to JSON-string per field with a log message.
3288
+ sync
3289
+ .command("migrate-toml")
3290
+ .description("Rewrite database-type TOML files to native [operations.definition] / [[operations.params]] form")
3291
+ .argument("[app-id]", "App ID (uses current app if not specified)")
3292
+ .option("--app <app-id>", "App ID")
3293
+ .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
3294
+ .option("--dry-run", "Show what would change without writing files")
3295
+ .action(async (appId, options) => {
3296
+ // Resolve appId so we land in the per-env sync dir even when the user
3297
+ // didn't pass `--dir`. The app ID itself isn't used for any API calls
3298
+ // — `migrate-toml` is a purely local rewrite.
3299
+ const resolvedAppId = resolveAppId(appId, options);
3300
+ const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
3301
+ if (isAutoResolvedSyncDir(options.dir)) {
3302
+ info(`Using per-environment sync directory: ${configDir}`);
3303
+ }
3304
+ if (!existsSync(configDir)) {
3305
+ error(`Config directory not found: ${configDir}`);
3306
+ process.exit(1);
3307
+ }
3308
+ const dbTypesDir = join(configDir, "database-types");
3309
+ if (!existsSync(dbTypesDir)) {
3310
+ info("No database-types/ directory found; nothing to migrate.");
3311
+ return;
3312
+ }
3313
+ const files = readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml"));
3314
+ if (files.length === 0) {
3315
+ info("No database-type TOML files found; nothing to migrate.");
3316
+ return;
3317
+ }
3318
+ // Hydrate ruleSetIdToName from local sync state so files that
3319
+ // reference a rule set via the legacy `ruleSetId = "01..."` form
3320
+ // round-trip with the correct `ruleSetName`. Without this, any user
3321
+ // who runs migrate-toml against a file with a rule-set assignment
3322
+ // would silently lose that reference (review feedback r3246633010).
3323
+ //
3324
+ // Files that use the modern key-based `ruleSetName = "..."` form
3325
+ // don't need the map at all — `parseDatabaseTypeToml` stores the
3326
+ // value in `typeConfig._ruleSetName` and the serializer now prefers
3327
+ // that. The map is only load-bearing for the legacy ID-based form.
3328
+ const ruleSetIdToName = new Map();
3329
+ const migrateSyncState = loadSyncState(configDir);
3330
+ if (migrateSyncState?.entities?.ruleSets) {
3331
+ for (const [fileKey, entry] of Object.entries(migrateSyncState.entities.ruleSets)) {
3332
+ if (entry && typeof entry === "object" && "id" in entry && entry.id) {
3333
+ // fileKey is the sanitized rule-set name (see sync pull at
3334
+ // sync.ts:1385). It's the same shape the server returned and
3335
+ // matches what a TOML file's ruleSetName field would carry.
3336
+ ruleSetIdToName.set(entry.id, fileKey);
3337
+ }
3338
+ }
3339
+ }
3340
+ let migratedCount = 0;
3341
+ let unchangedCount = 0;
3342
+ for (const file of files) {
3343
+ const filePath = join(dbTypesDir, file);
3344
+ const rawBefore = readFileSync(filePath, "utf-8");
3345
+ const tomlData = parseTomlFile(filePath);
3346
+ const { typeConfig, operations } = parseDatabaseTypeToml(tomlData);
3347
+ // Force-native: ignore existing hints so every TOMLable field
3348
+ // ends up in nested-table form.
3349
+ const rewritten = serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
3350
+ defaultForm: "native",
3351
+ logger: (msg) => info(` ${msg}`),
3352
+ });
3353
+ if (rewritten === rawBefore) {
3354
+ unchangedCount++;
3355
+ continue;
3356
+ }
3357
+ if (options.dryRun) {
3358
+ info(`Would migrate database-types/${file}`);
3359
+ }
3360
+ else {
3361
+ writeFileSync(filePath, rewritten);
3362
+ info(`Migrated database-types/${file}`);
3363
+ // Update content hash in sync state if we have an entry for it.
3364
+ const syncState = loadSyncState(configDir);
3365
+ const dbType = typeConfig.databaseType || basename(file, ".toml");
3366
+ if (syncState?.entities?.databaseTypes?.[dbType]) {
3367
+ syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
3368
+ saveSyncState(configDir, syncState);
3369
+ }
3370
+ }
3371
+ migratedCount++;
3372
+ }
3373
+ divider();
3374
+ keyValue("Migrated", migratedCount);
3375
+ keyValue("Already native (unchanged)", unchangedCount);
3376
+ if (options.dryRun) {
3377
+ info("Dry-run only — no files were modified.");
3378
+ }
3379
+ else if (migratedCount > 0) {
3380
+ success(`Rewrote ${migratedCount} TOML file(s) to native form.`);
3381
+ }
3382
+ });
2855
3383
  }
2856
3384
  //# sourceMappingURL=sync.js.map