@mepuka/skygent 0.2.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/README.md +59 -0
- package/index.ts +146 -0
- package/package.json +56 -0
- package/src/cli/app.ts +75 -0
- package/src/cli/config-command.ts +140 -0
- package/src/cli/config.ts +91 -0
- package/src/cli/derive.ts +205 -0
- package/src/cli/doc/annotation.ts +36 -0
- package/src/cli/doc/filter.ts +69 -0
- package/src/cli/doc/index.ts +9 -0
- package/src/cli/doc/post.ts +155 -0
- package/src/cli/doc/primitives.ts +25 -0
- package/src/cli/doc/render.ts +18 -0
- package/src/cli/doc/table.ts +114 -0
- package/src/cli/doc/thread.ts +46 -0
- package/src/cli/doc/tree.ts +126 -0
- package/src/cli/errors.ts +59 -0
- package/src/cli/exit-codes.ts +52 -0
- package/src/cli/feed.ts +177 -0
- package/src/cli/filter-dsl.ts +1411 -0
- package/src/cli/filter-errors.ts +208 -0
- package/src/cli/filter-help.ts +70 -0
- package/src/cli/filter-input.ts +54 -0
- package/src/cli/filter.ts +435 -0
- package/src/cli/graph.ts +472 -0
- package/src/cli/help.ts +14 -0
- package/src/cli/interval.ts +35 -0
- package/src/cli/jetstream.ts +173 -0
- package/src/cli/layers.ts +180 -0
- package/src/cli/logging.ts +136 -0
- package/src/cli/output-format.ts +26 -0
- package/src/cli/output.ts +82 -0
- package/src/cli/parse.ts +80 -0
- package/src/cli/post.ts +193 -0
- package/src/cli/preferences.ts +11 -0
- package/src/cli/query-fields.ts +247 -0
- package/src/cli/query.ts +415 -0
- package/src/cli/range.ts +44 -0
- package/src/cli/search.ts +465 -0
- package/src/cli/shared-options.ts +169 -0
- package/src/cli/shared.ts +20 -0
- package/src/cli/store-errors.ts +80 -0
- package/src/cli/store-tree.ts +392 -0
- package/src/cli/store.ts +395 -0
- package/src/cli/sync-factory.ts +107 -0
- package/src/cli/sync.ts +366 -0
- package/src/cli/view-thread.ts +196 -0
- package/src/cli/view.ts +47 -0
- package/src/cli/watch.ts +344 -0
- package/src/db/migrations/store-catalog/001_init.ts +14 -0
- package/src/db/migrations/store-index/001_init.ts +34 -0
- package/src/db/migrations/store-index/002_event_log.ts +24 -0
- package/src/db/migrations/store-index/003_fts_and_derived.ts +52 -0
- package/src/db/migrations/store-index/004_query_indexes.ts +9 -0
- package/src/db/migrations/store-index/005_post_lang.ts +15 -0
- package/src/db/migrations/store-index/006_has_embed.ts +10 -0
- package/src/db/migrations/store-index/007_event_seq_and_checkpoints.ts +68 -0
- package/src/domain/bsky.ts +467 -0
- package/src/domain/config.ts +11 -0
- package/src/domain/credentials.ts +6 -0
- package/src/domain/defaults.ts +8 -0
- package/src/domain/derivation.ts +55 -0
- package/src/domain/errors.ts +71 -0
- package/src/domain/events.ts +55 -0
- package/src/domain/extract.ts +64 -0
- package/src/domain/filter-describe.ts +551 -0
- package/src/domain/filter-explain.ts +9 -0
- package/src/domain/filter.ts +797 -0
- package/src/domain/format.ts +91 -0
- package/src/domain/index.ts +13 -0
- package/src/domain/indexes.ts +17 -0
- package/src/domain/policies.ts +16 -0
- package/src/domain/post.ts +88 -0
- package/src/domain/primitives.ts +50 -0
- package/src/domain/raw.ts +140 -0
- package/src/domain/store.ts +103 -0
- package/src/domain/sync.ts +211 -0
- package/src/domain/text-width.ts +56 -0
- package/src/services/app-config.ts +278 -0
- package/src/services/bsky-client.ts +2113 -0
- package/src/services/credential-store.ts +408 -0
- package/src/services/derivation-engine.ts +502 -0
- package/src/services/derivation-settings.ts +61 -0
- package/src/services/derivation-validator.ts +68 -0
- package/src/services/filter-compiler.ts +269 -0
- package/src/services/filter-library.ts +371 -0
- package/src/services/filter-runtime.ts +821 -0
- package/src/services/filter-settings.ts +30 -0
- package/src/services/identity-resolver.ts +563 -0
- package/src/services/jetstream-sync.ts +636 -0
- package/src/services/lineage-store.ts +89 -0
- package/src/services/link-validator.ts +244 -0
- package/src/services/output-manager.ts +274 -0
- package/src/services/post-parser.ts +62 -0
- package/src/services/profile-resolver.ts +223 -0
- package/src/services/resource-monitor.ts +106 -0
- package/src/services/shared.ts +69 -0
- package/src/services/store-cleaner.ts +43 -0
- package/src/services/store-commit.ts +168 -0
- package/src/services/store-db.ts +248 -0
- package/src/services/store-event-log.ts +285 -0
- package/src/services/store-index-sql.ts +289 -0
- package/src/services/store-index.ts +1152 -0
- package/src/services/store-keys.ts +4 -0
- package/src/services/store-manager.ts +358 -0
- package/src/services/store-stats.ts +522 -0
- package/src/services/store-writer.ts +200 -0
- package/src/services/sync-checkpoint-store.ts +169 -0
- package/src/services/sync-engine.ts +547 -0
- package/src/services/sync-reporter.ts +16 -0
- package/src/services/sync-settings.ts +72 -0
- package/src/services/trending-topics.ts +226 -0
- package/src/services/view-checkpoint-store.ts +238 -0
- package/src/typeclass/chunk.ts +84 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync engine service for orchestrating data synchronization from Bluesky to stores.
|
|
3
|
+
*
|
|
4
|
+
* This service provides the core synchronization logic for fetching posts from various
|
|
5
|
+
* Bluesky sources (timeline, feeds, lists, etc.), filtering them, and persisting them
|
|
6
|
+
* to a store. It supports both one-time sync operations and continuous watch mode.
|
|
7
|
+
*
|
|
8
|
+
* ## Features
|
|
9
|
+
*
|
|
10
|
+
* - **Multiple data sources**: Timeline, custom feeds, lists, notifications, author feeds, threads, Jetstream
|
|
11
|
+
* - **Filter-based storage**: Only matching posts are stored based on filter expressions
|
|
12
|
+
* - **Deduplication**: Configurable upsert policies (dedupe vs refresh)
|
|
13
|
+
* - **Incremental sync**: Resumable sync with checkpoint support
|
|
14
|
+
* - **Watch mode**: Continuous streaming sync with progress tracking
|
|
15
|
+
* - **Error handling**: Comprehensive error tracking and reporting
|
|
16
|
+
*
|
|
17
|
+
* ## Architecture
|
|
18
|
+
*
|
|
19
|
+
* The sync process follows these stages:
|
|
20
|
+
* 1. **Fetch**: Retrieve posts from the configured data source
|
|
21
|
+
* 2. **Parse**: Convert raw Bluesky posts to normalized Post objects
|
|
22
|
+
* 3. **Filter**: Evaluate posts against the filter expression
|
|
23
|
+
* 4. **Store**: Persist matching posts to the store's event log
|
|
24
|
+
* 5. **Checkpoint**: Save progress for resumable sync
|
|
25
|
+
*
|
|
26
|
+
* ## Dependencies
|
|
27
|
+
*
|
|
28
|
+
* - `BskyClient`: For API access
|
|
29
|
+
* - `PostParser`: For post normalization
|
|
30
|
+
* - `FilterRuntime`: For filter evaluation
|
|
31
|
+
* - `StoreCommitter`: For event persistence
|
|
32
|
+
* - `SyncCheckpointStore`: For progress tracking
|
|
33
|
+
* - `SyncReporter`: For progress reporting
|
|
34
|
+
* - `SyncSettings`: For configuration
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```ts
|
|
38
|
+
* import { Effect } from "effect";
|
|
39
|
+
* import { SyncEngine } from "./services/sync-engine.js";
|
|
40
|
+
* import { DataSource } from "./domain/sync.js";
|
|
41
|
+
* import { StoreRef } from "./domain/store.js";
|
|
42
|
+
* import { hashtag } from "./domain/filter.js";
|
|
43
|
+
*
|
|
44
|
+
* const program = Effect.gen(function* () {
|
|
45
|
+
* const engine = yield* SyncEngine;
|
|
46
|
+
*
|
|
47
|
+
* // Sync a feed to a store
|
|
48
|
+
* const result = yield* engine.sync(
|
|
49
|
+
* DataSource.Feed({ uri: "at://did:plc:abc/app.bsky.feed.generator/tech" }),
|
|
50
|
+
* StoreRef.make({ name: "tech-posts", root: "/path/to/.skygent" }),
|
|
51
|
+
* hashtag("javascript"),
|
|
52
|
+
* { policy: "dedupe" }
|
|
53
|
+
* );
|
|
54
|
+
*
|
|
55
|
+
* console.log(`Synced ${result.stored} posts`);
|
|
56
|
+
* });
|
|
57
|
+
* ```
|
|
58
|
+
*
|
|
59
|
+
* @module services/sync-engine
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import { Clock, Context, Duration, Effect, Layer, Option, Ref, Schedule, Schema, Stream } from "effect";
|
|
63
|
+
import { messageFromCause } from "./shared.js";
|
|
64
|
+
import { FilterRuntime } from "./filter-runtime.js";
|
|
65
|
+
import { PostParser } from "./post-parser.js";
|
|
66
|
+
import { StoreCommitter } from "./store-commit.js";
|
|
67
|
+
import { BskyClient } from "./bsky-client.js";
|
|
68
|
+
import type { FilterExpr } from "../domain/filter.js";
|
|
69
|
+
import { filterExprSignature } from "../domain/filter.js";
|
|
70
|
+
import { EventMeta, PostUpsert } from "../domain/events.js";
|
|
71
|
+
import type { Post } from "../domain/post.js";
|
|
72
|
+
import { EventSeq, Timestamp } from "../domain/primitives.js";
|
|
73
|
+
import type { RawPost } from "../domain/raw.js";
|
|
74
|
+
import type { StoreRef, SyncUpsertPolicy } from "../domain/store.js";
|
|
75
|
+
import {
|
|
76
|
+
DataSource,
|
|
77
|
+
SyncCheckpoint,
|
|
78
|
+
SyncError,
|
|
79
|
+
SyncEvent,
|
|
80
|
+
SyncProgress,
|
|
81
|
+
SyncResult,
|
|
82
|
+
SyncResultMonoid,
|
|
83
|
+
SyncStage,
|
|
84
|
+
WatchConfig
|
|
85
|
+
} from "../domain/sync.js";
|
|
86
|
+
import { SyncCheckpointStore } from "./sync-checkpoint-store.js";
|
|
87
|
+
import { SyncReporter } from "./sync-reporter.js";
|
|
88
|
+
import { SyncSettings } from "./sync-settings.js";
|
|
89
|
+
|
|
90
|
+
type PreparedOutcome =
|
|
91
|
+
| { readonly _tag: "Store"; readonly post: Post; readonly pageCursor?: string }
|
|
92
|
+
| { readonly _tag: "Skip"; readonly pageCursor?: string }
|
|
93
|
+
| { readonly _tag: "Error"; readonly error: SyncError; readonly pageCursor?: string };
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
type SyncOutcome =
|
|
97
|
+
| { readonly _tag: "Stored"; readonly eventSeq: EventSeq }
|
|
98
|
+
| { readonly _tag: "Skipped" }
|
|
99
|
+
| { readonly _tag: "Error"; readonly error: SyncError };
|
|
100
|
+
|
|
101
|
+
const skippedOutcome: SyncOutcome = { _tag: "Skipped" };
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
const toSyncError =
|
|
105
|
+
(stage: SyncStage, fallback: string) => (cause: unknown) =>
|
|
106
|
+
SyncError.make({
|
|
107
|
+
stage,
|
|
108
|
+
message: messageFromCause(fallback, cause),
|
|
109
|
+
cause
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const sourceLabel = (source: DataSource) => {
|
|
113
|
+
switch (source._tag) {
|
|
114
|
+
case "Timeline":
|
|
115
|
+
return "timeline";
|
|
116
|
+
case "Feed":
|
|
117
|
+
return "feed";
|
|
118
|
+
case "List":
|
|
119
|
+
return "list";
|
|
120
|
+
case "Notifications":
|
|
121
|
+
return "notifications";
|
|
122
|
+
case "Author":
|
|
123
|
+
return "author";
|
|
124
|
+
case "Thread":
|
|
125
|
+
return "thread";
|
|
126
|
+
case "Jetstream":
|
|
127
|
+
return "jetstream";
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const commandForSource = (source: DataSource) => {
|
|
132
|
+
switch (source._tag) {
|
|
133
|
+
case "Timeline":
|
|
134
|
+
return "sync timeline";
|
|
135
|
+
case "Feed":
|
|
136
|
+
return `sync feed ${source.uri}`;
|
|
137
|
+
case "List":
|
|
138
|
+
return `sync list ${source.uri}`;
|
|
139
|
+
case "Notifications":
|
|
140
|
+
return "sync notifications";
|
|
141
|
+
case "Author":
|
|
142
|
+
return `sync author ${source.actor}`;
|
|
143
|
+
case "Thread":
|
|
144
|
+
return `sync thread ${source.uri}`;
|
|
145
|
+
case "Jetstream":
|
|
146
|
+
return "sync jetstream";
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export class SyncEngine extends Context.Tag("@skygent/SyncEngine")<
|
|
151
|
+
SyncEngine,
|
|
152
|
+
{
|
|
153
|
+
readonly sync: (
|
|
154
|
+
source: DataSource,
|
|
155
|
+
target: StoreRef,
|
|
156
|
+
filter: FilterExpr,
|
|
157
|
+
options?: { readonly policy?: SyncUpsertPolicy }
|
|
158
|
+
) => Effect.Effect<SyncResult, SyncError>;
|
|
159
|
+
readonly watch: (config: WatchConfig) => Stream.Stream<SyncEvent, SyncError>;
|
|
160
|
+
}
|
|
161
|
+
>() {
|
|
162
|
+
static readonly layer = Layer.effect(
|
|
163
|
+
SyncEngine,
|
|
164
|
+
Effect.gen(function* () {
|
|
165
|
+
const client = yield* BskyClient;
|
|
166
|
+
const parser = yield* PostParser;
|
|
167
|
+
const runtime = yield* FilterRuntime;
|
|
168
|
+
const committer = yield* StoreCommitter;
|
|
169
|
+
const checkpoints = yield* SyncCheckpointStore;
|
|
170
|
+
const reporter = yield* SyncReporter;
|
|
171
|
+
const settings = yield* SyncSettings;
|
|
172
|
+
|
|
173
|
+
const sync = Effect.fn("SyncEngine.sync")(
|
|
174
|
+
(source: DataSource, target: StoreRef, filter: FilterExpr, options?: { readonly policy?: SyncUpsertPolicy }) =>
|
|
175
|
+
Effect.gen(function* () {
|
|
176
|
+
const predicate = yield* runtime
|
|
177
|
+
.evaluateWithMetadata(filter)
|
|
178
|
+
.pipe(
|
|
179
|
+
Effect.mapError(
|
|
180
|
+
toSyncError("filter", "Filter compilation failed")
|
|
181
|
+
)
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const filterHash = filterExprSignature(filter);
|
|
185
|
+
const policy = options?.policy ?? "dedupe";
|
|
186
|
+
|
|
187
|
+
const makeMeta = () =>
|
|
188
|
+
Clock.currentTimeMillis.pipe(
|
|
189
|
+
Effect.flatMap((now) => Schema.decodeUnknown(Timestamp)(new Date(now).toISOString())),
|
|
190
|
+
Effect.mapError(
|
|
191
|
+
toSyncError("store", "Failed to create event metadata")
|
|
192
|
+
),
|
|
193
|
+
Effect.map((createdAt) =>
|
|
194
|
+
EventMeta.make({
|
|
195
|
+
source: sourceLabel(source),
|
|
196
|
+
command: commandForSource(source),
|
|
197
|
+
filterExprHash: filterHash,
|
|
198
|
+
createdAt
|
|
199
|
+
})
|
|
200
|
+
)
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const storePost = (post: Post) =>
|
|
204
|
+
Effect.gen(function* () {
|
|
205
|
+
const meta = yield* makeMeta();
|
|
206
|
+
const event = PostUpsert.make({ post, meta });
|
|
207
|
+
if (policy === "refresh") {
|
|
208
|
+
const record = yield* committer
|
|
209
|
+
.appendUpsert(target, event)
|
|
210
|
+
.pipe(
|
|
211
|
+
Effect.mapError(
|
|
212
|
+
toSyncError("store", "Failed to append event")
|
|
213
|
+
)
|
|
214
|
+
);
|
|
215
|
+
return Option.some(record.seq);
|
|
216
|
+
}
|
|
217
|
+
const stored = yield* committer
|
|
218
|
+
.appendUpsertIfMissing(target, event)
|
|
219
|
+
.pipe(
|
|
220
|
+
Effect.mapError(
|
|
221
|
+
toSyncError("store", "Failed to append event")
|
|
222
|
+
)
|
|
223
|
+
);
|
|
224
|
+
return Option.map(stored, (entry) => entry.seq);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const prepareRaw = (raw: RawPost): Effect.Effect<PreparedOutcome, SyncError> =>
|
|
228
|
+
parser.parsePost(raw).pipe(
|
|
229
|
+
Effect.mapError(toSyncError("parse", "Failed to parse post")),
|
|
230
|
+
Effect.flatMap((post) =>
|
|
231
|
+
predicate(post).pipe(
|
|
232
|
+
Effect.mapError(
|
|
233
|
+
toSyncError("filter", "Filter evaluation failed")
|
|
234
|
+
),
|
|
235
|
+
Effect.map(({ ok }) =>
|
|
236
|
+
ok
|
|
237
|
+
? ({ _tag: "Store", post, ...(raw._pageCursor !== undefined ? { pageCursor: raw._pageCursor } : {}) } as const)
|
|
238
|
+
: ({ _tag: "Skip", ...(raw._pageCursor !== undefined ? { pageCursor: raw._pageCursor } : {}) } as const)
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
),
|
|
242
|
+
Effect.catchAll((error) =>
|
|
243
|
+
Effect.succeed({ _tag: "Error", error, ...(raw._pageCursor !== undefined ? { pageCursor: raw._pageCursor } : {}) } as const)
|
|
244
|
+
)
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const applyPrepared = (
|
|
248
|
+
prepared: PreparedOutcome
|
|
249
|
+
): Effect.Effect<SyncOutcome, SyncError> =>
|
|
250
|
+
Effect.gen(function* () {
|
|
251
|
+
switch (prepared._tag) {
|
|
252
|
+
case "Skip":
|
|
253
|
+
return skippedOutcome;
|
|
254
|
+
case "Error":
|
|
255
|
+
return { _tag: "Error", error: prepared.error } as const;
|
|
256
|
+
case "Store": {
|
|
257
|
+
const stored = yield* storePost(prepared.post);
|
|
258
|
+
return Option.match(stored, {
|
|
259
|
+
onNone: () => skippedOutcome,
|
|
260
|
+
onSome: (eventSeq) => ({ _tag: "Stored", eventSeq } as const)
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const initial = SyncResultMonoid.empty;
|
|
267
|
+
const previousCheckpoint = yield* checkpoints.load(target, source).pipe(
|
|
268
|
+
Effect.mapError(toSyncError("store", "Failed to load sync checkpoint"))
|
|
269
|
+
);
|
|
270
|
+
const activeCheckpoint = Option.filter(previousCheckpoint, (value) =>
|
|
271
|
+
value.filterHash ? value.filterHash === filterHash : true
|
|
272
|
+
);
|
|
273
|
+
const cursorOption = Option.flatMap(activeCheckpoint, (value) =>
|
|
274
|
+
Option.fromNullable(value.cursor)
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const stream = (() => {
|
|
278
|
+
switch (source._tag) {
|
|
279
|
+
case "Timeline":
|
|
280
|
+
return client.getTimeline(
|
|
281
|
+
Option.match(cursorOption, {
|
|
282
|
+
onNone: () => undefined,
|
|
283
|
+
onSome: (value) => ({ cursor: value })
|
|
284
|
+
})
|
|
285
|
+
);
|
|
286
|
+
case "Feed":
|
|
287
|
+
return client.getFeed(
|
|
288
|
+
source.uri,
|
|
289
|
+
Option.match(cursorOption, {
|
|
290
|
+
onNone: () => undefined,
|
|
291
|
+
onSome: (value) => ({ cursor: value })
|
|
292
|
+
})
|
|
293
|
+
);
|
|
294
|
+
case "List":
|
|
295
|
+
return client.getListFeed(
|
|
296
|
+
source.uri,
|
|
297
|
+
Option.match(cursorOption, {
|
|
298
|
+
onNone: () => undefined,
|
|
299
|
+
onSome: (value) => ({ cursor: value })
|
|
300
|
+
})
|
|
301
|
+
);
|
|
302
|
+
case "Notifications":
|
|
303
|
+
return client.getNotifications(
|
|
304
|
+
Option.match(cursorOption, {
|
|
305
|
+
onNone: () => undefined,
|
|
306
|
+
onSome: (value) => ({ cursor: value })
|
|
307
|
+
})
|
|
308
|
+
);
|
|
309
|
+
case "Author":
|
|
310
|
+
const authorOptions = {
|
|
311
|
+
...(source.filter !== undefined
|
|
312
|
+
? { filter: source.filter }
|
|
313
|
+
: {}),
|
|
314
|
+
...(source.includePins !== undefined
|
|
315
|
+
? { includePins: source.includePins }
|
|
316
|
+
: {})
|
|
317
|
+
};
|
|
318
|
+
return client.getAuthorFeed(
|
|
319
|
+
source.actor,
|
|
320
|
+
Option.match(cursorOption, {
|
|
321
|
+
onNone: () => authorOptions,
|
|
322
|
+
onSome: (value) => ({
|
|
323
|
+
...authorOptions,
|
|
324
|
+
cursor: value
|
|
325
|
+
})
|
|
326
|
+
})
|
|
327
|
+
);
|
|
328
|
+
case "Thread":
|
|
329
|
+
return Stream.unwrap(
|
|
330
|
+
client
|
|
331
|
+
.getPostThread(source.uri, {
|
|
332
|
+
...(source.depth !== undefined
|
|
333
|
+
? { depth: source.depth }
|
|
334
|
+
: {}),
|
|
335
|
+
...(source.parentHeight !== undefined
|
|
336
|
+
? { parentHeight: source.parentHeight }
|
|
337
|
+
: {})
|
|
338
|
+
})
|
|
339
|
+
.pipe(Effect.map((posts) => Stream.fromIterable(posts)))
|
|
340
|
+
);
|
|
341
|
+
case "Jetstream":
|
|
342
|
+
return Stream.fail(
|
|
343
|
+
SyncError.make({
|
|
344
|
+
stage: "source",
|
|
345
|
+
message: "Jetstream sources require the jetstream sync engine."
|
|
346
|
+
})
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
})();
|
|
350
|
+
|
|
351
|
+
type SyncState = {
|
|
352
|
+
readonly result: SyncResult;
|
|
353
|
+
readonly lastEventSeq: Option.Option<EventSeq>;
|
|
354
|
+
readonly latestCursor: Option.Option<string>;
|
|
355
|
+
readonly processed: number;
|
|
356
|
+
readonly stored: number;
|
|
357
|
+
readonly skipped: number;
|
|
358
|
+
readonly errors: number;
|
|
359
|
+
readonly lastReportAt: number;
|
|
360
|
+
readonly lastCheckpointAt: number;
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const resolveLastEventSeq = (candidate: Option.Option<EventSeq>) =>
|
|
364
|
+
Option.match(candidate, {
|
|
365
|
+
onNone: () =>
|
|
366
|
+
Option.flatMap(activeCheckpoint, (value) =>
|
|
367
|
+
Option.fromNullable(value.lastEventSeq)
|
|
368
|
+
),
|
|
369
|
+
onSome: Option.some
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const saveCheckpoint = (state: SyncState, now: number) => {
|
|
373
|
+
const lastEventSeq = resolveLastEventSeq(state.lastEventSeq);
|
|
374
|
+
const shouldSave =
|
|
375
|
+
Option.isSome(lastEventSeq) ||
|
|
376
|
+
Option.isSome(state.latestCursor) ||
|
|
377
|
+
Option.isSome(activeCheckpoint);
|
|
378
|
+
if (!shouldSave) {
|
|
379
|
+
return Effect.void;
|
|
380
|
+
}
|
|
381
|
+
return Schema.decodeUnknown(Timestamp)(new Date(now).toISOString()).pipe(
|
|
382
|
+
Effect.mapError(
|
|
383
|
+
toSyncError("store", "Failed to create checkpoint timestamp")
|
|
384
|
+
),
|
|
385
|
+
Effect.flatMap((updatedAt) => {
|
|
386
|
+
const effectiveCursor = Option.orElse(state.latestCursor, () => cursorOption);
|
|
387
|
+
const checkpoint = SyncCheckpoint.make({
|
|
388
|
+
source,
|
|
389
|
+
cursor: Option.getOrUndefined(effectiveCursor),
|
|
390
|
+
lastEventSeq: Option.getOrUndefined(lastEventSeq),
|
|
391
|
+
filterHash,
|
|
392
|
+
updatedAt
|
|
393
|
+
});
|
|
394
|
+
return checkpoints
|
|
395
|
+
.save(target, checkpoint)
|
|
396
|
+
.pipe(
|
|
397
|
+
Effect.mapError(
|
|
398
|
+
toSyncError("store", "Failed to save checkpoint")
|
|
399
|
+
)
|
|
400
|
+
);
|
|
401
|
+
})
|
|
402
|
+
);
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const startTime = yield* Clock.currentTimeMillis;
|
|
406
|
+
const initialState: SyncState = {
|
|
407
|
+
result: initial,
|
|
408
|
+
lastEventSeq: Option.none<EventSeq>(),
|
|
409
|
+
latestCursor: Option.none<string>(),
|
|
410
|
+
processed: 0,
|
|
411
|
+
stored: 0,
|
|
412
|
+
skipped: 0,
|
|
413
|
+
errors: 0,
|
|
414
|
+
lastReportAt: startTime,
|
|
415
|
+
lastCheckpointAt: startTime
|
|
416
|
+
};
|
|
417
|
+
const stateRef = yield* Ref.make(initialState);
|
|
418
|
+
|
|
419
|
+
const state = yield* stream.pipe(
|
|
420
|
+
Stream.mapError(toSyncError("source", "Source stream failed")),
|
|
421
|
+
Stream.mapEffect(prepareRaw, {
|
|
422
|
+
concurrency: settings.concurrency,
|
|
423
|
+
unordered: false
|
|
424
|
+
}),
|
|
425
|
+
Stream.runFoldEffect(
|
|
426
|
+
initialState,
|
|
427
|
+
(state, prepared) =>
|
|
428
|
+
Effect.gen(function* () {
|
|
429
|
+
const outcome = yield* applyPrepared(prepared);
|
|
430
|
+
const delta = (() => {
|
|
431
|
+
switch (outcome._tag) {
|
|
432
|
+
case "Stored":
|
|
433
|
+
return SyncResult.make({
|
|
434
|
+
postsAdded: 1,
|
|
435
|
+
postsDeleted: 0,
|
|
436
|
+
postsSkipped: 0,
|
|
437
|
+
errors: []
|
|
438
|
+
});
|
|
439
|
+
case "Skipped":
|
|
440
|
+
return SyncResult.make({
|
|
441
|
+
postsAdded: 0,
|
|
442
|
+
postsDeleted: 0,
|
|
443
|
+
postsSkipped: 1,
|
|
444
|
+
errors: []
|
|
445
|
+
});
|
|
446
|
+
case "Error":
|
|
447
|
+
return SyncResult.make({
|
|
448
|
+
postsAdded: 0,
|
|
449
|
+
postsDeleted: 0,
|
|
450
|
+
postsSkipped: 1,
|
|
451
|
+
errors: [outcome.error]
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
})();
|
|
455
|
+
|
|
456
|
+
const processed = state.processed + 1;
|
|
457
|
+
const stored =
|
|
458
|
+
state.stored + (outcome._tag === "Stored" ? 1 : 0);
|
|
459
|
+
const skipped =
|
|
460
|
+
state.skipped + (outcome._tag === "Skipped" ? 1 : 0);
|
|
461
|
+
const errors =
|
|
462
|
+
state.errors + (outcome._tag === "Error" ? 1 : 0);
|
|
463
|
+
const now = yield* Clock.currentTimeMillis;
|
|
464
|
+
const shouldReport =
|
|
465
|
+
processed % 100 === 0 || now - state.lastReportAt >= 5000;
|
|
466
|
+
if (shouldReport) {
|
|
467
|
+
const elapsedMs = now - startTime;
|
|
468
|
+
const rate =
|
|
469
|
+
elapsedMs > 0 ? processed / (elapsedMs / 1000) : 0;
|
|
470
|
+
yield* reporter.report(
|
|
471
|
+
SyncProgress.make({
|
|
472
|
+
processed,
|
|
473
|
+
stored,
|
|
474
|
+
skipped,
|
|
475
|
+
errors,
|
|
476
|
+
elapsedMs,
|
|
477
|
+
rate
|
|
478
|
+
})
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const nextCursor = prepared.pageCursor
|
|
483
|
+
? Option.some(prepared.pageCursor)
|
|
484
|
+
: state.latestCursor;
|
|
485
|
+
|
|
486
|
+
const nextState: SyncState = {
|
|
487
|
+
result: SyncResultMonoid.combine(state.result, delta),
|
|
488
|
+
lastEventSeq:
|
|
489
|
+
outcome._tag === "Stored"
|
|
490
|
+
? Option.some(outcome.eventSeq)
|
|
491
|
+
: state.lastEventSeq,
|
|
492
|
+
latestCursor: nextCursor,
|
|
493
|
+
processed,
|
|
494
|
+
stored,
|
|
495
|
+
skipped,
|
|
496
|
+
errors,
|
|
497
|
+
lastReportAt: shouldReport ? now : state.lastReportAt,
|
|
498
|
+
lastCheckpointAt: state.lastCheckpointAt
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const shouldCheckpoint =
|
|
502
|
+
processed > 0 &&
|
|
503
|
+
(processed % settings.checkpointEvery === 0 ||
|
|
504
|
+
(settings.checkpointIntervalMs > 0 &&
|
|
505
|
+
now - state.lastCheckpointAt >=
|
|
506
|
+
settings.checkpointIntervalMs));
|
|
507
|
+
if (shouldCheckpoint) {
|
|
508
|
+
yield* saveCheckpoint(nextState, now);
|
|
509
|
+
const updated = { ...nextState, lastCheckpointAt: now };
|
|
510
|
+
yield* Ref.set(stateRef, updated);
|
|
511
|
+
return updated;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
yield* Ref.set(stateRef, nextState);
|
|
515
|
+
return nextState;
|
|
516
|
+
})
|
|
517
|
+
),
|
|
518
|
+
Effect.withRequestBatching(true),
|
|
519
|
+
Effect.ensuring(
|
|
520
|
+
Ref.get(stateRef).pipe(
|
|
521
|
+
Effect.flatMap((state) =>
|
|
522
|
+
Clock.currentTimeMillis.pipe(
|
|
523
|
+
Effect.flatMap((now) => saveCheckpoint(state, now))
|
|
524
|
+
)
|
|
525
|
+
),
|
|
526
|
+
Effect.catchAll(() => Effect.void)
|
|
527
|
+
)
|
|
528
|
+
)
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
return state.result;
|
|
532
|
+
})
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
const watch = (config: WatchConfig) => {
|
|
536
|
+
const interval = config.interval ?? Duration.seconds(30);
|
|
537
|
+
const syncOptions = config.policy ? { policy: config.policy } : undefined;
|
|
538
|
+
return Stream.repeatEffectWithSchedule(
|
|
539
|
+
sync(config.source, config.store, config.filter, syncOptions),
|
|
540
|
+
Schedule.spaced(interval)
|
|
541
|
+
).pipe(Stream.map((result) => SyncEvent.make({ result })));
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
return SyncEngine.of({ sync, watch });
|
|
545
|
+
})
|
|
546
|
+
);
|
|
547
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
import type { SyncProgress } from "../domain/sync.js";
|
|
3
|
+
|
|
4
|
+
export class SyncReporter extends Context.Tag("@skygent/SyncReporter")<
|
|
5
|
+
SyncReporter,
|
|
6
|
+
{
|
|
7
|
+
readonly report: (progress: SyncProgress) => Effect.Effect<void>;
|
|
8
|
+
}
|
|
9
|
+
>() {
|
|
10
|
+
static readonly layer = Layer.succeed(
|
|
11
|
+
SyncReporter,
|
|
12
|
+
SyncReporter.of({
|
|
13
|
+
report: () => Effect.void
|
|
14
|
+
})
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Config, Context, Effect, Layer } from "effect";
|
|
2
|
+
import { pickDefined, validatePositive, validateNonNegative } from "./shared.js";
|
|
3
|
+
|
|
4
|
+
export type SyncSettingsValue = {
|
|
5
|
+
readonly checkpointEvery: number;
|
|
6
|
+
readonly checkpointIntervalMs: number;
|
|
7
|
+
readonly concurrency: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type SyncSettingsOverridesValue = Partial<SyncSettingsValue>;
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
export class SyncSettingsOverrides extends Context.Tag("@skygent/SyncSettingsOverrides")<
|
|
15
|
+
SyncSettingsOverrides,
|
|
16
|
+
SyncSettingsOverridesValue
|
|
17
|
+
>() {
|
|
18
|
+
static readonly layer = Layer.succeed(SyncSettingsOverrides, {});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class SyncSettings extends Context.Tag("@skygent/SyncSettings")<
|
|
22
|
+
SyncSettings,
|
|
23
|
+
SyncSettingsValue
|
|
24
|
+
>() {
|
|
25
|
+
static readonly layer = Layer.effect(
|
|
26
|
+
SyncSettings,
|
|
27
|
+
Effect.gen(function* () {
|
|
28
|
+
const overrides = yield* SyncSettingsOverrides;
|
|
29
|
+
|
|
30
|
+
const checkpointEvery = yield* Config.integer("SKYGENT_SYNC_CHECKPOINT_EVERY").pipe(
|
|
31
|
+
Config.withDefault(100)
|
|
32
|
+
);
|
|
33
|
+
const checkpointIntervalMs = yield* Config.integer(
|
|
34
|
+
"SKYGENT_SYNC_CHECKPOINT_INTERVAL_MS"
|
|
35
|
+
).pipe(Config.withDefault(5000));
|
|
36
|
+
const concurrency = yield* Config.integer("SKYGENT_SYNC_CONCURRENCY").pipe(
|
|
37
|
+
Config.withDefault(5)
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const merged = {
|
|
41
|
+
checkpointEvery,
|
|
42
|
+
checkpointIntervalMs,
|
|
43
|
+
concurrency,
|
|
44
|
+
...pickDefined(overrides as Record<string, unknown>)
|
|
45
|
+
} as SyncSettingsValue;
|
|
46
|
+
|
|
47
|
+
const checkpointEveryError = validatePositive(
|
|
48
|
+
"SKYGENT_SYNC_CHECKPOINT_EVERY",
|
|
49
|
+
merged.checkpointEvery
|
|
50
|
+
);
|
|
51
|
+
if (checkpointEveryError) {
|
|
52
|
+
return yield* checkpointEveryError;
|
|
53
|
+
}
|
|
54
|
+
const checkpointIntervalError = validateNonNegative(
|
|
55
|
+
"SKYGENT_SYNC_CHECKPOINT_INTERVAL_MS",
|
|
56
|
+
merged.checkpointIntervalMs
|
|
57
|
+
);
|
|
58
|
+
if (checkpointIntervalError) {
|
|
59
|
+
return yield* checkpointIntervalError;
|
|
60
|
+
}
|
|
61
|
+
const concurrencyError = validatePositive(
|
|
62
|
+
"SKYGENT_SYNC_CONCURRENCY",
|
|
63
|
+
merged.concurrency
|
|
64
|
+
);
|
|
65
|
+
if (concurrencyError) {
|
|
66
|
+
return yield* concurrencyError;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return SyncSettings.of(merged);
|
|
70
|
+
})
|
|
71
|
+
);
|
|
72
|
+
}
|