@pattern-stack/codegen 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +56 -1
  2. package/consumer-skills/integration/SKILL.md +11 -3
  3. package/dist/{chunk-XKWOJZZ4.js → chunk-37PILMIT.js} +4 -4
  4. package/dist/{chunk-NR7QQ6ZI.js → chunk-6M6LZEP6.js} +3 -3
  5. package/dist/{chunk-VDL5CJ5C.js → chunk-7B7MMDOJ.js} +54 -1
  6. package/dist/chunk-7B7MMDOJ.js.map +1 -0
  7. package/dist/{chunk-NXHL5YII.js → chunk-7LKAMLV4.js} +4 -4
  8. package/dist/{chunk-6DQEIXYU.js → chunk-CKLM57IE.js} +10 -10
  9. package/dist/chunk-CKLM57IE.js.map +1 -0
  10. package/dist/{chunk-QXVCRA23.js → chunk-ENAR3F5S.js} +9 -4
  11. package/dist/chunk-ENAR3F5S.js.map +1 -0
  12. package/dist/{chunk-FFUDEIFF.js → chunk-HN5HT5WL.js} +2 -2
  13. package/dist/{chunk-6ECCJVYW.js → chunk-K4BQQ2NN.js} +46 -2
  14. package/dist/chunk-K4BQQ2NN.js.map +1 -0
  15. package/dist/{chunk-QFUIE37H.js → chunk-KFXXOFDC.js} +4 -4
  16. package/dist/{chunk-O2A6XHGD.js → chunk-LLDJS7PJ.js} +2 -2
  17. package/dist/{chunk-JOBQ6RUU.js → chunk-LQZESSM3.js} +28 -1
  18. package/dist/chunk-LQZESSM3.js.map +1 -0
  19. package/dist/{chunk-JRQO2IOF.js → chunk-MU54DZCC.js} +27 -1
  20. package/dist/chunk-MU54DZCC.js.map +1 -0
  21. package/dist/{chunk-INO47JXD.js → chunk-PBENHIN2.js} +3 -3
  22. package/dist/{chunk-CLWBNXKF.js → chunk-PLUJEQLU.js} +2 -2
  23. package/dist/{chunk-DB5UXJC3.js → chunk-PNCOUFFI.js} +4 -2
  24. package/dist/chunk-PNCOUFFI.js.map +1 -0
  25. package/dist/{chunk-S7C6TIIF.js → chunk-S5G3HO7N.js} +3 -1
  26. package/dist/chunk-S5G3HO7N.js.map +1 -0
  27. package/dist/{chunk-FNHNSFIJ.js → chunk-WZOPWQN2.js} +2 -2
  28. package/dist/{chunk-TDEHU73T.js → chunk-YIVQ7KLS.js} +46 -5
  29. package/dist/chunk-YIVQ7KLS.js.map +1 -0
  30. package/dist/runtime/subsystems/auth/auth.module.js +2 -2
  31. package/dist/runtime/subsystems/auth/index.js +4 -4
  32. package/dist/runtime/subsystems/bridge/bridge.module.js +7 -7
  33. package/dist/runtime/subsystems/bridge/index.js +7 -7
  34. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +1 -1
  35. package/dist/runtime/subsystems/events/events.module.js +5 -5
  36. package/dist/runtime/subsystems/events/generated/bus.js +3 -3
  37. package/dist/runtime/subsystems/events/generated/index.d.ts +2 -2
  38. package/dist/runtime/subsystems/events/generated/index.js +9 -3
  39. package/dist/runtime/subsystems/events/generated/registry.d.ts +36 -0
  40. package/dist/runtime/subsystems/events/generated/registry.js +1 -1
  41. package/dist/runtime/subsystems/events/generated/schemas.d.ts +109 -1
  42. package/dist/runtime/subsystems/events/generated/schemas.js +7 -1
  43. package/dist/runtime/subsystems/events/generated/types.d.ts +48 -2
  44. package/dist/runtime/subsystems/events/index.js +5 -5
  45. package/dist/runtime/subsystems/index.d.ts +3 -2
  46. package/dist/runtime/subsystems/index.js +29 -25
  47. package/dist/runtime/subsystems/integration/execute-integration.use-case.d.ts +11 -1
  48. package/dist/runtime/subsystems/integration/execute-integration.use-case.js +2 -2
  49. package/dist/runtime/subsystems/integration/index.d.ts +2 -1
  50. package/dist/runtime/subsystems/integration/index.js +10 -8
  51. package/dist/runtime/subsystems/integration/integration-change-emitter.protocol.d.ts +106 -0
  52. package/dist/runtime/subsystems/integration/integration-change-emitter.protocol.js +1 -0
  53. package/dist/runtime/subsystems/integration/integration-change-emitter.protocol.js.map +1 -0
  54. package/dist/runtime/subsystems/integration/integration-cursor-store.drizzle-backend.js +2 -2
  55. package/dist/runtime/subsystems/integration/integration-run-recorder.drizzle-backend.js +2 -2
  56. package/dist/runtime/subsystems/integration/integration.module.js +4 -4
  57. package/dist/runtime/subsystems/integration/integration.tokens.d.ts +11 -1
  58. package/dist/runtime/subsystems/integration/integration.tokens.js +3 -1
  59. package/dist/runtime/subsystems/jobs/index.js +12 -12
  60. package/dist/runtime/subsystems/jobs/job-worker.d.ts +592 -4
  61. package/dist/runtime/subsystems/jobs/job-worker.js +3 -1
  62. package/dist/runtime/subsystems/jobs/job-worker.module.js +6 -6
  63. package/dist/runtime/subsystems/jobs/jobs-domain.module.d.ts +19 -0
  64. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +4 -4
  65. package/dist/runtime/subsystems/observability/index.js +3 -3
  66. package/dist/runtime/subsystems/observability/observability.module.js +3 -3
  67. package/dist/runtime/subsystems/observability/observability.service.js +2 -2
  68. package/dist/src/cli/index.js +413 -85
  69. package/dist/src/cli/index.js.map +1 -1
  70. package/dist/src/index.d.ts +490 -1
  71. package/dist/src/index.js +7 -7
  72. package/package.json +1 -1
  73. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +23 -7
  74. package/runtime/subsystems/events/generated/registry.ts +27 -0
  75. package/runtime/subsystems/events/generated/schemas.ts +26 -0
  76. package/runtime/subsystems/events/generated/types.ts +52 -0
  77. package/runtime/subsystems/index.ts +23 -0
  78. package/runtime/subsystems/integration/execute-integration.use-case.ts +69 -1
  79. package/runtime/subsystems/integration/index.ts +6 -0
  80. package/runtime/subsystems/integration/integration-change-emitter.protocol.ts +107 -0
  81. package/runtime/subsystems/integration/integration.tokens.ts +11 -0
  82. package/runtime/subsystems/jobs/job-worker.module.ts +5 -0
  83. package/runtime/subsystems/jobs/job-worker.ts +126 -12
  84. package/runtime/subsystems/jobs/jobs-domain.module.ts +19 -0
  85. package/templates/entity/new/clean-lite-ps/prompt-extension.js +59 -10
  86. package/templates/subsystem/jobs-config/codegen-config-jobs-block.ejs.t +11 -0
  87. package/dist/chunk-6DQEIXYU.js.map +0 -1
  88. package/dist/chunk-6ECCJVYW.js.map +0 -1
  89. package/dist/chunk-DB5UXJC3.js.map +0 -1
  90. package/dist/chunk-JOBQ6RUU.js.map +0 -1
  91. package/dist/chunk-JRQO2IOF.js.map +0 -1
  92. package/dist/chunk-QXVCRA23.js.map +0 -1
  93. package/dist/chunk-S7C6TIIF.js.map +0 -1
  94. package/dist/chunk-TDEHU73T.js.map +0 -1
  95. package/dist/chunk-VDL5CJ5C.js.map +0 -1
  96. /package/dist/{chunk-XKWOJZZ4.js.map → chunk-37PILMIT.js.map} +0 -0
  97. /package/dist/{chunk-NR7QQ6ZI.js.map → chunk-6M6LZEP6.js.map} +0 -0
  98. /package/dist/{chunk-NXHL5YII.js.map → chunk-7LKAMLV4.js.map} +0 -0
  99. /package/dist/{chunk-FFUDEIFF.js.map → chunk-HN5HT5WL.js.map} +0 -0
  100. /package/dist/{chunk-QFUIE37H.js.map → chunk-KFXXOFDC.js.map} +0 -0
  101. /package/dist/{chunk-O2A6XHGD.js.map → chunk-LLDJS7PJ.js.map} +0 -0
  102. /package/dist/{chunk-INO47JXD.js.map → chunk-PBENHIN2.js.map} +0 -0
  103. /package/dist/{chunk-CLWBNXKF.js.map → chunk-PLUJEQLU.js.map} +0 -0
  104. /package/dist/{chunk-FNHNSFIJ.js.map → chunk-WZOPWQN2.js.map} +0 -0
@@ -44,13 +44,13 @@ import {
44
44
  validateOrchestrationProject,
45
45
  validateProviders,
46
46
  writeManifest
47
- } from "../../chunk-6ECCJVYW.js";
47
+ } from "../../chunk-K4BQQ2NN.js";
48
48
  import "../../chunk-KVOWSC5S.js";
49
- import "../../chunk-QFUIE37H.js";
50
- import "../../chunk-FFUDEIFF.js";
51
- import "../../chunk-EO2QPOKH.js";
52
49
  import "../../chunk-PRWIX6UW.js";
53
- import "../../chunk-O2A6XHGD.js";
50
+ import "../../chunk-KFXXOFDC.js";
51
+ import "../../chunk-HN5HT5WL.js";
52
+ import "../../chunk-EO2QPOKH.js";
53
+ import "../../chunk-LLDJS7PJ.js";
54
54
  import "../../chunk-HNWZFNKP.js";
55
55
  import "../../chunk-AHV4GDYM.js";
56
56
  import "../../chunk-SQDOBLBP.js";
@@ -63,8 +63,8 @@ import {
63
63
  } from "../../chunk-5TK7MEN4.js";
64
64
  import "../../chunk-4KNXX6TI.js";
65
65
  import "../../chunk-3CJFPU6Q.js";
66
- import "../../chunk-TDEHU73T.js";
67
- import "../../chunk-S7C6TIIF.js";
66
+ import "../../chunk-YIVQ7KLS.js";
67
+ import "../../chunk-S5G3HO7N.js";
68
68
  import "../../chunk-MZ6GV4YF.js";
69
69
  import "../../chunk-LG57S2SC.js";
70
70
  import "../../chunk-U64T4YZE.js";
@@ -2151,7 +2151,7 @@ function loadEvents(eventsDir, entityNames) {
2151
2151
  function desugarEntityEvents(entity) {
2152
2152
  const entityName = entity.entity.name;
2153
2153
  const entityEvents = entity.events ?? [];
2154
- return entityEvents.map((ev) => {
2154
+ const explicit = entityEvents.map((ev) => {
2155
2155
  const payload = {};
2156
2156
  for (const [key, typeString] of Object.entries(ev.body)) {
2157
2157
  if (!isEventFieldType(typeString)) {
@@ -2173,6 +2173,56 @@ function desugarEntityEvents(entity) {
2173
2173
  };
2174
2174
  return def;
2175
2175
  });
2176
+ const changeTriad = desugarEmitChangeEvents(entity);
2177
+ return [...explicit, ...changeTriad];
2178
+ }
2179
+ var EMIT_CHANGE_SUFFIXES = ["created", "edited", "deleted"];
2180
+ function desugarEmitChangeEvents(entity) {
2181
+ if (entity.integration?.sink?.emit_changes !== true) return [];
2182
+ const entityName = entity.entity.name;
2183
+ const basePayload = {
2184
+ entity_id: {
2185
+ type: "uuid",
2186
+ nullable: false,
2187
+ description: "Local aggregate id the sink wrote/soft-deleted."
2188
+ },
2189
+ external_id: {
2190
+ type: "string",
2191
+ nullable: false,
2192
+ description: "Vendor external id the change keyed on."
2193
+ },
2194
+ provider: {
2195
+ type: "string",
2196
+ nullable: false,
2197
+ description: "Provider label (e.g. 'slack', 'google')."
2198
+ },
2199
+ source: {
2200
+ type: "string",
2201
+ nullable: false,
2202
+ description: "Provenance marker \u2014 always 'integration'. A write-back action reads this to avoid echoing the change back to the vendor."
2203
+ }
2204
+ };
2205
+ return EMIT_CHANGE_SUFFIXES.map((suffix) => {
2206
+ const payload = { ...basePayload };
2207
+ if (suffix !== "deleted") {
2208
+ payload.changed_fields = {
2209
+ type: "json",
2210
+ nullable: true,
2211
+ description: "Differ's per-field before/after map (same value as integration_run_items.changed_fields)."
2212
+ };
2213
+ }
2214
+ const def = {
2215
+ type: `${entityName}_${suffix}`,
2216
+ tier: "domain",
2217
+ direction: "change",
2218
+ aggregate: entityName,
2219
+ payload,
2220
+ retry: { attempts: 3, backoff: "exponential" },
2221
+ version: 1,
2222
+ pool: DIRECTION_TO_POOL.change
2223
+ };
2224
+ return def;
2225
+ });
2176
2226
  }
2177
2227
  function isEventFieldType(s) {
2178
2228
  return EVENT_FIELD_TYPES.includes(s);
@@ -2737,6 +2787,12 @@ function drizzleJobsExtensions(backend, cfg) {
2737
2787
  if (typeof drizzle.listen_notify === "boolean") out.listenNotify = drizzle.listen_notify;
2738
2788
  if (typeof drizzle.poll_interval_ms === "number")
2739
2789
  out.pollIntervalMs = drizzle.poll_interval_ms;
2790
+ if (typeof drizzle.stale_sweeper_interval_ms === "number")
2791
+ out.staleSweeperIntervalMs = drizzle.stale_sweeper_interval_ms;
2792
+ if (typeof drizzle.stale_threshold_ms === "number")
2793
+ out.staleThresholdMs = drizzle.stale_threshold_ms;
2794
+ if (typeof drizzle.claim_heartbeat_interval_ms === "number")
2795
+ out.claimHeartbeatIntervalMs = drizzle.claim_heartbeat_interval_ms;
2740
2796
  return Object.keys(out).length > 0 ? out : void 0;
2741
2797
  }
2742
2798
  function drizzleExtensionsClause(ext, key) {
@@ -3690,64 +3746,207 @@ import {
3690
3746
  } from "fs";
3691
3747
  import { dirname, join as join8 } from "path";
3692
3748
 
3749
+ // src/cli/shared/change-emitter-emission-generator.ts
3750
+ function generatedBanner(sourceDesc) {
3751
+ return `// @generated by @pattern-stack/codegen from ${sourceDesc} \u2014 DO NOT EDIT.
3752
+ // Hand edits are overwritten on re-emit. Regenerate with \`bun run codegen\`.`;
3753
+ }
3754
+ function changeEmitterClass(entityClass) {
3755
+ return `${entityClass}ChangeEmitter`;
3756
+ }
3757
+ function generateChangeEmitter(input) {
3758
+ const mode = input.mode ?? "package";
3759
+ const cls = changeEmitterClass(input.entityClass);
3760
+ const eventsImport = subsystemsImport(mode, "events");
3761
+ const integrationImport = subsystemsImport(mode, "integration");
3762
+ const createdType = `${input.entityName}_created`;
3763
+ const editedType = `${input.entityName}_edited`;
3764
+ const deletedType = `${input.entityName}_deleted`;
3765
+ return `${generatedBanner(input.sourceDesc)}
3766
+ import { Inject, Injectable } from '@nestjs/common';
3767
+ import { TYPED_EVENT_BUS } from '${eventsImport}';
3768
+ import type {
3769
+ IIntegrationChangeEmitter,
3770
+ IntegrationChangeNotification,
3771
+ } from '${integrationImport}';
3772
+
3773
+ /**
3774
+ * Minimal publisher shape the emitter calls \u2014 decoupled from BOTH the package's
3775
+ * \`TypedEventBus\` (whose \`EventTypeName\` union is the package's own events) and
3776
+ * the consumer's generated \`TypedEventBus\` (whose union is the consumer's
3777
+ * events). The \`${createdType}\` / \`${editedType}\` / \`${deletedType}\` events
3778
+ * THIS class publishes live in the CONSUMER's registry; the structural \`publish\`
3779
+ * here accepts the string types without depending on either compile-time union.
3780
+ * The TYPED_EVENT_BUS token resolves to the consumer's generated bus at runtime
3781
+ * (bound by EventsModule.forRoot()), which validates the payload against the
3782
+ * generated event schema.
3783
+ */
3784
+ interface ChangeEventPublisher {
3785
+ publish(
3786
+ type: string,
3787
+ aggregateId: string,
3788
+ payload: Record<string, unknown>,
3789
+ opts?: { tx?: unknown; metadata?: Record<string, unknown> },
3790
+ ): Promise<void>;
3791
+ }
3792
+
3793
+ /**
3794
+ * ${cls} \u2014 publishes the typed ${input.entityName} data-level change events
3795
+ * (\`${createdType}\` / \`${editedType}\` / \`${deletedType}\`) after the
3796
+ * integration orchestrator's sink write/soft-delete (EMIT-CHANGES seam).
3797
+ *
3798
+ * Bound to INTEGRATION_CHANGE_EMITTER in the ${input.entityName} integration
3799
+ * assembly; the orchestrator calls \`emitChange\` once per real change. The
3800
+ * payload carries \`source: 'integration'\` so a write-back action can detect an
3801
+ * integration-originated change and decline to echo it back to the vendor.
3802
+ */
3803
+ @Injectable()
3804
+ export class ${cls} implements IIntegrationChangeEmitter {
3805
+ constructor(
3806
+ @Inject(TYPED_EVENT_BUS) private readonly bus: ChangeEventPublisher,
3807
+ ) {}
3808
+
3809
+ async emitChange(notification: IntegrationChangeNotification): Promise<void> {
3810
+ const type =
3811
+ notification.action === 'created'
3812
+ ? '${createdType}'
3813
+ : notification.action === 'updated'
3814
+ ? '${editedType}'
3815
+ : '${deletedType}';
3816
+
3817
+ const payload: Record<string, unknown> = {
3818
+ entityId: notification.entityId,
3819
+ externalId: notification.externalId,
3820
+ provider: notification.provider,
3821
+ source: 'integration',
3822
+ };
3823
+ // changedFields is present on created/edited only (deletes are tombstones).
3824
+ if (notification.changedFields !== undefined) {
3825
+ payload['changedFields'] = notification.changedFields;
3826
+ }
3827
+
3828
+ await this.bus.publish(type, notification.entityId, payload, {
3829
+ tx: notification.tx,
3830
+ metadata:
3831
+ notification.tenantId !== undefined && notification.tenantId !== null
3832
+ ? { tenantId: notification.tenantId }
3833
+ : undefined,
3834
+ });
3835
+ }
3836
+ }
3837
+ `;
3838
+ }
3839
+
3693
3840
  // src/cli/shared/sink-emission-generator.ts
3694
- var SCAFFOLD_SENTINEL = "// <CODEGEN-SCAFFOLD-V1>";
3695
3841
  var USER_ID_FIELD = "userId";
3696
3842
  function sinkNames(entityClass) {
3697
3843
  return {
3698
3844
  sinkClass: `${entityClass}Sink`,
3699
- canonicalType: `${entityClass}Canonical`,
3845
+ sinkBaseClass: `${entityClass}SinkBase`,
3700
3846
  repoClass: `${entityClass}Repository`,
3701
3847
  projectionType: `${entityClass}IntegrationProjection`,
3702
- writeType: `${entityClass}IntegrationWrite`
3848
+ writeType: `${entityClass}IntegrationWrite`,
3849
+ defaultBuildWrite: `default${entityClass}BuildWrite`,
3850
+ defaultToCanonicalView: `default${entityClass}ToCanonicalView`
3703
3851
  };
3704
3852
  }
3705
- function generateDefaultSink(input) {
3853
+ function assertIntegrated(input) {
3706
3854
  if (input.pattern !== "Integrated") {
3707
3855
  throw new Error(
3708
3856
  `cannot emit default integration sink for entity '${input.entityName}': it is 'pattern: ${input.pattern}', but the default sink is emittable only for 'pattern: Integrated' entities (the only family with the integrationUpsertOne / findByExternalIdProjected projection path). Add 'pattern: Integrated' to the entity or provide a hand-authored sink.`
3709
3857
  );
3710
3858
  }
3711
- const n = sinkNames(input.entityClass);
3859
+ }
3860
+ function buildWriteBodyLines(input, n) {
3712
3861
  const hasUserIdField = input.copyThroughFields.some(
3713
3862
  (f) => f.camelName === USER_ID_FIELD
3714
3863
  );
3715
3864
  const copyThroughLines = input.copyThroughFields.filter((f) => f.camelName !== USER_ID_FIELD).map((f) => ` ${f.camelName}: record.${f.camelName},`);
3716
- const fkTodoLines = input.fkExternalKeys.map(
3717
- (fk) => ` // ${fk.writeKey}: /* TODO(author): external id of the related ${relationLabel(fk.writeKey)} */ null,`
3718
- );
3719
- const writeBodyLines = [
3865
+ const fkLines = input.fkExternalKeys.flatMap((fk) => [
3866
+ ` // SEAM (FK external key \u2014 null until you widen ${n.projectionType} to carry \`${fk.writeKey}\`):`,
3867
+ ` // Replace null with record.${fk.writeKey} after widening. Write-safe: repo skips null FKs.`,
3868
+ ` ${fk.writeKey}: null,`
3869
+ ]);
3870
+ const lines = [
3720
3871
  ` externalId: record.externalId,`
3721
3872
  ];
3722
3873
  if (copyThroughLines.length > 0) {
3723
- writeBodyLines.push(
3874
+ lines.push(
3724
3875
  ` // copy-through fields (one line per \`fields:\` entry):`,
3725
3876
  ...copyThroughLines
3726
3877
  );
3727
3878
  }
3728
- if (fkTodoLines.length > 0) {
3729
- writeBodyLines.push(
3730
- ` // FK external join-keys \u2014 projection has no external key; supply from your canonical record:`,
3731
- ...fkTodoLines
3879
+ if (fkLines.length > 0) {
3880
+ lines.push(
3881
+ ` // FK external join-keys (null until canonical widens to carry them):`,
3882
+ ...fkLines
3732
3883
  );
3733
3884
  }
3734
3885
  if (hasUserIdField) {
3735
- writeBodyLines.push(` userId,`);
3886
+ lines.push(` userId,`);
3887
+ }
3888
+ return lines;
3889
+ }
3890
+ function buildFindViewLines(input) {
3891
+ const viewFields = input.viewCopyThroughFields ?? input.copyThroughFields;
3892
+ const lines = [
3893
+ ` id: row.id,`,
3894
+ ` externalId: row.externalId,`
3895
+ ];
3896
+ for (const f of viewFields) {
3897
+ const isJson = f.tsType.startsWith("unknown");
3898
+ if (isJson) {
3899
+ lines.push(` // SEAM (typed json \u2014 unknown; narrow on canonical widen): ${f.camelName}`);
3900
+ }
3901
+ lines.push(` ${f.camelName}: row.${f.camelName},`);
3902
+ }
3903
+ for (const localFk of input.localFkColumns ?? []) {
3904
+ lines.push(` ${localFk.camelName}: row.${localFk.camelName},`);
3905
+ }
3906
+ if (input.hasTimestamps) {
3907
+ lines.push(` createdAt: row.createdAt,`);
3908
+ lines.push(` updatedAt: row.updatedAt,`);
3909
+ }
3910
+ return lines;
3911
+ }
3912
+ function buildDeleteBody(input) {
3913
+ if ((input.deleteMode ?? "delegate") === "noop") {
3914
+ return [
3915
+ `// delete:noop (YAML integration.sink.delete: noop) \u2014 tombstone-preserving:`,
3916
+ `// an upstream delete signal is a no-op here; the repo row is left intact.`,
3917
+ `// Returns null \u2192 the orchestrator records an audit noop. Override this`,
3918
+ `// method in the subclass if you need a log line.`,
3919
+ `return null;`
3920
+ ].join("\n ");
3736
3921
  }
3922
+ return `return this.repo.softDeleteByExternalId(externalId, this.provider);`;
3923
+ }
3924
+ function generateSinkBase(input) {
3925
+ assertIntegrated(input);
3926
+ const n = sinkNames(input.entityClass);
3927
+ const writeBodyLines = buildWriteBodyLines(input, n);
3737
3928
  const writeBody = writeBodyLines.join("\n");
3738
- return `${SCAFFOLD_SENTINEL}
3739
- // Scaffolded once by @pattern-stack/codegen, then author-owned. Re-running codegen
3740
- // detects the sentinel above and SKIPS this file \u2014 your edits are safe.
3929
+ const findViewLines = buildFindViewLines(input);
3930
+ const findViewBody = findViewLines.map((l) => ` ${l}`).join("\n");
3931
+ const deleteBody = buildDeleteBody(input);
3932
+ const banner = `// @generated by @pattern-stack/codegen from definitions entity '${input.entityName}' (surface: ${input.surface}) \u2014 DO NOT EDIT.
3933
+ // Hand edits are overwritten on re-emit. Regenerate with \`bun run codegen\`.
3741
3934
  //
3742
- // Default IIntegrationSink over the generated ${n.repoClass}. The PLUMBING
3743
- // (constructor, provider-match assert, repo delegation, userId scoping, return
3744
- // shapes) is generated. The canonical<->local FIELD MAPPING is the author seam:
3745
- // the canonical type is whatever your adapter's changeSource yields \u2014 the same
3746
- // seam as the IChangeSource.listChanges fetch body. For FK-free entities the
3747
- // generated ${n.projectionType} IS the canonical shape (passthrough);
3748
- // for entities with external FK join-keys, fill the marked TODO(s) below.
3749
- // Source: definitions entity '${input.entityName}' (surface: ${input.surface}).
3750
- import { Injectable } from '@nestjs/common';
3935
+ // Two-file seam (Shape C, #491, RFC-0002 \xA74):
3936
+ // THIS FILE \u2014 @generated base: two standalone default functions at concrete types
3937
+ // + abstract class ${n.sinkBaseClass}<TCanonical>.
3938
+ // A YAML field change reflows the mapping here on every run.
3939
+ // ${input.entityName}.sink.ts \u2014 emit-once subclass: \`class ${n.sinkClass} extends ${n.sinkBaseClass}\`
3940
+ // with the two one-line seam wirings. Author overrides survive regen.
3941
+ //
3942
+ // SEAM #1 (canonical widening): change \`extends ${n.sinkBaseClass}\` \u2192
3943
+ // \`extends ${n.sinkBaseClass}<YourCanonical>\` in the subclass.
3944
+ // SEAM #2 (FK activation): override \`buildWrite\` in the subclass.
3945
+ // Replace \`${n.defaultBuildWrite}(record)\` with your body that sets
3946
+ // \`<writeKey>: record.<writeKey>\` after widening the canonical to carry it.
3947
+ // SEAM #3/#4 (typed-json narrow / null-coerce on widen): override \`toCanonicalView\`.
3948
+ // The bare passthrough preserves null (diff-soundness); coerce only on canonical widen.`;
3949
+ return `${banner}
3751
3950
  import type { IIntegrationSink } from '${subsystemsImport(input.mode ?? "package", "integration")}';
3752
3951
  import {
3753
3952
  ${n.repoClass},
@@ -3755,54 +3954,110 @@ import {
3755
3954
  type ${n.writeType},
3756
3955
  } from '${input.repoImportSpecifier}';
3757
3956
 
3758
- /** Canonical type the orchestrator diffs. Defaults to the generated projection;
3759
- * widen to your adapter's canonical shape if it carries fields the projection
3760
- * does not (e.g. external FK join-keys). */
3761
- export type ${n.canonicalType} = ${n.projectionType};
3957
+ // Standalone default functions at CONCRETE projection/write types \u2014 the literal IS the
3958
+ // return type, so NO cast is needed (this is what dodges the TS2322 of a generic default
3959
+ // body). The regenerated home of the #487 write body and #488 find view.
3960
+ // Override these in the emit-once subclass via buildWrite / toCanonicalView.
3961
+ export function ${n.defaultBuildWrite}(record: ${n.projectionType}): ${n.writeType} {
3962
+ return {
3963
+ ${writeBody}
3964
+ };
3965
+ }
3762
3966
 
3763
- @Injectable()
3764
- export class ${n.sinkClass} implements IIntegrationSink<${n.canonicalType}> {
3967
+ export function ${n.defaultToCanonicalView}(row: ${n.projectionType}): ${n.projectionType} {
3968
+ // BARE passthrough \u2014 preserves null so the orchestrator's DeepEqualDiffer converges
3969
+ // to noop (null \u2260 ''; deep-equal.differ.ts:187-208). The projection-default canonical
3970
+ // is exactly the projection shape, so bare passthrough type-checks without a cast.
3971
+ const view: ${n.projectionType} = {
3972
+ ${findViewBody}
3973
+ };
3974
+ return view;
3975
+ }
3976
+
3977
+ // Abstract base \u2014 the three IIntegrationSink methods are CONCRETE (machinery: provider
3978
+ // assert, repo delegation, #490 knob-driven delete). The two protected abstract seams are
3979
+ // typed at TCanonical with NO body \u2014 a default body returning TCanonical would be TS2322
3980
+ // (Shape A failure \u2014 see RFC-0002 \xA74 and the \xA73b gate: sink-widened-canonical.gate.test.ts).
3981
+ // The bodies live in the standalone functions above; the emit-once subclass wires them in one
3982
+ // line each. No @Injectable() \u2014 the assembly binds via useFactory (OQ2 CLOSED, #491).
3983
+ export abstract class ${n.sinkBaseClass}<TCanonical = ${n.projectionType}>
3984
+ implements IIntegrationSink<TCanonical> {
3765
3985
  constructor(
3766
- private readonly repo: ${n.repoClass},
3767
- private readonly provider: string,
3986
+ protected readonly repo: ${n.repoClass},
3987
+ protected readonly provider: string,
3768
3988
  ) {}
3769
3989
 
3770
- async findByExternalId(userId: string, externalId: string): Promise<${n.canonicalType} | null> {
3990
+ async findByExternalId(userId: string, externalId: string): Promise<TCanonical | null> {
3771
3991
  const row = await this.repo.findByExternalIdProjected(externalId, this.provider);
3992
+ if (row === null) return null;
3772
3993
  // The repo lookup is (provider, externalId)-scoped. If your external_id is not
3773
3994
  // globally unique, enforce ownership here (e.g. row.userId === userId).
3774
- return row;
3995
+ return this.toCanonicalView(row);
3775
3996
  }
3776
3997
 
3777
3998
  async upsertByExternalId(
3778
3999
  userId: string,
3779
- record: ${n.canonicalType},
4000
+ record: TCanonical,
3780
4001
  provider: string,
3781
- ): Promise<{ id: string; saved: ${n.canonicalType} }> {
4002
+ ): Promise<{ id: string; saved: TCanonical }> {
3782
4003
  if (provider !== this.provider) {
3783
4004
  throw new Error(\`${n.sinkClass}: bound provider '\${this.provider}' != run provider '\${provider}'\`);
3784
4005
  }
3785
- const write: ${n.writeType} = {
3786
- ${writeBody}
3787
- };
3788
- const proj = await this.repo.integrationUpsertOne(write, this.provider);
4006
+ const proj = await this.repo.integrationUpsertOne(this.buildWrite(record), this.provider);
3789
4007
  return { id: proj.id, saved: record };
3790
4008
  }
3791
4009
 
3792
4010
  async softDeleteByExternalId(_userId: string, externalId: string): Promise<{ id: string } | null> {
3793
- return this.repo.softDeleteByExternalId(externalId, this.provider);
4011
+ ${deleteBody}
3794
4012
  }
4013
+
4014
+ // ABSTRACT seams (NO body \u2014 a default body returning TCanonical is TS2322).
4015
+ // The projection-default subclass wires the default functions; a widened
4016
+ // subclass reimplements them (FK activation / typed-json narrow / null-coerce).
4017
+ protected abstract toCanonicalView(row: ${n.projectionType}): TCanonical;
4018
+ protected abstract buildWrite(record: TCanonical): ${n.writeType};
3795
4019
  }
3796
4020
  `;
3797
4021
  }
3798
- function relationLabel(writeKey) {
3799
- const stripped = writeKey.replace(/ExternalId$/, "");
3800
- return stripped || "related entity";
4022
+ function generateSinkSubclass(input) {
4023
+ assertIntegrated(input);
4024
+ const n = sinkNames(input.entityClass);
4025
+ return `// Emit-once \u2014 author-owned. Regen never overwrites this file.
4026
+ // The mechanical mapping lives in ${input.entityName}.sink.generated.ts and reflows on every
4027
+ // codegen run (a YAML field change reflows into the @generated base + default functions).
4028
+ //
4029
+ // To WIDEN (all four seams \u2014 see ${input.entityName}.sink.generated.ts banner for detail):
4030
+ // 1. Change \`extends ${n.sinkBaseClass}\` \u2192 \`extends ${n.sinkBaseClass}<YourCanonical>\`.
4031
+ // 2. Override \`toCanonicalView\`: reshape projection, narrow typed json, coerce nulls.
4032
+ // 3. Override \`buildWrite\`: activate FK write keys (\`<writeKey>: record.<writeKey>\`).
4033
+ // 4. Optionally override \`softDeleteByExternalId\` if you want a log line.
4034
+ // (The base handles delegate/noop via the #490 YAML knob \u2014 no override needed otherwise.)
4035
+ // Source: definitions entity '${input.entityName}' (surface: ${input.surface}).
4036
+ import {
4037
+ ${n.sinkBaseClass},
4038
+ ${n.defaultToCanonicalView},
4039
+ ${n.defaultBuildWrite},
4040
+ } from './${input.entityName}.sink.generated';
4041
+ import type {
4042
+ ${n.projectionType},
4043
+ ${n.writeType},
4044
+ } from '${input.repoImportSpecifier}';
4045
+
4046
+ export class ${n.sinkClass} extends ${n.sinkBaseClass} {
4047
+ protected toCanonicalView(row: ${n.projectionType}): ${n.projectionType} {
4048
+ return ${n.defaultToCanonicalView}(row);
4049
+ }
4050
+
4051
+ protected buildWrite(record: ${n.projectionType}): ${n.writeType} {
4052
+ return ${n.defaultBuildWrite}(record);
4053
+ }
4054
+ }
4055
+ `;
3801
4056
  }
3802
4057
 
3803
4058
  // src/cli/shared/assembly-emission-generator.ts
3804
4059
  import { relative, resolve as resolve2, sep } from "path";
3805
- function generatedBanner(sourceDesc) {
4060
+ function generatedBanner2(sourceDesc) {
3806
4061
  return `// @generated by @pattern-stack/codegen from ${sourceDesc} \u2014 DO NOT EDIT.
3807
4062
  // Hand edits are overwritten on re-emit. Regenerate with \`bun run codegen\`.`;
3808
4063
  }
@@ -3833,19 +4088,30 @@ function generateAssemblyModule(input) {
3833
4088
  const token = integrationUseCaseToken(input.entityName, input.provider);
3834
4089
  const moduleClass = assemblyModuleClass(input.entityName, input.provider);
3835
4090
  const tokensImport = `../../${input.surface}-integration.tokens`;
3836
- return `${generatedBanner(input.sourceDesc)}
4091
+ const emitChanges = input.emitChanges === true;
4092
+ const emitterClass = changeEmitterClass(input.entityClass);
4093
+ const emitterImport = `../../sinks/${input.entityName}.change-emitter`;
4094
+ const integrationTokenImports = emitChanges ? ` INTEGRATION_CHANGE_EMITTER,
4095
+ INTEGRATION_CHANGE_SOURCE,
4096
+ INTEGRATION_SINK,` : ` INTEGRATION_CHANGE_SOURCE,
4097
+ INTEGRATION_SINK,`;
4098
+ const emitterImportLine = emitChanges ? `
4099
+ import { ${emitterClass} } from '${emitterImport}';` : "";
4100
+ const emitterProviderBlock = emitChanges ? `
4101
+ ${emitterClass},
4102
+ { provide: INTEGRATION_CHANGE_EMITTER, useExisting: ${emitterClass} },` : "";
4103
+ return `${generatedBanner2(input.sourceDesc)}
3837
4104
  import { Module } from '@nestjs/common';
3838
4105
  import {
3839
4106
  ExecuteIntegrationUseCase,
3840
- INTEGRATION_CHANGE_SOURCE,
3841
- INTEGRATION_SINK,
4107
+ ${integrationTokenImports}
3842
4108
  } from '${subsystemsImport(input.mode ?? "package", "integration")}';
3843
4109
  import { ${adapterClass} } from '${adapterImport}';
3844
4110
  import { ${adapterModuleClass} } from '${adapterModuleImport}';
3845
4111
  import { ${sinkClass} } from '${sinkImport}';
3846
4112
  import { ${input.repoClass} } from '${input.repoImportSpecifier}';
3847
4113
  import { ${input.moduleClass} } from '${input.moduleImportSpecifier}';
3848
- import { ${token} } from '${tokensImport}';
4114
+ import { ${token} } from '${tokensImport}';${emitterImportLine}
3849
4115
 
3850
4116
  /**
3851
4117
  * ${moduleClass} \u2014 the ${input.surface}/${input.entityName} \u2190 ${input.provider}
@@ -3855,7 +4121,12 @@ import { ${token} } from '${tokensImport}';
3855
4121
  * \`changeSources.${input.entityName}\` and INTEGRATION_SINK from
3856
4122
  * ${sinkClass}, provides a local ExecuteIntegrationUseCase, and aliases+exports
3857
4123
  * it under ${token} (the bare class token is ambiguous at app root \u2014 every
3858
- * assembly provides it). The substrate (cursor store, run recorder, differ,
4124
+ * assembly provides it).${emitChanges ? `
4125
+ *
4126
+ * EMIT-CHANGES: binds INTEGRATION_CHANGE_EMITTER to ${emitterClass} so the
4127
+ * orchestrator publishes typed ${input.entityName}_created/_edited/_deleted events
4128
+ * after every sink write/soft-delete (integration.sink.emit_changes).
4129
+ *` : ""} The substrate (cursor store, run recorder, differ,
3859
4130
  * multi-tenant flag) comes from the global IntegrationModule.forRoot(...) in
3860
4131
  * AppModule, never re-bound here.
3861
4132
  */
@@ -3871,7 +4142,7 @@ import { ${token} } from '${tokensImport}';
3871
4142
  provide: INTEGRATION_SINK,
3872
4143
  useFactory: (repo: ${input.repoClass}) => new ${sinkClass}(repo, '${input.provider}'),
3873
4144
  inject: [${input.repoClass}],
3874
- },
4145
+ },${emitterProviderBlock}
3875
4146
  ExecuteIntegrationUseCase,
3876
4147
  { provide: ${token}, useExisting: ExecuteIntegrationUseCase },
3877
4148
  ],
@@ -3891,7 +4162,7 @@ function generateIntegrationTokens(surface, entries) {
3891
4162
  * (${assemblyModuleClass(e.entityName, e.provider)}). A trigger grabs this to run it. */
3892
4163
  export const ${token} = Symbol.for('${key}');`;
3893
4164
  }).join("\n\n");
3894
- return `${generatedBanner(`surface: ${surface}`)}
4165
+ return `${generatedBanner2(`surface: ${surface}`)}
3895
4166
  /**
3896
4167
  * Use-case handles for the \`${surface}\` surface \u2014 one per (entity, provider)
3897
4168
  * assembly. Each is a Symbol \`provide:\` token a per-entity
@@ -3917,7 +4188,7 @@ function generateIntegrationAggregator(surface, entries) {
3917
4188
  return `import { ${cls} } from '${path36}';`;
3918
4189
  }).join("\n");
3919
4190
  const membersInline = moduleClasses.join(", ");
3920
- return `${generatedBanner(`surface: ${surface}`)}
4191
+ return `${generatedBanner2(`surface: ${surface}`)}
3921
4192
  import { Module } from '@nestjs/common';
3922
4193
  ${importLines || "// no (entity, provider) integration assemblies on this surface yet"}
3923
4194
 
@@ -3993,6 +4264,7 @@ function resolveEntityModuleImports(input) {
3993
4264
  }
3994
4265
 
3995
4266
  // src/cli/shared/adapter-emission-generator.ts
4267
+ import pluralize2 from "pluralize";
3996
4268
  var SURFACE_REGISTRY = {
3997
4269
  crm: {
3998
4270
  packageName: "@pattern-stack/codegen-crm",
@@ -4068,8 +4340,8 @@ var SURFACE_REGISTRY = {
4068
4340
  function isClientlessProvider(surfaces) {
4069
4341
  return surfaces.length > 0 && surfaces.every((s) => SURFACE_REGISTRY[s]?.readPrimitive === true);
4070
4342
  }
4071
- var SCAFFOLD_SENTINEL2 = "// <CODEGEN-SCAFFOLD-V1>";
4072
- function generatedBanner2(sourceDesc) {
4343
+ var SCAFFOLD_SENTINEL = "// <CODEGEN-SCAFFOLD-V1>";
4344
+ function generatedBanner3(sourceDesc) {
4073
4345
  return `// @generated by @pattern-stack/codegen from ${sourceDesc} \u2014 DO NOT EDIT.
4074
4346
  // Hand edits are overwritten on re-emit. Regenerate with \`bun run codegen\`.`;
4075
4347
  }
@@ -4278,7 +4550,7 @@ ${l2Members}
4278
4550
  const ctorOpen = rp ? ` constructor(@Inject(${n.strategyToken}) readonly auth: IAuthStrategy) {${changeSourcesAssign}}` : ` constructor(
4279
4551
  @Inject(${n.strategyToken}) readonly auth: IAuthStrategy,${ctorClientParam}
4280
4552
  ) {${changeSourcesAssign}}`;
4281
- return `${SCAFFOLD_SENTINEL2}
4553
+ return `${SCAFFOLD_SENTINEL}
4282
4554
  // Scaffolded once by @pattern-stack/codegen, then author-owned. Re-running
4283
4555
  // codegen detects the sentinel above and SKIPS this file \u2014 your edits are safe.
4284
4556
  // Source: definitions/providers/${def.slug}.yaml (surface: ${surface}).
@@ -4309,7 +4581,7 @@ ${changeSourcesDecl}
4309
4581
  }
4310
4582
  function generateAdapterModule(def, surface) {
4311
4583
  const n = names(def.slug, surface);
4312
- return `${generatedBanner2(`definitions/providers/${def.slug}.yaml (surface: ${surface})`)}
4584
+ return `${generatedBanner3(`definitions/providers/${def.slug}.yaml (surface: ${surface})`)}
4313
4585
  import { Module } from '@nestjs/common';
4314
4586
  import { ${n.providerModuleClass} } from '../../../providers/${def.slug}/${def.slug}.provider.module';
4315
4587
  import { ${n.adapterClass} } from './${def.slug}-${surface}.adapter';
@@ -4327,13 +4599,13 @@ function generateAdaptersBarrel(surface, providerSlugs) {
4327
4599
  const n = names(slug, surface);
4328
4600
  return `export { ${n.adapterModuleClass} } from './${slug}/${slug}-${surface}.adapter.module';`;
4329
4601
  }).join("\n");
4330
- return `${generatedBanner2(`definitions/providers/*.yaml (surface: ${surface})`)}
4602
+ return `${generatedBanner3(`definitions/providers/*.yaml (surface: ${surface})`)}
4331
4603
  ${lines}
4332
4604
  `;
4333
4605
  }
4334
4606
  function generateSurfaceTokens(surface, mode = "package") {
4335
4607
  const n = names("__placeholder__", surface);
4336
- return `${generatedBanner2(`surface: ${surface}`)}
4608
+ return `${generatedBanner3(`surface: ${surface}`)}
4337
4609
  import type { IChangeSource } from '${subsystemsImport(mode, "integration")}';
4338
4610
 
4339
4611
  /** The assembled list of every ${surface} adapter's contribution. */
@@ -4372,7 +4644,7 @@ function generateSurfaceAggregator(surface, providerSlugs, mode = "package") {
4372
4644
  return `${lowerFirst(p.adapterClass)}: ${p.adapterClass}`;
4373
4645
  }).join(", ");
4374
4646
  const injectTokens = per.map((p) => p.adapterClass).join(", ");
4375
- return `${generatedBanner2(`surface: ${surface}`)}
4647
+ return `${generatedBanner3(`surface: ${surface}`)}
4376
4648
  import { Module } from '@nestjs/common';
4377
4649
  import {
4378
4650
  MemoryEntityChangeSourceRegistry,
@@ -4444,7 +4716,7 @@ function generateTypedView(surface, providerSlugs, entities) {
4444
4716
  const providerUnion = slugs.length ? slugs.map((s) => `'${s}'`).join(" | ") : "never";
4445
4717
  const entityUnion = ents.length ? ents.map((e) => `'${e}'`).join(" | ") : "never";
4446
4718
  const mapEntries = slugs.map((s) => ` ${jsKey(s)}: ${surfacePascal}Entity;`).join("\n");
4447
- return `${generatedBanner2(`surface: ${surface}`)}
4719
+ return `${generatedBanner3(`surface: ${surface}`)}
4448
4720
  /**
4449
4721
  * Per-consumer typed view for the \`${surface}\` surface. Surface-scoped unions
4450
4722
  * + a (provider, entity) validity map for compile-time-checked consumer
@@ -4474,6 +4746,7 @@ function emitAdapters(opts) {
4474
4746
  scaffoldsSkipped: [],
4475
4747
  skippedSurfaces: [],
4476
4748
  assembliesWritten: [],
4749
+ changeEmittersWritten: [],
4477
4750
  tokensWritten: [],
4478
4751
  integrationAggregatorsWritten: [],
4479
4752
  skippedAssemblies: []
@@ -4574,14 +4847,31 @@ function emitAdapters(opts) {
4574
4847
  backendSrcAbs: opts.backendSrcAbs,
4575
4848
  aliases
4576
4849
  });
4577
- const sinkPath = join8(sinksDir, `${entityName}.sink.ts`);
4578
- if (existsSync6(sinkPath)) {
4579
- result.scaffoldsSkipped.push(sinkPath);
4850
+ const sinkInput = buildSinkInput(def, surface, slugs[0], loc.repoImportSpecifier);
4851
+ const basePath = join8(sinksDir, `${entityName}.sink.generated.ts`);
4852
+ const baseContent = generateSinkBase({ ...sinkInput, mode });
4853
+ if (!opts.dryRun) writeIfChanged(basePath, baseContent);
4854
+ result.written.push(basePath);
4855
+ const subclassPath = join8(sinksDir, `${entityName}.sink.ts`);
4856
+ if (existsSync6(subclassPath)) {
4857
+ result.scaffoldsSkipped.push(subclassPath);
4580
4858
  } else {
4581
- const sinkInput = buildSinkInput(def, surface, slugs[0], loc.repoImportSpecifier);
4582
- const sinkContent = generateDefaultSink({ ...sinkInput, mode });
4583
- if (!opts.dryRun) writeFile(sinkPath, sinkContent);
4584
- result.scaffoldsWritten.push(sinkPath);
4859
+ const subclassContent = generateSinkSubclass({ ...sinkInput, mode });
4860
+ if (!opts.dryRun) writeFile(subclassPath, subclassContent);
4861
+ result.scaffoldsWritten.push(subclassPath);
4862
+ }
4863
+ const emitChanges = def?.integration?.sink?.emit_changes === true;
4864
+ if (emitChanges) {
4865
+ const emitterPath = join8(sinksDir, `${entityName}.change-emitter.ts`);
4866
+ const emitterContent = generateChangeEmitter({
4867
+ entityName,
4868
+ entityClass: loc.entityClass,
4869
+ surface,
4870
+ sourceDesc: `definitions entity '${entityName}' (integration.sink.emit_changes)`,
4871
+ mode
4872
+ });
4873
+ if (!opts.dryRun) writeIfChanged(emitterPath, emitterContent);
4874
+ result.changeEmittersWritten.push(emitterPath);
4585
4875
  }
4586
4876
  for (const slug of slugs) {
4587
4877
  const assemblyPath = join8(
@@ -4599,7 +4889,8 @@ function emitAdapters(opts) {
4599
4889
  repoImportSpecifier: loc.repoImportSpecifier,
4600
4890
  repoClass: loc.repoClass,
4601
4891
  sourceDesc: `definitions/providers/${slug}.yaml`,
4602
- mode
4892
+ mode,
4893
+ emitChanges
4603
4894
  });
4604
4895
  if (!opts.dryRun) writeIfChanged(assemblyPath, assemblyContent);
4605
4896
  result.assembliesWritten.push(assemblyPath);
@@ -4630,6 +4921,13 @@ function emitAdapters(opts) {
4630
4921
  }
4631
4922
  return result;
4632
4923
  }
4924
+ function fkWriteKey(target, foreignKey, isSelfFk) {
4925
+ if (isSelfFk) {
4926
+ const base = foreignKey.endsWith("_id") ? foreignKey.slice(0, -3) : foreignKey;
4927
+ return snakeToCamel(base) + "ExternalId";
4928
+ }
4929
+ return `${target}ExternalId`;
4930
+ }
4633
4931
  function buildSinkInput(def, surface, provider, repoImportSpecifier) {
4634
4932
  const fields = def.fields ?? {};
4635
4933
  const relationships = def.relationships ?? {};
@@ -4639,14 +4937,35 @@ function buildSinkInput(def, surface, provider, repoImportSpecifier) {
4639
4937
  fkColumns.add(rel2.foreign_key);
4640
4938
  }
4641
4939
  }
4642
- const copyThroughFields = Object.entries(fields).filter(([name]) => name !== "id" && !fkColumns.has(name)).map(([name, f]) => ({
4940
+ const excludeSet = new Set(def.integration?.sink?.exclude_fields ?? []);
4941
+ const isCopyThrough = ([name]) => name !== "id" && !fkColumns.has(name);
4942
+ const viewCopyThroughFields = Object.entries(fields).filter(isCopyThrough).map(([name, f]) => ({
4943
+ camelName: snakeToCamel(name),
4944
+ tsType: tsTypeFor(f.type, f.nullable)
4945
+ }));
4946
+ const copyThroughFields = Object.entries(fields).filter((entry) => isCopyThrough(entry) && !excludeSet.has(entry[0])).map(([name, f]) => ({
4643
4947
  camelName: snakeToCamel(name),
4644
4948
  tsType: tsTypeFor(f.type, f.nullable)
4645
4949
  }));
4950
+ const entityNamePlural = def.entity.plural ?? pluralize2.plural(def.entity.name);
4646
4951
  const fkExternalKeys = Object.entries(relationships).filter(([, rel2]) => rel2.type === "belongs_to").map(([relName, rel2]) => {
4647
4952
  const target = rel2.target ?? relName;
4648
- return { writeKey: `${snakeToCamel(target)}ExternalId` };
4953
+ const foreignKey = rel2.foreign_key ?? `${target}_id`;
4954
+ const isSelfFk = pluralize2.plural(target) === entityNamePlural;
4955
+ return { writeKey: fkWriteKey(target, foreignKey, isSelfFk) };
4649
4956
  });
4957
+ const localFkColumns = Object.entries(relationships).filter(([, rel2]) => rel2.type === "belongs_to").map(([relName, rel2]) => {
4958
+ const foreignKey = rel2.foreign_key ?? `${relName}_id`;
4959
+ return {
4960
+ camelName: snakeToCamel(foreignKey),
4961
+ tsType: rel2.nullable ? "string | null" : "string"
4962
+ };
4963
+ });
4964
+ const hasTimestamps2 = (def.behaviors ?? []).some(
4965
+ (b) => typeof b === "string" ? b === "timestamps" : b.name === "timestamps"
4966
+ );
4967
+ const deleteKnob = def.integration?.sink?.delete;
4968
+ const deleteMode = deleteKnob === "noop" ? "noop" : "delegate";
4650
4969
  return {
4651
4970
  entityName: def.entity.name,
4652
4971
  entityClass: pascalFromSnake(def.entity.name),
@@ -4654,8 +4973,12 @@ function buildSinkInput(def, surface, provider, repoImportSpecifier) {
4654
4973
  pattern: "Integrated",
4655
4974
  provider,
4656
4975
  copyThroughFields,
4976
+ viewCopyThroughFields,
4657
4977
  fkExternalKeys,
4658
- repoImportSpecifier
4978
+ localFkColumns,
4979
+ hasTimestamps: hasTimestamps2,
4980
+ repoImportSpecifier,
4981
+ deleteMode
4659
4982
  };
4660
4983
  }
4661
4984
  var TS_TYPE_FOR_SINK = {
@@ -4901,12 +5224,12 @@ function resolveSyncMode(entity, config) {
4901
5224
  // src/emitters/frontend/emit-utils.ts
4902
5225
  import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
4903
5226
  import { dirname as dirname3 } from "path";
4904
- function generatedBanner3(sourceDesc) {
5227
+ function generatedBanner4(sourceDesc) {
4905
5228
  return `// @generated by @pattern-stack/codegen from ${sourceDesc} \u2014 DO NOT EDIT.
4906
5229
  // Hand edits are overwritten on re-emit. Regenerate with \`bun run codegen\`.`;
4907
5230
  }
4908
5231
  function withBanner(sourceDesc, body) {
4909
- return `${generatedBanner3(sourceDesc)}
5232
+ return `${generatedBanner4(sourceDesc)}
4910
5233
 
4911
5234
  ${body}`;
4912
5235
  }
@@ -6742,6 +7065,11 @@ var EntityNewCommand = class extends Command2 {
6742
7065
  `integration assembly codegen: ${adapterResult.assembliesWritten.length} module(s) + ${adapterResult.tokensWritten.length} tokens file(s) + ${adapterResult.integrationAggregatorsWritten.length} aggregator(s)`
6743
7066
  );
6744
7067
  }
7068
+ if (adapterResult.changeEmittersWritten.length) {
7069
+ printInfo(
7070
+ `integration change-emitters (emit_changes): ${adapterResult.changeEmittersWritten.length} emitter(s)`
7071
+ );
7072
+ }
6745
7073
  for (const s of adapterResult.scaffoldsSkipped) {
6746
7074
  printInfo(`skipped scaffold ${s} (author-owned)`);
6747
7075
  }