@aotter/mantle 0.0.11-alpha.59 → 0.0.11-alpha.61

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 CHANGED
@@ -93,6 +93,10 @@ The `mantle-runtime` package never imports Cloudflare-specific types — adapter
93
93
  - `node_modules/@aotter/mantle/docs/api-mcp-authorization.md` (anonymous,
94
94
  API-key, paid guard, personal-token, OAuth, REST, and MCP examples)
95
95
  - `node_modules/@aotter/mantle/docs/media-uploads.md` (Cloudflare R2 adapter recipe)
96
+ - `node_modules/@aotter/mantle/docs/deferred-lifecycle-queues.md` (versioned
97
+ Queue wiring, retry/DLQ, idempotency, and delivery guarantees)
98
+ - `node_modules/@aotter/mantle/docs/schema-indexes.md` (ordered composite
99
+ JSON-field indexes, D1 query plans, and the safe Procedure SQL helper)
96
100
  - `node_modules/@aotter/mantle/docs/adr/`
97
101
  - `node_modules/@aotter/mantle/skills/develop/SKILL.md`
98
102
  - `node_modules/@aotter/mantle/skills/plugin/SKILL.md`
@@ -103,6 +107,8 @@ The `mantle-runtime` package never imports Cloudflare-specific types — adapter
103
107
  - [Repo](https://github.com/aotter/mantle)
104
108
  - [4-atom manifest model (ADR-0001)](https://github.com/aotter/mantle/blob/develop/docs/adr/0001-four-atom-manifest-model.md)
105
109
  - [API and MCP authorization](https://github.com/aotter/mantle/blob/develop/docs/api-mcp-authorization.md)
110
+ - [Deferred lifecycle Queues](https://github.com/aotter/mantle/blob/develop/docs/deferred-lifecycle-queues.md)
111
+ - [Schema indexes on D1](https://github.com/aotter/mantle/blob/develop/docs/schema-indexes.md)
106
112
  - [Release process](https://github.com/aotter/mantle/blob/develop/docs/release-process.md)
107
113
  - [Issues](https://github.com/aotter/mantle/issues)
108
114
 
@@ -25,10 +25,17 @@ Optional ports are enabled only when a feature needs them:
25
25
  | Contract | Source | Required when |
26
26
  |---|---|---|
27
27
  | `MediaStorage` | `packages/mantle-runtime/src/domain/port/MediaStorage.ts` | The adapter exposes admin/MCP media upload flows. |
28
- | `DeferredHookDispatcher` | `packages/mantle-runtime/src/domain/port/DeferredHookDispatcher.ts` | The adapter wants durable queue delivery for `after_*` lifecycle hooks. |
28
+ | `DeferredHookDispatcher` | `packages/mantle-runtime/src/domain/port/DeferredHookDispatcher.ts` | The adapter wants at-least-once queue delivery for `after_*` lifecycle hooks. |
29
29
 
30
30
  Test seams such as `Clock` and `IdGenerator` are injectable through `createCmsRuntime`, but normal adapters do not need custom implementations.
31
31
 
32
+ Deferred delivery is an optional, versioned wire contract. Queue acceptance is
33
+ not atomic with the entry write; adapters must preserve the supplied event id,
34
+ validate untrusted messages before dereferencing them, surface handler failures
35
+ to their retry mechanism, and document poison-message/DLQ behavior. See
36
+ [Deferred lifecycle hooks on Cloudflare Queues](deferred-lifecycle-queues.md)
37
+ for the reference implementation and exact guarantees.
38
+
32
39
  ## Runtime boot
33
40
 
34
41
  Adapters compose the runtime through `createCmsRuntime`:
@@ -148,6 +155,8 @@ Minimum auth/MCP behavior:
148
155
  - [ ] Prove one guarded target has identical REST/MCP outcomes, including
149
156
  mutable revocation on the next call.
150
157
  - [ ] Add optional `MediaStorage` or `DeferredHookDispatcher` only when the adapter supports those features.
158
+ - [ ] For deferred hooks, document at-least-once delivery, idempotency,
159
+ message limits, retries/DLQ, and the non-transactional write-to-enqueue gap.
151
160
  - [ ] Verify the runtime package still has no platform-specific imports.
152
161
 
153
162
  ## Current non-goals
@@ -285,6 +285,7 @@ forces it.
285
285
  **Schema (v0.1)**:
286
286
  - `spec.schema:` (JSON Schema 2020-12 body)
287
287
  - `spec.uniqueIndexes:` (composite uniques)
288
+ - `spec.indexes:` (ordered composite, non-unique access paths)
288
289
  - Property extensions: `x-mantle-bind`, `x-mantle-ref`, `x-mcp-hint`
289
290
 
290
291
  **View (v0.1)**:
@@ -332,7 +333,7 @@ forces it.
332
333
  #### What's DRAFT (do not implement, do not type)
333
334
 
334
335
  - Schema: `policies.{visible, readable, writable, owner}`,
335
- `indexedFields:`, `x-mantle-ref` auto-lift to virtual column,
336
+ `x-mantle-ref` auto-lift to virtual column,
336
337
  computed columns via projection Trigger
337
338
  - View: `recursive`, `gatedBy`, `join`, `policies.skip`,
338
339
  filter AST extensions (`contains`, `not`, `in`, `like`)
@@ -95,8 +95,8 @@ What it catches:
95
95
  - `Trigger.target.procedure` references a Procedure that exists
96
96
  - `View.from` references a Schema that exists; `View.fields` and
97
97
  `View.filter` refer only to declared properties of that Schema
98
- - `Schema.uniqueIndexes` lists only properties declared in the
99
- Schema's `spec.schema.properties`
98
+ - `Schema.uniqueIndexes` and `Schema.indexes` contain valid ordered
99
+ tuples of scalar properties declared in `spec.schema.properties`
100
100
  - `x-mantle-bind: <value>` is in the closed enum
101
101
  - `requires.auth.all` predicates are in the v0.1 vocabulary
102
102
  - `Trigger.source.path` does not collide with another Trigger
@@ -31,17 +31,27 @@ The POC accumulated multiple half-decisions about this seam (POC ADR-0015 docume
31
31
 
32
32
  Optional feature ports may also live in `domain/port/`, but they are
33
33
  not part of the first-run adapter contract until a feature is enabled.
34
- For v0.1.x media hosting and durable lifecycle dispatch:
34
+ For v0.1.x media hosting and deferred lifecycle dispatch:
35
35
 
36
36
  | Optional port | Surface |
37
37
  |---|---|
38
38
  | `MediaStorage` | Object-storage-shaped media upload/commit/public URL/delete contract for **public** media. Cloudflare may implement with R2, but runtime must not import R2 types. |
39
- | `DeferredHookDispatcher` | Queue-shaped dispatcher for durable `after_*` lifecycle hooks. Cloudflare may implement with Workers Queues; other adapters may use a queue, job runner, or leave it unset. |
39
+ | `DeferredHookDispatcher` | Queue-shaped dispatcher for at-least-once `after_*` lifecycle delivery. Cloudflare may implement with Workers Queues; other adapters may use a queue, job runner, or leave it unset. |
40
40
 
41
41
  These optional ports must not force first-run provisioning to create R2
42
42
  resources. Publication starters can carry external image URLs without a
43
43
  media storage implementation.
44
44
 
45
+ `DeferredHookDispatcher.enqueue` confirms adapter acceptance only. It cannot
46
+ make an entry write and an external queue send atomic. The versioned envelope
47
+ contains a stable event id plus captured Trigger names; handlers combine them
48
+ for idempotency because retries and ambiguous producer fallback can duplicate
49
+ execution. Deferred envelopes carry persisted entry data and normalized actor
50
+ metadata, never arbitrary original request input. Adapter-specific retry,
51
+ delay, batch, DLQ, and message-size settings remain outside manifest grammar.
52
+ The operational reference is
53
+ [`docs/deferred-lifecycle-queues.md`](../deferred-lifecycle-queues.md).
54
+
45
55
  Identity, session, OAuth, and role enforcement are adapter-owned per
46
56
  ADR-0014. The runtime does not define `SessionRepository`,
47
57
  `OAuthVerifier`, `UserRepository`, or `StaffRepository` ports. Adapters
@@ -0,0 +1,267 @@
1
+ # Deferred lifecycle hooks on Cloudflare Queues
2
+
3
+ Mantle can opt `after_*` lifecycle Triggers into a Cloudflare Queue. This is
4
+ useful for notifications and projections that should not add request latency,
5
+ but it is not an exactly-once transaction boundary.
6
+
7
+ ## Contract
8
+
9
+ - The entry write commits before Queue delivery. D1 and Queue do not share a
10
+ transaction, so a Worker failure can still occur in that gap.
11
+ - A resolved `Queue.send()` means Cloudflare accepted the message. Consumption
12
+ is at-least-once; a message can run more than once.
13
+ - A rejected or ambiguous send falls back with the same event identity through
14
+ `ctx.waitUntil`, or inline when `waitUntil` is unavailable. `waitUntil` is
15
+ best-effort and does not close the D1-to-Queue gap.
16
+ - A consumer acknowledges a message only after every captured Trigger
17
+ succeeds. It runs all captured Triggers before surfacing failures, so a later
18
+ failure can replay an earlier success.
19
+ - Malformed, unsupported, removed-Trigger, and persistently failing messages
20
+ are retried and then sent to the configured dead-letter queue (DLQ). Without
21
+ a DLQ, Cloudflare discards them after `max_retries`.
22
+
23
+ Deferred handlers must therefore be idempotent. Mantle does not promise
24
+ exactly-once execution.
25
+
26
+ ## Version 1 event
27
+
28
+ The strict v1 envelope contains:
29
+
30
+ ```ts
31
+ interface DeferredHookEnvelope {
32
+ version: 1;
33
+ eventId: string;
34
+ triggerNames: readonly string[];
35
+ hook: "after_create" | "after_update" | "after_delete" | "after_publish";
36
+ schema: string;
37
+ entry: EntryRow;
38
+ ctxSnapshot: CtxSnapshot | null;
39
+ }
40
+ ```
41
+
42
+ `triggerNames` is captured when the entry mutation runs. A later deployment
43
+ does not add a new Trigger to an old event. Removing or renaming a captured
44
+ Trigger makes that old message fail validation at dispatch and eventually
45
+ reach the DLQ instead of silently changing its meaning.
46
+
47
+ Each handler receives persisted `entry.data` as its input and:
48
+
49
+ ```ts
50
+ ctx.event = {
51
+ id: envelope.eventId,
52
+ trigger: currentTriggerName,
53
+ hook: envelope.hook,
54
+ schema: envelope.schema,
55
+ entry: envelope.entry,
56
+ };
57
+ ```
58
+
59
+ Use `${ctx.event.id}:${ctx.event.trigger}` as the handler's idempotency key.
60
+ The same key survives enqueue fallback, Queue retries, and replay. Synchronous
61
+ `before_*` hooks may still receive pre-projection request input such as a
62
+ CAPTCHA token; deferred `after_*` envelopes never retain that input. The
63
+ identity snapshot contains normalized actor and credential metadata, never raw
64
+ cookies, tokens, or API keys.
65
+
66
+ Cloudflare's 128 KB limit is decimal and includes platform metadata. The
67
+ adapter therefore rejects non-JSON envelopes and encoded envelopes at or above
68
+ 127,000 bytes before calling `Queue.send`, leaving at least 1 KB of headroom.
69
+ Keep entry payloads smaller than that supported limit.
70
+
71
+ ## Cloudflare opt-in
72
+
73
+ Create the internal queue and its DLQ:
74
+
75
+ ```sh
76
+ pnpm wrangler queues create mantle-internal
77
+ pnpm wrangler queues create mantle-internal-dlq
78
+ ```
79
+
80
+ Add both producer and consumer bindings. These are adapter settings, not Mantle
81
+ manifest grammar:
82
+
83
+ ```toml
84
+ [[queues.producers]]
85
+ binding = "MANTLE_INTERNAL_QUEUE"
86
+ queue = "mantle-internal"
87
+
88
+ [[queues.consumers]]
89
+ queue = "mantle-internal"
90
+ max_batch_size = 10
91
+ max_batch_timeout = 5
92
+ max_retries = 5
93
+ retry_delay = 60
94
+ dead_letter_queue = "mantle-internal-dlq"
95
+ ```
96
+
97
+ Wire the producer into `CmsConfig.bindings` and export the consumer alongside
98
+ the existing HTTP/OAuth handler. The same Worker may be both producer and
99
+ consumer:
100
+
101
+ ```ts
102
+ import type { DeferredHookEnvelope } from "@aotter/mantle/runtime";
103
+ import {
104
+ AssetsAssetServer,
105
+ D1DatabaseDriver,
106
+ KvCacheBinding,
107
+ WorkersQueueHookDispatcher,
108
+ createCmsRef,
109
+ createQueueHandler,
110
+ createOAuthProvider,
111
+ } from "@aotter/mantle/cloudflare";
112
+
113
+ interface Env {
114
+ DB: D1Database;
115
+ KV: KVNamespace;
116
+ ASSETS: Fetcher;
117
+ MANTLE_INTERNAL_QUEUE: Queue<DeferredHookEnvelope>;
118
+ }
119
+
120
+ function buildWorker(env: Env) {
121
+ const cms = createCmsRef({
122
+ manifests,
123
+ handlers,
124
+ auth: createSiteAuth(env),
125
+ bindings: {
126
+ db: new D1DatabaseDriver(env.DB),
127
+ kv: new KvCacheBinding(env.KV),
128
+ assets: new AssetsAssetServer(env.ASSETS),
129
+ deferredHookDispatcher: new WorkersQueueHookDispatcher(
130
+ env.MANTLE_INTERNAL_QUEUE,
131
+ ),
132
+ },
133
+ });
134
+
135
+ const http = createOAuthProvider<Env>({
136
+ defaultHandler: createSiteHttpHandler(cms),
137
+ apiHandlers: createSiteMcpHandlers(cms),
138
+ });
139
+
140
+ return { http, consumeMantle: createQueueHandler<Env>(cms) };
141
+ }
142
+
143
+ let built: ReturnType<typeof buildWorker> | undefined;
144
+ const worker = (env: Env) => built ??= buildWorker(env);
145
+
146
+ export default {
147
+ fetch(request, env, ctx) {
148
+ return worker(env).http.fetch(request, env, ctx);
149
+ },
150
+ queue(batch, env) {
151
+ return worker(env).consumeMantle(batch, env);
152
+ },
153
+ } satisfies ExportedHandler<Env>;
154
+ ```
155
+
156
+ `createSiteAuth`, `createSiteHttpHandler`, and `createSiteMcpHandlers` above
157
+ stand for the site's existing adapter assembly; Queue opt-in adds only the
158
+ dispatcher binding and `queue` export.
159
+
160
+ ## Idempotent handlers
161
+
162
+ For a D1-owned effect, make the event/Trigger key a unique database key. This
163
+ example records a site-owned notification job exactly once even when the
164
+ lifecycle handler is replayed:
165
+
166
+ ```sql
167
+ CREATE TABLE notification_jobs (
168
+ idempotency_key TEXT PRIMARY KEY,
169
+ entry_id TEXT NOT NULL,
170
+ payload TEXT NOT NULL,
171
+ created_at INTEGER NOT NULL
172
+ );
173
+ ```
174
+
175
+ ```ts
176
+ import type { HandlerContext } from "@aotter/mantle/runtime";
177
+
178
+ async function enqueueNotification(
179
+ input: Record<string, unknown>,
180
+ ctx: HandlerContext,
181
+ ) {
182
+ if (!ctx.event?.entry) throw new Error("lifecycle entry event required");
183
+ const key = `${ctx.event.id}:${ctx.event.trigger}`;
184
+ await (ctx.env as Env).DB.prepare(
185
+ `INSERT OR IGNORE INTO notification_jobs
186
+ (idempotency_key, entry_id, payload, created_at)
187
+ VALUES (?, ?, ?, ?)`,
188
+ ).bind(key, ctx.event.entry.id, JSON.stringify(input), Date.now()).run();
189
+ return { ok: true };
190
+ }
191
+ ```
192
+
193
+ For an upstream API that supports idempotency, send the same stable key and
194
+ throw on failure so Queue retries it:
195
+
196
+ ```ts
197
+ const key = `${ctx.event!.id}:${ctx.event!.trigger}`;
198
+ const response = await fetch("https://api.example.com/messages", {
199
+ method: "POST",
200
+ headers: {
201
+ "Content-Type": "application/json",
202
+ "Idempotency-Key": key,
203
+ },
204
+ body: JSON.stringify(input),
205
+ });
206
+ if (!response.ok) throw new Error(`message provider returned ${response.status}`);
207
+ ```
208
+
209
+ Core intentionally does not add a business outbox, payment state machine,
210
+ provider retry wrapper, or generic job registry. Those remain site-owned.
211
+
212
+ ## Multiplexing site queues
213
+
214
+ One Worker can consume the Mantle queue and site-owned queues without a Core
215
+ registry. Route on `batch.queue` in the Worker entry:
216
+
217
+ ```ts
218
+ const mantleQueue = createQueueHandler<Env>(cms);
219
+
220
+ export default {
221
+ fetch: http.fetch.bind(http),
222
+ queue(batch, env, ctx) {
223
+ switch (batch.queue) {
224
+ case "mantle-internal":
225
+ return mantleQueue(batch, env);
226
+ case "billing-jobs":
227
+ return consumeBilling(batch, env, ctx);
228
+ default:
229
+ batch.retryAll();
230
+ console.error(`No consumer for queue '${batch.queue}'`);
231
+ return;
232
+ }
233
+ },
234
+ } satisfies ExportedHandler<Env>;
235
+ ```
236
+
237
+ The Mantle consumer processes at most five messages concurrently within each
238
+ delivered batch even if `max_batch_size` is raised. It calls `retryAll()` when
239
+ runtime boot fails, `retry()` for one failed event, and `ack()` exactly once
240
+ after success.
241
+
242
+ ## Verification and operations
243
+
244
+ Run the integrated contract test:
245
+
246
+ ```sh
247
+ pnpm --filter @aotter/mantle-cloudflare test -- mantle-internal-queue.test.ts
248
+ ```
249
+
250
+ It covers real runtime failure propagation, all-Trigger execution, stable
251
+ replay identity, per-message ack/retry, malformed bodies, size/JSON rejection,
252
+ runtime boot failure, and the five-message concurrency bound.
253
+
254
+ For a site integration, run `wrangler dev`, submit a mutation with an
255
+ `after_*` Trigger, and verify the consumer log. Make one handler fail once and
256
+ confirm its first delivery is retried and the replay sees the same
257
+ `ctx.event.id` and `ctx.event.trigger`. Inspect the DLQ before increasing
258
+ `max_retries`; poison messages otherwise hide as repeated retries.
259
+
260
+ ### Upgrading from the unversioned envelope
261
+
262
+ Version 1 intentionally rejects the old unversioned envelope and the old
263
+ `originalInput` field. Before deploying this release to a Worker that already
264
+ uses `mantle-internal`, stop old producers and drain the queue (including any
265
+ in-flight retries), or explicitly move the remaining messages to an operator
266
+ workflow. Deploying v1 with legacy messages still queued will retry those
267
+ messages and eventually place them in the DLQ.
@@ -101,7 +101,7 @@ stays honest.
101
101
  ### 1. `Schema` — the entity (internal)
102
102
 
103
103
  A relation. Defines what data exists. You declare the JSON Schema for
104
- each row, the unique indexes, the binding directives. Schema is **not
104
+ each row, its indexes, and the binding directives. Schema is **not
105
105
  directly externally exposed** — clients don't write to a Schema URL; they
106
106
  hit a Procedure that the SDK translates into Schema mutations.
107
107
 
@@ -128,6 +128,7 @@ spec:
128
128
  authorId: { type: string, format: uuid, x-mantle-bind: ctx.user }
129
129
  createdAt: { type: string, format: date-time, x-mantle-bind: now }
130
130
  uniqueIndexes: [[slug, locale]]
131
+ indexes: [[locale, title]] # ordered, non-unique hot path
131
132
  ```
132
133
 
133
134
  **`spec.title`** — admin UI label. Required. AI authors must populate
@@ -675,11 +676,13 @@ indexing it surfaces as a JSON-extracted virtual generated column
675
676
  (`json_extract(data, '$.locale')`) with a partial unique index, not as
676
677
  a top-level column.
677
678
 
678
- `uniqueIndexes` declarations compile into virtual generated columns +
679
- partial unique indexes via the spec engine's DDL emitter
680
- (`@aotter/mantle-spec`); different collections coexist on the
681
- same table without colliding because the generated-column expression
682
- is gated by `WHEN collection = '<name>'`.
679
+ `uniqueIndexes` and ordered, non-unique `indexes` declarations compile
680
+ into affinity-correct virtual generated columns plus partial B-tree
681
+ indexes via the spec engine's DDL emitter (`@aotter/mantle-spec`).
682
+ Different collections coexist on the same table without colliding
683
+ because each generated-column expression is gated by `WHEN collection
684
+ = '<name>'`. See [Schema indexes on D1](schema-indexes.md) for the
685
+ grammar, leftmost-prefix rules, SQL helper, and query-plan examples.
683
686
 
684
687
  ### What works well on D1
685
688
 
@@ -688,8 +691,9 @@ is gated by `WHEN collection = '<name>'`.
688
691
  plus `->` / `->>` operators.
689
692
  - Reserved-column queries (`status = 'published'`, `ORDER BY
690
693
  updated_at`) use native indexes — fast.
691
- - `uniqueIndexes`-declared paths use virtual columns + partial unique
692
- indexes fast equality.
694
+ - `uniqueIndexes`- and `indexes`-declared paths use virtual columns +
695
+ partial indexes. Core-compiled Views and repository lookups reference
696
+ those columns instead of repeating `json_extract`.
693
697
 
694
698
  ### Where D1's JSON support has limits (vs Postgres)
695
699
 
@@ -708,11 +712,9 @@ is gated by `WHEN collection = '<name>'`.
708
712
 
709
713
  - **Blog-scale (< 10k entries / collection)**: today's design is
710
714
  comfortable. Public render hits KV cache, not D1.
711
- - **Mid-scale (10k – 100k entries)**: list/filter Views on
712
- JSON-internal fields start to need `indexedFields` (future
713
- grammar). Cross-collection JSON-path joins start to need
714
- `x-mantle-ref` auto-lift. Both are reserved in the future-grammar
715
- appendix.
715
+ - **Mid-scale (10k – 100k entries)**: declare measured list/filter
716
+ access paths with ordered `indexes`. Cross-collection JSON-path joins
717
+ may eventually gain `x-mantle-ref` auto-lift; that remains DRAFT.
716
718
  - **Hard D1 limits**: 1 MB max row size; 5,000 rows per query result;
717
719
  single-writer per database (concurrent writes serialize).
718
720
  - **Cross-region read**: D1 is region-pinned; first hit from a
@@ -739,7 +741,7 @@ When this path is taken (post-v0.1; not implemented yet), the
739
741
  | What changes (SDK runtime) | Author impact |
740
742
  |---|---|
741
743
  | `data TEXT` → `data JSONB` | none |
742
- | Virtual generated columns → optional (PG can use GIN) | author may stop declaring `indexedFields` if defaults suffice; same YAML still valid |
744
+ | Virtual generated columns → optional (PG can use GIN) | author may stop declaring `indexes` if defaults suffice; the same YAML remains valid |
743
745
  | `json_each` → array operators (`@>`, `&&`) | none — predicates stay declarative |
744
746
  | SDK-stamped `now` → `DEFAULT now()` (optional) | none |
745
747
  | SDK-side policy rewriting → native RLS (optional) | none |
@@ -880,20 +882,29 @@ spec:
880
882
  **Atomicity defaults by phase**:
881
883
  - `before_*`: `errorPolicy: abort`. Handler throw cancels the
882
884
  surrounding mutation; caller receives the diagnostic.
883
- - `after_*`: `errorPolicy: continue`. Handler throw is logged and
884
- discarded; the mutation succeeds. Runs via `ctx.waitUntil` when
885
- available so the caller doesn't block on a remote call.
885
+ - `after_*`: `errorPolicy: continue`. The committed mutation remains
886
+ successful. On the inline / `ctx.waitUntil` path, a failure is logged
887
+ and swallowed. With an optional `DeferredHookDispatcher`, failures
888
+ surface to the delivery adapter so its at-least-once retry/DLQ policy
889
+ can run; they still never roll back the entry mutation.
886
890
 
887
891
  Authors override either default by declaring `errorPolicy: abort |
888
892
  continue` explicitly.
889
893
 
890
- **Hook handlers** receive the *original* (pre-projection) Procedure
891
- input so a `before_create` hook on `contact-messages` can read the
892
- caller's `recaptchaToken` field even though the row never stores it.
893
- They also receive `ctx.event = { hook, schema, entry }`. `entry` is
894
- null only on `before_create` (no row exists yet); the pre-mutation
895
- row on `before_update` / `before_delete` / `before_publish`; the
896
- persisted post-mutation row on every `after_*`.
894
+ **Hook handler input** is phase-specific. Synchronous `before_*` hooks
895
+ receive the original pre-projection Procedure input, so a
896
+ `before_create` hook on `contact-messages` can read the caller's
897
+ `recaptchaToken` even though the row never stores it. Every `after_*`
898
+ hook receives only persisted `entry.data`; deferred envelopes never
899
+ retain arbitrary request input.
900
+
901
+ Handlers also receive
902
+ `ctx.event = { id, trigger, hook, schema, entry }`. `id` is stable
903
+ across enqueue fallback and deferred retries; `trigger` is the current
904
+ `Trigger.metadata.name`. Deferred handlers use `${id}:${trigger}` as
905
+ their idempotency key. `entry` is null only on `before_create`; it is
906
+ the pre-mutation row for the other `before_*` hooks and the persisted
907
+ post-mutation row for every `after_*`.
897
908
 
898
909
  **Hook ordering**: when multiple lifecycle Triggers bind the same
899
910
  `(schema, hook)`, the runtime fires them **alphabetically by
@@ -901,6 +912,15 @@ persisted post-mutation row on every `after_*`.
901
912
  v0.2; today, choose names that sort correctly (`010-bot-check`,
902
913
  `020-rate-limit`).
903
914
 
915
+ For deferred delivery, that ordered Trigger-name list is captured in
916
+ one strict versioned event. Every captured Trigger runs before a
917
+ failure is returned to the Queue, so retry may replay Triggers that
918
+ already succeeded. Queue acceptance is not transactional with D1,
919
+ `waitUntil` fallback is best-effort, and exactly-once is not promised.
920
+ Cloudflare wiring, the 128 KB platform limit, retry/DLQ configuration,
921
+ idempotency, and legacy-envelope draining are specified in
922
+ [Deferred lifecycle hooks on Cloudflare Queues](deferred-lifecycle-queues.md).
923
+
904
924
  **Editorial-lifecycle hooks** (`before_publish`, `after_publish`)
905
925
  depend on the `lifecycle: editorial` runtime, which ships in the
906
926
  same v0.1.x cut.
@@ -912,18 +932,11 @@ forces it, not on speculation. Today, do not implement; the boot
912
932
  validator rejects with `DRAFT_KEY_USED`.
913
933
 
914
934
  #### Schema future
915
- - **`spec.indexedFields: [<field-path>, ...]`** — non-unique
916
- performance indexes on JSON-internal fields. Compiles to one virtual
917
- generated column + one non-unique index per declared field, mirroring
918
- the existing `uniqueIndexes` mechanism but without the UNIQUE
919
- constraint. Lands when first list/filter Views on JSON-internal
920
- fields start to slow (typical threshold: 5–10k rows on a collection
921
- whose hot filter or sort key isn't a reserved native column).
922
935
  - **`x-mantle-ref` auto-lift to virtual column** — when a property
923
936
  carrying `x-mantle-ref: <other-schema>` is referenced in any registered
924
937
  View's `filter:` or future `join:`, the SDK auto-creates the virtual
925
- column + non-unique index without requiring explicit
926
- `indexedFields` entry. Solves cross-collection JSON-path join
938
+ column + non-unique index without requiring an explicit `indexes`
939
+ declaration. Solves cross-collection JSON-path join
927
940
  performance without making authors think about it. Pure SDK
928
941
  behavior; no new YAML grammar.
929
942
  - **`spec.policies.visible`** — row-level read predicate auto-AND'd
@@ -0,0 +1,169 @@
1
+ # Schema indexes on D1
2
+
3
+ Mantle stores every authored Schema property inside the shared `entries.data`
4
+ JSON `TEXT` column. Declare `indexes` for measured, hot access paths that need
5
+ an ordered non-unique B-tree index. Use `uniqueIndexes` when the same ordered
6
+ fields are a data-integrity constraint.
7
+
8
+ ```yaml
9
+ apiVersion: cms.mantle.aotter.net/v1
10
+ kind: Schema
11
+ metadata: { name: account-members }
12
+ spec:
13
+ title: Account members
14
+ lifecycle: none
15
+ schema:
16
+ type: object
17
+ properties:
18
+ userId: { type: string }
19
+ state: { type: string }
20
+ accountId: { type: string }
21
+ email: { type: [string, "null"] }
22
+ indexes:
23
+ - [userId, state, accountId]
24
+ - [accountId, state, userId]
25
+ uniqueIndexes:
26
+ - [accountId, userId]
27
+ - [accountId, email]
28
+ ```
29
+
30
+ Each inner array is one index. Field order is significant. A one-field array
31
+ is valid; a bare string or the retired `indexedFields` spelling is not.
32
+ Invalid matrix shapes report `INVALID_MANIFEST_ENVELOPE`; invalid index
33
+ semantics report `SCHEMA_INDEX_INVALID`, and unknown fields report
34
+ `SCHEMA_INDEX_FIELD_UNKNOWN` (`uniqueIndexes` keeps its existing
35
+ `UNIQUE_INDEX_FIELD_UNKNOWN` code).
36
+
37
+ ## Leftmost-prefix behavior
38
+
39
+ For `[userId, state, accountId]`, SQLite can use the index for:
40
+
41
+ ```sql
42
+ WHERE userId = ?
43
+ WHERE userId = ? AND state = ?
44
+ WHERE userId = ? AND state = ? AND accountId = ?
45
+ ```
46
+
47
+ It does not provide the same guarantee when the leftmost field is skipped:
48
+
49
+ ```sql
50
+ WHERE state = ? AND accountId = ?
51
+ ```
52
+
53
+ The second declaration, `[accountId, state, userId]`, supports the other hot
54
+ path and supplies the requested order without a temporary sort:
55
+
56
+ ```sql
57
+ WHERE accountId = ? AND state = ?
58
+ ORDER BY userId
59
+ ```
60
+
61
+ Do not declare every possible permutation. Each index consumes storage and
62
+ adds work to inserts and updates. Start from an observed query plan or hot
63
+ endpoint and declare only the access paths it needs.
64
+
65
+ ## Supported fields
66
+
67
+ Indexed fields are exact, own, top-level keys of
68
+ `spec.schema.properties`. `a.b` means a literal top-level key named `a.b`; it
69
+ does not traverse `{ a: { b: ... } }`.
70
+
71
+ An index-bearing Schema name and every indexed field must match
72
+ `^[A-Za-z][A-Za-z0-9_.-]*$`.
73
+
74
+ The property must declare exactly one non-null scalar type. It may also allow
75
+ `null` with a type array or `nullable: true`.
76
+
77
+ | JSON Schema type | generated-column affinity |
78
+ |---|---|
79
+ | `string` | `TEXT` |
80
+ | `integer`, `boolean` | `INTEGER` |
81
+ | `number` | `REAL` |
82
+
83
+ Objects, arrays, enum-only properties, and mixed non-null unions are not
84
+ indexable. Reserved native View fields (`id`, `status`, `version`,
85
+ `createdAt`, `updatedAt`, `authorId`) also cannot be index declarations.
86
+
87
+ Missing and `null` leftmost values are omitted from the partial index. SQLite
88
+ UNIQUE semantics still allow more than one composite containing `NULL`.
89
+
90
+ ## Core-compiled queries
91
+
92
+ Mantle creates collection-gated VIRTUAL generated columns during `bootInit`.
93
+ Core-compiled View projections, filters, and ordering automatically reference
94
+ a generated column when their field is declared by `indexes` or
95
+ `uniqueIndexes`; undeclared fields keep using `json_extract`.
96
+
97
+ D1 stores the original JSON as `TEXT`, not PostgreSQL JSONB. The generated
98
+ scalar columns and their B-tree indexes are the supported indexed-path
99
+ mechanism. Their physical identifiers are private, versioned, and encoded;
100
+ never copy one from `sqlite_schema` into site code.
101
+
102
+ ## Site-owned Procedure SQL
103
+
104
+ Use the pure helper when a business query remains site-owned. It returns a
105
+ fully quoted reference for a declared field or `null` when that field has no
106
+ declared index. Pass a table alias for joins so the generated column is not
107
+ ambiguous.
108
+
109
+ ```ts
110
+ import {
111
+ schemaIndexedFieldSql,
112
+ type SchemaManifest,
113
+ } from "@aotter/mantle/spec";
114
+ import { loadManifests } from "../mantle/manifests.js";
115
+
116
+ const accountMembers = loadManifests().find(
117
+ (manifest): manifest is SchemaManifest =>
118
+ manifest.kind === "Schema" && manifest.metadata.name === "account-members",
119
+ );
120
+ if (!accountMembers) throw new Error("account-members Schema is missing");
121
+
122
+ const userId = schemaIndexedFieldSql(accountMembers, "userId", "member");
123
+ const state = schemaIndexedFieldSql(accountMembers, "state", "member");
124
+ if (!userId || !state) {
125
+ throw new Error("account-members actor lookup requires its declared index");
126
+ }
127
+
128
+ const rows = await env.DB.prepare(
129
+ `SELECT member.id, member.data
130
+ FROM entries AS member
131
+ WHERE member.collection = ?
132
+ AND ${userId} = ?
133
+ AND ${state} = ?`,
134
+ )
135
+ .bind("account-members", actorId, "active")
136
+ .all();
137
+ ```
138
+
139
+ The helper does not authorize the query or define its business response. The
140
+ site still owns actor resolution, account rules, aggregation, and output.
141
+
142
+ ## Verify a plan
143
+
144
+ Inspect the real database when adding or changing a hot path:
145
+
146
+ ```sql
147
+ PRAGMA index_list("entries");
148
+ PRAGMA index_xinfo("<index returned above>");
149
+
150
+ EXPLAIN QUERY PLAN
151
+ SELECT id FROM entries
152
+ WHERE collection = 'account-members'
153
+ AND "<reference emitted by Core>" = 'user-1';
154
+ ```
155
+
156
+ The plan should contain `SEARCH entries USING INDEX ...`, and an indexed
157
+ filter/order path should not contain `USE TEMP B-TREE FOR ORDER BY`.
158
+
159
+ Existing databases upgrade by adding new affinity-correct VIRTUAL columns and
160
+ versioned indexes; populated `entries` rows do not need a rewrite. Unused
161
+ generated columns may remain because dropping them is not safe across every D1
162
+ version Mantle supports. Compatible alpha.59 unique indexes remain during a
163
+ rolling upgrade; ambiguous legacy flattened names and indexes removed from
164
+ manifests are reconciled away at boot.
165
+
166
+ References: [D1 JSON](https://developers.cloudflare.com/d1/sql-api/query-json/),
167
+ [D1 generated columns](https://developers.cloudflare.com/d1/reference/generated-columns/),
168
+ [D1 indexes](https://developers.cloudflare.com/d1/best-practices/use-indexes/),
169
+ [SQLite partial indexes](https://www.sqlite.org/partialindex.html).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aotter/mantle",
3
- "version": "0.0.11-alpha.59",
3
+ "version": "0.0.11-alpha.61",
4
4
  "description": "Umbrella entry for @aotter/mantle. Adopters install this one package and import from subpaths: /spec, /runtime, /cloudflare, /admin-ui. Sub-packages remain individually installable on npm for tooling / alt-adapter authors. The Netlify adapter ships as a private workspace stub in v0.1 — its subpath will be added when the impl lands in v0.2.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://mantle.tools/",
@@ -47,10 +47,10 @@
47
47
  "README.md"
48
48
  ],
49
49
  "dependencies": {
50
- "@aotter/mantle-admin-ui": "0.0.11-alpha.59",
51
- "@aotter/mantle-runtime": "0.0.11-alpha.59",
52
- "@aotter/mantle-cloudflare": "0.0.11-alpha.59",
53
- "@aotter/mantle-spec": "0.0.11-alpha.59"
50
+ "@aotter/mantle-admin-ui": "0.0.11-alpha.61",
51
+ "@aotter/mantle-cloudflare": "0.0.11-alpha.61",
52
+ "@aotter/mantle-spec": "0.0.11-alpha.61",
53
+ "@aotter/mantle-runtime": "0.0.11-alpha.61"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "@cloudflare/workers-oauth-provider": "^0.8.0",