@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,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DerivationEngine - Creates derived stores by filtering posts from a source store.
|
|
3
|
+
*
|
|
4
|
+
* ## Purpose and Use Cases
|
|
5
|
+
*
|
|
6
|
+
* The DerivationEngine enables creation of filtered views (derived stores) from source stores.
|
|
7
|
+
* Common use cases include:
|
|
8
|
+
* - Creating topic-specific feeds (e.g., "tech news", "sports")
|
|
9
|
+
* - Filtering by content criteria (e.g., posts with images, posts from specific authors)
|
|
10
|
+
* - Building trend-based feeds using time-windowed filters like Trending
|
|
11
|
+
* - Creating hierarchical store relationships where derived stores become sources for further derivation
|
|
12
|
+
*
|
|
13
|
+
* ## Evaluation Modes
|
|
14
|
+
*
|
|
15
|
+
* The engine supports two evaluation modes that determine when and how filters are applied:
|
|
16
|
+
*
|
|
17
|
+
* ### EventTime Mode
|
|
18
|
+
* - **When to use**: For pure filters that operate solely on event data
|
|
19
|
+
* - **Characteristics**: Processes historical events from the source store
|
|
20
|
+
* - **Limitations**: Only supports pure filters; effectful filters (Trending, HasValidLinks) are rejected
|
|
21
|
+
* - **Use case**: Static filtering based on post content, author, hashtags, etc.
|
|
22
|
+
*
|
|
23
|
+
* ### DeriveTime Mode
|
|
24
|
+
* - **When to use**: For effectful filters that require external data or time-based calculations
|
|
25
|
+
* - **Characteristics**: Supports filters like Trending (which needs current time context) and HasValidLinks (which may fetch external metadata)
|
|
26
|
+
* - **Use case**: Dynamic feeds that depend on current state or external conditions
|
|
27
|
+
*
|
|
28
|
+
* ## Incremental Derivation with Checkpoints
|
|
29
|
+
*
|
|
30
|
+
* The engine supports incremental derivation for efficiency:
|
|
31
|
+
*
|
|
32
|
+
* 1. **Checkpoint Persistence**: After each derivation run, the engine saves a checkpoint containing:
|
|
33
|
+
* - The last processed source event ID
|
|
34
|
+
* - Filter hash (to detect filter changes)
|
|
35
|
+
* - Evaluation mode
|
|
36
|
+
* - Event processing statistics
|
|
37
|
+
*
|
|
38
|
+
* 2. **Resumption**: Subsequent runs resume from the last checkpoint, processing only new events
|
|
39
|
+
* since the previous run
|
|
40
|
+
*
|
|
41
|
+
* 3. **Validation**: If filter or mode changes are detected, derivation fails unless `--reset` is used
|
|
42
|
+
*
|
|
43
|
+
* 4. **Periodic Checkpoints**: Checkpoints are saved periodically during long-running derivations based on
|
|
44
|
+
* settings (`checkpointEvery` events or `checkpointIntervalMs`)
|
|
45
|
+
*
|
|
46
|
+
* ## Event Replay and Propagation
|
|
47
|
+
*
|
|
48
|
+
* - **PostUpsert events**: Evaluated against the filter; matching posts are added to the target store
|
|
49
|
+
* - **PostDelete events**: All deletes are propagated to maintain consistency between source and derived stores
|
|
50
|
+
* - **URI deduplication**: Posts already in the target store are skipped to prevent duplicates
|
|
51
|
+
*
|
|
52
|
+
* ## Dependencies
|
|
53
|
+
*
|
|
54
|
+
* The DerivationEngine depends on:
|
|
55
|
+
* - StoreEventLog: Streams events from the source store
|
|
56
|
+
* - StoreIndex: Checks for existing posts and clears target store on reset
|
|
57
|
+
* - StoreCommitter: Appends matched posts and propagated deletes to the target store
|
|
58
|
+
* - FilterCompiler: Compiles and validates filter expressions
|
|
59
|
+
* - FilterRuntime: Evaluates filters against posts
|
|
60
|
+
* - ViewCheckpointStore: Persists and loads derivation checkpoints
|
|
61
|
+
* - LineageStore: Records derivation metadata and store relationships
|
|
62
|
+
* - DerivationSettings: Configures checkpoint frequency and intervals
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
import { Clock, Context, Effect, Exit, Layer, Option, ParseResult, Ref, Schema, Stream } from "effect";
|
|
66
|
+
import { StoreEventLog } from "./store-event-log.js";
|
|
67
|
+
import { StoreIndex } from "./store-index.js";
|
|
68
|
+
import { StoreCommitter } from "./store-commit.js";
|
|
69
|
+
import { FilterRuntime } from "./filter-runtime.js";
|
|
70
|
+
import { FilterCompiler } from "./filter-compiler.js";
|
|
71
|
+
import { ViewCheckpointStore } from "./view-checkpoint-store.js";
|
|
72
|
+
import { LineageStore } from "./lineage-store.js";
|
|
73
|
+
import { DerivationSettings } from "./derivation-settings.js";
|
|
74
|
+
import { FilterOutput, FilterSpec } from "../domain/store.js";
|
|
75
|
+
import type { StoreRef } from "../domain/store.js";
|
|
76
|
+
import { filterExprSignature, isEffectfulFilter } from "../domain/filter.js";
|
|
77
|
+
import type { FilterExpr } from "../domain/filter.js";
|
|
78
|
+
import {
|
|
79
|
+
DerivationCheckpoint,
|
|
80
|
+
DerivationError,
|
|
81
|
+
DerivationResult,
|
|
82
|
+
FilterEvaluationMode,
|
|
83
|
+
StoreLineage,
|
|
84
|
+
StoreSource
|
|
85
|
+
} from "../domain/derivation.js";
|
|
86
|
+
import { EventMeta, PostDelete, PostUpsert } from "../domain/events.js";
|
|
87
|
+
import type { EventLogEntry } from "../domain/events.js";
|
|
88
|
+
import { EventSeq, Timestamp } from "../domain/primitives.js";
|
|
89
|
+
import type { FilterCompileError, FilterEvalError, StoreIndexError, StoreIoError } from "../domain/errors.js";
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Options controlling the derivation process.
|
|
93
|
+
*/
|
|
94
|
+
export interface DerivationOptions {
|
|
95
|
+
/**
|
|
96
|
+
* The filter evaluation mode determining when filters are applied.
|
|
97
|
+
*
|
|
98
|
+
* - "EventTime": For pure filters only; processes historical events. Rejects effectful filters.
|
|
99
|
+
* - "DeriveTime": Supports effectful filters (Trending, HasValidLinks) that require external context.
|
|
100
|
+
*/
|
|
101
|
+
readonly mode: FilterEvaluationMode;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Whether to reset the derivation, clearing all target store data and checkpoints.
|
|
105
|
+
*
|
|
106
|
+
* When true:
|
|
107
|
+
* - Clears the target store's event log and index
|
|
108
|
+
* - Removes any existing checkpoint
|
|
109
|
+
* - Starts derivation from the beginning of the source store
|
|
110
|
+
*
|
|
111
|
+
* Use this when changing filters or recovering from inconsistent state.
|
|
112
|
+
*/
|
|
113
|
+
readonly reset: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Service for creating derived stores by filtering posts from a source store.
|
|
118
|
+
*
|
|
119
|
+
* The DerivationEngine provides the core functionality for store derivation, including:
|
|
120
|
+
* - Filter compilation and validation
|
|
121
|
+
* - Incremental event processing with checkpoint support
|
|
122
|
+
* - Post matching and delete propagation
|
|
123
|
+
* - Lineage tracking for derived stores
|
|
124
|
+
*
|
|
125
|
+
* Use this service to create filtered views that automatically stay in sync with their
|
|
126
|
+
* source stores through incremental updates.
|
|
127
|
+
*/
|
|
128
|
+
export class DerivationEngine extends Context.Tag("@skygent/DerivationEngine")<
|
|
129
|
+
DerivationEngine,
|
|
130
|
+
{
|
|
131
|
+
/**
|
|
132
|
+
* Derives a target store by applying a filter to posts from a source store.
|
|
133
|
+
*
|
|
134
|
+
* This method processes events from the source store, evaluates each post against
|
|
135
|
+
* the provided filter expression, and appends matching posts to the target store.
|
|
136
|
+
* Delete events are always propagated to maintain consistency.
|
|
137
|
+
*
|
|
138
|
+
* ## Process Overview
|
|
139
|
+
*
|
|
140
|
+
* 1. **Validation**: Ensures source and target stores are different; validates
|
|
141
|
+
* filter compatibility with the selected evaluation mode
|
|
142
|
+
*
|
|
143
|
+
* 2. **Filter Compilation**: Compiles the filter expression and creates an
|
|
144
|
+
* executable predicate
|
|
145
|
+
*
|
|
146
|
+
* 3. **Reset (optional)**: If `options.reset` is true, clears the target store
|
|
147
|
+
* and removes any existing checkpoint
|
|
148
|
+
*
|
|
149
|
+
* 4. **Checkpoint Loading**: Loads the last checkpoint (if exists and compatible)
|
|
150
|
+
* to resume from where derivation left off
|
|
151
|
+
*
|
|
152
|
+
* 5. **Event Streaming**: Streams events from the source store starting after
|
|
153
|
+
* the checkpoint position
|
|
154
|
+
*
|
|
155
|
+
* 6. **Event Processing**:
|
|
156
|
+
* - PostDelete: Always propagated to target store
|
|
157
|
+
* - PostUpsert: Filtered; matching posts are added (with URI deduplication)
|
|
158
|
+
*
|
|
159
|
+
* 7. **Checkpoint Saving**: Saves checkpoint periodically during processing and
|
|
160
|
+
* at completion, including on failure (if any progress was made)
|
|
161
|
+
*
|
|
162
|
+
* 8. **Lineage Recording**: Records derivation metadata for tracking store relationships
|
|
163
|
+
*
|
|
164
|
+
* @param sourceRef - Reference to the source store containing posts to filter
|
|
165
|
+
* @param targetRef - Reference to the target store where filtered posts will be stored
|
|
166
|
+
* @param filterExpr - The filter expression defining which posts to include
|
|
167
|
+
* @param options - Derivation options controlling mode and reset behavior
|
|
168
|
+
*
|
|
169
|
+
* @returns An effect that resolves to a {@link DerivationResult} containing:
|
|
170
|
+
* - `eventsProcessed`: Total events evaluated from the source
|
|
171
|
+
* - `eventsMatched`: Posts that matched the filter and were added
|
|
172
|
+
* - `eventsSkipped`: Posts that didn't match or were duplicates
|
|
173
|
+
* - `deletesPropagated`: Delete events forwarded to the target
|
|
174
|
+
* - `durationMs`: Time taken for the derivation process
|
|
175
|
+
*
|
|
176
|
+
* @throws {DerivationError} When:
|
|
177
|
+
* - Source and target stores are the same
|
|
178
|
+
* - EventTime mode is used with effectful filters
|
|
179
|
+
* - Target store has data but no checkpoint (inconsistent state)
|
|
180
|
+
* - Filter or mode has changed since last run (without reset)
|
|
181
|
+
* @throws {StoreIoError} When reading from source or writing to target fails
|
|
182
|
+
* @throws {StoreIndexError} When index operations fail
|
|
183
|
+
* @throws {FilterCompileError} When the filter expression cannot be compiled
|
|
184
|
+
* @throws {FilterEvalError} When filter evaluation fails at runtime
|
|
185
|
+
* @throws {ParseResult.ParseError} When timestamp parsing fails
|
|
186
|
+
*/
|
|
187
|
+
readonly derive: (
|
|
188
|
+
sourceRef: StoreRef,
|
|
189
|
+
targetRef: StoreRef,
|
|
190
|
+
filterExpr: FilterExpr,
|
|
191
|
+
options: DerivationOptions
|
|
192
|
+
) => Effect.Effect<
|
|
193
|
+
DerivationResult,
|
|
194
|
+
DerivationError | StoreIoError | StoreIndexError | FilterCompileError | FilterEvalError | ParseResult.ParseError
|
|
195
|
+
>;
|
|
196
|
+
}
|
|
197
|
+
>() {
|
|
198
|
+
static readonly layer = Layer.effect(
|
|
199
|
+
DerivationEngine,
|
|
200
|
+
Effect.gen(function* () {
|
|
201
|
+
const eventLog = yield* StoreEventLog;
|
|
202
|
+
const index = yield* StoreIndex;
|
|
203
|
+
const committer = yield* StoreCommitter;
|
|
204
|
+
const compiler = yield* FilterCompiler;
|
|
205
|
+
const runtime = yield* FilterRuntime;
|
|
206
|
+
const checkpoints = yield* ViewCheckpointStore;
|
|
207
|
+
const lineageStore = yield* LineageStore;
|
|
208
|
+
const settings = yield* DerivationSettings;
|
|
209
|
+
|
|
210
|
+
const derive = Effect.fn("DerivationEngine.derive")(
|
|
211
|
+
(sourceRef, targetRef, filterExpr, options) =>
|
|
212
|
+
Effect.gen(function* () {
|
|
213
|
+
if (sourceRef.name === targetRef.name) {
|
|
214
|
+
return yield* DerivationError.make({
|
|
215
|
+
reason: "Source and target stores must be different.",
|
|
216
|
+
sourceStore: sourceRef.name,
|
|
217
|
+
targetStore: targetRef.name
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
// EventTime mode guard: reject effectful filters
|
|
221
|
+
// Defense-in-depth: CLI validates for UX (user-friendly errors),
|
|
222
|
+
// service validates for safety (in case called from other contexts)
|
|
223
|
+
if (options.mode === "EventTime" && isEffectfulFilter(filterExpr)) {
|
|
224
|
+
return yield* DerivationError.make({
|
|
225
|
+
reason:
|
|
226
|
+
"EventTime mode only supports pure filters. Use --mode derive-time for Trending/HasValidLinks.",
|
|
227
|
+
sourceStore: sourceRef.name,
|
|
228
|
+
targetStore: targetRef.name
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const startTimeMillis = yield* Clock.currentTimeMillis;
|
|
233
|
+
|
|
234
|
+
// Filter compilation
|
|
235
|
+
const filterSpec = FilterSpec.make({
|
|
236
|
+
name: "derive",
|
|
237
|
+
expr: filterExpr,
|
|
238
|
+
output: FilterOutput.make({ path: "derive", json: false, markdown: false })
|
|
239
|
+
});
|
|
240
|
+
yield* compiler.compile(filterSpec);
|
|
241
|
+
const predicate = yield* runtime.evaluate(filterExpr);
|
|
242
|
+
|
|
243
|
+
// Reset logic: clear target store + checkpoint if requested
|
|
244
|
+
if (options.reset) {
|
|
245
|
+
yield* index.clear(targetRef);
|
|
246
|
+
yield* eventLog.clear(targetRef);
|
|
247
|
+
yield* checkpoints.remove(targetRef.name, sourceRef.name);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Checkpoint loading (skip when reset)
|
|
251
|
+
const checkpointOption = options.reset
|
|
252
|
+
? Option.none()
|
|
253
|
+
: yield* checkpoints.load(targetRef.name, sourceRef.name);
|
|
254
|
+
const filterHash = filterExprSignature(filterExpr);
|
|
255
|
+
|
|
256
|
+
if (!options.reset && Option.isNone(checkpointOption)) {
|
|
257
|
+
const lastTargetSeq = yield* eventLog.getLastEventSeq(targetRef);
|
|
258
|
+
if (Option.isSome(lastTargetSeq)) {
|
|
259
|
+
return yield* DerivationError.make({
|
|
260
|
+
reason:
|
|
261
|
+
"Target store has existing data but no derivation checkpoint. Use --reset to rebuild or choose a new target store.",
|
|
262
|
+
sourceStore: sourceRef.name,
|
|
263
|
+
targetStore: targetRef.name
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (!options.reset && Option.isSome(checkpointOption)) {
|
|
269
|
+
const checkpoint = checkpointOption.value;
|
|
270
|
+
if (checkpoint.filterHash !== filterHash || checkpoint.evaluationMode !== options.mode) {
|
|
271
|
+
return yield* DerivationError.make({
|
|
272
|
+
reason:
|
|
273
|
+
"Derivation settings have changed since last run. Use --reset to rebuild or choose a new target store.",
|
|
274
|
+
sourceStore: sourceRef.name,
|
|
275
|
+
targetStore: targetRef.name
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Check if checkpoint is valid (matching filter and mode)
|
|
281
|
+
// Schema.optional makes lastSourceEventSeq EventSeq | undefined
|
|
282
|
+
const startAfter: Option.Option<EventSeq> = Option.flatMap(checkpointOption, (cp) => {
|
|
283
|
+
if (cp.filterHash === filterHash && cp.evaluationMode === options.mode) {
|
|
284
|
+
return Option.fromNullable(cp.lastSourceEventSeq);
|
|
285
|
+
}
|
|
286
|
+
return Option.none();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
type DerivationState = {
|
|
290
|
+
readonly processed: number;
|
|
291
|
+
readonly matched: number;
|
|
292
|
+
readonly skipped: number;
|
|
293
|
+
readonly deletes: number;
|
|
294
|
+
readonly lastSourceSeq: Option.Option<EventSeq>;
|
|
295
|
+
readonly lastCheckpointAt: number;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const saveCheckpointFromState = (state: DerivationState, now: number) =>
|
|
299
|
+
Schema.decodeUnknown(Timestamp)(new Date(now).toISOString()).pipe(
|
|
300
|
+
Effect.flatMap((timestamp) => {
|
|
301
|
+
const checkpoint = DerivationCheckpoint.make({
|
|
302
|
+
viewName: targetRef.name,
|
|
303
|
+
sourceStore: sourceRef.name,
|
|
304
|
+
targetStore: targetRef.name,
|
|
305
|
+
filterHash,
|
|
306
|
+
evaluationMode: options.mode,
|
|
307
|
+
lastSourceEventSeq: Option.getOrUndefined(state.lastSourceSeq),
|
|
308
|
+
eventsProcessed: state.processed,
|
|
309
|
+
eventsMatched: state.matched,
|
|
310
|
+
deletesPropagated: state.deletes,
|
|
311
|
+
updatedAt: timestamp
|
|
312
|
+
});
|
|
313
|
+
return checkpoints.save(checkpoint);
|
|
314
|
+
})
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
const shouldCheckpoint = (state: DerivationState, now: number) =>
|
|
318
|
+
state.processed > 0 &&
|
|
319
|
+
(state.processed % settings.checkpointEvery === 0 ||
|
|
320
|
+
(settings.checkpointIntervalMs > 0 &&
|
|
321
|
+
now - state.lastCheckpointAt >= settings.checkpointIntervalMs));
|
|
322
|
+
|
|
323
|
+
const initialState: DerivationState = {
|
|
324
|
+
processed: 0,
|
|
325
|
+
matched: 0,
|
|
326
|
+
skipped: 0,
|
|
327
|
+
deletes: 0,
|
|
328
|
+
lastSourceSeq: Option.none<EventSeq>(),
|
|
329
|
+
lastCheckpointAt: startTimeMillis
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const stateRef = yield* Ref.make(initialState);
|
|
333
|
+
|
|
334
|
+
const finalizeState = (nextState: DerivationState) =>
|
|
335
|
+
Effect.gen(function* () {
|
|
336
|
+
const now = yield* Clock.currentTimeMillis;
|
|
337
|
+
if (shouldCheckpoint(nextState, now)) {
|
|
338
|
+
yield* saveCheckpointFromState(nextState, now);
|
|
339
|
+
const updated = { ...nextState, lastCheckpointAt: now };
|
|
340
|
+
yield* Ref.set(stateRef, updated);
|
|
341
|
+
return updated;
|
|
342
|
+
}
|
|
343
|
+
yield* Ref.set(stateRef, nextState);
|
|
344
|
+
return nextState;
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const processRecord = (state: DerivationState, entry: EventLogEntry) =>
|
|
348
|
+
Effect.gen(function* () {
|
|
349
|
+
const event = entry.record.event;
|
|
350
|
+
const nextLast = Option.some(entry.seq);
|
|
351
|
+
|
|
352
|
+
const baseState: DerivationState = {
|
|
353
|
+
processed: state.processed + 1,
|
|
354
|
+
matched: state.matched,
|
|
355
|
+
skipped: state.skipped,
|
|
356
|
+
deletes: state.deletes,
|
|
357
|
+
lastSourceSeq: nextLast,
|
|
358
|
+
lastCheckpointAt: state.lastCheckpointAt
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
// PostDelete: propagate ALL unfiltered
|
|
362
|
+
if (event._tag === "PostDelete") {
|
|
363
|
+
const derivedMeta = EventMeta.make({
|
|
364
|
+
...event.meta,
|
|
365
|
+
sourceStore: sourceRef.name
|
|
366
|
+
});
|
|
367
|
+
const derivedEvent = PostDelete.make({ ...event, meta: derivedMeta });
|
|
368
|
+
yield* committer.appendDelete(targetRef, derivedEvent);
|
|
369
|
+
return yield* finalizeState({
|
|
370
|
+
...baseState,
|
|
371
|
+
deletes: baseState.deletes + 1
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// URI deduplication: check if post already exists
|
|
376
|
+
const exists = yield* index.hasUri(targetRef, event.post.uri);
|
|
377
|
+
if (exists) {
|
|
378
|
+
return yield* finalizeState({
|
|
379
|
+
...baseState,
|
|
380
|
+
skipped: baseState.skipped + 1
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Filter evaluation: failures propagate to caller
|
|
385
|
+
const matches = yield* predicate(event.post);
|
|
386
|
+
|
|
387
|
+
if (matches) {
|
|
388
|
+
const derivedMeta = EventMeta.make({
|
|
389
|
+
...event.meta,
|
|
390
|
+
sourceStore: sourceRef.name
|
|
391
|
+
});
|
|
392
|
+
const derivedEvent = PostUpsert.make({ post: event.post, meta: derivedMeta });
|
|
393
|
+
const stored = yield* committer.appendUpsertIfMissing(
|
|
394
|
+
targetRef,
|
|
395
|
+
derivedEvent
|
|
396
|
+
);
|
|
397
|
+
return yield* finalizeState(
|
|
398
|
+
Option.match(stored, {
|
|
399
|
+
onNone: () => ({
|
|
400
|
+
...baseState,
|
|
401
|
+
skipped: baseState.skipped + 1
|
|
402
|
+
}),
|
|
403
|
+
onSome: () => ({
|
|
404
|
+
...baseState,
|
|
405
|
+
matched: baseState.matched + 1
|
|
406
|
+
})
|
|
407
|
+
})
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return yield* finalizeState({
|
|
412
|
+
...baseState,
|
|
413
|
+
skipped: baseState.skipped + 1
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// Event streaming with runFoldEffect + periodic checkpoints
|
|
418
|
+
const fold = eventLog.stream(sourceRef).pipe(
|
|
419
|
+
Stream.filter((entry) =>
|
|
420
|
+
Option.match(startAfter, {
|
|
421
|
+
onNone: () => true,
|
|
422
|
+
onSome: (seq: EventSeq) => entry.seq > seq
|
|
423
|
+
})
|
|
424
|
+
),
|
|
425
|
+
Stream.runFoldEffect(initialState, processRecord)
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
const result = yield* fold.pipe(
|
|
429
|
+
Effect.onExit((exit) =>
|
|
430
|
+
Exit.isFailure(exit)
|
|
431
|
+
? Ref.get(stateRef).pipe(
|
|
432
|
+
Effect.flatMap((state) =>
|
|
433
|
+
state.processed > 0
|
|
434
|
+
? Clock.currentTimeMillis.pipe(
|
|
435
|
+
Effect.flatMap((now) =>
|
|
436
|
+
saveCheckpointFromState(state, now)
|
|
437
|
+
)
|
|
438
|
+
)
|
|
439
|
+
: Effect.void
|
|
440
|
+
),
|
|
441
|
+
Effect.catchAll(() => Effect.void)
|
|
442
|
+
)
|
|
443
|
+
: Effect.void
|
|
444
|
+
)
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
// Timestamp creation using Clock
|
|
448
|
+
const endTimeMillis = yield* Clock.currentTimeMillis;
|
|
449
|
+
const timestamp = yield* Schema.decodeUnknown(Timestamp)(
|
|
450
|
+
new Date(endTimeMillis).toISOString()
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
// Checkpoint saving: always record materialization attempt
|
|
454
|
+
const lastSourceSeqOption = Option.isSome(result.lastSourceSeq)
|
|
455
|
+
? result.lastSourceSeq
|
|
456
|
+
: yield* eventLog.getLastEventSeq(sourceRef);
|
|
457
|
+
const checkpoint = DerivationCheckpoint.make({
|
|
458
|
+
viewName: targetRef.name,
|
|
459
|
+
sourceStore: sourceRef.name,
|
|
460
|
+
targetStore: targetRef.name,
|
|
461
|
+
filterHash,
|
|
462
|
+
evaluationMode: options.mode,
|
|
463
|
+
lastSourceEventSeq: Option.getOrUndefined(lastSourceSeqOption),
|
|
464
|
+
eventsProcessed: result.processed,
|
|
465
|
+
eventsMatched: result.matched,
|
|
466
|
+
deletesPropagated: result.deletes,
|
|
467
|
+
updatedAt: timestamp
|
|
468
|
+
});
|
|
469
|
+
yield* checkpoints.save(checkpoint);
|
|
470
|
+
|
|
471
|
+
// Lineage saving: record derivation metadata
|
|
472
|
+
const lineage = StoreLineage.make({
|
|
473
|
+
storeName: targetRef.name,
|
|
474
|
+
isDerived: true,
|
|
475
|
+
sources: [
|
|
476
|
+
StoreSource.make({
|
|
477
|
+
storeName: sourceRef.name,
|
|
478
|
+
filter: filterExpr,
|
|
479
|
+
filterHash,
|
|
480
|
+
evaluationMode: options.mode,
|
|
481
|
+
derivedAt: timestamp
|
|
482
|
+
})
|
|
483
|
+
],
|
|
484
|
+
updatedAt: timestamp
|
|
485
|
+
});
|
|
486
|
+
yield* lineageStore.save(lineage);
|
|
487
|
+
|
|
488
|
+
// Return DerivationResult
|
|
489
|
+
return DerivationResult.make({
|
|
490
|
+
eventsProcessed: result.processed,
|
|
491
|
+
eventsMatched: result.matched,
|
|
492
|
+
eventsSkipped: result.skipped,
|
|
493
|
+
deletesPropagated: result.deletes,
|
|
494
|
+
durationMs: endTimeMillis - startTimeMillis
|
|
495
|
+
});
|
|
496
|
+
})
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
return DerivationEngine.of({ derive });
|
|
500
|
+
})
|
|
501
|
+
);
|
|
502
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Config, Context, Effect, Layer, Option } from "effect";
|
|
2
|
+
import { pickDefined, validatePositive, validateNonNegative } from "./shared.js";
|
|
3
|
+
|
|
4
|
+
export type DerivationSettingsValue = {
|
|
5
|
+
readonly checkpointEvery: number;
|
|
6
|
+
readonly checkpointIntervalMs: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type DerivationSettingsOverridesValue = Partial<DerivationSettingsValue>;
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
export class DerivationSettingsOverrides extends Context.Tag(
|
|
14
|
+
"@skygent/DerivationSettingsOverrides"
|
|
15
|
+
)<DerivationSettingsOverrides, DerivationSettingsOverridesValue>() {
|
|
16
|
+
static readonly layer = Layer.succeed(DerivationSettingsOverrides, {});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class DerivationSettings extends Context.Tag("@skygent/DerivationSettings")<
|
|
20
|
+
DerivationSettings,
|
|
21
|
+
DerivationSettingsValue
|
|
22
|
+
>() {
|
|
23
|
+
static readonly layer = Layer.effect(
|
|
24
|
+
DerivationSettings,
|
|
25
|
+
Effect.gen(function* () {
|
|
26
|
+
const overrides = yield* Effect.serviceOption(DerivationSettingsOverrides).pipe(
|
|
27
|
+
Effect.map(Option.getOrElse(() => ({} as DerivationSettingsOverridesValue)))
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const checkpointEvery = yield* Config.integer(
|
|
31
|
+
"SKYGENT_DERIVATION_CHECKPOINT_EVERY"
|
|
32
|
+
).pipe(Config.withDefault(100));
|
|
33
|
+
const checkpointIntervalMs = yield* Config.integer(
|
|
34
|
+
"SKYGENT_DERIVATION_CHECKPOINT_INTERVAL_MS"
|
|
35
|
+
).pipe(Config.withDefault(5000));
|
|
36
|
+
|
|
37
|
+
const merged = {
|
|
38
|
+
checkpointEvery,
|
|
39
|
+
checkpointIntervalMs,
|
|
40
|
+
...pickDefined(overrides as Record<string, unknown>)
|
|
41
|
+
} as DerivationSettingsValue;
|
|
42
|
+
|
|
43
|
+
const checkpointEveryError = validatePositive(
|
|
44
|
+
"SKYGENT_DERIVATION_CHECKPOINT_EVERY",
|
|
45
|
+
merged.checkpointEvery
|
|
46
|
+
);
|
|
47
|
+
if (checkpointEveryError) {
|
|
48
|
+
return yield* checkpointEveryError;
|
|
49
|
+
}
|
|
50
|
+
const checkpointIntervalError = validateNonNegative(
|
|
51
|
+
"SKYGENT_DERIVATION_CHECKPOINT_INTERVAL_MS",
|
|
52
|
+
merged.checkpointIntervalMs
|
|
53
|
+
);
|
|
54
|
+
if (checkpointIntervalError) {
|
|
55
|
+
return yield* checkpointIntervalError;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return DerivationSettings.of(merged);
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Option } from "effect";
|
|
2
|
+
import { ViewCheckpointStore } from "./view-checkpoint-store.js";
|
|
3
|
+
import { StoreEventLog } from "./store-event-log.js";
|
|
4
|
+
import { StoreManager } from "./store-manager.js";
|
|
5
|
+
import { StoreName } from "../domain/primitives.js";
|
|
6
|
+
import { StoreIoError } from "../domain/errors.js";
|
|
7
|
+
|
|
8
|
+
export class DerivationValidator extends Context.Tag(
|
|
9
|
+
"@skygent/DerivationValidator"
|
|
10
|
+
)<
|
|
11
|
+
DerivationValidator,
|
|
12
|
+
{
|
|
13
|
+
readonly isStale: (
|
|
14
|
+
viewName: StoreName,
|
|
15
|
+
sourceName: StoreName
|
|
16
|
+
) => Effect.Effect<boolean, StoreIoError>;
|
|
17
|
+
}
|
|
18
|
+
>() {
|
|
19
|
+
static readonly layer = Layer.effect(
|
|
20
|
+
DerivationValidator,
|
|
21
|
+
Effect.gen(function* () {
|
|
22
|
+
const checkpoints = yield* ViewCheckpointStore;
|
|
23
|
+
const eventLog = yield* StoreEventLog;
|
|
24
|
+
const storeManager = yield* StoreManager;
|
|
25
|
+
|
|
26
|
+
const isStale = Effect.fn("DerivationValidator.isStale")(
|
|
27
|
+
(viewName: StoreName, sourceName: StoreName) =>
|
|
28
|
+
Effect.gen(function* () {
|
|
29
|
+
const checkpointOption = yield* checkpoints.load(viewName, sourceName);
|
|
30
|
+
|
|
31
|
+
if (Option.isNone(checkpointOption)) {
|
|
32
|
+
return true; // Never materialized
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const checkpoint = checkpointOption.value;
|
|
36
|
+
|
|
37
|
+
// O(1) optimization: use getLastEventSeq instead of streaming
|
|
38
|
+
const sourceRefOption = yield* storeManager.getStore(sourceName);
|
|
39
|
+
if (Option.isNone(sourceRefOption)) {
|
|
40
|
+
return false; // Source store deleted
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const sourceRef = sourceRefOption.value;
|
|
44
|
+
const lastSourceSeqOption = yield* eventLog.getLastEventSeq(sourceRef);
|
|
45
|
+
|
|
46
|
+
if (Option.isNone(lastSourceSeqOption)) {
|
|
47
|
+
return false; // Source has no events
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const lastSourceSeq = lastSourceSeqOption.value;
|
|
51
|
+
|
|
52
|
+
// Convert checkpoint.lastSourceEventSeq from EventSeq | undefined to Option<EventSeq>
|
|
53
|
+
const checkpointLastSeqOption = Option.fromNullable(
|
|
54
|
+
checkpoint.lastSourceEventSeq
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (Option.isNone(checkpointLastSeqOption)) {
|
|
58
|
+
return true; // Checkpoint never recorded a last event
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return lastSourceSeq > checkpointLastSeqOption.value;
|
|
62
|
+
})
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return DerivationValidator.of({ isStale });
|
|
66
|
+
})
|
|
67
|
+
);
|
|
68
|
+
}
|