birdclaw 0.8.2 → 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 +16 -0
- package/package.json +2 -1
- 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/MarkdownCitations.tsx +680 -0
- package/src/components/MarkdownViewer.tsx +8 -674
- package/src/components/ProfileAnalysisClient.ts +191 -0
- package/src/components/ProfileAnalysisStream.tsx +16 -185
- package/src/components/ProfilePreview.tsx +2 -0
- 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/useTimelineRouteData.ts +105 -235
- package/src/lib/analysis-runtime.ts +238 -0
- package/src/lib/api-client.ts +16 -215
- package/src/lib/api-contracts.ts +328 -0
- package/src/lib/archive-import-plan.ts +102 -0
- package/src/lib/archive-import.ts +170 -239
- package/src/lib/authored-live.ts +75 -120
- package/src/lib/backup.ts +335 -424
- 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 +84 -330
- 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 -177
- package/src/lib/mentions-export.ts +1 -1
- package/src/lib/mentions-live.ts +57 -181
- 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 +36 -110
- package/src/lib/queries.ts +6 -2381
- 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 +48 -12
- 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 -257
- package/src/lib/timeline-live.ts +86 -236
- package/src/lib/timeline-read-model.ts +1191 -0
- package/src/lib/tweet-repository.ts +156 -0
- package/src/lib/tweet-search-live.ts +63 -167
- package/src/lib/web-sync.ts +67 -50
- package/src/lib/whois.ts +2 -1
- package/src/routes/__root.tsx +11 -8
- 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 -4
- 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 +167 -184
- package/src/routes/inbox.tsx +63 -57
- package/src/routes/links.tsx +31 -394
- package/src/routes/network-map.tsx +41 -344
- package/src/routes/rate-limits.tsx +17 -21
- package/src/lib/client-cache.ts +0 -109
package/src/lib/sqlite.ts
CHANGED
|
@@ -6,6 +6,7 @@ type DatabaseOptions = {
|
|
|
6
6
|
readonly?: boolean;
|
|
7
7
|
fileMustExist?: boolean;
|
|
8
8
|
timeout?: number;
|
|
9
|
+
onStatement?: (sql: string, durationMs: number) => void;
|
|
9
10
|
};
|
|
10
11
|
|
|
11
12
|
type PragmaOptions = {
|
|
@@ -53,41 +54,72 @@ function normalizeRow(row: unknown): unknown {
|
|
|
53
54
|
class NativeSqliteStatement {
|
|
54
55
|
readonly reader: boolean;
|
|
55
56
|
|
|
56
|
-
constructor(
|
|
57
|
+
constructor(
|
|
58
|
+
private readonly statement: StatementSync,
|
|
59
|
+
private readonly sql: string,
|
|
60
|
+
private readonly onStatement?: (sql: string, durationMs: number) => void,
|
|
61
|
+
) {
|
|
57
62
|
this.reader = statement.columns().length > 0;
|
|
58
63
|
}
|
|
59
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
|
+
|
|
60
74
|
all(...parameters: unknown[]): unknown[] {
|
|
61
|
-
return this.
|
|
75
|
+
return this.track(() =>
|
|
76
|
+
this.statement.all(...bindArgs(parameters)).map(normalizeRow),
|
|
77
|
+
);
|
|
62
78
|
}
|
|
63
79
|
|
|
64
80
|
get(...parameters: unknown[]): unknown {
|
|
65
|
-
return
|
|
81
|
+
return this.track(() =>
|
|
82
|
+
normalizeRow(this.statement.get(...bindArgs(parameters))),
|
|
83
|
+
);
|
|
66
84
|
}
|
|
67
85
|
|
|
68
86
|
run(...parameters: unknown[]): RunResult {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
+
});
|
|
74
94
|
}
|
|
75
95
|
|
|
76
96
|
iterate(...parameters: unknown[]): IterableIterator<unknown> {
|
|
77
97
|
const rows = this.statement.iterate(...bindArgs(parameters));
|
|
98
|
+
const startedAt = performance.now();
|
|
99
|
+
const onStatement = this.onStatement;
|
|
100
|
+
const sql = this.sql;
|
|
78
101
|
return (function* () {
|
|
79
|
-
|
|
80
|
-
|
|
102
|
+
try {
|
|
103
|
+
for (const row of rows) {
|
|
104
|
+
yield normalizeRow(row);
|
|
105
|
+
}
|
|
106
|
+
} finally {
|
|
107
|
+
onStatement?.(sql, performance.now() - startedAt);
|
|
81
108
|
}
|
|
82
109
|
})();
|
|
83
110
|
}
|
|
84
111
|
}
|
|
85
112
|
|
|
86
113
|
export class NativeSqliteDatabase {
|
|
114
|
+
readonly writeIdentity: string | object;
|
|
87
115
|
private transactionDepth = 0;
|
|
88
116
|
private readonly db: DatabaseSync;
|
|
89
117
|
|
|
90
|
-
constructor(
|
|
118
|
+
constructor(
|
|
119
|
+
path: string,
|
|
120
|
+
private readonly options: DatabaseOptions = {},
|
|
121
|
+
) {
|
|
122
|
+
this.writeIdentity = path === ":memory:" ? this : path;
|
|
91
123
|
this.db = new DatabaseSync(path, {
|
|
92
124
|
readOnly: options.readonly,
|
|
93
125
|
timeout: options.timeout ?? SQLITE_BUSY_TIMEOUT_MS,
|
|
@@ -117,7 +149,11 @@ export class NativeSqliteDatabase {
|
|
|
117
149
|
}
|
|
118
150
|
|
|
119
151
|
prepare(sql: string): NativeSqliteStatement {
|
|
120
|
-
return new NativeSqliteStatement(
|
|
152
|
+
return new NativeSqliteStatement(
|
|
153
|
+
this.db.prepare(sql),
|
|
154
|
+
sql,
|
|
155
|
+
this.options.onStatement,
|
|
156
|
+
);
|
|
121
157
|
}
|
|
122
158
|
|
|
123
159
|
transaction<TArgs extends unknown[], TResult>(
|
|
@@ -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
|
+
}
|