synomem 0.9.6 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/README.md +5 -3
- package/dist/actor-directory.d.ts +34 -0
- package/dist/actor-directory.d.ts.map +1 -0
- package/dist/actor-directory.js +52 -0
- package/dist/actor-directory.js.map +1 -0
- package/dist/backend.d.ts +3 -0
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +26 -3
- package/dist/backend.js.map +1 -1
- package/dist/bookmarks.d.ts +30 -0
- package/dist/bookmarks.d.ts.map +1 -0
- package/dist/bookmarks.js +87 -0
- package/dist/bookmarks.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +291 -61
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +115 -4
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +854 -138
- package/dist/client.js.map +1 -1
- package/dist/cursors.d.ts +30 -0
- package/dist/cursors.d.ts.map +1 -0
- package/dist/cursors.js +101 -0
- package/dist/cursors.js.map +1 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -0
- package/dist/errors.js.map +1 -1
- package/dist/import.d.ts +605 -39
- package/dist/import.d.ts.map +1 -1
- package/dist/index.d.ts +19 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/index.d.ts +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +329 -22
- package/dist/mcp/index.js.map +1 -1
- package/dist/mutation-receipts.d.ts +8 -0
- package/dist/mutation-receipts.d.ts.map +1 -0
- package/dist/mutation-receipts.js +2 -0
- package/dist/mutation-receipts.js.map +1 -0
- package/dist/notifications.d.ts +73 -0
- package/dist/notifications.d.ts.map +1 -0
- package/dist/notifications.js +309 -0
- package/dist/notifications.js.map +1 -0
- package/dist/participation-repository.d.ts +46 -0
- package/dist/participation-repository.d.ts.map +1 -0
- package/dist/participation-repository.js +296 -0
- package/dist/participation-repository.js.map +1 -0
- package/dist/participation.d.ts +97 -0
- package/dist/participation.d.ts.map +1 -0
- package/dist/participation.js +10 -0
- package/dist/participation.js.map +1 -0
- package/dist/policy.d.ts +31 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +71 -0
- package/dist/policy.js.map +1 -0
- package/dist/ports/repository.d.ts +23 -0
- package/dist/ports/repository.d.ts.map +1 -1
- package/dist/profiles.d.ts +12 -0
- package/dist/profiles.d.ts.map +1 -1
- package/dist/profiles.js +2 -1
- package/dist/profiles.js.map +1 -1
- package/dist/projections.d.ts.map +1 -1
- package/dist/projections.js +29 -11
- package/dist/projections.js.map +1 -1
- package/dist/remote.d.ts +79 -0
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +65 -6
- package/dist/remote.js.map +1 -1
- package/dist/schemas.d.ts +746 -58
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +114 -16
- package/dist/schemas.js.map +1 -1
- package/dist/search.d.ts +25 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +2 -0
- package/dist/search.js.map +1 -0
- package/dist/service.d.ts +89 -0
- package/dist/service.d.ts.map +1 -1
- package/dist/storage.d.ts +38 -3
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +494 -365
- package/dist/storage.js.map +1 -1
- package/dist/types.d.ts +65 -34
- package/dist/types.d.ts.map +1 -1
- package/docs/cli.md +6 -0
- package/docs/human-participation.md +20 -0
- package/docs/mcp.md +2 -0
- package/docs/storage-format.md +4 -15
- package/openapi/synomem-v1.yaml +187 -10
- package/package.json +2 -1
- package/skills/synomem/SKILL.md +2 -0
- package/src/actor-directory.ts +87 -0
- package/src/backend.ts +29 -3
- package/src/bookmarks.ts +127 -0
- package/src/cli.ts +941 -546
- package/src/client.ts +1143 -279
- package/src/cursors.ts +132 -0
- package/src/errors.ts +2 -0
- package/src/index.ts +50 -0
- package/src/mcp/index.ts +412 -96
- package/src/mutation-receipts.ts +7 -0
- package/src/notifications.ts +483 -0
- package/src/participation-repository.ts +406 -0
- package/src/participation.ts +103 -0
- package/src/policy.ts +109 -0
- package/src/ports/repository.ts +33 -0
- package/src/profiles.ts +2 -1
- package/src/projections.ts +49 -48
- package/src/remote.ts +227 -37
- package/src/schemas.ts +116 -16
- package/src/search.ts +24 -0
- package/src/service.ts +122 -8
- package/src/storage.ts +1039 -697
- 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?:
|
|
76
|
+
sequence?: bigint;
|
|
53
77
|
}
|
|
54
|
-
|
|
55
78
|
interface CurrentRow {
|
|
56
79
|
kudos_id: string;
|
|
57
|
-
given_sequence:
|
|
80
|
+
given_sequence: bigint;
|
|
58
81
|
created_at: string;
|
|
59
|
-
|
|
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:
|
|
94
|
+
updated_sequence: bigint;
|
|
71
95
|
}
|
|
72
|
-
|
|
73
96
|
interface ItemRow {
|
|
74
97
|
item_id: string;
|
|
75
98
|
kind: RecordKind;
|
|
76
|
-
created_sequence:
|
|
77
|
-
updated_sequence:
|
|
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
|
-
|
|
111
|
+
recipient_kind: 'human' | 'agent';
|
|
112
|
+
recipient_id: string | null;
|
|
89
113
|
recipient_display_name: string | null;
|
|
90
|
-
|
|
114
|
+
owner_kind: 'human' | 'agent' | null;
|
|
115
|
+
owner_id: string | null;
|
|
91
116
|
owner_display_name: string | null;
|
|
92
|
-
|
|
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<{
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
375
|
+
recipient_kind TEXT NOT NULL DEFAULT 'agent',
|
|
376
|
+
recipient_id TEXT,
|
|
357
377
|
recipient_display_name TEXT,
|
|
358
|
-
|
|
378
|
+
owner_kind TEXT,
|
|
379
|
+
owner_id TEXT,
|
|
359
380
|
owner_display_name TEXT,
|
|
360
|
-
|
|
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(
|
|
365
|
-
CREATE INDEX items_current_owner ON items_current(
|
|
366
|
-
CREATE INDEX items_current_assignee ON items_current(
|
|
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.
|
|
420
|
-
...(row.
|
|
421
|
-
...(row.
|
|
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
|
-
|
|
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 =
|
|
480
|
-
private
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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<{
|
|
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
|
-
(
|
|
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
|
|
744
|
-
|
|
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.
|
|
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 =
|
|
793
|
-
(
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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.
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
.
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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.
|
|
856
|
-
|
|
857
|
-
|
|
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.
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
|
|
1078
|
+
recipient?: ActorRef;
|
|
878
1079
|
recipientDisplayName?: string;
|
|
879
|
-
|
|
1080
|
+
owner?: ActorRef;
|
|
880
1081
|
ownerDisplayName?: string;
|
|
881
|
-
|
|
1082
|
+
assignee?: ActorRef;
|
|
882
1083
|
assigneeDisplayName?: string;
|
|
883
1084
|
dueAt?: string;
|
|
884
1085
|
}): void => {
|
|
885
|
-
this.
|
|
886
|
-
|
|
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
|
-
|
|
891
|
-
|
|
1090
|
+
recipient_id, recipient_display_name, owner_id, owner_display_name,
|
|
1091
|
+
assignee_id, assignee_display_name, due_at
|
|
892
1092
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
893
|
-
|
|
894
|
-
.
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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.
|
|
920
|
-
|
|
921
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
980
|
-
|
|
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
|
-
.
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
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.
|
|
998
|
-
|
|
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
|
-
.
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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.
|
|
1016
|
-
|
|
1017
|
-
|
|
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.
|
|
1022
|
-
|
|
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
|
-
.
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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
|
-
|
|
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.
|
|
1050
|
-
|
|
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
|
-
.
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
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
|
-
|
|
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.
|
|
1086
|
-
|
|
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
|
-
.
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
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.
|
|
1107
|
-
const rows = this.
|
|
1108
|
-
|
|
1109
|
-
|
|
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.
|
|
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.
|
|
1121
|
-
const rows = this.
|
|
1122
|
-
|
|
1123
|
-
|
|
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.
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1170
|
-
|
|
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.
|
|
1191
|
-
|
|
1192
|
-
|
|
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 =
|
|
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.
|
|
1205
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
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.
|
|
1255
|
-
.
|
|
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 =
|
|
1496
|
+
const sequence = row.sequence!;
|
|
1281
1497
|
let actor: ActorIdentity;
|
|
1282
1498
|
try {
|
|
1283
|
-
const payload = JSON.parse(row.payload) as {
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
1566
|
+
if (input.participant?.id) {
|
|
1337
1567
|
add(
|
|
1338
|
-
'(
|
|
1339
|
-
input.
|
|
1340
|
-
input.
|
|
1341
|
-
input.
|
|
1568
|
+
'((recipient_kind=? AND recipient_id=?) OR (owner_kind=? AND owner_id=?) OR (assignee_kind=? AND assignee_id=?))',
|
|
1569
|
+
input.participant.kind,
|
|
1570
|
+
input.participant.id,
|
|
1571
|
+
input.participant.kind,
|
|
1572
|
+
input.participant.id,
|
|
1573
|
+
input.participant.kind,
|
|
1574
|
+
input.participant.id,
|
|
1342
1575
|
);
|
|
1343
1576
|
}
|
|
1344
1577
|
if (input.actorId) add('actor_id = ?', input.actorId);
|
|
@@ -1355,7 +1588,6 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
1355
1588
|
if (input.visibility) add('visibility = ?', input.visibility);
|
|
1356
1589
|
if (input.from) add('created_at >= ?', input.from);
|
|
1357
1590
|
if (input.to) add('created_at <= ?', input.to);
|
|
1358
|
-
|
|
1359
1591
|
// Unanswered discovery: items still waiting for somebody to respond. Kept
|
|
1360
1592
|
// distinct from `pending`, which also counts accepted-and-in-progress work.
|
|
1361
1593
|
if (input.awaitingResponse) {
|
|
@@ -1364,7 +1596,6 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
1364
1596
|
(kind = 'task' AND status = 'assigned'))`);
|
|
1365
1597
|
}
|
|
1366
1598
|
if (input.awaitingSince) add('created_at <= ?', input.awaitingSince);
|
|
1367
|
-
|
|
1368
1599
|
// Overdue discovery: a deadline that has passed, on work still open. A
|
|
1369
1600
|
// completed or canceled item is not overdue, however late it was.
|
|
1370
1601
|
if (input.overdueAsOf) {
|
|
@@ -1373,64 +1604,32 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
1373
1604
|
input.overdueAsOf,
|
|
1374
1605
|
);
|
|
1375
1606
|
}
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
* A todo is hidden from other agents, never from the human operating this home: humans are
|
|
1379
|
-
* exempt from every private-record rule here (they are this store's administrators), and the
|
|
1380
|
-
* hosted API grants the same to workspace owners and admins. Agents and system actors see only
|
|
1381
|
-
* their own todos.
|
|
1382
|
-
*/
|
|
1383
|
-
if (viewer.kind !== 'human') {
|
|
1384
|
-
add(
|
|
1385
|
-
`(kind != 'todo' OR (owner_agent_id = ? AND ? = 'agent') OR (actor_id = ? AND actor_kind = ?))`,
|
|
1386
|
-
viewer.id,
|
|
1387
|
-
viewer.kind,
|
|
1388
|
-
viewer.id,
|
|
1389
|
-
viewer.kind,
|
|
1390
|
-
);
|
|
1391
|
-
}
|
|
1392
|
-
|
|
1393
|
-
if (viewer.kind !== 'human') {
|
|
1394
|
-
if (viewer.kind === 'agent') {
|
|
1395
|
-
add(
|
|
1396
|
-
`(visibility != 'private' OR (actor_kind = ? AND actor_id = ?) OR
|
|
1397
|
-
recipient_agent_id = ? OR owner_agent_id = ? OR assignee_agent_id = ?)`,
|
|
1398
|
-
viewer.kind,
|
|
1399
|
-
viewer.id,
|
|
1400
|
-
viewer.id,
|
|
1401
|
-
viewer.id,
|
|
1402
|
-
viewer.id,
|
|
1403
|
-
);
|
|
1404
|
-
} else {
|
|
1405
|
-
add(
|
|
1406
|
-
`(visibility != 'private' OR (actor_kind = ? AND actor_id = ?))`,
|
|
1407
|
-
viewer.kind,
|
|
1408
|
-
viewer.id,
|
|
1409
|
-
);
|
|
1410
|
-
}
|
|
1411
|
-
}
|
|
1607
|
+
const privacy = recordVisibilityPredicate(viewer, this.authority);
|
|
1608
|
+
add(privacy.sql, ...privacy.values);
|
|
1412
1609
|
const baseWhere = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
1413
1610
|
const total = Number(
|
|
1414
1611
|
(
|
|
1415
|
-
this.
|
|
1416
|
-
|
|
1417
|
-
|
|
1612
|
+
this.prepare(`SELECT COUNT(*) AS count FROM items_current ${baseWhere}`).get(
|
|
1613
|
+
...parameters,
|
|
1614
|
+
) as {
|
|
1615
|
+
count: number;
|
|
1616
|
+
}
|
|
1418
1617
|
).count,
|
|
1419
1618
|
);
|
|
1420
|
-
const cursorSequence =
|
|
1619
|
+
const cursorSequence = position?.sequence;
|
|
1421
1620
|
const pageWhere = [...where];
|
|
1422
1621
|
const pageParameters = [...parameters];
|
|
1423
1622
|
if (cursorSequence !== undefined) {
|
|
1424
1623
|
pageWhere.push('created_sequence < ?');
|
|
1425
1624
|
pageParameters.push(cursorSequence);
|
|
1426
1625
|
}
|
|
1626
|
+
pageWhere.push('created_sequence<=? AND updated_sequence<=?');
|
|
1627
|
+
pageParameters.push(high, high);
|
|
1427
1628
|
const sqlWhere = pageWhere.length ? `WHERE ${pageWhere.join(' AND ')}` : '';
|
|
1428
1629
|
const offset = cursorSequence === undefined ? input.offset : 0;
|
|
1429
|
-
const rows = this.
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
)
|
|
1433
|
-
.all(...pageParameters, input.limit + 1, offset) as unknown as ItemRow[];
|
|
1630
|
+
const rows = this.prepare(
|
|
1631
|
+
`SELECT * FROM items_current ${sqlWhere} ORDER BY created_sequence DESC LIMIT ? OFFSET ?`,
|
|
1632
|
+
).all(...pageParameters, input.limit + 1, offset) as unknown as ItemRow[];
|
|
1434
1633
|
const items: ItemSummary[] = [];
|
|
1435
1634
|
let bytes = 2;
|
|
1436
1635
|
let contextLimited = false;
|
|
@@ -1452,46 +1651,37 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
1452
1651
|
limit: input.limit,
|
|
1453
1652
|
offset,
|
|
1454
1653
|
...(hasMore && lastRow
|
|
1455
|
-
? { nextCursor: encodeCursor(
|
|
1654
|
+
? { nextCursor: this.encodeCursor(binding, lastRow.created_sequence, high) }
|
|
1456
1655
|
: {}),
|
|
1457
1656
|
hasMore,
|
|
1458
|
-
watermark: encodeCursor(
|
|
1657
|
+
watermark: this.encodeCursor(changesBinding, high, high),
|
|
1459
1658
|
contextLimited,
|
|
1460
1659
|
};
|
|
1461
1660
|
}
|
|
1462
|
-
|
|
1463
1661
|
listItemChanges(
|
|
1464
1662
|
after: string | undefined,
|
|
1465
1663
|
limit: number,
|
|
1466
1664
|
viewer: ActorIdentity,
|
|
1467
1665
|
kinds?: RecordKind[],
|
|
1468
1666
|
): ChangePage {
|
|
1469
|
-
const
|
|
1667
|
+
const binding = this.binding('item-changes', viewer, { kinds: kinds ?? [] });
|
|
1668
|
+
const afterSequence = after ? this.decodeCursor(after, binding).sequence : 0n;
|
|
1470
1669
|
const highWatermark = this.maxEventSequence();
|
|
1471
1670
|
const where = ['e.sequence > ?', 'e.sequence <= ?', 'e.item_kind IS NOT NULL'];
|
|
1472
|
-
const parameters: Array<string | number> = [afterSequence, highWatermark];
|
|
1671
|
+
const parameters: Array<string | number | bigint> = [afterSequence, highWatermark];
|
|
1473
1672
|
if (kinds?.length) {
|
|
1474
1673
|
where.push(`e.item_kind IN (${kinds.map(() => '?').join(', ')})`);
|
|
1475
1674
|
parameters.push(...kinds);
|
|
1476
1675
|
}
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
i.recipient_agent_id = ? OR i.owner_agent_id = ? OR i.assignee_agent_id = ?)`);
|
|
1481
|
-
parameters.push(viewer.kind, viewer.id, viewer.id, viewer.id, viewer.id);
|
|
1482
|
-
} else {
|
|
1483
|
-
where.push(`(i.visibility != 'private' OR (i.actor_kind = ? AND i.actor_id = ?))`);
|
|
1484
|
-
parameters.push(viewer.kind, viewer.id);
|
|
1485
|
-
}
|
|
1486
|
-
}
|
|
1676
|
+
const privacy = recordVisibilityPredicate(viewer, this.authority, false, 'i.');
|
|
1677
|
+
where.push(privacy.sql);
|
|
1678
|
+
parameters.push(...privacy.values);
|
|
1487
1679
|
parameters.push(limit + 1);
|
|
1488
|
-
const rows = this.
|
|
1489
|
-
.
|
|
1490
|
-
`SELECT e.id AS event_id, e.sequence, e.type, e.created_at AS event_created_at,
|
|
1680
|
+
const rows = this.prepare(
|
|
1681
|
+
`SELECT e.id AS event_id, e.sequence, e.type, e.created_at AS event_created_at,
|
|
1491
1682
|
e.payload, i.* FROM events e JOIN items_current i ON i.item_id = e.aggregate_id
|
|
1492
1683
|
WHERE ${where.join(' AND ')} ORDER BY e.sequence ASC LIMIT ?`,
|
|
1493
|
-
|
|
1494
|
-
.all(...parameters) as unknown as Array<
|
|
1684
|
+
).all(...parameters) as unknown as Array<
|
|
1495
1685
|
ItemRow &
|
|
1496
1686
|
EventRow & {
|
|
1497
1687
|
event_id: string;
|
|
@@ -1505,18 +1695,24 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
1505
1695
|
let consumed = 0;
|
|
1506
1696
|
let lastConsumedSequence = afterSequence;
|
|
1507
1697
|
for (const row of rows.slice(0, limit)) {
|
|
1508
|
-
const sequence =
|
|
1698
|
+
const sequence = row.sequence!;
|
|
1509
1699
|
let actor: ActorIdentity;
|
|
1510
1700
|
try {
|
|
1511
|
-
actor = actorSchema.parse(
|
|
1701
|
+
actor = actorSchema.parse(
|
|
1702
|
+
(
|
|
1703
|
+
JSON.parse(row.payload) as {
|
|
1704
|
+
actor: unknown;
|
|
1705
|
+
}
|
|
1706
|
+
).actor,
|
|
1707
|
+
);
|
|
1512
1708
|
} catch {
|
|
1513
1709
|
consumed += 1;
|
|
1514
1710
|
lastConsumedSequence = sequence;
|
|
1515
1711
|
continue;
|
|
1516
1712
|
}
|
|
1517
1713
|
const item: ItemChange = {
|
|
1518
|
-
cursor: encodeCursor(
|
|
1519
|
-
sequence,
|
|
1714
|
+
cursor: this.encodeCursor(binding, sequence, highWatermark),
|
|
1715
|
+
sequence: exactSequence(sequence),
|
|
1520
1716
|
eventId: row.event_id,
|
|
1521
1717
|
type: row.type,
|
|
1522
1718
|
createdAt: row.event_created_at,
|
|
@@ -1539,24 +1735,25 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
1539
1735
|
return {
|
|
1540
1736
|
items,
|
|
1541
1737
|
limit,
|
|
1542
|
-
nextCursor: encodeCursor(
|
|
1738
|
+
nextCursor: this.encodeCursor(
|
|
1739
|
+
binding,
|
|
1740
|
+
hasMore ? lastConsumedSequence : highWatermark,
|
|
1741
|
+
highWatermark,
|
|
1742
|
+
),
|
|
1543
1743
|
hasMore,
|
|
1544
|
-
watermark: encodeCursor(
|
|
1744
|
+
watermark: this.encodeCursor(binding, highWatermark, highWatermark),
|
|
1545
1745
|
contextLimited,
|
|
1546
1746
|
};
|
|
1547
1747
|
}
|
|
1548
|
-
|
|
1549
1748
|
getItemSummary(id: string): ItemSummary | undefined {
|
|
1550
|
-
const row = this.
|
|
1551
|
-
|
|
1552
|
-
.get(id) as unknown as ItemRow | undefined;
|
|
1749
|
+
const row = this.prepare('SELECT * FROM items_current WHERE item_id = ?').get(id) as unknown as
|
|
1750
|
+
ItemRow | undefined;
|
|
1553
1751
|
return row ? itemSummaryFromRow(row) : undefined;
|
|
1554
1752
|
}
|
|
1555
|
-
|
|
1556
1753
|
getReadableItemEvents(id: string): SynomemEvent[] {
|
|
1557
|
-
const rows = this.
|
|
1558
|
-
|
|
1559
|
-
|
|
1754
|
+
const rows = this.prepare(
|
|
1755
|
+
'SELECT id, payload FROM events WHERE aggregate_id = ? ORDER BY sequence ASC',
|
|
1756
|
+
).all(id) as unknown as EventRow[];
|
|
1560
1757
|
const events: SynomemEvent[] = [];
|
|
1561
1758
|
for (const row of rows) {
|
|
1562
1759
|
try {
|
|
@@ -1567,24 +1764,29 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
1567
1764
|
}
|
|
1568
1765
|
return events;
|
|
1569
1766
|
}
|
|
1570
|
-
|
|
1571
|
-
|
|
1767
|
+
currentIndexHealth(): {
|
|
1768
|
+
given: number;
|
|
1769
|
+
indexed: number;
|
|
1770
|
+
stateMismatches: number;
|
|
1771
|
+
} {
|
|
1572
1772
|
const given = Number(
|
|
1573
1773
|
(
|
|
1574
|
-
this.
|
|
1575
|
-
|
|
1576
|
-
|
|
1774
|
+
this.prepare("SELECT COUNT(*) AS count FROM events WHERE type = 'kudos.given'").get() as {
|
|
1775
|
+
count: number;
|
|
1776
|
+
}
|
|
1577
1777
|
).count,
|
|
1578
1778
|
);
|
|
1579
1779
|
const indexed = Number(
|
|
1580
|
-
(
|
|
1581
|
-
.count
|
|
1780
|
+
(
|
|
1781
|
+
this.prepare('SELECT COUNT(*) AS count FROM kudos_current').get() as {
|
|
1782
|
+
count: number;
|
|
1783
|
+
}
|
|
1784
|
+
).count,
|
|
1582
1785
|
);
|
|
1583
1786
|
const stateMismatches = Number(
|
|
1584
1787
|
(
|
|
1585
|
-
this.
|
|
1586
|
-
|
|
1587
|
-
`SELECT COUNT(*) AS count
|
|
1788
|
+
this.prepare(
|
|
1789
|
+
`SELECT COUNT(*) AS count
|
|
1588
1790
|
FROM kudos_current k
|
|
1589
1791
|
WHERE k.status != CASE WHEN EXISTS (
|
|
1590
1792
|
SELECT 1 FROM events e
|
|
@@ -1594,45 +1796,56 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
1594
1796
|
SELECT 1 FROM events e
|
|
1595
1797
|
WHERE e.type = 'kudos.revoked' AND e.kudos_id = k.kudos_id
|
|
1596
1798
|
) THEN 'revoked' ELSE 'active' END`,
|
|
1597
|
-
|
|
1598
|
-
|
|
1799
|
+
).get() as {
|
|
1800
|
+
count: number;
|
|
1801
|
+
}
|
|
1599
1802
|
).count,
|
|
1600
1803
|
);
|
|
1601
1804
|
return { given, indexed, stateMismatches };
|
|
1602
1805
|
}
|
|
1603
|
-
|
|
1604
|
-
|
|
1806
|
+
itemIndexHealth(): {
|
|
1807
|
+
created: number;
|
|
1808
|
+
indexed: number;
|
|
1809
|
+
} {
|
|
1605
1810
|
const created = Number(
|
|
1606
1811
|
(
|
|
1607
|
-
this.
|
|
1608
|
-
|
|
1609
|
-
`SELECT COUNT(*) AS count FROM events WHERE type IN
|
|
1812
|
+
this.prepare(
|
|
1813
|
+
`SELECT COUNT(*) AS count FROM events WHERE type IN
|
|
1610
1814
|
('kudos.given', 'memo.sent', 'note.created', 'post.created', 'task.created')`,
|
|
1611
|
-
|
|
1612
|
-
|
|
1815
|
+
).get() as {
|
|
1816
|
+
count: number;
|
|
1817
|
+
}
|
|
1613
1818
|
).count,
|
|
1614
1819
|
);
|
|
1615
1820
|
const indexed = Number(
|
|
1616
|
-
(
|
|
1617
|
-
.count
|
|
1821
|
+
(
|
|
1822
|
+
this.prepare('SELECT COUNT(*) AS count FROM items_current').get() as {
|
|
1823
|
+
count: number;
|
|
1824
|
+
}
|
|
1825
|
+
).count,
|
|
1618
1826
|
);
|
|
1619
1827
|
return { created, indexed };
|
|
1620
1828
|
}
|
|
1621
|
-
|
|
1622
|
-
|
|
1829
|
+
migrationState(): {
|
|
1830
|
+
schemaVersion: number;
|
|
1831
|
+
appliedVersions: number[];
|
|
1832
|
+
} {
|
|
1623
1833
|
const schemaVersion = Number(
|
|
1624
|
-
(
|
|
1834
|
+
(
|
|
1835
|
+
this.prepare('PRAGMA user_version').get() as {
|
|
1836
|
+
user_version: number;
|
|
1837
|
+
}
|
|
1838
|
+
).user_version,
|
|
1625
1839
|
);
|
|
1626
1840
|
const appliedVersions = (
|
|
1627
|
-
this.
|
|
1628
|
-
|
|
1629
|
-
|
|
1841
|
+
this.prepare(
|
|
1842
|
+
'SELECT version FROM schema_migrations ORDER BY version',
|
|
1843
|
+
).all() as unknown as Array<{
|
|
1630
1844
|
version: number;
|
|
1631
1845
|
}>
|
|
1632
1846
|
).map((row) => Number(row.version));
|
|
1633
1847
|
return { schemaVersion, appliedVersions };
|
|
1634
1848
|
}
|
|
1635
|
-
|
|
1636
1849
|
/**
|
|
1637
1850
|
* Aliases that also name an agent directly.
|
|
1638
1851
|
*
|
|
@@ -1641,63 +1854,55 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
1641
1854
|
* agent's handle — which is the collision that actually makes a lookup
|
|
1642
1855
|
* ambiguous now.
|
|
1643
1856
|
*/
|
|
1644
|
-
aliasIdentityConflicts(): Array<{
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1857
|
+
aliasIdentityConflicts(): Array<{
|
|
1858
|
+
alias: string;
|
|
1859
|
+
agentId: string;
|
|
1860
|
+
}> {
|
|
1861
|
+
return this.prepare(
|
|
1862
|
+
`SELECT x.alias, x.agent_id AS agentId
|
|
1648
1863
|
FROM aliases x
|
|
1649
1864
|
JOIN agents a ON lower(a.id) = x.normalized_alias
|
|
1650
1865
|
OR lower(a.handle) = x.normalized_alias
|
|
1651
1866
|
WHERE a.id != x.agent_id
|
|
1652
1867
|
ORDER BY x.alias`,
|
|
1653
|
-
|
|
1654
|
-
|
|
1868
|
+
).all() as unknown as Array<{
|
|
1869
|
+
alias: string;
|
|
1870
|
+
agentId: string;
|
|
1871
|
+
}>;
|
|
1655
1872
|
}
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
}
|
|
1663
|
-
).sequence,
|
|
1664
|
-
);
|
|
1873
|
+
private maxEventSequence(): bigint {
|
|
1874
|
+
return (
|
|
1875
|
+
this.prepare('SELECT COALESCE(MAX(sequence),0) AS sequence FROM events').get() as {
|
|
1876
|
+
sequence: bigint;
|
|
1877
|
+
}
|
|
1878
|
+
).sequence;
|
|
1665
1879
|
}
|
|
1666
|
-
|
|
1667
1880
|
getEvent(id: string): SynomemEvent | undefined {
|
|
1668
|
-
const row = this.
|
|
1881
|
+
const row = this.prepare('SELECT id, payload FROM events WHERE id = ?').get(id) as
|
|
1669
1882
|
EventRow | undefined;
|
|
1670
1883
|
return row ? this.parseEvent(row) : undefined;
|
|
1671
1884
|
}
|
|
1672
|
-
|
|
1673
1885
|
getEventByIdempotency(actorKind: string, actorId: string, key: string): SynomemEvent | undefined {
|
|
1674
|
-
const row = this.
|
|
1675
|
-
|
|
1676
|
-
`SELECT id, payload FROM events
|
|
1886
|
+
const row = this.prepare(
|
|
1887
|
+
`SELECT id, payload FROM events
|
|
1677
1888
|
WHERE actor_kind = ? AND actor_id = ? AND idempotency_key = ?`,
|
|
1678
|
-
|
|
1679
|
-
.get(actorKind, actorId, key) as EventRow | undefined;
|
|
1889
|
+
).get(actorKind, actorId, key) as EventRow | undefined;
|
|
1680
1890
|
return row ? this.parseEvent(row) : undefined;
|
|
1681
1891
|
}
|
|
1682
|
-
|
|
1683
1892
|
getEvents(): SynomemEvent[] {
|
|
1684
1893
|
const scan = this.scanEvents();
|
|
1685
1894
|
if (scan.invalid[0]) throw scan.invalid[0].error;
|
|
1686
1895
|
return scan.events;
|
|
1687
1896
|
}
|
|
1688
|
-
|
|
1689
1897
|
getReadableEvents(): SynomemEvent[] {
|
|
1690
1898
|
return this.scanEvents().events;
|
|
1691
1899
|
}
|
|
1692
|
-
|
|
1693
1900
|
getReadableSynomemEvents(kudosId: string): SynomemEvent[] {
|
|
1694
|
-
const rows = this.
|
|
1695
|
-
|
|
1696
|
-
`SELECT id, payload FROM events
|
|
1901
|
+
const rows = this.prepare(
|
|
1902
|
+
`SELECT id, payload FROM events
|
|
1697
1903
|
WHERE (type = 'kudos.given' AND id = ?) OR kudos_id = ?
|
|
1698
1904
|
ORDER BY sequence ASC`,
|
|
1699
|
-
|
|
1700
|
-
.all(kudosId, kudosId) as unknown as EventRow[];
|
|
1905
|
+
).all(kudosId, kudosId) as unknown as EventRow[];
|
|
1701
1906
|
const events: SynomemEvent[] = [];
|
|
1702
1907
|
for (const row of rows) {
|
|
1703
1908
|
try {
|
|
@@ -1708,7 +1913,6 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
1708
1913
|
}
|
|
1709
1914
|
return events;
|
|
1710
1915
|
}
|
|
1711
|
-
|
|
1712
1916
|
scanEvents(): EventScan {
|
|
1713
1917
|
const rows = this.rawEventRows();
|
|
1714
1918
|
const events: SynomemEvent[] = [];
|
|
@@ -1723,11 +1927,14 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
1723
1927
|
}
|
|
1724
1928
|
return { events, invalid };
|
|
1725
1929
|
}
|
|
1726
|
-
|
|
1727
1930
|
assertEventCompatibility(): void {
|
|
1728
|
-
const rows = this.
|
|
1729
|
-
|
|
1730
|
-
|
|
1931
|
+
const rows = this.prepare(
|
|
1932
|
+
'SELECT id, payload, sequence FROM events WHERE sequence > ? ORDER BY sequence ASC',
|
|
1933
|
+
).all(this.validatedEventSequence) as unknown as Array<
|
|
1934
|
+
EventRow & {
|
|
1935
|
+
sequence: bigint;
|
|
1936
|
+
}
|
|
1937
|
+
>;
|
|
1731
1938
|
const invalid: EventScan['invalid'] = [];
|
|
1732
1939
|
for (const row of rows) {
|
|
1733
1940
|
try {
|
|
@@ -1747,19 +1954,24 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
1747
1954
|
{ eventIds: invalid.map((item) => item.id) },
|
|
1748
1955
|
);
|
|
1749
1956
|
}
|
|
1750
|
-
|
|
1751
1957
|
rawEventRows(): EventRow[] {
|
|
1752
|
-
return this.
|
|
1753
|
-
|
|
1754
|
-
|
|
1958
|
+
return this.prepare(
|
|
1959
|
+
'SELECT id, payload FROM events ORDER BY sequence ASC',
|
|
1960
|
+
).all() as unknown as EventRow[];
|
|
1755
1961
|
}
|
|
1756
|
-
|
|
1757
1962
|
private parseEvent(row: EventRow): SynomemEvent {
|
|
1758
1963
|
try {
|
|
1759
|
-
|
|
1964
|
+
const value = JSON.parse(row.payload) as unknown;
|
|
1760
1965
|
if (typeof value === 'object' && value !== null) {
|
|
1761
|
-
const candidate = value as {
|
|
1966
|
+
const candidate = value as {
|
|
1967
|
+
schemaVersion?: unknown;
|
|
1968
|
+
type?: unknown;
|
|
1969
|
+
};
|
|
1762
1970
|
const supportedTypes = new Set([
|
|
1971
|
+
'reply.created',
|
|
1972
|
+
'reply.deleted',
|
|
1973
|
+
'reaction.added',
|
|
1974
|
+
'reaction.removed',
|
|
1763
1975
|
'agent.created',
|
|
1764
1976
|
'agent.updated',
|
|
1765
1977
|
'topic.created',
|
|
@@ -1785,6 +1997,7 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
1785
1997
|
'task.accepted',
|
|
1786
1998
|
'task.rejected',
|
|
1787
1999
|
'task.canceled',
|
|
2000
|
+
'task.decision_overridden',
|
|
1788
2001
|
'todo.created',
|
|
1789
2002
|
'todo.updated',
|
|
1790
2003
|
'todo.completed',
|
|
@@ -1793,7 +2006,7 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
1793
2006
|
'todo.archived',
|
|
1794
2007
|
]);
|
|
1795
2008
|
if (
|
|
1796
|
-
(typeof candidate.schemaVersion === 'number' && candidate.schemaVersion >
|
|
2009
|
+
(typeof candidate.schemaVersion === 'number' && candidate.schemaVersion > 2) ||
|
|
1797
2010
|
(typeof candidate.type === 'string' && !supportedTypes.has(candidate.type))
|
|
1798
2011
|
) {
|
|
1799
2012
|
throw new SynomemError(
|
|
@@ -1801,30 +2014,6 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
1801
2014
|
`Event ${row.id} was written by a newer or incompatible Synomem version.`,
|
|
1802
2015
|
);
|
|
1803
2016
|
}
|
|
1804
|
-
const legacy = candidate as Record<string, unknown>;
|
|
1805
|
-
if (legacy.workspaceId === undefined) legacy.workspaceId = 'legacy-local';
|
|
1806
|
-
if (legacy.aggregateId === undefined) {
|
|
1807
|
-
legacy.aggregateId =
|
|
1808
|
-
legacy.kudosId ??
|
|
1809
|
-
legacy.memoId ??
|
|
1810
|
-
legacy.noteId ??
|
|
1811
|
-
legacy.taskId ??
|
|
1812
|
-
(typeof legacy.agentId === 'string' ? legacy.agentId : undefined) ??
|
|
1813
|
-
(typeof legacy.agent === 'object' && legacy.agent !== null
|
|
1814
|
-
? (legacy.agent as { id?: unknown }).id
|
|
1815
|
-
: undefined) ??
|
|
1816
|
-
legacy.id;
|
|
1817
|
-
}
|
|
1818
|
-
if (legacy.aggregateVersion === undefined) {
|
|
1819
|
-
legacy.aggregateVersion =
|
|
1820
|
-
String(legacy.type).endsWith('.given') ||
|
|
1821
|
-
String(legacy.type).endsWith('.sent') ||
|
|
1822
|
-
String(legacy.type).endsWith('.created')
|
|
1823
|
-
? 1
|
|
1824
|
-
: 2;
|
|
1825
|
-
}
|
|
1826
|
-
if (legacy.visibility === 'local') legacy.visibility = 'workspace';
|
|
1827
|
-
value = legacy;
|
|
1828
2017
|
}
|
|
1829
2018
|
return eventSchema.parse(value);
|
|
1830
2019
|
} catch (error) {
|
|
@@ -1838,50 +2027,216 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
1838
2027
|
);
|
|
1839
2028
|
}
|
|
1840
2029
|
}
|
|
2030
|
+
canActorReadItem(id: string, actor: ActorRef): boolean {
|
|
2031
|
+
const target = this.getActor(actor);
|
|
2032
|
+
if (!target || target.status !== 'active') return false;
|
|
2033
|
+
const authority =
|
|
2034
|
+
this.boundActor && this.boundActor.kind === actor.kind && this.boundActor.id === actor.id
|
|
2035
|
+
? this.authority
|
|
2036
|
+
: resolveAuthority();
|
|
2037
|
+
const predicate = recordVisibilityPredicate(actor, authority);
|
|
2038
|
+
return !!this.prepare(`SELECT 1 FROM items_current WHERE item_id=? AND (${predicate.sql})`).get(
|
|
2039
|
+
id,
|
|
2040
|
+
...predicate.values,
|
|
2041
|
+
);
|
|
2042
|
+
}
|
|
2043
|
+
getMutationReceipt(
|
|
2044
|
+
actorKind: string,
|
|
2045
|
+
actorId: string,
|
|
2046
|
+
keyHash: string,
|
|
2047
|
+
): MutationReceipt | undefined {
|
|
2048
|
+
const row = this.prepare(
|
|
2049
|
+
'SELECT operation,request_hash,result_json,event_id,created_at FROM mutation_receipts WHERE actor_kind=? AND actor_id=? AND key_hash=?',
|
|
2050
|
+
).get(actorKind, actorId, keyHash) as
|
|
2051
|
+
| {
|
|
2052
|
+
operation: string;
|
|
2053
|
+
request_hash: string;
|
|
2054
|
+
result_json: string | null;
|
|
2055
|
+
event_id: string | null;
|
|
2056
|
+
created_at: string;
|
|
2057
|
+
}
|
|
2058
|
+
| undefined;
|
|
2059
|
+
return row
|
|
2060
|
+
? {
|
|
2061
|
+
operation: row.operation,
|
|
2062
|
+
requestHash: row.request_hash,
|
|
2063
|
+
resultJson: row.result_json,
|
|
2064
|
+
createdAt: row.created_at,
|
|
2065
|
+
...(row.event_id ? { eventId: row.event_id } : {}),
|
|
2066
|
+
}
|
|
2067
|
+
: undefined;
|
|
2068
|
+
}
|
|
2069
|
+
insertMutationReceipt(
|
|
2070
|
+
actorKind: string,
|
|
2071
|
+
actorId: string,
|
|
2072
|
+
keyHash: string,
|
|
2073
|
+
receipt: MutationReceipt,
|
|
2074
|
+
): void {
|
|
2075
|
+
this.prepare(
|
|
2076
|
+
'INSERT INTO mutation_receipts(actor_kind,actor_id,key_hash,operation,request_hash,result_json,event_id,created_at) VALUES(?,?,?,?,?,?,?,?)',
|
|
2077
|
+
).run(
|
|
2078
|
+
actorKind,
|
|
2079
|
+
actorId,
|
|
2080
|
+
keyHash,
|
|
2081
|
+
receipt.operation,
|
|
2082
|
+
receipt.requestHash,
|
|
2083
|
+
receipt.resultJson,
|
|
2084
|
+
receipt.eventId ?? null,
|
|
2085
|
+
receipt.createdAt,
|
|
2086
|
+
);
|
|
2087
|
+
}
|
|
2088
|
+
compactMutationReceipts(before: string): number {
|
|
2089
|
+
this.assertWritable();
|
|
2090
|
+
return Number(
|
|
2091
|
+
this.prepare(
|
|
2092
|
+
'UPDATE mutation_receipts SET result_json=NULL WHERE rowid IN (SELECT rowid FROM mutation_receipts WHERE created_at<? AND result_json IS NOT NULL ORDER BY created_at LIMIT 1000)',
|
|
2093
|
+
).run(before).changes,
|
|
2094
|
+
);
|
|
2095
|
+
}
|
|
2096
|
+
getActor(ref: ActorRef): AddressableActor | undefined {
|
|
2097
|
+
if (ref.kind === 'agent') {
|
|
2098
|
+
const profile = this.getAgent(ref.id);
|
|
2099
|
+
return profile
|
|
2100
|
+
? {
|
|
2101
|
+
kind: 'agent',
|
|
2102
|
+
id: profile.id,
|
|
2103
|
+
handle: profile.handle,
|
|
2104
|
+
displayName: profile.displayName,
|
|
2105
|
+
status: profile.status === 'archived' ? 'inactive' : 'active',
|
|
2106
|
+
}
|
|
2107
|
+
: undefined;
|
|
2108
|
+
}
|
|
2109
|
+
const row = this.prepare(
|
|
2110
|
+
'SELECT id,handle,display_name,status FROM human_actors WHERE id=? OR lower(handle)=lower(?)',
|
|
2111
|
+
).get(ref.id, ref.id) as
|
|
2112
|
+
| { id: string; handle: string; display_name: string; status: 'active' | 'inactive' }
|
|
2113
|
+
| undefined;
|
|
2114
|
+
return row
|
|
2115
|
+
? {
|
|
2116
|
+
kind: 'human',
|
|
2117
|
+
id: row.id,
|
|
2118
|
+
handle: row.handle,
|
|
2119
|
+
displayName: row.display_name,
|
|
2120
|
+
status: row.status,
|
|
2121
|
+
}
|
|
2122
|
+
: undefined;
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
actorCounts(
|
|
2126
|
+
target: ActorRef,
|
|
2127
|
+
viewer: ActorIdentity,
|
|
2128
|
+
): { kudosReceived: number; usefulReceived: number } {
|
|
2129
|
+
const policy = recordVisibilityPredicate(viewer, this.authority, false, 'i.');
|
|
2130
|
+
const kudos = this.prepare(
|
|
2131
|
+
`SELECT COUNT(*) AS count FROM kudos_current k JOIN items_current i ON i.item_id=k.kudos_id WHERE i.workspace_id=? AND k.recipient_kind=? AND k.recipient_id=? AND k.revocation_status!='revoked' AND (${policy.sql})`,
|
|
2132
|
+
).get(this.config.workspaceId, target.kind, target.id, ...policy.values) as { count: bigint };
|
|
2133
|
+
const useful = this.prepare(
|
|
2134
|
+
`SELECT COUNT(*) AS count FROM reactions_current c JOIN items_current i ON i.workspace_id=c.workspace_id AND i.item_id=c.root_id LEFT JOIN replies_current r ON r.workspace_id=c.workspace_id AND r.reply_id=c.target_id WHERE c.workspace_id=? AND c.code='useful' AND (${policy.sql}) AND ((c.target_id=i.item_id AND i.actor_kind=? AND i.actor_id=?) OR (r.deleted_at IS NULL AND json_extract(r.author_json,'$.kind')=? AND json_extract(r.author_json,'$.id')=?))`,
|
|
2135
|
+
).get(
|
|
2136
|
+
this.config.workspaceId,
|
|
2137
|
+
...policy.values,
|
|
2138
|
+
target.kind,
|
|
2139
|
+
target.id,
|
|
2140
|
+
target.kind,
|
|
2141
|
+
target.id,
|
|
2142
|
+
) as { count: bigint };
|
|
2143
|
+
return { kudosReceived: Number(kudos.count), usefulReceived: Number(useful.count) };
|
|
2144
|
+
}
|
|
2145
|
+
listActors(input: ActorDirectoryInput = {}, viewer: ActorIdentity): ActorDirectoryPage {
|
|
2146
|
+
const query = (input.query ?? '').toLowerCase();
|
|
2147
|
+
const binding = actorDirectoryBinding(this.config.workspaceId, viewer, input);
|
|
2148
|
+
const position = input.cursor
|
|
2149
|
+
? this.cursorCodec!.decode(input.cursor, binding, '0')
|
|
2150
|
+
: undefined;
|
|
2151
|
+
const after = position?.id ? parseActorDirectoryKey(position.id) : undefined;
|
|
2152
|
+
const source = `(
|
|
2153
|
+
SELECT 'human' AS kind,id,handle,display_name,status FROM human_actors
|
|
2154
|
+
UNION ALL SELECT 'agent' AS kind,id,json_extract(profile_json,'$.handle') AS handle,display_name,
|
|
2155
|
+
CASE WHEN json_extract(profile_json,'$.status')='archived' THEN 'inactive' ELSE 'active' END AS status FROM agents
|
|
2156
|
+
)`;
|
|
2157
|
+
const filter = `status='active' AND (? IS NULL OR kind=?) AND (?='' OR instr(lower(id||' '||handle||' '||display_name),?)>0)`;
|
|
2158
|
+
const values = [input.kind ?? null, input.kind ?? null, query, query];
|
|
2159
|
+
const total = this.prepare(`SELECT COUNT(*) AS count FROM ${source} WHERE ${filter}`).get(
|
|
2160
|
+
...values,
|
|
2161
|
+
) as { count: bigint };
|
|
2162
|
+
const rows = this.prepare(
|
|
2163
|
+
`SELECT kind,id,handle,display_name,status FROM ${source} WHERE ${filter}${after ? ' AND (kind,handle,id)>(?,?,?)' : ''} ORDER BY kind,handle,id LIMIT ?`,
|
|
2164
|
+
).all(...values, ...(after ?? []), (input.limit ?? 20) + 1) as Array<{
|
|
2165
|
+
kind: 'human' | 'agent';
|
|
2166
|
+
id: string;
|
|
2167
|
+
handle: string;
|
|
2168
|
+
display_name: string;
|
|
2169
|
+
status: 'active';
|
|
2170
|
+
}>;
|
|
2171
|
+
const limit = input.limit ?? 20;
|
|
2172
|
+
const hasMore = rows.length > limit;
|
|
2173
|
+
const items = rows.slice(0, limit).map((row) => ({
|
|
2174
|
+
kind: row.kind,
|
|
2175
|
+
id: row.id,
|
|
2176
|
+
handle: row.handle,
|
|
2177
|
+
displayName: row.display_name,
|
|
2178
|
+
status: row.status,
|
|
2179
|
+
}));
|
|
2180
|
+
return {
|
|
2181
|
+
items,
|
|
2182
|
+
total: Number(total.count),
|
|
2183
|
+
limit,
|
|
2184
|
+
hasMore,
|
|
2185
|
+
...(hasMore
|
|
2186
|
+
? {
|
|
2187
|
+
nextCursor: this.cursorCodec!.encode(binding, {
|
|
2188
|
+
sequence: '0',
|
|
2189
|
+
watermark: '0',
|
|
2190
|
+
id: actorDirectoryKey(items.at(-1)!),
|
|
2191
|
+
}),
|
|
2192
|
+
}
|
|
2193
|
+
: {}),
|
|
2194
|
+
};
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
registerHuman(actor: AddressableActor): void {
|
|
2198
|
+
this.assertWritable();
|
|
2199
|
+
this.prepare(
|
|
2200
|
+
'INSERT INTO human_actors(id,handle,display_name,status) VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET handle=excluded.handle,display_name=excluded.display_name,status=excluded.status',
|
|
2201
|
+
).run(actor.id, actor.handle, actor.displayName, actor.status);
|
|
2202
|
+
}
|
|
1841
2203
|
|
|
1842
2204
|
insertAgent(profile: AgentProfile): void {
|
|
1843
2205
|
const parsed = profileSchema.parse(profile);
|
|
1844
|
-
this.
|
|
1845
|
-
|
|
1846
|
-
`INSERT INTO agents(id, handle, status, display_name, profile_json, created_at, updated_at)
|
|
2206
|
+
this.prepare(
|
|
2207
|
+
`INSERT INTO agents(id, handle, status, display_name, profile_json, created_at, updated_at)
|
|
1847
2208
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
1848
|
-
|
|
1849
|
-
.
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
);
|
|
2209
|
+
).run(
|
|
2210
|
+
parsed.id,
|
|
2211
|
+
parsed.handle,
|
|
2212
|
+
parsed.status,
|
|
2213
|
+
parsed.displayName,
|
|
2214
|
+
JSON.stringify(parsed),
|
|
2215
|
+
parsed.createdAt,
|
|
2216
|
+
parsed.createdAt,
|
|
2217
|
+
);
|
|
1858
2218
|
this.insertAliases(parsed.id, parsed.aliases ?? []);
|
|
1859
2219
|
}
|
|
1860
|
-
|
|
1861
2220
|
updateAgent(profile: AgentProfile, updatedAt: string): void {
|
|
1862
2221
|
const parsed = profileSchema.parse(profile);
|
|
1863
|
-
this.
|
|
1864
|
-
|
|
1865
|
-
`UPDATE agents SET handle = ?, status = ?, display_name = ?, profile_json = ?,
|
|
2222
|
+
this.prepare(
|
|
2223
|
+
`UPDATE agents SET handle = ?, status = ?, display_name = ?, profile_json = ?,
|
|
1866
2224
|
updated_at = ? WHERE id = ?`,
|
|
1867
|
-
|
|
1868
|
-
.
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
this.db().prepare('DELETE FROM aliases WHERE agent_id = ?').run(parsed.id);
|
|
2225
|
+
).run(
|
|
2226
|
+
parsed.handle,
|
|
2227
|
+
parsed.status,
|
|
2228
|
+
parsed.displayName,
|
|
2229
|
+
JSON.stringify(parsed),
|
|
2230
|
+
updatedAt,
|
|
2231
|
+
parsed.id,
|
|
2232
|
+
);
|
|
2233
|
+
this.prepare('DELETE FROM aliases WHERE agent_id = ?').run(parsed.id);
|
|
1877
2234
|
this.insertAliases(parsed.id, parsed.aliases ?? []);
|
|
1878
2235
|
}
|
|
1879
|
-
|
|
1880
2236
|
getAgent(idOrAlias: string): AgentProfile | undefined {
|
|
1881
2237
|
const resolved = this.resolveAgent(idOrAlias);
|
|
1882
2238
|
return resolved.match;
|
|
1883
2239
|
}
|
|
1884
|
-
|
|
1885
2240
|
/**
|
|
1886
2241
|
* Resolves a name to a canonical agent, reporting ambiguity rather than
|
|
1887
2242
|
* guessing.
|
|
@@ -1892,78 +2247,74 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
1892
2247
|
* case — silently preferring the ID would attribute work to the wrong agent,
|
|
1893
2248
|
* and the person who typed the name would never know.
|
|
1894
2249
|
*/
|
|
1895
|
-
resolveAgent(query: string): {
|
|
2250
|
+
resolveAgent(query: string): {
|
|
2251
|
+
match?: AgentProfile;
|
|
2252
|
+
candidates: AgentProfile[];
|
|
2253
|
+
} {
|
|
1896
2254
|
const normalized = query.trim().toLowerCase();
|
|
1897
|
-
const rows = this.
|
|
1898
|
-
.
|
|
1899
|
-
`SELECT DISTINCT a.profile_json
|
|
2255
|
+
const rows = this.prepare(
|
|
2256
|
+
`SELECT DISTINCT a.profile_json
|
|
1900
2257
|
FROM agents a
|
|
1901
2258
|
LEFT JOIN aliases x ON x.agent_id = a.id
|
|
1902
2259
|
WHERE lower(a.id) = ? OR lower(a.handle) = ? OR x.normalized_alias = ?
|
|
1903
2260
|
ORDER BY a.id ASC`,
|
|
1904
|
-
|
|
1905
|
-
.all(normalized, normalized, normalized) as unknown as ProfileRow[];
|
|
2261
|
+
).all(normalized, normalized, normalized) as unknown as ProfileRow[];
|
|
1906
2262
|
const candidates = rows.map((row) => profileSchema.parse(JSON.parse(row.profile_json)));
|
|
1907
2263
|
return candidates.length === 1 ? { match: candidates[0]!, candidates } : { candidates };
|
|
1908
2264
|
}
|
|
1909
|
-
|
|
1910
2265
|
/* -------------------------------------------------------------------- topics */
|
|
1911
|
-
|
|
1912
2266
|
insertTopic(topic: Topic): void {
|
|
1913
2267
|
const parsed = topicProfileSchema.parse(topic);
|
|
1914
|
-
this.
|
|
1915
|
-
|
|
1916
|
-
`INSERT INTO topics(id, display_name, status, created_at, updated_at)
|
|
2268
|
+
this.prepare(
|
|
2269
|
+
`INSERT INTO topics(id, display_name, status, created_at, updated_at)
|
|
1917
2270
|
VALUES (?, ?, ?, ?, ?)`,
|
|
1918
|
-
|
|
1919
|
-
.run(parsed.id, parsed.displayName, parsed.status, parsed.createdAt, parsed.createdAt);
|
|
2271
|
+
).run(parsed.id, parsed.displayName, parsed.status, parsed.createdAt, parsed.createdAt);
|
|
1920
2272
|
this.insertTopicAliases(parsed.id, parsed.aliases ?? []);
|
|
1921
2273
|
}
|
|
1922
|
-
|
|
1923
2274
|
updateTopic(topic: Topic, updatedAt: string): void {
|
|
1924
2275
|
const parsed = topicProfileSchema.parse(topic);
|
|
1925
|
-
this.
|
|
1926
|
-
.
|
|
1927
|
-
|
|
1928
|
-
|
|
2276
|
+
this.prepare('UPDATE topics SET display_name = ?, status = ?, updated_at = ? WHERE id = ?').run(
|
|
2277
|
+
parsed.displayName,
|
|
2278
|
+
parsed.status,
|
|
2279
|
+
updatedAt,
|
|
2280
|
+
parsed.id,
|
|
2281
|
+
);
|
|
2282
|
+
this.prepare('DELETE FROM topic_aliases WHERE topic_id = ?').run(parsed.id);
|
|
1929
2283
|
this.insertTopicAliases(parsed.id, parsed.aliases ?? []);
|
|
1930
2284
|
}
|
|
1931
|
-
|
|
1932
2285
|
getTopic(idOrAlias: string): Topic | undefined {
|
|
1933
2286
|
return this.resolveTopic(idOrAlias).match;
|
|
1934
2287
|
}
|
|
1935
|
-
|
|
1936
2288
|
listTopics(status?: 'active' | 'archived'): Topic[] {
|
|
1937
2289
|
const rows = (status
|
|
1938
|
-
? this.
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
.prepare('SELECT * FROM topics ORDER BY display_name ASC')
|
|
1943
|
-
.all()) as unknown as TopicRow[];
|
|
2290
|
+
? this.prepare('SELECT * FROM topics WHERE status = ? ORDER BY display_name ASC').all(status)
|
|
2291
|
+
: this.prepare(
|
|
2292
|
+
'SELECT * FROM topics ORDER BY display_name ASC',
|
|
2293
|
+
).all()) as unknown as TopicRow[];
|
|
1944
2294
|
return rows.map((row) => this.topicFromRow(row));
|
|
1945
2295
|
}
|
|
1946
|
-
|
|
1947
2296
|
/** Resolves a name case-insensitively, reporting ambiguity instead of guessing. */
|
|
1948
|
-
resolveTopic(query: string): {
|
|
2297
|
+
resolveTopic(query: string): {
|
|
2298
|
+
match?: Topic;
|
|
2299
|
+
candidates: Topic[];
|
|
2300
|
+
} {
|
|
1949
2301
|
const normalized = query.trim().toLowerCase();
|
|
1950
|
-
const rows = this.
|
|
1951
|
-
|
|
1952
|
-
`SELECT DISTINCT t.*
|
|
2302
|
+
const rows = this.prepare(
|
|
2303
|
+
`SELECT DISTINCT t.*
|
|
1953
2304
|
FROM topics t
|
|
1954
2305
|
LEFT JOIN topic_aliases x ON x.topic_id = t.id
|
|
1955
2306
|
WHERE lower(t.id) = ? OR lower(t.display_name) = ? OR x.alias = ?
|
|
1956
2307
|
ORDER BY t.id ASC`,
|
|
1957
|
-
|
|
1958
|
-
.all(normalized, normalized, normalized) as unknown as TopicRow[];
|
|
2308
|
+
).all(normalized, normalized, normalized) as unknown as TopicRow[];
|
|
1959
2309
|
const candidates = rows.map((row) => this.topicFromRow(row));
|
|
1960
2310
|
return candidates.length === 1 ? { match: candidates[0]!, candidates } : { candidates };
|
|
1961
2311
|
}
|
|
1962
|
-
|
|
1963
2312
|
private topicFromRow(row: TopicRow): Topic {
|
|
1964
|
-
const aliasRows = this.
|
|
1965
|
-
|
|
1966
|
-
|
|
2313
|
+
const aliasRows = this.prepare(
|
|
2314
|
+
'SELECT alias FROM topic_aliases WHERE topic_id = ? ORDER BY alias ASC',
|
|
2315
|
+
).all(row.id) as unknown as Array<{
|
|
2316
|
+
alias: string;
|
|
2317
|
+
}>;
|
|
1967
2318
|
const aliases = aliasRows.map((aliasRow) => aliasRow.alias);
|
|
1968
2319
|
return {
|
|
1969
2320
|
id: row.id,
|
|
@@ -1973,24 +2324,20 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
1973
2324
|
createdAt: row.created_at,
|
|
1974
2325
|
};
|
|
1975
2326
|
}
|
|
1976
|
-
|
|
1977
2327
|
private insertTopicAliases(topicId: string, aliases: string[]): void {
|
|
1978
2328
|
for (const alias of aliases) {
|
|
1979
|
-
this.
|
|
1980
|
-
.
|
|
1981
|
-
|
|
2329
|
+
this.prepare('INSERT INTO topic_aliases(alias, topic_id) VALUES (?, ?)').run(
|
|
2330
|
+
alias.trim().toLowerCase(),
|
|
2331
|
+
topicId,
|
|
2332
|
+
);
|
|
1982
2333
|
}
|
|
1983
2334
|
}
|
|
1984
|
-
|
|
1985
2335
|
/* -------------------------------------------------------- post acknowledgment */
|
|
1986
|
-
|
|
1987
2336
|
listPostAcknowledgments(postId: string): PostAcknowledgment[] {
|
|
1988
|
-
const rows = this.
|
|
1989
|
-
|
|
1990
|
-
`SELECT actor_kind, actor_id, actor_display_name, note, acknowledged_at
|
|
2337
|
+
const rows = this.prepare(
|
|
2338
|
+
`SELECT actor_kind, actor_id, actor_display_name, note, acknowledged_at
|
|
1991
2339
|
FROM post_acknowledgments WHERE post_id = ? ORDER BY acknowledged_at ASC`,
|
|
1992
|
-
|
|
1993
|
-
.all(postId) as unknown as Array<{
|
|
2340
|
+
).all(postId) as unknown as Array<{
|
|
1994
2341
|
actor_kind: string;
|
|
1995
2342
|
actor_id: string;
|
|
1996
2343
|
actor_display_name: string | null;
|
|
@@ -2007,7 +2354,6 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
2007
2354
|
...(row.note ? { note: row.note } : {}),
|
|
2008
2355
|
}));
|
|
2009
2356
|
}
|
|
2010
|
-
|
|
2011
2357
|
/**
|
|
2012
2358
|
* Who has acknowledged a post and who has not.
|
|
2013
2359
|
*
|
|
@@ -2017,36 +2363,36 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
2017
2363
|
* newcomer of ignoring something written before it arrived.
|
|
2018
2364
|
*/
|
|
2019
2365
|
postRoster(postId: string): PostRoster | undefined {
|
|
2020
|
-
const post = this.
|
|
2021
|
-
|
|
2022
|
-
|
|
2366
|
+
const post = this.prepare(
|
|
2367
|
+
"SELECT created_at FROM items_current WHERE item_id = ? AND kind = 'post'",
|
|
2368
|
+
).get(postId) as
|
|
2369
|
+
| {
|
|
2370
|
+
created_at: string;
|
|
2371
|
+
}
|
|
2372
|
+
| undefined;
|
|
2023
2373
|
if (!post) return undefined;
|
|
2024
|
-
|
|
2025
2374
|
const acknowledged = this.listPostAcknowledgments(postId);
|
|
2026
2375
|
const acknowledgedIds = new Set(acknowledged.map((entry) => entry.actor.id));
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2376
|
+
const eligible = this.prepare(
|
|
2377
|
+
'SELECT id, display_name, created_at FROM agents ORDER BY id ASC',
|
|
2378
|
+
).all() as unknown as Array<{
|
|
2379
|
+
id: string;
|
|
2380
|
+
display_name: string;
|
|
2381
|
+
created_at: string;
|
|
2382
|
+
}>;
|
|
2032
2383
|
const outstanding = eligible
|
|
2033
2384
|
.filter((agent) => agent.created_at <= post.created_at && !acknowledgedIds.has(agent.id))
|
|
2034
2385
|
.map((agent) => ({ id: agent.id, displayName: agent.display_name }));
|
|
2035
2386
|
const joinedSince = eligible.filter((agent) => agent.created_at > post.created_at).length;
|
|
2036
|
-
|
|
2037
2387
|
return { postId, acknowledged, outstanding, joinedSince };
|
|
2038
2388
|
}
|
|
2039
|
-
|
|
2040
2389
|
/* ------------------------------------------------------- runtime bindings */
|
|
2041
|
-
|
|
2042
2390
|
listRuntimeBindings(agentId: string): AgentRuntimeBinding[] {
|
|
2043
|
-
const rows = this.
|
|
2044
|
-
|
|
2045
|
-
`SELECT id, agent_id, installation_id, runtime, profile, capabilities_json,
|
|
2391
|
+
const rows = this.prepare(
|
|
2392
|
+
`SELECT id, agent_id, installation_id, runtime, profile, capabilities_json,
|
|
2046
2393
|
bound_at, last_seen_at
|
|
2047
2394
|
FROM agent_runtime_bindings WHERE agent_id = ? ORDER BY bound_at ASC`,
|
|
2048
|
-
|
|
2049
|
-
.all(agentId) as unknown as Array<{
|
|
2395
|
+
).all(agentId) as unknown as Array<{
|
|
2050
2396
|
id: string;
|
|
2051
2397
|
agent_id: string;
|
|
2052
2398
|
installation_id: string | null;
|
|
@@ -2067,7 +2413,6 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
2067
2413
|
...(row.last_seen_at ? { lastSeenAt: row.last_seen_at } : {}),
|
|
2068
2414
|
}));
|
|
2069
2415
|
}
|
|
2070
|
-
|
|
2071
2416
|
bindRuntime(binding: {
|
|
2072
2417
|
id: string;
|
|
2073
2418
|
agentId: string;
|
|
@@ -2077,44 +2422,35 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
2077
2422
|
capabilities?: Record<string, JsonValue>;
|
|
2078
2423
|
boundAt: string;
|
|
2079
2424
|
}): void {
|
|
2080
|
-
this.
|
|
2081
|
-
|
|
2082
|
-
`INSERT INTO agent_runtime_bindings(
|
|
2425
|
+
this.prepare(
|
|
2426
|
+
`INSERT INTO agent_runtime_bindings(
|
|
2083
2427
|
id, agent_id, installation_id, runtime, profile, capabilities_json, bound_at
|
|
2084
2428
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
2085
2429
|
ON CONFLICT(agent_id, runtime, COALESCE(profile, ''), COALESCE(installation_id, ''))
|
|
2086
2430
|
DO UPDATE SET capabilities_json = excluded.capabilities_json`,
|
|
2087
|
-
|
|
2088
|
-
.
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
);
|
|
2431
|
+
).run(
|
|
2432
|
+
binding.id,
|
|
2433
|
+
binding.agentId,
|
|
2434
|
+
binding.installationId ?? null,
|
|
2435
|
+
binding.runtime,
|
|
2436
|
+
binding.profile ?? null,
|
|
2437
|
+
JSON.stringify(binding.capabilities ?? {}),
|
|
2438
|
+
binding.boundAt,
|
|
2439
|
+
);
|
|
2097
2440
|
}
|
|
2098
|
-
|
|
2099
2441
|
unbindRuntime(bindingId: string): boolean {
|
|
2100
|
-
const result = this.
|
|
2101
|
-
.prepare('DELETE FROM agent_runtime_bindings WHERE id = ?')
|
|
2102
|
-
.run(bindingId);
|
|
2442
|
+
const result = this.prepare('DELETE FROM agent_runtime_bindings WHERE id = ?').run(bindingId);
|
|
2103
2443
|
return Number(result.changes) > 0;
|
|
2104
2444
|
}
|
|
2105
|
-
|
|
2106
2445
|
/**
|
|
2107
2446
|
* Advisory only. Records that Synomem observed this binding act — never that
|
|
2108
2447
|
* the runtime is reachable now, and never that a delivery succeeded.
|
|
2109
2448
|
*/
|
|
2110
2449
|
touchRuntimeBinding(agentId: string, runtime: string, at: string): void {
|
|
2111
|
-
this.
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
)
|
|
2115
|
-
.run(at, agentId, runtime);
|
|
2450
|
+
this.prepare(
|
|
2451
|
+
'UPDATE agent_runtime_bindings SET last_seen_at = ? WHERE agent_id = ? AND runtime = ?',
|
|
2452
|
+
).run(at, agentId, runtime);
|
|
2116
2453
|
}
|
|
2117
|
-
|
|
2118
2454
|
/**
|
|
2119
2455
|
* Writes an agent's aliases, refusing any that would make a name ambiguous.
|
|
2120
2456
|
*
|
|
@@ -2127,47 +2463,54 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
2127
2463
|
private insertAliases(agentId: string, aliases: string[]): void {
|
|
2128
2464
|
for (const alias of aliases) {
|
|
2129
2465
|
const normalized = alias.trim().toLowerCase();
|
|
2130
|
-
const conflictingAgent = this.
|
|
2131
|
-
|
|
2132
|
-
|
|
2466
|
+
const conflictingAgent = this.prepare(
|
|
2467
|
+
'SELECT id FROM agents WHERE lower(id) = ? AND id != ?',
|
|
2468
|
+
).get(normalized, agentId) as
|
|
2469
|
+
| {
|
|
2470
|
+
id: string;
|
|
2471
|
+
}
|
|
2472
|
+
| undefined;
|
|
2133
2473
|
if (conflictingAgent) {
|
|
2134
2474
|
throw new SynomemError(
|
|
2135
2475
|
'ALIAS_CONFLICT',
|
|
2136
2476
|
`Alias "${alias}" is already the canonical ID of agent ${conflictingAgent.id}. Aliases must resolve to exactly one agent.`,
|
|
2137
2477
|
);
|
|
2138
2478
|
}
|
|
2139
|
-
const conflictingAlias = this.
|
|
2140
|
-
|
|
2141
|
-
|
|
2479
|
+
const conflictingAlias = this.prepare(
|
|
2480
|
+
'SELECT agent_id FROM aliases WHERE normalized_alias = ? AND agent_id != ?',
|
|
2481
|
+
).get(normalized, agentId) as
|
|
2482
|
+
| {
|
|
2483
|
+
agent_id: string;
|
|
2484
|
+
}
|
|
2485
|
+
| undefined;
|
|
2142
2486
|
if (conflictingAlias) {
|
|
2143
2487
|
throw new SynomemError(
|
|
2144
2488
|
'ALIAS_CONFLICT',
|
|
2145
2489
|
`Alias "${alias}" already belongs to agent ${conflictingAlias.agent_id}. Aliases must resolve to exactly one agent.`,
|
|
2146
2490
|
);
|
|
2147
2491
|
}
|
|
2148
|
-
this.
|
|
2149
|
-
|
|
2150
|
-
|
|
2492
|
+
this.prepare('INSERT INTO aliases(alias, agent_id, normalized_alias) VALUES (?, ?, ?)').run(
|
|
2493
|
+
alias,
|
|
2494
|
+
agentId,
|
|
2495
|
+
normalized,
|
|
2496
|
+
);
|
|
2151
2497
|
}
|
|
2152
2498
|
}
|
|
2153
|
-
|
|
2154
2499
|
listAgents(): AgentProfile[] {
|
|
2155
|
-
const rows = this.
|
|
2156
|
-
|
|
2157
|
-
|
|
2500
|
+
const rows = this.prepare(
|
|
2501
|
+
'SELECT profile_json FROM agents ORDER BY id ASC',
|
|
2502
|
+
).all() as unknown as ProfileRow[];
|
|
2158
2503
|
return rows.map((row) => profileSchema.parse(JSON.parse(row.profile_json)));
|
|
2159
2504
|
}
|
|
2160
|
-
|
|
2161
2505
|
replaceProjectionManifest(paths: string[], generatedAt: string): void {
|
|
2162
2506
|
this.transactionSync(() => {
|
|
2163
|
-
this.
|
|
2164
|
-
const insert = this.
|
|
2507
|
+
this.prepare('DELETE FROM projection_manifest').run();
|
|
2508
|
+
const insert = this.prepare(
|
|
2165
2509
|
'INSERT INTO projection_manifest(path, generated_at) VALUES (?, ?)',
|
|
2166
2510
|
);
|
|
2167
2511
|
for (const path of paths) insert.run(path, generatedAt);
|
|
2168
2512
|
});
|
|
2169
2513
|
}
|
|
2170
|
-
|
|
2171
2514
|
/**
|
|
2172
2515
|
* @param directory the agent's projection directory name, which is its
|
|
2173
2516
|
* handle rather than its canonical ID: these rows are keyed by the path on
|
|
@@ -2175,24 +2518,23 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
2175
2518
|
*/
|
|
2176
2519
|
replaceAgentProjectionManifest(directory: string, paths: string[], generatedAt: string): void {
|
|
2177
2520
|
this.transactionSync(() => {
|
|
2178
|
-
this.
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2521
|
+
this.prepare('DELETE FROM projection_manifest WHERE path LIKE ? OR path LIKE ?').run(
|
|
2522
|
+
`${directory}/%`,
|
|
2523
|
+
`${directory}\\%`,
|
|
2524
|
+
);
|
|
2525
|
+
const insert = this.prepare(
|
|
2182
2526
|
'INSERT INTO projection_manifest(path, generated_at) VALUES (?, ?)',
|
|
2183
2527
|
);
|
|
2184
2528
|
for (const path of paths) insert.run(path, generatedAt);
|
|
2185
2529
|
});
|
|
2186
2530
|
}
|
|
2187
|
-
|
|
2188
2531
|
projectionManifest(): string[] {
|
|
2189
2532
|
return (
|
|
2190
|
-
this.
|
|
2533
|
+
this.prepare('SELECT path FROM projection_manifest ORDER BY path').all() as unknown as {
|
|
2191
2534
|
path: string;
|
|
2192
2535
|
}[]
|
|
2193
2536
|
).map((row) => row.path);
|
|
2194
2537
|
}
|
|
2195
|
-
|
|
2196
2538
|
/**
|
|
2197
2539
|
* Re-point manifest paths from one agent directory to another.
|
|
2198
2540
|
*
|
|
@@ -2211,8 +2553,8 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
2211
2553
|
prefixes.some((prefix) => entry.path.startsWith(prefix)),
|
|
2212
2554
|
);
|
|
2213
2555
|
if (!rows.length) return;
|
|
2214
|
-
const remove = this.
|
|
2215
|
-
const insert = this.
|
|
2556
|
+
const remove = this.prepare('DELETE FROM projection_manifest WHERE path = ?');
|
|
2557
|
+
const insert = this.prepare(
|
|
2216
2558
|
'INSERT OR REPLACE INTO projection_manifest(path, generated_at) VALUES (?, ?)',
|
|
2217
2559
|
);
|
|
2218
2560
|
for (const row of rows) {
|
|
@@ -2220,29 +2562,31 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
2220
2562
|
insert.run(`${nextHandle}${row.path.slice(previousHandle.length)}`, row.generatedAt);
|
|
2221
2563
|
}
|
|
2222
2564
|
}
|
|
2223
|
-
|
|
2224
2565
|
/** The manifest with the time each path was written, newest first. */
|
|
2225
|
-
projectionManifestEntries(): {
|
|
2566
|
+
projectionManifestEntries(): {
|
|
2567
|
+
path: string;
|
|
2568
|
+
generatedAt: string;
|
|
2569
|
+
}[] {
|
|
2226
2570
|
return (
|
|
2227
|
-
this.
|
|
2228
|
-
|
|
2229
|
-
|
|
2571
|
+
this.prepare(
|
|
2572
|
+
'SELECT path, generated_at FROM projection_manifest ORDER BY generated_at DESC',
|
|
2573
|
+
).all() as unknown as {
|
|
2574
|
+
path: string;
|
|
2575
|
+
generated_at: string;
|
|
2576
|
+
}[]
|
|
2230
2577
|
).map((row) => ({ path: row.path, generatedAt: row.generated_at }));
|
|
2231
2578
|
}
|
|
2232
|
-
|
|
2233
2579
|
integrityCheck(): string[] {
|
|
2234
|
-
const rows = this.
|
|
2580
|
+
const rows = this.prepare('PRAGMA integrity_check').all() as unknown as Record<
|
|
2235
2581
|
string,
|
|
2236
2582
|
string
|
|
2237
2583
|
>[];
|
|
2238
2584
|
return rows.map((row) => Object.values(row)[0] ?? 'unknown');
|
|
2239
2585
|
}
|
|
2240
|
-
|
|
2241
2586
|
journalMode(): string {
|
|
2242
|
-
const row = this.
|
|
2587
|
+
const row = this.prepare('PRAGMA journal_mode').get() as Record<string, string>;
|
|
2243
2588
|
return Object.values(row)[0] ?? 'unknown';
|
|
2244
2589
|
}
|
|
2245
|
-
|
|
2246
2590
|
async backup(destination: string): Promise<string> {
|
|
2247
2591
|
this.assertWritable();
|
|
2248
2592
|
const output = resolve(destination);
|
|
@@ -2268,7 +2612,6 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
2268
2612
|
rmdirSync(temporaryDirectory);
|
|
2269
2613
|
}
|
|
2270
2614
|
}
|
|
2271
|
-
|
|
2272
2615
|
private restrictDatabaseFiles(): void {
|
|
2273
2616
|
if (this.readOnly) return;
|
|
2274
2617
|
for (const path of [
|
|
@@ -2279,7 +2622,6 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
2279
2622
|
if (existsSync(path)) chmodSync(path, 0o600);
|
|
2280
2623
|
}
|
|
2281
2624
|
}
|
|
2282
|
-
|
|
2283
2625
|
close(): void {
|
|
2284
2626
|
this.database?.close();
|
|
2285
2627
|
this.database = undefined;
|