@caelo-cms/provisioning 0.11.1 → 0.11.3

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.
Files changed (2) hide show
  1. package/dist/agent-db.js +458 -62
  2. package/package.json +2 -2
package/dist/agent-db.js CHANGED
@@ -6509,6 +6509,98 @@ function decodeEscapedLineBreaks(value, language) {
6509
6509
  ${"\t".repeat(tabs.length / 2)}`);
6510
6510
  }
6511
6511
 
6512
+ // ../shared/src/lists/default-types.ts
6513
+ var DEFAULT_LIST_TYPES = [
6514
+ {
6515
+ slug: "texts",
6516
+ name: "Texts",
6517
+ purpose: "A flat list of short texts inside one module: feature bullets, chips, steps.",
6518
+ role: "content",
6519
+ root: { allow: ["text"] },
6520
+ nodes: {
6521
+ text: {
6522
+ label: "Text",
6523
+ fields: [{ name: "text", label: "Text", type: "text", required: true }]
6524
+ }
6525
+ }
6526
+ },
6527
+ {
6528
+ slug: "links",
6529
+ name: "Links",
6530
+ purpose: "A flat group of links: legal links, social links, a footer column.",
6531
+ role: "content",
6532
+ root: { allow: ["link"] },
6533
+ nodes: {
6534
+ link: {
6535
+ label: "Link",
6536
+ fields: [
6537
+ { name: "label", label: "Label", type: "text", required: true },
6538
+ { name: "link", label: "Link", type: "link", required: true }
6539
+ ],
6540
+ current_from: "link"
6541
+ }
6542
+ }
6543
+ },
6544
+ {
6545
+ slug: "menu",
6546
+ name: "Menu",
6547
+ purpose: "Site navigation with up to three levels: header menu, footer menu, mobile menu. Use a custom type for mega menus with columns or teasers.",
6548
+ role: "navigation",
6549
+ root: { allow: ["entry"] },
6550
+ nodes: {
6551
+ entry: {
6552
+ label: "Menu entry",
6553
+ fields: [
6554
+ { name: "label", label: "Label", type: "text", required: true },
6555
+ { name: "link", label: "Link", type: "link" }
6556
+ ],
6557
+ children: { name: "children", allow: ["entry"] },
6558
+ current_from: "link"
6559
+ }
6560
+ },
6561
+ max_depth: 3
6562
+ },
6563
+ {
6564
+ slug: "categories",
6565
+ name: "Categories",
6566
+ purpose: "A category tree pages are assigned to: blog sections, product groups, doc areas.",
6567
+ role: "vocabulary",
6568
+ root: { allow: ["category"] },
6569
+ nodes: {
6570
+ category: {
6571
+ label: "Category",
6572
+ fields: [
6573
+ { name: "name", label: "Name", type: "text", required: true },
6574
+ { name: "slug", label: "Slug", type: "text", required: true },
6575
+ { name: "description", label: "Description", type: "richtext" },
6576
+ { name: "landing_page", label: "Landing page", type: "link" }
6577
+ ],
6578
+ children: { name: "children", allow: ["category"] },
6579
+ current_from: "landing_page"
6580
+ }
6581
+ },
6582
+ max_depth: 6
6583
+ },
6584
+ {
6585
+ slug: "tags",
6586
+ name: "Tags",
6587
+ purpose: "Flat keywords pages are tagged with, for filters, tag clouds and related content.",
6588
+ role: "vocabulary",
6589
+ root: { allow: ["tag"] },
6590
+ nodes: {
6591
+ tag: {
6592
+ label: "Tag",
6593
+ fields: [
6594
+ { name: "name", label: "Name", type: "text", required: true },
6595
+ { name: "slug", label: "Slug", type: "text", required: true },
6596
+ { name: "color", label: "Color", type: "text", maxLength: 9 }
6597
+ ]
6598
+ }
6599
+ }
6600
+ }
6601
+ ];
6602
+ var SITE_TEXTS_LIST_TYPE = "site-texts";
6603
+
6512
6604
  // ../shared/src/lists/fields.ts
6513
6605
  var LIST_RESERVED_NAMES = new Set([
6514
6606
  "id",
@@ -6769,11 +6861,14 @@ var moduleFieldModuleListSchema = object({
6769
6861
  min: number2().int().nonnegative().optional(),
6770
6862
  max: number2().int().positive().max(256).optional()
6771
6863
  }).strict();
6864
+ var SHIPPED_LIST_TYPE_SLUGS = [...DEFAULT_LIST_TYPES.map((t) => t.slug), SITE_TEXTS_LIST_TYPE].map((slug) => `"${slug}"`).join(", ");
6772
6865
  var moduleFieldListSchema = object({
6773
6866
  name: moduleFieldName,
6774
6867
  kind: literal("list"),
6775
6868
  label: moduleFieldLabel,
6776
- listType: slugSchema,
6869
+ listType: string2({
6870
+ error: (iss) => iss.input === undefined ? `a list field needs listType: ${SHIPPED_LIST_TYPE_SLUGS} or a custom type's slug (list_list_types shows them)` : undefined
6871
+ }).regex(/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/, "slug must be lowercase letters, digits, or hyphens (1\u201364 chars, no leading/trailing hyphen)"),
6777
6872
  bind: _enum(["local", "shared", "source"]).optional(),
6778
6873
  list: string2().regex(LIST_REF_RE, "list must be caelo:list/<id>").optional(),
6779
6874
  source: sourceDefSchema.optional(),
@@ -6968,8 +7063,9 @@ var pageSetModulesSchema = object({
6968
7063
  expectedVersion: number2().int().nonnegative().optional(),
6969
7064
  blocks: array(object({
6970
7065
  blockName: slugSchema,
6971
- moduleIds: array(string2().uuid())
6972
- }).strict())
7066
+ moduleIds: array(string2().uuid()),
7067
+ contentInstanceIds: array(string2().uuid().nullable()).optional()
7068
+ }).strict().refine((b) => b.contentInstanceIds === undefined || b.contentInstanceIds.length === b.moduleIds.length, { message: "contentInstanceIds needs one entry (or null) per moduleIds entry" }))
6973
7069
  }).strict();
6974
7070
  var syncModeSchema = _enum(["synced", "unsynced"]);
6975
7071
  var contentValuesSchema = record(string2(), unknown());
@@ -7382,6 +7478,8 @@ var movePositionInputSchema = preprocess(stripSurroundingQuotes, union([
7382
7478
  var moveModuleToolInput = object({
7383
7479
  pageId: string2().uuid(),
7384
7480
  moduleId: string2().uuid(),
7481
+ fromBlockName: string2().min(1).max(80).optional(),
7482
+ fromPosition: preprocess((v) => typeof v === "string" && /^\d+$/.test(v) ? Number.parseInt(v, 10) : v, number2().int().min(0).max(1000).optional()),
7385
7483
  toBlockName: string2().min(1).max(80).optional(),
7386
7484
  position: movePositionInputSchema
7387
7485
  }).strict();
@@ -7588,20 +7686,42 @@ var bugIntakeRegisterRequest = object({
7588
7686
  publicKey: string2().regex(/^[A-Za-z0-9+/]{43}=$/, "publicKey must be a base64 Ed25519 key"),
7589
7687
  caeloVersion: string2().min(1).max(64)
7590
7688
  }).strict();
7689
+ var bugReportKind = _enum(["defect", "suggestion"]);
7690
+ var BUG_REPORT_KIND_BEFORE_1192 = "defect";
7691
+ var bugReportSeverity = _enum(["blocking", "degraded", "cosmetic"]);
7591
7692
  var bugIntakeReport = object({
7592
7693
  protocolVersion: literal(BUG_INTAKE_PROTOCOL_VERSION),
7593
7694
  reportId: string2().uuid(),
7594
7695
  caeloVersion: string2().min(1).max(64),
7595
7696
  fingerprint: fingerprintSchema,
7596
7697
  source: _enum(["ai", "auto"]),
7597
- severity: _enum(["blocking", "degraded", "cosmetic"]),
7698
+ kind: bugReportKind.default(BUG_REPORT_KIND_BEFORE_1192),
7699
+ severity: bugReportSeverity.nullable(),
7598
7700
  blockedTask: boolean2(),
7599
7701
  suspectedTool: string2().max(200).nullable(),
7600
7702
  title: string2().min(1).max(BUG_INTAKE_LIMITS.title),
7601
7703
  whatHappened: string2().min(1).max(BUG_INTAKE_LIMITS.whatHappened),
7602
7704
  expected: string2().min(1).max(BUG_INTAKE_LIMITS.expected),
7603
7705
  evidence: string2().max(BUG_INTAKE_LIMITS.evidence).nullable()
7604
- }).strict();
7706
+ }).strict().superRefine((r, ctx) => {
7707
+ if (r.kind === "defect" && r.severity === null) {
7708
+ ctx.addIssue({ code: "custom", path: ["severity"], message: "a defect needs a severity" });
7709
+ }
7710
+ if (r.kind === "suggestion" && r.severity !== null) {
7711
+ ctx.addIssue({
7712
+ code: "custom",
7713
+ path: ["severity"],
7714
+ message: "a suggestion has no severity; send null"
7715
+ });
7716
+ }
7717
+ if (r.kind === "suggestion" && r.source === "auto") {
7718
+ ctx.addIssue({
7719
+ code: "custom",
7720
+ path: ["kind"],
7721
+ message: "an auto-captured report is always a defect"
7722
+ });
7723
+ }
7724
+ });
7605
7725
  var bugClusterStatus = _enum(["open", "fixed", "regression", "wontfix"]);
7606
7726
  var bugIntakeClusterInfo = object({
7607
7727
  clusterId: string2().min(1).max(100),
@@ -7758,7 +7878,7 @@ var buildPageTargetSchema = object({
7758
7878
  if (createKeys.length > 0) {
7759
7879
  ctx.addIssue({
7760
7880
  code: ZodIssueCode.custom,
7761
- message: `pageId targets an EXISTING page \u2014 drop ${createKeys.join(", ")}. ` + "To create a new page instead, omit pageId and pass slug + title."
7881
+ message: `pageId targets an EXISTING page \u2014 drop ${createKeys.join(", ")}. ` + (createKeys.includes("status") ? "`status` only applies to a page this call creates; publish an existing page with set_pages_status_many. " : "") + "To create a new page instead, omit pageId and pass slug + title."
7762
7882
  });
7763
7883
  }
7764
7884
  return;
@@ -8926,6 +9046,16 @@ var PAGE_PROP_RE = new RegExp(`^page/(${SEG2})/props/(${SEG2})$`);
8926
9046
  var LIST_HIDDEN_RE = new RegExp(`^list/(${SEG2})/item/(${SEG2})$`);
8927
9047
  var INSTANCE_HIDDEN_RE = new RegExp(`^instance/(${SEG2})/(${SEG2})/item/(${SEG2})$`);
8928
9048
  var STRUCTURAL_TYPES = new Set(["module", "term"]);
9049
+ // ../shared/src/live-events.ts
9050
+ var LIVE_TOPICS = [
9051
+ "agents",
9052
+ "pending",
9053
+ "credentials",
9054
+ "quality",
9055
+ "crawl",
9056
+ "deploys"
9057
+ ];
9058
+ var TOPIC_SET = new Set(LIVE_TOPICS);
8929
9059
  // ../shared/src/markup-structure.ts
8930
9060
  var LEAF_TAGS = new Set(["svg", "math", "script", "style", "noscript", "iframe"]);
8931
9061
  // ../shared/src/page-log.ts
@@ -18539,6 +18669,40 @@ function isRlsDenial(error) {
18539
18669
  return false;
18540
18670
  }
18541
18671
 
18672
+ // ../query-api/src/operation.ts
18673
+ import { appendFileSync, mkdirSync } from "fs";
18674
+ import { dirname } from "path";
18675
+ function isReadOperation(op) {
18676
+ return "kind" in op && op.kind === "reads";
18677
+ }
18678
+ var OP_COVERAGE_FILE_DEFAULT = "coverage/op-coverage.jsonl";
18679
+ var recordedNames = new Set;
18680
+ var coverageDirEnsured = false;
18681
+ function recordOpInvocation(name) {
18682
+ if (recordedNames.has(name))
18683
+ return;
18684
+ recordedNames.add(name);
18685
+ const file = process.env.CAELO_OP_COVERAGE_FILE ?? OP_COVERAGE_FILE_DEFAULT;
18686
+ if (!coverageDirEnsured) {
18687
+ mkdirSync(dirname(file), { recursive: true });
18688
+ coverageDirEnsured = true;
18689
+ }
18690
+ appendFileSync(file, `${name}
18691
+ `, { flag: "a" });
18692
+ }
18693
+ function defineOperation(def) {
18694
+ if (process.env.CAELO_OP_COVERAGE !== "1")
18695
+ return def;
18696
+ const original = def.handler;
18697
+ return {
18698
+ ...def,
18699
+ handler: (ctx, input, tx) => {
18700
+ recordOpInvocation(def.name);
18701
+ return original(ctx, input, tx);
18702
+ }
18703
+ };
18704
+ }
18705
+
18542
18706
  // ../query-api/src/plugin-schema-provision.ts
18543
18707
  import { createHash } from "crypto";
18544
18708
  var DROPPED_COLUMNS_SETTING = "caelo.plugin_dropped_columns";
@@ -18577,6 +18741,119 @@ async function provisionPluginSchemaInTx(tx, caller, opts) {
18577
18741
  };
18578
18742
  }
18579
18743
 
18744
+ // ../query-api/src/pool-guard.ts
18745
+ import { AsyncLocalStorage } from "async_hooks";
18746
+ var POOL_ACQUIRE_TIMEOUT_MS = 1e4;
18747
+ var ALLOWLIST = [
18748
+ { holder: "deploy.promote", issue: 1225, nestedCheckouts: true, noSessionTimeouts: true },
18749
+ {
18750
+ holder: "quality_audits.publish_anyway",
18751
+ issue: 1225,
18752
+ nestedCheckouts: true,
18753
+ noSessionTimeouts: true
18754
+ },
18755
+ {
18756
+ holder: "quality_audits.execute_proposal",
18757
+ issue: 1225,
18758
+ nestedCheckouts: true,
18759
+ noSessionTimeouts: true
18760
+ },
18761
+ {
18762
+ holder: "deploy.execute_proposal",
18763
+ issue: 1225,
18764
+ nestedCheckouts: true,
18765
+ noSessionTimeouts: true
18766
+ },
18767
+ {
18768
+ holder: "deploy.build_plugin_data",
18769
+ issue: 1220,
18770
+ nestedCheckouts: true,
18771
+ noSessionTimeouts: true
18772
+ },
18773
+ {
18774
+ holder: "plugins.activation_lock",
18775
+ issue: 1220,
18776
+ nestedCheckouts: true,
18777
+ noSessionTimeouts: false
18778
+ },
18779
+ { holder: "static-generator.build", issue: 1220, nestedCheckouts: true, noSessionTimeouts: true },
18780
+ { holder: "plugins.render_hook", issue: 1220, nestedCheckouts: true, noSessionTimeouts: false }
18781
+ ];
18782
+ var NESTED_CHECKOUT_ALLOWLIST = new Map(ALLOWLIST.map((entry) => [entry.holder, entry]));
18783
+
18784
+ class NestedPoolCheckoutError extends Error {
18785
+ pool;
18786
+ holder;
18787
+ requester;
18788
+ name = "NestedPoolCheckoutError";
18789
+ constructor(pool, holder, requester) {
18790
+ super(`${requester} asked the ${pool} database pool for a second connection while ${holder} holds a transaction of that pool in the same call chain. ` + "Enough of these at once deadlock the whole pool (issue #1210), so it is refused. Nothing of this call ran. " + `Fix in the code: pass ${holder}'s transaction to the inner work (op.handler(ctx, input, tx)) or move the inner work out of the transaction (transaction: "none").`);
18791
+ this.pool = pool;
18792
+ this.holder = holder;
18793
+ this.requester = requester;
18794
+ }
18795
+ }
18796
+
18797
+ class PoolAcquireTimeoutError extends Error {
18798
+ pool;
18799
+ requester;
18800
+ timeoutMs;
18801
+ name = "PoolAcquireTimeoutError";
18802
+ constructor(pool, requester, timeoutMs) {
18803
+ super(`the ${pool} database pool had no free connection for ${requester} within ${timeoutMs} ms: every connection is busy. ` + "Nothing was changed. Retry in a few seconds; if it keeps happening the admin is overloaded or a request holds its connections \u2014 the server log names it.");
18804
+ this.pool = pool;
18805
+ this.requester = requester;
18806
+ this.timeoutMs = timeoutMs;
18807
+ }
18808
+ }
18809
+ var allowances = new AsyncLocalStorage;
18810
+ class PoolGuard {
18811
+ #stores = {
18812
+ admin: new AsyncLocalStorage,
18813
+ public: new AsyncLocalStorage
18814
+ };
18815
+ enter(pool, holder) {
18816
+ const allowedBy = this.assertMayCheckout(pool, holder);
18817
+ return { holder, active: true, allowedBy };
18818
+ }
18819
+ assertMayCheckout(pool, requester) {
18820
+ const outer = this.#stores[pool].getStore();
18821
+ if (outer === undefined || !outer.active)
18822
+ return null;
18823
+ const allowedBy = outer.allowedBy ?? (NESTED_CHECKOUT_ALLOWLIST.get(outer.holder)?.nestedCheckouts ? outer.holder : null) ?? allowances.getStore() ?? null;
18824
+ if (allowedBy === null)
18825
+ throw new NestedPoolCheckoutError(pool, outer.holder, requester);
18826
+ return allowedBy;
18827
+ }
18828
+ async run(pool, marker, fn) {
18829
+ try {
18830
+ return await this.#stores[pool].run(marker, fn);
18831
+ } finally {
18832
+ marker.active = false;
18833
+ }
18834
+ }
18835
+ }
18836
+ function exemptFromSessionTimeouts(holder) {
18837
+ return NESTED_CHECKOUT_ALLOWLIST.get(holder)?.noSessionTimeouts === true;
18838
+ }
18839
+ async function liftSessionTimeouts(tx) {
18840
+ await tx.execute(sql`SELECT set_config('statement_timeout', '0', true)`);
18841
+ await tx.execute(sql`SELECT set_config('lock_timeout', '0', true)`);
18842
+ await tx.execute(sql`SELECT set_config('idle_in_transaction_session_timeout', '0', true)`);
18843
+ }
18844
+ function detachedRunner(operation, open) {
18845
+ const transaction = (fn) => open(fn);
18846
+ return new Proxy({}, {
18847
+ get(_target, property) {
18848
+ if (property === "transaction")
18849
+ return transaction;
18850
+ if (property === "then")
18851
+ return;
18852
+ throw new Error(`${operation} is a detached operation (transaction: "none") and holds no transaction; it used tx.${String(property)}. Wrap the step in tx.transaction(async (t) => \u2026) or run it as its own operation.`);
18853
+ }
18854
+ });
18855
+ }
18856
+
18580
18857
  // ../query-api/src/post-commit.ts
18581
18858
  var pendingByTx = new WeakMap;
18582
18859
  function collectPostCommitEffects(tx) {
@@ -18598,6 +18875,16 @@ async function runPostCommitEffects(effects) {
18598
18875
  }
18599
18876
  return failures;
18600
18877
  }
18878
+ var committedListeners = new Set;
18879
+ function notifyOperationCommitted(operationName) {
18880
+ for (const listener of committedListeners) {
18881
+ try {
18882
+ listener(operationName);
18883
+ } catch (thrown) {
18884
+ console.error(`[query-api] committed-operation listener failed for ${operationName}`, thrown);
18885
+ }
18886
+ }
18887
+ }
18601
18888
 
18602
18889
  // ../query-api/src/adapter.ts
18603
18890
  var SQL3 = globalThis.Bun?.SQL;
@@ -18674,8 +18961,12 @@ class DatabaseAdapter {
18674
18961
  #publicRaw;
18675
18962
  #skipVerify;
18676
18963
  #roles;
18964
+ #acquireTimeoutMs;
18965
+ #guard = new PoolGuard;
18677
18966
  #verifyPromise = null;
18678
18967
  #projectionPublisher = null;
18968
+ #onStatement;
18969
+ #drizzleOptions;
18679
18970
  constructor(config) {
18680
18971
  this.#roles = config.expectedRoles ?? DEFAULT_ROLES;
18681
18972
  if (config.adminDatabaseUrl === null !== (this.#roles.admin === null)) {
@@ -18687,9 +18978,47 @@ class DatabaseAdapter {
18687
18978
  const open = (url) => new SQL3(url, poolMax === undefined ? { idleTimeout } : { max: poolMax, idleTimeout });
18688
18979
  this.#adminRaw = config.adminDatabaseUrl === null ? null : open(config.adminDatabaseUrl);
18689
18980
  this.#publicRaw = open(config.publicDatabaseUrl);
18690
- this.#admin = this.#adminRaw === null ? null : drizzle(this.#adminRaw);
18691
- this.#public = drizzle(this.#publicRaw);
18981
+ this.#onStatement = config.onStatement ?? null;
18982
+ const observe = this.#onStatement;
18983
+ this.#drizzleOptions = observe ? { logger: { logQuery: (query) => observe(query) } } : {};
18984
+ this.#admin = this.#adminRaw === null ? null : drizzle(this.#adminRaw, this.#drizzleOptions);
18985
+ this.#public = drizzle(this.#publicRaw, this.#drizzleOptions);
18692
18986
  this.#skipVerify = config.skipRoleVerification === true;
18987
+ this.#acquireTimeoutMs = config.poolAcquireTimeoutMs ?? POOL_ACQUIRE_TIMEOUT_MS;
18988
+ }
18989
+ async#transaction(pool, holder, fn) {
18990
+ const raw = pool === "admin" ? this.#adminRaw : this.#publicRaw;
18991
+ if (raw === null)
18992
+ throw new AdminDatabaseUnavailableError(holder);
18993
+ const marker = this.#guard.enter(pool, holder);
18994
+ const reserved = await this.#reserve(pool, raw, holder);
18995
+ try {
18996
+ const db = drizzle({ client: reserved, ...this.#drizzleOptions });
18997
+ this.#onStatement?.("BEGIN");
18998
+ const out = await db.transaction((tx) => this.#guard.run(pool, marker, async () => {
18999
+ if (exemptFromSessionTimeouts(holder))
19000
+ await liftSessionTimeouts(tx);
19001
+ return fn(tx);
19002
+ }));
19003
+ this.#onStatement?.("COMMIT");
19004
+ return out;
19005
+ } finally {
19006
+ reserved.release();
19007
+ }
19008
+ }
19009
+ async#reserve(pool, raw, requester) {
19010
+ const deadline = new AbortController;
19011
+ const timer = setTimeout(() => deadline.abort(), this.#acquireTimeoutMs);
19012
+ try {
19013
+ return await raw.reserve({ signal: deadline.signal });
19014
+ } catch (e) {
19015
+ if (deadline.signal.aborted) {
19016
+ throw new PoolAcquireTimeoutError(pool, requester, this.#acquireTimeoutMs);
19017
+ }
19018
+ throw e;
19019
+ } finally {
19020
+ clearTimeout(timer);
19021
+ }
18693
19022
  }
18694
19023
  get hasAdminDatabase() {
18695
19024
  return this.#admin !== null;
@@ -18777,15 +19106,22 @@ class DatabaseAdapter {
18777
19106
  return row;
18778
19107
  }
18779
19108
  async runOperation(op, ctx, validatedInput) {
19109
+ const pool = op.database === "cms_admin" ? "admin" : "public";
19110
+ this.#guard.assertMayCheckout(pool, op.name);
19111
+ if (op.database === "cms_admin")
19112
+ this.#adminDb(`operation ${op.name}`);
18780
19113
  if (!this.#skipVerify)
18781
19114
  await this.verifyRoles();
18782
- const db = op.database === "cms_admin" ? this.#adminDb(`operation ${op.name}`) : this.#public;
19115
+ if (isReadOperation(op))
19116
+ return this.#runReadOperation(op, ctx, validatedInput);
19117
+ if (op.transaction === "none")
19118
+ return this.#runDetached(op, ctx, validatedInput);
18783
19119
  let publishAfterCommit = false;
18784
19120
  let postCommit = [];
18785
19121
  let result;
18786
19122
  let handlerError = null;
18787
19123
  try {
18788
- result = await db.transaction(async (tx) => {
19124
+ result = await this.#transaction(pool, op.name, async (tx) => {
18789
19125
  postCommit = collectPostCommitEffects(tx);
18790
19126
  await this.#setIdentity(tx, ctx);
18791
19127
  if (op.database === "cms_admin") {
@@ -18802,6 +19138,8 @@ class DatabaseAdapter {
18802
19138
  return out;
18803
19139
  });
18804
19140
  } catch (thrown) {
19141
+ if (thrown instanceof NestedPoolCheckoutError)
19142
+ throw thrown;
18805
19143
  if (thrown instanceof OperationAbortError) {
18806
19144
  return err(thrown.queryError);
18807
19145
  }
@@ -18832,6 +19170,8 @@ class DatabaseAdapter {
18832
19170
  publishFailure = `The change is saved, but the public site does not serve it yet: publishing it failed (${thrown instanceof Error ? thrown.message : String(thrown)}). Do not repeat the change; it is published automatically within seconds once the public database answers.`;
18833
19171
  }
18834
19172
  }
19173
+ if (result.ok)
19174
+ notifyOperationCommitted(op.name);
18835
19175
  if (effectFailures.length > 0) {
18836
19176
  const effects = postCommitFailureMessage(effectFailures);
18837
19177
  return err({
@@ -18845,12 +19185,80 @@ class DatabaseAdapter {
18845
19185
  }
18846
19186
  return result;
18847
19187
  }
19188
+ async#runDetached(op, ctx, validatedInput) {
19189
+ try {
19190
+ const branches = op.database === "cms_admin" ? namedBranches(ctx, validatedInput) : [];
19191
+ if (branches.length > 0) {
19192
+ const denied = await this.#transaction("admin", op.name, async (tx) => {
19193
+ await this.#setIdentity(tx, ctx);
19194
+ return firstInvisibleBranch(tx, branches);
19195
+ });
19196
+ if (denied !== null)
19197
+ return err(branchNotFound(op.name, denied));
19198
+ }
19199
+ const options = { holder: op.name };
19200
+ const runner = detachedRunner(op.name, (fn) => op.database === "cms_admin" ? this.withAdminTransaction(ctx, fn, options) : this.withPublicTransaction(ctx, fn, options));
19201
+ return await op.handler(ctx, validatedInput, runner);
19202
+ } catch (thrown) {
19203
+ if (thrown instanceof NestedPoolCheckoutError)
19204
+ throw thrown;
19205
+ if (thrown instanceof OperationAbortError)
19206
+ return err(thrown.queryError);
19207
+ const pgDetail = extractPgFields(thrown);
19208
+ return err({
19209
+ kind: "HandlerError",
19210
+ operation: op.name,
19211
+ message: thrown instanceof Error ? thrown.message : String(thrown),
19212
+ ...pgDetail ? { pgDetail } : {}
19213
+ });
19214
+ }
19215
+ }
18848
19216
  async#setIdentity(tx, ctx) {
18849
- await tx.execute(sql`SELECT set_config('caelo.actor_id', ${ctx.actorId}, true)`);
18850
- await tx.execute(sql`SELECT set_config('caelo.actor_kind', ${ctx.actorKind}, true)`);
18851
- await tx.execute(sql`SELECT set_config('caelo.plugin_id', ${ctx.pluginId ?? ""}, true)`);
18852
- await tx.execute(sql`SELECT set_config('caelo.chat_branch_id', ${ctx.chatBranchId ?? ""}, true)`);
18853
- await tx.execute(sql`SELECT set_config('caelo.chat_task_id', ${ctx.chatTaskId ?? ""}, true)`);
19217
+ await tx.execute(sql`SELECT
19218
+ set_config('caelo.actor_id', ${ctx.actorId}, true),
19219
+ set_config('caelo.actor_kind', ${ctx.actorKind}, true),
19220
+ set_config('caelo.plugin_id', ${ctx.pluginId ?? ""}, true),
19221
+ set_config('caelo.chat_branch_id', ${ctx.chatBranchId ?? ""}, true),
19222
+ set_config('caelo.chat_task_id', ${ctx.chatTaskId ?? ""}, true)`);
19223
+ }
19224
+ async#runReadOperation(op, ctx, validatedInput) {
19225
+ try {
19226
+ const branches = namedBranches(ctx, validatedInput);
19227
+ if (branches.length > 0) {
19228
+ const denied = await this.#shortRead(ctx, op.name, (tx) => firstInvisibleBranch(tx, branches));
19229
+ if (denied !== null)
19230
+ return err(branchNotFound(op.name, denied));
19231
+ }
19232
+ return await op.handler(ctx, validatedInput, this.readScope(ctx, op.name));
19233
+ } catch (thrown) {
19234
+ if (thrown instanceof NestedPoolCheckoutError)
19235
+ throw thrown;
19236
+ if (thrown instanceof OperationAbortError)
19237
+ return err(thrown.queryError);
19238
+ if (isRlsDenial(thrown)) {
19239
+ return err({
19240
+ kind: "RLSDenied",
19241
+ operation: op.name,
19242
+ detail: thrown.message ?? "RLS policy denied the read"
19243
+ });
19244
+ }
19245
+ const pgDetail = extractPgFields(thrown);
19246
+ return err({
19247
+ kind: "HandlerError",
19248
+ operation: op.name,
19249
+ message: thrown instanceof Error ? thrown.message : String(thrown),
19250
+ ...pgDetail ? { pgDetail } : {}
19251
+ });
19252
+ }
19253
+ }
19254
+ readScope(ctx, holder = "reads.read") {
19255
+ return { read: (fn) => this.#shortRead(ctx, holder, fn) };
19256
+ }
19257
+ #shortRead(ctx, holder, fn) {
19258
+ return this.#transaction("admin", holder, async (tx) => {
19259
+ await this.#setIdentity(tx, ctx);
19260
+ return fn(tx);
19261
+ });
18854
19262
  }
18855
19263
  async#settlePostCommit(operation, ctx, effects) {
18856
19264
  if (effects.length === 0)
@@ -18866,7 +19274,7 @@ class DatabaseAdapter {
18866
19274
  if (this.#admin === null)
18867
19275
  continue;
18868
19276
  try {
18869
- await this.#admin.transaction(async (tx) => {
19277
+ await this.#transaction("admin", `${operation}.post_commit`, async (tx) => {
18870
19278
  await this.#setIdentity(tx, ctx);
18871
19279
  await recordAudit(tx, {
18872
19280
  actorId: ctx.actorId,
@@ -18891,13 +19299,15 @@ class DatabaseAdapter {
18891
19299
  async close() {
18892
19300
  await Promise.all([this.#adminRaw?.end(), this.#publicRaw.end()]);
18893
19301
  }
18894
- async withAdminTransaction(ctx, fn) {
18895
- const admin = this.#adminDb("withAdminTransaction");
19302
+ async withAdminTransaction(ctx, fn, options = {}) {
19303
+ const holder = options.holder ?? "withAdminTransaction";
19304
+ this.#guard.assertMayCheckout("admin", holder);
19305
+ this.#adminDb(holder);
18896
19306
  if (!this.#skipVerify)
18897
19307
  await this.verifyRoles();
18898
19308
  let publishAfterCommit = false;
18899
19309
  let postCommit = [];
18900
- const out = await admin.transaction(async (tx) => {
19310
+ const out = await this.#transaction("admin", holder, async (tx) => {
18901
19311
  postCommit = collectPostCommitEffects(tx);
18902
19312
  await this.#setIdentity(tx, ctx);
18903
19313
  const denied = await firstInvisibleBranch(tx, namedBranches(ctx, undefined));
@@ -18908,25 +19318,27 @@ class DatabaseAdapter {
18908
19318
  publishAfterCommit = await this.#projectionDirty(tx);
18909
19319
  return result;
18910
19320
  });
18911
- const effectFailures = await this.#settlePostCommit("withAdminTransaction", ctx, postCommit);
19321
+ const effectFailures = await this.#settlePostCommit(holder, ctx, postCommit);
18912
19322
  if (publishAfterCommit)
18913
19323
  await this.#publishAfterCommit();
18914
19324
  if (effectFailures.length > 0)
18915
19325
  throw new Error(postCommitFailureMessage(effectFailures));
18916
19326
  return out;
18917
19327
  }
18918
- async withPublicTransaction(ctx, fn) {
19328
+ async withPublicTransaction(ctx, fn, options = {}) {
19329
+ const holder = options.holder ?? "withPublicTransaction";
19330
+ this.#guard.assertMayCheckout("public", holder);
18919
19331
  if (!this.#skipVerify)
18920
19332
  await this.verifyRoles();
18921
19333
  let postCommit = [];
18922
- const out = await this.#public.transaction(async (tx) => {
19334
+ const out = await this.#transaction("public", holder, async (tx) => {
18923
19335
  postCommit = collectPostCommitEffects(tx);
18924
19336
  await tx.execute(sql`SELECT set_config('caelo.actor_id', ${ctx.actorId}, true)`);
18925
19337
  await tx.execute(sql`SELECT set_config('caelo.actor_kind', ${ctx.actorKind}, true)`);
18926
19338
  await tx.execute(sql`SELECT set_config('caelo.plugin_id', ${ctx.pluginId ?? ""}, true)`);
18927
19339
  return await fn(tx);
18928
19340
  });
18929
- const effectFailures = await this.#settlePostCommit("withPublicTransaction", ctx, postCommit);
19341
+ const effectFailures = await this.#settlePostCommit(holder, ctx, postCommit);
18930
19342
  if (effectFailures.length > 0)
18931
19343
  throw new Error(postCommitFailureMessage(effectFailures));
18932
19344
  return out;
@@ -18934,9 +19346,11 @@ class DatabaseAdapter {
18934
19346
  rawAdmin() {
18935
19347
  if (this.#adminRaw === null)
18936
19348
  throw new AdminDatabaseUnavailableError("rawAdmin");
19349
+ this.#guard.assertMayCheckout("admin", "rawAdmin()");
18937
19350
  return this.#adminRaw;
18938
19351
  }
18939
19352
  rawPublic() {
19353
+ this.#guard.assertMayCheckout("public", "rawPublic()");
18940
19354
  return this.#publicRaw;
18941
19355
  }
18942
19356
  async provisionPluginPublicSchema(opts) {
@@ -18945,12 +19359,11 @@ class DatabaseAdapter {
18945
19359
  }
18946
19360
  if (!this.#skipVerify)
18947
19361
  await this.verifyRoles();
18948
- const outcome = await this.#public.transaction(async (tx) => {
19362
+ const outcome = await this.#transaction("public", "provisionPluginPublicSchema", async (tx) => {
18949
19363
  return provisionPluginSchemaInTx(tx, "provisionPluginPublicSchema", opts);
18950
19364
  });
18951
19365
  if (outcome.droppedColumns.length > 0) {
18952
- const admin = this.#adminDb("provisionPluginPublicSchema");
18953
- await admin.transaction(async (tx) => {
19366
+ await this.#transaction("admin", "provisionPluginPublicSchema", async (tx) => {
18954
19367
  await tx.execute(sql.raw("SELECT set_config('caelo.actor_kind', 'system', true)"));
18955
19368
  await auditDroppedColumns(tx, "cms_public", opts.pluginId, outcome);
18956
19369
  });
@@ -18960,10 +19373,10 @@ class DatabaseAdapter {
18960
19373
  if (!/^[0-9a-f-]{36}$/i.test(opts.pluginId)) {
18961
19374
  throw new Error(`provisionPluginAdminSchema: pluginId "${opts.pluginId}" is not a UUID`);
18962
19375
  }
18963
- const admin = this.#adminDb("provisionPluginAdminSchema");
19376
+ this.#adminDb("provisionPluginAdminSchema");
18964
19377
  if (!this.#skipVerify)
18965
19378
  await this.verifyRoles();
18966
- await admin.transaction(async (tx) => {
19379
+ await this.#transaction("admin", "provisionPluginAdminSchema", async (tx) => {
18967
19380
  const outcome = await provisionPluginSchemaInTx(tx, "provisionPluginAdminSchema", opts);
18968
19381
  if (outcome.droppedColumns.length > 0) {
18969
19382
  await auditDroppedColumns(tx, "cms_admin", opts.pluginId, outcome);
@@ -18971,13 +19384,13 @@ class DatabaseAdapter {
18971
19384
  });
18972
19385
  }
18973
19386
  async dropPluginAdminSchema(opts) {
18974
- const admin = this.#adminDb("dropPluginAdminSchema");
19387
+ this.#adminDb("dropPluginAdminSchema");
18975
19388
  if (!this.#skipVerify)
18976
19389
  await this.verifyRoles();
18977
19390
  if (!/^plugin_[a-z][a-z0-9_]*$/.test(opts.schemaName)) {
18978
19391
  throw new Error(`dropPluginAdminSchema: refusing to drop schema "${opts.schemaName}" \u2014 name doesn't match plugin_<slug> pattern`);
18979
19392
  }
18980
- await admin.transaction(async (tx) => {
19393
+ await this.#transaction("admin", "dropPluginAdminSchema", async (tx) => {
18981
19394
  await tx.execute(sql.raw("SELECT set_config('caelo.actor_kind', 'system', true)"));
18982
19395
  await tx.execute(sql.raw(`DROP SCHEMA IF EXISTS "${opts.schemaName}" CASCADE`));
18983
19396
  await tx.execute(sql`DELETE FROM plugin_schema_provisions WHERE schema_name = ${opts.schemaName}`);
@@ -18989,16 +19402,19 @@ class DatabaseAdapter {
18989
19402
  if (!/^plugin_[a-z][a-z0-9_]*$/.test(opts.schemaName)) {
18990
19403
  throw new Error(`dropPluginPublicSchema: refusing to drop schema "${opts.schemaName}" \u2014 name doesn't match plugin_<slug> pattern`);
18991
19404
  }
18992
- await this.#public.transaction(async (tx) => {
19405
+ await this.#transaction("public", "dropPluginPublicSchema", async (tx) => {
18993
19406
  await tx.execute(sql.raw("SELECT set_config('caelo.actor_kind', 'system', true)"));
18994
19407
  await tx.execute(sql.raw(`DROP SCHEMA IF EXISTS "${opts.schemaName}" CASCADE`));
18995
19408
  await tx.execute(sql`DELETE FROM plugin_schema_provisions WHERE schema_name = ${opts.schemaName}`);
18996
19409
  });
18997
19410
  }
18998
19411
  get admin() {
18999
- return this.#adminDb("admin pool");
19412
+ const db = this.#adminDb("admin pool");
19413
+ this.#guard.assertMayCheckout("admin", "adapter.admin");
19414
+ return db;
19000
19415
  }
19001
19416
  get public() {
19417
+ this.#guard.assertMayCheckout("public", "adapter.public");
19002
19418
  return this.#public;
19003
19419
  }
19004
19420
  }
@@ -19041,37 +19457,17 @@ async function execute(registry, adapter, ctx, name, rawInput, options = {}) {
19041
19457
  if (!parsed.success) {
19042
19458
  return err({ kind: "ValidationFailed", issues: parsed.error.issues });
19043
19459
  }
19044
- return adapter.runOperation(op, ctx, parsed.data);
19045
- }
19046
- // ../query-api/src/operation.ts
19047
- import { appendFileSync, mkdirSync } from "fs";
19048
- import { dirname } from "path";
19049
- var OP_COVERAGE_FILE_DEFAULT = "coverage/op-coverage.jsonl";
19050
- var recordedNames = new Set;
19051
- var coverageDirEnsured = false;
19052
- function recordOpInvocation(name) {
19053
- if (recordedNames.has(name))
19054
- return;
19055
- recordedNames.add(name);
19056
- const file = process.env.CAELO_OP_COVERAGE_FILE ?? OP_COVERAGE_FILE_DEFAULT;
19057
- if (!coverageDirEnsured) {
19058
- mkdirSync(dirname(file), { recursive: true });
19059
- coverageDirEnsured = true;
19460
+ if (!isReadOperation(op) && op.stepwise) {
19461
+ const result = await op.stepwise(ctx, parsed.data, {
19462
+ registry,
19463
+ adapter,
19464
+ ...options.signal ? { signal: options.signal } : {}
19465
+ });
19466
+ if (result.ok)
19467
+ notifyOperationCommitted(op.name);
19468
+ return result;
19060
19469
  }
19061
- appendFileSync(file, `${name}
19062
- `, { flag: "a" });
19063
- }
19064
- function defineOperation(def) {
19065
- if (process.env.CAELO_OP_COVERAGE !== "1")
19066
- return def;
19067
- const original = def.handler;
19068
- return {
19069
- ...def,
19070
- handler: (ctx, input, tx) => {
19071
- recordOpInvocation(def.name);
19072
- return original(ctx, input, tx);
19073
- }
19074
- };
19470
+ return adapter.runOperation(op, ctx, parsed.data);
19075
19471
  }
19076
19472
  // ../query-api/src/registry.ts
19077
19473
  class OperationRegistry {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caelo-cms/provisioning",
3
- "version": "0.11.1",
3
+ "version": "0.11.3",
4
4
  "private": false,
5
5
  "license": "MPL-2.0",
6
6
  "description": "Pulumi-driven provisioning for Caelo CMS — `bunx @caelo-cms/provisioning --provider <self-hosted|gcp|aws|azure>` brings up a TLS-served install with Postgres + storage + secrets manager + the admin + the API gateway.",
@@ -40,7 +40,7 @@
40
40
  "prepublishOnly": "tsc -b --force && bun build ../platform-updater/src/agent-db.ts --target=bun --outfile=dist/agent-db.js && tsc --project stacks/gcp/tsconfig.json && tsc --project stacks/gcp-firebase/tsconfig.json"
41
41
  },
42
42
  "dependencies": {
43
- "@caelo-cms/shared": "^0.11.1",
43
+ "@caelo-cms/shared": "^0.11.3",
44
44
  "@clack/prompts": "1.8.1",
45
45
  "@pulumi/command": "^1",
46
46
  "@pulumi/gcp": "^10",