@pattern-stack/codegen 0.7.5 → 0.7.7
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 +356 -18
- package/dist/runtime/base-classes/index.js.map +1 -1
- package/dist/runtime/base-classes/junction-sync-repository.d.ts +89 -0
- package/dist/runtime/base-classes/junction-sync-repository.js +373 -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 +295 -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 +1 -0
- 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
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// runtime/base-classes/junction-sync-repository.ts
|
|
2
|
+
import { and, eq as eq2 } from "drizzle-orm";
|
|
3
|
+
|
|
4
|
+
// runtime/base-classes/base-repository.ts
|
|
5
|
+
import { eq, inArray, isNull, sql } from "drizzle-orm";
|
|
6
|
+
var BaseRepository = class {
|
|
7
|
+
// eslint-disable-line @typescript-eslint/no-explicit-any
|
|
8
|
+
/**
|
|
9
|
+
* Behavior flags controlling automatic behavior injection.
|
|
10
|
+
* Override in concrete repositories to enable behaviors.
|
|
11
|
+
*/
|
|
12
|
+
behaviors = {
|
|
13
|
+
timestamps: false,
|
|
14
|
+
softDelete: false,
|
|
15
|
+
userTracking: false
|
|
16
|
+
};
|
|
17
|
+
db;
|
|
18
|
+
constructor(db) {
|
|
19
|
+
this.db = db;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Pick the runner for a write: the caller-supplied transaction handle
|
|
23
|
+
* if present, otherwise the repository's own client. Keeps the `tx`
|
|
24
|
+
* parameter purely additive — callers without a transaction call as
|
|
25
|
+
* before. Used by the write methods below + consumer overrides (e.g.
|
|
26
|
+
* the generated `upsertCurrentValues` on EAV value tables).
|
|
27
|
+
*/
|
|
28
|
+
runner(tx) {
|
|
29
|
+
return tx ?? this.db;
|
|
30
|
+
}
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// Read Operations
|
|
33
|
+
// ============================================================================
|
|
34
|
+
/**
|
|
35
|
+
* Find a single entity by its primary key.
|
|
36
|
+
* Returns null if not found (or soft-deleted when softDelete=true).
|
|
37
|
+
*/
|
|
38
|
+
async findById(id) {
|
|
39
|
+
const rows = await this.baseQuery().where(eq(this.table["id"], id)).limit(1);
|
|
40
|
+
return rows[0] ?? null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Find multiple entities by their primary keys.
|
|
44
|
+
* Returns empty array immediately for empty input (avoids DB errors).
|
|
45
|
+
*/
|
|
46
|
+
async findByIds(ids) {
|
|
47
|
+
if (ids.length === 0) return [];
|
|
48
|
+
const rows = await this.baseQuery().where(inArray(this.table["id"], ids));
|
|
49
|
+
return rows;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* List entities with optional filtering, pagination, and ordering.
|
|
53
|
+
*/
|
|
54
|
+
async list(options) {
|
|
55
|
+
let query = this.baseQuery();
|
|
56
|
+
if (options?.where) {
|
|
57
|
+
query = query.where(options.where);
|
|
58
|
+
}
|
|
59
|
+
if (options?.orderBy) {
|
|
60
|
+
query = query.orderBy(options.orderBy);
|
|
61
|
+
}
|
|
62
|
+
if (options?.limit !== void 0) {
|
|
63
|
+
query = query.limit(options.limit);
|
|
64
|
+
}
|
|
65
|
+
if (options?.offset !== void 0) {
|
|
66
|
+
query = query.offset(options.offset);
|
|
67
|
+
}
|
|
68
|
+
const rows = await query;
|
|
69
|
+
return rows;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Count entities matching an optional WHERE clause.
|
|
73
|
+
* Soft-deleted rows are always excluded when softDelete=true.
|
|
74
|
+
*/
|
|
75
|
+
async count(where) {
|
|
76
|
+
let query = this.db.select({ count: sql`cast(count(*) as integer)` }).from(this.table);
|
|
77
|
+
const conditions = [];
|
|
78
|
+
if (this.behaviors.softDelete) {
|
|
79
|
+
conditions.push(isNull(this.table["deletedAt"]));
|
|
80
|
+
}
|
|
81
|
+
if (where) {
|
|
82
|
+
conditions.push(where);
|
|
83
|
+
}
|
|
84
|
+
if (conditions.length === 1) {
|
|
85
|
+
query = query.where(conditions[0]);
|
|
86
|
+
} else if (conditions.length > 1) {
|
|
87
|
+
const { and: and2 } = await import("drizzle-orm");
|
|
88
|
+
query = query.where(and2(...conditions));
|
|
89
|
+
}
|
|
90
|
+
const rows = await query;
|
|
91
|
+
return rows[0]?.count ?? 0;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Check whether an entity with the given id exists.
|
|
95
|
+
*/
|
|
96
|
+
async exists(id) {
|
|
97
|
+
const result = await this.findById(id);
|
|
98
|
+
return result !== null;
|
|
99
|
+
}
|
|
100
|
+
// ============================================================================
|
|
101
|
+
// Write Operations
|
|
102
|
+
// ============================================================================
|
|
103
|
+
/**
|
|
104
|
+
* Insert a new entity. Timestamps are auto-injected when timestamps=true.
|
|
105
|
+
*/
|
|
106
|
+
async create(input, tx) {
|
|
107
|
+
const data = this.withTimestamps(input, "create");
|
|
108
|
+
const rows = await this.runner(tx).insert(this.table).values(data).returning();
|
|
109
|
+
return rows[0];
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Update an existing entity by id. updatedAt is auto-injected when timestamps=true.
|
|
113
|
+
* Returns the updated entity.
|
|
114
|
+
*/
|
|
115
|
+
async update(id, input, tx) {
|
|
116
|
+
const data = this.withTimestamps(input, "update");
|
|
117
|
+
const rows = await this.runner(tx).update(this.table).set(data).where(eq(this.table["id"], id)).returning();
|
|
118
|
+
return rows[0];
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Delete an entity by id.
|
|
122
|
+
* - softDelete=true: sets deletedAt to current timestamp
|
|
123
|
+
* - softDelete=false: hard-deletes the row
|
|
124
|
+
*/
|
|
125
|
+
async delete(id, tx) {
|
|
126
|
+
const runner = this.runner(tx);
|
|
127
|
+
if (this.behaviors.softDelete) {
|
|
128
|
+
await runner.update(this.table).set({ deletedAt: /* @__PURE__ */ new Date() }).where(eq(this.table["id"], id));
|
|
129
|
+
} else {
|
|
130
|
+
await runner.delete(this.table).where(eq(this.table["id"], id));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Insert or update multiple entities.
|
|
135
|
+
* Default naive implementation — family repositories override with
|
|
136
|
+
* proper conflict-target upsert (e.g., CrmEntityRepository).
|
|
137
|
+
*/
|
|
138
|
+
async upsertMany(inputs, tx) {
|
|
139
|
+
return Promise.all(inputs.map((input) => this.create(input, tx)));
|
|
140
|
+
}
|
|
141
|
+
// ============================================================================
|
|
142
|
+
// Protected Helpers
|
|
143
|
+
// ============================================================================
|
|
144
|
+
/**
|
|
145
|
+
* Base SELECT query that automatically excludes soft-deleted rows
|
|
146
|
+
* when softDelete behavior is enabled.
|
|
147
|
+
*/
|
|
148
|
+
baseQuery() {
|
|
149
|
+
const query = this.db.select().from(this.table).$dynamic();
|
|
150
|
+
if (this.behaviors.softDelete) {
|
|
151
|
+
return query.where(isNull(this.table["deletedAt"]));
|
|
152
|
+
}
|
|
153
|
+
return query;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Merge timestamp fields into an input object.
|
|
157
|
+
* - mode='create': adds createdAt and updatedAt
|
|
158
|
+
* - mode='update': adds updatedAt only
|
|
159
|
+
*
|
|
160
|
+
* No-op when timestamps behavior is disabled.
|
|
161
|
+
*/
|
|
162
|
+
withTimestamps(input, mode) {
|
|
163
|
+
if (!this.behaviors.timestamps) return input;
|
|
164
|
+
const now = /* @__PURE__ */ new Date();
|
|
165
|
+
if (mode === "create") {
|
|
166
|
+
return { ...input, createdAt: now, updatedAt: now };
|
|
167
|
+
}
|
|
168
|
+
return { ...input, updatedAt: now };
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Build a WHERE clause fragment that restricts results to rows whose
|
|
172
|
+
* parent (identified by a belongs_to FK) is not soft-deleted.
|
|
173
|
+
*
|
|
174
|
+
* Use this in custom repository methods when you need "rows reachable
|
|
175
|
+
* from an active parent". The default findAll / findById behavior is
|
|
176
|
+
* NOT changed by this helper — opt in explicitly where needed.
|
|
177
|
+
*
|
|
178
|
+
* ADR-021 — Soft-delete cascade: Option A (filter at query time).
|
|
179
|
+
* `on_delete` FK rules do not fire for soft-deletes; use this helper
|
|
180
|
+
* instead of expecting cascade semantics on the DB level.
|
|
181
|
+
*
|
|
182
|
+
* Example:
|
|
183
|
+
* async listActiveMessages(): Promise<Message[]> {
|
|
184
|
+
* return this.list({
|
|
185
|
+
* where: this.activeParentFilter(conversations, this.table['conversationId']),
|
|
186
|
+
* });
|
|
187
|
+
* }
|
|
188
|
+
*
|
|
189
|
+
* @param parentTable The Drizzle table object for the parent entity.
|
|
190
|
+
* @param parentFkColumn The FK column on this (child) table that references parent.id.
|
|
191
|
+
*/
|
|
192
|
+
activeParentFilter(parentTable, parentFkColumn) {
|
|
193
|
+
return sql`EXISTS (
|
|
194
|
+
SELECT 1 FROM ${parentTable} p
|
|
195
|
+
WHERE p.id = ${parentFkColumn}
|
|
196
|
+
AND p.deleted_at IS NULL
|
|
197
|
+
)`;
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// runtime/base-classes/junction-sync-repository.ts
|
|
202
|
+
var JunctionSyncRepository = class extends BaseRepository {
|
|
203
|
+
/**
|
|
204
|
+
* Upsert ONE junction row by its composite identity, in a single transaction:
|
|
205
|
+
* 1. resolve the REQUIRED left FK (provider-scoped) — STRICT: missing → throws;
|
|
206
|
+
* 2. resolve the REQUIRED right FK (provider-scoped) — STRICT: missing → throws;
|
|
207
|
+
* 3. insert-or-update on the `(left, right[, role])` conflict target.
|
|
208
|
+
*
|
|
209
|
+
* Idempotent. Returns the composite externalId as the projection `id`.
|
|
210
|
+
*
|
|
211
|
+
* @param write parent external ids (`<left>ExternalId`/`<right>ExternalId`)
|
|
212
|
+
* + optional `role` + `userId`
|
|
213
|
+
* @param provider adapter/provider label used to scope the parent lookups
|
|
214
|
+
* @param tx optional outer transaction; when omitted we open our own
|
|
215
|
+
*/
|
|
216
|
+
async syncUpsertOne(write, provider, tx) {
|
|
217
|
+
const cfg = this.syncConfig;
|
|
218
|
+
const w = write;
|
|
219
|
+
const leftWriteKey = `${cfg.left.column.replace(/Id$/, "")}ExternalId`;
|
|
220
|
+
const rightWriteKey = `${cfg.right.column.replace(/Id$/, "")}ExternalId`;
|
|
221
|
+
const run = async (db) => {
|
|
222
|
+
const leftId = await this.resolveStrict(
|
|
223
|
+
db,
|
|
224
|
+
cfg.left.refTable,
|
|
225
|
+
w[leftWriteKey],
|
|
226
|
+
provider,
|
|
227
|
+
cfg.left.column
|
|
228
|
+
);
|
|
229
|
+
const rightId = await this.resolveStrict(
|
|
230
|
+
db,
|
|
231
|
+
cfg.right.refTable,
|
|
232
|
+
w[rightWriteKey],
|
|
233
|
+
provider,
|
|
234
|
+
cfg.right.column
|
|
235
|
+
);
|
|
236
|
+
const now = /* @__PURE__ */ new Date();
|
|
237
|
+
const role = cfg.roleColumn ? w["role"] : void 0;
|
|
238
|
+
const values = {
|
|
239
|
+
[cfg.left.column]: leftId,
|
|
240
|
+
[cfg.right.column]: rightId,
|
|
241
|
+
...cfg.roleColumn ? { [cfg.roleColumn]: role } : {},
|
|
242
|
+
...this.behaviors.timestamps ? { updatedAt: now } : {}
|
|
243
|
+
};
|
|
244
|
+
const target = cfg.roleColumn ? [this.table[cfg.left.column], this.table[cfg.right.column], this.table[cfg.roleColumn]] : [this.table[cfg.left.column], this.table[cfg.right.column]];
|
|
245
|
+
const rows = await db.insert(this.table).values(values).onConflictDoUpdate({
|
|
246
|
+
target,
|
|
247
|
+
set: { ...this.behaviors.timestamps ? { updatedAt: now } : {} }
|
|
248
|
+
}).returning();
|
|
249
|
+
const saved = rows[0];
|
|
250
|
+
return this.toProjection(saved, w, provider);
|
|
251
|
+
};
|
|
252
|
+
return tx ? run(tx) : this.db.transaction((t) => run(t));
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Canonical-projected lookup by the COMPOSITE externalId, differ-ready. Parses
|
|
256
|
+
* the composite, resolves BOTH parents NON-throwing (→ null), then selects by
|
|
257
|
+
* the identity tuple. Returns `null` on malformed composite / unresolved
|
|
258
|
+
* parent / no row (a missing "before" side is a create from the differ's view).
|
|
259
|
+
*/
|
|
260
|
+
async findByExternalIdProjected(externalId, provider) {
|
|
261
|
+
const cfg = this.syncConfig;
|
|
262
|
+
const parsed = parseCompositeExternalId(externalId, cfg.roleColumn !== null);
|
|
263
|
+
if (!parsed) return null;
|
|
264
|
+
const leftId = await this.resolveLoose(this.db, cfg.left.refTable, parsed.left, provider);
|
|
265
|
+
if (leftId === null) return null;
|
|
266
|
+
const rightId = await this.resolveLoose(this.db, cfg.right.refTable, parsed.right, provider);
|
|
267
|
+
if (rightId === null) return null;
|
|
268
|
+
const rows = await this.db.select().from(this.table).where(this.identityWhere(leftId, rightId, parsed.role)).limit(1);
|
|
269
|
+
const row = rows[0];
|
|
270
|
+
if (!row) return null;
|
|
271
|
+
const w = {
|
|
272
|
+
[`${cfg.left.column.replace(/Id$/, "")}ExternalId`]: parsed.left,
|
|
273
|
+
[`${cfg.right.column.replace(/Id$/, "")}ExternalId`]: parsed.right,
|
|
274
|
+
...cfg.roleColumn ? { role: parsed.role } : {},
|
|
275
|
+
userId: ""
|
|
276
|
+
};
|
|
277
|
+
return this.toProjection(row, w, provider);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Hard-delete the junction by composite externalId. Junctions have no
|
|
281
|
+
* `deleted_at` and no external-linkage columns to clear, so a sync "delete"
|
|
282
|
+
* removes the row. Resolves both parents NON-throwing, then deletes by the
|
|
283
|
+
* identity tuple. Returns the composite id, or `null` when nothing matched.
|
|
284
|
+
*/
|
|
285
|
+
async softDeleteByExternalId(externalId, provider, tx) {
|
|
286
|
+
const cfg = this.syncConfig;
|
|
287
|
+
const parsed = parseCompositeExternalId(externalId, cfg.roleColumn !== null);
|
|
288
|
+
if (!parsed) return null;
|
|
289
|
+
const db = this.runner(tx);
|
|
290
|
+
const leftId = await this.resolveLoose(db, cfg.left.refTable, parsed.left, provider);
|
|
291
|
+
if (leftId === null) return null;
|
|
292
|
+
const rightId = await this.resolveLoose(db, cfg.right.refTable, parsed.right, provider);
|
|
293
|
+
if (rightId === null) return null;
|
|
294
|
+
const rows = await db.delete(this.table).where(this.identityWhere(leftId, rightId, parsed.role)).returning({ id: this.table[cfg.left.column] });
|
|
295
|
+
return rows[0] ? { id: externalId } : null;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Project a raw junction row to the differ shape: the COMPOSITE externalId
|
|
299
|
+
* `id` (junctions have no surrogate id) plus the local FK columns, role (when
|
|
300
|
+
* role-bearing) and timestamps — matching the emitted
|
|
301
|
+
* `<Junction>SyncProjection` interface (id + leftId + rightId + role? +
|
|
302
|
+
* createdAt + updatedAt). Junction projections are purely structural, so this
|
|
303
|
+
* is fully generic over `syncConfig` — no per-junction override is emitted.
|
|
304
|
+
*/
|
|
305
|
+
toProjection(row, write, _provider) {
|
|
306
|
+
const cfg = this.syncConfig;
|
|
307
|
+
const r = row;
|
|
308
|
+
const leftExt = write[`${cfg.left.column.replace(/Id$/, "")}ExternalId`];
|
|
309
|
+
const rightExt = write[`${cfg.right.column.replace(/Id$/, "")}ExternalId`];
|
|
310
|
+
const role = cfg.roleColumn ? r[cfg.roleColumn] : void 0;
|
|
311
|
+
const out = {
|
|
312
|
+
id: buildCompositeExternalId(leftExt, rightExt, role),
|
|
313
|
+
[cfg.left.column]: r[cfg.left.column],
|
|
314
|
+
[cfg.right.column]: r[cfg.right.column]
|
|
315
|
+
};
|
|
316
|
+
if (cfg.roleColumn) out[cfg.roleColumn] = r[cfg.roleColumn];
|
|
317
|
+
out["createdAt"] = r["createdAt"];
|
|
318
|
+
out["updatedAt"] = r["updatedAt"];
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
/** Build the identity WHERE clause `(left, right[, role])`. */
|
|
322
|
+
identityWhere(leftId, rightId, role) {
|
|
323
|
+
const cfg = this.syncConfig;
|
|
324
|
+
const conds = [
|
|
325
|
+
eq2(this.table[cfg.left.column], leftId),
|
|
326
|
+
eq2(this.table[cfg.right.column], rightId)
|
|
327
|
+
];
|
|
328
|
+
if (cfg.roleColumn && role !== void 0) {
|
|
329
|
+
conds.push(eq2(this.table[cfg.roleColumn], role));
|
|
330
|
+
}
|
|
331
|
+
return and(...conds);
|
|
332
|
+
}
|
|
333
|
+
/** Resolve a parent id (provider-scoped), throwing when unresolved. */
|
|
334
|
+
async resolveStrict(db, refTable, parentExternalId, provider, column) {
|
|
335
|
+
const id = await this.resolveLoose(db, refTable, parentExternalId, provider);
|
|
336
|
+
if (!id) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
`${this.constructor.name}.syncUpsertOne: unresolved parent '${parentExternalId}' (provider '${provider}') for '${column}' \u2014 parent not synced yet`
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
return id;
|
|
342
|
+
}
|
|
343
|
+
/** Resolve a parent id (provider-scoped), returning null when unresolved. */
|
|
344
|
+
async resolveLoose(db, refTable, parentExternalId, provider) {
|
|
345
|
+
if (!parentExternalId) return null;
|
|
346
|
+
const rows = await db.select({ id: refTable["id"] }).from(refTable).where(
|
|
347
|
+
and(
|
|
348
|
+
eq2(refTable["provider"], provider),
|
|
349
|
+
eq2(refTable["externalId"], parentExternalId)
|
|
350
|
+
)
|
|
351
|
+
).limit(1);
|
|
352
|
+
return rows[0]?.id ?? null;
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
function buildCompositeExternalId(leftExternalId, rightExternalId, role) {
|
|
356
|
+
return role !== void 0 ? `${leftExternalId}::${rightExternalId}::${role}` : `${leftExternalId}::${rightExternalId}`;
|
|
357
|
+
}
|
|
358
|
+
function parseCompositeExternalId(externalId, withRole) {
|
|
359
|
+
const parts = externalId.split("::");
|
|
360
|
+
const expected = withRole ? 3 : 2;
|
|
361
|
+
if (parts.length !== expected || parts.some((p) => p.length === 0)) return null;
|
|
362
|
+
return {
|
|
363
|
+
left: parts[0],
|
|
364
|
+
right: parts[1],
|
|
365
|
+
role: withRole ? parts[2] : void 0
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
export {
|
|
369
|
+
JunctionSyncRepository,
|
|
370
|
+
buildCompositeExternalId,
|
|
371
|
+
parseCompositeExternalId
|
|
372
|
+
};
|
|
373
|
+
//# sourceMappingURL=junction-sync-repository.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../runtime/base-classes/junction-sync-repository.ts","../../../runtime/base-classes/base-repository.ts"],"sourcesContent":["/**\n * JunctionSyncRepository<TEntity, TSyncWrite, TSyncProjection>\n *\n * Base for junction repos that participate in inbound sync (#374). A junction's\n * sync identity is the tuple `(leftId, rightId[, role])` — there is no native\n * `external_id`/`provider` column, so the sync seam's externalId is a COMPOSITE\n * string `<leftExternalId>::<rightExternalId>[::<role>]` (see the static\n * build/parse helpers below).\n *\n * Both parent FKs are resolved STRICTLY in the write path (a missing parent\n * throws → the orchestrator records a failed item and continues). As of #372\n * role-bearing junctions carry a unique constraint over `(left, right, role)`,\n * so the upsert uses `onConflictDoUpdate` (not the legacy select-then-write).\n * Role-less junctions conflict on `(left, right)`.\n */\nimport { and, eq } from 'drizzle-orm';\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nimport type { PgTableWithColumns } from 'drizzle-orm/pg-core';\nimport type { DrizzleTx } from '../types/drizzle';\nimport { BaseRepository } from './base-repository';\n\nexport interface JunctionSyncConfig {\n /** Left endpoint: local FK column (camel) + strict parent table. */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n left: { column: string; refTable: PgTableWithColumns<any> };\n /** Right endpoint: local FK column (camel) + strict parent table. */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n right: { column: string; refTable: PgTableWithColumns<any> };\n /** Role column (camel), or null for a role-less (2-part composite) junction. */\n roleColumn: string | null;\n}\n\nexport abstract class JunctionSyncRepository<\n TEntity,\n TSyncWrite,\n TSyncProjection,\n> extends BaseRepository<TEntity> {\n /**\n * Declarative junction sync surface. Concrete repos declare this — the\n * template emits it with live parent-table handles.\n */\n protected abstract readonly syncConfig: JunctionSyncConfig;\n\n /**\n * Upsert ONE junction row by its composite identity, in a single transaction:\n * 1. resolve the REQUIRED left FK (provider-scoped) — STRICT: missing → throws;\n * 2. resolve the REQUIRED right FK (provider-scoped) — STRICT: missing → throws;\n * 3. insert-or-update on the `(left, right[, role])` conflict target.\n *\n * Idempotent. Returns the composite externalId as the projection `id`.\n *\n * @param write parent external ids (`<left>ExternalId`/`<right>ExternalId`)\n * + optional `role` + `userId`\n * @param provider adapter/provider label used to scope the parent lookups\n * @param tx optional outer transaction; when omitted we open our own\n */\n async syncUpsertOne(\n write: TSyncWrite,\n provider: string,\n tx?: DrizzleTx,\n ): Promise<TSyncProjection> {\n const cfg = this.syncConfig;\n const w = write as Record<string, unknown>;\n const leftWriteKey = `${cfg.left.column.replace(/Id$/, '')}ExternalId`;\n const rightWriteKey = `${cfg.right.column.replace(/Id$/, '')}ExternalId`;\n\n const run = async (db: DrizzleTx): Promise<TSyncProjection> => {\n const leftId = await this.resolveStrict(\n db, cfg.left.refTable, w[leftWriteKey] as string, provider, cfg.left.column,\n );\n const rightId = await this.resolveStrict(\n db, cfg.right.refTable, w[rightWriteKey] as string, provider, cfg.right.column,\n );\n\n const now = new Date();\n const role = cfg.roleColumn ? (w['role'] as unknown) : undefined;\n const values: Record<string, unknown> = {\n [cfg.left.column]: leftId,\n [cfg.right.column]: rightId,\n ...(cfg.roleColumn ? { [cfg.roleColumn]: role } : {}),\n ...(this.behaviors.timestamps ? { updatedAt: now } : {}),\n };\n const target = cfg.roleColumn\n ? [this.table[cfg.left.column], this.table[cfg.right.column], this.table[cfg.roleColumn]]\n : [this.table[cfg.left.column], this.table[cfg.right.column]];\n\n const rows = await db\n .insert(this.table)\n .values(values as never)\n .onConflictDoUpdate({\n target,\n set: { ...(this.behaviors.timestamps ? { updatedAt: now } : {}) } as never,\n })\n .returning();\n\n const saved = rows[0] as Record<string, unknown>;\n return this.toProjection(saved as TEntity, w, provider);\n };\n\n return tx ? run(tx) : this.db.transaction((t) => run(t));\n }\n\n /**\n * Canonical-projected lookup by the COMPOSITE externalId, differ-ready. Parses\n * the composite, resolves BOTH parents NON-throwing (→ null), then selects by\n * the identity tuple. Returns `null` on malformed composite / unresolved\n * parent / no row (a missing \"before\" side is a create from the differ's view).\n */\n async findByExternalIdProjected(\n externalId: string,\n provider: string,\n ): Promise<TSyncProjection | null> {\n const cfg = this.syncConfig;\n const parsed = parseCompositeExternalId(externalId, cfg.roleColumn !== null);\n if (!parsed) return null;\n\n const leftId = await this.resolveLoose(this.db, cfg.left.refTable, parsed.left, provider);\n if (leftId === null) return null;\n const rightId = await this.resolveLoose(this.db, cfg.right.refTable, parsed.right, provider);\n if (rightId === null) return null;\n\n const rows = await this.db\n .select()\n .from(this.table)\n .where(this.identityWhere(leftId, rightId, parsed.role))\n .limit(1);\n const row = rows[0] as TEntity | undefined;\n if (!row) return null;\n\n const w: Record<string, unknown> = {\n [`${cfg.left.column.replace(/Id$/, '')}ExternalId`]: parsed.left,\n [`${cfg.right.column.replace(/Id$/, '')}ExternalId`]: parsed.right,\n ...(cfg.roleColumn ? { role: parsed.role } : {}),\n userId: '',\n };\n return this.toProjection(row, w, provider);\n }\n\n /**\n * Hard-delete the junction by composite externalId. Junctions have no\n * `deleted_at` and no external-linkage columns to clear, so a sync \"delete\"\n * removes the row. Resolves both parents NON-throwing, then deletes by the\n * identity tuple. Returns the composite id, or `null` when nothing matched.\n */\n async softDeleteByExternalId(\n externalId: string,\n provider: string,\n tx?: DrizzleTx,\n ): Promise<{ id: string } | null> {\n const cfg = this.syncConfig;\n const parsed = parseCompositeExternalId(externalId, cfg.roleColumn !== null);\n if (!parsed) return null;\n const db = this.runner(tx);\n\n const leftId = await this.resolveLoose(db, cfg.left.refTable, parsed.left, provider);\n if (leftId === null) return null;\n const rightId = await this.resolveLoose(db, cfg.right.refTable, parsed.right, provider);\n if (rightId === null) return null;\n\n const rows = await db\n .delete(this.table)\n .where(this.identityWhere(leftId, rightId, parsed.role))\n .returning({ id: this.table[cfg.left.column] });\n return rows[0] ? { id: externalId } : null;\n }\n\n /**\n * Project a raw junction row to the differ shape: the COMPOSITE externalId\n * `id` (junctions have no surrogate id) plus the local FK columns, role (when\n * role-bearing) and timestamps — matching the emitted\n * `<Junction>SyncProjection` interface (id + leftId + rightId + role? +\n * createdAt + updatedAt). Junction projections are purely structural, so this\n * is fully generic over `syncConfig` — no per-junction override is emitted.\n */\n protected toProjection(\n row: TEntity,\n write: Record<string, unknown>,\n _provider: string,\n ): TSyncProjection {\n const cfg = this.syncConfig;\n const r = row as Record<string, unknown>;\n const leftExt = write[`${cfg.left.column.replace(/Id$/, '')}ExternalId`] as string;\n const rightExt = write[`${cfg.right.column.replace(/Id$/, '')}ExternalId`] as string;\n const role = cfg.roleColumn ? (r[cfg.roleColumn] as string | undefined) : undefined;\n const out: Record<string, unknown> = {\n id: buildCompositeExternalId(leftExt, rightExt, role),\n [cfg.left.column]: r[cfg.left.column],\n [cfg.right.column]: r[cfg.right.column],\n };\n if (cfg.roleColumn) out[cfg.roleColumn] = r[cfg.roleColumn];\n out['createdAt'] = r['createdAt'];\n out['updatedAt'] = r['updatedAt'];\n return out as TSyncProjection;\n }\n\n /** Build the identity WHERE clause `(left, right[, role])`. */\n private identityWhere(leftId: string, rightId: string, role: string | undefined) {\n const cfg = this.syncConfig;\n const conds = [\n eq(this.table[cfg.left.column], leftId),\n eq(this.table[cfg.right.column], rightId),\n ];\n if (cfg.roleColumn && role !== undefined) {\n conds.push(eq(this.table[cfg.roleColumn], role));\n }\n return and(...conds);\n }\n\n /** Resolve a parent id (provider-scoped), throwing when unresolved. */\n private async resolveStrict(\n db: DrizzleTx,\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n refTable: PgTableWithColumns<any>,\n parentExternalId: string,\n provider: string,\n column: string,\n ): Promise<string> {\n const id = await this.resolveLoose(db, refTable, parentExternalId, provider);\n if (!id) {\n throw new Error(\n `${this.constructor.name}.syncUpsertOne: unresolved parent ` +\n `'${parentExternalId}' (provider '${provider}') for '${column}' — ` +\n `parent not synced yet`,\n );\n }\n return id;\n }\n\n /** Resolve a parent id (provider-scoped), returning null when unresolved. */\n private async resolveLoose(\n db: DrizzleTx,\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n refTable: PgTableWithColumns<any>,\n parentExternalId: string | null | undefined,\n provider: string,\n ): Promise<string | null> {\n if (!parentExternalId) return null;\n const rows = await db\n .select({ id: refTable['id'] })\n .from(refTable)\n .where(\n and(\n eq(refTable['provider'], provider),\n eq(refTable['externalId'], parentExternalId),\n ),\n )\n .limit(1);\n return (rows[0]?.id as string | undefined) ?? null;\n }\n}\n\n// ============================================================================\n// Composite externalId — the junction sync seam's deterministic identity.\n//\n// Format: `<leftExternalId>::<rightExternalId>[::<role>]`\n// e.g. `hubspot:42::hubspot:99::employee` (role-bearing)\n// `hubspot:42::hubspot:99` (role-less)\n//\n// Vendor-prefixed ids use a SINGLE colon, so `::` is an unambiguous delimiter.\n// Kept static in the base (replacing the per-repo free functions) so every\n// junction's lookups + its ChangeSource share one definition.\n// ============================================================================\n\n/**\n * Build the composite externalId from the two parent external ids (+ role when\n * the junction is role-bearing).\n */\nexport function buildCompositeExternalId(\n leftExternalId: string,\n rightExternalId: string,\n role?: string,\n): string {\n return role !== undefined\n ? `${leftExternalId}::${rightExternalId}::${role}`\n : `${leftExternalId}::${rightExternalId}`;\n}\n\n/**\n * Parse a composite externalId. `withRole` selects the expected part count\n * (3 when role-bearing, else 2). Returns `null` when the shape doesn't match\n * or any part is empty.\n */\nexport function parseCompositeExternalId(\n externalId: string,\n withRole: boolean,\n): { left: string; right: string; role: string | undefined } | null {\n const parts = externalId.split('::');\n const expected = withRole ? 3 : 2;\n if (parts.length !== expected || parts.some((p) => p.length === 0)) return null;\n return {\n left: parts[0] as string,\n right: parts[1] as string,\n role: withRole ? (parts[2] as string) : undefined,\n };\n}\n","/**\n * BaseRepository<TEntity>\n *\n * Abstract base class providing standard CRUD operations via Drizzle ORM.\n * Every generated repository extends this class.\n *\n * Family-specific bases (CrmEntityRepository, etc.) extend this in v0.1\n * without any changes to BaseRepository.\n *\n * NOT @Injectable — concrete repositories are @Injectable and inject DRIZZLE.\n */\nimport { eq, inArray, isNull, sql } from 'drizzle-orm';\nimport type { PgTableWithColumns, PgColumn } from 'drizzle-orm/pg-core';\nimport type { SQL } from 'drizzle-orm';\nimport type { DrizzleClient, DrizzleTx } from '../types/drizzle';\n\n// ============================================================================\n// Interfaces\n// ============================================================================\n\n/**\n * Behavior flags for the repository. Controls automatic timestamp injection\n * and soft-delete filtering.\n */\nexport interface BehaviorConfig {\n timestamps: boolean;\n softDelete: boolean;\n userTracking: boolean;\n}\n\n/**\n * Options for the list() method.\n */\nexport interface ListOptions {\n where?: SQL;\n limit?: number;\n offset?: number;\n orderBy?: PgColumn | SQL;\n}\n\n// ============================================================================\n// BaseRepository\n// ============================================================================\n\nexport abstract class BaseRepository<TEntity> {\n /**\n * The Drizzle table schema for this entity.\n * Concrete repositories declare this as a class property.\n */\n protected abstract readonly table: PgTableWithColumns<any>; // eslint-disable-line @typescript-eslint/no-explicit-any\n\n /**\n * Behavior flags controlling automatic behavior injection.\n * Override in concrete repositories to enable behaviors.\n */\n protected readonly behaviors: BehaviorConfig = {\n timestamps: false,\n softDelete: false,\n userTracking: false,\n };\n\n protected readonly db: DrizzleClient;\n\n constructor(db: DrizzleClient) {\n this.db = db;\n }\n\n /**\n * Pick the runner for a write: the caller-supplied transaction handle\n * if present, otherwise the repository's own client. Keeps the `tx`\n * parameter purely additive — callers without a transaction call as\n * before. Used by the write methods below + consumer overrides (e.g.\n * the generated `upsertCurrentValues` on EAV value tables).\n */\n protected runner(tx?: DrizzleTx): DrizzleClient {\n return tx ?? this.db;\n }\n\n // ============================================================================\n // Read Operations\n // ============================================================================\n\n /**\n * Find a single entity by its primary key.\n * Returns null if not found (or soft-deleted when softDelete=true).\n */\n async findById(id: string): Promise<TEntity | null> {\n const rows = await this.baseQuery()\n .where(eq(this.table['id'], id))\n .limit(1);\n return (rows[0] as TEntity) ?? null;\n }\n\n /**\n * Find multiple entities by their primary keys.\n * Returns empty array immediately for empty input (avoids DB errors).\n */\n async findByIds(ids: string[]): Promise<TEntity[]> {\n if (ids.length === 0) return [];\n const rows = await this.baseQuery().where(inArray(this.table['id'], ids));\n return rows as TEntity[];\n }\n\n /**\n * List entities with optional filtering, pagination, and ordering.\n */\n async list(options?: ListOptions): Promise<TEntity[]> {\n let query = this.baseQuery();\n\n if (options?.where) {\n query = query.where(options.where) as typeof query;\n }\n if (options?.orderBy) {\n query = query.orderBy(options.orderBy as SQL) as typeof query;\n }\n if (options?.limit !== undefined) {\n query = query.limit(options.limit) as typeof query;\n }\n if (options?.offset !== undefined) {\n query = query.offset(options.offset) as typeof query;\n }\n\n const rows = await query;\n return rows as TEntity[];\n }\n\n /**\n * Count entities matching an optional WHERE clause.\n * Soft-deleted rows are always excluded when softDelete=true.\n */\n async count(where?: SQL): Promise<number> {\n let query = this.db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(this.table);\n\n const conditions: SQL[] = [];\n if (this.behaviors.softDelete) {\n conditions.push(isNull(this.table['deletedAt']));\n }\n if (where) {\n conditions.push(where);\n }\n\n if (conditions.length === 1) {\n query = query.where(conditions[0]) as typeof query;\n } else if (conditions.length > 1) {\n // Combine with AND by building the condition inline\n const { and } = await import('drizzle-orm');\n query = query.where(and(...conditions)) as typeof query;\n }\n\n const rows = await query;\n return rows[0]?.count ?? 0;\n }\n\n /**\n * Check whether an entity with the given id exists.\n */\n async exists(id: string): Promise<boolean> {\n const result = await this.findById(id);\n return result !== null;\n }\n\n // ============================================================================\n // Write Operations\n // ============================================================================\n\n /**\n * Insert a new entity. Timestamps are auto-injected when timestamps=true.\n */\n async create(input: Partial<TEntity>, tx?: DrizzleTx): Promise<TEntity> {\n const data = this.withTimestamps(input as Record<string, unknown>, 'create');\n const rows = await this.runner(tx)\n .insert(this.table)\n .values(data as any) // eslint-disable-line @typescript-eslint/no-explicit-any\n .returning();\n return rows[0] as TEntity;\n }\n\n /**\n * Update an existing entity by id. updatedAt is auto-injected when timestamps=true.\n * Returns the updated entity.\n */\n async update(id: string, input: Partial<TEntity>, tx?: DrizzleTx): Promise<TEntity> {\n const data = this.withTimestamps(input as Record<string, unknown>, 'update');\n const rows = await this.runner(tx)\n .update(this.table)\n .set(data as any) // eslint-disable-line @typescript-eslint/no-explicit-any\n .where(eq(this.table['id'], id))\n .returning();\n return rows[0] as TEntity;\n }\n\n /**\n * Delete an entity by id.\n * - softDelete=true: sets deletedAt to current timestamp\n * - softDelete=false: hard-deletes the row\n */\n async delete(id: string, tx?: DrizzleTx): Promise<void> {\n const runner = this.runner(tx);\n if (this.behaviors.softDelete) {\n await runner\n .update(this.table)\n .set({ deletedAt: new Date() } as any) // eslint-disable-line @typescript-eslint/no-explicit-any\n .where(eq(this.table['id'], id));\n } else {\n await runner\n .delete(this.table)\n .where(eq(this.table['id'], id));\n }\n }\n\n /**\n * Insert or update multiple entities.\n * Default naive implementation — family repositories override with\n * proper conflict-target upsert (e.g., CrmEntityRepository).\n */\n async upsertMany(inputs: Array<Partial<TEntity>>, tx?: DrizzleTx): Promise<TEntity[]> {\n return Promise.all(inputs.map((input) => this.create(input, tx)));\n }\n\n // ============================================================================\n // Protected Helpers\n // ============================================================================\n\n /**\n * Base SELECT query that automatically excludes soft-deleted rows\n * when softDelete behavior is enabled.\n */\n protected baseQuery() {\n const query = this.db.select().from(this.table).$dynamic();\n if (this.behaviors.softDelete) {\n return query.where(isNull(this.table['deletedAt']));\n }\n return query;\n }\n\n /**\n * Merge timestamp fields into an input object.\n * - mode='create': adds createdAt and updatedAt\n * - mode='update': adds updatedAt only\n *\n * No-op when timestamps behavior is disabled.\n */\n protected withTimestamps(\n input: Record<string, unknown>,\n mode: 'create' | 'update',\n ): Record<string, unknown> {\n if (!this.behaviors.timestamps) return input;\n const now = new Date();\n if (mode === 'create') {\n return { ...input, createdAt: now, updatedAt: now };\n }\n return { ...input, updatedAt: now };\n }\n\n /**\n * Build a WHERE clause fragment that restricts results to rows whose\n * parent (identified by a belongs_to FK) is not soft-deleted.\n *\n * Use this in custom repository methods when you need \"rows reachable\n * from an active parent\". The default findAll / findById behavior is\n * NOT changed by this helper — opt in explicitly where needed.\n *\n * ADR-021 — Soft-delete cascade: Option A (filter at query time).\n * `on_delete` FK rules do not fire for soft-deletes; use this helper\n * instead of expecting cascade semantics on the DB level.\n *\n * Example:\n * async listActiveMessages(): Promise<Message[]> {\n * return this.list({\n * where: this.activeParentFilter(conversations, this.table['conversationId']),\n * });\n * }\n *\n * @param parentTable The Drizzle table object for the parent entity.\n * @param parentFkColumn The FK column on this (child) table that references parent.id.\n */\n protected activeParentFilter(\n parentTable: PgTableWithColumns<any>, // eslint-disable-line @typescript-eslint/no-explicit-any\n parentFkColumn: PgColumn,\n ): SQL {\n return sql`EXISTS (\n SELECT 1 FROM ${parentTable} p\n WHERE p.id = ${parentFkColumn}\n AND p.deleted_at IS NULL\n )`;\n }\n}\n"],"mappings":"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","names":["eq","and","eq"]}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { PgTableWithColumns } from 'drizzle-orm/pg-core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SyncUpsertConfig + SyncFkResolver
|
|
5
|
+
*
|
|
6
|
+
* Declarative description of an entity's inbound-sync write surface, consumed
|
|
7
|
+
* by `SyncedEntityRepository.syncUpsertOne` / `findByExternalIdProjected` /
|
|
8
|
+
* `softDeleteByExternalId` / `toProjection`. Each `pattern: Synced` repository
|
|
9
|
+
* declares a concrete `syncConfig: SyncUpsertConfig` (emitted by the template),
|
|
10
|
+
* the same idiom as `behaviors: BehaviorConfig`.
|
|
11
|
+
*
|
|
12
|
+
* Named `SyncUpsertConfig` (not `SyncConfig`) to avoid colliding with the sync
|
|
13
|
+
* subsystem's `DetectionConfig`/`SyncConfig` surface.
|
|
14
|
+
*
|
|
15
|
+
* The generic upsert separates three column roles:
|
|
16
|
+
* - identity (`conflictTarget`) — only in `values`, never in `set`
|
|
17
|
+
* - copy-through (`writeColumns`) — in both `values` and `set`
|
|
18
|
+
* - resolved FK (`fkResolvers`) — conditional in `set` (no-clobber)
|
|
19
|
+
*/
|
|
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
|
+
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
|
+
refTable: PgTableWithColumns<any> | 'self';
|
|
37
|
+
/** true = throw on unresolved (junction); falsy = opportunistic null (entity). */
|
|
38
|
+
strict?: boolean;
|
|
39
|
+
}
|
|
40
|
+
interface SyncUpsertConfig {
|
|
41
|
+
/** Camel keys into `this.table` forming the conflict target, e.g. `['provider', 'externalId']`. */
|
|
42
|
+
conflictTarget: string[];
|
|
43
|
+
/**
|
|
44
|
+
* Canonical columns copied verbatim write→values/set (camel). EXCLUDES
|
|
45
|
+
* `externalId`, `provider`, FK columns, and behavior-managed timestamps.
|
|
46
|
+
*/
|
|
47
|
+
writeColumns: string[];
|
|
48
|
+
/** Conditional, provider-scoped FK resolvers. */
|
|
49
|
+
fkResolvers: SyncFkResolver[];
|
|
50
|
+
/** Columns picked into the projection (camel), incl. id/externalId/timestamps. */
|
|
51
|
+
projectionColumns: string[];
|
|
52
|
+
/** When true, `syncUpsertOne` calls `writeCustomFields` for a non-empty `fields` bag. */
|
|
53
|
+
eav: boolean;
|
|
54
|
+
/** When true, deletes set `deletedAt`; when false, tombstone-by-clearing external_id/provider. */
|
|
55
|
+
softDelete: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type { SyncFkResolver, SyncUpsertConfig };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
//# sourceMappingURL=sync-upsert-config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
+
import { DrizzleTx } from '../types/drizzle.js';
|
|
1
2
|
import { BaseRepository } from './base-repository.js';
|
|
3
|
+
import { SyncUpsertConfig } from './sync-upsert-config.js';
|
|
4
|
+
import 'drizzle-orm/node-postgres';
|
|
2
5
|
import 'drizzle-orm/pg-core';
|
|
3
6
|
import 'drizzle-orm';
|
|
4
|
-
import '../types/drizzle.js';
|
|
5
|
-
import 'drizzle-orm/node-postgres';
|
|
6
7
|
|
|
7
|
-
declare abstract class SyncedEntityRepository<TEntity> extends BaseRepository<TEntity> {
|
|
8
|
+
declare abstract class SyncedEntityRepository<TEntity, TSyncWrite = Partial<TEntity>, TSyncProjection = TEntity> extends BaseRepository<TEntity> {
|
|
9
|
+
/**
|
|
10
|
+
* Declarative sync write surface. Concrete (`pattern: Synced`) repositories
|
|
11
|
+
* declare this — the template emits it from the entity's fields + FKs.
|
|
12
|
+
*/
|
|
13
|
+
protected abstract readonly syncConfig: SyncUpsertConfig;
|
|
8
14
|
/**
|
|
9
15
|
* Find a single entity by its external CRM identifier.
|
|
10
16
|
*/
|
|
@@ -18,10 +24,66 @@ declare abstract class SyncedEntityRepository<TEntity> extends BaseRepository<TE
|
|
|
18
24
|
*/
|
|
19
25
|
findAllByUserId(userId: string): Promise<TEntity[]>;
|
|
20
26
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
27
|
+
* Upsert ONE entity by its `(provider, externalId)` identity, in a single
|
|
28
|
+
* transaction:
|
|
29
|
+
* 1. resolve each `syncConfig.fkResolvers` FK (provider-scoped). Strict
|
|
30
|
+
* resolvers throw on unresolved; non-strict leave the column null.
|
|
31
|
+
* 2. insert-or-update the canonical columns via `onConflictDoUpdate` on the
|
|
32
|
+
* `conflictTarget`. Resolved FKs are only written into `set` when
|
|
33
|
+
* non-null this run (no-clobber).
|
|
34
|
+
* 3. EAV dual-write of `write.fields` via `writeCustomFields` when
|
|
35
|
+
* `syncConfig.eav` and the bag is non-empty (same tx).
|
|
36
|
+
*
|
|
37
|
+
* Idempotent: a second call with the same identity updates in place. Returns
|
|
38
|
+
* the canonical projection (so the orchestrator records `local_id`).
|
|
39
|
+
*
|
|
40
|
+
* @param write canonical fields + parent external ids + custom-field bag
|
|
41
|
+
* @param provider adapter/provider label persisted + used to scope lookups
|
|
42
|
+
* @param tx optional outer transaction; when omitted we open our own
|
|
43
|
+
*/
|
|
44
|
+
syncUpsertOne(write: TSyncWrite, provider: string, tx?: DrizzleTx): Promise<TSyncProjection>;
|
|
45
|
+
/**
|
|
46
|
+
* Canonical-projected lookup by external id (differ-ready). Returns `null`
|
|
47
|
+
* when no local row exists. Provider-scoped so a HubSpot id can't match a
|
|
48
|
+
* Salesforce row.
|
|
49
|
+
*/
|
|
50
|
+
findByExternalIdProjected(externalId: string, provider: string): Promise<TSyncProjection | null>;
|
|
51
|
+
/**
|
|
52
|
+
* Sync "delete" by external id, provider-scoped. When `softDelete: true`,
|
|
53
|
+
* sets `deletedAt`. When `softDelete: false`, tombstone-by-clearing: null out
|
|
54
|
+
* `external_id`/`provider` so the row no longer matches future inbound
|
|
55
|
+
* changes while preserving local-id references. Returns `{ id }` or `null`.
|
|
56
|
+
*/
|
|
57
|
+
softDeleteByExternalId(externalId: string, provider: string, tx?: DrizzleTx): Promise<{
|
|
58
|
+
id: string;
|
|
59
|
+
} | null>;
|
|
60
|
+
/**
|
|
61
|
+
* Batch sync upsert — concretizes the former abstract stub. Delegates to
|
|
62
|
+
* `syncUpsertOne` per input inside one transaction. Inputs are raw partial
|
|
63
|
+
* rows: provider is read from each input's own `provider` column; rows
|
|
64
|
+
* missing `externalId`/`provider` are skipped.
|
|
65
|
+
*/
|
|
66
|
+
syncUpsert(inputs: Array<Partial<TEntity>>): Promise<TEntity[]>;
|
|
67
|
+
/**
|
|
68
|
+
* Project a raw row to the canonical differ shape — a generic pick over
|
|
69
|
+
* `syncConfig.projectionColumns`. Override only for synthesized projections
|
|
70
|
+
* (e.g. junctions); entities use this verbatim.
|
|
71
|
+
*/
|
|
72
|
+
protected toProjection(row: TEntity): TSyncProjection;
|
|
73
|
+
/**
|
|
74
|
+
* EAV dual-write seam (#374, live path lands in #124). No-op by default;
|
|
75
|
+
* `eav: true` entities emit a concrete override that injects
|
|
76
|
+
* `FieldValueService` and delegates to `upsertFieldsTransactional` so the
|
|
77
|
+
* dual-write joins the same tx (`db`). Kept as an explicit hook so the base
|
|
78
|
+
* stays portable (the FieldValueService dependency is eav-only).
|
|
79
|
+
*/
|
|
80
|
+
protected writeCustomFields(_db: DrizzleTx, _entityId: string, _userId: string, _fields: Record<string, unknown>): Promise<void>;
|
|
81
|
+
/**
|
|
82
|
+
* Resolve one FK from a parent external id (provider-scoped). `self` resolves
|
|
83
|
+
* against `this.table`. Strict resolvers throw when unresolved; non-strict
|
|
84
|
+
* return null. A null/absent write value short-circuits to null.
|
|
23
85
|
*/
|
|
24
|
-
|
|
86
|
+
private resolveFk;
|
|
25
87
|
/**
|
|
26
88
|
* Find entities visible to a user (ownership + sharing rules).
|
|
27
89
|
* Concrete repositories must implement with visibility logic.
|