@pattern-stack/codegen 0.21.0 → 0.23.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 (66) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +5 -1
  3. package/dist/{chunk-3A34R6CI.js → chunk-3VEVGL74.js} +4 -4
  4. package/dist/{chunk-G3IKPDTP.js → chunk-42763UEE.js} +2 -2
  5. package/dist/{chunk-524YKITE.js → chunk-4M66MQYA.js} +50 -6
  6. package/dist/chunk-4M66MQYA.js.map +1 -0
  7. package/dist/{chunk-EEJC66ZF.js → chunk-6XP2Q5SS.js} +3 -3
  8. package/dist/{chunk-6CJRZHV4.js → chunk-7B7MMDOJ.js} +57 -4
  9. package/dist/chunk-7B7MMDOJ.js.map +1 -0
  10. package/dist/{chunk-NXHL5YII.js → chunk-7LKAMLV4.js} +4 -4
  11. package/dist/{chunk-7625PLY7.js → chunk-COGHTKXY.js} +4 -4
  12. package/dist/{chunk-GV337QP3.js → chunk-E5FJWOMP.js} +7 -7
  13. package/dist/{chunk-TKU6VYG3.js → chunk-E6PLM6QG.js} +6 -6
  14. package/dist/{chunk-GMRTI7AK.js → chunk-FIUC6QB5.js} +3 -3
  15. package/dist/chunk-FIUC6QB5.js.map +1 -0
  16. package/dist/{chunk-YXI7K4MJ.js → chunk-PNCOUFFI.js} +7 -5
  17. package/dist/chunk-PNCOUFFI.js.map +1 -0
  18. package/dist/{chunk-MBFSG4KQ.js → chunk-SH76CFAY.js} +9 -4
  19. package/dist/chunk-SH76CFAY.js.map +1 -0
  20. package/dist/runtime/subsystems/auth/auth.module.js +3 -3
  21. package/dist/runtime/subsystems/auth/index.js +7 -7
  22. package/dist/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.js +1 -1
  23. package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js +4 -4
  24. package/dist/runtime/subsystems/bridge/bridge.module.js +10 -10
  25. package/dist/runtime/subsystems/bridge/index.js +13 -13
  26. package/dist/runtime/subsystems/cache/cache.module.js +2 -2
  27. package/dist/runtime/subsystems/cache/index.js +4 -4
  28. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +2 -2
  29. package/dist/runtime/subsystems/events/events.module.js +3 -3
  30. package/dist/runtime/subsystems/events/index.js +5 -5
  31. package/dist/runtime/subsystems/index.js +48 -48
  32. package/dist/runtime/subsystems/jobs/index.js +18 -18
  33. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +5 -5
  34. package/dist/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.js +2 -2
  35. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +2 -2
  36. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js +3 -3
  37. package/dist/runtime/subsystems/jobs/job-worker.d.ts +592 -4
  38. package/dist/runtime/subsystems/jobs/job-worker.js +4 -2
  39. package/dist/runtime/subsystems/jobs/job-worker.module.js +6 -6
  40. package/dist/runtime/subsystems/jobs/jobs-domain.module.d.ts +19 -0
  41. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +4 -4
  42. package/dist/runtime/subsystems/storage/index.js +1 -1
  43. package/dist/runtime/subsystems/storage/storage.module.js +1 -1
  44. package/dist/src/cli/index.js +226 -65
  45. package/dist/src/cli/index.js.map +1 -1
  46. package/dist/src/index.d.ts +477 -1
  47. package/dist/src/index.js +1 -1
  48. package/package.json +1 -1
  49. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +23 -7
  50. package/runtime/subsystems/jobs/job-worker.module.ts +5 -0
  51. package/runtime/subsystems/jobs/job-worker.ts +126 -12
  52. package/runtime/subsystems/jobs/jobs-domain.module.ts +19 -0
  53. package/templates/entity/new/clean-lite-ps/prompt-extension.js +59 -10
  54. package/templates/subsystem/jobs-config/codegen-config-jobs-block.ejs.t +11 -0
  55. package/dist/chunk-524YKITE.js.map +0 -1
  56. package/dist/chunk-6CJRZHV4.js.map +0 -1
  57. package/dist/chunk-GMRTI7AK.js.map +0 -1
  58. package/dist/chunk-MBFSG4KQ.js.map +0 -1
  59. package/dist/chunk-YXI7K4MJ.js.map +0 -1
  60. /package/dist/{chunk-3A34R6CI.js.map → chunk-3VEVGL74.js.map} +0 -0
  61. /package/dist/{chunk-G3IKPDTP.js.map → chunk-42763UEE.js.map} +0 -0
  62. /package/dist/{chunk-EEJC66ZF.js.map → chunk-6XP2Q5SS.js.map} +0 -0
  63. /package/dist/{chunk-NXHL5YII.js.map → chunk-7LKAMLV4.js.map} +0 -0
  64. /package/dist/{chunk-7625PLY7.js.map → chunk-COGHTKXY.js.map} +0 -0
  65. /package/dist/{chunk-GV337QP3.js.map → chunk-E5FJWOMP.js.map} +0 -0
  66. /package/dist/{chunk-TKU6VYG3.js.map → chunk-E6PLM6QG.js.map} +0 -0
@@ -2,11 +2,11 @@ import {
2
2
  JOB_WORKER_MODULE_OPTIONS,
3
3
  JobWorkerModule,
4
4
  JobWorkerOrchestrator
5
- } from "../../../chunk-MBFSG4KQ.js";
6
- import "../../../chunk-6CJRZHV4.js";
7
- import "../../../chunk-GMRTI7AK.js";
5
+ } from "../../../chunk-SH76CFAY.js";
6
+ import "../../../chunk-7B7MMDOJ.js";
7
+ import "../../../chunk-FIUC6QB5.js";
8
8
  import "../../../chunk-VQOAATIG.js";
9
- import "../../../chunk-3A34R6CI.js";
9
+ import "../../../chunk-3VEVGL74.js";
10
10
  import "../../../chunk-CDLWYZVQ.js";
11
11
  import "../../../chunk-L3LZWWSX.js";
12
12
  import "../../../chunk-DV4RV2DC.js";
@@ -14,12 +14,12 @@ import "../../../chunk-PNZSGAB2.js";
14
14
  import "../../../chunk-SNQ3TOWP.js";
15
15
  import "../../../chunk-I6MVCB5A.js";
16
16
  import "../../../chunk-RHVN6NA7.js";
17
- import "../../../chunk-TKU6VYG3.js";
17
+ import "../../../chunk-E6PLM6QG.js";
18
18
  import "../../../chunk-T4BIIU5E.js";
19
19
  import "../../../chunk-Q6LRJ4VI.js";
20
- import "../../../chunk-OKXZ63IA.js";
21
20
  import "../../../chunk-7P5ODGLA.js";
22
21
  import "../../../chunk-ZPL74UQN.js";
22
+ import "../../../chunk-OKXZ63IA.js";
23
23
  import "../../../chunk-GYGNEQSC.js";
24
24
  import "../../../chunk-U64T4YZE.js";
25
25
  import "../../../chunk-2E224ZSN.js";
@@ -37,6 +37,25 @@ interface DrizzleBackendExtensions {
37
37
  listenNotify?: boolean;
38
38
  /** Polling interval (ms). Default 1000. */
39
39
  pollIntervalMs?: number;
40
+ /**
41
+ * CLAIM-HB-1 — stale-claim sweep interval (ms). How often each worker scans
42
+ * for `running` rows whose lease has expired. Default 60_000.
43
+ */
44
+ staleSweeperIntervalMs?: number;
45
+ /**
46
+ * CLAIM-HB-1 — stale-claim threshold (ms). A `running` row whose `claimed_at`
47
+ * has not been renewed within this window is presumed stranded by a dead
48
+ * worker and reset to `pending`. A LIVE worker renews the lease every
49
+ * `claimHeartbeatIntervalMs`, so this only catches genuine crashes. Default
50
+ * 300_000 (5 min).
51
+ */
52
+ staleThresholdMs?: number;
53
+ /**
54
+ * CLAIM-HB-1 — claim heartbeat interval (ms). How often a worker bumps
55
+ * `claimed_at` for its in-flight runs to keep them from being swept. Must be
56
+ * comfortably below `staleThresholdMs`. Default `staleThresholdMs / 3`.
57
+ */
58
+ claimHeartbeatIntervalMs?: number;
40
59
  }
41
60
  interface JobsDomainModuleOptions {
42
61
  backend: 'drizzle' | 'memory' | 'bullmq';
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  JobsDomainModule
3
- } from "../../../chunk-GMRTI7AK.js";
3
+ } from "../../../chunk-FIUC6QB5.js";
4
4
  import "../../../chunk-VQOAATIG.js";
5
- import "../../../chunk-3A34R6CI.js";
5
+ import "../../../chunk-3VEVGL74.js";
6
6
  import "../../../chunk-CDLWYZVQ.js";
7
7
  import "../../../chunk-L3LZWWSX.js";
8
8
  import "../../../chunk-DV4RV2DC.js";
@@ -10,12 +10,12 @@ import "../../../chunk-PNZSGAB2.js";
10
10
  import "../../../chunk-SNQ3TOWP.js";
11
11
  import "../../../chunk-I6MVCB5A.js";
12
12
  import "../../../chunk-RHVN6NA7.js";
13
- import "../../../chunk-TKU6VYG3.js";
13
+ import "../../../chunk-E6PLM6QG.js";
14
14
  import "../../../chunk-T4BIIU5E.js";
15
15
  import "../../../chunk-Q6LRJ4VI.js";
16
- import "../../../chunk-OKXZ63IA.js";
17
16
  import "../../../chunk-7P5ODGLA.js";
18
17
  import "../../../chunk-ZPL74UQN.js";
18
+ import "../../../chunk-OKXZ63IA.js";
19
19
  import "../../../chunk-GYGNEQSC.js";
20
20
  import "../../../chunk-U64T4YZE.js";
21
21
  import "../../../chunk-2E224ZSN.js";
@@ -8,10 +8,10 @@ import {
8
8
  import {
9
9
  MemoryStorageBackend
10
10
  } from "../../../chunk-3SZFUTXE.js";
11
- import "../../../chunk-J6MN42LG.js";
12
11
  import {
13
12
  STORAGE
14
13
  } from "../../../chunk-NYBCQZC7.js";
14
+ import "../../../chunk-J6MN42LG.js";
15
15
  import "../../../chunk-GYGNEQSC.js";
16
16
  import "../../../chunk-2E224ZSN.js";
17
17
  export {
@@ -3,8 +3,8 @@ import {
3
3
  } from "../../../chunk-4MVGAMUA.js";
4
4
  import "../../../chunk-JWNHNUYL.js";
5
5
  import "../../../chunk-3SZFUTXE.js";
6
- import "../../../chunk-J6MN42LG.js";
7
6
  import "../../../chunk-NYBCQZC7.js";
7
+ import "../../../chunk-J6MN42LG.js";
8
8
  import "../../../chunk-GYGNEQSC.js";
9
9
  import "../../../chunk-2E224ZSN.js";
10
10
  export {
@@ -44,7 +44,7 @@ import {
44
44
  validateOrchestrationProject,
45
45
  validateProviders,
46
46
  writeManifest
47
- } from "../../chunk-524YKITE.js";
47
+ } from "../../chunk-4M66MQYA.js";
48
48
  import "../../chunk-KVOWSC5S.js";
49
49
  import "../../chunk-QFUIE37H.js";
50
50
  import "../../chunk-FFUDEIFF.js";
@@ -2737,6 +2737,12 @@ function drizzleJobsExtensions(backend, cfg) {
2737
2737
  if (typeof drizzle.listen_notify === "boolean") out.listenNotify = drizzle.listen_notify;
2738
2738
  if (typeof drizzle.poll_interval_ms === "number")
2739
2739
  out.pollIntervalMs = drizzle.poll_interval_ms;
2740
+ if (typeof drizzle.stale_sweeper_interval_ms === "number")
2741
+ out.staleSweeperIntervalMs = drizzle.stale_sweeper_interval_ms;
2742
+ if (typeof drizzle.stale_threshold_ms === "number")
2743
+ out.staleThresholdMs = drizzle.stale_threshold_ms;
2744
+ if (typeof drizzle.claim_heartbeat_interval_ms === "number")
2745
+ out.claimHeartbeatIntervalMs = drizzle.claim_heartbeat_interval_ms;
2740
2746
  return Object.keys(out).length > 0 ? out : void 0;
2741
2747
  }
2742
2748
  function drizzleExtensionsClause(ext, key) {
@@ -3691,63 +3697,115 @@ import {
3691
3697
  import { dirname, join as join8 } from "path";
3692
3698
 
3693
3699
  // src/cli/shared/sink-emission-generator.ts
3694
- var SCAFFOLD_SENTINEL = "// <CODEGEN-SCAFFOLD-V1>";
3695
3700
  var USER_ID_FIELD = "userId";
3696
3701
  function sinkNames(entityClass) {
3697
3702
  return {
3698
3703
  sinkClass: `${entityClass}Sink`,
3699
- canonicalType: `${entityClass}Canonical`,
3704
+ sinkBaseClass: `${entityClass}SinkBase`,
3700
3705
  repoClass: `${entityClass}Repository`,
3701
3706
  projectionType: `${entityClass}IntegrationProjection`,
3702
- writeType: `${entityClass}IntegrationWrite`
3707
+ writeType: `${entityClass}IntegrationWrite`,
3708
+ defaultBuildWrite: `default${entityClass}BuildWrite`,
3709
+ defaultToCanonicalView: `default${entityClass}ToCanonicalView`
3703
3710
  };
3704
3711
  }
3705
- function generateDefaultSink(input) {
3712
+ function assertIntegrated(input) {
3706
3713
  if (input.pattern !== "Integrated") {
3707
3714
  throw new Error(
3708
3715
  `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
3716
  );
3710
3717
  }
3711
- const n = sinkNames(input.entityClass);
3718
+ }
3719
+ function buildWriteBodyLines(input, n) {
3712
3720
  const hasUserIdField = input.copyThroughFields.some(
3713
3721
  (f) => f.camelName === USER_ID_FIELD
3714
3722
  );
3715
3723
  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 = [
3724
+ const fkLines = input.fkExternalKeys.flatMap((fk) => [
3725
+ ` // SEAM (FK external key \u2014 null until you widen ${n.projectionType} to carry \`${fk.writeKey}\`):`,
3726
+ ` // Replace null with record.${fk.writeKey} after widening. Write-safe: repo skips null FKs.`,
3727
+ ` ${fk.writeKey}: null,`
3728
+ ]);
3729
+ const lines = [
3720
3730
  ` externalId: record.externalId,`
3721
3731
  ];
3722
3732
  if (copyThroughLines.length > 0) {
3723
- writeBodyLines.push(
3733
+ lines.push(
3724
3734
  ` // copy-through fields (one line per \`fields:\` entry):`,
3725
3735
  ...copyThroughLines
3726
3736
  );
3727
3737
  }
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
3738
+ if (fkLines.length > 0) {
3739
+ lines.push(
3740
+ ` // FK external join-keys (null until canonical widens to carry them):`,
3741
+ ...fkLines
3732
3742
  );
3733
3743
  }
3734
3744
  if (hasUserIdField) {
3735
- writeBodyLines.push(` userId,`);
3745
+ lines.push(` userId,`);
3746
+ }
3747
+ return lines;
3748
+ }
3749
+ function buildFindViewLines(input) {
3750
+ const viewFields = input.viewCopyThroughFields ?? input.copyThroughFields;
3751
+ const lines = [
3752
+ ` id: row.id,`,
3753
+ ` externalId: row.externalId,`
3754
+ ];
3755
+ for (const f of viewFields) {
3756
+ const isJson = f.tsType.startsWith("unknown");
3757
+ if (isJson) {
3758
+ lines.push(` // SEAM (typed json \u2014 unknown; narrow on canonical widen): ${f.camelName}`);
3759
+ }
3760
+ lines.push(` ${f.camelName}: row.${f.camelName},`);
3761
+ }
3762
+ for (const localFk of input.localFkColumns ?? []) {
3763
+ lines.push(` ${localFk.camelName}: row.${localFk.camelName},`);
3764
+ }
3765
+ if (input.hasTimestamps) {
3766
+ lines.push(` createdAt: row.createdAt,`);
3767
+ lines.push(` updatedAt: row.updatedAt,`);
3736
3768
  }
3769
+ return lines;
3770
+ }
3771
+ function buildDeleteBody(input) {
3772
+ if ((input.deleteMode ?? "delegate") === "noop") {
3773
+ return [
3774
+ `// delete:noop (YAML integration.sink.delete: noop) \u2014 tombstone-preserving:`,
3775
+ `// an upstream delete signal is a no-op here; the repo row is left intact.`,
3776
+ `// Returns null \u2192 the orchestrator records an audit noop. Override this`,
3777
+ `// method in the subclass if you need a log line.`,
3778
+ `return null;`
3779
+ ].join("\n ");
3780
+ }
3781
+ return `return this.repo.softDeleteByExternalId(externalId, this.provider);`;
3782
+ }
3783
+ function generateSinkBase(input) {
3784
+ assertIntegrated(input);
3785
+ const n = sinkNames(input.entityClass);
3786
+ const writeBodyLines = buildWriteBodyLines(input, n);
3737
3787
  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.
3788
+ const findViewLines = buildFindViewLines(input);
3789
+ const findViewBody = findViewLines.map((l) => ` ${l}`).join("\n");
3790
+ const deleteBody = buildDeleteBody(input);
3791
+ const banner = `// @generated by @pattern-stack/codegen from definitions entity '${input.entityName}' (surface: ${input.surface}) \u2014 DO NOT EDIT.
3792
+ // Hand edits are overwritten on re-emit. Regenerate with \`bun run codegen\`.
3741
3793
  //
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';
3794
+ // Two-file seam (Shape C, #491, RFC-0002 \xA74):
3795
+ // THIS FILE \u2014 @generated base: two standalone default functions at concrete types
3796
+ // + abstract class ${n.sinkBaseClass}<TCanonical>.
3797
+ // A YAML field change reflows the mapping here on every run.
3798
+ // ${input.entityName}.sink.ts \u2014 emit-once subclass: \`class ${n.sinkClass} extends ${n.sinkBaseClass}\`
3799
+ // with the two one-line seam wirings. Author overrides survive regen.
3800
+ //
3801
+ // SEAM #1 (canonical widening): change \`extends ${n.sinkBaseClass}\` \u2192
3802
+ // \`extends ${n.sinkBaseClass}<YourCanonical>\` in the subclass.
3803
+ // SEAM #2 (FK activation): override \`buildWrite\` in the subclass.
3804
+ // Replace \`${n.defaultBuildWrite}(record)\` with your body that sets
3805
+ // \`<writeKey>: record.<writeKey>\` after widening the canonical to carry it.
3806
+ // SEAM #3/#4 (typed-json narrow / null-coerce on widen): override \`toCanonicalView\`.
3807
+ // The bare passthrough preserves null (diff-soundness); coerce only on canonical widen.`;
3808
+ return `${banner}
3751
3809
  import type { IIntegrationSink } from '${subsystemsImport(input.mode ?? "package", "integration")}';
3752
3810
  import {
3753
3811
  ${n.repoClass},
@@ -3755,49 +3813,105 @@ import {
3755
3813
  type ${n.writeType},
3756
3814
  } from '${input.repoImportSpecifier}';
3757
3815
 
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};
3816
+ // Standalone default functions at CONCRETE projection/write types \u2014 the literal IS the
3817
+ // return type, so NO cast is needed (this is what dodges the TS2322 of a generic default
3818
+ // body). The regenerated home of the #487 write body and #488 find view.
3819
+ // Override these in the emit-once subclass via buildWrite / toCanonicalView.
3820
+ export function ${n.defaultBuildWrite}(record: ${n.projectionType}): ${n.writeType} {
3821
+ return {
3822
+ ${writeBody}
3823
+ };
3824
+ }
3762
3825
 
3763
- @Injectable()
3764
- export class ${n.sinkClass} implements IIntegrationSink<${n.canonicalType}> {
3826
+ export function ${n.defaultToCanonicalView}(row: ${n.projectionType}): ${n.projectionType} {
3827
+ // BARE passthrough \u2014 preserves null so the orchestrator's DeepEqualDiffer converges
3828
+ // to noop (null \u2260 ''; deep-equal.differ.ts:187-208). The projection-default canonical
3829
+ // is exactly the projection shape, so bare passthrough type-checks without a cast.
3830
+ const view: ${n.projectionType} = {
3831
+ ${findViewBody}
3832
+ };
3833
+ return view;
3834
+ }
3835
+
3836
+ // Abstract base \u2014 the three IIntegrationSink methods are CONCRETE (machinery: provider
3837
+ // assert, repo delegation, #490 knob-driven delete). The two protected abstract seams are
3838
+ // typed at TCanonical with NO body \u2014 a default body returning TCanonical would be TS2322
3839
+ // (Shape A failure \u2014 see RFC-0002 \xA74 and the \xA73b gate: sink-widened-canonical.gate.test.ts).
3840
+ // The bodies live in the standalone functions above; the emit-once subclass wires them in one
3841
+ // line each. No @Injectable() \u2014 the assembly binds via useFactory (OQ2 CLOSED, #491).
3842
+ export abstract class ${n.sinkBaseClass}<TCanonical = ${n.projectionType}>
3843
+ implements IIntegrationSink<TCanonical> {
3765
3844
  constructor(
3766
- private readonly repo: ${n.repoClass},
3767
- private readonly provider: string,
3845
+ protected readonly repo: ${n.repoClass},
3846
+ protected readonly provider: string,
3768
3847
  ) {}
3769
3848
 
3770
- async findByExternalId(userId: string, externalId: string): Promise<${n.canonicalType} | null> {
3849
+ async findByExternalId(userId: string, externalId: string): Promise<TCanonical | null> {
3771
3850
  const row = await this.repo.findByExternalIdProjected(externalId, this.provider);
3851
+ if (row === null) return null;
3772
3852
  // The repo lookup is (provider, externalId)-scoped. If your external_id is not
3773
3853
  // globally unique, enforce ownership here (e.g. row.userId === userId).
3774
- return row;
3854
+ return this.toCanonicalView(row);
3775
3855
  }
3776
3856
 
3777
3857
  async upsertByExternalId(
3778
3858
  userId: string,
3779
- record: ${n.canonicalType},
3859
+ record: TCanonical,
3780
3860
  provider: string,
3781
- ): Promise<{ id: string; saved: ${n.canonicalType} }> {
3861
+ ): Promise<{ id: string; saved: TCanonical }> {
3782
3862
  if (provider !== this.provider) {
3783
3863
  throw new Error(\`${n.sinkClass}: bound provider '\${this.provider}' != run provider '\${provider}'\`);
3784
3864
  }
3785
- const write: ${n.writeType} = {
3786
- ${writeBody}
3787
- };
3788
- const proj = await this.repo.integrationUpsertOne(write, this.provider);
3865
+ const proj = await this.repo.integrationUpsertOne(this.buildWrite(record), this.provider);
3789
3866
  return { id: proj.id, saved: record };
3790
3867
  }
3791
3868
 
3792
3869
  async softDeleteByExternalId(_userId: string, externalId: string): Promise<{ id: string } | null> {
3793
- return this.repo.softDeleteByExternalId(externalId, this.provider);
3870
+ ${deleteBody}
3794
3871
  }
3872
+
3873
+ // ABSTRACT seams (NO body \u2014 a default body returning TCanonical is TS2322).
3874
+ // The projection-default subclass wires the default functions; a widened
3875
+ // subclass reimplements them (FK activation / typed-json narrow / null-coerce).
3876
+ protected abstract toCanonicalView(row: ${n.projectionType}): TCanonical;
3877
+ protected abstract buildWrite(record: TCanonical): ${n.writeType};
3795
3878
  }
3796
3879
  `;
3797
3880
  }
3798
- function relationLabel(writeKey) {
3799
- const stripped = writeKey.replace(/ExternalId$/, "");
3800
- return stripped || "related entity";
3881
+ function generateSinkSubclass(input) {
3882
+ assertIntegrated(input);
3883
+ const n = sinkNames(input.entityClass);
3884
+ return `// Emit-once \u2014 author-owned. Regen never overwrites this file.
3885
+ // The mechanical mapping lives in ${input.entityName}.sink.generated.ts and reflows on every
3886
+ // codegen run (a YAML field change reflows into the @generated base + default functions).
3887
+ //
3888
+ // To WIDEN (all four seams \u2014 see ${input.entityName}.sink.generated.ts banner for detail):
3889
+ // 1. Change \`extends ${n.sinkBaseClass}\` \u2192 \`extends ${n.sinkBaseClass}<YourCanonical>\`.
3890
+ // 2. Override \`toCanonicalView\`: reshape projection, narrow typed json, coerce nulls.
3891
+ // 3. Override \`buildWrite\`: activate FK write keys (\`<writeKey>: record.<writeKey>\`).
3892
+ // 4. Optionally override \`softDeleteByExternalId\` if you want a log line.
3893
+ // (The base handles delegate/noop via the #490 YAML knob \u2014 no override needed otherwise.)
3894
+ // Source: definitions entity '${input.entityName}' (surface: ${input.surface}).
3895
+ import {
3896
+ ${n.sinkBaseClass},
3897
+ ${n.defaultToCanonicalView},
3898
+ ${n.defaultBuildWrite},
3899
+ } from './${input.entityName}.sink.generated';
3900
+ import type {
3901
+ ${n.projectionType},
3902
+ ${n.writeType},
3903
+ } from '${input.repoImportSpecifier}';
3904
+
3905
+ export class ${n.sinkClass} extends ${n.sinkBaseClass} {
3906
+ protected toCanonicalView(row: ${n.projectionType}): ${n.projectionType} {
3907
+ return ${n.defaultToCanonicalView}(row);
3908
+ }
3909
+
3910
+ protected buildWrite(record: ${n.projectionType}): ${n.writeType} {
3911
+ return ${n.defaultBuildWrite}(record);
3912
+ }
3913
+ }
3914
+ `;
3801
3915
  }
3802
3916
 
3803
3917
  // src/cli/shared/assembly-emission-generator.ts
@@ -3993,6 +4107,7 @@ function resolveEntityModuleImports(input) {
3993
4107
  }
3994
4108
 
3995
4109
  // src/cli/shared/adapter-emission-generator.ts
4110
+ import pluralize2 from "pluralize";
3996
4111
  var SURFACE_REGISTRY = {
3997
4112
  crm: {
3998
4113
  packageName: "@pattern-stack/codegen-crm",
@@ -4068,7 +4183,7 @@ var SURFACE_REGISTRY = {
4068
4183
  function isClientlessProvider(surfaces) {
4069
4184
  return surfaces.length > 0 && surfaces.every((s) => SURFACE_REGISTRY[s]?.readPrimitive === true);
4070
4185
  }
4071
- var SCAFFOLD_SENTINEL2 = "// <CODEGEN-SCAFFOLD-V1>";
4186
+ var SCAFFOLD_SENTINEL = "// <CODEGEN-SCAFFOLD-V1>";
4072
4187
  function generatedBanner2(sourceDesc) {
4073
4188
  return `// @generated by @pattern-stack/codegen from ${sourceDesc} \u2014 DO NOT EDIT.
4074
4189
  // Hand edits are overwritten on re-emit. Regenerate with \`bun run codegen\`.`;
@@ -4278,7 +4393,7 @@ ${l2Members}
4278
4393
  const ctorOpen = rp ? ` constructor(@Inject(${n.strategyToken}) readonly auth: IAuthStrategy) {${changeSourcesAssign}}` : ` constructor(
4279
4394
  @Inject(${n.strategyToken}) readonly auth: IAuthStrategy,${ctorClientParam}
4280
4395
  ) {${changeSourcesAssign}}`;
4281
- return `${SCAFFOLD_SENTINEL2}
4396
+ return `${SCAFFOLD_SENTINEL}
4282
4397
  // Scaffolded once by @pattern-stack/codegen, then author-owned. Re-running
4283
4398
  // codegen detects the sentinel above and SKIPS this file \u2014 your edits are safe.
4284
4399
  // Source: definitions/providers/${def.slug}.yaml (surface: ${surface}).
@@ -4574,14 +4689,18 @@ function emitAdapters(opts) {
4574
4689
  backendSrcAbs: opts.backendSrcAbs,
4575
4690
  aliases
4576
4691
  });
4577
- const sinkPath = join8(sinksDir, `${entityName}.sink.ts`);
4578
- if (existsSync6(sinkPath)) {
4579
- result.scaffoldsSkipped.push(sinkPath);
4692
+ const sinkInput = buildSinkInput(def, surface, slugs[0], loc.repoImportSpecifier);
4693
+ const basePath = join8(sinksDir, `${entityName}.sink.generated.ts`);
4694
+ const baseContent = generateSinkBase({ ...sinkInput, mode });
4695
+ if (!opts.dryRun) writeIfChanged(basePath, baseContent);
4696
+ result.written.push(basePath);
4697
+ const subclassPath = join8(sinksDir, `${entityName}.sink.ts`);
4698
+ if (existsSync6(subclassPath)) {
4699
+ result.scaffoldsSkipped.push(subclassPath);
4580
4700
  } 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);
4701
+ const subclassContent = generateSinkSubclass({ ...sinkInput, mode });
4702
+ if (!opts.dryRun) writeFile(subclassPath, subclassContent);
4703
+ result.scaffoldsWritten.push(subclassPath);
4585
4704
  }
4586
4705
  for (const slug of slugs) {
4587
4706
  const assemblyPath = join8(
@@ -4630,6 +4749,13 @@ function emitAdapters(opts) {
4630
4749
  }
4631
4750
  return result;
4632
4751
  }
4752
+ function fkWriteKey(target, foreignKey, isSelfFk) {
4753
+ if (isSelfFk) {
4754
+ const base = foreignKey.endsWith("_id") ? foreignKey.slice(0, -3) : foreignKey;
4755
+ return snakeToCamel(base) + "ExternalId";
4756
+ }
4757
+ return `${target}ExternalId`;
4758
+ }
4633
4759
  function buildSinkInput(def, surface, provider, repoImportSpecifier) {
4634
4760
  const fields = def.fields ?? {};
4635
4761
  const relationships = def.relationships ?? {};
@@ -4639,14 +4765,35 @@ function buildSinkInput(def, surface, provider, repoImportSpecifier) {
4639
4765
  fkColumns.add(rel2.foreign_key);
4640
4766
  }
4641
4767
  }
4642
- const copyThroughFields = Object.entries(fields).filter(([name]) => name !== "id" && !fkColumns.has(name)).map(([name, f]) => ({
4768
+ const excludeSet = new Set(def.integration?.sink?.exclude_fields ?? []);
4769
+ const isCopyThrough = ([name]) => name !== "id" && !fkColumns.has(name);
4770
+ const viewCopyThroughFields = Object.entries(fields).filter(isCopyThrough).map(([name, f]) => ({
4771
+ camelName: snakeToCamel(name),
4772
+ tsType: tsTypeFor(f.type, f.nullable)
4773
+ }));
4774
+ const copyThroughFields = Object.entries(fields).filter((entry) => isCopyThrough(entry) && !excludeSet.has(entry[0])).map(([name, f]) => ({
4643
4775
  camelName: snakeToCamel(name),
4644
4776
  tsType: tsTypeFor(f.type, f.nullable)
4645
4777
  }));
4778
+ const entityNamePlural = def.entity.plural ?? pluralize2.plural(def.entity.name);
4646
4779
  const fkExternalKeys = Object.entries(relationships).filter(([, rel2]) => rel2.type === "belongs_to").map(([relName, rel2]) => {
4647
4780
  const target = rel2.target ?? relName;
4648
- return { writeKey: `${snakeToCamel(target)}ExternalId` };
4781
+ const foreignKey = rel2.foreign_key ?? `${target}_id`;
4782
+ const isSelfFk = pluralize2.plural(target) === entityNamePlural;
4783
+ return { writeKey: fkWriteKey(target, foreignKey, isSelfFk) };
4649
4784
  });
4785
+ const localFkColumns = Object.entries(relationships).filter(([, rel2]) => rel2.type === "belongs_to").map(([relName, rel2]) => {
4786
+ const foreignKey = rel2.foreign_key ?? `${relName}_id`;
4787
+ return {
4788
+ camelName: snakeToCamel(foreignKey),
4789
+ tsType: rel2.nullable ? "string | null" : "string"
4790
+ };
4791
+ });
4792
+ const hasTimestamps2 = (def.behaviors ?? []).some(
4793
+ (b) => typeof b === "string" ? b === "timestamps" : b.name === "timestamps"
4794
+ );
4795
+ const deleteKnob = def.integration?.sink?.delete;
4796
+ const deleteMode = deleteKnob === "noop" ? "noop" : "delegate";
4650
4797
  return {
4651
4798
  entityName: def.entity.name,
4652
4799
  entityClass: pascalFromSnake(def.entity.name),
@@ -4654,8 +4801,12 @@ function buildSinkInput(def, surface, provider, repoImportSpecifier) {
4654
4801
  pattern: "Integrated",
4655
4802
  provider,
4656
4803
  copyThroughFields,
4804
+ viewCopyThroughFields,
4657
4805
  fkExternalKeys,
4658
- repoImportSpecifier
4806
+ localFkColumns,
4807
+ hasTimestamps: hasTimestamps2,
4808
+ repoImportSpecifier,
4809
+ deleteMode
4659
4810
  };
4660
4811
  }
4661
4812
  var TS_TYPE_FOR_SINK = {
@@ -5574,7 +5725,7 @@ function hasTimestamps(parsed) {
5574
5725
  function hasSoftDelete(parsed) {
5575
5726
  return parsed?.behaviors.includes("soft_delete") ?? false;
5576
5727
  }
5577
- function displayFields(parsed) {
5728
+ function displayFields(parsed, opts = {}) {
5578
5729
  if (!parsed) return [];
5579
5730
  const syncShape = hasExternalSyncShape(parsed.fields.keys());
5580
5731
  const out = [];
@@ -5582,7 +5733,7 @@ function displayFields(parsed) {
5582
5733
  if (field.name === "id") continue;
5583
5734
  if (isEntityRefField(field)) continue;
5584
5735
  const defaults = syncShape && EXTERNAL_SYNC_FIELDS.has(field.name) ? { group: EXTERNAL_SYNC_GROUP } : void 0;
5585
- out.push(deriveFieldMeta(field, defaults));
5736
+ out.push(deriveFieldMeta(field, defaults, opts));
5586
5737
  }
5587
5738
  return out;
5588
5739
  }
@@ -5625,7 +5776,7 @@ function humanizeClass(className) {
5625
5776
  function buildEntityFieldsFile(entity, ctx) {
5626
5777
  const parsed = ctx.parsed.get(entity.name);
5627
5778
  const { camelName, className, classNamePlural, name, plural } = entity;
5628
- const fields = displayFields(parsed);
5779
+ const fields = displayFields(parsed, { textareaThreshold: ctx.config.textareaThreshold });
5629
5780
  const rels = resolvableRels(entity, ctx);
5630
5781
  const ts3 = hasTimestamps(parsed);
5631
5782
  const sd = hasSoftDelete(parsed);
@@ -5954,11 +6105,20 @@ var FrontendCatalogConfigSchema = z.object({
5954
6105
  }).strict()
5955
6106
  ).default([])
5956
6107
  }).default({});
6108
+ var FrontendFieldsConfigSchema = z.object({
6109
+ /**
6110
+ * String → textarea cutoff (strictly greater than). Absent or `undefined`
6111
+ * ⇒ 500. Explicit `null` ⇒ heuristic disabled (all bounded strings stay
6112
+ * `text` unless the author sets `ui_type: textarea`).
6113
+ */
6114
+ textareaThreshold: z.number().int().positive().nullable().default(500)
6115
+ }).strict().default({});
5957
6116
  var FrontendConfigSchema = z.object({
5958
6117
  auth: FrontendAuthConfigSchema,
5959
6118
  parsers: z.record(z.string()).default({ timestamptz: "(date: string) => new Date(date)" }),
5960
6119
  sync: FrontendSyncConfigSchema,
5961
- catalog: FrontendCatalogConfigSchema
6120
+ catalog: FrontendCatalogConfigSchema,
6121
+ fields: FrontendFieldsConfigSchema
5962
6122
  }).strict().default({});
5963
6123
 
5964
6124
  // src/emitters/frontend/load-context.ts
@@ -6006,7 +6166,8 @@ function mapFrontendEmitConfig(config) {
6006
6166
  parsers: fe.parsers,
6007
6167
  architecture,
6008
6168
  dbEntitiesImport: dbEntities.import,
6009
- catalogCategories: fe.catalog.categories
6169
+ catalogCategories: fe.catalog.categories,
6170
+ textareaThreshold: fe.fields.textareaThreshold
6010
6171
  };
6011
6172
  }
6012
6173
  function loadProviderCatalogInputs(cwd, config) {