@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.
- package/dist/auth/auth-failure.d.ts +6 -1
- package/dist/auth/auth-failure.d.ts.map +1 -1
- package/dist/auth/auth-failure.js +4 -0
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +17 -3
- package/dist/config/types.d.ts +2 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/kernel/hooks/bulk-pairs.d.ts +10 -0
- package/dist/kernel/hooks/bulk-pairs.d.ts.map +1 -0
- package/dist/kernel/hooks/bulk-pairs.js +20 -0
- package/dist/kernel/plugin/manifest.d.ts.map +1 -1
- package/dist/kernel/plugin/manifest.js +2 -0
- package/dist/modules/audit/hooks.d.ts.map +1 -1
- package/dist/modules/audit/hooks.js +16 -0
- package/dist/modules/audit/service.d.ts +2 -0
- package/dist/modules/audit/service.d.ts.map +1 -1
- package/dist/modules/audit/service.js +20 -0
- package/dist/modules/channels/adapter.d.ts +10 -0
- package/dist/modules/channels/adapter.d.ts.map +1 -1
- package/dist/modules/inventory/repository/index.d.ts +11 -0
- package/dist/modules/inventory/repository/index.d.ts.map +1 -1
- package/dist/modules/inventory/repository/index.js +40 -0
- package/dist/modules/inventory/service.d.ts +32 -0
- package/dist/modules/inventory/service.d.ts.map +1 -1
- package/dist/modules/inventory/service.js +97 -0
- package/dist/modules/webhooks/hook.d.ts +7 -0
- package/dist/modules/webhooks/hook.d.ts.map +1 -1
- package/dist/modules/webhooks/hook.js +31 -0
- package/dist/runtime/kernel-register-hooks.d.ts.map +1 -1
- package/dist/runtime/kernel-register-hooks.js +4 -1
- package/package.json +3 -3
- package/src/auth/auth-failure.ts +6 -1
- package/src/auth/middleware.ts +18 -3
- package/src/config/types.ts +2 -0
- package/src/index.ts +3 -0
- package/src/kernel/hooks/bulk-pairs.ts +23 -0
- package/src/kernel/plugin/manifest.ts +2 -0
- package/src/modules/audit/hooks.ts +17 -0
- package/src/modules/audit/service.ts +21 -0
- package/src/modules/channels/adapter.ts +7 -0
- package/src/modules/inventory/repository/index.ts +57 -0
- package/src/modules/inventory/service.ts +116 -0
- package/src/modules/webhooks/hook.ts +33 -0
- 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;
|
|
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;
|
|
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.
|
|
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/
|
|
66
|
-
"@porulle/
|
|
65
|
+
"@porulle/eslint-config": "0.1.0",
|
|
66
|
+
"@porulle/typescript-config": "0.1.0"
|
|
67
67
|
},
|
|
68
68
|
"publishConfig": {
|
|
69
69
|
"access": "public"
|
package/src/auth/auth-failure.ts
CHANGED
|
@@ -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):
|
|
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
|
+
}
|
package/src/auth/middleware.ts
CHANGED
|
@@ -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
|
-
//
|
|
212
|
-
//
|
|
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.
|
package/src/config/types.ts
CHANGED
|
@@ -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
|
*
|