|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#25073] `SqlDriver.issueAutonumbers` — the record number on request, ahead |
| 5 | + * of the write. |
| 6 | + * |
| 7 | + * `create()` / `bulkCreate()` fill an empty `autonumber` slot as the last step |
| 8 | + * before the INSERT, so a stored field whose `expression` reads the number was |
| 9 | + * computed by the engine before the number existed (null for a concatenation, |
| 10 | + * the `has()` fallback otherwise). The member issues the same number earlier, |
| 11 | + * on the caller's row, and `create()` then finds the slot filled and writes |
| 12 | + * the row as it stands: one statement, carrying the number the engine computed |
| 13 | + * from. |
| 14 | + * |
| 15 | + * What is pinned here is the member's own half, on a real SQLite database: |
| 16 | + * |
| 17 | + * 1. ISSUE fills what `create()` would have filled (same counter, same |
| 18 | + * organization scope), leaves a typed value alone, and `create()` of the |
| 19 | + * filled row draws no second number. |
| 20 | + * 2. AFTER A COLLISION it is `create()`'s #5495 re-seed split at the point |
| 21 | + * only the caller can go on: it judges the error as `create()` does, |
| 22 | + * re-seeds the stale counter once and EMPTIES the slot, so the caller's |
| 23 | + * next issue lands on MAX + 1. It never answers for a duplicate on a |
| 24 | + * value the caller typed, for a non-unique error, or inside a caller's |
| 25 | + * transaction. |
| 26 | + * 3. A batch: every row that drew from the stale counter is emptied (#6943's |
| 27 | + * per-counter reading), the counter re-seeded once. |
| 28 | + * |
| 29 | + * The engine's half (issue after validation, recompute, retry) is pinned in |
| 30 | + * `packages/runtime/src/autonumber-stored-expression.integration.test.ts`. |
| 31 | + */ |
| 32 | + |
| 33 | +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; |
| 34 | +import { isUniqueViolationError } from '@objectstack/types'; |
| 35 | +import { SqlDriver } from './index.js'; |
| 36 | + |
| 37 | +const SEQUENCES_TABLE = '_objectstack_sequences'; |
| 38 | + |
| 39 | +const CRM_CASE = { |
| 40 | + name: 'crm_case', |
| 41 | + fields: { |
| 42 | + organization_id: { type: 'string' }, |
| 43 | + case_number: { type: 'autonumber', format: 'CASE-{00000}', unique: true }, |
| 44 | + title: { type: 'string' }, |
| 45 | + }, |
| 46 | +} as any; |
| 47 | + |
| 48 | +describe('[#25073] SqlDriver.issueAutonumbers', () => { |
| 49 | + let driver: SqlDriver; |
| 50 | + const knex = () => (driver as any).knex; |
| 51 | + |
| 52 | + const lastValue = async (tenant: string): Promise<number | undefined> => { |
| 53 | + const rows = await knex()(SEQUENCES_TABLE).select('*'); |
| 54 | + const row = rows.find((r: any) => r.object === 'crm_case' && r.field === 'case_number' && r.tenant_id === tenant); |
| 55 | + return row ? Number(row.last_value) : undefined; |
| 56 | + }; |
| 57 | + |
| 58 | + /** Land rows by a path that bypasses the counter, as a seed replay does. */ |
| 59 | + const bypassInsert = async (org: string, from: number, to: number) => { |
| 60 | + const rows = []; |
| 61 | + for (let n = from; n <= to; n++) { |
| 62 | + rows.push({ id: `${org}-s${n}`, organization_id: org, case_number: `CASE-${String(n).padStart(5, '0')}`, title: `seed ${n}` }); |
| 63 | + } |
| 64 | + await knex()('crm_case').insert(rows); |
| 65 | + }; |
| 66 | + |
| 67 | + /** The error a write raises, or a failure when it lands. */ |
| 68 | + const refusal = async (write: Promise<unknown>): Promise<unknown> => { |
| 69 | + try { |
| 70 | + await write; |
| 71 | + } catch (error) { |
| 72 | + return error; |
| 73 | + } |
| 74 | + throw new Error('expected the write to be refused'); |
| 75 | + }; |
| 76 | + |
| 77 | + beforeEach(async () => { |
| 78 | + driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); |
| 79 | + await driver.initObjects([CRM_CASE]); |
| 80 | + }); |
| 81 | + |
| 82 | + afterEach(async () => { |
| 83 | + await driver.disconnect(); |
| 84 | + }); |
| 85 | + |
| 86 | + describe('issue', () => { |
| 87 | + it('fills the empty slot in place, from the counter create() draws from, and create() draws no second number', async () => { |
| 88 | + await driver.create('crm_case', { organization_id: 'orgA', title: 'by create' }); |
| 89 | + expect(await lastValue('orgA')).toBe(1); |
| 90 | + |
| 91 | + const row: Record<string, unknown> = { organization_id: 'orgA', title: 'ahead' }; |
| 92 | + expect(await driver.issueAutonumbers('crm_case', [row])).toEqual([['case_number']]); |
| 93 | + expect(row.case_number).toBe('CASE-00002'); |
| 94 | + expect(await lastValue('orgA')).toBe(2); |
| 95 | + |
| 96 | + const stored = await driver.create('crm_case', row); |
| 97 | + expect(stored.case_number).toBe('CASE-00002'); |
| 98 | + expect(await lastValue('orgA')).toBe(2); |
| 99 | + // And the counter simply goes on from there. |
| 100 | + expect((await driver.create('crm_case', { organization_id: 'orgA', title: 'next' })).case_number).toBe('CASE-00003'); |
| 101 | + }); |
| 102 | + |
| 103 | + it('issues per organization from the call options, as create() stamps the organization first', async () => { |
| 104 | + await driver.create('crm_case', { organization_id: 'orgA', title: 'a1' }); |
| 105 | + const row: Record<string, unknown> = { title: 'b, organization from options' }; |
| 106 | + expect(await driver.issueAutonumbers('crm_case', [row], { tenantId: 'orgB' } as any)).toEqual([['case_number']]); |
| 107 | + expect(row.case_number).toBe('CASE-00001'); |
| 108 | + expect(await lastValue('orgB')).toBe(1); |
| 109 | + expect(await lastValue('orgA')).toBe(1); |
| 110 | + // Only the number lands on the caller's row; `create()` stamps the organization. |
| 111 | + expect(row).not.toHaveProperty('organization_id'); |
| 112 | + expect((await driver.create('crm_case', row, { tenantId: 'orgB' } as any)).organization_id).toBe('orgB'); |
| 113 | + }); |
| 114 | + |
| 115 | + it('leaves a typed value alone and does not report it', async () => { |
| 116 | + const row: Record<string, unknown> = { organization_id: 'orgA', case_number: 'CASE-07777', title: 'import' }; |
| 117 | + expect(await driver.issueAutonumbers('crm_case', [row])).toEqual([[]]); |
| 118 | + expect(row.case_number).toBe('CASE-07777'); |
| 119 | + expect(await lastValue('orgA')).toBeUndefined(); |
| 120 | + }); |
| 121 | + |
| 122 | + it('answers index-aligned with the rows of a batch', async () => { |
| 123 | + const rows: Record<string, unknown>[] = [ |
| 124 | + { organization_id: 'orgA', title: 'one' }, |
| 125 | + { organization_id: 'orgA', case_number: 'CASE-00500', title: 'typed' }, |
| 126 | + { organization_id: 'orgA', title: 'two' }, |
| 127 | + ]; |
| 128 | + expect(await driver.issueAutonumbers('crm_case', rows)).toEqual([['case_number'], [], ['case_number']]); |
| 129 | + expect(rows.map((r) => r.case_number)).toEqual(['CASE-00001', 'CASE-00500', 'CASE-00002']); |
| 130 | + const stored = await driver.bulkCreate('crm_case', rows); |
| 131 | + expect(stored.map((r) => r.case_number)).toEqual(['CASE-00001', 'CASE-00500', 'CASE-00002']); |
| 132 | + }); |
| 133 | + }); |
| 134 | + |
| 135 | + describe('after a collision', () => { |
| 136 | + it('re-seeds the counter it proves stale and empties the slot, so the next issue lands on MAX + 1', async () => { |
| 137 | + await bypassInsert('orgA', 9, 9); |
| 138 | + await driver.create('crm_case', { organization_id: 'orgA', title: 'first' }); |
| 139 | + expect(await lastValue('orgA')).toBe(10); |
| 140 | + await bypassInsert('orgA', 11, 39); |
| 141 | + |
| 142 | + const row: Record<string, unknown> = { organization_id: 'orgA', title: 'after the seeds' }; |
| 143 | + await driver.issueAutonumbers('crm_case', [row]); |
| 144 | + expect(row.case_number).toBe('CASE-00011'); |
| 145 | + const error = await refusal(driver.create('crm_case', row)); |
| 146 | + expect(isUniqueViolationError(error)).toBe(true); |
| 147 | + |
| 148 | + expect(await driver.issueAutonumbers('crm_case', [row], undefined, error)).toEqual([['case_number']]); |
| 149 | + expect(row).not.toHaveProperty('case_number'); |
| 150 | + expect(await lastValue('orgA')).toBe(39); |
| 151 | + |
| 152 | + expect(await driver.issueAutonumbers('crm_case', [row])).toEqual([['case_number']]); |
| 153 | + expect(row.case_number).toBe('CASE-00040'); |
| 154 | + expect((await driver.create('crm_case', row)).case_number).toBe('CASE-00040'); |
| 155 | + }); |
| 156 | + |
| 157 | + it('answers nothing, and changes nothing, for a duplicate on a value the caller typed', async () => { |
| 158 | + await driver.create('crm_case', { organization_id: 'orgA', case_number: 'CASE-00001', title: 'typed' }); |
| 159 | + const row: Record<string, unknown> = { organization_id: 'orgA', case_number: 'CASE-00001', title: 'typed again' }; |
| 160 | + expect(await driver.issueAutonumbers('crm_case', [row])).toEqual([[]]); |
| 161 | + const error = await refusal(driver.create('crm_case', row)); |
| 162 | + expect(isUniqueViolationError(error)).toBe(true); |
| 163 | + |
| 164 | + expect(await driver.issueAutonumbers('crm_case', [row], undefined, error)).toEqual([[]]); |
| 165 | + expect(row.case_number).toBe('CASE-00001'); |
| 166 | + }); |
| 167 | + |
| 168 | + it('answers nothing for an error that is not a unique violation', async () => { |
| 169 | + const row: Record<string, unknown> = { organization_id: 'orgA', title: 'x' }; |
| 170 | + await driver.issueAutonumbers('crm_case', [row]); |
| 171 | + const issued = row.case_number; |
| 172 | + expect(await driver.issueAutonumbers('crm_case', [row], undefined, new Error('SQLITE_BUSY: database is locked'))).toEqual([[]]); |
| 173 | + expect(row.case_number).toBe(issued); |
| 174 | + }); |
| 175 | + |
| 176 | + it('answers nothing inside a caller transaction, where create() retries nothing either', async () => { |
| 177 | + await driver.create('crm_case', { organization_id: 'orgA', title: 'seeds the counter at 1' }); |
| 178 | + await bypassInsert('orgA', 2, 5); |
| 179 | + const row: Record<string, unknown> = { organization_id: 'orgA', title: 'in a transaction' }; |
| 180 | + await driver.issueAutonumbers('crm_case', [row]); |
| 181 | + const error = await refusal(driver.create('crm_case', row)); |
| 182 | + expect(isUniqueViolationError(error)).toBe(true); |
| 183 | + const trx = await knex().transaction(); |
| 184 | + try { |
| 185 | + expect(await driver.issueAutonumbers('crm_case', [row], { transaction: trx } as any, error)).toEqual([[]]); |
| 186 | + } finally { |
| 187 | + await trx.rollback(); |
| 188 | + } |
| 189 | + expect(row.case_number).toBe('CASE-00002'); |
| 190 | + expect(await lastValue('orgA')).toBe(2); |
| 191 | + }); |
| 192 | + |
| 193 | + it('empties every row of a batch that drew from the stale counter, and re-seeds it once', async () => { |
| 194 | + await bypassInsert('orgA', 1, 1); |
| 195 | + await driver.create('crm_case', { organization_id: 'orgA', title: 'seeds the counter at 2' }); |
| 196 | + await bypassInsert('orgA', 3, 3); |
| 197 | + |
| 198 | + const rows: Record<string, unknown>[] = [ |
| 199 | + { organization_id: 'orgA', title: 'collides on 3' }, |
| 200 | + { organization_id: 'orgA', title: 'would be 4, did not collide' }, |
| 201 | + { organization_id: 'orgB', title: 'another counter' }, |
| 202 | + ]; |
| 203 | + await driver.issueAutonumbers('crm_case', rows); |
| 204 | + expect(rows.map((r) => r.case_number)).toEqual(['CASE-00003', 'CASE-00004', 'CASE-00001']); |
| 205 | + const error = await refusal(driver.bulkCreate('crm_case', rows)); |
| 206 | + |
| 207 | + expect(await driver.issueAutonumbers('crm_case', rows, undefined, error)).toEqual([['case_number'], ['case_number'], []]); |
| 208 | + expect(rows[0]).not.toHaveProperty('case_number'); |
| 209 | + expect(rows[1]).not.toHaveProperty('case_number'); |
| 210 | + expect(rows[2].case_number).toBe('CASE-00001'); |
| 211 | + expect(await lastValue('orgA')).toBe(4); |
| 212 | + expect(await lastValue('orgB')).toBe(1); |
| 213 | + }); |
| 214 | + }); |
| 215 | +}); |
0 commit comments