@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
@@ -4,11 +4,13 @@ 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";
13
+ import { CliInputError } from "./errors.js";
12
14
  import type { StoreName } from "../domain/primitives.js";
13
15
 
14
16
  /** Common options shared by sync and watch API-based commands */
@@ -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);
@@ -56,13 +59,16 @@ export const makeSyncCommandBody = (
56
59
  filters: materialized.filters.map((spec) => spec.name)
57
60
  });
58
61
  }
62
+ const totalPosts = yield* index.count(storeRef);
59
63
  yield* logInfo("Sync complete", { source: sourceName, store: storeRef.name, ...extraLogFields });
60
- yield* writeJson(result as SyncResult);
64
+ yield* writeJson({ ...(result as SyncResult), totalPosts });
61
65
  });
62
66
 
63
67
  /** Common options for watch API-based commands */
64
68
  export interface WatchCommandInput extends CommonCommandInput {
65
69
  readonly interval: Option.Option<string>;
70
+ readonly maxCycles: Option.Option<number>;
71
+ readonly until: Option.Option<string>;
66
72
  }
67
73
 
68
74
  /** Build the command body for a watch command (timeline, feed, notifications). */
@@ -82,6 +88,19 @@ export const makeWatchCommandBody = (
82
88
  const basePolicy = storeConfig.syncPolicy ?? "dedupe";
83
89
  const policy = input.refresh ? "refresh" : basePolicy;
84
90
  const parsedInterval = yield* parseInterval(input.interval);
91
+ const parsedUntil = yield* parseOptionalDuration(input.until);
92
+ const parsedMaxCycles = yield* Option.match(input.maxCycles, {
93
+ onNone: () => Effect.succeed(Option.none<number>()),
94
+ onSome: (value) =>
95
+ value <= 0
96
+ ? Effect.fail(
97
+ CliInputError.make({
98
+ message: "--max-cycles must be a positive integer.",
99
+ cause: { maxCycles: value }
100
+ })
101
+ )
102
+ : Effect.succeed(Option.some(value))
103
+ });
85
104
  yield* logInfo("Starting watch", { source: sourceName, store: storeRef.name, ...extraLogFields });
86
105
  if (policy === "refresh") {
87
106
  yield* logWarn("Refresh mode updates existing posts and may grow the event log.", {
@@ -89,7 +108,7 @@ export const makeWatchCommandBody = (
89
108
  store: storeRef.name
90
109
  });
91
110
  }
92
- const stream = sync
111
+ let stream = sync
93
112
  .watch(
94
113
  WatchConfig.make({
95
114
  source: makeDataSource(),
@@ -103,5 +122,11 @@ export const makeWatchCommandBody = (
103
122
  Stream.map((event) => event.result),
104
123
  Stream.provideService(SyncReporter, makeSyncReporter(input.quiet, monitor, output))
105
124
  );
125
+ if (Option.isSome(parsedMaxCycles)) {
126
+ stream = stream.pipe(Stream.take(parsedMaxCycles.value));
127
+ }
128
+ if (Option.isSome(parsedUntil)) {
129
+ stream = stream.pipe(Stream.interruptWhen(Effect.sleep(parsedUntil.value)));
130
+ }
106
131
  yield* writeJsonStream(stream);
107
132
  });
package/src/cli/sync.ts CHANGED
@@ -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";
@@ -33,9 +34,13 @@ import {
33
34
  strictOption,
34
35
  maxErrorsOption,
35
36
  parseMaxErrors,
36
- parseLimit,
37
- parseBoundedIntOption
37
+ parseLimit
38
38
  } from "./shared-options.js";
39
+ import {
40
+ depthOption as threadDepthOption,
41
+ parentHeightOption as threadParentHeightOption,
42
+ parseThreadDepth
43
+ } from "./thread-options.js";
39
44
 
40
45
  const limitOption = Options.integer("limit").pipe(
41
46
  Options.withDescription("Maximum number of Jetstream events to process"),
@@ -45,13 +50,11 @@ const durationOption = Options.text("duration").pipe(
45
50
  Options.withDescription("Stop after a duration (e.g. \"2 minutes\")"),
46
51
  Options.optional
47
52
  );
48
- const depthOption = Options.integer("depth").pipe(
49
- Options.withDescription("Thread reply depth to include (0-1000, default 6)"),
50
- Options.optional
53
+ const depthOption = threadDepthOption(
54
+ "Thread reply depth to include (0-1000, default 6)"
51
55
  );
52
- const parentHeightOption = Options.integer("parent-height").pipe(
53
- Options.withDescription("Thread parent height to include (0-1000, default 80)"),
54
- Options.optional
56
+ const parentHeightOption = threadParentHeightOption(
57
+ "Thread parent height to include (0-1000, default 80)"
55
58
  );
56
59
 
57
60
  const parseDuration = (value: Option.Option<string>) =>
@@ -202,15 +205,8 @@ const threadCommand = Command.make(
202
205
  },
203
206
  ({ uri, depth, parentHeight, filter, filterJson, store, quiet, refresh }) =>
204
207
  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);
208
+ const { depth: depthValue, parentHeight: parentHeightValue } =
209
+ yield* parseThreadDepth(depth, parentHeight);
214
210
  const source = DataSource.thread(uri, {
215
211
  ...(depthValue !== undefined ? { depth: depthValue } : {}),
216
212
  ...(parentHeightValue !== undefined ? { parentHeight: parentHeightValue } : {})
@@ -273,6 +269,7 @@ const jetstreamCommand = Command.make(
273
269
  const monitor = yield* ResourceMonitor;
274
270
  const output = yield* CliOutput;
275
271
  const outputManager = yield* OutputManager;
272
+ const index = yield* StoreIndex;
276
273
  const storeRef = yield* storeOptions.loadStoreRef(store);
277
274
  const expr = yield* parseFilterExpr(filter, filterJson);
278
275
  const filterHash = filterExprSignature(expr);
@@ -332,8 +329,9 @@ const jetstreamCommand = Command.make(
332
329
  filters: materialized.filters.map((spec) => spec.name)
333
330
  });
334
331
  }
332
+ const totalPosts = yield* index.count(storeRef);
335
333
  yield* logInfo("Sync complete", { source: "jetstream", store: storeRef.name });
336
- yield* writeJson(result as SyncResult);
334
+ yield* writeJson({ ...(result as SyncResult), totalPosts });
337
335
  })
338
336
  ).pipe(
339
337
  Command.withDescription(
@@ -0,0 +1,33 @@
1
+ import { Options } from "@effect/cli";
2
+ import { Effect, Option } from "effect";
3
+ import { parseBoundedIntOption } from "./shared-options.js";
4
+
5
+ export const depthOption = (description: string) =>
6
+ Options.integer("depth").pipe(
7
+ Options.withDescription(description),
8
+ Options.optional
9
+ );
10
+
11
+ export const parentHeightOption = (description: string) =>
12
+ Options.integer("parent-height").pipe(
13
+ Options.withDescription(description),
14
+ Options.optional
15
+ );
16
+
17
+ export const parseThreadDepth = (
18
+ depth: Option.Option<number>,
19
+ parentHeight: Option.Option<number>
20
+ ) =>
21
+ Effect.gen(function* () {
22
+ const parsedDepth = yield* parseBoundedIntOption(depth, "depth", 0, 1000);
23
+ const parsedParentHeight = yield* parseBoundedIntOption(
24
+ parentHeight,
25
+ "parent-height",
26
+ 0,
27
+ 1000
28
+ );
29
+ return {
30
+ depth: Option.getOrUndefined(parsedDepth),
31
+ parentHeight: Option.getOrUndefined(parsedParentHeight)
32
+ };
33
+ });
@@ -0,0 +1,171 @@
1
+ import { Duration, Effect, Schema } from "effect";
2
+ import { Timestamp } from "../domain/primitives.js";
3
+ import { CliInputError } from "./errors.js";
4
+
5
+ export type TimeParseError = (message: string, cause?: unknown) => CliInputError;
6
+
7
+ type TimeParseOptions = {
8
+ readonly label?: string;
9
+ readonly onError?: TimeParseError;
10
+ };
11
+
12
+ const defaultError: TimeParseError = (message, cause) =>
13
+ CliInputError.make({ message, cause });
14
+
15
+ const compactDurationPattern = /^(-?\d+(?:\.\d+)?)(ms|s|m|h|d|w)$/i;
16
+ const compactDurationUnits: Record<string, number> = {
17
+ ms: 1,
18
+ s: 1000,
19
+ m: 60_000,
20
+ h: 3_600_000,
21
+ d: 86_400_000,
22
+ w: 604_800_000
23
+ };
24
+
25
+ const parseCompactDurationMillis = (raw: string): number | undefined => {
26
+ const match = raw.match(compactDurationPattern);
27
+ if (!match) {
28
+ return undefined;
29
+ }
30
+ const value = Number.parseFloat(match[1] ?? "");
31
+ const unit = (match[2] ?? "").toLowerCase();
32
+ const multiplier = compactDurationUnits[unit];
33
+ if (!Number.isFinite(value) || multiplier === undefined) {
34
+ return Number.NaN;
35
+ }
36
+ return value * multiplier;
37
+ };
38
+
39
+ const toUtcStartOfDay = (date: Date) =>
40
+ new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
41
+
42
+ const parseDateOnly = (raw: string): Date | undefined => {
43
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
44
+ if (!match) {
45
+ return undefined;
46
+ }
47
+ const year = Number.parseInt(match[1] ?? "", 10);
48
+ const month = Number.parseInt(match[2] ?? "", 10);
49
+ const day = Number.parseInt(match[3] ?? "", 10);
50
+ if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
51
+ return undefined;
52
+ }
53
+ const date = new Date(Date.UTC(year, month - 1, day));
54
+ if (
55
+ date.getUTCFullYear() !== year ||
56
+ date.getUTCMonth() !== month - 1 ||
57
+ date.getUTCDate() !== day
58
+ ) {
59
+ return undefined;
60
+ }
61
+ return date;
62
+ };
63
+
64
+ const looksLikeDate = (raw: string) => /^\d{4}-\d{2}-\d{2}/.test(raw);
65
+
66
+ const hasExplicitTimezone = (raw: string) => /([zZ]|[+-]\d{2}:\d{2})$/.test(raw);
67
+
68
+ const makeErrorFactory = (options?: TimeParseOptions): TimeParseError => {
69
+ const base = options?.onError ?? defaultError;
70
+ const label = options?.label;
71
+ if (!label) {
72
+ return base;
73
+ }
74
+ return (message, cause) => base(`${label}: ${message}`, cause);
75
+ };
76
+
77
+ export const parseDurationInput = (
78
+ raw: string,
79
+ options?: TimeParseOptions
80
+ ): Effect.Effect<Duration.Duration, CliInputError> =>
81
+ Effect.suspend(() => {
82
+ const onError = makeErrorFactory(options);
83
+ const trimmed = raw.trim();
84
+ if (trimmed.length === 0) {
85
+ return Effect.fail(onError("Duration cannot be empty."));
86
+ }
87
+
88
+ const compactMillis = parseCompactDurationMillis(trimmed);
89
+ if (compactMillis !== undefined) {
90
+ if (!Number.isFinite(compactMillis)) {
91
+ return Effect.fail(onError("Duration must be a finite number."));
92
+ }
93
+ if (compactMillis < 0) {
94
+ return Effect.fail(onError("Duration must be non-negative."));
95
+ }
96
+ return Effect.succeed(Duration.millis(compactMillis));
97
+ }
98
+
99
+ return Effect.try({
100
+ try: () => Duration.decode(trimmed as Duration.DurationInput),
101
+ catch: (cause) =>
102
+ onError(
103
+ `Invalid duration "${raw}". Use formats like "30 seconds", "500 millis", or "1.5h".`,
104
+ cause
105
+ )
106
+ }).pipe(
107
+ Effect.flatMap((duration) => {
108
+ if (!Duration.isFinite(duration)) {
109
+ return Effect.fail(onError("Duration must be finite."));
110
+ }
111
+ if (Duration.toMillis(duration) < 0) {
112
+ return Effect.fail(onError("Duration must be non-negative."));
113
+ }
114
+ return Effect.succeed(duration);
115
+ })
116
+ );
117
+ });
118
+
119
+ export const parseTimeInput = (
120
+ raw: string,
121
+ now: Date,
122
+ options?: TimeParseOptions
123
+ ): Effect.Effect<Date, CliInputError> =>
124
+ Effect.suspend(() => {
125
+ const onError = makeErrorFactory(options);
126
+ const trimmed = raw.trim();
127
+ if (trimmed.length === 0) {
128
+ return Effect.fail(onError("Time value cannot be empty."));
129
+ }
130
+
131
+ const lower = trimmed.toLowerCase();
132
+ if (lower === "now") {
133
+ return Effect.succeed(new Date(now.getTime()));
134
+ }
135
+ if (lower === "today") {
136
+ return Effect.succeed(toUtcStartOfDay(now));
137
+ }
138
+ if (lower === "yesterday") {
139
+ return Effect.succeed(new Date(toUtcStartOfDay(now).getTime() - 86_400_000));
140
+ }
141
+
142
+ const dateOnly = parseDateOnly(trimmed);
143
+ if (dateOnly) {
144
+ return Effect.succeed(dateOnly);
145
+ }
146
+
147
+ if (looksLikeDate(trimmed)) {
148
+ if (/[Tt]/.test(trimmed) && !hasExplicitTimezone(trimmed)) {
149
+ return Effect.fail(
150
+ onError(
151
+ "Timestamp must include a timezone (e.g. 2026-01-01T00:00:00Z)."
152
+ )
153
+ );
154
+ }
155
+ return Schema.decodeUnknown(Timestamp)(trimmed).pipe(
156
+ Effect.mapError((cause) =>
157
+ onError(
158
+ `Invalid timestamp "${raw}". Expected ISO 8601 with timezone (e.g. 2026-01-01T00:00:00Z).`,
159
+ cause
160
+ )
161
+ )
162
+ );
163
+ }
164
+
165
+ return parseDurationInput(trimmed, options).pipe(
166
+ Effect.map((duration) => {
167
+ const millis = Duration.toMillis(duration);
168
+ return new Date(now.getTime() - millis);
169
+ })
170
+ );
171
+ });
@@ -16,7 +16,11 @@ import { storeOptions } from "./store.js";
16
16
  import { withExamples } from "./help.js";
17
17
  import { CliInputError } from "./errors.js";
18
18
  import { formatSchemaError } from "./shared.js";
19
- import { parseBoundedIntOption } from "./shared-options.js";
19
+ import {
20
+ depthOption as threadDepthOption,
21
+ parentHeightOption as threadParentHeightOption,
22
+ parseThreadDepth
23
+ } from "./thread-options.js";
20
24
  import { textJsonFormats } from "./output-format.js";
21
25
 
22
26
  const uriArg = Args.text({ name: "uri" }).pipe(
@@ -47,14 +51,9 @@ const formatOption = Options.choice("format", textJsonFormats).pipe(
47
51
  Options.optional
48
52
  );
49
53
 
50
- const depthOption = Options.integer("depth").pipe(
51
- Options.withDescription("Reply depth (API only, default: 6)"),
52
- Options.optional
53
- );
54
-
55
- const parentHeightOption = Options.integer("parent-height").pipe(
56
- Options.withDescription("Parent height (API only, default: 80)"),
57
- Options.optional
54
+ const depthOption = threadDepthOption("Reply depth (API only, default: 6)");
55
+ const parentHeightOption = threadParentHeightOption(
56
+ "Parent height (API only, default: 80)"
58
57
  );
59
58
 
60
59
  export const threadCommand = Command.make(
@@ -73,15 +72,10 @@ export const threadCommand = Command.make(
73
72
  Effect.gen(function* () {
74
73
  const outputFormat = Option.getOrElse(format, () => "text" as const);
75
74
  const w = Option.getOrUndefined(width);
76
- const parsedDepth = yield* parseBoundedIntOption(depth, "depth", 0, 1000);
77
- const parsedParentHeight = yield* parseBoundedIntOption(
78
- parentHeight,
79
- "parent-height",
80
- 0,
81
- 1000
82
- );
83
- const d = Option.getOrElse(parsedDepth, () => 6);
84
- const ph = Option.getOrElse(parsedParentHeight, () => 80);
75
+ const { depth: depthValue, parentHeight: parentHeightValue } =
76
+ yield* parseThreadDepth(depth, parentHeight);
77
+ const d = depthValue ?? 6;
78
+ const ph = parentHeightValue ?? 80;
85
79
 
86
80
  let posts: ReadonlyArray<Post>;
87
81
 
package/src/cli/watch.ts CHANGED
@@ -13,6 +13,8 @@ import { ResourceMonitor } from "../services/resource-monitor.js";
13
13
  import { withExamples } from "./help.js";
14
14
  import { buildJetstreamSelection, jetstreamOptions } from "./jetstream.js";
15
15
  import { makeWatchCommandBody } from "./sync-factory.js";
16
+ import { parseOptionalDuration } from "./interval.js";
17
+ import { CliInputError } from "./errors.js";
16
18
  import {
17
19
  feedUriArg,
18
20
  listUriArg,
@@ -30,9 +32,13 @@ import {
30
32
  refreshOption,
31
33
  strictOption,
32
34
  maxErrorsOption,
33
- parseMaxErrors,
34
- parseBoundedIntOption
35
+ parseMaxErrors
35
36
  } from "./shared-options.js";
37
+ import {
38
+ depthOption as threadDepthOption,
39
+ parentHeightOption as threadParentHeightOption,
40
+ parseThreadDepth
41
+ } from "./thread-options.js";
36
42
 
37
43
  const intervalOption = Options.text("interval").pipe(
38
44
  Options.withDescription(
@@ -40,14 +46,20 @@ const intervalOption = Options.text("interval").pipe(
40
46
  ),
41
47
  Options.optional
42
48
  );
43
- const depthOption = Options.integer("depth").pipe(
44
- Options.withDescription("Thread reply depth to include (0-1000, default 6)"),
49
+ const maxCyclesOption = Options.integer("max-cycles").pipe(
50
+ Options.withDescription("Stop after N watch cycles"),
45
51
  Options.optional
46
52
  );
47
- const parentHeightOption = Options.integer("parent-height").pipe(
48
- Options.withDescription("Thread parent height to include (0-1000, default 80)"),
53
+ const untilOption = Options.text("until").pipe(
54
+ Options.withDescription("Stop after a duration (e.g. \"10 minutes\")"),
49
55
  Options.optional
50
56
  );
57
+ const depthOption = threadDepthOption(
58
+ "Thread reply depth to include (0-1000, default 6)"
59
+ );
60
+ const parentHeightOption = threadParentHeightOption(
61
+ "Thread parent height to include (0-1000, default 80)"
62
+ );
51
63
 
52
64
  const timelineCommand = Command.make(
53
65
  "timeline",
@@ -56,6 +68,8 @@ const timelineCommand = Command.make(
56
68
  filter: filterOption,
57
69
  filterJson: filterJsonOption,
58
70
  interval: intervalOption,
71
+ maxCycles: maxCyclesOption,
72
+ until: untilOption,
59
73
  quiet: quietOption,
60
74
  refresh: refreshOption
61
75
  },
@@ -81,6 +95,8 @@ const feedCommand = Command.make(
81
95
  filter: filterOption,
82
96
  filterJson: filterJsonOption,
83
97
  interval: intervalOption,
98
+ maxCycles: maxCyclesOption,
99
+ until: untilOption,
84
100
  quiet: quietOption,
85
101
  refresh: refreshOption
86
102
  },
@@ -105,6 +121,8 @@ const listCommand = Command.make(
105
121
  filter: filterOption,
106
122
  filterJson: filterJsonOption,
107
123
  interval: intervalOption,
124
+ maxCycles: maxCyclesOption,
125
+ until: untilOption,
108
126
  quiet: quietOption,
109
127
  refresh: refreshOption
110
128
  },
@@ -128,6 +146,8 @@ const notificationsCommand = Command.make(
128
146
  filter: filterOption,
129
147
  filterJson: filterJsonOption,
130
148
  interval: intervalOption,
149
+ maxCycles: maxCyclesOption,
150
+ until: untilOption,
131
151
  quiet: quietOption,
132
152
  refresh: refreshOption
133
153
  },
@@ -152,10 +172,12 @@ const authorCommand = Command.make(
152
172
  postFilter: postFilterOption,
153
173
  postFilterJson: postFilterJsonOption,
154
174
  interval: intervalOption,
175
+ maxCycles: maxCyclesOption,
176
+ until: untilOption,
155
177
  quiet: quietOption,
156
178
  refresh: refreshOption
157
179
  },
158
- ({ actor, filter, includePins, postFilter, postFilterJson, interval, store, quiet, refresh }) =>
180
+ ({ actor, filter, includePins, postFilter, postFilterJson, interval, maxCycles, until, store, quiet, refresh }) =>
159
181
  Effect.gen(function* () {
160
182
  const resolvedActor = yield* decodeActor(actor);
161
183
  const apiFilter = Option.getOrUndefined(filter);
@@ -173,6 +195,8 @@ const authorCommand = Command.make(
173
195
  filter: postFilter,
174
196
  filterJson: postFilterJson,
175
197
  interval,
198
+ maxCycles,
199
+ until,
176
200
  quiet,
177
201
  refresh
178
202
  });
@@ -200,20 +224,15 @@ const threadCommand = Command.make(
200
224
  filter: filterOption,
201
225
  filterJson: filterJsonOption,
202
226
  interval: intervalOption,
227
+ maxCycles: maxCyclesOption,
228
+ until: untilOption,
203
229
  quiet: quietOption,
204
230
  refresh: refreshOption
205
231
  },
206
- ({ uri, depth, parentHeight, filter, filterJson, interval, store, quiet, refresh }) =>
232
+ ({ uri, depth, parentHeight, filter, filterJson, interval, maxCycles, until, store, quiet, refresh }) =>
207
233
  Effect.gen(function* () {
208
- const parsedDepth = yield* parseBoundedIntOption(depth, "depth", 0, 1000);
209
- const parsedParentHeight = yield* parseBoundedIntOption(
210
- parentHeight,
211
- "parent-height",
212
- 0,
213
- 1000
214
- );
215
- const depthValue = Option.getOrUndefined(parsedDepth);
216
- const parentHeightValue = Option.getOrUndefined(parsedParentHeight);
234
+ const { depth: depthValue, parentHeight: parentHeightValue } =
235
+ yield* parseThreadDepth(depth, parentHeight);
217
236
  const source = DataSource.thread(uri, {
218
237
  ...(depthValue !== undefined ? { depth: depthValue } : {}),
219
238
  ...(parentHeightValue !== undefined ? { parentHeight: parentHeightValue } : {})
@@ -223,7 +242,7 @@ const threadCommand = Command.make(
223
242
  ...(depthValue !== undefined ? { depth: depthValue } : {}),
224
243
  ...(parentHeightValue !== undefined ? { parentHeight: parentHeightValue } : {})
225
244
  });
226
- return yield* run({ store, filter, filterJson, interval, quiet, refresh });
245
+ return yield* run({ store, filter, filterJson, interval, maxCycles, until, quiet, refresh });
227
246
  })
228
247
  ).pipe(
229
248
  Command.withDescription(
@@ -251,6 +270,8 @@ const jetstreamCommand = Command.make(
251
270
  cursor: jetstreamOptions.cursor,
252
271
  compress: jetstreamOptions.compress,
253
272
  maxMessageSize: jetstreamOptions.maxMessageSize,
273
+ maxCycles: maxCyclesOption,
274
+ until: untilOption,
254
275
  strict: strictOption,
255
276
  maxErrors: maxErrorsOption
256
277
  },
@@ -265,6 +286,8 @@ const jetstreamCommand = Command.make(
265
286
  cursor,
266
287
  compress,
267
288
  maxMessageSize,
289
+ maxCycles,
290
+ until,
268
291
  strict,
269
292
  maxErrors
270
293
  }) =>
@@ -287,6 +310,19 @@ const jetstreamCommand = Command.make(
287
310
  filterHash
288
311
  );
289
312
  const parsedMaxErrors = yield* parseMaxErrors(maxErrors);
313
+ const parsedUntil = yield* parseOptionalDuration(until);
314
+ const parsedMaxCycles = yield* Option.match(maxCycles, {
315
+ onNone: () => Effect.succeed(Option.none<number>()),
316
+ onSome: (value) =>
317
+ value <= 0
318
+ ? Effect.fail(
319
+ CliInputError.make({
320
+ message: "--max-cycles must be a positive integer.",
321
+ cause: { maxCycles: value }
322
+ })
323
+ )
324
+ : Effect.succeed(Option.some(value))
325
+ });
290
326
  const engineLayer = JetstreamSyncEngine.layer.pipe(
291
327
  Layer.provideMerge(Jetstream.live(selection.config))
292
328
  );
@@ -310,7 +346,13 @@ const jetstreamCommand = Command.make(
310
346
  makeSyncReporter(quiet, monitor, output)
311
347
  )
312
348
  );
313
- return yield* writeJsonStream(outputStream);
349
+ const limited = Option.isSome(parsedMaxCycles)
350
+ ? outputStream.pipe(Stream.take(parsedMaxCycles.value))
351
+ : outputStream;
352
+ const timed = Option.isSome(parsedUntil)
353
+ ? limited.pipe(Stream.interruptWhen(Effect.sleep(parsedUntil.value)))
354
+ : limited;
355
+ return yield* writeJsonStream(timed);
314
356
  }).pipe(Effect.provide(engineLayer));
315
357
  })
316
358
  ).pipe(
@@ -42,6 +42,11 @@ export class StoreNotFound extends Schema.TaggedError<StoreNotFound>()(
42
42
  { name: StoreName }
43
43
  ) {}
44
44
 
45
+ export class StoreAlreadyExists extends Schema.TaggedError<StoreAlreadyExists>()(
46
+ "StoreAlreadyExists",
47
+ { name: StoreName }
48
+ ) {}
49
+
45
50
  export class StoreIoError extends Schema.TaggedError<StoreIoError>()(
46
51
  "StoreIoError",
47
52
  { path: StorePath, cause: Schema.Unknown }
@@ -68,4 +73,4 @@ export class FilterLibraryError extends Schema.TaggedError<FilterLibraryError>()
68
73
  }
69
74
  ) {}
70
75
 
71
- export type StoreError = StoreNotFound | StoreIoError | StoreIndexError;
76
+ export type StoreError = StoreNotFound | StoreAlreadyExists | StoreIoError | StoreIndexError;
@@ -2,6 +2,7 @@ import { Post } from "./post.js";
2
2
  import { displayWidth, padEndDisplay } from "./text-width.js";
3
3
 
4
4
  const headers = ["Created At", "Author", "Text", "URI"];
5
+ const headersWithStore = ["Store", ...headers];
5
6
  const textLimit = 80;
6
7
 
7
8
  export const normalizeWhitespace = (text: string) =>
@@ -41,6 +42,16 @@ const postToMarkdownRow = (post: Post) => [
41
42
  post.uri.replace(/\|/g, "\\|")
42
43
  ];
43
44
 
45
+ const storePostToRow = (entry: { readonly store: string; readonly post: Post }) => [
46
+ entry.store,
47
+ ...postToRow(entry.post)
48
+ ];
49
+
50
+ const storePostToMarkdownRow = (entry: { readonly store: string; readonly post: Post }) => [
51
+ entry.store,
52
+ ...postToMarkdownRow(entry.post)
53
+ ];
54
+
44
55
  const renderTable = (
45
56
  head: ReadonlyArray<string>,
46
57
  rows: ReadonlyArray<ReadonlyArray<string>>
@@ -89,3 +100,13 @@ export const renderPostsTable = (posts: ReadonlyArray<Post>) =>
89
100
 
90
101
  export const renderPostsMarkdown = (posts: ReadonlyArray<Post>) =>
91
102
  renderMarkdownTable(headers, posts.map(postToMarkdownRow));
103
+
104
+ export const renderStorePostsTable = (
105
+ entries: ReadonlyArray<{ readonly store: string; readonly post: Post }>
106
+ ) =>
107
+ renderTable(headersWithStore, entries.map(storePostToRow));
108
+
109
+ export const renderStorePostsMarkdown = (
110
+ entries: ReadonlyArray<{ readonly store: string; readonly post: Post }>
111
+ ) =>
112
+ renderMarkdownTable(headersWithStore, entries.map(storePostToMarkdownRow));
@@ -0,0 +1,24 @@
1
+ import { Order } from "effect";
2
+ import type { Post } from "./post.js";
3
+ import type { StoreRef } from "./store.js";
4
+
5
+ export const LocaleStringOrder = Order.make<string>((left, right) => {
6
+ const result = left.localeCompare(right);
7
+ if (result < 0) return -1;
8
+ if (result > 0) return 1;
9
+ return 0;
10
+ });
11
+
12
+ export const PostOrder = Order.mapInput(
13
+ Order.tuple(Order.Date, LocaleStringOrder),
14
+ (post: Post) => [post.createdAt, post.uri] as const
15
+ );
16
+
17
+ export const StorePostOrder = Order.mapInput(
18
+ Order.tuple(Order.Date, LocaleStringOrder, LocaleStringOrder),
19
+ (entry: { readonly post: Post; readonly store: StoreRef }) =>
20
+ [entry.post.createdAt, entry.post.uri, entry.store.name] as const
21
+ );
22
+
23
+ export const updatedAtOrder = <A extends { readonly updatedAt: Date }>() =>
24
+ Order.mapInput(Order.Date, (value: A) => value.updatedAt);