@ismaelsoilet/synapsejs 1.9.0 → 1.11.1
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/README.md +4 -3
- package/bin/synapse.ts +74 -2
- package/package.json +2 -2
- package/src/client/components.tsx +459 -1
- package/src/client/context.tsx +23 -3
- package/src/client/error-boundary.tsx +59 -0
- package/src/client/hooks.ts +100 -1
- package/src/client/toast.tsx +219 -0
- package/src/client/upload.ts +153 -0
- package/src/client.ts +37 -2
- package/src/compiler/client-bundler.ts +94 -2
- package/src/compiler/context-bench.ts +2 -0
- package/src/compiler/contract.ts +1 -1
- package/src/compiler/oracle-runner.ts +10 -1
- package/src/compiler/scaffolder.ts +113 -4
- package/src/compiler/slice-splitter.ts +21 -3
- package/src/compiler/slice-templates.ts +234 -10
- package/src/compiler/typescript-api.ts +33 -0
- package/src/core/action-context.ts +50 -1
- package/src/core/cron-parser.ts +167 -0
- package/src/core/database-client.ts +35 -0
- package/src/core/index.ts +2 -0
- package/src/core/jobs.ts +37 -0
- package/src/core/oauth-client.ts +322 -0
- package/src/core/postgres-client.ts +37 -0
- package/src/core/postgres-queue.ts +51 -2
- package/src/core/query-builder.ts +166 -34
- package/src/core/session-context.ts +45 -4
- package/src/core/session-token.ts +16 -1
- package/src/core/sqlite-client.ts +20 -0
- package/src/mcp/server.ts +257 -1
- package/src/mcp/tools.ts +92 -1
- package/src/project-status.ts +103 -0
- package/src/runtime/client-entry.ts +11 -5
- package/src/runtime/queue-engine.ts +120 -5
- package/src/runtime/security-headers.ts +35 -0
- package/src/runtime/server.ts +300 -58
- package/templates/starter/package.json +2 -2
|
@@ -38,10 +38,29 @@ export interface JoinClause {
|
|
|
38
38
|
as?: string;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
export interface AggregateItem {
|
|
42
|
+
fn: 'COUNT' | 'SUM' | 'AVG' | 'MIN' | 'MAX';
|
|
43
|
+
column?: string;
|
|
44
|
+
as?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface HavingAggregateCondition {
|
|
48
|
+
fn: 'COUNT' | 'SUM' | 'AVG' | 'MIN' | 'MAX';
|
|
49
|
+
column?: string;
|
|
50
|
+
op: WhereOperator;
|
|
51
|
+
value?: unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type HavingInput = WhereCondition | HavingAggregateCondition | HavingAggregateCondition[];
|
|
55
|
+
|
|
41
56
|
export interface QueryOptions<T = Record<string, unknown>> {
|
|
42
57
|
select?: Array<keyof T | string>;
|
|
58
|
+
distinct?: boolean;
|
|
59
|
+
aggregations?: AggregateItem[];
|
|
43
60
|
where?: WhereCondition;
|
|
44
61
|
join?: JoinClause[];
|
|
62
|
+
groupBy?: string[] | string;
|
|
63
|
+
having?: HavingInput;
|
|
45
64
|
orderBy?: Record<string, 'asc' | 'desc' | 'ASC' | 'DESC'> | string;
|
|
46
65
|
limit?: number;
|
|
47
66
|
offset?: number;
|
|
@@ -73,6 +92,8 @@ const VALID_WHERE_OPERATORS = new Set<string>([
|
|
|
73
92
|
'BETWEEN'
|
|
74
93
|
]);
|
|
75
94
|
|
|
95
|
+
const VALID_AGGREGATE_FUNCTIONS = new Set<string>(['COUNT', 'SUM', 'AVG', 'MIN', 'MAX']);
|
|
96
|
+
|
|
76
97
|
export function sanitizeOperator(op: string): WhereOperator {
|
|
77
98
|
const normalized = op.trim().toUpperCase();
|
|
78
99
|
if (!VALID_WHERE_OPERATORS.has(normalized)) {
|
|
@@ -109,41 +130,114 @@ function normalizeWhere(where?: WhereCondition): WhereClauseItem[] {
|
|
|
109
130
|
return items;
|
|
110
131
|
}
|
|
111
132
|
|
|
133
|
+
/** Compiles one expression and its operator into a parameterized SQL condition. */
|
|
134
|
+
function compileCondition(expression: string, op: string, value: unknown, params: unknown[]): string {
|
|
135
|
+
const normalized = sanitizeOperator(op);
|
|
136
|
+
|
|
137
|
+
if (normalized === 'IS NULL' || normalized === 'IS NOT NULL') {
|
|
138
|
+
return `${expression} ${normalized}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (normalized === 'IN' || normalized === 'NOT IN') {
|
|
142
|
+
const arr = Array.isArray(value) ? value : [value];
|
|
143
|
+
if (arr.length === 0) {
|
|
144
|
+
return normalized === 'IN' ? '1=0' : '1=1';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const placeholders = arr.map((entry) => {
|
|
148
|
+
params.push(entry);
|
|
149
|
+
return `$${params.length}`;
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return `${expression} ${normalized} (${placeholders.join(', ')})`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (normalized === 'BETWEEN') {
|
|
156
|
+
const arr = Array.isArray(value) ? value : [];
|
|
157
|
+
if (arr.length !== 2) {
|
|
158
|
+
throw new Error(`BETWEEN operator requires an array of 2 values, received: ${JSON.stringify(value)}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
params.push(arr[0]);
|
|
162
|
+
const p1 = `$${params.length}`;
|
|
163
|
+
params.push(arr[1]);
|
|
164
|
+
const p2 = `$${params.length}`;
|
|
165
|
+
return `${expression} BETWEEN ${p1} AND ${p2}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
params.push(value);
|
|
169
|
+
return `${expression} ${normalized} $${params.length}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
112
172
|
/** Compiles WhereClauseItem array into parameterized SQL condition strings */
|
|
113
173
|
export function compileWhereClauses(whereItems: WhereClauseItem[], params: unknown[]): string[] {
|
|
174
|
+
return whereItems.map((item) => compileCondition(sanitizeIdentifier(item.column), item.op, item.value, params));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Compiles HAVING conditions. A condition that names an aggregation alias is
|
|
179
|
+
* compiled as the aggregate expression, so PostgreSQL (which rejects output
|
|
180
|
+
* aliases in HAVING) and SQLite receive the same portable SQL.
|
|
181
|
+
*/
|
|
182
|
+
function compileHaving(
|
|
183
|
+
having: HavingInput,
|
|
184
|
+
aggregations: AggregateItem[],
|
|
185
|
+
groupBy: string[] | string | undefined,
|
|
186
|
+
params: unknown[]
|
|
187
|
+
): string[] {
|
|
188
|
+
const aliases = new Map<string, string>();
|
|
189
|
+
const groupedColumns = new Set<string>();
|
|
190
|
+
|
|
191
|
+
if (groupBy) {
|
|
192
|
+
const columns = Array.isArray(groupBy) ? groupBy : groupBy.split(',').map((column) => column.trim());
|
|
193
|
+
for (const column of columns) {
|
|
194
|
+
groupedColumns.add(sanitizeIdentifier(column.trim()));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const aggregation of aggregations) {
|
|
199
|
+
const fn = aggregation.fn.toUpperCase();
|
|
200
|
+
if (!VALID_AGGREGATE_FUNCTIONS.has(fn)) {
|
|
201
|
+
throw new Error(`Invalid aggregate function: "${aggregation.fn}"`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const column = aggregation.column?.trim() || '*';
|
|
205
|
+
const expression = `${fn}(${column === '*' ? '*' : sanitizeIdentifier(column)})`;
|
|
206
|
+
|
|
207
|
+
if (aggregation.as) {
|
|
208
|
+
aliases.set(aggregation.as, expression);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const items: Array<WhereClauseItem | HavingAggregateCondition> = Array.isArray(having)
|
|
213
|
+
? having
|
|
214
|
+
: typeof having === 'object' && having !== null && 'fn' in having
|
|
215
|
+
? [having as HavingAggregateCondition]
|
|
216
|
+
: normalizeWhere(having as WhereCondition);
|
|
217
|
+
|
|
114
218
|
const conditions: string[] = [];
|
|
115
219
|
|
|
116
|
-
for (const item of
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
conditions.push(`${col} ${op}`);
|
|
122
|
-
} else if (op === 'IN' || op === 'NOT IN') {
|
|
123
|
-
const arr = Array.isArray(item.value) ? item.value : [item.value];
|
|
124
|
-
if (arr.length === 0) {
|
|
125
|
-
conditions.push(op === 'IN' ? '1=0' : '1=1');
|
|
126
|
-
} else {
|
|
127
|
-
const placeholders = arr.map((val) => {
|
|
128
|
-
params.push(val);
|
|
129
|
-
return `$${params.length}`;
|
|
130
|
-
});
|
|
131
|
-
conditions.push(`${col} ${op} (${placeholders.join(', ')})`);
|
|
132
|
-
}
|
|
133
|
-
} else if (op === 'BETWEEN') {
|
|
134
|
-
const arr = Array.isArray(item.value) ? item.value : [];
|
|
135
|
-
if (arr.length !== 2) {
|
|
136
|
-
throw new Error(`BETWEEN operator requires an array of 2 values, received: ${JSON.stringify(item.value)}`);
|
|
220
|
+
for (const item of items) {
|
|
221
|
+
if ('fn' in item) {
|
|
222
|
+
const fn = item.fn.toUpperCase();
|
|
223
|
+
if (!VALID_AGGREGATE_FUNCTIONS.has(fn)) {
|
|
224
|
+
throw new Error(`Invalid aggregate function: "${item.fn}"`);
|
|
137
225
|
}
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
} else {
|
|
144
|
-
params.push(item.value);
|
|
145
|
-
conditions.push(`${col} ${op} $${params.length}`);
|
|
226
|
+
|
|
227
|
+
const column = item.column?.trim() || '*';
|
|
228
|
+
const expression = `${fn}(${column === '*' ? '*' : sanitizeIdentifier(column)})`;
|
|
229
|
+
conditions.push(compileCondition(expression, item.op, item.value, params));
|
|
230
|
+
continue;
|
|
146
231
|
}
|
|
232
|
+
|
|
233
|
+
const aliasExpression = aliases.get(item.column);
|
|
234
|
+
const column = sanitizeIdentifier(item.column);
|
|
235
|
+
|
|
236
|
+
if (!aliasExpression && !groupedColumns.has(column)) {
|
|
237
|
+
throw new Error(`Invalid HAVING column: "${item.column}" must be a groupBy column or an aggregation alias`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
conditions.push(compileCondition(aliasExpression ?? column, item.op, item.value, params));
|
|
147
241
|
}
|
|
148
242
|
|
|
149
243
|
return conditions;
|
|
@@ -200,10 +294,28 @@ export function compileSelect(table: string, options?: QueryOptions): { sql: str
|
|
|
200
294
|
return safeCol;
|
|
201
295
|
})
|
|
202
296
|
);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Handle explicit aggregations
|
|
300
|
+
if (options?.aggregations && options.aggregations.length > 0) {
|
|
301
|
+
for (const agg of options.aggregations) {
|
|
302
|
+
const fn = agg.fn.toUpperCase();
|
|
303
|
+
if (!VALID_AGGREGATE_FUNCTIONS.has(fn)) {
|
|
304
|
+
throw new Error(`Invalid aggregate function: "${agg.fn}"`);
|
|
305
|
+
}
|
|
306
|
+
const rawCol = agg.column?.trim() || '*';
|
|
307
|
+
const col = rawCol === '*' ? '*' : sanitizeIdentifier(rawCol);
|
|
308
|
+
const alias = agg.as ? ` AS "${sanitizeIdentifier(agg.as)}"` : '';
|
|
309
|
+
selectCols.push(`${fn}(${col})${alias}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (selectCols.length === 0) {
|
|
314
|
+
if (!options?.join || options.join.length === 0) {
|
|
315
|
+
selectCols.push('*');
|
|
316
|
+
} else {
|
|
317
|
+
selectCols.push(`${safeTable}.*`);
|
|
318
|
+
}
|
|
207
319
|
}
|
|
208
320
|
|
|
209
321
|
// Handle joins
|
|
@@ -238,7 +350,8 @@ export function compileSelect(table: string, options?: QueryOptions): { sql: str
|
|
|
238
350
|
}
|
|
239
351
|
}
|
|
240
352
|
|
|
241
|
-
|
|
353
|
+
const distinctPrefix = options?.distinct ? 'DISTINCT ' : '';
|
|
354
|
+
let sql = `SELECT ${distinctPrefix}${selectCols.join(', ')} FROM ${safeTable}`;
|
|
242
355
|
if (joinClauses.length > 0) {
|
|
243
356
|
sql += ` ${joinClauses.join(' ')}`;
|
|
244
357
|
}
|
|
@@ -252,6 +365,25 @@ export function compileSelect(table: string, options?: QueryOptions): { sql: str
|
|
|
252
365
|
}
|
|
253
366
|
}
|
|
254
367
|
|
|
368
|
+
// 3. GROUP BY clause
|
|
369
|
+
if (options?.groupBy) {
|
|
370
|
+
const groupCols = Array.isArray(options.groupBy)
|
|
371
|
+
? options.groupBy
|
|
372
|
+
: options.groupBy.split(',').map((c) => c.trim());
|
|
373
|
+
const safeGroupCols = groupCols.map((c) => sanitizeIdentifier(c.trim())).filter(Boolean);
|
|
374
|
+
if (safeGroupCols.length > 0) {
|
|
375
|
+
sql += ` GROUP BY ${safeGroupCols.join(', ')}`;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// 4. HAVING clause
|
|
380
|
+
if (options?.having) {
|
|
381
|
+
const conditions = compileHaving(options.having, options.aggregations ?? [], options.groupBy, params);
|
|
382
|
+
if (conditions.length > 0) {
|
|
383
|
+
sql += ` HAVING ${conditions.join(' AND ')}`;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
255
387
|
// 3. ORDER BY clause
|
|
256
388
|
if (options?.orderBy) {
|
|
257
389
|
if (typeof options.orderBy === 'string') {
|
|
@@ -13,6 +13,7 @@ export interface SessionContext {
|
|
|
13
13
|
readonly tenantId?: string;
|
|
14
14
|
readonly email?: string;
|
|
15
15
|
readonly roles: string[];
|
|
16
|
+
readonly permissions?: string[];
|
|
16
17
|
readonly isAuthenticated: boolean;
|
|
17
18
|
readonly token?: string;
|
|
18
19
|
readonly metadata?: Record<string, unknown>;
|
|
@@ -21,6 +22,7 @@ export interface SessionContext {
|
|
|
21
22
|
export function AnonymousSession(tenantId?: string): SessionContext {
|
|
22
23
|
return {
|
|
23
24
|
roles: [],
|
|
25
|
+
permissions: [],
|
|
24
26
|
isAuthenticated: false,
|
|
25
27
|
tenantId
|
|
26
28
|
};
|
|
@@ -31,6 +33,7 @@ export function createSession(data: {
|
|
|
31
33
|
tenantId?: string;
|
|
32
34
|
email?: string;
|
|
33
35
|
roles?: string[];
|
|
36
|
+
permissions?: string[];
|
|
34
37
|
token?: string;
|
|
35
38
|
metadata?: Record<string, unknown>;
|
|
36
39
|
}): SessionContext {
|
|
@@ -39,6 +42,7 @@ export function createSession(data: {
|
|
|
39
42
|
tenantId: data.tenantId,
|
|
40
43
|
email: data.email,
|
|
41
44
|
roles: data.roles || ['user'],
|
|
45
|
+
permissions: data.permissions || [],
|
|
42
46
|
isAuthenticated: true,
|
|
43
47
|
token: data.token,
|
|
44
48
|
metadata: data.metadata || {}
|
|
@@ -53,18 +57,55 @@ export function hasAnyRole(session: SessionContext, roles: string[]): boolean {
|
|
|
53
57
|
return session.isAuthenticated && roles.some((r) => session.roles.includes(r));
|
|
54
58
|
}
|
|
55
59
|
|
|
60
|
+
export function hasPermission(session: SessionContext, permission: string): boolean {
|
|
61
|
+
if (!session.isAuthenticated) return false;
|
|
62
|
+
|
|
63
|
+
const adminBypass = process.env.SYNAPSE_RBAC_ADMIN_BYPASS !== 'false';
|
|
64
|
+
|
|
65
|
+
if (adminBypass && (session.roles.includes('admin') || session.roles.includes('superadmin'))) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return Boolean(session.permissions?.includes(permission));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function hasAllPermissions(session: SessionContext, permissions: string[]): boolean {
|
|
73
|
+
return session.isAuthenticated && permissions.every((p) => hasPermission(session, p));
|
|
74
|
+
}
|
|
75
|
+
|
|
56
76
|
export type AuthCheckResult = Result<SessionContext, 'UNAUTHORIZED' | 'FORBIDDEN'>;
|
|
57
77
|
|
|
58
|
-
export
|
|
78
|
+
export interface AuthRequirements {
|
|
79
|
+
roles?: string[];
|
|
80
|
+
permissions?: string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function requireAuth(session?: SessionContext, requirements?: string[] | AuthRequirements): AuthCheckResult {
|
|
59
84
|
if (!session?.isAuthenticated) {
|
|
60
85
|
return Err('UNAUTHORIZED');
|
|
61
86
|
}
|
|
62
87
|
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
88
|
+
if (!requirements) {
|
|
89
|
+
return Ok(session);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (Array.isArray(requirements)) {
|
|
93
|
+
if (requirements.length > 0 && !hasAnyRole(session, requirements)) {
|
|
66
94
|
return Err('FORBIDDEN');
|
|
67
95
|
}
|
|
96
|
+
return Ok(session);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (requirements.roles && requirements.roles.length > 0 && !hasAnyRole(session, requirements.roles)) {
|
|
100
|
+
return Err('FORBIDDEN');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (
|
|
104
|
+
requirements.permissions &&
|
|
105
|
+
requirements.permissions.length > 0 &&
|
|
106
|
+
!hasAllPermissions(session, requirements.permissions)
|
|
107
|
+
) {
|
|
108
|
+
return Err('FORBIDDEN');
|
|
68
109
|
}
|
|
69
110
|
|
|
70
111
|
return Ok(session);
|
|
@@ -18,6 +18,8 @@ export interface SessionClaims {
|
|
|
18
18
|
userId: string;
|
|
19
19
|
roles: string[];
|
|
20
20
|
tenantId?: string;
|
|
21
|
+
/** Fine-grained permissions carried by the signed token. */
|
|
22
|
+
permissions?: string[];
|
|
21
23
|
/** Expiry, in seconds since the epoch. */
|
|
22
24
|
exp: number;
|
|
23
25
|
}
|
|
@@ -220,7 +222,7 @@ function constantTimeEquals(left: string, right: string): boolean {
|
|
|
220
222
|
}
|
|
221
223
|
|
|
222
224
|
export function signSessionToken(
|
|
223
|
-
claims: { userId: string; roles: string[]; tenantId?: string },
|
|
225
|
+
claims: { userId: string; roles: string[]; tenantId?: string; permissions?: string[] },
|
|
224
226
|
secret: string,
|
|
225
227
|
maxAgeSeconds = 86_400
|
|
226
228
|
): string {
|
|
@@ -229,6 +231,7 @@ export function signSessionToken(
|
|
|
229
231
|
userId: claims.userId,
|
|
230
232
|
roles: claims.roles,
|
|
231
233
|
tenantId: claims.tenantId,
|
|
234
|
+
permissions: claims.permissions,
|
|
232
235
|
exp: Math.floor(Date.now() / 1000) + maxAgeSeconds
|
|
233
236
|
})
|
|
234
237
|
);
|
|
@@ -269,6 +272,18 @@ export function verifySessionToken(
|
|
|
269
272
|
return Err('INVALID_TOKEN');
|
|
270
273
|
}
|
|
271
274
|
|
|
275
|
+
if (claims.permissions !== undefined) {
|
|
276
|
+
if (
|
|
277
|
+
!Array.isArray(claims.permissions) ||
|
|
278
|
+
claims.permissions.length > 100 ||
|
|
279
|
+
claims.permissions.some(
|
|
280
|
+
(permission) => typeof permission !== 'string' || permission.length === 0 || permission.length > 100
|
|
281
|
+
)
|
|
282
|
+
) {
|
|
283
|
+
return Err('INVALID_TOKEN');
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
272
287
|
if (claims.exp * 1000 < Date.now()) {
|
|
273
288
|
return Err('EXPIRED_TOKEN');
|
|
274
289
|
}
|
|
@@ -166,6 +166,26 @@ export class SqliteDatabaseClient implements DatabaseClient {
|
|
|
166
166
|
return rows.length;
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
async count(table: string, where?: WhereCondition): Promise<number> {
|
|
170
|
+
const { sql, params } = compileSelect(table, {
|
|
171
|
+
aggregations: [{ fn: 'COUNT', column: '*', as: 'total' }],
|
|
172
|
+
where
|
|
173
|
+
});
|
|
174
|
+
const row = await this.queryOne<{ total: number | string }>(sql, params);
|
|
175
|
+
return row ? Number(row.total) : 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async exists(table: string, where: WhereCondition): Promise<boolean> {
|
|
179
|
+
const { sql, params } = compileSelect(table, { select: ['1'], where, limit: 1 });
|
|
180
|
+
const row = await this.queryOne<Record<string, unknown>>(sql, params);
|
|
181
|
+
|
|
182
|
+
return row !== null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async aggregate<T = Record<string, unknown>>(table: string, options: QueryOptions): Promise<T[]> {
|
|
186
|
+
return this.findMany<T>(table, options);
|
|
187
|
+
}
|
|
188
|
+
|
|
169
189
|
async transaction<T>(operation: (tx: DatabaseClient) => Promise<T>): Promise<T> {
|
|
170
190
|
if (this.txDepth > 0) {
|
|
171
191
|
// Reentrant transaction: use SQLite SAVEPOINT
|