@mavware/bug-surveillance 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/src/index.js ADDED
@@ -0,0 +1,72 @@
1
+ // @mavware/bug-surveillance — overnight bug surveillance from a browser camera.
2
+ //
3
+ // Detection and tracking run entirely in the browser; a night can be kept in the
4
+ // browser's own IndexedDB store and reported on without any server at all. The
5
+ // consumer owns the page: it wires a camera, a detector and a tracker together,
6
+ // hands closed tracks to a "sink" (the LocalNightSink here, or one of its own
7
+ // that uploads them), and draws the report with Replay. Nothing in this package
8
+ // assumes a framework, a route, or a piece of markup.
9
+
10
+ // Seeing: the camera, calibration, and per-frame detection and tracking.
11
+ export { Camera } from './camera.js';
12
+ export { BRIGHTNESS_BLOCK, BRIGHTNESS_WARN, calibrate, toGrayscale } from './brightness.js';
13
+ export { DEFAULT_PARAMS, Detector } from './detector.js';
14
+ export { TRACKER_DEFAULTS, Tracker } from './tracker.js';
15
+ export { WakeLock } from './wakeLock.js';
16
+
17
+ // Deciding: what a capture page says and shows.
18
+ export {
19
+ CAMERA_CHECK_MESSAGE,
20
+ DIM_MESSAGE,
21
+ LARGE_MOTION_MESSAGE,
22
+ LEAVE_ROOM_SECONDS,
23
+ PREFLIGHT_MESSAGE,
24
+ TOO_DARK_MESSAGE,
25
+ WATCHING_MESSAGE,
26
+ calibrationOutcome,
27
+ cameraCheckLabel,
28
+ countdownMessage,
29
+ formatClock,
30
+ overlayBoxes,
31
+ wakeLockMessage,
32
+ watchingState,
33
+ } from './captureLogic.js';
34
+
35
+ // Summarising: the night's analytics.
36
+ export {
37
+ EDGE_BINS,
38
+ classifyEdge,
39
+ clusterEdgePoints,
40
+ computeNightAnalytics,
41
+ mergeAdjacentBins,
42
+ trackEdges,
43
+ } from './sessionAnalytics.js';
44
+
45
+ // Keeping: a night in the browser.
46
+ export { IndexedDbNightStore, STORE_NAME, STORE_VERSION, openNightStore, upgradeSchema } from './nightStore.js';
47
+ export { InMemoryNightStore } from './nightStoreMemory.js';
48
+ export { HEARTBEAT_INTERVAL_MS, LocalNightSink } from './localNightSink.js';
49
+ export {
50
+ LOCAL_ID_PLACEHOLDER,
51
+ LOCAL_STORAGE_NOTICE,
52
+ MISSING_NIGHT_MESSAGE,
53
+ NIGHT_BOUNDARY_HOUR,
54
+ VOLATILE_STORE_MESSAGE,
55
+ buildLocalNight,
56
+ buildLocalReportPayload,
57
+ finalizeInterruptedNight,
58
+ localTrackFromClosed,
59
+ nightAnalytics,
60
+ nightDateFor,
61
+ nightName,
62
+ nightRows,
63
+ referenceBlobKey,
64
+ reportHeader,
65
+ reportUrlFor,
66
+ sightingRows,
67
+ statTiles,
68
+ } from './localNight.js';
69
+
70
+ // Showing: the replay and its controls.
71
+ export { Replay } from './replay.js';
72
+ export { loadImage, mountReportControls, trackIdFrom } from './reportControls.js';
@@ -0,0 +1,196 @@
1
+ // The decisions behind a night that never reaches the server: what a stored
2
+ // night looks like, how it is named, how its tracks and report are shaped, and
3
+ // what the pages say about it. Pure, so it is tested in node; the pages and the
4
+ // sink are plumbing that call it. Names and shapes mirror the server's so a
5
+ // night claimed into an account later is indistinguishable from a live one.
6
+ import { computeNightAnalytics, trackEdges } from './sessionAnalytics.js';
7
+
8
+ /**
9
+ * A night begun after midnight still belongs to the evening before, the same
10
+ * rule SurveillanceSession::nightDateFor applies on the server.
11
+ */
12
+ export const NIGHT_BOUNDARY_HOUR = 6;
13
+
14
+ /**
15
+ * The uuid the watch report route is generated with, for the JS to swap out.
16
+ * Routes are named server-side and the page cannot know a night's id up front.
17
+ */
18
+ export const LOCAL_ID_PLACEHOLDER = '00000000-0000-4000-8000-000000000000';
19
+
20
+ export const LOCAL_STORAGE_NOTICE =
21
+ 'Nights recorded here stay in this browser on this device. Clearing site data removes them. Create an account to keep them and see trends across nights.';
22
+
23
+ export const VOLATILE_STORE_MESSAGE =
24
+ 'This browser will not keep nights between visits — the report will be gone once this tab closes. Try a normal (non-private) window to keep them.';
25
+
26
+ export const MISSING_NIGHT_MESSAGE =
27
+ 'This night is not stored in this browser. Nights are kept only on the device that recorded them.';
28
+
29
+ export function nightDateFor(ms) {
30
+ const date = new Date(ms - NIGHT_BOUNDARY_HOUR * 60 * 60 * 1000);
31
+ date.setHours(0, 0, 0, 0);
32
+
33
+ return date;
34
+ }
35
+
36
+ /** "Night of Sep 8", the shape the dashboard gives a session when it is created. */
37
+ export function nightName(startedAt) {
38
+ return `Night of ${nightDateFor(startedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`;
39
+ }
40
+
41
+ export function buildLocalNight({ id, startedAt, frameWidth, frameHeight, settings }) {
42
+ return {
43
+ id,
44
+ name: nightName(startedAt),
45
+ status: 'active',
46
+ startedAt,
47
+ endedAt: null,
48
+ lastHeartbeatAt: startedAt,
49
+ frameWidth,
50
+ frameHeight,
51
+ settings,
52
+ analytics: null,
53
+ createdAt: startedAt,
54
+ claimedSessionId: null,
55
+ claimedAt: null,
56
+ };
57
+ }
58
+
59
+ export function referenceBlobKey(nightId) {
60
+ return `${nightId}/reference`;
61
+ }
62
+
63
+ export function reportUrlFor(template, nightId) {
64
+ return template.replace(LOCAL_ID_PLACEHOLDER, nightId);
65
+ }
66
+
67
+ /**
68
+ * A closed track from the tracker, as the store keeps it: the server's insert
69
+ * shape with edges classified up front, as CaptureController does.
70
+ */
71
+ export function localTrackFromClosed(track, nightId, frameWidth, frameHeight) {
72
+ return {
73
+ nightId,
74
+ clientTrackId: track.client_track_id,
75
+ startOffsetMs: track.start_offset_ms,
76
+ endOffsetMs: track.end_offset_ms,
77
+ pointCount: track.points.length,
78
+ points: track.points,
79
+ ...trackEdges(track.points, frameWidth, frameHeight),
80
+ startCrop: track.start_crop ?? null,
81
+ endCrop: track.end_crop ?? null,
82
+ dismissedAt: null,
83
+ };
84
+ }
85
+
86
+ export function nightAnalytics(night, tracks) {
87
+ return computeNightAnalytics({
88
+ tracks,
89
+ startedAt: night.startedAt,
90
+ endedAt: night.endedAt,
91
+ frameWidth: night.frameWidth,
92
+ frameHeight: night.frameHeight,
93
+ });
94
+ }
95
+
96
+ /** The payload replay.js draws from, in exactly the shape the server's report page emits. */
97
+ export function buildLocalReportPayload(night, tracks) {
98
+ return {
99
+ frameWidth: night.frameWidth,
100
+ frameHeight: night.frameHeight,
101
+ analytics: night.analytics ?? nightAnalytics(night, tracks),
102
+ tracks: tracks
103
+ .filter((track) => track.dismissedAt == null)
104
+ .sort((a, b) => a.startOffsetMs - b.startOffsetMs)
105
+ .map((track) => ({
106
+ id: track.clientTrackId,
107
+ startOffsetMs: track.startOffsetMs,
108
+ endOffsetMs: track.endOffsetMs,
109
+ points: track.points,
110
+ entryEdge: track.entryEdge,
111
+ exitEdge: track.exitEdge,
112
+ })),
113
+ };
114
+ }
115
+
116
+ function capitalise(value) {
117
+ return value === null || value === undefined ? '—' : value.charAt(0).toUpperCase() + value.slice(1);
118
+ }
119
+
120
+ function clockAt(ms) {
121
+ return new Date(ms).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
122
+ }
123
+
124
+ /** One row per track for the sightings table, dismissed rows included but marked. */
125
+ export function sightingRows(night, tracks) {
126
+ return [...tracks]
127
+ .sort((a, b) => a.startOffsetMs - b.startOffsetMs)
128
+ .map((track) => ({
129
+ clientTrackId: track.clientTrackId,
130
+ time: clockAt(night.startedAt + track.startOffsetMs),
131
+ durationSeconds: Math.round((track.endOffsetMs - track.startOffsetMs) / 100) / 10,
132
+ entered: capitalise(track.entryEdge),
133
+ exited: capitalise(track.exitEdge),
134
+ startCropSrc: track.startCrop ? `data:image/jpeg;base64,${track.startCrop}` : null,
135
+ endCropSrc: track.endCrop ? `data:image/jpeg;base64,${track.endCrop}` : null,
136
+ dismissed: track.dismissedAt != null,
137
+ }));
138
+ }
139
+
140
+ function topZoneLabel(zones) {
141
+ return zones && zones.length > 0 ? `${capitalise(zones[0].edge)} edge` : 'None';
142
+ }
143
+
144
+ export function statTiles(analytics) {
145
+ return {
146
+ trackCount: analytics?.track_count ?? 0,
147
+ topEntry: topZoneLabel(analytics?.entry_zones),
148
+ topExit: topZoneLabel(analytics?.exit_zones),
149
+ };
150
+ }
151
+
152
+ function dateRange(night) {
153
+ const format = (ms) => new Date(ms).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false });
154
+
155
+ return night.endedAt === null ? format(night.startedAt) : `${format(night.startedAt)} – ${format(night.endedAt)}`;
156
+ }
157
+
158
+ /** What the report header says about a night. */
159
+ export function reportHeader(night) {
160
+ return {
161
+ title: night.name,
162
+ range: dateRange(night),
163
+ discarded: night.status === 'aborted',
164
+ };
165
+ }
166
+
167
+ const STATUS_LABELS = { active: 'Interrupted', completed: 'Completed', aborted: 'Discarded' };
168
+
169
+ /** One row per stored night for the list on the watch page. */
170
+ export function nightRows(nights, { reportUrlTemplate }) {
171
+ return nights.map((night) => ({
172
+ id: night.id,
173
+ name: night.name,
174
+ started: dateRange({ ...night, endedAt: null }),
175
+ status: STATUS_LABELS[night.status] ?? night.status,
176
+ sightings: night.analytics?.track_count ?? 0,
177
+ reportUrl: reportUrlFor(reportUrlTemplate, night.id),
178
+ claimed: night.claimedSessionId !== null,
179
+ }));
180
+ }
181
+
182
+ /**
183
+ * A night still marked active when a page loads is one whose tab died
184
+ * overnight. Close it at the last heartbeat, as the server's stale-device
185
+ * warning would have the user do, so its report can be read.
186
+ */
187
+ export function finalizeInterruptedNight(night, tracks) {
188
+ if (night.status !== 'active') {
189
+ return null;
190
+ }
191
+
192
+ const endedAt = Math.max(night.startedAt, night.lastHeartbeatAt ?? night.startedAt);
193
+ const closed = { ...night, status: 'completed', endedAt };
194
+
195
+ return { status: 'completed', endedAt, analytics: nightAnalytics(closed, tracks) };
196
+ }
@@ -0,0 +1,114 @@
1
+ // The night sink for a guest: the same six calls capture.js makes on the
2
+ // Uploader, answered by the browser's own store instead of the server. Nothing
3
+ // here touches the network. The reference photo, every closed track and the
4
+ // end-of-night summary land in the night store, and the report URL handed back
5
+ // points at the local report page.
6
+ import {
7
+ buildLocalNight,
8
+ localTrackFromClosed,
9
+ nightAnalytics,
10
+ referenceBlobKey,
11
+ reportUrlFor,
12
+ } from './localNight.js';
13
+
14
+ export const HEARTBEAT_INTERVAL_MS = 60000;
15
+
16
+ export class LocalNightSink {
17
+ constructor({ store, reportUrlTemplate, onStatus }) {
18
+ this.store = store;
19
+ this.reportUrlTemplate = reportUrlTemplate;
20
+ this.onStatus = onStatus ?? (() => {});
21
+ this.nightId = null;
22
+ this.night = null;
23
+ this.pending = new Set();
24
+ this.retry = [];
25
+ this.timers = [];
26
+ }
27
+
28
+ /** Create the night and keep its reference photo. The night's clock starts here. */
29
+ async storeReference({ blob, frameWidth, frameHeight, settings }) {
30
+ this.nightId = crypto.randomUUID();
31
+ this.night = buildLocalNight({ id: this.nightId, startedAt: Date.now(), frameWidth, frameHeight, settings });
32
+
33
+ await this.store.putNight(this.night);
34
+ await this.store.putBlob({
35
+ key: referenceBlobKey(this.nightId),
36
+ nightId: this.nightId,
37
+ bytes: await blob.arrayBuffer(),
38
+ type: blob.type || 'image/jpeg',
39
+ });
40
+
41
+ // Ask the browser not to evict a night's worth of watching under storage
42
+ // pressure. Not every browser grants it, and there is nothing to do if not.
43
+ navigator.storage?.persist?.().catch(() => {});
44
+ }
45
+
46
+ /** A local heartbeat, so an interrupted night can be closed at the right time later. */
47
+ start() {
48
+ this.timers.push(setInterval(() => this.heartbeat(), HEARTBEAT_INTERVAL_MS));
49
+ }
50
+
51
+ stop() {
52
+ this.timers.forEach(clearInterval);
53
+ this.timers = [];
54
+ }
55
+
56
+ enqueue(track) {
57
+ this.write(localTrackFromClosed(track, this.nightId, this.night.frameWidth, this.night.frameHeight));
58
+ }
59
+
60
+ write(localTrack) {
61
+ const attempt = this.store
62
+ .putTrack(localTrack)
63
+ .catch((error) => {
64
+ // Kept for the next flush, as the uploader keeps a failed batch.
65
+ this.retry.push(localTrack);
66
+ this.onStatus({ queueDepth: this.pending.size + this.retry.length, lastError: String(error) });
67
+ })
68
+ .finally(() => {
69
+ this.pending.delete(attempt);
70
+ this.onStatus({ queueDepth: this.pending.size + this.retry.length });
71
+ });
72
+
73
+ this.pending.add(attempt);
74
+ this.onStatus({ queueDepth: this.pending.size + this.retry.length });
75
+ }
76
+
77
+ async flush() {
78
+ await Promise.allSettled([...this.pending]);
79
+
80
+ const leftovers = this.retry;
81
+ this.retry = [];
82
+ leftovers.forEach((localTrack) => this.write(localTrack));
83
+
84
+ if (leftovers.length > 0) {
85
+ await Promise.allSettled([...this.pending]);
86
+ }
87
+ }
88
+
89
+ async heartbeat() {
90
+ try {
91
+ await this.store.patchNight(this.nightId, { lastHeartbeatAt: Date.now() });
92
+ } catch {
93
+ // A missed heartbeat only shifts where an interrupted night is closed.
94
+ }
95
+ }
96
+
97
+ /** Close the night, summarise it, and say where its report is. */
98
+ async end({ endedAtOffsetMs, aborted }) {
99
+ await this.flush();
100
+
101
+ const endedAt = this.night.startedAt + endedAtOffsetMs;
102
+ const tracks = await this.store.listTracks(this.nightId);
103
+ const closed = { ...this.night, endedAt };
104
+
105
+ this.night = await this.store.patchNight(this.nightId, {
106
+ status: aborted ? 'aborted' : 'completed',
107
+ endedAt,
108
+ lastHeartbeatAt: endedAt,
109
+ analytics: nightAnalytics(closed, tracks),
110
+ });
111
+
112
+ return { ok: true, status: 200, reportUrl: reportUrlFor(this.reportUrlTemplate, this.nightId) };
113
+ }
114
+ }
@@ -0,0 +1,164 @@
1
+ // Where a guest's nights live: an IndexedDB database in this browser profile.
2
+ // Nothing here leaves the device. Three stores: nights, their tracks, and the
3
+ // one binary per night (the reference photo, as an ArrayBuffer; crops stay as
4
+ // base64 strings on the track, the same shape the server accepts, so claiming a
5
+ // night later re-sends them untouched).
6
+ //
7
+ // The interface is shared with InMemoryNightStore, which is both the fallback
8
+ // when IndexedDB is refused and the double the specs use. Keep the two in step.
9
+ import { InMemoryNightStore } from './nightStoreMemory.js';
10
+
11
+ export const STORE_NAME = 'bugtracker-local';
12
+ export const STORE_VERSION = 1;
13
+
14
+ /** Build the schema. Exported so a spec can run it against a fake factory. */
15
+ export function upgradeSchema(db) {
16
+ if (!db.objectStoreNames.contains('nights')) {
17
+ db.createObjectStore('nights', { keyPath: 'id' }).createIndex('byStartedAt', 'startedAt');
18
+ }
19
+
20
+ if (!db.objectStoreNames.contains('tracks')) {
21
+ db.createObjectStore('tracks', { keyPath: ['nightId', 'clientTrackId'] }).createIndex('byNight', 'nightId');
22
+ }
23
+
24
+ if (!db.objectStoreNames.contains('blobs')) {
25
+ db.createObjectStore('blobs', { keyPath: 'key' }).createIndex('byNight', 'nightId');
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Open the store, or fall back to memory when the browser has no IndexedDB or
31
+ * refuses to open it. The fallback is marked volatile so a page can warn that
32
+ * the night will not survive the tab.
33
+ */
34
+ export async function openNightStore({ indexedDB = globalThis.indexedDB } = {}) {
35
+ if (indexedDB === undefined || indexedDB === null) {
36
+ return new InMemoryNightStore();
37
+ }
38
+
39
+ try {
40
+ const db = await new Promise((resolve, reject) => {
41
+ const request = indexedDB.open(STORE_NAME, STORE_VERSION);
42
+ request.onupgradeneeded = () => upgradeSchema(request.result);
43
+ request.onsuccess = () => resolve(request.result);
44
+ request.onerror = () => reject(request.error);
45
+ request.onblocked = () => reject(new Error('IndexedDB open blocked'));
46
+ });
47
+
48
+ return new IndexedDbNightStore(db);
49
+ } catch {
50
+ return new InMemoryNightStore();
51
+ }
52
+ }
53
+
54
+ export class IndexedDbNightStore {
55
+ constructor(db) {
56
+ this.db = db;
57
+ this.volatile = false;
58
+ }
59
+
60
+ async putNight(night) {
61
+ await this.write('nights', (store) => store.put(night));
62
+ }
63
+
64
+ async getNight(id) {
65
+ return (await this.read('nights', (store) => store.get(id))) ?? null;
66
+ }
67
+
68
+ async patchNight(id, patch) {
69
+ return this.patch('nights', id, patch);
70
+ }
71
+
72
+ async listNights() {
73
+ const nights = await this.read('nights', (store) => store.getAll());
74
+
75
+ return nights.sort((a, b) => b.startedAt - a.startedAt);
76
+ }
77
+
78
+ /** Remove a night with its tracks and blobs in one transaction. */
79
+ async deleteNight(id) {
80
+ const transaction = this.db.transaction(['nights', 'tracks', 'blobs'], 'readwrite');
81
+
82
+ transaction.objectStore('nights').delete(id);
83
+ deleteByIndex(transaction.objectStore('tracks').index('byNight'), id);
84
+ deleteByIndex(transaction.objectStore('blobs').index('byNight'), id);
85
+
86
+ await settled(transaction);
87
+ }
88
+
89
+ async putTrack(track) {
90
+ await this.write('tracks', (store) => store.put(track));
91
+ }
92
+
93
+ async listTracks(nightId) {
94
+ const tracks = await this.read('tracks', (store) => store.index('byNight').getAll(nightId));
95
+
96
+ return tracks.sort((a, b) => a.startOffsetMs - b.startOffsetMs);
97
+ }
98
+
99
+ async patchTrack(nightId, clientTrackId, patch) {
100
+ return this.patch('tracks', [nightId, clientTrackId], patch);
101
+ }
102
+
103
+ async putBlob(blob) {
104
+ await this.write('blobs', (store) => store.put(blob));
105
+ }
106
+
107
+ async getBlob(key) {
108
+ return (await this.read('blobs', (store) => store.get(key))) ?? null;
109
+ }
110
+
111
+ async patch(storeName, key, patch) {
112
+ const transaction = this.db.transaction(storeName, 'readwrite');
113
+ const store = transaction.objectStore(storeName);
114
+ const existing = await request(store.get(key));
115
+
116
+ if (existing === undefined) {
117
+ return null;
118
+ }
119
+
120
+ const updated = { ...existing, ...patch };
121
+ store.put(updated);
122
+ await settled(transaction);
123
+
124
+ return updated;
125
+ }
126
+
127
+ read(storeName, operation) {
128
+ return request(operation(this.db.transaction(storeName, 'readonly').objectStore(storeName)));
129
+ }
130
+
131
+ async write(storeName, operation) {
132
+ const transaction = this.db.transaction(storeName, 'readwrite');
133
+ operation(transaction.objectStore(storeName));
134
+ await settled(transaction);
135
+ }
136
+ }
137
+
138
+ function request(idbRequest) {
139
+ return new Promise((resolve, reject) => {
140
+ idbRequest.onsuccess = () => resolve(idbRequest.result);
141
+ idbRequest.onerror = () => reject(idbRequest.error);
142
+ });
143
+ }
144
+
145
+ function settled(transaction) {
146
+ return new Promise((resolve, reject) => {
147
+ transaction.oncomplete = () => resolve();
148
+ transaction.onerror = () => reject(transaction.error);
149
+ transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted'));
150
+ });
151
+ }
152
+
153
+ function deleteByIndex(index, value) {
154
+ const cursorRequest = index.openKeyCursor(IDBKeyRange.only(value));
155
+
156
+ cursorRequest.onsuccess = () => {
157
+ const cursor = cursorRequest.result;
158
+
159
+ if (cursor) {
160
+ index.objectStore.delete(cursor.primaryKey);
161
+ cursor.continue();
162
+ }
163
+ };
164
+ }
@@ -0,0 +1,97 @@
1
+ // The night store held in plain Maps. Two jobs: the runtime fallback when a
2
+ // browser refuses IndexedDB (some private modes), where a night lives only as
3
+ // long as the tab; and the double every spec above the store runs against.
4
+ // Values are cloned on the way in and out so nothing can pass by aliasing.
5
+
6
+ export class InMemoryNightStore {
7
+ constructor() {
8
+ this.nights = new Map();
9
+ this.tracks = new Map();
10
+ this.blobs = new Map();
11
+ this.volatile = true;
12
+ }
13
+
14
+ async putNight(night) {
15
+ this.nights.set(night.id, structuredClone(night));
16
+ }
17
+
18
+ async getNight(id) {
19
+ const night = this.nights.get(id);
20
+
21
+ return night === undefined ? null : structuredClone(night);
22
+ }
23
+
24
+ async patchNight(id, patch) {
25
+ const night = this.nights.get(id);
26
+
27
+ if (night === undefined) {
28
+ return null;
29
+ }
30
+
31
+ const updated = { ...night, ...structuredClone(patch) };
32
+ this.nights.set(id, updated);
33
+
34
+ return structuredClone(updated);
35
+ }
36
+
37
+ async listNights() {
38
+ return [...this.nights.values()]
39
+ .sort((a, b) => b.startedAt - a.startedAt)
40
+ .map((night) => structuredClone(night));
41
+ }
42
+
43
+ async deleteNight(id) {
44
+ this.nights.delete(id);
45
+
46
+ for (const [key, track] of this.tracks) {
47
+ if (track.nightId === id) {
48
+ this.tracks.delete(key);
49
+ }
50
+ }
51
+
52
+ for (const [key, blob] of this.blobs) {
53
+ if (blob.nightId === id) {
54
+ this.blobs.delete(key);
55
+ }
56
+ }
57
+ }
58
+
59
+ async putTrack(track) {
60
+ this.tracks.set(trackKey(track.nightId, track.clientTrackId), structuredClone(track));
61
+ }
62
+
63
+ async listTracks(nightId) {
64
+ return [...this.tracks.values()]
65
+ .filter((track) => track.nightId === nightId)
66
+ .sort((a, b) => a.startOffsetMs - b.startOffsetMs)
67
+ .map((track) => structuredClone(track));
68
+ }
69
+
70
+ async patchTrack(nightId, clientTrackId, patch) {
71
+ const key = trackKey(nightId, clientTrackId);
72
+ const track = this.tracks.get(key);
73
+
74
+ if (track === undefined) {
75
+ return null;
76
+ }
77
+
78
+ const updated = { ...track, ...structuredClone(patch) };
79
+ this.tracks.set(key, updated);
80
+
81
+ return structuredClone(updated);
82
+ }
83
+
84
+ async putBlob(blob) {
85
+ this.blobs.set(blob.key, structuredClone(blob));
86
+ }
87
+
88
+ async getBlob(key) {
89
+ const blob = this.blobs.get(key);
90
+
91
+ return blob === undefined ? null : structuredClone(blob);
92
+ }
93
+ }
94
+
95
+ function trackKey(nightId, clientTrackId) {
96
+ return `${nightId} ${clientTrackId}`;
97
+ }