birdclaw 0.8.1 → 0.8.3
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/CHANGELOG.md +25 -0
- package/package.json +2 -4
- package/scripts/browser-perf.mjs +27 -0
- package/src/cli/command-context.ts +17 -0
- package/src/cli/register-analysis.ts +500 -0
- package/src/cli/register-compose.ts +40 -0
- package/src/cli/register-graph.ts +132 -0
- package/src/cli/register-inbox.ts +41 -0
- package/src/cli/register-storage.ts +106 -0
- package/src/cli.ts +30 -750
- package/src/components/AccountSwitcher.tsx +7 -15
- package/src/components/AvatarChip.tsx +1 -1
- package/src/components/AvatarPreload.ts +149 -0
- package/src/components/ConversationThread.tsx +4 -0
- package/src/components/EmbeddedTweetCard.tsx +4 -0
- package/src/components/FloatingPreview.tsx +382 -0
- package/src/components/MarkdownCitations.tsx +680 -0
- package/src/components/MarkdownViewer.tsx +7 -715
- package/src/components/ProfileAnalysisClient.ts +191 -0
- package/src/components/ProfileAnalysisStream.tsx +16 -185
- package/src/components/ProfilePreview.tsx +41 -81
- package/src/components/TimelineCard.tsx +18 -2
- package/src/components/TweetArticleCard.tsx +66 -0
- package/src/components/TweetRichText.tsx +33 -2
- package/src/components/links-controller.ts +162 -0
- package/src/components/links-model.ts +198 -0
- package/src/components/network-map-controller.ts +84 -0
- package/src/components/network-map-model.ts +255 -0
- package/src/components/useDebouncedValue.ts +12 -0
- package/src/components/useTimelineRouteData.ts +142 -170
- package/src/lib/analysis-runtime.ts +238 -0
- package/src/lib/api-client.ts +16 -100
- package/src/lib/api-contracts.ts +328 -0
- package/src/lib/archive-finder.ts +38 -0
- package/src/lib/archive-import-plan.ts +102 -0
- package/src/lib/archive-import.ts +170 -239
- package/src/lib/authored-live.ts +77 -182
- package/src/lib/backup.ts +335 -424
- package/src/lib/bird.ts +32 -1
- package/src/lib/blocks-write.ts +30 -26
- package/src/lib/blocks.ts +18 -20
- package/src/lib/database-metrics.ts +88 -0
- package/src/lib/database-migrations.ts +34 -0
- package/src/lib/database-schema.ts +312 -0
- package/src/lib/database-writer.ts +69 -0
- package/src/lib/db.ts +141 -334
- package/src/lib/dm-read-model.ts +533 -0
- package/src/lib/dms-live.ts +34 -97
- package/src/lib/follow-graph.ts +17 -27
- package/src/lib/import-repository.ts +138 -0
- package/src/lib/inbox.ts +2 -1
- package/src/lib/live-sync-engine.ts +209 -0
- package/src/lib/live-transport-gateway.ts +128 -0
- package/src/lib/mention-threads-live.ts +90 -176
- package/src/lib/mentions-export.ts +1 -1
- package/src/lib/mentions-live.ts +57 -225
- package/src/lib/moderation-target.ts +15 -4
- package/src/lib/moderation-write.ts +1 -1
- package/src/lib/mutes-write.ts +30 -26
- package/src/lib/openai-response-runtime.ts +251 -0
- package/src/lib/paginated-sync.ts +93 -0
- package/src/lib/period-digest.ts +116 -304
- package/src/lib/profile-analysis.ts +37 -111
- package/src/lib/queries.ts +6 -2380
- package/src/lib/query-actions.ts +437 -0
- package/src/lib/query-client.tsx +47 -0
- package/src/lib/query-read-model-shared.ts +52 -0
- package/src/lib/query-read-models.ts +5 -0
- package/src/lib/query-resource.ts +41 -0
- package/src/lib/query-status.ts +164 -0
- package/src/lib/research.ts +1 -1
- package/src/lib/runtime-services.ts +20 -0
- package/src/lib/search-discussion.ts +75 -279
- package/src/lib/server-runtime-services.ts +30 -0
- package/src/lib/sqlite.ts +53 -14
- package/src/lib/streaming-ingestion.ts +240 -0
- package/src/lib/sync-cache.ts +6 -1
- package/src/lib/sync-plan.ts +175 -0
- package/src/lib/timeline-collections-live.ts +83 -256
- package/src/lib/timeline-live.ts +86 -235
- package/src/lib/timeline-read-model.ts +1191 -0
- package/src/lib/tweet-render.ts +78 -0
- package/src/lib/tweet-repository.ts +156 -0
- package/src/lib/tweet-search-live.ts +63 -166
- package/src/lib/types.ts +8 -0
- package/src/lib/ui.ts +1 -1
- package/src/lib/web-sync.ts +67 -50
- package/src/lib/whois.ts +2 -1
- package/src/router.tsx +1 -1
- package/src/routes/__root.tsx +11 -21
- package/src/routes/api/action.tsx +1 -1
- package/src/routes/api/conversation.tsx +1 -1
- package/src/routes/api/query.tsx +32 -26
- package/src/routes/api/status.tsx +6 -2
- package/src/routes/api/sync.tsx +5 -2
- package/src/routes/blocks.tsx +97 -131
- package/src/routes/data-sources.tsx +17 -25
- package/src/routes/dms.tsx +168 -167
- package/src/routes/inbox.tsx +63 -57
- package/src/routes/links.tsx +31 -329
- package/src/routes/network-map.tsx +41 -344
- package/src/routes/rate-limits.tsx +17 -21
- package/vite.config.ts +0 -2
package/src/lib/sqlite.ts
CHANGED
|
@@ -5,6 +5,8 @@ export type Database = NativeSqliteDatabase;
|
|
|
5
5
|
type DatabaseOptions = {
|
|
6
6
|
readonly?: boolean;
|
|
7
7
|
fileMustExist?: boolean;
|
|
8
|
+
timeout?: number;
|
|
9
|
+
onStatement?: (sql: string, durationMs: number) => void;
|
|
8
10
|
};
|
|
9
11
|
|
|
10
12
|
type PragmaOptions = {
|
|
@@ -16,6 +18,8 @@ type RunResult = {
|
|
|
16
18
|
lastInsertRowid: number;
|
|
17
19
|
};
|
|
18
20
|
|
|
21
|
+
export const SQLITE_BUSY_TIMEOUT_MS = 30_000;
|
|
22
|
+
|
|
19
23
|
function bindArgs(parameters: unknown[]) {
|
|
20
24
|
if (parameters.length === 1 && Array.isArray(parameters[0])) {
|
|
21
25
|
return parameters[0];
|
|
@@ -50,44 +54,75 @@ function normalizeRow(row: unknown): unknown {
|
|
|
50
54
|
class NativeSqliteStatement {
|
|
51
55
|
readonly reader: boolean;
|
|
52
56
|
|
|
53
|
-
constructor(
|
|
57
|
+
constructor(
|
|
58
|
+
private readonly statement: StatementSync,
|
|
59
|
+
private readonly sql: string,
|
|
60
|
+
private readonly onStatement?: (sql: string, durationMs: number) => void,
|
|
61
|
+
) {
|
|
54
62
|
this.reader = statement.columns().length > 0;
|
|
55
63
|
}
|
|
56
64
|
|
|
65
|
+
private track<T>(operation: () => T) {
|
|
66
|
+
const startedAt = performance.now();
|
|
67
|
+
try {
|
|
68
|
+
return operation();
|
|
69
|
+
} finally {
|
|
70
|
+
this.onStatement?.(this.sql, performance.now() - startedAt);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
57
74
|
all(...parameters: unknown[]): unknown[] {
|
|
58
|
-
return this.
|
|
75
|
+
return this.track(() =>
|
|
76
|
+
this.statement.all(...bindArgs(parameters)).map(normalizeRow),
|
|
77
|
+
);
|
|
59
78
|
}
|
|
60
79
|
|
|
61
80
|
get(...parameters: unknown[]): unknown {
|
|
62
|
-
return
|
|
81
|
+
return this.track(() =>
|
|
82
|
+
normalizeRow(this.statement.get(...bindArgs(parameters))),
|
|
83
|
+
);
|
|
63
84
|
}
|
|
64
85
|
|
|
65
86
|
run(...parameters: unknown[]): RunResult {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
87
|
+
return this.track(() => {
|
|
88
|
+
const result = this.statement.run(...bindArgs(parameters));
|
|
89
|
+
return {
|
|
90
|
+
changes: Number(result.changes),
|
|
91
|
+
lastInsertRowid: Number(result.lastInsertRowid),
|
|
92
|
+
};
|
|
93
|
+
});
|
|
71
94
|
}
|
|
72
95
|
|
|
73
96
|
iterate(...parameters: unknown[]): IterableIterator<unknown> {
|
|
74
97
|
const rows = this.statement.iterate(...bindArgs(parameters));
|
|
98
|
+
const startedAt = performance.now();
|
|
99
|
+
const onStatement = this.onStatement;
|
|
100
|
+
const sql = this.sql;
|
|
75
101
|
return (function* () {
|
|
76
|
-
|
|
77
|
-
|
|
102
|
+
try {
|
|
103
|
+
for (const row of rows) {
|
|
104
|
+
yield normalizeRow(row);
|
|
105
|
+
}
|
|
106
|
+
} finally {
|
|
107
|
+
onStatement?.(sql, performance.now() - startedAt);
|
|
78
108
|
}
|
|
79
109
|
})();
|
|
80
110
|
}
|
|
81
111
|
}
|
|
82
112
|
|
|
83
113
|
export class NativeSqliteDatabase {
|
|
114
|
+
readonly writeIdentity: string | object;
|
|
84
115
|
private transactionDepth = 0;
|
|
85
116
|
private readonly db: DatabaseSync;
|
|
86
117
|
|
|
87
|
-
constructor(
|
|
118
|
+
constructor(
|
|
119
|
+
path: string,
|
|
120
|
+
private readonly options: DatabaseOptions = {},
|
|
121
|
+
) {
|
|
122
|
+
this.writeIdentity = path === ":memory:" ? this : path;
|
|
88
123
|
this.db = new DatabaseSync(path, {
|
|
89
124
|
readOnly: options.readonly,
|
|
90
|
-
timeout:
|
|
125
|
+
timeout: options.timeout ?? SQLITE_BUSY_TIMEOUT_MS,
|
|
91
126
|
});
|
|
92
127
|
}
|
|
93
128
|
|
|
@@ -114,7 +149,11 @@ export class NativeSqliteDatabase {
|
|
|
114
149
|
}
|
|
115
150
|
|
|
116
151
|
prepare(sql: string): NativeSqliteStatement {
|
|
117
|
-
return new NativeSqliteStatement(
|
|
152
|
+
return new NativeSqliteStatement(
|
|
153
|
+
this.db.prepare(sql),
|
|
154
|
+
sql,
|
|
155
|
+
this.options.onStatement,
|
|
156
|
+
);
|
|
118
157
|
}
|
|
119
158
|
|
|
120
159
|
transaction<TArgs extends unknown[], TResult>(
|
|
@@ -123,7 +162,7 @@ export class NativeSqliteDatabase {
|
|
|
123
162
|
return (...args: TArgs) => {
|
|
124
163
|
const nested = this.db.isTransaction;
|
|
125
164
|
const savepoint = `__birdclaw_tx_${++this.transactionDepth}`;
|
|
126
|
-
this.exec(nested ? `savepoint ${savepoint}` : "begin");
|
|
165
|
+
this.exec(nested ? `savepoint ${savepoint}` : "begin immediate");
|
|
127
166
|
try {
|
|
128
167
|
const result = fn(...args);
|
|
129
168
|
this.exec(nested ? `release ${savepoint}` : "commit");
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
|
|
5
|
+
export interface IngestionCheckpoint {
|
|
6
|
+
processed: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface IngestionSource<T> {
|
|
10
|
+
id: string;
|
|
11
|
+
stream: () => AsyncIterable<T>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface IngestionSourceCheckpoint extends IngestionCheckpoint {
|
|
15
|
+
sourceId: string;
|
|
16
|
+
sourceIndex: number;
|
|
17
|
+
sourceProcessed: number;
|
|
18
|
+
sources: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function* streamJsonLines(
|
|
22
|
+
filePath: string,
|
|
23
|
+
): AsyncGenerator<{ lineNumber: number; value: Record<string, unknown> }> {
|
|
24
|
+
const input = createReadStream(filePath, { encoding: "utf8" });
|
|
25
|
+
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
26
|
+
let lineNumber = 0;
|
|
27
|
+
for await (const line of lines) {
|
|
28
|
+
lineNumber += 1;
|
|
29
|
+
if (!line.trim()) continue;
|
|
30
|
+
yield {
|
|
31
|
+
lineNumber,
|
|
32
|
+
value: JSON.parse(line) as Record<string, unknown>,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function* streamAssignedJsonArray(
|
|
38
|
+
source: AsyncIterable<Buffer | string>,
|
|
39
|
+
): AsyncGenerator<Record<string, unknown>> {
|
|
40
|
+
let started = false;
|
|
41
|
+
let item = "";
|
|
42
|
+
let depth = 0;
|
|
43
|
+
let inString = false;
|
|
44
|
+
let escaped = false;
|
|
45
|
+
|
|
46
|
+
const flush = () => {
|
|
47
|
+
const value = item.trim();
|
|
48
|
+
item = "";
|
|
49
|
+
return value ? (JSON.parse(value) as Record<string, unknown>) : undefined;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
for await (const chunk of source) {
|
|
53
|
+
for (const character of String(chunk)) {
|
|
54
|
+
if (!started) {
|
|
55
|
+
if (character === "[") started = true;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (inString) {
|
|
60
|
+
item += character;
|
|
61
|
+
if (escaped) {
|
|
62
|
+
escaped = false;
|
|
63
|
+
} else if (character === "\\") {
|
|
64
|
+
escaped = true;
|
|
65
|
+
} else if (character === '"') {
|
|
66
|
+
inString = false;
|
|
67
|
+
}
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (character === '"') {
|
|
72
|
+
inString = true;
|
|
73
|
+
item += character;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (character === "{" || character === "[") {
|
|
77
|
+
depth += 1;
|
|
78
|
+
item += character;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (character === "}" || (character === "]" && depth > 0)) {
|
|
82
|
+
depth -= 1;
|
|
83
|
+
item += character;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (character === "]" && depth === 0) {
|
|
87
|
+
const value = flush();
|
|
88
|
+
if (value) yield value;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (character === "," && depth === 0) {
|
|
92
|
+
const value = flush();
|
|
93
|
+
if (value) yield value;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (item.length > 0 || !/\s/.test(character)) {
|
|
97
|
+
item += character;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!started) return;
|
|
103
|
+
const value = flush();
|
|
104
|
+
if (value) yield value;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function ingestStreamInBatchesEffect<T>({
|
|
108
|
+
batchSize = 250,
|
|
109
|
+
onCheckpoint,
|
|
110
|
+
processBatch,
|
|
111
|
+
resumeAfter = 0,
|
|
112
|
+
source,
|
|
113
|
+
}: {
|
|
114
|
+
batchSize?: number;
|
|
115
|
+
onCheckpoint?: (checkpoint: IngestionCheckpoint) => void | Promise<void>;
|
|
116
|
+
processBatch: (
|
|
117
|
+
batch: T[],
|
|
118
|
+
checkpoint: IngestionCheckpoint,
|
|
119
|
+
) => void | Promise<void>;
|
|
120
|
+
resumeAfter?: number;
|
|
121
|
+
source: () => AsyncIterable<T>;
|
|
122
|
+
}): Effect.Effect<IngestionCheckpoint, unknown> {
|
|
123
|
+
return Effect.tryPromise({
|
|
124
|
+
try: async () => {
|
|
125
|
+
const normalizedBatchSize = Math.max(1, Math.trunc(batchSize));
|
|
126
|
+
const normalizedResumeAfter = Math.max(0, Math.trunc(resumeAfter));
|
|
127
|
+
let visited = 0;
|
|
128
|
+
let processed = normalizedResumeAfter;
|
|
129
|
+
let batch: T[] = [];
|
|
130
|
+
|
|
131
|
+
const flush = async () => {
|
|
132
|
+
if (batch.length === 0) return;
|
|
133
|
+
processed += batch.length;
|
|
134
|
+
const checkpoint = { processed };
|
|
135
|
+
await processBatch(batch, checkpoint);
|
|
136
|
+
await onCheckpoint?.(checkpoint);
|
|
137
|
+
batch = [];
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
for await (const item of source()) {
|
|
141
|
+
visited += 1;
|
|
142
|
+
if (visited <= normalizedResumeAfter) continue;
|
|
143
|
+
batch.push(item);
|
|
144
|
+
if (batch.length >= normalizedBatchSize) {
|
|
145
|
+
await flush();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
await flush();
|
|
149
|
+
return { processed };
|
|
150
|
+
},
|
|
151
|
+
catch: (error) =>
|
|
152
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function ingestSourcesInBatchesEffect<T>({
|
|
157
|
+
batchSize,
|
|
158
|
+
onCheckpoint,
|
|
159
|
+
onSourceComplete,
|
|
160
|
+
processBatch,
|
|
161
|
+
sources,
|
|
162
|
+
}: {
|
|
163
|
+
batchSize?: number;
|
|
164
|
+
onCheckpoint?: (
|
|
165
|
+
checkpoint: IngestionSourceCheckpoint,
|
|
166
|
+
) => void | Promise<void>;
|
|
167
|
+
onSourceComplete?: (
|
|
168
|
+
checkpoint: IngestionSourceCheckpoint,
|
|
169
|
+
) => void | Promise<void>;
|
|
170
|
+
processBatch: (
|
|
171
|
+
batch: T[],
|
|
172
|
+
checkpoint: IngestionSourceCheckpoint,
|
|
173
|
+
) => void | Promise<void>;
|
|
174
|
+
sources: IngestionSource<T>[];
|
|
175
|
+
}): Effect.Effect<IngestionCheckpoint, unknown> {
|
|
176
|
+
return Effect.gen(function* () {
|
|
177
|
+
let processed = 0;
|
|
178
|
+
|
|
179
|
+
for (const [sourceIndex, source] of sources.entries()) {
|
|
180
|
+
const sourceResult = yield* ingestStreamInBatchesEffect({
|
|
181
|
+
batchSize,
|
|
182
|
+
source: source.stream,
|
|
183
|
+
processBatch: (batch, checkpoint) =>
|
|
184
|
+
processBatch(batch, {
|
|
185
|
+
...checkpoint,
|
|
186
|
+
processed: processed + checkpoint.processed,
|
|
187
|
+
sourceId: source.id,
|
|
188
|
+
sourceIndex,
|
|
189
|
+
sourceProcessed: checkpoint.processed,
|
|
190
|
+
sources: sources.length,
|
|
191
|
+
}),
|
|
192
|
+
onCheckpoint: onCheckpoint
|
|
193
|
+
? (checkpoint) =>
|
|
194
|
+
onCheckpoint({
|
|
195
|
+
...checkpoint,
|
|
196
|
+
processed: processed + checkpoint.processed,
|
|
197
|
+
sourceId: source.id,
|
|
198
|
+
sourceIndex,
|
|
199
|
+
sourceProcessed: checkpoint.processed,
|
|
200
|
+
sources: sources.length,
|
|
201
|
+
})
|
|
202
|
+
: undefined,
|
|
203
|
+
});
|
|
204
|
+
processed += sourceResult.processed;
|
|
205
|
+
yield* Effect.tryPromise({
|
|
206
|
+
try: () =>
|
|
207
|
+
Promise.resolve(
|
|
208
|
+
onSourceComplete?.({
|
|
209
|
+
processed,
|
|
210
|
+
sourceId: source.id,
|
|
211
|
+
sourceIndex,
|
|
212
|
+
sourceProcessed: sourceResult.processed,
|
|
213
|
+
sources: sources.length,
|
|
214
|
+
}),
|
|
215
|
+
),
|
|
216
|
+
catch: (error) =>
|
|
217
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { processed };
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function collectIngestionSourcesEffect<T>(
|
|
226
|
+
sources: IngestionSource<T>[],
|
|
227
|
+
options: { batchSize?: number } = {},
|
|
228
|
+
): Effect.Effect<T[], unknown> {
|
|
229
|
+
return Effect.gen(function* () {
|
|
230
|
+
const rows: T[] = [];
|
|
231
|
+
yield* ingestSourcesInBatchesEffect({
|
|
232
|
+
...options,
|
|
233
|
+
sources,
|
|
234
|
+
processBatch: (batch) => {
|
|
235
|
+
rows.push(...batch);
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
return rows;
|
|
239
|
+
});
|
|
240
|
+
}
|
package/src/lib/sync-cache.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { getNativeDb } from "./db";
|
|
2
|
+
import {
|
|
3
|
+
defaultServerRuntimeServices,
|
|
4
|
+
type ServerRuntimeServices,
|
|
5
|
+
} from "./server-runtime-services";
|
|
2
6
|
|
|
3
7
|
export interface SyncCacheEntry<T> {
|
|
4
8
|
value: T;
|
|
@@ -45,8 +49,9 @@ export function writeSyncCache(
|
|
|
45
49
|
cacheKey: string,
|
|
46
50
|
value: unknown,
|
|
47
51
|
db = getNativeDb(),
|
|
52
|
+
runtime: ServerRuntimeServices = defaultServerRuntimeServices,
|
|
48
53
|
) {
|
|
49
|
-
const updatedAt =
|
|
54
|
+
const updatedAt = runtime.now().toISOString();
|
|
50
55
|
db.prepare(
|
|
51
56
|
`
|
|
52
57
|
insert into sync_cache (cache_key, value_json, updated_at)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
export type PaginationStopReason =
|
|
4
|
+
| "boundary"
|
|
5
|
+
| "exhausted"
|
|
6
|
+
| "item-limit"
|
|
7
|
+
| "page-limit"
|
|
8
|
+
| "repeated-cursor";
|
|
9
|
+
|
|
10
|
+
export interface PaginationPageContext<Page> {
|
|
11
|
+
cursor?: string;
|
|
12
|
+
fetched: number;
|
|
13
|
+
page: Page;
|
|
14
|
+
pageIndex: number;
|
|
15
|
+
pageNumber: number;
|
|
16
|
+
stopReason?: PaginationStopReason;
|
|
17
|
+
done: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type SyncPlanStopReason = PaginationStopReason | "error";
|
|
21
|
+
|
|
22
|
+
export interface SyncPlanPageContext<Page> extends Omit<
|
|
23
|
+
PaginationPageContext<Page>,
|
|
24
|
+
"stopReason"
|
|
25
|
+
> {
|
|
26
|
+
nextCursor?: string;
|
|
27
|
+
stopReason?: SyncPlanStopReason;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SyncPlanResult<Page, ErrorType> {
|
|
31
|
+
complete: boolean;
|
|
32
|
+
fetched: number;
|
|
33
|
+
nextCursor?: string;
|
|
34
|
+
pages: Page[];
|
|
35
|
+
stopReason: SyncPlanStopReason;
|
|
36
|
+
error?: ErrorType;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeCursor(value: string | null | undefined) {
|
|
40
|
+
const normalized = value?.trim();
|
|
41
|
+
return normalized || undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function runSyncPlanEffect<Page, FetchError, PageError = never>({
|
|
45
|
+
fetchPage,
|
|
46
|
+
getItemCount,
|
|
47
|
+
getNextCursor,
|
|
48
|
+
initialCursor,
|
|
49
|
+
maxItems,
|
|
50
|
+
maxPages,
|
|
51
|
+
onPage,
|
|
52
|
+
pageDelayMs,
|
|
53
|
+
persistPage,
|
|
54
|
+
shouldStop,
|
|
55
|
+
allowPartialFailure = false,
|
|
56
|
+
}: {
|
|
57
|
+
fetchPage: (context: {
|
|
58
|
+
cursor?: string;
|
|
59
|
+
fetched: number;
|
|
60
|
+
pageIndex: number;
|
|
61
|
+
}) => Effect.Effect<Page, FetchError>;
|
|
62
|
+
getItemCount?: (page: Page) => number;
|
|
63
|
+
getNextCursor: (page: Page) => string | null | undefined;
|
|
64
|
+
initialCursor?: string;
|
|
65
|
+
maxItems?: number;
|
|
66
|
+
maxPages?: number;
|
|
67
|
+
onPage?: (context: SyncPlanPageContext<Page>) => void;
|
|
68
|
+
pageDelayMs?: number;
|
|
69
|
+
persistPage?: (
|
|
70
|
+
context: SyncPlanPageContext<Page>,
|
|
71
|
+
) => Effect.Effect<void, PageError>;
|
|
72
|
+
shouldStop?: (
|
|
73
|
+
context: Omit<SyncPlanPageContext<Page>, "done" | "nextCursor">,
|
|
74
|
+
) => boolean;
|
|
75
|
+
allowPartialFailure?: boolean;
|
|
76
|
+
}): Effect.Effect<SyncPlanResult<Page, FetchError>, FetchError | PageError> {
|
|
77
|
+
return Effect.gen(function* () {
|
|
78
|
+
const pages: Page[] = [];
|
|
79
|
+
const seenCursors = new Set<string>();
|
|
80
|
+
let cursor = normalizeCursor(initialCursor);
|
|
81
|
+
let fetched = 0;
|
|
82
|
+
const pageLimit =
|
|
83
|
+
maxPages === undefined ? Number.POSITIVE_INFINITY : Math.max(1, maxPages);
|
|
84
|
+
const itemLimit =
|
|
85
|
+
maxItems === undefined ? Number.POSITIVE_INFINITY : Math.max(1, maxItems);
|
|
86
|
+
|
|
87
|
+
if (cursor) seenCursors.add(cursor);
|
|
88
|
+
|
|
89
|
+
while (pages.length < pageLimit) {
|
|
90
|
+
const pageIndex = pages.length;
|
|
91
|
+
const outcome = yield* fetchPage({ cursor, fetched, pageIndex }).pipe(
|
|
92
|
+
Effect.map((page) => ({ ok: true as const, page })),
|
|
93
|
+
Effect.catchAll((error) =>
|
|
94
|
+
Effect.succeed({ ok: false as const, error }),
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
if (!outcome.ok) {
|
|
98
|
+
if (!allowPartialFailure || pages.length === 0) {
|
|
99
|
+
return yield* Effect.fail(outcome.error);
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
complete: false,
|
|
103
|
+
fetched,
|
|
104
|
+
...(cursor ? { nextCursor: cursor } : {}),
|
|
105
|
+
pages,
|
|
106
|
+
stopReason: "error",
|
|
107
|
+
error: outcome.error,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const page = outcome.page;
|
|
112
|
+
pages.push(page);
|
|
113
|
+
fetched += Math.max(0, getItemCount?.(page) ?? 0);
|
|
114
|
+
const nextCursor = normalizeCursor(getNextCursor(page));
|
|
115
|
+
const baseContext = {
|
|
116
|
+
cursor,
|
|
117
|
+
fetched,
|
|
118
|
+
page,
|
|
119
|
+
pageIndex,
|
|
120
|
+
pageNumber: pageIndex + 1,
|
|
121
|
+
};
|
|
122
|
+
let stopReason: PaginationStopReason | undefined;
|
|
123
|
+
|
|
124
|
+
if (!nextCursor) {
|
|
125
|
+
stopReason = "exhausted";
|
|
126
|
+
} else if (shouldStop?.(baseContext)) {
|
|
127
|
+
stopReason = "boundary";
|
|
128
|
+
} else if (fetched >= itemLimit) {
|
|
129
|
+
stopReason = "item-limit";
|
|
130
|
+
} else if (pages.length >= pageLimit) {
|
|
131
|
+
stopReason = "page-limit";
|
|
132
|
+
} else if (seenCursors.has(nextCursor)) {
|
|
133
|
+
stopReason = "repeated-cursor";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const context: SyncPlanPageContext<Page> = {
|
|
137
|
+
...baseContext,
|
|
138
|
+
...(nextCursor ? { nextCursor } : {}),
|
|
139
|
+
done: Boolean(stopReason),
|
|
140
|
+
...(stopReason ? { stopReason } : {}),
|
|
141
|
+
};
|
|
142
|
+
if (persistPage) {
|
|
143
|
+
yield* persistPage(context);
|
|
144
|
+
}
|
|
145
|
+
onPage?.(context);
|
|
146
|
+
|
|
147
|
+
if (stopReason) {
|
|
148
|
+
return {
|
|
149
|
+
complete:
|
|
150
|
+
stopReason === "exhausted" ||
|
|
151
|
+
stopReason === "boundary" ||
|
|
152
|
+
stopReason === "item-limit",
|
|
153
|
+
fetched,
|
|
154
|
+
...(nextCursor ? { nextCursor } : {}),
|
|
155
|
+
pages,
|
|
156
|
+
stopReason,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
cursor = nextCursor;
|
|
161
|
+
seenCursors.add(nextCursor!);
|
|
162
|
+
if (typeof pageDelayMs === "number" && pageDelayMs > 0) {
|
|
163
|
+
yield* Effect.sleep(pageDelayMs);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
complete: false,
|
|
169
|
+
fetched,
|
|
170
|
+
...(cursor ? { nextCursor: cursor } : {}),
|
|
171
|
+
pages,
|
|
172
|
+
stopReason: "page-limit",
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
}
|