From d108b3ec39ce8be9c19c2eb46120909db36bef9d Mon Sep 17 00:00:00 2001 From: Julien Lucca Date: Fri, 16 Oct 2026 23:56:27 +0200 Subject: [PATCH 1/2] S5a-es: the indexer draws Elm purchases of batch offers from the live batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan §9 (D2). Inside transferSale's existing transaction, after the replay guard: a `batches` offer calls the backend's frozen public.shop_draw_from_batch(product, units, block time) in a savepoint with SET LOCAL lock_timeout = '2s' (the caller's timeout is restored, since SET LOCAL outlives a released savepoint). Deadlock / serialization / lock timeouts retry 3× 50 ms apart; anything else, or a 4th failure, rolls back only the savepoint and records the order unassigned with draw_outcome = 'error' — the block commits and the indexer keeps running. batch_id and draw_outcome are written only for batch offers; every other insert is unchanged. A startup check (and again after every draw error) logs "S5 draw function missing" to stderr when the function or order_items.draw_outcome is absent. The product lookup no longer filters is_deleted, and a product that truly doesn't exist is a ResolveError (skip, re-indexable) instead of a crash loop. First test runner (mocha): a scratch DB built from written_columns.json (byte-identical to the backend's) plus the migration's definitions; 15 cases incl. window edges in São Paulo and Costa Rica, a forced deadlock, a held product lock, the function dropped, replay, and "transferSale writes only manifest columns". Green on Node 16.20.2 and 26. CI workflow added (GitHub Actions is out of credits, so it won't run yet). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TeHsQCwWCBJzuob6cYEPo --- .github/workflows/test.yml | 31 ++ .nvmrc | 1 + package.json | 5 +- src/app.js | 5 + src/updaters/community.js | 96 +++++- test/fixtures/generate_s5_schema.js | 82 +++++ test/fixtures/s5_schema.sql | 89 ++++++ test/fixtures/written_columns.json | 43 +++ test/transfer_sale_draw.test.js | 443 ++++++++++++++++++++++++++++ yarn.lock | 443 +++++++++++++++++++++++++++- 10 files changed, 1226 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 .nvmrc create mode 100644 test/fixtures/generate_s5_schema.js create mode 100644 test/fixtures/s5_schema.sql create mode 100644 test/fixtures/written_columns.json create mode 100644 test/transfer_sale_draw.test.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..37acec8 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,31 @@ +name: Test + +on: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + # The box's version (see .nvmrc); upgrading it is separate work. + node-version-file: .nvmrc + - name: Install libpq headers (pg-native) + run: sudo apt-get update && sudo apt-get install -y libpq-dev + - run: yarn install --frozen-lockfile + - run: yarn format + - run: yarn test diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..cb406c6 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +16.20.2 diff --git a/package.json b/package.json index f056035..d2503c2 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "main": "src/app.js", "scripts": { "start": "standard ./src/ && node src/app.js", - "format": "standard ./src/", - "test": "echo \"Error: no test specified\" && exit 1" + "format": "standard --env mocha ./src/ ./test/", + "test": "mocha --exit test/*.test.js" }, "author": "Julien Lucca", "license": "MIT", @@ -19,6 +19,7 @@ "pg-native": "^3.0.0" }, "devDependencies": { + "mocha": "^10.8.2", "standard": "^12.0.1" } } diff --git a/src/app.js b/src/app.js index 0afa301..6836152 100644 --- a/src/app.js +++ b/src/app.js @@ -6,6 +6,7 @@ const { GetActionsHandler } = require('./GetActionsHandler') const { logInit, logExit } = require('./logging') const updaters = require('./updaters') +const { checkDrawFunction } = require('./updaters/community') const effects = [] const http = require('http') @@ -42,6 +43,10 @@ async function init () { )` ) + // S5: log 'S5 draw function missing' if the backend's draw function or + // order_items.draw_outcome isn't there; indexing carries on either way. + await checkDrawFunction(db) + const actionHandler = new GetActionsHandler( updaters, effects, diff --git a/src/updaters/community.js b/src/updaters/community.js index ad273de..2eee973 100644 --- a/src/updaters/community.js +++ b/src/updaters/community.js @@ -7,6 +7,70 @@ const config = require(`../config/${process.env.NODE_ENV || 'dev'}`) const { resolveClaimId, resolveCreatedActionId, resolveCreatedObjectiveId, ResolveError } = require('../chain') const { toUTC } = require('../dates') +// S5a-es (plan §9): an Elm purchase of a batch offer takes its units from the live batch +// through the backend's frozen DB function. Transient database errors are retried; anything +// else fails soft (order recorded unassigned) so a draw problem never halts the indexer. +const DRAW_TRANSIENT_CODES = ['40P01', '40001', '55P03'] +const DRAW_MAX_RETRIES = 3 +const DRAW_RETRY_DELAY_MS = 50 + +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) + +// Startup check, re-run after every draw error: the draw function and the +// order_items.draw_outcome column must exist (massive reads the schema once at startup and +// rejects unknown columns). The hourly cron greps the log for 'S5 draw function missing'; +// indexing carries on regardless. +async function checkDrawFunction (db) { + try { + const row = await db.instance.one( + `SELECT to_regprocedure('public.shop_draw_from_batch(bigint,integer,timestamp)') IS NOT NULL AS has_function, + EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'order_items' + AND column_name = 'draw_outcome') AS has_column` + ) + if (!row.has_function || !row.has_column) { + console.error(`S5 draw function missing (function: ${row.has_function}, order_items.draw_outcome: ${row.has_column})`) + return false + } + return true + } catch (e) { + logError('S5 draw function check failed', e) + return false + } +} + +// Draw `units` from the batch live at `at` (the block's time, never the wall clock). +// Each attempt is a savepoint (a nested pg-promise transaction), so a failed draw rolls +// back only itself and the outer transaction carries on. +// Resolves { batchId, outcome } with outcome 'drawn' | 'no_fit' | 'error'. +async function drawFromBatch (tx, productId, units, at) { + for (let attempt = 0; ; attempt++) { + try { + const row = await tx.instance.tx(async sp => { + const { previous } = await sp.one(`SELECT current_setting('lock_timeout') AS previous`) + await sp.none(`SET LOCAL lock_timeout = '2s'`) + const drawn = await sp.oneOrNone( + 'SELECT public.shop_draw_from_batch($1, $2, $3) AS batch_id', + [productId, units, at] + ) + // SET LOCAL outlives a released savepoint; put the caller's timeout back. + await sp.one(`SELECT set_config('lock_timeout', $1, true)`, [previous]) + return drawn + }) + const batchId = row && row.batch_id != null ? row.batch_id : null + return { batchId, outcome: batchId === null ? 'no_fit' : 'drawn' } + } catch (e) { + if (DRAW_TRANSIENT_CODES.includes(e.code) && attempt < DRAW_MAX_RETRIES) { + await sleep(DRAW_RETRY_DELAY_MS) + continue + } + logError('S5 draw failed; order recorded unassigned', e) + await checkDrawFunction(tx) + return { batchId: null, outcome: 'error' } + } + } +} + async function createCommunity (db, payload, blockInfo) { console.log(`Cambiatus >>> Create Community`, blockInfo.blockNumber) @@ -245,17 +309,25 @@ async function transferSale (db, payload, blockInfo, context) { }) if (Number(existing) > 0) return - const whereArg = { - id: payload.data.sale_id, - is_deleted: false - } + const whereArg = { id: payload.data.sale_id } - // Find sale + // Find sale. Deleted offers are still found: a purchase signed just as the seller + // deleted the offer is recorded like any other. A product that truly doesn't exist + // is a ResolveError (skipped, paged, re-indexable) rather than a crash loop. const sale = await tx.products.findOne(whereArg) if (sale === null) { - throw new Error('No sale data available') + throw new ResolveError(`transfersale: product ${payload.data.sale_id} not found`) } + const quantity = parseInt(payload.data.units) || 1 + + // Batch offers draw from the live batch at the block's time (a batch offer has + // track_stock false, so the units decrement below is skipped). The replay guard above + // already ran, so re-indexing a block never draws twice. + const draw = sale.availability === 'batches' + ? await drawFromBatch(tx, sale.id, quantity, toUTC(blockInfo.timestamp)) + : null + if (sale.track_stock) { const newUnits = sale.units - parseInt(payload.data.units) @@ -290,8 +362,7 @@ async function transferSale (db, payload, blockInfo, context) { // Write the matching line item. Kept inside the same guarded path so a // replay that hits orders_dedup_idx above never reaches this insert. - const quantity = parseInt(payload.data.units) || 1 - await tx.order_items.insert({ + const item = { order_id: order.id, product_id: sale.id, quantity: quantity, @@ -299,7 +370,13 @@ async function transferSale (db, payload, blockInfo, context) { title_snapshot: sale.title, inserted_at: toUTC(blockInfo.timestamp), updated_at: toUTC(blockInfo.timestamp) - }) + } + // Only batch offers carry the new keys; every other insert is exactly as before. + if (draw !== null) { + item.batch_id = draw.batchId + item.draw_outcome = draw.outcome + } + await tx.order_items.insert(item) } // Return the transaction so ledgered() can await it. Without the return the updater @@ -936,6 +1013,7 @@ async function assignRole (db, payload, blockInfo, _context) { } module.exports = { + checkDrawFunction, createCommunity, updateCommunity, netlink, diff --git a/test/fixtures/generate_s5_schema.js b/test/fixtures/generate_s5_schema.js new file mode 100644 index 0000000..8efc725 --- /dev/null +++ b/test/fixtures/generate_s5_schema.js @@ -0,0 +1,82 @@ +// Generates test/fixtures/s5_schema.sql: the tables transferSale writes, built from +// written_columns.json (the manifest shared with the backend repo), plus the fixed +// definitions copied from the backend's S5a-core migration (20261017100000_s5a_batches.exs). +// Run `node test/fixtures/generate_s5_schema.js > test/fixtures/s5_schema.sql` after the +// manifest changes; the test suite fails if the committed file is stale. +const manifest = require('./written_columns.json') + +// Not in the manifest (the indexer only UPDATEs products.units and reads the rest), so +// declared by hand: the columns transferSale reads. +const PRODUCTS = `CREATE TABLE products ( + id bigserial PRIMARY KEY, + title character varying(255), + units integer, + is_deleted boolean DEFAULT false, + track_stock boolean NOT NULL DEFAULT true, + availability character varying(255) +);` + +// Copied from the backend migration; the draw function is a frozen API. +const FIXED = `CREATE EXTENSION IF NOT EXISTS btree_gist; + +CREATE TABLE product_batches ( + id bigserial PRIMARY KEY, + product_id bigint NOT NULL REFERENCES products(id), + quantity integer NOT NULL, + remaining integer NOT NULL, + label character varying(255), + opens_at timestamp(0) without time zone NOT NULL, + closes_at timestamp(0) without time zone NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL DEFAULT now(), + updated_at timestamp(0) without time zone NOT NULL DEFAULT now(), + delivery_on date, + closed_at timestamp(0) without time zone, + CONSTRAINT product_batches_quantity_range CHECK (quantity BETWEEN 1 AND 200), + CONSTRAINT product_batches_remaining_non_negative CHECK (remaining >= 0), + CONSTRAINT product_batches_remaining_lte_quantity CHECK (remaining <= quantity), + CONSTRAINT product_batches_window_order CHECK (closes_at > opens_at) +); + +ALTER TABLE product_batches ADD CONSTRAINT product_batches_no_overlap + EXCLUDE USING gist (product_id WITH =, tsrange(opens_at, closes_at, '[)') WITH &&) + WHERE (closed_at IS NULL); + +CREATE TABLE _processed_actions ( + global_seq bigint PRIMARY KEY, + processed_at timestamptz NOT NULL DEFAULT now() +); + +CREATE FUNCTION public.shop_draw_from_batch(p_product_id bigint, p_quantity integer, p_at timestamp) +RETURNS bigint LANGUAGE plpgsql AS $$ +DECLARE v_batch_id bigint; +BEGIN + PERFORM 1 FROM products WHERE id = p_product_id FOR NO KEY UPDATE; + UPDATE product_batches SET remaining = remaining - p_quantity + WHERE product_id = p_product_id AND closed_at IS NULL + AND opens_at <= p_at AND closes_at > p_at + AND remaining >= p_quantity + RETURNING id INTO v_batch_id; + RETURN v_batch_id; +END $$;` + +function table (name, spec) { + const columns = Object.entries(spec.columns).map(([column, type]) => ` ${column} ${type}`) + return `CREATE TABLE ${name} (\n id bigserial PRIMARY KEY,\n${columns.join(',\n')}\n);` +} + +function generate () { + return [ + '-- GENERATED by test/fixtures/generate_s5_schema.js from written_columns.json. Do not edit.', + PRODUCTS, + FIXED, + table('orders', manifest.orders), + 'CREATE UNIQUE INDEX orders_dedup_idx ON orders (created_tx, from_id, product_id);', + table('order_items', manifest.order_items), + 'ALTER TABLE order_items ADD CONSTRAINT order_items_draw_outcome_check CHECK (draw_outcome IN (\'drawn\', \'no_fit\', \'error\'));', + '' + ].join('\n\n') +} + +if (require.main === module) process.stdout.write(generate()) + +module.exports = { generate } diff --git a/test/fixtures/s5_schema.sql b/test/fixtures/s5_schema.sql new file mode 100644 index 0000000..8ee57b8 --- /dev/null +++ b/test/fixtures/s5_schema.sql @@ -0,0 +1,89 @@ +-- GENERATED by test/fixtures/generate_s5_schema.js from written_columns.json. Do not edit. + +CREATE TABLE products ( + id bigserial PRIMARY KEY, + title character varying(255), + units integer, + is_deleted boolean DEFAULT false, + track_stock boolean NOT NULL DEFAULT true, + availability character varying(255) +); + +CREATE EXTENSION IF NOT EXISTS btree_gist; + +CREATE TABLE product_batches ( + id bigserial PRIMARY KEY, + product_id bigint NOT NULL REFERENCES products(id), + quantity integer NOT NULL, + remaining integer NOT NULL, + label character varying(255), + opens_at timestamp(0) without time zone NOT NULL, + closes_at timestamp(0) without time zone NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL DEFAULT now(), + updated_at timestamp(0) without time zone NOT NULL DEFAULT now(), + delivery_on date, + closed_at timestamp(0) without time zone, + CONSTRAINT product_batches_quantity_range CHECK (quantity BETWEEN 1 AND 200), + CONSTRAINT product_batches_remaining_non_negative CHECK (remaining >= 0), + CONSTRAINT product_batches_remaining_lte_quantity CHECK (remaining <= quantity), + CONSTRAINT product_batches_window_order CHECK (closes_at > opens_at) +); + +ALTER TABLE product_batches ADD CONSTRAINT product_batches_no_overlap + EXCLUDE USING gist (product_id WITH =, tsrange(opens_at, closes_at, '[)') WITH &&) + WHERE (closed_at IS NULL); + +CREATE TABLE _processed_actions ( + global_seq bigint PRIMARY KEY, + processed_at timestamptz NOT NULL DEFAULT now() +); + +CREATE FUNCTION public.shop_draw_from_batch(p_product_id bigint, p_quantity integer, p_at timestamp) +RETURNS bigint LANGUAGE plpgsql AS $$ +DECLARE v_batch_id bigint; +BEGIN + PERFORM 1 FROM products WHERE id = p_product_id FOR NO KEY UPDATE; + UPDATE product_batches SET remaining = remaining - p_quantity + WHERE product_id = p_product_id AND closed_at IS NULL + AND opens_at <= p_at AND closes_at > p_at + AND remaining >= p_quantity + RETURNING id INTO v_batch_id; + RETURN v_batch_id; +END $$; + +CREATE TABLE orders ( + id bigserial PRIMARY KEY, + product_id bigint, + from_id character varying, + to_id character varying, + amount double precision, + units integer, + community_id character varying, + status character varying, + origin character varying, + total_token numeric, + created_block integer, + created_tx character varying, + created_at timestamp without time zone, + created_eos_account character varying, + inserted_at timestamp without time zone, + updated_at timestamp without time zone +); + +CREATE UNIQUE INDEX orders_dedup_idx ON orders (created_tx, from_id, product_id); + +CREATE TABLE order_items ( + id bigserial PRIMARY KEY, + order_id bigint, + product_id bigint, + batch_id bigint, + quantity integer, + unit_price_token numeric, + title_snapshot character varying, + draw_outcome character varying, + inserted_at timestamp without time zone, + updated_at timestamp without time zone +); + +ALTER TABLE order_items ADD CONSTRAINT order_items_draw_outcome_check CHECK (draw_outcome IN ('drawn', 'no_fit', 'error')); + diff --git a/test/fixtures/written_columns.json b/test/fixtures/written_columns.json new file mode 100644 index 0000000..d41cafa --- /dev/null +++ b/test/fixtures/written_columns.json @@ -0,0 +1,43 @@ +{ + "_comment": "Columns event-source writes (transferSale). The same file is committed in the event-source repo. orders and order_items are inserted by the indexer, so any NOT NULL column without a default must be listed; products is only updated. batch_id and draw_outcome are written by S5a-es, only for batch offers.", + "orders": { + "insert": true, + "columns": { + "product_id": "bigint", + "from_id": "character varying", + "to_id": "character varying", + "amount": "double precision", + "units": "integer", + "community_id": "character varying", + "status": "character varying", + "origin": "character varying", + "total_token": "numeric", + "created_block": "integer", + "created_tx": "character varying", + "created_at": "timestamp without time zone", + "created_eos_account": "character varying", + "inserted_at": "timestamp without time zone", + "updated_at": "timestamp without time zone" + } + }, + "order_items": { + "insert": true, + "columns": { + "order_id": "bigint", + "product_id": "bigint", + "batch_id": "bigint", + "quantity": "integer", + "unit_price_token": "numeric", + "title_snapshot": "character varying", + "draw_outcome": "character varying", + "inserted_at": "timestamp without time zone", + "updated_at": "timestamp without time zone" + } + }, + "products": { + "insert": false, + "columns": { + "units": "integer" + } + } +} diff --git a/test/transfer_sale_draw.test.js b/test/transfer_sale_draw.test.js new file mode 100644 index 0000000..9ec2909 --- /dev/null +++ b/test/transfer_sale_draw.test.js @@ -0,0 +1,443 @@ +/* eslint-env mocha */ +// S5a-es (plan §9.2): transferSale draws Elm purchases of batch offers from the live batch. +// Runs against a scratch database (s5a_es_test) it creates and drops itself. +process.env.NODE_ENV = process.env.NODE_ENV || 'dev' + +const assert = require('assert') +const fs = require('fs') +const path = require('path') +// The pg that massive's pg-promise runs on (the top-level `pg` is an older, unused copy). +const { Client } = require(require.resolve('pg', { paths: [require.resolve('pg-promise')] })) +const massive = require('massive') + +// community.js destructures logError at require time, so stub it first. +const logging = require('../src/logging') +const loggedErrors = [] +logging.logError = (message, error) => loggedErrors.push({ message, error }) + +const { transferSale, checkDrawFunction } = require('../src/updaters/community') +const { ResolveError } = require('../src/chain') +const { generate } = require('./fixtures/generate_s5_schema') +const manifest = require('./fixtures/written_columns.json') + +const SCRATCH_DB = 's5a_es_test' +const PG = { host: 'localhost', port: 5432, user: 'postgres', password: '' } +const SCHEMA_SQL = fs.readFileSync(path.join(__dirname, 'fixtures/s5_schema.sql'), 'utf8') + +const DRAW_FUNCTION_SQL = SCHEMA_SQL.match(/CREATE FUNCTION public\.shop_draw_from_batch[\s\S]*?END \$\$;/)[0] + +let db +let queries +let txCounter = 0 + +async function adminQuery (sql) { + const client = new Client({ ...PG, database: 'postgres' }) + await client.connect() + try { + await client.query(sql) + } finally { + await client.end() + } +} + +function scratchClient () { + return new Client({ ...PG, database: SCRATCH_DB }) +} + +async function insertProduct (fields = {}) { + return db.products.insert({ + title: 'Batch cake', + units: null, + track_stock: false, + availability: 'batches', + ...fields + }) +} + +async function insertBatch (productId, fields = {}) { + return db.product_batches.insert({ + product_id: productId, + quantity: 10, + remaining: 10, + opens_at: '2026-10-20 03:00:00', + closes_at: '2026-10-22 03:00:00', + ...fields + }) +} + +function purchase (productId, { units = 2, at = '2026-10-21T12:00:00Z' } = {}) { + txCounter += 1 + const payload = { + transactionId: `tx${txCounter}`, + authorization: [{ actor: 'buyer' }], + data: { from: 'buyer', to: 'seller', quantity: '10.0000 TST', units, sale_id: productId } + } + const blockInfo = { blockNumber: 100 + txCounter, timestamp: new Date(at) } + return { payload, blockInfo } +} + +async function buy (productId, opts) { + const { payload, blockInfo } = purchase(productId, opts) + await transferSale(db, payload, blockInfo, {}) + return payload +} + +async function itemFor (payload) { + const [item] = await db.instance.query( + `SELECT oi.* FROM order_items oi JOIN orders o ON o.id = oi.order_id WHERE o.created_tx = $1`, + [payload.transactionId] + ) + return item +} + +async function remaining (batchId) { + return (await db.product_batches.findOne(batchId)).remaining +} + +describe('transferSale draws from the live batch (S5a-es)', function () { + this.timeout(20000) + + before(async function () { + await adminQuery(`DROP DATABASE IF EXISTS ${SCRATCH_DB}`) + await adminQuery(`CREATE DATABASE ${SCRATCH_DB}`) + // Short deadlock detection so the forced-deadlock case is fast. + await adminQuery(`ALTER DATABASE ${SCRATCH_DB} SET deadlock_timeout = '500ms'`) + const client = scratchClient() + await client.connect() + await client.query(SCHEMA_SQL) + await client.end() + + queries = [] + db = await massive({ ...PG, database: SCRATCH_DB, max: 3 }, {}, { + query: e => queries.push(e.query) + }) + }) + + after(async function () { + if (db) await db.instance.$pool.end() + await adminQuery(`DROP DATABASE IF EXISTS ${SCRATCH_DB}`) + }) + + beforeEach(async function () { + await db.instance.none('TRUNCATE order_items, orders, product_batches, products, _processed_actions RESTART IDENTITY CASCADE') + queries.length = 0 + loggedErrors.length = 0 + }) + + // AT-e13: the schema stub is generated from written_columns.json + it('the committed schema stub matches the manifest generator', function () { + assert.strictEqual(SCHEMA_SQL, generate()) + }) + + // AT-e10: a purchase that fits is drawn from the live batch + it('draws a purchase that fits', async function () { + const product = await insertProduct() + const batch = await insertBatch(product.id) + const item = await itemFor(await buy(product.id, { units: 3 })) + + assert.strictEqual(item.draw_outcome, 'drawn') + assert.strictEqual(String(item.batch_id), String(batch.id)) + assert.strictEqual(item.quantity, 3) + assert.strictEqual(await remaining(batch.id), 7) + assert.strictEqual((await db.products.findOne(product.id)).units, null) + assert.strictEqual(loggedErrors.length, 0) + }) + + // AT-e10: a purchase larger than what remains is recorded unassigned + it('records a purchase larger than the remainder as no_fit', async function () { + const product = await insertProduct() + const batch = await insertBatch(product.id, { remaining: 1 }) + const item = await itemFor(await buy(product.id, { units: 2 })) + + assert.strictEqual(item.draw_outcome, 'no_fit') + assert.strictEqual(item.batch_id, null) + assert.strictEqual(await remaining(batch.id), 1) + assert.strictEqual(await db.orders.count({ product_id: product.id }) | 0, 1) + }) + + // AT-e10: the window is judged at the block's time in the batch's local day, São Paulo (UTC-3) + // and Costa Rica (UTC-6): last day's closes_at is local midnight, stored as UTC wall time. + const zones = [ + { name: 'São Paulo', closesAt: '2026-10-22 03:00:00', before: '2026-10-22T02:59:58Z', after: '2026-10-22T03:00:02Z' }, + { name: 'Costa Rica', closesAt: '2026-10-22 06:00:00', before: '2026-10-22T05:59:58Z', after: '2026-10-22T06:00:02Z' } + ] + zones.forEach(zone => { + it(`draws at 23:59:58 local on the last day and not at 00:00:02 next day (${zone.name})`, async function () { + const product = await insertProduct() + const batch = await insertBatch(product.id, { + opens_at: zone.closesAt.replace('22', '20'), + closes_at: zone.closesAt + }) + + const inside = await itemFor(await buy(product.id, { units: 1, at: zone.before })) + assert.strictEqual(inside.draw_outcome, 'drawn') + assert.strictEqual(String(inside.batch_id), String(batch.id)) + + const outside = await itemFor(await buy(product.id, { units: 1, at: zone.after })) + assert.strictEqual(outside.draw_outcome, 'no_fit') + assert.strictEqual(outside.batch_id, null) + assert.strictEqual(await remaining(batch.id), 9) + }) + }) + + // AT-e10: a closed batch is never drawn from + it('records a purchase against a closed batch as no_fit', async function () { + const product = await insertProduct() + const batch = await insertBatch(product.id, { closed_at: '2026-10-21 10:00:00' }) + const item = await itemFor(await buy(product.id)) + + assert.strictEqual(item.draw_outcome, 'no_fit') + assert.strictEqual(item.batch_id, null) + assert.strictEqual(await remaining(batch.id), 10) + }) + + // AT-e11: a deadlock inside the savepoint is retried and the purchase is then drawn. + // Connection B holds the batch row; the draw (A) takes the product lock then waits on that + // row; B then asks for the product lock. Postgres aborts A (B's own deadlock_timeout is + // huge, so A's check is the one that finds the cycle), the savepoint rolls back, and + // A's retry succeeds once B has committed. + it('retries a forced deadlock and then draws', async function () { + const product = await insertProduct() + const batch = await insertBatch(product.id) + + const b = scratchClient() + await b.connect() + try { + await b.query(`SET deadlock_timeout = '1h'`) + await b.query('BEGIN') + await b.query('UPDATE product_batches SET label = $1 WHERE id = $2', ['held', batch.id]) + + const sale = buy(product.id, { units: 2 }) + + // Wait until the draw is blocked on B's row lock, then close the cycle. + const deadline = Date.now() + 10000 + for (;;) { + const { rows } = await b.query( + `SELECT 1 FROM pg_stat_activity + WHERE datname = $1 AND wait_event_type = 'Lock' AND pid <> pg_backend_pid()`, + [SCRATCH_DB] + ) + if (rows.length > 0) break + assert.ok(Date.now() < deadline, 'the draw never blocked') + await new Promise(resolve => setTimeout(resolve, 5)) + } + const bLock = b.query('SELECT id FROM products WHERE id = $1 FOR UPDATE', [product.id]) + .then(() => b.query('COMMIT')) + + const payload = await sale + await bLock + + const item = await itemFor(payload) + assert.strictEqual(item.draw_outcome, 'drawn') + assert.strictEqual(String(item.batch_id), String(batch.id)) + assert.strictEqual(await remaining(batch.id), 8) + const draws = queries.filter(q => /SELECT public\.shop_draw_from_batch\(/.test(q)) + assert.ok(draws.length >= 2, `expected a retried draw, saw ${draws.length} call(s)`) + assert.strictEqual(loggedErrors.length, 0) + assert.strictEqual(await db.orders.count({ product_id: product.id }) | 0, 1) + } finally { + await b.end() + } + }) + + // Keeps connection B's batch-row lock and, every time the draw is blocked on it, closes a + // deadlock cycle (B asks for the product lock, the draw is the victim), then releases the + // product lock again so the next attempt can start. Runs until `until` settles. + async function deadlockEveryAttempt (b, productId, until) { + let finished = false + until.then(() => { finished = true }, () => { finished = true }) + while (!finished) { + const { rows } = await b.query( + `SELECT 1 FROM pg_stat_activity + WHERE datname = $1 AND wait_event_type = 'Lock' AND pid <> pg_backend_pid()`, + [SCRATCH_DB] + ) + if (rows.length === 0) { + await new Promise(resolve => setTimeout(resolve, 5)) + continue + } + await b.query('SAVEPOINT attempt') + await b.query('SELECT id FROM products WHERE id = $1 FOR UPDATE', [productId]) + await b.query('ROLLBACK TO attempt') + } + } + + // AT-e11: with the deadlock forced on every attempt the draw is retried three times, then the + // order is recorded with 'error', the outer transaction commits and nothing throws + it('records error after three retries when every attempt deadlocks', async function () { + const product = await insertProduct() + const batch = await insertBatch(product.id) + + const b = scratchClient() + await b.connect() + try { + await b.query(`SET deadlock_timeout = '1h'`) + await b.query('BEGIN') + await b.query('UPDATE product_batches SET label = $1 WHERE id = $2', ['held', batch.id]) + + const sale = buy(product.id, { units: 2 }) + const driver = deadlockEveryAttempt(b, product.id, sale) + const payload = await sale + await driver + await b.query('COMMIT') + + const item = await itemFor(payload) + assert.strictEqual(item.draw_outcome, 'error') + assert.strictEqual(item.batch_id, null) + assert.strictEqual(await remaining(batch.id), 10) + assert.strictEqual(await db.orders.count({ product_id: product.id }) | 0, 1) + assert.strictEqual(queries.filter(q => /SELECT public\.shop_draw_from_batch\(/.test(q)).length, 4) + assert.strictEqual(loggedErrors.length, 1) + assert.strictEqual(loggedErrors[0].message, 'S5 draw failed; order recorded unassigned') + assert.strictEqual(loggedErrors[0].error.code, '40P01') + } finally { + await b.end() + } + }) + + // AT-e18: a product lock held by another connection makes the draw fail with 55P03 after 2s, + // retry, and end 'error' without stalling the indexer + it('fails soft with error when the product lock is held (55P03)', async function () { + this.timeout(30000) + const product = await insertProduct() + const batch = await insertBatch(product.id) + + const b = scratchClient() + await b.connect() + try { + await b.query('BEGIN') + await b.query('SELECT id FROM products WHERE id = $1 FOR UPDATE', [product.id]) + + const started = Date.now() + const payload = await buy(product.id) + const elapsed = Date.now() - started + await b.query('ROLLBACK') + + // four attempts at 2s each plus three 50 ms pauses: bounded, not stalled + assert.ok(elapsed >= 7500 && elapsed < 15000, `took ${elapsed}ms`) + const item = await itemFor(payload) + assert.strictEqual(item.draw_outcome, 'error') + assert.strictEqual(item.batch_id, null) + assert.strictEqual(await remaining(batch.id), 10) + assert.strictEqual(queries.filter(q => /SELECT public\.shop_draw_from_batch\(/.test(q)).length, 4) + assert.strictEqual(loggedErrors[0].error.code, '55P03') + } finally { + await b.end() + } + }) + + // AT-e10: the same payload twice leaves one order and one draw + it('records one order and one draw for a replayed payload', async function () { + const product = await insertProduct() + const batch = await insertBatch(product.id) + const { payload, blockInfo } = purchase(product.id, { units: 4 }) + + await transferSale(db, payload, blockInfo, {}) + await transferSale(db, payload, blockInfo, {}) + + assert.strictEqual(await db.orders.count({ product_id: product.id }) | 0, 1) + assert.strictEqual(await db.order_items.count({ product_id: product.id }) | 0, 1) + assert.strictEqual(await remaining(batch.id), 6) + }) + + // AT-e18: a purchase of a soft-deleted offer is still recorded (drawn if it fits); a missing product is a ResolveError + it('records a purchase of a deleted offer and raises ResolveError for a missing one', async function () { + const product = await insertProduct({ is_deleted: true }) + await insertBatch(product.id) + const item = await itemFor(await buy(product.id, { units: 1 })) + assert.strictEqual(item.draw_outcome, 'drawn') + + await assert.rejects(buy(999999), ResolveError) + assert.strictEqual(await db.orders.count({ product_id: 999999 }) | 0, 0) + }) + + // AT-e18: a non-batch offer's order_items insert carries no batch_id or draw_outcome key + it('writes no batch_id or draw_outcome for a stock offer and still decrements units', async function () { + const product = await insertProduct({ availability: 'stock', track_stock: true, units: 5 }) + await buy(product.id, { units: 2 }) + + assert.strictEqual((await db.products.findOne(product.id)).units, 3) + const itemInsert = queries.find(q => /INSERT INTO "?order_items"?/i.test(q)) + assert.ok(itemInsert, 'no order_items insert seen') + assert.ok(!/batch_id|draw_outcome/.test(itemInsert), itemInsert) + assert.ok(!queries.some(q => /shop_draw_from_batch/.test(q))) + const [item] = await db.order_items.find({ product_id: product.id }) + assert.strictEqual(item.draw_outcome, null) + assert.strictEqual(item.batch_id, null) + }) + + // AT-e13: transferSale writes only columns listed in written_columns.json + it('writes only manifest columns', async function () { + const stockProduct = await insertProduct({ availability: 'stock', track_stock: true, units: 5 }) + const batchProduct = await insertProduct() + await insertBatch(batchProduct.id) + await buy(stockProduct.id) + await buy(batchProduct.id) + + const written = { orders: new Set(), order_items: new Set(), products: new Set() } + queries.forEach(q => { + let m = q.match(/INSERT INTO "?(orders|order_items)"?\s*\(([^)]*)\)/i) + if (m) m[2].split(',').forEach(c => written[m[1]].add(c.replace(/["\s]/g, ''))) + m = q.match(/UPDATE "?products"?\s+SET\s+(.*?)\s+WHERE/is) + if (m) m[1].split(',').forEach(c => written.products.add(c.split('=')[0].replace(/["\s]/g, ''))) + }) + assert.ok(written.orders.size > 0 && written.order_items.size > 0 && written.products.size > 0, 'no writes captured') + Object.keys(written).forEach(table => { + written[table].forEach(column => { + assert.ok(column in manifest[table].columns, `${table}.${column} is written but not in written_columns.json`) + }) + }) + }) + + // AT-e09: with the draw function dropped the order is recorded unassigned with 'error', + // the error is logged, nothing is thrown, and the startup check reports the function missing + describe('draw function missing', function () { + afterEach(async function () { + await db.instance.none(`DROP FUNCTION IF EXISTS public.shop_draw_from_batch(bigint, integer, timestamp)`) + await db.instance.none(DRAW_FUNCTION_SQL) + await db.instance.none('ALTER TABLE order_items DROP COLUMN IF EXISTS draw_outcome') + await db.instance.none('ALTER TABLE order_items ADD COLUMN draw_outcome character varying') + await db.instance.none(`ALTER TABLE order_items ADD CONSTRAINT order_items_draw_outcome_check CHECK (draw_outcome IN ('drawn', 'no_fit', 'error'))`) + }) + + // AT-e18: with order_items.draw_outcome missing, the startup check reports it + it('the startup check reports a missing draw_outcome column', async function () { + await db.instance.none('ALTER TABLE order_items DROP COLUMN draw_outcome') + const lines = [] + const originalLog = console.error + console.error = (...args) => lines.push(args.join(' ')) + try { + assert.strictEqual(await checkDrawFunction(db), false) + } finally { + console.error = originalLog + } + assert.ok(lines.some(l => l.includes('S5 draw function missing')), lines.join('\n')) + }) + + it('fails soft with draw_outcome error and the startup check says so', async function () { + const product = await insertProduct() + await insertBatch(product.id) + assert.strictEqual(await checkDrawFunction(db), true) + + await db.instance.none('DROP FUNCTION public.shop_draw_from_batch(bigint, integer, timestamp)') + const lines = [] + const originalLog = console.error + console.error = (...args) => lines.push(args.join(' ')) + let payload + try { + payload = await buy(product.id) + assert.strictEqual(await checkDrawFunction(db), false) + } finally { + console.error = originalLog + } + + const item = await itemFor(payload) + assert.strictEqual(item.draw_outcome, 'error') + assert.strictEqual(item.batch_id, null) + assert.strictEqual(await db.orders.count({ product_id: product.id }) | 0, 1) + assert.ok(loggedErrors.some(e => e.message === 'S5 draw failed; order recorded unassigned')) + // once from the re-check after the draw error, once from the explicit startup check + assert.ok(lines.filter(l => l.includes('S5 draw function missing')).length >= 2, lines.join('\n')) + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index 47f21e0..4ed7392 100644 --- a/yarn.lock +++ b/yarn.lock @@ -98,6 +98,11 @@ ajv@^6.0.1, ajv@^6.12.3, ajv@^6.5.0: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ansi-colors@^4.1.3: + version "4.1.3" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" + integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw== + ansi-escapes@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" @@ -113,6 +118,11 @@ ansi-regex@^3.0.0: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + ansi-styles@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" @@ -125,6 +135,21 @@ ansi-styles@^3.2.1: dependencies: color-convert "^1.9.0" +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -132,6 +157,11 @@ argparse@^1.0.7: dependencies: sprintf-js "~1.0.2" +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + array-includes@^3.0.3, array-includes@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.1.tgz#cdd67e6852bdf9c1215460786732255ed2459348" @@ -199,6 +229,11 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" +binary-extensions@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" + integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== + bindings@1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" @@ -222,6 +257,25 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" +brace-expansion@^2.0.1: + version "2.1.7" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.1.7.tgz#14ea3836731823bb24740225ea43b941b436f9d1" + integrity sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g== + dependencies: + balanced-match "^1.0.0" + +braces@~3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +browser-stdout@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" + integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== + bson@^1.1.4: version "1.1.5" resolved "https://registry.yarnpkg.com/bson/-/bson-1.1.5.tgz#2aaae98fcdf6750c0848b0cba1ddec3c73060a34" @@ -249,6 +303,11 @@ callsites@^0.2.0: resolved "https://registry.yarnpkg.com/callsites/-/callsites-0.2.0.tgz#afab96262910a7f33c19a5775825c69f34e350ca" integrity sha1-r6uWJikQp/M8GaV3WCXGnzTjUMo= +camelcase@^6.0.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + caseless@~0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" @@ -274,11 +333,34 @@ chalk@^2.0.0, chalk@^2.1.0: escape-string-regexp "^1.0.5" supports-color "^5.3.0" +chalk@^4.1.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + chardet@^0.4.0: version "0.4.2" resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.4.2.tgz#b5473b33dc97c424e5d98dc87d55d4d8a29c8bf2" integrity sha1-tUc7M9yXxCTl2Y3IfVXU2KKci/I= +chokidar@^3.5.3: + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + circular-json@^0.3.1: version "0.3.3" resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.3.3.tgz#815c99ea84f6809529d2f45791bdf82711352d66" @@ -296,6 +378,15 @@ cli-width@^2.0.0: resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.1.tgz#b0433d0b4e9c847ef18868a4ef16fd5fc8271c48" integrity sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw== +cliui@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -303,11 +394,23 @@ color-convert@^1.9.0: dependencies: color-name "1.1.3" +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + color-name@1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + combined-stream@^1.0.6, combined-stream@~1.0.6: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" @@ -382,6 +485,18 @@ debug@^3.1.0: dependencies: ms "^2.1.1" +debug@^4.3.5: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +decamelize@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837" + integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ== + deep-is@~0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" @@ -440,6 +555,11 @@ denque@^1.4.1: resolved "https://registry.yarnpkg.com/denque/-/denque-1.4.1.tgz#6744ff7641c148c3f8a69c307e51235c1f4a37cf" integrity sha512-OfzPuSZKGcgr96rf1oODnfjqBFmr1DVoc/TrItj3Ohe0Ah1C5WX5Baquw/9U9KovnQ88EqmJbD66rKYUQYN1tQ== +diff@^5.2.0: + version "5.2.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.2.tgz#0a4742797281d09cfa699b79ea32d27723623bad" + integrity sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A== + doctrine@1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa" @@ -463,6 +583,11 @@ ecc-jsbn@~0.1.1: jsbn "~0.1.0" safer-buffer "^2.1.0" +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + error-ex@^1.2.0, error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" @@ -526,11 +651,21 @@ es6-promisify@^5.0.0: dependencies: es6-promise "^4.0.3" +escalade@^3.1.1: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + eslint-config-standard-jsx@6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/eslint-config-standard-jsx/-/eslint-config-standard-jsx-6.0.2.tgz#90c9aa16ac2c4f8970c13fc7efc608bacd02da70" @@ -780,6 +915,13 @@ file-uri-to-path@1.0.0: resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + find-root@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" @@ -792,6 +934,14 @@ find-up@^2.0.0, find-up@^2.1.0: dependencies: locate-path "^2.0.0" +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + flat-cache@^1.2.1: version "1.3.4" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-1.3.4.tgz#2c2ef77525cc2929007dfffa1dd314aa9c9dee6f" @@ -802,6 +952,11 @@ flat-cache@^1.2.1: rimraf "~2.6.2" write "^0.2.1" +flat@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" + integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== + forever-agent@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" @@ -821,6 +976,11 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= +fsevents@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -831,6 +991,11 @@ functional-red-black-tree@^1.0.1: resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + get-stdin@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-6.0.0.tgz#9e09bf712b360ab9225e812048f71fde9c89657b" @@ -843,6 +1008,13 @@ getpass@^0.1.1: dependencies: assert-plus "^1.0.0" +glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + glob@7.1.4: version "7.1.4" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.4.tgz#aa608a2f6c577ad357e1ae5a5c26d9a8d1969255" @@ -867,6 +1039,17 @@ glob@7.1.6, glob@^7.0.5, glob@^7.1.2, glob@^7.1.3: once "^1.3.0" path-is-absolute "^1.0.0" +glob@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e" + integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^5.0.1" + once "^1.3.0" + globals@^11.7.0: version "11.12.0" resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" @@ -902,6 +1085,11 @@ has-flag@^3.0.0: resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + has-symbols@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8" @@ -914,6 +1102,11 @@ has@^1.0.1, has@^1.0.3: dependencies: function-bind "^1.1.1" +he@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + hosted-git-info@^2.1.4: version "2.8.9" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" @@ -995,6 +1188,13 @@ is-arrayish@^0.2.1: resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + is-callable@^1.1.4, is-callable@^1.2.0: version "1.2.1" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.1.tgz#4d1e21a4f437509d25ce55f8184350771421c96d" @@ -1005,16 +1205,43 @@ is-date-object@^1.0.1: resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.2.tgz#bda736f2cd8fd06d32844e7743bfa7494c3bfd7e" integrity sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g== +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + is-fullwidth-code-point@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-glob@^4.0.1, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + is-negative-zero@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.0.tgz#9553b121b0fac28869da9ed459e20c7543788461" integrity sha1-lVOxIbD6wohp2p7UWeIMdUN4hGE= +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-plain-obj@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" + integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== + is-regex@^1.1.0, is-regex@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9" @@ -1044,6 +1271,11 @@ is-typedarray@~1.0.0: resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= +is-unicode-supported@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" + integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + isarray@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" @@ -1082,6 +1314,13 @@ js-yaml@^3.11.0: argparse "^1.0.7" esprima "^4.0.0" +js-yaml@^4.1.0: + version "4.3.2" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.2.tgz#8e44fb14a2643c59726bb15787b5f1512cb3d3fb" + integrity sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA== + dependencies: + argparse "^2.0.1" + jsbn@~0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" @@ -1174,6 +1413,13 @@ locate-path@^2.0.0: p-locate "^2.0.0" path-exists "^3.0.0" +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + lodash@4.17.14: version "4.17.14" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.14.tgz#9ce487ae66c96254fe20b599f21b6816028078ba" @@ -1189,6 +1435,14 @@ lodash@^4.17.19, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.3.0: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== +log-symbols@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" + integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== + dependencies: + chalk "^4.1.0" + is-unicode-supported "^0.1.0" + loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" @@ -1268,6 +1522,13 @@ minimatch@^3.0.3, minimatch@^3.0.4: dependencies: brace-expansion "^1.1.7" +minimatch@^5.0.1, minimatch@^5.1.6: + version "5.1.9" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.9.tgz#1293ef15db0098b394540e8f9f744f9fda8dee4b" + integrity sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw== + dependencies: + brace-expansion "^2.0.1" + minimist@^1.1.0, minimist@^1.2.5: version "1.2.6" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" @@ -1280,6 +1541,32 @@ mkdirp@^0.5.1: dependencies: minimist "^1.2.5" +mocha@^10.8.2: + version "10.8.2" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.8.2.tgz#8d8342d016ed411b12a429eb731b825f961afb96" + integrity sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg== + dependencies: + ansi-colors "^4.1.3" + browser-stdout "^1.3.1" + chokidar "^3.5.3" + debug "^4.3.5" + diff "^5.2.0" + escape-string-regexp "^4.0.0" + find-up "^5.0.0" + glob "^8.1.0" + he "^1.2.0" + js-yaml "^4.1.0" + log-symbols "^4.1.0" + minimatch "^5.1.6" + ms "^2.1.3" + serialize-javascript "^6.0.2" + strip-json-comments "^3.1.1" + supports-color "^8.1.1" + workerpool "^6.5.1" + yargs "^16.2.0" + yargs-parser "^20.2.9" + yargs-unparser "^2.0.0" + mongodb@^3.1.3: version "3.6.2" resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.6.2.tgz#1154a4ac107bf1375112d83a29c5cf97704e96b6" @@ -1303,6 +1590,11 @@ ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + murmurhash@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/murmurhash/-/murmurhash-1.0.0.tgz#d0e4556892317d984af632b6a88d5605beef26a7" @@ -1338,6 +1630,11 @@ normalize-package-data@^2.3.2: semver "2 || 3 || 4 || 5" validate-npm-package-license "^3.0.1" +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + oauth-sign@~0.9.0: version "0.9.0" resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" @@ -1406,6 +1703,13 @@ p-limit@^1.1.0: dependencies: p-try "^1.0.0" +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + p-locate@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" @@ -1413,6 +1717,13 @@ p-locate@^2.0.0: dependencies: p-limit "^1.1.0" +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + p-try@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" @@ -1448,6 +1759,11 @@ path-exists@^3.0.0: resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + path-is-absolute@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" @@ -1679,6 +1995,11 @@ pgpass@1.x: dependencies: split "^1.0.0" +picomatch@^2.0.4, picomatch@^2.2.1: + version "2.3.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601" + integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== + pify@^2.0.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" @@ -1784,6 +2105,13 @@ qs@~6.5.2: resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== +randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + react-is@^16.8.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -1829,6 +2157,13 @@ readable-stream@^2.3.5: string_decoder "~1.1.1" util-deprecate "~1.0.1" +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + regexpp@^2.0.0, regexpp@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.1.tgz#8d19d31cf632482b589049f8281f93dbcba4d07f" @@ -1876,6 +2211,11 @@ request@^2.87.0: tunnel-agent "^0.6.0" uuid "^3.3.2" +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + require-uncached@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/require-uncached/-/require-uncached-1.0.3.tgz#4e0d56d6c9662fd31e43011c4b95aa49955421d3" @@ -1941,7 +2281,7 @@ rxjs@^5.5.2: dependencies: symbol-observable "1.0.1" -safe-buffer@^5.0.1, safe-buffer@^5.1.1, safe-buffer@^5.1.2: +safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -1973,6 +2313,13 @@ semver@4.3.2: resolved "https://registry.yarnpkg.com/semver/-/semver-4.3.2.tgz#c7a07158a80bedd052355b770d82d6640f803be7" integrity sha1-x6BxWKgL7dBSNVt3DYLWZA+AO+c= +serialize-javascript@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" + integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== + dependencies: + randombytes "^2.1.0" + shebang-command@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" @@ -2115,6 +2462,15 @@ string-width@^2.1.0, string-width@^2.1.1: is-fullwidth-code-point "^2.0.0" strip-ansi "^4.0.0" +string-width@^4.1.0, string-width@^4.2.0: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string.prototype.trimend@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz#85812a6b847ac002270f5808146064c995fb6913" @@ -2157,6 +2513,13 @@ strip-ansi@^4.0.0: dependencies: ansi-regex "^3.0.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-bom@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" @@ -2167,6 +2530,11 @@ strip-json-comments@^2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= +strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + supports-color@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" @@ -2179,6 +2547,20 @@ supports-color@^5.3.0: dependencies: has-flag "^3.0.0" +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-color@^8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + symbol-observable@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.0.1.tgz#8340fc4702c3122df5d22288f88283f513d3fdd4" @@ -2213,6 +2595,13 @@ tmp@^0.0.33: dependencies: os-tmpdir "~1.0.2" +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + tough-cookie@^2.3.3, tough-cookie@~2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" @@ -2296,6 +2685,20 @@ word-wrap@~1.2.3: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== +workerpool@^6.5.1: + version "6.5.1" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.5.1.tgz#060f73b39d0caf97c6db64da004cd01b4c099544" + integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" @@ -2312,3 +2715,41 @@ xtend@^4.0.0, xtend@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yargs-parser@^20.2.2, yargs-parser@^20.2.9: + version "20.2.9" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" + integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== + +yargs-unparser@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb" + integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== + dependencies: + camelcase "^6.0.0" + decamelize "^4.0.0" + flat "^5.0.2" + is-plain-obj "^2.1.0" + +yargs@^16.2.0: + version "16.2.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.2.tgz#c56731dca0d2788ae0866dd3c83907d6bab85f7d" + integrity sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== From 98216572f72430a34f9924929438e3c703dce1e0 Mon Sep 17 00:00:00 2001 From: Julien Lucca Date: Sat, 17 Oct 2026 00:16:26 +0200 Subject: [PATCH 2/2] S5a-es: follow the draw function's review fix (p_quantity > 0, public. tables) The backend's S5a-core migration now refuses a zero or negative quantity and schema-qualifies its tables; the fixture copy follows, regenerated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TeHsQCwWCBJzuob6cYEPo --- test/fixtures/generate_s5_schema.js | 6 +++--- test/fixtures/s5_schema.sql | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/fixtures/generate_s5_schema.js b/test/fixtures/generate_s5_schema.js index 8efc725..93e3a06 100644 --- a/test/fixtures/generate_s5_schema.js +++ b/test/fixtures/generate_s5_schema.js @@ -50,11 +50,11 @@ CREATE FUNCTION public.shop_draw_from_batch(p_product_id bigint, p_quantity inte RETURNS bigint LANGUAGE plpgsql AS $$ DECLARE v_batch_id bigint; BEGIN - PERFORM 1 FROM products WHERE id = p_product_id FOR NO KEY UPDATE; - UPDATE product_batches SET remaining = remaining - p_quantity + PERFORM 1 FROM public.products WHERE id = p_product_id FOR NO KEY UPDATE; + UPDATE public.product_batches SET remaining = remaining - p_quantity WHERE product_id = p_product_id AND closed_at IS NULL AND opens_at <= p_at AND closes_at > p_at - AND remaining >= p_quantity + AND remaining >= p_quantity AND p_quantity > 0 RETURNING id INTO v_batch_id; RETURN v_batch_id; END $$;` diff --git a/test/fixtures/s5_schema.sql b/test/fixtures/s5_schema.sql index 8ee57b8..8aab9b0 100644 --- a/test/fixtures/s5_schema.sql +++ b/test/fixtures/s5_schema.sql @@ -42,11 +42,11 @@ CREATE FUNCTION public.shop_draw_from_batch(p_product_id bigint, p_quantity inte RETURNS bigint LANGUAGE plpgsql AS $$ DECLARE v_batch_id bigint; BEGIN - PERFORM 1 FROM products WHERE id = p_product_id FOR NO KEY UPDATE; - UPDATE product_batches SET remaining = remaining - p_quantity + PERFORM 1 FROM public.products WHERE id = p_product_id FOR NO KEY UPDATE; + UPDATE public.product_batches SET remaining = remaining - p_quantity WHERE product_id = p_product_id AND closed_at IS NULL AND opens_at <= p_at AND closes_at > p_at - AND remaining >= p_quantity + AND remaining >= p_quantity AND p_quantity > 0 RETURNING id INTO v_batch_id; RETURN v_batch_id; END $$;