primitive-admin 1.0.50 → 1.0.52
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/README.md +27 -8
- package/dist/src/commands/admins.js +25 -27
- package/dist/src/commands/admins.js.map +1 -1
- package/dist/src/commands/apps.js +8 -0
- package/dist/src/commands/apps.js.map +1 -1
- package/dist/src/commands/blob-buckets.js +30 -26
- package/dist/src/commands/blob-buckets.js.map +1 -1
- package/dist/src/commands/catalog.js +17 -18
- package/dist/src/commands/catalog.js.map +1 -1
- package/dist/src/commands/collection-type-configs.js +9 -9
- package/dist/src/commands/collection-type-configs.js.map +1 -1
- package/dist/src/commands/collections.js +33 -36
- package/dist/src/commands/collections.js.map +1 -1
- package/dist/src/commands/database-types.js +17 -18
- package/dist/src/commands/database-types.js.map +1 -1
- package/dist/src/commands/databases.js +41 -45
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/documents.js +17 -18
- package/dist/src/commands/documents.js.map +1 -1
- package/dist/src/commands/email-templates.js +9 -9
- package/dist/src/commands/email-templates.js.map +1 -1
- package/dist/src/commands/group-type-configs.js +9 -9
- package/dist/src/commands/group-type-configs.js.map +1 -1
- package/dist/src/commands/groups.js +17 -18
- package/dist/src/commands/groups.js.map +1 -1
- package/dist/src/commands/integrations.js +17 -18
- package/dist/src/commands/integrations.js.map +1 -1
- package/dist/src/commands/llm.js +4 -2
- package/dist/src/commands/llm.js.map +1 -1
- package/dist/src/commands/prompts.js +33 -36
- package/dist/src/commands/prompts.js.map +1 -1
- package/dist/src/commands/rule-sets.js +9 -9
- package/dist/src/commands/rule-sets.js.map +1 -1
- package/dist/src/commands/sync.d.ts +30 -14
- package/dist/src/commands/sync.js +289 -95
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/tokens.js +9 -9
- package/dist/src/commands/tokens.js.map +1 -1
- package/dist/src/commands/users.js +41 -45
- package/dist/src/commands/users.js.map +1 -1
- package/dist/src/commands/waitlist.js +9 -9
- package/dist/src/commands/waitlist.js.map +1 -1
- package/dist/src/commands/webhooks.js +9 -9
- package/dist/src/commands/webhooks.js.map +1 -1
- package/dist/src/commands/workflows.js +62 -36
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +9 -24
- package/dist/src/lib/api-client.js +36 -33
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/confirm-prompt.d.ts +25 -8
- package/dist/src/lib/confirm-prompt.js +44 -19
- package/dist/src/lib/confirm-prompt.js.map +1 -1
- package/dist/src/lib/generated-allowlist.d.ts +28 -0
- package/dist/src/lib/generated-allowlist.js +195 -0
- package/dist/src/lib/generated-allowlist.js.map +1 -0
- package/dist/src/lib/workflow-toml-validator.d.ts +26 -17
- package/dist/src/lib/workflow-toml-validator.js +52 -141
- package/dist/src/lib/workflow-toml-validator.js.map +1 -1
- package/package.json +4 -1
|
@@ -12,6 +12,7 @@ import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneS
|
|
|
12
12
|
import { validateWorkflowToml, formatWorkflowTomlErrors, } from "../lib/workflow-toml-validator.js";
|
|
13
13
|
import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
|
|
14
14
|
import { success, error, info, warn, keyValue, json, divider, } from "../lib/output.js";
|
|
15
|
+
import { confirmPrompt } from "../lib/confirm-prompt.js";
|
|
15
16
|
import chalk from "chalk";
|
|
16
17
|
function ensureDir(dirPath) {
|
|
17
18
|
if (!existsSync(dirPath)) {
|
|
@@ -130,6 +131,74 @@ export function shouldPushExpandedFile(parsed, storedHash) {
|
|
|
130
131
|
return true;
|
|
131
132
|
return computeExpandedContentHash(parsed) !== storedHash;
|
|
132
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Model defaults for the *exact five* workflow-level fields that
|
|
136
|
+
* `serializeWorkflow` emits unconditionally (`sync.ts` workflow serializer:
|
|
137
|
+
* `perUserMaxRunning`, `perUserMaxQueued`, `dequeueOrder`,
|
|
138
|
+
* `requiresClientApply`, `syncCallable`). These mirror `models.yaml`
|
|
139
|
+
* (`WorkflowDefinition`): `perUserMaxRunning=4`, `perUserMaxQueued=100`,
|
|
140
|
+
* `dequeueOrder="fifo"`, `requiresClientApply=true`, `syncCallable=false`.
|
|
141
|
+
*
|
|
142
|
+
* This is deliberately NOT a generic models.yaml default importer — the model
|
|
143
|
+
* carries many defaults the serializer never writes (`perAppMax*`,
|
|
144
|
+
* `queueTtlSeconds`, …). Only the serializer-emitted surface matters for a
|
|
145
|
+
* content comparison, because the remote side is hashed via `serializeWorkflow`
|
|
146
|
+
* and therefore only ever contains these five.
|
|
147
|
+
*/
|
|
148
|
+
const WORKFLOW_SERIALIZER_DEFAULTS = {
|
|
149
|
+
perUserMaxRunning: 4,
|
|
150
|
+
perUserMaxQueued: 100,
|
|
151
|
+
dequeueOrder: "fifo",
|
|
152
|
+
requiresClientApply: true,
|
|
153
|
+
syncCallable: false,
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* Normalize a parsed workflow TOML object so that any of the five
|
|
157
|
+
* serializer-emitted fields the *local* file omits are filled in with the
|
|
158
|
+
* model default. Used by `diff` so a hand-authored workflow that omits e.g.
|
|
159
|
+
* `perUserMaxRunning` compares equal to a server that defaulted it to 4
|
|
160
|
+
* (the #1175 false-positive guard) — while a server value that was
|
|
161
|
+
* *explicitly set* to a non-default (e.g. 8) still shows as Modified.
|
|
162
|
+
*
|
|
163
|
+
* Returns a shallow clone with a normalized `workflow` table; the input is
|
|
164
|
+
* not mutated. Non-workflow TOML (no `workflow` table) is returned unchanged.
|
|
165
|
+
*/
|
|
166
|
+
export function normalizeWorkflowTomlDefaults(parsed) {
|
|
167
|
+
if (!parsed || typeof parsed !== "object" || !parsed.workflow) {
|
|
168
|
+
return parsed;
|
|
169
|
+
}
|
|
170
|
+
const workflow = { ...parsed.workflow };
|
|
171
|
+
for (const [field, defaultValue] of Object.entries(WORKFLOW_SERIALIZER_DEFAULTS)) {
|
|
172
|
+
if (workflow[field] === undefined || workflow[field] === null) {
|
|
173
|
+
workflow[field] = defaultValue;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { ...parsed, workflow };
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Canonical content hash of a workflow TOML for `diff`'s content comparison.
|
|
180
|
+
* Applies `normalizeWorkflowTomlDefaults` first so omitted-vs-defaulted fields
|
|
181
|
+
* don't spuriously diff, then runs the same `computeExpandedContentHash` that
|
|
182
|
+
* pull stores and push compares. Both the local file and the
|
|
183
|
+
* `serializeWorkflow`-produced remote form flow through this single function,
|
|
184
|
+
* so the two sides are normalized identically by construction.
|
|
185
|
+
*/
|
|
186
|
+
export function hashWorkflowTomlForDiff(parsed) {
|
|
187
|
+
return computeExpandedContentHash(normalizeWorkflowTomlDefaults(parsed));
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Build the canonical content hash for a *server* workflow (as returned by
|
|
191
|
+
* `getWorkflow` + active `getWorkflowConfig`), mirroring exactly what a fresh
|
|
192
|
+
* `sync pull` would write to disk. Serializes via `serializeWorkflow`, parses
|
|
193
|
+
* the resulting TOML (remote serialized TOML never carries `include`s, so a
|
|
194
|
+
* plain `TOML.parse` is sufficient — no fragment path needed), then hashes
|
|
195
|
+
* through `hashWorkflowTomlForDiff` so it lines up with the local-file hash.
|
|
196
|
+
*/
|
|
197
|
+
export function hashRemoteWorkflowForDiff(workflow, draft, configs) {
|
|
198
|
+
const serialized = serializeWorkflow(workflow, draft, configs || []);
|
|
199
|
+
const parsed = TOML.parse(serialized);
|
|
200
|
+
return hashWorkflowTomlForDiff(parsed);
|
|
201
|
+
}
|
|
133
202
|
// TOML serialization helpers
|
|
134
203
|
function serializeAppSettings(settings) {
|
|
135
204
|
const data = {
|
|
@@ -260,7 +329,13 @@ function serializeBlobBucket(bucket) {
|
|
|
260
329
|
name: bucket.name,
|
|
261
330
|
description: bucket.description || undefined,
|
|
262
331
|
ttlTier: bucket.ttlTier,
|
|
263
|
-
|
|
332
|
+
// #1020: `preset` is the honest key; a custom bucket carries ruleSetId
|
|
333
|
+
// instead. Omit preset for custom buckets (preset === "custom").
|
|
334
|
+
preset: bucket.preset && bucket.preset !== "custom"
|
|
335
|
+
? bucket.preset
|
|
336
|
+
: bucket.ruleSetId
|
|
337
|
+
? undefined
|
|
338
|
+
: bucket.preset,
|
|
264
339
|
},
|
|
265
340
|
};
|
|
266
341
|
if (bucket.ruleSetId) {
|
|
@@ -313,6 +388,9 @@ function serializeWorkflow(workflow, draft, configs) {
|
|
|
313
388
|
status: workflow.status,
|
|
314
389
|
activeConfigName: activeConfigName,
|
|
315
390
|
accessRule: workflow.accessRule || undefined,
|
|
391
|
+
// #1081 — workflow principal mode. Emit only when set so a fresh
|
|
392
|
+
// pull → push round-trips it without writing a noisy `runAs = ""`.
|
|
393
|
+
runAs: workflow.runAs || undefined,
|
|
316
394
|
perUserMaxRunning: workflow.perUserMaxRunning,
|
|
317
395
|
perUserMaxQueued: workflow.perUserMaxQueued,
|
|
318
396
|
dequeueOrder: workflow.dequeueOrder,
|
|
@@ -734,56 +812,6 @@ export async function pullScripts(client, appId, configDir, logger = () => { })
|
|
|
734
812
|
}
|
|
735
813
|
return { scriptEntities, count: items.length };
|
|
736
814
|
}
|
|
737
|
-
/**
|
|
738
|
-
* Issue #973 — format the "stale referencing workflows" warning for a script
|
|
739
|
-
* push. Pure (no I/O) so it can be unit-tested directly.
|
|
740
|
-
*
|
|
741
|
-
* `staleWorkflows` is the server's report of active workflows whose frozen
|
|
742
|
-
* snapshot still pins a DIFFERENT (old) body hash for `scriptName` — i.e. the
|
|
743
|
-
* workflows that will keep running the previous script body until republished.
|
|
744
|
-
* Returns `null` when nothing is stale (no warning to print). When `dryRun` is
|
|
745
|
-
* true the wording reflects that nothing was mutated yet.
|
|
746
|
-
*/
|
|
747
|
-
export function formatStaleWorkflowsWarning(scriptName, staleWorkflows, dryRun = false) {
|
|
748
|
-
if (!staleWorkflows || staleWorkflows.length === 0)
|
|
749
|
-
return null;
|
|
750
|
-
const labels = staleWorkflows.map((w) => w.workflowKey || w.name || "(unknown workflow)");
|
|
751
|
-
const count = labels.length;
|
|
752
|
-
const noun = count === 1 ? "workflow" : "workflows";
|
|
753
|
-
const verb = dryRun ? "would still run" : "still run";
|
|
754
|
-
return (`Script "${scriptName}" ${dryRun ? "would be updated" : "updated"}, but ` +
|
|
755
|
-
`${count} ${noun} ${verb} the previous body until re-pushed: ` +
|
|
756
|
-
`${labels.join(", ")}. ` +
|
|
757
|
-
`Re-push (or republish) ${count === 1 ? "it" : "them"} to pick up the change ` +
|
|
758
|
-
`(the run path reads each workflow's frozen snapshot, not the live script).`);
|
|
759
|
-
}
|
|
760
|
-
/**
|
|
761
|
-
* Issue #973 — query the server for active workflows left stale by a script
|
|
762
|
-
* push and print a warning naming them. Best-effort: a missing route (older
|
|
763
|
-
* server) or any transport error is swallowed so it never fails the sync.
|
|
764
|
-
*
|
|
765
|
-
* `contentHash` is the body hash being pushed (the server's authoritative hash
|
|
766
|
-
* after a real push, or the locally-computed would-be hash for `--dry-run`).
|
|
767
|
-
*/
|
|
768
|
-
async function warnStaleWorkflowsForScript(client, appId, scriptId, contentHash, dryRun) {
|
|
769
|
-
let report;
|
|
770
|
-
try {
|
|
771
|
-
report = await client.getStaleWorkflowsForScript(appId, scriptId, contentHash);
|
|
772
|
-
}
|
|
773
|
-
catch {
|
|
774
|
-
// Older server without the route, or a transient failure — staleness
|
|
775
|
-
// info is advisory, so we stay silent rather than break the push.
|
|
776
|
-
return;
|
|
777
|
-
}
|
|
778
|
-
const message = formatStaleWorkflowsWarning(report.scriptName, report.staleWorkflows, dryRun);
|
|
779
|
-
if (message) {
|
|
780
|
-
warn(` ${message}`);
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
/** SHA-256 hex of a string body, matching the server's script `contentHash`. */
|
|
784
|
-
function sha256HexString(body) {
|
|
785
|
-
return createHash("sha256").update(body, "utf-8").digest("hex");
|
|
786
|
-
}
|
|
787
815
|
async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, testCaseEntities, lookupMaps) {
|
|
788
816
|
let testCases;
|
|
789
817
|
try {
|
|
@@ -2273,8 +2301,29 @@ Directory Structure:
|
|
|
2273
2301
|
continue;
|
|
2274
2302
|
}
|
|
2275
2303
|
if (existingId) {
|
|
2276
|
-
//
|
|
2277
|
-
|
|
2304
|
+
// #1020 (D8): idempotently apply preset/ruleSet/name/description
|
|
2305
|
+
// changes via PATCH. bucketKey/ttlTier are immutable and not sent.
|
|
2306
|
+
const updatePayload = {};
|
|
2307
|
+
if (bucket.preset)
|
|
2308
|
+
updatePayload.preset = bucket.preset;
|
|
2309
|
+
else if (bucket.accessPolicy)
|
|
2310
|
+
updatePayload.accessPolicy = bucket.accessPolicy;
|
|
2311
|
+
if (bucket.ruleSetId)
|
|
2312
|
+
updatePayload.ruleSetId = bucket.ruleSetId;
|
|
2313
|
+
if (bucket.name)
|
|
2314
|
+
updatePayload.name = bucket.name;
|
|
2315
|
+
if (bucket.description !== undefined)
|
|
2316
|
+
updatePayload.description = bucket.description || null;
|
|
2317
|
+
changes.push({ type: "blob-bucket", action: "update", key });
|
|
2318
|
+
if (!options.dryRun) {
|
|
2319
|
+
try {
|
|
2320
|
+
await client.updateBlobBucket(resolvedAppId, existingId, updatePayload);
|
|
2321
|
+
info(` Updated blob bucket: ${key}`);
|
|
2322
|
+
}
|
|
2323
|
+
catch (err) {
|
|
2324
|
+
warn(` Failed to update blob bucket ${key}: ${String(err?.message || err)}`);
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2278
2327
|
if (syncState?.entities?.blobBuckets?.[key]) {
|
|
2279
2328
|
syncState.entities.blobBuckets[key].contentHash = computeFileHash(filePath);
|
|
2280
2329
|
}
|
|
@@ -2284,8 +2333,11 @@ Directory Structure:
|
|
|
2284
2333
|
bucketKey: key,
|
|
2285
2334
|
name: bucket.name || key,
|
|
2286
2335
|
ttlTier: bucket.ttlTier,
|
|
2287
|
-
accessPolicy: bucket.accessPolicy,
|
|
2288
2336
|
};
|
|
2337
|
+
if (bucket.preset)
|
|
2338
|
+
payload.preset = bucket.preset;
|
|
2339
|
+
else if (bucket.accessPolicy)
|
|
2340
|
+
payload.accessPolicy = bucket.accessPolicy;
|
|
2289
2341
|
if (bucket.description)
|
|
2290
2342
|
payload.description = bucket.description;
|
|
2291
2343
|
if (bucket.ruleSetId)
|
|
@@ -2578,7 +2630,10 @@ Directory Structure:
|
|
|
2578
2630
|
changes.push({ type: "script", action: "update", key: name });
|
|
2579
2631
|
if (!options.dryRun) {
|
|
2580
2632
|
try {
|
|
2581
|
-
|
|
2633
|
+
// #1000 — push a new ScriptConfig + activate it. Referencing
|
|
2634
|
+
// workflows pick up the new body on their next run (no
|
|
2635
|
+
// fan-out, no re-push needed).
|
|
2636
|
+
await client.pushScriptBody(resolvedAppId, existingId, body);
|
|
2582
2637
|
info(` Updated script: ${name}`);
|
|
2583
2638
|
if (syncState) {
|
|
2584
2639
|
if (!syncState.entities.scripts) {
|
|
@@ -2586,24 +2641,16 @@ Directory Structure:
|
|
|
2586
2641
|
}
|
|
2587
2642
|
syncState.entities.scripts[name] = {
|
|
2588
2643
|
id: existingId,
|
|
2589
|
-
modifiedAt:
|
|
2590
|
-
syncState.entities.scripts[name]?.modifiedAt ||
|
|
2644
|
+
modifiedAt: syncState.entities.scripts[name]?.modifiedAt ||
|
|
2591
2645
|
new Date().toISOString(),
|
|
2592
2646
|
contentHash: computeFileHash(filePath),
|
|
2593
2647
|
};
|
|
2594
2648
|
}
|
|
2595
|
-
// Issue #973 — warn if active workflows still pin the OLD
|
|
2596
|
-
// body. Use the server's authoritative post-push hash.
|
|
2597
|
-
await warnStaleWorkflowsForScript(client, resolvedAppId, existingId, updated?.contentHash || sha256HexString(body), false);
|
|
2598
2649
|
}
|
|
2599
2650
|
catch (err) {
|
|
2600
2651
|
throw wrapEntityError(err, "update", "script", name);
|
|
2601
2652
|
}
|
|
2602
2653
|
}
|
|
2603
|
-
else {
|
|
2604
|
-
// Dry-run: surface the would-be stale set without mutating.
|
|
2605
|
-
await warnStaleWorkflowsForScript(client, resolvedAppId, existingId, sha256HexString(body), true);
|
|
2606
|
-
}
|
|
2607
2654
|
}
|
|
2608
2655
|
else {
|
|
2609
2656
|
// No id in local state — could be brand new OR could be
|
|
@@ -2616,7 +2663,7 @@ Directory Structure:
|
|
|
2616
2663
|
changes.push({ type: "script", action: "update", key: name });
|
|
2617
2664
|
if (!options.dryRun) {
|
|
2618
2665
|
try {
|
|
2619
|
-
|
|
2666
|
+
await client.pushScriptBody(resolvedAppId, match.scriptId, body);
|
|
2620
2667
|
info(` Updated script: ${name} (adopted from server)`);
|
|
2621
2668
|
if (syncState) {
|
|
2622
2669
|
if (!syncState.entities.scripts) {
|
|
@@ -2624,21 +2671,15 @@ Directory Structure:
|
|
|
2624
2671
|
}
|
|
2625
2672
|
syncState.entities.scripts[name] = {
|
|
2626
2673
|
id: match.scriptId,
|
|
2627
|
-
modifiedAt:
|
|
2674
|
+
modifiedAt: new Date().toISOString(),
|
|
2628
2675
|
contentHash: computeFileHash(filePath),
|
|
2629
2676
|
};
|
|
2630
2677
|
}
|
|
2631
|
-
// Issue #973 — same staleness warning on the adopt path.
|
|
2632
|
-
await warnStaleWorkflowsForScript(client, resolvedAppId, match.scriptId, updated?.contentHash || sha256HexString(body), false);
|
|
2633
2678
|
}
|
|
2634
2679
|
catch (err) {
|
|
2635
2680
|
throw wrapEntityError(err, "update", "script", name);
|
|
2636
2681
|
}
|
|
2637
2682
|
}
|
|
2638
|
-
else {
|
|
2639
|
-
// Dry-run on the adopt path.
|
|
2640
|
-
await warnStaleWorkflowsForScript(client, resolvedAppId, match.scriptId, sha256HexString(body), true);
|
|
2641
|
-
}
|
|
2642
2683
|
}
|
|
2643
2684
|
else {
|
|
2644
2685
|
changes.push({ type: "script", action: "create", key: name });
|
|
@@ -2738,6 +2779,10 @@ Directory Structure:
|
|
|
2738
2779
|
description: workflow.description,
|
|
2739
2780
|
status: workflow.status,
|
|
2740
2781
|
accessRule: workflow.accessRule !== undefined ? (workflow.accessRule || null) : undefined,
|
|
2782
|
+
// #1081 — workflow principal mode. Absent key → undefined
|
|
2783
|
+
// (server leaves it untouched); explicit value/empty is
|
|
2784
|
+
// forwarded so it round-trips.
|
|
2785
|
+
runAs: workflow.runAs !== undefined ? (workflow.runAs || null) : undefined,
|
|
2741
2786
|
perUserMaxRunning: workflow.perUserMaxRunning,
|
|
2742
2787
|
perUserMaxQueued: workflow.perUserMaxQueued,
|
|
2743
2788
|
dequeueOrder: workflow.dequeueOrder,
|
|
@@ -2832,6 +2877,8 @@ Directory Structure:
|
|
|
2832
2877
|
inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : undefined,
|
|
2833
2878
|
outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : undefined,
|
|
2834
2879
|
accessRule: workflow.accessRule || undefined,
|
|
2880
|
+
// #1081 — workflow principal mode (absent → server default).
|
|
2881
|
+
runAs: workflow.runAs || undefined,
|
|
2835
2882
|
perUserMaxRunning: workflow.perUserMaxRunning,
|
|
2836
2883
|
perUserMaxQueued: workflow.perUserMaxQueued,
|
|
2837
2884
|
dequeueOrder: workflow.dequeueOrder,
|
|
@@ -2946,6 +2993,15 @@ Directory Structure:
|
|
|
2946
2993
|
`\nAborting push: ${validation.errors.length} unresolved $params reference(s) in ${file}.`);
|
|
2947
2994
|
}
|
|
2948
2995
|
const existingEntry = syncState?.entities?.databaseTypes?.[dbType];
|
|
2996
|
+
// Issue #915 (defect b): track whether the apply path rejected any
|
|
2997
|
+
// op for THIS file via the schema gate (OperationRefError /
|
|
2998
|
+
// SchemaRequiredError). On a non-transactional fresh-type push the
|
|
2999
|
+
// db-type lands but the bad op is blocked and the loop continues; if
|
|
3000
|
+
// we then wrote the file-level contentHash, the next push's
|
|
3001
|
+
// `shouldPushFile` would short-circuit and the missing op would be
|
|
3002
|
+
// invisible drift. When this flag is set we skip the contentHash
|
|
3003
|
+
// write so the drift stays visible and a corrected re-push converges.
|
|
3004
|
+
let fileHadGateRejectedOp = false;
|
|
2949
3005
|
// Skip if file hasn't changed since last sync
|
|
2950
3006
|
if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
|
|
2951
3007
|
skippedCount++;
|
|
@@ -3032,8 +3088,7 @@ Directory Structure:
|
|
|
3032
3088
|
}
|
|
3033
3089
|
// 2. Op-edit gate (op create/update dry-run) for every op in the
|
|
3034
3090
|
// TOML. The server runs `runSchemaGate` and short-circuits
|
|
3035
|
-
// before persisting.
|
|
3036
|
-
// server yet → gate is a no-op anyway).
|
|
3091
|
+
// before persisting.
|
|
3037
3092
|
//
|
|
3038
3093
|
// The op gate is run against the schema THIS push is landing
|
|
3039
3094
|
// (`schemaOverride`), not the stale stored schema — the real
|
|
@@ -3043,11 +3098,36 @@ Directory Structure:
|
|
|
3043
3098
|
// a schema landed earlier in the same push"). When the push
|
|
3044
3099
|
// isn't changing the schema, leave the override unset so the
|
|
3045
3100
|
// server uses the stored schema.
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3101
|
+
//
|
|
3102
|
+
// Issue #915 (defect a): this gate now runs for FRESH types too
|
|
3103
|
+
// (previously guarded by `if (existingEntry)`). For a brand-new
|
|
3104
|
+
// db-type the server has no stored config, so we gate the op
|
|
3105
|
+
// against the PROPOSED local schema via `schemaOverride`. The
|
|
3106
|
+
// server's op-create dry-run accepts a missing type when a
|
|
3107
|
+
// `schemaOverride` is present (see
|
|
3108
|
+
// database-type-operations-controller.ts). A fresh schemaless
|
|
3109
|
+
// type (#666 bootstrap) sends an empty/absent override, and
|
|
3110
|
+
// `runSchemaGate` is a no-op without a schema — so that path
|
|
3111
|
+
// still succeeds.
|
|
3112
|
+
{
|
|
3113
|
+
const localHasSchema = typeof typeConfig.schema === "string" &&
|
|
3114
|
+
typeConfig.schema.trim().length > 0;
|
|
3115
|
+
let proposedSchema;
|
|
3116
|
+
if (existingEntry) {
|
|
3117
|
+
const validateUpdateData = computeTypeUpdateData();
|
|
3118
|
+
proposedSchema =
|
|
3119
|
+
"schema" in validateUpdateData
|
|
3120
|
+
? validateUpdateData.schema
|
|
3121
|
+
: undefined;
|
|
3122
|
+
}
|
|
3123
|
+
else {
|
|
3124
|
+
// Fresh type: the proposed schema is whatever the local TOML
|
|
3125
|
+
// declares. A schemaless fresh type (no `[models.*]`) sends an
|
|
3126
|
+
// empty-string override so the server takes the no-schema =
|
|
3127
|
+
// no-op gate path rather than falling back to a (nonexistent)
|
|
3128
|
+
// stored config — keeping the #666 bootstrap convergent.
|
|
3129
|
+
proposedSchema = localHasSchema ? typeConfig.schema : "";
|
|
3130
|
+
}
|
|
3051
3131
|
for (const op of operations) {
|
|
3052
3132
|
const existingOp = existingOpsForType[op.name];
|
|
3053
3133
|
opGateContext = {
|
|
@@ -3071,7 +3151,20 @@ Directory Structure:
|
|
|
3071
3151
|
access: op.access,
|
|
3072
3152
|
definition: op.definition,
|
|
3073
3153
|
params: op.params,
|
|
3074
|
-
}, {
|
|
3154
|
+
}, {
|
|
3155
|
+
dryRun: true,
|
|
3156
|
+
schemaOverride: proposedSchema,
|
|
3157
|
+
// Issue #915 (defect a, follow-up): for a fresh type the
|
|
3158
|
+
// server has no stored config to read `defaultAccess`
|
|
3159
|
+
// from, so an op that omits `access` to inherit the
|
|
3160
|
+
// TOML-declared type-level `defaultAccess` would be
|
|
3161
|
+
// falsely rejected. Thread the proposed value so the
|
|
3162
|
+
// fresh-type dry-run gates the same way the real push
|
|
3163
|
+
// (which lands the type first) will.
|
|
3164
|
+
defaultAccess: typeof typeConfig.defaultAccess === "string"
|
|
3165
|
+
? typeConfig.defaultAccess
|
|
3166
|
+
: undefined,
|
|
3167
|
+
});
|
|
3075
3168
|
}
|
|
3076
3169
|
}
|
|
3077
3170
|
// Every op gate passed; clear the context so a later (non-op)
|
|
@@ -3563,6 +3656,10 @@ Directory Structure:
|
|
|
3563
3656
|
key: `${dbType}/${op.name}`,
|
|
3564
3657
|
message: err.message,
|
|
3565
3658
|
});
|
|
3659
|
+
// Issue #915 (defect b): a gate-rejected op means this
|
|
3660
|
+
// file's state did NOT fully land — don't poison the
|
|
3661
|
+
// content hash (see the contentHash write below).
|
|
3662
|
+
fileHadGateRejectedOp = true;
|
|
3566
3663
|
}
|
|
3567
3664
|
else if (err instanceof OperationRefError) {
|
|
3568
3665
|
schemaErrors.opRefs.push({
|
|
@@ -3571,6 +3668,7 @@ Directory Structure:
|
|
|
3571
3668
|
refs: err.refs,
|
|
3572
3669
|
message: err.message,
|
|
3573
3670
|
});
|
|
3671
|
+
fileHadGateRejectedOp = true;
|
|
3574
3672
|
}
|
|
3575
3673
|
else {
|
|
3576
3674
|
throw wrapEntityError(err, "update", "operation", `${dbType}/${op.name}`);
|
|
@@ -3608,6 +3706,10 @@ Directory Structure:
|
|
|
3608
3706
|
key: `${dbType}/${op.name}`,
|
|
3609
3707
|
message: err.message,
|
|
3610
3708
|
});
|
|
3709
|
+
// Issue #915 (defect b): a gate-rejected op means this
|
|
3710
|
+
// file's state did NOT fully land — don't poison the
|
|
3711
|
+
// content hash (see the contentHash write below).
|
|
3712
|
+
fileHadGateRejectedOp = true;
|
|
3611
3713
|
}
|
|
3612
3714
|
else if (err instanceof OperationRefError) {
|
|
3613
3715
|
schemaErrors.opRefs.push({
|
|
@@ -3616,6 +3718,7 @@ Directory Structure:
|
|
|
3616
3718
|
refs: err.refs,
|
|
3617
3719
|
message: err.message,
|
|
3618
3720
|
});
|
|
3721
|
+
fileHadGateRejectedOp = true;
|
|
3619
3722
|
}
|
|
3620
3723
|
else {
|
|
3621
3724
|
throw wrapEntityError(err, "create", "operation", `${dbType}/${op.name}`);
|
|
@@ -3747,9 +3850,27 @@ Directory Structure:
|
|
|
3747
3850
|
// file. Recompute here so a truly-unchanged subsequent push skips
|
|
3748
3851
|
// the whole file via `shouldPushFile`. `--force` bypasses that
|
|
3749
3852
|
// skip, so forced re-pushes are unaffected.
|
|
3853
|
+
//
|
|
3854
|
+
// Issue #915 (defect b): when the gate rejected an op for this
|
|
3855
|
+
// file, do NOT record a matching content hash. The db-type itself
|
|
3856
|
+
// may have been created (the push is non-transactional), but the
|
|
3857
|
+
// blocked op never landed — a matching hash would make
|
|
3858
|
+
// `shouldPushFile` short-circuit the next push and hide the missing
|
|
3859
|
+
// op as "in sync". Clear the hash instead of writing it: an empty
|
|
3860
|
+
// hash forces `shouldPushFile` to re-evaluate the file, so a
|
|
3861
|
+
// subsequent `sync push --dry-run` still surfaces the op as drift
|
|
3862
|
+
// and a corrected re-push converges without manual file edits. We
|
|
3863
|
+
// clear (rather than merely skip) so the fresh-type create path's
|
|
3864
|
+
// earlier optimistic hash write (see `createDatabaseTypeConfig`
|
|
3865
|
+
// above) is also invalidated.
|
|
3750
3866
|
if (!options.dryRun && syncState?.entities?.databaseTypes?.[dbType]) {
|
|
3751
|
-
|
|
3752
|
-
|
|
3867
|
+
if (fileHadGateRejectedOp) {
|
|
3868
|
+
syncState.entities.databaseTypes[dbType].contentHash = "";
|
|
3869
|
+
}
|
|
3870
|
+
else {
|
|
3871
|
+
syncState.entities.databaseTypes[dbType].contentHash =
|
|
3872
|
+
computeFileHash(filePath);
|
|
3873
|
+
}
|
|
3753
3874
|
}
|
|
3754
3875
|
}
|
|
3755
3876
|
}
|
|
@@ -4167,12 +4288,17 @@ Directory Structure:
|
|
|
4167
4288
|
localPrompts.add(key);
|
|
4168
4289
|
}
|
|
4169
4290
|
}
|
|
4291
|
+
// #1175: capture the parsed (fragment-expanded) local workflow TOML
|
|
4292
|
+
// per key so the content-aware comparison below can hash it without a
|
|
4293
|
+
// second read.
|
|
4294
|
+
const localWorkflowParsed = new Map();
|
|
4170
4295
|
const workflowsDir = join(configDir, "workflows");
|
|
4171
4296
|
if (existsSync(workflowsDir)) {
|
|
4172
4297
|
for (const file of readdirSync(workflowsDir).filter((f) => f.endsWith(".toml"))) {
|
|
4173
4298
|
const tomlData = parseTomlFile(join(workflowsDir, file));
|
|
4174
4299
|
const key = tomlData.workflow?.key || basename(file, ".toml");
|
|
4175
4300
|
localWorkflows.add(key);
|
|
4301
|
+
localWorkflowParsed.set(key, tomlData);
|
|
4176
4302
|
}
|
|
4177
4303
|
}
|
|
4178
4304
|
const emailTemplatesDirPath = join(configDir, "email-templates");
|
|
@@ -4255,14 +4381,69 @@ Directory Structure:
|
|
|
4255
4381
|
differences.push({ type: "prompt", key, status: "remote only" });
|
|
4256
4382
|
}
|
|
4257
4383
|
}
|
|
4258
|
-
// Workflows
|
|
4384
|
+
// Workflows — #1175: content-aware comparison.
|
|
4385
|
+
//
|
|
4386
|
+
// For workflows present on BOTH sides we no longer stop at key
|
|
4387
|
+
// existence. We fetch the full remote workflow (mirroring `sync pull`:
|
|
4388
|
+
// `getWorkflow` + active `getWorkflowConfig` for steps), serialize it
|
|
4389
|
+
// through the SAME `serializeWorkflow` pull writes to disk, and hash
|
|
4390
|
+
// both sides through the canonical pull/push hash. A difference is
|
|
4391
|
+
// reported as `modified`, which `diff` frames as a preview of
|
|
4392
|
+
// `sync pull` (pull would rewrite the local file to match running
|
|
4393
|
+
// state). It is NOT framed as "push would send": push *preserves*
|
|
4394
|
+
// omitted remote values (sync.ts updateWorkflow), so a remote
|
|
4395
|
+
// non-default vs a local omission shows Modified even though push
|
|
4396
|
+
// wouldn't change it.
|
|
4397
|
+
//
|
|
4398
|
+
// Default-normalization (see `hashWorkflowTomlForDiff`) means a local
|
|
4399
|
+
// file that omits perUserMaxRunning/Queued etc. reads as Synced when
|
|
4400
|
+
// the server holds the model defaults (4/100/…), and only an
|
|
4401
|
+
// explicitly-set non-default server value reads as Modified.
|
|
4402
|
+
const remoteWorkflowIds = new Map(workflowItems.map((w) => [w.workflowKey, w.workflowId]));
|
|
4259
4403
|
for (const key of localWorkflows) {
|
|
4260
4404
|
if (!remoteWorkflows.has(key)) {
|
|
4261
4405
|
differences.push({ type: "workflow", key, status: "local only" });
|
|
4406
|
+
continue;
|
|
4262
4407
|
}
|
|
4263
|
-
|
|
4264
|
-
|
|
4408
|
+
// Present on both sides → compare content.
|
|
4409
|
+
const workflowId = remoteWorkflowIds.get(key);
|
|
4410
|
+
const localParsed = localWorkflowParsed.get(key);
|
|
4411
|
+
let status = "exists";
|
|
4412
|
+
let hint;
|
|
4413
|
+
try {
|
|
4414
|
+
if (!workflowId || !localParsed) {
|
|
4415
|
+
throw new Error("missing workflow id or local parse");
|
|
4416
|
+
}
|
|
4417
|
+
const remoteData = await client.getWorkflow(resolvedAppId, workflowId);
|
|
4418
|
+
// Fetch active config steps (mirrors pull at the workflows hydrate
|
|
4419
|
+
// block). Failure here is non-fatal — we degrade to key-only.
|
|
4420
|
+
const activeConfigId = remoteData.workflow?.activeConfigId;
|
|
4421
|
+
if (activeConfigId && Array.isArray(remoteData.configs)) {
|
|
4422
|
+
try {
|
|
4423
|
+
const activeConfig = await client.getWorkflowConfig(resolvedAppId, workflowId, activeConfigId);
|
|
4424
|
+
const idx = remoteData.configs.findIndex((c) => c.configId === activeConfigId);
|
|
4425
|
+
if (idx >= 0 && activeConfig) {
|
|
4426
|
+
remoteData.configs[idx] = activeConfig;
|
|
4427
|
+
}
|
|
4428
|
+
}
|
|
4429
|
+
catch {
|
|
4430
|
+
// Ignore — fall back to whatever steps the workflow GET carried.
|
|
4431
|
+
}
|
|
4432
|
+
}
|
|
4433
|
+
const remoteHash = hashRemoteWorkflowForDiff(remoteData.workflow, remoteData.draft, remoteData.configs || []);
|
|
4434
|
+
const localHash = hashWorkflowTomlForDiff(localParsed);
|
|
4435
|
+
if (remoteHash !== localHash) {
|
|
4436
|
+
status = "modified";
|
|
4437
|
+
hint = "run `sync pull` to rewrite the local TOML to match running state";
|
|
4438
|
+
}
|
|
4265
4439
|
}
|
|
4440
|
+
catch {
|
|
4441
|
+
// Per-workflow fetch failure must not abort the whole diff. Degrade
|
|
4442
|
+
// gracefully to a key-only "Synced (content not compared)" caveat.
|
|
4443
|
+
status = "exists";
|
|
4444
|
+
hint = "content not compared (fetch failed)";
|
|
4445
|
+
}
|
|
4446
|
+
differences.push({ type: "workflow", key, status, hint });
|
|
4266
4447
|
}
|
|
4267
4448
|
for (const key of remoteWorkflows) {
|
|
4268
4449
|
if (!localWorkflows.has(key)) {
|
|
@@ -4333,6 +4514,7 @@ Directory Structure:
|
|
|
4333
4514
|
divider();
|
|
4334
4515
|
const localOnly = differences.filter((d) => d.status === "local only");
|
|
4335
4516
|
const remoteOnly = differences.filter((d) => d.status === "remote only");
|
|
4517
|
+
const modified = differences.filter((d) => d.status === "modified");
|
|
4336
4518
|
const existing = differences.filter((d) => d.status === "exists");
|
|
4337
4519
|
if (localOnly.length > 0) {
|
|
4338
4520
|
info("Local only (will be created on push):");
|
|
@@ -4348,10 +4530,22 @@ Directory Structure:
|
|
|
4348
4530
|
}
|
|
4349
4531
|
console.log();
|
|
4350
4532
|
}
|
|
4533
|
+
// #1175: workflows whose deployed content differs from the local TOML.
|
|
4534
|
+
// Framed as a preview of `sync pull` — pull would rewrite these files
|
|
4535
|
+
// to match what's actually running (NOT "push would send").
|
|
4536
|
+
if (modified.length > 0) {
|
|
4537
|
+
warn("Modified — `sync pull` would rewrite these to match running state:");
|
|
4538
|
+
for (const d of modified) {
|
|
4539
|
+
const hint = d.hint ? ` ${chalk.dim(`(${d.hint})`)}` : "";
|
|
4540
|
+
console.log(` ${chalk.yellow("~")} ${d.type}: ${d.key}${hint}`);
|
|
4541
|
+
}
|
|
4542
|
+
console.log();
|
|
4543
|
+
}
|
|
4351
4544
|
if (existing.length > 0) {
|
|
4352
4545
|
info("Synced:");
|
|
4353
4546
|
for (const d of existing) {
|
|
4354
|
-
|
|
4547
|
+
const hint = d.hint ? ` ${chalk.dim(`(${d.hint})`)}` : "";
|
|
4548
|
+
console.log(` ${chalk.dim("=")} ${d.type}: ${d.key}${hint}`);
|
|
4355
4549
|
}
|
|
4356
4550
|
}
|
|
4357
4551
|
// Show test case differences
|
|
@@ -4374,6 +4568,7 @@ Directory Structure:
|
|
|
4374
4568
|
divider();
|
|
4375
4569
|
keyValue("Local only", localOnly.length);
|
|
4376
4570
|
keyValue("Remote only", remoteOnly.length);
|
|
4571
|
+
keyValue("Modified", modified.length);
|
|
4377
4572
|
keyValue("Synced", existing.length);
|
|
4378
4573
|
if (testCaseDiffs.length > 0) {
|
|
4379
4574
|
keyValue("Test Cases (local only)", tcLocalOnly.length);
|
|
@@ -4572,15 +4767,14 @@ Directory Structure:
|
|
|
4572
4767
|
warn(`You have uncommitted git changes under ${configDir}. ` +
|
|
4573
4768
|
"Reverting will overwrite them.");
|
|
4574
4769
|
}
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
{
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
]);
|
|
4770
|
+
let confirm;
|
|
4771
|
+
try {
|
|
4772
|
+
confirm = await confirmPrompt(`Restore snapshot ${target.id} into ${configDir}? This overwrites the current sync directory.`);
|
|
4773
|
+
}
|
|
4774
|
+
catch (err) {
|
|
4775
|
+
error(err.message);
|
|
4776
|
+
process.exit(1);
|
|
4777
|
+
}
|
|
4584
4778
|
if (!confirm) {
|
|
4585
4779
|
info("Cancelled.");
|
|
4586
4780
|
return;
|