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.
- package/dist/src/commands/databases.js +134 -0
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/sync.js +254 -12
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/workflows.js +12 -0
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.js +33 -0
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/db-codegen/dbFingerprint.js +17 -0
- package/dist/src/lib/db-codegen/dbFingerprint.js.map +1 -0
- package/dist/src/lib/db-codegen/dbGenerator.js +255 -0
- package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -0
- package/dist/src/lib/db-codegen/dbNaming.js +104 -0
- package/dist/src/lib/db-codegen/dbNaming.js.map +1 -0
- package/dist/src/lib/db-codegen/dbTemplates.js +138 -0
- package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -0
- package/dist/src/lib/db-codegen/dbTsTypes.js +61 -0
- package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -0
- package/dist/src/lib/toml-database-config.js +182 -1
- package/dist/src/lib/toml-database-config.js.map +1 -1
- package/dist/src/lib/workflow-toml-validator.js +16 -1
- package/dist/src/lib/workflow-toml-validator.js.map +1 -1
- package/package.json +2 -1
|
@@ -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
|
-
|
|
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
|
|
1080
|
-
|
|
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] &&
|
|
2171
|
-
syncState.entities.workflows[key].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
|
-
|
|
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
|
-
|
|
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++;
|