@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,392 @@
1
+ // Collection lifecycle: normalize -> validate -> plan SQL -> one D1 batch. Schema is data, as in PocketBase.
2
+ import { trigger } from "../hooks/runtime";
3
+ import { CollectionRef } from "../hooks/record";
4
+ import { crc32 } from "../crc32";
5
+ import { all, ident, one, stmt } from "../db";
6
+ import { badRequest, notFound, type FieldErrors } from "../errors";
7
+ import { nowString, randomString } from "../ids";
8
+ import { createIndexesSQL, createTableSQL, createViewSQL, dropIndexesSQL, dropTableSQL, dropViewSQL, parseIndex, buildIndex, syncTableSQL, truncateSQL } from "./ddl";
9
+ import { defaultFieldId, normalizeField, sortKeys, type Field } from "./fields";
10
+ import { collectionToJSON, invalidateCollections, jsonToCollection, listCollections, loadCollections, type Collection } from "./model";
11
+ import { validateCollection, type ValidateContext } from "./validate";
12
+ import scaffolds from "./scaffolds.json";
13
+
14
+ const SECRET_OPTIONS = ["authToken", "passwordResetToken", "emailChangeToken", "verificationToken", "fileToken"];
15
+ const COMMON = ["id", "system", "type", "name", "fields", "indexes", "listRule", "viewRule", "createRule", "updateRule", "deleteRule", "created", "updated"];
16
+
17
+ export const collectionId = (type: string, name: string) => "pbc_" + crc32(type + name);
18
+
19
+ // PocketBase stores index expressions as written and only rebuilds them when the table name changes.
20
+ function retargetIndex(raw: string, table: string): string {
21
+ const idx = parseIndex(raw);
22
+ if (!idx || idx.table.toLowerCase() === table.toLowerCase()) return raw;
23
+ return buildIndex(idx, table);
24
+ }
25
+
26
+ // Build the target collection from request JSON, optionally overlaying an existing collection (PATCH semantics).
27
+ export function prepareCollection(raw: Record<string, unknown>, old: Collection | null): Collection {
28
+ const base = old ? collectionToJSON(old) : {};
29
+ const merged: Record<string, unknown> = { ...base };
30
+ for (const [k, v] of Object.entries(raw)) {
31
+ if (k === "created" || k === "updated") continue;
32
+ const cur = merged[k];
33
+ merged[k] = !COMMON.includes(k) && cur && typeof cur === "object" && !Array.isArray(cur) && v && typeof v === "object" && !Array.isArray(v)
34
+ ? { ...(cur as object), ...(v as object) }
35
+ : v;
36
+ }
37
+ if (old) {
38
+ // keep stored secrets (never present in API JSON) unless explicitly replaced
39
+ for (const key of SECRET_OPTIONS) {
40
+ const stored = old.options[key] as Record<string, unknown> | undefined;
41
+ const incoming = merged[key] as Record<string, unknown> | undefined;
42
+ if (stored?.secret && incoming && !incoming.secret) merged[key] = { ...incoming, secret: stored.secret };
43
+ }
44
+ merged.id = old.id;
45
+ merged.system = old.system;
46
+ }
47
+ const c = jsonToCollection(merged);
48
+ if (!c.type) c.type = "base";
49
+ if (!c.id) c.id = collectionId(c.type, c.name);
50
+ if (c.type === "auth") {
51
+ // missing option keys take PocketBase's struct defaults (as in the auth scaffold)
52
+ c.options = deepDefaults(c.options, authOptionDefaults());
53
+ for (const key of SECRET_OPTIONS) {
54
+ const cfg = (c.options[key] as Record<string, unknown> | undefined) ?? {};
55
+ if (!cfg.secret) c.options[key] = { ...cfg, secret: randomString(50) };
56
+ }
57
+ }
58
+ // fields: normalize, reuse ids by name (PocketBase FieldsList.Add semantics), generate missing ids
59
+ const oldFields = (old?.fields ?? []) as Field[];
60
+ const taken = new Set<string>();
61
+ const rawFields = Array.isArray(merged.fields) ? (merged.fields as Record<string, unknown>[]) : [];
62
+ c.fields = rawFields.map((rf) => {
63
+ const f = normalizeField(rf ?? {});
64
+ if (!f.id) {
65
+ const byName = oldFields.find((x) => x.name === f.name);
66
+ f.id = byName ? byName.id : defaultFieldId(f.type, f.name, taken);
67
+ }
68
+ taken.add(f.id);
69
+ return sortKeys(f);
70
+ });
71
+ ensureDefaultFields(c, taken);
72
+ c.indexes = Array.isArray(merged.indexes) ? (merged.indexes as unknown[]).map((i) => retargetIndex(String(i), c.name)) : [];
73
+ const now = nowString();
74
+ c.created = old?.created || now;
75
+ c.updated = now;
76
+ return c;
77
+ }
78
+
79
+ async function validateContext(db: D1Database, extra: Collection[] = []): Promise<ValidateContext> {
80
+ const known = await listCollections(db);
81
+ // stored collections being replaced by an update are represented by their new version only
82
+ const replaced = new Set(extra.filter((e) => known.some((k) => k.id === e.id)).map((e) => e.id));
83
+ const rows = await all<{ name: string; tbl_name: string }>(db, "SELECT name, tbl_name FROM sqlite_master WHERE type = 'index' AND sql IS NOT NULL");
84
+ return { all: [...known.filter((k) => !replaced.has(k.id)), ...extra], usedIndexNames: new Map(rows.map((r) => [r.name.toLowerCase(), r.tbl_name])) };
85
+ }
86
+
87
+ function authOptionDefaults(): Record<string, unknown> {
88
+ const out: Record<string, unknown> = {};
89
+ for (const [k, v] of Object.entries((scaffolds as Record<string, Record<string, unknown>>).auth ?? {})) if (!COMMON.includes(k)) out[k] = v;
90
+ return out;
91
+ }
92
+
93
+ function deepDefaults(value: Record<string, unknown>, defaults: Record<string, unknown>): Record<string, unknown> {
94
+ const out: Record<string, unknown> = { ...value };
95
+ for (const [k, d] of Object.entries(defaults)) {
96
+ const v = out[k];
97
+ if (v === undefined) out[k] = structuredClone(d);
98
+ else if (v && d && typeof v === "object" && typeof d === "object" && !Array.isArray(v) && !Array.isArray(d)) {
99
+ out[k] = deepDefaults(v as Record<string, unknown>, d as Record<string, unknown>);
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+
105
+ // PocketBase's default system fields for base/auth collections, inserted when a payload omits them.
106
+ const ID_FIELD = { name: "id", type: "text", system: true, required: true, primaryKey: true, autogeneratePattern: "[a-z0-9]{15}", min: 15, max: 15, pattern: "^[a-z0-9]+$", hidden: false, presentable: false, help: "" };
107
+ const AUTH_FIELDS: Record<string, unknown>[] = [
108
+ { name: "password", type: "password", system: true, hidden: true, required: true, cost: 0, min: 8, max: 0, pattern: "", presentable: false, help: "" },
109
+ { name: "tokenKey", type: "text", system: true, hidden: true, required: true, min: 30, max: 60, pattern: "", autogeneratePattern: "[a-zA-Z0-9]{50}", primaryKey: false, presentable: false, help: "" },
110
+ { name: "email", type: "email", system: true, required: true, exceptDomains: null, onlyDomains: null, hidden: false, presentable: false, help: "" },
111
+ { name: "emailVisibility", type: "bool", system: true, hidden: false, presentable: false, required: false, help: "" },
112
+ { name: "verified", type: "bool", system: true, hidden: false, presentable: false, required: false, help: "" },
113
+ ];
114
+ function ensureDefaultFields(c: Collection, taken: Set<string>) {
115
+ if (c.type === "view") return;
116
+ const fields = c.fields as Field[];
117
+ const add = (raw: Record<string, unknown>, at: number) => {
118
+ const f = normalizeField(raw);
119
+ f.id = defaultFieldId(f.type, f.name, taken);
120
+ taken.add(f.id);
121
+ fields.splice(at, 0, sortKeys(f));
122
+ };
123
+ if (!fields.some((f) => f.name === "id")) add(ID_FIELD, 0);
124
+ if (c.type === "auth") {
125
+ let pos = fields.findIndex((f) => f.name === "id") + 1;
126
+ for (const def of AUTH_FIELDS) {
127
+ const i = fields.findIndex((f) => f.name === def.name);
128
+ if (i >= 0) { pos = i + 1; continue; }
129
+ add(def, pos);
130
+ pos++;
131
+ }
132
+ }
133
+ }
134
+
135
+ function throwIfErrors(errs: Record<string, unknown>, message: string) {
136
+ if (Object.keys(errs).length) throw badRequest(message, errs as FieldErrors);
137
+ }
138
+
139
+ function rowStatements(db: D1Database, c: Collection, mode: "insert" | "update") {
140
+ const params = [c.system, c.type, c.name, JSON.stringify(c.fields), JSON.stringify(c.indexes), c.listRule, c.viewRule, c.createRule, c.updateRule, c.deleteRule, JSON.stringify(c.options), c.created, c.updated];
141
+ return mode === "insert"
142
+ ? stmt(db, "INSERT INTO `_collections` (id, system, type, name, fields, indexes, listRule, viewRule, createRule, updateRule, deleteRule, options, created, updated) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", [c.id, ...params])
143
+ : stmt(db, "UPDATE `_collections` SET system=?, type=?, name=?, fields=?, indexes=?, listRule=?, viewRule=?, createRule=?, updateRule=?, deleteRule=?, options=?, created=?, updated=? WHERE id = ?", [...params, c.id]);
144
+ }
145
+
146
+ // Model-level collection events (core/collection_model.go): onCollection{Create,Update,Delete} wrap the
147
+ // persist step (validation runs inside onCollectionValidate within it), then AfterXSuccess or AfterXError.
148
+ async function withCollectionHooks(op: "Create" | "Update" | "Delete", c: Collection, isNew: boolean, validate: () => Promise<void>, persist: () => Promise<void>): Promise<void> {
149
+ const ev = { app: undefined as unknown, collection: new CollectionRef(c), isNew, next: async () => undefined as unknown };
150
+ try {
151
+ await trigger(`onCollection${op}`, ev, c.name, async () => {
152
+ await trigger("onCollectionValidate", ev, c.name, validate);
153
+ await persist();
154
+ });
155
+ } catch (err) {
156
+ try { await trigger(`onCollectionAfter${op}Error`, { ...ev, error: err }, c.name, async () => undefined); } catch (hookErr) { console.error(`voidbase: onCollectionAfter${op}Error handler failed`, hookErr); }
157
+ throw err;
158
+ }
159
+ await trigger(`onCollectionAfter${op}Success`, ev, c.name, async () => undefined);
160
+ }
161
+
162
+ export async function createCollection(db: D1Database, raw: Record<string, unknown>): Promise<Collection> {
163
+ const c = prepareCollection(raw, null);
164
+ await withCollectionHooks("Create", c, true, async () => {
165
+ const ctx = await validateContext(db, []);
166
+ ctx.all.push(c);
167
+ if (c.type === "view") await preloadViewFields(db, c);
168
+ throwIfErrors(validateCollection(c, null, ctx), "Failed to create collection.");
169
+ if (c.type === "view") await viewDryRun(db, c, "Failed to create collection.", true);
170
+ }, async () => {
171
+ const statements = [rowStatements(db, c, "insert"), ...planCreate(c).map((sql) => db.prepare(sql))];
172
+ await db.batch(statements);
173
+ invalidateCollections();
174
+ if (c.type === "view") await deriveViewFields(db, c);
175
+ });
176
+ return c;
177
+ }
178
+
179
+ export async function updateCollection(db: D1Database, old: Collection, raw: Record<string, unknown>): Promise<Collection> {
180
+ const c = prepareCollection(raw, old);
181
+ await withCollectionHooks("Update", c, false, async () => {
182
+ const ctx = await validateContext(db, [c]);
183
+ if (c.type === "view") await preloadViewFields(db, c);
184
+ throwIfErrors(validateCollection(c, old, ctx), "Failed to update collection.");
185
+ if (c.type === "view") await viewDryRun(db, c, "Failed to update collection.", false);
186
+ }, async () => {
187
+ const sql = c.type === "view" ? [dropViewSQL(old.name), createViewSQL(c.name, String(c.options.viewQuery ?? ""))] : syncTableSQL(old, c);
188
+ await db.batch([rowStatements(db, c, "update"), ...sql.map((s) => db.prepare(s))]);
189
+ invalidateCollections();
190
+ if (c.type === "view") await deriveViewFields(db, c);
191
+ });
192
+ return c;
193
+ }
194
+
195
+ export async function deleteCollection(db: D1Database, c: Collection): Promise<void> {
196
+ await withCollectionHooks("Delete", c, false, async () => {
197
+ if (c.system) throw badRequest("Failed to delete collection.");
198
+ const refs = (await listCollections(db)).filter((x) => x.id !== c.id && (x.fields as Field[]).some((f) => f.type === "relation" && f.collectionId === c.id));
199
+ if (refs.length) throw badRequest(`Failed to delete collection probably due to existing reference in ${refs.map((r) => r.name).sort().join(", ")}.`);
200
+ }, async () => {
201
+ const sql = c.type === "view" ? [dropViewSQL(c.name)] : [...dropIndexesSQL(c), dropTableSQL(c.name)];
202
+ await db.batch([...sql.map((s) => db.prepare(s)), stmt(db, "DELETE FROM `_collections` WHERE id = ?", [c.id])]);
203
+ invalidateCollections();
204
+ });
205
+ }
206
+
207
+ export async function truncateCollection(db: D1Database, c: Collection): Promise<void> {
208
+ if (c.type === "view") throw badRequest("View collections cannot be truncated since they don't store their own records.");
209
+ await db.prepare(truncateSQL(c.name)).run();
210
+ }
211
+
212
+ // PUT /api/collections/import
213
+ export async function importCollections(db: D1Database, items: Record<string, unknown>[], deleteMissing: boolean): Promise<void> {
214
+ const existing = await listCollections(db);
215
+ const byId = new Map(existing.map((c) => [c.id, c]));
216
+ const byName = new Map(existing.map((c) => [c.name.toLowerCase(), c]));
217
+ const plan: Array<{ c: Collection; old: Collection | null }> = [];
218
+ for (const raw of items) {
219
+ const old: Collection | null = (raw.id ? byId.get(String(raw.id)) : undefined) ?? (raw.name ? byName.get(String(raw.name).toLowerCase()) : undefined) ?? null;
220
+ plan.push({ c: prepareCollection(raw, old), old });
221
+ }
222
+ const keep = new Set(plan.map((p) => p.c.id));
223
+ const toDelete = deleteMissing ? existing.filter((c) => !c.system && !keep.has(c.id)) : [];
224
+ const ctx = await validateContext(db, plan.map((p) => p.c));
225
+ ctx.all = ctx.all.filter((c) => !toDelete.some((d) => d.id === c.id));
226
+ const errors: Record<string, unknown> = {};
227
+ plan.forEach(({ c, old }, i) => {
228
+ const errs = validateCollection(c, old, ctx);
229
+ if (Object.keys(errs).length) errors[String(i)] = errs;
230
+ });
231
+ if (Object.keys(errors).length) throw badRequest("Failed to import collections.", { collections: errors } as unknown as FieldErrors);
232
+
233
+ const statements: D1PreparedStatement[] = [];
234
+ for (const d of toDelete) {
235
+ statements.push(...(d.type === "view" ? [dropViewSQL(d.name)] : [...dropIndexesSQL(d), dropTableSQL(d.name)]).map((s) => db.prepare(s)));
236
+ statements.push(stmt(db, "DELETE FROM `_collections` WHERE id = ?", [d.id]));
237
+ }
238
+ // tables first, views last (views may reference the tables)
239
+ const ordered = [...plan.filter((p) => p.c.type !== "view"), ...plan.filter((p) => p.c.type === "view")];
240
+ for (const { c, old } of ordered) {
241
+ statements.push(rowStatements(db, c, old ? "update" : "insert"));
242
+ const sql = old
243
+ ? c.type === "view" ? [dropViewSQL(old.name), createViewSQL(c.name, String(c.options.viewQuery ?? ""))] : syncTableSQL(old, c)
244
+ : planCreate(c);
245
+ statements.push(...sql.map((s) => db.prepare(s)));
246
+ }
247
+ await db.batch(statements);
248
+ invalidateCollections();
249
+ for (const { c } of ordered) if (c.type === "view") await deriveViewFields(db, c);
250
+ }
251
+
252
+ export function planCreate(c: Collection): string[] {
253
+ if (c.type === "view") return [createViewSQL(c.name, String(c.options.viewQuery ?? ""))];
254
+ return [createTableSQL(c), ...createIndexesSQL(c)];
255
+ }
256
+
257
+ // View collections: fields come from the view's columns (text by default; PocketBase infers more, milestone five).
258
+ // core/view.go CreateViewFields: run the query as a temporary view to learn its columns, then infer each field
259
+ // from the SELECT list (clones of the source collection fields, a relation for a source id, number for
260
+ // count()/total(), CAST types, json for everything else). Throws Error with PocketBase's raw message on failure.
261
+ export async function inferViewFields(db: D1Database, query: string, collections: Map<string, Collection>): Promise<Field[]> {
262
+ const parsed = parseViewQuery(query);
263
+ for (const col of parsed.columns) if (col.alias === "*" || col.original === "*") throw new Error("wildcard columns (*) are not supported - manually type the collection field names you want the view query to have");
264
+ const tmp = `__vb_view_${randomString(8).toLowerCase()}`;
265
+ let info: { name: string; type: string }[];
266
+ try {
267
+ await db.batch([db.prepare(`CREATE VIEW ${ident(tmp)} AS ${query}`)]);
268
+ info = await all<{ name: string; type: string }>(db, `PRAGMA table_info(${ident(tmp)})`);
269
+ } catch (err) {
270
+ throw new Error(String((err as Error)?.message ?? err).replace(/^D1_ERROR: /, "").replace(/: SQLITE_ERROR$/, ""));
271
+ } finally { try { await db.batch([db.prepare(`DROP VIEW IF EXISTS ${ident(tmp)}`)]); } catch { /* ignore */ } }
272
+ const byAlias = new Map(parsed.tables.map((t) => [t.alias, t.original]));
273
+ const main = parsed.tables[0];
274
+ const suggested = new Map<string, Record<string, unknown>>();
275
+ for (const col of parsed.columns) {
276
+ const lower = col.original.toLowerCase();
277
+ if (col.alias === "id") { suggested.set("id", viewIdField()); continue; }
278
+ if (lower.startsWith("count(")) { suggested.set(col.alias, { name: col.alias, type: "number", onlyInt: true }); continue; }
279
+ if (lower.startsWith("total(")) { suggested.set(col.alias, { name: col.alias, type: "number" }); continue; }
280
+ const cast = /^cast\s*\(.*\s+as\s+(\w+)\s*\)$/i.exec(col.original);
281
+ if (cast) {
282
+ const t = cast[1]!.toLowerCase();
283
+ if (["real", "decimal", "numeric"].includes(t)) { suggested.set(col.alias, { name: col.alias, type: "number" }); continue; }
284
+ if (["int", "integer"].includes(t)) { suggested.set(col.alias, { name: col.alias, type: "number", onlyInt: true }); continue; }
285
+ if (t === "text") { suggested.set(col.alias, { name: col.alias, type: "text" }); continue; }
286
+ if (["boolean", "bool"].includes(t)) { suggested.set(col.alias, { name: col.alias, type: "bool" }); continue; }
287
+ }
288
+ const parts = col.original.split(".");
289
+ const [tableRef, fieldName] = parts.length === 2 ? [parts[0]!, parts[1]!] : [main?.alias ?? "", parts[0]!];
290
+ const source = collections.get(byAlias.get(tableRef) ?? tableRef);
291
+ if (!source) { suggested.set(col.alias, { name: col.alias, type: "json", maxSize: 1 }); continue; }
292
+ const field = (source.fields as Field[]).find((f) => f.name.toLowerCase() === fieldName.toLowerCase());
293
+ if (!field) { suggested.set(col.alias, { name: col.alias, type: "json", maxSize: 1 }); continue; }
294
+ if (fieldName.toLowerCase() === "id") { suggested.set(col.alias, { name: col.alias, type: "relation", maxSelect: 1, collectionId: source.id }); continue; }
295
+ suggested.set(col.alias, { ...(field as unknown as Record<string, unknown>), name: col.alias, id: "_clone_" + randomString(4) });
296
+ }
297
+ let hasId = false;
298
+ const taken = new Set<string>();
299
+ const fields = info.map((row) => {
300
+ if (row.name === "id") hasId = true;
301
+ const raw = suggested.get(row.name) ?? (row.name === "id" ? viewIdField() : { name: row.name, type: "json", maxSize: 1 });
302
+ const f = normalizeField(raw);
303
+ if (!f.id) f.id = defaultFieldId(f.type, f.name, taken);
304
+ taken.add(f.id);
305
+ return sortKeys(f);
306
+ });
307
+ if (!hasId) throw new Error("missing required id column (you can use `(ROW_NUMBER() OVER()) as id` if you don't have one)");
308
+ return fields as Field[];
309
+ }
310
+ // validation_invalid_view_query, the way collection_validate.go reports a broken view query
311
+ // Best-effort inference before validation: API rules are checked against the fields the query produces.
312
+ // A broken query is reported by viewDryRun right after, with PocketBase's viewQuery/fields errors.
313
+ async function preloadViewFields(db: D1Database, c: Collection): Promise<void> {
314
+ try { c.fields = await inferViewFields(db, String(c.options.viewQuery ?? ""), await loadCollections(db)); } catch { /* reported by viewDryRun */ }
315
+ }
316
+
317
+ async function viewDryRun(db: D1Database, c: Collection, failMsg: string, isNew: boolean): Promise<void> {
318
+ try { await inferViewFields(db, String(c.options.viewQuery ?? ""), await loadCollections(db)); }
319
+ catch (err) {
320
+ const raw = err instanceof Error ? err.message : String(err);
321
+ const sentenized = /[.!?]$/.test(raw) ? raw : raw + ".";
322
+ // the fields are re-derived from the query during validation, so a broken query also leaves them blank
323
+ const fieldsErr = raw.startsWith("missing required id column") ? { code: "validation_missing_primary_key", message: 'Missing or invalid "id" PK field.' } : { code: "validation_required", message: "Cannot be blank." };
324
+ const errs: Record<string, unknown> = { fields: fieldsErr, viewQuery: { code: "validation_invalid_view_query", message: "Invalid query - " + sentenized } };
325
+ void isNew;
326
+ throw badRequest(failMsg, errs as never);
327
+ }
328
+ }
329
+ const viewIdField = () => ({ name: "id", type: "text", system: true, required: true, primaryKey: true, pattern: "^[a-z0-9]+$" });
330
+
331
+ // core/view.go identifiersParser: select list, from table and join tables with their aliases
332
+ function parseViewQuery(sql: string): { columns: { original: string; alias: string }[]; tables: { original: string; alias: string }[] } {
333
+ let str = sql.trim().replace(/;+$/, "");
334
+ str = str.replace(/--[^\n]*/g, " ").replace(/\/\*[\s\S]*?\*\//g, " ");
335
+ str = str.replace(/\s+(full\s+outer\s+join|left\s+outer\s+join|right\s+outer\s+join|full\s+join|cross\s+join|inner\s+join|outer\s+join|left\s+join|right\s+join|join)\s+/gi, " __pb_join__ ");
336
+ str = str.replace(/\s+(where|group\s+by|having|order\s+by|limit|offset|window|union|except|intersect)\s+/gi, " __pb_discard__ ");
337
+ const tokens = tokenize(str);
338
+ let part = ""; let skip = false;
339
+ const parts: Record<string, string[]> = { select: [], from: [], join: [] };
340
+ for (const tok of tokens) {
341
+ const low = tok.toLowerCase();
342
+ if (low === "select") { part = "select"; skip = false; continue; }
343
+ if (low === "distinct") continue;
344
+ if (low === "from") { part = "from"; skip = false; continue; }
345
+ if (low === "__pb_join__") { if (part === "join") parts.join!.push(","); part = "join"; skip = false; continue; }
346
+ if (low === "__pb_discard__") { skip = true; continue; }
347
+ if (part === "join" && low === "on") { skip = true; continue; }
348
+ if (!skip && part) parts[part]!.push(tok);
349
+ }
350
+ return { columns: extractIdentifiers(parts.select!), tables: [...extractIdentifiers(parts.from!), ...extractIdentifiers(parts.join!)] };
351
+ }
352
+ // splits on whitespace and commas at nesting level zero, keeping the commas as separators
353
+ function tokenize(str: string): string[] {
354
+ const out: string[] = []; let cur = ""; let depth = 0; let quote = "";
355
+ for (const ch of str) {
356
+ if (quote) { cur += ch; if (ch === quote) quote = ""; continue; }
357
+ if (ch === "'" || ch === '"' || ch === "`" || ch === "[") { quote = ch === "[" ? "]" : ch; cur += ch; continue; }
358
+ if (ch === "(") depth++; if (ch === ")") depth--;
359
+ if (depth === 0 && (ch === "," || /\s/.test(ch))) { if (cur) out.push(cur); cur = ""; if (ch === ",") out.push(","); continue; }
360
+ cur += ch;
361
+ }
362
+ if (cur) out.push(cur);
363
+ return out;
364
+ }
365
+ const trimIdent = (s: string) => s.replace(/^[`"\[]|[`"\]]$/g, "").split(".").map((p) => p.replace(/^[`"\[]|[`"\]]$/g, "")).join(".");
366
+ function extractIdentifiers(tokens: string[]): { original: string; alias: string }[] {
367
+ const groups: string[][] = [[]];
368
+ for (const t of tokens) { if (t === ",") groups.push([]); else groups[groups.length - 1]!.push(t); }
369
+ const out: { original: string; alias: string }[] = [];
370
+ for (const g of groups.filter((x) => x.length)) {
371
+ let original: string, alias: string;
372
+ if (g.length >= 3 && g[g.length - 2]!.toLowerCase() === "as") { original = g.slice(0, -2).join(" "); alias = g[g.length - 1]!; }
373
+ else if (g.length >= 2 && /^[`"\[]?[A-Za-z_]\w*[`"\]]?$/.test(g[g.length - 1]!) && !/[()]/.test(g[g.length - 1]!)) { original = g.slice(0, -1).join(" "); alias = g[g.length - 1]!; }
374
+ else { original = g.join(" "); const parts = trimIdent(original).split("."); alias = parts[parts.length - 1]!; }
375
+ if (/\s/.test(original.trim()) && !/^\(.*\)$/.test(original.trim()) && !/^\w+\s*\(.*\)$/i.test(original.trim())) throw new Error(`invalid identifier parts [${g.join(" ")}].`);
376
+ out.push({ original: trimIdent(original), alias: trimIdent(alias) });
377
+ }
378
+ return out;
379
+ }
380
+
381
+ async function deriveViewFields(db: D1Database, c: Collection): Promise<void> {
382
+ const collections = await loadCollections(db);
383
+ c.fields = await inferViewFields(db, String(c.options.viewQuery ?? ""), collections);
384
+ await stmt(db, "UPDATE `_collections` SET fields = ? WHERE id = ?", [JSON.stringify(c.fields), c.id]).run();
385
+ invalidateCollections();
386
+ }
387
+
388
+ export async function mustCollectionRow(db: D1Database, idOrName: string) {
389
+ const row = await one(db, "SELECT id FROM `_collections` WHERE id = ? OR name = ? LIMIT 1", [idOrName, idOrName]);
390
+ if (!row) throw notFound();
391
+ return row;
392
+ }