@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.
Files changed (114) hide show
  1. package/README.md +59 -0
  2. package/index.ts +146 -0
  3. package/package.json +56 -0
  4. package/src/cli/app.ts +75 -0
  5. package/src/cli/config-command.ts +140 -0
  6. package/src/cli/config.ts +91 -0
  7. package/src/cli/derive.ts +205 -0
  8. package/src/cli/doc/annotation.ts +36 -0
  9. package/src/cli/doc/filter.ts +69 -0
  10. package/src/cli/doc/index.ts +9 -0
  11. package/src/cli/doc/post.ts +155 -0
  12. package/src/cli/doc/primitives.ts +25 -0
  13. package/src/cli/doc/render.ts +18 -0
  14. package/src/cli/doc/table.ts +114 -0
  15. package/src/cli/doc/thread.ts +46 -0
  16. package/src/cli/doc/tree.ts +126 -0
  17. package/src/cli/errors.ts +59 -0
  18. package/src/cli/exit-codes.ts +52 -0
  19. package/src/cli/feed.ts +177 -0
  20. package/src/cli/filter-dsl.ts +1411 -0
  21. package/src/cli/filter-errors.ts +208 -0
  22. package/src/cli/filter-help.ts +70 -0
  23. package/src/cli/filter-input.ts +54 -0
  24. package/src/cli/filter.ts +435 -0
  25. package/src/cli/graph.ts +472 -0
  26. package/src/cli/help.ts +14 -0
  27. package/src/cli/interval.ts +35 -0
  28. package/src/cli/jetstream.ts +173 -0
  29. package/src/cli/layers.ts +180 -0
  30. package/src/cli/logging.ts +136 -0
  31. package/src/cli/output-format.ts +26 -0
  32. package/src/cli/output.ts +82 -0
  33. package/src/cli/parse.ts +80 -0
  34. package/src/cli/post.ts +193 -0
  35. package/src/cli/preferences.ts +11 -0
  36. package/src/cli/query-fields.ts +247 -0
  37. package/src/cli/query.ts +415 -0
  38. package/src/cli/range.ts +44 -0
  39. package/src/cli/search.ts +465 -0
  40. package/src/cli/shared-options.ts +169 -0
  41. package/src/cli/shared.ts +20 -0
  42. package/src/cli/store-errors.ts +80 -0
  43. package/src/cli/store-tree.ts +392 -0
  44. package/src/cli/store.ts +395 -0
  45. package/src/cli/sync-factory.ts +107 -0
  46. package/src/cli/sync.ts +366 -0
  47. package/src/cli/view-thread.ts +196 -0
  48. package/src/cli/view.ts +47 -0
  49. package/src/cli/watch.ts +344 -0
  50. package/src/db/migrations/store-catalog/001_init.ts +14 -0
  51. package/src/db/migrations/store-index/001_init.ts +34 -0
  52. package/src/db/migrations/store-index/002_event_log.ts +24 -0
  53. package/src/db/migrations/store-index/003_fts_and_derived.ts +52 -0
  54. package/src/db/migrations/store-index/004_query_indexes.ts +9 -0
  55. package/src/db/migrations/store-index/005_post_lang.ts +15 -0
  56. package/src/db/migrations/store-index/006_has_embed.ts +10 -0
  57. package/src/db/migrations/store-index/007_event_seq_and_checkpoints.ts +68 -0
  58. package/src/domain/bsky.ts +467 -0
  59. package/src/domain/config.ts +11 -0
  60. package/src/domain/credentials.ts +6 -0
  61. package/src/domain/defaults.ts +8 -0
  62. package/src/domain/derivation.ts +55 -0
  63. package/src/domain/errors.ts +71 -0
  64. package/src/domain/events.ts +55 -0
  65. package/src/domain/extract.ts +64 -0
  66. package/src/domain/filter-describe.ts +551 -0
  67. package/src/domain/filter-explain.ts +9 -0
  68. package/src/domain/filter.ts +797 -0
  69. package/src/domain/format.ts +91 -0
  70. package/src/domain/index.ts +13 -0
  71. package/src/domain/indexes.ts +17 -0
  72. package/src/domain/policies.ts +16 -0
  73. package/src/domain/post.ts +88 -0
  74. package/src/domain/primitives.ts +50 -0
  75. package/src/domain/raw.ts +140 -0
  76. package/src/domain/store.ts +103 -0
  77. package/src/domain/sync.ts +211 -0
  78. package/src/domain/text-width.ts +56 -0
  79. package/src/services/app-config.ts +278 -0
  80. package/src/services/bsky-client.ts +2113 -0
  81. package/src/services/credential-store.ts +408 -0
  82. package/src/services/derivation-engine.ts +502 -0
  83. package/src/services/derivation-settings.ts +61 -0
  84. package/src/services/derivation-validator.ts +68 -0
  85. package/src/services/filter-compiler.ts +269 -0
  86. package/src/services/filter-library.ts +371 -0
  87. package/src/services/filter-runtime.ts +821 -0
  88. package/src/services/filter-settings.ts +30 -0
  89. package/src/services/identity-resolver.ts +563 -0
  90. package/src/services/jetstream-sync.ts +636 -0
  91. package/src/services/lineage-store.ts +89 -0
  92. package/src/services/link-validator.ts +244 -0
  93. package/src/services/output-manager.ts +274 -0
  94. package/src/services/post-parser.ts +62 -0
  95. package/src/services/profile-resolver.ts +223 -0
  96. package/src/services/resource-monitor.ts +106 -0
  97. package/src/services/shared.ts +69 -0
  98. package/src/services/store-cleaner.ts +43 -0
  99. package/src/services/store-commit.ts +168 -0
  100. package/src/services/store-db.ts +248 -0
  101. package/src/services/store-event-log.ts +285 -0
  102. package/src/services/store-index-sql.ts +289 -0
  103. package/src/services/store-index.ts +1152 -0
  104. package/src/services/store-keys.ts +4 -0
  105. package/src/services/store-manager.ts +358 -0
  106. package/src/services/store-stats.ts +522 -0
  107. package/src/services/store-writer.ts +200 -0
  108. package/src/services/sync-checkpoint-store.ts +169 -0
  109. package/src/services/sync-engine.ts +547 -0
  110. package/src/services/sync-reporter.ts +16 -0
  111. package/src/services/sync-settings.ts +72 -0
  112. package/src/services/trending-topics.ts +226 -0
  113. package/src/services/view-checkpoint-store.ts +238 -0
  114. 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
+ }