@pattern-stack/codegen 0.7.4 → 0.7.6

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 (119) hide show
  1. package/dist/runtime/base-classes/index.d.ts +2 -0
  2. package/dist/runtime/base-classes/index.js +345 -18
  3. package/dist/runtime/base-classes/index.js.map +1 -1
  4. package/dist/runtime/base-classes/junction-sync-repository.d.ts +87 -0
  5. package/dist/runtime/base-classes/junction-sync-repository.js +362 -0
  6. package/dist/runtime/base-classes/junction-sync-repository.js.map +1 -0
  7. package/dist/runtime/base-classes/sync-upsert-config.d.ts +58 -0
  8. package/dist/runtime/base-classes/sync-upsert-config.js +1 -0
  9. package/dist/runtime/base-classes/sync-upsert-config.js.map +1 -0
  10. package/dist/runtime/base-classes/synced-entity-repository.d.ts +68 -6
  11. package/dist/runtime/base-classes/synced-entity-repository.js +173 -7
  12. package/dist/runtime/base-classes/synced-entity-repository.js.map +1 -1
  13. package/dist/runtime/subsystems/sync/execute-sync.use-case.js +19 -1
  14. package/dist/runtime/subsystems/sync/execute-sync.use-case.js.map +1 -1
  15. package/dist/runtime/subsystems/sync/index.js +19 -1
  16. package/dist/runtime/subsystems/sync/index.js.map +1 -1
  17. package/dist/runtime/subsystems/sync/sync-sink.protocol.d.ts +6 -0
  18. package/dist/src/cli/index.js +24 -2
  19. package/dist/src/cli/index.js.map +1 -1
  20. package/dist/src/index.js +21 -2
  21. package/dist/src/index.js.map +1 -1
  22. package/package.json +1 -1
  23. package/runtime/base-classes/index.ts +9 -0
  24. package/runtime/base-classes/junction-sync-repository.ts +284 -0
  25. package/runtime/base-classes/sync-upsert-config.ts +58 -0
  26. package/runtime/base-classes/synced-entity-repository.ts +263 -9
  27. package/runtime/subsystems/sync/execute-sync.use-case.ts +25 -1
  28. package/runtime/subsystems/sync/sync-sink.protocol.ts +7 -0
  29. package/src/patterns/library/synced.pattern.ts +2 -1
  30. package/templates/_shared/generated-banner.mjs +74 -0
  31. package/templates/broadcast/new/backend-interface.ejs.t +1 -0
  32. package/templates/broadcast/new/bridge-listener.ejs.t +1 -0
  33. package/templates/broadcast/new/channel.ejs.t +1 -0
  34. package/templates/broadcast/new/index.ejs.t +1 -0
  35. package/templates/broadcast/new/memory-backend.ejs.t +1 -0
  36. package/templates/broadcast/new/module.ejs.t +1 -0
  37. package/templates/broadcast/new/prompt.js +13 -0
  38. package/templates/broadcast/new/websocket-backend.ejs.t +1 -0
  39. package/templates/entity/new/backend/application/commands/create.ejs.t +1 -0
  40. package/templates/entity/new/backend/application/commands/delete.ejs.t +1 -0
  41. package/templates/entity/new/backend/application/commands/grouped-index.ejs.t +1 -0
  42. package/templates/entity/new/backend/application/commands/index.ejs.t +1 -0
  43. package/templates/entity/new/backend/application/commands/update.ejs.t +1 -0
  44. package/templates/entity/new/backend/application/queries/declarative-queries.ejs.t +1 -0
  45. package/templates/entity/new/backend/application/queries/get-by-id.ejs.t +1 -0
  46. package/templates/entity/new/backend/application/queries/grouped-index.ejs.t +1 -0
  47. package/templates/entity/new/backend/application/queries/index.ejs.t +1 -0
  48. package/templates/entity/new/backend/application/queries/list.ejs.t +1 -0
  49. package/templates/entity/new/backend/application/queries/relationships.queries.ejs.t +1 -0
  50. package/templates/entity/new/backend/application/schemas/dto.ejs.t +1 -0
  51. package/templates/entity/new/backend/database/repository.ejs.t +1 -0
  52. package/templates/entity/new/backend/database/schema.ejs.t +1 -0
  53. package/templates/entity/new/backend/domain/entity.ejs.t +1 -0
  54. package/templates/entity/new/backend/domain/grouped-index.ejs.t +1 -0
  55. package/templates/entity/new/backend/domain/index.ejs.t +1 -0
  56. package/templates/entity/new/backend/domain/repository-interface.ejs.t +1 -0
  57. package/templates/entity/new/backend/modules/core/module.ejs.t +1 -0
  58. package/templates/entity/new/backend/modules/core/sync-source.ejs.t +1 -0
  59. package/templates/entity/new/backend/modules/core/sync-source.providers.ejs.t +1 -0
  60. package/templates/entity/new/backend/modules/trpc/module.ejs.t +1 -0
  61. package/templates/entity/new/backend/presentation/controller.ejs.t +1 -0
  62. package/templates/entity/new/clean-lite-ps/controller.ejs.t +1 -0
  63. package/templates/entity/new/clean-lite-ps/dto/create.ejs.t +1 -0
  64. package/templates/entity/new/clean-lite-ps/dto/output.ejs.t +1 -0
  65. package/templates/entity/new/clean-lite-ps/dto/update.ejs.t +1 -0
  66. package/templates/entity/new/clean-lite-ps/entity.ejs.t +1 -0
  67. package/templates/entity/new/clean-lite-ps/index.ejs.t +1 -0
  68. package/templates/entity/new/clean-lite-ps/module.ejs.t +1 -0
  69. package/templates/entity/new/clean-lite-ps/prompt-extension.js +148 -0
  70. package/templates/entity/new/clean-lite-ps/repository.ejs.t +92 -1
  71. package/templates/entity/new/clean-lite-ps/search-controller.ejs.t +1 -0
  72. package/templates/entity/new/clean-lite-ps/service.ejs.t +1 -0
  73. package/templates/entity/new/clean-lite-ps/use-cases/create.ejs.t +1 -0
  74. package/templates/entity/new/clean-lite-ps/use-cases/declarative-queries.ejs.t +1 -0
  75. package/templates/entity/new/clean-lite-ps/use-cases/delete.ejs.t +1 -0
  76. package/templates/entity/new/clean-lite-ps/use-cases/find-by-id-with-fields.ejs.t +1 -0
  77. package/templates/entity/new/clean-lite-ps/use-cases/find-by-id.ejs.t +1 -0
  78. package/templates/entity/new/clean-lite-ps/use-cases/list-with-fields.ejs.t +1 -0
  79. package/templates/entity/new/clean-lite-ps/use-cases/list.ejs.t +1 -0
  80. package/templates/entity/new/clean-lite-ps/use-cases/search.ejs.t +1 -0
  81. package/templates/entity/new/clean-lite-ps/use-cases/update.ejs.t +1 -0
  82. package/templates/entity/new/frontend/entity/collection.ejs.t +1 -0
  83. package/templates/entity/new/frontend/entity/combined.ejs.t +1 -0
  84. package/templates/entity/new/frontend/entity/fields.ejs.t +1 -0
  85. package/templates/entity/new/frontend/entity/hooks.ejs.t +1 -0
  86. package/templates/entity/new/frontend/entity/index.ejs.t +1 -0
  87. package/templates/entity/new/frontend/entity/mutation-hooks.ejs.t +1 -0
  88. package/templates/entity/new/frontend/entity/mutations.ejs.t +1 -0
  89. package/templates/entity/new/frontend/entity/types.ejs.t +1 -0
  90. package/templates/entity/new/frontend/store/hooks.ejs.t +1 -0
  91. package/templates/entity/new/frontend/unified-entity.ejs.t +1 -0
  92. package/templates/entity/new/prompt.js +19 -0
  93. package/templates/junction/new/entity.ejs.t +14 -2
  94. package/templates/junction/new/index.ejs.t +1 -0
  95. package/templates/junction/new/module.ejs.t +1 -0
  96. package/templates/junction/new/prompt.js +83 -0
  97. package/templates/junction/new/repository.ejs.t +44 -3
  98. package/templates/junction/new/service.ejs.t +1 -0
  99. package/templates/relationship/new/controller.ejs.t +1 -0
  100. package/templates/relationship/new/dto/create.ejs.t +1 -0
  101. package/templates/relationship/new/dto/output.ejs.t +1 -0
  102. package/templates/relationship/new/dto/update.ejs.t +1 -0
  103. package/templates/relationship/new/entity.ejs.t +1 -0
  104. package/templates/relationship/new/index.ejs.t +1 -0
  105. package/templates/relationship/new/module.ejs.t +1 -0
  106. package/templates/relationship/new/prompt.js +14 -0
  107. package/templates/relationship/new/repository.ejs.t +1 -0
  108. package/templates/relationship/new/service.ejs.t +1 -0
  109. package/templates/relationship/new/use-cases/declarative-queries.ejs.t +1 -0
  110. package/templates/relationship/new/use-cases/find-by-id.ejs.t +1 -0
  111. package/templates/relationship/new/use-cases/list.ejs.t +1 -0
  112. package/templates/subsystem/auth/auth-oauth-state.schema.ejs.t +1 -0
  113. package/templates/subsystem/auth/prompt.js +8 -0
  114. package/templates/subsystem/events/domain-events.schema.ejs.t +1 -0
  115. package/templates/subsystem/events/prompt.js +8 -0
  116. package/templates/subsystem/jobs/job-orchestration.schema.ejs.t +1 -0
  117. package/templates/subsystem/jobs/prompt.js +8 -0
  118. package/templates/subsystem/sync/prompt.js +8 -0
  119. package/templates/subsystem/sync/sync-audit.schema.ejs.t +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pattern-stack/codegen",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
4
4
  "description": "Entity-driven code generation for full-stack TypeScript applications",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,8 +19,17 @@ export type { EventCategory } from './lifecycle-events';
19
19
  export { BaseFindByIdUseCase, BaseListUseCase } from './base-read-use-cases';
20
20
  export type { IFindByIdService, IListService } from './base-read-use-cases';
21
21
 
22
+ // Sync upsert config (consumed by SyncedEntityRepository + JunctionSyncRepository)
23
+ export type { SyncUpsertConfig, SyncFkResolver } from './sync-upsert-config';
24
+
22
25
  // Family-specific repository base classes
23
26
  export { SyncedEntityRepository } from './synced-entity-repository';
27
+ export {
28
+ JunctionSyncRepository,
29
+ buildCompositeExternalId,
30
+ parseCompositeExternalId,
31
+ } from './junction-sync-repository';
32
+ export type { JunctionSyncConfig } from './junction-sync-repository';
24
33
  export { ActivityEntityRepository } from './activity-entity-repository';
25
34
  export { MetadataEntityRepository } from './metadata-entity-repository';
26
35
  export { KnowledgeEntityRepository } from './knowledge-entity-repository';
@@ -0,0 +1,284 @@
1
+ /**
2
+ * JunctionSyncRepository<TEntity, TSyncWrite, TSyncProjection>
3
+ *
4
+ * Base for junction repos that participate in inbound sync (#374). A junction's
5
+ * sync identity is the tuple `(leftId, rightId[, role])` — there is no native
6
+ * `external_id`/`provider` column, so the sync seam's externalId is a COMPOSITE
7
+ * string `<leftExternalId>::<rightExternalId>[::<role>]` (see the static
8
+ * build/parse helpers below).
9
+ *
10
+ * Both parent FKs are resolved STRICTLY in the write path (a missing parent
11
+ * throws → the orchestrator records a failed item and continues). As of #372
12
+ * role-bearing junctions carry a unique constraint over `(left, right, role)`,
13
+ * so the upsert uses `onConflictDoUpdate` (not the legacy select-then-write).
14
+ * Role-less junctions conflict on `(left, right)`.
15
+ */
16
+ import { and, eq } from 'drizzle-orm';
17
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
18
+ import type { PgTableWithColumns } from 'drizzle-orm/pg-core';
19
+ import type { DrizzleTx } from '../types/drizzle';
20
+ import { BaseRepository } from './base-repository';
21
+
22
+ export interface JunctionSyncConfig {
23
+ /** Left endpoint: local FK column (camel) + strict parent table. */
24
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
25
+ left: { column: string; refTable: PgTableWithColumns<any> };
26
+ /** Right endpoint: local FK column (camel) + strict parent table. */
27
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
+ right: { column: string; refTable: PgTableWithColumns<any> };
29
+ /** Role column (camel), or null for a role-less (2-part composite) junction. */
30
+ roleColumn: string | null;
31
+ }
32
+
33
+ export abstract class JunctionSyncRepository<
34
+ TEntity,
35
+ TSyncWrite,
36
+ TSyncProjection,
37
+ > extends BaseRepository<TEntity> {
38
+ /**
39
+ * Declarative junction sync surface. Concrete repos declare this — the
40
+ * template emits it with live parent-table handles.
41
+ */
42
+ protected abstract readonly syncConfig: JunctionSyncConfig;
43
+
44
+ /**
45
+ * Upsert ONE junction row by its composite identity, in a single transaction:
46
+ * 1. resolve the REQUIRED left FK (provider-scoped) — STRICT: missing → throws;
47
+ * 2. resolve the REQUIRED right FK (provider-scoped) — STRICT: missing → throws;
48
+ * 3. insert-or-update on the `(left, right[, role])` conflict target.
49
+ *
50
+ * Idempotent. Returns the composite externalId as the projection `id`.
51
+ *
52
+ * @param write parent external ids (`<left>ExternalId`/`<right>ExternalId`)
53
+ * + optional `role` + `userId`
54
+ * @param provider adapter/provider label used to scope the parent lookups
55
+ * @param tx optional outer transaction; when omitted we open our own
56
+ */
57
+ async syncUpsertOne(
58
+ write: TSyncWrite,
59
+ provider: string,
60
+ tx?: DrizzleTx,
61
+ ): Promise<TSyncProjection> {
62
+ const cfg = this.syncConfig;
63
+ const w = write as Record<string, unknown>;
64
+ const leftWriteKey = `${cfg.left.column.replace(/Id$/, '')}ExternalId`;
65
+ const rightWriteKey = `${cfg.right.column.replace(/Id$/, '')}ExternalId`;
66
+
67
+ const run = async (db: DrizzleTx): Promise<TSyncProjection> => {
68
+ const leftId = await this.resolveStrict(
69
+ db, cfg.left.refTable, w[leftWriteKey] as string, provider, cfg.left.column,
70
+ );
71
+ const rightId = await this.resolveStrict(
72
+ db, cfg.right.refTable, w[rightWriteKey] as string, provider, cfg.right.column,
73
+ );
74
+
75
+ const now = new Date();
76
+ const role = cfg.roleColumn ? (w['role'] as unknown) : undefined;
77
+ const values: Record<string, unknown> = {
78
+ [cfg.left.column]: leftId,
79
+ [cfg.right.column]: rightId,
80
+ ...(cfg.roleColumn ? { [cfg.roleColumn]: role } : {}),
81
+ ...(this.behaviors.timestamps ? { updatedAt: now } : {}),
82
+ };
83
+ const target = cfg.roleColumn
84
+ ? [this.table[cfg.left.column], this.table[cfg.right.column], this.table[cfg.roleColumn]]
85
+ : [this.table[cfg.left.column], this.table[cfg.right.column]];
86
+
87
+ const rows = await db
88
+ .insert(this.table)
89
+ .values(values as never)
90
+ .onConflictDoUpdate({
91
+ target,
92
+ set: { ...(this.behaviors.timestamps ? { updatedAt: now } : {}) } as never,
93
+ })
94
+ .returning();
95
+
96
+ const saved = rows[0] as Record<string, unknown>;
97
+ return this.toProjection(saved as TEntity, w, provider);
98
+ };
99
+
100
+ return tx ? run(tx) : this.db.transaction((t) => run(t));
101
+ }
102
+
103
+ /**
104
+ * Canonical-projected lookup by the COMPOSITE externalId, differ-ready. Parses
105
+ * the composite, resolves BOTH parents NON-throwing (→ null), then selects by
106
+ * the identity tuple. Returns `null` on malformed composite / unresolved
107
+ * parent / no row (a missing "before" side is a create from the differ's view).
108
+ */
109
+ async findByExternalIdProjected(
110
+ externalId: string,
111
+ provider: string,
112
+ ): Promise<TSyncProjection | null> {
113
+ const cfg = this.syncConfig;
114
+ const parsed = parseCompositeExternalId(externalId, cfg.roleColumn !== null);
115
+ if (!parsed) return null;
116
+
117
+ const leftId = await this.resolveLoose(this.db, cfg.left.refTable, parsed.left, provider);
118
+ if (leftId === null) return null;
119
+ const rightId = await this.resolveLoose(this.db, cfg.right.refTable, parsed.right, provider);
120
+ if (rightId === null) return null;
121
+
122
+ const rows = await this.db
123
+ .select()
124
+ .from(this.table)
125
+ .where(this.identityWhere(leftId, rightId, parsed.role))
126
+ .limit(1);
127
+ const row = rows[0] as TEntity | undefined;
128
+ if (!row) return null;
129
+
130
+ const w: Record<string, unknown> = {
131
+ [`${cfg.left.column.replace(/Id$/, '')}ExternalId`]: parsed.left,
132
+ [`${cfg.right.column.replace(/Id$/, '')}ExternalId`]: parsed.right,
133
+ ...(cfg.roleColumn ? { role: parsed.role } : {}),
134
+ userId: '',
135
+ };
136
+ return this.toProjection(row, w, provider);
137
+ }
138
+
139
+ /**
140
+ * Hard-delete the junction by composite externalId. Junctions have no
141
+ * `deleted_at` and no external-linkage columns to clear, so a sync "delete"
142
+ * removes the row. Resolves both parents NON-throwing, then deletes by the
143
+ * identity tuple. Returns the composite id, or `null` when nothing matched.
144
+ */
145
+ async softDeleteByExternalId(
146
+ externalId: string,
147
+ provider: string,
148
+ tx?: DrizzleTx,
149
+ ): Promise<{ id: string } | null> {
150
+ const cfg = this.syncConfig;
151
+ const parsed = parseCompositeExternalId(externalId, cfg.roleColumn !== null);
152
+ if (!parsed) return null;
153
+ const db = this.runner(tx);
154
+
155
+ const leftId = await this.resolveLoose(db, cfg.left.refTable, parsed.left, provider);
156
+ if (leftId === null) return null;
157
+ const rightId = await this.resolveLoose(db, cfg.right.refTable, parsed.right, provider);
158
+ if (rightId === null) return null;
159
+
160
+ const rows = await db
161
+ .delete(this.table)
162
+ .where(this.identityWhere(leftId, rightId, parsed.role))
163
+ .returning({ id: this.table[cfg.left.column] });
164
+ return rows[0] ? { id: externalId } : null;
165
+ }
166
+
167
+ /**
168
+ * Project a raw junction row to the differ shape. `id` is the COMPOSITE
169
+ * externalId (the junction has no surrogate id). Override to widen the
170
+ * projection beyond the identity tuple (the template emits a concrete
171
+ * `toProjection` carrying the role + local FKs + timestamps).
172
+ */
173
+ protected toProjection(
174
+ _row: TEntity,
175
+ write: Record<string, unknown>,
176
+ _provider: string,
177
+ ): TSyncProjection {
178
+ const cfg = this.syncConfig;
179
+ const leftExt = write[`${cfg.left.column.replace(/Id$/, '')}ExternalId`] as string;
180
+ const rightExt = write[`${cfg.right.column.replace(/Id$/, '')}ExternalId`] as string;
181
+ const role = cfg.roleColumn ? (write['role'] as string) : undefined;
182
+ return { id: buildCompositeExternalId(leftExt, rightExt, role) } as TSyncProjection;
183
+ }
184
+
185
+ /** Build the identity WHERE clause `(left, right[, role])`. */
186
+ private identityWhere(leftId: string, rightId: string, role: string | undefined) {
187
+ const cfg = this.syncConfig;
188
+ const conds = [
189
+ eq(this.table[cfg.left.column], leftId),
190
+ eq(this.table[cfg.right.column], rightId),
191
+ ];
192
+ if (cfg.roleColumn && role !== undefined) {
193
+ conds.push(eq(this.table[cfg.roleColumn], role));
194
+ }
195
+ return and(...conds);
196
+ }
197
+
198
+ /** Resolve a parent id (provider-scoped), throwing when unresolved. */
199
+ private async resolveStrict(
200
+ db: DrizzleTx,
201
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
202
+ refTable: PgTableWithColumns<any>,
203
+ parentExternalId: string,
204
+ provider: string,
205
+ column: string,
206
+ ): Promise<string> {
207
+ const id = await this.resolveLoose(db, refTable, parentExternalId, provider);
208
+ if (!id) {
209
+ throw new Error(
210
+ `${this.constructor.name}.syncUpsertOne: unresolved parent ` +
211
+ `'${parentExternalId}' (provider '${provider}') for '${column}' — ` +
212
+ `parent not synced yet`,
213
+ );
214
+ }
215
+ return id;
216
+ }
217
+
218
+ /** Resolve a parent id (provider-scoped), returning null when unresolved. */
219
+ private async resolveLoose(
220
+ db: DrizzleTx,
221
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
222
+ refTable: PgTableWithColumns<any>,
223
+ parentExternalId: string | null | undefined,
224
+ provider: string,
225
+ ): Promise<string | null> {
226
+ if (!parentExternalId) return null;
227
+ const rows = await db
228
+ .select({ id: refTable['id'] })
229
+ .from(refTable)
230
+ .where(
231
+ and(
232
+ eq(refTable['provider'], provider),
233
+ eq(refTable['externalId'], parentExternalId),
234
+ ),
235
+ )
236
+ .limit(1);
237
+ return (rows[0]?.id as string | undefined) ?? null;
238
+ }
239
+ }
240
+
241
+ // ============================================================================
242
+ // Composite externalId — the junction sync seam's deterministic identity.
243
+ //
244
+ // Format: `<leftExternalId>::<rightExternalId>[::<role>]`
245
+ // e.g. `hubspot:42::hubspot:99::employee` (role-bearing)
246
+ // `hubspot:42::hubspot:99` (role-less)
247
+ //
248
+ // Vendor-prefixed ids use a SINGLE colon, so `::` is an unambiguous delimiter.
249
+ // Kept static in the base (replacing the per-repo free functions) so every
250
+ // junction's lookups + its ChangeSource share one definition.
251
+ // ============================================================================
252
+
253
+ /**
254
+ * Build the composite externalId from the two parent external ids (+ role when
255
+ * the junction is role-bearing).
256
+ */
257
+ export function buildCompositeExternalId(
258
+ leftExternalId: string,
259
+ rightExternalId: string,
260
+ role?: string,
261
+ ): string {
262
+ return role !== undefined
263
+ ? `${leftExternalId}::${rightExternalId}::${role}`
264
+ : `${leftExternalId}::${rightExternalId}`;
265
+ }
266
+
267
+ /**
268
+ * Parse a composite externalId. `withRole` selects the expected part count
269
+ * (3 when role-bearing, else 2). Returns `null` when the shape doesn't match
270
+ * or any part is empty.
271
+ */
272
+ export function parseCompositeExternalId(
273
+ externalId: string,
274
+ withRole: boolean,
275
+ ): { left: string; right: string; role: string | undefined } | null {
276
+ const parts = externalId.split('::');
277
+ const expected = withRole ? 3 : 2;
278
+ if (parts.length !== expected || parts.some((p) => p.length === 0)) return null;
279
+ return {
280
+ left: parts[0] as string,
281
+ right: parts[1] as string,
282
+ role: withRole ? (parts[2] as string) : undefined,
283
+ };
284
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * SyncUpsertConfig + SyncFkResolver
3
+ *
4
+ * Declarative description of an entity's inbound-sync write surface, consumed
5
+ * by `SyncedEntityRepository.syncUpsertOne` / `findByExternalIdProjected` /
6
+ * `softDeleteByExternalId` / `toProjection`. Each `pattern: Synced` repository
7
+ * declares a concrete `syncConfig: SyncUpsertConfig` (emitted by the template),
8
+ * the same idiom as `behaviors: BehaviorConfig`.
9
+ *
10
+ * Named `SyncUpsertConfig` (not `SyncConfig`) to avoid colliding with the sync
11
+ * subsystem's `DetectionConfig`/`SyncConfig` surface.
12
+ *
13
+ * The generic upsert separates three column roles:
14
+ * - identity (`conflictTarget`) — only in `values`, never in `set`
15
+ * - copy-through (`writeColumns`) — in both `values` and `set`
16
+ * - resolved FK (`fkResolvers`) — conditional in `set` (no-clobber)
17
+ */
18
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
19
+ import type { PgTableWithColumns } from 'drizzle-orm/pg-core';
20
+
21
+ /**
22
+ * Resolves a local FK column from a parent's external id, provider-scoped.
23
+ *
24
+ * The base does `SELECT id FROM <refTable> WHERE (provider, externalId) =
25
+ * (provider, write[writeKey])`. `refTable === 'self'` resolves to `this.table`
26
+ * (self-FK). `strict: true` throws when the parent is unresolved (junction
27
+ * posture); falsy leaves the column null this run (opportunistic, entity
28
+ * posture).
29
+ */
30
+ export interface SyncFkResolver {
31
+ /** Local FK column — camel key into `this.table`, e.g. `'parentAccountId'`. */
32
+ column: string;
33
+ /** Key on `TSyncWrite` carrying the parent external id (see Decision 4). */
34
+ writeKey: string;
35
+ /** Parent table to resolve against; `'self'` → `this.table`. */
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ refTable: PgTableWithColumns<any> | 'self';
38
+ /** true = throw on unresolved (junction); falsy = opportunistic null (entity). */
39
+ strict?: boolean;
40
+ }
41
+
42
+ export interface SyncUpsertConfig {
43
+ /** Camel keys into `this.table` forming the conflict target, e.g. `['provider', 'externalId']`. */
44
+ conflictTarget: string[];
45
+ /**
46
+ * Canonical columns copied verbatim write→values/set (camel). EXCLUDES
47
+ * `externalId`, `provider`, FK columns, and behavior-managed timestamps.
48
+ */
49
+ writeColumns: string[];
50
+ /** Conditional, provider-scoped FK resolvers. */
51
+ fkResolvers: SyncFkResolver[];
52
+ /** Columns picked into the projection (camel), incl. id/externalId/timestamps. */
53
+ projectionColumns: string[];
54
+ /** When true, `syncUpsertOne` calls `writeCustomFields` for a non-empty `fields` bag. */
55
+ eav: boolean;
56
+ /** When true, deletes set `deletedAt`; when false, tombstone-by-clearing external_id/provider. */
57
+ softDelete: boolean;
58
+ }
@@ -1,15 +1,32 @@
1
1
  /**
2
- * SyncedEntityRepository<TEntity>
2
+ * SyncedEntityRepository<TEntity, TSyncWrite, TSyncProjection>
3
3
  *
4
4
  * Family-specific base for Synced entities (contacts, accounts, opportunities).
5
- * Adds external ID lookups, user-scoped queries, and sync stubs.
5
+ * Adds external ID lookups, user-scoped queries, and the generic inbound-sync
6
+ * write surface (canonical→Drizzle upsert + provider-scoped FK resolution +
7
+ * EAV dual-write seam), driven by the concrete repo's `syncConfig`.
6
8
  *
7
- * Concrete repos extend this and declare their table + behaviors.
9
+ * The type params default so pre-existing single-param subclasses keep
10
+ * compiling; `pattern: Synced` repos declare all three plus `syncConfig`.
8
11
  */
9
- import { eq, inArray } from 'drizzle-orm';
12
+ import { and, eq, inArray } from 'drizzle-orm';
13
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
+ import type { PgTableWithColumns } from 'drizzle-orm/pg-core';
15
+ import type { DrizzleTx } from '../types/drizzle';
10
16
  import { BaseRepository } from './base-repository';
17
+ import type { SyncUpsertConfig, SyncFkResolver } from './sync-upsert-config';
18
+
19
+ export abstract class SyncedEntityRepository<
20
+ TEntity,
21
+ TSyncWrite = Partial<TEntity>,
22
+ TSyncProjection = TEntity,
23
+ > extends BaseRepository<TEntity> {
24
+ /**
25
+ * Declarative sync write surface. Concrete (`pattern: Synced`) repositories
26
+ * declare this — the template emits it from the entity's fields + FKs.
27
+ */
28
+ protected abstract readonly syncConfig: SyncUpsertConfig;
11
29
 
12
- export abstract class SyncedEntityRepository<TEntity> extends BaseRepository<TEntity> {
13
30
  /**
14
31
  * Find a single entity by its external CRM identifier.
15
32
  */
@@ -39,12 +56,249 @@ export abstract class SyncedEntityRepository<TEntity> extends BaseRepository<TEn
39
56
  return rows as TEntity[];
40
57
  }
41
58
 
59
+ // ==========================================================================
60
+ // Inbound sync (#374) — canonical→Drizzle write + provider-scoped FK
61
+ // resolution + EAV dual-write seam, all inside a SINGLE transaction.
62
+ // Driven entirely by `this.syncConfig`; the per-entity shape lives there.
63
+ // ==========================================================================
64
+
65
+ /**
66
+ * Upsert ONE entity by its `(provider, externalId)` identity, in a single
67
+ * transaction:
68
+ * 1. resolve each `syncConfig.fkResolvers` FK (provider-scoped). Strict
69
+ * resolvers throw on unresolved; non-strict leave the column null.
70
+ * 2. insert-or-update the canonical columns via `onConflictDoUpdate` on the
71
+ * `conflictTarget`. Resolved FKs are only written into `set` when
72
+ * non-null this run (no-clobber).
73
+ * 3. EAV dual-write of `write.fields` via `writeCustomFields` when
74
+ * `syncConfig.eav` and the bag is non-empty (same tx).
75
+ *
76
+ * Idempotent: a second call with the same identity updates in place. Returns
77
+ * the canonical projection (so the orchestrator records `local_id`).
78
+ *
79
+ * @param write canonical fields + parent external ids + custom-field bag
80
+ * @param provider adapter/provider label persisted + used to scope lookups
81
+ * @param tx optional outer transaction; when omitted we open our own
82
+ */
83
+ async syncUpsertOne(
84
+ write: TSyncWrite,
85
+ provider: string,
86
+ tx?: DrizzleTx,
87
+ ): Promise<TSyncProjection> {
88
+ const cfg = this.syncConfig;
89
+ const w = write as Record<string, unknown>;
90
+
91
+ const run = async (db: DrizzleTx): Promise<TSyncProjection> => {
92
+ // 1. FK resolution (provider-scoped). Strict → throw; else opportunistic null.
93
+ const resolvedFks: Record<string, string | null> = {};
94
+ for (const fk of cfg.fkResolvers) {
95
+ resolvedFks[fk.column] = await this.resolveFk(db, fk, w[fk.writeKey], provider);
96
+ }
97
+
98
+ // 2. Canonical → Drizzle insert-or-update by the conflict target.
99
+ const now = new Date();
100
+ const copyThrough: Record<string, unknown> = {};
101
+ for (const col of cfg.writeColumns) copyThrough[col] = w[col];
102
+
103
+ const values: Record<string, unknown> = {
104
+ externalId: w['externalId'],
105
+ provider,
106
+ ...copyThrough,
107
+ ...resolvedFks,
108
+ ...(this.behaviors.timestamps ? { updatedAt: now } : {}),
109
+ };
110
+
111
+ // `set` excludes the identity (externalId/provider). Resolved FKs are
112
+ // only written when non-null this run — never clobber a previously
113
+ // resolved parent with null on a later run that dropped the ref.
114
+ const set: Record<string, unknown> = {
115
+ ...copyThrough,
116
+ ...(this.behaviors.timestamps ? { updatedAt: now } : {}),
117
+ };
118
+ for (const fk of cfg.fkResolvers) {
119
+ if (resolvedFks[fk.column] !== null) set[fk.column] = resolvedFks[fk.column];
120
+ }
121
+
122
+ const rows = await db
123
+ .insert(this.table)
124
+ .values(values as never)
125
+ .onConflictDoUpdate({
126
+ target: cfg.conflictTarget.map((c: string) => this.table[c]),
127
+ set: set as never,
128
+ })
129
+ .returning();
130
+
131
+ const saved = rows[0] as Record<string, unknown>;
132
+
133
+ // 3. EAV dual-write seam — same tx. No-op unless the entity opts in.
134
+ const fields = w['fields'] as Record<string, unknown> | undefined;
135
+ if (cfg.eav && fields && Object.keys(fields).length > 0) {
136
+ await this.writeCustomFields(
137
+ db,
138
+ saved['id'] as string,
139
+ w['userId'] as string,
140
+ fields,
141
+ );
142
+ }
143
+
144
+ return this.toProjection(saved as TEntity);
145
+ };
146
+
147
+ return tx ? run(tx) : this.db.transaction((t) => run(t));
148
+ }
149
+
42
150
  /**
43
- * Sync upsert bulk insert-or-update from external CRM data.
44
- * Concrete repositories must implement with the appropriate conflict target.
151
+ * Canonical-projected lookup by external id (differ-ready). Returns `null`
152
+ * when no local row exists. Provider-scoped so a HubSpot id can't match a
153
+ * Salesforce row.
45
154
  */
46
- async syncUpsert(_inputs: Array<Partial<TEntity>>): Promise<TEntity[]> {
47
- throw new Error('syncUpsert not implemented — override in concrete repository');
155
+ async findByExternalIdProjected(
156
+ externalId: string,
157
+ provider: string,
158
+ ): Promise<TSyncProjection | null> {
159
+ const rows = await this.db
160
+ .select()
161
+ .from(this.table)
162
+ .where(
163
+ and(
164
+ eq(this.table['provider'], provider),
165
+ eq(this.table['externalId'], externalId),
166
+ ),
167
+ )
168
+ .limit(1);
169
+ const row = rows[0] as TEntity | undefined;
170
+ return row ? this.toProjection(row) : null;
171
+ }
172
+
173
+ /**
174
+ * Sync "delete" by external id, provider-scoped. When `softDelete: true`,
175
+ * sets `deletedAt`. When `softDelete: false`, tombstone-by-clearing: null out
176
+ * `external_id`/`provider` so the row no longer matches future inbound
177
+ * changes while preserving local-id references. Returns `{ id }` or `null`.
178
+ */
179
+ async softDeleteByExternalId(
180
+ externalId: string,
181
+ provider: string,
182
+ tx?: DrizzleTx,
183
+ ): Promise<{ id: string } | null> {
184
+ const db = this.runner(tx);
185
+ const set = this.syncConfig.softDelete
186
+ ? { deletedAt: new Date(), updatedAt: new Date() }
187
+ : { externalId: null, provider: null, updatedAt: new Date() };
188
+ const rows = await db
189
+ .update(this.table)
190
+ .set(set as never)
191
+ .where(
192
+ and(
193
+ eq(this.table['provider'], provider),
194
+ eq(this.table['externalId'], externalId),
195
+ ),
196
+ )
197
+ .returning({ id: this.table['id'] });
198
+ return rows[0] ? { id: rows[0].id as string } : null;
199
+ }
200
+
201
+ /**
202
+ * Batch sync upsert — concretizes the former abstract stub. Delegates to
203
+ * `syncUpsertOne` per input inside one transaction. Inputs are raw partial
204
+ * rows: provider is read from each input's own `provider` column; rows
205
+ * missing `externalId`/`provider` are skipped.
206
+ */
207
+ async syncUpsert(inputs: Array<Partial<TEntity>>): Promise<TEntity[]> {
208
+ if (inputs.length === 0) return [];
209
+ return this.db.transaction(async (tx) => {
210
+ const out: TEntity[] = [];
211
+ for (const input of inputs) {
212
+ const rec = input as Record<string, unknown>;
213
+ if (!rec['externalId'] || !rec['provider']) continue;
214
+ const proj = await this.syncUpsertOne(
215
+ input as unknown as TSyncWrite,
216
+ rec['provider'] as string,
217
+ tx,
218
+ );
219
+ const id = (proj as Record<string, unknown>)['id'] as string;
220
+ const row = await tx
221
+ .select()
222
+ .from(this.table)
223
+ .where(eq(this.table['id'], id))
224
+ .limit(1);
225
+ out.push(row[0] as TEntity);
226
+ }
227
+ return out;
228
+ });
229
+ }
230
+
231
+ /**
232
+ * Project a raw row to the canonical differ shape — a generic pick over
233
+ * `syncConfig.projectionColumns`. Override only for synthesized projections
234
+ * (e.g. junctions); entities use this verbatim.
235
+ */
236
+ protected toProjection(row: TEntity): TSyncProjection {
237
+ const r = row as Record<string, unknown>;
238
+ const out: Record<string, unknown> = {};
239
+ for (const col of this.syncConfig.projectionColumns) out[col] = r[col];
240
+ return out as TSyncProjection;
241
+ }
242
+
243
+ /**
244
+ * EAV dual-write seam (#374, live path lands in #124). No-op by default;
245
+ * `eav: true` entities emit a concrete override that injects
246
+ * `FieldValueService` and delegates to `upsertFieldsTransactional` so the
247
+ * dual-write joins the same tx (`db`). Kept as an explicit hook so the base
248
+ * stays portable (the FieldValueService dependency is eav-only).
249
+ */
250
+ protected async writeCustomFields(
251
+ _db: DrizzleTx,
252
+ _entityId: string,
253
+ _userId: string,
254
+ _fields: Record<string, unknown>,
255
+ ): Promise<void> {
256
+ // Intentionally empty until the entity opts into EAV.
257
+ }
258
+
259
+ /**
260
+ * Resolve one FK from a parent external id (provider-scoped). `self` resolves
261
+ * against `this.table`. Strict resolvers throw when unresolved; non-strict
262
+ * return null. A null/absent write value short-circuits to null.
263
+ */
264
+ private async resolveFk(
265
+ db: DrizzleTx,
266
+ fk: SyncFkResolver,
267
+ rawExternalId: unknown,
268
+ provider: string,
269
+ ): Promise<string | null> {
270
+ const parentExternalId = rawExternalId as string | null | undefined;
271
+ if (!parentExternalId) {
272
+ if (fk.strict) {
273
+ throw new Error(
274
+ `${this.constructor.name}.syncUpsertOne: missing required parent ` +
275
+ `external id for '${fk.column}' (writeKey '${fk.writeKey}')`,
276
+ );
277
+ }
278
+ return null;
279
+ }
280
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
281
+ const refTable: PgTableWithColumns<any> =
282
+ fk.refTable === 'self' ? this.table : fk.refTable;
283
+ const rows = await db
284
+ .select({ id: refTable['id'] })
285
+ .from(refTable)
286
+ .where(
287
+ and(
288
+ eq(refTable['provider'], provider),
289
+ eq(refTable['externalId'], parentExternalId),
290
+ ),
291
+ )
292
+ .limit(1);
293
+ const id = (rows[0]?.id as string | undefined) ?? null;
294
+ if (id === null && fk.strict) {
295
+ throw new Error(
296
+ `${this.constructor.name}.syncUpsertOne: unresolved parent ` +
297
+ `'${parentExternalId}' (provider '${provider}') for '${fk.column}' — ` +
298
+ `parent not synced yet`,
299
+ );
300
+ }
301
+ return id;
48
302
  }
49
303
 
50
304
  /**