@pattern-stack/codegen 0.12.2 → 0.13.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pattern-stack/codegen",
3
- "version": "0.12.2",
3
+ "version": "0.13.1",
4
4
  "description": "Entity-driven code generation for full-stack TypeScript applications",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -63,8 +63,43 @@ export {
63
63
  export type {
64
64
  IEntityChangeSourceRegistry,
65
65
  IChangeSource,
66
+ IntegrationSubscriptionView,
66
67
  } from './integration';
67
68
 
69
+ // Integration — IncrementalRead read primitive (RFC-0003 R1). Re-exported here
70
+ // so surface packages can author enumerate/hydrate adapters across the package
71
+ // boundary via @pattern-stack/codegen/subsystems. ResolvedFilter rides along:
72
+ // the R3 read-primitive scaffold imports it for its static `detection.filters`
73
+ // const and the `F = ResolvedFilter[]` type parameter.
74
+ export {
75
+ CURSOR_DIVISIBILITY,
76
+ IncrementalReadBase,
77
+ isDivisibleCursor,
78
+ mapConcurrent,
79
+ } from './integration';
80
+ export type {
81
+ IncrementalRead,
82
+ RandomRead,
83
+ ReadContext,
84
+ ReadMode,
85
+ ReadRequest,
86
+ Ref,
87
+ ResolvedFilter,
88
+ SourcedRecord,
89
+ } from './integration';
90
+
91
+ // Integration — assembly emission (RFC-0002). The generated per-entity sink
92
+ // imports `IIntegrationSink`; the generated per-entity assembly module imports
93
+ // `ExecuteIntegrationUseCase` + `INTEGRATION_CHANGE_SOURCE` + `INTEGRATION_SINK`
94
+ // — all from `@pattern-stack/codegen/subsystems`. Forwarded here so the emitted
95
+ // `src/integrations/**` tree resolves them across the package boundary.
96
+ export {
97
+ ExecuteIntegrationUseCase,
98
+ INTEGRATION_CHANGE_SOURCE,
99
+ INTEGRATION_SINK,
100
+ } from './integration';
101
+ export type { IIntegrationSink } from './integration';
102
+
68
103
  // Auth
69
104
  export {
70
105
  ENCRYPTION_KEY,
@@ -85,15 +85,70 @@ const EventIdCursorSchema = z.object({
85
85
  field: z.string().min(1),
86
86
  });
87
87
 
88
+ /**
89
+ * Gmail `historyId` (RFC-0003 §3) — an opaque, atomic vendor token. The next
90
+ * watermark only exists at end-of-walk; there is no resumable mid-walk value.
91
+ * `field` is metadata for codegen/adapters (the response key the token lives on).
92
+ */
93
+ const HistoryIdCursorSchema = z.object({
94
+ kind: z.literal('historyId'),
95
+ field: z.string().min(1),
96
+ });
97
+
98
+ /**
99
+ * Google Calendar `syncToken` (RFC-0003 §3) — an opaque, atomic sync token,
100
+ * same divisibility profile as `historyId`.
101
+ */
102
+ const SyncTokenCursorSchema = z.object({
103
+ kind: z.literal('syncToken'),
104
+ field: z.string().min(1),
105
+ });
106
+
88
107
  export const CursorStrategySchema = z.discriminatedUnion('kind', [
89
108
  SystemModstampCursorSchema,
90
109
  ReplayIdCursorSchema,
91
110
  TimestampCursorSchema,
92
111
  EventIdCursorSchema,
112
+ HistoryIdCursorSchema,
113
+ SyncTokenCursorSchema,
93
114
  ]);
94
115
 
95
116
  export type CursorStrategy = z.infer<typeof CursorStrategySchema>;
96
117
 
118
+ // ============================================================================
119
+ // Cursor divisibility (RFC-0003 §3)
120
+ // ============================================================================
121
+
122
+ /**
123
+ * Whether a cursor strategy is *divisible* — a property of the strategy, not
124
+ * the read primitive. Divisible cursors are sortable/monotonic watermarks whose
125
+ * value is meaningful AS OF any single record (HubSpot `systemModstamp`, a
126
+ * `timestamp` field, a Salesforce CDC `replayId`); the read primitive may
127
+ * checkpoint per-ref mid-walk, so a crash resumes from the last delivered ref.
128
+ *
129
+ * Atomic cursors are opaque vendor tokens (Gmail `historyId`, Calendar
130
+ * `syncToken`, a generic `eventId`) whose next value only exists at end-of-walk.
131
+ * The primitive must withhold per-ref cursors and emit the token only at a safe
132
+ * boundary, so an interrupted run never persists an unresumable mid-walk token
133
+ * (it resumes all-or-nothing from the prior token — see `IncrementalReadBase`).
134
+ *
135
+ * `eventId` is classified atomic conservatively: a generic opaque id is treated
136
+ * all-or-nothing unless a concrete strategy proves it monotonically resumable.
137
+ */
138
+ export const CURSOR_DIVISIBILITY: Readonly<Record<CursorStrategy['kind'], boolean>> = {
139
+ systemModstamp: true,
140
+ timestamp: true,
141
+ replayId: true,
142
+ eventId: false,
143
+ historyId: false,
144
+ syncToken: false,
145
+ };
146
+
147
+ /** Predicate form of {@link CURSOR_DIVISIBILITY}. */
148
+ export function isDivisibleCursor(kind: CursorStrategy['kind']): boolean {
149
+ return CURSOR_DIVISIBILITY[kind];
150
+ }
151
+
97
152
  // ============================================================================
98
153
  // Mode-specific blocks
99
154
  // ============================================================================
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Integration subsystem — `IncrementalRead<T, F>` + `RandomRead<T>` capability
3
+ * and the providing `IncrementalReadBase<T, F, M>` (RFC-0003 R1).
4
+ *
5
+ * The universal read primitive. Where `IChangeSource.listChanges` is the
6
+ * *transport* contract (stream `Change<T>`, orchestrator owns cursor lifecycle),
7
+ * this base owns *how the body that produces those changes is written* — the
8
+ * level the bare `changeSources = {}` author-seam left unstructured.
9
+ *
10
+ * The read decomposes into two composable verbs the adapter supplies:
11
+ *
12
+ * - `enumerate(mode, filter) → AsyncIterable<Ref<M>[]>` — the cheap delta /
13
+ * backfill walk; streams pages of lightweight refs (id + per-ref cursor +
14
+ * filterable metadata). LAZY: pull-driven so hydrate backpressures it.
15
+ * - `hydrate(ids) → Map<id, raw>` — the expensive fetch-by-id, batched; where
16
+ * bounded concurrency / a vendor `/batch` endpoint lives. Keyed and
17
+ * miss-tolerant (a mid-run 404 cannot shift alignment).
18
+ * - `toCanonical(raw) → T | null` — provider payload → canonical record.
19
+ *
20
+ * The base PROVIDES the orchestration: drain enumerate, **filter before
21
+ * hydrate** (structural — an adapter physically cannot hydrate-then-discard),
22
+ * keyed pairing, per-ref cursor emission, and the `IChangeSource.listChanges`
23
+ * adaptation. It also provides `RandomRead.get()` for free as
24
+ * `toCanonical ∘ hydrate([id])` — so every incremental adapter is a
25
+ * single-record reader (the "list cheaply, fill on click" query-surface need)
26
+ * without extra code.
27
+ *
28
+ * The shape generalizes dealbrain's proven HubSpot `listSince` (streams, pushes
29
+ * the filter server-side, carries a per-record cursor) to vendors whose list
30
+ * returns id-stubs (Gmail) or nested resources (Meet). Calendar-style
31
+ * full-object lists override `hydrate` as a passthrough.
32
+ *
33
+ * See RFC-0003 (Track D round-3), ADR-033 (`detection:` config), and
34
+ * `poll-change-source.ts` (the sibling primitive this composes beside).
35
+ */
36
+
37
+ import type {
38
+ Change,
39
+ ChangeSource,
40
+ IChangeSource,
41
+ IntegrationSubscriptionView,
42
+ } from './integration-change-source.protocol';
43
+
44
+ // ============================================================================
45
+ // Capability shapes
46
+ // ============================================================================
47
+
48
+ /**
49
+ * How a read walks the upstream. Modes are values, not verbs (swe-brain
50
+ * ADR-0003: mode ≠ capability) — one `read()` verb dispatches on these.
51
+ *
52
+ * - `delta` — incremental walk from a persisted cursor.
53
+ * - `full` — cursorless backfill (optionally bounded by `since`).
54
+ * - `reconcile` — gap-repair: re-fetch a known id set the cursor skipped
55
+ * (the repair pass for the silent-tail-skip + #414-style
56
+ * multi-provider divergence).
57
+ */
58
+ export type ReadMode =
59
+ | { readonly kind: 'delta'; readonly cursor: unknown }
60
+ | { readonly kind: 'full'; readonly since?: Date }
61
+ | { readonly kind: 'reconcile'; readonly knownIds: readonly string[] };
62
+
63
+ /**
64
+ * A cheap ref from the enumerate pass: identity + per-ref cursor + metadata to
65
+ * filter or display on. `cursor` is the position AS OF this ref — see
66
+ * `IncrementalReadBase.cursorDivisible` (R2) for when it may be checkpointed
67
+ * mid-walk versus withheld until a safe boundary.
68
+ */
69
+ export interface Ref<M = Record<string, unknown>> {
70
+ readonly externalId: string;
71
+ readonly cursor: unknown;
72
+ readonly meta: M;
73
+ }
74
+
75
+ /** A read request: the mode, an optional adapter-typed filter, and page size. */
76
+ export interface ReadRequest<F = unknown> {
77
+ readonly mode: ReadMode;
78
+ readonly filter?: F;
79
+ readonly pageSize?: number;
80
+ }
81
+
82
+ /**
83
+ * Per-run context threaded from `listChanges` into the vendor read body (R5).
84
+ *
85
+ * Carries the `subscription` framing the run so `enumerate`/`hydrate` can resolve
86
+ * **per-connection credentials** (and raw-landing keys) from
87
+ * `subscription.externalRef` — the gap a multi-account consumer surfaced: a
88
+ * singleton change source cannot hold connection-scoped auth, and before R5 the
89
+ * base forwarded the subscription only into `filterFor`, never into the fetch.
90
+ *
91
+ * Optional throughout (the core contract): a direct `read()` / `get()` call — the
92
+ * query surface's "fill one record on click" — may omit it. An adapter that needs
93
+ * per-connection auth reads `ctx?.subscription?.externalRef` and asserts its
94
+ * presence; a provider-level-auth adapter ignores it.
95
+ */
96
+ export interface ReadContext {
97
+ /** The subscription framing this run; `externalRef` is the upstream scope /
98
+ * connection id the adapter resolves credentials + raw-landing keys from. */
99
+ readonly subscription?: IntegrationSubscriptionView;
100
+ }
101
+
102
+ /**
103
+ * The `read()`-side envelope: canonical record + the raw vendor payload it came
104
+ * from + the originating external id + the per-ref cursor.
105
+ *
106
+ * Distinct from the runtime's transport envelope `Change<T>`
107
+ * (operation/externalId/cursor/source). The relationship is one-directional:
108
+ * `listChanges()` adapts `read()` → `Change<T>` (dropping `raw`, stamping
109
+ * `operation`). `read()` keeps `raw` and `externalId` so a query surface can
110
+ * re-project without a second fetch.
111
+ */
112
+ export interface SourcedRecord<T> {
113
+ readonly externalId: string;
114
+ readonly record: T;
115
+ readonly raw: unknown;
116
+ readonly cursor: unknown;
117
+ }
118
+
119
+ /**
120
+ * The universal read capability — one public verb that streams. Filtering,
121
+ * hydration, and cursor emission are the providing base's concern.
122
+ */
123
+ export interface IncrementalRead<T, F = unknown> {
124
+ read(req: ReadRequest<F>, ctx?: ReadContext): AsyncIterable<SourcedRecord<T>>;
125
+ }
126
+
127
+ /**
128
+ * Single-record read by external id — the "fill on click" atom. Provided for
129
+ * free by `IncrementalReadBase` (composes `hydrate` + `toCanonical`); declared
130
+ * as its own capability so consumers can depend on it without the streaming
131
+ * surface.
132
+ */
133
+ export interface RandomRead<T> {
134
+ get(id: string, ctx?: ReadContext): Promise<T | null>;
135
+ }
136
+
137
+ // ============================================================================
138
+ // Bounded-parallel map helper
139
+ // ============================================================================
140
+
141
+ /**
142
+ * Map `ids` through `fn` with at most `limit` concurrent in-flight calls,
143
+ * collecting results keyed by id. The workhorse for writing a batched
144
+ * `hydrate` over a single-id fetch without serial N+1 latency.
145
+ */
146
+ export async function mapConcurrent<R>(
147
+ ids: readonly string[],
148
+ fn: (id: string) => Promise<R>,
149
+ limit: number,
150
+ ): Promise<Map<string, R>> {
151
+ const out = new Map<string, R>();
152
+ if (ids.length === 0) return out;
153
+ const width = Math.max(1, Math.min(limit, ids.length));
154
+ let next = 0;
155
+ const worker = async (): Promise<void> => {
156
+ while (next < ids.length) {
157
+ const idx = next++;
158
+ const id = ids[idx]!;
159
+ out.set(id, await fn(id));
160
+ }
161
+ };
162
+ await Promise.all(Array.from({ length: width }, worker));
163
+ return out;
164
+ }
165
+
166
+ // ============================================================================
167
+ // IncrementalReadBase
168
+ // ============================================================================
169
+
170
+ /**
171
+ * Providing base for the read capability. A subclass fills exactly three vendor
172
+ * methods — `enumerate`, `hydrate`, `toCanonical` — and gets a streaming,
173
+ * filter-before-hydrate, miss-tolerant `IncrementalRead<T, F>` +
174
+ * `IChangeSource<T>` + `RandomRead<T>`.
175
+ *
176
+ * Type params: `T` canonical record, `F` adapter-typed filter, `M` per-ref
177
+ * metadata (defaults to an untyped bag — surface packages supply a domain `M`).
178
+ */
179
+ export abstract class IncrementalReadBase<T, F = unknown, M = Record<string, unknown>>
180
+ implements IncrementalRead<T, F>, IChangeSource<T>, RandomRead<T>
181
+ {
182
+ /** Human label for run logs — e.g. `'google-mail-email'`. */
183
+ abstract readonly label: string;
184
+
185
+ /**
186
+ * Whether the vendor takes the request predicate server-side. Declared, not
187
+ * enforced here — surfaced into the emission manifest (R3) so the falsifier
188
+ * suite (R4) can record which adapters filter post-hydrate. `false` is the
189
+ * honest floor (e.g. Gmail without `q=`), handled via `matchesRecord`.
190
+ */
191
+ protected readonly filterPushdown: boolean = false;
192
+
193
+ /** Max concurrent in-flight calls for a `mapConcurrent`-built `hydrate`. */
194
+ protected readonly hydrateConcurrency: number = 10;
195
+
196
+ /** `Change<T>.source` provenance stamped by `listChanges`. */
197
+ protected readonly changeSource: ChangeSource = 'poll';
198
+
199
+ /**
200
+ * Whether this source's cursor strategy is divisible (RFC-0003 §3). When
201
+ * `true` (default — sortable watermarks like `systemModstamp`/`timestamp`/
202
+ * `replayId`), `listChanges` emits each record's per-ref cursor, so the
203
+ * orchestrator may checkpoint mid-walk and a crash resumes from the last
204
+ * delivered ref.
205
+ *
206
+ * When `false` (atomic opaque tokens — Gmail `historyId`, Calendar
207
+ * `syncToken`), `listChanges` WITHHOLDS per-ref cursors and emits the
208
+ * end-of-walk token only on the final record, so the orchestrator's
209
+ * persist-last-yielded lifecycle can never persist an unresumable mid-walk
210
+ * token. The cost is blast-radius: an interrupted atomic run resumes
211
+ * all-or-nothing from the prior persisted token. For atomic *backfills* that
212
+ * radius is the whole enumerate walk — bound it with `ReadRequest.pageSize`
213
+ * (smaller pages ⇒ shorter walks per run). Per-page atomic checkpointing is a
214
+ * future refinement; R2 gates at end-of-walk.
215
+ *
216
+ * Codegen (R3) sets this from the strategy kind via `isDivisibleCursor`.
217
+ */
218
+ protected readonly cursorDivisible: boolean = true;
219
+
220
+ // ---- SUPPLIED by the adapter (the irreducible vendor seam) ----
221
+
222
+ /**
223
+ * The cheap walk. Streams pages of refs; LAZY so `hydrate` backpressures it
224
+ * (one page hydrated before the next is pulled). Mode-dispatch lives here:
225
+ * `delta` resumes from `mode.cursor`, `full` walks from the top, `reconcile`
226
+ * re-fetches `mode.knownIds`.
227
+ *
228
+ * `pageSize` (from `ReadRequest`) is the adapter's requested vendor page size
229
+ * — also the atomic-cursor backfill blast-radius bound (§ `cursorDivisible`).
230
+ * Honor it as a hint; vendors that cap page size clamp it.
231
+ *
232
+ * `ctx?.subscription` (R5) carries the run's subscription, so a per-connection
233
+ * adapter resolves credentials / upstream scope from `externalRef` here; absent
234
+ * on a direct `read()` with no run subscription.
235
+ */
236
+ protected abstract enumerate(
237
+ mode: ReadMode,
238
+ filter?: F,
239
+ pageSize?: number,
240
+ ctx?: ReadContext,
241
+ ): AsyncIterable<Ref<M>[]>;
242
+
243
+ /**
244
+ * Fetch raw payloads for `ids`, keyed by id. MUST be miss-tolerant: omit (or
245
+ * map to `null`) any id that 404s mid-run rather than throwing or shifting
246
+ * alignment. Write it over `mapConcurrent(ids, (id) => this.fetchOne(id),
247
+ * this.hydrateConcurrency)`; override with a real `/batch` call or a
248
+ * passthrough (full-object list) where the vendor allows.
249
+ *
250
+ * `ctx?.subscription` (R5) carries the run's subscription for per-connection
251
+ * credential resolution (the fetch is where the vendor call happens) and is the
252
+ * natural place to land raw payloads keyed by `subscription.id`.
253
+ */
254
+ protected abstract hydrate(ids: string[], ctx?: ReadContext): Promise<Map<string, unknown>>;
255
+
256
+ /** Provider payload → canonical record. Return `null` to drop a record. */
257
+ protected abstract toCanonical(raw: unknown): T | null;
258
+
259
+ // ---- Optional filter hooks — exactly one is live per `filterPushdown` ----
260
+
261
+ /** Pre-hydrate predicate over the cheap ref (preferred — avoids hydration). */
262
+ protected matchesRef(_ref: Ref<M>, _filter?: F): boolean {
263
+ return true;
264
+ }
265
+
266
+ /** Post-hydrate predicate over the canonical record (the no-pushdown floor). */
267
+ protected matchesRecord(_record: T, _filter?: F): boolean {
268
+ return true;
269
+ }
270
+
271
+ /**
272
+ * Resolve the filter for a subscription when adapting to `listChanges`
273
+ * (which has no filter argument). Defaults to none; codegen wiring (R3)
274
+ * overrides this to thread `DetectionConfig.filters`.
275
+ */
276
+ protected filterFor(_subscription: IntegrationSubscriptionView): F | undefined {
277
+ return undefined;
278
+ }
279
+
280
+ // ---- PROVIDED by the base ----
281
+
282
+ /**
283
+ * Stream canonical records for a request. Filter is applied BEFORE hydrate
284
+ * (structural: a kept ref is hydrated, a rejected one never is), so an
285
+ * adapter cannot hydrate-then-discard. A hydrate miss (deleted mid-run) is
286
+ * skipped, never fabricated.
287
+ */
288
+ async *read(req: ReadRequest<F>, ctx?: ReadContext): AsyncIterable<SourcedRecord<T>> {
289
+ for await (const refPage of this.enumerate(req.mode, req.filter, req.pageSize, ctx)) {
290
+ const kept = refPage.filter((ref) => this.matchesRef(ref, req.filter));
291
+ if (kept.length === 0) continue;
292
+ const raws = await this.hydrate(
293
+ kept.map((ref) => ref.externalId),
294
+ ctx,
295
+ );
296
+ for (const ref of kept) {
297
+ const raw = raws.get(ref.externalId);
298
+ if (raw === undefined || raw === null) continue; // deleted mid-run → skip
299
+ const record = this.toCanonical(raw);
300
+ if (record !== null && this.matchesRecord(record, req.filter)) {
301
+ yield { externalId: ref.externalId, record, raw, cursor: ref.cursor };
302
+ }
303
+ }
304
+ }
305
+ }
306
+
307
+ /**
308
+ * `RandomRead<T>` — single-record read, provided for free as
309
+ * `toCanonical ∘ hydrate([id])`. Reuses the adapter's batched fetch + miss
310
+ * tolerance; returns `null` for a missing or undecodable record.
311
+ */
312
+ async get(id: string, ctx?: ReadContext): Promise<T | null> {
313
+ const raws = await this.hydrate([id], ctx);
314
+ const raw = raws.get(id);
315
+ if (raw === undefined || raw === null) return null;
316
+ return this.toCanonical(raw);
317
+ }
318
+
319
+ /**
320
+ * `IChangeSource<T>` adaptation. Maps the orchestrator's by-value cursor to a
321
+ * `ReadMode` (`null` → `full` backfill, else `delta`), streams `read()`, and
322
+ * stamps each `SourcedRecord` into a `Change<T>`. All records surface as
323
+ * `'updated'`; the orchestrator's diff stage classifies create-vs-update and
324
+ * deletes arrive as tombstone refs (`toCanonical` may flag them).
325
+ *
326
+ * Cursor emission honors `cursorDivisible` (RFC-0003 §3). Divisible: each
327
+ * record carries its own per-ref cursor. Atomic: per-ref cursors are withheld
328
+ * (`undefined`, which the orchestrator skips persisting) and the end-of-walk
329
+ * token rides only on the final record — so a mid-walk crash never persists
330
+ * an unresumable token. If an atomic run yields no surviving records, no
331
+ * cursor is persisted and the next run re-reads the same (empty) delta — a
332
+ * bounded inefficiency, never data loss.
333
+ */
334
+ async *listChanges(
335
+ subscription: IntegrationSubscriptionView,
336
+ cursor: unknown | null,
337
+ ): AsyncIterable<Change<T>> {
338
+ const mode: ReadMode =
339
+ cursor === null || cursor === undefined
340
+ ? { kind: 'full' }
341
+ : { kind: 'delta', cursor };
342
+ const filter = this.filterFor(subscription);
343
+ // R5: thread the run's subscription into the read body so `enumerate`/`hydrate`
344
+ // can resolve per-connection credentials (and raw-landing keys) from it.
345
+ const stream = this.read({ mode, filter }, { subscription });
346
+
347
+ if (this.cursorDivisible) {
348
+ for await (const sourced of stream) {
349
+ yield this.toChange(sourced, sourced.cursor);
350
+ }
351
+ return;
352
+ }
353
+
354
+ // Atomic: one-record lookahead. Emit every record but the last with a
355
+ // withheld (`undefined`) cursor; the last record carries the end-of-walk
356
+ // token. Contract: an atomic adapter stamps the (single, shared) end-of-walk
357
+ // token onto its refs' `cursor` — so whichever record survives last carries
358
+ // it. The base emits a real cursor exactly once, on that final record, so the
359
+ // orchestrator can never persist a mid-walk value. If zero records survive,
360
+ // nothing is persisted (next run re-reads the delta — bounded, never lossy).
361
+ let prev: SourcedRecord<T> | null = null;
362
+ for await (const sourced of stream) {
363
+ if (prev !== null) yield this.toChange(prev, undefined);
364
+ prev = sourced;
365
+ }
366
+ if (prev !== null) yield this.toChange(prev, prev.cursor);
367
+ }
368
+
369
+ /** Stamp a `SourcedRecord` into a `Change<T>` with an explicit emitted cursor. */
370
+ private toChange(sourced: SourcedRecord<T>, cursor: unknown): Change<T> {
371
+ return {
372
+ externalId: sourced.externalId,
373
+ operation: 'updated',
374
+ record: sourced.record,
375
+ cursor,
376
+ source: this.changeSource,
377
+ };
378
+ }
379
+ }
@@ -55,9 +55,11 @@ export { MemoryEntityChangeSourceRegistry } from './entity-change-source-registr
55
55
  // DetectionConfig (#226-1) — Zod schema + inferred types; canonical source
56
56
  // of filter/mapping shape consumed by primitives + codegen YAML validator
57
57
  export {
58
+ CURSOR_DIVISIBILITY,
58
59
  CursorStrategySchema,
59
60
  DetectionConfigSchema,
60
61
  FieldMappingSchema,
62
+ isDivisibleCursor,
61
63
  PollDetectionSchema,
62
64
  ResolvedFilterSchema,
63
65
  WebhookDetectionSchema,
@@ -92,6 +94,20 @@ export {
92
94
  type PollFetchContext,
93
95
  } from './poll-change-source';
94
96
 
97
+ // IncrementalRead primitive (RFC-0003 R1) — enumerate/hydrate read capability;
98
+ // the providing base emits a streaming, filter-before-hydrate IChangeSource<T>.
99
+ export {
100
+ IncrementalReadBase,
101
+ mapConcurrent,
102
+ type IncrementalRead,
103
+ type RandomRead,
104
+ type ReadContext,
105
+ type ReadMode,
106
+ type ReadRequest,
107
+ type Ref,
108
+ type SourcedRecord,
109
+ } from './incremental-read';
110
+
95
111
  // Webhook primitive (#226-4) — generic webhook-mode IChangeSource<T>
96
112
  // driven by a consumer-owned inbound staging queue iterator
97
113
  export {