|
1 | 1 | // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | 3 | /** |
4 | | - * [#20701] A declared field whose column is missing (schema drift): the import |
5 | | - * row answers what `POST /api/v1/data/:object` answers, on the commit and on |
6 | | - * the async job's rows. Read through the public doors over a REAL |
7 | | - * {@link ObjectQL} + {@link ObjectStackProtocolImplementation} + SQLite |
8 | | - * `:memory:`, with the platform's own `sys_import_job`. |
| 4 | + * [#20701] A write the driver refuses on a column: the import row answers what |
| 5 | + * `POST /api/v1/data/:object` answers, on the commit and on the async job's |
| 6 | + * rows. Read through the public doors over a REAL {@link ObjectQL} + |
| 7 | + * {@link ObjectStackProtocolImplementation} + SQLite `:memory:`, with the |
| 8 | + * platform's own `sys_import_job`. Three refusals, each classified by |
| 9 | + * `mapDataError`, the mapper the door uses (`toFailedResult` adopts its |
| 10 | + * verdict): |
9 | 11 | * |
10 | | - * Drift is made the way it happens: the object declares `late` after the DDL |
11 | | - * ran, with no re-sync. The engine's declared-field door passes `late`, since it |
12 | | - * is declared, and the driver refuses the write. |
| 12 | + * 1. **Schema drift.** The object declares `late` after the DDL ran, with no |
| 13 | + * re-sync. The engine's declared-field door passes `late`, since it is |
| 14 | + * declared, and the driver refuses the write. |
| 15 | + * 2. **NOT NULL.** `must` is `storage: { notNull: true }` and not `required`, |
| 16 | + * so the record validator lets a missing value through (ADR-0113) and the |
| 17 | + * driver refuses it. |
| 18 | + * 3. **Unique conflict.** `code` is `unique: true` and the row repeats `X`. |
13 | 19 | * |
14 | | - * Measured on the base (`67c1b11a20`): |
| 20 | + * Measured on the bases (`67c1b11a20` for 1, `75519e1c0a` for 2 and 3): |
15 | 21 | * |
16 | | - * | door | answer for `late` | |
17 | | - * |:--|:--| |
18 | | - * | `POST /data/:object` | `400 INVALID_FIELD`, `field: 'late'`, "The database table of object … has no column for field 'late'. …" | |
19 | | - * | import commit, insert | failed, `code: 'SQLITE_ERROR'`, `table proj_… has no column named late`, no `field` | |
20 | | - * | import commit, upsert onto an existing row | failed, `code: 'SQLITE_ERROR'`, `no such column: late`, no `field` | |
21 | | - * | async import job, results route | failed, `code: 'SQLITE_ERROR'`, the driver's text, no `field` | |
| 22 | + * | door | drift (`late`) | NOT NULL (`must`) | unique (`code`) | |
| 23 | + * |:--|:--|:--|:--| |
| 24 | + * | `POST /data/:object` | `400 INVALID_FIELD`, `field: 'late'`, "The database table of object … has no column for field 'late'. …" | `400 VALIDATION_FAILED`, `fields: [{ field: 'must', code: 'required' }]`, "must is required", a `hint` | `409 UNIQUE_VIOLATION`, `field: 'code'`, "A record with this code already exists", the engine's sentence as `developerMessage` | |
| 25 | + * | import commit row, before | `SQLITE_ERROR`, the driver's text, no `field` | `SQLITE_CONSTRAINT_NOTNULL`, "must is required.", no `field` | `UNIQUE_VIOLATION`, `field: 'code'`, the engine's sentence | |
| 26 | + * | async import job row, before | the same as the commit | the same as the commit | the same as the commit | |
22 | 27 | * |
23 | | - * The row now takes the door's verdict when the door's verdict is |
24 | | - * `INVALID_FIELD` (`toFailedResult` asks `mapDataError`, the mapper the door |
25 | | - * uses). The unique-conflict and NOT NULL rows are controls: the door answers |
26 | | - * them in other words (see `toFailedResult`'s docblock), and this change leaves |
27 | | - * both rows as they were. Their pins say "unchanged by this card", not "right". |
28 | | - * A card that converges them updates these pins on purpose. |
| 28 | + * Each row now carries the door's `code`, `field` and sentence. For NOT NULL |
| 29 | + * the door's `required` finding wins over its top-level `VALIDATION_FAILED`, |
| 30 | + * the rule the row applies to the engine's own findings, so the row reads |
| 31 | + * `code: 'required'`, `field: 'must'` — the row a metadata-`required` field |
| 32 | + * already gets — and no driver dialect's code reaches the wire (ADR-0112). No |
| 33 | + * key is added: the door's `hint`, `object` and `developerMessage` stay off the |
| 34 | + * row, whose keys are `ImportRowResultSchema`'s. |
29 | 35 | * |
30 | 36 | * ⚠️ Not pinned: the dry run. `engine.validate` reads metadata, never the |
31 | | - * table, so a drifted column previews as `ok` / `created` (measured on the |
32 | | - * base, and unchanged here). That is the gap between the preview and the |
33 | | - * commit that the fix report names, not a behaviour this file vouches for. |
| 37 | + * table, and does not judge `storage.notNull` or uniqueness, so all three rows |
| 38 | + * preview as `ok` / `created` (measured on the bases, and unchanged here). That |
| 39 | + * gap between the preview and the commit is not a behaviour this file vouches |
| 40 | + * for; triage settled the drift case as the migration door's. |
34 | 41 | */ |
35 | 42 |
|
36 | 43 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; |
@@ -175,26 +182,97 @@ describe('[#20701] the async import job\'s rows carry the same answer', () => { |
175 | 182 | }); |
176 | 183 | }); |
177 | 184 |
|
178 | | -describe('[#20701] controls: a unique conflict and a NOT NULL row keep the answers they had', () => { |
| 185 | +/** The row's whole key set: the door's extra keys (`hint`, `object`, `developerMessage`) are not on it. */ |
| 186 | +const ROW_KEYS = ['action', 'code', 'error', 'field', 'ok', 'row']; |
| 187 | + |
| 188 | +/** Dialect codes for a NOT NULL or unique refusal (SQLite, Postgres SQLSTATE, MySQL): none reaches a row. */ |
| 189 | +const DIALECT_CODE = /^(SQLITE_|ER_)|^\d{5}$/; |
| 190 | + |
| 191 | +function expectRowIsDoor(row: any, door: { code: unknown; field: unknown; error: unknown }, rowNo: number) { |
| 192 | + expect(row, JSON.stringify(row)).toMatchObject({ row: rowNo, ok: false, action: 'failed', ...door }); |
| 193 | + expect(Object.keys(row).sort()).toEqual(ROW_KEYS); |
| 194 | + expect(String(row.code)).not.toMatch(DIALECT_CODE); |
| 195 | +} |
| 196 | + |
| 197 | +describe('[#20701] a NOT NULL refusal fails the row with the create door\'s `required` finding', () => { |
| 198 | + let b: Boot; |
| 199 | + beforeEach(async () => { b = await boot(); }); |
| 200 | + |
| 201 | + /** What the door answers for a record missing `must`, read as the row renders a finding. */ |
| 202 | + const notNullDoor = async () => { |
| 203 | + const door = await b.call('POST', '/api/v1/data/:object', { title: 't' }); |
| 204 | + expect(door.status, JSON.stringify(door.body)).toBe(400); |
| 205 | + expect(door.body).toMatchObject({ code: 'VALIDATION_FAILED', fields: [{ field: 'must', code: 'required' }] }); |
| 206 | + return { code: door.body.fields[0].code, field: door.body.fields[0].field, error: door.body.error }; |
| 207 | + }; |
| 208 | + |
| 209 | + it('insert: the bulk create path', async () => { |
| 210 | + const door = await notNullDoor(); |
| 211 | + const r = await b.importRows([{ title: 't' }, { id: 'w1', title: 'control', must: 'm' }]); |
| 212 | + expect(r.status, JSON.stringify(r.body)).toBe(200); |
| 213 | + expectRowIsDoor(r.body.results[0], door, 1); |
| 214 | + expect(r.body.results[0]).toMatchObject({ code: 'required', field: 'must' }); |
| 215 | + expect(r.body.results[1]).toMatchObject({ row: 2, ok: true, action: 'created', id: 'w1' }); |
| 216 | + expect(r.body).toMatchObject({ ok: 1, errors: 1, created: 1 }); |
| 217 | + }); |
| 218 | + |
| 219 | + it('upsert with no match: the create half of an upsert', async () => { |
| 220 | + const door = await notNullDoor(); |
| 221 | + const r = await b.importRows([{ id: 'n1', title: 't' }], { writeMode: 'upsert' }); |
| 222 | + expect(r.status, JSON.stringify(r.body)).toBe(200); |
| 223 | + expectRowIsDoor(r.body.results[0], door, 1); |
| 224 | + expect(await b.engine.findOne(OBJECT, { where: { id: 'n1' } })).toBeNull(); |
| 225 | + }); |
| 226 | +}); |
| 227 | + |
| 228 | +describe('[#20701] a unique conflict fails the row with the create door\'s sentence', () => { |
179 | 229 | let b: Boot; |
180 | 230 | beforeEach(async () => { b = await boot(); }); |
181 | 231 |
|
182 | | - it('unique conflict: UNIQUE_VIOLATION naming the column, with the engine\'s sentence', async () => { |
| 232 | + it('insert: UNIQUE_VIOLATION naming the column, in the words of `POST /data/:object`', async () => { |
183 | 233 | const door = await b.call('POST', '/api/v1/data/:object', { title: 't', must: 'm', code: 'X' }); |
184 | | - expect(door.status).toBe(409); |
185 | | - const r = await b.importRows([{ title: 't', must: 'm', code: 'X' }]); |
186 | | - expect(r.body.results[0]).toMatchObject({ |
187 | | - row: 1, ok: false, action: 'failed', code: 'UNIQUE_VIOLATION', field: 'code', |
188 | | - // The row's sentence is the engine's, which the door ships as `developerMessage`. |
189 | | - error: door.body.developerMessage, |
190 | | - }); |
| 234 | + expect(door.status, JSON.stringify(door.body)).toBe(409); |
| 235 | + expect(door.body).toMatchObject({ code: 'UNIQUE_VIOLATION', field: 'code' }); |
| 236 | + const r = await b.importRows([{ title: 't', must: 'm', code: 'X' }, { id: 'w1', title: 'control', must: 'm', code: 'Z' }]); |
| 237 | + expectRowIsDoor(r.body.results[0], { code: door.body.code, field: door.body.field, error: door.body.error }, 1); |
| 238 | + // The engine's sentence is the door's `developerMessage`, and it is no longer the row's. |
| 239 | + expect(r.body.results[0].error).not.toBe(door.body.developerMessage); |
| 240 | + expect(r.body.results[1]).toMatchObject({ row: 2, ok: true, action: 'created', id: 'w1' }); |
191 | 241 | }); |
192 | 242 |
|
193 | | - it('NOT NULL: the driver\'s code, with no field (the door says VALIDATION_FAILED with a `required` finding)', async () => { |
194 | | - const door = await b.call('POST', '/api/v1/data/:object', { title: 't' }); |
195 | | - expect(door.body).toMatchObject({ code: 'VALIDATION_FAILED' }); |
196 | | - const r = await b.importRows([{ title: 't' }]); |
197 | | - expect(r.body.results[0]).toMatchObject({ row: 1, ok: false, action: 'failed', code: 'SQLITE_CONSTRAINT_NOTNULL' }); |
198 | | - expect(r.body.results[0]).not.toHaveProperty('field'); |
| 243 | + it('upsert onto an existing row: the update path, in the words of `PATCH /data/:object/:id`', async () => { |
| 244 | + await b.engine.insert(OBJECT, { id: 'e2', title: 'other', code: 'Y', must: 'm' } as any); |
| 245 | + const door = await b.call('PATCH', '/api/v1/data/:object/:id', { code: 'X' }, { object: OBJECT, id: 'e2' }); |
| 246 | + expect(door.status, JSON.stringify(door.body)).toBe(409); |
| 247 | + const r = await b.importRows([{ id: 'e2', code: 'X' }], { writeMode: 'upsert' }); |
| 248 | + expectRowIsDoor(r.body.results[0], { code: door.body.code, field: door.body.field, error: door.body.error }, 1); |
| 249 | + expect(await b.engine.findOne(OBJECT, { where: { id: 'e2' } })).toMatchObject({ code: 'Y' }); |
| 250 | + }); |
| 251 | +}); |
| 252 | + |
| 253 | +describe('[#20701] the async import job\'s rows carry the door\'s NOT NULL and unique answers', () => { |
| 254 | + it('the results route reports both, and the writable row is created', async () => { |
| 255 | + const b = await boot(); |
| 256 | + const notNull = await b.call('POST', '/api/v1/data/:object', { title: 't' }); |
| 257 | + const unique = await b.call('POST', '/api/v1/data/:object', { title: 't', must: 'm', code: 'X' }); |
| 258 | + const created = await b.call('POST', '/api/v1/data/:object/import/jobs', { |
| 259 | + format: 'json', dryRun: false, |
| 260 | + rows: [{ title: 't' }, { title: 't', must: 'm', code: 'X' }, { id: 'w3', title: 'control', must: 'm' }], |
| 261 | + }); |
| 262 | + expect(created.status, JSON.stringify(created.body)).toBe(201); |
| 263 | + const jobId = String(created.body.jobId); |
| 264 | + let progress: { status: number; body: any } | undefined; |
| 265 | + for (let i = 0; i < 200; i++) { |
| 266 | + progress = await b.call('GET', '/api/v1/data/import/jobs/:jobId', undefined, { jobId }); |
| 267 | + if (['succeeded', 'failed', 'cancelled'].includes(progress.body?.status)) break; |
| 268 | + await new Promise((r) => setTimeout(r, 5)); |
| 269 | + } |
| 270 | + expect(progress?.body).toMatchObject({ status: 'succeeded', created: 1, errors: 2 }); |
| 271 | + const rows: any[] = (await b.call('GET', '/api/v1/data/import/jobs/:jobId/results', undefined, { jobId })).body.results; |
| 272 | + expectRowIsDoor(rows.find((r) => r.row === 1), { |
| 273 | + code: notNull.body.fields[0].code, field: notNull.body.fields[0].field, error: notNull.body.error, |
| 274 | + }, 1); |
| 275 | + expectRowIsDoor(rows.find((r) => r.row === 2), { code: unique.body.code, field: unique.body.field, error: unique.body.error }, 2); |
| 276 | + expect(rows.find((r) => r.row === 3)).toMatchObject({ row: 3, ok: true, action: 'created', id: 'w3' }); |
199 | 277 | }); |
200 | 278 | }); |
0 commit comments