primitive-admin 1.1.0-alpha.43 → 1.1.0-alpha.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 (79) hide show
  1. package/README.md +2 -2
  2. package/dist/bin/primitive.js +0 -0
  3. package/dist/src/commands/blob-buckets.js +20 -7
  4. package/dist/src/commands/blob-buckets.js.map +1 -1
  5. package/dist/src/commands/catalog.js +3 -3
  6. package/dist/src/commands/catalog.js.map +1 -1
  7. package/dist/src/commands/databases.js +3 -3
  8. package/dist/src/commands/databases.js.map +1 -1
  9. package/dist/src/commands/guides.d.ts +86 -0
  10. package/dist/src/commands/guides.js +222 -41
  11. package/dist/src/commands/guides.js.map +1 -1
  12. package/dist/src/commands/integrations.js +2 -2
  13. package/dist/src/commands/integrations.js.map +1 -1
  14. package/dist/src/commands/prompts.js +2 -2
  15. package/dist/src/commands/prompts.js.map +1 -1
  16. package/dist/src/commands/sync.d.ts +23 -3
  17. package/dist/src/commands/sync.js +370 -121
  18. package/dist/src/commands/sync.js.map +1 -1
  19. package/dist/src/commands/workflows.js +191 -26
  20. package/dist/src/commands/workflows.js.map +1 -1
  21. package/dist/src/lib/api-client.d.ts +1 -0
  22. package/dist/src/lib/api-client.js +4 -0
  23. package/dist/src/lib/api-client.js.map +1 -1
  24. package/dist/src/lib/block-layout.js +5 -5
  25. package/dist/src/lib/block-layout.js.map +1 -1
  26. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +61 -0
  27. package/dist/src/lib/codegen-shared/generatedFiles.js +127 -0
  28. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -0
  29. package/dist/src/lib/config-toml.d.ts +10 -0
  30. package/dist/src/lib/config-toml.js +42 -0
  31. package/dist/src/lib/config-toml.js.map +1 -0
  32. package/dist/src/lib/constants.d.ts +9 -0
  33. package/dist/src/lib/constants.js +9 -0
  34. package/dist/src/lib/constants.js.map +1 -1
  35. package/dist/src/lib/db-codegen/dbGenerator.d.ts +35 -7
  36. package/dist/src/lib/db-codegen/dbGenerator.js +384 -125
  37. package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
  38. package/dist/src/lib/db-codegen/dbNaming.d.ts +29 -0
  39. package/dist/src/lib/db-codegen/dbNaming.js +58 -0
  40. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
  41. package/dist/src/lib/db-codegen/dbTemplates.d.ts +114 -5
  42. package/dist/src/lib/db-codegen/dbTemplates.js +163 -16
  43. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
  44. package/dist/src/lib/db-codegen/dbTsTypes.d.ts +7 -3
  45. package/dist/src/lib/db-codegen/dbTsTypes.js +23 -1
  46. package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -1
  47. package/dist/src/lib/generated-allowlist.js +14 -0
  48. package/dist/src/lib/generated-allowlist.js.map +1 -1
  49. package/dist/src/lib/init-config.js +2 -2
  50. package/dist/src/lib/init-config.js.map +1 -1
  51. package/dist/src/lib/toml-database-config.d.ts +10 -39
  52. package/dist/src/lib/toml-database-config.js +16 -82
  53. package/dist/src/lib/toml-database-config.js.map +1 -1
  54. package/dist/src/lib/toml-metadata-config.d.ts +1 -1
  55. package/dist/src/lib/toml-metadata-config.js +3 -3
  56. package/dist/src/lib/toml-metadata-config.js.map +1 -1
  57. package/dist/src/lib/toml-native-form.d.ts +46 -0
  58. package/dist/src/lib/toml-native-form.js +78 -0
  59. package/dist/src/lib/toml-native-form.js.map +1 -0
  60. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.d.ts +96 -0
  61. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js +112 -0
  62. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js.map +1 -0
  63. package/dist/src/lib/workflow-codegen/generator.d.ts +70 -0
  64. package/dist/src/lib/workflow-codegen/generator.js +186 -0
  65. package/dist/src/lib/workflow-codegen/generator.js.map +1 -0
  66. package/dist/src/lib/workflow-codegen/naming.d.ts +33 -0
  67. package/dist/src/lib/workflow-codegen/naming.js +81 -0
  68. package/dist/src/lib/workflow-codegen/naming.js.map +1 -0
  69. package/dist/src/lib/workflow-codegen/schemaToTs.d.ts +55 -0
  70. package/dist/src/lib/workflow-codegen/schemaToTs.js +237 -0
  71. package/dist/src/lib/workflow-codegen/schemaToTs.js.map +1 -0
  72. package/dist/src/lib/workflow-fragments.js +3 -3
  73. package/dist/src/lib/workflow-fragments.js.map +1 -1
  74. package/dist/src/lib/workflow-toml-validator.d.ts +1 -1
  75. package/dist/src/lib/workflow-toml-validator.js +1 -1
  76. package/dist/src/validators.d.ts +4 -3
  77. package/dist/src/validators.js +6 -5
  78. package/dist/src/validators.js.map +1 -1
  79. package/package.json +5 -3
@@ -1,10 +1,10 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync, rmdirSync, statSync } from "fs";
2
2
  import { join, basename } from "path";
3
3
  import { createHash } from "crypto";
4
- import * as TOML from "@iarna/toml";
5
4
  import { lookup as mimeLookup } from "mime-types";
6
5
  import { ApiClient, ApiError, ConflictError, SchemaRequiredError, OperationRefError, SchemaBreaksOpsError, SchemaHasUncheckableOpsError, TomlParseError, OpsExistError, } from "../lib/api-client.js";
7
6
  import { buildDatabaseTypeTomlData, detectExistingOperationForms, normalizeOperationFromToml, normalizeSubscriptionFromToml, SubscriptionAccessKeyConflictError, } from "../lib/toml-database-config.js";
7
+ import { canEmitNative } from "../lib/toml-native-form.js";
8
8
  import { parseMetadataCategoryToml, validateMetadataCategoryConfig, serializeMetadataCategoryConfig, parseDeclaredAccessManifestToml, validateDeclaredAccessManifest, serializeDeclaredAccessManifest, } from "../lib/toml-metadata-config.js";
9
9
  import { validateOperations, formatIssue, } from "../lib/toml-params-validator.js";
10
10
  import { getServerUrl, resolveAppId } from "../lib/config.js";
@@ -12,6 +12,7 @@ import { resolveSyncDir, resolveSnapshotsRoot, isAutoResolvedSyncDir, checkLegac
12
12
  import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneSnapshots, } from "../lib/snapshots.js";
13
13
  import { validateWorkflowToml, formatWorkflowTomlErrors, } from "../lib/workflow-toml-validator.js";
14
14
  import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
15
+ import { parseConfigToml, stringifyConfigToml } from "../lib/config-toml.js";
15
16
  import { applyWorkflowBody } from "../lib/workflow-apply.js";
16
17
  import { buildWorkflowPayloadFromToml } from "../lib/workflow-payload.js";
17
18
  import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
@@ -190,6 +191,25 @@ export function normalizeWorkflowTomlDefaults(parsed) {
190
191
  workflow[field] = defaultValue;
191
192
  }
192
193
  }
194
+ // Schema-form canonicalization (issue #1446): a legacy file carries
195
+ // `inputSchema` / `outputSchema` as a JSON string, while the native form (and
196
+ // the remote serialization) carries a parsed object. Parse the string form to
197
+ // its object here so the two forms hash equal — `sync diff` stays
198
+ // form-independent and an unchanged schema never shows a false `modified`
199
+ // during the transition. This runs only on the diff-hash path
200
+ // (`hashWorkflowTomlForDiff`); the stored-state hash path is untouched, so
201
+ // there is no forced one-time re-push after upgrade.
202
+ for (const field of ["inputSchema", "outputSchema"]) {
203
+ if (typeof workflow[field] === "string") {
204
+ try {
205
+ workflow[field] = JSON.parse(workflow[field]);
206
+ }
207
+ catch {
208
+ // Not valid JSON — leave the raw string so a genuinely malformed value
209
+ // still compares by its literal content.
210
+ }
211
+ }
212
+ }
193
213
  return { ...parsed, workflow };
194
214
  }
195
215
  /**
@@ -208,12 +228,14 @@ export function hashWorkflowTomlForDiff(parsed) {
208
228
  * `getWorkflow` + active `getWorkflowConfig`), mirroring exactly what a fresh
209
229
  * `sync pull` would write to disk. Serializes via `serializeWorkflow`, parses
210
230
  * the resulting TOML (remote serialized TOML never carries `include`s, so a
211
- * plain `TOML.parse` is sufficient — no fragment path needed), then hashes
231
+ * plain `parseConfigToml` is sufficient — no fragment path needed), then hashes
212
232
  * through `hashWorkflowTomlForDiff` so it lines up with the local-file hash.
213
233
  */
214
234
  export function hashRemoteWorkflowForDiff(workflow, draft, configs) {
215
235
  const serialized = serializeWorkflow(workflow, draft, configs || []);
216
- const parsed = TOML.parse(serialized);
236
+ // serializeWorkflow emits via smol-toml (issue #1450) and may contain a
237
+ // heterogeneous array, so re-parse through the TOML 1.0-compliant facade.
238
+ const parsed = parseConfigToml(serialized);
217
239
  return hashWorkflowTomlForDiff(parsed);
218
240
  }
219
241
  // Boolean [auth] keys in app.toml that map 1:1 to app-settings fields.
@@ -238,7 +260,7 @@ const RECOGNIZED_AUTH_KEYS = new Set([
238
260
  *
239
261
  * Apple/OTP fields round-trip alongside the legacy google/passkey/magic-link
240
262
  * keys. Two asymmetries are handled deliberately:
241
- * - `appleSignInEnabled` comes back as `boolean | null`; @iarna/toml throws on
263
+ * - `appleSignInEnabled` comes back as `boolean | null`; smol-toml throws on
242
264
  * null, so an unset value (null) is omitted rather than written.
243
265
  * - `appleAudiences` is omitted when empty. An empty array and an omitted key
244
266
  * both mean "no audiences" (the server normalizes [] -> null), so omitting
@@ -317,7 +339,7 @@ function serializeAppSettings(settings) {
317
339
  maxAge: settings.corsMaxAge,
318
340
  };
319
341
  }
320
- return TOML.stringify(data);
342
+ return stringifyConfigToml(data);
321
343
  }
322
344
  function serializeIntegration(integration) {
323
345
  const data = {
@@ -330,9 +352,9 @@ function serializeIntegration(integration) {
330
352
  },
331
353
  requestConfig: integration.requestConfig || {},
332
354
  };
333
- return TOML.stringify(data);
355
+ return stringifyConfigToml(data);
334
356
  }
335
- function serializeWebhook(webhook) {
357
+ export function serializeWebhook(webhook) {
336
358
  const data = {
337
359
  webhook: {
338
360
  key: webhook.webhookKey,
@@ -353,7 +375,9 @@ function serializeWebhook(webhook) {
353
375
  // the template shape here too so a masked or raw value can never be written
354
376
  // to TOML.
355
377
  if (typeof webhook.signingSecret === "string" &&
356
- /\{\{secrets\.[A-Z][A-Z0-9_]{0,63}\}\}/.test(webhook.signingSecret)) {
378
+ // Whitespace-tolerant (#1468) to match the server's shared resolver, so a
379
+ // hand-authored spaced reference `{{ secrets.KEY }}` round-trips pull→push.
380
+ /\{\{\s*secrets\.[A-Z][A-Z0-9_]{0,63}\s*\}\}/.test(webhook.signingSecret)) {
357
381
  data.webhook.signingSecret = webhook.signingSecret;
358
382
  }
359
383
  // Parse JSON fields if they're strings
@@ -384,7 +408,7 @@ function serializeWebhook(webhook) {
384
408
  if (data.webhook[k] === undefined)
385
409
  delete data.webhook[k];
386
410
  });
387
- return TOML.stringify(data);
411
+ return stringifyConfigToml(data);
388
412
  }
389
413
  function serializeCronTrigger(trigger) {
390
414
  const data = {
@@ -420,7 +444,7 @@ function serializeCronTrigger(trigger) {
420
444
  if (data.cronTrigger[k] === undefined)
421
445
  delete data.cronTrigger[k];
422
446
  });
423
- return TOML.stringify(data);
447
+ return stringifyConfigToml(data);
424
448
  }
425
449
  function serializeBlobBucket(bucket) {
426
450
  const data = {
@@ -446,7 +470,32 @@ function serializeBlobBucket(bucket) {
446
470
  if (data.bucket[k] === undefined)
447
471
  delete data.bucket[k];
448
472
  });
449
- return TOML.stringify(data);
473
+ return stringifyConfigToml(data);
474
+ }
475
+ /**
476
+ * Build the PATCH payload for a blob-bucket update from local TOML.
477
+ *
478
+ * EXACT extraction of the inline builder shared by the existing-update and
479
+ * 409-adopt branches — do NOT "clean up" the truthiness checks. The server
480
+ * treats `preset` and `accessPolicy` as mutually exclusive, only clears
481
+ * `ruleSetId` when a preset/accessPolicy is also present, and nulls
482
+ * `description` only when the key is explicitly present with a falsy value.
483
+ * `bucketKey` and `ttlTier` are immutable and never sent. Both call sites
484
+ * must go through this helper so their field sets never drift.
485
+ */
486
+ function buildBlobBucketUpdatePayload(bucket) {
487
+ const updatePayload = {};
488
+ if (bucket.preset)
489
+ updatePayload.preset = bucket.preset;
490
+ else if (bucket.accessPolicy)
491
+ updatePayload.accessPolicy = bucket.accessPolicy;
492
+ if (bucket.ruleSetId)
493
+ updatePayload.ruleSetId = bucket.ruleSetId;
494
+ if (bucket.name)
495
+ updatePayload.name = bucket.name;
496
+ if (bucket.description !== undefined)
497
+ updatePayload.description = bucket.description || null;
498
+ return updatePayload;
450
499
  }
451
500
  function serializePrompt(prompt) {
452
501
  const data = {
@@ -469,9 +518,102 @@ function serializePrompt(prompt) {
469
518
  userPromptTemplate: config.userPromptTemplate,
470
519
  })),
471
520
  };
472
- return TOML.stringify(data);
521
+ return stringifyConfigToml(data);
522
+ }
523
+ /**
524
+ * Emit a workflow schema field (`inputSchema` / `outputSchema`) for the TOML
525
+ * `[workflow]` table.
526
+ *
527
+ * Issue #1446: pull used to `JSON.stringify` both schema fields into a single
528
+ * escaped-JSON line — unreadable and undiffable. We now emit the parsed JSON
529
+ * Schema as a native `[workflow.inputSchema]` / `[workflow.outputSchema]`
530
+ * table whenever it round-trips faithfully (`canEmitNative === null`), falling
531
+ * back to the JSON string only for the values TOML silently drops (`null` /
532
+ * `undefined` anywhere in the schema). The empty-object schema `{}` is a real,
533
+ * distinct value ("accepts anything") and emits as an empty table.
534
+ *
535
+ * Returns `undefined` for an absent schema (the key is then omitted). The
536
+ * sponsor-approved decision is to always move to native aggressively — no
537
+ * form preservation — so this does not consult the existing file's form.
538
+ */
539
+ function serializeWorkflowSchemaField(value, fieldName, workflowKey, log) {
540
+ if (value === undefined || value === null)
541
+ return undefined;
542
+ // Schemas arrive from the server GET as parsed objects; defensively accept a
543
+ // JSON string too (e.g. an inline-authored file re-serialized here).
544
+ let obj = value;
545
+ if (typeof value === "string") {
546
+ try {
547
+ obj = JSON.parse(value);
548
+ }
549
+ catch {
550
+ // Not valid JSON — keep the original string as the emitted value.
551
+ return value;
552
+ }
553
+ }
554
+ if (obj && typeof obj === "object") {
555
+ const reason = canEmitNative(obj);
556
+ if (reason) {
557
+ log(`Workflow '${workflowKey}': cannot emit \`${fieldName}\` as native TOML (${reason}); using JSON string.`);
558
+ return JSON.stringify(obj);
559
+ }
560
+ return obj;
561
+ }
562
+ // A non-object schema (unexpected) — preserve it as a JSON string.
563
+ return JSON.stringify(obj);
564
+ }
565
+ /**
566
+ * Rewrite a workflow TOML file's string-form `inputSchema` / `outputSchema` to
567
+ * native object form for `sync migrate-toml` (issue #1446).
568
+ *
569
+ * Purely local, no server fetch. Parses the raw source WITHOUT expanding
570
+ * fragment `include`s (so those directives survive the rewrite), swaps a
571
+ * JSON-string schema for its parsed object when it round-trips faithfully
572
+ * (`canEmitNative === null`), and re-serializes via `stringifyConfigToml`.
573
+ *
574
+ * Only rewrites when a field is actually converted: a file whose schemas are
575
+ * already native, absent, or un-representable (a `null` default) is returned
576
+ * with `changed: false` and its original bytes untouched — so migrate is
577
+ * idempotent and never reports a spurious migration or reformats a file it
578
+ * didn't need to touch.
579
+ */
580
+ export function rewriteWorkflowSchemasToNative(rawToml, workflowKey = "", log = () => { }) {
581
+ const parsed = parseConfigToml(rawToml);
582
+ if (!parsed || typeof parsed !== "object" || !parsed.workflow) {
583
+ return { content: rawToml, changed: false };
584
+ }
585
+ const workflow = parsed.workflow;
586
+ let converted = false;
587
+ for (const field of ["inputSchema", "outputSchema"]) {
588
+ const value = workflow[field];
589
+ // Only a JSON-string schema is a migration candidate; an object is already
590
+ // native and an absent key needs nothing.
591
+ if (typeof value !== "string")
592
+ continue;
593
+ let obj;
594
+ try {
595
+ obj = JSON.parse(value);
596
+ }
597
+ catch {
598
+ // Not JSON — leave the literal string in place.
599
+ continue;
600
+ }
601
+ if (!obj || typeof obj !== "object")
602
+ continue;
603
+ const reason = canEmitNative(obj);
604
+ if (reason) {
605
+ log(`Workflow '${workflowKey}': cannot migrate \`${field}\` to native TOML (${reason}); leaving as JSON string.`);
606
+ continue;
607
+ }
608
+ workflow[field] = obj;
609
+ converted = true;
610
+ }
611
+ if (!converted)
612
+ return { content: rawToml, changed: false };
613
+ return { content: stringifyConfigToml(parsed), changed: true };
473
614
  }
474
- export function serializeWorkflow(workflow, draft, configs) {
615
+ export function serializeWorkflow(workflow, draft, configs, logger) {
616
+ const log = logger ?? (() => { });
475
617
  // Find the active config or use the first one
476
618
  const activeConfigId = workflow.activeConfigId;
477
619
  const activeConfig = configs?.find((c) => c.configId === activeConfigId) || configs?.[0];
@@ -516,9 +658,11 @@ export function serializeWorkflow(workflow, draft, configs) {
516
658
  // they're safe to coerce here.
517
659
  requiresClientApply: workflow.requiresClientApply !== false,
518
660
  syncCallable: workflow.syncCallable === true,
519
- // Schemas are at workflow level
520
- inputSchema: workflow.inputSchema ? JSON.stringify(workflow.inputSchema) : undefined,
521
- outputSchema: workflow.outputSchema ? JSON.stringify(workflow.outputSchema) : undefined,
661
+ // Schemas are at workflow level. Emit native `[workflow.inputSchema]` /
662
+ // `[workflow.outputSchema]` tables when representable, JSON string only
663
+ // as a data-loss fallback (issue #1446).
664
+ inputSchema: serializeWorkflowSchemaField(workflow.inputSchema, "inputSchema", workflow.workflowKey, log),
665
+ outputSchema: serializeWorkflowSchemaField(workflow.outputSchema, "outputSchema", workflow.workflowKey, log),
522
666
  },
523
667
  // Use active config steps, fall back to draft
524
668
  steps,
@@ -534,7 +678,7 @@ export function serializeWorkflow(workflow, draft, configs) {
534
678
  // a workflow's `md`/`secrets` declarations round-trip through pull → push.
535
679
  ...serializeDeclaredAccessManifest(workflow.metadataManifest),
536
680
  };
537
- return TOML.stringify(data);
681
+ return stringifyConfigToml(data);
538
682
  }
539
683
  // Database config serialization helpers
540
684
  //
@@ -543,15 +687,17 @@ export function serializeWorkflow(workflow, draft, configs) {
543
687
  // rows over JSON-strings stuffed into a single field. The form is sticky
544
688
  // per file — if the existing file uses the legacy JSON-string form, we
545
689
  // preserve it so we don't generate surprise diffs. New files default to
546
- // native. Un-TOMLable shapes (null, mixed-type arrays) fall back to JSON
547
- // string per field with a log message.
690
+ // native. The round-trip serializes with `smol-toml` (TOML 1.0), so
691
+ // heterogeneous arrays emit natively (issue #1450); only shapes that TOML
692
+ // genuinely can't represent without data loss (null / undefined / an array
693
+ // containing null) fall back to a JSON string per field with a log message.
548
694
  //
549
695
  // Issue #666: when the type has a stored `schema` (raw TOML of `[models.*]`
550
696
  // blocks), `buildDatabaseTypeTomlData` parses + merges it into the same TOML
551
697
  // object so the result round-trips as `[models.<Name>.fields.<field>]`.
552
698
  function serializeDatabaseType(typeConfig, operations, ruleSetIdToName, options = {}) {
553
699
  const data = buildDatabaseTypeTomlData(typeConfig, operations, ruleSetIdToName, options);
554
- return TOML.stringify(data);
700
+ return stringifyConfigToml(data);
555
701
  }
556
702
  function serializeEmailTemplate(template) {
557
703
  // The API returns { emailType, hasOverride, override: { subject, htmlBody, textBody }, default: { ... } }
@@ -569,7 +715,7 @@ function serializeEmailTemplate(template) {
569
715
  if (override.textBody) {
570
716
  data.template.textBody = override.textBody;
571
717
  }
572
- return TOML.stringify(data);
718
+ return stringifyConfigToml(data);
573
719
  }
574
720
  function serializeRuleSet(ruleSet) {
575
721
  const data = {
@@ -586,7 +732,7 @@ function serializeRuleSet(ruleSet) {
586
732
  if (Object.keys(rules).length > 0) {
587
733
  data.rules = rules;
588
734
  }
589
- return TOML.stringify(data);
735
+ return stringifyConfigToml(data);
590
736
  }
591
737
  export function serializeGroupTypeConfig(config, ruleSetIdToName) {
592
738
  const ruleSetName = config.ruleSetId ? (ruleSetIdToName.get(config.ruleSetId) || "") : "";
@@ -601,7 +747,7 @@ export function serializeGroupTypeConfig(config, ruleSetIdToName) {
601
747
  // round-trips, exactly like the collection/database/workflow type configs.
602
748
  ...serializeDeclaredAccessManifest(config.metadataManifest),
603
749
  };
604
- return TOML.stringify(data);
750
+ return stringifyConfigToml(data);
605
751
  }
606
752
  function serializeCollectionTypeConfig(config, ruleSetIdToName) {
607
753
  const ruleSetName = config.ruleSetId ? (ruleSetIdToName.get(config.ruleSetId) || "") : "";
@@ -614,7 +760,7 @@ function serializeCollectionTypeConfig(config, ruleSetIdToName) {
614
760
  // `[metadata]` + `secrets` fragments so a pull→push→pull cycle round-trips.
615
761
  ...serializeDeclaredAccessManifest(config.metadataManifest),
616
762
  };
617
- return TOML.stringify(data);
763
+ return stringifyConfigToml(data);
618
764
  }
619
765
  export function parseDatabaseTypeToml(tomlData) {
620
766
  const typeSection = tomlData.type || {};
@@ -680,7 +826,7 @@ export function parseDatabaseTypeToml(tomlData) {
680
826
  let schemaSerialized = null;
681
827
  if (tomlData.models && typeof tomlData.models === "object") {
682
828
  try {
683
- schemaSerialized = TOML.stringify({ models: tomlData.models });
829
+ schemaSerialized = stringifyConfigToml({ models: tomlData.models });
684
830
  }
685
831
  catch {
686
832
  // Fall through — server-side parser will fail with a clear
@@ -769,7 +915,7 @@ export function parseCollectionTypeConfigToml(tomlData) {
769
915
  // the original object untouched.
770
916
  function parseTomlFile(filePath) {
771
917
  const content = readFileSync(filePath, "utf-8");
772
- const parsed = TOML.parse(content);
918
+ const parsed = parseConfigToml(content);
773
919
  return expandWorkflowTomlData(parsed, filePath);
774
920
  }
775
921
  /**
@@ -861,7 +1007,7 @@ export function serializeTestCase(testCase, lookupMaps) {
861
1007
  expectedJsonSubset: testCase.expectedJsonSubset || "{}",
862
1008
  },
863
1009
  };
864
- return TOML.stringify(data);
1010
+ return stringifyConfigToml(data);
865
1011
  }
866
1012
  export function parseTestCaseToml(tomlData) {
867
1013
  const test = tomlData.test || {};
@@ -1587,7 +1733,7 @@ Directory Structure:
1587
1733
  for (const { workflow, draft, configs } of workflows) {
1588
1734
  const filename = `${workflow.workflowKey}.toml`;
1589
1735
  const filePath = join(configDir, "workflows", filename);
1590
- writeFileSync(filePath, serializeWorkflow(workflow, draft, configs || []));
1736
+ writeFileSync(filePath, serializeWorkflow(workflow, draft, configs || [], (msg) => info(` ${msg}`)));
1591
1737
  // Hash the expanded (post-fragment-splice) form so subsequent pushes
1592
1738
  // can detect fragment-only edits. See `computeExpandedContentHash`.
1593
1739
  // Pulled workflows never carry `include`, so the expander is a
@@ -2562,30 +2708,27 @@ Directory Structure:
2562
2708
  if (existingId) {
2563
2709
  // #1020 (D8): idempotently apply preset/ruleSet/name/description
2564
2710
  // changes via PATCH. bucketKey/ttlTier are immutable and not sent.
2565
- const updatePayload = {};
2566
- if (bucket.preset)
2567
- updatePayload.preset = bucket.preset;
2568
- else if (bucket.accessPolicy)
2569
- updatePayload.accessPolicy = bucket.accessPolicy;
2570
- if (bucket.ruleSetId)
2571
- updatePayload.ruleSetId = bucket.ruleSetId;
2572
- if (bucket.name)
2573
- updatePayload.name = bucket.name;
2574
- if (bucket.description !== undefined)
2575
- updatePayload.description = bucket.description || null;
2711
+ const updatePayload = buildBlobBucketUpdatePayload(bucket);
2576
2712
  changes.push({ type: "blob-bucket", action: "update", key });
2577
2713
  if (!options.dryRun) {
2578
2714
  try {
2579
2715
  await client.updateBlobBucket(resolvedAppId, existingId, updatePayload);
2580
2716
  info(` Updated blob bucket: ${key}`);
2717
+ // #1459: stamp the content hash ONLY after a successful
2718
+ // update, and only when this is not a dry run. A failed
2719
+ // update (or a dry run) must leave the file pending rather
2720
+ // than mark it in-sync and suppress it on the next push.
2721
+ if (syncState?.entities?.blobBuckets?.[key]) {
2722
+ syncState.entities.blobBuckets[key].contentHash =
2723
+ computeFileHash(filePath);
2724
+ }
2581
2725
  }
2582
2726
  catch (err) {
2727
+ // Keep the warn-and-continue contract (sponsor decision):
2728
+ // do not abort the push, just don't stamp the hash.
2583
2729
  warn(` Failed to update blob bucket ${key}: ${String(err?.message || err)}`);
2584
2730
  }
2585
2731
  }
2586
- if (syncState?.entities?.blobBuckets?.[key]) {
2587
- syncState.entities.blobBuckets[key].contentHash = computeFileHash(filePath);
2588
- }
2589
2732
  }
2590
2733
  else {
2591
2734
  const payload = {
@@ -2620,23 +2763,86 @@ Directory Structure:
2620
2763
  catch (err) {
2621
2764
  const msg = String(err?.message || err);
2622
2765
  if (msg.includes("already exists") || err.statusCode === 409) {
2623
- info(` Blob bucket already exists on server: ${key}`);
2624
- // Fetch the existing bucket to get its ID
2766
+ // #1459: adopt-by-key on 409. A blob bucket can already
2767
+ // exist on the server (an out-of-band create, or a prior
2768
+ // push that aborted before recording it in sync state). The
2769
+ // old handler stamped the LOCAL file's contentHash WITHOUT
2770
+ // applying the local config, so a divergent bucket was
2771
+ // silently marked in-sync and never reconciled. Mirror the
2772
+ // cron/workflow adopt paths: look up the bucket by key,
2773
+ // re-issue the local payload as an UPDATE, and stamp the
2774
+ // hash ONLY after the update succeeds.
2775
+ info(` Blob bucket already exists on server, adopting by key: ${key}`);
2776
+ let existing;
2625
2777
  try {
2626
- const existing = await client.getBlobBucket(resolvedAppId, key);
2627
- if (syncState && existing?.bucketId) {
2628
- if (!syncState.entities.blobBuckets) {
2629
- syncState.entities.blobBuckets = {};
2630
- }
2631
- syncState.entities.blobBuckets[key] = {
2632
- id: existing.bucketId,
2633
- modifiedAt: existing.modifiedAt || new Date().toISOString(),
2634
- contentHash: computeFileHash(filePath),
2635
- };
2778
+ existing = await client.getBlobBucket(resolvedAppId, key);
2779
+ }
2780
+ catch (lookupErr) {
2781
+ // A lookup failure after a 409 means we cannot adopt —
2782
+ // surface it instead of swallowing it (the old silent
2783
+ // `catch {}` left no sync-state entry, looping
2784
+ // create→409 on every push).
2785
+ throw wrapEntityError(new Error(`blob bucket "${key}" already exists but could not be adopted (lookup failed: ${String(lookupErr?.message || lookupErr)})`), "create", "blob bucket", key);
2786
+ }
2787
+ if (!existing?.bucketId) {
2788
+ // 409 but no matching bucket found — surface the original
2789
+ // create error rather than silently proceeding.
2790
+ throw wrapEntityError(err, "create", "blob bucket", key);
2791
+ }
2792
+ // Verify the resolved bucket is actually the one we asked
2793
+ // for. `getBlobBucket` resolves the identifier by primary id
2794
+ // (appId, bucketId) BEFORE bucketKey, and a bucketKey may be
2795
+ // a ULID-shaped string (the key regex permits it). If the
2796
+ // local key collides with an unrelated bucket's bucketId,
2797
+ // the lookup returns that unrelated bucket — patching and
2798
+ // stamping it would overwrite the wrong resource. Require an
2799
+ // exact key match, matching the cron/workflow adopt paths'
2800
+ // exact-key equality (they list + filter by key for the same
2801
+ // reason). Surface the mismatch via wrapEntityError; do NOT
2802
+ // patch or stamp.
2803
+ if (existing.bucketKey !== key) {
2804
+ throw wrapEntityError(new Error(`blob bucket "${key}" already exists, but the server resolved a different bucket (bucketKey "${existing.bucketKey}", bucketId "${existing.bucketId}"); refusing to adopt. This happens when the local key collides with an unrelated bucket's id — rename the local bucket key so it does not match an existing bucket id.`), "create", "blob bucket", key);
2805
+ }
2806
+ // ttlTier is immutable and cannot be reconciled by an
2807
+ // UPDATE. A local TOML whose retention tier diverges from
2808
+ // the server must fail loudly (sponsor decision) rather
2809
+ // than be silently blessed while the cosmetic fields
2810
+ // converge.
2811
+ if (bucket.ttlTier &&
2812
+ existing.ttlTier &&
2813
+ bucket.ttlTier !== existing.ttlTier) {
2814
+ throw wrapEntityError(new Error(`blob bucket "${key}" ttlTier mismatch: local TOML has "${bucket.ttlTier}" but the server bucket has "${existing.ttlTier}". ttlTier is immutable; align the TOML with the server (or recreate the bucket) before pushing.`), "update", "blob bucket", key);
2815
+ }
2816
+ const updatePayload = buildBlobBucketUpdatePayload(bucket);
2817
+ let updated;
2818
+ if (Object.keys(updatePayload).length > 0) {
2819
+ // Surface an update failure via wrapEntityError (no
2820
+ // stamp) — matches the cron adopt path.
2821
+ try {
2822
+ updated = await client.updateBlobBucket(resolvedAppId, existing.bucketId, updatePayload);
2823
+ }
2824
+ catch (updateErr) {
2825
+ throw wrapEntityError(updateErr, "update", "blob bucket", key);
2636
2826
  }
2827
+ info(` Adopted + updated blob bucket: ${key}`);
2637
2828
  }
2638
- catch {
2639
- // Ignore fetch errors
2829
+ else {
2830
+ // No mutable fields in the local TOML — the server
2831
+ // rejects an empty update (400). Adopt the existing
2832
+ // bucket without a PATCH.
2833
+ info(` Adopted blob bucket (no updatable fields): ${key}`);
2834
+ }
2835
+ if (syncState) {
2836
+ if (!syncState.entities.blobBuckets) {
2837
+ syncState.entities.blobBuckets = {};
2838
+ }
2839
+ syncState.entities.blobBuckets[key] = {
2840
+ id: existing.bucketId,
2841
+ modifiedAt: updated?.modifiedAt ||
2842
+ existing.modifiedAt ||
2843
+ new Date().toISOString(),
2844
+ contentHash: computeFileHash(filePath),
2845
+ };
2640
2846
  }
2641
2847
  }
2642
2848
  else {
@@ -5097,11 +5303,15 @@ Directory Structure:
5097
5303
  // Migrate-toml (issue #752): bulk-rewrite database-type TOML files from
5098
5304
  // the legacy JSON-string form for `definition`/`params` to the native
5099
5305
  // nested-table form. Idempotent: ops already in native form are left as
5100
- // is; ops that are un-TOMLable (mixed-type arrays, null values) fall
5101
- // back to JSON-string per field with a log message.
5306
+ // is; the round-trip serializes with smol-toml (TOML 1.0, issue #1450), so
5307
+ // heterogeneous arrays now converge to native form too only shapes that
5308
+ // TOML can't represent without data loss (null / undefined / an array
5309
+ // containing null) fall back to JSON-string per field with a log message.
5102
5310
  sync
5103
5311
  .command("migrate-toml")
5104
- .description("Rewrite database-type TOML files to native [operations.definition] / [[operations.params]] form")
5312
+ .description("Rewrite database-type and workflow TOML files to native form " +
5313
+ "([operations.definition] / [[operations.params]] for database types; " +
5314
+ "[workflow.inputSchema] / [workflow.outputSchema] for workflows)")
5105
5315
  .argument("[app-id]", "App ID (uses current app if not specified)")
5106
5316
  .option("--app <app-id>", "App ID")
5107
5317
  .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
@@ -5120,83 +5330,122 @@ Directory Structure:
5120
5330
  process.exit(1);
5121
5331
  }
5122
5332
  const dbTypesDir = join(configDir, "database-types");
5123
- if (!existsSync(dbTypesDir)) {
5124
- info("No database-types/ directory found; nothing to migrate.");
5125
- return;
5126
- }
5127
- const files = readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml"));
5128
- if (files.length === 0) {
5129
- info("No database-type TOML files found; nothing to migrate.");
5333
+ const workflowsDir = join(configDir, "workflows");
5334
+ const dbFiles = existsSync(dbTypesDir)
5335
+ ? readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml"))
5336
+ : [];
5337
+ const workflowFiles = existsSync(workflowsDir)
5338
+ ? readdirSync(workflowsDir).filter((f) => f.endsWith(".toml"))
5339
+ : [];
5340
+ // Restructured early-return (issue #1446): don't bail just because
5341
+ // `database-types/` is absent — a workflow-only project still has
5342
+ // `inputSchema`/`outputSchema` to migrate.
5343
+ if (dbFiles.length === 0 && workflowFiles.length === 0) {
5344
+ info("No database-type or workflow TOML files found; nothing to migrate.");
5130
5345
  return;
5131
5346
  }
5132
- // Hydrate ruleSetIdToName from local sync state so files that
5133
- // reference a rule set via the legacy `ruleSetId = "01..."` form
5134
- // round-trip with the correct `ruleSetName`. Without this, any user
5135
- // who runs migrate-toml against a file with a rule-set assignment
5136
- // would silently lose that reference (review feedback r3246633010).
5137
- //
5138
- // Files that use the modern key-based `ruleSetName = "..."` form
5139
- // don't need the map at all — `parseDatabaseTypeToml` stores the
5140
- // value in `typeConfig._ruleSetName` and the serializer now prefers
5141
- // that. The map is only load-bearing for the legacy ID-based form.
5142
- const ruleSetIdToName = new Map();
5143
- const migrateSyncState = loadSyncState(configDir);
5144
- if (migrateSyncState?.entities?.ruleSets) {
5145
- for (const [fileKey, entry] of Object.entries(migrateSyncState.entities.ruleSets)) {
5146
- if (entry && typeof entry === "object" && "id" in entry && entry.id) {
5147
- // fileKey is the sanitized rule-set name (see sync pull at
5148
- // sync.ts:1385). It's the same shape the server returned and
5149
- // matches what a TOML file's ruleSetName field would carry.
5150
- ruleSetIdToName.set(entry.id, fileKey);
5151
- }
5152
- }
5153
- }
5154
5347
  let migratedCount = 0;
5155
5348
  let unchangedCount = 0;
5156
- for (const file of files) {
5157
- const filePath = join(dbTypesDir, file);
5158
- const rawBefore = readFileSync(filePath, "utf-8");
5159
- const tomlData = parseTomlFile(filePath);
5160
- // `accessRule`/`access` conflicts surface as a clear error here too,
5161
- // rather than silently dropping the field during a migrate rewrite.
5162
- let typeConfig;
5163
- let operations;
5164
- let subscriptions;
5165
- try {
5166
- ({ typeConfig, operations, subscriptions } =
5167
- parseDatabaseTypeToml(tomlData));
5349
+ // ---- database-types/ pass ----
5350
+ if (dbFiles.length > 0) {
5351
+ // Hydrate ruleSetIdToName from local sync state so files that
5352
+ // reference a rule set via the legacy `ruleSetId = "01..."` form
5353
+ // round-trip with the correct `ruleSetName`. Without this, any user
5354
+ // who runs migrate-toml against a file with a rule-set assignment
5355
+ // would silently lose that reference (review feedback r3246633010).
5356
+ //
5357
+ // Files that use the modern key-based `ruleSetName = "..."` form
5358
+ // don't need the map at all — `parseDatabaseTypeToml` stores the
5359
+ // value in `typeConfig._ruleSetName` and the serializer now prefers
5360
+ // that. The map is only load-bearing for the legacy ID-based form.
5361
+ const ruleSetIdToName = new Map();
5362
+ const migrateSyncState = loadSyncState(configDir);
5363
+ if (migrateSyncState?.entities?.ruleSets) {
5364
+ for (const [fileKey, entry] of Object.entries(migrateSyncState.entities.ruleSets)) {
5365
+ if (entry && typeof entry === "object" && "id" in entry && entry.id) {
5366
+ // fileKey is the sanitized rule-set name (see sync pull at
5367
+ // sync.ts:1385). It's the same shape the server returned and
5368
+ // matches what a TOML file's ruleSetName field would carry.
5369
+ ruleSetIdToName.set(entry.id, fileKey);
5370
+ }
5371
+ }
5168
5372
  }
5169
- catch (err) {
5170
- if (err instanceof SubscriptionAccessKeyConflictError) {
5171
- error(` ${file}: ${err.message}`);
5172
- error(`Aborting migrate-toml: subscription \`access\`/\`accessRule\` conflict in ${file}.`);
5173
- process.exit(1);
5373
+ for (const file of dbFiles) {
5374
+ const filePath = join(dbTypesDir, file);
5375
+ const rawBefore = readFileSync(filePath, "utf-8");
5376
+ const tomlData = parseTomlFile(filePath);
5377
+ // `accessRule`/`access` conflicts surface as a clear error here too,
5378
+ // rather than silently dropping the field during a migrate rewrite.
5379
+ let typeConfig;
5380
+ let operations;
5381
+ let subscriptions;
5382
+ try {
5383
+ ({ typeConfig, operations, subscriptions } =
5384
+ parseDatabaseTypeToml(tomlData));
5385
+ }
5386
+ catch (err) {
5387
+ if (err instanceof SubscriptionAccessKeyConflictError) {
5388
+ error(` ${file}: ${err.message}`);
5389
+ error(`Aborting migrate-toml: subscription \`access\`/\`accessRule\` conflict in ${file}.`);
5390
+ process.exit(1);
5391
+ }
5392
+ throw err;
5393
+ }
5394
+ // Force-native: ignore existing hints so every TOMLable field
5395
+ // ends up in nested-table form. Subscriptions are preserved on the
5396
+ // rewrite (issue #803) so migrate-toml never drops them.
5397
+ const rewritten = serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
5398
+ defaultForm: "native",
5399
+ logger: (msg) => info(` ${msg}`),
5400
+ subscriptions,
5401
+ });
5402
+ if (rewritten === rawBefore) {
5403
+ unchangedCount++;
5404
+ continue;
5174
5405
  }
5175
- throw err;
5406
+ if (options.dryRun) {
5407
+ info(`Would migrate database-types/${file}`);
5408
+ }
5409
+ else {
5410
+ writeFileSync(filePath, rewritten);
5411
+ info(`Migrated database-types/${file}`);
5412
+ // Update content hash in sync state if we have an entry for it.
5413
+ const syncState = loadSyncState(configDir);
5414
+ const dbType = typeConfig.databaseType || basename(file, ".toml");
5415
+ if (syncState?.entities?.databaseTypes?.[dbType]) {
5416
+ syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
5417
+ saveSyncState(configDir, syncState);
5418
+ }
5419
+ }
5420
+ migratedCount++;
5176
5421
  }
5177
- // Force-native: ignore existing hints so every TOMLable field
5178
- // ends up in nested-table form. Subscriptions are preserved on the
5179
- // rewrite (issue #803) so migrate-toml never drops them.
5180
- const rewritten = serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
5181
- defaultForm: "native",
5182
- logger: (msg) => info(` ${msg}`),
5183
- subscriptions,
5184
- });
5185
- if (rewritten === rawBefore) {
5422
+ }
5423
+ // ---- workflows/ pass (issue #1446) ----
5424
+ // Convert string-form `inputSchema`/`outputSchema` to native tables.
5425
+ // Purely local, mirroring the database-types pass. The stored workflow
5426
+ // hash uses `computeExpandedContentHash` (parse then hash), NOT
5427
+ // `computeFileHash`, matching the workflow convention on the pull/push
5428
+ // paths — otherwise a push after migrate would spuriously re-push.
5429
+ for (const file of workflowFiles) {
5430
+ const filePath = join(workflowsDir, file);
5431
+ const rawBefore = readFileSync(filePath, "utf-8");
5432
+ const workflowKey = basename(file, ".toml");
5433
+ const { content: rewritten, changed } = rewriteWorkflowSchemasToNative(rawBefore, workflowKey, (msg) => info(` ${msg}`));
5434
+ if (!changed) {
5186
5435
  unchangedCount++;
5187
5436
  continue;
5188
5437
  }
5189
5438
  if (options.dryRun) {
5190
- info(`Would migrate database-types/${file}`);
5439
+ info(`Would migrate workflows/${file}`);
5191
5440
  }
5192
5441
  else {
5193
5442
  writeFileSync(filePath, rewritten);
5194
- info(`Migrated database-types/${file}`);
5195
- // Update content hash in sync state if we have an entry for it.
5443
+ info(`Migrated workflows/${file}`);
5444
+ // Update the stored content hash so a subsequent push sees a no-op.
5196
5445
  const syncState = loadSyncState(configDir);
5197
- const dbType = typeConfig.databaseType || basename(file, ".toml");
5198
- if (syncState?.entities?.databaseTypes?.[dbType]) {
5199
- syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
5446
+ if (syncState?.entities?.workflows?.[workflowKey]) {
5447
+ syncState.entities.workflows[workflowKey].contentHash =
5448
+ computeExpandedContentHash(parseTomlFile(filePath));
5200
5449
  saveSyncState(configDir, syncState);
5201
5450
  }
5202
5451
  }