@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
package/src/cli/search.ts CHANGED
@@ -1,12 +1,11 @@
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
9
  import { StoreName } from "../domain/primitives.js";
11
10
  import { storeOptions } from "./store.js";
12
11
  import { withExamples } from "./help.js";
@@ -14,20 +13,21 @@ import { CliInputError } from "./errors.js";
14
13
  import { decodeActor } from "./shared-options.js";
15
14
  import { formatSchemaError } from "./shared.js";
16
15
  import { writeJson, writeJsonStream, writeText } from "./output.js";
17
- import { jsonNdjsonTableFormats, resolveOutputFormat } from "./output-format.js";
16
+ import { jsonNdjsonTableFormats } from "./output-format.js";
17
+ import { emitWithFormat } from "./output-render.js";
18
+ import { cursorOption as baseCursorOption, limitOption as baseLimitOption, parsePagination } from "./pagination.js";
19
+ import { parseLimit } from "./shared-options.js";
18
20
 
19
21
  const queryArg = Args.text({ name: "query" }).pipe(
20
22
  Args.withDescription("Search query string")
21
23
  );
22
24
 
23
- const limitOption = Options.integer("limit").pipe(
24
- Options.withDescription("Maximum number of results"),
25
- Options.optional
25
+ const limitOption = baseLimitOption.pipe(
26
+ Options.withDescription("Maximum number of results")
26
27
  );
27
28
 
28
- const cursorOption = Options.text("cursor").pipe(
29
- Options.withDescription("Pagination cursor"),
30
- Options.optional
29
+ const cursorOption = baseCursorOption.pipe(
30
+ Options.withDescription("Pagination cursor")
31
31
  );
32
32
 
33
33
  const typeaheadOption = Options.boolean("typeahead").pipe(
@@ -50,9 +50,8 @@ const networkOption = Options.boolean("network").pipe(
50
50
  Options.withDescription("Search the Bluesky network instead of a local store")
51
51
  );
52
52
 
53
- const postCursorOption = Options.text("cursor").pipe(
54
- Options.withDescription("Pagination cursor (network) or offset (local)"),
55
- Options.optional
53
+ const postCursorOption = baseCursorOption.pipe(
54
+ Options.withDescription("Pagination cursor (network) or offset (local)")
56
55
  );
57
56
 
58
57
  const sortOption = Options.text("sort").pipe(
@@ -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 } = yield* 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 } = yield* 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,9 @@ 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 parsedLimit = yield* parseLimit(limit);
224
+ const limitValue = Option.getOrUndefined(parsedLimit);
247
225
  if (network && Option.isSome(store)) {
248
226
  return yield* CliInputError.make({
249
227
  message: "--store cannot be used with --network.",
@@ -281,12 +259,6 @@ const postsCommand = Command.make(
281
259
  });
282
260
  }
283
261
 
284
- const outputFormat = resolveOutputFormat(
285
- format,
286
- appConfig.outputFormat,
287
- jsonNdjsonTableFormats,
288
- "json"
289
- );
290
262
  const storeValue = Option.getOrElse(store, () => undefined);
291
263
 
292
264
  if (network) {
@@ -331,13 +303,13 @@ const postsCommand = Command.make(
331
303
  return String(decoded);
332
304
  })
333
305
  });
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 } : {}),
306
+ const parsedAuthor = yield* authorValue;
307
+ const parsedMentions = yield* mentionsValue;
308
+ const result = yield* client.searchPosts(queryValue, {
309
+ ...(limitValue !== undefined ? { limit: limitValue } : {}),
310
+ ...(Option.isSome(cursorValue) ? { cursor: cursorValue.value } : {}),
311
+ ...(sortValue ? { sort: sortValue } : {}),
312
+ ...(Option.isSome(since) ? { since: since.value } : {}),
341
313
  ...(Option.isSome(until) ? { until: until.value } : {}),
342
314
  ...(parsedMentions ? { mentions: parsedMentions } : {}),
343
315
  ...(parsedAuthor ? { author: parsedAuthor } : {}),
@@ -359,21 +331,23 @@ const postsCommand = Command.make(
359
331
  ),
360
332
  { concurrency: "unbounded" }
361
333
  );
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
- });
334
+ yield* emitWithFormat(
335
+ format,
336
+ appConfig.outputFormat,
337
+ jsonNdjsonTableFormats,
338
+ "json",
339
+ {
340
+ json: writeJson({
341
+ query: queryValue,
342
+ cursor: result.cursor,
343
+ hitsTotal: result.hitsTotal,
344
+ count: posts.length,
345
+ posts
346
+ }),
347
+ ndjson: writeJsonStream(Stream.fromIterable(posts)),
348
+ table: writeText(renderPostsTable(posts))
349
+ }
350
+ );
377
351
  return;
378
352
  }
379
353
 
@@ -419,28 +393,28 @@ const postsCommand = Command.make(
419
393
  });
420
394
  }
421
395
  const input = {
422
- query,
423
- ...(Option.isSome(limit) ? { limit: limit.value } : {}),
396
+ query: queryValue,
397
+ ...(limitValue !== undefined ? { limit: limitValue } : {}),
424
398
  ...(Option.isSome(cursorValue) ? { cursor: cursorValue.value } : {}),
425
399
  ...(localSort ? { sort: localSort } : {})
426
400
  };
427
401
  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
- });
402
+ yield* emitWithFormat(
403
+ format,
404
+ appConfig.outputFormat,
405
+ jsonNdjsonTableFormats,
406
+ "json",
407
+ {
408
+ json: writeJson({
409
+ query: queryValue,
410
+ cursor: result.cursor,
411
+ count: result.posts.length,
412
+ posts: result.posts
413
+ }),
414
+ ndjson: writeJsonStream(Stream.fromIterable(result.posts)),
415
+ table: writeText(renderPostsTable(result.posts))
416
+ }
417
+ );
444
418
  })
445
419
  ).pipe(
446
420
  Command.withDescription(
package/src/cli/shared.ts CHANGED
@@ -1,20 +1 @@
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,23 +1,31 @@
1
1
  import { ParseResult } from "effect";
2
- import { safeParseJson, issueDetails } from "./shared.js";
2
+ import { safeParseJson, issueDetails } 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,
@@ -36,7 +44,7 @@ export const formatStoreConfigParseError = (
36
44
  if (jsonParseIssue) {
37
45
  return formatAgentError({
38
46
  error: "StoreConfigJsonParseError",
39
- message: "Invalid JSON in --config-json.",
47
+ message: `Invalid JSON in --config-json. ${storeConfigDocHint}`,
40
48
  received: raw,
41
49
  details: [
42
50
  jsonParseIssue.message,
@@ -49,7 +57,7 @@ export const formatStoreConfigParseError = (
49
57
  if (issues.some((issue) => issue._tag === "Missing" && hasPath(issue, "filters"))) {
50
58
  return formatAgentError({
51
59
  error: "StoreConfigValidationError",
52
- message: "Store config requires a filters array.",
60
+ message: `Store config requires a filters array. ${storeConfigDocHint}`,
53
61
  received: receivedValue,
54
62
  expected: storeConfigExample,
55
63
  fix:
@@ -60,7 +68,7 @@ export const formatStoreConfigParseError = (
60
68
  if (issues.some((issue) => hasPath(issue, "filters"))) {
61
69
  return formatAgentError({
62
70
  error: "StoreConfigValidationError",
63
- message: "Store config filters must include name, expr, and output fields.",
71
+ message: `Store config filters must include name, expr, and output fields. ${storeConfigDocHint}`,
64
72
  received: receivedValue,
65
73
  expected: storeConfigExample,
66
74
  fix: "Each filter requires name, expr (filter JSON), and output (path/json/markdown).",
@@ -68,12 +76,15 @@ export const formatStoreConfigParseError = (
68
76
  });
69
77
  }
70
78
 
79
+ const details = issueDetails(issues);
80
+ const primaryIssue = details[0];
81
+ const issueHint = primaryIssue ? ` (${primaryIssue})` : "";
71
82
  return formatAgentError({
72
83
  error: "StoreConfigValidationError",
73
- message: "Store config failed validation.",
84
+ message: `Store config failed validation${issueHint}. ${storeConfigDocHint}`,
74
85
  received: receivedValue,
75
86
  expected: storeConfigExample,
76
- details: issueDetails(issues),
87
+ details,
77
88
  fix:
78
89
  "Check required fields (format, autoSync, filters). For ingestion filters, use --filter/--filter-json on sync/query."
79
90
  });
@@ -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
  }
package/src/cli/store.ts CHANGED
@@ -14,12 +14,13 @@ import { StoreCleaner } from "../services/store-cleaner.js";
14
14
  import { LineageStore } from "../services/lineage-store.js";
15
15
  import { CliInputError } from "./errors.js";
16
16
  import { OutputManager } from "../services/output-manager.js";
17
- import { formatStoreConfigParseError } from "./store-errors.js";
17
+ import { formatStoreConfigHelp, formatStoreConfigParseError } from "./store-errors.js";
18
18
  import { formatFilterExpr } from "../domain/filter-describe.js";
19
19
  import { CliPreferences } from "./preferences.js";
20
20
  import { StoreStats } from "../services/store-stats.js";
21
21
  import { withExamples } from "./help.js";
22
22
  import { resolveOutputFormat, treeTableJsonFormats } from "./output-format.js";
23
+ import { StoreRenamer } from "../services/store-renamer.js";
23
24
  import {
24
25
  buildStoreTreeData,
25
26
  renderStoreTree,
@@ -33,6 +34,14 @@ const storeNameArg = Args.text({ name: "name" }).pipe(
33
34
  Args.withSchema(StoreName),
34
35
  Args.withDescription("Store name")
35
36
  );
37
+ const storeRenameFromArg = Args.text({ name: "from" }).pipe(
38
+ Args.withSchema(StoreName),
39
+ Args.withDescription("Existing store name")
40
+ );
41
+ const storeRenameToArg = Args.text({ name: "to" }).pipe(
42
+ Args.withSchema(StoreName),
43
+ Args.withDescription("New store name")
44
+ );
36
45
  const storeNameOption = Options.text("store").pipe(
37
46
  Options.withSchema(StoreName),
38
47
  Options.withDescription("Store name")
@@ -254,6 +263,27 @@ export const storeDelete = Command.make(
254
263
  )
255
264
  );
256
265
 
266
+ export const storeRename = Command.make(
267
+ "rename",
268
+ { from: storeRenameFromArg, to: storeRenameToArg },
269
+ ({ from, to }) =>
270
+ Effect.gen(function* () {
271
+ if (from === to) {
272
+ return yield* CliInputError.make({
273
+ message: "Old and new store names must be different.",
274
+ cause: { from, to }
275
+ });
276
+ }
277
+ const renamer = yield* StoreRenamer;
278
+ const result = yield* renamer.rename(from, to);
279
+ yield* writeJson(result);
280
+ })
281
+ ).pipe(
282
+ Command.withDescription(
283
+ withExamples("Rename a store", ["skygent store rename old-name new-name"])
284
+ )
285
+ );
286
+
257
287
  export const storeMaterialize = Command.make(
258
288
  "materialize",
259
289
  { name: storeNameArg, filter: filterNameOption },
@@ -267,7 +297,9 @@ export const storeMaterialize = Command.make(
267
297
 
268
298
  if (config.filters.length === 0) {
269
299
  return yield* CliInputError.make({
270
- message: `Store "${name}" has no configured filters to materialize. Update the store config to add filters.`,
300
+ message: formatStoreConfigHelp(
301
+ `Store "${name}" has no configured filters to materialize. Add filters to the store config.`
302
+ ),
271
303
  cause: { store: name }
272
304
  });
273
305
  }
@@ -378,6 +410,7 @@ export const storeCommand = Command.make("store", {}).pipe(
378
410
  storeCreate,
379
411
  storeList,
380
412
  storeShow,
413
+ storeRename,
381
414
  storeDelete,
382
415
  storeMaterialize,
383
416
  storeStats,
@@ -0,0 +1,105 @@
1
+ import { Chunk, Effect, Option, Order, Stream } from "effect";
2
+
3
+ export const mergeOrderedStreams = <A, E, R>(
4
+ streams: ReadonlyArray<Stream.Stream<A, E, R>>,
5
+ order: Order.Order<A>
6
+ ): Stream.Stream<A, E, R> => {
7
+ if (streams.length === 0) {
8
+ return Stream.empty;
9
+ }
10
+
11
+ return Stream.unwrapScoped(
12
+ Effect.gen(function* () {
13
+ const pulls = yield* Effect.forEach(streams, (stream) => Stream.toPull(stream), {
14
+ discard: false
15
+ });
16
+
17
+ const buffers: Array<ReadonlyArray<A>> = pulls.map(() => []);
18
+ const indices: number[] = pulls.map(() => 0);
19
+ const heads: Array<A | undefined> = pulls.map(() => undefined);
20
+ let active = pulls.length;
21
+
22
+ const pullChunk = (index: number) =>
23
+ pulls[index]!.pipe(
24
+ Effect.map(Option.some),
25
+ Effect.catchAll((cause) =>
26
+ Option.match(cause, {
27
+ onNone: () => Effect.succeed(Option.none()),
28
+ onSome: (error) => Effect.fail(error)
29
+ })
30
+ )
31
+ );
32
+
33
+ const nextValue = (index: number): Effect.Effect<Option.Option<A>, E, R> =>
34
+ Effect.gen(function* () {
35
+ while (true) {
36
+ const buffer = buffers[index] ?? [];
37
+ const position = indices[index] ?? 0;
38
+ if (position < buffer.length) {
39
+ const value = buffer[position]!;
40
+ indices[index] = position + 1;
41
+ return Option.some(value);
42
+ }
43
+
44
+ const nextChunkOption = yield* pullChunk(index);
45
+ if (Option.isNone(nextChunkOption)) {
46
+ return Option.none<A>();
47
+ }
48
+ const nextChunk = Chunk.toReadonlyArray(nextChunkOption.value);
49
+ if (nextChunk.length === 0) {
50
+ continue;
51
+ }
52
+ buffers[index] = nextChunk;
53
+ indices[index] = 1;
54
+ return Option.some(nextChunk[0]!);
55
+ }
56
+ });
57
+
58
+ for (let index = 0; index < pulls.length; index += 1) {
59
+ const next = yield* nextValue(index);
60
+ if (Option.isNone(next)) {
61
+ active -= 1;
62
+ heads[index] = undefined;
63
+ } else {
64
+ heads[index] = next.value;
65
+ }
66
+ }
67
+
68
+ const pull: Effect.Effect<Chunk.Chunk<A>, Option.Option<E>, R> =
69
+ Effect.gen(function* () {
70
+ if (active === 0) {
71
+ return yield* Effect.fail(Option.none<E>());
72
+ }
73
+
74
+ let selectedIndex = -1;
75
+ let selectedValue: A | undefined;
76
+ for (let index = 0; index < heads.length; index += 1) {
77
+ const value = heads[index];
78
+ if (value === undefined) continue;
79
+ if (selectedIndex < 0 || order(value, selectedValue as A) < 0) {
80
+ selectedIndex = index;
81
+ selectedValue = value;
82
+ }
83
+ }
84
+
85
+ if (selectedIndex < 0 || selectedValue === undefined) {
86
+ return yield* Effect.fail(Option.none<E>());
87
+ }
88
+
89
+ const next = yield* nextValue(selectedIndex).pipe(
90
+ Effect.mapError(Option.some)
91
+ );
92
+ if (Option.isNone(next)) {
93
+ heads[selectedIndex] = undefined;
94
+ active -= 1;
95
+ } else {
96
+ heads[selectedIndex] = next.value;
97
+ }
98
+
99
+ return Chunk.of(selectedValue);
100
+ });
101
+
102
+ return Stream.fromPull(Effect.succeed(pull));
103
+ })
104
+ );
105
+ };