@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.
- package/README.md +15 -5
- package/dist/auth/adapter.js +1 -28
- package/dist/auth/admin.js +2 -5
- package/dist/auth/caller.js +0 -27
- package/dist/auth/consent.js +100 -0
- package/dist/auth/hardening.js +0 -7
- package/dist/auth/identity.js +0 -11
- package/dist/auth/oauth.js +0 -32
- package/dist/auth/resource-seeds.js +0 -2
- package/dist/auth/route-http.js +33 -0
- package/dist/auth/routes.js +5 -151
- package/dist/build/compiler.d.ts +19 -609
- package/dist/build/compiler.js +6 -6896
- package/dist/build/spec/domain/model/ManifestGrammar.d.ts +39 -1
- package/dist/build/spec/domain/model/RuntimePlan.d.ts +16 -3
- package/dist/build/spec/domain/model/SqlIr.d.ts +32 -0
- package/dist/build/spec/domain/model/UserFields.d.ts +1 -0
- package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
- package/dist/bun/assets.js +0 -2
- package/dist/domain/adminOrder.js +0 -2
- package/dist/domain/columns.js +7 -20
- package/dist/domain/describe/chunk-P6PP6VVV.js +1 -0
- package/dist/domain/describe/index.d.ts +1 -0
- package/dist/domain/describe/index.js +1 -115
- package/dist/domain/describe/match.d.ts +19 -0
- package/dist/domain/describe/target-hash.js +1 -12
- package/dist/domain/entry.js +0 -14
- package/dist/domain/generated-tools.js +0 -1
- package/dist/domain/input.js +0 -2
- package/dist/domain/paging.js +0 -17
- package/dist/domain/recipes.js +10 -7
- package/dist/domain/userReferences.js +0 -1
- package/dist/domain/wireScalars.js +0 -6
- package/dist/infrastructure/adminEntries.js +27 -8
- package/dist/infrastructure/adminPaging.js +55 -57
- package/dist/infrastructure/artifacts.js +0 -4
- package/dist/infrastructure/authSchema.js +0 -9
- package/dist/infrastructure/bundle.js +45 -38
- package/dist/infrastructure/cli.js +26 -12
- package/dist/infrastructure/generate.js +26 -19
- package/dist/infrastructure/generateAuth.js +1 -9
- package/dist/infrastructure/help.js +55 -0
- package/dist/infrastructure/init.js +50 -0
- package/dist/infrastructure/inputs.js +0 -9
- package/dist/infrastructure/mounts.js +22 -0
- package/dist/infrastructure/wrangler.js +0 -6
- package/dist/kernel/authorization.js +0 -1
- package/dist/kernel/binds.d.ts +34 -7
- package/dist/kernel/binds.js +5 -11
- package/dist/kernel/caller-identity.d.ts +0 -3
- package/dist/kernel/caller-identity.js +0 -15
- package/dist/kernel/caller-mode.d.ts +4 -0
- package/dist/kernel/caller-mode.js +14 -0
- package/dist/kernel/contracts.d.ts +13 -10
- package/dist/kernel/contracts.js +14 -41
- package/dist/kernel/cron.js +0 -4
- package/dist/kernel/dialect/common.js +0 -3
- package/dist/kernel/dialect/postgres.js +0 -2
- package/dist/kernel/dialect/sqlite.js +1 -6
- package/dist/kernel/drain.js +2 -8
- package/dist/kernel/driver/bun-sqlite.js +3 -7
- package/dist/kernel/driver/d1.d.ts +1 -1
- package/dist/kernel/driver/d1.js +6 -4
- package/dist/kernel/driver/postgres.d.ts +2 -1
- package/dist/kernel/driver/postgres.js +12 -22
- package/dist/kernel/driver/transaction-context.js +0 -1
- package/dist/kernel/email.js +0 -4
- package/dist/kernel/host-access.js +2 -3
- package/dist/kernel/host-caller.d.ts +1 -1
- package/dist/kernel/host-caller.js +3 -6
- package/dist/kernel/json-body.js +0 -2
- package/dist/kernel/mcp-apps.js +0 -5
- package/dist/kernel/mcp-call.js +0 -1
- package/dist/kernel/mcp-handler.js +4 -27
- package/dist/kernel/mcp.js +0 -5
- package/dist/kernel/media.js +0 -18
- package/dist/kernel/paging.js +1 -9
- package/dist/kernel/publishing.js +3 -4
- package/dist/kernel/r2-media.js +0 -7
- package/dist/kernel/rest-dispatch.js +0 -1
- package/dist/kernel/rest-router.js +5 -8
- package/dist/kernel/rest.d.ts +2 -1
- package/dist/kernel/rest.js +1 -3
- package/dist/kernel/route-pattern.d.ts +26 -0
- package/dist/kernel/route-pattern.js +34 -0
- package/dist/kernel/scalars.js +1 -9
- package/dist/kernel/search.js +0 -1
- package/dist/kernel/site.js +0 -2
- package/dist/kernel/store.d.ts +26 -2
- package/dist/kernel/store.js +69 -49
- package/dist/kernel/surface-failure.d.ts +10 -0
- package/dist/kernel/surface-failure.js +29 -0
- package/dist/kernel/surface.js +0 -3
- package/dist/kernel/sweep.js +0 -1
- package/dist/kernel/translations.js +0 -1
- package/dist/kernel/user-caller.js +2 -8
- package/dist/kernel/user-fields.d.ts +3 -2
- package/dist/kernel/user-fields.js +9 -9
- package/dist/kernel/validation.js +0 -8
- package/dist/kernel/web-gateway.js +0 -2
- package/dist/kernel/web-head.js +0 -1
- package/dist/kernel/web-index.js +0 -1
- package/dist/kernel/web-response.js +0 -1
- package/dist/kernel/web-serve.js +97 -0
- package/dist/kernel/web.d.ts +1 -1
- package/dist/kernel/web.js +10 -91
- package/dist/kernel/where.js +0 -6
- package/dist/usecase/adminBindings.js +32 -23
- package/dist/usecase/adminClient.js +4 -22
- package/dist/usecase/adminDescriptors.js +2 -9
- package/dist/usecase/generate.js +14 -17
- package/dist/usecase/handlers.js +1 -4
- package/dist/usecase/mcp-media.js +0 -1
- package/dist/usecase/mcp.js +0 -4
- package/dist/usecase/paging.js +1 -3
- package/dist/usecase/publishing.js +20 -11
- package/dist/usecase/rest.js +5 -6
- package/dist/usecase/site.js +0 -1
- package/dist/usecase/stores.js +12 -17
- package/dist/usecase/tool-version.js +0 -3
- package/dist/usecase/translations.js +0 -2
- package/dist/usecase/validation.js +0 -20
- package/docs/examples/README.md +13 -5
- package/docs/examples/app-shell/README.md +91 -12
- package/docs/examples/app-shell/accept.mjs +91 -0
- package/docs/examples/app-shell/gitignore +7 -0
- package/docs/examples/app-shell/package.json +10 -9
- package/docs/examples/app-shell/smoke-kit.mjs +29 -15
- package/docs/examples/app-shell/src/routes.ts +6 -3
- package/docs/examples/app-shell/src/service.ts +2 -1
- package/docs/examples/club-events/README.md +7 -2
- package/docs/examples/club-events/manifests/club-events.yaml +17 -0
- package/docs/examples/club-events/smoke.mjs +7 -1
- package/docs/examples/generated-web/seed.sql +5 -4
- package/docs/examples/purchasing/README.md +12 -3
- package/docs/examples/purchasing/manifests/purchasing.yaml +24 -4
- package/docs/examples/purchasing/smoke.mjs +14 -0
- package/docs/examples/recipes/README.md +5 -3
- package/docs/handbook/cloudflare/media-r2.md +6 -3
- package/docs/handbook/cloudflare/service-entry.md +5 -0
- package/docs/handbook/concepts/lifecycle-and-locales.md +21 -1
- package/docs/handbook/examples/hub.md +1 -1
- package/docs/handbook/guides/admin-ui.md +1 -1
- package/docs/handbook/guides/agent-setup.md +20 -10
- package/docs/handbook/guides/agent-workflow-acceptance.md +225 -105
- package/docs/handbook/guides/local-testing.md +85 -0
- package/docs/handbook/guides/native-admin.md +242 -16
- package/docs/handbook/guides/native-execution.md +10 -8
- package/docs/handbook/guides/public-pages.md +44 -4
- package/docs/handbook/guides/revisions.md +584 -0
- package/docs/handbook/guides/size-and-startup.md +140 -0
- package/docs/handbook/guides/sql-patterns.md +312 -0
- package/docs/handbook/guides/typed-queries.md +146 -3
- package/docs/handbook/navigation.json +20 -0
- package/docs/handbook/reference/admin-descriptors.md +4 -1
- package/docs/handbook/reference/authorization.md +3 -1
- package/docs/handbook/reference/describe.md +42 -0
- package/docs/handbook/reference/diagnostics.md +16 -2
- package/docs/handbook/reference/procedure.md +6 -6
- package/docs/handbook/reference/schema.md +79 -9
- package/docs/handbook/reference/sql.md +65 -0
- package/docs/handbook/reference/surface.md +8 -4
- package/docs/handbook/reference/trigger.md +12 -0
- package/docs/handbook/reference/view.md +11 -3
- package/docs/handbook/releases/index.md +71 -7
- package/docs/handbook/start/agents.md +49 -0
- package/docs/handbook/start/project-and-cli.md +10 -0
- package/docs/handbook/start/quickstart-worker.md +1 -1
- package/docs/skills/develop/SKILL.md +19 -6
- package/docs/skills/integrate/SKILL.md +3 -1
- package/docs/skills/performance/SKILL.md +169 -0
- package/docs/skills/update/SKILL.md +10 -4
- package/docs/upgrade-0.1-to-0.2.md +151 -39
- package/package.json +31 -11
- package/skills/README.md +6 -2
- package/skills/mantle/SKILL.md +53 -120
- package/dist/domain/describe/chunk-ZVXBGP7Y.js +0 -424
- package/skills/mantle/references/local-testing.md +0 -20
- package/skills/mantle/references/web.md +0 -41
- package/skills/mantle/references/website.md +0 -51
|
@@ -7,3 +7,143 @@ Workers use `mantle bundle` to emit split ESM. Keep Better Auth behind `authLoad
|
|
|
7
7
|
Contributor checks use tracked fixtures to compare runtime/startup graphs against `size-budgets.json` and inspect the published allowlist in `check:package`. Package packed/unpacked bytes are reported separately from request startup and query costs. Do not infer latency from bundle bytes.
|
|
8
8
|
|
|
9
9
|
Cloudflare startup_time_ms needs an actual upload/deployment; offline dry runs do not measure it. Measure on the selected host and report first use and warm work separately. Applications own deployment authorization and resources.
|
|
10
|
+
|
|
11
|
+
## Size metrics
|
|
12
|
+
|
|
13
|
+
Three metrics, one definition each. The [performance skill](../../skills/performance/SKILL.md)
|
|
14
|
+
and the SDK's own checks use these names. Measure the production build that
|
|
15
|
+
`mantle bundle` writes, never a dev server.
|
|
16
|
+
|
|
17
|
+
"gzip per file" means: compress each file, or each inline script, on its own with
|
|
18
|
+
Node's `zlib.gzipSync` at its default level, then add the results. A host may
|
|
19
|
+
serve Brotli or another level; that is a different number, so name it when you
|
|
20
|
+
report it.
|
|
21
|
+
|
|
22
|
+
| Metric | What counts | Unit |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| First-load JavaScript | Every script a page's HTML loads: `<script src>` files and inline `<script>` text, classic or `type="module"`, plus every file they import statically, recursively. A dynamic `import()` is not followed. CSS, fonts, images and scripts from another origin do not count. | gzip per file |
|
|
25
|
+
| MCP App HTML document | The whole built App document as served: its inline JavaScript, CSS and markup in one file. | raw bytes and gzip of the file |
|
|
26
|
+
| Worker entry static graph | The Worker entry (Wrangler's `main`, such as `dist/worker/worker.js`) plus every chunk it imports statically, recursively. A dynamic `import()` is not followed. | gzip per file; raw bytes beside it |
|
|
27
|
+
|
|
28
|
+
The 100 KB budget in the performance skill is first-load JavaScript. It applies to
|
|
29
|
+
an MCP App too: `mantle bundle` builds the App as one HTML file with its
|
|
30
|
+
JavaScript inline, so its first-load JavaScript is its inline script text. The
|
|
31
|
+
MCP App HTML document also holds CSS and markup, so do not compare the document
|
|
32
|
+
with that budget.
|
|
33
|
+
|
|
34
|
+
### Measure
|
|
35
|
+
|
|
36
|
+
This script measures first-load JavaScript and the Worker entry static graph. It
|
|
37
|
+
follows static imports in the minified ESM that Vite and `mantle bundle` emit,
|
|
38
|
+
and reproduces the SDK checks' graphs below. Save it outside `src/`:
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
// first-load.mjs — node first-load.mjs <page.html | entry.js> [assets-directory]
|
|
42
|
+
import { readFileSync } from "node:fs";
|
|
43
|
+
import { dirname, join, resolve } from "node:path";
|
|
44
|
+
import { gzipSync } from "node:zlib";
|
|
45
|
+
|
|
46
|
+
const [target, root = dirname(target)] = process.argv.slice(2);
|
|
47
|
+
const seen = new Set();
|
|
48
|
+
let scripts = 0, raw = 0, gzip = 0;
|
|
49
|
+
// `import … from "./x.js"`, `import "./x.js"`, `export … from "./x.js"`; a dynamic `import("./x.js")` is not followed.
|
|
50
|
+
const imports = /(?:^|[;}\s])(?:import|export)\s*(?:[\w$*{},\s]*?from\s*)?["'](\.{1,2}\/[^"']+)["']/g;
|
|
51
|
+
function count(code, directory) {
|
|
52
|
+
scripts += 1;
|
|
53
|
+
raw += code.length;
|
|
54
|
+
gzip += gzipSync(code).length;
|
|
55
|
+
for (const [, path] of code.toString().matchAll(imports)) visit(resolve(directory, path));
|
|
56
|
+
}
|
|
57
|
+
function visit(file) {
|
|
58
|
+
if (seen.has(file)) return;
|
|
59
|
+
seen.add(file);
|
|
60
|
+
count(readFileSync(file), dirname(file));
|
|
61
|
+
}
|
|
62
|
+
if (target.endsWith(".html")) {
|
|
63
|
+
// Every script the page loads: `<script src>` files and inline `<script>` text, classic or module.
|
|
64
|
+
const html = readFileSync(target, "utf8");
|
|
65
|
+
for (const [, attributes, body] of html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) {
|
|
66
|
+
const type = /\btype="([^"]*)"/i.exec(attributes)?.[1] ?? "";
|
|
67
|
+
if (!["", "module", "text/javascript"].includes(type)) continue; // JSON data and import maps are not JavaScript
|
|
68
|
+
const src = /\bsrc="([^"]+)"/i.exec(attributes)?.[1];
|
|
69
|
+
if (!src) count(Buffer.from(body), dirname(resolve(target)));
|
|
70
|
+
else if (!/^(?:[a-z]+:)?\/\//i.test(src)) visit(src.startsWith("/") ? join(resolve(root), src) : resolve(dirname(target), src));
|
|
71
|
+
}
|
|
72
|
+
} else visit(resolve(target));
|
|
73
|
+
console.log(JSON.stringify({ scripts, raw, gzip }));
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
First-load JavaScript of a page: pass its HTML, and the assets directory when the
|
|
77
|
+
page loads scripts by absolute path. Built-in Admin loads the blocking `theme.js`
|
|
78
|
+
and the module entry:
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
pnpm exec mantle bundle src/worker.ts dist
|
|
82
|
+
node first-load.mjs dist/client/admin/index.html dist/client
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
First-load JavaScript of an MCP App: pass its HTML document.
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
node first-load.mjs dist/client/admin/mcp-app.html
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Worker entry static graph: pass Wrangler's `main`.
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
node first-load.mjs dist/worker/worker.js
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
MCP App HTML document:
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
node -e 'const b=require("fs").readFileSync(process.argv[1]);console.log(JSON.stringify({raw:b.length,gzip:require("zlib").gzipSync(b).length}))' dist/client/admin/mcp-app.html
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## What the SDK's checks measure
|
|
104
|
+
|
|
105
|
+
The SDK's own checks run on fixtures and the built-in Admin entry, not on your
|
|
106
|
+
application. Where they differ from the definitions above:
|
|
107
|
+
|
|
108
|
+
| Check | Reports | Difference |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| `check:ui-size`, `admin:entry-gzip` | Admin's module entry and its static graph, gzip per file | Leaves out the blocking classic `theme.js` (about 0.8 KB gzip). Reported, not gating. |
|
|
111
|
+
| `check:ui-size`, `admin:mcp-html` | Raw bytes of `mcp-app.html` | No gzip figure and no inline JavaScript figure. Reported, not gating. |
|
|
112
|
+
| `check:startup` and `check-size.ts`, `startup:<surface>` | The static graph of a fixture Worker mounting each surface, from esbuild's metafile | Raw minified bytes, not gzip. Gates against `size-budgets.json`. |
|
|
113
|
+
| `check-size.ts`, `direct-op` | The static graph of one direct operation import | Raw minified bytes. Gates below 4 KiB. |
|
|
114
|
+
| `check-size.ts`, `startupBytes` of `cloudflare-d1` and `cloudflare-postgres` | The fixture Worker entry's static graph | Raw minified bytes. Reported, not gating. |
|
|
115
|
+
| `check-size.ts`, `totalBytes` and `gzipBytes` | Every `.js` file of a fixture Worker bundle; `gzipBytes` is gzip per file | Includes lazy chunks, so it is the whole bundle, not the static graph. Only `totalBytes` warns above its reference; neither gates. |
|
|
116
|
+
|
|
117
|
+
`check:ui-size` also gates that lazy features and locales stay out of Admin's
|
|
118
|
+
startup graph, and `check:startup` runs it with `--startup-only`. Package bytes
|
|
119
|
+
(`packed`, `unpacked`) are a separate measure of what installs.
|
|
120
|
+
|
|
121
|
+
## Native Admin baselines
|
|
122
|
+
|
|
123
|
+
Built-in Admin is SDK-existing cost: both its page and its MCP App. The 0.2.0
|
|
124
|
+
size pass, [#1554](https://github.com/aotter/mantle/issues/1554), shrank them and
|
|
125
|
+
is closed; the misses below are what remains. Measured with the commands above on
|
|
126
|
+
the built-in entry. The figures move with any change to Admin or its
|
|
127
|
+
dependencies, so they hold only for the version and commit named.
|
|
128
|
+
|
|
129
|
+
| SDK | Source | Admin page first-load JavaScript | MCP App first-load JavaScript | MCP App HTML document |
|
|
130
|
+
|---|---|---|---|---|
|
|
131
|
+
| 0.2.1 | `develop` at `6dafb812`, before the release | 212,102 B gzip in 5 files (211,338 B without `theme.js`) | 193,535 B gzip, inline | 694,170 B raw / 200,457 B gzip |
|
|
132
|
+
|
|
133
|
+
Neither the built-in Admin page nor its MCP App meets the 100 KB first-load
|
|
134
|
+
budget. Both misses are SDK cost, not your application's: report them as
|
|
135
|
+
SDK-existing, link #1554, and keep them out of app-added cost.
|
|
136
|
+
|
|
137
|
+
## Report size
|
|
138
|
+
|
|
139
|
+
- Report app-added cost and SDK-existing cost separately. App-added cost is your
|
|
140
|
+
measurement minus the baseline for the installed SDK version. When your figures
|
|
141
|
+
for an unchanged built-in Admin differ from the table, measure the baseline
|
|
142
|
+
again on your install and subtract that.
|
|
143
|
+
- When the installed SDK and Admin versions are unchanged and Admin uses the
|
|
144
|
+
built-in entry, the baseline still holds: do not repeat that experiment each
|
|
145
|
+
round. An `admin.tsx` entry or a custom MCP App changes what you measure.
|
|
146
|
+
- A target still missed is PARTIAL, even when the SDK's cost causes it. Never
|
|
147
|
+
relabel PARTIAL as PASS and never raise a threshold to pass.
|
|
148
|
+
- Do not patch vendor or SDK code, remove native Admin features or build a
|
|
149
|
+
general benchmark framework to pass. Report the miss and its owner.
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Eight SQL patterns most apps need, each a complete manifest that compiles on every target, with the PostgreSQL spelling and what each refuses.
|
|
3
|
+
---
|
|
4
|
+
# SQL patterns
|
|
5
|
+
|
|
6
|
+
Each section is a business need, the manifest that meets it, and what the database refuses. Every block here is
|
|
7
|
+
generated by the docs check, and `packages/mantle/tests/sql-patterns.test.ts` runs the same manifest on bun:sqlite,
|
|
8
|
+
local D1 and PostgreSQL through the generated REST boundary. The SQL is the SQLite and D1 spelling; where PostgreSQL
|
|
9
|
+
differs, the section says so. What each dialect accepts is listed in [the SQL reference](../reference/sql.md).
|
|
10
|
+
|
|
11
|
+
For an order with any number of lines, see [several rows that commit together](typed-queries.md#several-rows-that-commit-together).
|
|
12
|
+
|
|
13
|
+
The sections share two Schemas:
|
|
14
|
+
|
|
15
|
+
```yaml
|
|
16
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
17
|
+
kind: Schema
|
|
18
|
+
metadata: { name: products }
|
|
19
|
+
spec:
|
|
20
|
+
title: Products
|
|
21
|
+
lifecycle: operational
|
|
22
|
+
uniqueIndexes: [[sku]]
|
|
23
|
+
checks: ["reserved <= stock"]
|
|
24
|
+
schema:
|
|
25
|
+
type: object
|
|
26
|
+
required: [sku, name, price, stock, reserved, active]
|
|
27
|
+
properties:
|
|
28
|
+
sku: { type: string }
|
|
29
|
+
name: { type: string, minLength: 1 }
|
|
30
|
+
note: { type: string }
|
|
31
|
+
price: { type: integer, minimum: 0 }
|
|
32
|
+
stock: { type: integer, minimum: 0 }
|
|
33
|
+
reserved: { type: integer, minimum: 0 }
|
|
34
|
+
active: { type: boolean }
|
|
35
|
+
---
|
|
36
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
37
|
+
kind: Schema
|
|
38
|
+
metadata: { name: orders }
|
|
39
|
+
spec:
|
|
40
|
+
title: Orders
|
|
41
|
+
lifecycle: operational
|
|
42
|
+
indexes: [[productId, createdAt, id]]
|
|
43
|
+
schema:
|
|
44
|
+
type: object
|
|
45
|
+
required: [productId, qty, total, orderStatus]
|
|
46
|
+
properties:
|
|
47
|
+
productId: { type: string, x-mantle-ref: products }
|
|
48
|
+
qty: { type: integer, minimum: 1 }
|
|
49
|
+
total: { type: integer, minimum: 0 }
|
|
50
|
+
orderStatus: { type: string, enum: [reserved, pending_approval] }
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Choose a status while writing: CASE
|
|
54
|
+
|
|
55
|
+
An order over 5000 waits for a manager; a smaller one reserves stock at once. `CASE` picks the status inside the
|
|
56
|
+
INSERT, and the reservation runs only for a small order, in the same program, so the two never disagree. The total
|
|
57
|
+
comes from the catalogue's `price`, never from the caller: a price in the input would let a caller choose the total,
|
|
58
|
+
and with it whether the order needs approval.
|
|
59
|
+
|
|
60
|
+
```yaml
|
|
61
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
62
|
+
kind: Procedure
|
|
63
|
+
metadata: { name: place-order }
|
|
64
|
+
spec:
|
|
65
|
+
description: Order a product. An order over 5000 waits for approval and reserves nothing yet.
|
|
66
|
+
requires: { auth: { all: [ctx.user] } }
|
|
67
|
+
input:
|
|
68
|
+
type: object
|
|
69
|
+
additionalProperties: false
|
|
70
|
+
required: [productId, qty]
|
|
71
|
+
properties:
|
|
72
|
+
productId: { type: string }
|
|
73
|
+
qty: { type: integer, minimum: 1 }
|
|
74
|
+
output: { type: object }
|
|
75
|
+
handler:
|
|
76
|
+
sql: |
|
|
77
|
+
INSERT INTO orders (productId, qty, total, orderStatus)
|
|
78
|
+
SELECT p.id, input.qty, input.qty * p.price,
|
|
79
|
+
CASE WHEN input.qty * p.price > 5000 THEN 'pending_approval' ELSE 'reserved' END
|
|
80
|
+
FROM products p WHERE p.id = input.productId AND p.active
|
|
81
|
+
RETURNING id, orderStatus AS "orderStatus";
|
|
82
|
+
UPDATE products SET reserved = reserved + input.qty
|
|
83
|
+
WHERE id IN (SELECT p.id FROM products p WHERE p.id = input.productId AND p.active AND input.qty * p.price <= 5000)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The second statement is written as `WHERE id IN (SELECT …)` on purpose. `WHERE id = input.productId AND …` is a
|
|
87
|
+
row-classified UPDATE, which must change exactly one row: for an order that waits, it would change none and refuse the
|
|
88
|
+
whole call with `CONFLICT`. A write that should do nothing when its condition is false needs the set form. The first
|
|
89
|
+
statement's join on `products` with `p.active` means an unknown or discontinued product writes no order.
|
|
90
|
+
|
|
91
|
+
## Approve before reserving
|
|
92
|
+
|
|
93
|
+
Approval changes the status and reserves the stock in one program. The first statement is the guard: a stale
|
|
94
|
+
`expectedVersion` or an order that is no longer `pending_approval` matches no row, which is `CONFLICT`. The second
|
|
95
|
+
statement fails `reserved <= stock` when the stock is gone by now, and that rolls back the status change too, so the
|
|
96
|
+
order stays pending. [Purchasing](../../examples/purchasing/README.md) does the same with a department budget
|
|
97
|
+
(`approve-request-write`), and approves small requests at submission with `CASE` (`submit-request`).
|
|
98
|
+
|
|
99
|
+
```yaml
|
|
100
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
101
|
+
kind: Procedure
|
|
102
|
+
metadata: { name: approve-order }
|
|
103
|
+
spec:
|
|
104
|
+
description: Approve a pending order at the version you reviewed; reserves its stock.
|
|
105
|
+
requires: { auth: { all: [ctx.admin] } }
|
|
106
|
+
input:
|
|
107
|
+
type: object
|
|
108
|
+
additionalProperties: false
|
|
109
|
+
required: [id, expectedVersion]
|
|
110
|
+
properties:
|
|
111
|
+
id: { type: string }
|
|
112
|
+
expectedVersion: { type: integer }
|
|
113
|
+
output: { type: object }
|
|
114
|
+
handler:
|
|
115
|
+
sql: |
|
|
116
|
+
UPDATE orders SET orderStatus = 'reserved'
|
|
117
|
+
WHERE id = input.id AND version = input.expectedVersion AND orderStatus = 'pending_approval'
|
|
118
|
+
RETURNING id, version;
|
|
119
|
+
UPDATE products SET reserved = reserved + (SELECT o.qty FROM orders o WHERE o.id = input.id)
|
|
120
|
+
WHERE id = (SELECT o.productId FROM orders o WHERE o.id = input.id)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Change only the fields given: COALESCE
|
|
124
|
+
|
|
125
|
+
A partial update keeps every field the caller left out: `COALESCE(input.name, name)` is the new name when one is given
|
|
126
|
+
and the current one otherwise. `NULLIF(input.note, '')` also treats an empty string as left out. Because of that, this
|
|
127
|
+
Procedure cannot clear a note; give clearing its own input (for example `clearNote: true` with
|
|
128
|
+
`CASE WHEN input.clearNote THEN NULL ELSE … END`) if the app needs it. The version guard makes a stale edit a 409.
|
|
129
|
+
|
|
130
|
+
```yaml
|
|
131
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
132
|
+
kind: Procedure
|
|
133
|
+
metadata: { name: update-product }
|
|
134
|
+
spec:
|
|
135
|
+
description: Change a product's name or note; a field you leave out keeps its value.
|
|
136
|
+
requires: { auth: { all: [ctx.admin] } }
|
|
137
|
+
input:
|
|
138
|
+
type: object
|
|
139
|
+
additionalProperties: false
|
|
140
|
+
required: [id, expectedVersion]
|
|
141
|
+
properties:
|
|
142
|
+
id: { type: string }
|
|
143
|
+
expectedVersion: { type: integer }
|
|
144
|
+
name: { type: string, minLength: 1 }
|
|
145
|
+
note: { type: string }
|
|
146
|
+
output: { type: object }
|
|
147
|
+
handler:
|
|
148
|
+
sql: |
|
|
149
|
+
UPDATE products SET name = COALESCE(input.name, name), note = COALESCE(NULLIF(input.note, ''), note)
|
|
150
|
+
WHERE id = input.id AND version = input.expectedVersion
|
|
151
|
+
RETURNING id, version, name, note
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Create or add: ON CONFLICT DO UPDATE
|
|
155
|
+
|
|
156
|
+
Restocking a SKU creates the product the first time and adds to its stock afterwards, in one statement. The conflict
|
|
157
|
+
target must be a declared unique index (`uniqueIndexes: [[sku]]`). `excluded` is the row the INSERT proposed;
|
|
158
|
+
qualify the existing row with the table name (`products.stock`).
|
|
159
|
+
|
|
160
|
+
```yaml
|
|
161
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
162
|
+
kind: Procedure
|
|
163
|
+
metadata: { name: restock }
|
|
164
|
+
spec:
|
|
165
|
+
description: Add stock by SKU, creating the product the first time.
|
|
166
|
+
requires: { auth: { all: [ctx.admin] } }
|
|
167
|
+
input:
|
|
168
|
+
type: object
|
|
169
|
+
additionalProperties: false
|
|
170
|
+
required: [sku, name, price, qty]
|
|
171
|
+
properties:
|
|
172
|
+
sku: { type: string }
|
|
173
|
+
name: { type: string, minLength: 1 }
|
|
174
|
+
price: { type: integer, minimum: 0 }
|
|
175
|
+
qty: { type: integer, minimum: 1 }
|
|
176
|
+
output: { type: object }
|
|
177
|
+
handler:
|
|
178
|
+
sql: |
|
|
179
|
+
INSERT INTO products (sku, name, price, stock, reserved, active) VALUES (input.sku, input.name, input.price, input.qty, 0, true)
|
|
180
|
+
ON CONFLICT (sku) DO UPDATE SET stock = products.stock + excluded.stock
|
|
181
|
+
RETURNING id, stock
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Refuse while something refers to it: NOT EXISTS
|
|
185
|
+
|
|
186
|
+
A product cannot be discontinued while an order for it waits for approval, since approving that order would reserve
|
|
187
|
+
stock of a product no longer sold. `NOT EXISTS (…)` is part of the guard, so while such an order exists the
|
|
188
|
+
row-classified UPDATE changes nothing and the call is `CONFLICT`; the product stays on sale. Use `EXISTS` the same way
|
|
189
|
+
for "only if a related row exists". [Purchasing](../../examples/purchasing/README.md) uses `NOT EXISTS` to write the
|
|
190
|
+
ledger row only for the request its program just approved (`submit-request`).
|
|
191
|
+
|
|
192
|
+
```yaml
|
|
193
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
194
|
+
kind: Procedure
|
|
195
|
+
metadata: { name: discontinue-product }
|
|
196
|
+
spec:
|
|
197
|
+
description: Stop selling a product; refused while an order for it waits for approval.
|
|
198
|
+
requires: { auth: { all: [ctx.admin] } }
|
|
199
|
+
input:
|
|
200
|
+
type: object
|
|
201
|
+
additionalProperties: false
|
|
202
|
+
required: [id]
|
|
203
|
+
properties:
|
|
204
|
+
id: { type: string }
|
|
205
|
+
output: { type: object }
|
|
206
|
+
handler:
|
|
207
|
+
sql: |
|
|
208
|
+
UPDATE products SET active = false
|
|
209
|
+
WHERE id = input.id
|
|
210
|
+
AND NOT EXISTS (SELECT 1 FROM orders o WHERE o.productId = products.id AND o.orderStatus = 'pending_approval')
|
|
211
|
+
RETURNING id
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Create only if the parent exists: onNoRows
|
|
215
|
+
|
|
216
|
+
`place-order` above writes no order for an unknown or discontinued product and still succeeds: its result is
|
|
217
|
+
`results[0] = []`, and the caller must look. That is right when "nothing to do" is a normal outcome. When writing no
|
|
218
|
+
row means the request failed, say so: write the handler as `steps`, one statement each, and give the statement that
|
|
219
|
+
must write `onNoRows: conflict`. A step marked so that writes no row fails the call with `CONFLICT` (409) kind
|
|
220
|
+
`"row"`, as a row-classified statement does, and the whole program rolls back, including steps that ran before it.
|
|
221
|
+
|
|
222
|
+
```yaml
|
|
223
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
224
|
+
kind: Procedure
|
|
225
|
+
metadata: { name: request-order }
|
|
226
|
+
spec:
|
|
227
|
+
description: Ask for an order of a product that is still sold; it waits for approval. Refused (409) for an unknown or discontinued product.
|
|
228
|
+
requires: { auth: { all: [ctx.user] } }
|
|
229
|
+
input:
|
|
230
|
+
type: object
|
|
231
|
+
additionalProperties: false
|
|
232
|
+
required: [productId, qty]
|
|
233
|
+
properties:
|
|
234
|
+
productId: { type: string }
|
|
235
|
+
qty: { type: integer, minimum: 1 }
|
|
236
|
+
output: { type: object }
|
|
237
|
+
handler:
|
|
238
|
+
steps:
|
|
239
|
+
- sql: |
|
|
240
|
+
INSERT INTO orders (productId, qty, total, orderStatus)
|
|
241
|
+
SELECT p.id, input.qty, input.qty * p.price, 'pending_approval'
|
|
242
|
+
FROM products p WHERE p.id = input.productId AND p.active
|
|
243
|
+
RETURNING id
|
|
244
|
+
onNoRows: conflict
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`steps` is the same program as `sql`, one statement per step, run in order in the same batch with the same checks:
|
|
248
|
+
`handler: { sql }` and `handler: { steps }` are alternatives, and a step without `onNoRows` behaves exactly as that
|
|
249
|
+
statement does in `sql`. A further step, such as `place-order`'s conditional reservation, is another list item
|
|
250
|
+
(`- sql: UPDATE products SET reserved = …`) without `onNoRows` when writing nothing is fine.
|
|
251
|
+
|
|
252
|
+
`WHERE EXISTS (SELECT 1 FROM products WHERE id = input.productId)` after a `SELECT` of input values works the same
|
|
253
|
+
way when the new row needs nothing from the parent. Use `onNoRows` on a set op only: an `INSERT … SELECT`, an
|
|
254
|
+
`INSERT … ON CONFLICT`, or an UPDATE or DELETE that does not pin `id`. A one-row `INSERT … VALUES` or a
|
|
255
|
+
`WHERE id = input.id` statement already must change exactly one row, and generation refuses `onNoRows` on it. The
|
|
256
|
+
check is "at least one", so a statement that writes several rows (`json_each(input.items)`, an UPDATE over a set)
|
|
257
|
+
passes; give an array input `minItems: 1` when an empty one is a mistake. A ref handler throws its own
|
|
258
|
+
`ContractError` instead.
|
|
259
|
+
|
|
260
|
+
## A row with its children in one read
|
|
261
|
+
|
|
262
|
+
An admin, or an AI client, reads each product with its orders in one call instead of one call per product. A
|
|
263
|
+
correlated subquery builds the array, so a product without orders has `[]`; `LEFT JOIN … GROUP BY` would give it one
|
|
264
|
+
empty object instead.
|
|
265
|
+
|
|
266
|
+
```yaml
|
|
267
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
268
|
+
kind: View
|
|
269
|
+
metadata: { name: products-with-orders }
|
|
270
|
+
spec:
|
|
271
|
+
description: Each product with its orders as one JSON array.
|
|
272
|
+
surface: admin
|
|
273
|
+
requires: { auth: { all: [ctx.admin] } }
|
|
274
|
+
sql: |
|
|
275
|
+
SELECT p.id, p.name,
|
|
276
|
+
(SELECT json_group_array(json_set('{}', '$.id', o.id, '$.qty', o.qty)) FROM orders o WHERE o.productId = p.id) AS orders
|
|
277
|
+
FROM products p ORDER BY p.id
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
SQLite and D1 build each object with `json_set('{}', '$.id', o.id, …)` (`json_object` is not on the allowlist).
|
|
281
|
+
PostgreSQL writes it as `COALESCE(jsonb_agg(jsonb_build_object('id', o.id, 'qty', o.qty)), CAST('[]' AS jsonb))`;
|
|
282
|
+
`jsonb_agg` of no rows is `NULL`, hence the `COALESCE`. PostgreSQL can also order inside the aggregate
|
|
283
|
+
(`jsonb_agg(… ORDER BY o.id)`); SQLite cannot, so sort the array.
|
|
284
|
+
|
|
285
|
+
Generation types the column as JSON because its expression can only produce a JSON document: a `json_group_array`,
|
|
286
|
+
`json_group_object`, `json_set`, `json_insert`, `json_remove`, `jsonb_agg`, `jsonb_object_agg`, `jsonb_build_object`,
|
|
287
|
+
`jsonb_build_array`, `jsonb_strip_nulls`, `to_jsonb` or `CAST(… AS jsonb)` call, a scalar subquery of one, or a
|
|
288
|
+
`COALESCE` of those with JSON literal fallbacks such as `'[]'`. REST, MCP (`structuredContent`) and Admin therefore
|
|
289
|
+
return an array on every target, and `WireRow` types the column `unknown`. The bare View and the Store keep the
|
|
290
|
+
driver's value: on SQLite and D1 that is JSON text, so code calling the bare View parses it. `json_extract`, `->>`
|
|
291
|
+
and `CASE` can return a scalar, so their output stays native.
|
|
292
|
+
|
|
293
|
+
## The latest row of each group: row_number()
|
|
294
|
+
|
|
295
|
+
The most recent order of each product. `row_number() OVER (PARTITION BY … ORDER BY …)` numbers each product's orders
|
|
296
|
+
newest first, and the outer query keeps number 1. End the window's ORDER BY with `id` so two rows created in the same
|
|
297
|
+
instant still have one order. `rank()` instead gives tied rows the same number.
|
|
298
|
+
|
|
299
|
+
```yaml
|
|
300
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
301
|
+
kind: View
|
|
302
|
+
metadata: { name: latest-order-per-product }
|
|
303
|
+
spec:
|
|
304
|
+
description: The most recent order of each product.
|
|
305
|
+
surface: admin
|
|
306
|
+
requires: { auth: { all: [ctx.admin] } }
|
|
307
|
+
sql: |
|
|
308
|
+
SELECT x.productId AS "productId", x.id, x.qty FROM (
|
|
309
|
+
SELECT o.productId, o.id, o.qty, row_number() OVER (PARTITION BY o.productId ORDER BY o.created_at DESC, o.id DESC) AS n
|
|
310
|
+
FROM orders o
|
|
311
|
+
) x WHERE x.n = 1 ORDER BY x.productId
|
|
312
|
+
```
|
|
@@ -83,11 +83,16 @@ CREATE INDEX articles_title_trgm ON articles USING gin (title gin_trgm_ops);
|
|
|
83
83
|
|
|
84
84
|
Index each searched field as needed and inspect EXPLAIN for the workload. Case folding follows the engine.
|
|
85
85
|
|
|
86
|
-
Writes are lazy and execute once when awaited. Insert/update return native rows; delete returns void. `update(id, set, { lock })` and `delete(id, { lock })` require the version the caller reviewed. A stale lock, excluded row or unique clash is `CONFLICT`. `batch` atomically executes unused Writes from one Store in input order; a step cannot use a prior result.
|
|
86
|
+
Writes are lazy and execute once when awaited. Insert/update return native rows; delete returns void. Store rows carry `version` as a number on every target: PostgreSQL's int8 text is converted, and a value beyond `Number.MAX_SAFE_INTEGER` is `SQL_TYPE` rather than rounded. `update(id, set, { lock })` and `delete(id, { lock })` require the version the caller reviewed, as a number or an exact integer string. A stale lock, excluded row or unique clash is `CONFLICT`. A definitive PostgreSQL serialization failure (`40001`) or deadlock (`40P01`) is also `CONFLICT`, with `kind: "serialization"` and the native error as `cause`; the write was rolled back. This includes a server refusal at COMMIT. A missing/uncertain response remains `OUTCOME_UNKNOWN`. Re-read before deciding whether to retry; there is no automatic retry. `batch` atomically executes unused Writes from one Store in input order; a step cannot use a prior result.
|
|
87
87
|
|
|
88
|
-
Interactive `transaction(async tx => …)` exists on bun:sqlite and PostgreSQL, with collections, Views and Procedures bound to the transaction. D1 supports batches only. PostgreSQL defaults to serializable, with an explicit isolation override; SQLite uses BEGIN IMMEDIATE. Use `tx` inside the callback, not the outer Store or handle. Nested transactions
|
|
88
|
+
Interactive `transaction(async tx => …)` exists on bun:sqlite and PostgreSQL, with collections, Views and Procedures bound to the transaction. D1 supports batches only. PostgreSQL defaults to serializable, with an explicit isolation override; SQLite uses BEGIN IMMEDIATE. Use `tx` inside the callback, not the outer Store or handle. Nested transactions are refused; there is no automatic retry. `tx.batch` joins the outer transaction, with no savepoint.
|
|
89
|
+
A rejected batch marks the whole outer transaction rollback-only, including when its error is caught: the transaction
|
|
90
|
+
rejects with the first batch failure and rolls back all its writes. This includes invalid/reused/foreign Writes and
|
|
91
|
+
errors while building or executing the batch.
|
|
89
92
|
|
|
90
|
-
Ref handlers receive `{ caller, db, store }` bound to the same Caller and transaction.
|
|
93
|
+
Ref handlers receive `{ caller, db, store }` bound to the same Caller and transaction. `ctx.store.batch` is available
|
|
94
|
+
on every target: a native atomic batch standalone, or part of the same outer transaction when one exists. Use it for
|
|
95
|
+
known multi-writes rather than constructing another Store inside a handler. Implement the generated `Handlers` interface and pass it to `store(db, caller, { handlers })`. Direct Procedure modules expose SQL Procedures; ref Procedures are reached through the bound Store or selected surfaces.
|
|
91
96
|
|
|
92
97
|
See [native execution](native-execution.md) for values and write outcomes and [migrations](../concepts/runtime-and-adapters.md#schema-installation-and-migrations) before changing a Schema.
|
|
93
98
|
|
|
@@ -125,3 +130,141 @@ step's returned ID. D1 has no interactive transaction callback, so retain the
|
|
|
125
130
|
supported SQL program or another existing native atomic boundary for that case.
|
|
126
131
|
The [MCP design audit](agent-workflow-acceptance.md) applies these choices before
|
|
127
132
|
implementation and separates them from independent end-user acceptance.
|
|
133
|
+
|
|
134
|
+
## Several rows that commit together
|
|
135
|
+
|
|
136
|
+
An order with any number of lines, a cart checkout or a bulk import must commit
|
|
137
|
+
every row or none. Pass the rows as one array input and write them in one SQL
|
|
138
|
+
Procedure that reads the array as a row source: `json_each(input.items)` on
|
|
139
|
+
SQLite and D1, `jsonb_array_elements(input.items) AS j(value)` on PostgreSQL.
|
|
140
|
+
The Procedure's statements commit together on every target, so a Schema
|
|
141
|
+
`checks` failure in any statement refuses the whole call with `CONFLICT` and
|
|
142
|
+
leaves nothing behind. Bound the array with `maxItems`.
|
|
143
|
+
|
|
144
|
+
A statement cannot read an earlier statement's `RETURNING`, so rows that must
|
|
145
|
+
refer to each other share a key from the input: here the caller (or a `ref`
|
|
146
|
+
handler that then calls this Procedure) supplies `orderRef`. A later statement
|
|
147
|
+
does see the rows an earlier one wrote, so it can also store the parent's id
|
|
148
|
+
(`(SELECT o.id FROM orders o WHERE o.orderRef = input.orderRef)`) in a field with
|
|
149
|
+
`x-mantle-ref: orders`, which lets Admin fold the lines under their order.
|
|
150
|
+
|
|
151
|
+
```yaml
|
|
152
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
153
|
+
kind: Schema
|
|
154
|
+
metadata: { name: products }
|
|
155
|
+
spec:
|
|
156
|
+
title: Products
|
|
157
|
+
lifecycle: operational
|
|
158
|
+
checks: ["reserved <= stock"]
|
|
159
|
+
schema:
|
|
160
|
+
type: object
|
|
161
|
+
required: [name, stock, reserved]
|
|
162
|
+
properties:
|
|
163
|
+
name: { type: string }
|
|
164
|
+
stock: { type: integer, minimum: 0 }
|
|
165
|
+
reserved: { type: integer, minimum: 0 }
|
|
166
|
+
---
|
|
167
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
168
|
+
kind: Schema
|
|
169
|
+
metadata: { name: orders }
|
|
170
|
+
spec:
|
|
171
|
+
title: Orders
|
|
172
|
+
lifecycle: operational
|
|
173
|
+
uniqueIndexes: [[orderRef]]
|
|
174
|
+
checks: ["matchedLines = lineCount"]
|
|
175
|
+
schema:
|
|
176
|
+
type: object
|
|
177
|
+
required: [orderRef, lineCount, matchedLines]
|
|
178
|
+
properties:
|
|
179
|
+
orderRef: { type: string }
|
|
180
|
+
lineCount: { type: integer, minimum: 1 }
|
|
181
|
+
matchedLines: { type: integer, minimum: 0 }
|
|
182
|
+
---
|
|
183
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
184
|
+
kind: Schema
|
|
185
|
+
metadata: { name: order_lines }
|
|
186
|
+
spec:
|
|
187
|
+
title: Order lines
|
|
188
|
+
lifecycle: operational
|
|
189
|
+
schema:
|
|
190
|
+
type: object
|
|
191
|
+
required: [orderRef, productId, qty]
|
|
192
|
+
properties:
|
|
193
|
+
orderRef: { type: string }
|
|
194
|
+
productId: { type: string, x-mantle-ref: products }
|
|
195
|
+
qty: { type: integer, minimum: 1 }
|
|
196
|
+
---
|
|
197
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
198
|
+
kind: Procedure
|
|
199
|
+
metadata: { name: place-order }
|
|
200
|
+
spec:
|
|
201
|
+
description: Place an order of one or more lines; an unknown product or a line that would oversell refuses the whole order.
|
|
202
|
+
requires: { auth: { all: [ctx.user] } }
|
|
203
|
+
input:
|
|
204
|
+
type: object
|
|
205
|
+
additionalProperties: false
|
|
206
|
+
required: [orderRef, items]
|
|
207
|
+
properties:
|
|
208
|
+
orderRef: { type: string }
|
|
209
|
+
items:
|
|
210
|
+
type: array
|
|
211
|
+
minItems: 1
|
|
212
|
+
maxItems: 50
|
|
213
|
+
items:
|
|
214
|
+
type: object
|
|
215
|
+
additionalProperties: false
|
|
216
|
+
required: [productId, qty]
|
|
217
|
+
properties:
|
|
218
|
+
productId: { type: string }
|
|
219
|
+
qty: { type: integer, minimum: 1 }
|
|
220
|
+
output: { type: object }
|
|
221
|
+
handler:
|
|
222
|
+
sql: |
|
|
223
|
+
INSERT INTO orders (orderRef, lineCount, matchedLines) VALUES (input.orderRef,
|
|
224
|
+
(SELECT COUNT(*) FROM json_each(input.items) j),
|
|
225
|
+
(SELECT COUNT(*) FROM json_each(input.items) j JOIN products p ON p.id = j.value ->> 'productId'));
|
|
226
|
+
UPDATE products SET reserved = reserved + (SELECT SUM(j.value ->> 'qty') FROM json_each(input.items) j WHERE j.value ->> 'productId' = products.id)
|
|
227
|
+
WHERE id IN (SELECT j.value ->> 'productId' FROM json_each(input.items) j);
|
|
228
|
+
INSERT INTO order_lines (orderRef, productId, qty)
|
|
229
|
+
SELECT input.orderRef, p.id, j.value ->> 'qty' FROM json_each(input.items) j JOIN products p ON p.id = j.value ->> 'productId'
|
|
230
|
+
RETURNING id, productId AS "productId";
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Each refusal is a check or a unique index, so it rolls back all three statements:
|
|
234
|
+
|
|
235
|
+
- A line that would oversell fails `reserved <= stock`. Repeated lines for one
|
|
236
|
+
product add up in the `SUM`, so they are checked against the same stock.
|
|
237
|
+
- A line naming an unknown product does not match in the join, so
|
|
238
|
+
`matchedLines` falls short of `lineCount` and the `orders` check fails. Set
|
|
239
|
+
operations alone would skip that line and still succeed.
|
|
240
|
+
- A repeated `orderRef` clashes with the unique index, so replaying a request
|
|
241
|
+
cannot reserve twice.
|
|
242
|
+
|
|
243
|
+
On PostgreSQL `->>` returns text: write `CAST(j.value ->> 'qty' AS int8)`
|
|
244
|
+
there. SQLite and D1 refuse that integer cast (`SQL_TYPE`, because SQLite
|
|
245
|
+
truncates where PostgreSQL rounds), and their `->>` already returns the JSON
|
|
246
|
+
number.
|
|
247
|
+
|
|
248
|
+
[SQL patterns](sql-patterns.md) covers approving before reserving, choosing a status with `CASE`, partial updates,
|
|
249
|
+
upserts, `EXISTS` guards and nested reads on the same `products`.
|
|
250
|
+
|
|
251
|
+
Do not split this into a Store write, then a SQL Procedure, then a compensating
|
|
252
|
+
delete: the rows are visible between the steps and a failure leaves cleanup to
|
|
253
|
+
the handler. A `ref` handler that needs the rows written together calls this
|
|
254
|
+
Procedure through `ctx.store.procedures`, passing the array itself: the
|
|
255
|
+
generated input type is the authored one, and the generated query serializes
|
|
256
|
+
the array for the driver (a string is bound as it is). Like a direct call, the
|
|
257
|
+
Store does not validate input against the Procedure's schema (REST and MCP do),
|
|
258
|
+
so `maxItems` and the line shape hold only for rows the handler has checked or
|
|
259
|
+
built.
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
import { store } from './generated/generated.js';
|
|
263
|
+
import type { Caller } from '@aotter/mantle/contracts';
|
|
264
|
+
|
|
265
|
+
// `ctx.store` in a `ref` handler is this Store: `ctx.store.procedures['place-order']({ orderRef, items })`.
|
|
266
|
+
export async function placeOrder(db: Parameters<typeof store>[0], caller: Caller, orderRef: string) {
|
|
267
|
+
const items = [{ productId: 'pen', qty: 2 }, { productId: 'stapler', qty: 1 }];
|
|
268
|
+
return store(db, caller).procedures['place-order']({ orderRef, items });
|
|
269
|
+
}
|
|
270
|
+
```
|
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
{
|
|
5
5
|
"text": "Start here",
|
|
6
6
|
"items": [
|
|
7
|
+
{
|
|
8
|
+
"text": "Build with an agent",
|
|
9
|
+
"link": "/start/agents"
|
|
10
|
+
},
|
|
7
11
|
{
|
|
8
12
|
"text": "Handbook overview",
|
|
9
13
|
"link": "/start/overview"
|
|
@@ -33,6 +37,18 @@
|
|
|
33
37
|
"text": "Public pages: SEO, AEO and languages",
|
|
34
38
|
"link": "/guides/public-pages"
|
|
35
39
|
},
|
|
40
|
+
{
|
|
41
|
+
"text": "Local HTTP checks",
|
|
42
|
+
"link": "/guides/local-testing"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"text": "SQL patterns",
|
|
46
|
+
"link": "/guides/sql-patterns"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"text": "Edit published content without downtime",
|
|
50
|
+
"link": "/guides/revisions"
|
|
51
|
+
},
|
|
36
52
|
{
|
|
37
53
|
"text": "Native Admin",
|
|
38
54
|
"link": "/guides/native-admin"
|
|
@@ -131,6 +147,10 @@
|
|
|
131
147
|
"text": "Trigger",
|
|
132
148
|
"link": "/reference/trigger"
|
|
133
149
|
},
|
|
150
|
+
{
|
|
151
|
+
"text": "SQL accepted by each dialect",
|
|
152
|
+
"link": "/reference/sql"
|
|
153
|
+
},
|
|
134
154
|
{
|
|
135
155
|
"text": "Authorization requirements",
|
|
136
156
|
"link": "/reference/authorization"
|
|
@@ -37,7 +37,10 @@ execution plan or SQL AST, storage access or execution observer is included.
|
|
|
37
37
|
admin tool names. A conflicting declared admin tool is refused.
|
|
38
38
|
- `webmcp` carries static tool declarations and Admin page routes. `bootstrap`
|
|
39
39
|
combines `collections`, `operations`, `views` and `webmcp`; the handler adds
|
|
40
|
-
request-owned `me`, `site` and any selected entries page.
|
|
40
|
+
request-owned `me`, `site` and any selected entries page. Its
|
|
41
|
+
`developerConsole` is a loader for the same snapshot, generated again in
|
|
42
|
+
`admin-developer-console.ts`, so Admin imports it on a developer-console
|
|
43
|
+
request instead of at Worker startup.
|
|
41
44
|
|
|
42
45
|
Generation reuses the 0.2 projection and SQL-logic helpers. View columns come
|
|
43
46
|
from the compiled plan (Core's `viewOutputs` provenance, including aliases,
|