@aotter/mantle 0.2.0-alpha.5 → 0.2.0-alpha.6
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 +2 -2
- package/dist/admin/createAdminSurface.d.ts +3 -0
- package/dist/admin/createAdminSurface.d.ts.map +1 -1
- package/dist/admin/createAdminSurface.js +51 -8
- package/dist/admin/createAdminSurface.js.map +1 -1
- package/dist/admin/extensions.d.ts +290 -0
- package/dist/admin/extensions.d.ts.map +1 -0
- package/dist/admin/extensions.js +532 -0
- package/dist/admin/extensions.js.map +1 -0
- package/dist/admin/index.d.ts +1 -0
- package/dist/admin/index.d.ts.map +1 -1
- package/dist/admin/index.js +1 -0
- package/dist/admin/index.js.map +1 -1
- package/dist/auth/buildAuth.d.ts +3 -1
- package/dist/auth/buildAuth.d.ts.map +1 -1
- package/dist/auth/buildAuth.js +3 -2
- package/dist/auth/buildAuth.js.map +1 -1
- package/dist/auth/callerResolver.d.ts +4 -0
- package/dist/auth/callerResolver.d.ts.map +1 -1
- package/dist/auth/callerResolver.js +4 -1
- package/dist/auth/callerResolver.js.map +1 -1
- package/dist/auth/createMantleAuth.d.ts.map +1 -1
- package/dist/auth/createMantleAuth.js +3 -2
- package/dist/auth/createMantleAuth.js.map +1 -1
- package/dist/auth/db.d.ts +1 -0
- package/dist/auth/db.d.ts.map +1 -1
- package/dist/auth/db.js +6 -3
- package/dist/auth/db.js.map +1 -1
- package/dist/bun/index.d.ts +0 -44
- package/dist/bun/index.d.ts.map +1 -1
- package/dist/bun/index.js +0 -84
- package/dist/bun/index.js.map +1 -1
- package/dist/cli/generate.js +3 -3
- package/dist/cli/generate.js.map +1 -1
- package/dist/cli/preset.d.ts.map +1 -1
- package/dist/cli/preset.js +80 -28
- package/dist/cli/preset.js.map +1 -1
- package/dist/cloudflare/index.d.ts +4 -0
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +2 -0
- package/dist/cloudflare/index.js.map +1 -1
- package/dist/core/dialect.d.ts +13 -1
- package/dist/core/dialect.d.ts.map +1 -1
- package/dist/core/driver.d.ts +7 -0
- package/dist/core/driver.d.ts.map +1 -1
- package/dist/core/runtime/createRuntime.js +1 -1
- package/dist/core/runtime/createRuntime.js.map +1 -1
- package/dist/core/sessionUser.d.ts +14 -0
- package/dist/core/sessionUser.d.ts.map +1 -0
- package/dist/core/sessionUser.js +4 -0
- package/dist/core/sessionUser.js.map +1 -0
- package/dist/core/sql/allowlist.d.ts +2 -0
- package/dist/core/sql/allowlist.d.ts.map +1 -1
- package/dist/core/sql/allowlist.js +43 -11
- package/dist/core/sql/allowlist.js.map +1 -1
- package/dist/core/sql/ast.js +1 -1
- package/dist/core/sql/ast.js.map +1 -1
- package/dist/core/sql/compile.js +1 -1
- package/dist/core/sql/compile.js.map +1 -1
- package/dist/core/sql/policy.d.ts +2 -0
- package/dist/core/sql/policy.d.ts.map +1 -1
- package/dist/core/sql/policy.js +25 -6
- package/dist/core/sql/policy.js.map +1 -1
- package/dist/core/sql/run.d.ts +0 -8
- package/dist/core/sql/run.d.ts.map +1 -1
- package/dist/core/sql/run.js +152 -35
- package/dist/core/sql/run.js.map +1 -1
- package/dist/d1/executor.d.ts +2 -0
- package/dist/d1/executor.d.ts.map +1 -1
- package/dist/d1/executor.js +6 -1
- package/dist/d1/executor.js.map +1 -1
- package/dist/postgres/adapter.d.ts +5 -2
- package/dist/postgres/adapter.d.ts.map +1 -1
- package/dist/postgres/adapter.js +7 -1
- package/dist/postgres/adapter.js.map +1 -1
- package/dist/postgres/compile/index.d.ts +1 -1
- package/dist/postgres/compile/index.js +1 -1
- package/dist/postgres/driver.d.ts +38 -21
- package/dist/postgres/driver.d.ts.map +1 -1
- package/dist/postgres/driver.js +100 -24
- package/dist/postgres/driver.js.map +1 -1
- package/dist/postgres/executor.d.ts +8 -0
- package/dist/postgres/executor.d.ts.map +1 -1
- package/dist/postgres/executor.js +40 -13
- package/dist/postgres/executor.js.map +1 -1
- package/dist/postgres/index.d.ts +2 -1
- package/dist/postgres/index.d.ts.map +1 -1
- package/dist/postgres/index.js +1 -0
- package/dist/postgres/index.js.map +1 -1
- package/dist/postgres/lower.d.ts.map +1 -1
- package/dist/postgres/lower.js +3 -8
- package/dist/postgres/lower.js.map +1 -1
- package/dist/postgres/print.d.ts.map +1 -1
- package/dist/postgres/print.js +2 -15
- package/dist/postgres/print.js.map +1 -1
- package/dist/postgres/session.d.ts +9 -0
- package/dist/postgres/session.d.ts.map +1 -0
- package/dist/postgres/session.js +111 -0
- package/dist/postgres/session.js.map +1 -0
- package/dist/postgres/storage.d.ts +14 -1
- package/dist/postgres/storage.d.ts.map +1 -1
- package/dist/postgres/storage.js +136 -54
- package/dist/postgres/storage.js.map +1 -1
- package/dist/postgres/validator.d.ts +4 -3
- package/dist/postgres/validator.d.ts.map +1 -1
- package/dist/postgres/validator.js +13 -6
- package/dist/postgres/validator.js.map +1 -1
- package/dist/spec/domain/model/ManifestGrammar.d.ts +4 -0
- package/dist/spec/domain/model/ManifestGrammar.d.ts.map +1 -1
- package/dist/spec/domain/model/ManifestGrammar.js +4 -0
- package/dist/spec/domain/model/ManifestGrammar.js.map +1 -1
- package/dist/spec/domain/service/SchemaAdminUiChecker.d.ts +2 -0
- package/dist/spec/domain/service/SchemaAdminUiChecker.d.ts.map +1 -1
- package/dist/spec/domain/service/SchemaAdminUiChecker.js +50 -6
- package/dist/spec/domain/service/SchemaAdminUiChecker.js.map +1 -1
- package/dist/spec/domain/service/ViewAdminUiChecker.d.ts +1 -1
- package/dist/spec/domain/service/ViewAdminUiChecker.d.ts.map +1 -1
- package/dist/spec/domain/service/ViewAdminUiChecker.js +7 -2
- package/dist/spec/domain/service/ViewAdminUiChecker.js.map +1 -1
- package/dist/spec/infrastructure/sql/compilePlan.d.ts.map +1 -1
- package/dist/spec/infrastructure/sql/compilePlan.js +5 -0
- package/dist/spec/infrastructure/sql/compilePlan.js.map +1 -1
- package/dist/spec/kernel/diagnostic.d.ts +1 -1
- package/dist/spec/kernel/diagnostic.d.ts.map +1 -1
- package/dist/spec/kernel/diagnostic.js +5 -0
- package/dist/spec/kernel/diagnostic.js.map +1 -1
- package/dist/testing/cases/corpus.d.ts +2 -0
- package/dist/testing/cases/corpus.d.ts.map +1 -1
- package/dist/testing/cases/corpus.js +7 -5
- package/dist/testing/cases/corpus.js.map +1 -1
- package/dist/testing/cases/policy.d.ts +14 -1
- package/dist/testing/cases/policy.d.ts.map +1 -1
- package/dist/testing/cases/policy.js +3 -2
- package/dist/testing/cases/policy.js.map +1 -1
- package/dist/testing/cases/printer.d.ts.map +1 -1
- package/dist/testing/cases/printer.js +3 -1
- package/dist/testing/cases/printer.js.map +1 -1
- package/dist/testing/cases/report-view.d.ts.map +1 -1
- package/dist/testing/cases/report-view.js +18 -0
- package/dist/testing/cases/report-view.js.map +1 -1
- package/docs/adr/0037-postgresql-is-the-reference-dialect.md +2 -1
- package/docs/adr/0039-postgresql-is-native.md +130 -0
- package/docs/adr/README.md +2 -0
- package/docs/adr/adr-lite-1376-admin-extensions.md +330 -0
- package/docs/examples/commerce-inventory.md +18 -1
- package/docs/examples/reference-service/package.json +2 -2
- package/docs/handbook/cloudflare/authentication.md +3 -3
- package/docs/handbook/cloudflare/deploy-and-operate.md +4 -1
- package/docs/handbook/concepts/runtime-and-adapters.md +74 -33
- package/docs/handbook/concepts/views.md +1 -1
- package/docs/handbook/guides/admin-extensions.md +191 -0
- package/docs/handbook/guides/admin-ui.md +2 -1
- package/docs/handbook/navigation.json +2 -1
- package/docs/handbook/reference/diagnostics.md +1 -0
- package/docs/handbook/reference/procedure.md +2 -1
- package/docs/handbook/reference/schema.md +6 -3
- package/docs/handbook/reference/surface.md +6 -2
- package/docs/handbook/reference/view.md +7 -5
- package/docs/handbook/releases/index.md +78 -4
- package/docs/handbook/start/project-and-cli.md +20 -12
- package/docs/skills/develop/SKILL.md +8 -2
- package/docs/skills/provision/SKILL.md +4 -1
- package/docs/skills/update/SKILL.md +3 -0
- package/docs/upgrade-0.1-to-0.2.md +1 -1
- package/package.json +6 -5
- package/dist/postgres/textRows.d.ts +0 -10
- package/dist/postgres/textRows.d.ts.map +0 -1
- package/dist/postgres/textRows.js +0 -44
- package/dist/postgres/textRows.js.map +0 -1
|
@@ -103,13 +103,28 @@ dialect, and boot refuses a plan compiled for another one.
|
|
|
103
103
|
PostgreSQL (13 or later). On Cloudflare, `mantle generate --dialect postgres`
|
|
104
104
|
writes the preset over Hyperdrive; on any other platform, add `"host": "none"`
|
|
105
105
|
and compose `createMantle` with `postgresStorage` yourself (ADR-0036). PostgreSQL
|
|
106
|
-
is Mantle SQL's reference dialect (ADR-0037)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
[View reference](../reference/view.md)'s PostgreSQL table. A manifest
|
|
111
|
-
|
|
112
|
-
|
|
106
|
+
is Mantle SQL's reference dialect (ADR-0037), and its SQL is PostgreSQL's own
|
|
107
|
+
(ADR-0039): Mantle lowers nothing to imitate SQLite. It accepts D1's subset except
|
|
108
|
+
SQLite's own vocabulary (below), plus `WITH`, set operations, `LATERAL`, window
|
|
109
|
+
frames, `FILTER`, jsonb operators and the rest of the
|
|
110
|
+
[View reference](../reference/view.md)'s PostgreSQL table. A manifest targets one
|
|
111
|
+
dialect: it need not compile for D1 or return the same values there. D1 to
|
|
112
|
+
PostgreSQL is a migration (export, rewrite the SQL, import), not a switch.
|
|
113
|
+
|
|
114
|
+
A SQLite spelling fails validation with its position and the PostgreSQL one:
|
|
115
|
+
|
|
116
|
+
| SQLite (D1) | PostgreSQL |
|
|
117
|
+
|---|---|
|
|
118
|
+
| `FROM t, json_each(t.col) j`, `j.value` | `FROM t, jsonb_array_elements_text(t.col) WITH ORDINALITY AS j(value, n)` (`jsonb_array_elements` for jsonb values, `jsonb_each_text(t.col) WITH ORDINALITY AS j(key, value, n)` for an object) |
|
|
119
|
+
| `ORDER BY …, j.id` of `json_each` | `WITH ORDINALITY AS j(value, n)`, then `ORDER BY …, j.n` (a sorted View over a row source needs `WITH ORDINALITY`; Mantle pages by it) |
|
|
120
|
+
| `x ->> '$.a.b'`, `x ->> '$[0]'` | `x #>> '{a,b}'`, `x ->> 'a'`, `x ->> 0` (a `$…` string is refused: PostgreSQL would read it as a key) |
|
|
121
|
+
| `json_extract(x, '$.a')` | `x ->> 'a'` (text) or `x -> 'a'` (jsonb) |
|
|
122
|
+
| `json_remove(x, '$.a')` | `x - 'a'` |
|
|
123
|
+
| `json_set`, `json_insert` | build the value with `jsonb_build_object` or `jsonb_build_array` |
|
|
124
|
+
| `CAST(n AS bool)` of an integer | `n <> 0` (PostgreSQL has no bigint to boolean cast; `CAST('true' AS bool)` is unchanged) |
|
|
125
|
+
| `2 * 3 \|\| 4` (text by any type) | `2 * 3 \|\| '4'`, or `CAST(n AS text) \|\| …`: `\|\|` needs a text operand |
|
|
126
|
+
| `hex(x)`, `typeof(x)` | no equivalent on the allowlist; refused |
|
|
127
|
+
| `NULL` first when ascending | `NULL` last when ascending (PostgreSQL's; say `NULLS FIRST` to override) |
|
|
113
128
|
|
|
114
129
|
- Columns have native types: `timestamptz`, `date`, `numeric(p, s)`, `boolean`,
|
|
115
130
|
`jsonb`, `bigint` and `double precision`. Values are decoded by the type
|
|
@@ -120,26 +135,39 @@ recompile.
|
|
|
120
135
|
concurrent writers as it does on SQLite's single writer.
|
|
121
136
|
- `checks` are `CHECK` constraints added `NOT VALID`: they bind every later
|
|
122
137
|
write and leave older rows alone, like D1's triggers.
|
|
123
|
-
-
|
|
124
|
-
SQL
|
|
125
|
-
`
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
138
|
+
- `->>`, `||`, `CAST` and the jsonb row sources mean what PostgreSQL says; the
|
|
139
|
+
only SQL function Mantle creates is `_mantle_expect`. A database booted by an
|
|
140
|
+
earlier release keeps its `_mantle_jget`, `_mantle_bool` and `_mantle_json_each`
|
|
141
|
+
unused. `json_group_array` and `json_group_object` order by value.
|
|
142
|
+
- Text columns use `COLLATE "C"`, so they compare by code point. An `ORDER BY` key without `NULLS FIRST/LAST`
|
|
143
|
+
sorts NULL as PostgreSQL does: last ascending, first descending (D1 puts it
|
|
144
|
+
first ascending). The `id` tiebreak follows the last key's direction, so an
|
|
145
|
+
index on the sort keys (and the `updated_at` index Mantle creates) serves a
|
|
146
|
+
paged sort. This is PostgreSQL's default for every `ORDER BY`, window and
|
|
147
|
+
aggregate (`json_group_array`) ones included. Values never depend on
|
|
129
148
|
the server's `DateStyle`, `IntervalStyle` or `TimeZone`. Elsewhere the meaning
|
|
130
149
|
is PostgreSQL's, where D1 differs:
|
|
131
150
|
- `LIKE` is case-sensitive.
|
|
132
151
|
- Division by zero is an error.
|
|
133
152
|
- `->>` returns text.
|
|
134
|
-
- `||` prints booleans and floats as PostgreSQL does.
|
|
135
153
|
- `searchableFields` and `mantle.near()` scan without an index in 0.2.0, and
|
|
136
154
|
`mantle.search_rank()` counts occurrences rather than computing bm25.
|
|
137
155
|
Site settings and media are SQLite-only (D1, bun:sqlite).
|
|
138
156
|
- `date_trunc` and `extract` compute in the site time zone
|
|
139
157
|
(`postgresStorage({ connect, timeZone })`, default UTC).
|
|
140
|
-
- Every statement has a `statement_timeout` of 10 seconds
|
|
141
|
-
|
|
142
|
-
A statement
|
|
158
|
+
- Every statement has a `statement_timeout` of at most 10 seconds
|
|
159
|
+
(`statementTimeoutMs`; 0 is none). A write batch sets it on its transaction.
|
|
160
|
+
A read is one statement outside a transaction, under the role's own
|
|
161
|
+
`statement_timeout`, so boot requires the role to have one no larger
|
|
162
|
+
(`ALTER ROLE app SET statement_timeout = '10s'`). Storage convergence has
|
|
163
|
+
none. A statement past it fails with `RESOURCE_UNAVAILABLE` and writes nothing.
|
|
164
|
+
- Boot reads the role's settings once and refuses to start, naming the
|
|
165
|
+
`ALTER ROLE … SET` to run, unless `DateStyle` is ISO, `IntervalStyle` is
|
|
166
|
+
`postgres`, `extra_float_digits` is at least 1, `standard_conforming_strings`
|
|
167
|
+
is on and `TimeZone` is UTC (date and instant casts in your SQL use the
|
|
168
|
+
session's zone). These are PostgreSQL's defaults, except a server initialized
|
|
169
|
+
in another time zone. Mantle sets nothing per session, because
|
|
170
|
+
Hyperdrive resets every pooled session to the role's configuration.
|
|
143
171
|
- Connect as a role that owns the service's tables but is not a superuser and
|
|
144
172
|
holds no file or server privilege (`pg_read_server_files`,
|
|
145
173
|
`pg_execute_server_program`). Mantle's allowlist refuses such functions; the
|
|
@@ -151,40 +179,53 @@ recompile.
|
|
|
151
179
|
|---|---|---|
|
|
152
180
|
| `d1Storage(env.DB, { timeZone?, site? })` | `@aotter/mantle/cloudflare` | Cloudflare D1, the one the preset uses |
|
|
153
181
|
| `sqliteStorage(driver, { timeZone?, maxBindings?, site?, restrict? })` | `@aotter/mantle/d1` | any `DatabaseDriver`: `{ batch(statements) }`, all or nothing |
|
|
154
|
-
| `postgresStorage({ connect, timeZone?, statementTimeoutMs?, restrict? })` | `@aotter/mantle/postgres` | `connect` opens one node-postgres (`pg`) client
|
|
155
|
-
| `bunPostgresStorage(sql, { timeZone?, statementTimeoutMs?, restrict? })` | `@aotter/mantle/bun` | a Bun.SQL pool with `prepare: false`; its role needs TEMPORARY (ADR-0038) |
|
|
182
|
+
| `postgresStorage({ connect, timeZone?, statementTimeoutMs?, restrict? })` | `@aotter/mantle/postgres` | `connect` opens one node-postgres (`pg`) client: Hyperdrive on Workers, a `pg.Pool` on Bun (ADR-0039) |
|
|
156
183
|
| `bunSqliteStorage(db, { timeZone?, site?, restrict? })` | `@aotter/mantle/bun` | a bun:sqlite `Database`; foreign keys are turned on |
|
|
157
184
|
|
|
158
185
|
`restrict(plan, context)` returns refusals of its own, run after the dialect's
|
|
159
186
|
on every program at runtime. It only narrows what runs, for an operator that
|
|
160
187
|
runs other people's plans; a self-hosted service leaves it out (ADR-0037).
|
|
161
188
|
|
|
162
|
-
On Workers, PostgreSQL goes through Hyperdrive, which pools the connections
|
|
163
|
-
`connect` opens
|
|
189
|
+
On Workers, PostgreSQL goes through Hyperdrive, which pools the connections.
|
|
190
|
+
`connect` opens one client, and `requestScoped` makes it one per request: every
|
|
191
|
+
query of a request (Store, Better Auth, auth SQL) reuses it, and it is ended
|
|
192
|
+
when the request ends, as a Worker's socket must be. Outside `run` (boot,
|
|
193
|
+
schedules) each operation opens its own:
|
|
164
194
|
|
|
165
195
|
```ts
|
|
166
196
|
import pg from "pg";
|
|
167
|
-
import { pgDatabaseDriver, pgPool, postgresStorage } from "@aotter/mantle/postgres";
|
|
197
|
+
import { pgDatabaseDriver, pgPool, postgresStorage, requestScoped, type PgSession } from "@aotter/mantle/postgres";
|
|
168
198
|
|
|
169
|
-
|
|
199
|
+
// one per isolate: every consumer must share it
|
|
200
|
+
let session: PgSession | undefined;
|
|
201
|
+
const database = (env: Env) => (session ??= requestScoped(async () => {
|
|
170
202
|
const client = new pg.Client({ connectionString: env.HYPERDRIVE.connectionString });
|
|
171
203
|
await client.connect();
|
|
172
204
|
return client;
|
|
173
|
-
};
|
|
174
|
-
// storage: (env) => postgresStorage({ connect:
|
|
175
|
-
// identity: createMantleAuth({ database: pgPool(
|
|
205
|
+
}));
|
|
206
|
+
// storage: (env) => postgresStorage({ connect: database(env).connect })
|
|
207
|
+
// identity: createMantleAuth({ database: pgPool(database(env).connect), driver: pgDatabaseDriver(database(env).connect), ... })
|
|
208
|
+
// fetch: (request, env, ctx) => database(env).run(() => routes(request))
|
|
176
209
|
```
|
|
177
210
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
211
|
+
One operation holds the shared client from `connect()` to `end()`; another that
|
|
212
|
+
arrives meanwhile opens its own, so nothing runs inside another's transaction.
|
|
213
|
+
With `new pg.Client({ …, pipeline: true })` (pg 8.23 or later) a write batch is
|
|
214
|
+
one round trip instead of N + 2; the generated Workers preset leaves it off
|
|
215
|
+
until Hyperdrive is verified to forward a pipelined transaction.
|
|
216
|
+
|
|
217
|
+
The Worker needs `compatibility_flags: ["nodejs_compat"]` for `pg`. Mantle's
|
|
218
|
+
and Better Auth's reads run outside a transaction, which Hyperdrive would answer
|
|
219
|
+
from its cache, so create the Hyperdrive config with caching disabled
|
|
220
|
+
(`wrangler hyperdrive create … --caching-disabled`), or a read can miss the
|
|
221
|
+
write before it and a revoked session can be accepted until the cache expires. The same holds for a role
|
|
222
|
+
re-read and for a revoked grant: Auth's reads are single statements outside a transaction, so their freshness
|
|
223
|
+
depends on `--caching-disabled` too. `timeZone` must be an
|
|
183
224
|
IANA name: PostgreSQL reads an offset such as `+08:00` with the opposite sign.
|
|
184
225
|
|
|
185
226
|
Bun has a generated preset for both engines (`mantle generate --host bun`,
|
|
186
|
-
ADR-0038)
|
|
187
|
-
Better Auth the same store. A `DatabaseDriver` is one method, so another engine
|
|
227
|
+
ADR-0038, ADR-0039): over PostgreSQL it is the Workers composition on a `pg.Pool`;
|
|
228
|
+
over SQLite `bunSqliteDriver` gives Better Auth the same store. A `DatabaseDriver` is one method, so another engine
|
|
188
229
|
(libSQL, for example) is a few lines of application code on `host: none`: the
|
|
189
230
|
entry calls `createMantle` and its own server.
|
|
190
231
|
|
|
@@ -33,7 +33,7 @@ has the full table):
|
|
|
33
33
|
integer literals: use `round(x)`), JSON `->>`;
|
|
34
34
|
- `WHERE` with comparisons, `BETWEEN`, `IN (list | subquery)`, `EXISTS`,
|
|
35
35
|
`LIKE` (case-insensitive, as in SQLite), `IS DISTINCT FROM`;
|
|
36
|
-
- `INNER` and `LEFT JOIN`, subqueries, `json_each(…)
|
|
36
|
+
- `INNER` and `LEFT JOIN`, subqueries, `json_each(…)` on D1 (PostgreSQL: `jsonb_array_elements_text(…) WITH ORDINALITY AS j(value, n)`);
|
|
37
37
|
- `GROUP BY`, `HAVING`, `count`, `sum`, `min`, `max`, `avg`,
|
|
38
38
|
`json_group_array`, `json_group_object`; `row_number()`, `rank()` and running
|
|
39
39
|
aggregates;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Add your own pages, settings, actions, record panels and field widgets to Admin in 0.2.0, bind them from manifest uiSchema, and verify them.
|
|
3
|
+
---
|
|
4
|
+
# Extend Admin
|
|
5
|
+
|
|
6
|
+
Manifests decide most of what Admin shows ([Customize Admin from
|
|
7
|
+
manifests](admin-ui.md)). When a project needs UI of its own, it adds an
|
|
8
|
+
**Admin extension**: a declaration that is plain data, server handlers, and
|
|
9
|
+
optionally one ES module with renderers. The contract is
|
|
10
|
+
[ADR-lite 1376](../../adr/adr-lite-1376-admin-extensions.md).
|
|
11
|
+
|
|
12
|
+
For a new project with feature `admin`, the preset writes
|
|
13
|
+
`src/admin-extensions.ts` (yours to edit) and passes its `adminExtensions` to
|
|
14
|
+
`createAdminSurface`. The preset is written once: when `src/service.ts`
|
|
15
|
+
already exists, `mantle generate` leaves it alone, so add the file and
|
|
16
|
+
`extensions: adminExtensions` to your `createAdminSurface` call yourself.
|
|
17
|
+
|
|
18
|
+
## What you can add
|
|
19
|
+
|
|
20
|
+
| Contribution | Where it appears | Client code |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| `pages` | `/admin/x/{extension}/{page}`; in **More** when it declares `nav` | a renderer |
|
|
23
|
+
| `settings` | `/admin/x/{extension}/{id}`, always in **More** | none: Admin renders a form from `schema`, which must be the form subset (a flat object whose properties are `string`, `number`, `integer` or `boolean`, with `title`, `description`, `default`, `enum`, `oneOf`, `minimum`, `maximum`, `minLength`, `maxLength`) |
|
|
24
|
+
| `actions` | `record/v1` (a record's header), `list.selection/v1` (the bulk bar), `list.toolbar/v1` (a list's toolbar) | none for `run` and `confirm` (a server `run`); a renderer for `dialog` |
|
|
25
|
+
| `panels` | `record.sidebar/v1` (a record's side column), `home/v1` (the home page) | a renderer |
|
|
26
|
+
| `fields` | `field.input/v1` (a form control), `field.cell/v1` (a list cell) | a renderer |
|
|
27
|
+
|
|
28
|
+
Pages, settings, actions and panels need a minimum staff `role`; a field
|
|
29
|
+
widget's `role` is optional and defaults to `contributor` (below it, Admin's
|
|
30
|
+
own control is used). The server sends each staff member only the
|
|
31
|
+
contributions their role reaches, and checks the session and role again on
|
|
32
|
+
every extension route: hiding a button is never the only guard.
|
|
33
|
+
|
|
34
|
+
- A settings page and an action are checked against their own `role`.
|
|
35
|
+
- The extension's `api` and its served `source` are shared by all its
|
|
36
|
+
contributions, so they need only the **lowest** role among them: an owner-only
|
|
37
|
+
page does not make the `api` owner-only.
|
|
38
|
+
|
|
39
|
+
Finer rules belong in your handlers, which receive the caller.
|
|
40
|
+
|
|
41
|
+
## A complete extension
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
// src/admin-extensions.ts
|
|
45
|
+
import type { AdminExtension } from "@aotter/mantle/admin";
|
|
46
|
+
|
|
47
|
+
const brand: AdminExtension = {
|
|
48
|
+
apiVersion: 1,
|
|
49
|
+
id: "brand",
|
|
50
|
+
title: { en: "Brand", "zh-TW": "品牌" },
|
|
51
|
+
// Admin serves this at /admin/extensions/brand.js, with its integrity computed
|
|
52
|
+
source: BRAND_MODULE,
|
|
53
|
+
contributes: {
|
|
54
|
+
pages: [{ id: "palette", title: "Palette", role: "editor", nav: { group: "more" } }],
|
|
55
|
+
settings: [{ id: "defaults", title: "Brand defaults", role: "owner", schema: {
|
|
56
|
+
type: "object", required: ["accent"],
|
|
57
|
+
properties: { accent: { type: "string", title: "Accent", enum: ["teal", "navy"] }, limit: { type: "integer", minimum: 1, maximum: 20 } },
|
|
58
|
+
} }],
|
|
59
|
+
actions: [{ id: "reindex", title: "Re-index colors", role: "editor", target: "record/v1", presentation: "confirm", when: { schema: ["products"] } }],
|
|
60
|
+
panels: [{ id: "usage", title: "Where it is used", role: "contributor", target: "record.sidebar/v1", when: { schema: ["products"] } }],
|
|
61
|
+
fields: [
|
|
62
|
+
{ id: "color", target: "field.input/v1", optionsSchema: { type: "object", properties: { palette: { type: "string", enum: ["brand", "web"] } } } },
|
|
63
|
+
{ id: "swatch", target: "field.cell/v1" },
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
handlers: {
|
|
67
|
+
settings: { defaults: { load: async () => ({ accent: "teal" }), save: async (_caller, value) => value } },
|
|
68
|
+
actions: { reindex: { run: async (caller, { record }) => ({ message: `Re-indexed ${record!.id}` }) } },
|
|
69
|
+
// GET/POST /admin/api/x/brand/api/{path}; null answers 404
|
|
70
|
+
api: async (request, { caller, path }) => (path === "usage" ? Response.json({ count: 3 }) : null),
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const adminExtensions: readonly AdminExtension[] = [brand];
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`source` is the module's code (or a function returning it). How you load the
|
|
78
|
+
text is up to your host: a string constant, a Wrangler `Text` module rule, or
|
|
79
|
+
Bun's `with { type: "text" }` import. A host that serves the file itself sets
|
|
80
|
+
`module: "/path.js"` (same-origin) and optionally `integrity` instead.
|
|
81
|
+
|
|
82
|
+
## The module
|
|
83
|
+
|
|
84
|
+
The module needs no build step. Admin's import map provides `react`,
|
|
85
|
+
`react/jsx-runtime`, `react-dom`, `react-dom/client`,
|
|
86
|
+
`@aotter/mantle-ui/kit` and `@aotter/mantle-ui/extension`, so the module
|
|
87
|
+
uses Admin's own React and kit and looks like the rest of Admin. If you do
|
|
88
|
+
bundle it, mark those specifiers external.
|
|
89
|
+
|
|
90
|
+
```js
|
|
91
|
+
import { jsx } from "react/jsx-runtime";
|
|
92
|
+
import { useEffect, useState } from "react";
|
|
93
|
+
import { createRoot } from "react-dom/client";
|
|
94
|
+
import { Badge, Input } from "@aotter/mantle-ui/kit";
|
|
95
|
+
import { defineAdminExtension } from "@aotter/mantle-ui/extension";
|
|
96
|
+
|
|
97
|
+
// a renderer gets an element and a context; it may return a cleanup or { update, unmount }
|
|
98
|
+
const react = (Component) => (element, context) => {
|
|
99
|
+
const root = createRoot(element);
|
|
100
|
+
root.render(jsx(Component, { context }));
|
|
101
|
+
return { update: (next) => root.render(jsx(Component, { context: next })), unmount: () => root.unmount() };
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
function Usage({ context }) {
|
|
105
|
+
const [count, setCount] = useState(null);
|
|
106
|
+
useEffect(() => { fetch(`${context.apiBase}/usage`).then((r) => r.json()).then((b) => setCount(b.count)); }, [context.apiBase]);
|
|
107
|
+
return jsx(Badge, { children: count === null ? "…" : `${count} pages` });
|
|
108
|
+
}
|
|
109
|
+
function Color({ context }) {
|
|
110
|
+
return jsx(Input, { "aria-label": context.field.name, value: String(context.field.value ?? ""), onChange: (e) => context.onChange(e.target.value) });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export default defineAdminExtension({
|
|
114
|
+
pages: { palette: react(() => jsx("p", { children: "Palette" })) },
|
|
115
|
+
panels: { usage: react(Usage) },
|
|
116
|
+
fields: {
|
|
117
|
+
color: react(Color),
|
|
118
|
+
swatch: (element, context) => { element.textContent = `■ ${context.field.value ?? ""}`; },
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The context holds `extension`, `contribution`, `apiBase` (your `api`
|
|
124
|
+
handler), `language`, `theme`, `caller.role`, and where it applies `record`
|
|
125
|
+
(`{ schema, id, version }`), `selection`, `schema`, `field`, `options` and,
|
|
126
|
+
for `field.input/v1`, `onChange`. `host.navigate(path)`, `host.notify(text)`
|
|
127
|
+
and `host.close()` (a dialog action) are the supported console API. They are
|
|
128
|
+
not a sandbox: a module is trusted same-origin code and can still reach the
|
|
129
|
+
DOM, `fetch` and whatever the signed-in session may do (see Trust).
|
|
130
|
+
|
|
131
|
+
## Bind contributions from manifests
|
|
132
|
+
|
|
133
|
+
A manifest's `uiSchema` can name a project contribution as
|
|
134
|
+
`<extension>/<contribution>`. The binding sits next to the field it changes,
|
|
135
|
+
so one change (and one review) covers both:
|
|
136
|
+
|
|
137
|
+
```yaml
|
|
138
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
139
|
+
kind: Schema
|
|
140
|
+
metadata: { name: products }
|
|
141
|
+
spec:
|
|
142
|
+
title: Products
|
|
143
|
+
lifecycle: operational
|
|
144
|
+
uiSchema:
|
|
145
|
+
fields:
|
|
146
|
+
color: { widget: brand/color, options: { palette: brand } }
|
|
147
|
+
list:
|
|
148
|
+
columns: [color]
|
|
149
|
+
cells: { color: brand/swatch }
|
|
150
|
+
panels: [brand/usage]
|
|
151
|
+
schema:
|
|
152
|
+
type: object
|
|
153
|
+
required: [name]
|
|
154
|
+
properties:
|
|
155
|
+
name: { type: string }
|
|
156
|
+
color: { type: string }
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
- `fields.<name>.widget` names a `field.input/v1` contribution (any field type);
|
|
160
|
+
`options` must satisfy its `optionsSchema`.
|
|
161
|
+
- `list.cells.<field>` (Schemas, and staff Views by output name) names a
|
|
162
|
+
`field.cell/v1` contribution.
|
|
163
|
+
- `panels` (Schemas) names `record.sidebar/v1` contributions.
|
|
164
|
+
- Procedure `uiSchema.fields.<name>.widget` works like a Schema's.
|
|
165
|
+
|
|
166
|
+
`mantle generate` checks the shape of the names. `createAdminSurface` checks
|
|
167
|
+
that each named contribution exists, has the right target and accepts its
|
|
168
|
+
`options`, and refuses to start otherwise (`UI_EXTENSION_UNKNOWN`,
|
|
169
|
+
`UI_EXTENSION_TARGET`, `UI_EXTENSION_OPTIONS`). Name only your project's own
|
|
170
|
+
extensions, so the project renders the same locally and deployed; a host that
|
|
171
|
+
adds extensions attaches them with `when`. Core checks that a name exists, not
|
|
172
|
+
whose extension it is. When a manifest names a widget for a field (even
|
|
173
|
+
`textarea`), a matching `when` does not replace it. `uiSchema` stays presentation only: the field's JSON Schema
|
|
174
|
+
still validates whatever a widget sends.
|
|
175
|
+
|
|
176
|
+
## Trust
|
|
177
|
+
|
|
178
|
+
An extension's module runs in Admin's origin with the viewer's session. It
|
|
179
|
+
has no more power than the project already has: whoever deploys the project
|
|
180
|
+
already controls its handlers and storage. Keep host routes that act beyond
|
|
181
|
+
this project (an organization console, other projects) on another origin.
|
|
182
|
+
|
|
183
|
+
## Verify
|
|
184
|
+
|
|
185
|
+
- `createAdminSurface` validates every declaration at start
|
|
186
|
+
(`UI_EXTENSION_INVALID` names the first problem).
|
|
187
|
+
- `ADMIN_EXTENSION_JSON_SCHEMA` (from `@aotter/mantle/admin`) describes a
|
|
188
|
+
declaration for tooling; `diffAdminExtensions(before, after)` lists added,
|
|
189
|
+
removed and changed contributions for review.
|
|
190
|
+
- Sign in as each role and check what appears; call
|
|
191
|
+
`/admin/api/x/{extension}/...` as a lower role and expect 403.
|
|
@@ -25,7 +25,8 @@ same files with `bunAdminAssets` from `@aotter/mantle/bun`. Sign in at
|
|
|
25
25
|
| Rename a collection or explain a field | Schema `title` / `description`, property `title` / `description` (a string or a locale map) | Labels and help. Stored names do not change |
|
|
26
26
|
| Required fields and option lists | JSON Schema `required`, `enum`, `type` | Data contracts, checked on every write, not styling |
|
|
27
27
|
| Option labels | a string property's `oneOf: [{ const: open, title: { en: Open, zh-TW: 處理中 } }, …]` instead of `enum` | Selects, filter tabs and list cells show the `title` |
|
|
28
|
-
| A multiline string | Schema or Procedure `uiSchema.fields.<name>.widget: textarea` | `textarea` is the only explicit widget |
|
|
28
|
+
| A multiline string | Schema or Procedure `uiSchema.fields.<name>.widget: textarea` | `textarea` is the only built-in explicit widget |
|
|
29
|
+
| Your own control, list cell or record panel | `uiSchema.fields.<name>.widget: <extension>/<contribution>`, `uiSchema.list.cells`, `uiSchema.panels` | Contributions of the project's Admin extensions; see [Extend Admin](admin-extensions.md) |
|
|
29
30
|
| Markdown or HTML | property `x-mcp-hint: markdown` or `html` | A rich editor |
|
|
30
31
|
| A timestamp, a date or money | `format: date-time`, `format: date`; `x-mcp-hint: money-minor` on an integer | Date controls; money in minor units, with a sibling `currency` when present |
|
|
31
32
|
| Columns of an operational list | Schema `uiSchema.list.primaryField`, `.columns` | `primaryField` is a scalar data field; `columns` may name native columns (`createdAt`, `updatedAt`) |
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"items": [
|
|
15
15
|
{ "text": "Agent installation and handoff", "link": "/guides/agent-setup" },
|
|
16
16
|
{ "text": "Query from TypeScript", "link": "/guides/typed-queries" },
|
|
17
|
-
{ "text": "Customize Admin from manifests", "link": "/guides/admin-ui" }
|
|
17
|
+
{ "text": "Customize Admin from manifests", "link": "/guides/admin-ui" },
|
|
18
|
+
{ "text": "Extend Admin", "link": "/guides/admin-extensions" }
|
|
18
19
|
]
|
|
19
20
|
},
|
|
20
21
|
{
|
|
@@ -69,6 +69,7 @@ Admin's statistics route answers 501 with the wire code
|
|
|
69
69
|
| `INVALID_MANIFEST_ENVELOPE`, `DUPLICATE_NAME`, `MANIFEST_ROOT_NOT_FOUND`, `MANIFEST_READ_FAILED` | files and the envelope; a `v1` apiVersion fails here and names the upgrade guide; `x-mantle-bind` fails and names its replacements (`spec.scope`, `auth.uid()`, `now()`) |
|
|
70
70
|
| `REQUIRED_FIELD_UNKNOWN`, `INVALID_PATTERN`, `JSON_SCHEMA_UNSUPPORTED`, `JSON_SCHEMA_REF_INVALID`, `JSON_SCHEMA_LIMIT_EXCEEDED` | JSON Schemas |
|
|
71
71
|
| `SCHEMA_INDEX_INVALID`, `SCHEMA_INDEX_FIELD_UNKNOWN`, `UNIQUE_INDEX_FIELD_UNKNOWN`, `SCHEMA_SEARCH_INVALID`, `SCHEMA_SEARCH_FIELD_UNKNOWN`, `SCHEMA_TTL_INVALID`, `SCHEMA_TTL_TRANSLATION_UNSUPPORTED`, `SCHEMA_UI_INVALID`, `SCHEMA_NAME_CASE_COLLISION`, `FIELD_NAME_CASE_COLLISION`, `MANTLE_REF_INVALID` | Schemas |
|
|
72
|
+
| `UI_EXTENSION_INVALID`, `UI_EXTENSION_UNKNOWN`, `UI_EXTENSION_TARGET`, `UI_EXTENSION_OPTIONS` | Admin extensions, at `createAdminSurface`: a declaration it cannot honor, and a `uiSchema` contribution name that is missing, of the wrong target, or given options its `optionsSchema` refuses |
|
|
72
73
|
| `TRANSLATES_PARENT_UNKNOWN`, `TRANSLATES_REQUIRES_LOCALIZED`, `TRANSLATES_REQUIRES_CONTENT_FIELD`, `TRANSLATES_FIELD_NOT_IN_PARENT`, `TRANSLATES_FIELD_NOT_IN_CHILD`, `TRANSLATES_PARENT_IS_LOCALIZED` | `translates` |
|
|
73
74
|
| `VIEW_INPUT_INVALID_SHAPE`, `VIEW_INPUT_RESERVED_NAME`, `VIEW_CACHE_INVALID`, `VIEW_UI_INVALID` | Views |
|
|
74
75
|
| `SQL_SYNTAX`, `SQL_UNSUPPORTED`, `SQL_FUNCTION`, `SQL_RELATION`, `SQL_COLUMN`, `SQL_WRITE`, `SQL_SHAPE`, `SQL_TYPE` | View and Procedure SQL, with a position |
|
|
@@ -44,7 +44,7 @@ References: `input.<name>`, `auth.uid()`, `auth.role()`, `now()`.
|
|
|
44
44
|
`updated_at`, `author_id`, or `id` in an `UPDATE`, in a column list, `SET` or
|
|
45
45
|
`DO UPDATE SET`; on a scoped Schema an `INSERT` names no `id`. Store fills
|
|
46
46
|
them (`SQL_WRITE`). Multi-row `VALUES` is refused: insert from
|
|
47
|
-
`json_each(input.items)` instead. `UPDATE … FROM`, `DELETE … USING`,
|
|
47
|
+
`json_each(input.items)` on D1 instead (PostgreSQL: `jsonb_array_elements(input.items)`). `UPDATE … FROM`, `DELETE … USING`,
|
|
48
48
|
`INSERT OR …` and `last_insert_rowid()` are refused.
|
|
49
49
|
|
|
50
50
|
**RETURNING names** fold to lower case unless quoted:
|
|
@@ -109,6 +109,7 @@ handler, which always writes, may not claim `readOnlyHint: true`.
|
|
|
109
109
|
| Key | Effect in Admin |
|
|
110
110
|
|---|---|
|
|
111
111
|
| `fields.<name>.widget: textarea` | a multiline input |
|
|
112
|
+
| `fields.<name>.widget: <extension>/<contribution>` (with optional `options`) | an Admin extension's `field.input/v1` control ([Extend Admin](../guides/admin-extensions.md)) |
|
|
112
113
|
| `collectionAction: <schema>` | offer the operation on that collection's list |
|
|
113
114
|
|
|
114
115
|
Admin lists a Procedure as an operation only when a Trigger binds it to the
|
|
@@ -43,7 +43,7 @@ insert and when a draft is published; a draft may be saved incomplete.
|
|
|
43
43
|
| `type: integer` | `INTEGER` | `int8` | |
|
|
44
44
|
| `type: number` | `REAL` | `float8` | |
|
|
45
45
|
| `type: boolean` | integer 0/1 | `bool` | `true`/`false` on the wire |
|
|
46
|
-
| `type: object`, `type: array` | JSON | `jsonb` | read with `->>` or `json_each`
|
|
46
|
+
| `type: object`, `type: array` | JSON | `jsonb` | read with `->>` in SQL, or `json_each` on D1 (PostgreSQL: `jsonb_array_elements_text(…) WITH ORDINALITY AS j(value, n)`) |
|
|
47
47
|
| `format: geo` | two `REAL` columns and an R*Tree | two `float8` columns | `{ lat, lng }`; query with `mantle.near` and `mantle.distance` |
|
|
48
48
|
|
|
49
49
|
### Reserved entry columns
|
|
@@ -181,14 +181,17 @@ Admin presentation only; it never changes validation or MCP.
|
|
|
181
181
|
|
|
182
182
|
| Key | Values |
|
|
183
183
|
|---|---|
|
|
184
|
-
| `fields.<name>.widget` | `textarea` |
|
|
184
|
+
| `fields.<name>.widget` | `textarea` (a string field), or `<extension>/<contribution>`: an Admin extension's `field.input/v1` control, for any field type |
|
|
185
|
+
| `fields.<name>.options` | with an extension widget only: an object checked against the contribution's `optionsSchema` |
|
|
185
186
|
| `list.primaryField` | a scalar data field shown first (operational Schemas) |
|
|
186
187
|
| `list.columns` | data fields and native columns, in order (operational Schemas) |
|
|
188
|
+
| `list.cells` | data field or native column -> `<extension>/<contribution>`, an Admin extension's `field.cell/v1` renderer |
|
|
189
|
+
| `panels` | up to 16 `<extension>/<contribution>` names: Admin extension `record.sidebar/v1` panels on the record page |
|
|
187
190
|
| `list.filterField` | a string enum field that leads an index: tabs per value (operational Schemas) |
|
|
188
191
|
| `nav.standalone` | `true`: also list a folded child in navigation |
|
|
189
192
|
| `nav.parentField` | which `x-mantle-ref` field filters it, when several could |
|
|
190
193
|
|
|
191
|
-
Other keys are refused (`SCHEMA_UI_INVALID`).
|
|
194
|
+
Other keys are refused (`SCHEMA_UI_INVALID`). A contribution name is checked for shape here; whether it exists, fits the key and accepts the `options` is checked when Admin starts ([Extend Admin](../guides/admin-extensions.md)).
|
|
192
195
|
|
|
193
196
|
## Diagnostics
|
|
194
197
|
|
|
@@ -56,13 +56,17 @@ Every route needs a staff caller; the column names the least role.
|
|
|
56
56
|
| `POST …/entries/{id}/publish`, `…/unpublish`, `DELETE …/entries/{id}` | editor | |
|
|
57
57
|
| `GET /admin/api/views-manifest`, `GET …/views/{name}`, `GET …/views/{name}/export` | contributor | staff Views (`{ name, title, description, input, list: { columns, searchFields, filterFields }, columns }`); rows by `limit`/`cursor` with `search` and `filter.<output>`; CSV |
|
|
58
58
|
| `GET /admin/api/operations`, `POST …/operations/{name}` | contributor | Procedures bound to the staff MCP surface |
|
|
59
|
-
| `GET /admin/api/site` | contributor | site metadata, `mcpEndpoints`,
|
|
59
|
+
| `GET /admin/api/site` | contributor | site metadata, `mcpEndpoints`, `capabilities: { siteSettings, media, invitationEmail, statistics }` (what this deployment turned on; the console hides what is off), and `extensions` (the Admin extension declarations the caller's role reaches, never handlers or integrity) |
|
|
60
60
|
| `GET`, `PATCH /admin/api/site-settings` | owner | 501 `SITE_NOT_CONFIGURED` without `runtime.site` |
|
|
61
61
|
| `POST /admin/api/media/uploads`, `POST …/media/uploads/{groupId}/commit`, `GET …/media`, `GET`, `PATCH`, `DELETE …/media/{id}` | editor | with `media` and `runtime.site` |
|
|
62
62
|
| `GET /admin/api/webmcp`, `POST …/webmcp/{tool}` | contributor | the staff tools for a browser agent (`{ tools, routes }`); a call runs one as `/mcp/staff` does and answers `{ output }` |
|
|
63
63
|
| `GET /admin/api/staff`, `PATCH …/staff/{id}/role`, `POST …/staff/invitations`, `DELETE …/staff/invitations/{id}` | owner | with an `AdminIdentity` that has `directory` / `roles` |
|
|
64
64
|
| `GET /admin/api/members` | editor | with `directory` |
|
|
65
65
|
| `GET /admin/api/developer-console` | owner | the plan's data model, logic and schedules |
|
|
66
|
+
| `GET`, `PATCH /admin/api/x/{extension}/settings/{id}` | the settings' `role` | load and save an extension's settings; `PATCH { value }` is checked against its `schema` (400 with `error.fields`) |
|
|
67
|
+
| `POST /admin/api/x/{extension}/actions/{id}` | the action's `role` | run a `run` or `confirm` action with `{ record }`, `{ selection }` or `{ schema }` for its target; `when.schema` is enforced |
|
|
68
|
+
| `… /admin/api/x/{extension}/api/{path}` | the extension's lowest contribution role | the extension's own API (`handlers.api`); `null` answers 404; answers are `no-store` unless the handler sets `cache-control` |
|
|
69
|
+
| `GET /admin/extensions/{id}.js` | the extension's lowest contribution role | a `source` extension's module; its integrity is in the shell's import map ([Extend Admin](../guides/admin-extensions.md)) |
|
|
66
70
|
|
|
67
71
|
## Packages
|
|
68
72
|
|
|
@@ -79,7 +83,7 @@ Two npm packages, always published together at one version.
|
|
|
79
83
|
| `@aotter/mantle/admin` | `createAdminSurface`, `AdminIdentity` |
|
|
80
84
|
| `@aotter/mantle/mcp` | `createMcpSurface`, `planApp` and `appCatalog` (the MCP App's catalog) |
|
|
81
85
|
| `@aotter/mantle/web` | `createRestSurface` |
|
|
82
|
-
| `@aotter/mantle/bun` | `
|
|
86
|
+
| `@aotter/mantle/bun` | `bunSqliteStorage`, `bunSqliteDriver`, `bunAdminAssets` |
|
|
83
87
|
| `@aotter/mantle/testing` | `runStorageConformance`, the dialect compliance suite |
|
|
84
88
|
| `@aotter/mantle-ui` | the root (React operation components and the controller), `/controller`, `/kit`, `/kit.css`, `/tokens.css`, `/mcp-app` (`mantleAppHtml`, the App `planApp` serves), and the prebuilt Admin console in `dist/admin` (exported as `./admin/index.html`), which the preset serves at `/admin` |
|
|
85
89
|
|
|
@@ -38,13 +38,13 @@ SQLite and on any `sqliteStorage` driver.
|
|
|
38
38
|
|
|
39
39
|
| Area | Supported | Rule |
|
|
40
40
|
|---|---|---|
|
|
41
|
-
| Expressions | columns, aliases, literals, arithmetic, `\|\|`, `CASE`, `COALESCE`, `NULLIF`, `CAST` | `CAST(x AS int)` only for an integer literal: use `round(x)`. `CAST(x AS bool)` follows PostgreSQL
|
|
41
|
+
| Expressions | columns, aliases, literals, arithmetic, `\|\|`, `CASE`, `COALESCE`, `NULLIF`, `CAST` | `CAST(x AS int)` only for an integer literal: use `round(x)`. `CAST(x AS bool)` follows PostgreSQL's text rules; an integer has no cast to boolean on PostgreSQL: write `x <> 0`. `*` expands to declared fields; a bare `*` over a subquery or `json_each` is refused: name the columns |
|
|
42
42
|
| Conditions | comparisons, `AND`/`OR`/`NOT`, `BETWEEN`, `IS [NOT] NULL`, `IS DISTINCT FROM`, `IN (list \| subquery)`, `[NOT] EXISTS`, `LIKE … ESCAPE` | `LIKE` is case-insensitive (SQLite). An input array in `IN` binds once. A date-time, date or boolean column (and `created_at`, `updated_at`) is not compared with a bare string: write `CAST('…' AS timestamptz)`, `true`, or bind an input |
|
|
43
|
-
| Relations | one Schema, `INNER`/`LEFT JOIN … ON` (self-joins too), a subquery in `FROM`, `json_each(<input or column>)` | the only comma join is `t, json_each(t.col)` |
|
|
43
|
+
| Relations | one Schema, `INNER`/`LEFT JOIN … ON` (self-joins too), a subquery in `FROM`, `json_each(<input or column>)` on D1; on PostgreSQL `jsonb_array_elements_text(<input or column>) WITH ORDINALITY AS j(value, n)` | the only comma join is `t, json_each(t.col)` on D1 (PostgreSQL: `t, jsonb_array_elements_text(t.col) WITH ORDINALITY AS j(value, n)`, [SQLite to PostgreSQL rewrites](../concepts/runtime-and-adapters.md#the-postgresql-dialect)) |
|
|
44
44
|
| Subqueries | scalar and correlated | |
|
|
45
45
|
| Aggregation | `count`, `sum`, `min`, `max`, `avg`, `count(DISTINCT)`, `json_group_array([DISTINCT])`, `json_group_object`, `GROUP BY`, `HAVING` | a selected column is grouped or aggregated |
|
|
46
46
|
| Windows | `row_number()`, `rank()`, `sum`/`count … OVER (PARTITION BY … ORDER BY …)` | no frame clause |
|
|
47
|
-
| Order and paging | `ORDER BY … [NULLS FIRST \| LAST]`, `LIMIT`, `DISTINCT` | `id` is appended as the last sort key; `LIMIT` needs `ORDER BY` and bounds every page together; a sort key may be NULL (an unstated `NULLS` puts NULL first ascending and last descending, and the cursor follows the same rule); `ORDER BY 1` sorts by the first output; `DISTINCT` with `ORDER BY` is refused. A View without `ORDER BY` (a `DISTINCT`, an aggregate, a `GROUP BY`) is one page: it takes no cursor and is refused when it has more rows than the page |
|
|
47
|
+
| Order and paging | `ORDER BY … [NULLS FIRST \| LAST]`, `LIMIT`, `DISTINCT` | `id` is appended as the last sort key (on PostgreSQL in the last key's direction); `LIMIT` needs `ORDER BY` and bounds every page together; a sort key may be NULL (an unstated `NULLS` follows the dialect: D1 puts NULL first ascending and last descending, PostgreSQL last ascending and first descending, and the cursor follows the same rule); `ORDER BY 1` sorts by the first output; `DISTINCT` with `ORDER BY` is refused. A View without `ORDER BY` (a `DISTINCT`, an aggregate, a `GROUP BY`) is one page: it takes no cursor and is refused when it has more rows than the page |
|
|
48
48
|
| JSON | `->>`, `json_extract`, `json_set`, `json_insert`, `json_remove`, `json_array_length` | `->` is refused |
|
|
49
49
|
| Time | `now()`, `date_trunc('hour'\|'day'\|'week'\|'month'\|'year', ts)`, `extract(year\|month\|day\|dow\|hour FROM ts)`, `ts ± interval '<n> seconds\|minutes\|hours'`, `ts - ts` | site time zone; `ts - ts` is microseconds. Calendar intervals (`day`, `month`) are refused: bind the boundary as an input |
|
|
50
50
|
| Search and places | `mantle.search(t, q)`, `mantle.search_rank(t)`, `mantle.near(t.f, lat, lng, meters)`, `mantle.distance(t.f, lat, lng)` | `near` takes a literal radius of at most 50 km; ordering by `distance` needs `LIMIT` ≤ 100 and has no cursor |
|
|
@@ -78,8 +78,9 @@ On PostgreSQL a View may also use:
|
|
|
78
78
|
| Time | `ts AT TIME ZONE 'Asia/Taipei'`; `date_trunc` adds `minute` and `quarter`; `extract` adds `minute`, `quarter`, `week`, `isoyear`, `isodow`, `doy`, `epoch` |
|
|
79
79
|
| Casts | any expression to `int4`, `int8`, `numeric(p, s)`, `date`, `timestamptz`, `jsonb`; a text literal compared with a date-time column is cast, as PostgreSQL does |
|
|
80
80
|
|
|
81
|
-
Every statement on PostgreSQL has a
|
|
82
|
-
(`postgresStorage({ statementTimeoutMs })`)
|
|
81
|
+
Every statement on PostgreSQL has a `statement_timeout` of at most 10 seconds
|
|
82
|
+
(`postgresStorage({ statementTimeoutMs })`); a View runs under the role's own,
|
|
83
|
+
which boot requires to be set and no larger.
|
|
83
84
|
|
|
84
85
|
### Reading another View
|
|
85
86
|
|
|
@@ -136,6 +137,7 @@ REST coerces each `input` query parameter to its declared type.
|
|
|
136
137
|
| `list.columns` | the outputs the console shows, in order; CSV export uses them |
|
|
137
138
|
| `list.searchFields` | outputs the console's search box matches: one case-insensitive `LIKE '%text%'` per output, ORed, with `%`, `_` and `\` in the text matched literally |
|
|
138
139
|
| `list.filterFields` | outputs the console offers as filters: one `=` each, the value coerced to the output's field type |
|
|
140
|
+
| `list.cells` | output -> `<extension>/<contribution>`: an Admin extension's `field.cell/v1` renderer for that column ([Extend Admin](../guides/admin-extensions.md)) |
|
|
139
141
|
|
|
140
142
|
Each name must be one of the View's outputs (`VIEW_UI_INVALID` otherwise).
|
|
141
143
|
The conditions wrap the View's own query, after its `WHERE` and the injected
|
|
@@ -28,9 +28,10 @@ by hand.
|
|
|
28
28
|
Procedures are SQL statements or a `ref`, in PostgreSQL syntax. The builtin
|
|
29
29
|
handlers, the Filter AST, `params`, `$ctx` references and `x-mantle-bind` are
|
|
30
30
|
gone; Schemas gain `checks`, and `searchableFields` becomes full-text search.
|
|
31
|
-
- **SQL in
|
|
31
|
+
- **SQL in the dialect's syntax.** PostgreSQL is the reference dialect
|
|
32
32
|
(`@aotter/mantle/postgres`); D1 and SQLite run a subset
|
|
33
|
-
(`@aotter/mantle/d1`).
|
|
33
|
+
(`@aotter/mantle/d1`). Each dialect is its own target (ADR-0039), and a
|
|
34
|
+
plan records its dialect.
|
|
34
35
|
- **One Store.** Every read and write goes through Store, which adds caller
|
|
35
36
|
scope, TTL and published-only to every statement, and an optimistic lock
|
|
36
37
|
where the caller passes one; a write
|
|
@@ -38,8 +39,8 @@ by hand.
|
|
|
38
39
|
- **The service is the application's.** `createMantle(service, …)` replaces
|
|
39
40
|
`createMantleWorker`, and the first `mantle generate` writes the host's
|
|
40
41
|
preset (`src/service.ts`, `src/index.ts`, ...) once. `--host` is
|
|
41
|
-
`cloudflare` (D1, or PostgreSQL through Hyperdrive), `bun` (
|
|
42
|
-
bun:sqlite, `@aotter/mantle/bun`) or `none` (plan and types only), and
|
|
42
|
+
`cloudflare` (D1, or PostgreSQL through Hyperdrive), `bun` (node-postgres
|
|
43
|
+
or bun:sqlite, `@aotter/mantle/bun`) or `none` (plan and types only), and
|
|
43
44
|
`--dialect` is `sqlite` or `postgres`; both are chosen once.
|
|
44
45
|
- **Mantle never owns your users.** A `CallerResolver` turns a request into a
|
|
45
46
|
`Caller`; identity is `mantle` (Better Auth), `custom` or `none`. Core creates
|
|
@@ -64,6 +65,79 @@ by hand.
|
|
|
64
65
|
- **Not yet in 0.2.0:** Mantle-rendered public pages and the MCP interaction
|
|
65
66
|
App tools.
|
|
66
67
|
|
|
68
|
+
### Upgrading an existing PostgreSQL service (0.2.0-alpha.6)
|
|
69
|
+
|
|
70
|
+
PostgreSQL is native from this alpha (ADR-0039): each dialect is its own target,
|
|
71
|
+
PostgreSQL's SQL and ordering are PostgreSQL's, and node-postgres (`pg`) is the
|
|
72
|
+
PostgreSQL driver on every host. D1 and SQLite output is unchanged except where
|
|
73
|
+
noted. Work through these in order before deploying.
|
|
74
|
+
|
|
75
|
+
1. **Role settings (from #1383, now also checked on Bun).** Boot refuses with
|
|
76
|
+
`STORAGE_CHANGE_BLOCKED` (path `storage:settings`) unless the role Mantle
|
|
77
|
+
connects as has a statement limit and UTC:
|
|
78
|
+
`ALTER ROLE app SET statement_timeout = '10s'; ALTER ROLE app SET TimeZone = 'UTC';`
|
|
79
|
+
(10s or less). Bun's `Bun.SQL` entry used to pin its own limit and skip this
|
|
80
|
+
check. A Hyperdrive config must keep `--caching-disabled`: reads, role
|
|
81
|
+
re-reads and grant revocations run outside a transaction.
|
|
82
|
+
2. **Rewrite SQLite spellings, then run `mantle generate`.** A PostgreSQL
|
|
83
|
+
manifest that uses `json_each`, `-> '$.a'`, `->> '$.a'`, `json_extract`,
|
|
84
|
+
`json_set`, `json_insert`, `json_remove` or `hex` fails validation with its
|
|
85
|
+
position and the PostgreSQL spelling. The
|
|
86
|
+
[rewrite table](../concepts/runtime-and-adapters.md#the-postgresql-dialect)
|
|
87
|
+
lists them: `json_each(t.col) j` becomes
|
|
88
|
+
`jsonb_array_elements_text(t.col) WITH ORDINALITY AS j(value, n)` (a sorted
|
|
89
|
+
View over a row source needs the alias and `WITH ORDINALITY`, and `j.id`
|
|
90
|
+
becomes `j.n`), `CAST(n AS bool)` of an integer becomes `n <> 0`, and `||`
|
|
91
|
+
needs a text operand. The PostgreSQL dialect version is now 2, so a plan
|
|
92
|
+
compiled earlier fails at boot with `PLAN_FINGERPRINT_MISMATCH`: run
|
|
93
|
+
`mantle generate` and deploy the regenerated plan.
|
|
94
|
+
3. **NULL order.** Unstated `NULLS` now follows PostgreSQL: NULL last ascending,
|
|
95
|
+
first descending, for every `ORDER BY` including windows and
|
|
96
|
+
`json_group_array`. Write `NULLS FIRST` or `NULLS LAST` to keep a specific
|
|
97
|
+
order. Rows tied on the last sort key are ordered by `id` in that key's
|
|
98
|
+
direction. A cursor live across the deploy may repeat or skip rows once.
|
|
99
|
+
4. **Bun entry.** Install `pg` and `@types/pg`. Replace
|
|
100
|
+
`new SQL(url, { prepare: false, ... })` with
|
|
101
|
+
`new pg.Pool({ connectionString, pipeline: true })`; `Env.SQL` becomes
|
|
102
|
+
`Env.PG`. The simplest path is to delete `src/service.ts` and `src/index.ts`
|
|
103
|
+
and re-run `mantle generate --host bun`, or copy the new preset. Removed from
|
|
104
|
+
`@aotter/mantle/bun`: `bunPostgresStorage`, `bunPgConnect`,
|
|
105
|
+
`bunDatabaseDriver`, `bunAuthDatabase`, `BunSqlPool`, `BunAuthPool`. The role
|
|
106
|
+
no longer needs `TEMPORARY`. Custom transports lose `temporaryResultMetadata`,
|
|
107
|
+
`describeResult` and `readOnly` from `PgClient` / `PgStatement`.
|
|
108
|
+
5. **Generated `src/service.ts` changed.** The `database(env).run` wrapper moved
|
|
109
|
+
to the exported `mantle`, and the Bun pooled wrapper ends each client once.
|
|
110
|
+
The preset is never rewritten: re-run `mantle generate` into a scratch
|
|
111
|
+
directory and copy the difference, or delete and regenerate if unedited.
|
|
112
|
+
6. **First boot does one-time work.** It runs once under the advisory lock:
|
|
113
|
+
- one `_mantle_ix_<schema>_updated` index on `([scope,] updated_at, id)` per
|
|
114
|
+
Schema, built without `CONCURRENTLY` inside the convergence transaction, so
|
|
115
|
+
writes to a large table block while it builds. Pre-create it before
|
|
116
|
+
deploying, on the schema (`search_path`) Mantle uses, with both names quoted:
|
|
117
|
+
`CREATE INDEX CONCURRENTLY "_mantle_ix_<schema>_updated" ON "<schema>" (["<scope>",] updated_at, id)`.
|
|
118
|
+
A matching index is accepted. If a concurrent build fails, drop the invalid
|
|
119
|
+
index before deploying: boot accepts it by name and columns and never
|
|
120
|
+
rebuilds it. A Schema name longer than about 43 bytes gets a hashed index
|
|
121
|
+
name; read it from a fresh database's `pg_indexes` instead;
|
|
122
|
+
- each `_mantle_chk_*` check is rebuilt once (earlier releases left no
|
|
123
|
+
marker); later boots leave unchanged checks alone;
|
|
124
|
+
- the old `_mantle_jget`, `_mantle_bool` and `_mantle_json_each` functions
|
|
125
|
+
stay in place, unused: a previous release still running during a rolling
|
|
126
|
+
deploy calls them.
|
|
127
|
+
7. **Boot under contention.** Concurrent boots converge once. A boot that cannot
|
|
128
|
+
get a lock within about five tries of one second fails with
|
|
129
|
+
`STORAGE_CHANGE_BLOCKED` naming the lock (another boot still converging, or a
|
|
130
|
+
long transaction on a table the plan changes), and is not retried for 10
|
|
131
|
+
seconds. A winner building a very large index can outlast its waiters, which
|
|
132
|
+
then report the blocked boot; boot again when it has finished (#1408).
|
|
133
|
+
8. **Auth.** `get-session` no longer carries a `set-auth-jwt` response header
|
|
134
|
+
when the OAuth provider is configured; call the plugin's `/api/auth/token`
|
|
135
|
+
instead. `d1Driver`'s structural `D1PreparedStatement` now requires `first`
|
|
136
|
+
and `all`: a hand-rolled fake must add them.
|
|
137
|
+
9. **D1.** A paged View with `json_each` in a `JOIN` arm, or with more than one
|
|
138
|
+
`json_each`, now pages by every element's `id`: its order among ties changes,
|
|
139
|
+
no row is skipped, and a cursor issued before the upgrade may need to restart.
|
|
140
|
+
|
|
67
141
|
## 0.1.4 — 2026-09-24
|
|
68
142
|
|
|
69
143
|
0.1.4 repairs the published consumer path without changing the Manifest
|