@columenlabs/lumen-person 0.0.0-stage → 0.1.144

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Columen Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE ADDED
@@ -0,0 +1,12 @@
1
+ Lumen
2
+ Copyright (c) 2026 Columen Labs. Licensed under the MIT License (see LICENSE).
3
+
4
+ Lumen draws price and market charts with TradingView Lightweight Charts™
5
+ (https://github.com/tradingview/lightweight-charts), Copyright (c) 2025
6
+ TradingView, Inc., licensed under the Apache License 2.0. Lumen turns off the
7
+ chart's own logo, so per that library's NOTICE, an app that shows these charts
8
+ should link to https://www.tradingview.com/ somewhere a user can find it (an
9
+ About page or footer is enough).
10
+
11
+ The components under packages/ui/src/ui are based on shadcn/ui
12
+ (https://ui.shadcn.com), MIT License, Copyright (c) 2023 shadcn.
package/README.md CHANGED
@@ -1,3 +1,87 @@
1
- # Temporary Holding Version
1
+ # @columenlabs/lumen-person
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Lumen's one store per person: a Durable Object whose SQLite holds every app's tables for that person. Each app is a module that owns its tables (`<module>_*`), its migrations and its wake time, so app code stays apart while one person's data joins in one SQL query, one backup and one delete. No dependencies: it runs in a Worker with `nodejs_compat` (for `AsyncLocalStorage`) and any Durable Object with SQLite storage.
4
+
5
+ - Source: https://github.com/columenlabs/lumen (`packages/person`). MIT.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ bun add @columenlabs/lumen-person
11
+ ```
12
+
13
+ Pin it to the same exact version as the other Lumen packages.
14
+
15
+ ## A store and its modules
16
+
17
+ ```ts
18
+ import { DurableObject } from 'cloudflare:workers';
19
+ import { PersonObject, type PersonModule, personModule } from '@columenlabs/lumen-person';
20
+
21
+ const notes: PersonModule<Env> = {
22
+ name: 'notes',
23
+ migrations: [
24
+ 'CREATE TABLE notes_items (id INTEGER PRIMARY KEY, title TEXT NOT NULL, at INTEGER NOT NULL)',
25
+ ],
26
+ async call(ctx, method, args) {
27
+ if (method === 'add') {
28
+ ctx.sql.exec('INSERT INTO notes_items (title, at) VALUES (?, ?)', (args as { title: string }).title, Date.now());
29
+ await ctx.wakeAt(Date.now() + 60_000);
30
+ return { ok: true };
31
+ }
32
+ throw new Error(`no ${method}`);
33
+ },
34
+ async wake(ctx) {
35
+ // small steps: stop by ctx.deadline and ask for the next time
36
+ },
37
+ };
38
+
39
+ export class PersonStore extends DurableObject<Env> {
40
+ private readonly person = new PersonObject(this.ctx, { modules: [notes], extra: this.env });
41
+ call(owner: string, module: string, method: string, args?: unknown) {
42
+ return this.person.call(owner, module, method, args);
43
+ }
44
+ alarm() {
45
+ return this.person.alarm();
46
+ }
47
+ }
48
+
49
+ // In the Worker
50
+ const mine = personModule(env.PERSON, email, 'notes');
51
+ await mine.call('add', { title: 'Buy saw blades' });
52
+ ```
53
+
54
+ The first call names the person and claims the store; a later call for anyone else is refused (403).
55
+
56
+ ## Rules it keeps
57
+
58
+ - **Migrations fail per module.** Each module migrates on its first call or wake, each step in one transaction. A step that throws leaves only that module answering 503 for a minute before it tries again; every other module keeps working. Never edit or reorder a released step: add one.
59
+ - **One alarm for every module.** `_wakes` keeps each module's next time and the store's alarm is the earliest. Each wake gets `ctx.deadline` (30 s by default, `wakeMs`) to stop by and set its next time. A wake that throws is tried again in 1 minute, doubling to an hour.
60
+ - **Waiting is shared, work is not.** While one call awaits the network (a fetch, a model) other calls run. Only synchronous work holds the store, so keep heavy parsing out of it.
61
+ - **Calls stay in place.** Code running in a person's store that calls the same person through `personCall` runs in place instead of a new request.
62
+
63
+ ## Backup, restore, delete
64
+
65
+ The store's own module `_object` (or `personModule(...).own(method)` for one module):
66
+
67
+ | Method | Does |
68
+ | --- | --- |
69
+ | `dump` | Every module's tables as stored (sealed values stay sealed), with each module's migration level |
70
+ | `dumpModule {module}` | One module's tables |
71
+ | `loadModule {module, dump, prefix?}` | Replaces one module's tables from a dump. With `prefix`, tables named without it (`items`) load into `<module>_items`, so rows move in from an older store |
72
+ | `load` | Replaces every module's tables from a whole dump |
73
+ | `wipeModule {module}` | Empties one module's tables and its wake |
74
+ | `wipe` | Deletes the whole store (every table, key and the alarm) |
75
+ | `counts` | Rows per table, migration levels and any module whose migration is failing |
76
+
77
+ ## Moving rows in from an older store
78
+
79
+ An app that kept its own Durable Object moves into the person's store a page at a time, so no step holds either store for long:
80
+
81
+ - The old store answers `exportRows(sql, table, after)`: rows after a rowid bookmark, at most 500 rows and about 4 MB a page (at least one row, so a big blob still moves), bytes as `{b64}`.
82
+ - The module's wake writes each page with `importRows(sql, table, page)` inside `ctx.transaction`, keeps the bookmark and asks to wake again. Rows keep their rowids, so a page sent twice changes nothing.
83
+ - When every table is across, `rowStats` on both sides (rows and highest rowid per table) must match before the app reads from the new store. Stop the old store's alarm and writes while it moves; a table whose stats differ goes again.
84
+
85
+ Point-in-time recovery (`ctx.storage.getBookmarkForTime`) restores the whole store, every module at once. Restore one module from its dump instead, and keep PITR for the whole store.
86
+
87
+ Virtual tables (an FTS5 word index from `@columenlabs/lumen-ai`'s `findSql`) are not copied: their triggers fill them as rows load, and a rebuild runs after.
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @columenlabs/lumen-person: one store per person, every app's tables in it. `PersonObject` runs
3
+ * inside a Durable Object class; `personCall` reaches it from the Worker (or in place, from code
4
+ * already running in that person's store).
5
+ */
6
+ import { type PersonReply } from './object.js';
7
+ export * from './move.js';
8
+ export * from './object.js';
9
+ /** What the Worker holds of the Durable Object namespace: the class's RPC `call` passes to `PersonObject.call`. */
10
+ export interface PersonNamespace {
11
+ idFromName(name: string): unknown;
12
+ get(id: unknown): {
13
+ call(owner: string, module: string, method: string, args?: unknown): Promise<PersonReply>;
14
+ };
15
+ }
16
+ /** Calls `module.method(args)` in the person's store and returns its value, or throws its `PersonError`. */
17
+ export declare function personCall<T>(ns: PersonNamespace, owner: string, module: string, method: string, args?: unknown): Promise<T>;
18
+ /** One module of one person's store, so app code reads `career.call('stories')`. */
19
+ export declare function personModule(ns: PersonNamespace, owner: string, module: string): {
20
+ call: <T>(method: string, args?: unknown) => Promise<T>;
21
+ /** The store's own methods for this module: dumpModule, loadModule, wipeModule. */
22
+ own: <T>(method: string, args?: Record<string, unknown>) => Promise<T>;
23
+ };
package/dist/index.js ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @columenlabs/lumen-person: one store per person, every app's tables in it. `PersonObject` runs
3
+ * inside a Durable Object class; `personCall` reaches it from the Worker (or in place, from code
4
+ * already running in that person's store).
5
+ */
6
+ import { OBJECT_MODULE, PersonError, insideStore } from './object.js';
7
+ export * from './move.js';
8
+ export * from './object.js';
9
+ /** Calls `module.method(args)` in the person's store and returns its value, or throws its `PersonError`. */
10
+ export async function personCall(ns, owner, module, method, args) {
11
+ const here = insideStore();
12
+ if (here?.owner === owner) {
13
+ // In place, copied as a stub call copies (structured clone, bytes included), so callers see the same values.
14
+ try {
15
+ const out = await here.call(module, method, structuredClone(args ?? null));
16
+ return (out === undefined ? null : structuredClone(out));
17
+ }
18
+ catch (err) {
19
+ if (err instanceof PersonError)
20
+ throw err;
21
+ throw new PersonError(502, `${module} failed`);
22
+ }
23
+ }
24
+ const r = await ns.get(ns.idFromName(owner)).call(owner, module, method, args ?? null);
25
+ if (!r.ok)
26
+ throw new PersonError(r.status, r.error);
27
+ return r.value;
28
+ }
29
+ /** One module of one person's store, so app code reads `career.call('stories')`. */
30
+ export function personModule(ns, owner, module) {
31
+ return {
32
+ call: (method, args) => personCall(ns, owner, module, method, args),
33
+ /** The store's own methods for this module: dumpModule, loadModule, wipeModule. */
34
+ own: (method, args) => personCall(ns, owner, OBJECT_MODULE, method, { ...args, module }),
35
+ };
36
+ }
package/dist/json.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ /** A SQLite value as JSON carries it: bytes become `{b64}` (no Buffer, so it runs anywhere). */
2
+ export declare function toJson(v: unknown): unknown;
3
+ export declare function fromJson(v: unknown): unknown;
package/dist/json.js ADDED
@@ -0,0 +1,23 @@
1
+ /** A SQLite value as JSON carries it: bytes become `{b64}` (no Buffer, so it runs anywhere). */
2
+ export function toJson(v) {
3
+ if (v instanceof ArrayBuffer || ArrayBuffer.isView(v)) {
4
+ const bytes = v instanceof ArrayBuffer
5
+ ? new Uint8Array(v)
6
+ : new Uint8Array(v.buffer, v.byteOffset, v.byteLength);
7
+ let s = '';
8
+ for (let i = 0; i < bytes.length; i += 0x8000)
9
+ s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
10
+ return { b64: btoa(s) };
11
+ }
12
+ return v;
13
+ }
14
+ export function fromJson(v) {
15
+ if (v && typeof v === 'object' && 'b64' in v) {
16
+ const s = atob(String(v.b64));
17
+ const bytes = new Uint8Array(s.length);
18
+ for (let i = 0; i < s.length; i++)
19
+ bytes[i] = s.charCodeAt(i);
20
+ return bytes.buffer;
21
+ }
22
+ return v;
23
+ }
package/dist/move.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type { DumpTable, SqlLike } from './object.js';
2
+ /** One page of a table: its columns, rows and their rowids, and the bookmark to ask for next (null: done). */
3
+ export interface RowsPage extends DumpTable {
4
+ ids: number[];
5
+ next: number | null;
6
+ }
7
+ /** A table's row count and highest rowid, to check a move is whole. */
8
+ export type RowStats = Record<string, {
9
+ n: number;
10
+ max: number;
11
+ }>;
12
+ /**
13
+ * Rows of `table` after rowid `after`, in rowid order: at most `limit` rows and about `maxBytes`
14
+ * (at least one row a page, so a big blob still moves). Bytes come as `{b64}`, as in a dump.
15
+ */
16
+ export declare function exportRows(sql: SqlLike, table: string, after: number, opts?: {
17
+ limit?: number;
18
+ maxBytes?: number;
19
+ }): RowsPage;
20
+ /**
21
+ * Writes a page into `table`, naming only the columns the table has (the rest keep their
22
+ * defaults) and keeping each row's rowid, so the two stores' `rowStats` match and a page sent
23
+ * twice changes nothing. Run it inside the module's transaction.
24
+ */
25
+ export declare function importRows(sql: SqlLike, table: string, page: DumpTable & {
26
+ ids?: number[];
27
+ }): number;
28
+ /** Row counts and highest rowids of `tables` (a missing table counts as empty). */
29
+ export declare function rowStats(sql: SqlLike, tables: string[]): RowStats;
package/dist/move.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Moving an app's rows in from the Durable Object that held them before, a page at a time, so no
3
+ * step holds either store for long. The old store answers `exportRows` pages (by rowid, from a
4
+ * bookmark); the module's wake writes each page with `importRows` and keeps the bookmark, and when
5
+ * every table is across `rowStats` on both sides says the move is whole. Keep the old store from
6
+ * writing while it moves (stop its alarm and its writes), or rows it changes behind the bookmark
7
+ * are missed: `rowStats` then tells, and that table goes again.
8
+ */
9
+ import { fromJson, toJson } from './json.js';
10
+ const quote = (name) => `"${name.replace(/"/g, '""')}"`;
11
+ /**
12
+ * Rows of `table` after rowid `after`, in rowid order: at most `limit` rows and about `maxBytes`
13
+ * (at least one row a page, so a big blob still moves). Bytes come as `{b64}`, as in a dump.
14
+ */
15
+ export function exportRows(sql, table, after, opts = {}) {
16
+ const limit = Math.min(Math.max(opts.limit ?? 500, 1), 5000);
17
+ const maxBytes = opts.maxBytes ?? 4_000_000;
18
+ const cols = sql
19
+ .exec(`SELECT name FROM pragma_table_info('${table.replace(/'/g, "''")}')`)
20
+ .toArray()
21
+ .map((r) => r.name);
22
+ if (!cols.length)
23
+ return { cols, rows: [], ids: [], next: null };
24
+ const found = sql
25
+ .exec(`SELECT rowid AS _rowid_, * FROM ${quote(table)} WHERE rowid > ? ORDER BY rowid LIMIT ?`, after, limit)
26
+ .toArray();
27
+ const rows = [];
28
+ const ids = [];
29
+ let bytes = 0;
30
+ let last = after;
31
+ for (const r of found) {
32
+ if (rows.length && bytes > maxBytes)
33
+ break;
34
+ const row = cols.map((c) => toJson(r[c]));
35
+ bytes += JSON.stringify(row).length;
36
+ rows.push(row);
37
+ last = Number(r._rowid_);
38
+ ids.push(last);
39
+ }
40
+ const more = rows.length === found.length ? found.length === limit : true;
41
+ return { cols, rows, ids, next: more ? last : null };
42
+ }
43
+ /**
44
+ * Writes a page into `table`, naming only the columns the table has (the rest keep their
45
+ * defaults) and keeping each row's rowid, so the two stores' `rowStats` match and a page sent
46
+ * twice changes nothing. Run it inside the module's transaction.
47
+ */
48
+ export function importRows(sql, table, page) {
49
+ const have = new Set(sql
50
+ .exec(`SELECT name FROM pragma_table_info('${table.replace(/'/g, "''")}')`)
51
+ .toArray()
52
+ .map((r) => r.name));
53
+ const pick = page.cols.map((c, i) => [c, i]).filter(([c]) => have.has(c));
54
+ if (!pick.length)
55
+ return 0;
56
+ const ids = page.ids?.length === page.rows.length ? page.ids : null;
57
+ const names = [...(ids ? ['rowid'] : []), ...pick.map(([c]) => quote(c))];
58
+ const insert = `INSERT OR REPLACE INTO ${quote(table)} (${names.join(', ')}) VALUES (${names.map(() => '?').join(', ')})`;
59
+ page.rows.forEach((row, k) => sql.exec(insert, ...(ids ? [ids[k]] : []), ...pick.map(([, i]) => fromJson(row[i]))));
60
+ return page.rows.length;
61
+ }
62
+ /** Row counts and highest rowids of `tables` (a missing table counts as empty). */
63
+ export function rowStats(sql, tables) {
64
+ const out = {};
65
+ for (const t of tables) {
66
+ try {
67
+ const r = sql
68
+ .exec(`SELECT COUNT(*) AS n, MAX(rowid) AS max FROM ${quote(t)}`)
69
+ .toArray()[0];
70
+ out[t] = { n: Number(r?.n ?? 0), max: Number(r?.max ?? 0) };
71
+ }
72
+ catch {
73
+ out[t] = { n: 0, max: 0 };
74
+ }
75
+ }
76
+ return out;
77
+ }
@@ -0,0 +1,135 @@
1
+ /** The SQLite of a Durable Object (`ctx.storage.sql`): typed loosely, so `SqlStorage` passes as it is. */
2
+ export interface SqlLike {
3
+ exec<T extends Record<string, any> = Record<string, unknown>>(query: string, ...bindings: any[]): {
4
+ toArray(): T[];
5
+ };
6
+ }
7
+ /** What the store needs of `DurableObjectState`. */
8
+ export interface PersonState {
9
+ storage: {
10
+ sql: SqlLike;
11
+ get<T>(key: string): Promise<T | undefined>;
12
+ put(key: string, value: unknown): Promise<void>;
13
+ setAlarm(ms: number): Promise<void>;
14
+ deleteAlarm(): Promise<void>;
15
+ /** Deletes every key and table of the object, SQLite included. */
16
+ deleteAll(): Promise<void>;
17
+ /** Runs `fn` as one SQLite transaction: a throw rolls back every statement in it. */
18
+ transactionSync<T>(fn: () => T): T;
19
+ };
20
+ blockConcurrencyWhile<T>(fn: () => Promise<T>): Promise<T>;
21
+ }
22
+ /** One migration step: SQL (statements separated by `;`) or a function over the SQLite, never async. */
23
+ export type PersonMigration = string | ((sql: SqlLike) => void);
24
+ /** What a module gets on each call and wake. `X` is the app's own (its env, a vault). */
25
+ export interface PersonCtx<X = unknown> {
26
+ owner: string;
27
+ module: string;
28
+ sql: SqlLike;
29
+ extra: X;
30
+ /** `fn` as one SQLite transaction (a throw rolls it all back). */
31
+ transaction<T>(fn: () => T): T;
32
+ /** Runs `fn` with no other call or wake let in until it ends. Use rarely: it stops the store. */
33
+ exclusive<T>(fn: () => Promise<T>): Promise<T>;
34
+ /** When this module wants its `wake` next; null: never. */
35
+ wakeAt(at: number | null): Promise<void>;
36
+ /** The time (ms) a wake should stop by and set its next time. A call gets none (Infinity). */
37
+ deadline: number;
38
+ }
39
+ /** An app's part of the store: its tables, what the Worker may ask of it and its alarm. */
40
+ export interface PersonModule<X = unknown> {
41
+ /** Lower-case letters and digits; every table of the module is `<name>_*`. */
42
+ name: string;
43
+ /** In order; each runs once, inside the object. Never edit or reorder a released step: add one. */
44
+ migrations: PersonMigration[];
45
+ call(ctx: PersonCtx<X>, method: string, args: unknown): Promise<unknown>;
46
+ /** Runs at the time it last asked for (`wakeAt`). Do small steps and ask again for more. */
47
+ wake?(ctx: PersonCtx<X>): Promise<void>;
48
+ }
49
+ export interface PersonDeps<X = unknown> {
50
+ modules: PersonModule<X>[];
51
+ extra: X;
52
+ /** Names a person may have (the first call claims the store). Default: 1 to 200 non-space characters. */
53
+ owner?: RegExp;
54
+ /** Runs once, when the first call claims the store (to list it somewhere). */
55
+ onClaim?(owner: string): Promise<void>;
56
+ /** How long one module's wake may run (default 30 s). */
57
+ wakeMs?: number;
58
+ }
59
+ /** A refusal with a status and a message the person can act on. */
60
+ export declare class PersonError extends Error {
61
+ readonly status: 400 | 403 | 404 | 409 | 502 | 503;
62
+ constructor(status: 400 | 403 | 404 | 409 | 502 | 503, message: string);
63
+ }
64
+ /** How a call answers: across RPC an error loses its class, so the status travels in the value. */
65
+ export type PersonReply = {
66
+ ok: true;
67
+ value: unknown;
68
+ } | {
69
+ ok: false;
70
+ status: PersonError['status'];
71
+ error: string;
72
+ };
73
+ /** The store's own module: backup, restore, delete and counts. */
74
+ export declare const OBJECT_MODULE = "_object";
75
+ /** One table of a dump: its columns and rows in that order. Bytes are `{b64}`. */
76
+ export interface DumpTable {
77
+ cols: string[];
78
+ rows: unknown[][];
79
+ }
80
+ /** What `dump` (whole store) or `dumpModule` (one module) returns, and `load`/`loadModule` take. */
81
+ export interface PersonDump {
82
+ owner: string;
83
+ /** The module dumped; absent for the whole store. */
84
+ module?: string;
85
+ at: string;
86
+ /** Each module's migration level when dumped. */
87
+ schema: Record<string, number>;
88
+ tables: Record<string, DumpTable>;
89
+ }
90
+ /** Calls made from inside a store to its own person run in place (a stub call back in is a new request). */
91
+ type Inside = {
92
+ owner: string;
93
+ call(module: string, method: string, args: unknown): Promise<unknown>;
94
+ };
95
+ export declare const insideStore: () => Inside | undefined;
96
+ /** One person's store. A Durable Object class holds one and passes its calls and alarm through. */
97
+ export declare class PersonObject<X = unknown> {
98
+ private readonly state;
99
+ private readonly load;
100
+ private deps?;
101
+ private owner?;
102
+ private ownerRead;
103
+ private readonly ready;
104
+ private readonly broken;
105
+ constructor(state: PersonState, load: PersonDeps<X> | (() => Promise<PersonDeps<X>>));
106
+ private get sql();
107
+ private setup;
108
+ /** The module's pending migrations, each in its own transaction. False: it is broken for now. */
109
+ private migrate;
110
+ private currentOwner;
111
+ /** The first call names the person; every later one must match. */
112
+ private claim;
113
+ private ctx;
114
+ private arm;
115
+ private module;
116
+ /** A call from the Worker (an RPC method on the Durable Object passes it through). */
117
+ call(owner: string, module: string, method: string, args?: unknown): Promise<PersonReply>;
118
+ /** The same as `call`, for a store reached by `fetch` with `{owner, module, method, args}`. */
119
+ fetch(req: Request): Promise<Response>;
120
+ private run;
121
+ /** Runs `fn` as code inside this store: its calls to this person stay in place. */
122
+ private within;
123
+ private tables;
124
+ /** Data tables a dump carries: virtual tables (FTS5) and their shadow tables are rebuilt, not copied. */
125
+ private dataTables;
126
+ private columns;
127
+ private schema;
128
+ private dumpTables;
129
+ /** Replaces `names` with the dump's rows; only columns this version has, the rest keep defaults. */
130
+ private loadTables;
131
+ private own;
132
+ /** The Durable Object's alarm: each due module's wake in turn, then the next earliest time. */
133
+ alarm(): Promise<void>;
134
+ }
135
+ export {};
package/dist/object.js ADDED
@@ -0,0 +1,387 @@
1
+ /**
2
+ * One store per person: a Durable Object (named by the person) whose SQLite holds every app's
3
+ * tables for that person. Each app is a module that owns the tables named `<module>_*`, its own
4
+ * migrations and its own wake time, so app code stays apart while one person's data can be joined
5
+ * in one SQL query.
6
+ *
7
+ * - Migrations run per module, on that module's first call or wake, each step in one transaction.
8
+ * A step that throws leaves the module refusing its own calls (503) for a minute, then it tries
9
+ * again; every other module keeps answering.
10
+ * - One alarm serves every module: `_wakes` keeps each module's next time and the alarm is set to
11
+ * the earliest. A wake gets a deadline to stop by and set its next time. A wake that throws is
12
+ * tried again later, sooner first (1 minute, doubling to an hour).
13
+ * - `_object` methods back up, restore and delete one module's tables or the whole store.
14
+ *
15
+ * The object is the only writer of its person's data: a sync, a webhook and an edit run in turn,
16
+ * so a read-modify-write needs no retry loop. Network waits still let other calls in (input gates).
17
+ */
18
+ import { AsyncLocalStorage } from 'node:async_hooks';
19
+ import { fromJson, toJson } from './json.js';
20
+ /** A refusal with a status and a message the person can act on. */
21
+ export class PersonError extends Error {
22
+ status;
23
+ constructor(status, message) {
24
+ super(message);
25
+ this.status = status;
26
+ }
27
+ }
28
+ /** The store's own module: backup, restore, delete and counts. */
29
+ export const OBJECT_MODULE = '_object';
30
+ const NAME = /^[a-z][a-z0-9]*$/;
31
+ const OWNER = /^[^\s]{1,200}$/;
32
+ const RETRY_MS = 60_000;
33
+ const BACKOFF_MAX_MS = 3_600_000;
34
+ /** An alarm may run 15 minutes; stop starting wakes well before. */
35
+ const ALARM_MS = 10 * 60_000;
36
+ const inside = new AsyncLocalStorage();
37
+ export const insideStore = () => inside.getStore();
38
+ /** One person's store. A Durable Object class holds one and passes its calls and alarm through. */
39
+ export class PersonObject {
40
+ state;
41
+ load;
42
+ deps;
43
+ owner;
44
+ ownerRead = false;
45
+ ready = new Set();
46
+ broken = new Map();
47
+ constructor(state, load) {
48
+ this.state = state;
49
+ this.load = load;
50
+ }
51
+ get sql() {
52
+ return this.state.storage.sql;
53
+ }
54
+ async setup() {
55
+ this.deps ??= (async () => {
56
+ const deps = typeof this.load === 'function' ? await this.load() : this.load;
57
+ for (const m of deps.modules)
58
+ if (!NAME.test(m.name))
59
+ throw new Error(`person module name ${m.name}`);
60
+ this.sql.exec('CREATE TABLE IF NOT EXISTS _schema (module TEXT PRIMARY KEY, version INTEGER NOT NULL)');
61
+ this.sql.exec('CREATE TABLE IF NOT EXISTS _wakes (module TEXT PRIMARY KEY, at INTEGER NOT NULL, fails INTEGER NOT NULL DEFAULT 0)');
62
+ return deps;
63
+ })();
64
+ this.deps.catch(() => {
65
+ this.deps = undefined;
66
+ });
67
+ return this.deps;
68
+ }
69
+ /** The module's pending migrations, each in its own transaction. False: it is broken for now. */
70
+ migrate(m) {
71
+ if (this.ready.has(m.name))
72
+ return true;
73
+ const broke = this.broken.get(m.name);
74
+ if (broke && Date.now() - broke.at < RETRY_MS)
75
+ return false;
76
+ try {
77
+ const row = this.sql
78
+ .exec('SELECT version FROM _schema WHERE module = ?', m.name)
79
+ .toArray()[0];
80
+ for (let v = row?.version ?? 0; v < m.migrations.length; v++) {
81
+ const step = m.migrations[v];
82
+ this.state.storage.transactionSync(() => {
83
+ if (typeof step === 'string')
84
+ this.sql.exec(step);
85
+ else
86
+ step(this.sql);
87
+ this.sql.exec('INSERT INTO _schema (module, version) VALUES (?, ?) ON CONFLICT (module) DO UPDATE SET version = excluded.version', m.name, v + 1);
88
+ });
89
+ }
90
+ this.broken.delete(m.name);
91
+ this.ready.add(m.name);
92
+ return true;
93
+ }
94
+ catch (err) {
95
+ const error = err instanceof Error ? err.message : String(err);
96
+ this.broken.set(m.name, { at: Date.now(), error });
97
+ console.error(JSON.stringify({ event: 'person_migration_failed', module: m.name, error }));
98
+ return false;
99
+ }
100
+ }
101
+ async currentOwner() {
102
+ if (!this.ownerRead) {
103
+ this.owner = await this.state.storage.get('owner');
104
+ this.ownerRead = true;
105
+ }
106
+ return this.owner;
107
+ }
108
+ /** The first call names the person; every later one must match. */
109
+ async claim(deps, owner) {
110
+ if (!(deps.owner ?? OWNER).test(owner))
111
+ throw new PersonError(400, 'not a person this store can hold');
112
+ const now = await this.currentOwner();
113
+ if (now === owner)
114
+ return;
115
+ if (now)
116
+ throw new PersonError(403, 'not this person’s store');
117
+ await this.state.storage.put('owner', owner);
118
+ this.owner = owner;
119
+ await deps.onClaim?.(owner);
120
+ }
121
+ ctx(deps, owner, module, deadline) {
122
+ return {
123
+ owner,
124
+ module,
125
+ sql: this.sql,
126
+ extra: deps.extra,
127
+ deadline,
128
+ transaction: (fn) => this.state.storage.transactionSync(fn),
129
+ exclusive: (fn) => this.state.blockConcurrencyWhile(fn),
130
+ wakeAt: async (at) => {
131
+ if (at === null)
132
+ this.sql.exec('DELETE FROM _wakes WHERE module = ?', module);
133
+ else
134
+ this.sql.exec('INSERT INTO _wakes (module, at, fails) VALUES (?, ?, 0) ON CONFLICT (module) DO UPDATE SET at = excluded.at, fails = 0', module, Math.round(at));
135
+ await this.arm();
136
+ },
137
+ };
138
+ }
139
+ async arm() {
140
+ const next = this.sql
141
+ .exec('SELECT MIN(at) AS at FROM _wakes')
142
+ .toArray()[0]?.at;
143
+ if (next)
144
+ await this.state.storage.setAlarm(Math.max(next, Date.now()));
145
+ else
146
+ await this.state.storage.deleteAlarm();
147
+ }
148
+ module(deps, name) {
149
+ const m = deps.modules.find((x) => x.name === name);
150
+ if (!m)
151
+ throw new PersonError(404, `no module ${name}`);
152
+ if (!this.migrate(m))
153
+ throw new PersonError(503, `${name} is updating; try again in a minute`);
154
+ return m;
155
+ }
156
+ /** A call from the Worker (an RPC method on the Durable Object passes it through). */
157
+ async call(owner, module, method, args) {
158
+ try {
159
+ const deps = await this.setup();
160
+ await this.claim(deps, owner);
161
+ const value = await this.within(deps, owner, () => this.run(deps, owner, module, method, args ?? null));
162
+ return { ok: true, value: value ?? null };
163
+ }
164
+ catch (err) {
165
+ if (err instanceof PersonError)
166
+ return { ok: false, status: err.status, error: err.message };
167
+ console.error(JSON.stringify({ event: 'person_call_failed', module, method, error: String(err) }));
168
+ return { ok: false, status: 502, error: `${module} failed` };
169
+ }
170
+ }
171
+ /** The same as `call`, for a store reached by `fetch` with `{owner, module, method, args}`. */
172
+ async fetch(req) {
173
+ const body = (await req.json().catch(() => null));
174
+ if (!body?.owner || !body.module || !body.method)
175
+ return Response.json({ error: 'bad request' }, { status: 400 });
176
+ const r = await this.call(body.owner, body.module, body.method, body.args);
177
+ return r.ok ? Response.json(r) : Response.json({ error: r.error }, { status: r.status });
178
+ }
179
+ run(deps, owner, module, method, args) {
180
+ if (module === OBJECT_MODULE)
181
+ return this.own(deps, method, args);
182
+ const m = this.module(deps, module);
183
+ return m.call(this.ctx(deps, owner, m.name, Number.POSITIVE_INFINITY), method, args);
184
+ }
185
+ /** Runs `fn` as code inside this store: its calls to this person stay in place. */
186
+ within(deps, owner, fn) {
187
+ return inside.run({ owner, call: (module, method, args) => this.run(deps, owner, module, method, args) }, fn);
188
+ }
189
+ tables() {
190
+ return this.sql
191
+ .exec(
192
+ // Not SQLite's, the platform's (`_cf_*`) or a local runtime's (`__*`) own tables.
193
+ "SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' AND name NOT LIKE '\\_cf\\_%' ESCAPE '\\' AND name NOT LIKE '\\_\\_%' ESCAPE '\\' ORDER BY name")
194
+ .toArray()
195
+ .map((r) => ({ name: r.name, virtual: /^\s*CREATE\s+VIRTUAL/i.test(r.sql ?? '') }));
196
+ }
197
+ /** Data tables a dump carries: virtual tables (FTS5) and their shadow tables are rebuilt, not copied. */
198
+ dataTables(prefix) {
199
+ const all = this.tables();
200
+ const virtual = all.filter((t) => t.virtual).map((t) => t.name);
201
+ const shadow = (n) => virtual.some((v) => n.startsWith(`${v}_`));
202
+ return all
203
+ .filter((t) => !t.virtual && !shadow(t.name))
204
+ .map((t) => t.name)
205
+ .filter((n) => (prefix ? n.startsWith(prefix) : true));
206
+ }
207
+ columns(table) {
208
+ return this.sql
209
+ .exec(`SELECT name FROM pragma_table_info('${table.replace(/'/g, "''")}')`)
210
+ .toArray()
211
+ .map((r) => r.name);
212
+ }
213
+ schema() {
214
+ return Object.fromEntries(this.sql
215
+ .exec('SELECT module, version FROM _schema')
216
+ .toArray()
217
+ .map((r) => [r.module, r.version]));
218
+ }
219
+ dumpTables(names) {
220
+ const tables = {};
221
+ for (const t of names) {
222
+ const cols = this.columns(t);
223
+ const rows = this.sql
224
+ .exec(`SELECT * FROM "${t}"`)
225
+ .toArray()
226
+ .map((r) => cols.map((c) => toJson(r[c])));
227
+ tables[t] = { cols, rows };
228
+ }
229
+ return tables;
230
+ }
231
+ /** Replaces `names` with the dump's rows; only columns this version has, the rest keep defaults. */
232
+ loadTables(names, dump, rename) {
233
+ const loaded = {};
234
+ const skipped = [];
235
+ this.state.storage.transactionSync(() => {
236
+ this.sql.exec('PRAGMA defer_foreign_keys = ON');
237
+ for (const t of names)
238
+ this.sql.exec(`DELETE FROM "${t}"`);
239
+ for (const [from, data] of Object.entries(dump)) {
240
+ const t = rename(from);
241
+ if (!names.includes(t)) {
242
+ skipped.push(from);
243
+ continue;
244
+ }
245
+ const cols = this.columns(t);
246
+ const pick = data.cols.map((c, i) => [c, i]).filter(([c]) => cols.includes(c));
247
+ const insert = `INSERT INTO "${t}" (${pick.map(([c]) => `"${c}"`).join(', ')}) VALUES (${pick.map(() => '?').join(', ')})`;
248
+ for (const row of data.rows)
249
+ this.sql.exec(insert, ...pick.map(([, i]) => fromJson(row[i])));
250
+ loaded[t] = data.rows.length;
251
+ }
252
+ });
253
+ // Word indexes kept by triggers are already in step; a rebuild covers any that are not.
254
+ for (const v of this.tables().filter((x) => x.virtual)) {
255
+ try {
256
+ this.sql.exec(`INSERT INTO "${v.name}"("${v.name}") VALUES ('rebuild')`);
257
+ }
258
+ catch {
259
+ // Not an FTS5 table with its own content: nothing to rebuild.
260
+ }
261
+ }
262
+ return { loaded, skipped };
263
+ }
264
+ async own(deps, method, args) {
265
+ const owner = this.owner;
266
+ const a = (args ?? {});
267
+ const named = () => {
268
+ if (!a.module)
269
+ throw new PersonError(400, 'name the module');
270
+ return this.module(deps, a.module);
271
+ };
272
+ switch (method) {
273
+ case 'dump':
274
+ for (const m of deps.modules)
275
+ this.migrate(m);
276
+ return {
277
+ owner,
278
+ at: new Date().toISOString(),
279
+ schema: this.schema(),
280
+ tables: this.dumpTables(this.dataTables().filter((t) => !t.startsWith('_'))),
281
+ };
282
+ case 'dumpModule': {
283
+ const m = named();
284
+ return {
285
+ owner,
286
+ module: m.name,
287
+ at: new Date().toISOString(),
288
+ schema: { [m.name]: this.schema()[m.name] ?? 0 },
289
+ tables: this.dumpTables(this.dataTables(`${m.name}_`)),
290
+ };
291
+ }
292
+ case 'loadModule': {
293
+ // Restores one module (from its own dump), or moves rows in from an older store: table
294
+ // names without the prefix get it (`stories` → `career_stories`) when `prefix` is set.
295
+ const m = named();
296
+ const dump = a.dump;
297
+ if (!dump?.tables)
298
+ throw new PersonError(400, 'loadModule takes a dump');
299
+ if (dump.owner && dump.owner !== owner)
300
+ throw new PersonError(400, 'that dump is another person’s');
301
+ const p = `${m.name}_`;
302
+ return this.state.blockConcurrencyWhile(async () => this.loadTables(this.dataTables(p), dump.tables, (t) => a.prefix && !t.startsWith(p) ? p + t : t));
303
+ }
304
+ case 'load': {
305
+ const dump = args;
306
+ if (!dump?.tables || dump.owner !== owner)
307
+ throw new PersonError(400, 'load takes this person’s dump');
308
+ for (const m of deps.modules)
309
+ this.migrate(m);
310
+ return this.state.blockConcurrencyWhile(async () => {
311
+ const out = this.loadTables(this.dataTables().filter((t) => !t.startsWith('_')), dump.tables, (t) => t);
312
+ await this.arm();
313
+ return out;
314
+ });
315
+ }
316
+ case 'counts': {
317
+ const counts = {};
318
+ for (const t of this.dataTables())
319
+ counts[t] = Number(this.sql.exec(`SELECT COUNT(*) AS n FROM "${t}"`).toArray()[0]?.n ?? 0);
320
+ return { schema: this.schema(), counts, broken: Object.fromEntries(this.broken) };
321
+ }
322
+ case 'wipeModule': {
323
+ const m = named();
324
+ this.state.storage.transactionSync(() => {
325
+ for (const t of this.dataTables(`${m.name}_`))
326
+ this.sql.exec(`DELETE FROM "${t}"`);
327
+ this.sql.exec('DELETE FROM _wakes WHERE module = ?', m.name);
328
+ });
329
+ await this.arm();
330
+ return { wiped: m.name };
331
+ }
332
+ case 'wipe':
333
+ await this.state.storage.deleteAlarm();
334
+ await this.state.storage.deleteAll();
335
+ // The next call starts over: migrations again, then the first call claims it.
336
+ this.deps = undefined;
337
+ this.ready.clear();
338
+ this.broken.clear();
339
+ this.owner = undefined;
340
+ this.ownerRead = false;
341
+ return { wiped: true };
342
+ default:
343
+ throw new PersonError(404, `no ${OBJECT_MODULE} method ${method}`);
344
+ }
345
+ }
346
+ /** The Durable Object's alarm: each due module's wake in turn, then the next earliest time. */
347
+ async alarm() {
348
+ const deps = await this.setup();
349
+ const owner = await this.currentOwner();
350
+ if (!owner)
351
+ return;
352
+ const start = Date.now();
353
+ const due = this.sql
354
+ .exec('SELECT module, fails FROM _wakes WHERE at <= ? ORDER BY at', start + 1000)
355
+ .toArray();
356
+ for (const { module, fails } of due) {
357
+ if (Date.now() - start > ALARM_MS)
358
+ break; // the rest stay due; the alarm comes straight back
359
+ const m = deps.modules.find((x) => x.name === module);
360
+ if (!m?.wake) {
361
+ this.sql.exec('DELETE FROM _wakes WHERE module = ?', module);
362
+ continue;
363
+ }
364
+ if (!this.migrate(m)) {
365
+ this.sql.exec('UPDATE _wakes SET at = ? WHERE module = ?', Date.now() + RETRY_MS, module);
366
+ continue;
367
+ }
368
+ // A module that sets no new time is not woken again.
369
+ this.sql.exec('DELETE FROM _wakes WHERE module = ?', module);
370
+ const wake = m.wake.bind(m);
371
+ try {
372
+ await this.within(deps, owner, () => wake(this.ctx(deps, owner, module, Date.now() + (deps.wakeMs ?? 30_000))));
373
+ }
374
+ catch (err) {
375
+ const wait = Math.min(RETRY_MS * 2 ** fails, BACKOFF_MAX_MS);
376
+ this.sql.exec('INSERT INTO _wakes (module, at, fails) VALUES (?, ?, ?) ON CONFLICT (module) DO UPDATE SET at = MIN(_wakes.at, excluded.at), fails = excluded.fails', module, Date.now() + wait, fails + 1);
377
+ console.error(JSON.stringify({
378
+ event: 'person_wake_failed',
379
+ module,
380
+ fails: fails + 1,
381
+ error: String(err),
382
+ }));
383
+ }
384
+ }
385
+ await this.arm();
386
+ }
387
+ }
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-person",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.144",
4
+ "description": "Lumen's one store per person: a Durable Object whose SQLite holds every app's tables for that person, with migrations that fail per app, one alarm shared by every app, and backup, restore and delete per app or whole.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "LICENSE",
18
+ "NOTICE"
19
+ ],
20
+ "sideEffects": [
21
+ "**/*.css"
22
+ ],
23
+ "homepage": "https://github.com/columenlabs/lumen",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/columenlabs/lumen.git",
27
+ "directory": "packages/person"
28
+ },
29
+ "bugs": "https://github.com/columenlabs/lumen/issues",
30
+ "publishConfig": {
31
+ "access": "public",
32
+ "registry": "https://registry.npmjs.org/"
33
+ },
34
+ "dependencies": {}
35
+ }