primitive-admin 1.0.46 → 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.
@@ -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
- 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;
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: !!options.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
- throw wrapEntityError(err, "create", "database type", dbType);
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
- 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) {
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
- const successCount = changes.length - totalBlocked;
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). ${totalBlocked} blocked.`);
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: ${totalBlocked} blocked. No changes applied.`);
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
  }