@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.
Files changed (76) hide show
  1. package/dist/auth.d.ts +35 -0
  2. package/dist/auth.js +189 -0
  3. package/dist/durable-object.d.ts +48 -0
  4. package/dist/durable-object.js +282 -0
  5. package/dist/index.d.ts +14 -0
  6. package/dist/index.js +20 -0
  7. package/dist/pramen.d.ts +42 -0
  8. package/dist/pramen.js +19 -0
  9. package/dist/runtime/acl.d.ts +62 -0
  10. package/dist/runtime/acl.js +289 -0
  11. package/dist/runtime/db.d.ts +139 -0
  12. package/dist/runtime/db.js +425 -0
  13. package/dist/runtime/ddl.d.ts +16 -0
  14. package/dist/runtime/ddl.js +64 -0
  15. package/dist/runtime/digest.d.ts +1 -0
  16. package/dist/runtime/digest.js +29 -0
  17. package/dist/runtime/dispatch.d.ts +12 -0
  18. package/dist/runtime/dispatch.js +37 -0
  19. package/dist/runtime/driver.d.ts +45 -0
  20. package/dist/runtime/driver.js +70 -0
  21. package/dist/runtime/errors.d.ts +34 -0
  22. package/dist/runtime/errors.js +43 -0
  23. package/dist/runtime/kv.d.ts +23 -0
  24. package/dist/runtime/kv.js +41 -0
  25. package/dist/runtime/migrate.d.ts +22 -0
  26. package/dist/runtime/migrate.js +158 -0
  27. package/dist/runtime/protocol.d.ts +40 -0
  28. package/dist/runtime/protocol.js +12 -0
  29. package/dist/runtime/read-engine.d.ts +73 -0
  30. package/dist/runtime/read-engine.js +219 -0
  31. package/dist/runtime/schema-diff.d.ts +14 -0
  32. package/dist/runtime/schema-diff.js +41 -0
  33. package/dist/runtime/storage.d.ts +74 -0
  34. package/dist/runtime/storage.js +0 -0
  35. package/dist/sdk/acl.d.ts +130 -0
  36. package/dist/sdk/acl.js +55 -0
  37. package/dist/sdk/app.d.ts +7 -0
  38. package/dist/sdk/app.js +11 -0
  39. package/dist/sdk/files.d.ts +51 -0
  40. package/dist/sdk/files.js +4 -0
  41. package/dist/sdk/handlers.d.ts +36 -0
  42. package/dist/sdk/handlers.js +11 -0
  43. package/dist/sdk/infer.d.ts +79 -0
  44. package/dist/sdk/infer.js +5 -0
  45. package/dist/sdk/schema.d.ts +112 -0
  46. package/dist/sdk/schema.js +56 -0
  47. package/dist/worker-entry.d.ts +3 -0
  48. package/dist/worker-entry.js +8 -0
  49. package/dist/worker.d.ts +41 -0
  50. package/dist/worker.js +213 -0
  51. package/package.json +43 -0
  52. package/src/auth.ts +215 -0
  53. package/src/durable-object.ts +346 -0
  54. package/src/index.ts +77 -0
  55. package/src/pramen.ts +58 -0
  56. package/src/runtime/acl.ts +362 -0
  57. package/src/runtime/db.ts +550 -0
  58. package/src/runtime/ddl.ts +67 -0
  59. package/src/runtime/digest.ts +31 -0
  60. package/src/runtime/dispatch.ts +65 -0
  61. package/src/runtime/driver.ts +95 -0
  62. package/src/runtime/errors.ts +56 -0
  63. package/src/runtime/kv.ts +47 -0
  64. package/src/runtime/migrate.ts +193 -0
  65. package/src/runtime/protocol.ts +46 -0
  66. package/src/runtime/read-engine.ts +243 -0
  67. package/src/runtime/schema-diff.ts +57 -0
  68. package/src/runtime/storage.ts +0 -0
  69. package/src/sdk/acl.ts +196 -0
  70. package/src/sdk/app.ts +25 -0
  71. package/src/sdk/files.ts +53 -0
  72. package/src/sdk/handlers.ts +65 -0
  73. package/src/sdk/infer.ts +105 -0
  74. package/src/sdk/schema.ts +122 -0
  75. package/src/worker-entry.ts +9 -0
  76. package/src/worker.ts +253 -0
package/dist/auth.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ import type { Identity } from "./sdk/acl";
2
+ /** Verifies a JWT and returns its claims, or null if invalid. Implementations
3
+ * differ only in how they verify the signature. */
4
+ export interface VerifyStrategy {
5
+ verify(token: string): Promise<Record<string, unknown> | null>;
6
+ }
7
+ /** HS256 via a shared secret. The dev/default strategy. */
8
+ export declare class HmacStrategy implements VerifyStrategy {
9
+ private readonly secret;
10
+ constructor(secret: string);
11
+ verify(token: string): Promise<Record<string, unknown> | null>;
12
+ }
13
+ /** RS256 verified against a remote JWKS. Public keys are fetched once and cached
14
+ * (TTL); a token with an unknown `kid` triggers one forced refetch to pick up key
15
+ * rotation. Stale keys are kept if a refetch fails. */
16
+ export declare class JwksStrategy implements VerifyStrategy {
17
+ readonly url: string;
18
+ private readonly ttlMs;
19
+ private keys;
20
+ private fetchedAt;
21
+ private inflight;
22
+ constructor(url: string, ttlMs?: number);
23
+ verify(token: string): Promise<Record<string, unknown> | null>;
24
+ private lookup;
25
+ private keyFor;
26
+ private refresh;
27
+ private fetchKeys;
28
+ }
29
+ export declare function resolveIdentity(request: Request, strategy: VerifyStrategy): Promise<Identity | null>;
30
+ /** May this identity address the given tenant? Gates `X-Pramen-Tenant` so a caller
31
+ * can't reach (or register) arbitrary tenants. Default policy: admins → any
32
+ * tenant; everyone else → only tenants listed in their `tenants` claim. Customize
33
+ * for your tenancy model (e.g. tenant === identity.org, or a lookup). */
34
+ export declare function authorizeTenant(identity: Identity | null, tenant: string): boolean;
35
+ export declare function isAdmin(identity: Identity | null): boolean;
package/dist/auth.js ADDED
@@ -0,0 +1,189 @@
1
+ // Identity resolution at the edge. The Worker verifies a signed token and
2
+ // forwards a trusted identity to the DO; the DO never re-derives it, and the
3
+ // client-supplied X-Pramen-Identity header is stripped unless a token verified
4
+ // (see src/index.ts), so a validly-signed JWT is the only path to an identity.
5
+ //
6
+ // Verification is pluggable via VerifyStrategy: HmacStrategy (HS256, shared secret
7
+ // in env.AUTH_SECRET) for dev/symmetric setups, JwksStrategy (RS256 against a remote
8
+ // JWKS, with key caching) for real identity providers. The header parse, exp/nbf
9
+ // checks, and claim->Identity mapping are shared; only signature verification
10
+ // differs. Claims map to Identity: `sub` -> userId, `roles`/`role` -> roles, other
11
+ // non-standard claims pass through.
12
+ const STANDARD_CLAIMS = new Set(["exp", "iat", "nbf", "iss", "aud", "jti", "sub", "role", "roles", "userId"]);
13
+ function b64urlToBytes(s) {
14
+ const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (s.length % 4)) % 4);
15
+ const bin = atob(b64);
16
+ const out = new Uint8Array(bin.length);
17
+ for (let i = 0; i < bin.length; i++)
18
+ out[i] = bin.charCodeAt(i);
19
+ return out;
20
+ }
21
+ function b64urlToString(s) {
22
+ return new TextDecoder().decode(b64urlToBytes(s));
23
+ }
24
+ // Shared JWT pipeline: parse, verify the signature via the supplied function, then
25
+ // validate exp/nbf. Any malformed part or a verification throw -> null (reject).
26
+ async function verifyJwt(token, verifySignature) {
27
+ const parts = token.split(".");
28
+ if (parts.length !== 3)
29
+ return null;
30
+ const [h, p, sig] = parts;
31
+ let header;
32
+ try {
33
+ header = JSON.parse(b64urlToString(h));
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ let valid;
39
+ try {
40
+ valid = await verifySignature(`${h}.${p}`, b64urlToBytes(sig), header);
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ if (!valid)
46
+ return null;
47
+ let payload;
48
+ try {
49
+ payload = JSON.parse(b64urlToString(p));
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ const now = Math.floor(Date.now() / 1000);
55
+ if (typeof payload.exp === "number" && now >= payload.exp)
56
+ return null;
57
+ if (typeof payload.nbf === "number" && now < payload.nbf)
58
+ return null;
59
+ return payload;
60
+ }
61
+ /** HS256 via a shared secret. The dev/default strategy. */
62
+ export class HmacStrategy {
63
+ secret;
64
+ constructor(secret) {
65
+ this.secret = secret;
66
+ }
67
+ verify(token) {
68
+ return verifyJwt(token, async (input, signature, header) => {
69
+ if (header.alg !== "HS256" || !this.secret)
70
+ return false;
71
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(this.secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
72
+ return crypto.subtle.verify("HMAC", key, signature, new TextEncoder().encode(input));
73
+ });
74
+ }
75
+ }
76
+ const SINGLE_KEY = "\0single";
77
+ /** RS256 verified against a remote JWKS. Public keys are fetched once and cached
78
+ * (TTL); a token with an unknown `kid` triggers one forced refetch to pick up key
79
+ * rotation. Stale keys are kept if a refetch fails. */
80
+ export class JwksStrategy {
81
+ url;
82
+ ttlMs;
83
+ keys = new Map();
84
+ fetchedAt = 0;
85
+ inflight = null;
86
+ constructor(url, ttlMs = 600_000) {
87
+ this.url = url;
88
+ this.ttlMs = ttlMs;
89
+ }
90
+ verify(token) {
91
+ return verifyJwt(token, async (input, signature, header) => {
92
+ if (header.alg !== "RS256")
93
+ return false;
94
+ const key = await this.keyFor(header.kid);
95
+ if (!key)
96
+ return false;
97
+ return crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signature, new TextEncoder().encode(input));
98
+ });
99
+ }
100
+ lookup(kid) {
101
+ if (kid)
102
+ return this.keys.get(kid) ?? null;
103
+ if (this.keys.size === 1)
104
+ return [...this.keys.values()][0]; // no kid + single key
105
+ return this.keys.get(SINGLE_KEY) ?? null;
106
+ }
107
+ async keyFor(kid) {
108
+ await this.refresh(false);
109
+ let key = this.lookup(kid);
110
+ if (!key) {
111
+ await this.refresh(true); // unknown kid -> force a refetch (key rotation)
112
+ key = this.lookup(kid);
113
+ }
114
+ return key;
115
+ }
116
+ async refresh(force) {
117
+ if (!force && this.keys.size > 0 && Date.now() - this.fetchedAt < this.ttlMs)
118
+ return;
119
+ if (this.inflight)
120
+ return this.inflight;
121
+ this.inflight = this.fetchKeys();
122
+ try {
123
+ await this.inflight;
124
+ }
125
+ finally {
126
+ this.inflight = null;
127
+ }
128
+ }
129
+ async fetchKeys() {
130
+ try {
131
+ const res = await fetch(this.url);
132
+ if (!res.ok)
133
+ return; // keep stale keys
134
+ const body = (await res.json());
135
+ const next = new Map();
136
+ for (const jwk of body.keys ?? []) {
137
+ if (jwk.kty !== "RSA")
138
+ continue;
139
+ const key = await crypto.subtle.importKey("jwk", jwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"]);
140
+ next.set(jwk.kid ?? SINGLE_KEY, key);
141
+ }
142
+ if (next.size > 0) {
143
+ this.keys = next;
144
+ this.fetchedAt = Date.now();
145
+ }
146
+ }
147
+ catch {
148
+ // network error -> keep whatever keys we have
149
+ }
150
+ }
151
+ }
152
+ function toIdentity(claims) {
153
+ const roles = Array.isArray(claims.roles)
154
+ ? claims.roles
155
+ : typeof claims.role === "string"
156
+ ? [claims.role]
157
+ : [];
158
+ const identity = { roles, userId: (claims.sub ?? claims.userId) };
159
+ for (const [k, v] of Object.entries(claims)) {
160
+ if (!STANDARD_CLAIMS.has(k))
161
+ identity[k] = v; // carry custom claims (tier, …)
162
+ }
163
+ return identity;
164
+ }
165
+ export async function resolveIdentity(request, strategy) {
166
+ const token = request.headers.get("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1];
167
+ if (!token)
168
+ return null;
169
+ const claims = await strategy.verify(token);
170
+ return claims ? toIdentity(claims) : null;
171
+ }
172
+ /** May this identity address the given tenant? Gates `X-Pramen-Tenant` so a caller
173
+ * can't reach (or register) arbitrary tenants. Default policy: admins → any
174
+ * tenant; everyone else → only tenants listed in their `tenants` claim. Customize
175
+ * for your tenancy model (e.g. tenant === identity.org, or a lookup). */
176
+ export function authorizeTenant(identity, tenant) {
177
+ // Anonymous (no verified token) may reach only the default tenant — enough for
178
+ // first-class public flows (the `anonymous` ACL role still gates the data), while
179
+ // not letting unauthenticated callers address/register arbitrary tenants.
180
+ if (!identity)
181
+ return tenant === "main";
182
+ if (identity.roles?.includes("admin"))
183
+ return true;
184
+ const allowed = Array.isArray(identity.tenants) ? identity.tenants : [];
185
+ return allowed.includes(tenant);
186
+ }
187
+ export function isAdmin(identity) {
188
+ return identity?.roles?.includes("admin") ?? false;
189
+ }
@@ -0,0 +1,48 @@
1
+ import { DurableObject } from "cloudflare:workers";
2
+ import type { PramenApp } from "./pramen";
3
+ export interface DoEnv {
4
+ /** Project KV — tenant registry (`tenant:`) + handler ctx.kv (`app:`). */
5
+ KV: KVNamespace;
6
+ /** R2 bucket backing ctx.files + the Worker /files/* route. */
7
+ FILES: R2Bucket;
8
+ /** HMAC secret for signing file upload/download tokens (falls back to AUTH_SECRET). */
9
+ FILES_SECRET?: string;
10
+ /** Bearer-JWT secret; also the fallback for signing file tokens. */
11
+ AUTH_SECRET?: string;
12
+ /** "true" to apply destructive schema migrations (drop/rebuild/type-change). Off by
13
+ * default — data-loss is gated behind this explicit opt-in. */
14
+ PRAMEN_ALLOW_DESTRUCTIVE?: string;
15
+ }
16
+ export declare class PramenDOBase extends DurableObject<DoEnv> {
17
+ private readonly app;
18
+ private readonly acl;
19
+ private readonly kv;
20
+ private readonly driver;
21
+ private registered;
22
+ /** Tenant this DO serves (one per idFromName). Learned from the Worker-forwarded
23
+ * x-pramen-tenant header; defaults to "main". */
24
+ private tenant;
25
+ private files?;
26
+ constructor(ctx: DurableObjectState, env: DoEnv, app: PramenApp);
27
+ fetch(request: Request): Promise<Response>;
28
+ webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise<void>;
29
+ webSocketClose(ws: WebSocket): Promise<void>;
30
+ webSocketError(ws: WebSocket, error: unknown): Promise<void>;
31
+ private onSubscribe;
32
+ private onCall;
33
+ private broadcast;
34
+ private ensureRegistered;
35
+ private handleRecover;
36
+ private handleSchema;
37
+ private ctxFor;
38
+ private get envBag();
39
+ private filesFor;
40
+ private identityOf;
41
+ private getState;
42
+ private setState;
43
+ private send;
44
+ }
45
+ /** Produce the concrete, app-bound Durable Object class. A DO is constructed by the
46
+ * platform with just (ctx, env), so the app is closed over here. Re-export the
47
+ * result from your Worker entry under the class name wrangler expects ("PramenDO"). */
48
+ export declare function pramenDO(app: PramenApp): typeof PramenDOBase;
@@ -0,0 +1,282 @@
1
+ // PramenDOBase — the database. One instance per tenant (see worker.ts routing).
2
+ // Holds the SQLite store in-process, applies the schema on boot, dispatches
3
+ // handler RPCs over HTTP, and serves live queries over WebSockets. The concrete,
4
+ // app-bound class is produced by pramenDO(app) (and createPramen) — the DO can't
5
+ // take constructor args beyond (ctx, env), so the app is closed over.
6
+ //
7
+ // ACL: policies are compiled once on boot. Identity is resolved by the Worker
8
+ // and forwarded in the X-Pramen-Identity header. For HTTP it's per request; for a
9
+ // WebSocket it's fixed at connect time and stored on the socket, so live queries
10
+ // are evaluated per-identity (row-level scopes apply to pushes too).
11
+ //
12
+ // Reactivity: the DO is the single writer, so it sees every mutation. After a
13
+ // mutation commits we re-run each subscription whose read-set intersects the
14
+ // written tables, but push only when that subscription's result actually changed
15
+ // (digest diff). Connections use Hibernatable WebSockets; per-socket state
16
+ // (identity + subscriptions) is stored via serializeAttachment().
17
+ import { DurableObject } from "cloudflare:workers";
18
+ import { migrate } from "./runtime/migrate";
19
+ import { dispatch } from "./runtime/dispatch";
20
+ import { digest } from "./runtime/digest";
21
+ import { compileAcl } from "./runtime/acl";
22
+ import { DoSqliteDriver } from "./runtime/driver";
23
+ import { BadRequest, toResponse, toWsError } from "./runtime/errors";
24
+ import { Kv } from "./runtime/kv";
25
+ import { createFiles, R2Adapter } from "./runtime/storage";
26
+ /** Per-socket subscription cap — bounds memory and per-mutation re-run cost. */
27
+ const MAX_SUBSCRIPTIONS = 64;
28
+ export class PramenDOBase extends DurableObject {
29
+ app;
30
+ acl;
31
+ kv;
32
+ driver;
33
+ registered = false;
34
+ /** Tenant this DO serves (one per idFromName). Learned from the Worker-forwarded
35
+ * x-pramen-tenant header; defaults to "main". */
36
+ tenant = "main";
37
+ files;
38
+ constructor(ctx, env, app) {
39
+ super(ctx, env);
40
+ this.app = app;
41
+ this.acl = compileAcl(this.app.acl ?? []);
42
+ this.kv = new Kv(env.KV); // app:-prefixed, handed to handlers as ctx.kv
43
+ // The data layer runs over a Driver. The DO's store is its own in-process SQLite;
44
+ // the D1 substrate (D1Driver) lives in the Worker (the "Worker + D1, no DO" path).
45
+ this.driver = new DoSqliteDriver(ctx.storage);
46
+ // Reconcile the store with the schema before any request is served (create/alter
47
+ // tables; destructive changes rebuild the table). Wrapped in a transaction so a
48
+ // partial migration can't leave a half-rebuilt table.
49
+ const allowDestructive = env.PRAMEN_ALLOW_DESTRUCTIVE === "true";
50
+ ctx.blockConcurrencyWhile(() => this.driver.transaction(() => migrate(this.driver, this.app.schema, { allowDestructive }).then(() => { })));
51
+ }
52
+ async fetch(request) {
53
+ await this.ensureRegistered(request);
54
+ const tenantHeader = request.headers.get("x-pramen-tenant");
55
+ if (tenantHeader)
56
+ this.tenant = tenantHeader;
57
+ const path = new URL(request.url).pathname;
58
+ if (path === "/__recover")
59
+ return this.handleRecover(request);
60
+ if (path === "/__schema")
61
+ return this.handleSchema();
62
+ const identity = this.identityOf(request);
63
+ if (request.headers.get("Upgrade") === "websocket") {
64
+ const { 0: client, 1: server } = new WebSocketPair();
65
+ this.ctx.acceptWebSocket(server); // hibernatable
66
+ this.setState(server, { identity, tenant: this.tenant, subs: [] });
67
+ return new Response(null, { status: 101, webSocket: client });
68
+ }
69
+ const name = new URL(request.url).pathname.replace(/^\/rpc\//, "");
70
+ let input;
71
+ if (request.method === "POST") {
72
+ input = await request.json().catch(() => undefined);
73
+ }
74
+ try {
75
+ const { result, kind, touched } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(this.tenant), this.envBag, this.ctxFor(identity), name, input);
76
+ if (kind === "mutation" && touched.length > 0)
77
+ await this.broadcast(touched);
78
+ return Response.json({ ok: true, result });
79
+ }
80
+ catch (err) {
81
+ const { status, body } = toResponse(err);
82
+ return Response.json(body, { status });
83
+ }
84
+ }
85
+ // --- Hibernatable WebSocket handlers ---
86
+ async webSocketMessage(ws, raw) {
87
+ let msg;
88
+ try {
89
+ msg = JSON.parse(typeof raw === "string" ? raw : new TextDecoder().decode(raw));
90
+ }
91
+ catch {
92
+ return this.send(ws, { type: "error", id: "", error: "invalid JSON" });
93
+ }
94
+ switch (msg.type) {
95
+ case "subscribe":
96
+ return this.onSubscribe(ws, msg.id, msg.name, msg.input);
97
+ case "unsubscribe": {
98
+ const state = this.getState(ws);
99
+ this.setState(ws, { ...state, subs: state.subs.filter((s) => s.id !== msg.id) });
100
+ return;
101
+ }
102
+ case "call":
103
+ return this.onCall(ws, msg.id, msg.name, msg.input);
104
+ default:
105
+ return this.send(ws, { type: "error", id: "", error: "unknown message type" });
106
+ }
107
+ }
108
+ async webSocketClose(ws) {
109
+ ws.close();
110
+ }
111
+ async webSocketError(ws, error) {
112
+ console.error("pramen: websocket error", error);
113
+ try {
114
+ ws.close(1011, "error");
115
+ }
116
+ catch {
117
+ /* already closing */
118
+ }
119
+ }
120
+ // --- live-query internals ---
121
+ async onSubscribe(ws, id, name, input) {
122
+ const state = this.getState(ws);
123
+ try {
124
+ const replacing = state.subs.some((s) => s.id === id);
125
+ if (!replacing && state.subs.length >= MAX_SUBSCRIPTIONS) {
126
+ return this.send(ws, toWsError(id, new BadRequest("subscription limit reached")));
127
+ }
128
+ const { result, kind, touched } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(state.tenant), this.envBag, this.ctxFor(state.identity), name, input);
129
+ if (kind !== "query") {
130
+ return this.send(ws, toWsError(id, new BadRequest(`${name} is not a query`)));
131
+ }
132
+ const subs = state.subs.filter((s) => s.id !== id);
133
+ subs.push({ id, name, input, tables: touched, digest: digest(result) });
134
+ this.setState(ws, { ...state, subs });
135
+ this.send(ws, { type: "data", id, result });
136
+ }
137
+ catch (err) {
138
+ this.send(ws, toWsError(id, err));
139
+ }
140
+ }
141
+ async onCall(ws, id, name, input) {
142
+ const state = this.getState(ws);
143
+ try {
144
+ const { result, kind, touched } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(state.tenant), this.envBag, this.ctxFor(state.identity), name, input);
145
+ this.send(ws, { type: "result", id, result });
146
+ if (kind === "mutation" && touched.length > 0)
147
+ await this.broadcast(touched);
148
+ }
149
+ catch (err) {
150
+ this.send(ws, toWsError(id, err));
151
+ }
152
+ }
153
+ // Re-run every subscription whose read-set intersects the written tables, each
154
+ // under its own socket's identity, and push only when its result changed.
155
+ async broadcast(touched) {
156
+ const written = new Set(touched);
157
+ for (const ws of this.ctx.getWebSockets()) {
158
+ const state = this.getState(ws);
159
+ let dirty = false;
160
+ for (const sub of state.subs) {
161
+ if (!sub.tables.some((t) => written.has(t)))
162
+ continue;
163
+ try {
164
+ const { result } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(state.tenant), this.envBag, this.ctxFor(state.identity), sub.name, sub.input);
165
+ const next = digest(result);
166
+ if (next === sub.digest)
167
+ continue; // result unchanged for this subscription
168
+ sub.digest = next;
169
+ dirty = true;
170
+ this.send(ws, { type: "data", id: sub.id, result });
171
+ }
172
+ catch (err) {
173
+ this.send(ws, toWsError(sub.id, err));
174
+ }
175
+ }
176
+ if (dirty)
177
+ this.setState(ws, state);
178
+ }
179
+ }
180
+ // --- helpers ---
181
+ // A DO addressed by idFromName(tenant) doesn't know its own name — the Worker
182
+ // forwards it. On the first touch ever (guarded by a persisted meta flag), the
183
+ // tenant records itself in the registry KV so it stays discoverable. Exactly
184
+ // one KV write per tenant across its whole lifetime.
185
+ async ensureRegistered(request) {
186
+ if (this.registered)
187
+ return;
188
+ const name = request.headers.get("x-pramen-tenant");
189
+ if (!name)
190
+ return;
191
+ const seen = await this.driver.exec(`SELECT 1 FROM _pramen_meta WHERE key = 'registered'`, []);
192
+ if (seen.length > 0) {
193
+ this.registered = true;
194
+ return;
195
+ }
196
+ await this.env.KV.put(`tenant:${name}`, JSON.stringify({ firstSeen: Date.now() }));
197
+ await this.driver.exec(`INSERT OR REPLACE INTO _pramen_meta (key, value) VALUES ('registered', ?)`, [name]);
198
+ this.registered = true;
199
+ }
200
+ // Point-in-time recovery (admin-gated at the Worker). Arms a restore to the
201
+ // given time and returns the `undo` bookmark (the point just before recovery,
202
+ // so the operation is reversible). We intentionally do NOT call ctx.abort()
203
+ // here, so this response can return the undo bookmark — the restore completes
204
+ // when the DO next restarts. PITR is unavailable in local dev (no change-log).
205
+ async handleRecover(request) {
206
+ const body = (await request.json().catch(() => ({})));
207
+ const ts = typeof body.timestamp === "number" ? body.timestamp : Date.parse(String(body.timestamp));
208
+ if (!Number.isFinite(ts)) {
209
+ return Response.json({ ok: false, error: "invalid timestamp", code: "bad_request" }, { status: 400 });
210
+ }
211
+ const storage = this.ctx.storage;
212
+ try {
213
+ const bookmark = await storage.getBookmarkForTime(ts);
214
+ const undo = await storage.onNextSessionRestoreBookmark(bookmark);
215
+ return Response.json({ ok: true, result: { restoredTo: ts, bookmark, undo, applied: false } });
216
+ }
217
+ catch (err) {
218
+ // PITR is a platform feature — unavailable in local dev, and can otherwise
219
+ // fail operationally. Report 501 (not a generic 500); log the real reason.
220
+ console.error("pramen: recovery unavailable", err);
221
+ return Response.json({ ok: false, error: "point-in-time recovery is unavailable in this environment", code: "unavailable" }, { status: 501 });
222
+ }
223
+ }
224
+ // Introspection: this tenant's applied schema hash + live table/column shape
225
+ // (admin-gated at the Worker). Powers the CLI's `schema status`.
226
+ async handleSchema() {
227
+ const hashRow = (await this.driver.exec(`SELECT value FROM _pramen_meta WHERE key = 'schema_hash'`, []));
228
+ const tableRows = (await this.driver.exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name <> '_pramen_meta'`, []));
229
+ const tables = {};
230
+ for (const { name } of tableRows) {
231
+ tables[name] = (await this.driver.exec(`PRAGMA table_info(${name})`, [])).map((r) => r.name);
232
+ }
233
+ return Response.json({ ok: true, result: { hash: hashRow[0]?.value ?? null, tables } });
234
+ }
235
+ ctxFor(identity) {
236
+ return { acl: this.acl, identity };
237
+ }
238
+ // The DO env (bindings + vars + secrets) handed to handlers as ctx.env. Loosely
239
+ // typed at the boundary so handlers can read any var/secret without a DoEnv cast.
240
+ get envBag() {
241
+ return this.env;
242
+ }
243
+ // One Files facade per DO (a DO serves one tenant). Backed by the R2 binding;
244
+ // signing uses FILES_SECRET. Handlers mint signed urls; the bytes never enter here.
245
+ filesFor(tenant) {
246
+ if (!this.files) {
247
+ const secret = this.env.FILES_SECRET || this.env.AUTH_SECRET || "";
248
+ this.files = createFiles({ tenant, secret, adapter: new R2Adapter(this.env.FILES) });
249
+ }
250
+ return this.files;
251
+ }
252
+ identityOf(request) {
253
+ const raw = request.headers.get("x-pramen-identity");
254
+ if (!raw)
255
+ return null;
256
+ try {
257
+ return JSON.parse(raw);
258
+ }
259
+ catch {
260
+ return null;
261
+ }
262
+ }
263
+ getState(ws) {
264
+ return ws.deserializeAttachment() ?? { identity: null, tenant: this.tenant, subs: [] };
265
+ }
266
+ setState(ws, state) {
267
+ ws.serializeAttachment(state);
268
+ }
269
+ send(ws, msg) {
270
+ ws.send(JSON.stringify(msg));
271
+ }
272
+ }
273
+ /** Produce the concrete, app-bound Durable Object class. A DO is constructed by the
274
+ * platform with just (ctx, env), so the app is closed over here. Re-export the
275
+ * result from your Worker entry under the class name wrangler expects ("PramenDO"). */
276
+ export function pramenDO(app) {
277
+ return class PramenDO extends PramenDOBase {
278
+ constructor(ctx, env) {
279
+ super(ctx, env, app);
280
+ }
281
+ };
282
+ }
@@ -0,0 +1,14 @@
1
+ export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, defaultTo } from "./sdk/schema";
2
+ export type { DefaultValue, FieldType, FieldDef, EntityFields, EntityDef, SchemaDef, RelationDef, RelationDefs, BelongsToDef, HasManyDef, } from "./sdk/schema";
3
+ export { createApp } from "./sdk/app";
4
+ export { query, mutation } from "./sdk/handlers";
5
+ export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts } from "./sdk/handlers";
6
+ export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker } from "./sdk/acl";
7
+ export type { Action, Identity, IdentityMarker, InputMarker, Policy, PolicyRule, PolicyRules, Role, Validator, WhereRule, ConditionalFields, FieldsFn, RelationAclRule, SetValue, ResolverFn, ResolverContext, ResolverDb, } from "./sdk/acl";
8
+ export type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, JsonValue, ProjectedRow, RelationsOf, RelationsResult, WhereInput, WhereOps, } from "./sdk/infer";
9
+ export type { FileRef, Files, SignUploadOpts, SignDownloadOpts, HeadResult } from "./sdk/files";
10
+ export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
11
+ export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
12
+ export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
13
+ export { sqliteDialect, postgresDialect, DoSqliteDriver, D1Driver } from "./runtime/driver";
14
+ export type { Driver, Dialect, Row } from "./runtime/driver";
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ // @pramen/server — the authoring entry: schema, handlers, ACL, files, errors, and
2
+ // the substrate seam. A pramen project is just an app.ts (schema + handlers + ACL),
3
+ // an oblaka.ts (topology), and a 3-line Worker entry.
4
+ //
5
+ // The deploy half — createPramen / the Durable Object — lives at "@pramen/server/worker"
6
+ // (see worker-entry.ts). It is split off because it imports `cloudflare:workers`,
7
+ // which only exists in the Workers runtime; keeping it separate lets the CLI, tests,
8
+ // and codegen load an app.ts for its schema without dragging in the DO runtime.
9
+ // --- schema authoring ---
10
+ export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, defaultTo } from "./sdk/schema";
11
+ // --- app + handlers ---
12
+ export { createApp } from "./sdk/app";
13
+ export { query, mutation } from "./sdk/handlers";
14
+ // --- ACL ---
15
+ export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker } from "./sdk/acl";
16
+ export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
17
+ // --- errors ---
18
+ export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
19
+ // --- substrate seam (advanced: bring your own SQL backend) ---
20
+ export { sqliteDialect, postgresDialect, DoSqliteDriver, D1Driver } from "./runtime/driver";
@@ -0,0 +1,42 @@
1
+ import { type Env } from "./worker";
2
+ import { pramenDO, type DoEnv } from "./durable-object";
3
+ import type { SchemaDef } from "./sdk/schema";
4
+ import type { HandlerMap } from "./sdk/handlers";
5
+ import type { Role } from "./sdk/acl";
6
+ /** Injected into a public route's handler — forward a privileged mutation into the
7
+ * tenant's DO without the handler importing any deploy-side code (so app.ts stays
8
+ * authoring-only). The synthetic identity defaults to the admin role. */
9
+ export interface RouteContext {
10
+ callPrivileged(opts: {
11
+ name: string;
12
+ input?: unknown;
13
+ tenant?: string;
14
+ roles?: string[];
15
+ }): Promise<Response>;
16
+ }
17
+ /** A public, pre-auth route — matched before identity resolution, so it can host a
18
+ * signature-authenticated endpoint (e.g. a Stripe webhook) that doesn't fit the
19
+ * JWT-gated /rpc surface. The handler verifies its own auth (a signature), then can
20
+ * `ctx.callPrivileged(...)` to apply a mutation. `env` is loosely typed here so the
21
+ * app definition stays platform-agnostic. */
22
+ export interface PublicRoute {
23
+ /** HTTP method to match (e.g. "POST"). */
24
+ method: string;
25
+ /** Exact pathname to match (e.g. "/stripe/webhook"). */
26
+ path: string;
27
+ handler: (request: Request, env: Readonly<Record<string, unknown>>, ctx: RouteContext) => Response | Promise<Response>;
28
+ }
29
+ /** The user-facing app: a schema, the handler map, ACL roles, and optional public
30
+ * (pre-auth) routes. `example/app.ts` exports this shape. */
31
+ export interface PramenApp {
32
+ schema: SchemaDef;
33
+ handlers: HandlerMap;
34
+ acl?: Role[];
35
+ routes?: PublicRoute[];
36
+ }
37
+ export type { Env, DoEnv };
38
+ /** Build the deployable pair for an app. */
39
+ export declare function createPramen(app: PramenApp): {
40
+ fetch: (request: Request, env: Env) => Promise<Response>;
41
+ PramenDO: ReturnType<typeof pramenDO>;
42
+ };
package/dist/pramen.js ADDED
@@ -0,0 +1,19 @@
1
+ // createPramen(app) — the server library entry. Turns an app (schema + handlers +
2
+ // ACL) into the two things a Cloudflare deployment needs: a Worker `fetch` and the
3
+ // `PramenDO` Durable Object class. A consumer's whole entry is three lines:
4
+ //
5
+ // import { createPramen } from "@pramen/server";
6
+ // import { app } from "./app";
7
+ // const pramen = createPramen(app);
8
+ // export default { fetch: pramen.fetch };
9
+ // export const PramenDO = pramen.PramenDO; // wrangler binds this by class_name
10
+ //
11
+ // The DO class is produced per-app (pramenDO closes over `app`) because the platform
12
+ // constructs a DO with only (ctx, env). PramenApp is defined here and imported
13
+ // type-only by worker.ts / durable-object.ts, so there is no runtime import cycle.
14
+ import { makeWorker } from "./worker";
15
+ import { pramenDO } from "./durable-object";
16
+ /** Build the deployable pair for an app. */
17
+ export function createPramen(app) {
18
+ return { fetch: makeWorker(app).fetch, PramenDO: pramenDO(app) };
19
+ }