@mepuka/skygent 0.2.0 → 0.3.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 (49) 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/config.ts +20 -3
  6. package/src/cli/doc/table-renderers.ts +29 -0
  7. package/src/cli/doc/thread.ts +2 -4
  8. package/src/cli/exit-codes.ts +2 -0
  9. package/src/cli/feed.ts +35 -55
  10. package/src/cli/filter-dsl.ts +146 -11
  11. package/src/cli/filter-errors.ts +9 -3
  12. package/src/cli/filter-help.ts +7 -0
  13. package/src/cli/filter-input.ts +3 -2
  14. package/src/cli/filter.ts +84 -4
  15. package/src/cli/graph.ts +193 -156
  16. package/src/cli/input.ts +45 -0
  17. package/src/cli/layers.ts +10 -0
  18. package/src/cli/logging.ts +8 -0
  19. package/src/cli/output-render.ts +14 -0
  20. package/src/cli/pagination.ts +18 -0
  21. package/src/cli/parse-errors.ts +18 -0
  22. package/src/cli/pipe.ts +157 -0
  23. package/src/cli/post.ts +43 -66
  24. package/src/cli/query.ts +349 -74
  25. package/src/cli/search.ts +92 -118
  26. package/src/cli/shared.ts +0 -19
  27. package/src/cli/store-errors.ts +24 -13
  28. package/src/cli/store-tree.ts +6 -4
  29. package/src/cli/store.ts +35 -2
  30. package/src/cli/stream-merge.ts +105 -0
  31. package/src/cli/sync-factory.ts +28 -3
  32. package/src/cli/sync.ts +16 -18
  33. package/src/cli/thread-options.ts +33 -0
  34. package/src/cli/time.ts +171 -0
  35. package/src/cli/view-thread.ts +12 -18
  36. package/src/cli/watch.ts +61 -19
  37. package/src/domain/errors.ts +6 -1
  38. package/src/domain/format.ts +21 -0
  39. package/src/domain/order.ts +24 -0
  40. package/src/graph/relationships.ts +129 -0
  41. package/src/services/jetstream-sync.ts +4 -4
  42. package/src/services/lineage-store.ts +15 -1
  43. package/src/services/store-commit.ts +60 -0
  44. package/src/services/store-manager.ts +69 -2
  45. package/src/services/store-renamer.ts +286 -0
  46. package/src/services/store-stats.ts +7 -5
  47. package/src/services/sync-engine.ts +136 -85
  48. package/src/services/sync-reporter.ts +3 -1
  49. package/src/services/sync-settings.ts +24 -0
@@ -38,7 +38,7 @@
38
38
 
39
39
  import { FileSystem, Path } from "@effect/platform";
40
40
  import { directorySize } from "./shared.js";
41
- import { Context, Effect, Layer, Option } from "effect";
41
+ import { Context, Effect, Layer, Option, Order } from "effect";
42
42
  import { AppConfigService } from "./app-config.js";
43
43
  import { StoreManager } from "./store-manager.js";
44
44
  import { StoreIndex } from "./store-index.js";
@@ -47,11 +47,12 @@ import { LineageStore } from "./lineage-store.js";
47
47
  import { DerivationValidator } from "./derivation-validator.js";
48
48
  import { StoreEventLog } from "./store-event-log.js";
49
49
  import { SyncCheckpointStore } from "./sync-checkpoint-store.js";
50
- import { DataSource } from "../domain/sync.js";
50
+ import { DataSource, type SyncCheckpoint } from "../domain/sync.js";
51
51
  import { StoreName, type StorePath } from "../domain/primitives.js";
52
52
  import { StoreRef } from "../domain/store.js";
53
53
  import type { StoreLineage } from "../domain/derivation.js";
54
54
  import { StoreIoError, type StoreIndexError } from "../domain/errors.js";
55
+ import { updatedAtOrder } from "../domain/order.js";
55
56
 
56
57
  /**
57
58
  * Detailed statistics for a single store.
@@ -257,9 +258,10 @@ const resolveSyncStatus = (
257
258
  if (candidates.length === 0) {
258
259
  return "unknown" as const;
259
260
  }
260
- const latest = candidates.sort(
261
- (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
262
- )[0];
261
+ const checkpointOrder = updatedAtOrder<SyncCheckpoint>();
262
+ const latest = candidates.reduce((acc, candidate) =>
263
+ Order.max(checkpointOrder)(acc, candidate)
264
+ );
263
265
  if (!latest || !latest.lastEventSeq) {
264
266
  return "stale" as const;
265
267
  }
@@ -59,7 +59,7 @@
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
64
  import { FilterRuntime } from "./filter-runtime.js";
65
65
  import { PostParser } from "./post-parser.js";
@@ -68,6 +68,7 @@ import { BskyClient } from "./bsky-client.js";
68
68
  import type { FilterExpr } from "../domain/filter.js";
69
69
  import { filterExprSignature } from "../domain/filter.js";
70
70
  import { EventMeta, PostUpsert } from "../domain/events.js";
71
+ import type { EventLogEntry } from "../domain/events.js";
71
72
  import type { Post } from "../domain/post.js";
72
73
  import { EventSeq, Timestamp } from "../domain/primitives.js";
73
74
  import type { RawPost } from "../domain/raw.js";
@@ -200,29 +201,10 @@ export class SyncEngine extends Context.Tag("@skygent/SyncEngine")<
200
201
  )
201
202
  );
202
203
 
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
- });
204
+ const buildUpsert = (post: Post) =>
205
+ makeMeta().pipe(
206
+ Effect.map((meta) => PostUpsert.make({ post, meta }))
207
+ );
226
208
 
227
209
  const prepareRaw = (raw: RawPost): Effect.Effect<PreparedOutcome, SyncError> =>
228
210
  parser.parsePost(raw).pipe(
@@ -244,23 +226,53 @@ export class SyncEngine extends Context.Tag("@skygent/SyncEngine")<
244
226
  )
245
227
  );
246
228
 
247
- const applyPrepared = (
248
- prepared: PreparedOutcome
249
- ): Effect.Effect<SyncOutcome, SyncError> =>
229
+ const commitStoreEvents = (events: ReadonlyArray<PostUpsert>) => {
230
+ if (events.length === 0) {
231
+ return Effect.succeed(
232
+ [] as ReadonlyArray<Option.Option<EventLogEntry>>
233
+ );
234
+ }
235
+ const commit = policy === "refresh"
236
+ ? committer
237
+ .appendUpserts(target, events)
238
+ .pipe(Effect.map((entries) => entries.map(Option.some)))
239
+ : committer.appendUpsertsIfMissing(target, events);
240
+ return commit.pipe(
241
+ Effect.mapError(
242
+ toSyncError("store", "Failed to append events")
243
+ )
244
+ );
245
+ };
246
+
247
+ const applyPreparedBatch = (
248
+ preparedBatch: ReadonlyArray<PreparedOutcome>
249
+ ): Effect.Effect<ReadonlyArray<SyncOutcome>, SyncError> =>
250
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
- });
251
+ const storeItems = preparedBatch.filter(
252
+ (item): item is Extract<PreparedOutcome, { _tag: "Store" }> =>
253
+ item._tag === "Store"
254
+ );
255
+ const events = yield* Effect.forEach(storeItems, (item) =>
256
+ buildUpsert(item.post)
257
+ );
258
+ const storedEntries = yield* commitStoreEvents(events);
259
+ let storeIndex = 0;
260
+ return preparedBatch.map((prepared) => {
261
+ switch (prepared._tag) {
262
+ case "Skip":
263
+ return skippedOutcome;
264
+ case "Error":
265
+ return { _tag: "Error", error: prepared.error } as const;
266
+ case "Store": {
267
+ const entry = storedEntries[storeIndex++] ?? Option.none();
268
+ return Option.match(entry, {
269
+ onNone: () => skippedOutcome,
270
+ onSome: (record) =>
271
+ ({ _tag: "Stored", eventSeq: record.seq } as const)
272
+ });
273
+ }
262
274
  }
263
- }
275
+ });
264
276
  });
265
277
 
266
278
  const initial = SyncResultMonoid.empty;
@@ -273,41 +285,43 @@ export class SyncEngine extends Context.Tag("@skygent/SyncEngine")<
273
285
  const cursorOption = Option.flatMap(activeCheckpoint, (value) =>
274
286
  Option.fromNullable(value.cursor)
275
287
  );
288
+ const pageLimit = settings.pageLimit;
276
289
 
277
290
  const stream = (() => {
278
291
  switch (source._tag) {
279
292
  case "Timeline":
280
293
  return client.getTimeline(
281
294
  Option.match(cursorOption, {
282
- onNone: () => undefined,
283
- onSome: (value) => ({ cursor: value })
295
+ onNone: () => ({ limit: pageLimit }),
296
+ onSome: (value) => ({ cursor: value, limit: pageLimit })
284
297
  })
285
298
  );
286
299
  case "Feed":
287
300
  return client.getFeed(
288
301
  source.uri,
289
302
  Option.match(cursorOption, {
290
- onNone: () => undefined,
291
- onSome: (value) => ({ cursor: value })
303
+ onNone: () => ({ limit: pageLimit }),
304
+ onSome: (value) => ({ cursor: value, limit: pageLimit })
292
305
  })
293
306
  );
294
307
  case "List":
295
308
  return client.getListFeed(
296
309
  source.uri,
297
310
  Option.match(cursorOption, {
298
- onNone: () => undefined,
299
- onSome: (value) => ({ cursor: value })
311
+ onNone: () => ({ limit: pageLimit }),
312
+ onSome: (value) => ({ cursor: value, limit: pageLimit })
300
313
  })
301
314
  );
302
315
  case "Notifications":
303
316
  return client.getNotifications(
304
317
  Option.match(cursorOption, {
305
- onNone: () => undefined,
306
- onSome: (value) => ({ cursor: value })
318
+ onNone: () => ({ limit: pageLimit }),
319
+ onSome: (value) => ({ cursor: value, limit: pageLimit })
307
320
  })
308
321
  );
309
322
  case "Author":
310
323
  const authorOptions = {
324
+ limit: pageLimit,
311
325
  ...(source.filter !== undefined
312
326
  ? { filter: source.filter }
313
327
  : {}),
@@ -403,6 +417,7 @@ export class SyncEngine extends Context.Tag("@skygent/SyncEngine")<
403
417
  };
404
418
 
405
419
  const startTime = yield* Clock.currentTimeMillis;
420
+ const progressIntervalMs = 5000;
406
421
  const initialState: SyncState = {
407
422
  result: initial,
408
423
  lastEventSeq: Option.none<EventSeq>(),
@@ -415,6 +430,33 @@ export class SyncEngine extends Context.Tag("@skygent/SyncEngine")<
415
430
  lastCheckpointAt: startTime
416
431
  };
417
432
  const stateRef = yield* Ref.make(initialState);
433
+ const heartbeat = Effect.gen(function* () {
434
+ while (true) {
435
+ yield* Effect.sleep(Duration.millis(progressIntervalMs));
436
+ const now = yield* Clock.currentTimeMillis;
437
+ const state = yield* Ref.get(stateRef);
438
+ if (now - state.lastReportAt < progressIntervalMs) {
439
+ continue;
440
+ }
441
+ const elapsedMs = now - startTime;
442
+ const rate = elapsedMs > 0 ? state.processed / (elapsedMs / 1000) : 0;
443
+ yield* reporter.report(
444
+ SyncProgress.make({
445
+ processed: state.processed,
446
+ stored: state.stored,
447
+ skipped: state.skipped,
448
+ errors: state.errors,
449
+ elapsedMs,
450
+ rate
451
+ })
452
+ );
453
+ yield* Ref.update(stateRef, (current) => ({
454
+ ...current,
455
+ lastReportAt: now
456
+ }));
457
+ }
458
+ });
459
+ const heartbeatFiber = yield* Effect.fork(heartbeat);
418
460
 
419
461
  const state = yield* stream.pipe(
420
462
  Stream.mapError(toSyncError("source", "Source stream failed")),
@@ -422,47 +464,48 @@ export class SyncEngine extends Context.Tag("@skygent/SyncEngine")<
422
464
  concurrency: settings.concurrency,
423
465
  unordered: false
424
466
  }),
467
+ Stream.grouped(settings.batchSize),
425
468
  Stream.runFoldEffect(
426
469
  initialState,
427
- (state, prepared) =>
470
+ (state, preparedChunk) =>
428
471
  Effect.gen(function* () {
429
- const outcome = yield* applyPrepared(prepared);
430
- const delta = (() => {
472
+ const preparedBatch = Chunk.toReadonlyArray(preparedChunk);
473
+ const outcomes = yield* applyPreparedBatch(preparedBatch);
474
+ let storedDelta = 0;
475
+ let skippedDelta = 0;
476
+ let errorDelta = 0;
477
+ let lastStoredSeq = Option.none<EventSeq>();
478
+ const errorList: Array<SyncError> = [];
479
+ for (const outcome of outcomes) {
431
480
  switch (outcome._tag) {
432
481
  case "Stored":
433
- return SyncResult.make({
434
- postsAdded: 1,
435
- postsDeleted: 0,
436
- postsSkipped: 0,
437
- errors: []
438
- });
482
+ storedDelta += 1;
483
+ lastStoredSeq = Option.some(outcome.eventSeq);
484
+ break;
439
485
  case "Skipped":
440
- return SyncResult.make({
441
- postsAdded: 0,
442
- postsDeleted: 0,
443
- postsSkipped: 1,
444
- errors: []
445
- });
486
+ skippedDelta += 1;
487
+ break;
446
488
  case "Error":
447
- return SyncResult.make({
448
- postsAdded: 0,
449
- postsDeleted: 0,
450
- postsSkipped: 1,
451
- errors: [outcome.error]
452
- });
489
+ skippedDelta += 1;
490
+ errorDelta += 1;
491
+ errorList.push(outcome.error);
492
+ break;
453
493
  }
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);
494
+ }
495
+ const delta = SyncResult.make({
496
+ postsAdded: storedDelta,
497
+ postsDeleted: 0,
498
+ postsSkipped: skippedDelta,
499
+ errors: errorList
500
+ });
501
+
502
+ const processed = state.processed + preparedBatch.length;
503
+ const stored = state.stored + storedDelta;
504
+ const skipped = state.skipped + skippedDelta;
505
+ const errors = state.errors + errorDelta;
463
506
  const now = yield* Clock.currentTimeMillis;
464
507
  const shouldReport =
465
- processed % 100 === 0 || now - state.lastReportAt >= 5000;
508
+ processed % 100 === 0 || now - state.lastReportAt >= progressIntervalMs;
466
509
  if (shouldReport) {
467
510
  const elapsedMs = now - startTime;
468
511
  const rate =
@@ -479,16 +522,21 @@ export class SyncEngine extends Context.Tag("@skygent/SyncEngine")<
479
522
  );
480
523
  }
481
524
 
482
- const nextCursor = prepared.pageCursor
483
- ? Option.some(prepared.pageCursor)
484
- : state.latestCursor;
525
+ const nextCursor = preparedBatch.reduce(
526
+ (cursor, prepared) =>
527
+ prepared.pageCursor
528
+ ? Option.some(prepared.pageCursor)
529
+ : cursor,
530
+ state.latestCursor
531
+ );
532
+ const nextLastEventSeq =
533
+ Option.isSome(lastStoredSeq)
534
+ ? lastStoredSeq
535
+ : state.lastEventSeq;
485
536
 
486
537
  const nextState: SyncState = {
487
538
  result: SyncResultMonoid.combine(state.result, delta),
488
- lastEventSeq:
489
- outcome._tag === "Stored"
490
- ? Option.some(outcome.eventSeq)
491
- : state.lastEventSeq,
539
+ lastEventSeq: nextLastEventSeq,
492
540
  latestCursor: nextCursor,
493
541
  processed,
494
542
  stored,
@@ -516,6 +564,9 @@ export class SyncEngine extends Context.Tag("@skygent/SyncEngine")<
516
564
  })
517
565
  ),
518
566
  Effect.withRequestBatching(true),
567
+ Effect.ensuring(
568
+ Fiber.interrupt(heartbeatFiber)
569
+ ),
519
570
  Effect.ensuring(
520
571
  Ref.get(stateRef).pipe(
521
572
  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
  })