@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.
- 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/config.ts +20 -3
- 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 +35 -55
- package/src/cli/filter-dsl.ts +146 -11
- package/src/cli/filter-errors.ts +9 -3
- package/src/cli/filter-help.ts +7 -0
- package/src/cli/filter-input.ts +3 -2
- package/src/cli/filter.ts +84 -4
- package/src/cli/graph.ts +193 -156
- package/src/cli/input.ts +45 -0
- package/src/cli/layers.ts +10 -0
- package/src/cli/logging.ts +8 -0
- package/src/cli/output-render.ts +14 -0
- package/src/cli/pagination.ts +18 -0
- package/src/cli/parse-errors.ts +18 -0
- package/src/cli/pipe.ts +157 -0
- package/src/cli/post.ts +43 -66
- package/src/cli/query.ts +349 -74
- package/src/cli/search.ts +92 -118
- package/src/cli/shared.ts +0 -19
- package/src/cli/store-errors.ts +24 -13
- package/src/cli/store-tree.ts +6 -4
- package/src/cli/store.ts +35 -2
- package/src/cli/stream-merge.ts +105 -0
- package/src/cli/sync-factory.ts +28 -3
- package/src/cli/sync.ts +16 -18
- package/src/cli/thread-options.ts +33 -0
- package/src/cli/time.ts +171 -0
- package/src/cli/view-thread.ts +12 -18
- package/src/cli/watch.ts +61 -19
- package/src/domain/errors.ts +6 -1
- package/src/domain/format.ts +21 -0
- package/src/domain/order.ts +24 -0
- package/src/graph/relationships.ts +129 -0
- package/src/services/jetstream-sync.ts +4 -4
- package/src/services/lineage-store.ts +15 -1
- package/src/services/store-commit.ts +60 -0
- package/src/services/store-manager.ts +69 -2
- package/src/services/store-renamer.ts +286 -0
- package/src/services/store-stats.ts +7 -5
- package/src/services/sync-engine.ts +136 -85
- 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);
|
|
@@ -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
|
}
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { FileSystem, Path } from "@effect/platform";
|
|
2
|
+
import { Chunk, Context, Effect, Layer, Option, Ref, Schema } from "effect";
|
|
3
|
+
import { StoreAlreadyExists, StoreIoError, StoreNotFound } from "../domain/errors.js";
|
|
4
|
+
import { StoreLineage, StoreSource } from "../domain/derivation.js";
|
|
5
|
+
import { StoreName, StorePath } from "../domain/primitives.js";
|
|
6
|
+
import { StoreRef } from "../domain/store.js";
|
|
7
|
+
import { AppConfigService } from "./app-config.js";
|
|
8
|
+
import { LineageStore } from "./lineage-store.js";
|
|
9
|
+
import { StoreDb } from "./store-db.js";
|
|
10
|
+
import { StoreManager } from "./store-manager.js";
|
|
11
|
+
|
|
12
|
+
type StoreRenameResult = {
|
|
13
|
+
readonly from: StoreName;
|
|
14
|
+
readonly to: StoreName;
|
|
15
|
+
readonly moved: boolean;
|
|
16
|
+
readonly lineagesUpdated: number;
|
|
17
|
+
readonly checkpointsUpdated: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type RenameState = {
|
|
21
|
+
readonly completed: boolean;
|
|
22
|
+
readonly dirRenamed: boolean;
|
|
23
|
+
readonly catalogUpdated: boolean;
|
|
24
|
+
readonly checkpointsUpdated: boolean;
|
|
25
|
+
readonly lineagesUpdated: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const storeRootKey = (name: StoreName) =>
|
|
29
|
+
Schema.decodeUnknownSync(StorePath)(`stores/${name}`);
|
|
30
|
+
|
|
31
|
+
const toStoreIoError = (path: StorePath) => (cause: unknown) =>
|
|
32
|
+
StoreIoError.make({ path, cause });
|
|
33
|
+
|
|
34
|
+
const toStoreRef = (metadata: { readonly name: StoreName; readonly root: StorePath }) =>
|
|
35
|
+
StoreRef.make({ name: metadata.name, root: metadata.root });
|
|
36
|
+
|
|
37
|
+
const renameDirectory = (
|
|
38
|
+
fs: FileSystem.FileSystem,
|
|
39
|
+
path: Path.Path,
|
|
40
|
+
fromPath: string,
|
|
41
|
+
toPath: string
|
|
42
|
+
) =>
|
|
43
|
+
Effect.gen(function* () {
|
|
44
|
+
if (fromPath === toPath) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const normalizedFrom = fromPath.toLowerCase();
|
|
48
|
+
const normalizedTo = toPath.toLowerCase();
|
|
49
|
+
if (normalizedFrom === normalizedTo) {
|
|
50
|
+
const tempName = `.rename-${Date.now()}`;
|
|
51
|
+
const tempPath = path.join(path.dirname(toPath), tempName);
|
|
52
|
+
yield* fs.rename(fromPath, tempPath);
|
|
53
|
+
yield* fs.rename(tempPath, toPath);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
yield* fs.rename(fromPath, toPath);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const renameLineage = (
|
|
60
|
+
lineage: StoreLineage,
|
|
61
|
+
from: StoreName,
|
|
62
|
+
to: StoreName
|
|
63
|
+
) => {
|
|
64
|
+
const nextStoreName = lineage.storeName === from ? to : lineage.storeName;
|
|
65
|
+
let changed = nextStoreName !== lineage.storeName;
|
|
66
|
+
const nextSources = lineage.sources.map((source) => {
|
|
67
|
+
if (source.storeName !== from) {
|
|
68
|
+
return source;
|
|
69
|
+
}
|
|
70
|
+
changed = true;
|
|
71
|
+
return StoreSource.make({ ...source, storeName: to });
|
|
72
|
+
});
|
|
73
|
+
if (!changed) {
|
|
74
|
+
return Option.none<StoreLineage>();
|
|
75
|
+
}
|
|
76
|
+
return Option.some(
|
|
77
|
+
StoreLineage.make({
|
|
78
|
+
...lineage,
|
|
79
|
+
storeName: nextStoreName,
|
|
80
|
+
sources: nextSources
|
|
81
|
+
})
|
|
82
|
+
);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export class StoreRenamer extends Context.Tag("@skygent/StoreRenamer")<
|
|
86
|
+
StoreRenamer,
|
|
87
|
+
{
|
|
88
|
+
readonly rename: (
|
|
89
|
+
from: StoreName,
|
|
90
|
+
to: StoreName
|
|
91
|
+
) => Effect.Effect<StoreRenameResult, StoreNotFound | StoreAlreadyExists | StoreIoError>;
|
|
92
|
+
}
|
|
93
|
+
>() {
|
|
94
|
+
static readonly layer = Layer.effect(
|
|
95
|
+
StoreRenamer,
|
|
96
|
+
Effect.gen(function* () {
|
|
97
|
+
const manager = yield* StoreManager;
|
|
98
|
+
const storeDb = yield* StoreDb;
|
|
99
|
+
const lineageStore = yield* LineageStore;
|
|
100
|
+
const appConfig = yield* AppConfigService;
|
|
101
|
+
const fs = yield* FileSystem.FileSystem;
|
|
102
|
+
const path = yield* Path.Path;
|
|
103
|
+
|
|
104
|
+
const updateLineages = (
|
|
105
|
+
from: StoreName,
|
|
106
|
+
to: StoreName,
|
|
107
|
+
storeNames: ReadonlyArray<StoreName>
|
|
108
|
+
) =>
|
|
109
|
+
Effect.forEach(
|
|
110
|
+
storeNames,
|
|
111
|
+
(storeName) =>
|
|
112
|
+
lineageStore.get(storeName).pipe(
|
|
113
|
+
Effect.flatMap(
|
|
114
|
+
Option.match({
|
|
115
|
+
onNone: () => Effect.succeed(0),
|
|
116
|
+
onSome: (lineage) =>
|
|
117
|
+
Option.match(renameLineage(lineage, from, to), {
|
|
118
|
+
onNone: () => Effect.succeed(0),
|
|
119
|
+
onSome: (updated) =>
|
|
120
|
+
Effect.gen(function* () {
|
|
121
|
+
yield* lineageStore.save(updated);
|
|
122
|
+
if (lineage.storeName === from) {
|
|
123
|
+
yield* lineageStore.remove(from);
|
|
124
|
+
}
|
|
125
|
+
return 1;
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
)
|
|
130
|
+
),
|
|
131
|
+
{ discard: false }
|
|
132
|
+
).pipe(Effect.map((updates) => updates.reduce((sum, value) => sum + value, 0)));
|
|
133
|
+
|
|
134
|
+
const updateDerivationCheckpoints = (
|
|
135
|
+
from: StoreName,
|
|
136
|
+
to: StoreName,
|
|
137
|
+
stores: ReadonlyArray<StoreRef>
|
|
138
|
+
) =>
|
|
139
|
+
Effect.forEach(
|
|
140
|
+
stores,
|
|
141
|
+
(store) =>
|
|
142
|
+
storeDb.withClient(store, (client) =>
|
|
143
|
+
client`UPDATE derivation_checkpoints
|
|
144
|
+
SET view_name = CASE WHEN view_name = ${from} THEN ${to} ELSE view_name END,
|
|
145
|
+
source_store = CASE WHEN source_store = ${from} THEN ${to} ELSE source_store END,
|
|
146
|
+
target_store = CASE WHEN target_store = ${from} THEN ${to} ELSE target_store END
|
|
147
|
+
WHERE view_name = ${from}
|
|
148
|
+
OR source_store = ${from}
|
|
149
|
+
OR target_store = ${from}`.pipe(Effect.as(1))
|
|
150
|
+
).pipe(Effect.mapError(toStoreIoError(store.root))),
|
|
151
|
+
{ discard: false }
|
|
152
|
+
).pipe(Effect.map((updates) => updates.reduce((sum, value) => sum + value, 0)));
|
|
153
|
+
|
|
154
|
+
const rename = Effect.fn("StoreRenamer.rename")(
|
|
155
|
+
(from: StoreName, to: StoreName) =>
|
|
156
|
+
Effect.gen(function* () {
|
|
157
|
+
const storeOption = yield* manager.getStore(from);
|
|
158
|
+
if (Option.isNone(storeOption)) {
|
|
159
|
+
return yield* StoreNotFound.make({ name: from });
|
|
160
|
+
}
|
|
161
|
+
const targetOption = yield* manager.getStore(to);
|
|
162
|
+
if (Option.isSome(targetOption)) {
|
|
163
|
+
return yield* StoreAlreadyExists.make({ name: to });
|
|
164
|
+
}
|
|
165
|
+
const store = storeOption.value;
|
|
166
|
+
const newRoot = storeRootKey(to);
|
|
167
|
+
const fromPath = path.join(appConfig.storeRoot, store.root);
|
|
168
|
+
const toPath = path.join(appConfig.storeRoot, newRoot);
|
|
169
|
+
const fromExists = yield* fs
|
|
170
|
+
.exists(fromPath)
|
|
171
|
+
.pipe(Effect.mapError(toStoreIoError(store.root)));
|
|
172
|
+
const toExists = yield* fs
|
|
173
|
+
.exists(toPath)
|
|
174
|
+
.pipe(Effect.mapError(toStoreIoError(newRoot)));
|
|
175
|
+
if (toExists) {
|
|
176
|
+
return yield* StoreAlreadyExists.make({ name: to });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const storesBefore = yield* manager.listStores();
|
|
180
|
+
const storeNamesBefore = Chunk.toReadonlyArray(storesBefore).map(
|
|
181
|
+
(entry) => entry.name
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const state = yield* Ref.make<RenameState>({
|
|
185
|
+
completed: false,
|
|
186
|
+
dirRenamed: false,
|
|
187
|
+
catalogUpdated: false,
|
|
188
|
+
checkpointsUpdated: false,
|
|
189
|
+
lineagesUpdated: false
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const rollback = (status: RenameState) =>
|
|
193
|
+
status.completed
|
|
194
|
+
? Effect.void
|
|
195
|
+
: Effect.gen(function* () {
|
|
196
|
+
if (status.lineagesUpdated) {
|
|
197
|
+
const stores = yield* manager.listStores();
|
|
198
|
+
const storeNames = Chunk.toReadonlyArray(stores).map(
|
|
199
|
+
(entry) => entry.name
|
|
200
|
+
);
|
|
201
|
+
yield* updateLineages(to, from, storeNames).pipe(
|
|
202
|
+
Effect.catchAll(() => Effect.void)
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (status.checkpointsUpdated) {
|
|
206
|
+
const stores = yield* manager.listStores();
|
|
207
|
+
const storeRefs = Chunk.toReadonlyArray(stores).map(toStoreRef);
|
|
208
|
+
yield* updateDerivationCheckpoints(to, from, storeRefs).pipe(
|
|
209
|
+
Effect.catchAll(() => Effect.void)
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
if (status.dirRenamed) {
|
|
213
|
+
yield* storeDb.removeClient(to);
|
|
214
|
+
yield* renameDirectory(fs, path, toPath, fromPath).pipe(
|
|
215
|
+
Effect.catchAll(() => Effect.void)
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (status.catalogUpdated) {
|
|
219
|
+
yield* manager.renameStore(to, from).pipe(
|
|
220
|
+
Effect.catchAll(() => Effect.void)
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const program = Effect.gen(function* () {
|
|
226
|
+
yield* storeDb.removeClient(from);
|
|
227
|
+
|
|
228
|
+
if (fromExists) {
|
|
229
|
+
yield* renameDirectory(fs, path, fromPath, toPath).pipe(
|
|
230
|
+
Effect.mapError(toStoreIoError(store.root))
|
|
231
|
+
);
|
|
232
|
+
yield* Ref.update(state, (current) => ({ ...current, dirRenamed: true }));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
yield* manager.renameStore(from, to);
|
|
236
|
+
yield* Ref.update(state, (current) => ({
|
|
237
|
+
...current,
|
|
238
|
+
catalogUpdated: true
|
|
239
|
+
}));
|
|
240
|
+
|
|
241
|
+
const storesAfter = yield* manager.listStores();
|
|
242
|
+
const storeRefsAfter = Chunk.toReadonlyArray(storesAfter).map(toStoreRef);
|
|
243
|
+
|
|
244
|
+
const checkpointsUpdated = yield* updateDerivationCheckpoints(
|
|
245
|
+
from,
|
|
246
|
+
to,
|
|
247
|
+
storeRefsAfter
|
|
248
|
+
);
|
|
249
|
+
yield* Ref.update(state, (current) => ({
|
|
250
|
+
...current,
|
|
251
|
+
checkpointsUpdated: true
|
|
252
|
+
}));
|
|
253
|
+
|
|
254
|
+
const lineagesUpdated = yield* updateLineages(from, to, storeNamesBefore);
|
|
255
|
+
yield* Ref.update(state, (current) => ({
|
|
256
|
+
...current,
|
|
257
|
+
lineagesUpdated: true
|
|
258
|
+
}));
|
|
259
|
+
|
|
260
|
+
yield* Ref.update(state, (current) => ({ ...current, completed: true }));
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
from,
|
|
264
|
+
to,
|
|
265
|
+
moved: fromExists,
|
|
266
|
+
lineagesUpdated,
|
|
267
|
+
checkpointsUpdated
|
|
268
|
+
} satisfies StoreRenameResult;
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
return yield* program.pipe(
|
|
272
|
+
Effect.ensuring(
|
|
273
|
+
Ref.get(state).pipe(
|
|
274
|
+
Effect.flatMap(rollback),
|
|
275
|
+
Effect.catchAll(() => Effect.void),
|
|
276
|
+
Effect.uninterruptible
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
);
|
|
280
|
+
})
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
return StoreRenamer.of({ rename });
|
|
284
|
+
})
|
|
285
|
+
);
|
|
286
|
+
}
|