@mepuka/skygent 0.2.0 → 0.3.1

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 (62) hide show
  1. package/README.md +269 -31
  2. package/index.ts +18 -3
  3. package/package.json +1 -1
  4. package/src/cli/app.ts +4 -2
  5. package/src/cli/compact-output.ts +52 -0
  6. package/src/cli/config.ts +46 -4
  7. package/src/cli/doc/table-renderers.ts +29 -0
  8. package/src/cli/doc/thread.ts +2 -4
  9. package/src/cli/exit-codes.ts +2 -0
  10. package/src/cli/feed.ts +78 -61
  11. package/src/cli/filter-dsl.ts +146 -11
  12. package/src/cli/filter-errors.ts +13 -11
  13. package/src/cli/filter-help.ts +7 -0
  14. package/src/cli/filter-input.ts +3 -2
  15. package/src/cli/filter.ts +83 -5
  16. package/src/cli/graph.ts +297 -169
  17. package/src/cli/input.ts +45 -0
  18. package/src/cli/interval.ts +4 -33
  19. package/src/cli/jetstream.ts +2 -0
  20. package/src/cli/layers.ts +10 -0
  21. package/src/cli/logging.ts +8 -0
  22. package/src/cli/option-schemas.ts +22 -0
  23. package/src/cli/output-format.ts +11 -0
  24. package/src/cli/output-render.ts +14 -0
  25. package/src/cli/pagination.ts +17 -0
  26. package/src/cli/parse-errors.ts +30 -0
  27. package/src/cli/parse.ts +1 -47
  28. package/src/cli/pipe-input.ts +18 -0
  29. package/src/cli/pipe.ts +154 -0
  30. package/src/cli/post.ts +88 -66
  31. package/src/cli/query-fields.ts +13 -3
  32. package/src/cli/query.ts +354 -100
  33. package/src/cli/search.ts +93 -136
  34. package/src/cli/shared-options.ts +11 -63
  35. package/src/cli/shared.ts +1 -20
  36. package/src/cli/store-errors.ts +28 -21
  37. package/src/cli/store-tree.ts +6 -4
  38. package/src/cli/store.ts +41 -2
  39. package/src/cli/stream-merge.ts +105 -0
  40. package/src/cli/sync-factory.ts +24 -7
  41. package/src/cli/sync.ts +46 -67
  42. package/src/cli/thread-options.ts +25 -0
  43. package/src/cli/time.ts +171 -0
  44. package/src/cli/view-thread.ts +29 -32
  45. package/src/cli/watch.ts +55 -26
  46. package/src/domain/errors.ts +6 -1
  47. package/src/domain/format.ts +21 -0
  48. package/src/domain/order.ts +24 -0
  49. package/src/domain/primitives.ts +20 -3
  50. package/src/graph/relationships.ts +129 -0
  51. package/src/services/bsky-client.ts +11 -5
  52. package/src/services/jetstream-sync.ts +4 -4
  53. package/src/services/lineage-store.ts +15 -1
  54. package/src/services/shared.ts +48 -1
  55. package/src/services/store-cleaner.ts +5 -2
  56. package/src/services/store-commit.ts +60 -0
  57. package/src/services/store-manager.ts +69 -2
  58. package/src/services/store-renamer.ts +288 -0
  59. package/src/services/store-stats.ts +7 -5
  60. package/src/services/sync-engine.ts +149 -89
  61. package/src/services/sync-reporter.ts +3 -1
  62. package/src/services/sync-settings.ts +24 -0
@@ -59,8 +59,9 @@
59
59
  * @module services/sync-engine
60
60
  */
61
61
 
62
- import { Clock, Context, Duration, Effect, Layer, Option, Ref, Schedule, Schema, Stream } from "effect";
62
+ import { Chunk, Clock, Context, Duration, Effect, Fiber, Layer, Option, Ref, Schedule, Schema, Stream } from "effect";
63
63
  import { messageFromCause } from "./shared.js";
64
+ import type { BskyError } from "../domain/errors.js";
64
65
  import { FilterRuntime } from "./filter-runtime.js";
65
66
  import { PostParser } from "./post-parser.js";
66
67
  import { StoreCommitter } from "./store-commit.js";
@@ -68,6 +69,7 @@ import { BskyClient } from "./bsky-client.js";
68
69
  import type { FilterExpr } from "../domain/filter.js";
69
70
  import { filterExprSignature } from "../domain/filter.js";
70
71
  import { EventMeta, PostUpsert } from "../domain/events.js";
72
+ import type { EventLogEntry } from "../domain/events.js";
71
73
  import type { Post } from "../domain/post.js";
72
74
  import { EventSeq, Timestamp } from "../domain/primitives.js";
73
75
  import type { RawPost } from "../domain/raw.js";
@@ -154,7 +156,7 @@ export class SyncEngine extends Context.Tag("@skygent/SyncEngine")<
154
156
  source: DataSource,
155
157
  target: StoreRef,
156
158
  filter: FilterExpr,
157
- options?: { readonly policy?: SyncUpsertPolicy }
159
+ options?: { readonly policy?: SyncUpsertPolicy; readonly limit?: number }
158
160
  ) => Effect.Effect<SyncResult, SyncError>;
159
161
  readonly watch: (config: WatchConfig) => Stream.Stream<SyncEvent, SyncError>;
160
162
  }
@@ -171,7 +173,12 @@ export class SyncEngine extends Context.Tag("@skygent/SyncEngine")<
171
173
  const settings = yield* SyncSettings;
172
174
 
173
175
  const sync = Effect.fn("SyncEngine.sync")(
174
- (source: DataSource, target: StoreRef, filter: FilterExpr, options?: { readonly policy?: SyncUpsertPolicy }) =>
176
+ (
177
+ source: DataSource,
178
+ target: StoreRef,
179
+ filter: FilterExpr,
180
+ options?: { readonly policy?: SyncUpsertPolicy; readonly limit?: number }
181
+ ) =>
175
182
  Effect.gen(function* () {
176
183
  const predicate = yield* runtime
177
184
  .evaluateWithMetadata(filter)
@@ -200,29 +207,10 @@ export class SyncEngine extends Context.Tag("@skygent/SyncEngine")<
200
207
  )
201
208
  );
202
209
 
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
- });
210
+ const buildUpsert = (post: Post) =>
211
+ makeMeta().pipe(
212
+ Effect.map((meta) => PostUpsert.make({ post, meta }))
213
+ );
226
214
 
227
215
  const prepareRaw = (raw: RawPost): Effect.Effect<PreparedOutcome, SyncError> =>
228
216
  parser.parsePost(raw).pipe(
@@ -244,23 +232,53 @@ export class SyncEngine extends Context.Tag("@skygent/SyncEngine")<
244
232
  )
245
233
  );
246
234
 
247
- const applyPrepared = (
248
- prepared: PreparedOutcome
249
- ): Effect.Effect<SyncOutcome, SyncError> =>
235
+ const commitStoreEvents = (events: ReadonlyArray<PostUpsert>) => {
236
+ if (events.length === 0) {
237
+ return Effect.succeed(
238
+ [] as ReadonlyArray<Option.Option<EventLogEntry>>
239
+ );
240
+ }
241
+ const commit = policy === "refresh"
242
+ ? committer
243
+ .appendUpserts(target, events)
244
+ .pipe(Effect.map((entries) => entries.map(Option.some)))
245
+ : committer.appendUpsertsIfMissing(target, events);
246
+ return commit.pipe(
247
+ Effect.mapError(
248
+ toSyncError("store", "Failed to append events")
249
+ )
250
+ );
251
+ };
252
+
253
+ const applyPreparedBatch = (
254
+ preparedBatch: ReadonlyArray<PreparedOutcome>
255
+ ): Effect.Effect<ReadonlyArray<SyncOutcome>, SyncError> =>
250
256
  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
- });
257
+ const storeItems = preparedBatch.filter(
258
+ (item): item is Extract<PreparedOutcome, { _tag: "Store" }> =>
259
+ item._tag === "Store"
260
+ );
261
+ const events = yield* Effect.forEach(storeItems, (item) =>
262
+ buildUpsert(item.post)
263
+ );
264
+ const storedEntries = yield* commitStoreEvents(events);
265
+ let storeIndex = 0;
266
+ return preparedBatch.map((prepared) => {
267
+ switch (prepared._tag) {
268
+ case "Skip":
269
+ return skippedOutcome;
270
+ case "Error":
271
+ return { _tag: "Error", error: prepared.error } as const;
272
+ case "Store": {
273
+ const entry = storedEntries[storeIndex++] ?? Option.none();
274
+ return Option.match(entry, {
275
+ onNone: () => skippedOutcome,
276
+ onSome: (record) =>
277
+ ({ _tag: "Stored", eventSeq: record.seq } as const)
278
+ });
279
+ }
262
280
  }
263
- }
281
+ });
264
282
  });
265
283
 
266
284
  const initial = SyncResultMonoid.empty;
@@ -273,41 +291,43 @@ export class SyncEngine extends Context.Tag("@skygent/SyncEngine")<
273
291
  const cursorOption = Option.flatMap(activeCheckpoint, (value) =>
274
292
  Option.fromNullable(value.cursor)
275
293
  );
294
+ const pageLimit = settings.pageLimit;
276
295
 
277
- const stream = (() => {
296
+ let stream = (() => {
278
297
  switch (source._tag) {
279
298
  case "Timeline":
280
299
  return client.getTimeline(
281
300
  Option.match(cursorOption, {
282
- onNone: () => undefined,
283
- onSome: (value) => ({ cursor: value })
301
+ onNone: () => ({ limit: pageLimit }),
302
+ onSome: (value) => ({ cursor: value, limit: pageLimit })
284
303
  })
285
304
  );
286
305
  case "Feed":
287
306
  return client.getFeed(
288
307
  source.uri,
289
308
  Option.match(cursorOption, {
290
- onNone: () => undefined,
291
- onSome: (value) => ({ cursor: value })
309
+ onNone: () => ({ limit: pageLimit }),
310
+ onSome: (value) => ({ cursor: value, limit: pageLimit })
292
311
  })
293
312
  );
294
313
  case "List":
295
314
  return client.getListFeed(
296
315
  source.uri,
297
316
  Option.match(cursorOption, {
298
- onNone: () => undefined,
299
- onSome: (value) => ({ cursor: value })
317
+ onNone: () => ({ limit: pageLimit }),
318
+ onSome: (value) => ({ cursor: value, limit: pageLimit })
300
319
  })
301
320
  );
302
321
  case "Notifications":
303
322
  return client.getNotifications(
304
323
  Option.match(cursorOption, {
305
- onNone: () => undefined,
306
- onSome: (value) => ({ cursor: value })
324
+ onNone: () => ({ limit: pageLimit }),
325
+ onSome: (value) => ({ cursor: value, limit: pageLimit })
307
326
  })
308
327
  );
309
328
  case "Author":
310
329
  const authorOptions = {
330
+ limit: pageLimit,
311
331
  ...(source.filter !== undefined
312
332
  ? { filter: source.filter }
313
333
  : {}),
@@ -346,7 +366,10 @@ export class SyncEngine extends Context.Tag("@skygent/SyncEngine")<
346
366
  })
347
367
  );
348
368
  }
349
- })();
369
+ })() as Stream.Stream<RawPost, BskyError | SyncError>;
370
+ if (options?.limit !== undefined) {
371
+ stream = stream.pipe(Stream.take(options.limit));
372
+ }
350
373
 
351
374
  type SyncState = {
352
375
  readonly result: SyncResult;
@@ -403,6 +426,7 @@ export class SyncEngine extends Context.Tag("@skygent/SyncEngine")<
403
426
  };
404
427
 
405
428
  const startTime = yield* Clock.currentTimeMillis;
429
+ const progressIntervalMs = 5000;
406
430
  const initialState: SyncState = {
407
431
  result: initial,
408
432
  lastEventSeq: Option.none<EventSeq>(),
@@ -415,6 +439,33 @@ export class SyncEngine extends Context.Tag("@skygent/SyncEngine")<
415
439
  lastCheckpointAt: startTime
416
440
  };
417
441
  const stateRef = yield* Ref.make(initialState);
442
+ const heartbeat = Effect.gen(function* () {
443
+ while (true) {
444
+ yield* Effect.sleep(Duration.millis(progressIntervalMs));
445
+ const now = yield* Clock.currentTimeMillis;
446
+ const state = yield* Ref.get(stateRef);
447
+ if (now - state.lastReportAt < progressIntervalMs) {
448
+ continue;
449
+ }
450
+ const elapsedMs = now - startTime;
451
+ const rate = elapsedMs > 0 ? state.processed / (elapsedMs / 1000) : 0;
452
+ yield* reporter.report(
453
+ SyncProgress.make({
454
+ processed: state.processed,
455
+ stored: state.stored,
456
+ skipped: state.skipped,
457
+ errors: state.errors,
458
+ elapsedMs,
459
+ rate
460
+ })
461
+ );
462
+ yield* Ref.update(stateRef, (current) => ({
463
+ ...current,
464
+ lastReportAt: now
465
+ }));
466
+ }
467
+ });
468
+ const heartbeatFiber = yield* Effect.fork(heartbeat);
418
469
 
419
470
  const state = yield* stream.pipe(
420
471
  Stream.mapError(toSyncError("source", "Source stream failed")),
@@ -422,47 +473,48 @@ export class SyncEngine extends Context.Tag("@skygent/SyncEngine")<
422
473
  concurrency: settings.concurrency,
423
474
  unordered: false
424
475
  }),
476
+ Stream.grouped(settings.batchSize),
425
477
  Stream.runFoldEffect(
426
478
  initialState,
427
- (state, prepared) =>
479
+ (state, preparedChunk) =>
428
480
  Effect.gen(function* () {
429
- const outcome = yield* applyPrepared(prepared);
430
- const delta = (() => {
481
+ const preparedBatch = Chunk.toReadonlyArray(preparedChunk);
482
+ const outcomes = yield* applyPreparedBatch(preparedBatch);
483
+ let storedDelta = 0;
484
+ let skippedDelta = 0;
485
+ let errorDelta = 0;
486
+ let lastStoredSeq = Option.none<EventSeq>();
487
+ const errorList: Array<SyncError> = [];
488
+ for (const outcome of outcomes) {
431
489
  switch (outcome._tag) {
432
490
  case "Stored":
433
- return SyncResult.make({
434
- postsAdded: 1,
435
- postsDeleted: 0,
436
- postsSkipped: 0,
437
- errors: []
438
- });
491
+ storedDelta += 1;
492
+ lastStoredSeq = Option.some(outcome.eventSeq);
493
+ break;
439
494
  case "Skipped":
440
- return SyncResult.make({
441
- postsAdded: 0,
442
- postsDeleted: 0,
443
- postsSkipped: 1,
444
- errors: []
445
- });
495
+ skippedDelta += 1;
496
+ break;
446
497
  case "Error":
447
- return SyncResult.make({
448
- postsAdded: 0,
449
- postsDeleted: 0,
450
- postsSkipped: 1,
451
- errors: [outcome.error]
452
- });
498
+ skippedDelta += 1;
499
+ errorDelta += 1;
500
+ errorList.push(outcome.error);
501
+ break;
453
502
  }
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);
503
+ }
504
+ const delta = SyncResult.make({
505
+ postsAdded: storedDelta,
506
+ postsDeleted: 0,
507
+ postsSkipped: skippedDelta,
508
+ errors: errorList
509
+ });
510
+
511
+ const processed = state.processed + preparedBatch.length;
512
+ const stored = state.stored + storedDelta;
513
+ const skipped = state.skipped + skippedDelta;
514
+ const errors = state.errors + errorDelta;
463
515
  const now = yield* Clock.currentTimeMillis;
464
516
  const shouldReport =
465
- processed % 100 === 0 || now - state.lastReportAt >= 5000;
517
+ processed % 100 === 0 || now - state.lastReportAt >= progressIntervalMs;
466
518
  if (shouldReport) {
467
519
  const elapsedMs = now - startTime;
468
520
  const rate =
@@ -479,16 +531,21 @@ export class SyncEngine extends Context.Tag("@skygent/SyncEngine")<
479
531
  );
480
532
  }
481
533
 
482
- const nextCursor = prepared.pageCursor
483
- ? Option.some(prepared.pageCursor)
484
- : state.latestCursor;
534
+ const nextCursor = preparedBatch.reduce(
535
+ (cursor, prepared) =>
536
+ prepared.pageCursor
537
+ ? Option.some(prepared.pageCursor)
538
+ : cursor,
539
+ state.latestCursor
540
+ );
541
+ const nextLastEventSeq =
542
+ Option.isSome(lastStoredSeq)
543
+ ? lastStoredSeq
544
+ : state.lastEventSeq;
485
545
 
486
546
  const nextState: SyncState = {
487
547
  result: SyncResultMonoid.combine(state.result, delta),
488
- lastEventSeq:
489
- outcome._tag === "Stored"
490
- ? Option.some(outcome.eventSeq)
491
- : state.lastEventSeq,
548
+ lastEventSeq: nextLastEventSeq,
492
549
  latestCursor: nextCursor,
493
550
  processed,
494
551
  stored,
@@ -516,6 +573,9 @@ export class SyncEngine extends Context.Tag("@skygent/SyncEngine")<
516
573
  })
517
574
  ),
518
575
  Effect.withRequestBatching(true),
576
+ Effect.ensuring(
577
+ Fiber.interrupt(heartbeatFiber)
578
+ ),
519
579
  Effect.ensuring(
520
580
  Ref.get(stateRef).pipe(
521
581
  Effect.flatMap((state) =>
@@ -5,12 +5,14 @@ export class SyncReporter extends Context.Tag("@skygent/SyncReporter")<
5
5
  SyncReporter,
6
6
  {
7
7
  readonly report: (progress: SyncProgress) => Effect.Effect<void>;
8
+ readonly warn: (message: string, data?: Record<string, unknown>) => Effect.Effect<void>;
8
9
  }
9
10
  >() {
10
11
  static readonly layer = Layer.succeed(
11
12
  SyncReporter,
12
13
  SyncReporter.of({
13
- report: () => Effect.void
14
+ report: () => Effect.void,
15
+ warn: () => Effect.void
14
16
  })
15
17
  );
16
18
  }
@@ -5,6 +5,8 @@ export type SyncSettingsValue = {
5
5
  readonly checkpointEvery: number;
6
6
  readonly checkpointIntervalMs: number;
7
7
  readonly concurrency: number;
8
+ readonly batchSize: number;
9
+ readonly pageLimit: number;
8
10
  };
9
11
 
10
12
  type SyncSettingsOverridesValue = Partial<SyncSettingsValue>;
@@ -36,11 +38,19 @@ export class SyncSettings extends Context.Tag("@skygent/SyncSettings")<
36
38
  const concurrency = yield* Config.integer("SKYGENT_SYNC_CONCURRENCY").pipe(
37
39
  Config.withDefault(5)
38
40
  );
41
+ const batchSize = yield* Config.integer("SKYGENT_SYNC_BATCH_SIZE").pipe(
42
+ Config.withDefault(100)
43
+ );
44
+ const pageLimit = yield* Config.integer("SKYGENT_SYNC_PAGE_LIMIT").pipe(
45
+ Config.withDefault(100)
46
+ );
39
47
 
40
48
  const merged = {
41
49
  checkpointEvery,
42
50
  checkpointIntervalMs,
43
51
  concurrency,
52
+ batchSize,
53
+ pageLimit,
44
54
  ...pickDefined(overrides as Record<string, unknown>)
45
55
  } as SyncSettingsValue;
46
56
 
@@ -65,6 +75,20 @@ export class SyncSettings extends Context.Tag("@skygent/SyncSettings")<
65
75
  if (concurrencyError) {
66
76
  return yield* concurrencyError;
67
77
  }
78
+ const batchSizeError = validatePositive(
79
+ "SKYGENT_SYNC_BATCH_SIZE",
80
+ merged.batchSize
81
+ );
82
+ if (batchSizeError) {
83
+ return yield* batchSizeError;
84
+ }
85
+ const pageLimitError = validatePositive(
86
+ "SKYGENT_SYNC_PAGE_LIMIT",
87
+ merged.pageLimit
88
+ );
89
+ if (pageLimitError) {
90
+ return yield* pageLimitError;
91
+ }
68
92
 
69
93
  return SyncSettings.of(merged);
70
94
  })