@ai-matrx/data 0.23.0 → 0.24.0

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 (73) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +34 -0
  3. package/bin/emit.mjs +32 -7
  4. package/bin/generate.mjs +37 -8
  5. package/bin/matrx-data.mjs +6 -4
  6. package/description/admin.json.gz +0 -0
  7. package/description/agent.json.gz +0 -0
  8. package/description/ai.json.gz +0 -0
  9. package/description/api.json.gz +0 -0
  10. package/description/app.json.gz +0 -0
  11. package/description/assignment.json.gz +0 -0
  12. package/description/audit.json.gz +0 -0
  13. package/description/batch.json.gz +0 -0
  14. package/description/billing.json.gz +0 -0
  15. package/description/browser.json.gz +0 -0
  16. package/description/canvas.json.gz +0 -0
  17. package/description/chat.json.gz +0 -0
  18. package/description/code.json.gz +0 -0
  19. package/description/commerce.json.gz +0 -0
  20. package/description/communication.json.gz +0 -0
  21. package/description/content.json.gz +0 -0
  22. package/description/content_ir.json.gz +0 -0
  23. package/description/context.json.gz +0 -0
  24. package/description/crm.json.gz +0 -0
  25. package/description/custom.json.gz +0 -0
  26. package/description/dictionary.json.gz +0 -0
  27. package/description/docproc.json.gz +0 -0
  28. package/description/education.json.gz +0 -0
  29. package/description/esign.json.gz +0 -0
  30. package/description/extend.json.gz +0 -0
  31. package/description/files.json.gz +0 -0
  32. package/description/growth.json.gz +0 -0
  33. package/description/hindsight.json.gz +0 -0
  34. package/description/history.json.gz +0 -0
  35. package/description/iam.json.gz +0 -0
  36. package/description/index.json.gz +0 -0
  37. package/description/interview.json.gz +0 -0
  38. package/description/knowledge.json.gz +0 -0
  39. package/description/legal.json.gz +0 -0
  40. package/description/mandate.json.gz +0 -0
  41. package/description/marketing.json.gz +0 -0
  42. package/description/media.json.gz +0 -0
  43. package/description/meta.json.gz +0 -0
  44. package/description/ops.json.gz +0 -0
  45. package/description/pdf.json.gz +0 -0
  46. package/description/plan.json.gz +0 -0
  47. package/description/platform.json.gz +0 -0
  48. package/description/podcast.json.gz +0 -0
  49. package/description/projects.json.gz +0 -0
  50. package/description/public.json.gz +0 -0
  51. package/description/rag.json.gz +0 -0
  52. package/description/research.json.gz +0 -0
  53. package/description/runtime.json.gz +0 -0
  54. package/description/scheduler.json.gz +0 -0
  55. package/description/scraper.json.gz +0 -0
  56. package/description/seo.json.gz +0 -0
  57. package/description/skill.json.gz +0 -0
  58. package/description/social.json.gz +0 -0
  59. package/description/tool.json.gz +0 -0
  60. package/description/transcripts.json.gz +0 -0
  61. package/description/ui.json.gz +0 -0
  62. package/description/users.json.gz +0 -0
  63. package/description/web.json.gz +0 -0
  64. package/description/work.json.gz +0 -0
  65. package/description/workbench.json.gz +0 -0
  66. package/description/workflow.json.gz +0 -0
  67. package/dist/db.cjs +62 -28
  68. package/dist/db.cjs.map +1 -1
  69. package/dist/db.d.cts +83 -33
  70. package/dist/db.d.ts +83 -33
  71. package/dist/db.js +62 -28
  72. package/dist/db.js.map +1 -1
  73. package/package.json +5 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.24.0 — doors hardened (independent review)
4
+
5
+ - **Every write on a versioned table is guarded.** `remove` (and `archive` / `restore`) take the same
6
+ `{ expectedVersion }` / `{ overwrite: true }` guard as `update`; `expectedVersion` on an UNversioned
7
+ table is refused (it used to be ignored silently). `remove` returns `{ status }` (or a conflict).
8
+ - **Reads exclude soft-deleted rows** by default (`read`, `listAll`, `page`, `count`); `includeDeleted: true` opts in.
9
+ - **`listAll` / `page` order by the full primary key** (stable paging on composite keys).
10
+ - **Types:** ops infer `TableDescriptor<Row, Insert, Update>` directly, so an insert error names the
11
+ table's Insert type; `read`/`listAll`/`page` take `columns: [...]` and type the result to them; the
12
+ `Where` callback receives a typed `Filter<Row>` (column names and value types checked).
13
+ - **Pay-only runtime tables:** `--flags-index` writes `<schema>.flags.ts` per schema plus
14
+ `flags-index.ts` loaders; `loadTableByName(flagLoaders, "schema.table")` loads only that schema.
15
+ - **The description ships in the package** (`description/*.json.gz`, 0.5 MB): `matrx-data generate`
16
+ needs no repository or database access (`--from` still takes a fresher one). README documents it.
17
+ - `matrx-data emit` accounts for every callable-door register row: `index.json` →
18
+ `report.registeredDoorsOutsideDescription` by reason, and the emit fails if any row is unexplained.
19
+
20
+ Consumer action: `remove(db, t, id)` on a versioned table now needs a guard and returns `{ status }`;
21
+ `read(db, t, id, "cols")` becomes `read(db, t, id, { columns: [...] })`. No consumer used 0.23.0's doors yet.
22
+
3
23
  ## 0.23.0 — the doors: one way to talk to the database
4
24
 
5
25
  - **`matrx-data` CLI** (`bin`): `matrx-data emit [--out db-contract] [--check]` reads the LIVE database
package/README.md CHANGED
@@ -32,6 +32,40 @@ npm install @ai-matrx/data
32
32
  | `createActiveOrgCookie` | ONE browser memory of the organization a person is acting in, shared by every Matrx surface on the apex (`matrx-active-org`, `Domain=.aimatrx.com`): identity-keyed (`<userId>:<orgId>`), domain-wide only on a listed apex, host-only elsewhere. Stores the stored-selection rung of the canonical order; never decides. |
33
33
  | `uniqueChannelTopic` | Realtime channel hygiene: unique per-mount topics so React 19 double-invoked effects and Fast Refresh can never collide with a still-joined channel. |
34
34
 
35
+ ## The doors — one way to talk to the database
36
+
37
+ Every app talks to the database through doors over descriptors it **generates locally** from the
38
+ ONE database description. The description ships inside this package (`description/`, gzipped, the one
39
+ this version was built with), so no repository or database access is needed.
40
+
41
+ ```bash
42
+ # in your app (package.json: "db:generate": "matrx-data generate --out src/db/generated --schemas workbench,chat")
43
+ npx matrx-data generate --out src/db/generated --schemas workbench # one file per schema
44
+ npx matrx-data generate --out src/db/generated --schemas workbench --check # CI: fail when stale
45
+ npx matrx-data generate --out src/db/generated --flags-index # + per-schema flags for tableByName
46
+ # fresher than this package version? With the direct-DB variables (SUPABASE_MATRIX_*):
47
+ npx matrx-data emit --out db-contract && npx matrx-data generate --from db-contract --out src/db/generated
48
+ ```
49
+
50
+ ```ts
51
+ import { createDb, listAll, read, insert, update, remove, call } from "@ai-matrx/data/db";
52
+ import { noteFolders, notes, noteFolderGetOrCreate } from "./db/generated/workbench";
53
+
54
+ const db = createDb({ client: supabase, organization: () => activeOrgId }); // wraps YOUR client
55
+ const pinned = await listAll(db, notes, (q) => q.eq("is_pinned", true)); // complete; typed filter; live rows
56
+ const row = await read(db, notes, id, { columns: ["id", "label"] }); // typed to those columns
57
+ await insert(db, notes, { folder_id, label }); // child: the folder's organization, never the active one
58
+ await update(db, notes, id, { label }, { expectedVersion: row.version }); // or { overwrite: true }
59
+ await remove(db, notes, id, { expectedVersion: v }); // soft delete; archive-only tables refuse
60
+ await call(db, noteFolderGetOrCreate, { p_name: "Clients" });
61
+ // @ai-matrx/api: pass db.credentials as its `credentials` option — one sign-in feeds both lanes
62
+ ```
63
+
64
+ Rules come from the description (the live table registry), never the call site: organization by the
65
+ access ladder; reads never filter by the active organization and exclude soft-deleted rows unless
66
+ `includeDeleted`; `listAll` is complete and ordered by the full primary key; every write on a versioned
67
+ table is guarded; the door set follows the registry type; `createServiceDb` is server-only.
68
+
35
69
  ## `@ai-matrx/data/net`
36
70
 
37
71
  | Export | What it does |
package/bin/emit.mjs CHANGED
@@ -137,12 +137,32 @@ function collect(schemas) {
137
137
  and (${ROLES.map((r) => `has_function_privilege('${r}', p.oid, 'EXECUTE')`).join(" or ")})
138
138
  ) f`);
139
139
 
140
+ // Every register row, matched to its live function (schema-qualified type names normalized, so
141
+ // "p_row block_states" matches "p_row platform.block_states"), and when it is not in the
142
+ // description, WHY — so no register row is ever unaccounted for.
140
143
  const doors = readJson(`
141
144
  select coalesce(json_agg(json_build_object(
142
- 'schema', d.schema_name, 'name', d.function_name, 'identity', d.identity_args,
145
+ 'schema', d.schema_name, 'name', d.function_name, 'identity', coalesce(p.ident, d.identity_args),
143
146
  'gatePredicate', d.gate_predicate, 'anonymousCallers', d.anonymous_callers, 'signedInCallers', d.signed_in_callers,
144
- 'nonClientLane', d.non_client_lane, 'argumentRules', d.argument_rules, 'refusalOnly', d.refusal_only
145
- )), '[]') from platform.client_callable_door d`);
147
+ 'nonClientLane', d.non_client_lane, 'argumentRules', d.argument_rules, 'refusalOnly', d.refusal_only,
148
+ 'outside', case
149
+ when p.oid is null and not exists (select 1 from pg_proc p2 join pg_namespace n2 on n2.oid = p2.pronamespace
150
+ where n2.nspname = d.schema_name and p2.proname = d.function_name) then 'function no longer exists'
151
+ when p.oid is null then 'no live overload has the registered signature'
152
+ when not (d.schema_name = any(${S})) then 'schema not exposed to PostgREST (server-only)'
153
+ when not (${ROLES.map((r) => `has_function_privilege('${r}', p.oid, 'EXECUTE')`).join(" or ")}) then 'no client role may execute it'
154
+ when p.prorettype in ('trigger'::regtype, 'event_trigger'::regtype) then 'trigger function'
155
+ when exists (select 1 from pg_depend dd where dd.classid = 'pg_proc'::regclass and dd.objid = p.oid and dd.deptype = 'e') then 'extension-owned'
156
+ end
157
+ )), '[]')
158
+ from platform.client_callable_door d
159
+ left join lateral (
160
+ select p.oid, p.prorettype, pg_get_function_identity_arguments(p.oid) as ident
161
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
162
+ where n.nspname = d.schema_name and p.proname = d.function_name
163
+ and regexp_replace(pg_get_function_identity_arguments(p.oid), '\\m[a-z_][a-z0-9_]*\\.', '', 'g')
164
+ = regexp_replace(d.identity_args, '\\m[a-z_][a-z0-9_]*\\.', '', 'g')
165
+ limit 1) p on true`);
146
166
 
147
167
  const enums = readJson(`
148
168
  select coalesce(json_object_agg(n.nspname || '.' || t.typname,
@@ -262,7 +282,7 @@ export function buildDescription() {
262
282
  const doorsByKey = new Map(doors.map((d) => [doorKey(d.schema, d.name, d.identity), d]));
263
283
 
264
284
  const files = {};
265
- const index = { format: FORMAT, schemas: {}, publications: {}, report: { tablesWithoutRegistryRow: [], untypedRegistryRows: [], registeredDoorsOutsideDescription: 0 } };
285
+ const index = { format: FORMAT, schemas: {}, publications: {}, report: { tablesWithoutRegistryRow: [], untypedRegistryRows: [], registeredDoorsOutsideDescription: {} } };
266
286
  const publications = {};
267
287
  for (const { pub, table } of published) {
268
288
  const name = table.replace(/^realtime\.messages_\d{4}_\d{2}_\d{2}$/, "realtime.messages");
@@ -313,9 +333,14 @@ export function buildDescription() {
313
333
  }
314
334
  for (const k of Object.keys(publications)) publications[k].sort();
315
335
  index.publications = publications;
316
- // Register rows whose function is outside the exposed schemas, not executable by any client role,
317
- // or whose identity no longer matches a live signature.
318
- index.report.registeredDoorsOutsideDescription = doors.length - matchedDoors;
336
+ // Every register row not in the description, by reason (each named; none unaccounted for).
337
+ const outside = {};
338
+ for (const d of doors) if (d.outside) (outside[d.outside] ??= []).push(`${d.schema}.${d.name}(${d.identity})`);
339
+ for (const k of Object.keys(outside)) outside[k].sort();
340
+ index.report.registeredDoorsOutsideDescription = outside;
341
+ index.report.registeredDoors = { total: doors.length, inDescription: matchedDoors, outside: doors.length - matchedDoors };
342
+ const unexplained = doors.length - matchedDoors - Object.values(outside).reduce((n, l) => n + l.length, 0);
343
+ if (unexplained !== 0) throw new Error(`${unexplained} register row(s) neither in the description nor explained — the emitter's matching is wrong`);
319
344
  index.report.tablesWithoutRegistryRow.sort();
320
345
  index.report.untypedRegistryRows.sort();
321
346
  index.contractSha256 = sha256(Object.keys(index.schemas).sort().map((s) => `${s}:${index.schemas[s].sha256}`).join("\n"));
package/bin/generate.mjs CHANGED
@@ -17,6 +17,8 @@
17
17
  */
18
18
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
19
  import path from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { gunzipSync } from "node:zlib";
20
22
 
21
23
  const RESERVED = new Set(
22
24
  "break case catch class const continue debugger default delete do else enum export extends false finally for function if import in instanceof new null return super switch this throw true try typeof var void while with yield let static implements interface package private protected public await".split(
@@ -157,29 +159,56 @@ export function renderSchema(desc, schemaSha, importFrom) {
157
159
  return out;
158
160
  }
159
161
 
160
- export function renderFlagsIndex(descs, importFrom) {
162
+ /** One schema's flags (`<schema>.flags.ts`) — loaded only when a runtime name names that schema. */
163
+ export function renderSchemaFlags(desc, importFrom) {
161
164
  const index = {};
162
- for (const d of descs) for (const [name, t] of Object.entries({ ...d.tables, ...d.views })) index[`${d.schema}.${name}`] = tableDescriptor(d.schema, name, t);
163
- return `/** GENERATED by \`matrx-data generate --flags-index\` — the flags of every table, for tableByName(). Never edit by hand. */
165
+ for (const [name, t] of Object.entries({ ...desc.tables, ...desc.views })) index[`${desc.schema}.${name}`] = tableDescriptor(desc.schema, name, t);
166
+ return `/** GENERATED by \`matrx-data generate --flags-index\` — ${desc.schema}'s table flags for tableByName(). Never edit by hand. */
164
167
  /* eslint-disable */
165
168
  import type { FlagsIndex } from "${importFrom}";
166
169
 
167
- export const flagsIndex: FlagsIndex = ${JSON.stringify(index)} as unknown as FlagsIndex;
170
+ export const flags: FlagsIndex = ${JSON.stringify(index)} as unknown as FlagsIndex;
168
171
  `;
169
172
  }
170
173
 
171
- export function generate({ from, out, schemas, check, importFrom, flagsIndex = process.argv.includes("--flags-index") }) {
172
- const index = JSON.parse(readFileSync(path.join(from, "index.json"), "utf8"));
174
+ /** Per-schema loaders: `loadTableByName(flagLoaders, "schema.table")` imports only that schema's flags. */
175
+ export function renderFlagLoaders(schemas, importFrom) {
176
+ return `/** GENERATED by \`matrx-data generate --flags-index\` — never edit by hand. */
177
+ /* eslint-disable */
178
+ import type { FlagLoaders } from "${importFrom}";
179
+
180
+ export const flagLoaders: FlagLoaders = {
181
+ ${schemas.map((s) => ` ${key(s)}: () => import("./${s}.flags").then((m) => m.flags),`).join("\n")}
182
+ };
183
+ `;
184
+ }
185
+
186
+ /** The description ships inside @ai-matrx/data (gzipped); `--from` points at a fresher one. */
187
+ export const BUNDLED_DESCRIPTION = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "description");
188
+
189
+ function readDescriptionFile(dir, name) {
190
+ const plain = path.join(dir, name);
191
+ if (existsSync(plain)) return readFileSync(plain, "utf8");
192
+ const gz = `${plain}.gz`;
193
+ if (existsSync(gz)) return gunzipSync(readFileSync(gz)).toString("utf8");
194
+ throw new Error(`${name} not found in ${dir} (neither ${name} nor ${name}.gz)`);
195
+ }
196
+
197
+ export function generate({ from = BUNDLED_DESCRIPTION, out, schemas, check, importFrom, flagsIndex = process.argv.includes("--flags-index") }) {
198
+ const index = JSON.parse(readDescriptionFile(from, "index.json"));
173
199
  const names = schemas?.length ? schemas : Object.keys(index.schemas).sort();
174
200
  const files = {};
175
201
  const descs = [];
176
202
  for (const s of names) {
177
203
  if (!index.schemas[s]) throw new Error(`schema "${s}" is not in the description (${Object.keys(index.schemas).length} schemas)`);
178
- const desc = JSON.parse(readFileSync(path.join(from, `${s}.json`), "utf8"));
204
+ const desc = JSON.parse(readDescriptionFile(from, `${s}.json`));
179
205
  descs.push(desc);
180
206
  files[`${s}.ts`] = renderSchema(desc, index.schemas[s].sha256, importFrom);
181
207
  }
182
- if (flagsIndex) files["flags-index.ts"] = renderFlagsIndex(descs, importFrom);
208
+ if (flagsIndex) {
209
+ for (const d of descs) files[`${d.schema}.flags.ts`] = renderSchemaFlags(d, importFrom);
210
+ files["flags-index.ts"] = renderFlagLoaders(descs.map((d) => d.schema), importFrom);
211
+ }
183
212
  if (check) {
184
213
  const stale = Object.entries(files).filter(([n, t]) => !existsSync(path.join(out, n)) || readFileSync(path.join(out, n), "utf8") !== t).map(([n]) => n);
185
214
  if (stale.length) {
@@ -3,7 +3,9 @@
3
3
  * matrx-data — @ai-matrx/data's build-time CLI.
4
4
  *
5
5
  * matrx-data emit [--out <dir>] [--check] live database → the database description (read-only)
6
- * matrx-data generate --from <dir> --out <dir> [--schemas a,b] description → typed doors in THIS app
6
+ * matrx-data generate --out <dir> [--from <dir>] [--schemas a,b] [--flags-index] [--check]
7
+ * description → typed doors in THIS app (--from defaults to the description
8
+ * shipped inside this package version)
7
9
  *
8
10
  * The package ships the runtime and this CLI; each app generates its own doors
9
11
  * locally (Prisma's model), so a new column never waits on a package release.
@@ -25,12 +27,12 @@ async function main() {
25
27
  const { generate } = await import("./generate.mjs");
26
28
  const from = flag("--from");
27
29
  const out = flag("--out");
28
- if (!from || !out) {
29
- console.error("matrx-data generate: --from <description dir> and --out <dir> are required");
30
+ if (!out) {
31
+ console.error("matrx-data generate: --out <dir> is required (--from defaults to the description shipped in this package)");
30
32
  return 2;
31
33
  }
32
34
  return generate({
33
- from: path.resolve(from),
35
+ ...(from ? { from: path.resolve(from) } : {}),
34
36
  out: path.resolve(out),
35
37
  schemas: flag("--schemas")?.split(",").map((s) => s.trim()).filter(Boolean),
36
38
  check: rest.includes("--check"),
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/dist/db.cjs CHANGED
@@ -53,6 +53,7 @@ __export(db_exports, {
53
53
  isMissingSessionError: () => isMissingSessionError,
54
54
  isRecordUnavailableError: () => isRecordUnavailableError,
55
55
  listAll: () => listAll,
56
+ loadTableByName: () => loadTableByName,
56
57
  mergeJsonColumn: () => mergeJsonColumn,
57
58
  page: () => page,
58
59
  parseCookieHeader: () => parseCookieHeader,
@@ -1054,18 +1055,26 @@ function byPk(q, t, id) {
1054
1055
  const key = id;
1055
1056
  return t.pk.reduce((acc, col) => acc.eq(col, key[col]), q);
1056
1057
  }
1057
- async function read(db, table, id, columns = "*") {
1058
+ var selectOf = (columns) => columns && columns.length ? columns.join(",") : "*";
1059
+ function live(q, table, includeDeleted) {
1060
+ return table.softDelete && !includeDeleted ? q.is("deleted_at", null) : q;
1061
+ }
1062
+ function ordered(q, table, orderBy, ascending = true) {
1063
+ const cols = orderBy ? [orderBy, ...table.pk.filter((c) => c !== orderBy)] : table.pk.length ? table.pk : ["id"];
1064
+ return cols.reduce((acc, c) => acc.order(c, { ascending }), q);
1065
+ }
1066
+ async function read(db, table, id, opts = {}) {
1058
1067
  assertDoor(db, table, "read");
1059
- return check(`read ${qualified(table)}`, await byPk(from(db, table).select(columns), table, id).maybeSingle());
1068
+ const q = live(byPk(from(db, table).select(selectOf(opts.columns)), table, id), table, opts.includeDeleted);
1069
+ return check(`read ${qualified(table)}`, await q.maybeSingle());
1060
1070
  }
1061
1071
  async function listAll(db, table, where, opts = {}) {
1062
1072
  assertDoor(db, table, "read");
1063
- const order = opts.orderBy ?? table.pk[0] ?? "id";
1064
1073
  return readAllRows(
1065
1074
  ({ from: lo, to: hi }) => {
1066
- let q = from(db, table).select(opts.columns ?? "*", { count: "exact" });
1075
+ let q = live(from(db, table).select(selectOf(opts.columns), { count: "exact" }), table, opts.includeDeleted);
1067
1076
  if (where) q = where(q);
1068
- return q.order(order, { ascending: true }).range(lo, hi);
1077
+ return ordered(q, table, opts.orderBy).range(lo, hi);
1069
1078
  },
1070
1079
  {
1071
1080
  label: `listAll ${qualified(table)}`,
@@ -1076,14 +1085,14 @@ async function listAll(db, table, where, opts = {}) {
1076
1085
  }
1077
1086
  async function page(db, table, window2) {
1078
1087
  assertDoor(db, table, "read");
1079
- let q = from(db, table).select(window2.columns ?? "*", { count: "exact" });
1088
+ let q = live(from(db, table).select(selectOf(window2.columns), { count: "exact" }), table, window2.includeDeleted);
1080
1089
  if (window2.where) q = window2.where(q);
1081
- const res = await q.order(window2.orderBy ?? table.pk[0] ?? "id", { ascending: window2.ascending ?? true }).range(window2.from, window2.to);
1090
+ const res = await ordered(q, table, window2.orderBy, window2.ascending ?? true).range(window2.from, window2.to);
1082
1091
  return { rows: check(`page ${qualified(table)}`, res) ?? [], total: res.count ?? null };
1083
1092
  }
1084
- async function count(db, table, where) {
1093
+ async function count(db, table, where, opts = {}) {
1085
1094
  assertDoor(db, table, "read");
1086
- let q = from(db, table).select("*", { count: "exact", head: true });
1095
+ let q = live(from(db, table).select("*", { count: "exact", head: true }), table, opts.includeDeleted);
1087
1096
  if (where) q = where(q);
1088
1097
  const res = await q;
1089
1098
  check(`count ${qualified(table)}`, res);
@@ -1116,6 +1125,24 @@ async function insert(db, table, values) {
1116
1125
  const stored = check(`insert ${qualified(table)}`, res);
1117
1126
  return many ? stored : stored[0];
1118
1127
  }
1128
+ async function writeRow(db, table, id, values, door, guard) {
1129
+ const expected = guard && "expectedVersion" in guard ? guard.expectedVersion : void 0;
1130
+ if (expected !== void 0 && !table.versioned) {
1131
+ throw new DoorRefusedError(door, "expectedVersion on a table that is not versioned \u2014 nothing would guard it; pass no guard");
1132
+ }
1133
+ if (table.versioned && expected === void 0 && !(guard && "overwrite" in guard && guard.overwrite)) {
1134
+ throw new DoorRefusedError(door, "a versioned table takes { expectedVersion } or { overwrite: true }");
1135
+ }
1136
+ if (expected !== void 0) {
1137
+ return await guardedUpdate({
1138
+ expectedVersion: expected,
1139
+ applyUpdate: ({ expectedVersion, nextVersion }) => byPk(from(db, table).update({ ...values, version: nextVersion }), table, id).eq("version", expectedVersion).select("*").maybeSingle(),
1140
+ fetchCurrent: () => byPk(from(db, table).select("*"), table, id).maybeSingle()
1141
+ });
1142
+ }
1143
+ const row = check(door, await byPk(from(db, table).update(values), table, id).select("*").maybeSingle());
1144
+ return row ? { status: "saved", row } : { status: "not_found" };
1145
+ }
1119
1146
  async function update(db, table, id, patch, guard) {
1120
1147
  assertDoor(db, table, "update");
1121
1148
  const door = `update ${qualified(table)}`;
@@ -1124,29 +1151,30 @@ async function update(db, table, id, patch, guard) {
1124
1151
  const blocked = table.readOnlyColumns.filter((c) => c in values);
1125
1152
  if (blocked.length) throw new DoorRefusedError(door, `read-only to clients: ${blocked.join(", ")}`);
1126
1153
  }
1127
- if (table.versioned && guard && "expectedVersion" in guard) {
1128
- return guardedUpdate({
1129
- expectedVersion: guard.expectedVersion,
1130
- applyUpdate: ({ expectedVersion, nextVersion }) => byPk(from(db, table).update({ ...values, version: nextVersion }), table, id).eq("version", expectedVersion).select("*").maybeSingle(),
1131
- fetchCurrent: () => byPk(from(db, table).select("*"), table, id).maybeSingle()
1132
- });
1133
- }
1134
- if (table.versioned && !(guard && "overwrite" in guard && guard.overwrite)) {
1135
- throw new DoorRefusedError(door, "a versioned table takes { expectedVersion } or { overwrite: true }");
1136
- }
1137
- const row = check(door, await byPk(from(db, table).update(values), table, id).select("*").maybeSingle());
1138
- return row ? { status: "saved", row } : { status: "not_found" };
1154
+ return writeRow(db, table, id, values, door, guard);
1139
1155
  }
1140
- async function remove(db, table, id) {
1156
+ async function remove(db, table, id, guard) {
1141
1157
  assertDoor(db, table, "remove");
1142
1158
  const door = `remove ${qualified(table)}`;
1143
1159
  if (table.softDelete) {
1144
- const row = check(door, await byPk(from(db, table).update({ deleted_at: (/* @__PURE__ */ new Date()).toISOString() }), table, id).select("*").maybeSingle());
1145
- return row ? "soft_deleted" : "not_found";
1160
+ const res = await writeRow(db, table, id, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() }, door, guard);
1161
+ return res.status === "saved" ? { status: "soft_deleted" } : res.status === "conflict" ? { status: "conflict", currentVersion: res.currentVersion } : res;
1146
1162
  }
1147
1163
  if (table.archive) throw new DoorRefusedError(door, "this table archives instead of deleting \u2014 use archive()");
1148
- const rows = check(door, await byPk(from(db, table).delete(), table, id).select("*"));
1149
- return rows && rows.length ? "deleted" : "not_found";
1164
+ const expected = guard && "expectedVersion" in guard ? guard.expectedVersion : void 0;
1165
+ if (expected !== void 0 && !table.versioned) throw new DoorRefusedError(door, "expectedVersion on a table that is not versioned");
1166
+ if (table.versioned && expected === void 0 && !(guard && "overwrite" in guard)) {
1167
+ throw new DoorRefusedError(door, "a versioned table takes { expectedVersion } or { overwrite: true }");
1168
+ }
1169
+ let q = byPk(from(db, table).delete(), table, id);
1170
+ if (expected !== void 0) q = q.eq("version", expected);
1171
+ const rows = check(door, await q.select("*"));
1172
+ if (rows && rows.length) return { status: "deleted" };
1173
+ if (expected !== void 0) {
1174
+ const current = check(door, await byPk(from(db, table).select("version"), table, id).maybeSingle());
1175
+ if (current) return { status: "conflict", currentVersion: current.version };
1176
+ }
1177
+ return { status: "not_found" };
1150
1178
  }
1151
1179
  async function archive(db, table, id, opts = {}) {
1152
1180
  assertDoor(db, table, "archive");
@@ -1156,9 +1184,9 @@ async function archive(db, table, id, opts = {}) {
1156
1184
  else if (table.archive === "is_archived") values = { is_archived: !opts.restore };
1157
1185
  else if (table.softDelete && opts.restore) values = { deleted_at: null };
1158
1186
  else throw new DoorRefusedError(door, "the table has no archive column");
1159
- return check(door, await byPk(from(db, table).update(values), table, id).select("*").maybeSingle());
1187
+ return writeRow(db, table, id, values, door, opts.guard);
1160
1188
  }
1161
- var restore = (db, table, id) => archive(db, table, id, { restore: true });
1189
+ var restore = (db, table, id, guard) => archive(db, table, id, guard ? { restore: true, guard } : { restore: true });
1162
1190
  async function call(db, fn, args) {
1163
1191
  const door = `call ${fn.schema}.${fn.name}`;
1164
1192
  const role = db.role === "service" ? "service_role" : "authenticated";
@@ -1172,6 +1200,12 @@ function tableByName(index, name) {
1172
1200
  if (!t) throw new DoorRefusedError(`tableByName ${name}`, "no such table in the database description (schema-qualify it: schema.table)");
1173
1201
  return t;
1174
1202
  }
1203
+ async function loadTableByName(loaders, name) {
1204
+ const schema = name.slice(0, name.indexOf("."));
1205
+ const load = schema ? loaders[schema] : void 0;
1206
+ if (!load) throw new DoorRefusedError(`tableByName ${name}`, "no such schema in the database description (schema-qualify it: schema.table)");
1207
+ return tableByName(await load(), name);
1208
+ }
1175
1209
  function changesOf(table) {
1176
1210
  if (!table.realtime) throw new DoorRefusedError(`changesOf ${qualified(table)}`, "the table is not in the supabase_realtime publication");
1177
1211
  return { schema: table.schema, table: table.name };