Skip to content

Commit ba7e237

Browse files
committed
fix(objectql): assign autonumbers after validation; document at-least-once hooks (#3152)
In the batch insert path, beforeInsert hooks and autonumber assignment ran before per-row validation. When bulkWrite degrades a batch to per-row (or retries it), the whole engine.insert re-runs, so a batch that dies in validation had already consumed an autonumber sequence value for every good row — leaving number-range gaps — and re-fired side-effecting beforeInsert hooks on the retry. Move autonumber assignment to after validation: a doomed attempt now consumes no sequence value (required-validation already exempts autonumber fields, and a driver-owned autonumber assigns nothing here, so no validation rule can depend on the value — the reorder is safe). The remaining beforeInsert double-execution on a degraded batch is documented as at-least-once hook semantics on engine.insert (hooks must be idempotent); root-causing it needs a partial-success engine API, out of scope here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APnSDJneEDRh3Z51KGCLga
1 parent 45c5df6 commit ba7e237

2 files changed

Lines changed: 115 additions & 3 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// framework#3152: a batch insert that dies in validation must not have already
4+
// consumed autonumber sequence values for its good rows. Autonumbers are now
5+
// assigned AFTER validation, so a doomed attempt (which bulkWrite then degrades
6+
// to per-row) leaves no gaps in the number range.
7+
8+
import { describe, it, expect } from 'vitest';
9+
import { ObjectQL } from './engine.js';
10+
11+
function makeDriver() {
12+
const stores = new Map<string, Map<string, any>>();
13+
const storeFor = (o: string) => {
14+
let s = stores.get(o);
15+
if (!s) { s = new Map(); stores.set(o, s); }
16+
return s;
17+
};
18+
let n = 0;
19+
const driver: any = {
20+
name: 'memory', version: '0.0.0', supports: {},
21+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
22+
async find(object: string) { return Array.from(storeFor(object).values()); },
23+
findStream() { throw new Error('ni'); },
24+
async findOne() { return null; },
25+
async create(object: string, data: Record<string, unknown>) {
26+
n += 1;
27+
const id = (data.id as string) ?? `r_${n}`;
28+
const row = { ...data, id };
29+
storeFor(object).set(id, row);
30+
return row;
31+
},
32+
async update(object: string, id: string, data: Record<string, unknown>) {
33+
const s = storeFor(object);
34+
const row = { ...s.get(id), ...data, id };
35+
s.set(id, row);
36+
return row;
37+
},
38+
async delete(object: string, id: string) { return storeFor(object).delete(id); },
39+
async count() { return 0; },
40+
async bulkCreate(object: string, rows: Record<string, unknown>[]) {
41+
const out: any[] = [];
42+
for (const r of rows) out.push(await this.create(object, r));
43+
return out;
44+
},
45+
async bulkUpdate() { return []; }, async bulkDelete() {},
46+
async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; },
47+
async commit() {}, async rollback() {},
48+
};
49+
return { driver, storeFor };
50+
}
51+
52+
async function makeEngine() {
53+
const engine = new ObjectQL();
54+
const d = makeDriver();
55+
engine.registerDriver(d.driver, true);
56+
await engine.init();
57+
engine.registry.registerObject({
58+
name: 'task',
59+
fields: {
60+
name: { type: 'text', required: true },
61+
code: { type: 'autonumber', format: 'T-{000}' },
62+
},
63+
} as any);
64+
return { engine, storeFor: d.storeFor };
65+
}
66+
67+
describe('batch insert — autonumber assigned after validation (framework#3152)', () => {
68+
it('a validation-failed batch consumes no autonumber, leaving no gap for the degraded per-row retry', async () => {
69+
const { engine } = await makeEngine();
70+
71+
// A batch with a bad (name-less) middle row fails in validation before any
72+
// driver write. Previously the good rows had already been numbered.
73+
await expect(engine.insert('task', [{ name: 'a' }, { slug: 'no-name' }, { name: 'b' }]))
74+
.rejects.toThrow();
75+
76+
// bulkWrite would now degrade to per-row; simulate that by inserting the
77+
// good rows individually. Their numbers must be contiguous from 1.
78+
const a = await engine.insert('task', { name: 'a' });
79+
const b = await engine.insert('task', { name: 'b' });
80+
81+
expect(a.code).toBe('T-001');
82+
expect(b.code).toBe('T-002'); // no gap — the failed batch consumed nothing
83+
});
84+
85+
it('a fully-valid batch still numbers every row contiguously', async () => {
86+
const { engine } = await makeEngine();
87+
const rows = await engine.insert('task', [{ name: 'a' }, { name: 'b' }, { name: 'c' }]);
88+
expect((rows as any[]).map((r) => r.code)).toEqual(['T-001', 'T-002', 'T-003']);
89+
});
90+
});

packages/objectql/src/engine.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2200,6 +2200,20 @@ export class ObjectQL implements IDataEngine {
22002200
return opCtx.result;
22012201
}
22022202

2203+
/**
2204+
* Insert one record or an array of records.
2205+
*
2206+
* At-least-once hook semantics (framework#3152): when this call is driven by
2207+
* `bulkWrite` (seed / import), a transient batch retry or a per-row
2208+
* degradation re-runs the whole insert, so a `beforeInsert` hook may fire
2209+
* MORE THAN ONCE for the same input row (a first attempt that failed in
2210+
* validation, then the degraded retry). Side-effecting `beforeInsert` hooks
2211+
* (notifications, external calls, counters) must therefore be idempotent.
2212+
* `afterInsert` hooks fire only on a successful write, so they are not
2213+
* re-run by a validation failure. Autonumbers are assigned only after
2214+
* validation passes, so a doomed attempt no longer consumes a sequence value
2215+
* (no number-range gaps from a rejected batch).
2216+
*/
22032217
async insert(object: string, data: any | any[], options?: DataEngineInsertOptions): Promise<any> {
22042218
object = this.resolveObjectName(object);
22052219
this.logger.debug('Insert operation starting', { object, isBatch: Array.isArray(data) });
@@ -2271,9 +2285,6 @@ export class ObjectQL implements IDataEngine {
22712285
// Defaults are already resolved above (pre-hook, #2703); a hook may
22722286
// have overridden fields or replaced `input.data` — take its data as-is.
22732287
const rows = rowHookContexts.map((rowCtx) => rowCtx.input.data as Record<string, unknown>);
2274-
for (const r of rows) {
2275-
await this.applyAutonumbers(object, r, opCtx.context, driverOwnsAutonumber);
2276-
}
22772288
for (const r of rows) {
22782289
await this.encryptSecretFields(object, r, opCtx.context, driverOptions);
22792290
}
@@ -2282,6 +2293,17 @@ export class ObjectQL implements IDataEngine {
22822293
validateRecord(schemaForValidation, r, 'insert');
22832294
evaluateValidationRules(schemaForValidation as any, r, 'insert', { logger: this.logger, currentUser: this.buildEvalUser(opCtx.context) });
22842295
}
2296+
// Autonumbers are assigned AFTER validation (framework#3152): in the
2297+
// batch path a bulkWrite retry / per-row degradation re-runs the whole
2298+
// engine.insert, and a batch that dies in validation would otherwise
2299+
// have already consumed a sequence value for every good row on the
2300+
// failed attempt, leaving gaps. Required-validation exempts autonumber
2301+
// fields either way (they are engine-assigned), and a driver that owns
2302+
// autonumber assigns nothing here — so no validation rule can depend on
2303+
// the value, making this reorder safe.
2304+
for (const r of rows) {
2305+
await this.applyAutonumbers(object, r, opCtx.context, driverOwnsAutonumber);
2306+
}
22852307
if (isBatch) {
22862308
if (driver.bulkCreate) {
22872309
result = await driver.bulkCreate(object, rows, driverOptions);

0 commit comments

Comments
 (0)