@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.
- package/README.md +269 -31
- package/index.ts +18 -3
- package/package.json +1 -1
- package/src/cli/app.ts +4 -2
- package/src/cli/compact-output.ts +52 -0
- package/src/cli/config.ts +46 -4
- package/src/cli/doc/table-renderers.ts +29 -0
- package/src/cli/doc/thread.ts +2 -4
- package/src/cli/exit-codes.ts +2 -0
- package/src/cli/feed.ts +78 -61
- package/src/cli/filter-dsl.ts +146 -11
- package/src/cli/filter-errors.ts +13 -11
- package/src/cli/filter-help.ts +7 -0
- package/src/cli/filter-input.ts +3 -2
- package/src/cli/filter.ts +83 -5
- package/src/cli/graph.ts +297 -169
- package/src/cli/input.ts +45 -0
- package/src/cli/interval.ts +4 -33
- package/src/cli/jetstream.ts +2 -0
- package/src/cli/layers.ts +10 -0
- package/src/cli/logging.ts +8 -0
- package/src/cli/option-schemas.ts +22 -0
- package/src/cli/output-format.ts +11 -0
- package/src/cli/output-render.ts +14 -0
- package/src/cli/pagination.ts +17 -0
- package/src/cli/parse-errors.ts +30 -0
- package/src/cli/parse.ts +1 -47
- package/src/cli/pipe-input.ts +18 -0
- package/src/cli/pipe.ts +154 -0
- package/src/cli/post.ts +88 -66
- package/src/cli/query-fields.ts +13 -3
- package/src/cli/query.ts +354 -100
- package/src/cli/search.ts +93 -136
- package/src/cli/shared-options.ts +11 -63
- package/src/cli/shared.ts +1 -20
- package/src/cli/store-errors.ts +28 -21
- package/src/cli/store-tree.ts +6 -4
- package/src/cli/store.ts +41 -2
- package/src/cli/stream-merge.ts +105 -0
- package/src/cli/sync-factory.ts +24 -7
- package/src/cli/sync.ts +46 -67
- package/src/cli/thread-options.ts +25 -0
- package/src/cli/time.ts +171 -0
- package/src/cli/view-thread.ts +29 -32
- package/src/cli/watch.ts +55 -26
- package/src/domain/errors.ts +6 -1
- package/src/domain/format.ts +21 -0
- package/src/domain/order.ts +24 -0
- package/src/domain/primitives.ts +20 -3
- package/src/graph/relationships.ts +129 -0
- package/src/services/bsky-client.ts +11 -5
- package/src/services/jetstream-sync.ts +4 -4
- package/src/services/lineage-store.ts +15 -1
- package/src/services/shared.ts +48 -1
- package/src/services/store-cleaner.ts +5 -2
- package/src/services/store-commit.ts +60 -0
- package/src/services/store-manager.ts +69 -2
- package/src/services/store-renamer.ts +288 -0
- package/src/services/store-stats.ts +7 -5
- package/src/services/sync-engine.ts +149 -89
- package/src/services/sync-reporter.ts +3 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/services/shared.ts
CHANGED
|
@@ -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
|
|
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<
|
|
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({
|
|
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
|
}
|