@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/search.ts CHANGED
@@ -1,33 +1,31 @@
1
1
  import { Args, Command, Options } from "@effect/cli";
2
2
  import { Effect, Option, Stream } from "effect";
3
- import { renderTableLegacy } from "./doc/table.js";
3
+ import { renderFeedTable, renderProfileTable } from "./doc/table-renderers.js";
4
4
  import { BskyClient } from "../services/bsky-client.js";
5
5
  import { PostParser } from "../services/post-parser.js";
6
6
  import { StoreIndex } from "../services/store-index.js";
7
7
  import { renderPostsTable } from "../domain/format.js";
8
8
  import { AppConfigService } from "../services/app-config.js";
9
- import type { FeedGeneratorView, ProfileView } from "../domain/bsky.js";
10
- import { StoreName } from "../domain/primitives.js";
9
+ import { ActorId, StoreName } from "../domain/primitives.js";
11
10
  import { storeOptions } from "./store.js";
12
11
  import { withExamples } from "./help.js";
13
12
  import { CliInputError } from "./errors.js";
14
- import { decodeActor } from "./shared-options.js";
15
13
  import { formatSchemaError } from "./shared.js";
16
14
  import { writeJson, writeJsonStream, writeText } from "./output.js";
17
- import { jsonNdjsonTableFormats, resolveOutputFormat } from "./output-format.js";
15
+ import { jsonNdjsonTableFormats } from "./output-format.js";
16
+ import { emitWithFormat } from "./output-render.js";
17
+ import { cursorOption as baseCursorOption, limitOption as baseLimitOption, parsePagination } from "./pagination.js";
18
18
 
19
19
  const queryArg = Args.text({ name: "query" }).pipe(
20
20
  Args.withDescription("Search query string")
21
21
  );
22
22
 
23
- const limitOption = Options.integer("limit").pipe(
24
- Options.withDescription("Maximum number of results"),
25
- Options.optional
23
+ const limitOption = baseLimitOption.pipe(
24
+ Options.withDescription("Maximum number of results")
26
25
  );
27
26
 
28
- const cursorOption = Options.text("cursor").pipe(
29
- Options.withDescription("Pagination cursor"),
30
- Options.optional
27
+ const cursorOption = baseCursorOption.pipe(
28
+ Options.withDescription("Pagination cursor")
31
29
  );
32
30
 
33
31
  const typeaheadOption = Options.boolean("typeahead").pipe(
@@ -50,9 +48,8 @@ const networkOption = Options.boolean("network").pipe(
50
48
  Options.withDescription("Search the Bluesky network instead of a local store")
51
49
  );
52
50
 
53
- const postCursorOption = Options.text("cursor").pipe(
54
- Options.withDescription("Pagination cursor (network) or offset (local)"),
55
- Options.optional
51
+ const postCursorOption = baseCursorOption.pipe(
52
+ Options.withDescription("Pagination cursor (network) or offset (local)")
56
53
  );
57
54
 
58
55
  const sortOption = Options.text("sort").pipe(
@@ -71,11 +68,13 @@ const untilOption = Options.text("until").pipe(
71
68
  );
72
69
 
73
70
  const mentionsOption = Options.text("mentions").pipe(
71
+ Options.withSchema(ActorId),
74
72
  Options.withDescription("Filter network results by mention (handle or DID)"),
75
73
  Options.optional
76
74
  );
77
75
 
78
76
  const authorOption = Options.text("author").pipe(
77
+ Options.withSchema(ActorId),
79
78
  Options.withDescription("Filter network results by author (handle or DID)"),
80
79
  Options.optional
81
80
  );
@@ -100,35 +99,21 @@ const tagOption = Options.text("tag").pipe(
100
99
  Options.optional
101
100
  );
102
101
 
102
+ const requireNonEmptyQuery = (raw: string) =>
103
+ Effect.gen(function* () {
104
+ const trimmed = raw.trim();
105
+ if (trimmed.length === 0) {
106
+ return yield* CliInputError.make({
107
+ message: "Search query must be non-empty.",
108
+ cause: { query: raw }
109
+ });
110
+ }
111
+ return trimmed;
112
+ });
103
113
 
104
- type LocalSort = "relevance" | "newest" | "oldest";
105
114
 
106
- const renderProfileTable = (
107
- actors: ReadonlyArray<ProfileView>,
108
- cursor: string | undefined
109
- ) => {
110
- const rows = actors.map((actor) => [
111
- actor.handle,
112
- actor.displayName ?? "",
113
- actor.did
114
- ]);
115
- const table = renderTableLegacy(["HANDLE", "DISPLAY NAME", "DID"], rows);
116
- return cursor ? `${table}\n\nCursor: ${cursor}` : table;
117
- };
115
+ type LocalSort = "relevance" | "newest" | "oldest";
118
116
 
119
- const renderFeedTable = (
120
- feeds: ReadonlyArray<FeedGeneratorView>,
121
- cursor: string | undefined
122
- ) => {
123
- const rows = feeds.map((feed) => [
124
- feed.displayName,
125
- feed.creator.handle,
126
- feed.uri,
127
- typeof feed.likeCount === "number" ? String(feed.likeCount) : ""
128
- ]);
129
- const table = renderTableLegacy(["NAME", "CREATOR", "URI", "LIKES"], rows);
130
- return cursor ? `${table}\n\nCursor: ${cursor}` : table;
131
- };
132
117
 
133
118
  const handlesCommand = Command.make(
134
119
  "handles",
@@ -142,6 +127,7 @@ const handlesCommand = Command.make(
142
127
  ({ query, limit, cursor, typeahead, format }) =>
143
128
  Effect.gen(function* () {
144
129
  const appConfig = yield* AppConfigService;
130
+ const queryValue = yield* requireNonEmptyQuery(query);
145
131
  if (typeahead && Option.isSome(cursor)) {
146
132
  return yield* CliInputError.make({
147
133
  message: "--cursor is not supported with --typeahead.",
@@ -149,27 +135,24 @@ const handlesCommand = Command.make(
149
135
  });
150
136
  }
151
137
  const client = yield* BskyClient;
138
+ const { limit: limitValue, cursor: cursorValue } = parsePagination(limit, cursor);
152
139
  const options = {
153
- ...(Option.isSome(limit) ? { limit: limit.value } : {}),
154
- ...(Option.isSome(cursor) ? { cursor: cursor.value } : {}),
140
+ ...(limitValue !== undefined ? { limit: limitValue } : {}),
141
+ ...(cursorValue !== undefined ? { cursor: cursorValue } : {}),
155
142
  ...(typeahead ? { typeahead: true } : {})
156
143
  };
157
- const result = yield* client.searchActors(query, options);
158
- const outputFormat = resolveOutputFormat(
144
+ const result = yield* client.searchActors(queryValue, options);
145
+ yield* emitWithFormat(
159
146
  format,
160
147
  appConfig.outputFormat,
161
148
  jsonNdjsonTableFormats,
162
- "json"
149
+ "json",
150
+ {
151
+ json: writeJson(result),
152
+ ndjson: writeJsonStream(Stream.fromIterable(result.actors)),
153
+ table: writeText(renderProfileTable(result.actors, result.cursor))
154
+ }
163
155
  );
164
- if (outputFormat === "ndjson") {
165
- yield* writeJsonStream(Stream.fromIterable(result.actors));
166
- return;
167
- }
168
- if (outputFormat === "table") {
169
- yield* writeText(renderProfileTable(result.actors, result.cursor));
170
- return;
171
- }
172
- yield* writeJson(result);
173
156
  })
174
157
  ).pipe(
175
158
  Command.withDescription(
@@ -186,27 +169,25 @@ const feedsCommand = Command.make(
186
169
  ({ query, limit, cursor, format }) =>
187
170
  Effect.gen(function* () {
188
171
  const appConfig = yield* AppConfigService;
172
+ const queryValue = yield* requireNonEmptyQuery(query);
189
173
  const client = yield* BskyClient;
174
+ const { limit: limitValue, cursor: cursorValue } = parsePagination(limit, cursor);
190
175
  const options = {
191
- ...(Option.isSome(limit) ? { limit: limit.value } : {}),
192
- ...(Option.isSome(cursor) ? { cursor: cursor.value } : {})
176
+ ...(limitValue !== undefined ? { limit: limitValue } : {}),
177
+ ...(cursorValue !== undefined ? { cursor: cursorValue } : {})
193
178
  };
194
- const result = yield* client.searchFeedGenerators(query, options);
195
- const outputFormat = resolveOutputFormat(
179
+ const result = yield* client.searchFeedGenerators(queryValue, options);
180
+ yield* emitWithFormat(
196
181
  format,
197
182
  appConfig.outputFormat,
198
183
  jsonNdjsonTableFormats,
199
- "json"
184
+ "json",
185
+ {
186
+ json: writeJson(result),
187
+ ndjson: writeJsonStream(Stream.fromIterable(result.feeds)),
188
+ table: writeText(renderFeedTable(result.feeds, result.cursor))
189
+ }
200
190
  );
201
- if (outputFormat === "ndjson") {
202
- yield* writeJsonStream(Stream.fromIterable(result.feeds));
203
- return;
204
- }
205
- if (outputFormat === "table") {
206
- yield* writeText(renderFeedTable(result.feeds, result.cursor));
207
- return;
208
- }
209
- yield* writeJson(result);
210
191
  })
211
192
  ).pipe(
212
193
  Command.withDescription(
@@ -238,12 +219,8 @@ const postsCommand = Command.make(
238
219
  ({ query, store, network, limit, cursor, sort, since, until, mentions, author, lang, domain, url, tag, format }) =>
239
220
  Effect.gen(function* () {
240
221
  const appConfig = yield* AppConfigService;
241
- if (Option.isSome(limit) && limit.value <= 0) {
242
- return yield* CliInputError.make({
243
- message: "--limit must be a positive integer.",
244
- cause: { limit: limit.value }
245
- });
246
- }
222
+ const queryValue = yield* requireNonEmptyQuery(query);
223
+ const limitValue = Option.getOrUndefined(limit);
247
224
  if (network && Option.isSome(store)) {
248
225
  return yield* CliInputError.make({
249
226
  message: "--store cannot be used with --network.",
@@ -281,12 +258,6 @@ const postsCommand = Command.make(
281
258
  });
282
259
  }
283
260
 
284
- const outputFormat = resolveOutputFormat(
285
- format,
286
- appConfig.outputFormat,
287
- jsonNdjsonTableFormats,
288
- "json"
289
- );
290
261
  const storeValue = Option.getOrElse(store, () => undefined);
291
262
 
292
263
  if (network) {
@@ -315,29 +286,13 @@ const postsCommand = Command.make(
315
286
  .map((item) => item.trim())
316
287
  .filter((item) => item.length > 0)
317
288
  });
318
- const authorValue = Option.match(author, {
319
- onNone: () => Effect.void.pipe(Effect.as(undefined)),
320
- onSome: (value) =>
321
- Effect.gen(function* () {
322
- const decoded = yield* decodeActor(value);
323
- return String(decoded);
324
- })
325
- });
326
- const mentionsValue = Option.match(mentions, {
327
- onNone: () => Effect.void.pipe(Effect.as(undefined)),
328
- onSome: (value) =>
329
- Effect.gen(function* () {
330
- const decoded = yield* decodeActor(value);
331
- return String(decoded);
332
- })
333
- });
334
- const parsedAuthor = yield* authorValue;
335
- const parsedMentions = yield* mentionsValue;
336
- const result = yield* client.searchPosts(query, {
337
- ...(Option.isSome(limit) ? { limit: limit.value } : {}),
338
- ...(Option.isSome(cursorValue) ? { cursor: cursorValue.value } : {}),
339
- ...(sortValue ? { sort: sortValue } : {}),
340
- ...(Option.isSome(since) ? { since: since.value } : {}),
289
+ const parsedAuthor = Option.getOrUndefined(author);
290
+ const parsedMentions = Option.getOrUndefined(mentions);
291
+ const result = yield* client.searchPosts(queryValue, {
292
+ ...(limitValue !== undefined ? { limit: limitValue } : {}),
293
+ ...(Option.isSome(cursorValue) ? { cursor: cursorValue.value } : {}),
294
+ ...(sortValue ? { sort: sortValue } : {}),
295
+ ...(Option.isSome(since) ? { since: since.value } : {}),
341
296
  ...(Option.isSome(until) ? { until: until.value } : {}),
342
297
  ...(parsedMentions ? { mentions: parsedMentions } : {}),
343
298
  ...(parsedAuthor ? { author: parsedAuthor } : {}),
@@ -359,21 +314,23 @@ const postsCommand = Command.make(
359
314
  ),
360
315
  { concurrency: "unbounded" }
361
316
  );
362
- if (outputFormat === "ndjson") {
363
- yield* writeJsonStream(Stream.fromIterable(posts));
364
- return;
365
- }
366
- if (outputFormat === "table") {
367
- yield* writeText(renderPostsTable(posts));
368
- return;
369
- }
370
- yield* writeJson({
371
- query,
372
- cursor: result.cursor,
373
- hitsTotal: result.hitsTotal,
374
- count: posts.length,
375
- posts
376
- });
317
+ yield* emitWithFormat(
318
+ format,
319
+ appConfig.outputFormat,
320
+ jsonNdjsonTableFormats,
321
+ "json",
322
+ {
323
+ json: writeJson({
324
+ query: queryValue,
325
+ cursor: result.cursor,
326
+ hitsTotal: result.hitsTotal,
327
+ count: posts.length,
328
+ posts
329
+ }),
330
+ ndjson: writeJsonStream(Stream.fromIterable(posts)),
331
+ table: writeText(renderPostsTable(posts))
332
+ }
333
+ );
377
334
  return;
378
335
  }
379
336
 
@@ -419,28 +376,28 @@ const postsCommand = Command.make(
419
376
  });
420
377
  }
421
378
  const input = {
422
- query,
423
- ...(Option.isSome(limit) ? { limit: limit.value } : {}),
379
+ query: queryValue,
380
+ ...(limitValue !== undefined ? { limit: limitValue } : {}),
424
381
  ...(Option.isSome(cursorValue) ? { cursor: cursorValue.value } : {}),
425
382
  ...(localSort ? { sort: localSort } : {})
426
383
  };
427
384
  const result = yield* index.searchPosts(storeRef, input);
428
-
429
- if (outputFormat === "ndjson") {
430
- const stream = Stream.fromIterable(result.posts);
431
- yield* writeJsonStream(stream);
432
- return;
433
- }
434
- if (outputFormat === "table") {
435
- yield* writeText(renderPostsTable(result.posts));
436
- return;
437
- }
438
- yield* writeJson({
439
- query,
440
- cursor: result.cursor,
441
- count: result.posts.length,
442
- posts: result.posts
443
- });
385
+ yield* emitWithFormat(
386
+ format,
387
+ appConfig.outputFormat,
388
+ jsonNdjsonTableFormats,
389
+ "json",
390
+ {
391
+ json: writeJson({
392
+ query: queryValue,
393
+ cursor: result.cursor,
394
+ count: result.posts.length,
395
+ posts: result.posts
396
+ }),
397
+ ndjson: writeJsonStream(Stream.fromIterable(result.posts)),
398
+ table: writeText(renderPostsTable(result.posts))
399
+ }
400
+ );
444
401
  })
445
402
  ).pipe(
446
403
  Command.withDescription(
@@ -1,9 +1,10 @@
1
1
  import { Args, Options } from "@effect/cli";
2
- import { Effect, Option, Schema } from "effect";
3
- import { Did, Handle, StoreName } from "../domain/primitives.js";
2
+ import { Effect, Schema } from "effect";
3
+ import { ActorId, AtUri, PostUri, StoreName } from "../domain/primitives.js";
4
4
  import { filterDslDescription, filterJsonDescription } from "./filter-help.js";
5
5
  import { CliInputError } from "./errors.js";
6
6
  import { formatSchemaError } from "./shared.js";
7
+ import { NonNegativeInt } from "./option-schemas.js";
7
8
 
8
9
  /** --store option with StoreName schema validation */
9
10
  export const storeNameOption = Options.text("store").pipe(
@@ -52,27 +53,32 @@ export const strictOption = Options.boolean("strict").pipe(
52
53
 
53
54
  /** --max-errors option (optional) */
54
55
  export const maxErrorsOption = Options.integer("max-errors").pipe(
56
+ Options.withSchema(NonNegativeInt),
55
57
  Options.withDescription("Stop after exceeding N errors (default: unlimited)"),
56
58
  Options.optional
57
59
  );
58
60
 
59
61
  /** Positional arg for feed URI */
60
62
  export const feedUriArg = Args.text({ name: "uri" }).pipe(
63
+ Args.withSchema(AtUri),
61
64
  Args.withDescription("Bluesky feed URI (at://...)")
62
65
  );
63
66
 
64
67
  /** Positional arg for list URI */
65
68
  export const listUriArg = Args.text({ name: "uri" }).pipe(
69
+ Args.withSchema(AtUri),
66
70
  Args.withDescription("Bluesky list URI (at://...)")
67
71
  );
68
72
 
69
73
  /** Positional arg for author handle or DID */
70
74
  export const actorArg = Args.text({ name: "actor" }).pipe(
75
+ Args.withSchema(ActorId),
71
76
  Args.withDescription("Bluesky handle or DID")
72
77
  );
73
78
 
74
79
  /** Positional arg for post URI */
75
80
  export const postUriArg = Args.text({ name: "uri" }).pipe(
81
+ Args.withSchema(PostUri),
76
82
  Args.withDescription("Bluesky post URI (at://...)")
77
83
  );
78
84
 
@@ -100,70 +106,12 @@ export const includePinsOption = Options.boolean("include-pins").pipe(
100
106
  );
101
107
 
102
108
  /** Validate --max-errors value is non-negative */
103
- export const parseMaxErrors = (maxErrors: Option.Option<number>) =>
104
- Option.match(maxErrors, {
105
- onNone: () => Effect.succeed(Option.none()),
106
- onSome: (value) =>
107
- value < 0
108
- ? Effect.fail(
109
- CliInputError.make({
110
- message: "max-errors must be a non-negative integer.",
111
- cause: value
112
- })
113
- )
114
- : Effect.succeed(Option.some(value))
115
- });
116
-
117
- export const decodeActor = (actor: string) => {
118
- if (actor.startsWith("did:")) {
119
- return Schema.decodeUnknown(Did)(actor).pipe(
120
- Effect.mapError((error) =>
121
- CliInputError.make({
122
- message: `Invalid DID: ${formatSchemaError(error)}`,
123
- cause: { actor }
124
- })
125
- )
126
- );
127
- }
128
- return Schema.decodeUnknown(Handle)(actor).pipe(
109
+ export const decodeActor = (actor: string) =>
110
+ Schema.decodeUnknown(ActorId)(actor).pipe(
129
111
  Effect.mapError((error) =>
130
112
  CliInputError.make({
131
- message: `Invalid handle: ${formatSchemaError(error)}`,
113
+ message: `Invalid actor: ${formatSchemaError(error)}`,
132
114
  cause: { actor }
133
115
  })
134
116
  )
135
117
  );
136
- };
137
-
138
- export const parseLimit = (limit: Option.Option<number>) =>
139
- Option.match(limit, {
140
- onNone: () => Effect.succeed(Option.none()),
141
- onSome: (value) =>
142
- value <= 0
143
- ? Effect.fail(
144
- CliInputError.make({
145
- message: "--limit must be a positive integer.",
146
- cause: value
147
- })
148
- )
149
- : Effect.succeed(Option.some(value))
150
- });
151
-
152
- export const parseBoundedIntOption = (
153
- value: Option.Option<number>,
154
- name: string,
155
- min: number,
156
- max: number
157
- ) =>
158
- Option.match(value, {
159
- onNone: () => Effect.succeed(Option.none()),
160
- onSome: (raw) =>
161
- raw < min || raw > max
162
- ? Effect.fail(
163
- CliInputError.make({
164
- message: `${name} must be between ${min} and ${max}.`,
165
- cause: raw
166
- })
167
- )
168
- : Effect.succeed(Option.some(raw))
169
- });
package/src/cli/shared.ts CHANGED
@@ -1,20 +1 @@
1
- export { formatSchemaError } from "../services/shared.js";
2
-
3
- /** Safely parse JSON, returning `undefined` on failure. */
4
- export const safeParseJson = (raw: string): unknown => {
5
- try {
6
- return JSON.parse(raw);
7
- } catch {
8
- return undefined;
9
- }
10
- };
11
-
12
- /** Format schema issues into an array of "path: message" strings. */
13
- export const issueDetails = (
14
- issues: ReadonlyArray<{ readonly path: ReadonlyArray<unknown>; readonly message: string }>
15
- ) =>
16
- issues.map((issue) => {
17
- const path =
18
- issue.path.length > 0 ? issue.path.map((entry) => String(entry)).join(".") : "value";
19
- return `${path}: ${issue.message}`;
20
- });
1
+ export { formatParseError, formatSchemaError } from "../services/shared.js";
@@ -1,23 +1,31 @@
1
1
  import { ParseResult } from "effect";
2
- import { safeParseJson, issueDetails } from "./shared.js";
2
+ import { safeParseJson, issueDetails, findJsonParseIssue, jsonParseTip } from "./parse-errors.js";
3
3
  import { formatAgentError } from "./errors.js";
4
4
 
5
5
  const storeConfigExample = {
6
6
  format: { json: true, markdown: false },
7
7
  autoSync: false,
8
- filters: [
9
- {
10
- name: "tech",
11
- expr: { _tag: "Hashtag", tag: "#tech" },
12
- output: { path: "views/tech", json: true, markdown: true }
13
- }
14
- ]
8
+ filters: []
15
9
  };
16
10
 
11
+ const storeConfigExampleJson = JSON.stringify(storeConfigExample);
12
+ const storeConfigDocHint = "See docs/cli.md for a minimal StoreConfig JSON example.";
13
+
17
14
 
18
15
  const hasPath = (issue: { readonly path: ReadonlyArray<unknown> }, key: string) =>
19
16
  issue.path.length > 0 && issue.path[0] === key;
20
17
 
18
+ export const formatStoreConfigHelp = (
19
+ message: string,
20
+ error = "StoreConfigValidationError"
21
+ ): string =>
22
+ formatAgentError({
23
+ error,
24
+ message: `${message} ${storeConfigDocHint}`,
25
+ expected: storeConfigExample,
26
+ fix: `Start with: --config-json '${storeConfigExampleJson}'`
27
+ });
28
+
21
29
 
22
30
  export const formatStoreConfigParseError = (
23
31
  error: ParseResult.ParseError,
@@ -27,20 +35,16 @@ export const formatStoreConfigParseError = (
27
35
  const received = safeParseJson(raw);
28
36
  const receivedValue = received === undefined ? raw : received;
29
37
 
30
- const jsonParseIssue = issues.find(
31
- (issue) =>
32
- issue._tag === "Transformation" &&
33
- typeof issue.message === "string" &&
34
- issue.message.startsWith("JSON Parse error")
35
- );
38
+ const jsonParseIssue = findJsonParseIssue(issues);
36
39
  if (jsonParseIssue) {
40
+ const jsonMessage = jsonParseIssue.message ?? "Invalid JSON input.";
37
41
  return formatAgentError({
38
42
  error: "StoreConfigJsonParseError",
39
- message: "Invalid JSON in --config-json.",
43
+ message: `Invalid JSON in --config-json. ${storeConfigDocHint}`,
40
44
  received: raw,
41
45
  details: [
42
- jsonParseIssue.message,
43
- "Tip: wrap JSON in single quotes to avoid shell escaping issues."
46
+ jsonMessage,
47
+ jsonParseTip
44
48
  ],
45
49
  expected: storeConfigExample
46
50
  });
@@ -49,7 +53,7 @@ export const formatStoreConfigParseError = (
49
53
  if (issues.some((issue) => issue._tag === "Missing" && hasPath(issue, "filters"))) {
50
54
  return formatAgentError({
51
55
  error: "StoreConfigValidationError",
52
- message: "Store config requires a filters array.",
56
+ message: `Store config requires a filters array. ${storeConfigDocHint}`,
53
57
  received: receivedValue,
54
58
  expected: storeConfigExample,
55
59
  fix:
@@ -60,7 +64,7 @@ export const formatStoreConfigParseError = (
60
64
  if (issues.some((issue) => hasPath(issue, "filters"))) {
61
65
  return formatAgentError({
62
66
  error: "StoreConfigValidationError",
63
- message: "Store config filters must include name, expr, and output fields.",
67
+ message: `Store config filters must include name, expr, and output fields. ${storeConfigDocHint}`,
64
68
  received: receivedValue,
65
69
  expected: storeConfigExample,
66
70
  fix: "Each filter requires name, expr (filter JSON), and output (path/json/markdown).",
@@ -68,12 +72,15 @@ export const formatStoreConfigParseError = (
68
72
  });
69
73
  }
70
74
 
75
+ const details = issueDetails(issues);
76
+ const primaryIssue = details[0];
77
+ const issueHint = primaryIssue ? ` (${primaryIssue})` : "";
71
78
  return formatAgentError({
72
79
  error: "StoreConfigValidationError",
73
- message: "Store config failed validation.",
80
+ message: `Store config failed validation${issueHint}. ${storeConfigDocHint}`,
74
81
  received: receivedValue,
75
82
  expected: storeConfigExample,
76
- details: issueDetails(issues),
83
+ details,
77
84
  fix:
78
85
  "Check required fields (format, autoSync, filters). For ingestion filters, use --filter/--filter-json on sync/query."
79
86
  });
@@ -1,5 +1,5 @@
1
1
  import * as Doc from "@effect/printer/Doc";
2
- import { Chunk, Context, Effect, Option } from "effect";
2
+ import { Chunk, Context, Effect, Option, Order } from "effect";
3
3
  import { StoreIndex } from "../services/store-index.js";
4
4
  import { StoreManager } from "../services/store-manager.js";
5
5
  import { LineageStore } from "../services/lineage-store.js";
@@ -9,6 +9,7 @@ import { StoreEventLog } from "../services/store-event-log.js";
9
9
  import { DataSource } from "../domain/sync.js";
10
10
  import type { FilterExpr } from "../domain/filter.js";
11
11
  import { formatFilterExpr } from "../domain/filter-describe.js";
12
+ import { updatedAtOrder } from "../domain/order.js";
12
13
  import type { StoreName } from "../domain/primitives.js";
13
14
  import type { StoreRef } from "../domain/store.js";
14
15
  import type { StoreLineage } from "../domain/derivation.js";
@@ -123,9 +124,10 @@ const resolveSyncInfo = (
123
124
  if (candidates.length === 0) {
124
125
  return { syncStatus: "unknown" as const };
125
126
  }
126
- const latest = candidates.sort(
127
- (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
128
- )[0];
127
+ const checkpointOrder = updatedAtOrder<(typeof candidates)[number]>();
128
+ const latest = candidates.reduce((acc, candidate) =>
129
+ Order.max(checkpointOrder)(acc, candidate)
130
+ );
129
131
  if (!latest) {
130
132
  return { syncStatus: "unknown" as const };
131
133
  }