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