@hraness/dawg 0.2.0 → 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.
- package/CHANGELOG.md +79 -0
- package/DAWG.md +196 -11
- package/README.md +79 -30
- package/core/diff.ts +249 -0
- package/core/key.ts +43 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +324 -1
- package/core/sdk/eval-child.ts +113 -0
- package/core/sdk/eval.ts +257 -0
- package/core/sdk/print.ts +393 -0
- package/core/sdk/v1.ts +954 -0
- package/core/slug.ts +19 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +280 -14
- package/src/agent/brief.ts +23 -3
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/planner.ts +38 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +398 -1
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +27 -12
- package/src/agent/xcb.ts +233 -21
- package/src/audio/click.ts +125 -0
- package/src/audio/engine.ts +407 -33
- package/src/audio/live.ts +152 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +292 -0
- package/src/audio/samples.ts +683 -0
- package/src/audio/wav.ts +290 -76
- package/src/auth/cli.ts +146 -27
- package/src/auth/credentials.ts +167 -41
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +885 -128
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +25 -2
- package/src/auth/tui.ts +60 -43
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +24 -7
- package/src/commands/sample.ts +302 -0
- package/src/main.ts +1043 -166
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +173 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +292 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +28 -6
- package/src/session/attach.ts +3 -4
- package/src/session/daemon.ts +25 -8
- package/src/session/delta.ts +249 -0
- package/src/session/naming.ts +3 -37
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/menu.ts +1083 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +636 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +681 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +261 -18
- package/tui/drums.ts +44 -0
- package/tui/highway.ts +18 -2
- package/tui/layers.ts +14 -2
- package/tui/play-strip.ts +143 -0
package/src/session/port.ts
CHANGED
|
@@ -56,6 +56,8 @@ export type WindowPlayer = {
|
|
|
56
56
|
/** Start at `beat`, or swap a playing loop in place (gapless engine). */
|
|
57
57
|
play(score: TrackScore, beat?: number): Promise<void>;
|
|
58
58
|
stop(): void;
|
|
59
|
+
/** Stops and releases the render worker; the player is done for good. */
|
|
60
|
+
dispose(): Promise<void>;
|
|
59
61
|
};
|
|
60
62
|
|
|
61
63
|
/**
|
|
@@ -65,6 +67,8 @@ export type WindowPlayer = {
|
|
|
65
67
|
*/
|
|
66
68
|
export interface SessionPort<T> {
|
|
67
69
|
readonly mode: "daemon" | "file";
|
|
70
|
+
/** This window's presence id, to exclude itself from `presence()`. */
|
|
71
|
+
readonly clientId: string;
|
|
68
72
|
/** Human-readable status for the activity line. */
|
|
69
73
|
readonly status: string;
|
|
70
74
|
readonly player: WindowPlayer;
|
|
@@ -157,10 +161,12 @@ export async function openSessionPort<T>(
|
|
|
157
161
|
const silentPlayer: WindowPlayer = {
|
|
158
162
|
play: async () => undefined,
|
|
159
163
|
stop: () => undefined,
|
|
164
|
+
dispose: async () => undefined,
|
|
160
165
|
};
|
|
161
166
|
|
|
162
167
|
class DaemonPort<T> implements SessionPort<T> {
|
|
163
168
|
public readonly mode = "daemon" as const;
|
|
169
|
+
public readonly clientId: string;
|
|
164
170
|
public readonly player = silentPlayer;
|
|
165
171
|
public status: string;
|
|
166
172
|
public sync: SyncStatus = "synced";
|
|
@@ -168,6 +174,7 @@ class DaemonPort<T> implements SessionPort<T> {
|
|
|
168
174
|
|
|
169
175
|
public constructor(private readonly client: DaemonClient) {
|
|
170
176
|
this.status = `dawgd pid ${client.daemonPid}`;
|
|
177
|
+
this.clientId = client.clientId;
|
|
171
178
|
}
|
|
172
179
|
|
|
173
180
|
private setSync(sync: SyncStatus): void {
|
|
@@ -303,17 +310,24 @@ class DaemonPort<T> implements SessionPort<T> {
|
|
|
303
310
|
|
|
304
311
|
class FilePort<T> implements SessionPort<T> {
|
|
305
312
|
public readonly mode = "file" as const;
|
|
313
|
+
public readonly clientId = randomUUID();
|
|
306
314
|
public status = "file session";
|
|
307
315
|
public readonly sync = "local" as const;
|
|
308
316
|
public readonly player: WindowPlayer;
|
|
309
317
|
private readonly presenceStore: FilePresence;
|
|
318
|
+
private readonly statusListeners = new Set<(update: PortUpdate<T>) => void>();
|
|
310
319
|
|
|
311
320
|
private constructor(private readonly options: OpenPortOptions) {
|
|
312
321
|
this.player = new AudioEngine({
|
|
313
322
|
lockPath: `${options.paths.record}.audio.lock`,
|
|
323
|
+
projectRoot: dirname(options.paths.root),
|
|
324
|
+
onStatus: (status) => {
|
|
325
|
+
for (const listener of this.statusListeners)
|
|
326
|
+
listener({ type: "status", message: status.message });
|
|
327
|
+
},
|
|
314
328
|
});
|
|
315
329
|
this.presenceStore = new FilePresence(options.paths, {
|
|
316
|
-
clientId:
|
|
330
|
+
clientId: this.clientId,
|
|
317
331
|
pid: process.pid,
|
|
318
332
|
label: options.label.slice(0, 128),
|
|
319
333
|
focusedTrackId: options.focusedTrackId,
|
|
@@ -393,13 +407,17 @@ class FilePort<T> implements SessionPort<T> {
|
|
|
393
407
|
});
|
|
394
408
|
};
|
|
395
409
|
const recordName = basename(this.options.paths.record);
|
|
410
|
+
// macOS coalesces the temp write and the rename into events named after
|
|
411
|
+
// the temp file (`<record>.<pid>.tmp`), so match that too, but not the
|
|
412
|
+
// sibling `.audio.lock` / `.daemon.log` files that share the prefix.
|
|
413
|
+
const recordFile = new RegExp(
|
|
414
|
+
`^${recordName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\.\\d+\\.tmp)?$`,
|
|
415
|
+
);
|
|
396
416
|
let watcher: FSWatcher | undefined;
|
|
397
417
|
try {
|
|
398
418
|
watcher = watch(dirname(this.options.paths.record), (_event, file) => {
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
// re-check shortly after in case the event preceded the rename.
|
|
402
|
-
if (file !== null && !String(file).startsWith(recordName)) return;
|
|
419
|
+
// Re-check shortly after in case the event preceded the rename.
|
|
420
|
+
if (file !== null && !recordFile.test(String(file))) return;
|
|
403
421
|
check();
|
|
404
422
|
setTimeout(check, 40).unref?.();
|
|
405
423
|
});
|
|
@@ -412,9 +430,11 @@ class FilePort<T> implements SessionPort<T> {
|
|
|
412
430
|
watcher = undefined;
|
|
413
431
|
}
|
|
414
432
|
const timer = setInterval(check, watcher ? WATCHED_POLL_MS : POLL_MS);
|
|
433
|
+
this.statusListeners.add(listener);
|
|
415
434
|
check();
|
|
416
435
|
return () => {
|
|
417
436
|
closed = true;
|
|
437
|
+
this.statusListeners.delete(listener);
|
|
418
438
|
clearInterval(timer);
|
|
419
439
|
watcher?.close();
|
|
420
440
|
};
|
|
@@ -455,7 +475,7 @@ class FilePort<T> implements SessionPort<T> {
|
|
|
455
475
|
}
|
|
456
476
|
|
|
457
477
|
public async close(): Promise<void> {
|
|
458
|
-
this.player.
|
|
478
|
+
void this.player.dispose();
|
|
459
479
|
await this.presenceStore.stop();
|
|
460
480
|
}
|
|
461
481
|
}
|
package/src/session/rebase.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type ScoreOperation,
|
|
16
16
|
type TrackScore,
|
|
17
17
|
} from "../../core/score.ts";
|
|
18
|
+
import { rewindComposition, type Rewind } from "./delta.ts";
|
|
18
19
|
|
|
19
20
|
/** How far behind a base may be and still be rebased. */
|
|
20
21
|
export const MAX_REBASE_DISTANCE = 64;
|
|
@@ -105,6 +106,30 @@ export function rebaseOperations(
|
|
|
105
106
|
if (base.bars !== current.bars)
|
|
106
107
|
return { ok: false, reason: "loop length changed" };
|
|
107
108
|
break;
|
|
109
|
+
case "setKey":
|
|
110
|
+
if (base.key !== current.key)
|
|
111
|
+
return { ok: false, reason: "key changed" };
|
|
112
|
+
break;
|
|
113
|
+
case "setMeter":
|
|
114
|
+
if (base.beatsPerBar !== current.beatsPerBar)
|
|
115
|
+
return { ok: false, reason: "meter changed" };
|
|
116
|
+
break;
|
|
117
|
+
case "removeTrack": {
|
|
118
|
+
if (added.tracks.has(operation.trackId)) break;
|
|
119
|
+
if (
|
|
120
|
+
!settingsUnchanged(operation.trackId) ||
|
|
121
|
+
!notesUnchanged(operation.trackId)
|
|
122
|
+
)
|
|
123
|
+
return { ok: false, reason: `track ${operation.trackId} changed` };
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
case "moveTrack": {
|
|
127
|
+
const order = (score: TrackScore) =>
|
|
128
|
+
score.tracks.map((track) => track.id).join("\u0000");
|
|
129
|
+
if (order(base) !== order(current))
|
|
130
|
+
return { ok: false, reason: "track order changed" };
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
108
133
|
default:
|
|
109
134
|
return { ok: false, reason: "unsupported operation" };
|
|
110
135
|
}
|
|
@@ -123,16 +148,21 @@ export function rebaseOperations(
|
|
|
123
148
|
}
|
|
124
149
|
|
|
125
150
|
/**
|
|
126
|
-
* The composition at revision `revision`, recovered
|
|
127
|
-
*
|
|
151
|
+
* The composition at revision `revision`, recovered by rewinding the current
|
|
152
|
+
* composition through every later event's `rewind`. Undefined when the log
|
|
153
|
+
* cannot vouch for it (a gap in revisions, or a rewind compacted away).
|
|
128
154
|
*/
|
|
129
155
|
export function compositionAt(
|
|
130
|
-
|
|
156
|
+
record: {
|
|
157
|
+
composition: unknown;
|
|
158
|
+
events: readonly { revision: number; rewind?: Rewind | undefined }[];
|
|
159
|
+
},
|
|
131
160
|
revision: number,
|
|
132
161
|
): unknown {
|
|
133
|
-
const
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
162
|
+
const { events } = record;
|
|
163
|
+
if (!Number.isSafeInteger(revision) || revision < 0) return undefined;
|
|
164
|
+
if (revision >= events.length) return undefined;
|
|
165
|
+
for (let index = revision; index < events.length; index += 1)
|
|
166
|
+
if (events[index]!.revision !== index + 1) return undefined;
|
|
167
|
+
return rewindComposition(record.composition, events, revision);
|
|
138
168
|
}
|
package/src/session/store.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { mkdir, readFile, rename, rm
|
|
1
|
+
import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { acquireSessionLock } from "./lock.ts";
|
|
5
|
+
import { diffRewind, parseRewind, type Rewind } from "./delta.ts";
|
|
5
6
|
import {
|
|
6
7
|
applyMetaPatch,
|
|
7
8
|
defaultSessionMeta,
|
|
@@ -14,9 +15,14 @@ import {
|
|
|
14
15
|
type SessionMeta,
|
|
15
16
|
} from "./meta.ts";
|
|
16
17
|
|
|
18
|
+
/** Caller payloads; the store keeps history separately in each event's `rewind`. */
|
|
17
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;
|
|
18
22
|
const MAX_EVENTS = 2_000;
|
|
19
|
-
const MAX_RECORD_BYTES = 4 * 1024 * 1024;
|
|
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;
|
|
20
26
|
const MAX_SESSION_ID_LENGTH = 64;
|
|
21
27
|
const MAX_EVENT_KIND_LENGTH = 128;
|
|
22
28
|
const MAX_TIMESTAMP_LENGTH = 64;
|
|
@@ -34,6 +40,14 @@ export type SessionEvent = {
|
|
|
34
40
|
kind: string;
|
|
35
41
|
payload: unknown;
|
|
36
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;
|
|
37
51
|
};
|
|
38
52
|
|
|
39
53
|
export type SessionRecord<T> = {
|
|
@@ -149,7 +163,7 @@ export async function ensureSession<T>(
|
|
|
149
163
|
),
|
|
150
164
|
};
|
|
151
165
|
const validated = validateSessionRecord<T>(record);
|
|
152
|
-
await writeAtomic(paths.record, JSON.stringify(validated
|
|
166
|
+
await writeAtomic(paths.record, JSON.stringify(validated));
|
|
153
167
|
return { paths, record: validated };
|
|
154
168
|
} catch (error) {
|
|
155
169
|
if (!isCode(error, "ENOENT")) throw error;
|
|
@@ -167,13 +181,18 @@ export async function ensureSession<T>(
|
|
|
167
181
|
export async function appendSessionEvent<T>(
|
|
168
182
|
paths: SessionPaths,
|
|
169
183
|
current: SessionRecord<T>,
|
|
170
|
-
event: Omit<SessionEvent, "id" | "revision" | "at"> & {
|
|
184
|
+
event: Omit<SessionEvent, "id" | "revision" | "at" | "rewind"> & {
|
|
185
|
+
id?: string;
|
|
186
|
+
},
|
|
171
187
|
composition: T,
|
|
172
188
|
): Promise<SessionRecord<T>> {
|
|
173
189
|
// dawgd passes the client's idempotency key as the durable event id so a
|
|
174
190
|
// retried intent stays a no-op across daemon restarts.
|
|
175
191
|
if (event.id !== undefined) assertSessionId(event.id);
|
|
176
|
-
|
|
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");
|
|
177
196
|
const payloadBytes = Buffer.byteLength(payloadJson, "utf8");
|
|
178
197
|
if (payloadBytes > MAX_EVENT_BYTES)
|
|
179
198
|
throw new Error(`session event exceeds ${MAX_EVENT_BYTES} bytes`);
|
|
@@ -183,6 +202,7 @@ export async function appendSessionEvent<T>(
|
|
|
183
202
|
event.kind.length > MAX_EVENT_KIND_LENGTH
|
|
184
203
|
)
|
|
185
204
|
throw new SessionValidationError("session event kind is invalid");
|
|
205
|
+
stringifyJson(composition, "session composition");
|
|
186
206
|
const release = await acquireSessionLock(paths.lock);
|
|
187
207
|
try {
|
|
188
208
|
const disk = await readRecord<T>(paths.record);
|
|
@@ -193,33 +213,79 @@ export async function appendSessionEvent<T>(
|
|
|
193
213
|
throw new Error(`session event limit ${MAX_EVENTS} reached`);
|
|
194
214
|
const revision = disk.revision + 1;
|
|
195
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;
|
|
196
226
|
const next: SessionRecord<T> = {
|
|
197
227
|
sessionId: disk.sessionId,
|
|
198
228
|
revision,
|
|
199
229
|
updatedAt: at,
|
|
200
230
|
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
|
-
],
|
|
231
|
+
events: [...disk.events, appended],
|
|
211
232
|
// Metadata always comes from disk so a rename written by another window
|
|
212
233
|
// between this caller's read and its write is never reverted.
|
|
213
234
|
meta: disk.meta,
|
|
214
235
|
};
|
|
215
236
|
const validated = validateSessionRecord<T>(next);
|
|
216
|
-
|
|
217
|
-
|
|
237
|
+
const { record, json } = compactRecord(validated);
|
|
238
|
+
await writeAtomic(paths.record, json);
|
|
239
|
+
return record;
|
|
218
240
|
} finally {
|
|
219
241
|
await release();
|
|
220
242
|
}
|
|
221
243
|
}
|
|
222
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
|
+
|
|
223
289
|
export type MetaUpdate<T> =
|
|
224
290
|
| { status: "applied"; record: SessionRecord<T> }
|
|
225
291
|
| { status: "stale"; record: SessionRecord<T> };
|
|
@@ -245,7 +311,7 @@ export async function updateSessionMeta<T>(
|
|
|
245
311
|
meta: applyMetaPatch(disk.meta, patch, new Date().toISOString()),
|
|
246
312
|
};
|
|
247
313
|
const validated = validateSessionRecord<T>(next);
|
|
248
|
-
await writeAtomic(paths.record, JSON.stringify(validated
|
|
314
|
+
await writeAtomic(paths.record, JSON.stringify(validated));
|
|
249
315
|
return { status: "applied", record: validated };
|
|
250
316
|
} finally {
|
|
251
317
|
await release();
|
|
@@ -277,7 +343,7 @@ export async function createForkSession<T>(
|
|
|
277
343
|
forkOf: { sessionId: source.sessionId, revision: source.revision },
|
|
278
344
|
},
|
|
279
345
|
});
|
|
280
|
-
await writeAtomic(paths.record, JSON.stringify(record
|
|
346
|
+
await writeAtomic(paths.record, JSON.stringify(record));
|
|
281
347
|
return record;
|
|
282
348
|
}
|
|
283
349
|
|
|
@@ -348,7 +414,18 @@ async function writeAtomic(path: string, contents: string): Promise<void> {
|
|
|
348
414
|
`session record exceeds ${MAX_RECORD_BYTES} bytes`,
|
|
349
415
|
);
|
|
350
416
|
const temporary = `${path}.${process.pid}.tmp`;
|
|
351
|
-
|
|
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();
|
|
352
429
|
await rename(temporary, path);
|
|
353
430
|
}
|
|
354
431
|
|
|
@@ -432,7 +509,25 @@ function validateSessionRecord<T>(value: unknown): SessionRecord<T> {
|
|
|
432
509
|
throw new SessionValidationError(
|
|
433
510
|
`session event exceeds ${MAX_EVENT_BYTES} bytes`,
|
|
434
511
|
);
|
|
435
|
-
|
|
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;
|
|
436
531
|
});
|
|
437
532
|
let meta: SessionMeta;
|
|
438
533
|
try {
|