@open-mercato/shared 0.6.5-canary.4425.1.75a266b232 → 0.6.5-develop.4393.1.de282b5dfd

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.
@@ -1,172 +0,0 @@
1
- import {
2
- OPTIMISTIC_LOCK_CONFLICT_CODE,
3
- OPTIMISTIC_LOCK_CONFLICT_ERROR,
4
- OPTIMISTIC_LOCK_ENV_VAR,
5
- OPTIMISTIC_LOCK_HEADER_NAME
6
- } from "./optimistic-lock-headers.js";
7
- import { getAllOptimisticLockReaders } from "./optimistic-lock-store.js";
8
- const OPTIMISTIC_LOCK_OFF_TOKENS = /* @__PURE__ */ new Set([
9
- "off",
10
- "false",
11
- "0",
12
- "no",
13
- "disabled",
14
- "none"
15
- ]);
16
- function parseOptimisticLockEnv(raw) {
17
- if (raw == null) return { mode: "all" };
18
- const trimmed = String(raw).trim();
19
- if (trimmed === "") return { mode: "all" };
20
- const tokens = trimmed.split(",").map((token) => token.trim().toLowerCase()).filter((token) => token.length > 0);
21
- if (tokens.length === 0) return { mode: "all" };
22
- if (tokens.some((token) => OPTIMISTIC_LOCK_OFF_TOKENS.has(token))) return { mode: "off" };
23
- if (tokens.includes("all")) return { mode: "all" };
24
- return { mode: "allowlist", entities: new Set(tokens) };
25
- }
26
- const defaultResolveExpectedUpdatedAt = ({ expectedFromHeader }) => expectedFromHeader;
27
- function createGenericOptimisticLockReader(opts) {
28
- const idField = opts.idField ?? "id";
29
- const tenantField = opts.tenantField === null ? null : opts.tenantField ?? "tenantId";
30
- const orgField = opts.orgField === null ? null : opts.orgField ?? "organizationId";
31
- const softDeleteField = opts.softDeleteField === null ? null : opts.softDeleteField ?? "deletedAt";
32
- const updatedAtField = opts.updatedAtField ?? "updatedAt";
33
- const extraFilter = opts.extraFilter ?? {};
34
- return async (em, { resourceId, tenantId, organizationId }) => {
35
- const filter = { [idField]: resourceId };
36
- if (tenantField) filter[tenantField] = tenantId;
37
- if (orgField && organizationId) filter[orgField] = organizationId;
38
- if (softDeleteField) filter[softDeleteField] = null;
39
- for (const [key, value] of Object.entries(extraFilter)) filter[key] = value;
40
- try {
41
- const row = await em.findOne(opts.entity, filter, {
42
- fields: [updatedAtField]
43
- });
44
- if (!row || typeof row !== "object") return null;
45
- const value = row[updatedAtField];
46
- if (value instanceof Date) return value.toISOString();
47
- if (typeof value === "string" && value.length > 0) return value;
48
- return null;
49
- } catch {
50
- return null;
51
- }
52
- };
53
- }
54
- function readHeader(headers, name) {
55
- const direct = headers.get(name);
56
- if (typeof direct === "string" && direct.trim().length > 0) return direct.trim();
57
- return null;
58
- }
59
- function normalizeIsoToken(raw) {
60
- const ms = Date.parse(raw);
61
- if (!Number.isFinite(ms)) return null;
62
- return new Date(ms).toISOString();
63
- }
64
- function buildConflictBody(currentIso, expectedIso) {
65
- return {
66
- error: OPTIMISTIC_LOCK_CONFLICT_ERROR,
67
- code: OPTIMISTIC_LOCK_CONFLICT_CODE,
68
- currentUpdatedAt: currentIso,
69
- expectedUpdatedAt: expectedIso
70
- };
71
- }
72
- function createOptimisticLockGuardService(opts) {
73
- const envValue = opts.envValue !== void 0 ? opts.envValue : process.env[OPTIMISTIC_LOCK_ENV_VAR];
74
- const config = parseOptimisticLockEnv(envValue);
75
- const resolveExpected = opts.resolveExpected ?? defaultResolveExpectedUpdatedAt;
76
- const debugEnabled = process.env.OM_OPTIMISTIC_LOCK_DEBUG === "1";
77
- function isEntityEnabled(resourceKind) {
78
- if (config.mode === "off") return false;
79
- if (config.mode === "all") return true;
80
- return config.entities.has(resourceKind.toLowerCase());
81
- }
82
- async function validateMutation(input) {
83
- if (config.mode === "off") {
84
- return { ok: true, shouldRunAfterSuccess: false };
85
- }
86
- if (input.operation !== "update" && input.operation !== "delete") {
87
- return { ok: true, shouldRunAfterSuccess: false };
88
- }
89
- if (!isEntityEnabled(input.resourceKind)) {
90
- return { ok: true, shouldRunAfterSuccess: false };
91
- }
92
- const readers = opts.readers ?? getAllOptimisticLockReaders();
93
- const reader = readers[input.resourceKind];
94
- if (!reader) {
95
- return { ok: true, shouldRunAfterSuccess: false };
96
- }
97
- const expectedRaw = readHeader(input.requestHeaders, OPTIMISTIC_LOCK_HEADER_NAME);
98
- const resolvedExpected = await resolveExpected({
99
- expectedFromHeader: expectedRaw,
100
- resourceKind: input.resourceKind,
101
- resourceId: input.resourceId
102
- });
103
- if (resolvedExpected == null) {
104
- return { ok: true, shouldRunAfterSuccess: false };
105
- }
106
- const expectedIso = normalizeIsoToken(resolvedExpected);
107
- if (expectedIso == null) {
108
- return { ok: true, shouldRunAfterSuccess: false };
109
- }
110
- const em = opts.getEm();
111
- const currentRaw = await reader(em, {
112
- resourceKind: input.resourceKind,
113
- resourceId: input.resourceId,
114
- tenantId: input.tenantId,
115
- organizationId: input.organizationId ?? null
116
- });
117
- if (currentRaw == null) {
118
- return { ok: true, shouldRunAfterSuccess: false };
119
- }
120
- const currentIso = normalizeIsoToken(currentRaw);
121
- if (currentIso == null) {
122
- return { ok: true, shouldRunAfterSuccess: false };
123
- }
124
- if (currentIso === expectedIso) {
125
- if (debugEnabled) {
126
- console.log("[optimistic-lock] match", {
127
- resourceKind: input.resourceKind,
128
- resourceId: input.resourceId,
129
- operation: input.operation,
130
- currentIso,
131
- expectedIso
132
- });
133
- }
134
- return { ok: true, shouldRunAfterSuccess: false };
135
- }
136
- if (debugEnabled) {
137
- console.log("[optimistic-lock] CONFLICT", {
138
- resourceKind: input.resourceKind,
139
- resourceId: input.resourceId,
140
- operation: input.operation,
141
- tenantId: input.tenantId,
142
- organizationId: input.organizationId ?? null,
143
- expectedRaw: resolvedExpected,
144
- expectedIso,
145
- currentRaw,
146
- currentIso
147
- });
148
- }
149
- return {
150
- ok: false,
151
- status: 409,
152
- body: buildConflictBody(currentIso, expectedIso)
153
- };
154
- }
155
- async function afterMutationSuccess(_input) {
156
- }
157
- function getConfig() {
158
- return config;
159
- }
160
- return {
161
- validateMutation,
162
- afterMutationSuccess,
163
- getConfig
164
- };
165
- }
166
- export {
167
- createGenericOptimisticLockReader,
168
- createOptimisticLockGuardService,
169
- normalizeIsoToken,
170
- parseOptimisticLockEnv
171
- };
172
- //# sourceMappingURL=optimistic-lock.js.map
@@ -1,7 +0,0 @@
1
- {
2
- "version": 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/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": "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",
6
- "names": []
7
- }
@@ -1,425 +0,0 @@
1
- import {
2
- assertOptimisticLock,
3
- buildOptimisticLockConflictBody,
4
- createCommandOptimisticLockGuardService,
5
- enforceCommandOptimisticLock,
6
- enforceRecordGoneIsConflict,
7
- readOptimisticLockExpected,
8
- } from '../optimistic-lock-command'
9
- import { CrudHttpError, isCrudHttpError } from '../errors'
10
- import {
11
- OPTIMISTIC_LOCK_CONFLICT_CODE,
12
- OPTIMISTIC_LOCK_CONFLICT_ERROR,
13
- OPTIMISTIC_LOCK_HEADER_NAME,
14
- } from '../optimistic-lock-headers'
15
-
16
- const A = '2026-05-25T08:42:18.123Z'
17
- const B = '2026-05-25T08:42:20.999Z'
18
-
19
- function headersWith(token: string | null): Headers {
20
- const h = new Headers()
21
- if (token != null) h.set(OPTIMISTIC_LOCK_HEADER_NAME, token)
22
- return h
23
- }
24
-
25
- describe('readOptimisticLockExpected', () => {
26
- it('reads + trims the header from a Headers object', () => {
27
- expect(readOptimisticLockExpected(headersWith(` ${A} `))).toBe(A)
28
- })
29
-
30
- it('reads the header from a Request', () => {
31
- const req = new Request('https://example.test/api/sales/order-lines', {
32
- method: 'PUT',
33
- headers: headersWith(A),
34
- })
35
- expect(readOptimisticLockExpected(req)).toBe(A)
36
- })
37
-
38
- it('returns null when the header is absent / empty / source missing', () => {
39
- expect(readOptimisticLockExpected(headersWith(null))).toBeNull()
40
- expect(readOptimisticLockExpected(headersWith(' '))).toBeNull()
41
- expect(readOptimisticLockExpected(null)).toBeNull()
42
- expect(readOptimisticLockExpected(undefined)).toBeNull()
43
- })
44
- })
45
-
46
- describe('assertOptimisticLock', () => {
47
- it('throws 409 with the structured body on a version mismatch', () => {
48
- let caught: unknown
49
- try {
50
- assertOptimisticLock({
51
- resourceKind: 'sales.order',
52
- resourceId: 'order-1',
53
- expected: A,
54
- current: B,
55
- envValue: 'all',
56
- })
57
- } catch (err) {
58
- caught = err
59
- }
60
- expect(isCrudHttpError(caught)).toBe(true)
61
- const httpError = caught as CrudHttpError
62
- expect(httpError.status).toBe(409)
63
- expect(httpError.body).toEqual({
64
- error: OPTIMISTIC_LOCK_CONFLICT_ERROR,
65
- code: OPTIMISTIC_LOCK_CONFLICT_CODE,
66
- currentUpdatedAt: B,
67
- expectedUpdatedAt: A,
68
- })
69
- })
70
-
71
- it('passes when expected matches current (normalized comparison across Date + string)', () => {
72
- expect(() =>
73
- assertOptimisticLock({
74
- resourceKind: 'sales.order',
75
- resourceId: 'order-1',
76
- expected: A,
77
- current: new Date(A),
78
- envValue: 'all',
79
- }),
80
- ).not.toThrow()
81
- })
82
-
83
- it('is a no-op when no expected token is supplied (strictly additive)', () => {
84
- expect(() =>
85
- assertOptimisticLock({
86
- resourceKind: 'sales.order',
87
- resourceId: 'order-1',
88
- expected: null,
89
- current: B,
90
- envValue: 'all',
91
- }),
92
- ).not.toThrow()
93
- expect(() =>
94
- assertOptimisticLock({
95
- resourceKind: 'sales.order',
96
- resourceId: 'order-1',
97
- expected: ' ',
98
- current: B,
99
- envValue: 'all',
100
- }),
101
- ).not.toThrow()
102
- })
103
-
104
- it('is a no-op when the current version is missing (let the command 404)', () => {
105
- expect(() =>
106
- assertOptimisticLock({
107
- resourceKind: 'sales.order',
108
- resourceId: 'order-1',
109
- expected: A,
110
- current: null,
111
- envValue: 'all',
112
- }),
113
- ).not.toThrow()
114
- })
115
-
116
- it('is a no-op when expected is an unparseable token', () => {
117
- expect(() =>
118
- assertOptimisticLock({
119
- resourceKind: 'sales.order',
120
- resourceId: 'order-1',
121
- expected: 'not-a-date',
122
- current: B,
123
- envValue: 'all',
124
- }),
125
- ).not.toThrow()
126
- })
127
-
128
- it('respects OM_OPTIMISTIC_LOCK=off (no 409 even on mismatch)', () => {
129
- expect(() =>
130
- assertOptimisticLock({
131
- resourceKind: 'sales.order',
132
- resourceId: 'order-1',
133
- expected: A,
134
- current: B,
135
- envValue: 'off',
136
- }),
137
- ).not.toThrow()
138
- })
139
-
140
- it('respects an allow-list that excludes the resourceKind', () => {
141
- expect(() =>
142
- assertOptimisticLock({
143
- resourceKind: 'sales.order',
144
- resourceId: 'order-1',
145
- expected: A,
146
- current: B,
147
- envValue: 'customers.company',
148
- }),
149
- ).not.toThrow()
150
- })
151
-
152
- it('enforces the check for a resourceKind present in the allow-list', () => {
153
- expect(() =>
154
- assertOptimisticLock({
155
- resourceKind: 'sales.order',
156
- resourceId: 'order-1',
157
- expected: A,
158
- current: B,
159
- envValue: 'sales.order',
160
- }),
161
- ).toThrow(CrudHttpError)
162
- })
163
-
164
- it('is ON by default (env unset) — mismatch 409s', () => {
165
- const prev = process.env.OM_OPTIMISTIC_LOCK
166
- delete process.env.OM_OPTIMISTIC_LOCK
167
- try {
168
- expect(() =>
169
- assertOptimisticLock({
170
- resourceKind: 'sales.order',
171
- resourceId: 'order-1',
172
- expected: A,
173
- current: B,
174
- }),
175
- ).toThrow(CrudHttpError)
176
- } finally {
177
- if (prev !== undefined) process.env.OM_OPTIMISTIC_LOCK = prev
178
- }
179
- })
180
- })
181
-
182
- describe('enforceCommandOptimisticLock', () => {
183
- it('reads the expected token from the request header and 409s on mismatch', () => {
184
- const req = new Request('https://example.test/api/sales/order-lines', {
185
- method: 'PUT',
186
- headers: headersWith(A),
187
- })
188
- expect(() =>
189
- enforceCommandOptimisticLock({
190
- resourceKind: 'sales.order',
191
- resourceId: 'order-1',
192
- current: B,
193
- request: req,
194
- envValue: 'all',
195
- }),
196
- ).toThrow(CrudHttpError)
197
- })
198
-
199
- it('prefers the explicit expected override over the header', () => {
200
- const req = new Request('https://example.test/api/sales/order-lines', {
201
- method: 'PUT',
202
- headers: headersWith(B), // header matches current → would pass if used
203
- })
204
- expect(() =>
205
- enforceCommandOptimisticLock({
206
- resourceKind: 'sales.order',
207
- resourceId: 'order-1',
208
- current: B,
209
- expected: A, // stale override → must 409
210
- request: req,
211
- envValue: 'all',
212
- }),
213
- ).toThrow(CrudHttpError)
214
- })
215
-
216
- it('is a no-op when neither override nor header is present', () => {
217
- expect(() =>
218
- enforceCommandOptimisticLock({
219
- resourceKind: 'sales.order',
220
- resourceId: 'order-1',
221
- current: B,
222
- request: headersWith(null),
223
- envValue: 'all',
224
- }),
225
- ).not.toThrow()
226
- })
227
-
228
- it('passes when the header matches the current version', () => {
229
- expect(() =>
230
- enforceCommandOptimisticLock({
231
- resourceKind: 'sales.order',
232
- resourceId: 'order-1',
233
- current: new Date(A),
234
- request: headersWith(A),
235
- envValue: 'all',
236
- }),
237
- ).not.toThrow()
238
- })
239
- })
240
-
241
- describe('enforceRecordGoneIsConflict', () => {
242
- it('409s when the client opted in (header present) but the record is gone', () => {
243
- let caught: unknown
244
- try {
245
- enforceRecordGoneIsConflict({
246
- resourceKind: 'customers.interaction',
247
- resourceId: 'int-1',
248
- request: headersWith(A),
249
- envValue: 'all',
250
- })
251
- } catch (err) {
252
- caught = err
253
- }
254
- expect(isCrudHttpError(caught)).toBe(true)
255
- const httpError = caught as CrudHttpError
256
- expect(httpError.status).toBe(409)
257
- // No current version exists for a deleted record, so it echoes the expected token.
258
- expect(httpError.body).toEqual({
259
- error: OPTIMISTIC_LOCK_CONFLICT_ERROR,
260
- code: OPTIMISTIC_LOCK_CONFLICT_CODE,
261
- currentUpdatedAt: A,
262
- expectedUpdatedAt: A,
263
- })
264
- })
265
-
266
- it('is a no-op when the client did not opt in (no header → caller 404 fires)', () => {
267
- expect(() =>
268
- enforceRecordGoneIsConflict({
269
- resourceKind: 'customers.interaction',
270
- resourceId: 'int-1',
271
- request: headersWith(null),
272
- envValue: 'all',
273
- }),
274
- ).not.toThrow()
275
- })
276
-
277
- it('prefers an explicit expected override over the header', () => {
278
- expect(() =>
279
- enforceRecordGoneIsConflict({
280
- resourceKind: 'customers.interaction',
281
- resourceId: 'int-1',
282
- expected: A,
283
- request: headersWith(null),
284
- envValue: 'all',
285
- }),
286
- ).toThrow(CrudHttpError)
287
- })
288
-
289
- it('is a no-op when the env disables the guard for the resource', () => {
290
- expect(() =>
291
- enforceRecordGoneIsConflict({
292
- resourceKind: 'customers.interaction',
293
- resourceId: 'int-1',
294
- request: headersWith(A),
295
- envValue: 'off',
296
- }),
297
- ).not.toThrow()
298
- })
299
-
300
- it('is a no-op when the expected token is unparseable', () => {
301
- expect(() =>
302
- enforceRecordGoneIsConflict({
303
- resourceKind: 'customers.interaction',
304
- resourceId: 'int-1',
305
- request: headersWith('not-a-date'),
306
- envValue: 'all',
307
- }),
308
- ).not.toThrow()
309
- })
310
- })
311
-
312
- describe('buildOptimisticLockConflictBody', () => {
313
- it('shapes the structured conflict body', () => {
314
- expect(buildOptimisticLockConflictBody(B, A)).toEqual({
315
- error: OPTIMISTIC_LOCK_CONFLICT_ERROR,
316
- code: OPTIMISTIC_LOCK_CONFLICT_CODE,
317
- currentUpdatedAt: B,
318
- expectedUpdatedAt: A,
319
- })
320
- })
321
- })
322
-
323
- describe('createCommandOptimisticLockGuardService', () => {
324
- it('default service mirrors enforceCommandOptimisticLock (header compare → 409 on mismatch)', async () => {
325
- const guard = createCommandOptimisticLockGuardService()
326
- let caught: unknown
327
- try {
328
- await guard.enforce({
329
- resourceKind: 'sales.order',
330
- resourceId: 'order-1',
331
- current: B,
332
- request: headersWith(A),
333
- envValue: 'all',
334
- })
335
- } catch (err) {
336
- caught = err
337
- }
338
- expect(isCrudHttpError(caught)).toBe(true)
339
- expect((caught as CrudHttpError).status).toBe(409)
340
- })
341
-
342
- it('default service is a no-op when the client sends no expected token (strictly additive)', async () => {
343
- const guard = createCommandOptimisticLockGuardService()
344
- await expect(
345
- guard.enforce({
346
- resourceKind: 'sales.order',
347
- resourceId: 'order-1',
348
- current: B,
349
- request: headersWith(null),
350
- envValue: 'all',
351
- }),
352
- ).resolves.toBeUndefined()
353
- })
354
-
355
- it('default service is a no-op when the env disables the guard for the resource', async () => {
356
- const guard = createCommandOptimisticLockGuardService()
357
- await expect(
358
- guard.enforce({
359
- resourceKind: 'sales.order',
360
- resourceId: 'order-1',
361
- current: B,
362
- request: headersWith(A),
363
- envValue: 'off',
364
- }),
365
- ).resolves.toBeUndefined()
366
- })
367
-
368
- it('resolveExpected overrides the header-derived token (enterprise extension point)', async () => {
369
- const seen: Array<{ expectedFromHeader: string | null; resourceKind: string; resourceId: string }> = []
370
- // Resolver supplies a stale token even though the client header matches `current`,
371
- // proving enterprise can drive the expected version from a lock record.
372
- const guard = createCommandOptimisticLockGuardService({
373
- resolveExpected: (input) => {
374
- seen.push(input)
375
- return A
376
- },
377
- })
378
- let caught: unknown
379
- try {
380
- await guard.enforce({
381
- resourceKind: 'sales.order',
382
- resourceId: 'order-9',
383
- current: B,
384
- request: headersWith(B),
385
- envValue: 'all',
386
- })
387
- } catch (err) {
388
- caught = err
389
- }
390
- expect(isCrudHttpError(caught)).toBe(true)
391
- expect((caught as CrudHttpError).status).toBe(409)
392
- expect(seen).toHaveLength(1)
393
- expect(seen[0]).toEqual({ expectedFromHeader: B, resourceKind: 'sales.order', resourceId: 'order-9' })
394
- })
395
-
396
- it('awaits an async resolveExpected and passes when it returns the current token', async () => {
397
- const guard = createCommandOptimisticLockGuardService({
398
- resolveExpected: async () => B,
399
- })
400
- await expect(
401
- guard.enforce({
402
- resourceKind: 'sales.order',
403
- resourceId: 'order-9',
404
- current: B,
405
- request: headersWith(A),
406
- envValue: 'all',
407
- }),
408
- ).resolves.toBeUndefined()
409
- })
410
-
411
- it('resolveExpected returning null skips the check (no expected → additive no-op)', async () => {
412
- const guard = createCommandOptimisticLockGuardService({
413
- resolveExpected: () => null,
414
- })
415
- await expect(
416
- guard.enforce({
417
- resourceKind: 'sales.order',
418
- resourceId: 'order-9',
419
- current: B,
420
- request: headersWith(A),
421
- envValue: 'all',
422
- }),
423
- ).resolves.toBeUndefined()
424
- })
425
- })