@aotter/mantle 0.2.0-alpha.9 → 0.2.1-alpha.1

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 (180) hide show
  1. package/README.md +15 -5
  2. package/dist/auth/adapter.js +1 -28
  3. package/dist/auth/admin.js +2 -5
  4. package/dist/auth/caller.js +0 -27
  5. package/dist/auth/consent.js +100 -0
  6. package/dist/auth/hardening.js +0 -7
  7. package/dist/auth/identity.js +0 -11
  8. package/dist/auth/oauth.js +0 -32
  9. package/dist/auth/resource-seeds.js +0 -2
  10. package/dist/auth/route-http.js +33 -0
  11. package/dist/auth/routes.js +5 -151
  12. package/dist/build/compiler.d.ts +19 -609
  13. package/dist/build/compiler.js +6 -6896
  14. package/dist/build/spec/domain/model/ManifestGrammar.d.ts +39 -1
  15. package/dist/build/spec/domain/model/RuntimePlan.d.ts +16 -3
  16. package/dist/build/spec/domain/model/SqlIr.d.ts +32 -0
  17. package/dist/build/spec/domain/model/UserFields.d.ts +1 -0
  18. package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
  19. package/dist/bun/assets.js +0 -2
  20. package/dist/domain/adminOrder.js +0 -2
  21. package/dist/domain/columns.js +7 -20
  22. package/dist/domain/describe/chunk-P6PP6VVV.js +1 -0
  23. package/dist/domain/describe/index.d.ts +1 -0
  24. package/dist/domain/describe/index.js +1 -115
  25. package/dist/domain/describe/match.d.ts +19 -0
  26. package/dist/domain/describe/target-hash.js +1 -12
  27. package/dist/domain/entry.js +0 -14
  28. package/dist/domain/generated-tools.js +0 -1
  29. package/dist/domain/input.js +0 -2
  30. package/dist/domain/paging.js +0 -17
  31. package/dist/domain/recipes.js +10 -7
  32. package/dist/domain/userReferences.js +0 -1
  33. package/dist/domain/wireScalars.js +0 -6
  34. package/dist/infrastructure/adminEntries.js +27 -8
  35. package/dist/infrastructure/adminPaging.js +55 -57
  36. package/dist/infrastructure/artifacts.js +0 -4
  37. package/dist/infrastructure/authSchema.js +0 -9
  38. package/dist/infrastructure/bundle.js +45 -38
  39. package/dist/infrastructure/cli.js +26 -12
  40. package/dist/infrastructure/generate.js +26 -19
  41. package/dist/infrastructure/generateAuth.js +1 -9
  42. package/dist/infrastructure/help.js +55 -0
  43. package/dist/infrastructure/init.js +50 -0
  44. package/dist/infrastructure/inputs.js +0 -9
  45. package/dist/infrastructure/mounts.js +22 -0
  46. package/dist/infrastructure/wrangler.js +0 -6
  47. package/dist/kernel/authorization.js +0 -1
  48. package/dist/kernel/binds.d.ts +34 -7
  49. package/dist/kernel/binds.js +5 -11
  50. package/dist/kernel/caller-identity.d.ts +0 -3
  51. package/dist/kernel/caller-identity.js +0 -15
  52. package/dist/kernel/caller-mode.d.ts +4 -0
  53. package/dist/kernel/caller-mode.js +14 -0
  54. package/dist/kernel/contracts.d.ts +13 -10
  55. package/dist/kernel/contracts.js +14 -41
  56. package/dist/kernel/cron.js +0 -4
  57. package/dist/kernel/dialect/common.js +0 -3
  58. package/dist/kernel/dialect/postgres.js +0 -2
  59. package/dist/kernel/dialect/sqlite.js +1 -6
  60. package/dist/kernel/drain.js +2 -8
  61. package/dist/kernel/driver/bun-sqlite.js +3 -7
  62. package/dist/kernel/driver/d1.d.ts +1 -1
  63. package/dist/kernel/driver/d1.js +6 -4
  64. package/dist/kernel/driver/postgres.d.ts +2 -1
  65. package/dist/kernel/driver/postgres.js +12 -22
  66. package/dist/kernel/driver/transaction-context.js +0 -1
  67. package/dist/kernel/email.js +0 -4
  68. package/dist/kernel/host-access.js +2 -3
  69. package/dist/kernel/host-caller.d.ts +1 -1
  70. package/dist/kernel/host-caller.js +3 -6
  71. package/dist/kernel/json-body.js +0 -2
  72. package/dist/kernel/mcp-apps.js +0 -5
  73. package/dist/kernel/mcp-call.js +0 -1
  74. package/dist/kernel/mcp-handler.js +4 -27
  75. package/dist/kernel/mcp.js +0 -5
  76. package/dist/kernel/media.js +0 -18
  77. package/dist/kernel/paging.js +1 -9
  78. package/dist/kernel/publishing.js +3 -4
  79. package/dist/kernel/r2-media.js +0 -7
  80. package/dist/kernel/rest-dispatch.js +0 -1
  81. package/dist/kernel/rest-router.js +5 -8
  82. package/dist/kernel/rest.d.ts +2 -1
  83. package/dist/kernel/rest.js +1 -3
  84. package/dist/kernel/route-pattern.d.ts +26 -0
  85. package/dist/kernel/route-pattern.js +34 -0
  86. package/dist/kernel/scalars.js +1 -9
  87. package/dist/kernel/search.js +0 -1
  88. package/dist/kernel/site.js +0 -2
  89. package/dist/kernel/store.d.ts +26 -2
  90. package/dist/kernel/store.js +69 -49
  91. package/dist/kernel/surface-failure.d.ts +10 -0
  92. package/dist/kernel/surface-failure.js +29 -0
  93. package/dist/kernel/surface.js +0 -3
  94. package/dist/kernel/sweep.js +0 -1
  95. package/dist/kernel/translations.js +0 -1
  96. package/dist/kernel/user-caller.js +2 -8
  97. package/dist/kernel/user-fields.d.ts +3 -2
  98. package/dist/kernel/user-fields.js +9 -9
  99. package/dist/kernel/validation.js +0 -8
  100. package/dist/kernel/web-gateway.js +0 -2
  101. package/dist/kernel/web-head.js +0 -1
  102. package/dist/kernel/web-index.js +0 -1
  103. package/dist/kernel/web-response.js +0 -1
  104. package/dist/kernel/web-serve.js +97 -0
  105. package/dist/kernel/web.d.ts +1 -1
  106. package/dist/kernel/web.js +10 -91
  107. package/dist/kernel/where.js +0 -6
  108. package/dist/usecase/adminBindings.js +32 -23
  109. package/dist/usecase/adminClient.js +4 -22
  110. package/dist/usecase/adminDescriptors.js +2 -9
  111. package/dist/usecase/generate.js +14 -17
  112. package/dist/usecase/handlers.js +1 -4
  113. package/dist/usecase/mcp-media.js +0 -1
  114. package/dist/usecase/mcp.js +0 -4
  115. package/dist/usecase/paging.js +1 -3
  116. package/dist/usecase/publishing.js +20 -11
  117. package/dist/usecase/rest.js +5 -6
  118. package/dist/usecase/site.js +0 -1
  119. package/dist/usecase/stores.js +12 -17
  120. package/dist/usecase/tool-version.js +0 -3
  121. package/dist/usecase/translations.js +0 -2
  122. package/dist/usecase/validation.js +0 -20
  123. package/docs/examples/README.md +13 -5
  124. package/docs/examples/app-shell/README.md +91 -12
  125. package/docs/examples/app-shell/accept.mjs +91 -0
  126. package/docs/examples/app-shell/gitignore +7 -0
  127. package/docs/examples/app-shell/package.json +10 -9
  128. package/docs/examples/app-shell/smoke-kit.mjs +29 -15
  129. package/docs/examples/app-shell/src/routes.ts +6 -3
  130. package/docs/examples/app-shell/src/service.ts +2 -1
  131. package/docs/examples/club-events/README.md +7 -2
  132. package/docs/examples/club-events/manifests/club-events.yaml +17 -0
  133. package/docs/examples/club-events/smoke.mjs +7 -1
  134. package/docs/examples/generated-web/seed.sql +5 -4
  135. package/docs/examples/purchasing/README.md +12 -3
  136. package/docs/examples/purchasing/manifests/purchasing.yaml +24 -4
  137. package/docs/examples/purchasing/smoke.mjs +14 -0
  138. package/docs/examples/recipes/README.md +5 -3
  139. package/docs/handbook/cloudflare/media-r2.md +6 -3
  140. package/docs/handbook/cloudflare/service-entry.md +5 -0
  141. package/docs/handbook/concepts/lifecycle-and-locales.md +21 -1
  142. package/docs/handbook/examples/hub.md +1 -1
  143. package/docs/handbook/guides/admin-ui.md +1 -1
  144. package/docs/handbook/guides/agent-setup.md +20 -10
  145. package/docs/handbook/guides/agent-workflow-acceptance.md +225 -105
  146. package/docs/handbook/guides/local-testing.md +85 -0
  147. package/docs/handbook/guides/native-admin.md +242 -16
  148. package/docs/handbook/guides/native-execution.md +10 -8
  149. package/docs/handbook/guides/public-pages.md +44 -4
  150. package/docs/handbook/guides/revisions.md +584 -0
  151. package/docs/handbook/guides/size-and-startup.md +140 -0
  152. package/docs/handbook/guides/sql-patterns.md +312 -0
  153. package/docs/handbook/guides/typed-queries.md +146 -3
  154. package/docs/handbook/navigation.json +20 -0
  155. package/docs/handbook/reference/admin-descriptors.md +4 -1
  156. package/docs/handbook/reference/authorization.md +3 -1
  157. package/docs/handbook/reference/describe.md +42 -0
  158. package/docs/handbook/reference/diagnostics.md +16 -2
  159. package/docs/handbook/reference/procedure.md +6 -6
  160. package/docs/handbook/reference/schema.md +79 -9
  161. package/docs/handbook/reference/sql.md +65 -0
  162. package/docs/handbook/reference/surface.md +8 -4
  163. package/docs/handbook/reference/trigger.md +12 -0
  164. package/docs/handbook/reference/view.md +11 -3
  165. package/docs/handbook/releases/index.md +71 -7
  166. package/docs/handbook/start/agents.md +49 -0
  167. package/docs/handbook/start/project-and-cli.md +10 -0
  168. package/docs/handbook/start/quickstart-worker.md +1 -1
  169. package/docs/skills/develop/SKILL.md +19 -6
  170. package/docs/skills/integrate/SKILL.md +3 -1
  171. package/docs/skills/performance/SKILL.md +169 -0
  172. package/docs/skills/update/SKILL.md +10 -4
  173. package/docs/upgrade-0.1-to-0.2.md +151 -39
  174. package/package.json +31 -11
  175. package/skills/README.md +6 -2
  176. package/skills/mantle/SKILL.md +53 -120
  177. package/dist/domain/describe/chunk-ZVXBGP7Y.js +0 -424
  178. package/skills/mantle/references/local-testing.md +0 -20
  179. package/skills/mantle/references/web.md +0 -41
  180. package/skills/mantle/references/website.md +0 -51
@@ -4,7 +4,6 @@ import { basename, dirname, join, relative, resolve } from "node:path";
4
4
  import { isDeepStrictEqual } from "node:util";
5
5
  import { applyEdits, modify, parse } from "jsonc-parser";
6
6
  const requiredFlags = ["nodejs_compat", "global_fetch_strictly_public"];
7
- /** Newest date the project's installed workerd supports, as create-cloudflare picks it; a fixed date before Wrangler is installed. */
8
7
  function compatibilityDate(dir) {
9
8
  try {
10
9
  let from = join(dir, "package.json");
@@ -17,7 +16,6 @@ function compatibilityDate(dir) {
17
16
  catch { }
18
17
  return "2026-09-28";
19
18
  }
20
- /** `assets`: with `--admin`, the static assets directory `mantle bundle` builds Admin into (`<assets>/admin`). */
21
19
  export function configureWrangler(configPath, entry, outdir, generated, check = false, assets, publicFetchRequired = false) {
22
20
  const configDir = dirname(resolve(configPath));
23
21
  const local = (path) => relative(configDir, resolve(path)).replaceAll("\\", "/") || ".";
@@ -49,13 +47,11 @@ export function configureWrangler(configPath, entry, outdir, generated, check =
49
47
  throw new Error(`${configPath} must be a valid JSONC object.`);
50
48
  const indent = text.match(/\n([ \t]+)"/)?.[1] ?? " ";
51
49
  const formattingOptions = { insertSpaces: !indent.includes("\t"), tabSize: indent.length, eol: text.includes("\r\n") ? "\r\n" : "\n" };
52
- // Merge, never replace: the user's own module rules and build options survive.
53
50
  const { rules: [rule], build: buildKeys, ...top } = keys;
54
51
  const edit = (path, value) => {
55
52
  if (!isDeepStrictEqual(path.reduce((v, k) => v?.[k], config), value))
56
53
  text = applyEdits(text, modify(text, path, value, { formattingOptions }));
57
54
  };
58
- // Public routing is the default; an explicit private origin remains app-owned without OAuth/CIMD.
59
55
  const ensureFlags = (path, flags) => {
60
56
  if (flags !== undefined && (!Array.isArray(flags) || !flags.every(flag => typeof flag === "string")))
61
57
  throw new Error(`${configPath}: ${path.join(".")} must be an array of strings.`);
@@ -71,7 +67,6 @@ export function configureWrangler(configPath, entry, outdir, generated, check =
71
67
  edit([...path, -1], flag);
72
68
  };
73
69
  ensureFlags(["compatibility_flags"], config.compatibility_flags);
74
- // Wrangler environment arrays replace the top-level flags rather than inheriting them.
75
70
  for (const [name, environment] of Object.entries(config.env ?? {})) {
76
71
  if (environment?.compatibility_flags !== undefined)
77
72
  ensureFlags(["env", name, "compatibility_flags"], environment.compatibility_flags);
@@ -88,7 +83,6 @@ export function configureWrangler(configPath, entry, outdir, generated, check =
88
83
  edit([...path, "binding"], "ASSETS");
89
84
  if (ownsDefaults && current?.run_worker_first === undefined)
90
85
  edit([...path, "run_worker_first"], true);
91
- // The shell fetches index.html itself; asset canonicalization would redirect it back to /admin/ forever.
92
86
  if (current?.html_handling === undefined && (current?.run_worker_first === true || (ownsDefaults && current?.run_worker_first === undefined)))
93
87
  edit([...path, "html_handling"], "none");
94
88
  };
@@ -1,5 +1,4 @@
1
1
  import { ContractError } from "./contracts.js";
2
- /** Rejections deny; synchronous application failures remain internal errors. */
3
2
  export async function authorized(authorize, target, caller) {
4
3
  let result;
5
4
  try {
@@ -18,30 +18,57 @@ export type Recipe = {
18
18
  } | {
19
19
  readonly k: "const";
20
20
  readonly value: unknown;
21
+ }
22
+ /** A field-scoped Schema's scope column on an SQL INSERT: the Store's insert rule (autofill one value, refuse a value outside the caller's set). */
23
+ | {
24
+ readonly k: "scoped";
25
+ readonly column: string;
26
+ readonly field: string;
27
+ readonly type: "string" | "number";
28
+ readonly name?: string;
29
+ readonly value?: string | number;
21
30
  } | {
22
31
  readonly k: "input";
23
- readonly name: string; /** Only a date-time or a date is converted, so only those carry a type. */
24
- readonly type?: "timestamptz" | "date";
32
+ readonly name: string; /** Only a date-time, a date or JSON is converted, so only those carry a type. */
33
+ readonly type?: "timestamptz" | "date" | "json";
25
34
  };
26
- /** One printed statement and how to bind it. `row`: it must affect exactly one row. */
35
+ /**
36
+ * How a row write a Schema lock guards tells, when it changed no row, a lock from the row conflict (#1822): `sql` re-reads that
37
+ * row as its caller sees it (version and locks aside) and outputs `lock`, the name of a lock that holds, or NULL. `locks` maps
38
+ * each lock on this verb to its message.
39
+ */
40
+ export interface LockProbe<B = readonly Recipe[]> {
41
+ readonly sql: string;
42
+ readonly binds: B;
43
+ readonly schema: string;
44
+ readonly verb: "update" | "delete";
45
+ readonly locks: Readonly<Record<string, string>>; /** The probe can also answer `publishing.required` (a Store update of a published row): this message. */
46
+ readonly required?: string;
47
+ }
48
+ /** One printed statement and how to bind it. `row`: it must affect exactly one row; `"some"`: at least one (a step with `onNoRows: conflict`). `lock`: only on a row op. */
27
49
  export interface Step {
28
50
  readonly sql: string;
29
51
  readonly binds: readonly Recipe[];
30
- readonly row?: boolean;
52
+ readonly row?: boolean | "some";
53
+ readonly lock?: LockProbe;
31
54
  }
32
55
  export type Bound = {
33
56
  readonly sql: string;
34
57
  readonly binds: unknown[];
35
- readonly row?: boolean;
58
+ readonly row?: boolean | "some";
59
+ readonly lock?: LockProbe<unknown[]>;
36
60
  };
37
61
  /** The dialect functions the recipes in use call; generated code passes only those. */
38
62
  export type Values = Partial<Pick<Dialect, "now" | "cutoff" | "timestamp" | "date">> & {
39
63
  readonly scopeInsert?: Dialect['scopeInsert'];
40
- readonly userRefs?: (recipe: Recipe, caller: Caller) => unknown;
64
+ readonly userRefs?: (recipe: Recipe, caller: Caller, input?: Readonly<Record<string, unknown>>) => unknown;
41
65
  /** Present only in apps with user fields: keeps, per caller, one printed form of each Schema scope term. */
42
66
  readonly scopedAll?: <T>(value: T, caller: Caller) => T;
43
67
  };
44
- /** A declared date-time or date as the engine stores it; any other value (and `null`) is bound as it is. */
68
+ /**
69
+ * A declared date-time or date as the engine stores it, and a JSON value as its text (a string, such as a boundary's
70
+ * already converted value, is bound as it is); any other value (and `null`) is bound as it is.
71
+ */
45
72
  export declare const encode: (d: Values, type: string | undefined, v: unknown) => unknown;
46
73
  export declare function binds(d: Values, recipes: readonly Recipe[], caller: Caller, input?: Readonly<Record<string, unknown>>, ms?: number): unknown[];
47
74
  /** Every step's binds, evaluated for a Procedure. */
@@ -1,10 +1,6 @@
1
- /**
2
- * Core's bind recipes (`BindSpec` in the policy rewriter), evaluated for one call. Generated Views, generated
3
- * Procedures and the Store all bind through this one function. Input recipes carry the authored field name; `version` reads the Store's lock from `input`.
4
- */
5
1
  import { deny, isAdmin } from "./contracts.js";
6
- /** A declared date-time or date as the engine stores it; any other value (and `null`) is bound as it is. */
7
- export const encode = (d, type, v) => type === "timestamptz" ? (v == null ? null : d.timestamp(v)) : type === "date" ? (v == null ? null : d.date(v)) : v;
2
+ export const encode = (d, type, v) => type === "timestamptz" ? (v == null ? null : d.timestamp(v)) : type === "date" ? (v == null ? null : d.date(v))
3
+ : type === "json" && v != null && typeof v !== "string" ? JSON.stringify(v) : v;
8
4
  export function binds(d, recipes, caller, input, ms = 0) {
9
5
  const out = [];
10
6
  for (const r of recipes) {
@@ -32,14 +28,12 @@ export function binds(d, recipes, caller, input, ms = 0) {
32
28
  case "input":
33
29
  out.push(encode(d, r.type, input[r.name]));
34
30
  break;
35
- // Only apps whose plan uses caller values import the version-checked capability; it refuses an unknown recipe, and without it this throws before SQL.
36
- default: out.push(d.userRefs(r, caller));
31
+ default: out.push(d.userRefs(r, caller, input));
37
32
  }
38
33
  }
39
34
  return out;
40
35
  }
41
- /** Every step's binds, evaluated for a Procedure. */
42
36
  export function bound(d, steps, caller, input) {
43
- const ms = Date.now(); // one clock for the whole Procedure
44
- return steps.map(s => ({ sql: s.sql, binds: binds(d, s.binds, caller, input, ms), row: s.row }));
37
+ const ms = Date.now();
38
+ return steps.map(s => ({ sql: s.sql, binds: binds(d, s.binds, caller, input, ms), row: s.row, ...(s.lock ? { lock: { ...s.lock, binds: binds(d, s.lock.binds, caller, input, ms) } } : {}) }));
45
39
  }
@@ -18,7 +18,4 @@ export declare const isAdminCaller: (caller: Caller) => caller is Extract<Caller
18
18
  }> & {
19
19
  readonly admin: true;
20
20
  };
21
- export declare function modeOf(caller: Caller): "caller" | "trusted";
22
- /** Only branded host identities may carry freshness authority. */
23
- export declare function checkFresh(caller: Caller, readOnly: boolean, target?: string): void;
24
21
  export {};
@@ -1,22 +1,7 @@
1
- import { ContractError } from "./contract-error.js";
2
1
  export const identities = /* @__PURE__ */ new WeakMap();
3
2
  export const isHostCaller = (caller) => identities.get(caller)?.host === true;
4
3
  export const rolesOf = (caller) => identities.get(caller)?.roles ?? [];
5
- // Unbranded callers get a fresh empty map; mutating it cannot grant authority.
6
4
  export const fieldsOf = (caller) => identities.get(caller)?.fields ?? {};
7
5
  export const valueFor = (caller, field) => identities.get(caller)?.fields[field];
8
6
  export const hasRole = (caller, role) => rolesOf(caller).includes(role);
9
7
  export const isAdminCaller = (caller) => !!identities.get(caller)?.admin;
10
- export function modeOf(caller) {
11
- const kind = caller.kind;
12
- if (kind === 'system')
13
- return 'trusted';
14
- if (kind === 'user' || kind === 'anonymous' || kind === 'host' && isHostCaller(caller))
15
- return 'caller';
16
- throw new ContractError('AUTH_DENIED', 'Unknown caller');
17
- }
18
- /** Only branded host identities may carry freshness authority. */
19
- export function checkFresh(caller, readOnly, target) {
20
- if (!readOnly && isHostCaller(caller) && "authenticatedAs" in caller && caller.authenticatedAs === "snapshot")
21
- throw new ContractError("STALE_CALLER", "Snapshot callers cannot write.", target, { status: 403 });
22
- }
@@ -0,0 +1,4 @@
1
+ import type { Caller } from "./contracts.js";
2
+ export declare function modeOf(caller: Caller): "caller" | "trusted";
3
+ /** Only branded host identities may carry freshness authority. */
4
+ export declare function checkFresh(caller: Caller, readOnly: boolean, target?: string): void;
@@ -0,0 +1,14 @@
1
+ import { ContractError } from "./contract-error.js";
2
+ import { isHostCaller } from "./caller-identity.js";
3
+ export function modeOf(caller) {
4
+ const kind = caller.kind;
5
+ if (kind === 'system')
6
+ return 'trusted';
7
+ if (kind === 'user' || kind === 'anonymous' || kind === 'host' && isHostCaller(caller))
8
+ return 'caller';
9
+ throw new ContractError('AUTH_DENIED', 'Unknown caller');
10
+ }
11
+ export function checkFresh(caller, readOnly, target) {
12
+ if (!readOnly && isHostCaller(caller) && "authenticatedAs" in caller && caller.authenticatedAs === "snapshot")
13
+ throw new ContractError("STALE_CALLER", "Snapshot callers cannot write.", target, { status: 403 });
14
+ }
@@ -108,10 +108,21 @@ export type Output = {
108
108
  };
109
109
  /** Deny unless `allowed`: a user is AUTH_DENIED, anyone else UNAUTHENTICATED. */
110
110
  export declare function deny(caller: Caller, allowed: boolean, path: string): void;
111
- /** CONFLICT for a write the database refused: a unique-index clash, or a row statement that did not change exactly one row. */
111
+ /** A row statement that changed no row cannot tell why without leaking whether the row exists, so it says how to recover. */
112
+ export declare const ROW_CONFLICT = "No row matched this change: it does not exist or is not visible to you, it changed since it was read, or its state no longer allows this change. Read it again before deciding whether to retry.";
113
+ /** CONFLICT for a write the database refused: a unique-index clash (`fields.kind` "unique"), or a row statement that did not change exactly one row ("row"). */
112
114
  export declare const conflict: (path: string, unique: boolean, cause?: unknown) => ContractError;
115
+ /** A guarded row write that changed no row (#1822): CONFLICT `kind: "lock"` when its probe found the row visible and held by a lock, else the row conflict, which reveals nothing. */
116
+ export declare const lockConflict: (path: string, probe: {
117
+ readonly schema: string;
118
+ readonly verb: string;
119
+ readonly locks: Readonly<Record<string, string>>;
120
+ readonly required?: string;
121
+ }, rows: readonly Row[]) => ContractError;
113
122
  /** A Schema `checks` violation (Mantle's SQLite check trigger, or PostgreSQL SQLSTATE 23514) is a CONFLICT naming the check. */
114
123
  export declare const checkConflict: (path: string, error: unknown) => ContractError | undefined;
124
+ /** One message on every target, built from the parsed fields; the driver's own text stays in `cause`. */
125
+ export declare const schemaCheckFailed: (path: string, cause: unknown, schema: string | undefined, check: string | undefined) => ContractError;
115
126
  /** One result of `unsafe`: the rows (typed by the generated function), plus the affected count. */
116
127
  export type PostgresResult = any[] & {
117
128
  readonly count: number | null;
@@ -128,12 +139,4 @@ export interface Postgres {
128
139
  }
129
140
  /** What a read or a single statement needs: a transaction handle suits it too. */
130
141
  export type Unsafe = Pick<Postgres, "unsafe">;
131
- /** A cookie is ambient authority; bearer credentials deliberately supplied by the app are not. */
132
- export declare function checkOrigin(request: Request, caller: Caller): void;
133
- /** A resolver's ContractError (e.g. UNAUTHENTICATED for an expired token) keeps its status; anything else is an application failure. */
134
- export declare function surfaceCaller<Env>(request: Request, env: Env, resolve: (request: Request, env: Env, authority: {
135
- readonly readOnly: boolean;
136
- }) => Caller | Promise<Caller>, readOnly?: boolean): Promise<Caller>;
137
- /** The client sees the redacted wire form; the log gets the surface (a route or tool, never headers) and the original message, stack and cause. */
138
- export declare function surfaceError(error: unknown, where?: string): ReturnType<typeof wire>;
139
- export declare function surfaceFailure(error: unknown, challenge?: string, where?: string): Response;
142
+ export { checkOrigin, surfaceCaller, surfaceError, surfaceFailure } from "./surface-failure.js";
@@ -1,20 +1,14 @@
1
- /** Boundary primitives only. No plan, SQL interpreter or resource ownership. */
2
1
  import { ContractError } from "./contract-error.js";
3
2
  export { ContractError } from "./contract-error.js";
4
3
  import { hasRole, isAdminCaller as isAdmin } from "./caller-identity.js";
5
- /** Gateway mode or the one execution prop carrying forwarded authority. */
6
4
  export const forwarded = (ctx, gateway) => !!(gateway || ctx?.props && Object.hasOwn(ctx.props, "mantle"));
7
- /** Privilege never follows a member role name. Only the original verified identity carries the admin layer. */
8
5
  export { isAdmin };
9
- /** Both authenticated layers pass ctx.user; the host layer still needs its original brand. */
10
6
  export const isUser = (caller) => caller.kind === "user" || isAdmin(caller);
11
7
  export const hasAdminRole = (caller, name) => isAdmin(caller) && hasRole(caller, name);
12
- /** Only the core transport failures; other codes carry their own status, and unknown codes remain server errors. */
13
8
  export const statusOf = (code) => ({ __proto__: null,
14
9
  INPUT_VALIDATION_FAILED: 400, UNAUTHENTICATED: 401, AUTH_DENIED: 403, NOT_FOUND: 404,
15
10
  METHOD_NOT_ALLOWED: 405, CONFLICT: 409, OUTCOME_UNKNOWN: 503,
16
11
  }[code] ?? 500);
17
- /** Redact native errors at the selected boundary; the bare query preserves their identity. */
18
12
  export function wire(error) {
19
13
  const status = error instanceof ContractError ? error.status ?? statusOf(error.code) : 500;
20
14
  if (status !== 500) {
@@ -46,47 +40,26 @@ export function wireRows(rows, fields = []) {
46
40
  return result;
47
41
  });
48
42
  }
49
- /** Deny unless `allowed`: a user is AUTH_DENIED, anyone else UNAUTHENTICATED. */
50
43
  export function deny(caller, allowed, path) {
51
44
  if (!allowed)
52
45
  throw new ContractError("subject" in caller ? "AUTH_DENIED" : "UNAUTHENTICATED", "Authorization predicate not satisfied.", path);
53
46
  }
54
- /** CONFLICT for a write the database refused: a unique-index clash, or a row statement that did not change exactly one row. */
55
- export const conflict = (path, unique, cause) => new ContractError("CONFLICT", unique ? "A unique constraint of the Schema was violated." : "Expected one affected row.", path, { cause });
56
- /** A Schema `checks` violation (Mantle's SQLite check trigger, or PostgreSQL SQLSTATE 23514) is a CONFLICT naming the check. */
47
+ export const ROW_CONFLICT = "No row matched this change: it does not exist or is not visible to you, it changed since it was read, or its state no longer allows this change. Read it again before deciding whether to retry.";
48
+ export const conflict = (path, unique, cause) => new ContractError("CONFLICT", unique ? "A unique constraint of the Schema was violated." : ROW_CONFLICT, path, { cause, fields: { kind: unique ? "unique" : "row" } });
49
+ export const lockConflict = (path, probe, rows) => {
50
+ const lock = rows[0]?.lock;
51
+ if (lock === "publishing.required" && probe.required)
52
+ return new ContractError("INPUT_VALIDATION_FAILED", probe.required, path);
53
+ if (typeof lock !== "string" || !Object.hasOwn(probe.locks, lock))
54
+ return conflict(path, false);
55
+ const message = probe.locks[lock];
56
+ return new ContractError("CONFLICT", message, path, { fields: { kind: "lock", schema: probe.schema, verb: probe.verb, lock, message } });
57
+ };
57
58
  export const checkConflict = (path, error) => {
58
59
  const message = error?.message ?? "", marker = /MANTLE_CHECK ([^:\n]+): (.+)/.exec(message);
59
60
  if (!/MANTLE_CHECK /.test(message))
60
61
  return undefined;
61
- return new ContractError("CONFLICT", `A check of the Schema failed: ${marker?.[0] ?? message}`, path, { cause: error, fields: { kind: "check", schema: marker?.[1], check: marker?.[2]?.replace(/: SQLITE_CONSTRAINT.*$/, "") } });
62
+ return schemaCheckFailed(path, error, marker?.[1], marker?.[2]?.replace(/: SQLITE_CONSTRAINT.*$/, ""));
62
63
  };
63
- /** A cookie is ambient authority; bearer credentials deliberately supplied by the app are not. */
64
- export function checkOrigin(request, caller) {
65
- if (!("credential" in caller) || caller.credential !== "session" || ["GET", "HEAD", "OPTIONS"].includes(request.method))
66
- return;
67
- const site = request.headers.get("sec-fetch-site"), origin = request.headers.get("origin");
68
- if ((!site && !origin) || (site && site !== "same-origin" && site !== "none") || (origin && origin !== new URL(request.url).origin))
69
- throw new ContractError("AUTH_DENIED", "Cross-origin session mutation rejected.", "request:origin");
70
- }
71
- /** A resolver's ContractError (e.g. UNAUTHENTICATED for an expired token) keeps its status; anything else is an application failure. */
72
- export async function surfaceCaller(request, env, resolve, readOnly = false) {
73
- try {
74
- return await resolve(request, env, { readOnly });
75
- }
76
- catch (cause) {
77
- if (cause instanceof ContractError)
78
- throw cause;
79
- throw new Error("Caller resolution failed.", { cause });
80
- }
81
- }
82
- /** The client sees the redacted wire form; the log gets the surface (a route or tool, never headers) and the original message, stack and cause. */
83
- export function surfaceError(error, where = "surface") {
84
- const result = wire(error);
85
- if (result.status === 500)
86
- console.error(`[mantle ${where}] unhandled failure`, error);
87
- return result;
88
- }
89
- export function surfaceFailure(error, challenge, where) {
90
- const result = surfaceError(error, where);
91
- return Response.json({ error: result.error }, { status: result.status, headers: { "cache-control": "private, no-store", ...(challenge && [401, 403].includes(result.status) && result.error.path !== "request:origin" ? { "www-authenticate": challenge } : {}) } });
92
- }
64
+ export const schemaCheckFailed = (path, cause, schema, check) => new ContractError("CONFLICT", schema && check ? `A check of the Schema failed: ${schema}: ${check}` : "A check of the Schema failed.", path, { cause, fields: { kind: "check", schema, check } });
65
+ export { checkOrigin, surfaceCaller, surfaceError, surfaceFailure } from "./surface-failure.js";
@@ -1,8 +1,6 @@
1
- /** SDK-owned POSIX to Cloudflare cron conversion, copied verbatim from Core's CloudflareCron so Workers don't pull Core's D1 driver; Core is retired with 0.2.0, so this copy becomes the only one. */
2
1
  const PART = /^(\*|\d+|[A-Za-z]{3})(?:-(\d+|[A-Za-z]{3}))?(?:\/(\d+))?$/;
3
2
  const MONTHS = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
4
3
  const DAYS = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
5
- /** label, lowest, highest, names (by value from `lowest`) */
6
4
  const FIELDS = [["minute", 0, 59, null], ["hour", 0, 23, null], ["day of month", 1, 31, null], ["month", 1, 12, MONTHS], ["weekday", 0, 6, DAYS]];
7
5
  export function toCloudflareCron(cron) {
8
6
  const refuse = (why) => {
@@ -14,7 +12,6 @@ export function toCloudflareCron(cron) {
14
12
  const weekday = [];
15
13
  fields.forEach((field, i) => {
16
14
  const [label, min, max, names] = FIELDS[i];
17
- // a value as a number: a name counts as its number, and a number must be written plainly (`01` and `1` are one value)
18
15
  const value = (token) => {
19
16
  if (/^\d+$/.test(token)) {
20
17
  if (token.length > 1 && token.startsWith("0"))
@@ -51,7 +48,6 @@ export function toCloudflareCron(cron) {
51
48
  if (i === 4)
52
49
  weekday.push(...out);
53
50
  });
54
- // POSIX runs on either day when both are restricted; Cloudflare does not document that rule, so only one may be
55
51
  if (fields[2] !== "*" && fields[4] !== "*")
56
52
  refuse("restrict the day of month or the weekday, not both");
57
53
  return [...fields.slice(0, 4), weekday.join(",")].join(" ");
@@ -1,6 +1,3 @@
1
- /** What SQLite and PostgreSQL spell identically, defined once; each dialect module re-exports it. */
2
- /** A (possibly schema-qualified) name, quoted. */
3
1
  export const ident = (name) => name.split(".").map(part => `"${part.replaceAll('"', '""')}"`).join(".");
4
- /** A literal piece of text for a LIKE pattern: `\`, `%` and `_` match themselves. Pair with `likeEscape`. */
5
2
  export const escapeLike = (value) => value.replace(/[\\%_]/g, "\\$&");
6
3
  export const likeEscape = " ESCAPE '\\'";
@@ -1,4 +1,3 @@
1
- /** PostgreSQL: `$n` placeholders, time as timestamptz (ISO text keeps every microsecond). */
2
1
  import { isoDate, isoTimestamptz } from "../scalars.js";
3
2
  import { searchLike } from "../search.js";
4
3
  import { escapeLike, ident, likeEscape } from "./common.js";
@@ -13,6 +12,5 @@ export const cutoff = (ms, seconds) => new Date(ms - seconds * 1000);
13
12
  export const timestamp = isoTimestamptz;
14
13
  export const date = isoDate;
15
14
  export const cast = (placeholder, type) => type === "timestamptz" || type === "date" ? `CAST(CAST(${placeholder} AS text) AS ${type})` : type === "json" ? `CAST(CAST(${placeholder} AS text) AS jsonb)` : placeholder;
16
- // ponytail: preserve Core's ILIKE scan; add pg_trgm only when measured search latency needs it.
17
15
  export const search = (_table, fields, alias, query) => searchLike(fields.map(field => `${alias}.${ident(field)}`), query, "ILIKE");
18
16
  export const dialect = { param, ident, escapeLike, likeEscape, like, search, wireTimestamp, wireDate, now, cutoff, timestamp, date, cast, anyOf };
@@ -1,24 +1,19 @@
1
1
  import { ContractError } from "../contracts.js";
2
- /** SQLite and Cloudflare D1: `?n` placeholders, time as microseconds and dates as days since the epoch. */
3
2
  import { encodeDate, encodeTimestamptz } from "../scalars.js";
4
3
  import { searchLike } from "../search.js";
5
4
  import { escapeLike, ident, likeEscape } from "./common.js";
6
5
  export { ident, escapeLike, likeEscape };
7
6
  export const param = (n) => `?${n}`;
8
7
  export const anyOf = (column, placeholder) => `${column} IN (SELECT value FROM json_each(${placeholder}))`;
9
- export const like = () => "LIKE"; // SQLite's LIKE already ignores ASCII case
10
- /** A write that broke a UNIQUE index or the id's UNIQUE (bun:sqlite and D1 word it the same). */
8
+ export const like = () => "LIKE";
11
9
  export const unique = (error) => /UNIQUE constraint failed/.test(String(error?.message));
12
- /** Positive rollback evidence only. Plain/network/I/O errors are deliberately uncertain. */
13
10
  export function definitive(error) {
14
11
  if (error instanceof ContractError)
15
12
  return error.code === "CONFLICT";
16
13
  const e = error;
17
- // Bun before 1.4 omits `code` on statement errors; its SQLiteError still carries the native errno.
18
14
  const native = typeof e?.code === "string" ? e.code.startsWith("SQLITE_") : e?.name === "SQLiteError";
19
15
  return native && typeof e.errno === "number" && [1, 19, 20, 25].includes(e.errno & 255);
20
16
  }
21
- // floor, not SQLite's truncating `/` and `%`: a time before 1970 is a negative number of microseconds
22
17
  export const wireTimestamp = (v) => ["strftime('%Y-%m-%dT%H:%M:%S', ((", v, ") - ((", v, ") % 1000000 + 1000000) % 1000000) / 1000000, 'unixepoch') || '.' || substr('00000' || (((", v, ") % 1000000 + 1000000) % 1000000), -6) || 'Z'"];
23
18
  export const wireDate = (v) => ["date((", v, ") * 86400, 'unixepoch')"];
24
19
  export const now = (ms) => ms * 1000;
@@ -1,25 +1,19 @@
1
- /** Request-time only: no Worker startup path imports this. */
2
- /** Read and discard what is left of a request body, at most 1 MiB and 1 s. A response sent over an unread POST body makes local
3
- * `wrangler dev` break the connection, and the next request fails with "Network connection lost". */
4
1
  async function drain(request) {
5
2
  let reader;
6
3
  const timer = setTimeout(() => reader?.cancel().catch(() => { }), 1000);
7
4
  try {
8
- reader = request.body?.getReader(); // a body the app consumed is done; one it still holds is locked and throws
5
+ reader = request.body?.getReader();
9
6
  for (let read = 0, chunk; reader && !(chunk = await reader.read()).done;)
10
7
  if ((read += chunk.value.byteLength) > 1024 * 1024) {
11
8
  await reader.cancel();
12
9
  return;
13
10
  }
14
11
  }
15
- catch { /* the client went away */ }
12
+ catch { }
16
13
  finally {
17
14
  clearTimeout(timer);
18
15
  }
19
16
  }
20
- /** A request handler that drains the body once it has answered or thrown. `undefined` and `null` mean the request is not this
21
- * handler's, so the next handler can still read it. A handler that answers and reads the request later (in `waitUntil`) must
22
- * read or lock the body before answering. */
23
17
  export const draining = (handle) => async (...args) => {
24
18
  let response;
25
19
  try {
@@ -1,10 +1,9 @@
1
- import { checkConflict, conflict, ContractError } from "../contracts.js";
1
+ import { checkConflict, conflict, ContractError, lockConflict } from "../contracts.js";
2
2
  import { unique } from "../dialect/sqlite.js";
3
3
  export { definitive } from "../dialect/sqlite.js";
4
4
  import { context, guard } from "./transaction-context.js";
5
5
  const transactions = new WeakSet();
6
6
  const queues = new WeakMap();
7
- /** Only queued transactions make otherwise synchronous statements wait; a rejection cannot poison the queue. */
8
7
  function enqueue(db, fn) {
9
8
  const result = (queues.get(db) ?? Promise.resolve()).then(fn);
10
9
  const tail = result.then(() => { }, () => { });
@@ -15,11 +14,9 @@ function enqueue(db, fn) {
15
14
  }
16
15
  const available = (db, fn) => { guard(db); return queues.has(db) ? enqueue(db, fn) : fn(); };
17
16
  export const read = (db, sql, binds) => available(db, () => db.query(sql).all(...binds));
18
- /** One statement is atomic on its own; several run in a transaction, and a row statement that does not change exactly one row rolls it back. A unique-index clash is CONFLICT. */
19
17
  export async function procedure(db, steps, path) {
20
18
  const execute = (handle) => steps.map(s => {
21
19
  const statement = handle.query(s.sql);
22
- // a statement with columns (SELECT, RETURNING) gives rows, and their number is its affected count
23
20
  let rows = [], changes;
24
21
  try {
25
22
  rows = statement.columnNames.length ? statement.all(...s.binds) : [];
@@ -27,15 +24,14 @@ export async function procedure(db, steps, path) {
27
24
  changes = rows.length;
28
25
  else {
29
26
  statement.run(...s.binds);
30
- // Bun's run().changes includes FTS maintenance writes; SQLite changes() counts the authored statement only.
31
27
  changes = s.row ? Number(handle.query("SELECT changes() AS n").all()[0].n) : 0;
32
28
  }
33
29
  }
34
30
  catch (error) {
35
31
  throw unique(error) ? conflict(path, true, error) : checkConflict(path, error) ?? error;
36
32
  }
37
- if (s.row && changes !== 1)
38
- throw conflict(path, false);
33
+ if (s.row && (changes < 1 || changes > 1 && s.row !== "some"))
34
+ throw s.row === true && s.lock ? lockConflict(path, s.lock, handle.query(s.lock.sql).all(...s.lock.binds)) : conflict(path, false);
39
35
  return rows;
40
36
  });
41
37
  return available(db, () => transactions.has(db) || steps.length === 1 ? execute(db) : db.transaction(execute)(db));
@@ -20,5 +20,5 @@ export interface Handle extends Reader {
20
20
  }[]>;
21
21
  }
22
22
  export declare const read: (db: Reader, sql: string, binds: unknown[]) => Promise<any[]>;
23
- /** The assert table's trigger aborts the whole batch with `CONFLICT op=<k>` when statement k did not change exactly one row. */
23
+ /** The assert table's trigger aborts the whole batch with `CONFLICT op=<k>` when statement k did not change exactly one row (at least one for `row: "some"`). */
24
24
  export declare function procedure(db: Handle, steps: readonly Bound[], path: string): Promise<Row[][]>;
@@ -1,22 +1,24 @@
1
- import { checkConflict, conflict } from "../contracts.js";
1
+ import { checkConflict, conflict, lockConflict } from "../contracts.js";
2
2
  import { unique } from "../dialect/sqlite.js";
3
3
  export { definitive } from "../dialect/sqlite.js";
4
4
  export const read = async (db, sql, binds) => (await db.prepare(sql).bind(...binds).all()).results;
5
- /** The assert table's trigger aborts the whole batch with `CONFLICT op=<k>` when statement k did not change exactly one row. */
6
5
  export async function procedure(db, steps, path) {
7
6
  const batch = [], at = [];
8
7
  steps.forEach((s, i) => {
9
8
  at.push(batch.length);
10
9
  batch.push(db.prepare(s.sql).bind(...s.binds));
11
10
  if (s.row)
12
- batch.push(db.prepare(`INSERT INTO _mantle_assert (op,ok) SELECT ${i}, changes() = 1`));
11
+ batch.push(db.prepare(`INSERT INTO _mantle_assert (op,ok) SELECT ${i}, changes() ${s.row === "some" ? "> 0" : "= 1"}`));
13
12
  });
14
13
  let out;
15
14
  try {
16
15
  out = await db.batch(batch);
17
16
  }
18
17
  catch (error) {
19
- throw unique(error) ? conflict(path, true, error) : /CONFLICT op=\d/.test(String(error?.message)) ? conflict(path, false, error) : checkConflict(path, error) ?? error;
18
+ const op = /CONFLICT op=(\d+)/.exec(String(error?.message)), step = op ? steps[Number(op[1])] : undefined, probe = step?.row === true ? step.lock : undefined;
19
+ if (probe)
20
+ throw lockConflict(path, probe, await read(db, probe.sql, probe.binds));
21
+ throw unique(error) ? conflict(path, true, error) : op ? conflict(path, false, error) : checkConflict(path, error) ?? error;
20
22
  }
21
23
  return at.map(i => out[i].results);
22
24
  }
@@ -16,7 +16,8 @@ export declare function definitive(error: unknown): boolean;
16
16
  */
17
17
  export declare function postgresOutcome(error: unknown, path: string): unknown;
18
18
  /**
19
- * One statement is atomic on its own (a row statement touches at most one row, so a failed count check changes nothing)
19
+ * One statement is atomic on its own (a row statement touches at most one row, and a `"some"` one fails only when it wrote
20
+ * none, so a failed count check changes nothing)
20
21
  * and runs in autocommit. Several run in `begin(serializable)`: a failure before the callback returns was rolled back;
21
22
  * only a failed COMMIT can leave the outcome unknown.
22
23
  */
@@ -1,4 +1,4 @@
1
- import { conflict, ContractError } from "../contracts.js";
1
+ import { conflict, ContractError, lockConflict, schemaCheckFailed } from "../contracts.js";
2
2
  import { context, guard } from "./transaction-context.js";
3
3
  const transactions = new WeakSet();
4
4
  const ISOLATION = ["serializable", "repeatable read", "read committed", "read uncommitted"];
@@ -7,38 +7,31 @@ export async function run(db, sql, binds) {
7
7
  const rows = await read(db, sql, binds);
8
8
  return { rows, changes: Number(rows.count ?? 0) };
9
9
  }
10
- /** SQLSTATE of a server error: `errno` on Bun.SQL, `code` on postgres.js. */
11
10
  const sqlstate = (error) => { const e = error; return typeof e?.errno === "string" ? e.errno : e?.code; };
12
- const check = (path, e) => new ContractError("CONFLICT", `A check of the Schema failed: ${e.message}`, path, { cause: e, fields: { kind: "check", schema: e.table ?? e.table_name, check: e.constraint ?? e.constraint_name } });
13
- /** A structured server ErrorResponse proves rollback; missing evidence is uncertain. */
11
+ const check = (path, e) => schemaCheckFailed(path, e, e.table ?? e.table_name, e.constraint ?? e.constraint_name);
14
12
  export function definitive(error) {
15
13
  if (error instanceof ContractError)
16
14
  return error.code === "CONFLICT";
17
15
  const state = sqlstate(error);
18
16
  return typeof state === "string" && /^[0-9A-Z]{5}$/.test(state) && typeof error?.severity === "string" && !/^(08|28|53|57|58|XX)/.test(state);
19
17
  }
20
- /**
21
- * The error to throw when a PostgreSQL write fails where it may already have committed (autocommit, or COMMIT).
22
- * A server ErrorResponse (SQLSTATE + severity) outside connection/resource classes rolled back; anything else, such as a
23
- * lost socket, is OUTCOME_UNKNOWN.
24
- */
18
+ function serialization(error, path) {
19
+ const state = sqlstate(error);
20
+ if ((state === "40001" || state === "40P01") && definitive(error))
21
+ return new ContractError("CONFLICT", "A concurrent transaction prevented this write. Read again before deciding whether to retry.", path, { cause: error, fields: { kind: "serialization" } });
22
+ }
25
23
  export function postgresOutcome(error, path) {
26
24
  if (definitive(error))
27
- return error;
25
+ return serialization(error, path) ?? error;
28
26
  return new ContractError("OUTCOME_UNKNOWN", "The write outcome is unknown; reconcile before retrying.", path, { cause: error });
29
27
  }
30
- /**
31
- * One statement is atomic on its own (a row statement touches at most one row, so a failed count check changes nothing)
32
- * and runs in autocommit. Several run in `begin(serializable)`: a failure before the callback returns was rolled back;
33
- * only a failed COMMIT can leave the outcome unknown.
34
- */
35
28
  export async function procedure(db, steps, path) {
36
29
  const execute = async (handle) => {
37
30
  const results = [];
38
31
  for (const s of steps) {
39
- const { rows, changes } = await run(handle, s.sql, s.binds).catch(error => { throw sqlstate(error) === "23505" ? conflict(path, true, error) : sqlstate(error) === "23514" ? check(path, error) : error; });
40
- if (s.row && changes !== 1)
41
- throw conflict(path, false);
32
+ const { rows, changes } = await run(handle, s.sql, s.binds).catch(error => { throw sqlstate(error) === "23505" ? conflict(path, true, error) : sqlstate(error) === "23514" ? check(path, error) : serialization(error, path) ?? error; });
33
+ if (s.row && (changes < 1 || changes > 1 && s.row !== "some"))
34
+ throw s.row === true && s.lock ? lockConflict(path, s.lock, (await run(handle, s.lock.sql, s.lock.binds)).rows) : conflict(path, false);
42
35
  results.push(rows);
43
36
  }
44
37
  return results;
@@ -55,18 +48,15 @@ export async function procedure(db, steps, path) {
55
48
  }
56
49
  return transaction(db, execute, undefined, path);
57
50
  }
58
- /** Shared BEGIN/COMMIT outcome handling for Procedures and interactive Store transactions. */
59
51
  export async function transaction(db, fn, options = {}, path = "store/transaction") {
60
52
  guard(db);
61
53
  if (transactions.has(db))
62
54
  throw new Error("Nested transactions are not supported.");
63
- // a transaction handle the app opened (postgres.js types it without `begin`) only suits reads and single statements
64
55
  if (typeof db.begin !== "function")
65
56
  throw new Error("This PostgreSQL handle is a transaction the app opened and cannot begin another: open the transaction with store(pool, caller).transaction and call Procedures through its tx.");
66
57
  let committing = false;
67
58
  const state = { outer: db };
68
59
  try {
69
- // printed into BEGIN, so only PostgreSQL's own four levels: a handler may pass request input here
70
60
  const level = options.isolation ?? "serializable";
71
61
  if (!ISOLATION.includes(level))
72
62
  throw new ContractError("INPUT_VALIDATION_FAILED", `isolation '${level}' is not a PostgreSQL isolation level`, path);
@@ -90,7 +80,7 @@ export async function transaction(db, fn, options = {}, path = "store/transactio
90
80
  });
91
81
  }
92
82
  catch (error) {
93
- throw error instanceof ContractError || !committing ? error : postgresOutcome(error, path);
83
+ throw serialization(error, path) ?? (error instanceof ContractError || !committing ? error : postgresOutcome(error, path));
94
84
  }
95
85
  finally {
96
86
  state.outer = undefined;
@@ -5,7 +5,6 @@ export function guard(db) {
5
5
  if (context.getStore()?.outer === db)
6
6
  throw new ContractError("INPUT_VALIDATION_FAILED", "Inside store.transaction use tx, not the outer store or handle.", "store/transaction");
7
7
  }
8
- /** One atomic write cannot publish a translation and change its parent's status. */
9
8
  export function translationWrite(db, schema, parent) {
10
9
  const state = context.getStore();
11
10
  if (!state || state.handle !== db)