@kahitsan/plugin-sdk 0.2.0 → 0.3.0-staging.43

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 (69) hide show
  1. package/bin/cli.mjs +54 -0
  2. package/dist/index.js +1 -1
  3. package/package.json +8 -2
  4. package/shell/_build/.vite/manifest.json +189 -0
  5. package/shell/_build/.vite/manifest.json.br +0 -0
  6. package/shell/_build/.vite/manifest.json.gz +0 -0
  7. package/shell/_build/assets/_...slug_-BNycC89F.js +1 -0
  8. package/shell/_build/assets/_...slug_-BNycC89F.js.br +0 -0
  9. package/shell/_build/assets/_...slug_-BNycC89F.js.gz +0 -0
  10. package/shell/_build/assets/api-keys-zDu0DTIi.js +1 -0
  11. package/shell/_build/assets/auth-CPW4mGTx.js +70 -0
  12. package/shell/_build/assets/auth-CPW4mGTx.js.br +0 -0
  13. package/shell/_build/assets/auth-CPW4mGTx.js.gz +0 -0
  14. package/shell/_build/assets/client-B0tH7z8C.css +1 -0
  15. package/shell/_build/assets/client-B0tH7z8C.css.br +0 -0
  16. package/shell/_build/assets/client-B0tH7z8C.css.gz +0 -0
  17. package/shell/_build/assets/client-BhDpDGXU.js +2 -0
  18. package/shell/_build/assets/client-BhDpDGXU.js.br +0 -0
  19. package/shell/_build/assets/client-BhDpDGXU.js.gz +0 -0
  20. package/shell/_build/assets/components-kMeatXhB.js +1 -0
  21. package/shell/_build/assets/dashboard-BwMkrsX2.js +1 -0
  22. package/shell/_build/assets/index-dCQOOcel.js +1 -0
  23. package/shell/_build/assets/login-BwMkrsX2.js +1 -0
  24. package/shell/_build/assets/members-CKVe_2mI.js +1 -0
  25. package/shell/_build/assets/plugin-ui-rgxz3UHD.js +10 -0
  26. package/shell/_build/assets/plugin-ui-rgxz3UHD.js.br +0 -0
  27. package/shell/_build/assets/plugin-ui-rgxz3UHD.js.gz +0 -0
  28. package/shell/_build/assets/register-BwMkrsX2.js +1 -0
  29. package/shell/_build/assets/routing-BlHUL5zP.js +1 -0
  30. package/shell/_build/assets/routing-BlHUL5zP.js.br +0 -0
  31. package/shell/_build/assets/routing-BlHUL5zP.js.gz +0 -0
  32. package/shell/_build/assets/settings-BwMkrsX2.js +1 -0
  33. package/shell/_build/assets/users-BwMkrsX2.js +1 -0
  34. package/shell/_build/assets/workspace-nav-DsOJmRdY.js +1 -0
  35. package/shell/_build/assets/workspace-nav-DsOJmRdY.js.br +0 -0
  36. package/shell/_build/assets/workspace-nav-DsOJmRdY.js.gz +0 -0
  37. package/shell/_build/assets/workspace-settings-BwMkrsX2.js +1 -0
  38. package/shell/_build/assets/workspaces-BwMkrsX2.js +1 -0
  39. package/shell/_headers +20 -0
  40. package/shell/_redirects +4 -0
  41. package/shell/_server/assets/app-B0tH7z8C.css +1 -0
  42. package/shell/_server/assets/app-B0tH7z8C.css.br +0 -0
  43. package/shell/_server/assets/app-B0tH7z8C.css.gz +0 -0
  44. package/shell/apple-touch-icon.png +0 -0
  45. package/shell/dev/apple-touch-icon.png +0 -0
  46. package/shell/dev/favicon-32.png +0 -0
  47. package/shell/dev/favicon.ico +0 -0
  48. package/shell/dev/manifest.webmanifest +31 -0
  49. package/shell/dev/pwa-192.png +0 -0
  50. package/shell/dev/pwa-512-maskable.png +0 -0
  51. package/shell/dev/pwa-512.png +0 -0
  52. package/shell/favicon-32.png +0 -0
  53. package/shell/favicon.ico +0 -0
  54. package/shell/index.html +1 -0
  55. package/shell/index.html.br +0 -0
  56. package/shell/index.html.gz +0 -0
  57. package/shell/manifest.webmanifest +31 -0
  58. package/shell/pwa-192.png +0 -0
  59. package/shell/pwa-512-maskable.png +0 -0
  60. package/shell/pwa-512.png +0 -0
  61. package/shell/sw.js +103 -0
  62. package/shell/sw.js.br +0 -0
  63. package/shell/sw.js.gz +0 -0
  64. package/src/dev/dev-assets.ts +156 -0
  65. package/src/dev/dev-host.ts +194 -0
  66. package/src/dev/dev-kernel.ts +499 -0
  67. package/src/dev/preload.ts +61 -0
  68. package/src/dev/serve.ts +141 -0
  69. package/src/dev/sqlite-pool.ts +358 -0
@@ -0,0 +1,141 @@
1
+ // §5.3 — the FULL toolkit launcher. Boots N plugins + the dev-kernel (the complete
2
+ // `/api` + `/_internal` backend) AND serves the prebuilt kernel-identical host-UI
3
+ // SHELL, so `npx @kahitsan/plugin-sdk <dir>…` brings up the real visual app with ZERO
4
+ // kserp source. The shell is a static SPA (the host UI is `ssr:false`, so a toolkit
5
+ // `vinxi build` emits pure static files) shipped in the package at `<pkg>/shell`.
6
+ //
7
+ // bun packages/plugin-sdk/src/dev/serve.ts <pluginDir> [<pluginDir> …]
8
+ //
9
+ // Topology: the browser only ever talks to THIS server (one origin). Static asset →
10
+ // served from ./shell; `/api/*` or `/_internal/*` → proxied to the dev-kernel (which
11
+ // itself proxies `<basePath>/_ui/remote.js` to each plugin); anything else → index.html
12
+ // (the SPA owns client routing). dev-host.ts remains the headless/backend-only variant.
13
+
14
+ import { readFileSync, existsSync } from "node:fs";
15
+ import { join, resolve } from "node:path";
16
+ import { spawn, type Subprocess } from "bun";
17
+
18
+ const PRELOAD = join(import.meta.dir, "preload.ts");
19
+ const DEV_KERNEL = join(import.meta.dir, "dev-kernel.ts");
20
+ // The prebuilt static shell: `<pkg>/shell` (src/dev → ../../shell). Built by
21
+ // `npm run build:toolkit-shell`; shipped via package.json `files`.
22
+ const SHELL_DIR = resolve(import.meta.dir, "../../shell");
23
+ const DEV_SECRET = "dev-stub-internal-secret";
24
+ const BASE = Number(process.env.KSERP_DEV_BASE_PORT || "4500");
25
+
26
+ const dirs = process.argv.slice(2).map((d) => resolve(d));
27
+ if (dirs.length === 0) {
28
+ console.error("usage: bun serve.ts <pluginDir> [<pluginDir> …]");
29
+ process.exit(1);
30
+ }
31
+ if (!existsSync(join(SHELL_DIR, "index.html"))) {
32
+ console.error(
33
+ `[toolkit] host-UI shell missing at ${SHELL_DIR}.\n` +
34
+ ` In a source checkout run \`npm run build:toolkit-shell\`; in an installed package this\n` +
35
+ ` means the published tarball didn't ship ./shell — reinstall @kahitsan/plugin-sdk.`,
36
+ );
37
+ process.exit(1);
38
+ }
39
+
40
+ const uiPort = BASE; // the visual app the developer opens
41
+ const kernelPort = BASE + 1; // dev-kernel: /api + /_internal
42
+ const KERNEL = `http://127.0.0.1:${kernelPort}`;
43
+
44
+ interface PluginReg {
45
+ name: string;
46
+ dir: string;
47
+ port: number;
48
+ }
49
+ const regs: PluginReg[] = dirs.map((dir, i) => {
50
+ const m = JSON.parse(readFileSync(join(dir, "plugin.manifest.json"), "utf8")) as {
51
+ name: string;
52
+ };
53
+ return { name: m.name, dir, port: BASE + 2 + i };
54
+ });
55
+
56
+ // ── spawn each plugin (Bun + preload → SQLite + synthetic admin), pointed at the dev-kernel ──
57
+ const procs: Subprocess[] = [];
58
+ for (const r of regs) {
59
+ const m = JSON.parse(readFileSync(join(r.dir, "plugin.manifest.json"), "utf8")) as {
60
+ schemas?: string[];
61
+ };
62
+ procs.push(
63
+ spawn({
64
+ cmd: ["bun", "--preload", PRELOAD, join(r.dir, "server", "main.ts")],
65
+ cwd: r.dir,
66
+ env: {
67
+ ...process.env,
68
+ KSERP_DEV_PLUGIN_DIR: r.dir,
69
+ KSERP_DB_ENGINE: "sqlite",
70
+ KSERP_PLUGIN_PORT: String(r.port),
71
+ KSERP_PLUGIN_BIND: "127.0.0.1",
72
+ KSERP_PLUGIN_SCHEMAS: (m.schemas ?? []).join(","),
73
+ KSERP_KERNEL_URL: KERNEL,
74
+ KSERP_INTERNAL_SECRET: DEV_SECRET,
75
+ },
76
+ stdout: "inherit",
77
+ stderr: "inherit",
78
+ }),
79
+ );
80
+ }
81
+
82
+ // ── spawn the dev-kernel (serves /api/* + /_internal/rpc + /_internal/assets, and proxies
83
+ // each plugin's bundle + routes by basePath) ──
84
+ const kernelProc = spawn({
85
+ cmd: ["bun", DEV_KERNEL],
86
+ env: {
87
+ ...process.env,
88
+ KSERP_DEV_KERNEL_PORT: String(kernelPort),
89
+ KSERP_INTERNAL_SECRET: DEV_SECRET,
90
+ KSERP_DEV_PLUGINS: JSON.stringify(
91
+ regs.map((r) => ({ name: r.name, dir: r.dir, port: r.port })),
92
+ ),
93
+ },
94
+ stdout: "inherit",
95
+ stderr: "inherit",
96
+ });
97
+
98
+ // ── the one origin the browser talks to: static shell + a proxy to the dev-kernel ──
99
+ const ui = Bun.serve({
100
+ port: uiPort,
101
+ async fetch(req) {
102
+ const url = new URL(req.url);
103
+ const path = url.pathname;
104
+ // backend (kernel API, cross-plugin RPC, assets, AND plugin bundles under /api/<base>/_ui)
105
+ if (path === "/api" || path.startsWith("/api/") || path.startsWith("/_internal/")) {
106
+ const upstream = await fetch(KERNEL + path + url.search, {
107
+ method: req.method,
108
+ headers: req.headers,
109
+ body: req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer(),
110
+ redirect: "manual",
111
+ }).catch(() => null);
112
+ if (!upstream) return new Response("dev-kernel not ready", { status: 502 });
113
+ return new Response(upstream.body, { status: upstream.status, headers: upstream.headers });
114
+ }
115
+ // a real static asset (index.html, /_build/*, icons, /dev/*)
116
+ const rel = path === "/" ? "/index.html" : path;
117
+ const asset = Bun.file(join(SHELL_DIR, rel));
118
+ if (await asset.exists()) return new Response(asset);
119
+ // otherwise it's a client route (/payees, /transactions, …) → the SPA owns it
120
+ return new Response(Bun.file(join(SHELL_DIR, "index.html")), {
121
+ headers: { "content-type": "text/html" },
122
+ });
123
+ },
124
+ });
125
+
126
+ console.log("\n────────────────────────────────────────────────────────");
127
+ console.log(` ▸ toolkit app http://127.0.0.1:${ui.port} ← open this`);
128
+ console.log(` ▸ dev-kernel http://127.0.0.1:${kernelPort} (/api + /_internal)`);
129
+ for (const r of regs) console.log(` ▸ ${r.name.padEnd(18)} :${r.port}`);
130
+ console.log("────────────────────────────────────────────────────────");
131
+ console.log(" The kernel-identical shell is served from the package — no kserp source needed.");
132
+ console.log(" Ctrl-C stops everything.\n");
133
+
134
+ function shutdown() {
135
+ for (const p of procs) p.kill();
136
+ kernelProc.kill();
137
+ ui.stop(true);
138
+ process.exit(0);
139
+ }
140
+ process.on("SIGINT", shutdown);
141
+ process.on("SIGTERM", shutdown);
@@ -0,0 +1,358 @@
1
+ // Dev-stub SQLite "pool" — the local-dev engine of the dual-engine SDK (Vision §1
2
+ // / D3). Runs on **Bun** (`bun:sqlite`). It presents the SAME duck-typed surface
3
+ // the kernel's `makeDatabaseService` + `makeDataSurface` + migration runner call on
4
+ // a `pg.Pool` (`query`, `connect()` → a client with `query`/`release`, `end`), so a
5
+ // plugin's byte-identical server code runs on SQLite locally and Postgres in prod —
6
+ // the developer never wires the engine; the dev host selects it.
7
+ //
8
+ // The kernel's data path wraps every scoped query in Postgres ceremony — `BEGIN;
9
+ // SET LOCAL ROLE app_authenticated; SET LOCAL search_path = …; set_config('app.*',
10
+ // …, true); … ; COMMIT`. SQLite has none of roles/schemas/GUCs/RLS, so this adapter
11
+ // **no-ops the ceremony** and runs the real statement. The tenant wall on SQLite is
12
+ // the SDK's explicit `WHERE workspace_id` filter standing ALONE (D3: the injection
13
+ // must be strong enough without RLS) — which it is, because the same generated SQL
14
+ // carries the filter on both engines.
15
+ //
16
+ // It also translates the three Postgres *introspection* queries the data surface's
17
+ // `classify()` runs (`to_regclass`, `pg_attribute`, `pg_constraint`) into SQLite
18
+ // `sqlite_master`/`pragma` equivalents — the D5-flagged gap — so table classification
19
+ // (direct workspace_id vs FK-to-parent) works on SQLite too.
20
+ //
21
+ // Bun-only by construction (`bun:sqlite`); excluded from the node/base tsconfig and
22
+ // loaded only by the dev host under Bun. Never on the prod PG path.
23
+
24
+ import { Database } from "bun:sqlite";
25
+
26
+ interface QueryResult<R = Record<string, unknown>> {
27
+ rows: R[];
28
+ rowCount: number;
29
+ }
30
+
31
+ interface SqliteClient {
32
+ query<R = Record<string, unknown>>(
33
+ text: string,
34
+ params?: readonly unknown[],
35
+ ): Promise<QueryResult<R>>;
36
+ release(): void;
37
+ }
38
+
39
+ export interface SqlitePool {
40
+ query<R = Record<string, unknown>>(
41
+ text: string,
42
+ params?: readonly unknown[],
43
+ ): Promise<QueryResult<R>>;
44
+ connect(): Promise<SqliteClient>;
45
+ end(): Promise<void>;
46
+ on(event: string, handler: (...args: unknown[]) => void): void;
47
+ }
48
+
49
+ // ── Postgres ceremony this adapter absorbs (no-op on SQLite). Each pattern matches a
50
+ // statement's leading keyword(s) so a real user query is never misclassified. Kept
51
+ // as an array of SIMPLE regexes (not one mega-alternation) for readability + low
52
+ // individual complexity. ──
53
+ //
54
+ // NOOP: connection/session ceremony + PG built-ins with no SQLite analogue. Advisory
55
+ // locks are unnecessary on the dev host's single serialized connection.
56
+ const NOOP: readonly RegExp[] = [
57
+ /^\s*SET\s+(LOCAL\s+)?(ROLE|SESSION|search_path)\b/i,
58
+ /^\s*RESET\b/i,
59
+ /^\s*SELECT\s+set_config\(/i,
60
+ /^\s*SELECT\s+pg_advisory_(un)?lock\b/i,
61
+ ];
62
+ const TXN = /^\s*(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE\s+SAVEPOINT)\b/i;
63
+ // SKIP_DDL: Postgres-only DDL with no SQLite analogue. The tenant wall (RLS) + roles/
64
+ // grants are PG prod concerns the SDK's explicit workspace_id filter already covers;
65
+ // procedural blocks / functions / triggers / extensions / comments are migration
66
+ // ceremony a plugin author's local routes don't need. (The declarative CREATE TABLE /
67
+ // ADD COLUMN / CREATE INDEX still run.)
68
+ const SKIP_DDL: readonly RegExp[] = [
69
+ /^\s*DO\s+\$/i,
70
+ /^\s*(CREATE|DROP)\s+SCHEMA\b/i,
71
+ /^\s*(CREATE|DROP|ALTER)\s+SEQUENCE\b/i,
72
+ /^\s*(GRANT|REVOKE)\b/i,
73
+ /^\s*(CREATE|DROP)\s+EXTENSION\b/i,
74
+ /^\s*COMMENT\s+ON\b/i,
75
+ /^\s*CREATE(\s+OR\s+REPLACE)?\s+FUNCTION\b/i,
76
+ /^\s*DROP\s+FUNCTION\b/i,
77
+ /^\s*(CREATE|DROP)\s+TRIGGER\b/i,
78
+ /^\s*(CREATE|DROP|ALTER)\s+POLICY\b/i,
79
+ ];
80
+ // The only `ALTER TABLE` forms SQLite supports are ADD/DROP COLUMN and RENAME; every
81
+ // other form (ALTER COLUMN, ADD/DROP/VALIDATE CONSTRAINT, ENABLE RLS, SET …) is
82
+ // Postgres-only migration ceremony — no-op'd on the dev engine.
83
+ const ALTER_TABLE = /^\s*ALTER\s+TABLE\b/i;
84
+ const ALTER_TABLE_SUPPORTED = /\b(ADD\s+COLUMN|DROP\s+COLUMN|RENAME)\b/i;
85
+ const RETURNS_ROWS = /^\s*(SELECT|PRAGMA|WITH|VALUES)\b/i;
86
+ // DDL (schema statements, migration-only) — best-effort on the dev engine; a failure
87
+ // is logged + skipped, never thrown. DML failures still surface.
88
+ const IS_DDL = /^\s*(CREATE|ALTER|DROP|REINDEX|TRUNCATE|VACUUM|ANALYZE)\b/i;
89
+
90
+ const matchesAny = (sql: string, pats: readonly RegExp[]): boolean => pats.some((p) => p.test(sql));
91
+
92
+ // classify() introspection signatures (kernel-base/workspace-db.ts).
93
+ const Q_REGCLASS = /to_regclass\(\$1\)::oid\s+AS\s+oid/i;
94
+ const Q_OWN_WORKSPACE_COL = /pg_attribute[\s\S]*attname\s*=\s*'workspace_id'/i;
95
+ const Q_FK_TO_PARENT = /pg_constraint[\s\S]*confrelid::regclass::text\s+AS\s+parent_regclass/i;
96
+
97
+ /** Postgres `$1,$2,…` (may repeat / be out of order) → SQLite positional `?`, with
98
+ * the bind list rebuilt in placeholder-appearance order (F5 adapter semantics). */
99
+ function toSqlite(text: string, params: readonly unknown[]): { sql: string; binds: unknown[] } {
100
+ const binds: unknown[] = [];
101
+ let sql = text.replace(/\$(\d+)/g, (_m, n: string) => {
102
+ binds.push(normalizeBind(params[Number(n) - 1]));
103
+ return "?";
104
+ });
105
+ // Strip Postgres `::type` casts the generated surface emits (e.g. `COUNT(*)::int`,
106
+ // `$1::text`, `x::timestamptz`). One type token only — NO spaces — so `::int AS c`
107
+ // strips just `::int` and leaves ` AS c`. SQLite has no `::` cast; affinity is fine.
108
+ sql = sql.replace(/::"?[a-zA-Z_]\w*"?(\[\])?/g, "");
109
+ sql = translateDialect(sql);
110
+ return { sql, binds };
111
+ }
112
+
113
+ // Postgres built-ins the generated surface + migrations emit that SQLite lacks an
114
+ // identical spelling for. Dev-fidelity, not byte-perfection — enough for a plugin
115
+ // author to exercise their routes locally (the prod engine is still Postgres).
116
+ function translateDialect(sql: string): string {
117
+ return (
118
+ sql
119
+ // time: NOW()/CURRENT_TIMESTAMP both → SQLite's CURRENT_TIMESTAMP (UTC text).
120
+ .replace(/\bNOW\s*\(\s*\)/gi, "CURRENT_TIMESTAMP")
121
+ // a UUID-shaped random hex (not RFC-4122-strict, but unique + the right shape).
122
+ .replace(
123
+ /\bgen_random_uuid\s*\(\s*\)/gi,
124
+ "(lower(hex(randomblob(4)))||'-'||lower(hex(randomblob(2)))||'-'||lower(hex(randomblob(2)))||'-'||lower(hex(randomblob(2)))||'-'||lower(hex(randomblob(6))))",
125
+ )
126
+ // SQLite's ALTER TABLE ADD COLUMN has no `IF NOT EXISTS` (PG-only). Strip it;
127
+ // idempotency is restored by swallowing the duplicate-column error in runReal.
128
+ .replace(/\bADD\s+COLUMN\s+IF\s+NOT\s+EXISTS\b/gi, "ADD COLUMN")
129
+ // `id SERIAL PRIMARY KEY` → `id INTEGER PRIMARY KEY` (SQLite's rowid alias
130
+ // auto-increments). Without this a SERIAL id stays NULL on insert and every
131
+ // by-id route (GET/PUT/DELETE /:id) is dead.
132
+ .replace(/\b(BIG|SMALL)?SERIAL\b/gi, "INTEGER")
133
+ // sequence calls (the SQLite sequences were no-op'd) → NULL, so an INTEGER
134
+ // PRIMARY KEY autoincrements and any other sequence-backed column nulls out.
135
+ .replace(/\b(next|curr)val\s*\([^)]*\)/gi, "NULL")
136
+ // (TRUE/FALSE need no translation — bun:sqlite's SQLite recognizes them as 1/0.)
137
+ );
138
+ }
139
+
140
+ // bun:sqlite binds only number | bigint | string | boolean | Uint8Array | null.
141
+ // Coerce the value shapes the SDK passes (Date, undefined, json objects) so a
142
+ // scoped write doesn't throw on an otherwise-valid value.
143
+ function normalizeBind(v: unknown): unknown {
144
+ if (v === undefined) return null;
145
+ if (v instanceof Date) return v.toISOString();
146
+ if (typeof v === "boolean") return v ? 1 : 0;
147
+ if (v !== null && typeof v === "object") return JSON.stringify(v);
148
+ return v;
149
+ }
150
+
151
+ /** Resolve a table name from a `to_regclass` "oid" stand-in (we return the bare
152
+ * table name as the oid, so downstream introspection queries get it back). */
153
+ function tableExists(db: Database, table: string): boolean {
154
+ const row = db
155
+ .query(`SELECT 1 FROM sqlite_master WHERE type='table' AND name = ? LIMIT 1`)
156
+ .get(table);
157
+ return !!row;
158
+ }
159
+
160
+ function hasWorkspaceIdColumn(db: Database, table: string): boolean {
161
+ const cols = db.query(`PRAGMA table_info(${quote(table)})`).all() as { name: string }[];
162
+ return cols.some((c) => c.name === "workspace_id");
163
+ }
164
+
165
+ function findWorkspaceFk(
166
+ db: Database,
167
+ table: string,
168
+ ): { fk_column: string; parent_regclass: string } | null {
169
+ const fks = db.query(`PRAGMA foreign_key_list(${quote(table)})`).all() as {
170
+ table: string;
171
+ from: string;
172
+ }[];
173
+ for (const fk of fks) {
174
+ if (hasWorkspaceIdColumn(db, fk.table)) {
175
+ return { fk_column: fk.from, parent_regclass: fk.table };
176
+ }
177
+ }
178
+ return null;
179
+ }
180
+
181
+ function quote(name: string): string {
182
+ return `"${name.replace(/"/g, '""')}"`;
183
+ }
184
+
185
+ /** Classify a SQLite execution error into a tolerable empty result (Vision §5.1
186
+ * resilience), or null to rethrow. Tolerated: an idempotent re-`ADD COLUMN`; any
187
+ * statement during the boot migration window or any DDL (best-effort — the plugin
188
+ * boots with the tables it could create); a Postgres-only runtime READ (degrades to
189
+ * empty so the plugin's page renders instead of crashing on `undefined.length`). A
190
+ * runtime WRITE is NOT tolerated — a failing INSERT/UPDATE/DELETE is a real bug. */
191
+ function tolerateError<R>(sql: string, err: Error): QueryResult<R> | null {
192
+ const msg = err.message;
193
+ const empty: QueryResult<R> = { rows: [], rowCount: 0 };
194
+ const debug = (kind: string) => {
195
+ console.warn(`[dev-stub] ${kind} on SQLite: ${msg}`);
196
+ if (process.env.KSERP_DEV_SQL_DEBUG) console.warn(` ↳ ${sql.slice(0, 200)}`);
197
+ };
198
+ if (/duplicate column name/i.test(msg) && /\bADD\s+COLUMN\b/i.test(sql)) return empty;
199
+ const migrating = (globalThis as { __KSERP_DEV_MIGRATING__?: boolean }).__KSERP_DEV_MIGRATING__;
200
+ if (migrating || IS_DDL.test(sql)) return (debug("skipped a Postgres-only statement"), empty);
201
+ if (RETURNS_ROWS.test(sql)) return (debug("degraded a Postgres-only read (empty result)"), empty);
202
+ if (process.env.KSERP_DEV_SQL_DEBUG)
203
+ console.error(`[dev-stub sqlite] FAILED SQL:\n${sql}\n→ ${msg}`);
204
+ return null;
205
+ }
206
+
207
+ /** Run a single (already known to be real) statement against the SQLite db. */
208
+ function runReal<R>(db: Database, text: string, params: readonly unknown[]): QueryResult<R> {
209
+ const { sql, binds } = toSqlite(text, params);
210
+ let stmt;
211
+ try {
212
+ stmt = db.query(sql);
213
+ } catch (err) {
214
+ const empty = tolerateError<R>(sql, err as Error);
215
+ if (empty) return empty;
216
+ throw err;
217
+ }
218
+ if (RETURNS_ROWS.test(sql) || /\bRETURNING\b/i.test(sql)) {
219
+ const rows = stmt.all(...(binds as never[])) as R[];
220
+ return { rows, rowCount: rows.length };
221
+ }
222
+ const info = stmt.run(...(binds as never[]));
223
+ return { rows: [], rowCount: Number(info.changes ?? 0) };
224
+ }
225
+
226
+ /** Translate / no-op a statement; returns the QueryResult or null if it's a real
227
+ * user statement the caller should run with `runReal`. */
228
+ function translate<R>(
229
+ db: Database,
230
+ text: string,
231
+ params: readonly unknown[],
232
+ ): QueryResult<R> | null {
233
+ if (TXN.test(text)) {
234
+ db.exec(text.trim());
235
+ return { rows: [], rowCount: 0 };
236
+ }
237
+ if (matchesAny(text, SKIP_DDL)) {
238
+ return { rows: [], rowCount: 0 };
239
+ }
240
+ if (ALTER_TABLE.test(text) && !ALTER_TABLE_SUPPORTED.test(text)) {
241
+ return { rows: [], rowCount: 0 };
242
+ }
243
+ if (matchesAny(text, NOOP)) {
244
+ // set_config(...) is a SELECT that the caller may read; hand back one empty row.
245
+ return { rows: [{}] as R[], rowCount: 1 };
246
+ }
247
+ if (Q_REGCLASS.test(text)) {
248
+ const table = String(params[0]);
249
+ const oid = tableExists(db, table) ? table : null;
250
+ return { rows: [{ oid }] as R[], rowCount: 1 };
251
+ }
252
+ if (Q_OWN_WORKSPACE_COL.test(text)) {
253
+ const table = String(params[0]); // the "oid" we handed back == the table name
254
+ return hasWorkspaceIdColumn(db, table)
255
+ ? { rows: [{ "?column?": 1 }] as R[], rowCount: 1 }
256
+ : { rows: [], rowCount: 0 };
257
+ }
258
+ if (Q_FK_TO_PARENT.test(text)) {
259
+ const table = String(params[0]);
260
+ const fk = findWorkspaceFk(db, table);
261
+ return fk ? { rows: [fk] as R[], rowCount: 1 } : { rows: [], rowCount: 0 };
262
+ }
263
+ return null;
264
+ }
265
+
266
+ /**
267
+ * Open a dev-stub SQLite pool. `filename` defaults to in-memory; pass a path to
268
+ * persist across restarts. All access is serialized through one connection (a
269
+ * dev host is low-concurrency; correctness over throughput), so the per-query
270
+ * `BEGIN…COMMIT` the kernel emits never interleaves.
271
+ */
272
+ export interface KernelStubSeed {
273
+ workspaceId: number;
274
+ userId: string;
275
+ userEmail?: string;
276
+ userName?: string;
277
+ wsRole?: string;
278
+ }
279
+
280
+ /** Seed the minimal kernel-owned tables a plugin references (FK to `workspaces`,
281
+ * the occasional join to `users`/`workspace_members`) + the synthetic admin's rows,
282
+ * so a plugin's prod FK/joins resolve locally. Created BEFORE the plugin's migrations
283
+ * run, so its `REFERENCES workspaces(id)` is satisfiable. Minimal columns only — a
284
+ * route selecting an exotic kernel column degrades gracefully (the dev-host's job). */
285
+ function seedKernelStub(db: Database, seed: KernelStubSeed): void {
286
+ db.exec(`CREATE TABLE IF NOT EXISTS workspaces (
287
+ id INTEGER PRIMARY KEY, name TEXT, slug TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP)`);
288
+ db.exec(`CREATE TABLE IF NOT EXISTS users (
289
+ id TEXT PRIMARY KEY, email TEXT, name TEXT, role TEXT, is_active INTEGER DEFAULT 1)`);
290
+ db.exec(`CREATE TABLE IF NOT EXISTS workspace_members (
291
+ workspace_id INTEGER, user_id TEXT, role TEXT, PRIMARY KEY (workspace_id, user_id))`);
292
+ db.query(
293
+ `INSERT OR IGNORE INTO workspaces (id, name, slug) VALUES (?, 'Dev Workspace', 'dev')`,
294
+ ).run(seed.workspaceId);
295
+ db.query(
296
+ `INSERT OR IGNORE INTO users (id, email, name, role, is_active) VALUES (?, ?, ?, ?, 1)`,
297
+ ).run(seed.userId, seed.userEmail ?? "dev@localhost", seed.userName ?? "Dev Admin", "superuser");
298
+ db.query(
299
+ `INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)`,
300
+ ).run(seed.workspaceId, seed.userId, seed.wsRole ?? "Admin");
301
+ }
302
+
303
+ /** SQLite is flat — no schemas. A named-schema plugin's `packages.foo` (or the
304
+ * kernel `public.workspaces`) must collapse to the bare table `foo`/`workspaces`,
305
+ * matching the unqualified names the data surface uses under its no-op'd
306
+ * search_path. Strips `<schema>.` and `"<schema>".` for the plugin's declared
307
+ * schemas plus `public`. */
308
+ function makeSchemaStripper(schemas: readonly string[]): (sql: string) => string {
309
+ const names = ["public", ...schemas].filter(Boolean);
310
+ if (names.length === 0) return (s) => s;
311
+ const alt = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
312
+ const re = new RegExp(`"?\\b(${alt})\\b"?\\.`, "gi");
313
+ return (sql) => sql.replace(re, "");
314
+ }
315
+
316
+ export function makeSqlitePool(
317
+ filename = ":memory:",
318
+ seed?: KernelStubSeed,
319
+ schemas: readonly string[] = [],
320
+ ): SqlitePool {
321
+ const db = new Database(filename);
322
+ db.exec("PRAGMA foreign_keys = ON");
323
+ if (seed) seedKernelStub(db, seed);
324
+ const stripSchema = makeSchemaStripper(schemas);
325
+ // Serialize: one statement/transaction span at a time over the single handle.
326
+ let tail: Promise<unknown> = Promise.resolve();
327
+ function serialize<T>(fn: () => T): Promise<T> {
328
+ const run = tail.then(fn, fn);
329
+ tail = run.catch(() => {});
330
+ return run;
331
+ }
332
+
333
+ async function query<R>(
334
+ rawText: string,
335
+ params: readonly unknown[] = [],
336
+ ): Promise<QueryResult<R>> {
337
+ const text = stripSchema(rawText);
338
+ return serialize(() => {
339
+ const translated = translate<R>(db, text, params);
340
+ return translated ?? runReal<R>(db, text, params);
341
+ });
342
+ }
343
+
344
+ return {
345
+ query,
346
+ async connect(): Promise<SqliteClient> {
347
+ // One shared handle; the client just forwards. release() is a no-op (nothing
348
+ // to return to a pool), but the per-call serialization still applies.
349
+ return { query, release() {} };
350
+ },
351
+ async end() {
352
+ await serialize(() => db.close());
353
+ },
354
+ on() {
355
+ // pg's pool.on('error', …) — no idle-client errors on a single sqlite handle.
356
+ },
357
+ };
358
+ }