@palbase/backend 23.0.0 → 23.1.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/dist/bin/palbase-backend.cjs +313 -24
- package/dist/bin/palbase-backend.cjs.map +1 -1
- package/dist/bin/palbase-backend.js +5 -5
- package/dist/{chunk-Y5HXVUMP.js → chunk-HQRJDARQ.js} +2 -2
- package/dist/{chunk-REZU6UKT.js → chunk-M5MCBWJI.js} +296 -25
- package/dist/chunk-M5MCBWJI.js.map +1 -0
- package/dist/{chunk-W5ODXPY3.js → chunk-NS5V43YQ.js} +14 -1
- package/dist/chunk-NS5V43YQ.js.map +1 -0
- package/dist/{chunk-FSGSB42K.js → chunk-OHALWEOG.js} +9 -4
- package/dist/chunk-OHALWEOG.js.map +1 -0
- package/dist/{chunk-HAF67F2H.js → chunk-PY7YJDCT.js} +57 -3
- package/dist/chunk-PY7YJDCT.js.map +1 -0
- package/dist/{chunk-ZC6Q2BRD.js → chunk-R3KN6RHD.js} +2 -2
- package/dist/{chunk-OMRTHM4X.js → chunk-RCLNBJCM.js} +1 -1
- package/dist/chunk-RCLNBJCM.js.map +1 -0
- package/dist/db/index.cjs +68 -1
- package/dist/db/index.cjs.map +1 -1
- package/dist/db/index.d.cts +2 -2
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.js +2 -2
- package/dist/{endpoint-BavvbW4P.d.ts → endpoint-CVWXh6oG.d.ts} +70 -8
- package/dist/{endpoint-i8TTCohk.d.cts → endpoint-c9h5jriX.d.cts} +70 -8
- package/dist/engine/index.cjs +313 -24
- package/dist/engine/index.cjs.map +1 -1
- package/dist/engine/index.d.cts +4 -4
- package/dist/engine/index.d.ts +4 -4
- package/dist/engine/index.js +4 -4
- package/dist/{index-B7YBEG5w.d.ts → index-BZrJXnVh.d.ts} +49 -4
- package/dist/{index-Bmvx1EvJ.d.cts → index-By8Dle5U.d.cts} +86 -17
- package/dist/{index-B3jmmItD.d.ts → index-CwAJ7HEe.d.ts} +86 -17
- package/dist/{index-E7OscPJT.d.cts → index-CxeQSfJP.d.cts} +49 -4
- package/dist/index.cjs +104 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -8
- package/dist/index.d.ts +8 -8
- package/dist/index.js +32 -6
- package/dist/index.js.map +1 -1
- package/dist/openapi/index.cjs +29 -4
- package/dist/openapi/index.cjs.map +1 -1
- package/dist/openapi/index.d.cts +5 -2
- package/dist/openapi/index.d.ts +5 -2
- package/dist/openapi/index.js +32 -7
- package/dist/openapi/index.js.map +1 -1
- package/dist/{registry-DY3d9l1k.d.ts → registry-B3niOVYp.d.ts} +101 -7
- package/dist/{registry-C3H2uPeZ.d.cts → registry-CqPK2Qby.d.cts} +101 -7
- package/dist/test/index.cjs +482 -9
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.d.cts +35 -3
- package/dist/test/index.d.ts +35 -3
- package/dist/test/index.js +480 -8
- package/dist/test/index.js.map +1 -1
- package/package.json +4 -3
- package/stager/return_types.js +23 -0
- package/dist/chunk-FSGSB42K.js.map +0 -1
- package/dist/chunk-HAF67F2H.js.map +0 -1
- package/dist/chunk-OMRTHM4X.js.map +0 -1
- package/dist/chunk-REZU6UKT.js.map +0 -1
- package/dist/chunk-W5ODXPY3.js.map +0 -1
- /package/dist/{chunk-Y5HXVUMP.js.map → chunk-HQRJDARQ.js.map} +0 -0
- /package/dist/{chunk-ZC6Q2BRD.js.map → chunk-R3KN6RHD.js.map} +0 -0
package/dist/test/index.cjs
CHANGED
|
@@ -22,10 +22,117 @@ var test_exports = {};
|
|
|
22
22
|
__export(test_exports, {
|
|
23
23
|
TestApiError: () => TestApiError,
|
|
24
24
|
api: () => api,
|
|
25
|
-
createTestApi: () => createTestApi
|
|
25
|
+
createTestApi: () => createTestApi,
|
|
26
|
+
fakeDatabase: () => fakeDatabase
|
|
26
27
|
});
|
|
27
28
|
module.exports = __toCommonJS(test_exports);
|
|
28
29
|
|
|
30
|
+
// ../core/dist/index.js
|
|
31
|
+
var CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
32
|
+
var POW_CHALLENGE_ID_HEADER = "X-PoW-Challenge-ID";
|
|
33
|
+
var POW_NONCE_HEADER = "X-PoW-Nonce";
|
|
34
|
+
function asPowChallenge(details) {
|
|
35
|
+
if (typeof details !== "object" || details === null) return null;
|
|
36
|
+
const env = details;
|
|
37
|
+
if (env.error !== "pow_required") return null;
|
|
38
|
+
const c = env.challenge;
|
|
39
|
+
if (typeof c !== "object" || c === null) return null;
|
|
40
|
+
const { id, prefix, difficulty } = c;
|
|
41
|
+
if (typeof id !== "string" || typeof prefix !== "string") return null;
|
|
42
|
+
if (typeof difficulty !== "number" || !Number.isInteger(difficulty) || difficulty < 0) return null;
|
|
43
|
+
return { id, prefix, difficulty };
|
|
44
|
+
}
|
|
45
|
+
var encoder = new TextEncoder();
|
|
46
|
+
var hasher = null;
|
|
47
|
+
async function digester() {
|
|
48
|
+
if (hasher) return hasher;
|
|
49
|
+
const runtime = globalThis;
|
|
50
|
+
const nodeish = runtime.process?.versions?.node !== void 0 || runtime.process?.versions?.bun !== void 0;
|
|
51
|
+
if (nodeish) {
|
|
52
|
+
try {
|
|
53
|
+
const mod = await import(
|
|
54
|
+
/* @vite-ignore */
|
|
55
|
+
`${"node:"}crypto`
|
|
56
|
+
);
|
|
57
|
+
if (typeof mod.createHash === "function") {
|
|
58
|
+
const createHash = mod.createHash;
|
|
59
|
+
hasher = (input) => new Uint8Array(createHash("sha256").update(input).digest());
|
|
60
|
+
return hasher;
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
hasher = async (input) => new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(input)));
|
|
66
|
+
return hasher;
|
|
67
|
+
}
|
|
68
|
+
var now = () => typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
|
|
69
|
+
function leadingZeroBits(hash) {
|
|
70
|
+
let bits = 0;
|
|
71
|
+
for (const byte of hash) {
|
|
72
|
+
if (byte === 0) {
|
|
73
|
+
bits += 8;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
return bits + Math.clz32(byte) - 24;
|
|
77
|
+
}
|
|
78
|
+
return bits;
|
|
79
|
+
}
|
|
80
|
+
var MAX_POW_DIFFICULTY = 24;
|
|
81
|
+
function powBudget(difficulty) {
|
|
82
|
+
return 8 * 2 ** difficulty;
|
|
83
|
+
}
|
|
84
|
+
var POW_TIME_BUDGET_MS = 12e4;
|
|
85
|
+
var CALIBRATION_WARMUP = 1024;
|
|
86
|
+
var CALIBRATION_END = 9216;
|
|
87
|
+
async function solvePowChallenge(challenge, maxIterations = powBudget(challenge.difficulty), signal) {
|
|
88
|
+
if (challenge.difficulty > MAX_POW_DIFFICULTY) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`proof-of-work: refusing difficulty ${challenge.difficulty}; this client attempts at most ${MAX_POW_DIFFICULTY}, which is the highest a Palbase stack issues`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
const digest = await digester();
|
|
94
|
+
let warmedAt = 0;
|
|
95
|
+
let calibrated = false;
|
|
96
|
+
let deadline = Number.POSITIVE_INFINITY;
|
|
97
|
+
for (let nonce = 0; nonce < maxIterations; nonce++) {
|
|
98
|
+
if ((nonce & 1023) === 0) {
|
|
99
|
+
if (signal?.aborted) {
|
|
100
|
+
throw new DOMException("proof-of-work solve aborted", "AbortError");
|
|
101
|
+
}
|
|
102
|
+
if (now() > deadline) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`proof-of-work: gave up on difficulty ${challenge.difficulty} after ${POW_TIME_BUDGET_MS / 1e3}s and ${nonce.toLocaleString()} attempts \u2014 the tail this run drew is longer than the allowance`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (nonce === CALIBRATION_WARMUP) {
|
|
109
|
+
warmedAt = now();
|
|
110
|
+
}
|
|
111
|
+
if (!calibrated && nonce === CALIBRATION_END) {
|
|
112
|
+
calibrated = true;
|
|
113
|
+
const elapsed = Math.max(now() - warmedAt, 1e-3);
|
|
114
|
+
const rate = (CALIBRATION_END - CALIBRATION_WARMUP) / (elapsed / 1e3);
|
|
115
|
+
const expectedMs = 2 ** challenge.difficulty / rate * 1e3;
|
|
116
|
+
if (expectedMs > POW_TIME_BUDGET_MS) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`proof-of-work: difficulty ${challenge.difficulty} needs about ${Math.round(expectedMs / 1e3)}s here (${Math.round(rate).toLocaleString()} digests/s) and this client allows ${POW_TIME_BUDGET_MS / 1e3}s; refusing before spending the time rather than after`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
deadline = now() + (POW_TIME_BUDGET_MS - (now() - warmedAt));
|
|
122
|
+
}
|
|
123
|
+
const hash = await digest(challenge.prefix + nonce);
|
|
124
|
+
if (leadingZeroBits(hash) >= challenge.difficulty) {
|
|
125
|
+
return {
|
|
126
|
+
[POW_CHALLENGE_ID_HEADER]: challenge.id,
|
|
127
|
+
[POW_NONCE_HEADER]: String(nonce)
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
throw new Error(
|
|
132
|
+
`proof-of-work: no nonce found for difficulty ${challenge.difficulty} within ${maxIterations} attempts`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
29
136
|
// src/test/api.ts
|
|
30
137
|
var TestApiError = class extends Error {
|
|
31
138
|
status;
|
|
@@ -53,24 +160,45 @@ function required(value, envName) {
|
|
|
53
160
|
}
|
|
54
161
|
return value;
|
|
55
162
|
}
|
|
163
|
+
function isLocalTarget(baseUrl) {
|
|
164
|
+
try {
|
|
165
|
+
const { hostname } = new URL(baseUrl);
|
|
166
|
+
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "[::1]" || hostname === "::1";
|
|
167
|
+
} catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function secondsUntilExpiry(token) {
|
|
172
|
+
const body = token.split(".")[1];
|
|
173
|
+
if (!body) return null;
|
|
174
|
+
try {
|
|
175
|
+
const claims = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
|
176
|
+
return typeof claims.exp === "number" ? claims.exp - Math.floor(Date.now() / 1e3) : null;
|
|
177
|
+
} catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
56
181
|
function createTestApi(config) {
|
|
57
182
|
const baseUrl = required(config.baseUrl, "PALBASE_TEST_BASE_URL").replace(/\/$/, "");
|
|
58
183
|
const apiKey = required(config.apiKey, "PALBASE_TEST_API_KEY");
|
|
59
|
-
const
|
|
184
|
+
const local = isLocalTarget(baseUrl);
|
|
185
|
+
const candidateToken = local ? config.candidateToken ?? "" : required(config.candidateToken ?? "", "PALBASE_TEST_CANDIDATE");
|
|
186
|
+
const doFetch = config.fetch ?? fetch;
|
|
60
187
|
const requests = [];
|
|
61
188
|
let bearer = null;
|
|
62
189
|
async function call(method, path, body, opts = {}) {
|
|
63
190
|
const headers = {
|
|
64
191
|
apikey: apiKey,
|
|
65
192
|
// Selects the release under test. Omit it and the gateway serves the LIVE
|
|
66
|
-
// one, which would make the whole suite grade the wrong code.
|
|
67
|
-
|
|
193
|
+
// one, which would make the whole suite grade the wrong code. Absent only
|
|
194
|
+
// against a local stack, which has a single version.
|
|
195
|
+
...candidateToken ? { "x-palbase-candidate": candidateToken } : {},
|
|
68
196
|
...opts.headers
|
|
69
197
|
};
|
|
70
198
|
if (bearer) headers.authorization = `Bearer ${bearer}`;
|
|
71
199
|
if (body !== void 0) headers["content-type"] = "application/json";
|
|
72
200
|
const startedAt = Date.now();
|
|
73
|
-
const res = await
|
|
201
|
+
const res = await doFetch(`${baseUrl}${path}`, {
|
|
74
202
|
method,
|
|
75
203
|
headers,
|
|
76
204
|
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
@@ -78,7 +206,18 @@ function createTestApi(config) {
|
|
|
78
206
|
const text = await res.text();
|
|
79
207
|
const parsed = text ? safeParse(text) : void 0;
|
|
80
208
|
requests.push({ method, path, status: res.status, ms: Date.now() - startedAt });
|
|
81
|
-
if (!res.ok)
|
|
209
|
+
if (!res.ok) {
|
|
210
|
+
if (res.status === 401 && bearer) {
|
|
211
|
+
const left = secondsUntilExpiry(bearer);
|
|
212
|
+
if (left !== null && left <= 0) {
|
|
213
|
+
throw new TestApiError(method, path, res.status, {
|
|
214
|
+
error: "access_token_expired",
|
|
215
|
+
error_description: `this run's access token EXPIRED ${Math.abs(left)}s ago \u2014 a test identity is minted for the length of ONE deploy, so a saved token does not survive to the next run. Re-mint it (\`palbase test-user create --json\`, or let \`palbase test\` do it) and run again.`
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
throw new TestApiError(method, path, res.status, parsed);
|
|
220
|
+
}
|
|
82
221
|
return parsed;
|
|
83
222
|
}
|
|
84
223
|
return {
|
|
@@ -110,11 +249,21 @@ function createTestApi(config) {
|
|
|
110
249
|
return this.signIn(identity);
|
|
111
250
|
},
|
|
112
251
|
async signIn(credentials) {
|
|
113
|
-
const
|
|
252
|
+
const attempt = async (extra) => call(
|
|
114
253
|
"POST",
|
|
115
254
|
"/auth/login",
|
|
116
|
-
credentials
|
|
255
|
+
credentials,
|
|
256
|
+
extra ? { headers: extra } : {}
|
|
117
257
|
);
|
|
258
|
+
let result;
|
|
259
|
+
try {
|
|
260
|
+
result = await attempt();
|
|
261
|
+
} catch (e) {
|
|
262
|
+
const refusal = e;
|
|
263
|
+
const challenge = refusal.status === 403 ? asPowChallenge(refusal.body) : null;
|
|
264
|
+
if (!challenge) throw e;
|
|
265
|
+
result = await attempt(await solvePowChallenge(challenge));
|
|
266
|
+
}
|
|
118
267
|
bearer = result.access_token;
|
|
119
268
|
return result.user ?? { id: "" };
|
|
120
269
|
},
|
|
@@ -154,10 +303,334 @@ var api = new Proxy({}, {
|
|
|
154
303
|
return Reflect.get(configured, prop, configured);
|
|
155
304
|
}
|
|
156
305
|
});
|
|
306
|
+
|
|
307
|
+
// src/__tests__/helpers/mock-db.ts
|
|
308
|
+
function createMockDB() {
|
|
309
|
+
const store = /* @__PURE__ */ new Map();
|
|
310
|
+
const tracked = {
|
|
311
|
+
inserted: /* @__PURE__ */ new Map(),
|
|
312
|
+
updated: /* @__PURE__ */ new Map(),
|
|
313
|
+
deleted: /* @__PURE__ */ new Map()
|
|
314
|
+
};
|
|
315
|
+
function rowsOf(table) {
|
|
316
|
+
let rows = store.get(table);
|
|
317
|
+
if (!rows) {
|
|
318
|
+
rows = [];
|
|
319
|
+
store.set(table, rows);
|
|
320
|
+
}
|
|
321
|
+
return rows;
|
|
322
|
+
}
|
|
323
|
+
function track(map, table, row) {
|
|
324
|
+
const list = map.get(table);
|
|
325
|
+
if (list) list.push(row);
|
|
326
|
+
else map.set(table, [row]);
|
|
327
|
+
}
|
|
328
|
+
const ops = {
|
|
329
|
+
async search(_table, _params) {
|
|
330
|
+
return [];
|
|
331
|
+
},
|
|
332
|
+
async similar() {
|
|
333
|
+
return [];
|
|
334
|
+
},
|
|
335
|
+
async recommend() {
|
|
336
|
+
return [];
|
|
337
|
+
},
|
|
338
|
+
async supersede(_table, _id, row) {
|
|
339
|
+
return { id: crypto.randomUUID(), ...row };
|
|
340
|
+
},
|
|
341
|
+
async query(_sql, _params) {
|
|
342
|
+
return [];
|
|
343
|
+
},
|
|
344
|
+
async insert(table, data) {
|
|
345
|
+
const record = { id: crypto.randomUUID(), ...data };
|
|
346
|
+
rowsOf(table).push(record);
|
|
347
|
+
track(tracked.inserted, table, record);
|
|
348
|
+
return record;
|
|
349
|
+
},
|
|
350
|
+
// Same semantics the engine's SQL has: match on the conflict columns, update
|
|
351
|
+
// everything else, and return the resulting row either way.
|
|
352
|
+
async upsert(table, data, opts) {
|
|
353
|
+
if (opts.onConflict.length === 0) {
|
|
354
|
+
throw new Error(`upsert into ${table}: onConflict en az bir kolon ad\u0131 ister`);
|
|
355
|
+
}
|
|
356
|
+
const rows = rowsOf(table);
|
|
357
|
+
const existing = rows.find((r) => opts.onConflict.every((c) => r[c] === data[c]));
|
|
358
|
+
if (existing) {
|
|
359
|
+
for (const [k, v] of Object.entries(data)) {
|
|
360
|
+
if (!opts.onConflict.includes(k)) existing[k] = v;
|
|
361
|
+
}
|
|
362
|
+
return existing;
|
|
363
|
+
}
|
|
364
|
+
const record = { id: crypto.randomUUID(), ...data };
|
|
365
|
+
rows.push(record);
|
|
366
|
+
track(tracked.inserted, table, record);
|
|
367
|
+
return record;
|
|
368
|
+
},
|
|
369
|
+
async update(table, id, data) {
|
|
370
|
+
const rows = store.get(table) ?? [];
|
|
371
|
+
const idx = rows.findIndex((r) => r["id"] === id);
|
|
372
|
+
const updated = idx >= 0 ? { ...rows[idx], ...data } : { id, ...data };
|
|
373
|
+
if (idx >= 0) {
|
|
374
|
+
rows[idx] = updated;
|
|
375
|
+
}
|
|
376
|
+
track(tracked.updated, table, updated);
|
|
377
|
+
return updated;
|
|
378
|
+
},
|
|
379
|
+
async delete(table, id) {
|
|
380
|
+
const rows = store.get(table) ?? [];
|
|
381
|
+
const idx = rows.findIndex((r) => r["id"] === id);
|
|
382
|
+
if (idx >= 0) rows.splice(idx, 1);
|
|
383
|
+
const list = tracked.deleted.get(table);
|
|
384
|
+
if (list) list.push(id);
|
|
385
|
+
else tracked.deleted.set(table, [id]);
|
|
386
|
+
},
|
|
387
|
+
async findById(table, id) {
|
|
388
|
+
const rows = store.get(table) ?? [];
|
|
389
|
+
return rows.find((r) => r["id"] === id) ?? null;
|
|
390
|
+
},
|
|
391
|
+
// The SAME filter language the engine compiles to SQL: a plain value is
|
|
392
|
+
// equality, an object is an operator set. A fake that understood less would
|
|
393
|
+
// pass a service test that the live database then fails — which is the one
|
|
394
|
+
// thing a stand-in must never do.
|
|
395
|
+
async findMany(table, query, opts) {
|
|
396
|
+
const rows = store.get(table) ?? [];
|
|
397
|
+
const matches2 = (row, key, cond) => {
|
|
398
|
+
if (cond !== null && typeof cond === "object" && !Array.isArray(cond)) {
|
|
399
|
+
return Object.entries(cond).every(([op, v]) => {
|
|
400
|
+
const cell = row[key];
|
|
401
|
+
switch (op) {
|
|
402
|
+
case "in":
|
|
403
|
+
return Array.isArray(v) && v.includes(cell);
|
|
404
|
+
case "neq":
|
|
405
|
+
return cell !== v;
|
|
406
|
+
case "gt":
|
|
407
|
+
return cell > v;
|
|
408
|
+
case "gte":
|
|
409
|
+
return cell >= v;
|
|
410
|
+
case "lt":
|
|
411
|
+
return cell < v;
|
|
412
|
+
case "lte":
|
|
413
|
+
return cell <= v;
|
|
414
|
+
default:
|
|
415
|
+
throw new Error(`findMany(${table}): where.${key} bilinmeyen operat\xF6r "${op}"`);
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
return row[key] === cond;
|
|
420
|
+
};
|
|
421
|
+
let out = query ? rows.filter((row) => Object.entries(query).every(([k, c]) => matches2(row, k, c))) : [...rows];
|
|
422
|
+
const order = opts?.orderBy;
|
|
423
|
+
if (order) {
|
|
424
|
+
const dir = order.direction === "desc" ? -1 : 1;
|
|
425
|
+
out = [...out].sort((a, b) => {
|
|
426
|
+
const x = a[order.column];
|
|
427
|
+
const y = b[order.column];
|
|
428
|
+
return x === y ? 0 : (x < y ? -1 : 1) * dir;
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
return opts?.limit === void 0 ? out : out.slice(0, opts.limit);
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
async function txPlan(plan) {
|
|
435
|
+
const snapshot = /* @__PURE__ */ new Map();
|
|
436
|
+
for (const [table, rows] of store) snapshot.set(table, [...rows]);
|
|
437
|
+
const trackedSnapshot = {
|
|
438
|
+
inserted: cloneTracked(tracked.inserted),
|
|
439
|
+
updated: cloneTracked(tracked.updated),
|
|
440
|
+
deleted: new Map([...tracked.deleted].map(([k, v]) => [k, [...v]]))
|
|
441
|
+
};
|
|
442
|
+
const results = [];
|
|
443
|
+
try {
|
|
444
|
+
for (const op of plan.ops) {
|
|
445
|
+
const result = applyOp(op, results);
|
|
446
|
+
results.push(result);
|
|
447
|
+
const failure = guardFailure(op.guard, result.rows.length);
|
|
448
|
+
if (failure) throw failure;
|
|
449
|
+
}
|
|
450
|
+
} catch (err) {
|
|
451
|
+
store.clear();
|
|
452
|
+
for (const [table, rows] of snapshot) store.set(table, rows);
|
|
453
|
+
tracked.inserted = trackedSnapshot.inserted;
|
|
454
|
+
tracked.updated = trackedSnapshot.updated;
|
|
455
|
+
tracked.deleted = trackedSnapshot.deleted;
|
|
456
|
+
throw err;
|
|
457
|
+
}
|
|
458
|
+
return { results };
|
|
459
|
+
}
|
|
460
|
+
function applyOp(op, results) {
|
|
461
|
+
switch (op.op) {
|
|
462
|
+
case "upsert": {
|
|
463
|
+
const values = resolveMap(op.values ?? {}, results, null);
|
|
464
|
+
const conflict = op.onConflict ?? [];
|
|
465
|
+
const rows = rowsOf(op.table);
|
|
466
|
+
const hit = rows.find((r) => conflict.every((c) => r[c] === values[c]));
|
|
467
|
+
if (hit) {
|
|
468
|
+
for (const [key, value] of Object.entries(values)) {
|
|
469
|
+
if (!conflict.includes(key)) hit[key] = value;
|
|
470
|
+
}
|
|
471
|
+
return { rows: [hit], rows_affected: 1 };
|
|
472
|
+
}
|
|
473
|
+
const created = { id: crypto.randomUUID(), ...values };
|
|
474
|
+
rows.push(created);
|
|
475
|
+
track(tracked.inserted, op.table, created);
|
|
476
|
+
return { rows: [created], rows_affected: 1 };
|
|
477
|
+
}
|
|
478
|
+
case "insert": {
|
|
479
|
+
const record = { id: crypto.randomUUID(), ...resolveMap(op.values ?? {}, results, null) };
|
|
480
|
+
rowsOf(op.table).push(record);
|
|
481
|
+
track(tracked.inserted, op.table, record);
|
|
482
|
+
return { rows: [record], rows_affected: 1 };
|
|
483
|
+
}
|
|
484
|
+
case "insertMany": {
|
|
485
|
+
const written = (op.rows ?? []).map((row) => {
|
|
486
|
+
const record = { id: crypto.randomUUID(), ...resolveMap(row, results, null) };
|
|
487
|
+
rowsOf(op.table).push(record);
|
|
488
|
+
track(tracked.inserted, op.table, record);
|
|
489
|
+
return record;
|
|
490
|
+
});
|
|
491
|
+
return { rows: written, rows_affected: written.length };
|
|
492
|
+
}
|
|
493
|
+
case "update": {
|
|
494
|
+
const rows = rowsOf(op.table);
|
|
495
|
+
const where = resolveMap(op.where ?? {}, results, null);
|
|
496
|
+
const written = [];
|
|
497
|
+
for (let i = 0; i < rows.length; i++) {
|
|
498
|
+
const row = rows[i];
|
|
499
|
+
if (!row || !matches(row, where)) continue;
|
|
500
|
+
const next = { ...row, ...resolveMap(op.set ?? {}, results, row) };
|
|
501
|
+
rows[i] = next;
|
|
502
|
+
track(tracked.updated, op.table, next);
|
|
503
|
+
written.push(next);
|
|
504
|
+
}
|
|
505
|
+
return { rows: written, rows_affected: written.length };
|
|
506
|
+
}
|
|
507
|
+
case "delete": {
|
|
508
|
+
const rows = rowsOf(op.table);
|
|
509
|
+
const where = resolveMap(op.where ?? {}, results, null);
|
|
510
|
+
const removed = rows.filter((row) => matches(row, where));
|
|
511
|
+
for (const row of removed) {
|
|
512
|
+
rows.splice(rows.indexOf(row), 1);
|
|
513
|
+
const id = row["id"];
|
|
514
|
+
const list = tracked.deleted.get(op.table);
|
|
515
|
+
const key = typeof id === "string" ? id : String(id);
|
|
516
|
+
if (list) list.push(key);
|
|
517
|
+
else tracked.deleted.set(op.table, [key]);
|
|
518
|
+
}
|
|
519
|
+
return { rows: removed, rows_affected: removed.length };
|
|
520
|
+
}
|
|
521
|
+
case "select": {
|
|
522
|
+
const where = resolveMap(op.where ?? {}, results, null);
|
|
523
|
+
let found = rowsOf(op.table).filter((row) => matches(row, where));
|
|
524
|
+
if (op.limit !== void 0) found = found.slice(0, op.limit);
|
|
525
|
+
return { rows: found, rows_affected: found.length };
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const client = {
|
|
530
|
+
...ops,
|
|
531
|
+
// No real savepoint in memory: the fake runs the callback against the SAME
|
|
532
|
+
// store. An assertion about rollback here would be asserting the fake.
|
|
533
|
+
attempt: (fn) => fn(ops),
|
|
534
|
+
txPlan,
|
|
535
|
+
// In tests there is no real DB role; `asService()` returns the same
|
|
536
|
+
// in-memory client so RLS-bypass code paths still hit the same store and
|
|
537
|
+
// tracking maps. The omitted `asService` matches the contract (no
|
|
538
|
+
// double-bypass), so callers can't recurse.
|
|
539
|
+
asService() {
|
|
540
|
+
return client;
|
|
541
|
+
},
|
|
542
|
+
inserted(table) {
|
|
543
|
+
return tracked.inserted.get(table) ?? [];
|
|
544
|
+
},
|
|
545
|
+
updated(table) {
|
|
546
|
+
return tracked.updated.get(table) ?? [];
|
|
547
|
+
},
|
|
548
|
+
deleted(table) {
|
|
549
|
+
return tracked.deleted.get(table) ?? [];
|
|
550
|
+
},
|
|
551
|
+
seed(table, data) {
|
|
552
|
+
store.set(table, [...data]);
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
return client;
|
|
556
|
+
}
|
|
557
|
+
function cloneTracked(map) {
|
|
558
|
+
return new Map([...map].map(([k, v]) => [k, [...v]]));
|
|
559
|
+
}
|
|
560
|
+
function resolveValue(value, results, current, column) {
|
|
561
|
+
if (typeof value !== "object" || value === null) return value;
|
|
562
|
+
const tagged = value;
|
|
563
|
+
if (tagged.$ref) {
|
|
564
|
+
const row = results[tagged.$ref.op]?.rows[0];
|
|
565
|
+
if (!row) {
|
|
566
|
+
throw txRejection(409, "tx_ref_unresolved", {
|
|
567
|
+
message: `operation ${tagged.$ref.op} produced no row to reference`
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
return row[tagged.$ref.field];
|
|
571
|
+
}
|
|
572
|
+
if (tagged.$expr) {
|
|
573
|
+
const fn = tagged.$expr["fn"];
|
|
574
|
+
if (fn === "now") return (/* @__PURE__ */ new Date()).toISOString();
|
|
575
|
+
const by = Number(tagged.$expr["by"]);
|
|
576
|
+
const base = Number(current?.[column] ?? 0);
|
|
577
|
+
return fn === "dec" ? base - by : base + by;
|
|
578
|
+
}
|
|
579
|
+
return value;
|
|
580
|
+
}
|
|
581
|
+
function resolveMap(map, results, current) {
|
|
582
|
+
const out = {};
|
|
583
|
+
for (const [key, value] of Object.entries(map)) {
|
|
584
|
+
out[key] = resolveValue(value, results, current, key);
|
|
585
|
+
}
|
|
586
|
+
return out;
|
|
587
|
+
}
|
|
588
|
+
function matches(row, where) {
|
|
589
|
+
return Object.entries(where).every(
|
|
590
|
+
([key, value]) => value === null ? row[key] === null || row[key] === void 0 : row[key] === value
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
function guardFailure(guard, count) {
|
|
594
|
+
if (!guard) return null;
|
|
595
|
+
const ok = guard.kind === "one" ? count === 1 : guard.kind === "none" ? count === 0 : guard.kind === "atLeast" ? count >= guard.n : count <= guard.n;
|
|
596
|
+
if (ok) return null;
|
|
597
|
+
return txRejection(409, "tx_guard_failed", {
|
|
598
|
+
slot: guard.slot,
|
|
599
|
+
message: `expected ${guard.kind} ${guard.n} row(s), got ${count}`
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
function txRejection(status, code, extra) {
|
|
603
|
+
const err = new Error(extra.message);
|
|
604
|
+
err.status = status;
|
|
605
|
+
err.error_code = code;
|
|
606
|
+
if (extra.slot !== void 0) err.slot = extra.slot;
|
|
607
|
+
return err;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// src/test/fake-db.ts
|
|
611
|
+
function fakeDatabase() {
|
|
612
|
+
const mock = createMockDB();
|
|
613
|
+
const queries = [];
|
|
614
|
+
const db = Object.assign(Object.create(Object.getPrototypeOf(mock)), mock, {
|
|
615
|
+
query: async (sql, params = []) => {
|
|
616
|
+
queries.push({ sql, params });
|
|
617
|
+
return mock.query(sql, params);
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
return {
|
|
621
|
+
db,
|
|
622
|
+
queries,
|
|
623
|
+
seed: (table, rows) => mock.seed(table, rows),
|
|
624
|
+
inserted: (table) => mock.inserted(table),
|
|
625
|
+
updated: (table) => mock.updated(table),
|
|
626
|
+
deleted: (table) => mock.deleted(table)
|
|
627
|
+
};
|
|
628
|
+
}
|
|
157
629
|
// Annotate the CommonJS export names for ESM import in node:
|
|
158
630
|
0 && (module.exports = {
|
|
159
631
|
TestApiError,
|
|
160
632
|
api,
|
|
161
|
-
createTestApi
|
|
633
|
+
createTestApi,
|
|
634
|
+
fakeDatabase
|
|
162
635
|
});
|
|
163
636
|
//# sourceMappingURL=index.cjs.map
|