synomem 0.1.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/AGENTS.md +42 -0
- package/ARCHITECTURE.md +126 -0
- package/CHANGELOG.md +35 -0
- package/CODE_OF_CONDUCT.md +29 -0
- package/CONTRIBUTING.md +48 -0
- package/LICENSE +21 -0
- package/README.md +247 -0
- package/SECURITY.md +117 -0
- package/dist/backend.d.ts +9 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/backend.js +60 -0
- package/dist/backend.js.map +1 -0
- package/dist/cli.d.ts +34 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1040 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +168 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1031 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +33 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +153 -0
- package/dist/config.js.map +1 -0
- package/dist/credentials.d.ts +36 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +127 -0
- package/dist/credentials.js.map +1 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +60 -0
- package/dist/errors.js.map +1 -0
- package/dist/fs-utils.d.ts +7 -0
- package/dist/fs-utils.d.ts.map +1 -0
- package/dist/fs-utils.js +68 -0
- package/dist/fs-utils.js.map +1 -0
- package/dist/import.d.ts +655 -0
- package/dist/import.d.ts.map +1 -0
- package/dist/import.js +226 -0
- package/dist/import.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/index.d.ts +14 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +698 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp-server.d.ts +3 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +45 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/oauth.d.ts +27 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +201 -0
- package/dist/oauth.js.map +1 -0
- package/dist/ports/projections.d.ts +6 -0
- package/dist/ports/projections.d.ts.map +1 -0
- package/dist/ports/projections.js +2 -0
- package/dist/ports/projections.js.map +1 -0
- package/dist/ports/repository.d.ts +31 -0
- package/dist/ports/repository.d.ts.map +1 -0
- package/dist/ports/repository.js +2 -0
- package/dist/ports/repository.js.map +1 -0
- package/dist/projections.d.ts +24 -0
- package/dist/projections.d.ts.map +1 -0
- package/dist/projections.js +531 -0
- package/dist/projections.js.map +1 -0
- package/dist/remote.d.ts +120 -0
- package/dist/remote.d.ts.map +1 -0
- package/dist/remote.js +286 -0
- package/dist/remote.js.map +1 -0
- package/dist/schemas.d.ts +901 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +424 -0
- package/dist/schemas.js.map +1 -0
- package/dist/service.d.ts +126 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +2 -0
- package/dist/service.js.map +1 -0
- package/dist/skill-install.d.ts +34 -0
- package/dist/skill-install.d.ts.map +1 -0
- package/dist/skill-install.js +256 -0
- package/dist/skill-install.js.map +1 -0
- package/dist/storage.d.ts +94 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +1229 -0
- package/dist/storage.js.map +1 -0
- package/dist/types.d.ts +442 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +14 -0
- package/dist/version.js.map +1 -0
- package/docs/cli.md +180 -0
- package/docs/examples.md +70 -0
- package/docs/mcp.md +96 -0
- package/docs/recovery.md +45 -0
- package/docs/releasing.md +61 -0
- package/docs/skill.md +93 -0
- package/docs/storage-format.md +83 -0
- package/openapi/synomem-v1.yaml +674 -0
- package/package.json +100 -0
- package/skills/synomem/SKILL.md +71 -0
- package/skills/synomem/agents/openai.yaml +8 -0
- package/skills/synomem/references/examples.md +28 -0
- package/src/backend.ts +91 -0
- package/src/cli.ts +1623 -0
- package/src/client.ts +1261 -0
- package/src/config.ts +170 -0
- package/src/credentials.ts +194 -0
- package/src/errors.ts +64 -0
- package/src/fs-utils.ts +85 -0
- package/src/import.ts +284 -0
- package/src/index.ts +61 -0
- package/src/mcp/index.ts +973 -0
- package/src/mcp-server.ts +46 -0
- package/src/oauth.ts +252 -0
- package/src/ports/projections.ts +6 -0
- package/src/ports/repository.ts +67 -0
- package/src/projections.ts +624 -0
- package/src/remote.ts +516 -0
- package/src/schemas.ts +457 -0
- package/src/service.ts +140 -0
- package/src/skill-install.ts +332 -0
- package/src/storage.ts +1603 -0
- package/src/types.ts +458 -0
- package/src/version.ts +16 -0
package/src/storage.ts
ADDED
|
@@ -0,0 +1,1603 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
closeSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
linkSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
mkdtempSync,
|
|
9
|
+
openSync,
|
|
10
|
+
rmdirSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
} from 'node:fs';
|
|
13
|
+
import { ulid } from 'ulid';
|
|
14
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
15
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
16
|
+
import { defaultConfig, mergeConfig } from './config.js';
|
|
17
|
+
import type { SynomemRepository } from './ports/repository.js';
|
|
18
|
+
import { asSynomemError, SynomemError } from './errors.js';
|
|
19
|
+
import {
|
|
20
|
+
assertNoSymlinkEscape,
|
|
21
|
+
atomicWriteFile,
|
|
22
|
+
ensureDirectory,
|
|
23
|
+
readJsonFile,
|
|
24
|
+
} from './fs-utils.js';
|
|
25
|
+
import { actorSchema, eventSchema, profileSchema } from './schemas.js';
|
|
26
|
+
import type {
|
|
27
|
+
ActorIdentity,
|
|
28
|
+
SynomemConfig,
|
|
29
|
+
SynomemConfigOverrides,
|
|
30
|
+
AgentProfile,
|
|
31
|
+
ChangePage,
|
|
32
|
+
KudosChange,
|
|
33
|
+
SynomemEvent,
|
|
34
|
+
ItemChange,
|
|
35
|
+
ItemListInput,
|
|
36
|
+
ItemSummary,
|
|
37
|
+
RecordKind,
|
|
38
|
+
KudosListInput,
|
|
39
|
+
KudosSummary,
|
|
40
|
+
Page,
|
|
41
|
+
} from './types.js';
|
|
42
|
+
|
|
43
|
+
interface EventRow {
|
|
44
|
+
id: string;
|
|
45
|
+
payload: string;
|
|
46
|
+
sequence?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface CurrentRow {
|
|
50
|
+
kudos_id: string;
|
|
51
|
+
given_sequence: number;
|
|
52
|
+
created_at: string;
|
|
53
|
+
recipient_agent_id: string;
|
|
54
|
+
recipient_display_name: string;
|
|
55
|
+
actor_kind: ActorIdentity['kind'];
|
|
56
|
+
actor_id: string;
|
|
57
|
+
actor_display_name: string | null;
|
|
58
|
+
title: string;
|
|
59
|
+
tags_json: string;
|
|
60
|
+
visibility: KudosSummary['visibility'];
|
|
61
|
+
status: KudosSummary['status'];
|
|
62
|
+
revocation_status: KudosSummary['revocationStatus'];
|
|
63
|
+
updated_sequence: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface ItemRow {
|
|
67
|
+
item_id: string;
|
|
68
|
+
kind: RecordKind;
|
|
69
|
+
created_sequence: number;
|
|
70
|
+
updated_sequence: number;
|
|
71
|
+
created_at: string;
|
|
72
|
+
updated_at: string;
|
|
73
|
+
actor_kind: ActorIdentity['kind'];
|
|
74
|
+
actor_id: string;
|
|
75
|
+
actor_display_name: string | null;
|
|
76
|
+
title: string;
|
|
77
|
+
tags_json: string;
|
|
78
|
+
visibility: ItemSummary['visibility'];
|
|
79
|
+
status: string;
|
|
80
|
+
recipient_agent_id: string | null;
|
|
81
|
+
recipient_display_name: string | null;
|
|
82
|
+
owner_agent_id: string | null;
|
|
83
|
+
owner_display_name: string | null;
|
|
84
|
+
assignee_agent_id: string | null;
|
|
85
|
+
assignee_display_name: string | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface EventScan {
|
|
89
|
+
events: SynomemEvent[];
|
|
90
|
+
invalid: Array<{ id: string; error: SynomemError }>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface ProfileRow {
|
|
94
|
+
profile_json: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const migrationV1 = `
|
|
98
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
99
|
+
version INTEGER PRIMARY KEY,
|
|
100
|
+
applied_at TEXT NOT NULL
|
|
101
|
+
) STRICT;
|
|
102
|
+
|
|
103
|
+
CREATE TABLE IF NOT EXISTS agents (
|
|
104
|
+
id TEXT PRIMARY KEY,
|
|
105
|
+
display_name TEXT NOT NULL,
|
|
106
|
+
profile_json TEXT NOT NULL CHECK (json_valid(profile_json)),
|
|
107
|
+
created_at TEXT NOT NULL,
|
|
108
|
+
updated_at TEXT NOT NULL
|
|
109
|
+
) STRICT;
|
|
110
|
+
|
|
111
|
+
CREATE TABLE IF NOT EXISTS aliases (
|
|
112
|
+
alias TEXT PRIMARY KEY,
|
|
113
|
+
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE RESTRICT
|
|
114
|
+
) STRICT;
|
|
115
|
+
|
|
116
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
117
|
+
id TEXT PRIMARY KEY,
|
|
118
|
+
schema_version INTEGER NOT NULL,
|
|
119
|
+
type TEXT NOT NULL,
|
|
120
|
+
created_at TEXT NOT NULL,
|
|
121
|
+
actor_kind TEXT NOT NULL,
|
|
122
|
+
actor_id TEXT NOT NULL,
|
|
123
|
+
recipient_agent_id TEXT,
|
|
124
|
+
kudos_id TEXT,
|
|
125
|
+
visibility TEXT,
|
|
126
|
+
idempotency_key TEXT,
|
|
127
|
+
payload TEXT NOT NULL CHECK (json_valid(payload))
|
|
128
|
+
) STRICT;
|
|
129
|
+
|
|
130
|
+
CREATE UNIQUE INDEX IF NOT EXISTS events_actor_idempotency
|
|
131
|
+
ON events(actor_kind, actor_id, idempotency_key)
|
|
132
|
+
WHERE type = 'kudos.given' AND idempotency_key IS NOT NULL;
|
|
133
|
+
|
|
134
|
+
CREATE INDEX IF NOT EXISTS events_type_created ON events(type, created_at, id);
|
|
135
|
+
CREATE INDEX IF NOT EXISTS events_recipient ON events(recipient_agent_id, created_at, id);
|
|
136
|
+
CREATE INDEX IF NOT EXISTS events_kudos_id ON events(kudos_id, created_at, id);
|
|
137
|
+
|
|
138
|
+
CREATE TABLE IF NOT EXISTS projection_manifest (
|
|
139
|
+
path TEXT PRIMARY KEY,
|
|
140
|
+
generated_at TEXT NOT NULL
|
|
141
|
+
) STRICT;
|
|
142
|
+
|
|
143
|
+
CREATE TRIGGER IF NOT EXISTS events_append_only_update
|
|
144
|
+
BEFORE UPDATE ON events BEGIN
|
|
145
|
+
SELECT RAISE(ABORT, 'events are append-only');
|
|
146
|
+
END;
|
|
147
|
+
|
|
148
|
+
CREATE TRIGGER IF NOT EXISTS events_append_only_delete
|
|
149
|
+
BEFORE DELETE ON events BEGIN
|
|
150
|
+
SELECT RAISE(ABORT, 'events are append-only');
|
|
151
|
+
END;
|
|
152
|
+
`;
|
|
153
|
+
|
|
154
|
+
const migrationV2 = `
|
|
155
|
+
DROP TRIGGER IF EXISTS events_append_only_update;
|
|
156
|
+
ALTER TABLE events ADD COLUMN sequence INTEGER;
|
|
157
|
+
UPDATE events SET sequence = rowid;
|
|
158
|
+
CREATE UNIQUE INDEX events_sequence ON events(sequence);
|
|
159
|
+
CREATE TRIGGER events_sequence_required
|
|
160
|
+
BEFORE INSERT ON events WHEN NEW.sequence IS NULL BEGIN
|
|
161
|
+
SELECT RAISE(ABORT, 'events require an ingestion sequence');
|
|
162
|
+
END;
|
|
163
|
+
CREATE TRIGGER events_append_only_update
|
|
164
|
+
BEFORE UPDATE ON events BEGIN
|
|
165
|
+
SELECT RAISE(ABORT, 'events are append-only');
|
|
166
|
+
END;
|
|
167
|
+
|
|
168
|
+
CREATE TABLE kudos_current (
|
|
169
|
+
kudos_id TEXT PRIMARY KEY,
|
|
170
|
+
given_sequence INTEGER NOT NULL UNIQUE,
|
|
171
|
+
created_at TEXT NOT NULL,
|
|
172
|
+
recipient_agent_id TEXT NOT NULL,
|
|
173
|
+
recipient_display_name TEXT NOT NULL,
|
|
174
|
+
actor_kind TEXT NOT NULL,
|
|
175
|
+
actor_id TEXT NOT NULL,
|
|
176
|
+
actor_display_name TEXT,
|
|
177
|
+
title TEXT NOT NULL,
|
|
178
|
+
tags_json TEXT NOT NULL CHECK (json_valid(tags_json)),
|
|
179
|
+
visibility TEXT NOT NULL,
|
|
180
|
+
status TEXT NOT NULL,
|
|
181
|
+
revocation_status TEXT NOT NULL,
|
|
182
|
+
updated_sequence INTEGER NOT NULL
|
|
183
|
+
) STRICT;
|
|
184
|
+
CREATE INDEX kudos_current_recipient ON kudos_current(recipient_agent_id, given_sequence DESC);
|
|
185
|
+
CREATE INDEX kudos_current_actor ON kudos_current(actor_kind, actor_id, given_sequence DESC);
|
|
186
|
+
CREATE INDEX kudos_current_status ON kudos_current(status, revocation_status, given_sequence DESC);
|
|
187
|
+
`;
|
|
188
|
+
|
|
189
|
+
const migrationV3 = `
|
|
190
|
+
DROP TRIGGER IF EXISTS events_append_only_update;
|
|
191
|
+
DROP TRIGGER IF EXISTS events_append_only_delete;
|
|
192
|
+
ALTER TABLE events ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'legacy-local';
|
|
193
|
+
ALTER TABLE events ADD COLUMN aggregate_id TEXT;
|
|
194
|
+
ALTER TABLE events ADD COLUMN aggregate_version INTEGER;
|
|
195
|
+
ALTER TABLE events ADD COLUMN item_kind TEXT;
|
|
196
|
+
|
|
197
|
+
UPDATE events SET aggregate_id = CASE
|
|
198
|
+
WHEN type IN ('kudos.acknowledged', 'kudos.revoked') THEN kudos_id
|
|
199
|
+
WHEN type = 'kudos.given' THEN id
|
|
200
|
+
WHEN type = 'agent.created' THEN json_extract(payload, '$.agent.id')
|
|
201
|
+
WHEN type = 'agent.updated' THEN json_extract(payload, '$.agentId')
|
|
202
|
+
ELSE id END;
|
|
203
|
+
WITH ranked AS (
|
|
204
|
+
SELECT id, ROW_NUMBER() OVER (
|
|
205
|
+
PARTITION BY workspace_id, aggregate_id ORDER BY sequence
|
|
206
|
+
) AS version
|
|
207
|
+
FROM events
|
|
208
|
+
)
|
|
209
|
+
UPDATE events SET aggregate_version = (
|
|
210
|
+
SELECT version FROM ranked WHERE ranked.id = events.id
|
|
211
|
+
);
|
|
212
|
+
UPDATE events SET item_kind = 'kudos' WHERE type LIKE 'kudos.%';
|
|
213
|
+
|
|
214
|
+
DROP INDEX IF EXISTS events_actor_idempotency;
|
|
215
|
+
CREATE UNIQUE INDEX events_actor_idempotency
|
|
216
|
+
ON events(actor_kind, actor_id, idempotency_key)
|
|
217
|
+
WHERE idempotency_key IS NOT NULL;
|
|
218
|
+
CREATE INDEX events_workspace_sequence ON events(workspace_id, sequence);
|
|
219
|
+
CREATE INDEX events_aggregate ON events(workspace_id, aggregate_id, sequence);
|
|
220
|
+
CREATE UNIQUE INDEX events_aggregate_version
|
|
221
|
+
ON events(workspace_id, aggregate_id, aggregate_version);
|
|
222
|
+
CREATE INDEX events_item_kind ON events(item_kind, sequence);
|
|
223
|
+
|
|
224
|
+
CREATE TABLE items_current (
|
|
225
|
+
item_id TEXT PRIMARY KEY,
|
|
226
|
+
kind TEXT NOT NULL,
|
|
227
|
+
created_sequence INTEGER NOT NULL UNIQUE,
|
|
228
|
+
updated_sequence INTEGER NOT NULL,
|
|
229
|
+
created_at TEXT NOT NULL,
|
|
230
|
+
updated_at TEXT NOT NULL,
|
|
231
|
+
actor_kind TEXT NOT NULL,
|
|
232
|
+
actor_id TEXT NOT NULL,
|
|
233
|
+
actor_display_name TEXT,
|
|
234
|
+
title TEXT NOT NULL,
|
|
235
|
+
tags_json TEXT NOT NULL CHECK (json_valid(tags_json)),
|
|
236
|
+
visibility TEXT NOT NULL,
|
|
237
|
+
status TEXT NOT NULL,
|
|
238
|
+
recipient_agent_id TEXT,
|
|
239
|
+
recipient_display_name TEXT,
|
|
240
|
+
owner_agent_id TEXT,
|
|
241
|
+
owner_display_name TEXT,
|
|
242
|
+
assignee_agent_id TEXT,
|
|
243
|
+
assignee_display_name TEXT
|
|
244
|
+
) STRICT;
|
|
245
|
+
CREATE INDEX items_current_kind_sequence ON items_current(kind, created_sequence DESC);
|
|
246
|
+
CREATE INDEX items_current_recipient ON items_current(recipient_agent_id, created_sequence DESC);
|
|
247
|
+
CREATE INDEX items_current_owner ON items_current(owner_agent_id, created_sequence DESC);
|
|
248
|
+
CREATE INDEX items_current_assignee ON items_current(assignee_agent_id, created_sequence DESC);
|
|
249
|
+
CREATE INDEX items_current_status ON items_current(kind, status, created_sequence DESC);
|
|
250
|
+
|
|
251
|
+
CREATE TRIGGER events_append_only_update
|
|
252
|
+
BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events are append-only'); END;
|
|
253
|
+
CREATE TRIGGER events_append_only_delete
|
|
254
|
+
BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events are append-only'); END;
|
|
255
|
+
`;
|
|
256
|
+
|
|
257
|
+
const CONTEXT_BUDGET_BYTES = 24_576;
|
|
258
|
+
|
|
259
|
+
function encodeCursor(kind: 'list' | 'items' | 'change', sequence: number): string {
|
|
260
|
+
return Buffer.from(JSON.stringify({ v: 1, kind, sequence }), 'utf8').toString('base64url');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function decodeCursor(cursor: string, kind: 'list' | 'items' | 'change'): number {
|
|
264
|
+
try {
|
|
265
|
+
const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as {
|
|
266
|
+
v?: unknown;
|
|
267
|
+
kind?: unknown;
|
|
268
|
+
sequence?: unknown;
|
|
269
|
+
};
|
|
270
|
+
if (
|
|
271
|
+
value.v !== 1 ||
|
|
272
|
+
value.kind !== kind ||
|
|
273
|
+
typeof value.sequence !== 'number' ||
|
|
274
|
+
!Number.isSafeInteger(value.sequence) ||
|
|
275
|
+
value.sequence < 0
|
|
276
|
+
) {
|
|
277
|
+
throw new Error('invalid cursor');
|
|
278
|
+
}
|
|
279
|
+
return value.sequence;
|
|
280
|
+
} catch {
|
|
281
|
+
throw new SynomemError('INVALID_INPUT', `Invalid ${kind} cursor.`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function itemSummaryFromRow(row: ItemRow): ItemSummary {
|
|
286
|
+
return {
|
|
287
|
+
id: row.item_id,
|
|
288
|
+
kind: row.kind,
|
|
289
|
+
createdAt: row.created_at,
|
|
290
|
+
updatedAt: row.updated_at,
|
|
291
|
+
actor: {
|
|
292
|
+
kind: row.actor_kind,
|
|
293
|
+
id: row.actor_id,
|
|
294
|
+
...(row.actor_display_name ? { displayName: row.actor_display_name } : {}),
|
|
295
|
+
},
|
|
296
|
+
title: row.title,
|
|
297
|
+
tags: JSON.parse(row.tags_json) as string[],
|
|
298
|
+
visibility: row.visibility,
|
|
299
|
+
status: row.status,
|
|
300
|
+
...(row.recipient_agent_id ? { recipientAgentId: row.recipient_agent_id } : {}),
|
|
301
|
+
...(row.owner_agent_id ? { ownerAgentId: row.owner_agent_id } : {}),
|
|
302
|
+
...(row.assignee_agent_id ? { assigneeAgentId: row.assignee_agent_id } : {}),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function eventKind(event: SynomemEvent): RecordKind | undefined {
|
|
307
|
+
if (event.type.startsWith('kudos.')) return 'kudos';
|
|
308
|
+
if (event.type.startsWith('memo.')) return 'memo';
|
|
309
|
+
if (event.type.startsWith('note.')) return 'note';
|
|
310
|
+
if (event.type.startsWith('todo.')) return 'todo';
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function summaryFromRow(row: CurrentRow): KudosSummary {
|
|
315
|
+
return {
|
|
316
|
+
id: row.kudos_id,
|
|
317
|
+
kind: 'kudos',
|
|
318
|
+
createdAt: row.created_at,
|
|
319
|
+
updatedAt: row.created_at,
|
|
320
|
+
recipientAgentId: row.recipient_agent_id,
|
|
321
|
+
recipientDisplayName: row.recipient_display_name,
|
|
322
|
+
actor: {
|
|
323
|
+
kind: row.actor_kind,
|
|
324
|
+
id: row.actor_id,
|
|
325
|
+
...(row.actor_display_name ? { displayName: row.actor_display_name } : {}),
|
|
326
|
+
},
|
|
327
|
+
title: row.title,
|
|
328
|
+
tags: JSON.parse(row.tags_json) as string[],
|
|
329
|
+
visibility: row.visibility,
|
|
330
|
+
status: row.status,
|
|
331
|
+
revocationStatus: row.revocation_status,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export interface StorageOptions {
|
|
336
|
+
home: string;
|
|
337
|
+
readOnly: boolean;
|
|
338
|
+
config?: SynomemConfigOverrides;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export class SynomemStorage implements SynomemRepository {
|
|
342
|
+
readonly home: string;
|
|
343
|
+
readonly storageDirectory: string;
|
|
344
|
+
readonly databasePath: string;
|
|
345
|
+
readonly configPath: string;
|
|
346
|
+
readonly readOnly: boolean;
|
|
347
|
+
config: SynomemConfig = defaultConfig;
|
|
348
|
+
private database?: DatabaseSync;
|
|
349
|
+
private readonly configOverrides?: SynomemConfigOverrides;
|
|
350
|
+
private validatedEventSequence = 0;
|
|
351
|
+
private transactionTail: Promise<void> = Promise.resolve();
|
|
352
|
+
|
|
353
|
+
constructor(options: StorageOptions) {
|
|
354
|
+
this.home = resolve(options.home);
|
|
355
|
+
this.storageDirectory = join(this.home, 'synomem');
|
|
356
|
+
this.databasePath = join(this.storageDirectory, 'synomem.sqlite3');
|
|
357
|
+
this.configPath = join(this.storageDirectory, 'config.json');
|
|
358
|
+
this.readOnly = options.readOnly;
|
|
359
|
+
this.configOverrides = options.config;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
init(): void {
|
|
363
|
+
try {
|
|
364
|
+
if (this.configOverrides?.backend?.kind === 'remote') {
|
|
365
|
+
throw new SynomemError(
|
|
366
|
+
'CONFIG_INVALID',
|
|
367
|
+
'SynomemClient supports only the local backend. Use RemoteSynomemService for remote workspaces.',
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
if (!existsSync(this.home)) {
|
|
371
|
+
if (this.readOnly) throw new SynomemError('READ_ONLY', 'Storage home does not exist.');
|
|
372
|
+
mkdirSync(this.home, { recursive: true, mode: 0o700 });
|
|
373
|
+
}
|
|
374
|
+
if (lstatSync(this.home).isSymbolicLink()) {
|
|
375
|
+
throw new SynomemError(
|
|
376
|
+
'UNSAFE_PATH',
|
|
377
|
+
'The configured Synomem home cannot be a symbolic link.',
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
if (!this.readOnly) ensureDirectory(this.storageDirectory);
|
|
381
|
+
assertNoSymlinkEscape(this.home, this.storageDirectory);
|
|
382
|
+
if (!this.readOnly) {
|
|
383
|
+
chmodSync(this.storageDirectory, 0o700);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const fileConfig = existsSync(this.configPath) ? readJsonFile(this.configPath) : undefined;
|
|
387
|
+
const initialConfig = fileConfig ?? { ...defaultConfig, workspaceId: ulid() };
|
|
388
|
+
this.config = mergeConfig(initialConfig, this.configOverrides);
|
|
389
|
+
if (this.config.backend.kind !== 'local') {
|
|
390
|
+
throw new SynomemError(
|
|
391
|
+
'CONFIG_INVALID',
|
|
392
|
+
'SynomemClient supports only the local backend. Use RemoteSynomemService for remote workspaces.',
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
if (this.readOnly && !existsSync(this.databasePath)) {
|
|
396
|
+
throw new SynomemError(
|
|
397
|
+
'READ_ONLY',
|
|
398
|
+
'The Synomem database does not exist in read-only mode.',
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
if (!this.readOnly && !existsSync(this.databasePath)) {
|
|
402
|
+
closeSync(openSync(this.databasePath, 'wx', 0o600));
|
|
403
|
+
}
|
|
404
|
+
if (!this.readOnly && !existsSync(this.configPath)) {
|
|
405
|
+
atomicWriteFile(this.configPath, `${JSON.stringify(this.config, null, 2)}\n`);
|
|
406
|
+
} else if (
|
|
407
|
+
!this.readOnly &&
|
|
408
|
+
typeof fileConfig === 'object' &&
|
|
409
|
+
fileConfig !== null &&
|
|
410
|
+
(fileConfig as { schemaVersion?: unknown }).schemaVersion === 2
|
|
411
|
+
) {
|
|
412
|
+
const migrated = mergeConfig(fileConfig, undefined, {});
|
|
413
|
+
atomicWriteFile(this.configPath, `${JSON.stringify(migrated, null, 2)}\n`);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
this.database = new DatabaseSync(this.databasePath, {
|
|
417
|
+
readOnly: this.readOnly,
|
|
418
|
+
enableForeignKeyConstraints: true,
|
|
419
|
+
});
|
|
420
|
+
this.database.exec('PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON;');
|
|
421
|
+
if (!this.readOnly) {
|
|
422
|
+
this.database.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;');
|
|
423
|
+
this.migrate();
|
|
424
|
+
this.restrictDatabaseFiles();
|
|
425
|
+
} else {
|
|
426
|
+
this.assertSchemaSupported();
|
|
427
|
+
}
|
|
428
|
+
} catch (error) {
|
|
429
|
+
this.close();
|
|
430
|
+
if (error instanceof SynomemError) throw error;
|
|
431
|
+
if (error instanceof SyntaxError) {
|
|
432
|
+
throw new SynomemError('CONFIG_INVALID', 'Could not parse synomem/config.json.');
|
|
433
|
+
}
|
|
434
|
+
throw asSynomemError(error);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private migrate(): void {
|
|
439
|
+
const db = this.db();
|
|
440
|
+
const version = Number(
|
|
441
|
+
(db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
|
|
442
|
+
);
|
|
443
|
+
if (version > 3) {
|
|
444
|
+
throw new SynomemError(
|
|
445
|
+
'UNSUPPORTED_SCHEMA',
|
|
446
|
+
`Database schema version ${version} is newer than this package supports.`,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
if (version === 0) {
|
|
450
|
+
this.transactionSync(() => {
|
|
451
|
+
db.exec(migrationV1);
|
|
452
|
+
db.prepare(
|
|
453
|
+
'INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, ?)',
|
|
454
|
+
).run(1, new Date().toISOString());
|
|
455
|
+
db.exec('PRAGMA user_version = 1');
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
const currentVersion = Number(
|
|
459
|
+
(db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
|
|
460
|
+
);
|
|
461
|
+
if (currentVersion === 1) {
|
|
462
|
+
this.transactionSync(() => {
|
|
463
|
+
db.exec(migrationV2);
|
|
464
|
+
this.rebuildKudosCurrentIndex();
|
|
465
|
+
db.prepare(
|
|
466
|
+
'INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, ?)',
|
|
467
|
+
).run(2, new Date().toISOString());
|
|
468
|
+
db.exec('PRAGMA user_version = 2');
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
const afterV2 = Number(
|
|
472
|
+
(db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
|
|
473
|
+
);
|
|
474
|
+
if (afterV2 === 2) {
|
|
475
|
+
this.transactionSync(() => {
|
|
476
|
+
db.exec(migrationV3);
|
|
477
|
+
this.rebuildItemsCurrentIndex();
|
|
478
|
+
db.prepare(
|
|
479
|
+
'INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, ?)',
|
|
480
|
+
).run(3, new Date().toISOString());
|
|
481
|
+
db.exec('PRAGMA user_version = 3');
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private assertSchemaSupported(): void {
|
|
487
|
+
const version = Number(
|
|
488
|
+
(this.db().prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
|
|
489
|
+
);
|
|
490
|
+
if (version !== 3) {
|
|
491
|
+
if (version === 1 || version === 2) {
|
|
492
|
+
throw new SynomemError(
|
|
493
|
+
'UNSUPPORTED_SCHEMA',
|
|
494
|
+
'Database schema version 1 requires migration. Open this home once with readOnly: false, then retry the read-only client.',
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
throw new SynomemError(
|
|
498
|
+
'UNSUPPORTED_SCHEMA',
|
|
499
|
+
`Expected database schema version 3; found ${version}.`,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
db(): DatabaseSync {
|
|
505
|
+
if (!this.database)
|
|
506
|
+
throw new SynomemError('INTERNAL_ERROR', 'SynomemClient.init() has not completed.');
|
|
507
|
+
return this.database;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
assertWritable(): void {
|
|
511
|
+
if (this.readOnly) throw new SynomemError('READ_ONLY', 'This Synomem client is read-only.');
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
transaction<T>(operation: () => T | Promise<T>): Promise<T> {
|
|
515
|
+
const execute = async (): Promise<T> => {
|
|
516
|
+
this.assertWritable();
|
|
517
|
+
const db = this.db();
|
|
518
|
+
let began = false;
|
|
519
|
+
try {
|
|
520
|
+
db.exec('BEGIN IMMEDIATE');
|
|
521
|
+
began = true;
|
|
522
|
+
const result = await operation();
|
|
523
|
+
db.exec('COMMIT');
|
|
524
|
+
began = false;
|
|
525
|
+
this.restrictDatabaseFiles();
|
|
526
|
+
return result;
|
|
527
|
+
} catch (error) {
|
|
528
|
+
if (began) {
|
|
529
|
+
try {
|
|
530
|
+
db.exec('ROLLBACK');
|
|
531
|
+
} catch {
|
|
532
|
+
// Preserve the original failure.
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
throw asSynomemError(error);
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
const result = this.transactionTail.then(execute, execute);
|
|
539
|
+
this.transactionTail = result.then(
|
|
540
|
+
() => undefined,
|
|
541
|
+
() => undefined,
|
|
542
|
+
);
|
|
543
|
+
return result;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
transactionSync<T>(operation: () => T): T {
|
|
547
|
+
this.assertWritable();
|
|
548
|
+
const db = this.db();
|
|
549
|
+
let began = false;
|
|
550
|
+
try {
|
|
551
|
+
db.exec('BEGIN IMMEDIATE');
|
|
552
|
+
began = true;
|
|
553
|
+
const result = operation();
|
|
554
|
+
db.exec('COMMIT');
|
|
555
|
+
began = false;
|
|
556
|
+
this.restrictDatabaseFiles();
|
|
557
|
+
return result;
|
|
558
|
+
} catch (error) {
|
|
559
|
+
if (began) {
|
|
560
|
+
try {
|
|
561
|
+
db.exec('ROLLBACK');
|
|
562
|
+
} catch {
|
|
563
|
+
// Preserve the original failure.
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
throw asSynomemError(error);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
insertEvent(event: SynomemEvent): void {
|
|
571
|
+
const parsed = eventSchema.parse(event);
|
|
572
|
+
const recipientAgentId =
|
|
573
|
+
parsed.type === 'kudos.given' ||
|
|
574
|
+
parsed.type === 'kudos.acknowledged' ||
|
|
575
|
+
parsed.type === 'memo.sent' ||
|
|
576
|
+
parsed.type === 'memo.read' ||
|
|
577
|
+
parsed.type === 'memo.archived'
|
|
578
|
+
? parsed.recipientAgentId
|
|
579
|
+
: null;
|
|
580
|
+
const kudosId =
|
|
581
|
+
parsed.type === 'kudos.acknowledged' || parsed.type === 'kudos.revoked'
|
|
582
|
+
? parsed.kudosId
|
|
583
|
+
: null;
|
|
584
|
+
const visibility = 'visibility' in parsed ? parsed.visibility : null;
|
|
585
|
+
const idempotencyKey = parsed.idempotencyKey ?? null;
|
|
586
|
+
const kind = eventKind(parsed) ?? null;
|
|
587
|
+
const sequence = Number(
|
|
588
|
+
(
|
|
589
|
+
this.db().prepare('SELECT COALESCE(MAX(sequence), 0) + 1 AS next FROM events').get() as {
|
|
590
|
+
next: number;
|
|
591
|
+
}
|
|
592
|
+
).next,
|
|
593
|
+
);
|
|
594
|
+
this.db()
|
|
595
|
+
.prepare(
|
|
596
|
+
`INSERT INTO events(
|
|
597
|
+
id, schema_version, type, created_at, actor_kind, actor_id,
|
|
598
|
+
recipient_agent_id, kudos_id, visibility, idempotency_key, payload, sequence,
|
|
599
|
+
workspace_id, aggregate_id, aggregate_version, item_kind
|
|
600
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
601
|
+
)
|
|
602
|
+
.run(
|
|
603
|
+
parsed.id,
|
|
604
|
+
parsed.schemaVersion,
|
|
605
|
+
parsed.type,
|
|
606
|
+
parsed.createdAt,
|
|
607
|
+
parsed.actor.kind,
|
|
608
|
+
parsed.actor.id,
|
|
609
|
+
recipientAgentId,
|
|
610
|
+
kudosId,
|
|
611
|
+
visibility,
|
|
612
|
+
idempotencyKey,
|
|
613
|
+
JSON.stringify(parsed),
|
|
614
|
+
sequence,
|
|
615
|
+
parsed.workspaceId,
|
|
616
|
+
parsed.aggregateId,
|
|
617
|
+
parsed.aggregateVersion,
|
|
618
|
+
kind,
|
|
619
|
+
);
|
|
620
|
+
this.applyEventToCurrent(parsed, sequence);
|
|
621
|
+
this.applyEventToItems(parsed, sequence);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
private applyEventToCurrent(event: SynomemEvent, sequence: number): void {
|
|
625
|
+
if (event.type === 'kudos.given') {
|
|
626
|
+
this.db()
|
|
627
|
+
.prepare(
|
|
628
|
+
`INSERT INTO kudos_current(
|
|
629
|
+
kudos_id, given_sequence, created_at, recipient_agent_id, recipient_display_name,
|
|
630
|
+
actor_kind, actor_id, actor_display_name, title, tags_json, visibility,
|
|
631
|
+
status, revocation_status, updated_sequence
|
|
632
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'unacknowledged', 'active', ?)`,
|
|
633
|
+
)
|
|
634
|
+
.run(
|
|
635
|
+
event.id,
|
|
636
|
+
sequence,
|
|
637
|
+
event.createdAt,
|
|
638
|
+
event.recipientAgentId,
|
|
639
|
+
event.recipientDisplayName,
|
|
640
|
+
event.actor.kind,
|
|
641
|
+
event.actor.id,
|
|
642
|
+
event.actor.displayName ?? null,
|
|
643
|
+
event.title,
|
|
644
|
+
JSON.stringify(event.tags ?? []),
|
|
645
|
+
event.visibility,
|
|
646
|
+
sequence,
|
|
647
|
+
);
|
|
648
|
+
} else if (event.type === 'kudos.acknowledged') {
|
|
649
|
+
this.db()
|
|
650
|
+
.prepare(
|
|
651
|
+
"UPDATE kudos_current SET status = 'acknowledged', updated_sequence = ? WHERE kudos_id = ?",
|
|
652
|
+
)
|
|
653
|
+
.run(sequence, event.kudosId);
|
|
654
|
+
} else if (event.type === 'kudos.revoked') {
|
|
655
|
+
this.db()
|
|
656
|
+
.prepare(
|
|
657
|
+
"UPDATE kudos_current SET revocation_status = 'revoked', updated_sequence = ? WHERE kudos_id = ?",
|
|
658
|
+
)
|
|
659
|
+
.run(sequence, event.kudosId);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
private applyEventToItems(event: SynomemEvent, sequence: number): void {
|
|
664
|
+
const insert = (values: {
|
|
665
|
+
kind: RecordKind;
|
|
666
|
+
title: string;
|
|
667
|
+
tags?: string[];
|
|
668
|
+
visibility: ItemSummary['visibility'];
|
|
669
|
+
status: string;
|
|
670
|
+
recipientAgentId?: string;
|
|
671
|
+
recipientDisplayName?: string;
|
|
672
|
+
ownerAgentId?: string;
|
|
673
|
+
ownerDisplayName?: string;
|
|
674
|
+
assigneeAgentId?: string;
|
|
675
|
+
assigneeDisplayName?: string;
|
|
676
|
+
}): void => {
|
|
677
|
+
this.db()
|
|
678
|
+
.prepare(
|
|
679
|
+
`INSERT INTO items_current(
|
|
680
|
+
item_id, kind, created_sequence, updated_sequence, created_at, updated_at,
|
|
681
|
+
actor_kind, actor_id, actor_display_name, title, tags_json, visibility, status,
|
|
682
|
+
recipient_agent_id, recipient_display_name, owner_agent_id, owner_display_name,
|
|
683
|
+
assignee_agent_id, assignee_display_name
|
|
684
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
685
|
+
)
|
|
686
|
+
.run(
|
|
687
|
+
event.aggregateId,
|
|
688
|
+
values.kind,
|
|
689
|
+
sequence,
|
|
690
|
+
sequence,
|
|
691
|
+
event.createdAt,
|
|
692
|
+
event.createdAt,
|
|
693
|
+
event.actor.kind,
|
|
694
|
+
event.actor.id,
|
|
695
|
+
event.actor.displayName ?? null,
|
|
696
|
+
values.title,
|
|
697
|
+
JSON.stringify(values.tags ?? []),
|
|
698
|
+
values.visibility,
|
|
699
|
+
values.status,
|
|
700
|
+
values.recipientAgentId ?? null,
|
|
701
|
+
values.recipientDisplayName ?? null,
|
|
702
|
+
values.ownerAgentId ?? null,
|
|
703
|
+
values.ownerDisplayName ?? null,
|
|
704
|
+
values.assigneeAgentId ?? null,
|
|
705
|
+
values.assigneeDisplayName ?? null,
|
|
706
|
+
);
|
|
707
|
+
};
|
|
708
|
+
const updateStatus = (status: string): void => {
|
|
709
|
+
this.db()
|
|
710
|
+
.prepare(
|
|
711
|
+
'UPDATE items_current SET status = ?, updated_sequence = ?, updated_at = ? WHERE item_id = ?',
|
|
712
|
+
)
|
|
713
|
+
.run(status, sequence, event.createdAt, event.aggregateId);
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
if (event.type === 'kudos.given') {
|
|
717
|
+
insert({
|
|
718
|
+
kind: 'kudos',
|
|
719
|
+
title: event.title,
|
|
720
|
+
tags: event.tags,
|
|
721
|
+
visibility: event.visibility,
|
|
722
|
+
status: 'unacknowledged',
|
|
723
|
+
recipientAgentId: event.recipientAgentId,
|
|
724
|
+
recipientDisplayName: event.recipientDisplayName,
|
|
725
|
+
});
|
|
726
|
+
} else if (event.type === 'kudos.acknowledged') updateStatus('acknowledged');
|
|
727
|
+
else if (event.type === 'kudos.revoked') updateStatus('revoked');
|
|
728
|
+
else if (event.type === 'memo.sent') {
|
|
729
|
+
insert({
|
|
730
|
+
kind: 'memo',
|
|
731
|
+
title: event.subject,
|
|
732
|
+
tags: event.tags,
|
|
733
|
+
visibility: event.visibility,
|
|
734
|
+
status: 'unread',
|
|
735
|
+
recipientAgentId: event.recipientAgentId,
|
|
736
|
+
recipientDisplayName: event.recipientDisplayName,
|
|
737
|
+
});
|
|
738
|
+
} else if (event.type === 'memo.read') updateStatus('read');
|
|
739
|
+
else if (event.type === 'memo.archived') updateStatus('archived');
|
|
740
|
+
else if (event.type === 'note.created') {
|
|
741
|
+
insert({
|
|
742
|
+
kind: 'note',
|
|
743
|
+
title: event.title,
|
|
744
|
+
tags: event.tags,
|
|
745
|
+
visibility: event.visibility,
|
|
746
|
+
status: 'active',
|
|
747
|
+
ownerAgentId: event.ownerAgentId,
|
|
748
|
+
ownerDisplayName: event.ownerDisplayName,
|
|
749
|
+
});
|
|
750
|
+
} else if (event.type === 'note.revised') {
|
|
751
|
+
this.db()
|
|
752
|
+
.prepare(
|
|
753
|
+
`UPDATE items_current SET title = ?, tags_json = ?, visibility = ?,
|
|
754
|
+
updated_sequence = ?, updated_at = ? WHERE item_id = ?`,
|
|
755
|
+
)
|
|
756
|
+
.run(
|
|
757
|
+
event.title,
|
|
758
|
+
JSON.stringify(event.tags ?? []),
|
|
759
|
+
event.visibility,
|
|
760
|
+
sequence,
|
|
761
|
+
event.createdAt,
|
|
762
|
+
event.aggregateId,
|
|
763
|
+
);
|
|
764
|
+
} else if (event.type === 'note.archived') updateStatus('archived');
|
|
765
|
+
else if (event.type === 'todo.created') {
|
|
766
|
+
insert({
|
|
767
|
+
kind: 'todo',
|
|
768
|
+
title: event.title,
|
|
769
|
+
tags: event.tags,
|
|
770
|
+
visibility: event.visibility,
|
|
771
|
+
status: event.requiresAcceptance ? 'assigned' : 'open',
|
|
772
|
+
assigneeAgentId: event.assigneeAgentId,
|
|
773
|
+
assigneeDisplayName: event.assigneeDisplayName,
|
|
774
|
+
});
|
|
775
|
+
} else if (event.type === 'todo.updated') {
|
|
776
|
+
this.db()
|
|
777
|
+
.prepare(
|
|
778
|
+
`UPDATE items_current SET title = ?, tags_json = ?, visibility = ?,
|
|
779
|
+
updated_sequence = ?, updated_at = ? WHERE item_id = ?`,
|
|
780
|
+
)
|
|
781
|
+
.run(
|
|
782
|
+
event.title,
|
|
783
|
+
JSON.stringify(event.tags ?? []),
|
|
784
|
+
event.visibility,
|
|
785
|
+
sequence,
|
|
786
|
+
event.createdAt,
|
|
787
|
+
event.aggregateId,
|
|
788
|
+
);
|
|
789
|
+
} else if (event.type === 'todo.accepted') updateStatus('open');
|
|
790
|
+
else if (event.type === 'todo.rejected') updateStatus('rejected');
|
|
791
|
+
else if (event.type === 'todo.completed') updateStatus('completed');
|
|
792
|
+
else if (event.type === 'todo.reopened') updateStatus('open');
|
|
793
|
+
else if (event.type === 'todo.canceled') updateStatus('canceled');
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
rebuildItemsCurrentIndex(): void {
|
|
797
|
+
this.db().prepare('DELETE FROM items_current').run();
|
|
798
|
+
const rows = this.db()
|
|
799
|
+
.prepare('SELECT id, payload, sequence FROM events ORDER BY sequence ASC')
|
|
800
|
+
.all() as unknown as Array<EventRow & { sequence: number }>;
|
|
801
|
+
for (const row of rows) {
|
|
802
|
+
try {
|
|
803
|
+
this.applyEventToItems(this.parseEvent(row), row.sequence);
|
|
804
|
+
} catch (error) {
|
|
805
|
+
if (!(error instanceof SynomemError)) throw error;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
rebuildKudosCurrentIndex(): void {
|
|
811
|
+
this.db().prepare('DELETE FROM kudos_current').run();
|
|
812
|
+
const rows = this.db()
|
|
813
|
+
.prepare('SELECT id, payload, sequence FROM events ORDER BY sequence ASC')
|
|
814
|
+
.all() as unknown as Array<EventRow & { sequence: number }>;
|
|
815
|
+
for (const row of rows) {
|
|
816
|
+
try {
|
|
817
|
+
this.applyEventToCurrent(this.parseEvent(row), row.sequence);
|
|
818
|
+
} catch (error) {
|
|
819
|
+
if (!(error instanceof SynomemError)) throw error;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
nextAggregateVersion(aggregateId: string): number {
|
|
825
|
+
return Number(
|
|
826
|
+
(
|
|
827
|
+
this.db()
|
|
828
|
+
.prepare(
|
|
829
|
+
'SELECT COALESCE(MAX(aggregate_version), 0) + 1 AS next FROM events WHERE workspace_id = ? AND aggregate_id = ?',
|
|
830
|
+
)
|
|
831
|
+
.get(this.config.workspaceId, aggregateId) as { next: number }
|
|
832
|
+
).next,
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
listKudosSummaries(
|
|
837
|
+
input: Required<Pick<KudosListInput, 'limit' | 'offset'>> & KudosListInput,
|
|
838
|
+
viewer: ActorIdentity,
|
|
839
|
+
): Page<KudosSummary> {
|
|
840
|
+
const where: string[] = [];
|
|
841
|
+
const parameters: Array<string | number> = [];
|
|
842
|
+
const add = (clause: string, ...values: Array<string | number>): void => {
|
|
843
|
+
where.push(clause);
|
|
844
|
+
parameters.push(...values);
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
if (input.recipientAgentId) add('recipient_agent_id = ?', input.recipientAgentId);
|
|
848
|
+
if (input.actorId) add('actor_id = ?', input.actorId);
|
|
849
|
+
if (input.actorKind) add('actor_kind = ?', input.actorKind);
|
|
850
|
+
if (input.tag) add('EXISTS (SELECT 1 FROM json_each(tags_json) WHERE value = ?)', input.tag);
|
|
851
|
+
if (input.status) add('status = ?', input.status);
|
|
852
|
+
if (input.visibility) add('visibility = ?', input.visibility);
|
|
853
|
+
if (input.revoked !== undefined) {
|
|
854
|
+
add('revocation_status = ?', input.revoked ? 'revoked' : 'active');
|
|
855
|
+
}
|
|
856
|
+
if (input.from) add('created_at >= ?', input.from);
|
|
857
|
+
if (input.to) add('created_at <= ?', input.to);
|
|
858
|
+
if (viewer.kind !== 'human') {
|
|
859
|
+
if (viewer.kind === 'agent') {
|
|
860
|
+
add(
|
|
861
|
+
`(visibility != 'private' OR recipient_agent_id = ? OR
|
|
862
|
+
(actor_kind = ? AND actor_id = ?))`,
|
|
863
|
+
viewer.id,
|
|
864
|
+
viewer.kind,
|
|
865
|
+
viewer.id,
|
|
866
|
+
);
|
|
867
|
+
} else {
|
|
868
|
+
add(
|
|
869
|
+
`(visibility != 'private' OR (actor_kind = ? AND actor_id = ?))`,
|
|
870
|
+
viewer.kind,
|
|
871
|
+
viewer.id,
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const baseWhere = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
877
|
+
const total = Number(
|
|
878
|
+
(
|
|
879
|
+
this.db()
|
|
880
|
+
.prepare(`SELECT COUNT(*) AS count FROM kudos_current ${baseWhere}`)
|
|
881
|
+
.get(...parameters) as { count: number }
|
|
882
|
+
).count,
|
|
883
|
+
);
|
|
884
|
+
const cursorSequence = input.cursor ? decodeCursor(input.cursor, 'list') : undefined;
|
|
885
|
+
const pageWhere = [...where];
|
|
886
|
+
const pageParameters = [...parameters];
|
|
887
|
+
if (cursorSequence !== undefined) {
|
|
888
|
+
pageWhere.push('given_sequence < ?');
|
|
889
|
+
pageParameters.push(cursorSequence);
|
|
890
|
+
}
|
|
891
|
+
const sqlWhere = pageWhere.length ? `WHERE ${pageWhere.join(' AND ')}` : '';
|
|
892
|
+
const offset = cursorSequence === undefined ? input.offset : 0;
|
|
893
|
+
const rows = this.db()
|
|
894
|
+
.prepare(
|
|
895
|
+
`SELECT * FROM kudos_current ${sqlWhere}
|
|
896
|
+
ORDER BY given_sequence DESC LIMIT ? OFFSET ?`,
|
|
897
|
+
)
|
|
898
|
+
.all(...pageParameters, input.limit + 1, offset) as unknown as CurrentRow[];
|
|
899
|
+
|
|
900
|
+
const items: KudosSummary[] = [];
|
|
901
|
+
let bytes = 2;
|
|
902
|
+
let contextLimited = false;
|
|
903
|
+
for (const row of rows.slice(0, input.limit)) {
|
|
904
|
+
const item = summaryFromRow(row);
|
|
905
|
+
const itemBytes = Buffer.byteLength(JSON.stringify(item), 'utf8') + 1;
|
|
906
|
+
if (items.length > 0 && bytes + itemBytes > CONTEXT_BUDGET_BYTES) {
|
|
907
|
+
contextLimited = true;
|
|
908
|
+
break;
|
|
909
|
+
}
|
|
910
|
+
items.push(item);
|
|
911
|
+
bytes += itemBytes;
|
|
912
|
+
}
|
|
913
|
+
const hasMore = contextLimited || rows.length > items.length;
|
|
914
|
+
const last = items.at(-1);
|
|
915
|
+
const lastRow = last ? rows[items.length - 1] : undefined;
|
|
916
|
+
const watermark = encodeCursor('change', this.maxEventSequence());
|
|
917
|
+
return {
|
|
918
|
+
items,
|
|
919
|
+
total,
|
|
920
|
+
limit: input.limit,
|
|
921
|
+
offset,
|
|
922
|
+
...(hasMore && lastRow ? { nextCursor: encodeCursor('list', lastRow.given_sequence) } : {}),
|
|
923
|
+
hasMore,
|
|
924
|
+
watermark,
|
|
925
|
+
contextLimited,
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
listKudosChanges(after: string | undefined, limit: number, viewer: ActorIdentity): ChangePage {
|
|
930
|
+
const afterSequence = after ? decodeCursor(after, 'change') : 0;
|
|
931
|
+
const highWatermark = this.maxEventSequence();
|
|
932
|
+
const privacy =
|
|
933
|
+
viewer.kind === 'human'
|
|
934
|
+
? ''
|
|
935
|
+
: viewer.kind === 'agent'
|
|
936
|
+
? `AND (k.visibility != 'private' OR k.recipient_agent_id = ? OR
|
|
937
|
+
(k.actor_kind = ? AND k.actor_id = ?))`
|
|
938
|
+
: `AND (k.visibility != 'private' OR (k.actor_kind = ? AND k.actor_id = ?))`;
|
|
939
|
+
const parameters: Array<string | number> = [afterSequence, highWatermark];
|
|
940
|
+
if (viewer.kind === 'agent') parameters.push(viewer.id, viewer.kind, viewer.id);
|
|
941
|
+
else if (viewer.kind === 'system') parameters.push(viewer.kind, viewer.id);
|
|
942
|
+
parameters.push(limit + 1);
|
|
943
|
+
const rows = this.db()
|
|
944
|
+
.prepare(
|
|
945
|
+
`SELECT e.id AS event_id, e.sequence, e.type, e.created_at AS event_created_at, e.payload, k.*
|
|
946
|
+
FROM events e
|
|
947
|
+
JOIN kudos_current k ON k.kudos_id = CASE
|
|
948
|
+
WHEN e.type = 'kudos.given' THEN e.id ELSE e.kudos_id END
|
|
949
|
+
WHERE e.sequence > ? AND e.sequence <= ?
|
|
950
|
+
AND e.type IN ('kudos.given', 'kudos.acknowledged', 'kudos.revoked')
|
|
951
|
+
${privacy}
|
|
952
|
+
ORDER BY e.sequence ASC LIMIT ?`,
|
|
953
|
+
)
|
|
954
|
+
.all(...parameters) as unknown as Array<
|
|
955
|
+
CurrentRow &
|
|
956
|
+
EventRow & {
|
|
957
|
+
event_id: string;
|
|
958
|
+
event_created_at: string;
|
|
959
|
+
type: KudosChange['type'];
|
|
960
|
+
}
|
|
961
|
+
>;
|
|
962
|
+
|
|
963
|
+
const items: KudosChange[] = [];
|
|
964
|
+
let bytes = 2;
|
|
965
|
+
let contextLimited = false;
|
|
966
|
+
let consumed = 0;
|
|
967
|
+
let lastConsumedSequence = afterSequence;
|
|
968
|
+
for (const row of rows.slice(0, limit)) {
|
|
969
|
+
const sequence = Number(row.sequence);
|
|
970
|
+
let actor: ActorIdentity;
|
|
971
|
+
try {
|
|
972
|
+
const payload = JSON.parse(row.payload) as { actor: ActorIdentity };
|
|
973
|
+
actor = actorSchema.parse(payload.actor);
|
|
974
|
+
} catch {
|
|
975
|
+
consumed += 1;
|
|
976
|
+
lastConsumedSequence = sequence;
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
const item: KudosChange = {
|
|
980
|
+
cursor: encodeCursor('change', sequence),
|
|
981
|
+
sequence,
|
|
982
|
+
eventId: row.event_id,
|
|
983
|
+
type: row.type,
|
|
984
|
+
createdAt: row.event_created_at,
|
|
985
|
+
actor,
|
|
986
|
+
kudosId: row.kudos_id,
|
|
987
|
+
recipientAgentId: row.recipient_agent_id,
|
|
988
|
+
summary: summaryFromRow(row),
|
|
989
|
+
};
|
|
990
|
+
const itemBytes = Buffer.byteLength(JSON.stringify(item), 'utf8') + 1;
|
|
991
|
+
if (items.length > 0 && bytes + itemBytes > CONTEXT_BUDGET_BYTES) {
|
|
992
|
+
contextLimited = true;
|
|
993
|
+
break;
|
|
994
|
+
}
|
|
995
|
+
items.push(item);
|
|
996
|
+
bytes += itemBytes;
|
|
997
|
+
consumed += 1;
|
|
998
|
+
lastConsumedSequence = sequence;
|
|
999
|
+
}
|
|
1000
|
+
const hasMore = contextLimited || rows.length > consumed;
|
|
1001
|
+
const nextCursor = encodeCursor('change', hasMore ? lastConsumedSequence : highWatermark);
|
|
1002
|
+
return {
|
|
1003
|
+
items,
|
|
1004
|
+
limit,
|
|
1005
|
+
nextCursor,
|
|
1006
|
+
hasMore,
|
|
1007
|
+
watermark: encodeCursor('change', highWatermark),
|
|
1008
|
+
contextLimited,
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
listItemSummaries(
|
|
1013
|
+
input: Required<Pick<ItemListInput, 'limit' | 'offset'>> & ItemListInput,
|
|
1014
|
+
viewer: ActorIdentity,
|
|
1015
|
+
): Page<ItemSummary> {
|
|
1016
|
+
const where: string[] = [];
|
|
1017
|
+
const parameters: Array<string | number> = [];
|
|
1018
|
+
const add = (clause: string, ...values: Array<string | number>): void => {
|
|
1019
|
+
where.push(clause);
|
|
1020
|
+
parameters.push(...values);
|
|
1021
|
+
};
|
|
1022
|
+
if (input.kinds?.length) {
|
|
1023
|
+
add(`kind IN (${input.kinds.map(() => '?').join(', ')})`, ...input.kinds);
|
|
1024
|
+
}
|
|
1025
|
+
if (input.participantAgentId) {
|
|
1026
|
+
add(
|
|
1027
|
+
'(recipient_agent_id = ? OR owner_agent_id = ? OR assignee_agent_id = ?)',
|
|
1028
|
+
input.participantAgentId,
|
|
1029
|
+
input.participantAgentId,
|
|
1030
|
+
input.participantAgentId,
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
if (input.actorId) add('actor_id = ?', input.actorId);
|
|
1034
|
+
if (input.actorKind) add('actor_kind = ?', input.actorKind);
|
|
1035
|
+
if (input.tag) add('EXISTS (SELECT 1 FROM json_each(tags_json) WHERE value = ?)', input.tag);
|
|
1036
|
+
if (input.status) add('status = ?', input.status);
|
|
1037
|
+
if (input.pending) {
|
|
1038
|
+
add(`((kind = 'kudos' AND status = 'unacknowledged') OR
|
|
1039
|
+
(kind = 'memo' AND status = 'unread') OR
|
|
1040
|
+
(kind = 'todo' AND status IN ('assigned', 'open')))`);
|
|
1041
|
+
}
|
|
1042
|
+
if (input.visibility) add('visibility = ?', input.visibility);
|
|
1043
|
+
if (input.from) add('created_at >= ?', input.from);
|
|
1044
|
+
if (input.to) add('created_at <= ?', input.to);
|
|
1045
|
+
if (viewer.kind !== 'human') {
|
|
1046
|
+
if (viewer.kind === 'agent') {
|
|
1047
|
+
add(
|
|
1048
|
+
`(visibility != 'private' OR (actor_kind = ? AND actor_id = ?) OR
|
|
1049
|
+
recipient_agent_id = ? OR owner_agent_id = ? OR assignee_agent_id = ?)`,
|
|
1050
|
+
viewer.kind,
|
|
1051
|
+
viewer.id,
|
|
1052
|
+
viewer.id,
|
|
1053
|
+
viewer.id,
|
|
1054
|
+
viewer.id,
|
|
1055
|
+
);
|
|
1056
|
+
} else {
|
|
1057
|
+
add(
|
|
1058
|
+
`(visibility != 'private' OR (actor_kind = ? AND actor_id = ?))`,
|
|
1059
|
+
viewer.kind,
|
|
1060
|
+
viewer.id,
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
const baseWhere = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
1065
|
+
const total = Number(
|
|
1066
|
+
(
|
|
1067
|
+
this.db()
|
|
1068
|
+
.prepare(`SELECT COUNT(*) AS count FROM items_current ${baseWhere}`)
|
|
1069
|
+
.get(...parameters) as { count: number }
|
|
1070
|
+
).count,
|
|
1071
|
+
);
|
|
1072
|
+
const cursorSequence = input.cursor ? decodeCursor(input.cursor, 'items') : undefined;
|
|
1073
|
+
const pageWhere = [...where];
|
|
1074
|
+
const pageParameters = [...parameters];
|
|
1075
|
+
if (cursorSequence !== undefined) {
|
|
1076
|
+
pageWhere.push('created_sequence < ?');
|
|
1077
|
+
pageParameters.push(cursorSequence);
|
|
1078
|
+
}
|
|
1079
|
+
const sqlWhere = pageWhere.length ? `WHERE ${pageWhere.join(' AND ')}` : '';
|
|
1080
|
+
const offset = cursorSequence === undefined ? input.offset : 0;
|
|
1081
|
+
const rows = this.db()
|
|
1082
|
+
.prepare(
|
|
1083
|
+
`SELECT * FROM items_current ${sqlWhere} ORDER BY created_sequence DESC LIMIT ? OFFSET ?`,
|
|
1084
|
+
)
|
|
1085
|
+
.all(...pageParameters, input.limit + 1, offset) as unknown as ItemRow[];
|
|
1086
|
+
const items: ItemSummary[] = [];
|
|
1087
|
+
let bytes = 2;
|
|
1088
|
+
let contextLimited = false;
|
|
1089
|
+
for (const row of rows.slice(0, input.limit)) {
|
|
1090
|
+
const item = itemSummaryFromRow(row);
|
|
1091
|
+
const itemBytes = Buffer.byteLength(JSON.stringify(item), 'utf8') + 1;
|
|
1092
|
+
if (items.length > 0 && bytes + itemBytes > CONTEXT_BUDGET_BYTES) {
|
|
1093
|
+
contextLimited = true;
|
|
1094
|
+
break;
|
|
1095
|
+
}
|
|
1096
|
+
items.push(item);
|
|
1097
|
+
bytes += itemBytes;
|
|
1098
|
+
}
|
|
1099
|
+
const hasMore = contextLimited || rows.length > items.length;
|
|
1100
|
+
const lastRow = items.length ? rows[items.length - 1] : undefined;
|
|
1101
|
+
return {
|
|
1102
|
+
items,
|
|
1103
|
+
total,
|
|
1104
|
+
limit: input.limit,
|
|
1105
|
+
offset,
|
|
1106
|
+
...(hasMore && lastRow
|
|
1107
|
+
? { nextCursor: encodeCursor('items', lastRow.created_sequence) }
|
|
1108
|
+
: {}),
|
|
1109
|
+
hasMore,
|
|
1110
|
+
watermark: encodeCursor('change', this.maxEventSequence()),
|
|
1111
|
+
contextLimited,
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
listItemChanges(
|
|
1116
|
+
after: string | undefined,
|
|
1117
|
+
limit: number,
|
|
1118
|
+
viewer: ActorIdentity,
|
|
1119
|
+
kinds?: RecordKind[],
|
|
1120
|
+
): ChangePage {
|
|
1121
|
+
const afterSequence = after ? decodeCursor(after, 'change') : 0;
|
|
1122
|
+
const highWatermark = this.maxEventSequence();
|
|
1123
|
+
const where = ['e.sequence > ?', 'e.sequence <= ?', 'e.item_kind IS NOT NULL'];
|
|
1124
|
+
const parameters: Array<string | number> = [afterSequence, highWatermark];
|
|
1125
|
+
if (kinds?.length) {
|
|
1126
|
+
where.push(`e.item_kind IN (${kinds.map(() => '?').join(', ')})`);
|
|
1127
|
+
parameters.push(...kinds);
|
|
1128
|
+
}
|
|
1129
|
+
if (viewer.kind !== 'human') {
|
|
1130
|
+
if (viewer.kind === 'agent') {
|
|
1131
|
+
where.push(`(i.visibility != 'private' OR (i.actor_kind = ? AND i.actor_id = ?) OR
|
|
1132
|
+
i.recipient_agent_id = ? OR i.owner_agent_id = ? OR i.assignee_agent_id = ?)`);
|
|
1133
|
+
parameters.push(viewer.kind, viewer.id, viewer.id, viewer.id, viewer.id);
|
|
1134
|
+
} else {
|
|
1135
|
+
where.push(`(i.visibility != 'private' OR (i.actor_kind = ? AND i.actor_id = ?))`);
|
|
1136
|
+
parameters.push(viewer.kind, viewer.id);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
parameters.push(limit + 1);
|
|
1140
|
+
const rows = this.db()
|
|
1141
|
+
.prepare(
|
|
1142
|
+
`SELECT e.id AS event_id, e.sequence, e.type, e.created_at AS event_created_at,
|
|
1143
|
+
e.payload, i.* FROM events e JOIN items_current i ON i.item_id = e.aggregate_id
|
|
1144
|
+
WHERE ${where.join(' AND ')} ORDER BY e.sequence ASC LIMIT ?`,
|
|
1145
|
+
)
|
|
1146
|
+
.all(...parameters) as unknown as Array<
|
|
1147
|
+
ItemRow &
|
|
1148
|
+
EventRow & {
|
|
1149
|
+
event_id: string;
|
|
1150
|
+
event_created_at: string;
|
|
1151
|
+
type: ItemChange['type'];
|
|
1152
|
+
}
|
|
1153
|
+
>;
|
|
1154
|
+
const items: ItemChange[] = [];
|
|
1155
|
+
let bytes = 2;
|
|
1156
|
+
let contextLimited = false;
|
|
1157
|
+
let consumed = 0;
|
|
1158
|
+
let lastConsumedSequence = afterSequence;
|
|
1159
|
+
for (const row of rows.slice(0, limit)) {
|
|
1160
|
+
const sequence = Number(row.sequence);
|
|
1161
|
+
let actor: ActorIdentity;
|
|
1162
|
+
try {
|
|
1163
|
+
actor = actorSchema.parse((JSON.parse(row.payload) as { actor: unknown }).actor);
|
|
1164
|
+
} catch {
|
|
1165
|
+
consumed += 1;
|
|
1166
|
+
lastConsumedSequence = sequence;
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
const item: ItemChange = {
|
|
1170
|
+
cursor: encodeCursor('change', sequence),
|
|
1171
|
+
sequence,
|
|
1172
|
+
eventId: row.event_id,
|
|
1173
|
+
type: row.type,
|
|
1174
|
+
createdAt: row.event_created_at,
|
|
1175
|
+
actor,
|
|
1176
|
+
itemId: row.item_id,
|
|
1177
|
+
kind: row.kind,
|
|
1178
|
+
summary: itemSummaryFromRow(row),
|
|
1179
|
+
};
|
|
1180
|
+
const itemBytes = Buffer.byteLength(JSON.stringify(item), 'utf8') + 1;
|
|
1181
|
+
if (items.length > 0 && bytes + itemBytes > CONTEXT_BUDGET_BYTES) {
|
|
1182
|
+
contextLimited = true;
|
|
1183
|
+
break;
|
|
1184
|
+
}
|
|
1185
|
+
items.push(item);
|
|
1186
|
+
bytes += itemBytes;
|
|
1187
|
+
consumed += 1;
|
|
1188
|
+
lastConsumedSequence = sequence;
|
|
1189
|
+
}
|
|
1190
|
+
const hasMore = contextLimited || rows.length > consumed;
|
|
1191
|
+
return {
|
|
1192
|
+
items,
|
|
1193
|
+
limit,
|
|
1194
|
+
nextCursor: encodeCursor('change', hasMore ? lastConsumedSequence : highWatermark),
|
|
1195
|
+
hasMore,
|
|
1196
|
+
watermark: encodeCursor('change', highWatermark),
|
|
1197
|
+
contextLimited,
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
getItemSummary(id: string): ItemSummary | undefined {
|
|
1202
|
+
const row = this.db()
|
|
1203
|
+
.prepare('SELECT * FROM items_current WHERE item_id = ?')
|
|
1204
|
+
.get(id) as unknown as ItemRow | undefined;
|
|
1205
|
+
return row ? itemSummaryFromRow(row) : undefined;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
getReadableItemEvents(id: string): SynomemEvent[] {
|
|
1209
|
+
const rows = this.db()
|
|
1210
|
+
.prepare('SELECT id, payload FROM events WHERE aggregate_id = ? ORDER BY sequence ASC')
|
|
1211
|
+
.all(id) as unknown as EventRow[];
|
|
1212
|
+
const events: SynomemEvent[] = [];
|
|
1213
|
+
for (const row of rows) {
|
|
1214
|
+
try {
|
|
1215
|
+
events.push(this.parseEvent(row));
|
|
1216
|
+
} catch {
|
|
1217
|
+
/* detail is tolerant */
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
return events;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
currentIndexHealth(): { given: number; indexed: number; stateMismatches: number } {
|
|
1224
|
+
const given = Number(
|
|
1225
|
+
(
|
|
1226
|
+
this.db()
|
|
1227
|
+
.prepare("SELECT COUNT(*) AS count FROM events WHERE type = 'kudos.given'")
|
|
1228
|
+
.get() as { count: number }
|
|
1229
|
+
).count,
|
|
1230
|
+
);
|
|
1231
|
+
const indexed = Number(
|
|
1232
|
+
(this.db().prepare('SELECT COUNT(*) AS count FROM kudos_current').get() as { count: number })
|
|
1233
|
+
.count,
|
|
1234
|
+
);
|
|
1235
|
+
const stateMismatches = Number(
|
|
1236
|
+
(
|
|
1237
|
+
this.db()
|
|
1238
|
+
.prepare(
|
|
1239
|
+
`SELECT COUNT(*) AS count
|
|
1240
|
+
FROM kudos_current k
|
|
1241
|
+
WHERE k.status != CASE WHEN EXISTS (
|
|
1242
|
+
SELECT 1 FROM events e
|
|
1243
|
+
WHERE e.type = 'kudos.acknowledged' AND e.kudos_id = k.kudos_id
|
|
1244
|
+
) THEN 'acknowledged' ELSE 'unacknowledged' END
|
|
1245
|
+
OR k.revocation_status != CASE WHEN EXISTS (
|
|
1246
|
+
SELECT 1 FROM events e
|
|
1247
|
+
WHERE e.type = 'kudos.revoked' AND e.kudos_id = k.kudos_id
|
|
1248
|
+
) THEN 'revoked' ELSE 'active' END`,
|
|
1249
|
+
)
|
|
1250
|
+
.get() as { count: number }
|
|
1251
|
+
).count,
|
|
1252
|
+
);
|
|
1253
|
+
return { given, indexed, stateMismatches };
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
itemIndexHealth(): { created: number; indexed: number } {
|
|
1257
|
+
const created = Number(
|
|
1258
|
+
(
|
|
1259
|
+
this.db()
|
|
1260
|
+
.prepare(
|
|
1261
|
+
`SELECT COUNT(*) AS count FROM events WHERE type IN
|
|
1262
|
+
('kudos.given', 'memo.sent', 'note.created', 'todo.created')`,
|
|
1263
|
+
)
|
|
1264
|
+
.get() as { count: number }
|
|
1265
|
+
).count,
|
|
1266
|
+
);
|
|
1267
|
+
const indexed = Number(
|
|
1268
|
+
(this.db().prepare('SELECT COUNT(*) AS count FROM items_current').get() as { count: number })
|
|
1269
|
+
.count,
|
|
1270
|
+
);
|
|
1271
|
+
return { created, indexed };
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
migrationState(): { schemaVersion: number; appliedVersions: number[] } {
|
|
1275
|
+
const schemaVersion = Number(
|
|
1276
|
+
(this.db().prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
|
|
1277
|
+
);
|
|
1278
|
+
const appliedVersions = (
|
|
1279
|
+
this.db()
|
|
1280
|
+
.prepare('SELECT version FROM schema_migrations ORDER BY version')
|
|
1281
|
+
.all() as unknown as Array<{
|
|
1282
|
+
version: number;
|
|
1283
|
+
}>
|
|
1284
|
+
).map((row) => Number(row.version));
|
|
1285
|
+
return { schemaVersion, appliedVersions };
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
aliasIdentityConflicts(): Array<{ alias: string; agentId: string }> {
|
|
1289
|
+
return this.db()
|
|
1290
|
+
.prepare(
|
|
1291
|
+
`SELECT x.alias, x.agent_id AS agentId
|
|
1292
|
+
FROM aliases x JOIN agents a ON a.id = x.alias
|
|
1293
|
+
ORDER BY x.alias`,
|
|
1294
|
+
)
|
|
1295
|
+
.all() as unknown as Array<{ alias: string; agentId: string }>;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
private maxEventSequence(): number {
|
|
1299
|
+
return Number(
|
|
1300
|
+
(
|
|
1301
|
+
this.db().prepare('SELECT COALESCE(MAX(sequence), 0) AS sequence FROM events').get() as {
|
|
1302
|
+
sequence: number;
|
|
1303
|
+
}
|
|
1304
|
+
).sequence,
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
getEvent(id: string): SynomemEvent | undefined {
|
|
1309
|
+
const row = this.db().prepare('SELECT id, payload FROM events WHERE id = ?').get(id) as
|
|
1310
|
+
EventRow | undefined;
|
|
1311
|
+
return row ? this.parseEvent(row) : undefined;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
getEventByIdempotency(actorKind: string, actorId: string, key: string): SynomemEvent | undefined {
|
|
1315
|
+
const row = this.db()
|
|
1316
|
+
.prepare(
|
|
1317
|
+
`SELECT id, payload FROM events
|
|
1318
|
+
WHERE actor_kind = ? AND actor_id = ? AND idempotency_key = ?`,
|
|
1319
|
+
)
|
|
1320
|
+
.get(actorKind, actorId, key) as EventRow | undefined;
|
|
1321
|
+
return row ? this.parseEvent(row) : undefined;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
getEvents(): SynomemEvent[] {
|
|
1325
|
+
const scan = this.scanEvents();
|
|
1326
|
+
if (scan.invalid[0]) throw scan.invalid[0].error;
|
|
1327
|
+
return scan.events;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
getReadableEvents(): SynomemEvent[] {
|
|
1331
|
+
return this.scanEvents().events;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
getReadableSynomemEvents(kudosId: string): SynomemEvent[] {
|
|
1335
|
+
const rows = this.db()
|
|
1336
|
+
.prepare(
|
|
1337
|
+
`SELECT id, payload FROM events
|
|
1338
|
+
WHERE (type = 'kudos.given' AND id = ?) OR kudos_id = ?
|
|
1339
|
+
ORDER BY sequence ASC`,
|
|
1340
|
+
)
|
|
1341
|
+
.all(kudosId, kudosId) as unknown as EventRow[];
|
|
1342
|
+
const events: SynomemEvent[] = [];
|
|
1343
|
+
for (const row of rows) {
|
|
1344
|
+
try {
|
|
1345
|
+
events.push(this.parseEvent(row));
|
|
1346
|
+
} catch {
|
|
1347
|
+
// Detail reads remain available when an unrelated or newer row is unreadable.
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
return events;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
scanEvents(): EventScan {
|
|
1354
|
+
const rows = this.rawEventRows();
|
|
1355
|
+
const events: SynomemEvent[] = [];
|
|
1356
|
+
const invalid: EventScan['invalid'] = [];
|
|
1357
|
+
for (const row of rows) {
|
|
1358
|
+
try {
|
|
1359
|
+
events.push(this.parseEvent(row));
|
|
1360
|
+
} catch (error) {
|
|
1361
|
+
const parsed = asSynomemError(error);
|
|
1362
|
+
invalid.push({ id: row.id, error: parsed });
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
return { events, invalid };
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
assertEventCompatibility(): void {
|
|
1369
|
+
const rows = this.db()
|
|
1370
|
+
.prepare('SELECT id, payload, sequence FROM events WHERE sequence > ? ORDER BY sequence ASC')
|
|
1371
|
+
.all(this.validatedEventSequence) as unknown as Array<EventRow & { sequence: number }>;
|
|
1372
|
+
const invalid: EventScan['invalid'] = [];
|
|
1373
|
+
for (const row of rows) {
|
|
1374
|
+
try {
|
|
1375
|
+
this.parseEvent(row);
|
|
1376
|
+
} catch (error) {
|
|
1377
|
+
invalid.push({ id: row.id, error: asSynomemError(error) });
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
if (!invalid.length) {
|
|
1381
|
+
this.validatedEventSequence = rows.at(-1)?.sequence ?? this.validatedEventSequence;
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
const first = invalid[0]!;
|
|
1385
|
+
throw new SynomemError(
|
|
1386
|
+
first.error.code,
|
|
1387
|
+
`Cannot write while canonical event ${first.id} is unsupported or malformed; upgrade Synomem or inspect with synomem doctor and export.`,
|
|
1388
|
+
{ eventIds: invalid.map((item) => item.id) },
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
rawEventRows(): EventRow[] {
|
|
1393
|
+
return this.db()
|
|
1394
|
+
.prepare('SELECT id, payload FROM events ORDER BY sequence ASC')
|
|
1395
|
+
.all() as unknown as EventRow[];
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
private parseEvent(row: EventRow): SynomemEvent {
|
|
1399
|
+
try {
|
|
1400
|
+
let value = JSON.parse(row.payload) as unknown;
|
|
1401
|
+
if (typeof value === 'object' && value !== null) {
|
|
1402
|
+
const candidate = value as { schemaVersion?: unknown; type?: unknown };
|
|
1403
|
+
const supportedTypes = new Set([
|
|
1404
|
+
'agent.created',
|
|
1405
|
+
'agent.updated',
|
|
1406
|
+
'kudos.given',
|
|
1407
|
+
'kudos.acknowledged',
|
|
1408
|
+
'kudos.revoked',
|
|
1409
|
+
'memo.sent',
|
|
1410
|
+
'memo.read',
|
|
1411
|
+
'memo.archived',
|
|
1412
|
+
'note.created',
|
|
1413
|
+
'note.revised',
|
|
1414
|
+
'note.archived',
|
|
1415
|
+
'todo.created',
|
|
1416
|
+
'todo.updated',
|
|
1417
|
+
'todo.completed',
|
|
1418
|
+
'todo.reopened',
|
|
1419
|
+
'todo.accepted',
|
|
1420
|
+
'todo.rejected',
|
|
1421
|
+
'todo.canceled',
|
|
1422
|
+
]);
|
|
1423
|
+
if (
|
|
1424
|
+
(typeof candidate.schemaVersion === 'number' && candidate.schemaVersion > 1) ||
|
|
1425
|
+
(typeof candidate.type === 'string' && !supportedTypes.has(candidate.type))
|
|
1426
|
+
) {
|
|
1427
|
+
throw new SynomemError(
|
|
1428
|
+
'UNSUPPORTED_EVENT',
|
|
1429
|
+
`Event ${row.id} was written by a newer or incompatible Synomem version.`,
|
|
1430
|
+
);
|
|
1431
|
+
}
|
|
1432
|
+
const legacy = candidate as Record<string, unknown>;
|
|
1433
|
+
if (legacy.workspaceId === undefined) legacy.workspaceId = 'legacy-local';
|
|
1434
|
+
if (legacy.aggregateId === undefined) {
|
|
1435
|
+
legacy.aggregateId =
|
|
1436
|
+
legacy.kudosId ??
|
|
1437
|
+
legacy.memoId ??
|
|
1438
|
+
legacy.noteId ??
|
|
1439
|
+
legacy.todoId ??
|
|
1440
|
+
(typeof legacy.agentId === 'string' ? legacy.agentId : undefined) ??
|
|
1441
|
+
(typeof legacy.agent === 'object' && legacy.agent !== null
|
|
1442
|
+
? (legacy.agent as { id?: unknown }).id
|
|
1443
|
+
: undefined) ??
|
|
1444
|
+
legacy.id;
|
|
1445
|
+
}
|
|
1446
|
+
if (legacy.aggregateVersion === undefined) {
|
|
1447
|
+
legacy.aggregateVersion =
|
|
1448
|
+
String(legacy.type).endsWith('.given') ||
|
|
1449
|
+
String(legacy.type).endsWith('.sent') ||
|
|
1450
|
+
String(legacy.type).endsWith('.created')
|
|
1451
|
+
? 1
|
|
1452
|
+
: 2;
|
|
1453
|
+
}
|
|
1454
|
+
if (legacy.visibility === 'local') legacy.visibility = 'workspace';
|
|
1455
|
+
value = legacy;
|
|
1456
|
+
}
|
|
1457
|
+
return eventSchema.parse(value);
|
|
1458
|
+
} catch (error) {
|
|
1459
|
+
if (error instanceof SynomemError) throw error;
|
|
1460
|
+
throw new SynomemError(
|
|
1461
|
+
'INVALID_EVENT',
|
|
1462
|
+
`Unsupported or malformed event ${row.id} in canonical storage.`,
|
|
1463
|
+
{
|
|
1464
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
1465
|
+
},
|
|
1466
|
+
);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
insertAgent(profile: AgentProfile): void {
|
|
1471
|
+
const parsed = profileSchema.parse(profile);
|
|
1472
|
+
this.db()
|
|
1473
|
+
.prepare(
|
|
1474
|
+
'INSERT INTO agents(id, display_name, profile_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
|
|
1475
|
+
)
|
|
1476
|
+
.run(
|
|
1477
|
+
parsed.id,
|
|
1478
|
+
parsed.displayName,
|
|
1479
|
+
JSON.stringify(parsed),
|
|
1480
|
+
parsed.createdAt,
|
|
1481
|
+
parsed.createdAt,
|
|
1482
|
+
);
|
|
1483
|
+
for (const alias of parsed.aliases ?? []) {
|
|
1484
|
+
this.db().prepare('INSERT INTO aliases(alias, agent_id) VALUES (?, ?)').run(alias, parsed.id);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
updateAgent(profile: AgentProfile, updatedAt: string): void {
|
|
1489
|
+
const parsed = profileSchema.parse(profile);
|
|
1490
|
+
this.db()
|
|
1491
|
+
.prepare('UPDATE agents SET display_name = ?, profile_json = ?, updated_at = ? WHERE id = ?')
|
|
1492
|
+
.run(parsed.displayName, JSON.stringify(parsed), updatedAt, parsed.id);
|
|
1493
|
+
this.db().prepare('DELETE FROM aliases WHERE agent_id = ?').run(parsed.id);
|
|
1494
|
+
for (const alias of parsed.aliases ?? []) {
|
|
1495
|
+
this.db().prepare('INSERT INTO aliases(alias, agent_id) VALUES (?, ?)').run(alias, parsed.id);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
getAgent(idOrAlias: string): AgentProfile | undefined {
|
|
1500
|
+
const direct = this.db()
|
|
1501
|
+
.prepare('SELECT profile_json FROM agents WHERE id = ?')
|
|
1502
|
+
.get(idOrAlias) as ProfileRow | undefined;
|
|
1503
|
+
if (direct) return profileSchema.parse(JSON.parse(direct.profile_json));
|
|
1504
|
+
const alias = this.db()
|
|
1505
|
+
.prepare(
|
|
1506
|
+
'SELECT a.profile_json FROM agents a JOIN aliases x ON x.agent_id = a.id WHERE x.alias = ?',
|
|
1507
|
+
)
|
|
1508
|
+
.get(idOrAlias) as ProfileRow | undefined;
|
|
1509
|
+
return alias ? profileSchema.parse(JSON.parse(alias.profile_json)) : undefined;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
listAgents(): AgentProfile[] {
|
|
1513
|
+
const rows = this.db()
|
|
1514
|
+
.prepare('SELECT profile_json FROM agents ORDER BY id ASC')
|
|
1515
|
+
.all() as unknown as ProfileRow[];
|
|
1516
|
+
return rows.map((row) => profileSchema.parse(JSON.parse(row.profile_json)));
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
replaceProjectionManifest(paths: string[], generatedAt: string): void {
|
|
1520
|
+
this.transactionSync(() => {
|
|
1521
|
+
this.db().prepare('DELETE FROM projection_manifest').run();
|
|
1522
|
+
const insert = this.db().prepare(
|
|
1523
|
+
'INSERT INTO projection_manifest(path, generated_at) VALUES (?, ?)',
|
|
1524
|
+
);
|
|
1525
|
+
for (const path of paths) insert.run(path, generatedAt);
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
replaceAgentProjectionManifest(agentId: string, paths: string[], generatedAt: string): void {
|
|
1530
|
+
this.transactionSync(() => {
|
|
1531
|
+
this.db()
|
|
1532
|
+
.prepare('DELETE FROM projection_manifest WHERE path LIKE ? OR path LIKE ?')
|
|
1533
|
+
.run(`${agentId}/%`, `${agentId}\\%`);
|
|
1534
|
+
const insert = this.db().prepare(
|
|
1535
|
+
'INSERT INTO projection_manifest(path, generated_at) VALUES (?, ?)',
|
|
1536
|
+
);
|
|
1537
|
+
for (const path of paths) insert.run(path, generatedAt);
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
projectionManifest(): string[] {
|
|
1542
|
+
return (
|
|
1543
|
+
this.db().prepare('SELECT path FROM projection_manifest ORDER BY path').all() as unknown as {
|
|
1544
|
+
path: string;
|
|
1545
|
+
}[]
|
|
1546
|
+
).map((row) => row.path);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
integrityCheck(): string[] {
|
|
1550
|
+
const rows = this.db().prepare('PRAGMA integrity_check').all() as unknown as Record<
|
|
1551
|
+
string,
|
|
1552
|
+
string
|
|
1553
|
+
>[];
|
|
1554
|
+
return rows.map((row) => Object.values(row)[0] ?? 'unknown');
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
journalMode(): string {
|
|
1558
|
+
const row = this.db().prepare('PRAGMA journal_mode').get() as Record<string, string>;
|
|
1559
|
+
return Object.values(row)[0] ?? 'unknown';
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
async backup(destination: string): Promise<string> {
|
|
1563
|
+
this.assertWritable();
|
|
1564
|
+
const output = resolve(destination);
|
|
1565
|
+
if (existsSync(output)) {
|
|
1566
|
+
throw new SynomemError(
|
|
1567
|
+
'INVALID_INPUT',
|
|
1568
|
+
`Backup destination already exists: ${basename(output)}`,
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1571
|
+
ensureDirectory(dirname(output));
|
|
1572
|
+
const temporaryDirectory = mkdtempSync(join(dirname(output), '.synomem-backup-'));
|
|
1573
|
+
chmodSync(temporaryDirectory, 0o700);
|
|
1574
|
+
const temporaryOutput = join(temporaryDirectory, basename(output));
|
|
1575
|
+
try {
|
|
1576
|
+
const escaped = temporaryOutput.replaceAll("'", "''");
|
|
1577
|
+
this.db().exec(`VACUUM INTO '${escaped}'`);
|
|
1578
|
+
chmodSync(temporaryOutput, 0o600);
|
|
1579
|
+
linkSync(temporaryOutput, output);
|
|
1580
|
+
unlinkSync(temporaryOutput);
|
|
1581
|
+
return output;
|
|
1582
|
+
} finally {
|
|
1583
|
+
if (existsSync(temporaryOutput)) unlinkSync(temporaryOutput);
|
|
1584
|
+
rmdirSync(temporaryDirectory);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
private restrictDatabaseFiles(): void {
|
|
1589
|
+
if (this.readOnly) return;
|
|
1590
|
+
for (const path of [
|
|
1591
|
+
this.databasePath,
|
|
1592
|
+
`${this.databasePath}-wal`,
|
|
1593
|
+
`${this.databasePath}-shm`,
|
|
1594
|
+
]) {
|
|
1595
|
+
if (existsSync(path)) chmodSync(path, 0o600);
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
close(): void {
|
|
1600
|
+
this.database?.close();
|
|
1601
|
+
this.database = undefined;
|
|
1602
|
+
}
|
|
1603
|
+
}
|