@open-mercato/shared 0.6.5-develop.4534.1.b459babe6d → 0.6.5-develop.4559.1.839e136509
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/.turbo/turbo-build.log +1 -1
- package/dist/lib/commands/flush.js +23 -1
- package/dist/lib/commands/flush.js.map +2 -2
- package/dist/lib/crud/factory.js +16 -0
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/optimistic-lock-command.js +109 -0
- package/dist/lib/crud/optimistic-lock-command.js.map +7 -0
- package/dist/lib/crud/optimistic-lock-headers.js +15 -0
- package/dist/lib/crud/optimistic-lock-headers.js.map +7 -0
- package/dist/lib/crud/optimistic-lock-store.js +52 -0
- package/dist/lib/crud/optimistic-lock-store.js.map +7 -0
- package/dist/lib/crud/optimistic-lock.js +172 -0
- package/dist/lib/crud/optimistic-lock.js.map +7 -0
- package/dist/lib/di/container.js +18 -2
- 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/commands/__tests__/flush.test.ts +110 -9
- package/src/lib/commands/flush.ts +79 -2
- package/src/lib/crud/__tests__/crud-factory.test.ts +106 -0
- package/src/lib/crud/__tests__/optimistic-lock-command.test.ts +425 -0
- package/src/lib/crud/__tests__/optimistic-lock-store.test.ts +194 -0
- package/src/lib/crud/__tests__/optimistic-lock.test.ts +526 -0
- package/src/lib/crud/factory.ts +23 -0
- package/src/lib/crud/optimistic-lock-command.ts +305 -0
- package/src/lib/crud/optimistic-lock-headers.ts +30 -0
- package/src/lib/crud/optimistic-lock-store.ts +87 -0
- package/src/lib/crud/optimistic-lock.ts +379 -0
- 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
|
+
}
|