Public: anyone can read this; the crew can change it.

Security first: `bun test` is red on main — one test leaks database state into another #36

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

ledger/bazaar:ledger/25-security-first-bun-test-is-red-on-main-o → main 1 commit fork 1 thread · 1 unresolved

  • Merged. ledger/25-security-first-bun-test-is-red-on-main-o was merged into main as 5316680.

Review

Sign in to review this change.
10 files changed +236 −55
AGENTS.md +6 −1
@@ -10,7 +10,12 @@ preview at its own address, deployed from your branch as you push.
1010 a person merges your pull once it is reviewed.
1111 - Keep state under `FORGE_APP_DATA`. Never write elsewhere at runtime.
1212 - Run `bun test` before you push. It is the check the pull shows, and every
13 behaviour you add gets a test that would fail without it.
13 behaviour you add gets a test that would fail without it. A new test file
14 starts with `await freshData("its-name")` from `src/fresh.ts` before it
15 imports anything that queries: the files share one process and one module
16 registry, so that call is what gives the file a database of its own. No
17 test may depend on another file's rows, and the suite has to pass in any
18 order (`bun test --randomize` is the quick way to find out).
1419 - `src/tty.ts` is the app's terminal interface. The exact command that
1520 reaches it is `FORGE_APP_SSH` at runtime; print that verbatim wherever the
1621 app tells people how to connect. Keep it working and keep it useful: what
README.md +20 −2
@@ -88,7 +88,12 @@ takes off.
8888 discount, never on shipping and never on the sale of a gift card, which is
8989 money moving rather than a sale. The rate and its name are copied onto the
9090 order at the moment of sale, so changing the table never rewrites what
91 somebody was charged.
91 somebody was charged. Before an address is typed the cart and the checkout
92 page estimate with the home country's row — `tax.HOME_COUNTRY`, the country
93 the checkout form itself offers — so the rate a shopper is shown and the
94 rate the order charges are one lookup in one table, and the "anywhere else"
95 default set at `/admin/tax` is what it says it is: the rate for the places
96 the table does not name.
9297
9398 The arithmetic runs in one fixed order, every step an integer number of
9499 cents: goods, less the discount, plus shipping, plus tax on what is left of
@@ -251,7 +256,8 @@ till: someone reading their card over and over must still be able to spend it.
251256 | --- | --- |
252257 | `src/server.ts` | The front door: security headers on every response, same-origin and CSRF checks, the request's context |
253258 | `src/routes.ts` | The table of paths |
254 | `src/db.ts` | The schema, opened once |
259 | `src/db.ts` | The schema, and `open(dir)`: a directory in, a database out |
260 | `src/fresh.ts` | A database of a test file's own, opened before the file imports anything that queries |
255261 | `src/catalog.ts` `src/inventory.ts` `src/orders.ts` | Products, the stock ledger, orders and refunds |
256262 | `src/cart.ts` `src/codes.ts` `src/mine.ts` `src/money.ts` `src/sign.ts` | The signed cart, the codes cookie, the orders this browser placed, integer cents, HMACs |
257263 | `src/discounts.ts` `src/giftcards.ts` `src/tax.ts` | What a code is worth, what a card holds, what the rate is |
@@ -263,6 +269,18 @@ till: someone reading their card over and over must still be able to spend it.
263269 | `src/pages/*.ts` `src/html.ts` `src/style.ts` | Rendering; the stylesheet is served under a name made from its own contents |
264270 | `src/units.test.ts` `src/server.test.ts` `src/stripe.test.ts` `src/proxy.test.ts` | The rules on their own, the shop over HTTP, the Stripe path against a fake Stripe, the app booted behind a declared proxy |
265271
272 ### Tests
273
274 `bun test`. `bun test` runs every file in one process with one module
275 registry, so `src/db.ts` is evaluated once: a test file that only set
276 `FORGE_APP_DATA` would still be querying whichever database the first file to
277 import it opened. Each file therefore starts with `await freshData(name)`
278 (`src/fresh.ts`), which makes a temporary directory and opens a database there
279 before anything that queries is imported. No file depends on another file's
280 rows: every file passes alone with the same count as it does in the suite, and
281 the suite passes in any order — `bun test`, `bun test --isolate`,
282 `bun test --parallel=4` and `bun test --randomize --seed N` are all green.
283
266284 ## The terminal
267285
268286 The same shop over the same data. The exact command is in `FORGE_APP_SSH`
src/db.ts +62 −24
@@ -1,5 +1,5 @@
11 /**
2 * The database: one SQLite file under FORGE_APP_DATA, opened once.
2 * The database: one SQLite file in a directory, opened by a factory.
33 *
44 * The shape of the store, in one place. Two rules hold everywhere:
55 * money is an integer number of cents, and anything that changes stock, a
@@ -7,21 +7,17 @@
77 * gift_card_entry, discount_redemption, order_event). A product's stock and a
88 * gift card's balance are the running totals of their ledgers, never a bare
99 * edit.
10 *
11 * `open(dir)` is the whole of it: a directory in, a usable database out. The
12 * app keeps one of those — `db`, under FORGE_APP_DATA — and a test keeps its
13 * own, which is why the handle is a live binding and not a frozen singleton.
1014 */
1115
1216 import { Database } from "bun:sqlite";
1317 import { mkdirSync } from "node:fs";
1418 import { join } from "node:path";
1519
16 export const dataDir = process.env.FORGE_APP_DATA ?? "./data";
17 mkdirSync(dataDir, { recursive: true });
18
19 export const db = new Database(join(dataDir, "app.db"));
20 db.exec("PRAGMA journal_mode = WAL");
21 db.exec("PRAGMA foreign_keys = ON");
22 db.exec("PRAGMA busy_timeout = 5000");
23
24 db.exec(`
20 const SCHEMA = `
2521 CREATE TABLE IF NOT EXISTS category (
2622 id INTEGER PRIMARY KEY,
2723 slug TEXT NOT NULL UNIQUE,
@@ -217,29 +213,71 @@ CREATE TABLE IF NOT EXISTS tax_rate (
217213 active INTEGER NOT NULL DEFAULT 1
218214 );
219215 CREATE UNIQUE INDEX IF NOT EXISTS tax_rate_place ON tax_rate(country, region);
220 `);
216 `;
221217
222218 /**
223219 * Columns added after the first release. A database that already holds orders
224220 * is grown, never rebuilt: each column is added once, with a default that
225221 * makes the rows already there say the truth about themselves.
226222 */
227 const columnsOf = (table: string): Set<string> =>
228 new Set(db.query<{ name: string }, []>(`PRAGMA table_info("${table}")`).all().map((column) => column.name));
223 const columnsOf = (handle: Database, table: string): Set<string> =>
224 new Set(handle.query<{ name: string }, []>(`PRAGMA table_info("${table}")`).all().map((column) => column.name));
229225
230 function addColumn(table: string, name: string, declaration: string, backfill?: string): void {
231 if (columnsOf(table).has(name)) return;
232 db.exec(`ALTER TABLE "${table}" ADD COLUMN ${name} ${declaration}`);
233 if (backfill) db.exec(backfill);
226 function addColumn(handle: Database, table: string, name: string, declaration: string, backfill?: string): void {
227 if (columnsOf(handle, table).has(name)) return;
228 handle.exec(`ALTER TABLE "${table}" ADD COLUMN ${name} ${declaration}`);
229 if (backfill) handle.exec(backfill);
234230 }
235231
236 addColumn("order", "discount_code", "TEXT NOT NULL DEFAULT ''");
237 addColumn("order", "discount_cents", "INTEGER NOT NULL DEFAULT 0");
238 addColumn("order", "gift_card_cents", "INTEGER NOT NULL DEFAULT 0");
239 addColumn("order", "tax_rate_bp", "INTEGER NOT NULL DEFAULT 0");
240 addColumn("order", "tax_label", "TEXT NOT NULL DEFAULT ''");
241 addColumn("variant", "gift_card_cents", "INTEGER NOT NULL DEFAULT 0");
242 addColumn("order_item", "gift_card_cents", "INTEGER NOT NULL DEFAULT 0");
232 function migrate(handle: Database): void {
233 addColumn(handle, "order", "discount_code", "TEXT NOT NULL DEFAULT ''");
234 addColumn(handle, "order", "discount_cents", "INTEGER NOT NULL DEFAULT 0");
235 addColumn(handle, "order", "gift_card_cents", "INTEGER NOT NULL DEFAULT 0");
236 addColumn(handle, "order", "tax_rate_bp", "INTEGER NOT NULL DEFAULT 0");
237 addColumn(handle, "order", "tax_label", "TEXT NOT NULL DEFAULT ''");
238 addColumn(handle, "variant", "gift_card_cents", "INTEGER NOT NULL DEFAULT 0");
239 addColumn(handle, "order_item", "gift_card_cents", "INTEGER NOT NULL DEFAULT 0");
240 }
241
242 /**
243 * Open the database kept in a directory: the directory is made if it is not
244 * there, the file is opened, the pragmas are set, the schema is created and
245 * the migrations are run. Call it twice with two directories and you have two
246 * databases that know nothing of each other.
247 */
248 export function open(dir: string): Database {
249 mkdirSync(dir, { recursive: true });
250 const handle = new Database(join(dir, "app.db"));
251 handle.exec("PRAGMA journal_mode = WAL");
252 handle.exec("PRAGMA foreign_keys = ON");
253 handle.exec("PRAGMA busy_timeout = 5000");
254 handle.exec(SCHEMA);
255 migrate(handle);
256 return handle;
257 }
258
259 /** The database this process is working in, and where it lives. */
260 export let dataDir = process.env.FORGE_APP_DATA ?? "./data";

x

261 export let db = open(dataDir);
262
263 /**
264 * Work in another database from here on.
265 *
266 * `bun test` runs every file in one process with one module registry, so this
267 * module is evaluated once and the first file to import it would otherwise
268 * choose the database for all of them — each file's own `mkdtempSync` would
269 * isolate nothing. A test file calls this (see `src/fresh.ts`) before it
270 * imports anything that queries. Nothing in the app calls it: an import of
271 * `db` is a live binding, so every module follows the swap.
272 */
273 export function useDatabase(dir: string): Database {
274 const previous = db;
275 dataDir = dir;
276 process.env.FORGE_APP_DATA = dir;
277 db = open(dir);
278 previous.close(false);
279 return db;
280 }
243281
244282 export type Category = { id: number; slug: string; name: string; blurb: string };
245283 export type Product = {
src/fresh.ts added +26
@@ -0,0 +1,26 @@
1 /**
2 * A database of this test file's own.
3 *
4 * `bun test` runs every file in one process with one module registry, so
5 * `db.ts` is evaluated exactly once: the first file to import it would
6 * otherwise pick the database every other file queries, and a file's own
7 * `mkdtempSync` would isolate nothing. Each test file calls this first, before
8 * it imports anything that queries, and gets a directory and a database that
9 * belong to it alone — in any order the files happen to run in.
10 *
11 * Only tests call it. The app opens its database once, from FORGE_APP_DATA.
12 */
13
14 import { mkdtempSync } from "node:fs";
15 import { tmpdir } from "node:os";
16 import { join } from "node:path";
17
18 export async function freshData(name: string): Promise<string> {
19 const dir = mkdtempSync(join(tmpdir(), `bazaar-${name}-`));
20 process.env.FORGE_APP_DATA = dir;
21 // Imported here, not at the top: this may be the import that evaluates
22 // db.ts, and by now FORGE_APP_DATA says where its database belongs.
23 const { useDatabase } = await import("./db.ts");
24 useDatabase(dir);
25 return dir;
26 }
src/inventory.ts +13 −6
@@ -17,16 +17,23 @@ export type StockEntry = {
1717 sku: string;
1818 };
1919
20 const record = db.query(
21 "INSERT INTO stock_entry (variant_id, delta, reason, order_id) VALUES (?1, ?2, ?3, ?4)",
22 );
20 /** The row that says why stock moved. Prepared on the handle in use, never
21 * held across one: `db.query` caches the statement on the database itself. */
22 const record = (variantId: number, delta: number, reason: string, orderId?: number): void => {
23 db.query("INSERT INTO stock_entry (variant_id, delta, reason, order_id) VALUES (?1, ?2, ?3, ?4)").run(
24 variantId,
25 delta,
26 reason,
27 orderId ?? null,
28 );
29 };
2330
2431 /** Add stock (a delivery, a correction, a restock after a refund). */
2532 export function addStock(variantId: number, quantity: number, reason: string, orderId?: number): void {
2633 if (quantity <= 0) return;
2734 db.transaction(() => {
2835 db.query("UPDATE variant SET stock = stock + ?2 WHERE id = ?1").run(variantId, quantity);
29 record.run(variantId, quantity, reason, orderId ?? null);
36 record(variantId, quantity, reason, orderId);
3037 })();
3138 }
3239
@@ -41,7 +48,7 @@ export function takeStock(variantId: number, quantity: number, reason: string, o
4148 .query("UPDATE variant SET stock = stock - ?2 WHERE id = ?1 AND stock >= ?2")
4249 .run(variantId, quantity).changes;
4350 if (changes !== 1) return false;
44 record.run(variantId, -quantity, reason, orderId ?? null);
51 record(variantId, -quantity, reason, orderId);
4552 return true;
4653 })();
4754 }
@@ -56,7 +63,7 @@ export function setStock(variantId: number, target: number, reason: string): voi
5663 else
5764 db.transaction(() => {
5865 db.query("UPDATE variant SET stock = stock + ?2 WHERE id = ?1").run(variantId, delta);
59 record.run(variantId, delta, reason, null);
66 record(variantId, delta, reason);
6067 })();
6168 }
6269
src/pages/checkout.ts +2 −1
@@ -5,6 +5,7 @@
55 * on the page. The mock is labelled as a mock everywhere it appears.
66 */
77
8 import { HOME_COUNTRY } from "../tax.ts";
89 import { csrfField, escape } from "../html.ts";
910 import { dueCents, formatCents } from "../money.ts";
1011 import { codeBox, totals } from "./cart.ts";
@@ -62,7 +63,7 @@ ${quote.problems.concat(quote.code_problems).map((problem) => `<p class="notice
6263 ${field("city", "City", values.city ?? "", { autocomplete: "address-level2" })}
6364 ${field("region", "State or region", values.region ?? "", { required: false, autocomplete: "address-level1" })}
6465 ${field("postal", "Postcode", values.postal ?? "", { autocomplete: "postal-code", maxlength: 20 })}
65 ${field("country", "Country", values.country ?? "United States", { autocomplete: "country-name" })}
66 ${field("country", "Country", values.country ?? HOME_COUNTRY, { autocomplete: "country-name" })}
6667 </div>
6768 <p class="muted" style="font-size:.88rem; margin-top:1rem">Payment is taken next, by ${escape(gateway.label)}.</p>
6869 <button class="btn" style="margin-top:.5rem">Continue to payment</button>
src/server.test.ts +45 −4
@@ -1,9 +1,10 @@
11 import { expect, test } from "bun:test";
2 import { mkdtempSync } from "node:fs";
3 import { tmpdir } from "node:os";
42 import { join } from "node:path";
3 import { freshData } from "./fresh.ts";
54
6 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "bazaar-"));
5 // A database of this file's own, opened before anything that queries is
6 // imported — the module registry is shared with every other test file.
7 const dataDir = await freshData("server");
78 process.env.FORGE_APP_IDENTITY_KEY = "the-key-the-forge-signs-with";
89 process.env.PORT = "0";
910 delete process.env.STRIPE_SECRET_KEY;
@@ -14,6 +15,7 @@ const { db } = await import("./db.ts");
1415 const limit = await import("./ratelimit.ts");
1516 const giftcards = await import("./giftcards.ts");
1617 const discounts = await import("./discounts.ts");
18 const tax = await import("./tax.ts");
1719 const base = `http://${server.hostname}:${server.port}`;
1820
1921 type Person = { id: string; name: string; role: "user" | "admin"; expiresMs?: number };
@@ -265,6 +267,8 @@ test("a cart cookie from somewhere else is ignored", async () => {
265267 });
266268
267269 test("checkout refuses an address it cannot use", async () => {
270 const orders = (): number => db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM "order"').get()!.n;
271 const before = orders();
268272 const shopper = new Shopper();
269273 const product = await shopper.page("/p/house-espresso");
270274 await shopper.post(
@@ -288,7 +292,7 @@ test("checkout refuses an address it cannot use", async () => {
288292 );
289293 expect(response.status).toBe(200);
290294 expect(await response.text()).toContain("does not look right");
291 expect(db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM "order"').get()!.n).toBe(0);
295 expect(orders()).toBe(before); // nothing was written, whatever was there already
292296 });
293297
294298 test("a purchase takes stock, records a charge and writes a receipt", async () => {
@@ -1093,3 +1097,40 @@ test("a category longer than one page is paged, and every page is reachable", as
10931097 expect(found).toContain("Page 1 of 2");
10941098 expect(await fetch(base + "/search?q=Bulk+Item&page=2").then((r) => r.text())).toContain("Bulk Item 26");
10951099 });
1100
1101 test("the rate the cart estimates is the rate the order charges, from one table", async () => {
1102 limit.forget();
1103 // The setting behind the table is set to something else on purpose: what
1104 // the cart shows has to come from the row the order will be priced from,
1105 // not from a second source that can drift away from it.
1106 tax.setDefaultRateBp(2000);
1107 tax.upsertRate({ country: tax.HOME_COUNTRY, region: "", rate_bp: 1000, label: "US sales tax", active: true });
1108 try {
1109 const shopper = new Shopper();
1110 await addToCart(shopper, "COF-GUJI-250", 2); // $38.00 of goods
1111
1112 const cart = await shopper.page("/cart");
1113 expect(cart).toContain("10.00%, estimated");
1114 expect(cart).toContain("$3.80"); // 10% of $38, and not 20% of it
1115 expect(cart).not.toContain("20.00%");
1116 expect(await shopper.page("/checkout")).toContain("10.00%");
1117
1118 const placed = await submitCheckout(shopper, { email: "[email protected]" });
1119 expect(placed.status).toBe(303);
1120 const order = orderNamed(orderAt(placed.headers.get("location")!).number);
1121 expect(order.tax_rate_bp).toBe(1000);
1122 expect(order.tax_cents).toBe(380);
1123 expect(order.total_cents).toBe(4780); // $38.00 + $6.00 shipping + $3.80 tax
1124 } finally {
1125 tax.setDefaultRateBp(tax.DEFAULT_RATE_BP);
1126 tax.upsertRate({ country: tax.HOME_COUNTRY, region: "", rate_bp: 850, label: "US sales tax", active: true });
1127 }
1128 });
1129
1130 test("this file's rows are this file's own", async () => {
1131 // The isolation every test file depends on: the database the app modules
1132 // are querying is the one this file opened, not one another file chose.
1133 const current = await import("./db.ts");
1134 expect(current.dataDir).toBe(dataDir);
1135 expect(db.filename).toBe(join(dataDir, "app.db"));
1136 });
src/shop.ts +2 −1
@@ -18,6 +18,7 @@ import * as orders from "./orders.ts";
1818 import * as giftCardPages from "./pages/giftcard.ts";
1919 import * as pages from "./pages/checkout.ts";
2020 import * as storefront from "./pages/storefront.ts";
21 import * as tax from "./tax.ts";
2122 import {
2223 categories,
2324 categoryBySlug,
@@ -244,7 +245,7 @@ export function checkoutForm(ctx: Ctx, error?: string, values: Record<string, st
244245 return render(
245246 ctx,
246247 { title: "Checkout" },
247 pages.checkout(quote, ctx.session, chosen(), idempotencyFor(ctx), { country: "United States", ...values }, error),
248 pages.checkout(quote, ctx.session, chosen(), idempotencyFor(ctx), { country: tax.HOME_COUNTRY, ...values }, error),
248249 );
249250 }
250251
src/tax.ts +24 −13
@@ -17,6 +17,15 @@ import { db, getSetting, setSetting, type TaxRate } from "./db.ts";
1717 export const DEFAULT_RATE_BP = 850;
1818 export const DEFAULT_LABEL = "Tax";
1919
20 /**
21 * Where a parcel goes until somebody says otherwise: the shop's own country.
22 * The checkout form offers it and the cart estimates with it, so the rate on
23 * the cart page and the rate the order charges are the same row of the same
24 * table — an estimate that is a different number from the bill is a bug.
25 */
26 export const HOME_COUNTRY = "United States";
27 export const home = (): Destination => ({ country: HOME_COUNTRY, region: "" });
28
2029 export type Destination = { country: string; region: string };
2130 export type Rate = { rate_bp: number; label: string };
2231
@@ -37,19 +46,21 @@ export const rates = (): TaxRate[] =>
3746 export const rateById = (id: number): TaxRate | null =>
3847 db.query<TaxRate, [number]>("SELECT * FROM tax_rate WHERE id = ?1").get(id);
3948
40 /** The rate for an address, or the default when the table does not know it. */
41 export function rateFor(destination?: Destination): Rate {
42 if (destination) {
43 const country = normalize(destination.country);
44 const region = normalize(destination.region);
45 const found = db
46 .query<TaxRate, [string, string]>(
47 `SELECT * FROM tax_rate WHERE active = 1 AND country = ?1 AND region IN (?2, '')
48 ORDER BY region = ?2 DESC LIMIT 1`,
49 )
50 .get(country, region);
51 if (found) return { rate_bp: found.rate_bp, label: found.label };
52 }
49 /**
50 * The rate for an address, or the default when the table does not know it.
51 * With no address it is the home country's rate — the one the checkout form
52 * is about to offer — so an estimate is this same lookup, never a second one.
53 */
54 export function rateFor(destination: Destination = home()): Rate {
55 const country = normalize(destination.country);
56 const region = normalize(destination.region);
57 const found = db
58 .query<TaxRate, [string, string]>(
59 `SELECT * FROM tax_rate WHERE active = 1 AND country = ?1 AND region IN (?2, '')
60 ORDER BY region = ?2 DESC LIMIT 1`,
61 )
62 .get(country, region);
63 if (found) return { rate_bp: found.rate_bp, label: found.label };
5364 return { rate_bp: defaultRateBp(), label: DEFAULT_LABEL };
5465 }
5566
src/units.test.ts +36 −3
@@ -2,9 +2,11 @@ import { expect, test } from "bun:test";
22 import { mkdtempSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
5 import { freshData } from "./fresh.ts";
56
6 // A database of its own: these tests write rows, and never into a real shop.
7 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "bazaar-units-"));
7 // A database of its own: these tests write rows, and never into a real shop
8 // or into another test file's rows, whichever order the files run in.
9 const dataDir = await freshData("units");
810
911 const { dueCents, formatCents, parseCents, parseQuantity, sumLines } = await import("./money.ts");
1012 const { encode, decode, put, add, quote } = await import("./cart.ts");
@@ -19,7 +21,8 @@ const desk = await import("./desk.ts");
1921 const discounts = await import("./discounts.ts");
2022 const giftcards = await import("./giftcards.ts");
2123 const tax = await import("./tax.ts");
22 const { db, forgetPasswordSettings, getSetting, setSetting } = await import("./db.ts");
24 const dbModule = await import("./db.ts");
25 const { db, forgetPasswordSettings, getSetting, setSetting } = dbModule;
2326
2427 catalog.seed();
2528 discounts.seed();
@@ -586,3 +589,33 @@ test("a card's holder is told what happened, not what the shop wrote to itself",
586589 // The shop's own view keeps every word of it.
587590 expect(giftcards.entriesOf(card.id)[0]!.reason).toBe("chargeback from the buyer, do not honour");
588591 });
592
593 test("a test file's database is its own, from the factory", () => {
594 // bun test shares one process and one module registry across files, so this
595 // is the assertion that keeps every other one honest: the database the app
596 // modules query is the one this file opened.
597 expect(dbModule.dataDir).toBe(dataDir);
598 expect(db.filename).toBe(join(dataDir, "app.db"));
599 // Every order in here is one this file wrote by hand; an order that went
600 // through checkout belongs to the server tests and to their database.
601 expect(db.query<{ n: number }, []>(`SELECT COUNT(*) AS n FROM "order" WHERE access_token <> 'token'`).get()!.n).toBe(
602 0,
603 );
604 // Which is what makes the settings this file moves — tax_default_bp among
605 // them — safe: they move nothing in any other file's database.
606 });
607
608 test("two databases from the factory know nothing of each other", () => {
609 const elsewhere = dbModule.open(mkdtempSync(join(tmpdir(), "bazaar-elsewhere-")));
610 try {
611 elsewhere.query("INSERT INTO category (slug, name) VALUES ('only-there', 'Only there')").run();
612 const counted = (handle: typeof db): number =>
613 handle.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM category WHERE slug = 'only-there'").get()!.n;
614 expect(counted(elsewhere)).toBe(1);
615 expect(counted(db)).toBe(0);
616 // And the schema is all there in the new one: it is the same factory.
617 expect(elsewhere.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM tax_rate").get()!.n).toBe(0);
618 } finally {
619 elsewhere.close(false);
620 }
621 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs