@aotter/mantle 0.1.0-alpha.9 → 0.1.2-alpha.2
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 +38 -38
- package/dist/cli/generate.d.ts +10 -1
- package/dist/cli/generate.d.ts.map +1 -1
- package/dist/cli/generate.js +49 -4
- package/dist/cli/generate.js.map +1 -1
- package/dist/cli/main.js +0 -8
- package/dist/cli/main.js.map +1 -1
- package/dist/codegen/emitMantleModule.js +8 -0
- package/dist/codegen/emitMantleModule.js.map +1 -1
- package/docs/adapter-guide.md +23 -26
- package/docs/adr/0008-structured-diagnostic-shape.md +7 -2
- package/docs/adr/0010-locale-and-translates.md +14 -16
- package/docs/adr/0011-adapter-port-spec.md +2 -1
- package/docs/adr/0014-auth-better-auth-and-multi-tenant-mcp.md +100 -0
- package/docs/adr/0018-core-starters-repository-boundary.md +3 -0
- package/docs/adr/0019-sealed-manifest-runtime-pipeline.md +38 -2
- package/docs/adr/0020-builtin-handler-contracts-and-matched-upsert.md +199 -0
- package/docs/adr/0021-retire-starter-scaffolding.md +30 -0
- package/docs/adr/0022-caller-observed-version-occ.md +212 -0
- package/docs/adr/0023-port-failure-contract.md +69 -0
- package/docs/adr/0024-manifest-native-schema-tables.md +260 -0
- package/docs/adr/README.md +19 -3
- package/docs/adr/adr-lite-803-request-diagnostics.md +38 -0
- package/docs/adr/adr-lite-808-route-readiness.md +47 -0
- package/docs/adr/adr-lite-809-bounded-public-content.md +71 -0
- package/docs/adr/adr-lite-812-native-parity.md +150 -0
- package/docs/adr/adr-lite-823-home-statistics.md +64 -0
- package/docs/adr/adr-lite-842-mcp-authoring.md +30 -0
- package/docs/adr/adr-lite-861-admin-webmcp.md +42 -0
- package/docs/adr/adr-lite-909-admin-ui-kit.md +37 -0
- package/docs/api-mcp-authorization.md +3 -664
- package/docs/auth-hosting-model.md +4 -4
- package/docs/cloudflare-low-level-composition.md +3 -104
- package/docs/deferred-lifecycle-queues.md +2 -243
- package/docs/design-atoms.md +16 -796
- package/docs/direct-authoring.md +5 -0
- package/docs/examples/minimal-worker/README.md +37 -0
- package/docs/examples/minimal-worker/manifests/site.yaml +25 -0
- package/docs/examples/minimal-worker/package.json +26 -0
- package/docs/examples/minimal-worker/smoke.mjs +23 -0
- package/docs/examples/minimal-worker/src/index.ts +4 -0
- package/docs/examples/minimal-worker/tsconfig.json +17 -0
- package/docs/examples/minimal-worker/wrangler.jsonc +22 -0
- package/docs/handbook/cloudflare/authentication.md +167 -0
- package/docs/handbook/cloudflare/bindings.md +236 -0
- package/docs/handbook/cloudflare/conventional-worker.md +162 -0
- package/docs/handbook/cloudflare/deferred-hooks-queues.md +171 -0
- package/docs/handbook/cloudflare/deploy-and-operate.md +105 -0
- package/docs/handbook/cloudflare/low-level-composition.md +169 -0
- package/docs/handbook/cloudflare/media-r2.md +216 -0
- package/docs/handbook/cloudflare/public-web.md +144 -0
- package/docs/handbook/concepts/authorization.md +116 -0
- package/docs/handbook/concepts/four-atoms.md +173 -0
- package/docs/handbook/concepts/lifecycle-and-locales.md +142 -0
- package/docs/handbook/concepts/mcp-and-agents.md +109 -0
- package/docs/handbook/concepts/procedures-and-triggers.md +221 -0
- package/docs/handbook/concepts/runtime-and-adapters.md +177 -0
- package/docs/handbook/concepts/views.md +191 -0
- package/docs/handbook/examples/commerce-transaction.md +813 -0
- package/docs/handbook/examples/guarded-api.md +427 -0
- package/docs/handbook/examples/intake-form.md +319 -0
- package/docs/handbook/examples/legal-documents.md +218 -0
- package/docs/handbook/examples/procurement-approvals.md +240 -0
- package/docs/handbook/examples/publication.md +240 -0
- package/docs/handbook/examples/reservation.md +220 -0
- package/docs/handbook/navigation.json +63 -0
- package/docs/handbook/reference/authorization.md +295 -0
- package/docs/handbook/reference/diagnostics.md +183 -0
- package/docs/handbook/reference/manifest.md +132 -0
- package/docs/handbook/reference/procedure.md +263 -0
- package/docs/handbook/reference/schema.md +250 -0
- package/docs/handbook/reference/site-config.md +233 -0
- package/docs/handbook/reference/surface.md +207 -0
- package/docs/handbook/reference/trigger.md +193 -0
- package/docs/handbook/reference/view.md +256 -0
- package/docs/handbook/start/project-and-cli.md +122 -0
- package/docs/handbook/start/quickstart-worker.md +193 -0
- package/docs/labels.md +3 -1
- package/docs/media-uploads.md +3 -184
- package/docs/migration-0.1.2.md +60 -3
- package/docs/performance-harness.md +127 -10
- package/docs/release-process.md +85 -231
- package/docs/schema-indexes.md +3 -180
- package/docs/sealed-pipeline-ownership.md +6 -5
- package/docs/spec-only-host-adoption.md +158 -0
- package/docs/transaction-patterns.md +5 -0
- package/package.json +21 -25
- package/skills/README.md +4 -4
- package/skills/develop/SKILL.md +23 -28
- package/skills/install/SKILL.md +55 -147
- package/skills/provision/SKILL.md +4 -3
- package/skills/theme/SKILL.md +6 -6
- package/skills/update/SKILL.md +32 -68
- package/dist/cli/create.d.ts +0 -2
- package/dist/cli/create.d.ts.map +0 -1
- package/dist/cli/create.js +0 -243
- package/dist/cli/create.js.map +0 -1
- package/dist/cli/update.d.ts +0 -2
- package/dist/cli/update.d.ts.map +0 -1
- package/dist/cli/update.js +0 -413
- package/dist/cli/update.js.map +0 -1
- package/dist/provision/renderProvisionBundle.d.ts +0 -70
- package/dist/provision/renderProvisionBundle.d.ts.map +0 -1
- package/dist/provision/renderProvisionBundle.js +0 -367
- package/dist/provision/renderProvisionBundle.js.map +0 -1
- package/dist/provision.d.ts +0 -2
- package/dist/provision.d.ts.map +0 -1
- package/dist/provision.js +0 -2
- package/dist/provision.js.map +0 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: The sealed parse-link-compile-prepare-bind pipeline, Core versus optional products, and how Cloudflare, Bun, Vercel and IndexedDB differ.
|
|
3
|
+
---
|
|
4
|
+
# Runtime pipeline and adapters
|
|
5
|
+
|
|
6
|
+
Mantle has exactly one path from YAML to a running service, and one owner for every rule along it. This page walks that pipeline, then shows what changes and what stays fixed when you swap the host underneath it.
|
|
7
|
+
|
|
8
|
+
## The sealed pipeline
|
|
9
|
+
|
|
10
|
+
```txt
|
|
11
|
+
ManifestSourceSet
|
|
12
|
+
-> parse + normalize -> ParsedManifestSet
|
|
13
|
+
-> link -> LinkedManifestSet
|
|
14
|
+
-> compile -> RuntimePlan
|
|
15
|
+
-> prepare deployment -> PreparedRevision
|
|
16
|
+
-> bind runtime -> MantleRuntime
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Each stage's output can be constructed only by the stage that owns it, and a failed stage produces nothing the next stage can use. Parse, link and compile are pure and deterministic.
|
|
20
|
+
|
|
21
|
+
| Stage | Owns | Rejects | Must not own |
|
|
22
|
+
|---|---|---|---|
|
|
23
|
+
| Parse + normalize | YAML syntax and alias limits, the closed four-atom shape, atom-local rules, behavior-affecting defaults, source metadata | Unknown keys, bad envelopes, unsupported JSON Schema keywords, invalid index and `uiSchema` shapes | Cross-atom references, handlers, storage, routes |
|
|
24
|
+
| Link | Duplicate symbols, cross-atom references, guard graphs, translations, manifest-owned route and tool collisions | Unknown Schema or Procedure references, guard self-reference and chains, duplicate HTTP paths, MCP tool-name collisions | I/O, selected modules, handler availability |
|
|
25
|
+
| Compile | Immutable lookup records, authorization plans, Trigger indices, Procedure descriptors, logical View plans, the semantic fingerprint | Nothing new; it projects an already valid graph | Connections, repositories, handlers, requests, templates, assets |
|
|
26
|
+
| Prepare | Storage migrations, native Schema tables and indexes, prepared Views, handler availability, selected capability and reserved-route checks, the readiness revision | Missing handler refs, reserved-path conflicts, `View.spec.sql` on storage that does not declare the SQLite dialect | Re-interpreting YAML, executing requests |
|
|
27
|
+
| Bind and invoke | Semantic ports, handler dispatch, parameter binding, centralized authorization, content, View, Procedure, Trigger and lifecycle operations | Invalid input, unauthorized callers, lifecycle transitions the state machine forbids | DDL, route mounting, assets, HTTP, session and cache policy |
|
|
28
|
+
| Optional modules and adapters | Web and Admin composition; request, session, cache and platform translation | Whatever the platform itself rejects | Re-parsing, re-linking, or a second authorization stack |
|
|
29
|
+
|
|
30
|
+
Nothing downstream may reinterpret raw manifests. That is what makes a rule verifiable exactly once, and why every rejection carries a diagnostic code instead of a guess. See [Diagnostic codes](../reference/diagnostics.md).
|
|
31
|
+
|
|
32
|
+
## What `mantle generate` produces
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
pnpm exec mantle generate
|
|
36
|
+
pnpm exec mantle generate --check
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`generate` validates and compiles the manifests directory, then writes one typed module at `.mantle/generated/mantle.ts` containing the sealed `plan` with its fingerprint, the handler types, and two entry points:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { bindMantle, createMantle, plan } from "../.mantle/generated/mantle.js";
|
|
43
|
+
|
|
44
|
+
// Eager: one preparation attempt, no caching and no retry.
|
|
45
|
+
const mantle = await createMantle({ storage, handlers, ports });
|
|
46
|
+
const notes = await mantle.views.publishedNotes();
|
|
47
|
+
await mantle.entries.orders.createDraft({ data, authorId: user.id });
|
|
48
|
+
|
|
49
|
+
// Or bind a runtime the host already assembled and owns the lifecycle of.
|
|
50
|
+
const bound = bindMantle(runtime);
|
|
51
|
+
await bound.runtime.archive.execute({ id, ctx });
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Generated property names are deterministic lower-camel identifiers; calls keep the authored wire names internally, and a collision is an error (`CODEGEN_IDENTIFIER_COLLISION`). Code generation is a pure projection: it never caches, retries, mounts routes or owns host lifecycle, and the typed API keeps its raw `runtime` so it hides nothing. Skipping generation is valid — call `runtime.executeView({ view: "published-notes" })` by name.
|
|
55
|
+
|
|
56
|
+
## Core, optional products, adapters
|
|
57
|
+
|
|
58
|
+
| Layer | Package | Responsibility |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| Core | `@aotter/mantle-spec` | Sources, parse, normalize, link, introspection, code generation. No runtime or platform dependency. |
|
|
61
|
+
| Core | `@aotter/mantle-runtime` | `RuntimePlan`, preparation contracts, semantic storage ports, `MantleRuntime`. No Web, Admin or platform dependency. |
|
|
62
|
+
| Optional product | `@aotter/mantle-web` | Public HTML, Markdown mirrors, `llms.txt`, sitemap, SEO, preview, templates, path composition. Owns no routes. |
|
|
63
|
+
| Optional product | `@aotter/mantle-admin` | Admin API orchestration, OAuth surfaces, the asset contract. |
|
|
64
|
+
| Optional product | `@aotter/mantle-admin-ui` | The pre-built React Admin SPA artifact. |
|
|
65
|
+
| Adapter | `@aotter/mantle-cloudflare`, `-bun`, `-vercel`, `-indexeddb` | Bind platform storage, lifecycle, request, session, cache and asset concerns to Core ports. |
|
|
66
|
+
|
|
67
|
+
The umbrella `@aotter/mantle` installs Spec and Runtime only; every other subpath is an optional peer you install when you select it. Core does not reserve Admin paths or serve a UI when the module is absent.
|
|
68
|
+
|
|
69
|
+
## Capability matrix
|
|
70
|
+
|
|
71
|
+
| | Cloudflare | Bun | Vercel Functions | IndexedDB |
|
|
72
|
+
|---|---|---|---|---|
|
|
73
|
+
| Storage | D1 through the SQLite chain | Caller-owned `bun:sqlite` `Database` | Any injected `MantleStorageAdapter`; optional `/libsql` Turso driver | One application-owned IndexedDB database |
|
|
74
|
+
| Public View REST | Yes | Yes | Yes | Not mounted; call the runtime directly |
|
|
75
|
+
| HTTP Triggers | Yes | Yes | Yes | Not mounted; `runtime.invokeTrigger` |
|
|
76
|
+
| Admin, Auth, OAuth | Yes | Absent | Absent | Absent |
|
|
77
|
+
| MCP `/mcp`, `/mcp/staff` | Yes | Absent | Absent | Absent (WebMCP is a separate browser binding) |
|
|
78
|
+
| Public web pages | Opt-in via `mountPublicRoutes` plus templates and a resolver | Absent | Absent | Absent |
|
|
79
|
+
| Cache policy | Owned, applied at the final boundary | Host-owned | Host-owned | n/a |
|
|
80
|
+
| `View.spec.sql` | Supported | Supported | Supported on a SQLite-family driver | Rejected at preparation |
|
|
81
|
+
| Optional capabilities | R2 media, Queues deferred hooks, KV catalog cache | None | Platform `waitUntil` | None |
|
|
82
|
+
| Host still owns | Application routes and frontend | `Bun.serve`, auth, CSRF, database shutdown | Web handler, auth, CSRF, route composition | Database naming, persistence requests, UI invalidation, sync |
|
|
83
|
+
|
|
84
|
+
The Manifest does not change across that row. What changes is which surfaces exist to reach it.
|
|
85
|
+
|
|
86
|
+
## Storage ports
|
|
87
|
+
|
|
88
|
+
A storage adapter prepares one `RuntimePlan` into semantic ports. Three are required:
|
|
89
|
+
|
|
90
|
+
| Port | Role |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `MantleStorageAdapter` / `PreparedMantleStorage` | Prepares one plan into a revision: migrations, native Schema tables and indexes, prepared Views |
|
|
93
|
+
| `EntryRepository` and `EntryReader` | Entry writes and reads |
|
|
94
|
+
| `ViewQueryExecutor` | Executes compiled logical View plans |
|
|
95
|
+
|
|
96
|
+
Two are optional and only when a feature needs them: `MediaStorage` for upload flows, and `DeferredHookDispatcher` for at-least-once `after_*` delivery. `DatabaseDriver` is not a portability contract — it is the reusable SQLite/D1 seam. A PostgreSQL, MongoDB or application-owned-table adapter implements the semantic ports directly rather than emulating D1.
|
|
97
|
+
|
|
98
|
+
Declarative Views compile to logical plans once, and preparation lowers those plans to native queries. `View.spec.sql` is the exception: it is explicitly SQLite-only in v0.1. Storage that does not declare that dialect rejects such a View at preparation with `VIEW_DIALECT_UNSUPPORTED`, before mutating any state. Mantle does not guess a translation and ships no universal query driver. A View that must run everywhere uses `from` with a filter AST; see [View](../reference/view.md).
|
|
99
|
+
|
|
100
|
+
## Embedding each adapter
|
|
101
|
+
|
|
102
|
+
Cloudflare, through the conventional facade:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { createMantleWorker } from "@aotter/mantle/cloudflare";
|
|
106
|
+
import { plan } from "../.mantle/generated/mantle.js";
|
|
107
|
+
|
|
108
|
+
export default createMantleWorker({ plan });
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Bun, with the server and SQLite handle staying yours:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import { Database } from "bun:sqlite";
|
|
115
|
+
import { createBunMantle } from "@aotter/mantle-bun";
|
|
116
|
+
|
|
117
|
+
const database = new Database("app.sqlite");
|
|
118
|
+
const mantle = createBunMantle({ plan, database, handlers });
|
|
119
|
+
|
|
120
|
+
Bun.serve({
|
|
121
|
+
async fetch(request) {
|
|
122
|
+
return (await mantle.handle(request)) ?? new Response("not found", { status: 404 });
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Vercel Functions, with storage injected:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
import { SqliteMantleStorageAdapter } from "@aotter/mantle-runtime";
|
|
131
|
+
import { createVercelMantle } from "@aotter/mantle-vercel";
|
|
132
|
+
import { LibsqlDatabaseDriver } from "@aotter/mantle-vercel/libsql";
|
|
133
|
+
|
|
134
|
+
const mantle = createVercelMantle({
|
|
135
|
+
plan,
|
|
136
|
+
handlers,
|
|
137
|
+
storage: new SqliteMantleStorageAdapter(new LibsqlDatabaseDriver(client)),
|
|
138
|
+
});
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Browser IndexedDB, with no HTTP transport at all:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import { bootMantleRuntime } from "@aotter/mantle-runtime";
|
|
145
|
+
import { IndexedDbMantleStorageAdapter } from "@aotter/mantle-indexeddb";
|
|
146
|
+
|
|
147
|
+
const storage = new IndexedDbMantleStorageAdapter({ databaseName: "my-app" });
|
|
148
|
+
const runtime = await bootMantleRuntime({ plan, storage, handlers });
|
|
149
|
+
await runtime.invokeTrigger({ trigger: "rename-board-mcp", input, ctx });
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`handle()` returns `null` for a path Mantle does not own, so the host keeps its own routes. Never treat a Vercel Function's filesystem or `/tmp` as durable state.
|
|
153
|
+
|
|
154
|
+
## The adapter-boundary rule
|
|
155
|
+
|
|
156
|
+
Platform bindings belong at the composition root only: the Worker entry, `createMantleWorker` options, the `bindings` hook and `wrangler.jsonc`. Procedure handlers receive them through `ctx.env`.
|
|
157
|
+
|
|
158
|
+
Application code does not bypass Mantle's storage ports to write Schema tables, `site_config`, media, or Auth tables. Each Schema is a native table, but Mantle still owns its metadata columns, lifecycle checks, and optimistic concurrency. Use Manifests, runtime use cases, `runtime.entries`, `runtime.siteConfig`, generated `bindMantle(runtime)`, and Views instead. An application may own separate tables behind its own repository; that is different from writing around Core's invariants.
|
|
159
|
+
|
|
160
|
+
If a normal feature cannot be expressed through a purpose-shaped surface, treat that as a gap in the abstraction rather than teaching the project Mantle's internals. Internals change between versions; the ports do not.
|
|
161
|
+
|
|
162
|
+
## Related
|
|
163
|
+
|
|
164
|
+
- [The four atoms](./four-atoms.md) — what the pipeline is compiling.
|
|
165
|
+
- [Bindings and primitives](../cloudflare/bindings.md) — the composition root in practice.
|
|
166
|
+
- [Low-level composition](../cloudflare/low-level-composition.md) — assembling a Worker from the same public primitives.
|
|
167
|
+
- [Project layout and the CLI loop](../start/project-and-cli.md) — where `generate` sits in the daily loop.
|
|
168
|
+
|
|
169
|
+
## Source
|
|
170
|
+
- [`docs/adr/0019-sealed-manifest-runtime-pipeline.md`](../../../docs/adr/0019-sealed-manifest-runtime-pipeline.md)
|
|
171
|
+
- [`docs/adapter-guide.md`](../../../docs/adapter-guide.md)
|
|
172
|
+
- [`packages/mantle/README.md`](../../../packages/mantle/README.md)
|
|
173
|
+
- [`packages/mantle-runtime/README.md`](../../../packages/mantle-runtime/README.md)
|
|
174
|
+
- [`packages/adapters/bun/README.md`](../../../packages/adapters/bun/README.md)
|
|
175
|
+
- [`packages/adapters/vercel/README.md`](../../../packages/adapters/vercel/README.md)
|
|
176
|
+
- [`packages/adapters/indexeddb/README.md`](../../../packages/adapters/indexeddb/README.md)
|
|
177
|
+
- [`skills/develop/SKILL.md`](../../../skills/develop/SKILL.md)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Views are the only public read surface — surfaces, declarative versus SQL form, params, identity filters, pagination, the MCP mirror and index planning.
|
|
3
|
+
---
|
|
4
|
+
# Reads: Views, REST and MCP
|
|
5
|
+
|
|
6
|
+
Every read that leaves the runtime goes through a View. This page explains why, how a View is shaped, and what it costs at query time. Field-level rules are in the [View reference](../reference/view.md).
|
|
7
|
+
|
|
8
|
+
## One read surface, not two
|
|
9
|
+
|
|
10
|
+
A View is a named, read-only query over Schemas. Declaring one is enough: it mounts on its surface with no [Trigger](./procedures-and-triggers.md) involved. Schemas are never publicly readable on their own.
|
|
11
|
+
|
|
12
|
+
Exposing collections directly — a `Schema.spec.expose.rest` flag, or a `GET /api/<collection>` shortcut — was considered and rejected. A Schema declares the storage shape. Its entries carry drafts, internal status, server-stamped fields and per-row data the author never intended to publish; `contact-messages` is the canonical example, where a direct collection route would be a privacy bug by default. A View already has the right semantics — a named query with explicit fields, filter, ordering and limit — so auto-exposing it only ratifies what the manifest already says.
|
|
13
|
+
|
|
14
|
+
There is no internal-only View surface either. A query that should not be externally callable stays a TypeScript helper.
|
|
15
|
+
|
|
16
|
+
## Surfaces
|
|
17
|
+
|
|
18
|
+
`spec.surface` is required and closed to two values.
|
|
19
|
+
|
|
20
|
+
| `surface` | REST | MCP | Extra |
|
|
21
|
+
|---|---|---|---|
|
|
22
|
+
| `public` | `GET /api/views/<name>` | `query_view_<segment>` on `/mcp` | Listed by `GET /api/views` |
|
|
23
|
+
| `staff` | `GET /admin/api/views/<name>` | `query_view_<segment>` on `/mcp/staff` | `GET /admin/api/views/<name>/export`, Admin report sidebar |
|
|
24
|
+
|
|
25
|
+
The adapter filters the View set before building each MCP dispatcher, so a guessed public tool call cannot reach a staff View. Surface decides transport visibility; `spec.requires` decides whether the verified caller may execute the View, on REST and MCP alike. See [Authorization](./authorization.md).
|
|
26
|
+
|
|
27
|
+
## Declarative `from` versus one `sql` SELECT
|
|
28
|
+
|
|
29
|
+
A View declares exactly one of `from` or `sql`; declaring both or neither is rejected.
|
|
30
|
+
|
|
31
|
+
- **`from`** names a Schema and pairs with `fields`, `filter`, `orderBy` and `limit`. The filter is a closed AST: `eq`, `gt`, `gte`, `lt`, `lte`, combined with `and` and `or`. Field-to-field comparison and arithmetic are not expressible.
|
|
32
|
+
- **`sql`** is a single `SELECT` with no semicolon. Every Schema is available as a logical table named after its `metadata.name`, with data properties projected as columns; quote names containing hyphens (`"post-translations"`). Combining `sql` with `fields`, `filter` or `orderBy` is rejected.
|
|
33
|
+
|
|
34
|
+
**New Views should use one `SELECT`.** The declarative form remains accepted for existing manifests. SQL Views compile to native SQLite; a storage adapter that cannot run them fails at boot with `VIEW_DIALECT_UNSUPPORTED`, and static validation never executes the statement — run it against the selected adapter or through `mantle-harness indexes`.
|
|
35
|
+
|
|
36
|
+
## Params
|
|
37
|
+
|
|
38
|
+
`spec.params` is a JSON Schema with `type: object` and a `properties` map. The reserved names `page`, `show` and `cursor` are rejected (`VIEW_PARAMS_RESERVED_NAME`); the rest of the query-string namespace is yours.
|
|
39
|
+
|
|
40
|
+
In the declarative form, a filter value may be the sentinel `{ $param: <name> }`. The name must resolve to a declared param and must appear in `params.required`. In a SQL View, params are named bindings written `:name`; they are bound, never interpolated, and every `:name` must be a required declared param.
|
|
41
|
+
|
|
42
|
+
## Identity-bound Views
|
|
43
|
+
|
|
44
|
+
A View can filter to the calling user's own rows with the closed sentinel `{ "$ctx.user": "id" }`. Three rules apply, each with its own diagnostic:
|
|
45
|
+
|
|
46
|
+
1. It appears only under `eq`, as a single-key object whose value is the literal string `"id"` — otherwise `VIEW_FILTER_CTX_USER_REF_INVALID`.
|
|
47
|
+
2. The View must require a signed-in user: `ctx.user` in `requires.auth.all` — otherwise `VIEW_FILTER_CTX_USER_REF_REQUIRES_AUTH`.
|
|
48
|
+
3. The compared field must be the leftmost field of a declared index on the source Schema — otherwise `VIEW_FILTER_CTX_USER_REF_REQUIRES_INDEX`.
|
|
49
|
+
|
|
50
|
+
This is a filter, not a row-level policy engine. `requires` authorizes the whole query; it does not inject per-row visibility predicates. Membership, payment and entitlement checks belong in a guard Procedure. See [Procurement approvals](../examples/procurement-approvals.md).
|
|
51
|
+
|
|
52
|
+
## Shared response cache
|
|
53
|
+
|
|
54
|
+
Caller-independent published data may declare `cache: { sharedMaxAge: <seconds> }`, with an integer from 1 through 86400. The Cloudflare adapter applies it only to anonymous REST responses when the Worker also has a stable `cacheScope`. Staff, guarded, identity-bound, SQL and operational-schema Views cannot opt in; MCP and WebMCP calls remain private. Publishing writes purge the deployment-scoped tag after the canonical write.
|
|
55
|
+
|
|
56
|
+
## Pagination and the envelope
|
|
57
|
+
|
|
58
|
+
REST callers pass `?page=<1-indexed>&show=<page size>`; MCP callers pass the same two names as tool arguments.
|
|
59
|
+
|
|
60
|
+
| Knob | Behavior |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `View.spec.limit` | The server-enforced cap. Missing or invalid becomes 50; otherwise the floor of the value, capped at 500. |
|
|
63
|
+
| `show` | Missing, non-numeric or non-positive becomes the cap; otherwise the smaller of the request and the cap. |
|
|
64
|
+
| `page` | Missing, non-numeric or below 1 becomes 1. |
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{ "ok": true, "data": { "rows": [], "page": 1, "show": 20, "hasMore": false } }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`hasMore` is lazy: it is true exactly when `rows.length === show`. There is no `COUNT(*)` and no `LIMIT n+1` probe, so a full final page reports `hasMore: true` and the next request returns an empty list. That is one false positive on the boundary in exchange for no extra round-trip on every request.
|
|
71
|
+
|
|
72
|
+
Query strings arrive as strings, so the transport boundary coerces each declared param: `string` is identity, `integer` uses `parseInt` and rejects float-like input, `number` uses `Number()`, `boolean` accepts only `"true"` and `"false"`, and an `enum` is matched against its array. A missing required param or a coercion failure is `INPUT_VALIDATION_FAILED` (HTTP 400). Unknown query-string keys are ignored.
|
|
73
|
+
|
|
74
|
+
## Staff report lists
|
|
75
|
+
|
|
76
|
+
A staff View may opt into Admin's standard list chrome without changing its REST or MCP contract:
|
|
77
|
+
|
|
78
|
+
```yaml
|
|
79
|
+
spec:
|
|
80
|
+
uiSchema:
|
|
81
|
+
list:
|
|
82
|
+
columns: [orderNumber, customerName, orderStatus]
|
|
83
|
+
searchFields: [orderNumber, customerName, customerEmail]
|
|
84
|
+
filterFields: [orderStatus]
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
These names are output field names — SQL aliases for a `sql` View, Schema properties or reserved columns for a `from` View. Admin applies search and exact filters *before* pagination and carries them into `GET /admin/api/views/<name>/export`, whose CSV contains every matching row, not only the visible page. `uiSchema` is staff-only; a public View that declares it is `VIEW_UI_INVALID`.
|
|
88
|
+
|
|
89
|
+
## The MCP mirror
|
|
90
|
+
|
|
91
|
+
Every View is also a tool. The name is the View name lowercased with hyphens replaced by underscores, prefixed `query_view_`. Its input schema is `params.properties` plus `page` and `show`, and it is annotated `readOnlyHint: true`. One executor and one response shape serve REST and MCP, so an agent and a downstream service read exactly the same rows. See [MCP and agents](./mcp-and-agents.md).
|
|
92
|
+
|
|
93
|
+
## Performance: declare the index the query needs
|
|
94
|
+
|
|
95
|
+
On SQLite and D1, every Schema is a native table and each top-level property is
|
|
96
|
+
a native column. Declared `indexes` and `uniqueIndexes` become B-tree indexes
|
|
97
|
+
over those columns; Core-compiled projections, filters and ordering reference
|
|
98
|
+
the same columns directly.
|
|
99
|
+
|
|
100
|
+
Declare the **smallest ordered index justified by the measured path**, and respect SQLite's leftmost-prefix rule. An index on `[locale, publishedAt]` serves `WHERE locale = ?`, `WHERE locale = ? AND publishedAt > ?`, and `WHERE locale = ? ORDER BY publishedAt`. It does not serve `WHERE publishedAt > ?` alone. If a second hot path needs a different leading field, that is a second index — not a reason to enumerate every permutation, since each index costs storage and slows every write.
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
pnpm exec mantle-harness indexes --require-public --format text
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The harness applies the real migrations and generated DDL, seeds skewed rows,
|
|
107
|
+
compiles and executes the actual View SQL, and records `EXPLAIN QUERY PLAN`. A
|
|
108
|
+
healthy indexed plan contains `SEARCH <schema-table> USING INDEX`; an indexed
|
|
109
|
+
filter-and-order path should not contain `USE TEMP B-TREE FOR ORDER BY`.
|
|
110
|
+
Findings are advisory unless you opt a View into the gate. Never change
|
|
111
|
+
user-visible filter or ordering semantics just to make the gate pass.
|
|
112
|
+
|
|
113
|
+
## Example: a public localized list
|
|
114
|
+
|
|
115
|
+
```yaml
|
|
116
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
117
|
+
kind: View
|
|
118
|
+
metadata:
|
|
119
|
+
name: published-announcements
|
|
120
|
+
spec:
|
|
121
|
+
title: Published announcements
|
|
122
|
+
surface: public
|
|
123
|
+
from: announcements
|
|
124
|
+
cache: { sharedMaxAge: 3600 }
|
|
125
|
+
params:
|
|
126
|
+
type: object
|
|
127
|
+
additionalProperties: false
|
|
128
|
+
required: [locale]
|
|
129
|
+
properties:
|
|
130
|
+
locale: { type: string }
|
|
131
|
+
fields: [id, slug, locale, title, summary, publishedAt]
|
|
132
|
+
filter:
|
|
133
|
+
and:
|
|
134
|
+
- eq: { field: status, value: published }
|
|
135
|
+
- eq: { field: locale, value: { $param: locale } }
|
|
136
|
+
- gte: { field: publishedAt, value: 0 }
|
|
137
|
+
orderBy:
|
|
138
|
+
- { field: publishedAt, direction: desc }
|
|
139
|
+
limit: 50
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The source Schema declares `indexes: [[locale, publishedAt]]`. The `gte publishedAt 0` clause is what keeps the ordered column inside the indexed range rather than forcing a sort. Omitting `locale` returns 400.
|
|
143
|
+
|
|
144
|
+
## Example: a staff SQL report
|
|
145
|
+
|
|
146
|
+
A `sql` View earns its keep when the answer is not one row per entry. Here an operational `requests` Schema stores a `tags` array, and the report needs one row per tag so staff can search and filter by tag:
|
|
147
|
+
|
|
148
|
+
```yaml
|
|
149
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
150
|
+
kind: View
|
|
151
|
+
metadata:
|
|
152
|
+
name: requests-by-tag
|
|
153
|
+
spec:
|
|
154
|
+
title: Requests by tag
|
|
155
|
+
surface: staff
|
|
156
|
+
sql: |
|
|
157
|
+
SELECT r._mantle_id AS requestId,
|
|
158
|
+
r.subject AS subject,
|
|
159
|
+
r.requestStatus AS requestStatus,
|
|
160
|
+
tag.value AS tag,
|
|
161
|
+
r._mantle_created_at AS createdAt
|
|
162
|
+
FROM requests AS r
|
|
163
|
+
JOIN json_each(r.tags) AS tag
|
|
164
|
+
WHERE r.requestStatus = :requestStatus
|
|
165
|
+
ORDER BY r._mantle_created_at DESC
|
|
166
|
+
params:
|
|
167
|
+
type: object
|
|
168
|
+
required: [requestStatus]
|
|
169
|
+
properties:
|
|
170
|
+
requestStatus: { type: string, enum: [open, waiting, closed] }
|
|
171
|
+
uiSchema:
|
|
172
|
+
list:
|
|
173
|
+
columns: [subject, tag, requestStatus]
|
|
174
|
+
searchFields: [subject, tag]
|
|
175
|
+
filterFields: [tag]
|
|
176
|
+
limit: 200
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`tags` is an array property, so `json_each` unnests it and one request appears once per tag. The runtime wraps the whole statement as a subquery before applying pagination, which is why Admin's search and filters attach to the SQL output aliases — `tag` is a real filterable column even though no Schema property is named `tag`. See [Commerce and settlement](../examples/commerce-transaction.md) for the same technique over order lines.
|
|
180
|
+
|
|
181
|
+
## Source
|
|
182
|
+
|
|
183
|
+
- [`docs/adr/0012-views-as-public-rest.md`](../../../docs/adr/0012-views-as-public-rest.md)
|
|
184
|
+
- [`docs/schema-indexes.md`](../../../docs/schema-indexes.md)
|
|
185
|
+
- [`packages/mantle-spec/src/domain/service/ManifestGraphValidator.ts`](../../../packages/mantle-spec/src/domain/service/ManifestGraphValidator.ts)
|
|
186
|
+
- [`packages/mantle-runtime/src/domain/service/Pagination.ts`](../../../packages/mantle-runtime/src/domain/service/Pagination.ts)
|
|
187
|
+
- [`packages/mantle-runtime/src/domain/service/ViewParamCoercer.ts`](../../../packages/mantle-runtime/src/domain/service/ViewParamCoercer.ts)
|
|
188
|
+
- [`packages/mantle-runtime/src/infrastructure/storage/SqliteViewCompiler.ts`](../../../packages/mantle-runtime/src/infrastructure/storage/SqliteViewCompiler.ts)
|
|
189
|
+
- [`packages/mantle-runtime/src/infrastructure/http/createMantleRequestHandler.ts`](../../../packages/mantle-runtime/src/infrastructure/http/createMantleRequestHandler.ts)
|
|
190
|
+
- [`packages/adapters/cloudflare/src/mount/mountRuntimeEndpoints.ts`](../../../packages/adapters/cloudflare/src/mount/mountRuntimeEndpoints.ts)
|
|
191
|
+
- [`skills/develop/SKILL.md`](../../../skills/develop/SKILL.md)
|