@vibeorm/runtime 1.3.0 → 2.0.0-alpha.2
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/README.md +50 -107
- package/dist/adapter.d.ts +124 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/client.d.ts +152 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/codecs.d.ts +170 -0
- package/dist/codecs.d.ts.map +1 -0
- package/dist/computed.d.ts +43 -0
- package/dist/computed.d.ts.map +1 -0
- package/dist/extensions.d.ts +102 -0
- package/dist/extensions.d.ts.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6625 -0
- package/dist/index.js.map +21 -0
- package/dist/model-meta.d.ts +156 -0
- package/dist/model-meta.d.ts.map +1 -0
- package/dist/nested-writes.d.ts +100 -0
- package/dist/nested-writes.d.ts.map +1 -0
- package/dist/query-builder.d.ts +250 -0
- package/dist/query-builder.d.ts.map +1 -0
- package/dist/relation-loader.d.ts +75 -0
- package/dist/relation-loader.d.ts.map +1 -0
- package/dist/relation-plan.d.ts +103 -0
- package/dist/relation-plan.d.ts.map +1 -0
- package/dist/render-cache.d.ts +48 -0
- package/dist/render-cache.d.ts.map +1 -0
- package/dist/views.d.ts +97 -0
- package/dist/views.d.ts.map +1 -0
- package/package.json +31 -26
- package/src/adapter.ts +0 -146
- package/src/client.ts +0 -2172
- package/src/coerce.ts +0 -184
- package/src/count-loader.ts +0 -152
- package/src/errors.ts +0 -492
- package/src/id-generators.ts +0 -151
- package/src/index.ts +0 -55
- package/src/lateral-join-builder.ts +0 -1053
- package/src/query-builder.ts +0 -1832
- package/src/relation-loader.ts +0 -534
- package/src/retry.ts +0 -183
- package/src/types.ts +0 -317
- package/src/view.ts +0 -629
- package/src/where-builder.ts +0 -772
package/src/relation-loader.ts
DELETED
|
@@ -1,534 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Relation Loader
|
|
3
|
-
*
|
|
4
|
-
* Implements the hybrid loading strategy:
|
|
5
|
-
* - To-one relations: LEFT JOIN in the main query (future optimization)
|
|
6
|
-
* - To-many relations: Separate batched WHERE IN queries
|
|
7
|
-
*
|
|
8
|
-
* Currently uses batched queries for all relation types for simplicity.
|
|
9
|
-
* The JOIN strategy for to-one can be added later as an optimization.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import type {
|
|
13
|
-
ModelMeta,
|
|
14
|
-
ModelMetaMap,
|
|
15
|
-
RelationFieldMeta,
|
|
16
|
-
ProfilingContext,
|
|
17
|
-
} from "./types.ts";
|
|
18
|
-
import { getModelByNameMap, getScalarFieldMap, PgArray } from "./types.ts";
|
|
19
|
-
import { buildRelationQuery, buildManyToManyQuery, buildSelectQuery } from "./query-builder.ts";
|
|
20
|
-
import type { RelationSqlQuery } from "./query-builder.ts";
|
|
21
|
-
import { buildWhereClause } from "./where-builder.ts";
|
|
22
|
-
import { coerceFieldTypes } from "./coerce.ts";
|
|
23
|
-
|
|
24
|
-
type SqlExecutor = (params: {
|
|
25
|
-
text: string;
|
|
26
|
-
values: unknown[];
|
|
27
|
-
}) => Promise<Record<string, unknown>[]>;
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Load relations for a set of parent records.
|
|
31
|
-
*
|
|
32
|
-
* Examines the select/include args to determine which relations to load,
|
|
33
|
-
* executes batched queries in PARALLEL, and stitches the results onto
|
|
34
|
-
* parent records.
|
|
35
|
-
*
|
|
36
|
-
* Sibling relations are independent — they query different tables and write
|
|
37
|
-
* to different keys on the parent records — so they can safely run concurrently.
|
|
38
|
-
*/
|
|
39
|
-
export async function loadRelations(params: {
|
|
40
|
-
parentRecords: Record<string, unknown>[];
|
|
41
|
-
parentModelMeta: ModelMeta;
|
|
42
|
-
allModelsMeta: ModelMetaMap;
|
|
43
|
-
args: Record<string, unknown>;
|
|
44
|
-
executor: SqlExecutor;
|
|
45
|
-
profilingCtx?: ProfilingContext;
|
|
46
|
-
defaultOrderByPk?: boolean;
|
|
47
|
-
}): Promise<Record<string, unknown>[]> {
|
|
48
|
-
const { parentRecords, parentModelMeta, allModelsMeta, args, executor, profilingCtx, defaultOrderByPk = false } =
|
|
49
|
-
params;
|
|
50
|
-
|
|
51
|
-
if (parentRecords.length === 0) return parentRecords;
|
|
52
|
-
|
|
53
|
-
const relationsToLoad = resolveRelationsToLoad({
|
|
54
|
-
parentModelMeta,
|
|
55
|
-
args,
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
if (relationsToLoad.length === 0) return parentRecords;
|
|
59
|
-
|
|
60
|
-
const modelMap = getModelByNameMap({ allModelsMeta });
|
|
61
|
-
const pkField = parentModelMeta.primaryKey[0]!;
|
|
62
|
-
const parentIds = [
|
|
63
|
-
...new Set(
|
|
64
|
-
parentRecords
|
|
65
|
-
.map((r) => r[pkField])
|
|
66
|
-
.filter((id) => id !== undefined && id !== null)
|
|
67
|
-
),
|
|
68
|
-
];
|
|
69
|
-
|
|
70
|
-
if (parentIds.length === 0) return parentRecords;
|
|
71
|
-
|
|
72
|
-
// Phase 1: Load all sibling relations in parallel.
|
|
73
|
-
// Each relation query hits a different table and writes to a unique key
|
|
74
|
-
// on the parent records, so there are no data races.
|
|
75
|
-
await Promise.all(
|
|
76
|
-
relationsToLoad.map(async ({ relationMeta, nestedArgs }) => {
|
|
77
|
-
const relatedModelMeta = modelMap.get(relationMeta.relatedModel);
|
|
78
|
-
if (!relatedModelMeta) return;
|
|
79
|
-
|
|
80
|
-
if (relationMeta.isList) {
|
|
81
|
-
await loadToManyRelation({
|
|
82
|
-
parentRecords,
|
|
83
|
-
parentModelMeta,
|
|
84
|
-
relationMeta,
|
|
85
|
-
relatedModelMeta,
|
|
86
|
-
parentIds,
|
|
87
|
-
nestedArgs,
|
|
88
|
-
allModelsMeta,
|
|
89
|
-
executor,
|
|
90
|
-
pkField,
|
|
91
|
-
profilingCtx,
|
|
92
|
-
defaultOrderByPk,
|
|
93
|
-
});
|
|
94
|
-
} else if (relationMeta.isForeignKey) {
|
|
95
|
-
await loadToOneWithFk({
|
|
96
|
-
parentRecords,
|
|
97
|
-
parentModelMeta,
|
|
98
|
-
relationMeta,
|
|
99
|
-
relatedModelMeta,
|
|
100
|
-
nestedArgs,
|
|
101
|
-
allModelsMeta,
|
|
102
|
-
executor,
|
|
103
|
-
profilingCtx,
|
|
104
|
-
});
|
|
105
|
-
} else {
|
|
106
|
-
await loadToOneWithoutFk({
|
|
107
|
-
parentRecords,
|
|
108
|
-
parentModelMeta,
|
|
109
|
-
relationMeta,
|
|
110
|
-
relatedModelMeta,
|
|
111
|
-
parentIds,
|
|
112
|
-
nestedArgs,
|
|
113
|
-
allModelsMeta,
|
|
114
|
-
executor,
|
|
115
|
-
pkField,
|
|
116
|
-
profilingCtx,
|
|
117
|
-
defaultOrderByPk,
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
})
|
|
121
|
-
);
|
|
122
|
-
|
|
123
|
-
// Phase 2: Recursively load nested relations in parallel.
|
|
124
|
-
// Each nested load operates on a different set of child records.
|
|
125
|
-
await Promise.all(
|
|
126
|
-
relationsToLoad
|
|
127
|
-
.filter(({ nestedArgs }) => hasNestedRelations({ nestedArgs }))
|
|
128
|
-
.map(async ({ relationMeta, nestedArgs }) => {
|
|
129
|
-
const relatedModelMeta = modelMap.get(relationMeta.relatedModel);
|
|
130
|
-
if (!relatedModelMeta) return;
|
|
131
|
-
|
|
132
|
-
const loadedRecords = parentRecords
|
|
133
|
-
.flatMap((r) => {
|
|
134
|
-
const val = r[relationMeta.name];
|
|
135
|
-
if (Array.isArray(val)) return val;
|
|
136
|
-
if (val && typeof val === "object") return [val];
|
|
137
|
-
return [];
|
|
138
|
-
})
|
|
139
|
-
.filter((r): r is Record<string, unknown> => r !== null);
|
|
140
|
-
|
|
141
|
-
if (loadedRecords.length > 0) {
|
|
142
|
-
await loadRelations({
|
|
143
|
-
parentRecords: loadedRecords,
|
|
144
|
-
parentModelMeta: relatedModelMeta,
|
|
145
|
-
allModelsMeta,
|
|
146
|
-
args: nestedArgs,
|
|
147
|
-
executor,
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
// Strip auto-included PKs from child records if user's select didn't include them
|
|
151
|
-
const nestedSelect = nestedArgs.select as Record<string, boolean | object> | undefined;
|
|
152
|
-
if (nestedSelect) {
|
|
153
|
-
for (const pkName of relatedModelMeta.primaryKey) {
|
|
154
|
-
if (nestedSelect[pkName] === undefined) {
|
|
155
|
-
for (const rec of loadedRecords) {
|
|
156
|
-
delete rec[pkName];
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
})
|
|
163
|
-
);
|
|
164
|
-
|
|
165
|
-
return parentRecords;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// ─── To-Many Loader ───────────────────────────────────────────────
|
|
169
|
-
|
|
170
|
-
async function loadToManyRelation(params: {
|
|
171
|
-
parentRecords: Record<string, unknown>[];
|
|
172
|
-
parentModelMeta: ModelMeta;
|
|
173
|
-
relationMeta: RelationFieldMeta;
|
|
174
|
-
relatedModelMeta: ModelMeta;
|
|
175
|
-
parentIds: unknown[];
|
|
176
|
-
nestedArgs: Record<string, unknown>;
|
|
177
|
-
allModelsMeta: ModelMetaMap;
|
|
178
|
-
executor: SqlExecutor;
|
|
179
|
-
pkField: string;
|
|
180
|
-
profilingCtx?: ProfilingContext;
|
|
181
|
-
defaultOrderByPk?: boolean;
|
|
182
|
-
}): Promise<void> {
|
|
183
|
-
const {
|
|
184
|
-
parentRecords,
|
|
185
|
-
parentModelMeta,
|
|
186
|
-
relationMeta,
|
|
187
|
-
relatedModelMeta,
|
|
188
|
-
parentIds,
|
|
189
|
-
nestedArgs,
|
|
190
|
-
allModelsMeta,
|
|
191
|
-
executor,
|
|
192
|
-
pkField,
|
|
193
|
-
profilingCtx,
|
|
194
|
-
defaultOrderByPk = false,
|
|
195
|
-
} = params;
|
|
196
|
-
|
|
197
|
-
// Choose builder based on relation type
|
|
198
|
-
const isM2M = relationMeta.type === "manyToMany" && (relationMeta as { joinTable?: string }).joinTable;
|
|
199
|
-
|
|
200
|
-
const query = isM2M
|
|
201
|
-
? buildManyToManyQuery({
|
|
202
|
-
parentModelMeta,
|
|
203
|
-
relationMeta,
|
|
204
|
-
relatedModelMeta,
|
|
205
|
-
parentIds,
|
|
206
|
-
args: nestedArgs,
|
|
207
|
-
allModelsMeta,
|
|
208
|
-
defaultOrderByPk,
|
|
209
|
-
})
|
|
210
|
-
: buildRelationQuery({
|
|
211
|
-
parentModelMeta,
|
|
212
|
-
relationMeta,
|
|
213
|
-
relatedModelMeta,
|
|
214
|
-
parentIds,
|
|
215
|
-
args: nestedArgs,
|
|
216
|
-
allModelsMeta,
|
|
217
|
-
defaultOrderByPk,
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
const t0 = profilingCtx ? performance.now() : 0;
|
|
221
|
-
const rows = await executor(query);
|
|
222
|
-
if (profilingCtx) {
|
|
223
|
-
profilingCtx.relationProfiles.push({
|
|
224
|
-
relation: relationMeta.name,
|
|
225
|
-
sqlExecMs: performance.now() - t0,
|
|
226
|
-
rowCount: rows.length,
|
|
227
|
-
sql: query.text,
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Group by FK — use the already-selected field name when FK was deduped,
|
|
232
|
-
// otherwise use the __vibeorm_fk alias
|
|
233
|
-
const fkKey = query.fkFieldName ?? "__vibeorm_fk";
|
|
234
|
-
const grouped = new Map<unknown, Record<string, unknown>[]>();
|
|
235
|
-
for (const row of rows) {
|
|
236
|
-
const fk = row[fkKey];
|
|
237
|
-
if (!query.fkFieldName) delete row.__vibeorm_fk;
|
|
238
|
-
// Also clean up __vibeorm_rn from ROW_NUMBER windowed queries
|
|
239
|
-
if ("__vibeorm_rn" in row) delete row.__vibeorm_rn;
|
|
240
|
-
if (!grouped.has(fk)) {
|
|
241
|
-
grouped.set(fk, []);
|
|
242
|
-
}
|
|
243
|
-
grouped.get(fk)!.push(row);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// Coerce scalar types on the loaded child records (e.g., BigInt from string).
|
|
247
|
-
// Mirrors what `loadRelationsForStrategy` does on parent records.
|
|
248
|
-
coerceFieldTypes({ records: rows, modelMeta: relatedModelMeta });
|
|
249
|
-
|
|
250
|
-
// Stitch onto parent records
|
|
251
|
-
for (const parent of parentRecords) {
|
|
252
|
-
const parentId = parent[pkField];
|
|
253
|
-
parent[relationMeta.name] = grouped.get(parentId) ?? [];
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// ─── To-One with FK (this side has foreignKey) ────────────────────
|
|
258
|
-
|
|
259
|
-
async function loadToOneWithFk(params: {
|
|
260
|
-
parentRecords: Record<string, unknown>[];
|
|
261
|
-
parentModelMeta: ModelMeta;
|
|
262
|
-
relationMeta: RelationFieldMeta;
|
|
263
|
-
relatedModelMeta: ModelMeta;
|
|
264
|
-
nestedArgs: Record<string, unknown>;
|
|
265
|
-
allModelsMeta: ModelMetaMap;
|
|
266
|
-
executor: SqlExecutor;
|
|
267
|
-
profilingCtx?: ProfilingContext;
|
|
268
|
-
}): Promise<void> {
|
|
269
|
-
const {
|
|
270
|
-
parentRecords,
|
|
271
|
-
parentModelMeta,
|
|
272
|
-
relationMeta,
|
|
273
|
-
relatedModelMeta,
|
|
274
|
-
nestedArgs,
|
|
275
|
-
allModelsMeta,
|
|
276
|
-
executor,
|
|
277
|
-
profilingCtx,
|
|
278
|
-
} = params;
|
|
279
|
-
|
|
280
|
-
// Get FK values from parent records (deduplicated for smaller IN clauses)
|
|
281
|
-
const fkFieldName = relationMeta.fields[0]!;
|
|
282
|
-
const fkValues = [
|
|
283
|
-
...new Set(
|
|
284
|
-
parentRecords
|
|
285
|
-
.map((r) => r[fkFieldName])
|
|
286
|
-
.filter((v) => v !== undefined && v !== null)
|
|
287
|
-
),
|
|
288
|
-
];
|
|
289
|
-
|
|
290
|
-
if (fkValues.length === 0) {
|
|
291
|
-
for (const parent of parentRecords) {
|
|
292
|
-
parent[relationMeta.name] = null;
|
|
293
|
-
}
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// Query related model by its PK (which is what our FK references)
|
|
298
|
-
const refField = relationMeta.references[0]!;
|
|
299
|
-
const sfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
|
|
300
|
-
const refScalarField = sfMap.get(refField);
|
|
301
|
-
const refDbName = refScalarField?.dbName ?? refField;
|
|
302
|
-
|
|
303
|
-
const columns = resolveSelectColumnsForRelation({
|
|
304
|
-
modelMeta: relatedModelMeta,
|
|
305
|
-
args: nestedArgs,
|
|
306
|
-
});
|
|
307
|
-
const table = `"${relatedModelMeta.dbName}"`;
|
|
308
|
-
const columnsSql = columns
|
|
309
|
-
.map((c) => `${table}."${c.dbName}" AS "${c.name}"`)
|
|
310
|
-
.join(", ");
|
|
311
|
-
|
|
312
|
-
// Build the base WHERE: pk = ANY($1) for batched loading
|
|
313
|
-
const allValues: unknown[] = [new PgArray(fkValues)];
|
|
314
|
-
let paramIdx = 1;
|
|
315
|
-
let whereSql = `${table}."${refDbName}" = ANY($${paramIdx})`;
|
|
316
|
-
|
|
317
|
-
// Apply nested where filters (e.g., include: { author: { where: { isActive: true } } })
|
|
318
|
-
if (nestedArgs.where) {
|
|
319
|
-
const subWhere = buildWhereClause({
|
|
320
|
-
where: nestedArgs.where as Record<string, unknown>,
|
|
321
|
-
modelMeta: relatedModelMeta,
|
|
322
|
-
allModelsMeta,
|
|
323
|
-
paramOffset: paramIdx,
|
|
324
|
-
});
|
|
325
|
-
if (subWhere.sql) {
|
|
326
|
-
whereSql += ` AND (${subWhere.sql})`;
|
|
327
|
-
allValues.push(...subWhere.values);
|
|
328
|
-
paramIdx += subWhere.values.length;
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
const text = `SELECT ${columnsSql}, ${table}."${refDbName}" AS "__vibeorm_pk" FROM ${table} WHERE ${whereSql}`;
|
|
333
|
-
|
|
334
|
-
const t0 = profilingCtx ? performance.now() : 0;
|
|
335
|
-
const rows = await executor({ text, values: allValues });
|
|
336
|
-
if (profilingCtx) {
|
|
337
|
-
profilingCtx.relationProfiles.push({
|
|
338
|
-
relation: relationMeta.name,
|
|
339
|
-
sqlExecMs: performance.now() - t0,
|
|
340
|
-
rowCount: rows.length,
|
|
341
|
-
sql: text,
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// Index by PK
|
|
346
|
-
const byPk = new Map<unknown, Record<string, unknown>>();
|
|
347
|
-
for (const row of rows) {
|
|
348
|
-
const pk = row.__vibeorm_pk;
|
|
349
|
-
delete row.__vibeorm_pk;
|
|
350
|
-
byPk.set(pk, row);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// Coerce scalar types on the loaded child records before stitching.
|
|
354
|
-
coerceFieldTypes({ records: rows, modelMeta: relatedModelMeta });
|
|
355
|
-
|
|
356
|
-
// Stitch
|
|
357
|
-
for (const parent of parentRecords) {
|
|
358
|
-
const fkValue = parent[fkFieldName];
|
|
359
|
-
parent[relationMeta.name] = byPk.get(fkValue) ?? null;
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// ─── To-One without FK (other side has FK) ────────────────────────
|
|
364
|
-
|
|
365
|
-
async function loadToOneWithoutFk(params: {
|
|
366
|
-
parentRecords: Record<string, unknown>[];
|
|
367
|
-
parentModelMeta: ModelMeta;
|
|
368
|
-
relationMeta: RelationFieldMeta;
|
|
369
|
-
relatedModelMeta: ModelMeta;
|
|
370
|
-
parentIds: unknown[];
|
|
371
|
-
nestedArgs: Record<string, unknown>;
|
|
372
|
-
allModelsMeta: ModelMetaMap;
|
|
373
|
-
executor: SqlExecutor;
|
|
374
|
-
pkField: string;
|
|
375
|
-
profilingCtx?: ProfilingContext;
|
|
376
|
-
defaultOrderByPk?: boolean;
|
|
377
|
-
}): Promise<void> {
|
|
378
|
-
const {
|
|
379
|
-
parentRecords,
|
|
380
|
-
parentModelMeta,
|
|
381
|
-
relationMeta,
|
|
382
|
-
relatedModelMeta,
|
|
383
|
-
parentIds,
|
|
384
|
-
nestedArgs,
|
|
385
|
-
allModelsMeta,
|
|
386
|
-
executor,
|
|
387
|
-
pkField,
|
|
388
|
-
profilingCtx,
|
|
389
|
-
defaultOrderByPk = false,
|
|
390
|
-
} = params;
|
|
391
|
-
|
|
392
|
-
const query = buildRelationQuery({
|
|
393
|
-
parentModelMeta,
|
|
394
|
-
relationMeta,
|
|
395
|
-
relatedModelMeta,
|
|
396
|
-
parentIds,
|
|
397
|
-
args: nestedArgs,
|
|
398
|
-
allModelsMeta,
|
|
399
|
-
defaultOrderByPk,
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
const t0 = profilingCtx ? performance.now() : 0;
|
|
403
|
-
const rows = await executor(query);
|
|
404
|
-
if (profilingCtx) {
|
|
405
|
-
profilingCtx.relationProfiles.push({
|
|
406
|
-
relation: relationMeta.name,
|
|
407
|
-
sqlExecMs: performance.now() - t0,
|
|
408
|
-
rowCount: rows.length,
|
|
409
|
-
sql: query.text,
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// Index by FK (each FK should map to at most one record for to-one)
|
|
414
|
-
const fkKey = query.fkFieldName ?? "__vibeorm_fk";
|
|
415
|
-
const byFk = new Map<unknown, Record<string, unknown>>();
|
|
416
|
-
for (const row of rows) {
|
|
417
|
-
const fk = row[fkKey];
|
|
418
|
-
if (!query.fkFieldName) delete row.__vibeorm_fk;
|
|
419
|
-
byFk.set(fk, row);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
// Coerce scalar types on the loaded child records before stitching.
|
|
423
|
-
coerceFieldTypes({ records: rows, modelMeta: relatedModelMeta });
|
|
424
|
-
|
|
425
|
-
// Stitch
|
|
426
|
-
for (const parent of parentRecords) {
|
|
427
|
-
const parentId = parent[pkField];
|
|
428
|
-
parent[relationMeta.name] = byFk.get(parentId) ?? null;
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
// ─── Helpers ──────────────────────────────────────────────────────
|
|
433
|
-
|
|
434
|
-
export type RelationToLoad = {
|
|
435
|
-
relationMeta: RelationFieldMeta;
|
|
436
|
-
nestedArgs: Record<string, unknown>;
|
|
437
|
-
};
|
|
438
|
-
|
|
439
|
-
export function resolveRelationsToLoad(params: {
|
|
440
|
-
parentModelMeta: ModelMeta;
|
|
441
|
-
args: Record<string, unknown>;
|
|
442
|
-
}): RelationToLoad[] {
|
|
443
|
-
const { parentModelMeta, args } = params;
|
|
444
|
-
const result: RelationToLoad[] = [];
|
|
445
|
-
|
|
446
|
-
const select = args.select as Record<string, unknown> | undefined;
|
|
447
|
-
const include = args.include as Record<string, unknown> | undefined;
|
|
448
|
-
|
|
449
|
-
for (const relationMeta of parentModelMeta.relationFields) {
|
|
450
|
-
let shouldLoad = false;
|
|
451
|
-
let nestedArgs: Record<string, unknown> = {};
|
|
452
|
-
|
|
453
|
-
if (select) {
|
|
454
|
-
const val = select[relationMeta.name];
|
|
455
|
-
if (val === true) {
|
|
456
|
-
shouldLoad = true;
|
|
457
|
-
} else if (typeof val === "object" && val !== null) {
|
|
458
|
-
shouldLoad = true;
|
|
459
|
-
nestedArgs = val as Record<string, unknown>;
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
if (include) {
|
|
464
|
-
const val = include[relationMeta.name];
|
|
465
|
-
if (val === true) {
|
|
466
|
-
shouldLoad = true;
|
|
467
|
-
} else if (typeof val === "object" && val !== null) {
|
|
468
|
-
shouldLoad = true;
|
|
469
|
-
nestedArgs = val as Record<string, unknown>;
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
if (shouldLoad) {
|
|
474
|
-
result.push({ relationMeta, nestedArgs });
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
return result;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
export function hasNestedRelations(params: {
|
|
482
|
-
nestedArgs: Record<string, unknown>;
|
|
483
|
-
}): boolean {
|
|
484
|
-
const { nestedArgs } = params;
|
|
485
|
-
return (
|
|
486
|
-
nestedArgs.include !== undefined ||
|
|
487
|
-
(typeof nestedArgs.select === "object" && nestedArgs.select !== null)
|
|
488
|
-
);
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
function resolveSelectColumnsForRelation(params: {
|
|
492
|
-
modelMeta: ModelMeta;
|
|
493
|
-
args: Record<string, unknown>;
|
|
494
|
-
}): { name: string; dbName: string }[] {
|
|
495
|
-
const { modelMeta, args } = params;
|
|
496
|
-
const select = args.select as Record<string, boolean | object> | undefined;
|
|
497
|
-
|
|
498
|
-
if (!select) {
|
|
499
|
-
return modelMeta.scalarFields.map((f) => ({
|
|
500
|
-
name: f.name,
|
|
501
|
-
dbName: f.dbName,
|
|
502
|
-
}));
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
const columns = modelMeta.scalarFields
|
|
506
|
-
.filter((f) => {
|
|
507
|
-
const val = select[f.name];
|
|
508
|
-
return val === true || (typeof val === "object" && val !== null);
|
|
509
|
-
})
|
|
510
|
-
.map((f) => ({ name: f.name, dbName: f.dbName }));
|
|
511
|
-
|
|
512
|
-
// Check if select has nested relations (object values referencing relation fields)
|
|
513
|
-
const relationNameSet = new Set(modelMeta.relationFields.map((r) => r.name));
|
|
514
|
-
const hasNested = Object.entries(select).some(([key, val]) => {
|
|
515
|
-
if (typeof val !== "object" || val === null) return false;
|
|
516
|
-
return relationNameSet.has(key);
|
|
517
|
-
});
|
|
518
|
-
|
|
519
|
-
if (hasNested) {
|
|
520
|
-
// Auto-include PK fields if not already selected
|
|
521
|
-
const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
|
|
522
|
-
const selectedNames = new Set(columns.map((c) => c.name));
|
|
523
|
-
for (const pkName of modelMeta.primaryKey) {
|
|
524
|
-
if (!selectedNames.has(pkName)) {
|
|
525
|
-
const sf = sfMap.get(pkName);
|
|
526
|
-
if (sf) {
|
|
527
|
-
columns.push({ name: sf.name, dbName: sf.dbName });
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
return columns;
|
|
534
|
-
}
|
package/src/retry.ts
DELETED
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Retry utility with exponential backoff and jitter.
|
|
3
|
-
*
|
|
4
|
-
* Only retries on transient errors (VibeTransientError) — connection failures,
|
|
5
|
-
* deadlocks, serialization failures, statement timeouts, and pool exhaustion.
|
|
6
|
-
* Deterministic errors (VibeRequestError) are never retried.
|
|
7
|
-
*
|
|
8
|
-
* @example
|
|
9
|
-
* ```ts
|
|
10
|
-
* import { withRetry } from "@vibeorm/runtime";
|
|
11
|
-
*
|
|
12
|
-
* // Basic — 3 retries, exponential backoff with jitter
|
|
13
|
-
* const users = await withRetry(() =>
|
|
14
|
-
* db.user.findMany({ where: { active: true } })
|
|
15
|
-
* );
|
|
16
|
-
*
|
|
17
|
-
* // Custom options
|
|
18
|
-
* const user = await withRetry(
|
|
19
|
-
* () => db.user.create({ data: { email: "new@example.com" } }),
|
|
20
|
-
* { maxRetries: 5, baseDelay: 100, maxDelay: 10000 },
|
|
21
|
-
* );
|
|
22
|
-
*
|
|
23
|
-
* // With abort signal
|
|
24
|
-
* const controller = new AbortController();
|
|
25
|
-
* const result = await withRetry(
|
|
26
|
-
* () => db.user.findFirst({ where: { id: 1 } }),
|
|
27
|
-
* { signal: controller.signal },
|
|
28
|
-
* );
|
|
29
|
-
*
|
|
30
|
-
* // Retry only specific error codes
|
|
31
|
-
* const result = await withRetry(
|
|
32
|
-
* () => db.$transaction(async (tx) => { ... }, { isolationLevel: "Serializable" }),
|
|
33
|
-
* { retryOn: ["SERIALIZATION_FAILURE", "DEADLOCK"] },
|
|
34
|
-
* );
|
|
35
|
-
* ```
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
|
-
import { VibeTransientError } from "./errors.ts";
|
|
39
|
-
import type { VibeTransientErrorCode } from "./errors.ts";
|
|
40
|
-
|
|
41
|
-
// ─── Types ───────────────────────────────────────────────────────
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Options for configuring retry behavior.
|
|
45
|
-
*/
|
|
46
|
-
export type RetryOptions = {
|
|
47
|
-
/**
|
|
48
|
-
* Maximum number of retry attempts (default: 3).
|
|
49
|
-
* The first execution is not counted as a retry, so the operation
|
|
50
|
-
* may execute up to `maxRetries + 1` times total.
|
|
51
|
-
*/
|
|
52
|
-
maxRetries?: number;
|
|
53
|
-
/**
|
|
54
|
-
* Base delay in milliseconds for exponential backoff (default: 50).
|
|
55
|
-
* Actual delay = min(baseDelay * 2^attempt + jitter, maxDelay).
|
|
56
|
-
*/
|
|
57
|
-
baseDelay?: number;
|
|
58
|
-
/**
|
|
59
|
-
* Maximum delay in milliseconds between retries (default: 5000).
|
|
60
|
-
* Caps the exponential growth to prevent excessively long waits.
|
|
61
|
-
*/
|
|
62
|
-
maxDelay?: number;
|
|
63
|
-
/**
|
|
64
|
-
* Maximum jitter in milliseconds added to each delay (default: 50).
|
|
65
|
-
* Randomized per retry to prevent thundering herd effects when
|
|
66
|
-
* many clients retry simultaneously after a shared failure.
|
|
67
|
-
*/
|
|
68
|
-
maxJitter?: number;
|
|
69
|
-
/**
|
|
70
|
-
* Optional AbortSignal to cancel pending retries.
|
|
71
|
-
* When aborted, the last error is thrown immediately without
|
|
72
|
-
* further retry attempts.
|
|
73
|
-
*/
|
|
74
|
-
signal?: AbortSignal;
|
|
75
|
-
/**
|
|
76
|
-
* Optional filter to retry only specific transient error codes.
|
|
77
|
-
* When provided, only errors with matching codes are retried;
|
|
78
|
-
* other transient errors are thrown immediately.
|
|
79
|
-
*
|
|
80
|
-
* When not provided, all transient errors are retried.
|
|
81
|
-
*/
|
|
82
|
-
retryOn?: VibeTransientErrorCode[];
|
|
83
|
-
/**
|
|
84
|
-
* Optional callback invoked before each retry attempt.
|
|
85
|
-
* Useful for logging or metrics.
|
|
86
|
-
*/
|
|
87
|
-
onRetry?: (params: { error: VibeTransientError; attempt: number; delay: number }) => void;
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
// ─── Implementation ──────────────────────────────────────────────
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Compute the delay for a retry attempt using exponential backoff with jitter.
|
|
94
|
-
* Formula: min(baseDelay * 2^attempt + random(0, maxJitter), maxDelay)
|
|
95
|
-
*/
|
|
96
|
-
function computeDelay(params: {
|
|
97
|
-
attempt: number;
|
|
98
|
-
baseDelay: number;
|
|
99
|
-
maxDelay: number;
|
|
100
|
-
maxJitter: number;
|
|
101
|
-
}): number {
|
|
102
|
-
const { attempt, baseDelay, maxDelay, maxJitter } = params;
|
|
103
|
-
const exponential = baseDelay * Math.pow(2, attempt);
|
|
104
|
-
const jitter = Math.random() * maxJitter;
|
|
105
|
-
return Math.min(exponential + jitter, maxDelay);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Sleep for a given number of milliseconds.
|
|
110
|
-
* Resolves immediately if the signal is already aborted.
|
|
111
|
-
*/
|
|
112
|
-
function sleep(params: { ms: number; signal?: AbortSignal }): Promise<void> {
|
|
113
|
-
const { ms, signal } = params;
|
|
114
|
-
if (signal?.aborted) return Promise.resolve();
|
|
115
|
-
return new Promise((resolve) => {
|
|
116
|
-
const timer = setTimeout(resolve, ms);
|
|
117
|
-
signal?.addEventListener("abort", () => {
|
|
118
|
-
clearTimeout(timer);
|
|
119
|
-
resolve();
|
|
120
|
-
}, { once: true });
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Execute a function with automatic retry on transient errors.
|
|
126
|
-
*
|
|
127
|
-
* Uses exponential backoff with jitter to space out retries and
|
|
128
|
-
* prevent thundering herd effects. Only retries `VibeTransientError`
|
|
129
|
-
* instances — deterministic errors (constraint violations, validation
|
|
130
|
-
* errors, etc.) are thrown immediately.
|
|
131
|
-
*
|
|
132
|
-
* @param fn - The async operation to execute and potentially retry.
|
|
133
|
-
* @param options - Optional retry configuration.
|
|
134
|
-
* @returns The result of the first successful execution.
|
|
135
|
-
* @throws The last error if all retries are exhausted.
|
|
136
|
-
*/
|
|
137
|
-
export async function withRetry<T>(
|
|
138
|
-
fn: () => Promise<T>,
|
|
139
|
-
options?: RetryOptions,
|
|
140
|
-
): Promise<T> {
|
|
141
|
-
const maxRetries = options?.maxRetries ?? 3;
|
|
142
|
-
const baseDelay = options?.baseDelay ?? 50;
|
|
143
|
-
const maxDelay = options?.maxDelay ?? 5000;
|
|
144
|
-
const maxJitter = options?.maxJitter ?? 50;
|
|
145
|
-
const signal = options?.signal;
|
|
146
|
-
const retryOn = options?.retryOn;
|
|
147
|
-
const onRetry = options?.onRetry;
|
|
148
|
-
|
|
149
|
-
let lastError: unknown;
|
|
150
|
-
|
|
151
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
152
|
-
try {
|
|
153
|
-
return await fn();
|
|
154
|
-
} catch (err) {
|
|
155
|
-
lastError = err;
|
|
156
|
-
|
|
157
|
-
// Only retry transient errors
|
|
158
|
-
if (!(err instanceof VibeTransientError)) throw err;
|
|
159
|
-
|
|
160
|
-
// If retryOn filter is set, only retry matching codes
|
|
161
|
-
if (retryOn && !retryOn.includes(err.code)) throw err;
|
|
162
|
-
|
|
163
|
-
// Don't retry if we've exhausted attempts
|
|
164
|
-
if (attempt >= maxRetries) throw err;
|
|
165
|
-
|
|
166
|
-
// Don't retry if aborted
|
|
167
|
-
if (signal?.aborted) throw err;
|
|
168
|
-
|
|
169
|
-
const delay = computeDelay({ attempt, baseDelay, maxDelay, maxJitter });
|
|
170
|
-
|
|
171
|
-
// Notify before sleeping
|
|
172
|
-
onRetry?.({ error: err, attempt: attempt + 1, delay });
|
|
173
|
-
|
|
174
|
-
await sleep({ ms: delay, signal });
|
|
175
|
-
|
|
176
|
-
// Check abort again after sleep
|
|
177
|
-
if (signal?.aborted) throw err;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Should never reach here, but TypeScript needs it
|
|
182
|
-
throw lastError;
|
|
183
|
-
}
|