@mepuka/skygent 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +59 -0
  2. package/index.ts +146 -0
  3. package/package.json +56 -0
  4. package/src/cli/app.ts +75 -0
  5. package/src/cli/config-command.ts +140 -0
  6. package/src/cli/config.ts +91 -0
  7. package/src/cli/derive.ts +205 -0
  8. package/src/cli/doc/annotation.ts +36 -0
  9. package/src/cli/doc/filter.ts +69 -0
  10. package/src/cli/doc/index.ts +9 -0
  11. package/src/cli/doc/post.ts +155 -0
  12. package/src/cli/doc/primitives.ts +25 -0
  13. package/src/cli/doc/render.ts +18 -0
  14. package/src/cli/doc/table.ts +114 -0
  15. package/src/cli/doc/thread.ts +46 -0
  16. package/src/cli/doc/tree.ts +126 -0
  17. package/src/cli/errors.ts +59 -0
  18. package/src/cli/exit-codes.ts +52 -0
  19. package/src/cli/feed.ts +177 -0
  20. package/src/cli/filter-dsl.ts +1411 -0
  21. package/src/cli/filter-errors.ts +208 -0
  22. package/src/cli/filter-help.ts +70 -0
  23. package/src/cli/filter-input.ts +54 -0
  24. package/src/cli/filter.ts +435 -0
  25. package/src/cli/graph.ts +472 -0
  26. package/src/cli/help.ts +14 -0
  27. package/src/cli/interval.ts +35 -0
  28. package/src/cli/jetstream.ts +173 -0
  29. package/src/cli/layers.ts +180 -0
  30. package/src/cli/logging.ts +136 -0
  31. package/src/cli/output-format.ts +26 -0
  32. package/src/cli/output.ts +82 -0
  33. package/src/cli/parse.ts +80 -0
  34. package/src/cli/post.ts +193 -0
  35. package/src/cli/preferences.ts +11 -0
  36. package/src/cli/query-fields.ts +247 -0
  37. package/src/cli/query.ts +415 -0
  38. package/src/cli/range.ts +44 -0
  39. package/src/cli/search.ts +465 -0
  40. package/src/cli/shared-options.ts +169 -0
  41. package/src/cli/shared.ts +20 -0
  42. package/src/cli/store-errors.ts +80 -0
  43. package/src/cli/store-tree.ts +392 -0
  44. package/src/cli/store.ts +395 -0
  45. package/src/cli/sync-factory.ts +107 -0
  46. package/src/cli/sync.ts +366 -0
  47. package/src/cli/view-thread.ts +196 -0
  48. package/src/cli/view.ts +47 -0
  49. package/src/cli/watch.ts +344 -0
  50. package/src/db/migrations/store-catalog/001_init.ts +14 -0
  51. package/src/db/migrations/store-index/001_init.ts +34 -0
  52. package/src/db/migrations/store-index/002_event_log.ts +24 -0
  53. package/src/db/migrations/store-index/003_fts_and_derived.ts +52 -0
  54. package/src/db/migrations/store-index/004_query_indexes.ts +9 -0
  55. package/src/db/migrations/store-index/005_post_lang.ts +15 -0
  56. package/src/db/migrations/store-index/006_has_embed.ts +10 -0
  57. package/src/db/migrations/store-index/007_event_seq_and_checkpoints.ts +68 -0
  58. package/src/domain/bsky.ts +467 -0
  59. package/src/domain/config.ts +11 -0
  60. package/src/domain/credentials.ts +6 -0
  61. package/src/domain/defaults.ts +8 -0
  62. package/src/domain/derivation.ts +55 -0
  63. package/src/domain/errors.ts +71 -0
  64. package/src/domain/events.ts +55 -0
  65. package/src/domain/extract.ts +64 -0
  66. package/src/domain/filter-describe.ts +551 -0
  67. package/src/domain/filter-explain.ts +9 -0
  68. package/src/domain/filter.ts +797 -0
  69. package/src/domain/format.ts +91 -0
  70. package/src/domain/index.ts +13 -0
  71. package/src/domain/indexes.ts +17 -0
  72. package/src/domain/policies.ts +16 -0
  73. package/src/domain/post.ts +88 -0
  74. package/src/domain/primitives.ts +50 -0
  75. package/src/domain/raw.ts +140 -0
  76. package/src/domain/store.ts +103 -0
  77. package/src/domain/sync.ts +211 -0
  78. package/src/domain/text-width.ts +56 -0
  79. package/src/services/app-config.ts +278 -0
  80. package/src/services/bsky-client.ts +2113 -0
  81. package/src/services/credential-store.ts +408 -0
  82. package/src/services/derivation-engine.ts +502 -0
  83. package/src/services/derivation-settings.ts +61 -0
  84. package/src/services/derivation-validator.ts +68 -0
  85. package/src/services/filter-compiler.ts +269 -0
  86. package/src/services/filter-library.ts +371 -0
  87. package/src/services/filter-runtime.ts +821 -0
  88. package/src/services/filter-settings.ts +30 -0
  89. package/src/services/identity-resolver.ts +563 -0
  90. package/src/services/jetstream-sync.ts +636 -0
  91. package/src/services/lineage-store.ts +89 -0
  92. package/src/services/link-validator.ts +244 -0
  93. package/src/services/output-manager.ts +274 -0
  94. package/src/services/post-parser.ts +62 -0
  95. package/src/services/profile-resolver.ts +223 -0
  96. package/src/services/resource-monitor.ts +106 -0
  97. package/src/services/shared.ts +69 -0
  98. package/src/services/store-cleaner.ts +43 -0
  99. package/src/services/store-commit.ts +168 -0
  100. package/src/services/store-db.ts +248 -0
  101. package/src/services/store-event-log.ts +285 -0
  102. package/src/services/store-index-sql.ts +289 -0
  103. package/src/services/store-index.ts +1152 -0
  104. package/src/services/store-keys.ts +4 -0
  105. package/src/services/store-manager.ts +358 -0
  106. package/src/services/store-stats.ts +522 -0
  107. package/src/services/store-writer.ts +200 -0
  108. package/src/services/sync-checkpoint-store.ts +169 -0
  109. package/src/services/sync-engine.ts +547 -0
  110. package/src/services/sync-reporter.ts +16 -0
  111. package/src/services/sync-settings.ts +72 -0
  112. package/src/services/trending-topics.ts +226 -0
  113. package/src/services/view-checkpoint-store.ts +238 -0
  114. package/src/typeclass/chunk.ts +84 -0
@@ -0,0 +1,522 @@
1
+ /**
2
+ * Store Statistics Service
3
+ *
4
+ * Calculates and reports statistics for data stores.
5
+ * Provides detailed metrics including post counts, author counts, date ranges,
6
+ * top hashtags/authors, and store status (source vs derived, stale vs current).
7
+ *
8
+ * This service is used by the `skygent store stats` command to display
9
+ * information about stores to users. It aggregates data from multiple sources:
10
+ * - SQLite database for post/author counts and top items
11
+ * - Store lineage for derivation information
12
+ * - Store index for counts
13
+ * - Checkpoint store for sync status
14
+ *
15
+ * @module services/store-stats
16
+ * @example
17
+ * ```ts
18
+ * import { Effect } from "effect";
19
+ * import { StoreStats } from "./services/store-stats.js";
20
+ * import { StoreRef } from "./domain/store.js";
21
+ *
22
+ * const program = Effect.gen(function* () {
23
+ * const stats = yield* StoreStats;
24
+ *
25
+ * // Get detailed stats for a specific store
26
+ * const storeStats = yield* stats.stats(StoreRef.make({
27
+ * name: "my-store",
28
+ * root: "my-store"
29
+ * }));
30
+ * console.log(`Posts: ${storeStats.posts}, Authors: ${storeStats.authors}`);
31
+ *
32
+ * // Get summary of all stores
33
+ * const summary = yield* stats.summary();
34
+ * console.log(`Total stores: ${summary.total}, Total posts: ${summary.totalPosts}`);
35
+ * });
36
+ * ```
37
+ */
38
+
39
+ import { FileSystem, Path } from "@effect/platform";
40
+ import { directorySize } from "./shared.js";
41
+ import { Context, Effect, Layer, Option } from "effect";
42
+ import { AppConfigService } from "./app-config.js";
43
+ import { StoreManager } from "./store-manager.js";
44
+ import { StoreIndex } from "./store-index.js";
45
+ import { StoreDb } from "./store-db.js";
46
+ import { LineageStore } from "./lineage-store.js";
47
+ import { DerivationValidator } from "./derivation-validator.js";
48
+ import { StoreEventLog } from "./store-event-log.js";
49
+ import { SyncCheckpointStore } from "./sync-checkpoint-store.js";
50
+ import { DataSource } from "../domain/sync.js";
51
+ import { StoreName, type StorePath } from "../domain/primitives.js";
52
+ import { StoreRef } from "../domain/store.js";
53
+ import type { StoreLineage } from "../domain/derivation.js";
54
+ import { StoreIoError, type StoreIndexError } from "../domain/errors.js";
55
+
56
+ /**
57
+ * Detailed statistics for a single store.
58
+ * Includes post counts, author counts, date ranges, top items, and status.
59
+ */
60
+ type StoreStatsResult = {
61
+ /** Store name */
62
+ readonly store: string;
63
+ /** Total number of posts in the store */
64
+ readonly posts: number;
65
+ /** Number of unique authors in the store */
66
+ readonly authors: number;
67
+ /** Date range of posts (first and last post dates) */
68
+ readonly dateRange?: { readonly first: string; readonly last: string };
69
+ /** Top hashtags in the store (up to TOP_LIMIT) */
70
+ readonly hashtags: ReadonlyArray<string>;
71
+ /** Top authors by post count (up to TOP_LIMIT) */
72
+ readonly topAuthors: ReadonlyArray<string>;
73
+ /** Whether this is a derived store (vs source store) */
74
+ readonly derived: boolean;
75
+ /** Store status: source (not derived), ready (derived and current), stale (derived and outdated), or unknown */
76
+ readonly status: "source" | "ready" | "stale" | "unknown";
77
+ /** Sync status for source stores: current, stale, unknown, or empty */
78
+ readonly syncStatus?: "current" | "stale" | "unknown" | "empty";
79
+ /** Total size of the store directory in bytes */
80
+ readonly sizeBytes: number;
81
+ };
82
+
83
+ /**
84
+ * Summary entry for a single store in the overall summary.
85
+ */
86
+ type StoreSummaryEntry = {
87
+ /** Store name */
88
+ readonly name: string;
89
+ /** Number of posts in the store */
90
+ readonly posts: number;
91
+ /** Store status */
92
+ readonly status: "source" | "ready" | "stale" | "unknown";
93
+ /** Source store name (for single-source derived stores) */
94
+ readonly source?: string;
95
+ /** Source store names (for multi-source derived stores) */
96
+ readonly sources?: ReadonlyArray<string>;
97
+ };
98
+
99
+ /**
100
+ * Summary statistics across all stores.
101
+ */
102
+ type StoreSummaryResult = {
103
+ /** Total number of stores */
104
+ readonly total: number;
105
+ /** Number of source stores */
106
+ readonly sources: number;
107
+ /** Number of derived stores */
108
+ readonly derived: number;
109
+ /** Total posts across all stores */
110
+ readonly totalPosts: number;
111
+ /** Total size of all stores in bytes */
112
+ readonly totalSizeBytes: number;
113
+ /** Human-readable total size (e.g., "1.5MB") */
114
+ readonly totalSize: string;
115
+ /** Individual store summaries */
116
+ readonly stores: ReadonlyArray<StoreSummaryEntry>;
117
+ };
118
+
119
+ /** Number of top items (hashtags/authors) to include in stats */
120
+ const TOP_LIMIT = 5;
121
+
122
+ /**
123
+ * Parses a count value from database results.
124
+ * Handles null/undefined by returning 0.
125
+ * @param value - The value to parse as a count
126
+ * @returns The parsed number
127
+ */
128
+ const parseCount = (value: unknown) =>
129
+ typeof value === "number" ? value : Number(value ?? 0);
130
+
131
+ /**
132
+ * Converts an unknown error to a StoreIoError.
133
+ * Preserves existing StoreIoError instances.
134
+ * @param path - Store path for the error context
135
+ * @returns A function that converts causes to StoreIoError
136
+ */
137
+ const toStoreIoError = (path: StorePath) => (cause: unknown) => {
138
+ if (typeof cause === "object" && cause !== null) {
139
+ const tagged = cause as { _tag?: string };
140
+ if (tagged._tag === "StoreIoError") {
141
+ return tagged as StoreIoError;
142
+ }
143
+ }
144
+ return StoreIoError.make({ path, cause });
145
+ };
146
+
147
+ /**
148
+ * Formats a byte count as a human-readable string.
149
+ * Uses appropriate units (B, KB, MB, GB, TB) and rounds to reasonable precision.
150
+ * @param bytes - Number of bytes to format
151
+ * @returns Human-readable size string (e.g., "1.5MB", "1024B")
152
+ * @example
153
+ * ```ts
154
+ * formatBytes(1024) // "1KB"
155
+ * formatBytes(1536000) // "1.5MB"
156
+ * ```
157
+ */
158
+ const formatBytes = (bytes: number) => {
159
+ if (bytes < 1024) return `${bytes}B`;
160
+ const units = ["KB", "MB", "GB", "TB"];
161
+ let value = bytes;
162
+ let unit = "B";
163
+ for (const next of units) {
164
+ value /= 1024;
165
+ unit = next;
166
+ if (value < 1024) break;
167
+ }
168
+ const rounded = value >= 100 ? Math.round(value) : Math.round(value * 10) / 10;
169
+ return `${rounded}${unit}`;
170
+ };
171
+
172
+ /**
173
+ * Checks if a store is derived based on its lineage.
174
+ * @param lineage - Optional store lineage information
175
+ * @returns True if the store is derived from other stores
176
+ */
177
+ const isDerived = (lineage: Option.Option<StoreLineage>) =>
178
+ Option.isSome(lineage) && lineage.value.isDerived;
179
+
180
+ /**
181
+ * Extracts source store names from lineage information.
182
+ * @param lineage - Optional store lineage information
183
+ * @returns Array of source store names, empty if no lineage or not derived
184
+ */
185
+ const lineageSources = (lineage: Option.Option<StoreLineage>) =>
186
+ Option.match(lineage, {
187
+ onNone: () => [] as ReadonlyArray<string>,
188
+ onSome: (value) => value.sources.map((source) => source.storeName)
189
+ });
190
+
191
+ type DerivationValidatorService = Context.Tag.Service<typeof DerivationValidator>;
192
+ type StoreEventLogService = Context.Tag.Service<typeof StoreEventLog>;
193
+ type SyncCheckpointStoreService = Context.Tag.Service<typeof SyncCheckpointStore>;
194
+
195
+ /**
196
+ * Resolves the derivation status of a store.
197
+ * - "source": Not a derived store
198
+ * - "ready": Derived and all sources are current
199
+ * - "stale": Derived and at least one source is stale
200
+ * - "unknown": Derived but no source information available
201
+ *
202
+ * @param store - Name of the store to check
203
+ * @param lineage - Optional lineage information for the store
204
+ * @param validator - DerivationValidator service for checking staleness
205
+ * @returns An Effect that resolves to the derivation status
206
+ */
207
+ const resolveDerivedStatus = (
208
+ store: StoreName,
209
+ lineage: Option.Option<StoreLineage>,
210
+ validator: DerivationValidatorService
211
+ ) =>
212
+ Effect.gen(function* () {
213
+ if (Option.isNone(lineage) || !lineage.value.isDerived) {
214
+ return "source" as const;
215
+ }
216
+ const sources = lineage.value.sources;
217
+ if (sources.length === 0) {
218
+ return "unknown" as const;
219
+ }
220
+ const staleFlags = yield* Effect.forEach(
221
+ sources,
222
+ (source) => validator.isStale(store, source.storeName),
223
+ { discard: false }
224
+ );
225
+ return staleFlags.some(Boolean) ? ("stale" as const) : ("ready" as const);
226
+ });
227
+
228
+ /**
229
+ * Resolves the sync status of a source store.
230
+ * - "current": Last event ID matches the latest checkpoint
231
+ * - "stale": Checkpoints exist but don't match current event ID
232
+ * - "unknown": No checkpoints available
233
+ * - "empty": No events in the event log
234
+ *
235
+ * @param storeRef - Reference to the store to check
236
+ * @param eventLog - StoreEventLog service for accessing event log
237
+ * @param checkpoints - SyncCheckpointStore service for accessing checkpoints
238
+ * @returns An Effect that resolves to the sync status
239
+ */
240
+ const resolveSyncStatus = (
241
+ storeRef: StoreRef,
242
+ eventLog: StoreEventLogService,
243
+ checkpoints: SyncCheckpointStoreService
244
+ ) =>
245
+ Effect.gen(function* () {
246
+ const lastEventSeqOption = yield* eventLog.getLastEventSeq(storeRef);
247
+ if (Option.isNone(lastEventSeqOption)) {
248
+ return "empty" as const;
249
+ }
250
+ const [timelineCheckpoint, notificationsCheckpoint] = yield* Effect.all([
251
+ checkpoints.load(storeRef, DataSource.timeline()),
252
+ checkpoints.load(storeRef, DataSource.notifications())
253
+ ]);
254
+ const candidates = [timelineCheckpoint, notificationsCheckpoint]
255
+ .filter(Option.isSome)
256
+ .map((option) => option.value);
257
+ if (candidates.length === 0) {
258
+ return "unknown" as const;
259
+ }
260
+ const latest = candidates.sort(
261
+ (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
262
+ )[0];
263
+ if (!latest || !latest.lastEventSeq) {
264
+ return "stale" as const;
265
+ }
266
+ return latest.lastEventSeq === lastEventSeqOption.value
267
+ ? ("current" as const)
268
+ : ("stale" as const);
269
+ });
270
+
271
+ /**
272
+ * Effect Context Tag for the Store Statistics service.
273
+ * Calculates detailed statistics and summaries for data stores.
274
+ *
275
+ * This service provides:
276
+ * - Per-store statistics (posts, authors, date ranges, top hashtags/authors, status)
277
+ * - Overall summary across all stores
278
+ * - Derivation status tracking (stale vs current derived stores)
279
+ * - Sync status for source stores
280
+ * - Store size calculations
281
+ *
282
+ * @example
283
+ * ```ts
284
+ * // Use in an Effect program
285
+ * const program = Effect.gen(function* () {
286
+ * const storeStats = yield* StoreStats;
287
+ *
288
+ * // Get stats for a specific store
289
+ * const stats = yield* storeStats.stats(StoreRef.make({
290
+ * name: "tech-posts",
291
+ * root: "tech-posts"
292
+ * }));
293
+ * console.log(`Store has ${stats.posts} posts from ${stats.authors} authors`);
294
+ * console.log(`Status: ${stats.status}, Sync: ${stats.syncStatus}`);
295
+ * console.log(`Top hashtags: ${stats.hashtags.join(", ")}`);
296
+ *
297
+ * // Get summary of all stores
298
+ * const summary = yield* storeStats.summary();
299
+ * console.log(`Total: ${summary.total} stores, ${summary.totalPosts} posts`);
300
+ * });
301
+ *
302
+ * // Provide the layer
303
+ * const runnable = program.pipe(Effect.provide(StoreStats.layer));
304
+ * ```
305
+ */
306
+ export class StoreStats extends Context.Tag("@skygent/StoreStats")<
307
+ StoreStats,
308
+ {
309
+ /**
310
+ * Calculates detailed statistics for a single store.
311
+ * Includes post counts, author counts, date ranges, top hashtags/authors,
312
+ * derivation status, sync status, and store size.
313
+ *
314
+ * @param store - Reference to the store to analyze
315
+ * @returns An Effect that resolves to detailed store statistics
316
+ * @throws {StoreIndexError} If accessing store index fails
317
+ * @throws {StoreIoError} If database operations fail
318
+ * @example
319
+ * ```ts
320
+ * const stats = yield* storeStats.stats(StoreRef.make({ name: "my-store", root: "my-store" }));
321
+ * console.log(`${stats.posts} posts, ${stats.authors} authors`);
322
+ * console.log(`Derived: ${stats.derived}, Status: ${stats.status}`);
323
+ * ```
324
+ */
325
+ readonly stats: (
326
+ store: StoreRef
327
+ ) => Effect.Effect<StoreStatsResult, StoreIndexError | StoreIoError>;
328
+
329
+ /**
330
+ * Calculates a summary across all stores.
331
+ * Provides aggregate statistics including total counts, sizes, and per-store summaries.
332
+ *
333
+ * @returns An Effect that resolves to a summary of all stores
334
+ * @throws {StoreIndexError} If accessing store index fails
335
+ * @throws {StoreIoError} If database operations fail
336
+ * @example
337
+ * ```ts
338
+ * const summary = yield* storeStats.summary();
339
+ * console.log(`${summary.total} stores (${summary.sources} source, ${summary.derived} derived)`);
340
+ * console.log(`${summary.totalPosts} total posts, ${summary.totalSize} storage used`);
341
+ * ```
342
+ */
343
+ readonly summary: () => Effect.Effect<StoreSummaryResult, StoreIndexError | StoreIoError>;
344
+ }
345
+ >() {
346
+ /**
347
+ * Production layer that provides the StoreStats service.
348
+ * Requires multiple services to be provided: StoreIndex, StoreManager,
349
+ * LineageStore, DerivationValidator, StoreEventLog, SyncCheckpointStore,
350
+ * StoreDb, AppConfigService, FileSystem, and Path.
351
+ *
352
+ * The implementation queries SQLite databases for statistics and aggregates
353
+ * information from various sources to provide comprehensive store metrics.
354
+ */
355
+ static readonly layer = Layer.effect(
356
+ StoreStats,
357
+ Effect.gen(function* () {
358
+ const index = yield* StoreIndex;
359
+ const manager = yield* StoreManager;
360
+ const lineageStore = yield* LineageStore;
361
+ const validator = yield* DerivationValidator;
362
+ const eventLog = yield* StoreEventLog;
363
+ const checkpoints = yield* SyncCheckpointStore;
364
+ const storeDb = yield* StoreDb;
365
+ const config = yield* AppConfigService;
366
+ const fs = yield* FileSystem.FileSystem;
367
+ const path = yield* Path.Path;
368
+
369
+ /**
370
+ * Calculates the total size of a store directory in bytes.
371
+ * Returns 0 if size calculation fails.
372
+ * @param store - Store reference to measure
373
+ * @returns An Effect that resolves to the size in bytes
374
+ */
375
+ const storeSize = (store: StoreRef) => {
376
+ const storePath = path.join(config.storeRoot, store.root);
377
+ return directorySize(fs, path, storePath).pipe(Effect.orElseSucceed(() => 0));
378
+ };
379
+
380
+ /**
381
+ * Computes detailed statistics for a store.
382
+ * Aggregates data from lineage, checkpoints, event log, and SQLite database.
383
+ */
384
+ const computeStats = Effect.fn("StoreStats.stats")((store: StoreRef) =>
385
+ Effect.gen(function* () {
386
+ const lineage = yield* lineageStore.get(store.name);
387
+ const status = yield* resolveDerivedStatus(store.name, lineage, validator);
388
+ const syncStatus =
389
+ status === "source"
390
+ ? yield* resolveSyncStatus(store, eventLog, checkpoints)
391
+ : undefined;
392
+
393
+ // Ensure store is indexed before querying
394
+ yield* index.count(store);
395
+
396
+ // Query SQLite for post statistics, author counts, date ranges, and top items
397
+ const aggregate = yield* storeDb
398
+ .withClient(store, (client) =>
399
+ Effect.gen(function* () {
400
+ // Get basic counts and date range
401
+ const rows = yield* client`SELECT
402
+ COUNT(*) as posts,
403
+ COUNT(DISTINCT author) as authors,
404
+ MIN(created_date) as first,
405
+ MAX(created_date) as last
406
+ FROM posts`;
407
+ const row = rows[0] ?? {};
408
+
409
+ // Get top authors by post count
410
+ const topAuthorRows = yield* client`SELECT author, COUNT(*) as count
411
+ FROM posts
412
+ WHERE author IS NOT NULL
413
+ GROUP BY author
414
+ ORDER BY count DESC
415
+ LIMIT ${TOP_LIMIT}`;
416
+
417
+ // Get top hashtags by usage
418
+ const topHashtagRows = yield* client`SELECT tag, COUNT(*) as count
419
+ FROM post_hashtag
420
+ GROUP BY tag
421
+ ORDER BY count DESC
422
+ LIMIT ${TOP_LIMIT}`;
423
+
424
+ return {
425
+ posts: parseCount(row.posts),
426
+ authors: parseCount(row.authors),
427
+ first: typeof row.first === "string" ? row.first : undefined,
428
+ last: typeof row.last === "string" ? row.last : undefined,
429
+ topAuthors: topAuthorRows
430
+ .map((entry) => entry.author)
431
+ .filter((value): value is string => typeof value === "string"),
432
+ hashtags: topHashtagRows
433
+ .map((entry) => entry.tag)
434
+ .filter((value): value is string => typeof value === "string")
435
+ };
436
+ })
437
+ )
438
+ .pipe(Effect.mapError(toStoreIoError(store.root)));
439
+
440
+ const sizeBytes = yield* storeSize(store);
441
+ const dateRange =
442
+ aggregate.first && aggregate.last
443
+ ? { first: aggregate.first, last: aggregate.last }
444
+ : undefined;
445
+
446
+ return {
447
+ store: store.name,
448
+ posts: aggregate.posts,
449
+ authors: aggregate.authors,
450
+ hashtags: aggregate.hashtags,
451
+ topAuthors: aggregate.topAuthors,
452
+ derived: isDerived(lineage),
453
+ status,
454
+ sizeBytes,
455
+ ...(dateRange ? { dateRange } : {}),
456
+ ...(syncStatus ? { syncStatus } : {})
457
+ };
458
+ })
459
+ );
460
+
461
+ /**
462
+ * Computes a summary across all stores.
463
+ * Aggregates per-store information into overall statistics.
464
+ */
465
+ const summary = Effect.fn("StoreStats.summary")(() =>
466
+ Effect.gen(function* () {
467
+ // Get list of all stores
468
+ const stores = yield* manager.listStores();
469
+
470
+ // Compute summary for each store in parallel
471
+ const summaries = yield* Effect.forEach(
472
+ stores,
473
+ (storeMeta) =>
474
+ Effect.gen(function* () {
475
+ const storeRef = StoreRef.make({
476
+ name: storeMeta.name,
477
+ root: storeMeta.root
478
+ });
479
+ const lineage = yield* lineageStore.get(storeRef.name);
480
+ const status = yield* resolveDerivedStatus(storeRef.name, lineage, validator);
481
+ const sources = lineageSources(lineage);
482
+ const posts = yield* index.count(storeRef);
483
+
484
+ // Build summary entry with conditional source info
485
+ const entry: StoreSummaryEntry = {
486
+ name: storeRef.name,
487
+ posts,
488
+ status,
489
+ ...(sources.length === 1
490
+ ? { source: sources[0]! }
491
+ : sources.length > 1
492
+ ? { sources }
493
+ : {})
494
+ };
495
+ return { entry, derived: isDerived(lineage), sizeBytes: yield* storeSize(storeRef) };
496
+ }),
497
+ { discard: false }
498
+ );
499
+
500
+ // Aggregate totals
501
+ const total = summaries.length;
502
+ const derivedCount = summaries.filter((entry) => entry.derived).length;
503
+ const sourcesCount = total - derivedCount;
504
+ const totalPosts = summaries.reduce((sum, entry) => sum + entry.entry.posts, 0);
505
+ const totalSizeBytes = summaries.reduce((sum, entry) => sum + entry.sizeBytes, 0);
506
+
507
+ return {
508
+ total,
509
+ sources: sourcesCount,
510
+ derived: derivedCount,
511
+ totalPosts,
512
+ totalSizeBytes,
513
+ totalSize: formatBytes(totalSizeBytes),
514
+ stores: summaries.map((entry) => entry.entry)
515
+ };
516
+ })
517
+ );
518
+
519
+ return StoreStats.of({ stats: computeStats, summary });
520
+ })
521
+ );
522
+ }