@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
@@ -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
+ });
@@ -1,5 +1,5 @@
1
1
  import { Args, Command, Options } from "@effect/cli";
2
- import { Chunk, Console, Effect, Option, Schema, Stream } from "effect";
2
+ import { Chunk, Console, Effect, Option, Stream } from "effect";
3
3
  import { PostUri, StoreName } from "../domain/primitives.js";
4
4
  import type { Post } from "../domain/post.js";
5
5
  import { all } from "../domain/filter.js";
@@ -11,15 +11,20 @@ import { StoreIndex } from "../services/store-index.js";
11
11
  import { SyncEngine } from "../services/sync-engine.js";
12
12
  import { renderThread } from "./doc/thread.js";
13
13
  import { renderPlain, renderAnsi } from "./doc/render.js";
14
- import { writeJson, writeText } from "./output.js";
14
+ import { CliOutput, writeJson, writeText } from "./output.js";
15
15
  import { storeOptions } from "./store.js";
16
16
  import { withExamples } from "./help.js";
17
17
  import { CliInputError } from "./errors.js";
18
- import { formatSchemaError } from "./shared.js";
19
- import { parseBoundedIntOption } from "./shared-options.js";
18
+ import {
19
+ depthOption as threadDepthOption,
20
+ parentHeightOption as threadParentHeightOption,
21
+ parseThreadDepth
22
+ } from "./thread-options.js";
20
23
  import { textJsonFormats } from "./output-format.js";
24
+ import { PositiveInt } from "./option-schemas.js";
21
25
 
22
26
  const uriArg = Args.text({ name: "uri" }).pipe(
27
+ Args.withSchema(PostUri),
23
28
  Args.withDescription("AT-URI of any post in the thread")
24
29
  );
25
30
 
@@ -38,6 +43,7 @@ const ansiOption = Options.boolean("ansi").pipe(
38
43
  );
39
44
 
40
45
  const widthOption = Options.integer("width").pipe(
46
+ Options.withSchema(PositiveInt),
41
47
  Options.withDescription("Line width for terminal output"),
42
48
  Options.optional
43
49
  );
@@ -47,14 +53,9 @@ const formatOption = Options.choice("format", textJsonFormats).pipe(
47
53
  Options.optional
48
54
  );
49
55
 
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
56
+ const depthOption = threadDepthOption("Reply depth (API only, default: 6)");
57
+ const parentHeightOption = threadParentHeightOption(
58
+ "Parent height (API only, default: 80)"
58
59
  );
59
60
 
60
61
  export const threadCommand = Command.make(
@@ -71,32 +72,28 @@ export const threadCommand = Command.make(
71
72
  },
72
73
  ({ uri, store, compact, ansi, width, format, depth, parentHeight }) =>
73
74
  Effect.gen(function* () {
75
+ const output = yield* CliOutput;
74
76
  const outputFormat = Option.getOrElse(format, () => "text" as const);
75
77
  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);
78
+ const { depth: depthValue, parentHeight: parentHeightValue } =
79
+ parseThreadDepth(depth, parentHeight);
80
+ const d = depthValue ?? 6;
81
+ const ph = parentHeightValue ?? 80;
85
82
 
86
83
  let posts: ReadonlyArray<Post>;
87
84
 
88
85
  if (Option.isSome(store)) {
89
86
  const index = yield* StoreIndex;
90
87
  const storeRef = yield* storeOptions.loadStoreRef(store.value);
91
- const targetUri = yield* Schema.decodeUnknown(PostUri)(uri).pipe(
92
- Effect.mapError((error) =>
93
- CliInputError.make({
94
- message: `Invalid post URI: ${formatSchemaError(error)}`,
95
- cause: error
96
- })
97
- )
98
- );
99
- const hasTarget = yield* index.hasUri(storeRef, targetUri);
88
+ const totalPosts = yield* index.count(storeRef);
89
+ if (totalPosts > 20000) {
90
+ yield* output
91
+ .writeStderr(
92
+ `ℹ️ Store ${storeRef.name} has ${totalPosts} posts. Thread rendering will load all posts into memory.`
93
+ )
94
+ .pipe(Effect.catchAll(() => Effect.void));
95
+ }
96
+ const hasTarget = yield* index.hasUri(storeRef, uri);
100
97
  if (!hasTarget) {
101
98
  const engine = yield* SyncEngine;
102
99
  const source = DataSource.thread(uri, { depth: d, parentHeight: ph });
@@ -106,7 +103,7 @@ export const threadCommand = Command.make(
106
103
  const stream = index.query(storeRef, query);
107
104
  const collected = yield* Stream.runCollect(stream);
108
105
  const allPosts = Chunk.toReadonlyArray(collected);
109
- const threadPosts = selectThreadPosts(allPosts, String(targetUri));
106
+ const threadPosts = selectThreadPosts(allPosts, String(uri));
110
107
  if (threadPosts.length === 0) {
111
108
  return yield* CliInputError.make({
112
109
  message: `Thread not found for ${uri}.`,
@@ -114,7 +111,7 @@ export const threadCommand = Command.make(
114
111
  });
115
112
  }
116
113
  // B1: Hint when only root post exists in store
117
- if (threadPosts.length === 1 && threadPosts[0]?.uri === targetUri) {
114
+ if (threadPosts.length === 1 && threadPosts[0]?.uri === uri) {
118
115
  yield* Console.log("\nℹ️ Only root post found in store. Use --no-store to fetch full thread from API.\n");
119
116
  }
120
117
  posts = threadPosts;
package/src/cli/watch.ts CHANGED
@@ -13,6 +13,7 @@ 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";
16
17
  import {
17
18
  feedUriArg,
18
19
  listUriArg,
@@ -25,29 +26,41 @@ import {
25
26
  postFilterJsonOption,
26
27
  authorFilterOption,
27
28
  includePinsOption,
28
- decodeActor,
29
29
  quietOption,
30
30
  refreshOption,
31
31
  strictOption,
32
- maxErrorsOption,
33
- parseMaxErrors,
34
- parseBoundedIntOption
32
+ maxErrorsOption
35
33
  } from "./shared-options.js";
34
+ import {
35
+ depthOption as threadDepthOption,
36
+ parentHeightOption as threadParentHeightOption,
37
+ parseThreadDepth
38
+ } from "./thread-options.js";
39
+ import { DurationInput, PositiveInt } from "./option-schemas.js";
36
40
 
37
41
  const intervalOption = Options.text("interval").pipe(
42
+ Options.withSchema(DurationInput),
38
43
  Options.withDescription(
39
44
  "Polling interval (e.g. \"30 seconds\", \"500 millis\") (default: 30 seconds)"
40
45
  ),
41
46
  Options.optional
42
47
  );
43
- const depthOption = Options.integer("depth").pipe(
44
- Options.withDescription("Thread reply depth to include (0-1000, default 6)"),
48
+ const maxCyclesOption = Options.integer("max-cycles").pipe(
49
+ Options.withSchema(PositiveInt),
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.withSchema(DurationInput),
55
+ Options.withDescription("Stop after a duration (e.g. \"10 minutes\")"),
49
56
  Options.optional
50
57
  );
58
+ const depthOption = threadDepthOption(
59
+ "Thread reply depth to include (0-1000, default 6)"
60
+ );
61
+ const parentHeightOption = threadParentHeightOption(
62
+ "Thread parent height to include (0-1000, default 80)"
63
+ );
51
64
 
52
65
  const timelineCommand = Command.make(
53
66
  "timeline",
@@ -56,6 +69,8 @@ const timelineCommand = Command.make(
56
69
  filter: filterOption,
57
70
  filterJson: filterJsonOption,
58
71
  interval: intervalOption,
72
+ maxCycles: maxCyclesOption,
73
+ until: untilOption,
59
74
  quiet: quietOption,
60
75
  refresh: refreshOption
61
76
  },
@@ -81,6 +96,8 @@ const feedCommand = Command.make(
81
96
  filter: filterOption,
82
97
  filterJson: filterJsonOption,
83
98
  interval: intervalOption,
99
+ maxCycles: maxCyclesOption,
100
+ until: untilOption,
84
101
  quiet: quietOption,
85
102
  refresh: refreshOption
86
103
  },
@@ -105,6 +122,8 @@ const listCommand = Command.make(
105
122
  filter: filterOption,
106
123
  filterJson: filterJsonOption,
107
124
  interval: intervalOption,
125
+ maxCycles: maxCyclesOption,
126
+ until: untilOption,
108
127
  quiet: quietOption,
109
128
  refresh: refreshOption
110
129
  },
@@ -128,6 +147,8 @@ const notificationsCommand = Command.make(
128
147
  filter: filterOption,
129
148
  filterJson: filterJsonOption,
130
149
  interval: intervalOption,
150
+ maxCycles: maxCyclesOption,
151
+ until: untilOption,
131
152
  quiet: quietOption,
132
153
  refresh: refreshOption
133
154
  },
@@ -152,19 +173,20 @@ const authorCommand = Command.make(
152
173
  postFilter: postFilterOption,
153
174
  postFilterJson: postFilterJsonOption,
154
175
  interval: intervalOption,
176
+ maxCycles: maxCyclesOption,
177
+ until: untilOption,
155
178
  quiet: quietOption,
156
179
  refresh: refreshOption
157
180
  },
158
- ({ actor, filter, includePins, postFilter, postFilterJson, interval, store, quiet, refresh }) =>
181
+ ({ actor, filter, includePins, postFilter, postFilterJson, interval, maxCycles, until, store, quiet, refresh }) =>
159
182
  Effect.gen(function* () {
160
- const resolvedActor = yield* decodeActor(actor);
161
183
  const apiFilter = Option.getOrUndefined(filter);
162
- const source = DataSource.author(resolvedActor, {
184
+ const source = DataSource.author(actor, {
163
185
  ...(apiFilter !== undefined ? { filter: apiFilter } : {}),
164
186
  ...(includePins ? { includePins: true } : {})
165
187
  });
166
188
  const run = makeWatchCommandBody("author", () => source, {
167
- actor: resolvedActor,
189
+ actor,
168
190
  ...(apiFilter !== undefined ? { filter: apiFilter } : {}),
169
191
  ...(includePins ? { includePins: true } : {})
170
192
  });
@@ -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
+ 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
  }) =>
@@ -286,14 +309,14 @@ const jetstreamCommand = Command.make(
286
309
  storeRef,
287
310
  filterHash
288
311
  );
289
- const parsedMaxErrors = yield* parseMaxErrors(maxErrors);
312
+ const parsedUntil = parseOptionalDuration(until);
290
313
  const engineLayer = JetstreamSyncEngine.layer.pipe(
291
314
  Layer.provideMerge(Jetstream.live(selection.config))
292
315
  );
293
316
  yield* logInfo("Starting watch", { source: "jetstream", store: storeRef.name });
294
317
  yield* Effect.gen(function* () {
295
318
  const engine = yield* JetstreamSyncEngine;
296
- const maxErrorsValue = Option.getOrUndefined(parsedMaxErrors);
319
+ const maxErrorsValue = Option.getOrUndefined(maxErrors);
297
320
  const stream = engine.watch({
298
321
  source: selection.source,
299
322
  store: storeRef,
@@ -310,7 +333,13 @@ const jetstreamCommand = Command.make(
310
333
  makeSyncReporter(quiet, monitor, output)
311
334
  )
312
335
  );
313
- return yield* writeJsonStream(outputStream);
336
+ const limited = Option.isSome(maxCycles)
337
+ ? outputStream.pipe(Stream.take(maxCycles.value))
338
+ : outputStream;
339
+ const timed = Option.isSome(parsedUntil)
340
+ ? limited.pipe(Stream.interruptWhen(Effect.sleep(parsedUntil.value)))
341
+ : limited;
342
+ return yield* writeJsonStream(timed);
314
343
  }).pipe(Effect.provide(engineLayer));
315
344
  })
316
345
  ).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);
@@ -16,18 +16,35 @@ export const Hashtag = Schema.String.pipe(
16
16
  );
17
17
  export type Hashtag = typeof Hashtag.Type;
18
18
 
19
- export const AtUri = Schema.String.pipe(Schema.brand("AtUri"));
19
+ const atUriPattern = /^at:\/\/\S+$/;
20
+
21
+ export const AtUri = Schema.String.pipe(
22
+ Schema.pattern(atUriPattern),
23
+ Schema.brand("AtUri")
24
+ );
20
25
  export type AtUri = typeof AtUri.Type;
21
26
 
22
- export const PostUri = Schema.String.pipe(Schema.brand("PostUri"));
27
+ export const PostUri = Schema.String.pipe(
28
+ Schema.pattern(atUriPattern),
29
+ Schema.brand("PostUri")
30
+ );
23
31
  export type PostUri = typeof PostUri.Type;
24
32
 
25
33
  export const PostCid = Schema.String.pipe(Schema.brand("PostCid"));
26
34
  export type PostCid = typeof PostCid.Type;
27
35
 
28
- export const Did = Schema.String.pipe(Schema.brand("Did"));
36
+ export const Did = Schema.String.pipe(
37
+ Schema.pattern(/^did:\S+$/),
38
+ Schema.brand("Did")
39
+ );
29
40
  export type Did = typeof Did.Type;
30
41
 
42
+ export const ActorId = Schema.String.pipe(
43
+ Schema.pattern(/^(did:\S+|[a-z0-9][a-z0-9.-]{1,251})$/),
44
+ Schema.brand("ActorId")
45
+ );
46
+ export type ActorId = typeof ActorId.Type;
47
+
31
48
  export const Timestamp = Schema.Union(
32
49
  Schema.DateFromString,
33
50
  Schema.DateFromSelf