primitive-admin 1.0.44 → 1.0.46
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 +43 -0
- package/assets/skill/skills/primitive-platform/SKILL.md +85 -26
- package/dist/bin/primitive.js +6 -0
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/analytics.js +16 -16
- package/dist/src/commands/analytics.js.map +1 -1
- package/dist/src/commands/apps.js +14 -14
- package/dist/src/commands/apps.js.map +1 -1
- package/dist/src/commands/auth.js +70 -20
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/blob-buckets.js +11 -11
- package/dist/src/commands/blob-buckets.js.map +1 -1
- package/dist/src/commands/catalog.js +17 -17
- package/dist/src/commands/catalog.js.map +1 -1
- package/dist/src/commands/collection-type-configs.js +5 -5
- package/dist/src/commands/collection-type-configs.js.map +1 -1
- package/dist/src/commands/collections.js +6 -6
- package/dist/src/commands/collections.js.map +1 -1
- package/dist/src/commands/comparisons.js +6 -6
- package/dist/src/commands/comparisons.js.map +1 -1
- package/dist/src/commands/cron-triggers.js +17 -17
- package/dist/src/commands/cron-triggers.js.map +1 -1
- package/dist/src/commands/database-types.js +13 -13
- package/dist/src/commands/database-types.js.map +1 -1
- package/dist/src/commands/databases.js +266 -8
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/email-templates.js +6 -6
- package/dist/src/commands/email-templates.js.map +1 -1
- package/dist/src/commands/env.js +6 -6
- package/dist/src/commands/env.js.map +1 -1
- package/dist/src/commands/group-type-configs.js +6 -6
- package/dist/src/commands/group-type-configs.js.map +1 -1
- package/dist/src/commands/groups.js +7 -7
- package/dist/src/commands/groups.js.map +1 -1
- package/dist/src/commands/init.js +175 -144
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/integrations.js +31 -21
- package/dist/src/commands/integrations.js.map +1 -1
- package/dist/src/commands/prompts.js +17 -16
- package/dist/src/commands/prompts.js.map +1 -1
- package/dist/src/commands/rule-sets.js +8 -8
- package/dist/src/commands/rule-sets.js.map +1 -1
- package/dist/src/commands/sync.js +1054 -284
- 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 +44 -3
- package/dist/src/commands/users.js.map +1 -1
- package/dist/src/commands/webhooks.js +18 -18
- package/dist/src/commands/webhooks.js.map +1 -1
- package/dist/src/commands/workflows.js +285 -63
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.js +273 -72
- 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/migration-nag.js +163 -0
- package/dist/src/lib/migration-nag.js.map +1 -0
- package/dist/src/lib/output.js +58 -6
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/refresh-admin-credentials.js +103 -0
- package/dist/src/lib/refresh-admin-credentials.js.map +1 -0
- package/dist/src/lib/template.js +80 -1
- package/dist/src/lib/template.js.map +1 -1
- package/dist/src/lib/toml-database-config.js +565 -0
- package/dist/src/lib/toml-database-config.js.map +1 -0
- package/dist/src/lib/toml-params-validator.js +183 -0
- package/dist/src/lib/toml-params-validator.js.map +1 -0
- package/dist/src/lib/workflow-fragments.js +121 -0
- package/dist/src/lib/workflow-fragments.js.map +1 -0
- package/dist/src/lib/workflow-toml-validator.js +343 -0
- package/dist/src/lib/workflow-toml-validator.js.map +1 -0
- package/package.json +2 -1
|
@@ -3,9 +3,13 @@ import { join, basename } from "path";
|
|
|
3
3
|
import { createHash } from "crypto";
|
|
4
4
|
import * as TOML from "@iarna/toml";
|
|
5
5
|
import { lookup as mimeLookup } from "mime-types";
|
|
6
|
-
import { ApiClient, ConflictError } from "../lib/api-client.js";
|
|
6
|
+
import { ApiClient, ApiError, ConflictError, SchemaRequiredError, OperationRefError, SchemaBreaksOpsError, SchemaHasUncheckableOpsError, TomlParseError, OpsExistError, } from "../lib/api-client.js";
|
|
7
|
+
import { buildDatabaseTypeTomlData, detectExistingOperationForms, normalizeOperationFromToml, normalizeSubscriptionFromToml, SubscriptionAccessKeyConflictError, } from "../lib/toml-database-config.js";
|
|
8
|
+
import { validateOperations, formatIssue, } from "../lib/toml-params-validator.js";
|
|
7
9
|
import { getServerUrl, resolveAppId } from "../lib/config.js";
|
|
8
10
|
import { resolveSyncDir, isAutoResolvedSyncDir, checkLegacySyncMigration, } from "../lib/sync-paths.js";
|
|
11
|
+
import { validateWorkflowToml, formatWorkflowTomlErrors, } from "../lib/workflow-toml-validator.js";
|
|
12
|
+
import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
|
|
9
13
|
import { success, error, info, warn, keyValue, json, divider, } from "../lib/output.js";
|
|
10
14
|
import chalk from "chalk";
|
|
11
15
|
function ensureDir(dirPath) {
|
|
@@ -29,6 +33,31 @@ function saveSyncState(configDir, state) {
|
|
|
29
33
|
const syncFile = join(configDir, ".primitive-sync.json");
|
|
30
34
|
writeFileSync(syncFile, JSON.stringify(state, null, 2));
|
|
31
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Wrap a server-side error so the printed message identifies which entity
|
|
38
|
+
* was in flight. Used by every entity create/update/delete call site in the
|
|
39
|
+
* push loops (issue #684).
|
|
40
|
+
*
|
|
41
|
+
* Format: `Failed to ${action} ${kind} ${key}: ${serverMessage}`
|
|
42
|
+
*
|
|
43
|
+
* - `ConflictError` is rethrown unchanged so the existing
|
|
44
|
+
* `ConflictError → conflicts[]` accumulator in each loop still fires.
|
|
45
|
+
* - Other errors are rewrapped as a new `ApiError` carrying the wrapped
|
|
46
|
+
* message + the original `details[]` and `statusCode`.
|
|
47
|
+
*/
|
|
48
|
+
export function wrapEntityError(err, action, kind, key) {
|
|
49
|
+
if (err instanceof ConflictError) {
|
|
50
|
+
return err;
|
|
51
|
+
}
|
|
52
|
+
const original = err instanceof Error ? err.message : String(err?.message ?? err);
|
|
53
|
+
const wrappedMessage = `Failed to ${action} ${kind} ${key}: ${original}`;
|
|
54
|
+
if (err instanceof ApiError) {
|
|
55
|
+
return new ApiError(wrappedMessage, err.statusCode, err.code, err.details);
|
|
56
|
+
}
|
|
57
|
+
// Non-ApiError (e.g. network failure, JSON parse error) — preserve cause.
|
|
58
|
+
const wrapped = new Error(wrappedMessage);
|
|
59
|
+
return wrapped;
|
|
60
|
+
}
|
|
32
61
|
export function computeFileHash(filePath) {
|
|
33
62
|
const content = readFileSync(filePath);
|
|
34
63
|
return createHash("sha256").update(content).digest("hex");
|
|
@@ -38,6 +67,55 @@ export function shouldPushFile(filePath, storedHash) {
|
|
|
38
67
|
return true;
|
|
39
68
|
return computeFileHash(filePath) !== storedHash;
|
|
40
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Canonical-JSON serializer for hash computation.
|
|
72
|
+
*
|
|
73
|
+
* Sorts object keys at every level so two semantically-equivalent JSON
|
|
74
|
+
* blobs produce the same string. Arrays preserve order (step order
|
|
75
|
+
* matters in workflows). Used by `computeExpandedContentHash`.
|
|
76
|
+
*/
|
|
77
|
+
function canonicalJsonStringify(value) {
|
|
78
|
+
if (value === null || typeof value !== "object") {
|
|
79
|
+
return JSON.stringify(value);
|
|
80
|
+
}
|
|
81
|
+
if (Array.isArray(value)) {
|
|
82
|
+
return "[" + value.map((v) => canonicalJsonStringify(v)).join(",") + "]";
|
|
83
|
+
}
|
|
84
|
+
const keys = Object.keys(value).sort();
|
|
85
|
+
return ("{" +
|
|
86
|
+
keys
|
|
87
|
+
.map((k) => JSON.stringify(k) + ":" + canonicalJsonStringify(value[k]))
|
|
88
|
+
.join(",") +
|
|
89
|
+
"}");
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Hash the *expanded* (post-fragment-splice) content of a workflow TOML.
|
|
93
|
+
*
|
|
94
|
+
* The raw-file hash (`computeFileHash`) misses fragment-only edits: if a
|
|
95
|
+
* workflow uses `include = ["x"]` and only `workflow-fragments/x.toml`
|
|
96
|
+
* changes, the workflow file is byte-identical and shouldPushFile would
|
|
97
|
+
* skip the push, leaving the server with stale expanded steps.
|
|
98
|
+
*
|
|
99
|
+
* This helper hashes the parsed-and-expanded data instead, so fragment
|
|
100
|
+
* edits change the hash and force a re-push. Pass the already-parsed
|
|
101
|
+
* `tomlData` (from `parseTomlFile`) — that function runs the expander,
|
|
102
|
+
* so `tomlData` is the canonical expanded shape the server will see.
|
|
103
|
+
*/
|
|
104
|
+
export function computeExpandedContentHash(parsed) {
|
|
105
|
+
const canonical = canonicalJsonStringify(parsed);
|
|
106
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Variant of `shouldPushFile` for workflow files: compares the stored hash
|
|
110
|
+
* against the hash of the *expanded* content rather than the raw file
|
|
111
|
+
* bytes. Required so that edits to included fragments invalidate the
|
|
112
|
+
* push-skip cache for any workflow that references them.
|
|
113
|
+
*/
|
|
114
|
+
export function shouldPushExpandedFile(parsed, storedHash) {
|
|
115
|
+
if (!storedHash)
|
|
116
|
+
return true;
|
|
117
|
+
return computeExpandedContentHash(parsed) !== storedHash;
|
|
118
|
+
}
|
|
41
119
|
// TOML serialization helpers
|
|
42
120
|
function serializeAppSettings(settings) {
|
|
43
121
|
const data = {
|
|
@@ -224,6 +302,12 @@ function serializeWorkflow(workflow, draft, configs) {
|
|
|
224
302
|
perUserMaxRunning: workflow.perUserMaxRunning,
|
|
225
303
|
perUserMaxQueued: workflow.perUserMaxQueued,
|
|
226
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,
|
|
227
311
|
// Schemas are at workflow level
|
|
228
312
|
inputSchema: workflow.inputSchema ? JSON.stringify(workflow.inputSchema) : undefined,
|
|
229
313
|
outputSchema: workflow.outputSchema ? JSON.stringify(workflow.outputSchema) : undefined,
|
|
@@ -241,32 +325,20 @@ function serializeWorkflow(workflow, draft, configs) {
|
|
|
241
325
|
return TOML.stringify(data);
|
|
242
326
|
}
|
|
243
327
|
// Database config serialization helpers
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
data.triggers = typeConfig.triggers;
|
|
259
|
-
}
|
|
260
|
-
if (operations.length > 0) {
|
|
261
|
-
data.operations = operations.map((op) => ({
|
|
262
|
-
name: op.name,
|
|
263
|
-
type: op.type,
|
|
264
|
-
modelName: op.modelName,
|
|
265
|
-
access: op.access,
|
|
266
|
-
definition: typeof op.definition === "object" ? JSON.stringify(op.definition) : op.definition,
|
|
267
|
-
...(op.params ? { params: typeof op.params === "object" ? JSON.stringify(op.params) : op.params } : {}),
|
|
268
|
-
}));
|
|
269
|
-
}
|
|
328
|
+
//
|
|
329
|
+
// Native-TOML form (issue #752): when emitting a database-type config, we
|
|
330
|
+
// prefer nested `[operations.definition]` tables and `[[operations.params]]`
|
|
331
|
+
// rows over JSON-strings stuffed into a single field. The form is sticky
|
|
332
|
+
// per file — if the existing file uses the legacy JSON-string form, we
|
|
333
|
+
// preserve it so we don't generate surprise diffs. New files default to
|
|
334
|
+
// native. Un-TOMLable shapes (null, mixed-type arrays) fall back to JSON
|
|
335
|
+
// string per field with a log message.
|
|
336
|
+
//
|
|
337
|
+
// Issue #666: when the type has a stored `schema` (raw TOML of `[models.*]`
|
|
338
|
+
// blocks), `buildDatabaseTypeTomlData` parses + merges it into the same TOML
|
|
339
|
+
// object so the result round-trips as `[models.<Name>.fields.<field>]`.
|
|
340
|
+
function serializeDatabaseType(typeConfig, operations, ruleSetIdToName, options = {}) {
|
|
341
|
+
const data = buildDatabaseTypeTomlData(typeConfig, operations, ruleSetIdToName, options);
|
|
270
342
|
return TOML.stringify(data);
|
|
271
343
|
}
|
|
272
344
|
function serializeEmailTemplate(template) {
|
|
@@ -345,18 +417,58 @@ export function parseDatabaseTypeToml(tomlData) {
|
|
|
345
417
|
else if (typeSection.metadataAccess) {
|
|
346
418
|
typeConfig.metadataAccess = typeSection.metadataAccess;
|
|
347
419
|
}
|
|
420
|
+
if (typeSection.defaultAccess !== undefined) {
|
|
421
|
+
typeConfig.defaultAccess = typeSection.defaultAccess;
|
|
422
|
+
}
|
|
423
|
+
// autoPopulatedFields (issue #750) — pass through unchanged. Both the
|
|
424
|
+
// shorthand (`ownerId = "user.userId"`) and the verbose object form
|
|
425
|
+
// (`updatedBy = { value = "user.userId", on = ["create","update"] }`) are
|
|
426
|
+
// accepted; the server validates and normalizes at write time.
|
|
427
|
+
if (typeSection.autoPopulatedFields !== undefined) {
|
|
428
|
+
typeConfig.autoPopulatedFields = typeSection.autoPopulatedFields;
|
|
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
|
+
}
|
|
348
438
|
if (tomlData.triggers) {
|
|
349
439
|
typeConfig.triggers = tomlData.triggers;
|
|
350
440
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
441
|
+
// Issue #666: extract the `[models.*]` subtree (if present) and re-serialize
|
|
442
|
+
// it to a TOML string for the server's `schema` field.
|
|
443
|
+
//
|
|
444
|
+
// We always set `schema` — either to the serialized TOML string or to
|
|
445
|
+
// `null` — so the sync push diff can detect "models removed from local
|
|
446
|
+
// file" and forward `schema: null` to the server. Without this, removing
|
|
447
|
+
// `[models.*]` blocks would silently no-op and the next pull would
|
|
448
|
+
// resurrect the stale server schema, making schema deletion via `sync push`
|
|
449
|
+
// impossible (codex review on PR #766, [P2]).
|
|
450
|
+
let schemaSerialized = null;
|
|
451
|
+
if (tomlData.models && typeof tomlData.models === "object") {
|
|
452
|
+
try {
|
|
453
|
+
schemaSerialized = TOML.stringify({ models: tomlData.models });
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
// Fall through — server-side parser will fail with a clear
|
|
457
|
+
// TOML_PARSE_ERROR if we somehow produced an invalid round-trip.
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
typeConfig.schema = schemaSerialized;
|
|
461
|
+
// `normalizeOperationFromToml` accepts both forms:
|
|
462
|
+
// - legacy: `definition = '{...}'` (JSON string), `params = '{...}'`
|
|
463
|
+
// - native: `[operations.definition]` table, `[[operations.params]]` rows
|
|
464
|
+
// and returns the JS shape the server expects.
|
|
465
|
+
const operations = (tomlData.operations || []).map((op) => normalizeOperationFromToml(op));
|
|
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 };
|
|
360
472
|
}
|
|
361
473
|
function parseRuleSetToml(tomlData) {
|
|
362
474
|
const ruleSetSection = tomlData.ruleSet || {};
|
|
@@ -397,9 +509,16 @@ export function parseCollectionTypeConfigToml(tomlData) {
|
|
|
397
509
|
return result;
|
|
398
510
|
}
|
|
399
511
|
// Parsing helpers
|
|
512
|
+
//
|
|
513
|
+
// All 20+ TOML parse sites in this file route through `parseTomlFile()`.
|
|
514
|
+
// Workflow fragment expansion is wired in here so every push path (workflows,
|
|
515
|
+
// prompts, tests, etc.) gets it for free. For non-workflow TOMLs the
|
|
516
|
+
// expander is a no-op: if the parsed result has no `include` key, it returns
|
|
517
|
+
// the original object untouched.
|
|
400
518
|
function parseTomlFile(filePath) {
|
|
401
519
|
const content = readFileSync(filePath, "utf-8");
|
|
402
|
-
|
|
520
|
+
const parsed = TOML.parse(content);
|
|
521
|
+
return expandWorkflowTomlData(parsed, filePath);
|
|
403
522
|
}
|
|
404
523
|
/**
|
|
405
524
|
* Paginate through a list endpoint, collecting all items.
|
|
@@ -974,10 +1093,20 @@ Directory Structure:
|
|
|
974
1093
|
client.listGroupTypeConfigs(resolvedAppId).catch(() => []),
|
|
975
1094
|
client.listCollectionTypeConfigs(resolvedAppId).catch(() => []),
|
|
976
1095
|
]);
|
|
977
|
-
// 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.
|
|
978
1100
|
const databaseTypesWithOps = await Promise.all((Array.isArray(databaseTypeConfigsResult) ? databaseTypeConfigsResult : []).map(async (typeConfig) => {
|
|
979
|
-
const ops = await
|
|
980
|
-
|
|
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
|
+
};
|
|
981
1110
|
}));
|
|
982
1111
|
// Ensure directories exist
|
|
983
1112
|
ensureDir(configDir);
|
|
@@ -1087,11 +1216,16 @@ Directory Structure:
|
|
|
1087
1216
|
const filename = `${workflow.workflowKey}.toml`;
|
|
1088
1217
|
const filePath = join(configDir, "workflows", filename);
|
|
1089
1218
|
writeFileSync(filePath, serializeWorkflow(workflow, draft, configs || []));
|
|
1219
|
+
// Hash the expanded (post-fragment-splice) form so subsequent pushes
|
|
1220
|
+
// can detect fragment-only edits. See `computeExpandedContentHash`.
|
|
1221
|
+
// Pulled workflows never carry `include`, so the expander is a
|
|
1222
|
+
// no-op here — but we use the same canonical-JSON hash function so
|
|
1223
|
+
// the push-side comparison stays consistent.
|
|
1090
1224
|
workflowEntities[workflow.workflowKey] = {
|
|
1091
1225
|
id: workflow.workflowId,
|
|
1092
1226
|
modifiedAt: workflow.modifiedAt || new Date().toISOString(),
|
|
1093
1227
|
activeConfigId: workflow.activeConfigId,
|
|
1094
|
-
contentHash:
|
|
1228
|
+
contentHash: computeExpandedContentHash(parseTomlFile(filePath)),
|
|
1095
1229
|
};
|
|
1096
1230
|
info(` Wrote workflows/${filename}`);
|
|
1097
1231
|
}
|
|
@@ -1164,21 +1298,48 @@ Directory Structure:
|
|
|
1164
1298
|
}
|
|
1165
1299
|
// Write database types
|
|
1166
1300
|
const databaseTypeEntities = {};
|
|
1167
|
-
for (const { typeConfig, operations } of databaseTypesWithOps) {
|
|
1301
|
+
for (const { typeConfig, operations, subscriptions } of databaseTypesWithOps) {
|
|
1168
1302
|
const filename = `${typeConfig.databaseType}.toml`;
|
|
1169
1303
|
const filePath = join(configDir, "database-types", filename);
|
|
1170
|
-
|
|
1304
|
+
// Preserve the existing file's per-op form (issue #752): if the
|
|
1305
|
+
// file already exists, read it raw and detect which operations
|
|
1306
|
+
// currently use JSON-string vs native nested-table form.
|
|
1307
|
+
const hints = existsSync(filePath)
|
|
1308
|
+
? detectExistingOperationForms(readFileSync(filePath, "utf-8"))
|
|
1309
|
+
: undefined;
|
|
1310
|
+
writeFileSync(filePath, serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
|
|
1311
|
+
hints,
|
|
1312
|
+
defaultForm: "native",
|
|
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,
|
|
1317
|
+
}));
|
|
1171
1318
|
const opsEntities = {};
|
|
1172
1319
|
for (const op of operations) {
|
|
1173
1320
|
opsEntities[op.name] = {
|
|
1174
1321
|
modifiedAt: op.modifiedAt || new Date().toISOString(),
|
|
1175
1322
|
};
|
|
1176
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
|
+
}
|
|
1177
1335
|
databaseTypeEntities[typeConfig.databaseType] = {
|
|
1178
1336
|
databaseType: typeConfig.databaseType,
|
|
1179
1337
|
modifiedAt: typeConfig.modifiedAt || new Date().toISOString(),
|
|
1180
1338
|
operations: Object.keys(opsEntities).length > 0 ? opsEntities : undefined,
|
|
1339
|
+
subscriptions: Object.keys(subsEntities).length > 0 ? subsEntities : undefined,
|
|
1181
1340
|
contentHash: computeFileHash(filePath),
|
|
1341
|
+
hasSchema: typeof typeConfig.schema === "string" &&
|
|
1342
|
+
typeConfig.schema.trim().length > 0,
|
|
1182
1343
|
};
|
|
1183
1344
|
info(` Wrote database-types/${filename}`);
|
|
1184
1345
|
}
|
|
@@ -1277,6 +1438,7 @@ Directory Structure:
|
|
|
1277
1438
|
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
1278
1439
|
.option("--dry-run", "Show what would be changed without applying")
|
|
1279
1440
|
.option("--force", "Overwrite remote even if modified since last pull")
|
|
1441
|
+
.option("--accept-warnings", "Commit schema diffs that have operations with dynamic refs (issue #666 SCHEMA_HAS_UNCHECKABLE_OPS escape hatch)")
|
|
1280
1442
|
.action(async (appId, options) => {
|
|
1281
1443
|
const resolvedAppId = resolveAppId(appId, options);
|
|
1282
1444
|
const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
|
|
@@ -1310,6 +1472,17 @@ Directory Structure:
|
|
|
1310
1472
|
const changes = [];
|
|
1311
1473
|
let skippedCount = 0;
|
|
1312
1474
|
const conflicts = [];
|
|
1475
|
+
// Issue #666: per-class buckets for schema-feature errors. Each entry
|
|
1476
|
+
// groups failures by the code so the end-of-run report can print
|
|
1477
|
+
// them once with the friendly hint.
|
|
1478
|
+
const schemaErrors = {
|
|
1479
|
+
schemaRequired: [],
|
|
1480
|
+
opRefs: [],
|
|
1481
|
+
schemaBreaks: [],
|
|
1482
|
+
uncheckableOps: [],
|
|
1483
|
+
tomlParse: [],
|
|
1484
|
+
opsExist: [],
|
|
1485
|
+
};
|
|
1313
1486
|
// Track name→ID mappings for resolving cross-references during push
|
|
1314
1487
|
const ruleSetNameToId = new Map();
|
|
1315
1488
|
const promptKeyToId = new Map();
|
|
@@ -1408,7 +1581,7 @@ Directory Structure:
|
|
|
1408
1581
|
});
|
|
1409
1582
|
}
|
|
1410
1583
|
else {
|
|
1411
|
-
throw err;
|
|
1584
|
+
throw wrapEntityError(err, "update", "rule set", fileKey);
|
|
1412
1585
|
}
|
|
1413
1586
|
}
|
|
1414
1587
|
}
|
|
@@ -1419,12 +1592,18 @@ Directory Structure:
|
|
|
1419
1592
|
// Create new rule set
|
|
1420
1593
|
changes.push({ type: "rule-set", action: "create", key: fileKey });
|
|
1421
1594
|
if (!options.dryRun) {
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1595
|
+
let created;
|
|
1596
|
+
try {
|
|
1597
|
+
created = await client.createRuleSet(resolvedAppId, {
|
|
1598
|
+
name: ruleSetData.name,
|
|
1599
|
+
resourceType: ruleSetData.resourceType,
|
|
1600
|
+
rules: ruleSetData.rules,
|
|
1601
|
+
description: ruleSetData.description,
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
catch (err) {
|
|
1605
|
+
throw wrapEntityError(err, "create", "rule set", fileKey);
|
|
1606
|
+
}
|
|
1428
1607
|
info(` Created rule set: ${fileKey}`);
|
|
1429
1608
|
if (syncState && created?.ruleSetId) {
|
|
1430
1609
|
if (!syncState.entities.ruleSets) {
|
|
@@ -1494,7 +1673,7 @@ Directory Structure:
|
|
|
1494
1673
|
});
|
|
1495
1674
|
}
|
|
1496
1675
|
else {
|
|
1497
|
-
throw err;
|
|
1676
|
+
throw wrapEntityError(err, "update", "integration", key);
|
|
1498
1677
|
}
|
|
1499
1678
|
}
|
|
1500
1679
|
}
|
|
@@ -1502,18 +1681,23 @@ Directory Structure:
|
|
|
1502
1681
|
else {
|
|
1503
1682
|
changes.push({ type: "integration", action: "create", key });
|
|
1504
1683
|
if (!options.dryRun) {
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
if (
|
|
1510
|
-
syncState.entities.integrations
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1684
|
+
try {
|
|
1685
|
+
const created = await client.createIntegration(resolvedAppId, payload);
|
|
1686
|
+
info(` Created integration: ${key}`);
|
|
1687
|
+
// Add new entity to sync state
|
|
1688
|
+
if (syncState && created?.integrationId && created?.modifiedAt) {
|
|
1689
|
+
if (!syncState.entities.integrations) {
|
|
1690
|
+
syncState.entities.integrations = {};
|
|
1691
|
+
}
|
|
1692
|
+
syncState.entities.integrations[key] = {
|
|
1693
|
+
id: created.integrationId,
|
|
1694
|
+
modifiedAt: created.modifiedAt,
|
|
1695
|
+
contentHash: computeFileHash(filePath),
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
catch (err) {
|
|
1700
|
+
throw wrapEntityError(err, "create", "integration", key);
|
|
1517
1701
|
}
|
|
1518
1702
|
}
|
|
1519
1703
|
}
|
|
@@ -1580,7 +1764,7 @@ Directory Structure:
|
|
|
1580
1764
|
});
|
|
1581
1765
|
}
|
|
1582
1766
|
else {
|
|
1583
|
-
throw err;
|
|
1767
|
+
throw wrapEntityError(err, "update", "webhook", key);
|
|
1584
1768
|
}
|
|
1585
1769
|
}
|
|
1586
1770
|
}
|
|
@@ -1588,17 +1772,22 @@ Directory Structure:
|
|
|
1588
1772
|
else {
|
|
1589
1773
|
changes.push({ type: "webhook", action: "create", key });
|
|
1590
1774
|
if (!options.dryRun) {
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
if (
|
|
1595
|
-
syncState.entities.webhooks
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1775
|
+
try {
|
|
1776
|
+
const created = await client.createWebhook(resolvedAppId, payload);
|
|
1777
|
+
info(` Created webhook: ${key}`);
|
|
1778
|
+
if (syncState && created?.webhookId && created?.modifiedAt) {
|
|
1779
|
+
if (!syncState.entities.webhooks) {
|
|
1780
|
+
syncState.entities.webhooks = {};
|
|
1781
|
+
}
|
|
1782
|
+
syncState.entities.webhooks[key] = {
|
|
1783
|
+
id: created.webhookId,
|
|
1784
|
+
modifiedAt: created.modifiedAt,
|
|
1785
|
+
contentHash: computeFileHash(filePath),
|
|
1786
|
+
};
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
catch (err) {
|
|
1790
|
+
throw wrapEntityError(err, "create", "webhook", key);
|
|
1602
1791
|
}
|
|
1603
1792
|
}
|
|
1604
1793
|
}
|
|
@@ -1648,24 +1837,29 @@ Directory Structure:
|
|
|
1648
1837
|
}
|
|
1649
1838
|
}
|
|
1650
1839
|
catch (err) {
|
|
1651
|
-
throw err;
|
|
1840
|
+
throw wrapEntityError(err, "update", "cron trigger", key);
|
|
1652
1841
|
}
|
|
1653
1842
|
}
|
|
1654
1843
|
}
|
|
1655
1844
|
else {
|
|
1656
1845
|
changes.push({ type: "cron-trigger", action: "create", key });
|
|
1657
1846
|
if (!options.dryRun) {
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
if (
|
|
1662
|
-
syncState.entities.cronTriggers
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1847
|
+
try {
|
|
1848
|
+
const created = await client.createCronTrigger(resolvedAppId, payload);
|
|
1849
|
+
info(` Created cron trigger: ${key}`);
|
|
1850
|
+
if (syncState && created?.triggerId && created?.modifiedAt) {
|
|
1851
|
+
if (!syncState.entities.cronTriggers) {
|
|
1852
|
+
syncState.entities.cronTriggers = {};
|
|
1853
|
+
}
|
|
1854
|
+
syncState.entities.cronTriggers[key] = {
|
|
1855
|
+
id: created.triggerId,
|
|
1856
|
+
modifiedAt: created.modifiedAt,
|
|
1857
|
+
contentHash: computeFileHash(filePath),
|
|
1858
|
+
};
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
catch (err) {
|
|
1862
|
+
throw wrapEntityError(err, "create", "cron trigger", key);
|
|
1669
1863
|
}
|
|
1670
1864
|
}
|
|
1671
1865
|
}
|
|
@@ -1743,7 +1937,7 @@ Directory Structure:
|
|
|
1743
1937
|
}
|
|
1744
1938
|
}
|
|
1745
1939
|
else {
|
|
1746
|
-
throw err;
|
|
1940
|
+
throw wrapEntityError(err, "create", "blob bucket", key);
|
|
1747
1941
|
}
|
|
1748
1942
|
}
|
|
1749
1943
|
}
|
|
@@ -1857,7 +2051,7 @@ Directory Structure:
|
|
|
1857
2051
|
});
|
|
1858
2052
|
}
|
|
1859
2053
|
else {
|
|
1860
|
-
throw err;
|
|
2054
|
+
throw wrapEntityError(err, "update", "prompt", key);
|
|
1861
2055
|
}
|
|
1862
2056
|
}
|
|
1863
2057
|
}
|
|
@@ -1867,65 +2061,70 @@ Directory Structure:
|
|
|
1867
2061
|
const firstConfig = configs[0] || {};
|
|
1868
2062
|
changes.push({ type: "prompt", action: "create", key });
|
|
1869
2063
|
if (!options.dryRun) {
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
if (
|
|
1887
|
-
syncState.entities.prompts
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
// Track prompt key→ID and config name→ID
|
|
1896
|
-
if (created?.promptId) {
|
|
1897
|
-
promptKeyToId.set(key, created.promptId);
|
|
1898
|
-
}
|
|
1899
|
-
if (created?.configs) {
|
|
1900
|
-
for (const config of created.configs) {
|
|
1901
|
-
promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
2064
|
+
try {
|
|
2065
|
+
const created = await client.createPrompt(resolvedAppId, {
|
|
2066
|
+
promptKey: key,
|
|
2067
|
+
displayName: prompt.displayName || key,
|
|
2068
|
+
description: prompt.description,
|
|
2069
|
+
provider: firstConfig.provider || "openrouter",
|
|
2070
|
+
model: firstConfig.model || "google/gemini-2.0-flash-001",
|
|
2071
|
+
systemPrompt: firstConfig.systemPrompt,
|
|
2072
|
+
userPromptTemplate: firstConfig.userPromptTemplate || "{{ input }}",
|
|
2073
|
+
temperature: firstConfig.temperature,
|
|
2074
|
+
maxTokens: firstConfig.maxTokens,
|
|
2075
|
+
outputFormat: firstConfig.outputFormat,
|
|
2076
|
+
inputSchema: prompt.inputSchema,
|
|
2077
|
+
});
|
|
2078
|
+
info(` Created prompt: ${key}`);
|
|
2079
|
+
// Add new entity to sync state
|
|
2080
|
+
if (syncState && created?.promptId && created?.modifiedAt) {
|
|
2081
|
+
if (!syncState.entities.prompts) {
|
|
2082
|
+
syncState.entities.prompts = {};
|
|
2083
|
+
}
|
|
2084
|
+
syncState.entities.prompts[key] = {
|
|
2085
|
+
id: created.promptId,
|
|
2086
|
+
modifiedAt: created.modifiedAt,
|
|
2087
|
+
contentHash: computeFileHash(filePath),
|
|
2088
|
+
};
|
|
1902
2089
|
}
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
const
|
|
1909
|
-
|
|
1910
|
-
description: extraConfig.description,
|
|
1911
|
-
provider: extraConfig.provider || firstConfig.provider || "openrouter",
|
|
1912
|
-
model: extraConfig.model || firstConfig.model || "google/gemini-2.0-flash-001",
|
|
1913
|
-
systemPrompt: extraConfig.systemPrompt,
|
|
1914
|
-
userPromptTemplate: extraConfig.userPromptTemplate,
|
|
1915
|
-
temperature: extraConfig.temperature,
|
|
1916
|
-
maxTokens: extraConfig.maxTokens,
|
|
1917
|
-
outputFormat: extraConfig.outputFormat,
|
|
1918
|
-
});
|
|
1919
|
-
if (extraCreated?.configId) {
|
|
1920
|
-
const configName = extraConfig.name || `config-${i + 1}`;
|
|
1921
|
-
promptConfigNameToId.set(`${key}#${configName}`, extraCreated.configId);
|
|
2090
|
+
// Track prompt key→ID and config name→ID
|
|
2091
|
+
if (created?.promptId) {
|
|
2092
|
+
promptKeyToId.set(key, created.promptId);
|
|
2093
|
+
}
|
|
2094
|
+
if (created?.configs) {
|
|
2095
|
+
for (const config of created.configs) {
|
|
2096
|
+
promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
1922
2097
|
}
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
2098
|
+
}
|
|
2099
|
+
// Create additional configs (configs[1..n]) that weren't included in the initial create
|
|
2100
|
+
if (created?.promptId && configs.length > 1) {
|
|
2101
|
+
for (let i = 1; i < configs.length; i++) {
|
|
2102
|
+
const extraConfig = configs[i];
|
|
2103
|
+
const extraCreated = await client.createPromptConfig(resolvedAppId, created.promptId, {
|
|
2104
|
+
configName: extraConfig.name || `config-${i + 1}`,
|
|
2105
|
+
description: extraConfig.description,
|
|
2106
|
+
provider: extraConfig.provider || firstConfig.provider || "openrouter",
|
|
2107
|
+
model: extraConfig.model || firstConfig.model || "google/gemini-2.0-flash-001",
|
|
2108
|
+
systemPrompt: extraConfig.systemPrompt,
|
|
2109
|
+
userPromptTemplate: extraConfig.userPromptTemplate,
|
|
2110
|
+
temperature: extraConfig.temperature,
|
|
2111
|
+
maxTokens: extraConfig.maxTokens,
|
|
2112
|
+
outputFormat: extraConfig.outputFormat,
|
|
2113
|
+
});
|
|
2114
|
+
if (extraCreated?.configId) {
|
|
2115
|
+
const configName = extraConfig.name || `config-${i + 1}`;
|
|
2116
|
+
promptConfigNameToId.set(`${key}#${configName}`, extraCreated.configId);
|
|
2117
|
+
}
|
|
2118
|
+
// Activate this config if it was the active one
|
|
2119
|
+
if (extraConfig.isActive && extraCreated?.configId) {
|
|
2120
|
+
await client.activatePromptConfig(resolvedAppId, created.promptId, extraCreated.configId);
|
|
2121
|
+
}
|
|
1926
2122
|
}
|
|
2123
|
+
info(` Created ${configs.length - 1} additional config(s) for prompt: ${key}`);
|
|
1927
2124
|
}
|
|
1928
|
-
|
|
2125
|
+
}
|
|
2126
|
+
catch (err) {
|
|
2127
|
+
throw wrapEntityError(err, "create", "prompt", key);
|
|
1929
2128
|
}
|
|
1930
2129
|
}
|
|
1931
2130
|
}
|
|
@@ -1938,13 +2137,27 @@ Directory Structure:
|
|
|
1938
2137
|
for (const file of files) {
|
|
1939
2138
|
const filePath = join(workflowsDir, file);
|
|
1940
2139
|
const tomlData = parseTomlFile(filePath);
|
|
2140
|
+
// Issue #685: reject misnested headers (e.g.
|
|
2141
|
+
// [steps.<id>.request]) before pushing. The runtime silently
|
|
2142
|
+
// ignores fields outside the allowlist, so this is the only
|
|
2143
|
+
// place to catch the footgun. Validation happens BEFORE the
|
|
2144
|
+
// skip-if-unchanged check so a previously-pushed-but-broken
|
|
2145
|
+
// file gets a clear diagnostic on every push attempt.
|
|
2146
|
+
const tomlErrors = validateWorkflowToml(tomlData);
|
|
2147
|
+
if (tomlErrors.length > 0) {
|
|
2148
|
+
error(formatWorkflowTomlErrors(filePath, tomlErrors));
|
|
2149
|
+
process.exit(1);
|
|
2150
|
+
}
|
|
1941
2151
|
const workflow = tomlData.workflow || {};
|
|
1942
2152
|
const key = workflow.key || basename(file, ".toml");
|
|
1943
2153
|
const steps = tomlData.steps || [];
|
|
1944
2154
|
const existingId = syncState?.entities?.workflows?.[key]?.id;
|
|
1945
2155
|
const existingActiveConfigId = syncState?.entities?.workflows?.[key]?.activeConfigId;
|
|
1946
|
-
// Skip if file hasn't changed since last sync
|
|
1947
|
-
|
|
2156
|
+
// Skip if file hasn't changed since last sync. Use the expanded
|
|
2157
|
+
// content hash (post fragment splice) so that edits to included
|
|
2158
|
+
// `workflow-fragments/*.toml` files invalidate the push-skip cache
|
|
2159
|
+
// for any workflow that references them. See `computeExpandedContentHash`.
|
|
2160
|
+
if (!options.force && existingId && !shouldPushExpandedFile(tomlData, syncState?.entities?.workflows?.[key]?.contentHash)) {
|
|
1948
2161
|
skippedCount++;
|
|
1949
2162
|
// Only fetch config name→ID mappings if test cases exist for this workflow
|
|
1950
2163
|
const workflowTestsDir = getTestsDir(configDir, "workflow", key);
|
|
@@ -1966,7 +2179,21 @@ Directory Structure:
|
|
|
1966
2179
|
? undefined
|
|
1967
2180
|
: syncState?.entities?.workflows?.[key]?.modifiedAt;
|
|
1968
2181
|
try {
|
|
1969
|
-
// 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.
|
|
1970
2197
|
const updated = await client.updateWorkflow(resolvedAppId, existingId, {
|
|
1971
2198
|
name: workflow.name,
|
|
1972
2199
|
description: workflow.description,
|
|
@@ -1975,10 +2202,23 @@ Directory Structure:
|
|
|
1975
2202
|
perUserMaxRunning: workflow.perUserMaxRunning,
|
|
1976
2203
|
perUserMaxQueued: workflow.perUserMaxQueued,
|
|
1977
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,
|
|
1978
2210
|
inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
|
|
1979
2211
|
outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : null,
|
|
1980
2212
|
}, expectedModifiedAt);
|
|
1981
|
-
//
|
|
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;
|
|
2218
|
+
// Update active configuration steps (or draft for legacy).
|
|
2219
|
+
// Issue #687: name the slot we touched so the dev-loop
|
|
2220
|
+
// user can confirm before previewing.
|
|
2221
|
+
let updateSlotLabel = "active config";
|
|
1982
2222
|
if (existingActiveConfigId) {
|
|
1983
2223
|
await client.updateWorkflowConfig(resolvedAppId, existingId, existingActiveConfigId, {
|
|
1984
2224
|
steps,
|
|
@@ -1990,12 +2230,31 @@ Directory Structure:
|
|
|
1990
2230
|
steps,
|
|
1991
2231
|
inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
|
|
1992
2232
|
});
|
|
2233
|
+
updateSlotLabel = "draft (legacy)";
|
|
1993
2234
|
}
|
|
1994
|
-
|
|
1995
|
-
//
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
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
|
+
}
|
|
2252
|
+
info(` Updated workflow: ${key} (${updateSlotLabel})`);
|
|
2253
|
+
// Update sync state with new modifiedAt. Store the *expanded*
|
|
2254
|
+
// content hash so future fragment-only edits are detected.
|
|
2255
|
+
if (syncState?.entities?.workflows?.[key] && latestModifiedAt) {
|
|
2256
|
+
syncState.entities.workflows[key].modifiedAt = latestModifiedAt;
|
|
2257
|
+
syncState.entities.workflows[key].contentHash = computeExpandedContentHash(tomlData);
|
|
1999
2258
|
}
|
|
2000
2259
|
// Fetch full workflow to get config name→ID mappings
|
|
2001
2260
|
// (updateWorkflow response doesn't include configs)
|
|
@@ -2016,7 +2275,7 @@ Directory Structure:
|
|
|
2016
2275
|
});
|
|
2017
2276
|
}
|
|
2018
2277
|
else {
|
|
2019
|
-
throw err;
|
|
2278
|
+
throw wrapEntityError(err, "update", "workflow", key);
|
|
2020
2279
|
}
|
|
2021
2280
|
}
|
|
2022
2281
|
}
|
|
@@ -2025,63 +2284,74 @@ Directory Structure:
|
|
|
2025
2284
|
// Create new workflow (automatically creates default config)
|
|
2026
2285
|
changes.push({ type: "workflow", action: "create", key });
|
|
2027
2286
|
if (!options.dryRun) {
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2287
|
+
try {
|
|
2288
|
+
const created = await client.createWorkflow(resolvedAppId, {
|
|
2289
|
+
workflowKey: key,
|
|
2290
|
+
name: workflow.name || key,
|
|
2291
|
+
description: workflow.description,
|
|
2292
|
+
steps,
|
|
2293
|
+
inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : undefined,
|
|
2294
|
+
outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : undefined,
|
|
2295
|
+
accessRule: workflow.accessRule || undefined,
|
|
2296
|
+
perUserMaxRunning: workflow.perUserMaxRunning,
|
|
2297
|
+
perUserMaxQueued: workflow.perUserMaxQueued,
|
|
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,
|
|
2305
|
+
});
|
|
2306
|
+
info(` Created workflow: ${key}`);
|
|
2307
|
+
// Add new entity to sync state (including activeConfigId)
|
|
2308
|
+
if (syncState && created?.workflow?.workflowId && created?.workflow?.modifiedAt) {
|
|
2309
|
+
if (!syncState.entities.workflows) {
|
|
2310
|
+
syncState.entities.workflows = {};
|
|
2311
|
+
}
|
|
2312
|
+
syncState.entities.workflows[key] = {
|
|
2313
|
+
id: created.workflow.workflowId,
|
|
2314
|
+
modifiedAt: created.workflow.modifiedAt,
|
|
2315
|
+
activeConfigId: created.workflow.activeConfigId,
|
|
2316
|
+
contentHash: computeExpandedContentHash(tomlData),
|
|
2317
|
+
};
|
|
2057
2318
|
}
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
if (workflowId && tomlConfigs.length > 1) {
|
|
2063
|
-
for (let i = 1; i < tomlConfigs.length; i++) {
|
|
2064
|
-
const extraConfig = tomlConfigs[i];
|
|
2065
|
-
const extraCreated = await client.createWorkflowConfig(resolvedAppId, workflowId, {
|
|
2066
|
-
configName: extraConfig.name || `config-${i + 1}`,
|
|
2067
|
-
description: extraConfig.description,
|
|
2068
|
-
steps,
|
|
2069
|
-
});
|
|
2070
|
-
if (extraCreated?.configId) {
|
|
2071
|
-
const configName = extraConfig.name || `config-${i + 1}`;
|
|
2072
|
-
workflowConfigNameToId.set(`${key}#${configName}`, extraCreated.configId);
|
|
2319
|
+
// Track config name→ID mappings
|
|
2320
|
+
if (created?.configs) {
|
|
2321
|
+
for (const config of created.configs) {
|
|
2322
|
+
workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
2073
2323
|
}
|
|
2074
2324
|
}
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2325
|
+
const workflowId = created?.workflow?.workflowId;
|
|
2326
|
+
const tomlConfigs = tomlData.configs || [];
|
|
2327
|
+
// Create additional workflow configs (configs[1..n]) beyond the default
|
|
2328
|
+
if (workflowId && tomlConfigs.length > 1) {
|
|
2329
|
+
for (let i = 1; i < tomlConfigs.length; i++) {
|
|
2330
|
+
const extraConfig = tomlConfigs[i];
|
|
2331
|
+
const extraCreated = await client.createWorkflowConfig(resolvedAppId, workflowId, {
|
|
2332
|
+
configName: extraConfig.name || `config-${i + 1}`,
|
|
2333
|
+
description: extraConfig.description,
|
|
2334
|
+
steps,
|
|
2335
|
+
});
|
|
2336
|
+
if (extraCreated?.configId) {
|
|
2337
|
+
const configName = extraConfig.name || `config-${i + 1}`;
|
|
2338
|
+
workflowConfigNameToId.set(`${key}#${configName}`, extraCreated.configId);
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
info(` Created ${tomlConfigs.length - 1} additional config(s) for workflow: ${key}`);
|
|
2342
|
+
}
|
|
2343
|
+
// Activate the correct config based on activeConfigName from TOML
|
|
2344
|
+
if (workflowId && workflow.activeConfigName) {
|
|
2345
|
+
const activeConfigId = workflowConfigNameToId.get(`${key}#${workflow.activeConfigName}`);
|
|
2346
|
+
if (activeConfigId && activeConfigId !== created?.workflow?.activeConfigId) {
|
|
2347
|
+
await client.activateWorkflowConfig(resolvedAppId, workflowId, activeConfigId);
|
|
2348
|
+
info(` Activated config "${workflow.activeConfigName}" for workflow: ${key}`);
|
|
2349
|
+
}
|
|
2083
2350
|
}
|
|
2084
2351
|
}
|
|
2352
|
+
catch (err) {
|
|
2353
|
+
throw wrapEntityError(err, "create", "workflow", key);
|
|
2354
|
+
}
|
|
2085
2355
|
}
|
|
2086
2356
|
}
|
|
2087
2357
|
}
|
|
@@ -2092,11 +2362,49 @@ Directory Structure:
|
|
|
2092
2362
|
const files = readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml"));
|
|
2093
2363
|
for (const file of files) {
|
|
2094
2364
|
const filePath = join(dbTypesDir, file);
|
|
2365
|
+
const rawToml = readFileSync(filePath, "utf-8");
|
|
2095
2366
|
const tomlData = parseTomlFile(filePath);
|
|
2096
|
-
|
|
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
|
+
}
|
|
2097
2386
|
const dbType = typeConfig.databaseType || basename(file, ".toml");
|
|
2098
2387
|
// Resolve ruleSetName → ruleSetId if using key-based reference
|
|
2099
2388
|
resolveRuleSetReference(typeConfig, ruleSetNameToId, `database type ${dbType}`);
|
|
2389
|
+
// $params validator (issue #752): every `$params.X` reference
|
|
2390
|
+
// inside `definition` must correspond to a declared param in
|
|
2391
|
+
// `[[operations.params]]`. Catches typos like `$params.proectId`
|
|
2392
|
+
// at push time with the TOML file:line of the offending op block.
|
|
2393
|
+
const validation = validateOperations({
|
|
2394
|
+
filePath,
|
|
2395
|
+
rawToml,
|
|
2396
|
+
operations,
|
|
2397
|
+
});
|
|
2398
|
+
for (const w of validation.warnings) {
|
|
2399
|
+
warn(` ${formatIssue(w)}`);
|
|
2400
|
+
}
|
|
2401
|
+
if (validation.errors.length > 0) {
|
|
2402
|
+
for (const e of validation.errors) {
|
|
2403
|
+
error(` ${formatIssue(e)}`);
|
|
2404
|
+
}
|
|
2405
|
+
error(`Aborting push: ${validation.errors.length} unresolved $params reference(s) in ${file}.`);
|
|
2406
|
+
process.exit(1);
|
|
2407
|
+
}
|
|
2100
2408
|
const existingEntry = syncState?.entities?.databaseTypes?.[dbType];
|
|
2101
2409
|
// Skip if file hasn't changed since last sync
|
|
2102
2410
|
if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
|
|
@@ -2120,13 +2428,44 @@ Directory Structure:
|
|
|
2120
2428
|
updateData.triggers = typeConfig.triggers || null;
|
|
2121
2429
|
if ("metadataAccess" in typeConfig)
|
|
2122
2430
|
updateData.metadataAccess = typeConfig.metadataAccess || null;
|
|
2431
|
+
if ("defaultAccess" in typeConfig)
|
|
2432
|
+
updateData.defaultAccess = typeConfig.defaultAccess || null;
|
|
2433
|
+
if ("autoPopulatedFields" in typeConfig) {
|
|
2434
|
+
updateData.autoPopulatedFields =
|
|
2435
|
+
typeConfig.autoPopulatedFields || null;
|
|
2436
|
+
}
|
|
2437
|
+
if ("timestamps" in typeConfig) {
|
|
2438
|
+
updateData.timestamps = typeConfig.timestamps || null;
|
|
2439
|
+
}
|
|
2440
|
+
// Issue #666: forward `schema` when the local TOML declares
|
|
2441
|
+
// one (a set/update), OR when the server had one at last
|
|
2442
|
+
// sync and the local file no longer does (a deletion —
|
|
2443
|
+
// `schema: null` clears it server-side; codex review gap on
|
|
2444
|
+
// PR #766). When the type never had a schema and still
|
|
2445
|
+
// doesn't, omit it so an operations-only edit doesn't
|
|
2446
|
+
// register as an empty type-level update (issue #369).
|
|
2447
|
+
const localHasSchema = typeof typeConfig.schema === "string" &&
|
|
2448
|
+
typeConfig.schema.trim().length > 0;
|
|
2449
|
+
if (localHasSchema) {
|
|
2450
|
+
updateData.schema = typeConfig.schema;
|
|
2451
|
+
}
|
|
2452
|
+
else if (existingEntry.hasSchema) {
|
|
2453
|
+
updateData.schema = null;
|
|
2454
|
+
}
|
|
2123
2455
|
if (Object.keys(updateData).length > 0) {
|
|
2124
2456
|
changes.push({ type: "database-type", action: "update", key: dbType });
|
|
2125
|
-
const updated = await client.updateDatabaseTypeConfig(resolvedAppId, dbType, updateData, expectedModifiedAt
|
|
2457
|
+
const updated = await client.updateDatabaseTypeConfig(resolvedAppId, dbType, updateData, expectedModifiedAt, {
|
|
2458
|
+
dryRun: !!options.dryRun,
|
|
2459
|
+
acceptWarnings: !!options.acceptWarnings,
|
|
2460
|
+
});
|
|
2126
2461
|
info(` Updated database type: ${dbType}`);
|
|
2127
2462
|
if (syncState?.entities?.databaseTypes?.[dbType] && updated?.modifiedAt) {
|
|
2128
2463
|
syncState.entities.databaseTypes[dbType].modifiedAt = updated.modifiedAt;
|
|
2129
2464
|
syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
|
|
2465
|
+
syncState.entities.databaseTypes[dbType].hasSchema =
|
|
2466
|
+
"schema" in updateData
|
|
2467
|
+
? updateData.schema !== null
|
|
2468
|
+
: syncState.entities.databaseTypes[dbType].hasSchema;
|
|
2130
2469
|
}
|
|
2131
2470
|
}
|
|
2132
2471
|
}
|
|
@@ -2139,8 +2478,41 @@ Directory Structure:
|
|
|
2139
2478
|
localModifiedAt: expectedModifiedAt || "unknown",
|
|
2140
2479
|
});
|
|
2141
2480
|
}
|
|
2481
|
+
else if (err instanceof SchemaBreaksOpsError) {
|
|
2482
|
+
schemaErrors.schemaBreaks.push({
|
|
2483
|
+
type: "database-type",
|
|
2484
|
+
key: dbType,
|
|
2485
|
+
operations: err.operations,
|
|
2486
|
+
message: err.message,
|
|
2487
|
+
});
|
|
2488
|
+
}
|
|
2489
|
+
else if (err instanceof SchemaHasUncheckableOpsError) {
|
|
2490
|
+
schemaErrors.uncheckableOps.push({
|
|
2491
|
+
type: "database-type",
|
|
2492
|
+
key: dbType,
|
|
2493
|
+
operations: err.operations,
|
|
2494
|
+
message: err.message,
|
|
2495
|
+
});
|
|
2496
|
+
}
|
|
2497
|
+
else if (err instanceof TomlParseError) {
|
|
2498
|
+
schemaErrors.tomlParse.push({
|
|
2499
|
+
type: "database-type",
|
|
2500
|
+
key: dbType,
|
|
2501
|
+
line: err.line,
|
|
2502
|
+
column: err.column,
|
|
2503
|
+
message: err.message,
|
|
2504
|
+
});
|
|
2505
|
+
}
|
|
2506
|
+
else if (err instanceof OpsExistError) {
|
|
2507
|
+
schemaErrors.opsExist.push({
|
|
2508
|
+
type: "database-type",
|
|
2509
|
+
key: dbType,
|
|
2510
|
+
opCount: err.opCount,
|
|
2511
|
+
message: err.message,
|
|
2512
|
+
});
|
|
2513
|
+
}
|
|
2142
2514
|
else {
|
|
2143
|
-
throw err;
|
|
2515
|
+
throw wrapEntityError(err, "update", "database type", dbType);
|
|
2144
2516
|
}
|
|
2145
2517
|
}
|
|
2146
2518
|
}
|
|
@@ -2148,7 +2520,11 @@ Directory Structure:
|
|
|
2148
2520
|
// In dry-run mode, still report the change iff we would actually PATCH.
|
|
2149
2521
|
const wouldUpdate = "ruleSetId" in typeConfig ||
|
|
2150
2522
|
"triggers" in typeConfig ||
|
|
2151
|
-
"metadataAccess" in typeConfig
|
|
2523
|
+
"metadataAccess" in typeConfig ||
|
|
2524
|
+
"defaultAccess" in typeConfig ||
|
|
2525
|
+
"autoPopulatedFields" in typeConfig ||
|
|
2526
|
+
"timestamps" in typeConfig ||
|
|
2527
|
+
"schema" in typeConfig;
|
|
2152
2528
|
if (wouldUpdate) {
|
|
2153
2529
|
changes.push({ type: "database-type", action: "update", key: dbType });
|
|
2154
2530
|
}
|
|
@@ -2167,17 +2543,37 @@ Directory Structure:
|
|
|
2167
2543
|
createData.triggers = typeConfig.triggers;
|
|
2168
2544
|
if (typeConfig.metadataAccess)
|
|
2169
2545
|
createData.metadataAccess = typeConfig.metadataAccess;
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
if (
|
|
2173
|
-
|
|
2174
|
-
|
|
2546
|
+
if (typeConfig.defaultAccess)
|
|
2547
|
+
createData.defaultAccess = typeConfig.defaultAccess;
|
|
2548
|
+
if (typeConfig.autoPopulatedFields)
|
|
2549
|
+
createData.autoPopulatedFields = typeConfig.autoPopulatedFields;
|
|
2550
|
+
if (typeConfig.timestamps)
|
|
2551
|
+
createData.timestamps = typeConfig.timestamps;
|
|
2552
|
+
// Issue #666 addendum A2: forward `schema` on POST so a
|
|
2553
|
+
// single sync push can land both the type + its schema in
|
|
2554
|
+
// one round-trip (instead of POST → PATCH). The op-edit gate
|
|
2555
|
+
// itself no longer requires a schema on a fresh type, but
|
|
2556
|
+
// landing the schema up front keeps subsequent op-pushes
|
|
2557
|
+
// validating against the intended shape.
|
|
2558
|
+
if (typeConfig.schema)
|
|
2559
|
+
createData.schema = typeConfig.schema;
|
|
2560
|
+
try {
|
|
2561
|
+
const created = await client.createDatabaseTypeConfig(resolvedAppId, createData);
|
|
2562
|
+
info(` Created database type: ${dbType}`);
|
|
2563
|
+
if (syncState) {
|
|
2564
|
+
if (!syncState.entities.databaseTypes) {
|
|
2565
|
+
syncState.entities.databaseTypes = {};
|
|
2566
|
+
}
|
|
2567
|
+
syncState.entities.databaseTypes[dbType] = {
|
|
2568
|
+
databaseType: dbType,
|
|
2569
|
+
modifiedAt: created?.modifiedAt || new Date().toISOString(),
|
|
2570
|
+
contentHash: computeFileHash(filePath),
|
|
2571
|
+
hasSchema: !!createData.schema,
|
|
2572
|
+
};
|
|
2175
2573
|
}
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
contentHash: computeFileHash(filePath),
|
|
2180
|
-
};
|
|
2574
|
+
}
|
|
2575
|
+
catch (err) {
|
|
2576
|
+
throw wrapEntityError(err, "create", "database type", dbType);
|
|
2181
2577
|
}
|
|
2182
2578
|
}
|
|
2183
2579
|
}
|
|
@@ -2195,6 +2591,10 @@ Directory Structure:
|
|
|
2195
2591
|
: existingOp.modifiedAt;
|
|
2196
2592
|
try {
|
|
2197
2593
|
const updated = await client.updateDatabaseTypeOperation(resolvedAppId, dbType, op.name, {
|
|
2594
|
+
// Include `type` so the server can detect/apply
|
|
2595
|
+
// type-in-place changes (issue #692). When the type
|
|
2596
|
+
// matches the stored value, this is a no-op.
|
|
2597
|
+
type: op.type,
|
|
2198
2598
|
modelName: op.modelName,
|
|
2199
2599
|
access: op.access,
|
|
2200
2600
|
definition: op.definition,
|
|
@@ -2214,8 +2614,23 @@ Directory Structure:
|
|
|
2214
2614
|
localModifiedAt: expectedOpModifiedAt || "unknown",
|
|
2215
2615
|
});
|
|
2216
2616
|
}
|
|
2617
|
+
else if (err instanceof SchemaRequiredError) {
|
|
2618
|
+
schemaErrors.schemaRequired.push({
|
|
2619
|
+
type: "operation",
|
|
2620
|
+
key: `${dbType}/${op.name}`,
|
|
2621
|
+
message: err.message,
|
|
2622
|
+
});
|
|
2623
|
+
}
|
|
2624
|
+
else if (err instanceof OperationRefError) {
|
|
2625
|
+
schemaErrors.opRefs.push({
|
|
2626
|
+
type: "operation",
|
|
2627
|
+
key: `${dbType}/${op.name}`,
|
|
2628
|
+
refs: err.refs,
|
|
2629
|
+
message: err.message,
|
|
2630
|
+
});
|
|
2631
|
+
}
|
|
2217
2632
|
else {
|
|
2218
|
-
throw err;
|
|
2633
|
+
throw wrapEntityError(err, "update", "operation", `${dbType}/${op.name}`);
|
|
2219
2634
|
}
|
|
2220
2635
|
}
|
|
2221
2636
|
}
|
|
@@ -2224,22 +2639,44 @@ Directory Structure:
|
|
|
2224
2639
|
// Create new operation
|
|
2225
2640
|
changes.push({ type: "operation", action: "create", key: `${dbType}/${op.name}` });
|
|
2226
2641
|
if (!options.dryRun) {
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
if (
|
|
2238
|
-
syncState.entities.databaseTypes[dbType].operations
|
|
2642
|
+
try {
|
|
2643
|
+
const created = await client.createDatabaseTypeOperation(resolvedAppId, dbType, {
|
|
2644
|
+
name: op.name,
|
|
2645
|
+
type: op.type,
|
|
2646
|
+
modelName: op.modelName,
|
|
2647
|
+
access: op.access,
|
|
2648
|
+
definition: op.definition,
|
|
2649
|
+
params: op.params,
|
|
2650
|
+
});
|
|
2651
|
+
info(` Created operation: ${dbType}/${op.name}`);
|
|
2652
|
+
if (syncState?.entities?.databaseTypes?.[dbType]) {
|
|
2653
|
+
if (!syncState.entities.databaseTypes[dbType].operations) {
|
|
2654
|
+
syncState.entities.databaseTypes[dbType].operations = {};
|
|
2655
|
+
}
|
|
2656
|
+
syncState.entities.databaseTypes[dbType].operations[op.name] = {
|
|
2657
|
+
modifiedAt: created?.modifiedAt || new Date().toISOString(),
|
|
2658
|
+
};
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
catch (err) {
|
|
2662
|
+
if (err instanceof SchemaRequiredError) {
|
|
2663
|
+
schemaErrors.schemaRequired.push({
|
|
2664
|
+
type: "operation",
|
|
2665
|
+
key: `${dbType}/${op.name}`,
|
|
2666
|
+
message: err.message,
|
|
2667
|
+
});
|
|
2668
|
+
}
|
|
2669
|
+
else if (err instanceof OperationRefError) {
|
|
2670
|
+
schemaErrors.opRefs.push({
|
|
2671
|
+
type: "operation",
|
|
2672
|
+
key: `${dbType}/${op.name}`,
|
|
2673
|
+
refs: err.refs,
|
|
2674
|
+
message: err.message,
|
|
2675
|
+
});
|
|
2676
|
+
}
|
|
2677
|
+
else {
|
|
2678
|
+
throw wrapEntityError(err, "create", "operation", `${dbType}/${op.name}`);
|
|
2239
2679
|
}
|
|
2240
|
-
syncState.entities.databaseTypes[dbType].operations[op.name] = {
|
|
2241
|
-
modifiedAt: created?.modifiedAt || new Date().toISOString(),
|
|
2242
|
-
};
|
|
2243
2680
|
}
|
|
2244
2681
|
}
|
|
2245
2682
|
}
|
|
@@ -2249,14 +2686,128 @@ Directory Structure:
|
|
|
2249
2686
|
if (!tomlOpNames.has(existingOpName)) {
|
|
2250
2687
|
changes.push({ type: "operation", action: "delete", key: `${dbType}/${existingOpName}` });
|
|
2251
2688
|
if (!options.dryRun) {
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2689
|
+
try {
|
|
2690
|
+
await client.deleteDatabaseTypeOperation(resolvedAppId, dbType, existingOpName);
|
|
2691
|
+
info(` Deleted operation: ${dbType}/${existingOpName}`);
|
|
2692
|
+
if (syncState?.entities?.databaseTypes?.[dbType]?.operations) {
|
|
2693
|
+
delete syncState.entities.databaseTypes[dbType].operations[existingOpName];
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
catch (err) {
|
|
2697
|
+
throw wrapEntityError(err, "delete", "operation", `${dbType}/${existingOpName}`);
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
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}`);
|
|
2256
2772
|
}
|
|
2257
2773
|
}
|
|
2258
2774
|
}
|
|
2259
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
|
+
}
|
|
2260
2811
|
}
|
|
2261
2812
|
}
|
|
2262
2813
|
// Process group type configs
|
|
@@ -2304,7 +2855,7 @@ Directory Structure:
|
|
|
2304
2855
|
});
|
|
2305
2856
|
}
|
|
2306
2857
|
else {
|
|
2307
|
-
throw err;
|
|
2858
|
+
throw wrapEntityError(err, "update", "group type config", groupType);
|
|
2308
2859
|
}
|
|
2309
2860
|
}
|
|
2310
2861
|
}
|
|
@@ -2313,20 +2864,25 @@ Directory Structure:
|
|
|
2313
2864
|
// Create new group type config
|
|
2314
2865
|
changes.push({ type: "group-type-config", action: "create", key: groupType });
|
|
2315
2866
|
if (!options.dryRun) {
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
if (
|
|
2324
|
-
syncState.entities.groupTypeConfigs
|
|
2867
|
+
try {
|
|
2868
|
+
const created = await client.createGroupTypeConfig(resolvedAppId, {
|
|
2869
|
+
groupType,
|
|
2870
|
+
ruleSetId: configData.ruleSetId || undefined,
|
|
2871
|
+
autoAddCreator: configData.autoAddCreator,
|
|
2872
|
+
});
|
|
2873
|
+
info(` Created group type config: ${groupType}`);
|
|
2874
|
+
if (syncState) {
|
|
2875
|
+
if (!syncState.entities.groupTypeConfigs) {
|
|
2876
|
+
syncState.entities.groupTypeConfigs = {};
|
|
2877
|
+
}
|
|
2878
|
+
syncState.entities.groupTypeConfigs[groupType] = {
|
|
2879
|
+
modifiedAt: created?.modifiedAt || new Date().toISOString(),
|
|
2880
|
+
contentHash: computeFileHash(filePath),
|
|
2881
|
+
};
|
|
2325
2882
|
}
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
};
|
|
2883
|
+
}
|
|
2884
|
+
catch (err) {
|
|
2885
|
+
throw wrapEntityError(err, "create", "group type config", groupType);
|
|
2330
2886
|
}
|
|
2331
2887
|
}
|
|
2332
2888
|
}
|
|
@@ -2376,7 +2932,7 @@ Directory Structure:
|
|
|
2376
2932
|
});
|
|
2377
2933
|
}
|
|
2378
2934
|
else {
|
|
2379
|
-
throw err;
|
|
2935
|
+
throw wrapEntityError(err, "update", "collection type config", collectionType);
|
|
2380
2936
|
}
|
|
2381
2937
|
}
|
|
2382
2938
|
}
|
|
@@ -2385,19 +2941,24 @@ Directory Structure:
|
|
|
2385
2941
|
// Create new collection type config
|
|
2386
2942
|
changes.push({ type: "collection-type-config", action: "create", key: collectionType });
|
|
2387
2943
|
if (!options.dryRun) {
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
if (
|
|
2395
|
-
syncState.entities.collectionTypeConfigs
|
|
2944
|
+
try {
|
|
2945
|
+
const created = await client.createCollectionTypeConfig(resolvedAppId, {
|
|
2946
|
+
collectionType,
|
|
2947
|
+
ruleSetId: configData.ruleSetId || undefined,
|
|
2948
|
+
});
|
|
2949
|
+
info(` Created collection type config: ${collectionType}`);
|
|
2950
|
+
if (syncState) {
|
|
2951
|
+
if (!syncState.entities.collectionTypeConfigs) {
|
|
2952
|
+
syncState.entities.collectionTypeConfigs = {};
|
|
2953
|
+
}
|
|
2954
|
+
syncState.entities.collectionTypeConfigs[collectionType] = {
|
|
2955
|
+
modifiedAt: created?.modifiedAt || new Date().toISOString(),
|
|
2956
|
+
contentHash: computeFileHash(filePath),
|
|
2957
|
+
};
|
|
2396
2958
|
}
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
};
|
|
2959
|
+
}
|
|
2960
|
+
catch (err) {
|
|
2961
|
+
throw wrapEntityError(err, "create", "collection type config", collectionType);
|
|
2401
2962
|
}
|
|
2402
2963
|
}
|
|
2403
2964
|
}
|
|
@@ -2478,33 +3039,118 @@ Directory Structure:
|
|
|
2478
3039
|
console.log(` ${color(change.action)} ${change.type}: ${change.key}`);
|
|
2479
3040
|
}
|
|
2480
3041
|
}
|
|
2481
|
-
else if (conflicts.length > 0
|
|
3042
|
+
else if (conflicts.length > 0 ||
|
|
3043
|
+
schemaErrors.schemaRequired.length > 0 ||
|
|
3044
|
+
schemaErrors.opRefs.length > 0 ||
|
|
3045
|
+
schemaErrors.schemaBreaks.length > 0 ||
|
|
3046
|
+
schemaErrors.uncheckableOps.length > 0 ||
|
|
3047
|
+
schemaErrors.tomlParse.length > 0 ||
|
|
3048
|
+
schemaErrors.opsExist.length > 0) {
|
|
2482
3049
|
// Handle conflicts
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
3050
|
+
if (conflicts.length > 0) {
|
|
3051
|
+
console.log();
|
|
3052
|
+
warn(`${conflicts.length} conflict(s) detected:`);
|
|
3053
|
+
console.log();
|
|
3054
|
+
for (const conflict of conflicts) {
|
|
3055
|
+
console.log(` ${chalk.red("CONFLICT")} ${conflict.type}: ${chalk.bold(conflict.key)}`);
|
|
3056
|
+
console.log(` Local last sync: ${chalk.dim(conflict.localModifiedAt)}`);
|
|
3057
|
+
console.log(` Server modified: ${chalk.yellow(conflict.serverModifiedAt)}`);
|
|
3058
|
+
}
|
|
3059
|
+
console.log();
|
|
3060
|
+
warn("These entities were modified on the server since your last pull.");
|
|
3061
|
+
info("Options:");
|
|
3062
|
+
console.log(` 1. Run ${chalk.cyan("primitive sync pull")} to get latest changes`);
|
|
3063
|
+
console.log(` 2. Run ${chalk.cyan("primitive sync push --force")} to overwrite server`);
|
|
3064
|
+
console.log();
|
|
3065
|
+
}
|
|
3066
|
+
// Report schema-feature errors (issue #666).
|
|
3067
|
+
if (schemaErrors.schemaRequired.length > 0) {
|
|
3068
|
+
console.log();
|
|
3069
|
+
warn(`${schemaErrors.schemaRequired.length} operation push(es) blocked: type has no schema set.`);
|
|
3070
|
+
for (const e of schemaErrors.schemaRequired) {
|
|
3071
|
+
const type = e.key.split("/")[0];
|
|
3072
|
+
console.log(` ${chalk.red("SCHEMA_REQUIRED")} ${e.key}`);
|
|
3073
|
+
console.log(` Run ${chalk.cyan(`primitive databases schema generate ${type}`)} to scaffold one, then retry.`);
|
|
3074
|
+
}
|
|
3075
|
+
console.log();
|
|
3076
|
+
}
|
|
3077
|
+
if (schemaErrors.opRefs.length > 0) {
|
|
3078
|
+
console.log();
|
|
3079
|
+
warn(`${schemaErrors.opRefs.length} operation push(es) blocked: unresolved references.`);
|
|
3080
|
+
for (const e of schemaErrors.opRefs) {
|
|
3081
|
+
console.log(` ${chalk.red("OPERATION_REFERENCES_UNDEFINED")} ${e.key}`);
|
|
3082
|
+
for (const ref of e.refs) {
|
|
3083
|
+
console.log(` - ${chalk.yellow(ref.ref)} at ${chalk.dim(ref.location)}`);
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
console.log();
|
|
3087
|
+
}
|
|
3088
|
+
if (schemaErrors.schemaBreaks.length > 0) {
|
|
3089
|
+
console.log();
|
|
3090
|
+
warn(`${schemaErrors.schemaBreaks.length} schema push(es) blocked: existing operations would break.`);
|
|
3091
|
+
for (const e of schemaErrors.schemaBreaks) {
|
|
3092
|
+
console.log(` ${chalk.red("SCHEMA_BREAKS_OPERATIONS")} ${e.key}`);
|
|
3093
|
+
for (const op of e.operations) {
|
|
3094
|
+
console.log(` ${chalk.bold(op.operation)}`);
|
|
3095
|
+
for (const ref of op.refs) {
|
|
3096
|
+
console.log(` - ${chalk.yellow(ref.ref)} at ${chalk.dim(ref.location)}`);
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
console.log();
|
|
3101
|
+
}
|
|
3102
|
+
if (schemaErrors.uncheckableOps.length > 0) {
|
|
3103
|
+
console.log();
|
|
3104
|
+
warn(`${schemaErrors.uncheckableOps.length} schema push(es) blocked: operations with dynamic refs.`);
|
|
3105
|
+
for (const e of schemaErrors.uncheckableOps) {
|
|
3106
|
+
console.log(` ${chalk.red("SCHEMA_HAS_UNCHECKABLE_OPS")} ${e.key}`);
|
|
3107
|
+
for (const op of e.operations) {
|
|
3108
|
+
console.log(` ${chalk.bold(op.operation)}`);
|
|
3109
|
+
for (const loc of op.locations) {
|
|
3110
|
+
console.log(` - ${chalk.dim(loc)}`);
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
}
|
|
3114
|
+
info(`Re-run with ${chalk.cyan("--accept-warnings")} to commit anyway.`);
|
|
3115
|
+
console.log();
|
|
3116
|
+
}
|
|
3117
|
+
if (schemaErrors.tomlParse.length > 0) {
|
|
3118
|
+
console.log();
|
|
3119
|
+
warn(`${schemaErrors.tomlParse.length} TOML parse error(s):`);
|
|
3120
|
+
for (const e of schemaErrors.tomlParse) {
|
|
3121
|
+
const loc = e.line ? ` (line ${e.line}${e.column ? `, col ${e.column}` : ""})` : "";
|
|
3122
|
+
console.log(` ${chalk.red("TOML_PARSE_ERROR")} ${e.key}${loc}`);
|
|
3123
|
+
console.log(` ${e.message}`);
|
|
3124
|
+
}
|
|
3125
|
+
console.log();
|
|
3126
|
+
}
|
|
3127
|
+
if (schemaErrors.opsExist.length > 0) {
|
|
3128
|
+
console.log();
|
|
3129
|
+
warn(`${schemaErrors.opsExist.length} schema delete(s) blocked: operations still registered.`);
|
|
3130
|
+
for (const e of schemaErrors.opsExist) {
|
|
3131
|
+
console.log(` ${chalk.red("OPS_EXIST")} ${e.key} — ${e.opCount} op(s)`);
|
|
3132
|
+
console.log(` Delete or migrate the affected operations before clearing the schema.`);
|
|
3133
|
+
}
|
|
3134
|
+
console.log();
|
|
2490
3135
|
}
|
|
2491
|
-
console.log();
|
|
2492
|
-
warn("These entities were modified on the server since your last pull.");
|
|
2493
|
-
info("Options:");
|
|
2494
|
-
console.log(` 1. Run ${chalk.cyan("primitive sync pull")} to get latest changes`);
|
|
2495
|
-
console.log(` 2. Run ${chalk.cyan("primitive sync push --force")} to overwrite server`);
|
|
2496
|
-
console.log();
|
|
2497
3136
|
// Update sync state for non-conflicting changes
|
|
2498
3137
|
if (syncState) {
|
|
2499
3138
|
syncState.lastSyncedAt = new Date().toISOString();
|
|
2500
3139
|
saveSyncState(configDir, syncState);
|
|
2501
3140
|
}
|
|
2502
|
-
const
|
|
3141
|
+
const totalBlocked = conflicts.length +
|
|
3142
|
+
schemaErrors.schemaRequired.length +
|
|
3143
|
+
schemaErrors.opRefs.length +
|
|
3144
|
+
schemaErrors.schemaBreaks.length +
|
|
3145
|
+
schemaErrors.uncheckableOps.length +
|
|
3146
|
+
schemaErrors.tomlParse.length +
|
|
3147
|
+
schemaErrors.opsExist.length;
|
|
3148
|
+
const successCount = changes.length - totalBlocked;
|
|
2503
3149
|
if (successCount > 0) {
|
|
2504
|
-
success(`Pushed ${successCount} change(s). ${
|
|
3150
|
+
success(`Pushed ${successCount} change(s). ${totalBlocked} blocked.`);
|
|
2505
3151
|
}
|
|
2506
3152
|
else {
|
|
2507
|
-
error(`Push failed: ${
|
|
3153
|
+
error(`Push failed: ${totalBlocked} blocked. No changes applied.`);
|
|
2508
3154
|
}
|
|
2509
3155
|
process.exit(1);
|
|
2510
3156
|
}
|
|
@@ -2532,6 +3178,12 @@ Directory Structure:
|
|
|
2532
3178
|
}
|
|
2533
3179
|
}
|
|
2534
3180
|
error(err.message);
|
|
3181
|
+
// Print structured server-side validation details (issue #684).
|
|
3182
|
+
if (err instanceof ApiError && Array.isArray(err.details) && err.details.length > 0) {
|
|
3183
|
+
for (const detail of err.details) {
|
|
3184
|
+
console.error(` - ${typeof detail === "string" ? detail : JSON.stringify(detail)}`);
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
2535
3187
|
process.exit(1);
|
|
2536
3188
|
}
|
|
2537
3189
|
});
|
|
@@ -2852,5 +3504,123 @@ Directory Structure:
|
|
|
2852
3504
|
process.exit(1);
|
|
2853
3505
|
}
|
|
2854
3506
|
});
|
|
3507
|
+
// Migrate-toml (issue #752): bulk-rewrite database-type TOML files from
|
|
3508
|
+
// the legacy JSON-string form for `definition`/`params` to the native
|
|
3509
|
+
// nested-table form. Idempotent: ops already in native form are left as
|
|
3510
|
+
// is; ops that are un-TOMLable (mixed-type arrays, null values) fall
|
|
3511
|
+
// back to JSON-string per field with a log message.
|
|
3512
|
+
sync
|
|
3513
|
+
.command("migrate-toml")
|
|
3514
|
+
.description("Rewrite database-type TOML files to native [operations.definition] / [[operations.params]] form")
|
|
3515
|
+
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
3516
|
+
.option("--app <app-id>", "App ID")
|
|
3517
|
+
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
3518
|
+
.option("--dry-run", "Show what would change without writing files")
|
|
3519
|
+
.action(async (appId, options) => {
|
|
3520
|
+
// Resolve appId so we land in the per-env sync dir even when the user
|
|
3521
|
+
// didn't pass `--dir`. The app ID itself isn't used for any API calls
|
|
3522
|
+
// — `migrate-toml` is a purely local rewrite.
|
|
3523
|
+
const resolvedAppId = resolveAppId(appId, options);
|
|
3524
|
+
const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
|
|
3525
|
+
if (isAutoResolvedSyncDir(options.dir)) {
|
|
3526
|
+
info(`Using per-environment sync directory: ${configDir}`);
|
|
3527
|
+
}
|
|
3528
|
+
if (!existsSync(configDir)) {
|
|
3529
|
+
error(`Config directory not found: ${configDir}`);
|
|
3530
|
+
process.exit(1);
|
|
3531
|
+
}
|
|
3532
|
+
const dbTypesDir = join(configDir, "database-types");
|
|
3533
|
+
if (!existsSync(dbTypesDir)) {
|
|
3534
|
+
info("No database-types/ directory found; nothing to migrate.");
|
|
3535
|
+
return;
|
|
3536
|
+
}
|
|
3537
|
+
const files = readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml"));
|
|
3538
|
+
if (files.length === 0) {
|
|
3539
|
+
info("No database-type TOML files found; nothing to migrate.");
|
|
3540
|
+
return;
|
|
3541
|
+
}
|
|
3542
|
+
// Hydrate ruleSetIdToName from local sync state so files that
|
|
3543
|
+
// reference a rule set via the legacy `ruleSetId = "01..."` form
|
|
3544
|
+
// round-trip with the correct `ruleSetName`. Without this, any user
|
|
3545
|
+
// who runs migrate-toml against a file with a rule-set assignment
|
|
3546
|
+
// would silently lose that reference (review feedback r3246633010).
|
|
3547
|
+
//
|
|
3548
|
+
// Files that use the modern key-based `ruleSetName = "..."` form
|
|
3549
|
+
// don't need the map at all — `parseDatabaseTypeToml` stores the
|
|
3550
|
+
// value in `typeConfig._ruleSetName` and the serializer now prefers
|
|
3551
|
+
// that. The map is only load-bearing for the legacy ID-based form.
|
|
3552
|
+
const ruleSetIdToName = new Map();
|
|
3553
|
+
const migrateSyncState = loadSyncState(configDir);
|
|
3554
|
+
if (migrateSyncState?.entities?.ruleSets) {
|
|
3555
|
+
for (const [fileKey, entry] of Object.entries(migrateSyncState.entities.ruleSets)) {
|
|
3556
|
+
if (entry && typeof entry === "object" && "id" in entry && entry.id) {
|
|
3557
|
+
// fileKey is the sanitized rule-set name (see sync pull at
|
|
3558
|
+
// sync.ts:1385). It's the same shape the server returned and
|
|
3559
|
+
// matches what a TOML file's ruleSetName field would carry.
|
|
3560
|
+
ruleSetIdToName.set(entry.id, fileKey);
|
|
3561
|
+
}
|
|
3562
|
+
}
|
|
3563
|
+
}
|
|
3564
|
+
let migratedCount = 0;
|
|
3565
|
+
let unchangedCount = 0;
|
|
3566
|
+
for (const file of files) {
|
|
3567
|
+
const filePath = join(dbTypesDir, file);
|
|
3568
|
+
const rawBefore = readFileSync(filePath, "utf-8");
|
|
3569
|
+
const tomlData = parseTomlFile(filePath);
|
|
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
|
+
}
|
|
3587
|
+
// Force-native: ignore existing hints so every TOMLable field
|
|
3588
|
+
// ends up in nested-table form. Subscriptions are preserved on the
|
|
3589
|
+
// rewrite (issue #803) so migrate-toml never drops them.
|
|
3590
|
+
const rewritten = serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
|
|
3591
|
+
defaultForm: "native",
|
|
3592
|
+
logger: (msg) => info(` ${msg}`),
|
|
3593
|
+
subscriptions,
|
|
3594
|
+
});
|
|
3595
|
+
if (rewritten === rawBefore) {
|
|
3596
|
+
unchangedCount++;
|
|
3597
|
+
continue;
|
|
3598
|
+
}
|
|
3599
|
+
if (options.dryRun) {
|
|
3600
|
+
info(`Would migrate database-types/${file}`);
|
|
3601
|
+
}
|
|
3602
|
+
else {
|
|
3603
|
+
writeFileSync(filePath, rewritten);
|
|
3604
|
+
info(`Migrated database-types/${file}`);
|
|
3605
|
+
// Update content hash in sync state if we have an entry for it.
|
|
3606
|
+
const syncState = loadSyncState(configDir);
|
|
3607
|
+
const dbType = typeConfig.databaseType || basename(file, ".toml");
|
|
3608
|
+
if (syncState?.entities?.databaseTypes?.[dbType]) {
|
|
3609
|
+
syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
|
|
3610
|
+
saveSyncState(configDir, syncState);
|
|
3611
|
+
}
|
|
3612
|
+
}
|
|
3613
|
+
migratedCount++;
|
|
3614
|
+
}
|
|
3615
|
+
divider();
|
|
3616
|
+
keyValue("Migrated", migratedCount);
|
|
3617
|
+
keyValue("Already native (unchanged)", unchangedCount);
|
|
3618
|
+
if (options.dryRun) {
|
|
3619
|
+
info("Dry-run only — no files were modified.");
|
|
3620
|
+
}
|
|
3621
|
+
else if (migratedCount > 0) {
|
|
3622
|
+
success(`Rewrote ${migratedCount} TOML file(s) to native form.`);
|
|
3623
|
+
}
|
|
3624
|
+
});
|
|
2855
3625
|
}
|
|
2856
3626
|
//# sourceMappingURL=sync.js.map
|