@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.
- package/README.md +59 -0
- package/index.ts +146 -0
- package/package.json +56 -0
- package/src/cli/app.ts +75 -0
- package/src/cli/config-command.ts +140 -0
- package/src/cli/config.ts +91 -0
- package/src/cli/derive.ts +205 -0
- package/src/cli/doc/annotation.ts +36 -0
- package/src/cli/doc/filter.ts +69 -0
- package/src/cli/doc/index.ts +9 -0
- package/src/cli/doc/post.ts +155 -0
- package/src/cli/doc/primitives.ts +25 -0
- package/src/cli/doc/render.ts +18 -0
- package/src/cli/doc/table.ts +114 -0
- package/src/cli/doc/thread.ts +46 -0
- package/src/cli/doc/tree.ts +126 -0
- package/src/cli/errors.ts +59 -0
- package/src/cli/exit-codes.ts +52 -0
- package/src/cli/feed.ts +177 -0
- package/src/cli/filter-dsl.ts +1411 -0
- package/src/cli/filter-errors.ts +208 -0
- package/src/cli/filter-help.ts +70 -0
- package/src/cli/filter-input.ts +54 -0
- package/src/cli/filter.ts +435 -0
- package/src/cli/graph.ts +472 -0
- package/src/cli/help.ts +14 -0
- package/src/cli/interval.ts +35 -0
- package/src/cli/jetstream.ts +173 -0
- package/src/cli/layers.ts +180 -0
- package/src/cli/logging.ts +136 -0
- package/src/cli/output-format.ts +26 -0
- package/src/cli/output.ts +82 -0
- package/src/cli/parse.ts +80 -0
- package/src/cli/post.ts +193 -0
- package/src/cli/preferences.ts +11 -0
- package/src/cli/query-fields.ts +247 -0
- package/src/cli/query.ts +415 -0
- package/src/cli/range.ts +44 -0
- package/src/cli/search.ts +465 -0
- package/src/cli/shared-options.ts +169 -0
- package/src/cli/shared.ts +20 -0
- package/src/cli/store-errors.ts +80 -0
- package/src/cli/store-tree.ts +392 -0
- package/src/cli/store.ts +395 -0
- package/src/cli/sync-factory.ts +107 -0
- package/src/cli/sync.ts +366 -0
- package/src/cli/view-thread.ts +196 -0
- package/src/cli/view.ts +47 -0
- package/src/cli/watch.ts +344 -0
- package/src/db/migrations/store-catalog/001_init.ts +14 -0
- package/src/db/migrations/store-index/001_init.ts +34 -0
- package/src/db/migrations/store-index/002_event_log.ts +24 -0
- package/src/db/migrations/store-index/003_fts_and_derived.ts +52 -0
- package/src/db/migrations/store-index/004_query_indexes.ts +9 -0
- package/src/db/migrations/store-index/005_post_lang.ts +15 -0
- package/src/db/migrations/store-index/006_has_embed.ts +10 -0
- package/src/db/migrations/store-index/007_event_seq_and_checkpoints.ts +68 -0
- package/src/domain/bsky.ts +467 -0
- package/src/domain/config.ts +11 -0
- package/src/domain/credentials.ts +6 -0
- package/src/domain/defaults.ts +8 -0
- package/src/domain/derivation.ts +55 -0
- package/src/domain/errors.ts +71 -0
- package/src/domain/events.ts +55 -0
- package/src/domain/extract.ts +64 -0
- package/src/domain/filter-describe.ts +551 -0
- package/src/domain/filter-explain.ts +9 -0
- package/src/domain/filter.ts +797 -0
- package/src/domain/format.ts +91 -0
- package/src/domain/index.ts +13 -0
- package/src/domain/indexes.ts +17 -0
- package/src/domain/policies.ts +16 -0
- package/src/domain/post.ts +88 -0
- package/src/domain/primitives.ts +50 -0
- package/src/domain/raw.ts +140 -0
- package/src/domain/store.ts +103 -0
- package/src/domain/sync.ts +211 -0
- package/src/domain/text-width.ts +56 -0
- package/src/services/app-config.ts +278 -0
- package/src/services/bsky-client.ts +2113 -0
- package/src/services/credential-store.ts +408 -0
- package/src/services/derivation-engine.ts +502 -0
- package/src/services/derivation-settings.ts +61 -0
- package/src/services/derivation-validator.ts +68 -0
- package/src/services/filter-compiler.ts +269 -0
- package/src/services/filter-library.ts +371 -0
- package/src/services/filter-runtime.ts +821 -0
- package/src/services/filter-settings.ts +30 -0
- package/src/services/identity-resolver.ts +563 -0
- package/src/services/jetstream-sync.ts +636 -0
- package/src/services/lineage-store.ts +89 -0
- package/src/services/link-validator.ts +244 -0
- package/src/services/output-manager.ts +274 -0
- package/src/services/post-parser.ts +62 -0
- package/src/services/profile-resolver.ts +223 -0
- package/src/services/resource-monitor.ts +106 -0
- package/src/services/shared.ts +69 -0
- package/src/services/store-cleaner.ts +43 -0
- package/src/services/store-commit.ts +168 -0
- package/src/services/store-db.ts +248 -0
- package/src/services/store-event-log.ts +285 -0
- package/src/services/store-index-sql.ts +289 -0
- package/src/services/store-index.ts +1152 -0
- package/src/services/store-keys.ts +4 -0
- package/src/services/store-manager.ts +358 -0
- package/src/services/store-stats.ts +522 -0
- package/src/services/store-writer.ts +200 -0
- package/src/services/sync-checkpoint-store.ts +169 -0
- package/src/services/sync-engine.ts +547 -0
- package/src/services/sync-reporter.ts +16 -0
- package/src/services/sync-settings.ts +72 -0
- package/src/services/trending-topics.ts +226 -0
- package/src/services/view-checkpoint-store.ts +238 -0
- 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
|
+
}
|