@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,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
+ }