@opensaas/stack-core 0.39.2 → 0.41.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 +189 -0
- package/CLAUDE.md +103 -0
- package/dist/access/access-filter.d.ts +157 -14
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +470 -42
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +982 -17
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/denied-relation-visibility.test.d.ts +2 -0
- package/dist/access/denied-relation-visibility.test.d.ts.map +1 -0
- package/dist/access/denied-relation-visibility.test.js +114 -0
- package/dist/access/denied-relation-visibility.test.js.map +1 -0
- package/dist/access/engine.d.ts +54 -1
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +84 -0
- package/dist/access/engine.js.map +1 -1
- package/dist/access/engine.test.d.ts +2 -0
- package/dist/access/engine.test.d.ts.map +1 -0
- package/dist/access/engine.test.js +56 -0
- package/dist/access/engine.test.js.map +1 -0
- package/dist/access/errors.d.ts +62 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +85 -2
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-access.d.ts +12 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +22 -2
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +66 -0
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -1
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +107 -15
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +9 -2
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +11 -2
- package/dist/access/index.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/access/query-validation.d.ts +31 -0
- package/dist/access/query-validation.d.ts.map +1 -1
- package/dist/access/query-validation.js +75 -16
- package/dist/access/query-validation.js.map +1 -1
- package/dist/access/relationship-count.d.ts +41 -3
- package/dist/access/relationship-count.d.ts.map +1 -1
- package/dist/access/relationship-count.js +44 -7
- package/dist/access/relationship-count.js.map +1 -1
- package/dist/access/relationship-count.test.js +62 -0
- package/dist/access/relationship-count.test.js.map +1 -1
- package/dist/access/synthetic-include-read.test.d.ts +2 -0
- package/dist/access/synthetic-include-read.test.d.ts.map +1 -0
- package/dist/access/synthetic-include-read.test.js +121 -0
- package/dist/access/synthetic-include-read.test.js.map +1 -0
- package/dist/config/types.d.ts +98 -32
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts +2 -2
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +35 -2
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +121 -27
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +2 -2
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +74 -15
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +3 -2
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +31 -34
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/index.d.ts +13 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +50 -5
- package/dist/fields/index.js.map +1 -1
- package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
- package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
- package/dist/fields/relationship-foreign-key.test.js +18 -0
- package/dist/fields/relationship-foreign-key.test.js.map +1 -0
- package/dist/hooks/index.d.ts +16 -15
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/case-utils.d.ts +15 -0
- package/dist/lib/case-utils.d.ts.map +1 -1
- package/dist/lib/case-utils.js +21 -0
- package/dist/lib/case-utils.js.map +1 -1
- package/dist/lib/case-utils.test.js +23 -1
- package/dist/lib/case-utils.test.js.map +1 -1
- package/dist/lib/prisma-errors.d.ts +38 -0
- package/dist/lib/prisma-errors.d.ts.map +1 -0
- package/dist/lib/prisma-errors.js +53 -0
- package/dist/lib/prisma-errors.js.map +1 -0
- package/dist/mcp/constants.d.ts +23 -0
- package/dist/mcp/constants.d.ts.map +1 -0
- package/dist/mcp/constants.js +23 -0
- package/dist/mcp/constants.js.map +1 -0
- package/dist/mcp/field-schema.d.ts +8 -0
- package/dist/mcp/field-schema.d.ts.map +1 -0
- package/dist/mcp/field-schema.js +69 -0
- package/dist/mcp/field-schema.js.map +1 -0
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +76 -90
- package/dist/mcp/handler.js.map +1 -1
- package/dist/mcp/projection.d.ts +62 -0
- package/dist/mcp/projection.d.ts.map +1 -0
- package/dist/mcp/projection.js +390 -0
- package/dist/mcp/projection.js.map +1 -0
- package/dist/utils/password.d.ts +3 -1
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js +4 -2
- package/dist/utils/password.js.map +1 -1
- package/package.json +2 -2
- package/src/access/access-filter.test.ts +1528 -18
- package/src/access/access-filter.ts +708 -42
- package/src/access/denied-relation-visibility.test.ts +173 -0
- package/src/access/engine.test.ts +67 -0
- package/src/access/engine.ts +109 -1
- package/src/access/errors.ts +97 -2
- package/src/access/field-access.test.ts +78 -0
- package/src/access/field-access.ts +34 -2
- package/src/access/field-visibility.ts +171 -25
- package/src/access/index.ts +25 -0
- package/src/access/multi-column-read-write.test.ts +3 -2
- package/src/access/query-validation.ts +136 -9
- package/src/access/relationship-count.test.ts +75 -0
- package/src/access/relationship-count.ts +53 -10
- package/src/access/synthetic-include-read.test.ts +179 -0
- package/src/config/types.ts +98 -32
- package/src/context/hook-pipeline.ts +4 -2
- package/src/context/index.ts +203 -42
- package/src/context/nested-operations.ts +96 -27
- package/src/context/write-pipeline.ts +60 -36
- package/src/fields/index.ts +55 -5
- package/src/fields/relationship-foreign-key.test.ts +29 -0
- package/src/hooks/index.ts +22 -15
- package/src/index.ts +17 -1
- package/src/lib/case-utils.test.ts +32 -0
- package/src/lib/case-utils.ts +26 -0
- package/src/lib/prisma-errors.ts +86 -0
- package/src/mcp/constants.ts +24 -0
- package/src/mcp/field-schema.ts +84 -0
- package/src/mcp/handler.ts +109 -107
- package/src/mcp/projection.ts +497 -0
- package/src/utils/password.ts +5 -3
- package/tests/access-relationships.test.ts +3 -3
- package/tests/access.test.ts +83 -0
- package/tests/context.test.ts +452 -17
- package/tests/field-types.test.ts +113 -0
- package/tests/hook-context-secured.test.ts +424 -0
- package/tests/mcp-fields-projection-access.test.ts +250 -0
- package/tests/mcp-handler.test.ts +695 -4
- package/tests/nested-access-and-hooks.test.ts +389 -0
- package/tests/nested-write-synthetic-relation.test.ts +437 -0
- package/tests/password-types.test.ts +28 -0
- package/tests/password.test.ts +16 -2
- package/tests/prisma-errors.test.ts +141 -0
- package/tests/singleton.test.ts +9 -1
- package/tests/to-one-include-access-filter.test.ts +172 -0
- package/tests/with-session.test.ts +243 -0
- package/tests/write-pipeline.test.ts +29 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { describe, it, expect, vi } 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
|
+
import { ValidationError } from '../src/hooks/index.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* #978: a nested write through a list-only ref's SYNTHETIC reverse relation
|
|
9
|
+
* (`from_<SourceList>_<sourceField>`, generated because Prisma requires an
|
|
10
|
+
* opposite field but the config never declares one) must run the target
|
|
11
|
+
* list's full write pipeline — the same resolveInput/validate/field-rules/
|
|
12
|
+
* beforeOperation/afterOperation a nested write through a DECLARED
|
|
13
|
+
* relationship field gets. Before this fix, the field went unrecognised by
|
|
14
|
+
* `processNestedOperations` and was handed to Prisma completely untransformed
|
|
15
|
+
* — no hooks, no validation, silently committing whatever the caller sent.
|
|
16
|
+
*
|
|
17
|
+
* `Account` declares no relationship field back to `ChargeRequest` — only
|
|
18
|
+
* `ChargeRequest.account: relationship({ ref: 'Account' })` (list-only), so
|
|
19
|
+
* the generator synthesizes `from_ChargeRequest_account` on Account. Reachable
|
|
20
|
+
* only under `sudo()`: the undeclared-key guard refuses it otherwise (pinned
|
|
21
|
+
* below too).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const SYNTHETIC_FIELD = 'from_ChargeRequest_account'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A transaction-aware in-memory Prisma mock for Account (one) / ChargeRequest
|
|
28
|
+
* (many), modeling the synthetic back-relation as a real field on the Account
|
|
29
|
+
* model — exactly as Prisma's generated client would, since the field is a
|
|
30
|
+
* genuine schema artefact even though it has no entry in the OpenSaaS config.
|
|
31
|
+
*/
|
|
32
|
+
function createAccountPrisma() {
|
|
33
|
+
const tables: Record<string, Map<string, Record<string, unknown>>> = {
|
|
34
|
+
account: new Map(),
|
|
35
|
+
chargeRequest: new Map(),
|
|
36
|
+
}
|
|
37
|
+
const chargeToAccount = new Map<string, string>()
|
|
38
|
+
let idCounter = 0
|
|
39
|
+
const nextId = () => `cr-${++idCounter}`
|
|
40
|
+
|
|
41
|
+
function linkedChargeRequests(accountId: string): Array<Record<string, unknown>> {
|
|
42
|
+
const rows: Array<Record<string, unknown>> = []
|
|
43
|
+
for (const [crId, owner] of chargeToAccount.entries()) {
|
|
44
|
+
if (owner === accountId) {
|
|
45
|
+
const row = tables.chargeRequest.get(crId)
|
|
46
|
+
if (row) rows.push(row)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return rows
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function applyChargeRequestOps(accountId: string, ops: Record<string, unknown>) {
|
|
53
|
+
if (ops.create) {
|
|
54
|
+
const creates = Array.isArray(ops.create) ? ops.create : [ops.create]
|
|
55
|
+
for (const data of creates as Array<Record<string, unknown>>) {
|
|
56
|
+
const id = (data.id as string) ?? nextId()
|
|
57
|
+
tables.chargeRequest.set(id, { id, ...data })
|
|
58
|
+
chargeToAccount.set(id, accountId)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (ops.update) {
|
|
62
|
+
const updates = Array.isArray(ops.update) ? ops.update : [ops.update]
|
|
63
|
+
for (const u of updates as Array<{ where: { id: string }; data: Record<string, unknown> }>) {
|
|
64
|
+
const existing = tables.chargeRequest.get(u.where.id) ?? { id: u.where.id }
|
|
65
|
+
tables.chargeRequest.set(u.where.id, { ...existing, ...u.data })
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (ops.delete) {
|
|
69
|
+
const deletes = Array.isArray(ops.delete) ? ops.delete : [ops.delete]
|
|
70
|
+
for (const d of deletes as Array<{ id: string }>) {
|
|
71
|
+
tables.chargeRequest.delete(d.id)
|
|
72
|
+
chargeToAccount.delete(d.id)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function buildAccountResult(
|
|
78
|
+
accountId: string,
|
|
79
|
+
include?: Record<string, unknown>,
|
|
80
|
+
): Record<string, unknown> {
|
|
81
|
+
const base = tables.account.get(accountId) ?? { id: accountId }
|
|
82
|
+
if (include?.[SYNTHETIC_FIELD]) {
|
|
83
|
+
return { ...base, [SYNTHETIC_FIELD]: linkedChargeRequests(accountId) }
|
|
84
|
+
}
|
|
85
|
+
return { ...base }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const accountModel = {
|
|
89
|
+
findUnique: vi.fn(
|
|
90
|
+
async ({ where, include }: { where: { id: string }; include?: Record<string, unknown> }) => {
|
|
91
|
+
if (!tables.account.has(where.id)) return null
|
|
92
|
+
return buildAccountResult(where.id, include)
|
|
93
|
+
},
|
|
94
|
+
),
|
|
95
|
+
findFirst: vi.fn(async ({ where }: { where?: { id?: string } }) => {
|
|
96
|
+
if (where?.id) return tables.account.get(where.id) ?? null
|
|
97
|
+
return tables.account.values().next().value ?? null
|
|
98
|
+
}),
|
|
99
|
+
findMany: vi.fn(async () => Array.from(tables.account.values())),
|
|
100
|
+
count: vi.fn(async () => tables.account.size),
|
|
101
|
+
create: vi.fn(
|
|
102
|
+
async ({
|
|
103
|
+
data,
|
|
104
|
+
include,
|
|
105
|
+
}: {
|
|
106
|
+
data: Record<string, unknown>
|
|
107
|
+
include?: Record<string, unknown>
|
|
108
|
+
}) => {
|
|
109
|
+
const id = (data.id as string) ?? `a-${++idCounter}`
|
|
110
|
+
const { [SYNTHETIC_FIELD]: nested, ...scalars } = data
|
|
111
|
+
tables.account.set(id, { id, ...scalars })
|
|
112
|
+
if (nested) applyChargeRequestOps(id, nested as Record<string, unknown>)
|
|
113
|
+
return buildAccountResult(id, include)
|
|
114
|
+
},
|
|
115
|
+
),
|
|
116
|
+
update: vi.fn(
|
|
117
|
+
async ({
|
|
118
|
+
where,
|
|
119
|
+
data,
|
|
120
|
+
include,
|
|
121
|
+
}: {
|
|
122
|
+
where: { id: string }
|
|
123
|
+
data: Record<string, unknown>
|
|
124
|
+
include?: Record<string, unknown>
|
|
125
|
+
}) => {
|
|
126
|
+
const existing = tables.account.get(where.id) ?? { id: where.id }
|
|
127
|
+
const { [SYNTHETIC_FIELD]: nested, ...scalars } = data
|
|
128
|
+
tables.account.set(where.id, { ...existing, ...scalars })
|
|
129
|
+
if (nested) applyChargeRequestOps(where.id, nested as Record<string, unknown>)
|
|
130
|
+
return buildAccountResult(where.id, include)
|
|
131
|
+
},
|
|
132
|
+
),
|
|
133
|
+
delete: vi.fn(async ({ where }: { where: { id: string } }) => {
|
|
134
|
+
const existing = tables.account.get(where.id) ?? { id: where.id }
|
|
135
|
+
tables.account.delete(where.id)
|
|
136
|
+
return existing
|
|
137
|
+
}),
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function makeChargeRequestModel() {
|
|
141
|
+
return {
|
|
142
|
+
findUnique: vi.fn(
|
|
143
|
+
async ({ where }: { where: { id: string } }) => tables.chargeRequest.get(where.id) ?? null,
|
|
144
|
+
),
|
|
145
|
+
findFirst: vi.fn(async () => tables.chargeRequest.values().next().value ?? null),
|
|
146
|
+
findMany: vi.fn(async () => Array.from(tables.chargeRequest.values())),
|
|
147
|
+
count: vi.fn(async () => tables.chargeRequest.size),
|
|
148
|
+
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
|
149
|
+
const id = (data.id as string) ?? nextId()
|
|
150
|
+
const row = { id, ...data }
|
|
151
|
+
tables.chargeRequest.set(id, row)
|
|
152
|
+
return row
|
|
153
|
+
}),
|
|
154
|
+
update: vi.fn(
|
|
155
|
+
async ({ where, data }: { where: { id: string }; data: Record<string, unknown> }) => {
|
|
156
|
+
const existing = tables.chargeRequest.get(where.id) ?? { id: where.id }
|
|
157
|
+
const updated = { ...existing, ...data }
|
|
158
|
+
tables.chargeRequest.set(where.id, updated)
|
|
159
|
+
return updated
|
|
160
|
+
},
|
|
161
|
+
),
|
|
162
|
+
delete: vi.fn(async ({ where }: { where: { id: string } }) => {
|
|
163
|
+
const existing = tables.chargeRequest.get(where.id) ?? { id: where.id }
|
|
164
|
+
tables.chargeRequest.delete(where.id)
|
|
165
|
+
chargeToAccount.delete(where.id)
|
|
166
|
+
return existing
|
|
167
|
+
}),
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const client: Record<string, unknown> = {
|
|
172
|
+
account: accountModel,
|
|
173
|
+
chargeRequest: makeChargeRequestModel(),
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
client.$transaction = async (fn: (tx: unknown) => Promise<unknown>) => {
|
|
177
|
+
const snapshot = {
|
|
178
|
+
account: new Map(tables.account),
|
|
179
|
+
chargeRequest: new Map(tables.chargeRequest),
|
|
180
|
+
links: new Map(chargeToAccount),
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
return await fn(client)
|
|
184
|
+
} catch (err) {
|
|
185
|
+
tables.account = snapshot.account
|
|
186
|
+
tables.chargeRequest = snapshot.chargeRequest
|
|
187
|
+
chargeToAccount.clear()
|
|
188
|
+
for (const [k, v] of snapshot.links) chargeToAccount.set(k, v)
|
|
189
|
+
throw err
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function seedChargeRequest(accountId: string, row: Record<string, unknown>) {
|
|
194
|
+
tables.chargeRequest.set(row.id as string, row)
|
|
195
|
+
chargeToAccount.set(row.id as string, accountId)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { client, tables, seedChargeRequest, linkedChargeRequests }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function buildConfig(hooks: Parameters<typeof list>[0]['hooks'] = {}) {
|
|
202
|
+
return config({
|
|
203
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
204
|
+
lists: {
|
|
205
|
+
Account: list({
|
|
206
|
+
fields: { name: text() },
|
|
207
|
+
access: {
|
|
208
|
+
operation: {
|
|
209
|
+
query: () => true,
|
|
210
|
+
create: () => true,
|
|
211
|
+
update: () => true,
|
|
212
|
+
delete: () => true,
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
}),
|
|
216
|
+
// No `requests`/`chargeRequests` field on Account — this ref is
|
|
217
|
+
// list-only, so the generator synthesizes `from_ChargeRequest_account`.
|
|
218
|
+
ChargeRequest: list({
|
|
219
|
+
fields: {
|
|
220
|
+
kind: text(),
|
|
221
|
+
account: relationship({ ref: 'Account' }),
|
|
222
|
+
},
|
|
223
|
+
access: {
|
|
224
|
+
operation: {
|
|
225
|
+
query: () => true,
|
|
226
|
+
create: () => true,
|
|
227
|
+
update: () => true,
|
|
228
|
+
delete: () => true,
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
hooks,
|
|
232
|
+
}),
|
|
233
|
+
},
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
describe('#978 nested writes through a synthetic reverse relation', () => {
|
|
238
|
+
it('a non-sudo write through the synthetic key is still refused — unchanged', async () => {
|
|
239
|
+
const mock = createAccountPrisma()
|
|
240
|
+
mock.tables.account.set('a1', { id: 'a1', name: 'Acme' })
|
|
241
|
+
const context = getContext(await buildConfig(), mock.client, { userId: '1' })
|
|
242
|
+
|
|
243
|
+
await expect(
|
|
244
|
+
context.db.account.update({
|
|
245
|
+
where: { id: 'a1' },
|
|
246
|
+
data: { [SYNTHETIC_FIELD]: { create: { kind: 'DEPOSIT' } } },
|
|
247
|
+
}),
|
|
248
|
+
).rejects.toThrow(/from_ChargeRequest_account/)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('sudo nested create through the synthetic key runs the target list validate hook and refuses an invalid row', async () => {
|
|
252
|
+
const mock = createAccountPrisma()
|
|
253
|
+
mock.tables.account.set('a1', { id: 'a1', name: 'Acme' })
|
|
254
|
+
|
|
255
|
+
const testConfig = buildConfig({
|
|
256
|
+
validate: ({ resolvedData, addValidationError }) => {
|
|
257
|
+
if (resolvedData.kind === 'INCOHERENT') {
|
|
258
|
+
addValidationError('kind must not be INCOHERENT')
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
})
|
|
262
|
+
const context = getContext(await testConfig, mock.client, { userId: '1' }).sudo()
|
|
263
|
+
|
|
264
|
+
await expect(
|
|
265
|
+
context.db.account.update({
|
|
266
|
+
where: { id: 'a1' },
|
|
267
|
+
data: { [SYNTHETIC_FIELD]: { create: { kind: 'INCOHERENT' } } },
|
|
268
|
+
}),
|
|
269
|
+
).rejects.toThrow(ValidationError)
|
|
270
|
+
|
|
271
|
+
// Refused before commit — nothing persisted, matching the atomic write
|
|
272
|
+
// pipeline's "throw rolls back the whole transaction" contract.
|
|
273
|
+
expect(mock.tables.chargeRequest.size).toBe(0)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('sudo nested create through the synthetic key runs resolveInput, beforeOperation, and afterOperation', async () => {
|
|
277
|
+
const mock = createAccountPrisma()
|
|
278
|
+
mock.tables.account.set('a1', { id: 'a1', name: 'Acme' })
|
|
279
|
+
|
|
280
|
+
const events: string[] = []
|
|
281
|
+
const testConfig = buildConfig({
|
|
282
|
+
resolveInput: ({ resolvedData }) => {
|
|
283
|
+
events.push('resolveInput')
|
|
284
|
+
return { ...resolvedData, kind: (resolvedData.kind as string).toUpperCase() }
|
|
285
|
+
},
|
|
286
|
+
beforeOperation: ({ operation }) => {
|
|
287
|
+
events.push(`beforeOperation:${operation}`)
|
|
288
|
+
},
|
|
289
|
+
afterOperation: ({ operation, item }) => {
|
|
290
|
+
events.push(`afterOperation:${operation}`)
|
|
291
|
+
expect(item).toBeDefined()
|
|
292
|
+
expect((item as Record<string, unknown>).id).toBeDefined()
|
|
293
|
+
},
|
|
294
|
+
})
|
|
295
|
+
const context = getContext(await testConfig, mock.client, { userId: '1' }).sudo()
|
|
296
|
+
|
|
297
|
+
await context.db.account.update({
|
|
298
|
+
where: { id: 'a1' },
|
|
299
|
+
data: { [SYNTHETIC_FIELD]: { create: { kind: 'deposit' } } },
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
expect(events).toEqual(['resolveInput', 'beforeOperation:create', 'afterOperation:create'])
|
|
303
|
+
const created = Array.from(mock.tables.chargeRequest.values())
|
|
304
|
+
expect(created).toHaveLength(1)
|
|
305
|
+
// resolveInput's transform actually landed in the persisted row.
|
|
306
|
+
expect(created[0].kind).toBe('DEPOSIT')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('created-row recovery works for a synthetic-relation nested create, like the declared path', async () => {
|
|
310
|
+
const mock = createAccountPrisma()
|
|
311
|
+
mock.tables.account.set('a1', { id: 'a1', name: 'Acme' })
|
|
312
|
+
mock.seedChargeRequest('a1', { id: 'pre-1', kind: 'PRE_EXISTING' })
|
|
313
|
+
|
|
314
|
+
const createdItems: Array<Record<string, unknown>> = []
|
|
315
|
+
const testConfig = buildConfig({
|
|
316
|
+
afterOperation: ({ operation, item }) => {
|
|
317
|
+
if (operation === 'create') createdItems.push(item as Record<string, unknown>)
|
|
318
|
+
},
|
|
319
|
+
})
|
|
320
|
+
const context = getContext(await testConfig, mock.client, { userId: '1' }).sudo()
|
|
321
|
+
|
|
322
|
+
await context.db.account.update({
|
|
323
|
+
where: { id: 'a1' },
|
|
324
|
+
data: { [SYNTHETIC_FIELD]: { create: [{ kind: 'DEPOSIT' }, { kind: 'WITHDRAW' }] } },
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
// Exactly the two NEW rows fired afterOperation, each with its own
|
|
328
|
+
// distinct persisted id — never the pre-existing row, never a shared id.
|
|
329
|
+
expect(createdItems).toHaveLength(2)
|
|
330
|
+
const ids = createdItems.map((c) => c.id)
|
|
331
|
+
expect(new Set(ids).size).toBe(2)
|
|
332
|
+
expect(ids).not.toContain('pre-1')
|
|
333
|
+
expect(createdItems.map((c) => c.kind).sort()).toEqual(['DEPOSIT', 'WITHDRAW'])
|
|
334
|
+
|
|
335
|
+
expect(
|
|
336
|
+
mock
|
|
337
|
+
.linkedChargeRequests('a1')
|
|
338
|
+
.map((c) => c.kind)
|
|
339
|
+
.sort(),
|
|
340
|
+
).toEqual(['DEPOSIT', 'PRE_EXISTING', 'WITHDRAW'])
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('sudo nested update through the synthetic key fires afterOperation with originalItem and the updated item', async () => {
|
|
344
|
+
const mock = createAccountPrisma()
|
|
345
|
+
mock.tables.account.set('a1', { id: 'a1', name: 'Acme' })
|
|
346
|
+
mock.seedChargeRequest('a1', { id: 'cr-1', kind: 'DEPOSIT' })
|
|
347
|
+
|
|
348
|
+
const afterOp = vi.fn()
|
|
349
|
+
const testConfig = buildConfig({ afterOperation: afterOp })
|
|
350
|
+
const context = getContext(await testConfig, mock.client, { userId: '1' }).sudo()
|
|
351
|
+
|
|
352
|
+
await context.db.account.update({
|
|
353
|
+
where: { id: 'a1' },
|
|
354
|
+
data: {
|
|
355
|
+
[SYNTHETIC_FIELD]: { update: { where: { id: 'cr-1' }, data: { kind: 'WITHDRAW' } } },
|
|
356
|
+
},
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
expect(afterOp).toHaveBeenCalledWith(
|
|
360
|
+
expect.objectContaining({
|
|
361
|
+
operation: 'update',
|
|
362
|
+
originalItem: expect.objectContaining({ id: 'cr-1', kind: 'DEPOSIT' }),
|
|
363
|
+
item: expect.objectContaining({ id: 'cr-1', kind: 'WITHDRAW' }),
|
|
364
|
+
}),
|
|
365
|
+
)
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('sudo nested delete through the synthetic key fires before/afterOperation with originalItem', async () => {
|
|
369
|
+
const mock = createAccountPrisma()
|
|
370
|
+
mock.tables.account.set('a1', { id: 'a1', name: 'Acme' })
|
|
371
|
+
mock.seedChargeRequest('a1', { id: 'cr-1', kind: 'DEPOSIT' })
|
|
372
|
+
|
|
373
|
+
const listBefore = vi.fn()
|
|
374
|
+
const listAfter = vi.fn()
|
|
375
|
+
const testConfig = buildConfig({ beforeOperation: listBefore, afterOperation: listAfter })
|
|
376
|
+
const context = getContext(await testConfig, mock.client, { userId: '1' }).sudo()
|
|
377
|
+
|
|
378
|
+
await context.db.account.update({
|
|
379
|
+
where: { id: 'a1' },
|
|
380
|
+
data: { [SYNTHETIC_FIELD]: { delete: { id: 'cr-1' } } },
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
expect(listBefore).toHaveBeenCalledWith(
|
|
384
|
+
expect.objectContaining({
|
|
385
|
+
operation: 'delete',
|
|
386
|
+
item: expect.objectContaining({ id: 'cr-1', kind: 'DEPOSIT' }),
|
|
387
|
+
}),
|
|
388
|
+
)
|
|
389
|
+
expect(listAfter).toHaveBeenCalledWith(
|
|
390
|
+
expect.objectContaining({
|
|
391
|
+
operation: 'delete',
|
|
392
|
+
originalItem: expect.objectContaining({ id: 'cr-1', kind: 'DEPOSIT' }),
|
|
393
|
+
}),
|
|
394
|
+
)
|
|
395
|
+
expect(mock.tables.chargeRequest.has('cr-1')).toBe(false)
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it('a throwing nested afterOperation on the synthetic path rolls back the whole write (atomicity)', async () => {
|
|
399
|
+
const mock = createAccountPrisma()
|
|
400
|
+
mock.tables.account.set('a1', { id: 'a1', name: 'Acme' })
|
|
401
|
+
|
|
402
|
+
const testConfig = buildConfig({
|
|
403
|
+
afterOperation: ({ operation }) => {
|
|
404
|
+
if (operation === 'create') throw new Error('synthetic afterOperation boom')
|
|
405
|
+
},
|
|
406
|
+
})
|
|
407
|
+
const context = getContext(await testConfig, mock.client, { userId: '1' }).sudo()
|
|
408
|
+
|
|
409
|
+
await expect(
|
|
410
|
+
context.db.account.update({
|
|
411
|
+
where: { id: 'a1' },
|
|
412
|
+
data: {
|
|
413
|
+
name: 'Should NOT persist',
|
|
414
|
+
[SYNTHETIC_FIELD]: { create: { kind: 'DEPOSIT' } },
|
|
415
|
+
},
|
|
416
|
+
}),
|
|
417
|
+
).rejects.toThrow('synthetic afterOperation boom')
|
|
418
|
+
|
|
419
|
+
expect(mock.tables.account.get('a1')?.name).toBe('Acme')
|
|
420
|
+
expect(mock.tables.chargeRequest.size).toBe(0)
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it('a genuinely unknown key (not a synthetic reverse relation) is refused even under sudo', async () => {
|
|
424
|
+
const mock = createAccountPrisma()
|
|
425
|
+
mock.tables.account.set('a1', { id: 'a1', name: 'Acme' })
|
|
426
|
+
const context = getContext(await buildConfig(), mock.client, { userId: '1' }).sudo()
|
|
427
|
+
|
|
428
|
+
await expect(
|
|
429
|
+
context.db.account.update({
|
|
430
|
+
where: { id: 'a1' },
|
|
431
|
+
data: { totallyBogusKey: { create: { kind: 'DEPOSIT' } } },
|
|
432
|
+
}),
|
|
433
|
+
).rejects.toThrow(/totallyBogusKey/)
|
|
434
|
+
|
|
435
|
+
expect(mock.tables.account.get('a1')?.name).toBe('Acme')
|
|
436
|
+
})
|
|
437
|
+
})
|
|
@@ -143,4 +143,32 @@ describe('Password Field Type Safety', () => {
|
|
|
143
143
|
expect(await passwordField.compare('test')).toBe(false)
|
|
144
144
|
}
|
|
145
145
|
})
|
|
146
|
+
|
|
147
|
+
it('should redact the password field when a read row is JSON.stringify-ed', async () => {
|
|
148
|
+
const readableConfig = config({
|
|
149
|
+
db: { provider: 'sqlite' },
|
|
150
|
+
lists: {
|
|
151
|
+
User: list({
|
|
152
|
+
fields: {
|
|
153
|
+
email: text({ validation: { isRequired: true } }),
|
|
154
|
+
password: password({ validation: { isRequired: true } }),
|
|
155
|
+
},
|
|
156
|
+
access: { operation: { query: () => true } },
|
|
157
|
+
}),
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
const readablePrismaClient = {
|
|
161
|
+
user: {
|
|
162
|
+
...mockPrismaClient.user,
|
|
163
|
+
findFirst: mockPrismaClient.user.findUnique,
|
|
164
|
+
},
|
|
165
|
+
} as unknown as PrismaClient
|
|
166
|
+
const context = getContext(await readableConfig, readablePrismaClient, null)
|
|
167
|
+
|
|
168
|
+
const user = await context.db.user.findUnique({ where: { id: '1' } })
|
|
169
|
+
|
|
170
|
+
const serialized = JSON.stringify(user)
|
|
171
|
+
expect(serialized).not.toContain('$2a$10$hashedpassword')
|
|
172
|
+
expect(JSON.parse(serialized).password).toEqual({ isSet: true })
|
|
173
|
+
})
|
|
146
174
|
})
|
package/tests/password.test.ts
CHANGED
|
@@ -161,12 +161,26 @@ describe('Password Utilities', () => {
|
|
|
161
161
|
expect(`Password: ${wrapped}`).toBe(`Password: ${hash}`)
|
|
162
162
|
})
|
|
163
163
|
|
|
164
|
-
it('should
|
|
164
|
+
it('should redact the hash when serialized to JSON', async () => {
|
|
165
165
|
const hash = await hashPassword('test')
|
|
166
166
|
const wrapped = new HashedPassword(hash)
|
|
167
167
|
|
|
168
|
+
expect(JSON.stringify(wrapped)).toBe(JSON.stringify({ isSet: true }))
|
|
169
|
+
|
|
168
170
|
const json = JSON.stringify({ password: wrapped })
|
|
169
|
-
expect(json).toBe(JSON.stringify({ password:
|
|
171
|
+
expect(json).toBe(JSON.stringify({ password: { isSet: true } }))
|
|
172
|
+
expect(json).not.toContain(hash)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('should keep returning the raw hash from string-coercion surfaces', async () => {
|
|
176
|
+
const hash = await hashPassword('test')
|
|
177
|
+
const wrapped = new HashedPassword(hash)
|
|
178
|
+
|
|
179
|
+
expect(wrapped.toString()).toBe(hash)
|
|
180
|
+
expect(wrapped.valueOf()).toBe(hash)
|
|
181
|
+
expect(String(wrapped)).toBe(hash)
|
|
182
|
+
expect(`${wrapped}`).toBe(hash)
|
|
183
|
+
expect(wrapped == hash).toBe(true)
|
|
170
184
|
})
|
|
171
185
|
|
|
172
186
|
it('should work with valueOf', async () => {
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { uniqueConstraintOf } from '../src/lib/prisma-errors.js'
|
|
3
|
+
|
|
4
|
+
// Fixtures below are the verified output captured during triage of issue #979
|
|
5
|
+
// (PGlite behind a Postgres wire-protocol socket, @prisma/adapter-pg@7.8.0,
|
|
6
|
+
// @prisma/client@7.8.0) — not a hand-invented shape.
|
|
7
|
+
const compositeDriverAdapterError = {
|
|
8
|
+
code: 'P2002',
|
|
9
|
+
meta: {
|
|
10
|
+
modelName: 'Thing',
|
|
11
|
+
driverAdapterError: {
|
|
12
|
+
name: 'DriverAdapterError',
|
|
13
|
+
cause: {
|
|
14
|
+
originalCode: '23505',
|
|
15
|
+
originalMessage: 'duplicate key value violates unique constraint "thing_tenant_slug_key"',
|
|
16
|
+
kind: 'UniqueConstraintViolation',
|
|
17
|
+
constraint: { fields: ['"tenantId"', 'slug'] },
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const singleColumnDriverAdapterError = {
|
|
24
|
+
code: 'P2002',
|
|
25
|
+
meta: {
|
|
26
|
+
modelName: 'Thing',
|
|
27
|
+
driverAdapterError: {
|
|
28
|
+
name: 'DriverAdapterError',
|
|
29
|
+
cause: {
|
|
30
|
+
originalCode: '23505',
|
|
31
|
+
originalMessage: 'duplicate key value violates unique constraint "thing_extRef_key"',
|
|
32
|
+
kind: 'UniqueConstraintViolation',
|
|
33
|
+
constraint: { fields: ['"extRef"'] },
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// This shape is read directly out of @prisma/adapter-better-sqlite3's own
|
|
40
|
+
// error-mapping source (its SQLITE_CONSTRAINT_UNIQUE branch), not hand-invented:
|
|
41
|
+
// `constraint.fields` comes unquoted (SQLite has no Postgres-style identifier
|
|
42
|
+
// quoting), and `originalMessage` ("UNIQUE constraint failed: Post.slug") has no
|
|
43
|
+
// quoted constraint name for the regex to recover.
|
|
44
|
+
const sqliteDriverAdapterError = {
|
|
45
|
+
code: 'P2002',
|
|
46
|
+
meta: {
|
|
47
|
+
modelName: 'Post',
|
|
48
|
+
driverAdapterError: {
|
|
49
|
+
name: 'DriverAdapterError',
|
|
50
|
+
cause: {
|
|
51
|
+
originalCode: 'SQLITE_CONSTRAINT_UNIQUE',
|
|
52
|
+
originalMessage: 'UNIQUE constraint failed: Post.slug',
|
|
53
|
+
kind: 'UniqueConstraintViolation',
|
|
54
|
+
constraint: { fields: ['slug'] },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('uniqueConstraintOf', () => {
|
|
61
|
+
it('resolves fields and constraint name from a composite driver-adapter P2002', () => {
|
|
62
|
+
expect(uniqueConstraintOf(compositeDriverAdapterError)).toEqual({
|
|
63
|
+
fields: ['tenantId', 'slug'],
|
|
64
|
+
constraintName: 'thing_tenant_slug_key',
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('resolves fields (no constraint name) from a SQLite driver-adapter P2002', () => {
|
|
69
|
+
// SQLite's error text carries no quoted constraint name, so this must
|
|
70
|
+
// degrade to fields-only rather than leaving an empty/garbage name.
|
|
71
|
+
expect(uniqueConstraintOf(sqliteDriverAdapterError)).toEqual({ fields: ['slug'] })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('resolves a single-column driver-adapter P2002', () => {
|
|
75
|
+
expect(uniqueConstraintOf(singleColumnDriverAdapterError)).toEqual({
|
|
76
|
+
fields: ['extRef'],
|
|
77
|
+
constraintName: 'thing_extRef_key',
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('strips quotes only from identifiers Postgres quoted (camelCase), leaving plain names as-is', () => {
|
|
82
|
+
const info = uniqueConstraintOf(compositeDriverAdapterError)
|
|
83
|
+
// `tenantId` is camelCase and arrives quoted; `slug` is lowercase and does not.
|
|
84
|
+
expect(info?.fields).toEqual(['tenantId', 'slug'])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('prefers an already-populated meta.target and does not consult driverAdapterError', () => {
|
|
88
|
+
const error = {
|
|
89
|
+
code: 'P2002',
|
|
90
|
+
meta: {
|
|
91
|
+
target: ['email'],
|
|
92
|
+
// Present but must be ignored — meta.target already wins.
|
|
93
|
+
driverAdapterError: {
|
|
94
|
+
cause: {
|
|
95
|
+
originalMessage: 'duplicate key value violates unique constraint "other_key"',
|
|
96
|
+
constraint: { fields: ['other'] },
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
expect(uniqueConstraintOf(error)).toEqual({ fields: ['email'] })
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('returns undefined for a P2002 with no recoverable constraint information', () => {
|
|
106
|
+
expect(uniqueConstraintOf({ code: 'P2002', meta: {} })).toBeUndefined()
|
|
107
|
+
expect(uniqueConstraintOf({ code: 'P2002' })).toBeUndefined()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('returns undefined for a non-P2002 error', () => {
|
|
111
|
+
expect(uniqueConstraintOf({ code: 'P2025', meta: { target: ['email'] } })).toBeUndefined()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('returns undefined for non-error values without throwing', () => {
|
|
115
|
+
expect(uniqueConstraintOf(null)).toBeUndefined()
|
|
116
|
+
expect(uniqueConstraintOf(undefined)).toBeUndefined()
|
|
117
|
+
expect(uniqueConstraintOf('boom')).toBeUndefined()
|
|
118
|
+
expect(uniqueConstraintOf({})).toBeUndefined()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('never throws when the adapter error shape is malformed at any level', () => {
|
|
122
|
+
expect(() =>
|
|
123
|
+
uniqueConstraintOf({ code: 'P2002', meta: { driverAdapterError: null } }),
|
|
124
|
+
).not.toThrow()
|
|
125
|
+
expect(() =>
|
|
126
|
+
uniqueConstraintOf({ code: 'P2002', meta: { driverAdapterError: { cause: null } } }),
|
|
127
|
+
).not.toThrow()
|
|
128
|
+
expect(() =>
|
|
129
|
+
uniqueConstraintOf({
|
|
130
|
+
code: 'P2002',
|
|
131
|
+
meta: { driverAdapterError: { cause: { constraint: { fields: [1, null, 'ok'] } } } },
|
|
132
|
+
}),
|
|
133
|
+
).not.toThrow()
|
|
134
|
+
expect(
|
|
135
|
+
uniqueConstraintOf({
|
|
136
|
+
code: 'P2002',
|
|
137
|
+
meta: { driverAdapterError: { cause: { constraint: { fields: [1, null, 'ok'] } } } },
|
|
138
|
+
}),
|
|
139
|
+
).toEqual({ fields: ['ok'] })
|
|
140
|
+
})
|
|
141
|
+
})
|
package/tests/singleton.test.ts
CHANGED
|
@@ -278,12 +278,20 @@ describe('Singleton Lists', () => {
|
|
|
278
278
|
featuredAuthorId: 'a1',
|
|
279
279
|
featuredAuthor: { id: 'a1', name: 'Ann' },
|
|
280
280
|
})
|
|
281
|
+
// `featuredAuthor` is to-one, so Prisma cannot scope it with a nested
|
|
282
|
+
// `where` (#974) — it's fetched unscoped, then this batched existence
|
|
283
|
+
// check decides whether it survives.
|
|
284
|
+
relPrisma.author.findMany.mockResolvedValue([{ id: 'a1' }])
|
|
281
285
|
|
|
282
286
|
const context = getContext(relConfig, relPrisma, null)
|
|
283
287
|
const result = await context.db.homePage.get({ include: { featuredAuthor: true } })
|
|
284
288
|
|
|
285
289
|
const call = relPrisma.homePage.findFirst.mock.calls[0][0]
|
|
286
|
-
expect(call.include.featuredAuthor).
|
|
290
|
+
expect(call.include.featuredAuthor).toBe(true)
|
|
291
|
+
expect(relPrisma.author.findMany).toHaveBeenCalledWith({
|
|
292
|
+
where: { AND: [{ published: { equals: true } }, { id: { in: ['a1'] } }] },
|
|
293
|
+
select: { id: true },
|
|
294
|
+
})
|
|
287
295
|
expect(result?.featuredAuthor).toEqual({ id: 'a1', name: 'Ann' })
|
|
288
296
|
})
|
|
289
297
|
})
|