@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,248 @@
|
|
|
1
|
+
import { FileSystem, Path } from "@effect/platform";
|
|
2
|
+
import { Context, Effect, Exit, Layer, Ref, Scope } from "effect";
|
|
3
|
+
import * as Reactivity from "@effect/experimental/Reactivity";
|
|
4
|
+
import * as Migrator from "@effect/sql/Migrator";
|
|
5
|
+
import * as MigratorFileSystem from "@effect/sql/Migrator/FileSystem";
|
|
6
|
+
import * as SqlClient from "@effect/sql/SqlClient";
|
|
7
|
+
import { SqliteClient } from "@effect/sql-sqlite-bun";
|
|
8
|
+
import { StoreIoError } from "../domain/errors.js";
|
|
9
|
+
import type { StoreRef } from "../domain/store.js";
|
|
10
|
+
import type { StorePath } from "../domain/primitives.js";
|
|
11
|
+
import { AppConfigService } from "./app-config.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Store Database Connection Management Service
|
|
15
|
+
*
|
|
16
|
+
* This module provides a centralized SQLite database connection manager for store indexes.
|
|
17
|
+
* It implements a connection caching/pooling pattern to efficiently manage database connections
|
|
18
|
+
* across multiple stores while ensuring proper resource cleanup.
|
|
19
|
+
*
|
|
20
|
+
* Key features:
|
|
21
|
+
* - Connection caching: Reuses existing connections to the same store
|
|
22
|
+
* - Thread-safe client access using semaphores for concurrent operations
|
|
23
|
+
* - Automatic migration execution on first connection to each store
|
|
24
|
+
* - Optimized SQLite pragmas for performance (WAL mode, memory-mapped I/O, cache sizing)
|
|
25
|
+
* - Graceful cleanup on service shutdown via finalizers
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```typescript
|
|
29
|
+
* import { Effect } from "effect";
|
|
30
|
+
* import { StoreDb } from "./services/store-db.js";
|
|
31
|
+
* import type { StoreRef } from "./domain/store.js";
|
|
32
|
+
*
|
|
33
|
+
* const program = Effect.gen(function* () {
|
|
34
|
+
* const storeDb = yield* StoreDb;
|
|
35
|
+
* const store: StoreRef = { name: "myStore", root: "stores/myStore" };
|
|
36
|
+
*
|
|
37
|
+
* // Execute queries with automatic connection management
|
|
38
|
+
* const result = yield* storeDb.withClient(store, (client) =>
|
|
39
|
+
* client`SELECT * FROM posts WHERE id = ${postId}`
|
|
40
|
+
* );
|
|
41
|
+
*
|
|
42
|
+
* // Remove a store's connection when done
|
|
43
|
+
* yield* storeDb.removeClient(store.name);
|
|
44
|
+
* });
|
|
45
|
+
*
|
|
46
|
+
* const runnable = program.pipe(Effect.provide(StoreDb.layer));
|
|
47
|
+
* ```
|
|
48
|
+
*
|
|
49
|
+
* @module services/store-db
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
const migrationsDir = decodeURIComponent(
|
|
53
|
+
new URL("../db/migrations/store-index", import.meta.url).pathname
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const toStoreIoError = (path: StorePath) => (cause: unknown) =>
|
|
57
|
+
StoreIoError.make({ path, cause });
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Service for managing SQLite database connections to store indexes.
|
|
61
|
+
*
|
|
62
|
+
* Provides connection pooling/caching, automatic migrations, and optimized
|
|
63
|
+
* SQLite configuration for each store. Connections are lazily created and
|
|
64
|
+
* cached for reuse, with proper cleanup on service termination.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```typescript
|
|
68
|
+
* // Basic usage - execute a query
|
|
69
|
+
* const result = yield* storeDb.withClient(store, (client) =>
|
|
70
|
+
* client`SELECT COUNT(*) as count FROM posts`
|
|
71
|
+
* );
|
|
72
|
+
*
|
|
73
|
+
* // Batch operations with the same connection
|
|
74
|
+
* const results = yield* storeDb.withClient(store, (client) =>
|
|
75
|
+
* Effect.gen(function* () {
|
|
76
|
+
* yield* client`INSERT INTO posts (id, content) VALUES (${id1}, ${content1})`;
|
|
77
|
+
* yield* client`INSERT INTO posts (id, content) VALUES (${id2}, ${content2})`;
|
|
78
|
+
* return yield* client`SELECT * FROM posts`;
|
|
79
|
+
* })
|
|
80
|
+
* );
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
export class StoreDb extends Context.Tag("@skygent/StoreDb")<
|
|
84
|
+
StoreDb,
|
|
85
|
+
{
|
|
86
|
+
/**
|
|
87
|
+
* Execute a database operation with a cached client for the specified store.
|
|
88
|
+
*
|
|
89
|
+
* This method automatically retrieves an existing connection or creates a new one,
|
|
90
|
+
* runs the provided operation, and keeps the connection cached for future use.
|
|
91
|
+
* The connection remains open until explicitly removed via `removeClient` or
|
|
92
|
+
* service shutdown.
|
|
93
|
+
*
|
|
94
|
+
* @param store - Store reference containing name and root path
|
|
95
|
+
* @param run - Effect function that receives the SQL client and returns a result
|
|
96
|
+
* @returns Effect containing the operation result, potentially failing with StoreIoError
|
|
97
|
+
* @example
|
|
98
|
+
* ```typescript
|
|
99
|
+
* const posts = yield* storeDb.withClient(store, (client) =>
|
|
100
|
+
* client`SELECT * FROM posts LIMIT 10`
|
|
101
|
+
* );
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
readonly withClient: <A, E>(
|
|
105
|
+
store: StoreRef,
|
|
106
|
+
run: (client: SqlClient.SqlClient) => Effect.Effect<A, E>
|
|
107
|
+
) => Effect.Effect<A, StoreIoError | E>;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Remove a cached database connection for a store.
|
|
111
|
+
*
|
|
112
|
+
* Closes the connection gracefully (running PRAGMA optimize first) and
|
|
113
|
+
* removes it from the cache. Subsequent calls to `withClient` for this
|
|
114
|
+
* store will create a new connection.
|
|
115
|
+
*
|
|
116
|
+
* @param storeName - The name of the store whose connection should be removed
|
|
117
|
+
* @returns Effect that completes when the connection is closed
|
|
118
|
+
* @example
|
|
119
|
+
* ```typescript
|
|
120
|
+
* // Clean up a store's connection when no longer needed
|
|
121
|
+
* yield* storeDb.removeClient("myStore");
|
|
122
|
+
* ```
|
|
123
|
+
*/
|
|
124
|
+
readonly removeClient: (storeName: string) => Effect.Effect<void>;
|
|
125
|
+
}
|
|
126
|
+
>() {
|
|
127
|
+
static readonly layer = Layer.scoped(
|
|
128
|
+
StoreDb,
|
|
129
|
+
Effect.gen(function* () {
|
|
130
|
+
const config = yield* AppConfigService;
|
|
131
|
+
const fs = yield* FileSystem.FileSystem;
|
|
132
|
+
const path = yield* Path.Path;
|
|
133
|
+
const reactivity = yield* Reactivity.Reactivity;
|
|
134
|
+
|
|
135
|
+
type CachedClient = {
|
|
136
|
+
readonly client: SqlClient.SqlClient;
|
|
137
|
+
readonly scope: Scope.CloseableScope;
|
|
138
|
+
};
|
|
139
|
+
const clients = yield* Ref.make(new Map<string, CachedClient>());
|
|
140
|
+
const clientLock = yield* Effect.makeSemaphore(1);
|
|
141
|
+
|
|
142
|
+
const migrate = Migrator.make({})({
|
|
143
|
+
loader: MigratorFileSystem.fromFileSystem(migrationsDir)
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const optimizeClient = (client: SqlClient.SqlClient) =>
|
|
147
|
+
client`PRAGMA optimize`.pipe(
|
|
148
|
+
Effect.catchAll((error) =>
|
|
149
|
+
Effect.logWarning("PRAGMA optimize failed", {
|
|
150
|
+
error: error instanceof Error ? error.message : String(error)
|
|
151
|
+
})
|
|
152
|
+
)
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const closeCachedClient = ({ client, scope }: CachedClient) =>
|
|
156
|
+
optimizeClient(client).pipe(Effect.zipRight(Scope.close(scope, Exit.void)));
|
|
157
|
+
|
|
158
|
+
const closeAllClients = () =>
|
|
159
|
+
Ref.get(clients).pipe(
|
|
160
|
+
Effect.flatMap((current) =>
|
|
161
|
+
Effect.forEach(current.values(), closeCachedClient, { discard: true })
|
|
162
|
+
)
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
yield* Effect.addFinalizer(() => closeAllClients());
|
|
166
|
+
|
|
167
|
+
const openClient = (store: StoreRef) =>
|
|
168
|
+
Effect.gen(function* () {
|
|
169
|
+
const dbPath = path.join(config.storeRoot, store.root, "index.sqlite");
|
|
170
|
+
const dbDir = path.dirname(dbPath);
|
|
171
|
+
yield* fs.makeDirectory(dbDir, { recursive: true });
|
|
172
|
+
|
|
173
|
+
const clientScope = yield* Scope.make();
|
|
174
|
+
const client = yield* SqliteClient.make({ filename: dbPath }).pipe(
|
|
175
|
+
Effect.provideService(Scope.Scope, clientScope),
|
|
176
|
+
Effect.provideService(Reactivity.Reactivity, reactivity)
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
// Configure SQLite for optimal performance
|
|
180
|
+
yield* client`PRAGMA busy_timeout = 5000`;
|
|
181
|
+
yield* client`PRAGMA journal_mode = WAL`;
|
|
182
|
+
yield* client`PRAGMA synchronous = NORMAL`;
|
|
183
|
+
yield* client`PRAGMA temp_store = MEMORY`;
|
|
184
|
+
yield* client`PRAGMA cache_size = -64000`;
|
|
185
|
+
yield* client`PRAGMA mmap_size = 30000000000`;
|
|
186
|
+
yield* client`PRAGMA optimize=0x10002`;
|
|
187
|
+
yield* client`PRAGMA foreign_keys = ON`;
|
|
188
|
+
yield* migrate.pipe(
|
|
189
|
+
Effect.provideService(SqlClient.SqlClient, client),
|
|
190
|
+
Effect.provideService(FileSystem.FileSystem, fs)
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
return { client, scope: clientScope };
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const getClient = (store: StoreRef) =>
|
|
197
|
+
Effect.gen(function* () {
|
|
198
|
+
const cached = (yield* Ref.get(clients)).get(store.name);
|
|
199
|
+
if (cached) {
|
|
200
|
+
return cached.client;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return yield* clientLock.withPermits(1)(
|
|
204
|
+
Effect.gen(function* () {
|
|
205
|
+
const current = yield* Ref.get(clients);
|
|
206
|
+
const existing = current.get(store.name);
|
|
207
|
+
if (existing) {
|
|
208
|
+
return existing.client;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const created = yield* openClient(store);
|
|
212
|
+
|
|
213
|
+
const next = new Map(current);
|
|
214
|
+
next.set(store.name, created);
|
|
215
|
+
yield* Ref.set(clients, next);
|
|
216
|
+
|
|
217
|
+
return created.client;
|
|
218
|
+
})
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const withClient = <A, E>(
|
|
223
|
+
store: StoreRef,
|
|
224
|
+
run: (client: SqlClient.SqlClient) => Effect.Effect<A, E>
|
|
225
|
+
) =>
|
|
226
|
+
getClient(store).pipe(
|
|
227
|
+
Effect.mapError(toStoreIoError(store.root)),
|
|
228
|
+
Effect.flatMap(run)
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const removeClient = (storeName: string) =>
|
|
232
|
+
Ref.modify(clients, (current) => {
|
|
233
|
+
const next = new Map(current);
|
|
234
|
+
const existing = next.get(storeName);
|
|
235
|
+
if (existing) {
|
|
236
|
+
next.delete(storeName);
|
|
237
|
+
}
|
|
238
|
+
return [existing, next] as const;
|
|
239
|
+
}).pipe(
|
|
240
|
+
Effect.flatMap((existing) =>
|
|
241
|
+
existing ? closeCachedClient(existing) : Effect.void
|
|
242
|
+
)
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
return StoreDb.of({ withClient, removeClient });
|
|
246
|
+
})
|
|
247
|
+
).pipe(Layer.provide(Reactivity.layer));
|
|
248
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Option, ParseResult, Schema, Stream } from "effect";
|
|
2
|
+
import * as SqlSchema from "@effect/sql/SqlSchema";
|
|
3
|
+
import { StoreIoError } from "../domain/errors.js";
|
|
4
|
+
import { type EventLogEntry, PostEventRecord } from "../domain/events.js";
|
|
5
|
+
import { EventSeq } from "../domain/primitives.js";
|
|
6
|
+
import type { StorePath } from "../domain/primitives.js";
|
|
7
|
+
import type { StoreRef } from "../domain/store.js";
|
|
8
|
+
import { StoreDb } from "./store-db.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Store Event Log Service
|
|
12
|
+
*
|
|
13
|
+
* This module provides event sourcing capabilities for stores by streaming events
|
|
14
|
+
* from the SQLite `event_log` table. It implements an append-only event log pattern
|
|
15
|
+
* for tracking all changes to a store's data, enabling event-driven architectures,
|
|
16
|
+
* audit trails, and data synchronization.
|
|
17
|
+
*
|
|
18
|
+
* Key features:
|
|
19
|
+
* - Paginated event streaming with configurable batch size (500 events per page)
|
|
20
|
+
* - Automatic event sequence tracking for resumable streams
|
|
21
|
+
* - JSON event payload serialization/deserialization
|
|
22
|
+
* - Event log clearing for data reset scenarios
|
|
23
|
+
* - Efficient last event sequence retrieval from the event log table
|
|
24
|
+
*
|
|
25
|
+
* The service supports the event sourcing pattern where all state changes are stored
|
|
26
|
+
* as immutable events, allowing for event replay, projections, and temporal queries.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```typescript
|
|
30
|
+
* import { Effect, Stream } from "effect";
|
|
31
|
+
* import { StoreEventLog } from "./services/store-event-log.js";
|
|
32
|
+
* import type { StoreRef } from "./domain/store.js";
|
|
33
|
+
*
|
|
34
|
+
* const program = Effect.gen(function* () {
|
|
35
|
+
* const eventLog = yield* StoreEventLog;
|
|
36
|
+
* const store: StoreRef = { name: "myStore", root: "stores/myStore" };
|
|
37
|
+
*
|
|
38
|
+
* // Stream all events from the store
|
|
39
|
+
* const events = yield* eventLog.stream(store).pipe(
|
|
40
|
+
* Stream.runCollect
|
|
41
|
+
* );
|
|
42
|
+
*
|
|
43
|
+
* // Get the last event sequence for resuming streams
|
|
44
|
+
* const lastSeq = yield* eventLog.getLastEventSeq(store);
|
|
45
|
+
*
|
|
46
|
+
* // Clear the event log (use with caution)
|
|
47
|
+
* yield* eventLog.clear(store);
|
|
48
|
+
* });
|
|
49
|
+
*
|
|
50
|
+
* const runnable = program.pipe(Effect.provide(StoreEventLog.layer));
|
|
51
|
+
* ```
|
|
52
|
+
*
|
|
53
|
+
* @module services/store-event-log
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
const pageSize = 500;
|
|
57
|
+
|
|
58
|
+
const eventLogRow = Schema.Struct({
|
|
59
|
+
event_seq: EventSeq,
|
|
60
|
+
payload_json: Schema.String
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const lastEventSeqRow = Schema.Struct({
|
|
64
|
+
value: EventSeq
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const decodeEventJson = (raw: string) =>
|
|
68
|
+
Schema.decodeUnknown(Schema.parseJson(PostEventRecord))(raw);
|
|
69
|
+
|
|
70
|
+
const toStoreIoError = (path: StorePath) => (cause: unknown) =>
|
|
71
|
+
StoreIoError.make({ path, cause });
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Service for managing and streaming store event logs.
|
|
75
|
+
*
|
|
76
|
+
* Provides event sourcing capabilities with paginated streaming, event
|
|
77
|
+
* tracking, and log management. Events are stored as JSON payloads in an
|
|
78
|
+
* SQLite table with sequential event numbers for ordering.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```typescript
|
|
82
|
+
* // Stream all events from a store
|
|
83
|
+
* const events = yield* eventLog.stream(store).pipe(
|
|
84
|
+
* Stream.runCollect
|
|
85
|
+
* );
|
|
86
|
+
*
|
|
87
|
+
* // Process events incrementally
|
|
88
|
+
* yield* eventLog.stream(store).pipe(
|
|
89
|
+
* Stream.tap((event) => Effect.sync(() => console.log(event))),
|
|
90
|
+
* Stream.runDrain
|
|
91
|
+
* );
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
export class StoreEventLog extends Context.Tag("@skygent/StoreEventLog")<
|
|
95
|
+
StoreEventLog,
|
|
96
|
+
{
|
|
97
|
+
/**
|
|
98
|
+
* Stream all events from a store's event log.
|
|
99
|
+
*
|
|
100
|
+
* Returns a Stream that emits all `PostEventRecord` events from the store's
|
|
101
|
+
* event_log table in ascending order by event_seq. Events are fetched in
|
|
102
|
+
* paginated batches of 500 for memory efficiency. The stream automatically
|
|
103
|
+
* handles pagination and continues until all events are emitted.
|
|
104
|
+
*
|
|
105
|
+
* @param store - Store reference to stream events from
|
|
106
|
+
* @returns Stream of PostEventRecord events, failing with StoreIoError on database errors
|
|
107
|
+
* @example
|
|
108
|
+
* ```typescript
|
|
109
|
+
* // Collect all events into an array
|
|
110
|
+
* const allEvents = yield* eventLog.stream(store).pipe(
|
|
111
|
+
* Stream.runCollect
|
|
112
|
+
* );
|
|
113
|
+
*
|
|
114
|
+
* // Process events with backpressure
|
|
115
|
+
* yield* eventLog.stream(store).pipe(
|
|
116
|
+
* Stream.grouped(10),
|
|
117
|
+
* Stream.tap((batch) => processBatch(batch)),
|
|
118
|
+
* Stream.runDrain
|
|
119
|
+
* );
|
|
120
|
+
* ```
|
|
121
|
+
*/
|
|
122
|
+
readonly stream: (
|
|
123
|
+
store: StoreRef
|
|
124
|
+
) => Stream.Stream<EventLogEntry, StoreIoError>;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Clear all events from a store's event log.
|
|
128
|
+
*
|
|
129
|
+
* Deletes all records from the event_log table.
|
|
130
|
+
* This operation is irreversible and should be used with caution, typically
|
|
131
|
+
* for data resets or re-initialization scenarios.
|
|
132
|
+
*
|
|
133
|
+
* @param store - Store reference whose event log should be cleared
|
|
134
|
+
* @returns Effect that completes when the event log is cleared, failing with StoreIoError
|
|
135
|
+
* @example
|
|
136
|
+
* ```typescript
|
|
137
|
+
* // Clear the event log before re-syncing
|
|
138
|
+
* yield* eventLog.clear(store);
|
|
139
|
+
* // Now ready to start fresh event sourcing
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
readonly clear: (store: StoreRef) => Effect.Effect<void, StoreIoError>;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Retrieve the last event sequence from a store's event log.
|
|
146
|
+
*
|
|
147
|
+
* Queries the event_log table directly for the highest event_seq.
|
|
148
|
+
* Returns None if no events exist.
|
|
149
|
+
*
|
|
150
|
+
* This is useful for resuming event streams from a known position.
|
|
151
|
+
*
|
|
152
|
+
* @param store - Store reference to get the last event sequence from
|
|
153
|
+
* @returns Effect containing Option of EventSeq (None if no events), failing with StoreIoError
|
|
154
|
+
* @example
|
|
155
|
+
* ```typescript
|
|
156
|
+
* // Get last event sequence for incremental processing
|
|
157
|
+
* const lastSeq = yield* eventLog.getLastEventSeq(store);
|
|
158
|
+
*
|
|
159
|
+
* // Use with Option.match to handle empty event log
|
|
160
|
+
* yield* Option.match(lastId, {
|
|
161
|
+
* onNone: () => syncAllEvents(),
|
|
162
|
+
* onSome: (id) => syncEventsSince(id)
|
|
163
|
+
* });
|
|
164
|
+
* ```
|
|
165
|
+
*/
|
|
166
|
+
readonly getLastEventSeq: (
|
|
167
|
+
store: StoreRef
|
|
168
|
+
) => Effect.Effect<Option.Option<EventSeq>, StoreIoError>;
|
|
169
|
+
}
|
|
170
|
+
>() {
|
|
171
|
+
static readonly layer = Layer.effect(
|
|
172
|
+
StoreEventLog,
|
|
173
|
+
Effect.gen(function* () {
|
|
174
|
+
const storeDb = yield* StoreDb;
|
|
175
|
+
|
|
176
|
+
const stream = (store: StoreRef) =>
|
|
177
|
+
Stream.unwrap(
|
|
178
|
+
storeDb.withClient(store, (client) =>
|
|
179
|
+
Effect.gen(function* () {
|
|
180
|
+
const firstPage = SqlSchema.findAll({
|
|
181
|
+
Request: Schema.Void,
|
|
182
|
+
Result: eventLogRow,
|
|
183
|
+
execute: () =>
|
|
184
|
+
client`SELECT event_seq, payload_json
|
|
185
|
+
FROM event_log
|
|
186
|
+
ORDER BY event_seq ASC
|
|
187
|
+
LIMIT ${pageSize}`
|
|
188
|
+
});
|
|
189
|
+
const nextPage = SqlSchema.findAll({
|
|
190
|
+
Request: EventSeq,
|
|
191
|
+
Result: eventLogRow,
|
|
192
|
+
execute: (after) =>
|
|
193
|
+
client`SELECT event_seq, payload_json
|
|
194
|
+
FROM event_log
|
|
195
|
+
WHERE event_seq > ${after}
|
|
196
|
+
ORDER BY event_seq ASC
|
|
197
|
+
LIMIT ${pageSize}`
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const decodeRows = (rows: ReadonlyArray<typeof eventLogRow.Type>) =>
|
|
201
|
+
Effect.forEach(
|
|
202
|
+
rows,
|
|
203
|
+
(row) =>
|
|
204
|
+
decodeEventJson(row.payload_json).pipe(
|
|
205
|
+
Effect.map((record) => ({
|
|
206
|
+
seq: row.event_seq,
|
|
207
|
+
record
|
|
208
|
+
}) satisfies EventLogEntry)
|
|
209
|
+
),
|
|
210
|
+
{ discard: false }
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const toPage = (
|
|
214
|
+
rows: ReadonlyArray<typeof eventLogRow.Type>
|
|
215
|
+
): Effect.Effect<
|
|
216
|
+
Option.Option<readonly [ReadonlyArray<EventLogEntry>, Option.Option<EventSeq>]>,
|
|
217
|
+
ParseResult.ParseError
|
|
218
|
+
> =>
|
|
219
|
+
rows.length === 0
|
|
220
|
+
? Effect.succeed(Option.none())
|
|
221
|
+
: decodeRows(rows).pipe(
|
|
222
|
+
Effect.map((records) => {
|
|
223
|
+
const lastSeq = rows[rows.length - 1]!.event_seq;
|
|
224
|
+
return Option.some([
|
|
225
|
+
records,
|
|
226
|
+
Option.some(lastSeq)
|
|
227
|
+
] as const);
|
|
228
|
+
})
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
return Stream.unfoldEffect(
|
|
232
|
+
Option.none<EventSeq>(),
|
|
233
|
+
(cursor) =>
|
|
234
|
+
Option.match(cursor, {
|
|
235
|
+
onNone: () => firstPage(undefined).pipe(Effect.flatMap(toPage)),
|
|
236
|
+
onSome: (after) => nextPage(after).pipe(Effect.flatMap(toPage))
|
|
237
|
+
})
|
|
238
|
+
).pipe(
|
|
239
|
+
Stream.mapConcat((records) => records),
|
|
240
|
+
Stream.mapError(toStoreIoError(store.root))
|
|
241
|
+
);
|
|
242
|
+
})
|
|
243
|
+
).pipe(Effect.mapError(toStoreIoError(store.root)))
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const clear = Effect.fn("StoreEventLog.clear")((store: StoreRef) =>
|
|
247
|
+
storeDb
|
|
248
|
+
.withClient(store, (client) =>
|
|
249
|
+
Effect.gen(function* () {
|
|
250
|
+
yield* client`DELETE FROM event_log`;
|
|
251
|
+
yield* client`DELETE FROM event_log_meta`;
|
|
252
|
+
}).pipe(Effect.asVoid)
|
|
253
|
+
)
|
|
254
|
+
.pipe(Effect.mapError(toStoreIoError(store.root)))
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const getLastEventSeq = Effect.fn("StoreEventLog.getLastEventSeq")(
|
|
258
|
+
(store: StoreRef) =>
|
|
259
|
+
storeDb
|
|
260
|
+
.withClient(store, (client) => {
|
|
261
|
+
const findLast = SqlSchema.findAll({
|
|
262
|
+
Request: Schema.Void,
|
|
263
|
+
Result: lastEventSeqRow,
|
|
264
|
+
execute: () =>
|
|
265
|
+
client`SELECT event_seq as value
|
|
266
|
+
FROM event_log
|
|
267
|
+
ORDER BY event_seq DESC
|
|
268
|
+
LIMIT 1`
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
return findLast(undefined).pipe(
|
|
272
|
+
Effect.flatMap((rows) =>
|
|
273
|
+
rows.length > 0
|
|
274
|
+
? Effect.succeed(Option.some(rows[0]!.value))
|
|
275
|
+
: Effect.succeed(Option.none<EventSeq>())
|
|
276
|
+
)
|
|
277
|
+
);
|
|
278
|
+
})
|
|
279
|
+
.pipe(Effect.mapError(toStoreIoError(store.root)))
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
return StoreEventLog.of({ stream, clear, getLastEventSeq });
|
|
283
|
+
})
|
|
284
|
+
);
|
|
285
|
+
}
|