@pattern-stack/codegen 0.12.1 → 0.13.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.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../runtime/subsystems/integration/incremental-read.ts"],"sourcesContent":["/**\n * Integration subsystem — `IncrementalRead<T, F>` + `RandomRead<T>` capability\n * and the providing `IncrementalReadBase<T, F, M>` (RFC-0003 R1).\n *\n * The universal read primitive. Where `IChangeSource.listChanges` is the\n * *transport* contract (stream `Change<T>`, orchestrator owns cursor lifecycle),\n * this base owns *how the body that produces those changes is written* — the\n * level the bare `changeSources = {}` author-seam left unstructured.\n *\n * The read decomposes into two composable verbs the adapter supplies:\n *\n * - `enumerate(mode, filter) → AsyncIterable<Ref<M>[]>` — the cheap delta /\n * backfill walk; streams pages of lightweight refs (id + per-ref cursor +\n * filterable metadata). LAZY: pull-driven so hydrate backpressures it.\n * - `hydrate(ids) → Map<id, raw>` — the expensive fetch-by-id, batched; where\n * bounded concurrency / a vendor `/batch` endpoint lives. Keyed and\n * miss-tolerant (a mid-run 404 cannot shift alignment).\n * - `toCanonical(raw) → T | null` — provider payload → canonical record.\n *\n * The base PROVIDES the orchestration: drain enumerate, **filter before\n * hydrate** (structural — an adapter physically cannot hydrate-then-discard),\n * keyed pairing, per-ref cursor emission, and the `IChangeSource.listChanges`\n * adaptation. It also provides `RandomRead.get()` for free as\n * `toCanonical ∘ hydrate([id])` — so every incremental adapter is a\n * single-record reader (the \"list cheaply, fill on click\" query-surface need)\n * without extra code.\n *\n * The shape generalizes dealbrain's proven HubSpot `listSince` (streams, pushes\n * the filter server-side, carries a per-record cursor) to vendors whose list\n * returns id-stubs (Gmail) or nested resources (Meet). Calendar-style\n * full-object lists override `hydrate` as a passthrough.\n *\n * See RFC-0003 (Track D round-3), ADR-033 (`detection:` config), and\n * `poll-change-source.ts` (the sibling primitive this composes beside).\n */\n\nimport type {\n Change,\n ChangeSource,\n IChangeSource,\n IntegrationSubscriptionView,\n} from './integration-change-source.protocol';\n\n// ============================================================================\n// Capability shapes\n// ============================================================================\n\n/**\n * How a read walks the upstream. Modes are values, not verbs (swe-brain\n * ADR-0003: mode ≠ capability) — one `read()` verb dispatches on these.\n *\n * - `delta` — incremental walk from a persisted cursor.\n * - `full` — cursorless backfill (optionally bounded by `since`).\n * - `reconcile` — gap-repair: re-fetch a known id set the cursor skipped\n * (the repair pass for the silent-tail-skip + #414-style\n * multi-provider divergence).\n */\nexport type ReadMode =\n | { readonly kind: 'delta'; readonly cursor: unknown }\n | { readonly kind: 'full'; readonly since?: Date }\n | { readonly kind: 'reconcile'; readonly knownIds: readonly string[] };\n\n/**\n * A cheap ref from the enumerate pass: identity + per-ref cursor + metadata to\n * filter or display on. `cursor` is the position AS OF this ref — see\n * `IncrementalReadBase.cursorDivisible` (R2) for when it may be checkpointed\n * mid-walk versus withheld until a safe boundary.\n */\nexport interface Ref<M = Record<string, unknown>> {\n readonly externalId: string;\n readonly cursor: unknown;\n readonly meta: M;\n}\n\n/** A read request: the mode, an optional adapter-typed filter, and page size. */\nexport interface ReadRequest<F = unknown> {\n readonly mode: ReadMode;\n readonly filter?: F;\n readonly pageSize?: number;\n}\n\n/**\n * The `read()`-side envelope: canonical record + the raw vendor payload it came\n * from + the originating external id + the per-ref cursor.\n *\n * Distinct from the runtime's transport envelope `Change<T>`\n * (operation/externalId/cursor/source). The relationship is one-directional:\n * `listChanges()` adapts `read()` → `Change<T>` (dropping `raw`, stamping\n * `operation`). `read()` keeps `raw` and `externalId` so a query surface can\n * re-project without a second fetch.\n */\nexport interface SourcedRecord<T> {\n readonly externalId: string;\n readonly record: T;\n readonly raw: unknown;\n readonly cursor: unknown;\n}\n\n/**\n * The universal read capability — one public verb that streams. Filtering,\n * hydration, and cursor emission are the providing base's concern.\n */\nexport interface IncrementalRead<T, F = unknown> {\n read(req: ReadRequest<F>): AsyncIterable<SourcedRecord<T>>;\n}\n\n/**\n * Single-record read by external id — the \"fill on click\" atom. Provided for\n * free by `IncrementalReadBase` (composes `hydrate` + `toCanonical`); declared\n * as its own capability so consumers can depend on it without the streaming\n * surface.\n */\nexport interface RandomRead<T> {\n get(id: string): Promise<T | null>;\n}\n\n// ============================================================================\n// Bounded-parallel map helper\n// ============================================================================\n\n/**\n * Map `ids` through `fn` with at most `limit` concurrent in-flight calls,\n * collecting results keyed by id. The workhorse for writing a batched\n * `hydrate` over a single-id fetch without serial N+1 latency.\n */\nexport async function mapConcurrent<R>(\n ids: readonly string[],\n fn: (id: string) => Promise<R>,\n limit: number,\n): Promise<Map<string, R>> {\n const out = new Map<string, R>();\n if (ids.length === 0) return out;\n const width = Math.max(1, Math.min(limit, ids.length));\n let next = 0;\n const worker = async (): Promise<void> => {\n while (next < ids.length) {\n const idx = next++;\n const id = ids[idx]!;\n out.set(id, await fn(id));\n }\n };\n await Promise.all(Array.from({ length: width }, worker));\n return out;\n}\n\n// ============================================================================\n// IncrementalReadBase\n// ============================================================================\n\n/**\n * Providing base for the read capability. A subclass fills exactly three vendor\n * methods — `enumerate`, `hydrate`, `toCanonical` — and gets a streaming,\n * filter-before-hydrate, miss-tolerant `IncrementalRead<T, F>` +\n * `IChangeSource<T>` + `RandomRead<T>`.\n *\n * Type params: `T` canonical record, `F` adapter-typed filter, `M` per-ref\n * metadata (defaults to an untyped bag — surface packages supply a domain `M`).\n */\nexport abstract class IncrementalReadBase<T, F = unknown, M = Record<string, unknown>>\n implements IncrementalRead<T, F>, IChangeSource<T>, RandomRead<T>\n{\n /** Human label for run logs — e.g. `'google-mail-email'`. */\n abstract readonly label: string;\n\n /**\n * Whether the vendor takes the request predicate server-side. Declared, not\n * enforced here — surfaced into the emission manifest (R3) so the falsifier\n * suite (R4) can record which adapters filter post-hydrate. `false` is the\n * honest floor (e.g. Gmail without `q=`), handled via `matchesRecord`.\n */\n protected readonly filterPushdown: boolean = false;\n\n /** Max concurrent in-flight calls for a `mapConcurrent`-built `hydrate`. */\n protected readonly hydrateConcurrency: number = 10;\n\n /** `Change<T>.source` provenance stamped by `listChanges`. */\n protected readonly changeSource: ChangeSource = 'poll';\n\n /**\n * Whether this source's cursor strategy is divisible (RFC-0003 §3). When\n * `true` (default — sortable watermarks like `systemModstamp`/`timestamp`/\n * `replayId`), `listChanges` emits each record's per-ref cursor, so the\n * orchestrator may checkpoint mid-walk and a crash resumes from the last\n * delivered ref.\n *\n * When `false` (atomic opaque tokens — Gmail `historyId`, Calendar\n * `syncToken`), `listChanges` WITHHOLDS per-ref cursors and emits the\n * end-of-walk token only on the final record, so the orchestrator's\n * persist-last-yielded lifecycle can never persist an unresumable mid-walk\n * token. The cost is blast-radius: an interrupted atomic run resumes\n * all-or-nothing from the prior persisted token. For atomic *backfills* that\n * radius is the whole enumerate walk — bound it with `ReadRequest.pageSize`\n * (smaller pages ⇒ shorter walks per run). Per-page atomic checkpointing is a\n * future refinement; R2 gates at end-of-walk.\n *\n * Codegen (R3) sets this from the strategy kind via `isDivisibleCursor`.\n */\n protected readonly cursorDivisible: boolean = true;\n\n // ---- SUPPLIED by the adapter (the irreducible vendor seam) ----\n\n /**\n * The cheap walk. Streams pages of refs; LAZY so `hydrate` backpressures it\n * (one page hydrated before the next is pulled). Mode-dispatch lives here:\n * `delta` resumes from `mode.cursor`, `full` walks from the top, `reconcile`\n * re-fetches `mode.knownIds`.\n *\n * `pageSize` (from `ReadRequest`) is the adapter's requested vendor page size\n * — also the atomic-cursor backfill blast-radius bound (§ `cursorDivisible`).\n * Honor it as a hint; vendors that cap page size clamp it.\n */\n protected abstract enumerate(\n mode: ReadMode,\n filter?: F,\n pageSize?: number,\n ): AsyncIterable<Ref<M>[]>;\n\n /**\n * Fetch raw payloads for `ids`, keyed by id. MUST be miss-tolerant: omit (or\n * map to `null`) any id that 404s mid-run rather than throwing or shifting\n * alignment. Write it over `mapConcurrent(ids, (id) => this.fetchOne(id),\n * this.hydrateConcurrency)`; override with a real `/batch` call or a\n * passthrough (full-object list) where the vendor allows.\n */\n protected abstract hydrate(ids: string[]): Promise<Map<string, unknown>>;\n\n /** Provider payload → canonical record. Return `null` to drop a record. */\n protected abstract toCanonical(raw: unknown): T | null;\n\n // ---- Optional filter hooks — exactly one is live per `filterPushdown` ----\n\n /** Pre-hydrate predicate over the cheap ref (preferred — avoids hydration). */\n protected matchesRef(_ref: Ref<M>, _filter?: F): boolean {\n return true;\n }\n\n /** Post-hydrate predicate over the canonical record (the no-pushdown floor). */\n protected matchesRecord(_record: T, _filter?: F): boolean {\n return true;\n }\n\n /**\n * Resolve the filter for a subscription when adapting to `listChanges`\n * (which has no filter argument). Defaults to none; codegen wiring (R3)\n * overrides this to thread `DetectionConfig.filters`.\n */\n protected filterFor(_subscription: IntegrationSubscriptionView): F | undefined {\n return undefined;\n }\n\n // ---- PROVIDED by the base ----\n\n /**\n * Stream canonical records for a request. Filter is applied BEFORE hydrate\n * (structural: a kept ref is hydrated, a rejected one never is), so an\n * adapter cannot hydrate-then-discard. A hydrate miss (deleted mid-run) is\n * skipped, never fabricated.\n */\n async *read(req: ReadRequest<F>): AsyncIterable<SourcedRecord<T>> {\n for await (const refPage of this.enumerate(req.mode, req.filter, req.pageSize)) {\n const kept = refPage.filter((ref) => this.matchesRef(ref, req.filter));\n if (kept.length === 0) continue;\n const raws = await this.hydrate(kept.map((ref) => ref.externalId));\n for (const ref of kept) {\n const raw = raws.get(ref.externalId);\n if (raw === undefined || raw === null) continue; // deleted mid-run → skip\n const record = this.toCanonical(raw);\n if (record !== null && this.matchesRecord(record, req.filter)) {\n yield { externalId: ref.externalId, record, raw, cursor: ref.cursor };\n }\n }\n }\n }\n\n /**\n * `RandomRead<T>` — single-record read, provided for free as\n * `toCanonical ∘ hydrate([id])`. Reuses the adapter's batched fetch + miss\n * tolerance; returns `null` for a missing or undecodable record.\n */\n async get(id: string): Promise<T | null> {\n const raws = await this.hydrate([id]);\n const raw = raws.get(id);\n if (raw === undefined || raw === null) return null;\n return this.toCanonical(raw);\n }\n\n /**\n * `IChangeSource<T>` adaptation. Maps the orchestrator's by-value cursor to a\n * `ReadMode` (`null` → `full` backfill, else `delta`), streams `read()`, and\n * stamps each `SourcedRecord` into a `Change<T>`. All records surface as\n * `'updated'`; the orchestrator's diff stage classifies create-vs-update and\n * deletes arrive as tombstone refs (`toCanonical` may flag them).\n *\n * Cursor emission honors `cursorDivisible` (RFC-0003 §3). Divisible: each\n * record carries its own per-ref cursor. Atomic: per-ref cursors are withheld\n * (`undefined`, which the orchestrator skips persisting) and the end-of-walk\n * token rides only on the final record — so a mid-walk crash never persists\n * an unresumable token. If an atomic run yields no surviving records, no\n * cursor is persisted and the next run re-reads the same (empty) delta — a\n * bounded inefficiency, never data loss.\n */\n async *listChanges(\n subscription: IntegrationSubscriptionView,\n cursor: unknown | null,\n ): AsyncIterable<Change<T>> {\n const mode: ReadMode =\n cursor === null || cursor === undefined\n ? { kind: 'full' }\n : { kind: 'delta', cursor };\n const filter = this.filterFor(subscription);\n const stream = this.read({ mode, filter });\n\n if (this.cursorDivisible) {\n for await (const sourced of stream) {\n yield this.toChange(sourced, sourced.cursor);\n }\n return;\n }\n\n // Atomic: one-record lookahead. Emit every record but the last with a\n // withheld (`undefined`) cursor; the last record carries the end-of-walk\n // token. Contract: an atomic adapter stamps the (single, shared) end-of-walk\n // token onto its refs' `cursor` — so whichever record survives last carries\n // it. The base emits a real cursor exactly once, on that final record, so the\n // orchestrator can never persist a mid-walk value. If zero records survive,\n // nothing is persisted (next run re-reads the delta — bounded, never lossy).\n let prev: SourcedRecord<T> | null = null;\n for await (const sourced of stream) {\n if (prev !== null) yield this.toChange(prev, undefined);\n prev = sourced;\n }\n if (prev !== null) yield this.toChange(prev, prev.cursor);\n }\n\n /** Stamp a `SourcedRecord` into a `Change<T>` with an explicit emitted cursor. */\n private toChange(sourced: SourcedRecord<T>, cursor: unknown): Change<T> {\n return {\n externalId: sourced.externalId,\n operation: 'updated',\n record: sourced.record,\n cursor,\n source: this.changeSource,\n };\n }\n}\n"],"mappings":"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","names":[]}
@@ -6,10 +6,11 @@ export { CompleteRunInput, IIntegrationRunRecorder, IntegrationRunSummary, Recor
6
6
  export { ILoopbackFingerprintStore } from './integration-loopback.protocol.js';
7
7
  export { IEntityChangeSourceRegistry, UnknownEntityError } from './entity-change-source-registry.protocol.js';
8
8
  export { MemoryEntityChangeSourceRegistry } from './entity-change-source-registry.memory.js';
9
- export { CursorStrategy, CursorStrategySchema, DetectionConfig, DetectionConfigSchema, FieldMapping, FieldMappingSchema, PollDetection, PollDetectionSchema, ResolvedFilter, ResolvedFilterSchema, WebhookDetection, WebhookDetectionSchema } from './detection-config.schema.js';
9
+ export { CURSOR_DIVISIBILITY, CursorStrategy, CursorStrategySchema, DetectionConfig, DetectionConfigSchema, FieldMapping, FieldMappingSchema, PollDetection, PollDetectionSchema, ResolvedFilter, ResolvedFilterSchema, WebhookDetection, WebhookDetectionSchema, isDivisibleCursor } from './detection-config.schema.js';
10
10
  export { ChangeIterator, ChangeMiddleware, ComposeChangeMiddleware } from './integration-middleware.protocol.js';
11
11
  export { createLoopbackMiddleware } from './loopback.middleware.js';
12
12
  export { PollChangeSource, PollChangeSourceOptions, PollCursor, PollFetchCallback, PollFetchContext } from './poll-change-source.js';
13
+ export { IncrementalRead, IncrementalReadBase, RandomRead, ReadMode, ReadRequest, Ref, SourcedRecord, mapConcurrent } from './incremental-read.js';
13
14
  export { WebhookChangeSource, WebhookChangeSourceOptions, WebhookCursor, WebhookFetchCallback, WebhookFetchContext } from './webhook-change-source.js';
14
15
  export { buildChangeSource } from './build-change-source.js';
15
16
  export { ENTITY_CHANGE_SOURCE_REGISTRY, INTEGRATION_CHANGE_SOURCE, INTEGRATION_CURSOR_STORE, INTEGRATION_FIELD_DIFFER, INTEGRATION_MODULE_OPTIONS, INTEGRATION_MULTI_TENANT, INTEGRATION_RUN_RECORDER, INTEGRATION_SINK } from './integration.tokens.js';
@@ -77,12 +77,33 @@ var EventIdCursorSchema = z2.object({
77
77
  kind: z2.literal("eventId"),
78
78
  field: z2.string().min(1)
79
79
  });
80
+ var HistoryIdCursorSchema = z2.object({
81
+ kind: z2.literal("historyId"),
82
+ field: z2.string().min(1)
83
+ });
84
+ var SyncTokenCursorSchema = z2.object({
85
+ kind: z2.literal("syncToken"),
86
+ field: z2.string().min(1)
87
+ });
80
88
  var CursorStrategySchema = z2.discriminatedUnion("kind", [
81
89
  SystemModstampCursorSchema,
82
90
  ReplayIdCursorSchema,
83
91
  TimestampCursorSchema,
84
- EventIdCursorSchema
92
+ EventIdCursorSchema,
93
+ HistoryIdCursorSchema,
94
+ SyncTokenCursorSchema
85
95
  ]);
96
+ var CURSOR_DIVISIBILITY = {
97
+ systemModstamp: true,
98
+ timestamp: true,
99
+ replayId: true,
100
+ eventId: false,
101
+ historyId: false,
102
+ syncToken: false
103
+ };
104
+ function isDivisibleCursor(kind) {
105
+ return CURSOR_DIVISIBILITY[kind];
106
+ }
86
107
  var PollDetectionSchema = z2.object({
87
108
  cursor: CursorStrategySchema,
88
109
  provenance: z2.enum(["poll", "cdc"]).optional()
@@ -214,6 +235,148 @@ var PollChangeSource = class {
214
235
  }
215
236
  };
216
237
 
238
+ // runtime/subsystems/integration/incremental-read.ts
239
+ async function mapConcurrent(ids, fn, limit) {
240
+ const out = /* @__PURE__ */ new Map();
241
+ if (ids.length === 0) return out;
242
+ const width = Math.max(1, Math.min(limit, ids.length));
243
+ let next = 0;
244
+ const worker = async () => {
245
+ while (next < ids.length) {
246
+ const idx = next++;
247
+ const id = ids[idx];
248
+ out.set(id, await fn(id));
249
+ }
250
+ };
251
+ await Promise.all(Array.from({ length: width }, worker));
252
+ return out;
253
+ }
254
+ var IncrementalReadBase = class {
255
+ /**
256
+ * Whether the vendor takes the request predicate server-side. Declared, not
257
+ * enforced here — surfaced into the emission manifest (R3) so the falsifier
258
+ * suite (R4) can record which adapters filter post-hydrate. `false` is the
259
+ * honest floor (e.g. Gmail without `q=`), handled via `matchesRecord`.
260
+ */
261
+ filterPushdown = false;
262
+ /** Max concurrent in-flight calls for a `mapConcurrent`-built `hydrate`. */
263
+ hydrateConcurrency = 10;
264
+ /** `Change<T>.source` provenance stamped by `listChanges`. */
265
+ changeSource = "poll";
266
+ /**
267
+ * Whether this source's cursor strategy is divisible (RFC-0003 §3). When
268
+ * `true` (default — sortable watermarks like `systemModstamp`/`timestamp`/
269
+ * `replayId`), `listChanges` emits each record's per-ref cursor, so the
270
+ * orchestrator may checkpoint mid-walk and a crash resumes from the last
271
+ * delivered ref.
272
+ *
273
+ * When `false` (atomic opaque tokens — Gmail `historyId`, Calendar
274
+ * `syncToken`), `listChanges` WITHHOLDS per-ref cursors and emits the
275
+ * end-of-walk token only on the final record, so the orchestrator's
276
+ * persist-last-yielded lifecycle can never persist an unresumable mid-walk
277
+ * token. The cost is blast-radius: an interrupted atomic run resumes
278
+ * all-or-nothing from the prior persisted token. For atomic *backfills* that
279
+ * radius is the whole enumerate walk — bound it with `ReadRequest.pageSize`
280
+ * (smaller pages ⇒ shorter walks per run). Per-page atomic checkpointing is a
281
+ * future refinement; R2 gates at end-of-walk.
282
+ *
283
+ * Codegen (R3) sets this from the strategy kind via `isDivisibleCursor`.
284
+ */
285
+ cursorDivisible = true;
286
+ // ---- Optional filter hooks — exactly one is live per `filterPushdown` ----
287
+ /** Pre-hydrate predicate over the cheap ref (preferred — avoids hydration). */
288
+ matchesRef(_ref, _filter) {
289
+ return true;
290
+ }
291
+ /** Post-hydrate predicate over the canonical record (the no-pushdown floor). */
292
+ matchesRecord(_record, _filter) {
293
+ return true;
294
+ }
295
+ /**
296
+ * Resolve the filter for a subscription when adapting to `listChanges`
297
+ * (which has no filter argument). Defaults to none; codegen wiring (R3)
298
+ * overrides this to thread `DetectionConfig.filters`.
299
+ */
300
+ filterFor(_subscription) {
301
+ return void 0;
302
+ }
303
+ // ---- PROVIDED by the base ----
304
+ /**
305
+ * Stream canonical records for a request. Filter is applied BEFORE hydrate
306
+ * (structural: a kept ref is hydrated, a rejected one never is), so an
307
+ * adapter cannot hydrate-then-discard. A hydrate miss (deleted mid-run) is
308
+ * skipped, never fabricated.
309
+ */
310
+ async *read(req) {
311
+ for await (const refPage of this.enumerate(req.mode, req.filter, req.pageSize)) {
312
+ const kept = refPage.filter((ref) => this.matchesRef(ref, req.filter));
313
+ if (kept.length === 0) continue;
314
+ const raws = await this.hydrate(kept.map((ref) => ref.externalId));
315
+ for (const ref of kept) {
316
+ const raw = raws.get(ref.externalId);
317
+ if (raw === void 0 || raw === null) continue;
318
+ const record = this.toCanonical(raw);
319
+ if (record !== null && this.matchesRecord(record, req.filter)) {
320
+ yield { externalId: ref.externalId, record, raw, cursor: ref.cursor };
321
+ }
322
+ }
323
+ }
324
+ }
325
+ /**
326
+ * `RandomRead<T>` — single-record read, provided for free as
327
+ * `toCanonical ∘ hydrate([id])`. Reuses the adapter's batched fetch + miss
328
+ * tolerance; returns `null` for a missing or undecodable record.
329
+ */
330
+ async get(id) {
331
+ const raws = await this.hydrate([id]);
332
+ const raw = raws.get(id);
333
+ if (raw === void 0 || raw === null) return null;
334
+ return this.toCanonical(raw);
335
+ }
336
+ /**
337
+ * `IChangeSource<T>` adaptation. Maps the orchestrator's by-value cursor to a
338
+ * `ReadMode` (`null` → `full` backfill, else `delta`), streams `read()`, and
339
+ * stamps each `SourcedRecord` into a `Change<T>`. All records surface as
340
+ * `'updated'`; the orchestrator's diff stage classifies create-vs-update and
341
+ * deletes arrive as tombstone refs (`toCanonical` may flag them).
342
+ *
343
+ * Cursor emission honors `cursorDivisible` (RFC-0003 §3). Divisible: each
344
+ * record carries its own per-ref cursor. Atomic: per-ref cursors are withheld
345
+ * (`undefined`, which the orchestrator skips persisting) and the end-of-walk
346
+ * token rides only on the final record — so a mid-walk crash never persists
347
+ * an unresumable token. If an atomic run yields no surviving records, no
348
+ * cursor is persisted and the next run re-reads the same (empty) delta — a
349
+ * bounded inefficiency, never data loss.
350
+ */
351
+ async *listChanges(subscription, cursor) {
352
+ const mode = cursor === null || cursor === void 0 ? { kind: "full" } : { kind: "delta", cursor };
353
+ const filter = this.filterFor(subscription);
354
+ const stream = this.read({ mode, filter });
355
+ if (this.cursorDivisible) {
356
+ for await (const sourced of stream) {
357
+ yield this.toChange(sourced, sourced.cursor);
358
+ }
359
+ return;
360
+ }
361
+ let prev = null;
362
+ for await (const sourced of stream) {
363
+ if (prev !== null) yield this.toChange(prev, void 0);
364
+ prev = sourced;
365
+ }
366
+ if (prev !== null) yield this.toChange(prev, prev.cursor);
367
+ }
368
+ /** Stamp a `SourcedRecord` into a `Change<T>` with an explicit emitted cursor. */
369
+ toChange(sourced, cursor) {
370
+ return {
371
+ externalId: sourced.externalId,
372
+ operation: "updated",
373
+ record: sourced.record,
374
+ cursor,
375
+ source: this.changeSource
376
+ };
377
+ }
378
+ };
379
+
217
380
  // runtime/subsystems/integration/webhook-change-source.ts
218
381
  var WebhookChangeSource = class {
219
382
  label;
@@ -1186,6 +1349,7 @@ IntegrationModule = __decorateClass([
1186
1349
  Module({})
1187
1350
  ], IntegrationModule);
1188
1351
  export {
1352
+ CURSOR_DIVISIBILITY,
1189
1353
  CursorStrategySchema,
1190
1354
  DeepEqualDiffer,
1191
1355
  DetectionConfigSchema,
@@ -1202,6 +1366,7 @@ export {
1202
1366
  INTEGRATION_MULTI_TENANT,
1203
1367
  INTEGRATION_RUN_RECORDER,
1204
1368
  INTEGRATION_SINK,
1369
+ IncrementalReadBase,
1205
1370
  IntegrationModule,
1206
1371
  MemoryCursorStore,
1207
1372
  MemoryEntityChangeSourceRegistry,
@@ -1224,6 +1389,8 @@ export {
1224
1389
  integrationRunItems,
1225
1390
  integrationRunStatusEnum,
1226
1391
  integrationRuns,
1227
- integrationSubscriptions
1392
+ integrationSubscriptions,
1393
+ isDivisibleCursor,
1394
+ mapConcurrent
1228
1395
  };
1229
1396
  //# sourceMappingURL=index.js.map