@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.
- package/.env.example +9 -0
- package/CHANGELOG.md +19 -0
- package/COMPAT.md +43 -0
- package/LICENSE +21 -0
- package/NOTICE +8 -0
- package/README.md +124 -0
- package/bin/voidbase.ts +158 -0
- package/crons/every-minute.ts +13 -0
- package/db/migrations/20260905175935_large_swarm.sql +87 -0
- package/db/migrations/20260905185720_wild_sunspot.sql +16 -0
- package/db/migrations/20260905190723_solid_toro.sql +1 -0
- package/db/migrations/20260905213340_remarkable_union_jack.sql +11 -0
- package/db/migrations/meta/20260905175935_snapshot.json +599 -0
- package/db/migrations/meta/20260905185720_snapshot.json +703 -0
- package/db/migrations/meta/20260905190723_snapshot.json +710 -0
- package/db/migrations/meta/20260905213340_snapshot.json +781 -0
- package/db/migrations/meta/_journal.json +34 -0
- package/db/schema.ts +130 -0
- package/docs/deploy.md +153 -0
- package/docs/differences.md +88 -0
- package/docs/hooks.md +84 -0
- package/docs/migrating.md +29 -0
- package/docs/perf.md +53 -0
- package/docs/platform.md +208 -0
- package/docs/releasing.md +38 -0
- package/env.ts +23 -0
- package/hooks-plugin.ts +237 -0
- package/package.json +134 -0
- package/queues/jobs.ts +13 -0
- package/routes/api/[...path].ts +19 -0
- package/scripts/bench-realtime.ts +46 -0
- package/scripts/bench.ts +39 -0
- package/scripts/ci-suites.sh +27 -0
- package/scripts/dev.sh +29 -0
- package/scripts/export.ts +70 -0
- package/scripts/seed-app-user.sh +14 -0
- package/scripts/seed-d1.ts +17 -0
- package/scripts/seed-reference.sh +29 -0
- package/scripts/starter.sh +22 -0
- package/scripts/sync-app.ts +22 -0
- package/scripts/sync-panel.ts +66 -0
- package/src/cloud/rest.ts +297 -0
- package/src/node/assets.ts +22 -0
- package/src/node/bundle.ts +88 -0
- package/src/node/cloud-init.ts +51 -0
- package/src/node/d1.ts +44 -0
- package/src/node/deploy-cf.ts +179 -0
- package/src/node/index.ts +5 -0
- package/src/node/panel.ts +21 -0
- package/src/node/serve.ts +125 -0
- package/src/node/storage.ts +51 -0
- package/src/platform/node/env.ts +4 -0
- package/src/platform/node/hooks.ts +19 -0
- package/src/platform/node/log.ts +7 -0
- package/src/platform/node/migrations.ts +5 -0
- package/src/platform/node/photon.ts +1 -0
- package/src/platform/node/sockets.ts +22 -0
- package/src/platform/node/sse.ts +23 -0
- package/src/platform/workers/env.ts +3 -0
- package/src/platform/workers/hooks.ts +2 -0
- package/src/platform/workers/log.ts +1 -0
- package/src/platform/workers/migrations.ts +1 -0
- package/src/platform/workers/photon.ts +1 -0
- package/src/platform/workers/sockets.ts +3 -0
- package/src/platform/workers/sse.ts +1 -0
- package/src/server/api.ts +27 -0
- package/src/server/app.ts +582 -0
- package/src/server/auth-extra.ts +113 -0
- package/src/server/auth-flows.ts +186 -0
- package/src/server/auth-response.ts +111 -0
- package/src/server/auth.ts +187 -0
- package/src/server/backups.ts +234 -0
- package/src/server/batch.ts +123 -0
- package/src/server/bootstrap.ts +71 -0
- package/src/server/collections/auth-option-shape.json +71 -0
- package/src/server/collections/ddl.ts +127 -0
- package/src/server/collections/fields.ts +120 -0
- package/src/server/collections/model.ts +185 -0
- package/src/server/collections/oauth2-providers.json +1 -0
- package/src/server/collections/scaffolds.json +210 -0
- package/src/server/collections/service.ts +392 -0
- package/src/server/collections/system.json +605 -0
- package/src/server/collections/system.ts +19 -0
- package/src/server/collections/validate.ts +239 -0
- package/src/server/crc32.ts +13 -0
- package/src/server/crons.ts +100 -0
- package/src/server/crypto.ts +26 -0
- package/src/server/db.ts +37 -0
- package/src/server/errors.ts +53 -0
- package/src/server/files-api.ts +52 -0
- package/src/server/filter/compile.ts +420 -0
- package/src/server/filter/lexer.ts +107 -0
- package/src/server/filter/parser.ts +49 -0
- package/src/server/hardening.ts +136 -0
- package/src/server/hooks/index.ts +147 -0
- package/src/server/hooks/migrations.ts +58 -0
- package/src/server/hooks/node-async-hooks.d.ts +7 -0
- package/src/server/hooks/record.ts +152 -0
- package/src/server/hooks/runtime.ts +344 -0
- package/src/server/hooks/virtual-migrations.d.ts +4 -0
- package/src/server/hooks/virtual.d.ts +7 -0
- package/src/server/hub.ts +91 -0
- package/src/server/ids.ts +22 -0
- package/src/server/jobs.ts +84 -0
- package/src/server/jwt.ts +61 -0
- package/src/server/logs.ts +144 -0
- package/src/server/mail/index.ts +99 -0
- package/src/server/mail/message.ts +43 -0
- package/src/server/mail/smtp.ts +82 -0
- package/src/server/mail/templates.ts +168 -0
- package/src/server/oauth2/index.ts +198 -0
- package/src/server/oauth2/providers.ts +153 -0
- package/src/server/password.ts +17 -0
- package/src/server/realtime/hub-client.ts +50 -0
- package/src/server/realtime/index.ts +239 -0
- package/src/server/records/expand.ts +129 -0
- package/src/server/records/files.ts +69 -0
- package/src/server/records/json.ts +23 -0
- package/src/server/records/picker.ts +80 -0
- package/src/server/records/service.ts +598 -0
- package/src/server/records/thumbs.ts +148 -0
- package/src/server/records/values.ts +295 -0
- package/src/server/settings-api.ts +104 -0
- package/src/server/settings.ts +215 -0
- package/src/server/sql.ts +61 -0
- package/src/server/static.ts +17 -0
- package/src/server/storage/s3.ts +118 -0
- package/src/server/types.ts +25 -0
- package/src/server/webauthn.ts +168 -0
- package/tsconfig.json +36 -0
- package/tsconfig.node.json +27 -0
- package/types/pb_data.d.ts +24438 -0
- package/vite.config.ts +10 -0
- package/void.json +12 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Backups (apis/backup*.go): zip archives kept in R2 under __backups__/. A voidbase archive holds data.json
|
|
2
|
+
// (every D1 table: columns and rows) and storage/<key> for every uploaded file, so a restore can rebuild the
|
|
3
|
+
// collections, tables, rows and files. PocketBase archives (SQLite files) cannot be restored here.
|
|
4
|
+
import type { Context, Hono } from "hono";
|
|
5
|
+
import { unzipSync, zipSync } from "fflate";
|
|
6
|
+
import { findAuthRecordByToken, requireSuperuser } from "./auth";
|
|
7
|
+
import { ipInList, realIP } from "./hardening";
|
|
8
|
+
import { invalidateCollections, loadCollections } from "./collections/model";
|
|
9
|
+
import { planCreate } from "./collections/service";
|
|
10
|
+
import { createViewSQL } from "./collections/ddl";
|
|
11
|
+
import { all, ident, one, run, stmt } from "./db";
|
|
12
|
+
import { ApiError, badRequest, forbidden } from "./errors";
|
|
13
|
+
import { trigger } from "./hooks/runtime";
|
|
14
|
+
import { withHookStore } from "./hooks/migrations";
|
|
15
|
+
import { nowString } from "./ids";
|
|
16
|
+
import { dispatch, registerJobHandler } from "./jobs";
|
|
17
|
+
import { loadSettings } from "./settings";
|
|
18
|
+
import { s3Bucket, withS3Storage } from "./storage/s3";
|
|
19
|
+
import { normalizeFilename } from "./records/files";
|
|
20
|
+
import type { AppEnv } from "./types";
|
|
21
|
+
|
|
22
|
+
const PREFIX = "__backups__/";
|
|
23
|
+
const LOCK_KEY = "__activeBackup__";
|
|
24
|
+
const NAME_RE = /^[a-z0-9_-]+\.zip$/;
|
|
25
|
+
const INTERNAL_TABLES = /^(_cf_|__drizzle|_void|d1_migrations|sqlite_)/;
|
|
26
|
+
const SKIP_TABLES = new Set(["_changes", "_realtime_clients"]);
|
|
27
|
+
|
|
28
|
+
interface Dump { format: "voidbase-backup"; version: 1; created: string; tables: Record<string, { columns: string[]; rows: unknown[][] }>; files: string[] }
|
|
29
|
+
|
|
30
|
+
const snake = (s: string) => s.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase();
|
|
31
|
+
export async function generateBackupName(db: D1Database, prefix = "pb_backup_"): Promise<string> {
|
|
32
|
+
const appName = snake((await loadSettings(db)).meta.appName).slice(0, 50);
|
|
33
|
+
const d = new Date(); const p = (n: number) => String(n).padStart(2, "0");
|
|
34
|
+
return `${prefix}${appName}_${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}.zip`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function activeBackup(db: D1Database): Promise<string | null> {
|
|
38
|
+
const row = await one<{ value: string }>(db, "SELECT value FROM `_params` WHERE id = ?", [LOCK_KEY]);
|
|
39
|
+
if (!row) return null;
|
|
40
|
+
const { name, at } = JSON.parse(row.value) as { name: string; at: number };
|
|
41
|
+
if (Date.now() - at > 10 * 60_000) { await run(db, "DELETE FROM `_params` WHERE id = ?", [LOCK_KEY]); return null; }
|
|
42
|
+
return name;
|
|
43
|
+
}
|
|
44
|
+
const lock = (db: D1Database, name: string) => run(db, "INSERT OR REPLACE INTO `_params` (id, value, created, updated) VALUES (?, ?, ?, ?)", [LOCK_KEY, JSON.stringify({ name, at: Date.now() }), nowString(), nowString()]);
|
|
45
|
+
const unlock = (db: D1Database) => run(db, "DELETE FROM `_params` WHERE id = ?", [LOCK_KEY]);
|
|
46
|
+
// apis/health.go canBackup: no backup or restore currently holds the lock
|
|
47
|
+
export async function backupActive(db: D1Database): Promise<boolean> {
|
|
48
|
+
const row = await db.prepare("SELECT value FROM `_params` WHERE id = ?").bind(LOCK_KEY).first<{ value: string }>();
|
|
49
|
+
if (!row) return false;
|
|
50
|
+
try { const at = Number((JSON.parse(row.value) as { at?: number }).at ?? 0); return Date.now() - at < 30 * 60_000; } catch { return true; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// where the archives live: settings.backups.s3 when enabled, otherwise next to the files
|
|
54
|
+
// PocketBase keeps archives at the root of a dedicated S3 backups bucket, so the __backups__/ prefix used inside
|
|
55
|
+
// the shared R2 bucket is stripped on the way to S3 and re-added on the way back.
|
|
56
|
+
async function backupsStorage(env: AppEnv["Bindings"]): Promise<R2Bucket> {
|
|
57
|
+
const cfg = (await loadSettings(env.DB)).backups.s3;
|
|
58
|
+
if (!cfg.enabled) return env.STORAGE;
|
|
59
|
+
const s3 = s3Bucket(cfg);
|
|
60
|
+
const strip = (k: string) => (k.startsWith(PREFIX) ? k.slice(PREFIX.length) : k);
|
|
61
|
+
const view = {
|
|
62
|
+
put: (k: string, v: unknown, o?: unknown) => s3.put(strip(k), v as never, o as never),
|
|
63
|
+
get: async (k: string, o?: unknown) => { const r = await s3.get(strip(k), o as never); return r ? Object.assign(r, { key: PREFIX + r.key }) : null; },
|
|
64
|
+
head: async (k: string) => { const r = await s3.head(strip(k)); return r ? Object.assign(r, { key: PREFIX + r.key }) : null; },
|
|
65
|
+
delete: (k: string | string[]) => s3.delete(Array.isArray(k) ? k.map(strip) : strip(k)),
|
|
66
|
+
list: async (o: { prefix?: string; cursor?: string; limit?: number } = {}) => { const r = await s3.list({ ...o, prefix: strip(o.prefix ?? "") }); return { ...r, objects: r.objects.map((x) => Object.assign(x, { key: PREFIX + x.key })) }; },
|
|
67
|
+
};
|
|
68
|
+
return view as unknown as R2Bucket;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function listAll(storage: R2Bucket, prefix: string): Promise<R2Object[]> {
|
|
72
|
+
const out: R2Object[] = []; let cursor: string | undefined;
|
|
73
|
+
do { const l = await storage.list({ prefix, cursor }); out.push(...l.objects); cursor = l.truncated ? l.cursor : undefined; } while (cursor);
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function createBackup(env: AppEnv["Bindings"], name: string): Promise<string> {
|
|
78
|
+
const db = env.DB;
|
|
79
|
+
if (await activeBackup(db)) throw new Error("try again later - another backup/restore operation has already been started");
|
|
80
|
+
const ev = { app: undefined as unknown, name, exclude: [] as string[], next: async () => undefined as unknown };
|
|
81
|
+
await trigger("onBackupCreate", ev, null, async () => {
|
|
82
|
+
if (!ev.name) ev.name = await generateBackupName(db);
|
|
83
|
+
await lock(db, ev.name);
|
|
84
|
+
try {
|
|
85
|
+
const tables = (await all<{ name: string }>(db, "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")).map((t) => t.name).filter((t) => !INTERNAL_TABLES.test(t) && !SKIP_TABLES.has(t));
|
|
86
|
+
const dump: Dump = { format: "voidbase-backup", version: 1, created: nowString(), tables: {}, files: [] };
|
|
87
|
+
for (const t of tables) {
|
|
88
|
+
const rows = await all<Record<string, unknown>>(db, `SELECT * FROM ${ident(t)}`);
|
|
89
|
+
const columns = rows.length ? Object.keys(rows[0]!) : (await all<{ name: string }>(db, `PRAGMA table_info(${ident(t)})`)).map((c) => c.name);
|
|
90
|
+
dump.tables[t] = { columns, rows: rows.map((r) => columns.map((c) => r[c] ?? null)) };
|
|
91
|
+
}
|
|
92
|
+
const entries: Record<string, [Uint8Array, { level: 0 | 6 }]> = {};
|
|
93
|
+
for (const obj of await listAll(env.STORAGE, "")) {
|
|
94
|
+
if (obj.key.startsWith(PREFIX)) continue;
|
|
95
|
+
const body = await env.STORAGE.get(obj.key);
|
|
96
|
+
if (!body) continue;
|
|
97
|
+
dump.files.push(obj.key);
|
|
98
|
+
entries[`storage/${obj.key}`] = [new Uint8Array(await body.arrayBuffer()), { level: 0 }];
|
|
99
|
+
}
|
|
100
|
+
entries["data.json"] = [new TextEncoder().encode(JSON.stringify(dump)), { level: 6 }];
|
|
101
|
+
const zip = zipSync(entries);
|
|
102
|
+
await (await backupsStorage(env)).put(PREFIX + ev.name, zip, { httpMetadata: { contentType: "application/zip" } });
|
|
103
|
+
} finally { await unlock(db); }
|
|
104
|
+
});
|
|
105
|
+
return ev.name;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function restoreBackup(env: AppEnv["Bindings"], key: string): Promise<void> {
|
|
109
|
+
const db = env.DB;
|
|
110
|
+
const obj = await (await backupsStorage(env)).get(PREFIX + key);
|
|
111
|
+
if (!obj) throw new Error("missing or invalid backup file");
|
|
112
|
+
const files = unzipSync(new Uint8Array(await obj.arrayBuffer()));
|
|
113
|
+
const raw = files["data.json"];
|
|
114
|
+
if (!raw) throw new Error("not a voidbase backup archive (PocketBase SQLite archives cannot be restored on this server)");
|
|
115
|
+
const dump = JSON.parse(new TextDecoder().decode(raw)) as Dump;
|
|
116
|
+
if (dump.format !== "voidbase-backup") throw new Error("unsupported backup format");
|
|
117
|
+
await lock(db, key);
|
|
118
|
+
try {
|
|
119
|
+
const ev = { app: undefined as unknown, name: key, exclude: [] as string[], next: async () => undefined as unknown };
|
|
120
|
+
await trigger("onBackupRestore", ev, null, async () => {
|
|
121
|
+
// 1. drop every user collection table/view, 2. restore _collections and rebuild the tables, 3. rows, 4. files
|
|
122
|
+
const current = await loadCollections(db);
|
|
123
|
+
const drops: D1PreparedStatement[] = [];
|
|
124
|
+
for (const c of new Set(current.values())) if (!c.system) drops.push(stmt(db, c.type === "view" ? `DROP VIEW IF EXISTS ${ident(c.name)}` : `DROP TABLE IF EXISTS ${ident(c.name)}`, []));
|
|
125
|
+
if (drops.length) await db.batch(drops);
|
|
126
|
+
const insertRows = async (table: string, columns: string[], rows: unknown[][]) => {
|
|
127
|
+
for (let i = 0; i < rows.length; i += 40) {
|
|
128
|
+
const chunk = rows.slice(i, i + 40);
|
|
129
|
+
await db.batch(chunk.map((r) => stmt(db, `INSERT OR REPLACE INTO ${ident(table)} (${columns.map(ident).join(", ")}) VALUES (${columns.map(() => "?").join(", ")})`, r.map((v) => (v === undefined ? null : v)))));
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const coll = dump.tables["_collections"];
|
|
133
|
+
if (coll) { await run(db, "DELETE FROM `_collections`"); await insertRows("_collections", coll.columns, coll.rows); }
|
|
134
|
+
invalidateCollections();
|
|
135
|
+
const restored = await loadCollections(db);
|
|
136
|
+
const creates: D1PreparedStatement[] = [];
|
|
137
|
+
for (const c of new Set(restored.values())) {
|
|
138
|
+
if (c.system) continue;
|
|
139
|
+
if (c.type === "view") creates.push(db.prepare(createViewSQL(c.name, String(c.options.viewQuery ?? ""))));
|
|
140
|
+
else for (const sql of planCreate(c)) creates.push(db.prepare(sql));
|
|
141
|
+
}
|
|
142
|
+
if (creates.length) await db.batch(creates);
|
|
143
|
+
for (const [table, data] of Object.entries(dump.tables)) {
|
|
144
|
+
if (table === "_collections") continue;
|
|
145
|
+
const exists = await one(db, "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", [table]);
|
|
146
|
+
if (!exists) continue;
|
|
147
|
+
await run(db, `DELETE FROM ${ident(table)}`);
|
|
148
|
+
await insertRows(table, data.columns, data.rows);
|
|
149
|
+
}
|
|
150
|
+
for (const o of await listAll(env.STORAGE, "")) if (!o.key.startsWith(PREFIX)) await env.STORAGE.delete(o.key);
|
|
151
|
+
for (const f of dump.files) { const bytes = files[`storage/${f}`]; if (bytes) await env.STORAGE.put(f, bytes); }
|
|
152
|
+
invalidateCollections();
|
|
153
|
+
});
|
|
154
|
+
} finally { await unlock(db); }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// autobackup (core/backup.go registerAutobackupHooks): settings.backups.cron, keeping the newest cronMaxKeep
|
|
158
|
+
export async function autoBackup(env: AppEnv["Bindings"]): Promise<void> {
|
|
159
|
+
const name = await generateBackupName(env.DB, "@auto_pb_backup_");
|
|
160
|
+
await dispatch({ type: "backup", name }, { env });
|
|
161
|
+
}
|
|
162
|
+
// runs from the jobs queue on Cloudflare (inline elsewhere): create, then keep the newest cronMaxKeep
|
|
163
|
+
registerJobHandler("backup", async (env, job) => {
|
|
164
|
+
env = await withS3Storage(env);
|
|
165
|
+
const settings = await loadSettings(env.DB);
|
|
166
|
+
try { await withHookStore(env.DB, env, () => createBackup(env, job.name)); } catch (err) { console.error("voidbase: [Backup cron] Failed to create backup", job.name, err); return; }
|
|
167
|
+
const maxKeep = settings.backups.cronMaxKeep;
|
|
168
|
+
if (!maxKeep) return;
|
|
169
|
+
const bk = await backupsStorage(env);
|
|
170
|
+
const autos = (await listAll(bk, PREFIX + "@auto_pb_backup_")).sort((a, b) => b.uploaded.getTime() - a.uploaded.getTime());
|
|
171
|
+
for (const o of autos.slice(maxKeep)) await bk.delete(o.key);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
export function mountBackupsApi(app: Hono<AppEnv>) {
|
|
175
|
+
app.get("/api/backups", async (c) => {
|
|
176
|
+
requireSuperuser(c);
|
|
177
|
+
const objs = await listAll(await backupsStorage(c.env), PREFIX);
|
|
178
|
+
return c.json(objs.sort((a, b) => (a.key < b.key ? -1 : 1)).map((o) => ({ key: o.key.slice(PREFIX.length), modified: nowString(o.uploaded), size: o.size })));
|
|
179
|
+
});
|
|
180
|
+
app.post("/api/backups", async (c) => {
|
|
181
|
+
requireSuperuser(c);
|
|
182
|
+
if (await activeBackup(c.env.DB)) throw badRequest("Try again later - another backup/restore process has already been started");
|
|
183
|
+
let body: Record<string, unknown> = {};
|
|
184
|
+
try { body = (await c.req.json()) ?? {}; } catch { body = {}; }
|
|
185
|
+
const name = String(body.name ?? "");
|
|
186
|
+
if (name) {
|
|
187
|
+
if (name.length > 150) throw new ApiError(400, "An error occurred while validating the submitted data.", { name: { code: "validation_length_out_of_range", message: "The length must be between 1 and 150.", params: { max: 150, min: 1 } } } as never);
|
|
188
|
+
if (!NAME_RE.test(name)) throw new ApiError(400, "An error occurred while validating the submitted data.", { name: { code: "validation_match_invalid", message: "Must be in a valid format." } } as never);
|
|
189
|
+
if (await (await backupsStorage(c.env)).head(PREFIX + name)) throw new ApiError(400, "An error occurred while validating the submitted data.", { name: { code: "validation_backup_name_exists", message: "The backup file name is invalid or already exists." } } as never);
|
|
190
|
+
}
|
|
191
|
+
try { await createBackup(c.env, name); } catch (err) { throw badRequest("Failed to create backup."); void err; }
|
|
192
|
+
return c.body(null, 204);
|
|
193
|
+
});
|
|
194
|
+
app.post("/api/backups/upload", async (c) => {
|
|
195
|
+
requireSuperuser(c);
|
|
196
|
+
let file: File | null = null;
|
|
197
|
+
try { const fd = await c.req.formData(); const f = fd.get("file"); if (f instanceof File) file = f; } catch { /* no multipart */ }
|
|
198
|
+
if (!file) throw new ApiError(400, "An error occurred while validating the submitted data.", { file: { code: "validation_required", message: "Cannot be blank." } } as never);
|
|
199
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
200
|
+
const isZip = bytes.length > 3 && bytes[0] === 0x50 && bytes[1] === 0x4b && (bytes[2] === 0x03 || bytes[2] === 0x05 || bytes[2] === 0x07);
|
|
201
|
+
if (!isZip) throw new ApiError(400, "An error occurred while validating the submitted data.", { file: { code: "validation_invalid_mime_type", message: `"${normalizeFilename(file.name, file.name.includes(".") ? file.name.slice(file.name.lastIndexOf(".")).toLowerCase() : "")}" mime type must be one of: application/zip.` } } as never);
|
|
202
|
+
const bk = await backupsStorage(c.env);
|
|
203
|
+
if (await bk.head(PREFIX + file.name)) throw new ApiError(400, "An error occurred while validating the submitted data.", { file: { code: "validation_backup_name_exists", message: "Backup file with the specified name already exists." } } as never);
|
|
204
|
+
await bk.put(PREFIX + file.name, bytes, { httpMetadata: { contentType: "application/zip" } });
|
|
205
|
+
return c.body(null, 204);
|
|
206
|
+
});
|
|
207
|
+
app.get("/api/backups/:key", async (c) => {
|
|
208
|
+
const auth = await findAuthRecordByToken(c.env.DB, c.req.query("token") ?? "", "file");
|
|
209
|
+
if (!auth || auth.collection.name !== "_superusers") throw forbidden("Insufficient permissions to access the resource.");
|
|
210
|
+
const allowed = (await loadSettings(c.env.DB)).superuserIPs;
|
|
211
|
+
if (allowed.length && !ipInList(allowed, await realIP(c))) throw forbidden("Insufficient permissions to access the resource.");
|
|
212
|
+
const key = c.req.param("key") ?? "";
|
|
213
|
+
const obj = await (await backupsStorage(c.env)).get(PREFIX + key);
|
|
214
|
+
if (!obj) throw new ApiError(404, "The requested resource wasn't found.", {});
|
|
215
|
+
return new Response(obj.body, { headers: { "Content-Type": "application/zip", "Content-Length": String(obj.size), "Content-Disposition": `attachment; filename=${JSON.stringify(key.split("/").pop() ?? key)}`, "Content-Security-Policy": "default-src 'none'; media-src 'self'; style-src 'unsafe-inline'; sandbox" } });
|
|
216
|
+
});
|
|
217
|
+
app.delete("/api/backups/:key", async (c) => {
|
|
218
|
+
requireSuperuser(c);
|
|
219
|
+
const key = c.req.param("key") ?? "";
|
|
220
|
+
if (key && (await activeBackup(c.env.DB)) === key) throw badRequest("The backup is currently being used and cannot be deleted.");
|
|
221
|
+
const bk = await backupsStorage(c.env);
|
|
222
|
+
if (!(await bk.head(PREFIX + key))) throw badRequest("Invalid or already deleted backup file. Raw error: \nbackup does not exist");
|
|
223
|
+
await bk.delete(PREFIX + key);
|
|
224
|
+
return c.body(null, 204);
|
|
225
|
+
});
|
|
226
|
+
app.post("/api/backups/:key/restore", async (c) => {
|
|
227
|
+
requireSuperuser(c);
|
|
228
|
+
if (await activeBackup(c.env.DB)) throw badRequest("Try again later - another backup/restore process has already been started.");
|
|
229
|
+
const key = c.req.param("key") ?? "";
|
|
230
|
+
if (!(await (await backupsStorage(c.env)).head(PREFIX + key))) throw badRequest("Missing or invalid backup file.");
|
|
231
|
+
c.executionCtx.waitUntil(restoreBackup(c.env, key).catch((err) => console.error("voidbase: Failed to restore backup", key, err)));
|
|
232
|
+
return c.body(null, 204);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// POST /api/batch (apis/batch.go): a list of record create/update/upsert/delete requests executed in order
|
|
2
|
+
// through the same handlers. D1 has no interactive transactions, so atomicity is emulated: every applied
|
|
3
|
+
// request records an undo statement (delete the created row, restore the previous row) and a failure rolls
|
|
4
|
+
// them back in one D1 batch before answering with PocketBase's error shape. Files of rolled-back creates are
|
|
5
|
+
// removed; files removed by a rolled-back delete cannot be restored, and cascades are not undone.
|
|
6
|
+
import type { Context, Hono } from "hono";
|
|
7
|
+
import { loadCollections } from "./collections/model";
|
|
8
|
+
import { ident, one, stmt } from "./db";
|
|
9
|
+
import { ApiError, badRequest, forbidden } from "./errors";
|
|
10
|
+
import { deletePrefix, normalizeFilename } from "./records/files";
|
|
11
|
+
import { loadSettings } from "./settings";
|
|
12
|
+
import { requestHook } from "./hooks/runtime";
|
|
13
|
+
import type { AppEnv, Row } from "./types";
|
|
14
|
+
import type { Field } from "./collections/fields";
|
|
15
|
+
import { toColumn } from "./records/values";
|
|
16
|
+
|
|
17
|
+
interface InternalRequest { method?: string; url?: string; headers?: Record<string, string>; body?: Record<string, unknown> }
|
|
18
|
+
const ACTIONS: { re: RegExp; kind: "upsert" | "create" | "update" | "delete" }[] = [
|
|
19
|
+
{ re: /^PUT \/api\/collections\/([^/?]+)\/records(\?.*)?$/, kind: "upsert" },
|
|
20
|
+
{ re: /^POST \/api\/collections\/([^/?]+)\/records(\?.*)?$/, kind: "create" },
|
|
21
|
+
{ re: /^PATCH \/api\/collections\/([^/?]+)\/records\/([^/?]+)(\?.*)?$/, kind: "update" },
|
|
22
|
+
{ re: /^DELETE \/api\/collections\/([^/?]+)\/records\/([^/?]+)(\?.*)?$/, kind: "delete" },
|
|
23
|
+
];
|
|
24
|
+
export const BATCH_CONTEXT_HEADER = "x-voidbase-internal-context";
|
|
25
|
+
// per isolate; external requests cannot forge it. Generated lazily: Workers forbid random values at module scope.
|
|
26
|
+
let batchToken: string | null = null;
|
|
27
|
+
export const batchContextToken = () => (batchToken ??= crypto.randomUUID());
|
|
28
|
+
|
|
29
|
+
export function mountBatch(app: Hono<AppEnv>) {
|
|
30
|
+
app.post("/api/batch", async (c) => {
|
|
31
|
+
const settings = await loadSettings(c.env.DB);
|
|
32
|
+
if (!settings.batch.enabled || settings.batch.maxRequests <= 0) throw forbidden("Batch requests are not allowed.");
|
|
33
|
+
const { requests: parsed, files } = await readBatchBody(c);
|
|
34
|
+
if (!parsed) throw new ApiError(400, "Invalid batch request data.", { requests: { code: "validation_required", message: "Cannot be blank." } } as never);
|
|
35
|
+
if (parsed.length > settings.batch.maxRequests) throw new ApiError(400, "Invalid batch request data.", { requests: { code: "validation_length_too_long", message: `The length must be no more than ${settings.batch.maxRequests}.`, params: { max: settings.batch.maxRequests, min: 0 } } } as never);
|
|
36
|
+
return requestHook("onBatchRequest", c, null, { batch: parsed }, async (ev) => {
|
|
37
|
+
const requests = ev.batch as InternalRequest[];
|
|
38
|
+
const collections = await loadCollections(c.env.DB);
|
|
39
|
+
const origin = new URL(c.req.url).origin;
|
|
40
|
+
const results: { body: unknown; status: number }[] = [];
|
|
41
|
+
const undo: { statements: D1PreparedStatement[]; filePrefixes: string[] } = { statements: [], filePrefixes: [] };
|
|
42
|
+
const fail = async (index: number, response: unknown) => {
|
|
43
|
+
if (undo.statements.length) { try { await c.env.DB.batch(undo.statements.reverse()); } catch (err) { console.error("voidbase: batch rollback failed", err); } }
|
|
44
|
+
for (const p of undo.filePrefixes) { try { await deletePrefix(c.env.STORAGE, p); } catch { /* best effort */ } }
|
|
45
|
+
throw new ApiError(400, "Batch transaction failed.", { requests: { [String(index)]: { code: "batch_request_failed", message: "Batch request failed.", response } } } as never);
|
|
46
|
+
};
|
|
47
|
+
for (let i = 0; i < requests.length; i++) {
|
|
48
|
+
const ir = requests[i] ?? {};
|
|
49
|
+
let method = String(ir.method ?? "").toUpperCase(), url = String(ir.url ?? "");
|
|
50
|
+
const action = ACTIONS.map((a) => ({ a, m: a.re.exec(`${method} ${url}`) })).find((x) => x.m);
|
|
51
|
+
if (!action) await fail(i, { data: {}, message: "Something went wrong while processing your request.", status: 400 });
|
|
52
|
+
const [, collName, maybeId] = action!.m!;
|
|
53
|
+
const collection = collections.get(collName!) ?? null;
|
|
54
|
+
let kind = action!.a.kind;
|
|
55
|
+
let id = kind === "update" || kind === "delete" ? maybeId! : "";
|
|
56
|
+
if (kind === "upsert") {
|
|
57
|
+
const bodyId = String(ir.body?.id ?? "");
|
|
58
|
+
const existing = bodyId && collection ? await one<Row>(c.env.DB, `SELECT id FROM ${ident(collection.name)} WHERE id = ? LIMIT 1`, [bodyId]) : null;
|
|
59
|
+
const query = action!.m![2] ?? "";
|
|
60
|
+
if (existing) { kind = "update"; id = bodyId; method = "PATCH"; url = `/api/collections/${collName}/records/${bodyId}${query}`; }
|
|
61
|
+
else { kind = "create"; method = "POST"; url = `/api/collections/${collName}/records${query}`; }
|
|
62
|
+
}
|
|
63
|
+
// snapshot for the undo of updates and deletes
|
|
64
|
+
const before = (kind === "update" || kind === "delete") && collection ? await one<Row>(c.env.DB, `SELECT * FROM ${ident(collection.name)} WHERE id = ? LIMIT 1`, [id]) : null;
|
|
65
|
+
const headers = new Headers(c.req.raw.headers);
|
|
66
|
+
headers.delete("content-type"); headers.delete("content-length");
|
|
67
|
+
for (const [k, v] of Object.entries(ir.headers ?? {})) if (k.toLowerCase() !== "authorization") headers.set(k, v);
|
|
68
|
+
headers.set(BATCH_CONTEXT_HEADER, batchContextToken());
|
|
69
|
+
let body: BodyInit | undefined;
|
|
70
|
+
const reqFiles = files.get(i);
|
|
71
|
+
if (reqFiles && reqFiles.length) {
|
|
72
|
+
const fd = new FormData();
|
|
73
|
+
fd.append("@jsonPayload", JSON.stringify(ir.body ?? {}));
|
|
74
|
+
// PocketBase normalizes the uploaded name once here (NewFileFromMultipart) and again in the record form
|
|
75
|
+
for (const [field, file] of reqFiles) fd.append(field, file, normalizeFilename(file.name, file.name.includes(".") ? file.name.slice(file.name.lastIndexOf(".")).toLowerCase() : ""));
|
|
76
|
+
body = fd;
|
|
77
|
+
} else if (method !== "DELETE") { body = JSON.stringify(ir.body ?? {}); headers.set("content-type", "application/json"); }
|
|
78
|
+
const res = await app.fetch(new Request(origin + url, { method, headers, body }), c.env, c.executionCtx);
|
|
79
|
+
const text = await res.text();
|
|
80
|
+
let json: unknown = null; try { json = text ? JSON.parse(text) : null; } catch { json = text; }
|
|
81
|
+
if (res.status >= 400) await fail(i, json);
|
|
82
|
+
results.push({ body: json, status: res.status });
|
|
83
|
+
if (!collection) continue;
|
|
84
|
+
const table = ident(collection.name);
|
|
85
|
+
if (kind === "create") {
|
|
86
|
+
const createdId = String((json as { id?: string })?.id ?? "");
|
|
87
|
+
if (createdId) { undo.statements.push(stmt(c.env.DB, `DELETE FROM ${table} WHERE id = ?`, [createdId])); undo.filePrefixes.push(`${collection.id}/${createdId}/`); }
|
|
88
|
+
} else if (before) {
|
|
89
|
+
const fields = collection.fields as Field[];
|
|
90
|
+
const cols = fields.map((f) => ident(f.name));
|
|
91
|
+
const params = fields.map((f) => before[f.name] === undefined ? null : toColumn(f, before[f.name]));
|
|
92
|
+
undo.statements.push(kind === "delete"
|
|
93
|
+
? stmt(c.env.DB, `INSERT OR REPLACE INTO ${table} (${cols.join(", ")}) VALUES (${cols.map(() => "?").join(", ")})`, params)
|
|
94
|
+
: stmt(c.env.DB, `UPDATE ${table} SET ${fields.filter((f) => f.name !== "id").map((f) => `${ident(f.name)} = ?`).join(", ")} WHERE id = ?`, [...fields.filter((f) => f.name !== "id").map((f) => before[f.name] === undefined ? null : toColumn(f, before[f.name])), id]));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return c.json(results);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function readBatchBody(c: Context<AppEnv>): Promise<{ requests: InternalRequest[] | null; files: Map<number, [string, File][]> }> {
|
|
103
|
+
const ct = c.req.header("content-type") ?? "";
|
|
104
|
+
const files = new Map<number, [string, File][]>();
|
|
105
|
+
try {
|
|
106
|
+
if (ct.includes("multipart/form-data")) {
|
|
107
|
+
const fd = await c.req.formData();
|
|
108
|
+
let payload: { requests?: InternalRequest[] } = {};
|
|
109
|
+
const raw = fd.get("@jsonPayload");
|
|
110
|
+
if (typeof raw === "string") payload = JSON.parse(raw) as typeof payload;
|
|
111
|
+
for (const [key, value] of fd.entries()) {
|
|
112
|
+
const m = /^requests[.[](\d+)[.\]]\.?(.+)$/.exec(key.replace(/\]\./, "."));
|
|
113
|
+
if (!m || typeof value === "string") continue;
|
|
114
|
+
const idx = Number(m[1]); const field = m[2]!.replace(/^\./, "");
|
|
115
|
+
if (!files.has(idx)) files.set(idx, []);
|
|
116
|
+
files.get(idx)!.push([field, value]);
|
|
117
|
+
}
|
|
118
|
+
return { requests: Array.isArray(payload.requests) && payload.requests.length ? payload.requests : null, files };
|
|
119
|
+
}
|
|
120
|
+
const json = (await c.req.json()) as { requests?: InternalRequest[] };
|
|
121
|
+
return { requests: Array.isArray(json?.requests) && json.requests.length ? json.requests : null, files };
|
|
122
|
+
} catch { throw badRequest("Failed to read the submitted batch data."); }
|
|
123
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { env as voidEnv } from "#platform/env";
|
|
2
|
+
import { invalidateCollections, type Collection } from "./collections/model";
|
|
3
|
+
import { systemCollections } from "./collections/system";
|
|
4
|
+
import { all, ident, one, run } from "./db";
|
|
5
|
+
import { nowString, randomId, randomString } from "./ids";
|
|
6
|
+
import { hashPassword } from "./password";
|
|
7
|
+
import { ensureSettingsRow } from "./settings";
|
|
8
|
+
|
|
9
|
+
let pending: Promise<void> | null = null;
|
|
10
|
+
type AfterSystem = (db: D1Database) => Promise<unknown>;
|
|
11
|
+
|
|
12
|
+
// Runs once per isolate; idempotent across isolates (INSERT OR IGNORE on unique keys).
|
|
13
|
+
// `afterSystem` runs after the system tables exist (used for the bundled pb_migrations).
|
|
14
|
+
export function ensureBootstrapped(db: D1Database, afterSystem?: AfterSystem): Promise<void> {
|
|
15
|
+
return (pending ??= bootstrap(db, afterSystem).catch((err) => {
|
|
16
|
+
pending = null;
|
|
17
|
+
throw err;
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function bootstrap(db: D1Database, afterSystem?: AfterSystem): Promise<void> {
|
|
22
|
+
const existing = new Set((await all<{ name: string }>(db, "SELECT name FROM `_collections` WHERE system = 1")).map((r) => r.name));
|
|
23
|
+
const now = nowString();
|
|
24
|
+
for (const c of systemCollections()) {
|
|
25
|
+
if (existing.has(c.name)) continue;
|
|
26
|
+
await insertCollection(db, c, now);
|
|
27
|
+
}
|
|
28
|
+
if (existing.size === 0) invalidateCollections();
|
|
29
|
+
await ensureSettingsRow(db);
|
|
30
|
+
await upsertSuperuserFromEnv(db);
|
|
31
|
+
if (afterSystem) await afterSystem(db);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function insertCollection(db: D1Database, c: Collection, now = nowString()): Promise<void> {
|
|
35
|
+
await run(
|
|
36
|
+
db,
|
|
37
|
+
"INSERT OR IGNORE INTO `_collections` (id, system, type, name, fields, indexes, listRule, viewRule, createRule, updateRule, deleteRule, options, created, updated) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
38
|
+
[c.id, c.system, c.type, c.name, JSON.stringify(c.fields), JSON.stringify(c.indexes),
|
|
39
|
+
c.listRule, c.viewRule, c.createRule, c.updateRule, c.deleteRule, JSON.stringify(c.options), c.created || now, c.updated || now],
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Mirrors `pocketbase superuser upsert EMAIL PASS` from env, so a fresh deploy has a way in.
|
|
44
|
+
async function upsertSuperuserFromEnv(db: D1Database): Promise<void> {
|
|
45
|
+
const email = voidEnv.VOIDBASE_SUPERUSER_EMAIL;
|
|
46
|
+
const password = voidEnv.VOIDBASE_SUPERUSER_PASSWORD;
|
|
47
|
+
if (!email || !password) return;
|
|
48
|
+
await upsertSuperuser(db, email, password);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// `pocketbase superuser upsert EMAIL PASS`
|
|
52
|
+
export async function upsertSuperuser(db: D1Database, email: string, password: string): Promise<"created" | "updated" | "unchanged"> {
|
|
53
|
+
const table = ident("_superusers");
|
|
54
|
+
const existing = await one(db, `SELECT id, password FROM ${table} WHERE email = ? LIMIT 1`, [email]);
|
|
55
|
+
const now = nowString();
|
|
56
|
+
if (existing) {
|
|
57
|
+
// Only rehash when the stored hash no longer matches the configured password (cheap check first).
|
|
58
|
+
const { verifyPassword } = await import("./password");
|
|
59
|
+
if (await verifyPassword(password, String(existing.password ?? ""))) return "unchanged";
|
|
60
|
+
await run(db, `UPDATE ${table} SET password = ?, tokenKey = ?, updated = ? WHERE id = ?`, [
|
|
61
|
+
await hashPassword(password), randomString(50), now, existing.id,
|
|
62
|
+
]);
|
|
63
|
+
return "updated";
|
|
64
|
+
}
|
|
65
|
+
await run(
|
|
66
|
+
db,
|
|
67
|
+
`INSERT OR IGNORE INTO ${table} (id, password, tokenKey, email, emailVisibility, verified, created, updated) VALUES (?,?,?,?,?,?,?,?)`,
|
|
68
|
+
[randomId(), await hashPassword(password), randomString(50), email, false, true, now, now],
|
|
69
|
+
);
|
|
70
|
+
return "created";
|
|
71
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"authRule": null,
|
|
3
|
+
"manageRule": null,
|
|
4
|
+
"authAlert": {
|
|
5
|
+
"enabled": null,
|
|
6
|
+
"emailTemplate": {
|
|
7
|
+
"subject": null,
|
|
8
|
+
"body": null
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"oauth2": {
|
|
12
|
+
"providers": null,
|
|
13
|
+
"mappedFields": {
|
|
14
|
+
"id": null,
|
|
15
|
+
"name": null,
|
|
16
|
+
"username": null,
|
|
17
|
+
"avatarURL": null
|
|
18
|
+
},
|
|
19
|
+
"enabled": null
|
|
20
|
+
},
|
|
21
|
+
"passwordAuth": {
|
|
22
|
+
"enabled": null,
|
|
23
|
+
"identityFields": null
|
|
24
|
+
},
|
|
25
|
+
"mfa": {
|
|
26
|
+
"enabled": null,
|
|
27
|
+
"duration": null,
|
|
28
|
+
"rule": null
|
|
29
|
+
},
|
|
30
|
+
"otp": {
|
|
31
|
+
"enabled": null,
|
|
32
|
+
"duration": null,
|
|
33
|
+
"length": null,
|
|
34
|
+
"emailTemplate": {
|
|
35
|
+
"subject": null,
|
|
36
|
+
"body": null
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"authToken": {
|
|
40
|
+
"secret": null,
|
|
41
|
+
"duration": null
|
|
42
|
+
},
|
|
43
|
+
"passwordResetToken": {
|
|
44
|
+
"secret": null,
|
|
45
|
+
"duration": null
|
|
46
|
+
},
|
|
47
|
+
"emailChangeToken": {
|
|
48
|
+
"secret": null,
|
|
49
|
+
"duration": null
|
|
50
|
+
},
|
|
51
|
+
"verificationToken": {
|
|
52
|
+
"secret": null,
|
|
53
|
+
"duration": null
|
|
54
|
+
},
|
|
55
|
+
"fileToken": {
|
|
56
|
+
"secret": null,
|
|
57
|
+
"duration": null
|
|
58
|
+
},
|
|
59
|
+
"verificationTemplate": {
|
|
60
|
+
"subject": null,
|
|
61
|
+
"body": null
|
|
62
|
+
},
|
|
63
|
+
"resetPasswordTemplate": {
|
|
64
|
+
"subject": null,
|
|
65
|
+
"body": null
|
|
66
|
+
},
|
|
67
|
+
"confirmEmailChangeTemplate": {
|
|
68
|
+
"subject": null,
|
|
69
|
+
"body": null
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// SQL generation mirroring PocketBase core/collection_record_table_sync.go.
|
|
2
|
+
// Everything returns statement strings; the service runs them in one D1 batch.
|
|
3
|
+
import { ident } from "../db";
|
|
4
|
+
import { columnType, isMultiple, type Field } from "./fields";
|
|
5
|
+
import type { Collection } from "./model";
|
|
6
|
+
|
|
7
|
+
export interface ParsedIndex {
|
|
8
|
+
unique: boolean;
|
|
9
|
+
optional: boolean;
|
|
10
|
+
name: string;
|
|
11
|
+
table: string;
|
|
12
|
+
columns: string;
|
|
13
|
+
where: string;
|
|
14
|
+
raw: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// CREATE [UNIQUE] INDEX [IF NOT EXISTS] `name` ON `table` (cols) [WHERE expr]
|
|
18
|
+
const INDEX_RE = /^\s*CREATE\s+(UNIQUE\s+)?\s*INDEX\s*(IF\s+NOT\s+EXISTS\s+)?([^\s(]*)\s+ON\s+([^\s(]*)\s*\(([\s\S]*)\)(?:\s*WHERE\s+([\s\S]*))?\s*;?\s*$/i;
|
|
19
|
+
|
|
20
|
+
export function parseIndex(raw: string): ParsedIndex | null {
|
|
21
|
+
const m = INDEX_RE.exec(raw);
|
|
22
|
+
if (!m) return null;
|
|
23
|
+
const unq = (v: string) => v.trim().replace(/^[`"']|[`"']$/g, "");
|
|
24
|
+
return { unique: !!m[1], optional: !!m[2], name: unq(m[3] ?? ""), table: unq(m[4] ?? ""), columns: (m[5] ?? "").trim(), where: (m[6] ?? "").trim(), raw };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Mirrors dbutils.Index.Build(): backticked names, multi-column bodies on indented lines, COLLATE and sort kept.
|
|
28
|
+
export function buildIndex(idx: ParsedIndex, table: string): string {
|
|
29
|
+
const cols = idx.columns.split(",").map((c) => c.trim()).filter(Boolean).map((raw) => {
|
|
30
|
+
const m = /^([\s\S]+?)(?:\s+collate\s+(\w+))?(?:\s+(asc|desc))?$/i.exec(raw);
|
|
31
|
+
const name = (m?.[1] ?? raw).trim().replace(/^[`"']|[`"']$/g, "");
|
|
32
|
+
const quoted = name.includes("(") || name.includes(" ") ? name : "`" + name + "`";
|
|
33
|
+
return quoted + (m?.[2] ? ` COLLATE ${m[2]}` : "") + (m?.[3] ? ` ${m[3].toUpperCase()}` : "");
|
|
34
|
+
});
|
|
35
|
+
let out = `CREATE ${idx.unique ? "UNIQUE " : ""}INDEX ${idx.optional ? "IF NOT EXISTS " : ""}\`${idx.name}\` ON \`${table}\` (`;
|
|
36
|
+
out += cols.length > 1 ? "\n " + cols.join(",\n ") + "\n" : cols.join("");
|
|
37
|
+
out += ")";
|
|
38
|
+
if (idx.where) out += ` WHERE ${idx.where}`;
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Normalize an index expression the way PocketBase stores it: parsed, table name replaced, rebuilt.
|
|
43
|
+
export function normalizeIndex(raw: string, table: string): string {
|
|
44
|
+
const idx = parseIndex(raw);
|
|
45
|
+
return idx ? buildIndex(idx, table) : raw;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createTableSQL(c: Collection): string {
|
|
49
|
+
const cols = (c.fields as Field[]).map((f) => `${ident(f.name)} ${columnType(f)}`);
|
|
50
|
+
return `CREATE TABLE ${ident(c.name)} (${cols.join(", ")})`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createIndexesSQL(c: Collection): string[] {
|
|
54
|
+
return c.indexes.map((raw) => {
|
|
55
|
+
const idx = parseIndex(raw);
|
|
56
|
+
if (!idx) throw new Error(`invalid index expression: ${raw}`);
|
|
57
|
+
return buildIndex(idx, c.name);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function dropIndexesSQL(c: Collection): string[] {
|
|
62
|
+
return c.indexes.map((raw) => parseIndex(raw)).filter((i): i is ParsedIndex => !!i && !!i.name).map((i) => `DROP INDEX IF EXISTS ${ident(i.name)}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const dropTableSQL = (name: string) => `DROP TABLE IF EXISTS ${ident(name)}`;
|
|
66
|
+
export const dropViewSQL = (name: string) => `DROP VIEW IF EXISTS ${ident(name)}`;
|
|
67
|
+
export const truncateSQL = (name: string) => `DELETE FROM ${ident(name)}`;
|
|
68
|
+
export const createViewSQL = (name: string, query: string) => `CREATE VIEW ${ident(name)} AS ${query.trim().replace(/;\s*$/, "")}`;
|
|
69
|
+
|
|
70
|
+
let tempCounter = 0;
|
|
71
|
+
const temp = (base: string) => `${base}_vb${(++tempCounter).toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
72
|
+
|
|
73
|
+
// Statements to bring the table for `oldC` in line with `newC` (fields matched by id, as PocketBase does).
|
|
74
|
+
export function syncTableSQL(oldC: Collection, newC: Collection): string[] {
|
|
75
|
+
const out: string[] = [];
|
|
76
|
+
const oldFields = oldC.fields as Field[];
|
|
77
|
+
const newFields = newC.fields as Field[];
|
|
78
|
+
const renameTable = oldC.name.toLowerCase() !== newC.name.toLowerCase();
|
|
79
|
+
const indexesChanged = renameTable || JSON.stringify(oldFields) !== JSON.stringify(newFields) || JSON.stringify(oldC.indexes) !== JSON.stringify(newC.indexes);
|
|
80
|
+
|
|
81
|
+
if (indexesChanged) out.push(...dropIndexesSQL(oldC));
|
|
82
|
+
if (renameTable) out.push(`ALTER TABLE ${ident(oldC.name)} RENAME TO ${ident(newC.name)}`);
|
|
83
|
+
const table = newC.name;
|
|
84
|
+
|
|
85
|
+
// removed columns
|
|
86
|
+
for (const of of oldFields) {
|
|
87
|
+
if (!newFields.some((f) => f.id === of.id)) out.push(`ALTER TABLE ${ident(table)} DROP COLUMN ${ident(of.name)}`);
|
|
88
|
+
}
|
|
89
|
+
// added + renamed columns, via temp names so swaps cannot collide
|
|
90
|
+
const toRename: Array<[string, string]> = [];
|
|
91
|
+
for (const f of newFields) {
|
|
92
|
+
const of = oldFields.find((x) => x.id === f.id);
|
|
93
|
+
if (!of) {
|
|
94
|
+
const t = temp(f.name);
|
|
95
|
+
toRename.push([t, f.name]);
|
|
96
|
+
out.push(`ALTER TABLE ${ident(table)} ADD COLUMN ${ident(t)} ${columnType(f)}`);
|
|
97
|
+
} else if (of.name !== f.name) {
|
|
98
|
+
const t = temp(f.name);
|
|
99
|
+
toRename.push([t, f.name]);
|
|
100
|
+
out.push(`ALTER TABLE ${ident(table)} RENAME COLUMN ${ident(of.name)} TO ${ident(t)}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (const [t, name] of toRename) out.push(`ALTER TABLE ${ident(table)} RENAME COLUMN ${ident(t)} TO ${ident(name)}`);
|
|
104
|
+
|
|
105
|
+
// single <-> multiple value conversions (select, file, relation)
|
|
106
|
+
for (const f of newFields) {
|
|
107
|
+
const of = oldFields.find((x) => x.id === f.id);
|
|
108
|
+
if (!of || of.type !== f.type) continue;
|
|
109
|
+
const wasMulti = isMultiple(of);
|
|
110
|
+
const isMulti = isMultiple(f);
|
|
111
|
+
if (wasMulti === isMulti) continue;
|
|
112
|
+
const col = ident(f.name);
|
|
113
|
+
const old = temp("_" + f.name);
|
|
114
|
+
out.push(`ALTER TABLE ${ident(table)} RENAME COLUMN ${col} TO ${ident(old)}`);
|
|
115
|
+
out.push(`ALTER TABLE ${ident(table)} ADD COLUMN ${col} ${columnType(f)}`);
|
|
116
|
+
const o = ident(old);
|
|
117
|
+
out.push(
|
|
118
|
+
isMulti
|
|
119
|
+
? `UPDATE ${ident(table)} SET ${col} = (CASE WHEN COALESCE(${o}, '') = '' THEN '[]' ELSE (CASE WHEN json_valid(${o}) AND json_type(${o}) = 'array' THEN ${o} ELSE json_array(${o}) END) END)`
|
|
120
|
+
: `UPDATE ${ident(table)} SET ${col} = (CASE WHEN COALESCE(${o}, '[]') = '[]' THEN '' ELSE (CASE WHEN json_valid(${o}) AND json_type(${o}) = 'array' THEN COALESCE(json_extract(${o}, '$[#-1]'), '') ELSE ${o} END) END)`,
|
|
121
|
+
);
|
|
122
|
+
out.push(`ALTER TABLE ${ident(table)} DROP COLUMN ${o}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (indexesChanged) out.push(...createIndexesSQL(newC));
|
|
126
|
+
return out;
|
|
127
|
+
}
|