|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #13155 — conformance gate: the body `deleteMetaItem` really returns must |
| 5 | + * parse through `DeleteMetaItemResponseSchema` with NOTHING stripped. |
| 6 | + * |
| 7 | + * This is the producer side of the declaration. The spec-side suite |
| 8 | + * (`packages/spec/src/api/protocol.test.ts`) pins what the schema says; this |
| 9 | + * one pins that the schema still matches what the code emits, driving the REAL |
| 10 | + * protocol against a REAL ObjectQL engine. The two together are what makes |
| 11 | + * "declared = returned" checkable — a future field added to the response, or an |
| 12 | + * existing one dropped, turns this red instead of silently vanishing at parse. |
| 13 | + * |
| 14 | + * Why the REST layer needs no separate case: the route hands this exact object |
| 15 | + * to `res.json()` verbatim (`rest-server.ts`, `DELETE /meta/:type/:name`), so |
| 16 | + * the protocol return IS the wire body. |
| 17 | + * |
| 18 | + * The exact shape of the two sibling gates on the same door |
| 19 | + * (`save-meta-response-conformance.test.ts` #5745, |
| 20 | + * `publish-meta-response-conformance.test.ts` #7294) — deliberately, because |
| 21 | + * the third verb is the same class of surface and its declaration was the |
| 22 | + * short one. Before this card the first assertion below was red in the same |
| 23 | + * quiet way theirs were: `safeParse` SUCCEEDED and `seq` / |
| 24 | + * `projectionApplied` were dropped from the parsed result, so the "stripped |
| 25 | + * keys" set was non-empty. That is the direction it must never drift back to. |
| 26 | + * |
| 27 | + * ## The delete door's own surface: FOUR success returns, not one |
| 28 | + * |
| 29 | + * Both siblings have a single success return that always sets `seq`, so they |
| 30 | + * declare it REQUIRED. `deleteMetaItem` has four, and only one of them appends |
| 31 | + * a history event — which is the whole reason `seq` is `.optional()` here and |
| 32 | + * why each branch needs its own case: |
| 33 | + * |
| 34 | + * 1. repository path, row deleted → `seq`, plus `projectionApplied` when a |
| 35 | + * projector is registered. The only branch that carries either key. |
| 36 | + * 2. repository path, no row ("nothing to delete") → a success/no-op. |
| 37 | + * 3. legacy raw-engine path, row deleted (#5264, deliberately alive) → no |
| 38 | + * history row, no watch event, so no `seq` even though a row really went |
| 39 | + * away. The branch that would make a REQUIRED `seq` a false contract. |
| 40 | + * 4. legacy raw-engine path, no row → the same no-op as (2). |
| 41 | + * |
| 42 | + * Cases 2 and 3 are the ones a mirror-the-siblings declaration gets wrong if |
| 43 | + * it is written from the siblings' shape instead of from this producer. |
| 44 | + */ |
| 45 | +import { describe, it, expect } from 'vitest'; |
| 46 | +import type { ServiceObject } from '@objectstack/spec/data'; |
| 47 | +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; |
| 48 | +import { DeleteMetaItemResponseSchema } from '@objectstack/spec/api'; |
| 49 | +import { ObjectQL } from './engine.js'; |
| 50 | + |
| 51 | +const sysMetadataObject: ServiceObject = { |
| 52 | + name: 'sys_metadata', |
| 53 | + label: 'System Metadata', |
| 54 | + fields: { |
| 55 | + id: { name: 'id', label: 'ID', type: 'text' as const }, |
| 56 | + type: { name: 'type', label: 'Type', type: 'text' as const, required: true }, |
| 57 | + name: { name: 'name', label: 'Name', type: 'text' as const, required: true }, |
| 58 | + organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, |
| 59 | + // [#8682] Part of the real row's uniqueness key `(type, name, |
| 60 | + // organization_id, package_id)` and written by `SysMetadataRepository` |
| 61 | + // — the declared-field door judges the payload against this map, so |
| 62 | + // omitting it here would be a fixture defect, not a simplification. |
| 63 | + package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, |
| 64 | + metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, |
| 65 | + checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, |
| 66 | + state: { name: 'state', label: 'State', type: 'text' as const }, |
| 67 | + version: { name: 'version', label: 'Version', type: 'number' as const }, |
| 68 | + created_at: { name: 'created_at', label: 'Created', type: 'datetime' as const }, |
| 69 | + updated_at: { name: 'updated_at', label: 'Updated', type: 'datetime' as const }, |
| 70 | + }, |
| 71 | +}; |
| 72 | + |
| 73 | +function makeMemoryDriver() { |
| 74 | + const stores = new Map<string, Map<string, Record<string, unknown>>>(); |
| 75 | + const storeFor = (obj: string) => { |
| 76 | + let s = stores.get(obj); |
| 77 | + if (!s) { s = new Map(); stores.set(obj, s); } |
| 78 | + return s; |
| 79 | + }; |
| 80 | + let nextId = 0; |
| 81 | + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real |
| 82 | + // driver ANDs them — see #7620 for what the short-circuiting shape cost. |
| 83 | + const matchesWhere = (row: Record<string, unknown>, where: any): boolean => { |
| 84 | + if (!where || typeof where !== 'object') return true; |
| 85 | + for (const [k, v] of Object.entries(where)) { |
| 86 | + if (k === '$and' && Array.isArray(v)) { |
| 87 | + if (!v.every((w: any) => matchesWhere(row, w))) return false; |
| 88 | + continue; |
| 89 | + } |
| 90 | + if (k === '$or' && Array.isArray(v)) { |
| 91 | + if (!v.some((w: any) => matchesWhere(row, w))) return false; |
| 92 | + continue; |
| 93 | + } |
| 94 | + if (k.startsWith('$')) continue; |
| 95 | + const rowVal = row[k]; |
| 96 | + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; |
| 97 | + const a = rowVal === undefined ? null : rowVal; |
| 98 | + const b = expected === undefined ? null : expected; |
| 99 | + if (a !== b) return false; |
| 100 | + } |
| 101 | + return true; |
| 102 | + }; |
| 103 | + const driver: any = { |
| 104 | + name: 'memory', version: '0.0.0', supports: {} as any, |
| 105 | + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, |
| 106 | + async execute() { return null; }, |
| 107 | + async find(object: string, ast: any) { |
| 108 | + const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); |
| 109 | + // Hold the caller's bound, AFTER the filter and by PRESENCE — a |
| 110 | + // limit-blind double answers more rows than the caller asked for |
| 111 | + // and every assertion about a bounded read passes for the wrong |
| 112 | + // reason (`check:objectql-double-limit`). The two sibling |
| 113 | + // conformance files predate that gate and sit in its shrink-only |
| 114 | + // baseline; a new double conforms. |
| 115 | + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; |
| 116 | + }, |
| 117 | + async findOne(object: string, ast: any) { |
| 118 | + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; |
| 119 | + return null; |
| 120 | + }, |
| 121 | + async create(object: string, data: Record<string, unknown>) { |
| 122 | + nextId += 1; |
| 123 | + const id = (data.id as string) ?? `r_${nextId}`; |
| 124 | + const row = { ...data, id }; |
| 125 | + storeFor(object).set(id, row); |
| 126 | + return row; |
| 127 | + }, |
| 128 | + async update(object: string, id: string, data: Record<string, unknown>) { |
| 129 | + const s = storeFor(object); |
| 130 | + const cur = s.get(id); |
| 131 | + if (!cur) throw new Error(`not found: ${object}/${id}`); |
| 132 | + const updated = { ...cur, ...data, id }; |
| 133 | + s.set(id, updated); |
| 134 | + return updated; |
| 135 | + }, |
| 136 | + async upsert(object: string, data: Record<string, unknown>) { |
| 137 | + const id = data.id as string | undefined; |
| 138 | + if (id && storeFor(object).has(id)) return this.update(object, id, data); |
| 139 | + return this.create(object, data); |
| 140 | + }, |
| 141 | + async delete(object: string, id: string) { return storeFor(object).delete(id); }, |
| 142 | + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, |
| 143 | + async bulkCreate(object: string, rows: Record<string, unknown>[]) { |
| 144 | + return Promise.all(rows.map((r) => this.create(object, r))); |
| 145 | + }, |
| 146 | + async bulkUpdate() { return []; }, async bulkDelete() {}, |
| 147 | + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, |
| 148 | + async commit() {}, async rollback() {}, |
| 149 | + }; |
| 150 | + return { driver, stores }; |
| 151 | +} |
| 152 | + |
| 153 | +async function makeProtocol() { |
| 154 | + const engine = new ObjectQL(); |
| 155 | + const { driver, stores } = makeMemoryDriver(); |
| 156 | + engine.registerDriver(driver, true); |
| 157 | + await engine.init(); |
| 158 | + engine.registry.registerObject(sysMetadataObject, 'test-package'); |
| 159 | + return { p: new ObjectStackProtocolImplementation(engine), stores }; |
| 160 | +} |
| 161 | + |
| 162 | +// [#7741] the inline arm requires the object binding pair |
| 163 | +const viewBody = (label: string) => ({ |
| 164 | + name: 'cases', type: 'grid', label, columns: ['id'], object: 'case', viewKind: 'list', |
| 165 | +}); |
| 166 | + |
| 167 | +const ORG = 'org_x'; |
| 168 | + |
| 169 | +/** Keys the producer emitted that the schema refused to carry through. */ |
| 170 | +function strippedKeys(raw: Record<string, unknown>): string[] { |
| 171 | + const parsed = DeleteMetaItemResponseSchema.parse(raw) as Record<string, unknown>; |
| 172 | + return Object.keys(raw).filter((k) => !(k in parsed)); |
| 173 | +} |
| 174 | + |
| 175 | +describe('deleteMetaItem response conforms to DeleteMetaItemResponseSchema (#13155)', () => { |
| 176 | + it('repository path, row deleted: parses green, strips nothing, and carries seq', async () => { |
| 177 | + const { p } = await makeProtocol(); |
| 178 | + await (p as any).saveMetaItem({ |
| 179 | + type: 'view', name: 'cases', organizationId: ORG, item: viewBody('A'), |
| 180 | + }); |
| 181 | + |
| 182 | + const raw: any = await (p as any).deleteMetaItem({ |
| 183 | + type: 'view', name: 'cases', organizationId: ORG, |
| 184 | + }); |
| 185 | + |
| 186 | + // The assertion that was red before the declaration: `seq` rode the |
| 187 | + // wire and the schema dropped it on the floor. |
| 188 | + expect(Object.keys(raw)).toContain('seq'); |
| 189 | + expect(strippedKeys(raw)).toEqual([]); |
| 190 | + |
| 191 | + const parsed = DeleteMetaItemResponseSchema.parse(raw); |
| 192 | + expect(parsed.success).toBe(true); |
| 193 | + expect(parsed.reset).toBe(true); |
| 194 | + // The ordering token the history/audit trail is read by — an integer, |
| 195 | + // and the same value the receipt message's `[seq=…]` suffix quotes. |
| 196 | + expect(typeof parsed.seq).toBe('number'); |
| 197 | + expect(Number.isInteger(parsed.seq)).toBe(true); |
| 198 | + expect(parsed.message).toContain(`[seq=${parsed.seq}]`); |
| 199 | + }); |
| 200 | + |
| 201 | + it('with an ADR-0094 projector registered: projectionApplied is carried through', async () => { |
| 202 | + const { p } = await makeProtocol(); |
| 203 | + await (p as any).saveMetaItem({ |
| 204 | + type: 'view', name: 'cases', organizationId: ORG, item: viewBody('P'), |
| 205 | + }); |
| 206 | + // Registered AFTER the save so the save's own projection is not what |
| 207 | + // this case reads — the delete's is. |
| 208 | + (p as any).registerMutationProjector('view', async () => { throw new Error('boom-from-projector'); }); |
| 209 | + |
| 210 | + const raw: any = await (p as any).deleteMetaItem({ |
| 211 | + type: 'view', name: 'cases', organizationId: ORG, |
| 212 | + }); |
| 213 | + |
| 214 | + expect(Object.keys(raw)).toContain('projectionApplied'); |
| 215 | + expect(strippedKeys(raw)).toEqual([]); |
| 216 | + const parsed = DeleteMetaItemResponseSchema.parse(raw); |
| 217 | + // Best-effort by contract: the projector threw, the delete still |
| 218 | + // succeeded, and the failure is reported HERE rather than as a non-200. |
| 219 | + // This is the channel the card names — a caller that needs the derived |
| 220 | + // read model to be live reads it instead of trusting the 200. |
| 221 | + expect(parsed.success).toBe(true); |
| 222 | + expect(parsed.projectionApplied).toEqual({ success: false, error: 'boom-from-projector' }); |
| 223 | + }); |
| 224 | + |
| 225 | + it('no projector registered → projectionApplied is absent, which is why it is optional', async () => { |
| 226 | + const { p } = await makeProtocol(); |
| 227 | + await (p as any).saveMetaItem({ |
| 228 | + type: 'view', name: 'cases', organizationId: ORG, item: viewBody('N'), |
| 229 | + }); |
| 230 | + |
| 231 | + const raw: any = await (p as any).deleteMetaItem({ |
| 232 | + type: 'view', name: 'cases', organizationId: ORG, |
| 233 | + }); |
| 234 | + |
| 235 | + expect(raw.projectionApplied).toBeUndefined(); |
| 236 | + expect(strippedKeys(raw)).toEqual([]); |
| 237 | + expect(DeleteMetaItemResponseSchema.safeParse(raw).success).toBe(true); |
| 238 | + }); |
| 239 | + |
| 240 | + it('repository path, no overlay row: a no-op success that carries no seq', async () => { |
| 241 | + const { p } = await makeProtocol(); |
| 242 | + |
| 243 | + const raw: any = await (p as any).deleteMetaItem({ |
| 244 | + type: 'view', name: 'never_written', organizationId: ORG, |
| 245 | + }); |
| 246 | + |
| 247 | + // This is the branch that makes `seq` optional rather than required. |
| 248 | + // Declaring it required would make the producer's own no-op fail its |
| 249 | + // own contract — the #5563 defect in mirror image. |
| 250 | + expect(raw).not.toHaveProperty('seq'); |
| 251 | + expect(strippedKeys(raw)).toEqual([]); |
| 252 | + const parsed = DeleteMetaItemResponseSchema.parse(raw); |
| 253 | + expect(parsed.success).toBe(true); |
| 254 | + // `reset`, not the absence of `seq`, is what says nothing was removed. |
| 255 | + expect(parsed.reset).toBe(false); |
| 256 | + expect(parsed.seq).toBeUndefined(); |
| 257 | + }); |
| 258 | + |
| 259 | + it('seq really is the history event sequence: it advances across the item\'s writes', async () => { |
| 260 | + const { p } = await makeProtocol(); |
| 261 | + const saved: any = await (p as any).saveMetaItem({ |
| 262 | + type: 'view', name: 'cases', organizationId: ORG, item: viewBody('S'), |
| 263 | + }); |
| 264 | + const raw: any = await (p as any).deleteMetaItem({ |
| 265 | + type: 'view', name: 'cases', organizationId: ORG, |
| 266 | + }); |
| 267 | + |
| 268 | + // The delete's tombstone event comes after the save's write event on |
| 269 | + // the same item, which is the ordering property a history/audit |
| 270 | + // consumer reads the key FOR. A `seq` that did not move would parse |
| 271 | + // just as green, so the contract needs this asserted, not assumed. |
| 272 | + const parsed = DeleteMetaItemResponseSchema.parse(raw); |
| 273 | + expect(parsed.seq).toBeGreaterThan(saved.seq); |
| 274 | + }); |
| 275 | +}); |
0 commit comments