|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#12578] The flat-input Proxy's own-key ENUMERATION agrees with its other two |
| 5 | + * own-ness instruments, and with the row the engine persists. |
| 6 | + * |
| 7 | + * `installFlatInput` (`hook-wrappers.ts`) answered `ownKeys` from |
| 8 | + * `Object.keys(data)` — own **enumerable string** keys. The `enumerable` |
| 9 | + * filtering was incidental to what the trap is for (hiding the wrapper keys), |
| 10 | + * and it cost a key: an own NON-ENUMERABLE key on the payload was absent from |
| 11 | + * `Object.getOwnPropertyNames` / `Reflect.ownKeys` while `hasOwnProperty` and |
| 12 | + * the descriptor trap both reported it. Measured on the merged ref before the |
| 13 | + * repair, for `Object.defineProperty(ctx.input, 'k', { value: 1, |
| 14 | + * enumerable: false, configurable: true })` over a payload `{ subject }` the |
| 15 | + * engine then persisted holding BOTH keys: |
| 16 | + * |
| 17 | + * ``` |
| 18 | + * Object.getOwnPropertyDescriptor(input, 'k') -> own, enumerable:false |
| 19 | + * Object.prototype.hasOwnProperty.call(input, 'k') -> true |
| 20 | + * Object.getOwnPropertyNames(input) -> ['subject'] <- not own? |
| 21 | + * Object.getOwnPropertyNames(raw.data) -> ['subject','k'] |
| 22 | + * ``` |
| 23 | + * |
| 24 | + * Newly reachable, not newly written: #12277 routed `defineProperty` into |
| 25 | + * `data`, so a hook can put a non-default-attribute key on the payload for the |
| 26 | + * first time, and #12397 made the descriptor trap mirror `data` rather than |
| 27 | + * synthesise defaults — which is what gave the third instrument an opinion. |
| 28 | + * |
| 29 | + * ## What is pinned here, and why it is the AGREEMENT rather than one trap |
| 30 | + * |
| 31 | + * The contract these cases assert is not "`ownKeys` returns X". It is that the |
| 32 | + * three instruments an author can reach — the enumeration surfaces, |
| 33 | + * `hasOwnProperty`, and the descriptor trap — give the SAME answer about |
| 34 | + * own-ness for a given key, and that the answer is the one the persisted row |
| 35 | + * gives. A pin asserting a single trap's output in isolation is what let the |
| 36 | + * two halves diverge in the first place: #12397 pinned the descriptor trap and |
| 37 | + * this file's subject drifted out from under it, on the same trap set, in the |
| 38 | + * same file, within the same week. |
| 39 | + * |
| 40 | + * The settled spelling, stated once so the tree stops holding two answers: |
| 41 | + * **`ownKeys` reports the record payload's own key set, not its enumerable |
| 42 | + * subset.** Enumerability is applied by the CONSUMERS, one layer up and through |
| 43 | + * the descriptor trap, which is why the enumerable face below is unchanged. |
| 44 | + * |
| 45 | + * ## The two deliberate exceptions, pinned as exceptions |
| 46 | + * |
| 47 | + * - WRAPPER KEYS (`id`/`options`/`ast`/`data`) stay out of the enumeration |
| 48 | + * face while `hasOwnProperty` and the descriptor trap still report them. |
| 49 | + * That disagreement is the trap's whole purpose (the payload-diff idiom must |
| 50 | + * see record fields only) and is pinned as DECLARED so it cannot be mistaken |
| 51 | + * for a residue of the defect above. |
| 52 | + * - SYMBOL KEYS carry the identical disagreement and are deliberately left |
| 53 | + * carrying it. Publishing them is a one-word change here |
| 54 | + * (`Object.getOwnPropertyNames` -> `Reflect.ownKeys`), but whether a record |
| 55 | + * payload may hold a symbol key at all is a question about the PAYLOAD |
| 56 | + * contract — the boundary #12397 drew and this card does not cross. It is |
| 57 | + * reported open on #12578 and pinned below in its open state, so answering |
| 58 | + * it changes a recorded fact instead of an unnoticed one. |
| 59 | + * |
| 60 | + * `wrapDeclarativeHook` is driven directly rather than through `ObjectQL`, for |
| 61 | + * the reason the sibling trap-set files give: the subject is the wrapper's |
| 62 | + * Proxy, and a full engine dispatch would put a driver's own copy semantics |
| 63 | + * between the hook and the assertion. |
| 64 | + */ |
| 65 | + |
| 66 | +import { describe, it, expect } from 'vitest'; |
| 67 | +import { wrapDeclarativeHook } from './hook-wrappers.js'; |
| 68 | + |
| 69 | +const silentLogger = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }; |
| 70 | + |
| 71 | +/** |
| 72 | + * Run `handler` as a declarative hook over `raw` (the engine's own envelope); |
| 73 | + * the caller keeps `raw` and reads `raw.data` — the row the engine is left |
| 74 | + * holding — after the wrapper has restored `ctx.input`. |
| 75 | + */ |
| 76 | +async function runHook(raw: Record<string, unknown>, handler: (input: any) => void): Promise<void> { |
| 77 | + const meta: any = { name: 'ownkeys_probe', object: 'case', event: 'beforeInsert' }; |
| 78 | + const wrapped = wrapDeclarativeHook(meta, (async (ctx: any) => handler(ctx.input)) as any, { |
| 79 | + logger: silentLogger, |
| 80 | + }); |
| 81 | + await wrapped({ object: 'case', event: 'beforeInsert', input: raw } as any); |
| 82 | +} |
| 83 | + |
| 84 | +/** |
| 85 | + * The three instruments, read on ONE key through ONE object. The assertions |
| 86 | + * below compare this triple against itself — that is the contract — rather than |
| 87 | + * asserting any member on its own. |
| 88 | + */ |
| 89 | +function ownness(obj: any, key: string | symbol) { |
| 90 | + return { |
| 91 | + enumeration: Reflect.ownKeys(obj).includes(key), |
| 92 | + hasOwnProperty: Object.prototype.hasOwnProperty.call(obj, key), |
| 93 | + descriptor: Object.getOwnPropertyDescriptor(obj, key) !== undefined, |
| 94 | + }; |
| 95 | +} |
| 96 | + |
| 97 | +/** All three instruments say "own". */ |
| 98 | +const OWN = { enumeration: true, hasOwnProperty: true, descriptor: true }; |
| 99 | +/** All three instruments say "not own". */ |
| 100 | +const NOT_OWN = { enumeration: false, hasOwnProperty: false, descriptor: false }; |
| 101 | + |
| 102 | +describe('[#12578] the flat-input `ownKeys` reports the payload own-key set, and the instruments agree', () => { |
| 103 | + it('REPRODUCTION — a key defined non-enumerable is own to all three instruments, and to the persisted row', async () => { |
| 104 | + // The card's repro, verbatim. Pre-fix the `enumeration` member of this |
| 105 | + // triple was `false` while the other two were `true`. |
| 106 | + const raw: any = { data: { subject: 'help' }, options: {} }; |
| 107 | + let seen: ReturnType<typeof ownness> | undefined; |
| 108 | + let names: string[] | undefined; |
| 109 | + await runHook(raw, (input) => { |
| 110 | + Object.defineProperty(input, 'k', { value: 1, enumerable: false, configurable: true }); |
| 111 | + seen = ownness(input, 'k'); |
| 112 | + names = Object.getOwnPropertyNames(input); |
| 113 | + }); |
| 114 | + |
| 115 | + expect(seen).toEqual(OWN); |
| 116 | + // The conjunction that makes it a contract and not three coincidences: the |
| 117 | + // proxy's own-key set IS the persisted payload's own-key set. Asserting |
| 118 | + // either side alone passes on a proxy whose halves disagree. |
| 119 | + expect(names).toEqual(Object.getOwnPropertyNames(raw.data)); |
| 120 | + expect(names).toEqual(['subject', 'k']); |
| 121 | + // …and the payload really does carry it — the key is not an artefact of the |
| 122 | + // proxy face, it is on the row the driver receives. |
| 123 | + expect(ownness(raw.data, 'k')).toEqual(OWN); |
| 124 | + }); |
| 125 | + |
| 126 | + it('the ENUMERABLE face is unchanged — Object.keys, spread, entries and JSON still omit it', async () => { |
| 127 | + // The other half of the repair, and the reason reporting the full own-key |
| 128 | + // set costs nothing downstream: every consumer that wants enumerability |
| 129 | + // filters for it ITSELF, through the descriptor trap, which mirrors `data`. |
| 130 | + // `unwrapProxyToPlain` (`packages/runtime/src/sandbox/body-runner.ts`) is |
| 131 | + // the consumer this protects — it snapshots the hook body's `ctx.input` as |
| 132 | + // `Object.entries` over this proxy, so the marshalled set is exactly what |
| 133 | + // it was before this card. |
| 134 | + const raw: any = { data: { subject: 'help' }, options: {} }; |
| 135 | + const seen: Record<string, unknown> = {}; |
| 136 | + await runHook(raw, (input) => { |
| 137 | + Object.defineProperty(input, 'hidden', { value: 1, enumerable: false, configurable: true }); |
| 138 | + seen.objectKeys = Object.keys(input); |
| 139 | + seen.spread = Object.keys({ ...input }); |
| 140 | + seen.entries = Object.entries(input).map(([k]) => k); |
| 141 | + seen.json = JSON.parse(JSON.stringify(input)); |
| 142 | + // The full set, alongside, in the same breath: this is the ONE surface |
| 143 | + // pair whose answers legitimately differ, and they differ by exactly the |
| 144 | + // non-enumerable key. |
| 145 | + seen.ownNames = Object.getOwnPropertyNames(input); |
| 146 | + }); |
| 147 | + |
| 148 | + expect(seen.objectKeys).toEqual(['subject']); |
| 149 | + expect(seen.spread).toEqual(['subject']); |
| 150 | + expect(seen.entries).toEqual(['subject']); |
| 151 | + expect(seen.json).toEqual({ subject: 'help' }); |
| 152 | + expect(seen.ownNames).toEqual(['subject', 'hidden']); |
| 153 | + }); |
| 154 | + |
| 155 | + it('agrees the other way: a key the payload does not hold is own to none of them', async () => { |
| 156 | + const raw: any = { data: { subject: 'help' }, options: {} }; |
| 157 | + let seen: ReturnType<typeof ownness> | undefined; |
| 158 | + await runHook(raw, (input) => { |
| 159 | + seen = ownness(input, 'absent'); |
| 160 | + }); |
| 161 | + expect(seen).toEqual(NOT_OWN); |
| 162 | + }); |
| 163 | + |
| 164 | + it('agrees on an ordinarily assigned key — the positive control', async () => { |
| 165 | + const raw: any = { data: {}, options: {} }; |
| 166 | + let seen: ReturnType<typeof ownness> | undefined; |
| 167 | + await runHook(raw, (input) => { |
| 168 | + input.subject = 'help'; |
| 169 | + seen = ownness(input, 'subject'); |
| 170 | + }); |
| 171 | + expect(seen).toEqual(OWN); |
| 172 | + expect(Object.getOwnPropertyNames(raw.data)).toEqual(['subject']); |
| 173 | + }); |
| 174 | + |
| 175 | + it('DECLARED EXCEPTION — wrapper keys stay out of enumeration while the other two report them', async () => { |
| 176 | + // Not a residue of the defect: hiding `id`/`options`/`ast`/`data` from |
| 177 | + // `Object.keys`/`for-in` is what this trap exists for. Pinned so the |
| 178 | + // exception stays deliberate and visible. |
| 179 | + const raw: any = { data: { subject: 'help' }, options: { multi: false }, id: 'WRAPPER-ID' }; |
| 180 | + const seen: Record<string, unknown> = {}; |
| 181 | + await runHook(raw, (input) => { |
| 182 | + seen.options = ownness(input, 'options'); |
| 183 | + seen.id = ownness(input, 'id'); |
| 184 | + seen.ownNames = Object.getOwnPropertyNames(input); |
| 185 | + // Still reachable by the spellings the contract names — hidden from |
| 186 | + // enumeration is not hidden from the author. |
| 187 | + seen.readId = input.id; |
| 188 | + seen.readMulti = (input.options as any).multi; |
| 189 | + }); |
| 190 | + |
| 191 | + expect(seen.options).toEqual({ enumeration: false, hasOwnProperty: true, descriptor: true }); |
| 192 | + expect(seen.id).toEqual({ enumeration: false, hasOwnProperty: true, descriptor: true }); |
| 193 | + expect(seen.ownNames).toEqual(['subject']); |
| 194 | + expect(seen.readId).toBe('WRAPPER-ID'); |
| 195 | + expect(seen.readMulti).toBe(false); |
| 196 | + }); |
| 197 | + |
| 198 | + it('OPEN QUESTION, pinned in its open state — a symbol key carries the same disagreement', async () => { |
| 199 | + // Reported on #12578 rather than decided here: publishing symbol keys |
| 200 | + // through `ownKeys` is `Reflect.ownKeys` in one line, but whether the |
| 201 | + // record payload may CARRY a symbol key is a payload-contract question and |
| 202 | + // a maintainer floor (#12397's boundary). |
| 203 | + // |
| 204 | + // What the measurement establishes, and what this case records: symbol keys |
| 205 | + // already reach `data` through the `set` trap and already persist. So the |
| 206 | + // open question is about what the enumeration face should PUBLISH, not |
| 207 | + // about what a hook can already put on the row. |
| 208 | + const raw: any = { data: { subject: 'help' }, options: {} }; |
| 209 | + const sym = Symbol.for('objectstack.test.12578'); |
| 210 | + const seen: Record<string, unknown> = {}; |
| 211 | + await runHook(raw, (input) => { |
| 212 | + input[sym] = 'symvalue'; |
| 213 | + seen.ownness = ownness(input, sym); |
| 214 | + seen.symbols = Object.getOwnPropertySymbols(input); |
| 215 | + }); |
| 216 | + |
| 217 | + // Today: two instruments say own, enumeration says no — the defect's shape, |
| 218 | + // deliberately left standing on this half. |
| 219 | + expect(seen.ownness).toEqual({ enumeration: false, hasOwnProperty: true, descriptor: true }); |
| 220 | + expect(seen.symbols).toEqual([]); |
| 221 | + // …while the payload the engine persists holds it. |
| 222 | + expect(Object.getOwnPropertySymbols(raw.data)).toEqual([sym]); |
| 223 | + expect((raw.data as any)[sym]).toBe('symvalue'); |
| 224 | + }); |
| 225 | +}); |
0 commit comments