@open-mercato/shared 0.6.5-develop.4534.1.b459babe6d → 0.6.5-develop.4544.1.71c003c861

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/lib/commands/flush.js +23 -1
  3. package/dist/lib/commands/flush.js.map +2 -2
  4. package/dist/lib/crud/factory.js +16 -0
  5. package/dist/lib/crud/factory.js.map +2 -2
  6. package/dist/lib/crud/optimistic-lock-command.js +109 -0
  7. package/dist/lib/crud/optimistic-lock-command.js.map +7 -0
  8. package/dist/lib/crud/optimistic-lock-headers.js +15 -0
  9. package/dist/lib/crud/optimistic-lock-headers.js.map +7 -0
  10. package/dist/lib/crud/optimistic-lock-store.js +52 -0
  11. package/dist/lib/crud/optimistic-lock-store.js.map +7 -0
  12. package/dist/lib/crud/optimistic-lock.js +172 -0
  13. package/dist/lib/crud/optimistic-lock.js.map +7 -0
  14. package/dist/lib/di/container.js +18 -2
  15. package/dist/lib/di/container.js.map +2 -2
  16. package/dist/lib/version.js +1 -1
  17. package/dist/lib/version.js.map +1 -1
  18. package/package.json +2 -2
  19. package/src/lib/commands/__tests__/flush.test.ts +110 -9
  20. package/src/lib/commands/flush.ts +79 -2
  21. package/src/lib/crud/__tests__/crud-factory.test.ts +106 -0
  22. package/src/lib/crud/__tests__/optimistic-lock-command.test.ts +425 -0
  23. package/src/lib/crud/__tests__/optimistic-lock-store.test.ts +194 -0
  24. package/src/lib/crud/__tests__/optimistic-lock.test.ts +526 -0
  25. package/src/lib/crud/factory.ts +23 -0
  26. package/src/lib/crud/optimistic-lock-command.ts +305 -0
  27. package/src/lib/crud/optimistic-lock-headers.ts +30 -0
  28. package/src/lib/crud/optimistic-lock-store.ts +87 -0
  29. package/src/lib/crud/optimistic-lock.ts +379 -0
  30. package/src/lib/di/container.ts +17 -1
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Generalist command-level OSS optimistic-locking helper.
3
+ *
4
+ * The CRUD guard (`optimistic-lock.ts` + `makeCrudRoute`) only protects
5
+ * mutations that flow through the CRUD factory. Domain writes implemented via
6
+ * the Command pattern — sales document sub-resources (lines, adjustments,
7
+ * shipments, payments, returns), status transitions, quote→order conversion,
8
+ * etc. — run their own logic inside a command handler and never reach the CRUD
9
+ * guard for the **aggregate** they mutate. This helper lets any command enforce
10
+ * the same `updated_at` version check against an arbitrary target record
11
+ * (typically the aggregate root, e.g. the parent order/quote) and fail with the
12
+ * identical structured 409 the CRUD path returns.
13
+ *
14
+ * Contract (mirrors the CRUD guard so clients see one behavior):
15
+ * - The client sends the expected version via the
16
+ * `x-om-ext-optimistic-lock-expected-updated-at` header (or a command
17
+ * accepts it as a typed input field and passes it as `expected`).
18
+ * - The command loads the current record (it usually already does) and passes
19
+ * its `updated_at` as `current`.
20
+ * - On mismatch the helper throws `CrudHttpError(409, OptimisticLockConflictBody)`.
21
+ *
22
+ * Strictly additive: when no expected token is present (no header, no input
23
+ * field) the helper is a no-op, so existing API consumers that don't send the
24
+ * header keep working. Respects the same `OM_OPTIMISTIC_LOCK` env contract
25
+ * (default ON; `off` disables; allow-list scopes by `resourceKind`).
26
+ *
27
+ * Spec: .ai/specs/2026-05-25-oss-optimistic-locking.md (§ command-level checks)
28
+ * .ai/specs/2026-05-28-optimistic-locking-coverage-completion.md (Phase 4)
29
+ */
30
+ import { CrudHttpError } from './errors'
31
+ import {
32
+ OPTIMISTIC_LOCK_CONFLICT_CODE,
33
+ OPTIMISTIC_LOCK_CONFLICT_ERROR,
34
+ OPTIMISTIC_LOCK_ENV_VAR,
35
+ OPTIMISTIC_LOCK_HEADER_NAME,
36
+ type OptimisticLockConflictBody,
37
+ } from './optimistic-lock-headers'
38
+ import {
39
+ normalizeIsoToken,
40
+ parseOptimisticLockEnv,
41
+ type OptimisticLockConfig,
42
+ type OptimisticLockResolverInput,
43
+ type ResolveExpectedUpdatedAt,
44
+ } from './optimistic-lock'
45
+
46
+ function toIsoOrNull(value: string | Date | null | undefined): string | null {
47
+ if (value == null) return null
48
+ if (value instanceof Date) {
49
+ const ms = value.getTime()
50
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null
51
+ }
52
+ const trimmed = String(value).trim()
53
+ if (!trimmed) return null
54
+ return normalizeIsoToken(trimmed)
55
+ }
56
+
57
+ function resolveConfig(envValue: string | null | undefined): OptimisticLockConfig {
58
+ return parseOptimisticLockEnv(envValue !== undefined ? envValue : process.env[OPTIMISTIC_LOCK_ENV_VAR])
59
+ }
60
+
61
+ function isResourceLockEnabled(config: OptimisticLockConfig, resourceKind: string): boolean {
62
+ if (config.mode === 'off') return false
63
+ if (config.mode === 'all') return true
64
+ return config.entities.has(resourceKind.toLowerCase())
65
+ }
66
+
67
+ /**
68
+ * Extract the expected `updated_at` token from a request's headers (or a bare
69
+ * `Headers` object). Returns the trimmed header value, or `null` when absent /
70
+ * empty. Does NOT normalize — `assertOptimisticLock` normalizes both sides.
71
+ */
72
+ export function readOptimisticLockExpected(
73
+ source: Request | Headers | null | undefined,
74
+ ): string | null {
75
+ if (!source) return null
76
+ const headers = source instanceof Headers
77
+ ? source
78
+ : (source as Request).headers instanceof Headers
79
+ ? (source as Request).headers
80
+ : null
81
+ if (!headers) return null
82
+ const direct = headers.get(OPTIMISTIC_LOCK_HEADER_NAME)
83
+ if (typeof direct === 'string' && direct.trim().length > 0) return direct.trim()
84
+ return null
85
+ }
86
+
87
+ export function buildOptimisticLockConflictBody(
88
+ currentIso: string,
89
+ expectedIso: string,
90
+ ): OptimisticLockConflictBody {
91
+ return {
92
+ error: OPTIMISTIC_LOCK_CONFLICT_ERROR,
93
+ code: OPTIMISTIC_LOCK_CONFLICT_CODE,
94
+ currentUpdatedAt: currentIso,
95
+ expectedUpdatedAt: expectedIso,
96
+ }
97
+ }
98
+
99
+ export type AssertOptimisticLockInput = {
100
+ resourceKind: string
101
+ resourceId: string
102
+ /** Client-provided expected version (header value or typed input field). */
103
+ expected: string | Date | null | undefined
104
+ /** Current version loaded from the DB (typically the aggregate root's `updatedAt`). */
105
+ current: string | Date | null | undefined
106
+ /** Override `OM_OPTIMISTIC_LOCK` (mostly for tests). */
107
+ envValue?: string | null
108
+ }
109
+
110
+ /**
111
+ * Pure version assertion. Throws `CrudHttpError(409, OptimisticLockConflictBody)`
112
+ * when the expected and current versions disagree.
113
+ *
114
+ * No-op (returns silently) when:
115
+ * - the env disables the guard for this `resourceKind`,
116
+ * - `expected` is missing / unparseable (strictly additive — clients that
117
+ * don't send the token are never blocked),
118
+ * - `current` is missing / unparseable (let the command's own 404 fire).
119
+ */
120
+ export function assertOptimisticLock(input: AssertOptimisticLockInput): void {
121
+ const config = resolveConfig(input.envValue)
122
+ if (!isResourceLockEnabled(config, input.resourceKind)) return
123
+
124
+ const expectedIso = toIsoOrNull(input.expected)
125
+ if (expectedIso == null) return
126
+
127
+ const currentIso = toIsoOrNull(input.current)
128
+ if (currentIso == null) return
129
+
130
+ if (currentIso === expectedIso) return
131
+
132
+ throw new CrudHttpError(409, buildOptimisticLockConflictBody(currentIso, expectedIso))
133
+ }
134
+
135
+ export type EnforceCommandOptimisticLockInput = {
136
+ resourceKind: string
137
+ resourceId: string
138
+ /** Current version loaded from the DB (the aggregate root's `updatedAt`). */
139
+ current: string | Date | null | undefined
140
+ /**
141
+ * Explicit expected version — wins over the request header. Use when the
142
+ * command accepts the token as a typed input field instead of (or in addition
143
+ * to) the extension header.
144
+ */
145
+ expected?: string | Date | null
146
+ /** Request whose headers carry the expected token (e.g. `ctx.request`). */
147
+ request?: Request | Headers | null
148
+ /** Override `OM_OPTIMISTIC_LOCK` (mostly for tests). */
149
+ envValue?: string | null
150
+ }
151
+
152
+ /**
153
+ * Command-handler convenience: resolves the expected version from an explicit
154
+ * override or the request header, then delegates to `assertOptimisticLock`.
155
+ *
156
+ * ```ts
157
+ * enforceCommandOptimisticLock({
158
+ * resourceKind: 'sales.order',
159
+ * resourceId: order.id,
160
+ * current: order.updatedAt,
161
+ * request: ctx.request,
162
+ * })
163
+ * ```
164
+ */
165
+ export function enforceCommandOptimisticLock(input: EnforceCommandOptimisticLockInput): void {
166
+ const expected = input.expected !== undefined && input.expected !== null
167
+ ? input.expected
168
+ : readOptimisticLockExpected(input.request ?? null)
169
+ assertOptimisticLock({
170
+ resourceKind: input.resourceKind,
171
+ resourceId: input.resourceId,
172
+ expected,
173
+ current: input.current,
174
+ envValue: input.envValue,
175
+ })
176
+ }
177
+
178
+ export type EnforceRecordGoneIsConflictInput = {
179
+ resourceKind: string
180
+ resourceId: string
181
+ /** Explicit expected version — wins over the request header. */
182
+ expected?: string | Date | null
183
+ /** Request whose headers carry the expected token (e.g. `ctx.request`). */
184
+ request?: Request | Headers | null
185
+ /** Override `OM_OPTIMISTIC_LOCK` (mostly for tests). */
186
+ envValue?: string | null
187
+ }
188
+
189
+ /**
190
+ * Command-handler convenience for the *concurrent-delete* race: when a command
191
+ * cannot find its target record (it was deleted in another tab) AND the client
192
+ * opted into optimistic locking (sent the expected-version header / token),
193
+ * throw the SAME structured `CrudHttpError(409, OptimisticLockConflictBody)`
194
+ * the version-mismatch path returns — so a stale modal save surfaces the unified
195
+ * "Record changed" conflict bar instead of a bare, generic 404.
196
+ *
197
+ * Strictly additive and fail-open: a no-op (returns silently) when the env
198
+ * disables the guard for `resourceKind`, or when no expected token is present
199
+ * (plain API consumers that never sent the header keep their existing 404).
200
+ * The caller MUST still throw its own 404 afterwards for that no-token path:
201
+ *
202
+ * ```ts
203
+ * if (!interaction) {
204
+ * enforceRecordGoneIsConflict({ resourceKind: 'customers.interaction', resourceId: id, request: ctx.request })
205
+ * throw new CrudHttpError(404, { error: 'Interaction not found' })
206
+ * }
207
+ * ```
208
+ *
209
+ * The gone record has no current version, so `currentUpdatedAt` echoes the
210
+ * expected token (the body is used only for the conflict-bar copy + diagnostics;
211
+ * the client keys off `code`, not the timestamps).
212
+ */
213
+ export function enforceRecordGoneIsConflict(input: EnforceRecordGoneIsConflictInput): void {
214
+ const config = resolveConfig(input.envValue)
215
+ if (!isResourceLockEnabled(config, input.resourceKind)) return
216
+ const clientSupplied = input.expected !== undefined && input.expected !== null
217
+ ? input.expected
218
+ : readOptimisticLockExpected(input.request ?? null)
219
+ const expectedIso = toIsoOrNull(clientSupplied)
220
+ if (expectedIso == null) return
221
+ throw new CrudHttpError(409, buildOptimisticLockConflictBody(expectedIso, expectedIso))
222
+ }
223
+
224
+ /**
225
+ * DI-resolvable command-level optimistic-lock guard. This is the framework
226
+ * seam that lets BOTH layers protect Command-pattern writes through one
227
+ * contract:
228
+ *
229
+ * - **OSS** registers the default service (header/explicit token compare —
230
+ * identical to calling `enforceCommandOptimisticLock` directly).
231
+ * - **Enterprise** (`record_locks`) re-registers the same DI key with a
232
+ * `resolveExpected` that reads the held pessimistic lock's version, so a
233
+ * stale command write fails with the same structured 409 WITHOUT any
234
+ * command handler changing. Mirrors how enterprise already replaces the
235
+ * CRUD-path `crudMutationGuardService` (see `optimistic-lock.ts`).
236
+ *
237
+ * Command handlers depend only on this interface (resolved from the container),
238
+ * never on a concrete implementation — that is what makes the next-PR
239
+ * enterprise extension a pure DI swap.
240
+ */
241
+ export type CommandOptimisticLockGuardService = {
242
+ /**
243
+ * Enforce the version check for a command-level mutation against an
244
+ * aggregate/record. Async because an enterprise resolver may load the
245
+ * expected token from a lock record. No-op (resolves silently) when the env
246
+ * disables the guard for `resourceKind` or when no expected token is
247
+ * resolved — strictly additive, exactly like {@link enforceCommandOptimisticLock}.
248
+ * Throws `CrudHttpError(409, OptimisticLockConflictBody)` on mismatch.
249
+ */
250
+ enforce: (input: EnforceCommandOptimisticLockInput) => Promise<void>
251
+ }
252
+
253
+ export type CreateCommandOptimisticLockGuardServiceOptions = {
254
+ /**
255
+ * Override how the expected version is derived. Receives
256
+ * `{ expectedFromHeader, resourceKind, resourceId }` (where
257
+ * `expectedFromHeader` is the normalized client-supplied token — explicit
258
+ * input or request header) and returns the expected token (or `null` to
259
+ * skip). Defaults to "use the client-supplied token", which is the OSS
260
+ * behavior. The enterprise `record_locks` module plugs a lock-backed
261
+ * resolver here. Mirrors the CRUD guard's `resolveExpected`.
262
+ */
263
+ resolveExpected?: ResolveExpectedUpdatedAt
264
+ }
265
+
266
+ /**
267
+ * Build a {@link CommandOptimisticLockGuardService}. With no options it is
268
+ * behaviourally identical to {@link enforceCommandOptimisticLock} (header/
269
+ * explicit compare), so the OSS default is a thin wrapper. Pass
270
+ * `resolveExpected` to override token resolution (enterprise extension point).
271
+ */
272
+ export function createCommandOptimisticLockGuardService(
273
+ options: CreateCommandOptimisticLockGuardServiceOptions = {},
274
+ ): CommandOptimisticLockGuardService {
275
+ const resolveExpected: ResolveExpectedUpdatedAt | null = options.resolveExpected ?? null
276
+ return {
277
+ async enforce(input: EnforceCommandOptimisticLockInput): Promise<void> {
278
+ const config = resolveConfig(input.envValue)
279
+ if (!isResourceLockEnabled(config, input.resourceKind)) return
280
+
281
+ const clientSupplied = input.expected !== undefined && input.expected !== null
282
+ ? input.expected
283
+ : readOptimisticLockExpected(input.request ?? null)
284
+ const expectedFromHeader = toIsoOrNull(clientSupplied)
285
+
286
+ let expected: string | null = expectedFromHeader
287
+ if (resolveExpected) {
288
+ const resolverInput: OptimisticLockResolverInput = {
289
+ expectedFromHeader,
290
+ resourceKind: input.resourceKind,
291
+ resourceId: input.resourceId,
292
+ }
293
+ expected = await resolveExpected(resolverInput)
294
+ }
295
+
296
+ assertOptimisticLock({
297
+ resourceKind: input.resourceKind,
298
+ resourceId: input.resourceId,
299
+ expected,
300
+ current: input.current,
301
+ envValue: input.envValue,
302
+ })
303
+ },
304
+ }
305
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Wire constants for the OSS opt-in optimistic-locking guard.
3
+ *
4
+ * The header name follows the project's extension-header convention
5
+ * (`x-om-ext-<moduleId>-<key>`, see `umes/extension-headers.ts`). The
6
+ * module id used by env opt-in is `optimistic_lock` (snake_case), but
7
+ * the HTTP header itself uses dash-separated `optimistic-lock` because
8
+ * many HTTP intermediaries (nginx, some fetch implementations) strip
9
+ * underscored header names — see RFC 7230 §3.2.6.
10
+ *
11
+ * Spec: .ai/specs/2026-05-25-oss-optimistic-locking.md §3.2
12
+ */
13
+ export const OPTIMISTIC_LOCK_MODULE_ID = 'optimistic_lock'
14
+
15
+ export const OPTIMISTIC_LOCK_HEADER_NAME = 'x-om-ext-optimistic-lock-expected-updated-at'
16
+
17
+ export const OPTIMISTIC_LOCK_CONFLICT_CODE = 'optimistic_lock_conflict'
18
+
19
+ export const OPTIMISTIC_LOCK_CONFLICT_ERROR = 'record_modified'
20
+
21
+ export const OPTIMISTIC_LOCK_ENV_VAR = 'OM_OPTIMISTIC_LOCK'
22
+
23
+ export const OPTIMISTIC_LOCK_DEFAULT_PRIORITY = 50
24
+
25
+ export type OptimisticLockConflictBody = {
26
+ error: typeof OPTIMISTIC_LOCK_CONFLICT_ERROR
27
+ code: typeof OPTIMISTIC_LOCK_CONFLICT_CODE
28
+ currentUpdatedAt: string
29
+ expectedUpdatedAt: string
30
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Global registry of OSS optimistic-lock readers keyed by `resourceKind`.
3
+ *
4
+ * Multiple modules can contribute readers without conflicting on the
5
+ * single Awilix `crudMutationGuardService` slot: each module calls
6
+ * `registerOptimisticLockReaders({...})` from its `di.ts` at module-load
7
+ * time, and any one of them can register the
8
+ * `crudMutationGuardService` Awilix binding (Awilix replaces same-key
9
+ * registrations, so the *last loaded* wins — but every binding points
10
+ * to the same store-backed factory, so the resulting guard set is
11
+ * identical regardless of order).
12
+ *
13
+ * Mirrors the `mutation-guard-store.ts` HMR-safe globalThis pattern.
14
+ *
15
+ * Spec: .ai/specs/2026-05-25-oss-optimistic-locking.md
16
+ */
17
+ import type { OptimisticLockCurrentReader } from './optimistic-lock'
18
+
19
+ const GLOBAL_KEY = '__openMercatoOptimisticLockReaders__'
20
+
21
+ function readGlobal(): Record<string, OptimisticLockCurrentReader> {
22
+ try {
23
+ const value = (globalThis as Record<string, unknown>)[GLOBAL_KEY]
24
+ if (value && typeof value === 'object') {
25
+ return value as Record<string, OptimisticLockCurrentReader>
26
+ }
27
+ return {}
28
+ } catch {
29
+ return {}
30
+ }
31
+ }
32
+
33
+ function writeGlobal(value: Record<string, OptimisticLockCurrentReader>): void {
34
+ try {
35
+ ;(globalThis as Record<string, unknown>)[GLOBAL_KEY] = value
36
+ } catch {
37
+ // ignore global assignment failures in restricted runtimes
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Register optimistic-lock readers for one or more `resourceKind` values.
43
+ * Idempotent for same-key calls (later registration overrides earlier).
44
+ */
45
+ export function registerOptimisticLockReaders(
46
+ readers: Record<string, OptimisticLockCurrentReader>,
47
+ ): void {
48
+ const existing = readGlobal()
49
+ writeGlobal({ ...existing, ...readers })
50
+ }
51
+
52
+ /**
53
+ * Register optimistic-lock readers only for keys that have no reader yet.
54
+ * Use this for fallback / generic registrations (e.g. the auto-registration
55
+ * driven by `makeCrudRoute`) so module-level hand-wired readers — which
56
+ * register first via `di.ts` — always win.
57
+ *
58
+ * Returns the set of keys that were actually written, which makes the helper
59
+ * easy to assert on in tests and useful for diagnostics in callers.
60
+ */
61
+ export function registerOptimisticLockReaderIfAbsent(
62
+ readers: Record<string, OptimisticLockCurrentReader>,
63
+ ): string[] {
64
+ const existing = readGlobal()
65
+ const next: Record<string, OptimisticLockCurrentReader> = { ...existing }
66
+ const written: string[] = []
67
+ for (const [key, reader] of Object.entries(readers)) {
68
+ if (!(key in existing)) {
69
+ next[key] = reader
70
+ written.push(key)
71
+ }
72
+ }
73
+ if (written.length > 0) writeGlobal(next)
74
+ return written
75
+ }
76
+
77
+ export function getAllOptimisticLockReaders(): Record<string, OptimisticLockCurrentReader> {
78
+ return readGlobal()
79
+ }
80
+
81
+ export function clearOptimisticLockReadersForTests(): void {
82
+ try {
83
+ delete (globalThis as Record<string, unknown>)[GLOBAL_KEY]
84
+ } catch {
85
+ // ignore
86
+ }
87
+ }