@freqhole/api-client 0.1.30 → 0.1.32

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/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # @freqhole/api-client
2
+
3
+ type-safe TypeScript client for freqhole APIs.
4
+
5
+ this package wraps generated route + schema metadata with a small runtime client so requests are validated and typed on both input and output.
6
+
7
+ ## install
8
+
9
+ ```bash
10
+ npm i @freqhole/api-client
11
+ ```
12
+
13
+ for local monorepo usage, this repo usually links it by file path.
14
+
15
+ ## quick start
16
+
17
+ ```ts
18
+ import { createHttpClient } from "@freqhole/api-client";
19
+
20
+ const api = createHttpClient({
21
+ baseUrl: "http://localhost:4315",
22
+ });
23
+
24
+ const songs = await api.music.listSongs({
25
+ query: { limit: 25, offset: 0 },
26
+ });
27
+
28
+ if (songs.success) {
29
+ console.log(songs.data.songs.length);
30
+ }
31
+ ```
32
+
33
+ ## package structure
34
+
35
+ - `src/codegen/schema.ts`: generated zod schemas
36
+ - `src/codegen/routes.ts`: generated route metadata
37
+ - `src/*.ts`: hand-written helpers and client adapters
38
+
39
+ avoid editing files under `src/codegen/` directly.
40
+
41
+ ## development notes
42
+
43
+ if you are working in this repo and need to regenerate types/routes, run codegen from `client-codegen/`.
44
+
45
+ the full generator workflow and architecture docs live in `client-codegen/README.md`.
46
+
47
+ ---
48
+
49
+ made with 💖 in NYC
package/package.json CHANGED
@@ -1,7 +1,11 @@
1
1
  {
2
2
  "name": "@freqhole/api-client",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/freqhole/tomb"
8
+ },
5
9
  "main": "./src/index.ts",
6
10
  "publishConfig": {
7
11
  "access": "public"
@@ -9,7 +13,6 @@
9
13
  "exports": {
10
14
  ".": "./src/index.ts",
11
15
  "./storage": "./src/storage/index.ts",
12
- "./playlistz": "./src/playlistz/index.ts",
13
16
  "./automerge": "./src/automerge/index.ts"
14
17
  },
15
18
  "scripts": {
@@ -77,6 +77,8 @@ export interface BiStreamLike {
77
77
  */
78
78
  export interface MiddenStreamNode {
79
79
  node_id(): string;
80
+ /** full serialized endpoint addr (node id + relay url) for deterministic dialing. */
81
+ node_addr?(): string;
80
82
  open_bi(peer_addr: string, alpn: string): Promise<BiStreamLike>;
81
83
  accept(): Promise<BiStreamLike | null>;
82
84
  }
@@ -153,6 +155,14 @@ export class IrohNetworkAdapter extends NetworkAdapter {
153
155
  { attempt: number; timer: ReturnType<typeof setTimeout> | null }
154
156
  >();
155
157
 
158
+ /**
159
+ * optional per-peer dial address hint keyed by node id. when present the
160
+ * adapter dials this full endpoint addr instead of the bare node id, which
161
+ * skips discovery (pkarr/dns) lookup. empty in normal operation; populated
162
+ * by callers that already know a peer's reachable addr.
163
+ */
164
+ private peerDialAddr = new Map<string, string>();
165
+
156
166
  /** listeners for connection state changes. */
157
167
  private connectionStateListeners: Array<() => void> = [];
158
168
 
@@ -248,6 +258,7 @@ export class IrohNetworkAdapter extends NetworkAdapter {
248
258
  this.readLoops.clear();
249
259
  this.intendedPeers.clear();
250
260
  this.failedPeers.clear();
261
+ this.peerDialAddr.clear();
251
262
  this.alpnHandlers.clear();
252
263
 
253
264
  // cancel all pending reconnection timers
@@ -278,7 +289,7 @@ export class IrohNetworkAdapter extends NetworkAdapter {
278
289
  * ALPN, starts a read loop, and emits a peer-candidate event so
279
290
  * automerge-repo begins syncing with this peer.
280
291
  */
281
- async addPeer(nodeId: string): Promise<void> {
292
+ async addPeer(nodeId: string, dialAddr?: string): Promise<void> {
282
293
  if (this._disconnected) {
283
294
  throw new Error("adapter is disconnected");
284
295
  }
@@ -286,6 +297,11 @@ export class IrohNetworkAdapter extends NetworkAdapter {
286
297
  // track this peer as one we intend to stay connected to
287
298
  this.intendedPeers.add(nodeId);
288
299
  this.failedPeers.delete(nodeId);
300
+ // remember an explicit dial addr hint so dials (and reconnects) can skip
301
+ // discovery. keyed by node id; everything else stays keyed by node id too.
302
+ if (dialAddr) {
303
+ this.peerDialAddr.set(nodeId, dialAddr);
304
+ }
289
305
  this.emitConnectionStateChange();
290
306
 
291
307
  // clear any pending reconnection state - this is a fresh attempt
@@ -298,9 +314,22 @@ export class IrohNetworkAdapter extends NetworkAdapter {
298
314
  }
299
315
 
300
316
  const midden = await this.ensureMidden();
301
- const stream = await midden.open_bi(nodeId, this.syncAlpn);
302
-
303
- this.registerStream(nodeId, stream);
317
+ const target = this.peerDialAddr.get(nodeId) ?? nodeId;
318
+ try {
319
+ const stream = await midden.open_bi(target, this.syncAlpn);
320
+ this.registerStream(nodeId, stream);
321
+ } catch (err) {
322
+ // the initial dial can fail when the peer is not yet discoverable. hand
323
+ // off to the background reconnect loop with backoff instead of forcing
324
+ // every caller to implement its own retry.
325
+ console.warn(
326
+ TAG,
327
+ "initial dial failed, scheduling reconnect:",
328
+ nodeId.slice(0, 16) + "...",
329
+ err
330
+ );
331
+ this.scheduleReconnect(nodeId);
332
+ }
304
333
  }
305
334
 
306
335
  /**
@@ -313,6 +342,7 @@ export class IrohNetworkAdapter extends NetworkAdapter {
313
342
  // remove from intended set first so removePeer() won't schedule a reconnect
314
343
  this.intendedPeers.delete(nodeId);
315
344
  this.failedPeers.delete(nodeId);
345
+ this.peerDialAddr.delete(nodeId);
316
346
  this.clearReconnectState(nodeId);
317
347
  // delegate stream cleanup and peer-disconnected emission to removePeer
318
348
  this.removePeer(nodeId);
@@ -643,6 +673,7 @@ export class IrohNetworkAdapter extends NetworkAdapter {
643
673
  );
644
674
  this.intendedPeers.delete(peerId);
645
675
  this.failedPeers.add(peerId);
676
+ this.peerDialAddr.delete(peerId);
646
677
  this.clearReconnectState(peerId);
647
678
  this.emitConnectionStateChange();
648
679
  return;
@@ -696,7 +727,8 @@ export class IrohNetworkAdapter extends NetworkAdapter {
696
727
 
697
728
  try {
698
729
  const midden = await this.ensureMidden();
699
- const stream = await midden.open_bi(peerId, this.syncAlpn);
730
+ const target = this.peerDialAddr.get(peerId) ?? peerId;
731
+ const stream = await midden.open_bi(target, this.syncAlpn);
700
732
  console.log(TAG, "reconnected to peer:", peerId.slice(0, 16) + "...");
701
733
  this.registerStream(peerId, stream);
702
734
  // registerStream calls clearReconnectState, so no need to do it here
@@ -93,5 +93,28 @@ export function createAdminMethods(call: CallFn) {
93
93
  routes.admin.get_knock_status_public.path,
94
94
  );
95
95
  },
96
+
97
+ // internal service callbacks
98
+ deviceLinkedCallback: (params: s.DeviceLinkedCallbackRequest) => {
99
+ return call(
100
+ "admin", "device_linked_callback",
101
+ routes.admin.device_linked_callback.resp,
102
+ routes.admin.device_linked_callback.req,
103
+ routes.admin.device_linked_callback.method,
104
+ routes.admin.device_linked_callback.path,
105
+ params,
106
+ );
107
+ },
108
+
109
+ knockAcceptedCallback: (params: s.KnockAcceptedCallbackRequest) => {
110
+ return call(
111
+ "admin", "knock_accepted_callback",
112
+ routes.admin.knock_accepted_callback.resp,
113
+ routes.admin.knock_accepted_callback.req,
114
+ routes.admin.knock_accepted_callback.method,
115
+ routes.admin.knock_accepted_callback.path,
116
+ params,
117
+ );
118
+ },
96
119
  };
97
120
  }
package/src/test.ts CHANGED
@@ -2,7 +2,6 @@
2
2
  import { validateWrapperCoverage } from "./test/coverage.js";
3
3
  import { runIntegrationTests } from "./test/integration.js";
4
4
  import { runStatefulTests } from "./test/stateful.js";
5
- import { runPlaylistzTests } from "./test/playlistz.js";
6
5
  import { runAutomergeTests } from "./test/automerge.js";
7
6
  import { runStorageTests } from "./test/storage.js";
8
7
 
@@ -22,14 +21,6 @@ async function main() {
22
21
  console.log("=".repeat(50));
23
22
  console.log("");
24
23
 
25
- // always run playlistz domain tests (pure - no server needed)
26
- const playlistzResults = await runPlaylistzTests();
27
- totalPassed += playlistzResults.passed;
28
- totalFailed += playlistzResults.failed;
29
-
30
- console.log("=".repeat(50));
31
- console.log("");
32
-
33
24
  // automerge adapter unit tests (no server required)
34
25
  const automergeResults = await runAutomergeTests();
35
26
  totalPassed += automergeResults.passed;
@@ -1,267 +0,0 @@
1
- // converters between external record shapes and PlaylistDoc.
2
- // all converters are total: missing optional source fields become schema defaults.
3
- // never throw on extra fields.
4
- import type { Playlist as FreqholePlaylist, Song as FreqholeSong } from "../codegen/schema.js";
5
- import {
6
- emptyPlaylistDoc,
7
- type ImageRef,
8
- type PlaylistDoc,
9
- type SongEntry,
10
- type EntityUrl,
11
- } from "./schema.js";
12
-
13
- // ---- shared helpers ----
14
-
15
- function tsToIso(ts: number): string {
16
- return new Date(ts).toISOString();
17
- }
18
-
19
- function mapEntityUrls(
20
- urls?: Array<{ id?: string | null; name?: string | null; url: string }> | null,
21
- ): EntityUrl[] {
22
- if (!urls) return [];
23
- return urls.map((u) => ({
24
- url: u.url,
25
- ...(u.id != null ? { id: u.id } : {}),
26
- ...(u.name != null ? { name: u.name } : {}),
27
- }));
28
- }
29
-
30
- // ---- legacy playlistz types ----
31
- // (removed: playlistz has no deployed users, so there is no legacy idb data
32
- // to convert - new playlists are created directly as docs)
33
-
34
- // ---- spume idb types (inline - do not import from spume source) ----
35
- // field shapes from tomb/client/spume/src/music/services/storage/types.ts
36
-
37
- interface SpumeImageMetadata {
38
- local_blob_id?: string;
39
- remote_blob_id?: string;
40
- is_primary: boolean;
41
- blob_type: "thumbnail" | "waveform" | "original" | "preview";
42
- }
43
-
44
- interface SpumePlaylist {
45
- playlist_id: string;
46
- title: string;
47
- description?: string | null;
48
- is_public?: boolean;
49
- images?: SpumeImageMetadata[];
50
- urls?: Array<{ id?: string; name?: string; url: string }>;
51
- created_at?: number;
52
- updated_at?: number;
53
- }
54
-
55
- interface SpumeSong {
56
- id: string;
57
- sha256: string;
58
- title: string;
59
- artist_name?: string;
60
- album_title?: string;
61
- duration_seconds?: number;
62
- mime_type?: string | null;
63
- file_size?: number | null;
64
- blake3?: string | null;
65
- lyrics?: string | null;
66
- images?: SpumeImageMetadata[];
67
- urls?: Array<{ id?: string; name?: string; url: string }>;
68
- }
69
-
70
- // map SpumeImageMetadata -> ImageRef, skipping entries with no blob id
71
- function mapSpumeImages(images?: SpumeImageMetadata[]): ImageRef[] {
72
- if (!images) return [];
73
- const result: ImageRef[] = [];
74
- for (const img of images) {
75
- const blobId = img.local_blob_id ?? img.remote_blob_id;
76
- if (!blobId) continue;
77
- result.push({
78
- blobId,
79
- isPrimary: img.is_primary,
80
- blobType: img.blob_type,
81
- });
82
- }
83
- return result;
84
- }
85
-
86
- // map freqhole wire image (is_primary as 0/1 number) -> ImageRef
87
- function mapFreqholeImages(
88
- images?: Array<{
89
- blob_id: string;
90
- is_primary: number;
91
- blob_type: "original" | "thumbnail" | "waveform" | "preview";
92
- }> | null,
93
- ): ImageRef[] {
94
- if (!images) return [];
95
- return images.map((img) => ({
96
- blobId: img.blob_id,
97
- isPrimary: img.is_primary !== 0,
98
- blobType: img.blob_type,
99
- }));
100
- }
101
-
102
- // ---- converters ----
103
-
104
- // convert a freqhole wire Playlist + Song[] to a PlaylistDoc.
105
- // maps snake_case fields and is_primary number (0/1) -> boolean.
106
- // note: the freqhole Song wire type does not carry album_title; that field defaults to "".
107
- // media_blob_id is used as sha256 (it is the server's short blob id, not a true content hash).
108
- export function freqholePlaylistToDoc(
109
- playlist: FreqholePlaylist,
110
- songs: FreqholeSong[],
111
- ): PlaylistDoc {
112
- const createdAt = tsToIso(playlist.created_at);
113
- const lastModified = tsToIso(playlist.updated_at);
114
-
115
- const songEntries: Record<string, SongEntry> = {};
116
- const order: string[] = [];
117
-
118
- for (const song of songs) {
119
- const entry: SongEntry = {
120
- id: song.id,
121
- title: song.title ?? "",
122
- artist: song.track_artist ?? "",
123
- album: "",
124
- duration: song.duration ?? 0,
125
- mimeType: "",
126
- fileSize: 0,
127
- sha256: song.media_blob_id,
128
- images: mapFreqholeImages(song.images),
129
- urls: mapEntityUrls(song.urls),
130
- ...(song.lyrics != null ? { lyrics: song.lyrics } : {}),
131
- };
132
- songEntries[song.id] = entry;
133
- order.push(song.id);
134
- }
135
-
136
- return emptyPlaylistDoc({
137
- title: playlist.title ?? "",
138
- description: playlist.description ?? "",
139
- createdAt,
140
- lastModified,
141
- images: mapFreqholeImages(playlist.images),
142
- urls: mapEntityUrls(playlist.urls),
143
- songs: songEntries,
144
- order,
145
- });
146
- }
147
-
148
- // convert a spume idb Playlist + Song[] to a PlaylistDoc.
149
- // maps spume's ImageMetadata (local_blob_id/remote_blob_id) to ImageRef.
150
- // spume Song fields: sha256, artist_name, album_title, duration_seconds, mime_type, file_size, blake3.
151
- export function spumePlaylistToDoc(
152
- playlist: SpumePlaylist,
153
- songs: SpumeSong[],
154
- ): PlaylistDoc {
155
- const now = new Date().toISOString();
156
- const createdAt = playlist.created_at ? tsToIso(playlist.created_at) : now;
157
- const lastModified = playlist.updated_at ? tsToIso(playlist.updated_at) : now;
158
-
159
- const songEntries: Record<string, SongEntry> = {};
160
- const order: string[] = [];
161
-
162
- for (const song of songs) {
163
- const entry: SongEntry = {
164
- id: song.id,
165
- title: song.title ?? "",
166
- artist: song.artist_name ?? "",
167
- album: song.album_title ?? "",
168
- duration: song.duration_seconds ?? 0,
169
- mimeType: song.mime_type ?? "",
170
- fileSize: song.file_size ?? 0,
171
- sha256: song.sha256,
172
- images: mapSpumeImages(song.images),
173
- urls: mapEntityUrls(song.urls),
174
- ...(song.blake3 != null ? { blake3: song.blake3 } : {}),
175
- ...(song.lyrics != null ? { lyrics: song.lyrics } : {}),
176
- };
177
- songEntries[song.id] = entry;
178
- order.push(song.id);
179
- }
180
-
181
- return emptyPlaylistDoc({
182
- title: playlist.title ?? "",
183
- description: playlist.description ?? "",
184
- createdAt,
185
- lastModified,
186
- images: mapSpumeImages(playlist.images),
187
- urls: mapEntityUrls(playlist.urls),
188
- songs: songEntries,
189
- order,
190
- });
191
- }
192
-
193
- // convert a PlaylistDoc back to the freqhole wire format where lossless.
194
- // id is not stored in the doc - defaults to "". caller must supply the real
195
- // playlist id if needed (e.g. from the docIndex entry).
196
- // note: album_title, mime_type, file_size are not in the freqhole Song wire type
197
- // and are dropped. sha256 is mapped back to media_blob_id.
198
- export function docToFreqholePlaylist(
199
- doc: PlaylistDoc,
200
- ): { playlist: FreqholePlaylist; songs: FreqholeSong[] } {
201
- const createdAt = doc.createdAt ? new Date(doc.createdAt).getTime() : 0;
202
- const updatedAt = doc.lastModified ? new Date(doc.lastModified).getTime() : 0;
203
-
204
- const playlist: FreqholePlaylist = {
205
- id: "",
206
- title: doc.title,
207
- description: doc.description || null,
208
- is_public: 0,
209
- images: doc.images.map((img) => ({
210
- blob_id: img.blobId,
211
- is_primary: img.isPrimary ? 1 : 0,
212
- blob_type: img.blobType,
213
- })),
214
- urls: doc.urls.map((u) => ({
215
- url: u.url,
216
- id: u.id ?? null,
217
- name: u.name ?? null,
218
- })),
219
- created_by_id: null,
220
- created_at: createdAt,
221
- updated_at: updatedAt,
222
- deleted_at: null,
223
- deleted_by: null,
224
- created_by: null,
225
- updated_by: null,
226
- song_count: doc.order.length,
227
- };
228
-
229
- const songs: FreqholeSong[] = doc.order
230
- .map((id) => doc.songs[id])
231
- .filter((s): s is SongEntry => s != null)
232
- .map((s) => {
233
- return {
234
- id: s.id,
235
- media_blob_id: s.sha256,
236
- title: s.title,
237
- track_number: 1,
238
- disc_number: 1,
239
- duration: s.duration,
240
- track_artist: s.artist || null,
241
- images: s.images.map((img) => ({
242
- blob_id: img.blobId,
243
- is_primary: img.isPrimary ? 1 : 0,
244
- blob_type: img.blobType,
245
- })),
246
- urls: s.urls.map((u) => ({
247
- url: u.url,
248
- id: u.id ?? null,
249
- name: u.name ?? null,
250
- })),
251
- metadata: null,
252
- lyrics: s.lyrics ?? null,
253
- created_at: createdAt,
254
- updated_at: updatedAt,
255
- deleted_at: null,
256
- deleted_by: null,
257
- created_by: null,
258
- updated_by: null,
259
- created_by_username: null,
260
- updated_by_username: null,
261
- bpm: null,
262
- play_count: null,
263
- };
264
- });
265
-
266
- return { playlist, songs };
267
- }
@@ -1,6 +0,0 @@
1
- // public exports for the playlistz domain package
2
- export * from "./schema.js";
3
- export * from "./mutations.js";
4
- export * from "./convert.js";
5
- export * from "./shareLink.js";
6
- export * from "./protocol.js";
@@ -1,181 +0,0 @@
1
- // pure mutation helpers for PlaylistDoc.
2
- // each function mutates the doc draft in place - safe to call inside handle.change().
3
- // no automerge imports: these are plain object mutations over plain objects.
4
- import type { PlaylistDoc, SongEntry, ImageRef } from "./schema.js";
5
-
6
- function nowIso(): string {
7
- return new Date().toISOString();
8
- }
9
-
10
- // bump lastModified to now, optionally setting lastModifiedBy
11
- function bumpModified(doc: PlaylistDoc, by?: string): void {
12
- doc.lastModified = nowIso();
13
- if (by !== undefined) {
14
- doc.lastModifiedBy = by;
15
- }
16
- }
17
-
18
- // insert or replace a song in the doc, appending its id to order if not already present
19
- export function upsertSong(doc: PlaylistDoc, song: SongEntry, by?: string): void {
20
- doc.songs[song.id] = song;
21
- if (!doc.order.includes(song.id)) {
22
- doc.order.push(song.id);
23
- }
24
- bumpModified(doc, by);
25
- }
26
-
27
- // remove a song from songs and order
28
- export function removeSong(doc: PlaylistDoc, songId: string, by?: string): void {
29
- delete doc.songs[songId];
30
- const idx = doc.order.indexOf(songId);
31
- if (idx !== -1) {
32
- doc.order.splice(idx, 1);
33
- }
34
- bumpModified(doc, by);
35
- }
36
-
37
- // move a single song to a target index within order.
38
- // single-item splice keeps automerge merge semantics sane.
39
- export function reorderSongs(
40
- doc: PlaylistDoc,
41
- songId: string,
42
- toIndex: number,
43
- by?: string,
44
- ): void {
45
- const fromIdx = doc.order.indexOf(songId);
46
- if (fromIdx === -1) return;
47
- doc.order.splice(fromIdx, 1);
48
- const clamped = Math.max(0, Math.min(toIndex, doc.order.length));
49
- doc.order.splice(clamped, 0, songId);
50
- bumpModified(doc, by);
51
- }
52
-
53
- // playlist metadata fields settable via setMetadata
54
- type MetadataFields = Partial<
55
- Pick<
56
- PlaylistDoc,
57
- | "title"
58
- | "description"
59
- | "bgFilterEnabled"
60
- | "bgFilterBlur"
61
- | "bgFilterContrast"
62
- | "bgFilterBrightness"
63
- | "coverFilterEnabled"
64
- | "coverFilterBlur"
65
- | "bgSize"
66
- | "bgPosition"
67
- | "bgRepeat"
68
- >
69
- >;
70
-
71
- // update playlist-level metadata fields
72
- export function setMetadata(
73
- doc: PlaylistDoc,
74
- fields: MetadataFields,
75
- by?: string,
76
- ): void {
77
- if (fields.title !== undefined) doc.title = fields.title;
78
- if (fields.description !== undefined) doc.description = fields.description;
79
- for (const key of [
80
- "bgFilterEnabled",
81
- "bgFilterBlur",
82
- "bgFilterContrast",
83
- "bgFilterBrightness",
84
- "coverFilterEnabled",
85
- "coverFilterBlur",
86
- "bgSize",
87
- "bgPosition",
88
- "bgRepeat",
89
- ] as const) {
90
- if (fields[key] !== undefined) {
91
- // assignment is type-safe per key; typescript can't narrow the loop var
92
- (doc as Record<string, unknown>)[key] = fields[key];
93
- }
94
- }
95
- bumpModified(doc, by);
96
- }
97
-
98
- // add an image ref to the playlist or to a specific song.
99
- // if ref.isPrimary is true, clears isPrimary on all sibling images first.
100
- export function addImage(
101
- doc: PlaylistDoc,
102
- ref: ImageRef,
103
- target?: { songId?: string },
104
- by?: string,
105
- ): void {
106
- const images =
107
- target?.songId != null
108
- ? doc.songs[target.songId]?.images
109
- : doc.images;
110
-
111
- if (!images) return;
112
-
113
- if (ref.isPrimary) {
114
- for (const img of images) {
115
- img.isPrimary = false;
116
- }
117
- }
118
- images.push(ref);
119
- bumpModified(doc, by);
120
- }
121
-
122
- // set the primary image by blobId, clearing isPrimary on all others.
123
- // operates on playlist images or a specific song's images.
124
- export function setPrimaryImage(
125
- doc: PlaylistDoc,
126
- blobId: string,
127
- target?: { songId?: string },
128
- by?: string,
129
- ): void {
130
- const images =
131
- target?.songId != null
132
- ? doc.songs[target.songId]?.images
133
- : doc.images;
134
-
135
- if (!images) return;
136
-
137
- for (const img of images) {
138
- img.isPrimary = img.blobId === blobId;
139
- }
140
- bumpModified(doc, by);
141
- }
142
-
143
- // record a peer joining this doc. idempotent - no-op if peer is already present.
144
- // does not bump lastModified (peer bookkeeping is not a content edit).
145
- export function addPeer(doc: PlaylistDoc, nodeId: string): void {
146
- if (doc.peers[nodeId]) return;
147
- doc.peers[nodeId] = {
148
- nodeId,
149
- joinedAt: nowIso(),
150
- };
151
- }
152
-
153
- // update lastSeenAt for an existing peer entry.
154
- // does not bump lastModified (ephemeral presence, not a content edit).
155
- export function stampLastSeen(doc: PlaylistDoc, nodeId: string): void {
156
- const peer = doc.peers[nodeId];
157
- if (peer) {
158
- peer.lastSeenAt = nowIso();
159
- }
160
- }
161
-
162
- // set or update the access role for a node in the acl
163
- export function setAclRole(
164
- doc: PlaylistDoc,
165
- nodeId: string,
166
- role: "owner" | "editor" | "viewer",
167
- by?: string,
168
- ): void {
169
- if (!doc.acl) {
170
- doc.acl = {};
171
- }
172
- doc.acl[nodeId] = { role };
173
- bumpModified(doc, by);
174
- }
175
-
176
- // mark this playlist as deleted (tombstone).
177
- // the doc remains in the repo; consumers check deleted before rendering.
178
- export function tombstone(doc: PlaylistDoc, by?: string): void {
179
- doc.deleted = true;
180
- bumpModified(doc, by);
181
- }