synomem 0.9.6 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +5 -3
  3. package/dist/actor-directory.d.ts +34 -0
  4. package/dist/actor-directory.d.ts.map +1 -0
  5. package/dist/actor-directory.js +52 -0
  6. package/dist/actor-directory.js.map +1 -0
  7. package/dist/backend.d.ts +3 -0
  8. package/dist/backend.d.ts.map +1 -1
  9. package/dist/backend.js +26 -3
  10. package/dist/backend.js.map +1 -1
  11. package/dist/bookmarks.d.ts +30 -0
  12. package/dist/bookmarks.d.ts.map +1 -0
  13. package/dist/bookmarks.js +87 -0
  14. package/dist/bookmarks.js.map +1 -0
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +291 -61
  17. package/dist/cli.js.map +1 -1
  18. package/dist/client.d.ts +115 -4
  19. package/dist/client.d.ts.map +1 -1
  20. package/dist/client.js +854 -138
  21. package/dist/client.js.map +1 -1
  22. package/dist/cursors.d.ts +30 -0
  23. package/dist/cursors.d.ts.map +1 -0
  24. package/dist/cursors.js +101 -0
  25. package/dist/cursors.js.map +1 -0
  26. package/dist/errors.d.ts +1 -1
  27. package/dist/errors.d.ts.map +1 -1
  28. package/dist/errors.js +2 -0
  29. package/dist/errors.js.map +1 -1
  30. package/dist/import.d.ts +605 -39
  31. package/dist/import.d.ts.map +1 -1
  32. package/dist/index.d.ts +19 -1
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +11 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/mcp/index.d.ts +1 -1
  37. package/dist/mcp/index.d.ts.map +1 -1
  38. package/dist/mcp/index.js +329 -22
  39. package/dist/mcp/index.js.map +1 -1
  40. package/dist/mutation-receipts.d.ts +8 -0
  41. package/dist/mutation-receipts.d.ts.map +1 -0
  42. package/dist/mutation-receipts.js +2 -0
  43. package/dist/mutation-receipts.js.map +1 -0
  44. package/dist/notifications.d.ts +73 -0
  45. package/dist/notifications.d.ts.map +1 -0
  46. package/dist/notifications.js +309 -0
  47. package/dist/notifications.js.map +1 -0
  48. package/dist/participation-repository.d.ts +46 -0
  49. package/dist/participation-repository.d.ts.map +1 -0
  50. package/dist/participation-repository.js +296 -0
  51. package/dist/participation-repository.js.map +1 -0
  52. package/dist/participation.d.ts +97 -0
  53. package/dist/participation.d.ts.map +1 -0
  54. package/dist/participation.js +10 -0
  55. package/dist/participation.js.map +1 -0
  56. package/dist/policy.d.ts +31 -0
  57. package/dist/policy.d.ts.map +1 -0
  58. package/dist/policy.js +71 -0
  59. package/dist/policy.js.map +1 -0
  60. package/dist/ports/repository.d.ts +23 -0
  61. package/dist/ports/repository.d.ts.map +1 -1
  62. package/dist/profiles.d.ts +12 -0
  63. package/dist/profiles.d.ts.map +1 -1
  64. package/dist/profiles.js +2 -1
  65. package/dist/profiles.js.map +1 -1
  66. package/dist/projections.d.ts.map +1 -1
  67. package/dist/projections.js +29 -11
  68. package/dist/projections.js.map +1 -1
  69. package/dist/remote.d.ts +79 -0
  70. package/dist/remote.d.ts.map +1 -1
  71. package/dist/remote.js +65 -6
  72. package/dist/remote.js.map +1 -1
  73. package/dist/schemas.d.ts +746 -58
  74. package/dist/schemas.d.ts.map +1 -1
  75. package/dist/schemas.js +114 -16
  76. package/dist/schemas.js.map +1 -1
  77. package/dist/search.d.ts +25 -0
  78. package/dist/search.d.ts.map +1 -0
  79. package/dist/search.js +2 -0
  80. package/dist/search.js.map +1 -0
  81. package/dist/service.d.ts +89 -0
  82. package/dist/service.d.ts.map +1 -1
  83. package/dist/storage.d.ts +38 -3
  84. package/dist/storage.d.ts.map +1 -1
  85. package/dist/storage.js +494 -365
  86. package/dist/storage.js.map +1 -1
  87. package/dist/types.d.ts +65 -34
  88. package/dist/types.d.ts.map +1 -1
  89. package/docs/cli.md +6 -0
  90. package/docs/human-participation.md +20 -0
  91. package/docs/mcp.md +2 -0
  92. package/docs/storage-format.md +4 -15
  93. package/openapi/synomem-v1.yaml +187 -10
  94. package/package.json +2 -1
  95. package/skills/synomem/SKILL.md +2 -0
  96. package/src/actor-directory.ts +87 -0
  97. package/src/backend.ts +29 -3
  98. package/src/bookmarks.ts +127 -0
  99. package/src/cli.ts +941 -546
  100. package/src/client.ts +1143 -279
  101. package/src/cursors.ts +132 -0
  102. package/src/errors.ts +2 -0
  103. package/src/index.ts +50 -0
  104. package/src/mcp/index.ts +412 -96
  105. package/src/mutation-receipts.ts +7 -0
  106. package/src/notifications.ts +483 -0
  107. package/src/participation-repository.ts +406 -0
  108. package/src/participation.ts +103 -0
  109. package/src/policy.ts +109 -0
  110. package/src/ports/repository.ts +33 -0
  111. package/src/profiles.ts +2 -1
  112. package/src/projections.ts +49 -48
  113. package/src/remote.ts +227 -37
  114. package/src/schemas.ts +116 -16
  115. package/src/search.ts +24 -0
  116. package/src/service.ts +122 -8
  117. package/src/storage.ts +1039 -697
  118. package/src/types.ts +67 -32
package/dist/storage.js CHANGED
@@ -1,4 +1,12 @@
1
- import { chmodSync, closeSync, existsSync, lstatSync, linkSync, mkdirSync, mkdtempSync, openSync, rmdirSync, unlinkSync, } from 'node:fs';
1
+ import { actorDirectoryBinding, actorDirectoryKey, parseActorDirectoryKey, } from './actor-directory.js';
2
+ import { SqlBookmarkRepository, bookmarkTables } from './bookmarks.js';
3
+ import { SqlNotificationRepository, notificationTables } from './notifications.js';
4
+ import { SqlParticipationRepository, participationStatements, participationTables, } from './participation-repository.js';
5
+ import { AsyncLocalStorage } from 'node:async_hooks';
6
+ import { randomBytes } from 'node:crypto';
7
+ import { SignedCursorCodec, cursorFilter, exactSequence } from './cursors.js';
8
+ import { recordVisibilityPredicate, resolveAuthority } from './policy.js';
9
+ import { chmodSync, closeSync, existsSync, lstatSync, linkSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, rmdirSync, unlinkSync, } from 'node:fs';
2
10
  import { ulid } from 'ulid';
3
11
  import { basename, dirname, join, resolve } from 'node:path';
4
12
  import { DatabaseSync } from 'node:sqlite';
@@ -8,7 +16,7 @@ import { assertNoSymlinkEscape, atomicWriteFile, ensureDirectory, readJsonFile,
8
16
  import { dueInstant } from './projections.js';
9
17
  import { actorSchema, eventSchema, profileSchema, topicProfileSchema } from './schemas.js';
10
18
  /** The schema version this file writes and expects. */
11
- const SUPPORTED_SCHEMA_VERSION = 8;
19
+ const SUPPORTED_SCHEMA_VERSION = 9;
12
20
  const migrationV1 = `
13
21
  CREATE TABLE IF NOT EXISTS schema_migrations (
14
22
  version INTEGER PRIMARY KEY,
@@ -35,7 +43,8 @@ CREATE TABLE IF NOT EXISTS events (
35
43
  created_at TEXT NOT NULL,
36
44
  actor_kind TEXT NOT NULL,
37
45
  actor_id TEXT NOT NULL,
38
- recipient_agent_id TEXT,
46
+ recipient_kind TEXT NOT NULL DEFAULT 'agent',
47
+ recipient_id TEXT,
39
48
  kudos_id TEXT,
40
49
  visibility TEXT,
41
50
  idempotency_key TEXT,
@@ -47,7 +56,7 @@ ON events(actor_kind, actor_id, idempotency_key)
47
56
  WHERE type = 'kudos.given' AND idempotency_key IS NOT NULL;
48
57
 
49
58
  CREATE INDEX IF NOT EXISTS events_type_created ON events(type, created_at, id);
50
- CREATE INDEX IF NOT EXISTS events_recipient ON events(recipient_agent_id, created_at, id);
59
+ CREATE INDEX IF NOT EXISTS events_recipient ON events(recipient_id, created_at, id);
51
60
  CREATE INDEX IF NOT EXISTS events_kudos_id ON events(kudos_id, created_at, id);
52
61
 
53
62
  CREATE TABLE IF NOT EXISTS projection_manifest (
@@ -83,7 +92,8 @@ CREATE TABLE kudos_current (
83
92
  kudos_id TEXT PRIMARY KEY,
84
93
  given_sequence INTEGER NOT NULL UNIQUE,
85
94
  created_at TEXT NOT NULL,
86
- recipient_agent_id TEXT NOT NULL,
95
+ recipient_kind TEXT NOT NULL DEFAULT 'agent',
96
+ recipient_id TEXT NOT NULL,
87
97
  recipient_display_name TEXT NOT NULL,
88
98
  actor_kind TEXT NOT NULL,
89
99
  actor_id TEXT NOT NULL,
@@ -96,7 +106,7 @@ CREATE TABLE kudos_current (
96
106
  revocation_status TEXT NOT NULL,
97
107
  updated_sequence INTEGER NOT NULL
98
108
  ) STRICT;
99
- CREATE INDEX kudos_current_recipient ON kudos_current(recipient_agent_id, given_sequence DESC);
109
+ CREATE INDEX kudos_current_recipient ON kudos_current(recipient_id, given_sequence DESC);
100
110
  CREATE INDEX kudos_current_actor ON kudos_current(actor_kind, actor_id, given_sequence DESC);
101
111
  CREATE INDEX kudos_current_status ON kudos_current(status, revocation_status, given_sequence DESC);
102
112
  `;
@@ -243,17 +253,20 @@ CREATE TABLE items_current (
243
253
  topic_ids_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(topic_ids_json)),
244
254
  visibility TEXT NOT NULL,
245
255
  status TEXT NOT NULL,
246
- recipient_agent_id TEXT,
256
+ recipient_kind TEXT NOT NULL DEFAULT 'agent',
257
+ recipient_id TEXT,
247
258
  recipient_display_name TEXT,
248
- owner_agent_id TEXT,
259
+ owner_kind TEXT,
260
+ owner_id TEXT,
249
261
  owner_display_name TEXT,
250
- assignee_agent_id TEXT,
262
+ assignee_kind TEXT,
263
+ assignee_id TEXT,
251
264
  assignee_display_name TEXT
252
265
  ) STRICT;
253
266
  CREATE INDEX items_current_kind_sequence ON items_current(kind, created_sequence DESC);
254
- CREATE INDEX items_current_recipient ON items_current(recipient_agent_id, created_sequence DESC);
255
- CREATE INDEX items_current_owner ON items_current(owner_agent_id, created_sequence DESC);
256
- CREATE INDEX items_current_assignee ON items_current(assignee_agent_id, created_sequence DESC);
267
+ CREATE INDEX items_current_recipient ON items_current(recipient_id, created_sequence DESC);
268
+ CREATE INDEX items_current_owner ON items_current(owner_id, created_sequence DESC);
269
+ CREATE INDEX items_current_assignee ON items_current(assignee_id, created_sequence DESC);
257
270
  CREATE INDEX items_current_status ON items_current(kind, status, created_sequence DESC);
258
271
 
259
272
  CREATE TRIGGER events_append_only_update
@@ -261,26 +274,7 @@ BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events are append-only'); END
261
274
  CREATE TRIGGER events_append_only_delete
262
275
  BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events are append-only'); END;
263
276
  `;
264
- const CONTEXT_BUDGET_BYTES = 24_576;
265
- function encodeCursor(kind, sequence) {
266
- return Buffer.from(JSON.stringify({ v: 1, kind, sequence }), 'utf8').toString('base64url');
267
- }
268
- function decodeCursor(cursor, kind) {
269
- try {
270
- const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
271
- if (value.v !== 1 ||
272
- value.kind !== kind ||
273
- typeof value.sequence !== 'number' ||
274
- !Number.isSafeInteger(value.sequence) ||
275
- value.sequence < 0) {
276
- throw new Error('invalid cursor');
277
- }
278
- return value.sequence;
279
- }
280
- catch {
281
- throw new SynomemError('INVALID_INPUT', `Invalid ${kind} cursor.`);
282
- }
283
- }
277
+ const CONTEXT_BUDGET_BYTES = 24576;
284
278
  function itemSummaryFromRow(row) {
285
279
  return {
286
280
  id: row.item_id,
@@ -297,9 +291,11 @@ function itemSummaryFromRow(row) {
297
291
  topicIds: JSON.parse(row.topic_ids_json),
298
292
  visibility: row.visibility,
299
293
  status: row.status,
300
- ...(row.recipient_agent_id ? { recipientAgentId: row.recipient_agent_id } : {}),
301
- ...(row.owner_agent_id ? { ownerAgentId: row.owner_agent_id } : {}),
302
- ...(row.assignee_agent_id ? { assigneeAgentId: row.assignee_agent_id } : {}),
294
+ ...(row.recipient_id ? { recipient: { kind: row.recipient_kind, id: row.recipient_id } } : {}),
295
+ ...(row.owner_id ? { owner: { kind: row.owner_kind ?? 'agent', id: row.owner_id } } : {}),
296
+ ...(row.assignee_id
297
+ ? { assignee: { kind: row.assignee_kind ?? 'agent', id: row.assignee_id } }
298
+ : {}),
303
299
  };
304
300
  }
305
301
  /**
@@ -330,7 +326,7 @@ function summaryFromRow(row) {
330
326
  kind: 'kudos',
331
327
  createdAt: row.created_at,
332
328
  updatedAt: row.created_at,
333
- recipientAgentId: row.recipient_agent_id,
329
+ recipient: { kind: row.recipient_kind, id: row.recipient_id },
334
330
  recipientDisplayName: row.recipient_display_name,
335
331
  actor: {
336
332
  kind: row.actor_kind,
@@ -345,7 +341,58 @@ function summaryFromRow(row) {
345
341
  revocationStatus: row.revocation_status,
346
342
  };
347
343
  }
344
+ const databaseWriteTails = new Map();
348
345
  export class SynomemStorage {
346
+ notifications = this.makeNotifications();
347
+ makeNotifications() {
348
+ const workspaceId = () => this.config.workspaceId;
349
+ return new SqlNotificationRepository({
350
+ get workspaceId() {
351
+ return workspaceId();
352
+ },
353
+ query: async (sql, values) => this.prepare(sql).all(...values),
354
+ execute: async (sql, values) => {
355
+ this.prepare(sql).run(...values);
356
+ },
357
+ codec: () => this.cursorCodec,
358
+ canRead: async (id, actor) => this.canActorReadItem(id, actor),
359
+ visibility: (actor) => recordVisibilityPredicate(actor, this.boundActor && this.boundActor.kind === actor.kind && this.boundActor.id === actor.id
360
+ ? this.authority
361
+ : resolveAuthority(), false, 'i.'),
362
+ });
363
+ }
364
+ bookmarks = this.makeBookmarks();
365
+ makeBookmarks() {
366
+ const workspaceId = () => this.config.workspaceId;
367
+ return new SqlBookmarkRepository({
368
+ get workspaceId() {
369
+ return workspaceId();
370
+ },
371
+ query: async (sql, values) => this.prepare(sql).all(...values),
372
+ execute: async (sql, values) => {
373
+ this.prepare(sql).run(...values);
374
+ },
375
+ codec: () => this.cursorCodec,
376
+ canRead: async (id, actor) => this.canActorReadItem(id, actor),
377
+ visibility: (actor) => recordVisibilityPredicate(actor, this.boundActor && this.boundActor.kind === actor.kind && this.boundActor.id === actor.id
378
+ ? this.authority
379
+ : resolveAuthority(), false, 'i.'),
380
+ });
381
+ }
382
+ participation = this.makeParticipation();
383
+ makeParticipation() {
384
+ const workspaceId = () => this.config.workspaceId;
385
+ return new SqlParticipationRepository({
386
+ get workspaceId() {
387
+ return workspaceId();
388
+ },
389
+ query: async (sql, values) => this.prepare(sql).all(...values),
390
+ execute: async (sql, values) => {
391
+ this.prepare(sql).run(...values);
392
+ },
393
+ codec: () => this.cursorCodec,
394
+ });
395
+ }
349
396
  home;
350
397
  storageDirectory;
351
398
  databasePath;
@@ -353,10 +400,15 @@ export class SynomemStorage {
353
400
  readOnly;
354
401
  config = defaultConfig;
355
402
  database;
403
+ cursorCodec;
404
+ authority;
405
+ boundActor;
356
406
  configOverrides;
357
- validatedEventSequence = 0;
358
- transactionTail = Promise.resolve();
407
+ validatedEventSequence = 0n;
408
+ transactionState = new AsyncLocalStorage();
359
409
  constructor(options) {
410
+ this.authority = resolveAuthority(options.authority);
411
+ this.boundActor = options.actor;
360
412
  this.home = resolve(options.home);
361
413
  // The home is the storage directory; see configLocation in backend.ts.
362
414
  this.storageDirectory = this.home;
@@ -365,6 +417,9 @@ export class SynomemStorage {
365
417
  this.readOnly = options.readOnly;
366
418
  this.configOverrides = options.config;
367
419
  }
420
+ setAuthority(authority) {
421
+ this.authority = resolveAuthority(authority);
422
+ }
368
423
  init() {
369
424
  try {
370
425
  if (this.configOverrides?.backend?.kind === 'remote') {
@@ -378,10 +433,29 @@ export class SynomemStorage {
378
433
  if (lstatSync(this.home).isSymbolicLink()) {
379
434
  throw new SynomemError('UNSAFE_PATH', 'The configured Synomem home cannot be a symbolic link.');
380
435
  }
381
- if (!this.readOnly)
382
- ensureDirectory(this.storageDirectory);
383
436
  assertNoSymlinkEscape(this.home, this.storageDirectory);
437
+ if (existsSync(this.databasePath)) {
438
+ // The SQLite header stores PRAGMA user_version at byte 60. A read-only
439
+ // SQLite connection can still create/delete shared-memory sidecars in
440
+ // WAL mode, changing directory metadata before we refuse legacy data.
441
+ // Read only the header to keep the export-only guard truly inert.
442
+ const handle = openSync(this.databasePath, 'r');
443
+ const header = Buffer.alloc(100);
444
+ let size = 0;
445
+ try {
446
+ size = readSync(handle, header, 0, header.length, 0);
447
+ }
448
+ finally {
449
+ closeSync(handle);
450
+ }
451
+ if (size < 100 || header.toString('ascii', 0, 16) !== 'SQLite format 3\0')
452
+ throw new SynomemError('UNSUPPORTED_SCHEMA', 'This database header is malformed; use raw recovery.');
453
+ const version = header.readUInt32BE(60);
454
+ if (version > 0 && version !== SUPPORTED_SCHEMA_VERSION)
455
+ throw new SynomemError('UNSUPPORTED_SCHEMA', 'This home is export-only. Preserve its raw events and create a fresh human-participation home.');
456
+ }
384
457
  if (!this.readOnly) {
458
+ ensureDirectory(this.storageDirectory);
385
459
  chmodSync(this.storageDirectory, 0o700);
386
460
  }
387
461
  const fileConfig = existsSync(this.configPath) ? readJsonFile(this.configPath) : undefined;
@@ -411,6 +485,15 @@ export class SynomemStorage {
411
485
  enableForeignKeyConstraints: true,
412
486
  });
413
487
  this.database.exec('PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON;');
488
+ const keyPath = join(this.home, 'cursor-key');
489
+ assertNoSymlinkEscape(this.home, keyPath);
490
+ if (!existsSync(keyPath)) {
491
+ if (this.readOnly)
492
+ throw new SynomemError('CONFIG_INVALID', 'The home cursor key is missing.');
493
+ atomicWriteFile(keyPath, randomBytes(32).toString('base64url'), 0o600);
494
+ }
495
+ const key = Buffer.from(readFileSync(keyPath, 'utf8'), 'base64url');
496
+ this.cursorCodec = new SignedCursorCodec({ keyId: 'home', key });
414
497
  if (!this.readOnly) {
415
498
  this.database.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;');
416
499
  this.migrate();
@@ -430,9 +513,35 @@ export class SynomemStorage {
430
513
  throw asSynomemError(error);
431
514
  }
432
515
  }
516
+ prepare(sql) {
517
+ const statement = this.db().prepare(sql);
518
+ statement.setReadBigInts(true);
519
+ return statement;
520
+ }
521
+ binding(purpose, viewer, filters) {
522
+ return {
523
+ purpose,
524
+ workspaceId: this.config.workspaceId,
525
+ actor: { kind: viewer.kind, id: viewer.id },
526
+ filter: cursorFilter(filters),
527
+ };
528
+ }
529
+ encodeCursor(binding, sequence, watermark = this.maxEventSequence()) {
530
+ return this.cursorCodec.encode(binding, {
531
+ sequence: exactSequence(sequence),
532
+ watermark: exactSequence(watermark),
533
+ });
534
+ }
535
+ decodeCursor(token, binding) {
536
+ const value = this.cursorCodec.decode(token, binding, exactSequence(this.maxEventSequence()));
537
+ return { sequence: BigInt(value.sequence), watermark: BigInt(value.watermark) };
538
+ }
433
539
  migrate() {
434
540
  const db = this.db();
435
541
  const version = Number(db.prepare('PRAGMA user_version').get().user_version);
542
+ if (version > 0 && version < 9 && db.prepare('SELECT 1 FROM events LIMIT 1').get()) {
543
+ throw new SynomemError('UNSUPPORTED_SCHEMA', 'This populated legacy home is export-only. Export its raw events and explicitly create a new human-participation home.');
544
+ }
436
545
  if (version > SUPPORTED_SCHEMA_VERSION) {
437
546
  throw new SynomemError('UNSUPPORTED_SCHEMA', `Database schema version ${version} is newer than this package supports.`);
438
547
  }
@@ -514,9 +623,26 @@ export class SynomemStorage {
514
623
  db.exec('PRAGMA user_version = 8');
515
624
  });
516
625
  }
626
+ if (Number(db.prepare('PRAGMA user_version').get().user_version) ===
627
+ 8) {
628
+ this.transactionSync(() => {
629
+ db.exec(`ALTER TABLE items_current ADD COLUMN workspace_id TEXT;
630
+ ${notificationTables}
631
+ ${bookmarkTables}
632
+ ${participationTables}
633
+ CREATE TABLE mutation_receipts(actor_kind TEXT NOT NULL,actor_id TEXT NOT NULL,key_hash TEXT NOT NULL,operation TEXT NOT NULL,request_hash TEXT NOT NULL,result_json TEXT,event_id TEXT,created_at TEXT NOT NULL,PRIMARY KEY(actor_kind,actor_id,key_hash)) STRICT;
634
+ CREATE INDEX mutation_receipts_retention ON mutation_receipts(created_at) WHERE result_json IS NOT NULL;
635
+ CREATE TABLE human_actors (id TEXT PRIMARY KEY, handle TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('active','inactive'))) STRICT;
636
+ CREATE INDEX items_current_owner_actor ON items_current(owner_kind,owner_id,created_sequence DESC);
637
+ CREATE INDEX items_current_recipient_actor ON items_current(recipient_kind,recipient_id,created_sequence DESC);
638
+ CREATE INDEX items_current_assignee_actor ON items_current(assignee_kind,assignee_id,created_sequence DESC);`);
639
+ db.prepare('INSERT INTO schema_migrations(version,applied_at) VALUES(9,?)').run(new Date().toISOString());
640
+ db.exec('PRAGMA user_version=9');
641
+ });
642
+ }
517
643
  }
518
644
  assertSchemaSupported() {
519
- const version = Number(this.db().prepare('PRAGMA user_version').get().user_version);
645
+ const version = Number(this.prepare('PRAGMA user_version').get().user_version);
520
646
  if (version !== SUPPORTED_SCHEMA_VERSION) {
521
647
  if (version >= 1 && version < SUPPORTED_SCHEMA_VERSION) {
522
648
  throw new SynomemError('UNSUPPORTED_SCHEMA', `Database schema version ${version} requires migration. Open this home once with readOnly: false, then retry the read-only client.`);
@@ -534,6 +660,8 @@ export class SynomemStorage {
534
660
  throw new SynomemError('READ_ONLY', 'This Synomem client is read-only.');
535
661
  }
536
662
  transaction(operation) {
663
+ if (this.transactionState.getStore())
664
+ return Promise.resolve().then(operation);
537
665
  const execute = async () => {
538
666
  this.assertWritable();
539
667
  const db = this.db();
@@ -541,7 +669,7 @@ export class SynomemStorage {
541
669
  try {
542
670
  db.exec('BEGIN IMMEDIATE');
543
671
  began = true;
544
- const result = await operation();
672
+ const result = await this.transactionState.run(true, operation);
545
673
  db.exec('COMMIT');
546
674
  began = false;
547
675
  this.restrictDatabaseFiles();
@@ -559,8 +687,14 @@ export class SynomemStorage {
559
687
  throw asSynomemError(error);
560
688
  }
561
689
  };
562
- const result = this.transactionTail.then(execute, execute);
563
- this.transactionTail = result.then(() => undefined, () => undefined);
690
+ const prior = databaseWriteTails.get(this.databasePath) ?? Promise.resolve();
691
+ const result = prior.then(execute, execute);
692
+ const tail = result.then(() => undefined, () => undefined);
693
+ databaseWriteTails.set(this.databasePath, tail);
694
+ void tail.then(() => {
695
+ if (databaseWriteTails.get(this.databasePath) === tail)
696
+ databaseWriteTails.delete(this.databasePath);
697
+ });
564
698
  return result;
565
699
  }
566
700
  transactionSync(operation) {
@@ -588,14 +722,18 @@ export class SynomemStorage {
588
722
  throw asSynomemError(error);
589
723
  }
590
724
  }
591
- insertEvent(event) {
725
+ async insertEvent(event) {
726
+ if (!this.transactionState.getStore()) {
727
+ await this.transaction(() => this.insertEvent(event));
728
+ return;
729
+ }
592
730
  const parsed = eventSchema.parse(event);
593
731
  const recipientAgentId = parsed.type === 'kudos.given' ||
594
732
  parsed.type === 'kudos.acknowledged' ||
595
733
  parsed.type === 'memo.sent' ||
596
734
  parsed.type === 'memo.read' ||
597
735
  parsed.type === 'memo.archived'
598
- ? parsed.recipientAgentId
736
+ ? parsed.recipient?.id
599
737
  : null;
600
738
  const kudosId = parsed.type === 'kudos.acknowledged' || parsed.type === 'kudos.revoked'
601
739
  ? parsed.kudosId
@@ -603,53 +741,46 @@ export class SynomemStorage {
603
741
  const visibility = 'visibility' in parsed ? parsed.visibility : null;
604
742
  const idempotencyKey = parsed.idempotencyKey ?? null;
605
743
  const kind = eventKind(parsed) ?? null;
606
- const sequence = Number(this.db().prepare('SELECT COALESCE(MAX(sequence), 0) + 1 AS next FROM events').get().next);
607
- this.db()
608
- .prepare(`INSERT INTO events(
744
+ const sequence = this.prepare('SELECT COALESCE(MAX(sequence),0)+1 AS next FROM events').get().next;
745
+ this.prepare(`INSERT INTO events(
609
746
  id, schema_version, type, created_at, actor_kind, actor_id,
610
- recipient_agent_id, kudos_id, visibility, idempotency_key, payload, sequence,
747
+ recipient_id, kudos_id, visibility, idempotency_key, payload, sequence,
611
748
  workspace_id, aggregate_id, aggregate_version, item_kind
612
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
613
- .run(parsed.id, parsed.schemaVersion, parsed.type, parsed.createdAt, parsed.actor.kind, parsed.actor.id, recipientAgentId, kudosId, visibility, idempotencyKey, JSON.stringify(parsed), sequence, parsed.workspaceId, parsed.aggregateId, parsed.aggregateVersion, kind);
749
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(parsed.id, parsed.schemaVersion, parsed.type, parsed.createdAt, parsed.actor.kind, parsed.actor.id, recipientAgentId, kudosId, visibility, idempotencyKey, JSON.stringify(parsed), sequence, parsed.workspaceId, parsed.aggregateId, parsed.aggregateVersion, kind);
614
750
  this.applyEventToCurrent(parsed, sequence);
615
751
  this.applyEventToItems(parsed, sequence);
752
+ this.prepare('UPDATE items_current SET workspace_id=? WHERE item_id=?').run(parsed.workspaceId, parsed.aggregateId);
753
+ for (const command of participationStatements(parsed, sequence, this.config.workspaceId))
754
+ this.prepare(command.sql).run(...command.values);
755
+ await this.notifications.apply(parsed, sequence);
616
756
  }
617
757
  applyEventToCurrent(event, sequence) {
618
758
  if (event.type === 'kudos.given') {
619
- this.db()
620
- .prepare(`INSERT INTO kudos_current(
621
- kudos_id, given_sequence, created_at, recipient_agent_id, recipient_display_name,
759
+ this.prepare(`INSERT INTO kudos_current(
760
+ kudos_id, given_sequence, created_at, recipient_id, recipient_display_name,
622
761
  actor_kind, actor_id, actor_display_name, title, tags_json, topic_ids_json, visibility,
623
762
  status, revocation_status, updated_sequence
624
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'unacknowledged', 'active', ?)`)
625
- .run(event.id, sequence, event.createdAt, event.recipientAgentId, event.recipientDisplayName, event.actor.kind, event.actor.id, event.actor.displayName ?? null, event.title, JSON.stringify(event.tags ?? []), JSON.stringify(event.topicIds ?? []), event.visibility, sequence);
763
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'unacknowledged', 'active', ?)`).run(event.id, sequence, event.createdAt, event.recipient?.id, event.recipientDisplayName, event.actor.kind, event.actor.id, event.actor.displayName ?? null, event.title, JSON.stringify(event.tags ?? []), JSON.stringify(event.topicIds ?? []), event.visibility, sequence);
764
+ this.prepare('UPDATE kudos_current SET recipient_kind=? WHERE kudos_id=?').run(event.recipient.kind, event.id);
626
765
  }
627
766
  else if (event.type === 'kudos.acknowledged') {
628
- this.db()
629
- .prepare("UPDATE kudos_current SET status = 'acknowledged', updated_sequence = ? WHERE kudos_id = ?")
630
- .run(sequence, event.kudosId);
767
+ this.prepare("UPDATE kudos_current SET status = 'acknowledged', updated_sequence = ? WHERE kudos_id = ?").run(sequence, event.kudosId);
631
768
  }
632
769
  else if (event.type === 'kudos.revoked') {
633
- this.db()
634
- .prepare("UPDATE kudos_current SET revocation_status = 'revoked', updated_sequence = ? WHERE kudos_id = ?")
635
- .run(sequence, event.kudosId);
770
+ this.prepare("UPDATE kudos_current SET revocation_status = 'revoked', updated_sequence = ? WHERE kudos_id = ?").run(sequence, event.kudosId);
636
771
  }
637
772
  }
638
773
  applyEventToItems(event, sequence) {
639
774
  const insert = (values) => {
640
- this.db()
641
- .prepare(`INSERT INTO items_current(
775
+ this.prepare(`INSERT INTO items_current(
642
776
  item_id, kind, created_sequence, updated_sequence, created_at, updated_at,
643
777
  actor_kind, actor_id, actor_display_name, title, tags_json, topic_ids_json, visibility, status,
644
- recipient_agent_id, recipient_display_name, owner_agent_id, owner_display_name,
645
- assignee_agent_id, assignee_display_name, due_at
646
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
647
- .run(event.aggregateId, values.kind, sequence, sequence, event.createdAt, event.createdAt, event.actor.kind, event.actor.id, event.actor.displayName ?? null, values.title, JSON.stringify(values.tags ?? []), JSON.stringify(values.topicIds ?? []), values.visibility, values.status, values.recipientAgentId ?? null, values.recipientDisplayName ?? null, values.ownerAgentId ?? null, values.ownerDisplayName ?? null, values.assigneeAgentId ?? null, values.assigneeDisplayName ?? null, values.dueAt ?? null);
778
+ recipient_id, recipient_display_name, owner_id, owner_display_name,
779
+ assignee_id, assignee_display_name, due_at
780
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(event.aggregateId, values.kind, sequence, sequence, event.createdAt, event.createdAt, event.actor.kind, event.actor.id, event.actor.displayName ?? null, values.title, JSON.stringify(values.tags ?? []), JSON.stringify(values.topicIds ?? []), values.visibility, values.status, values.recipient?.id ?? null, values.recipientDisplayName ?? null, values.owner?.id ?? null, values.ownerDisplayName ?? null, values.assignee?.id ?? null, values.assigneeDisplayName ?? null, values.dueAt ?? null);
648
781
  };
649
782
  const updateStatus = (status) => {
650
- this.db()
651
- .prepare('UPDATE items_current SET status = ?, updated_sequence = ?, updated_at = ? WHERE item_id = ?')
652
- .run(status, sequence, event.createdAt, event.aggregateId);
783
+ this.prepare('UPDATE items_current SET status = ?, updated_sequence = ?, updated_at = ? WHERE item_id = ?').run(status, sequence, event.createdAt, event.aggregateId);
653
784
  };
654
785
  if (event.type === 'kudos.given') {
655
786
  insert({
@@ -659,7 +790,7 @@ export class SynomemStorage {
659
790
  topicIds: event.topicIds,
660
791
  visibility: event.visibility,
661
792
  status: 'unacknowledged',
662
- recipientAgentId: event.recipientAgentId,
793
+ recipient: event.recipient,
663
794
  recipientDisplayName: event.recipientDisplayName,
664
795
  });
665
796
  }
@@ -675,7 +806,7 @@ export class SynomemStorage {
675
806
  topicIds: event.topicIds,
676
807
  visibility: event.visibility,
677
808
  status: 'unread',
678
- recipientAgentId: event.recipientAgentId,
809
+ recipient: event.recipient,
679
810
  recipientDisplayName: event.recipientDisplayName,
680
811
  });
681
812
  }
@@ -691,7 +822,7 @@ export class SynomemStorage {
691
822
  topicIds: event.topicIds,
692
823
  visibility: event.visibility,
693
824
  status: 'active',
694
- ownerAgentId: event.ownerAgentId,
825
+ owner: event.owner,
695
826
  ownerDisplayName: event.ownerDisplayName,
696
827
  });
697
828
  }
@@ -712,10 +843,8 @@ export class SynomemStorage {
712
843
  });
713
844
  }
714
845
  else if (event.type === 'post.edited') {
715
- this.db()
716
- .prepare(`UPDATE items_current SET title = ?, tags_json = ?, topic_ids_json = ?,
717
- updated_sequence = ?, updated_at = ? WHERE item_id = ?`)
718
- .run(event.title, JSON.stringify(event.tags ?? []), JSON.stringify(event.topicIds ?? []), sequence, event.createdAt, event.postId);
846
+ this.prepare(`UPDATE items_current SET title = ?, tags_json = ?, topic_ids_json = ?,
847
+ updated_sequence = ?, updated_at = ? WHERE item_id = ?`).run(event.title, JSON.stringify(event.tags ?? []), JSON.stringify(event.topicIds ?? []), sequence, event.createdAt, event.postId);
719
848
  }
720
849
  else if (event.type === 'post.archived') {
721
850
  updateStatus('archived');
@@ -723,25 +852,19 @@ export class SynomemStorage {
723
852
  else if (event.type === 'post.acknowledged') {
724
853
  // One row per actor per post: acknowledging twice is the same statement,
725
854
  // not a second one.
726
- this.db()
727
- .prepare(`INSERT INTO post_acknowledgments
855
+ this.prepare(`INSERT INTO post_acknowledgments
728
856
  (post_id, actor_kind, actor_id, actor_display_name, note, acknowledged_at)
729
857
  VALUES (?, ?, ?, ?, ?, ?)
730
858
  ON CONFLICT(post_id, actor_kind, actor_id) DO UPDATE SET
731
859
  note = excluded.note,
732
- acknowledged_at = excluded.acknowledged_at`)
733
- .run(event.postId, event.actor.kind, event.actor.id, event.actor.displayName ?? null, event.note ?? null, event.createdAt);
860
+ acknowledged_at = excluded.acknowledged_at`).run(event.postId, event.actor.kind, event.actor.id, event.actor.displayName ?? null, event.note ?? null, event.createdAt);
734
861
  }
735
862
  else if (event.type === 'post.acknowledgment.withdrawn') {
736
- this.db()
737
- .prepare('DELETE FROM post_acknowledgments WHERE post_id = ? AND actor_kind = ? AND actor_id = ?')
738
- .run(event.postId, event.actor.kind, event.actor.id);
863
+ this.prepare('DELETE FROM post_acknowledgments WHERE post_id = ? AND actor_kind = ? AND actor_id = ?').run(event.postId, event.actor.kind, event.actor.id);
739
864
  }
740
865
  else if (event.type === 'note.revised') {
741
- this.db()
742
- .prepare(`UPDATE items_current SET title = ?, tags_json = ?, topic_ids_json = ?, visibility = ?,
743
- updated_sequence = ?, updated_at = ? WHERE item_id = ?`)
744
- .run(event.title, JSON.stringify(event.tags ?? []), JSON.stringify(event.topicIds ?? []), event.visibility, sequence, event.createdAt, event.aggregateId);
866
+ this.prepare(`UPDATE items_current SET title = ?, tags_json = ?, topic_ids_json = ?, visibility = ?,
867
+ updated_sequence = ?, updated_at = ? WHERE item_id = ?`).run(event.title, JSON.stringify(event.tags ?? []), JSON.stringify(event.topicIds ?? []), event.visibility, sequence, event.createdAt, event.aggregateId);
745
868
  }
746
869
  else if (event.type === 'note.archived')
747
870
  updateStatus('archived');
@@ -753,16 +876,14 @@ export class SynomemStorage {
753
876
  topicIds: event.topicIds,
754
877
  visibility: event.visibility,
755
878
  status: event.requiresAcceptance ? 'assigned' : 'open',
756
- assigneeAgentId: event.assigneeAgentId,
879
+ assignee: event.assignee,
757
880
  assigneeDisplayName: event.assigneeDisplayName,
758
881
  ...((due) => (due ? { dueAt: due } : {}))(dueInstant(event.due)),
759
882
  });
760
883
  }
761
884
  else if (event.type === 'task.updated') {
762
- this.db()
763
- .prepare(`UPDATE items_current SET title = ?, tags_json = ?, topic_ids_json = ?, visibility = ?, due_at = ?,
764
- updated_sequence = ?, updated_at = ? WHERE item_id = ?`)
765
- .run(event.title, JSON.stringify(event.tags ?? []), JSON.stringify(event.topicIds ?? []), event.visibility, dueInstant(event.due) ?? null, sequence, event.createdAt, event.aggregateId);
885
+ this.prepare(`UPDATE items_current SET title = ?, tags_json = ?, topic_ids_json = ?, visibility = ?, due_at = ?,
886
+ updated_sequence = ?, updated_at = ? WHERE item_id = ?`).run(event.title, JSON.stringify(event.tags ?? []), JSON.stringify(event.topicIds ?? []), event.visibility, dueInstant(event.due) ?? null, sequence, event.createdAt, event.aggregateId);
766
887
  }
767
888
  else if (event.type === 'task.accepted')
768
889
  updateStatus('open');
@@ -772,6 +893,8 @@ export class SynomemStorage {
772
893
  updateStatus('completed');
773
894
  else if (event.type === 'task.reopened')
774
895
  updateStatus('open');
896
+ else if (event.type === 'task.decision_overridden')
897
+ updateStatus(event.nextStatus);
775
898
  else if (event.type === 'task.canceled')
776
899
  updateStatus('canceled');
777
900
  else if (event.type === 'todo.created') {
@@ -785,16 +908,14 @@ export class SynomemStorage {
785
908
  topicIds: event.topicIds,
786
909
  visibility: 'private',
787
910
  status: 'open',
788
- ownerAgentId: event.actor.id,
911
+ owner: event.owner,
789
912
  ...(event.actor.displayName ? { ownerDisplayName: event.actor.displayName } : {}),
790
913
  ...((due) => (due ? { dueAt: due } : {}))(dueInstant(event.due)),
791
914
  });
792
915
  }
793
916
  else if (event.type === 'todo.updated') {
794
- this.db()
795
- .prepare(`UPDATE items_current SET title = ?, tags_json = ?, topic_ids_json = ?, due_at = ?,
796
- updated_sequence = ?, updated_at = ? WHERE item_id = ?`)
797
- .run(event.title, JSON.stringify(event.tags ?? []), JSON.stringify(event.topicIds ?? []), dueInstant(event.due) ?? null, sequence, event.createdAt, event.aggregateId);
917
+ this.prepare(`UPDATE items_current SET title = ?, tags_json = ?, topic_ids_json = ?, due_at = ?,
918
+ updated_sequence = ?, updated_at = ? WHERE item_id = ?`).run(event.title, JSON.stringify(event.tags ?? []), JSON.stringify(event.topicIds ?? []), dueInstant(event.due) ?? null, sequence, event.createdAt, event.aggregateId);
798
919
  }
799
920
  else if (event.type === 'todo.completed')
800
921
  updateStatus('completed');
@@ -804,15 +925,24 @@ export class SynomemStorage {
804
925
  updateStatus('canceled');
805
926
  else if (event.type === 'todo.archived')
806
927
  updateStatus('archived');
928
+ if ('recipient' in event) {
929
+ this.prepare('UPDATE items_current SET recipient_kind=? WHERE item_id=?').run(event.recipient.kind, event.aggregateId);
930
+ }
931
+ if ('owner' in event) {
932
+ this.prepare('UPDATE items_current SET owner_kind=?,owner_id=? WHERE item_id=?').run(event.owner.kind, event.owner.id, event.aggregateId);
933
+ }
934
+ if ('assignee' in event) {
935
+ this.prepare('UPDATE items_current SET assignee_kind=? WHERE item_id=?').run(event.assignee.kind, event.aggregateId);
936
+ }
807
937
  }
808
938
  rebuildItemsCurrentIndex() {
809
- this.db().prepare('DELETE FROM items_current').run();
810
- const rows = this.db()
811
- .prepare('SELECT id, payload, sequence FROM events ORDER BY sequence ASC')
812
- .all();
939
+ this.prepare('DELETE FROM items_current').run();
940
+ const rows = this.prepare('SELECT id, payload, sequence FROM events ORDER BY sequence ASC').all();
813
941
  for (const row of rows) {
814
942
  try {
815
- this.applyEventToItems(this.parseEvent(row), row.sequence);
943
+ const event = this.parseEvent(row);
944
+ this.applyEventToItems(event, row.sequence);
945
+ this.prepare('UPDATE items_current SET workspace_id=? WHERE item_id=?').run(event.workspaceId, event.aggregateId);
816
946
  }
817
947
  catch (error) {
818
948
  if (!(error instanceof SynomemError))
@@ -820,11 +950,20 @@ export class SynomemStorage {
820
950
  }
821
951
  }
822
952
  }
953
+ rebuildParticipationCurrentIndex() {
954
+ for (const table of ['thread_entries', 'reactions_current', 'replies_current'])
955
+ this.prepare(`DELETE FROM ${table} WHERE workspace_id=?`).run(this.config.workspaceId);
956
+ for (const row of this.rawEventRows()) {
957
+ const event = this.parseEvent(row);
958
+ const sequence = this.prepare('SELECT sequence FROM events WHERE id=?').get(event.id).sequence;
959
+ for (const command of participationStatements(event, sequence, this.config.workspaceId))
960
+ if (!command.sql.includes('thread_members'))
961
+ this.prepare(command.sql).run(...command.values);
962
+ }
963
+ }
823
964
  rebuildKudosCurrentIndex() {
824
- this.db().prepare('DELETE FROM kudos_current').run();
825
- const rows = this.db()
826
- .prepare('SELECT id, payload, sequence FROM events ORDER BY sequence ASC')
827
- .all();
965
+ this.prepare('DELETE FROM kudos_current').run();
966
+ const rows = this.prepare('SELECT id, payload, sequence FROM events ORDER BY sequence ASC').all();
828
967
  for (const row of rows) {
829
968
  try {
830
969
  this.applyEventToCurrent(this.parseEvent(row), row.sequence);
@@ -836,19 +975,26 @@ export class SynomemStorage {
836
975
  }
837
976
  }
838
977
  nextAggregateVersion(aggregateId) {
839
- return Number(this.db()
840
- .prepare('SELECT COALESCE(MAX(aggregate_version), 0) + 1 AS next FROM events WHERE workspace_id = ? AND aggregate_id = ?')
841
- .get(this.config.workspaceId, aggregateId).next);
978
+ return Number(this.prepare('SELECT COALESCE(MAX(aggregate_version), 0) + 1 AS next FROM events WHERE workspace_id = ? AND aggregate_id = ?').get(this.config.workspaceId, aggregateId).next);
842
979
  }
843
980
  listKudosSummaries(input, viewer) {
981
+ const binding = this.binding('kudos-list', viewer, {
982
+ ...input,
983
+ cursor: undefined,
984
+ limit: undefined,
985
+ offset: undefined,
986
+ });
987
+ const changesBinding = this.binding('kudos-changes', viewer, { kinds: ['kudos'] });
988
+ const position = input.cursor ? this.decodeCursor(input.cursor, binding) : undefined;
989
+ const high = position?.watermark ?? this.maxEventSequence();
844
990
  const where = [];
845
991
  const parameters = [];
846
992
  const add = (clause, ...values) => {
847
993
  where.push(clause);
848
994
  parameters.push(...values);
849
995
  };
850
- if (input.recipientAgentId)
851
- add('recipient_agent_id = ?', input.recipientAgentId);
996
+ if (input.recipient?.id)
997
+ add('recipient_kind=? AND recipient_id = ?', input.recipient.kind, input.recipient.id);
852
998
  if (input.actorId)
853
999
  add('actor_id = ?', input.actorId);
854
1000
  if (input.actorKind)
@@ -868,32 +1014,23 @@ export class SynomemStorage {
868
1014
  add('created_at >= ?', input.from);
869
1015
  if (input.to)
870
1016
  add('created_at <= ?', input.to);
871
- if (viewer.kind !== 'human') {
872
- if (viewer.kind === 'agent') {
873
- add(`(visibility != 'private' OR recipient_agent_id = ? OR
874
- (actor_kind = ? AND actor_id = ?))`, viewer.id, viewer.kind, viewer.id);
875
- }
876
- else {
877
- add(`(visibility != 'private' OR (actor_kind = ? AND actor_id = ?))`, viewer.kind, viewer.id);
878
- }
879
- }
1017
+ const privacy = recordVisibilityPredicate(viewer, this.authority, true);
1018
+ add(privacy.sql, ...privacy.values);
880
1019
  const baseWhere = where.length ? `WHERE ${where.join(' AND ')}` : '';
881
- const total = Number(this.db()
882
- .prepare(`SELECT COUNT(*) AS count FROM kudos_current ${baseWhere}`)
883
- .get(...parameters).count);
884
- const cursorSequence = input.cursor ? decodeCursor(input.cursor, 'list') : undefined;
1020
+ const total = Number(this.prepare(`SELECT COUNT(*) AS count FROM kudos_current ${baseWhere}`).get(...parameters).count);
1021
+ const cursorSequence = position?.sequence;
885
1022
  const pageWhere = [...where];
886
1023
  const pageParameters = [...parameters];
887
1024
  if (cursorSequence !== undefined) {
888
1025
  pageWhere.push('given_sequence < ?');
889
1026
  pageParameters.push(cursorSequence);
890
1027
  }
1028
+ pageWhere.push('given_sequence<=? AND updated_sequence<=?');
1029
+ pageParameters.push(high, high);
891
1030
  const sqlWhere = pageWhere.length ? `WHERE ${pageWhere.join(' AND ')}` : '';
892
1031
  const offset = cursorSequence === undefined ? input.offset : 0;
893
- const rows = this.db()
894
- .prepare(`SELECT * FROM kudos_current ${sqlWhere}
895
- ORDER BY given_sequence DESC LIMIT ? OFFSET ?`)
896
- .all(...pageParameters, input.limit + 1, offset);
1032
+ const rows = this.prepare(`SELECT * FROM kudos_current ${sqlWhere}
1033
+ ORDER BY given_sequence DESC LIMIT ? OFFSET ?`).all(...pageParameters, input.limit + 1, offset);
897
1034
  const items = [];
898
1035
  let bytes = 2;
899
1036
  let contextLimited = false;
@@ -910,50 +1047,47 @@ export class SynomemStorage {
910
1047
  const hasMore = contextLimited || rows.length > items.length;
911
1048
  const last = items.at(-1);
912
1049
  const lastRow = last ? rows[items.length - 1] : undefined;
913
- const watermark = encodeCursor('change', this.maxEventSequence());
1050
+ const watermark = this.encodeCursor(changesBinding, high, high);
914
1051
  return {
915
1052
  items,
916
1053
  total,
917
1054
  limit: input.limit,
918
1055
  offset,
919
- ...(hasMore && lastRow ? { nextCursor: encodeCursor('list', lastRow.given_sequence) } : {}),
1056
+ ...(hasMore && lastRow
1057
+ ? { nextCursor: this.encodeCursor(binding, lastRow.given_sequence, high) }
1058
+ : {}),
920
1059
  hasMore,
921
1060
  watermark,
922
1061
  contextLimited,
923
1062
  };
924
1063
  }
925
1064
  listKudosChanges(after, limit, viewer) {
926
- const afterSequence = after ? decodeCursor(after, 'change') : 0;
1065
+ const binding = this.binding('kudos-changes', viewer, { kinds: ['kudos'] });
1066
+ const afterSequence = after ? this.decodeCursor(after, binding).sequence : 0n;
927
1067
  const highWatermark = this.maxEventSequence();
928
- const privacy = viewer.kind === 'human'
929
- ? ''
930
- : viewer.kind === 'agent'
931
- ? `AND (k.visibility != 'private' OR k.recipient_agent_id = ? OR
932
- (k.actor_kind = ? AND k.actor_id = ?))`
933
- : `AND (k.visibility != 'private' OR (k.actor_kind = ? AND k.actor_id = ?))`;
934
- const parameters = [afterSequence, highWatermark];
935
- if (viewer.kind === 'agent')
936
- parameters.push(viewer.id, viewer.kind, viewer.id);
937
- else if (viewer.kind === 'system')
938
- parameters.push(viewer.kind, viewer.id);
1068
+ const predicate = recordVisibilityPredicate(viewer, this.authority, true, 'k.');
1069
+ const privacy = `AND ${predicate.sql}`;
1070
+ const parameters = [
1071
+ afterSequence,
1072
+ highWatermark,
1073
+ ...predicate.values,
1074
+ ];
939
1075
  parameters.push(limit + 1);
940
- const rows = this.db()
941
- .prepare(`SELECT e.id AS event_id, e.sequence, e.type, e.created_at AS event_created_at, e.payload, k.*
1076
+ const rows = this.prepare(`SELECT e.id AS event_id, e.sequence, e.type, e.created_at AS event_created_at, e.payload, k.*
942
1077
  FROM events e
943
1078
  JOIN kudos_current k ON k.kudos_id = CASE
944
1079
  WHEN e.type = 'kudos.given' THEN e.id ELSE e.kudos_id END
945
1080
  WHERE e.sequence > ? AND e.sequence <= ?
946
1081
  AND e.type IN ('kudos.given', 'kudos.acknowledged', 'kudos.revoked')
947
1082
  ${privacy}
948
- ORDER BY e.sequence ASC LIMIT ?`)
949
- .all(...parameters);
1083
+ ORDER BY e.sequence ASC LIMIT ?`).all(...parameters);
950
1084
  const items = [];
951
1085
  let bytes = 2;
952
1086
  let contextLimited = false;
953
1087
  let consumed = 0;
954
1088
  let lastConsumedSequence = afterSequence;
955
1089
  for (const row of rows.slice(0, limit)) {
956
- const sequence = Number(row.sequence);
1090
+ const sequence = row.sequence;
957
1091
  let actor;
958
1092
  try {
959
1093
  const payload = JSON.parse(row.payload);
@@ -965,14 +1099,14 @@ export class SynomemStorage {
965
1099
  continue;
966
1100
  }
967
1101
  const item = {
968
- cursor: encodeCursor('change', sequence),
969
- sequence,
1102
+ cursor: this.encodeCursor(binding, sequence, highWatermark),
1103
+ sequence: exactSequence(sequence),
970
1104
  eventId: row.event_id,
971
1105
  type: row.type,
972
1106
  createdAt: row.event_created_at,
973
1107
  actor,
974
1108
  kudosId: row.kudos_id,
975
- recipientAgentId: row.recipient_agent_id,
1109
+ recipient: { kind: row.recipient_kind, id: row.recipient_id },
976
1110
  summary: summaryFromRow(row),
977
1111
  };
978
1112
  const itemBytes = Buffer.byteLength(JSON.stringify(item), 'utf8') + 1;
@@ -986,17 +1120,26 @@ export class SynomemStorage {
986
1120
  lastConsumedSequence = sequence;
987
1121
  }
988
1122
  const hasMore = contextLimited || rows.length > consumed;
989
- const nextCursor = encodeCursor('change', hasMore ? lastConsumedSequence : highWatermark);
1123
+ const nextCursor = this.encodeCursor(binding, hasMore ? lastConsumedSequence : highWatermark, highWatermark);
990
1124
  return {
991
1125
  items,
992
1126
  limit,
993
1127
  nextCursor,
994
1128
  hasMore,
995
- watermark: encodeCursor('change', highWatermark),
1129
+ watermark: this.encodeCursor(binding, highWatermark, highWatermark),
996
1130
  contextLimited,
997
1131
  };
998
1132
  }
999
1133
  listItemSummaries(input, viewer) {
1134
+ const binding = this.binding('item-list', viewer, {
1135
+ ...input,
1136
+ cursor: undefined,
1137
+ limit: undefined,
1138
+ offset: undefined,
1139
+ });
1140
+ const changesBinding = this.binding('item-changes', viewer, { kinds: input.kinds ?? [] });
1141
+ const position = input.cursor ? this.decodeCursor(input.cursor, binding) : undefined;
1142
+ const high = position?.watermark ?? this.maxEventSequence();
1000
1143
  const where = [];
1001
1144
  const parameters = [];
1002
1145
  const add = (clause, ...values) => {
@@ -1006,8 +1149,8 @@ export class SynomemStorage {
1006
1149
  if (input.kinds?.length) {
1007
1150
  add(`kind IN (${input.kinds.map(() => '?').join(', ')})`, ...input.kinds);
1008
1151
  }
1009
- if (input.participantAgentId) {
1010
- add('(recipient_agent_id = ? OR owner_agent_id = ? OR assignee_agent_id = ?)', input.participantAgentId, input.participantAgentId, input.participantAgentId);
1152
+ if (input.participant?.id) {
1153
+ add('((recipient_kind=? AND recipient_id=?) OR (owner_kind=? AND owner_id=?) OR (assignee_kind=? AND assignee_id=?))', input.participant.kind, input.participant.id, input.participant.kind, input.participant.id, input.participant.kind, input.participant.id);
1011
1154
  }
1012
1155
  if (input.actorId)
1013
1156
  add('actor_id = ?', input.actorId);
@@ -1044,40 +1187,22 @@ export class SynomemStorage {
1044
1187
  if (input.overdueAsOf) {
1045
1188
  add(`(due_at IS NOT NULL AND due_at < ? AND status IN ('assigned', 'open'))`, input.overdueAsOf);
1046
1189
  }
1047
- /*
1048
- * A todo is hidden from other agents, never from the human operating this home: humans are
1049
- * exempt from every private-record rule here (they are this store's administrators), and the
1050
- * hosted API grants the same to workspace owners and admins. Agents and system actors see only
1051
- * their own todos.
1052
- */
1053
- if (viewer.kind !== 'human') {
1054
- add(`(kind != 'todo' OR (owner_agent_id = ? AND ? = 'agent') OR (actor_id = ? AND actor_kind = ?))`, viewer.id, viewer.kind, viewer.id, viewer.kind);
1055
- }
1056
- if (viewer.kind !== 'human') {
1057
- if (viewer.kind === 'agent') {
1058
- add(`(visibility != 'private' OR (actor_kind = ? AND actor_id = ?) OR
1059
- recipient_agent_id = ? OR owner_agent_id = ? OR assignee_agent_id = ?)`, viewer.kind, viewer.id, viewer.id, viewer.id, viewer.id);
1060
- }
1061
- else {
1062
- add(`(visibility != 'private' OR (actor_kind = ? AND actor_id = ?))`, viewer.kind, viewer.id);
1063
- }
1064
- }
1190
+ const privacy = recordVisibilityPredicate(viewer, this.authority);
1191
+ add(privacy.sql, ...privacy.values);
1065
1192
  const baseWhere = where.length ? `WHERE ${where.join(' AND ')}` : '';
1066
- const total = Number(this.db()
1067
- .prepare(`SELECT COUNT(*) AS count FROM items_current ${baseWhere}`)
1068
- .get(...parameters).count);
1069
- const cursorSequence = input.cursor ? decodeCursor(input.cursor, 'items') : undefined;
1193
+ const total = Number(this.prepare(`SELECT COUNT(*) AS count FROM items_current ${baseWhere}`).get(...parameters).count);
1194
+ const cursorSequence = position?.sequence;
1070
1195
  const pageWhere = [...where];
1071
1196
  const pageParameters = [...parameters];
1072
1197
  if (cursorSequence !== undefined) {
1073
1198
  pageWhere.push('created_sequence < ?');
1074
1199
  pageParameters.push(cursorSequence);
1075
1200
  }
1201
+ pageWhere.push('created_sequence<=? AND updated_sequence<=?');
1202
+ pageParameters.push(high, high);
1076
1203
  const sqlWhere = pageWhere.length ? `WHERE ${pageWhere.join(' AND ')}` : '';
1077
1204
  const offset = cursorSequence === undefined ? input.offset : 0;
1078
- const rows = this.db()
1079
- .prepare(`SELECT * FROM items_current ${sqlWhere} ORDER BY created_sequence DESC LIMIT ? OFFSET ?`)
1080
- .all(...pageParameters, input.limit + 1, offset);
1205
+ const rows = this.prepare(`SELECT * FROM items_current ${sqlWhere} ORDER BY created_sequence DESC LIMIT ? OFFSET ?`).all(...pageParameters, input.limit + 1, offset);
1081
1206
  const items = [];
1082
1207
  let bytes = 2;
1083
1208
  let contextLimited = false;
@@ -1099,15 +1224,16 @@ export class SynomemStorage {
1099
1224
  limit: input.limit,
1100
1225
  offset,
1101
1226
  ...(hasMore && lastRow
1102
- ? { nextCursor: encodeCursor('items', lastRow.created_sequence) }
1227
+ ? { nextCursor: this.encodeCursor(binding, lastRow.created_sequence, high) }
1103
1228
  : {}),
1104
1229
  hasMore,
1105
- watermark: encodeCursor('change', this.maxEventSequence()),
1230
+ watermark: this.encodeCursor(changesBinding, high, high),
1106
1231
  contextLimited,
1107
1232
  };
1108
1233
  }
1109
1234
  listItemChanges(after, limit, viewer, kinds) {
1110
- const afterSequence = after ? decodeCursor(after, 'change') : 0;
1235
+ const binding = this.binding('item-changes', viewer, { kinds: kinds ?? [] });
1236
+ const afterSequence = after ? this.decodeCursor(after, binding).sequence : 0n;
1111
1237
  const highWatermark = this.maxEventSequence();
1112
1238
  const where = ['e.sequence > ?', 'e.sequence <= ?', 'e.item_kind IS NOT NULL'];
1113
1239
  const parameters = [afterSequence, highWatermark];
@@ -1115,30 +1241,20 @@ export class SynomemStorage {
1115
1241
  where.push(`e.item_kind IN (${kinds.map(() => '?').join(', ')})`);
1116
1242
  parameters.push(...kinds);
1117
1243
  }
1118
- if (viewer.kind !== 'human') {
1119
- if (viewer.kind === 'agent') {
1120
- where.push(`(i.visibility != 'private' OR (i.actor_kind = ? AND i.actor_id = ?) OR
1121
- i.recipient_agent_id = ? OR i.owner_agent_id = ? OR i.assignee_agent_id = ?)`);
1122
- parameters.push(viewer.kind, viewer.id, viewer.id, viewer.id, viewer.id);
1123
- }
1124
- else {
1125
- where.push(`(i.visibility != 'private' OR (i.actor_kind = ? AND i.actor_id = ?))`);
1126
- parameters.push(viewer.kind, viewer.id);
1127
- }
1128
- }
1244
+ const privacy = recordVisibilityPredicate(viewer, this.authority, false, 'i.');
1245
+ where.push(privacy.sql);
1246
+ parameters.push(...privacy.values);
1129
1247
  parameters.push(limit + 1);
1130
- const rows = this.db()
1131
- .prepare(`SELECT e.id AS event_id, e.sequence, e.type, e.created_at AS event_created_at,
1248
+ const rows = this.prepare(`SELECT e.id AS event_id, e.sequence, e.type, e.created_at AS event_created_at,
1132
1249
  e.payload, i.* FROM events e JOIN items_current i ON i.item_id = e.aggregate_id
1133
- WHERE ${where.join(' AND ')} ORDER BY e.sequence ASC LIMIT ?`)
1134
- .all(...parameters);
1250
+ WHERE ${where.join(' AND ')} ORDER BY e.sequence ASC LIMIT ?`).all(...parameters);
1135
1251
  const items = [];
1136
1252
  let bytes = 2;
1137
1253
  let contextLimited = false;
1138
1254
  let consumed = 0;
1139
1255
  let lastConsumedSequence = afterSequence;
1140
1256
  for (const row of rows.slice(0, limit)) {
1141
- const sequence = Number(row.sequence);
1257
+ const sequence = row.sequence;
1142
1258
  let actor;
1143
1259
  try {
1144
1260
  actor = actorSchema.parse(JSON.parse(row.payload).actor);
@@ -1149,8 +1265,8 @@ export class SynomemStorage {
1149
1265
  continue;
1150
1266
  }
1151
1267
  const item = {
1152
- cursor: encodeCursor('change', sequence),
1153
- sequence,
1268
+ cursor: this.encodeCursor(binding, sequence, highWatermark),
1269
+ sequence: exactSequence(sequence),
1154
1270
  eventId: row.event_id,
1155
1271
  type: row.type,
1156
1272
  createdAt: row.event_created_at,
@@ -1173,22 +1289,18 @@ export class SynomemStorage {
1173
1289
  return {
1174
1290
  items,
1175
1291
  limit,
1176
- nextCursor: encodeCursor('change', hasMore ? lastConsumedSequence : highWatermark),
1292
+ nextCursor: this.encodeCursor(binding, hasMore ? lastConsumedSequence : highWatermark, highWatermark),
1177
1293
  hasMore,
1178
- watermark: encodeCursor('change', highWatermark),
1294
+ watermark: this.encodeCursor(binding, highWatermark, highWatermark),
1179
1295
  contextLimited,
1180
1296
  };
1181
1297
  }
1182
1298
  getItemSummary(id) {
1183
- const row = this.db()
1184
- .prepare('SELECT * FROM items_current WHERE item_id = ?')
1185
- .get(id);
1299
+ const row = this.prepare('SELECT * FROM items_current WHERE item_id = ?').get(id);
1186
1300
  return row ? itemSummaryFromRow(row) : undefined;
1187
1301
  }
1188
1302
  getReadableItemEvents(id) {
1189
- const rows = this.db()
1190
- .prepare('SELECT id, payload FROM events WHERE aggregate_id = ? ORDER BY sequence ASC')
1191
- .all(id);
1303
+ const rows = this.prepare('SELECT id, payload FROM events WHERE aggregate_id = ? ORDER BY sequence ASC').all(id);
1192
1304
  const events = [];
1193
1305
  for (const row of rows) {
1194
1306
  try {
@@ -1201,13 +1313,9 @@ export class SynomemStorage {
1201
1313
  return events;
1202
1314
  }
1203
1315
  currentIndexHealth() {
1204
- const given = Number(this.db()
1205
- .prepare("SELECT COUNT(*) AS count FROM events WHERE type = 'kudos.given'")
1206
- .get().count);
1207
- const indexed = Number(this.db().prepare('SELECT COUNT(*) AS count FROM kudos_current').get()
1208
- .count);
1209
- const stateMismatches = Number(this.db()
1210
- .prepare(`SELECT COUNT(*) AS count
1316
+ const given = Number(this.prepare("SELECT COUNT(*) AS count FROM events WHERE type = 'kudos.given'").get().count);
1317
+ const indexed = Number(this.prepare('SELECT COUNT(*) AS count FROM kudos_current').get().count);
1318
+ const stateMismatches = Number(this.prepare(`SELECT COUNT(*) AS count
1211
1319
  FROM kudos_current k
1212
1320
  WHERE k.status != CASE WHEN EXISTS (
1213
1321
  SELECT 1 FROM events e
@@ -1216,24 +1324,18 @@ export class SynomemStorage {
1216
1324
  OR k.revocation_status != CASE WHEN EXISTS (
1217
1325
  SELECT 1 FROM events e
1218
1326
  WHERE e.type = 'kudos.revoked' AND e.kudos_id = k.kudos_id
1219
- ) THEN 'revoked' ELSE 'active' END`)
1220
- .get().count);
1327
+ ) THEN 'revoked' ELSE 'active' END`).get().count);
1221
1328
  return { given, indexed, stateMismatches };
1222
1329
  }
1223
1330
  itemIndexHealth() {
1224
- const created = Number(this.db()
1225
- .prepare(`SELECT COUNT(*) AS count FROM events WHERE type IN
1226
- ('kudos.given', 'memo.sent', 'note.created', 'post.created', 'task.created')`)
1227
- .get().count);
1228
- const indexed = Number(this.db().prepare('SELECT COUNT(*) AS count FROM items_current').get()
1229
- .count);
1331
+ const created = Number(this.prepare(`SELECT COUNT(*) AS count FROM events WHERE type IN
1332
+ ('kudos.given', 'memo.sent', 'note.created', 'post.created', 'task.created')`).get().count);
1333
+ const indexed = Number(this.prepare('SELECT COUNT(*) AS count FROM items_current').get().count);
1230
1334
  return { created, indexed };
1231
1335
  }
1232
1336
  migrationState() {
1233
- const schemaVersion = Number(this.db().prepare('PRAGMA user_version').get().user_version);
1234
- const appliedVersions = this.db()
1235
- .prepare('SELECT version FROM schema_migrations ORDER BY version')
1236
- .all().map((row) => Number(row.version));
1337
+ const schemaVersion = Number(this.prepare('PRAGMA user_version').get().user_version);
1338
+ const appliedVersions = this.prepare('SELECT version FROM schema_migrations ORDER BY version').all().map((row) => Number(row.version));
1237
1339
  return { schemaVersion, appliedVersions };
1238
1340
  }
1239
1341
  /**
@@ -1245,27 +1347,23 @@ export class SynomemStorage {
1245
1347
  * ambiguous now.
1246
1348
  */
1247
1349
  aliasIdentityConflicts() {
1248
- return this.db()
1249
- .prepare(`SELECT x.alias, x.agent_id AS agentId
1350
+ return this.prepare(`SELECT x.alias, x.agent_id AS agentId
1250
1351
  FROM aliases x
1251
1352
  JOIN agents a ON lower(a.id) = x.normalized_alias
1252
1353
  OR lower(a.handle) = x.normalized_alias
1253
1354
  WHERE a.id != x.agent_id
1254
- ORDER BY x.alias`)
1255
- .all();
1355
+ ORDER BY x.alias`).all();
1256
1356
  }
1257
1357
  maxEventSequence() {
1258
- return Number(this.db().prepare('SELECT COALESCE(MAX(sequence), 0) AS sequence FROM events').get().sequence);
1358
+ return this.prepare('SELECT COALESCE(MAX(sequence),0) AS sequence FROM events').get().sequence;
1259
1359
  }
1260
1360
  getEvent(id) {
1261
- const row = this.db().prepare('SELECT id, payload FROM events WHERE id = ?').get(id);
1361
+ const row = this.prepare('SELECT id, payload FROM events WHERE id = ?').get(id);
1262
1362
  return row ? this.parseEvent(row) : undefined;
1263
1363
  }
1264
1364
  getEventByIdempotency(actorKind, actorId, key) {
1265
- const row = this.db()
1266
- .prepare(`SELECT id, payload FROM events
1267
- WHERE actor_kind = ? AND actor_id = ? AND idempotency_key = ?`)
1268
- .get(actorKind, actorId, key);
1365
+ const row = this.prepare(`SELECT id, payload FROM events
1366
+ WHERE actor_kind = ? AND actor_id = ? AND idempotency_key = ?`).get(actorKind, actorId, key);
1269
1367
  return row ? this.parseEvent(row) : undefined;
1270
1368
  }
1271
1369
  getEvents() {
@@ -1278,11 +1376,9 @@ export class SynomemStorage {
1278
1376
  return this.scanEvents().events;
1279
1377
  }
1280
1378
  getReadableSynomemEvents(kudosId) {
1281
- const rows = this.db()
1282
- .prepare(`SELECT id, payload FROM events
1379
+ const rows = this.prepare(`SELECT id, payload FROM events
1283
1380
  WHERE (type = 'kudos.given' AND id = ?) OR kudos_id = ?
1284
- ORDER BY sequence ASC`)
1285
- .all(kudosId, kudosId);
1381
+ ORDER BY sequence ASC`).all(kudosId, kudosId);
1286
1382
  const events = [];
1287
1383
  for (const row of rows) {
1288
1384
  try {
@@ -1310,9 +1406,7 @@ export class SynomemStorage {
1310
1406
  return { events, invalid };
1311
1407
  }
1312
1408
  assertEventCompatibility() {
1313
- const rows = this.db()
1314
- .prepare('SELECT id, payload, sequence FROM events WHERE sequence > ? ORDER BY sequence ASC')
1315
- .all(this.validatedEventSequence);
1409
+ const rows = this.prepare('SELECT id, payload, sequence FROM events WHERE sequence > ? ORDER BY sequence ASC').all(this.validatedEventSequence);
1316
1410
  const invalid = [];
1317
1411
  for (const row of rows) {
1318
1412
  try {
@@ -1330,16 +1424,18 @@ export class SynomemStorage {
1330
1424
  throw new SynomemError(first.error.code, `Cannot write while canonical event ${first.id} is unsupported or malformed; upgrade Synomem or inspect with synomem doctor and export.`, { eventIds: invalid.map((item) => item.id) });
1331
1425
  }
1332
1426
  rawEventRows() {
1333
- return this.db()
1334
- .prepare('SELECT id, payload FROM events ORDER BY sequence ASC')
1335
- .all();
1427
+ return this.prepare('SELECT id, payload FROM events ORDER BY sequence ASC').all();
1336
1428
  }
1337
1429
  parseEvent(row) {
1338
1430
  try {
1339
- let value = JSON.parse(row.payload);
1431
+ const value = JSON.parse(row.payload);
1340
1432
  if (typeof value === 'object' && value !== null) {
1341
1433
  const candidate = value;
1342
1434
  const supportedTypes = new Set([
1435
+ 'reply.created',
1436
+ 'reply.deleted',
1437
+ 'reaction.added',
1438
+ 'reaction.removed',
1343
1439
  'agent.created',
1344
1440
  'agent.updated',
1345
1441
  'topic.created',
@@ -1365,6 +1461,7 @@ export class SynomemStorage {
1365
1461
  'task.accepted',
1366
1462
  'task.rejected',
1367
1463
  'task.canceled',
1464
+ 'task.decision_overridden',
1368
1465
  'todo.created',
1369
1466
  'todo.updated',
1370
1467
  'todo.completed',
@@ -1372,36 +1469,10 @@ export class SynomemStorage {
1372
1469
  'todo.canceled',
1373
1470
  'todo.archived',
1374
1471
  ]);
1375
- if ((typeof candidate.schemaVersion === 'number' && candidate.schemaVersion > 1) ||
1472
+ if ((typeof candidate.schemaVersion === 'number' && candidate.schemaVersion > 2) ||
1376
1473
  (typeof candidate.type === 'string' && !supportedTypes.has(candidate.type))) {
1377
1474
  throw new SynomemError('UNSUPPORTED_EVENT', `Event ${row.id} was written by a newer or incompatible Synomem version.`);
1378
1475
  }
1379
- const legacy = candidate;
1380
- if (legacy.workspaceId === undefined)
1381
- legacy.workspaceId = 'legacy-local';
1382
- if (legacy.aggregateId === undefined) {
1383
- legacy.aggregateId =
1384
- legacy.kudosId ??
1385
- legacy.memoId ??
1386
- legacy.noteId ??
1387
- legacy.taskId ??
1388
- (typeof legacy.agentId === 'string' ? legacy.agentId : undefined) ??
1389
- (typeof legacy.agent === 'object' && legacy.agent !== null
1390
- ? legacy.agent.id
1391
- : undefined) ??
1392
- legacy.id;
1393
- }
1394
- if (legacy.aggregateVersion === undefined) {
1395
- legacy.aggregateVersion =
1396
- String(legacy.type).endsWith('.given') ||
1397
- String(legacy.type).endsWith('.sent') ||
1398
- String(legacy.type).endsWith('.created')
1399
- ? 1
1400
- : 2;
1401
- }
1402
- if (legacy.visibility === 'local')
1403
- legacy.visibility = 'workspace';
1404
- value = legacy;
1405
1476
  }
1406
1477
  return eventSchema.parse(value);
1407
1478
  }
@@ -1413,21 +1484,121 @@ export class SynomemStorage {
1413
1484
  });
1414
1485
  }
1415
1486
  }
1487
+ canActorReadItem(id, actor) {
1488
+ const target = this.getActor(actor);
1489
+ if (!target || target.status !== 'active')
1490
+ return false;
1491
+ const authority = this.boundActor && this.boundActor.kind === actor.kind && this.boundActor.id === actor.id
1492
+ ? this.authority
1493
+ : resolveAuthority();
1494
+ const predicate = recordVisibilityPredicate(actor, authority);
1495
+ return !!this.prepare(`SELECT 1 FROM items_current WHERE item_id=? AND (${predicate.sql})`).get(id, ...predicate.values);
1496
+ }
1497
+ getMutationReceipt(actorKind, actorId, keyHash) {
1498
+ const row = this.prepare('SELECT operation,request_hash,result_json,event_id,created_at FROM mutation_receipts WHERE actor_kind=? AND actor_id=? AND key_hash=?').get(actorKind, actorId, keyHash);
1499
+ return row
1500
+ ? {
1501
+ operation: row.operation,
1502
+ requestHash: row.request_hash,
1503
+ resultJson: row.result_json,
1504
+ createdAt: row.created_at,
1505
+ ...(row.event_id ? { eventId: row.event_id } : {}),
1506
+ }
1507
+ : undefined;
1508
+ }
1509
+ insertMutationReceipt(actorKind, actorId, keyHash, receipt) {
1510
+ this.prepare('INSERT INTO mutation_receipts(actor_kind,actor_id,key_hash,operation,request_hash,result_json,event_id,created_at) VALUES(?,?,?,?,?,?,?,?)').run(actorKind, actorId, keyHash, receipt.operation, receipt.requestHash, receipt.resultJson, receipt.eventId ?? null, receipt.createdAt);
1511
+ }
1512
+ compactMutationReceipts(before) {
1513
+ this.assertWritable();
1514
+ return Number(this.prepare('UPDATE mutation_receipts SET result_json=NULL WHERE rowid IN (SELECT rowid FROM mutation_receipts WHERE created_at<? AND result_json IS NOT NULL ORDER BY created_at LIMIT 1000)').run(before).changes);
1515
+ }
1516
+ getActor(ref) {
1517
+ if (ref.kind === 'agent') {
1518
+ const profile = this.getAgent(ref.id);
1519
+ return profile
1520
+ ? {
1521
+ kind: 'agent',
1522
+ id: profile.id,
1523
+ handle: profile.handle,
1524
+ displayName: profile.displayName,
1525
+ status: profile.status === 'archived' ? 'inactive' : 'active',
1526
+ }
1527
+ : undefined;
1528
+ }
1529
+ const row = this.prepare('SELECT id,handle,display_name,status FROM human_actors WHERE id=? OR lower(handle)=lower(?)').get(ref.id, ref.id);
1530
+ return row
1531
+ ? {
1532
+ kind: 'human',
1533
+ id: row.id,
1534
+ handle: row.handle,
1535
+ displayName: row.display_name,
1536
+ status: row.status,
1537
+ }
1538
+ : undefined;
1539
+ }
1540
+ actorCounts(target, viewer) {
1541
+ const policy = recordVisibilityPredicate(viewer, this.authority, false, 'i.');
1542
+ const kudos = this.prepare(`SELECT COUNT(*) AS count FROM kudos_current k JOIN items_current i ON i.item_id=k.kudos_id WHERE i.workspace_id=? AND k.recipient_kind=? AND k.recipient_id=? AND k.revocation_status!='revoked' AND (${policy.sql})`).get(this.config.workspaceId, target.kind, target.id, ...policy.values);
1543
+ const useful = this.prepare(`SELECT COUNT(*) AS count FROM reactions_current c JOIN items_current i ON i.workspace_id=c.workspace_id AND i.item_id=c.root_id LEFT JOIN replies_current r ON r.workspace_id=c.workspace_id AND r.reply_id=c.target_id WHERE c.workspace_id=? AND c.code='useful' AND (${policy.sql}) AND ((c.target_id=i.item_id AND i.actor_kind=? AND i.actor_id=?) OR (r.deleted_at IS NULL AND json_extract(r.author_json,'$.kind')=? AND json_extract(r.author_json,'$.id')=?))`).get(this.config.workspaceId, ...policy.values, target.kind, target.id, target.kind, target.id);
1544
+ return { kudosReceived: Number(kudos.count), usefulReceived: Number(useful.count) };
1545
+ }
1546
+ listActors(input = {}, viewer) {
1547
+ const query = (input.query ?? '').toLowerCase();
1548
+ const binding = actorDirectoryBinding(this.config.workspaceId, viewer, input);
1549
+ const position = input.cursor
1550
+ ? this.cursorCodec.decode(input.cursor, binding, '0')
1551
+ : undefined;
1552
+ const after = position?.id ? parseActorDirectoryKey(position.id) : undefined;
1553
+ const source = `(
1554
+ SELECT 'human' AS kind,id,handle,display_name,status FROM human_actors
1555
+ UNION ALL SELECT 'agent' AS kind,id,json_extract(profile_json,'$.handle') AS handle,display_name,
1556
+ CASE WHEN json_extract(profile_json,'$.status')='archived' THEN 'inactive' ELSE 'active' END AS status FROM agents
1557
+ )`;
1558
+ const filter = `status='active' AND (? IS NULL OR kind=?) AND (?='' OR instr(lower(id||' '||handle||' '||display_name),?)>0)`;
1559
+ const values = [input.kind ?? null, input.kind ?? null, query, query];
1560
+ const total = this.prepare(`SELECT COUNT(*) AS count FROM ${source} WHERE ${filter}`).get(...values);
1561
+ const rows = this.prepare(`SELECT kind,id,handle,display_name,status FROM ${source} WHERE ${filter}${after ? ' AND (kind,handle,id)>(?,?,?)' : ''} ORDER BY kind,handle,id LIMIT ?`).all(...values, ...(after ?? []), (input.limit ?? 20) + 1);
1562
+ const limit = input.limit ?? 20;
1563
+ const hasMore = rows.length > limit;
1564
+ const items = rows.slice(0, limit).map((row) => ({
1565
+ kind: row.kind,
1566
+ id: row.id,
1567
+ handle: row.handle,
1568
+ displayName: row.display_name,
1569
+ status: row.status,
1570
+ }));
1571
+ return {
1572
+ items,
1573
+ total: Number(total.count),
1574
+ limit,
1575
+ hasMore,
1576
+ ...(hasMore
1577
+ ? {
1578
+ nextCursor: this.cursorCodec.encode(binding, {
1579
+ sequence: '0',
1580
+ watermark: '0',
1581
+ id: actorDirectoryKey(items.at(-1)),
1582
+ }),
1583
+ }
1584
+ : {}),
1585
+ };
1586
+ }
1587
+ registerHuman(actor) {
1588
+ this.assertWritable();
1589
+ this.prepare('INSERT INTO human_actors(id,handle,display_name,status) VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET handle=excluded.handle,display_name=excluded.display_name,status=excluded.status').run(actor.id, actor.handle, actor.displayName, actor.status);
1590
+ }
1416
1591
  insertAgent(profile) {
1417
1592
  const parsed = profileSchema.parse(profile);
1418
- this.db()
1419
- .prepare(`INSERT INTO agents(id, handle, status, display_name, profile_json, created_at, updated_at)
1420
- VALUES (?, ?, ?, ?, ?, ?, ?)`)
1421
- .run(parsed.id, parsed.handle, parsed.status, parsed.displayName, JSON.stringify(parsed), parsed.createdAt, parsed.createdAt);
1593
+ this.prepare(`INSERT INTO agents(id, handle, status, display_name, profile_json, created_at, updated_at)
1594
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(parsed.id, parsed.handle, parsed.status, parsed.displayName, JSON.stringify(parsed), parsed.createdAt, parsed.createdAt);
1422
1595
  this.insertAliases(parsed.id, parsed.aliases ?? []);
1423
1596
  }
1424
1597
  updateAgent(profile, updatedAt) {
1425
1598
  const parsed = profileSchema.parse(profile);
1426
- this.db()
1427
- .prepare(`UPDATE agents SET handle = ?, status = ?, display_name = ?, profile_json = ?,
1428
- updated_at = ? WHERE id = ?`)
1429
- .run(parsed.handle, parsed.status, parsed.displayName, JSON.stringify(parsed), updatedAt, parsed.id);
1430
- this.db().prepare('DELETE FROM aliases WHERE agent_id = ?').run(parsed.id);
1599
+ this.prepare(`UPDATE agents SET handle = ?, status = ?, display_name = ?, profile_json = ?,
1600
+ updated_at = ? WHERE id = ?`).run(parsed.handle, parsed.status, parsed.displayName, JSON.stringify(parsed), updatedAt, parsed.id);
1601
+ this.prepare('DELETE FROM aliases WHERE agent_id = ?').run(parsed.id);
1431
1602
  this.insertAliases(parsed.id, parsed.aliases ?? []);
1432
1603
  }
1433
1604
  getAgent(idOrAlias) {
@@ -1446,31 +1617,25 @@ export class SynomemStorage {
1446
1617
  */
1447
1618
  resolveAgent(query) {
1448
1619
  const normalized = query.trim().toLowerCase();
1449
- const rows = this.db()
1450
- .prepare(`SELECT DISTINCT a.profile_json
1620
+ const rows = this.prepare(`SELECT DISTINCT a.profile_json
1451
1621
  FROM agents a
1452
1622
  LEFT JOIN aliases x ON x.agent_id = a.id
1453
1623
  WHERE lower(a.id) = ? OR lower(a.handle) = ? OR x.normalized_alias = ?
1454
- ORDER BY a.id ASC`)
1455
- .all(normalized, normalized, normalized);
1624
+ ORDER BY a.id ASC`).all(normalized, normalized, normalized);
1456
1625
  const candidates = rows.map((row) => profileSchema.parse(JSON.parse(row.profile_json)));
1457
1626
  return candidates.length === 1 ? { match: candidates[0], candidates } : { candidates };
1458
1627
  }
1459
1628
  /* -------------------------------------------------------------------- topics */
1460
1629
  insertTopic(topic) {
1461
1630
  const parsed = topicProfileSchema.parse(topic);
1462
- this.db()
1463
- .prepare(`INSERT INTO topics(id, display_name, status, created_at, updated_at)
1464
- VALUES (?, ?, ?, ?, ?)`)
1465
- .run(parsed.id, parsed.displayName, parsed.status, parsed.createdAt, parsed.createdAt);
1631
+ this.prepare(`INSERT INTO topics(id, display_name, status, created_at, updated_at)
1632
+ VALUES (?, ?, ?, ?, ?)`).run(parsed.id, parsed.displayName, parsed.status, parsed.createdAt, parsed.createdAt);
1466
1633
  this.insertTopicAliases(parsed.id, parsed.aliases ?? []);
1467
1634
  }
1468
1635
  updateTopic(topic, updatedAt) {
1469
1636
  const parsed = topicProfileSchema.parse(topic);
1470
- this.db()
1471
- .prepare('UPDATE topics SET display_name = ?, status = ?, updated_at = ? WHERE id = ?')
1472
- .run(parsed.displayName, parsed.status, updatedAt, parsed.id);
1473
- this.db().prepare('DELETE FROM topic_aliases WHERE topic_id = ?').run(parsed.id);
1637
+ this.prepare('UPDATE topics SET display_name = ?, status = ?, updated_at = ? WHERE id = ?').run(parsed.displayName, parsed.status, updatedAt, parsed.id);
1638
+ this.prepare('DELETE FROM topic_aliases WHERE topic_id = ?').run(parsed.id);
1474
1639
  this.insertTopicAliases(parsed.id, parsed.aliases ?? []);
1475
1640
  }
1476
1641
  getTopic(idOrAlias) {
@@ -1478,31 +1643,23 @@ export class SynomemStorage {
1478
1643
  }
1479
1644
  listTopics(status) {
1480
1645
  const rows = (status
1481
- ? this.db()
1482
- .prepare('SELECT * FROM topics WHERE status = ? ORDER BY display_name ASC')
1483
- .all(status)
1484
- : this.db()
1485
- .prepare('SELECT * FROM topics ORDER BY display_name ASC')
1486
- .all());
1646
+ ? this.prepare('SELECT * FROM topics WHERE status = ? ORDER BY display_name ASC').all(status)
1647
+ : this.prepare('SELECT * FROM topics ORDER BY display_name ASC').all());
1487
1648
  return rows.map((row) => this.topicFromRow(row));
1488
1649
  }
1489
1650
  /** Resolves a name case-insensitively, reporting ambiguity instead of guessing. */
1490
1651
  resolveTopic(query) {
1491
1652
  const normalized = query.trim().toLowerCase();
1492
- const rows = this.db()
1493
- .prepare(`SELECT DISTINCT t.*
1653
+ const rows = this.prepare(`SELECT DISTINCT t.*
1494
1654
  FROM topics t
1495
1655
  LEFT JOIN topic_aliases x ON x.topic_id = t.id
1496
1656
  WHERE lower(t.id) = ? OR lower(t.display_name) = ? OR x.alias = ?
1497
- ORDER BY t.id ASC`)
1498
- .all(normalized, normalized, normalized);
1657
+ ORDER BY t.id ASC`).all(normalized, normalized, normalized);
1499
1658
  const candidates = rows.map((row) => this.topicFromRow(row));
1500
1659
  return candidates.length === 1 ? { match: candidates[0], candidates } : { candidates };
1501
1660
  }
1502
1661
  topicFromRow(row) {
1503
- const aliasRows = this.db()
1504
- .prepare('SELECT alias FROM topic_aliases WHERE topic_id = ? ORDER BY alias ASC')
1505
- .all(row.id);
1662
+ const aliasRows = this.prepare('SELECT alias FROM topic_aliases WHERE topic_id = ? ORDER BY alias ASC').all(row.id);
1506
1663
  const aliases = aliasRows.map((aliasRow) => aliasRow.alias);
1507
1664
  return {
1508
1665
  id: row.id,
@@ -1514,17 +1671,13 @@ export class SynomemStorage {
1514
1671
  }
1515
1672
  insertTopicAliases(topicId, aliases) {
1516
1673
  for (const alias of aliases) {
1517
- this.db()
1518
- .prepare('INSERT INTO topic_aliases(alias, topic_id) VALUES (?, ?)')
1519
- .run(alias.trim().toLowerCase(), topicId);
1674
+ this.prepare('INSERT INTO topic_aliases(alias, topic_id) VALUES (?, ?)').run(alias.trim().toLowerCase(), topicId);
1520
1675
  }
1521
1676
  }
1522
1677
  /* -------------------------------------------------------- post acknowledgment */
1523
1678
  listPostAcknowledgments(postId) {
1524
- const rows = this.db()
1525
- .prepare(`SELECT actor_kind, actor_id, actor_display_name, note, acknowledged_at
1526
- FROM post_acknowledgments WHERE post_id = ? ORDER BY acknowledged_at ASC`)
1527
- .all(postId);
1679
+ const rows = this.prepare(`SELECT actor_kind, actor_id, actor_display_name, note, acknowledged_at
1680
+ FROM post_acknowledgments WHERE post_id = ? ORDER BY acknowledged_at ASC`).all(postId);
1528
1681
  return rows.map((row) => ({
1529
1682
  actor: {
1530
1683
  kind: row.actor_kind,
@@ -1544,16 +1697,12 @@ export class SynomemStorage {
1544
1697
  * newcomer of ignoring something written before it arrived.
1545
1698
  */
1546
1699
  postRoster(postId) {
1547
- const post = this.db()
1548
- .prepare("SELECT created_at FROM items_current WHERE item_id = ? AND kind = 'post'")
1549
- .get(postId);
1700
+ const post = this.prepare("SELECT created_at FROM items_current WHERE item_id = ? AND kind = 'post'").get(postId);
1550
1701
  if (!post)
1551
1702
  return undefined;
1552
1703
  const acknowledged = this.listPostAcknowledgments(postId);
1553
1704
  const acknowledgedIds = new Set(acknowledged.map((entry) => entry.actor.id));
1554
- const eligible = this.db()
1555
- .prepare('SELECT id, display_name, created_at FROM agents ORDER BY id ASC')
1556
- .all();
1705
+ const eligible = this.prepare('SELECT id, display_name, created_at FROM agents ORDER BY id ASC').all();
1557
1706
  const outstanding = eligible
1558
1707
  .filter((agent) => agent.created_at <= post.created_at && !acknowledgedIds.has(agent.id))
1559
1708
  .map((agent) => ({ id: agent.id, displayName: agent.display_name }));
@@ -1562,11 +1711,9 @@ export class SynomemStorage {
1562
1711
  }
1563
1712
  /* ------------------------------------------------------- runtime bindings */
1564
1713
  listRuntimeBindings(agentId) {
1565
- const rows = this.db()
1566
- .prepare(`SELECT id, agent_id, installation_id, runtime, profile, capabilities_json,
1714
+ const rows = this.prepare(`SELECT id, agent_id, installation_id, runtime, profile, capabilities_json,
1567
1715
  bound_at, last_seen_at
1568
- FROM agent_runtime_bindings WHERE agent_id = ? ORDER BY bound_at ASC`)
1569
- .all(agentId);
1716
+ FROM agent_runtime_bindings WHERE agent_id = ? ORDER BY bound_at ASC`).all(agentId);
1570
1717
  return rows.map((row) => ({
1571
1718
  id: row.id,
1572
1719
  agentId: row.agent_id,
@@ -1579,18 +1726,14 @@ export class SynomemStorage {
1579
1726
  }));
1580
1727
  }
1581
1728
  bindRuntime(binding) {
1582
- this.db()
1583
- .prepare(`INSERT INTO agent_runtime_bindings(
1729
+ this.prepare(`INSERT INTO agent_runtime_bindings(
1584
1730
  id, agent_id, installation_id, runtime, profile, capabilities_json, bound_at
1585
1731
  ) VALUES (?, ?, ?, ?, ?, ?, ?)
1586
1732
  ON CONFLICT(agent_id, runtime, COALESCE(profile, ''), COALESCE(installation_id, ''))
1587
- DO UPDATE SET capabilities_json = excluded.capabilities_json`)
1588
- .run(binding.id, binding.agentId, binding.installationId ?? null, binding.runtime, binding.profile ?? null, JSON.stringify(binding.capabilities ?? {}), binding.boundAt);
1733
+ DO UPDATE SET capabilities_json = excluded.capabilities_json`).run(binding.id, binding.agentId, binding.installationId ?? null, binding.runtime, binding.profile ?? null, JSON.stringify(binding.capabilities ?? {}), binding.boundAt);
1589
1734
  }
1590
1735
  unbindRuntime(bindingId) {
1591
- const result = this.db()
1592
- .prepare('DELETE FROM agent_runtime_bindings WHERE id = ?')
1593
- .run(bindingId);
1736
+ const result = this.prepare('DELETE FROM agent_runtime_bindings WHERE id = ?').run(bindingId);
1594
1737
  return Number(result.changes) > 0;
1595
1738
  }
1596
1739
  /**
@@ -1598,9 +1741,7 @@ export class SynomemStorage {
1598
1741
  * the runtime is reachable now, and never that a delivery succeeded.
1599
1742
  */
1600
1743
  touchRuntimeBinding(agentId, runtime, at) {
1601
- this.db()
1602
- .prepare('UPDATE agent_runtime_bindings SET last_seen_at = ? WHERE agent_id = ? AND runtime = ?')
1603
- .run(at, agentId, runtime);
1744
+ this.prepare('UPDATE agent_runtime_bindings SET last_seen_at = ? WHERE agent_id = ? AND runtime = ?').run(at, agentId, runtime);
1604
1745
  }
1605
1746
  /**
1606
1747
  * Writes an agent's aliases, refusing any that would make a name ambiguous.
@@ -1614,33 +1755,25 @@ export class SynomemStorage {
1614
1755
  insertAliases(agentId, aliases) {
1615
1756
  for (const alias of aliases) {
1616
1757
  const normalized = alias.trim().toLowerCase();
1617
- const conflictingAgent = this.db()
1618
- .prepare('SELECT id FROM agents WHERE lower(id) = ? AND id != ?')
1619
- .get(normalized, agentId);
1758
+ const conflictingAgent = this.prepare('SELECT id FROM agents WHERE lower(id) = ? AND id != ?').get(normalized, agentId);
1620
1759
  if (conflictingAgent) {
1621
1760
  throw new SynomemError('ALIAS_CONFLICT', `Alias "${alias}" is already the canonical ID of agent ${conflictingAgent.id}. Aliases must resolve to exactly one agent.`);
1622
1761
  }
1623
- const conflictingAlias = this.db()
1624
- .prepare('SELECT agent_id FROM aliases WHERE normalized_alias = ? AND agent_id != ?')
1625
- .get(normalized, agentId);
1762
+ const conflictingAlias = this.prepare('SELECT agent_id FROM aliases WHERE normalized_alias = ? AND agent_id != ?').get(normalized, agentId);
1626
1763
  if (conflictingAlias) {
1627
1764
  throw new SynomemError('ALIAS_CONFLICT', `Alias "${alias}" already belongs to agent ${conflictingAlias.agent_id}. Aliases must resolve to exactly one agent.`);
1628
1765
  }
1629
- this.db()
1630
- .prepare('INSERT INTO aliases(alias, agent_id, normalized_alias) VALUES (?, ?, ?)')
1631
- .run(alias, agentId, normalized);
1766
+ this.prepare('INSERT INTO aliases(alias, agent_id, normalized_alias) VALUES (?, ?, ?)').run(alias, agentId, normalized);
1632
1767
  }
1633
1768
  }
1634
1769
  listAgents() {
1635
- const rows = this.db()
1636
- .prepare('SELECT profile_json FROM agents ORDER BY id ASC')
1637
- .all();
1770
+ const rows = this.prepare('SELECT profile_json FROM agents ORDER BY id ASC').all();
1638
1771
  return rows.map((row) => profileSchema.parse(JSON.parse(row.profile_json)));
1639
1772
  }
1640
1773
  replaceProjectionManifest(paths, generatedAt) {
1641
1774
  this.transactionSync(() => {
1642
- this.db().prepare('DELETE FROM projection_manifest').run();
1643
- const insert = this.db().prepare('INSERT INTO projection_manifest(path, generated_at) VALUES (?, ?)');
1775
+ this.prepare('DELETE FROM projection_manifest').run();
1776
+ const insert = this.prepare('INSERT INTO projection_manifest(path, generated_at) VALUES (?, ?)');
1644
1777
  for (const path of paths)
1645
1778
  insert.run(path, generatedAt);
1646
1779
  });
@@ -1652,16 +1785,14 @@ export class SynomemStorage {
1652
1785
  */
1653
1786
  replaceAgentProjectionManifest(directory, paths, generatedAt) {
1654
1787
  this.transactionSync(() => {
1655
- this.db()
1656
- .prepare('DELETE FROM projection_manifest WHERE path LIKE ? OR path LIKE ?')
1657
- .run(`${directory}/%`, `${directory}\\%`);
1658
- const insert = this.db().prepare('INSERT INTO projection_manifest(path, generated_at) VALUES (?, ?)');
1788
+ this.prepare('DELETE FROM projection_manifest WHERE path LIKE ? OR path LIKE ?').run(`${directory}/%`, `${directory}\\%`);
1789
+ const insert = this.prepare('INSERT INTO projection_manifest(path, generated_at) VALUES (?, ?)');
1659
1790
  for (const path of paths)
1660
1791
  insert.run(path, generatedAt);
1661
1792
  });
1662
1793
  }
1663
1794
  projectionManifest() {
1664
- return this.db().prepare('SELECT path FROM projection_manifest ORDER BY path').all().map((row) => row.path);
1795
+ return this.prepare('SELECT path FROM projection_manifest ORDER BY path').all().map((row) => row.path);
1665
1796
  }
1666
1797
  /**
1667
1798
  * Re-point manifest paths from one agent directory to another.
@@ -1680,8 +1811,8 @@ export class SynomemStorage {
1680
1811
  const rows = this.projectionManifestEntries().filter((entry) => prefixes.some((prefix) => entry.path.startsWith(prefix)));
1681
1812
  if (!rows.length)
1682
1813
  return;
1683
- const remove = this.db().prepare('DELETE FROM projection_manifest WHERE path = ?');
1684
- const insert = this.db().prepare('INSERT OR REPLACE INTO projection_manifest(path, generated_at) VALUES (?, ?)');
1814
+ const remove = this.prepare('DELETE FROM projection_manifest WHERE path = ?');
1815
+ const insert = this.prepare('INSERT OR REPLACE INTO projection_manifest(path, generated_at) VALUES (?, ?)');
1685
1816
  for (const row of rows) {
1686
1817
  remove.run(row.path);
1687
1818
  insert.run(`${nextHandle}${row.path.slice(previousHandle.length)}`, row.generatedAt);
@@ -1689,16 +1820,14 @@ export class SynomemStorage {
1689
1820
  }
1690
1821
  /** The manifest with the time each path was written, newest first. */
1691
1822
  projectionManifestEntries() {
1692
- return this.db()
1693
- .prepare('SELECT path, generated_at FROM projection_manifest ORDER BY generated_at DESC')
1694
- .all().map((row) => ({ path: row.path, generatedAt: row.generated_at }));
1823
+ return this.prepare('SELECT path, generated_at FROM projection_manifest ORDER BY generated_at DESC').all().map((row) => ({ path: row.path, generatedAt: row.generated_at }));
1695
1824
  }
1696
1825
  integrityCheck() {
1697
- const rows = this.db().prepare('PRAGMA integrity_check').all();
1826
+ const rows = this.prepare('PRAGMA integrity_check').all();
1698
1827
  return rows.map((row) => Object.values(row)[0] ?? 'unknown');
1699
1828
  }
1700
1829
  journalMode() {
1701
- const row = this.db().prepare('PRAGMA journal_mode').get();
1830
+ const row = this.prepare('PRAGMA journal_mode').get();
1702
1831
  return Object.values(row)[0] ?? 'unknown';
1703
1832
  }
1704
1833
  async backup(destination) {