@porulle/core 0.57.1 → 0.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/auth/auth-failure.d.ts +6 -1
  2. package/dist/auth/auth-failure.d.ts.map +1 -1
  3. package/dist/auth/auth-failure.js +4 -0
  4. package/dist/auth/middleware.d.ts.map +1 -1
  5. package/dist/auth/middleware.js +17 -3
  6. package/dist/config/types.d.ts +2 -0
  7. package/dist/config/types.d.ts.map +1 -1
  8. package/dist/index.d.ts +3 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/kernel/hooks/bulk-pairs.d.ts +10 -0
  11. package/dist/kernel/hooks/bulk-pairs.d.ts.map +1 -0
  12. package/dist/kernel/hooks/bulk-pairs.js +20 -0
  13. package/dist/kernel/plugin/manifest.d.ts.map +1 -1
  14. package/dist/kernel/plugin/manifest.js +2 -0
  15. package/dist/modules/audit/hooks.d.ts.map +1 -1
  16. package/dist/modules/audit/hooks.js +16 -0
  17. package/dist/modules/audit/service.d.ts +2 -0
  18. package/dist/modules/audit/service.d.ts.map +1 -1
  19. package/dist/modules/audit/service.js +20 -0
  20. package/dist/modules/channels/adapter.d.ts +10 -0
  21. package/dist/modules/channels/adapter.d.ts.map +1 -1
  22. package/dist/modules/inventory/repository/index.d.ts +11 -0
  23. package/dist/modules/inventory/repository/index.d.ts.map +1 -1
  24. package/dist/modules/inventory/repository/index.js +40 -0
  25. package/dist/modules/inventory/service.d.ts +32 -0
  26. package/dist/modules/inventory/service.d.ts.map +1 -1
  27. package/dist/modules/inventory/service.js +97 -0
  28. package/dist/modules/webhooks/hook.d.ts +7 -0
  29. package/dist/modules/webhooks/hook.d.ts.map +1 -1
  30. package/dist/modules/webhooks/hook.js +31 -0
  31. package/dist/runtime/kernel-register-hooks.d.ts.map +1 -1
  32. package/dist/runtime/kernel-register-hooks.js +4 -1
  33. package/package.json +3 -3
  34. package/src/auth/auth-failure.ts +6 -1
  35. package/src/auth/middleware.ts +18 -3
  36. package/src/config/types.ts +2 -0
  37. package/src/index.ts +3 -0
  38. package/src/kernel/hooks/bulk-pairs.ts +23 -0
  39. package/src/kernel/plugin/manifest.ts +2 -0
  40. package/src/modules/audit/hooks.ts +17 -0
  41. package/src/modules/audit/service.ts +21 -0
  42. package/src/modules/channels/adapter.ts +7 -0
  43. package/src/modules/inventory/repository/index.ts +57 -0
  44. package/src/modules/inventory/service.ts +116 -0
  45. package/src/modules/webhooks/hook.ts +33 -0
  46. package/src/runtime/kernel-register-hooks.ts +7 -1
@@ -291,6 +291,103 @@ export class InventoryService {
291
291
  ...(input.variantId !== undefined ? { variantId: input.variantId } : {}),
292
292
  }, actor, ctx);
293
293
  }
294
+ /**
295
+ * `setAbsolute` for a whole page of levels, set-based — the inventory sync's write.
296
+ *
297
+ * setAbsolute per level is a permission check, a row lock, a clamped write, a movement row and an
298
+ * `inventory.afterAdjust` each: a 27k-variant store synced at ~20 levels per Workflow step and
299
+ * every changed variant re-marked its product. This keeps the same invariants in a CONSTANT
300
+ * number of statements per call:
301
+ * - `inventory:adjust` is asserted once for the page (refused → nothing is written);
302
+ * - the default warehouse (`pickWarehouse`) and the actor's org, once;
303
+ * - an unchanged level writes nothing and is not announced; a missing level is created;
304
+ * - quantities clamp at 0 (as `GREATEST(0, …)` does) and `version` is bumped;
305
+ * - one `adjustment` movement per changed level, carrying its delta.
306
+ * It announces the page ONCE through `inventory.afterAdjustMany`, grouped by product — NOT
307
+ * `inventory.afterAdjust` per level. Core's audit and webhook subscribers handle the bulk hook,
308
+ * so every change is still audited and delivered; a plugin that subscribes to `afterAdjust` must
309
+ * also subscribe to `afterAdjustMany` (the kernel refuses to boot otherwise).
310
+ */
311
+ async setAbsoluteMany(rows, actor, ctx, options = {}) {
312
+ try {
313
+ assertPermission(actor ?? null, "inventory:adjust");
314
+ }
315
+ catch (error) {
316
+ return Err(toCommerceError(error));
317
+ }
318
+ const orgId = resolveOrgIdForCommerce(actor ?? ctx?.actor ?? null, this.deps.config);
319
+ if (rows.length === 0)
320
+ return Ok({ organizationId: orgId, entities: [] });
321
+ const warehouseId = await this.pickWarehouse(actor, ctx);
322
+ const reason = options.reason ?? "External store absolute inventory sync";
323
+ const performedBy = actor?.userId ?? "system";
324
+ const keyOf = (entityId, variantId) => `${entityId}\u0000${variantId ?? ""}`;
325
+ const write = async (txCtx) => {
326
+ const entityIds = [...new Set(rows.map((row) => row.entityId))];
327
+ const existing = new Map((await this.repo.findLevelsForUpdate(orgId, warehouseId, entityIds, txCtx))
328
+ .map((level) => [keyOf(level.entityId, level.variantId), level]));
329
+ const updates = [];
330
+ const inserts = [];
331
+ const seen = new Set();
332
+ for (const row of rows) {
333
+ const key = keyOf(row.entityId, row.variantId ?? null);
334
+ if (seen.has(key))
335
+ continue;
336
+ seen.add(key);
337
+ const quantity = Math.max(0, row.quantity);
338
+ const level = existing.get(key);
339
+ if (level === undefined) {
340
+ inserts.push({
341
+ organizationId: orgId, entityId: row.entityId, warehouseId, quantityOnHand: quantity,
342
+ quantityReserved: 0, quantityIncoming: 0, ...(row.variantId !== undefined ? { variantId: row.variantId } : {}),
343
+ });
344
+ }
345
+ else if (level.quantityOnHand !== quantity) {
346
+ updates.push({ id: level.id, quantity, before: level.quantityOnHand });
347
+ }
348
+ }
349
+ if (updates.length === 0 && inserts.length === 0)
350
+ return { organizationId: orgId, entities: [] };
351
+ const beforeById = new Map(updates.map((update) => [update.id, update.before]));
352
+ const updated = await this.repo.setLevelQuantities(orgId, updates, txCtx);
353
+ const created = await this.repo.createLevels(inserts, txCtx);
354
+ const changed = [...updated, ...created];
355
+ await this.repo.createMovements(changed.map((level) => ({
356
+ organizationId: orgId,
357
+ entityId: level.entityId,
358
+ warehouseId,
359
+ type: "adjustment",
360
+ quantity: level.quantityOnHand - (beforeById.get(level.id) ?? 0),
361
+ reason,
362
+ performedBy,
363
+ ...(level.variantId !== null ? { variantId: level.variantId } : {}),
364
+ })), txCtx);
365
+ const byEntity = new Map();
366
+ for (const level of changed)
367
+ byEntity.set(level.entityId, [...(byEntity.get(level.entityId) ?? []), level]);
368
+ const result = {
369
+ organizationId: orgId,
370
+ entities: [...byEntity].map(([entityId, levels]) => ({ entityId, levels })),
371
+ };
372
+ const hookCtx = createHookContext({
373
+ actor: actor ?? null,
374
+ tx: txCtx.tx,
375
+ logger: createLogger("inventory.adjustMany"),
376
+ services: this.deps.services,
377
+ context: { moduleName: "inventory" },
378
+ database: { db: this.deps.database.db },
379
+ commerceConfig: this.deps.config,
380
+ });
381
+ await runAfterHooks(this.deps.hooks.resolve("inventory.afterAdjustMany"), null, result, "update", hookCtx, (hook) => this.deps.hooks.runsInTransaction(hook));
382
+ return result;
383
+ };
384
+ try {
385
+ return Ok(await this.withTransaction(ctx, async (tx) => write(ctx?.tx ? ctx : createTxContext(tx, { actor: actor ?? null }))));
386
+ }
387
+ catch (error) {
388
+ return Err(toCommerceError(error));
389
+ }
390
+ }
294
391
  /**
295
392
  * Deduct inventory on fulfillment (system-level, no permission check).
296
393
  *
@@ -1,4 +1,5 @@
1
1
  import type { AfterHook } from "../../kernel/hooks/types.js";
2
+ import type { InventoryAdjustManyResult } from "../inventory/service.js";
2
3
  /**
3
4
  * Webhook delivery hook — enqueues delivery jobs instead of blocking.
4
5
  *
@@ -10,4 +11,10 @@ import type { AfterHook } from "../../kernel/hooks/types.js";
10
11
  * asynchronously with retries. The HTTP response returns immediately.
11
12
  */
12
13
  export declare const deliverWebhooks: AfterHook<unknown>;
14
+ /**
15
+ * `inventory.afterAdjustMany` — a page of changed levels. Subscribers get exactly what
16
+ * `inventory.afterAdjust` gave them: one `inventory.update` event per changed level, per endpoint.
17
+ * Only the endpoint lookup is shared across the page.
18
+ */
19
+ export declare const deliverWebhooksForAdjustMany: AfterHook<InventoryAdjustManyResult>;
13
20
  //# sourceMappingURL=hook.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"hook.d.ts","sourceRoot":"","sources":["../../../src/modules/webhooks/hook.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,6BAA6B,CAAC;AAE7D;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,EAAE,SAAS,CAAC,OAAO,CA2B9C,CAAC"}
1
+ {"version":3,"file":"hook.d.ts","sourceRoot":"","sources":["../../../src/modules/webhooks/hook.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,6BAA6B,CAAC;AAC7D,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,yBAAyB,CAAC;AAEzE;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,EAAE,SAAS,CAAC,OAAO,CA2B9C,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,4BAA4B,EAAE,SAAS,CAAC,yBAAyB,CAyB7E,CAAC"}
@@ -33,3 +33,34 @@ export const deliverWebhooks = async ({ result, operation, context }) => {
33
33
  });
34
34
  }
35
35
  };
36
+ /**
37
+ * `inventory.afterAdjustMany` — a page of changed levels. Subscribers get exactly what
38
+ * `inventory.afterAdjust` gave them: one `inventory.update` event per changed level, per endpoint.
39
+ * Only the endpoint lookup is shared across the page.
40
+ */
41
+ export const deliverWebhooksForAdjustMany = async ({ result, operation, context }) => {
42
+ const eventName = `${String(context.context.moduleName ?? "unknown")}.${operation}`;
43
+ const webhooksService = context.services.webhooks;
44
+ const orgId = resolveOrgIdForCommerce(context.actor, context.commerceConfig);
45
+ const levels = result.entities.flatMap((entity) => entity.levels);
46
+ if (levels.length === 0)
47
+ return;
48
+ const endpoints = await webhooksService.getEndpointsForEvent(eventName, orgId);
49
+ if (!endpoints.ok)
50
+ return;
51
+ for (const endpoint of endpoints.value) {
52
+ for (const level of levels) {
53
+ await context.jobs.enqueue("webhooks/deliver", {
54
+ endpointId: endpoint.id,
55
+ endpointUrl: endpoint.url,
56
+ endpointSecret: endpoint.secret,
57
+ eventName,
58
+ payload: level,
59
+ }, {
60
+ organizationId: orgId,
61
+ maxAttempts: 5,
62
+ queue: "webhooks",
63
+ });
64
+ }
65
+ }
66
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"kernel-register-hooks.d.ts","sourceRoot":"","sources":["../../src/runtime/kernel-register-hooks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,EAAE,YAAY,EAAoB,MAAM,6BAA6B,CAAC;AAK7E,wBAAgB,6BAA6B,CAC3C,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,YAAY,GAClB,IAAI,CAmDN"}
1
+ {"version":3,"file":"kernel-register-hooks.d.ts","sourceRoot":"","sources":["../../src/runtime/kernel-register-hooks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,EAAE,YAAY,EAAoB,MAAM,6BAA6B,CAAC;AAM7E,wBAAgB,6BAA6B,CAC3C,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,YAAY,GAClB,IAAI,CAwDN"}
@@ -1,6 +1,7 @@
1
- import { deliverWebhooks } from "../modules/webhooks/hook.js";
1
+ import { deliverWebhooks, deliverWebhooksForAdjustMany } from "../modules/webhooks/hook.js";
2
2
  import { syncToSearchIndex } from "../modules/search/hooks.js";
3
3
  import { auditHooks } from "../modules/audit/hooks.js";
4
+ import { assertBulkHookPairs } from "../kernel/hooks/bulk-pairs.js";
4
5
  export function registerConfiguredKernelHooks(config, hooks) {
5
6
  for (const [entityType, entityConfig] of Object.entries(config.entities ?? {})) {
6
7
  const entityHooks = entityConfig.hooks ?? {};
@@ -17,6 +18,7 @@ export function registerConfiguredKernelHooks(config, hooks) {
17
18
  const hooksObject = moduleConfig?.hooks;
18
19
  if (!hooksObject)
19
20
  continue;
21
+ assertBulkHookPairs(Object.entries(hooksObject).filter(([, handlers]) => Array.isArray(handlers) && handlers.length > 0).map(([hookName]) => `${moduleName}.${hookName}`), `config.${moduleName}.hooks`);
20
22
  for (const [hookName, handlers] of Object.entries(hooksObject)) {
21
23
  const normalizedHandlers = (Array.isArray(handlers) ? handlers : []);
22
24
  hooks.registerConfigHooks(`${moduleName}.${hookName}`, normalizedHandlers);
@@ -28,6 +30,7 @@ export function registerConfiguredKernelHooks(config, hooks) {
28
30
  hooks.append("catalog.afterUpdate", deliverWebhooks);
29
31
  hooks.append("catalog.afterDelete", deliverWebhooks);
30
32
  hooks.append("inventory.afterAdjust", deliverWebhooks);
33
+ hooks.append("inventory.afterAdjustMany", deliverWebhooksForAdjustMany);
31
34
  hooks.append("customers.afterCreate", deliverWebhooks);
32
35
  hooks.append("customers.afterUpdate", deliverWebhooks);
33
36
  hooks.append("pricing.afterCreate", deliverWebhooks);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/core",
3
- "version": "0.57.1",
3
+ "version": "0.59.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -62,8 +62,8 @@
62
62
  "eslint": "^9.39.1",
63
63
  "typescript": "5.9.2",
64
64
  "vitest": "^3.2.4",
65
- "@porulle/typescript-config": "0.1.0",
66
- "@porulle/eslint-config": "0.1.0"
65
+ "@porulle/eslint-config": "0.1.0",
66
+ "@porulle/typescript-config": "0.1.0"
67
67
  },
68
68
  "publishConfig": {
69
69
  "access": "public"
@@ -11,8 +11,13 @@
11
11
  * better-call's `APIError` carries `name === "APIError"` and a numeric
12
12
  * `statusCode`; its `status` is a string such as `"UNAUTHORIZED"`.
13
13
  */
14
- export function isCredentialRejection(err: unknown): boolean {
14
+ export function isCredentialRejection(err: unknown): err is { name: "APIError"; statusCode: number } {
15
15
  if (typeof err !== "object" || err === null) return false;
16
16
  const { name, statusCode } = err as { name?: unknown; statusCode?: unknown };
17
17
  return name === "APIError" && typeof statusCode === "number";
18
18
  }
19
+
20
+ /** The HTTP status better-auth attached to a credential rejection, or null for anything else. */
21
+ export function credentialRejectionStatus(err: unknown): number | null {
22
+ return isCredentialRejection(err) ? err.statusCode : null;
23
+ }
@@ -4,7 +4,7 @@ import type { Actor } from "./types.js";
4
4
  import type { AuthInstance } from "./setup.js";
5
5
  import { getCustomerPermissions, resolveActor } from "./actor.js";
6
6
  import { DEFAULT_ORG_ID } from "./org.js";
7
- import { isCredentialRejection } from "./auth-failure.js";
7
+ import { credentialRejectionStatus, isCredentialRejection } from "./auth-failure.js";
8
8
  import { isStrictOrgResolution } from "./strict-org-resolution.js";
9
9
  import { isIdentityFreeRoute } from "./identity-free-routes.js";
10
10
 
@@ -208,15 +208,30 @@ export function authMiddleware(
208
208
  return;
209
209
  }
210
210
  } catch (err) {
211
- // An invalid, expired, or rate-limited key is a rejection: fall through
212
- // to anonymous. Anything else means the key was never checked.
211
+ // A rejection better-auth raised was an evaluated credential; anything else means the key
212
+ // was never checked, and is a fault. A rate-limited key is not a bad one: say so.
213
213
  if (!isCredentialRejection(err)) {
214
214
  reportAuthCheckFault(err, "api_key");
215
215
  throw err;
216
216
  }
217
+ if (credentialRejectionStatus(err) === 429) {
218
+ return c.json({ error: { code: "RATE_LIMITED", message: "Too many requests for this credential." } }, 429);
219
+ }
217
220
  }
218
221
  }
219
222
 
223
+ // A credential the caller PRESENTED and that did not verify is refused, not served as a guest.
224
+ // Falling through to anonymous gave a shopper whose token had expired a fresh guest cart in
225
+ // place of theirs, and told a broken client nothing. Absent credentials stay anonymous (guest
226
+ // checkout depends on it), and a stale session COOKIE is not "presented": browsers carry one on
227
+ // every public page, and refusing it would 401 logged-out browsing.
228
+ const presented = (c.req.header("x-api-key") ?? "").trim() !== "" || (c.req.header("authorization") ?? "").trim() !== "";
229
+ if (presented) {
230
+ const refused = c.json({ error: { code: "UNAUTHORIZED", message: "The presented credential could not be verified." } }, 401);
231
+ applyAuthenticateChallenge(refused);
232
+ return refused;
233
+ }
234
+
220
235
  if (!c.get("actor")) {
221
236
  // For anonymous requests in multi-store deployments, resolve the
222
237
  // store so catalog/search queries return the right store's data.
@@ -380,6 +380,8 @@ export interface OrdersConfig {
380
380
  export interface InventoryConfig {
381
381
  hooks?: {
382
382
  afterAdjust?: AfterHook<unknown>[];
383
+ /** One page of changed levels grouped by product (`inventory.setAbsoluteMany`). Required alongside `afterAdjust`. */
384
+ afterAdjustMany?: AfterHook<unknown>[];
383
385
  };
384
386
  }
385
387
 
package/src/index.ts CHANGED
@@ -228,6 +228,9 @@ export type {
228
228
  ImportRowFailureCode,
229
229
  } from "./modules/catalog/import-service.js";
230
230
  export { writeEntityLinks, removeEntityLinks, linkFieldPaths } from "./modules/catalog/entity-links.js";
231
+ /** The `inventory.afterAdjustMany` payload: a page's changed levels, grouped by product. */
232
+ export type { InventoryAdjustManyResult } from "./modules/inventory/service.js";
233
+ export type { InventoryLevel } from "./modules/inventory/repository/index.js";
231
234
  export type { EntityLinkRemovals, EntityLinkRows, EntityMediaRole, WrittenEntityLinks } from "./modules/catalog/entity-links.js";
232
235
  export type {
233
236
  TxContext,
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Hooks with a bulk sibling. An operation that writes a whole page at once announces ONLY the bulk
3
+ * hook — `inventory.setAbsoluteMany` fires `inventory.afterAdjustMany` once per page, not
4
+ * `inventory.afterAdjust` per level — so a subscriber to the single hook alone would go silently
5
+ * blind to every bulk write. Registering the single without the bulk is refused at boot, loudly,
6
+ * naming who did it: a boot failure is found by the first test or dev run, a missing event never is.
7
+ */
8
+ export const BULK_HOOK_PAIRS: Readonly<Record<string, string>> = {
9
+ "inventory.afterAdjust": "inventory.afterAdjustMany",
10
+ };
11
+
12
+ export function assertBulkHookPairs(keys: Iterable<string>, registeredBy: string): void {
13
+ const registered = new Set(keys);
14
+ for (const [single, bulk] of Object.entries(BULK_HOOK_PAIRS)) {
15
+ if (registered.has(single) && !registered.has(bulk)) {
16
+ throw new Error(
17
+ `${registeredBy} subscribes to "${single}" but not to "${bulk}". Bulk writes (e.g. a store's `
18
+ + `inventory sync through setAbsoluteMany) announce only "${bulk}", once per page grouped by `
19
+ + `product — subscribe to it too, or this subscriber never sees them.`,
20
+ );
21
+ }
22
+ }
23
+ }
@@ -1,3 +1,4 @@
1
+ import { assertBulkHookPairs } from "../hooks/bulk-pairs.js";
1
2
  import type { Hono } from "hono";
2
3
  import { markHookInTransaction } from "../hooks/registry.js";
3
4
  import type { OpenAPIHono, RouteConfig } from "@hono/zod-openapi";
@@ -300,6 +301,7 @@ export function defineCommercePlugin(
300
301
  // 2. Hooks — merge into flat hooks map (kernel registers at boot)
301
302
  if (manifest.hooks) {
302
303
  const registrations = manifest.hooks();
304
+ assertBulkHookPairs(registrations.map((reg) => reg.key), `Plugin "${manifest.id}"`);
303
305
  const hookMap: Record<string, Array<(...args: unknown[]) => unknown>> = {
304
306
  ...(result.hooks ?? {}),
305
307
  };
@@ -2,6 +2,7 @@ import type { AfterHook } from "../../kernel/hooks/types.js";
2
2
  import type { HookHandler } from "../../kernel/hooks/registry.js";
3
3
  import type { AuditService } from "./service.js";
4
4
  import type { ImportProductsReport } from "../catalog/import-service.js";
5
+ import type { InventoryAdjustManyResult } from "../inventory/service.js";
5
6
 
6
7
  /**
7
8
  * Creates an after-hook that records an audit entry for the operation.
@@ -69,6 +70,21 @@ const catalogImportAuditHook: AfterHook<ImportProductsReport> = async ({ result,
69
70
  });
70
71
  };
71
72
 
73
+ /**
74
+ * `inventory.afterAdjustMany` — one page of absolute levels. Each changed level is still its own
75
+ * audit entry (as `inventory.afterAdjust` records one per adjust), written in ONE insert.
76
+ */
77
+ const inventoryAdjustManyAuditHook: AfterHook<InventoryAdjustManyResult> = async ({ result, context }) => {
78
+ const audit = context.services.audit as AuditService | undefined;
79
+ if (!audit?.recordMany) return;
80
+ await audit.recordMany(result.entities.flatMap((entity) => entity.levels.map((level) => ({
81
+ entityType: "inventory",
82
+ entityId: level.id,
83
+ event: "adjusted",
84
+ payload: safePayload(level),
85
+ }))), context);
86
+ };
87
+
72
88
  export const auditHooks: Record<string, HookHandler> = {
73
89
  // Catalog
74
90
  "catalog.afterCreate": createAuditAfterHook("catalog_entity", "created") as HookHandler,
@@ -81,6 +97,7 @@ export const auditHooks: Record<string, HookHandler> = {
81
97
 
82
98
  // Inventory
83
99
  "inventory.afterAdjust": createAuditAfterHook("inventory", "adjusted") as HookHandler,
100
+ "inventory.afterAdjustMany": inventoryAdjustManyAuditHook as HookHandler,
84
101
 
85
102
  // Customers
86
103
  "customers.afterCreate": createAuditAfterHook("customer", "created") as HookHandler,
@@ -37,6 +37,8 @@ export interface ListArgs {
37
37
 
38
38
  export interface AuditService {
39
39
  record(args: RecordArgs): Promise<void>;
40
+ /** Many entries sharing one context, in ONE insert — for bulk operations (`inventory.afterAdjustMany`). */
41
+ recordMany(entries: ReadonlyArray<Omit<RecordArgs, "ctx">>, ctx: RecordArgs["ctx"]): Promise<void>;
40
42
  listForEntity(args: ListForEntityArgs): Promise<AuditEntry[]>;
41
43
  list(args: ListArgs): Promise<AuditEntry[]>;
42
44
  }
@@ -58,6 +60,9 @@ export function createNullAuditService(): AuditService {
58
60
  createdAt: new Date(),
59
61
  });
60
62
  },
63
+ async recordMany(many, ctx) {
64
+ for (const entry of many) await this.record({ ...entry, ctx });
65
+ },
61
66
  async listForEntity(args) {
62
67
  return entries
63
68
  .filter(
@@ -105,6 +110,22 @@ export function createAuditService(db: DrizzleDatabase): AuditService {
105
110
  });
106
111
  },
107
112
 
113
+ async recordMany(many, ctx) {
114
+ if (many.length === 0) return;
115
+ const dbOrTx = ctx.tx != null ? (ctx.tx as typeof db) : db;
116
+ const organizationId = resolveOrgIdForCommerce(ctx.actor, ctx.commerceConfig);
117
+ await dbOrTx.insert(auditLog).values(many.map((entry) => ({
118
+ organizationId,
119
+ entityType: entry.entityType,
120
+ entityId: entry.entityId,
121
+ event: entry.event,
122
+ payload: entry.payload ?? {},
123
+ actorId: ctx.actor?.userId ?? null,
124
+ actorType: ctx.actor != null ? "user" : null,
125
+ requestId: ctx.requestId,
126
+ })));
127
+ },
128
+
108
129
  async listForEntity(args) {
109
130
  const { organizationId, entityType, entityId, limit = 50, ctx } = args;
110
131
  const dbOrTx =
@@ -215,6 +215,13 @@ export interface ChannelConnector {
215
215
  ): Promise<Result<{ credentials: Record<string, unknown>; storeDomain: string }, ChannelConnectorError>>;
216
216
  importCatalog(store: ChannelStore, cursor?: string): Promise<Result<ChannelCatalogPage>>;
217
217
  fetchInventory(store: ChannelStore, ids?: string[]): Promise<Result<ChannelInventoryLevel[]>>;
218
+ /**
219
+ * One page of the store's inventory, starting at `cursor` (null for the first page), with the
220
+ * cursor of the next page or null on the last. The store's inventory sync takes one page per
221
+ * step through this; a connector without it is synced by re-reading `fetchInventory` whole on
222
+ * every step, which is O(levels²) per sync.
223
+ */
224
+ fetchInventoryPage?(store: ChannelStore, cursor: string | null): Promise<Result<{ levels: ChannelInventoryLevel[]; nextCursor: string | null }>>;
218
225
  pushOrder(store: ChannelStore, slice: ChannelOrderSlice): Promise<Result<ChannelPushOrderResult, ChannelConnectorError>>;
219
226
  pushCatalog?(
220
227
  store: ChannelStore,
@@ -622,6 +622,63 @@ export class InventoryRepository {
622
622
  return { ok: true, level: updated[0]! };
623
623
  }
624
624
 
625
+ // ─────────────────────────────────────────────────────────────────────────────
626
+ // Set-based writes for one page of absolute levels (`setAbsoluteMany`)
627
+ // ─────────────────────────────────────────────────────────────────────────────
628
+
629
+ /** Every level of these entities in one warehouse, row-locked — one statement for a page. */
630
+ async findLevelsForUpdate(
631
+ organizationId: string,
632
+ warehouseId: string,
633
+ entityIds: readonly string[],
634
+ ctx: TxContext,
635
+ ): Promise<InventoryLevel[]> {
636
+ if (entityIds.length === 0) return [];
637
+ return this.getDb(ctx)
638
+ .select()
639
+ .from(inventoryLevels)
640
+ .where(and(
641
+ eq(inventoryLevels.organizationId, organizationId),
642
+ eq(inventoryLevels.warehouseId, warehouseId),
643
+ inArray(inventoryLevels.entityId, [...entityIds]),
644
+ ))
645
+ .for("update");
646
+ }
647
+
648
+ /** Set each level to its absolute quantity (clamped at 0), one UPDATE for the page; bumps `version`. */
649
+ async setLevelQuantities(
650
+ organizationId: string,
651
+ rows: ReadonlyArray<{ id: string; quantity: number }>,
652
+ ctx: TxContext,
653
+ ): Promise<InventoryLevel[]> {
654
+ if (rows.length === 0) return [];
655
+ const byId = sql.join(rows.map((row) => sql`WHEN ${row.id}::uuid THEN ${Math.max(0, row.quantity)}::integer`), sql` `);
656
+ return this.getDb(ctx)
657
+ .update(inventoryLevels)
658
+ .set({
659
+ quantityOnHand: sql`CASE ${inventoryLevels.id} ${byId} END`,
660
+ updatedAt: new Date(),
661
+ version: sql`${inventoryLevels.version} + 1`,
662
+ })
663
+ .where(and(
664
+ eq(inventoryLevels.organizationId, organizationId),
665
+ inArray(inventoryLevels.id, rows.map((row) => row.id)),
666
+ ))
667
+ .returning();
668
+ }
669
+
670
+ /** New levels, one INSERT for the page; a row another writer created meanwhile is skipped. */
671
+ async createLevels(rows: readonly InventoryLevelInsert[], ctx: TxContext): Promise<InventoryLevel[]> {
672
+ if (rows.length === 0) return [];
673
+ return this.getDb(ctx).insert(inventoryLevels).values([...rows]).onConflictDoNothing().returning();
674
+ }
675
+
676
+ /** Movements, one INSERT for the page. */
677
+ async createMovements(rows: readonly InventoryMovementInsert[], ctx: TxContext): Promise<void> {
678
+ if (rows.length === 0) return;
679
+ await this.getDb(ctx).insert(inventoryMovements).values([...rows]);
680
+ }
681
+
625
682
  // ─────────────────────────────────────────────────────────────────────────────
626
683
  // Aggregate Queries
627
684
  // ─────────────────────────────────────────────────────────────────────────────
@@ -22,8 +22,17 @@ import {
22
22
  InventoryRepository,
23
23
  type Warehouse,
24
24
  type InventoryLevel,
25
+ type InventoryLevelInsert,
25
26
  } from "./repository/index.js";
26
27
 
28
+ type InventoryLevelInsertRow = InventoryLevelInsert;
29
+
30
+ /** What `inventory.afterAdjustMany` announces: the page's CHANGED levels, grouped by product. */
31
+ export interface InventoryAdjustManyResult {
32
+ organizationId: string;
33
+ entities: Array<{ entityId: string; levels: InventoryLevel[] }>;
34
+ }
35
+
27
36
  export type { InventoryAdjustInput, InventoryReserveInput, InventoryReleaseInput } from "./schemas.js";
28
37
  import type { InventoryAdjustInput, InventoryReserveInput, InventoryReleaseInput } from "./schemas.js";
29
38
 
@@ -533,6 +542,113 @@ export class InventoryService {
533
542
  );
534
543
  }
535
544
 
545
+ /**
546
+ * `setAbsolute` for a whole page of levels, set-based — the inventory sync's write.
547
+ *
548
+ * setAbsolute per level is a permission check, a row lock, a clamped write, a movement row and an
549
+ * `inventory.afterAdjust` each: a 27k-variant store synced at ~20 levels per Workflow step and
550
+ * every changed variant re-marked its product. This keeps the same invariants in a CONSTANT
551
+ * number of statements per call:
552
+ * - `inventory:adjust` is asserted once for the page (refused → nothing is written);
553
+ * - the default warehouse (`pickWarehouse`) and the actor's org, once;
554
+ * - an unchanged level writes nothing and is not announced; a missing level is created;
555
+ * - quantities clamp at 0 (as `GREATEST(0, …)` does) and `version` is bumped;
556
+ * - one `adjustment` movement per changed level, carrying its delta.
557
+ * It announces the page ONCE through `inventory.afterAdjustMany`, grouped by product — NOT
558
+ * `inventory.afterAdjust` per level. Core's audit and webhook subscribers handle the bulk hook,
559
+ * so every change is still audited and delivered; a plugin that subscribes to `afterAdjust` must
560
+ * also subscribe to `afterAdjustMany` (the kernel refuses to boot otherwise).
561
+ */
562
+ async setAbsoluteMany(
563
+ rows: ReadonlyArray<{ entityId: string; variantId?: string | undefined; quantity: number }>,
564
+ actor?: Actor | null,
565
+ ctx?: TxContext,
566
+ options: { reason?: string } = {},
567
+ ): Promise<Result<InventoryAdjustManyResult>> {
568
+ try {
569
+ assertPermission(actor ?? null, "inventory:adjust");
570
+ } catch (error) {
571
+ return Err(toCommerceError(error));
572
+ }
573
+ const orgId = resolveOrgIdForCommerce(actor ?? ctx?.actor ?? null, this.deps.config);
574
+ if (rows.length === 0) return Ok({ organizationId: orgId, entities: [] });
575
+ const warehouseId = await this.pickWarehouse(actor, ctx);
576
+ const reason = options.reason ?? "External store absolute inventory sync";
577
+ const performedBy = actor?.userId ?? "system";
578
+ const keyOf = (entityId: string, variantId: string | null) => `${entityId}\u0000${variantId ?? ""}`;
579
+
580
+ const write = async (txCtx: TxContext): Promise<InventoryAdjustManyResult> => {
581
+ const entityIds = [...new Set(rows.map((row) => row.entityId))];
582
+ const existing = new Map((await this.repo.findLevelsForUpdate(orgId, warehouseId, entityIds, txCtx))
583
+ .map((level) => [keyOf(level.entityId, level.variantId), level]));
584
+ const updates: Array<{ id: string; quantity: number; before: number }> = [];
585
+ const inserts: InventoryLevelInsertRow[] = [];
586
+ const seen = new Set<string>();
587
+ for (const row of rows) {
588
+ const key = keyOf(row.entityId, row.variantId ?? null);
589
+ if (seen.has(key)) continue;
590
+ seen.add(key);
591
+ const quantity = Math.max(0, row.quantity);
592
+ const level = existing.get(key);
593
+ if (level === undefined) {
594
+ inserts.push({
595
+ organizationId: orgId, entityId: row.entityId, warehouseId, quantityOnHand: quantity,
596
+ quantityReserved: 0, quantityIncoming: 0, ...(row.variantId !== undefined ? { variantId: row.variantId } : {}),
597
+ });
598
+ } else if (level.quantityOnHand !== quantity) {
599
+ updates.push({ id: level.id, quantity, before: level.quantityOnHand });
600
+ }
601
+ }
602
+ if (updates.length === 0 && inserts.length === 0) return { organizationId: orgId, entities: [] };
603
+
604
+ const beforeById = new Map(updates.map((update) => [update.id, update.before]));
605
+ const updated = await this.repo.setLevelQuantities(orgId, updates, txCtx);
606
+ const created = await this.repo.createLevels(inserts, txCtx);
607
+ const changed = [...updated, ...created];
608
+ await this.repo.createMovements(changed.map((level) => ({
609
+ organizationId: orgId,
610
+ entityId: level.entityId,
611
+ warehouseId,
612
+ type: "adjustment" as const,
613
+ quantity: level.quantityOnHand - (beforeById.get(level.id) ?? 0),
614
+ reason,
615
+ performedBy,
616
+ ...(level.variantId !== null ? { variantId: level.variantId } : {}),
617
+ })), txCtx);
618
+
619
+ const byEntity = new Map<string, InventoryLevel[]>();
620
+ for (const level of changed) byEntity.set(level.entityId, [...(byEntity.get(level.entityId) ?? []), level]);
621
+ const result: InventoryAdjustManyResult = {
622
+ organizationId: orgId,
623
+ entities: [...byEntity].map(([entityId, levels]) => ({ entityId, levels })),
624
+ };
625
+ const hookCtx: HookContext = createHookContext({
626
+ actor: actor ?? null,
627
+ tx: txCtx.tx,
628
+ logger: createLogger("inventory.adjustMany"),
629
+ services: this.deps.services,
630
+ context: { moduleName: "inventory" },
631
+ database: { db: this.deps.database.db as PluginDb },
632
+ commerceConfig: this.deps.config,
633
+ });
634
+ await runAfterHooks(
635
+ this.deps.hooks.resolve("inventory.afterAdjustMany") as Parameters<typeof runAfterHooks>[0],
636
+ null,
637
+ result,
638
+ "update",
639
+ hookCtx,
640
+ (hook) => this.deps.hooks.runsInTransaction(hook),
641
+ );
642
+ return result;
643
+ };
644
+
645
+ try {
646
+ return Ok(await this.withTransaction(ctx, async (tx) => write(ctx?.tx ? ctx : createTxContext(tx, { actor: actor ?? null }))));
647
+ } catch (error) {
648
+ return Err(toCommerceError(error));
649
+ }
650
+ }
651
+
536
652
  /**
537
653
  * Deduct inventory on fulfillment (system-level, no permission check).
538
654
  *