@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,129 @@
1
+ import { Graph, Option } from "effect";
2
+ import type { RelationshipView } from "../domain/bsky.js";
3
+
4
+ export type RelationshipNode = {
5
+ readonly did?: string;
6
+ readonly handle?: string;
7
+ readonly displayName?: string;
8
+ readonly inputs: ReadonlyArray<string>;
9
+ readonly notFound?: boolean;
10
+ };
11
+
12
+ export type RelationshipEdge = {
13
+ readonly following: boolean;
14
+ readonly followedBy: boolean;
15
+ readonly mutual: boolean;
16
+ readonly blocking: boolean;
17
+ readonly blockedBy: boolean;
18
+ readonly blockingByList: boolean;
19
+ readonly blockedByList: boolean;
20
+ readonly notFound: boolean;
21
+ };
22
+
23
+ export type RelationshipEntry = {
24
+ readonly actor: RelationshipNode;
25
+ readonly other: RelationshipNode;
26
+ readonly relationship: RelationshipEdge;
27
+ };
28
+
29
+ export type RelationshipGraphResult = {
30
+ readonly graph: Graph.DirectedGraph<RelationshipNode, RelationshipEdge>;
31
+ readonly actorIndex: Graph.NodeIndex;
32
+ readonly nodeIndexByKey: Map<string, Graph.NodeIndex>;
33
+ };
34
+
35
+ const edgeFromRelationship = (relationship: RelationshipView): RelationshipEdge => {
36
+ if (!("did" in relationship)) {
37
+ return {
38
+ following: false,
39
+ followedBy: false,
40
+ mutual: false,
41
+ blocking: false,
42
+ blockedBy: false,
43
+ blockingByList: false,
44
+ blockedByList: false,
45
+ notFound: true
46
+ };
47
+ }
48
+ const following = typeof relationship.following === "string";
49
+ const followedBy = typeof relationship.followedBy === "string";
50
+ const blocking = typeof relationship.blocking === "string";
51
+ const blockedBy = typeof relationship.blockedBy === "string";
52
+ const blockingByList = typeof relationship.blockingByList === "string";
53
+ const blockedByList = typeof relationship.blockedByList === "string";
54
+ return {
55
+ following,
56
+ followedBy,
57
+ mutual: following && followedBy,
58
+ blocking,
59
+ blockedBy,
60
+ blockingByList,
61
+ blockedByList,
62
+ notFound: false
63
+ };
64
+ };
65
+
66
+ const nodeFromKey = (key: string): RelationshipNode => ({
67
+ ...(key.startsWith("did:") ? { did: key } : {}),
68
+ inputs: [key],
69
+ notFound: true
70
+ });
71
+
72
+ export const buildRelationshipGraph = (
73
+ actorKey: string,
74
+ nodesByKey: Map<string, RelationshipNode>,
75
+ relationships: ReadonlyArray<RelationshipView>
76
+ ): RelationshipGraphResult => {
77
+ const base = Graph.directed<RelationshipNode, RelationshipEdge>();
78
+ const nodeIndexByKey = new Map<string, Graph.NodeIndex>();
79
+
80
+ const graph = Graph.mutate(base, (mutable) => {
81
+ const actorNode = nodesByKey.get(actorKey) ?? nodeFromKey(actorKey);
82
+ const actorIndex = Graph.addNode(mutable, actorNode);
83
+ nodeIndexByKey.set(actorKey, actorIndex);
84
+
85
+ const ensureNode = (key: string, node: RelationshipNode) => {
86
+ const existing = nodeIndexByKey.get(key);
87
+ if (existing !== undefined) {
88
+ return existing;
89
+ }
90
+ const index = Graph.addNode(mutable, node);
91
+ nodeIndexByKey.set(key, index);
92
+ return index;
93
+ };
94
+
95
+ for (const relationship of relationships) {
96
+ const key = "did" in relationship ? relationship.did : relationship.actor;
97
+ const node = nodesByKey.get(key) ?? nodeFromKey(key);
98
+ const otherIndex = ensureNode(key, node);
99
+ Graph.addEdge(mutable, actorIndex, otherIndex, edgeFromRelationship(relationship));
100
+ }
101
+ });
102
+
103
+ const actorIndex = nodeIndexByKey.get(actorKey);
104
+ if (actorIndex === undefined) {
105
+ throw new Error(`Missing actor node for ${actorKey}`);
106
+ }
107
+
108
+ return { graph, actorIndex, nodeIndexByKey };
109
+ };
110
+
111
+ export const relationshipEntries = (
112
+ graph: Graph.DirectedGraph<RelationshipNode, RelationshipEdge>
113
+ ): ReadonlyArray<RelationshipEntry> => {
114
+ const edges = Array.from(Graph.values(Graph.edges(graph)));
115
+ const entries: RelationshipEntry[] = [];
116
+ for (const edge of edges) {
117
+ const actor = Option.getOrUndefined(Graph.getNode(graph, edge.source));
118
+ const other = Option.getOrUndefined(Graph.getNode(graph, edge.target));
119
+ if (!actor || !other) {
120
+ continue;
121
+ }
122
+ entries.push({ actor, other, relationship: edge.data });
123
+ }
124
+ return entries;
125
+ };
126
+
127
+ export const relationshipMermaid = (
128
+ graph: Graph.DirectedGraph<RelationshipNode, RelationshipEdge>
129
+ ): string => Graph.toMermaid(graph);
@@ -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")));
@@ -583,10 +583,10 @@ export class JetstreamSyncEngine extends Context.Tag("@skygent/JetstreamSyncEngi
583
583
  Stream.interruptWhen(
584
584
  Effect.sleep(config.duration).pipe(
585
585
  Effect.zipRight(
586
- Effect.logWarning(
587
- "Jetstream sync exceeded duration; shutting down.",
588
- { durationMs: Duration.toMillis(config.duration) }
589
- )
586
+ reporter.warn("Jetstream sync exceeded duration; shutting down.", {
587
+ durationMs: Duration.toMillis(config.duration),
588
+ store: config.store.name
589
+ })
590
590
  ),
591
591
  Effect.zipRight(safeShutdown)
592
592
  )
@@ -63,6 +63,14 @@ export class LineageStore extends Context.Tag("@skygent/LineageStore")<
63
63
  * @returns Effect resolving to void, or StoreIoError on failure
64
64
  */
65
65
  readonly save: (lineage: StoreLineage) => Effect.Effect<void, StoreIoError>;
66
+
67
+ /**
68
+ * Removes lineage information for a store.
69
+ *
70
+ * @param storeName - The name of the store to remove lineage for
71
+ * @returns Effect resolving to void, or StoreIoError on failure
72
+ */
73
+ readonly remove: (storeName: StoreName) => Effect.Effect<void, StoreIoError>;
66
74
  }
67
75
  >() {
68
76
  static readonly layer = Layer.effect(
@@ -83,7 +91,13 @@ export class LineageStore extends Context.Tag("@skygent/LineageStore")<
83
91
  .pipe(Effect.mapError(toStoreIoError(lineage.storeName)))
84
92
  );
85
93
 
86
- return LineageStore.of({ get, save });
94
+ const remove = Effect.fn("LineageStore.remove")((storeName: StoreName) =>
95
+ lineages
96
+ .remove(lineageKey(storeName))
97
+ .pipe(Effect.mapError(toStoreIoError(storeName)))
98
+ );
99
+
100
+ return LineageStore.of({ get, save, remove });
87
101
  })
88
102
  );
89
103
  }
@@ -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);
@@ -67,6 +67,13 @@ export class StoreCommitter extends Context.Tag("@skygent/StoreCommitter")<
67
67
  store: StoreRef,
68
68
  event: PostUpsert
69
69
  ) => Effect.Effect<EventLogEntry, StoreIoError>;
70
+ /**
71
+ * Append multiple upsert events in a single transaction.
72
+ */
73
+ readonly appendUpserts: (
74
+ store: StoreRef,
75
+ events: ReadonlyArray<PostUpsert>
76
+ ) => Effect.Effect<ReadonlyArray<EventLogEntry>, StoreIoError>;
70
77
 
71
78
  /**
72
79
  * Append an upsert event only if the post doesn't already exist.
@@ -84,6 +91,13 @@ export class StoreCommitter extends Context.Tag("@skygent/StoreCommitter")<
84
91
  store: StoreRef,
85
92
  event: PostUpsert
86
93
  ) => Effect.Effect<Option.Option<EventLogEntry>, StoreIoError>;
94
+ /**
95
+ * Append multiple upsert events if missing in a single transaction.
96
+ */
97
+ readonly appendUpsertsIfMissing: (
98
+ store: StoreRef,
99
+ events: ReadonlyArray<PostUpsert>
100
+ ) => Effect.Effect<ReadonlyArray<Option.Option<EventLogEntry>>, StoreIoError>;
87
101
 
88
102
  /**
89
103
  * Append a delete event to the store, removing the post.
@@ -125,6 +139,26 @@ export class StoreCommitter extends Context.Tag("@skygent/StoreCommitter")<
125
139
  .pipe(Effect.mapError(toStoreIoError(store.root)))
126
140
  );
127
141
 
142
+ const appendUpserts = Effect.fn("StoreCommitter.appendUpserts")(
143
+ (store: StoreRef, events: ReadonlyArray<PostUpsert>) => {
144
+ if (events.length === 0) {
145
+ return Effect.succeed([] as ReadonlyArray<EventLogEntry>);
146
+ }
147
+ return storeDb
148
+ .withClient(store, (client) =>
149
+ client.withTransaction(
150
+ Effect.forEach(events, (event) =>
151
+ Effect.gen(function* () {
152
+ yield* upsertPost(client, event.post);
153
+ return yield* writer.appendWithClient(client, event);
154
+ })
155
+ )
156
+ )
157
+ )
158
+ .pipe(Effect.mapError(toStoreIoError(store.root)));
159
+ }
160
+ );
161
+
128
162
  const appendUpsertIfMissing = Effect.fn(
129
163
  "StoreCommitter.appendUpsertIfMissing"
130
164
  )((store: StoreRef, event: PostUpsert) =>
@@ -144,6 +178,30 @@ export class StoreCommitter extends Context.Tag("@skygent/StoreCommitter")<
144
178
  .pipe(Effect.mapError(toStoreIoError(store.root)))
145
179
  );
146
180
 
181
+ const appendUpsertsIfMissing = Effect.fn(
182
+ "StoreCommitter.appendUpsertsIfMissing"
183
+ )((store: StoreRef, events: ReadonlyArray<PostUpsert>) => {
184
+ if (events.length === 0) {
185
+ return Effect.succeed([] as ReadonlyArray<Option.Option<EventLogEntry>>);
186
+ }
187
+ return storeDb
188
+ .withClient(store, (client) =>
189
+ client.withTransaction(
190
+ Effect.forEach(events, (event) =>
191
+ Effect.gen(function* () {
192
+ const inserted = yield* insertPostIfMissing(client, event.post);
193
+ if (!inserted) {
194
+ return Option.none<EventLogEntry>();
195
+ }
196
+ const entry = yield* writer.appendWithClient(client, event);
197
+ return Option.some(entry);
198
+ })
199
+ )
200
+ )
201
+ )
202
+ .pipe(Effect.mapError(toStoreIoError(store.root)));
203
+ });
204
+
147
205
  const appendDelete = Effect.fn("StoreCommitter.appendDelete")(
148
206
  (store: StoreRef, event: PostDelete) =>
149
207
  storeDb
@@ -160,7 +218,9 @@ export class StoreCommitter extends Context.Tag("@skygent/StoreCommitter")<
160
218
 
161
219
  return StoreCommitter.of({
162
220
  appendUpsert,
221
+ appendUpserts,
163
222
  appendUpsertIfMissing,
223
+ appendUpsertsIfMissing,
164
224
  appendDelete
165
225
  });
166
226
  })
@@ -63,7 +63,7 @@ import * as MigratorFileSystem from "@effect/sql/Migrator/FileSystem";
63
63
  import * as SqlClient from "@effect/sql/SqlClient";
64
64
  import * as SqlSchema from "@effect/sql/SqlSchema";
65
65
  import { SqliteClient } from "@effect/sql-sqlite-bun";
66
- import { StoreIoError } from "../domain/errors.js";
66
+ import { StoreAlreadyExists, StoreIoError, StoreNotFound } from "../domain/errors.js";
67
67
  import { StoreConfig, StoreMetadata, StoreRef } from "../domain/store.js";
68
68
  import { StoreName, StorePath } from "../domain/primitives.js";
69
69
  import { AppConfigService } from "./app-config.js";
@@ -188,6 +188,23 @@ export class StoreManager extends Context.Tag("@skygent/StoreManager")<
188
188
  readonly deleteStore: (
189
189
  name: StoreName
190
190
  ) => Effect.Effect<void, StoreIoError>;
191
+
192
+ /**
193
+ * Renames a store in the catalog.
194
+ *
195
+ * This updates the store name and root path while preserving creation time.
196
+ *
197
+ * @param from - Existing store name
198
+ * @param to - New store name
199
+ * @returns Effect resolving to the updated StoreRef
200
+ * @throws {StoreNotFound} If the source store does not exist
201
+ * @throws {StoreAlreadyExists} If the target store already exists
202
+ * @throws {StoreIoError} When database operations fail
203
+ */
204
+ readonly renameStore: (
205
+ from: StoreName,
206
+ to: StoreName
207
+ ) => Effect.Effect<StoreRef, StoreNotFound | StoreAlreadyExists | StoreIoError>;
191
208
  }
192
209
  >() {
193
210
  /**
@@ -267,6 +284,21 @@ export class StoreManager extends Context.Tag("@skygent/StoreManager")<
267
284
  client`DELETE FROM stores WHERE name = ${name}`
268
285
  });
269
286
 
287
+ const renameStoreSql = SqlSchema.void({
288
+ Request: Schema.Struct({
289
+ oldName: StoreName,
290
+ newName: StoreName,
291
+ root: StorePath,
292
+ updatedAt: Schema.String
293
+ }),
294
+ execute: ({ oldName, newName, root, updatedAt }) =>
295
+ client`UPDATE stores
296
+ SET name = ${newName},
297
+ root = ${root},
298
+ updated_at = ${updatedAt}
299
+ WHERE name = ${oldName}`
300
+ });
301
+
270
302
  const createStore = Effect.fn("StoreManager.createStore")(
271
303
  (name: StoreName, config: StoreConfig) =>
272
304
  decodeStorePath(storeRootKey(name)).pipe(
@@ -337,6 +369,34 @@ export class StoreManager extends Context.Tag("@skygent/StoreManager")<
337
369
  );
338
370
  });
339
371
 
372
+ const renameStore = Effect.fn("StoreManager.renameStore")(
373
+ (from: StoreName, to: StoreName) =>
374
+ Effect.gen(function* () {
375
+ const newRoot = yield* decodeStorePath(storeRootKey(to));
376
+ const existing = yield* findStore(from).pipe(
377
+ Effect.mapError(toStoreIoError(manifestPath))
378
+ );
379
+ if (existing.length === 0) {
380
+ return yield* StoreNotFound.make({ name: from });
381
+ }
382
+ const conflict = yield* findStore(to).pipe(
383
+ Effect.mapError(toStoreIoError(manifestPath))
384
+ );
385
+ if (conflict.length > 0) {
386
+ return yield* StoreAlreadyExists.make({ name: to });
387
+ }
388
+ const nowMillis = yield* Clock.currentTimeMillis;
389
+ const now = new Date(nowMillis).toISOString();
390
+ yield* renameStoreSql({
391
+ oldName: from,
392
+ newName: to,
393
+ root: newRoot,
394
+ updatedAt: now
395
+ }).pipe(Effect.mapError(toStoreIoError(manifestPath)));
396
+ return StoreRef.make({ name: to, root: newRoot });
397
+ })
398
+ );
399
+
340
400
  const listStores = Effect.fn("StoreManager.listStores")(() =>
341
401
  Effect.gen(function* () {
342
402
  const rows = yield* listStoresSql(undefined);
@@ -352,7 +412,14 @@ export class StoreManager extends Context.Tag("@skygent/StoreManager")<
352
412
  }).pipe(Effect.mapError(toStoreIoError(manifestPath)))
353
413
  );
354
414
 
355
- return StoreManager.of({ createStore, getStore, listStores, getConfig, deleteStore });
415
+ return StoreManager.of({
416
+ createStore,
417
+ getStore,
418
+ listStores,
419
+ getConfig,
420
+ deleteStore,
421
+ renameStore
422
+ });
356
423
  })
357
424
  ).pipe(Layer.provide(Reactivity.layer));
358
425
  }