···1212This importer automatically protects your PDS by:
1313- Limiting imports to **1,000 records per day** (with 75% safety margin)
1414- Calculating optimal batch sizes and delays
1515-- Pausing 24 hours between days for large imports
1515+- Automatically waiting for rate limit resets when limits are hit
1616- Providing clear progress tracking and time estimates
17171818For more details, see the [Bluesky Rate Limits Documentation](https://docs.bsky.app/blog/rate-limits-pds-v3).
···5858- ✅ **Input Deduplication**: Removes duplicate entries within the source file before submission
5959- ✅ **Batch Operations**: Uses `com.atproto.repo.applyWrites` for efficient batch publishing (up to 200 records per call)
6060- ✅ **Rate Limiting**: Automatic daily limits prevent PDS rate limiting
6161-- ✅ **Multi-Day Imports**: Large imports automatically span multiple days with 24-hour pauses
6161+- ✅ **Automatic Rate Limiting**: Waits for limit resets when daily/hourly limits are reached
6262+- ✅ **Multi-Day Imports**: Large imports automatically span multiple days with automatic waits
6263- ✅ **Resume Support**: Safe to stop (Ctrl+C) and restart - continues from where it left off
6364- ✅ **Graceful Cancellation**: Press Ctrl+C to stop after the current batch completes
6465···4114123. Calculates optimal batch size and delay to spread records evenly
4124134. Enforces minimum delay between batches
4134145. Shows clear schedule before starting
415415+6. Logs waiting periods when rate limits are hit with duration
416416+417417+**Example rate-limit wait logging:**
418418+```
419419+ℹ Rate limit (hourly), waiting 23m 45s for reset
420420+ℹ Rate limit (daily), waiting 1h 12m 30s for reset
421421+```
414422415423### Multi-Day Imports
416424···4184261. **Calculates a schedule**: Splits your import across multiple days
4194272. **Shows the plan**: Displays which records will be imported each day
4204283. **Processes Day 1**: Imports the first batch of records
421421-4. **Pauses 24 hours**: Waits a full day before continuing
429429+4. **Waits for reset**: When limits are reached, waits for the hourly/daily reset
4224305. **Repeats**: Continues until all records are imported
423431424432**Example output for a 20,000 record import:**
425433```
426426-📊 Rate Limiting Information:
427427- Total records: 20,000
428428- Daily limit: 7,500 records/day
429429- Estimated duration: 3 days
430430- Batch size: 200 records
431431- Batch delay: 11.52s
434434+=== Batch Configuration ===
435435+ℹ Using auto-calculated batch size: 200 records
436436+ℹ Batch delay: 11520ms
437437+438438+=== Import Configuration ===
439439+ℹ Total records: 20,000
440440+ℹ Batch size: 200 records
441441+ℹ Batch delay: 11520ms
442442+ℹ Duration: 3 days (7,500 records/day limit)
443443+⚠️ Large import will span multiple days with automatic rate-limit waits
444444+445445+=== Publishing Records ===
446446+→ Processed batch 1-200 (0.0s, 173.2 rec/s, 2m 0s remaining)
447447+→ Processed batch 201-400 (2.0s, 100.0 rec/s, 1m 58s remaining)
448448+...
449449+ℹ Rate limit (hourly), waiting 45m 0s for reset
450450+→ Resuming after rate limit reset
451451+→ Processed batch 7801-8000 (45m 0s, 2.9 rec/s, 4h 35m 50s remaining)
432452```
433453434454**Important notes:**
···446466- **Yellow (⚠️)**: Warnings
447467- **Red (✗)**: Errors
448468- **Bold Red (🛑)**: Fatal errors
469469+- **Blue (ℹ)**: Informational messages (including rate-limit waits)
470470+471471+### Rate-Limit Wait Messages
472472+473473+When the importer hits a rate limit and needs to wait, it logs a clear message:
474474+475475+```
476476+ℹ Rate limit (hourly), waiting 23m 45s for reset
477477+ℹ Rate limit (daily), waiting 1h 12m 30s for reset
478478+```
479479+480480+These messages use `formatDuration()` for human-readable duration display (e.g., `23m 45s`, `1h 12m 30s`). The wait reason indicates which limit was hit:
481481+- **hourly**: Hourly rate limit reached
482482+- **daily**: Daily rate limit reached
449483450484### Verbosity Levels
451485···471505- **Network errors**: Failed records are logged but don't stop the import
472506- **Invalid data**: Skipped with error messages
473507- **Authentication issues**: Clear error messages with suggested fixes
474474-- **Rate limit hits**: Automatic adjustment and retry logic
508508+- **Rate limit hits**: Automatic adjustment with logged backoff duration and retry logic
475509- **Ctrl+C handling**: Gracefully stops after current batch
476510477511## Troubleshooting
···489523### Performance Issues
490524491525**"Rate limit exceeded"**
492492-- The importer should prevent this automatically
493493-- If you see this, wait 24 hours before retrying
526526+- The importer handles this automatically by waiting for reset
527527+- Progress messages show wait duration when rate limits are hit
494528- Consider reducing batch size with `-b` flag
495529496530**Import seems stuck**
497531- Check progress messages - large imports take time
498498-- Multi-day imports pause for 24 hours between days
532532+- Rate-limit waits may occur between days
499533- You can safely stop (Ctrl+C) and resume later
500534- Use `--verbose` flag to see detailed progress
501535
···11/**
22 * TID (Timestamp Identifier) Clock for ATProto
33- *
33+ *
44 * Implements spec-compliant, monotonic TID generation with:
55 * - AT-Protocol format validation (13 chars, base32 alphabet)
66 * - Strict monotonicity guarantees (even under clock drift)
···88 * - Deterministic mode for dry-runs
99 * - Full observability (structured JSON logging)
1010 * - Collision resistance
1111- *
1111+ *
1212 * Based on AT-Protocol spec: https://atproto.com/specs/tid
1313 * Reference implementation: @atproto/common-web
1414 */
···2525 * TID validation error
2626 */
2727export class InvalidTidError extends Error {
2828- constructor(message: string, public tid?: string) {
2828+ constructor(
2929+ message: string,
3030+ public tid?: string,
3131+ ) {
2932 super(message);
3033 this.name = 'InvalidTidError';
3134 }
3235}
33363437/**
3535- * TID generation modes
3636- */
3737-export enum TidMode {
3838- PRODUCTION = 'production', // Real wall-clock time
3939- DRY_RUN = 'dry-run', // Deterministic with fixed seed
4040- REPLAY = 'replay', // Replay from logged state
4141-}
4242-4343-/**
4438 * Clock source abstraction for testability
4539 */
4640export interface ClockSource {
···6155 */
6256export class FakeClock implements ClockSource {
6357 constructor(private timestamp: number) {}
6464-5858+6559 now(): number {
6660 return this.timestamp;
6761 }
6868-6262+6963 advance(microseconds: number): void {
7064 this.timestamp += microseconds;
7165 }
7272-6666+7367 set(microseconds: number): void {
7468 this.timestamp = microseconds;
7569 }
···7973 * TID generator state (for persistence and logging)
8074 */
8175export interface TidState {
8282- lastTimestampUs: number; // Last generated timestamp in microseconds
8383- clockId: number; // Clock identifier (0-31 for this implementation)
8484- generatedCount: number; // Total TIDs generated
7676+ lastTimestampUs: number; // Last generated timestamp in microseconds
7777+ clockId: number; // Clock identifier (0-31 for this implementation)
7878+ generatedCount: number; // Total TIDs generated
8579}
86808781/**
···9185 tid: string;
9286 timestampUs: number;
9387 clockId: number;
9494- generatedAt: string; // ISO8601 with microseconds
8888+ generatedAt: string; // ISO8601 with microseconds
9589 validated: boolean;
9690 context?: string;
9791}
···106100}
107101108102/**
109109- * Default console-based logger
110110- */
111111-export class ConsoleTidLogger implements TidLogger {
112112- logGenerated(metadata: TidMetadata, opId: string): void {
113113- const entry = {
114114- level: 'INFO',
115115- op_id: opId,
116116- ts: metadata.generatedAt,
117117- event: 'tid.generated',
118118- tid: metadata.tid,
119119- clock_id: metadata.clockId,
120120- wall_ts_us: metadata.timestampUs,
121121- generator: 'tid-clock-v1',
122122- context: metadata.context || 'unknown',
123123- validated: metadata.validated,
124124- };
125125- console.log(JSON.stringify(entry));
126126- }
127127-128128- logWarning(message: string, details?: any): void {
129129- console.warn(JSON.stringify({
130130- level: 'WARN',
131131- event: 'tid.warning',
132132- message,
133133- ...details,
134134- }));
135135- }
136136-137137- logError(message: string, details?: any): void {
138138- console.error(JSON.stringify({
139139- level: 'ERROR',
140140- event: 'tid.error',
141141- message,
142142- ...details,
143143- }));
144144- }
145145-}
146146-147147-/**
148103 * Silent logger (for production when structured logging is handled elsewhere)
149104 */
150105export class SilentTidLogger implements TidLogger {
···170125 statePath?: string;
171126 clockId?: number;
172127 initialState?: Partial<TidState>;
173173- } = {}
128128+ } = {},
174129 ) {
175130 this.clock = clock;
176131 this.logger = logger;
···201156202157 /**
203158 * Generate next TID with monotonicity guarantees
204204- *
159159+ *
205160 * Per AT-Protocol spec and reference implementation:
206161 * - Use max(currentTime, lastTimestamp) to handle clock drift
207162 * - If same as last timestamp, increment the timestamp itself (acts as sequence)
···210165 async next(context?: string): Promise<string> {
211166 return this.withMutex(async () => {
212167 const currentTime = this.clock.now();
213213-168168+214169 // Take max of current time and last timestamp (handles backwards clock drift)
215170 let timestamp = Math.max(currentTime, this.state.lastTimestampUs);
216216-171171+217172 // If we're at the same timestamp, increment by 1 microsecond
218173 // This acts as our sequence counter while maintaining monotonicity
219174 if (timestamp === this.state.lastTimestampUs) {
···275230 */
276231 async fromDate(date: Date, context?: string): Promise<string> {
277232 const timestamp = date.getTime() * 1000; // Convert ms to µs
278278-233233+279234 return this.withMutex(async () => {
280235 // Ensure monotonicity: use max of input timestamp and last generated
281236 let finalTimestamp = Math.max(timestamp, this.state.lastTimestampUs);
282282-237237+283238 // If we're at the same timestamp, increment by 1 microsecond
284239 if (finalTimestamp === this.state.lastTimestampUs) {
285240 finalTimestamp = this.state.lastTimestampUs + 1;
+21-16
src/utils/tid.ts
···11/**
22 * TID (Timestamp Identifier) generation for ATProto
33- *
33+ *
44 * This module provides a high-level API for TID generation.
55 * For the full implementation with monotonicity guarantees, see tid-clock.ts
66- *
66+ *
77 * Based on: https://atproto.com/specs/tid
88 */
99···1616 * Initialize the global TID clock
1717 * Should be called once at application startup
1818 */
1919-export function initTidClock(options: {
2020- mode?: 'production' | 'dry-run';
2121- statePath?: string;
2222- seed?: number;
2323- clockId?: number;
2424-} = {}): void {
1919+export function initTidClock(
2020+ options: {
2121+ mode?: 'production' | 'dry-run';
2222+ statePath?: string;
2323+ seed?: number;
2424+ clockId?: number;
2525+ } = {},
2626+): void {
2527 const { mode = 'production', statePath, seed, clockId } = options;
26282729 if (mode === 'dry-run' && seed !== undefined) {
2830 // Deterministic clock for dry-run with fixed clockId for reproducibility
2929- globalClock = new TidClock(new FakeClock(seed), undefined, {
3131+ globalClock = new TidClock(new FakeClock(seed), undefined, {
3032 statePath,
3131- clockId: clockId ?? 0 // Use fixed clockId for deterministic TIDs
3333+ clockId: clockId ?? 0, // Use fixed clockId for deterministic TIDs
3234 });
3335 } else {
3436 // Production real-time clock
3535- globalClock = new TidClock(new RealClock(), undefined, { statePath, clockId });
3737+ globalClock = new TidClock(new RealClock(), undefined, {
3838+ statePath,
3939+ clockId,
4040+ });
3641 }
3742}
3843···49545055/**
5156 * Generate a TID from a Date object with monotonicity guarantees
5252- *
5757+ *
5358 * This is the recommended way to generate TIDs for historical records.
5459 * TIDs are guaranteed to be:
5560 * - Spec-compliant (13 chars, valid base32)
5661 * - Strictly monotonic (even if dates are out of order)
5762 * - Collision-free (duplicate detection)
5858- *
6363+ *
5964 * @param date - The date to generate a TID from
6065 * @param context - Optional context for logging (e.g., "inject:playlist")
6166 * @returns A valid 13-character TID string
···67726873/**
6974 * Generate a TID from an ISO 8601 timestamp string
7070- *
7575+ *
7176 * @param isoString - ISO 8601 formatted datetime string
7277 * @param context - Optional context for logging
7378 * @returns A valid 13-character TID string
···78837984/**
8085 * Generate next TID using current time
8181- *
8686+ *
8287 * @param context - Optional context for logging
8388 * @returns A valid 13-character TID string
8489 */
···89949095/**
9196 * Validate a TID string
9292- *
9797+ *
9398 * @param tid - The TID to validate
9499 * @returns true if valid, false otherwise
95100 */