@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.
@@ -24,16 +24,140 @@ __export(sqlite_exports, {
24
24
  });
25
25
  module.exports = __toCommonJS(sqlite_exports);
26
26
  var import_node_module = require("module");
27
+
28
+ // src/storage/store.ts
29
+ var COLLECTIONS = [
30
+ "members",
31
+ "tasks",
32
+ "overrides",
33
+ "whispers",
34
+ "deliberations",
35
+ "handoffs",
36
+ "snapshots",
37
+ "route_decisions"
38
+ ];
39
+ var IDEMPOTENCY_KEYS = "idempotency_keys";
40
+ var StoreConflictError = class extends Error {
41
+ workspace;
42
+ /** Audit entries the store holds. */
43
+ held;
44
+ /** Audit entries the change expected. */
45
+ base;
46
+ constructor(workspace, held, base) {
47
+ super(
48
+ `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.`
49
+ );
50
+ this.name = "StoreConflictError";
51
+ this.workspace = workspace;
52
+ this.held = held;
53
+ this.base = base;
54
+ }
55
+ /**
56
+ * Matched by name, so `instanceof` holds across the package's entry points,
57
+ * each of which is bundled with its own copy of this class.
58
+ */
59
+ static [Symbol.hasInstance](value) {
60
+ return value instanceof Error && value.name === "StoreConflictError";
61
+ }
62
+ };
63
+ function isCollection(name) {
64
+ return COLLECTIONS.includes(name);
65
+ }
66
+ function objectKey(collection, item) {
67
+ if (item === null || typeof item !== "object") return void 0;
68
+ const o = item;
69
+ const key = collection === "members" ? o.uri ?? o.id : o.id ?? o.uri;
70
+ return typeof key === "string" ? key : void 0;
71
+ }
72
+ function keyed(collection, value) {
73
+ const out = {};
74
+ if (Array.isArray(value)) {
75
+ for (const item of value) {
76
+ const key = objectKey(collection, item);
77
+ if (key !== void 0) out[key] = item;
78
+ }
79
+ } else if (value !== null && typeof value === "object") {
80
+ Object.assign(out, value);
81
+ }
82
+ return out;
83
+ }
84
+ function changeFromRecord(record) {
85
+ const data = record.data !== null && typeof record.data === "object" ? record.data : {};
86
+ const meta = {};
87
+ const objects = {};
88
+ let entries = [];
89
+ for (const [name, value] of Object.entries(data)) {
90
+ if (name === "audit") entries = Array.isArray(value) ? value : [];
91
+ else if (name === IDEMPOTENCY_KEYS || isCollection(name)) objects[name] = keyed(name, value);
92
+ else meta[name] = value;
93
+ }
94
+ if (meta.id === void 0) meta.id = record.id;
95
+ return {
96
+ id: record.id,
97
+ base: 0,
98
+ version: record.version,
99
+ updated_at: record.updated_at,
100
+ meta,
101
+ entries,
102
+ objects,
103
+ replace: true
104
+ };
105
+ }
106
+ function assembleSnapshot(meta, entries, objects) {
107
+ const data = { ...meta };
108
+ for (const name of COLLECTIONS) data[name] = [];
109
+ const keys = {};
110
+ data[IDEMPOTENCY_KEYS] = keys;
111
+ for (const [collection, key, value] of objects) {
112
+ if (collection === IDEMPOTENCY_KEYS) {
113
+ keys[key] = value;
114
+ } else {
115
+ const list = Array.isArray(data[collection]) ? data[collection] : data[collection] = [];
116
+ list.push(value);
117
+ }
118
+ }
119
+ data.audit = entries;
120
+ return data;
121
+ }
122
+
123
+ // src/storage/sqlite.ts
27
124
  var import_meta = {};
125
+ var SCHEMA = `
126
+ CREATE TABLE IF NOT EXISTS chap_meta (
127
+ workspace TEXT PRIMARY KEY,
128
+ data TEXT NOT NULL,
129
+ version INTEGER NOT NULL,
130
+ updated_at TEXT NOT NULL
131
+ );
132
+ CREATE TABLE IF NOT EXISTS chap_entries (
133
+ workspace TEXT NOT NULL,
134
+ seq INTEGER NOT NULL,
135
+ data TEXT NOT NULL,
136
+ PRIMARY KEY (workspace, seq)
137
+ );
138
+ CREATE TABLE IF NOT EXISTS chap_objects (
139
+ workspace TEXT NOT NULL,
140
+ collection TEXT NOT NULL,
141
+ id TEXT NOT NULL,
142
+ data TEXT NOT NULL,
143
+ PRIMARY KEY (workspace, collection, id)
144
+ );
145
+ `;
146
+ var LEGACY_TABLE = "chap_workspaces";
147
+ var LEGACY_VIEW = `CREATE VIEW IF NOT EXISTS ${LEGACY_TABLE} AS SELECT m.workspace AS id, json_set(m.data, ` + [
148
+ "'$.audit', json((SELECT json_group_array(json(e.data)) FROM (SELECT data FROM chap_entries WHERE workspace = m.workspace ORDER BY seq) AS e))",
149
+ ...[...COLLECTIONS, IDEMPOTENCY_KEYS].map((c) => `'$.${c}', json((SELECT json_group_object(id, json(data)) FROM chap_objects WHERE workspace = m.workspace AND collection = '${c}'))`)
150
+ ].join(", ") + ") AS data, m.version AS version, m.updated_at AS updated_at FROM chap_meta AS m";
28
151
  var SqliteStore = class {
29
152
  db;
30
- stmtLoad;
31
- stmtSave;
32
- stmtDelete;
153
+ stmt;
154
+ txWrite;
155
+ txDelete;
33
156
  constructor(path, options = {}) {
34
157
  let Database;
35
158
  try {
36
- Database = (0, import_node_module.createRequire)(import_meta.url)("better-sqlite3");
159
+ const here = typeof __filename === "string" ? __filename : import_meta.url;
160
+ Database = (0, import_node_module.createRequire)(here)("better-sqlite3");
37
161
  } catch (err) {
38
162
  const detail = err instanceof Error ? `: ${err.message.split("\n")[0]}` : "";
39
163
  throw new Error(
@@ -44,47 +168,159 @@ var SqliteStore = class {
44
168
  if (path !== ":memory:" && options.wal !== false) {
45
169
  this.db.pragma("journal_mode = WAL");
46
170
  }
47
- this.db.exec(`
48
- CREATE TABLE IF NOT EXISTS chap_workspaces (
49
- id TEXT PRIMARY KEY,
50
- data TEXT NOT NULL,
51
- version INTEGER NOT NULL,
52
- updated_at TEXT NOT NULL
53
- );
54
- CREATE INDEX IF NOT EXISTS idx_chap_workspaces_updated_at
55
- ON chap_workspaces (updated_at);
56
- `);
57
- this.stmtLoad = this.db.prepare(
58
- "SELECT id, data, version, updated_at FROM chap_workspaces"
59
- );
60
- this.stmtSave = this.db.prepare(
61
- "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"
62
- );
63
- this.stmtDelete = this.db.prepare(
64
- "DELETE FROM chap_workspaces WHERE id = ?"
65
- );
171
+ this.db.exec(SCHEMA);
172
+ const prepare = (sql) => this.db.prepare(sql);
173
+ this.stmt = {
174
+ held: prepare("SELECT MAX(seq) AS last FROM chap_entries WHERE workspace = ?"),
175
+ hasMeta: prepare("SELECT 1 FROM chap_meta WHERE workspace = ?"),
176
+ hasTable: prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?"),
177
+ hasName: prepare("SELECT 1 FROM sqlite_master WHERE name = ?"),
178
+ putEntry: prepare("INSERT INTO chap_entries (workspace, seq, data) VALUES (?, ?, ?)"),
179
+ putObject: prepare(
180
+ "INSERT INTO chap_objects (workspace, collection, id, data) VALUES (?, ?, ?, ?) ON CONFLICT(workspace, collection, id) DO UPDATE SET data = excluded.data"
181
+ ),
182
+ dropObject: prepare("DELETE FROM chap_objects WHERE workspace = ? AND collection = ? AND id = ?"),
183
+ putMeta: prepare(
184
+ "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"
185
+ ),
186
+ dropMeta: prepare("DELETE FROM chap_meta WHERE workspace = ?"),
187
+ dropEntries: prepare("DELETE FROM chap_entries WHERE workspace = ?"),
188
+ dropObjects: prepare("DELETE FROM chap_objects WHERE workspace = ?"),
189
+ metas: prepare("SELECT workspace, data, version, updated_at FROM chap_meta"),
190
+ entries: prepare("SELECT workspace, seq, data FROM chap_entries ORDER BY workspace, seq"),
191
+ objects: prepare("SELECT workspace, collection, id, data FROM chap_objects ORDER BY rowid")
192
+ };
193
+ this.txWrite = this.db.transaction((change) => this.write(change));
194
+ this.txDelete = this.db.transaction((id) => this.drop(id));
195
+ this.open();
196
+ }
197
+ /**
198
+ * In one transaction, so processes opening the file at once agree: move the
199
+ * snapshots a release before 0.3.2 wrote into the tables above, then put the
200
+ * view in the old table's place.
201
+ */
202
+ open() {
203
+ let kept = "";
204
+ const unreadable = [];
205
+ this.db.transaction(() => {
206
+ if (this.stmt.hasTable.get(LEGACY_TABLE)) {
207
+ const rows = this.db.prepare(
208
+ `SELECT id, data, version, updated_at FROM ${LEGACY_TABLE}`
209
+ ).all();
210
+ for (const r of rows) {
211
+ if (this.stmt.hasMeta.get(r.id)) continue;
212
+ let data;
213
+ try {
214
+ data = JSON.parse(r.data);
215
+ } catch {
216
+ unreadable.push(r.id);
217
+ continue;
218
+ }
219
+ const change = changeFromRecord({ id: r.id, data, version: r.version, updated_at: r.updated_at });
220
+ this.write({ ...change, version: change.entries.length });
221
+ }
222
+ kept = `${LEGACY_TABLE}_v1`;
223
+ for (let n = 2; this.stmt.hasName.get(kept); n++) kept = `${LEGACY_TABLE}_v1_${n}`;
224
+ const legacy = this.db.pragma("legacy_alter_table", { simple: true });
225
+ this.db.pragma("legacy_alter_table = ON");
226
+ try {
227
+ this.db.exec(`ALTER TABLE ${LEGACY_TABLE} RENAME TO ${kept}`);
228
+ } finally {
229
+ this.db.pragma(`legacy_alter_table = ${legacy ? "ON" : "OFF"}`);
230
+ }
231
+ this.db.exec("DROP INDEX IF EXISTS idx_chap_workspaces_updated_at");
232
+ }
233
+ if (!this.stmt.hasName.get(LEGACY_TABLE)) this.db.exec(LEGACY_VIEW);
234
+ }).immediate();
235
+ for (const id of unreadable) {
236
+ console.warn(`chap SqliteStore: workspace ${id} could not be read during the upgrade; its row is kept, unchanged, in ${kept}`);
237
+ }
238
+ }
239
+ write(change) {
240
+ const id = change.id;
241
+ if (change.replace) {
242
+ this.drop(id);
243
+ } else {
244
+ const last = this.stmt.held.get(id)?.last;
245
+ const held = last === null || last === void 0 ? 0 : last + 1;
246
+ if (held !== change.base) throw new StoreConflictError(id, held, change.base);
247
+ }
248
+ const base = change.replace ? 0 : change.base;
249
+ for (let i = 0; i < change.entries.length; i++) {
250
+ this.stmt.putEntry.run(id, base + i, JSON.stringify(change.entries[i]));
251
+ }
252
+ for (const [collection, items] of Object.entries(change.objects)) {
253
+ for (const [key, value] of Object.entries(items)) {
254
+ if (value === null || value === void 0) this.stmt.dropObject.run(id, collection, key);
255
+ else this.stmt.putObject.run(id, collection, key, JSON.stringify(value));
256
+ }
257
+ }
258
+ this.stmt.putMeta.run(id, JSON.stringify(change.meta), change.version, change.updated_at);
259
+ }
260
+ drop(id) {
261
+ this.stmt.dropMeta.run(id);
262
+ this.stmt.dropEntries.run(id);
263
+ this.stmt.dropObjects.run(id);
66
264
  }
67
265
  load() {
68
- const rows = this.stmtLoad.all();
69
- const out = [];
70
- for (const r of rows) {
266
+ return this.db.transaction(() => this.readAll())();
267
+ }
268
+ readAll() {
269
+ const broken = /* @__PURE__ */ new Map();
270
+ const entries = /* @__PURE__ */ new Map();
271
+ for (const r of this.stmt.entries.all()) {
272
+ if (broken.has(r.workspace)) continue;
273
+ let list = entries.get(r.workspace);
274
+ if (!list) entries.set(r.workspace, list = []);
275
+ if (r.seq !== list.length) {
276
+ broken.set(r.workspace, `audit entry ${list.length} is missing`);
277
+ continue;
278
+ }
71
279
  try {
72
- out.push({ id: r.id, data: JSON.parse(r.data), version: r.version, updated_at: r.updated_at });
280
+ list.push(JSON.parse(r.data));
73
281
  } catch {
282
+ broken.set(r.workspace, `audit entry ${r.seq} is unreadable`);
74
283
  }
75
284
  }
285
+ const objects = /* @__PURE__ */ new Map();
286
+ for (const r of this.stmt.objects.all()) {
287
+ if (broken.has(r.workspace)) continue;
288
+ let list = objects.get(r.workspace);
289
+ if (!list) objects.set(r.workspace, list = []);
290
+ try {
291
+ list.push([r.collection, r.id, JSON.parse(r.data)]);
292
+ } catch {
293
+ broken.set(r.workspace, `${r.collection} ${r.id} is unreadable`);
294
+ }
295
+ }
296
+ const out = [];
297
+ for (const r of this.stmt.metas.all()) {
298
+ let meta;
299
+ if (!broken.has(r.workspace)) {
300
+ try {
301
+ meta = JSON.parse(r.data);
302
+ } catch {
303
+ broken.set(r.workspace, "its own fields are unreadable");
304
+ }
305
+ }
306
+ if (!meta || typeof meta !== "object") {
307
+ console.warn(`chap SqliteStore: workspace ${r.workspace} was not loaded: ${broken.get(r.workspace) ?? "its own fields are unreadable"}`);
308
+ continue;
309
+ }
310
+ const data = assembleSnapshot(meta, entries.get(r.workspace) ?? [], objects.get(r.workspace) ?? []);
311
+ data.id = r.workspace;
312
+ out.push({ id: r.workspace, data, version: r.version, updated_at: r.updated_at });
313
+ }
76
314
  return out;
77
315
  }
78
316
  save(record) {
79
- this.stmtSave.run(
80
- record.id,
81
- JSON.stringify(record.data),
82
- record.version,
83
- record.updated_at
84
- );
317
+ this.txWrite.immediate(changeFromRecord(record));
318
+ }
319
+ apply(change) {
320
+ this.txWrite.immediate(change);
85
321
  }
86
322
  delete(id) {
87
- this.stmtDelete.run(id);
323
+ this.txDelete.immediate(id);
88
324
  }
89
325
  close() {
90
326
  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":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AA4BA,yBAA8B;AA5B9B;AAuDO,IAAM,cAAN,MAAmC;AAAA,EAC/B;AAAA,EACQ;AAAA,EACA;AAAA,EACA;AAAA,EAEjB,YAAY,MAAc,UAA8B,CAAC,GAAG;AAC1D,QAAI;AACJ,QAAI;AAQF,qBAAW,kCAAc,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":[]}
@@ -1,15 +1,50 @@
1
- import { Store, WorkspaceRecord } from './store.cjs';
1
+ import { Store, WorkspaceRecord, StoreChange } from './store.cjs';
2
2
 
3
3
  /**
4
4
  * SQLite-backed store using better-sqlite3.
5
5
  *
6
- * Schema:
7
- * CREATE TABLE chap_workspaces (
8
- * id TEXT PRIMARY KEY,
9
- * data TEXT NOT NULL, -- JSON.stringify of snapshot
10
- * version INTEGER NOT NULL,
6
+ * Schema, shared with the Python SqliteStore. Both write these tables, and
7
+ * the same audit entries for the same calls; the objects are each
8
+ * coordinator's own, so each restores a file it wrote, and chap-analytics
9
+ * reads a file from either:
10
+ *
11
+ * CREATE TABLE chap_meta (
12
+ * workspace TEXT PRIMARY KEY,
13
+ * data TEXT NOT NULL, -- the workspace's own fields, JSON
14
+ * version INTEGER NOT NULL, -- audit entries held
11
15
  * updated_at TEXT NOT NULL -- ISO-8601
12
- * )
16
+ * );
17
+ * CREATE TABLE chap_entries (
18
+ * workspace TEXT NOT NULL,
19
+ * seq INTEGER NOT NULL,
20
+ * data TEXT NOT NULL, -- one audit entry, JSON
21
+ * PRIMARY KEY (workspace, seq)
22
+ * );
23
+ * CREATE TABLE chap_objects (
24
+ * workspace TEXT NOT NULL,
25
+ * collection TEXT NOT NULL, -- members, tasks, ..., idempotency_keys
26
+ * id TEXT NOT NULL,
27
+ * data TEXT NOT NULL, -- one object, JSON
28
+ * PRIMARY KEY (workspace, collection, id)
29
+ * );
30
+ *
31
+ * A write adds the call's entries, updates the objects the call changed and
32
+ * the workspace's own fields, in one transaction, so its cost does not grow
33
+ * with the log. A write whose first entry is not the next seq the file holds
34
+ * is refused with a StoreConflictError; see the concurrency model in
35
+ * ./store.ts. Audit entries are only ever inserted, except when a workspace
36
+ * is written whole: after `Coordinator.restore()`, and for a workspace an
37
+ * application put in place itself.
38
+ *
39
+ * Files written by releases before 0.3.2 hold one JSON snapshot per workspace
40
+ * in a table named chap_workspaces. Opening such a file moves each workspace
41
+ * into the tables above, in one transaction, and renames the old table to
42
+ * chap_workspaces_v1, which is kept as it was.
43
+ *
44
+ * A view named chap_workspaces takes the old table's place, with the same
45
+ * columns, so a script that read the old table reads the current state; it
46
+ * assembles each workspace when read, with collections keyed by id. Releases
47
+ * before 0.3.2 refuse to open the file, since they cannot write to it.
13
48
  *
14
49
  * Usage:
15
50
  * import { Coordinator } from "@brightbeamai/chap-coordinator";
@@ -34,6 +69,7 @@ interface SqliteDatabase {
34
69
  pragma(name: string, options?: {
35
70
  simple?: boolean;
36
71
  }): unknown;
72
+ transaction<A extends unknown[], R>(fn: (...args: A) => R): SqliteTransaction<A, R>;
37
73
  close(): void;
38
74
  }
39
75
  interface SqliteStatement {
@@ -44,6 +80,10 @@ interface SqliteStatement {
44
80
  get(...params: unknown[]): unknown;
45
81
  all(...params: unknown[]): unknown[];
46
82
  }
83
+ interface SqliteTransaction<A extends unknown[], R = void> {
84
+ (...args: A): R;
85
+ immediate(...args: A): R;
86
+ }
47
87
  interface SqliteStoreOptions {
48
88
  /**
49
89
  * Enable WAL journal mode for better concurrent-read performance.
@@ -54,12 +94,22 @@ interface SqliteStoreOptions {
54
94
  }
55
95
  declare class SqliteStore implements Store {
56
96
  readonly db: SqliteDatabase;
57
- private readonly stmtLoad;
58
- private readonly stmtSave;
59
- private readonly stmtDelete;
97
+ private readonly stmt;
98
+ private readonly txWrite;
99
+ private readonly txDelete;
60
100
  constructor(path: string, options?: SqliteStoreOptions);
101
+ /**
102
+ * In one transaction, so processes opening the file at once agree: move the
103
+ * snapshots a release before 0.3.2 wrote into the tables above, then put the
104
+ * view in the old table's place.
105
+ */
106
+ private open;
107
+ private write;
108
+ private drop;
61
109
  load(): WorkspaceRecord[];
110
+ private readAll;
62
111
  save(record: WorkspaceRecord): void;
112
+ apply(change: StoreChange): void;
63
113
  delete(id: string): void;
64
114
  close(): void;
65
115
  }
@@ -1,15 +1,50 @@
1
- import { Store, WorkspaceRecord } from './store.js';
1
+ import { Store, WorkspaceRecord, StoreChange } from './store.js';
2
2
 
3
3
  /**
4
4
  * SQLite-backed store using better-sqlite3.
5
5
  *
6
- * Schema:
7
- * CREATE TABLE chap_workspaces (
8
- * id TEXT PRIMARY KEY,
9
- * data TEXT NOT NULL, -- JSON.stringify of snapshot
10
- * version INTEGER NOT NULL,
6
+ * Schema, shared with the Python SqliteStore. Both write these tables, and
7
+ * the same audit entries for the same calls; the objects are each
8
+ * coordinator's own, so each restores a file it wrote, and chap-analytics
9
+ * reads a file from either:
10
+ *
11
+ * CREATE TABLE chap_meta (
12
+ * workspace TEXT PRIMARY KEY,
13
+ * data TEXT NOT NULL, -- the workspace's own fields, JSON
14
+ * version INTEGER NOT NULL, -- audit entries held
11
15
  * updated_at TEXT NOT NULL -- ISO-8601
12
- * )
16
+ * );
17
+ * CREATE TABLE chap_entries (
18
+ * workspace TEXT NOT NULL,
19
+ * seq INTEGER NOT NULL,
20
+ * data TEXT NOT NULL, -- one audit entry, JSON
21
+ * PRIMARY KEY (workspace, seq)
22
+ * );
23
+ * CREATE TABLE chap_objects (
24
+ * workspace TEXT NOT NULL,
25
+ * collection TEXT NOT NULL, -- members, tasks, ..., idempotency_keys
26
+ * id TEXT NOT NULL,
27
+ * data TEXT NOT NULL, -- one object, JSON
28
+ * PRIMARY KEY (workspace, collection, id)
29
+ * );
30
+ *
31
+ * A write adds the call's entries, updates the objects the call changed and
32
+ * the workspace's own fields, in one transaction, so its cost does not grow
33
+ * with the log. A write whose first entry is not the next seq the file holds
34
+ * is refused with a StoreConflictError; see the concurrency model in
35
+ * ./store.ts. Audit entries are only ever inserted, except when a workspace
36
+ * is written whole: after `Coordinator.restore()`, and for a workspace an
37
+ * application put in place itself.
38
+ *
39
+ * Files written by releases before 0.3.2 hold one JSON snapshot per workspace
40
+ * in a table named chap_workspaces. Opening such a file moves each workspace
41
+ * into the tables above, in one transaction, and renames the old table to
42
+ * chap_workspaces_v1, which is kept as it was.
43
+ *
44
+ * A view named chap_workspaces takes the old table's place, with the same
45
+ * columns, so a script that read the old table reads the current state; it
46
+ * assembles each workspace when read, with collections keyed by id. Releases
47
+ * before 0.3.2 refuse to open the file, since they cannot write to it.
13
48
  *
14
49
  * Usage:
15
50
  * import { Coordinator } from "@brightbeamai/chap-coordinator";
@@ -34,6 +69,7 @@ interface SqliteDatabase {
34
69
  pragma(name: string, options?: {
35
70
  simple?: boolean;
36
71
  }): unknown;
72
+ transaction<A extends unknown[], R>(fn: (...args: A) => R): SqliteTransaction<A, R>;
37
73
  close(): void;
38
74
  }
39
75
  interface SqliteStatement {
@@ -44,6 +80,10 @@ interface SqliteStatement {
44
80
  get(...params: unknown[]): unknown;
45
81
  all(...params: unknown[]): unknown[];
46
82
  }
83
+ interface SqliteTransaction<A extends unknown[], R = void> {
84
+ (...args: A): R;
85
+ immediate(...args: A): R;
86
+ }
47
87
  interface SqliteStoreOptions {
48
88
  /**
49
89
  * Enable WAL journal mode for better concurrent-read performance.
@@ -54,12 +94,22 @@ interface SqliteStoreOptions {
54
94
  }
55
95
  declare class SqliteStore implements Store {
56
96
  readonly db: SqliteDatabase;
57
- private readonly stmtLoad;
58
- private readonly stmtSave;
59
- private readonly stmtDelete;
97
+ private readonly stmt;
98
+ private readonly txWrite;
99
+ private readonly txDelete;
60
100
  constructor(path: string, options?: SqliteStoreOptions);
101
+ /**
102
+ * In one transaction, so processes opening the file at once agree: move the
103
+ * snapshots a release before 0.3.2 wrote into the tables above, then put the
104
+ * view in the old table's place.
105
+ */
106
+ private open;
107
+ private write;
108
+ private drop;
61
109
  load(): WorkspaceRecord[];
110
+ private readAll;
62
111
  save(record: WorkspaceRecord): void;
112
+ apply(change: StoreChange): void;
63
113
  delete(id: string): void;
64
114
  close(): void;
65
115
  }