primitive-admin 1.0.47 → 1.0.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/commands/databases.js +203 -0
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/init.js +22 -13
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/sync.js +766 -124
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/workflows.js +14 -0
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.js +61 -6
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/csv.js +177 -0
- package/dist/src/lib/csv.js.map +1 -0
- package/dist/src/lib/db-codegen/dbGenerator.js +119 -3
- package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
- package/dist/src/lib/db-codegen/dbTemplates.js +3 -3
- package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
- package/dist/src/lib/db-codegen/dbTsTypes.js +55 -4
- package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -1
- package/dist/src/lib/workflow-toml-validator.js +3 -0
- package/dist/src/lib/workflow-toml-validator.js.map +1 -1
- package/package.json +3 -2
|
@@ -676,6 +676,50 @@ export function parseTestCaseToml(tomlData) {
|
|
|
676
676
|
}
|
|
677
677
|
return payload;
|
|
678
678
|
}
|
|
679
|
+
/**
|
|
680
|
+
* Pull server-side `Script` rows into `transforms/*.rhai` and record
|
|
681
|
+
* each in the returned sync-state map. Issue #892 slice 7 + codex
|
|
682
|
+
* follow-up on PR #893. Exported so the unit test can drive it
|
|
683
|
+
* against a stubbed `client.listScripts`.
|
|
684
|
+
*
|
|
685
|
+
* Idempotency: every call writes the body the server returned, so
|
|
686
|
+
* re-running `sync pull` on an unchanged server overwrites with the
|
|
687
|
+
* same bytes and produces the same `contentHash`. The result map
|
|
688
|
+
* always reflects the current server state for the writes performed.
|
|
689
|
+
*
|
|
690
|
+
* Older-server graceful path: if `listScripts` rejects (e.g. older
|
|
691
|
+
* server without the route), the caller catches and treats it as
|
|
692
|
+
* "no scripts to pull", leaving the directory empty.
|
|
693
|
+
*/
|
|
694
|
+
export async function pullScripts(client, appId, configDir, logger = () => { }) {
|
|
695
|
+
const transformsDir = join(configDir, "transforms");
|
|
696
|
+
ensureDir(transformsDir);
|
|
697
|
+
const scriptEntities = {};
|
|
698
|
+
let items = [];
|
|
699
|
+
try {
|
|
700
|
+
const result = await client.listScripts(appId);
|
|
701
|
+
items = result.items || [];
|
|
702
|
+
}
|
|
703
|
+
catch {
|
|
704
|
+
return { scriptEntities, count: 0 };
|
|
705
|
+
}
|
|
706
|
+
for (const script of items) {
|
|
707
|
+
const name = script?.name;
|
|
708
|
+
if (!name)
|
|
709
|
+
continue;
|
|
710
|
+
const filename = `${name}.rhai`;
|
|
711
|
+
const filePath = join(transformsDir, filename);
|
|
712
|
+
const body = typeof script.body === "string" ? script.body : "";
|
|
713
|
+
writeFileSync(filePath, body);
|
|
714
|
+
scriptEntities[name] = {
|
|
715
|
+
id: script.scriptId,
|
|
716
|
+
modifiedAt: script.modifiedAt || new Date().toISOString(),
|
|
717
|
+
contentHash: computeFileHash(filePath),
|
|
718
|
+
};
|
|
719
|
+
logger(` Wrote transforms/${filename}`);
|
|
720
|
+
}
|
|
721
|
+
return { scriptEntities, count: items.length };
|
|
722
|
+
}
|
|
679
723
|
async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, testCaseEntities, lookupMaps) {
|
|
680
724
|
let testCases;
|
|
681
725
|
try {
|
|
@@ -1012,6 +1056,7 @@ Directory Structure:
|
|
|
1012
1056
|
ensureDir(join(configDir, "blob-buckets"));
|
|
1013
1057
|
ensureDir(join(configDir, "prompts"));
|
|
1014
1058
|
ensureDir(join(configDir, "workflows"));
|
|
1059
|
+
ensureDir(join(configDir, "transforms")); // #892 slice 7 — Rhai scripts
|
|
1015
1060
|
ensureDir(join(configDir, "database-types"));
|
|
1016
1061
|
ensureDir(join(configDir, "rule-sets"));
|
|
1017
1062
|
ensureDir(join(configDir, "group-type-configs"));
|
|
@@ -1116,6 +1161,7 @@ Directory Structure:
|
|
|
1116
1161
|
ensureDir(join(configDir, "blob-buckets"));
|
|
1117
1162
|
ensureDir(join(configDir, "prompts"));
|
|
1118
1163
|
ensureDir(join(configDir, "workflows"));
|
|
1164
|
+
ensureDir(join(configDir, "transforms")); // #892 slice 7 — Rhai scripts
|
|
1119
1165
|
ensureDir(join(configDir, "database-types"));
|
|
1120
1166
|
ensureDir(join(configDir, "rule-sets"));
|
|
1121
1167
|
ensureDir(join(configDir, "group-type-configs"));
|
|
@@ -1229,6 +1275,16 @@ Directory Structure:
|
|
|
1229
1275
|
};
|
|
1230
1276
|
info(` Wrote workflows/${filename}`);
|
|
1231
1277
|
}
|
|
1278
|
+
// Pull transforms (Rhai scripts) — issue #892, slice 7 + codex
|
|
1279
|
+
// follow-up on PR #893. Mirrors the prompts pull pattern: list
|
|
1280
|
+
// server-side `Script` rows, write each body to
|
|
1281
|
+
// `transforms/<name>.rhai`, and record `{id, modifiedAt,
|
|
1282
|
+
// contentHash}` under `entities.scripts[name]` so a subsequent
|
|
1283
|
+
// `sync push` round-trips without false diffs.
|
|
1284
|
+
const { scriptEntities, count: pulledScriptsCount } = await pullScripts(client, resolvedAppId, configDir, info);
|
|
1285
|
+
if (pulledScriptsCount > 0) {
|
|
1286
|
+
info(` Pulled ${pulledScriptsCount} transform(s)`);
|
|
1287
|
+
}
|
|
1232
1288
|
// Build lookup maps for test case serialization
|
|
1233
1289
|
const configIdToName = new Map();
|
|
1234
1290
|
const promptIdToKey = new Map();
|
|
@@ -1398,6 +1454,7 @@ Directory Structure:
|
|
|
1398
1454
|
blobBuckets: Object.keys(blobBucketEntities).length > 0 ? blobBucketEntities : undefined,
|
|
1399
1455
|
prompts: promptEntities,
|
|
1400
1456
|
workflows: workflowEntities,
|
|
1457
|
+
scripts: Object.keys(scriptEntities).length > 0 ? scriptEntities : undefined,
|
|
1401
1458
|
emailTemplates: Object.keys(emailTemplateEntities).length > 0 ? emailTemplateEntities : undefined,
|
|
1402
1459
|
testCases: Object.keys(testCaseEntities).length > 0 ? testCaseEntities : undefined,
|
|
1403
1460
|
databaseTypes: Object.keys(databaseTypeEntities).length > 0 ? databaseTypeEntities : undefined,
|
|
@@ -1417,6 +1474,7 @@ Directory Structure:
|
|
|
1417
1474
|
keyValue("Blob Buckets", blobBucketItems.length);
|
|
1418
1475
|
keyValue("Prompts", prompts.length);
|
|
1419
1476
|
keyValue("Workflows", workflows.length);
|
|
1477
|
+
keyValue("Transforms", pulledScriptsCount);
|
|
1420
1478
|
keyValue("Email Templates", emailTemplates.length);
|
|
1421
1479
|
keyValue("Test Cases", totalTestCases);
|
|
1422
1480
|
keyValue("Database Types", databaseTypesWithOps.length);
|
|
@@ -1483,6 +1541,120 @@ Directory Structure:
|
|
|
1483
1541
|
tomlParse: [],
|
|
1484
1542
|
opsExist: [],
|
|
1485
1543
|
};
|
|
1544
|
+
// Issue #813 (decision 4b): per-database-type partial-state disclosure.
|
|
1545
|
+
// When a database type's gate fails during the validate-first pass, we
|
|
1546
|
+
// skip ALL of that type's mutating calls (4a) and record an entry here
|
|
1547
|
+
// so the push summary can name which type's schema/ops did NOT apply,
|
|
1548
|
+
// the actionable reason, and that re-running `sync push` is convergent.
|
|
1549
|
+
const blockedDatabaseTypes = [];
|
|
1550
|
+
// Issue #813: render the collected conflicts + schema/op gate failures
|
|
1551
|
+
// + per-type partial-state disclosure. Shared by the real-push branch
|
|
1552
|
+
// and the dry-run branch (dry-run runs the same server gates via the
|
|
1553
|
+
// validate-first pass, so the same failures surface).
|
|
1554
|
+
const reportSchemaGateFailures = () => {
|
|
1555
|
+
if (conflicts.length > 0) {
|
|
1556
|
+
console.log();
|
|
1557
|
+
warn(`${conflicts.length} conflict(s) detected:`);
|
|
1558
|
+
console.log();
|
|
1559
|
+
for (const conflict of conflicts) {
|
|
1560
|
+
console.log(` ${chalk.red("CONFLICT")} ${conflict.type}: ${chalk.bold(conflict.key)}`);
|
|
1561
|
+
console.log(` Local last sync: ${chalk.dim(conflict.localModifiedAt)}`);
|
|
1562
|
+
console.log(` Server modified: ${chalk.yellow(conflict.serverModifiedAt)}`);
|
|
1563
|
+
}
|
|
1564
|
+
console.log();
|
|
1565
|
+
warn("These entities were modified on the server since your last pull.");
|
|
1566
|
+
info("Options:");
|
|
1567
|
+
console.log(` 1. Run ${chalk.cyan("primitive sync pull")} to get latest changes`);
|
|
1568
|
+
console.log(` 2. Run ${chalk.cyan("primitive sync push --force")} to overwrite server`);
|
|
1569
|
+
console.log();
|
|
1570
|
+
}
|
|
1571
|
+
// Report schema-feature errors (issue #666).
|
|
1572
|
+
if (schemaErrors.schemaRequired.length > 0) {
|
|
1573
|
+
console.log();
|
|
1574
|
+
warn(`${schemaErrors.schemaRequired.length} operation push(es) blocked: type has no schema set.`);
|
|
1575
|
+
for (const e of schemaErrors.schemaRequired) {
|
|
1576
|
+
const type = e.key.split("/")[0];
|
|
1577
|
+
console.log(` ${chalk.red("SCHEMA_REQUIRED")} ${e.key}`);
|
|
1578
|
+
console.log(` Run ${chalk.cyan(`primitive databases schema generate ${type}`)} to scaffold one, then retry.`);
|
|
1579
|
+
}
|
|
1580
|
+
console.log();
|
|
1581
|
+
}
|
|
1582
|
+
if (schemaErrors.opRefs.length > 0) {
|
|
1583
|
+
console.log();
|
|
1584
|
+
warn(`${schemaErrors.opRefs.length} operation push(es) blocked: unresolved references.`);
|
|
1585
|
+
for (const e of schemaErrors.opRefs) {
|
|
1586
|
+
console.log(` ${chalk.red("OPERATION_REFERENCES_UNDEFINED")} ${e.key}`);
|
|
1587
|
+
for (const ref of e.refs) {
|
|
1588
|
+
console.log(` - ${chalk.yellow(ref.ref)} at ${chalk.dim(ref.location)}`);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
console.log();
|
|
1592
|
+
}
|
|
1593
|
+
if (schemaErrors.schemaBreaks.length > 0) {
|
|
1594
|
+
console.log();
|
|
1595
|
+
warn(`${schemaErrors.schemaBreaks.length} schema push(es) blocked: existing operations would break.`);
|
|
1596
|
+
for (const e of schemaErrors.schemaBreaks) {
|
|
1597
|
+
console.log(` ${chalk.red("SCHEMA_BREAKS_OPERATIONS")} ${e.key}`);
|
|
1598
|
+
for (const op of e.operations) {
|
|
1599
|
+
console.log(` ${chalk.bold(op.operation)}`);
|
|
1600
|
+
for (const ref of op.refs) {
|
|
1601
|
+
console.log(` - ${chalk.yellow(ref.ref)} at ${chalk.dim(ref.location)}`);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
console.log();
|
|
1606
|
+
}
|
|
1607
|
+
if (schemaErrors.uncheckableOps.length > 0) {
|
|
1608
|
+
console.log();
|
|
1609
|
+
warn(`${schemaErrors.uncheckableOps.length} schema push(es) blocked: operations with dynamic refs.`);
|
|
1610
|
+
for (const e of schemaErrors.uncheckableOps) {
|
|
1611
|
+
console.log(` ${chalk.red("SCHEMA_HAS_UNCHECKABLE_OPS")} ${e.key}`);
|
|
1612
|
+
for (const op of e.operations) {
|
|
1613
|
+
console.log(` ${chalk.bold(op.operation)}`);
|
|
1614
|
+
for (const loc of op.locations) {
|
|
1615
|
+
console.log(` - ${chalk.dim(loc)}`);
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
info(`Re-run with ${chalk.cyan("--accept-warnings")} to commit anyway.`);
|
|
1620
|
+
console.log();
|
|
1621
|
+
}
|
|
1622
|
+
if (schemaErrors.tomlParse.length > 0) {
|
|
1623
|
+
console.log();
|
|
1624
|
+
warn(`${schemaErrors.tomlParse.length} TOML parse error(s):`);
|
|
1625
|
+
for (const e of schemaErrors.tomlParse) {
|
|
1626
|
+
const loc = e.line ? ` (line ${e.line}${e.column ? `, col ${e.column}` : ""})` : "";
|
|
1627
|
+
console.log(` ${chalk.red("TOML_PARSE_ERROR")} ${e.key}${loc}`);
|
|
1628
|
+
console.log(` ${e.message}`);
|
|
1629
|
+
}
|
|
1630
|
+
console.log();
|
|
1631
|
+
}
|
|
1632
|
+
if (schemaErrors.opsExist.length > 0) {
|
|
1633
|
+
console.log();
|
|
1634
|
+
warn(`${schemaErrors.opsExist.length} schema delete(s) blocked: operations still registered.`);
|
|
1635
|
+
for (const e of schemaErrors.opsExist) {
|
|
1636
|
+
console.log(` ${chalk.red("OPS_EXIST")} ${e.key} — ${e.opCount} op(s)`);
|
|
1637
|
+
console.log(` Delete or migrate the affected operations before clearing the schema.`);
|
|
1638
|
+
}
|
|
1639
|
+
console.log();
|
|
1640
|
+
}
|
|
1641
|
+
// Issue #813 (decision 4b): partial-state disclosure. Name which
|
|
1642
|
+
// database type's schema/ops did NOT apply (the validate-first pass
|
|
1643
|
+
// skipped the whole type before any mutating call), the actionable
|
|
1644
|
+
// reason, and that re-running `sync push` is safe and convergent
|
|
1645
|
+
// (the local TOML is the source of truth). This replaces the bare
|
|
1646
|
+
// `Pushed N change(s). M blocked.` which hid that a partial state
|
|
1647
|
+
// was left.
|
|
1648
|
+
if (blockedDatabaseTypes.length > 0) {
|
|
1649
|
+
console.log();
|
|
1650
|
+
warn(`${blockedDatabaseTypes.length} database type(s) were NOT applied (schema + operations skipped before any write):`);
|
|
1651
|
+
for (const b of blockedDatabaseTypes) {
|
|
1652
|
+
console.log(` ${chalk.red("SKIPPED")} ${chalk.bold(b.databaseType)} — ${b.reason}`);
|
|
1653
|
+
}
|
|
1654
|
+
info(`No partial changes were written for the skipped type(s) — fix the cause above and re-run ${chalk.cyan("primitive sync push")}. The push is convergent: your local TOML is the source of truth and applied changes are idempotent, so re-running is always safe.`);
|
|
1655
|
+
console.log();
|
|
1656
|
+
}
|
|
1657
|
+
};
|
|
1486
1658
|
// Track name→ID mappings for resolving cross-references during push
|
|
1487
1659
|
const ruleSetNameToId = new Map();
|
|
1488
1660
|
const promptKeyToId = new Map();
|
|
@@ -2130,6 +2302,136 @@ Directory Structure:
|
|
|
2130
2302
|
}
|
|
2131
2303
|
}
|
|
2132
2304
|
}
|
|
2305
|
+
// Process transforms (Rhai scripts) — issue #892, slice 7 of #783.
|
|
2306
|
+
//
|
|
2307
|
+
// `transforms/*.rhai` files are mirrored to `Script` rows via the
|
|
2308
|
+
// admin REST surface (slice 6, `/admin/api/apps/{appId}/scripts`).
|
|
2309
|
+
// The script's `name` is the file basename without the `.rhai`
|
|
2310
|
+
// extension; the file's content is the script body. The
|
|
2311
|
+
// content-hash skip is identical to prompts: an unchanged file is
|
|
2312
|
+
// a no-op; an edited file is a PUT; a new file is a POST.
|
|
2313
|
+
//
|
|
2314
|
+
// Deletes (files removed from `transforms/`) are intentionally
|
|
2315
|
+
// NOT auto-deleted on the server — a workflow revision can still
|
|
2316
|
+
// reference the named script via its frozen R2 payload, and we
|
|
2317
|
+
// don't want a stray rm + push to surface as a hard delete.
|
|
2318
|
+
// The admin REST DELETE remains available for explicit removal.
|
|
2319
|
+
const transformsDir = join(configDir, "transforms");
|
|
2320
|
+
if (existsSync(transformsDir)) {
|
|
2321
|
+
const files = readdirSync(transformsDir).filter((f) => f.endsWith(".rhai"));
|
|
2322
|
+
// Lazy-load the server-side script index on the first miss so
|
|
2323
|
+
// we can map file-basename → existing scriptId for updates of
|
|
2324
|
+
// scripts that the local sync state doesn't yet know about
|
|
2325
|
+
// (e.g. first push after an `init` against a server that has
|
|
2326
|
+
// pre-existing scripts).
|
|
2327
|
+
let serverScripts = null;
|
|
2328
|
+
const loadServerScripts = async () => {
|
|
2329
|
+
if (serverScripts === null) {
|
|
2330
|
+
try {
|
|
2331
|
+
const result = await client.listScripts(resolvedAppId);
|
|
2332
|
+
serverScripts = result.items || [];
|
|
2333
|
+
}
|
|
2334
|
+
catch (err) {
|
|
2335
|
+
// If the list endpoint isn't available (older server), we
|
|
2336
|
+
// fall back to "treat as new" — create attempts will get
|
|
2337
|
+
// a deterministic 409 on duplicate name, which the user
|
|
2338
|
+
// can reconcile.
|
|
2339
|
+
serverScripts = [];
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
return serverScripts;
|
|
2343
|
+
};
|
|
2344
|
+
for (const file of files) {
|
|
2345
|
+
const filePath = join(transformsDir, file);
|
|
2346
|
+
const name = basename(file, ".rhai");
|
|
2347
|
+
const body = readFileSync(filePath, "utf-8");
|
|
2348
|
+
const existingId = syncState?.entities?.scripts?.[name]?.id;
|
|
2349
|
+
// Skip if unchanged since last sync (same content-hash gate
|
|
2350
|
+
// as prompts).
|
|
2351
|
+
if (!options.force &&
|
|
2352
|
+
existingId &&
|
|
2353
|
+
!shouldPushFile(filePath, syncState?.entities?.scripts?.[name]?.contentHash)) {
|
|
2354
|
+
skippedCount++;
|
|
2355
|
+
continue;
|
|
2356
|
+
}
|
|
2357
|
+
if (existingId) {
|
|
2358
|
+
changes.push({ type: "script", action: "update", key: name });
|
|
2359
|
+
if (!options.dryRun) {
|
|
2360
|
+
try {
|
|
2361
|
+
const updated = await client.updateScript(resolvedAppId, existingId, { body });
|
|
2362
|
+
info(` Updated script: ${name}`);
|
|
2363
|
+
if (syncState) {
|
|
2364
|
+
if (!syncState.entities.scripts) {
|
|
2365
|
+
syncState.entities.scripts = {};
|
|
2366
|
+
}
|
|
2367
|
+
syncState.entities.scripts[name] = {
|
|
2368
|
+
id: existingId,
|
|
2369
|
+
modifiedAt: updated?.modifiedAt ||
|
|
2370
|
+
syncState.entities.scripts[name]?.modifiedAt ||
|
|
2371
|
+
new Date().toISOString(),
|
|
2372
|
+
contentHash: computeFileHash(filePath),
|
|
2373
|
+
};
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
catch (err) {
|
|
2377
|
+
throw wrapEntityError(err, "update", "script", name);
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
else {
|
|
2382
|
+
// No id in local state — could be brand new OR could be
|
|
2383
|
+
// tracked only on the server (e.g. fresh `init`). Look up
|
|
2384
|
+
// the server list once and reuse for the rest of the loop.
|
|
2385
|
+
const serverList = await loadServerScripts();
|
|
2386
|
+
const match = serverList.find((s) => s.name === name);
|
|
2387
|
+
if (match) {
|
|
2388
|
+
// Adopt the server's id and update.
|
|
2389
|
+
changes.push({ type: "script", action: "update", key: name });
|
|
2390
|
+
if (!options.dryRun) {
|
|
2391
|
+
try {
|
|
2392
|
+
const updated = await client.updateScript(resolvedAppId, match.scriptId, { body });
|
|
2393
|
+
info(` Updated script: ${name} (adopted from server)`);
|
|
2394
|
+
if (syncState) {
|
|
2395
|
+
if (!syncState.entities.scripts) {
|
|
2396
|
+
syncState.entities.scripts = {};
|
|
2397
|
+
}
|
|
2398
|
+
syncState.entities.scripts[name] = {
|
|
2399
|
+
id: match.scriptId,
|
|
2400
|
+
modifiedAt: updated?.modifiedAt || new Date().toISOString(),
|
|
2401
|
+
contentHash: computeFileHash(filePath),
|
|
2402
|
+
};
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
catch (err) {
|
|
2406
|
+
throw wrapEntityError(err, "update", "script", name);
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
else {
|
|
2411
|
+
changes.push({ type: "script", action: "create", key: name });
|
|
2412
|
+
if (!options.dryRun) {
|
|
2413
|
+
try {
|
|
2414
|
+
const created = await client.createScript(resolvedAppId, { name, body });
|
|
2415
|
+
info(` Created script: ${name}`);
|
|
2416
|
+
if (syncState && created?.scriptId) {
|
|
2417
|
+
if (!syncState.entities.scripts) {
|
|
2418
|
+
syncState.entities.scripts = {};
|
|
2419
|
+
}
|
|
2420
|
+
syncState.entities.scripts[name] = {
|
|
2421
|
+
id: created.scriptId,
|
|
2422
|
+
modifiedAt: created.modifiedAt || new Date().toISOString(),
|
|
2423
|
+
contentHash: computeFileHash(filePath),
|
|
2424
|
+
};
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
catch (err) {
|
|
2428
|
+
throw wrapEntityError(err, "create", "script", name);
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2133
2435
|
// Process workflows
|
|
2134
2436
|
const workflowsDir = join(configDir, "workflows");
|
|
2135
2437
|
if (existsSync(workflowsDir)) {
|
|
@@ -2411,6 +2713,270 @@ Directory Structure:
|
|
|
2411
2713
|
skippedCount++;
|
|
2412
2714
|
continue;
|
|
2413
2715
|
}
|
|
2716
|
+
// ── Issue #813: pre-compute the ops being deleted in THIS push and
|
|
2717
|
+
// the type-level update payload, then run a validate-first pass
|
|
2718
|
+
// before any mutating call for this type. ──
|
|
2719
|
+
const existingOpsForType = existingEntry?.operations || {};
|
|
2720
|
+
const tomlOpNamesForType = new Set(operations.map((op) => op.name));
|
|
2721
|
+
// Ops present in SyncState but absent from the TOML = pending
|
|
2722
|
+
// deletes. The server excludes these from the schema-edit /
|
|
2723
|
+
// OPS_EXIST gate queries (Option 2B) so a single push that deletes
|
|
2724
|
+
// an op AND lands an incompatible schema is not falsely blocked.
|
|
2725
|
+
const pendingOpDeletes = Object.keys(existingOpsForType).filter((n) => !tomlOpNamesForType.has(n));
|
|
2726
|
+
// Issue #813 hardening: the desired final op set this push lands
|
|
2727
|
+
// (the bare op names declared in the TOML). The server only honors
|
|
2728
|
+
// a `pendingOpDeletes` exclusion when the named op is absent here,
|
|
2729
|
+
// proving it's gone from the target state — closing the direct-API
|
|
2730
|
+
// gate bypass (maintainer decision 2026-05-26). `pendingOpDeletes`
|
|
2731
|
+
// is derived as exactly the complement of this set, so the two
|
|
2732
|
+
// never overlap on the legitimate path.
|
|
2733
|
+
const finalOpNames = Array.from(tomlOpNamesForType);
|
|
2734
|
+
// Build the type-level `updateData` (same logic as the apply path
|
|
2735
|
+
// below) so the validate pass and the apply pass agree on whether
|
|
2736
|
+
// a schema PATCH would run and what it carries.
|
|
2737
|
+
const computeTypeUpdateData = () => {
|
|
2738
|
+
const u = {};
|
|
2739
|
+
if ("ruleSetId" in typeConfig)
|
|
2740
|
+
u.ruleSetId = typeConfig.ruleSetId || null;
|
|
2741
|
+
if ("triggers" in typeConfig)
|
|
2742
|
+
u.triggers = typeConfig.triggers || null;
|
|
2743
|
+
if ("metadataAccess" in typeConfig)
|
|
2744
|
+
u.metadataAccess = typeConfig.metadataAccess || null;
|
|
2745
|
+
if ("defaultAccess" in typeConfig)
|
|
2746
|
+
u.defaultAccess = typeConfig.defaultAccess || null;
|
|
2747
|
+
if ("autoPopulatedFields" in typeConfig) {
|
|
2748
|
+
u.autoPopulatedFields = typeConfig.autoPopulatedFields || null;
|
|
2749
|
+
}
|
|
2750
|
+
if ("timestamps" in typeConfig) {
|
|
2751
|
+
u.timestamps = typeConfig.timestamps || null;
|
|
2752
|
+
}
|
|
2753
|
+
const localHasSchema = typeof typeConfig.schema === "string" &&
|
|
2754
|
+
typeConfig.schema.trim().length > 0;
|
|
2755
|
+
if (localHasSchema) {
|
|
2756
|
+
u.schema = typeConfig.schema;
|
|
2757
|
+
}
|
|
2758
|
+
else if (existingEntry?.hasSchema) {
|
|
2759
|
+
u.schema = null;
|
|
2760
|
+
}
|
|
2761
|
+
return u;
|
|
2762
|
+
};
|
|
2763
|
+
// Validate-first pass (decision 4a): run every gate for this type
|
|
2764
|
+
// BEFORE issuing any mutating call. If a gate fails, the whole
|
|
2765
|
+
// type's mutating calls (op creates/updates/deletes AND the schema
|
|
2766
|
+
// PATCH) are skipped — eliminating the gate-failure partial commit.
|
|
2767
|
+
// The same pass IS the dry-run path for `sync push --dry-run`:
|
|
2768
|
+
// it runs the real server gates without persisting (Option 1A).
|
|
2769
|
+
let typeGateBlocked = false;
|
|
2770
|
+
// Issue #684 regression guard: track which op the op-edit gate is
|
|
2771
|
+
// dry-running so an unrecognized server error (e.g. a plain
|
|
2772
|
+
// validation 400 like `"limit" must be 1000 or less`) can be
|
|
2773
|
+
// attributed to that specific operation in the catch-all below,
|
|
2774
|
+
// instead of being coarsened to the db-type level.
|
|
2775
|
+
let opGateContext = null;
|
|
2776
|
+
const validateExpectedModifiedAt = options.force
|
|
2777
|
+
? undefined
|
|
2778
|
+
: existingEntry?.modifiedAt;
|
|
2779
|
+
try {
|
|
2780
|
+
// 1. Schema-edit gate (type-config PATCH dry-run) — only when the
|
|
2781
|
+
// type already exists on the server and we'd actually PATCH a
|
|
2782
|
+
// schema-relevant field. A fresh type (no existingEntry) has no
|
|
2783
|
+
// ops to break, so there's nothing to gate yet.
|
|
2784
|
+
if (existingEntry) {
|
|
2785
|
+
const validateUpdateData = computeTypeUpdateData();
|
|
2786
|
+
if (Object.keys(validateUpdateData).length > 0) {
|
|
2787
|
+
await client.updateDatabaseTypeConfig(resolvedAppId, dbType, pendingOpDeletes.length > 0
|
|
2788
|
+
? { ...validateUpdateData, pendingOpDeletes, finalOpNames }
|
|
2789
|
+
: validateUpdateData, validateExpectedModifiedAt, {
|
|
2790
|
+
dryRun: true,
|
|
2791
|
+
acceptWarnings: !!options.acceptWarnings,
|
|
2792
|
+
});
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
// 2. Op-edit gate (op create/update dry-run) for every op in the
|
|
2796
|
+
// TOML. The server runs `runSchemaGate` and short-circuits
|
|
2797
|
+
// before persisting. Skipped for a fresh type (no schema on the
|
|
2798
|
+
// server yet → gate is a no-op anyway).
|
|
2799
|
+
//
|
|
2800
|
+
// The op gate is run against the schema THIS push is landing
|
|
2801
|
+
// (`schemaOverride`), not the stale stored schema — the real
|
|
2802
|
+
// apply path PATCHes the schema before creating/updating ops,
|
|
2803
|
+
// so an op that depends on a field this push adds must NOT be
|
|
2804
|
+
// falsely rejected (plan edge case: "op create that depends on
|
|
2805
|
+
// a schema landed earlier in the same push"). When the push
|
|
2806
|
+
// isn't changing the schema, leave the override unset so the
|
|
2807
|
+
// server uses the stored schema.
|
|
2808
|
+
if (existingEntry) {
|
|
2809
|
+
const validateUpdateData = computeTypeUpdateData();
|
|
2810
|
+
const proposedSchema = "schema" in validateUpdateData
|
|
2811
|
+
? validateUpdateData.schema
|
|
2812
|
+
: undefined;
|
|
2813
|
+
for (const op of operations) {
|
|
2814
|
+
const existingOp = existingOpsForType[op.name];
|
|
2815
|
+
opGateContext = {
|
|
2816
|
+
action: existingOp ? "update" : "create",
|
|
2817
|
+
key: `${dbType}/${op.name}`,
|
|
2818
|
+
};
|
|
2819
|
+
if (existingOp) {
|
|
2820
|
+
await client.updateDatabaseTypeOperation(resolvedAppId, dbType, op.name, {
|
|
2821
|
+
type: op.type,
|
|
2822
|
+
modelName: op.modelName,
|
|
2823
|
+
access: op.access,
|
|
2824
|
+
definition: op.definition,
|
|
2825
|
+
params: op.params,
|
|
2826
|
+
}, options.force ? undefined : existingOp.modifiedAt, { dryRun: true, schemaOverride: proposedSchema });
|
|
2827
|
+
}
|
|
2828
|
+
else {
|
|
2829
|
+
await client.createDatabaseTypeOperation(resolvedAppId, dbType, {
|
|
2830
|
+
name: op.name,
|
|
2831
|
+
type: op.type,
|
|
2832
|
+
modelName: op.modelName,
|
|
2833
|
+
access: op.access,
|
|
2834
|
+
definition: op.definition,
|
|
2835
|
+
params: op.params,
|
|
2836
|
+
}, { dryRun: true, schemaOverride: proposedSchema });
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
// Every op gate passed; clear the context so a later (non-op)
|
|
2840
|
+
// throw inside this try isn't misattributed to the last op.
|
|
2841
|
+
opGateContext = null;
|
|
2842
|
+
}
|
|
2843
|
+
}
|
|
2844
|
+
catch (err) {
|
|
2845
|
+
// A gate failure during validate aborts the whole type. Bucket it
|
|
2846
|
+
// into the same `schemaErrors` collectors used by the reporting
|
|
2847
|
+
// path so the summary renders it identically, and record a
|
|
2848
|
+
// disclosure entry. Conflicts and other errors fall through to
|
|
2849
|
+
// the apply path's handling (validate-first doesn't change
|
|
2850
|
+
// conflict semantics).
|
|
2851
|
+
if (err instanceof SchemaBreaksOpsError) {
|
|
2852
|
+
schemaErrors.schemaBreaks.push({
|
|
2853
|
+
type: "database-type",
|
|
2854
|
+
key: dbType,
|
|
2855
|
+
operations: err.operations,
|
|
2856
|
+
message: err.message,
|
|
2857
|
+
});
|
|
2858
|
+
blockedDatabaseTypes.push({
|
|
2859
|
+
databaseType: dbType,
|
|
2860
|
+
reason: `schema change blocked: ${err.operations.length} kept operation(s) would break under the new schema`,
|
|
2861
|
+
});
|
|
2862
|
+
typeGateBlocked = true;
|
|
2863
|
+
}
|
|
2864
|
+
else if (err instanceof SchemaHasUncheckableOpsError) {
|
|
2865
|
+
schemaErrors.uncheckableOps.push({
|
|
2866
|
+
type: "database-type",
|
|
2867
|
+
key: dbType,
|
|
2868
|
+
operations: err.operations,
|
|
2869
|
+
message: err.message,
|
|
2870
|
+
});
|
|
2871
|
+
blockedDatabaseTypes.push({
|
|
2872
|
+
databaseType: dbType,
|
|
2873
|
+
reason: `schema change blocked: operation(s) have dynamic refs (re-run with --accept-warnings to commit)`,
|
|
2874
|
+
});
|
|
2875
|
+
typeGateBlocked = true;
|
|
2876
|
+
}
|
|
2877
|
+
else if (err instanceof OpsExistError) {
|
|
2878
|
+
schemaErrors.opsExist.push({
|
|
2879
|
+
type: "database-type",
|
|
2880
|
+
key: dbType,
|
|
2881
|
+
opCount: err.opCount,
|
|
2882
|
+
message: err.message,
|
|
2883
|
+
});
|
|
2884
|
+
blockedDatabaseTypes.push({
|
|
2885
|
+
databaseType: dbType,
|
|
2886
|
+
reason: `schema deletion blocked: ${err.opCount} operation(s) still registered after this push's deletes`,
|
|
2887
|
+
});
|
|
2888
|
+
typeGateBlocked = true;
|
|
2889
|
+
}
|
|
2890
|
+
else if (err instanceof SchemaRequiredError) {
|
|
2891
|
+
schemaErrors.schemaRequired.push({
|
|
2892
|
+
type: "operation",
|
|
2893
|
+
key: `${dbType}/_`,
|
|
2894
|
+
message: err.message,
|
|
2895
|
+
});
|
|
2896
|
+
blockedDatabaseTypes.push({
|
|
2897
|
+
databaseType: dbType,
|
|
2898
|
+
reason: `operation push blocked: type has no schema set`,
|
|
2899
|
+
});
|
|
2900
|
+
typeGateBlocked = true;
|
|
2901
|
+
}
|
|
2902
|
+
else if (err instanceof OperationRefError) {
|
|
2903
|
+
schemaErrors.opRefs.push({
|
|
2904
|
+
type: "operation",
|
|
2905
|
+
key: dbType,
|
|
2906
|
+
refs: err.refs,
|
|
2907
|
+
message: err.message,
|
|
2908
|
+
});
|
|
2909
|
+
blockedDatabaseTypes.push({
|
|
2910
|
+
databaseType: dbType,
|
|
2911
|
+
reason: `operation push blocked: unresolved references in the schema`,
|
|
2912
|
+
});
|
|
2913
|
+
typeGateBlocked = true;
|
|
2914
|
+
}
|
|
2915
|
+
else if (err instanceof ConflictError) {
|
|
2916
|
+
// Surface the conflict and skip this type (don't blow away the
|
|
2917
|
+
// server with mutating calls). Mirrors the apply path.
|
|
2918
|
+
conflicts.push({
|
|
2919
|
+
type: "database-type",
|
|
2920
|
+
key: dbType,
|
|
2921
|
+
serverModifiedAt: err.serverModifiedAt,
|
|
2922
|
+
localModifiedAt: validateExpectedModifiedAt || "unknown",
|
|
2923
|
+
});
|
|
2924
|
+
typeGateBlocked = true;
|
|
2925
|
+
}
|
|
2926
|
+
else if (err instanceof TomlParseError) {
|
|
2927
|
+
schemaErrors.tomlParse.push({
|
|
2928
|
+
type: "database-type",
|
|
2929
|
+
key: dbType,
|
|
2930
|
+
line: err.line,
|
|
2931
|
+
column: err.column,
|
|
2932
|
+
message: err.message,
|
|
2933
|
+
});
|
|
2934
|
+
typeGateBlocked = true;
|
|
2935
|
+
}
|
|
2936
|
+
else if (opGateContext) {
|
|
2937
|
+
// The op-edit gate dry-run threw a plain (non-gate) error —
|
|
2938
|
+
// e.g. an invalid op `definition` the server rejects with a
|
|
2939
|
+
// 400. The validate-first loop already knows which op failed,
|
|
2940
|
+
// so name it (issue #684) instead of coarsening to the
|
|
2941
|
+
// db-type level. Mirrors the apply path's per-op wraps.
|
|
2942
|
+
throw wrapEntityError(err, opGateContext.action, "operation", opGateContext.key);
|
|
2943
|
+
}
|
|
2944
|
+
else {
|
|
2945
|
+
throw wrapEntityError(err, "update", "database type", dbType);
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
// If the validate pass found a gate failure, record the change
|
|
2949
|
+
// labels (so dry-run still shows what WOULD change) but skip ALL
|
|
2950
|
+
// mutating calls for this type.
|
|
2951
|
+
if (typeGateBlocked) {
|
|
2952
|
+
const wouldUpdate = "ruleSetId" in typeConfig ||
|
|
2953
|
+
"triggers" in typeConfig ||
|
|
2954
|
+
"metadataAccess" in typeConfig ||
|
|
2955
|
+
"defaultAccess" in typeConfig ||
|
|
2956
|
+
"autoPopulatedFields" in typeConfig ||
|
|
2957
|
+
"timestamps" in typeConfig ||
|
|
2958
|
+
"schema" in typeConfig;
|
|
2959
|
+
if (existingEntry) {
|
|
2960
|
+
if (wouldUpdate) {
|
|
2961
|
+
changes.push({ type: "database-type", action: "update", key: dbType });
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
else {
|
|
2965
|
+
changes.push({ type: "database-type", action: "create", key: dbType });
|
|
2966
|
+
}
|
|
2967
|
+
for (const op of operations) {
|
|
2968
|
+
const existingOp = existingOpsForType[op.name];
|
|
2969
|
+
changes.push({
|
|
2970
|
+
type: "operation",
|
|
2971
|
+
action: existingOp ? "update" : "create",
|
|
2972
|
+
key: `${dbType}/${op.name}`,
|
|
2973
|
+
});
|
|
2974
|
+
}
|
|
2975
|
+
for (const delName of pendingOpDeletes) {
|
|
2976
|
+
changes.push({ type: "operation", action: "delete", key: `${dbType}/${delName}` });
|
|
2977
|
+
}
|
|
2978
|
+
continue;
|
|
2979
|
+
}
|
|
2414
2980
|
if (existingEntry) {
|
|
2415
2981
|
// Update existing type config — only if there are type-level fields to update.
|
|
2416
2982
|
// Operations are handled separately below, so skipping the PATCH here when
|
|
@@ -2421,41 +2987,32 @@ Directory Structure:
|
|
|
2421
2987
|
? undefined
|
|
2422
2988
|
: existingEntry.modifiedAt;
|
|
2423
2989
|
try {
|
|
2424
|
-
const updateData = {};
|
|
2425
|
-
if ("ruleSetId" in typeConfig)
|
|
2426
|
-
updateData.ruleSetId = typeConfig.ruleSetId || null;
|
|
2427
|
-
if ("triggers" in typeConfig)
|
|
2428
|
-
updateData.triggers = typeConfig.triggers || null;
|
|
2429
|
-
if ("metadataAccess" in typeConfig)
|
|
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
2990
|
// Issue #666: forward `schema` when the local TOML declares
|
|
2441
2991
|
// one (a set/update), OR when the server had one at last
|
|
2442
2992
|
// sync and the local file no longer does (a deletion —
|
|
2443
2993
|
// `schema: null` clears it server-side; codex review gap on
|
|
2444
2994
|
// PR #766). When the type never had a schema and still
|
|
2445
2995
|
// doesn't, omit it so an operations-only edit doesn't
|
|
2446
|
-
// register as an empty type-level update (issue #369).
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2996
|
+
// register as an empty type-level update (issue #369). Built
|
|
2997
|
+
// by the shared helper so the validate pass and apply pass
|
|
2998
|
+
// agree (issue #813).
|
|
2999
|
+
const updateData = computeTypeUpdateData();
|
|
3000
|
+
// Issue #813 (2B): forward the ops being deleted in this push
|
|
3001
|
+
// so the schema-edit / OPS_EXIST gates evaluate against the
|
|
3002
|
+
// post-deletion op set. The op deletes themselves still run
|
|
3003
|
+
// in step 3 below; this lets the schema PATCH (step 1) land
|
|
3004
|
+
// in the same push.
|
|
3005
|
+
if (pendingOpDeletes.length > 0) {
|
|
3006
|
+
updateData.pendingOpDeletes = pendingOpDeletes;
|
|
3007
|
+
// Issue #813 hardening: prove the named deletes are gone
|
|
3008
|
+
// from the target state so the server honors the gate
|
|
3009
|
+
// exclusion (see derivation above).
|
|
3010
|
+
updateData.finalOpNames = finalOpNames;
|
|
2454
3011
|
}
|
|
2455
|
-
if (Object.keys(updateData).length > 0) {
|
|
3012
|
+
if (Object.keys(updateData).filter((k) => k !== "pendingOpDeletes" && k !== "finalOpNames").length > 0) {
|
|
2456
3013
|
changes.push({ type: "database-type", action: "update", key: dbType });
|
|
2457
3014
|
const updated = await client.updateDatabaseTypeConfig(resolvedAppId, dbType, updateData, expectedModifiedAt, {
|
|
2458
|
-
dryRun:
|
|
3015
|
+
dryRun: false,
|
|
2459
3016
|
acceptWarnings: !!options.acceptWarnings,
|
|
2460
3017
|
});
|
|
2461
3018
|
info(` Updated database type: ${dbType}`);
|
|
@@ -2573,7 +3130,153 @@ Directory Structure:
|
|
|
2573
3130
|
}
|
|
2574
3131
|
}
|
|
2575
3132
|
catch (err) {
|
|
2576
|
-
|
|
3133
|
+
// Issue #855: a database type can be auto-created at runtime by
|
|
3134
|
+
// `client.databases.create({ databaseType })`, which leaves a
|
|
3135
|
+
// minimal `DatabaseTypeConfig` (no ops/schema) on the server but
|
|
3136
|
+
// no entry in local sync state. The create-vs-update fork above
|
|
3137
|
+
// is driven only by sync state, so push takes the CREATE path
|
|
3138
|
+
// and the server returns 409 "Config for this database type
|
|
3139
|
+
// already exists". Mirror the blob-bucket 409-recovery in this
|
|
3140
|
+
// same loop: adopt the existing config into sync state and
|
|
3141
|
+
// reconcile it via PATCH instead of aborting. Recovery fires
|
|
3142
|
+
// ONLY on the 409 — the happy path takes no extra round-trip.
|
|
3143
|
+
const msg = String(err?.message || err);
|
|
3144
|
+
if (msg.includes("already exists") || err?.statusCode === 409) {
|
|
3145
|
+
// 1. Fetch the existing config to recover its modifiedAt /
|
|
3146
|
+
// schema baseline.
|
|
3147
|
+
const existing = await client.getDatabaseTypeConfig(resolvedAppId, dbType);
|
|
3148
|
+
// Issue #855 (codex [P2]): the recovery is only safe for the
|
|
3149
|
+
// genuine *auto-created minimal* config — the bare shape
|
|
3150
|
+
// `databases.create({ databaseType })` stamps (appId +
|
|
3151
|
+
// databaseType + createdBy, nothing else: no ops, no
|
|
3152
|
+
// triggers, no non-default access/schema; see
|
|
3153
|
+
// src/app-api/controllers/databases-controller.ts:392-400).
|
|
3154
|
+
// For that case the adopt-without-baseline PATCH below
|
|
3155
|
+
// cannot clobber anything, because there is nothing to
|
|
3156
|
+
// clobber.
|
|
3157
|
+
//
|
|
3158
|
+
// But the 409 also fires when the local sync state is merely
|
|
3159
|
+
// ABSENT for a type that another admin has already FULLY
|
|
3160
|
+
// configured (or whose `.primitive-sync.json` was not copied
|
|
3161
|
+
// alongside the TOML). PATCHing that without
|
|
3162
|
+
// `expectedModifiedAt` would silently overwrite the existing
|
|
3163
|
+
// triggers/access/schema with the local TOML — with no
|
|
3164
|
+
// CONFLICT protection, since the baseline that would catch it
|
|
3165
|
+
// is the very thing being skipped. Detect a non-minimal
|
|
3166
|
+
// remote config and refuse to mutate it without a baseline:
|
|
3167
|
+
// surface it as a SKIPPED type (reusing the #813 blocked-type
|
|
3168
|
+
// disclosure + non-zero exit) so the user runs `sync pull`
|
|
3169
|
+
// first. The op sub-loop is skipped via `continue`, so no
|
|
3170
|
+
// operations are created against the un-reconciled type.
|
|
3171
|
+
const existingOpsRemote = await client
|
|
3172
|
+
.listDatabaseTypeOperations(resolvedAppId, dbType)
|
|
3173
|
+
.catch(() => []);
|
|
3174
|
+
const hasNonDefaultField = (v) => {
|
|
3175
|
+
if (v === null || v === undefined)
|
|
3176
|
+
return false;
|
|
3177
|
+
if (typeof v === "string")
|
|
3178
|
+
return v.trim().length > 0;
|
|
3179
|
+
if (typeof v === "object")
|
|
3180
|
+
return Object.keys(v).length > 0;
|
|
3181
|
+
return true;
|
|
3182
|
+
};
|
|
3183
|
+
const isMinimalAutoCreated = (!Array.isArray(existingOpsRemote) ||
|
|
3184
|
+
existingOpsRemote.length === 0) &&
|
|
3185
|
+
!hasNonDefaultField(existing?.ruleSetId) &&
|
|
3186
|
+
!hasNonDefaultField(existing?.triggers) &&
|
|
3187
|
+
!hasNonDefaultField(existing?.metadataAccess) &&
|
|
3188
|
+
!hasNonDefaultField(existing?.defaultAccess) &&
|
|
3189
|
+
!hasNonDefaultField(existing?.autoPopulatedFields) &&
|
|
3190
|
+
!hasNonDefaultField(existing?.timestamps) &&
|
|
3191
|
+
!hasNonDefaultField(existing?.schema);
|
|
3192
|
+
if (!isMinimalAutoCreated) {
|
|
3193
|
+
// Adopt-without-mutating is NOT safe here: the remote type
|
|
3194
|
+
// carries real config. Abort this type with actionable
|
|
3195
|
+
// guidance instead of silently overwriting it. Seed the
|
|
3196
|
+
// baseline FIRST so the very next push (after the user runs
|
|
3197
|
+
// `sync pull` or re-pushes) reconciles against a real
|
|
3198
|
+
// modifiedAt rather than re-tripping this recovery.
|
|
3199
|
+
if (syncState) {
|
|
3200
|
+
if (!syncState.entities.databaseTypes) {
|
|
3201
|
+
syncState.entities.databaseTypes = {};
|
|
3202
|
+
}
|
|
3203
|
+
syncState.entities.databaseTypes[dbType] = {
|
|
3204
|
+
databaseType: dbType,
|
|
3205
|
+
modifiedAt: existing?.modifiedAt || new Date().toISOString(),
|
|
3206
|
+
contentHash: computeFileHash(filePath),
|
|
3207
|
+
hasSchema: typeof existing?.schema === "string" &&
|
|
3208
|
+
existing.schema.trim().length > 0,
|
|
3209
|
+
};
|
|
3210
|
+
}
|
|
3211
|
+
blockedDatabaseTypes.push({
|
|
3212
|
+
databaseType: dbType,
|
|
3213
|
+
reason: `already configured remotely — run \`primitive sync pull\` to establish a local baseline before pushing (refusing to overwrite the existing config without one)`,
|
|
3214
|
+
});
|
|
3215
|
+
// Record the change labels for visibility (mirrors the
|
|
3216
|
+
// validate-first blocked path) without applying them.
|
|
3217
|
+
changes.push({
|
|
3218
|
+
type: "database-type",
|
|
3219
|
+
action: "update",
|
|
3220
|
+
key: dbType,
|
|
3221
|
+
});
|
|
3222
|
+
for (const op of operations) {
|
|
3223
|
+
changes.push({
|
|
3224
|
+
type: "operation",
|
|
3225
|
+
action: "create",
|
|
3226
|
+
key: `${dbType}/${op.name}`,
|
|
3227
|
+
});
|
|
3228
|
+
}
|
|
3229
|
+
continue;
|
|
3230
|
+
}
|
|
3231
|
+
info(` reconciled auto-created database type \`${dbType}\``);
|
|
3232
|
+
// 2. Seed sync state so subsequent pushes take the UPDATE
|
|
3233
|
+
// path and a re-push is idempotent.
|
|
3234
|
+
if (syncState) {
|
|
3235
|
+
if (!syncState.entities.databaseTypes) {
|
|
3236
|
+
syncState.entities.databaseTypes = {};
|
|
3237
|
+
}
|
|
3238
|
+
syncState.entities.databaseTypes[dbType] = {
|
|
3239
|
+
databaseType: dbType,
|
|
3240
|
+
modifiedAt: existing?.modifiedAt || new Date().toISOString(),
|
|
3241
|
+
contentHash: computeFileHash(filePath),
|
|
3242
|
+
hasSchema: typeof existing?.schema === "string" &&
|
|
3243
|
+
existing.schema.trim().length > 0,
|
|
3244
|
+
};
|
|
3245
|
+
}
|
|
3246
|
+
// 3. Reconcile the TOML's type-level fields onto the
|
|
3247
|
+
// (formerly empty) auto-created config. Omit
|
|
3248
|
+
// `expectedModifiedAt` on this first adopt: there is no
|
|
3249
|
+
// local baseline, so passing one would falsely trip the
|
|
3250
|
+
// "modified since last sync" CONFLICT (Q4). Subsequent
|
|
3251
|
+
// pushes carry the baseline just seeded above. Skip the
|
|
3252
|
+
// PATCH entirely when the TOML declares no type-level
|
|
3253
|
+
// fields (an ops-only file) so we don't send an empty
|
|
3254
|
+
// body, which the server rejects with HTTP 400 (#369).
|
|
3255
|
+
const reconcileData = computeTypeUpdateData();
|
|
3256
|
+
if (Object.keys(reconcileData).length > 0) {
|
|
3257
|
+
const reconciled = await client.updateDatabaseTypeConfig(resolvedAppId, dbType, reconcileData, undefined, {
|
|
3258
|
+
dryRun: false,
|
|
3259
|
+
acceptWarnings: !!options.acceptWarnings,
|
|
3260
|
+
});
|
|
3261
|
+
if (syncState?.entities?.databaseTypes?.[dbType] &&
|
|
3262
|
+
reconciled?.modifiedAt) {
|
|
3263
|
+
syncState.entities.databaseTypes[dbType].modifiedAt =
|
|
3264
|
+
reconciled.modifiedAt;
|
|
3265
|
+
syncState.entities.databaseTypes[dbType].hasSchema =
|
|
3266
|
+
"schema" in reconcileData
|
|
3267
|
+
? reconcileData.schema !== null
|
|
3268
|
+
: syncState.entities.databaseTypes[dbType].hasSchema;
|
|
3269
|
+
}
|
|
3270
|
+
}
|
|
3271
|
+
// Operations declared in the TOML flow through the existing
|
|
3272
|
+
// op sub-loop below. `existingOps` is derived from
|
|
3273
|
+
// `existingEntry` (the pre-push sync-state snapshot, which is
|
|
3274
|
+
// empty here), so every op is CREATEd — matching the empty
|
|
3275
|
+
// auto-created type on the server.
|
|
3276
|
+
}
|
|
3277
|
+
else {
|
|
3278
|
+
throw wrapEntityError(err, "create", "database type", dbType);
|
|
3279
|
+
}
|
|
2577
3280
|
}
|
|
2578
3281
|
}
|
|
2579
3282
|
}
|
|
@@ -3029,6 +3732,16 @@ Directory Structure:
|
|
|
3029
3732
|
}
|
|
3030
3733
|
}
|
|
3031
3734
|
divider();
|
|
3735
|
+
// Issue #813: a gate failure surfaced by the validate-first pass (which
|
|
3736
|
+
// also runs in dry-run mode via the server's dry-run gate endpoints).
|
|
3737
|
+
const hasGateFailure = conflicts.length > 0 ||
|
|
3738
|
+
blockedDatabaseTypes.length > 0 ||
|
|
3739
|
+
schemaErrors.schemaRequired.length > 0 ||
|
|
3740
|
+
schemaErrors.opRefs.length > 0 ||
|
|
3741
|
+
schemaErrors.schemaBreaks.length > 0 ||
|
|
3742
|
+
schemaErrors.uncheckableOps.length > 0 ||
|
|
3743
|
+
schemaErrors.tomlParse.length > 0 ||
|
|
3744
|
+
schemaErrors.opsExist.length > 0;
|
|
3032
3745
|
if (options.dryRun) {
|
|
3033
3746
|
info("Dry run - no changes applied.");
|
|
3034
3747
|
console.log("\nChanges that would be made:");
|
|
@@ -3038,101 +3751,18 @@ Directory Structure:
|
|
|
3038
3751
|
: chalk.yellow;
|
|
3039
3752
|
console.log(` ${color(change.action)} ${change.type}: ${change.key}`);
|
|
3040
3753
|
}
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
// Handle conflicts
|
|
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();
|
|
3754
|
+
// Issue #813 (behaviors 1 & 2): dry-run actually runs the server
|
|
3755
|
+
// schema-edit / op-edit gates. If the proposed state would be
|
|
3756
|
+
// rejected, report it and exit non-zero so dry-run no longer reports
|
|
3757
|
+
// a doomed push as clean. The dry-run gate excludes pending op
|
|
3758
|
+
// deletes (2B), so a delete-then-schema push is NOT falsely flagged.
|
|
3759
|
+
if (hasGateFailure) {
|
|
3760
|
+
reportSchemaGateFailures();
|
|
3761
|
+
process.exit(1);
|
|
3135
3762
|
}
|
|
3763
|
+
}
|
|
3764
|
+
else if (hasGateFailure) {
|
|
3765
|
+
reportSchemaGateFailures();
|
|
3136
3766
|
// Update sync state for non-conflicting changes
|
|
3137
3767
|
if (syncState) {
|
|
3138
3768
|
syncState.lastSyncedAt = new Date().toISOString();
|
|
@@ -3145,12 +3775,24 @@ Directory Structure:
|
|
|
3145
3775
|
schemaErrors.uncheckableOps.length +
|
|
3146
3776
|
schemaErrors.tomlParse.length +
|
|
3147
3777
|
schemaErrors.opsExist.length;
|
|
3148
|
-
|
|
3778
|
+
// With validate-first (issue #813) a blocked type's change labels are
|
|
3779
|
+
// recorded for visibility but none of them actually applied, so count
|
|
3780
|
+
// skipped types' labels as not-applied for an honest success count.
|
|
3781
|
+
const blockedTypeNames = new Set(blockedDatabaseTypes.map((b) => b.databaseType));
|
|
3782
|
+
const skippedChangeCount = changes.filter((c) => {
|
|
3783
|
+
if (c.type === "database-type")
|
|
3784
|
+
return blockedTypeNames.has(c.key);
|
|
3785
|
+
if (c.type === "operation") {
|
|
3786
|
+
return blockedTypeNames.has(String(c.key).split("/")[0]);
|
|
3787
|
+
}
|
|
3788
|
+
return false;
|
|
3789
|
+
}).length;
|
|
3790
|
+
const successCount = Math.max(0, changes.length - totalBlocked - skippedChangeCount);
|
|
3149
3791
|
if (successCount > 0) {
|
|
3150
|
-
success(`Pushed ${successCount} change(s). ${
|
|
3792
|
+
success(`Pushed ${successCount} change(s). ${blockedDatabaseTypes.length} database type(s) skipped, ${conflicts.length} conflict(s). Re-run \`sync push\` to converge.`);
|
|
3151
3793
|
}
|
|
3152
3794
|
else {
|
|
3153
|
-
error(`Push failed: ${
|
|
3795
|
+
error(`Push failed: ${blockedDatabaseTypes.length} database type(s) skipped, ${conflicts.length} conflict(s). No schema/op changes applied to the blocked type(s).`);
|
|
3154
3796
|
}
|
|
3155
3797
|
process.exit(1);
|
|
3156
3798
|
}
|