@mepuka/skygent 0.3.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.
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";
@@ -28,25 +28,30 @@ import {
28
28
  postFilterJsonOption,
29
29
  authorFilterOption,
30
30
  includePinsOption,
31
- decodeActor,
32
31
  quietOption,
33
32
  refreshOption,
34
33
  strictOption,
35
- maxErrorsOption,
36
- parseMaxErrors,
37
- parseLimit
34
+ maxErrorsOption
38
35
  } from "./shared-options.js";
39
36
  import {
40
37
  depthOption as threadDepthOption,
41
38
  parentHeightOption as threadParentHeightOption,
42
39
  parseThreadDepth
43
40
  } from "./thread-options.js";
41
+ import { DurationInput, PositiveInt } from "./option-schemas.js";
44
42
 
45
- 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),
46
50
  Options.withDescription("Maximum number of Jetstream events to process"),
47
51
  Options.optional
48
52
  );
49
53
  const durationOption = Options.text("duration").pipe(
54
+ Options.withSchema(DurationInput),
50
55
  Options.withDescription("Stop after a duration (e.g. \"2 minutes\")"),
51
56
  Options.optional
52
57
  );
@@ -57,34 +62,10 @@ const parentHeightOption = threadParentHeightOption(
57
62
  "Thread parent height to include (0-1000, default 80)"
58
63
  );
59
64
 
60
- const parseDuration = (value: Option.Option<string>) =>
61
- Option.match(value, {
62
- onNone: () => Effect.succeed(Option.none()),
63
- onSome: (raw) =>
64
- Effect.try({
65
- try: () => Duration.decode(raw as Duration.DurationInput),
66
- catch: (cause) =>
67
- CliInputError.make({
68
- message: `Invalid duration: ${raw}. Use formats like \"2 minutes\".`,
69
- cause
70
- })
71
- }).pipe(
72
- Effect.flatMap((duration) =>
73
- Duration.toMillis(duration) < 0
74
- ? Effect.fail(
75
- CliInputError.make({
76
- message: "Duration must be non-negative.",
77
- cause: duration
78
- })
79
- )
80
- : Effect.succeed(Option.some(duration))
81
- )
82
- )
83
- });
84
65
 
85
66
  const timelineCommand = Command.make(
86
67
  "timeline",
87
- { store: storeNameOption, filter: filterOption, filterJson: filterJsonOption, quiet: quietOption, refresh: refreshOption },
68
+ { store: storeNameOption, filter: filterOption, filterJson: filterJsonOption, quiet: quietOption, refresh: refreshOption, limit: syncLimitOption },
88
69
  makeSyncCommandBody("timeline", () => DataSource.timeline())
89
70
  ).pipe(
90
71
  Command.withDescription(
@@ -101,7 +82,7 @@ const timelineCommand = Command.make(
101
82
 
102
83
  const feedCommand = Command.make(
103
84
  "feed",
104
- { 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 },
105
86
  ({ uri, ...rest }) => makeSyncCommandBody("feed", () => DataSource.feed(uri), { uri })(rest)
106
87
  ).pipe(
107
88
  Command.withDescription(
@@ -117,7 +98,7 @@ const feedCommand = Command.make(
117
98
 
118
99
  const listCommand = Command.make(
119
100
  "list",
120
- { 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 },
121
102
  ({ uri, ...rest }) => makeSyncCommandBody("list", () => DataSource.list(uri), { uri })(rest)
122
103
  ).pipe(
123
104
  Command.withDescription(
@@ -133,7 +114,7 @@ const listCommand = Command.make(
133
114
 
134
115
  const notificationsCommand = Command.make(
135
116
  "notifications",
136
- { store: storeNameOption, filter: filterOption, filterJson: filterJsonOption, quiet: quietOption, refresh: refreshOption },
117
+ { store: storeNameOption, filter: filterOption, filterJson: filterJsonOption, quiet: quietOption, refresh: refreshOption, limit: syncLimitOption },
137
118
  makeSyncCommandBody("notifications", () => DataSource.notifications())
138
119
  ).pipe(
139
120
  Command.withDescription(
@@ -155,18 +136,18 @@ const authorCommand = Command.make(
155
136
  postFilter: postFilterOption,
156
137
  postFilterJson: postFilterJsonOption,
157
138
  quiet: quietOption,
158
- refresh: refreshOption
139
+ refresh: refreshOption,
140
+ limit: syncLimitOption
159
141
  },
160
- ({ actor, filter, includePins, postFilter, postFilterJson, store, quiet, refresh }) =>
142
+ ({ actor, filter, includePins, postFilter, postFilterJson, store, quiet, refresh, limit }) =>
161
143
  Effect.gen(function* () {
162
- const resolvedActor = yield* decodeActor(actor);
163
144
  const apiFilter = Option.getOrUndefined(filter);
164
- const source = DataSource.author(resolvedActor, {
145
+ const source = DataSource.author(actor, {
165
146
  ...(apiFilter !== undefined ? { filter: apiFilter } : {}),
166
147
  ...(includePins ? { includePins: true } : {})
167
148
  });
168
149
  const run = makeSyncCommandBody("author", () => source, {
169
- actor: resolvedActor,
150
+ actor,
170
151
  ...(apiFilter !== undefined ? { filter: apiFilter } : {}),
171
152
  ...(includePins ? { includePins: true } : {})
172
153
  });
@@ -175,7 +156,8 @@ const authorCommand = Command.make(
175
156
  filter: postFilter,
176
157
  filterJson: postFilterJson,
177
158
  quiet,
178
- refresh
159
+ refresh,
160
+ limit
179
161
  });
180
162
  })
181
163
  ).pipe(
@@ -201,12 +183,13 @@ const threadCommand = Command.make(
201
183
  filter: filterOption,
202
184
  filterJson: filterJsonOption,
203
185
  quiet: quietOption,
204
- refresh: refreshOption
186
+ refresh: refreshOption,
187
+ limit: syncLimitOption
205
188
  },
206
- ({ uri, depth, parentHeight, filter, filterJson, store, quiet, refresh }) =>
189
+ ({ uri, depth, parentHeight, filter, filterJson, store, quiet, refresh, limit }) =>
207
190
  Effect.gen(function* () {
208
191
  const { depth: depthValue, parentHeight: parentHeightValue } =
209
- yield* parseThreadDepth(depth, parentHeight);
192
+ parseThreadDepth(depth, parentHeight);
210
193
  const source = DataSource.thread(uri, {
211
194
  ...(depthValue !== undefined ? { depth: depthValue } : {}),
212
195
  ...(parentHeightValue !== undefined ? { parentHeight: parentHeightValue } : {})
@@ -216,7 +199,7 @@ const threadCommand = Command.make(
216
199
  ...(depthValue !== undefined ? { depth: depthValue } : {}),
217
200
  ...(parentHeightValue !== undefined ? { parentHeight: parentHeightValue } : {})
218
201
  });
219
- return yield* run({ store, filter, filterJson, quiet, refresh });
202
+ return yield* run({ store, filter, filterJson, quiet, refresh, limit });
220
203
  })
221
204
  ).pipe(
222
205
  Command.withDescription(
@@ -244,7 +227,7 @@ const jetstreamCommand = Command.make(
244
227
  cursor: jetstreamOptions.cursor,
245
228
  compress: jetstreamOptions.compress,
246
229
  maxMessageSize: jetstreamOptions.maxMessageSize,
247
- limit: limitOption,
230
+ limit: jetstreamLimitOption,
248
231
  duration: durationOption,
249
232
  strict: strictOption,
250
233
  maxErrors: maxErrorsOption
@@ -285,10 +268,8 @@ const jetstreamCommand = Command.make(
285
268
  storeRef,
286
269
  filterHash
287
270
  );
288
- const parsedLimit = yield* parseLimit(limit);
289
- const parsedDuration = yield* parseDuration(duration);
290
- const parsedMaxErrors = yield* parseMaxErrors(maxErrors);
291
- if (Option.isNone(parsedLimit) && Option.isNone(parsedDuration)) {
271
+ const parsedDuration = duration;
272
+ if (Option.isNone(limit) && Option.isNone(parsedDuration)) {
292
273
  return yield* CliInputError.make({
293
274
  message:
294
275
  "Jetstream sync requires --limit or --duration. Use watch jetstream for continuous streaming.",
@@ -304,9 +285,9 @@ const jetstreamCommand = Command.make(
304
285
  });
305
286
  const result = yield* Effect.gen(function* () {
306
287
  const engine = yield* JetstreamSyncEngine;
307
- const limitValue = Option.getOrUndefined(parsedLimit);
288
+ const limitValue = Option.getOrUndefined(limit);
308
289
  const durationValue = Option.getOrUndefined(parsedDuration);
309
- const maxErrorsValue = Option.getOrUndefined(parsedMaxErrors);
290
+ const maxErrorsValue = Option.getOrUndefined(maxErrors);
310
291
  return yield* engine.sync({
311
292
  source: selection.source,
312
293
  store: storeRef,
@@ -1,15 +1,17 @@
1
1
  import { Options } from "@effect/cli";
2
- import { Effect, Option } from "effect";
3
- import { parseBoundedIntOption } from "./shared-options.js";
2
+ import { Option } from "effect";
3
+ import { boundedInt } from "./option-schemas.js";
4
4
 
5
5
  export const depthOption = (description: string) =>
6
6
  Options.integer("depth").pipe(
7
+ Options.withSchema(boundedInt(0, 1000)),
7
8
  Options.withDescription(description),
8
9
  Options.optional
9
10
  );
10
11
 
11
12
  export const parentHeightOption = (description: string) =>
12
13
  Options.integer("parent-height").pipe(
14
+ Options.withSchema(boundedInt(0, 1000)),
13
15
  Options.withDescription(description),
14
16
  Options.optional
15
17
  );
@@ -17,17 +19,7 @@ export const parentHeightOption = (description: string) =>
17
19
  export const parseThreadDepth = (
18
20
  depth: Option.Option<number>,
19
21
  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
- });
22
+ ) => ({
23
+ depth: Option.getOrUndefined(depth),
24
+ parentHeight: Option.getOrUndefined(parentHeight)
25
+ });
@@ -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,19 +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
18
  import {
20
19
  depthOption as threadDepthOption,
21
20
  parentHeightOption as threadParentHeightOption,
22
21
  parseThreadDepth
23
22
  } from "./thread-options.js";
24
23
  import { textJsonFormats } from "./output-format.js";
24
+ import { PositiveInt } from "./option-schemas.js";
25
25
 
26
26
  const uriArg = Args.text({ name: "uri" }).pipe(
27
+ Args.withSchema(PostUri),
27
28
  Args.withDescription("AT-URI of any post in the thread")
28
29
  );
29
30
 
@@ -42,6 +43,7 @@ const ansiOption = Options.boolean("ansi").pipe(
42
43
  );
43
44
 
44
45
  const widthOption = Options.integer("width").pipe(
46
+ Options.withSchema(PositiveInt),
45
47
  Options.withDescription("Line width for terminal output"),
46
48
  Options.optional
47
49
  );
@@ -70,10 +72,11 @@ export const threadCommand = Command.make(
70
72
  },
71
73
  ({ uri, store, compact, ansi, width, format, depth, parentHeight }) =>
72
74
  Effect.gen(function* () {
75
+ const output = yield* CliOutput;
73
76
  const outputFormat = Option.getOrElse(format, () => "text" as const);
74
77
  const w = Option.getOrUndefined(width);
75
78
  const { depth: depthValue, parentHeight: parentHeightValue } =
76
- yield* parseThreadDepth(depth, parentHeight);
79
+ parseThreadDepth(depth, parentHeight);
77
80
  const d = depthValue ?? 6;
78
81
  const ph = parentHeightValue ?? 80;
79
82
 
@@ -82,15 +85,15 @@ export const threadCommand = Command.make(
82
85
  if (Option.isSome(store)) {
83
86
  const index = yield* StoreIndex;
84
87
  const storeRef = yield* storeOptions.loadStoreRef(store.value);
85
- const targetUri = yield* Schema.decodeUnknown(PostUri)(uri).pipe(
86
- Effect.mapError((error) =>
87
- CliInputError.make({
88
- message: `Invalid post URI: ${formatSchemaError(error)}`,
89
- cause: error
90
- })
91
- )
92
- );
93
- 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);
94
97
  if (!hasTarget) {
95
98
  const engine = yield* SyncEngine;
96
99
  const source = DataSource.thread(uri, { depth: d, parentHeight: ph });
@@ -100,7 +103,7 @@ export const threadCommand = Command.make(
100
103
  const stream = index.query(storeRef, query);
101
104
  const collected = yield* Stream.runCollect(stream);
102
105
  const allPosts = Chunk.toReadonlyArray(collected);
103
- const threadPosts = selectThreadPosts(allPosts, String(targetUri));
106
+ const threadPosts = selectThreadPosts(allPosts, String(uri));
104
107
  if (threadPosts.length === 0) {
105
108
  return yield* CliInputError.make({
106
109
  message: `Thread not found for ${uri}.`,
@@ -108,7 +111,7 @@ export const threadCommand = Command.make(
108
111
  });
109
112
  }
110
113
  // B1: Hint when only root post exists in store
111
- if (threadPosts.length === 1 && threadPosts[0]?.uri === targetUri) {
114
+ if (threadPosts.length === 1 && threadPosts[0]?.uri === uri) {
112
115
  yield* Console.log("\nℹ️ Only root post found in store. Use --no-store to fetch full thread from API.\n");
113
116
  }
114
117
  posts = threadPosts;
package/src/cli/watch.ts CHANGED
@@ -14,7 +14,6 @@ import { withExamples } from "./help.js";
14
14
  import { buildJetstreamSelection, jetstreamOptions } from "./jetstream.js";
15
15
  import { makeWatchCommandBody } from "./sync-factory.js";
16
16
  import { parseOptionalDuration } from "./interval.js";
17
- import { CliInputError } from "./errors.js";
18
17
  import {
19
18
  feedUriArg,
20
19
  listUriArg,
@@ -27,30 +26,32 @@ import {
27
26
  postFilterJsonOption,
28
27
  authorFilterOption,
29
28
  includePinsOption,
30
- decodeActor,
31
29
  quietOption,
32
30
  refreshOption,
33
31
  strictOption,
34
- maxErrorsOption,
35
- parseMaxErrors
32
+ maxErrorsOption
36
33
  } from "./shared-options.js";
37
34
  import {
38
35
  depthOption as threadDepthOption,
39
36
  parentHeightOption as threadParentHeightOption,
40
37
  parseThreadDepth
41
38
  } from "./thread-options.js";
39
+ import { DurationInput, PositiveInt } from "./option-schemas.js";
42
40
 
43
41
  const intervalOption = Options.text("interval").pipe(
42
+ Options.withSchema(DurationInput),
44
43
  Options.withDescription(
45
44
  "Polling interval (e.g. \"30 seconds\", \"500 millis\") (default: 30 seconds)"
46
45
  ),
47
46
  Options.optional
48
47
  );
49
48
  const maxCyclesOption = Options.integer("max-cycles").pipe(
49
+ Options.withSchema(PositiveInt),
50
50
  Options.withDescription("Stop after N watch cycles"),
51
51
  Options.optional
52
52
  );
53
53
  const untilOption = Options.text("until").pipe(
54
+ Options.withSchema(DurationInput),
54
55
  Options.withDescription("Stop after a duration (e.g. \"10 minutes\")"),
55
56
  Options.optional
56
57
  );
@@ -179,14 +180,13 @@ const authorCommand = Command.make(
179
180
  },
180
181
  ({ actor, filter, includePins, postFilter, postFilterJson, interval, maxCycles, until, store, quiet, refresh }) =>
181
182
  Effect.gen(function* () {
182
- const resolvedActor = yield* decodeActor(actor);
183
183
  const apiFilter = Option.getOrUndefined(filter);
184
- const source = DataSource.author(resolvedActor, {
184
+ const source = DataSource.author(actor, {
185
185
  ...(apiFilter !== undefined ? { filter: apiFilter } : {}),
186
186
  ...(includePins ? { includePins: true } : {})
187
187
  });
188
188
  const run = makeWatchCommandBody("author", () => source, {
189
- actor: resolvedActor,
189
+ actor,
190
190
  ...(apiFilter !== undefined ? { filter: apiFilter } : {}),
191
191
  ...(includePins ? { includePins: true } : {})
192
192
  });
@@ -232,7 +232,7 @@ const threadCommand = Command.make(
232
232
  ({ uri, depth, parentHeight, filter, filterJson, interval, maxCycles, until, store, quiet, refresh }) =>
233
233
  Effect.gen(function* () {
234
234
  const { depth: depthValue, parentHeight: parentHeightValue } =
235
- yield* parseThreadDepth(depth, parentHeight);
235
+ parseThreadDepth(depth, parentHeight);
236
236
  const source = DataSource.thread(uri, {
237
237
  ...(depthValue !== undefined ? { depth: depthValue } : {}),
238
238
  ...(parentHeightValue !== undefined ? { parentHeight: parentHeightValue } : {})
@@ -309,27 +309,14 @@ const jetstreamCommand = Command.make(
309
309
  storeRef,
310
310
  filterHash
311
311
  );
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
- });
312
+ const parsedUntil = parseOptionalDuration(until);
326
313
  const engineLayer = JetstreamSyncEngine.layer.pipe(
327
314
  Layer.provideMerge(Jetstream.live(selection.config))
328
315
  );
329
316
  yield* logInfo("Starting watch", { source: "jetstream", store: storeRef.name });
330
317
  yield* Effect.gen(function* () {
331
318
  const engine = yield* JetstreamSyncEngine;
332
- const maxErrorsValue = Option.getOrUndefined(parsedMaxErrors);
319
+ const maxErrorsValue = Option.getOrUndefined(maxErrors);
333
320
  const stream = engine.watch({
334
321
  source: selection.source,
335
322
  store: storeRef,
@@ -346,8 +333,8 @@ const jetstreamCommand = Command.make(
346
333
  makeSyncReporter(quiet, monitor, output)
347
334
  )
348
335
  );
349
- const limited = Option.isSome(parsedMaxCycles)
350
- ? outputStream.pipe(Stream.take(parsedMaxCycles.value))
336
+ const limited = Option.isSome(maxCycles)
337
+ ? outputStream.pipe(Stream.take(maxCycles.value))
351
338
  : outputStream;
352
339
  const timed = Option.isSome(parsedUntil)
353
340
  ? limited.pipe(Stream.interruptWhen(Effect.sleep(parsedUntil.value)))
@@ -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
@@ -1256,6 +1256,7 @@ export class BskyClient extends Context.Tag("@skygent/BskyClient")<
1256
1256
  const config = yield* AppConfigService;
1257
1257
  const credentials = yield* CredentialStore;
1258
1258
  const agent = new AtpAgent({ service: config.service });
1259
+ const publicAgent = new AtpAgent({ service: "https://public.api.bsky.app" });
1259
1260
 
1260
1261
  const minInterval = yield* Config.duration("SKYGENT_BSKY_RATE_LIMIT").pipe(
1261
1262
  Config.withDefault(Duration.millis(250))
@@ -1507,6 +1508,7 @@ export class BskyClient extends Context.Tag("@skygent/BskyClient")<
1507
1508
  const getFollowers = (actor: string, opts?: GraphOptions) =>
1508
1509
  Effect.gen(function* () {
1509
1510
  yield* ensureAuth(false);
1511
+ const api = agent.hasSession ? agent : publicAgent;
1510
1512
  const params = withCursor(
1511
1513
  { actor, limit: opts?.limit ?? 50 },
1512
1514
  opts?.cursor
@@ -1514,7 +1516,7 @@ export class BskyClient extends Context.Tag("@skygent/BskyClient")<
1514
1516
  const response = yield* withRetry(
1515
1517
  withRateLimit(
1516
1518
  Effect.tryPromise<AppBskyGraphGetFollowers.Response>(() =>
1517
- agent.app.bsky.graph.getFollowers(params)
1519
+ api.app.bsky.graph.getFollowers(params)
1518
1520
  )
1519
1521
  )
1520
1522
  ).pipe(Effect.mapError(toBskyError("Failed to fetch followers", "getFollowers")));
@@ -1531,6 +1533,7 @@ export class BskyClient extends Context.Tag("@skygent/BskyClient")<
1531
1533
  const getFollows = (actor: string, opts?: GraphOptions) =>
1532
1534
  Effect.gen(function* () {
1533
1535
  yield* ensureAuth(false);
1536
+ const api = agent.hasSession ? agent : publicAgent;
1534
1537
  const params = withCursor(
1535
1538
  { actor, limit: opts?.limit ?? 50 },
1536
1539
  opts?.cursor
@@ -1538,7 +1541,7 @@ export class BskyClient extends Context.Tag("@skygent/BskyClient")<
1538
1541
  const response = yield* withRetry(
1539
1542
  withRateLimit(
1540
1543
  Effect.tryPromise<AppBskyGraphGetFollows.Response>(() =>
1541
- agent.app.bsky.graph.getFollows(params)
1544
+ api.app.bsky.graph.getFollows(params)
1542
1545
  )
1543
1546
  )
1544
1547
  ).pipe(Effect.mapError(toBskyError("Failed to fetch follows", "getFollows")));
@@ -1582,10 +1585,11 @@ export class BskyClient extends Context.Tag("@skygent/BskyClient")<
1582
1585
  return { actor, relationships: [] };
1583
1586
  }
1584
1587
  yield* ensureAuth(false);
1588
+ const api = agent.hasSession ? agent : publicAgent;
1585
1589
  const response = yield* withRetry(
1586
1590
  withRateLimit(
1587
1591
  Effect.tryPromise<AppBskyGraphGetRelationships.Response>(() =>
1588
- agent.app.bsky.graph.getRelationships({ actor, others: [...others] })
1592
+ api.app.bsky.graph.getRelationships({ actor, others: [...others] })
1589
1593
  )
1590
1594
  )
1591
1595
  ).pipe(Effect.mapError(toBskyError("Failed to fetch relationships", "getRelationships")));
@@ -1602,6 +1606,7 @@ export class BskyClient extends Context.Tag("@skygent/BskyClient")<
1602
1606
  const getList = (uri: string, opts?: GraphOptions) =>
1603
1607
  Effect.gen(function* () {
1604
1608
  yield* ensureAuth(false);
1609
+ const api = agent.hasSession ? agent : publicAgent;
1605
1610
  const params = withCursor(
1606
1611
  { list: uri, limit: opts?.limit ?? 50 },
1607
1612
  opts?.cursor
@@ -1609,7 +1614,7 @@ export class BskyClient extends Context.Tag("@skygent/BskyClient")<
1609
1614
  const response = yield* withRetry(
1610
1615
  withRateLimit(
1611
1616
  Effect.tryPromise<AppBskyGraphGetList.Response>(() =>
1612
- agent.app.bsky.graph.getList(params)
1617
+ api.app.bsky.graph.getList(params)
1613
1618
  )
1614
1619
  )
1615
1620
  ).pipe(Effect.mapError(toBskyError("Failed to fetch list", "getList")));
@@ -1626,6 +1631,7 @@ export class BskyClient extends Context.Tag("@skygent/BskyClient")<
1626
1631
  const getLists = (actor: string, opts?: GraphListsOptions) =>
1627
1632
  Effect.gen(function* () {
1628
1633
  yield* ensureAuth(false);
1634
+ const api = agent.hasSession ? agent : publicAgent;
1629
1635
  const params = withCursor(
1630
1636
  {
1631
1637
  actor,
@@ -1639,7 +1645,7 @@ export class BskyClient extends Context.Tag("@skygent/BskyClient")<
1639
1645
  const response = yield* withRetry(
1640
1646
  withRateLimit(
1641
1647
  Effect.tryPromise<AppBskyGraphGetLists.Response>(() =>
1642
- agent.app.bsky.graph.getLists(params)
1648
+ api.app.bsky.graph.getLists(params)
1643
1649
  )
1644
1650
  )
1645
1651
  ).pipe(Effect.mapError(toBskyError("Failed to fetch lists", "getLists")));
@@ -19,10 +19,57 @@ export const pickDefined = <T extends Record<string, unknown>>(input: T): Partia
19
19
  Object.entries(input).filter(([, value]) => value !== undefined)
20
20
  ) as Partial<T>;
21
21
 
22
+ type FormatParseErrorOptions = {
23
+ readonly label?: string;
24
+ readonly maxIssues?: number;
25
+ };
26
+
27
+ const formatPath = (path: ReadonlyArray<unknown>) =>
28
+ path.length > 0 ? path.map((entry) => String(entry)).join(".") : "value";
29
+
30
+ export const formatParseError = (
31
+ error: ParseResult.ParseError,
32
+ options?: FormatParseErrorOptions
33
+ ) => {
34
+ const issues = ParseResult.ArrayFormatter.formatErrorSync(error);
35
+ if (issues.length === 0) {
36
+ return ParseResult.TreeFormatter.formatErrorSync(error);
37
+ }
38
+
39
+ const jsonParseIssue = issues.find(
40
+ (issue) =>
41
+ issue._tag === "Transformation" &&
42
+ typeof issue.message === "string" &&
43
+ issue.message.startsWith("JSON Parse error")
44
+ );
45
+ if (jsonParseIssue) {
46
+ const header = options?.label
47
+ ? `Invalid JSON input for ${options.label}.`
48
+ : "Invalid JSON input.";
49
+ return [
50
+ header,
51
+ jsonParseIssue.message,
52
+ "Tip: wrap JSON in single quotes to avoid shell escaping issues."
53
+ ].join("\n");
54
+ }
55
+
56
+ const maxIssues = options?.maxIssues ?? 6;
57
+ const lines = issues.slice(0, maxIssues).map((issue) => {
58
+ const path = formatPath(issue.path);
59
+ return `${path}: ${issue.message}`;
60
+ });
61
+ if (issues.length > maxIssues) {
62
+ lines.push(`Additional issues: ${issues.length - maxIssues}`);
63
+ }
64
+
65
+ const header = options?.label ? `Invalid ${options.label}.` : undefined;
66
+ return header ? [header, ...lines].join("\n") : lines.join("\n");
67
+ };
68
+
22
69
  /** Format a Schema parse error (or arbitrary unknown) as a readable string. */
23
70
  export const formatSchemaError = (error: unknown) => {
24
71
  if (ParseResult.isParseError(error)) {
25
- return ParseResult.TreeFormatter.formatErrorSync(error);
72
+ return formatParseError(error);
26
73
  }
27
74
  return String(error);
28
75
  };
@@ -11,7 +11,10 @@ export class StoreCleaner extends Context.Tag("@skygent/StoreCleaner")<
11
11
  {
12
12
  readonly deleteStore: (
13
13
  name: StoreName
14
- ) => Effect.Effect<{ readonly deleted: boolean }, StoreError>;
14
+ ) => Effect.Effect<
15
+ { readonly deleted: boolean; readonly reason?: "missing" },
16
+ StoreError
17
+ >;
15
18
  }
16
19
  >() {
17
20
  static readonly layer = Layer.effect(
@@ -26,7 +29,7 @@ export class StoreCleaner extends Context.Tag("@skygent/StoreCleaner")<
26
29
  Effect.gen(function* () {
27
30
  const storeOption = yield* manager.getStore(name);
28
31
  if (Option.isNone(storeOption)) {
29
- return { deleted: false } as const;
32
+ return { deleted: false, reason: "missing" } as const;
30
33
  }
31
34
  const store = storeOption.value;
32
35
  yield* eventLog.clear(store);
@@ -13,6 +13,7 @@ type StoreRenameResult = {
13
13
  readonly from: StoreName;
14
14
  readonly to: StoreName;
15
15
  readonly moved: boolean;
16
+ readonly movedOnDisk: boolean;
16
17
  readonly lineagesUpdated: number;
17
18
  readonly checkpointsUpdated: number;
18
19
  };
@@ -262,7 +263,8 @@ export class StoreRenamer extends Context.Tag("@skygent/StoreRenamer")<
262
263
  return {
263
264
  from,
264
265
  to,
265
- moved: fromExists,
266
+ moved: true,
267
+ movedOnDisk: fromExists,
266
268
  lineagesUpdated,
267
269
  checkpointsUpdated
268
270
  } satisfies StoreRenameResult;