@aotter/mantle 0.2.0-alpha.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- 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 +3 -6896
- package/dist/build/spec/domain/model/SqlIr.d.ts +2 -0
- package/dist/build/spec/domain/model/UserFields.d.ts +1 -0
- 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-SGMRVDGE.js +1 -0
- package/dist/domain/describe/index.js +1 -115
- 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 +10 -7
- 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 +2 -30
- package/dist/infrastructure/cli.js +25 -11
- package/dist/infrastructure/generate.js +9 -16
- 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 +16 -4
- package/dist/kernel/binds.js +4 -10
- 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 +6 -10
- package/dist/kernel/contracts.js +5 -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 +0 -4
- package/dist/kernel/driver/d1.js +0 -1
- package/dist/kernel/driver/postgres.js +2 -17
- 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 +2 -26
- 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 +2 -3
- package/dist/kernel/rest.js +0 -3
- 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 +11 -1
- package/dist/kernel/store.js +21 -34
- 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 +30 -21
- package/dist/usecase/adminClient.js +0 -21
- package/dist/usecase/adminDescriptors.js +2 -9
- package/dist/usecase/generate.js +7 -12
- package/dist/usecase/handlers.js +0 -3
- 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 +0 -4
- package/dist/usecase/rest.js +0 -1
- package/dist/usecase/site.js +0 -1
- package/dist/usecase/stores.js +4 -14
- 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 +38 -7
- package/docs/examples/app-shell/accept.mjs +91 -0
- package/docs/examples/app-shell/gitignore +7 -0
- package/docs/examples/app-shell/package.json +7 -6
- 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 +15 -0
- package/docs/examples/club-events/smoke.mjs +7 -1
- package/docs/examples/purchasing/README.md +12 -3
- package/docs/examples/purchasing/manifests/purchasing.yaml +21 -4
- package/docs/examples/purchasing/smoke.mjs +14 -0
- package/docs/examples/recipes/README.md +5 -3
- package/docs/handbook/cloudflare/service-entry.md +3 -0
- package/docs/handbook/examples/hub.md +1 -1
- package/docs/handbook/guides/agent-setup.md +15 -10
- package/docs/handbook/guides/agent-workflow-acceptance.md +113 -103
- package/docs/handbook/guides/local-testing.md +40 -0
- package/docs/handbook/guides/native-admin.md +25 -6
- package/docs/handbook/guides/native-execution.md +5 -4
- package/docs/handbook/guides/public-pages.md +44 -4
- package/docs/handbook/guides/sql-patterns.md +266 -0
- package/docs/handbook/guides/typed-queries.md +138 -0
- package/docs/handbook/navigation.json +16 -0
- package/docs/handbook/reference/admin-descriptors.md +4 -1
- package/docs/handbook/reference/authorization.md +1 -1
- package/docs/handbook/reference/diagnostics.md +6 -1
- package/docs/handbook/reference/procedure.md +5 -5
- package/docs/handbook/reference/schema.md +10 -4
- package/docs/handbook/reference/sql.md +64 -0
- package/docs/handbook/reference/surface.md +5 -2
- package/docs/handbook/reference/view.md +11 -3
- package/docs/handbook/releases/index.md +27 -6
- package/docs/handbook/start/agents.md +45 -0
- package/docs/handbook/start/project-and-cli.md +6 -0
- package/docs/handbook/start/quickstart-worker.md +1 -1
- package/docs/skills/develop/SKILL.md +6 -5
- package/docs/skills/update/SKILL.md +2 -0
- package/package.json +23 -3
- package/skills/README.md +1 -1
- package/skills/mantle/SKILL.md +39 -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
|
@@ -11,6 +11,12 @@ here instead of repeating it.
|
|
|
11
11
|
- **App-owned pages** in `src/routes.ts`, when the page needs its own layout, JSON-LD or languages. The reference
|
|
12
12
|
implementation is `node_modules/@aotter/mantle/docs/examples/recipes` (`src/pages.ts`, `src/routes.ts`; `smoke.mjs` checks every item below).
|
|
13
13
|
|
|
14
|
+
## URLs
|
|
15
|
+
|
|
16
|
+
Mantle owns `/admin`, `/mcp`, `/api/auth/`, `/oauth/consent` and `/.well-known/oauth-*`, and the app's HTTP Triggers own
|
|
17
|
+
their declared paths. Give visitor pages their own prefix (`/{locale}/…`, `/teas/{slug}`), or make a root page (`/{slug}`)
|
|
18
|
+
return nothing for those paths; the app shell already keeps `/admin` and `/mcp` away from `src/routes.ts`.
|
|
19
|
+
|
|
14
20
|
## Every public page
|
|
15
21
|
|
|
16
22
|
- Server-rendered HTML per URL with the content in the body. A client-only SPA shell or metadata set by script is not
|
|
@@ -26,7 +32,9 @@ here instead of repeating it.
|
|
|
26
32
|
|
|
27
33
|
- JSON-LD of the matching schema.org type (`Recipe`, `Article`, `Event`, `Product`, ...), built from the same row as
|
|
28
34
|
the visible content: `name`, `description`, `url`, `inLanguage`, `datePublished`, `dateModified`, and `image` when
|
|
29
|
-
there is one (some rich results require it).
|
|
35
|
+
there is one (some rich results require it). A type that has no date or language properties (`Product`) gets them on
|
|
36
|
+
a page node instead: an `@graph` with an `ItemPage` (`inLanguage`, dates, `mainEntity`) pointing to the `Product`
|
|
37
|
+
(`offers` with `price` and `priceCurrency`). Escape `<` in the serialized JSON so stored text cannot close the
|
|
30
38
|
script element. Do not add facts the page does not show.
|
|
31
39
|
- A short answer block for answer engines: one question heading and a one-sentence answer from the row's data.
|
|
32
40
|
- A Markdown alternate at `<url>.md`, linked with `<link rel="alternate" type="text/markdown">`.
|
|
@@ -46,8 +54,40 @@ here instead of repeating it.
|
|
|
46
54
|
- `/llms.txt` ([llmstxt.org](https://llmstxt.org)): a title, a one-line summary and a link to each item's Markdown
|
|
47
55
|
alternate.
|
|
48
56
|
|
|
57
|
+
## A complete site
|
|
58
|
+
|
|
59
|
+
When the brief names a site, shop, catalogue or blog for visitors, these are part of the work without the user
|
|
60
|
+
listing them:
|
|
61
|
+
|
|
62
|
+
- Every item on this page, for every public page type the site has.
|
|
63
|
+
- Accessible, responsive pages with the app's own styling; the examples are unstyled on purpose.
|
|
64
|
+
- Content managed in Admin (draft, publish, edit, unpublish) shows on the site without a rebuild. Images go through
|
|
65
|
+
Admin's media library (`mantle help media-r2`); store committed asset IDs and never use an ID as an image URL.
|
|
66
|
+
- Members' pages, if any, send anonymous visitors to `/sign-in` (`signInRedirect` in the app shell's `src/sign-in.ts`).
|
|
67
|
+
- Who runs the site: an About/contact page and a footer with the business's name, location and contact, and on the
|
|
68
|
+
home page `Organization` (or `LocalBusiness`) JSON-LD with the same facts and the logo. Answer engines use these to
|
|
69
|
+
say whether the site is legitimate. Ask for the facts the brief lacks; never invent an address.
|
|
70
|
+
- A favicon, and `og:image` on the home and list pages too.
|
|
71
|
+
- A detail page leads to the action the brief sells (order, register, book), saying when it needs an account.
|
|
72
|
+
- A cache policy: `Last-Modified` (from the row's `updated_at`) or an `ETag` on pages, `.md`, sitemap and `llms.txt`,
|
|
73
|
+
so crawlers can revalidate; `no-store` everywhere is safe but makes every crawl a full fetch.
|
|
74
|
+
- Structured data and answer blocks state only what the page shows. Never promise ranking or AI citation.
|
|
75
|
+
|
|
49
76
|
## Check before you report done
|
|
50
77
|
|
|
51
|
-
Request
|
|
52
|
-
|
|
53
|
-
|
|
78
|
+
Request each page without running JavaScript (plain `fetch` in `smoke.mjs`; `mantle help local-testing`) and assert:
|
|
79
|
+
|
|
80
|
+
1. HTML content type and the visible content; `<title>`, description, canonical on `PUBLIC_ORIGIN`, Open Graph; on
|
|
81
|
+
detail pages JSON-LD that parses with the visible name, and the answer block.
|
|
82
|
+
2. Each `.md` alternate returns `text/markdown` with the current text.
|
|
83
|
+
3. Walk the list View's complete cursor chain: its rows equal the `llms.txt` entries and the sitemap's detail URLs
|
|
84
|
+
(below the generated `web()` caps: 5,000 rows for `llms.txt`, 50,000 for the sitemap). `/robots.txt` names the
|
|
85
|
+
sitemap.
|
|
86
|
+
4. An unknown URL, a malformed path, a draft and a row the visitor may not read are 404, also with an admin session.
|
|
87
|
+
5. Publish, edit and unpublish an item through Admin's API (or the Procedure Admin uses); after each, its page,
|
|
88
|
+
`.md`, sitemap and `llms.txt` follow.
|
|
89
|
+
6. Stored text with `<`, `&` and `</script>` renders escaped in HTML and inside JSON-LD.
|
|
90
|
+
|
|
91
|
+
Report a check you could not run (for example a production origin you do not have) as UNVERIFIED with the reason.
|
|
92
|
+
Then run [independent acceptance](agent-workflow-acceptance.md) with a visitor evaluator in a browser on the
|
|
93
|
+
`pnpm accept` URL.
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Seven 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
|
+
## A row with its children in one read
|
|
215
|
+
|
|
216
|
+
An admin, or an AI client, reads each product with its orders in one call instead of one call per product. A
|
|
217
|
+
correlated subquery builds the array, so a product without orders has `[]`; `LEFT JOIN … GROUP BY` would give it one
|
|
218
|
+
empty object instead.
|
|
219
|
+
|
|
220
|
+
```yaml
|
|
221
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
222
|
+
kind: View
|
|
223
|
+
metadata: { name: products-with-orders }
|
|
224
|
+
spec:
|
|
225
|
+
description: Each product with its orders as one JSON array.
|
|
226
|
+
surface: admin
|
|
227
|
+
requires: { auth: { all: [ctx.admin] } }
|
|
228
|
+
sql: |
|
|
229
|
+
SELECT p.id, p.name,
|
|
230
|
+
(SELECT json_group_array(json_set('{}', '$.id', o.id, '$.qty', o.qty)) FROM orders o WHERE o.productId = p.id) AS orders
|
|
231
|
+
FROM products p ORDER BY p.id
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
SQLite and D1 build each object with `json_set('{}', '$.id', o.id, …)` (`json_object` is not on the allowlist).
|
|
235
|
+
PostgreSQL writes it as `COALESCE(jsonb_agg(jsonb_build_object('id', o.id, 'qty', o.qty)), CAST('[]' AS jsonb))`;
|
|
236
|
+
`jsonb_agg` of no rows is `NULL`, hence the `COALESCE`. PostgreSQL can also order inside the aggregate
|
|
237
|
+
(`jsonb_agg(… ORDER BY o.id)`); SQLite cannot, so sort the array.
|
|
238
|
+
|
|
239
|
+
Generation types the column as JSON because its expression can only produce a JSON document: a `json_group_array`,
|
|
240
|
+
`json_group_object`, `json_set`, `json_insert`, `json_remove`, `jsonb_agg`, `jsonb_object_agg`, `jsonb_build_object`,
|
|
241
|
+
`jsonb_build_array`, `jsonb_strip_nulls`, `to_jsonb` or `CAST(… AS jsonb)` call, a scalar subquery of one, or a
|
|
242
|
+
`COALESCE` of those with JSON literal fallbacks such as `'[]'`. REST, MCP (`structuredContent`) and Admin therefore
|
|
243
|
+
return an array on every target, and `WireRow` types the column `unknown`. The bare View and the Store keep the
|
|
244
|
+
driver's value: on SQLite and D1 that is JSON text, so code calling the bare View parses it. `json_extract`, `->>`
|
|
245
|
+
and `CASE` can return a scalar, so their output stays native.
|
|
246
|
+
|
|
247
|
+
## The latest row of each group: row_number()
|
|
248
|
+
|
|
249
|
+
The most recent order of each product. `row_number() OVER (PARTITION BY … ORDER BY …)` numbers each product's orders
|
|
250
|
+
newest first, and the outer query keeps number 1. End the window's ORDER BY with `id` so two rows created in the same
|
|
251
|
+
instant still have one order. `rank()` instead gives tied rows the same number.
|
|
252
|
+
|
|
253
|
+
```yaml
|
|
254
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
255
|
+
kind: View
|
|
256
|
+
metadata: { name: latest-order-per-product }
|
|
257
|
+
spec:
|
|
258
|
+
description: The most recent order of each product.
|
|
259
|
+
surface: admin
|
|
260
|
+
requires: { auth: { all: [ctx.admin] } }
|
|
261
|
+
sql: |
|
|
262
|
+
SELECT x.productId AS "productId", x.id, x.qty FROM (
|
|
263
|
+
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
|
|
264
|
+
FROM orders o
|
|
265
|
+
) x WHERE x.n = 1 ORDER BY x.productId
|
|
266
|
+
```
|
|
@@ -125,3 +125,141 @@ step's returned ID. D1 has no interactive transaction callback, so retain the
|
|
|
125
125
|
supported SQL program or another existing native atomic boundary for that case.
|
|
126
126
|
The [MCP design audit](agent-workflow-acceptance.md) applies these choices before
|
|
127
127
|
implementation and separates them from independent end-user acceptance.
|
|
128
|
+
|
|
129
|
+
## Several rows that commit together
|
|
130
|
+
|
|
131
|
+
An order with any number of lines, a cart checkout or a bulk import must commit
|
|
132
|
+
every row or none. Pass the rows as one array input and write them in one SQL
|
|
133
|
+
Procedure that reads the array as a row source: `json_each(input.items)` on
|
|
134
|
+
SQLite and D1, `jsonb_array_elements(input.items) AS j(value)` on PostgreSQL.
|
|
135
|
+
The Procedure's statements commit together on every target, so a Schema
|
|
136
|
+
`checks` failure in any statement refuses the whole call with `CONFLICT` and
|
|
137
|
+
leaves nothing behind. Bound the array with `maxItems`.
|
|
138
|
+
|
|
139
|
+
A statement cannot read an earlier statement's `RETURNING`, so rows that must
|
|
140
|
+
refer to each other share a key from the input: here the caller (or a `ref`
|
|
141
|
+
handler that then calls this Procedure) supplies `orderRef`. A later statement
|
|
142
|
+
does see the rows an earlier one wrote, so it can also store the parent's id
|
|
143
|
+
(`(SELECT o.id FROM orders o WHERE o.orderRef = input.orderRef)`) in a field with
|
|
144
|
+
`x-mantle-ref: orders`, which lets Admin fold the lines under their order.
|
|
145
|
+
|
|
146
|
+
```yaml
|
|
147
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
148
|
+
kind: Schema
|
|
149
|
+
metadata: { name: products }
|
|
150
|
+
spec:
|
|
151
|
+
title: Products
|
|
152
|
+
lifecycle: operational
|
|
153
|
+
checks: ["reserved <= stock"]
|
|
154
|
+
schema:
|
|
155
|
+
type: object
|
|
156
|
+
required: [name, stock, reserved]
|
|
157
|
+
properties:
|
|
158
|
+
name: { type: string }
|
|
159
|
+
stock: { type: integer, minimum: 0 }
|
|
160
|
+
reserved: { type: integer, minimum: 0 }
|
|
161
|
+
---
|
|
162
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
163
|
+
kind: Schema
|
|
164
|
+
metadata: { name: orders }
|
|
165
|
+
spec:
|
|
166
|
+
title: Orders
|
|
167
|
+
lifecycle: operational
|
|
168
|
+
uniqueIndexes: [[orderRef]]
|
|
169
|
+
checks: ["matchedLines = lineCount"]
|
|
170
|
+
schema:
|
|
171
|
+
type: object
|
|
172
|
+
required: [orderRef, lineCount, matchedLines]
|
|
173
|
+
properties:
|
|
174
|
+
orderRef: { type: string }
|
|
175
|
+
lineCount: { type: integer, minimum: 1 }
|
|
176
|
+
matchedLines: { type: integer, minimum: 0 }
|
|
177
|
+
---
|
|
178
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
179
|
+
kind: Schema
|
|
180
|
+
metadata: { name: order_lines }
|
|
181
|
+
spec:
|
|
182
|
+
title: Order lines
|
|
183
|
+
lifecycle: operational
|
|
184
|
+
schema:
|
|
185
|
+
type: object
|
|
186
|
+
required: [orderRef, productId, qty]
|
|
187
|
+
properties:
|
|
188
|
+
orderRef: { type: string }
|
|
189
|
+
productId: { type: string, x-mantle-ref: products }
|
|
190
|
+
qty: { type: integer, minimum: 1 }
|
|
191
|
+
---
|
|
192
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
193
|
+
kind: Procedure
|
|
194
|
+
metadata: { name: place-order }
|
|
195
|
+
spec:
|
|
196
|
+
description: Place an order of one or more lines; an unknown product or a line that would oversell refuses the whole order.
|
|
197
|
+
requires: { auth: { all: [ctx.user] } }
|
|
198
|
+
input:
|
|
199
|
+
type: object
|
|
200
|
+
additionalProperties: false
|
|
201
|
+
required: [orderRef, items]
|
|
202
|
+
properties:
|
|
203
|
+
orderRef: { type: string }
|
|
204
|
+
items:
|
|
205
|
+
type: array
|
|
206
|
+
minItems: 1
|
|
207
|
+
maxItems: 50
|
|
208
|
+
items:
|
|
209
|
+
type: object
|
|
210
|
+
additionalProperties: false
|
|
211
|
+
required: [productId, qty]
|
|
212
|
+
properties:
|
|
213
|
+
productId: { type: string }
|
|
214
|
+
qty: { type: integer, minimum: 1 }
|
|
215
|
+
output: { type: object }
|
|
216
|
+
handler:
|
|
217
|
+
sql: |
|
|
218
|
+
INSERT INTO orders (orderRef, lineCount, matchedLines) VALUES (input.orderRef,
|
|
219
|
+
(SELECT COUNT(*) FROM json_each(input.items) j),
|
|
220
|
+
(SELECT COUNT(*) FROM json_each(input.items) j JOIN products p ON p.id = j.value ->> 'productId'));
|
|
221
|
+
UPDATE products SET reserved = reserved + (SELECT SUM(j.value ->> 'qty') FROM json_each(input.items) j WHERE j.value ->> 'productId' = products.id)
|
|
222
|
+
WHERE id IN (SELECT j.value ->> 'productId' FROM json_each(input.items) j);
|
|
223
|
+
INSERT INTO order_lines (orderRef, productId, qty)
|
|
224
|
+
SELECT input.orderRef, p.id, j.value ->> 'qty' FROM json_each(input.items) j JOIN products p ON p.id = j.value ->> 'productId'
|
|
225
|
+
RETURNING id, productId AS "productId";
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Each refusal is a check or a unique index, so it rolls back all three statements:
|
|
229
|
+
|
|
230
|
+
- A line that would oversell fails `reserved <= stock`. Repeated lines for one
|
|
231
|
+
product add up in the `SUM`, so they are checked against the same stock.
|
|
232
|
+
- A line naming an unknown product does not match in the join, so
|
|
233
|
+
`matchedLines` falls short of `lineCount` and the `orders` check fails. Set
|
|
234
|
+
operations alone would skip that line and still succeed.
|
|
235
|
+
- A repeated `orderRef` clashes with the unique index, so replaying a request
|
|
236
|
+
cannot reserve twice.
|
|
237
|
+
|
|
238
|
+
On PostgreSQL `->>` returns text: write `CAST(j.value ->> 'qty' AS int8)`
|
|
239
|
+
there. SQLite and D1 refuse that integer cast (`SQL_TYPE`, because SQLite
|
|
240
|
+
truncates where PostgreSQL rounds), and their `->>` already returns the JSON
|
|
241
|
+
number.
|
|
242
|
+
|
|
243
|
+
[SQL patterns](sql-patterns.md) covers approving before reserving, choosing a status with `CASE`, partial updates,
|
|
244
|
+
upserts, `EXISTS` guards and nested reads on the same `products`.
|
|
245
|
+
|
|
246
|
+
Do not split this into a Store write, then a SQL Procedure, then a compensating
|
|
247
|
+
delete: the rows are visible between the steps and a failure leaves cleanup to
|
|
248
|
+
the handler. A `ref` handler that needs the rows written together calls this
|
|
249
|
+
Procedure through `ctx.store.procedures`, passing the array itself: the
|
|
250
|
+
generated input type is the authored one, and the generated query serializes
|
|
251
|
+
the array for the driver (a string is bound as it is). Like a direct call, the
|
|
252
|
+
Store does not validate input against the Procedure's schema (REST and MCP do),
|
|
253
|
+
so `maxItems` and the line shape hold only for rows the handler has checked or
|
|
254
|
+
built.
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
import { store } from './generated/generated.js';
|
|
258
|
+
import type { Caller } from '@aotter/mantle/contracts';
|
|
259
|
+
|
|
260
|
+
// `ctx.store` in a `ref` handler is this Store: `ctx.store.procedures['place-order']({ orderRef, items })`.
|
|
261
|
+
export async function placeOrder(db: Parameters<typeof store>[0], caller: Caller, orderRef: string) {
|
|
262
|
+
const items = [{ productId: 'pen', qty: 2 }, { productId: 'stapler', qty: 1 }];
|
|
263
|
+
return store(db, caller).procedures['place-order']({ orderRef, items });
|
|
264
|
+
}
|
|
265
|
+
```
|
|
@@ -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,14 @@
|
|
|
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
|
+
},
|
|
36
48
|
{
|
|
37
49
|
"text": "Native Admin",
|
|
38
50
|
"link": "/guides/native-admin"
|
|
@@ -131,6 +143,10 @@
|
|
|
131
143
|
"text": "Trigger",
|
|
132
144
|
"link": "/reference/trigger"
|
|
133
145
|
},
|
|
146
|
+
{
|
|
147
|
+
"text": "SQL accepted by each dialect",
|
|
148
|
+
"link": "/reference/sql"
|
|
149
|
+
},
|
|
134
150
|
{
|
|
135
151
|
"text": "Authorization requirements",
|
|
136
152
|
"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,
|
|
@@ -38,7 +38,7 @@ Values come from the verified Better Auth user record. Declare stored fields wit
|
|
|
38
38
|
|
|
39
39
|
SQL cannot reference caller values: `auth.user()` fails with `USER_REF_SCOPE_ONLY`, and `auth.hasRole()` / `auth.role()` with `AUTH_ROLE_REMOVED`. Names are literals; Better Auth core fields and names beginning `mantle` are reserved.
|
|
40
40
|
|
|
41
|
-
Values come from the original frozen Caller, which holds identity in a private WeakMap. Anonymous and copied/unbranded callers have no roles or user fields. Values are validated and frozen: finite numbers; strings of 1–256 UTF-8 bytes without NUL or lone surrogates; arrays of at most 64 items with duplicates removed, only for multi-valued fields. `{ all: true }` is the typed "every value" state of one field; storing it requires a Better Auth additional field of type `json`. Unknown user fields are discarded. A missing or empty value is refused before SQL runs (`UNAUTHENTICATED` for anonymous callers, otherwise `AUTH_DENIED`); it never matches every row. Never mint verified users from request data. Resolve credentials before constructing an identity, and pass the original Caller to generated operations.
|
|
41
|
+
Values come from the original frozen Caller, which holds identity in a private WeakMap. Anonymous and copied/unbranded callers have no roles or user fields. Values are validated and frozen: finite numbers, integers when the field scopes an `integer` column; strings of 1–256 UTF-8 bytes without NUL or lone surrogates; arrays of at most 64 items with duplicates removed, only for multi-valued fields. `{ all: true }` is the typed "every value" state of one field; storing it requires a Better Auth additional field of type `json`. Unknown user fields are discarded. A missing or empty value is refused before SQL runs (`UNAUTHENTICATED` for anonymous callers, otherwise `AUTH_DENIED`); it never matches every row. Never mint verified users from request data. Resolve credentials before constructing an identity, and pass the original Caller to generated operations.
|
|
42
42
|
|
|
43
43
|
## Gateway mode
|
|
44
44
|
|
|
@@ -8,12 +8,17 @@ Bare queries preserve native errors except required conflict/uncertain-write cla
|
|
|
8
8
|
| --- | --- | --- |
|
|
9
9
|
| INPUT_VALIDATION_FAILED | 400 | Invalid external input, paging or Store operation |
|
|
10
10
|
| UNAUTHENTICATED | 401 | Caller lacks required authenticated authority |
|
|
11
|
-
| AUTH_DENIED | 403 | Predicate, role or session-origin refusal |
|
|
11
|
+
| AUTH_DENIED | 403 | Predicate, role or session-origin refusal; a generic Admin write to a read-only Schema |
|
|
12
12
|
| NOT_FOUND | 404 | Missing or excluded resource |
|
|
13
13
|
| METHOD_NOT_ALLOWED | 405 | Path exists for another method |
|
|
14
14
|
| CONFLICT | 409 | Unique clash, stale lock or failed row/state guard |
|
|
15
15
|
| OUTCOME_UNKNOWN | 503 | Write may have committed; reconcile without automatic retry |
|
|
16
16
|
|
|
17
|
+
A `CONFLICT` carries `kind`: `"row"` when a row statement changed no row (missing or not visible, a stale version, or a
|
|
18
|
+
state its WHERE no longer allows; one message covers all three, so it never reveals whether the row exists), `"unique"`
|
|
19
|
+
for a unique-index clash and `"check"` for a Schema check (with `schema` and `check`). Match `kind`, not the message.
|
|
20
|
+
To recover from `"row"`, read the record again and decide from its current state: retry with its current version only when the change still applies, and never retry blindly. A Procedure can name a precise reason itself (a ref handler that reads the row first, or a RETURNING the caller checks).
|
|
21
|
+
|
|
17
22
|
Unknown errors map to redacted `INTERNAL_ERROR` (500). A custom `ContractError` outside the core set must carry an explicit status. Media uses its selected `mediaError` contract. Never expose SQL errors, tokens or credentials.
|
|
18
23
|
|
|
19
24
|
The runtime runs no DDL or schema checks. Schema installation and failures belong to [the native migration tool](../concepts/runtime-and-adapters.md#schema-installation-and-migrations), not a boot diagnostic.
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
A Procedure requires `input`, `output` JSON Schemas and a `handler` containing SQL or a ref name. Optional fields include title, description, requires, target, MCP annotations and Admin uiSchema. See [manifest envelope](manifest.md).
|
|
4
4
|
|
|
5
|
-
SQL uses declared Schemas, `input.<name>`, `auth.uid()` and
|
|
5
|
+
SQL uses declared Schemas, `input.<name>`, `auth.uid()` and the expressions [the SQL reference](sql.md) lists for the selected dialect; [SQL patterns](../guides/sql-patterns.md) shows conditional status, approval, partial updates, upserts and guards. Compiler policy supplies scope and system fields. Do not assign scope, version, status, published_at, timestamps or author columns yourself. A SQL Procedure cannot write a `publishing` Schema: create and edit its rows from a ref handler through the Store, and change status with the generated publish/unpublish/archive transitions. `restrict` narrows the rows the statements read and write, as on a [View](view.md#restrict); it is refused on ref handlers.
|
|
6
6
|
|
|
7
|
-
A row-classified INSERT/UPDATE/DELETE (one that targets a single row, e.g. `WHERE id = input.id`) must affect one row. Use `WHERE id = input.id AND version = input.expectedVersion` for explicit OCC. Set operations, including UPDATE with a subquery and `INSERT ... SELECT`, may affect zero rows without an error: check the returned rows when zero means failure. A unique clash, a failed Schema `checks` constraint or a failed row assertion is `CONFLICT` (409); statements run atomically. A check failure keeps that code and its
|
|
7
|
+
A row-classified INSERT/UPDATE/DELETE (one that targets a single row, e.g. `WHERE id = input.id`) must affect one row. Use `WHERE id = input.id AND version = input.expectedVersion` for explicit OCC. Set operations, including UPDATE with a subquery and `INSERT ... SELECT`, may affect zero rows without an error: check the returned rows when zero means failure. A unique clash, a failed Schema `checks` constraint or a failed row assertion is `CONFLICT` (409) with `kind` `"unique"`, `"check"` or `"row"` ([diagnostics](diagnostics.md)); statements run atomically. A check failure keeps that code, and its message is `A check of the Schema failed: <schema>: <check>` on every target, with the driver's own error only in `cause`; `ContractError.fields` is `{ kind: "check", schema, check }`. `check` is the expression on SQLite/D1 and the generated constraint name on PostgreSQL. Results are raw `{ results }`: one array of RETURNING rows per statement, in order (`results[0][0].id` is the first statement's first row), not runtime-validated authored outputs, with driver-native values: a SQLite/D1 boolean is `1`/`0` here, while a View returns a declared boolean column as `true`/`false`; read a row back through a View when a client needs the typed form. An array input is a row source: `json_each(input.items)` on SQLite/D1 and `jsonb_array_elements(input.items) AS j(value)` on PostgreSQL write any number of rows in the same atomic program. Callers pass the array itself (from a handler, `ctx.store.procedures["place-order"]({ orderRef, items })`); the generated input type is the authored one and the generated query serializes a JSON input for the driver, so no `JSON.stringify` is needed (a string is bound as it is). See [several rows that commit together](../guides/typed-queries.md#several-rows-that-commit-together). RETURNING uses SQL output names; quote an explicit alias to preserve declared case (`RETURNING reqStatus AS "reqStatus"`). Generation warns about unaliased direct fields whose declared name differs from storage.
|
|
8
8
|
|
|
9
9
|
A row-classified UPDATE/DELETE on an excluded or missing id affects zero rows and returns HTTP 409 (`CONFLICT`), just like a stale version; reads return not found. Native Admin's visible-row pre-read returns 404 for excluded/missing ids.
|
|
10
10
|
|
|
11
|
-
A ref handler is application code called as `(ctx, input)`. Generation
|
|
11
|
+
A ref handler is application code called as `(ctx, input)`. Generation lists the `Handlers` keys the app must provide (every run, whether or not they exist yet); `tsc --noEmit` checks the implementation against the generated `Handlers` type. Wire them at `store(db, caller, { handlers })` and selected surfaces. The context has the Caller, the native db and a Store for that caller. A handler is not one transaction by itself: when it runs inside a caller's `store.transaction`, its db and Store are that transaction's; otherwise each write commits on its own. On PostgreSQL and bun:sqlite, `ctx.transaction(async ({ store, db }) => …)` makes writes that must commit together atomic; it joins the transaction the handler already runs in. A joined transaction has no savepoint, so let an error inside `ctx.transaction` propagate: a handler that catches it and carries on keeps the writes made before the error. Keep external calls (payments, supplier APIs) outside it, so a callback never waits for an uncommitted row. D1 has no interactive transactions, so its `HandlerContext` has no `transaction`: put writes that must commit together in one SQL Procedure, whose statements commit together, and call it through `ctx.store.procedures`. It is not a sandbox; the app owns external side effects and input/output handling. The context has no `env`: on Workers read bindings and secrets with `import { env } from "cloudflare:workers"`. The Caller carries no name or email; `userOf(caller)` from `@aotter/mantle/auth` returns the Better Auth user record it was resolved from, for session, OAuth and service credentials alike (undefined for system callers).
|
|
12
12
|
|
|
13
13
|
`target: { schema, id, version? }` binds an Admin operation to a row. SQL targets can be inferred for a single locked row operation; declare a target for a ref operation when needed. Input fields must have the required shapes.
|
|
14
14
|
|
|
@@ -18,6 +18,6 @@ Every generated AI-facing tool (MCP, WebMCP, publishing and media tools) carries
|
|
|
18
18
|
|
|
19
19
|
Generated requires checks gate calls. Boundary input validators are generated separately; direct queries accept driver-ready, app-validated input. Dynamic guards and lifecycle hooks are rejected. See [native execution](../guides/native-execution.md) and [surface contracts](surface.md).
|
|
20
20
|
|
|
21
|
-
SQL INSERT
|
|
21
|
+
SQL `INSERT … VALUES`/upsert into a user-field scoped Schema follows the Store's insert rule ([field scope](schema.md#field-scope)); `INSERT … SELECT` into it is system-only, so a ref handler inserts those rows through `ctx.store`. `ctx.store` speaks storage names, not the manifest's: the handler's `input` uses authored names (`input.parentId`), while Store writes, filters and rows use the folded column (`insert({ parentid: input.parentId })`, `where: { parentid }`, `row.parentid`); generated Store types show the exact names ([typed queries](../guides/typed-queries.md)). SET cannot change a field-scope column, including ON CONFLICT DO UPDATE. Scoped unique conflict targets gain the owner/field prefix automatically.
|
|
22
22
|
|
|
23
|
-
Triggers are the only way request data reaches a Procedure: REST and MCP validate the input against the Procedure's schema, then call it. App code may also call a generated Procedure directly (`import run from "../generated/procedures/<name>.js"`, `await run(db, caller, input)`); `requires`, row policy and checks still apply, but input validation does not, so pass only data the app produced or has verified (a cron job, a callback after its signature check). A page that takes user input submits it to the Procedure's REST Trigger; see `node_modules/@aotter/mantle/docs/examples/purchasing/src/requests-page.ts`.
|
|
23
|
+
Triggers are the only way request data reaches a Procedure: REST and MCP validate the input against the Procedure's schema, then call it. App code may also call a generated Procedure directly (`import run from "../generated/procedures/<name>.js"`, `await run(db, caller, input)`) or through the Store (`ctx.store.procedures[name](input)` in a handler); `requires`, row policy and checks still apply, but input validation does not, so pass only data the app produced or has verified (a cron job, a callback after its signature check). A page that takes user input submits it to the Procedure's REST Trigger; see `node_modules/@aotter/mantle/docs/examples/purchasing/src/requests-page.ts`.
|