@rocksky/sdk 0.10.2 → 0.11.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/dist/agent.d.ts +73 -0
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.ratelimit.test.d.ts +2 -0
- package/dist/agent.ratelimit.test.d.ts.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +78 -0
- package/package.json +1 -1
- package/src/agent.ratelimit.test.ts +164 -0
- package/src/agent.ts +196 -1
- package/src/index.ts +4 -0
package/dist/agent.d.ts
CHANGED
|
@@ -2,6 +2,55 @@ import { PasswordSession } from "@atcute/password-session";
|
|
|
2
2
|
import type { AlbumRecord, ArtistRecord, ActorTrackView, ScrobbleRecord, ShoutGif, SongRecord } from "./generated/types.js";
|
|
3
3
|
import type { IndexStats, RockskyIndex } from "./dedup.js";
|
|
4
4
|
import { type JetstreamOptions } from "./jetstream.js";
|
|
5
|
+
/**
|
|
6
|
+
* The highest sustained writes-per-hour that still fits Bluesky's write-point
|
|
7
|
+
* budget. On the official Bluesky PDS this is a hard ceiling the Agent will not
|
|
8
|
+
* let any caller exceed; self-hosted PDSes may allow more (or none).
|
|
9
|
+
*/
|
|
10
|
+
export declare const MAX_SAFE_WRITES_PER_HOUR: number;
|
|
11
|
+
export declare const DEFAULT_MATCH_SONG_PER_HOUR: number;
|
|
12
|
+
/** Options for {@link Agent.configureRateLimit}. */
|
|
13
|
+
export interface RateLimitOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Target writes (createRecord/putRecord/deleteRecord) per hour. Omitted →
|
|
16
|
+
* {@link MAX_SAFE_WRITES_PER_HOUR}. On the official Bluesky PDS this is
|
|
17
|
+
* clamped to the safe ceiling; on a self-hosted PDS it is honored as given.
|
|
18
|
+
* Ignored when `disabled` is true.
|
|
19
|
+
*/
|
|
20
|
+
writesPerHour?: number;
|
|
21
|
+
/**
|
|
22
|
+
* Turn the client-side *write* throttle off entirely. Honored on self-hosted
|
|
23
|
+
* PDSes (useful when you run your own PDS with its own limits). IGNORED —
|
|
24
|
+
* forced back on at the safe rate — when the account lives on the official
|
|
25
|
+
* Bluesky PDS (*.bsky.network), whose budget is enforced server-side.
|
|
26
|
+
*
|
|
27
|
+
* NOTE: this never affects the Rocksky AppView `matchSong` throttle, which is
|
|
28
|
+
* always enforced (see {@link RateLimitOptions.matchSongPerHour}).
|
|
29
|
+
*/
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Target Rocksky AppView `matchSong` calls per hour. ALWAYS enforced — a
|
|
33
|
+
* self-hosted PDS grants no extra AppView capacity — so `disabled` never turns
|
|
34
|
+
* it off; this only tunes the rate. Omitted → keep the current value
|
|
35
|
+
* (default {@link DEFAULT_MATCH_SONG_PER_HOUR}). Non-positive values are ignored.
|
|
36
|
+
*/
|
|
37
|
+
matchSongPerHour?: number;
|
|
38
|
+
}
|
|
39
|
+
/** The effective throttle state after {@link Agent.configureRateLimit} applies policy. */
|
|
40
|
+
export interface RateLimitState {
|
|
41
|
+
/** Whether the *write* throttle is active. */
|
|
42
|
+
enabled: boolean;
|
|
43
|
+
/** Effective writes/hour cap (Infinity when disabled). */
|
|
44
|
+
writesPerHour: number;
|
|
45
|
+
/** True when `disabled` was requested but overridden by the bsky.network guard. */
|
|
46
|
+
forcedOn: boolean;
|
|
47
|
+
/** True when a requested `writesPerHour` was clamped to the safe ceiling. */
|
|
48
|
+
capped: boolean;
|
|
49
|
+
/** The resolved PDS host the decision was based on. */
|
|
50
|
+
pdsHost: string;
|
|
51
|
+
/** Effective Rocksky AppView matchSong rate — always enforced, never disabled. */
|
|
52
|
+
matchSongPerHour: number;
|
|
53
|
+
}
|
|
5
54
|
/** Input for {@link Agent.scrobble} (createdAt defaults to now). */
|
|
6
55
|
export type ScrobbleInput = Omit<ScrobbleRecord, "createdAt"> & {
|
|
7
56
|
createdAt?: string;
|
|
@@ -42,7 +91,31 @@ export declare class Agent {
|
|
|
42
91
|
readonly session: PasswordSession;
|
|
43
92
|
private pds;
|
|
44
93
|
private idx?;
|
|
94
|
+
private writeGate;
|
|
95
|
+
private matchGate;
|
|
96
|
+
private matchSongPerHour;
|
|
97
|
+
/** Lower-case hostname of the account's resolved PDS (e.g. "pds.example.com"). */
|
|
98
|
+
readonly pdsHost: string;
|
|
45
99
|
private constructor();
|
|
100
|
+
/**
|
|
101
|
+
* Whether the account lives on the official Bluesky PDS (*.bsky.network).
|
|
102
|
+
* Its write budget is enforced server-side, so the client-side throttle can
|
|
103
|
+
* never be disabled for these hosts.
|
|
104
|
+
*/
|
|
105
|
+
get isOfficialBlueskyPds(): boolean;
|
|
106
|
+
/**
|
|
107
|
+
* Configure the client-side write throttle and return the effective state.
|
|
108
|
+
*
|
|
109
|
+
* Policy — the *.bsky.network guard is authoritative and cannot be bypassed:
|
|
110
|
+
* - `disabled: true` turns the throttle off on a self-hosted PDS, but on the
|
|
111
|
+
* official Bluesky PDS it is ignored and the throttle stays on at the safe
|
|
112
|
+
* rate (`forcedOn: true`).
|
|
113
|
+
* - `writesPerHour` is honored as given on a self-hosted PDS, but clamped to
|
|
114
|
+
* {@link MAX_SAFE_WRITES_PER_HOUR} on the official Bluesky PDS
|
|
115
|
+
* (`capped: true` when clamped).
|
|
116
|
+
* - Omitting both enables the throttle at the safe default rate.
|
|
117
|
+
*/
|
|
118
|
+
configureRateLimit(opts?: RateLimitOptions): RateLimitState;
|
|
46
119
|
/**
|
|
47
120
|
* Resolve the account's PDS, authenticate with an app password, and return an
|
|
48
121
|
* Agent. `identifier` is a handle or DID.
|
package/dist/agent.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAG3D,OAAO,KAAK,EACV,WAAW,EACX,YAAY,EACZ,cAAc,EACd,cAAc,EACd,QAAQ,EACR,UAAU,EACX,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC3D,OAAO,EAAgB,KAAK,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent.ratelimit.test.d.ts","sourceRoot":"","sources":["../src/agent.ratelimit.test.ts"],"names":[],"mappings":""}
|
package/dist/index.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
export { RockskyClient, DEFAULT_APPVIEW, Interval } from "./client.js";
|
|
12
12
|
export type { DateInterval } from "./client.js";
|
|
13
13
|
export { RockskyLibrary } from "./library.js";
|
|
14
|
-
export { Agent, type ScrobbleInput, type ScrobbleMatchInput, type SongInput, type AlbumInput, type ArtistInput, } from "./agent.js";
|
|
14
|
+
export { Agent, MAX_SAFE_WRITES_PER_HOUR, DEFAULT_MATCH_SONG_PER_HOUR, type ScrobbleInput, type ScrobbleMatchInput, type SongInput, type AlbumInput, type ArtistInput, type RateLimitOptions, type RateLimitState, } from "./agent.js";
|
|
15
15
|
export { RockskyIndex, totalIndexed, type IndexStats } from "./dedup.js";
|
|
16
16
|
export { runJetstream, DEFAULT_JETSTREAM_SERVERS, type JetstreamOptions } from "./jetstream.js";
|
|
17
17
|
export { RemotePlayer, DEFAULT_REMOTE_WS, type RemotePlayerOptions, type RemotePlayerHandlers, type RemoteNowPlaying, type RemoteQueueItem, type EnqueueCommand, } from "./remote-player.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACvE,YAAY,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EACL,KAAK,EACL,KAAK,aAAa,EAClB,KAAK,kBAAkB,EACvB,KAAK,SAAS,EACd,KAAK,UAAU,EACf,KAAK,WAAW,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACvE,YAAY,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EACL,KAAK,EACL,wBAAwB,EACxB,2BAA2B,EAC3B,KAAK,aAAa,EAClB,KAAK,kBAAkB,EACvB,KAAK,SAAS,EACd,KAAK,UAAU,EACf,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,cAAc,GACpB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AACzE,OAAO,EAAE,YAAY,EAAE,yBAAyB,EAAE,KAAK,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAChG,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,cAAc,GACpB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,gBAAgB,EAChB,KAAK,uBAAuB,EAC5B,KAAK,sBAAsB,EAC3B,KAAK,YAAY,EACjB,KAAK,YAAY,GAClB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAC5D,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,mBAAmB,sBAAsB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -626,6 +626,38 @@ function actorResolver() {
|
|
|
626
626
|
function nowISO() {
|
|
627
627
|
return new Date().toISOString();
|
|
628
628
|
}
|
|
629
|
+
function hostOf(url) {
|
|
630
|
+
try {
|
|
631
|
+
return new URL(url).hostname.toLowerCase();
|
|
632
|
+
} catch {
|
|
633
|
+
return "";
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
var PDS_WRITE_POINT_BUDGET_PER_HOUR = 5000;
|
|
637
|
+
var POINTS_PER_WRITE = 3;
|
|
638
|
+
var SAFETY_MARGIN = 0.9;
|
|
639
|
+
var MAX_SAFE_WRITES_PER_HOUR = Math.floor(PDS_WRITE_POINT_BUDGET_PER_HOUR * SAFETY_MARGIN / POINTS_PER_WRITE);
|
|
640
|
+
var APPVIEW_REQUEST_LIMIT = 1000;
|
|
641
|
+
var APPVIEW_WINDOW_SECONDS = 30;
|
|
642
|
+
var DEFAULT_MATCH_SONG_PER_HOUR = Math.floor(APPVIEW_REQUEST_LIMIT * SAFETY_MARGIN / APPVIEW_WINDOW_SECONDS * 3600);
|
|
643
|
+
|
|
644
|
+
class RateGate {
|
|
645
|
+
nextAt = 0;
|
|
646
|
+
minIntervalMs = 0;
|
|
647
|
+
setRate(writesPerHour) {
|
|
648
|
+
this.minIntervalMs = writesPerHour && writesPerHour > 0 ? Math.ceil(3600000 / writesPerHour) : 0;
|
|
649
|
+
}
|
|
650
|
+
async take() {
|
|
651
|
+
if (this.minIntervalMs <= 0)
|
|
652
|
+
return;
|
|
653
|
+
const now = Date.now();
|
|
654
|
+
const at = Math.max(now, this.nextAt);
|
|
655
|
+
this.nextAt = at + this.minIntervalMs;
|
|
656
|
+
const delay = at - now;
|
|
657
|
+
if (delay > 0)
|
|
658
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
659
|
+
}
|
|
660
|
+
}
|
|
629
661
|
|
|
630
662
|
class Agent {
|
|
631
663
|
rpc;
|
|
@@ -633,11 +665,51 @@ class Agent {
|
|
|
633
665
|
session;
|
|
634
666
|
pds;
|
|
635
667
|
idx;
|
|
668
|
+
writeGate = new RateGate;
|
|
669
|
+
matchGate = new RateGate;
|
|
670
|
+
matchSongPerHour = DEFAULT_MATCH_SONG_PER_HOUR;
|
|
671
|
+
pdsHost;
|
|
636
672
|
constructor(rpc, did, session, pds) {
|
|
637
673
|
this.rpc = rpc;
|
|
638
674
|
this.did = did;
|
|
639
675
|
this.session = session;
|
|
640
676
|
this.pds = pds;
|
|
677
|
+
this.pdsHost = hostOf(pds);
|
|
678
|
+
this.matchGate.setRate(this.matchSongPerHour);
|
|
679
|
+
}
|
|
680
|
+
get isOfficialBlueskyPds() {
|
|
681
|
+
return this.pdsHost === "bsky.network" || this.pdsHost.endsWith(".bsky.network");
|
|
682
|
+
}
|
|
683
|
+
configureRateLimit(opts = {}) {
|
|
684
|
+
if (opts.matchSongPerHour !== undefined && opts.matchSongPerHour > 0) {
|
|
685
|
+
this.matchSongPerHour = opts.matchSongPerHour;
|
|
686
|
+
this.matchGate.setRate(this.matchSongPerHour);
|
|
687
|
+
}
|
|
688
|
+
const official = this.isOfficialBlueskyPds;
|
|
689
|
+
const state = (partial) => ({
|
|
690
|
+
...partial,
|
|
691
|
+
pdsHost: this.pdsHost,
|
|
692
|
+
matchSongPerHour: this.matchSongPerHour
|
|
693
|
+
});
|
|
694
|
+
if (opts.disabled) {
|
|
695
|
+
if (official) {
|
|
696
|
+
const writesPerHour2 = Math.min(opts.writesPerHour ?? MAX_SAFE_WRITES_PER_HOUR, MAX_SAFE_WRITES_PER_HOUR);
|
|
697
|
+
this.writeGate.setRate(writesPerHour2);
|
|
698
|
+
return state({ enabled: true, writesPerHour: writesPerHour2, forcedOn: true, capped: false });
|
|
699
|
+
}
|
|
700
|
+
this.writeGate.setRate(null);
|
|
701
|
+
return state({ enabled: false, writesPerHour: Infinity, forcedOn: false, capped: false });
|
|
702
|
+
}
|
|
703
|
+
let writesPerHour = opts.writesPerHour ?? MAX_SAFE_WRITES_PER_HOUR;
|
|
704
|
+
if (!(writesPerHour > 0))
|
|
705
|
+
writesPerHour = MAX_SAFE_WRITES_PER_HOUR;
|
|
706
|
+
let capped = false;
|
|
707
|
+
if (official && writesPerHour > MAX_SAFE_WRITES_PER_HOUR) {
|
|
708
|
+
writesPerHour = MAX_SAFE_WRITES_PER_HOUR;
|
|
709
|
+
capped = true;
|
|
710
|
+
}
|
|
711
|
+
this.writeGate.setRate(writesPerHour);
|
|
712
|
+
return state({ enabled: true, writesPerHour, forcedOn: false, capped });
|
|
641
713
|
}
|
|
642
714
|
static async login(identifier, password) {
|
|
643
715
|
const actor = await actorResolver().resolve(identifier);
|
|
@@ -664,6 +736,7 @@ class Agent {
|
|
|
664
736
|
return runJetstream(this.idx, this.did, opts);
|
|
665
737
|
}
|
|
666
738
|
async create(collection, record) {
|
|
739
|
+
await this.writeGate.take();
|
|
667
740
|
const res = await this.rpc.post("com.atproto.repo.createRecord", {
|
|
668
741
|
input: { repo: this.did, collection, record: { ...record, $type: collection } }
|
|
669
742
|
});
|
|
@@ -672,6 +745,7 @@ class Agent {
|
|
|
672
745
|
return res.data.uri;
|
|
673
746
|
}
|
|
674
747
|
async putRecord(collection, rkey, record) {
|
|
748
|
+
await this.writeGate.take();
|
|
675
749
|
const res = await this.rpc.post("com.atproto.repo.putRecord", {
|
|
676
750
|
input: { repo: this.did, collection, rkey, record: { ...record, $type: collection } }
|
|
677
751
|
});
|
|
@@ -680,6 +754,7 @@ class Agent {
|
|
|
680
754
|
return res.data.uri;
|
|
681
755
|
}
|
|
682
756
|
async delete(collection, rkey) {
|
|
757
|
+
await this.writeGate.take();
|
|
683
758
|
const res = await this.rpc.post("com.atproto.repo.deleteRecord", {
|
|
684
759
|
input: { repo: this.did, collection, rkey }
|
|
685
760
|
});
|
|
@@ -730,6 +805,7 @@ class Agent {
|
|
|
730
805
|
async scrobbleMatch(input, appview) {
|
|
731
806
|
const { title, artist, album, mbId, isrc, timestamp } = input;
|
|
732
807
|
const { RockskyClient: RockskyClient2 } = await Promise.resolve().then(() => (init_client(), exports_client));
|
|
808
|
+
await this.matchGate.take();
|
|
733
809
|
const m = await new RockskyClient2(appview).matchSong(title, artist, mbId, isrc);
|
|
734
810
|
const s = (k) => m && typeof m[k] === "string" ? m[k] : undefined;
|
|
735
811
|
const n = (k) => m && typeof m[k] === "number" ? m[k] : undefined;
|
|
@@ -1541,8 +1617,10 @@ export {
|
|
|
1541
1617
|
RockskyClient,
|
|
1542
1618
|
RemotePlayer,
|
|
1543
1619
|
RemoteController,
|
|
1620
|
+
MAX_SAFE_WRITES_PER_HOUR,
|
|
1544
1621
|
Interval,
|
|
1545
1622
|
DEFAULT_REMOTE_WS,
|
|
1623
|
+
DEFAULT_MATCH_SONG_PER_HOUR,
|
|
1546
1624
|
DEFAULT_JETSTREAM_SERVERS,
|
|
1547
1625
|
DEFAULT_APPVIEW,
|
|
1548
1626
|
Agent
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rocksky/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
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": {
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { Agent, MAX_SAFE_WRITES_PER_HOUR, DEFAULT_MATCH_SONG_PER_HOUR } from "./agent.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A fake XRPC client that counts writes and records the wall-clock time of each
|
|
7
|
+
* one, without ever touching a PDS. Nothing here talks to the network.
|
|
8
|
+
*/
|
|
9
|
+
function fakeAgent(pds: string): {
|
|
10
|
+
agent: Agent;
|
|
11
|
+
writeTimes: number[];
|
|
12
|
+
} {
|
|
13
|
+
const writeTimes: number[] = [];
|
|
14
|
+
let n = 0;
|
|
15
|
+
const rpc = {
|
|
16
|
+
async post(nsid: string, opts: { input: { collection: string } }) {
|
|
17
|
+
if (
|
|
18
|
+
nsid === "com.atproto.repo.createRecord" ||
|
|
19
|
+
nsid === "com.atproto.repo.putRecord" ||
|
|
20
|
+
nsid === "com.atproto.repo.deleteRecord"
|
|
21
|
+
) {
|
|
22
|
+
writeTimes.push(Date.now());
|
|
23
|
+
return { ok: true, data: { uri: `at://did:plc:test/${opts.input.collection}/rec${++n}` } };
|
|
24
|
+
}
|
|
25
|
+
return { ok: false, data: { error: "UnexpectedCall", message: nsid } };
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
// The constructor is private (compile-time only); bun runs the source
|
|
29
|
+
// directly, so we can instantiate with a stub client + no real session.
|
|
30
|
+
const agent = new (Agent as unknown as new (...a: unknown[]) => Agent)(rpc, "did:plc:test", {}, pds);
|
|
31
|
+
return { agent, writeTimes };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const OFFICIAL = "https://amanita.us-east.host.bsky.network";
|
|
35
|
+
const SELFHOSTED = "https://pds.example.com";
|
|
36
|
+
|
|
37
|
+
describe("Agent PDS identity", () => {
|
|
38
|
+
test("recognizes the official Bluesky PDS by *.bsky.network host", () => {
|
|
39
|
+
expect(fakeAgent(OFFICIAL).agent.isOfficialBlueskyPds).toBe(true);
|
|
40
|
+
expect(fakeAgent("https://bsky.network").agent.isOfficialBlueskyPds).toBe(true);
|
|
41
|
+
expect(fakeAgent("https://Puffball.US-West.HOST.BSKY.NETWORK").agent.isOfficialBlueskyPds).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a self-hosted PDS is not treated as official", () => {
|
|
45
|
+
expect(fakeAgent(SELFHOSTED).agent.isOfficialBlueskyPds).toBe(false);
|
|
46
|
+
// A look-alike host that only *contains* the string must not match.
|
|
47
|
+
expect(fakeAgent("https://not-bsky.network.evil.com").agent.isOfficialBlueskyPds).toBe(false);
|
|
48
|
+
expect(fakeAgent("https://bsky.network.evil.com").agent.isOfficialBlueskyPds).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("exposes the resolved PDS host", () => {
|
|
52
|
+
expect(fakeAgent(SELFHOSTED).agent.pdsHost).toBe("pds.example.com");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("configureRateLimit policy (guard is authoritative)", () => {
|
|
57
|
+
test("self-hosted: disabling turns the throttle fully off", () => {
|
|
58
|
+
const { agent } = fakeAgent(SELFHOSTED);
|
|
59
|
+
const state = agent.configureRateLimit({ disabled: true });
|
|
60
|
+
expect(state.enabled).toBe(false);
|
|
61
|
+
expect(state.forcedOn).toBe(false);
|
|
62
|
+
expect(state.writesPerHour).toBe(Infinity);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("official Bluesky: disabling is IGNORED — throttle forced back on at the safe rate", () => {
|
|
66
|
+
const { agent } = fakeAgent(OFFICIAL);
|
|
67
|
+
const state = agent.configureRateLimit({ disabled: true });
|
|
68
|
+
expect(state.enabled).toBe(true);
|
|
69
|
+
expect(state.forcedOn).toBe(true);
|
|
70
|
+
expect(state.writesPerHour).toBe(MAX_SAFE_WRITES_PER_HOUR);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("official Bluesky: a huge writesPerHour is clamped to the safe ceiling", () => {
|
|
74
|
+
const { agent } = fakeAgent(OFFICIAL);
|
|
75
|
+
const state = agent.configureRateLimit({ writesPerHour: 1_000_000 });
|
|
76
|
+
expect(state.enabled).toBe(true);
|
|
77
|
+
expect(state.capped).toBe(true);
|
|
78
|
+
expect(state.writesPerHour).toBe(MAX_SAFE_WRITES_PER_HOUR);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("official Bluesky: even disabled+huge can never exceed the points budget", () => {
|
|
82
|
+
const { agent } = fakeAgent(OFFICIAL);
|
|
83
|
+
const state = agent.configureRateLimit({ disabled: true, writesPerHour: 1_000_000 });
|
|
84
|
+
expect(state.writesPerHour).toBe(MAX_SAFE_WRITES_PER_HOUR);
|
|
85
|
+
// Sanity: the effective rate stays inside Bluesky's ~5000 points/hour budget.
|
|
86
|
+
expect(state.writesPerHour * 3).toBeLessThanOrEqual(5000);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("self-hosted: a custom writesPerHour above the Bluesky ceiling is honored, not clamped", () => {
|
|
90
|
+
const { agent } = fakeAgent(SELFHOSTED);
|
|
91
|
+
const state = agent.configureRateLimit({ writesPerHour: 1_000_000 });
|
|
92
|
+
expect(state.enabled).toBe(true);
|
|
93
|
+
expect(state.capped).toBe(false);
|
|
94
|
+
expect(state.writesPerHour).toBe(1_000_000);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("default (no options) enables the throttle at the safe rate on any PDS", () => {
|
|
98
|
+
expect(fakeAgent(SELFHOSTED).agent.configureRateLimit().writesPerHour).toBe(MAX_SAFE_WRITES_PER_HOUR);
|
|
99
|
+
expect(fakeAgent(OFFICIAL).agent.configureRateLimit().writesPerHour).toBe(MAX_SAFE_WRITES_PER_HOUR);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("a zero / NaN writesPerHour falls back to the safe rate, never 'unlimited'", () => {
|
|
103
|
+
const { agent } = fakeAgent(SELFHOSTED);
|
|
104
|
+
expect(agent.configureRateLimit({ writesPerHour: 0 }).writesPerHour).toBe(MAX_SAFE_WRITES_PER_HOUR);
|
|
105
|
+
expect(agent.configureRateLimit({ writesPerHour: NaN }).writesPerHour).toBe(MAX_SAFE_WRITES_PER_HOUR);
|
|
106
|
+
expect(agent.configureRateLimit({ writesPerHour: -5 }).writesPerHour).toBe(MAX_SAFE_WRITES_PER_HOUR);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("matchSong AppView throttle is ALWAYS enforced", () => {
|
|
111
|
+
test("default matchSong rate is derived from the AppView's per-IP budget", () => {
|
|
112
|
+
// 1000 req / 30s * 0.9 * 3600 = 108,000/h — and never exceeds the raw budget.
|
|
113
|
+
expect(DEFAULT_MATCH_SONG_PER_HOUR).toBe(108_000);
|
|
114
|
+
expect(DEFAULT_MATCH_SONG_PER_HOUR / 3600).toBeLessThanOrEqual((1000 / 30));
|
|
115
|
+
expect(fakeAgent(SELFHOSTED).agent.configureRateLimit().matchSongPerHour).toBe(DEFAULT_MATCH_SONG_PER_HOUR);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("disabling the write throttle does NOT disable matchSong (self-hosted)", () => {
|
|
119
|
+
const { agent } = fakeAgent(SELFHOSTED);
|
|
120
|
+
const state = agent.configureRateLimit({ disabled: true });
|
|
121
|
+
expect(state.enabled).toBe(false); // writes off
|
|
122
|
+
expect(state.matchSongPerHour).toBe(DEFAULT_MATCH_SONG_PER_HOUR); // matchSong still on
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("matchSongPerHour can be retuned and persists across calls", () => {
|
|
126
|
+
const { agent } = fakeAgent(SELFHOSTED);
|
|
127
|
+
expect(agent.configureRateLimit({ matchSongPerHour: 500 }).matchSongPerHour).toBe(500);
|
|
128
|
+
// A later call that doesn't mention matchSong keeps the tuned value.
|
|
129
|
+
expect(agent.configureRateLimit({ disabled: true }).matchSongPerHour).toBe(500);
|
|
130
|
+
// Non-positive values are ignored (never turns matchSong off).
|
|
131
|
+
expect(agent.configureRateLimit({ matchSongPerHour: 0 }).matchSongPerHour).toBe(500);
|
|
132
|
+
expect(agent.configureRateLimit({ matchSongPerHour: -1 }).matchSongPerHour).toBe(500);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("throttle behavior (observable, no real PDS)", () => {
|
|
137
|
+
test("by default there is no throttle — a burst of writes runs back-to-back", async () => {
|
|
138
|
+
const { agent, writeTimes } = fakeAgent(SELFHOSTED);
|
|
139
|
+
for (let i = 0; i < 5; i++) await agent.createArtist({ name: `Artist ${i}` });
|
|
140
|
+
expect(writeTimes).toHaveLength(5);
|
|
141
|
+
// No configured limit → the whole burst completes near-instantly.
|
|
142
|
+
expect(writeTimes.at(-1)! - writeTimes[0]!).toBeLessThan(50);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("when enabled, writes are spaced at least the configured interval apart", async () => {
|
|
146
|
+
const { agent, writeTimes } = fakeAgent(SELFHOSTED);
|
|
147
|
+
// 180k writes/hour → 20ms minimum spacing; keeps the test fast but measurable.
|
|
148
|
+
agent.configureRateLimit({ writesPerHour: 180_000 });
|
|
149
|
+
const N = 4;
|
|
150
|
+
for (let i = 0; i < N; i++) await agent.createArtist({ name: `Artist ${i}` });
|
|
151
|
+
expect(writeTimes).toHaveLength(N);
|
|
152
|
+
// (N-1) gaps of ~20ms each; allow slack for timer jitter but require real spacing.
|
|
153
|
+
const elapsed = writeTimes.at(-1)! - writeTimes[0]!;
|
|
154
|
+
expect(elapsed).toBeGreaterThanOrEqual((N - 1) * 20 * 0.8);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("disabling after enabling removes the spacing again (self-hosted)", async () => {
|
|
158
|
+
const { agent, writeTimes } = fakeAgent(SELFHOSTED);
|
|
159
|
+
agent.configureRateLimit({ writesPerHour: 180_000 });
|
|
160
|
+
agent.configureRateLimit({ disabled: true });
|
|
161
|
+
for (let i = 0; i < 5; i++) await agent.createArtist({ name: `Artist ${i}` });
|
|
162
|
+
expect(writeTimes.at(-1)! - writeTimes[0]!).toBeLessThan(50);
|
|
163
|
+
});
|
|
164
|
+
});
|
package/src/agent.ts
CHANGED
|
@@ -50,6 +50,120 @@ function nowISO(): string {
|
|
|
50
50
|
return new Date().toISOString();
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** Lower-case hostname of a URL, or "" if it can't be parsed. */
|
|
54
|
+
function hostOf(url: string): string {
|
|
55
|
+
try {
|
|
56
|
+
return new URL(url).hostname.toLowerCase();
|
|
57
|
+
} catch {
|
|
58
|
+
return "";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- PDS write-rate limiting -----------------------------------------------
|
|
63
|
+
// Bluesky's PDS rate-limits repo writes by *points*, not requests: ~5,000
|
|
64
|
+
// points/hour per account, and each createRecord/putRecord/deleteRecord costs
|
|
65
|
+
// ~3 points. A bulk operation (importing a listening history) would blow that
|
|
66
|
+
// budget in seconds, so the Agent can throttle its own writes to stay inside
|
|
67
|
+
// it. The gate is OFF by default — single live scrobbles never need it — and
|
|
68
|
+
// callers opt in via {@link Agent.configureRateLimit}.
|
|
69
|
+
const PDS_WRITE_POINT_BUDGET_PER_HOUR = 5_000;
|
|
70
|
+
const POINTS_PER_WRITE = 3;
|
|
71
|
+
const SAFETY_MARGIN = 0.9; // headroom for 429 retries / clock skew
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The highest sustained writes-per-hour that still fits Bluesky's write-point
|
|
75
|
+
* budget. On the official Bluesky PDS this is a hard ceiling the Agent will not
|
|
76
|
+
* let any caller exceed; self-hosted PDSes may allow more (or none).
|
|
77
|
+
*/
|
|
78
|
+
export const MAX_SAFE_WRITES_PER_HOUR = Math.floor(
|
|
79
|
+
(PDS_WRITE_POINT_BUDGET_PER_HOUR * SAFETY_MARGIN) / POINTS_PER_WRITE,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// Rocksky's AppView `matchSong` endpoint is a *shared* service, and unlike PDS
|
|
83
|
+
// writes its rate limit is NOT the account owner's to waive — running your own
|
|
84
|
+
// PDS grants no extra AppView capacity. So matchSong is ALWAYS throttled,
|
|
85
|
+
// independent of the write throttle and of any `disabled` request.
|
|
86
|
+
//
|
|
87
|
+
// The AppView applies a global per-IP XRPC limit (see apps/api/src/index.ts:
|
|
88
|
+
// 1000 requests / 30s). matchSong runs against exactly that budget, so we throttle
|
|
89
|
+
// it to a safe fraction of the real server limit. Tune via `matchSongPerHour` if
|
|
90
|
+
// you operate your own AppView with a different limit.
|
|
91
|
+
const APPVIEW_REQUEST_LIMIT = 1_000; // requests …
|
|
92
|
+
const APPVIEW_WINDOW_SECONDS = 30; // … per this window (apps/api global rate limiter)
|
|
93
|
+
export const DEFAULT_MATCH_SONG_PER_HOUR = Math.floor(
|
|
94
|
+
((APPVIEW_REQUEST_LIMIT * SAFETY_MARGIN) / APPVIEW_WINDOW_SECONDS) * 3_600,
|
|
95
|
+
); // ≈ 108,000/h (~30 req/s) — 90% of the AppView's per-IP budget
|
|
96
|
+
|
|
97
|
+
/** Options for {@link Agent.configureRateLimit}. */
|
|
98
|
+
export interface RateLimitOptions {
|
|
99
|
+
/**
|
|
100
|
+
* Target writes (createRecord/putRecord/deleteRecord) per hour. Omitted →
|
|
101
|
+
* {@link MAX_SAFE_WRITES_PER_HOUR}. On the official Bluesky PDS this is
|
|
102
|
+
* clamped to the safe ceiling; on a self-hosted PDS it is honored as given.
|
|
103
|
+
* Ignored when `disabled` is true.
|
|
104
|
+
*/
|
|
105
|
+
writesPerHour?: number;
|
|
106
|
+
/**
|
|
107
|
+
* Turn the client-side *write* throttle off entirely. Honored on self-hosted
|
|
108
|
+
* PDSes (useful when you run your own PDS with its own limits). IGNORED —
|
|
109
|
+
* forced back on at the safe rate — when the account lives on the official
|
|
110
|
+
* Bluesky PDS (*.bsky.network), whose budget is enforced server-side.
|
|
111
|
+
*
|
|
112
|
+
* NOTE: this never affects the Rocksky AppView `matchSong` throttle, which is
|
|
113
|
+
* always enforced (see {@link RateLimitOptions.matchSongPerHour}).
|
|
114
|
+
*/
|
|
115
|
+
disabled?: boolean;
|
|
116
|
+
/**
|
|
117
|
+
* Target Rocksky AppView `matchSong` calls per hour. ALWAYS enforced — a
|
|
118
|
+
* self-hosted PDS grants no extra AppView capacity — so `disabled` never turns
|
|
119
|
+
* it off; this only tunes the rate. Omitted → keep the current value
|
|
120
|
+
* (default {@link DEFAULT_MATCH_SONG_PER_HOUR}). Non-positive values are ignored.
|
|
121
|
+
*/
|
|
122
|
+
matchSongPerHour?: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The effective throttle state after {@link Agent.configureRateLimit} applies policy. */
|
|
126
|
+
export interface RateLimitState {
|
|
127
|
+
/** Whether the *write* throttle is active. */
|
|
128
|
+
enabled: boolean;
|
|
129
|
+
/** Effective writes/hour cap (Infinity when disabled). */
|
|
130
|
+
writesPerHour: number;
|
|
131
|
+
/** True when `disabled` was requested but overridden by the bsky.network guard. */
|
|
132
|
+
forcedOn: boolean;
|
|
133
|
+
/** True when a requested `writesPerHour` was clamped to the safe ceiling. */
|
|
134
|
+
capped: boolean;
|
|
135
|
+
/** The resolved PDS host the decision was based on. */
|
|
136
|
+
pdsHost: string;
|
|
137
|
+
/** Effective Rocksky AppView matchSong rate — always enforced, never disabled. */
|
|
138
|
+
matchSongPerHour: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Global throttle: spaces calls at least `minIntervalMs` apart. A single shared
|
|
143
|
+
* `nextAt` cursor is advanced atomically per {@link RateGate.take}, so even
|
|
144
|
+
* highly concurrent callers never burst past the configured rate.
|
|
145
|
+
* `minIntervalMs <= 0` means no throttle (take() resolves immediately).
|
|
146
|
+
*/
|
|
147
|
+
class RateGate {
|
|
148
|
+
private nextAt = 0;
|
|
149
|
+
private minIntervalMs = 0;
|
|
150
|
+
|
|
151
|
+
/** null / non-positive → no throttle. */
|
|
152
|
+
setRate(writesPerHour: number | null): void {
|
|
153
|
+
this.minIntervalMs =
|
|
154
|
+
writesPerHour && writesPerHour > 0 ? Math.ceil(3_600_000 / writesPerHour) : 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async take(): Promise<void> {
|
|
158
|
+
if (this.minIntervalMs <= 0) return;
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
const at = Math.max(now, this.nextAt);
|
|
161
|
+
this.nextAt = at + this.minIntervalMs;
|
|
162
|
+
const delay = at - now;
|
|
163
|
+
if (delay > 0) await new Promise((r) => setTimeout(r, delay));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
53
167
|
// Write inputs: `createdAt` is optional (the SDK defaults it to now).
|
|
54
168
|
/** Input for {@link Agent.scrobble} (createdAt defaults to now). */
|
|
55
169
|
export type ScrobbleInput = Omit<ScrobbleRecord, "createdAt"> & { createdAt?: string };
|
|
@@ -80,13 +194,88 @@ export type ArtistInput = Omit<ArtistRecord, "createdAt"> & { createdAt?: string
|
|
|
80
194
|
*/
|
|
81
195
|
export class Agent {
|
|
82
196
|
private idx?: RockskyIndex;
|
|
197
|
+
// PDS write throttle — off by default: single live scrobbles don't need it. An
|
|
198
|
+
// import (or any bulk writer) opts in via configureRateLimit().
|
|
199
|
+
private writeGate = new RateGate();
|
|
200
|
+
// Rocksky AppView matchSong throttle — ALWAYS on. A self-hosted PDS grants no
|
|
201
|
+
// extra AppView capacity, so this is never disabled, only tuned.
|
|
202
|
+
private matchGate = new RateGate();
|
|
203
|
+
private matchSongPerHour = DEFAULT_MATCH_SONG_PER_HOUR;
|
|
204
|
+
|
|
205
|
+
/** Lower-case hostname of the account's resolved PDS (e.g. "pds.example.com"). */
|
|
206
|
+
readonly pdsHost: string;
|
|
83
207
|
|
|
84
208
|
private constructor(
|
|
85
209
|
private rpc: Client,
|
|
86
210
|
readonly did: string,
|
|
87
211
|
readonly session: PasswordSession,
|
|
88
212
|
private pds: string,
|
|
89
|
-
) {
|
|
213
|
+
) {
|
|
214
|
+
this.pdsHost = hostOf(pds);
|
|
215
|
+
this.matchGate.setRate(this.matchSongPerHour);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Whether the account lives on the official Bluesky PDS (*.bsky.network).
|
|
220
|
+
* Its write budget is enforced server-side, so the client-side throttle can
|
|
221
|
+
* never be disabled for these hosts.
|
|
222
|
+
*/
|
|
223
|
+
get isOfficialBlueskyPds(): boolean {
|
|
224
|
+
return this.pdsHost === "bsky.network" || this.pdsHost.endsWith(".bsky.network");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Configure the client-side write throttle and return the effective state.
|
|
229
|
+
*
|
|
230
|
+
* Policy — the *.bsky.network guard is authoritative and cannot be bypassed:
|
|
231
|
+
* - `disabled: true` turns the throttle off on a self-hosted PDS, but on the
|
|
232
|
+
* official Bluesky PDS it is ignored and the throttle stays on at the safe
|
|
233
|
+
* rate (`forcedOn: true`).
|
|
234
|
+
* - `writesPerHour` is honored as given on a self-hosted PDS, but clamped to
|
|
235
|
+
* {@link MAX_SAFE_WRITES_PER_HOUR} on the official Bluesky PDS
|
|
236
|
+
* (`capped: true` when clamped).
|
|
237
|
+
* - Omitting both enables the throttle at the safe default rate.
|
|
238
|
+
*/
|
|
239
|
+
configureRateLimit(opts: RateLimitOptions = {}): RateLimitState {
|
|
240
|
+
// matchSong throttle is always enforced — `disabled` never touches it. A
|
|
241
|
+
// positive `matchSongPerHour` retunes it; anything else keeps the current rate.
|
|
242
|
+
if (opts.matchSongPerHour !== undefined && opts.matchSongPerHour > 0) {
|
|
243
|
+
this.matchSongPerHour = opts.matchSongPerHour;
|
|
244
|
+
this.matchGate.setRate(this.matchSongPerHour);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const official = this.isOfficialBlueskyPds;
|
|
248
|
+
const state = (partial: Omit<RateLimitState, "pdsHost" | "matchSongPerHour">): RateLimitState => ({
|
|
249
|
+
...partial,
|
|
250
|
+
pdsHost: this.pdsHost,
|
|
251
|
+
matchSongPerHour: this.matchSongPerHour,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
if (opts.disabled) {
|
|
255
|
+
if (official) {
|
|
256
|
+
// Guard: never let the write throttle be turned off on the official
|
|
257
|
+
// Bluesky PDS — that only earns 429s and risks account-level throttling.
|
|
258
|
+
const writesPerHour = Math.min(
|
|
259
|
+
opts.writesPerHour ?? MAX_SAFE_WRITES_PER_HOUR,
|
|
260
|
+
MAX_SAFE_WRITES_PER_HOUR,
|
|
261
|
+
);
|
|
262
|
+
this.writeGate.setRate(writesPerHour);
|
|
263
|
+
return state({ enabled: true, writesPerHour, forcedOn: true, capped: false });
|
|
264
|
+
}
|
|
265
|
+
this.writeGate.setRate(null);
|
|
266
|
+
return state({ enabled: false, writesPerHour: Infinity, forcedOn: false, capped: false });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let writesPerHour = opts.writesPerHour ?? MAX_SAFE_WRITES_PER_HOUR;
|
|
270
|
+
if (!(writesPerHour > 0)) writesPerHour = MAX_SAFE_WRITES_PER_HOUR; // reject 0 / NaN / negatives
|
|
271
|
+
let capped = false;
|
|
272
|
+
if (official && writesPerHour > MAX_SAFE_WRITES_PER_HOUR) {
|
|
273
|
+
writesPerHour = MAX_SAFE_WRITES_PER_HOUR;
|
|
274
|
+
capped = true;
|
|
275
|
+
}
|
|
276
|
+
this.writeGate.setRate(writesPerHour);
|
|
277
|
+
return state({ enabled: true, writesPerHour, forcedOn: false, capped });
|
|
278
|
+
}
|
|
90
279
|
|
|
91
280
|
/**
|
|
92
281
|
* Resolve the account's PDS, authenticate with an app password, and return an
|
|
@@ -132,6 +321,7 @@ export class Agent {
|
|
|
132
321
|
}
|
|
133
322
|
|
|
134
323
|
private async create(collection: string, record: Record<string, unknown>): Promise<string> {
|
|
324
|
+
await this.writeGate.take();
|
|
135
325
|
const res = await this.rpc.post("com.atproto.repo.createRecord" as never, {
|
|
136
326
|
input: { repo: this.did, collection, record: { ...record, $type: collection } },
|
|
137
327
|
} as never);
|
|
@@ -140,6 +330,7 @@ export class Agent {
|
|
|
140
330
|
}
|
|
141
331
|
|
|
142
332
|
private async putRecord(collection: string, rkey: string, record: Record<string, unknown>): Promise<string> {
|
|
333
|
+
await this.writeGate.take();
|
|
143
334
|
const res = await this.rpc.post("com.atproto.repo.putRecord" as never, {
|
|
144
335
|
input: { repo: this.did, collection, rkey, record: { ...record, $type: collection } },
|
|
145
336
|
} as never);
|
|
@@ -149,6 +340,7 @@ export class Agent {
|
|
|
149
340
|
|
|
150
341
|
/** Delete a record by collection + rkey. */
|
|
151
342
|
async delete(collection: string, rkey: string): Promise<void> {
|
|
343
|
+
await this.writeGate.take();
|
|
152
344
|
const res = await this.rpc.post("com.atproto.repo.deleteRecord" as never, {
|
|
153
345
|
input: { repo: this.did, collection, rkey },
|
|
154
346
|
} as never);
|
|
@@ -223,6 +415,9 @@ export class Agent {
|
|
|
223
415
|
async scrobbleMatch(input: ScrobbleMatchInput, appview?: string): Promise<string> {
|
|
224
416
|
const { title, artist, album, mbId, isrc, timestamp } = input;
|
|
225
417
|
const { RockskyClient } = await import("./client.js");
|
|
418
|
+
// matchSong hits the shared Rocksky AppView; always throttle it, regardless
|
|
419
|
+
// of the write-throttle policy (a self-hosted PDS grants no AppView capacity).
|
|
420
|
+
await this.matchGate.take();
|
|
226
421
|
const m = (await new RockskyClient(appview).matchSong(title, artist, mbId, isrc)) as Record<
|
|
227
422
|
string,
|
|
228
423
|
unknown
|
package/src/index.ts
CHANGED
|
@@ -13,11 +13,15 @@ export type { DateInterval } from "./client.js";
|
|
|
13
13
|
export { RockskyLibrary } from "./library.js";
|
|
14
14
|
export {
|
|
15
15
|
Agent,
|
|
16
|
+
MAX_SAFE_WRITES_PER_HOUR,
|
|
17
|
+
DEFAULT_MATCH_SONG_PER_HOUR,
|
|
16
18
|
type ScrobbleInput,
|
|
17
19
|
type ScrobbleMatchInput,
|
|
18
20
|
type SongInput,
|
|
19
21
|
type AlbumInput,
|
|
20
22
|
type ArtistInput,
|
|
23
|
+
type RateLimitOptions,
|
|
24
|
+
type RateLimitState,
|
|
21
25
|
} from "./agent.js";
|
|
22
26
|
export { RockskyIndex, totalIndexed, type IndexStats } from "./dedup.js";
|
|
23
27
|
export { runJetstream, DEFAULT_JETSTREAM_SERVERS, type JetstreamOptions } from "./jetstream.js";
|