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.
Files changed (103) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/package.json +2 -4
  3. package/scripts/browser-perf.mjs +27 -0
  4. package/src/cli/command-context.ts +17 -0
  5. package/src/cli/register-analysis.ts +500 -0
  6. package/src/cli/register-compose.ts +40 -0
  7. package/src/cli/register-graph.ts +132 -0
  8. package/src/cli/register-inbox.ts +41 -0
  9. package/src/cli/register-storage.ts +106 -0
  10. package/src/cli.ts +30 -750
  11. package/src/components/AccountSwitcher.tsx +7 -15
  12. package/src/components/AvatarChip.tsx +1 -1
  13. package/src/components/AvatarPreload.ts +149 -0
  14. package/src/components/ConversationThread.tsx +4 -0
  15. package/src/components/EmbeddedTweetCard.tsx +4 -0
  16. package/src/components/FloatingPreview.tsx +382 -0
  17. package/src/components/MarkdownCitations.tsx +680 -0
  18. package/src/components/MarkdownViewer.tsx +7 -715
  19. package/src/components/ProfileAnalysisClient.ts +191 -0
  20. package/src/components/ProfileAnalysisStream.tsx +16 -185
  21. package/src/components/ProfilePreview.tsx +41 -81
  22. package/src/components/TimelineCard.tsx +18 -2
  23. package/src/components/TweetArticleCard.tsx +66 -0
  24. package/src/components/TweetRichText.tsx +33 -2
  25. package/src/components/links-controller.ts +162 -0
  26. package/src/components/links-model.ts +198 -0
  27. package/src/components/network-map-controller.ts +84 -0
  28. package/src/components/network-map-model.ts +255 -0
  29. package/src/components/useDebouncedValue.ts +12 -0
  30. package/src/components/useTimelineRouteData.ts +142 -170
  31. package/src/lib/analysis-runtime.ts +238 -0
  32. package/src/lib/api-client.ts +16 -100
  33. package/src/lib/api-contracts.ts +328 -0
  34. package/src/lib/archive-finder.ts +38 -0
  35. package/src/lib/archive-import-plan.ts +102 -0
  36. package/src/lib/archive-import.ts +170 -239
  37. package/src/lib/authored-live.ts +77 -182
  38. package/src/lib/backup.ts +335 -424
  39. package/src/lib/bird.ts +32 -1
  40. package/src/lib/blocks-write.ts +30 -26
  41. package/src/lib/blocks.ts +18 -20
  42. package/src/lib/database-metrics.ts +88 -0
  43. package/src/lib/database-migrations.ts +34 -0
  44. package/src/lib/database-schema.ts +312 -0
  45. package/src/lib/database-writer.ts +69 -0
  46. package/src/lib/db.ts +141 -334
  47. package/src/lib/dm-read-model.ts +533 -0
  48. package/src/lib/dms-live.ts +34 -97
  49. package/src/lib/follow-graph.ts +17 -27
  50. package/src/lib/import-repository.ts +138 -0
  51. package/src/lib/inbox.ts +2 -1
  52. package/src/lib/live-sync-engine.ts +209 -0
  53. package/src/lib/live-transport-gateway.ts +128 -0
  54. package/src/lib/mention-threads-live.ts +90 -176
  55. package/src/lib/mentions-export.ts +1 -1
  56. package/src/lib/mentions-live.ts +57 -225
  57. package/src/lib/moderation-target.ts +15 -4
  58. package/src/lib/moderation-write.ts +1 -1
  59. package/src/lib/mutes-write.ts +30 -26
  60. package/src/lib/openai-response-runtime.ts +251 -0
  61. package/src/lib/paginated-sync.ts +93 -0
  62. package/src/lib/period-digest.ts +116 -304
  63. package/src/lib/profile-analysis.ts +37 -111
  64. package/src/lib/queries.ts +6 -2380
  65. package/src/lib/query-actions.ts +437 -0
  66. package/src/lib/query-client.tsx +47 -0
  67. package/src/lib/query-read-model-shared.ts +52 -0
  68. package/src/lib/query-read-models.ts +5 -0
  69. package/src/lib/query-resource.ts +41 -0
  70. package/src/lib/query-status.ts +164 -0
  71. package/src/lib/research.ts +1 -1
  72. package/src/lib/runtime-services.ts +20 -0
  73. package/src/lib/search-discussion.ts +75 -279
  74. package/src/lib/server-runtime-services.ts +30 -0
  75. package/src/lib/sqlite.ts +53 -14
  76. package/src/lib/streaming-ingestion.ts +240 -0
  77. package/src/lib/sync-cache.ts +6 -1
  78. package/src/lib/sync-plan.ts +175 -0
  79. package/src/lib/timeline-collections-live.ts +83 -256
  80. package/src/lib/timeline-live.ts +86 -235
  81. package/src/lib/timeline-read-model.ts +1191 -0
  82. package/src/lib/tweet-render.ts +78 -0
  83. package/src/lib/tweet-repository.ts +156 -0
  84. package/src/lib/tweet-search-live.ts +63 -166
  85. package/src/lib/types.ts +8 -0
  86. package/src/lib/ui.ts +1 -1
  87. package/src/lib/web-sync.ts +67 -50
  88. package/src/lib/whois.ts +2 -1
  89. package/src/router.tsx +1 -1
  90. package/src/routes/__root.tsx +11 -21
  91. package/src/routes/api/action.tsx +1 -1
  92. package/src/routes/api/conversation.tsx +1 -1
  93. package/src/routes/api/query.tsx +32 -26
  94. package/src/routes/api/status.tsx +6 -2
  95. package/src/routes/api/sync.tsx +5 -2
  96. package/src/routes/blocks.tsx +97 -131
  97. package/src/routes/data-sources.tsx +17 -25
  98. package/src/routes/dms.tsx +168 -167
  99. package/src/routes/inbox.tsx +63 -57
  100. package/src/routes/links.tsx +31 -329
  101. package/src/routes/network-map.tsx +41 -344
  102. package/src/routes/rate-limits.tsx +17 -21
  103. 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(private readonly statement: StatementSync) {
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.statement.all(...bindArgs(parameters)).map(normalizeRow);
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 normalizeRow(this.statement.get(...bindArgs(parameters)));
81
+ return this.track(() =>
82
+ normalizeRow(this.statement.get(...bindArgs(parameters))),
83
+ );
63
84
  }
64
85
 
65
86
  run(...parameters: unknown[]): RunResult {
66
- const result = this.statement.run(...bindArgs(parameters));
67
- return {
68
- changes: Number(result.changes),
69
- lastInsertRowid: Number(result.lastInsertRowid),
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
- for (const row of rows) {
77
- yield normalizeRow(row);
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(path: string, options: DatabaseOptions = {}) {
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: 5000,
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(this.db.prepare(sql));
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
+ }
@@ -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 = new Date().toISOString();
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
+ }