@aotter/mantle 0.2.0-alpha.4 → 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.
Files changed (181) hide show
  1. package/README.md +2 -2
  2. package/dist/admin/createAdminSurface.d.ts +3 -0
  3. package/dist/admin/createAdminSurface.d.ts.map +1 -1
  4. package/dist/admin/createAdminSurface.js +51 -8
  5. package/dist/admin/createAdminSurface.js.map +1 -1
  6. package/dist/admin/extensions.d.ts +290 -0
  7. package/dist/admin/extensions.d.ts.map +1 -0
  8. package/dist/admin/extensions.js +532 -0
  9. package/dist/admin/extensions.js.map +1 -0
  10. package/dist/admin/index.d.ts +1 -0
  11. package/dist/admin/index.d.ts.map +1 -1
  12. package/dist/admin/index.js +1 -0
  13. package/dist/admin/index.js.map +1 -1
  14. package/dist/auth/buildAuth.d.ts +3 -1
  15. package/dist/auth/buildAuth.d.ts.map +1 -1
  16. package/dist/auth/buildAuth.js +3 -2
  17. package/dist/auth/buildAuth.js.map +1 -1
  18. package/dist/auth/callerResolver.d.ts +4 -0
  19. package/dist/auth/callerResolver.d.ts.map +1 -1
  20. package/dist/auth/callerResolver.js +4 -1
  21. package/dist/auth/callerResolver.js.map +1 -1
  22. package/dist/auth/createMantleAuth.d.ts.map +1 -1
  23. package/dist/auth/createMantleAuth.js +3 -2
  24. package/dist/auth/createMantleAuth.js.map +1 -1
  25. package/dist/auth/db.d.ts +1 -0
  26. package/dist/auth/db.d.ts.map +1 -1
  27. package/dist/auth/db.js +6 -3
  28. package/dist/auth/db.js.map +1 -1
  29. package/dist/bun/index.d.ts +0 -44
  30. package/dist/bun/index.d.ts.map +1 -1
  31. package/dist/bun/index.js +0 -84
  32. package/dist/bun/index.js.map +1 -1
  33. package/dist/cli/generate.js +3 -3
  34. package/dist/cli/generate.js.map +1 -1
  35. package/dist/cli/preset.d.ts.map +1 -1
  36. package/dist/cli/preset.js +80 -28
  37. package/dist/cli/preset.js.map +1 -1
  38. package/dist/cloudflare/index.d.ts +4 -0
  39. package/dist/cloudflare/index.d.ts.map +1 -1
  40. package/dist/cloudflare/index.js +2 -0
  41. package/dist/cloudflare/index.js.map +1 -1
  42. package/dist/core/dialect.d.ts +13 -1
  43. package/dist/core/dialect.d.ts.map +1 -1
  44. package/dist/core/driver.d.ts +7 -0
  45. package/dist/core/driver.d.ts.map +1 -1
  46. package/dist/core/runtime/createRuntime.js +1 -1
  47. package/dist/core/runtime/createRuntime.js.map +1 -1
  48. package/dist/core/sessionUser.d.ts +14 -0
  49. package/dist/core/sessionUser.d.ts.map +1 -0
  50. package/dist/core/sessionUser.js +4 -0
  51. package/dist/core/sessionUser.js.map +1 -0
  52. package/dist/core/sql/allowlist.d.ts +2 -0
  53. package/dist/core/sql/allowlist.d.ts.map +1 -1
  54. package/dist/core/sql/allowlist.js +43 -11
  55. package/dist/core/sql/allowlist.js.map +1 -1
  56. package/dist/core/sql/ast.js +1 -1
  57. package/dist/core/sql/ast.js.map +1 -1
  58. package/dist/core/sql/compile.js +1 -1
  59. package/dist/core/sql/compile.js.map +1 -1
  60. package/dist/core/sql/policy.d.ts +2 -0
  61. package/dist/core/sql/policy.d.ts.map +1 -1
  62. package/dist/core/sql/policy.js +25 -6
  63. package/dist/core/sql/policy.js.map +1 -1
  64. package/dist/core/sql/run.d.ts +0 -8
  65. package/dist/core/sql/run.d.ts.map +1 -1
  66. package/dist/core/sql/run.js +152 -35
  67. package/dist/core/sql/run.js.map +1 -1
  68. package/dist/d1/executor.d.ts +2 -0
  69. package/dist/d1/executor.d.ts.map +1 -1
  70. package/dist/d1/executor.js +6 -1
  71. package/dist/d1/executor.js.map +1 -1
  72. package/dist/mcp/createMcpSurface.d.ts +5 -2
  73. package/dist/mcp/createMcpSurface.d.ts.map +1 -1
  74. package/dist/mcp/createMcpSurface.js +14 -3
  75. package/dist/mcp/createMcpSurface.js.map +1 -1
  76. package/dist/mcp/media.d.ts +9 -0
  77. package/dist/mcp/media.d.ts.map +1 -0
  78. package/dist/mcp/media.js +33 -0
  79. package/dist/mcp/media.js.map +1 -0
  80. package/dist/postgres/adapter.d.ts +5 -2
  81. package/dist/postgres/adapter.d.ts.map +1 -1
  82. package/dist/postgres/adapter.js +7 -1
  83. package/dist/postgres/adapter.js.map +1 -1
  84. package/dist/postgres/compile/index.d.ts +1 -1
  85. package/dist/postgres/compile/index.js +1 -1
  86. package/dist/postgres/driver.d.ts +38 -21
  87. package/dist/postgres/driver.d.ts.map +1 -1
  88. package/dist/postgres/driver.js +100 -24
  89. package/dist/postgres/driver.js.map +1 -1
  90. package/dist/postgres/executor.d.ts +8 -0
  91. package/dist/postgres/executor.d.ts.map +1 -1
  92. package/dist/postgres/executor.js +40 -13
  93. package/dist/postgres/executor.js.map +1 -1
  94. package/dist/postgres/index.d.ts +2 -1
  95. package/dist/postgres/index.d.ts.map +1 -1
  96. package/dist/postgres/index.js +1 -0
  97. package/dist/postgres/index.js.map +1 -1
  98. package/dist/postgres/lower.d.ts.map +1 -1
  99. package/dist/postgres/lower.js +3 -8
  100. package/dist/postgres/lower.js.map +1 -1
  101. package/dist/postgres/print.d.ts.map +1 -1
  102. package/dist/postgres/print.js +2 -15
  103. package/dist/postgres/print.js.map +1 -1
  104. package/dist/postgres/session.d.ts +9 -0
  105. package/dist/postgres/session.d.ts.map +1 -0
  106. package/dist/postgres/session.js +111 -0
  107. package/dist/postgres/session.js.map +1 -0
  108. package/dist/postgres/storage.d.ts +14 -1
  109. package/dist/postgres/storage.d.ts.map +1 -1
  110. package/dist/postgres/storage.js +136 -54
  111. package/dist/postgres/storage.js.map +1 -1
  112. package/dist/postgres/validator.d.ts +4 -3
  113. package/dist/postgres/validator.d.ts.map +1 -1
  114. package/dist/postgres/validator.js +13 -6
  115. package/dist/postgres/validator.js.map +1 -1
  116. package/dist/spec/domain/model/ManifestGrammar.d.ts +4 -0
  117. package/dist/spec/domain/model/ManifestGrammar.d.ts.map +1 -1
  118. package/dist/spec/domain/model/ManifestGrammar.js +4 -0
  119. package/dist/spec/domain/model/ManifestGrammar.js.map +1 -1
  120. package/dist/spec/domain/service/SchemaAdminUiChecker.d.ts +2 -0
  121. package/dist/spec/domain/service/SchemaAdminUiChecker.d.ts.map +1 -1
  122. package/dist/spec/domain/service/SchemaAdminUiChecker.js +50 -6
  123. package/dist/spec/domain/service/SchemaAdminUiChecker.js.map +1 -1
  124. package/dist/spec/domain/service/ViewAdminUiChecker.d.ts +1 -1
  125. package/dist/spec/domain/service/ViewAdminUiChecker.d.ts.map +1 -1
  126. package/dist/spec/domain/service/ViewAdminUiChecker.js +7 -2
  127. package/dist/spec/domain/service/ViewAdminUiChecker.js.map +1 -1
  128. package/dist/spec/infrastructure/sql/compilePlan.d.ts.map +1 -1
  129. package/dist/spec/infrastructure/sql/compilePlan.js +5 -0
  130. package/dist/spec/infrastructure/sql/compilePlan.js.map +1 -1
  131. package/dist/spec/kernel/diagnostic.d.ts +1 -1
  132. package/dist/spec/kernel/diagnostic.d.ts.map +1 -1
  133. package/dist/spec/kernel/diagnostic.js +5 -0
  134. package/dist/spec/kernel/diagnostic.js.map +1 -1
  135. package/dist/testing/cases/corpus.d.ts +2 -0
  136. package/dist/testing/cases/corpus.d.ts.map +1 -1
  137. package/dist/testing/cases/corpus.js +7 -5
  138. package/dist/testing/cases/corpus.js.map +1 -1
  139. package/dist/testing/cases/policy.d.ts +14 -1
  140. package/dist/testing/cases/policy.d.ts.map +1 -1
  141. package/dist/testing/cases/policy.js +3 -2
  142. package/dist/testing/cases/policy.js.map +1 -1
  143. package/dist/testing/cases/printer.d.ts.map +1 -1
  144. package/dist/testing/cases/printer.js +3 -1
  145. package/dist/testing/cases/printer.js.map +1 -1
  146. package/dist/testing/cases/report-view.d.ts.map +1 -1
  147. package/dist/testing/cases/report-view.js +18 -0
  148. package/dist/testing/cases/report-view.js.map +1 -1
  149. package/docs/adr/0031-host-cloud-core-contract.md +62 -1
  150. package/docs/adr/0032-store-centric-core-and-service-contract.md +17 -0
  151. package/docs/adr/0037-postgresql-is-the-reference-dialect.md +2 -1
  152. package/docs/adr/0039-postgresql-is-native.md +130 -0
  153. package/docs/adr/README.md +2 -0
  154. package/docs/adr/adr-lite-1376-admin-extensions.md +330 -0
  155. package/docs/examples/commerce-inventory.md +18 -1
  156. package/docs/examples/reference-service/package.json +2 -2
  157. package/docs/handbook/cloudflare/authentication.md +3 -3
  158. package/docs/handbook/cloudflare/deploy-and-operate.md +4 -1
  159. package/docs/handbook/cloudflare/media-r2.md +59 -8
  160. package/docs/handbook/concepts/mcp-and-agents.md +12 -4
  161. package/docs/handbook/concepts/runtime-and-adapters.md +74 -33
  162. package/docs/handbook/concepts/views.md +1 -1
  163. package/docs/handbook/guides/admin-extensions.md +191 -0
  164. package/docs/handbook/guides/admin-ui.md +2 -1
  165. package/docs/handbook/navigation.json +2 -1
  166. package/docs/handbook/reference/diagnostics.md +1 -0
  167. package/docs/handbook/reference/procedure.md +2 -1
  168. package/docs/handbook/reference/schema.md +6 -3
  169. package/docs/handbook/reference/surface.md +6 -2
  170. package/docs/handbook/reference/view.md +7 -5
  171. package/docs/handbook/releases/index.md +78 -4
  172. package/docs/handbook/start/project-and-cli.md +20 -12
  173. package/docs/skills/develop/SKILL.md +8 -2
  174. package/docs/skills/provision/SKILL.md +4 -1
  175. package/docs/skills/update/SKILL.md +3 -0
  176. package/docs/upgrade-0.1-to-0.2.md +1 -1
  177. package/package.json +6 -5
  178. package/dist/postgres/textRows.d.ts +0 -10
  179. package/dist/postgres/textRows.d.ts.map +0 -1
  180. package/dist/postgres/textRows.js +0 -44
  181. 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): it accepts D1's subset without
107
- SQLite's own `typeof`, `hex`, `json_extract`, `json_set`, `json_insert` and
108
- `json_remove` (write `x ->> '$.path'`), plus `WITH`, set operations, `LATERAL`,
109
- window frames, `FILTER`, jsonb operators and the rest of the
110
- [View reference](../reference/view.md)'s PostgreSQL table. A manifest that uses
111
- them no longer compiles for D1; one that does not moves between engines with a
112
- recompile.
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
- - `json_each`, `->>` and `CAST(x AS bool)` read as they do on D1 through small
124
- SQL functions Mantle creates (`_mantle_json_each`, `_mantle_jget`,
125
- `_mantle_bool`). `json_group_array` and `json_group_object` order by value.
126
- - Results match D1 where Mantle decides them. Text columns use `COLLATE "C"`,
127
- so they compare by code point. An `ORDER BY` key without `NULLS FIRST/LAST`
128
- puts NULL first ascending, as Core pages every View. Values never depend on
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, pinned in each
141
- transaction (`statementTimeoutMs`; 0 is none). Storage convergence has none.
142
- A statement past it fails with `RESOURCE_UNAVAILABLE` and writes nothing.
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; Hyperdrive on Workers |
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, so
163
- `connect` opens a client per operation (a socket must not outlive its request):
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
- const connect = (env: Env) => async () => {
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: connect(env) })
175
- // identity: createMantleAuth({ database: pgPool(connect(env)), driver: pgDatabaseDriver(connect(env)), ... })
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
- The Worker needs `compatibility_flags: ["nodejs_compat"]` for `pg`. Mantle runs
179
- every read in a transaction, which Hyperdrive never answers from its cache.
180
- Better Auth's reads (sessions, roles) do not, so create the Hyperdrive config
181
- with caching disabled (`wrangler hyperdrive create … --caching-disabled`), or a
182
- revoked session can be accepted until the cache expires. `timeZone` must be an
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); `bunDatabaseDriver` / `bunAuthDatabase` and `bunSqliteDriver` give
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` in SQL |
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`, and `capabilities: { siteSettings, media, invitationEmail, statistics }` (what this deployment turned on; the console hides what is off) |
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` | `bunPostgresStorage`, `bunSqliteStorage`, `bunPgConnect`, `bunDatabaseDriver`, `bunSqliteDriver`, `bunAuthDatabase`, `bunAdminAssets` |
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. `*` expands to declared fields; a bare `*` over a subquery or `json_each` is refused: name the columns |
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 10-second `statement_timeout`
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 PostgreSQL syntax.** PostgreSQL is the reference dialect
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`). A plan records its dialect.
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` (Bun.SQL or
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