@pattern-stack/codegen 0.12.2 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,72 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.13.0] — 2026-05-31
8
+
9
+ Track D round-2/3 — the integration codegen now emits the **full** integration
10
+ layer. Where 0.12.x stopped at the read side (provider module, adapter scaffold,
11
+ registry, typed views), 0.13.0 adds the **module assembly** (the write/run side —
12
+ RFC-0002) and reshapes the read body into the **`IncrementalRead` primitive**
13
+ (RFC-0003). After this release the author fills only the irreducible vendor seam:
14
+ the `enumerate` / `hydrate` / `toCanonical` read methods and any non-generic sink
15
+ write logic.
16
+
17
+ Core and the four surface packages (`codegen-{mail,calendar,transcript,crm}`)
18
+ **release together** — the surfaces carry the BREAKING port change below and
19
+ require the matching core (peer dep `^0.13.0`).
20
+
21
+ ### BREAKING
22
+
23
+ - **Surface ports declare `changeSources`, not `sources`.** The four surface
24
+ ports (`MailPort` / `CalendarPort` / `TranscriptPort` / `CrmPort`) now require
25
+ `readonly changeSources: Record<string, IChangeSource<unknown>>` — the
26
+ per-entity change sources the adapter *contributes*, keyed by entity name —
27
+ instead of the old `readonly sources: IEntityChangeSourceRegistry`. The folded,
28
+ entity-keyed registry (`<SURFACE>_ENTITY_SOURCES`) is now the **surface
29
+ module's** concern: the surface aggregator folds every provider's
30
+ `changeSources` into it, and entity-agnostic consumers read it at runtime. This
31
+ drops a vestigial registry injection from the adapter (it was read by nothing
32
+ and formed a latent DI cycle — RFC-0002 §3 E0), making the adapter
33
+ standalone-importable. The four surface packages bump to **0.2.0**; their peer
34
+ dep on `@pattern-stack/codegen` moves to **^0.13.0**. Conformance helpers
35
+ (`assert<Surface>Adapter`) check `changeSources` membership accordingly.
36
+
37
+ ### Added
38
+
39
+ - **Integration module assembly emission (RFC-0002).** Per `(surface, provider,
40
+ entity-with-surface)`, codegen now emits the assembly that turns a registry of
41
+ change sources into a runnable integration per entity:
42
+ - `<surface>/modules/<provider>/<entity>-integration.module.ts` — `@generated`
43
+ per-entity feature module binding `INTEGRATION_CHANGE_SOURCE`
44
+ (= `adapter.changeSources['<entity>']`, Option A) + `INTEGRATION_SINK`, a
45
+ local `ExecuteIntegrationUseCase`, and a uniquely-tokened handle
46
+ (`<ENTITY>_INTEGRATION_USE_CASE__<PROVIDER>`) a trigger can grab.
47
+ - `<surface>/sinks/<entity>.sink.ts` — emit-once **default sink** scaffold
48
+ (`// <CODEGEN-SCAFFOLD-V1>`) over the entity's generated `Integrated`
49
+ repository (`pattern: Integrated` only — hard-errors otherwise); author fills
50
+ any non-generic `canonical ↔ local` mapping.
51
+ - `<surface>/<surface>-integration.module.ts` — `@generated` aggregator over
52
+ the per-entity modules.
53
+ - `<surface>/<surface>-integration.tokens.ts` — `@generated` use-case tokens.
54
+ - **`IncrementalRead` read primitive (RFC-0003).** A universal read capability
55
+ (`IncrementalRead<T, F>` / `RandomRead<T>` / `IncrementalReadBase` +
56
+ `SourcedRecord` / `Ref` / `ReadMode` / `ReadRequest` / `mapConcurrent`) in
57
+ `runtime/subsystems/integration/`, exported from
58
+ `@pattern-stack/codegen/subsystems`. The base decomposes the read into
59
+ `enumerate(mode, filter) → AsyncIterable<Ref>` (cheap delta/backfill walk) and
60
+ `hydrate(ids) → Map<id, raw>` (batched fetch-by-id), and owns the orchestration
61
+ (drain, **filter-before-hydrate**, bounded-concurrency hydrate, per-ref cursor
62
+ emission, `listChanges` adaptation). Cursor divisibility is kind-keyed
63
+ (`CURSOR_DIVISIBILITY` / `isDivisibleCursor`); atomic strategies (`historyId` /
64
+ `syncToken`) withhold the per-ref cursor until a safe boundary so an
65
+ interrupted backfill never persists an unresumable token.
66
+ - **Read-side scaffold reshape.** For interaction surfaces (mail / calendar /
67
+ transcript), `generateAdapterScaffold` now emits each `changeSources` entry as
68
+ an emit-once `IncrementalReadBase<Canonical<Entity>, ResolvedFilter[]>`
69
+ subclass — the buffer-all/serial/run-final-cursor regression becomes
70
+ structurally unwritable. CRM keeps its author-filled `changeSources` seam
71
+ (field-reader model, no single canonical `T`).
72
+
7
73
  ## [0.12.2] — 2026-05-31
8
74
 
9
75
  Track D consumer-CLI fix. The 0.12.0/0.12.1 generator was correct in the
package/README.md CHANGED
@@ -138,6 +138,50 @@ modules/{plural}/
138
138
  use-cases/ FindById, List, declarative queries
139
139
  ```
140
140
 
141
+ ## Integration Codegen (provider/adapter + assembly + read primitive)
142
+
143
+ When an entity carries a `surface:` tag and `definitions/providers/*.yaml` exist,
144
+ re-running `codegen entity new` emits the **full** integration layer for each
145
+ `(surface, provider, entity)` — not just the read side. The author fills only the
146
+ irreducible vendor seam: the `enumerate` / `hydrate` / `toCanonical` read methods
147
+ and any non-generic sink write logic.
148
+
149
+ **Read side** (provider/adapter — RFC-0001):
150
+ ```
151
+ integrations/providers/<provider>/ Auth strategy + client (provider module)
152
+ integrations/<surface>/adapters/<provider>/ Adapter scaffold: changeSources container
153
+ integrations/<surface>/<surface>-adapters.module.ts Aggregator → <SURFACE>_ENTITY_SOURCES registry
154
+ integrations/<surface>/types.generated.ts Typed views
155
+ ```
156
+
157
+ The adapter *contributes* `changeSources` (per-entity, keyed by entity name); the
158
+ aggregator folds every provider's contributions into the entity-keyed
159
+ `<SURFACE>_ENTITY_SOURCES` registry consumers read at runtime.
160
+
161
+ **Read primitive** (RFC-0003): for interaction surfaces (mail / calendar /
162
+ transcript) each `changeSources` entry is emitted as an emit-once
163
+ `IncrementalReadBase<Canonical<Entity>, ResolvedFilter[]>` subclass — the
164
+ enumerate/hydrate read capability (`@pattern-stack/codegen/subsystems`). The base
165
+ owns streaming, **filter-before-hydrate**, bounded-concurrency hydration, and
166
+ per-ref cursor emission, so the buffer-all/serial/run-final-cursor regression is
167
+ structurally unwritable; the author fills only `enumerate` / `hydrate` /
168
+ `toCanonical`.
169
+
170
+ **Write/run side** (assembly — RFC-0002):
171
+ ```
172
+ integrations/<surface>/modules/<provider>/<entity>-integration.module.ts @generated per-entity assembly
173
+ integrations/<surface>/sinks/<entity>.sink.ts emit-once default sink scaffold
174
+ integrations/<surface>/<surface>-integration.module.ts @generated aggregator
175
+ integrations/<surface>/<surface>-integration.tokens.ts @generated use-case tokens
176
+ ```
177
+
178
+ Each per-entity module binds `INTEGRATION_CHANGE_SOURCE`
179
+ (= `adapter.changeSources['<entity>']`) + `INTEGRATION_SINK`, provides a local
180
+ `ExecuteIntegrationUseCase`, and exports a uniquely-tokened handle
181
+ (`<ENTITY>_INTEGRATION_USE_CASE__<PROVIDER>`) a trigger grabs to run a sync. The
182
+ default sink scaffolds over the entity's generated `Integrated` repository
183
+ (`pattern: Integrated` only); the author overrides any non-generic write logic.
184
+
141
185
  ## Entity Families
142
186
 
143
187
  Families provide pre-built base classes with domain-specific query patterns:
@@ -19,12 +19,16 @@ export { IObservability } from './observability/observability.protocol.js';
19
19
  export { OBSERVABILITY, OBSERVABILITY_MODULE_OPTIONS } from './observability/observability.tokens.js';
20
20
  export { ObservabilityModule, ObservabilityModuleOptions } from './observability/observability.module.js';
21
21
  export { ObservabilityError } from './observability/observability-errors.js';
22
- export { IChangeSource } from './integration/integration-change-source.protocol.js';
22
+ export { IChangeSource, IntegrationSubscriptionView } from './integration/integration-change-source.protocol.js';
23
23
  export { CursorSnapshot } from './integration/integration-cursor-store.protocol.js';
24
+ export { IIntegrationSink } from './integration/integration-sink.protocol.js';
24
25
  export { IntegrationRunSummary } from './integration/integration-run-recorder.protocol.js';
25
26
  export { IEntityChangeSourceRegistry, UnknownEntityError } from './integration/entity-change-source-registry.protocol.js';
26
27
  export { MemoryEntityChangeSourceRegistry } from './integration/entity-change-source-registry.memory.js';
27
- export { ENTITY_CHANGE_SOURCE_REGISTRY } from './integration/integration.tokens.js';
28
+ export { CURSOR_DIVISIBILITY, ResolvedFilter, isDivisibleCursor } from './integration/detection-config.schema.js';
29
+ export { IncrementalRead, IncrementalReadBase, RandomRead, ReadMode, ReadRequest, Ref, SourcedRecord, mapConcurrent } from './integration/incremental-read.js';
30
+ export { ENTITY_CHANGE_SOURCE_REGISTRY, INTEGRATION_CHANGE_SOURCE, INTEGRATION_SINK } from './integration/integration.tokens.js';
31
+ export { ExecuteIntegrationUseCase } from './integration/execute-integration.use-case.js';
28
32
  export { AuthCredentials, AuthResolveOptions, IAuthStrategy } from './auth/protocols/auth-strategy.js';
29
33
  export { IEncryptionKey } from './auth/protocols/encryption-key.js';
30
34
  export { IOAuthStateStore, OAuthStateError, OAuthStateRecord } from './auth/protocols/oauth-state-store.js';
@@ -4360,12 +4360,33 @@ var EventIdCursorSchema = z3.object({
4360
4360
  kind: z3.literal("eventId"),
4361
4361
  field: z3.string().min(1)
4362
4362
  });
4363
+ var HistoryIdCursorSchema = z3.object({
4364
+ kind: z3.literal("historyId"),
4365
+ field: z3.string().min(1)
4366
+ });
4367
+ var SyncTokenCursorSchema = z3.object({
4368
+ kind: z3.literal("syncToken"),
4369
+ field: z3.string().min(1)
4370
+ });
4363
4371
  var CursorStrategySchema = z3.discriminatedUnion("kind", [
4364
4372
  SystemModstampCursorSchema,
4365
4373
  ReplayIdCursorSchema,
4366
4374
  TimestampCursorSchema,
4367
- EventIdCursorSchema
4375
+ EventIdCursorSchema,
4376
+ HistoryIdCursorSchema,
4377
+ SyncTokenCursorSchema
4368
4378
  ]);
4379
+ var CURSOR_DIVISIBILITY = {
4380
+ systemModstamp: true,
4381
+ timestamp: true,
4382
+ replayId: true,
4383
+ eventId: false,
4384
+ historyId: false,
4385
+ syncToken: false
4386
+ };
4387
+ function isDivisibleCursor(kind) {
4388
+ return CURSOR_DIVISIBILITY[kind];
4389
+ }
4369
4390
  var PollDetectionSchema = z3.object({
4370
4391
  cursor: CursorStrategySchema,
4371
4392
  provenance: z3.enum(["poll", "cdc"]).optional()
@@ -4390,6 +4411,148 @@ var DetectionConfigSchema = z3.discriminatedUnion("mode", [
4390
4411
  WebhookModeSchema
4391
4412
  ]);
4392
4413
 
4414
+ // runtime/subsystems/integration/incremental-read.ts
4415
+ async function mapConcurrent(ids, fn, limit) {
4416
+ const out = /* @__PURE__ */ new Map();
4417
+ if (ids.length === 0) return out;
4418
+ const width = Math.max(1, Math.min(limit, ids.length));
4419
+ let next = 0;
4420
+ const worker = async () => {
4421
+ while (next < ids.length) {
4422
+ const idx = next++;
4423
+ const id = ids[idx];
4424
+ out.set(id, await fn(id));
4425
+ }
4426
+ };
4427
+ await Promise.all(Array.from({ length: width }, worker));
4428
+ return out;
4429
+ }
4430
+ var IncrementalReadBase = class {
4431
+ /**
4432
+ * Whether the vendor takes the request predicate server-side. Declared, not
4433
+ * enforced here — surfaced into the emission manifest (R3) so the falsifier
4434
+ * suite (R4) can record which adapters filter post-hydrate. `false` is the
4435
+ * honest floor (e.g. Gmail without `q=`), handled via `matchesRecord`.
4436
+ */
4437
+ filterPushdown = false;
4438
+ /** Max concurrent in-flight calls for a `mapConcurrent`-built `hydrate`. */
4439
+ hydrateConcurrency = 10;
4440
+ /** `Change<T>.source` provenance stamped by `listChanges`. */
4441
+ changeSource = "poll";
4442
+ /**
4443
+ * Whether this source's cursor strategy is divisible (RFC-0003 §3). When
4444
+ * `true` (default — sortable watermarks like `systemModstamp`/`timestamp`/
4445
+ * `replayId`), `listChanges` emits each record's per-ref cursor, so the
4446
+ * orchestrator may checkpoint mid-walk and a crash resumes from the last
4447
+ * delivered ref.
4448
+ *
4449
+ * When `false` (atomic opaque tokens — Gmail `historyId`, Calendar
4450
+ * `syncToken`), `listChanges` WITHHOLDS per-ref cursors and emits the
4451
+ * end-of-walk token only on the final record, so the orchestrator's
4452
+ * persist-last-yielded lifecycle can never persist an unresumable mid-walk
4453
+ * token. The cost is blast-radius: an interrupted atomic run resumes
4454
+ * all-or-nothing from the prior persisted token. For atomic *backfills* that
4455
+ * radius is the whole enumerate walk — bound it with `ReadRequest.pageSize`
4456
+ * (smaller pages ⇒ shorter walks per run). Per-page atomic checkpointing is a
4457
+ * future refinement; R2 gates at end-of-walk.
4458
+ *
4459
+ * Codegen (R3) sets this from the strategy kind via `isDivisibleCursor`.
4460
+ */
4461
+ cursorDivisible = true;
4462
+ // ---- Optional filter hooks — exactly one is live per `filterPushdown` ----
4463
+ /** Pre-hydrate predicate over the cheap ref (preferred — avoids hydration). */
4464
+ matchesRef(_ref, _filter) {
4465
+ return true;
4466
+ }
4467
+ /** Post-hydrate predicate over the canonical record (the no-pushdown floor). */
4468
+ matchesRecord(_record, _filter) {
4469
+ return true;
4470
+ }
4471
+ /**
4472
+ * Resolve the filter for a subscription when adapting to `listChanges`
4473
+ * (which has no filter argument). Defaults to none; codegen wiring (R3)
4474
+ * overrides this to thread `DetectionConfig.filters`.
4475
+ */
4476
+ filterFor(_subscription) {
4477
+ return void 0;
4478
+ }
4479
+ // ---- PROVIDED by the base ----
4480
+ /**
4481
+ * Stream canonical records for a request. Filter is applied BEFORE hydrate
4482
+ * (structural: a kept ref is hydrated, a rejected one never is), so an
4483
+ * adapter cannot hydrate-then-discard. A hydrate miss (deleted mid-run) is
4484
+ * skipped, never fabricated.
4485
+ */
4486
+ async *read(req) {
4487
+ for await (const refPage of this.enumerate(req.mode, req.filter, req.pageSize)) {
4488
+ const kept = refPage.filter((ref) => this.matchesRef(ref, req.filter));
4489
+ if (kept.length === 0) continue;
4490
+ const raws = await this.hydrate(kept.map((ref) => ref.externalId));
4491
+ for (const ref of kept) {
4492
+ const raw = raws.get(ref.externalId);
4493
+ if (raw === void 0 || raw === null) continue;
4494
+ const record = this.toCanonical(raw);
4495
+ if (record !== null && this.matchesRecord(record, req.filter)) {
4496
+ yield { externalId: ref.externalId, record, raw, cursor: ref.cursor };
4497
+ }
4498
+ }
4499
+ }
4500
+ }
4501
+ /**
4502
+ * `RandomRead<T>` — single-record read, provided for free as
4503
+ * `toCanonical ∘ hydrate([id])`. Reuses the adapter's batched fetch + miss
4504
+ * tolerance; returns `null` for a missing or undecodable record.
4505
+ */
4506
+ async get(id) {
4507
+ const raws = await this.hydrate([id]);
4508
+ const raw = raws.get(id);
4509
+ if (raw === void 0 || raw === null) return null;
4510
+ return this.toCanonical(raw);
4511
+ }
4512
+ /**
4513
+ * `IChangeSource<T>` adaptation. Maps the orchestrator's by-value cursor to a
4514
+ * `ReadMode` (`null` → `full` backfill, else `delta`), streams `read()`, and
4515
+ * stamps each `SourcedRecord` into a `Change<T>`. All records surface as
4516
+ * `'updated'`; the orchestrator's diff stage classifies create-vs-update and
4517
+ * deletes arrive as tombstone refs (`toCanonical` may flag them).
4518
+ *
4519
+ * Cursor emission honors `cursorDivisible` (RFC-0003 §3). Divisible: each
4520
+ * record carries its own per-ref cursor. Atomic: per-ref cursors are withheld
4521
+ * (`undefined`, which the orchestrator skips persisting) and the end-of-walk
4522
+ * token rides only on the final record — so a mid-walk crash never persists
4523
+ * an unresumable token. If an atomic run yields no surviving records, no
4524
+ * cursor is persisted and the next run re-reads the same (empty) delta — a
4525
+ * bounded inefficiency, never data loss.
4526
+ */
4527
+ async *listChanges(subscription, cursor) {
4528
+ const mode = cursor === null || cursor === void 0 ? { kind: "full" } : { kind: "delta", cursor };
4529
+ const filter = this.filterFor(subscription);
4530
+ const stream = this.read({ mode, filter });
4531
+ if (this.cursorDivisible) {
4532
+ for await (const sourced of stream) {
4533
+ yield this.toChange(sourced, sourced.cursor);
4534
+ }
4535
+ return;
4536
+ }
4537
+ let prev = null;
4538
+ for await (const sourced of stream) {
4539
+ if (prev !== null) yield this.toChange(prev, void 0);
4540
+ prev = sourced;
4541
+ }
4542
+ if (prev !== null) yield this.toChange(prev, prev.cursor);
4543
+ }
4544
+ /** Stamp a `SourcedRecord` into a `Change<T>` with an explicit emitted cursor. */
4545
+ toChange(sourced, cursor) {
4546
+ return {
4547
+ externalId: sourced.externalId,
4548
+ operation: "updated",
4549
+ record: sourced.record,
4550
+ cursor,
4551
+ source: this.changeSource
4552
+ };
4553
+ }
4554
+ };
4555
+
4393
4556
  // runtime/subsystems/integration/integration-errors.ts
4394
4557
  var MissingTenantIdError3 = class extends Error {
4395
4558
  name = "MissingTenantIdError";
@@ -5741,6 +5904,7 @@ export {
5741
5904
  AuthController,
5742
5905
  AuthModule,
5743
5906
  CACHE,
5907
+ CURSOR_DIVISIBILITY,
5744
5908
  CacheModule,
5745
5909
  ConnectionBrokenError,
5746
5910
  DrizzleCacheService,
@@ -5751,6 +5915,10 @@ export {
5751
5915
  EVENT_BUS,
5752
5916
  EnvEncryptionKey,
5753
5917
  EventsModule,
5918
+ ExecuteIntegrationUseCase,
5919
+ INTEGRATION_CHANGE_SOURCE,
5920
+ INTEGRATION_SINK,
5921
+ IncrementalReadBase,
5754
5922
  LocalStorageBackend,
5755
5923
  MemoryCacheService,
5756
5924
  MemoryEntityChangeSourceRegistry,
@@ -5771,6 +5939,7 @@ export {
5771
5939
  UnknownEntityError,
5772
5940
  authOAuthState,
5773
5941
  collisionModeEnum,
5942
+ isDivisibleCursor,
5774
5943
  isSessionExpiredError,
5775
5944
  jobRunStatusEnum,
5776
5945
  jobRuns,
@@ -5778,6 +5947,7 @@ export {
5778
5947
  jobStepStatusEnum,
5779
5948
  jobSteps,
5780
5949
  jobs,
5950
+ mapConcurrent,
5781
5951
  parentClosePolicyEnum,
5782
5952
  replayFromEnum,
5783
5953
  triggerSourceEnum,