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