|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// GOLDEN REGRESSION — #21790: "a field reorder made in the object designer |
| 4 | +// survives draft save -> publish -> reload", exercised end-to-end through the |
| 5 | +// real HTTP + metadata stack on the showcase. |
| 6 | +// |
| 7 | +// `ObjectSchema.fields` is a name-keyed map whose traversal order the spec |
| 8 | +// declares to BE the field order. The content hash sorted every map, so a pure |
| 9 | +// reorder hashed equal to the published row, `SysMetadataRepository.put`'s |
| 10 | +// no-op short-circuit read the publish as "unchanged", and the draft was |
| 11 | +// drained — the publish answered success, the served object kept the old |
| 12 | +// order, and the draft was gone. Reproduced on 17.6.0 and the 17.7 |
| 13 | +// pre-release. |
| 14 | +// |
| 15 | +// The second block is the upgrade half. Every object row written before the |
| 16 | +// fix carries the ORDER-BLIND stamp, and that stamp is the version token its |
| 17 | +// readers hold. It is accepted as written: an identical save must record no |
| 18 | +// change, a receipt's token must still be honoured by `If-Match`, and a reorder |
| 19 | +// INTO sorted order — whose current hash IS the stale stamp — must not be |
| 20 | +// dropped. |
| 21 | + |
| 22 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 23 | +import showcaseStack from '@objectstack/example-showcase'; |
| 24 | +import { hashSpec } from '@objectstack/metadata-core'; |
| 25 | +import { bootStack, type VerifyStack } from '@objectstack/verify'; |
| 26 | + |
| 27 | +const OBJ = 'dogfood_field_reorder'; |
| 28 | +const SYSTEM_CTX = { isSystem: true }; |
| 29 | + |
| 30 | +const FIELDS: Record<string, { type: string; label: string }> = { |
| 31 | + title: { type: 'text', label: 'Title' }, |
| 32 | + amount: { type: 'number', label: 'Amount' }, |
| 33 | + due: { type: 'date', label: 'Due' }, |
| 34 | +}; |
| 35 | +const DECLARED = ['title', 'amount', 'due']; // deliberately not sorted |
| 36 | +const MOVED = ['due', 'title', 'amount']; // the designer drag |
| 37 | +const SORTED = ['amount', 'due', 'title']; // the order the old hash sorted to |
| 38 | + |
| 39 | +/** The designer's document, its `fields` in `order`. */ |
| 40 | +const objectBody = (order: readonly string[]) => ({ |
| 41 | + name: OBJ, |
| 42 | + label: 'Field Reorder', |
| 43 | + // The runtime object door refuses an unauthored OWD at publish (#8310). |
| 44 | + sharingModel: 'private', |
| 45 | + fields: Object.fromEntries(order.map((k) => [k, FIELDS[k]])), |
| 46 | +}); |
| 47 | + |
| 48 | +interface Row { |
| 49 | + id: string; |
| 50 | + metadata: string; |
| 51 | + checksum: string | null; |
| 52 | +} |
| 53 | +interface Ql { |
| 54 | + find(object: string, query: Record<string, unknown>): Promise<Row[]>; |
| 55 | + update(object: string, data: Record<string, unknown>, opts: Record<string, unknown>): Promise<unknown>; |
| 56 | +} |
| 57 | + |
| 58 | +describe('dogfood: an object designer field reorder publishes and reads back in the new order (#21790)', () => { |
| 59 | + let stack: VerifyStack; |
| 60 | + let token: string; |
| 61 | + let ql: Ql; |
| 62 | + |
| 63 | + beforeAll(async () => { |
| 64 | + stack = await bootStack(showcaseStack); |
| 65 | + token = await stack.signIn(); |
| 66 | + ql = (await stack.kernel.getServiceAsync('objectql')) as unknown as Ql; |
| 67 | + }, 90_000); |
| 68 | + |
| 69 | + afterAll(async () => { |
| 70 | + await stack?.stop(); |
| 71 | + }); |
| 72 | + |
| 73 | + /** `GET /meta/object/:name` — the authored fields, in the order served. */ |
| 74 | + const servedOrder = async (): Promise<string[]> => { |
| 75 | + const res = await stack.apiAs(token, 'GET', `/meta/object/${OBJ}`); |
| 76 | + expect(res.status).toBe(200); |
| 77 | + const body = (await res.json()) as { type?: string; name?: string; item?: { fields?: object } }; |
| 78 | + expect(body).toMatchObject({ type: 'object', name: OBJ }); |
| 79 | + // The registry may serve injected system columns beside the authored ones; |
| 80 | + // the order under test is the authored fields' relative order. |
| 81 | + return Object.keys(body.item?.fields ?? {}).filter((k) => k in FIELDS); |
| 82 | + }; |
| 83 | + const saveDraft = (order: readonly string[]) => |
| 84 | + stack.apiAs(token, 'PUT', `/meta/object/${OBJ}?mode=draft`, objectBody(order)); |
| 85 | + const publish = () => stack.apiAs(token, 'POST', `/meta/object/${OBJ}/publish`, {}); |
| 86 | + const activeRow = async (): Promise<Row> => { |
| 87 | + const rows = await ql.find('sys_metadata', { |
| 88 | + where: { type: 'object', name: OBJ, state: 'active' }, |
| 89 | + context: SYSTEM_CTX, |
| 90 | + }); |
| 91 | + expect(rows).toHaveLength(1); |
| 92 | + return rows[0]!; |
| 93 | + }; |
| 94 | + const historyRows = async () => |
| 95 | + (await ql.find('sys_metadata_history', { where: { type: 'object', name: OBJ }, context: SYSTEM_CTX })).length; |
| 96 | + |
| 97 | + describe('the designer round trip', () => { |
| 98 | + it('creates and publishes the object in its declared order', async () => { |
| 99 | + expect((await saveDraft(DECLARED)).status).toBe(200); |
| 100 | + expect((await publish()).status).toBe(200); |
| 101 | + expect(await servedOrder()).toEqual(DECLARED); |
| 102 | + }); |
| 103 | + |
| 104 | + it('a pure reorder, saved as a draft and published, is served — and still served on reload', async () => { |
| 105 | + expect((await saveDraft(MOVED)).status).toBe(200); |
| 106 | + // The defect: this answered 200 and dropped the reorder. |
| 107 | + expect((await publish()).status).toBe(200); |
| 108 | + expect(await servedOrder()).toEqual(MOVED); |
| 109 | + // The designer's reload is a second read of the same door. |
| 110 | + expect(await servedOrder()).toEqual(MOVED); |
| 111 | + // …and the stored row holds it, not just a registry entry. |
| 112 | + const stored = JSON.parse((await activeRow()).metadata) as { fields: object }; |
| 113 | + expect(Object.keys(stored.fields)).toEqual(MOVED); |
| 114 | + }); |
| 115 | + }); |
| 116 | + |
| 117 | + describe('an object row stamped before the fix (the order-blind stamp)', () => { |
| 118 | + let legacyStamp: string; |
| 119 | + let receipt: string; |
| 120 | + |
| 121 | + it('rewinds the stored stamp to the one the order-blind rule wrote', async () => { |
| 122 | + const row = await activeRow(); |
| 123 | + const stored = JSON.parse(row.metadata) as Record<string, unknown>; |
| 124 | + legacyStamp = hashSpec(stored); |
| 125 | + // A real stale stamp: the current rule hashes these bytes differently. |
| 126 | + expect(row.checksum).toBe(hashSpec(stored, 'object')); |
| 127 | + expect(legacyStamp).not.toBe(row.checksum); |
| 128 | + await ql.update('sys_metadata', { id: row.id, checksum: legacyStamp }, { context: SYSTEM_CTX }); |
| 129 | + expect((await activeRow()).checksum).toBe(legacyStamp); |
| 130 | + expect(await servedOrder()).toEqual(MOVED); |
| 131 | + }); |
| 132 | + |
| 133 | + it('an identical save records no change and leaves the stamp as written', async () => { |
| 134 | + const before = await historyRows(); |
| 135 | + const stored = JSON.parse((await activeRow()).metadata) as Record<string, unknown>; |
| 136 | + const res = await stack.apiAs(token, 'PUT', `/meta/object/${OBJ}`, stored); |
| 137 | + expect(res.status).toBe(200); |
| 138 | + receipt = ((await res.json()) as { version: string }).version; |
| 139 | + expect(typeof receipt).toBe('string'); |
| 140 | + expect(await historyRows()).toBe(before); |
| 141 | + expect((await activeRow()).checksum).toBe(legacyStamp); |
| 142 | + }); |
| 143 | + |
| 144 | + it('the receipt\'s token is honoured by If-Match, and a reorder into sorted order is published', async () => { |
| 145 | + // The trap: the stale stamp IS the sorted form's current hash. |
| 146 | + const stored = JSON.parse((await activeRow()).metadata) as { fields: Record<string, unknown> }; |
| 147 | + const sorted = { ...stored, fields: Object.fromEntries(SORTED.map((k) => [k, stored.fields[k]])) }; |
| 148 | + expect(hashSpec(sorted, 'object')).toBe(legacyStamp); |
| 149 | + |
| 150 | + const before = await historyRows(); |
| 151 | + const res = await stack.api(`/meta/object/${OBJ}`, { |
| 152 | + method: 'PUT', |
| 153 | + headers: { |
| 154 | + 'Content-Type': 'application/json', |
| 155 | + Authorization: `Bearer ${token}`, |
| 156 | + 'If-Match': `"${receipt}"`, |
| 157 | + }, |
| 158 | + body: JSON.stringify(sorted), |
| 159 | + }); |
| 160 | + expect(res.status).toBe(200); |
| 161 | + expect(await servedOrder()).toEqual(SORTED); |
| 162 | + expect(await historyRows()).toBe(before + 1); |
| 163 | + const written = JSON.parse((await activeRow()).metadata) as { fields: object }; |
| 164 | + expect(Object.keys(written.fields)).toEqual(SORTED); |
| 165 | + }); |
| 166 | + }); |
| 167 | +}); |
0 commit comments