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/src/storage.ts CHANGED
@@ -1,3 +1,26 @@
1
+ import {
2
+ actorDirectoryBinding,
3
+ actorDirectoryKey,
4
+ parseActorDirectoryKey,
5
+ } from './actor-directory.js';
6
+ import type { ActorDirectoryInput, ActorDirectoryPage } from './actor-directory.js';
7
+ import type { SQLInputValue as HpSQLInputValue } from 'node:sqlite';
8
+ import { SqlBookmarkRepository, bookmarkTables } from './bookmarks.js';
9
+ import { SqlNotificationRepository, notificationTables } from './notifications.js';
10
+ import {
11
+ SqlParticipationRepository,
12
+ participationStatements,
13
+ participationTables,
14
+ } from './participation-repository.js';
15
+ import { AsyncLocalStorage } from 'node:async_hooks';
16
+ import type { MutationReceipt } from './mutation-receipts.js';
17
+ import { randomBytes } from 'node:crypto';
18
+ import { SignedCursorCodec, cursorFilter, exactSequence } from './cursors.js';
19
+ import type { CursorBinding } from './cursors.js';
20
+ import type { StatementSync } from 'node:sqlite';
21
+ import type { ActorRef } from './policy.js';
22
+ import { recordVisibilityPredicate, resolveAuthority } from './policy.js';
23
+ import type { RecordAuthority, AddressableActor } from './policy.js';
1
24
  import {
2
25
  chmodSync,
3
26
  closeSync,
@@ -7,6 +30,8 @@ import {
7
30
  mkdirSync,
8
31
  mkdtempSync,
9
32
  openSync,
33
+ readFileSync,
34
+ readSync,
10
35
  rmdirSync,
11
36
  unlinkSync,
12
37
  } from 'node:fs';
@@ -45,18 +70,17 @@ import type {
45
70
  KudosSummary,
46
71
  Page,
47
72
  } from './types.js';
48
-
49
73
  interface EventRow {
50
74
  id: string;
51
75
  payload: string;
52
- sequence?: number;
76
+ sequence?: bigint;
53
77
  }
54
-
55
78
  interface CurrentRow {
56
79
  kudos_id: string;
57
- given_sequence: number;
80
+ given_sequence: bigint;
58
81
  created_at: string;
59
- recipient_agent_id: string;
82
+ recipient_kind: 'human' | 'agent';
83
+ recipient_id: string;
60
84
  recipient_display_name: string;
61
85
  actor_kind: ActorIdentity['kind'];
62
86
  actor_id: string;
@@ -67,14 +91,13 @@ interface CurrentRow {
67
91
  visibility: KudosSummary['visibility'];
68
92
  status: KudosSummary['status'];
69
93
  revocation_status: KudosSummary['revocationStatus'];
70
- updated_sequence: number;
94
+ updated_sequence: bigint;
71
95
  }
72
-
73
96
  interface ItemRow {
74
97
  item_id: string;
75
98
  kind: RecordKind;
76
- created_sequence: number;
77
- updated_sequence: number;
99
+ created_sequence: bigint;
100
+ updated_sequence: bigint;
78
101
  created_at: string;
79
102
  updated_at: string;
80
103
  actor_kind: ActorIdentity['kind'];
@@ -85,33 +108,34 @@ interface ItemRow {
85
108
  topic_ids_json: string;
86
109
  visibility: ItemSummary['visibility'];
87
110
  status: string;
88
- recipient_agent_id: string | null;
111
+ recipient_kind: 'human' | 'agent';
112
+ recipient_id: string | null;
89
113
  recipient_display_name: string | null;
90
- owner_agent_id: string | null;
114
+ owner_kind: 'human' | 'agent' | null;
115
+ owner_id: string | null;
91
116
  owner_display_name: string | null;
92
- assignee_agent_id: string | null;
117
+ assignee_kind: 'human' | 'agent' | null;
118
+ assignee_id: string | null;
93
119
  assignee_display_name: string | null;
94
120
  }
95
-
96
121
  export interface EventScan {
97
122
  events: SynomemEvent[];
98
- invalid: Array<{ id: string; error: SynomemError }>;
123
+ invalid: Array<{
124
+ id: string;
125
+ error: SynomemError;
126
+ }>;
99
127
  }
100
-
101
128
  interface ProfileRow {
102
129
  profile_json: string;
103
130
  }
104
-
105
131
  interface TopicRow {
106
132
  id: string;
107
133
  display_name: string;
108
134
  status: 'active' | 'archived';
109
135
  created_at: string;
110
136
  }
111
-
112
137
  /** The schema version this file writes and expects. */
113
- const SUPPORTED_SCHEMA_VERSION = 8;
114
-
138
+ const SUPPORTED_SCHEMA_VERSION = 9;
115
139
  const migrationV1 = `
116
140
  CREATE TABLE IF NOT EXISTS schema_migrations (
117
141
  version INTEGER PRIMARY KEY,
@@ -138,7 +162,8 @@ CREATE TABLE IF NOT EXISTS events (
138
162
  created_at TEXT NOT NULL,
139
163
  actor_kind TEXT NOT NULL,
140
164
  actor_id TEXT NOT NULL,
141
- recipient_agent_id TEXT,
165
+ recipient_kind TEXT NOT NULL DEFAULT 'agent',
166
+ recipient_id TEXT,
142
167
  kudos_id TEXT,
143
168
  visibility TEXT,
144
169
  idempotency_key TEXT,
@@ -150,7 +175,7 @@ ON events(actor_kind, actor_id, idempotency_key)
150
175
  WHERE type = 'kudos.given' AND idempotency_key IS NOT NULL;
151
176
 
152
177
  CREATE INDEX IF NOT EXISTS events_type_created ON events(type, created_at, id);
153
- CREATE INDEX IF NOT EXISTS events_recipient ON events(recipient_agent_id, created_at, id);
178
+ CREATE INDEX IF NOT EXISTS events_recipient ON events(recipient_id, created_at, id);
154
179
  CREATE INDEX IF NOT EXISTS events_kudos_id ON events(kudos_id, created_at, id);
155
180
 
156
181
  CREATE TABLE IF NOT EXISTS projection_manifest (
@@ -168,7 +193,6 @@ BEFORE DELETE ON events BEGIN
168
193
  SELECT RAISE(ABORT, 'events are append-only');
169
194
  END;
170
195
  `;
171
-
172
196
  const migrationV2 = `
173
197
  DROP TRIGGER IF EXISTS events_append_only_update;
174
198
  ALTER TABLE events ADD COLUMN sequence INTEGER;
@@ -187,7 +211,8 @@ CREATE TABLE kudos_current (
187
211
  kudos_id TEXT PRIMARY KEY,
188
212
  given_sequence INTEGER NOT NULL UNIQUE,
189
213
  created_at TEXT NOT NULL,
190
- recipient_agent_id TEXT NOT NULL,
214
+ recipient_kind TEXT NOT NULL DEFAULT 'agent',
215
+ recipient_id TEXT NOT NULL,
191
216
  recipient_display_name TEXT NOT NULL,
192
217
  actor_kind TEXT NOT NULL,
193
218
  actor_id TEXT NOT NULL,
@@ -200,11 +225,10 @@ CREATE TABLE kudos_current (
200
225
  revocation_status TEXT NOT NULL,
201
226
  updated_sequence INTEGER NOT NULL
202
227
  ) STRICT;
203
- CREATE INDEX kudos_current_recipient ON kudos_current(recipient_agent_id, given_sequence DESC);
228
+ CREATE INDEX kudos_current_recipient ON kudos_current(recipient_id, given_sequence DESC);
204
229
  CREATE INDEX kudos_current_actor ON kudos_current(actor_kind, actor_id, given_sequence DESC);
205
230
  CREATE INDEX kudos_current_status ON kudos_current(status, revocation_status, given_sequence DESC);
206
231
  `;
207
-
208
232
  /**
209
233
  * v4 records a task's or todo's deadline on the item index.
210
234
  *
@@ -218,7 +242,6 @@ const migrationV4 = `
218
242
  ALTER TABLE items_current ADD COLUMN due_at TEXT;
219
243
  CREATE INDEX items_current_due ON items_current(kind, status, due_at);
220
244
  `;
221
-
222
245
  /**
223
246
  * v5 makes alias lookup case-insensitive and unambiguous.
224
247
  *
@@ -252,7 +275,6 @@ CREATE UNIQUE INDEX agent_runtime_bindings_unique
252
275
  ON agent_runtime_bindings(agent_id, runtime, COALESCE(profile, ''), COALESCE(installation_id, ''));
253
276
  CREATE INDEX agent_runtime_bindings_agent ON agent_runtime_bindings(agent_id);
254
277
  `;
255
-
256
278
  const migrationV6 = `
257
279
  -- Acknowledgements are their own rows rather than a column on the post: many
258
280
  -- actors acknowledge one post independently, and the interesting question is
@@ -268,7 +290,6 @@ CREATE TABLE post_acknowledgments (
268
290
  ) STRICT;
269
291
  CREATE INDEX post_acknowledgments_post ON post_acknowledgments(post_id);
270
292
  `;
271
-
272
293
  const migrationV7 = `
273
294
  -- Opaque canonical IDs, with the handle as a separate mutable name.
274
295
  --
@@ -283,7 +304,6 @@ CREATE UNIQUE INDEX agents_handle ON agents(handle);
283
304
  -- Archived agents keep their records and stop being able to act.
284
305
  ALTER TABLE agents ADD COLUMN status TEXT NOT NULL DEFAULT 'active';
285
306
  `;
286
-
287
307
  const migrationV8 = `
288
308
  -- Topics: a controlled, reusable subject a record can be filed under,
289
309
  -- distinct from a free-text tag (§ Topic in types.ts). One canonical display
@@ -302,7 +322,6 @@ CREATE TABLE topic_aliases (
302
322
  ) STRICT;
303
323
  CREATE INDEX topic_aliases_topic ON topic_aliases(topic_id);
304
324
  `;
305
-
306
325
  const migrationV3 = `
307
326
  DROP TRIGGER IF EXISTS events_append_only_update;
308
327
  DROP TRIGGER IF EXISTS events_append_only_delete;
@@ -353,17 +372,20 @@ CREATE TABLE items_current (
353
372
  topic_ids_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(topic_ids_json)),
354
373
  visibility TEXT NOT NULL,
355
374
  status TEXT NOT NULL,
356
- recipient_agent_id TEXT,
375
+ recipient_kind TEXT NOT NULL DEFAULT 'agent',
376
+ recipient_id TEXT,
357
377
  recipient_display_name TEXT,
358
- owner_agent_id TEXT,
378
+ owner_kind TEXT,
379
+ owner_id TEXT,
359
380
  owner_display_name TEXT,
360
- assignee_agent_id TEXT,
381
+ assignee_kind TEXT,
382
+ assignee_id TEXT,
361
383
  assignee_display_name TEXT
362
384
  ) STRICT;
363
385
  CREATE INDEX items_current_kind_sequence ON items_current(kind, created_sequence DESC);
364
- CREATE INDEX items_current_recipient ON items_current(recipient_agent_id, created_sequence DESC);
365
- CREATE INDEX items_current_owner ON items_current(owner_agent_id, created_sequence DESC);
366
- CREATE INDEX items_current_assignee ON items_current(assignee_agent_id, created_sequence DESC);
386
+ CREATE INDEX items_current_recipient ON items_current(recipient_id, created_sequence DESC);
387
+ CREATE INDEX items_current_owner ON items_current(owner_id, created_sequence DESC);
388
+ CREATE INDEX items_current_assignee ON items_current(assignee_id, created_sequence DESC);
367
389
  CREATE INDEX items_current_status ON items_current(kind, status, created_sequence DESC);
368
390
 
369
391
  CREATE TRIGGER events_append_only_update
@@ -371,35 +393,7 @@ BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events are append-only'); END
371
393
  CREATE TRIGGER events_append_only_delete
372
394
  BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events are append-only'); END;
373
395
  `;
374
-
375
- const CONTEXT_BUDGET_BYTES = 24_576;
376
-
377
- function encodeCursor(kind: 'list' | 'items' | 'change', sequence: number): string {
378
- return Buffer.from(JSON.stringify({ v: 1, kind, sequence }), 'utf8').toString('base64url');
379
- }
380
-
381
- function decodeCursor(cursor: string, kind: 'list' | 'items' | 'change'): number {
382
- try {
383
- const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as {
384
- v?: unknown;
385
- kind?: unknown;
386
- sequence?: unknown;
387
- };
388
- if (
389
- value.v !== 1 ||
390
- value.kind !== kind ||
391
- typeof value.sequence !== 'number' ||
392
- !Number.isSafeInteger(value.sequence) ||
393
- value.sequence < 0
394
- ) {
395
- throw new Error('invalid cursor');
396
- }
397
- return value.sequence;
398
- } catch {
399
- throw new SynomemError('INVALID_INPUT', `Invalid ${kind} cursor.`);
400
- }
401
- }
402
-
396
+ const CONTEXT_BUDGET_BYTES = 24576;
403
397
  function itemSummaryFromRow(row: ItemRow): ItemSummary {
404
398
  return {
405
399
  id: row.item_id,
@@ -416,12 +410,13 @@ function itemSummaryFromRow(row: ItemRow): ItemSummary {
416
410
  topicIds: JSON.parse(row.topic_ids_json) as string[],
417
411
  visibility: row.visibility,
418
412
  status: row.status,
419
- ...(row.recipient_agent_id ? { recipientAgentId: row.recipient_agent_id } : {}),
420
- ...(row.owner_agent_id ? { ownerAgentId: row.owner_agent_id } : {}),
421
- ...(row.assignee_agent_id ? { assigneeAgentId: row.assignee_agent_id } : {}),
413
+ ...(row.recipient_id ? { recipient: { kind: row.recipient_kind, id: row.recipient_id } } : {}),
414
+ ...(row.owner_id ? { owner: { kind: row.owner_kind ?? 'agent', id: row.owner_id } } : {}),
415
+ ...(row.assignee_id
416
+ ? { assignee: { kind: row.assignee_kind ?? 'agent', id: row.assignee_id } }
417
+ : {}),
422
418
  };
423
419
  }
424
-
425
420
  /**
426
421
  * Resolves a due value to a comparable instant.
427
422
  *
@@ -438,14 +433,13 @@ function eventKind(event: SynomemEvent): RecordKind | undefined {
438
433
  if (event.type.startsWith('todo.')) return 'todo';
439
434
  return undefined;
440
435
  }
441
-
442
436
  function summaryFromRow(row: CurrentRow): KudosSummary {
443
437
  return {
444
438
  id: row.kudos_id,
445
439
  kind: 'kudos',
446
440
  createdAt: row.created_at,
447
441
  updatedAt: row.created_at,
448
- recipientAgentId: row.recipient_agent_id,
442
+ recipient: { kind: row.recipient_kind, id: row.recipient_id },
449
443
  recipientDisplayName: row.recipient_display_name,
450
444
  actor: {
451
445
  kind: row.actor_kind,
@@ -460,14 +454,79 @@ function summaryFromRow(row: CurrentRow): KudosSummary {
460
454
  revocationStatus: row.revocation_status,
461
455
  };
462
456
  }
463
-
464
457
  export interface StorageOptions {
458
+ actor?: ActorIdentity;
459
+ authority?: RecordAuthority;
465
460
  home: string;
466
461
  readOnly: boolean;
467
462
  config?: SynomemConfigOverrides;
468
463
  }
464
+ const databaseWriteTails = new Map<string, Promise<void>>();
469
465
 
470
466
  export class SynomemStorage implements SynomemRepository {
467
+ readonly notifications = this.makeNotifications();
468
+ private makeNotifications(): SqlNotificationRepository {
469
+ const workspaceId = () => this.config.workspaceId;
470
+ return new SqlNotificationRepository({
471
+ get workspaceId() {
472
+ return workspaceId();
473
+ },
474
+ query: async (sql, values) => this.prepare(sql).all(...(values as HpSQLInputValue[])),
475
+ execute: async (sql, values) => {
476
+ this.prepare(sql).run(...(values as HpSQLInputValue[]));
477
+ },
478
+ codec: () => this.cursorCodec!,
479
+ canRead: async (id, actor) => this.canActorReadItem(id, actor),
480
+ visibility: (actor) =>
481
+ recordVisibilityPredicate(
482
+ actor,
483
+ this.boundActor && this.boundActor.kind === actor.kind && this.boundActor.id === actor.id
484
+ ? this.authority
485
+ : resolveAuthority(),
486
+ false,
487
+ 'i.',
488
+ ),
489
+ });
490
+ }
491
+ readonly bookmarks = this.makeBookmarks();
492
+ private makeBookmarks(): SqlBookmarkRepository {
493
+ const workspaceId = () => this.config.workspaceId;
494
+ return new SqlBookmarkRepository({
495
+ get workspaceId() {
496
+ return workspaceId();
497
+ },
498
+ query: async (sql, values) => this.prepare(sql).all(...(values as HpSQLInputValue[])),
499
+ execute: async (sql, values) => {
500
+ this.prepare(sql).run(...(values as HpSQLInputValue[]));
501
+ },
502
+ codec: () => this.cursorCodec!,
503
+ canRead: async (id, actor) => this.canActorReadItem(id, actor),
504
+ visibility: (actor) =>
505
+ recordVisibilityPredicate(
506
+ actor,
507
+ this.boundActor && this.boundActor.kind === actor.kind && this.boundActor.id === actor.id
508
+ ? this.authority
509
+ : resolveAuthority(),
510
+ false,
511
+ 'i.',
512
+ ),
513
+ });
514
+ }
515
+ readonly participation = this.makeParticipation();
516
+ private makeParticipation(): SqlParticipationRepository {
517
+ const workspaceId = () => this.config.workspaceId;
518
+ return new SqlParticipationRepository({
519
+ get workspaceId() {
520
+ return workspaceId();
521
+ },
522
+ query: async (sql, values) => this.prepare(sql).all(...(values as HpSQLInputValue[])),
523
+ execute: async (sql, values) => {
524
+ this.prepare(sql).run(...(values as HpSQLInputValue[]));
525
+ },
526
+ codec: () => this.cursorCodec!,
527
+ });
528
+ }
529
+
471
530
  readonly home: string;
472
531
  readonly storageDirectory: string;
473
532
  readonly databasePath: string;
@@ -475,11 +534,15 @@ export class SynomemStorage implements SynomemRepository {
475
534
  readonly readOnly: boolean;
476
535
  config: SynomemConfig = defaultConfig;
477
536
  private database?: DatabaseSync;
537
+ private cursorCodec?: SignedCursorCodec;
538
+ private authority: RecordAuthority;
539
+ private readonly boundActor?: ActorIdentity;
478
540
  private readonly configOverrides?: SynomemConfigOverrides;
479
- private validatedEventSequence = 0;
480
- private transactionTail: Promise<void> = Promise.resolve();
481
-
541
+ private validatedEventSequence = 0n;
542
+ private readonly transactionState = new AsyncLocalStorage<boolean>();
482
543
  constructor(options: StorageOptions) {
544
+ this.authority = resolveAuthority(options.authority);
545
+ this.boundActor = options.actor;
483
546
  this.home = resolve(options.home);
484
547
  // The home is the storage directory; see configLocation in backend.ts.
485
548
  this.storageDirectory = this.home;
@@ -488,7 +551,9 @@ export class SynomemStorage implements SynomemRepository {
488
551
  this.readOnly = options.readOnly;
489
552
  this.configOverrides = options.config;
490
553
  }
491
-
554
+ setAuthority(authority: RecordAuthority): void {
555
+ this.authority = resolveAuthority(authority);
556
+ }
492
557
  init(): void {
493
558
  try {
494
559
  if (this.configOverrides?.backend?.kind === 'remote') {
@@ -507,12 +572,36 @@ export class SynomemStorage implements SynomemRepository {
507
572
  'The configured Synomem home cannot be a symbolic link.',
508
573
  );
509
574
  }
510
- if (!this.readOnly) ensureDirectory(this.storageDirectory);
511
575
  assertNoSymlinkEscape(this.home, this.storageDirectory);
576
+ if (existsSync(this.databasePath)) {
577
+ // The SQLite header stores PRAGMA user_version at byte 60. A read-only
578
+ // SQLite connection can still create/delete shared-memory sidecars in
579
+ // WAL mode, changing directory metadata before we refuse legacy data.
580
+ // Read only the header to keep the export-only guard truly inert.
581
+ const handle = openSync(this.databasePath, 'r');
582
+ const header = Buffer.alloc(100);
583
+ let size = 0;
584
+ try {
585
+ size = readSync(handle, header, 0, header.length, 0);
586
+ } finally {
587
+ closeSync(handle);
588
+ }
589
+ if (size < 100 || header.toString('ascii', 0, 16) !== 'SQLite format 3\0')
590
+ throw new SynomemError(
591
+ 'UNSUPPORTED_SCHEMA',
592
+ 'This database header is malformed; use raw recovery.',
593
+ );
594
+ const version = header.readUInt32BE(60);
595
+ if (version > 0 && version !== SUPPORTED_SCHEMA_VERSION)
596
+ throw new SynomemError(
597
+ 'UNSUPPORTED_SCHEMA',
598
+ 'This home is export-only. Preserve its raw events and create a fresh human-participation home.',
599
+ );
600
+ }
512
601
  if (!this.readOnly) {
602
+ ensureDirectory(this.storageDirectory);
513
603
  chmodSync(this.storageDirectory, 0o700);
514
604
  }
515
-
516
605
  const fileConfig = existsSync(this.configPath) ? readJsonFile(this.configPath) : undefined;
517
606
  const initialConfig = fileConfig ?? { ...defaultConfig, workspaceId: ulid() };
518
607
  this.config = mergeConfig(initialConfig, this.configOverrides);
@@ -537,17 +626,29 @@ export class SynomemStorage implements SynomemRepository {
537
626
  !this.readOnly &&
538
627
  typeof fileConfig === 'object' &&
539
628
  fileConfig !== null &&
540
- (fileConfig as { schemaVersion?: unknown }).schemaVersion === 2
629
+ (
630
+ fileConfig as {
631
+ schemaVersion?: unknown;
632
+ }
633
+ ).schemaVersion === 2
541
634
  ) {
542
635
  const migrated = mergeConfig(fileConfig, undefined, {});
543
636
  atomicWriteFile(this.configPath, `${JSON.stringify(migrated, null, 2)}\n`);
544
637
  }
545
-
546
638
  this.database = new DatabaseSync(this.databasePath, {
547
639
  readOnly: this.readOnly,
548
640
  enableForeignKeyConstraints: true,
549
641
  });
550
642
  this.database.exec('PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON;');
643
+ const keyPath = join(this.home, 'cursor-key');
644
+ assertNoSymlinkEscape(this.home, keyPath);
645
+ if (!existsSync(keyPath)) {
646
+ if (this.readOnly)
647
+ throw new SynomemError('CONFIG_INVALID', 'The home cursor key is missing.');
648
+ atomicWriteFile(keyPath, randomBytes(32).toString('base64url'), 0o600);
649
+ }
650
+ const key = Buffer.from(readFileSync(keyPath, 'utf8'), 'base64url');
651
+ this.cursorCodec = new SignedCursorCodec({ keyId: 'home', key });
551
652
  if (!this.readOnly) {
552
653
  this.database.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;');
553
654
  this.migrate();
@@ -564,12 +665,51 @@ export class SynomemStorage implements SynomemRepository {
564
665
  throw asSynomemError(error);
565
666
  }
566
667
  }
567
-
668
+ private prepare(sql: string): StatementSync {
669
+ const statement = this.db().prepare(sql);
670
+ statement.setReadBigInts(true);
671
+ return statement;
672
+ }
673
+ private binding(purpose: string, viewer: ActorIdentity, filters: unknown): CursorBinding {
674
+ return {
675
+ purpose,
676
+ workspaceId: this.config.workspaceId,
677
+ actor: { kind: viewer.kind, id: viewer.id },
678
+ filter: cursorFilter(filters),
679
+ };
680
+ }
681
+ private encodeCursor(
682
+ binding: CursorBinding,
683
+ sequence: bigint,
684
+ watermark = this.maxEventSequence(),
685
+ ): string {
686
+ return this.cursorCodec!.encode(binding, {
687
+ sequence: exactSequence(sequence),
688
+ watermark: exactSequence(watermark),
689
+ });
690
+ }
691
+ private decodeCursor(
692
+ token: string,
693
+ binding: CursorBinding,
694
+ ): { sequence: bigint; watermark: bigint } {
695
+ const value = this.cursorCodec!.decode(token, binding, exactSequence(this.maxEventSequence()));
696
+ return { sequence: BigInt(value.sequence), watermark: BigInt(value.watermark) };
697
+ }
568
698
  private migrate(): void {
569
699
  const db = this.db();
570
700
  const version = Number(
571
- (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
701
+ (
702
+ db.prepare('PRAGMA user_version').get() as {
703
+ user_version: number;
704
+ }
705
+ ).user_version,
572
706
  );
707
+ if (version > 0 && version < 9 && db.prepare('SELECT 1 FROM events LIMIT 1').get()) {
708
+ throw new SynomemError(
709
+ 'UNSUPPORTED_SCHEMA',
710
+ 'This populated legacy home is export-only. Export its raw events and explicitly create a new human-participation home.',
711
+ );
712
+ }
573
713
  if (version > SUPPORTED_SCHEMA_VERSION) {
574
714
  throw new SynomemError(
575
715
  'UNSUPPORTED_SCHEMA',
@@ -586,7 +726,11 @@ export class SynomemStorage implements SynomemRepository {
586
726
  });
587
727
  }
588
728
  const currentVersion = Number(
589
- (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
729
+ (
730
+ db.prepare('PRAGMA user_version').get() as {
731
+ user_version: number;
732
+ }
733
+ ).user_version,
590
734
  );
591
735
  if (currentVersion === 1) {
592
736
  this.transactionSync(() => {
@@ -599,7 +743,11 @@ export class SynomemStorage implements SynomemRepository {
599
743
  });
600
744
  }
601
745
  const afterV2 = Number(
602
- (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
746
+ (
747
+ db.prepare('PRAGMA user_version').get() as {
748
+ user_version: number;
749
+ }
750
+ ).user_version,
603
751
  );
604
752
  if (afterV2 === 2) {
605
753
  this.transactionSync(() => {
@@ -614,7 +762,11 @@ export class SynomemStorage implements SynomemRepository {
614
762
  });
615
763
  }
616
764
  const afterV3 = Number(
617
- (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
765
+ (
766
+ db.prepare('PRAGMA user_version').get() as {
767
+ user_version: number;
768
+ }
769
+ ).user_version,
618
770
  );
619
771
  if (afterV3 === 3) {
620
772
  this.transactionSync(() => {
@@ -627,7 +779,11 @@ export class SynomemStorage implements SynomemRepository {
627
779
  });
628
780
  }
629
781
  const afterV4 = Number(
630
- (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
782
+ (
783
+ db.prepare('PRAGMA user_version').get() as {
784
+ user_version: number;
785
+ }
786
+ ).user_version,
631
787
  );
632
788
  if (afterV4 === 4) {
633
789
  this.transactionSync(() => {
@@ -639,7 +795,11 @@ export class SynomemStorage implements SynomemRepository {
639
795
  });
640
796
  }
641
797
  const afterV5 = Number(
642
- (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
798
+ (
799
+ db.prepare('PRAGMA user_version').get() as {
800
+ user_version: number;
801
+ }
802
+ ).user_version,
643
803
  );
644
804
  if (afterV5 === 5) {
645
805
  this.transactionSync(() => {
@@ -651,7 +811,11 @@ export class SynomemStorage implements SynomemRepository {
651
811
  });
652
812
  }
653
813
  const afterV6 = Number(
654
- (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
814
+ (
815
+ db.prepare('PRAGMA user_version').get() as {
816
+ user_version: number;
817
+ }
818
+ ).user_version,
655
819
  );
656
820
  if (afterV6 === 6) {
657
821
  this.transactionSync(() => {
@@ -663,7 +827,11 @@ export class SynomemStorage implements SynomemRepository {
663
827
  });
664
828
  }
665
829
  const afterV7 = Number(
666
- (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
830
+ (
831
+ db.prepare('PRAGMA user_version').get() as {
832
+ user_version: number;
833
+ }
834
+ ).user_version,
667
835
  );
668
836
  if (afterV7 === 7) {
669
837
  this.transactionSync(() => {
@@ -674,7 +842,9 @@ export class SynomemStorage implements SynomemRepository {
674
842
  // column SQLite has no "IF NOT EXISTS" form for.
675
843
  for (const table of ['kudos_current', 'items_current']) {
676
844
  const hasColumn = (
677
- db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>
845
+ db.prepare(`PRAGMA table_info(${table})`).all() as Array<{
846
+ name: string;
847
+ }>
678
848
  ).some((column) => column.name === 'topic_ids_json');
679
849
  if (!hasColumn) {
680
850
  db.exec(`ALTER TABLE ${table} ADD COLUMN topic_ids_json TEXT NOT NULL DEFAULT '[]'`);
@@ -686,11 +856,35 @@ export class SynomemStorage implements SynomemRepository {
686
856
  db.exec('PRAGMA user_version = 8');
687
857
  });
688
858
  }
859
+ if (
860
+ Number((db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version) ===
861
+ 8
862
+ ) {
863
+ this.transactionSync(() => {
864
+ db.exec(`ALTER TABLE items_current ADD COLUMN workspace_id TEXT;
865
+ ${notificationTables}
866
+ ${bookmarkTables}
867
+ ${participationTables}
868
+ 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;
869
+ CREATE INDEX mutation_receipts_retention ON mutation_receipts(created_at) WHERE result_json IS NOT NULL;
870
+ 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;
871
+ CREATE INDEX items_current_owner_actor ON items_current(owner_kind,owner_id,created_sequence DESC);
872
+ CREATE INDEX items_current_recipient_actor ON items_current(recipient_kind,recipient_id,created_sequence DESC);
873
+ CREATE INDEX items_current_assignee_actor ON items_current(assignee_kind,assignee_id,created_sequence DESC);`);
874
+ db.prepare('INSERT INTO schema_migrations(version,applied_at) VALUES(9,?)').run(
875
+ new Date().toISOString(),
876
+ );
877
+ db.exec('PRAGMA user_version=9');
878
+ });
879
+ }
689
880
  }
690
-
691
881
  private assertSchemaSupported(): void {
692
882
  const version = Number(
693
- (this.db().prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
883
+ (
884
+ this.prepare('PRAGMA user_version').get() as {
885
+ user_version: number;
886
+ }
887
+ ).user_version,
694
888
  );
695
889
  if (version !== SUPPORTED_SCHEMA_VERSION) {
696
890
  if (version >= 1 && version < SUPPORTED_SCHEMA_VERSION) {
@@ -705,18 +899,16 @@ export class SynomemStorage implements SynomemRepository {
705
899
  );
706
900
  }
707
901
  }
708
-
709
902
  db(): DatabaseSync {
710
903
  if (!this.database)
711
904
  throw new SynomemError('INTERNAL_ERROR', 'SynomemClient.init() has not completed.');
712
905
  return this.database;
713
906
  }
714
-
715
907
  assertWritable(): void {
716
908
  if (this.readOnly) throw new SynomemError('READ_ONLY', 'This Synomem client is read-only.');
717
909
  }
718
-
719
910
  transaction<T>(operation: () => T | Promise<T>): Promise<T> {
911
+ if (this.transactionState.getStore()) return Promise.resolve().then(operation);
720
912
  const execute = async (): Promise<T> => {
721
913
  this.assertWritable();
722
914
  const db = this.db();
@@ -724,7 +916,7 @@ export class SynomemStorage implements SynomemRepository {
724
916
  try {
725
917
  db.exec('BEGIN IMMEDIATE');
726
918
  began = true;
727
- const result = await operation();
919
+ const result = await this.transactionState.run(true, operation);
728
920
  db.exec('COMMIT');
729
921
  began = false;
730
922
  this.restrictDatabaseFiles();
@@ -740,14 +932,19 @@ export class SynomemStorage implements SynomemRepository {
740
932
  throw asSynomemError(error);
741
933
  }
742
934
  };
743
- const result = this.transactionTail.then(execute, execute);
744
- this.transactionTail = result.then(
935
+ const prior = databaseWriteTails.get(this.databasePath) ?? Promise.resolve();
936
+ const result = prior.then(execute, execute);
937
+ const tail = result.then(
745
938
  () => undefined,
746
939
  () => undefined,
747
940
  );
941
+ databaseWriteTails.set(this.databasePath, tail);
942
+ void tail.then(() => {
943
+ if (databaseWriteTails.get(this.databasePath) === tail)
944
+ databaseWriteTails.delete(this.databasePath);
945
+ });
748
946
  return result;
749
947
  }
750
-
751
948
  transactionSync<T>(operation: () => T): T {
752
949
  this.assertWritable();
753
950
  const db = this.db();
@@ -771,8 +968,12 @@ export class SynomemStorage implements SynomemRepository {
771
968
  throw asSynomemError(error);
772
969
  }
773
970
  }
971
+ async insertEvent(event: SynomemEvent): Promise<void> {
972
+ if (!this.transactionState.getStore()) {
973
+ await this.transaction(() => this.insertEvent(event));
974
+ return;
975
+ }
774
976
 
775
- insertEvent(event: SynomemEvent): void {
776
977
  const parsed = eventSchema.parse(event);
777
978
  const recipientAgentId =
778
979
  parsed.type === 'kudos.given' ||
@@ -780,7 +981,7 @@ export class SynomemStorage implements SynomemRepository {
780
981
  parsed.type === 'memo.sent' ||
781
982
  parsed.type === 'memo.read' ||
782
983
  parsed.type === 'memo.archived'
783
- ? parsed.recipientAgentId
984
+ ? parsed.recipient?.id
784
985
  : null;
785
986
  const kudosId =
786
987
  parsed.type === 'kudos.acknowledged' || parsed.type === 'kudos.revoked'
@@ -789,84 +990,84 @@ export class SynomemStorage implements SynomemRepository {
789
990
  const visibility = 'visibility' in parsed ? parsed.visibility : null;
790
991
  const idempotencyKey = parsed.idempotencyKey ?? null;
791
992
  const kind = eventKind(parsed) ?? null;
792
- const sequence = Number(
793
- (
794
- this.db().prepare('SELECT COALESCE(MAX(sequence), 0) + 1 AS next FROM events').get() as {
795
- next: number;
796
- }
797
- ).next,
798
- );
799
- this.db()
800
- .prepare(
801
- `INSERT INTO events(
993
+ const sequence = (
994
+ this.prepare('SELECT COALESCE(MAX(sequence),0)+1 AS next FROM events').get() as {
995
+ next: bigint;
996
+ }
997
+ ).next;
998
+
999
+ this.prepare(
1000
+ `INSERT INTO events(
802
1001
  id, schema_version, type, created_at, actor_kind, actor_id,
803
- recipient_agent_id, kudos_id, visibility, idempotency_key, payload, sequence,
1002
+ recipient_id, kudos_id, visibility, idempotency_key, payload, sequence,
804
1003
  workspace_id, aggregate_id, aggregate_version, item_kind
805
1004
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
806
- )
807
- .run(
808
- parsed.id,
809
- parsed.schemaVersion,
810
- parsed.type,
811
- parsed.createdAt,
812
- parsed.actor.kind,
813
- parsed.actor.id,
814
- recipientAgentId,
815
- kudosId,
816
- visibility,
817
- idempotencyKey,
818
- JSON.stringify(parsed),
819
- sequence,
820
- parsed.workspaceId,
821
- parsed.aggregateId,
822
- parsed.aggregateVersion,
823
- kind,
824
- );
1005
+ ).run(
1006
+ parsed.id,
1007
+ parsed.schemaVersion,
1008
+ parsed.type,
1009
+ parsed.createdAt,
1010
+ parsed.actor.kind,
1011
+ parsed.actor.id,
1012
+ recipientAgentId,
1013
+ kudosId,
1014
+ visibility,
1015
+ idempotencyKey,
1016
+ JSON.stringify(parsed),
1017
+ sequence,
1018
+ parsed.workspaceId,
1019
+ parsed.aggregateId,
1020
+ parsed.aggregateVersion,
1021
+ kind,
1022
+ );
825
1023
  this.applyEventToCurrent(parsed, sequence);
826
1024
  this.applyEventToItems(parsed, sequence);
1025
+ this.prepare('UPDATE items_current SET workspace_id=? WHERE item_id=?').run(
1026
+ parsed.workspaceId,
1027
+ parsed.aggregateId,
1028
+ );
1029
+ for (const command of participationStatements(parsed, sequence, this.config.workspaceId))
1030
+ this.prepare(command.sql).run(...(command.values as HpSQLInputValue[]));
1031
+ await this.notifications.apply(parsed, sequence);
827
1032
  }
828
-
829
- private applyEventToCurrent(event: SynomemEvent, sequence: number): void {
1033
+ private applyEventToCurrent(event: SynomemEvent, sequence: bigint): void {
830
1034
  if (event.type === 'kudos.given') {
831
- this.db()
832
- .prepare(
833
- `INSERT INTO kudos_current(
834
- kudos_id, given_sequence, created_at, recipient_agent_id, recipient_display_name,
1035
+ this.prepare(
1036
+ `INSERT INTO kudos_current(
1037
+ kudos_id, given_sequence, created_at, recipient_id, recipient_display_name,
835
1038
  actor_kind, actor_id, actor_display_name, title, tags_json, topic_ids_json, visibility,
836
1039
  status, revocation_status, updated_sequence
837
1040
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'unacknowledged', 'active', ?)`,
838
- )
839
- .run(
840
- event.id,
841
- sequence,
842
- event.createdAt,
843
- event.recipientAgentId,
844
- event.recipientDisplayName,
845
- event.actor.kind,
846
- event.actor.id,
847
- event.actor.displayName ?? null,
848
- event.title,
849
- JSON.stringify(event.tags ?? []),
850
- JSON.stringify(event.topicIds ?? []),
851
- event.visibility,
852
- sequence,
853
- );
1041
+ ).run(
1042
+ event.id,
1043
+ sequence,
1044
+ event.createdAt,
1045
+ event.recipient?.id,
1046
+ event.recipientDisplayName,
1047
+ event.actor.kind,
1048
+ event.actor.id,
1049
+ event.actor.displayName ?? null,
1050
+ event.title,
1051
+ JSON.stringify(event.tags ?? []),
1052
+ JSON.stringify(event.topicIds ?? []),
1053
+ event.visibility,
1054
+ sequence,
1055
+ );
1056
+ this.prepare('UPDATE kudos_current SET recipient_kind=? WHERE kudos_id=?').run(
1057
+ event.recipient.kind,
1058
+ event.id,
1059
+ );
854
1060
  } else if (event.type === 'kudos.acknowledged') {
855
- this.db()
856
- .prepare(
857
- "UPDATE kudos_current SET status = 'acknowledged', updated_sequence = ? WHERE kudos_id = ?",
858
- )
859
- .run(sequence, event.kudosId);
1061
+ this.prepare(
1062
+ "UPDATE kudos_current SET status = 'acknowledged', updated_sequence = ? WHERE kudos_id = ?",
1063
+ ).run(sequence, event.kudosId);
860
1064
  } else if (event.type === 'kudos.revoked') {
861
- this.db()
862
- .prepare(
863
- "UPDATE kudos_current SET revocation_status = 'revoked', updated_sequence = ? WHERE kudos_id = ?",
864
- )
865
- .run(sequence, event.kudosId);
1065
+ this.prepare(
1066
+ "UPDATE kudos_current SET revocation_status = 'revoked', updated_sequence = ? WHERE kudos_id = ?",
1067
+ ).run(sequence, event.kudosId);
866
1068
  }
867
1069
  }
868
-
869
- private applyEventToItems(event: SynomemEvent, sequence: number): void {
1070
+ private applyEventToItems(event: SynomemEvent, sequence: bigint): void {
870
1071
  const insert = (values: {
871
1072
  kind: RecordKind;
872
1073
  title: string;
@@ -874,55 +1075,50 @@ export class SynomemStorage implements SynomemRepository {
874
1075
  topicIds?: string[];
875
1076
  visibility: ItemSummary['visibility'];
876
1077
  status: string;
877
- recipientAgentId?: string;
1078
+ recipient?: ActorRef;
878
1079
  recipientDisplayName?: string;
879
- ownerAgentId?: string;
1080
+ owner?: ActorRef;
880
1081
  ownerDisplayName?: string;
881
- assigneeAgentId?: string;
1082
+ assignee?: ActorRef;
882
1083
  assigneeDisplayName?: string;
883
1084
  dueAt?: string;
884
1085
  }): void => {
885
- this.db()
886
- .prepare(
887
- `INSERT INTO items_current(
1086
+ this.prepare(
1087
+ `INSERT INTO items_current(
888
1088
  item_id, kind, created_sequence, updated_sequence, created_at, updated_at,
889
1089
  actor_kind, actor_id, actor_display_name, title, tags_json, topic_ids_json, visibility, status,
890
- recipient_agent_id, recipient_display_name, owner_agent_id, owner_display_name,
891
- assignee_agent_id, assignee_display_name, due_at
1090
+ recipient_id, recipient_display_name, owner_id, owner_display_name,
1091
+ assignee_id, assignee_display_name, due_at
892
1092
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
893
- )
894
- .run(
895
- event.aggregateId,
896
- values.kind,
897
- sequence,
898
- sequence,
899
- event.createdAt,
900
- event.createdAt,
901
- event.actor.kind,
902
- event.actor.id,
903
- event.actor.displayName ?? null,
904
- values.title,
905
- JSON.stringify(values.tags ?? []),
906
- JSON.stringify(values.topicIds ?? []),
907
- values.visibility,
908
- values.status,
909
- values.recipientAgentId ?? null,
910
- values.recipientDisplayName ?? null,
911
- values.ownerAgentId ?? null,
912
- values.ownerDisplayName ?? null,
913
- values.assigneeAgentId ?? null,
914
- values.assigneeDisplayName ?? null,
915
- values.dueAt ?? null,
916
- );
1093
+ ).run(
1094
+ event.aggregateId,
1095
+ values.kind,
1096
+ sequence,
1097
+ sequence,
1098
+ event.createdAt,
1099
+ event.createdAt,
1100
+ event.actor.kind,
1101
+ event.actor.id,
1102
+ event.actor.displayName ?? null,
1103
+ values.title,
1104
+ JSON.stringify(values.tags ?? []),
1105
+ JSON.stringify(values.topicIds ?? []),
1106
+ values.visibility,
1107
+ values.status,
1108
+ values.recipient?.id ?? null,
1109
+ values.recipientDisplayName ?? null,
1110
+ values.owner?.id ?? null,
1111
+ values.ownerDisplayName ?? null,
1112
+ values.assignee?.id ?? null,
1113
+ values.assigneeDisplayName ?? null,
1114
+ values.dueAt ?? null,
1115
+ );
917
1116
  };
918
1117
  const updateStatus = (status: string): void => {
919
- this.db()
920
- .prepare(
921
- 'UPDATE items_current SET status = ?, updated_sequence = ?, updated_at = ? WHERE item_id = ?',
922
- )
923
- .run(status, sequence, event.createdAt, event.aggregateId);
1118
+ this.prepare(
1119
+ 'UPDATE items_current SET status = ?, updated_sequence = ?, updated_at = ? WHERE item_id = ?',
1120
+ ).run(status, sequence, event.createdAt, event.aggregateId);
924
1121
  };
925
-
926
1122
  if (event.type === 'kudos.given') {
927
1123
  insert({
928
1124
  kind: 'kudos',
@@ -931,7 +1127,7 @@ export class SynomemStorage implements SynomemRepository {
931
1127
  topicIds: event.topicIds,
932
1128
  visibility: event.visibility,
933
1129
  status: 'unacknowledged',
934
- recipientAgentId: event.recipientAgentId,
1130
+ recipient: event.recipient,
935
1131
  recipientDisplayName: event.recipientDisplayName,
936
1132
  });
937
1133
  } else if (event.type === 'kudos.acknowledged') updateStatus('acknowledged');
@@ -944,7 +1140,7 @@ export class SynomemStorage implements SynomemRepository {
944
1140
  topicIds: event.topicIds,
945
1141
  visibility: event.visibility,
946
1142
  status: 'unread',
947
- recipientAgentId: event.recipientAgentId,
1143
+ recipient: event.recipient,
948
1144
  recipientDisplayName: event.recipientDisplayName,
949
1145
  });
950
1146
  } else if (event.type === 'memo.read') updateStatus('read');
@@ -957,7 +1153,7 @@ export class SynomemStorage implements SynomemRepository {
957
1153
  topicIds: event.topicIds,
958
1154
  visibility: event.visibility,
959
1155
  status: 'active',
960
- ownerAgentId: event.ownerAgentId,
1156
+ owner: event.owner,
961
1157
  ownerDisplayName: event.ownerDisplayName,
962
1158
  });
963
1159
  } else if (event.type === 'post.created') {
@@ -976,62 +1172,54 @@ export class SynomemStorage implements SynomemRepository {
976
1172
  status: 'active',
977
1173
  });
978
1174
  } else if (event.type === 'post.edited') {
979
- this.db()
980
- .prepare(
981
- `UPDATE items_current SET title = ?, tags_json = ?, topic_ids_json = ?,
1175
+ this.prepare(
1176
+ `UPDATE items_current SET title = ?, tags_json = ?, topic_ids_json = ?,
982
1177
  updated_sequence = ?, updated_at = ? WHERE item_id = ?`,
983
- )
984
- .run(
985
- event.title,
986
- JSON.stringify(event.tags ?? []),
987
- JSON.stringify(event.topicIds ?? []),
988
- sequence,
989
- event.createdAt,
990
- event.postId,
991
- );
1178
+ ).run(
1179
+ event.title,
1180
+ JSON.stringify(event.tags ?? []),
1181
+ JSON.stringify(event.topicIds ?? []),
1182
+ sequence,
1183
+ event.createdAt,
1184
+ event.postId,
1185
+ );
992
1186
  } else if (event.type === 'post.archived') {
993
1187
  updateStatus('archived');
994
1188
  } else if (event.type === 'post.acknowledged') {
995
1189
  // One row per actor per post: acknowledging twice is the same statement,
996
1190
  // not a second one.
997
- this.db()
998
- .prepare(
999
- `INSERT INTO post_acknowledgments
1191
+ this.prepare(
1192
+ `INSERT INTO post_acknowledgments
1000
1193
  (post_id, actor_kind, actor_id, actor_display_name, note, acknowledged_at)
1001
1194
  VALUES (?, ?, ?, ?, ?, ?)
1002
1195
  ON CONFLICT(post_id, actor_kind, actor_id) DO UPDATE SET
1003
1196
  note = excluded.note,
1004
1197
  acknowledged_at = excluded.acknowledged_at`,
1005
- )
1006
- .run(
1007
- event.postId,
1008
- event.actor.kind,
1009
- event.actor.id,
1010
- event.actor.displayName ?? null,
1011
- event.note ?? null,
1012
- event.createdAt,
1013
- );
1198
+ ).run(
1199
+ event.postId,
1200
+ event.actor.kind,
1201
+ event.actor.id,
1202
+ event.actor.displayName ?? null,
1203
+ event.note ?? null,
1204
+ event.createdAt,
1205
+ );
1014
1206
  } else if (event.type === 'post.acknowledgment.withdrawn') {
1015
- this.db()
1016
- .prepare(
1017
- 'DELETE FROM post_acknowledgments WHERE post_id = ? AND actor_kind = ? AND actor_id = ?',
1018
- )
1019
- .run(event.postId, event.actor.kind, event.actor.id);
1207
+ this.prepare(
1208
+ 'DELETE FROM post_acknowledgments WHERE post_id = ? AND actor_kind = ? AND actor_id = ?',
1209
+ ).run(event.postId, event.actor.kind, event.actor.id);
1020
1210
  } else if (event.type === 'note.revised') {
1021
- this.db()
1022
- .prepare(
1023
- `UPDATE items_current SET title = ?, tags_json = ?, topic_ids_json = ?, visibility = ?,
1211
+ this.prepare(
1212
+ `UPDATE items_current SET title = ?, tags_json = ?, topic_ids_json = ?, visibility = ?,
1024
1213
  updated_sequence = ?, updated_at = ? WHERE item_id = ?`,
1025
- )
1026
- .run(
1027
- event.title,
1028
- JSON.stringify(event.tags ?? []),
1029
- JSON.stringify(event.topicIds ?? []),
1030
- event.visibility,
1031
- sequence,
1032
- event.createdAt,
1033
- event.aggregateId,
1034
- );
1214
+ ).run(
1215
+ event.title,
1216
+ JSON.stringify(event.tags ?? []),
1217
+ JSON.stringify(event.topicIds ?? []),
1218
+ event.visibility,
1219
+ sequence,
1220
+ event.createdAt,
1221
+ event.aggregateId,
1222
+ );
1035
1223
  } else if (event.type === 'note.archived') updateStatus('archived');
1036
1224
  else if (event.type === 'task.created') {
1037
1225
  insert({
@@ -1041,30 +1229,29 @@ export class SynomemStorage implements SynomemRepository {
1041
1229
  topicIds: event.topicIds,
1042
1230
  visibility: event.visibility,
1043
1231
  status: event.requiresAcceptance ? 'assigned' : 'open',
1044
- assigneeAgentId: event.assigneeAgentId,
1232
+ assignee: event.assignee,
1045
1233
  assigneeDisplayName: event.assigneeDisplayName,
1046
1234
  ...((due) => (due ? { dueAt: due } : {}))(dueInstant(event.due)),
1047
1235
  });
1048
1236
  } else if (event.type === 'task.updated') {
1049
- this.db()
1050
- .prepare(
1051
- `UPDATE items_current SET title = ?, tags_json = ?, topic_ids_json = ?, visibility = ?, due_at = ?,
1237
+ this.prepare(
1238
+ `UPDATE items_current SET title = ?, tags_json = ?, topic_ids_json = ?, visibility = ?, due_at = ?,
1052
1239
  updated_sequence = ?, updated_at = ? WHERE item_id = ?`,
1053
- )
1054
- .run(
1055
- event.title,
1056
- JSON.stringify(event.tags ?? []),
1057
- JSON.stringify(event.topicIds ?? []),
1058
- event.visibility,
1059
- dueInstant(event.due) ?? null,
1060
- sequence,
1061
- event.createdAt,
1062
- event.aggregateId,
1063
- );
1240
+ ).run(
1241
+ event.title,
1242
+ JSON.stringify(event.tags ?? []),
1243
+ JSON.stringify(event.topicIds ?? []),
1244
+ event.visibility,
1245
+ dueInstant(event.due) ?? null,
1246
+ sequence,
1247
+ event.createdAt,
1248
+ event.aggregateId,
1249
+ );
1064
1250
  } else if (event.type === 'task.accepted') updateStatus('open');
1065
1251
  else if (event.type === 'task.rejected') updateStatus('rejected');
1066
1252
  else if (event.type === 'task.completed') updateStatus('completed');
1067
1253
  else if (event.type === 'task.reopened') updateStatus('open');
1254
+ else if (event.type === 'task.decision_overridden') updateStatus(event.nextStatus);
1068
1255
  else if (event.type === 'task.canceled') updateStatus('canceled');
1069
1256
  else if (event.type === 'todo.created') {
1070
1257
  // A Todo's owner IS its author, and it is always private. Recording the
@@ -1077,50 +1264,91 @@ export class SynomemStorage implements SynomemRepository {
1077
1264
  topicIds: event.topicIds,
1078
1265
  visibility: 'private',
1079
1266
  status: 'open',
1080
- ownerAgentId: event.actor.id,
1267
+ owner: event.owner,
1081
1268
  ...(event.actor.displayName ? { ownerDisplayName: event.actor.displayName } : {}),
1082
1269
  ...((due) => (due ? { dueAt: due } : {}))(dueInstant(event.due)),
1083
1270
  });
1084
1271
  } else if (event.type === 'todo.updated') {
1085
- this.db()
1086
- .prepare(
1087
- `UPDATE items_current SET title = ?, tags_json = ?, topic_ids_json = ?, due_at = ?,
1272
+ this.prepare(
1273
+ `UPDATE items_current SET title = ?, tags_json = ?, topic_ids_json = ?, due_at = ?,
1088
1274
  updated_sequence = ?, updated_at = ? WHERE item_id = ?`,
1089
- )
1090
- .run(
1091
- event.title,
1092
- JSON.stringify(event.tags ?? []),
1093
- JSON.stringify(event.topicIds ?? []),
1094
- dueInstant(event.due) ?? null,
1095
- sequence,
1096
- event.createdAt,
1097
- event.aggregateId,
1098
- );
1275
+ ).run(
1276
+ event.title,
1277
+ JSON.stringify(event.tags ?? []),
1278
+ JSON.stringify(event.topicIds ?? []),
1279
+ dueInstant(event.due) ?? null,
1280
+ sequence,
1281
+ event.createdAt,
1282
+ event.aggregateId,
1283
+ );
1099
1284
  } else if (event.type === 'todo.completed') updateStatus('completed');
1100
1285
  else if (event.type === 'todo.reopened') updateStatus('open');
1101
1286
  else if (event.type === 'todo.canceled') updateStatus('canceled');
1102
1287
  else if (event.type === 'todo.archived') updateStatus('archived');
1288
+ if ('recipient' in event) {
1289
+ this.prepare('UPDATE items_current SET recipient_kind=? WHERE item_id=?').run(
1290
+ event.recipient.kind,
1291
+ event.aggregateId,
1292
+ );
1293
+ }
1294
+ if ('owner' in event) {
1295
+ this.prepare('UPDATE items_current SET owner_kind=?,owner_id=? WHERE item_id=?').run(
1296
+ event.owner.kind,
1297
+ event.owner.id,
1298
+ event.aggregateId,
1299
+ );
1300
+ }
1301
+ if ('assignee' in event) {
1302
+ this.prepare('UPDATE items_current SET assignee_kind=? WHERE item_id=?').run(
1303
+ event.assignee.kind,
1304
+ event.aggregateId,
1305
+ );
1306
+ }
1103
1307
  }
1104
-
1105
1308
  rebuildItemsCurrentIndex(): void {
1106
- this.db().prepare('DELETE FROM items_current').run();
1107
- const rows = this.db()
1108
- .prepare('SELECT id, payload, sequence FROM events ORDER BY sequence ASC')
1109
- .all() as unknown as Array<EventRow & { sequence: number }>;
1309
+ this.prepare('DELETE FROM items_current').run();
1310
+ const rows = this.prepare(
1311
+ 'SELECT id, payload, sequence FROM events ORDER BY sequence ASC',
1312
+ ).all() as unknown as Array<
1313
+ EventRow & {
1314
+ sequence: bigint;
1315
+ }
1316
+ >;
1110
1317
  for (const row of rows) {
1111
1318
  try {
1112
- this.applyEventToItems(this.parseEvent(row), row.sequence);
1319
+ const event = this.parseEvent(row);
1320
+ this.applyEventToItems(event, row.sequence);
1321
+ this.prepare('UPDATE items_current SET workspace_id=? WHERE item_id=?').run(
1322
+ event.workspaceId,
1323
+ event.aggregateId,
1324
+ );
1113
1325
  } catch (error) {
1114
1326
  if (!(error instanceof SynomemError)) throw error;
1115
1327
  }
1116
1328
  }
1117
1329
  }
1118
-
1330
+ rebuildParticipationCurrentIndex(): void {
1331
+ for (const table of ['thread_entries', 'reactions_current', 'replies_current'])
1332
+ this.prepare(`DELETE FROM ${table} WHERE workspace_id=?`).run(this.config.workspaceId);
1333
+ for (const row of this.rawEventRows()) {
1334
+ const event = this.parseEvent(row);
1335
+ const sequence = (
1336
+ this.prepare('SELECT sequence FROM events WHERE id=?').get(event.id) as { sequence: bigint }
1337
+ ).sequence;
1338
+ for (const command of participationStatements(event, sequence, this.config.workspaceId))
1339
+ if (!command.sql.includes('thread_members'))
1340
+ this.prepare(command.sql).run(...(command.values as HpSQLInputValue[]));
1341
+ }
1342
+ }
1119
1343
  rebuildKudosCurrentIndex(): void {
1120
- this.db().prepare('DELETE FROM kudos_current').run();
1121
- const rows = this.db()
1122
- .prepare('SELECT id, payload, sequence FROM events ORDER BY sequence ASC')
1123
- .all() as unknown as Array<EventRow & { sequence: number }>;
1344
+ this.prepare('DELETE FROM kudos_current').run();
1345
+ const rows = this.prepare(
1346
+ 'SELECT id, payload, sequence FROM events ORDER BY sequence ASC',
1347
+ ).all() as unknown as Array<
1348
+ EventRow & {
1349
+ sequence: bigint;
1350
+ }
1351
+ >;
1124
1352
  for (const row of rows) {
1125
1353
  try {
1126
1354
  this.applyEventToCurrent(this.parseEvent(row), row.sequence);
@@ -1129,31 +1357,38 @@ export class SynomemStorage implements SynomemRepository {
1129
1357
  }
1130
1358
  }
1131
1359
  }
1132
-
1133
1360
  nextAggregateVersion(aggregateId: string): number {
1134
1361
  return Number(
1135
1362
  (
1136
- this.db()
1137
- .prepare(
1138
- 'SELECT COALESCE(MAX(aggregate_version), 0) + 1 AS next FROM events WHERE workspace_id = ? AND aggregate_id = ?',
1139
- )
1140
- .get(this.config.workspaceId, aggregateId) as { next: number }
1363
+ this.prepare(
1364
+ 'SELECT COALESCE(MAX(aggregate_version), 0) + 1 AS next FROM events WHERE workspace_id = ? AND aggregate_id = ?',
1365
+ ).get(this.config.workspaceId, aggregateId) as {
1366
+ next: number;
1367
+ }
1141
1368
  ).next,
1142
1369
  );
1143
1370
  }
1144
-
1145
1371
  listKudosSummaries(
1146
1372
  input: Required<Pick<KudosListInput, 'limit' | 'offset'>> & KudosListInput,
1147
1373
  viewer: ActorIdentity,
1148
1374
  ): Page<KudosSummary> {
1375
+ const binding = this.binding('kudos-list', viewer, {
1376
+ ...input,
1377
+ cursor: undefined,
1378
+ limit: undefined,
1379
+ offset: undefined,
1380
+ });
1381
+ const changesBinding = this.binding('kudos-changes', viewer, { kinds: ['kudos'] });
1382
+ const position = input.cursor ? this.decodeCursor(input.cursor, binding) : undefined;
1383
+ const high = position?.watermark ?? this.maxEventSequence();
1149
1384
  const where: string[] = [];
1150
- const parameters: Array<string | number> = [];
1151
- const add = (clause: string, ...values: Array<string | number>): void => {
1385
+ const parameters: Array<string | number | bigint> = [];
1386
+ const add = (clause: string, ...values: Array<string | number | bigint>): void => {
1152
1387
  where.push(clause);
1153
1388
  parameters.push(...values);
1154
1389
  };
1155
-
1156
- if (input.recipientAgentId) add('recipient_agent_id = ?', input.recipientAgentId);
1390
+ if (input.recipient?.id)
1391
+ add('recipient_kind=? AND recipient_id = ?', input.recipient.kind, input.recipient.id);
1157
1392
  if (input.actorId) add('actor_id = ?', input.actorId);
1158
1393
  if (input.actorKind) add('actor_kind = ?', input.actorKind);
1159
1394
  if (input.tag) add('EXISTS (SELECT 1 FROM json_each(tags_json) WHERE value = ?)', input.tag);
@@ -1166,48 +1401,33 @@ export class SynomemStorage implements SynomemRepository {
1166
1401
  }
1167
1402
  if (input.from) add('created_at >= ?', input.from);
1168
1403
  if (input.to) add('created_at <= ?', input.to);
1169
- if (viewer.kind !== 'human') {
1170
- if (viewer.kind === 'agent') {
1171
- add(
1172
- `(visibility != 'private' OR recipient_agent_id = ? OR
1173
- (actor_kind = ? AND actor_id = ?))`,
1174
- viewer.id,
1175
- viewer.kind,
1176
- viewer.id,
1177
- );
1178
- } else {
1179
- add(
1180
- `(visibility != 'private' OR (actor_kind = ? AND actor_id = ?))`,
1181
- viewer.kind,
1182
- viewer.id,
1183
- );
1184
- }
1185
- }
1186
-
1404
+ const privacy = recordVisibilityPredicate(viewer, this.authority, true);
1405
+ add(privacy.sql, ...privacy.values);
1187
1406
  const baseWhere = where.length ? `WHERE ${where.join(' AND ')}` : '';
1188
1407
  const total = Number(
1189
1408
  (
1190
- this.db()
1191
- .prepare(`SELECT COUNT(*) AS count FROM kudos_current ${baseWhere}`)
1192
- .get(...parameters) as { count: number }
1409
+ this.prepare(`SELECT COUNT(*) AS count FROM kudos_current ${baseWhere}`).get(
1410
+ ...parameters,
1411
+ ) as {
1412
+ count: number;
1413
+ }
1193
1414
  ).count,
1194
1415
  );
1195
- const cursorSequence = input.cursor ? decodeCursor(input.cursor, 'list') : undefined;
1416
+ const cursorSequence = position?.sequence;
1196
1417
  const pageWhere = [...where];
1197
1418
  const pageParameters = [...parameters];
1198
1419
  if (cursorSequence !== undefined) {
1199
1420
  pageWhere.push('given_sequence < ?');
1200
1421
  pageParameters.push(cursorSequence);
1201
1422
  }
1423
+ pageWhere.push('given_sequence<=? AND updated_sequence<=?');
1424
+ pageParameters.push(high, high);
1202
1425
  const sqlWhere = pageWhere.length ? `WHERE ${pageWhere.join(' AND ')}` : '';
1203
1426
  const offset = cursorSequence === undefined ? input.offset : 0;
1204
- const rows = this.db()
1205
- .prepare(
1206
- `SELECT * FROM kudos_current ${sqlWhere}
1427
+ const rows = this.prepare(
1428
+ `SELECT * FROM kudos_current ${sqlWhere}
1207
1429
  ORDER BY given_sequence DESC LIMIT ? OFFSET ?`,
1208
- )
1209
- .all(...pageParameters, input.limit + 1, offset) as unknown as CurrentRow[];
1210
-
1430
+ ).all(...pageParameters, input.limit + 1, offset) as unknown as CurrentRow[];
1211
1431
  const items: KudosSummary[] = [];
1212
1432
  let bytes = 2;
1213
1433
  let contextLimited = false;
@@ -1224,36 +1444,34 @@ export class SynomemStorage implements SynomemRepository {
1224
1444
  const hasMore = contextLimited || rows.length > items.length;
1225
1445
  const last = items.at(-1);
1226
1446
  const lastRow = last ? rows[items.length - 1] : undefined;
1227
- const watermark = encodeCursor('change', this.maxEventSequence());
1447
+ const watermark = this.encodeCursor(changesBinding, high, high);
1228
1448
  return {
1229
1449
  items,
1230
1450
  total,
1231
1451
  limit: input.limit,
1232
1452
  offset,
1233
- ...(hasMore && lastRow ? { nextCursor: encodeCursor('list', lastRow.given_sequence) } : {}),
1453
+ ...(hasMore && lastRow
1454
+ ? { nextCursor: this.encodeCursor(binding, lastRow.given_sequence, high) }
1455
+ : {}),
1234
1456
  hasMore,
1235
1457
  watermark,
1236
1458
  contextLimited,
1237
1459
  };
1238
1460
  }
1239
-
1240
1461
  listKudosChanges(after: string | undefined, limit: number, viewer: ActorIdentity): ChangePage {
1241
- const afterSequence = after ? decodeCursor(after, 'change') : 0;
1462
+ const binding = this.binding('kudos-changes', viewer, { kinds: ['kudos'] });
1463
+ const afterSequence = after ? this.decodeCursor(after, binding).sequence : 0n;
1242
1464
  const highWatermark = this.maxEventSequence();
1243
- const privacy =
1244
- viewer.kind === 'human'
1245
- ? ''
1246
- : viewer.kind === 'agent'
1247
- ? `AND (k.visibility != 'private' OR k.recipient_agent_id = ? OR
1248
- (k.actor_kind = ? AND k.actor_id = ?))`
1249
- : `AND (k.visibility != 'private' OR (k.actor_kind = ? AND k.actor_id = ?))`;
1250
- const parameters: Array<string | number> = [afterSequence, highWatermark];
1251
- if (viewer.kind === 'agent') parameters.push(viewer.id, viewer.kind, viewer.id);
1252
- else if (viewer.kind === 'system') parameters.push(viewer.kind, viewer.id);
1465
+ const predicate = recordVisibilityPredicate(viewer, this.authority, true, 'k.');
1466
+ const privacy = `AND ${predicate.sql}`;
1467
+ const parameters: Array<string | number | bigint> = [
1468
+ afterSequence,
1469
+ highWatermark,
1470
+ ...predicate.values,
1471
+ ];
1253
1472
  parameters.push(limit + 1);
1254
- const rows = this.db()
1255
- .prepare(
1256
- `SELECT e.id AS event_id, e.sequence, e.type, e.created_at AS event_created_at, e.payload, k.*
1473
+ const rows = this.prepare(
1474
+ `SELECT e.id AS event_id, e.sequence, e.type, e.created_at AS event_created_at, e.payload, k.*
1257
1475
  FROM events e
1258
1476
  JOIN kudos_current k ON k.kudos_id = CASE
1259
1477
  WHEN e.type = 'kudos.given' THEN e.id ELSE e.kudos_id END
@@ -1261,8 +1479,7 @@ export class SynomemStorage implements SynomemRepository {
1261
1479
  AND e.type IN ('kudos.given', 'kudos.acknowledged', 'kudos.revoked')
1262
1480
  ${privacy}
1263
1481
  ORDER BY e.sequence ASC LIMIT ?`,
1264
- )
1265
- .all(...parameters) as unknown as Array<
1482
+ ).all(...parameters) as unknown as Array<
1266
1483
  CurrentRow &
1267
1484
  EventRow & {
1268
1485
  event_id: string;
@@ -1270,17 +1487,18 @@ export class SynomemStorage implements SynomemRepository {
1270
1487
  type: KudosChange['type'];
1271
1488
  }
1272
1489
  >;
1273
-
1274
1490
  const items: KudosChange[] = [];
1275
1491
  let bytes = 2;
1276
1492
  let contextLimited = false;
1277
1493
  let consumed = 0;
1278
1494
  let lastConsumedSequence = afterSequence;
1279
1495
  for (const row of rows.slice(0, limit)) {
1280
- const sequence = Number(row.sequence);
1496
+ const sequence = row.sequence!;
1281
1497
  let actor: ActorIdentity;
1282
1498
  try {
1283
- const payload = JSON.parse(row.payload) as { actor: ActorIdentity };
1499
+ const payload = JSON.parse(row.payload) as {
1500
+ actor: ActorIdentity;
1501
+ };
1284
1502
  actor = actorSchema.parse(payload.actor);
1285
1503
  } catch {
1286
1504
  consumed += 1;
@@ -1288,14 +1506,14 @@ export class SynomemStorage implements SynomemRepository {
1288
1506
  continue;
1289
1507
  }
1290
1508
  const item: KudosChange = {
1291
- cursor: encodeCursor('change', sequence),
1292
- sequence,
1509
+ cursor: this.encodeCursor(binding, sequence, highWatermark),
1510
+ sequence: exactSequence(sequence),
1293
1511
  eventId: row.event_id,
1294
1512
  type: row.type,
1295
1513
  createdAt: row.event_created_at,
1296
1514
  actor,
1297
1515
  kudosId: row.kudos_id,
1298
- recipientAgentId: row.recipient_agent_id,
1516
+ recipient: { kind: row.recipient_kind, id: row.recipient_id },
1299
1517
  summary: summaryFromRow(row),
1300
1518
  };
1301
1519
  const itemBytes = Buffer.byteLength(JSON.stringify(item), 'utf8') + 1;
@@ -1309,36 +1527,51 @@ export class SynomemStorage implements SynomemRepository {
1309
1527
  lastConsumedSequence = sequence;
1310
1528
  }
1311
1529
  const hasMore = contextLimited || rows.length > consumed;
1312
- const nextCursor = encodeCursor('change', hasMore ? lastConsumedSequence : highWatermark);
1530
+ const nextCursor = this.encodeCursor(
1531
+ binding,
1532
+ hasMore ? lastConsumedSequence : highWatermark,
1533
+ highWatermark,
1534
+ );
1313
1535
  return {
1314
1536
  items,
1315
1537
  limit,
1316
1538
  nextCursor,
1317
1539
  hasMore,
1318
- watermark: encodeCursor('change', highWatermark),
1540
+ watermark: this.encodeCursor(binding, highWatermark, highWatermark),
1319
1541
  contextLimited,
1320
1542
  };
1321
1543
  }
1322
-
1323
1544
  listItemSummaries(
1324
1545
  input: Required<Pick<ItemListInput, 'limit' | 'offset'>> & ItemListInput,
1325
1546
  viewer: ActorIdentity,
1326
1547
  ): Page<ItemSummary> {
1548
+ const binding = this.binding('item-list', viewer, {
1549
+ ...input,
1550
+ cursor: undefined,
1551
+ limit: undefined,
1552
+ offset: undefined,
1553
+ });
1554
+ const changesBinding = this.binding('item-changes', viewer, { kinds: input.kinds ?? [] });
1555
+ const position = input.cursor ? this.decodeCursor(input.cursor, binding) : undefined;
1556
+ const high = position?.watermark ?? this.maxEventSequence();
1327
1557
  const where: string[] = [];
1328
- const parameters: Array<string | number> = [];
1329
- const add = (clause: string, ...values: Array<string | number>): void => {
1558
+ const parameters: Array<string | number | bigint> = [];
1559
+ const add = (clause: string, ...values: Array<string | number | bigint>): void => {
1330
1560
  where.push(clause);
1331
1561
  parameters.push(...values);
1332
1562
  };
1333
1563
  if (input.kinds?.length) {
1334
1564
  add(`kind IN (${input.kinds.map(() => '?').join(', ')})`, ...input.kinds);
1335
1565
  }
1336
- if (input.participantAgentId) {
1566
+ if (input.participant?.id) {
1337
1567
  add(
1338
- '(recipient_agent_id = ? OR owner_agent_id = ? OR assignee_agent_id = ?)',
1339
- input.participantAgentId,
1340
- input.participantAgentId,
1341
- input.participantAgentId,
1568
+ '((recipient_kind=? AND recipient_id=?) OR (owner_kind=? AND owner_id=?) OR (assignee_kind=? AND assignee_id=?))',
1569
+ input.participant.kind,
1570
+ input.participant.id,
1571
+ input.participant.kind,
1572
+ input.participant.id,
1573
+ input.participant.kind,
1574
+ input.participant.id,
1342
1575
  );
1343
1576
  }
1344
1577
  if (input.actorId) add('actor_id = ?', input.actorId);
@@ -1355,7 +1588,6 @@ export class SynomemStorage implements SynomemRepository {
1355
1588
  if (input.visibility) add('visibility = ?', input.visibility);
1356
1589
  if (input.from) add('created_at >= ?', input.from);
1357
1590
  if (input.to) add('created_at <= ?', input.to);
1358
-
1359
1591
  // Unanswered discovery: items still waiting for somebody to respond. Kept
1360
1592
  // distinct from `pending`, which also counts accepted-and-in-progress work.
1361
1593
  if (input.awaitingResponse) {
@@ -1364,7 +1596,6 @@ export class SynomemStorage implements SynomemRepository {
1364
1596
  (kind = 'task' AND status = 'assigned'))`);
1365
1597
  }
1366
1598
  if (input.awaitingSince) add('created_at <= ?', input.awaitingSince);
1367
-
1368
1599
  // Overdue discovery: a deadline that has passed, on work still open. A
1369
1600
  // completed or canceled item is not overdue, however late it was.
1370
1601
  if (input.overdueAsOf) {
@@ -1373,64 +1604,32 @@ export class SynomemStorage implements SynomemRepository {
1373
1604
  input.overdueAsOf,
1374
1605
  );
1375
1606
  }
1376
-
1377
- /*
1378
- * A todo is hidden from other agents, never from the human operating this home: humans are
1379
- * exempt from every private-record rule here (they are this store's administrators), and the
1380
- * hosted API grants the same to workspace owners and admins. Agents and system actors see only
1381
- * their own todos.
1382
- */
1383
- if (viewer.kind !== 'human') {
1384
- add(
1385
- `(kind != 'todo' OR (owner_agent_id = ? AND ? = 'agent') OR (actor_id = ? AND actor_kind = ?))`,
1386
- viewer.id,
1387
- viewer.kind,
1388
- viewer.id,
1389
- viewer.kind,
1390
- );
1391
- }
1392
-
1393
- if (viewer.kind !== 'human') {
1394
- if (viewer.kind === 'agent') {
1395
- add(
1396
- `(visibility != 'private' OR (actor_kind = ? AND actor_id = ?) OR
1397
- recipient_agent_id = ? OR owner_agent_id = ? OR assignee_agent_id = ?)`,
1398
- viewer.kind,
1399
- viewer.id,
1400
- viewer.id,
1401
- viewer.id,
1402
- viewer.id,
1403
- );
1404
- } else {
1405
- add(
1406
- `(visibility != 'private' OR (actor_kind = ? AND actor_id = ?))`,
1407
- viewer.kind,
1408
- viewer.id,
1409
- );
1410
- }
1411
- }
1607
+ const privacy = recordVisibilityPredicate(viewer, this.authority);
1608
+ add(privacy.sql, ...privacy.values);
1412
1609
  const baseWhere = where.length ? `WHERE ${where.join(' AND ')}` : '';
1413
1610
  const total = Number(
1414
1611
  (
1415
- this.db()
1416
- .prepare(`SELECT COUNT(*) AS count FROM items_current ${baseWhere}`)
1417
- .get(...parameters) as { count: number }
1612
+ this.prepare(`SELECT COUNT(*) AS count FROM items_current ${baseWhere}`).get(
1613
+ ...parameters,
1614
+ ) as {
1615
+ count: number;
1616
+ }
1418
1617
  ).count,
1419
1618
  );
1420
- const cursorSequence = input.cursor ? decodeCursor(input.cursor, 'items') : undefined;
1619
+ const cursorSequence = position?.sequence;
1421
1620
  const pageWhere = [...where];
1422
1621
  const pageParameters = [...parameters];
1423
1622
  if (cursorSequence !== undefined) {
1424
1623
  pageWhere.push('created_sequence < ?');
1425
1624
  pageParameters.push(cursorSequence);
1426
1625
  }
1626
+ pageWhere.push('created_sequence<=? AND updated_sequence<=?');
1627
+ pageParameters.push(high, high);
1427
1628
  const sqlWhere = pageWhere.length ? `WHERE ${pageWhere.join(' AND ')}` : '';
1428
1629
  const offset = cursorSequence === undefined ? input.offset : 0;
1429
- const rows = this.db()
1430
- .prepare(
1431
- `SELECT * FROM items_current ${sqlWhere} ORDER BY created_sequence DESC LIMIT ? OFFSET ?`,
1432
- )
1433
- .all(...pageParameters, input.limit + 1, offset) as unknown as ItemRow[];
1630
+ const rows = this.prepare(
1631
+ `SELECT * FROM items_current ${sqlWhere} ORDER BY created_sequence DESC LIMIT ? OFFSET ?`,
1632
+ ).all(...pageParameters, input.limit + 1, offset) as unknown as ItemRow[];
1434
1633
  const items: ItemSummary[] = [];
1435
1634
  let bytes = 2;
1436
1635
  let contextLimited = false;
@@ -1452,46 +1651,37 @@ export class SynomemStorage implements SynomemRepository {
1452
1651
  limit: input.limit,
1453
1652
  offset,
1454
1653
  ...(hasMore && lastRow
1455
- ? { nextCursor: encodeCursor('items', lastRow.created_sequence) }
1654
+ ? { nextCursor: this.encodeCursor(binding, lastRow.created_sequence, high) }
1456
1655
  : {}),
1457
1656
  hasMore,
1458
- watermark: encodeCursor('change', this.maxEventSequence()),
1657
+ watermark: this.encodeCursor(changesBinding, high, high),
1459
1658
  contextLimited,
1460
1659
  };
1461
1660
  }
1462
-
1463
1661
  listItemChanges(
1464
1662
  after: string | undefined,
1465
1663
  limit: number,
1466
1664
  viewer: ActorIdentity,
1467
1665
  kinds?: RecordKind[],
1468
1666
  ): ChangePage {
1469
- const afterSequence = after ? decodeCursor(after, 'change') : 0;
1667
+ const binding = this.binding('item-changes', viewer, { kinds: kinds ?? [] });
1668
+ const afterSequence = after ? this.decodeCursor(after, binding).sequence : 0n;
1470
1669
  const highWatermark = this.maxEventSequence();
1471
1670
  const where = ['e.sequence > ?', 'e.sequence <= ?', 'e.item_kind IS NOT NULL'];
1472
- const parameters: Array<string | number> = [afterSequence, highWatermark];
1671
+ const parameters: Array<string | number | bigint> = [afterSequence, highWatermark];
1473
1672
  if (kinds?.length) {
1474
1673
  where.push(`e.item_kind IN (${kinds.map(() => '?').join(', ')})`);
1475
1674
  parameters.push(...kinds);
1476
1675
  }
1477
- if (viewer.kind !== 'human') {
1478
- if (viewer.kind === 'agent') {
1479
- where.push(`(i.visibility != 'private' OR (i.actor_kind = ? AND i.actor_id = ?) OR
1480
- i.recipient_agent_id = ? OR i.owner_agent_id = ? OR i.assignee_agent_id = ?)`);
1481
- parameters.push(viewer.kind, viewer.id, viewer.id, viewer.id, viewer.id);
1482
- } else {
1483
- where.push(`(i.visibility != 'private' OR (i.actor_kind = ? AND i.actor_id = ?))`);
1484
- parameters.push(viewer.kind, viewer.id);
1485
- }
1486
- }
1676
+ const privacy = recordVisibilityPredicate(viewer, this.authority, false, 'i.');
1677
+ where.push(privacy.sql);
1678
+ parameters.push(...privacy.values);
1487
1679
  parameters.push(limit + 1);
1488
- const rows = this.db()
1489
- .prepare(
1490
- `SELECT e.id AS event_id, e.sequence, e.type, e.created_at AS event_created_at,
1680
+ const rows = this.prepare(
1681
+ `SELECT e.id AS event_id, e.sequence, e.type, e.created_at AS event_created_at,
1491
1682
  e.payload, i.* FROM events e JOIN items_current i ON i.item_id = e.aggregate_id
1492
1683
  WHERE ${where.join(' AND ')} ORDER BY e.sequence ASC LIMIT ?`,
1493
- )
1494
- .all(...parameters) as unknown as Array<
1684
+ ).all(...parameters) as unknown as Array<
1495
1685
  ItemRow &
1496
1686
  EventRow & {
1497
1687
  event_id: string;
@@ -1505,18 +1695,24 @@ export class SynomemStorage implements SynomemRepository {
1505
1695
  let consumed = 0;
1506
1696
  let lastConsumedSequence = afterSequence;
1507
1697
  for (const row of rows.slice(0, limit)) {
1508
- const sequence = Number(row.sequence);
1698
+ const sequence = row.sequence!;
1509
1699
  let actor: ActorIdentity;
1510
1700
  try {
1511
- actor = actorSchema.parse((JSON.parse(row.payload) as { actor: unknown }).actor);
1701
+ actor = actorSchema.parse(
1702
+ (
1703
+ JSON.parse(row.payload) as {
1704
+ actor: unknown;
1705
+ }
1706
+ ).actor,
1707
+ );
1512
1708
  } catch {
1513
1709
  consumed += 1;
1514
1710
  lastConsumedSequence = sequence;
1515
1711
  continue;
1516
1712
  }
1517
1713
  const item: ItemChange = {
1518
- cursor: encodeCursor('change', sequence),
1519
- sequence,
1714
+ cursor: this.encodeCursor(binding, sequence, highWatermark),
1715
+ sequence: exactSequence(sequence),
1520
1716
  eventId: row.event_id,
1521
1717
  type: row.type,
1522
1718
  createdAt: row.event_created_at,
@@ -1539,24 +1735,25 @@ export class SynomemStorage implements SynomemRepository {
1539
1735
  return {
1540
1736
  items,
1541
1737
  limit,
1542
- nextCursor: encodeCursor('change', hasMore ? lastConsumedSequence : highWatermark),
1738
+ nextCursor: this.encodeCursor(
1739
+ binding,
1740
+ hasMore ? lastConsumedSequence : highWatermark,
1741
+ highWatermark,
1742
+ ),
1543
1743
  hasMore,
1544
- watermark: encodeCursor('change', highWatermark),
1744
+ watermark: this.encodeCursor(binding, highWatermark, highWatermark),
1545
1745
  contextLimited,
1546
1746
  };
1547
1747
  }
1548
-
1549
1748
  getItemSummary(id: string): ItemSummary | undefined {
1550
- const row = this.db()
1551
- .prepare('SELECT * FROM items_current WHERE item_id = ?')
1552
- .get(id) as unknown as ItemRow | undefined;
1749
+ const row = this.prepare('SELECT * FROM items_current WHERE item_id = ?').get(id) as unknown as
1750
+ ItemRow | undefined;
1553
1751
  return row ? itemSummaryFromRow(row) : undefined;
1554
1752
  }
1555
-
1556
1753
  getReadableItemEvents(id: string): SynomemEvent[] {
1557
- const rows = this.db()
1558
- .prepare('SELECT id, payload FROM events WHERE aggregate_id = ? ORDER BY sequence ASC')
1559
- .all(id) as unknown as EventRow[];
1754
+ const rows = this.prepare(
1755
+ 'SELECT id, payload FROM events WHERE aggregate_id = ? ORDER BY sequence ASC',
1756
+ ).all(id) as unknown as EventRow[];
1560
1757
  const events: SynomemEvent[] = [];
1561
1758
  for (const row of rows) {
1562
1759
  try {
@@ -1567,24 +1764,29 @@ export class SynomemStorage implements SynomemRepository {
1567
1764
  }
1568
1765
  return events;
1569
1766
  }
1570
-
1571
- currentIndexHealth(): { given: number; indexed: number; stateMismatches: number } {
1767
+ currentIndexHealth(): {
1768
+ given: number;
1769
+ indexed: number;
1770
+ stateMismatches: number;
1771
+ } {
1572
1772
  const given = Number(
1573
1773
  (
1574
- this.db()
1575
- .prepare("SELECT COUNT(*) AS count FROM events WHERE type = 'kudos.given'")
1576
- .get() as { count: number }
1774
+ this.prepare("SELECT COUNT(*) AS count FROM events WHERE type = 'kudos.given'").get() as {
1775
+ count: number;
1776
+ }
1577
1777
  ).count,
1578
1778
  );
1579
1779
  const indexed = Number(
1580
- (this.db().prepare('SELECT COUNT(*) AS count FROM kudos_current').get() as { count: number })
1581
- .count,
1780
+ (
1781
+ this.prepare('SELECT COUNT(*) AS count FROM kudos_current').get() as {
1782
+ count: number;
1783
+ }
1784
+ ).count,
1582
1785
  );
1583
1786
  const stateMismatches = Number(
1584
1787
  (
1585
- this.db()
1586
- .prepare(
1587
- `SELECT COUNT(*) AS count
1788
+ this.prepare(
1789
+ `SELECT COUNT(*) AS count
1588
1790
  FROM kudos_current k
1589
1791
  WHERE k.status != CASE WHEN EXISTS (
1590
1792
  SELECT 1 FROM events e
@@ -1594,45 +1796,56 @@ export class SynomemStorage implements SynomemRepository {
1594
1796
  SELECT 1 FROM events e
1595
1797
  WHERE e.type = 'kudos.revoked' AND e.kudos_id = k.kudos_id
1596
1798
  ) THEN 'revoked' ELSE 'active' END`,
1597
- )
1598
- .get() as { count: number }
1799
+ ).get() as {
1800
+ count: number;
1801
+ }
1599
1802
  ).count,
1600
1803
  );
1601
1804
  return { given, indexed, stateMismatches };
1602
1805
  }
1603
-
1604
- itemIndexHealth(): { created: number; indexed: number } {
1806
+ itemIndexHealth(): {
1807
+ created: number;
1808
+ indexed: number;
1809
+ } {
1605
1810
  const created = Number(
1606
1811
  (
1607
- this.db()
1608
- .prepare(
1609
- `SELECT COUNT(*) AS count FROM events WHERE type IN
1812
+ this.prepare(
1813
+ `SELECT COUNT(*) AS count FROM events WHERE type IN
1610
1814
  ('kudos.given', 'memo.sent', 'note.created', 'post.created', 'task.created')`,
1611
- )
1612
- .get() as { count: number }
1815
+ ).get() as {
1816
+ count: number;
1817
+ }
1613
1818
  ).count,
1614
1819
  );
1615
1820
  const indexed = Number(
1616
- (this.db().prepare('SELECT COUNT(*) AS count FROM items_current').get() as { count: number })
1617
- .count,
1821
+ (
1822
+ this.prepare('SELECT COUNT(*) AS count FROM items_current').get() as {
1823
+ count: number;
1824
+ }
1825
+ ).count,
1618
1826
  );
1619
1827
  return { created, indexed };
1620
1828
  }
1621
-
1622
- migrationState(): { schemaVersion: number; appliedVersions: number[] } {
1829
+ migrationState(): {
1830
+ schemaVersion: number;
1831
+ appliedVersions: number[];
1832
+ } {
1623
1833
  const schemaVersion = Number(
1624
- (this.db().prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
1834
+ (
1835
+ this.prepare('PRAGMA user_version').get() as {
1836
+ user_version: number;
1837
+ }
1838
+ ).user_version,
1625
1839
  );
1626
1840
  const appliedVersions = (
1627
- this.db()
1628
- .prepare('SELECT version FROM schema_migrations ORDER BY version')
1629
- .all() as unknown as Array<{
1841
+ this.prepare(
1842
+ 'SELECT version FROM schema_migrations ORDER BY version',
1843
+ ).all() as unknown as Array<{
1630
1844
  version: number;
1631
1845
  }>
1632
1846
  ).map((row) => Number(row.version));
1633
1847
  return { schemaVersion, appliedVersions };
1634
1848
  }
1635
-
1636
1849
  /**
1637
1850
  * Aliases that also name an agent directly.
1638
1851
  *
@@ -1641,63 +1854,55 @@ export class SynomemStorage implements SynomemRepository {
1641
1854
  * agent's handle — which is the collision that actually makes a lookup
1642
1855
  * ambiguous now.
1643
1856
  */
1644
- aliasIdentityConflicts(): Array<{ alias: string; agentId: string }> {
1645
- return this.db()
1646
- .prepare(
1647
- `SELECT x.alias, x.agent_id AS agentId
1857
+ aliasIdentityConflicts(): Array<{
1858
+ alias: string;
1859
+ agentId: string;
1860
+ }> {
1861
+ return this.prepare(
1862
+ `SELECT x.alias, x.agent_id AS agentId
1648
1863
  FROM aliases x
1649
1864
  JOIN agents a ON lower(a.id) = x.normalized_alias
1650
1865
  OR lower(a.handle) = x.normalized_alias
1651
1866
  WHERE a.id != x.agent_id
1652
1867
  ORDER BY x.alias`,
1653
- )
1654
- .all() as unknown as Array<{ alias: string; agentId: string }>;
1868
+ ).all() as unknown as Array<{
1869
+ alias: string;
1870
+ agentId: string;
1871
+ }>;
1655
1872
  }
1656
-
1657
- private maxEventSequence(): number {
1658
- return Number(
1659
- (
1660
- this.db().prepare('SELECT COALESCE(MAX(sequence), 0) AS sequence FROM events').get() as {
1661
- sequence: number;
1662
- }
1663
- ).sequence,
1664
- );
1873
+ private maxEventSequence(): bigint {
1874
+ return (
1875
+ this.prepare('SELECT COALESCE(MAX(sequence),0) AS sequence FROM events').get() as {
1876
+ sequence: bigint;
1877
+ }
1878
+ ).sequence;
1665
1879
  }
1666
-
1667
1880
  getEvent(id: string): SynomemEvent | undefined {
1668
- const row = this.db().prepare('SELECT id, payload FROM events WHERE id = ?').get(id) as
1881
+ const row = this.prepare('SELECT id, payload FROM events WHERE id = ?').get(id) as
1669
1882
  EventRow | undefined;
1670
1883
  return row ? this.parseEvent(row) : undefined;
1671
1884
  }
1672
-
1673
1885
  getEventByIdempotency(actorKind: string, actorId: string, key: string): SynomemEvent | undefined {
1674
- const row = this.db()
1675
- .prepare(
1676
- `SELECT id, payload FROM events
1886
+ const row = this.prepare(
1887
+ `SELECT id, payload FROM events
1677
1888
  WHERE actor_kind = ? AND actor_id = ? AND idempotency_key = ?`,
1678
- )
1679
- .get(actorKind, actorId, key) as EventRow | undefined;
1889
+ ).get(actorKind, actorId, key) as EventRow | undefined;
1680
1890
  return row ? this.parseEvent(row) : undefined;
1681
1891
  }
1682
-
1683
1892
  getEvents(): SynomemEvent[] {
1684
1893
  const scan = this.scanEvents();
1685
1894
  if (scan.invalid[0]) throw scan.invalid[0].error;
1686
1895
  return scan.events;
1687
1896
  }
1688
-
1689
1897
  getReadableEvents(): SynomemEvent[] {
1690
1898
  return this.scanEvents().events;
1691
1899
  }
1692
-
1693
1900
  getReadableSynomemEvents(kudosId: string): SynomemEvent[] {
1694
- const rows = this.db()
1695
- .prepare(
1696
- `SELECT id, payload FROM events
1901
+ const rows = this.prepare(
1902
+ `SELECT id, payload FROM events
1697
1903
  WHERE (type = 'kudos.given' AND id = ?) OR kudos_id = ?
1698
1904
  ORDER BY sequence ASC`,
1699
- )
1700
- .all(kudosId, kudosId) as unknown as EventRow[];
1905
+ ).all(kudosId, kudosId) as unknown as EventRow[];
1701
1906
  const events: SynomemEvent[] = [];
1702
1907
  for (const row of rows) {
1703
1908
  try {
@@ -1708,7 +1913,6 @@ export class SynomemStorage implements SynomemRepository {
1708
1913
  }
1709
1914
  return events;
1710
1915
  }
1711
-
1712
1916
  scanEvents(): EventScan {
1713
1917
  const rows = this.rawEventRows();
1714
1918
  const events: SynomemEvent[] = [];
@@ -1723,11 +1927,14 @@ export class SynomemStorage implements SynomemRepository {
1723
1927
  }
1724
1928
  return { events, invalid };
1725
1929
  }
1726
-
1727
1930
  assertEventCompatibility(): void {
1728
- const rows = this.db()
1729
- .prepare('SELECT id, payload, sequence FROM events WHERE sequence > ? ORDER BY sequence ASC')
1730
- .all(this.validatedEventSequence) as unknown as Array<EventRow & { sequence: number }>;
1931
+ const rows = this.prepare(
1932
+ 'SELECT id, payload, sequence FROM events WHERE sequence > ? ORDER BY sequence ASC',
1933
+ ).all(this.validatedEventSequence) as unknown as Array<
1934
+ EventRow & {
1935
+ sequence: bigint;
1936
+ }
1937
+ >;
1731
1938
  const invalid: EventScan['invalid'] = [];
1732
1939
  for (const row of rows) {
1733
1940
  try {
@@ -1747,19 +1954,24 @@ export class SynomemStorage implements SynomemRepository {
1747
1954
  { eventIds: invalid.map((item) => item.id) },
1748
1955
  );
1749
1956
  }
1750
-
1751
1957
  rawEventRows(): EventRow[] {
1752
- return this.db()
1753
- .prepare('SELECT id, payload FROM events ORDER BY sequence ASC')
1754
- .all() as unknown as EventRow[];
1958
+ return this.prepare(
1959
+ 'SELECT id, payload FROM events ORDER BY sequence ASC',
1960
+ ).all() as unknown as EventRow[];
1755
1961
  }
1756
-
1757
1962
  private parseEvent(row: EventRow): SynomemEvent {
1758
1963
  try {
1759
- let value = JSON.parse(row.payload) as unknown;
1964
+ const value = JSON.parse(row.payload) as unknown;
1760
1965
  if (typeof value === 'object' && value !== null) {
1761
- const candidate = value as { schemaVersion?: unknown; type?: unknown };
1966
+ const candidate = value as {
1967
+ schemaVersion?: unknown;
1968
+ type?: unknown;
1969
+ };
1762
1970
  const supportedTypes = new Set([
1971
+ 'reply.created',
1972
+ 'reply.deleted',
1973
+ 'reaction.added',
1974
+ 'reaction.removed',
1763
1975
  'agent.created',
1764
1976
  'agent.updated',
1765
1977
  'topic.created',
@@ -1785,6 +1997,7 @@ export class SynomemStorage implements SynomemRepository {
1785
1997
  'task.accepted',
1786
1998
  'task.rejected',
1787
1999
  'task.canceled',
2000
+ 'task.decision_overridden',
1788
2001
  'todo.created',
1789
2002
  'todo.updated',
1790
2003
  'todo.completed',
@@ -1793,7 +2006,7 @@ export class SynomemStorage implements SynomemRepository {
1793
2006
  'todo.archived',
1794
2007
  ]);
1795
2008
  if (
1796
- (typeof candidate.schemaVersion === 'number' && candidate.schemaVersion > 1) ||
2009
+ (typeof candidate.schemaVersion === 'number' && candidate.schemaVersion > 2) ||
1797
2010
  (typeof candidate.type === 'string' && !supportedTypes.has(candidate.type))
1798
2011
  ) {
1799
2012
  throw new SynomemError(
@@ -1801,30 +2014,6 @@ export class SynomemStorage implements SynomemRepository {
1801
2014
  `Event ${row.id} was written by a newer or incompatible Synomem version.`,
1802
2015
  );
1803
2016
  }
1804
- const legacy = candidate as Record<string, unknown>;
1805
- if (legacy.workspaceId === undefined) legacy.workspaceId = 'legacy-local';
1806
- if (legacy.aggregateId === undefined) {
1807
- legacy.aggregateId =
1808
- legacy.kudosId ??
1809
- legacy.memoId ??
1810
- legacy.noteId ??
1811
- legacy.taskId ??
1812
- (typeof legacy.agentId === 'string' ? legacy.agentId : undefined) ??
1813
- (typeof legacy.agent === 'object' && legacy.agent !== null
1814
- ? (legacy.agent as { id?: unknown }).id
1815
- : undefined) ??
1816
- legacy.id;
1817
- }
1818
- if (legacy.aggregateVersion === undefined) {
1819
- legacy.aggregateVersion =
1820
- String(legacy.type).endsWith('.given') ||
1821
- String(legacy.type).endsWith('.sent') ||
1822
- String(legacy.type).endsWith('.created')
1823
- ? 1
1824
- : 2;
1825
- }
1826
- if (legacy.visibility === 'local') legacy.visibility = 'workspace';
1827
- value = legacy;
1828
2017
  }
1829
2018
  return eventSchema.parse(value);
1830
2019
  } catch (error) {
@@ -1838,50 +2027,216 @@ export class SynomemStorage implements SynomemRepository {
1838
2027
  );
1839
2028
  }
1840
2029
  }
2030
+ canActorReadItem(id: string, actor: ActorRef): boolean {
2031
+ const target = this.getActor(actor);
2032
+ if (!target || target.status !== 'active') return false;
2033
+ const authority =
2034
+ this.boundActor && this.boundActor.kind === actor.kind && this.boundActor.id === actor.id
2035
+ ? this.authority
2036
+ : resolveAuthority();
2037
+ const predicate = recordVisibilityPredicate(actor, authority);
2038
+ return !!this.prepare(`SELECT 1 FROM items_current WHERE item_id=? AND (${predicate.sql})`).get(
2039
+ id,
2040
+ ...predicate.values,
2041
+ );
2042
+ }
2043
+ getMutationReceipt(
2044
+ actorKind: string,
2045
+ actorId: string,
2046
+ keyHash: string,
2047
+ ): MutationReceipt | undefined {
2048
+ const row = this.prepare(
2049
+ 'SELECT operation,request_hash,result_json,event_id,created_at FROM mutation_receipts WHERE actor_kind=? AND actor_id=? AND key_hash=?',
2050
+ ).get(actorKind, actorId, keyHash) as
2051
+ | {
2052
+ operation: string;
2053
+ request_hash: string;
2054
+ result_json: string | null;
2055
+ event_id: string | null;
2056
+ created_at: string;
2057
+ }
2058
+ | undefined;
2059
+ return row
2060
+ ? {
2061
+ operation: row.operation,
2062
+ requestHash: row.request_hash,
2063
+ resultJson: row.result_json,
2064
+ createdAt: row.created_at,
2065
+ ...(row.event_id ? { eventId: row.event_id } : {}),
2066
+ }
2067
+ : undefined;
2068
+ }
2069
+ insertMutationReceipt(
2070
+ actorKind: string,
2071
+ actorId: string,
2072
+ keyHash: string,
2073
+ receipt: MutationReceipt,
2074
+ ): void {
2075
+ this.prepare(
2076
+ 'INSERT INTO mutation_receipts(actor_kind,actor_id,key_hash,operation,request_hash,result_json,event_id,created_at) VALUES(?,?,?,?,?,?,?,?)',
2077
+ ).run(
2078
+ actorKind,
2079
+ actorId,
2080
+ keyHash,
2081
+ receipt.operation,
2082
+ receipt.requestHash,
2083
+ receipt.resultJson,
2084
+ receipt.eventId ?? null,
2085
+ receipt.createdAt,
2086
+ );
2087
+ }
2088
+ compactMutationReceipts(before: string): number {
2089
+ this.assertWritable();
2090
+ return Number(
2091
+ this.prepare(
2092
+ '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)',
2093
+ ).run(before).changes,
2094
+ );
2095
+ }
2096
+ getActor(ref: ActorRef): AddressableActor | undefined {
2097
+ if (ref.kind === 'agent') {
2098
+ const profile = this.getAgent(ref.id);
2099
+ return profile
2100
+ ? {
2101
+ kind: 'agent',
2102
+ id: profile.id,
2103
+ handle: profile.handle,
2104
+ displayName: profile.displayName,
2105
+ status: profile.status === 'archived' ? 'inactive' : 'active',
2106
+ }
2107
+ : undefined;
2108
+ }
2109
+ const row = this.prepare(
2110
+ 'SELECT id,handle,display_name,status FROM human_actors WHERE id=? OR lower(handle)=lower(?)',
2111
+ ).get(ref.id, ref.id) as
2112
+ | { id: string; handle: string; display_name: string; status: 'active' | 'inactive' }
2113
+ | undefined;
2114
+ return row
2115
+ ? {
2116
+ kind: 'human',
2117
+ id: row.id,
2118
+ handle: row.handle,
2119
+ displayName: row.display_name,
2120
+ status: row.status,
2121
+ }
2122
+ : undefined;
2123
+ }
2124
+
2125
+ actorCounts(
2126
+ target: ActorRef,
2127
+ viewer: ActorIdentity,
2128
+ ): { kudosReceived: number; usefulReceived: number } {
2129
+ const policy = recordVisibilityPredicate(viewer, this.authority, false, 'i.');
2130
+ const kudos = this.prepare(
2131
+ `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})`,
2132
+ ).get(this.config.workspaceId, target.kind, target.id, ...policy.values) as { count: bigint };
2133
+ const useful = this.prepare(
2134
+ `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')=?))`,
2135
+ ).get(
2136
+ this.config.workspaceId,
2137
+ ...policy.values,
2138
+ target.kind,
2139
+ target.id,
2140
+ target.kind,
2141
+ target.id,
2142
+ ) as { count: bigint };
2143
+ return { kudosReceived: Number(kudos.count), usefulReceived: Number(useful.count) };
2144
+ }
2145
+ listActors(input: ActorDirectoryInput = {}, viewer: ActorIdentity): ActorDirectoryPage {
2146
+ const query = (input.query ?? '').toLowerCase();
2147
+ const binding = actorDirectoryBinding(this.config.workspaceId, viewer, input);
2148
+ const position = input.cursor
2149
+ ? this.cursorCodec!.decode(input.cursor, binding, '0')
2150
+ : undefined;
2151
+ const after = position?.id ? parseActorDirectoryKey(position.id) : undefined;
2152
+ const source = `(
2153
+ SELECT 'human' AS kind,id,handle,display_name,status FROM human_actors
2154
+ UNION ALL SELECT 'agent' AS kind,id,json_extract(profile_json,'$.handle') AS handle,display_name,
2155
+ CASE WHEN json_extract(profile_json,'$.status')='archived' THEN 'inactive' ELSE 'active' END AS status FROM agents
2156
+ )`;
2157
+ const filter = `status='active' AND (? IS NULL OR kind=?) AND (?='' OR instr(lower(id||' '||handle||' '||display_name),?)>0)`;
2158
+ const values = [input.kind ?? null, input.kind ?? null, query, query];
2159
+ const total = this.prepare(`SELECT COUNT(*) AS count FROM ${source} WHERE ${filter}`).get(
2160
+ ...values,
2161
+ ) as { count: bigint };
2162
+ const rows = this.prepare(
2163
+ `SELECT kind,id,handle,display_name,status FROM ${source} WHERE ${filter}${after ? ' AND (kind,handle,id)>(?,?,?)' : ''} ORDER BY kind,handle,id LIMIT ?`,
2164
+ ).all(...values, ...(after ?? []), (input.limit ?? 20) + 1) as Array<{
2165
+ kind: 'human' | 'agent';
2166
+ id: string;
2167
+ handle: string;
2168
+ display_name: string;
2169
+ status: 'active';
2170
+ }>;
2171
+ const limit = input.limit ?? 20;
2172
+ const hasMore = rows.length > limit;
2173
+ const items = rows.slice(0, limit).map((row) => ({
2174
+ kind: row.kind,
2175
+ id: row.id,
2176
+ handle: row.handle,
2177
+ displayName: row.display_name,
2178
+ status: row.status,
2179
+ }));
2180
+ return {
2181
+ items,
2182
+ total: Number(total.count),
2183
+ limit,
2184
+ hasMore,
2185
+ ...(hasMore
2186
+ ? {
2187
+ nextCursor: this.cursorCodec!.encode(binding, {
2188
+ sequence: '0',
2189
+ watermark: '0',
2190
+ id: actorDirectoryKey(items.at(-1)!),
2191
+ }),
2192
+ }
2193
+ : {}),
2194
+ };
2195
+ }
2196
+
2197
+ registerHuman(actor: AddressableActor): void {
2198
+ this.assertWritable();
2199
+ this.prepare(
2200
+ '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',
2201
+ ).run(actor.id, actor.handle, actor.displayName, actor.status);
2202
+ }
1841
2203
 
1842
2204
  insertAgent(profile: AgentProfile): void {
1843
2205
  const parsed = profileSchema.parse(profile);
1844
- this.db()
1845
- .prepare(
1846
- `INSERT INTO agents(id, handle, status, display_name, profile_json, created_at, updated_at)
2206
+ this.prepare(
2207
+ `INSERT INTO agents(id, handle, status, display_name, profile_json, created_at, updated_at)
1847
2208
  VALUES (?, ?, ?, ?, ?, ?, ?)`,
1848
- )
1849
- .run(
1850
- parsed.id,
1851
- parsed.handle,
1852
- parsed.status,
1853
- parsed.displayName,
1854
- JSON.stringify(parsed),
1855
- parsed.createdAt,
1856
- parsed.createdAt,
1857
- );
2209
+ ).run(
2210
+ parsed.id,
2211
+ parsed.handle,
2212
+ parsed.status,
2213
+ parsed.displayName,
2214
+ JSON.stringify(parsed),
2215
+ parsed.createdAt,
2216
+ parsed.createdAt,
2217
+ );
1858
2218
  this.insertAliases(parsed.id, parsed.aliases ?? []);
1859
2219
  }
1860
-
1861
2220
  updateAgent(profile: AgentProfile, updatedAt: string): void {
1862
2221
  const parsed = profileSchema.parse(profile);
1863
- this.db()
1864
- .prepare(
1865
- `UPDATE agents SET handle = ?, status = ?, display_name = ?, profile_json = ?,
2222
+ this.prepare(
2223
+ `UPDATE agents SET handle = ?, status = ?, display_name = ?, profile_json = ?,
1866
2224
  updated_at = ? WHERE id = ?`,
1867
- )
1868
- .run(
1869
- parsed.handle,
1870
- parsed.status,
1871
- parsed.displayName,
1872
- JSON.stringify(parsed),
1873
- updatedAt,
1874
- parsed.id,
1875
- );
1876
- this.db().prepare('DELETE FROM aliases WHERE agent_id = ?').run(parsed.id);
2225
+ ).run(
2226
+ parsed.handle,
2227
+ parsed.status,
2228
+ parsed.displayName,
2229
+ JSON.stringify(parsed),
2230
+ updatedAt,
2231
+ parsed.id,
2232
+ );
2233
+ this.prepare('DELETE FROM aliases WHERE agent_id = ?').run(parsed.id);
1877
2234
  this.insertAliases(parsed.id, parsed.aliases ?? []);
1878
2235
  }
1879
-
1880
2236
  getAgent(idOrAlias: string): AgentProfile | undefined {
1881
2237
  const resolved = this.resolveAgent(idOrAlias);
1882
2238
  return resolved.match;
1883
2239
  }
1884
-
1885
2240
  /**
1886
2241
  * Resolves a name to a canonical agent, reporting ambiguity rather than
1887
2242
  * guessing.
@@ -1892,78 +2247,74 @@ export class SynomemStorage implements SynomemRepository {
1892
2247
  * case — silently preferring the ID would attribute work to the wrong agent,
1893
2248
  * and the person who typed the name would never know.
1894
2249
  */
1895
- resolveAgent(query: string): { match?: AgentProfile; candidates: AgentProfile[] } {
2250
+ resolveAgent(query: string): {
2251
+ match?: AgentProfile;
2252
+ candidates: AgentProfile[];
2253
+ } {
1896
2254
  const normalized = query.trim().toLowerCase();
1897
- const rows = this.db()
1898
- .prepare(
1899
- `SELECT DISTINCT a.profile_json
2255
+ const rows = this.prepare(
2256
+ `SELECT DISTINCT a.profile_json
1900
2257
  FROM agents a
1901
2258
  LEFT JOIN aliases x ON x.agent_id = a.id
1902
2259
  WHERE lower(a.id) = ? OR lower(a.handle) = ? OR x.normalized_alias = ?
1903
2260
  ORDER BY a.id ASC`,
1904
- )
1905
- .all(normalized, normalized, normalized) as unknown as ProfileRow[];
2261
+ ).all(normalized, normalized, normalized) as unknown as ProfileRow[];
1906
2262
  const candidates = rows.map((row) => profileSchema.parse(JSON.parse(row.profile_json)));
1907
2263
  return candidates.length === 1 ? { match: candidates[0]!, candidates } : { candidates };
1908
2264
  }
1909
-
1910
2265
  /* -------------------------------------------------------------------- topics */
1911
-
1912
2266
  insertTopic(topic: Topic): void {
1913
2267
  const parsed = topicProfileSchema.parse(topic);
1914
- this.db()
1915
- .prepare(
1916
- `INSERT INTO topics(id, display_name, status, created_at, updated_at)
2268
+ this.prepare(
2269
+ `INSERT INTO topics(id, display_name, status, created_at, updated_at)
1917
2270
  VALUES (?, ?, ?, ?, ?)`,
1918
- )
1919
- .run(parsed.id, parsed.displayName, parsed.status, parsed.createdAt, parsed.createdAt);
2271
+ ).run(parsed.id, parsed.displayName, parsed.status, parsed.createdAt, parsed.createdAt);
1920
2272
  this.insertTopicAliases(parsed.id, parsed.aliases ?? []);
1921
2273
  }
1922
-
1923
2274
  updateTopic(topic: Topic, updatedAt: string): void {
1924
2275
  const parsed = topicProfileSchema.parse(topic);
1925
- this.db()
1926
- .prepare('UPDATE topics SET display_name = ?, status = ?, updated_at = ? WHERE id = ?')
1927
- .run(parsed.displayName, parsed.status, updatedAt, parsed.id);
1928
- this.db().prepare('DELETE FROM topic_aliases WHERE topic_id = ?').run(parsed.id);
2276
+ this.prepare('UPDATE topics SET display_name = ?, status = ?, updated_at = ? WHERE id = ?').run(
2277
+ parsed.displayName,
2278
+ parsed.status,
2279
+ updatedAt,
2280
+ parsed.id,
2281
+ );
2282
+ this.prepare('DELETE FROM topic_aliases WHERE topic_id = ?').run(parsed.id);
1929
2283
  this.insertTopicAliases(parsed.id, parsed.aliases ?? []);
1930
2284
  }
1931
-
1932
2285
  getTopic(idOrAlias: string): Topic | undefined {
1933
2286
  return this.resolveTopic(idOrAlias).match;
1934
2287
  }
1935
-
1936
2288
  listTopics(status?: 'active' | 'archived'): Topic[] {
1937
2289
  const rows = (status
1938
- ? this.db()
1939
- .prepare('SELECT * FROM topics WHERE status = ? ORDER BY display_name ASC')
1940
- .all(status)
1941
- : this.db()
1942
- .prepare('SELECT * FROM topics ORDER BY display_name ASC')
1943
- .all()) as unknown as TopicRow[];
2290
+ ? this.prepare('SELECT * FROM topics WHERE status = ? ORDER BY display_name ASC').all(status)
2291
+ : this.prepare(
2292
+ 'SELECT * FROM topics ORDER BY display_name ASC',
2293
+ ).all()) as unknown as TopicRow[];
1944
2294
  return rows.map((row) => this.topicFromRow(row));
1945
2295
  }
1946
-
1947
2296
  /** Resolves a name case-insensitively, reporting ambiguity instead of guessing. */
1948
- resolveTopic(query: string): { match?: Topic; candidates: Topic[] } {
2297
+ resolveTopic(query: string): {
2298
+ match?: Topic;
2299
+ candidates: Topic[];
2300
+ } {
1949
2301
  const normalized = query.trim().toLowerCase();
1950
- const rows = this.db()
1951
- .prepare(
1952
- `SELECT DISTINCT t.*
2302
+ const rows = this.prepare(
2303
+ `SELECT DISTINCT t.*
1953
2304
  FROM topics t
1954
2305
  LEFT JOIN topic_aliases x ON x.topic_id = t.id
1955
2306
  WHERE lower(t.id) = ? OR lower(t.display_name) = ? OR x.alias = ?
1956
2307
  ORDER BY t.id ASC`,
1957
- )
1958
- .all(normalized, normalized, normalized) as unknown as TopicRow[];
2308
+ ).all(normalized, normalized, normalized) as unknown as TopicRow[];
1959
2309
  const candidates = rows.map((row) => this.topicFromRow(row));
1960
2310
  return candidates.length === 1 ? { match: candidates[0]!, candidates } : { candidates };
1961
2311
  }
1962
-
1963
2312
  private topicFromRow(row: TopicRow): Topic {
1964
- const aliasRows = this.db()
1965
- .prepare('SELECT alias FROM topic_aliases WHERE topic_id = ? ORDER BY alias ASC')
1966
- .all(row.id) as unknown as Array<{ alias: string }>;
2313
+ const aliasRows = this.prepare(
2314
+ 'SELECT alias FROM topic_aliases WHERE topic_id = ? ORDER BY alias ASC',
2315
+ ).all(row.id) as unknown as Array<{
2316
+ alias: string;
2317
+ }>;
1967
2318
  const aliases = aliasRows.map((aliasRow) => aliasRow.alias);
1968
2319
  return {
1969
2320
  id: row.id,
@@ -1973,24 +2324,20 @@ export class SynomemStorage implements SynomemRepository {
1973
2324
  createdAt: row.created_at,
1974
2325
  };
1975
2326
  }
1976
-
1977
2327
  private insertTopicAliases(topicId: string, aliases: string[]): void {
1978
2328
  for (const alias of aliases) {
1979
- this.db()
1980
- .prepare('INSERT INTO topic_aliases(alias, topic_id) VALUES (?, ?)')
1981
- .run(alias.trim().toLowerCase(), topicId);
2329
+ this.prepare('INSERT INTO topic_aliases(alias, topic_id) VALUES (?, ?)').run(
2330
+ alias.trim().toLowerCase(),
2331
+ topicId,
2332
+ );
1982
2333
  }
1983
2334
  }
1984
-
1985
2335
  /* -------------------------------------------------------- post acknowledgment */
1986
-
1987
2336
  listPostAcknowledgments(postId: string): PostAcknowledgment[] {
1988
- const rows = this.db()
1989
- .prepare(
1990
- `SELECT actor_kind, actor_id, actor_display_name, note, acknowledged_at
2337
+ const rows = this.prepare(
2338
+ `SELECT actor_kind, actor_id, actor_display_name, note, acknowledged_at
1991
2339
  FROM post_acknowledgments WHERE post_id = ? ORDER BY acknowledged_at ASC`,
1992
- )
1993
- .all(postId) as unknown as Array<{
2340
+ ).all(postId) as unknown as Array<{
1994
2341
  actor_kind: string;
1995
2342
  actor_id: string;
1996
2343
  actor_display_name: string | null;
@@ -2007,7 +2354,6 @@ export class SynomemStorage implements SynomemRepository {
2007
2354
  ...(row.note ? { note: row.note } : {}),
2008
2355
  }));
2009
2356
  }
2010
-
2011
2357
  /**
2012
2358
  * Who has acknowledged a post and who has not.
2013
2359
  *
@@ -2017,36 +2363,36 @@ export class SynomemStorage implements SynomemRepository {
2017
2363
  * newcomer of ignoring something written before it arrived.
2018
2364
  */
2019
2365
  postRoster(postId: string): PostRoster | undefined {
2020
- const post = this.db()
2021
- .prepare("SELECT created_at FROM items_current WHERE item_id = ? AND kind = 'post'")
2022
- .get(postId) as { created_at: string } | undefined;
2366
+ const post = this.prepare(
2367
+ "SELECT created_at FROM items_current WHERE item_id = ? AND kind = 'post'",
2368
+ ).get(postId) as
2369
+ | {
2370
+ created_at: string;
2371
+ }
2372
+ | undefined;
2023
2373
  if (!post) return undefined;
2024
-
2025
2374
  const acknowledged = this.listPostAcknowledgments(postId);
2026
2375
  const acknowledgedIds = new Set(acknowledged.map((entry) => entry.actor.id));
2027
-
2028
- const eligible = this.db()
2029
- .prepare('SELECT id, display_name, created_at FROM agents ORDER BY id ASC')
2030
- .all() as unknown as Array<{ id: string; display_name: string; created_at: string }>;
2031
-
2376
+ const eligible = this.prepare(
2377
+ 'SELECT id, display_name, created_at FROM agents ORDER BY id ASC',
2378
+ ).all() as unknown as Array<{
2379
+ id: string;
2380
+ display_name: string;
2381
+ created_at: string;
2382
+ }>;
2032
2383
  const outstanding = eligible
2033
2384
  .filter((agent) => agent.created_at <= post.created_at && !acknowledgedIds.has(agent.id))
2034
2385
  .map((agent) => ({ id: agent.id, displayName: agent.display_name }));
2035
2386
  const joinedSince = eligible.filter((agent) => agent.created_at > post.created_at).length;
2036
-
2037
2387
  return { postId, acknowledged, outstanding, joinedSince };
2038
2388
  }
2039
-
2040
2389
  /* ------------------------------------------------------- runtime bindings */
2041
-
2042
2390
  listRuntimeBindings(agentId: string): AgentRuntimeBinding[] {
2043
- const rows = this.db()
2044
- .prepare(
2045
- `SELECT id, agent_id, installation_id, runtime, profile, capabilities_json,
2391
+ const rows = this.prepare(
2392
+ `SELECT id, agent_id, installation_id, runtime, profile, capabilities_json,
2046
2393
  bound_at, last_seen_at
2047
2394
  FROM agent_runtime_bindings WHERE agent_id = ? ORDER BY bound_at ASC`,
2048
- )
2049
- .all(agentId) as unknown as Array<{
2395
+ ).all(agentId) as unknown as Array<{
2050
2396
  id: string;
2051
2397
  agent_id: string;
2052
2398
  installation_id: string | null;
@@ -2067,7 +2413,6 @@ export class SynomemStorage implements SynomemRepository {
2067
2413
  ...(row.last_seen_at ? { lastSeenAt: row.last_seen_at } : {}),
2068
2414
  }));
2069
2415
  }
2070
-
2071
2416
  bindRuntime(binding: {
2072
2417
  id: string;
2073
2418
  agentId: string;
@@ -2077,44 +2422,35 @@ export class SynomemStorage implements SynomemRepository {
2077
2422
  capabilities?: Record<string, JsonValue>;
2078
2423
  boundAt: string;
2079
2424
  }): void {
2080
- this.db()
2081
- .prepare(
2082
- `INSERT INTO agent_runtime_bindings(
2425
+ this.prepare(
2426
+ `INSERT INTO agent_runtime_bindings(
2083
2427
  id, agent_id, installation_id, runtime, profile, capabilities_json, bound_at
2084
2428
  ) VALUES (?, ?, ?, ?, ?, ?, ?)
2085
2429
  ON CONFLICT(agent_id, runtime, COALESCE(profile, ''), COALESCE(installation_id, ''))
2086
2430
  DO UPDATE SET capabilities_json = excluded.capabilities_json`,
2087
- )
2088
- .run(
2089
- binding.id,
2090
- binding.agentId,
2091
- binding.installationId ?? null,
2092
- binding.runtime,
2093
- binding.profile ?? null,
2094
- JSON.stringify(binding.capabilities ?? {}),
2095
- binding.boundAt,
2096
- );
2431
+ ).run(
2432
+ binding.id,
2433
+ binding.agentId,
2434
+ binding.installationId ?? null,
2435
+ binding.runtime,
2436
+ binding.profile ?? null,
2437
+ JSON.stringify(binding.capabilities ?? {}),
2438
+ binding.boundAt,
2439
+ );
2097
2440
  }
2098
-
2099
2441
  unbindRuntime(bindingId: string): boolean {
2100
- const result = this.db()
2101
- .prepare('DELETE FROM agent_runtime_bindings WHERE id = ?')
2102
- .run(bindingId);
2442
+ const result = this.prepare('DELETE FROM agent_runtime_bindings WHERE id = ?').run(bindingId);
2103
2443
  return Number(result.changes) > 0;
2104
2444
  }
2105
-
2106
2445
  /**
2107
2446
  * Advisory only. Records that Synomem observed this binding act — never that
2108
2447
  * the runtime is reachable now, and never that a delivery succeeded.
2109
2448
  */
2110
2449
  touchRuntimeBinding(agentId: string, runtime: string, at: string): void {
2111
- this.db()
2112
- .prepare(
2113
- 'UPDATE agent_runtime_bindings SET last_seen_at = ? WHERE agent_id = ? AND runtime = ?',
2114
- )
2115
- .run(at, agentId, runtime);
2450
+ this.prepare(
2451
+ 'UPDATE agent_runtime_bindings SET last_seen_at = ? WHERE agent_id = ? AND runtime = ?',
2452
+ ).run(at, agentId, runtime);
2116
2453
  }
2117
-
2118
2454
  /**
2119
2455
  * Writes an agent's aliases, refusing any that would make a name ambiguous.
2120
2456
  *
@@ -2127,47 +2463,54 @@ export class SynomemStorage implements SynomemRepository {
2127
2463
  private insertAliases(agentId: string, aliases: string[]): void {
2128
2464
  for (const alias of aliases) {
2129
2465
  const normalized = alias.trim().toLowerCase();
2130
- const conflictingAgent = this.db()
2131
- .prepare('SELECT id FROM agents WHERE lower(id) = ? AND id != ?')
2132
- .get(normalized, agentId) as { id: string } | undefined;
2466
+ const conflictingAgent = this.prepare(
2467
+ 'SELECT id FROM agents WHERE lower(id) = ? AND id != ?',
2468
+ ).get(normalized, agentId) as
2469
+ | {
2470
+ id: string;
2471
+ }
2472
+ | undefined;
2133
2473
  if (conflictingAgent) {
2134
2474
  throw new SynomemError(
2135
2475
  'ALIAS_CONFLICT',
2136
2476
  `Alias "${alias}" is already the canonical ID of agent ${conflictingAgent.id}. Aliases must resolve to exactly one agent.`,
2137
2477
  );
2138
2478
  }
2139
- const conflictingAlias = this.db()
2140
- .prepare('SELECT agent_id FROM aliases WHERE normalized_alias = ? AND agent_id != ?')
2141
- .get(normalized, agentId) as { agent_id: string } | undefined;
2479
+ const conflictingAlias = this.prepare(
2480
+ 'SELECT agent_id FROM aliases WHERE normalized_alias = ? AND agent_id != ?',
2481
+ ).get(normalized, agentId) as
2482
+ | {
2483
+ agent_id: string;
2484
+ }
2485
+ | undefined;
2142
2486
  if (conflictingAlias) {
2143
2487
  throw new SynomemError(
2144
2488
  'ALIAS_CONFLICT',
2145
2489
  `Alias "${alias}" already belongs to agent ${conflictingAlias.agent_id}. Aliases must resolve to exactly one agent.`,
2146
2490
  );
2147
2491
  }
2148
- this.db()
2149
- .prepare('INSERT INTO aliases(alias, agent_id, normalized_alias) VALUES (?, ?, ?)')
2150
- .run(alias, agentId, normalized);
2492
+ this.prepare('INSERT INTO aliases(alias, agent_id, normalized_alias) VALUES (?, ?, ?)').run(
2493
+ alias,
2494
+ agentId,
2495
+ normalized,
2496
+ );
2151
2497
  }
2152
2498
  }
2153
-
2154
2499
  listAgents(): AgentProfile[] {
2155
- const rows = this.db()
2156
- .prepare('SELECT profile_json FROM agents ORDER BY id ASC')
2157
- .all() as unknown as ProfileRow[];
2500
+ const rows = this.prepare(
2501
+ 'SELECT profile_json FROM agents ORDER BY id ASC',
2502
+ ).all() as unknown as ProfileRow[];
2158
2503
  return rows.map((row) => profileSchema.parse(JSON.parse(row.profile_json)));
2159
2504
  }
2160
-
2161
2505
  replaceProjectionManifest(paths: string[], generatedAt: string): void {
2162
2506
  this.transactionSync(() => {
2163
- this.db().prepare('DELETE FROM projection_manifest').run();
2164
- const insert = this.db().prepare(
2507
+ this.prepare('DELETE FROM projection_manifest').run();
2508
+ const insert = this.prepare(
2165
2509
  'INSERT INTO projection_manifest(path, generated_at) VALUES (?, ?)',
2166
2510
  );
2167
2511
  for (const path of paths) insert.run(path, generatedAt);
2168
2512
  });
2169
2513
  }
2170
-
2171
2514
  /**
2172
2515
  * @param directory the agent's projection directory name, which is its
2173
2516
  * handle rather than its canonical ID: these rows are keyed by the path on
@@ -2175,24 +2518,23 @@ export class SynomemStorage implements SynomemRepository {
2175
2518
  */
2176
2519
  replaceAgentProjectionManifest(directory: string, paths: string[], generatedAt: string): void {
2177
2520
  this.transactionSync(() => {
2178
- this.db()
2179
- .prepare('DELETE FROM projection_manifest WHERE path LIKE ? OR path LIKE ?')
2180
- .run(`${directory}/%`, `${directory}\\%`);
2181
- const insert = this.db().prepare(
2521
+ this.prepare('DELETE FROM projection_manifest WHERE path LIKE ? OR path LIKE ?').run(
2522
+ `${directory}/%`,
2523
+ `${directory}\\%`,
2524
+ );
2525
+ const insert = this.prepare(
2182
2526
  'INSERT INTO projection_manifest(path, generated_at) VALUES (?, ?)',
2183
2527
  );
2184
2528
  for (const path of paths) insert.run(path, generatedAt);
2185
2529
  });
2186
2530
  }
2187
-
2188
2531
  projectionManifest(): string[] {
2189
2532
  return (
2190
- this.db().prepare('SELECT path FROM projection_manifest ORDER BY path').all() as unknown as {
2533
+ this.prepare('SELECT path FROM projection_manifest ORDER BY path').all() as unknown as {
2191
2534
  path: string;
2192
2535
  }[]
2193
2536
  ).map((row) => row.path);
2194
2537
  }
2195
-
2196
2538
  /**
2197
2539
  * Re-point manifest paths from one agent directory to another.
2198
2540
  *
@@ -2211,8 +2553,8 @@ export class SynomemStorage implements SynomemRepository {
2211
2553
  prefixes.some((prefix) => entry.path.startsWith(prefix)),
2212
2554
  );
2213
2555
  if (!rows.length) return;
2214
- const remove = this.db().prepare('DELETE FROM projection_manifest WHERE path = ?');
2215
- const insert = this.db().prepare(
2556
+ const remove = this.prepare('DELETE FROM projection_manifest WHERE path = ?');
2557
+ const insert = this.prepare(
2216
2558
  'INSERT OR REPLACE INTO projection_manifest(path, generated_at) VALUES (?, ?)',
2217
2559
  );
2218
2560
  for (const row of rows) {
@@ -2220,29 +2562,31 @@ export class SynomemStorage implements SynomemRepository {
2220
2562
  insert.run(`${nextHandle}${row.path.slice(previousHandle.length)}`, row.generatedAt);
2221
2563
  }
2222
2564
  }
2223
-
2224
2565
  /** The manifest with the time each path was written, newest first. */
2225
- projectionManifestEntries(): { path: string; generatedAt: string }[] {
2566
+ projectionManifestEntries(): {
2567
+ path: string;
2568
+ generatedAt: string;
2569
+ }[] {
2226
2570
  return (
2227
- this.db()
2228
- .prepare('SELECT path, generated_at FROM projection_manifest ORDER BY generated_at DESC')
2229
- .all() as unknown as { path: string; generated_at: string }[]
2571
+ this.prepare(
2572
+ 'SELECT path, generated_at FROM projection_manifest ORDER BY generated_at DESC',
2573
+ ).all() as unknown as {
2574
+ path: string;
2575
+ generated_at: string;
2576
+ }[]
2230
2577
  ).map((row) => ({ path: row.path, generatedAt: row.generated_at }));
2231
2578
  }
2232
-
2233
2579
  integrityCheck(): string[] {
2234
- const rows = this.db().prepare('PRAGMA integrity_check').all() as unknown as Record<
2580
+ const rows = this.prepare('PRAGMA integrity_check').all() as unknown as Record<
2235
2581
  string,
2236
2582
  string
2237
2583
  >[];
2238
2584
  return rows.map((row) => Object.values(row)[0] ?? 'unknown');
2239
2585
  }
2240
-
2241
2586
  journalMode(): string {
2242
- const row = this.db().prepare('PRAGMA journal_mode').get() as Record<string, string>;
2587
+ const row = this.prepare('PRAGMA journal_mode').get() as Record<string, string>;
2243
2588
  return Object.values(row)[0] ?? 'unknown';
2244
2589
  }
2245
-
2246
2590
  async backup(destination: string): Promise<string> {
2247
2591
  this.assertWritable();
2248
2592
  const output = resolve(destination);
@@ -2268,7 +2612,6 @@ export class SynomemStorage implements SynomemRepository {
2268
2612
  rmdirSync(temporaryDirectory);
2269
2613
  }
2270
2614
  }
2271
-
2272
2615
  private restrictDatabaseFiles(): void {
2273
2616
  if (this.readOnly) return;
2274
2617
  for (const path of [
@@ -2279,7 +2622,6 @@ export class SynomemStorage implements SynomemRepository {
2279
2622
  if (existsSync(path)) chmodSync(path, 0o600);
2280
2623
  }
2281
2624
  }
2282
-
2283
2625
  close(): void {
2284
2626
  this.database?.close();
2285
2627
  this.database = undefined;