@rocksky/sdk 0.3.0 → 0.5.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 (51) hide show
  1. package/README.md +71 -245
  2. package/dist/agent.d.ts +82 -0
  3. package/dist/agent.d.ts.map +1 -0
  4. package/dist/client.d.ts +165 -111
  5. package/dist/client.d.ts.map +1 -1
  6. package/dist/dedup.d.ts +38 -0
  7. package/dist/dedup.d.ts.map +1 -0
  8. package/dist/errors.d.ts +3 -23
  9. package/dist/errors.d.ts.map +1 -1
  10. package/dist/generated/types.d.ts +106 -8
  11. package/dist/generated/types.d.ts.map +1 -1
  12. package/dist/hash.d.ts +7 -0
  13. package/dist/hash.d.ts.map +1 -0
  14. package/dist/index.d.ts +17 -17
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +6956 -1267
  17. package/dist/jetstream.d.ts +17 -0
  18. package/dist/jetstream.d.ts.map +1 -0
  19. package/package.json +15 -11
  20. package/src/agent.ts +278 -0
  21. package/src/client.ts +393 -212
  22. package/src/dedup.ts +207 -0
  23. package/src/errors.ts +6 -43
  24. package/src/generated/types.ts +115 -8
  25. package/src/hash.ts +22 -0
  26. package/src/index.ts +17 -86
  27. package/src/jetstream.ts +122 -0
  28. package/src/http.ts +0 -195
  29. package/src/namespaces/_helpers.ts +0 -27
  30. package/src/namespaces/actor.ts +0 -93
  31. package/src/namespaces/album.ts +0 -34
  32. package/src/namespaces/apikey.ts +0 -50
  33. package/src/namespaces/artist.ts +0 -68
  34. package/src/namespaces/charts.ts +0 -38
  35. package/src/namespaces/dropbox.ts +0 -53
  36. package/src/namespaces/feed.ts +0 -112
  37. package/src/namespaces/googledrive.ts +0 -41
  38. package/src/namespaces/graph.ts +0 -62
  39. package/src/namespaces/like.ts +0 -46
  40. package/src/namespaces/mirror.ts +0 -27
  41. package/src/namespaces/player.ts +0 -125
  42. package/src/namespaces/playlist.ts +0 -95
  43. package/src/namespaces/scrobble.ts +0 -41
  44. package/src/namespaces/shout.ts +0 -99
  45. package/src/namespaces/song.ts +0 -60
  46. package/src/namespaces/spotify.ts +0 -56
  47. package/src/namespaces/stats.ts +0 -27
  48. package/src/paginate.ts +0 -90
  49. package/src/pipe.ts +0 -146
  50. package/src/realtime.ts +0 -408
  51. package/src/types.ts +0 -41
@@ -0,0 +1,17 @@
1
+ import type { RockskyIndex } from "./dedup.js";
2
+ /** The four public Bluesky Jetstream servers. */
3
+ export declare const DEFAULT_JETSTREAM_SERVERS: string[];
4
+ export interface JetstreamOptions {
5
+ /** Servers to connect to at once (defaults to {@link DEFAULT_JETSTREAM_SERVERS}). */
6
+ servers?: string[];
7
+ /** Cancels the hydration and closes all connections when aborted. */
8
+ signal?: AbortSignal;
9
+ }
10
+ /**
11
+ * Hydrate `idx` from the Bluesky Jetstream firehose for `did`, connecting to
12
+ * every server at once, filtered to app.rocksky.* + this DID. A shared watermark
13
+ * de-duplicates the overlap between servers and is the reconnect cursor. Resolves
14
+ * when opts.signal aborts; each source reconnects with backoff.
15
+ */
16
+ export declare function runJetstream(idx: RockskyIndex, did: string, opts?: JetstreamOptions): Promise<void>;
17
+ //# sourceMappingURL=jetstream.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jetstream.d.ts","sourceRoot":"","sources":["../src/jetstream.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE/C,iDAAiD;AACjD,eAAO,MAAM,yBAAyB,UAKrC,CAAC;AAIF,MAAM,WAAW,gBAAgB;IAC/B,qFAAqF;IACrF,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,qEAAqE;IACrE,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAmBD;;;;;GAKG;AACH,wBAAsB,YAAY,CAAC,GAAG,EAAE,YAAY,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,GAAE,gBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAI7G"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rocksky/sdk",
3
- "version": "0.3.0",
4
- "description": "TypeScript SDK for the Rocksky XRPC API",
3
+ "version": "0.5.0",
4
+ "description": "TypeScript SDK for Rocksky built on atcute: AppView reads, AT Protocol PDS writes (scrobble, like, follow, shout), a local dedup index, and Jetstream real-time sync.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -31,16 +31,9 @@
31
31
  "scripts": {
32
32
  "build": "bun build ./src/index.ts --outdir ./dist --target node --format esm --external none && bun run build:types",
33
33
  "build:types": "tsc -p tsconfig.build.json",
34
- "test": "bun test",
35
- "test:watch": "bun test --watch",
36
34
  "typecheck": "tsc --noEmit",
37
- "example:quickstart": "bun run examples/quickstart.ts",
38
- "example:builder": "bun run examples/builder.ts",
39
- "example:pipe": "bun run examples/pipe.ts",
40
- "example:scrobble": "bun run examples/scrobble.ts",
41
- "example:pagination": "bun run examples/pagination.ts",
42
- "example:paginate": "bun run examples/paginate.ts",
43
- "example:realtime": "bun run examples/realtime.ts"
35
+ "test": "bun test",
36
+ "example": "bun run examples/native.ts"
44
37
  },
45
38
  "keywords": [
46
39
  "rocksky",
@@ -59,7 +52,18 @@
59
52
  "api"
60
53
  ],
61
54
  "devDependencies": {
55
+ "@atcute/atproto": "^4.0.3",
62
56
  "@types/bun": "latest",
63
57
  "typescript": "^5.6.0"
58
+ },
59
+ "dependencies": {
60
+ "@atcute/car": "^6.0.2",
61
+ "@atcute/cbor": "^2.3.6",
62
+ "@atcute/cid": "^2.4.2",
63
+ "@atcute/client": "^5.1.1",
64
+ "@atcute/identity": "^2.0.2",
65
+ "@atcute/identity-resolver": "^2.0.1",
66
+ "@atcute/password-session": "^1.0.2",
67
+ "classic-level": "^3.0.0"
64
68
  }
65
69
  }
package/src/agent.ts ADDED
@@ -0,0 +1,278 @@
1
+ import { Client } from "@atcute/client";
2
+ import {
3
+ CompositeDidDocumentResolver,
4
+ CompositeHandleResolver,
5
+ DohJsonHandleResolver,
6
+ LocalActorResolver,
7
+ PlcDidDocumentResolver,
8
+ WebDidDocumentResolver,
9
+ WellKnownHandleResolver,
10
+ } from "@atcute/identity-resolver";
11
+ import { PasswordSession } from "@atcute/password-session";
12
+
13
+ import { RockskyError } from "./errors.js";
14
+ import type {
15
+ AlbumRecord,
16
+ ArtistRecord,
17
+ ActorTrackView,
18
+ ScrobbleRecord,
19
+ SongRecord,
20
+ } from "./generated/types.js";
21
+ import type { IndexStats, RockskyIndex } from "./dedup.js";
22
+ import { runJetstream, type JetstreamOptions } from "./jetstream.js";
23
+
24
+ // Collection NSIDs written by the agent.
25
+ const C_SCROBBLE = "app.rocksky.scrobble";
26
+ const C_SONG = "app.rocksky.song";
27
+ const C_ALBUM = "app.rocksky.album";
28
+ const C_ARTIST = "app.rocksky.artist";
29
+ const C_LIKE = "app.rocksky.like";
30
+ const C_FOLLOW = "app.rocksky.graph.follow";
31
+ const C_SHOUT = "app.rocksky.shout";
32
+ const C_STATUS = "app.rocksky.actor.status";
33
+
34
+ function actorResolver(): LocalActorResolver {
35
+ return new LocalActorResolver({
36
+ handleResolver: new CompositeHandleResolver({
37
+ methods: {
38
+ dns: new DohJsonHandleResolver({ dohUrl: "https://mozilla.cloudflare-dns.com/dns-query" }),
39
+ http: new WellKnownHandleResolver(),
40
+ },
41
+ }),
42
+ didDocumentResolver: new CompositeDidDocumentResolver({
43
+ methods: { plc: new PlcDidDocumentResolver(), web: new WebDidDocumentResolver() },
44
+ }),
45
+ });
46
+ }
47
+
48
+ function nowISO(): string {
49
+ return new Date().toISOString();
50
+ }
51
+
52
+ // Write inputs: `createdAt` is optional (the SDK defaults it to now).
53
+ /** Input for {@link Agent.scrobble} (createdAt defaults to now). */
54
+ export type ScrobbleInput = Omit<ScrobbleRecord, "createdAt"> & { createdAt?: string };
55
+ /** Input for {@link Agent.createSong}. */
56
+ export type SongInput = Omit<SongRecord, "createdAt"> & { createdAt?: string };
57
+ /** Input for {@link Agent.createAlbum} (`artist` is the album artist). */
58
+ export type AlbumInput = Omit<AlbumRecord, "createdAt"> & { createdAt?: string };
59
+ /** Input for {@link Agent.createArtist}. */
60
+ export type ArtistInput = Omit<ArtistRecord, "createdAt"> & { createdAt?: string };
61
+
62
+ /**
63
+ * Authenticated Rocksky client: logs in with an app password and writes
64
+ * app.rocksky.* records to the user's PDS (via atcute). Attach a
65
+ * {@link RockskyIndex} with {@link Agent.useIndex} for duplicate prevention.
66
+ */
67
+ export class Agent {
68
+ private idx?: RockskyIndex;
69
+
70
+ private constructor(
71
+ private rpc: Client,
72
+ readonly did: string,
73
+ readonly session: PasswordSession,
74
+ private pds: string,
75
+ ) {}
76
+
77
+ /**
78
+ * Resolve the account's PDS, authenticate with an app password, and return an
79
+ * Agent. `identifier` is a handle or DID.
80
+ */
81
+ static async login(identifier: string, password: string): Promise<Agent> {
82
+ const actor = await actorResolver().resolve(identifier as never);
83
+ const session = await PasswordSession.login({ service: actor.pds, identifier, password });
84
+ return new Agent(new Client({ handler: session }), session.did, session, actor.pds);
85
+ }
86
+
87
+ /** Attach a local dedup index — write verbs then skip records that already exist. */
88
+ useIndex(idx: RockskyIndex): void {
89
+ this.idx = idx;
90
+ }
91
+
92
+ /**
93
+ * Download the caller's full repository and (re)build the dedup index. Requires
94
+ * an attached index ({@link Agent.useIndex}). Full backfill; keep it current
95
+ * with {@link Agent.hydrateFromJetstream}.
96
+ */
97
+ async syncRepo(): Promise<IndexStats> {
98
+ if (!this.idx) throw new Error("no dedup index attached (call useIndex)");
99
+ const url = `${this.pds}/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(this.did)}`;
100
+ const res = await fetch(url);
101
+ if (!res.ok) throw new Error(`getRepo: ${res.status}`);
102
+ const car = new Uint8Array(await res.arrayBuffer());
103
+ return this.idx.indexCar(this.did, car);
104
+ }
105
+
106
+ /**
107
+ * Keep the dedup index live from the Bluesky Jetstream firehose (all four
108
+ * servers at once, filtered to this DID + app.rocksky.*). Resolves when the
109
+ * options' AbortSignal fires. Requires an attached index.
110
+ */
111
+ hydrateFromJetstream(opts: JetstreamOptions = {}): Promise<void> {
112
+ if (!this.idx) throw new Error("no dedup index attached (call useIndex)");
113
+ return runJetstream(this.idx, this.did, opts);
114
+ }
115
+
116
+ private async create(collection: string, record: Record<string, unknown>): Promise<string> {
117
+ const res = await this.rpc.post("com.atproto.repo.createRecord" as never, {
118
+ input: { repo: this.did, collection, record: { ...record, $type: collection } },
119
+ } as never);
120
+ if (!res.ok) throw new RockskyError(res.data);
121
+ return (res.data as { uri: string }).uri;
122
+ }
123
+
124
+ private async putRecord(collection: string, rkey: string, record: Record<string, unknown>): Promise<string> {
125
+ const res = await this.rpc.post("com.atproto.repo.putRecord" as never, {
126
+ input: { repo: this.did, collection, rkey, record: { ...record, $type: collection } },
127
+ } as never);
128
+ if (!res.ok) throw new RockskyError(res.data);
129
+ return (res.data as { uri: string }).uri;
130
+ }
131
+
132
+ /** Delete a record by collection + rkey. */
133
+ async delete(collection: string, rkey: string): Promise<void> {
134
+ const res = await this.rpc.post("com.atproto.repo.deleteRecord" as never, {
135
+ input: { repo: this.did, collection, rkey },
136
+ } as never);
137
+ if (!res.ok) throw new RockskyError(res.data);
138
+ }
139
+
140
+ /** Scrobble a play (app.rocksky.scrobble). createdAt defaults to now. */
141
+ async scrobble(rec: ScrobbleInput): Promise<string> {
142
+ const record = { ...rec, createdAt: rec.createdAt || nowISO() };
143
+ if (this.idx) {
144
+ const secs = Math.floor(Date.parse(record.createdAt) / 1000);
145
+ const existing = await this.idx.scrobbleUri(this.did, record.title!, record.artist!, record.album!, secs);
146
+ if (existing) return existing;
147
+ }
148
+ const uri = await this.create(C_SCROBBLE, record as Record<string, unknown>);
149
+ if (this.idx) {
150
+ const secs = Math.floor(Date.parse(record.createdAt) / 1000);
151
+ await this.idx.recordScrobble(this.did, record.title!, record.artist!, record.album!, secs, uri);
152
+ }
153
+ return uri;
154
+ }
155
+
156
+ /** Scrobble from just a title + artist (album optional, plus optional
157
+ * `mbId`/`isrc` anchors): resolve full metadata via `matchSong`, then write.
158
+ * Matching uses the public AppView unless `appview` is given; an empty match
159
+ * falls back to a minimal record. */
160
+ async scrobbleMatch(
161
+ title: string,
162
+ artist: string,
163
+ album?: string,
164
+ mbId?: string,
165
+ isrc?: string,
166
+ appview?: string,
167
+ ): Promise<string> {
168
+ const { RockskyClient } = await import("./client.js");
169
+ const m = (await new RockskyClient(appview).matchSong(title, artist, mbId, isrc)) as Record<
170
+ string,
171
+ unknown
172
+ > | null;
173
+ const s = (k: string): string | undefined => (m && typeof m[k] === "string" ? (m[k] as string) : undefined);
174
+ const n = (k: string): number | undefined => (m && typeof m[k] === "number" ? (m[k] as number) : undefined);
175
+ const rec: ScrobbleInput =
176
+ m && m.title
177
+ ? {
178
+ title: s("title")!,
179
+ artist: s("artist")!,
180
+ albumArtist: s("albumArtist") ?? artist,
181
+ album: album ?? s("album") ?? "",
182
+ albumArtUrl: s("albumArt"),
183
+ duration: n("duration") ?? 0,
184
+ trackNumber: n("trackNumber"),
185
+ discNumber: n("discNumber"),
186
+ year: n("year"),
187
+ releaseDate: s("releaseDate"),
188
+ genre: s("genre"),
189
+ composer: s("composer"),
190
+ label: s("label"),
191
+ mbid: s("mbId"),
192
+ isrc: s("isrc"),
193
+ spotifyLink: s("spotifyLink"),
194
+ youtubeLink: s("youtubeLink"),
195
+ tidalLink: s("tidalLink"),
196
+ appleMusicLink: s("appleMusicLink"),
197
+ }
198
+ : { title, artist, album: album ?? "", albumArtist: artist, duration: 0 };
199
+ return this.scrobble(rec);
200
+ }
201
+
202
+ /** Create a canonical track record (app.rocksky.song). */
203
+ async createSong(rec: SongInput): Promise<string> {
204
+ const record = { ...rec, createdAt: rec.createdAt || nowISO() };
205
+ if (this.idx) {
206
+ const existing = await this.idx.songUri(this.did, record.title!, record.artist!, record.album!);
207
+ if (existing) return existing;
208
+ }
209
+ const uri = await this.create(C_SONG, record as Record<string, unknown>);
210
+ if (this.idx) await this.idx.recordSong(this.did, record.title!, record.artist!, record.album!, uri);
211
+ return uri;
212
+ }
213
+
214
+ /** Create an album record (app.rocksky.album). `artist` is the album artist. */
215
+ async createAlbum(rec: AlbumInput): Promise<string> {
216
+ const record = { ...rec, createdAt: rec.createdAt || nowISO() };
217
+ if (this.idx) {
218
+ const existing = await this.idx.albumUri(this.did, record.title!, record.artist!);
219
+ if (existing) return existing;
220
+ }
221
+ const uri = await this.create(C_ALBUM, record as Record<string, unknown>);
222
+ if (this.idx) await this.idx.recordAlbum(this.did, record.title!, record.artist!, uri);
223
+ return uri;
224
+ }
225
+
226
+ /** Create an artist record (app.rocksky.artist). */
227
+ async createArtist(rec: ArtistInput): Promise<string> {
228
+ const record = { ...rec, createdAt: rec.createdAt || nowISO() };
229
+ if (this.idx) {
230
+ const existing = await this.idx.artistUri(this.did, record.name!);
231
+ if (existing) return existing;
232
+ }
233
+ const uri = await this.create(C_ARTIST, record as Record<string, unknown>);
234
+ if (this.idx) await this.idx.recordArtist(this.did, record.name!, uri);
235
+ return uri;
236
+ }
237
+
238
+ /** Like a record by strong reference (uri + cid). Returns the like URI. */
239
+ like(uri: string, cid: string): Promise<string> {
240
+ return this.create(C_LIKE, { subject: { uri, cid }, createdAt: nowISO() });
241
+ }
242
+
243
+ /** Follow an account by DID. Returns the follow URI. */
244
+ follow(did: string): Promise<string> {
245
+ return this.create(C_FOLLOW, { subject: did, createdAt: nowISO() });
246
+ }
247
+
248
+ /** Post a shout on a subject. Returns the shout URI. */
249
+ shout(subjectUri: string, subjectCid: string, message: string): Promise<string> {
250
+ return this.create(C_SHOUT, { subject: { uri: subjectUri, cid: subjectCid }, message, createdAt: nowISO() });
251
+ }
252
+
253
+ /** Reply to a shout, with a parent strong-ref. */
254
+ replyShout(
255
+ subjectUri: string,
256
+ subjectCid: string,
257
+ parentUri: string,
258
+ parentCid: string,
259
+ message: string,
260
+ ): Promise<string> {
261
+ return this.create(C_SHOUT, {
262
+ subject: { uri: subjectUri, cid: subjectCid },
263
+ parent: { uri: parentUri, cid: parentCid },
264
+ message,
265
+ createdAt: nowISO(),
266
+ });
267
+ }
268
+
269
+ /** Upsert the actor's now-playing status singleton (rkey "self"). */
270
+ setNowPlaying(track: ActorTrackView): Promise<string> {
271
+ return this.putRecord(C_STATUS, "self", { track, startedAt: nowISO() });
272
+ }
273
+
274
+ /** Delete the actor's now-playing status singleton. */
275
+ clearNowPlaying(): Promise<void> {
276
+ return this.delete(C_STATUS, "self");
277
+ }
278
+ }