@substrat-run/control-plane-api 0.79.0 → 0.80.0

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/api.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
package/dist/api.js CHANGED
@@ -10,7 +10,7 @@ import { mapError } from './errors.js';
10
10
  import { maskDump, maskRecords } from './mask.js';
11
11
  import { openDump, sealDump } from './seal.js';
12
12
  import { assertSandboxContract, deployManifest, storedDeployManifest, deploymentRefFor, stableDeploymentRefFor, nextMigrationTag, upstreamStatusOf, } from './deploy.js';
13
- import { blobStoreBindings, collectBlobStoreHandles, collectTenantStoreHandles, tenantStoreBindings, } from './tenant-stores.js';
13
+ import { backfillDeclaredStores, blobStoreBindings, collectBlobStoreHandles, collectTenantStoreHandles, missingStoresForTenant, tenantStoreBindings, } from './tenant-stores.js';
14
14
  import { mintPushToken, pushActorFor } from './push-token.js';
15
15
  import { namespacesForScript } from './do-namespaces.js';
16
16
  import { backupDirectoryIfDue } from './directory-backup.js';
@@ -1128,6 +1128,15 @@ export function createControlPlaneApi(options) {
1128
1128
  // a new scope-DO route. `roleProjectionEmpty` on an active scope is the flag a console
1129
1129
  // fleet view raises; a scope whose roles live off-DO (adapter-sqlite's directory) reports
1130
1130
  // a null count and is not flagged.
1131
+ //
1132
+ // `missingStores` (#825) is the second silent condition, and the same shape of failure:
1133
+ // per-tenant stores are minted in the TENANT-creation lifecycle, so a tenant that predates
1134
+ // a newly declared `runtimeNeeds` store never gets one — the vertical's declaration and the
1135
+ // tenant's ledger simply disagree, forever, and the only signal was a runtime throw at
1136
+ // first use (an attachment upload refusing in production, long after the deploy that
1137
+ // introduced the need). Comparing DECLARED against MINTED here makes a scope that cannot
1138
+ // serve its own declared surface report unhealthy at the moment the need ships, and
1139
+ // re-provisioning the scope is the repair (that path now mints what this reports).
1131
1140
  app.get('/tenants/:tenantId/scopes/:scopeId/health', async (c) => {
1132
1141
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1133
1142
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
@@ -1146,12 +1155,21 @@ export function createControlPlaneApi(options) {
1146
1155
  const roles = tables.find((t) => t.name === '_substrat_roles');
1147
1156
  const roleCount = roles ? roles.rowCount : null;
1148
1157
  const roleProjectionEmpty = scope.status === 'active' && roleCount === 0;
1158
+ const missingStores = scope.vertical
1159
+ ? await missingStoresForTenant({
1160
+ host: options.host,
1161
+ actor: c.get('actor'),
1162
+ slug: scope.vertical,
1163
+ tenantId,
1164
+ })
1165
+ : [];
1149
1166
  return c.json({
1150
1167
  scopeId,
1151
1168
  status: scope.status,
1152
1169
  servingRef: scope.servingRef ?? null,
1153
1170
  roleCount,
1154
1171
  roleProjectionEmpty,
1172
+ missingStores,
1155
1173
  });
1156
1174
  });
1157
1175
  /**
@@ -1230,6 +1248,33 @@ export function createControlPlaneApi(options) {
1230
1248
  const connectionKeys = scope.vertical
1231
1249
  ? await admin.connectionSealingKeys(tenantId, scope.vertical)
1232
1250
  : [];
1251
+ // #825: the BACKFILL seam for per-tenant stores. Minting lived only in the
1252
+ // tenant-creation lifecycle, so a tenant created before its vertical declared a
1253
+ // `runtimeNeeds` store never got one and nothing ever gave it one — the vertical then
1254
+ // fails at first use, in production, with nothing an operator can reach. This is the
1255
+ // lever they already reach for (`substrat scope provision`, the idempotent repair), so
1256
+ // it mints and attaches what the serving version declares before reconciling. Safe to
1257
+ // re-run by construction: both collectors are idempotent on (tenant, vertical, binding)
1258
+ // and the binding attach is additive, so a scope whose stores already exist re-resolves
1259
+ // the same refs and changes nothing.
1260
+ const tenantStores = scope.vertical
1261
+ ? await collectTenantStoreHandles({
1262
+ host: options.host,
1263
+ actor,
1264
+ slug: scope.vertical,
1265
+ tenantId,
1266
+ patchBindings: options.patchScriptBindings,
1267
+ })
1268
+ : [];
1269
+ if (scope.vertical) {
1270
+ await collectBlobStoreHandles({
1271
+ host: options.host,
1272
+ actor,
1273
+ slug: scope.vertical,
1274
+ tenantId,
1275
+ patchBindings: options.patchScriptBindings,
1276
+ });
1277
+ }
1233
1278
  try {
1234
1279
  const result = await vertical.reconcileInstance({
1235
1280
  tenantId,
@@ -1238,6 +1283,10 @@ export function createControlPlaneApi(options) {
1238
1283
  identityLinks,
1239
1284
  connectionGrants,
1240
1285
  connectionKeys,
1286
+ // Handed over exactly as at provision: a store minted HERE has never been migrated
1287
+ // by the vertical, so the reconcile must carry it into the same ready-gate — a bound
1288
+ // but unmigrated database fails as loudly as an absent one.
1289
+ ...(tenantStores.length ? { tenantStores } : {}),
1241
1290
  });
1242
1291
  return c.json(result);
1243
1292
  }
@@ -2530,6 +2579,56 @@ export function createControlPlaneApi(options) {
2530
2579
  : 'v1',
2531
2580
  });
2532
2581
  };
2582
+ /**
2583
+ * Reconcile the INSTALLED FLEET's per-tenant stores to what the version now being served
2584
+ * declares (#825). Runs on promote, after the in-place serve, because that is the act
2585
+ * that makes a declaration real: until the serve, `verticalServing` still names the old
2586
+ * version and the declaration being backfilled would be the previous one.
2587
+ *
2588
+ * Without this, minting existed only in the tenant-creation lifecycle — a gate every
2589
+ * installed tenant passed long before the declaration was written — so declaring a store
2590
+ * in version N+1 gave it to nobody, and adopting it meant an operator remembering to
2591
+ * re-provision each install by hand. They forget, and the vertical then fails at first
2592
+ * use, in production, arbitrarily long after the deploy that introduced the need.
2593
+ *
2594
+ * Best-effort by construction, and deliberately NOT part of the serve's success: a
2595
+ * minting failure (the platform's Cloudflare credential, a store API refusing) must not
2596
+ * make a promote report failure when the new code is already live and serving. It lands
2597
+ * an ops-failure row, rides back in the promote response so the builder sees it at the
2598
+ * terminal, and the scope's own `/health` keeps reporting the gap until it closes —
2599
+ * `substrat scope provision` remains the per-scope retry.
2600
+ */
2601
+ const backfillFleetStores = async (c, slug) => {
2602
+ const actor = c.get('actor');
2603
+ try {
2604
+ // Every tenant with an install that can still serve. Archived/reaped scopes are
2605
+ // excluded — minting a bucket for storage that is gone is pure waste.
2606
+ const scopes = await admin.listScopes(actor, {
2607
+ vertical: slug,
2608
+ status: ['provisioning', 'active', 'suspended'],
2609
+ });
2610
+ const tenantIds = [...new Set(scopes.map((s) => s.tenantId))];
2611
+ if (tenantIds.length === 0)
2612
+ return { minted: [] };
2613
+ const minted = await backfillDeclaredStores({
2614
+ host: options.host,
2615
+ actor,
2616
+ slug,
2617
+ tenantIds,
2618
+ patchBindings: options.patchScriptBindings,
2619
+ });
2620
+ if (minted.length) {
2621
+ console.log('stores.backfilled', { slug, count: minted.length });
2622
+ }
2623
+ return { minted };
2624
+ }
2625
+ catch (e) {
2626
+ const message = e instanceof Error ? e.message : String(e);
2627
+ console.error('stores.backfill.failed', { slug, detail: message });
2628
+ recordFailure({ actor, operation: 'promote.store-backfill', vertical: slug, message });
2629
+ return { minted: [], error: message };
2630
+ }
2631
+ };
2533
2632
  app.post('/verticals/:slug/channels/:channel/promote', async (c) => {
2534
2633
  const p = c.get('principal');
2535
2634
  const slug = await resolveVerticalId(c, c.req.param('slug'));
@@ -2566,6 +2665,7 @@ export function createControlPlaneApi(options) {
2566
2665
  // uploading first would deploy to live scopes before the acknowledgement check.
2567
2666
  // A failed serve is NOT a failed promote: the channel moved (audited), old code
2568
2667
  // still serves, and promoting again retries the upload.
2668
+ let backfill = { minted: [] };
2569
2669
  if (channel === 'prod') {
2570
2670
  try {
2571
2671
  await serveVersionInPlace(c.get('actor'), slug, versionId);
@@ -2575,6 +2675,9 @@ export function createControlPlaneApi(options) {
2575
2675
  // so a legacy scope's data survives the promote instead of being stranded on a
2576
2676
  // fresh per-version script. Retry-safe: nothing rebound these scopes yet.
2577
2677
  await adoptAndRebindOwnedScopes(c, slug, versionId);
2678
+ // #825: and mint whatever THIS version newly declares for the tenants already
2679
+ // installed — after the serve, so the declaration being read is the one now live.
2680
+ backfill = await backfillFleetStores(c, slug);
2578
2681
  }
2579
2682
  catch (e) {
2580
2683
  const detail = e instanceof Error ? e.message : String(e);
@@ -2585,7 +2688,33 @@ export function createControlPlaneApi(options) {
2585
2688
  }, 502);
2586
2689
  }
2587
2690
  }
2588
- return c.json((await admin.listChannels(c.get('actor'), slug)).find((ch) => ch.channel === channel));
2691
+ const promoted = (await admin.listChannels(c.get('actor'), slug)).find((ch) => ch.channel === channel);
2692
+ // The store backfill rides the response only when it has something to say (#825), so
2693
+ // nothing changes for the overwhelming majority of promotes — and when a store WAS
2694
+ // minted, or could not be, the builder reads it at the terminal rather than finding out
2695
+ // from a runtime throw weeks later.
2696
+ //
2697
+ // Named tenants are narrowed to what the caller may see: the sweep is fleet-wide (it has
2698
+ // to be — every install needs the store), but a BUILDER reads only its own tenant's
2699
+ // directory rows everywhere else (K-3, the forced filter on `GET /scopes`), and a promote
2700
+ // report must not be the one place that hands them the tenant ids of everyone who
2701
+ // installed their vertical. The rest is a count, which tells them the fleet was covered
2702
+ // without naming who is in it.
2703
+ const p2 = c.get('principal');
2704
+ const visible = p2.kind === 'builder' ? backfill.minted.filter((m) => m.tenantId === p2.tenantId) : backfill.minted;
2705
+ const otherTenants = new Set(backfill.minted.filter((m) => !visible.includes(m)).map((m) => m.tenantId)).size;
2706
+ return c.json({
2707
+ ...promoted,
2708
+ ...(backfill.minted.length || backfill.error
2709
+ ? {
2710
+ storeBackfill: {
2711
+ minted: visible,
2712
+ ...(otherTenants ? { otherTenants } : {}),
2713
+ ...(backfill.error ? { error: backfill.error } : {}),
2714
+ },
2715
+ }
2716
+ : {}),
2717
+ });
2589
2718
  });
2590
2719
  // The deploy seam (self-serve-deploy.md): a `substrat push` uploads a built bundle
2591
2720
  // here. The order is upload → record, deliberately: a failed record leaves an