@hraness/dawg 0.0.0-stage → 0.3.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 (113) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/DAWG.md +327 -0
  3. package/LICENSE +21 -0
  4. package/README.md +213 -2
  5. package/core/diff.ts +249 -0
  6. package/core/drums.ts +102 -0
  7. package/core/key.ts +43 -0
  8. package/core/loop.ts +78 -0
  9. package/core/pitch.ts +60 -0
  10. package/core/score.ts +1388 -0
  11. package/core/sdk/eval-child.ts +113 -0
  12. package/core/sdk/eval.ts +257 -0
  13. package/core/sdk/print.ts +393 -0
  14. package/core/sdk/v1.ts +954 -0
  15. package/core/slug.ts +19 -0
  16. package/package.json +45 -4
  17. package/src/agent/agent.ts +853 -0
  18. package/src/agent/brief.ts +160 -0
  19. package/src/agent/gateway.ts +441 -0
  20. package/src/agent/models.ts +633 -0
  21. package/src/agent/ops.ts +157 -0
  22. package/src/agent/planner.ts +259 -0
  23. package/src/agent/provider.ts +454 -0
  24. package/src/agent/sse.ts +114 -0
  25. package/src/agent/tools.ts +1373 -0
  26. package/src/agent/usage.ts +296 -0
  27. package/src/agent/workspace.ts +683 -0
  28. package/src/agent/xcb-agent.ts +262 -0
  29. package/src/agent/xcb.ts +579 -0
  30. package/src/audio/click.ts +125 -0
  31. package/src/audio/clock.ts +68 -0
  32. package/src/audio/engine.ts +841 -0
  33. package/src/audio/live.ts +152 -0
  34. package/src/audio/lock.ts +57 -0
  35. package/src/audio/player.ts +134 -0
  36. package/src/audio/render-worker.ts +68 -0
  37. package/src/audio/renderer.ts +174 -0
  38. package/src/audio/sampler.ts +292 -0
  39. package/src/audio/samples.ts +683 -0
  40. package/src/audio/wav.ts +861 -0
  41. package/src/auth/cli.ts +231 -0
  42. package/src/auth/credentials.ts +411 -0
  43. package/src/auth/discover.ts +481 -0
  44. package/src/auth/login.ts +1191 -0
  45. package/src/auth/openrouter.ts +206 -0
  46. package/src/auth/picker.ts +282 -0
  47. package/src/auth/runner.ts +207 -0
  48. package/src/auth/tui.ts +107 -0
  49. package/src/commands/edit.ts +170 -0
  50. package/src/commands/help.ts +247 -0
  51. package/src/commands/history.ts +69 -0
  52. package/src/commands/music.ts +461 -0
  53. package/src/commands/sample.ts +302 -0
  54. package/src/daemon.ts +31 -0
  55. package/src/main.ts +2209 -0
  56. package/src/media/analyze.ts +364 -0
  57. package/src/media/backend.ts +253 -0
  58. package/src/media/cli.ts +173 -0
  59. package/src/media/download.ts +281 -0
  60. package/src/media/dsp.ts +281 -0
  61. package/src/media/import.ts +130 -0
  62. package/src/media/lyrics.ts +201 -0
  63. package/src/media/notes.ts +363 -0
  64. package/src/media/paths.ts +168 -0
  65. package/src/media/process.ts +226 -0
  66. package/src/media/registry.ts +9 -0
  67. package/src/media/sidecar.ts +72 -0
  68. package/src/media/stemdeck.ts +254 -0
  69. package/src/media/stems.ts +173 -0
  70. package/src/media/tools.ts +292 -0
  71. package/src/media/types.ts +92 -0
  72. package/src/media/vendor/basic-pitch.ts +261 -0
  73. package/src/media/vendor/drums.ts +817 -0
  74. package/src/media/vendor/grid.ts +203 -0
  75. package/src/media/vendor/util.ts +139 -0
  76. package/src/media/vendor/wav.ts +233 -0
  77. package/src/project/check.ts +80 -0
  78. package/src/project/init.ts +253 -0
  79. package/src/project/sync.ts +432 -0
  80. package/src/project/typecheck.ts +149 -0
  81. package/src/render.ts +121 -0
  82. package/src/session/attach.ts +181 -0
  83. package/src/session/client.ts +498 -0
  84. package/src/session/daemon.ts +740 -0
  85. package/src/session/delta.ts +249 -0
  86. package/src/session/list.ts +180 -0
  87. package/src/session/lock.ts +92 -0
  88. package/src/session/meta.ts +253 -0
  89. package/src/session/naming.ts +430 -0
  90. package/src/session/port.ts +481 -0
  91. package/src/session/presence.ts +159 -0
  92. package/src/session/protocol.ts +618 -0
  93. package/src/session/rebase.ts +168 -0
  94. package/src/session/store.ts +581 -0
  95. package/src/tui/menu.ts +1083 -0
  96. package/src/tui/play-mode.ts +442 -0
  97. package/src/tui/play-session.ts +636 -0
  98. package/src/web/fetch.ts +340 -0
  99. package/src/web/http.ts +137 -0
  100. package/src/web/search.ts +681 -0
  101. package/tui/activity.ts +364 -0
  102. package/tui/app.ts +1372 -0
  103. package/tui/drums.ts +65 -0
  104. package/tui/highway.ts +921 -0
  105. package/tui/input.ts +63 -0
  106. package/tui/keys.ts +102 -0
  107. package/tui/layers.ts +80 -0
  108. package/tui/play-strip.ts +143 -0
  109. package/tui/prompt.ts +609 -0
  110. package/tui/render.ts +124 -0
  111. package/tui/screen.ts +247 -0
  112. package/tui/text.ts +72 -0
  113. package/tui/theme.ts +451 -0
@@ -0,0 +1,581 @@
1
+ import { mkdir, open, readFile, rename, rm } 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 { diffRewind, parseRewind, type Rewind } from "./delta.ts";
6
+ import {
7
+ applyMetaPatch,
8
+ defaultSessionMeta,
9
+ metaMatches,
10
+ MetaValidationError,
11
+ normalizeSessionName,
12
+ parseSessionMeta,
13
+ type MetaExpect,
14
+ type MetaPatch,
15
+ type SessionMeta,
16
+ } from "./meta.ts";
17
+
18
+ /** Caller payloads; the store keeps history separately in each event's `rewind`. */
19
+ const MAX_EVENT_BYTES = 64 * 1024;
20
+ /** A rewind larger than this (a wholesale import) is not kept; the edit still lands. */
21
+ const MAX_REWIND_BYTES = 1024 * 1024;
22
+ const MAX_EVENTS = 2_000;
23
+ export const MAX_RECORD_BYTES = 4 * 1024 * 1024;
24
+ /** Oldest rewinds are dropped in batches of this size when the record fills up. */
25
+ const COMPACT_BATCH = 64;
26
+ const MAX_SESSION_ID_LENGTH = 64;
27
+ const MAX_EVENT_KIND_LENGTH = 128;
28
+ const MAX_TIMESTAMP_LENGTH = 64;
29
+
30
+ export class SessionValidationError extends Error {
31
+ public constructor(message: string) {
32
+ super(message);
33
+ this.name = "SessionValidationError";
34
+ }
35
+ }
36
+
37
+ export type SessionEvent = {
38
+ id: string;
39
+ revision: number;
40
+ kind: string;
41
+ payload: unknown;
42
+ at: string;
43
+ /**
44
+ * How to recover the composition this event replaced from the one it
45
+ * produced (see `delta.ts`). Absent once compacted away: the store drops
46
+ * the oldest rewinds first when the record would exceed its size cap, so
47
+ * an edit never fails because of history, and undo reaches as far back as
48
+ * the remaining rewinds form a contiguous suffix.
49
+ */
50
+ rewind?: Rewind;
51
+ };
52
+
53
+ export type SessionRecord<T> = {
54
+ sessionId: string;
55
+ revision: number;
56
+ updatedAt: string;
57
+ composition: T;
58
+ events: SessionEvent[];
59
+ /** Name and lineage. Versioned separately so renames keep the revision. */
60
+ meta: SessionMeta;
61
+ };
62
+
63
+ export type SessionPaths = {
64
+ root: string;
65
+ pointer: string;
66
+ record: string;
67
+ lock: string;
68
+ };
69
+
70
+ export class SessionConflictError extends Error {
71
+ public constructor() {
72
+ super(
73
+ "session changed in another dawg window; retry against the latest revision",
74
+ );
75
+ this.name = "SessionConflictError";
76
+ }
77
+ }
78
+
79
+ /** Per-workspace state directory. */
80
+ export const STATE_DIR = ".dawg";
81
+
82
+ /** The workspace state directory, `<workspace>/.dawg`. */
83
+ export function stateDir(workspace = process.cwd()): string {
84
+ return join(workspace, STATE_DIR);
85
+ }
86
+
87
+ export function sessionPaths(
88
+ workspace = process.cwd(),
89
+ sessionId: string,
90
+ ): SessionPaths {
91
+ assertSessionId(sessionId);
92
+ const root = stateDir(workspace);
93
+ return {
94
+ root,
95
+ pointer: join(root, "session"),
96
+ record: join(root, "sessions", `${sessionId}.json`),
97
+ lock: join(root, "sessions", `${sessionId}.lock`),
98
+ };
99
+ }
100
+
101
+ export async function readCurrentSessionId(
102
+ workspace = process.cwd(),
103
+ ): Promise<string | undefined> {
104
+ try {
105
+ const id = (
106
+ await readFile(join(stateDir(workspace), "session"), "utf8")
107
+ ).trim();
108
+ if (id.length === 0) return undefined;
109
+ assertSessionId(id);
110
+ return id;
111
+ } catch (error) {
112
+ if (isCode(error, "ENOENT")) return undefined;
113
+ throw error;
114
+ }
115
+ }
116
+
117
+ export async function ensureSession<T>(
118
+ initial: T,
119
+ options: {
120
+ workspace?: string;
121
+ sessionId?: string;
122
+ setCurrent?: boolean;
123
+ /** Name for a newly created session; ignored when it already exists. */
124
+ name?: string;
125
+ } = {},
126
+ ): Promise<{ paths: SessionPaths; record: SessionRecord<T> }> {
127
+ const workspace = options.workspace ?? process.cwd();
128
+ const root = stateDir(workspace);
129
+ await mkdir(root, { recursive: true });
130
+ // Serialize pointer selection and first-record creation. Without this,
131
+ // two windows launched together can each choose a different random session
132
+ // and race to overwrite `.dawg/session`.
133
+ const initLock = join(root, ".init.lock");
134
+ const release = await acquireSessionLock(initLock);
135
+ try {
136
+ const sessionId =
137
+ options.sessionId ??
138
+ (await readCurrentSessionId(workspace)) ??
139
+ randomUUID();
140
+ const paths = sessionPaths(workspace, sessionId);
141
+ await mkdir(dirname(paths.record), { recursive: true });
142
+ const existing = await readRecordIfPresent<T>(paths.record);
143
+ if (existing) {
144
+ if (options.setCurrent !== false)
145
+ await writeAtomic(paths.pointer, `${sessionId}`);
146
+ return { paths, record: existing };
147
+ }
148
+ if (options.setCurrent !== false)
149
+ await writeAtomic(paths.pointer, `${sessionId}`);
150
+ try {
151
+ const at = new Date().toISOString();
152
+ const record: SessionRecord<T> = {
153
+ sessionId,
154
+ revision: 0,
155
+ updatedAt: at,
156
+ composition: initial,
157
+ events: [],
158
+ meta: defaultSessionMeta(
159
+ at,
160
+ options.name === undefined
161
+ ? undefined
162
+ : normalizeSessionName(options.name),
163
+ ),
164
+ };
165
+ const validated = validateSessionRecord<T>(record);
166
+ await writeAtomic(paths.record, JSON.stringify(validated));
167
+ return { paths, record: validated };
168
+ } catch (error) {
169
+ if (!isCode(error, "ENOENT")) throw error;
170
+ // A concurrent external creator can win only if it does not use the
171
+ // init lock. Read it and validate rather than replacing its record.
172
+ const record = await readRecordIfPresent<T>(paths.record);
173
+ if (!record) throw error;
174
+ return { paths, record };
175
+ }
176
+ } finally {
177
+ await release();
178
+ }
179
+ }
180
+
181
+ export async function appendSessionEvent<T>(
182
+ paths: SessionPaths,
183
+ current: SessionRecord<T>,
184
+ event: Omit<SessionEvent, "id" | "revision" | "at" | "rewind"> & {
185
+ id?: string;
186
+ },
187
+ composition: T,
188
+ ): Promise<SessionRecord<T>> {
189
+ // dawgd passes the client's idempotency key as the durable event id so a
190
+ // retried intent stays a no-op across daemon restarts.
191
+ if (event.id !== undefined) assertSessionId(event.id);
192
+ // History is the store's job: a `before` composition in the payload (the
193
+ // pre-rewind convention) is dropped rather than persisted in full.
194
+ const payload = stripBefore(event.payload);
195
+ const payloadJson = stringifyJson(payload, "session event payload");
196
+ const payloadBytes = Buffer.byteLength(payloadJson, "utf8");
197
+ if (payloadBytes > MAX_EVENT_BYTES)
198
+ throw new Error(`session event exceeds ${MAX_EVENT_BYTES} bytes`);
199
+ if (
200
+ typeof event.kind !== "string" ||
201
+ event.kind.length === 0 ||
202
+ event.kind.length > MAX_EVENT_KIND_LENGTH
203
+ )
204
+ throw new SessionValidationError("session event kind is invalid");
205
+ stringifyJson(composition, "session composition");
206
+ const release = await acquireSessionLock(paths.lock);
207
+ try {
208
+ const disk = await readRecord<T>(paths.record);
209
+ if (disk.revision !== current.revision) throw new SessionConflictError();
210
+ // Re-check the bounded event budget against the locked record. A stale
211
+ // caller can pass the pre-lock check while another writer fills the log.
212
+ if (disk.events.length >= MAX_EVENTS)
213
+ throw new Error(`session event limit ${MAX_EVENTS} reached`);
214
+ const revision = disk.revision + 1;
215
+ const at = new Date().toISOString();
216
+ const appended: SessionEvent = {
217
+ kind: event.kind,
218
+ payload,
219
+ id: event.id ?? randomUUID(),
220
+ revision,
221
+ at,
222
+ };
223
+ const rewind = diffRewind(disk.composition, composition);
224
+ if (Buffer.byteLength(JSON.stringify(rewind), "utf8") <= MAX_REWIND_BYTES)
225
+ appended.rewind = rewind;
226
+ const next: SessionRecord<T> = {
227
+ sessionId: disk.sessionId,
228
+ revision,
229
+ updatedAt: at,
230
+ composition,
231
+ events: [...disk.events, appended],
232
+ // Metadata always comes from disk so a rename written by another window
233
+ // between this caller's read and its write is never reverted.
234
+ meta: disk.meta,
235
+ };
236
+ const validated = validateSessionRecord<T>(next);
237
+ const { record, json } = compactRecord(validated);
238
+ await writeAtomic(paths.record, json);
239
+ return record;
240
+ } finally {
241
+ await release();
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Keeps the record under its size cap by dropping the oldest events'
247
+ * rewinds, oldest first, so the rewinds that remain are always the newest
248
+ * contiguous run. Only the composition and the bounded payloads are
249
+ * irreducible; a record that still does not fit is rejected as before.
250
+ */
251
+ function compactRecord<T>(record: SessionRecord<T>): {
252
+ record: SessionRecord<T>;
253
+ json: string;
254
+ } {
255
+ let current = record;
256
+ let json = JSON.stringify(current);
257
+ while (Buffer.byteLength(json, "utf8") > MAX_RECORD_BYTES) {
258
+ const first = current.events.findIndex(
259
+ (event) => event.rewind !== undefined,
260
+ );
261
+ if (first < 0)
262
+ throw new SessionValidationError(
263
+ `session record exceeds ${MAX_RECORD_BYTES} bytes`,
264
+ );
265
+ const events = current.events.map((event, index) => {
266
+ if (index < first || index >= first + COMPACT_BATCH || !event.rewind)
267
+ return event;
268
+ const { rewind: _rewind, ...rest } = event;
269
+ return rest;
270
+ });
271
+ current = { ...current, events };
272
+ json = JSON.stringify(current);
273
+ }
274
+ return { record: current, json };
275
+ }
276
+
277
+ function stripBefore(payload: unknown): unknown {
278
+ if (
279
+ typeof payload !== "object" ||
280
+ payload === null ||
281
+ Array.isArray(payload) ||
282
+ !("before" in payload)
283
+ )
284
+ return payload;
285
+ const { before: _before, ...rest } = payload as Record<string, unknown>;
286
+ return rest;
287
+ }
288
+
289
+ export type MetaUpdate<T> =
290
+ | { status: "applied"; record: SessionRecord<T> }
291
+ | { status: "stale"; record: SessionRecord<T> };
292
+
293
+ /**
294
+ * Conditional metadata write under the session lock. It applies only when
295
+ * `expect` still matches the record on disk, so the latest user rename wins
296
+ * and an in-flight auto-name computed against an older name is dropped.
297
+ * The score revision and event log are untouched.
298
+ */
299
+ export async function updateSessionMeta<T>(
300
+ paths: SessionPaths,
301
+ patch: MetaPatch,
302
+ expect?: MetaExpect,
303
+ ): Promise<MetaUpdate<T>> {
304
+ const release = await acquireSessionLock(paths.lock);
305
+ try {
306
+ const disk = await readRecord<T>(paths.record);
307
+ if (!metaMatches(disk.meta, expect))
308
+ return { status: "stale", record: disk };
309
+ const next: SessionRecord<T> = {
310
+ ...disk,
311
+ meta: applyMetaPatch(disk.meta, patch, new Date().toISOString()),
312
+ };
313
+ const validated = validateSessionRecord<T>(next);
314
+ await writeAtomic(paths.record, JSON.stringify(validated));
315
+ return { status: "applied", record: validated };
316
+ } finally {
317
+ await release();
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Creates a new session holding a snapshot of `source` at its current
323
+ * revision: the composition only, not the event log, with `forkOf` lineage.
324
+ */
325
+ export async function createForkSession<T>(
326
+ workspace: string,
327
+ source: SessionRecord<T>,
328
+ name: string,
329
+ ): Promise<SessionRecord<T>> {
330
+ const sessionId = randomUUID();
331
+ const paths = sessionPaths(workspace, sessionId);
332
+ await mkdir(dirname(paths.record), { recursive: true });
333
+ const at = new Date().toISOString();
334
+ const record = validateSessionRecord<T>({
335
+ sessionId,
336
+ revision: 0,
337
+ updatedAt: at,
338
+ composition: source.composition,
339
+ events: [],
340
+ meta: {
341
+ ...defaultSessionMeta(at, normalizeSessionName(name)),
342
+ nameSource: source.meta.nameSource,
343
+ forkOf: { sessionId: source.sessionId, revision: source.revision },
344
+ },
345
+ });
346
+ await writeAtomic(paths.record, JSON.stringify(record));
347
+ return record;
348
+ }
349
+
350
+ /** Fork ancestry walked for undo, and the inherited event budget. */
351
+ export const MAX_FORK_DEPTH = 8;
352
+ export const MAX_INHERITED_EVENTS = MAX_EVENTS;
353
+
354
+ /**
355
+ * The event log a fork inherits for undo: each ancestor's events up to the
356
+ * revision it was forked at, oldest ancestor first, via the `forkOf` chain.
357
+ * Nothing is copied at fork time; the parent prefix is append-only, so it is
358
+ * immutable and safe to read later. Bounded by depth, by total events (the
359
+ * oldest are dropped first) and by cycle detection. A missing, unreadable or
360
+ * inconsistent ancestor (forked past its own revision) ends the chain there.
361
+ */
362
+ export async function inheritedEvents(
363
+ workspace: string,
364
+ meta: SessionMeta,
365
+ selfId: string,
366
+ ): Promise<SessionEvent[]> {
367
+ const chunks: SessionEvent[][] = [];
368
+ const seen = new Set([selfId]);
369
+ let fork = meta.forkOf;
370
+ let total = 0;
371
+ for (
372
+ let depth = 0;
373
+ fork && depth < MAX_FORK_DEPTH && total < MAX_INHERITED_EVENTS;
374
+ depth += 1
375
+ ) {
376
+ if (seen.has(fork.sessionId)) break;
377
+ seen.add(fork.sessionId);
378
+ let parent: SessionRecord<unknown> | undefined;
379
+ try {
380
+ parent = await readRecordIfPresent<unknown>(
381
+ sessionPaths(workspace, fork.sessionId).record,
382
+ );
383
+ } catch {
384
+ break;
385
+ }
386
+ if (!parent || fork.revision > parent.events.length) break;
387
+ const prefix = parent.events.slice(0, fork.revision);
388
+ chunks.unshift(prefix.slice(-(MAX_INHERITED_EVENTS - total)));
389
+ total += prefix.length;
390
+ fork = parent.meta.forkOf;
391
+ }
392
+ return chunks.flat().slice(-MAX_INHERITED_EVENTS);
393
+ }
394
+
395
+ /** Points `.dawg/session` at `sessionId` so plain `dawg` resumes it. */
396
+ export async function setCurrentSession(
397
+ workspace: string,
398
+ sessionId: string,
399
+ ): Promise<void> {
400
+ const paths = sessionPaths(workspace, sessionId);
401
+ await mkdir(paths.root, { recursive: true });
402
+ await writeAtomic(paths.pointer, sessionId);
403
+ }
404
+
405
+ export async function loadSession<T>(
406
+ paths: SessionPaths,
407
+ ): Promise<SessionRecord<T>> {
408
+ return readRecord<T>(paths.record);
409
+ }
410
+
411
+ async function writeAtomic(path: string, contents: string): Promise<void> {
412
+ if (Buffer.byteLength(contents, "utf8") > MAX_RECORD_BYTES)
413
+ throw new SessionValidationError(
414
+ `session record exceeds ${MAX_RECORD_BYTES} bytes`,
415
+ );
416
+ const temporary = `${path}.${process.pid}.tmp`;
417
+ // Durable before it is visible: the data reaches the disk before the
418
+ // rename publishes it, so a crash never leaves a truncated record behind.
419
+ const handle = await open(temporary, "w", 0o644);
420
+ try {
421
+ await handle.writeFile(`${contents}\n`, "utf8");
422
+ await handle.sync();
423
+ } catch (error) {
424
+ await handle.close().catch(() => undefined);
425
+ await rm(temporary, { force: true }).catch(() => undefined);
426
+ throw error;
427
+ }
428
+ await handle.close();
429
+ await rename(temporary, path);
430
+ }
431
+
432
+ async function readRecord<T>(path: string): Promise<SessionRecord<T>> {
433
+ const contents = await readFile(path, "utf8");
434
+ if (Buffer.byteLength(contents, "utf8") > MAX_RECORD_BYTES)
435
+ throw new SessionValidationError(
436
+ `session record exceeds ${MAX_RECORD_BYTES} bytes`,
437
+ );
438
+ let value: unknown;
439
+ try {
440
+ value = JSON.parse(contents);
441
+ } catch {
442
+ throw new SessionValidationError("session record contains invalid JSON");
443
+ }
444
+ return validateSessionRecord<T>(value);
445
+ }
446
+
447
+ async function readRecordIfPresent<T>(
448
+ path: string,
449
+ ): Promise<SessionRecord<T> | undefined> {
450
+ try {
451
+ return await readRecord<T>(path);
452
+ } catch (error) {
453
+ if (isCode(error, "ENOENT")) return undefined;
454
+ throw error;
455
+ }
456
+ }
457
+
458
+ function validateSessionRecord<T>(value: unknown): SessionRecord<T> {
459
+ if (typeof value !== "object" || value === null)
460
+ throw new SessionValidationError("session record must be an object");
461
+ const record = value as Record<string, unknown>;
462
+ assertSessionId(record.sessionId);
463
+ if (!Number.isSafeInteger(record.revision) || (record.revision as number) < 0)
464
+ throw new SessionValidationError("session revision must be a safe integer");
465
+ if (
466
+ typeof record.updatedAt !== "string" ||
467
+ record.updatedAt.length === 0 ||
468
+ record.updatedAt.length > MAX_TIMESTAMP_LENGTH
469
+ )
470
+ throw new SessionValidationError("session updatedAt is invalid");
471
+ if (!Array.isArray(record.events) || record.events.length > MAX_EVENTS)
472
+ throw new SessionValidationError(
473
+ `session has more than ${MAX_EVENTS} events`,
474
+ );
475
+ if (record.revision !== record.events.length)
476
+ throw new SessionValidationError(
477
+ "session revision does not match its event log",
478
+ );
479
+ if (!("composition" in record))
480
+ throw new SessionValidationError("session composition is missing");
481
+ if (record.composition === undefined)
482
+ throw new SessionValidationError("session composition is not JSON data");
483
+ const events = record.events.map((event, index) => {
484
+ if (typeof event !== "object" || event === null)
485
+ throw new SessionValidationError("session event must be an object");
486
+ const candidate = event as Record<string, unknown>;
487
+ assertSessionId(candidate.id);
488
+ if (
489
+ !Number.isSafeInteger(candidate.revision) ||
490
+ candidate.revision !== index + 1
491
+ )
492
+ throw new SessionValidationError("session event revisions are invalid");
493
+ if (
494
+ typeof candidate.kind !== "string" ||
495
+ candidate.kind.length === 0 ||
496
+ candidate.kind.length > MAX_EVENT_KIND_LENGTH
497
+ )
498
+ throw new SessionValidationError("session event kind is invalid");
499
+ if (
500
+ typeof candidate.at !== "string" ||
501
+ candidate.at.length > MAX_TIMESTAMP_LENGTH
502
+ )
503
+ throw new SessionValidationError("session event timestamp is invalid");
504
+ const payloadJson = stringifyJson(
505
+ candidate.payload,
506
+ "session event payload",
507
+ );
508
+ if (Buffer.byteLength(payloadJson, "utf8") > MAX_EVENT_BYTES)
509
+ throw new SessionValidationError(
510
+ `session event exceeds ${MAX_EVENT_BYTES} bytes`,
511
+ );
512
+ let rewind: Rewind | undefined;
513
+ try {
514
+ rewind = parseRewind(candidate.rewind);
515
+ } catch (error) {
516
+ throw new SessionValidationError(
517
+ error instanceof Error
518
+ ? error.message
519
+ : "session event rewind is invalid",
520
+ );
521
+ }
522
+ const parsed: SessionEvent = {
523
+ id: candidate.id,
524
+ revision: candidate.revision,
525
+ kind: candidate.kind,
526
+ payload: candidate.payload,
527
+ at: candidate.at,
528
+ };
529
+ if (rewind !== undefined) parsed.rewind = rewind;
530
+ return parsed;
531
+ });
532
+ let meta: SessionMeta;
533
+ try {
534
+ meta = parseSessionMeta(record.meta, {
535
+ sessionId: record.sessionId,
536
+ updatedAt: record.updatedAt,
537
+ });
538
+ } catch (error) {
539
+ if (error instanceof MetaValidationError)
540
+ throw new SessionValidationError(error.message);
541
+ throw error;
542
+ }
543
+ return {
544
+ sessionId: record.sessionId,
545
+ revision: record.revision as number,
546
+ updatedAt: record.updatedAt,
547
+ composition: record.composition as T,
548
+ events,
549
+ meta,
550
+ };
551
+ }
552
+
553
+ export function assertSessionId(value: unknown): asserts value is string {
554
+ if (
555
+ typeof value !== "string" ||
556
+ value.length === 0 ||
557
+ value.length > MAX_SESSION_ID_LENGTH ||
558
+ !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) ||
559
+ value === "." ||
560
+ value === ".."
561
+ )
562
+ throw new SessionValidationError(
563
+ "session id contains unsafe path characters",
564
+ );
565
+ }
566
+
567
+ function isCode(error: unknown, code: string): boolean {
568
+ return error instanceof Error && "code" in error && error.code === code;
569
+ }
570
+
571
+ function stringifyJson(value: unknown, label: string): string {
572
+ try {
573
+ const encoded = JSON.stringify(value);
574
+ if (encoded === undefined)
575
+ throw new SessionValidationError(`${label} must be JSON data`);
576
+ return encoded;
577
+ } catch (error) {
578
+ if (error instanceof SessionValidationError) throw error;
579
+ throw new SessionValidationError(`${label} must be JSON data`);
580
+ }
581
+ }