@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/CHANGELOG.md +66 -0
- package/README.md +44 -0
- package/dist/runtime/subsystems/index.d.ts +6 -2
- package/dist/runtime/subsystems/index.js +174 -1
- package/dist/runtime/subsystems/index.js.map +1 -1
- package/dist/runtime/subsystems/integration/detection-config.schema.d.ts +110 -1
- package/dist/runtime/subsystems/integration/detection-config.schema.js +25 -2
- package/dist/runtime/subsystems/integration/detection-config.schema.js.map +1 -1
- package/dist/runtime/subsystems/integration/incremental-read.d.ts +248 -0
- package/dist/runtime/subsystems/integration/incremental-read.js +149 -0
- package/dist/runtime/subsystems/integration/incremental-read.js.map +1 -0
- package/dist/runtime/subsystems/integration/index.d.ts +2 -1
- package/dist/runtime/subsystems/integration/index.js +172 -2
- package/dist/runtime/subsystems/integration/index.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-worker.module.d.ts +1 -1
- package/dist/src/cli/index.js +642 -35
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/index.d.ts +78 -0
- package/dist/src/index.js +11 -1
- package/dist/src/index.js.map +1 -1
- package/package.json +1 -1
- package/runtime/subsystems/index.ts +35 -0
- package/runtime/subsystems/integration/detection-config.schema.ts +55 -0
- package/runtime/subsystems/integration/incremental-read.ts +379 -0
- package/runtime/subsystems/integration/index.ts +16 -0
package/package.json
CHANGED
|
@@ -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 {
|