@open-mercato/shared 0.6.4-develop.4210.1.d412061cfe → 0.6.4-develop.4217.1.c9aa050183
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.
|
@@ -6,21 +6,26 @@ async function withAtomicFlush(em, phases, options) {
|
|
|
6
6
|
}
|
|
7
7
|
await em.flush();
|
|
8
8
|
};
|
|
9
|
-
if (options?.transaction) {
|
|
10
|
-
await
|
|
9
|
+
if (!options?.transaction) {
|
|
10
|
+
await runPhasesAndFlush();
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const isInTransaction = em.isInTransaction;
|
|
14
|
+
if (typeof isInTransaction === "function" && isInTransaction.call(em)) {
|
|
15
|
+
await runPhasesAndFlush();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
await em.begin(options.isolationLevel ? { isolationLevel: options.isolationLevel } : void 0);
|
|
19
|
+
try {
|
|
20
|
+
await runPhasesAndFlush();
|
|
21
|
+
await em.commit();
|
|
22
|
+
} catch (err) {
|
|
11
23
|
try {
|
|
12
|
-
await
|
|
13
|
-
|
|
14
|
-
} catch (err) {
|
|
15
|
-
try {
|
|
16
|
-
await em.rollback();
|
|
17
|
-
} catch {
|
|
18
|
-
}
|
|
19
|
-
throw err;
|
|
24
|
+
await em.rollback();
|
|
25
|
+
} catch {
|
|
20
26
|
}
|
|
21
|
-
|
|
27
|
+
throw err;
|
|
22
28
|
}
|
|
23
|
-
await runPhasesAndFlush();
|
|
24
29
|
}
|
|
25
30
|
export {
|
|
26
31
|
withAtomicFlush
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/commands/flush.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\n\n/**\n * Wraps multiple mutation phases in a single atomic flush.\n *\n * Prevents partial commits when a command mutates entities across\n * multiple phases (e.g., scalar mutations + relation syncs).\n * Each phase function runs sequentially; a single `em.flush()`\n * commits all changes at the end on the same `EntityManager` the\n * phases mutate, so closures over `em` stay valid.\n *\n * When `options.transaction` is true, the whole sequence runs\n * inside a database transaction (`em.begin()`
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { IsolationLevel } from '@mikro-orm/core'\n\n/**\n * Options controlling how {@link withAtomicFlush} executes its phases.\n */\nexport type AtomicFlushOptions = {\n /**\n * When true, the whole sequence runs inside a database transaction for\n * all-or-nothing semantics. Default: false (a single `em.flush()` commits\n * all phases at the end \u2014 no transaction).\n */\n transaction?: boolean\n /**\n * Optional transaction isolation level, forwarded to `em.begin()`. Only\n * honoured when this call opens a new top-level transaction (i.e.\n * `transaction: true` and the EntityManager is not already inside a\n * transaction). Ignored when joining an ambient transaction.\n */\n isolationLevel?: IsolationLevel\n /**\n * Optional label for diagnostics. Currently informational only.\n */\n label?: string\n}\n\n/**\n * Wraps multiple mutation phases in a single atomic flush.\n *\n * Prevents partial commits when a command mutates entities across\n * multiple phases (e.g., scalar mutations + relation syncs).\n * Each phase function runs sequentially; a single `em.flush()`\n * commits all changes at the end on the same `EntityManager` the\n * phases mutate, so closures over `em` stay valid.\n *\n * When `options.transaction` is true, the whole sequence runs\n * inside a database transaction for all-or-nothing semantics.\n *\n * ## Re-entrancy / composability\n *\n * `withAtomicFlush({ transaction: true })` is safe to nest. If the\n * supplied `EntityManager` is **already inside a transaction**, this\n * call does NOT open a second one (raw `em.begin()` would clobber the\n * active `#transactionContext` and orphan the outer transaction \u2014 unlike\n * `em.transactional()`, MikroORM's `em.begin()` does not check\n * `isInTransaction()`). Instead it joins the ambient transaction: the\n * phases run and flush within it, and the outermost caller owns the final\n * `commit()` / `rollback()`. A phase error therefore rolls back the entire\n * enclosing transaction (all-or-nothing across the whole nest).\n *\n * This mirrors the contract every command relies on: each command forks\n * the request `EntityManager` first, so the common case opens a fresh\n * top-level transaction; nesting only happens when one transactional unit\n * is composed inside another on the same `em`.\n *\n * When `phases` is empty the call is a true no-op \u2014 no flush,\n * no transaction. Callers that need an explicit commit should\n * pass at least one phase.\n *\n * Keep side-effect emissions (`emitCrudSideEffects` etc.) OUTSIDE\n * the `withAtomicFlush` block \u2014 they should only fire after commit.\n */\nexport async function withAtomicFlush(\n em: EntityManager,\n phases: Array<() => void | Promise<void>>,\n options?: AtomicFlushOptions,\n): Promise<void> {\n if (phases.length === 0) return\n\n const runPhasesAndFlush = async () => {\n for (const phase of phases) {\n await phase()\n }\n await em.flush()\n }\n\n if (!options?.transaction) {\n await runPhasesAndFlush()\n return\n }\n\n // Re-entrancy guard: never open a nested transaction with raw begin/commit.\n // If a transaction is already active on this EntityManager, join it \u2014 the\n // outermost caller owns commit/rollback. A phase error propagates and rolls\n // back the whole enclosing transaction.\n //\n // Guard the probe: real MikroORM EntityManagers always implement\n // `isInTransaction()`, but partial / mock EMs may not. A missing method is\n // treated as \"not in a transaction\", so this call opens its own top-level\n // transaction via the begin/commit path below (which those EMs do support).\n const isInTransaction = (em as { isInTransaction?: () => boolean }).isInTransaction\n if (typeof isInTransaction === 'function' && isInTransaction.call(em)) {\n await runPhasesAndFlush()\n return\n }\n\n await em.begin(options.isolationLevel ? { isolationLevel: options.isolationLevel } : undefined)\n try {\n await runPhasesAndFlush()\n await em.commit()\n } catch (err) {\n try {\n await em.rollback()\n } catch {\n // rollback failure should not mask the original error; intentionally swallowed\n }\n throw err\n }\n}\n"],
|
|
5
|
+
"mappings": "AA8DA,eAAsB,gBACpB,IACA,QACA,SACe;AACf,MAAI,OAAO,WAAW,EAAG;AAEzB,QAAM,oBAAoB,YAAY;AACpC,eAAW,SAAS,QAAQ;AAC1B,YAAM,MAAM;AAAA,IACd;AACA,UAAM,GAAG,MAAM;AAAA,EACjB;AAEA,MAAI,CAAC,SAAS,aAAa;AACzB,UAAM,kBAAkB;AACxB;AAAA,EACF;AAWA,QAAM,kBAAmB,GAA2C;AACpE,MAAI,OAAO,oBAAoB,cAAc,gBAAgB,KAAK,EAAE,GAAG;AACrE,UAAM,kBAAkB;AACxB;AAAA,EACF;AAEA,QAAM,GAAG,MAAM,QAAQ,iBAAiB,EAAE,gBAAgB,QAAQ,eAAe,IAAI,MAAS;AAC9F,MAAI;AACF,UAAM,kBAAkB;AACxB,UAAM,GAAG,OAAO;AAAA,EAClB,SAAS,KAAK;AACZ,QAAI;AACF,YAAM,GAAG,SAAS;AAAA,IACpB,QAAQ;AAAA,IAER;AACA,UAAM;AAAA,EACR;AACF;",
|
|
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.4-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.4-develop.4217.1.c9aa050183'\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.4-develop.
|
|
3
|
+
"version": "0.6.4-develop.4217.1.c9aa050183",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"@mikro-orm/core": "^7.1.1",
|
|
93
93
|
"@mikro-orm/decorators": "^7.1.1",
|
|
94
94
|
"@mikro-orm/postgresql": "^7.1.1",
|
|
95
|
-
"@open-mercato/cache": "0.6.4-develop.
|
|
95
|
+
"@open-mercato/cache": "0.6.4-develop.4217.1.c9aa050183",
|
|
96
96
|
"dotenv": "^17.4.2",
|
|
97
97
|
"rate-limiter-flexible": "^11.1.0",
|
|
98
98
|
"re2js": "2.8.3",
|
|
@@ -2,17 +2,19 @@ import { withAtomicFlush } from '../flush'
|
|
|
2
2
|
|
|
3
3
|
type FakeEntityManager = {
|
|
4
4
|
flush: jest.Mock<Promise<void>, []>
|
|
5
|
-
begin: jest.Mock<Promise<void>, []>
|
|
5
|
+
begin: jest.Mock<Promise<void>, [unknown?]>
|
|
6
6
|
commit: jest.Mock<Promise<void>, []>
|
|
7
7
|
rollback: jest.Mock<Promise<void>, []>
|
|
8
|
+
isInTransaction: jest.Mock<boolean, []>
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
function createFakeEm(): FakeEntityManager {
|
|
11
|
+
function createFakeEm(overrides?: { inTransaction?: boolean }): FakeEntityManager {
|
|
11
12
|
return {
|
|
12
13
|
flush: jest.fn().mockResolvedValue(undefined),
|
|
13
14
|
begin: jest.fn().mockResolvedValue(undefined),
|
|
14
15
|
commit: jest.fn().mockResolvedValue(undefined),
|
|
15
16
|
rollback: jest.fn().mockResolvedValue(undefined),
|
|
17
|
+
isInTransaction: jest.fn().mockReturnValue(overrides?.inTransaction ?? false),
|
|
16
18
|
}
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -163,4 +165,78 @@ describe('withAtomicFlush', () => {
|
|
|
163
165
|
|
|
164
166
|
expect(em.rollback).toHaveBeenCalledTimes(1)
|
|
165
167
|
})
|
|
168
|
+
|
|
169
|
+
it('joins an ambient transaction instead of clobbering it (re-entrancy)', async () => {
|
|
170
|
+
const em = createFakeEm({ inTransaction: true })
|
|
171
|
+
const phase = jest.fn()
|
|
172
|
+
|
|
173
|
+
await withAtomicFlush(em as any, [phase], { transaction: true })
|
|
174
|
+
|
|
175
|
+
// Must NOT open/commit a nested transaction — the outermost caller owns it.
|
|
176
|
+
expect(em.begin).not.toHaveBeenCalled()
|
|
177
|
+
expect(em.commit).not.toHaveBeenCalled()
|
|
178
|
+
expect(em.rollback).not.toHaveBeenCalled()
|
|
179
|
+
expect(phase).toHaveBeenCalledTimes(1)
|
|
180
|
+
expect(em.flush).toHaveBeenCalledTimes(1)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('propagates a phase error when joining an ambient transaction (no local rollback)', async () => {
|
|
184
|
+
const em = createFakeEm({ inTransaction: true })
|
|
185
|
+
const failure = new Error('nested-phase-failure')
|
|
186
|
+
|
|
187
|
+
await expect(
|
|
188
|
+
withAtomicFlush(em as any, [
|
|
189
|
+
() => {
|
|
190
|
+
throw failure
|
|
191
|
+
},
|
|
192
|
+
], { transaction: true }),
|
|
193
|
+
).rejects.toBe(failure)
|
|
194
|
+
|
|
195
|
+
// The enclosing transaction owns rollback; this call must not commit or rollback.
|
|
196
|
+
expect(em.begin).not.toHaveBeenCalled()
|
|
197
|
+
expect(em.commit).not.toHaveBeenCalled()
|
|
198
|
+
expect(em.rollback).not.toHaveBeenCalled()
|
|
199
|
+
expect(em.flush).not.toHaveBeenCalled()
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('forwards isolationLevel to begin when opening a top-level transaction', async () => {
|
|
203
|
+
const em = createFakeEm()
|
|
204
|
+
|
|
205
|
+
await withAtomicFlush(em as any, [() => {}], {
|
|
206
|
+
transaction: true,
|
|
207
|
+
isolationLevel: 'serializable' as any,
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
expect(em.begin).toHaveBeenCalledTimes(1)
|
|
211
|
+
expect(em.begin).toHaveBeenCalledWith({ isolationLevel: 'serializable' })
|
|
212
|
+
expect(em.commit).toHaveBeenCalledTimes(1)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('does not pass options to begin when no isolationLevel is set', async () => {
|
|
216
|
+
const em = createFakeEm()
|
|
217
|
+
|
|
218
|
+
await withAtomicFlush(em as any, [() => {}], { transaction: true })
|
|
219
|
+
|
|
220
|
+
expect(em.begin).toHaveBeenCalledWith(undefined)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('opens its own transaction when the EM does not implement isInTransaction (partial/mock EM)', async () => {
|
|
224
|
+
// Many command unit tests mock an EntityManager with begin/commit/rollback/flush
|
|
225
|
+
// but no isInTransaction. The re-entrancy probe must not throw on such EMs — it
|
|
226
|
+
// treats the missing method as "not in a transaction" and opens its own.
|
|
227
|
+
const begin = jest.fn().mockResolvedValue(undefined)
|
|
228
|
+
const commit = jest.fn().mockResolvedValue(undefined)
|
|
229
|
+
const flush = jest.fn().mockResolvedValue(undefined)
|
|
230
|
+
const partialEm = { begin, commit, rollback: jest.fn(), flush }
|
|
231
|
+
const phase = jest.fn()
|
|
232
|
+
|
|
233
|
+
await expect(
|
|
234
|
+
withAtomicFlush(partialEm as any, [phase], { transaction: true }),
|
|
235
|
+
).resolves.toBeUndefined()
|
|
236
|
+
|
|
237
|
+
expect(begin).toHaveBeenCalledTimes(1)
|
|
238
|
+
expect(phase).toHaveBeenCalledTimes(1)
|
|
239
|
+
expect(flush).toHaveBeenCalledTimes(1)
|
|
240
|
+
expect(commit).toHaveBeenCalledTimes(1)
|
|
241
|
+
})
|
|
166
242
|
})
|
|
@@ -1,4 +1,28 @@
|
|
|
1
1
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import type { IsolationLevel } from '@mikro-orm/core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Options controlling how {@link withAtomicFlush} executes its phases.
|
|
6
|
+
*/
|
|
7
|
+
export type AtomicFlushOptions = {
|
|
8
|
+
/**
|
|
9
|
+
* When true, the whole sequence runs inside a database transaction for
|
|
10
|
+
* all-or-nothing semantics. Default: false (a single `em.flush()` commits
|
|
11
|
+
* all phases at the end — no transaction).
|
|
12
|
+
*/
|
|
13
|
+
transaction?: boolean
|
|
14
|
+
/**
|
|
15
|
+
* Optional transaction isolation level, forwarded to `em.begin()`. Only
|
|
16
|
+
* honoured when this call opens a new top-level transaction (i.e.
|
|
17
|
+
* `transaction: true` and the EntityManager is not already inside a
|
|
18
|
+
* transaction). Ignored when joining an ambient transaction.
|
|
19
|
+
*/
|
|
20
|
+
isolationLevel?: IsolationLevel
|
|
21
|
+
/**
|
|
22
|
+
* Optional label for diagnostics. Currently informational only.
|
|
23
|
+
*/
|
|
24
|
+
label?: string
|
|
25
|
+
}
|
|
2
26
|
|
|
3
27
|
/**
|
|
4
28
|
* Wraps multiple mutation phases in a single atomic flush.
|
|
@@ -10,10 +34,24 @@ import type { EntityManager } from '@mikro-orm/postgresql'
|
|
|
10
34
|
* phases mutate, so closures over `em` stay valid.
|
|
11
35
|
*
|
|
12
36
|
* When `options.transaction` is true, the whole sequence runs
|
|
13
|
-
* inside a database transaction
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
37
|
+
* inside a database transaction for all-or-nothing semantics.
|
|
38
|
+
*
|
|
39
|
+
* ## Re-entrancy / composability
|
|
40
|
+
*
|
|
41
|
+
* `withAtomicFlush({ transaction: true })` is safe to nest. If the
|
|
42
|
+
* supplied `EntityManager` is **already inside a transaction**, this
|
|
43
|
+
* call does NOT open a second one (raw `em.begin()` would clobber the
|
|
44
|
+
* active `#transactionContext` and orphan the outer transaction — unlike
|
|
45
|
+
* `em.transactional()`, MikroORM's `em.begin()` does not check
|
|
46
|
+
* `isInTransaction()`). Instead it joins the ambient transaction: the
|
|
47
|
+
* phases run and flush within it, and the outermost caller owns the final
|
|
48
|
+
* `commit()` / `rollback()`. A phase error therefore rolls back the entire
|
|
49
|
+
* enclosing transaction (all-or-nothing across the whole nest).
|
|
50
|
+
*
|
|
51
|
+
* This mirrors the contract every command relies on: each command forks
|
|
52
|
+
* the request `EntityManager` first, so the common case opens a fresh
|
|
53
|
+
* top-level transaction; nesting only happens when one transactional unit
|
|
54
|
+
* is composed inside another on the same `em`.
|
|
17
55
|
*
|
|
18
56
|
* When `phases` is empty the call is a true no-op — no flush,
|
|
19
57
|
* no transaction. Callers that need an explicit commit should
|
|
@@ -25,7 +63,7 @@ import type { EntityManager } from '@mikro-orm/postgresql'
|
|
|
25
63
|
export async function withAtomicFlush(
|
|
26
64
|
em: EntityManager,
|
|
27
65
|
phases: Array<() => void | Promise<void>>,
|
|
28
|
-
options?:
|
|
66
|
+
options?: AtomicFlushOptions,
|
|
29
67
|
): Promise<void> {
|
|
30
68
|
if (phases.length === 0) return
|
|
31
69
|
|
|
@@ -36,21 +74,36 @@ export async function withAtomicFlush(
|
|
|
36
74
|
await em.flush()
|
|
37
75
|
}
|
|
38
76
|
|
|
39
|
-
if (options?.transaction) {
|
|
40
|
-
await
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
77
|
+
if (!options?.transaction) {
|
|
78
|
+
await runPhasesAndFlush()
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Re-entrancy guard: never open a nested transaction with raw begin/commit.
|
|
83
|
+
// If a transaction is already active on this EntityManager, join it — the
|
|
84
|
+
// outermost caller owns commit/rollback. A phase error propagates and rolls
|
|
85
|
+
// back the whole enclosing transaction.
|
|
86
|
+
//
|
|
87
|
+
// Guard the probe: real MikroORM EntityManagers always implement
|
|
88
|
+
// `isInTransaction()`, but partial / mock EMs may not. A missing method is
|
|
89
|
+
// treated as "not in a transaction", so this call opens its own top-level
|
|
90
|
+
// transaction via the begin/commit path below (which those EMs do support).
|
|
91
|
+
const isInTransaction = (em as { isInTransaction?: () => boolean }).isInTransaction
|
|
92
|
+
if (typeof isInTransaction === 'function' && isInTransaction.call(em)) {
|
|
93
|
+
await runPhasesAndFlush()
|
|
52
94
|
return
|
|
53
95
|
}
|
|
54
96
|
|
|
55
|
-
await
|
|
97
|
+
await em.begin(options.isolationLevel ? { isolationLevel: options.isolationLevel } : undefined)
|
|
98
|
+
try {
|
|
99
|
+
await runPhasesAndFlush()
|
|
100
|
+
await em.commit()
|
|
101
|
+
} catch (err) {
|
|
102
|
+
try {
|
|
103
|
+
await em.rollback()
|
|
104
|
+
} catch {
|
|
105
|
+
// rollback failure should not mask the original error; intentionally swallowed
|
|
106
|
+
}
|
|
107
|
+
throw err
|
|
108
|
+
}
|
|
56
109
|
}
|