@segmentstream-cloud/ontology 0.25.1 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -0
- package/dist/entity.d.ts +53 -2
- package/dist/entity.js +31 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.js +8 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/runtime/batch-diagnostics.d.ts +22 -0
- package/dist/runtime/batch-diagnostics.js +89 -0
- package/dist/runtime/batch.d.ts +48 -0
- package/dist/runtime/batch.js +473 -0
- package/dist/runtime/records.d.ts +2 -2
- package/dist/runtime/records.js +25 -6
- package/dist/runtime/sql-runtime.js +125 -5
- package/dist/runtime.d.ts +51 -2
- package/dist/runtime.js +2 -1
- package/dist/service/errors.js +17 -0
- package/dist/service/schemas.js +4 -0
- package/package.json +4 -2
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import { ActionRejected, Conflict, NotFound } from "../errors.js";
|
|
3
|
+
import { ACTION_ATTRIBUTION_TABLE, quoteIdentifier } from "../migrate/storage.js";
|
|
4
|
+
import { InvalidValues, StoredRecordMismatch } from "./errors.js";
|
|
5
|
+
import { diffRecords } from "./history.js";
|
|
6
|
+
import { uuidv7 } from "./ids.js";
|
|
7
|
+
import { entityModel } from "./model.js";
|
|
8
|
+
import { normalizeId } from "./reads.js";
|
|
9
|
+
import { bindValue, decodeRecord, isRecordId, prepareWrite, selectList, } from "./records.js";
|
|
10
|
+
import { columnOf, Params } from "./statement.js";
|
|
11
|
+
import { validate } from "./validation.js";
|
|
12
|
+
export const BATCH_MAX_ROWS = 200;
|
|
13
|
+
export const BATCH_MAX_BYTES = 2 * 1024 * 1024;
|
|
14
|
+
export const ACTION_MAX_BATCH_ROWS = 500;
|
|
15
|
+
/** The pinned PostgreSQL driver's signed Int16 count caps bindings at 32,767. */
|
|
16
|
+
export const BATCH_MAX_PARAMETERS = 30_000;
|
|
17
|
+
/** Includes bound values and an allowance for their SQL placeholders. */
|
|
18
|
+
export const BATCH_MAX_STATEMENT_BYTES = 4 * 1024 * 1024;
|
|
19
|
+
const summaryFields = new WeakMap();
|
|
20
|
+
const KeyOrderRow = Schema.Struct({ index: Schema.Int, duplicates: Schema.Int });
|
|
21
|
+
function rejected(message, indices, fields) {
|
|
22
|
+
return new ActionRejected({
|
|
23
|
+
message,
|
|
24
|
+
batch: { indices: [...indices], ...(fields === undefined ? {} : { fields: [...fields] }) },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** One UTF-8 JSON encoding defines the public byte bound, including IDs and conflict fields. */
|
|
28
|
+
export function batchInputBytes(input) {
|
|
29
|
+
return Buffer.byteLength(JSON.stringify({ records: input.records, ...(input.on ? { on: input.on } : {}) }));
|
|
30
|
+
}
|
|
31
|
+
function unsupportedFields(entity, values) {
|
|
32
|
+
return Object.keys(values).filter((name) => {
|
|
33
|
+
const field = entity.fieldsByName.get(name);
|
|
34
|
+
const link = entity.links.get(name);
|
|
35
|
+
return (values[name] !== undefined &&
|
|
36
|
+
(field?.storage === "blob" || link?.via === "join" || link?.via === "inverse-key"));
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function conflictFields(entity, on) {
|
|
40
|
+
return Effect.suspend(() => {
|
|
41
|
+
const fields = on.map((name) => entity.fieldsByName.get(name));
|
|
42
|
+
const uniqueProperty = on.length === 1 && fields[0]?.property?.definition.unique === true;
|
|
43
|
+
const declaredIndex = entity.manifest.indexes.find((index) => index.unique &&
|
|
44
|
+
index.properties.length === on.length &&
|
|
45
|
+
index.properties.every((name) => on.includes(name)));
|
|
46
|
+
if (on.length === 0 ||
|
|
47
|
+
new Set(on).size !== on.length ||
|
|
48
|
+
fields.some((field) => field?.source !== "property") ||
|
|
49
|
+
(!uniqueProperty && declaredIndex === undefined)) {
|
|
50
|
+
return Effect.die(new InvalidValues({
|
|
51
|
+
entity: entity.name,
|
|
52
|
+
message: `upsertMany needs declared ordinary properties forming a unique key of ${entity.name}.`,
|
|
53
|
+
issues: [
|
|
54
|
+
{
|
|
55
|
+
path: ["on"],
|
|
56
|
+
message: "Expected a declared unique property or compound unique index",
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
// Equivalent conflict-key permutations must acquire unique-index locks in the same order.
|
|
62
|
+
const canonical = declaredIndex?.properties ?? on;
|
|
63
|
+
return Effect.succeed(canonical.map((name) => entity.fieldsByName.get(name)));
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
function prepareBatchWrite(entity, values, mode, index) {
|
|
67
|
+
return prepareWrite(entity, values, mode).pipe(Effect.catchDefect((defect) => {
|
|
68
|
+
if (!(defect instanceof InvalidValues)) {
|
|
69
|
+
return Effect.die(defect);
|
|
70
|
+
}
|
|
71
|
+
const indexed = new InvalidValues({
|
|
72
|
+
entity: defect.entity,
|
|
73
|
+
message: `Invalid ${entity.name} values at batch index ${index}.`,
|
|
74
|
+
issues: defect.issues.map((issue) => ({
|
|
75
|
+
...issue,
|
|
76
|
+
path: ["records", index, ...issue.path],
|
|
77
|
+
})),
|
|
78
|
+
});
|
|
79
|
+
Object.defineProperty(indexed, "cause", { value: defect, enumerable: false });
|
|
80
|
+
return Effect.die(indexed);
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
/** All authored-value validation finishes before a record mutation can execute. */
|
|
84
|
+
export function planBatch(entity, operation, input) {
|
|
85
|
+
return Effect.gen(function* () {
|
|
86
|
+
const returning = input.returning ?? "records";
|
|
87
|
+
if (returning !== "records" && returning !== "summary") {
|
|
88
|
+
return yield* rejected("Unknown batch returning mode.", []);
|
|
89
|
+
}
|
|
90
|
+
const keys = operation === "upsert" ? yield* conflictFields(entity, input.on ?? []) : [];
|
|
91
|
+
const rows = [];
|
|
92
|
+
const updateIds = new Map();
|
|
93
|
+
const references = new Set();
|
|
94
|
+
for (const [index, entry] of input.records.entries()) {
|
|
95
|
+
const update = entry;
|
|
96
|
+
const values = operation === "update" ? update.values : entry;
|
|
97
|
+
const unsupported = unsupportedFields(entity, values);
|
|
98
|
+
if (unsupported.length > 0) {
|
|
99
|
+
return yield* rejected("Batch writes do not support blob or collection assignments.", [index], unsupported);
|
|
100
|
+
}
|
|
101
|
+
if (operation === "upsert") {
|
|
102
|
+
const missing = keys.filter((field) => values[field.name] === undefined || values[field.name] === null);
|
|
103
|
+
if (missing.length > 0) {
|
|
104
|
+
return yield* Effect.die(new InvalidValues({
|
|
105
|
+
entity: entity.name,
|
|
106
|
+
message: "Every upsertMany record needs non-null conflict key values.",
|
|
107
|
+
issues: missing.map((field) => ({
|
|
108
|
+
path: [index, field.name],
|
|
109
|
+
message: `${field.name} is required`,
|
|
110
|
+
})),
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const id = operation === "update" ? normalizeId(update.id) : uuidv7();
|
|
115
|
+
if (operation === "update") {
|
|
116
|
+
if (!isRecordId(id)) {
|
|
117
|
+
return yield* missingRecord(entity, id, [index], ["id"]);
|
|
118
|
+
}
|
|
119
|
+
const indices = updateIds.get(id) ?? [];
|
|
120
|
+
indices.push(index);
|
|
121
|
+
updateIds.set(id, indices);
|
|
122
|
+
}
|
|
123
|
+
const create = yield* prepareBatchWrite(entity, values, operation === "update" ? "update" : "create", index);
|
|
124
|
+
const patch = operation === "upsert"
|
|
125
|
+
? yield* prepareBatchWrite(entity, Object.fromEntries(Object.entries(values).filter(([name]) => !keys.some((key) => key.name === name))), "update", index)
|
|
126
|
+
: create;
|
|
127
|
+
for (const reference of create.references) {
|
|
128
|
+
references.add(`${reference.link.target}:${normalizeId(reference.id)}`);
|
|
129
|
+
}
|
|
130
|
+
rows.push({ index, id, create, patch });
|
|
131
|
+
}
|
|
132
|
+
const duplicates = [...updateIds.values()].filter((indices) => indices.length > 1).flat();
|
|
133
|
+
if (duplicates.length > 0) {
|
|
134
|
+
return yield* rejected("A batch cannot update a record more than once.", duplicates.sort((a, b) => a - b), ["id"]);
|
|
135
|
+
}
|
|
136
|
+
return { operation, returning, rows, keys, distinctReferences: references.size };
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
function missingRecord(entity, id, indices, fields) {
|
|
140
|
+
return new NotFound({
|
|
141
|
+
entity: entity.name,
|
|
142
|
+
id,
|
|
143
|
+
message: `A required ${entity.label.toLowerCase()} record does not exist.`,
|
|
144
|
+
batch: { indices: [...indices], ...(fields ? { fields: [...fields] } : {}) },
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/** Chunk by protocol parameters and encoded statement weight; chunks share one savepoint. */
|
|
148
|
+
function chunks(rows, width, valueBytes) {
|
|
149
|
+
return Effect.gen(function* () {
|
|
150
|
+
const result = [];
|
|
151
|
+
let current = [];
|
|
152
|
+
let bytes = 0;
|
|
153
|
+
for (const row of rows) {
|
|
154
|
+
const rowBytes = valueBytes(row) + width * 48;
|
|
155
|
+
if (width > BATCH_MAX_PARAMETERS || rowBytes > BATCH_MAX_STATEMENT_BYTES) {
|
|
156
|
+
return yield* rejected("A batch row exceeds the SQL statement resource limit.", []);
|
|
157
|
+
}
|
|
158
|
+
if (current.length > 0 &&
|
|
159
|
+
((current.length + 1) * width > BATCH_MAX_PARAMETERS ||
|
|
160
|
+
bytes + rowBytes > BATCH_MAX_STATEMENT_BYTES)) {
|
|
161
|
+
result.push(current);
|
|
162
|
+
current = [];
|
|
163
|
+
bytes = 0;
|
|
164
|
+
}
|
|
165
|
+
current.push(row);
|
|
166
|
+
bytes += rowBytes;
|
|
167
|
+
}
|
|
168
|
+
if (current.length > 0) {
|
|
169
|
+
result.push(current);
|
|
170
|
+
}
|
|
171
|
+
return result;
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
function writeBytes(write) {
|
|
175
|
+
return Buffer.byteLength(JSON.stringify(write.columns.map(({ field, value }) => bindValue(field, value))));
|
|
176
|
+
}
|
|
177
|
+
function selectedFields(entity, returning) {
|
|
178
|
+
if (returning === "records") {
|
|
179
|
+
return entity.fields;
|
|
180
|
+
}
|
|
181
|
+
const fields = summaryFields.get(entity) ??
|
|
182
|
+
entity.fields.filter((field) => field.source !== "system" || field.name === "id");
|
|
183
|
+
summaryFields.set(entity, fields);
|
|
184
|
+
return fields;
|
|
185
|
+
}
|
|
186
|
+
/** Summary mode needs history values, but never decodes a projected row as a complete record. */
|
|
187
|
+
function decodeBatchRow(entity, row, returning) {
|
|
188
|
+
return decodeRecord(entity, row, selectedFields(entity, returning));
|
|
189
|
+
}
|
|
190
|
+
function requireBatchReferences(context, plan) {
|
|
191
|
+
return Effect.gen(function* () {
|
|
192
|
+
const targets = new Map();
|
|
193
|
+
for (const row of plan.rows) {
|
|
194
|
+
for (const reference of row.create.references) {
|
|
195
|
+
const ids = targets.get(reference.link.target) ?? new Map();
|
|
196
|
+
const id = normalizeId(reference.id);
|
|
197
|
+
const positions = ids.get(id) ?? { indices: new Set(), fields: new Set() };
|
|
198
|
+
positions.indices.add(row.index);
|
|
199
|
+
positions.fields.add(reference.link.name);
|
|
200
|
+
ids.set(id, positions);
|
|
201
|
+
targets.set(reference.link.target, ids);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
for (const name of [...targets.keys()].sort()) {
|
|
205
|
+
const entity = entityModel(context.model, name);
|
|
206
|
+
const ids = targets.get(name);
|
|
207
|
+
const wanted = [...ids.keys()].sort();
|
|
208
|
+
const malformed = wanted.find((id) => !isRecordId(id));
|
|
209
|
+
if (malformed !== undefined) {
|
|
210
|
+
const positions = ids.get(malformed);
|
|
211
|
+
return yield* missingRecord(entity, malformed, [...positions.indices], [...positions.fields]);
|
|
212
|
+
}
|
|
213
|
+
for (const part of yield* chunks(wanted, 1, (id) => id.length)) {
|
|
214
|
+
const params = new Params();
|
|
215
|
+
const placeholders = part.map((id) => params.add(id, "uuid"));
|
|
216
|
+
const rows = yield* context.run(`SELECT "t0"."id"::text AS "id" FROM ${quoteIdentifier(entity.table)} AS "t0" ` +
|
|
217
|
+
`WHERE "t0"."id" IN (${placeholders.join(", ")}) ORDER BY "t0"."id" FOR KEY SHARE OF "t0"`, params);
|
|
218
|
+
const found = new Set(rows.map((row) => row.id));
|
|
219
|
+
const missing = part.filter((id) => !found.has(id));
|
|
220
|
+
if (missing.length > 0) {
|
|
221
|
+
const positions = missing.map((id) => ids.get(id));
|
|
222
|
+
return yield* missingRecord(entity, missing[0], [...new Set(positions.flatMap((position) => [...position.indices]))].sort((a, b) => a - b), [...new Set(positions.flatMap((position) => [...position.fields]))].sort());
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
function keyValue(row, field) {
|
|
229
|
+
return row.create.columns.find((column) => column.field.name === field.name).value;
|
|
230
|
+
}
|
|
231
|
+
/** PostgreSQL casts, equality and collation define duplicate keys; JS string encodings do not. */
|
|
232
|
+
function requireDistinctKeys(context, entity, plan) {
|
|
233
|
+
return Effect.gen(function* () {
|
|
234
|
+
const params = new Params();
|
|
235
|
+
const input = plan.rows.map((row) => Object.fromEntries([
|
|
236
|
+
["index", row.index],
|
|
237
|
+
...plan.keys.map((field, index) => [`k${index}`, keyValue(row, field)]),
|
|
238
|
+
]));
|
|
239
|
+
const keyNames = plan.keys.map((_, index) => quoteIdentifier(`k${index}`));
|
|
240
|
+
const definitions = plan.keys.map((field, index) => `${keyNames[index]} ${field.sqlType}`);
|
|
241
|
+
const rows = yield* context.run(`SELECT "index", count(*) OVER (PARTITION BY ${keyNames.join(", ")})::integer AS "duplicates" ` +
|
|
242
|
+
`FROM jsonb_to_recordset(${params.add(JSON.stringify(input), "jsonb")}) AS "input" ` +
|
|
243
|
+
`("index" integer, ${definitions.join(", ")}) ORDER BY ${keyNames.join(", ")}`, params);
|
|
244
|
+
const decoded = validate(Schema.Array(KeyOrderRow), rows);
|
|
245
|
+
if (!decoded.ok) {
|
|
246
|
+
return yield* Effect.die(new StoredRecordMismatch({
|
|
247
|
+
entity: entity.name,
|
|
248
|
+
message: "The database returned an invalid batch key ordering projection.",
|
|
249
|
+
}));
|
|
250
|
+
}
|
|
251
|
+
const indices = decoded.value
|
|
252
|
+
.filter((row) => Number(row.duplicates) > 1)
|
|
253
|
+
.map((row) => Number(row.index))
|
|
254
|
+
.sort((a, b) => a - b);
|
|
255
|
+
if (indices.length > 0) {
|
|
256
|
+
return yield* rejected("The batch contains duplicate conflict keys.", indices, plan.keys.map((field) => field.name));
|
|
257
|
+
}
|
|
258
|
+
const byIndex = new Map(plan.rows.map((row) => [row.index, row]));
|
|
259
|
+
return decoded.value.map((row) => byIndex.get(row.index));
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
function createRows(context, entity, plan) {
|
|
263
|
+
return Effect.gen(function* () {
|
|
264
|
+
const records = new Map();
|
|
265
|
+
const fields = entity.fields.filter((field) => field.source !== "system");
|
|
266
|
+
const sorted = plan.operation === "upsert"
|
|
267
|
+
? plan.rows
|
|
268
|
+
: [...plan.rows].sort((left, right) => left.id.localeCompare(right.id));
|
|
269
|
+
for (const part of yield* chunks(sorted, fields.length + 2, (row) => writeBytes(row.create))) {
|
|
270
|
+
const params = new Params();
|
|
271
|
+
const principal = params.add(context.scope.principalId, "uuid");
|
|
272
|
+
const values = part.map((row) => {
|
|
273
|
+
const columns = new Map(row.create.columns.map((column) => [column.field.name, column.value]));
|
|
274
|
+
const placeholders = [
|
|
275
|
+
params.add(row.id, "uuid"),
|
|
276
|
+
...fields.map((field) => params.add(bindValue(field, columns.get(field.name)), field.sqlType)),
|
|
277
|
+
];
|
|
278
|
+
return `(${placeholders.join(", ")})`;
|
|
279
|
+
});
|
|
280
|
+
const columns = fields.map((field) => quoteIdentifier(field.column));
|
|
281
|
+
const inputColumns = ['"id"', ...columns];
|
|
282
|
+
const targetColumns = [
|
|
283
|
+
'"id"',
|
|
284
|
+
'"created_at"',
|
|
285
|
+
'"updated_at"',
|
|
286
|
+
'"created_by"',
|
|
287
|
+
'"updated_by"',
|
|
288
|
+
...columns,
|
|
289
|
+
];
|
|
290
|
+
const selected = [
|
|
291
|
+
'"input"."id"',
|
|
292
|
+
"now()",
|
|
293
|
+
"now()",
|
|
294
|
+
principal,
|
|
295
|
+
principal,
|
|
296
|
+
...columns.map((column) => `"input".${column}`),
|
|
297
|
+
];
|
|
298
|
+
const conflict = plan.operation === "upsert"
|
|
299
|
+
? ` ON CONFLICT (${plan.keys.map((field) => quoteIdentifier(field.column)).join(", ")}) DO NOTHING`
|
|
300
|
+
: "";
|
|
301
|
+
const order = plan.operation === "upsert"
|
|
302
|
+
? plan.keys.map((field) => columnOf("input", field.column)).join(", ")
|
|
303
|
+
: '"input"."id"';
|
|
304
|
+
const rows = yield* context.run(`INSERT INTO ${quoteIdentifier(entity.table)} AS "t0" ` +
|
|
305
|
+
`(${targetColumns.join(", ")}) SELECT ${selected.join(", ")} ` +
|
|
306
|
+
`FROM (VALUES ${values.join(", ")}) AS "input" (${inputColumns.join(", ")}) ` +
|
|
307
|
+
`ORDER BY ${order}${conflict} RETURNING ${selectList(entity, "t0", selectedFields(entity, plan.returning))}`, params);
|
|
308
|
+
const byId = new Map(part.map((row) => [row.id, row.index]));
|
|
309
|
+
for (const row of rows) {
|
|
310
|
+
const record = yield* decodeBatchRow(entity, row, plan.returning);
|
|
311
|
+
records.set(byId.get(record.id), record);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return records;
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
function lockRows(context, entity, plan, pending) {
|
|
318
|
+
return Effect.gen(function* () {
|
|
319
|
+
const records = new Map();
|
|
320
|
+
const sorted = plan.operation === "upsert"
|
|
321
|
+
? pending
|
|
322
|
+
: [...pending].sort((left, right) => left.id.localeCompare(right.id));
|
|
323
|
+
const width = plan.operation === "upsert" ? plan.keys.length + 1 : 2;
|
|
324
|
+
for (const part of yield* chunks(sorted, width, (row) => writeBytes(row.create))) {
|
|
325
|
+
const params = new Params();
|
|
326
|
+
const keys = plan.operation === "upsert" ? plan.keys : [entity.fieldsByName.get("id")];
|
|
327
|
+
const values = part.map((row) => `(${params.add(row.index, "integer")}, ${keys
|
|
328
|
+
.map((field) => params.add(field.name === "id" ? row.id : bindValue(field, keyValue(row, field)), field.sqlType))
|
|
329
|
+
.join(", ")})`);
|
|
330
|
+
const keyNames = keys.map((_, index) => quoteIdentifier(`k${index}`));
|
|
331
|
+
const condition = keys
|
|
332
|
+
.map((field, index) => `${columnOf("t0", field.column)} = "input".${keyNames[index]}`)
|
|
333
|
+
.join(" AND ");
|
|
334
|
+
const order = keys.map((field) => columnOf("t0", field.column)).join(", ");
|
|
335
|
+
const rows = yield* context.run(`SELECT "input"."index" AS "__batch_index", ` +
|
|
336
|
+
`${selectList(entity, "t0", selectedFields(entity, plan.returning))} ` +
|
|
337
|
+
`FROM ${quoteIdentifier(entity.table)} AS "t0" ` +
|
|
338
|
+
`JOIN (VALUES ${values.join(", ")}) AS "input" ("index", ${keyNames.join(", ")}) ON ${condition} ` +
|
|
339
|
+
`ORDER BY ${order} FOR UPDATE OF "t0"`, params);
|
|
340
|
+
for (const row of rows) {
|
|
341
|
+
records.set(Number(row.__batch_index), yield* decodeBatchRow(entity, row, plan.returning));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const missing = pending.filter((row) => !records.has(row.index));
|
|
345
|
+
if (missing.length > 0) {
|
|
346
|
+
if (plan.operation === "upsert") {
|
|
347
|
+
return yield* new Conflict({
|
|
348
|
+
entity: entity.name,
|
|
349
|
+
message: "A conflicting upsert record disappeared before it could be locked.",
|
|
350
|
+
batch: {
|
|
351
|
+
indices: missing.map((row) => row.index),
|
|
352
|
+
fields: plan.keys.map((field) => field.name),
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
return yield* missingRecord(entity, missing[0].id, missing.map((row) => row.index), ["id"]);
|
|
357
|
+
}
|
|
358
|
+
return records;
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
/** Presence flags support heterogeneous patches in one set mutation without filling omissions. */
|
|
362
|
+
function updateRows(context, entity, plan, pending, before) {
|
|
363
|
+
return Effect.gen(function* () {
|
|
364
|
+
const records = new Map();
|
|
365
|
+
const names = new Set(pending.flatMap((row) => row.patch.columns.map(({ field }) => field.name)));
|
|
366
|
+
const fields = entity.fields.filter((field) => names.has(field.name));
|
|
367
|
+
if (fields.length === 0) {
|
|
368
|
+
return records;
|
|
369
|
+
}
|
|
370
|
+
const sorted = [...pending].sort((left, right) => String(before.get(left.index).id).localeCompare(String(before.get(right.index).id)));
|
|
371
|
+
for (const part of yield* chunks(sorted, fields.length * 2 + 2, (row) => writeBytes(row.patch))) {
|
|
372
|
+
const params = new Params();
|
|
373
|
+
const principal = params.add(context.scope.principalId, "uuid");
|
|
374
|
+
const values = part.map((row) => {
|
|
375
|
+
const columns = new Map(row.patch.columns.map((column) => [column.field.name, column.value]));
|
|
376
|
+
const fieldsSql = fields.flatMap((field) => [
|
|
377
|
+
params.add(columns.has(field.name), "boolean"),
|
|
378
|
+
params.add(columns.has(field.name) ? bindValue(field, columns.get(field.name)) : null, field.sqlType),
|
|
379
|
+
]);
|
|
380
|
+
return `(${params.add(before.get(row.index).id, "uuid")}, ${fieldsSql.join(", ")})`;
|
|
381
|
+
});
|
|
382
|
+
const inputColumns = fields.flatMap((_, index) => [`"has${index}"`, `"v${index}"`]);
|
|
383
|
+
const assignments = fields.map((field, index) => `${quoteIdentifier(field.column)} = CASE WHEN "input"."has${index}" ` +
|
|
384
|
+
`THEN "input"."v${index}" ELSE ${columnOf("t0", field.column)} END`);
|
|
385
|
+
const changed = fields.map((field, index) => `("input"."has${index}" AND ${columnOf("t0", field.column)} IS DISTINCT FROM "input"."v${index}")`);
|
|
386
|
+
const rows = yield* context.run(`UPDATE ${quoteIdentifier(entity.table)} AS "t0" SET ${assignments.join(", ")}, ` +
|
|
387
|
+
`"updated_at" = now(), "updated_by" = ${principal} ` +
|
|
388
|
+
`FROM (VALUES ${values.join(", ")}) AS "input" ("id", ${inputColumns.join(", ")}) ` +
|
|
389
|
+
`WHERE "t0"."id" = "input"."id" AND (${changed.join(" OR ")}) ` +
|
|
390
|
+
`RETURNING ${selectList(entity, "t0", selectedFields(entity, plan.returning))}`, params);
|
|
391
|
+
const byId = new Map(part.map((row) => [before.get(row.index).id, row.index]));
|
|
392
|
+
for (const row of rows) {
|
|
393
|
+
const record = yield* decodeBatchRow(entity, row, plan.returning);
|
|
394
|
+
records.set(byId.get(record.id), record);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return records;
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
function recordBatchEdits(context, entity, edits) {
|
|
401
|
+
return Effect.gen(function* () {
|
|
402
|
+
if (edits.length === 0) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (context.scope.execution !== undefined && !context.attributionInitialized) {
|
|
406
|
+
const params = new Params();
|
|
407
|
+
yield* context.run(`INSERT INTO "${ACTION_ATTRIBUTION_TABLE}" (action_id, execution) VALUES (` +
|
|
408
|
+
`${params.add(context.scope.actionId, "uuid")}, ` +
|
|
409
|
+
`${params.add(JSON.stringify(context.scope.execution), "jsonb")}) ON CONFLICT DO NOTHING`, params);
|
|
410
|
+
}
|
|
411
|
+
for (const part of yield* chunks(edits, 7, (edit) => Buffer.byteLength(JSON.stringify(edit.changes)))) {
|
|
412
|
+
const params = new Params();
|
|
413
|
+
const values = part.map((edit) => `(${[
|
|
414
|
+
params.add(uuidv7(), "uuid"),
|
|
415
|
+
params.add(edit.id, "uuid"),
|
|
416
|
+
params.add(context.scope.action, "text"),
|
|
417
|
+
params.add(context.scope.actionId, "uuid"),
|
|
418
|
+
params.add(edit.kind, "text"),
|
|
419
|
+
params.add(context.scope.principalId, "uuid"),
|
|
420
|
+
"now()",
|
|
421
|
+
params.add(JSON.stringify(edit.changes), "jsonb"),
|
|
422
|
+
].join(", ")})`);
|
|
423
|
+
yield* context.run(`INSERT INTO ${quoteIdentifier(entity.historyTable)} ` +
|
|
424
|
+
'("id", "record_id", "action", "action_id", "kind", "principal_id", "at", "changes") ' +
|
|
425
|
+
`VALUES ${values.join(", ")}`, params);
|
|
426
|
+
}
|
|
427
|
+
context.edits.push(...edits);
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
/** Executes one set of mutations; the caller owns its single savepoint and edit publication. */
|
|
431
|
+
export function executeBatch(context, entity, plan) {
|
|
432
|
+
return Effect.gen(function* () {
|
|
433
|
+
const sortedRows = plan.operation === "upsert" ? yield* requireDistinctKeys(context, entity, plan) : plan.rows;
|
|
434
|
+
const orderedPlan = { ...plan, rows: sortedRows };
|
|
435
|
+
yield* requireBatchReferences(context, plan);
|
|
436
|
+
const created = plan.operation === "update"
|
|
437
|
+
? new Map()
|
|
438
|
+
: yield* createRows(context, entity, orderedPlan);
|
|
439
|
+
const pending = sortedRows.filter((row) => !created.has(row.index));
|
|
440
|
+
// A new READ COMMITTED statement observes insertions that DO NOTHING waited for.
|
|
441
|
+
const before = pending.length === 0
|
|
442
|
+
? new Map()
|
|
443
|
+
: yield* lockRows(context, entity, plan, pending);
|
|
444
|
+
const updated = pending.length === 0
|
|
445
|
+
? new Map()
|
|
446
|
+
: yield* updateRows(context, entity, plan, pending, before);
|
|
447
|
+
const edits = [];
|
|
448
|
+
const items = plan.rows.map((row) => {
|
|
449
|
+
const record = created.get(row.index) ?? updated.get(row.index) ?? before.get(row.index);
|
|
450
|
+
const status = created.has(row.index)
|
|
451
|
+
? "created"
|
|
452
|
+
: updated.has(row.index)
|
|
453
|
+
? "updated"
|
|
454
|
+
: "unchanged";
|
|
455
|
+
if (status !== "unchanged") {
|
|
456
|
+
edits.push({
|
|
457
|
+
entity: entity.name,
|
|
458
|
+
id: record.id,
|
|
459
|
+
kind: status === "created" ? "create" : "update",
|
|
460
|
+
changes: diffRecords(entity, status === "created" ? undefined : before.get(row.index), record),
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
return { index: row.index, status, record };
|
|
464
|
+
});
|
|
465
|
+
yield* recordBatchEdits(context, entity, edits);
|
|
466
|
+
const counts = {
|
|
467
|
+
created: created.size,
|
|
468
|
+
updated: updated.size,
|
|
469
|
+
unchanged: before.size - updated.size,
|
|
470
|
+
};
|
|
471
|
+
return plan.returning === "summary" ? counts : { ...counts, items };
|
|
472
|
+
});
|
|
473
|
+
}
|
|
@@ -3,11 +3,11 @@ import type { StoredRecord, StoredValues } from "../runtime.js";
|
|
|
3
3
|
import { type EntityModel, type FieldModel, type JoinLink, type KeyLink } from "./model.js";
|
|
4
4
|
export type Row = Readonly<Record<string, unknown>>;
|
|
5
5
|
/** The select list of an entity's records from the table aliased `alias`. */
|
|
6
|
-
export declare function selectList(entity: EntityModel, alias: string): string;
|
|
6
|
+
export declare function selectList(entity: EntityModel, alias: string, fields?: ReadonlyArray<FieldModel>): string;
|
|
7
7
|
/** `2026-10-04T09:30:00.120000Z` → `2026-10-04T09:30:00.120Z`: milliseconds, then any further digits. */
|
|
8
8
|
export declare function canonicalInstant(value: string): string;
|
|
9
9
|
/** Decodes a row read with {@link selectList} against the entity's record schema. */
|
|
10
|
-
export declare function decodeRecord(entity: EntityModel, row: Row): Effect.Effect<StoredRecord>;
|
|
10
|
+
export declare function decodeRecord(entity: EntityModel, row: Row, fields?: ReadonlyArray<FieldModel>): Effect.Effect<StoredRecord>;
|
|
11
11
|
export declare function decodeRecords(entity: EntityModel, rows: ReadonlyArray<Row>): Effect.Effect<Array<StoredRecord>>;
|
|
12
12
|
/** The parameter a value of a field is bound as. JSON is bound as text and cast to `jsonb`. */
|
|
13
13
|
export declare function bindValue(field: FieldModel, value: unknown): unknown;
|
package/dist/runtime/records.js
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
|
-
import { Effect } from "effect";
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
2
|
import { isCalendarDate, isInstant, isUuid } from "../query.js";
|
|
3
3
|
import { quoteIdentifier } from "../migrate/storage.js";
|
|
4
4
|
import { InvalidValues, StoredRecordMismatch } from "./errors.js";
|
|
5
5
|
import { propertyValueSchema, } from "./model.js";
|
|
6
6
|
import { columnOf } from "./statement.js";
|
|
7
7
|
import { validate } from "./validation.js";
|
|
8
|
+
const projectionSchemas = new WeakMap();
|
|
9
|
+
function projectionSchema(entity, fields) {
|
|
10
|
+
if (fields === entity.fields) {
|
|
11
|
+
return entity.recordSchema;
|
|
12
|
+
}
|
|
13
|
+
const key = fields.map((field) => field.name).join(",");
|
|
14
|
+
const cached = projectionSchemas.get(entity) ?? new Map();
|
|
15
|
+
const existing = cached.get(key);
|
|
16
|
+
if (existing !== undefined) {
|
|
17
|
+
return existing;
|
|
18
|
+
}
|
|
19
|
+
// entityRecordSchema builds a Struct; derive each SQL projection from those field schemas.
|
|
20
|
+
const complete = entity.recordSchema;
|
|
21
|
+
const schema = Schema.Struct(Object.fromEntries(fields.map((field) => [field.name, complete.fields[field.name]])));
|
|
22
|
+
cached.set(key, schema);
|
|
23
|
+
projectionSchemas.set(entity, cached);
|
|
24
|
+
return schema;
|
|
25
|
+
}
|
|
8
26
|
/**
|
|
9
27
|
* How a column is read. Values come back as text where the driver's own decoding would lose
|
|
10
28
|
* information or depend on the driver: timestamps keep their microseconds in UTC, dates stay
|
|
@@ -31,8 +49,8 @@ function readExpression(field, column) {
|
|
|
31
49
|
}
|
|
32
50
|
}
|
|
33
51
|
/** The select list of an entity's records from the table aliased `alias`. */
|
|
34
|
-
export function selectList(entity, alias) {
|
|
35
|
-
return
|
|
52
|
+
export function selectList(entity, alias, fields = entity.fields) {
|
|
53
|
+
return fields
|
|
36
54
|
.map((field) => `${readExpression(field, columnOf(alias, field.column))} AS ${quoteIdentifier(field.name)}`)
|
|
37
55
|
.join(", ");
|
|
38
56
|
}
|
|
@@ -57,13 +75,14 @@ function readValue(field, value) {
|
|
|
57
75
|
}
|
|
58
76
|
}
|
|
59
77
|
/** Decodes a row read with {@link selectList} against the entity's record schema. */
|
|
60
|
-
export function decodeRecord(entity, row) {
|
|
78
|
+
export function decodeRecord(entity, row, fields = entity.fields) {
|
|
61
79
|
return Effect.suspend(() => {
|
|
62
80
|
const values = {};
|
|
63
|
-
for (const field of
|
|
81
|
+
for (const field of fields) {
|
|
64
82
|
values[field.name] = readValue(field, row[field.name]);
|
|
65
83
|
}
|
|
66
|
-
const
|
|
84
|
+
const schema = projectionSchema(entity, fields);
|
|
85
|
+
const decoded = validate(schema, values);
|
|
67
86
|
if (!decoded.ok) {
|
|
68
87
|
const fields = decoded.issues.map((issue) => issue.path.join(".")).join(", ");
|
|
69
88
|
return Effect.die(new StoredRecordMismatch({
|