@pramen/server 0.0.1
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/dist/auth.d.ts +35 -0
- package/dist/auth.js +189 -0
- package/dist/durable-object.d.ts +48 -0
- package/dist/durable-object.js +282 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +20 -0
- package/dist/pramen.d.ts +42 -0
- package/dist/pramen.js +19 -0
- package/dist/runtime/acl.d.ts +62 -0
- package/dist/runtime/acl.js +289 -0
- package/dist/runtime/db.d.ts +139 -0
- package/dist/runtime/db.js +425 -0
- package/dist/runtime/ddl.d.ts +16 -0
- package/dist/runtime/ddl.js +64 -0
- package/dist/runtime/digest.d.ts +1 -0
- package/dist/runtime/digest.js +29 -0
- package/dist/runtime/dispatch.d.ts +12 -0
- package/dist/runtime/dispatch.js +37 -0
- package/dist/runtime/driver.d.ts +45 -0
- package/dist/runtime/driver.js +70 -0
- package/dist/runtime/errors.d.ts +34 -0
- package/dist/runtime/errors.js +43 -0
- package/dist/runtime/kv.d.ts +23 -0
- package/dist/runtime/kv.js +41 -0
- package/dist/runtime/migrate.d.ts +22 -0
- package/dist/runtime/migrate.js +158 -0
- package/dist/runtime/protocol.d.ts +40 -0
- package/dist/runtime/protocol.js +12 -0
- package/dist/runtime/read-engine.d.ts +73 -0
- package/dist/runtime/read-engine.js +219 -0
- package/dist/runtime/schema-diff.d.ts +14 -0
- package/dist/runtime/schema-diff.js +41 -0
- package/dist/runtime/storage.d.ts +74 -0
- package/dist/runtime/storage.js +0 -0
- package/dist/sdk/acl.d.ts +130 -0
- package/dist/sdk/acl.js +55 -0
- package/dist/sdk/app.d.ts +7 -0
- package/dist/sdk/app.js +11 -0
- package/dist/sdk/files.d.ts +51 -0
- package/dist/sdk/files.js +4 -0
- package/dist/sdk/handlers.d.ts +36 -0
- package/dist/sdk/handlers.js +11 -0
- package/dist/sdk/infer.d.ts +79 -0
- package/dist/sdk/infer.js +5 -0
- package/dist/sdk/schema.d.ts +112 -0
- package/dist/sdk/schema.js +56 -0
- package/dist/worker-entry.d.ts +3 -0
- package/dist/worker-entry.js +8 -0
- package/dist/worker.d.ts +41 -0
- package/dist/worker.js +213 -0
- package/package.json +43 -0
- package/src/auth.ts +215 -0
- package/src/durable-object.ts +346 -0
- package/src/index.ts +77 -0
- package/src/pramen.ts +58 -0
- package/src/runtime/acl.ts +362 -0
- package/src/runtime/db.ts +550 -0
- package/src/runtime/ddl.ts +67 -0
- package/src/runtime/digest.ts +31 -0
- package/src/runtime/dispatch.ts +65 -0
- package/src/runtime/driver.ts +95 -0
- package/src/runtime/errors.ts +56 -0
- package/src/runtime/kv.ts +47 -0
- package/src/runtime/migrate.ts +193 -0
- package/src/runtime/protocol.ts +46 -0
- package/src/runtime/read-engine.ts +243 -0
- package/src/runtime/schema-diff.ts +57 -0
- package/src/runtime/storage.ts +0 -0
- package/src/sdk/acl.ts +196 -0
- package/src/sdk/app.ts +25 -0
- package/src/sdk/files.ts +53 -0
- package/src/sdk/handlers.ts +65 -0
- package/src/sdk/infer.ts +105 -0
- package/src/sdk/schema.ts +122 -0
- package/src/worker-entry.ts +9 -0
- package/src/worker.ts +253 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Dispatch — resolves a handler by name and runs it with a fresh, ACL-scoped Db.
|
|
2
|
+
// Mutations run inside storage.transaction(), which commits on success and rolls
|
|
3
|
+
// back on throw — the platform-correct way to auto-wrap mutations in BEGIN/COMMIT.
|
|
4
|
+
// (DO SQLite rejects raw BEGIN/COMMIT because it does atomic write coalescing under
|
|
5
|
+
// this API.) Single-writer serialization is free:
|
|
6
|
+
// a Durable Object processes one request at a time.
|
|
7
|
+
//
|
|
8
|
+
// The result reports `touched` (tables the run read or wrote) so the live-query
|
|
9
|
+
// layer can match a mutation's writes against each subscription's reads.
|
|
10
|
+
|
|
11
|
+
import { Db } from "./db";
|
|
12
|
+
import { warmup, type AclContext } from "./acl";
|
|
13
|
+
import { BadRequest } from "./errors";
|
|
14
|
+
import type { Driver } from "./driver";
|
|
15
|
+
import type { Kv } from "./kv";
|
|
16
|
+
import type { Files } from "../sdk/files";
|
|
17
|
+
import type { ResolverDb } from "../sdk/acl";
|
|
18
|
+
import type { SchemaDef } from "../sdk/schema";
|
|
19
|
+
import type { HandlerContext, HandlerKind, HandlerMap } from "../sdk/handlers";
|
|
20
|
+
|
|
21
|
+
export interface DispatchResult {
|
|
22
|
+
readonly result: unknown;
|
|
23
|
+
readonly kind: HandlerKind;
|
|
24
|
+
readonly touched: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function dispatch(
|
|
28
|
+
handlers: HandlerMap,
|
|
29
|
+
schema: SchemaDef,
|
|
30
|
+
driver: Driver,
|
|
31
|
+
kv: Kv,
|
|
32
|
+
files: Files,
|
|
33
|
+
env: Readonly<Record<string, unknown>>,
|
|
34
|
+
acl: AclContext,
|
|
35
|
+
name: string,
|
|
36
|
+
input: unknown,
|
|
37
|
+
): Promise<DispatchResult> {
|
|
38
|
+
const handler = handlers[name];
|
|
39
|
+
if (!handler) throw new BadRequest(`unknown handler: ${name}`);
|
|
40
|
+
|
|
41
|
+
// Validate/parse the request input at the boundary, if the handler declares it.
|
|
42
|
+
let parsed = input;
|
|
43
|
+
if (handler.input) {
|
|
44
|
+
try {
|
|
45
|
+
parsed = handler.input(input);
|
|
46
|
+
} catch (e) {
|
|
47
|
+
throw new BadRequest(e instanceof Error ? e.message : "invalid input");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Warmup: evaluate dynamic resolvers once, reading through a SYSTEM-mode db
|
|
52
|
+
// (separate from the handler's db, so its reads don't pollute `touched`).
|
|
53
|
+
const systemDb = new Db(driver, { acl: acl.acl, identity: acl.identity, system: true }, schema);
|
|
54
|
+
const resolved = await warmup(acl.acl, acl.identity, systemDb as unknown as ResolverDb);
|
|
55
|
+
|
|
56
|
+
const db = new Db(driver, { acl: acl.acl, identity: acl.identity, input: parsed, resolved }, schema);
|
|
57
|
+
const ctx: HandlerContext = { db, kv, files, env, identity: acl.identity };
|
|
58
|
+
|
|
59
|
+
const result =
|
|
60
|
+
handler.kind === "query"
|
|
61
|
+
? await handler.run(ctx, parsed)
|
|
62
|
+
: await driver.transaction(async () => handler.run(ctx, parsed));
|
|
63
|
+
|
|
64
|
+
return { result, kind: handler.kind, touched: [...db.touched] };
|
|
65
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Driver + Dialect — the substrate seam. pramen's ACL, read-engine, repository, and
|
|
2
|
+
// migrator are written against these two interfaces, so the same data layer runs
|
|
3
|
+
// over any SQL backend:
|
|
4
|
+
//
|
|
5
|
+
// - Driver : how to execute SQL and run a transaction (async). One per backend
|
|
6
|
+
// (DO SQLite, D1, Hyperdrive→Postgres, …).
|
|
7
|
+
// - Dialect : how a backend spells SQL (identifier quoting, bind placeholders,
|
|
8
|
+
// RETURNING support, value encoding). One per SQL flavor.
|
|
9
|
+
//
|
|
10
|
+
// This makes "Worker + D1" or "Worker + Hyperdrive/Postgres" a matter of plugging
|
|
11
|
+
// in a Driver, rather than rewriting the engine. Live queries remain a DO-only
|
|
12
|
+
// capability (they need a single writer + a stateful socket host).
|
|
13
|
+
|
|
14
|
+
export type Row = Record<string, unknown>;
|
|
15
|
+
|
|
16
|
+
export interface Dialect {
|
|
17
|
+
/** Render an identifier (table/column), quoting as the backend requires. */
|
|
18
|
+
id(name: string): string;
|
|
19
|
+
/** Bind placeholder for the `n`-th parameter (1-based): `?` (SQLite/MySQL) or `$n` (Postgres). */
|
|
20
|
+
placeholder(n: number): string;
|
|
21
|
+
/** Whether INSERT/UPDATE/DELETE ... RETURNING is supported (SQLite/Postgres yes; MySQL no). */
|
|
22
|
+
readonly returning: boolean;
|
|
23
|
+
/** Coerce a JS value for binding (e.g. boolean → 0/1 on SQLite). */
|
|
24
|
+
encode(v: unknown): unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
28
|
+
|
|
29
|
+
// Column/table names come from developer schema keys (and validated `where` keys),
|
|
30
|
+
// never raw user input — but we still guard the identifier shape before interpolating.
|
|
31
|
+
function checkIdent(name: string): string {
|
|
32
|
+
if (!IDENT_RE.test(name)) throw new Error(`invalid identifier: ${name}`);
|
|
33
|
+
return name;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** SQLite (DO SQLite and D1 both speak this). Bare identifiers, `?` placeholders,
|
|
37
|
+
* booleans stored as INTEGER 0/1, RETURNING supported. */
|
|
38
|
+
export const sqliteDialect: Dialect = {
|
|
39
|
+
id: checkIdent,
|
|
40
|
+
placeholder: () => "?",
|
|
41
|
+
returning: true,
|
|
42
|
+
encode: (v) => (typeof v === "boolean" ? (v ? 1 : 0) : v),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Postgres (e.g. over Hyperdrive). Double-quoted identifiers preserve case (so
|
|
46
|
+
* `ownerId` doesn't fold to `ownerid`), `$n` placeholders, native booleans,
|
|
47
|
+
* RETURNING supported. */
|
|
48
|
+
export const postgresDialect: Dialect = {
|
|
49
|
+
id: (name) => `"${checkIdent(name)}"`,
|
|
50
|
+
placeholder: (n) => `$${n}`,
|
|
51
|
+
returning: true,
|
|
52
|
+
encode: (v) => v, // the pg driver handles type encoding
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export interface Driver {
|
|
56
|
+
readonly dialect: Dialect;
|
|
57
|
+
/** Run a parameterized statement and return the result rows (empty for writes
|
|
58
|
+
* without RETURNING). Params are already dialect-encoded by the caller. */
|
|
59
|
+
exec(sql: string, params: unknown[]): Promise<Row[]>;
|
|
60
|
+
/** Run `fn` inside a transaction: commit on resolve, roll back on throw. */
|
|
61
|
+
transaction<T>(fn: () => Promise<T>): Promise<T>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** DO SQLite — the in-process store. `SqlStorage` is synchronous; we wrap it as an
|
|
65
|
+
* async Driver. Transactions use the DO's atomic `transaction()`. */
|
|
66
|
+
export class DoSqliteDriver implements Driver {
|
|
67
|
+
readonly dialect = sqliteDialect;
|
|
68
|
+
constructor(private readonly storage: DurableObjectStorage) {}
|
|
69
|
+
|
|
70
|
+
async exec(sql: string, params: unknown[]): Promise<Row[]> {
|
|
71
|
+
return this.storage.sql.exec(sql, ...params).toArray() as Row[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
transaction<T>(fn: () => Promise<T>): Promise<T> {
|
|
75
|
+
return this.storage.transaction(fn);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** D1 — SQLite over RPC. Async by nature. D1 has no interactive transactions, so
|
|
80
|
+
* `transaction()` runs `fn` without one (a documented limitation: mutations don't
|
|
81
|
+
* roll back on throw the way they do on a DO). Use a DO when you need that. */
|
|
82
|
+
export class D1Driver implements Driver {
|
|
83
|
+
readonly dialect = sqliteDialect;
|
|
84
|
+
constructor(private readonly db: D1Database) {}
|
|
85
|
+
|
|
86
|
+
async exec(sql: string, params: unknown[]): Promise<Row[]> {
|
|
87
|
+
const stmt = params.length ? this.db.prepare(sql).bind(...params) : this.db.prepare(sql);
|
|
88
|
+
const { results } = await stmt.all<Row>();
|
|
89
|
+
return results ?? [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
transaction<T>(fn: () => Promise<T>): Promise<T> {
|
|
93
|
+
return fn();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Error model. Anything that is the *client's* fault carries a status + code and
|
|
2
|
+
// a message safe to return. Everything else is logged server-side and surfaced as
|
|
3
|
+
// a generic 500 — internal messages / stack traces never reach the client.
|
|
4
|
+
|
|
5
|
+
export class PramenError extends Error {
|
|
6
|
+
constructor(
|
|
7
|
+
message: string,
|
|
8
|
+
readonly status: number,
|
|
9
|
+
readonly code: string,
|
|
10
|
+
) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "PramenError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class BadRequest extends PramenError {
|
|
17
|
+
constructor(message: string) {
|
|
18
|
+
super(message, 400, "bad_request");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 401 — the caller is unauthenticated (no/invalid identity). */
|
|
23
|
+
export class Unauthorized extends PramenError {
|
|
24
|
+
constructor(message = "authentication required") {
|
|
25
|
+
super(message, 401, "unauthorized");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 403 — authenticated but not permitted. For handler-level checks; the Db
|
|
30
|
+
* chokepoint raises AclDenied for row/field ACL. */
|
|
31
|
+
export class Forbidden extends PramenError {
|
|
32
|
+
constructor(message = "forbidden") {
|
|
33
|
+
super(message, 403, "forbidden");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ErrorBody {
|
|
38
|
+
ok: false;
|
|
39
|
+
error: string;
|
|
40
|
+
code: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function classify(err: unknown): { status: number; body: ErrorBody } {
|
|
44
|
+
if (err instanceof PramenError) {
|
|
45
|
+
return { status: err.status, body: { ok: false, error: err.message, code: err.code } };
|
|
46
|
+
}
|
|
47
|
+
console.error("pramen: unhandled error", err);
|
|
48
|
+
return { status: 500, body: { ok: false, error: "internal error", code: "internal" } };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const toResponse = classify;
|
|
52
|
+
|
|
53
|
+
export function toWsError(id: string, err: unknown): { type: "error"; id: string; error: string; code: string } {
|
|
54
|
+
const { body } = classify(err);
|
|
55
|
+
return { type: "error", id, error: body.error, code: body.code };
|
|
56
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Kv — a thin, prefixed wrapper over the project's Workers KV namespace, handed
|
|
2
|
+
// to handlers as ctx.kv.
|
|
3
|
+
//
|
|
4
|
+
// Two levels of namespacing keep things isolated:
|
|
5
|
+
// - Across projects: each project declares its own KV namespace in oblaka.ts
|
|
6
|
+
// (named per project), so projects in one account never share a namespace.
|
|
7
|
+
// - Within the namespace: keys are prefixed (`app:` for handler data) so they
|
|
8
|
+
// never collide with pramen-internal keys (the tenant registry uses `tenant:`).
|
|
9
|
+
//
|
|
10
|
+
// ctx.kv is GLOBAL across all tenants of the project — use it for config, feature
|
|
11
|
+
// flags, and caches, NOT per-tenant data (that's ctx.db). KV is eventually
|
|
12
|
+
// consistent and is NOT part of a mutation's transaction.
|
|
13
|
+
|
|
14
|
+
export class Kv {
|
|
15
|
+
constructor(
|
|
16
|
+
private readonly ns: KVNamespace,
|
|
17
|
+
private readonly prefix = "app:",
|
|
18
|
+
) {}
|
|
19
|
+
|
|
20
|
+
private full(key: string): string {
|
|
21
|
+
return this.prefix + key;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get(key: string): Promise<string | null>;
|
|
25
|
+
get(key: string, type: "json"): Promise<unknown>;
|
|
26
|
+
get(key: string, type?: "json"): Promise<string | null | unknown> {
|
|
27
|
+
return type === "json" ? this.ns.get(this.full(key), "json") : this.ns.get(this.full(key), "text");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async put(key: string, value: string, opts?: { expirationTtl?: number; expiration?: number }): Promise<void> {
|
|
31
|
+
await this.ns.put(this.full(key), value, opts);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async delete(key: string): Promise<void> {
|
|
35
|
+
await this.ns.delete(this.full(key));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** List keys under an (app-relative) prefix; returned names have the internal
|
|
39
|
+
* prefix stripped. cursor is null when the listing is complete. */
|
|
40
|
+
async list(opts?: { prefix?: string; limit?: number; cursor?: string }): Promise<{ keys: string[]; cursor: string | null }> {
|
|
41
|
+
const res = await this.ns.list({ prefix: this.full(opts?.prefix ?? ""), limit: opts?.limit, cursor: opts?.cursor });
|
|
42
|
+
return {
|
|
43
|
+
keys: res.keys.map((k) => k.name.slice(this.prefix.length)),
|
|
44
|
+
cursor: res.list_complete ? null : ((res as { cursor?: string }).cursor ?? null),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Schema migration — applied on boot, wrapped in a transaction by the caller.
|
|
2
|
+
// Runs over a Driver, so it works on any SQLite-flavored substrate (DO SQLite and
|
|
3
|
+
// D1). The introspection (`PRAGMA table_info`, `sqlite_master`) and table-rebuild
|
|
4
|
+
// are SQLite-specific; a Postgres/MySQL migrator would be a separate adapter.
|
|
5
|
+
// Reconciles the live store with the declared schema in two passes:
|
|
6
|
+
// 1. additive (no data loss): missing table -> CREATE TABLE; missing column ->
|
|
7
|
+
// ALTER TABLE ADD COLUMN (nullable).
|
|
8
|
+
// 2. destructive: a live column the schema no longer declares is DROPPED, a type
|
|
9
|
+
// change is applied, and a `renamedFrom` column is renamed — all via the
|
|
10
|
+
// standard SQLite table-rebuild (create new, copy, drop old, rename). This is
|
|
11
|
+
// auto-applied: a bad deploy CAN lose data, by design (WIP, no backward-compat).
|
|
12
|
+
//
|
|
13
|
+
// A schema hash in the internal `_pramen_meta` table lets an unchanged schema skip
|
|
14
|
+
// introspection entirely on warm boots. The live table (PRAGMA) is the ground
|
|
15
|
+
// truth diffed against the schema — no stored shape needed.
|
|
16
|
+
//
|
|
17
|
+
// ADD COLUMN is always nullable (SQLite can't add NOT NULL to a populated table).
|
|
18
|
+
// A rename can't be inferred from a diff (a removed + added column is ambiguous),
|
|
19
|
+
// so it must be declared with `renamedFrom`; otherwise it is applied as drop+add.
|
|
20
|
+
|
|
21
|
+
import { addColumnSql, createTableSql, indexStatements, sqlType } from "./ddl";
|
|
22
|
+
import { digest } from "./digest";
|
|
23
|
+
import type { Driver } from "./driver";
|
|
24
|
+
import type { EntityFields, FieldDef, SchemaDef } from "../sdk/schema";
|
|
25
|
+
|
|
26
|
+
export interface MigrationReport {
|
|
27
|
+
changed: boolean;
|
|
28
|
+
created: string[];
|
|
29
|
+
added: string[];
|
|
30
|
+
/** Tables rebuilt to apply a drop / rename / type change. */
|
|
31
|
+
rebuilt: string[];
|
|
32
|
+
/** Tables dropped because the schema no longer declares them. */
|
|
33
|
+
droppedTables: string[];
|
|
34
|
+
/** Destructive ops detected but NOT applied because destructive migrations are
|
|
35
|
+
* disabled (the default). Re-deploy with allowDestructive to apply them. */
|
|
36
|
+
skipped: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface MigrateOptions {
|
|
40
|
+
/** Apply destructive changes (drop/rebuild/type-change/table-drop). Off by default
|
|
41
|
+
* — data-loss is gated behind an explicit opt-in (env `PRAMEN_ALLOW_DESTRUCTIVE`).
|
|
42
|
+
* Additive changes (create table, add column, add index) always apply. */
|
|
43
|
+
allowDestructive?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Internal bookkeeping tables the migrator must never touch — pramen's own, SQLite's,
|
|
47
|
+
* and the substrate's (D1 keeps `_cf_*` / `d1_*` tables in sqlite_master and forbids
|
|
48
|
+
* dropping them). Matched case-insensitively. */
|
|
49
|
+
function isInternalTable(name: string): boolean {
|
|
50
|
+
const n = name.toLowerCase();
|
|
51
|
+
return (
|
|
52
|
+
n.startsWith("_pramen") ||
|
|
53
|
+
n.startsWith("__pramen") ||
|
|
54
|
+
n.startsWith("sqlite_") ||
|
|
55
|
+
n.startsWith("_cf_") ||
|
|
56
|
+
n.startsWith("d1_")
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ident(name: string): string {
|
|
61
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error(`invalid identifier: ${name}`);
|
|
62
|
+
return name;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function schemaHash(schema: SchemaDef): string {
|
|
66
|
+
const canon: Record<string, unknown> = {};
|
|
67
|
+
for (const [table, def] of Object.entries(schema)) canon[table] = def.fields;
|
|
68
|
+
return digest(canon);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Live columns of a table -> their declared SQL type (uppercased). Empty if the
|
|
72
|
+
* table doesn't exist. */
|
|
73
|
+
async function tableColumns(driver: Driver, table: string): Promise<Map<string, string>> {
|
|
74
|
+
const rows = (await driver.exec(`PRAGMA table_info(${ident(table)})`, [])) as { name: string; type: string }[];
|
|
75
|
+
return new Map(rows.map((r) => [r.name, (r.type || "").toUpperCase()]));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function readMeta(driver: Driver, key: string): Promise<string | undefined> {
|
|
79
|
+
const rows = (await driver.exec(`SELECT value FROM _pramen_meta WHERE key = ?`, [key])) as { value: string }[];
|
|
80
|
+
return rows[0]?.value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function writeMeta(driver: Driver, key: string, value: string): Promise<void> {
|
|
84
|
+
await driver.exec(`INSERT OR REPLACE INTO _pramen_meta (key, value) VALUES (?, ?)`, [key, value]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Rebuild a table to exactly the declared schema: create a temp table, copy each
|
|
88
|
+
* desired column from its source (renamed or same-named live column, CAST on a
|
|
89
|
+
* type change; brand-new columns left NULL), drop the old table, rename the temp. */
|
|
90
|
+
async function rebuildTable(driver: Driver, table: string, def: { fields: EntityFields }, live: Map<string, string>): Promise<void> {
|
|
91
|
+
const tmp = `__pramen_rebuild_${table}`;
|
|
92
|
+
await driver.exec(`DROP TABLE IF EXISTS ${ident(tmp)}`, []);
|
|
93
|
+
await driver.exec(createTableSql(tmp, def), []);
|
|
94
|
+
|
|
95
|
+
const destCols: string[] = [];
|
|
96
|
+
const srcExprs: string[] = [];
|
|
97
|
+
for (const [name, field] of Object.entries(def.fields)) {
|
|
98
|
+
const f = field as FieldDef;
|
|
99
|
+
const src = f.renamedFrom && live.has(f.renamedFrom) ? f.renamedFrom : live.has(name) ? name : undefined;
|
|
100
|
+
if (!src) continue; // brand-new column with no source -> leave NULL
|
|
101
|
+
const target = sqlType(f);
|
|
102
|
+
destCols.push(ident(name));
|
|
103
|
+
srcExprs.push(live.get(src) === target ? ident(src) : `CAST(${ident(src)} AS ${target})`);
|
|
104
|
+
}
|
|
105
|
+
if (destCols.length > 0) {
|
|
106
|
+
await driver.exec(`INSERT INTO ${ident(tmp)} (${destCols.join(", ")}) SELECT ${srcExprs.join(", ")} FROM ${ident(table)}`, []);
|
|
107
|
+
}
|
|
108
|
+
await driver.exec(`DROP TABLE ${ident(table)}`, []);
|
|
109
|
+
await driver.exec(`ALTER TABLE ${ident(tmp)} RENAME TO ${ident(table)}`, []);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function migrate(driver: Driver, schema: SchemaDef, opts: MigrateOptions = {}): Promise<MigrationReport> {
|
|
113
|
+
await driver.exec(`CREATE TABLE IF NOT EXISTS _pramen_meta (key TEXT PRIMARY KEY, value TEXT)`, []);
|
|
114
|
+
const allowDestructive = opts.allowDestructive ?? false;
|
|
115
|
+
|
|
116
|
+
const current = schemaHash(schema);
|
|
117
|
+
if ((await readMeta(driver, "schema_hash")) === current)
|
|
118
|
+
return { changed: false, created: [], added: [], rebuilt: [], droppedTables: [], skipped: [] };
|
|
119
|
+
|
|
120
|
+
const created: string[] = [];
|
|
121
|
+
const added: string[] = [];
|
|
122
|
+
const rebuilt: string[] = [];
|
|
123
|
+
const droppedTables: string[] = [];
|
|
124
|
+
const skipped: string[] = [];
|
|
125
|
+
|
|
126
|
+
for (const [table, def] of Object.entries(schema)) {
|
|
127
|
+
const existing = await tableColumns(driver, table);
|
|
128
|
+
if (existing.size === 0) {
|
|
129
|
+
await driver.exec(createTableSql(table, def), []);
|
|
130
|
+
created.push(table);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
// Pass 1 — additive: add any column the schema declares but the table lacks.
|
|
134
|
+
for (const [name, field] of Object.entries(def.fields)) {
|
|
135
|
+
if (existing.has(name)) continue;
|
|
136
|
+
await driver.exec(`ALTER TABLE ${ident(table)} ADD COLUMN ${addColumnSql(name, field as FieldDef)}`, []);
|
|
137
|
+
added.push(`${table}.${name}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Pass 2 — destructive: rebuild if any live column must be dropped, a declared
|
|
141
|
+
// column changed type, or a rename hint points at an existing live column.
|
|
142
|
+
const live = await tableColumns(driver, table); // re-read (now includes additively-added columns)
|
|
143
|
+
const desired = new Set(Object.keys(def.fields));
|
|
144
|
+
const renamedSources = new Set<string>();
|
|
145
|
+
for (const f of Object.values(def.fields)) {
|
|
146
|
+
const from = (f as FieldDef).renamedFrom;
|
|
147
|
+
if (from && live.has(from)) renamedSources.add(from);
|
|
148
|
+
}
|
|
149
|
+
const needsDrop = [...live.keys()].some((c) => !desired.has(c) && !renamedSources.has(c));
|
|
150
|
+
const needsTypeChange = Object.entries(def.fields).some(
|
|
151
|
+
([n, f]) => live.has(n) && live.get(n) !== sqlType(f as FieldDef),
|
|
152
|
+
);
|
|
153
|
+
if (needsDrop || needsTypeChange || renamedSources.size > 0) {
|
|
154
|
+
if (allowDestructive) {
|
|
155
|
+
await rebuildTable(driver, table, def, live);
|
|
156
|
+
rebuilt.push(table);
|
|
157
|
+
} else {
|
|
158
|
+
skipped.push(`rebuild ${table} (drop/type-change/rename)`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Ensure unique/index declarations (idempotent, via IF NOT EXISTS). Indexes can be
|
|
164
|
+
// added to an existing table without a rebuild; a stale index from a removed
|
|
165
|
+
// declaration is left in place (cleanup is future work).
|
|
166
|
+
for (const [table, def] of Object.entries(schema)) {
|
|
167
|
+
for (const stmt of indexStatements(table, def)) await driver.exec(stmt, []);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Drop tables the schema no longer declares (internal bookkeeping tables skipped).
|
|
171
|
+
const liveTables = (await driver.exec(`SELECT name FROM sqlite_master WHERE type = 'table'`, [])) as { name: string }[];
|
|
172
|
+
for (const { name } of liveTables) {
|
|
173
|
+
if (isInternalTable(name) || name in schema) continue;
|
|
174
|
+
if (allowDestructive) {
|
|
175
|
+
await driver.exec(`DROP TABLE ${ident(name)}`, []);
|
|
176
|
+
droppedTables.push(name);
|
|
177
|
+
} else {
|
|
178
|
+
skipped.push(`drop table ${name}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Only record the schema as applied when fully reconciled. If destructive changes
|
|
183
|
+
// were skipped, leave the hash so a later deploy (with allowDestructive) retries —
|
|
184
|
+
// additive work is idempotent, so re-running is safe.
|
|
185
|
+
if (skipped.length === 0) {
|
|
186
|
+
await writeMeta(driver, "schema_hash", current);
|
|
187
|
+
} else {
|
|
188
|
+
console.warn(
|
|
189
|
+
`pramen: ${skipped.length} destructive migration(s) skipped (set PRAMEN_ALLOW_DESTRUCTIVE=true to apply): ${skipped.join("; ")}`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return { changed: true, created, added, rebuilt, droppedTables, skipped };
|
|
193
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Live-query wire protocol (JSON over WebSocket).
|
|
2
|
+
//
|
|
3
|
+
// Client -> server:
|
|
4
|
+
// { type: "subscribe", id, name, input? } // query handler; initial data + pushes
|
|
5
|
+
// { type: "unsubscribe", id }
|
|
6
|
+
// { type: "call", id, name, input? } // one-shot any handler (query or mutation)
|
|
7
|
+
//
|
|
8
|
+
// Server -> client:
|
|
9
|
+
// { type: "data", id, result } // initial subscription result + every update
|
|
10
|
+
// { type: "result", id, result } // reply to a one-shot call
|
|
11
|
+
// { type: "error", id, error }
|
|
12
|
+
|
|
13
|
+
export interface SubscribeMsg {
|
|
14
|
+
type: "subscribe";
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
input?: unknown;
|
|
18
|
+
}
|
|
19
|
+
export interface UnsubscribeMsg {
|
|
20
|
+
type: "unsubscribe";
|
|
21
|
+
id: string;
|
|
22
|
+
}
|
|
23
|
+
export interface CallMsg {
|
|
24
|
+
type: "call";
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
input?: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type ClientMsg = SubscribeMsg | UnsubscribeMsg | CallMsg;
|
|
31
|
+
|
|
32
|
+
export type ServerMsg =
|
|
33
|
+
| { type: "data"; id: string; result: unknown }
|
|
34
|
+
| { type: "result"; id: string; result: unknown }
|
|
35
|
+
| { type: "error"; id: string; error: string };
|
|
36
|
+
|
|
37
|
+
/** A live subscription, persisted on the socket so it survives DO hibernation. */
|
|
38
|
+
export interface Subscription {
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
input: unknown;
|
|
42
|
+
/** Tables the query read — the coarse prefilter for which writes might matter. */
|
|
43
|
+
tables: string[];
|
|
44
|
+
/** Digest of the last result pushed — used to suppress no-op (row-level) pushes. */
|
|
45
|
+
digest: string;
|
|
46
|
+
}
|