@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.
- package/dist/runtime/base-classes/index.d.ts +2 -0
- package/dist/runtime/base-classes/index.js +345 -18
- package/dist/runtime/base-classes/index.js.map +1 -1
- package/dist/runtime/base-classes/junction-sync-repository.d.ts +87 -0
- package/dist/runtime/base-classes/junction-sync-repository.js +362 -0
- package/dist/runtime/base-classes/junction-sync-repository.js.map +1 -0
- package/dist/runtime/base-classes/sync-upsert-config.d.ts +58 -0
- package/dist/runtime/base-classes/sync-upsert-config.js +1 -0
- package/dist/runtime/base-classes/sync-upsert-config.js.map +1 -0
- package/dist/runtime/base-classes/synced-entity-repository.d.ts +68 -6
- package/dist/runtime/base-classes/synced-entity-repository.js +173 -7
- package/dist/runtime/base-classes/synced-entity-repository.js.map +1 -1
- package/dist/runtime/subsystems/sync/execute-sync.use-case.js +19 -1
- package/dist/runtime/subsystems/sync/execute-sync.use-case.js.map +1 -1
- package/dist/runtime/subsystems/sync/index.js +19 -1
- package/dist/runtime/subsystems/sync/index.js.map +1 -1
- package/dist/runtime/subsystems/sync/sync-sink.protocol.d.ts +6 -0
- package/dist/src/cli/index.js +24 -2
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/index.js +21 -2
- package/dist/src/index.js.map +1 -1
- package/package.json +1 -1
- package/runtime/base-classes/index.ts +9 -0
- package/runtime/base-classes/junction-sync-repository.ts +284 -0
- package/runtime/base-classes/sync-upsert-config.ts +58 -0
- package/runtime/base-classes/synced-entity-repository.ts +263 -9
- package/runtime/subsystems/sync/execute-sync.use-case.ts +25 -1
- package/runtime/subsystems/sync/sync-sink.protocol.ts +7 -0
- package/src/patterns/library/synced.pattern.ts +2 -1
- package/templates/_shared/generated-banner.mjs +74 -0
- package/templates/broadcast/new/backend-interface.ejs.t +1 -0
- package/templates/broadcast/new/bridge-listener.ejs.t +1 -0
- package/templates/broadcast/new/channel.ejs.t +1 -0
- package/templates/broadcast/new/index.ejs.t +1 -0
- package/templates/broadcast/new/memory-backend.ejs.t +1 -0
- package/templates/broadcast/new/module.ejs.t +1 -0
- package/templates/broadcast/new/prompt.js +13 -0
- package/templates/broadcast/new/websocket-backend.ejs.t +1 -0
- package/templates/entity/new/backend/application/commands/create.ejs.t +1 -0
- package/templates/entity/new/backend/application/commands/delete.ejs.t +1 -0
- package/templates/entity/new/backend/application/commands/grouped-index.ejs.t +1 -0
- package/templates/entity/new/backend/application/commands/index.ejs.t +1 -0
- package/templates/entity/new/backend/application/commands/update.ejs.t +1 -0
- package/templates/entity/new/backend/application/queries/declarative-queries.ejs.t +1 -0
- package/templates/entity/new/backend/application/queries/get-by-id.ejs.t +1 -0
- package/templates/entity/new/backend/application/queries/grouped-index.ejs.t +1 -0
- package/templates/entity/new/backend/application/queries/index.ejs.t +1 -0
- package/templates/entity/new/backend/application/queries/list.ejs.t +1 -0
- package/templates/entity/new/backend/application/queries/relationships.queries.ejs.t +1 -0
- package/templates/entity/new/backend/application/schemas/dto.ejs.t +1 -0
- package/templates/entity/new/backend/database/repository.ejs.t +1 -0
- package/templates/entity/new/backend/database/schema.ejs.t +1 -0
- package/templates/entity/new/backend/domain/entity.ejs.t +1 -0
- package/templates/entity/new/backend/domain/grouped-index.ejs.t +1 -0
- package/templates/entity/new/backend/domain/index.ejs.t +1 -0
- package/templates/entity/new/backend/domain/repository-interface.ejs.t +1 -0
- package/templates/entity/new/backend/modules/core/module.ejs.t +1 -0
- package/templates/entity/new/backend/modules/core/sync-source.ejs.t +1 -0
- package/templates/entity/new/backend/modules/core/sync-source.providers.ejs.t +1 -0
- package/templates/entity/new/backend/modules/trpc/module.ejs.t +1 -0
- package/templates/entity/new/backend/presentation/controller.ejs.t +1 -0
- package/templates/entity/new/clean-lite-ps/controller.ejs.t +1 -0
- package/templates/entity/new/clean-lite-ps/dto/create.ejs.t +1 -0
- package/templates/entity/new/clean-lite-ps/dto/output.ejs.t +1 -0
- package/templates/entity/new/clean-lite-ps/dto/update.ejs.t +1 -0
- package/templates/entity/new/clean-lite-ps/entity.ejs.t +1 -0
- package/templates/entity/new/clean-lite-ps/index.ejs.t +1 -0
- package/templates/entity/new/clean-lite-ps/module.ejs.t +1 -0
- package/templates/entity/new/clean-lite-ps/prompt-extension.js +148 -0
- package/templates/entity/new/clean-lite-ps/repository.ejs.t +92 -1
- package/templates/entity/new/clean-lite-ps/search-controller.ejs.t +1 -0
- package/templates/entity/new/clean-lite-ps/service.ejs.t +1 -0
- package/templates/entity/new/clean-lite-ps/use-cases/create.ejs.t +1 -0
- package/templates/entity/new/clean-lite-ps/use-cases/declarative-queries.ejs.t +1 -0
- package/templates/entity/new/clean-lite-ps/use-cases/delete.ejs.t +1 -0
- package/templates/entity/new/clean-lite-ps/use-cases/find-by-id-with-fields.ejs.t +1 -0
- package/templates/entity/new/clean-lite-ps/use-cases/find-by-id.ejs.t +1 -0
- package/templates/entity/new/clean-lite-ps/use-cases/list-with-fields.ejs.t +1 -0
- package/templates/entity/new/clean-lite-ps/use-cases/list.ejs.t +1 -0
- package/templates/entity/new/clean-lite-ps/use-cases/search.ejs.t +1 -0
- package/templates/entity/new/clean-lite-ps/use-cases/update.ejs.t +1 -0
- package/templates/entity/new/frontend/entity/collection.ejs.t +1 -0
- package/templates/entity/new/frontend/entity/combined.ejs.t +1 -0
- package/templates/entity/new/frontend/entity/fields.ejs.t +1 -0
- package/templates/entity/new/frontend/entity/hooks.ejs.t +1 -0
- package/templates/entity/new/frontend/entity/index.ejs.t +1 -0
- package/templates/entity/new/frontend/entity/mutation-hooks.ejs.t +1 -0
- package/templates/entity/new/frontend/entity/mutations.ejs.t +1 -0
- package/templates/entity/new/frontend/entity/types.ejs.t +1 -0
- package/templates/entity/new/frontend/store/hooks.ejs.t +1 -0
- package/templates/entity/new/frontend/unified-entity.ejs.t +1 -0
- package/templates/entity/new/prompt.js +19 -0
- package/templates/junction/new/entity.ejs.t +14 -2
- package/templates/junction/new/index.ejs.t +1 -0
- package/templates/junction/new/module.ejs.t +1 -0
- package/templates/junction/new/prompt.js +83 -0
- package/templates/junction/new/repository.ejs.t +44 -3
- package/templates/junction/new/service.ejs.t +1 -0
- package/templates/relationship/new/controller.ejs.t +1 -0
- package/templates/relationship/new/dto/create.ejs.t +1 -0
- package/templates/relationship/new/dto/output.ejs.t +1 -0
- package/templates/relationship/new/dto/update.ejs.t +1 -0
- package/templates/relationship/new/entity.ejs.t +1 -0
- package/templates/relationship/new/index.ejs.t +1 -0
- package/templates/relationship/new/module.ejs.t +1 -0
- package/templates/relationship/new/prompt.js +14 -0
- package/templates/relationship/new/repository.ejs.t +1 -0
- package/templates/relationship/new/service.ejs.t +1 -0
- package/templates/relationship/new/use-cases/declarative-queries.ejs.t +1 -0
- package/templates/relationship/new/use-cases/find-by-id.ejs.t +1 -0
- package/templates/relationship/new/use-cases/list.ejs.t +1 -0
- package/templates/subsystem/auth/auth-oauth-state.schema.ejs.t +1 -0
- package/templates/subsystem/auth/prompt.js +8 -0
- package/templates/subsystem/events/domain-events.schema.ejs.t +1 -0
- package/templates/subsystem/events/prompt.js +8 -0
- package/templates/subsystem/jobs/job-orchestration.schema.ejs.t +1 -0
- package/templates/subsystem/jobs/prompt.js +8 -0
- package/templates/subsystem/sync/prompt.js +8 -0
- package/templates/subsystem/sync/sync-audit.schema.ejs.t +1 -0
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
44
|
-
*
|
|
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
|
|
47
|
-
|
|
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
|
/**
|