@aotter/mantle 0.0.11-alpha.59 → 0.0.11-alpha.60
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 +6 -0
- package/docs/adapter-guide.md +10 -1
- package/docs/adr/0001-four-atom-manifest-model.md +2 -1
- package/docs/adr/0007-ai-as-primary-author.md +2 -2
- package/docs/adr/0011-adapter-port-spec.md +12 -2
- package/docs/deferred-lifecycle-queues.md +267 -0
- package/docs/design-atoms.md +46 -33
- package/docs/schema-indexes.md +169 -0
- package/package.json +5 -5
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
|
|
package/docs/adapter-guide.md
CHANGED
|
@@ -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
|
|
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
|
-
`
|
|
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`
|
|
99
|
-
|
|
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
|
|
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
|
|
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.
|
package/docs/design-atoms.md
CHANGED
|
@@ -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,
|
|
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`
|
|
679
|
-
|
|
680
|
-
(`@aotter/mantle-spec`)
|
|
681
|
-
same table without colliding
|
|
682
|
-
is gated by `WHEN collection
|
|
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 +
|
|
692
|
-
indexes
|
|
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
|
|
712
|
-
|
|
713
|
-
|
|
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 `
|
|
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`.
|
|
884
|
-
|
|
885
|
-
|
|
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
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.0.11-alpha.60",
|
|
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.
|
|
51
|
-
"@aotter/mantle-runtime": "0.0.11-alpha.
|
|
52
|
-
"@aotter/mantle-
|
|
53
|
-
"@aotter/mantle-
|
|
50
|
+
"@aotter/mantle-admin-ui": "0.0.11-alpha.60",
|
|
51
|
+
"@aotter/mantle-runtime": "0.0.11-alpha.60",
|
|
52
|
+
"@aotter/mantle-spec": "0.0.11-alpha.60",
|
|
53
|
+
"@aotter/mantle-cloudflare": "0.0.11-alpha.60"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
56
|
"@cloudflare/workers-oauth-provider": "^0.8.0",
|