@brightbeamai/chap-coordinator 0.3.0 → 0.3.2

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.
@@ -1,14 +1,138 @@
1
1
  // src/storage/sqlite.ts
2
2
  import { createRequire } from "module";
3
+
4
+ // src/storage/store.ts
5
+ var COLLECTIONS = [
6
+ "members",
7
+ "tasks",
8
+ "overrides",
9
+ "whispers",
10
+ "deliberations",
11
+ "handoffs",
12
+ "snapshots",
13
+ "route_decisions"
14
+ ];
15
+ var IDEMPOTENCY_KEYS = "idempotency_keys";
16
+ var StoreConflictError = class extends Error {
17
+ workspace;
18
+ /** Audit entries the store holds. */
19
+ held;
20
+ /** Audit entries the change expected. */
21
+ base;
22
+ constructor(workspace, held, base) {
23
+ super(
24
+ `The store holds ${held} audit entries for workspace ${workspace} and this write expected ${base}, so nothing was written. Another coordinator may be writing to this workspace, the store may have been changed outside this coordinator, or this coordinator could not load the workspace when it started. Restart this coordinator to reload.`
25
+ );
26
+ this.name = "StoreConflictError";
27
+ this.workspace = workspace;
28
+ this.held = held;
29
+ this.base = base;
30
+ }
31
+ /**
32
+ * Matched by name, so `instanceof` holds across the package's entry points,
33
+ * each of which is bundled with its own copy of this class.
34
+ */
35
+ static [Symbol.hasInstance](value) {
36
+ return value instanceof Error && value.name === "StoreConflictError";
37
+ }
38
+ };
39
+ function isCollection(name) {
40
+ return COLLECTIONS.includes(name);
41
+ }
42
+ function objectKey(collection, item) {
43
+ if (item === null || typeof item !== "object") return void 0;
44
+ const o = item;
45
+ const key = collection === "members" ? o.uri ?? o.id : o.id ?? o.uri;
46
+ return typeof key === "string" ? key : void 0;
47
+ }
48
+ function keyed(collection, value) {
49
+ const out = {};
50
+ if (Array.isArray(value)) {
51
+ for (const item of value) {
52
+ const key = objectKey(collection, item);
53
+ if (key !== void 0) out[key] = item;
54
+ }
55
+ } else if (value !== null && typeof value === "object") {
56
+ Object.assign(out, value);
57
+ }
58
+ return out;
59
+ }
60
+ function changeFromRecord(record) {
61
+ const data = record.data !== null && typeof record.data === "object" ? record.data : {};
62
+ const meta = {};
63
+ const objects = {};
64
+ let entries = [];
65
+ for (const [name, value] of Object.entries(data)) {
66
+ if (name === "audit") entries = Array.isArray(value) ? value : [];
67
+ else if (name === IDEMPOTENCY_KEYS || isCollection(name)) objects[name] = keyed(name, value);
68
+ else meta[name] = value;
69
+ }
70
+ if (meta.id === void 0) meta.id = record.id;
71
+ return {
72
+ id: record.id,
73
+ base: 0,
74
+ version: record.version,
75
+ updated_at: record.updated_at,
76
+ meta,
77
+ entries,
78
+ objects,
79
+ replace: true
80
+ };
81
+ }
82
+ function assembleSnapshot(meta, entries, objects) {
83
+ const data = { ...meta };
84
+ for (const name of COLLECTIONS) data[name] = [];
85
+ const keys = {};
86
+ data[IDEMPOTENCY_KEYS] = keys;
87
+ for (const [collection, key, value] of objects) {
88
+ if (collection === IDEMPOTENCY_KEYS) {
89
+ keys[key] = value;
90
+ } else {
91
+ const list = Array.isArray(data[collection]) ? data[collection] : data[collection] = [];
92
+ list.push(value);
93
+ }
94
+ }
95
+ data.audit = entries;
96
+ return data;
97
+ }
98
+
99
+ // src/storage/sqlite.ts
100
+ var SCHEMA = `
101
+ CREATE TABLE IF NOT EXISTS chap_meta (
102
+ workspace TEXT PRIMARY KEY,
103
+ data TEXT NOT NULL,
104
+ version INTEGER NOT NULL,
105
+ updated_at TEXT NOT NULL
106
+ );
107
+ CREATE TABLE IF NOT EXISTS chap_entries (
108
+ workspace TEXT NOT NULL,
109
+ seq INTEGER NOT NULL,
110
+ data TEXT NOT NULL,
111
+ PRIMARY KEY (workspace, seq)
112
+ );
113
+ CREATE TABLE IF NOT EXISTS chap_objects (
114
+ workspace TEXT NOT NULL,
115
+ collection TEXT NOT NULL,
116
+ id TEXT NOT NULL,
117
+ data TEXT NOT NULL,
118
+ PRIMARY KEY (workspace, collection, id)
119
+ );
120
+ `;
121
+ var LEGACY_TABLE = "chap_workspaces";
122
+ var LEGACY_VIEW = `CREATE VIEW IF NOT EXISTS ${LEGACY_TABLE} AS SELECT m.workspace AS id, json_set(m.data, ` + [
123
+ "'$.audit', json((SELECT json_group_array(json(e.data)) FROM (SELECT data FROM chap_entries WHERE workspace = m.workspace ORDER BY seq) AS e))",
124
+ ...[...COLLECTIONS, IDEMPOTENCY_KEYS].map((c) => `'$.${c}', json((SELECT json_group_object(id, json(data)) FROM chap_objects WHERE workspace = m.workspace AND collection = '${c}'))`)
125
+ ].join(", ") + ") AS data, m.version AS version, m.updated_at AS updated_at FROM chap_meta AS m";
3
126
  var SqliteStore = class {
4
127
  db;
5
- stmtLoad;
6
- stmtSave;
7
- stmtDelete;
128
+ stmt;
129
+ txWrite;
130
+ txDelete;
8
131
  constructor(path, options = {}) {
9
132
  let Database;
10
133
  try {
11
- Database = createRequire(import.meta.url)("better-sqlite3");
134
+ const here = typeof __filename === "string" ? __filename : import.meta.url;
135
+ Database = createRequire(here)("better-sqlite3");
12
136
  } catch (err) {
13
137
  const detail = err instanceof Error ? `: ${err.message.split("\n")[0]}` : "";
14
138
  throw new Error(
@@ -19,47 +143,159 @@ var SqliteStore = class {
19
143
  if (path !== ":memory:" && options.wal !== false) {
20
144
  this.db.pragma("journal_mode = WAL");
21
145
  }
22
- this.db.exec(`
23
- CREATE TABLE IF NOT EXISTS chap_workspaces (
24
- id TEXT PRIMARY KEY,
25
- data TEXT NOT NULL,
26
- version INTEGER NOT NULL,
27
- updated_at TEXT NOT NULL
28
- );
29
- CREATE INDEX IF NOT EXISTS idx_chap_workspaces_updated_at
30
- ON chap_workspaces (updated_at);
31
- `);
32
- this.stmtLoad = this.db.prepare(
33
- "SELECT id, data, version, updated_at FROM chap_workspaces"
34
- );
35
- this.stmtSave = this.db.prepare(
36
- "INSERT INTO chap_workspaces (id, data, version, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data, version = excluded.version, updated_at = excluded.updated_at"
37
- );
38
- this.stmtDelete = this.db.prepare(
39
- "DELETE FROM chap_workspaces WHERE id = ?"
40
- );
146
+ this.db.exec(SCHEMA);
147
+ const prepare = (sql) => this.db.prepare(sql);
148
+ this.stmt = {
149
+ held: prepare("SELECT MAX(seq) AS last FROM chap_entries WHERE workspace = ?"),
150
+ hasMeta: prepare("SELECT 1 FROM chap_meta WHERE workspace = ?"),
151
+ hasTable: prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?"),
152
+ hasName: prepare("SELECT 1 FROM sqlite_master WHERE name = ?"),
153
+ putEntry: prepare("INSERT INTO chap_entries (workspace, seq, data) VALUES (?, ?, ?)"),
154
+ putObject: prepare(
155
+ "INSERT INTO chap_objects (workspace, collection, id, data) VALUES (?, ?, ?, ?) ON CONFLICT(workspace, collection, id) DO UPDATE SET data = excluded.data"
156
+ ),
157
+ dropObject: prepare("DELETE FROM chap_objects WHERE workspace = ? AND collection = ? AND id = ?"),
158
+ putMeta: prepare(
159
+ "INSERT INTO chap_meta (workspace, data, version, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(workspace) DO UPDATE SET data = excluded.data, version = excluded.version, updated_at = excluded.updated_at"
160
+ ),
161
+ dropMeta: prepare("DELETE FROM chap_meta WHERE workspace = ?"),
162
+ dropEntries: prepare("DELETE FROM chap_entries WHERE workspace = ?"),
163
+ dropObjects: prepare("DELETE FROM chap_objects WHERE workspace = ?"),
164
+ metas: prepare("SELECT workspace, data, version, updated_at FROM chap_meta"),
165
+ entries: prepare("SELECT workspace, seq, data FROM chap_entries ORDER BY workspace, seq"),
166
+ objects: prepare("SELECT workspace, collection, id, data FROM chap_objects ORDER BY rowid")
167
+ };
168
+ this.txWrite = this.db.transaction((change) => this.write(change));
169
+ this.txDelete = this.db.transaction((id) => this.drop(id));
170
+ this.open();
171
+ }
172
+ /**
173
+ * In one transaction, so processes opening the file at once agree: move the
174
+ * snapshots a release before 0.3.2 wrote into the tables above, then put the
175
+ * view in the old table's place.
176
+ */
177
+ open() {
178
+ let kept = "";
179
+ const unreadable = [];
180
+ this.db.transaction(() => {
181
+ if (this.stmt.hasTable.get(LEGACY_TABLE)) {
182
+ const rows = this.db.prepare(
183
+ `SELECT id, data, version, updated_at FROM ${LEGACY_TABLE}`
184
+ ).all();
185
+ for (const r of rows) {
186
+ if (this.stmt.hasMeta.get(r.id)) continue;
187
+ let data;
188
+ try {
189
+ data = JSON.parse(r.data);
190
+ } catch {
191
+ unreadable.push(r.id);
192
+ continue;
193
+ }
194
+ const change = changeFromRecord({ id: r.id, data, version: r.version, updated_at: r.updated_at });
195
+ this.write({ ...change, version: change.entries.length });
196
+ }
197
+ kept = `${LEGACY_TABLE}_v1`;
198
+ for (let n = 2; this.stmt.hasName.get(kept); n++) kept = `${LEGACY_TABLE}_v1_${n}`;
199
+ const legacy = this.db.pragma("legacy_alter_table", { simple: true });
200
+ this.db.pragma("legacy_alter_table = ON");
201
+ try {
202
+ this.db.exec(`ALTER TABLE ${LEGACY_TABLE} RENAME TO ${kept}`);
203
+ } finally {
204
+ this.db.pragma(`legacy_alter_table = ${legacy ? "ON" : "OFF"}`);
205
+ }
206
+ this.db.exec("DROP INDEX IF EXISTS idx_chap_workspaces_updated_at");
207
+ }
208
+ if (!this.stmt.hasName.get(LEGACY_TABLE)) this.db.exec(LEGACY_VIEW);
209
+ }).immediate();
210
+ for (const id of unreadable) {
211
+ console.warn(`chap SqliteStore: workspace ${id} could not be read during the upgrade; its row is kept, unchanged, in ${kept}`);
212
+ }
213
+ }
214
+ write(change) {
215
+ const id = change.id;
216
+ if (change.replace) {
217
+ this.drop(id);
218
+ } else {
219
+ const last = this.stmt.held.get(id)?.last;
220
+ const held = last === null || last === void 0 ? 0 : last + 1;
221
+ if (held !== change.base) throw new StoreConflictError(id, held, change.base);
222
+ }
223
+ const base = change.replace ? 0 : change.base;
224
+ for (let i = 0; i < change.entries.length; i++) {
225
+ this.stmt.putEntry.run(id, base + i, JSON.stringify(change.entries[i]));
226
+ }
227
+ for (const [collection, items] of Object.entries(change.objects)) {
228
+ for (const [key, value] of Object.entries(items)) {
229
+ if (value === null || value === void 0) this.stmt.dropObject.run(id, collection, key);
230
+ else this.stmt.putObject.run(id, collection, key, JSON.stringify(value));
231
+ }
232
+ }
233
+ this.stmt.putMeta.run(id, JSON.stringify(change.meta), change.version, change.updated_at);
234
+ }
235
+ drop(id) {
236
+ this.stmt.dropMeta.run(id);
237
+ this.stmt.dropEntries.run(id);
238
+ this.stmt.dropObjects.run(id);
41
239
  }
42
240
  load() {
43
- const rows = this.stmtLoad.all();
44
- const out = [];
45
- for (const r of rows) {
241
+ return this.db.transaction(() => this.readAll())();
242
+ }
243
+ readAll() {
244
+ const broken = /* @__PURE__ */ new Map();
245
+ const entries = /* @__PURE__ */ new Map();
246
+ for (const r of this.stmt.entries.all()) {
247
+ if (broken.has(r.workspace)) continue;
248
+ let list = entries.get(r.workspace);
249
+ if (!list) entries.set(r.workspace, list = []);
250
+ if (r.seq !== list.length) {
251
+ broken.set(r.workspace, `audit entry ${list.length} is missing`);
252
+ continue;
253
+ }
254
+ try {
255
+ list.push(JSON.parse(r.data));
256
+ } catch {
257
+ broken.set(r.workspace, `audit entry ${r.seq} is unreadable`);
258
+ }
259
+ }
260
+ const objects = /* @__PURE__ */ new Map();
261
+ for (const r of this.stmt.objects.all()) {
262
+ if (broken.has(r.workspace)) continue;
263
+ let list = objects.get(r.workspace);
264
+ if (!list) objects.set(r.workspace, list = []);
46
265
  try {
47
- out.push({ id: r.id, data: JSON.parse(r.data), version: r.version, updated_at: r.updated_at });
266
+ list.push([r.collection, r.id, JSON.parse(r.data)]);
48
267
  } catch {
268
+ broken.set(r.workspace, `${r.collection} ${r.id} is unreadable`);
49
269
  }
50
270
  }
271
+ const out = [];
272
+ for (const r of this.stmt.metas.all()) {
273
+ let meta;
274
+ if (!broken.has(r.workspace)) {
275
+ try {
276
+ meta = JSON.parse(r.data);
277
+ } catch {
278
+ broken.set(r.workspace, "its own fields are unreadable");
279
+ }
280
+ }
281
+ if (!meta || typeof meta !== "object") {
282
+ console.warn(`chap SqliteStore: workspace ${r.workspace} was not loaded: ${broken.get(r.workspace) ?? "its own fields are unreadable"}`);
283
+ continue;
284
+ }
285
+ const data = assembleSnapshot(meta, entries.get(r.workspace) ?? [], objects.get(r.workspace) ?? []);
286
+ data.id = r.workspace;
287
+ out.push({ id: r.workspace, data, version: r.version, updated_at: r.updated_at });
288
+ }
51
289
  return out;
52
290
  }
53
291
  save(record) {
54
- this.stmtSave.run(
55
- record.id,
56
- JSON.stringify(record.data),
57
- record.version,
58
- record.updated_at
59
- );
292
+ this.txWrite.immediate(changeFromRecord(record));
293
+ }
294
+ apply(change) {
295
+ this.txWrite.immediate(change);
60
296
  }
61
297
  delete(id) {
62
- this.stmtDelete.run(id);
298
+ this.txDelete.immediate(id);
63
299
  }
64
300
  close() {
65
301
  this.db.close();
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/storage/sqlite.ts"],"sourcesContent":["/**\n * SQLite-backed store using better-sqlite3.\n *\n * Schema:\n * CREATE TABLE chap_workspaces (\n * id TEXT PRIMARY KEY,\n * data TEXT NOT NULL, -- JSON.stringify of snapshot\n * version INTEGER NOT NULL,\n * updated_at TEXT NOT NULL -- ISO-8601\n * )\n *\n * Usage:\n * import { Coordinator } from \"@brightbeamai/chap-coordinator\";\n * import { SqliteStore } from \"@brightbeamai/chap-coordinator/storage/sqlite\";\n *\n * const coord = new Coordinator({\n * store: new SqliteStore(\"./chap.db\"),\n * });\n *\n * Pass `\":memory:\"` for an in-memory SQLite instance (faster than the\n * MemoryStore for tests that need transactional semantics).\n *\n * better-sqlite3 is declared as an optionalDependency. If the binary\n * cannot be built (rare on Linux/macOS, common on locked-down CI), the\n * SqliteStore import will throw with a clear message and applications\n * can fall back to MemoryStore or another implementation.\n */\n\nimport { createRequire } from \"node:module\";\nimport type { Store, WorkspaceRecord } from \"./store.js\";\n\n// better-sqlite3 has no published types in its own package, but the\n// `@types/better-sqlite3` package provides them. We require a structural\n// subset so the file type-checks even if the typings are absent.\ninterface SqliteDatabase {\n exec(sql: string): unknown;\n prepare(sql: string): SqliteStatement;\n pragma(name: string, options?: { simple?: boolean }): unknown;\n close(): void;\n}\ninterface SqliteStatement {\n run(...params: unknown[]): { changes: number; lastInsertRowid: number | bigint };\n get(...params: unknown[]): unknown;\n all(...params: unknown[]): unknown[];\n}\n\nexport interface SqliteStoreOptions {\n /**\n * Enable WAL journal mode for better concurrent-read performance.\n * Default true. Set false for `:memory:` databases or when the\n * filesystem does not support WAL (some network filesystems).\n */\n wal?: boolean;\n}\n\nexport class SqliteStore implements Store {\n readonly db: SqliteDatabase;\n private readonly stmtLoad: SqliteStatement;\n private readonly stmtSave: SqliteStatement;\n private readonly stmtDelete: SqliteStatement;\n\n constructor(path: string, options: SqliteStoreOptions = {}) {\n let Database: new (path: string) => SqliteDatabase;\n try {\n // Resolved at construction so the dependency stays optional: importing\n // this module costs nothing if you never build a SqliteStore.\n //\n // createRequire rather than a bare require(). A bare one is undefined in\n // an ES module, so from source it threw ReferenceError and this catch\n // reported the dependency as missing when it was installed. The bundled\n // build masked it by shimming require, which is why it went unnoticed.\n Database = createRequire(import.meta.url)(\"better-sqlite3\");\n } catch (err) {\n const detail = err instanceof Error ? `: ${err.message.split(\"\\n\")[0]}` : \"\";\n throw new Error(\n \"SqliteStore requires the `better-sqlite3` package. \" +\n \"Install it with `npm install better-sqlite3` \" +\n `or use MemoryStore for non-persistent workloads${detail}`,\n );\n }\n\n this.db = new Database(path);\n if (path !== \":memory:\" && options.wal !== false) {\n this.db.pragma(\"journal_mode = WAL\");\n }\n this.db.exec(`\n CREATE TABLE IF NOT EXISTS chap_workspaces (\n id TEXT PRIMARY KEY,\n data TEXT NOT NULL,\n version INTEGER NOT NULL,\n updated_at TEXT NOT NULL\n );\n CREATE INDEX IF NOT EXISTS idx_chap_workspaces_updated_at\n ON chap_workspaces (updated_at);\n `);\n\n this.stmtLoad = this.db.prepare(\n \"SELECT id, data, version, updated_at FROM chap_workspaces\",\n );\n this.stmtSave = this.db.prepare(\n \"INSERT INTO chap_workspaces (id, data, version, updated_at) \" +\n \"VALUES (?, ?, ?, ?) \" +\n \"ON CONFLICT(id) DO UPDATE SET \" +\n \"data = excluded.data, \" +\n \"version = excluded.version, \" +\n \"updated_at = excluded.updated_at\",\n );\n this.stmtDelete = this.db.prepare(\n \"DELETE FROM chap_workspaces WHERE id = ?\",\n );\n }\n\n load(): WorkspaceRecord[] {\n const rows = this.stmtLoad.all() as Array<{\n id: string;\n data: string;\n version: number;\n updated_at: string;\n }>;\n const out: WorkspaceRecord[] = [];\n for (const r of rows) {\n try {\n out.push({ id: r.id, data: JSON.parse(r.data), version: r.version, updated_at: r.updated_at });\n } catch {\n // A single unreadable row must not discard every other workspace; skip it.\n }\n }\n return out;\n }\n\n save(record: WorkspaceRecord): void {\n this.stmtSave.run(\n record.id,\n JSON.stringify(record.data),\n record.version,\n record.updated_at,\n );\n }\n\n delete(id: string): void {\n this.stmtDelete.run(id);\n }\n\n close(): void {\n this.db.close();\n }\n}\n"],"mappings":";AA4BA,SAAS,qBAAqB;AA2BvB,IAAM,cAAN,MAAmC;AAAA,EAC/B;AAAA,EACQ;AAAA,EACA;AAAA,EACA;AAAA,EAEjB,YAAY,MAAc,UAA8B,CAAC,GAAG;AAC1D,QAAI;AACJ,QAAI;AAQF,iBAAW,cAAc,YAAY,GAAG,EAAE,gBAAgB;AAAA,IAC5D,SAAS,KAAK;AACZ,YAAM,SAAS,eAAe,QAAQ,KAAK,IAAI,QAAQ,MAAM,IAAI,EAAE,CAAC,CAAC,KAAK;AAC1E,YAAM,IAAI;AAAA,QACR,sJAEkD,MAAM;AAAA,MAC1D;AAAA,IACF;AAEA,SAAK,KAAK,IAAI,SAAS,IAAI;AAC3B,QAAI,SAAS,cAAc,QAAQ,QAAQ,OAAO;AAChD,WAAK,GAAG,OAAO,oBAAoB;AAAA,IACrC;AACA,SAAK,GAAG,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,KASZ;AAED,SAAK,WAAW,KAAK,GAAG;AAAA,MACtB;AAAA,IACF;AACA,SAAK,WAAW,KAAK,GAAG;AAAA,MACtB;AAAA,IAMF;AACA,SAAK,aAAa,KAAK,GAAG;AAAA,MACxB;AAAA,IACF;AAAA,EACF;AAAA,EAEA,OAA0B;AACxB,UAAM,OAAO,KAAK,SAAS,IAAI;AAM/B,UAAM,MAAyB,CAAC;AAChC,eAAW,KAAK,MAAM;AACpB,UAAI;AACF,YAAI,KAAK,EAAE,IAAI,EAAE,IAAI,MAAM,KAAK,MAAM,EAAE,IAAI,GAAG,SAAS,EAAE,SAAS,YAAY,EAAE,WAAW,CAAC;AAAA,MAC/F,QAAQ;AAAA,MAER;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAAA,EAEA,KAAK,QAA+B;AAClC,SAAK,SAAS;AAAA,MACZ,OAAO;AAAA,MACP,KAAK,UAAU,OAAO,IAAI;AAAA,MAC1B,OAAO;AAAA,MACP,OAAO;AAAA,IACT;AAAA,EACF;AAAA,EAEA,OAAO,IAAkB;AACvB,SAAK,WAAW,IAAI,EAAE;AAAA,EACxB;AAAA,EAEA,QAAc;AACZ,SAAK,GAAG,MAAM;AAAA,EAChB;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/storage/sqlite.ts","../../src/storage/store.ts"],"sourcesContent":["/**\n * SQLite-backed store using better-sqlite3.\n *\n * Schema, shared with the Python SqliteStore. Both write these tables, and\n * the same audit entries for the same calls; the objects are each\n * coordinator's own, so each restores a file it wrote, and chap-analytics\n * reads a file from either:\n *\n * CREATE TABLE chap_meta (\n * workspace TEXT PRIMARY KEY,\n * data TEXT NOT NULL, -- the workspace's own fields, JSON\n * version INTEGER NOT NULL, -- audit entries held\n * updated_at TEXT NOT NULL -- ISO-8601\n * );\n * CREATE TABLE chap_entries (\n * workspace TEXT NOT NULL,\n * seq INTEGER NOT NULL,\n * data TEXT NOT NULL, -- one audit entry, JSON\n * PRIMARY KEY (workspace, seq)\n * );\n * CREATE TABLE chap_objects (\n * workspace TEXT NOT NULL,\n * collection TEXT NOT NULL, -- members, tasks, ..., idempotency_keys\n * id TEXT NOT NULL,\n * data TEXT NOT NULL, -- one object, JSON\n * PRIMARY KEY (workspace, collection, id)\n * );\n *\n * A write adds the call's entries, updates the objects the call changed and\n * the workspace's own fields, in one transaction, so its cost does not grow\n * with the log. A write whose first entry is not the next seq the file holds\n * is refused with a StoreConflictError; see the concurrency model in\n * ./store.ts. Audit entries are only ever inserted, except when a workspace\n * is written whole: after `Coordinator.restore()`, and for a workspace an\n * application put in place itself.\n *\n * Files written by releases before 0.3.2 hold one JSON snapshot per workspace\n * in a table named chap_workspaces. Opening such a file moves each workspace\n * into the tables above, in one transaction, and renames the old table to\n * chap_workspaces_v1, which is kept as it was.\n *\n * A view named chap_workspaces takes the old table's place, with the same\n * columns, so a script that read the old table reads the current state; it\n * assembles each workspace when read, with collections keyed by id. Releases\n * before 0.3.2 refuse to open the file, since they cannot write to it.\n *\n * Usage:\n * import { Coordinator } from \"@brightbeamai/chap-coordinator\";\n * import { SqliteStore } from \"@brightbeamai/chap-coordinator/storage/sqlite\";\n *\n * const coord = new Coordinator({\n * store: new SqliteStore(\"./chap.db\"),\n * });\n *\n * Pass `\":memory:\"` for an in-memory SQLite instance (faster than the\n * MemoryStore for tests that need transactional semantics).\n *\n * better-sqlite3 is declared as an optionalDependency. If the binary\n * cannot be built (rare on Linux/macOS, common on locked-down CI), the\n * SqliteStore import will throw with a clear message and applications\n * can fall back to MemoryStore or another implementation.\n */\n\nimport { createRequire } from \"node:module\";\nimport {\n COLLECTIONS, IDEMPOTENCY_KEYS, StoreConflictError, assembleSnapshot, changeFromRecord,\n type Store, type StoreChange, type WorkspaceRecord,\n} from \"./store.js\";\n\n// better-sqlite3 has no published types in its own package, but the\n// `@types/better-sqlite3` package provides them. We require a structural\n// subset so the file type-checks even if the typings are absent.\ninterface SqliteDatabase {\n exec(sql: string): unknown;\n prepare(sql: string): SqliteStatement;\n pragma(name: string, options?: { simple?: boolean }): unknown;\n transaction<A extends unknown[], R>(fn: (...args: A) => R): SqliteTransaction<A, R>;\n close(): void;\n}\ninterface SqliteStatement {\n run(...params: unknown[]): { changes: number; lastInsertRowid: number | bigint };\n get(...params: unknown[]): unknown;\n all(...params: unknown[]): unknown[];\n}\ninterface SqliteTransaction<A extends unknown[], R = void> {\n (...args: A): R;\n immediate(...args: A): R;\n}\n\nexport interface SqliteStoreOptions {\n /**\n * Enable WAL journal mode for better concurrent-read performance.\n * Default true. Set false for `:memory:` databases or when the\n * filesystem does not support WAL (some network filesystems).\n */\n wal?: boolean;\n}\n\nconst SCHEMA = `\n CREATE TABLE IF NOT EXISTS chap_meta (\n workspace TEXT PRIMARY KEY,\n data TEXT NOT NULL,\n version INTEGER NOT NULL,\n updated_at TEXT NOT NULL\n );\n CREATE TABLE IF NOT EXISTS chap_entries (\n workspace TEXT NOT NULL,\n seq INTEGER NOT NULL,\n data TEXT NOT NULL,\n PRIMARY KEY (workspace, seq)\n );\n CREATE TABLE IF NOT EXISTS chap_objects (\n workspace TEXT NOT NULL,\n collection TEXT NOT NULL,\n id TEXT NOT NULL,\n data TEXT NOT NULL,\n PRIMARY KEY (workspace, collection, id)\n );\n`;\n\n/** The table that releases before 0.3.2 wrote, one snapshot per workspace. */\nconst LEGACY_TABLE = \"chap_workspaces\";\n\n/** A read-only view in the old table's place, for scripts that read it. */\nconst LEGACY_VIEW = `CREATE VIEW IF NOT EXISTS ${LEGACY_TABLE} AS SELECT m.workspace AS id, json_set(m.data, ` +\n [\n \"'$.audit', json((SELECT json_group_array(json(e.data)) FROM \" +\n \"(SELECT data FROM chap_entries WHERE workspace = m.workspace ORDER BY seq) AS e))\",\n ...[...COLLECTIONS, IDEMPOTENCY_KEYS].map(c =>\n `'$.${c}', json((SELECT json_group_object(id, json(data)) FROM chap_objects ` +\n `WHERE workspace = m.workspace AND collection = '${c}'))`),\n ].join(\", \") +\n \") AS data, m.version AS version, m.updated_at AS updated_at FROM chap_meta AS m\";\n\nexport class SqliteStore implements Store {\n readonly db: SqliteDatabase;\n private readonly stmt: Record<string, SqliteStatement>;\n private readonly txWrite: SqliteTransaction<[StoreChange]>;\n private readonly txDelete: SqliteTransaction<[string]>;\n\n constructor(path: string, options: SqliteStoreOptions = {}) {\n let Database: new (path: string) => SqliteDatabase;\n try {\n // Resolved at construction so the dependency stays optional: importing\n // this module costs nothing if you never build a SqliteStore.\n //\n // createRequire rather than a bare require(). A bare one is undefined in\n // an ES module, so from source it threw ReferenceError and this catch\n // reported the dependency as missing when it was installed. The bundled\n // build masked it by shimming require, which is why it went unnoticed.\n // The CommonJS build has no import.meta.url, and resolves from\n // __filename, which an ES module does not define.\n const here = typeof __filename === \"string\" ? __filename : import.meta.url;\n Database = createRequire(here)(\"better-sqlite3\");\n } catch (err) {\n const detail = err instanceof Error ? `: ${err.message.split(\"\\n\")[0]}` : \"\";\n throw new Error(\n \"SqliteStore requires the `better-sqlite3` package. \" +\n \"Install it with `npm install better-sqlite3` \" +\n `or use MemoryStore for non-persistent workloads${detail}`,\n );\n }\n\n this.db = new Database(path);\n if (path !== \":memory:\" && options.wal !== false) {\n this.db.pragma(\"journal_mode = WAL\");\n }\n this.db.exec(SCHEMA);\n\n const prepare = (sql: string) => this.db.prepare(sql);\n this.stmt = {\n held: prepare(\"SELECT MAX(seq) AS last FROM chap_entries WHERE workspace = ?\"),\n hasMeta: prepare(\"SELECT 1 FROM chap_meta WHERE workspace = ?\"),\n hasTable: prepare(\"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?\"),\n hasName: prepare(\"SELECT 1 FROM sqlite_master WHERE name = ?\"),\n putEntry: prepare(\"INSERT INTO chap_entries (workspace, seq, data) VALUES (?, ?, ?)\"),\n putObject: prepare(\n \"INSERT INTO chap_objects (workspace, collection, id, data) VALUES (?, ?, ?, ?) \" +\n \"ON CONFLICT(workspace, collection, id) DO UPDATE SET data = excluded.data\"),\n dropObject: prepare(\"DELETE FROM chap_objects WHERE workspace = ? AND collection = ? AND id = ?\"),\n putMeta: prepare(\n \"INSERT INTO chap_meta (workspace, data, version, updated_at) VALUES (?, ?, ?, ?) \" +\n \"ON CONFLICT(workspace) DO UPDATE SET data = excluded.data, \" +\n \"version = excluded.version, updated_at = excluded.updated_at\"),\n dropMeta: prepare(\"DELETE FROM chap_meta WHERE workspace = ?\"),\n dropEntries: prepare(\"DELETE FROM chap_entries WHERE workspace = ?\"),\n dropObjects: prepare(\"DELETE FROM chap_objects WHERE workspace = ?\"),\n metas: prepare(\"SELECT workspace, data, version, updated_at FROM chap_meta\"),\n entries: prepare(\"SELECT workspace, seq, data FROM chap_entries ORDER BY workspace, seq\"),\n objects: prepare(\"SELECT workspace, collection, id, data FROM chap_objects ORDER BY rowid\"),\n };\n this.txWrite = this.db.transaction((change: StoreChange) => this.write(change));\n this.txDelete = this.db.transaction((id: string) => this.drop(id));\n this.open();\n }\n\n /**\n * In one transaction, so processes opening the file at once agree: move the\n * snapshots a release before 0.3.2 wrote into the tables above, then put the\n * view in the old table's place.\n */\n private open(): void {\n let kept = \"\";\n const unreadable: string[] = [];\n this.db.transaction(() => {\n if (this.stmt.hasTable.get(LEGACY_TABLE)) {\n const rows = this.db.prepare(\n `SELECT id, data, version, updated_at FROM ${LEGACY_TABLE}`,\n ).all() as Array<{ id: string; data: string; version: number; updated_at: string }>;\n for (const r of rows) {\n // A workspace already in the new tables was written later; it stays.\n if (this.stmt.hasMeta.get(r.id)) continue;\n let data: unknown;\n try { data = JSON.parse(r.data); } catch { unreadable.push(r.id); continue; }\n const change = changeFromRecord({ id: r.id, data, version: r.version, updated_at: r.updated_at });\n this.write({ ...change, version: change.entries.length });\n }\n kept = `${LEGACY_TABLE}_v1`;\n for (let n = 2; this.stmt.hasName.get(kept); n++) kept = `${LEGACY_TABLE}_v1_${n}`;\n // With legacy_alter_table on, a view or trigger of the application's\n // own that reads the old table keeps reading it by name, so it reads\n // the view below and stays current.\n const legacy = this.db.pragma(\"legacy_alter_table\", { simple: true });\n this.db.pragma(\"legacy_alter_table = ON\");\n try {\n this.db.exec(`ALTER TABLE ${LEGACY_TABLE} RENAME TO ${kept}`);\n } finally {\n this.db.pragma(`legacy_alter_table = ${legacy ? \"ON\" : \"OFF\"}`);\n }\n // The old index moved with the table. Without it, a release before\n // 0.3.2 that opens this file tries to index the view and stops with\n // an error, which is better than starting with nothing loaded.\n this.db.exec(\"DROP INDEX IF EXISTS idx_chap_workspaces_updated_at\");\n }\n if (!this.stmt.hasName.get(LEGACY_TABLE)) this.db.exec(LEGACY_VIEW);\n }).immediate();\n for (const id of unreadable) {\n console.warn(`chap SqliteStore: workspace ${id} could not be read during the upgrade; ` +\n `its row is kept, unchanged, in ${kept}`);\n }\n }\n\n private write(change: StoreChange): void {\n const id = change.id;\n if (change.replace) {\n this.drop(id);\n } else {\n const last = (this.stmt.held.get(id) as { last: number | null } | undefined)?.last;\n const held = last === null || last === undefined ? 0 : last + 1;\n if (held !== change.base) throw new StoreConflictError(id, held, change.base);\n }\n const base = change.replace ? 0 : change.base;\n for (let i = 0; i < change.entries.length; i++) {\n this.stmt.putEntry.run(id, base + i, JSON.stringify(change.entries[i]));\n }\n for (const [collection, items] of Object.entries(change.objects)) {\n for (const [key, value] of Object.entries(items)) {\n if (value === null || value === undefined) this.stmt.dropObject.run(id, collection, key);\n else this.stmt.putObject.run(id, collection, key, JSON.stringify(value));\n }\n }\n this.stmt.putMeta.run(id, JSON.stringify(change.meta), change.version, change.updated_at);\n }\n\n private drop(id: string): void {\n this.stmt.dropMeta.run(id);\n this.stmt.dropEntries.run(id);\n this.stmt.dropObjects.run(id);\n }\n\n load(): WorkspaceRecord[] {\n // One read transaction, so a write by another process in the middle of\n // the read cannot leave the log and the objects out of step.\n return this.db.transaction(() => this.readAll())();\n }\n\n private readAll(): WorkspaceRecord[] {\n // A workspace with any unreadable row is skipped whole, with a warning,\n // so the others still load: restoring part of a workspace would put a\n // log beside objects it does not explain.\n const broken = new Map<string, string>();\n const entries = new Map<string, unknown[]>();\n for (const r of this.stmt.entries.all() as Array<{ workspace: string; seq: number; data: string }>) {\n if (broken.has(r.workspace)) continue;\n let list = entries.get(r.workspace);\n if (!list) entries.set(r.workspace, (list = []));\n if (r.seq !== list.length) { broken.set(r.workspace, `audit entry ${list.length} is missing`); continue; }\n try { list.push(JSON.parse(r.data)); }\n catch { broken.set(r.workspace, `audit entry ${r.seq} is unreadable`); }\n }\n const objects = new Map<string, Array<[string, string, unknown]>>();\n for (const r of this.stmt.objects.all() as Array<{ workspace: string; collection: string; id: string; data: string }>) {\n if (broken.has(r.workspace)) continue;\n let list = objects.get(r.workspace);\n if (!list) objects.set(r.workspace, (list = []));\n try { list.push([r.collection, r.id, JSON.parse(r.data)]); }\n catch { broken.set(r.workspace, `${r.collection} ${r.id} is unreadable`); }\n }\n const out: WorkspaceRecord[] = [];\n for (const r of this.stmt.metas.all() as Array<{ workspace: string; data: string; version: number; updated_at: string }>) {\n let meta: Record<string, unknown> | undefined;\n if (!broken.has(r.workspace)) {\n try { meta = JSON.parse(r.data) as Record<string, unknown>; }\n catch { broken.set(r.workspace, \"its own fields are unreadable\"); }\n }\n if (!meta || typeof meta !== \"object\") {\n console.warn(`chap SqliteStore: workspace ${r.workspace} was not loaded: ` +\n `${broken.get(r.workspace) ?? \"its own fields are unreadable\"}`);\n continue;\n }\n const data = assembleSnapshot(meta, entries.get(r.workspace) ?? [], objects.get(r.workspace) ?? []);\n data.id = r.workspace;\n out.push({ id: r.workspace, data, version: r.version, updated_at: r.updated_at });\n }\n return out;\n }\n\n save(record: WorkspaceRecord): void {\n this.txWrite.immediate(changeFromRecord(record));\n }\n\n apply(change: StoreChange): void {\n this.txWrite.immediate(change);\n }\n\n delete(id: string): void {\n this.txDelete.immediate(id);\n }\n\n close(): void {\n this.db.close();\n }\n}\n","/**\n * Pluggable persistence for the Coordinator.\n *\n * A Store keeps workspace state between process restarts. The Coordinator\n * calls `store.load()` once at start, and writes after every call that adds\n * to a workspace's audit log.\n *\n * How the Coordinator writes depends on what the store implements:\n *\n * - `apply(change)`: the Coordinator sends what the call changed: the new\n * audit entries, the objects it touched and the workspace's own fields.\n * The cost of a write stays flat as the log grows; it grows with the\n * objects a call changes, such as a task with a long history. MemoryStore\n * and SqliteStore implement it.\n * - `save(record)` alone: the Coordinator sends the whole workspace on every\n * write, as releases before 0.3.2 did, so the cost of a write grows with\n * the log. Stores written for earlier releases keep working this way.\n *\n * MemoryStore is the default and keeps nothing across processes. SqliteStore\n * keeps state in a SQLite file through better-sqlite3. Third-party stores\n * (Postgres, Redis and so on) implement this interface.\n *\n * Concurrency model\n * -----------------\n *\n * A Coordinator is a **single-writer** component: dispatch is serial,\n * workspace state is held in memory, and each chain link is computed from the\n * head the instance holds. Run one Coordinator per workspace.\n *\n * Every change names the number of audit entries the Coordinator expects the\n * store to hold already (`base`). MemoryStore and SqliteStore refuse a change\n * whose base differs from what they hold: they throw a StoreConflictError and\n * write nothing. A second Coordinator writing the same workspace is refused\n * and reported, and the entries already stored stay as they are. The refused\n * instance's memory no longer matches the store; restart it to reload.\n *\n * `save` makes no such check: it replaces what the store holds for the\n * workspace. A store that implements only `save` therefore still loses\n * entries silently when two instances write one workspace.\n *\n * To run more than one instance, partition workspaces so that no workspace is\n * ever written by two, or hold an external lock. See SPECIFICATION.md 10.3.\n *\n * Production deployments that need rich queries over the log can project it\n * into their own analytics store with the `onAudit` listener.\n */\n\n/** The workspace maps a store keeps object by object. */\nexport const COLLECTIONS = [\n \"members\", \"tasks\", \"overrides\", \"whispers\",\n \"deliberations\", \"handoffs\", \"snapshots\", \"route_decisions\",\n] as const;\n\n/** task.create idempotency keys, kept as one more collection of key to task id. */\nexport const IDEMPOTENCY_KEYS = \"idempotency_keys\";\n\nexport interface WorkspaceRecord {\n /** Workspace identifier. */\n id: string;\n /** JSON-serialisable payload in the shape `Coordinator.snapshot()` produces. */\n data: unknown;\n /**\n * For `save`, a counter the Coordinator increments on each write. For a\n * record a store loads after `apply`, the number of audit entries. Recorded\n * for diagnostics; `save` does not check it.\n */\n version: number;\n /** Wall-clock timestamp of the last write, ISO-8601. */\n updated_at: string;\n}\n\n/** One write to one workspace: what one or more calls changed. */\nexport interface StoreChange {\n /** Workspace identifier. */\n id: string;\n /**\n * Audit entries the store must already hold for this workspace. A store\n * that holds a different number refuses the change with a\n * StoreConflictError and writes nothing. Not checked when `replace` is set.\n */\n base: number;\n /** Audit entries the store holds after the change: `base + entries.length`. */\n version: number;\n /** Wall-clock timestamp of the write, ISO-8601. */\n updated_at: string;\n /** The workspace's own fields: everything except its collections and log. */\n meta: Record<string, unknown>;\n /** New audit entries in order. The first has seq `base`. */\n entries: unknown[];\n /**\n * Objects written, by collection and then by key. A null value removes the\n * object. The collections are those in COLLECTIONS and IDEMPOTENCY_KEYS.\n */\n objects: Record<string, Record<string, unknown>>;\n /**\n * Replace everything the store holds for this workspace with this change,\n * which then carries the whole workspace from seq 0.\n */\n replace?: boolean;\n}\n\n/**\n * A change whose base does not match the audit entries the store holds.\n * Usually a second Coordinator writing the same workspace.\n */\nexport class StoreConflictError extends Error {\n readonly workspace: string;\n /** Audit entries the store holds. */\n readonly held: number;\n /** Audit entries the change expected. */\n readonly base: number;\n\n constructor(workspace: string, held: number, base: number) {\n super(\n `The store holds ${held} audit entries for workspace ${workspace} and ` +\n `this write expected ${base}, so nothing was written. Another ` +\n `coordinator may be writing to this workspace, the store may have been ` +\n `changed outside this coordinator, or this coordinator could not load ` +\n `the workspace when it started. Restart this coordinator to reload.`,\n );\n this.name = \"StoreConflictError\";\n this.workspace = workspace;\n this.held = held;\n this.base = base;\n }\n\n /**\n * Matched by name, so `instanceof` holds across the package's entry points,\n * each of which is bundled with its own copy of this class.\n */\n static [Symbol.hasInstance](value: unknown): boolean {\n return value instanceof Error && value.name === \"StoreConflictError\";\n }\n}\n\nexport interface Store {\n /** Load all workspace records. Called once when the Coordinator starts. */\n load(): Promise<WorkspaceRecord[]> | WorkspaceRecord[];\n\n /**\n * Replace what the store holds for one workspace. The Coordinator calls it\n * after every write when the store has no `apply`.\n */\n save(record: WorkspaceRecord): Promise<void> | void;\n\n /**\n * Write one change. Optional; when present the Coordinator calls it in\n * place of `save`. It must be atomic: the whole change is written, or\n * nothing is. The change refers to the Coordinator's live objects, so\n * serialise or copy everything before returning, including in an async\n * store. Refuse a change whose `base` differs from the entries held, unless\n * `replace` is set, by throwing (or rejecting with) a StoreConflictError.\n */\n apply?(change: StoreChange): Promise<void> | void;\n\n /** Remove one workspace. */\n delete(id: string): Promise<void> | void;\n\n /** Release resources. Idempotent. */\n close?(): Promise<void> | void;\n}\n\nfunction isCollection(name: string): boolean {\n return (COLLECTIONS as readonly string[]).includes(name);\n}\n\n/** The key an object is stored under in its collection. */\nexport function objectKey(collection: string, item: unknown): string | undefined {\n if (item === null || typeof item !== \"object\") return undefined;\n const o = item as Record<string, unknown>;\n const key = collection === \"members\" ? (o.uri ?? o.id) : (o.id ?? o.uri);\n return typeof key === \"string\" ? key : undefined;\n}\n\n/**\n * A collection as a snapshot holds it, keyed. The TypeScript snapshot holds\n * arrays and the Python snapshot holds objects keyed by id; both are read.\n */\nfunction keyed(collection: string, value: unknown): Record<string, unknown> {\n const out: Record<string, unknown> = {};\n if (Array.isArray(value)) {\n for (const item of value) {\n const key = objectKey(collection, item);\n if (key !== undefined) out[key] = item;\n }\n } else if (value !== null && typeof value === \"object\") {\n Object.assign(out, value);\n }\n return out;\n}\n\n/** A whole-workspace snapshot as a change that replaces what the store holds. */\nexport function changeFromRecord(record: WorkspaceRecord): StoreChange {\n const data = (record.data !== null && typeof record.data === \"object\"\n ? record.data : {}) as Record<string, unknown>;\n const meta: Record<string, unknown> = {};\n const objects: Record<string, Record<string, unknown>> = {};\n let entries: unknown[] = [];\n for (const [name, value] of Object.entries(data)) {\n if (name === \"audit\") entries = Array.isArray(value) ? value : [];\n else if (name === IDEMPOTENCY_KEYS || isCollection(name)) objects[name] = keyed(name, value);\n else meta[name] = value;\n }\n if (meta.id === undefined) meta.id = record.id;\n return {\n id: record.id, base: 0, version: record.version, updated_at: record.updated_at,\n meta, entries, objects, replace: true,\n };\n}\n\n/**\n * Rows read back from a store, assembled into the snapshot shape\n * `Coordinator.restore()` reads: arrays for the collections, an object for\n * the idempotency keys, and the log in seq order.\n */\nexport function assembleSnapshot(\n meta: Record<string, unknown>,\n entries: unknown[],\n objects: Iterable<[collection: string, key: string, value: unknown]>,\n): Record<string, unknown> {\n const data: Record<string, unknown> = { ...meta };\n for (const name of COLLECTIONS) data[name] = [];\n const keys: Record<string, unknown> = {};\n data[IDEMPOTENCY_KEYS] = keys;\n for (const [collection, key, value] of objects) {\n if (collection === IDEMPOTENCY_KEYS) {\n keys[key] = value;\n } else {\n const list = (Array.isArray(data[collection]) ? data[collection] : (data[collection] = [])) as unknown[];\n list.push(value);\n }\n }\n data.audit = entries;\n return data;\n}\n\ninterface Held {\n meta: string;\n version: number;\n updated_at: string;\n entries: string[];\n objects: Map<string, Map<string, string>>;\n}\n\n/**\n * In-memory store. The default: it keeps nothing across processes. Like a\n * database it keeps each row as JSON text, so a Coordinator restarted on it\n * sees what was written and nothing changed later.\n */\nexport class MemoryStore implements Store {\n private held = new Map<string, Held>();\n\n load(): WorkspaceRecord[] {\n const out: WorkspaceRecord[] = [];\n for (const [id, h] of this.held) {\n const objects: Array<[string, string, unknown]> = [];\n for (const [collection, items] of h.objects) {\n for (const [key, value] of items) objects.push([collection, key, JSON.parse(value)]);\n }\n out.push({\n id,\n data: assembleSnapshot(JSON.parse(h.meta), h.entries.map(e => JSON.parse(e)), objects),\n version: h.version,\n updated_at: h.updated_at,\n });\n }\n return out;\n }\n\n save(record: WorkspaceRecord): void {\n this.apply(changeFromRecord(record));\n }\n\n apply(change: StoreChange): void {\n // Serialised before anything is held, so a change that cannot be written\n // leaves the store as it was.\n const meta = JSON.stringify(change.meta);\n const entries = change.entries.map(e => JSON.stringify(e));\n const objects = Object.entries(change.objects).map(([collection, items]) =>\n [collection, Object.entries(items).map(([key, value]) =>\n [key, value === null || value === undefined ? null : JSON.stringify(value)] as const)] as const);\n let h = this.held.get(change.id);\n if (!change.replace) {\n const holds = h ? h.entries.length : 0;\n if (holds !== change.base) throw new StoreConflictError(change.id, holds, change.base);\n }\n if (!h || change.replace) {\n h = { meta: \"{}\", version: 0, updated_at: \"\", entries: [], objects: new Map() };\n }\n for (const entry of entries) h.entries.push(entry);\n for (const [collection, items] of objects) {\n let held = h.objects.get(collection);\n if (!held) h.objects.set(collection, (held = new Map()));\n for (const [key, value] of items) {\n if (value === null) held.delete(key);\n else held.set(key, value);\n }\n }\n h.meta = meta;\n h.version = change.version;\n h.updated_at = change.updated_at;\n this.held.set(change.id, h);\n }\n\n delete(id: string): void {\n this.held.delete(id);\n }\n}\n"],"mappings":"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","names":[]}
@@ -20,26 +20,167 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/storage/store.ts
21
21
  var store_exports = {};
22
22
  __export(store_exports, {
23
- MemoryStore: () => MemoryStore
23
+ COLLECTIONS: () => COLLECTIONS,
24
+ IDEMPOTENCY_KEYS: () => IDEMPOTENCY_KEYS,
25
+ MemoryStore: () => MemoryStore,
26
+ StoreConflictError: () => StoreConflictError,
27
+ assembleSnapshot: () => assembleSnapshot,
28
+ changeFromRecord: () => changeFromRecord,
29
+ objectKey: () => objectKey
24
30
  });
25
31
  module.exports = __toCommonJS(store_exports);
32
+ var COLLECTIONS = [
33
+ "members",
34
+ "tasks",
35
+ "overrides",
36
+ "whispers",
37
+ "deliberations",
38
+ "handoffs",
39
+ "snapshots",
40
+ "route_decisions"
41
+ ];
42
+ var IDEMPOTENCY_KEYS = "idempotency_keys";
43
+ var StoreConflictError = class extends Error {
44
+ workspace;
45
+ /** Audit entries the store holds. */
46
+ held;
47
+ /** Audit entries the change expected. */
48
+ base;
49
+ constructor(workspace, held, base) {
50
+ super(
51
+ `The store holds ${held} audit entries for workspace ${workspace} and this write expected ${base}, so nothing was written. Another coordinator may be writing to this workspace, the store may have been changed outside this coordinator, or this coordinator could not load the workspace when it started. Restart this coordinator to reload.`
52
+ );
53
+ this.name = "StoreConflictError";
54
+ this.workspace = workspace;
55
+ this.held = held;
56
+ this.base = base;
57
+ }
58
+ /**
59
+ * Matched by name, so `instanceof` holds across the package's entry points,
60
+ * each of which is bundled with its own copy of this class.
61
+ */
62
+ static [Symbol.hasInstance](value) {
63
+ return value instanceof Error && value.name === "StoreConflictError";
64
+ }
65
+ };
66
+ function isCollection(name) {
67
+ return COLLECTIONS.includes(name);
68
+ }
69
+ function objectKey(collection, item) {
70
+ if (item === null || typeof item !== "object") return void 0;
71
+ const o = item;
72
+ const key = collection === "members" ? o.uri ?? o.id : o.id ?? o.uri;
73
+ return typeof key === "string" ? key : void 0;
74
+ }
75
+ function keyed(collection, value) {
76
+ const out = {};
77
+ if (Array.isArray(value)) {
78
+ for (const item of value) {
79
+ const key = objectKey(collection, item);
80
+ if (key !== void 0) out[key] = item;
81
+ }
82
+ } else if (value !== null && typeof value === "object") {
83
+ Object.assign(out, value);
84
+ }
85
+ return out;
86
+ }
87
+ function changeFromRecord(record) {
88
+ const data = record.data !== null && typeof record.data === "object" ? record.data : {};
89
+ const meta = {};
90
+ const objects = {};
91
+ let entries = [];
92
+ for (const [name, value] of Object.entries(data)) {
93
+ if (name === "audit") entries = Array.isArray(value) ? value : [];
94
+ else if (name === IDEMPOTENCY_KEYS || isCollection(name)) objects[name] = keyed(name, value);
95
+ else meta[name] = value;
96
+ }
97
+ if (meta.id === void 0) meta.id = record.id;
98
+ return {
99
+ id: record.id,
100
+ base: 0,
101
+ version: record.version,
102
+ updated_at: record.updated_at,
103
+ meta,
104
+ entries,
105
+ objects,
106
+ replace: true
107
+ };
108
+ }
109
+ function assembleSnapshot(meta, entries, objects) {
110
+ const data = { ...meta };
111
+ for (const name of COLLECTIONS) data[name] = [];
112
+ const keys = {};
113
+ data[IDEMPOTENCY_KEYS] = keys;
114
+ for (const [collection, key, value] of objects) {
115
+ if (collection === IDEMPOTENCY_KEYS) {
116
+ keys[key] = value;
117
+ } else {
118
+ const list = Array.isArray(data[collection]) ? data[collection] : data[collection] = [];
119
+ list.push(value);
120
+ }
121
+ }
122
+ data.audit = entries;
123
+ return data;
124
+ }
26
125
  var MemoryStore = class {
27
- records = /* @__PURE__ */ new Map();
126
+ held = /* @__PURE__ */ new Map();
28
127
  load() {
29
- return Array.from(this.records.values());
128
+ const out = [];
129
+ for (const [id, h] of this.held) {
130
+ const objects = [];
131
+ for (const [collection, items] of h.objects) {
132
+ for (const [key, value] of items) objects.push([collection, key, JSON.parse(value)]);
133
+ }
134
+ out.push({
135
+ id,
136
+ data: assembleSnapshot(JSON.parse(h.meta), h.entries.map((e) => JSON.parse(e)), objects),
137
+ version: h.version,
138
+ updated_at: h.updated_at
139
+ });
140
+ }
141
+ return out;
30
142
  }
31
143
  save(record) {
32
- this.records.set(record.id, record);
144
+ this.apply(changeFromRecord(record));
33
145
  }
34
- delete(id) {
35
- this.records.delete(id);
146
+ apply(change) {
147
+ const meta = JSON.stringify(change.meta);
148
+ const entries = change.entries.map((e) => JSON.stringify(e));
149
+ const objects = Object.entries(change.objects).map(([collection, items]) => [collection, Object.entries(items).map(([key, value]) => [key, value === null || value === void 0 ? null : JSON.stringify(value)])]);
150
+ let h = this.held.get(change.id);
151
+ if (!change.replace) {
152
+ const holds = h ? h.entries.length : 0;
153
+ if (holds !== change.base) throw new StoreConflictError(change.id, holds, change.base);
154
+ }
155
+ if (!h || change.replace) {
156
+ h = { meta: "{}", version: 0, updated_at: "", entries: [], objects: /* @__PURE__ */ new Map() };
157
+ }
158
+ for (const entry of entries) h.entries.push(entry);
159
+ for (const [collection, items] of objects) {
160
+ let held = h.objects.get(collection);
161
+ if (!held) h.objects.set(collection, held = /* @__PURE__ */ new Map());
162
+ for (const [key, value] of items) {
163
+ if (value === null) held.delete(key);
164
+ else held.set(key, value);
165
+ }
166
+ }
167
+ h.meta = meta;
168
+ h.version = change.version;
169
+ h.updated_at = change.updated_at;
170
+ this.held.set(change.id, h);
36
171
  }
37
- close() {
38
- this.records.clear();
172
+ delete(id) {
173
+ this.held.delete(id);
39
174
  }
40
175
  };
41
176
  // Annotate the CommonJS export names for ESM import in node:
42
177
  0 && (module.exports = {
43
- MemoryStore
178
+ COLLECTIONS,
179
+ IDEMPOTENCY_KEYS,
180
+ MemoryStore,
181
+ StoreConflictError,
182
+ assembleSnapshot,
183
+ changeFromRecord,
184
+ objectKey
44
185
  });
45
186
  //# sourceMappingURL=store.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/storage/store.ts"],"sourcesContent":["/**\n * Pluggable persistence for the Coordinator.\n *\n * A Store persists workspace state between process restarts. Coordinators\n * call `store.save(workspaceId, snapshot)` after every mutation that\n * appends to the audit chain, and `store.load()` on startup to restore.\n *\n * Two implementations ship in-tree:\n * - MemoryStore (default, no persistence; existing behaviour)\n * - SqliteStore (real SQLite via better-sqlite3, opt-in)\n *\n * Third-party stores (Postgres, Redis, etc.) implement this interface.\n *\n * Concurrency model\n * -----------------\n *\n * A Coordinator is a **single-writer** component: dispatch is serial, workspace\n * state is held in memory, and each chain link is computed from the head the\n * instance holds. This interface is a persistence mechanism, not a\n * concurrency-control one. `save` is an unconditional write and no\n * implementation rejects a stale record.\n *\n * Running two or more Coordinator instances against one shared store is\n * therefore not supported. A stale instance's write replaces a newer one\n * wholesale: entries written by the other instance are lost, the chain is\n * re-linked from the surviving head, and `audit.verify_chain` still reports\n * `ok`, because the surviving chain is internally consistent. The loss is\n * silent and CHAP's own verification will not reveal it.\n *\n * If you need more than one instance, serialise writes above the Coordinator by\n * partitioning workspaces so no workspace is ever written by two, or by holding\n * an external lock. See SPECIFICATION.md section 10.3.\n *\n * Stores hold per-workspace JSON snapshots, not a decomposed relational\n * model. The Coordinator's snapshot/restore methods produce JSON-safe\n * payloads; this is fast for workspaces in the low-thousands of audit\n * entries and trades query flexibility for simplicity. Production\n * deployments needing rich query semantics should project the audit log\n * into their own analytics store via the `onAudit` listener.\n */\n\nexport interface WorkspaceRecord {\n /** Workspace identifier. */\n id: string;\n /** JSON-serialisable payload produced by `Coordinator.snapshot()`. */\n data: unknown;\n /**\n * Incremented on each save. Recorded for diagnostics and for stores that\n * want it; it is NOT enforced. Nothing here performs a compare-and-swap,\n * so this field does not give you optimistic concurrency. See the\n * concurrency model in this module's header.\n */\n version: number;\n /** Wall-clock timestamp of the last write, ISO-8601. */\n updated_at: string;\n}\n\nexport interface Store {\n /** Load all workspace records into memory. Called once on coordinator start. */\n load(): Promise<WorkspaceRecord[]> | WorkspaceRecord[];\n\n /** Persist one workspace record. Called after every mutation. */\n save(record: WorkspaceRecord): Promise<void> | void;\n\n /** Remove one workspace. Used by `workspace.delete` semantics (not in v0.2). */\n delete(id: string): Promise<void> | void;\n\n /** Release resources. Idempotent. */\n close?(): Promise<void> | void;\n}\n\n/**\n * In-memory store. Default behaviour: no persistence, fast tests.\n * State lives in a Map; closing it discards everything.\n */\nexport class MemoryStore implements Store {\n private records = new Map<string, WorkspaceRecord>();\n\n load(): WorkspaceRecord[] {\n return Array.from(this.records.values());\n }\n\n save(record: WorkspaceRecord): void {\n this.records.set(record.id, record);\n }\n\n delete(id: string): void {\n this.records.delete(id);\n }\n\n close(): void {\n this.records.clear();\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AA2EO,IAAM,cAAN,MAAmC;AAAA,EAChC,UAAU,oBAAI,IAA6B;AAAA,EAEnD,OAA0B;AACxB,WAAO,MAAM,KAAK,KAAK,QAAQ,OAAO,CAAC;AAAA,EACzC;AAAA,EAEA,KAAK,QAA+B;AAClC,SAAK,QAAQ,IAAI,OAAO,IAAI,MAAM;AAAA,EACpC;AAAA,EAEA,OAAO,IAAkB;AACvB,SAAK,QAAQ,OAAO,EAAE;AAAA,EACxB;AAAA,EAEA,QAAc;AACZ,SAAK,QAAQ,MAAM;AAAA,EACrB;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/storage/store.ts"],"sourcesContent":["/**\n * Pluggable persistence for the Coordinator.\n *\n * A Store keeps workspace state between process restarts. The Coordinator\n * calls `store.load()` once at start, and writes after every call that adds\n * to a workspace's audit log.\n *\n * How the Coordinator writes depends on what the store implements:\n *\n * - `apply(change)`: the Coordinator sends what the call changed: the new\n * audit entries, the objects it touched and the workspace's own fields.\n * The cost of a write stays flat as the log grows; it grows with the\n * objects a call changes, such as a task with a long history. MemoryStore\n * and SqliteStore implement it.\n * - `save(record)` alone: the Coordinator sends the whole workspace on every\n * write, as releases before 0.3.2 did, so the cost of a write grows with\n * the log. Stores written for earlier releases keep working this way.\n *\n * MemoryStore is the default and keeps nothing across processes. SqliteStore\n * keeps state in a SQLite file through better-sqlite3. Third-party stores\n * (Postgres, Redis and so on) implement this interface.\n *\n * Concurrency model\n * -----------------\n *\n * A Coordinator is a **single-writer** component: dispatch is serial,\n * workspace state is held in memory, and each chain link is computed from the\n * head the instance holds. Run one Coordinator per workspace.\n *\n * Every change names the number of audit entries the Coordinator expects the\n * store to hold already (`base`). MemoryStore and SqliteStore refuse a change\n * whose base differs from what they hold: they throw a StoreConflictError and\n * write nothing. A second Coordinator writing the same workspace is refused\n * and reported, and the entries already stored stay as they are. The refused\n * instance's memory no longer matches the store; restart it to reload.\n *\n * `save` makes no such check: it replaces what the store holds for the\n * workspace. A store that implements only `save` therefore still loses\n * entries silently when two instances write one workspace.\n *\n * To run more than one instance, partition workspaces so that no workspace is\n * ever written by two, or hold an external lock. See SPECIFICATION.md 10.3.\n *\n * Production deployments that need rich queries over the log can project it\n * into their own analytics store with the `onAudit` listener.\n */\n\n/** The workspace maps a store keeps object by object. */\nexport const COLLECTIONS = [\n \"members\", \"tasks\", \"overrides\", \"whispers\",\n \"deliberations\", \"handoffs\", \"snapshots\", \"route_decisions\",\n] as const;\n\n/** task.create idempotency keys, kept as one more collection of key to task id. */\nexport const IDEMPOTENCY_KEYS = \"idempotency_keys\";\n\nexport interface WorkspaceRecord {\n /** Workspace identifier. */\n id: string;\n /** JSON-serialisable payload in the shape `Coordinator.snapshot()` produces. */\n data: unknown;\n /**\n * For `save`, a counter the Coordinator increments on each write. For a\n * record a store loads after `apply`, the number of audit entries. Recorded\n * for diagnostics; `save` does not check it.\n */\n version: number;\n /** Wall-clock timestamp of the last write, ISO-8601. */\n updated_at: string;\n}\n\n/** One write to one workspace: what one or more calls changed. */\nexport interface StoreChange {\n /** Workspace identifier. */\n id: string;\n /**\n * Audit entries the store must already hold for this workspace. A store\n * that holds a different number refuses the change with a\n * StoreConflictError and writes nothing. Not checked when `replace` is set.\n */\n base: number;\n /** Audit entries the store holds after the change: `base + entries.length`. */\n version: number;\n /** Wall-clock timestamp of the write, ISO-8601. */\n updated_at: string;\n /** The workspace's own fields: everything except its collections and log. */\n meta: Record<string, unknown>;\n /** New audit entries in order. The first has seq `base`. */\n entries: unknown[];\n /**\n * Objects written, by collection and then by key. A null value removes the\n * object. The collections are those in COLLECTIONS and IDEMPOTENCY_KEYS.\n */\n objects: Record<string, Record<string, unknown>>;\n /**\n * Replace everything the store holds for this workspace with this change,\n * which then carries the whole workspace from seq 0.\n */\n replace?: boolean;\n}\n\n/**\n * A change whose base does not match the audit entries the store holds.\n * Usually a second Coordinator writing the same workspace.\n */\nexport class StoreConflictError extends Error {\n readonly workspace: string;\n /** Audit entries the store holds. */\n readonly held: number;\n /** Audit entries the change expected. */\n readonly base: number;\n\n constructor(workspace: string, held: number, base: number) {\n super(\n `The store holds ${held} audit entries for workspace ${workspace} and ` +\n `this write expected ${base}, so nothing was written. Another ` +\n `coordinator may be writing to this workspace, the store may have been ` +\n `changed outside this coordinator, or this coordinator could not load ` +\n `the workspace when it started. Restart this coordinator to reload.`,\n );\n this.name = \"StoreConflictError\";\n this.workspace = workspace;\n this.held = held;\n this.base = base;\n }\n\n /**\n * Matched by name, so `instanceof` holds across the package's entry points,\n * each of which is bundled with its own copy of this class.\n */\n static [Symbol.hasInstance](value: unknown): boolean {\n return value instanceof Error && value.name === \"StoreConflictError\";\n }\n}\n\nexport interface Store {\n /** Load all workspace records. Called once when the Coordinator starts. */\n load(): Promise<WorkspaceRecord[]> | WorkspaceRecord[];\n\n /**\n * Replace what the store holds for one workspace. The Coordinator calls it\n * after every write when the store has no `apply`.\n */\n save(record: WorkspaceRecord): Promise<void> | void;\n\n /**\n * Write one change. Optional; when present the Coordinator calls it in\n * place of `save`. It must be atomic: the whole change is written, or\n * nothing is. The change refers to the Coordinator's live objects, so\n * serialise or copy everything before returning, including in an async\n * store. Refuse a change whose `base` differs from the entries held, unless\n * `replace` is set, by throwing (or rejecting with) a StoreConflictError.\n */\n apply?(change: StoreChange): Promise<void> | void;\n\n /** Remove one workspace. */\n delete(id: string): Promise<void> | void;\n\n /** Release resources. Idempotent. */\n close?(): Promise<void> | void;\n}\n\nfunction isCollection(name: string): boolean {\n return (COLLECTIONS as readonly string[]).includes(name);\n}\n\n/** The key an object is stored under in its collection. */\nexport function objectKey(collection: string, item: unknown): string | undefined {\n if (item === null || typeof item !== \"object\") return undefined;\n const o = item as Record<string, unknown>;\n const key = collection === \"members\" ? (o.uri ?? o.id) : (o.id ?? o.uri);\n return typeof key === \"string\" ? key : undefined;\n}\n\n/**\n * A collection as a snapshot holds it, keyed. The TypeScript snapshot holds\n * arrays and the Python snapshot holds objects keyed by id; both are read.\n */\nfunction keyed(collection: string, value: unknown): Record<string, unknown> {\n const out: Record<string, unknown> = {};\n if (Array.isArray(value)) {\n for (const item of value) {\n const key = objectKey(collection, item);\n if (key !== undefined) out[key] = item;\n }\n } else if (value !== null && typeof value === \"object\") {\n Object.assign(out, value);\n }\n return out;\n}\n\n/** A whole-workspace snapshot as a change that replaces what the store holds. */\nexport function changeFromRecord(record: WorkspaceRecord): StoreChange {\n const data = (record.data !== null && typeof record.data === \"object\"\n ? record.data : {}) as Record<string, unknown>;\n const meta: Record<string, unknown> = {};\n const objects: Record<string, Record<string, unknown>> = {};\n let entries: unknown[] = [];\n for (const [name, value] of Object.entries(data)) {\n if (name === \"audit\") entries = Array.isArray(value) ? value : [];\n else if (name === IDEMPOTENCY_KEYS || isCollection(name)) objects[name] = keyed(name, value);\n else meta[name] = value;\n }\n if (meta.id === undefined) meta.id = record.id;\n return {\n id: record.id, base: 0, version: record.version, updated_at: record.updated_at,\n meta, entries, objects, replace: true,\n };\n}\n\n/**\n * Rows read back from a store, assembled into the snapshot shape\n * `Coordinator.restore()` reads: arrays for the collections, an object for\n * the idempotency keys, and the log in seq order.\n */\nexport function assembleSnapshot(\n meta: Record<string, unknown>,\n entries: unknown[],\n objects: Iterable<[collection: string, key: string, value: unknown]>,\n): Record<string, unknown> {\n const data: Record<string, unknown> = { ...meta };\n for (const name of COLLECTIONS) data[name] = [];\n const keys: Record<string, unknown> = {};\n data[IDEMPOTENCY_KEYS] = keys;\n for (const [collection, key, value] of objects) {\n if (collection === IDEMPOTENCY_KEYS) {\n keys[key] = value;\n } else {\n const list = (Array.isArray(data[collection]) ? data[collection] : (data[collection] = [])) as unknown[];\n list.push(value);\n }\n }\n data.audit = entries;\n return data;\n}\n\ninterface Held {\n meta: string;\n version: number;\n updated_at: string;\n entries: string[];\n objects: Map<string, Map<string, string>>;\n}\n\n/**\n * In-memory store. The default: it keeps nothing across processes. Like a\n * database it keeps each row as JSON text, so a Coordinator restarted on it\n * sees what was written and nothing changed later.\n */\nexport class MemoryStore implements Store {\n private held = new Map<string, Held>();\n\n load(): WorkspaceRecord[] {\n const out: WorkspaceRecord[] = [];\n for (const [id, h] of this.held) {\n const objects: Array<[string, string, unknown]> = [];\n for (const [collection, items] of h.objects) {\n for (const [key, value] of items) objects.push([collection, key, JSON.parse(value)]);\n }\n out.push({\n id,\n data: assembleSnapshot(JSON.parse(h.meta), h.entries.map(e => JSON.parse(e)), objects),\n version: h.version,\n updated_at: h.updated_at,\n });\n }\n return out;\n }\n\n save(record: WorkspaceRecord): void {\n this.apply(changeFromRecord(record));\n }\n\n apply(change: StoreChange): void {\n // Serialised before anything is held, so a change that cannot be written\n // leaves the store as it was.\n const meta = JSON.stringify(change.meta);\n const entries = change.entries.map(e => JSON.stringify(e));\n const objects = Object.entries(change.objects).map(([collection, items]) =>\n [collection, Object.entries(items).map(([key, value]) =>\n [key, value === null || value === undefined ? null : JSON.stringify(value)] as const)] as const);\n let h = this.held.get(change.id);\n if (!change.replace) {\n const holds = h ? h.entries.length : 0;\n if (holds !== change.base) throw new StoreConflictError(change.id, holds, change.base);\n }\n if (!h || change.replace) {\n h = { meta: \"{}\", version: 0, updated_at: \"\", entries: [], objects: new Map() };\n }\n for (const entry of entries) h.entries.push(entry);\n for (const [collection, items] of objects) {\n let held = h.objects.get(collection);\n if (!held) h.objects.set(collection, (held = new Map()));\n for (const [key, value] of items) {\n if (value === null) held.delete(key);\n else held.set(key, value);\n }\n }\n h.meta = meta;\n h.version = change.version;\n h.updated_at = change.updated_at;\n this.held.set(change.id, h);\n }\n\n delete(id: string): void {\n this.held.delete(id);\n }\n}\n"],"mappings":"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","names":[]}