@open-mercato/shared 0.6.6-develop.6330.1.a261878aa8 → 0.6.6-develop.6332.1.6e73f0f55b
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/lib/crud/optimistic-lock-command.js +24 -1
- package/dist/lib/crud/optimistic-lock-command.js.map +2 -2
- package/dist/lib/crud/optimistic-lock.js +6 -2
- package/dist/lib/crud/optimistic-lock.js.map +2 -2
- package/dist/lib/di/container.js +11 -0
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/crud/__tests__/optimistic-lock-command-with-guards.test.ts +128 -0
- package/src/lib/crud/__tests__/optimistic-lock.test.ts +20 -9
- package/src/lib/crud/optimistic-lock-command.ts +60 -1
- package/src/lib/crud/optimistic-lock.ts +23 -2
- package/src/lib/di/container.ts +11 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CrudHttpError } from "./errors.js";
|
|
1
|
+
import { CrudHttpError, isCrudHttpError } from "./errors.js";
|
|
2
2
|
import {
|
|
3
3
|
OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
4
4
|
OPTIMISTIC_LOCK_CONFLICT_ERROR,
|
|
@@ -98,11 +98,34 @@ function createCommandOptimisticLockGuardService(options = {}) {
|
|
|
98
98
|
}
|
|
99
99
|
};
|
|
100
100
|
}
|
|
101
|
+
const COMMAND_OPTIMISTIC_LOCK_GUARD_SERVICE_KEY = "commandOptimisticLockGuardService";
|
|
102
|
+
function resolveCommandOptimisticLockGuardService(container) {
|
|
103
|
+
try {
|
|
104
|
+
const service = container.resolve(
|
|
105
|
+
COMMAND_OPTIMISTIC_LOCK_GUARD_SERVICE_KEY
|
|
106
|
+
);
|
|
107
|
+
if (!service || typeof service.enforce !== "function") return null;
|
|
108
|
+
return service;
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function enforceCommandOptimisticLockWithGuards(container, input) {
|
|
114
|
+
enforceCommandOptimisticLock(input);
|
|
115
|
+
const guard = resolveCommandOptimisticLockGuardService(container);
|
|
116
|
+
if (!guard) return;
|
|
117
|
+
try {
|
|
118
|
+
await guard.enforce(input);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (isCrudHttpError(error) && error.status === 409) throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
101
123
|
export {
|
|
102
124
|
assertOptimisticLock,
|
|
103
125
|
buildOptimisticLockConflictBody,
|
|
104
126
|
createCommandOptimisticLockGuardService,
|
|
105
127
|
enforceCommandOptimisticLock,
|
|
128
|
+
enforceCommandOptimisticLockWithGuards,
|
|
106
129
|
enforceRecordGoneIsConflict,
|
|
107
130
|
readOptimisticLockExpected
|
|
108
131
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/crud/optimistic-lock-command.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Generalist command-level OSS optimistic-locking helper.\n *\n * The CRUD guard (`optimistic-lock.ts` + `makeCrudRoute`) only protects\n * mutations that flow through the CRUD factory. Domain writes implemented via\n * the Command pattern \u2014 sales document sub-resources (lines, adjustments,\n * shipments, payments, returns), status transitions, quote\u2192order conversion,\n * etc. \u2014 run their own logic inside a command handler and never reach the CRUD\n * guard for the **aggregate** they mutate. This helper lets any command enforce\n * the same `updated_at` version check against an arbitrary target record\n * (typically the aggregate root, e.g. the parent order/quote) and fail with the\n * identical structured 409 the CRUD path returns.\n *\n * Contract (mirrors the CRUD guard so clients see one behavior):\n * - The client sends the expected version via the\n * `x-om-ext-optimistic-lock-expected-updated-at` header (or a command\n * accepts it as a typed input field and passes it as `expected`).\n * - The command loads the current record (it usually already does) and passes\n * its `updated_at` as `current`.\n * - On mismatch the helper throws `CrudHttpError(409, OptimisticLockConflictBody)`.\n *\n * Strictly additive: when no expected token is present (no header, no input\n * field) the helper is a no-op, so existing API consumers that don't send the\n * header keep working. Respects the same `OM_OPTIMISTIC_LOCK` env contract\n * (default ON; `off` disables; allow-list scopes by `resourceKind`).\n *\n * Spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md (\u00A7 command-level checks)\n * .ai/specs/2026-05-28-optimistic-locking-coverage-completion.md (Phase 4)\n */\nimport { CrudHttpError } from './errors'\nimport {\n OPTIMISTIC_LOCK_CONFLICT_CODE,\n OPTIMISTIC_LOCK_CONFLICT_ERROR,\n OPTIMISTIC_LOCK_ENV_VAR,\n OPTIMISTIC_LOCK_HEADER_NAME,\n type OptimisticLockConflictBody,\n} from './optimistic-lock-headers'\nimport {\n normalizeIsoToken,\n parseOptimisticLockEnv,\n type OptimisticLockConfig,\n type OptimisticLockResolverInput,\n type ResolveExpectedUpdatedAt,\n} from './optimistic-lock'\n\nfunction toIsoOrNull(value: string | Date | null | undefined): string | null {\n if (value == null) return null\n if (value instanceof Date) {\n const ms = value.getTime()\n return Number.isFinite(ms) ? new Date(ms).toISOString() : null\n }\n const trimmed = String(value).trim()\n if (!trimmed) return null\n return normalizeIsoToken(trimmed)\n}\n\nfunction resolveConfig(envValue: string | null | undefined): OptimisticLockConfig {\n return parseOptimisticLockEnv(envValue !== undefined ? envValue : process.env[OPTIMISTIC_LOCK_ENV_VAR])\n}\n\nfunction isResourceLockEnabled(config: OptimisticLockConfig, resourceKind: string): boolean {\n if (config.mode === 'off') return false\n if (config.mode === 'all') return true\n return config.entities.has(resourceKind.toLowerCase())\n}\n\n/**\n * Extract the expected `updated_at` token from a request's headers (or a bare\n * `Headers` object). Returns the trimmed header value, or `null` when absent /\n * empty. Does NOT normalize \u2014 `assertOptimisticLock` normalizes both sides.\n */\nexport function readOptimisticLockExpected(\n source: Request | Headers | null | undefined,\n): string | null {\n if (!source) return null\n const headers = source instanceof Headers\n ? source\n : (source as Request).headers instanceof Headers\n ? (source as Request).headers\n : null\n if (!headers) return null\n const direct = headers.get(OPTIMISTIC_LOCK_HEADER_NAME)\n if (typeof direct === 'string' && direct.trim().length > 0) return direct.trim()\n return null\n}\n\nexport function buildOptimisticLockConflictBody(\n currentIso: string,\n expectedIso: string,\n): OptimisticLockConflictBody {\n return {\n error: OPTIMISTIC_LOCK_CONFLICT_ERROR,\n code: OPTIMISTIC_LOCK_CONFLICT_CODE,\n currentUpdatedAt: currentIso,\n expectedUpdatedAt: expectedIso,\n }\n}\n\nexport type AssertOptimisticLockInput = {\n resourceKind: string\n resourceId: string\n /** Client-provided expected version (header value or typed input field). */\n expected: string | Date | null | undefined\n /** Current version loaded from the DB (typically the aggregate root's `updatedAt`). */\n current: string | Date | null | undefined\n /** Override `OM_OPTIMISTIC_LOCK` (mostly for tests). */\n envValue?: string | null\n}\n\n/**\n * Pure version assertion. Throws `CrudHttpError(409, OptimisticLockConflictBody)`\n * when the expected and current versions disagree.\n *\n * No-op (returns silently) when:\n * - the env disables the guard for this `resourceKind`,\n * - `expected` is missing / unparseable (strictly additive \u2014 clients that\n * don't send the token are never blocked),\n * - `current` is missing / unparseable (let the command's own 404 fire).\n */\nexport function assertOptimisticLock(input: AssertOptimisticLockInput): void {\n const config = resolveConfig(input.envValue)\n if (!isResourceLockEnabled(config, input.resourceKind)) return\n\n const expectedIso = toIsoOrNull(input.expected)\n if (expectedIso == null) return\n\n const currentIso = toIsoOrNull(input.current)\n if (currentIso == null) return\n\n if (currentIso === expectedIso) return\n\n throw new CrudHttpError(409, buildOptimisticLockConflictBody(currentIso, expectedIso))\n}\n\nexport type EnforceCommandOptimisticLockInput = {\n resourceKind: string\n resourceId: string\n /** Current version loaded from the DB (the aggregate root's `updatedAt`). */\n current: string | Date | null | undefined\n /**\n * Explicit expected version \u2014 wins over the request header. Use when the\n * command accepts the token as a typed input field instead of (or in addition\n * to) the extension header.\n */\n expected?: string | Date | null\n /** Request whose headers carry the expected token (e.g. `ctx.request`). */\n request?: Request | Headers | null\n /** Override `OM_OPTIMISTIC_LOCK` (mostly for tests). */\n envValue?: string | null\n}\n\n/**\n * Command-handler convenience: resolves the expected version from an explicit\n * override or the request header, then delegates to `assertOptimisticLock`.\n *\n * ```ts\n * enforceCommandOptimisticLock({\n * resourceKind: 'sales.order',\n * resourceId: order.id,\n * current: order.updatedAt,\n * request: ctx.request,\n * })\n * ```\n */\nexport function enforceCommandOptimisticLock(input: EnforceCommandOptimisticLockInput): void {\n const expected = input.expected !== undefined && input.expected !== null\n ? input.expected\n : readOptimisticLockExpected(input.request ?? null)\n assertOptimisticLock({\n resourceKind: input.resourceKind,\n resourceId: input.resourceId,\n expected,\n current: input.current,\n envValue: input.envValue,\n })\n}\n\nexport type EnforceRecordGoneIsConflictInput = {\n resourceKind: string\n resourceId: string\n /** Explicit expected version \u2014 wins over the request header. */\n expected?: string | Date | null\n /** Request whose headers carry the expected token (e.g. `ctx.request`). */\n request?: Request | Headers | null\n /** Override `OM_OPTIMISTIC_LOCK` (mostly for tests). */\n envValue?: string | null\n}\n\n/**\n * Command-handler convenience for the *concurrent-delete* race: when a command\n * cannot find its target record (it was deleted in another tab) AND the client\n * opted into optimistic locking (sent the expected-version header / token),\n * throw the SAME structured `CrudHttpError(409, OptimisticLockConflictBody)`\n * the version-mismatch path returns \u2014 so a stale modal save surfaces the unified\n * \"Record changed\" conflict bar instead of a bare, generic 404.\n *\n * Strictly additive and fail-open: a no-op (returns silently) when the env\n * disables the guard for `resourceKind`, or when no expected token is present\n * (plain API consumers that never sent the header keep their existing 404).\n * The caller MUST still throw its own 404 afterwards for that no-token path:\n *\n * ```ts\n * if (!interaction) {\n * enforceRecordGoneIsConflict({ resourceKind: 'customers.interaction', resourceId: id, request: ctx.request })\n * throw new CrudHttpError(404, { error: 'Interaction not found' })\n * }\n * ```\n *\n * The gone record has no current version, so `currentUpdatedAt` echoes the\n * expected token (the body is used only for the conflict-bar copy + diagnostics;\n * the client keys off `code`, not the timestamps).\n */\nexport function enforceRecordGoneIsConflict(input: EnforceRecordGoneIsConflictInput): void {\n const config = resolveConfig(input.envValue)\n if (!isResourceLockEnabled(config, input.resourceKind)) return\n const clientSupplied = input.expected !== undefined && input.expected !== null\n ? input.expected\n : readOptimisticLockExpected(input.request ?? null)\n const expectedIso = toIsoOrNull(clientSupplied)\n if (expectedIso == null) return\n throw new CrudHttpError(409, buildOptimisticLockConflictBody(expectedIso, expectedIso))\n}\n\n/**\n * DI-resolvable command-level optimistic-lock guard. This is the framework\n * seam that lets BOTH layers protect Command-pattern writes through one\n * contract:\n *\n * - **OSS** registers the default service (header/explicit token compare \u2014\n * identical to calling `enforceCommandOptimisticLock` directly).\n * - **Enterprise** (`record_locks`) re-registers the same DI key with a\n * `resolveExpected` that reads the held pessimistic lock's version, so a\n * stale command write fails with the same structured 409 WITHOUT any\n * command handler changing. Mirrors how enterprise already replaces the\n * CRUD-path `crudMutationGuardService` (see `optimistic-lock.ts`).\n *\n * Command handlers depend only on this interface (resolved from the container),\n * never on a concrete implementation \u2014 that is what makes the next-PR\n * enterprise extension a pure DI swap.\n */\nexport type CommandOptimisticLockGuardService = {\n /**\n * Enforce the version check for a command-level mutation against an\n * aggregate/record. Async because an enterprise resolver may load the\n * expected token from a lock record. No-op (resolves silently) when the env\n * disables the guard for `resourceKind` or when no expected token is\n * resolved \u2014 strictly additive, exactly like {@link enforceCommandOptimisticLock}.\n * Throws `CrudHttpError(409, OptimisticLockConflictBody)` on mismatch.\n */\n enforce: (input: EnforceCommandOptimisticLockInput) => Promise<void>\n}\n\nexport type CreateCommandOptimisticLockGuardServiceOptions = {\n /**\n * Override how the expected version is derived. Receives\n * `{ expectedFromHeader, resourceKind, resourceId }` (where\n * `expectedFromHeader` is the normalized client-supplied token \u2014 explicit\n * input or request header) and returns the expected token (or `null` to\n * skip). Defaults to \"use the client-supplied token\", which is the OSS\n * behavior. The enterprise `record_locks` module plugs a lock-backed\n * resolver here. Mirrors the CRUD guard's `resolveExpected`.\n */\n resolveExpected?: ResolveExpectedUpdatedAt\n}\n\n/**\n * Build a {@link CommandOptimisticLockGuardService}. With no options it is\n * behaviourally identical to {@link enforceCommandOptimisticLock} (header/\n * explicit compare), so the OSS default is a thin wrapper. Pass\n * `resolveExpected` to override token resolution (enterprise extension point).\n */\nexport function createCommandOptimisticLockGuardService(\n options: CreateCommandOptimisticLockGuardServiceOptions = {},\n): CommandOptimisticLockGuardService {\n const resolveExpected: ResolveExpectedUpdatedAt | null = options.resolveExpected ?? null\n return {\n async enforce(input: EnforceCommandOptimisticLockInput): Promise<void> {\n const config = resolveConfig(input.envValue)\n if (!isResourceLockEnabled(config, input.resourceKind)) return\n\n const clientSupplied = input.expected !== undefined && input.expected !== null\n ? input.expected\n : readOptimisticLockExpected(input.request ?? null)\n const expectedFromHeader = toIsoOrNull(clientSupplied)\n\n let expected: string | null = expectedFromHeader\n if (resolveExpected) {\n const resolverInput: OptimisticLockResolverInput = {\n expectedFromHeader,\n resourceKind: input.resourceKind,\n resourceId: input.resourceId,\n }\n expected = await resolveExpected(resolverInput)\n }\n\n assertOptimisticLock({\n resourceKind: input.resourceKind,\n resourceId: input.resourceId,\n expected,\n current: input.current,\n envValue: input.envValue,\n })\n },\n }\n}\n"],
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["/**\n * Generalist command-level OSS optimistic-locking helper.\n *\n * The CRUD guard (`optimistic-lock.ts` + `makeCrudRoute`) only protects\n * mutations that flow through the CRUD factory. Domain writes implemented via\n * the Command pattern \u2014 sales document sub-resources (lines, adjustments,\n * shipments, payments, returns), status transitions, quote\u2192order conversion,\n * etc. \u2014 run their own logic inside a command handler and never reach the CRUD\n * guard for the **aggregate** they mutate. This helper lets any command enforce\n * the same `updated_at` version check against an arbitrary target record\n * (typically the aggregate root, e.g. the parent order/quote) and fail with the\n * identical structured 409 the CRUD path returns.\n *\n * Contract (mirrors the CRUD guard so clients see one behavior):\n * - The client sends the expected version via the\n * `x-om-ext-optimistic-lock-expected-updated-at` header (or a command\n * accepts it as a typed input field and passes it as `expected`).\n * - The command loads the current record (it usually already does) and passes\n * its `updated_at` as `current`.\n * - On mismatch the helper throws `CrudHttpError(409, OptimisticLockConflictBody)`.\n *\n * Strictly additive: when no expected token is present (no header, no input\n * field) the helper is a no-op, so existing API consumers that don't send the\n * header keep working. Respects the same `OM_OPTIMISTIC_LOCK` env contract\n * (default ON; `off` disables; allow-list scopes by `resourceKind`).\n *\n * Spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md (\u00A7 command-level checks)\n * .ai/specs/2026-05-28-optimistic-locking-coverage-completion.md (Phase 4)\n */\nimport type { AwilixContainer } from 'awilix'\nimport { CrudHttpError, isCrudHttpError } from './errors'\nimport {\n OPTIMISTIC_LOCK_CONFLICT_CODE,\n OPTIMISTIC_LOCK_CONFLICT_ERROR,\n OPTIMISTIC_LOCK_ENV_VAR,\n OPTIMISTIC_LOCK_HEADER_NAME,\n type OptimisticLockConflictBody,\n} from './optimistic-lock-headers'\nimport {\n normalizeIsoToken,\n parseOptimisticLockEnv,\n type OptimisticLockConfig,\n type OptimisticLockResolverInput,\n type ResolveExpectedUpdatedAt,\n} from './optimistic-lock'\n\nfunction toIsoOrNull(value: string | Date | null | undefined): string | null {\n if (value == null) return null\n if (value instanceof Date) {\n const ms = value.getTime()\n return Number.isFinite(ms) ? new Date(ms).toISOString() : null\n }\n const trimmed = String(value).trim()\n if (!trimmed) return null\n return normalizeIsoToken(trimmed)\n}\n\nfunction resolveConfig(envValue: string | null | undefined): OptimisticLockConfig {\n return parseOptimisticLockEnv(envValue !== undefined ? envValue : process.env[OPTIMISTIC_LOCK_ENV_VAR])\n}\n\nfunction isResourceLockEnabled(config: OptimisticLockConfig, resourceKind: string): boolean {\n if (config.mode === 'off') return false\n if (config.mode === 'all') return true\n return config.entities.has(resourceKind.toLowerCase())\n}\n\n/**\n * Extract the expected `updated_at` token from a request's headers (or a bare\n * `Headers` object). Returns the trimmed header value, or `null` when absent /\n * empty. Does NOT normalize \u2014 `assertOptimisticLock` normalizes both sides.\n */\nexport function readOptimisticLockExpected(\n source: Request | Headers | null | undefined,\n): string | null {\n if (!source) return null\n const headers = source instanceof Headers\n ? source\n : (source as Request).headers instanceof Headers\n ? (source as Request).headers\n : null\n if (!headers) return null\n const direct = headers.get(OPTIMISTIC_LOCK_HEADER_NAME)\n if (typeof direct === 'string' && direct.trim().length > 0) return direct.trim()\n return null\n}\n\nexport function buildOptimisticLockConflictBody(\n currentIso: string,\n expectedIso: string,\n): OptimisticLockConflictBody {\n return {\n error: OPTIMISTIC_LOCK_CONFLICT_ERROR,\n code: OPTIMISTIC_LOCK_CONFLICT_CODE,\n currentUpdatedAt: currentIso,\n expectedUpdatedAt: expectedIso,\n }\n}\n\nexport type AssertOptimisticLockInput = {\n resourceKind: string\n resourceId: string\n /** Client-provided expected version (header value or typed input field). */\n expected: string | Date | null | undefined\n /** Current version loaded from the DB (typically the aggregate root's `updatedAt`). */\n current: string | Date | null | undefined\n /** Override `OM_OPTIMISTIC_LOCK` (mostly for tests). */\n envValue?: string | null\n}\n\n/**\n * Pure version assertion. Throws `CrudHttpError(409, OptimisticLockConflictBody)`\n * when the expected and current versions disagree.\n *\n * No-op (returns silently) when:\n * - the env disables the guard for this `resourceKind`,\n * - `expected` is missing / unparseable (strictly additive \u2014 clients that\n * don't send the token are never blocked),\n * - `current` is missing / unparseable (let the command's own 404 fire).\n */\nexport function assertOptimisticLock(input: AssertOptimisticLockInput): void {\n const config = resolveConfig(input.envValue)\n if (!isResourceLockEnabled(config, input.resourceKind)) return\n\n const expectedIso = toIsoOrNull(input.expected)\n if (expectedIso == null) return\n\n const currentIso = toIsoOrNull(input.current)\n if (currentIso == null) return\n\n if (currentIso === expectedIso) return\n\n throw new CrudHttpError(409, buildOptimisticLockConflictBody(currentIso, expectedIso))\n}\n\nexport type EnforceCommandOptimisticLockInput = {\n resourceKind: string\n resourceId: string\n /** Current version loaded from the DB (the aggregate root's `updatedAt`). */\n current: string | Date | null | undefined\n /**\n * Explicit expected version \u2014 wins over the request header. Use when the\n * command accepts the token as a typed input field instead of (or in addition\n * to) the extension header.\n */\n expected?: string | Date | null\n /** Request whose headers carry the expected token (e.g. `ctx.request`). */\n request?: Request | Headers | null\n /** Override `OM_OPTIMISTIC_LOCK` (mostly for tests). */\n envValue?: string | null\n}\n\n/**\n * Command-handler convenience: resolves the expected version from an explicit\n * override or the request header, then delegates to `assertOptimisticLock`.\n *\n * ```ts\n * enforceCommandOptimisticLock({\n * resourceKind: 'sales.order',\n * resourceId: order.id,\n * current: order.updatedAt,\n * request: ctx.request,\n * })\n * ```\n */\nexport function enforceCommandOptimisticLock(input: EnforceCommandOptimisticLockInput): void {\n const expected = input.expected !== undefined && input.expected !== null\n ? input.expected\n : readOptimisticLockExpected(input.request ?? null)\n assertOptimisticLock({\n resourceKind: input.resourceKind,\n resourceId: input.resourceId,\n expected,\n current: input.current,\n envValue: input.envValue,\n })\n}\n\nexport type EnforceRecordGoneIsConflictInput = {\n resourceKind: string\n resourceId: string\n /** Explicit expected version \u2014 wins over the request header. */\n expected?: string | Date | null\n /** Request whose headers carry the expected token (e.g. `ctx.request`). */\n request?: Request | Headers | null\n /** Override `OM_OPTIMISTIC_LOCK` (mostly for tests). */\n envValue?: string | null\n}\n\n/**\n * Command-handler convenience for the *concurrent-delete* race: when a command\n * cannot find its target record (it was deleted in another tab) AND the client\n * opted into optimistic locking (sent the expected-version header / token),\n * throw the SAME structured `CrudHttpError(409, OptimisticLockConflictBody)`\n * the version-mismatch path returns \u2014 so a stale modal save surfaces the unified\n * \"Record changed\" conflict bar instead of a bare, generic 404.\n *\n * Strictly additive and fail-open: a no-op (returns silently) when the env\n * disables the guard for `resourceKind`, or when no expected token is present\n * (plain API consumers that never sent the header keep their existing 404).\n * The caller MUST still throw its own 404 afterwards for that no-token path:\n *\n * ```ts\n * if (!interaction) {\n * enforceRecordGoneIsConflict({ resourceKind: 'customers.interaction', resourceId: id, request: ctx.request })\n * throw new CrudHttpError(404, { error: 'Interaction not found' })\n * }\n * ```\n *\n * The gone record has no current version, so `currentUpdatedAt` echoes the\n * expected token (the body is used only for the conflict-bar copy + diagnostics;\n * the client keys off `code`, not the timestamps).\n */\nexport function enforceRecordGoneIsConflict(input: EnforceRecordGoneIsConflictInput): void {\n const config = resolveConfig(input.envValue)\n if (!isResourceLockEnabled(config, input.resourceKind)) return\n const clientSupplied = input.expected !== undefined && input.expected !== null\n ? input.expected\n : readOptimisticLockExpected(input.request ?? null)\n const expectedIso = toIsoOrNull(clientSupplied)\n if (expectedIso == null) return\n throw new CrudHttpError(409, buildOptimisticLockConflictBody(expectedIso, expectedIso))\n}\n\n/**\n * DI-resolvable command-level optimistic-lock guard. This is the framework\n * seam that lets BOTH layers protect Command-pattern writes through one\n * contract:\n *\n * - **OSS** registers the default service (header/explicit token compare \u2014\n * identical to calling `enforceCommandOptimisticLock` directly).\n * - **Enterprise** (`record_locks`) re-registers the same DI key with a\n * `resolveExpected` that reads the held pessimistic lock's version, so a\n * stale command write fails with the same structured 409 WITHOUT any\n * command handler changing. Mirrors how enterprise already replaces the\n * CRUD-path `crudMutationGuardService` (see `optimistic-lock.ts`).\n *\n * Command handlers depend only on this interface (resolved from the container),\n * never on a concrete implementation \u2014 that is what makes the next-PR\n * enterprise extension a pure DI swap.\n */\nexport type CommandOptimisticLockGuardService = {\n /**\n * Enforce the version check for a command-level mutation against an\n * aggregate/record. Async because an enterprise resolver may load the\n * expected token from a lock record. No-op (resolves silently) when the env\n * disables the guard for `resourceKind` or when no expected token is\n * resolved \u2014 strictly additive, exactly like {@link enforceCommandOptimisticLock}.\n * Throws `CrudHttpError(409, OptimisticLockConflictBody)` on mismatch.\n */\n enforce: (input: EnforceCommandOptimisticLockInput) => Promise<void>\n}\n\nexport type CreateCommandOptimisticLockGuardServiceOptions = {\n /**\n * Override how the expected version is derived. Receives\n * `{ expectedFromHeader, resourceKind, resourceId }` (where\n * `expectedFromHeader` is the normalized client-supplied token \u2014 explicit\n * input or request header) and returns the expected token (or `null` to\n * skip). Defaults to \"use the client-supplied token\", which is the OSS\n * behavior. The enterprise `record_locks` module plugs a lock-backed\n * resolver here. Mirrors the CRUD guard's `resolveExpected`.\n */\n resolveExpected?: ResolveExpectedUpdatedAt\n}\n\n/**\n * Build a {@link CommandOptimisticLockGuardService}. With no options it is\n * behaviourally identical to {@link enforceCommandOptimisticLock} (header/\n * explicit compare), so the OSS default is a thin wrapper. Pass\n * `resolveExpected` to override token resolution (enterprise extension point).\n */\nexport function createCommandOptimisticLockGuardService(\n options: CreateCommandOptimisticLockGuardServiceOptions = {},\n): CommandOptimisticLockGuardService {\n const resolveExpected: ResolveExpectedUpdatedAt | null = options.resolveExpected ?? null\n return {\n async enforce(input: EnforceCommandOptimisticLockInput): Promise<void> {\n const config = resolveConfig(input.envValue)\n if (!isResourceLockEnabled(config, input.resourceKind)) return\n\n const clientSupplied = input.expected !== undefined && input.expected !== null\n ? input.expected\n : readOptimisticLockExpected(input.request ?? null)\n const expectedFromHeader = toIsoOrNull(clientSupplied)\n\n let expected: string | null = expectedFromHeader\n if (resolveExpected) {\n const resolverInput: OptimisticLockResolverInput = {\n expectedFromHeader,\n resourceKind: input.resourceKind,\n resourceId: input.resourceId,\n }\n expected = await resolveExpected(resolverInput)\n }\n\n assertOptimisticLock({\n resourceKind: input.resourceKind,\n resourceId: input.resourceId,\n expected,\n current: input.current,\n envValue: input.envValue,\n })\n },\n }\n}\n\nconst COMMAND_OPTIMISTIC_LOCK_GUARD_SERVICE_KEY = 'commandOptimisticLockGuardService'\n\nfunction resolveCommandOptimisticLockGuardService(\n container: AwilixContainer,\n): CommandOptimisticLockGuardService | null {\n try {\n const service = container.resolve<CommandOptimisticLockGuardService>(\n COMMAND_OPTIMISTIC_LOCK_GUARD_SERVICE_KEY,\n )\n if (!service || typeof service.enforce !== 'function') return null\n return service\n } catch {\n return null\n }\n}\n\n/**\n * Async, DI-aware command-level optimistic-lock runner (Phase 0 / S1). This is\n * the additive seam command handlers migrate to so BOTH the OSS floor and the\n * optional enterprise `record_locks` enrichment protect Command-pattern writes\n * through one call:\n *\n * 1. The OSS `updated_at` floor runs **unconditionally first** via the\n * synchronous {@link enforceCommandOptimisticLock} \u2014 a stale write 409s\n * here regardless of any record-lock token / widget state (H2). The legacy\n * helper is reused verbatim, so the floor behaves identically to existing\n * direct call sites.\n * 2. If the floor passes, the optional `commandOptimisticLockGuardService` is\n * resolved from the request container and awaited for enrichment.\n *\n * Fail-closed delegation (H3): a `record_lock_conflict`/409 from the enterprise\n * service is rethrown (the write must be blocked), but ANY non-conflict error\n * from a broken/unregistered enterprise guard is swallowed \u2014 the request\n * degrades to OSS-only protection, never to \"skip the guard\".\n *\n * Strictly additive: with no enterprise service registered (OSS-only build) this\n * is exactly the OSS compare. The synchronous {@link enforceCommandOptimisticLock}\n * helper is left untouched for external callers.\n */\nexport async function enforceCommandOptimisticLockWithGuards(\n container: AwilixContainer,\n input: EnforceCommandOptimisticLockInput,\n): Promise<void> {\n // 1. OSS floor \u2014 unconditional, synchronous, identical to direct call sites.\n enforceCommandOptimisticLock(input)\n\n // 2. Optional enterprise enrichment, fail-closed.\n const guard = resolveCommandOptimisticLockGuardService(container)\n if (!guard) return\n\n try {\n await guard.enforce(input)\n } catch (error) {\n // A real conflict must block the write; anything else degrades to OSS-only.\n if (isCrudHttpError(error) && error.status === 409) throw error\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -31,7 +31,7 @@ function createGenericOptimisticLockReader(opts) {
|
|
|
31
31
|
const softDeleteField = opts.softDeleteField === null ? null : opts.softDeleteField ?? "deletedAt";
|
|
32
32
|
const updatedAtField = opts.updatedAtField ?? "updatedAt";
|
|
33
33
|
const extraFilter = opts.extraFilter ?? {};
|
|
34
|
-
return async (em, { resourceId, tenantId, organizationId }) => {
|
|
34
|
+
return async (em, { resourceKind, resourceId, tenantId, organizationId }) => {
|
|
35
35
|
const filter = { [idField]: resourceId };
|
|
36
36
|
if (tenantField) filter[tenantField] = tenantId;
|
|
37
37
|
if (orgField && organizationId) filter[orgField] = organizationId;
|
|
@@ -46,7 +46,11 @@ function createGenericOptimisticLockReader(opts) {
|
|
|
46
46
|
if (value instanceof Date) return value.toISOString();
|
|
47
47
|
if (typeof value === "string" && value.length > 0) return value;
|
|
48
48
|
return null;
|
|
49
|
-
} catch {
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.error(
|
|
51
|
+
`[optimistic-lock] reader query failed for resourceKind="${resourceKind}" \u2014 optimistic locking is DISABLED for this entity until fixed. Most likely a softDeleteField/tenantField/orgField filters on a column the table does not have.`,
|
|
52
|
+
err
|
|
53
|
+
);
|
|
50
54
|
return null;
|
|
51
55
|
}
|
|
52
56
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/crud/optimistic-lock.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * OSS optimistic-locking guard service.\n *\n * Registered as `crudMutationGuardService` (by the platform DI bootstrap and\n * by hand-wiring modules that override the default reader). Compares the\n * client-sent expected `updated_at` (carried via the extension header\n * defined in `optimistic-lock-headers.ts`) against the current DB\n * `updated_at` for the target entity; on mismatch returns HTTP 409 with the\n * structured `OptimisticLockConflictBody`.\n *\n * **Default ON** (Phase 14, 2026-05-27). Activate / scope / disable via\n * `OM_OPTIMISTIC_LOCK`:\n * - unset / empty / whitespace \u2192 ON for every CRUD entity (`{ mode: 'all' }`)\n * - `all` \u2192 all entities (explicit form of the default)\n * - `customers.company,sales.order` \u2192 allow-list (lowercased, trimmed, deduped)\n * - `off` / `false` / `0` / `no` / `disabled` / `none` \u2192 fully disabled\n *\n * The guard is still strictly additive at runtime: clients that do not send\n * the `x-om-ext-optimistic-lock-expected-updated-at` header pass through\n * unchanged, so flipping the default to ON cannot introduce new 409s on\n * existing API consumers. Pages that opt into the round-trip (via\n * `CrudForm`'s `optimisticLockUpdatedAt` prop or by calling\n * `buildOptimisticLockHeader`) gain protection without any per-deployment\n * env change.\n *\n * Cannot be registered as a static `data/guards.ts` `MutationGuard` because\n * the static `validate(input)` receives only `MutationGuardInput` \u2014 no\n * container / em access. Stateful checks that need to read current DB state\n * MUST go through the DI service path (this file).\n *\n * Spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md\n */\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type {\n CrudMutationGuardValidateInput,\n CrudMutationGuardValidationResult,\n CrudMutationGuardAfterSuccessInput,\n} from './mutation-guard'\nimport {\n OPTIMISTIC_LOCK_CONFLICT_CODE,\n OPTIMISTIC_LOCK_CONFLICT_ERROR,\n OPTIMISTIC_LOCK_ENV_VAR,\n OPTIMISTIC_LOCK_HEADER_NAME,\n type OptimisticLockConflictBody,\n} from './optimistic-lock-headers'\nimport { getAllOptimisticLockReaders } from './optimistic-lock-store'\n\nexport type OptimisticLockConfig =\n | { mode: 'off' }\n | { mode: 'all' }\n | { mode: 'allowlist'; entities: ReadonlySet<string> }\n\n/**\n * Tokens (case-insensitive, single-token-only) that explicitly disable the\n * guard. Spelled out as a fixed set so tests can pin them; deliberately the\n * same shape `parseBooleanToken` recognises so operators can mirror existing\n * habit. Mixing an off-token with other entities is invalid input \u2014 we treat\n * any presence of an off-token in the comma list as a request to disable.\n */\nconst OPTIMISTIC_LOCK_OFF_TOKENS: ReadonlySet<string> = new Set([\n 'off',\n 'false',\n '0',\n 'no',\n 'disabled',\n 'none',\n])\n\n/**\n * Pure parser for `OM_OPTIMISTIC_LOCK`. Exported separately so tests can\n * exercise the grammar without spinning up the full service.\n *\n * Default is **ON** (`{ mode: 'all' }`) \u2014 unset / empty / whitespace input\n * activates the guard for every CRUD entity. Operators opt out via\n * `OM_OPTIMISTIC_LOCK=off` (or `false` / `0` / `no` / `disabled` / `none`).\n */\nexport function parseOptimisticLockEnv(raw: string | undefined | null): OptimisticLockConfig {\n if (raw == null) return { mode: 'all' }\n const trimmed = String(raw).trim()\n if (trimmed === '') return { mode: 'all' }\n\n const tokens = trimmed\n .split(',')\n .map((token) => token.trim().toLowerCase())\n .filter((token) => token.length > 0)\n\n if (tokens.length === 0) return { mode: 'all' }\n if (tokens.some((token) => OPTIMISTIC_LOCK_OFF_TOKENS.has(token))) return { mode: 'off' }\n if (tokens.includes('all')) return { mode: 'all' }\n\n return { mode: 'allowlist', entities: new Set(tokens) }\n}\n\nexport type OptimisticLockResolverInput = {\n expectedFromHeader: string | null\n resourceKind: string\n resourceId: string\n}\n\n/**\n * Hook reserved for the enterprise `record_locks` module to override token\n * resolution (e.g. read the expected token from a lock record instead of\n * the request header). OSS keeps the default = \"what the client sent\".\n *\n * Documented as part of the enterprise extension contract; not used in OSS\n * itself.\n */\nexport type ResolveExpectedUpdatedAt = (\n input: OptimisticLockResolverInput,\n) => Promise<string | null> | string | null\n\nconst defaultResolveExpectedUpdatedAt: ResolveExpectedUpdatedAt = ({ expectedFromHeader }) =>\n expectedFromHeader\n\nexport type OptimisticLockCurrentReader = (\n em: EntityManager,\n input: { resourceKind: string; resourceId: string; tenantId: string; organizationId: string | null },\n) => Promise<string | null>\n\nexport type GenericOptimisticLockReaderOptions = {\n /** MikroORM entity class. */\n entity: unknown\n /** Primary key field. Defaults to `id`. */\n idField?: string\n /** Tenant scope field. Defaults to `tenantId`. Pass `null` to skip tenant scoping (rare \u2014 only when the entity itself has no `tenantId` column). */\n tenantField?: string | null\n /** Organization scope field. Defaults to `organizationId`. Pass `null` to skip organization scoping. */\n orgField?: string | null\n /** Soft-delete column. Defaults to `deletedAt`. Pass `null` to skip the implicit not-deleted filter. */\n softDeleteField?: string | null\n /** Optional fixed filter merged into every query (e.g. `{ kind: 'company' }` for a discriminated table). */\n extraFilter?: Record<string, unknown>\n /** Optional ORM field name carrying the timestamp. Defaults to `updatedAt`. */\n updatedAtField?: string\n}\n\n/**\n * Build a generic optimistic-lock reader for any ORM entity that follows the\n * platform conventions (`id` + `tenantId` + `organizationId` + `deletedAt` +\n * `updatedAt`). The reader projects only the timestamp column so PII never\n * materializes.\n *\n * Used by `makeCrudRoute` to auto-register one reader per CRUD route at\n * module-load time (see Phase 13 of the OSS optimistic-locking spec).\n * Module authors who need bespoke filtering (e.g. discriminator on a shared\n * table) keep registering their own reader via `registerOptimisticLockReaders`\n * \u2014 those hand-wired registrations win because they land first.\n *\n * Fail-open contract: if the underlying `findOne` throws (missing column,\n * schema drift, mid-migration) the reader returns `null`, which the guard\n * treats as \"entity already gone\" and lets the CRUD path's own 404 fire.\n * We MUST NOT throw out of the reader \u2014 that would 500 every mutation on\n * the affected entity instead of opting it out of the optimistic check.\n */\nexport function createGenericOptimisticLockReader(\n opts: GenericOptimisticLockReaderOptions,\n): OptimisticLockCurrentReader {\n const idField = opts.idField ?? 'id'\n const tenantField = opts.tenantField === null ? null : opts.tenantField ?? 'tenantId'\n const orgField = opts.orgField === null ? null : opts.orgField ?? 'organizationId'\n const softDeleteField = opts.softDeleteField === null ? null : opts.softDeleteField ?? 'deletedAt'\n const updatedAtField = opts.updatedAtField ?? 'updatedAt'\n const extraFilter = opts.extraFilter ?? {}\n\n return async (em, { resourceId, tenantId, organizationId }) => {\n const filter: Record<string, unknown> = { [idField]: resourceId }\n if (tenantField) filter[tenantField] = tenantId\n if (orgField && organizationId) filter[orgField] = organizationId\n if (softDeleteField) filter[softDeleteField] = null\n for (const [key, value] of Object.entries(extraFilter)) filter[key] = value\n\n try {\n const row = await em.findOne(opts.entity as never, filter as never, {\n fields: [updatedAtField] as never,\n })\n if (!row || typeof row !== 'object') return null\n const value = (row as Record<string, unknown>)[updatedAtField]\n if (value instanceof Date) return value.toISOString()\n if (typeof value === 'string' && value.length > 0) return value\n return null\n } catch {\n return null\n }\n }\n}\n\nexport type OptimisticLockGuardOptions = {\n /** EntityManager resolver. Container-bound via DI in real usage. */\n getEm: () => EntityManager\n /**\n * Maps `resourceKind` \u2192 reader that returns the current\n * `updated_at` as an ISO string (or null when not found).\n *\n * The reader receives the EM so module authors can choose the\n * right `findOne` shape for their entity (`findOneWithDecryption`\n * when sensitive, plain `findOne` otherwise \u2014 but only requesting\n * `updated_at` so no PII materializes).\n *\n * When omitted, the service pulls readers from the shared\n * `optimistic-lock-store` (the recommended pattern for multi-module\n * deployments \u2014 each module registers its own readers via\n * `registerOptimisticLockReaders(...)` at module-load time).\n */\n readers?: Record<string, OptimisticLockCurrentReader>\n /** Override env source (mostly for tests). Defaults to `process.env`. */\n envValue?: string | null\n /** Override the token resolver. Defaults to \"use the header value\". */\n resolveExpected?: ResolveExpectedUpdatedAt\n}\n\nexport type OptimisticLockGuardService = {\n validateMutation: (input: CrudMutationGuardValidateInput) => Promise<CrudMutationGuardValidationResult>\n afterMutationSuccess: (input: CrudMutationGuardAfterSuccessInput) => Promise<void>\n /** Exposed for tests / introspection. */\n getConfig: () => OptimisticLockConfig\n}\n\nfunction readHeader(headers: Headers, name: string): string | null {\n const direct = headers.get(name)\n if (typeof direct === 'string' && direct.trim().length > 0) return direct.trim()\n return null\n}\n\n/**\n * Normalize an `updated_at` token to a canonical ISO-8601 string, or `null`\n * when the input cannot be parsed. Exported so the command-level helper\n * (`optimistic-lock-command.ts`) compares timestamps with the EXACT same\n * normalization as the CRUD guard \u2014 otherwise the same instant could compare\n * unequal across the two paths.\n */\nexport function normalizeIsoToken(raw: string): string | null {\n const ms = Date.parse(raw)\n if (!Number.isFinite(ms)) return null\n return new Date(ms).toISOString()\n}\n\nfunction buildConflictBody(currentIso: string, expectedIso: string): OptimisticLockConflictBody {\n return {\n error: OPTIMISTIC_LOCK_CONFLICT_ERROR,\n code: OPTIMISTIC_LOCK_CONFLICT_CODE,\n currentUpdatedAt: currentIso,\n expectedUpdatedAt: expectedIso,\n }\n}\n\n/**\n * Factory for the optimistic-lock guard service.\n *\n * Usage from a module's `di.ts`:\n *\n * ```ts\n * import { asFunction } from 'awilix'\n * import { createOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock'\n *\n * container.register({\n * crudMutationGuardService: asFunction((cradle) => createOptimisticLockGuardService({\n * getEm: () => cradle.em,\n * readers: {\n * 'customers.company': async (em, { resourceId, tenantId }) => {\n * const row = await em.findOne(Company, { id: resourceId, tenantId }, { fields: ['updatedAt'] })\n * return row?.updatedAt ? row.updatedAt.toISOString() : null\n * },\n * },\n * })).singleton(),\n * })\n * ```\n */\nexport function createOptimisticLockGuardService(\n opts: OptimisticLockGuardOptions,\n): OptimisticLockGuardService {\n const envValue = opts.envValue !== undefined ? opts.envValue : process.env[OPTIMISTIC_LOCK_ENV_VAR]\n const config = parseOptimisticLockEnv(envValue)\n const resolveExpected = opts.resolveExpected ?? defaultResolveExpectedUpdatedAt\n const debugEnabled = process.env.OM_OPTIMISTIC_LOCK_DEBUG === '1'\n\n function isEntityEnabled(resourceKind: string): boolean {\n if (config.mode === 'off') return false\n if (config.mode === 'all') return true\n return config.entities.has(resourceKind.toLowerCase())\n }\n\n async function validateMutation(\n input: CrudMutationGuardValidateInput,\n ): Promise<CrudMutationGuardValidationResult> {\n if (config.mode === 'off') {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n if (input.operation !== 'update' && input.operation !== 'delete') {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n if (!isEntityEnabled(input.resourceKind)) {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n const readers = opts.readers ?? getAllOptimisticLockReaders()\n const reader = readers[input.resourceKind]\n if (!reader) {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n\n const expectedRaw = readHeader(input.requestHeaders, OPTIMISTIC_LOCK_HEADER_NAME)\n const resolvedExpected = await resolveExpected({\n expectedFromHeader: expectedRaw,\n resourceKind: input.resourceKind,\n resourceId: input.resourceId,\n })\n if (resolvedExpected == null) {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n\n const expectedIso = normalizeIsoToken(resolvedExpected)\n if (expectedIso == null) {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n\n const em = opts.getEm()\n const currentRaw = await reader(em, {\n resourceKind: input.resourceKind,\n resourceId: input.resourceId,\n tenantId: input.tenantId,\n organizationId: input.organizationId ?? null,\n })\n if (currentRaw == null) {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n const currentIso = normalizeIsoToken(currentRaw)\n if (currentIso == null) {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n\n if (currentIso === expectedIso) {\n if (debugEnabled) {\n // eslint-disable-next-line no-console\n console.log('[optimistic-lock] match', {\n resourceKind: input.resourceKind,\n resourceId: input.resourceId,\n operation: input.operation,\n currentIso,\n expectedIso,\n })\n }\n return { ok: true, shouldRunAfterSuccess: false }\n }\n\n if (debugEnabled) {\n // eslint-disable-next-line no-console\n console.log('[optimistic-lock] CONFLICT', {\n resourceKind: input.resourceKind,\n resourceId: input.resourceId,\n operation: input.operation,\n tenantId: input.tenantId,\n organizationId: input.organizationId ?? null,\n expectedRaw: resolvedExpected,\n expectedIso,\n currentRaw,\n currentIso,\n })\n }\n\n return {\n ok: false,\n status: 409,\n body: buildConflictBody(currentIso, expectedIso),\n }\n }\n\n async function afterMutationSuccess(_input: CrudMutationGuardAfterSuccessInput): Promise<void> {\n // no-op: optimistic check has no post-success cleanup\n }\n\n function getConfig(): OptimisticLockConfig {\n return config\n }\n\n return {\n validateMutation,\n afterMutationSuccess,\n getConfig,\n }\n}\n"],
|
|
5
|
-
"mappings": "AAsCA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AACP,SAAS,mCAAmC;AAc5C,MAAM,6BAAkD,oBAAI,IAAI;AAAA,EAC9D;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAUM,SAAS,uBAAuB,KAAsD;AAC3F,MAAI,OAAO,KAAM,QAAO,EAAE,MAAM,MAAM;AACtC,QAAM,UAAU,OAAO,GAAG,EAAE,KAAK;AACjC,MAAI,YAAY,GAAI,QAAO,EAAE,MAAM,MAAM;AAEzC,QAAM,SAAS,QACZ,MAAM,GAAG,EACT,IAAI,CAAC,UAAU,MAAM,KAAK,EAAE,YAAY,CAAC,EACzC,OAAO,CAAC,UAAU,MAAM,SAAS,CAAC;AAErC,MAAI,OAAO,WAAW,EAAG,QAAO,EAAE,MAAM,MAAM;AAC9C,MAAI,OAAO,KAAK,CAAC,UAAU,2BAA2B,IAAI,KAAK,CAAC,EAAG,QAAO,EAAE,MAAM,MAAM;AACxF,MAAI,OAAO,SAAS,KAAK,EAAG,QAAO,EAAE,MAAM,MAAM;AAEjD,SAAO,EAAE,MAAM,aAAa,UAAU,IAAI,IAAI,MAAM,EAAE;AACxD;AAoBA,MAAM,kCAA4D,CAAC,EAAE,mBAAmB,MACtF;
|
|
4
|
+
"sourcesContent": ["/**\n * OSS optimistic-locking guard service.\n *\n * Registered as `crudMutationGuardService` (by the platform DI bootstrap and\n * by hand-wiring modules that override the default reader). Compares the\n * client-sent expected `updated_at` (carried via the extension header\n * defined in `optimistic-lock-headers.ts`) against the current DB\n * `updated_at` for the target entity; on mismatch returns HTTP 409 with the\n * structured `OptimisticLockConflictBody`.\n *\n * **Default ON** (Phase 14, 2026-05-27). Activate / scope / disable via\n * `OM_OPTIMISTIC_LOCK`:\n * - unset / empty / whitespace \u2192 ON for every CRUD entity (`{ mode: 'all' }`)\n * - `all` \u2192 all entities (explicit form of the default)\n * - `customers.company,sales.order` \u2192 allow-list (lowercased, trimmed, deduped)\n * - `off` / `false` / `0` / `no` / `disabled` / `none` \u2192 fully disabled\n *\n * The guard is still strictly additive at runtime: clients that do not send\n * the `x-om-ext-optimistic-lock-expected-updated-at` header pass through\n * unchanged, so flipping the default to ON cannot introduce new 409s on\n * existing API consumers. Pages that opt into the round-trip (via\n * `CrudForm`'s `optimisticLockUpdatedAt` prop or by calling\n * `buildOptimisticLockHeader`) gain protection without any per-deployment\n * env change.\n *\n * Cannot be registered as a static `data/guards.ts` `MutationGuard` because\n * the static `validate(input)` receives only `MutationGuardInput` \u2014 no\n * container / em access. Stateful checks that need to read current DB state\n * MUST go through the DI service path (this file).\n *\n * Spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md\n */\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type {\n CrudMutationGuardValidateInput,\n CrudMutationGuardValidationResult,\n CrudMutationGuardAfterSuccessInput,\n} from './mutation-guard'\nimport {\n OPTIMISTIC_LOCK_CONFLICT_CODE,\n OPTIMISTIC_LOCK_CONFLICT_ERROR,\n OPTIMISTIC_LOCK_ENV_VAR,\n OPTIMISTIC_LOCK_HEADER_NAME,\n type OptimisticLockConflictBody,\n} from './optimistic-lock-headers'\nimport { getAllOptimisticLockReaders } from './optimistic-lock-store'\n\nexport type OptimisticLockConfig =\n | { mode: 'off' }\n | { mode: 'all' }\n | { mode: 'allowlist'; entities: ReadonlySet<string> }\n\n/**\n * Tokens (case-insensitive, single-token-only) that explicitly disable the\n * guard. Spelled out as a fixed set so tests can pin them; deliberately the\n * same shape `parseBooleanToken` recognises so operators can mirror existing\n * habit. Mixing an off-token with other entities is invalid input \u2014 we treat\n * any presence of an off-token in the comma list as a request to disable.\n */\nconst OPTIMISTIC_LOCK_OFF_TOKENS: ReadonlySet<string> = new Set([\n 'off',\n 'false',\n '0',\n 'no',\n 'disabled',\n 'none',\n])\n\n/**\n * Pure parser for `OM_OPTIMISTIC_LOCK`. Exported separately so tests can\n * exercise the grammar without spinning up the full service.\n *\n * Default is **ON** (`{ mode: 'all' }`) \u2014 unset / empty / whitespace input\n * activates the guard for every CRUD entity. Operators opt out via\n * `OM_OPTIMISTIC_LOCK=off` (or `false` / `0` / `no` / `disabled` / `none`).\n */\nexport function parseOptimisticLockEnv(raw: string | undefined | null): OptimisticLockConfig {\n if (raw == null) return { mode: 'all' }\n const trimmed = String(raw).trim()\n if (trimmed === '') return { mode: 'all' }\n\n const tokens = trimmed\n .split(',')\n .map((token) => token.trim().toLowerCase())\n .filter((token) => token.length > 0)\n\n if (tokens.length === 0) return { mode: 'all' }\n if (tokens.some((token) => OPTIMISTIC_LOCK_OFF_TOKENS.has(token))) return { mode: 'off' }\n if (tokens.includes('all')) return { mode: 'all' }\n\n return { mode: 'allowlist', entities: new Set(tokens) }\n}\n\nexport type OptimisticLockResolverInput = {\n expectedFromHeader: string | null\n resourceKind: string\n resourceId: string\n}\n\n/**\n * Hook reserved for the enterprise `record_locks` module to override token\n * resolution (e.g. read the expected token from a lock record instead of\n * the request header). OSS keeps the default = \"what the client sent\".\n *\n * Documented as part of the enterprise extension contract; not used in OSS\n * itself.\n */\nexport type ResolveExpectedUpdatedAt = (\n input: OptimisticLockResolverInput,\n) => Promise<string | null> | string | null\n\nconst defaultResolveExpectedUpdatedAt: ResolveExpectedUpdatedAt = ({ expectedFromHeader }) =>\n expectedFromHeader\n\nexport type OptimisticLockCurrentReader = (\n em: EntityManager,\n input: { resourceKind: string; resourceId: string; tenantId: string; organizationId: string | null },\n) => Promise<string | null>\n\nexport type GenericOptimisticLockReaderOptions = {\n /** MikroORM entity class. */\n entity: unknown\n /** Primary key field. Defaults to `id`. */\n idField?: string\n /** Tenant scope field. Defaults to `tenantId`. Pass `null` to skip tenant scoping (rare \u2014 only when the entity itself has no `tenantId` column). */\n tenantField?: string | null\n /** Organization scope field. Defaults to `organizationId`. Pass `null` to skip organization scoping. */\n orgField?: string | null\n /** Soft-delete column. Defaults to `deletedAt`. Pass `null` to skip the implicit not-deleted filter. */\n softDeleteField?: string | null\n /** Optional fixed filter merged into every query (e.g. `{ kind: 'company' }` for a discriminated table). */\n extraFilter?: Record<string, unknown>\n /** Optional ORM field name carrying the timestamp. Defaults to `updatedAt`. */\n updatedAtField?: string\n}\n\n/**\n * Build a generic optimistic-lock reader for any ORM entity that follows the\n * platform conventions (`id` + `tenantId` + `organizationId` + `deletedAt` +\n * `updatedAt`). The reader projects only the timestamp column so PII never\n * materializes.\n *\n * Used by `makeCrudRoute` to auto-register one reader per CRUD route at\n * module-load time (see Phase 13 of the OSS optimistic-locking spec).\n * Module authors who need bespoke filtering (e.g. discriminator on a shared\n * table) keep registering their own reader via `registerOptimisticLockReaders`\n * \u2014 those hand-wired registrations win because they land first.\n *\n * Fail-open contract: if the underlying `findOne` throws (missing column,\n * schema drift, mid-migration) the reader returns `null`, which the guard\n * treats as \"entity already gone\" and lets the CRUD path's own 404 fire.\n * We MUST NOT throw out of the reader \u2014 that would 500 every mutation on\n * the affected entity instead of opting it out of the optimistic check.\n *\n * Because a genuine not-found resolves to `null` *without* throwing (MikroORM's\n * `findOne` returns `null`, it does not raise), the catch below only fires on a\n * real query failure \u2014 most commonly a `softDeleteField`/`tenantField`/`orgField`\n * misconfig that filters on a column the entity's table does not have. That class\n * of bug silently disables locking for the whole entity, so the catch logs loudly\n * with the `resourceKind` instead of swallowing the error. The control flow stays\n * fail-open (returns `null`) to honor the no-500 contract; the durable defense is\n * the static reader-resolution guard (`optimistic-lock-editable-entities.test.ts`)\n * which fails the build when a route would land in this path.\n */\nexport function createGenericOptimisticLockReader(\n opts: GenericOptimisticLockReaderOptions,\n): OptimisticLockCurrentReader {\n const idField = opts.idField ?? 'id'\n const tenantField = opts.tenantField === null ? null : opts.tenantField ?? 'tenantId'\n const orgField = opts.orgField === null ? null : opts.orgField ?? 'organizationId'\n const softDeleteField = opts.softDeleteField === null ? null : opts.softDeleteField ?? 'deletedAt'\n const updatedAtField = opts.updatedAtField ?? 'updatedAt'\n const extraFilter = opts.extraFilter ?? {}\n\n return async (em, { resourceKind, resourceId, tenantId, organizationId }) => {\n const filter: Record<string, unknown> = { [idField]: resourceId }\n if (tenantField) filter[tenantField] = tenantId\n if (orgField && organizationId) filter[orgField] = organizationId\n if (softDeleteField) filter[softDeleteField] = null\n for (const [key, value] of Object.entries(extraFilter)) filter[key] = value\n\n try {\n const row = await em.findOne(opts.entity as never, filter as never, {\n fields: [updatedAtField] as never,\n })\n if (!row || typeof row !== 'object') return null\n const value = (row as Record<string, unknown>)[updatedAtField]\n if (value instanceof Date) return value.toISOString()\n if (typeof value === 'string' && value.length > 0) return value\n return null\n } catch (err) {\n // A genuine not-found returns null above without throwing; reaching here\n // means the query itself failed (most likely a softDeleteField/tenant/org\n // misconfig filtering on a column the table lacks), which silently disables\n // locking for `resourceKind`. Log loudly so the misconfig is visible.\n // Control flow stays fail-open (return null) to honor the no-500 contract.\n // eslint-disable-next-line no-console\n console.error(\n `[optimistic-lock] reader query failed for resourceKind=\"${resourceKind}\" \u2014 optimistic locking is DISABLED for this entity until fixed. ` +\n `Most likely a softDeleteField/tenantField/orgField filters on a column the table does not have.`,\n err,\n )\n return null\n }\n }\n}\n\nexport type OptimisticLockGuardOptions = {\n /** EntityManager resolver. Container-bound via DI in real usage. */\n getEm: () => EntityManager\n /**\n * Maps `resourceKind` \u2192 reader that returns the current\n * `updated_at` as an ISO string (or null when not found).\n *\n * The reader receives the EM so module authors can choose the\n * right `findOne` shape for their entity (`findOneWithDecryption`\n * when sensitive, plain `findOne` otherwise \u2014 but only requesting\n * `updated_at` so no PII materializes).\n *\n * When omitted, the service pulls readers from the shared\n * `optimistic-lock-store` (the recommended pattern for multi-module\n * deployments \u2014 each module registers its own readers via\n * `registerOptimisticLockReaders(...)` at module-load time).\n */\n readers?: Record<string, OptimisticLockCurrentReader>\n /** Override env source (mostly for tests). Defaults to `process.env`. */\n envValue?: string | null\n /** Override the token resolver. Defaults to \"use the header value\". */\n resolveExpected?: ResolveExpectedUpdatedAt\n}\n\nexport type OptimisticLockGuardService = {\n validateMutation: (input: CrudMutationGuardValidateInput) => Promise<CrudMutationGuardValidationResult>\n afterMutationSuccess: (input: CrudMutationGuardAfterSuccessInput) => Promise<void>\n /** Exposed for tests / introspection. */\n getConfig: () => OptimisticLockConfig\n}\n\nfunction readHeader(headers: Headers, name: string): string | null {\n const direct = headers.get(name)\n if (typeof direct === 'string' && direct.trim().length > 0) return direct.trim()\n return null\n}\n\n/**\n * Normalize an `updated_at` token to a canonical ISO-8601 string, or `null`\n * when the input cannot be parsed. Exported so the command-level helper\n * (`optimistic-lock-command.ts`) compares timestamps with the EXACT same\n * normalization as the CRUD guard \u2014 otherwise the same instant could compare\n * unequal across the two paths.\n */\nexport function normalizeIsoToken(raw: string): string | null {\n const ms = Date.parse(raw)\n if (!Number.isFinite(ms)) return null\n return new Date(ms).toISOString()\n}\n\nfunction buildConflictBody(currentIso: string, expectedIso: string): OptimisticLockConflictBody {\n return {\n error: OPTIMISTIC_LOCK_CONFLICT_ERROR,\n code: OPTIMISTIC_LOCK_CONFLICT_CODE,\n currentUpdatedAt: currentIso,\n expectedUpdatedAt: expectedIso,\n }\n}\n\n/**\n * Factory for the optimistic-lock guard service.\n *\n * Usage from a module's `di.ts`:\n *\n * ```ts\n * import { asFunction } from 'awilix'\n * import { createOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock'\n *\n * container.register({\n * crudMutationGuardService: asFunction((cradle) => createOptimisticLockGuardService({\n * getEm: () => cradle.em,\n * readers: {\n * 'customers.company': async (em, { resourceId, tenantId }) => {\n * const row = await em.findOne(Company, { id: resourceId, tenantId }, { fields: ['updatedAt'] })\n * return row?.updatedAt ? row.updatedAt.toISOString() : null\n * },\n * },\n * })).singleton(),\n * })\n * ```\n */\nexport function createOptimisticLockGuardService(\n opts: OptimisticLockGuardOptions,\n): OptimisticLockGuardService {\n const envValue = opts.envValue !== undefined ? opts.envValue : process.env[OPTIMISTIC_LOCK_ENV_VAR]\n const config = parseOptimisticLockEnv(envValue)\n const resolveExpected = opts.resolveExpected ?? defaultResolveExpectedUpdatedAt\n const debugEnabled = process.env.OM_OPTIMISTIC_LOCK_DEBUG === '1'\n\n function isEntityEnabled(resourceKind: string): boolean {\n if (config.mode === 'off') return false\n if (config.mode === 'all') return true\n return config.entities.has(resourceKind.toLowerCase())\n }\n\n async function validateMutation(\n input: CrudMutationGuardValidateInput,\n ): Promise<CrudMutationGuardValidationResult> {\n if (config.mode === 'off') {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n if (input.operation !== 'update' && input.operation !== 'delete') {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n if (!isEntityEnabled(input.resourceKind)) {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n const readers = opts.readers ?? getAllOptimisticLockReaders()\n const reader = readers[input.resourceKind]\n if (!reader) {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n\n const expectedRaw = readHeader(input.requestHeaders, OPTIMISTIC_LOCK_HEADER_NAME)\n const resolvedExpected = await resolveExpected({\n expectedFromHeader: expectedRaw,\n resourceKind: input.resourceKind,\n resourceId: input.resourceId,\n })\n if (resolvedExpected == null) {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n\n const expectedIso = normalizeIsoToken(resolvedExpected)\n if (expectedIso == null) {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n\n const em = opts.getEm()\n const currentRaw = await reader(em, {\n resourceKind: input.resourceKind,\n resourceId: input.resourceId,\n tenantId: input.tenantId,\n organizationId: input.organizationId ?? null,\n })\n if (currentRaw == null) {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n const currentIso = normalizeIsoToken(currentRaw)\n if (currentIso == null) {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n\n if (currentIso === expectedIso) {\n if (debugEnabled) {\n // eslint-disable-next-line no-console\n console.log('[optimistic-lock] match', {\n resourceKind: input.resourceKind,\n resourceId: input.resourceId,\n operation: input.operation,\n currentIso,\n expectedIso,\n })\n }\n return { ok: true, shouldRunAfterSuccess: false }\n }\n\n if (debugEnabled) {\n // eslint-disable-next-line no-console\n console.log('[optimistic-lock] CONFLICT', {\n resourceKind: input.resourceKind,\n resourceId: input.resourceId,\n operation: input.operation,\n tenantId: input.tenantId,\n organizationId: input.organizationId ?? null,\n expectedRaw: resolvedExpected,\n expectedIso,\n currentRaw,\n currentIso,\n })\n }\n\n return {\n ok: false,\n status: 409,\n body: buildConflictBody(currentIso, expectedIso),\n }\n }\n\n async function afterMutationSuccess(_input: CrudMutationGuardAfterSuccessInput): Promise<void> {\n // no-op: optimistic check has no post-success cleanup\n }\n\n function getConfig(): OptimisticLockConfig {\n return config\n }\n\n return {\n validateMutation,\n afterMutationSuccess,\n getConfig,\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/lib/di/container.js
CHANGED
|
@@ -7,6 +7,7 @@ import { commandRegistry, CommandBus } from "@open-mercato/shared/lib/commands";
|
|
|
7
7
|
import { applyDiOverridesToContainer } from "@open-mercato/shared/modules/overrides";
|
|
8
8
|
import { createOptimisticLockGuardService } from "@open-mercato/shared/lib/crud/optimistic-lock";
|
|
9
9
|
import { getAllOptimisticLockReaders } from "@open-mercato/shared/lib/crud/optimistic-lock-store";
|
|
10
|
+
import { createCommandOptimisticLockGuardService } from "@open-mercato/shared/lib/crud/optimistic-lock-command";
|
|
10
11
|
const GLOBAL_KEY = "__openMercatoDiRegistrars__";
|
|
11
12
|
const BOOTSTRAP_CACHE_KEY = "__openMercatoBootstrapCache__";
|
|
12
13
|
const ENCRYPTION_ENABLED_KEY = "__openMercatoEncryptionEnabledCache__";
|
|
@@ -127,6 +128,16 @@ async function createRequestContainer() {
|
|
|
127
128
|
getEm: () => scopedEm,
|
|
128
129
|
readers: getAllOptimisticLockReaders()
|
|
129
130
|
})
|
|
131
|
+
).scoped(),
|
|
132
|
+
// Default OSS command-level optimistic-lock guard, awaited by
|
|
133
|
+
// `enforceCommandOptimisticLockWithGuards` for Command-pattern writes.
|
|
134
|
+
// Header/explicit-token compare only (no `resolveExpected`), so it is
|
|
135
|
+
// behaviourally identical to calling `enforceCommandOptimisticLock`
|
|
136
|
+
// directly. The enterprise `record_locks` module overrides this DI key
|
|
137
|
+
// with a lock-backed `resolveExpected` via Awilix replace semantics.
|
|
138
|
+
// Spec: .ai/specs/enterprise/2026-06-09-record-locks-unified-coverage.md (Phase 0)
|
|
139
|
+
commandOptimisticLockGuardService: asFunction(
|
|
140
|
+
() => createCommandOptimisticLockGuardService()
|
|
130
141
|
).scoped()
|
|
131
142
|
});
|
|
132
143
|
for (const reg of diRegistrars) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/di/container.ts"],
|
|
4
|
-
"sourcesContent": ["import { asFunction, createContainer, asValue, AwilixContainer, InjectionMode, type Resolver } from 'awilix'\nimport { RequestContext } from '@mikro-orm/core'\nimport { getOrm } from '@open-mercato/shared/lib/db/mikro'\nimport { EntityManager } from '@mikro-orm/postgresql'\nimport { BasicQueryEngine } from '@open-mercato/shared/lib/query/engine'\nimport { DefaultDataEngine } from '@open-mercato/shared/lib/data/engine'\nimport { commandRegistry, CommandBus } from '@open-mercato/shared/lib/commands'\nimport { applyDiOverridesToContainer } from '@open-mercato/shared/modules/overrides'\nimport { createOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock'\nimport { getAllOptimisticLockReaders } from '@open-mercato/shared/lib/crud/optimistic-lock-store'\n\ntype DynamicCradle = Record<string, any>\n\nexport type AppContainer = AwilixContainer<DynamicCradle>\nexport type DiRegistrar = (container: AppContainer) => void\n\n// Registration pattern for publishable packages\n// Use globalThis to survive tsx/esbuild module duplication issue where the same\n// file can be loaded as multiple module instances when mixing dynamic and static imports\nconst GLOBAL_KEY = '__openMercatoDiRegistrars__'\n// Phase 5 \u2014 process-scoped bootstrap cache. The cache/event-bus/encryption\n// services bootstrap() creates are inherently process-scoped (they hold\n// state across requests). Caching them on globalThis after the first\n// successful bootstrap call lets every subsequent request skip the\n// `await bootstrap(container)` body and just re-register the cached\n// instances. Same globalThis pattern as registerDiRegistrars so HMR\n// keeps working.\nconst BOOTSTRAP_CACHE_KEY = '__openMercatoBootstrapCache__'\nconst ENCRYPTION_ENABLED_KEY = '__openMercatoEncryptionEnabledCache__'\n\nconst BOOTSTRAP_CACHE_KEYS = [\n 'cache',\n 'eventBus',\n 'kmsService',\n 'tenantEncryptionService',\n 'rateLimiterService',\n 'searchModuleConfigs',\n 'searchIndexer',\n] as const\n\ntype BootstrapCacheEntry = Partial<Record<(typeof BOOTSTRAP_CACHE_KEYS)[number], unknown>>\n\n// Phase 5 is opt-in. Some bootstrap services close over per-request state\n// (e.g. tenantEncryptionService captures the first request's `em.fork`, the\n// event-bus's resolver closes over the first container) so naively replaying\n// them on later requests yields stale references \u2014 observed as a 500 from\n// CRUD list endpoints in `next start`. Default OFF preserves develop's\n// per-request bootstrap. Set `OM_BOOTSTRAP_CACHE=1` to opt in once each\n// cached service is verified safe for cross-request reuse.\nfunction isBootstrapCacheEnabled(): boolean {\n const raw = process.env.OM_BOOTSTRAP_CACHE\n if (raw === undefined) return false\n const normalized = raw.trim().toLowerCase()\n if (!normalized.length) return false\n if (normalized === '0' || normalized === 'off' || normalized === 'false' || normalized === 'no') return false\n return true\n}\n\nfunction getBootstrapCache(): BootstrapCacheEntry | null {\n if (!isBootstrapCacheEnabled()) return null\n const existing = (globalThis as any)[BOOTSTRAP_CACHE_KEY]\n return existing && typeof existing === 'object' ? (existing as BootstrapCacheEntry) : null\n}\n\nfunction setBootstrapCache(entry: BootstrapCacheEntry): void {\n if (!isBootstrapCacheEnabled()) return\n ;(globalThis as any)[BOOTSTRAP_CACHE_KEY] = entry\n}\n\nfunction harvestBootstrapCache(container: AwilixContainer): BootstrapCacheEntry {\n const entry: BootstrapCacheEntry = {}\n for (const key of BOOTSTRAP_CACHE_KEYS) {\n try {\n const value: unknown = container.resolve(key as never)\n if (value !== undefined && value !== null) entry[key] = value\n } catch {\n // not registered \u2014 skip\n }\n }\n return entry\n}\n\ntype EncryptionEnabledProbe = { isEnabled?: () => boolean } | null | undefined\n\nfunction getCachedEncryptionEnabled(service: EncryptionEnabledProbe): boolean | null {\n if (!service || typeof service.isEnabled !== 'function') return false\n const cached = (globalThis as Record<string, unknown>)[ENCRYPTION_ENABLED_KEY]\n if (typeof cached === 'boolean') return cached\n try {\n const result = !!service.isEnabled()\n ;(globalThis as Record<string, unknown>)[ENCRYPTION_ENABLED_KEY] = result\n return result\n } catch {\n return null\n }\n}\n\nfunction getGlobalRegistrars(): DiRegistrar[] | null {\n return (globalThis as any)[GLOBAL_KEY] ?? null\n}\n\nfunction setGlobalRegistrars(registrars: DiRegistrar[]): void {\n (globalThis as any)[GLOBAL_KEY] = registrars\n}\n\nexport function registerDiRegistrars(registrars: DiRegistrar[]) {\n const existing = getGlobalRegistrars()\n if (existing !== null && process.env.NODE_ENV === 'development') {\n console.debug('[Bootstrap] DI registrars re-registered (this may occur during HMR)')\n }\n setGlobalRegistrars(registrars)\n // Force re-bootstrap on HMR \u2014 module subscribers may have changed.\n ;(globalThis as any)[BOOTSTRAP_CACHE_KEY] = null\n ;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined\n}\n\nexport function getDiRegistrars(): DiRegistrar[] {\n const registrars = getGlobalRegistrars()\n if (!registrars) {\n throw new Error('[Bootstrap] DI registrars not registered. Call registerDiRegistrars() at bootstrap.')\n }\n return registrars\n}\n\n/** Test-only helper to drop the process-scoped bootstrap cache. */\nexport function resetBootstrapCache(): void {\n (globalThis as any)[BOOTSTRAP_CACHE_KEY] = null\n ;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined\n}\n\nfunction isAwilixResolver(value: unknown): value is Resolver<unknown> {\n return Boolean(value && typeof value === 'object' && typeof (value as { resolve?: unknown }).resolve === 'function')\n}\n\nfunction toAwilixRegistrations(registrations: Record<string, unknown>): Record<string, Resolver<any>> {\n return Object.fromEntries(\n Object.entries(registrations).map(([key, value]) => [\n key,\n isAwilixResolver(value) ? value : asValue(value),\n ]),\n )\n}\n\nexport async function createRequestContainer(): Promise<AppContainer> {\n const diRegistrars = getDiRegistrars()\n const orm = await getOrm()\n // Use a fresh event manager so request-level subscribers (e.g., encryption) don't pile up globally\n const baseEm = (RequestContext.getEntityManager() as any) ?? orm.em\n const em = baseEm.fork({ clear: true, freshEventManager: true, useContext: true }) as unknown as EntityManager\n const container = createContainer<DynamicCradle>({ injectionMode: InjectionMode.CLASSIC })\n // Core registrations\n container.register({\n em: asValue(em),\n queryEngine: asValue(new BasicQueryEngine(em, undefined, () => {\n try { return container.resolve('tenantEncryptionService') as any } catch { return null }\n })),\n dataEngine: asValue(new DefaultDataEngine(em, container as any)),\n commandRegistry: asValue(commandRegistry),\n commandBus: asValue(new CommandBus()),\n // Default OSS optimistic-lock guard. Reads from the global reader store\n // (populated by `makeCrudRoute` auto-registration + any module-DI\n // hand-wired calls to `registerOptimisticLockReaders`). Service is\n // strictly additive: when `OM_OPTIMISTIC_LOCK=off` (or no header is\n // sent) it short-circuits at validateMutation. Module-level di.ts\n // registrations override this default via Awilix replace semantics \u2014\n // see the enterprise `record_locks` module for the canonical override.\n // Spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md\n crudMutationGuardService: asFunction(({ em: scopedEm }: { em: EntityManager }) =>\n createOptimisticLockGuardService({\n getEm: () => scopedEm,\n readers: getAllOptimisticLockReaders(),\n }),\n ).scoped(),\n })\n // Allow modules to override/extend\n for (const reg of diRegistrars) {\n try { reg?.(container) } catch {}\n }\n // Core bootstrap (cache, event bus, encryption subscriber/KMS, module subscribers)\n // Phase 5 \u2014 process-scoped once-guard. The first request runs the full\n // bootstrap() body; later requests re-register the cached services\n // directly on this request's container without re-importing or\n // re-initializing anything. HMR clears the cache (see\n // registerDiRegistrars). Skippable if a caller already wired eventBus.\n const alreadyBootstrappedOnThisContainer = !!container.registrations?.eventBus\n if (!alreadyBootstrappedOnThisContainer) {\n const cached = getBootstrapCache()\n if (cached) {\n const replay: Record<string, any> = {}\n for (const [key, value] of Object.entries(cached)) {\n if (value !== undefined && value !== null) replay[key] = asValue(value)\n }\n if (Object.keys(replay).length > 0) container.register(replay)\n } else {\n try {\n const { bootstrap } = await import('@open-mercato/core/bootstrap') as any\n if (bootstrap && typeof bootstrap === 'function') {\n await bootstrap(container)\n setBootstrapCache(harvestBootstrapCache(container))\n }\n } catch { /* optional */ }\n }\n }\n // App-level DI override (last chance)\n // This import path resolves only in the app context, not in packages\n try {\n // @ts-ignore - @/di only exists in app context, not in packages\n const appDi = await import('@/di') as any\n if (appDi?.register) {\n try {\n const maybe = appDi.register(container)\n if (maybe && typeof maybe.then === 'function') await maybe\n } catch {}\n }\n } catch {}\n applyDiOverridesToContainer({\n register: (registrations) => container.register(toAwilixRegistrations(registrations)),\n unregister: (key) => container.register({ [key]: asValue(undefined) }),\n })\n // Ensure tenant encryption subscriber is always registered on the fresh request-scoped EM\n // Phase 5 \u2014 cache `tenantEncryptionService.isEnabled()` for the process\n // lifetime. The result depends only on config that does not change at\n // runtime, so reading it once skips a config lookup per request.\n try {\n const emForEnc = container.resolve('em') as any\n const tenantEncryptionService = container.hasRegistration('tenantEncryptionService')\n ? (container.resolve('tenantEncryptionService') as any)\n : null\n if (emForEnc && tenantEncryptionService && getCachedEncryptionEnabled(tenantEncryptionService) === true) {\n const { registerTenantEncryptionSubscriber } = await import('@open-mercato/shared/lib/encryption/subscriber')\n registerTenantEncryptionSubscriber(emForEnc, tenantEncryptionService)\n }\n } catch {\n // best-effort; do not block container creation\n }\n return container\n}\ntry {\n // eslint-disable-next-line @typescript-eslint/no-var-requires\n require('server-only')\n} catch {\n // allow CLI/generator usage where Next server-only is not present\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,YAAY,iBAAiB,SAA0B,qBAAoC;AACpG,SAAS,sBAAsB;AAC/B,SAAS,cAAc;AAEvB,SAAS,wBAAwB;AACjC,SAAS,yBAAyB;AAClC,SAAS,iBAAiB,kBAAkB;AAC5C,SAAS,mCAAmC;AAC5C,SAAS,wCAAwC;AACjD,SAAS,mCAAmC;
|
|
4
|
+
"sourcesContent": ["import { asFunction, createContainer, asValue, AwilixContainer, InjectionMode, type Resolver } from 'awilix'\nimport { RequestContext } from '@mikro-orm/core'\nimport { getOrm } from '@open-mercato/shared/lib/db/mikro'\nimport { EntityManager } from '@mikro-orm/postgresql'\nimport { BasicQueryEngine } from '@open-mercato/shared/lib/query/engine'\nimport { DefaultDataEngine } from '@open-mercato/shared/lib/data/engine'\nimport { commandRegistry, CommandBus } from '@open-mercato/shared/lib/commands'\nimport { applyDiOverridesToContainer } from '@open-mercato/shared/modules/overrides'\nimport { createOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock'\nimport { getAllOptimisticLockReaders } from '@open-mercato/shared/lib/crud/optimistic-lock-store'\nimport { createCommandOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\n\ntype DynamicCradle = Record<string, any>\n\nexport type AppContainer = AwilixContainer<DynamicCradle>\nexport type DiRegistrar = (container: AppContainer) => void\n\n// Registration pattern for publishable packages\n// Use globalThis to survive tsx/esbuild module duplication issue where the same\n// file can be loaded as multiple module instances when mixing dynamic and static imports\nconst GLOBAL_KEY = '__openMercatoDiRegistrars__'\n// Phase 5 \u2014 process-scoped bootstrap cache. The cache/event-bus/encryption\n// services bootstrap() creates are inherently process-scoped (they hold\n// state across requests). Caching them on globalThis after the first\n// successful bootstrap call lets every subsequent request skip the\n// `await bootstrap(container)` body and just re-register the cached\n// instances. Same globalThis pattern as registerDiRegistrars so HMR\n// keeps working.\nconst BOOTSTRAP_CACHE_KEY = '__openMercatoBootstrapCache__'\nconst ENCRYPTION_ENABLED_KEY = '__openMercatoEncryptionEnabledCache__'\n\nconst BOOTSTRAP_CACHE_KEYS = [\n 'cache',\n 'eventBus',\n 'kmsService',\n 'tenantEncryptionService',\n 'rateLimiterService',\n 'searchModuleConfigs',\n 'searchIndexer',\n] as const\n\ntype BootstrapCacheEntry = Partial<Record<(typeof BOOTSTRAP_CACHE_KEYS)[number], unknown>>\n\n// Phase 5 is opt-in. Some bootstrap services close over per-request state\n// (e.g. tenantEncryptionService captures the first request's `em.fork`, the\n// event-bus's resolver closes over the first container) so naively replaying\n// them on later requests yields stale references \u2014 observed as a 500 from\n// CRUD list endpoints in `next start`. Default OFF preserves develop's\n// per-request bootstrap. Set `OM_BOOTSTRAP_CACHE=1` to opt in once each\n// cached service is verified safe for cross-request reuse.\nfunction isBootstrapCacheEnabled(): boolean {\n const raw = process.env.OM_BOOTSTRAP_CACHE\n if (raw === undefined) return false\n const normalized = raw.trim().toLowerCase()\n if (!normalized.length) return false\n if (normalized === '0' || normalized === 'off' || normalized === 'false' || normalized === 'no') return false\n return true\n}\n\nfunction getBootstrapCache(): BootstrapCacheEntry | null {\n if (!isBootstrapCacheEnabled()) return null\n const existing = (globalThis as any)[BOOTSTRAP_CACHE_KEY]\n return existing && typeof existing === 'object' ? (existing as BootstrapCacheEntry) : null\n}\n\nfunction setBootstrapCache(entry: BootstrapCacheEntry): void {\n if (!isBootstrapCacheEnabled()) return\n ;(globalThis as any)[BOOTSTRAP_CACHE_KEY] = entry\n}\n\nfunction harvestBootstrapCache(container: AwilixContainer): BootstrapCacheEntry {\n const entry: BootstrapCacheEntry = {}\n for (const key of BOOTSTRAP_CACHE_KEYS) {\n try {\n const value: unknown = container.resolve(key as never)\n if (value !== undefined && value !== null) entry[key] = value\n } catch {\n // not registered \u2014 skip\n }\n }\n return entry\n}\n\ntype EncryptionEnabledProbe = { isEnabled?: () => boolean } | null | undefined\n\nfunction getCachedEncryptionEnabled(service: EncryptionEnabledProbe): boolean | null {\n if (!service || typeof service.isEnabled !== 'function') return false\n const cached = (globalThis as Record<string, unknown>)[ENCRYPTION_ENABLED_KEY]\n if (typeof cached === 'boolean') return cached\n try {\n const result = !!service.isEnabled()\n ;(globalThis as Record<string, unknown>)[ENCRYPTION_ENABLED_KEY] = result\n return result\n } catch {\n return null\n }\n}\n\nfunction getGlobalRegistrars(): DiRegistrar[] | null {\n return (globalThis as any)[GLOBAL_KEY] ?? null\n}\n\nfunction setGlobalRegistrars(registrars: DiRegistrar[]): void {\n (globalThis as any)[GLOBAL_KEY] = registrars\n}\n\nexport function registerDiRegistrars(registrars: DiRegistrar[]) {\n const existing = getGlobalRegistrars()\n if (existing !== null && process.env.NODE_ENV === 'development') {\n console.debug('[Bootstrap] DI registrars re-registered (this may occur during HMR)')\n }\n setGlobalRegistrars(registrars)\n // Force re-bootstrap on HMR \u2014 module subscribers may have changed.\n ;(globalThis as any)[BOOTSTRAP_CACHE_KEY] = null\n ;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined\n}\n\nexport function getDiRegistrars(): DiRegistrar[] {\n const registrars = getGlobalRegistrars()\n if (!registrars) {\n throw new Error('[Bootstrap] DI registrars not registered. Call registerDiRegistrars() at bootstrap.')\n }\n return registrars\n}\n\n/** Test-only helper to drop the process-scoped bootstrap cache. */\nexport function resetBootstrapCache(): void {\n (globalThis as any)[BOOTSTRAP_CACHE_KEY] = null\n ;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined\n}\n\nfunction isAwilixResolver(value: unknown): value is Resolver<unknown> {\n return Boolean(value && typeof value === 'object' && typeof (value as { resolve?: unknown }).resolve === 'function')\n}\n\nfunction toAwilixRegistrations(registrations: Record<string, unknown>): Record<string, Resolver<any>> {\n return Object.fromEntries(\n Object.entries(registrations).map(([key, value]) => [\n key,\n isAwilixResolver(value) ? value : asValue(value),\n ]),\n )\n}\n\nexport async function createRequestContainer(): Promise<AppContainer> {\n const diRegistrars = getDiRegistrars()\n const orm = await getOrm()\n // Use a fresh event manager so request-level subscribers (e.g., encryption) don't pile up globally\n const baseEm = (RequestContext.getEntityManager() as any) ?? orm.em\n const em = baseEm.fork({ clear: true, freshEventManager: true, useContext: true }) as unknown as EntityManager\n const container = createContainer<DynamicCradle>({ injectionMode: InjectionMode.CLASSIC })\n // Core registrations\n container.register({\n em: asValue(em),\n queryEngine: asValue(new BasicQueryEngine(em, undefined, () => {\n try { return container.resolve('tenantEncryptionService') as any } catch { return null }\n })),\n dataEngine: asValue(new DefaultDataEngine(em, container as any)),\n commandRegistry: asValue(commandRegistry),\n commandBus: asValue(new CommandBus()),\n // Default OSS optimistic-lock guard. Reads from the global reader store\n // (populated by `makeCrudRoute` auto-registration + any module-DI\n // hand-wired calls to `registerOptimisticLockReaders`). Service is\n // strictly additive: when `OM_OPTIMISTIC_LOCK=off` (or no header is\n // sent) it short-circuits at validateMutation. Module-level di.ts\n // registrations override this default via Awilix replace semantics \u2014\n // see the enterprise `record_locks` module for the canonical override.\n // Spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md\n crudMutationGuardService: asFunction(({ em: scopedEm }: { em: EntityManager }) =>\n createOptimisticLockGuardService({\n getEm: () => scopedEm,\n readers: getAllOptimisticLockReaders(),\n }),\n ).scoped(),\n // Default OSS command-level optimistic-lock guard, awaited by\n // `enforceCommandOptimisticLockWithGuards` for Command-pattern writes.\n // Header/explicit-token compare only (no `resolveExpected`), so it is\n // behaviourally identical to calling `enforceCommandOptimisticLock`\n // directly. The enterprise `record_locks` module overrides this DI key\n // with a lock-backed `resolveExpected` via Awilix replace semantics.\n // Spec: .ai/specs/enterprise/2026-06-09-record-locks-unified-coverage.md (Phase 0)\n commandOptimisticLockGuardService: asFunction(() =>\n createCommandOptimisticLockGuardService(),\n ).scoped(),\n })\n // Allow modules to override/extend\n for (const reg of diRegistrars) {\n try { reg?.(container) } catch {}\n }\n // Core bootstrap (cache, event bus, encryption subscriber/KMS, module subscribers)\n // Phase 5 \u2014 process-scoped once-guard. The first request runs the full\n // bootstrap() body; later requests re-register the cached services\n // directly on this request's container without re-importing or\n // re-initializing anything. HMR clears the cache (see\n // registerDiRegistrars). Skippable if a caller already wired eventBus.\n const alreadyBootstrappedOnThisContainer = !!container.registrations?.eventBus\n if (!alreadyBootstrappedOnThisContainer) {\n const cached = getBootstrapCache()\n if (cached) {\n const replay: Record<string, any> = {}\n for (const [key, value] of Object.entries(cached)) {\n if (value !== undefined && value !== null) replay[key] = asValue(value)\n }\n if (Object.keys(replay).length > 0) container.register(replay)\n } else {\n try {\n const { bootstrap } = await import('@open-mercato/core/bootstrap') as any\n if (bootstrap && typeof bootstrap === 'function') {\n await bootstrap(container)\n setBootstrapCache(harvestBootstrapCache(container))\n }\n } catch { /* optional */ }\n }\n }\n // App-level DI override (last chance)\n // This import path resolves only in the app context, not in packages\n try {\n // @ts-ignore - @/di only exists in app context, not in packages\n const appDi = await import('@/di') as any\n if (appDi?.register) {\n try {\n const maybe = appDi.register(container)\n if (maybe && typeof maybe.then === 'function') await maybe\n } catch {}\n }\n } catch {}\n applyDiOverridesToContainer({\n register: (registrations) => container.register(toAwilixRegistrations(registrations)),\n unregister: (key) => container.register({ [key]: asValue(undefined) }),\n })\n // Ensure tenant encryption subscriber is always registered on the fresh request-scoped EM\n // Phase 5 \u2014 cache `tenantEncryptionService.isEnabled()` for the process\n // lifetime. The result depends only on config that does not change at\n // runtime, so reading it once skips a config lookup per request.\n try {\n const emForEnc = container.resolve('em') as any\n const tenantEncryptionService = container.hasRegistration('tenantEncryptionService')\n ? (container.resolve('tenantEncryptionService') as any)\n : null\n if (emForEnc && tenantEncryptionService && getCachedEncryptionEnabled(tenantEncryptionService) === true) {\n const { registerTenantEncryptionSubscriber } = await import('@open-mercato/shared/lib/encryption/subscriber')\n registerTenantEncryptionSubscriber(emForEnc, tenantEncryptionService)\n }\n } catch {\n // best-effort; do not block container creation\n }\n return container\n}\ntry {\n // eslint-disable-next-line @typescript-eslint/no-var-requires\n require('server-only')\n} catch {\n // allow CLI/generator usage where Next server-only is not present\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.6-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.6-develop.6332.1.6e73f0f55b'\nexport const appVersion = APP_VERSION\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6332.1.6e73f0f55b",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"@mikro-orm/core": "^7.1.4",
|
|
94
94
|
"@mikro-orm/decorators": "^7.1.4",
|
|
95
95
|
"@mikro-orm/postgresql": "^7.1.4",
|
|
96
|
-
"@open-mercato/cache": "0.6.6-develop.
|
|
96
|
+
"@open-mercato/cache": "0.6.6-develop.6332.1.6e73f0f55b",
|
|
97
97
|
"dotenv": "^17.4.2",
|
|
98
98
|
"rate-limiter-flexible": "^11.2.0",
|
|
99
99
|
"re2js": "2.8.3",
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { AwilixContainer } from 'awilix'
|
|
2
|
+
import {
|
|
3
|
+
enforceCommandOptimisticLockWithGuards,
|
|
4
|
+
type CommandOptimisticLockGuardService,
|
|
5
|
+
type EnforceCommandOptimisticLockInput,
|
|
6
|
+
} from '../optimistic-lock-command'
|
|
7
|
+
import { CrudHttpError } from '../errors'
|
|
8
|
+
import { OPTIMISTIC_LOCK_HEADER_NAME, OPTIMISTIC_LOCK_CONFLICT_CODE } from '../optimistic-lock-headers'
|
|
9
|
+
|
|
10
|
+
function containerWith(service: CommandOptimisticLockGuardService | null): AwilixContainer {
|
|
11
|
+
return {
|
|
12
|
+
resolve(key: string) {
|
|
13
|
+
if (key === 'commandOptimisticLockGuardService' && service) return service
|
|
14
|
+
throw new Error(`unregistered: ${key}`)
|
|
15
|
+
},
|
|
16
|
+
} as unknown as AwilixContainer
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function headersWith(expected: string): Headers {
|
|
20
|
+
const h = new Headers()
|
|
21
|
+
h.set(OPTIMISTIC_LOCK_HEADER_NAME, expected)
|
|
22
|
+
return h
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ENV_ON = 'all'
|
|
26
|
+
|
|
27
|
+
describe('enforceCommandOptimisticLockWithGuards', () => {
|
|
28
|
+
test('runs the OSS floor first: a version mismatch 409s before the seam is consulted', async () => {
|
|
29
|
+
const enforce = jest.fn().mockResolvedValue(undefined)
|
|
30
|
+
const container = containerWith({ enforce })
|
|
31
|
+
const input: EnforceCommandOptimisticLockInput = {
|
|
32
|
+
resourceKind: 'sales.order',
|
|
33
|
+
resourceId: 'order-1',
|
|
34
|
+
current: '2026-06-01T00:00:01.000Z',
|
|
35
|
+
request: headersWith('2026-06-01T00:00:00.000Z'),
|
|
36
|
+
envValue: ENV_ON,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
await expect(enforceCommandOptimisticLockWithGuards(container, input)).rejects.toMatchObject({
|
|
40
|
+
status: 409,
|
|
41
|
+
})
|
|
42
|
+
// Floor blocked it; the enterprise seam was never reached.
|
|
43
|
+
expect(enforce).not.toHaveBeenCalled()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('migrated path awaits the seam before "mutating" when the floor passes', async () => {
|
|
47
|
+
const order: string[] = []
|
|
48
|
+
const enforce = jest.fn().mockImplementation(async () => {
|
|
49
|
+
order.push('seam-start')
|
|
50
|
+
await Promise.resolve()
|
|
51
|
+
order.push('seam-end')
|
|
52
|
+
})
|
|
53
|
+
const container = containerWith({ enforce })
|
|
54
|
+
|
|
55
|
+
await enforceCommandOptimisticLockWithGuards(container, {
|
|
56
|
+
resourceKind: 'sales.order',
|
|
57
|
+
resourceId: 'order-1',
|
|
58
|
+
current: '2026-06-01T00:00:00.000Z',
|
|
59
|
+
request: headersWith('2026-06-01T00:00:00.000Z'),
|
|
60
|
+
envValue: ENV_ON,
|
|
61
|
+
})
|
|
62
|
+
order.push('mutate')
|
|
63
|
+
|
|
64
|
+
expect(enforce).toHaveBeenCalledTimes(1)
|
|
65
|
+
expect(order).toEqual(['seam-start', 'seam-end', 'mutate'])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('an enterprise-service 409 conflict propagates (blocks the write)', async () => {
|
|
69
|
+
const enforce = jest.fn().mockRejectedValue(
|
|
70
|
+
new CrudHttpError(409, { code: OPTIMISTIC_LOCK_CONFLICT_CODE, error: 'record_modified' }),
|
|
71
|
+
)
|
|
72
|
+
const container = containerWith({ enforce })
|
|
73
|
+
|
|
74
|
+
await expect(
|
|
75
|
+
enforceCommandOptimisticLockWithGuards(container, {
|
|
76
|
+
resourceKind: 'sales.order',
|
|
77
|
+
resourceId: 'order-1',
|
|
78
|
+
current: '2026-06-01T00:00:00.000Z',
|
|
79
|
+
request: headersWith('2026-06-01T00:00:00.000Z'),
|
|
80
|
+
envValue: ENV_ON,
|
|
81
|
+
}),
|
|
82
|
+
).rejects.toMatchObject({ status: 409 })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('an unregistered service falls back to OSS-only (no throw beyond the floor)', async () => {
|
|
86
|
+
const container = containerWith(null)
|
|
87
|
+
await expect(
|
|
88
|
+
enforceCommandOptimisticLockWithGuards(container, {
|
|
89
|
+
resourceKind: 'sales.order',
|
|
90
|
+
resourceId: 'order-1',
|
|
91
|
+
current: '2026-06-01T00:00:00.000Z',
|
|
92
|
+
request: headersWith('2026-06-01T00:00:00.000Z'),
|
|
93
|
+
envValue: ENV_ON,
|
|
94
|
+
}),
|
|
95
|
+
).resolves.toBeUndefined()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('a throwing (non-conflict) service degrades to OSS-only — error is swallowed', async () => {
|
|
99
|
+
const enforce = jest.fn().mockRejectedValue(new Error('record_locks down'))
|
|
100
|
+
const container = containerWith({ enforce })
|
|
101
|
+
|
|
102
|
+
await expect(
|
|
103
|
+
enforceCommandOptimisticLockWithGuards(container, {
|
|
104
|
+
resourceKind: 'sales.order',
|
|
105
|
+
resourceId: 'order-1',
|
|
106
|
+
current: '2026-06-01T00:00:00.000Z',
|
|
107
|
+
request: headersWith('2026-06-01T00:00:00.000Z'),
|
|
108
|
+
envValue: ENV_ON,
|
|
109
|
+
}),
|
|
110
|
+
).resolves.toBeUndefined()
|
|
111
|
+
expect(enforce).toHaveBeenCalledTimes(1)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('a non-409 CrudHttpError from the service is also swallowed (degrade to OSS-only)', async () => {
|
|
115
|
+
const enforce = jest.fn().mockRejectedValue(new CrudHttpError(500, { error: 'boom' }))
|
|
116
|
+
const container = containerWith({ enforce })
|
|
117
|
+
|
|
118
|
+
await expect(
|
|
119
|
+
enforceCommandOptimisticLockWithGuards(container, {
|
|
120
|
+
resourceKind: 'sales.order',
|
|
121
|
+
resourceId: 'order-1',
|
|
122
|
+
current: '2026-06-01T00:00:00.000Z',
|
|
123
|
+
request: headersWith('2026-06-01T00:00:00.000Z'),
|
|
124
|
+
envValue: ENV_ON,
|
|
125
|
+
}),
|
|
126
|
+
).resolves.toBeUndefined()
|
|
127
|
+
})
|
|
128
|
+
})
|
|
@@ -469,20 +469,31 @@ describe('createGenericOptimisticLockReader', () => {
|
|
|
469
469
|
expect(captures[0].options).toEqual({ fields: ['modifiedAt'] })
|
|
470
470
|
})
|
|
471
471
|
|
|
472
|
-
it('fails open (returns null)
|
|
472
|
+
it('fails open (returns null) AND logs loudly with the resourceKind when findOne throws', async () => {
|
|
473
473
|
const reader = createGenericOptimisticLockReader({ entity: FakeEntity })
|
|
474
474
|
const em = {
|
|
475
475
|
async findOne() {
|
|
476
|
-
throw new Error('column "
|
|
476
|
+
throw new Error('column "deleted_at" does not exist')
|
|
477
477
|
},
|
|
478
478
|
} as never
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
479
|
+
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
480
|
+
try {
|
|
481
|
+
const result = await reader(em, {
|
|
482
|
+
resourceKind: 'customers.tag',
|
|
483
|
+
resourceId: 'r',
|
|
484
|
+
tenantId: 't',
|
|
485
|
+
organizationId: 'o',
|
|
486
|
+
})
|
|
487
|
+
// Fail-open control flow preserved: a query error must never 500 the mutation.
|
|
488
|
+
expect(result).toBeNull()
|
|
489
|
+
// ...but a misconfig must be visible, naming the affected resourceKind.
|
|
490
|
+
expect(errorSpy).toHaveBeenCalledTimes(1)
|
|
491
|
+
const [message] = errorSpy.mock.calls[0]
|
|
492
|
+
expect(String(message)).toContain('customers.tag')
|
|
493
|
+
expect(String(message)).toContain('[optimistic-lock]')
|
|
494
|
+
} finally {
|
|
495
|
+
errorSpy.mockRestore()
|
|
496
|
+
}
|
|
486
497
|
})
|
|
487
498
|
|
|
488
499
|
it('returns null when the projected updatedAt is missing / null / non-Date', async () => {
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
* Spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md (§ command-level checks)
|
|
28
28
|
* .ai/specs/2026-05-28-optimistic-locking-coverage-completion.md (Phase 4)
|
|
29
29
|
*/
|
|
30
|
-
import {
|
|
30
|
+
import type { AwilixContainer } from 'awilix'
|
|
31
|
+
import { CrudHttpError, isCrudHttpError } from './errors'
|
|
31
32
|
import {
|
|
32
33
|
OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
33
34
|
OPTIMISTIC_LOCK_CONFLICT_ERROR,
|
|
@@ -303,3 +304,61 @@ export function createCommandOptimisticLockGuardService(
|
|
|
303
304
|
},
|
|
304
305
|
}
|
|
305
306
|
}
|
|
307
|
+
|
|
308
|
+
const COMMAND_OPTIMISTIC_LOCK_GUARD_SERVICE_KEY = 'commandOptimisticLockGuardService'
|
|
309
|
+
|
|
310
|
+
function resolveCommandOptimisticLockGuardService(
|
|
311
|
+
container: AwilixContainer,
|
|
312
|
+
): CommandOptimisticLockGuardService | null {
|
|
313
|
+
try {
|
|
314
|
+
const service = container.resolve<CommandOptimisticLockGuardService>(
|
|
315
|
+
COMMAND_OPTIMISTIC_LOCK_GUARD_SERVICE_KEY,
|
|
316
|
+
)
|
|
317
|
+
if (!service || typeof service.enforce !== 'function') return null
|
|
318
|
+
return service
|
|
319
|
+
} catch {
|
|
320
|
+
return null
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Async, DI-aware command-level optimistic-lock runner (Phase 0 / S1). This is
|
|
326
|
+
* the additive seam command handlers migrate to so BOTH the OSS floor and the
|
|
327
|
+
* optional enterprise `record_locks` enrichment protect Command-pattern writes
|
|
328
|
+
* through one call:
|
|
329
|
+
*
|
|
330
|
+
* 1. The OSS `updated_at` floor runs **unconditionally first** via the
|
|
331
|
+
* synchronous {@link enforceCommandOptimisticLock} — a stale write 409s
|
|
332
|
+
* here regardless of any record-lock token / widget state (H2). The legacy
|
|
333
|
+
* helper is reused verbatim, so the floor behaves identically to existing
|
|
334
|
+
* direct call sites.
|
|
335
|
+
* 2. If the floor passes, the optional `commandOptimisticLockGuardService` is
|
|
336
|
+
* resolved from the request container and awaited for enrichment.
|
|
337
|
+
*
|
|
338
|
+
* Fail-closed delegation (H3): a `record_lock_conflict`/409 from the enterprise
|
|
339
|
+
* service is rethrown (the write must be blocked), but ANY non-conflict error
|
|
340
|
+
* from a broken/unregistered enterprise guard is swallowed — the request
|
|
341
|
+
* degrades to OSS-only protection, never to "skip the guard".
|
|
342
|
+
*
|
|
343
|
+
* Strictly additive: with no enterprise service registered (OSS-only build) this
|
|
344
|
+
* is exactly the OSS compare. The synchronous {@link enforceCommandOptimisticLock}
|
|
345
|
+
* helper is left untouched for external callers.
|
|
346
|
+
*/
|
|
347
|
+
export async function enforceCommandOptimisticLockWithGuards(
|
|
348
|
+
container: AwilixContainer,
|
|
349
|
+
input: EnforceCommandOptimisticLockInput,
|
|
350
|
+
): Promise<void> {
|
|
351
|
+
// 1. OSS floor — unconditional, synchronous, identical to direct call sites.
|
|
352
|
+
enforceCommandOptimisticLock(input)
|
|
353
|
+
|
|
354
|
+
// 2. Optional enterprise enrichment, fail-closed.
|
|
355
|
+
const guard = resolveCommandOptimisticLockGuardService(container)
|
|
356
|
+
if (!guard) return
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
await guard.enforce(input)
|
|
360
|
+
} catch (error) {
|
|
361
|
+
// A real conflict must block the write; anything else degrades to OSS-only.
|
|
362
|
+
if (isCrudHttpError(error) && error.status === 409) throw error
|
|
363
|
+
}
|
|
364
|
+
}
|
|
@@ -151,6 +151,16 @@ export type GenericOptimisticLockReaderOptions = {
|
|
|
151
151
|
* treats as "entity already gone" and lets the CRUD path's own 404 fire.
|
|
152
152
|
* We MUST NOT throw out of the reader — that would 500 every mutation on
|
|
153
153
|
* the affected entity instead of opting it out of the optimistic check.
|
|
154
|
+
*
|
|
155
|
+
* Because a genuine not-found resolves to `null` *without* throwing (MikroORM's
|
|
156
|
+
* `findOne` returns `null`, it does not raise), the catch below only fires on a
|
|
157
|
+
* real query failure — most commonly a `softDeleteField`/`tenantField`/`orgField`
|
|
158
|
+
* misconfig that filters on a column the entity's table does not have. That class
|
|
159
|
+
* of bug silently disables locking for the whole entity, so the catch logs loudly
|
|
160
|
+
* with the `resourceKind` instead of swallowing the error. The control flow stays
|
|
161
|
+
* fail-open (returns `null`) to honor the no-500 contract; the durable defense is
|
|
162
|
+
* the static reader-resolution guard (`optimistic-lock-editable-entities.test.ts`)
|
|
163
|
+
* which fails the build when a route would land in this path.
|
|
154
164
|
*/
|
|
155
165
|
export function createGenericOptimisticLockReader(
|
|
156
166
|
opts: GenericOptimisticLockReaderOptions,
|
|
@@ -162,7 +172,7 @@ export function createGenericOptimisticLockReader(
|
|
|
162
172
|
const updatedAtField = opts.updatedAtField ?? 'updatedAt'
|
|
163
173
|
const extraFilter = opts.extraFilter ?? {}
|
|
164
174
|
|
|
165
|
-
return async (em, { resourceId, tenantId, organizationId }) => {
|
|
175
|
+
return async (em, { resourceKind, resourceId, tenantId, organizationId }) => {
|
|
166
176
|
const filter: Record<string, unknown> = { [idField]: resourceId }
|
|
167
177
|
if (tenantField) filter[tenantField] = tenantId
|
|
168
178
|
if (orgField && organizationId) filter[orgField] = organizationId
|
|
@@ -178,7 +188,18 @@ export function createGenericOptimisticLockReader(
|
|
|
178
188
|
if (value instanceof Date) return value.toISOString()
|
|
179
189
|
if (typeof value === 'string' && value.length > 0) return value
|
|
180
190
|
return null
|
|
181
|
-
} catch {
|
|
191
|
+
} catch (err) {
|
|
192
|
+
// A genuine not-found returns null above without throwing; reaching here
|
|
193
|
+
// means the query itself failed (most likely a softDeleteField/tenant/org
|
|
194
|
+
// misconfig filtering on a column the table lacks), which silently disables
|
|
195
|
+
// locking for `resourceKind`. Log loudly so the misconfig is visible.
|
|
196
|
+
// Control flow stays fail-open (return null) to honor the no-500 contract.
|
|
197
|
+
// eslint-disable-next-line no-console
|
|
198
|
+
console.error(
|
|
199
|
+
`[optimistic-lock] reader query failed for resourceKind="${resourceKind}" — optimistic locking is DISABLED for this entity until fixed. ` +
|
|
200
|
+
`Most likely a softDeleteField/tenantField/orgField filters on a column the table does not have.`,
|
|
201
|
+
err,
|
|
202
|
+
)
|
|
182
203
|
return null
|
|
183
204
|
}
|
|
184
205
|
}
|
package/src/lib/di/container.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { commandRegistry, CommandBus } from '@open-mercato/shared/lib/commands'
|
|
|
8
8
|
import { applyDiOverridesToContainer } from '@open-mercato/shared/modules/overrides'
|
|
9
9
|
import { createOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock'
|
|
10
10
|
import { getAllOptimisticLockReaders } from '@open-mercato/shared/lib/crud/optimistic-lock-store'
|
|
11
|
+
import { createCommandOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
11
12
|
|
|
12
13
|
type DynamicCradle = Record<string, any>
|
|
13
14
|
|
|
@@ -171,6 +172,16 @@ export async function createRequestContainer(): Promise<AppContainer> {
|
|
|
171
172
|
readers: getAllOptimisticLockReaders(),
|
|
172
173
|
}),
|
|
173
174
|
).scoped(),
|
|
175
|
+
// Default OSS command-level optimistic-lock guard, awaited by
|
|
176
|
+
// `enforceCommandOptimisticLockWithGuards` for Command-pattern writes.
|
|
177
|
+
// Header/explicit-token compare only (no `resolveExpected`), so it is
|
|
178
|
+
// behaviourally identical to calling `enforceCommandOptimisticLock`
|
|
179
|
+
// directly. The enterprise `record_locks` module overrides this DI key
|
|
180
|
+
// with a lock-backed `resolveExpected` via Awilix replace semantics.
|
|
181
|
+
// Spec: .ai/specs/enterprise/2026-06-09-record-locks-unified-coverage.md (Phase 0)
|
|
182
|
+
commandOptimisticLockGuardService: asFunction(() =>
|
|
183
|
+
createCommandOptimisticLockGuardService(),
|
|
184
|
+
).scoped(),
|
|
174
185
|
})
|
|
175
186
|
// Allow modules to override/extend
|
|
176
187
|
for (const reg of diRegistrars) {
|