|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #6555 (half 2/3, #7262) — a format-LESS autonumber field renders through the |
| 5 | + * contract default `{0000}`, not through the empty string. |
| 6 | + * |
| 7 | + * `applyAutonumbers` used to read the format by hand — `autonumberFormat ?? |
| 8 | + * format`, then `typeof fmt === 'string' ? fmt : ''` — so a field declaring no |
| 9 | + * format handed `parseAutonumberFormat` the EMPTY string. An empty token list |
| 10 | + * renders through `renderAutonumber`'s no-slot branch as a bare counter: `1`, |
| 11 | + * `2`, …. `driver-sql` answered the same question with its own hardcoded |
| 12 | + * `|| '{0000}'` and issued `0001`, `0002`, …. One metadata document therefore |
| 13 | + * minted differently-shaped numbers depending on which driver served it, and a |
| 14 | + * suite asserting `'1'` against the memory driver did not hold in production on |
| 15 | + * SQL. The counter VALUE always agreed — #6468 pinned that — so the fork was |
| 16 | + * rendering width alone. |
| 17 | + * |
| 18 | + * The maintainer's route-3 ruling on #6555 (2026-08-08) moved the default into |
| 19 | + * the contract: `DEFAULT_AUTONUMBER_FORMAT` / `resolveAutonumberFormat` in |
| 20 | + * `@objectstack/spec/data` (#7265), read by `driver-sql` (#7263) and, here, by |
| 21 | + * the engine. This file is the engine-side pin for the two behaviour moves that |
| 22 | + * lands with. |
| 23 | + * |
| 24 | + * ## Why this file exists at all — a measured coverage gap |
| 25 | + * |
| 26 | + * The drivers seat measured, while landing #7263, that NOT ONE test on either |
| 27 | + * side declared an empty-string format: `git grep "format: ''\|autonumberFormat: |
| 28 | + * ''"` returned nothing across all 8 driver-sql autonumber suites and all 7 |
| 29 | + * `engine-autonumber-*.test.ts` suites. `''` is precisely the input this half |
| 30 | + * moves (`??` respects an empty string, the resolver's truthiness rule does |
| 31 | + * not), so a green run of the pre-existing suites is not evidence about it in |
| 32 | + * EITHER direction. Every `''` case below was written for that gap. |
| 33 | + * |
| 34 | + * The counterpart pins live in |
| 35 | + * `packages/drivers/driver-sql/src/sql-driver-autonumber-suffix.test.ts` (the SQL |
| 36 | + * arm, `0011` since #7263) and |
| 37 | + * `packages/runtime/src/autonumber-seed-cross-side-parity.integration.test.ts` |
| 38 | + * (the two arms asserted against each other over one dataset). |
| 39 | + * |
| 40 | + * These tests drive a fake DRIVER (not a fake engine) whose `supports = {}`, so |
| 41 | + * the engine's own fallback owns the counter — the path the whole card is about. |
| 42 | + */ |
| 43 | + |
| 44 | +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
| 45 | +import { ObjectQL } from './engine'; |
| 46 | +import { SchemaRegistry } from './registry'; |
| 47 | +import type { IDataDriver } from '@objectstack/spec/contracts'; |
| 48 | + |
| 49 | +vi.mock('./registry', () => { |
| 50 | + const instance: any = { |
| 51 | + getObject: vi.fn(), |
| 52 | + resolveObject: vi.fn((n: string) => instance.getObject(n)), |
| 53 | + registerObject: vi.fn(), |
| 54 | + getObjectOwner: vi.fn(), |
| 55 | + registerNamespace: vi.fn(), |
| 56 | + registerKind: vi.fn(), |
| 57 | + registerItem: vi.fn(), |
| 58 | + registerApp: vi.fn(), |
| 59 | + installPackage: vi.fn(), |
| 60 | + reset: vi.fn(), |
| 61 | + metadata: { get: vi.fn(() => new Map()) }, |
| 62 | + }; |
| 63 | + function SchemaRegistry() { |
| 64 | + return instance; |
| 65 | + } |
| 66 | + Object.assign(SchemaRegistry, instance); |
| 67 | + return { |
| 68 | + SchemaRegistry, |
| 69 | + computeFQN: (_ns: string | undefined, name: string) => name, |
| 70 | + parseFQN: (fqn: string) => ({ namespace: undefined, shortName: fqn }), |
| 71 | + RESERVED_NAMESPACES: new Set(['base', 'system']), |
| 72 | + }; |
| 73 | +}); |
| 74 | + |
| 75 | +/** Date tokens render from the wall clock, so the clock is pinned. Only `Date`. */ |
| 76 | +const FIXED_NOW = new Date('2026-06-15T09:00:00Z'); |
| 77 | + |
| 78 | +/** |
| 79 | + * Evaluate the operators the seeding walk actually emits. Anything else throws |
| 80 | + * rather than being tolerated: silently ignoring an unknown operator would let a |
| 81 | + * bad query pass as a good one. |
| 82 | + */ |
| 83 | +function matches(row: Record<string, unknown>, where: any): boolean { |
| 84 | + if (where == null) return true; |
| 85 | + for (const [key, cond] of Object.entries(where)) { |
| 86 | + if (key === '$and') { |
| 87 | + if (!(cond as any[]).every((w) => matches(row, w))) return false; |
| 88 | + continue; |
| 89 | + } |
| 90 | + if (key.startsWith('$')) throw new Error(`fake driver: unsupported logical operator ${key}`); |
| 91 | + const v = row[key]; |
| 92 | + if (cond !== null && typeof cond === 'object' && !Array.isArray(cond)) { |
| 93 | + for (const [op, operand] of Object.entries(cond as Record<string, unknown>)) { |
| 94 | + if (op === '$startsWith') { |
| 95 | + if (typeof v !== 'string' || !v.startsWith(String(operand))) return false; |
| 96 | + } else if (op === '$gt') { |
| 97 | + if (!(String(v) > String(operand))) return false; |
| 98 | + } else if (op === '$eq') { |
| 99 | + if (v !== operand) return false; |
| 100 | + } else { |
| 101 | + throw new Error(`fake driver: unsupported operator ${op}`); |
| 102 | + } |
| 103 | + } |
| 104 | + } else if (v !== cond) { |
| 105 | + return false; |
| 106 | + } |
| 107 | + } |
| 108 | + return true; |
| 109 | +} |
| 110 | + |
| 111 | +function makeDriver(rows: Array<Record<string, unknown>>): IDataDriver { |
| 112 | + const driver: any = { |
| 113 | + name: 'memory', |
| 114 | + version: '0.0.0', |
| 115 | + // No `autonumber` support — this is exactly the engine fallback path. |
| 116 | + supports: {}, |
| 117 | + connect: vi.fn().mockResolvedValue(undefined), |
| 118 | + disconnect: vi.fn().mockResolvedValue(undefined), |
| 119 | + checkHealth: vi.fn().mockResolvedValue(true), |
| 120 | + execute: vi.fn(), |
| 121 | + find: vi.fn(async (_obj: string, ast: any) => { |
| 122 | + let out = rows.filter((r) => matches(r, ast?.where)); |
| 123 | + const orderBy = ast?.orderBy; |
| 124 | + if (Array.isArray(orderBy) && orderBy.length > 0) { |
| 125 | + const { field, order } = orderBy[0]; |
| 126 | + out = [...out].sort((a, b) => { |
| 127 | + const av = String(a[field] ?? ''); |
| 128 | + const bv = String(b[field] ?? ''); |
| 129 | + const cmp = av < bv ? -1 : av > bv ? 1 : 0; |
| 130 | + return order === 'desc' ? -cmp : cmp; |
| 131 | + }); |
| 132 | + } |
| 133 | + if (typeof ast?.limit === 'number') out = out.slice(0, ast.limit); |
| 134 | + return out.map((r) => ({ ...r })); |
| 135 | + }), |
| 136 | + findOne: vi.fn(), |
| 137 | + create: vi.fn(async (_obj: string, row: any) => ({ id: 'new1', ...row })), |
| 138 | + update: vi.fn(), |
| 139 | + delete: vi.fn(), |
| 140 | + count: vi.fn(), |
| 141 | + }; |
| 142 | + return driver as IDataDriver; |
| 143 | +} |
| 144 | + |
| 145 | +const rowId = (n: number) => `r${String(n).padStart(6, '0')}`; |
| 146 | + |
| 147 | +/** |
| 148 | + * A schema whose single autonumber field carries EXACTLY the given keys — the |
| 149 | + * point of most cases below is a key that is present and empty, which a |
| 150 | + * `format?: string` parameter cannot express. |
| 151 | + */ |
| 152 | +function schemaWith(declaration: Record<string, unknown>) { |
| 153 | + return { |
| 154 | + name: 'rec', |
| 155 | + fields: { |
| 156 | + title: { type: 'text' }, |
| 157 | + rec_no: { type: 'autonumber', required: true, ...declaration }, |
| 158 | + }, |
| 159 | + }; |
| 160 | +} |
| 161 | + |
| 162 | +/** Stored rows carrying pre-existing record numbers, in insertion order. */ |
| 163 | +const storedRows = (values: string[]) => |
| 164 | + values.map((v, i) => ({ id: rowId(i + 1), rec_no: v })); |
| 165 | + |
| 166 | +async function issueOne(schema: any, rows: Array<Record<string, unknown>> = []): Promise<string> { |
| 167 | + vi.mocked(SchemaRegistry.getObject).mockReturnValue(schema as any); |
| 168 | + const engine = new ObjectQL(); |
| 169 | + engine.registerDriver(makeDriver(rows) as any, true); |
| 170 | + await engine.init(); |
| 171 | + const result: any = await engine.insert('rec', { title: 'next' }); |
| 172 | + return result.rec_no; |
| 173 | +} |
| 174 | + |
| 175 | +describe('ObjectQL applyAutonumbers — the contract default for a format-less field (#6555)', () => { |
| 176 | + beforeEach(() => { |
| 177 | + vi.clearAllMocks(); |
| 178 | + vi.useFakeTimers({ toFake: ['Date'] }); |
| 179 | + vi.setSystemTime(FIXED_NOW); |
| 180 | + }); |
| 181 | + |
| 182 | + afterEach(() => { |
| 183 | + vi.useRealTimers(); |
| 184 | + }); |
| 185 | + |
| 186 | + // ----------------------------------------- (1) the primary behaviour move -- |
| 187 | + |
| 188 | + describe('an undeclared format renders `{0000}`, not the bare counter', () => { |
| 189 | + /** The bug report's own metadata: `{ rec_no: { type: 'autonumber' } }`. */ |
| 190 | + it('issues `0001` on an empty store', async () => { |
| 191 | + expect(await issueOne(schemaWith({}))).toBe('0001'); |
| 192 | + }); |
| 193 | + |
| 194 | + it('issues `0011` after stored `1` / `2` / `10` — the bug report verbatim', async () => { |
| 195 | + // The reproduction from #6555. Two facts in one assertion: the counter |
| 196 | + // still reads the stored BARE values (seeding is untouched — `{0000}` |
| 197 | + // renders prefix '' and suffix '', so the unanchored legacy reading still |
| 198 | + // applies and `'10'` beats `'2'` numerically), and the number it issues is |
| 199 | + // now RENDERED padded. `driver-sql` answers `0011` over the same rows. |
| 200 | + expect(await issueOne(schemaWith({}), storedRows(['1', '2', '10']))).toBe('0011'); |
| 201 | + }); |
| 202 | + |
| 203 | + it('the counter continues across calls, each rendered padded', async () => { |
| 204 | + vi.mocked(SchemaRegistry.getObject).mockReturnValue(schemaWith({}) as any); |
| 205 | + const engine = new ObjectQL(); |
| 206 | + engine.registerDriver(makeDriver([]) as any, true); |
| 207 | + await engine.init(); |
| 208 | + |
| 209 | + const a: any = await engine.insert('rec', { title: 'a' }); |
| 210 | + const b: any = await engine.insert('rec', { title: 'b' }); |
| 211 | + |
| 212 | + expect([a.rec_no, b.rec_no]).toEqual(['0001', '0002']); |
| 213 | + }); |
| 214 | + }); |
| 215 | + |
| 216 | + // ------------------------------- (2) the second, smaller move: `''` inputs -- |
| 217 | + |
| 218 | + /** |
| 219 | + * The gap the drivers seat measured (#7262, comment 5237739551): no suite on |
| 220 | + * either side declared an empty-string format, and `''` is the one input whose |
| 221 | + * behaviour this half moves. The engine read the key with `??`, which respects |
| 222 | + * an empty string; `resolveAutonumberFormat` counts anything that is not a |
| 223 | + * NON-EMPTY string as undeclared — driver-sql's long-standing truthiness rule, |
| 224 | + * adopted deliberately so the two sides agree. |
| 225 | + */ |
| 226 | + describe('an EMPTY declared format is undeclared, and resolves to the default', () => { |
| 227 | + it("`autonumberFormat: ''` renders `0001`, not a bare `1`", async () => { |
| 228 | + expect(await issueOne(schemaWith({ autonumberFormat: '' }))).toBe('0001'); |
| 229 | + }); |
| 230 | + |
| 231 | + it("`format: '' ` renders `0001`, not a bare `1`", async () => { |
| 232 | + expect(await issueOne(schemaWith({ format: '' }))).toBe('0001'); |
| 233 | + }); |
| 234 | + |
| 235 | + it("an empty canonical key no longer MASKS a declared `format` shorthand", async () => { |
| 236 | + // The sharpest edge of `??` → truthiness, and the only case where the two |
| 237 | + // rules disagree on something other than the default: `'' ?? 'D-{0000}'` |
| 238 | + // is `''` (nullish coalescing does not fall through an empty string), so |
| 239 | + // the engine used to render bare and ignore the shorthand entirely. The |
| 240 | + // resolver falls through to it. |
| 241 | + expect(await issueOne(schemaWith({ autonumberFormat: '', format: 'D-{0000}' }))).toBe('D-0001'); |
| 242 | + }); |
| 243 | + |
| 244 | + it('a key holding a non-string is undeclared too', async () => { |
| 245 | + // Unreachable through a parsed `FieldSchema`, reachable through the |
| 246 | + // unvalidated field documents both generators actually hold. The old code |
| 247 | + // fell to `''` here (`typeof fmt === 'string' ? fmt : ''`) and rendered |
| 248 | + // bare; the resolver answers the declared default, same as driver-sql. |
| 249 | + expect(await issueOne(schemaWith({ autonumberFormat: 42 }))).toBe('0001'); |
| 250 | + expect(await issueOne(schemaWith({ format: null }))).toBe('0001'); |
| 251 | + }); |
| 252 | + }); |
| 253 | + |
| 254 | + // -------------------------------------------------- (3) controls — UNMOVED -- |
| 255 | + |
| 256 | + /** |
| 257 | + * Drift guards for the surface this change must NOT touch. Stated plainly: |
| 258 | + * these cannot go red when the fix is reverted, so they are not evidence for |
| 259 | + * the moving leg above — they exist to catch a future edit that overreaches. |
| 260 | + */ |
| 261 | + describe('a DECLARED format is honoured exactly as written', () => { |
| 262 | + it('`D-{0000}` is unchanged', async () => { |
| 263 | + expect(await issueOne(schemaWith({ format: 'D-{0000}' }), storedRows(['D-0001', 'D-0002']))).toBe('D-0003'); |
| 264 | + }); |
| 265 | + |
| 266 | + it('the spec-canonical key still wins over the shorthand (#1603)', async () => { |
| 267 | + expect(await issueOne(schemaWith({ autonumberFormat: 'A-{000}', format: 'B-{000}' }))).toBe('A-001'); |
| 268 | + }); |
| 269 | + |
| 270 | + it('a slot-less format still renders a BARE counter — the escape hatch', async () => { |
| 271 | + // The documented way to keep an unpadded number after this change: declare |
| 272 | + // a format with no `{0..0}` slot. `autonumberFormat: ''` is NOT that |
| 273 | + // spelling (see above), which is the whole reason the changeset spells |
| 274 | + // this out for anyone who was relying on the engine's bare rendering. |
| 275 | + expect(await issueOne(schemaWith({ format: 'PRE-' }))).toBe('PRE-1'); |
| 276 | + }); |
| 277 | + |
| 278 | + it('a driver that owns autonumber is untouched — the engine fills nothing', async () => { |
| 279 | + vi.mocked(SchemaRegistry.getObject).mockReturnValue(schemaWith({}) as any); |
| 280 | + const driver: any = makeDriver([]); |
| 281 | + driver.supports = { autonumber: true }; |
| 282 | + const engine = new ObjectQL(); |
| 283 | + engine.registerDriver(driver, true); |
| 284 | + await engine.init(); |
| 285 | + |
| 286 | + await engine.insert('rec', { title: 'next' }); |
| 287 | + |
| 288 | + // The driver's own sequence answers; the engine hands it an empty slot. |
| 289 | + expect(driver.create.mock.calls[0][1].rec_no).toBeUndefined(); |
| 290 | + }); |
| 291 | + }); |
| 292 | +}); |
0 commit comments