@opensaas/stack-core 0.37.0 → 0.39.0
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/CHANGELOG.md +191 -0
- package/CLAUDE.md +42 -3
- package/dist/access/access-filter.d.ts +70 -113
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +183 -210
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +148 -188
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/declared-dependencies.d.ts +66 -26
- package/dist/access/declared-dependencies.d.ts.map +1 -1
- package/dist/access/declared-dependencies.js +62 -17
- package/dist/access/declared-dependencies.js.map +1 -1
- package/dist/access/declared-dependencies.test.d.ts +2 -0
- package/dist/access/declared-dependencies.test.d.ts.map +1 -0
- package/dist/access/declared-dependencies.test.js +226 -0
- package/dist/access/declared-dependencies.test.js.map +1 -0
- package/dist/access/depth-limits.d.ts +8 -7
- package/dist/access/depth-limits.d.ts.map +1 -1
- package/dist/access/depth-limits.js +8 -7
- package/dist/access/depth-limits.js.map +1 -1
- package/dist/access/engine.d.ts +0 -20
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +0 -25
- package/dist/access/engine.js.map +1 -1
- package/dist/access/errors.d.ts +61 -8
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +91 -12
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-access.d.ts +39 -1
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +108 -42
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +229 -2
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/field-transforms.d.ts +0 -27
- package/dist/access/field-transforms.d.ts.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -5
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +89 -41
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +6 -3
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +12 -2
- package/dist/access/index.js.map +1 -1
- package/dist/access/query-validation.d.ts +121 -0
- package/dist/access/query-validation.d.ts.map +1 -0
- package/dist/access/query-validation.js +292 -0
- package/dist/access/query-validation.js.map +1 -0
- package/dist/access/relationship-count.d.ts +1 -1
- package/dist/access/relationship-count.d.ts.map +1 -1
- package/dist/access/relationship-count.js +2 -10
- package/dist/access/relationship-count.js.map +1 -1
- package/dist/access/relationship-label-filter.d.ts +23 -23
- package/dist/access/relationship-label-filter.d.ts.map +1 -1
- package/dist/access/relationship-label-filter.js +5 -76
- package/dist/access/relationship-label-filter.js.map +1 -1
- package/dist/access/relationship-label-filter.test.js +19 -56
- package/dist/access/relationship-label-filter.test.js.map +1 -1
- package/dist/access/transaction-registry.d.ts +45 -0
- package/dist/access/transaction-registry.d.ts.map +1 -0
- package/dist/access/transaction-registry.js +37 -0
- package/dist/access/transaction-registry.js.map +1 -0
- package/dist/access/types.d.ts +55 -59
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/index.d.ts +9 -7
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +10 -18
- package/dist/config/index.js.map +1 -1
- package/dist/config/label.d.ts +6 -13
- package/dist/config/label.d.ts.map +1 -1
- package/dist/config/label.js +6 -13
- package/dist/config/label.js.map +1 -1
- package/dist/config/nav-count.d.ts +11 -19
- package/dist/config/nav-count.d.ts.map +1 -1
- package/dist/config/nav-count.js +11 -19
- package/dist/config/nav-count.js.map +1 -1
- package/dist/config/plugin-engine.d.ts +0 -10
- package/dist/config/plugin-engine.d.ts.map +1 -1
- package/dist/config/plugin-engine.js +1 -37
- package/dist/config/plugin-engine.js.map +1 -1
- package/dist/config/types.d.ts +94 -109
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/apply-defaults.d.ts +8 -31
- package/dist/context/apply-defaults.d.ts.map +1 -1
- package/dist/context/apply-defaults.js +12 -41
- package/dist/context/apply-defaults.js.map +1 -1
- package/dist/context/hook-pipeline.d.ts +4 -29
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js +8 -41
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +4 -15
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +223 -215
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +7 -26
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +73 -171
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts +36 -47
- package/dist/context/transaction-boundary.d.ts.map +1 -1
- package/dist/context/transaction-boundary.js +92 -79
- package/dist/context/transaction-boundary.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +25 -64
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +81 -111
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/format-prisma-default.d.ts.map +1 -1
- package/dist/fields/format-prisma-default.js +4 -7
- package/dist/fields/format-prisma-default.js.map +1 -1
- package/dist/fields/index.d.ts +17 -2
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +136 -147
- package/dist/fields/index.js.map +1 -1
- package/dist/filter/collect.d.ts +26 -3
- package/dist/filter/collect.d.ts.map +1 -1
- package/dist/filter/collect.js +30 -12
- package/dist/filter/collect.js.map +1 -1
- package/dist/filter/filter.test.js +98 -15
- package/dist/filter/filter.test.js.map +1 -1
- package/dist/filter/index.d.ts +1 -0
- package/dist/filter/index.d.ts.map +1 -1
- package/dist/filter/index.js.map +1 -1
- package/dist/filter/map.d.ts.map +1 -1
- package/dist/filter/map.js +0 -5
- package/dist/filter/map.js.map +1 -1
- package/dist/filter/parse.d.ts.map +1 -1
- package/dist/filter/parse.js +2 -9
- package/dist/filter/parse.js.map +1 -1
- package/dist/filter/serialize.d.ts.map +1 -1
- package/dist/filter/serialize.js +2 -4
- package/dist/filter/serialize.js.map +1 -1
- package/dist/hooks/index.d.ts +4 -42
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +5 -59
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -4
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -0
- package/dist/internal.js.map +1 -1
- package/dist/lib/case-utils.d.ts +0 -32
- package/dist/lib/case-utils.d.ts.map +1 -1
- package/dist/lib/case-utils.js +0 -32
- package/dist/lib/case-utils.js.map +1 -1
- package/dist/mcp/handler.d.ts +0 -4
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +12 -69
- package/dist/mcp/handler.js.map +1 -1
- package/dist/query/index.d.ts +55 -227
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +41 -117
- package/dist/query/index.js.map +1 -1
- package/dist/query/relationship-options.d.ts +1 -1
- package/dist/query/relationship-options.js +1 -1
- package/dist/utils/password.d.ts +0 -68
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js +7 -76
- package/dist/utils/password.js.map +1 -1
- package/dist/validation/field-config.d.ts.map +1 -1
- package/dist/validation/field-config.js +0 -6
- package/dist/validation/field-config.js.map +1 -1
- package/dist/validation/schema.d.ts +0 -7
- package/dist/validation/schema.d.ts.map +1 -1
- package/dist/validation/schema.js +0 -10
- package/dist/validation/schema.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +205 -275
- package/src/access/access-filter.ts +236 -272
- package/src/access/declared-dependencies.test.ts +277 -0
- package/src/access/declared-dependencies.ts +117 -37
- package/src/access/depth-limits.ts +8 -7
- package/src/access/engine.ts +0 -25
- package/src/access/errors.ts +97 -12
- package/src/access/field-access.test.ts +312 -2
- package/src/access/field-access.ts +118 -42
- package/src/access/field-transforms.ts +8 -39
- package/src/access/field-visibility.ts +99 -45
- package/src/access/index.ts +17 -5
- package/src/access/query-validation.ts +359 -0
- package/src/access/relationship-count.ts +3 -11
- package/src/access/relationship-label-filter.test.ts +19 -68
- package/src/access/relationship-label-filter.ts +27 -104
- package/src/access/transaction-registry.ts +58 -0
- package/src/access/types.ts +83 -86
- package/src/config/index.ts +10 -22
- package/src/config/label.ts +6 -13
- package/src/config/nav-count.ts +11 -19
- package/src/config/plugin-engine.ts +1 -37
- package/src/config/types.ts +95 -110
- package/src/context/apply-defaults.ts +12 -44
- package/src/context/hook-pipeline.ts +12 -67
- package/src/context/index.ts +311 -276
- package/src/context/nested-operations.ts +94 -226
- package/src/context/transaction-boundary.ts +113 -109
- package/src/context/write-pipeline.ts +99 -155
- package/src/fields/format-prisma-default.ts +4 -7
- package/src/fields/index.ts +155 -147
- package/src/filter/collect.ts +48 -12
- package/src/filter/filter.test.ts +153 -15
- package/src/filter/index.ts +1 -0
- package/src/filter/map.ts +0 -5
- package/src/filter/parse.ts +2 -9
- package/src/filter/serialize.ts +2 -4
- package/src/hooks/index.ts +5 -59
- package/src/index.ts +23 -4
- package/src/internal.ts +5 -0
- package/src/lib/case-utils.ts +0 -32
- package/src/mcp/handler.ts +13 -71
- package/src/query/index.ts +82 -235
- package/src/query/relationship-options.ts +1 -1
- package/src/utils/password.ts +7 -76
- package/src/validation/field-config.ts +0 -6
- package/src/validation/schema.ts +0 -10
- package/tests/access-relationships.test.ts +18 -16
- package/tests/computed-field-selective-evaluation.test.ts +418 -0
- package/tests/context.test.ts +727 -1
- package/tests/field-types.test.ts +222 -0
- package/tests/mcp-handler.test.ts +34 -0
- package/tests/needs-declared-dependencies.test.ts +7 -4
- package/tests/resolve-chain.test.ts +11 -11
- package/tests/transaction-boundary-outermost.test.ts +530 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { getContext } from '../src/context/index.js'
|
|
3
|
+
import { config, list } from '../src/config/index.js'
|
|
4
|
+
import { text, relationship } from '../src/fields/index.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ADR-0028 / #899: a transaction-boundary hook reports the OUTERMOST
|
|
8
|
+
* transaction a write participates in, not the return of its own write.
|
|
9
|
+
*
|
|
10
|
+
* A write that joins a transaction it did not open (a JOINED write — either
|
|
11
|
+
* inside `context.transaction()`, or a hook's own `context.db` write inside a
|
|
12
|
+
* plain top-level write) defers its `afterTransaction` bracket until the
|
|
13
|
+
* transaction OWNER observes the real settle, instead of firing immediately.
|
|
14
|
+
*
|
|
15
|
+
* Per the ADR's verification note, these tests use a FAITHFUL transaction
|
|
16
|
+
* mock — the `tx` client handed to `$transaction`'s callback omits
|
|
17
|
+
* `$transaction` itself, mirroring real Prisma — because the bug is invisible
|
|
18
|
+
* under a mock whose transaction client still exposes `$transaction` (a
|
|
19
|
+
* nested write would open its own transaction rather than joining).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
function createFaithfulTxPrisma(extraTables: string[] = []) {
|
|
23
|
+
const tables: Record<string, Map<string, Record<string, unknown>>> = {
|
|
24
|
+
post: new Map(),
|
|
25
|
+
user: new Map(),
|
|
26
|
+
comment: new Map(),
|
|
27
|
+
}
|
|
28
|
+
for (const table of extraTables) tables[table] = new Map()
|
|
29
|
+
let idCounter = 0
|
|
30
|
+
const nextId = () => `id-${++idCounter}`
|
|
31
|
+
|
|
32
|
+
function applyNested(
|
|
33
|
+
table: string,
|
|
34
|
+
record: Record<string, unknown>,
|
|
35
|
+
data: Record<string, unknown>,
|
|
36
|
+
): Record<string, unknown> {
|
|
37
|
+
const result = { ...record }
|
|
38
|
+
for (const [key, value] of Object.entries(data)) {
|
|
39
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
40
|
+
const nested = value as Record<string, unknown>
|
|
41
|
+
if (nested.create || nested.update || nested.delete || nested.connect) {
|
|
42
|
+
const relTable = key === 'author' ? 'user' : key === 'comments' ? 'comment' : key
|
|
43
|
+
if (nested.create) {
|
|
44
|
+
const created = doCreate(relTable, nested.create as Record<string, unknown>)
|
|
45
|
+
result[key] = created
|
|
46
|
+
}
|
|
47
|
+
if (nested.update) {
|
|
48
|
+
const upd = nested.update as { where: { id: string }; data: Record<string, unknown> }
|
|
49
|
+
result[key] = doUpdate(relTable, upd.where, upd.data)
|
|
50
|
+
}
|
|
51
|
+
if (nested.delete) {
|
|
52
|
+
doDelete(relTable, nested.delete as { id: string })
|
|
53
|
+
result[key] = null
|
|
54
|
+
}
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
result[key] = value
|
|
59
|
+
}
|
|
60
|
+
return result
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function doCreate(table: string, data: Record<string, unknown>): Record<string, unknown> {
|
|
64
|
+
const id = (data.id as string) ?? nextId()
|
|
65
|
+
let record: Record<string, unknown> = { id }
|
|
66
|
+
record = applyNested(table, record, data)
|
|
67
|
+
tables[table].set(id, record)
|
|
68
|
+
return record
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function doUpdate(
|
|
72
|
+
table: string,
|
|
73
|
+
where: { id: string },
|
|
74
|
+
data: Record<string, unknown>,
|
|
75
|
+
): Record<string, unknown> {
|
|
76
|
+
const existing = tables[table].get(where.id) ?? { id: where.id }
|
|
77
|
+
const updated = applyNested(table, existing, data)
|
|
78
|
+
tables[table].set(where.id, updated)
|
|
79
|
+
return updated
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function doDelete(table: string, where: { id: string }): Record<string, unknown> {
|
|
83
|
+
const existing = tables[table].get(where.id) ?? { id: where.id }
|
|
84
|
+
tables[table].delete(where.id)
|
|
85
|
+
return existing
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function makeModel(table: string) {
|
|
89
|
+
return {
|
|
90
|
+
findUnique: vi.fn(
|
|
91
|
+
async ({ where }: { where: { id: string } }) => tables[table].get(where.id) ?? null,
|
|
92
|
+
),
|
|
93
|
+
findFirst: vi.fn(async ({ where }: { where?: { id?: string } } = {}) => {
|
|
94
|
+
if (where?.id) return tables[table].get(where.id) ?? null
|
|
95
|
+
return tables[table].values().next().value ?? null
|
|
96
|
+
}),
|
|
97
|
+
findMany: vi.fn(async () => Array.from(tables[table].values())),
|
|
98
|
+
count: vi.fn(async () => tables[table].size),
|
|
99
|
+
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => doCreate(table, data)),
|
|
100
|
+
update: vi.fn(
|
|
101
|
+
async ({ where, data }: { where: { id: string }; data: Record<string, unknown> }) =>
|
|
102
|
+
doUpdate(table, where, data),
|
|
103
|
+
),
|
|
104
|
+
delete: vi.fn(async ({ where }: { where: { id: string } }) => doDelete(table, where)),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const client: Record<string, unknown> = {
|
|
109
|
+
post: makeModel('post'),
|
|
110
|
+
user: makeModel('user'),
|
|
111
|
+
comment: makeModel('comment'),
|
|
112
|
+
}
|
|
113
|
+
for (const table of extraTables) client[table] = makeModel(table)
|
|
114
|
+
|
|
115
|
+
client.$transaction = async (fn: (tx: unknown) => Promise<unknown>) => {
|
|
116
|
+
const snapshot: Record<string, Map<string, Record<string, unknown>>> = {}
|
|
117
|
+
for (const [name, map] of Object.entries(tables)) {
|
|
118
|
+
snapshot[name] = new Map(map)
|
|
119
|
+
}
|
|
120
|
+
// Faithful to real Prisma: the tx client has the model delegates but NOT
|
|
121
|
+
// `$transaction` — this is how a nested write detects it must join rather
|
|
122
|
+
// than open a second transaction.
|
|
123
|
+
const { $transaction: _omit, ...models } = client
|
|
124
|
+
void _omit
|
|
125
|
+
try {
|
|
126
|
+
return await fn(models)
|
|
127
|
+
} catch (err) {
|
|
128
|
+
for (const [name, map] of Object.entries(snapshot)) {
|
|
129
|
+
tables[name] = map
|
|
130
|
+
}
|
|
131
|
+
throw err
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { client, tables }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const baseConfig = (hooks: {
|
|
139
|
+
user?: Record<string, unknown>
|
|
140
|
+
post?: Record<string, unknown>
|
|
141
|
+
comment?: Record<string, unknown>
|
|
142
|
+
}) =>
|
|
143
|
+
config({
|
|
144
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
145
|
+
lists: {
|
|
146
|
+
User: list({
|
|
147
|
+
fields: { name: text() },
|
|
148
|
+
access: { operation: { query: () => true, create: () => true, update: () => true } },
|
|
149
|
+
hooks: hooks.user,
|
|
150
|
+
}),
|
|
151
|
+
Post: list({
|
|
152
|
+
fields: { title: text(), author: relationship({ ref: 'User.posts' }) },
|
|
153
|
+
access: { operation: { query: () => true, create: () => true, update: () => true } },
|
|
154
|
+
hooks: hooks.post,
|
|
155
|
+
}),
|
|
156
|
+
Comment: list({
|
|
157
|
+
fields: { body: text() },
|
|
158
|
+
access: { operation: { query: () => true, create: () => true, update: () => true } },
|
|
159
|
+
hooks: hooks.comment,
|
|
160
|
+
}),
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('ADR-0028 / #899: context.transaction() joined writes defer to the owner settle', () => {
|
|
165
|
+
let mock: ReturnType<typeof createFaithfulTxPrisma>
|
|
166
|
+
|
|
167
|
+
beforeEach(() => {
|
|
168
|
+
mock = createFaithfulTxPrisma()
|
|
169
|
+
vi.clearAllMocks()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('commit: afterTransaction fires once, AFTER the callback returns, with status committed', async () => {
|
|
173
|
+
const events: string[] = []
|
|
174
|
+
const after = vi.fn(() => events.push('after'))
|
|
175
|
+
|
|
176
|
+
const testConfig = await baseConfig({ user: { afterTransaction: after } })
|
|
177
|
+
const context = getContext(testConfig, mock.client, { userId: '1' })
|
|
178
|
+
|
|
179
|
+
const result = await context.transaction(async (tx) => {
|
|
180
|
+
const user = await tx.db.user.create({ data: { name: 'jane' } })
|
|
181
|
+
events.push('callback-write-done')
|
|
182
|
+
// afterTransaction must NOT have fired yet — the outer transaction has
|
|
183
|
+
// not settled while the callback is still running.
|
|
184
|
+
expect(after).not.toHaveBeenCalled()
|
|
185
|
+
return user
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
expect(events).toEqual(['callback-write-done', 'after'])
|
|
189
|
+
expect(after).toHaveBeenCalledTimes(1)
|
|
190
|
+
const arg = after.mock.calls[0][0]
|
|
191
|
+
expect(arg.status).toBe('committed')
|
|
192
|
+
expect(arg.item).toEqual(expect.objectContaining({ name: 'jane' }))
|
|
193
|
+
expect(result).toEqual(expect.objectContaining({ name: 'jane' }))
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('rollback: afterTransaction fires exactly once with status rolled-back and the callback error', async () => {
|
|
197
|
+
const after = vi.fn()
|
|
198
|
+
const testConfig = await baseConfig({ user: { afterTransaction: after } })
|
|
199
|
+
const context = getContext(testConfig, mock.client, { userId: '1' })
|
|
200
|
+
|
|
201
|
+
await expect(
|
|
202
|
+
context.transaction(async (tx) => {
|
|
203
|
+
await tx.db.user.create({ data: { name: 'jane' } })
|
|
204
|
+
throw new Error('callback boom')
|
|
205
|
+
}),
|
|
206
|
+
).rejects.toThrow('callback boom')
|
|
207
|
+
|
|
208
|
+
expect(after).toHaveBeenCalledTimes(1)
|
|
209
|
+
const arg = after.mock.calls[0][0]
|
|
210
|
+
expect(arg.status).toBe('rolled-back')
|
|
211
|
+
expect(arg.error).toBeInstanceOf(Error)
|
|
212
|
+
expect((arg.error as Error).message).toBe('callback boom')
|
|
213
|
+
expect(arg.item).toBeUndefined()
|
|
214
|
+
expect(mock.tables.user.size).toBe(0)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('beforeTransaction stays eager: runs during the callback, before the write settles', async () => {
|
|
218
|
+
const order: string[] = []
|
|
219
|
+
const testConfig = await baseConfig({
|
|
220
|
+
user: {
|
|
221
|
+
beforeTransaction: () => order.push('before'),
|
|
222
|
+
afterTransaction: () => order.push('after'),
|
|
223
|
+
},
|
|
224
|
+
})
|
|
225
|
+
const context = getContext(testConfig, mock.client, { userId: '1' })
|
|
226
|
+
|
|
227
|
+
await context.transaction(async (tx) => {
|
|
228
|
+
order.push('callback-start')
|
|
229
|
+
await tx.db.user.create({ data: { name: 'jane' } })
|
|
230
|
+
order.push('callback-end')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
expect(order).toEqual(['callback-start', 'before', 'callback-end', 'after'])
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('three writes to the same list in one transaction flush in write order', async () => {
|
|
237
|
+
const order: string[] = []
|
|
238
|
+
const testConfig = await baseConfig({
|
|
239
|
+
user: {
|
|
240
|
+
beforeTransaction: ({ inputData }) => order.push(`before:${inputData?.name}`),
|
|
241
|
+
afterTransaction: ({ inputData }) => order.push(`after:${inputData?.name}`),
|
|
242
|
+
},
|
|
243
|
+
})
|
|
244
|
+
const context = getContext(testConfig, mock.client, { userId: '1' })
|
|
245
|
+
|
|
246
|
+
await context.transaction(async (tx) => {
|
|
247
|
+
await tx.db.user.create({ data: { name: 'a' } })
|
|
248
|
+
await tx.db.user.create({ data: { name: 'b' } })
|
|
249
|
+
await tx.db.user.create({ data: { name: 'c' } })
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
expect(order).toEqual(['before:a', 'before:b', 'before:c', 'after:a', 'after:b', 'after:c'])
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it("a write's own error takes precedence over the transaction's outcome even when the transaction commits", async () => {
|
|
256
|
+
const userAfter = vi.fn()
|
|
257
|
+
const postAfter = vi.fn()
|
|
258
|
+
// A dedicated config: User.name is required, so a create with no `name`
|
|
259
|
+
// throws ValidationError — the write's OWN failure — independent of
|
|
260
|
+
// whatever the surrounding transaction ultimately does.
|
|
261
|
+
const testConfig = await config({
|
|
262
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
263
|
+
lists: {
|
|
264
|
+
User: list({
|
|
265
|
+
fields: { name: text({ validation: { isRequired: true } }) },
|
|
266
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
267
|
+
hooks: { afterTransaction: userAfter },
|
|
268
|
+
}),
|
|
269
|
+
Post: list({
|
|
270
|
+
fields: { title: text() },
|
|
271
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
272
|
+
hooks: { afterTransaction: postAfter },
|
|
273
|
+
}),
|
|
274
|
+
},
|
|
275
|
+
})
|
|
276
|
+
const context = getContext(testConfig, mock.client, { userId: '1' })
|
|
277
|
+
|
|
278
|
+
const result = await context.transaction(async (tx) => {
|
|
279
|
+
let userError: unknown
|
|
280
|
+
try {
|
|
281
|
+
// Missing the required `name` — throws ValidationError, the write's
|
|
282
|
+
// OWN failure, independent of the enclosing transaction's fate.
|
|
283
|
+
await tx.db.user.create({ data: {} })
|
|
284
|
+
} catch (err) {
|
|
285
|
+
userError = err
|
|
286
|
+
}
|
|
287
|
+
await tx.db.post.create({ data: { title: 'ok' } })
|
|
288
|
+
return { userError: userError instanceof Error ? userError.message : undefined }
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
// The transaction committed overall (Post persisted)...
|
|
292
|
+
expect(mock.tables.post.size).toBe(1)
|
|
293
|
+
expect(postAfter).toHaveBeenCalledWith(expect.objectContaining({ status: 'committed' }))
|
|
294
|
+
// ...but the User write's OWN failure is what its afterTransaction reports,
|
|
295
|
+
// not the transaction's (otherwise committed) outcome.
|
|
296
|
+
expect(userAfter).toHaveBeenCalledTimes(1)
|
|
297
|
+
const userArg = userAfter.mock.calls[0][0]
|
|
298
|
+
expect(userArg.status).toBe('rolled-back')
|
|
299
|
+
expect(result.userError).toBeDefined()
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('a transaction that commits with a throwing deferred hook rejects with AfterTransactionError; other compensators still ran', async () => {
|
|
303
|
+
const fired: string[] = []
|
|
304
|
+
const testConfig = await baseConfig({
|
|
305
|
+
user: {
|
|
306
|
+
afterTransaction: () => {
|
|
307
|
+
fired.push('user')
|
|
308
|
+
throw new Error('user compensator boom')
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
post: {
|
|
312
|
+
afterTransaction: () => {
|
|
313
|
+
fired.push('post')
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
})
|
|
317
|
+
const context = getContext(testConfig, mock.client, { userId: '1' })
|
|
318
|
+
|
|
319
|
+
await expect(
|
|
320
|
+
context.transaction(async (tx) => {
|
|
321
|
+
await tx.db.user.create({ data: { name: 'jane' } })
|
|
322
|
+
await tx.db.post.create({ data: { title: 'ok' } })
|
|
323
|
+
}),
|
|
324
|
+
).rejects.toThrow(/afterTransaction hook\(s\) failed/)
|
|
325
|
+
|
|
326
|
+
expect(fired).toEqual(expect.arrayContaining(['user', 'post']))
|
|
327
|
+
// DB state is final — the underlying transaction already committed.
|
|
328
|
+
expect(mock.tables.user.size).toBe(1)
|
|
329
|
+
expect(mock.tables.post.size).toBe(1)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('a nested context.transaction() joins the outer owner instead of creating a second registry', async () => {
|
|
333
|
+
const order: string[] = []
|
|
334
|
+
const testConfig = await baseConfig({
|
|
335
|
+
user: { afterTransaction: () => order.push('user-after') },
|
|
336
|
+
post: { afterTransaction: () => order.push('post-after') },
|
|
337
|
+
})
|
|
338
|
+
const context = getContext(testConfig, mock.client, { userId: '1' })
|
|
339
|
+
|
|
340
|
+
await context.transaction(async (tx) => {
|
|
341
|
+
await tx.db.user.create({ data: { name: 'jane' } })
|
|
342
|
+
// Nested call: same underlying tx client (no $transaction), so it runs
|
|
343
|
+
// directly — and per ADR-0028 must join the OUTER owner's queue.
|
|
344
|
+
await tx.transaction(async (inner) => {
|
|
345
|
+
await inner.db.post.create({ data: { title: 'nested' } })
|
|
346
|
+
order.push('inner-callback-done')
|
|
347
|
+
})
|
|
348
|
+
order.push('outer-callback-done')
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
// Both brackets flush only once the OUTER transaction settles.
|
|
352
|
+
expect(order).toEqual([
|
|
353
|
+
'inner-callback-done',
|
|
354
|
+
'outer-callback-done',
|
|
355
|
+
'user-after',
|
|
356
|
+
'post-after',
|
|
357
|
+
])
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('a deferred afterTransaction hook receives a base-client context — a compensator write survives the enclosing rollback', async () => {
|
|
361
|
+
const testConfig = await baseConfig({
|
|
362
|
+
user: {
|
|
363
|
+
afterTransaction: async ({ status, context: hookContext }) => {
|
|
364
|
+
if (status === 'rolled-back') {
|
|
365
|
+
// Compensating write: NOT part of the (already rolled-back)
|
|
366
|
+
// transaction — it must succeed and persist independently.
|
|
367
|
+
await hookContext.db.comment.create({ data: { body: 'compensated' } })
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
})
|
|
372
|
+
const context = getContext(testConfig, mock.client, { userId: '1' })
|
|
373
|
+
|
|
374
|
+
await expect(
|
|
375
|
+
context.transaction(async (tx) => {
|
|
376
|
+
await tx.db.user.create({ data: { name: 'jane' } })
|
|
377
|
+
throw new Error('boom')
|
|
378
|
+
}),
|
|
379
|
+
).rejects.toThrow('boom')
|
|
380
|
+
|
|
381
|
+
// The original transaction rolled back (no user persisted)...
|
|
382
|
+
expect(mock.tables.user.size).toBe(0)
|
|
383
|
+
// ...but the compensator's own write, issued from the deferred hook,
|
|
384
|
+
// persisted — proving it ran against the base client, not the closed tx.
|
|
385
|
+
expect(mock.tables.comment.size).toBe(1)
|
|
386
|
+
expect(Array.from(mock.tables.comment.values())[0].body).toBe('compensated')
|
|
387
|
+
})
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
describe('ADR-0028 / #899: a hook-issued context.db write inside a plain top-level write', () => {
|
|
391
|
+
// A dedicated config per test in this block: Post's nested `author` (User)
|
|
392
|
+
// is processed BEFORE its nested `comments` (Comment) — object-literal key
|
|
393
|
+
// order — and each nested list's own `afterOperation` fires as a DEFERRED
|
|
394
|
+
// "after task" once the top-level write's own before/after-hooks are done,
|
|
395
|
+
// in that same order (see `runAfterTasks`). So User's afterOperation (which
|
|
396
|
+
// issues the hook's own separate `context.db.audit.create()` write — the
|
|
397
|
+
// JOINED write under test) always runs, and completes, BEFORE Comment's own
|
|
398
|
+
// afterOperation gets a chance to fail the whole transaction — letting each
|
|
399
|
+
// test control whether the ENCLOSING write settles by commit or rollback
|
|
400
|
+
// strictly AFTER the joined Audit write already happened.
|
|
401
|
+
function makeConfig(opts: { commentThrows: boolean; auditAfter: (arg: unknown) => void }) {
|
|
402
|
+
return config({
|
|
403
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
404
|
+
lists: {
|
|
405
|
+
User: list({
|
|
406
|
+
fields: { name: text() },
|
|
407
|
+
access: { operation: { query: () => true, create: () => true, update: () => true } },
|
|
408
|
+
hooks: {
|
|
409
|
+
afterOperation: async ({ operation, context: hookContext }) => {
|
|
410
|
+
if (operation === 'create') {
|
|
411
|
+
await hookContext.db.audit.create({ data: { note: 'audit' } })
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
}),
|
|
416
|
+
Comment: list({
|
|
417
|
+
fields: { body: text() },
|
|
418
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
419
|
+
hooks: opts.commentThrows
|
|
420
|
+
? {
|
|
421
|
+
afterOperation: () => {
|
|
422
|
+
throw new Error('comment afterOperation boom')
|
|
423
|
+
},
|
|
424
|
+
}
|
|
425
|
+
: undefined,
|
|
426
|
+
}),
|
|
427
|
+
Audit: list({
|
|
428
|
+
fields: { note: text() },
|
|
429
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
430
|
+
hooks: { afterTransaction: opts.auditAfter },
|
|
431
|
+
}),
|
|
432
|
+
Post: list({
|
|
433
|
+
fields: {
|
|
434
|
+
title: text(),
|
|
435
|
+
author: relationship({ ref: 'User.posts' }),
|
|
436
|
+
comments: relationship({ ref: 'Comment', many: true }),
|
|
437
|
+
},
|
|
438
|
+
access: { operation: { query: () => true, create: () => true, update: () => true } },
|
|
439
|
+
}),
|
|
440
|
+
},
|
|
441
|
+
})
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
let mock: ReturnType<typeof createFaithfulTxPrisma>
|
|
445
|
+
|
|
446
|
+
beforeEach(() => {
|
|
447
|
+
mock = createFaithfulTxPrisma(['audit'])
|
|
448
|
+
vi.clearAllMocks()
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it('defers and reports rolled-back when the enclosing (non-interactive) write later rolls back', async () => {
|
|
452
|
+
const auditAfter = vi.fn()
|
|
453
|
+
const testConfig = await makeConfig({ commentThrows: true, auditAfter })
|
|
454
|
+
mock.tables.post.set('p1', { id: 'p1', title: 'P' })
|
|
455
|
+
const context = getContext(testConfig, mock.client, { userId: '1' })
|
|
456
|
+
|
|
457
|
+
await expect(
|
|
458
|
+
context.db.post.update({
|
|
459
|
+
where: { id: 'p1' },
|
|
460
|
+
data: {
|
|
461
|
+
title: 'x',
|
|
462
|
+
author: { create: { name: 'jane' } },
|
|
463
|
+
comments: { create: [{ body: 'trigger' }] },
|
|
464
|
+
},
|
|
465
|
+
}),
|
|
466
|
+
).rejects.toThrow('comment afterOperation boom')
|
|
467
|
+
|
|
468
|
+
// Nothing persisted — the whole write, including the hook's own Audit
|
|
469
|
+
// write, rolled back together (ADR-0010 atomicity).
|
|
470
|
+
expect(mock.tables.user.size).toBe(0)
|
|
471
|
+
expect(mock.tables.audit.size).toBe(0)
|
|
472
|
+
|
|
473
|
+
// The Audit write itself succeeded (no error of its own) — its
|
|
474
|
+
// afterTransaction must still report the OWNER's real (rolled-back)
|
|
475
|
+
// outcome, not fire optimistically with 'committed' at write time.
|
|
476
|
+
expect(auditAfter).toHaveBeenCalledTimes(1)
|
|
477
|
+
const arg = auditAfter.mock.calls[0][0]
|
|
478
|
+
expect(arg.status).toBe('rolled-back')
|
|
479
|
+
expect(arg.error).toBeInstanceOf(Error)
|
|
480
|
+
expect((arg.error as Error).message).toBe('comment afterOperation boom')
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
it('defers and reports committed with the persisted item when the enclosing write commits', async () => {
|
|
484
|
+
const auditAfter = vi.fn()
|
|
485
|
+
const testConfig = await makeConfig({ commentThrows: false, auditAfter })
|
|
486
|
+
mock.tables.post.set('p1', { id: 'p1', title: 'P' })
|
|
487
|
+
const context = getContext(testConfig, mock.client, { userId: '1' })
|
|
488
|
+
|
|
489
|
+
await context.db.post.update({
|
|
490
|
+
where: { id: 'p1' },
|
|
491
|
+
data: {
|
|
492
|
+
title: 'x',
|
|
493
|
+
author: { create: { name: 'jane' } },
|
|
494
|
+
comments: { create: [{ body: 'fine' }] },
|
|
495
|
+
},
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
expect(mock.tables.audit.size).toBe(1)
|
|
499
|
+
expect(auditAfter).toHaveBeenCalledTimes(1)
|
|
500
|
+
const arg = auditAfter.mock.calls[0][0]
|
|
501
|
+
expect(arg.status).toBe('committed')
|
|
502
|
+
expect(arg.item).toEqual(expect.objectContaining({ note: 'audit' }))
|
|
503
|
+
})
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
describe('ADR-0028 / #899: a serialization failure still propagates unwrapped, with compensators run', () => {
|
|
507
|
+
it('P2034 rejects the caller unwrapped; the deferred afterTransaction still fired (rolled-back)', async () => {
|
|
508
|
+
const mock = createFaithfulTxPrisma()
|
|
509
|
+
const after = vi.fn()
|
|
510
|
+
const testConfig = await baseConfig({ user: { afterTransaction: after } })
|
|
511
|
+
const context = getContext(testConfig, mock.client, { userId: '1' })
|
|
512
|
+
|
|
513
|
+
const serializationError = Object.assign(new Error('could not serialize access'), {
|
|
514
|
+
code: 'P2034',
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
await expect(
|
|
518
|
+
context.transaction(async (tx) => {
|
|
519
|
+
await tx.db.user.create({ data: { name: 'jane' } })
|
|
520
|
+
throw serializationError
|
|
521
|
+
}),
|
|
522
|
+
).rejects.toMatchObject({ code: 'P2034' })
|
|
523
|
+
|
|
524
|
+
// The error propagated UNWRAPPED (not an AfterTransactionError) — transaction
|
|
525
|
+
// errors keep precedence over hook errors, so a P2034 retry loop still works.
|
|
526
|
+
expect(after).toHaveBeenCalledTimes(1)
|
|
527
|
+
expect(after.mock.calls[0][0].status).toBe('rolled-back')
|
|
528
|
+
expect(mock.tables.user.size).toBe(0)
|
|
529
|
+
})
|
|
530
|
+
})
|