@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
package/src/cli/store.ts CHANGED
@@ -14,12 +14,14 @@ import { StoreCleaner } from "../services/store-cleaner.js";
14
14
  import { LineageStore } from "../services/lineage-store.js";
15
15
  import { CliInputError } from "./errors.js";
16
16
  import { OutputManager } from "../services/output-manager.js";
17
- import { formatStoreConfigParseError } from "./store-errors.js";
17
+ import { formatStoreConfigHelp, formatStoreConfigParseError } from "./store-errors.js";
18
18
  import { formatFilterExpr } from "../domain/filter-describe.js";
19
19
  import { CliPreferences } from "./preferences.js";
20
20
  import { StoreStats } from "../services/store-stats.js";
21
21
  import { withExamples } from "./help.js";
22
22
  import { resolveOutputFormat, treeTableJsonFormats } from "./output-format.js";
23
+ import { StoreRenamer } from "../services/store-renamer.js";
24
+ import { PositiveInt } from "./option-schemas.js";
23
25
  import {
24
26
  buildStoreTreeData,
25
27
  renderStoreTree,
@@ -33,6 +35,14 @@ const storeNameArg = Args.text({ name: "name" }).pipe(
33
35
  Args.withSchema(StoreName),
34
36
  Args.withDescription("Store name")
35
37
  );
38
+ const storeRenameFromArg = Args.text({ name: "from" }).pipe(
39
+ Args.withSchema(StoreName),
40
+ Args.withDescription("Existing store name")
41
+ );
42
+ const storeRenameToArg = Args.text({ name: "to" }).pipe(
43
+ Args.withSchema(StoreName),
44
+ Args.withDescription("New store name")
45
+ );
36
46
  const storeNameOption = Options.text("store").pipe(
37
47
  Options.withSchema(StoreName),
38
48
  Options.withDescription("Store name")
@@ -53,6 +63,7 @@ const treeAnsiOption = Options.boolean("ansi").pipe(
53
63
  Options.withDescription("Enable ANSI color output for tree format")
54
64
  );
55
65
  const treeWidthOption = Options.integer("width").pipe(
66
+ Options.withSchema(PositiveInt),
56
67
  Options.withDescription("Line width for tree rendering (enables wrapping)"),
57
68
  Options.optional
58
69
  );
@@ -239,6 +250,10 @@ export const storeDelete = Command.make(
239
250
  const cleaner = yield* StoreCleaner;
240
251
  const result = yield* cleaner.deleteStore(name);
241
252
  if (!result.deleted) {
253
+ if (result.reason === "missing") {
254
+ yield* writeJson(result);
255
+ return;
256
+ }
242
257
  return yield* CliInputError.make({
243
258
  message: `Store "${name}" was not deleted.`,
244
259
  cause: result
@@ -254,6 +269,27 @@ export const storeDelete = Command.make(
254
269
  )
255
270
  );
256
271
 
272
+ export const storeRename = Command.make(
273
+ "rename",
274
+ { from: storeRenameFromArg, to: storeRenameToArg },
275
+ ({ from, to }) =>
276
+ Effect.gen(function* () {
277
+ if (from === to) {
278
+ return yield* CliInputError.make({
279
+ message: "Old and new store names must be different.",
280
+ cause: { from, to }
281
+ });
282
+ }
283
+ const renamer = yield* StoreRenamer;
284
+ const result = yield* renamer.rename(from, to);
285
+ yield* writeJson(result);
286
+ })
287
+ ).pipe(
288
+ Command.withDescription(
289
+ withExamples("Rename a store", ["skygent store rename old-name new-name"])
290
+ )
291
+ );
292
+
257
293
  export const storeMaterialize = Command.make(
258
294
  "materialize",
259
295
  { name: storeNameArg, filter: filterNameOption },
@@ -267,7 +303,9 @@ export const storeMaterialize = Command.make(
267
303
 
268
304
  if (config.filters.length === 0) {
269
305
  return yield* CliInputError.make({
270
- message: `Store "${name}" has no configured filters to materialize. Update the store config to add filters.`,
306
+ message: formatStoreConfigHelp(
307
+ `Store "${name}" has no configured filters to materialize. Add filters to the store config.`
308
+ ),
271
309
  cause: { store: name }
272
310
  });
273
311
  }
@@ -378,6 +416,7 @@ export const storeCommand = Command.make("store", {}).pipe(
378
416
  storeCreate,
379
417
  storeList,
380
418
  storeShow,
419
+ storeRename,
381
420
  storeDelete,
382
421
  storeMaterialize,
383
422
  storeStats,
@@ -0,0 +1,105 @@
1
+ import { Chunk, Effect, Option, Order, Stream } from "effect";
2
+
3
+ export const mergeOrderedStreams = <A, E, R>(
4
+ streams: ReadonlyArray<Stream.Stream<A, E, R>>,
5
+ order: Order.Order<A>
6
+ ): Stream.Stream<A, E, R> => {
7
+ if (streams.length === 0) {
8
+ return Stream.empty;
9
+ }
10
+
11
+ return Stream.unwrapScoped(
12
+ Effect.gen(function* () {
13
+ const pulls = yield* Effect.forEach(streams, (stream) => Stream.toPull(stream), {
14
+ discard: false
15
+ });
16
+
17
+ const buffers: Array<ReadonlyArray<A>> = pulls.map(() => []);
18
+ const indices: number[] = pulls.map(() => 0);
19
+ const heads: Array<A | undefined> = pulls.map(() => undefined);
20
+ let active = pulls.length;
21
+
22
+ const pullChunk = (index: number) =>
23
+ pulls[index]!.pipe(
24
+ Effect.map(Option.some),
25
+ Effect.catchAll((cause) =>
26
+ Option.match(cause, {
27
+ onNone: () => Effect.succeed(Option.none()),
28
+ onSome: (error) => Effect.fail(error)
29
+ })
30
+ )
31
+ );
32
+
33
+ const nextValue = (index: number): Effect.Effect<Option.Option<A>, E, R> =>
34
+ Effect.gen(function* () {
35
+ while (true) {
36
+ const buffer = buffers[index] ?? [];
37
+ const position = indices[index] ?? 0;
38
+ if (position < buffer.length) {
39
+ const value = buffer[position]!;
40
+ indices[index] = position + 1;
41
+ return Option.some(value);
42
+ }
43
+
44
+ const nextChunkOption = yield* pullChunk(index);
45
+ if (Option.isNone(nextChunkOption)) {
46
+ return Option.none<A>();
47
+ }
48
+ const nextChunk = Chunk.toReadonlyArray(nextChunkOption.value);
49
+ if (nextChunk.length === 0) {
50
+ continue;
51
+ }
52
+ buffers[index] = nextChunk;
53
+ indices[index] = 1;
54
+ return Option.some(nextChunk[0]!);
55
+ }
56
+ });
57
+
58
+ for (let index = 0; index < pulls.length; index += 1) {
59
+ const next = yield* nextValue(index);
60
+ if (Option.isNone(next)) {
61
+ active -= 1;
62
+ heads[index] = undefined;
63
+ } else {
64
+ heads[index] = next.value;
65
+ }
66
+ }
67
+
68
+ const pull: Effect.Effect<Chunk.Chunk<A>, Option.Option<E>, R> =
69
+ Effect.gen(function* () {
70
+ if (active === 0) {
71
+ return yield* Effect.fail(Option.none<E>());
72
+ }
73
+
74
+ let selectedIndex = -1;
75
+ let selectedValue: A | undefined;
76
+ for (let index = 0; index < heads.length; index += 1) {
77
+ const value = heads[index];
78
+ if (value === undefined) continue;
79
+ if (selectedIndex < 0 || order(value, selectedValue as A) < 0) {
80
+ selectedIndex = index;
81
+ selectedValue = value;
82
+ }
83
+ }
84
+
85
+ if (selectedIndex < 0 || selectedValue === undefined) {
86
+ return yield* Effect.fail(Option.none<E>());
87
+ }
88
+
89
+ const next = yield* nextValue(selectedIndex).pipe(
90
+ Effect.mapError(Option.some)
91
+ );
92
+ if (Option.isNone(next)) {
93
+ heads[selectedIndex] = undefined;
94
+ active -= 1;
95
+ } else {
96
+ heads[selectedIndex] = next.value;
97
+ }
98
+
99
+ return Chunk.of(selectedValue);
100
+ });
101
+
102
+ return Stream.fromPull(Effect.succeed(pull));
103
+ })
104
+ );
105
+ };
@@ -1,14 +1,15 @@
1
- import { Effect, Option, Stream } from "effect";
1
+ import { Duration, Effect, Option, Stream } from "effect";
2
2
  import { DataSource, SyncResult, WatchConfig } from "../domain/sync.js";
3
3
  import { SyncEngine } from "../services/sync-engine.js";
4
4
  import { SyncReporter } from "../services/sync-reporter.js";
5
5
  import { OutputManager } from "../services/output-manager.js";
6
6
  import { ResourceMonitor } from "../services/resource-monitor.js";
7
+ import { StoreIndex } from "../services/store-index.js";
7
8
  import { parseFilterExpr } from "./filter-input.js";
8
9
  import { CliOutput, writeJson, writeJsonStream } from "./output.js";
9
10
  import { storeOptions } from "./store.js";
10
11
  import { logInfo, logWarn, makeSyncReporter } from "./logging.js";
11
- import { parseInterval } from "./interval.js";
12
+ import { parseInterval, parseOptionalDuration } from "./interval.js";
12
13
  import type { StoreName } from "../domain/primitives.js";
13
14
 
14
15
  /** Common options shared by sync and watch API-based commands */
@@ -18,6 +19,7 @@ export interface CommonCommandInput {
18
19
  readonly filterJson: Option.Option<string>;
19
20
  readonly quiet: boolean;
20
21
  readonly refresh: boolean;
22
+ readonly limit?: Option.Option<number>;
21
23
  }
22
24
 
23
25
  /** Build the command body for a one-shot sync command (timeline, feed, notifications). */
@@ -32,6 +34,7 @@ export const makeSyncCommandBody = (
32
34
  const monitor = yield* ResourceMonitor;
33
35
  const output = yield* CliOutput;
34
36
  const outputManager = yield* OutputManager;
37
+ const index = yield* StoreIndex;
35
38
  const storeRef = yield* storeOptions.loadStoreRef(input.store);
36
39
  const storeConfig = yield* storeOptions.loadStoreConfig(input.store);
37
40
  const expr = yield* parseFilterExpr(input.filter, input.filterJson);
@@ -44,8 +47,12 @@ export const makeSyncCommandBody = (
44
47
  store: storeRef.name
45
48
  });
46
49
  }
50
+ const limitValue = Option.getOrUndefined(input.limit ?? Option.none());
47
51
  const result = yield* sync
48
- .sync(makeDataSource(), storeRef, expr, { policy })
52
+ .sync(makeDataSource(), storeRef, expr, {
53
+ policy,
54
+ ...(limitValue !== undefined ? { limit: limitValue } : {})
55
+ })
49
56
  .pipe(
50
57
  Effect.provideService(SyncReporter, makeSyncReporter(input.quiet, monitor, output))
51
58
  );
@@ -56,13 +63,16 @@ export const makeSyncCommandBody = (
56
63
  filters: materialized.filters.map((spec) => spec.name)
57
64
  });
58
65
  }
66
+ const totalPosts = yield* index.count(storeRef);
59
67
  yield* logInfo("Sync complete", { source: sourceName, store: storeRef.name, ...extraLogFields });
60
- yield* writeJson(result as SyncResult);
68
+ yield* writeJson({ ...(result as SyncResult), totalPosts });
61
69
  });
62
70
 
63
71
  /** Common options for watch API-based commands */
64
72
  export interface WatchCommandInput extends CommonCommandInput {
65
- readonly interval: Option.Option<string>;
73
+ readonly interval: Option.Option<Duration.Duration>;
74
+ readonly maxCycles: Option.Option<number>;
75
+ readonly until: Option.Option<Duration.Duration>;
66
76
  }
67
77
 
68
78
  /** Build the command body for a watch command (timeline, feed, notifications). */
@@ -81,7 +91,8 @@ export const makeWatchCommandBody = (
81
91
  const expr = yield* parseFilterExpr(input.filter, input.filterJson);
82
92
  const basePolicy = storeConfig.syncPolicy ?? "dedupe";
83
93
  const policy = input.refresh ? "refresh" : basePolicy;
84
- const parsedInterval = yield* parseInterval(input.interval);
94
+ const parsedInterval = parseInterval(input.interval);
95
+ const parsedUntil = parseOptionalDuration(input.until);
85
96
  yield* logInfo("Starting watch", { source: sourceName, store: storeRef.name, ...extraLogFields });
86
97
  if (policy === "refresh") {
87
98
  yield* logWarn("Refresh mode updates existing posts and may grow the event log.", {
@@ -89,7 +100,7 @@ export const makeWatchCommandBody = (
89
100
  store: storeRef.name
90
101
  });
91
102
  }
92
- const stream = sync
103
+ let stream = sync
93
104
  .watch(
94
105
  WatchConfig.make({
95
106
  source: makeDataSource(),
@@ -103,5 +114,11 @@ export const makeWatchCommandBody = (
103
114
  Stream.map((event) => event.result),
104
115
  Stream.provideService(SyncReporter, makeSyncReporter(input.quiet, monitor, output))
105
116
  );
117
+ if (Option.isSome(input.maxCycles)) {
118
+ stream = stream.pipe(Stream.take(input.maxCycles.value));
119
+ }
120
+ if (Option.isSome(parsedUntil)) {
121
+ stream = stream.pipe(Stream.interruptWhen(Effect.sleep(parsedUntil.value)));
122
+ }
106
123
  yield* writeJsonStream(stream);
107
124
  });
package/src/cli/sync.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Command, Options } from "@effect/cli";
2
- import { Duration, Effect, Layer, Option } from "effect";
2
+ import { Effect, Layer, Option } from "effect";
3
3
  import { Jetstream } from "effect-jetstream";
4
4
  import { filterExprSignature } from "../domain/filter.js";
5
5
  import { DataSource, SyncResult } from "../domain/sync.js";
@@ -9,6 +9,7 @@ import { logInfo, makeSyncReporter } from "./logging.js";
9
9
  import { SyncReporter } from "../services/sync-reporter.js";
10
10
  import { ResourceMonitor } from "../services/resource-monitor.js";
11
11
  import { OutputManager } from "../services/output-manager.js";
12
+ import { StoreIndex } from "../services/store-index.js";
12
13
  import { CliOutput, writeJson } from "./output.js";
13
14
  import { parseFilterExpr } from "./filter-input.js";
14
15
  import { withExamples } from "./help.js";
@@ -27,61 +28,44 @@ import {
27
28
  postFilterJsonOption,
28
29
  authorFilterOption,
29
30
  includePinsOption,
30
- decodeActor,
31
31
  quietOption,
32
32
  refreshOption,
33
33
  strictOption,
34
- maxErrorsOption,
35
- parseMaxErrors,
36
- parseLimit,
37
- parseBoundedIntOption
34
+ maxErrorsOption
38
35
  } from "./shared-options.js";
36
+ import {
37
+ depthOption as threadDepthOption,
38
+ parentHeightOption as threadParentHeightOption,
39
+ parseThreadDepth
40
+ } from "./thread-options.js";
41
+ import { DurationInput, PositiveInt } from "./option-schemas.js";
39
42
 
40
- const limitOption = Options.integer("limit").pipe(
43
+ const syncLimitOption = Options.integer("limit").pipe(
44
+ Options.withSchema(PositiveInt),
45
+ Options.withDescription("Maximum number of posts to sync"),
46
+ Options.optional
47
+ );
48
+ const jetstreamLimitOption = Options.integer("limit").pipe(
49
+ Options.withSchema(PositiveInt),
41
50
  Options.withDescription("Maximum number of Jetstream events to process"),
42
51
  Options.optional
43
52
  );
44
53
  const durationOption = Options.text("duration").pipe(
54
+ Options.withSchema(DurationInput),
45
55
  Options.withDescription("Stop after a duration (e.g. \"2 minutes\")"),
46
56
  Options.optional
47
57
  );
48
- const depthOption = Options.integer("depth").pipe(
49
- Options.withDescription("Thread reply depth to include (0-1000, default 6)"),
50
- Options.optional
58
+ const depthOption = threadDepthOption(
59
+ "Thread reply depth to include (0-1000, default 6)"
51
60
  );
52
- const parentHeightOption = Options.integer("parent-height").pipe(
53
- Options.withDescription("Thread parent height to include (0-1000, default 80)"),
54
- Options.optional
61
+ const parentHeightOption = threadParentHeightOption(
62
+ "Thread parent height to include (0-1000, default 80)"
55
63
  );
56
64
 
57
- const parseDuration = (value: Option.Option<string>) =>
58
- Option.match(value, {
59
- onNone: () => Effect.succeed(Option.none()),
60
- onSome: (raw) =>
61
- Effect.try({
62
- try: () => Duration.decode(raw as Duration.DurationInput),
63
- catch: (cause) =>
64
- CliInputError.make({
65
- message: `Invalid duration: ${raw}. Use formats like \"2 minutes\".`,
66
- cause
67
- })
68
- }).pipe(
69
- Effect.flatMap((duration) =>
70
- Duration.toMillis(duration) < 0
71
- ? Effect.fail(
72
- CliInputError.make({
73
- message: "Duration must be non-negative.",
74
- cause: duration
75
- })
76
- )
77
- : Effect.succeed(Option.some(duration))
78
- )
79
- )
80
- });
81
65
 
82
66
  const timelineCommand = Command.make(
83
67
  "timeline",
84
- { store: storeNameOption, filter: filterOption, filterJson: filterJsonOption, quiet: quietOption, refresh: refreshOption },
68
+ { store: storeNameOption, filter: filterOption, filterJson: filterJsonOption, quiet: quietOption, refresh: refreshOption, limit: syncLimitOption },
85
69
  makeSyncCommandBody("timeline", () => DataSource.timeline())
86
70
  ).pipe(
87
71
  Command.withDescription(
@@ -98,7 +82,7 @@ const timelineCommand = Command.make(
98
82
 
99
83
  const feedCommand = Command.make(
100
84
  "feed",
101
- { uri: feedUriArg, store: storeNameOption, filter: filterOption, filterJson: filterJsonOption, quiet: quietOption, refresh: refreshOption },
85
+ { uri: feedUriArg, store: storeNameOption, filter: filterOption, filterJson: filterJsonOption, quiet: quietOption, refresh: refreshOption, limit: syncLimitOption },
102
86
  ({ uri, ...rest }) => makeSyncCommandBody("feed", () => DataSource.feed(uri), { uri })(rest)
103
87
  ).pipe(
104
88
  Command.withDescription(
@@ -114,7 +98,7 @@ const feedCommand = Command.make(
114
98
 
115
99
  const listCommand = Command.make(
116
100
  "list",
117
- { uri: listUriArg, store: storeNameOption, filter: filterOption, filterJson: filterJsonOption, quiet: quietOption, refresh: refreshOption },
101
+ { uri: listUriArg, store: storeNameOption, filter: filterOption, filterJson: filterJsonOption, quiet: quietOption, refresh: refreshOption, limit: syncLimitOption },
118
102
  ({ uri, ...rest }) => makeSyncCommandBody("list", () => DataSource.list(uri), { uri })(rest)
119
103
  ).pipe(
120
104
  Command.withDescription(
@@ -130,7 +114,7 @@ const listCommand = Command.make(
130
114
 
131
115
  const notificationsCommand = Command.make(
132
116
  "notifications",
133
- { store: storeNameOption, filter: filterOption, filterJson: filterJsonOption, quiet: quietOption, refresh: refreshOption },
117
+ { store: storeNameOption, filter: filterOption, filterJson: filterJsonOption, quiet: quietOption, refresh: refreshOption, limit: syncLimitOption },
134
118
  makeSyncCommandBody("notifications", () => DataSource.notifications())
135
119
  ).pipe(
136
120
  Command.withDescription(
@@ -152,18 +136,18 @@ const authorCommand = Command.make(
152
136
  postFilter: postFilterOption,
153
137
  postFilterJson: postFilterJsonOption,
154
138
  quiet: quietOption,
155
- refresh: refreshOption
139
+ refresh: refreshOption,
140
+ limit: syncLimitOption
156
141
  },
157
- ({ actor, filter, includePins, postFilter, postFilterJson, store, quiet, refresh }) =>
142
+ ({ actor, filter, includePins, postFilter, postFilterJson, store, quiet, refresh, limit }) =>
158
143
  Effect.gen(function* () {
159
- const resolvedActor = yield* decodeActor(actor);
160
144
  const apiFilter = Option.getOrUndefined(filter);
161
- const source = DataSource.author(resolvedActor, {
145
+ const source = DataSource.author(actor, {
162
146
  ...(apiFilter !== undefined ? { filter: apiFilter } : {}),
163
147
  ...(includePins ? { includePins: true } : {})
164
148
  });
165
149
  const run = makeSyncCommandBody("author", () => source, {
166
- actor: resolvedActor,
150
+ actor,
167
151
  ...(apiFilter !== undefined ? { filter: apiFilter } : {}),
168
152
  ...(includePins ? { includePins: true } : {})
169
153
  });
@@ -172,7 +156,8 @@ const authorCommand = Command.make(
172
156
  filter: postFilter,
173
157
  filterJson: postFilterJson,
174
158
  quiet,
175
- refresh
159
+ refresh,
160
+ limit
176
161
  });
177
162
  })
178
163
  ).pipe(
@@ -198,19 +183,13 @@ const threadCommand = Command.make(
198
183
  filter: filterOption,
199
184
  filterJson: filterJsonOption,
200
185
  quiet: quietOption,
201
- refresh: refreshOption
186
+ refresh: refreshOption,
187
+ limit: syncLimitOption
202
188
  },
203
- ({ uri, depth, parentHeight, filter, filterJson, store, quiet, refresh }) =>
189
+ ({ uri, depth, parentHeight, filter, filterJson, store, quiet, refresh, limit }) =>
204
190
  Effect.gen(function* () {
205
- const parsedDepth = yield* parseBoundedIntOption(depth, "depth", 0, 1000);
206
- const parsedParentHeight = yield* parseBoundedIntOption(
207
- parentHeight,
208
- "parent-height",
209
- 0,
210
- 1000
211
- );
212
- const depthValue = Option.getOrUndefined(parsedDepth);
213
- const parentHeightValue = Option.getOrUndefined(parsedParentHeight);
191
+ const { depth: depthValue, parentHeight: parentHeightValue } =
192
+ parseThreadDepth(depth, parentHeight);
214
193
  const source = DataSource.thread(uri, {
215
194
  ...(depthValue !== undefined ? { depth: depthValue } : {}),
216
195
  ...(parentHeightValue !== undefined ? { parentHeight: parentHeightValue } : {})
@@ -220,7 +199,7 @@ const threadCommand = Command.make(
220
199
  ...(depthValue !== undefined ? { depth: depthValue } : {}),
221
200
  ...(parentHeightValue !== undefined ? { parentHeight: parentHeightValue } : {})
222
201
  });
223
- return yield* run({ store, filter, filterJson, quiet, refresh });
202
+ return yield* run({ store, filter, filterJson, quiet, refresh, limit });
224
203
  })
225
204
  ).pipe(
226
205
  Command.withDescription(
@@ -248,7 +227,7 @@ const jetstreamCommand = Command.make(
248
227
  cursor: jetstreamOptions.cursor,
249
228
  compress: jetstreamOptions.compress,
250
229
  maxMessageSize: jetstreamOptions.maxMessageSize,
251
- limit: limitOption,
230
+ limit: jetstreamLimitOption,
252
231
  duration: durationOption,
253
232
  strict: strictOption,
254
233
  maxErrors: maxErrorsOption
@@ -273,6 +252,7 @@ const jetstreamCommand = Command.make(
273
252
  const monitor = yield* ResourceMonitor;
274
253
  const output = yield* CliOutput;
275
254
  const outputManager = yield* OutputManager;
255
+ const index = yield* StoreIndex;
276
256
  const storeRef = yield* storeOptions.loadStoreRef(store);
277
257
  const expr = yield* parseFilterExpr(filter, filterJson);
278
258
  const filterHash = filterExprSignature(expr);
@@ -288,10 +268,8 @@ const jetstreamCommand = Command.make(
288
268
  storeRef,
289
269
  filterHash
290
270
  );
291
- const parsedLimit = yield* parseLimit(limit);
292
- const parsedDuration = yield* parseDuration(duration);
293
- const parsedMaxErrors = yield* parseMaxErrors(maxErrors);
294
- if (Option.isNone(parsedLimit) && Option.isNone(parsedDuration)) {
271
+ const parsedDuration = duration;
272
+ if (Option.isNone(limit) && Option.isNone(parsedDuration)) {
295
273
  return yield* CliInputError.make({
296
274
  message:
297
275
  "Jetstream sync requires --limit or --duration. Use watch jetstream for continuous streaming.",
@@ -307,9 +285,9 @@ const jetstreamCommand = Command.make(
307
285
  });
308
286
  const result = yield* Effect.gen(function* () {
309
287
  const engine = yield* JetstreamSyncEngine;
310
- const limitValue = Option.getOrUndefined(parsedLimit);
288
+ const limitValue = Option.getOrUndefined(limit);
311
289
  const durationValue = Option.getOrUndefined(parsedDuration);
312
- const maxErrorsValue = Option.getOrUndefined(parsedMaxErrors);
290
+ const maxErrorsValue = Option.getOrUndefined(maxErrors);
313
291
  return yield* engine.sync({
314
292
  source: selection.source,
315
293
  store: storeRef,
@@ -332,8 +310,9 @@ const jetstreamCommand = Command.make(
332
310
  filters: materialized.filters.map((spec) => spec.name)
333
311
  });
334
312
  }
313
+ const totalPosts = yield* index.count(storeRef);
335
314
  yield* logInfo("Sync complete", { source: "jetstream", store: storeRef.name });
336
- yield* writeJson(result as SyncResult);
315
+ yield* writeJson({ ...(result as SyncResult), totalPosts });
337
316
  })
338
317
  ).pipe(
339
318
  Command.withDescription(
@@ -0,0 +1,25 @@
1
+ import { Options } from "@effect/cli";
2
+ import { Option } from "effect";
3
+ import { boundedInt } from "./option-schemas.js";
4
+
5
+ export const depthOption = (description: string) =>
6
+ Options.integer("depth").pipe(
7
+ Options.withSchema(boundedInt(0, 1000)),
8
+ Options.withDescription(description),
9
+ Options.optional
10
+ );
11
+
12
+ export const parentHeightOption = (description: string) =>
13
+ Options.integer("parent-height").pipe(
14
+ Options.withSchema(boundedInt(0, 1000)),
15
+ Options.withDescription(description),
16
+ Options.optional
17
+ );
18
+
19
+ export const parseThreadDepth = (
20
+ depth: Option.Option<number>,
21
+ parentHeight: Option.Option<number>
22
+ ) => ({
23
+ depth: Option.getOrUndefined(depth),
24
+ parentHeight: Option.getOrUndefined(parentHeight)
25
+ });