@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,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile Resolver Service
|
|
3
|
+
*
|
|
4
|
+
* Resolves Bluesky profiles to get handles from DIDs with batching support.
|
|
5
|
+
* Uses Effect's RequestResolver for automatic batching of concurrent requests,
|
|
6
|
+
* reducing API calls by grouping multiple profile lookups together.
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - Automatic batching of profile requests (up to 25 per batch)
|
|
10
|
+
* - Two-level caching: identity resolver cache + request-level cache
|
|
11
|
+
* - Fallback strategies: strict mode uses identity resolver, normal mode uses getProfiles API
|
|
12
|
+
*
|
|
13
|
+
* @module services/profile-resolver
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
Config,
|
|
18
|
+
Context,
|
|
19
|
+
Duration,
|
|
20
|
+
Effect,
|
|
21
|
+
Either,
|
|
22
|
+
Layer,
|
|
23
|
+
Option,
|
|
24
|
+
Request,
|
|
25
|
+
RequestResolver
|
|
26
|
+
} from "effect";
|
|
27
|
+
import { BskyError } from "../domain/errors.js";
|
|
28
|
+
import { Handle } from "../domain/primitives.js";
|
|
29
|
+
import { BskyClient } from "./bsky-client.js";
|
|
30
|
+
import { IdentityResolver } from "./identity-resolver.js";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Configuration for the request cache.
|
|
34
|
+
*/
|
|
35
|
+
type CacheConfig = {
|
|
36
|
+
readonly capacity: number;
|
|
37
|
+
readonly timeToLive: Duration.Duration;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Request class for batching profile handle lookups.
|
|
42
|
+
* Used with Effect's RequestResolver for automatic batching.
|
|
43
|
+
*/
|
|
44
|
+
export class ProfileHandleRequest extends Request.TaggedClass("ProfileHandle")<
|
|
45
|
+
Handle,
|
|
46
|
+
BskyError,
|
|
47
|
+
{
|
|
48
|
+
/** The DID to resolve to a handle */
|
|
49
|
+
readonly did: string;
|
|
50
|
+
}
|
|
51
|
+
> {}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Service for resolving profile handles from DIDs with batching support.
|
|
55
|
+
*
|
|
56
|
+
* This service batches concurrent handle lookups to minimize API calls.
|
|
57
|
+
* First checks the identity resolver cache, then fetches from the network
|
|
58
|
+
* in batches if needed.
|
|
59
|
+
*/
|
|
60
|
+
export class ProfileResolver extends Context.Tag("@skygent/ProfileResolver")<
|
|
61
|
+
ProfileResolver,
|
|
62
|
+
{
|
|
63
|
+
/**
|
|
64
|
+
* Resolves a handle for a DID, with automatic batching and caching.
|
|
65
|
+
*
|
|
66
|
+
* @param did - The DID to resolve to a handle
|
|
67
|
+
* @returns Effect resolving to Handle, or BskyError on resolution failure
|
|
68
|
+
*/
|
|
69
|
+
readonly handleForDid: (did: string) => Effect.Effect<Handle, BskyError>;
|
|
70
|
+
}
|
|
71
|
+
>() {
|
|
72
|
+
static readonly layer = Layer.effect(
|
|
73
|
+
ProfileResolver,
|
|
74
|
+
Effect.gen(function* () {
|
|
75
|
+
const client = yield* BskyClient;
|
|
76
|
+
const identities = yield* IdentityResolver;
|
|
77
|
+
|
|
78
|
+
const batchSizeRaw = yield* Config.integer(
|
|
79
|
+
"SKYGENT_PROFILE_BATCH_SIZE"
|
|
80
|
+
).pipe(Config.withDefault(25));
|
|
81
|
+
const batchSize = batchSizeRaw <= 0 ? 25 : Math.min(batchSizeRaw, 25);
|
|
82
|
+
|
|
83
|
+
const cacheCapacity = yield* Config.integer(
|
|
84
|
+
"SKYGENT_PROFILE_CACHE_CAPACITY"
|
|
85
|
+
).pipe(Config.withDefault(5000));
|
|
86
|
+
const cacheTtl = yield* Config.duration("SKYGENT_PROFILE_CACHE_TTL").pipe(
|
|
87
|
+
Config.withDefault(Duration.hours(6))
|
|
88
|
+
);
|
|
89
|
+
const strict = yield* Config.boolean("SKYGENT_IDENTITY_STRICT").pipe(
|
|
90
|
+
Config.withDefault(false)
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const cacheConfig =
|
|
94
|
+
cacheCapacity > 0 && Duration.toMillis(cacheTtl) > 0
|
|
95
|
+
? Option.some<CacheConfig>({ capacity: cacheCapacity, timeToLive: cacheTtl })
|
|
96
|
+
: Option.none<CacheConfig>();
|
|
97
|
+
|
|
98
|
+
const cache = yield* Option.match(cacheConfig, {
|
|
99
|
+
onNone: () => Effect.succeed(Option.none()),
|
|
100
|
+
onSome: (config) => Request.makeCache(config).pipe(Effect.map(Option.some))
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const resolveBatch = Effect.fn("ProfileResolver.resolveBatch")(
|
|
104
|
+
(requests: ReadonlyArray<ProfileHandleRequest>) =>
|
|
105
|
+
Effect.gen(function* () {
|
|
106
|
+
const dids = Array.from(new Set(requests.map((request) => request.did)));
|
|
107
|
+
if (dids.length === 0) {
|
|
108
|
+
return new Map<string, Either.Either<Handle, BskyError>>();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const cached = yield* Effect.forEach(
|
|
112
|
+
dids,
|
|
113
|
+
(did) =>
|
|
114
|
+
identities
|
|
115
|
+
.lookupHandle(did)
|
|
116
|
+
.pipe(Effect.either, Effect.map((result) => [did, result] as const)),
|
|
117
|
+
{ concurrency: "unbounded" }
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const results = new Map<string, Either.Either<Handle, BskyError>>();
|
|
121
|
+
const misses: Array<string> = [];
|
|
122
|
+
|
|
123
|
+
for (const [did, result] of cached) {
|
|
124
|
+
if (Either.isLeft(result)) {
|
|
125
|
+
results.set(did, Either.left(result.left));
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (Option.isSome(result.right)) {
|
|
129
|
+
results.set(did, Either.right(result.right.value));
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
misses.push(did);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (misses.length === 0) {
|
|
136
|
+
return results;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (strict) {
|
|
140
|
+
const resolved = yield* Effect.forEach(
|
|
141
|
+
misses,
|
|
142
|
+
(did) =>
|
|
143
|
+
identities
|
|
144
|
+
.resolveHandle(did)
|
|
145
|
+
.pipe(Effect.either, Effect.map((value) => [did, value] as const)),
|
|
146
|
+
{ concurrency: "unbounded" }
|
|
147
|
+
);
|
|
148
|
+
for (const [did, value] of resolved) {
|
|
149
|
+
results.set(did, value);
|
|
150
|
+
}
|
|
151
|
+
return results;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const profiles = yield* client.getProfiles(misses);
|
|
155
|
+
for (const profile of profiles) {
|
|
156
|
+
results.set(String(profile.did), Either.right(profile.handle));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
yield* Effect.forEach(
|
|
160
|
+
profiles,
|
|
161
|
+
(profile) =>
|
|
162
|
+
identities.cacheProfile({
|
|
163
|
+
did: profile.did,
|
|
164
|
+
handle: profile.handle,
|
|
165
|
+
source: "getProfiles",
|
|
166
|
+
verified: false
|
|
167
|
+
}),
|
|
168
|
+
{ discard: true, concurrency: "unbounded" }
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
return results;
|
|
172
|
+
})
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const resolver = RequestResolver.makeBatched<ProfileHandleRequest, never>(
|
|
176
|
+
(requests) =>
|
|
177
|
+
resolveBatch(requests).pipe(
|
|
178
|
+
Effect.matchEffect({
|
|
179
|
+
onFailure: (error) =>
|
|
180
|
+
Effect.forEach(requests, (request) => Request.fail(request, error), {
|
|
181
|
+
discard: true
|
|
182
|
+
}),
|
|
183
|
+
onSuccess: (profileMap) =>
|
|
184
|
+
Effect.forEach(
|
|
185
|
+
requests,
|
|
186
|
+
(request) => {
|
|
187
|
+
const result = profileMap.get(request.did);
|
|
188
|
+
if (!result) {
|
|
189
|
+
return Request.fail(
|
|
190
|
+
request,
|
|
191
|
+
BskyError.make({
|
|
192
|
+
message: `Profile not found for DID ${request.did}`,
|
|
193
|
+
cause: request.did
|
|
194
|
+
})
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return Either.match(result, {
|
|
198
|
+
onLeft: (error) => Request.fail(request, error),
|
|
199
|
+
onRight: (handle) => Request.succeed(request, handle)
|
|
200
|
+
});
|
|
201
|
+
},
|
|
202
|
+
{ discard: true }
|
|
203
|
+
)
|
|
204
|
+
})
|
|
205
|
+
)
|
|
206
|
+
).pipe(RequestResolver.batchN(batchSize));
|
|
207
|
+
|
|
208
|
+
const handleForDid = Effect.fn("ProfileResolver.handleForDid")((did: string) => {
|
|
209
|
+
const effect = Effect.request(new ProfileHandleRequest({ did }), resolver);
|
|
210
|
+
return Option.match(cache, {
|
|
211
|
+
onNone: () => effect,
|
|
212
|
+
onSome: (cache) =>
|
|
213
|
+
effect.pipe(
|
|
214
|
+
Effect.withRequestCaching(true),
|
|
215
|
+
Effect.withRequestCache(cache)
|
|
216
|
+
)
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return ProfileResolver.of({ handleForDid });
|
|
221
|
+
})
|
|
222
|
+
);
|
|
223
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { FileSystem, Path } from "@effect/platform";
|
|
2
|
+
import { directorySize } from "./shared.js";
|
|
3
|
+
import { Clock, Config, Context, Duration, Effect, Layer, Ref } from "effect";
|
|
4
|
+
import { AppConfigService } from "./app-config.js";
|
|
5
|
+
|
|
6
|
+
export type ResourceWarning =
|
|
7
|
+
| {
|
|
8
|
+
readonly _tag: "StoreSize";
|
|
9
|
+
readonly bytes: number;
|
|
10
|
+
readonly threshold: number;
|
|
11
|
+
readonly root: string;
|
|
12
|
+
}
|
|
13
|
+
| {
|
|
14
|
+
readonly _tag: "MemoryRss";
|
|
15
|
+
readonly bytes: number;
|
|
16
|
+
readonly threshold: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface ResourceMonitorService {
|
|
20
|
+
readonly check: () => Effect.Effect<ReadonlyArray<ResourceWarning>>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class ResourceMonitor extends Context.Tag("@skygent/ResourceMonitor")<
|
|
24
|
+
ResourceMonitor,
|
|
25
|
+
ResourceMonitorService
|
|
26
|
+
>() {
|
|
27
|
+
static readonly layer = Layer.effect(
|
|
28
|
+
ResourceMonitor,
|
|
29
|
+
Effect.gen(function* () {
|
|
30
|
+
const config = yield* AppConfigService;
|
|
31
|
+
const fs = yield* FileSystem.FileSystem;
|
|
32
|
+
const path = yield* Path.Path;
|
|
33
|
+
|
|
34
|
+
const interval = yield* Config.duration("SKYGENT_RESOURCE_CHECK_INTERVAL").pipe(
|
|
35
|
+
Config.withDefault(Duration.minutes(1))
|
|
36
|
+
);
|
|
37
|
+
const storeWarnBytes = yield* Config.integer(
|
|
38
|
+
"SKYGENT_RESOURCE_STORE_WARN_BYTES"
|
|
39
|
+
).pipe(Config.withDefault(1_073_741_824));
|
|
40
|
+
const rssWarnBytes = yield* Config.integer(
|
|
41
|
+
"SKYGENT_RESOURCE_RSS_WARN_BYTES"
|
|
42
|
+
).pipe(Config.withDefault(1_073_741_824));
|
|
43
|
+
|
|
44
|
+
const lastCheck = yield* Ref.make(0);
|
|
45
|
+
const intervalMs = Duration.toMillis(interval);
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
const rssUsage = () => {
|
|
49
|
+
if (
|
|
50
|
+
typeof process !== "undefined" &&
|
|
51
|
+
typeof process.memoryUsage === "function"
|
|
52
|
+
) {
|
|
53
|
+
return process.memoryUsage().rss;
|
|
54
|
+
}
|
|
55
|
+
return 0;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const check = Effect.fn("ResourceMonitor.check")(() =>
|
|
59
|
+
Effect.gen(function* () {
|
|
60
|
+
const now = yield* Clock.currentTimeMillis;
|
|
61
|
+
const last = yield* Ref.get(lastCheck);
|
|
62
|
+
if (now - last < intervalMs) {
|
|
63
|
+
return [] as ReadonlyArray<ResourceWarning>;
|
|
64
|
+
}
|
|
65
|
+
yield* Ref.set(lastCheck, now);
|
|
66
|
+
|
|
67
|
+
const warnings: Array<ResourceWarning> = [];
|
|
68
|
+
|
|
69
|
+
if (storeWarnBytes > 0) {
|
|
70
|
+
const total = yield* directorySize(fs, path, config.storeRoot);
|
|
71
|
+
if (total >= storeWarnBytes) {
|
|
72
|
+
warnings.push({
|
|
73
|
+
_tag: "StoreSize",
|
|
74
|
+
bytes: total,
|
|
75
|
+
threshold: storeWarnBytes,
|
|
76
|
+
root: config.storeRoot
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (rssWarnBytes > 0) {
|
|
82
|
+
const rss = rssUsage();
|
|
83
|
+
if (rss >= rssWarnBytes) {
|
|
84
|
+
warnings.push({
|
|
85
|
+
_tag: "MemoryRss",
|
|
86
|
+
bytes: rss,
|
|
87
|
+
threshold: rssWarnBytes
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return warnings;
|
|
93
|
+
}).pipe(Effect.orElseSucceed(() => []))
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return ResourceMonitor.of({ check });
|
|
97
|
+
})
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
static readonly testLayer = Layer.succeed(
|
|
101
|
+
ResourceMonitor,
|
|
102
|
+
ResourceMonitor.of({
|
|
103
|
+
check: () => Effect.succeed([])
|
|
104
|
+
})
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { FileSystem, Path } from "@effect/platform";
|
|
2
|
+
import { Effect, ParseResult } from "effect";
|
|
3
|
+
import { ConfigError } from "../domain/errors.js";
|
|
4
|
+
|
|
5
|
+
/** Extract a human-readable message from an unknown cause, falling back to the given string. */
|
|
6
|
+
export const messageFromCause = (fallback: string, cause: unknown) => {
|
|
7
|
+
if (typeof cause === "object" && cause !== null && "message" in cause) {
|
|
8
|
+
const message = (cause as { readonly message?: unknown }).message;
|
|
9
|
+
if (typeof message === "string" && message.length > 0) {
|
|
10
|
+
return message;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return fallback;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Strip `undefined` values from a record, returning a Partial<T>. */
|
|
17
|
+
export const pickDefined = <T extends Record<string, unknown>>(input: T): Partial<T> =>
|
|
18
|
+
Object.fromEntries(
|
|
19
|
+
Object.entries(input).filter(([, value]) => value !== undefined)
|
|
20
|
+
) as Partial<T>;
|
|
21
|
+
|
|
22
|
+
/** Format a Schema parse error (or arbitrary unknown) as a readable string. */
|
|
23
|
+
export const formatSchemaError = (error: unknown) => {
|
|
24
|
+
if (ParseResult.isParseError(error)) {
|
|
25
|
+
return ParseResult.TreeFormatter.formatErrorSync(error);
|
|
26
|
+
}
|
|
27
|
+
return String(error);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Recursively compute the total byte-size of all files under a directory. */
|
|
31
|
+
export const directorySize = (fs: FileSystem.FileSystem, path: Path.Path, root: string) =>
|
|
32
|
+
Effect.gen(function* () {
|
|
33
|
+
const exists = yield* fs.exists(root).pipe(Effect.orElseSucceed(() => false));
|
|
34
|
+
if (!exists) {
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
const entries = yield* fs
|
|
38
|
+
.readDirectory(root, { recursive: true })
|
|
39
|
+
.pipe(Effect.orElseSucceed(() => []));
|
|
40
|
+
if (entries.length === 0) {
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
const sizes = yield* Effect.forEach(
|
|
44
|
+
entries,
|
|
45
|
+
(entry) =>
|
|
46
|
+
fs
|
|
47
|
+
.stat(path.join(root, entry))
|
|
48
|
+
.pipe(
|
|
49
|
+
Effect.map((info) => (info.type === "File" ? Number(info.size) : 0)),
|
|
50
|
+
Effect.orElseSucceed(() => 0)
|
|
51
|
+
),
|
|
52
|
+
{ concurrency: 10 }
|
|
53
|
+
);
|
|
54
|
+
return sizes.reduce((total, size) => total + size, 0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** Validate that a numeric config value is >= 1. */
|
|
58
|
+
export const validatePositive = (name: string, value: number) => {
|
|
59
|
+
if (!Number.isFinite(value) || value < 1) {
|
|
60
|
+
return ConfigError.make({ message: `${name} must be >= 1.` });
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Validate that a numeric config value is >= 0. */
|
|
65
|
+
export const validateNonNegative = (name: string, value: number) => {
|
|
66
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
67
|
+
return ConfigError.make({ message: `${name} must be >= 0.` });
|
|
68
|
+
}
|
|
69
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Option } from "effect";
|
|
2
|
+
import type { StoreError } from "../domain/errors.js";
|
|
3
|
+
import { StoreName } from "../domain/primitives.js";
|
|
4
|
+
import { StoreDb } from "./store-db.js";
|
|
5
|
+
import { StoreEventLog } from "./store-event-log.js";
|
|
6
|
+
import { StoreIndex } from "./store-index.js";
|
|
7
|
+
import { StoreManager } from "./store-manager.js";
|
|
8
|
+
|
|
9
|
+
export class StoreCleaner extends Context.Tag("@skygent/StoreCleaner")<
|
|
10
|
+
StoreCleaner,
|
|
11
|
+
{
|
|
12
|
+
readonly deleteStore: (
|
|
13
|
+
name: StoreName
|
|
14
|
+
) => Effect.Effect<{ readonly deleted: boolean }, StoreError>;
|
|
15
|
+
}
|
|
16
|
+
>() {
|
|
17
|
+
static readonly layer = Layer.effect(
|
|
18
|
+
StoreCleaner,
|
|
19
|
+
Effect.gen(function* () {
|
|
20
|
+
const manager = yield* StoreManager;
|
|
21
|
+
const index = yield* StoreIndex;
|
|
22
|
+
const eventLog = yield* StoreEventLog;
|
|
23
|
+
const storeDb = yield* StoreDb;
|
|
24
|
+
|
|
25
|
+
const deleteStore = Effect.fn("StoreCleaner.deleteStore")((name: StoreName) =>
|
|
26
|
+
Effect.gen(function* () {
|
|
27
|
+
const storeOption = yield* manager.getStore(name);
|
|
28
|
+
if (Option.isNone(storeOption)) {
|
|
29
|
+
return { deleted: false } as const;
|
|
30
|
+
}
|
|
31
|
+
const store = storeOption.value;
|
|
32
|
+
yield* eventLog.clear(store);
|
|
33
|
+
yield* index.clear(store);
|
|
34
|
+
yield* manager.deleteStore(name);
|
|
35
|
+
yield* storeDb.removeClient(name);
|
|
36
|
+
return { deleted: true } as const;
|
|
37
|
+
})
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
return StoreCleaner.of({ deleteStore });
|
|
41
|
+
})
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store Committer Service Module
|
|
3
|
+
*
|
|
4
|
+
* This module provides the StoreCommitter service, which handles atomic transactions
|
|
5
|
+
* for appending events to stores. It ensures data consistency by wrapping operations
|
|
6
|
+
* (upserts, conditional inserts, and deletes) in SQLite transactions.
|
|
7
|
+
*
|
|
8
|
+
* Key responsibilities:
|
|
9
|
+
* - Atomic event appending with database state changes
|
|
10
|
+
* - Deduplication support via conditional insert operations
|
|
11
|
+
* - Error handling and mapping to StoreIoError
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Context, Effect, Layer, Option } from "effect";
|
|
15
|
+
import { StoreIoError } from "../domain/errors.js";
|
|
16
|
+
import type { StorePath } from "../domain/primitives.js";
|
|
17
|
+
import type { StoreRef } from "../domain/store.js";
|
|
18
|
+
import { type EventLogEntry, PostDelete, PostUpsert } from "../domain/events.js";
|
|
19
|
+
import { StoreDb } from "./store-db.js";
|
|
20
|
+
import { StoreWriter } from "./store-writer.js";
|
|
21
|
+
import { deletePost, insertPostIfMissing, upsertPost } from "./store-index-sql.js";
|
|
22
|
+
|
|
23
|
+
const toStoreIoError = (path: StorePath) => (cause: unknown) =>
|
|
24
|
+
StoreIoError.make({ path, cause });
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Service for committing events to stores with atomic transaction guarantees.
|
|
28
|
+
*
|
|
29
|
+
* The StoreCommitter provides three core operations for persisting post events:
|
|
30
|
+
* - `appendUpsert`: Atomically upsert a post and record the event
|
|
31
|
+
* - `appendUpsertIfMissing`: Insert only if the post doesn't exist (for deduplication)
|
|
32
|
+
* - `appendDelete`: Atomically delete a post and record the event
|
|
33
|
+
*
|
|
34
|
+
* All operations are performed within SQLite transactions to maintain consistency
|
|
35
|
+
* between the post index and the event log.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* const program = Effect.gen(function* () {
|
|
40
|
+
* const committer = yield* StoreCommitter;
|
|
41
|
+
* const store = { root: "/data/posts" };
|
|
42
|
+
* const event = PostUpsert.make({ ... });
|
|
43
|
+
*
|
|
44
|
+
* const record = yield* committer.appendUpsert(store, event);
|
|
45
|
+
* return record;
|
|
46
|
+
* });
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export class StoreCommitter extends Context.Tag("@skygent/StoreCommitter")<
|
|
50
|
+
StoreCommitter,
|
|
51
|
+
{
|
|
52
|
+
/**
|
|
53
|
+
* Append an upsert event to the store, updating or inserting the post.
|
|
54
|
+
*
|
|
55
|
+
* This method performs an atomic transaction that:
|
|
56
|
+
* 1. Upserts the post data into the store's index
|
|
57
|
+
* 2. Appends the event to the store's event log
|
|
58
|
+
*
|
|
59
|
+
* If either operation fails, the entire transaction is rolled back.
|
|
60
|
+
*
|
|
61
|
+
* @param store - Reference to the target store
|
|
62
|
+
* @param event - The PostUpsert event containing the post data
|
|
63
|
+
* @returns Effect that resolves to the recorded PostEventRecord
|
|
64
|
+
* @throws StoreIoError if the transaction fails
|
|
65
|
+
*/
|
|
66
|
+
readonly appendUpsert: (
|
|
67
|
+
store: StoreRef,
|
|
68
|
+
event: PostUpsert
|
|
69
|
+
) => Effect.Effect<EventLogEntry, StoreIoError>;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Append an upsert event only if the post doesn't already exist.
|
|
73
|
+
*
|
|
74
|
+
* This method is used for deduplication scenarios. It performs an atomic transaction that:
|
|
75
|
+
* 1. Attempts to insert the post only if it's not already present
|
|
76
|
+
* 2. If inserted, appends the event to the store's event log
|
|
77
|
+
*
|
|
78
|
+
* @param store - Reference to the target store
|
|
79
|
+
* @param event - The PostUpsert event containing the post data
|
|
80
|
+
* @returns Effect that resolves to Option.Some(record) if inserted, or Option.None() if the post already exists
|
|
81
|
+
* @throws StoreIoError if the transaction fails
|
|
82
|
+
*/
|
|
83
|
+
readonly appendUpsertIfMissing: (
|
|
84
|
+
store: StoreRef,
|
|
85
|
+
event: PostUpsert
|
|
86
|
+
) => Effect.Effect<Option.Option<EventLogEntry>, StoreIoError>;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Append a delete event to the store, removing the post.
|
|
90
|
+
*
|
|
91
|
+
* This method performs an atomic transaction that:
|
|
92
|
+
* 1. Deletes the post from the store's index
|
|
93
|
+
* 2. Appends the delete event to the store's event log
|
|
94
|
+
*
|
|
95
|
+
* If either operation fails, the entire transaction is rolled back.
|
|
96
|
+
*
|
|
97
|
+
* @param store - Reference to the target store
|
|
98
|
+
* @param event - The PostDelete event containing the post URI to delete
|
|
99
|
+
* @returns Effect that resolves to the recorded PostEventRecord
|
|
100
|
+
* @throws StoreIoError if the transaction fails
|
|
101
|
+
*/
|
|
102
|
+
readonly appendDelete: (
|
|
103
|
+
store: StoreRef,
|
|
104
|
+
event: PostDelete
|
|
105
|
+
) => Effect.Effect<EventLogEntry, StoreIoError>;
|
|
106
|
+
}
|
|
107
|
+
>() {
|
|
108
|
+
static readonly layer = Layer.effect(
|
|
109
|
+
StoreCommitter,
|
|
110
|
+
Effect.gen(function* () {
|
|
111
|
+
const storeDb = yield* StoreDb;
|
|
112
|
+
const writer = yield* StoreWriter;
|
|
113
|
+
|
|
114
|
+
const appendUpsert = Effect.fn("StoreCommitter.appendUpsert")(
|
|
115
|
+
(store: StoreRef, event: PostUpsert) =>
|
|
116
|
+
storeDb
|
|
117
|
+
.withClient(store, (client) =>
|
|
118
|
+
client.withTransaction(
|
|
119
|
+
Effect.gen(function* () {
|
|
120
|
+
yield* upsertPost(client, event.post);
|
|
121
|
+
return yield* writer.appendWithClient(client, event);
|
|
122
|
+
})
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
.pipe(Effect.mapError(toStoreIoError(store.root)))
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const appendUpsertIfMissing = Effect.fn(
|
|
129
|
+
"StoreCommitter.appendUpsertIfMissing"
|
|
130
|
+
)((store: StoreRef, event: PostUpsert) =>
|
|
131
|
+
storeDb
|
|
132
|
+
.withClient(store, (client) =>
|
|
133
|
+
client.withTransaction(
|
|
134
|
+
Effect.gen(function* () {
|
|
135
|
+
const inserted = yield* insertPostIfMissing(client, event.post);
|
|
136
|
+
if (!inserted) {
|
|
137
|
+
return Option.none<EventLogEntry>();
|
|
138
|
+
}
|
|
139
|
+
const entry = yield* writer.appendWithClient(client, event);
|
|
140
|
+
return Option.some(entry);
|
|
141
|
+
})
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
.pipe(Effect.mapError(toStoreIoError(store.root)))
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const appendDelete = Effect.fn("StoreCommitter.appendDelete")(
|
|
148
|
+
(store: StoreRef, event: PostDelete) =>
|
|
149
|
+
storeDb
|
|
150
|
+
.withClient(store, (client) =>
|
|
151
|
+
client.withTransaction(
|
|
152
|
+
Effect.gen(function* () {
|
|
153
|
+
yield* deletePost(client, event.uri);
|
|
154
|
+
return yield* writer.appendWithClient(client, event);
|
|
155
|
+
})
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
.pipe(Effect.mapError(toStoreIoError(store.root)))
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
return StoreCommitter.of({
|
|
162
|
+
appendUpsert,
|
|
163
|
+
appendUpsertIfMissing,
|
|
164
|
+
appendDelete
|
|
165
|
+
});
|
|
166
|
+
})
|
|
167
|
+
);
|
|
168
|
+
}
|