@hraness/dawg 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/DAWG.md +142 -0
  3. package/LICENSE +21 -0
  4. package/README.md +164 -2
  5. package/core/drums.ts +102 -0
  6. package/core/loop.ts +78 -0
  7. package/core/score.ts +1065 -0
  8. package/package.json +42 -4
  9. package/src/agent/agent.ts +587 -0
  10. package/src/agent/brief.ts +140 -0
  11. package/src/agent/gateway.ts +267 -0
  12. package/src/agent/ops.ts +172 -0
  13. package/src/agent/planner.ts +221 -0
  14. package/src/agent/provider.ts +326 -0
  15. package/src/agent/sse.ts +90 -0
  16. package/src/agent/tools.ts +976 -0
  17. package/src/agent/xcb-agent.ts +247 -0
  18. package/src/agent/xcb.ts +367 -0
  19. package/src/audio/clock.ts +68 -0
  20. package/src/audio/engine.ts +467 -0
  21. package/src/audio/lock.ts +57 -0
  22. package/src/audio/player.ts +119 -0
  23. package/src/audio/wav.ts +647 -0
  24. package/src/auth/cli.ts +112 -0
  25. package/src/auth/credentials.ts +285 -0
  26. package/src/auth/login.ts +434 -0
  27. package/src/auth/runner.ts +184 -0
  28. package/src/auth/tui.ts +90 -0
  29. package/src/commands/history.ts +56 -0
  30. package/src/commands/music.ts +444 -0
  31. package/src/daemon.ts +31 -0
  32. package/src/main.ts +1332 -0
  33. package/src/render.ts +99 -0
  34. package/src/session/attach.ts +182 -0
  35. package/src/session/client.ts +498 -0
  36. package/src/session/daemon.ts +723 -0
  37. package/src/session/list.ts +180 -0
  38. package/src/session/lock.ts +92 -0
  39. package/src/session/meta.ts +253 -0
  40. package/src/session/naming.ts +464 -0
  41. package/src/session/port.ts +461 -0
  42. package/src/session/presence.ts +159 -0
  43. package/src/session/protocol.ts +618 -0
  44. package/src/session/rebase.ts +138 -0
  45. package/src/session/store.ts +486 -0
  46. package/tui/activity.ts +325 -0
  47. package/tui/app.ts +1129 -0
  48. package/tui/drums.ts +21 -0
  49. package/tui/highway.ts +905 -0
  50. package/tui/input.ts +63 -0
  51. package/tui/keys.ts +102 -0
  52. package/tui/layers.ts +68 -0
  53. package/tui/prompt.ts +609 -0
  54. package/tui/render.ts +124 -0
  55. package/tui/screen.ts +247 -0
  56. package/tui/text.ts +72 -0
  57. package/tui/theme.ts +451 -0
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Server-side rebase of agent operation intents.
3
+ *
4
+ * An agent validates its operations against the score at `base`. When other
5
+ * windows committed in the meantime, dawgd replays the operations on the
6
+ * current score instead of rejecting, but only when nothing they touch
7
+ * changed since `base`: the notes they update or remove, the tracks whose
8
+ * settings or contents they rewrite, the tracks they add notes to, and the
9
+ * tempo or length they set. Anything else is a conflict and the caller gets
10
+ * the usual `rebase` reply. The reducer still validates every operation.
11
+ */
12
+ import {
13
+ applyScoreOperation,
14
+ scoreFromJSON,
15
+ type ScoreOperation,
16
+ type TrackScore,
17
+ } from "../../core/score.ts";
18
+
19
+ /** How far behind a base may be and still be rebased. */
20
+ export const MAX_REBASE_DISTANCE = 64;
21
+
22
+ export type RebaseResult =
23
+ { ok: true; next: TrackScore } | { ok: false; reason: string };
24
+
25
+ export function rebaseOperations(
26
+ base: TrackScore,
27
+ current: TrackScore,
28
+ operations: readonly ScoreOperation[],
29
+ ): RebaseResult {
30
+ const baseNotes = new Map(base.notes.map((note) => [note.id, note]));
31
+ const currentNotes = new Map(current.notes.map((note) => [note.id, note]));
32
+ const same = (a: unknown, b: unknown) =>
33
+ JSON.stringify(a) === JSON.stringify(b);
34
+ const trackSettings = (score: TrackScore, id: string) =>
35
+ score.tracks.find((track) => track.id === id);
36
+ const trackNotes = (score: TrackScore, id: string) =>
37
+ score.notes.filter((note) => note.trackId === id);
38
+ const settingsUnchanged = (id: string) =>
39
+ same(trackSettings(base, id), trackSettings(current, id));
40
+ const notesUnchanged = (id: string) =>
41
+ same(trackNotes(base, id), trackNotes(current, id));
42
+ // Tracks and notes the intent itself creates are not concurrent edits.
43
+ const added = { tracks: new Set<string>(), notes: new Set<string>() };
44
+ for (const operation of operations) {
45
+ switch (operation.type) {
46
+ case "addTrack": {
47
+ const id = operation.track.id;
48
+ if (typeof id === "string") {
49
+ if (trackSettings(current, id) && !trackSettings(base, id))
50
+ return { ok: false, reason: `track ${id} was added concurrently` };
51
+ added.tracks.add(id);
52
+ }
53
+ break;
54
+ }
55
+ case "addNote": {
56
+ const { id, trackId } = operation.note;
57
+ if (
58
+ typeof id === "string" &&
59
+ currentNotes.has(id) &&
60
+ !baseNotes.has(id)
61
+ )
62
+ return { ok: false, reason: `note ${id} was added concurrently` };
63
+ if (typeof id === "string") added.notes.add(id);
64
+ if (
65
+ typeof trackId === "string" &&
66
+ !added.tracks.has(trackId) &&
67
+ !trackSettings(current, trackId)
68
+ )
69
+ return { ok: false, reason: `track ${trackId} was removed` };
70
+ break;
71
+ }
72
+ case "removeNote":
73
+ case "updateNote": {
74
+ if (added.notes.has(operation.noteId)) break;
75
+ if (
76
+ !same(
77
+ baseNotes.get(operation.noteId),
78
+ currentNotes.get(operation.noteId),
79
+ )
80
+ )
81
+ return { ok: false, reason: `note ${operation.noteId} changed` };
82
+ break;
83
+ }
84
+ case "updateTrack":
85
+ case "setAutomation": {
86
+ if (added.tracks.has(operation.trackId)) break;
87
+ if (!settingsUnchanged(operation.trackId))
88
+ return { ok: false, reason: `track ${operation.trackId} changed` };
89
+ break;
90
+ }
91
+ case "clearTrack": {
92
+ if (added.tracks.has(operation.trackId)) break;
93
+ if (
94
+ !settingsUnchanged(operation.trackId) ||
95
+ !notesUnchanged(operation.trackId)
96
+ )
97
+ return { ok: false, reason: `track ${operation.trackId} changed` };
98
+ break;
99
+ }
100
+ case "setTempo":
101
+ if (base.tempoBpm !== current.tempoBpm)
102
+ return { ok: false, reason: "tempo changed" };
103
+ break;
104
+ case "setBars":
105
+ if (base.bars !== current.bars)
106
+ return { ok: false, reason: "loop length changed" };
107
+ break;
108
+ default:
109
+ return { ok: false, reason: "unsupported operation" };
110
+ }
111
+ }
112
+ try {
113
+ let next = current;
114
+ for (const operation of operations)
115
+ next = applyScoreOperation(next, operation);
116
+ return { ok: true, next: scoreFromJSON(next.toJSON()) };
117
+ } catch (error) {
118
+ return {
119
+ ok: false,
120
+ reason: error instanceof Error ? error.message : String(error),
121
+ };
122
+ }
123
+ }
124
+
125
+ /**
126
+ * The composition at revision `revision`, recovered from the `before` of the
127
+ * event that replaced it. Undefined when the log cannot vouch for it.
128
+ */
129
+ export function compositionAt(
130
+ events: readonly { revision: number; payload: unknown }[],
131
+ revision: number,
132
+ ): unknown {
133
+ const event = events[revision];
134
+ if (event === undefined || event.revision !== revision + 1) return undefined;
135
+ const payload = event.payload;
136
+ if (typeof payload !== "object" || payload === null) return undefined;
137
+ return (payload as { before?: unknown }).before;
138
+ }
@@ -0,0 +1,486 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { acquireSessionLock } from "./lock.ts";
5
+ import {
6
+ applyMetaPatch,
7
+ defaultSessionMeta,
8
+ metaMatches,
9
+ MetaValidationError,
10
+ normalizeSessionName,
11
+ parseSessionMeta,
12
+ type MetaExpect,
13
+ type MetaPatch,
14
+ type SessionMeta,
15
+ } from "./meta.ts";
16
+
17
+ const MAX_EVENT_BYTES = 64 * 1024;
18
+ const MAX_EVENTS = 2_000;
19
+ const MAX_RECORD_BYTES = 4 * 1024 * 1024;
20
+ const MAX_SESSION_ID_LENGTH = 64;
21
+ const MAX_EVENT_KIND_LENGTH = 128;
22
+ const MAX_TIMESTAMP_LENGTH = 64;
23
+
24
+ export class SessionValidationError extends Error {
25
+ public constructor(message: string) {
26
+ super(message);
27
+ this.name = "SessionValidationError";
28
+ }
29
+ }
30
+
31
+ export type SessionEvent = {
32
+ id: string;
33
+ revision: number;
34
+ kind: string;
35
+ payload: unknown;
36
+ at: string;
37
+ };
38
+
39
+ export type SessionRecord<T> = {
40
+ sessionId: string;
41
+ revision: number;
42
+ updatedAt: string;
43
+ composition: T;
44
+ events: SessionEvent[];
45
+ /** Name and lineage. Versioned separately so renames keep the revision. */
46
+ meta: SessionMeta;
47
+ };
48
+
49
+ export type SessionPaths = {
50
+ root: string;
51
+ pointer: string;
52
+ record: string;
53
+ lock: string;
54
+ };
55
+
56
+ export class SessionConflictError extends Error {
57
+ public constructor() {
58
+ super(
59
+ "session changed in another dawg window; retry against the latest revision",
60
+ );
61
+ this.name = "SessionConflictError";
62
+ }
63
+ }
64
+
65
+ /** Per-workspace state directory. */
66
+ export const STATE_DIR = ".dawg";
67
+
68
+ /** The workspace state directory, `<workspace>/.dawg`. */
69
+ export function stateDir(workspace = process.cwd()): string {
70
+ return join(workspace, STATE_DIR);
71
+ }
72
+
73
+ export function sessionPaths(
74
+ workspace = process.cwd(),
75
+ sessionId: string,
76
+ ): SessionPaths {
77
+ assertSessionId(sessionId);
78
+ const root = stateDir(workspace);
79
+ return {
80
+ root,
81
+ pointer: join(root, "session"),
82
+ record: join(root, "sessions", `${sessionId}.json`),
83
+ lock: join(root, "sessions", `${sessionId}.lock`),
84
+ };
85
+ }
86
+
87
+ export async function readCurrentSessionId(
88
+ workspace = process.cwd(),
89
+ ): Promise<string | undefined> {
90
+ try {
91
+ const id = (
92
+ await readFile(join(stateDir(workspace), "session"), "utf8")
93
+ ).trim();
94
+ if (id.length === 0) return undefined;
95
+ assertSessionId(id);
96
+ return id;
97
+ } catch (error) {
98
+ if (isCode(error, "ENOENT")) return undefined;
99
+ throw error;
100
+ }
101
+ }
102
+
103
+ export async function ensureSession<T>(
104
+ initial: T,
105
+ options: {
106
+ workspace?: string;
107
+ sessionId?: string;
108
+ setCurrent?: boolean;
109
+ /** Name for a newly created session; ignored when it already exists. */
110
+ name?: string;
111
+ } = {},
112
+ ): Promise<{ paths: SessionPaths; record: SessionRecord<T> }> {
113
+ const workspace = options.workspace ?? process.cwd();
114
+ const root = stateDir(workspace);
115
+ await mkdir(root, { recursive: true });
116
+ // Serialize pointer selection and first-record creation. Without this,
117
+ // two windows launched together can each choose a different random session
118
+ // and race to overwrite `.dawg/session`.
119
+ const initLock = join(root, ".init.lock");
120
+ const release = await acquireSessionLock(initLock);
121
+ try {
122
+ const sessionId =
123
+ options.sessionId ??
124
+ (await readCurrentSessionId(workspace)) ??
125
+ randomUUID();
126
+ const paths = sessionPaths(workspace, sessionId);
127
+ await mkdir(dirname(paths.record), { recursive: true });
128
+ const existing = await readRecordIfPresent<T>(paths.record);
129
+ if (existing) {
130
+ if (options.setCurrent !== false)
131
+ await writeAtomic(paths.pointer, `${sessionId}`);
132
+ return { paths, record: existing };
133
+ }
134
+ if (options.setCurrent !== false)
135
+ await writeAtomic(paths.pointer, `${sessionId}`);
136
+ try {
137
+ const at = new Date().toISOString();
138
+ const record: SessionRecord<T> = {
139
+ sessionId,
140
+ revision: 0,
141
+ updatedAt: at,
142
+ composition: initial,
143
+ events: [],
144
+ meta: defaultSessionMeta(
145
+ at,
146
+ options.name === undefined
147
+ ? undefined
148
+ : normalizeSessionName(options.name),
149
+ ),
150
+ };
151
+ const validated = validateSessionRecord<T>(record);
152
+ await writeAtomic(paths.record, JSON.stringify(validated, null, 2));
153
+ return { paths, record: validated };
154
+ } catch (error) {
155
+ if (!isCode(error, "ENOENT")) throw error;
156
+ // A concurrent external creator can win only if it does not use the
157
+ // init lock. Read it and validate rather than replacing its record.
158
+ const record = await readRecordIfPresent<T>(paths.record);
159
+ if (!record) throw error;
160
+ return { paths, record };
161
+ }
162
+ } finally {
163
+ await release();
164
+ }
165
+ }
166
+
167
+ export async function appendSessionEvent<T>(
168
+ paths: SessionPaths,
169
+ current: SessionRecord<T>,
170
+ event: Omit<SessionEvent, "id" | "revision" | "at"> & { id?: string },
171
+ composition: T,
172
+ ): Promise<SessionRecord<T>> {
173
+ // dawgd passes the client's idempotency key as the durable event id so a
174
+ // retried intent stays a no-op across daemon restarts.
175
+ if (event.id !== undefined) assertSessionId(event.id);
176
+ const payloadJson = stringifyJson(event.payload, "session event payload");
177
+ const payloadBytes = Buffer.byteLength(payloadJson, "utf8");
178
+ if (payloadBytes > MAX_EVENT_BYTES)
179
+ throw new Error(`session event exceeds ${MAX_EVENT_BYTES} bytes`);
180
+ if (
181
+ typeof event.kind !== "string" ||
182
+ event.kind.length === 0 ||
183
+ event.kind.length > MAX_EVENT_KIND_LENGTH
184
+ )
185
+ throw new SessionValidationError("session event kind is invalid");
186
+ const release = await acquireSessionLock(paths.lock);
187
+ try {
188
+ const disk = await readRecord<T>(paths.record);
189
+ if (disk.revision !== current.revision) throw new SessionConflictError();
190
+ // Re-check the bounded event budget against the locked record. A stale
191
+ // caller can pass the pre-lock check while another writer fills the log.
192
+ if (disk.events.length >= MAX_EVENTS)
193
+ throw new Error(`session event limit ${MAX_EVENTS} reached`);
194
+ const revision = disk.revision + 1;
195
+ const at = new Date().toISOString();
196
+ const next: SessionRecord<T> = {
197
+ sessionId: disk.sessionId,
198
+ revision,
199
+ updatedAt: at,
200
+ composition,
201
+ events: [
202
+ ...disk.events,
203
+ {
204
+ kind: event.kind,
205
+ payload: event.payload,
206
+ id: event.id ?? randomUUID(),
207
+ revision,
208
+ at,
209
+ },
210
+ ],
211
+ // Metadata always comes from disk so a rename written by another window
212
+ // between this caller's read and its write is never reverted.
213
+ meta: disk.meta,
214
+ };
215
+ const validated = validateSessionRecord<T>(next);
216
+ await writeAtomic(paths.record, JSON.stringify(validated, null, 2));
217
+ return validated;
218
+ } finally {
219
+ await release();
220
+ }
221
+ }
222
+
223
+ export type MetaUpdate<T> =
224
+ | { status: "applied"; record: SessionRecord<T> }
225
+ | { status: "stale"; record: SessionRecord<T> };
226
+
227
+ /**
228
+ * Conditional metadata write under the session lock. It applies only when
229
+ * `expect` still matches the record on disk, so the latest user rename wins
230
+ * and an in-flight auto-name computed against an older name is dropped.
231
+ * The score revision and event log are untouched.
232
+ */
233
+ export async function updateSessionMeta<T>(
234
+ paths: SessionPaths,
235
+ patch: MetaPatch,
236
+ expect?: MetaExpect,
237
+ ): Promise<MetaUpdate<T>> {
238
+ const release = await acquireSessionLock(paths.lock);
239
+ try {
240
+ const disk = await readRecord<T>(paths.record);
241
+ if (!metaMatches(disk.meta, expect))
242
+ return { status: "stale", record: disk };
243
+ const next: SessionRecord<T> = {
244
+ ...disk,
245
+ meta: applyMetaPatch(disk.meta, patch, new Date().toISOString()),
246
+ };
247
+ const validated = validateSessionRecord<T>(next);
248
+ await writeAtomic(paths.record, JSON.stringify(validated, null, 2));
249
+ return { status: "applied", record: validated };
250
+ } finally {
251
+ await release();
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Creates a new session holding a snapshot of `source` at its current
257
+ * revision: the composition only, not the event log, with `forkOf` lineage.
258
+ */
259
+ export async function createForkSession<T>(
260
+ workspace: string,
261
+ source: SessionRecord<T>,
262
+ name: string,
263
+ ): Promise<SessionRecord<T>> {
264
+ const sessionId = randomUUID();
265
+ const paths = sessionPaths(workspace, sessionId);
266
+ await mkdir(dirname(paths.record), { recursive: true });
267
+ const at = new Date().toISOString();
268
+ const record = validateSessionRecord<T>({
269
+ sessionId,
270
+ revision: 0,
271
+ updatedAt: at,
272
+ composition: source.composition,
273
+ events: [],
274
+ meta: {
275
+ ...defaultSessionMeta(at, normalizeSessionName(name)),
276
+ nameSource: source.meta.nameSource,
277
+ forkOf: { sessionId: source.sessionId, revision: source.revision },
278
+ },
279
+ });
280
+ await writeAtomic(paths.record, JSON.stringify(record, null, 2));
281
+ return record;
282
+ }
283
+
284
+ /** Fork ancestry walked for undo, and the inherited event budget. */
285
+ export const MAX_FORK_DEPTH = 8;
286
+ export const MAX_INHERITED_EVENTS = MAX_EVENTS;
287
+
288
+ /**
289
+ * The event log a fork inherits for undo: each ancestor's events up to the
290
+ * revision it was forked at, oldest ancestor first, via the `forkOf` chain.
291
+ * Nothing is copied at fork time; the parent prefix is append-only, so it is
292
+ * immutable and safe to read later. Bounded by depth, by total events (the
293
+ * oldest are dropped first) and by cycle detection. A missing, unreadable or
294
+ * inconsistent ancestor (forked past its own revision) ends the chain there.
295
+ */
296
+ export async function inheritedEvents(
297
+ workspace: string,
298
+ meta: SessionMeta,
299
+ selfId: string,
300
+ ): Promise<SessionEvent[]> {
301
+ const chunks: SessionEvent[][] = [];
302
+ const seen = new Set([selfId]);
303
+ let fork = meta.forkOf;
304
+ let total = 0;
305
+ for (
306
+ let depth = 0;
307
+ fork && depth < MAX_FORK_DEPTH && total < MAX_INHERITED_EVENTS;
308
+ depth += 1
309
+ ) {
310
+ if (seen.has(fork.sessionId)) break;
311
+ seen.add(fork.sessionId);
312
+ let parent: SessionRecord<unknown> | undefined;
313
+ try {
314
+ parent = await readRecordIfPresent<unknown>(
315
+ sessionPaths(workspace, fork.sessionId).record,
316
+ );
317
+ } catch {
318
+ break;
319
+ }
320
+ if (!parent || fork.revision > parent.events.length) break;
321
+ const prefix = parent.events.slice(0, fork.revision);
322
+ chunks.unshift(prefix.slice(-(MAX_INHERITED_EVENTS - total)));
323
+ total += prefix.length;
324
+ fork = parent.meta.forkOf;
325
+ }
326
+ return chunks.flat().slice(-MAX_INHERITED_EVENTS);
327
+ }
328
+
329
+ /** Points `.dawg/session` at `sessionId` so plain `dawg` resumes it. */
330
+ export async function setCurrentSession(
331
+ workspace: string,
332
+ sessionId: string,
333
+ ): Promise<void> {
334
+ const paths = sessionPaths(workspace, sessionId);
335
+ await mkdir(paths.root, { recursive: true });
336
+ await writeAtomic(paths.pointer, sessionId);
337
+ }
338
+
339
+ export async function loadSession<T>(
340
+ paths: SessionPaths,
341
+ ): Promise<SessionRecord<T>> {
342
+ return readRecord<T>(paths.record);
343
+ }
344
+
345
+ async function writeAtomic(path: string, contents: string): Promise<void> {
346
+ if (Buffer.byteLength(contents, "utf8") > MAX_RECORD_BYTES)
347
+ throw new SessionValidationError(
348
+ `session record exceeds ${MAX_RECORD_BYTES} bytes`,
349
+ );
350
+ const temporary = `${path}.${process.pid}.tmp`;
351
+ await writeFile(temporary, `${contents}\n`, "utf8");
352
+ await rename(temporary, path);
353
+ }
354
+
355
+ async function readRecord<T>(path: string): Promise<SessionRecord<T>> {
356
+ const contents = await readFile(path, "utf8");
357
+ if (Buffer.byteLength(contents, "utf8") > MAX_RECORD_BYTES)
358
+ throw new SessionValidationError(
359
+ `session record exceeds ${MAX_RECORD_BYTES} bytes`,
360
+ );
361
+ let value: unknown;
362
+ try {
363
+ value = JSON.parse(contents);
364
+ } catch {
365
+ throw new SessionValidationError("session record contains invalid JSON");
366
+ }
367
+ return validateSessionRecord<T>(value);
368
+ }
369
+
370
+ async function readRecordIfPresent<T>(
371
+ path: string,
372
+ ): Promise<SessionRecord<T> | undefined> {
373
+ try {
374
+ return await readRecord<T>(path);
375
+ } catch (error) {
376
+ if (isCode(error, "ENOENT")) return undefined;
377
+ throw error;
378
+ }
379
+ }
380
+
381
+ function validateSessionRecord<T>(value: unknown): SessionRecord<T> {
382
+ if (typeof value !== "object" || value === null)
383
+ throw new SessionValidationError("session record must be an object");
384
+ const record = value as Record<string, unknown>;
385
+ assertSessionId(record.sessionId);
386
+ if (!Number.isSafeInteger(record.revision) || (record.revision as number) < 0)
387
+ throw new SessionValidationError("session revision must be a safe integer");
388
+ if (
389
+ typeof record.updatedAt !== "string" ||
390
+ record.updatedAt.length === 0 ||
391
+ record.updatedAt.length > MAX_TIMESTAMP_LENGTH
392
+ )
393
+ throw new SessionValidationError("session updatedAt is invalid");
394
+ if (!Array.isArray(record.events) || record.events.length > MAX_EVENTS)
395
+ throw new SessionValidationError(
396
+ `session has more than ${MAX_EVENTS} events`,
397
+ );
398
+ if (record.revision !== record.events.length)
399
+ throw new SessionValidationError(
400
+ "session revision does not match its event log",
401
+ );
402
+ if (!("composition" in record))
403
+ throw new SessionValidationError("session composition is missing");
404
+ if (record.composition === undefined)
405
+ throw new SessionValidationError("session composition is not JSON data");
406
+ const events = record.events.map((event, index) => {
407
+ if (typeof event !== "object" || event === null)
408
+ throw new SessionValidationError("session event must be an object");
409
+ const candidate = event as Record<string, unknown>;
410
+ assertSessionId(candidate.id);
411
+ if (
412
+ !Number.isSafeInteger(candidate.revision) ||
413
+ candidate.revision !== index + 1
414
+ )
415
+ throw new SessionValidationError("session event revisions are invalid");
416
+ if (
417
+ typeof candidate.kind !== "string" ||
418
+ candidate.kind.length === 0 ||
419
+ candidate.kind.length > MAX_EVENT_KIND_LENGTH
420
+ )
421
+ throw new SessionValidationError("session event kind is invalid");
422
+ if (
423
+ typeof candidate.at !== "string" ||
424
+ candidate.at.length > MAX_TIMESTAMP_LENGTH
425
+ )
426
+ throw new SessionValidationError("session event timestamp is invalid");
427
+ const payloadJson = stringifyJson(
428
+ candidate.payload,
429
+ "session event payload",
430
+ );
431
+ if (Buffer.byteLength(payloadJson, "utf8") > MAX_EVENT_BYTES)
432
+ throw new SessionValidationError(
433
+ `session event exceeds ${MAX_EVENT_BYTES} bytes`,
434
+ );
435
+ return candidate as unknown as SessionEvent;
436
+ });
437
+ let meta: SessionMeta;
438
+ try {
439
+ meta = parseSessionMeta(record.meta, {
440
+ sessionId: record.sessionId,
441
+ updatedAt: record.updatedAt,
442
+ });
443
+ } catch (error) {
444
+ if (error instanceof MetaValidationError)
445
+ throw new SessionValidationError(error.message);
446
+ throw error;
447
+ }
448
+ return {
449
+ sessionId: record.sessionId,
450
+ revision: record.revision as number,
451
+ updatedAt: record.updatedAt,
452
+ composition: record.composition as T,
453
+ events,
454
+ meta,
455
+ };
456
+ }
457
+
458
+ export function assertSessionId(value: unknown): asserts value is string {
459
+ if (
460
+ typeof value !== "string" ||
461
+ value.length === 0 ||
462
+ value.length > MAX_SESSION_ID_LENGTH ||
463
+ !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) ||
464
+ value === "." ||
465
+ value === ".."
466
+ )
467
+ throw new SessionValidationError(
468
+ "session id contains unsafe path characters",
469
+ );
470
+ }
471
+
472
+ function isCode(error: unknown, code: string): boolean {
473
+ return error instanceof Error && "code" in error && error.code === code;
474
+ }
475
+
476
+ function stringifyJson(value: unknown, label: string): string {
477
+ try {
478
+ const encoded = JSON.stringify(value);
479
+ if (encoded === undefined)
480
+ throw new SessionValidationError(`${label} must be JSON data`);
481
+ return encoded;
482
+ } catch (error) {
483
+ if (error instanceof SessionValidationError) throw error;
484
+ throw new SessionValidationError(`${label} must be JSON data`);
485
+ }
486
+ }