@geonosis/db 0.2.0 → 0.5.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/CHANGELOG.md +151 -0
- package/README.md +177 -8
- package/dist/index.cjs +1152 -171
- package/dist/index.d.cts +490 -78
- package/dist/index.d.ts +490 -78
- package/dist/index.js +1132 -167
- package/package.json +11 -3
package/dist/index.js
CHANGED
|
@@ -12,7 +12,7 @@ var DbRefusal = class extends Error {
|
|
|
12
12
|
|
|
13
13
|
// src/identifiers.ts
|
|
14
14
|
var quoted = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
15
|
-
var literal = (
|
|
15
|
+
var literal = (value2) => `'${value2.replaceAll("'", "''")}'`;
|
|
16
16
|
var qualified = (table, schema) => schema === void 0 ? quoted(table) : `${quoted(schema)}.${quoted(table)}`;
|
|
17
17
|
|
|
18
18
|
// src/app-role.ts
|
|
@@ -64,6 +64,123 @@ import {
|
|
|
64
64
|
ConformanceFailure,
|
|
65
65
|
rowsOf
|
|
66
66
|
} from "@geonosis/conformance";
|
|
67
|
+
|
|
68
|
+
// src/settings.ts
|
|
69
|
+
var DEFAULT_OPS_VALUE = "on";
|
|
70
|
+
var CUSTOM_SETTING = /^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$/;
|
|
71
|
+
var readSettings = (settings) => {
|
|
72
|
+
for (const named of ["opsSetting", "tenantSetting"]) {
|
|
73
|
+
const name = settings[named];
|
|
74
|
+
if (typeof name !== "string" || name === "") {
|
|
75
|
+
throw new DbRefusal(
|
|
76
|
+
`${named} is required and has no default: the name of a session setting is the one thing this package cannot pick for you, because the policies and the seam have to agree on it exactly`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (!CUSTOM_SETTING.test(name)) {
|
|
80
|
+
throw new DbRefusal(
|
|
81
|
+
`${named} is "${name}", and Postgres reads a setting an application defines as \`prefix.name\` \u2014 anything else is rejected as unrecognised the first time a session sets it`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { ...settings, opsValue: settings.opsValue ?? DEFAULT_OPS_VALUE };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// src/session.ts
|
|
89
|
+
var OPS = /* @__PURE__ */ Symbol("the ops lever");
|
|
90
|
+
var scopeOf = /* @__PURE__ */ new WeakMap();
|
|
91
|
+
var describe = (scope) => scope === OPS ? "the ops lever" : `tenant ${JSON.stringify(scope)}`;
|
|
92
|
+
var fragment = (chunks, values2 = []) => ({ chunks, values: values2 });
|
|
93
|
+
var asStatement = (piece) => ({
|
|
94
|
+
params: [...piece.values],
|
|
95
|
+
text: piece.chunks.map((chunk, at) => at === 0 ? chunk : `$${at}${chunk}`).join("")
|
|
96
|
+
});
|
|
97
|
+
var setConfigFragment = (settings) => {
|
|
98
|
+
if (settings.length === 0) return fragment(["select 1"]);
|
|
99
|
+
const chunks = ["select set_config("];
|
|
100
|
+
for (let at = 0; at < settings.length; at += 1) {
|
|
101
|
+
chunks.push(", ", at === settings.length - 1 ? ", true)" : ", true), set_config(");
|
|
102
|
+
}
|
|
103
|
+
return fragment(
|
|
104
|
+
chunks,
|
|
105
|
+
settings.flatMap((one) => [one.name, one.value])
|
|
106
|
+
);
|
|
107
|
+
};
|
|
108
|
+
var executorHandles = {
|
|
109
|
+
guard: (executor, stillOpen) => ({
|
|
110
|
+
// Spread, so what a driver put on the handle BESIDES the port reaches the query that was
|
|
111
|
+
// given it: a keeper that rebuilds the object hands back less than `open()` returned.
|
|
112
|
+
...executor,
|
|
113
|
+
execute: async (statement) => {
|
|
114
|
+
stillOpen();
|
|
115
|
+
return executor.execute(statement);
|
|
116
|
+
},
|
|
117
|
+
...typeof executor.transaction === "function" ? {
|
|
118
|
+
transaction: async (run) => {
|
|
119
|
+
stillOpen();
|
|
120
|
+
return executor.transaction(run);
|
|
121
|
+
}
|
|
122
|
+
} : {}
|
|
123
|
+
}),
|
|
124
|
+
opens: (executor) => typeof executor.transaction === "function",
|
|
125
|
+
send: (executor, statement) => executor.execute(asStatement(statement)),
|
|
126
|
+
transaction: (executor, run) => executor.transaction(run)
|
|
127
|
+
};
|
|
128
|
+
var DEFAULT_TENANT_KEY = "tenantId";
|
|
129
|
+
var STATEMENT_TIMEOUT = "statement_timeout";
|
|
130
|
+
var createSessionSeam = (config) => {
|
|
131
|
+
const settings = readSettings(config.settings);
|
|
132
|
+
const tenantKey = config.tenantKey ?? DEFAULT_TENANT_KEY;
|
|
133
|
+
const over = config.over ?? executorHandles;
|
|
134
|
+
if (tenantKey === "") {
|
|
135
|
+
throw new DbRefusal(
|
|
136
|
+
"tenantKey is empty, and it is the property the seam reads a query\u2019s tenant out of: name the key your queries carry, or leave it out for `tenantId`"
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const contextOf = (scope) => {
|
|
140
|
+
if (scope !== OPS) return [{ name: settings.tenantSetting, value: scope }];
|
|
141
|
+
const lever = { name: settings.opsSetting, value: settings.opsValue };
|
|
142
|
+
return config.opsStatementTimeout === void 0 ? [lever] : [lever, { name: STATEMENT_TIMEOUT, value: config.opsStatementTimeout }];
|
|
143
|
+
};
|
|
144
|
+
const opened = async (handle, scope, run) => {
|
|
145
|
+
const current = scopeOf.get(handle);
|
|
146
|
+
if (current === scope) return run(handle);
|
|
147
|
+
if (current !== void 0) {
|
|
148
|
+
throw new DbRefusal(
|
|
149
|
+
`this transaction is open for ${describe(current)} and was asked for ${describe(scope)}: a transaction names one scope, and the second query would run behind the first one's policy`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (!over.opens(handle)) {
|
|
153
|
+
throw new DbRefusal(
|
|
154
|
+
`this handle is in no session and cannot open a transaction, so ${describe(scope)} has nothing to name \u2014 pass the connection, or run it inside a session already open for that scope`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return over.transaction(handle, async (tx) => {
|
|
158
|
+
await over.send(tx, setConfigFragment(contextOf(scope)));
|
|
159
|
+
scopeOf.set(tx, scope);
|
|
160
|
+
return run(tx);
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
const named = (params) => {
|
|
164
|
+
const carried = params[tenantKey];
|
|
165
|
+
if (typeof carried !== "string" || carried === "") {
|
|
166
|
+
throw new DbRefusal(
|
|
167
|
+
`this query is scoped by "${tenantKey}" and its parameters carry no such value: the session would name no tenant at all, which is not a refusal but a query running outside every policy. Pass the tenant under "${tenantKey}", or build the seam with the key your own queries carry`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return carried;
|
|
171
|
+
};
|
|
172
|
+
return {
|
|
173
|
+
inOps: (handle, run) => opened(handle, OPS, run),
|
|
174
|
+
inTenant: (handle, tenantId, run) => opened(handle, tenantId, run),
|
|
175
|
+
scoped: (query) => async (handle, params) => opened(handle, named(params), (tx) => query(tx, params)),
|
|
176
|
+
scopedAsOps: (query) => (handle, params) => opened(handle, OPS, (tx) => query(tx, params)),
|
|
177
|
+
send: (handle, statement) => over.send(handle, statement),
|
|
178
|
+
tenantKey
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// src/conformance.ts
|
|
183
|
+
var asking = (subject) => subject.run ?? ((handle, statement) => handle.execute(asStatement(statement)));
|
|
67
184
|
var A = "tenant_conformance_a";
|
|
68
185
|
var B = "tenant_conformance_b";
|
|
69
186
|
var CONCURRENT = [
|
|
@@ -80,34 +197,45 @@ var probeSql = (probe) => {
|
|
|
80
197
|
const on = qualified(probe.name, probe.schema);
|
|
81
198
|
const tenant = quoted(probe.tenantColumn);
|
|
82
199
|
return {
|
|
83
|
-
insert: (tenantId, id) => (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
selectAll:
|
|
200
|
+
insert: (tenantId, id) => fragment(
|
|
201
|
+
[`insert into ${on} (${tenant}, ${quoted(probe.idColumn)}) values (`, ", ", ")"],
|
|
202
|
+
[tenantId, id]
|
|
203
|
+
),
|
|
204
|
+
selectAll: fragment([`select ${tenant} as tenant from ${on}`]),
|
|
205
|
+
selectBound: (ids) => fragment(
|
|
206
|
+
[
|
|
207
|
+
`select ${tenant} as tenant from ${on} where ${quoted(probe.idColumn)} = any(`,
|
|
208
|
+
") and ",
|
|
209
|
+
" > 0"
|
|
210
|
+
],
|
|
211
|
+
[ids, ids.length]
|
|
212
|
+
),
|
|
88
213
|
tenantsOf: (answer) => rowsOf(answer).map((row) => row.tenant).toSorted()
|
|
89
214
|
};
|
|
90
215
|
};
|
|
91
216
|
var written = async (subject, rows) => {
|
|
92
217
|
const probe = probeSql(subject.probe);
|
|
218
|
+
const ask = asking(subject);
|
|
93
219
|
for (const [tenantId, id] of rows) {
|
|
94
220
|
await subject.seam.inTenant(
|
|
95
221
|
subject.connection,
|
|
96
222
|
tenantId,
|
|
97
|
-
(tx) => tx
|
|
223
|
+
(tx) => ask(tx, probe.insert(tenantId, id))
|
|
98
224
|
);
|
|
99
225
|
}
|
|
100
226
|
};
|
|
101
227
|
var tenantIsolationConformance = (subject) => {
|
|
228
|
+
const ask = asking(subject);
|
|
102
229
|
const probe = probeSql(subject.probe);
|
|
103
230
|
return [
|
|
104
231
|
{
|
|
105
232
|
name: "the role the session connects as is no superuser",
|
|
106
233
|
run: async () => {
|
|
107
234
|
const [row] = rowsOf(
|
|
108
|
-
await
|
|
109
|
-
|
|
110
|
-
|
|
235
|
+
await ask(
|
|
236
|
+
subject.connection,
|
|
237
|
+
fragment([`select current_setting('is_superuser') as super`])
|
|
238
|
+
)
|
|
111
239
|
);
|
|
112
240
|
assertIs(
|
|
113
241
|
row?.super,
|
|
@@ -127,7 +255,7 @@ var tenantIsolationConformance = (subject) => {
|
|
|
127
255
|
const seen = await subject.seam.inTenant(
|
|
128
256
|
subject.connection,
|
|
129
257
|
A,
|
|
130
|
-
(tx) => tx
|
|
258
|
+
(tx) => ask(tx, probe.selectAll)
|
|
131
259
|
);
|
|
132
260
|
assertSame(
|
|
133
261
|
probe.tenantsOf(seen),
|
|
@@ -145,7 +273,7 @@ var tenantIsolationConformance = (subject) => {
|
|
|
145
273
|
[B, "row_b"]
|
|
146
274
|
]);
|
|
147
275
|
assertSame(
|
|
148
|
-
probe.tenantsOf(await subject.connection
|
|
276
|
+
probe.tenantsOf(await ask(subject.connection, probe.selectAll)),
|
|
149
277
|
[],
|
|
150
278
|
"a read outside every session answered with rows: the policy defaults to visible, so a query that forgets its session sees the whole table"
|
|
151
279
|
);
|
|
@@ -159,19 +287,40 @@ var tenantIsolationConformance = (subject) => {
|
|
|
159
287
|
() => subject.seam.inTenant(
|
|
160
288
|
subject.connection,
|
|
161
289
|
B,
|
|
162
|
-
(tx) => tx
|
|
290
|
+
(tx) => ask(tx, probe.insert(A, "row_forged"))
|
|
163
291
|
),
|
|
164
292
|
"a session naming one tenant wrote a row belonging to another \u2014 the isolation policy has a `using` clause and no `with check`, so it reads one way and writes any way"
|
|
165
293
|
);
|
|
166
294
|
assertSame(
|
|
167
295
|
probe.tenantsOf(
|
|
168
|
-
await subject.seam.inOps(subject.connection, (tx) => tx
|
|
296
|
+
await subject.seam.inOps(subject.connection, (tx) => ask(tx, probe.selectAll))
|
|
169
297
|
),
|
|
170
298
|
[],
|
|
171
299
|
"the forged write was reported as refused and the row is there anyway"
|
|
172
300
|
);
|
|
173
301
|
}
|
|
174
302
|
},
|
|
303
|
+
{
|
|
304
|
+
name: "a value the exam binds reaches the database as a parameter, an array included",
|
|
305
|
+
run: async () => {
|
|
306
|
+
await subject.reset();
|
|
307
|
+
await written(subject, [
|
|
308
|
+
[A, "row_a"],
|
|
309
|
+
[A, "row_b"],
|
|
310
|
+
[B, "row_c"]
|
|
311
|
+
]);
|
|
312
|
+
const seen = await subject.seam.inTenant(
|
|
313
|
+
subject.connection,
|
|
314
|
+
A,
|
|
315
|
+
(tx) => ask(tx, probe.selectBound(["row_a", "row_b"]))
|
|
316
|
+
);
|
|
317
|
+
assertSame(
|
|
318
|
+
probe.tenantsOf(seen),
|
|
319
|
+
[A, A],
|
|
320
|
+
"a read whose values were BOUND did not answer with the rows they name: a handle that renders a parameter into the statement\u2019s text \u2014 an array spliced into a list, a string quoted by hand \u2014 is a handle whose queries are built by concatenation, which is the injection this port\u2019s shape exists to make impossible"
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
},
|
|
175
324
|
{
|
|
176
325
|
name: "the ops lever sees every tenant",
|
|
177
326
|
run: async () => {
|
|
@@ -182,7 +331,7 @@ var tenantIsolationConformance = (subject) => {
|
|
|
182
331
|
]);
|
|
183
332
|
assertSame(
|
|
184
333
|
probe.tenantsOf(
|
|
185
|
-
await subject.seam.inOps(subject.connection, (tx) => tx
|
|
334
|
+
await subject.seam.inOps(subject.connection, (tx) => ask(tx, probe.selectAll))
|
|
186
335
|
),
|
|
187
336
|
[A, B].toSorted(),
|
|
188
337
|
"maintenance could not see across tenants, so every sweep, backfill and repair has to be run once per tenant or not at all"
|
|
@@ -201,6 +350,7 @@ var recorderOf = async (subject) => {
|
|
|
201
350
|
};
|
|
202
351
|
var counted = (statements, pattern) => statements.filter((one) => pattern.test(one)).length;
|
|
203
352
|
var statementCensusConformance = (subject) => {
|
|
353
|
+
const ask = asking(subject);
|
|
204
354
|
const probe = probeSql(subject.probe);
|
|
205
355
|
return [
|
|
206
356
|
{
|
|
@@ -209,7 +359,7 @@ var statementCensusConformance = (subject) => {
|
|
|
209
359
|
await subject.reset();
|
|
210
360
|
const recorded = await recorderOf(subject);
|
|
211
361
|
try {
|
|
212
|
-
await subject.seam.inTenant(recorded.connection, A, (tx) => tx
|
|
362
|
+
await subject.seam.inTenant(recorded.connection, A, (tx) => ask(tx, probe.selectAll));
|
|
213
363
|
} finally {
|
|
214
364
|
await recorded.close();
|
|
215
365
|
}
|
|
@@ -242,8 +392,8 @@ var statementCensusConformance = (subject) => {
|
|
|
242
392
|
const recorded = await recorderOf(subject);
|
|
243
393
|
try {
|
|
244
394
|
await subject.seam.inTenant(recorded.connection, A, async (tx) => {
|
|
245
|
-
await tx
|
|
246
|
-
await subject.seam.inTenant(tx, A, (inner) => inner
|
|
395
|
+
await ask(tx, probe.selectAll);
|
|
396
|
+
await subject.seam.inTenant(tx, A, (inner) => ask(inner, probe.selectAll));
|
|
247
397
|
});
|
|
248
398
|
} finally {
|
|
249
399
|
await recorded.close();
|
|
@@ -269,6 +419,7 @@ var statementCensusConformance = (subject) => {
|
|
|
269
419
|
];
|
|
270
420
|
};
|
|
271
421
|
var concurrentTenantsConformance = (subject) => {
|
|
422
|
+
const ask = asking(subject);
|
|
272
423
|
const probe = probeSql(subject.probe);
|
|
273
424
|
return [
|
|
274
425
|
{
|
|
@@ -280,13 +431,13 @@ var concurrentTenantsConformance = (subject) => {
|
|
|
280
431
|
(tenantId, index) => subject.seam.inTenant(
|
|
281
432
|
subject.connection,
|
|
282
433
|
tenantId,
|
|
283
|
-
(tx) => tx
|
|
434
|
+
(tx) => ask(tx, probe.insert(tenantId, `row_${index}`))
|
|
284
435
|
)
|
|
285
436
|
)
|
|
286
437
|
);
|
|
287
438
|
assertSame(
|
|
288
439
|
probe.tenantsOf(
|
|
289
|
-
await subject.seam.inOps(subject.connection, (tx) => tx
|
|
440
|
+
await subject.seam.inOps(subject.connection, (tx) => ask(tx, probe.selectAll))
|
|
290
441
|
),
|
|
291
442
|
CONCURRENT.toSorted(),
|
|
292
443
|
"writes made at the same moment did not each land under the tenant they named: the setting is not local to its transaction, so one connection carried another request's tenant"
|
|
@@ -302,13 +453,13 @@ var concurrentTenantsConformance = (subject) => {
|
|
|
302
453
|
(tenantId, index) => subject.seam.inTenant(
|
|
303
454
|
subject.connection,
|
|
304
455
|
tenantId,
|
|
305
|
-
(tx) => tx
|
|
456
|
+
(tx) => ask(tx, probe.insert(tenantId, `row_${index}`))
|
|
306
457
|
)
|
|
307
458
|
)
|
|
308
459
|
);
|
|
309
460
|
const seen = await Promise.all(
|
|
310
461
|
CONCURRENT.map(
|
|
311
|
-
(tenantId) => subject.seam.inTenant(subject.connection, tenantId, (tx) => tx
|
|
462
|
+
(tenantId) => subject.seam.inTenant(subject.connection, tenantId, (tx) => ask(tx, probe.selectAll)).then(probe.tenantsOf)
|
|
312
463
|
)
|
|
313
464
|
);
|
|
314
465
|
assertSame(
|
|
@@ -325,7 +476,7 @@ var concurrentTenantsConformance = (subject) => {
|
|
|
325
476
|
() => subject.seam.inTenant(
|
|
326
477
|
subject.connection,
|
|
327
478
|
A,
|
|
328
|
-
(tx) => subject.seam.inTenant(tx, B, (inner) => inner
|
|
479
|
+
(tx) => subject.seam.inTenant(tx, B, (inner) => ask(inner, probe.selectAll))
|
|
329
480
|
),
|
|
330
481
|
"a session open for one tenant accepted a query for another: whichever setting wins, one of the two queries runs behind the wrong policy",
|
|
331
482
|
// #211/#213: the SEAM's own refusal, by class — a driver that crashed throws too.
|
|
@@ -339,7 +490,7 @@ var concurrentTenantsConformance = (subject) => {
|
|
|
339
490
|
await assertRefuses(
|
|
340
491
|
() => subject.seam.inOps(
|
|
341
492
|
subject.connection,
|
|
342
|
-
(tx) => subject.seam.inTenant(tx, A, (inner) => inner
|
|
493
|
+
(tx) => subject.seam.inTenant(tx, A, (inner) => ask(inner, probe.selectAll))
|
|
343
494
|
),
|
|
344
495
|
"a tenant query reached from an ops sweep ran anyway \u2014 with no tenant named, behind the ops policy, which is every tenant",
|
|
345
496
|
DbRefusal
|
|
@@ -348,16 +499,77 @@ var concurrentTenantsConformance = (subject) => {
|
|
|
348
499
|
}
|
|
349
500
|
];
|
|
350
501
|
};
|
|
502
|
+
var scopedOf = (subject) => {
|
|
503
|
+
const scoped = subject.seam.scoped;
|
|
504
|
+
if (typeof scoped !== "function") {
|
|
505
|
+
throw new ConformanceFailure(
|
|
506
|
+
'this seam has no `scoped`, and "a query names its own tenant" is the claim that keeps every call site from naming one by hand: supply the wrapper \u2014 or this claim is untested rather than passing'
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
return scoped;
|
|
510
|
+
};
|
|
511
|
+
var scopedQueryConformance = (subject) => {
|
|
512
|
+
const ask = asking(subject);
|
|
513
|
+
const probe = probeSql(subject.probe);
|
|
514
|
+
const key = subject.seam.tenantKey ?? "tenantId";
|
|
515
|
+
const insert = () => scopedOf(subject)(
|
|
516
|
+
(tx, params) => ask(tx, probe.insert(params[key] ?? "", "row_scoped"))
|
|
517
|
+
);
|
|
518
|
+
return [
|
|
519
|
+
{
|
|
520
|
+
name: "a query scoped by its own parameters lands under the tenant they name",
|
|
521
|
+
run: async () => {
|
|
522
|
+
await subject.reset();
|
|
523
|
+
await insert()(subject.connection, { [key]: A });
|
|
524
|
+
assertSame(
|
|
525
|
+
probe.tenantsOf(
|
|
526
|
+
await subject.seam.inOps(subject.connection, (tx) => ask(tx, probe.selectAll))
|
|
527
|
+
),
|
|
528
|
+
[A],
|
|
529
|
+
`the row a query carrying its tenant under "${key}" wrote did not land under that tenant: the seam read the key it wanted rather than the one the query carries, and what it named instead was nothing`
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
name: "a scoped query for a second tenant inside a session is refused",
|
|
535
|
+
run: async () => {
|
|
536
|
+
await subject.reset();
|
|
537
|
+
await assertRefuses(
|
|
538
|
+
() => subject.seam.inTenant(subject.connection, B, (tx) => insert()(tx, { [key]: A })),
|
|
539
|
+
"a scoped query naming one tenant ran inside a session open for another: the wrapper opened nothing and the write went in behind the session it found",
|
|
540
|
+
DbRefusal
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
{
|
|
545
|
+
name: "a scoped query whose parameters carry no tenant is refused by that key\u2019s name",
|
|
546
|
+
run: async () => {
|
|
547
|
+
await subject.reset();
|
|
548
|
+
await assertRefuses(
|
|
549
|
+
() => insert()(subject.connection, {}),
|
|
550
|
+
`a query whose parameters carry no "${key}" was run anyway \u2014 with no tenant named at all, which is a query outside every session rather than a refusal`,
|
|
551
|
+
key
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
];
|
|
556
|
+
};
|
|
351
557
|
var forcedRowLevelSecurityConformance = (subject) => [
|
|
352
558
|
{
|
|
353
559
|
name: "every guarded table forces row level security",
|
|
354
560
|
run: async () => {
|
|
355
561
|
const tables = subject.guardedTables ?? [subject.probe.name];
|
|
356
562
|
const rows = rowsOf(
|
|
357
|
-
await subject
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
563
|
+
await asking(subject)(
|
|
564
|
+
subject.connection,
|
|
565
|
+
fragment(
|
|
566
|
+
[
|
|
567
|
+
"select relname as name, relforcerowsecurity as forced from pg_class where relname = any(",
|
|
568
|
+
")"
|
|
569
|
+
],
|
|
570
|
+
[tables]
|
|
571
|
+
)
|
|
572
|
+
)
|
|
361
573
|
);
|
|
362
574
|
assertSame(
|
|
363
575
|
rows.map((row) => row.name).toSorted(),
|
|
@@ -372,17 +584,140 @@ var forcedRowLevelSecurityConformance = (subject) => [
|
|
|
372
584
|
}
|
|
373
585
|
}
|
|
374
586
|
];
|
|
587
|
+
var surfaceOf = (handle) => {
|
|
588
|
+
const found = /* @__PURE__ */ new Set();
|
|
589
|
+
for (let at = handle; at !== null && at !== Object.prototype; at = Object.getPrototypeOf(at)) {
|
|
590
|
+
for (const name of Object.getOwnPropertyNames(at)) {
|
|
591
|
+
if (name !== "constructor") found.add(name);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return [...found].toSorted();
|
|
595
|
+
};
|
|
596
|
+
var perStepOf = (subject) => {
|
|
597
|
+
const perStep = subject.perStep;
|
|
598
|
+
if (typeof perStep !== "function") {
|
|
599
|
+
throw new ConformanceFailure(
|
|
600
|
+
'this subject has no `perStep`, and "a step\u2019s handle is not the next step\u2019s" is a claim about a durable run that hibernates between its steps: supply the per-step scope, named \u2014 or this claim is untested rather than passing'
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
return perStep;
|
|
604
|
+
};
|
|
605
|
+
var connectionsConformance = (subject) => {
|
|
606
|
+
const ask = asking(subject);
|
|
607
|
+
const probe = subject.probe ?? fragment(["select 1"]);
|
|
608
|
+
const CHARGE = "charge";
|
|
609
|
+
const SHIP = "ship";
|
|
610
|
+
return [
|
|
611
|
+
{
|
|
612
|
+
name: "a handle inside its invocation answers",
|
|
613
|
+
run: () => subject.connections.withConnection(async () => {
|
|
614
|
+
await ask(subject.connections.sessionDb(), probe);
|
|
615
|
+
})
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
name: "a handle keeps everything the driver put on it, so a query reaches what open() returned",
|
|
619
|
+
run: async () => {
|
|
620
|
+
const opening = subject.connections.open;
|
|
621
|
+
if (typeof opening !== "function") {
|
|
622
|
+
throw new ConformanceFailure(
|
|
623
|
+
'this subject has no `open`, and "the handle a query is given is the one the driver opened" cannot be asked without the two to compare: supply it \u2014 or this claim is untested rather than passing'
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
const held = opening();
|
|
627
|
+
try {
|
|
628
|
+
const opened = surfaceOf(held.connection);
|
|
629
|
+
await subject.connections.withConnection(() => {
|
|
630
|
+
const given = surfaceOf(subject.connections.sessionDb());
|
|
631
|
+
assertSame(
|
|
632
|
+
opened.filter((name) => !given.includes(name)),
|
|
633
|
+
[],
|
|
634
|
+
"the handle inside the invocation answers to less than the one the driver returned: a keeper that rebuilds the handle out of the port hands a query something its own library never made, and everything the driver attached \u2014 an ORM database, a logger, a schema \u2014 is undefined from inside every scoped query"
|
|
635
|
+
);
|
|
636
|
+
return Promise.resolve();
|
|
637
|
+
});
|
|
638
|
+
} finally {
|
|
639
|
+
await held.close();
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
name: "a handle asked for outside every invocation is refused, naming what to wrap it in",
|
|
645
|
+
run: () => assertRefuses(
|
|
646
|
+
() => Promise.resolve(subject.connections.sessionDb()),
|
|
647
|
+
"a handle was opened for a caller in no invocation, and nothing will ever close it: one leak per call, deferred to whoever reads the comment about it",
|
|
648
|
+
DbRefusal
|
|
649
|
+
)
|
|
650
|
+
},
|
|
651
|
+
{
|
|
652
|
+
name: "a handle used after its invocation ended is refused by name",
|
|
653
|
+
run: async () => {
|
|
654
|
+
let held;
|
|
655
|
+
await subject.connections.withConnection(async () => {
|
|
656
|
+
held = subject.connections.sessionDb();
|
|
657
|
+
await ask(held, probe);
|
|
658
|
+
});
|
|
659
|
+
await assertRefuses(
|
|
660
|
+
() => ask(held, probe),
|
|
661
|
+
"a handle answered a query after the invocation that opened it had ended: its pool is closed or closing, and what comes back is the driver\u2019s own error at some later line rather than a refusal naming the lifetime that was crossed",
|
|
662
|
+
DbRefusal
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
},
|
|
666
|
+
{
|
|
667
|
+
name: "a step inside a run gets a handle of its own, because workerd refuses I/O on another request\u2019s socket",
|
|
668
|
+
run: () => subject.connections.withConnection(async () => {
|
|
669
|
+
const ofTheRun = subject.connections.sessionDb();
|
|
670
|
+
await perStepOf(subject)(CHARGE)(async () => {
|
|
671
|
+
const ofTheStep = subject.connections.sessionDb();
|
|
672
|
+
assertThat(
|
|
673
|
+
ofTheStep !== ofTheRun,
|
|
674
|
+
"a step inside a run was handed the RUN\u2019s handle: a workflow entrypoint that opens an invocation around the run body hands every step a connection opened for another request, and workerd refuses to do I/O on it \u2014 the step joined the frame it was nested in rather than opening its own"
|
|
675
|
+
);
|
|
676
|
+
await ask(ofTheStep, probe);
|
|
677
|
+
});
|
|
678
|
+
await ask(ofTheRun, probe);
|
|
679
|
+
})
|
|
680
|
+
},
|
|
681
|
+
{
|
|
682
|
+
name: "a handle from one step is refused inside another, named with the step it came from",
|
|
683
|
+
run: async () => {
|
|
684
|
+
const perStep = perStepOf(subject);
|
|
685
|
+
let charged;
|
|
686
|
+
await perStep(CHARGE)(async () => {
|
|
687
|
+
charged = subject.connections.sessionDb();
|
|
688
|
+
await ask(charged, probe);
|
|
689
|
+
});
|
|
690
|
+
await perStep(SHIP)(
|
|
691
|
+
() => assertRefuses(
|
|
692
|
+
() => ask(charged, probe),
|
|
693
|
+
`the handle the "${CHARGE}" step opened answered a query inside the "${SHIP}" step: a durable run hibernates between its steps, so that handle is a dead socket, and the step that leaked it has to be in the refusal or the search starts at the top of the workflow`,
|
|
694
|
+
CHARGE
|
|
695
|
+
)
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
];
|
|
700
|
+
};
|
|
375
701
|
var sessionConformance = (subject) => [
|
|
376
702
|
...tenantIsolationConformance(subject),
|
|
377
703
|
...statementCensusConformance(subject),
|
|
378
704
|
...concurrentTenantsConformance(subject),
|
|
705
|
+
...scopedQueryConformance(subject),
|
|
379
706
|
...forcedRowLevelSecurityConformance(subject)
|
|
380
707
|
];
|
|
381
708
|
|
|
382
709
|
// src/connections.ts
|
|
383
710
|
import { AsyncLocalStorage } from "async_hooks";
|
|
384
711
|
var invocation = new AsyncLocalStorage();
|
|
712
|
+
var whose = (frame) => frame.named === void 0 ? "an invocation" : `the "${frame.named}" unit of work`;
|
|
713
|
+
var stillOpenIn = (frame) => () => {
|
|
714
|
+
if (frame.live) return;
|
|
715
|
+
throw new DbRefusal(
|
|
716
|
+
`this handle was opened in ${whose(frame)} and that invocation has ended: its connection is closed or closing, so a query on it now is a query on a dead socket. Open the handle inside the unit of work that uses it \u2014 a durable run hibernates between its steps, and a handle carried across one belongs to a request that is gone.`
|
|
717
|
+
);
|
|
718
|
+
};
|
|
385
719
|
var createConnections = (config) => {
|
|
720
|
+
const guard = config.over?.guard ?? executorHandles.guard;
|
|
386
721
|
const opened = () => config.open === void 0 ? config.driver.open(config.connectionString) : config.driver.open(config.connectionString, config.open);
|
|
387
722
|
return {
|
|
388
723
|
inInvocation: () => invocation.getStore() !== void 0,
|
|
@@ -394,27 +729,738 @@ var createConnections = (config) => {
|
|
|
394
729
|
"sessionDb() was called outside withConnection(), where a handle opened for it would be closed by nobody. Wrap the invocation \u2014 withConnection(() => \u2026, (closing) => ctx.waitUntil(closing)) \u2014 or call open() and close what you were given."
|
|
395
730
|
);
|
|
396
731
|
}
|
|
397
|
-
const known = frame.get(config.connectionString);
|
|
398
|
-
if (known !== void 0) return known.
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
732
|
+
const known = frame.held.get(config.connectionString);
|
|
733
|
+
if (known !== void 0) return known.guarded;
|
|
734
|
+
const open = opened();
|
|
735
|
+
const held = {
|
|
736
|
+
guarded: guard(open.connection, stillOpenIn(frame)),
|
|
737
|
+
open
|
|
738
|
+
};
|
|
739
|
+
frame.held.set(config.connectionString, held);
|
|
740
|
+
return held.guarded;
|
|
402
741
|
},
|
|
403
|
-
withConnection: async (run, release = (closing) => void closing) => {
|
|
404
|
-
if (invocation.getStore() !== void 0) return run();
|
|
405
|
-
const
|
|
742
|
+
withConnection: async (run, release = (closing) => void closing, options) => {
|
|
743
|
+
if (options?.own !== true && invocation.getStore() !== void 0) return run();
|
|
744
|
+
const frame = {
|
|
745
|
+
held: /* @__PURE__ */ new Map(),
|
|
746
|
+
live: true,
|
|
747
|
+
...options?.named === void 0 ? {} : { named: options.named }
|
|
748
|
+
};
|
|
406
749
|
try {
|
|
407
|
-
return await invocation.run(
|
|
750
|
+
return await invocation.run(frame, run);
|
|
408
751
|
} finally {
|
|
409
|
-
|
|
752
|
+
frame.live = false;
|
|
753
|
+
release(
|
|
754
|
+
Promise.all([...frame.held.values()].map((one) => one.open.close())).then(
|
|
755
|
+
() => void 0
|
|
756
|
+
)
|
|
757
|
+
);
|
|
410
758
|
}
|
|
411
759
|
}
|
|
412
760
|
};
|
|
413
761
|
};
|
|
414
762
|
var perStepConnection = (connections) => ({
|
|
415
|
-
perStep: () => (body) => connections.withConnection(body
|
|
763
|
+
perStep: (named) => (body) => connections.withConnection(body, void 0, {
|
|
764
|
+
own: true,
|
|
765
|
+
...named === void 0 ? {} : { named }
|
|
766
|
+
})
|
|
416
767
|
});
|
|
417
768
|
|
|
769
|
+
// src/drizzle.ts
|
|
770
|
+
var loadDrizzle = async () => {
|
|
771
|
+
let loaded;
|
|
772
|
+
try {
|
|
773
|
+
loaded = await import("drizzle-orm");
|
|
774
|
+
} catch (error) {
|
|
775
|
+
throw new DbRefusal(
|
|
776
|
+
"drizzle-orm is an optional peer dependency of @geonosis/db and is not installed \u2014 `npm install drizzle-orm`, or pass your own copy as `drizzleSession({ drizzleOrm })`",
|
|
777
|
+
{ cause: error }
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
return loaded.default ?? loaded;
|
|
781
|
+
};
|
|
782
|
+
var speaking = (module) => {
|
|
783
|
+
const sql2 = module?.sql;
|
|
784
|
+
if (typeof sql2?.join !== "function" || typeof sql2.param !== "function") {
|
|
785
|
+
throw new DbRefusal(
|
|
786
|
+
"what was given as drizzle-orm has no `sql` with `join`, `param` and `raw` \u2014 a statement is built out of those three, and whatever answered to that name is not the library this expects"
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
return sql2;
|
|
790
|
+
};
|
|
791
|
+
var database = (db) => {
|
|
792
|
+
if (typeof db?.execute !== "function" || typeof db.transaction !== "function") {
|
|
793
|
+
throw new DbRefusal(
|
|
794
|
+
"what was given as a drizzle database has no `execute` and `transaction` \u2014 a session is opened through the second and named through the first"
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
return db;
|
|
798
|
+
};
|
|
799
|
+
var guarding = (db, stillOpen) => new Proxy(db, {
|
|
800
|
+
get: (held, property) => {
|
|
801
|
+
stillOpen();
|
|
802
|
+
const value2 = Reflect.get(held, property);
|
|
803
|
+
return typeof value2 === "function" ? value2.bind(held) : value2;
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
var drizzleSession = async (options = {}) => {
|
|
807
|
+
const sql2 = speaking(options.drizzleOrm ?? await loadDrizzle());
|
|
808
|
+
const built = (statement) => sql2.join(
|
|
809
|
+
statement.chunks.flatMap(
|
|
810
|
+
(chunk, at) => at === 0 ? [sql2.raw(chunk)] : [sql2.param(statement.values[at - 1]), sql2.raw(chunk)]
|
|
811
|
+
)
|
|
812
|
+
);
|
|
813
|
+
return {
|
|
814
|
+
guard: guarding,
|
|
815
|
+
opens: (db) => typeof db?.transaction === "function",
|
|
816
|
+
send: async (db, statement) => await database(db).execute(built(statement)),
|
|
817
|
+
transaction: (db, run) => database(db).transaction((tx) => run(tx))
|
|
818
|
+
};
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
// src/entity.ts
|
|
822
|
+
import { rowsOf as rowsOf2 } from "@geonosis/conformance";
|
|
823
|
+
var then = (before, after2) => ({
|
|
824
|
+
chunks: [
|
|
825
|
+
...before.chunks.slice(0, -1),
|
|
826
|
+
`${before.chunks.at(-1) ?? ""}${after2.chunks[0] ?? ""}`,
|
|
827
|
+
...after2.chunks.slice(1)
|
|
828
|
+
],
|
|
829
|
+
values: [...before.values, ...after2.values]
|
|
830
|
+
});
|
|
831
|
+
var all = (pieces) => pieces.reduce((built, piece) => then(built, piece), fragment([""]));
|
|
832
|
+
var value = (held) => fragment(["", ""], [held]);
|
|
833
|
+
var sql = (text) => fragment([text]);
|
|
834
|
+
var DEFAULT_COLUMNS = {
|
|
835
|
+
body: "body",
|
|
836
|
+
createdAt: "created_at",
|
|
837
|
+
retiredAt: "retired_at",
|
|
838
|
+
tenant: "tenant_id",
|
|
839
|
+
updatedAt: "updated_at"
|
|
840
|
+
};
|
|
841
|
+
var DEFAULT_ENTITY_PAGE = 50;
|
|
842
|
+
var MAX_ENTITY_PAGE = 100;
|
|
843
|
+
var CURSOR = "cursor_";
|
|
844
|
+
var paged = (limit) => Math.min(Math.max(limit ?? DEFAULT_ENTITY_PAGE, 1), MAX_ENTITY_PAGE);
|
|
845
|
+
var stampOf = (order) => order.map((one) => `${one.column}:${one.direction ?? "asc"}`).join(",");
|
|
846
|
+
var readCursor = (cursor, order) => {
|
|
847
|
+
let held;
|
|
848
|
+
try {
|
|
849
|
+
held = JSON.parse(decodeURIComponent(cursor));
|
|
850
|
+
} catch {
|
|
851
|
+
throw new DbRefusal(
|
|
852
|
+
"this cursor was not minted by this listing: a page is reached by the key of the row before it, and that key is not readable here \u2014 an unreadable cursor answered with the first page again is a caller silently reading the list twice"
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
const [stamp, ...values2] = Array.isArray(held) ? held : [];
|
|
856
|
+
if (stamp !== stampOf(order) || values2.length !== order.length) {
|
|
857
|
+
throw new DbRefusal(
|
|
858
|
+
`this cursor was minted under another ordering or another width, and the rows after it are another list's: this one is ordered by ${stampOf(order)}`
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
return values2;
|
|
862
|
+
};
|
|
863
|
+
var after = (order, values2) => all([
|
|
864
|
+
sql(" and ("),
|
|
865
|
+
all(
|
|
866
|
+
order.map(
|
|
867
|
+
(column, depth) => all([
|
|
868
|
+
sql(depth === 0 ? "(" : " or ("),
|
|
869
|
+
all(
|
|
870
|
+
order.slice(0, depth).map(
|
|
871
|
+
(tied, at) => all([sql(`${quoted(tied.column)} = `), value(values2[at]), sql(" and ")])
|
|
872
|
+
)
|
|
873
|
+
),
|
|
874
|
+
sql(`${quoted(column.column)} ${column.direction === "desc" ? "<" : ">"} `),
|
|
875
|
+
value(values2[depth]),
|
|
876
|
+
sql(")")
|
|
877
|
+
])
|
|
878
|
+
)
|
|
879
|
+
),
|
|
880
|
+
sql(")")
|
|
881
|
+
]);
|
|
882
|
+
var defineEntity = (definition) => {
|
|
883
|
+
const columns = { ...DEFAULT_COLUMNS, ...definition.columns };
|
|
884
|
+
const { key, order, seam, table } = definition;
|
|
885
|
+
const on = qualified(table, definition.schema);
|
|
886
|
+
const tenantKey = seam.tenantKey;
|
|
887
|
+
const send = (handle, statement) => seam.send(handle, statement);
|
|
888
|
+
const keyed = (params) => params[key.param];
|
|
889
|
+
const withKey = (params, rest) => ({ [key.param]: params[key.param], ...rest });
|
|
890
|
+
const tenantOf = (params) => params[tenantKey];
|
|
891
|
+
const selected = [
|
|
892
|
+
`${quoted(key.column)} as "key"`,
|
|
893
|
+
`${quoted(columns.body)} as "body"`,
|
|
894
|
+
`${quoted(columns.createdAt)} as "createdAt"`,
|
|
895
|
+
`${quoted(columns.updatedAt)} as "updatedAt"`,
|
|
896
|
+
...order.map((one, at) => `${quoted(one.column)} as ${quoted(`${CURSOR}${at}`)}`)
|
|
897
|
+
].join(", ");
|
|
898
|
+
const alive = (tenant) => all([
|
|
899
|
+
sql(`select ${selected} from ${on} where ${quoted(columns.tenant)} = `),
|
|
900
|
+
value(tenant),
|
|
901
|
+
sql(` and ${quoted(columns.retiredAt)} is null`)
|
|
902
|
+
]);
|
|
903
|
+
const ordered = order.map((one) => `${quoted(one.column)} ${one.direction === "desc" ? "desc" : "asc"}`).join(", ");
|
|
904
|
+
const itemsOf = (rows) => rows.map((row) => definition.item(row));
|
|
905
|
+
const cursorOf = (row) => encodeURIComponent(
|
|
906
|
+
JSON.stringify([stampOf(order), ...order.map((_, at) => row[`${CURSOR}${at}`])])
|
|
907
|
+
);
|
|
908
|
+
const get = async (handle, params) => {
|
|
909
|
+
const [row] = rowsOf2(
|
|
910
|
+
await send(
|
|
911
|
+
handle,
|
|
912
|
+
all([
|
|
913
|
+
alive(tenantOf(params)),
|
|
914
|
+
sql(` and ${quoted(key.column)} = `),
|
|
915
|
+
value(keyed(params)),
|
|
916
|
+
sql(" limit 1")
|
|
917
|
+
])
|
|
918
|
+
)
|
|
919
|
+
);
|
|
920
|
+
return row === void 0 ? null : definition.item(row);
|
|
921
|
+
};
|
|
922
|
+
const listAll = async (handle, params) => itemsOf(
|
|
923
|
+
rowsOf2(
|
|
924
|
+
await send(handle, then(alive(tenantOf(params)), sql(` order by ${ordered}`)))
|
|
925
|
+
)
|
|
926
|
+
);
|
|
927
|
+
const list = async (handle, params) => {
|
|
928
|
+
const limit = paged(params.limit);
|
|
929
|
+
const rows = rowsOf2(
|
|
930
|
+
await send(
|
|
931
|
+
handle,
|
|
932
|
+
all([
|
|
933
|
+
alive(tenantOf(params)),
|
|
934
|
+
params.cursor === void 0 ? sql("") : after(order, readCursor(params.cursor, order)),
|
|
935
|
+
sql(` order by ${ordered} limit `),
|
|
936
|
+
value(limit + 1)
|
|
937
|
+
])
|
|
938
|
+
)
|
|
939
|
+
);
|
|
940
|
+
const page = rows.slice(0, limit);
|
|
941
|
+
const last = page.at(-1);
|
|
942
|
+
return {
|
|
943
|
+
items: itemsOf(page),
|
|
944
|
+
...rows.length > limit && last !== void 0 ? { nextCursor: cursorOf(last) } : {}
|
|
945
|
+
};
|
|
946
|
+
};
|
|
947
|
+
const upsert = async (handle, params) => {
|
|
948
|
+
const now = Date.now();
|
|
949
|
+
const indexed = definition.indexed?.(params.body) ?? {};
|
|
950
|
+
const names = Object.keys(indexed);
|
|
951
|
+
const tenant = tenantOf(params);
|
|
952
|
+
const [previous] = rowsOf2(
|
|
953
|
+
await send(
|
|
954
|
+
handle,
|
|
955
|
+
all([
|
|
956
|
+
sql(
|
|
957
|
+
`select ${quoted(columns.body)} as "body", ${quoted(columns.retiredAt)} as "retiredAt" from ${on} where ${quoted(columns.tenant)} = `
|
|
958
|
+
),
|
|
959
|
+
value(tenant),
|
|
960
|
+
sql(` and ${quoted(key.column)} = `),
|
|
961
|
+
value(keyed(params)),
|
|
962
|
+
sql(" for update")
|
|
963
|
+
])
|
|
964
|
+
)
|
|
965
|
+
);
|
|
966
|
+
const written2 = [
|
|
967
|
+
{ column: columns.body, held: params.body },
|
|
968
|
+
{ column: columns.updatedAt, held: now },
|
|
969
|
+
...names.map((name) => ({ column: name, held: indexed[name] }))
|
|
970
|
+
];
|
|
971
|
+
await send(
|
|
972
|
+
handle,
|
|
973
|
+
all([
|
|
974
|
+
sql(
|
|
975
|
+
`insert into ${on} (${[columns.tenant, key.column, columns.createdAt, columns.retiredAt, ...written2.map((one) => one.column)].map(quoted).join(", ")}) values (`
|
|
976
|
+
),
|
|
977
|
+
value(tenant),
|
|
978
|
+
sql(", "),
|
|
979
|
+
value(keyed(params)),
|
|
980
|
+
sql(", "),
|
|
981
|
+
value(now),
|
|
982
|
+
sql(", null"),
|
|
983
|
+
all(written2.map((one) => then(sql(", "), value(one.held)))),
|
|
984
|
+
sql(
|
|
985
|
+
`) on conflict (${quoted(columns.tenant)}, ${quoted(key.column)}) do update set ${quoted(columns.retiredAt)} = null, ${written2.map((one) => `${quoted(one.column)} = excluded.${quoted(one.column)}`).join(", ")}`
|
|
986
|
+
)
|
|
987
|
+
])
|
|
988
|
+
);
|
|
989
|
+
return withKey(params, {
|
|
990
|
+
previous: previous === void 0 || previous.retiredAt !== null ? null : previous.body
|
|
991
|
+
});
|
|
992
|
+
};
|
|
993
|
+
const retire = async (handle, params) => {
|
|
994
|
+
const now = Date.now();
|
|
995
|
+
const [row] = rowsOf2(
|
|
996
|
+
await send(
|
|
997
|
+
handle,
|
|
998
|
+
all([
|
|
999
|
+
sql(`update ${on} set ${quoted(columns.retiredAt)} = `),
|
|
1000
|
+
value(now),
|
|
1001
|
+
sql(`, ${quoted(columns.updatedAt)} = `),
|
|
1002
|
+
value(now),
|
|
1003
|
+
sql(` where ${quoted(columns.tenant)} = `),
|
|
1004
|
+
value(tenantOf(params)),
|
|
1005
|
+
sql(` and ${quoted(key.column)} = `),
|
|
1006
|
+
value(keyed(params)),
|
|
1007
|
+
sql(
|
|
1008
|
+
` and ${quoted(columns.retiredAt)} is null returning ${quoted(columns.body)} as "body"`
|
|
1009
|
+
)
|
|
1010
|
+
])
|
|
1011
|
+
)
|
|
1012
|
+
);
|
|
1013
|
+
return withKey(params, { retired: row === void 0 ? null : row.body });
|
|
1014
|
+
};
|
|
1015
|
+
const surface = {
|
|
1016
|
+
get: seam.scoped(get),
|
|
1017
|
+
list: seam.scoped(list),
|
|
1018
|
+
listAll: seam.scoped(listAll),
|
|
1019
|
+
retire: seam.scoped(retire),
|
|
1020
|
+
upsert: seam.scoped(upsert)
|
|
1021
|
+
};
|
|
1022
|
+
const addressed = (output, tenantId) => ({ [tenantKey]: tenantId, [key.param]: output[key.param] });
|
|
1023
|
+
return {
|
|
1024
|
+
...surface,
|
|
1025
|
+
// A write is undone by writing what was there before it, which is what the forward verb
|
|
1026
|
+
// answered with: nothing else has to be remembered anywhere.
|
|
1027
|
+
undos: {
|
|
1028
|
+
retire: async (handle, { output, tenantId }) => {
|
|
1029
|
+
if (output.retired === null) return;
|
|
1030
|
+
await surface.upsert(handle, { ...addressed(output, tenantId), body: output.retired });
|
|
1031
|
+
},
|
|
1032
|
+
upsert: async (handle, { output, tenantId }) => {
|
|
1033
|
+
await (output.previous === null ? surface.retire(handle, addressed(output, tenantId)) : surface.upsert(handle, {
|
|
1034
|
+
...addressed(output, tenantId),
|
|
1035
|
+
body: output.previous
|
|
1036
|
+
}));
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
};
|
|
1041
|
+
var undosOf = (entity) => entity.undos;
|
|
1042
|
+
|
|
1043
|
+
// src/entity-conformance.ts
|
|
1044
|
+
import { assertIs as assertIs2, assertSame as assertSame2, assertThat as assertThat2, ConformanceFailure as ConformanceFailure2 } from "@geonosis/conformance";
|
|
1045
|
+
var A2 = "tenant_entity_a";
|
|
1046
|
+
var B2 = "tenant_entity_b";
|
|
1047
|
+
var asking2 = (subject) => {
|
|
1048
|
+
const at = (tenantId, key) => ({
|
|
1049
|
+
[subject.tenantKey]: tenantId,
|
|
1050
|
+
...key === void 0 ? {} : { [subject.keyParam]: key }
|
|
1051
|
+
});
|
|
1052
|
+
return {
|
|
1053
|
+
at,
|
|
1054
|
+
get: (tenantId, key) => subject.entity.get(subject.connection, at(tenantId, key)),
|
|
1055
|
+
list: (tenantId, page = {}) => subject.entity.list(subject.connection, { ...at(tenantId), ...page }),
|
|
1056
|
+
listAll: (tenantId) => subject.entity.listAll(subject.connection, at(tenantId)),
|
|
1057
|
+
retire: (tenantId, key) => subject.entity.retire(subject.connection, at(tenantId, key)),
|
|
1058
|
+
upsert: (tenantId, key, body) => subject.entity.upsert(subject.connection, { ...at(tenantId, key), body })
|
|
1059
|
+
};
|
|
1060
|
+
};
|
|
1061
|
+
var entityConformance = (subject) => {
|
|
1062
|
+
const ask = asking2(subject);
|
|
1063
|
+
const [first, second] = subject.bodies;
|
|
1064
|
+
const [one, two, three] = subject.keys;
|
|
1065
|
+
const written2 = async (tenantId) => {
|
|
1066
|
+
for (const key of subject.keys) await ask.upsert(tenantId, key, first);
|
|
1067
|
+
};
|
|
1068
|
+
return [
|
|
1069
|
+
{
|
|
1070
|
+
name: "a row written is read back by its key",
|
|
1071
|
+
run: async () => {
|
|
1072
|
+
await subject.reset();
|
|
1073
|
+
await ask.upsert(A2, one, first);
|
|
1074
|
+
const found = await ask.get(A2, one);
|
|
1075
|
+
assertThat2(found !== null, "the row that was just written was read back as nothing");
|
|
1076
|
+
assertSame2(
|
|
1077
|
+
subject.bodyOf(found),
|
|
1078
|
+
first,
|
|
1079
|
+
"the row read back is not the body that was written"
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
},
|
|
1083
|
+
{
|
|
1084
|
+
name: "a key nothing was written under is nothing, not an empty row",
|
|
1085
|
+
run: async () => {
|
|
1086
|
+
await subject.reset();
|
|
1087
|
+
assertIs2(
|
|
1088
|
+
await ask.get(A2, one),
|
|
1089
|
+
null,
|
|
1090
|
+
"a key with no row behind it answered with something: a read that invents an empty row is a caller writing over nothing"
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
},
|
|
1094
|
+
{
|
|
1095
|
+
name: "an upsert answers with the body it replaced, and with nothing when it replaced nothing",
|
|
1096
|
+
run: async () => {
|
|
1097
|
+
await subject.reset();
|
|
1098
|
+
const created = await ask.upsert(A2, one, first);
|
|
1099
|
+
assertIs2(
|
|
1100
|
+
created.previous,
|
|
1101
|
+
null,
|
|
1102
|
+
"a write over an empty key answered with a previous body: its undo would restore a row that never existed"
|
|
1103
|
+
);
|
|
1104
|
+
const replaced = await ask.upsert(A2, one, second);
|
|
1105
|
+
assertSame2(
|
|
1106
|
+
replaced.previous,
|
|
1107
|
+
first,
|
|
1108
|
+
"a write over a living row did not answer with what it replaced, so nothing can put it back"
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
},
|
|
1112
|
+
{
|
|
1113
|
+
name: "every living row of one tenant is listed, in the listing order",
|
|
1114
|
+
run: async () => {
|
|
1115
|
+
await subject.reset();
|
|
1116
|
+
await written2(A2);
|
|
1117
|
+
assertIs2(
|
|
1118
|
+
(await ask.listAll(A2)).length,
|
|
1119
|
+
3,
|
|
1120
|
+
"the listing answered with a different number of rows than were written"
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
},
|
|
1124
|
+
{
|
|
1125
|
+
name: "a page carries a cursor while rows remain, and none on the last page",
|
|
1126
|
+
run: async () => {
|
|
1127
|
+
await subject.reset();
|
|
1128
|
+
await written2(A2);
|
|
1129
|
+
const page = await ask.list(A2, { limit: 2 });
|
|
1130
|
+
assertIs2(page.items.length, 2, "a page of two answered with another number of rows");
|
|
1131
|
+
assertThat2(
|
|
1132
|
+
page.nextCursor !== void 0,
|
|
1133
|
+
"a page with rows behind it carried no cursor, so nothing can reach them"
|
|
1134
|
+
);
|
|
1135
|
+
const last = await ask.list(A2, { cursor: page.nextCursor, limit: 2 });
|
|
1136
|
+
assertIs2(last.items.length, 1, "the page after the cursor answered with the wrong rows");
|
|
1137
|
+
assertIs2(
|
|
1138
|
+
last.nextCursor,
|
|
1139
|
+
void 0,
|
|
1140
|
+
"the last page carried a cursor: a caller who follows it asks for a page that is not there, and a list that never ends is how a walk becomes a loop"
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
},
|
|
1144
|
+
{
|
|
1145
|
+
name: "a walk one row at a time visits every row exactly once",
|
|
1146
|
+
run: async () => {
|
|
1147
|
+
await subject.reset();
|
|
1148
|
+
await written2(A2);
|
|
1149
|
+
const seen = [];
|
|
1150
|
+
let cursor;
|
|
1151
|
+
for (let page = 0; page < subject.keys.length + 1; page += 1) {
|
|
1152
|
+
const answered = await ask.list(A2, { cursor, limit: 1 });
|
|
1153
|
+
seen.push(...answered.items);
|
|
1154
|
+
cursor = answered.nextCursor;
|
|
1155
|
+
if (cursor === void 0) break;
|
|
1156
|
+
}
|
|
1157
|
+
assertIs2(
|
|
1158
|
+
seen.length,
|
|
1159
|
+
subject.keys.length,
|
|
1160
|
+
"the walk saw a different number of rows than the table holds: a keyset that repeats a row or skips one is a page boundary landing on a tie"
|
|
1161
|
+
);
|
|
1162
|
+
}
|
|
1163
|
+
},
|
|
1164
|
+
{
|
|
1165
|
+
name: "a retired row is gone from every read",
|
|
1166
|
+
run: async () => {
|
|
1167
|
+
await subject.reset();
|
|
1168
|
+
await ask.upsert(A2, one, first);
|
|
1169
|
+
const retired = await ask.retire(A2, one);
|
|
1170
|
+
assertSame2(retired.retired, first, "a retire did not answer with the body it took away");
|
|
1171
|
+
assertIs2(await ask.get(A2, one), null, "a retired row was still read back by its key");
|
|
1172
|
+
assertIs2((await ask.listAll(A2)).length, 0, "a retired row was still listed");
|
|
1173
|
+
}
|
|
1174
|
+
},
|
|
1175
|
+
{
|
|
1176
|
+
name: "the undo of an upsert puts back the body that was there",
|
|
1177
|
+
run: async () => {
|
|
1178
|
+
await subject.reset();
|
|
1179
|
+
await ask.upsert(A2, one, first);
|
|
1180
|
+
const replaced = await ask.upsert(A2, one, second);
|
|
1181
|
+
await subject.entity.undos.upsert(subject.connection, {
|
|
1182
|
+
output: replaced,
|
|
1183
|
+
tenantId: A2
|
|
1184
|
+
});
|
|
1185
|
+
const found = await ask.get(A2, one);
|
|
1186
|
+
assertSame2(
|
|
1187
|
+
found === null ? null : subject.bodyOf(found),
|
|
1188
|
+
first,
|
|
1189
|
+
"the compensation for a write left the new body in place: a run that failed after this step is a run whose write stood"
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
},
|
|
1193
|
+
{
|
|
1194
|
+
name: "the undo of an upsert that created the row retires it",
|
|
1195
|
+
run: async () => {
|
|
1196
|
+
await subject.reset();
|
|
1197
|
+
const created = await ask.upsert(A2, one, first);
|
|
1198
|
+
await subject.entity.undos.upsert(subject.connection, { output: created, tenantId: A2 });
|
|
1199
|
+
assertIs2(
|
|
1200
|
+
await ask.get(A2, one),
|
|
1201
|
+
null,
|
|
1202
|
+
"the compensation for a write that created a row left the row behind, because it restored a body that was never there"
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
},
|
|
1206
|
+
{
|
|
1207
|
+
name: "the undo of a retire writes the row back",
|
|
1208
|
+
run: async () => {
|
|
1209
|
+
await subject.reset();
|
|
1210
|
+
await ask.upsert(A2, one, first);
|
|
1211
|
+
const retired = await ask.retire(A2, one);
|
|
1212
|
+
await subject.entity.undos.retire(subject.connection, { output: retired, tenantId: A2 });
|
|
1213
|
+
const found = await ask.get(A2, one);
|
|
1214
|
+
assertSame2(
|
|
1215
|
+
found === null ? null : subject.bodyOf(found),
|
|
1216
|
+
first,
|
|
1217
|
+
"the compensation for a retire did not bring the row back"
|
|
1218
|
+
);
|
|
1219
|
+
}
|
|
1220
|
+
},
|
|
1221
|
+
{
|
|
1222
|
+
name: "the undo of a retire that retired nothing writes nothing",
|
|
1223
|
+
run: async () => {
|
|
1224
|
+
await subject.reset();
|
|
1225
|
+
const retired = await ask.retire(A2, one);
|
|
1226
|
+
await subject.entity.undos.retire(subject.connection, { output: retired, tenantId: A2 });
|
|
1227
|
+
assertIs2(
|
|
1228
|
+
(await ask.listAll(A2)).length,
|
|
1229
|
+
0,
|
|
1230
|
+
"the compensation for a retire that found no row wrote one: an undo that invents a row is worse than one that does nothing"
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
},
|
|
1234
|
+
{
|
|
1235
|
+
name: "a write in a session is five statements, the row it replaces locked before the insert",
|
|
1236
|
+
run: async () => {
|
|
1237
|
+
const recording = subject.recording;
|
|
1238
|
+
if (typeof recording !== "function") {
|
|
1239
|
+
throw new ConformanceFailure2(
|
|
1240
|
+
"this subject has no `recording`, and the write census is a count of what reached the server: supply a handle that reports the statements it sends \u2014 a driver log hook is usually one line \u2014 or this claim is untested rather than passing"
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
await subject.reset();
|
|
1244
|
+
const recorded = await recording();
|
|
1245
|
+
try {
|
|
1246
|
+
await subject.entity.upsert(recorded.connection, {
|
|
1247
|
+
...ask.at(A2, one),
|
|
1248
|
+
body: first
|
|
1249
|
+
});
|
|
1250
|
+
} finally {
|
|
1251
|
+
await recorded.close();
|
|
1252
|
+
}
|
|
1253
|
+
const { statements } = recorded;
|
|
1254
|
+
const found = (pattern) => statements.filter((sent) => pattern.test(sent)).length;
|
|
1255
|
+
assertIs2(
|
|
1256
|
+
found(/savepoint/i),
|
|
1257
|
+
0,
|
|
1258
|
+
`the write opened a savepoint, which means a nested transaction per statement: ${statements.join(" | ")}`
|
|
1259
|
+
);
|
|
1260
|
+
assertIs2(
|
|
1261
|
+
found(/set_config/i),
|
|
1262
|
+
1,
|
|
1263
|
+
`the tenant was named ${found(/set_config/i)} times for one write: ${statements.join(" | ")}`
|
|
1264
|
+
);
|
|
1265
|
+
assertThat2(
|
|
1266
|
+
statements.findIndex((sent) => /for update/i.test(sent)) < statements.findIndex((sent) => /on conflict/i.test(sent)),
|
|
1267
|
+
`the row was written before it was read under a lock, or never locked at all: at READ COMMITTED the pre-image is then whatever a concurrent commit left, and the undo restores a version this write never replaced \u2014 ${statements.join(" | ")}`
|
|
1268
|
+
);
|
|
1269
|
+
assertIs2(
|
|
1270
|
+
statements.length,
|
|
1271
|
+
5,
|
|
1272
|
+
`a write in a session is the transaction, the tenant, the locked read, the write and the commit: a sixth is a round trip somebody added \u2014 ${statements.join(" | ")}`
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
},
|
|
1276
|
+
{
|
|
1277
|
+
name: "the wall on this entity\u2019s table reads the setting names it was rendered with",
|
|
1278
|
+
run: async () => {
|
|
1279
|
+
const wall = subject.wall;
|
|
1280
|
+
if (wall === void 0) {
|
|
1281
|
+
throw new ConformanceFailure2(
|
|
1282
|
+
'this subject names no `wall`, and "the DDL, the policies and the session name the same two settings" is the claim a table rendered with the defaults cannot make: supply the settings, the ops lever and an unfiltered count \u2014 or this claim is untested rather than passing'
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
await subject.reset();
|
|
1286
|
+
await ask.upsert(A2, one, first);
|
|
1287
|
+
await ask.upsert(B2, two, second);
|
|
1288
|
+
assertIs2(
|
|
1289
|
+
(await ask.listAll(A2)).length,
|
|
1290
|
+
1,
|
|
1291
|
+
`a tenant saw more than its own rows on a table whose isolation policy reads "${wall.settings.tenantSetting}": the DDL was rendered with one setting name and the session names another, so the policy compares against nothing`
|
|
1292
|
+
);
|
|
1293
|
+
assertIs2(
|
|
1294
|
+
await wall.inOps((tx) => wall.countAll(tx)),
|
|
1295
|
+
2,
|
|
1296
|
+
`the ops lever saw ${await wall.inOps((tx) => wall.countAll(tx))} of 2 rows on a table whose maintenance policy reads "${wall.settings.opsSetting}": every sweep, backfill and repair on this entity has to be run once per tenant or not at all`
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
},
|
|
1300
|
+
{
|
|
1301
|
+
name: "one tenant never reaches another\u2019s rows through any verb",
|
|
1302
|
+
run: async () => {
|
|
1303
|
+
await subject.reset();
|
|
1304
|
+
await ask.upsert(A2, one, first);
|
|
1305
|
+
await ask.upsert(B2, two, second);
|
|
1306
|
+
assertIs2(await ask.get(B2, one), null, "a tenant read a row belonging to another by its key");
|
|
1307
|
+
assertIs2(
|
|
1308
|
+
(await ask.listAll(B2)).length,
|
|
1309
|
+
1,
|
|
1310
|
+
"a tenant listed rows belonging to another: the verbs are scoped, the wall is not, or both"
|
|
1311
|
+
);
|
|
1312
|
+
assertIs2(
|
|
1313
|
+
(await ask.retire(B2, one)).retired,
|
|
1314
|
+
null,
|
|
1315
|
+
"a tenant retired a row belonging to another, and was told what it took away"
|
|
1316
|
+
);
|
|
1317
|
+
assertThat2(
|
|
1318
|
+
await ask.get(A2, one) !== null,
|
|
1319
|
+
"the row of the first tenant is gone after the second tried to retire it"
|
|
1320
|
+
);
|
|
1321
|
+
assertIs2(await ask.get(A2, three), null, "a key nobody wrote answered with a row");
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
];
|
|
1325
|
+
};
|
|
1326
|
+
|
|
1327
|
+
// src/policies.ts
|
|
1328
|
+
var settingRead = (name) => `(select current_setting(${literal(name)}, true))`;
|
|
1329
|
+
var both = (predicate) => `using (${predicate}) with check (${predicate})`;
|
|
1330
|
+
var DEFAULT_NAMES = {
|
|
1331
|
+
freeze: "not_frozen",
|
|
1332
|
+
isolation: "tenant_isolation",
|
|
1333
|
+
opsMaintenance: "ops_maintenance"
|
|
1334
|
+
};
|
|
1335
|
+
var TOKEN = /^\{\{[A-Za-z][A-Za-z0-9_]*\}\}$/;
|
|
1336
|
+
var templated = (settings) => TOKEN.test(settings.tenantSetting) || TOKEN.test(settings.opsSetting);
|
|
1337
|
+
var readForDdl = (settings) => templated(settings) ? { opsValue: settings.opsValue ?? DEFAULT_OPS_VALUE, ...settings } : readSettings(settings);
|
|
1338
|
+
var tenantPolicySet = (options) => {
|
|
1339
|
+
const settings = readForDdl(options.settings);
|
|
1340
|
+
const names = { ...DEFAULT_NAMES, ...options.names };
|
|
1341
|
+
const tenant = settingRead(settings.tenantSetting);
|
|
1342
|
+
const opsIsOn = `${settingRead(settings.opsSetting)} = ${literal(settings.opsValue)}`;
|
|
1343
|
+
const frozen = options.freeze;
|
|
1344
|
+
return [
|
|
1345
|
+
{
|
|
1346
|
+
as: "permissive",
|
|
1347
|
+
name: names.isolation,
|
|
1348
|
+
predicate: `${quoted(options.tenantColumn)} = ${tenant}`
|
|
1349
|
+
},
|
|
1350
|
+
{ as: "permissive", name: names.opsMaintenance, predicate: opsIsOn },
|
|
1351
|
+
...frozen === void 0 ? [] : [
|
|
1352
|
+
{
|
|
1353
|
+
as: "restrictive",
|
|
1354
|
+
name: names.freeze,
|
|
1355
|
+
predicate: `${opsIsOn} or not exists (select 1 from ${quoted(frozen.table)} where ${quoted(frozen.tenantColumn)} = ${tenant})`
|
|
1356
|
+
}
|
|
1357
|
+
]
|
|
1358
|
+
];
|
|
1359
|
+
};
|
|
1360
|
+
var drizzleTenantPolicies = (tools, options) => tenantPolicySet(options).map(
|
|
1361
|
+
(policy) => tools.pgPolicy(policy.name, {
|
|
1362
|
+
as: policy.as,
|
|
1363
|
+
for: "all",
|
|
1364
|
+
using: tools.sql.raw(policy.predicate),
|
|
1365
|
+
withCheck: tools.sql.raw(policy.predicate)
|
|
1366
|
+
})
|
|
1367
|
+
);
|
|
1368
|
+
var forceRowLevelSecurity = (table, schema) => `alter table ${qualified(table, schema)} force row level security`;
|
|
1369
|
+
var tenantPolicies = (table, options) => {
|
|
1370
|
+
const on = qualified(table, options.schema);
|
|
1371
|
+
return [
|
|
1372
|
+
`alter table ${on} enable row level security`,
|
|
1373
|
+
forceRowLevelSecurity(table, options.schema),
|
|
1374
|
+
...tenantPolicySet(options).flatMap((policy) => [
|
|
1375
|
+
`drop policy if exists ${quoted(policy.name)} on ${on}`,
|
|
1376
|
+
`create policy ${quoted(policy.name)} on ${on} as ${policy.as} for all ${both(policy.predicate)}`
|
|
1377
|
+
])
|
|
1378
|
+
];
|
|
1379
|
+
};
|
|
1380
|
+
|
|
1381
|
+
// src/entity-ddl.ts
|
|
1382
|
+
var ENTITY_PLACEHOLDERS = {
|
|
1383
|
+
opsSetting: "{{opsSetting}}",
|
|
1384
|
+
tenantSetting: "{{tenantSetting}}"
|
|
1385
|
+
};
|
|
1386
|
+
var DEFAULT_COLUMNS2 = {
|
|
1387
|
+
body: "body",
|
|
1388
|
+
createdAt: "created_at",
|
|
1389
|
+
retiredAt: "retired_at",
|
|
1390
|
+
tenant: "tenant_id",
|
|
1391
|
+
updatedAt: "updated_at"
|
|
1392
|
+
};
|
|
1393
|
+
var NAME = /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
|
1394
|
+
var readName = (name) => {
|
|
1395
|
+
if (!NAME.test(name)) {
|
|
1396
|
+
throw new DbRefusal(
|
|
1397
|
+
`"${name}" is not a name this DDL can write: it is inlined into every statement inside double quotes, and a name outside \`letter_or_underscore followed by letters, digits, underscores or $\` could close them`
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
return name;
|
|
1401
|
+
};
|
|
1402
|
+
var accepted = (because, rules, statement) => `-- ${because}
|
|
1403
|
+
-- squawk-ignore ${rules.join(", ")}
|
|
1404
|
+
${statement}`;
|
|
1405
|
+
var TIMEOUTS_BELONG_TO_THE_DEPLOYMENT = "lock_timeout and statement_timeout are one deployment\u2019s numbers, and a value written into shipped DDL imposes that deployment\u2019s budget on every consumer: set them in the session that migrates.";
|
|
1406
|
+
var IN_ONE_TRANSACTION = "The migrator applies this file inside a transaction, which is also why CONCURRENTLY is not available here; the index is created in the same migration as its table, over no rows.";
|
|
1407
|
+
var RERUNNABLE = "This file is applied inside a transaction and every statement in it is idempotent, so a run that fails part way through leaves nothing behind and a rerun is a no-op.";
|
|
1408
|
+
var settingRead2 = (name) => `current_setting(${literal(name)}, true)`;
|
|
1409
|
+
var columnOf = (one) => typeof one === "string" ? `${quoted(readName(one))} text` : `${quoted(readName(one.column))} ${one.type}`;
|
|
1410
|
+
var entityTable = (options) => {
|
|
1411
|
+
const columns = { ...DEFAULT_COLUMNS2, ...options.columns };
|
|
1412
|
+
const table = readName(options.table);
|
|
1413
|
+
const on = qualified(table, options.schema);
|
|
1414
|
+
const key = readName(options.key);
|
|
1415
|
+
const listing = options.order.map((one) => `${quoted(readName(one.column))} ${one.direction === "desc" ? "desc" : "asc"}`).join(", ");
|
|
1416
|
+
return [
|
|
1417
|
+
`create table if not exists ${on} (
|
|
1418
|
+
${quoted(columns.tenant)} text not null default ${settingRead2(options.settings.tenantSetting)},
|
|
1419
|
+
${quoted(key)} text not null,
|
|
1420
|
+
${quoted(columns.body)} jsonb not null,
|
|
1421
|
+
${quoted(columns.createdAt)} bigint not null,
|
|
1422
|
+
${quoted(columns.updatedAt)} bigint not null,
|
|
1423
|
+
${quoted(columns.retiredAt)} bigint,${(options.indexed ?? []).map((one) => `
|
|
1424
|
+
${columnOf(one)},`).join("")}
|
|
1425
|
+
constraint ${quoted(`${table}_pkey`)} primary key (${quoted(columns.tenant)}, ${quoted(key)})
|
|
1426
|
+
)`,
|
|
1427
|
+
accepted(
|
|
1428
|
+
`${IN_ONE_TRANSACTION} ${TIMEOUTS_BELONG_TO_THE_DEPLOYMENT}`,
|
|
1429
|
+
["require-concurrent-index-creation", "require-lock-timeout", "require-statement-timeout"],
|
|
1430
|
+
`create index if not exists ${quoted(`${table}_listing_idx`)} on ${on} (${quoted(columns.tenant)}, ${listing})`
|
|
1431
|
+
)
|
|
1432
|
+
];
|
|
1433
|
+
};
|
|
1434
|
+
var both2 = (predicate) => `using (${predicate}) with check (${predicate})`;
|
|
1435
|
+
var entityPolicies = (options) => {
|
|
1436
|
+
const columns = { ...DEFAULT_COLUMNS2, ...options.columns };
|
|
1437
|
+
const on = qualified(readName(options.table), options.schema);
|
|
1438
|
+
const wall = {
|
|
1439
|
+
settings: options.settings,
|
|
1440
|
+
tenantColumn: columns.tenant,
|
|
1441
|
+
...options.schema === void 0 ? {} : { schema: options.schema }
|
|
1442
|
+
};
|
|
1443
|
+
return [
|
|
1444
|
+
accepted(
|
|
1445
|
+
`${RERUNNABLE} ${TIMEOUTS_BELONG_TO_THE_DEPLOYMENT}`,
|
|
1446
|
+
["prefer-robust-stmts", "require-lock-timeout", "require-statement-timeout"],
|
|
1447
|
+
`alter table ${on} enable row level security`
|
|
1448
|
+
),
|
|
1449
|
+
accepted(RERUNNABLE, ["prefer-robust-stmts"], `alter table ${on} force row level security`),
|
|
1450
|
+
...tenantPolicySet(wall).flatMap((policy) => [
|
|
1451
|
+
`drop policy if exists ${quoted(policy.name)} on ${on}`,
|
|
1452
|
+
`create policy ${quoted(policy.name)} on ${on} as ${policy.as} for all ${both2(policy.predicate)}`
|
|
1453
|
+
])
|
|
1454
|
+
];
|
|
1455
|
+
};
|
|
1456
|
+
var entityMigrations = (options) => {
|
|
1457
|
+
const table = readName(options.table);
|
|
1458
|
+
return [
|
|
1459
|
+
{ name: `0001_${table}.sql`, statements: entityTable(options) },
|
|
1460
|
+
{ name: `0002_${table}_policies.sql`, statements: entityPolicies(options) }
|
|
1461
|
+
];
|
|
1462
|
+
};
|
|
1463
|
+
|
|
418
1464
|
// src/node-postgres.ts
|
|
419
1465
|
import { readdir, readFile } from "fs/promises";
|
|
420
1466
|
import { join } from "path";
|
|
@@ -440,10 +1486,10 @@ var drivingPg = (module) => {
|
|
|
440
1486
|
};
|
|
441
1487
|
var values = (statement) => statement.params === void 0 ? void 0 : [...statement.params];
|
|
442
1488
|
var DEFAULT_MIGRATIONS_TABLE = "geonosis_db_migrations";
|
|
443
|
-
var nodePostgresDriver = async (options = {}) => {
|
|
1489
|
+
var nodePostgresDriver = (async (options = {}) => {
|
|
444
1490
|
const pg = drivingPg(options.pg ?? await loadPg());
|
|
445
1491
|
const ledger = quoted(options.migrationsTable ?? DEFAULT_MIGRATIONS_TABLE);
|
|
446
|
-
const
|
|
1492
|
+
const openPort = (connectionString, opened = {}) => {
|
|
447
1493
|
const say = opened.onStatement ?? (() => {
|
|
448
1494
|
});
|
|
449
1495
|
const pool = new pg.Pool({
|
|
@@ -456,31 +1502,33 @@ var nodePostgresDriver = async (options = {}) => {
|
|
|
456
1502
|
say(statement.text);
|
|
457
1503
|
return run(statement.text, values(statement));
|
|
458
1504
|
};
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
} finally {
|
|
479
|
-
client.release();
|
|
480
|
-
}
|
|
1505
|
+
const overThePort = {
|
|
1506
|
+
execute: (statement) => sent((text, params) => pool.query(text, params), statement),
|
|
1507
|
+
transaction: async (run) => {
|
|
1508
|
+
const client = await pool.connect();
|
|
1509
|
+
try {
|
|
1510
|
+
say("begin");
|
|
1511
|
+
await client.query("begin");
|
|
1512
|
+
const result = await run({
|
|
1513
|
+
execute: (statement) => sent((text, params) => client.query(text, params), statement)
|
|
1514
|
+
});
|
|
1515
|
+
say("commit");
|
|
1516
|
+
await client.query("commit");
|
|
1517
|
+
return result;
|
|
1518
|
+
} catch (error) {
|
|
1519
|
+
say("rollback");
|
|
1520
|
+
await client.query("rollback").catch(() => void 0);
|
|
1521
|
+
throw error;
|
|
1522
|
+
} finally {
|
|
1523
|
+
client.release();
|
|
481
1524
|
}
|
|
482
1525
|
}
|
|
483
1526
|
};
|
|
1527
|
+
return { close: () => pool.end(), connection: overThePort, pool };
|
|
1528
|
+
};
|
|
1529
|
+
const open = (connectionString, opened = {}) => {
|
|
1530
|
+
const held = openPort(connectionString, opened);
|
|
1531
|
+
return options.drizzle === void 0 ? held : { close: held.close, connection: options.drizzle(held.pool, opened) };
|
|
484
1532
|
};
|
|
485
1533
|
return {
|
|
486
1534
|
/**
|
|
@@ -489,21 +1537,21 @@ var nodePostgresDriver = async (options = {}) => {
|
|
|
489
1537
|
* of their own passes their own `Driver` and this is never called.
|
|
490
1538
|
*/
|
|
491
1539
|
migrate: async (connectionString, migrationsFolder) => {
|
|
492
|
-
const held =
|
|
1540
|
+
const held = openPort(connectionString, { poolSize: 1 });
|
|
493
1541
|
try {
|
|
494
1542
|
await held.connection.execute({
|
|
495
1543
|
text: `create table if not exists ${ledger} (name text primary key, applied_at timestamptz not null default now())`
|
|
496
1544
|
});
|
|
497
1545
|
const files = (await readdir(migrationsFolder)).filter((name) => name.endsWith(".sql")).toSorted();
|
|
498
1546
|
for (const name of files) {
|
|
499
|
-
const
|
|
1547
|
+
const sql2 = await readFile(join(migrationsFolder, name), "utf8");
|
|
500
1548
|
await held.connection.transaction(async (tx) => {
|
|
501
1549
|
const applied = await tx.execute({
|
|
502
1550
|
params: [name],
|
|
503
1551
|
text: `select name from ${ledger} where name = $1`
|
|
504
1552
|
});
|
|
505
1553
|
if (applied.rows.length > 0) return;
|
|
506
|
-
await tx.execute({ text:
|
|
1554
|
+
await tx.execute({ text: sql2 });
|
|
507
1555
|
await tx.execute({ params: [name], text: `insert into ${ledger} (name) values ($1)` });
|
|
508
1556
|
});
|
|
509
1557
|
}
|
|
@@ -513,119 +1561,36 @@ var nodePostgresDriver = async (options = {}) => {
|
|
|
513
1561
|
},
|
|
514
1562
|
open
|
|
515
1563
|
};
|
|
516
|
-
};
|
|
517
|
-
|
|
518
|
-
// src/settings.ts
|
|
519
|
-
var DEFAULT_OPS_VALUE = "on";
|
|
520
|
-
var CUSTOM_SETTING = /^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$/;
|
|
521
|
-
var readSettings = (settings) => {
|
|
522
|
-
for (const named of ["opsSetting", "tenantSetting"]) {
|
|
523
|
-
const name = settings[named];
|
|
524
|
-
if (typeof name !== "string" || name === "") {
|
|
525
|
-
throw new DbRefusal(
|
|
526
|
-
`${named} is required and has no default: the name of a session setting is the one thing this package cannot pick for you, because the policies and the seam have to agree on it exactly`
|
|
527
|
-
);
|
|
528
|
-
}
|
|
529
|
-
if (!CUSTOM_SETTING.test(name)) {
|
|
530
|
-
throw new DbRefusal(
|
|
531
|
-
`${named} is "${name}", and Postgres reads a setting an application defines as \`prefix.name\` \u2014 anything else is rejected as unrecognised the first time a session sets it`
|
|
532
|
-
);
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
return { ...settings, opsValue: settings.opsValue ?? DEFAULT_OPS_VALUE };
|
|
536
|
-
};
|
|
537
|
-
|
|
538
|
-
// src/policies.ts
|
|
539
|
-
var settingRead = (name) => `(select current_setting(${literal(name)}, true))`;
|
|
540
|
-
var both = (predicate) => `using (${predicate}) with check (${predicate})`;
|
|
541
|
-
var DEFAULT_NAMES = {
|
|
542
|
-
freeze: "not_frozen",
|
|
543
|
-
isolation: "tenant_isolation",
|
|
544
|
-
opsMaintenance: "ops_maintenance"
|
|
545
|
-
};
|
|
546
|
-
var tenantPolicies = (table, options) => {
|
|
547
|
-
const settings = readSettings(options.settings);
|
|
548
|
-
const names = { ...DEFAULT_NAMES, ...options.names };
|
|
549
|
-
const on = qualified(table, options.schema);
|
|
550
|
-
const tenant = settingRead(settings.tenantSetting);
|
|
551
|
-
const opsIsOn = `${settingRead(settings.opsSetting)} = ${literal(settings.opsValue)}`;
|
|
552
|
-
const policy = (name, as, predicate) => [
|
|
553
|
-
`drop policy if exists ${quoted(name)} on ${on}`,
|
|
554
|
-
`create policy ${quoted(name)} on ${on} as ${as} for all ${both(predicate)}`
|
|
555
|
-
];
|
|
556
|
-
const frozen = options.freeze;
|
|
557
|
-
const notFrozen = frozen === void 0 ? [] : policy(
|
|
558
|
-
names.freeze,
|
|
559
|
-
"restrictive",
|
|
560
|
-
`${opsIsOn} or not exists (select 1 from ${quoted(frozen.table)} where ${quoted(frozen.tenantColumn)} = ${tenant})`
|
|
561
|
-
);
|
|
562
|
-
return [
|
|
563
|
-
`alter table ${on} enable row level security`,
|
|
564
|
-
`alter table ${on} force row level security`,
|
|
565
|
-
...policy(names.isolation, "permissive", `${quoted(options.tenantColumn)} = ${tenant}`),
|
|
566
|
-
...policy(names.opsMaintenance, "permissive", opsIsOn),
|
|
567
|
-
...notFrozen
|
|
568
|
-
];
|
|
569
|
-
};
|
|
570
|
-
|
|
571
|
-
// src/session.ts
|
|
572
|
-
var OPS = /* @__PURE__ */ Symbol("the ops lever");
|
|
573
|
-
var scopeOf = /* @__PURE__ */ new WeakMap();
|
|
574
|
-
var describe = (scope) => scope === OPS ? "the ops lever" : `tenant ${JSON.stringify(scope)}`;
|
|
575
|
-
var canOpen = (executor) => typeof executor.transaction === "function";
|
|
576
|
-
var createSessionSeam = (config) => {
|
|
577
|
-
const settings = readSettings(config.settings);
|
|
578
|
-
const contextOf = (scope) => {
|
|
579
|
-
if (scope !== OPS) {
|
|
580
|
-
return { params: [settings.tenantSetting, scope], text: "select set_config($1, $2, true)" };
|
|
581
|
-
}
|
|
582
|
-
return config.opsStatementTimeout === void 0 ? {
|
|
583
|
-
params: [settings.opsSetting, settings.opsValue],
|
|
584
|
-
text: "select set_config($1, $2, true)"
|
|
585
|
-
} : {
|
|
586
|
-
params: [settings.opsSetting, settings.opsValue, config.opsStatementTimeout],
|
|
587
|
-
text: "select set_config($1, $2, true), set_config('statement_timeout', $3, true)"
|
|
588
|
-
};
|
|
589
|
-
};
|
|
590
|
-
const opened = async (executor, scope, run) => {
|
|
591
|
-
const current = scopeOf.get(executor);
|
|
592
|
-
if (current === scope) return run(executor);
|
|
593
|
-
if (current !== void 0) {
|
|
594
|
-
throw new DbRefusal(
|
|
595
|
-
`this transaction is open for ${describe(current)} and was asked for ${describe(scope)}: a transaction names one scope, and the second query would run behind the first one's policy`
|
|
596
|
-
);
|
|
597
|
-
}
|
|
598
|
-
if (!canOpen(executor)) {
|
|
599
|
-
throw new DbRefusal(
|
|
600
|
-
`this executor is in no session and cannot open a transaction, so ${describe(scope)} has nothing to name \u2014 pass the connection, or run it inside a session already open for that scope`
|
|
601
|
-
);
|
|
602
|
-
}
|
|
603
|
-
return executor.transaction(async (tx) => {
|
|
604
|
-
await tx.execute(contextOf(scope));
|
|
605
|
-
scopeOf.set(tx, scope);
|
|
606
|
-
return run(tx);
|
|
607
|
-
});
|
|
608
|
-
};
|
|
609
|
-
return {
|
|
610
|
-
inOps: (executor, run) => opened(executor, OPS, run),
|
|
611
|
-
inTenant: (executor, tenantId, run) => opened(executor, tenantId, run),
|
|
612
|
-
scoped: (query) => (executor, params) => opened(executor, params.tenantId, (tx) => query(tx, params)),
|
|
613
|
-
scopedAsOps: (query) => (executor, params) => opened(executor, OPS, (tx) => query(tx, params))
|
|
614
|
-
};
|
|
615
|
-
};
|
|
1564
|
+
});
|
|
616
1565
|
export {
|
|
1566
|
+
DEFAULT_ENTITY_PAGE,
|
|
617
1567
|
DEFAULT_OPS_VALUE,
|
|
1568
|
+
DEFAULT_TENANT_KEY,
|
|
618
1569
|
DbRefusal,
|
|
1570
|
+
ENTITY_PLACEHOLDERS,
|
|
1571
|
+
MAX_ENTITY_PAGE,
|
|
619
1572
|
appConnectionString,
|
|
620
1573
|
appRoleStatements,
|
|
1574
|
+
asStatement,
|
|
621
1575
|
concurrentTenantsConformance,
|
|
1576
|
+
connectionsConformance,
|
|
622
1577
|
createConnections,
|
|
623
1578
|
createSessionSeam,
|
|
1579
|
+
defineEntity,
|
|
1580
|
+
drizzleSession,
|
|
1581
|
+
drizzleTenantPolicies,
|
|
1582
|
+
entityConformance,
|
|
1583
|
+
entityMigrations,
|
|
1584
|
+
forceRowLevelSecurity,
|
|
624
1585
|
forcedRowLevelSecurityConformance,
|
|
1586
|
+
fragment,
|
|
625
1587
|
nodePostgresDriver,
|
|
626
1588
|
perStepConnection,
|
|
1589
|
+
scopedQueryConformance,
|
|
627
1590
|
sessionConformance,
|
|
628
1591
|
statementCensusConformance,
|
|
629
1592
|
tenantIsolationConformance,
|
|
630
|
-
tenantPolicies
|
|
1593
|
+
tenantPolicies,
|
|
1594
|
+
tenantPolicySet,
|
|
1595
|
+
undosOf
|
|
631
1596
|
};
|