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