primitive-admin 1.1.0-alpha.31 → 1.1.0-alpha.32

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.
@@ -4,7 +4,7 @@ import { createHash } from "crypto";
4
4
  import * as TOML from "@iarna/toml";
5
5
  import { lookup as mimeLookup } from "mime-types";
6
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";
7
+ import { buildDatabaseTypeTomlData, detectExistingOperationForms, normalizeOperationFromToml, normalizeSubscriptionFromToml, SubscriptionAccessKeyConflictError, } from "../lib/toml-database-config.js";
8
8
  import { validateOperations, formatIssue, } from "../lib/toml-params-validator.js";
9
9
  import { getServerUrl, resolveAppId } from "../lib/config.js";
10
10
  import { resolveSyncDir, isAutoResolvedSyncDir, checkLegacySyncMigration, } from "../lib/sync-paths.js";
@@ -302,6 +302,12 @@ function serializeWorkflow(workflow, draft, configs) {
302
302
  perUserMaxRunning: workflow.perUserMaxRunning,
303
303
  perUserMaxQueued: workflow.perUserMaxQueued,
304
304
  dequeueOrder: workflow.dequeueOrder,
305
+ // Sync-callable flags (#807): always emit both so a fresh pull → push
306
+ // cycle round-trips them. The GET response normalizes these to booleans
307
+ // (requiresClientApply defaults true, syncCallable defaults false), so
308
+ // they're safe to coerce here.
309
+ requiresClientApply: workflow.requiresClientApply !== false,
310
+ syncCallable: workflow.syncCallable === true,
305
311
  // Schemas are at workflow level
306
312
  inputSchema: workflow.inputSchema ? JSON.stringify(workflow.inputSchema) : undefined,
307
313
  outputSchema: workflow.outputSchema ? JSON.stringify(workflow.outputSchema) : undefined,
@@ -421,6 +427,14 @@ export function parseDatabaseTypeToml(tomlData) {
421
427
  if (typeSection.autoPopulatedFields !== undefined) {
422
428
  typeConfig.autoPopulatedFields = typeSection.autoPopulatedFields;
423
429
  }
430
+ // timestamps (issue #748) — inline-table form
431
+ // (`timestamps = { create = "createdAt", update = "modifiedAt" }`) passes
432
+ // through unchanged; the server validates and normalizes. Either lifecycle
433
+ // key may be omitted, and an optional `models = [...]` filter rides on the
434
+ // same object.
435
+ if (typeSection.timestamps !== undefined) {
436
+ typeConfig.timestamps = typeSection.timestamps;
437
+ }
424
438
  if (tomlData.triggers) {
425
439
  typeConfig.triggers = tomlData.triggers;
426
440
  }
@@ -449,7 +463,12 @@ export function parseDatabaseTypeToml(tomlData) {
449
463
  // - native: `[operations.definition]` table, `[[operations.params]]` rows
450
464
  // and returns the JS shape the server expects.
451
465
  const operations = (tomlData.operations || []).map((op) => normalizeOperationFromToml(op));
452
- return { typeConfig, operations };
466
+ // Subscriptions (issue #803): `[[subscriptions]]` array-of-tables.
467
+ // `normalizeSubscriptionFromToml` maps `accessRule`→`access` (throwing on a
468
+ // conflicting both-present declaration) and forwards `select`/`emit`/`params`
469
+ // in the wire shape. Until #803 this block was dropped at parse time.
470
+ const subscriptions = (tomlData.subscriptions || []).map((sub) => normalizeSubscriptionFromToml(sub));
471
+ return { typeConfig, operations, subscriptions };
453
472
  }
454
473
  function parseRuleSetToml(tomlData) {
455
474
  const ruleSetSection = tomlData.ruleSet || {};
@@ -1074,10 +1093,20 @@ Directory Structure:
1074
1093
  client.listGroupTypeConfigs(resolvedAppId).catch(() => []),
1075
1094
  client.listCollectionTypeConfigs(resolvedAppId).catch(() => []),
1076
1095
  ]);
1077
- // Fetch operations for each database type
1096
+ // Fetch operations + subscriptions for each database type. Issue #803:
1097
+ // subscriptions are pulled symmetrically with operations so a
1098
+ // pull → push cycle round-trips `[[subscriptions]]` blocks without a
1099
+ // spurious diff. The list endpoint already excludes archived rows.
1078
1100
  const databaseTypesWithOps = await Promise.all((Array.isArray(databaseTypeConfigsResult) ? databaseTypeConfigsResult : []).map(async (typeConfig) => {
1079
- const ops = await client.listDatabaseTypeOperations(resolvedAppId, typeConfig.databaseType).catch(() => []);
1080
- return { typeConfig, operations: Array.isArray(ops) ? ops : [] };
1101
+ const [ops, subs] = await Promise.all([
1102
+ client.listDatabaseTypeOperations(resolvedAppId, typeConfig.databaseType).catch(() => []),
1103
+ client.listDatabaseTypeSubscriptions(resolvedAppId, typeConfig.databaseType).catch(() => []),
1104
+ ]);
1105
+ return {
1106
+ typeConfig,
1107
+ operations: Array.isArray(ops) ? ops : [],
1108
+ subscriptions: Array.isArray(subs) ? subs : [],
1109
+ };
1081
1110
  }));
1082
1111
  // Ensure directories exist
1083
1112
  ensureDir(configDir);
@@ -1269,7 +1298,7 @@ Directory Structure:
1269
1298
  }
1270
1299
  // Write database types
1271
1300
  const databaseTypeEntities = {};
1272
- for (const { typeConfig, operations } of databaseTypesWithOps) {
1301
+ for (const { typeConfig, operations, subscriptions } of databaseTypesWithOps) {
1273
1302
  const filename = `${typeConfig.databaseType}.toml`;
1274
1303
  const filePath = join(configDir, "database-types", filename);
1275
1304
  // Preserve the existing file's per-op form (issue #752): if the
@@ -1282,6 +1311,9 @@ Directory Structure:
1282
1311
  hints,
1283
1312
  defaultForm: "native",
1284
1313
  logger: (msg) => info(` ${msg}`),
1314
+ // Issue #803: emit `[[subscriptions]]` blocks so a pull → push
1315
+ // cycle round-trips subscriptions without a spurious diff.
1316
+ subscriptions,
1285
1317
  }));
1286
1318
  const opsEntities = {};
1287
1319
  for (const op of operations) {
@@ -1289,10 +1321,22 @@ Directory Structure:
1289
1321
  modifiedAt: op.modifiedAt || new Date().toISOString(),
1290
1322
  };
1291
1323
  }
1324
+ // Track per-subscription modifiedAt in SyncState (issue #803),
1325
+ // exactly like operations, so the next push skips unchanged
1326
+ // subscriptions and detects removals.
1327
+ const subsEntities = {};
1328
+ for (const sub of subscriptions || []) {
1329
+ if (!sub?.subscriptionKey)
1330
+ continue;
1331
+ subsEntities[sub.subscriptionKey] = {
1332
+ modifiedAt: sub.modifiedAt || new Date().toISOString(),
1333
+ };
1334
+ }
1292
1335
  databaseTypeEntities[typeConfig.databaseType] = {
1293
1336
  databaseType: typeConfig.databaseType,
1294
1337
  modifiedAt: typeConfig.modifiedAt || new Date().toISOString(),
1295
1338
  operations: Object.keys(opsEntities).length > 0 ? opsEntities : undefined,
1339
+ subscriptions: Object.keys(subsEntities).length > 0 ? subsEntities : undefined,
1296
1340
  contentHash: computeFileHash(filePath),
1297
1341
  hasSchema: typeof typeConfig.schema === "string" &&
1298
1342
  typeConfig.schema.trim().length > 0,
@@ -2135,7 +2179,21 @@ Directory Structure:
2135
2179
  ? undefined
2136
2180
  : syncState?.entities?.workflows?.[key]?.modifiedAt;
2137
2181
  try {
2138
- // Update workflow metadata and schemas
2182
+ // Update workflow metadata and schemas.
2183
+ //
2184
+ // Sync-callable ordering (#807, codex review): the metadata
2185
+ // PATCH must NOT carry `syncCallable: true` here. The server
2186
+ // re-validates `syncCallable: true` against the workflow's
2187
+ // CURRENTLY-active server steps (`loadCurrentActiveSteps`),
2188
+ // not the steps being pushed in the same sync. So when a
2189
+ // single TOML edit both enables `syncCallable` and replaces
2190
+ // sync-incompatible active steps with compatible ones, a
2191
+ // combined metadata-first PATCH would be rejected against the
2192
+ // stale steps. Defer `syncCallable` to a second PATCH issued
2193
+ // AFTER the config/step update lands, so it validates against
2194
+ // the new (compatible) active steps. `requiresClientApply`
2195
+ // has no step-dependent validation, so it stays in the first
2196
+ // PATCH unchanged.
2139
2197
  const updated = await client.updateWorkflow(resolvedAppId, existingId, {
2140
2198
  name: workflow.name,
2141
2199
  description: workflow.description,
@@ -2144,9 +2202,19 @@ Directory Structure:
2144
2202
  perUserMaxRunning: workflow.perUserMaxRunning,
2145
2203
  perUserMaxQueued: workflow.perUserMaxQueued,
2146
2204
  dequeueOrder: workflow.dequeueOrder,
2205
+ // Sync-callable flags (#807): pass through as-is. An absent
2206
+ // TOML key is `undefined` (dropped by JSON.stringify, so the
2207
+ // server's hasOwnProperty guard leaves the value untouched);
2208
+ // an explicit `false` is preserved as a meaningful value.
2209
+ requiresClientApply: workflow.requiresClientApply,
2147
2210
  inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
2148
2211
  outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : null,
2149
2212
  }, expectedModifiedAt);
2213
+ // Track the latest workflow `modifiedAt` for the sync-state
2214
+ // write below. The first PATCH bumps it; the `syncCallable`
2215
+ // PATCH (if any) bumps it again. The config/step update does
2216
+ // NOT touch the workflow definition's `modifiedAt`.
2217
+ let latestModifiedAt = updated?.workflow?.modifiedAt;
2150
2218
  // Update active configuration steps (or draft for legacy).
2151
2219
  // Issue #687: name the slot we touched so the dev-loop
2152
2220
  // user can confirm before previewing.
@@ -2164,11 +2232,28 @@ Directory Structure:
2164
2232
  });
2165
2233
  updateSlotLabel = "draft (legacy)";
2166
2234
  }
2235
+ // Second PATCH: apply `syncCallable` now that the new steps
2236
+ // are active (#807). Only sent when the TOML actually carries
2237
+ // the key — an absent key stays `undefined` and we skip the
2238
+ // call entirely, preserving the no-clobber discipline. Chain
2239
+ // `expectedModifiedAt` off the first PATCH's returned value so
2240
+ // optimistic concurrency stays intact (the step update above
2241
+ // doesn't change the workflow definition's `modifiedAt`).
2242
+ if (workflow.syncCallable !== undefined) {
2243
+ const syncCallableUpdated = await client.updateWorkflow(resolvedAppId, existingId, { syncCallable: workflow.syncCallable },
2244
+ // Mirror the first PATCH's concurrency posture: `--force`
2245
+ // skips the check (undefined), otherwise reuse the fresh
2246
+ // `modifiedAt` from the first PATCH.
2247
+ options.force ? undefined : latestModifiedAt);
2248
+ if (syncCallableUpdated?.workflow?.modifiedAt) {
2249
+ latestModifiedAt = syncCallableUpdated.workflow.modifiedAt;
2250
+ }
2251
+ }
2167
2252
  info(` Updated workflow: ${key} (${updateSlotLabel})`);
2168
2253
  // Update sync state with new modifiedAt. Store the *expanded*
2169
2254
  // content hash so future fragment-only edits are detected.
2170
- if (syncState?.entities?.workflows?.[key] && updated?.workflow?.modifiedAt) {
2171
- syncState.entities.workflows[key].modifiedAt = updated.workflow.modifiedAt;
2255
+ if (syncState?.entities?.workflows?.[key] && latestModifiedAt) {
2256
+ syncState.entities.workflows[key].modifiedAt = latestModifiedAt;
2172
2257
  syncState.entities.workflows[key].contentHash = computeExpandedContentHash(tomlData);
2173
2258
  }
2174
2259
  // Fetch full workflow to get config name→ID mappings
@@ -2211,6 +2296,12 @@ Directory Structure:
2211
2296
  perUserMaxRunning: workflow.perUserMaxRunning,
2212
2297
  perUserMaxQueued: workflow.perUserMaxQueued,
2213
2298
  dequeueOrder: workflow.dequeueOrder,
2299
+ // Sync-callable flags (#807): pass through as-is. An absent
2300
+ // TOML key is `undefined` (dropped by JSON.stringify), so the
2301
+ // server applies its defaults (requiresClientApply=true,
2302
+ // syncCallable=false); an explicit value is honored.
2303
+ requiresClientApply: workflow.requiresClientApply,
2304
+ syncCallable: workflow.syncCallable,
2214
2305
  });
2215
2306
  info(` Created workflow: ${key}`);
2216
2307
  // Add new entity to sync state (including activeConfigId)
@@ -2273,7 +2364,25 @@ Directory Structure:
2273
2364
  const filePath = join(dbTypesDir, file);
2274
2365
  const rawToml = readFileSync(filePath, "utf-8");
2275
2366
  const tomlData = parseTomlFile(filePath);
2276
- const { typeConfig, operations } = parseDatabaseTypeToml(tomlData);
2367
+ let typeConfig;
2368
+ let operations;
2369
+ let subscriptions;
2370
+ try {
2371
+ ({ typeConfig, operations, subscriptions } =
2372
+ parseDatabaseTypeToml(tomlData));
2373
+ }
2374
+ catch (err) {
2375
+ // The only structured parse error today is the
2376
+ // `accessRule`/`access` conflict in a `[[subscriptions]]` block
2377
+ // (issue #803). Surface it with the file name and abort the
2378
+ // push rather than crashing with an opaque stack.
2379
+ if (err instanceof SubscriptionAccessKeyConflictError) {
2380
+ error(` ${file}: ${err.message}`);
2381
+ error(`Aborting push: subscription \`access\`/\`accessRule\` conflict in ${file}.`);
2382
+ process.exit(1);
2383
+ }
2384
+ throw err;
2385
+ }
2277
2386
  const dbType = typeConfig.databaseType || basename(file, ".toml");
2278
2387
  // Resolve ruleSetName → ruleSetId if using key-based reference
2279
2388
  resolveRuleSetReference(typeConfig, ruleSetNameToId, `database type ${dbType}`);
@@ -2325,6 +2434,9 @@ Directory Structure:
2325
2434
  updateData.autoPopulatedFields =
2326
2435
  typeConfig.autoPopulatedFields || null;
2327
2436
  }
2437
+ if ("timestamps" in typeConfig) {
2438
+ updateData.timestamps = typeConfig.timestamps || null;
2439
+ }
2328
2440
  // Issue #666: forward `schema` when the local TOML declares
2329
2441
  // one (a set/update), OR when the server had one at last
2330
2442
  // sync and the local file no longer does (a deletion —
@@ -2411,6 +2523,7 @@ Directory Structure:
2411
2523
  "metadataAccess" in typeConfig ||
2412
2524
  "defaultAccess" in typeConfig ||
2413
2525
  "autoPopulatedFields" in typeConfig ||
2526
+ "timestamps" in typeConfig ||
2414
2527
  "schema" in typeConfig;
2415
2528
  if (wouldUpdate) {
2416
2529
  changes.push({ type: "database-type", action: "update", key: dbType });
@@ -2434,6 +2547,8 @@ Directory Structure:
2434
2547
  createData.defaultAccess = typeConfig.defaultAccess;
2435
2548
  if (typeConfig.autoPopulatedFields)
2436
2549
  createData.autoPopulatedFields = typeConfig.autoPopulatedFields;
2550
+ if (typeConfig.timestamps)
2551
+ createData.timestamps = typeConfig.timestamps;
2437
2552
  // Issue #666 addendum A2: forward `schema` on POST so a
2438
2553
  // single sync push can land both the type + its schema in
2439
2554
  // one round-trip (instead of POST → PATCH). The op-edit gate
@@ -2584,6 +2699,115 @@ Directory Structure:
2584
2699
  }
2585
2700
  }
2586
2701
  }
2702
+ // Process subscriptions for this type (issue #803). Mirrors the
2703
+ // operations reconcile above, keyed on `subscriptionKey`:
2704
+ // create new keys, PUT changed/existing keys, delete keys present
2705
+ // in SyncState but absent from the TOML. Server errors (409 key
2706
+ // collision, `#`-in-key 400, max-20 400, missing-required 400)
2707
+ // are surfaced cleanly via `wrapEntityError` — named, not a crash.
2708
+ const existingSubs = syncState?.entities?.databaseTypes?.[dbType]?.subscriptions || {};
2709
+ const tomlSubKeys = new Set(subscriptions.map((sub) => sub.subscriptionKey));
2710
+ for (const sub of subscriptions) {
2711
+ const subKey = sub.subscriptionKey;
2712
+ const existingSub = existingSubs[subKey];
2713
+ if (existingSub) {
2714
+ // Update existing subscription (PUT — idempotent).
2715
+ changes.push({ type: "subscription", action: "update", key: `${dbType}/${subKey}` });
2716
+ if (!options.dryRun) {
2717
+ try {
2718
+ const updated = await client.updateDatabaseTypeSubscription(resolvedAppId, dbType, subKey, {
2719
+ displayName: sub.displayName,
2720
+ modelName: sub.modelName,
2721
+ filter: sub.filter,
2722
+ access: sub.access,
2723
+ description: sub.description,
2724
+ select: sub.select,
2725
+ emit: sub.emit,
2726
+ params: sub.params,
2727
+ status: sub.status,
2728
+ });
2729
+ info(` Updated subscription: ${dbType}/${subKey}`);
2730
+ // Gate the SyncState write on a server-returned
2731
+ // `modifiedAt`, mirroring the operations precedent above
2732
+ // (don't fall back to a fresh local timestamp).
2733
+ if (syncState?.entities?.databaseTypes?.[dbType]?.subscriptions?.[subKey] && updated?.modifiedAt) {
2734
+ syncState.entities.databaseTypes[dbType].subscriptions[subKey].modifiedAt =
2735
+ updated.modifiedAt;
2736
+ }
2737
+ }
2738
+ catch (err) {
2739
+ throw wrapEntityError(err, "update", "subscription", `${dbType}/${subKey}`);
2740
+ }
2741
+ }
2742
+ }
2743
+ else {
2744
+ // Create new subscription (POST).
2745
+ changes.push({ type: "subscription", action: "create", key: `${dbType}/${subKey}` });
2746
+ if (!options.dryRun) {
2747
+ try {
2748
+ const created = await client.createDatabaseTypeSubscription(resolvedAppId, dbType, {
2749
+ subscriptionKey: subKey,
2750
+ displayName: sub.displayName,
2751
+ modelName: sub.modelName,
2752
+ filter: sub.filter,
2753
+ access: sub.access,
2754
+ description: sub.description,
2755
+ select: sub.select,
2756
+ emit: sub.emit,
2757
+ params: sub.params,
2758
+ status: sub.status,
2759
+ });
2760
+ info(` Created subscription: ${dbType}/${subKey}`);
2761
+ if (syncState?.entities?.databaseTypes?.[dbType]) {
2762
+ if (!syncState.entities.databaseTypes[dbType].subscriptions) {
2763
+ syncState.entities.databaseTypes[dbType].subscriptions = {};
2764
+ }
2765
+ syncState.entities.databaseTypes[dbType].subscriptions[subKey] = {
2766
+ modifiedAt: created?.modifiedAt || new Date().toISOString(),
2767
+ };
2768
+ }
2769
+ }
2770
+ catch (err) {
2771
+ throw wrapEntityError(err, "create", "subscription", `${dbType}/${subKey}`);
2772
+ }
2773
+ }
2774
+ }
2775
+ }
2776
+ // Delete subscriptions that were removed from TOML (hard-delete on
2777
+ // current `main`, PR #787 — frees the key for reuse). A 409 on
2778
+ // recreate is impossible here since we delete first; the pull-side
2779
+ // "active only" filter (controller `list` excludes archived)
2780
+ // agrees because hard-delete leaves no archived row behind.
2781
+ for (const existingSubKey of Object.keys(existingSubs)) {
2782
+ if (!tomlSubKeys.has(existingSubKey)) {
2783
+ changes.push({ type: "subscription", action: "delete", key: `${dbType}/${existingSubKey}` });
2784
+ if (!options.dryRun) {
2785
+ try {
2786
+ await client.deleteDatabaseTypeSubscription(resolvedAppId, dbType, existingSubKey);
2787
+ info(` Deleted subscription: ${dbType}/${existingSubKey}`);
2788
+ if (syncState?.entities?.databaseTypes?.[dbType]?.subscriptions) {
2789
+ delete syncState.entities.databaseTypes[dbType].subscriptions[existingSubKey];
2790
+ }
2791
+ }
2792
+ catch (err) {
2793
+ throw wrapEntityError(err, "delete", "subscription", `${dbType}/${existingSubKey}`);
2794
+ }
2795
+ }
2796
+ }
2797
+ }
2798
+ // Refresh the file's content hash after processing operations +
2799
+ // subscriptions (issue #803, behavior g). The type-config update
2800
+ // block only refreshes the hash when a type-level field changed
2801
+ // (its `updateData` was non-empty); an operations- or
2802
+ // subscriptions-only edit left a stale hash, so the next push
2803
+ // would re-PUT every op/sub instead of skipping the unchanged
2804
+ // file. Recompute here so a truly-unchanged subsequent push skips
2805
+ // the whole file via `shouldPushFile`. `--force` bypasses that
2806
+ // skip, so forced re-pushes are unaffected.
2807
+ if (!options.dryRun && syncState?.entities?.databaseTypes?.[dbType]) {
2808
+ syncState.entities.databaseTypes[dbType].contentHash =
2809
+ computeFileHash(filePath);
2810
+ }
2587
2811
  }
2588
2812
  }
2589
2813
  // Process group type configs
@@ -3343,12 +3567,30 @@ Directory Structure:
3343
3567
  const filePath = join(dbTypesDir, file);
3344
3568
  const rawBefore = readFileSync(filePath, "utf-8");
3345
3569
  const tomlData = parseTomlFile(filePath);
3346
- const { typeConfig, operations } = parseDatabaseTypeToml(tomlData);
3570
+ // `accessRule`/`access` conflicts surface as a clear error here too,
3571
+ // rather than silently dropping the field during a migrate rewrite.
3572
+ let typeConfig;
3573
+ let operations;
3574
+ let subscriptions;
3575
+ try {
3576
+ ({ typeConfig, operations, subscriptions } =
3577
+ parseDatabaseTypeToml(tomlData));
3578
+ }
3579
+ catch (err) {
3580
+ if (err instanceof SubscriptionAccessKeyConflictError) {
3581
+ error(` ${file}: ${err.message}`);
3582
+ error(`Aborting migrate-toml: subscription \`access\`/\`accessRule\` conflict in ${file}.`);
3583
+ process.exit(1);
3584
+ }
3585
+ throw err;
3586
+ }
3347
3587
  // Force-native: ignore existing hints so every TOMLable field
3348
- // ends up in nested-table form.
3588
+ // ends up in nested-table form. Subscriptions are preserved on the
3589
+ // rewrite (issue #803) so migrate-toml never drops them.
3349
3590
  const rewritten = serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
3350
3591
  defaultForm: "native",
3351
3592
  logger: (msg) => info(` ${msg}`),
3593
+ subscriptions,
3352
3594
  });
3353
3595
  if (rewritten === rawBefore) {
3354
3596
  unchangedCount++;