synomem 0.9.5 → 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 (120) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +9 -7
  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 +337 -30
  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 -367
  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 +3 -1
  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 +13 -13
  96. package/skills/synomem/agents/openai.yaml +1 -1
  97. package/skills/synomem/references/examples.md +2 -3
  98. package/src/actor-directory.ts +87 -0
  99. package/src/backend.ts +29 -3
  100. package/src/bookmarks.ts +127 -0
  101. package/src/cli.ts +941 -546
  102. package/src/client.ts +1143 -279
  103. package/src/cursors.ts +132 -0
  104. package/src/errors.ts +2 -0
  105. package/src/index.ts +50 -0
  106. package/src/mcp/index.ts +420 -104
  107. package/src/mutation-receipts.ts +7 -0
  108. package/src/notifications.ts +483 -0
  109. package/src/participation-repository.ts +406 -0
  110. package/src/participation.ts +103 -0
  111. package/src/policy.ts +109 -0
  112. package/src/ports/repository.ts +33 -0
  113. package/src/profiles.ts +2 -1
  114. package/src/projections.ts +49 -48
  115. package/src/remote.ts +227 -37
  116. package/src/schemas.ts +116 -16
  117. package/src/search.ts +24 -0
  118. package/src/service.ts +122 -8
  119. package/src/storage.ts +1039 -699
  120. 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,66 +1604,32 @@ export class SynomemStorage implements SynomemRepository {
1373
1604
  input.overdueAsOf,
1374
1605
  );
1375
1606
  }
1376
-
1377
- /*
1378
- * Private todos are owner-only for EVERY viewer, including a human.
1379
- *
1380
- * The visibility rules below exempt human actors, on the reasoning that a
1381
- * person operating a local home is its operator. That does not extend to
1382
- * todos: the plan is explicit that a todo is visible only to its owner
1383
- * except through an explicitly authorized administrative capability, and
1384
- * this release has no such capability. Without this clause a human actor
1385
- * would see every agent's private reminders in an item list.
1386
- */
1387
- add(
1388
- `(kind != 'todo' OR (owner_agent_id = ? AND ? = 'agent') OR (actor_id = ? AND actor_kind = ?))`,
1389
- viewer.id,
1390
- viewer.kind,
1391
- viewer.id,
1392
- viewer.kind,
1393
- );
1394
-
1395
- if (viewer.kind !== 'human') {
1396
- if (viewer.kind === 'agent') {
1397
- add(
1398
- `(visibility != 'private' OR (actor_kind = ? AND actor_id = ?) OR
1399
- recipient_agent_id = ? OR owner_agent_id = ? OR assignee_agent_id = ?)`,
1400
- viewer.kind,
1401
- viewer.id,
1402
- viewer.id,
1403
- viewer.id,
1404
- viewer.id,
1405
- );
1406
- } else {
1407
- add(
1408
- `(visibility != 'private' OR (actor_kind = ? AND actor_id = ?))`,
1409
- viewer.kind,
1410
- viewer.id,
1411
- );
1412
- }
1413
- }
1607
+ const privacy = recordVisibilityPredicate(viewer, this.authority);
1608
+ add(privacy.sql, ...privacy.values);
1414
1609
  const baseWhere = where.length ? `WHERE ${where.join(' AND ')}` : '';
1415
1610
  const total = Number(
1416
1611
  (
1417
- this.db()
1418
- .prepare(`SELECT COUNT(*) AS count FROM items_current ${baseWhere}`)
1419
- .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
+ }
1420
1617
  ).count,
1421
1618
  );
1422
- const cursorSequence = input.cursor ? decodeCursor(input.cursor, 'items') : undefined;
1619
+ const cursorSequence = position?.sequence;
1423
1620
  const pageWhere = [...where];
1424
1621
  const pageParameters = [...parameters];
1425
1622
  if (cursorSequence !== undefined) {
1426
1623
  pageWhere.push('created_sequence < ?');
1427
1624
  pageParameters.push(cursorSequence);
1428
1625
  }
1626
+ pageWhere.push('created_sequence<=? AND updated_sequence<=?');
1627
+ pageParameters.push(high, high);
1429
1628
  const sqlWhere = pageWhere.length ? `WHERE ${pageWhere.join(' AND ')}` : '';
1430
1629
  const offset = cursorSequence === undefined ? input.offset : 0;
1431
- const rows = this.db()
1432
- .prepare(
1433
- `SELECT * FROM items_current ${sqlWhere} ORDER BY created_sequence DESC LIMIT ? OFFSET ?`,
1434
- )
1435
- .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[];
1436
1633
  const items: ItemSummary[] = [];
1437
1634
  let bytes = 2;
1438
1635
  let contextLimited = false;
@@ -1454,46 +1651,37 @@ export class SynomemStorage implements SynomemRepository {
1454
1651
  limit: input.limit,
1455
1652
  offset,
1456
1653
  ...(hasMore && lastRow
1457
- ? { nextCursor: encodeCursor('items', lastRow.created_sequence) }
1654
+ ? { nextCursor: this.encodeCursor(binding, lastRow.created_sequence, high) }
1458
1655
  : {}),
1459
1656
  hasMore,
1460
- watermark: encodeCursor('change', this.maxEventSequence()),
1657
+ watermark: this.encodeCursor(changesBinding, high, high),
1461
1658
  contextLimited,
1462
1659
  };
1463
1660
  }
1464
-
1465
1661
  listItemChanges(
1466
1662
  after: string | undefined,
1467
1663
  limit: number,
1468
1664
  viewer: ActorIdentity,
1469
1665
  kinds?: RecordKind[],
1470
1666
  ): ChangePage {
1471
- 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;
1472
1669
  const highWatermark = this.maxEventSequence();
1473
1670
  const where = ['e.sequence > ?', 'e.sequence <= ?', 'e.item_kind IS NOT NULL'];
1474
- const parameters: Array<string | number> = [afterSequence, highWatermark];
1671
+ const parameters: Array<string | number | bigint> = [afterSequence, highWatermark];
1475
1672
  if (kinds?.length) {
1476
1673
  where.push(`e.item_kind IN (${kinds.map(() => '?').join(', ')})`);
1477
1674
  parameters.push(...kinds);
1478
1675
  }
1479
- if (viewer.kind !== 'human') {
1480
- if (viewer.kind === 'agent') {
1481
- where.push(`(i.visibility != 'private' OR (i.actor_kind = ? AND i.actor_id = ?) OR
1482
- i.recipient_agent_id = ? OR i.owner_agent_id = ? OR i.assignee_agent_id = ?)`);
1483
- parameters.push(viewer.kind, viewer.id, viewer.id, viewer.id, viewer.id);
1484
- } else {
1485
- where.push(`(i.visibility != 'private' OR (i.actor_kind = ? AND i.actor_id = ?))`);
1486
- parameters.push(viewer.kind, viewer.id);
1487
- }
1488
- }
1676
+ const privacy = recordVisibilityPredicate(viewer, this.authority, false, 'i.');
1677
+ where.push(privacy.sql);
1678
+ parameters.push(...privacy.values);
1489
1679
  parameters.push(limit + 1);
1490
- const rows = this.db()
1491
- .prepare(
1492
- `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,
1493
1682
  e.payload, i.* FROM events e JOIN items_current i ON i.item_id = e.aggregate_id
1494
1683
  WHERE ${where.join(' AND ')} ORDER BY e.sequence ASC LIMIT ?`,
1495
- )
1496
- .all(...parameters) as unknown as Array<
1684
+ ).all(...parameters) as unknown as Array<
1497
1685
  ItemRow &
1498
1686
  EventRow & {
1499
1687
  event_id: string;
@@ -1507,18 +1695,24 @@ export class SynomemStorage implements SynomemRepository {
1507
1695
  let consumed = 0;
1508
1696
  let lastConsumedSequence = afterSequence;
1509
1697
  for (const row of rows.slice(0, limit)) {
1510
- const sequence = Number(row.sequence);
1698
+ const sequence = row.sequence!;
1511
1699
  let actor: ActorIdentity;
1512
1700
  try {
1513
- 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
+ );
1514
1708
  } catch {
1515
1709
  consumed += 1;
1516
1710
  lastConsumedSequence = sequence;
1517
1711
  continue;
1518
1712
  }
1519
1713
  const item: ItemChange = {
1520
- cursor: encodeCursor('change', sequence),
1521
- sequence,
1714
+ cursor: this.encodeCursor(binding, sequence, highWatermark),
1715
+ sequence: exactSequence(sequence),
1522
1716
  eventId: row.event_id,
1523
1717
  type: row.type,
1524
1718
  createdAt: row.event_created_at,
@@ -1541,24 +1735,25 @@ export class SynomemStorage implements SynomemRepository {
1541
1735
  return {
1542
1736
  items,
1543
1737
  limit,
1544
- nextCursor: encodeCursor('change', hasMore ? lastConsumedSequence : highWatermark),
1738
+ nextCursor: this.encodeCursor(
1739
+ binding,
1740
+ hasMore ? lastConsumedSequence : highWatermark,
1741
+ highWatermark,
1742
+ ),
1545
1743
  hasMore,
1546
- watermark: encodeCursor('change', highWatermark),
1744
+ watermark: this.encodeCursor(binding, highWatermark, highWatermark),
1547
1745
  contextLimited,
1548
1746
  };
1549
1747
  }
1550
-
1551
1748
  getItemSummary(id: string): ItemSummary | undefined {
1552
- const row = this.db()
1553
- .prepare('SELECT * FROM items_current WHERE item_id = ?')
1554
- .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;
1555
1751
  return row ? itemSummaryFromRow(row) : undefined;
1556
1752
  }
1557
-
1558
1753
  getReadableItemEvents(id: string): SynomemEvent[] {
1559
- const rows = this.db()
1560
- .prepare('SELECT id, payload FROM events WHERE aggregate_id = ? ORDER BY sequence ASC')
1561
- .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[];
1562
1757
  const events: SynomemEvent[] = [];
1563
1758
  for (const row of rows) {
1564
1759
  try {
@@ -1569,24 +1764,29 @@ export class SynomemStorage implements SynomemRepository {
1569
1764
  }
1570
1765
  return events;
1571
1766
  }
1572
-
1573
- currentIndexHealth(): { given: number; indexed: number; stateMismatches: number } {
1767
+ currentIndexHealth(): {
1768
+ given: number;
1769
+ indexed: number;
1770
+ stateMismatches: number;
1771
+ } {
1574
1772
  const given = Number(
1575
1773
  (
1576
- this.db()
1577
- .prepare("SELECT COUNT(*) AS count FROM events WHERE type = 'kudos.given'")
1578
- .get() as { count: number }
1774
+ this.prepare("SELECT COUNT(*) AS count FROM events WHERE type = 'kudos.given'").get() as {
1775
+ count: number;
1776
+ }
1579
1777
  ).count,
1580
1778
  );
1581
1779
  const indexed = Number(
1582
- (this.db().prepare('SELECT COUNT(*) AS count FROM kudos_current').get() as { count: number })
1583
- .count,
1780
+ (
1781
+ this.prepare('SELECT COUNT(*) AS count FROM kudos_current').get() as {
1782
+ count: number;
1783
+ }
1784
+ ).count,
1584
1785
  );
1585
1786
  const stateMismatches = Number(
1586
1787
  (
1587
- this.db()
1588
- .prepare(
1589
- `SELECT COUNT(*) AS count
1788
+ this.prepare(
1789
+ `SELECT COUNT(*) AS count
1590
1790
  FROM kudos_current k
1591
1791
  WHERE k.status != CASE WHEN EXISTS (
1592
1792
  SELECT 1 FROM events e
@@ -1596,45 +1796,56 @@ export class SynomemStorage implements SynomemRepository {
1596
1796
  SELECT 1 FROM events e
1597
1797
  WHERE e.type = 'kudos.revoked' AND e.kudos_id = k.kudos_id
1598
1798
  ) THEN 'revoked' ELSE 'active' END`,
1599
- )
1600
- .get() as { count: number }
1799
+ ).get() as {
1800
+ count: number;
1801
+ }
1601
1802
  ).count,
1602
1803
  );
1603
1804
  return { given, indexed, stateMismatches };
1604
1805
  }
1605
-
1606
- itemIndexHealth(): { created: number; indexed: number } {
1806
+ itemIndexHealth(): {
1807
+ created: number;
1808
+ indexed: number;
1809
+ } {
1607
1810
  const created = Number(
1608
1811
  (
1609
- this.db()
1610
- .prepare(
1611
- `SELECT COUNT(*) AS count FROM events WHERE type IN
1812
+ this.prepare(
1813
+ `SELECT COUNT(*) AS count FROM events WHERE type IN
1612
1814
  ('kudos.given', 'memo.sent', 'note.created', 'post.created', 'task.created')`,
1613
- )
1614
- .get() as { count: number }
1815
+ ).get() as {
1816
+ count: number;
1817
+ }
1615
1818
  ).count,
1616
1819
  );
1617
1820
  const indexed = Number(
1618
- (this.db().prepare('SELECT COUNT(*) AS count FROM items_current').get() as { count: number })
1619
- .count,
1821
+ (
1822
+ this.prepare('SELECT COUNT(*) AS count FROM items_current').get() as {
1823
+ count: number;
1824
+ }
1825
+ ).count,
1620
1826
  );
1621
1827
  return { created, indexed };
1622
1828
  }
1623
-
1624
- migrationState(): { schemaVersion: number; appliedVersions: number[] } {
1829
+ migrationState(): {
1830
+ schemaVersion: number;
1831
+ appliedVersions: number[];
1832
+ } {
1625
1833
  const schemaVersion = Number(
1626
- (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,
1627
1839
  );
1628
1840
  const appliedVersions = (
1629
- this.db()
1630
- .prepare('SELECT version FROM schema_migrations ORDER BY version')
1631
- .all() as unknown as Array<{
1841
+ this.prepare(
1842
+ 'SELECT version FROM schema_migrations ORDER BY version',
1843
+ ).all() as unknown as Array<{
1632
1844
  version: number;
1633
1845
  }>
1634
1846
  ).map((row) => Number(row.version));
1635
1847
  return { schemaVersion, appliedVersions };
1636
1848
  }
1637
-
1638
1849
  /**
1639
1850
  * Aliases that also name an agent directly.
1640
1851
  *
@@ -1643,63 +1854,55 @@ export class SynomemStorage implements SynomemRepository {
1643
1854
  * agent's handle — which is the collision that actually makes a lookup
1644
1855
  * ambiguous now.
1645
1856
  */
1646
- aliasIdentityConflicts(): Array<{ alias: string; agentId: string }> {
1647
- return this.db()
1648
- .prepare(
1649
- `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
1650
1863
  FROM aliases x
1651
1864
  JOIN agents a ON lower(a.id) = x.normalized_alias
1652
1865
  OR lower(a.handle) = x.normalized_alias
1653
1866
  WHERE a.id != x.agent_id
1654
1867
  ORDER BY x.alias`,
1655
- )
1656
- .all() as unknown as Array<{ alias: string; agentId: string }>;
1868
+ ).all() as unknown as Array<{
1869
+ alias: string;
1870
+ agentId: string;
1871
+ }>;
1657
1872
  }
1658
-
1659
- private maxEventSequence(): number {
1660
- return Number(
1661
- (
1662
- this.db().prepare('SELECT COALESCE(MAX(sequence), 0) AS sequence FROM events').get() as {
1663
- sequence: number;
1664
- }
1665
- ).sequence,
1666
- );
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;
1667
1879
  }
1668
-
1669
1880
  getEvent(id: string): SynomemEvent | undefined {
1670
- 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
1671
1882
  EventRow | undefined;
1672
1883
  return row ? this.parseEvent(row) : undefined;
1673
1884
  }
1674
-
1675
1885
  getEventByIdempotency(actorKind: string, actorId: string, key: string): SynomemEvent | undefined {
1676
- const row = this.db()
1677
- .prepare(
1678
- `SELECT id, payload FROM events
1886
+ const row = this.prepare(
1887
+ `SELECT id, payload FROM events
1679
1888
  WHERE actor_kind = ? AND actor_id = ? AND idempotency_key = ?`,
1680
- )
1681
- .get(actorKind, actorId, key) as EventRow | undefined;
1889
+ ).get(actorKind, actorId, key) as EventRow | undefined;
1682
1890
  return row ? this.parseEvent(row) : undefined;
1683
1891
  }
1684
-
1685
1892
  getEvents(): SynomemEvent[] {
1686
1893
  const scan = this.scanEvents();
1687
1894
  if (scan.invalid[0]) throw scan.invalid[0].error;
1688
1895
  return scan.events;
1689
1896
  }
1690
-
1691
1897
  getReadableEvents(): SynomemEvent[] {
1692
1898
  return this.scanEvents().events;
1693
1899
  }
1694
-
1695
1900
  getReadableSynomemEvents(kudosId: string): SynomemEvent[] {
1696
- const rows = this.db()
1697
- .prepare(
1698
- `SELECT id, payload FROM events
1901
+ const rows = this.prepare(
1902
+ `SELECT id, payload FROM events
1699
1903
  WHERE (type = 'kudos.given' AND id = ?) OR kudos_id = ?
1700
1904
  ORDER BY sequence ASC`,
1701
- )
1702
- .all(kudosId, kudosId) as unknown as EventRow[];
1905
+ ).all(kudosId, kudosId) as unknown as EventRow[];
1703
1906
  const events: SynomemEvent[] = [];
1704
1907
  for (const row of rows) {
1705
1908
  try {
@@ -1710,7 +1913,6 @@ export class SynomemStorage implements SynomemRepository {
1710
1913
  }
1711
1914
  return events;
1712
1915
  }
1713
-
1714
1916
  scanEvents(): EventScan {
1715
1917
  const rows = this.rawEventRows();
1716
1918
  const events: SynomemEvent[] = [];
@@ -1725,11 +1927,14 @@ export class SynomemStorage implements SynomemRepository {
1725
1927
  }
1726
1928
  return { events, invalid };
1727
1929
  }
1728
-
1729
1930
  assertEventCompatibility(): void {
1730
- const rows = this.db()
1731
- .prepare('SELECT id, payload, sequence FROM events WHERE sequence > ? ORDER BY sequence ASC')
1732
- .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
+ >;
1733
1938
  const invalid: EventScan['invalid'] = [];
1734
1939
  for (const row of rows) {
1735
1940
  try {
@@ -1749,19 +1954,24 @@ export class SynomemStorage implements SynomemRepository {
1749
1954
  { eventIds: invalid.map((item) => item.id) },
1750
1955
  );
1751
1956
  }
1752
-
1753
1957
  rawEventRows(): EventRow[] {
1754
- return this.db()
1755
- .prepare('SELECT id, payload FROM events ORDER BY sequence ASC')
1756
- .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[];
1757
1961
  }
1758
-
1759
1962
  private parseEvent(row: EventRow): SynomemEvent {
1760
1963
  try {
1761
- let value = JSON.parse(row.payload) as unknown;
1964
+ const value = JSON.parse(row.payload) as unknown;
1762
1965
  if (typeof value === 'object' && value !== null) {
1763
- const candidate = value as { schemaVersion?: unknown; type?: unknown };
1966
+ const candidate = value as {
1967
+ schemaVersion?: unknown;
1968
+ type?: unknown;
1969
+ };
1764
1970
  const supportedTypes = new Set([
1971
+ 'reply.created',
1972
+ 'reply.deleted',
1973
+ 'reaction.added',
1974
+ 'reaction.removed',
1765
1975
  'agent.created',
1766
1976
  'agent.updated',
1767
1977
  'topic.created',
@@ -1787,6 +1997,7 @@ export class SynomemStorage implements SynomemRepository {
1787
1997
  'task.accepted',
1788
1998
  'task.rejected',
1789
1999
  'task.canceled',
2000
+ 'task.decision_overridden',
1790
2001
  'todo.created',
1791
2002
  'todo.updated',
1792
2003
  'todo.completed',
@@ -1795,7 +2006,7 @@ export class SynomemStorage implements SynomemRepository {
1795
2006
  'todo.archived',
1796
2007
  ]);
1797
2008
  if (
1798
- (typeof candidate.schemaVersion === 'number' && candidate.schemaVersion > 1) ||
2009
+ (typeof candidate.schemaVersion === 'number' && candidate.schemaVersion > 2) ||
1799
2010
  (typeof candidate.type === 'string' && !supportedTypes.has(candidate.type))
1800
2011
  ) {
1801
2012
  throw new SynomemError(
@@ -1803,30 +2014,6 @@ export class SynomemStorage implements SynomemRepository {
1803
2014
  `Event ${row.id} was written by a newer or incompatible Synomem version.`,
1804
2015
  );
1805
2016
  }
1806
- const legacy = candidate as Record<string, unknown>;
1807
- if (legacy.workspaceId === undefined) legacy.workspaceId = 'legacy-local';
1808
- if (legacy.aggregateId === undefined) {
1809
- legacy.aggregateId =
1810
- legacy.kudosId ??
1811
- legacy.memoId ??
1812
- legacy.noteId ??
1813
- legacy.taskId ??
1814
- (typeof legacy.agentId === 'string' ? legacy.agentId : undefined) ??
1815
- (typeof legacy.agent === 'object' && legacy.agent !== null
1816
- ? (legacy.agent as { id?: unknown }).id
1817
- : undefined) ??
1818
- legacy.id;
1819
- }
1820
- if (legacy.aggregateVersion === undefined) {
1821
- legacy.aggregateVersion =
1822
- String(legacy.type).endsWith('.given') ||
1823
- String(legacy.type).endsWith('.sent') ||
1824
- String(legacy.type).endsWith('.created')
1825
- ? 1
1826
- : 2;
1827
- }
1828
- if (legacy.visibility === 'local') legacy.visibility = 'workspace';
1829
- value = legacy;
1830
2017
  }
1831
2018
  return eventSchema.parse(value);
1832
2019
  } catch (error) {
@@ -1840,50 +2027,216 @@ export class SynomemStorage implements SynomemRepository {
1840
2027
  );
1841
2028
  }
1842
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
+ }
1843
2203
 
1844
2204
  insertAgent(profile: AgentProfile): void {
1845
2205
  const parsed = profileSchema.parse(profile);
1846
- this.db()
1847
- .prepare(
1848
- `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)
1849
2208
  VALUES (?, ?, ?, ?, ?, ?, ?)`,
1850
- )
1851
- .run(
1852
- parsed.id,
1853
- parsed.handle,
1854
- parsed.status,
1855
- parsed.displayName,
1856
- JSON.stringify(parsed),
1857
- parsed.createdAt,
1858
- parsed.createdAt,
1859
- );
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
+ );
1860
2218
  this.insertAliases(parsed.id, parsed.aliases ?? []);
1861
2219
  }
1862
-
1863
2220
  updateAgent(profile: AgentProfile, updatedAt: string): void {
1864
2221
  const parsed = profileSchema.parse(profile);
1865
- this.db()
1866
- .prepare(
1867
- `UPDATE agents SET handle = ?, status = ?, display_name = ?, profile_json = ?,
2222
+ this.prepare(
2223
+ `UPDATE agents SET handle = ?, status = ?, display_name = ?, profile_json = ?,
1868
2224
  updated_at = ? WHERE id = ?`,
1869
- )
1870
- .run(
1871
- parsed.handle,
1872
- parsed.status,
1873
- parsed.displayName,
1874
- JSON.stringify(parsed),
1875
- updatedAt,
1876
- parsed.id,
1877
- );
1878
- 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);
1879
2234
  this.insertAliases(parsed.id, parsed.aliases ?? []);
1880
2235
  }
1881
-
1882
2236
  getAgent(idOrAlias: string): AgentProfile | undefined {
1883
2237
  const resolved = this.resolveAgent(idOrAlias);
1884
2238
  return resolved.match;
1885
2239
  }
1886
-
1887
2240
  /**
1888
2241
  * Resolves a name to a canonical agent, reporting ambiguity rather than
1889
2242
  * guessing.
@@ -1894,78 +2247,74 @@ export class SynomemStorage implements SynomemRepository {
1894
2247
  * case — silently preferring the ID would attribute work to the wrong agent,
1895
2248
  * and the person who typed the name would never know.
1896
2249
  */
1897
- resolveAgent(query: string): { match?: AgentProfile; candidates: AgentProfile[] } {
2250
+ resolveAgent(query: string): {
2251
+ match?: AgentProfile;
2252
+ candidates: AgentProfile[];
2253
+ } {
1898
2254
  const normalized = query.trim().toLowerCase();
1899
- const rows = this.db()
1900
- .prepare(
1901
- `SELECT DISTINCT a.profile_json
2255
+ const rows = this.prepare(
2256
+ `SELECT DISTINCT a.profile_json
1902
2257
  FROM agents a
1903
2258
  LEFT JOIN aliases x ON x.agent_id = a.id
1904
2259
  WHERE lower(a.id) = ? OR lower(a.handle) = ? OR x.normalized_alias = ?
1905
2260
  ORDER BY a.id ASC`,
1906
- )
1907
- .all(normalized, normalized, normalized) as unknown as ProfileRow[];
2261
+ ).all(normalized, normalized, normalized) as unknown as ProfileRow[];
1908
2262
  const candidates = rows.map((row) => profileSchema.parse(JSON.parse(row.profile_json)));
1909
2263
  return candidates.length === 1 ? { match: candidates[0]!, candidates } : { candidates };
1910
2264
  }
1911
-
1912
2265
  /* -------------------------------------------------------------------- topics */
1913
-
1914
2266
  insertTopic(topic: Topic): void {
1915
2267
  const parsed = topicProfileSchema.parse(topic);
1916
- this.db()
1917
- .prepare(
1918
- `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)
1919
2270
  VALUES (?, ?, ?, ?, ?)`,
1920
- )
1921
- .run(parsed.id, parsed.displayName, parsed.status, parsed.createdAt, parsed.createdAt);
2271
+ ).run(parsed.id, parsed.displayName, parsed.status, parsed.createdAt, parsed.createdAt);
1922
2272
  this.insertTopicAliases(parsed.id, parsed.aliases ?? []);
1923
2273
  }
1924
-
1925
2274
  updateTopic(topic: Topic, updatedAt: string): void {
1926
2275
  const parsed = topicProfileSchema.parse(topic);
1927
- this.db()
1928
- .prepare('UPDATE topics SET display_name = ?, status = ?, updated_at = ? WHERE id = ?')
1929
- .run(parsed.displayName, parsed.status, updatedAt, parsed.id);
1930
- 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);
1931
2283
  this.insertTopicAliases(parsed.id, parsed.aliases ?? []);
1932
2284
  }
1933
-
1934
2285
  getTopic(idOrAlias: string): Topic | undefined {
1935
2286
  return this.resolveTopic(idOrAlias).match;
1936
2287
  }
1937
-
1938
2288
  listTopics(status?: 'active' | 'archived'): Topic[] {
1939
2289
  const rows = (status
1940
- ? this.db()
1941
- .prepare('SELECT * FROM topics WHERE status = ? ORDER BY display_name ASC')
1942
- .all(status)
1943
- : this.db()
1944
- .prepare('SELECT * FROM topics ORDER BY display_name ASC')
1945
- .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[];
1946
2294
  return rows.map((row) => this.topicFromRow(row));
1947
2295
  }
1948
-
1949
2296
  /** Resolves a name case-insensitively, reporting ambiguity instead of guessing. */
1950
- resolveTopic(query: string): { match?: Topic; candidates: Topic[] } {
2297
+ resolveTopic(query: string): {
2298
+ match?: Topic;
2299
+ candidates: Topic[];
2300
+ } {
1951
2301
  const normalized = query.trim().toLowerCase();
1952
- const rows = this.db()
1953
- .prepare(
1954
- `SELECT DISTINCT t.*
2302
+ const rows = this.prepare(
2303
+ `SELECT DISTINCT t.*
1955
2304
  FROM topics t
1956
2305
  LEFT JOIN topic_aliases x ON x.topic_id = t.id
1957
2306
  WHERE lower(t.id) = ? OR lower(t.display_name) = ? OR x.alias = ?
1958
2307
  ORDER BY t.id ASC`,
1959
- )
1960
- .all(normalized, normalized, normalized) as unknown as TopicRow[];
2308
+ ).all(normalized, normalized, normalized) as unknown as TopicRow[];
1961
2309
  const candidates = rows.map((row) => this.topicFromRow(row));
1962
2310
  return candidates.length === 1 ? { match: candidates[0]!, candidates } : { candidates };
1963
2311
  }
1964
-
1965
2312
  private topicFromRow(row: TopicRow): Topic {
1966
- const aliasRows = this.db()
1967
- .prepare('SELECT alias FROM topic_aliases WHERE topic_id = ? ORDER BY alias ASC')
1968
- .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
+ }>;
1969
2318
  const aliases = aliasRows.map((aliasRow) => aliasRow.alias);
1970
2319
  return {
1971
2320
  id: row.id,
@@ -1975,24 +2324,20 @@ export class SynomemStorage implements SynomemRepository {
1975
2324
  createdAt: row.created_at,
1976
2325
  };
1977
2326
  }
1978
-
1979
2327
  private insertTopicAliases(topicId: string, aliases: string[]): void {
1980
2328
  for (const alias of aliases) {
1981
- this.db()
1982
- .prepare('INSERT INTO topic_aliases(alias, topic_id) VALUES (?, ?)')
1983
- .run(alias.trim().toLowerCase(), topicId);
2329
+ this.prepare('INSERT INTO topic_aliases(alias, topic_id) VALUES (?, ?)').run(
2330
+ alias.trim().toLowerCase(),
2331
+ topicId,
2332
+ );
1984
2333
  }
1985
2334
  }
1986
-
1987
2335
  /* -------------------------------------------------------- post acknowledgment */
1988
-
1989
2336
  listPostAcknowledgments(postId: string): PostAcknowledgment[] {
1990
- const rows = this.db()
1991
- .prepare(
1992
- `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
1993
2339
  FROM post_acknowledgments WHERE post_id = ? ORDER BY acknowledged_at ASC`,
1994
- )
1995
- .all(postId) as unknown as Array<{
2340
+ ).all(postId) as unknown as Array<{
1996
2341
  actor_kind: string;
1997
2342
  actor_id: string;
1998
2343
  actor_display_name: string | null;
@@ -2009,7 +2354,6 @@ export class SynomemStorage implements SynomemRepository {
2009
2354
  ...(row.note ? { note: row.note } : {}),
2010
2355
  }));
2011
2356
  }
2012
-
2013
2357
  /**
2014
2358
  * Who has acknowledged a post and who has not.
2015
2359
  *
@@ -2019,36 +2363,36 @@ export class SynomemStorage implements SynomemRepository {
2019
2363
  * newcomer of ignoring something written before it arrived.
2020
2364
  */
2021
2365
  postRoster(postId: string): PostRoster | undefined {
2022
- const post = this.db()
2023
- .prepare("SELECT created_at FROM items_current WHERE item_id = ? AND kind = 'post'")
2024
- .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;
2025
2373
  if (!post) return undefined;
2026
-
2027
2374
  const acknowledged = this.listPostAcknowledgments(postId);
2028
2375
  const acknowledgedIds = new Set(acknowledged.map((entry) => entry.actor.id));
2029
-
2030
- const eligible = this.db()
2031
- .prepare('SELECT id, display_name, created_at FROM agents ORDER BY id ASC')
2032
- .all() as unknown as Array<{ id: string; display_name: string; created_at: string }>;
2033
-
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
+ }>;
2034
2383
  const outstanding = eligible
2035
2384
  .filter((agent) => agent.created_at <= post.created_at && !acknowledgedIds.has(agent.id))
2036
2385
  .map((agent) => ({ id: agent.id, displayName: agent.display_name }));
2037
2386
  const joinedSince = eligible.filter((agent) => agent.created_at > post.created_at).length;
2038
-
2039
2387
  return { postId, acknowledged, outstanding, joinedSince };
2040
2388
  }
2041
-
2042
2389
  /* ------------------------------------------------------- runtime bindings */
2043
-
2044
2390
  listRuntimeBindings(agentId: string): AgentRuntimeBinding[] {
2045
- const rows = this.db()
2046
- .prepare(
2047
- `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,
2048
2393
  bound_at, last_seen_at
2049
2394
  FROM agent_runtime_bindings WHERE agent_id = ? ORDER BY bound_at ASC`,
2050
- )
2051
- .all(agentId) as unknown as Array<{
2395
+ ).all(agentId) as unknown as Array<{
2052
2396
  id: string;
2053
2397
  agent_id: string;
2054
2398
  installation_id: string | null;
@@ -2069,7 +2413,6 @@ export class SynomemStorage implements SynomemRepository {
2069
2413
  ...(row.last_seen_at ? { lastSeenAt: row.last_seen_at } : {}),
2070
2414
  }));
2071
2415
  }
2072
-
2073
2416
  bindRuntime(binding: {
2074
2417
  id: string;
2075
2418
  agentId: string;
@@ -2079,44 +2422,35 @@ export class SynomemStorage implements SynomemRepository {
2079
2422
  capabilities?: Record<string, JsonValue>;
2080
2423
  boundAt: string;
2081
2424
  }): void {
2082
- this.db()
2083
- .prepare(
2084
- `INSERT INTO agent_runtime_bindings(
2425
+ this.prepare(
2426
+ `INSERT INTO agent_runtime_bindings(
2085
2427
  id, agent_id, installation_id, runtime, profile, capabilities_json, bound_at
2086
2428
  ) VALUES (?, ?, ?, ?, ?, ?, ?)
2087
2429
  ON CONFLICT(agent_id, runtime, COALESCE(profile, ''), COALESCE(installation_id, ''))
2088
2430
  DO UPDATE SET capabilities_json = excluded.capabilities_json`,
2089
- )
2090
- .run(
2091
- binding.id,
2092
- binding.agentId,
2093
- binding.installationId ?? null,
2094
- binding.runtime,
2095
- binding.profile ?? null,
2096
- JSON.stringify(binding.capabilities ?? {}),
2097
- binding.boundAt,
2098
- );
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
+ );
2099
2440
  }
2100
-
2101
2441
  unbindRuntime(bindingId: string): boolean {
2102
- const result = this.db()
2103
- .prepare('DELETE FROM agent_runtime_bindings WHERE id = ?')
2104
- .run(bindingId);
2442
+ const result = this.prepare('DELETE FROM agent_runtime_bindings WHERE id = ?').run(bindingId);
2105
2443
  return Number(result.changes) > 0;
2106
2444
  }
2107
-
2108
2445
  /**
2109
2446
  * Advisory only. Records that Synomem observed this binding act — never that
2110
2447
  * the runtime is reachable now, and never that a delivery succeeded.
2111
2448
  */
2112
2449
  touchRuntimeBinding(agentId: string, runtime: string, at: string): void {
2113
- this.db()
2114
- .prepare(
2115
- 'UPDATE agent_runtime_bindings SET last_seen_at = ? WHERE agent_id = ? AND runtime = ?',
2116
- )
2117
- .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);
2118
2453
  }
2119
-
2120
2454
  /**
2121
2455
  * Writes an agent's aliases, refusing any that would make a name ambiguous.
2122
2456
  *
@@ -2129,47 +2463,54 @@ export class SynomemStorage implements SynomemRepository {
2129
2463
  private insertAliases(agentId: string, aliases: string[]): void {
2130
2464
  for (const alias of aliases) {
2131
2465
  const normalized = alias.trim().toLowerCase();
2132
- const conflictingAgent = this.db()
2133
- .prepare('SELECT id FROM agents WHERE lower(id) = ? AND id != ?')
2134
- .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;
2135
2473
  if (conflictingAgent) {
2136
2474
  throw new SynomemError(
2137
2475
  'ALIAS_CONFLICT',
2138
2476
  `Alias "${alias}" is already the canonical ID of agent ${conflictingAgent.id}. Aliases must resolve to exactly one agent.`,
2139
2477
  );
2140
2478
  }
2141
- const conflictingAlias = this.db()
2142
- .prepare('SELECT agent_id FROM aliases WHERE normalized_alias = ? AND agent_id != ?')
2143
- .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;
2144
2486
  if (conflictingAlias) {
2145
2487
  throw new SynomemError(
2146
2488
  'ALIAS_CONFLICT',
2147
2489
  `Alias "${alias}" already belongs to agent ${conflictingAlias.agent_id}. Aliases must resolve to exactly one agent.`,
2148
2490
  );
2149
2491
  }
2150
- this.db()
2151
- .prepare('INSERT INTO aliases(alias, agent_id, normalized_alias) VALUES (?, ?, ?)')
2152
- .run(alias, agentId, normalized);
2492
+ this.prepare('INSERT INTO aliases(alias, agent_id, normalized_alias) VALUES (?, ?, ?)').run(
2493
+ alias,
2494
+ agentId,
2495
+ normalized,
2496
+ );
2153
2497
  }
2154
2498
  }
2155
-
2156
2499
  listAgents(): AgentProfile[] {
2157
- const rows = this.db()
2158
- .prepare('SELECT profile_json FROM agents ORDER BY id ASC')
2159
- .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[];
2160
2503
  return rows.map((row) => profileSchema.parse(JSON.parse(row.profile_json)));
2161
2504
  }
2162
-
2163
2505
  replaceProjectionManifest(paths: string[], generatedAt: string): void {
2164
2506
  this.transactionSync(() => {
2165
- this.db().prepare('DELETE FROM projection_manifest').run();
2166
- const insert = this.db().prepare(
2507
+ this.prepare('DELETE FROM projection_manifest').run();
2508
+ const insert = this.prepare(
2167
2509
  'INSERT INTO projection_manifest(path, generated_at) VALUES (?, ?)',
2168
2510
  );
2169
2511
  for (const path of paths) insert.run(path, generatedAt);
2170
2512
  });
2171
2513
  }
2172
-
2173
2514
  /**
2174
2515
  * @param directory the agent's projection directory name, which is its
2175
2516
  * handle rather than its canonical ID: these rows are keyed by the path on
@@ -2177,24 +2518,23 @@ export class SynomemStorage implements SynomemRepository {
2177
2518
  */
2178
2519
  replaceAgentProjectionManifest(directory: string, paths: string[], generatedAt: string): void {
2179
2520
  this.transactionSync(() => {
2180
- this.db()
2181
- .prepare('DELETE FROM projection_manifest WHERE path LIKE ? OR path LIKE ?')
2182
- .run(`${directory}/%`, `${directory}\\%`);
2183
- 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(
2184
2526
  'INSERT INTO projection_manifest(path, generated_at) VALUES (?, ?)',
2185
2527
  );
2186
2528
  for (const path of paths) insert.run(path, generatedAt);
2187
2529
  });
2188
2530
  }
2189
-
2190
2531
  projectionManifest(): string[] {
2191
2532
  return (
2192
- 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 {
2193
2534
  path: string;
2194
2535
  }[]
2195
2536
  ).map((row) => row.path);
2196
2537
  }
2197
-
2198
2538
  /**
2199
2539
  * Re-point manifest paths from one agent directory to another.
2200
2540
  *
@@ -2213,8 +2553,8 @@ export class SynomemStorage implements SynomemRepository {
2213
2553
  prefixes.some((prefix) => entry.path.startsWith(prefix)),
2214
2554
  );
2215
2555
  if (!rows.length) return;
2216
- const remove = this.db().prepare('DELETE FROM projection_manifest WHERE path = ?');
2217
- const insert = this.db().prepare(
2556
+ const remove = this.prepare('DELETE FROM projection_manifest WHERE path = ?');
2557
+ const insert = this.prepare(
2218
2558
  'INSERT OR REPLACE INTO projection_manifest(path, generated_at) VALUES (?, ?)',
2219
2559
  );
2220
2560
  for (const row of rows) {
@@ -2222,29 +2562,31 @@ export class SynomemStorage implements SynomemRepository {
2222
2562
  insert.run(`${nextHandle}${row.path.slice(previousHandle.length)}`, row.generatedAt);
2223
2563
  }
2224
2564
  }
2225
-
2226
2565
  /** The manifest with the time each path was written, newest first. */
2227
- projectionManifestEntries(): { path: string; generatedAt: string }[] {
2566
+ projectionManifestEntries(): {
2567
+ path: string;
2568
+ generatedAt: string;
2569
+ }[] {
2228
2570
  return (
2229
- this.db()
2230
- .prepare('SELECT path, generated_at FROM projection_manifest ORDER BY generated_at DESC')
2231
- .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
+ }[]
2232
2577
  ).map((row) => ({ path: row.path, generatedAt: row.generated_at }));
2233
2578
  }
2234
-
2235
2579
  integrityCheck(): string[] {
2236
- 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<
2237
2581
  string,
2238
2582
  string
2239
2583
  >[];
2240
2584
  return rows.map((row) => Object.values(row)[0] ?? 'unknown');
2241
2585
  }
2242
-
2243
2586
  journalMode(): string {
2244
- 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>;
2245
2588
  return Object.values(row)[0] ?? 'unknown';
2246
2589
  }
2247
-
2248
2590
  async backup(destination: string): Promise<string> {
2249
2591
  this.assertWritable();
2250
2592
  const output = resolve(destination);
@@ -2270,7 +2612,6 @@ export class SynomemStorage implements SynomemRepository {
2270
2612
  rmdirSync(temporaryDirectory);
2271
2613
  }
2272
2614
  }
2273
-
2274
2615
  private restrictDatabaseFiles(): void {
2275
2616
  if (this.readOnly) return;
2276
2617
  for (const path of [
@@ -2281,7 +2622,6 @@ export class SynomemStorage implements SynomemRepository {
2281
2622
  if (existsSync(path)) chmodSync(path, 0o600);
2282
2623
  }
2283
2624
  }
2284
-
2285
2625
  close(): void {
2286
2626
  this.database?.close();
2287
2627
  this.database = undefined;