From c481704f474eb35c3747ccec44d8fafec1adacf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:37:48 +0000 Subject: [PATCH 1/5] test(rest): pin DELETE ?package=P&dropStorage=true of an object another row still holds (red at base) Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude --- ...eta-delete-drop-storage-other-rows.test.ts | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 packages/rest/src/meta-delete-drop-storage-other-rows.test.ts diff --git a/packages/rest/src/meta-delete-drop-storage-other-rows.test.ts b/packages/rest/src/meta-delete-drop-storage-other-rows.test.ts new file mode 100644 index 00000000000..50100bd6b2d --- /dev/null +++ b/packages/rest/src/meta-delete-drop-storage-other-rows.test.ts @@ -0,0 +1,244 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23415, ADR-0048] `DELETE /meta/object/:name?package=P&dropStorage=true` + * drops the object's table only with the object's LAST stored row. + * + * A `sys_metadata` row is keyed by its package, so one object can hold one + * row per package, and every such row serves the object over the ONE table + * its name maps to. The delete door names the row it removes (`?package=`), + * and `?dropStorage=true` asks it to drop the table too. The drop used to ask + * only about the type, the state and the `sys_` prefix: a delete of package + * P's row dropped the table while package Q's row of the object stayed stored + * and served, so Q's object kept its definition and lost its records. + * Measured on a booted showcase before the fix: the delete answered `200` + * with a receipt saying Q's row remains, and the next `GET /data` of the + * object answered `500` because its table was gone. + * + * Now such a delete is refused `409 RESOURCE_CONFLICT` before anything is + * written: P's row, Q's row, the table and its records all stay. The refusal + * names the way out (the delete without `dropStorage`, or the other rows + * first and the drop with the last one). The last row's delete with + * `dropStorage=true` still drops the table, and a per-package delete without + * `dropStorage` is unchanged. + * + * Driven at the HTTP door on the real stack: a better-sqlite3 `:memory:` + * engine whose tables are real, the real `sys_metadata*` objects, a real + * `ObjectStackProtocolImplementation` and the real routes, on both kernel + * topologies. The stub is the auth boundary (`resolveExecCtx`). + * + * ⚠️ `@objectstack/metadata-protocol` resolves through `exports` to its + * **`dist/`** (`check-test-source-alias.mjs`, `KNOWN_UNALIASED_TEST_IMPORTS`): + * rebuild it before reading a result here after touching `protocol.ts`. + * + * Every refusal pin asserts the ADR-0112 `code` and the HTTP status, and reads + * the store and the table to show the refused delete changed nothing. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject } from '@objectstack/platform-objects/metadata'; +import { hashSpec } from '@objectstack/metadata-core'; +import { RestServer } from './rest-server.js'; + +/** `registry.registerObject` requires a package id (see the sibling real-stack tests). */ +const TEST_PACKAGE_ID = 'objectstack-test'; +const META = '/api/v1/meta'; +const P = 'com.probe.p'; +const Q = 'com.probe.q'; +const NAME = 'probe_ticket'; + +const ticket = (label: string) => ({ + name: NAME, + label, + // [#8310] The runtime object door requires an authored OWD. + sharingModel: 'private', + fields: { title: { name: 'title', type: 'text' as const, label: 'Title' } }, +}); + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const res: any = { statusCode: 200, body: undefined, headers: {} as Record }; + res.status = (code: number) => { res.statusCode = code; return res; }; + res.json = (body: any) => { res.body = body; return res; }; + res.send = () => res; + res.end = () => res; + res.header = (k: string, v: string) => { res.headers[k] = v; return res; }; + res.setHeader = () => {}; + res.write = () => true; + return res; +} + +/** `environmentId` absent boots a control-plane kernel; set, an environment kernel. */ +async function boot(environmentId?: string) { + const engine = new ObjectQL(); + liveEngines.push(engine); + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(SysMetadata as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataHistoryObject as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID); + await engine.syncSchemas(); + + const protocol = new ObjectStackProtocolImplementation(engine as any, undefined, environmentId); + const config = { api: { requireAuth: false }, metadata: { enableCache: false } }; + const rest: any = new RestServer(createMockServer() as any, protocol as any, config as any); + rest.resolveExecCtx = async () => ({ userId: 'u_author', systemPermissions: ['manage_metadata'] }); + rest.registerRoutes(); + + const call = async (method: string, query: Record, body?: unknown) => { + const found = rest.getRoutes().find((r: any) => r.method === method && r.path === `${META}/:type/:name`); + if (!found) throw new Error(`${method} ${META}/:type/:name is not registered`); + const res = makeRes(); + await found.handler({ + method, headers: {}, path: `${META}/object/${NAME}`, + params: { type: 'object', name: NAME }, query, ...(body !== undefined ? { body } : {}), + }, res); + return res; + }; + + /** `PUT /meta/object/probe_ticket?package=…` — a publish, which creates the table. */ + const save = (packageId: string) => call('PUT', { package: packageId }, ticket(`Ticket ${packageId}`)); + /** `DELETE /meta/object/probe_ticket?…`. */ + const remove = (query: Record) => call('DELETE', query); + + /** The object's stored active rows, by binding — the store, not the door's word for it. */ + const storedPackages = async () => { + const rows = await engine.find('sys_metadata', { where: { type: 'object', name: NAME, state: 'active' } }); + return (rows ?? []).map((r: any) => (r.package_id ?? null) as string | null) + .sort((a, b) => String(a).localeCompare(String(b))); + }; + /** Whether the object's physical table exists — the database, not the registry. */ + const hasTable = () => (driver as any).knex.schema.hasTable(NAME) as Promise; + /** The object's records, read straight from the table. */ + const records = async () => ((await (driver as any).knex(NAME).select('title')) as Array<{ title: string }>) + .map((r) => r.title); + const insertRecord = (title: string) => + engine.insert(NAME, { title }, { context: { isSystem: true } } as any); + + /** + * A package-less row of the object as it exists at rest — the save door + * resolves a package-less save of an item a package holds to that + * package's row, so such a row is only ever a stored one. + */ + const seedUnboundRow = async () => { + const body = ticket('Ticket unbound'); + const now = new Date().toISOString(); + await engine.insert('sys_metadata', { + id: 'seed_active_unbound', type: 'object', name: NAME, organization_id: null, + package_id: null, state: 'active', metadata: JSON.stringify(body), checksum: hashSpec(body, 'object'), + version: 1, created_at: now, updated_at: now, + }, { context: { isSystem: true } } as any); + }; + + return { save, remove, storedPackages, hasTable, records, insertRecord, seedUnboundRow }; +} + +const ok = (res: any) => { + expect(res.statusCode, JSON.stringify(res.body)).toBe(200); + return res.body; +}; + +/** The refusal: the ADR-0112 `code` and the status, both asserted. */ +const refusedConflict = (res: any) => { + expect(res.statusCode, JSON.stringify(res.body)).toBe(409); + expect(res.body.code).toBe('RESOURCE_CONFLICT'); + return res.body; +}; + +/** Two packages' rows of the object, its table and one record — the card's setup. */ +async function twoPackageObject(environmentId?: string) { + const h = await boot(environmentId); + // The registry holds one owner per object name, so the second package's + // write-through is refused (and logged) at save time; the row is stored. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + ok(await h.save(P)); + ok(await h.save(Q)); + } finally { + warn.mockRestore(); + } + await h.insertRecord('kept'); + expect(await h.storedPackages()).toEqual([P, Q]); + expect(await h.hasTable()).toBe(true); + expect(await h.records()).toEqual(['kept']); + return h; +} + +describe('[#23415] DELETE ?package=P&dropStorage=true of an object another row still holds', () => { + for (const [kernel, environmentId] of [['control-plane', undefined], ['environment', 'env_test']] as const) { + it(`${kernel} kernel: refused 409 RESOURCE_CONFLICT before anything is written — both rows, the table and Q's record stay`, async () => { + const h = await twoPackageObject(environmentId); + + const body = refusedConflict(await h.remove({ package: P, dropStorage: 'true' })); + // The refusal names the row that keeps serving the object. + expect(String(body.message)).toContain(Q); + + expect(await h.storedPackages()).toEqual([P, Q]); + expect(await h.hasTable()).toBe(true); + expect(await h.records()).toEqual(['kept']); + }, 60_000); + } + + it('a package-less row left beside the deleted package\'s row counts as a row that remains', async () => { + const h = await boot(); + ok(await h.save(P)); + await h.seedUnboundRow(); + await h.insertRecord('kept'); + expect(await h.storedPackages()).toEqual([P, null]); + + refusedConflict(await h.remove({ package: P, dropStorage: 'true' })); + + expect(await h.storedPackages()).toEqual([P, null]); + expect(await h.hasTable()).toBe(true); + expect(await h.records()).toEqual(['kept']); + }, 60_000); + + it('the way out: without dropStorage the delete removes P\'s row and keeps the table (unchanged); the last row\'s delete WITH dropStorage drops it', async () => { + const h = await twoPackageObject(); + + // A per-package delete without `dropStorage`: as before — P's row + // goes, the receipt names Q's, the table and its record stay. + const kept = ok(await h.remove({ package: P })); + expect(kept.reset).toBe(true); + expect(String(kept.message)).toContain(Q); + expect(await h.storedPackages()).toEqual([Q]); + expect(await h.hasTable()).toBe(true); + expect(await h.records()).toEqual(['kept']); + + // CONTROL: the object's last row, with `dropStorage` — the table goes. + const dropped = ok(await h.remove({ package: Q, dropStorage: 'true' })); + expect(dropped.reset).toBe(true); + expect(await h.storedPackages()).toEqual([]); + expect(await h.hasTable()).toBe(false); + }, 60_000); + + it('CONTROL: an object one package holds — `?package=P&dropStorage=true` deletes its row and drops its table', async () => { + const h = await boot(); + ok(await h.save(P)); + await h.insertRecord('gone'); + + ok(await h.remove({ package: P, dropStorage: 'true' })); + + expect(await h.storedPackages()).toEqual([]); + expect(await h.hasTable()).toBe(false); + }, 60_000); +}); From 755eb9ead512d0407375f71b4d5c4889b72ea829 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:39:01 +0000 Subject: [PATCH 2/5] fix(metadata-protocol): refuse a dropStorage delete while another stored row of the object remains Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 91 ++++++++++++++++++++-- 1 file changed, 84 insertions(+), 7 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index f52ee8d4739..4d93ece86ae 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -20747,6 +20747,57 @@ export class ObjectStackProtocolImplementation implements return true; } + /** + * [#23415, ADR-0048] The refusal of a delete that asks to drop an object's + * table ({@link shouldDropStorage} says yes) while another stored row of + * the object stays after it. A `sys_metadata` row is keyed by its package, + * so one object can hold one row per package (and a package-less row), and + * every such row serves the object over the ONE table its name maps to. + * Dropping the table with one of them left the others' object served + * with its records gone: measured at `DELETE + * /api/v1/meta/object/NAME?package=P&dropStorage=true` on a booted + * showcase, `200` with a receipt naming Q's remaining row, then `500` on + * the object's next data read. + * + * Refused, not skipped: an explicit `dropStorage` that drops nothing would + * be an opt-in the runtime does not honour while the receipt answers + * success. Refused before any write, so nothing is deleted and the request + * succeeds once the remedy is applied: without `dropStorage` (this row + * goes, the table stays), or with the object's last row. + * + * The rows that count are the ones the delete's own address read + * ({@link packagelessAddress}) holds: the environment's ACTIVE rows of the + * object. A draft row is not one: a draft is never materialised and never + * served, so it holds no records, and the last active row's delete still + * drops the table it was the last to serve. + * + * `RESOURCE_CONFLICT` / 409, no code minted: the standard catalog's member + * for a refusal that comes from environment state rather than the request + * (the same request is accepted once the other rows are gone), as + * {@link packagelessAddressAmbiguityRefusal} answers for the same rows. + * The prescription first, because a 4xx message crosses the REST boundary + * bounded at 500 characters with its tail truncated. + */ + private static dropStorageRemainingRowsRefusal( + type: string, + name: string, + deletedPackage: string | null, + remainingPackages: readonly string[], + unboundRemains: boolean, + ): Error & { code: 'RESOURCE_CONFLICT'; status: 409 } { + const which = deletedPackage !== null ? ` (package=${deletedPackage})` : ''; + const err = new Error( + 'Send the delete again without dropStorage to remove this row and keep the table, or delete the ' + + "object's other rows first and drop the table with the last one. Nothing was deleted. " + + `After this delete${which}, ${type} '${name}' is still served: ` + + `${remainingRowsPhrase(remainingPackages, unboundRemains, 'row')}, and the object's records are in its table. ` + + "dropStorage drops an object's table only with the object's last row.", + ) as Error & { code: 'RESOURCE_CONFLICT'; status: 409 }; + err.code = 'RESOURCE_CONFLICT'; + err.status = 409; + return err; + } + /** * [commit 311433f6b] The item-name grammar verdict — stage 1 of the * maintainer-ruled retirement of compound `
/` addressing @@ -28367,6 +28418,9 @@ export class ObjectStackProtocolImplementation implements * is removed (object + active only; never `sys_`). Default false keeps * delete non-destructive to data. Used by the "discard a previewed * object" flow so a publish-to-preview leaves no orphan table. + * [#23415] Only with the object's last stored row: while another row + * of it stays stored, the delete is refused `409 RESOURCE_CONFLICT` + * ({@link dropStorageRemainingRowsRefusal}). */ dropStorage?: boolean; /** [#22877] The bundle member to reset — {@link resolveBundleMemberSelector}; omitted, the canonical one. */ @@ -28560,6 +28614,9 @@ export class ObjectStackProtocolImplementation implements // more packages hold: the door's own verdict, rethrown as it is by // the `catch` below, never re-wrapped as a delete that failed. let addressRefusal: Error | undefined; + // [#23415] The refusal of a table drop another stored row of the + // object still needs: rethrown as it is, as `addressRefusal` is. + let dropRefusal: Error | undefined; try { const targetState: 'active' | 'draft' = request.state === 'draft' ? 'draft' : 'active'; @@ -28656,6 +28713,25 @@ export class ObjectStackProtocolImplementation implements }; } + // [#23165] What is still stored at this address once this row + // is gone, from the rows the address read already holds. Read + // by the table drop's verdict just below and by the receipt. + const remainingPackages = rowsAtAddress.candidates.filter((id) => id !== packageId); + const unboundRemains = rowsAtAddress.unbound && packageId !== null; + const remains = remainingPackages.length > 0 || unboundRemains; + // [#23415] The table drop, decided ONCE, before anything is + // written: an object's table goes only with its last stored + // row. A `dropStorage` delete that would leave another row of + // the object stored, and served over this table, is refused + // whole — see {@link dropStorageRemainingRowsRefusal}. + const dropsStorage = this.shouldDropStorage(request.type, request.name, request.dropStorage, targetState); + if (dropsStorage && remains) { + dropRefusal = ObjectStackProtocolImplementation.dropStorageRemainingRowsRefusal( + singularTypeForRepo, request.name, packageId, remainingPackages, unboundRemains, + ); + throw dropRefusal; + } + // Last-write-wins parent resolution unless the caller pinned // an explicit version (Studio's "Reset" button is unpinned; // a future "delete vN" flow can pass parentVersion). @@ -28707,7 +28783,9 @@ export class ObjectStackProtocolImplementation implements // Storage teardown (opt-in): drop the now-orphaned physical table // for a discarded object so a publish-to-preview leaves no residue. - if (this.shouldDropStorage(request.type, request.name, request.dropStorage, targetState)) { + // [#23415] Decided above; reached only when this was the + // object's last stored row. + if (dropsStorage) { await this.dropObjectStorage(singularTypeForRepo, request.name); } @@ -28740,11 +28818,6 @@ export class ObjectStackProtocolImplementation implements organizationId: orgId, ...mutationEventMember(singularTypeForRepo, member), }); - // [#23165] What is still stored at this address once this row - // is gone, from the rows the address read already holds. - const remainingPackages = rowsAtAddress.candidates.filter((id) => id !== packageId); - const unboundRemains = rowsAtAddress.unbound && packageId !== null; - const remains = remainingPackages.length > 0 || unboundRemains; const deletedPackage = packageId !== null ? ` (package=${packageId})` : ''; return { success: true, @@ -28794,7 +28867,11 @@ export class ObjectStackProtocolImplementation implements // [#21899] The door's own verdict, not a store failure: it // leaves with its envelope and sentence, never re-wrapped as an // overlay delete that failed. - if (err === originGatedRefusal || (addressRefusal !== undefined && err === addressRefusal)) throw err; + if ( + err === originGatedRefusal + || (addressRefusal !== undefined && err === addressRefusal) + || (dropRefusal !== undefined && err === dropRefusal) + ) throw err; if (err instanceof ConflictError) { // [#21207] Keyed values or none in the text and attributes. const conflict = await this.metadataConflictRefusal( From 143edf7433df1095fb1252ce22e296867efaaa11 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:44:26 +0000 Subject: [PATCH 3/5] test(rest): read the refusal's sentence from the flat envelope's error field Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude --- packages/rest/src/meta-delete-drop-storage-other-rows.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rest/src/meta-delete-drop-storage-other-rows.test.ts b/packages/rest/src/meta-delete-drop-storage-other-rows.test.ts index 50100bd6b2d..3afb07c1dab 100644 --- a/packages/rest/src/meta-delete-drop-storage-other-rows.test.ts +++ b/packages/rest/src/meta-delete-drop-storage-other-rows.test.ts @@ -190,7 +190,7 @@ describe('[#23415] DELETE ?package=P&dropStorage=true of an object another row s const body = refusedConflict(await h.remove({ package: P, dropStorage: 'true' })); // The refusal names the row that keeps serving the object. - expect(String(body.message)).toContain(Q); + expect(String(body.error), JSON.stringify(body)).toContain(Q); expect(await h.storedPackages()).toEqual([P, Q]); expect(await h.hasTable()).toBe(true); From 9534b10c14e50c1ab18b6e6b65166e1f0ac2fbb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:47:13 +0000 Subject: [PATCH 4/5] changeset: 23415 dropStorage refused while another row of the object remains Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude --- .changeset/23415-drop-storage-other-rows.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .changeset/23415-drop-storage-other-rows.md diff --git a/.changeset/23415-drop-storage-other-rows.md b/.changeset/23415-drop-storage-other-rows.md new file mode 100644 index 00000000000..7d459e30faa --- /dev/null +++ b/.changeset/23415-drop-storage-other-rows.md @@ -0,0 +1,21 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +fix(metadata-protocol)!: a metadata delete with `dropStorage` drops an object's table only with the object's last stored row, and is refused `409 RESOURCE_CONFLICT` while another row of the object remains (ADR-0048) + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +A `sys_metadata` row is keyed by its package (ADR-0048), so one object can hold one row per package, and a package-less row beside them. Every such row serves the object over the one table its name maps to. + +**FROM.** `DELETE /api/v1/meta/object/my_object?package=com.acme.a&dropStorage=true` (`deleteMetaItem({ type: 'object', name, packageId, dropStorage: true })` in process), of an object a second package `com.acme.b` also holds, removed `com.acme.a`'s row, answered `200` with a receipt saying `com.acme.b`'s row remains, and dropped the object's table. The object stayed defined and served by `com.acme.b`'s row while its records were gone: its next data read answered `500`. The same held for a package-less row left beside the deleted package's row, and for an uninstall (`deletePackage`, which deletes each of the package's rows with `dropStorage` unless `keepData` is set). + +**TO.** Such a delete is refused `409 RESOURCE_CONFLICT` before anything is written: both rows, the table and its records stay. The message names the rows that remain and the way out. An uninstall reports the refusal for that object in its `failed[]` list, as it reports any per-item refusal, and leaves the object's row and table in place. + +**Unchanged.** The delete of an object's last stored row with `dropStorage` still drops its table. A delete without `dropStorage` still removes the named package's row and keeps the table, and its receipt names the rows that remain. Only `active` rows count: a draft is never materialised, so a remaining draft does not keep the table. + +**Remedy.** To remove one package's row of an object another row still holds, send the delete without `dropStorage`. To drop the table, delete the object's other rows first, then send the last row's delete with `dropStorage`. From dce4a7ea77bfd3ebeaac4e8bab76dd1e27c96aa6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:58:03 +0000 Subject: [PATCH 5/5] test(rest): no console spy in the dropStorage pin (check:rest-log-spy-declared) Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude --- .../src/meta-delete-drop-storage-other-rows.test.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/rest/src/meta-delete-drop-storage-other-rows.test.ts b/packages/rest/src/meta-delete-drop-storage-other-rows.test.ts index 3afb07c1dab..9d8216ab3a8 100644 --- a/packages/rest/src/meta-delete-drop-storage-other-rows.test.ts +++ b/packages/rest/src/meta-delete-drop-storage-other-rows.test.ts @@ -35,7 +35,7 @@ * the store and the table to show the refused delete changed nothing. */ -import { describe, it, expect, afterEach, vi } from 'vitest'; +import { describe, it, expect, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; @@ -169,13 +169,8 @@ async function twoPackageObject(environmentId?: string) { const h = await boot(environmentId); // The registry holds one owner per object name, so the second package's // write-through is refused (and logged) at save time; the row is stored. - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - try { - ok(await h.save(P)); - ok(await h.save(Q)); - } finally { - warn.mockRestore(); - } + ok(await h.save(P)); + ok(await h.save(Q)); await h.insertRecord('kept'); expect(await h.storedPackages()).toEqual([P, Q]); expect(await h.hasTable()).toBe(true);