fix(po): stop PocketBase auto-cancellation from killing PO creation

The Purchase Order form never reached the server. Creating a PO with two
or more "Manual Entry" rows fired N parallel POSTs to
/api/collections/stock_items/records via Promise.all. The PocketBase JS
SDK auto-cancels any in-flight request whose method + path match a newer
one, so the first POST was aborted with:

  ClientResponseError 0: The request was autocancelled.

Promise.all then rejected before the PO create ever ran, so the PO was
never created - only an orphan stock item per attempt. Confirmed against
the production PB logs: two stock_items POSTs today, zero
purchase_orders POSTs, and two orphan stock_items rows with no PO.

Fixes:
- src/lib/pocketbase.ts: pb.autoCancellation(false) on the browser
  client. Every server-side client in this repo already did this
  (scripts/*.mjs, server/po-api.mjs) - the browser client was the one
  place it was missing.
- PurchaseOrderForm: resolve rows sequentially instead of Promise.all,
  and build the snapshot in the same pass so manual rows carry the real
  stock item id instead of "".
- PurchaseOrderForm: disable the submit button while submitting, so a
  double click cannot create two POs.
- PurchaseOrderForm: compare SKUs trimmed, so a stray trailing space
  does not create a duplicate stock item.
- po-photo-ingest.mjs, jei_skill.ts, setup-users.js: same
  autoCancellation(false) guard.
This commit is contained in:
Dave 2026-10-22 07:56:30 +02:00
parent a30270e77f
commit 2b56053e1c
5 changed files with 97 additions and 44 deletions

View file

@ -44,6 +44,9 @@ async function main() {
}
const pb = new PocketBase(PB_URL);
// Never let the SDK auto-cancel a request: parallel stock-item lookups in
// photoIngest share one method+path and would otherwise cancel each other.
pb.autoCancellation(false);
await pb.admins.authWithPassword(PB_EMAIL, PB_PASSWORD);
const photoUrl = pathToFileURL(path.join(ROOT, 'src/lib/api/photoIngest.ts')).href;

View file

@ -1,6 +1,8 @@
import PocketBase from 'pocketbase';
const pb = new PocketBase(process.env.VITE_PB_URL || 'http://127.0.0.1:8090');
// Never let the SDK auto-cancel a request (see src/lib/pocketbase.ts).
pb.autoCancellation(false);
async function ensureUsersCollection() {
try {

View file

@ -285,62 +285,83 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
manualUnitPrice: Number(item.manualUnitPrice) || 0,
}));
// Resolve items — for manual entries, create stock items on-the-fly
const resolvedItems = await Promise.all(
normalizedItems.map(async (item) => {
if (item.entryMode === 'manual') {
// Look up by manual SKU first
const existing = stockItems.find(si => si.sku === item.manualSku && item.manualSku);
if (existing) return existing.id;
// Resolve items — for manual entries, create stock items on-the-fly.
//
// SEQUENTIAL ON PURPOSE. The PocketBase JS SDK cancels any in-flight
// request that shares a method + path with a newer one, so two parallel
// POSTs to /api/collections/stock_items/records cancel each other: the
// first is aborted with "The request was autocancelled", the rejection
// propagates, and the purchase order is never created. Resolving one row
// at a time also keeps the SKU de-dup check honest — in parallel, two
// rows with the same new SKU both miss the cache and both create it.
//
// One pass produces both the PB relation ids and the snapshot rows, so
// the snapshot carries the real stock item id instead of "".
const resolvedItems: string[] = [];
const snapshotItems: Array<{
stockItemId: string;
itemName: string;
sku: string;
quantity: number;
costPrice: number;
costPriceGBP: number;
entryMode: 'stock' | 'manual';
}> = [];
// Compare SKUs with surrounding whitespace trimmed so a stray trailing
// space ("JEIPILOT2 ") does not create a duplicate stock item.
const sameSku = (a: string, b: string) =>
!!a && !!b && a.trim().toLowerCase() === b.trim().toLowerCase();
for (const item of normalizedItems) {
if (item.entryMode === 'manual') {
const sku = item.manualSku || '';
let stockItem = stockItems.find(si => sameSku(si.sku, sku));
if (!stockItem) {
// Create new stock item
const newStockItem = await pb.collection('stock_items').create({
name: item.manualDescription || item.manualSku || 'Unnamed Item',
sku: item.manualSku || '',
name: item.manualDescription || sku || 'Unnamed Item',
sku,
cost_price_gbp: item.manualUnitPrice || 0,
quantity: 0,
});
// Update local stock items cache
setStockItems(prev => [...prev, {
stockItem = {
id: newStockItem.id,
name: newStockItem.name,
sku: newStockItem.sku,
costPrice: newStockItem.cost_price || 0,
costPriceGBP: newStockItem.cost_price_gbp || 0,
}]);
return newStockItem.id;
}
return item.stockItemId;
})
);
// Build snapshot items — store full details so PO survives stock item deletion
const snapshotItems = await Promise.all(
normalizedItems.map(async (item) => {
if (item.entryMode === 'manual') {
const existing = stockItems.find(si => si.sku === item.manualSku && item.manualSku);
const stockItemId = existing ? existing.id : '';
return {
stockItemId,
itemName: item.manualDescription || item.manualSku || 'Unnamed Item',
sku: item.manualSku || '',
quantity: item.quantity,
costPrice: 0,
costPriceGBP: item.manualUnitPrice || 0,
entryMode: 'manual' as const,
};
// Update local stock items cache
setStockItems(prev => [...prev, stockItem!]);
}
const stockItem = stockItems.find(si => si.id === item.stockItemId);
return {
stockItemId: item.stockItemId,
itemName: item.itemName,
sku: item.sku,
resolvedItems.push(stockItem.id);
snapshotItems.push({
stockItemId: stockItem.id,
itemName: item.manualDescription || sku || 'Unnamed Item',
sku,
quantity: item.quantity,
costPrice: Number(item.costPrice || 0),
costPriceGBP: Number(stockItem?.costPriceGBP || 0),
entryMode: 'stock' as const,
};
})
);
costPrice: 0,
costPriceGBP: item.manualUnitPrice || 0,
entryMode: 'manual',
});
continue;
}
const stockItem = stockItems.find(si => si.id === item.stockItemId);
resolvedItems.push(item.stockItemId);
snapshotItems.push({
stockItemId: item.stockItemId,
itemName: item.itemName,
sku: item.sku,
quantity: item.quantity,
costPrice: Number(item.costPrice || 0),
costPriceGBP: Number(stockItem?.costPriceGBP || 0),
entryMode: 'stock',
});
}
// Build the PO data
const purchaseOrderData: Record<string, any> = {
@ -767,7 +788,13 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
onFilesChange={setAttachedFiles}
/>
<Button type="submit" className="w-full bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98] transition-all">Create Purchase Order</Button>
<Button
type="submit"
disabled={form.formState.isSubmitting}
className="w-full bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98] transition-all disabled:opacity-60 disabled:pointer-events-none"
>
{form.formState.isSubmitting ? "Creating…" : "Create Purchase Order"}
</Button>
</form>
</Form>
</div>

View file

@ -20,6 +20,24 @@ function buildPbUrl(raw?: string): string {
const PB_URL = buildPbUrl(import.meta.env.VITE_PB_URL);
const pb = new PocketBase(PB_URL);
// Disable the SDK's request auto-cancellation.
//
// The PocketBase JS SDK aborts any in-flight request whose method + path
// match a NEWER request. That is fine for "latest search wins" list reads,
// but it silently kills concurrent WRITES to the same collection: the first
// POST /api/collections/<c>/records is aborted with
// "ClientResponseError 0: The request was autocancelled", the surrounding
// Promise.all rejects, and the write never lands.
//
// That is exactly how the Purchase Order form failed: two manual rows each
// created a stock item in parallel, the first was cancelled, Promise.all
// rejected, and the PO itself was never created.
//
// Every server-side client in this repo already calls
// `pb.autoCancellation(false)` (scripts/*.mjs, server/po-api.mjs). The
// browser client was the one place it was missing.
pb.autoCancellation(false);
// Optional: expose resolved URL for diagnostics
// @ts-ignore
pb.__resolvedUrl = PB_URL;

View file

@ -3,6 +3,9 @@ import PocketBase from 'pocketbase'
// PocketBase URL - change this to match your environment
const PB_URL = 'https://pb.jeistk.apphub.co.za'
const pb = new PocketBase(PB_URL)
// Never let the SDK auto-cancel a request: concurrent writes to the same
// collection share one method+path and would otherwise cancel each other.
pb.autoCancellation(false)
// Authenticate as admin using API token (set in env or replace here)
const API_TOKEN = import.meta.env.VITE_PB_ADMIN_TOKEN || ''