@voidbase-cloud/voidbase 0.1.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 (134) hide show
  1. package/.env.example +9 -0
  2. package/CHANGELOG.md +19 -0
  3. package/COMPAT.md +43 -0
  4. package/LICENSE +21 -0
  5. package/NOTICE +8 -0
  6. package/README.md +124 -0
  7. package/bin/voidbase.ts +158 -0
  8. package/crons/every-minute.ts +13 -0
  9. package/db/migrations/20260905175935_large_swarm.sql +87 -0
  10. package/db/migrations/20260905185720_wild_sunspot.sql +16 -0
  11. package/db/migrations/20260905190723_solid_toro.sql +1 -0
  12. package/db/migrations/20260905213340_remarkable_union_jack.sql +11 -0
  13. package/db/migrations/meta/20260905175935_snapshot.json +599 -0
  14. package/db/migrations/meta/20260905185720_snapshot.json +703 -0
  15. package/db/migrations/meta/20260905190723_snapshot.json +710 -0
  16. package/db/migrations/meta/20260905213340_snapshot.json +781 -0
  17. package/db/migrations/meta/_journal.json +34 -0
  18. package/db/schema.ts +130 -0
  19. package/docs/deploy.md +153 -0
  20. package/docs/differences.md +88 -0
  21. package/docs/hooks.md +84 -0
  22. package/docs/migrating.md +29 -0
  23. package/docs/perf.md +53 -0
  24. package/docs/platform.md +208 -0
  25. package/docs/releasing.md +38 -0
  26. package/env.ts +23 -0
  27. package/hooks-plugin.ts +237 -0
  28. package/package.json +134 -0
  29. package/queues/jobs.ts +13 -0
  30. package/routes/api/[...path].ts +19 -0
  31. package/scripts/bench-realtime.ts +46 -0
  32. package/scripts/bench.ts +39 -0
  33. package/scripts/ci-suites.sh +27 -0
  34. package/scripts/dev.sh +29 -0
  35. package/scripts/export.ts +70 -0
  36. package/scripts/seed-app-user.sh +14 -0
  37. package/scripts/seed-d1.ts +17 -0
  38. package/scripts/seed-reference.sh +29 -0
  39. package/scripts/starter.sh +22 -0
  40. package/scripts/sync-app.ts +22 -0
  41. package/scripts/sync-panel.ts +66 -0
  42. package/src/cloud/rest.ts +297 -0
  43. package/src/node/assets.ts +22 -0
  44. package/src/node/bundle.ts +88 -0
  45. package/src/node/cloud-init.ts +51 -0
  46. package/src/node/d1.ts +44 -0
  47. package/src/node/deploy-cf.ts +179 -0
  48. package/src/node/index.ts +5 -0
  49. package/src/node/panel.ts +21 -0
  50. package/src/node/serve.ts +125 -0
  51. package/src/node/storage.ts +51 -0
  52. package/src/platform/node/env.ts +4 -0
  53. package/src/platform/node/hooks.ts +19 -0
  54. package/src/platform/node/log.ts +7 -0
  55. package/src/platform/node/migrations.ts +5 -0
  56. package/src/platform/node/photon.ts +1 -0
  57. package/src/platform/node/sockets.ts +22 -0
  58. package/src/platform/node/sse.ts +23 -0
  59. package/src/platform/workers/env.ts +3 -0
  60. package/src/platform/workers/hooks.ts +2 -0
  61. package/src/platform/workers/log.ts +1 -0
  62. package/src/platform/workers/migrations.ts +1 -0
  63. package/src/platform/workers/photon.ts +1 -0
  64. package/src/platform/workers/sockets.ts +3 -0
  65. package/src/platform/workers/sse.ts +1 -0
  66. package/src/server/api.ts +27 -0
  67. package/src/server/app.ts +582 -0
  68. package/src/server/auth-extra.ts +113 -0
  69. package/src/server/auth-flows.ts +186 -0
  70. package/src/server/auth-response.ts +111 -0
  71. package/src/server/auth.ts +187 -0
  72. package/src/server/backups.ts +234 -0
  73. package/src/server/batch.ts +123 -0
  74. package/src/server/bootstrap.ts +71 -0
  75. package/src/server/collections/auth-option-shape.json +71 -0
  76. package/src/server/collections/ddl.ts +127 -0
  77. package/src/server/collections/fields.ts +120 -0
  78. package/src/server/collections/model.ts +185 -0
  79. package/src/server/collections/oauth2-providers.json +1 -0
  80. package/src/server/collections/scaffolds.json +210 -0
  81. package/src/server/collections/service.ts +392 -0
  82. package/src/server/collections/system.json +605 -0
  83. package/src/server/collections/system.ts +19 -0
  84. package/src/server/collections/validate.ts +239 -0
  85. package/src/server/crc32.ts +13 -0
  86. package/src/server/crons.ts +100 -0
  87. package/src/server/crypto.ts +26 -0
  88. package/src/server/db.ts +37 -0
  89. package/src/server/errors.ts +53 -0
  90. package/src/server/files-api.ts +52 -0
  91. package/src/server/filter/compile.ts +420 -0
  92. package/src/server/filter/lexer.ts +107 -0
  93. package/src/server/filter/parser.ts +49 -0
  94. package/src/server/hardening.ts +136 -0
  95. package/src/server/hooks/index.ts +147 -0
  96. package/src/server/hooks/migrations.ts +58 -0
  97. package/src/server/hooks/node-async-hooks.d.ts +7 -0
  98. package/src/server/hooks/record.ts +152 -0
  99. package/src/server/hooks/runtime.ts +344 -0
  100. package/src/server/hooks/virtual-migrations.d.ts +4 -0
  101. package/src/server/hooks/virtual.d.ts +7 -0
  102. package/src/server/hub.ts +91 -0
  103. package/src/server/ids.ts +22 -0
  104. package/src/server/jobs.ts +84 -0
  105. package/src/server/jwt.ts +61 -0
  106. package/src/server/logs.ts +144 -0
  107. package/src/server/mail/index.ts +99 -0
  108. package/src/server/mail/message.ts +43 -0
  109. package/src/server/mail/smtp.ts +82 -0
  110. package/src/server/mail/templates.ts +168 -0
  111. package/src/server/oauth2/index.ts +198 -0
  112. package/src/server/oauth2/providers.ts +153 -0
  113. package/src/server/password.ts +17 -0
  114. package/src/server/realtime/hub-client.ts +50 -0
  115. package/src/server/realtime/index.ts +239 -0
  116. package/src/server/records/expand.ts +129 -0
  117. package/src/server/records/files.ts +69 -0
  118. package/src/server/records/json.ts +23 -0
  119. package/src/server/records/picker.ts +80 -0
  120. package/src/server/records/service.ts +598 -0
  121. package/src/server/records/thumbs.ts +148 -0
  122. package/src/server/records/values.ts +295 -0
  123. package/src/server/settings-api.ts +104 -0
  124. package/src/server/settings.ts +215 -0
  125. package/src/server/sql.ts +61 -0
  126. package/src/server/static.ts +17 -0
  127. package/src/server/storage/s3.ts +118 -0
  128. package/src/server/types.ts +25 -0
  129. package/src/server/webauthn.ts +168 -0
  130. package/tsconfig.json +36 -0
  131. package/tsconfig.node.json +27 -0
  132. package/types/pb_data.d.ts +24438 -0
  133. package/vite.config.ts +10 -0
  134. package/void.json +12 -0
@@ -0,0 +1,598 @@
1
+ // Records CRUD mirroring apis/record_crud.go + forms/record_upsert.go: API rules, modifiers, autodate,
2
+ // auth-record form rules, unique-constraint mapping, file storage, expand and fields.
3
+ import { isMultiple, type Field } from "../collections/fields";
4
+ import type { Collection } from "../collections/model";
5
+ import { all, ident, one, stmt } from "../db";
6
+ import { ApiError, badRequest, forbidden, notFound, type FieldErrors } from "../errors";
7
+ import { compileFilter, compileSort, FilterError, renderJoin, type Join, type RequestInfo } from "../filter/compile";
8
+ import { FilterSyntaxError } from "../filter/lexer";
9
+ import { nowString, randomId, randomString } from "../ids";
10
+ import { hashPassword, verifyPassword } from "../password";
11
+ import type { AuthRecord, Row } from "../types";
12
+ import { expandRecords } from "./expand";
13
+ import { deleteAllRecordFiles, deleteFiles, normalizeFilename, putUpload, sniffMime } from "./files";
14
+ import { hubActive, publishChanges, type ChangeEvent } from "../realtime/hub-client";
15
+ import { recordToJSON } from "./json";
16
+ import { parseFields, pick } from "./picker";
17
+ import { autogenerate, normalizeInput, rowToValues, toColumn, uniqueStrings, validateValues, type FieldError, type RecordErrors, type Upload } from "./values";
18
+ import { CollectionRef, HookRecord } from "../hooks/record";
19
+ import { hasHandlers, trigger, type RequestEvent } from "../hooks/runtime";
20
+ import { fromColumn } from "./values";
21
+
22
+ export interface RecordContext {
23
+ db: D1Database;
24
+ storage: R2Bucket;
25
+ auth: AuthRecord | null;
26
+ superuser: boolean;
27
+ request: RequestInfo;
28
+ collections: Map<string, Collection>;
29
+ // present when called from an HTTP route: builds the JSVM RequestEvent for *Request hooks
30
+ hookEvent?: (record: HookRecord, collection: Collection) => RequestEvent;
31
+ // realtime: changes waiting to be published to the hub once their batch has committed, and the request's waitUntil
32
+ changes?: ChangeEvent[];
33
+ waitUntil?: (p: Promise<unknown>) => void;
34
+ }
35
+
36
+ export interface ListQuery { page: number; perPage: number; skipTotal: boolean; sort: string; filter: string; expand: string; fields: string }
37
+ export interface EnrichOptions { expand?: string; fields?: string }
38
+
39
+ const MAX_PER_PAGE = 1000;
40
+ const DEFAULT_PER_PAGE = 30;
41
+ const SUPERUSER_ONLY_MSG = "Only superusers can perform this action.";
42
+
43
+ // ---- reading -------------------------------------------------------------------------------------
44
+ function ruleParts(ctx: RecordContext, c: Collection, rule: string | null): { sql: string; params: unknown[]; joins: Join[] } | null {
45
+ if (ctx.superuser) return null;
46
+ if (rule === null) throw forbidden(SUPERUSER_ONLY_MSG);
47
+ if (rule.trim() === "") return null;
48
+ const compiled = compileFilter(rule, { base: c, collections: ctx.collections, request: ctx.request, allowHiddenFields: true });
49
+ return { sql: compiled.where, params: compiled.params, joins: compiled.joins };
50
+ }
51
+
52
+ function selectSQL(c: Collection, conds: { sql: string; params: unknown[] }[], joins: Join[], tail = "", count = false): { sql: string; params: unknown[] } {
53
+ const base = ident(c.name);
54
+ const distinct = joins.some((j) => j.multi);
55
+ const joinSql = joins.map(renderJoin).join(" ");
56
+ const where = conds.length ? `WHERE ${conds.map((x) => `(${x.sql})`).join(" AND ")}` : "";
57
+ const params = [...joins.flatMap((j) => j.params), ...conds.flatMap((x) => x.params)];
58
+ const select = count ? (distinct ? `COUNT(DISTINCT ${base}.id) AS n` : "COUNT(*) AS n") : `${distinct ? "DISTINCT " : ""}${base}.*`;
59
+ return { sql: `SELECT ${select} FROM ${base} ${joinSql} ${where} ${tail}`.replace(/\s+/g, " ").trim(), params };
60
+ }
61
+
62
+ function mergeJoins(...lists: Join[][]): Join[] {
63
+ const out: Join[] = [];
64
+ for (const j of lists.flat()) if (!out.some((x) => x.alias === j.alias)) out.push(j);
65
+ return out;
66
+ }
67
+
68
+ function wrapFilterError(err: unknown): never {
69
+ if (err instanceof FilterError || err instanceof FilterSyntaxError) throw badRequest();
70
+ throw err;
71
+ }
72
+
73
+ export async function listRecords(ctx: RecordContext, c: Collection, q: ListQuery) {
74
+ if (!ctx.superuser) {
75
+ for (const token of ["@collection.", "@request."]) {
76
+ if (q.filter.includes(token) || q.sort.includes(token)) throw forbidden(`Only superusers can filter by ${token}`);
77
+ }
78
+ }
79
+ const conds: { sql: string; params: unknown[] }[] = [];
80
+ let joins: Join[] = [];
81
+ const rule = ruleParts(ctx, c, c.listRule);
82
+ if (rule) { conds.push(rule); joins = mergeJoins(joins, rule.joins); }
83
+ if (q.filter.trim()) {
84
+ try {
85
+ const f = compileFilter(q.filter, { base: c, collections: ctx.collections, request: ctx.request, allowHiddenFields: ctx.superuser });
86
+ conds.push({ sql: f.where, params: f.params });
87
+ joins = mergeJoins(joins, f.joins);
88
+ } catch (err) { wrapFilterError(err); }
89
+ }
90
+ let orderBy = "";
91
+ // views have no rowid: PocketBase falls back to the id column there
92
+ try { orderBy = compileSort(q.sort, c, ctx.superuser) || (c.type === "view" ? `ORDER BY ${ident(c.name)}.id ASC` : `ORDER BY ${ident(c.name)}.rowid ASC`); } catch (err) { wrapFilterError(err); }
93
+ const page = Math.max(1, q.page || 1);
94
+ const perPage = Math.min(MAX_PER_PAGE, Math.max(1, q.perPage || DEFAULT_PER_PAGE));
95
+ const sel = selectSQL(c, conds, joins, `${orderBy} LIMIT ? OFFSET ?`);
96
+ let rows: Row[];
97
+ try { rows = await all(ctx.db, sel.sql, [...sel.params, perPage, (page - 1) * perPage]); } catch (err) { throw sqlError(err); }
98
+ let totalItems = -1, totalPages = -1;
99
+ if (!q.skipTotal) {
100
+ const cnt = selectSQL(c, conds, joins, "", true);
101
+ const r = await one<{ n: number }>(ctx.db, cnt.sql, cnt.params);
102
+ totalItems = r?.n ?? 0;
103
+ totalPages = Math.ceil(totalItems / perPage);
104
+ }
105
+ const items = await enrich(ctx, c, rows, { expand: q.expand, fields: q.fields });
106
+ return { items, page, perPage, totalItems, totalPages };
107
+ }
108
+
109
+ export async function fetchRecord(ctx: RecordContext, c: Collection, id: string, rule: string | null): Promise<Row | null> {
110
+ const conds: { sql: string; params: unknown[] }[] = [{ sql: `${ident(c.name)}.id = ?`, params: [id] }];
111
+ let joins: Join[] = [];
112
+ const r = ruleParts(ctx, c, rule);
113
+ if (r) { conds.push(r); joins = r.joins; }
114
+ const sel = selectSQL(c, conds, joins, "LIMIT 1");
115
+ try { return await one(ctx.db, sel.sql, sel.params); } catch (err) { throw sqlError(err); }
116
+ }
117
+
118
+ export async function viewRecord(ctx: RecordContext, c: Collection, id: string, opts: EnrichOptions) {
119
+ const row = await fetchRecord(ctx, c, id, c.viewRule);
120
+ if (!row) throw notFound();
121
+ return (await enrich(ctx, c, [row], opts))[0];
122
+ }
123
+
124
+ export async function enrich(ctx: RecordContext, c: Collection, rows: Row[], opts: EnrichOptions): Promise<Record<string, unknown>[]> {
125
+ const expands = (opts.expand ?? "").split(",").map((s) => s.trim()).filter(Boolean);
126
+ const expandMap = expands.length ? await expandRecords({ db: ctx.db, collections: ctx.collections, auth: ctx.auth, superuser: ctx.superuser, request: ctx.request }, c, rows, expands) : null;
127
+ let items: unknown;
128
+ if (hasHandlers("onRecordEnrich", c.name)) {
129
+ // OnRecordEnrich: hooks may hide()/unhide() fields per record before it is exported
130
+ const requestInfo = { auth: ctx.auth ? HookRecord.fromRow(ctx.auth.collection, ctx.auth.row) : null, method: ctx.request.method, query: ctx.request.query, headers: ctx.request.headers, context: ctx.request.context };
131
+ const out: Record<string, unknown>[] = [];
132
+ for (const r of rows) {
133
+ const rec = HookRecord.fromRow(c, r);
134
+ const ev = { app: undefined as unknown, record: rec, requestInfo, next: async () => undefined as unknown };
135
+ await trigger("onRecordEnrich", ev, c.name, async () => undefined);
136
+ const json = recordToJSON(c, r, { auth: ctx.auth, own: isOwn(ctx, c, r), expand: expandMap?.get(String(r.id)) });
137
+ const hidden = new Set(rec.hiddenFields());
138
+ for (const n of hidden) delete json[n];
139
+ for (const f of c.fields as Field[]) if (f.hidden && !hidden.has(f.name) && !(f.name in json) && f.type !== "password") json[f.name] = fromColumn(f, r[f.name]);
140
+ if (c.type === "auth" && !hidden.has("email") && !("email" in json)) json.email = r.email ?? "";
141
+ out.push(json);
142
+ }
143
+ items = out;
144
+ } else items = rows.map((r) => recordToJSON(c, r, { auth: ctx.auth, own: isOwn(ctx, c, r), expand: expandMap?.get(String(r.id)) }));
145
+ if (opts.fields?.trim()) {
146
+ try { items = pick(items, parseFields(opts.fields)); } catch { throw badRequest(); }
147
+ }
148
+ return items as Record<string, unknown>[];
149
+ }
150
+
151
+ const isOwn = (ctx: RecordContext, c: Collection, row: Row) => c.type === "auth" && !!ctx.auth && ctx.auth.collection.id === c.id && ctx.auth.row.id === row.id;
152
+
153
+ // ---- input ---------------------------------------------------------------------------------------
154
+ type RawBody = Record<string, unknown>; // JSON values, strings, File objects, or arrays of those
155
+
156
+ interface Prepared {
157
+ values: Record<string, unknown>; // final normalized values per field
158
+ uploads: Record<string, Map<string, Upload>>; // per file field
159
+ plain: { password?: string; passwordConfirm?: string; oldPassword?: string };
160
+ plainPasswords: Record<string, string>; // per password-type field: the submitted plain text
161
+ touched: Set<string>;
162
+ }
163
+
164
+ async function toUpload(file: File): Promise<Upload> {
165
+ const bytes = await file.arrayBuffer();
166
+ const sniffed = sniffMime(new Uint8Array(bytes), file.type, file.name);
167
+ return { name: normalizeFilename(file.name, sniffed.ext), type: sniffed.type, size: bytes.byteLength, bytes };
168
+ }
169
+
170
+ const isFile = (v: unknown): v is File => typeof File !== "undefined" && v instanceof File;
171
+ const isUpload = (v: unknown): v is Upload => !!v && typeof v === "object" && "bytes" in (v as object) && "name" in (v as object);
172
+
173
+ async function prepareInput(c: Collection, current: Record<string, unknown>, body: RawBody): Promise<Prepared> {
174
+ const fields = c.fields as Field[];
175
+ const byName = new Map(fields.map((f) => [f.name, f]));
176
+ const values: Record<string, unknown> = { ...current };
177
+ const uploads: Record<string, Map<string, Upload>> = {};
178
+ const plain: Prepared["plain"] = {};
179
+ const plainPasswords: Record<string, string> = {};
180
+ const touched = new Set<string>();
181
+ if ("passwordConfirm" in body) plain.passwordConfirm = String(body.passwordConfirm ?? "");
182
+ if ("oldPassword" in body) plain.oldPassword = String(body.oldPassword ?? "");
183
+
184
+ // PocketBase applies modifiers shortest key first
185
+ const keys = Object.keys(body).filter((k) => k !== "passwordConfirm" && k !== "oldPassword").sort((a, b) => a.length - b.length);
186
+ for (const key of keys) {
187
+ const raw = body[key];
188
+ let name = key, mod: "" | "append" | "prepend" | "remove" = "";
189
+ if (byName.has(key)) name = key;
190
+ else if (key.endsWith("+") && byName.has(key.slice(0, -1))) { name = key.slice(0, -1); mod = "append"; }
191
+ else if (key.startsWith("+") && byName.has(key.slice(1))) { name = key.slice(1); mod = "prepend"; }
192
+ else if (key.endsWith("-") && byName.has(key.slice(0, -1))) { name = key.slice(0, -1); mod = "remove"; }
193
+ else continue; // unknown keys are ignored
194
+ const f = byName.get(name)!;
195
+ if (f.type === "autodate") continue;
196
+ touched.add(name);
197
+ if (f.type === "password") {
198
+ plainPasswords[name] = raw == null ? "" : String(raw);
199
+ if (name === "password") plain.password = plainPasswords[name];
200
+ continue;
201
+ }
202
+ if (f.type === "file") {
203
+ const list = Array.isArray(raw) ? raw : raw == null || raw === "" ? [] : [raw];
204
+ const names: string[] = [];
205
+ for (const item of list) {
206
+ if (isFile(item)) { const up = await toUpload(item); (uploads[name] ??= new Map()).set(up.name, up); names.push(up.name); }
207
+ else if (isUpload(item)) { (uploads[name] ??= new Map()).set(item.name, item); names.push(item.name); }
208
+ else if (typeof item === "string" && item.trim()) { const s = item.trim(); if (s.startsWith("[")) { try { names.push(...(JSON.parse(s) as unknown[]).map(String)); continue; } catch { /* plain name */ } } names.push(s); }
209
+ }
210
+ const cur = (values[name] as string[] | undefined) ?? [];
211
+ const curList = Array.isArray(cur) ? cur : cur ? [String(cur)] : [];
212
+ let next: string[];
213
+ if (mod === "append") next = uniqueStrings([...curList, ...names]);
214
+ else if (mod === "prepend") next = uniqueStrings([...names, ...curList]);
215
+ else if (mod === "remove") next = curList.filter((n) => !names.includes(n));
216
+ else next = uniqueStrings(names);
217
+ values[name] = isMultiple(f) ? next : (next[next.length - 1] ?? "");
218
+ continue;
219
+ }
220
+ if (mod === "") { values[name] = normalizeInput(f, raw); continue; }
221
+ if (f.type === "number") {
222
+ const delta = Number(raw) || 0;
223
+ values[name] = (Number(values[name]) || 0) + (mod === "remove" ? -delta : delta);
224
+ continue;
225
+ }
226
+ if (f.type === "select" || f.type === "relation") {
227
+ const incoming = normalizeInput({ ...f, maxSelect: 99 } as Field, raw) as string[];
228
+ const curList = Array.isArray(values[name]) ? (values[name] as string[]) : values[name] ? [String(values[name])] : [];
229
+ let next: string[];
230
+ if (mod === "append") next = uniqueStrings([...curList, ...incoming]);
231
+ else if (mod === "prepend") next = uniqueStrings([...incoming, ...curList]);
232
+ else next = curList.filter((x) => !incoming.includes(x));
233
+ values[name] = isMultiple(f) ? next : (next[next.length - 1] ?? "");
234
+ continue;
235
+ }
236
+ values[name] = normalizeInput(f, raw);
237
+ }
238
+ return { values, uploads, plain, plainPasswords, touched };
239
+ }
240
+
241
+ function defaults(c: Collection): Record<string, unknown> {
242
+ const out: Record<string, unknown> = {};
243
+ for (const f of c.fields as Field[]) {
244
+ if (f.type === "bool") out[f.name] = false;
245
+ else if (f.type === "number") out[f.name] = 0;
246
+ else if (f.type === "json") out[f.name] = null;
247
+ else if (f.type === "geoPoint") out[f.name] = { lon: 0, lat: 0 };
248
+ else if (isMultiple(f)) out[f.name] = [];
249
+ else out[f.name] = "";
250
+ }
251
+ return out;
252
+ }
253
+
254
+ // ---- rules against not-yet-saved values (create rule, manage rule) ---------------------------------
255
+ export async function recordMatchesRule(ctx: RecordContext, c: Collection, rule: string, values: Record<string, unknown>): Promise<boolean> { return ruleMatchesValues(ctx, c, rule, values); }
256
+
257
+ async function ruleMatchesValues(ctx: RecordContext, c: Collection, rule: string, values: Record<string, unknown>): Promise<boolean> {
258
+ const dummy = `${c.name}__dry${randomString(6).toLowerCase()}`;
259
+ let compiled;
260
+ try {
261
+ compiled = compileFilter(rule, { base: c, baseTable: dummy, collections: ctx.collections, request: ctx.request, allowHiddenFields: true });
262
+ } catch { return false; }
263
+ const fields = c.fields as Field[];
264
+ const cols = fields.map((f) => `? AS ${ident(f.name)}`).join(", ");
265
+ const params = fields.map((f) => toColumn(f, values[f.name]));
266
+ const joins = compiled.joins.map(renderJoin).join(" ");
267
+ const sql = `WITH ${ident(dummy)} AS (SELECT ${cols}) SELECT 1 AS ok FROM ${ident(dummy)} ${joins} WHERE ${compiled.where} LIMIT 1`;
268
+ try {
269
+ const row = await one(ctx.db, sql, [...params, ...compiled.joins.flatMap((j) => j.params), ...compiled.params]);
270
+ return !!row;
271
+ } catch (err) { console.error("voidbase: rule evaluation failed", err); return false; }
272
+ }
273
+
274
+ async function hasManageAccess(ctx: RecordContext, c: Collection, values: Record<string, unknown>): Promise<boolean> {
275
+ if (ctx.superuser) return true;
276
+ if (c.type !== "auth" || !ctx.auth) return false;
277
+ const rule = c.options.manageRule as string | null | undefined;
278
+ if (rule === null || rule === undefined) return false;
279
+ if (rule.trim() === "") return true;
280
+ return ruleMatchesValues(ctx, c, rule, values);
281
+ }
282
+
283
+ // ---- auth form rules (forms/record_upsert.go validateFormFields) ----------------------------------
284
+ function authFormErrors(c: Collection, p: Prepared, original: Record<string, unknown> | null, manage: boolean): RecordErrors {
285
+ const errors: RecordErrors = {};
286
+ if (c.type !== "auth") return errors;
287
+ const isNew = original === null;
288
+ const mismatch: FieldError = { code: "validation_values_mismatch", message: "Values don't match." };
289
+ const required: FieldError = { code: "validation_required", message: "Cannot be blank." };
290
+ const password = p.plain.password ?? "";
291
+ const confirm = p.plain.passwordConfirm ?? "";
292
+ const old = p.plain.oldPassword ?? "";
293
+ if (!isNew && !manage && p.touched.has("email") && p.values.email !== original.email) errors.email = mismatch;
294
+ if (!manage && p.touched.has("verified") && !!p.values.verified !== !!(original?.verified ?? false)) errors.verified = mismatch;
295
+ if ((isNew || confirm !== "" || old !== "") && password === "") errors.password = required;
296
+ if ((isNew || password !== "" || old !== "") && confirm === "") errors.passwordConfirm = required;
297
+ else if (confirm !== "" && confirm !== password) errors.passwordConfirm = mismatch;
298
+ if (!isNew && !manage && (password !== "" || confirm !== "") && old === "") errors.oldPassword = required;
299
+ return errors;
300
+ }
301
+
302
+ // ---- change feed -----------------------------------------------------------------------------------
303
+ // With the hub the change is published after its batch commits (flushChanges); without it a `_changes` row goes into
304
+ // the batch itself, written only while a realtime client exists so idle apps and imports pay no feed rows.
305
+ function feed(ctx: RecordContext, c: Collection, action: "create" | "update" | "delete", row: Row): D1PreparedStatement[] {
306
+ if (hubActive()) { (ctx.changes ??= []).push({ collection: c.name, recordId: String(row.id), action, data: action === "delete" ? row : undefined }); return []; }
307
+ return [stmt(ctx.db, "INSERT INTO `_changes` (collection, recordId, action, data, created) SELECT ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM `_realtime_clients`)", [c.name, String(row.id), action, JSON.stringify(row), nowString()])];
308
+ }
309
+ function flushChanges(ctx: RecordContext): void {
310
+ const pending = ctx.changes; if (!pending?.length) return;
311
+ ctx.changes = [];
312
+ const p = publishChanges(pending);
313
+ if (ctx.waitUntil) ctx.waitUntil(p); else void p;
314
+ }
315
+ function valuesToRow(c: Collection, values: Record<string, unknown>): Row {
316
+ const row: Row = {};
317
+ for (const f of c.fields as Field[]) row[f.name] = toColumn(f, values[f.name]);
318
+ return row;
319
+ }
320
+
321
+ // ---- errors ---------------------------------------------------------------------------------------
322
+ const sortedErrors = (e: RecordErrors) => Object.fromEntries(Object.entries(e).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) as unknown as FieldErrors;
323
+
324
+ function sqlError(err: unknown): ApiError {
325
+ console.error("voidbase: sql error", err);
326
+ return badRequest();
327
+ }
328
+
329
+ function uniqueViolation(err: unknown): string | null {
330
+ const m = /UNIQUE constraint failed: \w+\.(\w+)/.exec(String((err as Error)?.message ?? err));
331
+ return m ? m[1]! : null;
332
+ }
333
+
334
+ // ---- create ---------------------------------------------------------------------------------------
335
+ export async function createRecord(ctx: RecordContext, c: Collection, body: RawBody, opts: EnrichOptions) {
336
+ if (c.type === "view") throw badRequest("Unsupported collection type.");
337
+ if (c.createRule === null && !ctx.superuser) throw forbidden(SUPERUSER_ONLY_MSG);
338
+ const fields = c.fields as Field[];
339
+ const p = await prepareInput(c, defaults(c), body);
340
+ const now = nowString();
341
+ for (const f of fields) {
342
+ if (f.type === "text" && f.autogeneratePattern && !p.values[f.name]) p.values[f.name] = autogenerate(String(f.autogeneratePattern));
343
+ if (f.type === "autodate" && f.onCreate) p.values[f.name] = now;
344
+ }
345
+ if (!p.values.id) p.values.id = randomId();
346
+ if (c.type === "auth" && !p.values.tokenKey) p.values.tokenKey = randomString(50);
347
+ for (const f of fields) if (f.type === "password") p.values[f.name] = p.plainPasswords[f.name] ?? "";
348
+ ctx.request.body = { ...p.values };
349
+ if (!ctx.superuser && c.createRule && c.createRule.trim() !== "" && !(await ruleMatchesValues(ctx, c, c.createRule, p.values))) {
350
+ throw badRequest("Failed to create record.");
351
+ }
352
+ const manage = await hasManageAccess(ctx, c, p.values);
353
+ const rec = new HookRecord(new CollectionRef(c), {}, { isNew: true });
354
+ rec.values = p.values;
355
+ const reqEv = ctx.hookEvent?.(rec, c);
356
+ const core = async () => {
357
+ const values = rec.values; // hooks may have changed it
358
+ const errors: RecordErrors = { ...authFormErrors(c, { ...p, values }, null, manage) };
359
+ const fieldErrors = await validateValues(c, values, { db: ctx.db, collections: ctx.collections, isNew: true, uploads: mergeUploads(p.uploads, rec.uploads), existingFiles: {} });
360
+ for (const [k, v] of Object.entries(fieldErrors)) if (!(k in errors)) errors[k] = v;
361
+ if (c.type === "auth" && values.id) {
362
+ const clash = await one(ctx.db, `SELECT 1 AS x FROM ${ident(c.name)} WHERE id = ?`, [values.id]);
363
+ if (clash) errors.id = { code: "validation_pk_invalid", message: "The record primary key is invalid or already exists." };
364
+ }
365
+ if (Object.keys(errors).length) throw badRequest("Failed to create record.", sortedErrors(errors));
366
+ await validateHooks(modelEv, c, "Failed to create record.");
367
+ const stored = { ...values };
368
+ for (const f of fields) if (f.type === "password") stored[f.name] = stored[f.name] ? await hashPassword(String(stored[f.name])) : "";
369
+ const cols = fields.map((f) => ident(f.name));
370
+ const params = fields.map((f) => toColumn(f, stored[f.name]));
371
+ try {
372
+ await ctx.db.batch([stmt(ctx.db, `INSERT INTO ${ident(c.name)} (${cols.join(", ")}) VALUES (${cols.map(() => "?").join(", ")})`, params), ...feed(ctx, c, "create", valuesToRow(c, stored))]);
373
+ flushChanges(ctx);
374
+ } catch (err) {
375
+ const col = uniqueViolation(err);
376
+ if (col) throw badRequest("Failed to create record.", { [col]: { code: "validation_not_unique", message: "Value must be unique." } } as unknown as FieldErrors);
377
+ if (String((err as Error)?.message).includes("PRIMARY KEY")) throw badRequest("Failed to create record.", { id: { code: "validation_pk_invalid", message: "The record primary key is invalid or already exists." } } as unknown as FieldErrors);
378
+ throw sqlError(err);
379
+ }
380
+ await storeUploads(ctx, c, String(values.id), mergeUploads(p.uploads, rec.uploads));
381
+ rec.markAsNotNew();
382
+ rec.setOriginal(values);
383
+ };
384
+ const modelEv = { app: undefined as unknown, record: rec, model: rec, collection: new CollectionRef(c), next: async () => undefined as unknown };
385
+ const withModelHooks = () => trigger("onModelCreate", modelEv, c.name, () => trigger("onRecordCreate", modelEv, c.name, core));
386
+ await withAfterError("Create", modelEv, c, () => (reqEv ? trigger("onRecordCreateRequest", reqEv, c.name, withModelHooks) : withModelHooks()));
387
+ await trigger("onRecordAfterCreateSuccess", modelEv, c.name, async () => undefined);
388
+ await trigger("onModelAfterCreateSuccess", modelEv, c.name, async () => undefined);
389
+ const row = await one(ctx.db, `SELECT * FROM ${ident(c.name)} WHERE id = ?`, [rec.values.id]);
390
+ return (await enrich(ctx, c, [row!], opts))[0];
391
+ }
392
+
393
+ function mergeUploads(a: Record<string, Map<string, Upload>>, b: Record<string, Upload[]>): Record<string, Map<string, Upload>> {
394
+ const out: Record<string, Map<string, Upload>> = {};
395
+ for (const [k, m] of Object.entries(a)) out[k] = new Map(m);
396
+ for (const [k, list] of Object.entries(b)) { const m = out[k] ?? (out[k] = new Map()); for (const up of list) m.set(up.name, up); }
397
+ return out;
398
+ }
399
+
400
+ async function storeUploads(ctx: RecordContext, c: Collection, id: string, uploads: Record<string, Map<string, Upload>>) {
401
+ for (const map of Object.values(uploads)) for (const up of map.values()) {
402
+ try { await putUpload(ctx.storage, c.id, id, up); } catch (err) { console.error("voidbase: file upload failed", err); }
403
+ }
404
+ }
405
+
406
+ // ---- update ---------------------------------------------------------------------------------------
407
+ export async function updateRecord(ctx: RecordContext, c: Collection, id: string, body: RawBody, opts: EnrichOptions) {
408
+ if (c.type === "view") throw badRequest("Unsupported collection type.");
409
+ if (c.updateRule === null && !ctx.superuser) throw forbidden(SUPERUSER_ONLY_MSG);
410
+ const row = await fetchRecord(ctx, c, id, c.updateRule);
411
+ if (!row) throw notFound();
412
+ const fields = c.fields as Field[];
413
+ const current = rowToValues(c, row);
414
+ const p = await prepareInput(c, current, body);
415
+ p.values.id = current.id;
416
+ const now = nowString();
417
+ for (const f of fields) if (f.type === "autodate" && f.onUpdate) p.values[f.name] = now;
418
+ ctx.request.body = { ...p.values };
419
+ const manage = await hasManageAccess(ctx, c, current);
420
+ const rec = new HookRecord(new CollectionRef(c), {}, { isNew: false });
421
+ rec.values = p.values;
422
+ rec.setOriginal(current);
423
+ const reqEv = ctx.hookEvent?.(rec, c);
424
+ const core = async () => {
425
+ const values = rec.values;
426
+ const errors: RecordErrors = { ...authFormErrors(c, { ...p, values }, current, manage) };
427
+ if (c.type === "auth" && !manage && !errors.oldPassword && (p.plain.password || p.plain.passwordConfirm) && p.plain.oldPassword) {
428
+ if (!(await verifyPassword(p.plain.oldPassword, String(row.password ?? "")))) errors.oldPassword = { code: "validation_invalid_old_password", message: "Missing or invalid old password." };
429
+ }
430
+ const forValidation = { ...values };
431
+ for (const f of fields) if (f.type === "password") forValidation[f.name] = p.plainPasswords[f.name] ?? "";
432
+ const existingFiles: Record<string, string[]> = {};
433
+ for (const f of fields) if (f.type === "file") { const v = current[f.name]; existingFiles[f.name] = Array.isArray(v) ? (v as string[]) : v ? [String(v)] : []; }
434
+ const uploads = mergeUploads(p.uploads, rec.uploads);
435
+ const fieldErrors = await validateValues(c, forValidation, { db: ctx.db, collections: ctx.collections, isNew: false, originalId: String(current.id), uploads, existingFiles });
436
+ for (const [k, v] of Object.entries(fieldErrors)) if (!(k in errors)) errors[k] = v;
437
+ if (Object.keys(errors).length) throw badRequest("Failed to update record.", sortedErrors(errors));
438
+ await validateHooks(modelEv, c, "Failed to update record.");
439
+
440
+ const stored = { ...values };
441
+ let refreshTokenKey = false;
442
+ for (const f of fields) {
443
+ if (f.type !== "password") continue;
444
+ const plain = p.plainPasswords[f.name];
445
+ if (plain) { stored[f.name] = await hashPassword(plain); if (f.name === "password") refreshTokenKey = true; }
446
+ else stored[f.name] = row[f.name] ?? "";
447
+ }
448
+ if (c.type === "auth" && p.touched.has("email") && values.email !== current.email) refreshTokenKey = true;
449
+ if (refreshTokenKey) stored.tokenKey = randomString(50);
450
+ const setCols = fields.filter((f) => f.name !== "id").map((f) => `${ident(f.name)} = ?`);
451
+ const params = fields.filter((f) => f.name !== "id").map((f) => toColumn(f, stored[f.name]));
452
+ try {
453
+ await ctx.db.batch([stmt(ctx.db, `UPDATE ${ident(c.name)} SET ${setCols.join(", ")} WHERE id = ?`, [...params, id]), ...feed(ctx, c, "update", { ...valuesToRow(c, stored), id })]);
454
+ flushChanges(ctx);
455
+ } catch (err) {
456
+ const col = uniqueViolation(err);
457
+ if (col) throw badRequest("Failed to update record.", { [col]: { code: "validation_not_unique", message: "Value must be unique." } } as unknown as FieldErrors);
458
+ throw sqlError(err);
459
+ }
460
+ await storeUploads(ctx, c, id, uploads);
461
+ for (const f of fields) {
462
+ if (f.type !== "file") continue;
463
+ const before = existingFiles[f.name] ?? [];
464
+ const afterV = stored[f.name];
465
+ const after = Array.isArray(afterV) ? (afterV as string[]) : afterV ? [String(afterV)] : [];
466
+ const removed = before.filter((n) => !after.includes(n));
467
+ if (removed.length) { try { await deleteFiles(ctx.storage, c.id, id, removed); } catch (err) { console.error("voidbase: file delete failed", err); } }
468
+ }
469
+ };
470
+ const modelEv = { app: undefined as unknown, record: rec, model: rec, collection: new CollectionRef(c), next: async () => undefined as unknown };
471
+ const withModelHooks = () => trigger("onModelUpdate", modelEv, c.name, () => trigger("onRecordUpdate", modelEv, c.name, core));
472
+ await withAfterError("Update", modelEv, c, () => (reqEv ? trigger("onRecordUpdateRequest", reqEv, c.name, withModelHooks) : withModelHooks()));
473
+ await trigger("onRecordAfterUpdateSuccess", modelEv, c.name, async () => undefined);
474
+ await trigger("onModelAfterUpdateSuccess", modelEv, c.name, async () => undefined);
475
+ const fresh = await one(ctx.db, `SELECT * FROM ${ident(c.name)} WHERE id = ?`, [id]);
476
+ return (await enrich(ctx, c, [fresh!], opts))[0];
477
+ }
478
+
479
+ // ---- delete ---------------------------------------------------------------------------------------
480
+ export async function deleteRecord(ctx: RecordContext, c: Collection, id: string): Promise<void> {
481
+ if (c.type === "view") throw badRequest("Unsupported collection type.");
482
+ if (c.deleteRule === null && !ctx.superuser) throw forbidden(SUPERUSER_ONLY_MSG);
483
+ const row = await fetchRecord(ctx, c, id, c.deleteRule);
484
+ if (!row) throw notFound();
485
+ const rec = HookRecord.fromRow(c, row);
486
+ const reqEv = ctx.hookEvent?.(rec, c);
487
+ const core = async () => {
488
+ // core/record_model.go cascadeRecordDelete: referencing records are unlinked, deleted when the relation is
489
+ // cascadeDelete and nothing else remains, or refused when a required relation would become empty.
490
+ // Everything is planned first and applied in one D1 batch so a refusal leaves nothing half-done.
491
+ const plan: DeletePlan = { statements: [], deleted: [], overlay: new Map() };
492
+ await planCascadeDelete(ctx, c, row, plan);
493
+ await ctx.db.batch(plan.statements);
494
+ flushChanges(ctx);
495
+ for (const d of plan.deleted) {
496
+ try { await deleteAllRecordFiles(ctx.storage, d.c.id, String(d.row.id)); } catch (err) { console.error("voidbase: file cleanup failed", err); }
497
+ }
498
+ for (const d of plan.deleted) {
499
+ if (d.c.id === c.id && d.row.id === row.id) continue; // the main record's hooks fire around this function
500
+ const r = HookRecord.fromRow(d.c, d.row);
501
+ const ev = { app: undefined as unknown, record: r, model: r, collection: new CollectionRef(d.c), next: async () => undefined as unknown };
502
+ await trigger("onRecordAfterDeleteSuccess", ev, d.c.name, async () => undefined);
503
+ await trigger("onModelAfterDeleteSuccess", ev, d.c.name, async () => undefined);
504
+ }
505
+ };
506
+ const modelEv = { app: undefined as unknown, record: rec, model: rec, collection: new CollectionRef(c), next: async () => undefined as unknown };
507
+ const withModelHooks = () => trigger("onModelDelete", modelEv, c.name, () => trigger("onRecordDelete", modelEv, c.name, core));
508
+ await withAfterError("Delete", modelEv, c, () => (reqEv ? trigger("onRecordDeleteRequest", reqEv, c.name, withModelHooks) : withModelHooks()));
509
+ await trigger("onRecordAfterDeleteSuccess", modelEv, c.name, async () => undefined);
510
+ await trigger("onModelAfterDeleteSuccess", modelEv, c.name, async () => undefined);
511
+ }
512
+
513
+
514
+
515
+ // ---- hook plumbing shared by create/update/delete ------------------------------------------------
516
+ type ModelEvent = { app: unknown; record: HookRecord; model: HookRecord; collection: CollectionRef; next: () => Promise<unknown> };
517
+ // OnModelValidate / OnRecordValidate run after the built-in checks; a thrown error fails the write like PocketBase
518
+ async function validateHooks(ev: ModelEvent, c: Collection, failMsg: string) {
519
+ if (!hasHandlers("onModelValidate", c.name) && !hasHandlers("onRecordValidate", c.name)) return;
520
+ try {
521
+ await trigger("onModelValidate", ev, c.name, () => trigger("onRecordValidate", ev, c.name, async () => undefined));
522
+ } catch (err) {
523
+ if (err instanceof ApiError) throw err;
524
+ throw badRequest(failMsg);
525
+ }
526
+ }
527
+ // OnRecordAfter{Create,Update,Delete}Error and the Model twins fire when the operation failed, then the error propagates
528
+ async function withAfterError(op: "Create" | "Update" | "Delete", ev: ModelEvent, c: Collection, run: () => Promise<unknown>) {
529
+ try { await run(); } catch (err) {
530
+ const errEv = { ...ev, error: err, next: async () => undefined as unknown };
531
+ try {
532
+ await trigger(`onRecordAfter${op}Error`, errEv, c.name, async () => undefined);
533
+ await trigger(`onModelAfter${op}Error`, errEv, c.name, async () => undefined);
534
+ } catch (hookErr) { console.error(`voidbase: onRecordAfter${op}Error handler failed`, hookErr); }
535
+ throw err;
536
+ }
537
+ }
538
+
539
+ // ---- cascade delete planning --------------------------------------------------------------------
540
+ interface DeletePlan { statements: D1PreparedStatement[]; deleted: { c: Collection; row: Row }[]; overlay: Map<string, Row> }
541
+ const REQUIRED_REF_MSG = "Failed to delete record. Make sure that the record is not part of a required relation reference.";
542
+
543
+ async function planCascadeDelete(ctx: RecordContext, c: Collection, row: Row, plan: DeletePlan): Promise<void> {
544
+ const key = `${c.id}:${row.id}`;
545
+ if (plan.deleted.some((d) => `${d.c.id}:${d.row.id}` === key)) return;
546
+ plan.deleted.push({ c, row });
547
+ const id = String(row.id);
548
+ const others = [...new Set(ctx.collections.values())].filter((o) => o.type !== "view").sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
549
+ for (const other of others) {
550
+ for (const f of other.fields as Field[]) {
551
+ if (f.type !== "relation" || f.collectionId !== c.id) continue;
552
+ const col = `${ident(other.name)}.${ident(f.name)}`;
553
+ const match = isMultiple(f) ? `EXISTS (SELECT 1 FROM json_each(CASE WHEN json_valid(${col}) THEN ${col} ELSE json_array(${col}) END) WHERE value = ?)` : `${col} = ?`;
554
+ const self = other.id === c.id;
555
+ const refs = await all<Row>(ctx.db, `SELECT * FROM ${ident(other.name)} WHERE ${match}${self ? ` AND ${ident(other.name)}.id != ?` : ""}`, self ? [id, id] : [id]);
556
+ for (const fresh of refs) {
557
+ const refKey = `${other.id}:${fresh.id}`;
558
+ if (plan.deleted.some((d) => `${d.c.id}:${d.row.id}` === refKey)) continue; // already going away
559
+ const r = plan.overlay.get(refKey) ?? fresh;
560
+ const raw = r[f.name];
561
+ const ids = (isMultiple(f) ? (Array.isArray(raw) ? raw : JSON.parse(String(raw || "[]"))) as unknown[] : [raw]).map(String).filter((x) => x !== "");
562
+ const remaining = ids.filter((x) => x !== id);
563
+ if (f.cascadeDelete && remaining.length === 0) { await planCascadeDelete(ctx, other, r, plan); continue; }
564
+ if (f.required && remaining.length === 0) throw badRequest(REQUIRED_REF_MSG);
565
+ const value = isMultiple(f) ? JSON.stringify(remaining) : (remaining[0] ?? "");
566
+ const now = nowString();
567
+ const updated: Row = { ...r, [f.name]: value };
568
+ const sets = [`${ident(f.name)} = ?`];
569
+ const params: unknown[] = [value];
570
+ for (const af of other.fields as Field[]) if (af.type === "autodate" && af.onUpdate) { sets.push(`${ident(af.name)} = ?`); params.push(now); updated[af.name] = now; }
571
+ plan.overlay.set(refKey, updated);
572
+ plan.statements.push(stmt(ctx.db, `UPDATE ${ident(other.name)} SET ${sets.join(", ")} WHERE id = ?`, [...params, r.id]), ...feed(ctx, other, "update", updated));
573
+ }
574
+ }
575
+ }
576
+ if (c.type === "auth") { // recordRefHooks: an auth record takes its OTPs, MFAs, external auths and auth origins with it
577
+ for (const t of ["_otps", "_mfas", "_externalAuths", "_authOrigins"]) plan.statements.push(stmt(ctx.db, `DELETE FROM ${ident(t)} WHERE collectionRef = ? AND recordRef = ?`, [c.id, id]));
578
+ }
579
+ plan.statements.push(stmt(ctx.db, `DELETE FROM ${ident(c.name)} WHERE id = ?`, [id]), ...feed(ctx, c, "delete", row));
580
+ }
581
+
582
+ // ---- programmatic saves from hooks ($app.save / RecordUpsertForm.submit) -------------------------
583
+ export async function saveHookRecord(ctx: RecordContext, rec: HookRecord): Promise<HookRecord> {
584
+ const c = rec.collection().data;
585
+ const body: Record<string, unknown> = {};
586
+ for (const f of c.fields as Field[]) {
587
+ if (f.type === "autodate") continue;
588
+ if (f.type === "password") { const v = rec.values[f.name]; if (v && !String(v).startsWith("$2")) body[f.name] = v; continue; }
589
+ if (f.type === "file") { body[f.name] = [...((rec.values[f.name] as string[] | string | undefined) ? ([] as string[]).concat(rec.values[f.name] as string[]) : []).filter((n) => !(rec.uploads[f.name] ?? []).some((u) => u.name === n)), ...(rec.uploads[f.name] ?? [])]; continue; }
590
+ body[f.name] = rec.values[f.name];
591
+ }
592
+ const sctx: RecordContext = { ...ctx, superuser: true, hookEvent: undefined };
593
+ const out = rec.isNew() ? await createRecord(sctx, c, body, {}) : await updateRecord(sctx, c, rec.id, body, {});
594
+ const row = await one(ctx.db, `SELECT * FROM ${ident(c.name)} WHERE id = ?`, [String(out.id)]);
595
+ const saved = HookRecord.fromRow(c, row!);
596
+ rec.values = saved.values; rec.setOriginal(saved.values); rec.markAsNotNew();
597
+ return rec;
598
+ }