primitive-admin 1.1.0-alpha.43 → 1.1.0-alpha.44

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 (55) hide show
  1. package/README.md +2 -2
  2. package/dist/src/commands/catalog.js +3 -3
  3. package/dist/src/commands/catalog.js.map +1 -1
  4. package/dist/src/commands/databases.js +3 -3
  5. package/dist/src/commands/databases.js.map +1 -1
  6. package/dist/src/commands/guides.d.ts +86 -0
  7. package/dist/src/commands/guides.js +222 -41
  8. package/dist/src/commands/guides.js.map +1 -1
  9. package/dist/src/commands/integrations.js +2 -2
  10. package/dist/src/commands/integrations.js.map +1 -1
  11. package/dist/src/commands/prompts.js +2 -2
  12. package/dist/src/commands/prompts.js.map +1 -1
  13. package/dist/src/commands/sync.d.ts +23 -3
  14. package/dist/src/commands/sync.js +257 -93
  15. package/dist/src/commands/sync.js.map +1 -1
  16. package/dist/src/commands/workflows.js +6 -6
  17. package/dist/src/commands/workflows.js.map +1 -1
  18. package/dist/src/lib/block-layout.js +5 -5
  19. package/dist/src/lib/block-layout.js.map +1 -1
  20. package/dist/src/lib/config-toml.d.ts +10 -0
  21. package/dist/src/lib/config-toml.js +42 -0
  22. package/dist/src/lib/config-toml.js.map +1 -0
  23. package/dist/src/lib/constants.d.ts +9 -0
  24. package/dist/src/lib/constants.js +9 -0
  25. package/dist/src/lib/constants.js.map +1 -1
  26. package/dist/src/lib/db-codegen/dbGenerator.d.ts +5 -2
  27. package/dist/src/lib/db-codegen/dbGenerator.js +204 -7
  28. package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
  29. package/dist/src/lib/db-codegen/dbNaming.d.ts +29 -0
  30. package/dist/src/lib/db-codegen/dbNaming.js +58 -0
  31. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
  32. package/dist/src/lib/db-codegen/dbTemplates.d.ts +125 -4
  33. package/dist/src/lib/db-codegen/dbTemplates.js +183 -16
  34. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
  35. package/dist/src/lib/generated-allowlist.js +1 -0
  36. package/dist/src/lib/generated-allowlist.js.map +1 -1
  37. package/dist/src/lib/init-config.js +2 -2
  38. package/dist/src/lib/init-config.js.map +1 -1
  39. package/dist/src/lib/toml-database-config.d.ts +10 -39
  40. package/dist/src/lib/toml-database-config.js +16 -82
  41. package/dist/src/lib/toml-database-config.js.map +1 -1
  42. package/dist/src/lib/toml-metadata-config.d.ts +1 -1
  43. package/dist/src/lib/toml-metadata-config.js +3 -3
  44. package/dist/src/lib/toml-metadata-config.js.map +1 -1
  45. package/dist/src/lib/toml-native-form.d.ts +46 -0
  46. package/dist/src/lib/toml-native-form.js +78 -0
  47. package/dist/src/lib/toml-native-form.js.map +1 -0
  48. package/dist/src/lib/workflow-fragments.js +3 -3
  49. package/dist/src/lib/workflow-fragments.js.map +1 -1
  50. package/dist/src/lib/workflow-toml-validator.d.ts +1 -1
  51. package/dist/src/lib/workflow-toml-validator.js +1 -1
  52. package/dist/src/validators.d.ts +4 -3
  53. package/dist/src/validators.js +6 -5
  54. package/dist/src/validators.js.map +1 -1
  55. package/package.json +2 -2
@@ -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,7 @@ 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);
450
474
  }
451
475
  function serializePrompt(prompt) {
452
476
  const data = {
@@ -469,9 +493,102 @@ function serializePrompt(prompt) {
469
493
  userPromptTemplate: config.userPromptTemplate,
470
494
  })),
471
495
  };
472
- return TOML.stringify(data);
496
+ return stringifyConfigToml(data);
497
+ }
498
+ /**
499
+ * Emit a workflow schema field (`inputSchema` / `outputSchema`) for the TOML
500
+ * `[workflow]` table.
501
+ *
502
+ * Issue #1446: pull used to `JSON.stringify` both schema fields into a single
503
+ * escaped-JSON line — unreadable and undiffable. We now emit the parsed JSON
504
+ * Schema as a native `[workflow.inputSchema]` / `[workflow.outputSchema]`
505
+ * table whenever it round-trips faithfully (`canEmitNative === null`), falling
506
+ * back to the JSON string only for the values TOML silently drops (`null` /
507
+ * `undefined` anywhere in the schema). The empty-object schema `{}` is a real,
508
+ * distinct value ("accepts anything") and emits as an empty table.
509
+ *
510
+ * Returns `undefined` for an absent schema (the key is then omitted). The
511
+ * sponsor-approved decision is to always move to native aggressively — no
512
+ * form preservation — so this does not consult the existing file's form.
513
+ */
514
+ function serializeWorkflowSchemaField(value, fieldName, workflowKey, log) {
515
+ if (value === undefined || value === null)
516
+ return undefined;
517
+ // Schemas arrive from the server GET as parsed objects; defensively accept a
518
+ // JSON string too (e.g. an inline-authored file re-serialized here).
519
+ let obj = value;
520
+ if (typeof value === "string") {
521
+ try {
522
+ obj = JSON.parse(value);
523
+ }
524
+ catch {
525
+ // Not valid JSON — keep the original string as the emitted value.
526
+ return value;
527
+ }
528
+ }
529
+ if (obj && typeof obj === "object") {
530
+ const reason = canEmitNative(obj);
531
+ if (reason) {
532
+ log(`Workflow '${workflowKey}': cannot emit \`${fieldName}\` as native TOML (${reason}); using JSON string.`);
533
+ return JSON.stringify(obj);
534
+ }
535
+ return obj;
536
+ }
537
+ // A non-object schema (unexpected) — preserve it as a JSON string.
538
+ return JSON.stringify(obj);
539
+ }
540
+ /**
541
+ * Rewrite a workflow TOML file's string-form `inputSchema` / `outputSchema` to
542
+ * native object form for `sync migrate-toml` (issue #1446).
543
+ *
544
+ * Purely local, no server fetch. Parses the raw source WITHOUT expanding
545
+ * fragment `include`s (so those directives survive the rewrite), swaps a
546
+ * JSON-string schema for its parsed object when it round-trips faithfully
547
+ * (`canEmitNative === null`), and re-serializes via `stringifyConfigToml`.
548
+ *
549
+ * Only rewrites when a field is actually converted: a file whose schemas are
550
+ * already native, absent, or un-representable (a `null` default) is returned
551
+ * with `changed: false` and its original bytes untouched — so migrate is
552
+ * idempotent and never reports a spurious migration or reformats a file it
553
+ * didn't need to touch.
554
+ */
555
+ export function rewriteWorkflowSchemasToNative(rawToml, workflowKey = "", log = () => { }) {
556
+ const parsed = parseConfigToml(rawToml);
557
+ if (!parsed || typeof parsed !== "object" || !parsed.workflow) {
558
+ return { content: rawToml, changed: false };
559
+ }
560
+ const workflow = parsed.workflow;
561
+ let converted = false;
562
+ for (const field of ["inputSchema", "outputSchema"]) {
563
+ const value = workflow[field];
564
+ // Only a JSON-string schema is a migration candidate; an object is already
565
+ // native and an absent key needs nothing.
566
+ if (typeof value !== "string")
567
+ continue;
568
+ let obj;
569
+ try {
570
+ obj = JSON.parse(value);
571
+ }
572
+ catch {
573
+ // Not JSON — leave the literal string in place.
574
+ continue;
575
+ }
576
+ if (!obj || typeof obj !== "object")
577
+ continue;
578
+ const reason = canEmitNative(obj);
579
+ if (reason) {
580
+ log(`Workflow '${workflowKey}': cannot migrate \`${field}\` to native TOML (${reason}); leaving as JSON string.`);
581
+ continue;
582
+ }
583
+ workflow[field] = obj;
584
+ converted = true;
585
+ }
586
+ if (!converted)
587
+ return { content: rawToml, changed: false };
588
+ return { content: stringifyConfigToml(parsed), changed: true };
473
589
  }
474
- export function serializeWorkflow(workflow, draft, configs) {
590
+ export function serializeWorkflow(workflow, draft, configs, logger) {
591
+ const log = logger ?? (() => { });
475
592
  // Find the active config or use the first one
476
593
  const activeConfigId = workflow.activeConfigId;
477
594
  const activeConfig = configs?.find((c) => c.configId === activeConfigId) || configs?.[0];
@@ -516,9 +633,11 @@ export function serializeWorkflow(workflow, draft, configs) {
516
633
  // they're safe to coerce here.
517
634
  requiresClientApply: workflow.requiresClientApply !== false,
518
635
  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,
636
+ // Schemas are at workflow level. Emit native `[workflow.inputSchema]` /
637
+ // `[workflow.outputSchema]` tables when representable, JSON string only
638
+ // as a data-loss fallback (issue #1446).
639
+ inputSchema: serializeWorkflowSchemaField(workflow.inputSchema, "inputSchema", workflow.workflowKey, log),
640
+ outputSchema: serializeWorkflowSchemaField(workflow.outputSchema, "outputSchema", workflow.workflowKey, log),
522
641
  },
523
642
  // Use active config steps, fall back to draft
524
643
  steps,
@@ -534,7 +653,7 @@ export function serializeWorkflow(workflow, draft, configs) {
534
653
  // a workflow's `md`/`secrets` declarations round-trip through pull → push.
535
654
  ...serializeDeclaredAccessManifest(workflow.metadataManifest),
536
655
  };
537
- return TOML.stringify(data);
656
+ return stringifyConfigToml(data);
538
657
  }
539
658
  // Database config serialization helpers
540
659
  //
@@ -543,15 +662,17 @@ export function serializeWorkflow(workflow, draft, configs) {
543
662
  // rows over JSON-strings stuffed into a single field. The form is sticky
544
663
  // per file — if the existing file uses the legacy JSON-string form, we
545
664
  // 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.
665
+ // native. The round-trip serializes with `smol-toml` (TOML 1.0), so
666
+ // heterogeneous arrays emit natively (issue #1450); only shapes that TOML
667
+ // genuinely can't represent without data loss (null / undefined / an array
668
+ // containing null) fall back to a JSON string per field with a log message.
548
669
  //
549
670
  // Issue #666: when the type has a stored `schema` (raw TOML of `[models.*]`
550
671
  // blocks), `buildDatabaseTypeTomlData` parses + merges it into the same TOML
551
672
  // object so the result round-trips as `[models.<Name>.fields.<field>]`.
552
673
  function serializeDatabaseType(typeConfig, operations, ruleSetIdToName, options = {}) {
553
674
  const data = buildDatabaseTypeTomlData(typeConfig, operations, ruleSetIdToName, options);
554
- return TOML.stringify(data);
675
+ return stringifyConfigToml(data);
555
676
  }
556
677
  function serializeEmailTemplate(template) {
557
678
  // The API returns { emailType, hasOverride, override: { subject, htmlBody, textBody }, default: { ... } }
@@ -569,7 +690,7 @@ function serializeEmailTemplate(template) {
569
690
  if (override.textBody) {
570
691
  data.template.textBody = override.textBody;
571
692
  }
572
- return TOML.stringify(data);
693
+ return stringifyConfigToml(data);
573
694
  }
574
695
  function serializeRuleSet(ruleSet) {
575
696
  const data = {
@@ -586,7 +707,7 @@ function serializeRuleSet(ruleSet) {
586
707
  if (Object.keys(rules).length > 0) {
587
708
  data.rules = rules;
588
709
  }
589
- return TOML.stringify(data);
710
+ return stringifyConfigToml(data);
590
711
  }
591
712
  export function serializeGroupTypeConfig(config, ruleSetIdToName) {
592
713
  const ruleSetName = config.ruleSetId ? (ruleSetIdToName.get(config.ruleSetId) || "") : "";
@@ -601,7 +722,7 @@ export function serializeGroupTypeConfig(config, ruleSetIdToName) {
601
722
  // round-trips, exactly like the collection/database/workflow type configs.
602
723
  ...serializeDeclaredAccessManifest(config.metadataManifest),
603
724
  };
604
- return TOML.stringify(data);
725
+ return stringifyConfigToml(data);
605
726
  }
606
727
  function serializeCollectionTypeConfig(config, ruleSetIdToName) {
607
728
  const ruleSetName = config.ruleSetId ? (ruleSetIdToName.get(config.ruleSetId) || "") : "";
@@ -614,7 +735,7 @@ function serializeCollectionTypeConfig(config, ruleSetIdToName) {
614
735
  // `[metadata]` + `secrets` fragments so a pull→push→pull cycle round-trips.
615
736
  ...serializeDeclaredAccessManifest(config.metadataManifest),
616
737
  };
617
- return TOML.stringify(data);
738
+ return stringifyConfigToml(data);
618
739
  }
619
740
  export function parseDatabaseTypeToml(tomlData) {
620
741
  const typeSection = tomlData.type || {};
@@ -680,7 +801,7 @@ export function parseDatabaseTypeToml(tomlData) {
680
801
  let schemaSerialized = null;
681
802
  if (tomlData.models && typeof tomlData.models === "object") {
682
803
  try {
683
- schemaSerialized = TOML.stringify({ models: tomlData.models });
804
+ schemaSerialized = stringifyConfigToml({ models: tomlData.models });
684
805
  }
685
806
  catch {
686
807
  // Fall through — server-side parser will fail with a clear
@@ -769,7 +890,7 @@ export function parseCollectionTypeConfigToml(tomlData) {
769
890
  // the original object untouched.
770
891
  function parseTomlFile(filePath) {
771
892
  const content = readFileSync(filePath, "utf-8");
772
- const parsed = TOML.parse(content);
893
+ const parsed = parseConfigToml(content);
773
894
  return expandWorkflowTomlData(parsed, filePath);
774
895
  }
775
896
  /**
@@ -861,7 +982,7 @@ export function serializeTestCase(testCase, lookupMaps) {
861
982
  expectedJsonSubset: testCase.expectedJsonSubset || "{}",
862
983
  },
863
984
  };
864
- return TOML.stringify(data);
985
+ return stringifyConfigToml(data);
865
986
  }
866
987
  export function parseTestCaseToml(tomlData) {
867
988
  const test = tomlData.test || {};
@@ -1587,7 +1708,7 @@ Directory Structure:
1587
1708
  for (const { workflow, draft, configs } of workflows) {
1588
1709
  const filename = `${workflow.workflowKey}.toml`;
1589
1710
  const filePath = join(configDir, "workflows", filename);
1590
- writeFileSync(filePath, serializeWorkflow(workflow, draft, configs || []));
1711
+ writeFileSync(filePath, serializeWorkflow(workflow, draft, configs || [], (msg) => info(` ${msg}`)));
1591
1712
  // Hash the expanded (post-fragment-splice) form so subsequent pushes
1592
1713
  // can detect fragment-only edits. See `computeExpandedContentHash`.
1593
1714
  // Pulled workflows never carry `include`, so the expander is a
@@ -5097,11 +5218,15 @@ Directory Structure:
5097
5218
  // Migrate-toml (issue #752): bulk-rewrite database-type TOML files from
5098
5219
  // the legacy JSON-string form for `definition`/`params` to the native
5099
5220
  // 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.
5221
+ // is; the round-trip serializes with smol-toml (TOML 1.0, issue #1450), so
5222
+ // heterogeneous arrays now converge to native form too only shapes that
5223
+ // TOML can't represent without data loss (null / undefined / an array
5224
+ // containing null) fall back to JSON-string per field with a log message.
5102
5225
  sync
5103
5226
  .command("migrate-toml")
5104
- .description("Rewrite database-type TOML files to native [operations.definition] / [[operations.params]] form")
5227
+ .description("Rewrite database-type and workflow TOML files to native form " +
5228
+ "([operations.definition] / [[operations.params]] for database types; " +
5229
+ "[workflow.inputSchema] / [workflow.outputSchema] for workflows)")
5105
5230
  .argument("[app-id]", "App ID (uses current app if not specified)")
5106
5231
  .option("--app <app-id>", "App ID")
5107
5232
  .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
@@ -5120,83 +5245,122 @@ Directory Structure:
5120
5245
  process.exit(1);
5121
5246
  }
5122
5247
  const dbTypesDir = join(configDir, "database-types");
5123
- if (!existsSync(dbTypesDir)) {
5124
- info("No database-types/ directory found; nothing to migrate.");
5248
+ const workflowsDir = join(configDir, "workflows");
5249
+ const dbFiles = existsSync(dbTypesDir)
5250
+ ? readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml"))
5251
+ : [];
5252
+ const workflowFiles = existsSync(workflowsDir)
5253
+ ? readdirSync(workflowsDir).filter((f) => f.endsWith(".toml"))
5254
+ : [];
5255
+ // Restructured early-return (issue #1446): don't bail just because
5256
+ // `database-types/` is absent — a workflow-only project still has
5257
+ // `inputSchema`/`outputSchema` to migrate.
5258
+ if (dbFiles.length === 0 && workflowFiles.length === 0) {
5259
+ info("No database-type or workflow TOML files found; nothing to migrate.");
5125
5260
  return;
5126
5261
  }
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.");
5130
- return;
5131
- }
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
5262
  let migratedCount = 0;
5155
5263
  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));
5264
+ // ---- database-types/ pass ----
5265
+ if (dbFiles.length > 0) {
5266
+ // Hydrate ruleSetIdToName from local sync state so files that
5267
+ // reference a rule set via the legacy `ruleSetId = "01..."` form
5268
+ // round-trip with the correct `ruleSetName`. Without this, any user
5269
+ // who runs migrate-toml against a file with a rule-set assignment
5270
+ // would silently lose that reference (review feedback r3246633010).
5271
+ //
5272
+ // Files that use the modern key-based `ruleSetName = "..."` form
5273
+ // don't need the map at all — `parseDatabaseTypeToml` stores the
5274
+ // value in `typeConfig._ruleSetName` and the serializer now prefers
5275
+ // that. The map is only load-bearing for the legacy ID-based form.
5276
+ const ruleSetIdToName = new Map();
5277
+ const migrateSyncState = loadSyncState(configDir);
5278
+ if (migrateSyncState?.entities?.ruleSets) {
5279
+ for (const [fileKey, entry] of Object.entries(migrateSyncState.entities.ruleSets)) {
5280
+ if (entry && typeof entry === "object" && "id" in entry && entry.id) {
5281
+ // fileKey is the sanitized rule-set name (see sync pull at
5282
+ // sync.ts:1385). It's the same shape the server returned and
5283
+ // matches what a TOML file's ruleSetName field would carry.
5284
+ ruleSetIdToName.set(entry.id, fileKey);
5285
+ }
5286
+ }
5168
5287
  }
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);
5288
+ for (const file of dbFiles) {
5289
+ const filePath = join(dbTypesDir, file);
5290
+ const rawBefore = readFileSync(filePath, "utf-8");
5291
+ const tomlData = parseTomlFile(filePath);
5292
+ // `accessRule`/`access` conflicts surface as a clear error here too,
5293
+ // rather than silently dropping the field during a migrate rewrite.
5294
+ let typeConfig;
5295
+ let operations;
5296
+ let subscriptions;
5297
+ try {
5298
+ ({ typeConfig, operations, subscriptions } =
5299
+ parseDatabaseTypeToml(tomlData));
5174
5300
  }
5175
- throw err;
5301
+ catch (err) {
5302
+ if (err instanceof SubscriptionAccessKeyConflictError) {
5303
+ error(` ${file}: ${err.message}`);
5304
+ error(`Aborting migrate-toml: subscription \`access\`/\`accessRule\` conflict in ${file}.`);
5305
+ process.exit(1);
5306
+ }
5307
+ throw err;
5308
+ }
5309
+ // Force-native: ignore existing hints so every TOMLable field
5310
+ // ends up in nested-table form. Subscriptions are preserved on the
5311
+ // rewrite (issue #803) so migrate-toml never drops them.
5312
+ const rewritten = serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
5313
+ defaultForm: "native",
5314
+ logger: (msg) => info(` ${msg}`),
5315
+ subscriptions,
5316
+ });
5317
+ if (rewritten === rawBefore) {
5318
+ unchangedCount++;
5319
+ continue;
5320
+ }
5321
+ if (options.dryRun) {
5322
+ info(`Would migrate database-types/${file}`);
5323
+ }
5324
+ else {
5325
+ writeFileSync(filePath, rewritten);
5326
+ info(`Migrated database-types/${file}`);
5327
+ // Update content hash in sync state if we have an entry for it.
5328
+ const syncState = loadSyncState(configDir);
5329
+ const dbType = typeConfig.databaseType || basename(file, ".toml");
5330
+ if (syncState?.entities?.databaseTypes?.[dbType]) {
5331
+ syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
5332
+ saveSyncState(configDir, syncState);
5333
+ }
5334
+ }
5335
+ migratedCount++;
5176
5336
  }
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) {
5337
+ }
5338
+ // ---- workflows/ pass (issue #1446) ----
5339
+ // Convert string-form `inputSchema`/`outputSchema` to native tables.
5340
+ // Purely local, mirroring the database-types pass. The stored workflow
5341
+ // hash uses `computeExpandedContentHash` (parse then hash), NOT
5342
+ // `computeFileHash`, matching the workflow convention on the pull/push
5343
+ // paths — otherwise a push after migrate would spuriously re-push.
5344
+ for (const file of workflowFiles) {
5345
+ const filePath = join(workflowsDir, file);
5346
+ const rawBefore = readFileSync(filePath, "utf-8");
5347
+ const workflowKey = basename(file, ".toml");
5348
+ const { content: rewritten, changed } = rewriteWorkflowSchemasToNative(rawBefore, workflowKey, (msg) => info(` ${msg}`));
5349
+ if (!changed) {
5186
5350
  unchangedCount++;
5187
5351
  continue;
5188
5352
  }
5189
5353
  if (options.dryRun) {
5190
- info(`Would migrate database-types/${file}`);
5354
+ info(`Would migrate workflows/${file}`);
5191
5355
  }
5192
5356
  else {
5193
5357
  writeFileSync(filePath, rewritten);
5194
- info(`Migrated database-types/${file}`);
5195
- // Update content hash in sync state if we have an entry for it.
5358
+ info(`Migrated workflows/${file}`);
5359
+ // Update the stored content hash so a subsequent push sees a no-op.
5196
5360
  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);
5361
+ if (syncState?.entities?.workflows?.[workflowKey]) {
5362
+ syncState.entities.workflows[workflowKey].contentHash =
5363
+ computeExpandedContentHash(parseTomlFile(filePath));
5200
5364
  saveSyncState(configDir, syncState);
5201
5365
  }
5202
5366
  }