@geonosis/db 0.2.0 → 0.3.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/README.md +33 -1
- package/dist/index.cjs +199 -12
- package/dist/index.d.cts +75 -12
- package/dist/index.d.ts +75 -12
- package/dist/index.js +196 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,6 +42,13 @@ const listInvoices = seam.scoped(async (executor, params: { tenantId: string })
|
|
|
42
42
|
const sweep = seam.scopedAsOps(async (executor, params: { since: number }) => …)
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
+
`scoped` reads the tenant out of a query's own parameters, under `DEFAULT_TENANT_KEY` — `tenantId`
|
|
46
|
+
— or under the `tenantKey` the seam was built with, so a repo whose queries carry `{ orgId }` names
|
|
47
|
+
the key once instead of wrapping every call site in `inTenant` by hand. The key is part of the
|
|
48
|
+
type: parameters that do not carry it do not compile, and parameters that carry it as nothing at
|
|
49
|
+
runtime are a `DbRefusal` naming the key rather than a query that quietly runs outside every
|
|
50
|
+
session.
|
|
51
|
+
|
|
45
52
|
`tenantSetting` and `opsSetting` have NO defaults. The name of a session setting is the one thing
|
|
46
53
|
this package cannot pick for you: the seam and the policies must agree on it exactly, and a default
|
|
47
54
|
here would be one repo's name compiled into every other repo's wall. `opsValue` defaults to
|
|
@@ -125,6 +132,17 @@ instead of something every caller threads through. `sessionDb()` resolves the in
|
|
|
125
132
|
and REFUSES outside a frame, where a handle it opened would be closed by nobody — the repo this
|
|
126
133
|
came from returns one there and says so in a comment. `open()` is for a caller who will close it.
|
|
127
134
|
|
|
135
|
+
A handle does not outlive its invocation: used after the frame that opened it has ended, it answers
|
|
136
|
+
with a `DbRefusal` naming the unit of work it came from. Without that, the answer is the driver's,
|
|
137
|
+
at whatever line the query sits on — `Cannot use a pool after calling end on the pool` from
|
|
138
|
+
node-postgres, and on workerd a refusal to do I/O on behalf of another request — which reads as a
|
|
139
|
+
bug in the query rather than as a lifetime that was crossed.
|
|
140
|
+
|
|
141
|
+
A nested `withConnection` JOINS the invocation it is inside, so a request that reaches two
|
|
142
|
+
databases still has one lifetime. A caller that needs its own frame regardless passes
|
|
143
|
+
`InvocationOptions`: `withConnection(run, release, { named: 'charge', own: true })`. `named` is what
|
|
144
|
+
a leaked handle is named with; `own` is what a durable step needs.
|
|
145
|
+
|
|
128
146
|
`onStatement` reports every statement a handle sends, the transaction's own `begin` and `commit`
|
|
129
147
|
included — a count of round trips that leaves those out is not a count of round trips. It is what
|
|
130
148
|
the statement census reads.
|
|
@@ -151,6 +169,12 @@ outside on the runner's connection.
|
|
|
151
169
|
createRuntime({ journal, scope: perStepConnection(records) })
|
|
152
170
|
```
|
|
153
171
|
|
|
172
|
+
Each step opens its OWN frame, even when the entrypoint has already wrapped the whole run in one.
|
|
173
|
+
A Cloudflare Workflows entrypoint that does that would otherwise hand every step the RUN's handle,
|
|
174
|
+
and workerd refuses I/O on a socket opened for another request. `perStep(name)` takes the step's
|
|
175
|
+
name where the caller has one — `RunScope` calls it with none — so a handle that leaked out of a
|
|
176
|
+
step is refused naming the step it came from.
|
|
177
|
+
|
|
154
178
|
The type is not imported in either direction. `@geonosis/db` and a workflow engine are sibling
|
|
155
179
|
foundations, and what crosses between them is the shape.
|
|
156
180
|
|
|
@@ -180,7 +204,9 @@ against THEIR session — no test framework rides along; a case fails by throwin
|
|
|
180
204
|
| `statementCensusConformance` | a read in a session is four statements: no savepoint, one `set_config`, and the tenant named before the read |
|
|
181
205
|
| `concurrentTenantsConformance` | eight tenants at once on one pool, and the two refusals |
|
|
182
206
|
| `forcedRowLevelSecurityConformance` | every guarded table forces RLS, so the owner is behind the wall too |
|
|
183
|
-
| `
|
|
207
|
+
| `scopedQueryConformance` | a query that names its own tenant lands under it, refuses a second one, and refuses parameters carrying no tenant at all |
|
|
208
|
+
| `sessionConformance` | all five |
|
|
209
|
+
| `connectionsConformance` | the handle discipline: alive inside its invocation, refused outside one, refused after it, a frame of its own per step, and the leak named with the step it came from |
|
|
184
210
|
|
|
185
211
|
```ts
|
|
186
212
|
for (const { name, run } of sessionConformance(subject)) {
|
|
@@ -188,4 +214,10 @@ for (const { name, run } of sessionConformance(subject)) {
|
|
|
188
214
|
}
|
|
189
215
|
```
|
|
190
216
|
|
|
217
|
+
`SeamUnderTest` carries `scoped` and the `tenantKey` those queries use — a seam with no `scoped` is
|
|
218
|
+
REFUSED by name rather than passing a claim nothing was asked of. A `ConnectionsSubject` is
|
|
219
|
+
`{ connections, perStep?, probe? }`: the `ConnectionsUnderTest` is `sessionDb` and `withConnection`,
|
|
220
|
+
`perStep` is the per-step scope BY NAME, and `probe` is any statement the role may run (`select 1`
|
|
221
|
+
when nothing is named).
|
|
222
|
+
|
|
191
223
|
This package sits its own exam over `nodePostgresDriver` against a real Postgres, on both majors.
|
package/dist/index.cjs
CHANGED
|
@@ -31,15 +31,18 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
33
|
DEFAULT_OPS_VALUE: () => DEFAULT_OPS_VALUE,
|
|
34
|
+
DEFAULT_TENANT_KEY: () => DEFAULT_TENANT_KEY,
|
|
34
35
|
DbRefusal: () => DbRefusal,
|
|
35
36
|
appConnectionString: () => appConnectionString,
|
|
36
37
|
appRoleStatements: () => appRoleStatements,
|
|
37
38
|
concurrentTenantsConformance: () => concurrentTenantsConformance,
|
|
39
|
+
connectionsConformance: () => connectionsConformance,
|
|
38
40
|
createConnections: () => createConnections,
|
|
39
41
|
createSessionSeam: () => createSessionSeam,
|
|
40
42
|
forcedRowLevelSecurityConformance: () => forcedRowLevelSecurityConformance,
|
|
41
43
|
nodePostgresDriver: () => nodePostgresDriver,
|
|
42
44
|
perStepConnection: () => perStepConnection,
|
|
45
|
+
scopedQueryConformance: () => scopedQueryConformance,
|
|
43
46
|
sessionConformance: () => sessionConformance,
|
|
44
47
|
statementCensusConformance: () => statementCensusConformance,
|
|
45
48
|
tenantIsolationConformance: () => tenantIsolationConformance,
|
|
@@ -390,6 +393,60 @@ var concurrentTenantsConformance = (subject) => {
|
|
|
390
393
|
}
|
|
391
394
|
];
|
|
392
395
|
};
|
|
396
|
+
var scopedOf = (subject) => {
|
|
397
|
+
const scoped = subject.seam.scoped;
|
|
398
|
+
if (typeof scoped !== "function") {
|
|
399
|
+
throw new import_conformance.ConformanceFailure(
|
|
400
|
+
'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'
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
return scoped;
|
|
404
|
+
};
|
|
405
|
+
var scopedQueryConformance = (subject) => {
|
|
406
|
+
const probe = probeSql(subject.probe);
|
|
407
|
+
const key = subject.seam.tenantKey ?? "tenantId";
|
|
408
|
+
const insert = () => scopedOf(subject)(
|
|
409
|
+
(tx, params) => tx.execute(probe.insert(params[key] ?? "", "row_scoped"))
|
|
410
|
+
);
|
|
411
|
+
return [
|
|
412
|
+
{
|
|
413
|
+
name: "a query scoped by its own parameters lands under the tenant they name",
|
|
414
|
+
run: async () => {
|
|
415
|
+
await subject.reset();
|
|
416
|
+
await insert()(subject.connection, { [key]: A });
|
|
417
|
+
(0, import_conformance.assertSame)(
|
|
418
|
+
probe.tenantsOf(
|
|
419
|
+
await subject.seam.inOps(subject.connection, (tx) => tx.execute(probe.selectAll))
|
|
420
|
+
),
|
|
421
|
+
[A],
|
|
422
|
+
`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`
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
{
|
|
427
|
+
name: "a scoped query for a second tenant inside a session is refused",
|
|
428
|
+
run: async () => {
|
|
429
|
+
await subject.reset();
|
|
430
|
+
await (0, import_conformance.assertRefuses)(
|
|
431
|
+
() => subject.seam.inTenant(subject.connection, B, (tx) => insert()(tx, { [key]: A })),
|
|
432
|
+
"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",
|
|
433
|
+
DbRefusal
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
name: "a scoped query whose parameters carry no tenant is refused by that key\u2019s name",
|
|
439
|
+
run: async () => {
|
|
440
|
+
await subject.reset();
|
|
441
|
+
await (0, import_conformance.assertRefuses)(
|
|
442
|
+
() => insert()(subject.connection, {}),
|
|
443
|
+
`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`,
|
|
444
|
+
key
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
];
|
|
449
|
+
};
|
|
393
450
|
var forcedRowLevelSecurityConformance = (subject) => [
|
|
394
451
|
{
|
|
395
452
|
name: "every guarded table forces row level security",
|
|
@@ -414,16 +471,114 @@ var forcedRowLevelSecurityConformance = (subject) => [
|
|
|
414
471
|
}
|
|
415
472
|
}
|
|
416
473
|
];
|
|
474
|
+
var perStepOf = (subject) => {
|
|
475
|
+
const perStep = subject.perStep;
|
|
476
|
+
if (typeof perStep !== "function") {
|
|
477
|
+
throw new import_conformance.ConformanceFailure(
|
|
478
|
+
'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'
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
return perStep;
|
|
482
|
+
};
|
|
483
|
+
var connectionsConformance = (subject) => {
|
|
484
|
+
const probe = subject.probe ?? { text: "select 1" };
|
|
485
|
+
const CHARGE = "charge";
|
|
486
|
+
const SHIP = "ship";
|
|
487
|
+
return [
|
|
488
|
+
{
|
|
489
|
+
name: "a handle inside its invocation answers",
|
|
490
|
+
run: () => subject.connections.withConnection(async () => {
|
|
491
|
+
await subject.connections.sessionDb().execute(probe);
|
|
492
|
+
})
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
name: "a handle asked for outside every invocation is refused, naming what to wrap it in",
|
|
496
|
+
run: () => (0, import_conformance.assertRefuses)(
|
|
497
|
+
() => Promise.resolve(subject.connections.sessionDb()),
|
|
498
|
+
"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",
|
|
499
|
+
DbRefusal
|
|
500
|
+
)
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
name: "a handle used after its invocation ended is refused by name",
|
|
504
|
+
run: async () => {
|
|
505
|
+
let held;
|
|
506
|
+
await subject.connections.withConnection(async () => {
|
|
507
|
+
held = subject.connections.sessionDb();
|
|
508
|
+
await held.execute(probe);
|
|
509
|
+
});
|
|
510
|
+
await (0, import_conformance.assertRefuses)(
|
|
511
|
+
() => held.execute(probe),
|
|
512
|
+
"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",
|
|
513
|
+
DbRefusal
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
},
|
|
517
|
+
{
|
|
518
|
+
name: "a step inside a run gets a handle of its own, because workerd refuses I/O on another request\u2019s socket",
|
|
519
|
+
run: () => subject.connections.withConnection(async () => {
|
|
520
|
+
const ofTheRun = subject.connections.sessionDb();
|
|
521
|
+
await perStepOf(subject)(CHARGE)(async () => {
|
|
522
|
+
const ofTheStep = subject.connections.sessionDb();
|
|
523
|
+
(0, import_conformance.assertThat)(
|
|
524
|
+
ofTheStep !== ofTheRun,
|
|
525
|
+
"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"
|
|
526
|
+
);
|
|
527
|
+
await ofTheStep.execute(probe);
|
|
528
|
+
});
|
|
529
|
+
await ofTheRun.execute(probe);
|
|
530
|
+
})
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
name: "a handle from one step is refused inside another, named with the step it came from",
|
|
534
|
+
run: async () => {
|
|
535
|
+
const perStep = perStepOf(subject);
|
|
536
|
+
let charged;
|
|
537
|
+
await perStep(CHARGE)(async () => {
|
|
538
|
+
charged = subject.connections.sessionDb();
|
|
539
|
+
await charged.execute(probe);
|
|
540
|
+
});
|
|
541
|
+
await perStep(SHIP)(
|
|
542
|
+
() => (0, import_conformance.assertRefuses)(
|
|
543
|
+
() => charged.execute(probe),
|
|
544
|
+
`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`,
|
|
545
|
+
CHARGE
|
|
546
|
+
)
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
];
|
|
551
|
+
};
|
|
417
552
|
var sessionConformance = (subject) => [
|
|
418
553
|
...tenantIsolationConformance(subject),
|
|
419
554
|
...statementCensusConformance(subject),
|
|
420
555
|
...concurrentTenantsConformance(subject),
|
|
556
|
+
...scopedQueryConformance(subject),
|
|
421
557
|
...forcedRowLevelSecurityConformance(subject)
|
|
422
558
|
];
|
|
423
559
|
|
|
424
560
|
// src/connections.ts
|
|
425
561
|
var import_node_async_hooks = require("async_hooks");
|
|
426
562
|
var invocation = new import_node_async_hooks.AsyncLocalStorage();
|
|
563
|
+
var whose = (frame) => frame.named === void 0 ? "an invocation" : `the "${frame.named}" unit of work`;
|
|
564
|
+
var guarded = (frame, connection) => {
|
|
565
|
+
const stillOpen = () => {
|
|
566
|
+
if (frame.live) return;
|
|
567
|
+
throw new DbRefusal(
|
|
568
|
+
`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.`
|
|
569
|
+
);
|
|
570
|
+
};
|
|
571
|
+
return {
|
|
572
|
+
execute: async (statement) => {
|
|
573
|
+
stillOpen();
|
|
574
|
+
return connection.execute(statement);
|
|
575
|
+
},
|
|
576
|
+
transaction: async (run) => {
|
|
577
|
+
stillOpen();
|
|
578
|
+
return connection.transaction(run);
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
};
|
|
427
582
|
var createConnections = (config) => {
|
|
428
583
|
const opened = () => config.open === void 0 ? config.driver.open(config.connectionString) : config.driver.open(config.connectionString, config.open);
|
|
429
584
|
return {
|
|
@@ -436,25 +591,38 @@ var createConnections = (config) => {
|
|
|
436
591
|
"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."
|
|
437
592
|
);
|
|
438
593
|
}
|
|
439
|
-
const known = frame.get(config.connectionString);
|
|
440
|
-
if (known !== void 0) return known.
|
|
441
|
-
const
|
|
442
|
-
frame.
|
|
443
|
-
|
|
594
|
+
const known = frame.held.get(config.connectionString);
|
|
595
|
+
if (known !== void 0) return known.guarded;
|
|
596
|
+
const open = opened();
|
|
597
|
+
const held = { guarded: guarded(frame, open.connection), open };
|
|
598
|
+
frame.held.set(config.connectionString, held);
|
|
599
|
+
return held.guarded;
|
|
444
600
|
},
|
|
445
|
-
withConnection: async (run, release = (closing) => void closing) => {
|
|
446
|
-
if (invocation.getStore() !== void 0) return run();
|
|
447
|
-
const
|
|
601
|
+
withConnection: async (run, release = (closing) => void closing, options) => {
|
|
602
|
+
if (options?.own !== true && invocation.getStore() !== void 0) return run();
|
|
603
|
+
const frame = {
|
|
604
|
+
held: /* @__PURE__ */ new Map(),
|
|
605
|
+
live: true,
|
|
606
|
+
...options?.named === void 0 ? {} : { named: options.named }
|
|
607
|
+
};
|
|
448
608
|
try {
|
|
449
|
-
return await invocation.run(
|
|
609
|
+
return await invocation.run(frame, run);
|
|
450
610
|
} finally {
|
|
451
|
-
|
|
611
|
+
frame.live = false;
|
|
612
|
+
release(
|
|
613
|
+
Promise.all([...frame.held.values()].map((one) => one.open.close())).then(
|
|
614
|
+
() => void 0
|
|
615
|
+
)
|
|
616
|
+
);
|
|
452
617
|
}
|
|
453
618
|
}
|
|
454
619
|
};
|
|
455
620
|
};
|
|
456
621
|
var perStepConnection = (connections) => ({
|
|
457
|
-
perStep: () => (body) => connections.withConnection(body
|
|
622
|
+
perStep: (named) => (body) => connections.withConnection(body, void 0, {
|
|
623
|
+
own: true,
|
|
624
|
+
...named === void 0 ? {} : { named }
|
|
625
|
+
})
|
|
458
626
|
});
|
|
459
627
|
|
|
460
628
|
// src/node-postgres.ts
|
|
@@ -615,8 +783,15 @@ var OPS = /* @__PURE__ */ Symbol("the ops lever");
|
|
|
615
783
|
var scopeOf = /* @__PURE__ */ new WeakMap();
|
|
616
784
|
var describe = (scope) => scope === OPS ? "the ops lever" : `tenant ${JSON.stringify(scope)}`;
|
|
617
785
|
var canOpen = (executor) => typeof executor.transaction === "function";
|
|
786
|
+
var DEFAULT_TENANT_KEY = "tenantId";
|
|
618
787
|
var createSessionSeam = (config) => {
|
|
619
788
|
const settings = readSettings(config.settings);
|
|
789
|
+
const tenantKey = config.tenantKey ?? DEFAULT_TENANT_KEY;
|
|
790
|
+
if (tenantKey === "") {
|
|
791
|
+
throw new DbRefusal(
|
|
792
|
+
"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`"
|
|
793
|
+
);
|
|
794
|
+
}
|
|
620
795
|
const contextOf = (scope) => {
|
|
621
796
|
if (scope !== OPS) {
|
|
622
797
|
return { params: [settings.tenantSetting, scope], text: "select set_config($1, $2, true)" };
|
|
@@ -648,25 +823,37 @@ var createSessionSeam = (config) => {
|
|
|
648
823
|
return run(tx);
|
|
649
824
|
});
|
|
650
825
|
};
|
|
826
|
+
const named = (params) => {
|
|
827
|
+
const carried = params[tenantKey];
|
|
828
|
+
if (typeof carried !== "string" || carried === "") {
|
|
829
|
+
throw new DbRefusal(
|
|
830
|
+
`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`
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
return carried;
|
|
834
|
+
};
|
|
651
835
|
return {
|
|
652
836
|
inOps: (executor, run) => opened(executor, OPS, run),
|
|
653
837
|
inTenant: (executor, tenantId, run) => opened(executor, tenantId, run),
|
|
654
|
-
scoped: (query) => (executor, params) => opened(executor, params
|
|
838
|
+
scoped: (query) => async (executor, params) => opened(executor, named(params), (tx) => query(tx, params)),
|
|
655
839
|
scopedAsOps: (query) => (executor, params) => opened(executor, OPS, (tx) => query(tx, params))
|
|
656
840
|
};
|
|
657
841
|
};
|
|
658
842
|
// Annotate the CommonJS export names for ESM import in node:
|
|
659
843
|
0 && (module.exports = {
|
|
660
844
|
DEFAULT_OPS_VALUE,
|
|
845
|
+
DEFAULT_TENANT_KEY,
|
|
661
846
|
DbRefusal,
|
|
662
847
|
appConnectionString,
|
|
663
848
|
appRoleStatements,
|
|
664
849
|
concurrentTenantsConformance,
|
|
850
|
+
connectionsConformance,
|
|
665
851
|
createConnections,
|
|
666
852
|
createSessionSeam,
|
|
667
853
|
forcedRowLevelSecurityConformance,
|
|
668
854
|
nodePostgresDriver,
|
|
669
855
|
perStepConnection,
|
|
856
|
+
scopedQueryConformance,
|
|
670
857
|
sessionConformance,
|
|
671
858
|
statementCensusConformance,
|
|
672
859
|
tenantIsolationConformance,
|
package/dist/index.d.cts
CHANGED
|
@@ -68,6 +68,13 @@ type RecordingConnection = {
|
|
|
68
68
|
type SeamUnderTest = {
|
|
69
69
|
inOps: <Result>(executor: Executor, run: (tx: Executor) => Promise<Result>) => Promise<Result>;
|
|
70
70
|
inTenant: <Result>(executor: Executor, tenantId: string, run: (tx: Executor) => Promise<Result>) => Promise<Result>;
|
|
71
|
+
/**
|
|
72
|
+
* The wrapper that reads the tenant out of a query's own parameters. Spelled here rather than
|
|
73
|
+
* imported: what the exam asks about is the behaviour, and a consumer's seam is their own type.
|
|
74
|
+
*/
|
|
75
|
+
scoped?: <Params extends Readonly<Record<string, string>>, Result>(query: (executor: Executor, params: Params) => Promise<Result>) => (executor: Executor, params: Params) => Promise<Result>;
|
|
76
|
+
/** The key those parameters carry the tenant under. `tenantId` when the seam names none. */
|
|
77
|
+
tenantKey?: string;
|
|
71
78
|
};
|
|
72
79
|
/**
|
|
73
80
|
* A table under the policies under test, whose only required columns are these two.
|
|
@@ -113,14 +120,58 @@ declare const statementCensusConformance: (subject: SessionSubject) => Conforman
|
|
|
113
120
|
* and the one a pooled connection carrying a stale setting would fail.
|
|
114
121
|
*/
|
|
115
122
|
declare const concurrentTenantsConformance: (subject: SessionSubject) => ConformanceCase[];
|
|
123
|
+
/**
|
|
124
|
+
* A query that names its own tenant, under the key the CONSUMER's queries carry it in (#245).
|
|
125
|
+
*
|
|
126
|
+
* A seam that reads only `tenantId` sends every repo whose queries say something else back through
|
|
127
|
+
* `inTenant` with a wrapper per call site, which is the hand-written scope the seam exists to
|
|
128
|
+
* remove — and a wrapper that reads a key nobody carries names `undefined`, which is not a tenant
|
|
129
|
+
* and, before this, was not a refusal either.
|
|
130
|
+
*/
|
|
131
|
+
declare const scopedQueryConformance: (subject: SessionSubject) => ConformanceCase[];
|
|
116
132
|
/**
|
|
117
133
|
* ENABLE alone exempts the table's OWNER, and the owner is the role that migrates and the role an
|
|
118
134
|
* ops sweep connects as. A wall only the application is behind is a wall with a door in it.
|
|
119
135
|
*/
|
|
120
136
|
declare const forcedRowLevelSecurityConformance: (subject: SessionSubject) => ConformanceCase[];
|
|
137
|
+
/** As much of a handle keeper as the exam asks about. */
|
|
138
|
+
type ConnectionsUnderTest = {
|
|
139
|
+
sessionDb: () => Connection;
|
|
140
|
+
withConnection: <Result>(run: () => Promise<Result>, release?: (closing: Promise<void>) => void) => Promise<Result>;
|
|
141
|
+
};
|
|
142
|
+
type ConnectionsSubject = {
|
|
143
|
+
connections: ConnectionsUnderTest;
|
|
144
|
+
/**
|
|
145
|
+
* A unit of work, by name. A durable step's handle is the one that outlives its step, and a
|
|
146
|
+
* refusal that cannot say WHICH step leaked sends a reader through every step in the workflow.
|
|
147
|
+
*/
|
|
148
|
+
perStep?: (named: string) => <Output>(body: () => Promise<Output>) => Promise<Output>;
|
|
149
|
+
/** Something any role may run. The exam asks a handle a question rather than assuming a table. */
|
|
150
|
+
probe?: Statement;
|
|
151
|
+
};
|
|
152
|
+
/**
|
|
153
|
+
* The handle discipline: a connection belongs to the invocation that opened it and to no other.
|
|
154
|
+
*
|
|
155
|
+
* A serverless invocation hibernates and dies, so a handle held across one is a socket nobody is on
|
|
156
|
+
* the other end of. Held inside the process that opened it, the failure is a driver's own error at
|
|
157
|
+
* some later line — "Cannot use a pool after calling end on the pool" — which reads as a bug in the
|
|
158
|
+
* query rather than as a handle used out of its lifetime.
|
|
159
|
+
*/
|
|
160
|
+
declare const connectionsConformance: (subject: ConnectionsSubject) => ConformanceCase[];
|
|
121
161
|
/** The whole exam: what a consumer runs against their own session before depending on it. */
|
|
122
162
|
declare const sessionConformance: (subject: SessionSubject) => ConformanceCase[];
|
|
123
163
|
|
|
164
|
+
/** What one invocation may be told about itself, for the refusal a handle that outlives it raises. */
|
|
165
|
+
type InvocationOptions = {
|
|
166
|
+
/** The unit of work this frame is, so a leaked handle is named with what leaked it. */
|
|
167
|
+
named?: string;
|
|
168
|
+
/**
|
|
169
|
+
* A frame of its own even when this call is nested inside one. A durable step needs it: an
|
|
170
|
+
* entrypoint that wraps the whole run in an invocation would otherwise hand every step the RUN's
|
|
171
|
+
* handle, and workerd refuses I/O on a socket opened for another request.
|
|
172
|
+
*/
|
|
173
|
+
own?: boolean;
|
|
174
|
+
};
|
|
124
175
|
type OpenOptions = {
|
|
125
176
|
/**
|
|
126
177
|
* Every statement this handle sends, as it is sent — the transaction's own begin and commit
|
|
@@ -157,7 +208,7 @@ type Connections = {
|
|
|
157
208
|
open: () => OpenConnection;
|
|
158
209
|
/** The handle this invocation is using, opened on first ask. */
|
|
159
210
|
sessionDb: () => Connection;
|
|
160
|
-
withConnection: <Result>(run: () => Promise<Result>, release?: (closing: Promise<void>) => void) => Promise<Result>;
|
|
211
|
+
withConnection: <Result>(run: () => Promise<Result>, release?: (closing: Promise<void>) => void, invocation?: InvocationOptions) => Promise<Result>;
|
|
161
212
|
};
|
|
162
213
|
type ConnectionsConfig = {
|
|
163
214
|
connectionString: string;
|
|
@@ -177,14 +228,18 @@ declare const createConnections: (config: ConnectionsConfig) => Connections;
|
|
|
177
228
|
* The `scope.perStep` hook a workflow engine asks for, answered with a connection per unit of work.
|
|
178
229
|
*
|
|
179
230
|
* A durable run hibernates between steps, so the handle its first step opened is dead by the fifth:
|
|
180
|
-
* each step body and each undo opens its own
|
|
181
|
-
*
|
|
231
|
+
* each step body and each undo opens its own — its OWN frame, even when the entrypoint has already
|
|
232
|
+
* wrapped the whole run in one, because a step handed the run's handle is a step doing I/O for a
|
|
233
|
+
* request that has gone. The engine's own writes — the trail, the closing batch — are not units of
|
|
234
|
+
* work and stay outside, on whatever connection the runner holds.
|
|
182
235
|
*
|
|
183
236
|
* The type is not imported. `@geonosis/workflows` is a sibling foundation and the floors forbid an
|
|
184
|
-
* edge between two of them in either direction; what crosses is the shape.
|
|
237
|
+
* edge between two of them in either direction; what crosses is the shape. `perStep` takes the
|
|
238
|
+
* step's name where the caller has one — `RunScope` calls it with none, and a frame with no name
|
|
239
|
+
* refuses by lifetime alone.
|
|
185
240
|
*/
|
|
186
241
|
declare const perStepConnection: (connections: Pick<Connections, "withConnection">) => {
|
|
187
|
-
perStep: () => <Output>(body: () => Promise<Output>) => Promise<Output>;
|
|
242
|
+
perStep: (named?: string) => <Output>(body: () => Promise<Output>) => Promise<Output>;
|
|
188
243
|
};
|
|
189
244
|
|
|
190
245
|
/**
|
|
@@ -307,15 +362,14 @@ declare const tenantPolicies: (table: string, options: TenantPolicyOptions) => s
|
|
|
307
362
|
|
|
308
363
|
/** A query that takes its session as an argument: what the seam wraps and what a provider writes. */
|
|
309
364
|
type Query<Params, Result> = (executor: Executor, params: Params) => Promise<Result>;
|
|
310
|
-
|
|
365
|
+
declare const DEFAULT_TENANT_KEY = "tenantId";
|
|
366
|
+
type SessionSeam<Key extends string = typeof DEFAULT_TENANT_KEY> = {
|
|
311
367
|
inOps: <Result>(executor: Executor, run: (tx: Executor) => Promise<Result>) => Promise<Result>;
|
|
312
368
|
inTenant: <Result>(executor: Executor, tenantId: string, run: (tx: Executor) => Promise<Result>) => Promise<Result>;
|
|
313
|
-
scoped: <Params extends
|
|
314
|
-
tenantId: string;
|
|
315
|
-
}, Result>(query: Query<Params, Result>) => Query<Params, Result>;
|
|
369
|
+
scoped: <Params extends Readonly<Record<Key, string>>, Result>(query: Query<Params, Result>) => Query<Params, Result>;
|
|
316
370
|
scopedAsOps: <Params, Result>(query: Query<Params, Result>) => Query<Params, Result>;
|
|
317
371
|
};
|
|
318
|
-
type SessionSeamConfig = {
|
|
372
|
+
type SessionSeamConfig<Key extends string = typeof DEFAULT_TENANT_KEY> = {
|
|
319
373
|
/**
|
|
320
374
|
* How long a statement may run under the ops lever, as Postgres reads it. No default: a sweep's
|
|
321
375
|
* budget is a fact about one deployment's data, and the value that fits belongs to whoever runs
|
|
@@ -323,6 +377,15 @@ type SessionSeamConfig = {
|
|
|
323
377
|
*/
|
|
324
378
|
opsStatementTimeout?: string;
|
|
325
379
|
settings: SessionSettings;
|
|
380
|
+
/**
|
|
381
|
+
* The key a query's own parameters carry its tenant under (#245). A repo whose queries say
|
|
382
|
+
* `{ orgId }` names it here instead of wrapping every call site in `inTenant` by hand.
|
|
383
|
+
*
|
|
384
|
+
* A key name rather than a selector, measured: under `tenantOf: (params) => string` nothing ties
|
|
385
|
+
* the selector's shape to the query's, and a query whose parameters carry no tenant at all
|
|
386
|
+
* compiles clean — which is the one thing `scoped` is here to make impossible.
|
|
387
|
+
*/
|
|
388
|
+
tenantKey?: Key;
|
|
326
389
|
};
|
|
327
390
|
/**
|
|
328
391
|
* The seam every query crosses: one transaction, the scope named as its first statement.
|
|
@@ -331,6 +394,6 @@ type SessionSeamConfig = {
|
|
|
331
394
|
* the name is gone when the transaction ends, and a connection handed to the next request carries
|
|
332
395
|
* nothing. Naming it second would leave the statements before it running under no tenant at all.
|
|
333
396
|
*/
|
|
334
|
-
declare const createSessionSeam: (config: SessionSeamConfig) => SessionSeam
|
|
397
|
+
declare const createSessionSeam: <Key extends string = typeof DEFAULT_TENANT_KEY>(config: SessionSeamConfig<Key>) => SessionSeam<Key>;
|
|
335
398
|
|
|
336
|
-
export { type AppRoleOptions, type ConformanceCase, type Connection, type Connections, type ConnectionsConfig, DEFAULT_OPS_VALUE, DbRefusal, type Driver, type Executor, type FreezeRegister, type NodePostgresOptions, type OpenConnection, type OpenOptions, type PgClientLike, type PgModuleLike, type PgPoolLike, type ProbeTable, type Query, type RecordingConnection, type SeamUnderTest, type SessionSeam, type SessionSeamConfig, type SessionSettings, type SessionSubject, type Statement, type TenantPolicyNames, type TenantPolicyOptions, appConnectionString, appRoleStatements, concurrentTenantsConformance, createConnections, createSessionSeam, forcedRowLevelSecurityConformance, nodePostgresDriver, perStepConnection, sessionConformance, statementCensusConformance, tenantIsolationConformance, tenantPolicies };
|
|
399
|
+
export { type AppRoleOptions, type ConformanceCase, type Connection, type Connections, type ConnectionsConfig, type ConnectionsSubject, type ConnectionsUnderTest, DEFAULT_OPS_VALUE, DEFAULT_TENANT_KEY, DbRefusal, type Driver, type Executor, type FreezeRegister, type InvocationOptions, type NodePostgresOptions, type OpenConnection, type OpenOptions, type PgClientLike, type PgModuleLike, type PgPoolLike, type ProbeTable, type Query, type RecordingConnection, type SeamUnderTest, type SessionSeam, type SessionSeamConfig, type SessionSettings, type SessionSubject, type Statement, type TenantPolicyNames, type TenantPolicyOptions, appConnectionString, appRoleStatements, concurrentTenantsConformance, connectionsConformance, createConnections, createSessionSeam, forcedRowLevelSecurityConformance, nodePostgresDriver, perStepConnection, scopedQueryConformance, sessionConformance, statementCensusConformance, tenantIsolationConformance, tenantPolicies };
|
package/dist/index.d.ts
CHANGED
|
@@ -68,6 +68,13 @@ type RecordingConnection = {
|
|
|
68
68
|
type SeamUnderTest = {
|
|
69
69
|
inOps: <Result>(executor: Executor, run: (tx: Executor) => Promise<Result>) => Promise<Result>;
|
|
70
70
|
inTenant: <Result>(executor: Executor, tenantId: string, run: (tx: Executor) => Promise<Result>) => Promise<Result>;
|
|
71
|
+
/**
|
|
72
|
+
* The wrapper that reads the tenant out of a query's own parameters. Spelled here rather than
|
|
73
|
+
* imported: what the exam asks about is the behaviour, and a consumer's seam is their own type.
|
|
74
|
+
*/
|
|
75
|
+
scoped?: <Params extends Readonly<Record<string, string>>, Result>(query: (executor: Executor, params: Params) => Promise<Result>) => (executor: Executor, params: Params) => Promise<Result>;
|
|
76
|
+
/** The key those parameters carry the tenant under. `tenantId` when the seam names none. */
|
|
77
|
+
tenantKey?: string;
|
|
71
78
|
};
|
|
72
79
|
/**
|
|
73
80
|
* A table under the policies under test, whose only required columns are these two.
|
|
@@ -113,14 +120,58 @@ declare const statementCensusConformance: (subject: SessionSubject) => Conforman
|
|
|
113
120
|
* and the one a pooled connection carrying a stale setting would fail.
|
|
114
121
|
*/
|
|
115
122
|
declare const concurrentTenantsConformance: (subject: SessionSubject) => ConformanceCase[];
|
|
123
|
+
/**
|
|
124
|
+
* A query that names its own tenant, under the key the CONSUMER's queries carry it in (#245).
|
|
125
|
+
*
|
|
126
|
+
* A seam that reads only `tenantId` sends every repo whose queries say something else back through
|
|
127
|
+
* `inTenant` with a wrapper per call site, which is the hand-written scope the seam exists to
|
|
128
|
+
* remove — and a wrapper that reads a key nobody carries names `undefined`, which is not a tenant
|
|
129
|
+
* and, before this, was not a refusal either.
|
|
130
|
+
*/
|
|
131
|
+
declare const scopedQueryConformance: (subject: SessionSubject) => ConformanceCase[];
|
|
116
132
|
/**
|
|
117
133
|
* ENABLE alone exempts the table's OWNER, and the owner is the role that migrates and the role an
|
|
118
134
|
* ops sweep connects as. A wall only the application is behind is a wall with a door in it.
|
|
119
135
|
*/
|
|
120
136
|
declare const forcedRowLevelSecurityConformance: (subject: SessionSubject) => ConformanceCase[];
|
|
137
|
+
/** As much of a handle keeper as the exam asks about. */
|
|
138
|
+
type ConnectionsUnderTest = {
|
|
139
|
+
sessionDb: () => Connection;
|
|
140
|
+
withConnection: <Result>(run: () => Promise<Result>, release?: (closing: Promise<void>) => void) => Promise<Result>;
|
|
141
|
+
};
|
|
142
|
+
type ConnectionsSubject = {
|
|
143
|
+
connections: ConnectionsUnderTest;
|
|
144
|
+
/**
|
|
145
|
+
* A unit of work, by name. A durable step's handle is the one that outlives its step, and a
|
|
146
|
+
* refusal that cannot say WHICH step leaked sends a reader through every step in the workflow.
|
|
147
|
+
*/
|
|
148
|
+
perStep?: (named: string) => <Output>(body: () => Promise<Output>) => Promise<Output>;
|
|
149
|
+
/** Something any role may run. The exam asks a handle a question rather than assuming a table. */
|
|
150
|
+
probe?: Statement;
|
|
151
|
+
};
|
|
152
|
+
/**
|
|
153
|
+
* The handle discipline: a connection belongs to the invocation that opened it and to no other.
|
|
154
|
+
*
|
|
155
|
+
* A serverless invocation hibernates and dies, so a handle held across one is a socket nobody is on
|
|
156
|
+
* the other end of. Held inside the process that opened it, the failure is a driver's own error at
|
|
157
|
+
* some later line — "Cannot use a pool after calling end on the pool" — which reads as a bug in the
|
|
158
|
+
* query rather than as a handle used out of its lifetime.
|
|
159
|
+
*/
|
|
160
|
+
declare const connectionsConformance: (subject: ConnectionsSubject) => ConformanceCase[];
|
|
121
161
|
/** The whole exam: what a consumer runs against their own session before depending on it. */
|
|
122
162
|
declare const sessionConformance: (subject: SessionSubject) => ConformanceCase[];
|
|
123
163
|
|
|
164
|
+
/** What one invocation may be told about itself, for the refusal a handle that outlives it raises. */
|
|
165
|
+
type InvocationOptions = {
|
|
166
|
+
/** The unit of work this frame is, so a leaked handle is named with what leaked it. */
|
|
167
|
+
named?: string;
|
|
168
|
+
/**
|
|
169
|
+
* A frame of its own even when this call is nested inside one. A durable step needs it: an
|
|
170
|
+
* entrypoint that wraps the whole run in an invocation would otherwise hand every step the RUN's
|
|
171
|
+
* handle, and workerd refuses I/O on a socket opened for another request.
|
|
172
|
+
*/
|
|
173
|
+
own?: boolean;
|
|
174
|
+
};
|
|
124
175
|
type OpenOptions = {
|
|
125
176
|
/**
|
|
126
177
|
* Every statement this handle sends, as it is sent — the transaction's own begin and commit
|
|
@@ -157,7 +208,7 @@ type Connections = {
|
|
|
157
208
|
open: () => OpenConnection;
|
|
158
209
|
/** The handle this invocation is using, opened on first ask. */
|
|
159
210
|
sessionDb: () => Connection;
|
|
160
|
-
withConnection: <Result>(run: () => Promise<Result>, release?: (closing: Promise<void>) => void) => Promise<Result>;
|
|
211
|
+
withConnection: <Result>(run: () => Promise<Result>, release?: (closing: Promise<void>) => void, invocation?: InvocationOptions) => Promise<Result>;
|
|
161
212
|
};
|
|
162
213
|
type ConnectionsConfig = {
|
|
163
214
|
connectionString: string;
|
|
@@ -177,14 +228,18 @@ declare const createConnections: (config: ConnectionsConfig) => Connections;
|
|
|
177
228
|
* The `scope.perStep` hook a workflow engine asks for, answered with a connection per unit of work.
|
|
178
229
|
*
|
|
179
230
|
* A durable run hibernates between steps, so the handle its first step opened is dead by the fifth:
|
|
180
|
-
* each step body and each undo opens its own
|
|
181
|
-
*
|
|
231
|
+
* each step body and each undo opens its own — its OWN frame, even when the entrypoint has already
|
|
232
|
+
* wrapped the whole run in one, because a step handed the run's handle is a step doing I/O for a
|
|
233
|
+
* request that has gone. The engine's own writes — the trail, the closing batch — are not units of
|
|
234
|
+
* work and stay outside, on whatever connection the runner holds.
|
|
182
235
|
*
|
|
183
236
|
* The type is not imported. `@geonosis/workflows` is a sibling foundation and the floors forbid an
|
|
184
|
-
* edge between two of them in either direction; what crosses is the shape.
|
|
237
|
+
* edge between two of them in either direction; what crosses is the shape. `perStep` takes the
|
|
238
|
+
* step's name where the caller has one — `RunScope` calls it with none, and a frame with no name
|
|
239
|
+
* refuses by lifetime alone.
|
|
185
240
|
*/
|
|
186
241
|
declare const perStepConnection: (connections: Pick<Connections, "withConnection">) => {
|
|
187
|
-
perStep: () => <Output>(body: () => Promise<Output>) => Promise<Output>;
|
|
242
|
+
perStep: (named?: string) => <Output>(body: () => Promise<Output>) => Promise<Output>;
|
|
188
243
|
};
|
|
189
244
|
|
|
190
245
|
/**
|
|
@@ -307,15 +362,14 @@ declare const tenantPolicies: (table: string, options: TenantPolicyOptions) => s
|
|
|
307
362
|
|
|
308
363
|
/** A query that takes its session as an argument: what the seam wraps and what a provider writes. */
|
|
309
364
|
type Query<Params, Result> = (executor: Executor, params: Params) => Promise<Result>;
|
|
310
|
-
|
|
365
|
+
declare const DEFAULT_TENANT_KEY = "tenantId";
|
|
366
|
+
type SessionSeam<Key extends string = typeof DEFAULT_TENANT_KEY> = {
|
|
311
367
|
inOps: <Result>(executor: Executor, run: (tx: Executor) => Promise<Result>) => Promise<Result>;
|
|
312
368
|
inTenant: <Result>(executor: Executor, tenantId: string, run: (tx: Executor) => Promise<Result>) => Promise<Result>;
|
|
313
|
-
scoped: <Params extends
|
|
314
|
-
tenantId: string;
|
|
315
|
-
}, Result>(query: Query<Params, Result>) => Query<Params, Result>;
|
|
369
|
+
scoped: <Params extends Readonly<Record<Key, string>>, Result>(query: Query<Params, Result>) => Query<Params, Result>;
|
|
316
370
|
scopedAsOps: <Params, Result>(query: Query<Params, Result>) => Query<Params, Result>;
|
|
317
371
|
};
|
|
318
|
-
type SessionSeamConfig = {
|
|
372
|
+
type SessionSeamConfig<Key extends string = typeof DEFAULT_TENANT_KEY> = {
|
|
319
373
|
/**
|
|
320
374
|
* How long a statement may run under the ops lever, as Postgres reads it. No default: a sweep's
|
|
321
375
|
* budget is a fact about one deployment's data, and the value that fits belongs to whoever runs
|
|
@@ -323,6 +377,15 @@ type SessionSeamConfig = {
|
|
|
323
377
|
*/
|
|
324
378
|
opsStatementTimeout?: string;
|
|
325
379
|
settings: SessionSettings;
|
|
380
|
+
/**
|
|
381
|
+
* The key a query's own parameters carry its tenant under (#245). A repo whose queries say
|
|
382
|
+
* `{ orgId }` names it here instead of wrapping every call site in `inTenant` by hand.
|
|
383
|
+
*
|
|
384
|
+
* A key name rather than a selector, measured: under `tenantOf: (params) => string` nothing ties
|
|
385
|
+
* the selector's shape to the query's, and a query whose parameters carry no tenant at all
|
|
386
|
+
* compiles clean — which is the one thing `scoped` is here to make impossible.
|
|
387
|
+
*/
|
|
388
|
+
tenantKey?: Key;
|
|
326
389
|
};
|
|
327
390
|
/**
|
|
328
391
|
* The seam every query crosses: one transaction, the scope named as its first statement.
|
|
@@ -331,6 +394,6 @@ type SessionSeamConfig = {
|
|
|
331
394
|
* the name is gone when the transaction ends, and a connection handed to the next request carries
|
|
332
395
|
* nothing. Naming it second would leave the statements before it running under no tenant at all.
|
|
333
396
|
*/
|
|
334
|
-
declare const createSessionSeam: (config: SessionSeamConfig) => SessionSeam
|
|
397
|
+
declare const createSessionSeam: <Key extends string = typeof DEFAULT_TENANT_KEY>(config: SessionSeamConfig<Key>) => SessionSeam<Key>;
|
|
335
398
|
|
|
336
|
-
export { type AppRoleOptions, type ConformanceCase, type Connection, type Connections, type ConnectionsConfig, DEFAULT_OPS_VALUE, DbRefusal, type Driver, type Executor, type FreezeRegister, type NodePostgresOptions, type OpenConnection, type OpenOptions, type PgClientLike, type PgModuleLike, type PgPoolLike, type ProbeTable, type Query, type RecordingConnection, type SeamUnderTest, type SessionSeam, type SessionSeamConfig, type SessionSettings, type SessionSubject, type Statement, type TenantPolicyNames, type TenantPolicyOptions, appConnectionString, appRoleStatements, concurrentTenantsConformance, createConnections, createSessionSeam, forcedRowLevelSecurityConformance, nodePostgresDriver, perStepConnection, sessionConformance, statementCensusConformance, tenantIsolationConformance, tenantPolicies };
|
|
399
|
+
export { type AppRoleOptions, type ConformanceCase, type Connection, type Connections, type ConnectionsConfig, type ConnectionsSubject, type ConnectionsUnderTest, DEFAULT_OPS_VALUE, DEFAULT_TENANT_KEY, DbRefusal, type Driver, type Executor, type FreezeRegister, type InvocationOptions, type NodePostgresOptions, type OpenConnection, type OpenOptions, type PgClientLike, type PgModuleLike, type PgPoolLike, type ProbeTable, type Query, type RecordingConnection, type SeamUnderTest, type SessionSeam, type SessionSeamConfig, type SessionSettings, type SessionSubject, type Statement, type TenantPolicyNames, type TenantPolicyOptions, appConnectionString, appRoleStatements, concurrentTenantsConformance, connectionsConformance, createConnections, createSessionSeam, forcedRowLevelSecurityConformance, nodePostgresDriver, perStepConnection, scopedQueryConformance, sessionConformance, statementCensusConformance, tenantIsolationConformance, tenantPolicies };
|
package/dist/index.js
CHANGED
|
@@ -348,6 +348,60 @@ var concurrentTenantsConformance = (subject) => {
|
|
|
348
348
|
}
|
|
349
349
|
];
|
|
350
350
|
};
|
|
351
|
+
var scopedOf = (subject) => {
|
|
352
|
+
const scoped = subject.seam.scoped;
|
|
353
|
+
if (typeof scoped !== "function") {
|
|
354
|
+
throw new ConformanceFailure(
|
|
355
|
+
'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'
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
return scoped;
|
|
359
|
+
};
|
|
360
|
+
var scopedQueryConformance = (subject) => {
|
|
361
|
+
const probe = probeSql(subject.probe);
|
|
362
|
+
const key = subject.seam.tenantKey ?? "tenantId";
|
|
363
|
+
const insert = () => scopedOf(subject)(
|
|
364
|
+
(tx, params) => tx.execute(probe.insert(params[key] ?? "", "row_scoped"))
|
|
365
|
+
);
|
|
366
|
+
return [
|
|
367
|
+
{
|
|
368
|
+
name: "a query scoped by its own parameters lands under the tenant they name",
|
|
369
|
+
run: async () => {
|
|
370
|
+
await subject.reset();
|
|
371
|
+
await insert()(subject.connection, { [key]: A });
|
|
372
|
+
assertSame(
|
|
373
|
+
probe.tenantsOf(
|
|
374
|
+
await subject.seam.inOps(subject.connection, (tx) => tx.execute(probe.selectAll))
|
|
375
|
+
),
|
|
376
|
+
[A],
|
|
377
|
+
`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`
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
name: "a scoped query for a second tenant inside a session is refused",
|
|
383
|
+
run: async () => {
|
|
384
|
+
await subject.reset();
|
|
385
|
+
await assertRefuses(
|
|
386
|
+
() => subject.seam.inTenant(subject.connection, B, (tx) => insert()(tx, { [key]: A })),
|
|
387
|
+
"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",
|
|
388
|
+
DbRefusal
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
name: "a scoped query whose parameters carry no tenant is refused by that key\u2019s name",
|
|
394
|
+
run: async () => {
|
|
395
|
+
await subject.reset();
|
|
396
|
+
await assertRefuses(
|
|
397
|
+
() => insert()(subject.connection, {}),
|
|
398
|
+
`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`,
|
|
399
|
+
key
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
];
|
|
404
|
+
};
|
|
351
405
|
var forcedRowLevelSecurityConformance = (subject) => [
|
|
352
406
|
{
|
|
353
407
|
name: "every guarded table forces row level security",
|
|
@@ -372,16 +426,114 @@ var forcedRowLevelSecurityConformance = (subject) => [
|
|
|
372
426
|
}
|
|
373
427
|
}
|
|
374
428
|
];
|
|
429
|
+
var perStepOf = (subject) => {
|
|
430
|
+
const perStep = subject.perStep;
|
|
431
|
+
if (typeof perStep !== "function") {
|
|
432
|
+
throw new ConformanceFailure(
|
|
433
|
+
'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'
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
return perStep;
|
|
437
|
+
};
|
|
438
|
+
var connectionsConformance = (subject) => {
|
|
439
|
+
const probe = subject.probe ?? { text: "select 1" };
|
|
440
|
+
const CHARGE = "charge";
|
|
441
|
+
const SHIP = "ship";
|
|
442
|
+
return [
|
|
443
|
+
{
|
|
444
|
+
name: "a handle inside its invocation answers",
|
|
445
|
+
run: () => subject.connections.withConnection(async () => {
|
|
446
|
+
await subject.connections.sessionDb().execute(probe);
|
|
447
|
+
})
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
name: "a handle asked for outside every invocation is refused, naming what to wrap it in",
|
|
451
|
+
run: () => assertRefuses(
|
|
452
|
+
() => Promise.resolve(subject.connections.sessionDb()),
|
|
453
|
+
"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",
|
|
454
|
+
DbRefusal
|
|
455
|
+
)
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
name: "a handle used after its invocation ended is refused by name",
|
|
459
|
+
run: async () => {
|
|
460
|
+
let held;
|
|
461
|
+
await subject.connections.withConnection(async () => {
|
|
462
|
+
held = subject.connections.sessionDb();
|
|
463
|
+
await held.execute(probe);
|
|
464
|
+
});
|
|
465
|
+
await assertRefuses(
|
|
466
|
+
() => held.execute(probe),
|
|
467
|
+
"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",
|
|
468
|
+
DbRefusal
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
name: "a step inside a run gets a handle of its own, because workerd refuses I/O on another request\u2019s socket",
|
|
474
|
+
run: () => subject.connections.withConnection(async () => {
|
|
475
|
+
const ofTheRun = subject.connections.sessionDb();
|
|
476
|
+
await perStepOf(subject)(CHARGE)(async () => {
|
|
477
|
+
const ofTheStep = subject.connections.sessionDb();
|
|
478
|
+
assertThat(
|
|
479
|
+
ofTheStep !== ofTheRun,
|
|
480
|
+
"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"
|
|
481
|
+
);
|
|
482
|
+
await ofTheStep.execute(probe);
|
|
483
|
+
});
|
|
484
|
+
await ofTheRun.execute(probe);
|
|
485
|
+
})
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
name: "a handle from one step is refused inside another, named with the step it came from",
|
|
489
|
+
run: async () => {
|
|
490
|
+
const perStep = perStepOf(subject);
|
|
491
|
+
let charged;
|
|
492
|
+
await perStep(CHARGE)(async () => {
|
|
493
|
+
charged = subject.connections.sessionDb();
|
|
494
|
+
await charged.execute(probe);
|
|
495
|
+
});
|
|
496
|
+
await perStep(SHIP)(
|
|
497
|
+
() => assertRefuses(
|
|
498
|
+
() => charged.execute(probe),
|
|
499
|
+
`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`,
|
|
500
|
+
CHARGE
|
|
501
|
+
)
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
];
|
|
506
|
+
};
|
|
375
507
|
var sessionConformance = (subject) => [
|
|
376
508
|
...tenantIsolationConformance(subject),
|
|
377
509
|
...statementCensusConformance(subject),
|
|
378
510
|
...concurrentTenantsConformance(subject),
|
|
511
|
+
...scopedQueryConformance(subject),
|
|
379
512
|
...forcedRowLevelSecurityConformance(subject)
|
|
380
513
|
];
|
|
381
514
|
|
|
382
515
|
// src/connections.ts
|
|
383
516
|
import { AsyncLocalStorage } from "async_hooks";
|
|
384
517
|
var invocation = new AsyncLocalStorage();
|
|
518
|
+
var whose = (frame) => frame.named === void 0 ? "an invocation" : `the "${frame.named}" unit of work`;
|
|
519
|
+
var guarded = (frame, connection) => {
|
|
520
|
+
const stillOpen = () => {
|
|
521
|
+
if (frame.live) return;
|
|
522
|
+
throw new DbRefusal(
|
|
523
|
+
`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.`
|
|
524
|
+
);
|
|
525
|
+
};
|
|
526
|
+
return {
|
|
527
|
+
execute: async (statement) => {
|
|
528
|
+
stillOpen();
|
|
529
|
+
return connection.execute(statement);
|
|
530
|
+
},
|
|
531
|
+
transaction: async (run) => {
|
|
532
|
+
stillOpen();
|
|
533
|
+
return connection.transaction(run);
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
};
|
|
385
537
|
var createConnections = (config) => {
|
|
386
538
|
const opened = () => config.open === void 0 ? config.driver.open(config.connectionString) : config.driver.open(config.connectionString, config.open);
|
|
387
539
|
return {
|
|
@@ -394,25 +546,38 @@ var createConnections = (config) => {
|
|
|
394
546
|
"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
547
|
);
|
|
396
548
|
}
|
|
397
|
-
const known = frame.get(config.connectionString);
|
|
398
|
-
if (known !== void 0) return known.
|
|
399
|
-
const
|
|
400
|
-
frame.
|
|
401
|
-
|
|
549
|
+
const known = frame.held.get(config.connectionString);
|
|
550
|
+
if (known !== void 0) return known.guarded;
|
|
551
|
+
const open = opened();
|
|
552
|
+
const held = { guarded: guarded(frame, open.connection), open };
|
|
553
|
+
frame.held.set(config.connectionString, held);
|
|
554
|
+
return held.guarded;
|
|
402
555
|
},
|
|
403
|
-
withConnection: async (run, release = (closing) => void closing) => {
|
|
404
|
-
if (invocation.getStore() !== void 0) return run();
|
|
405
|
-
const
|
|
556
|
+
withConnection: async (run, release = (closing) => void closing, options) => {
|
|
557
|
+
if (options?.own !== true && invocation.getStore() !== void 0) return run();
|
|
558
|
+
const frame = {
|
|
559
|
+
held: /* @__PURE__ */ new Map(),
|
|
560
|
+
live: true,
|
|
561
|
+
...options?.named === void 0 ? {} : { named: options.named }
|
|
562
|
+
};
|
|
406
563
|
try {
|
|
407
|
-
return await invocation.run(
|
|
564
|
+
return await invocation.run(frame, run);
|
|
408
565
|
} finally {
|
|
409
|
-
|
|
566
|
+
frame.live = false;
|
|
567
|
+
release(
|
|
568
|
+
Promise.all([...frame.held.values()].map((one) => one.open.close())).then(
|
|
569
|
+
() => void 0
|
|
570
|
+
)
|
|
571
|
+
);
|
|
410
572
|
}
|
|
411
573
|
}
|
|
412
574
|
};
|
|
413
575
|
};
|
|
414
576
|
var perStepConnection = (connections) => ({
|
|
415
|
-
perStep: () => (body) => connections.withConnection(body
|
|
577
|
+
perStep: (named) => (body) => connections.withConnection(body, void 0, {
|
|
578
|
+
own: true,
|
|
579
|
+
...named === void 0 ? {} : { named }
|
|
580
|
+
})
|
|
416
581
|
});
|
|
417
582
|
|
|
418
583
|
// src/node-postgres.ts
|
|
@@ -573,8 +738,15 @@ var OPS = /* @__PURE__ */ Symbol("the ops lever");
|
|
|
573
738
|
var scopeOf = /* @__PURE__ */ new WeakMap();
|
|
574
739
|
var describe = (scope) => scope === OPS ? "the ops lever" : `tenant ${JSON.stringify(scope)}`;
|
|
575
740
|
var canOpen = (executor) => typeof executor.transaction === "function";
|
|
741
|
+
var DEFAULT_TENANT_KEY = "tenantId";
|
|
576
742
|
var createSessionSeam = (config) => {
|
|
577
743
|
const settings = readSettings(config.settings);
|
|
744
|
+
const tenantKey = config.tenantKey ?? DEFAULT_TENANT_KEY;
|
|
745
|
+
if (tenantKey === "") {
|
|
746
|
+
throw new DbRefusal(
|
|
747
|
+
"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`"
|
|
748
|
+
);
|
|
749
|
+
}
|
|
578
750
|
const contextOf = (scope) => {
|
|
579
751
|
if (scope !== OPS) {
|
|
580
752
|
return { params: [settings.tenantSetting, scope], text: "select set_config($1, $2, true)" };
|
|
@@ -606,24 +778,36 @@ var createSessionSeam = (config) => {
|
|
|
606
778
|
return run(tx);
|
|
607
779
|
});
|
|
608
780
|
};
|
|
781
|
+
const named = (params) => {
|
|
782
|
+
const carried = params[tenantKey];
|
|
783
|
+
if (typeof carried !== "string" || carried === "") {
|
|
784
|
+
throw new DbRefusal(
|
|
785
|
+
`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`
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
return carried;
|
|
789
|
+
};
|
|
609
790
|
return {
|
|
610
791
|
inOps: (executor, run) => opened(executor, OPS, run),
|
|
611
792
|
inTenant: (executor, tenantId, run) => opened(executor, tenantId, run),
|
|
612
|
-
scoped: (query) => (executor, params) => opened(executor, params
|
|
793
|
+
scoped: (query) => async (executor, params) => opened(executor, named(params), (tx) => query(tx, params)),
|
|
613
794
|
scopedAsOps: (query) => (executor, params) => opened(executor, OPS, (tx) => query(tx, params))
|
|
614
795
|
};
|
|
615
796
|
};
|
|
616
797
|
export {
|
|
617
798
|
DEFAULT_OPS_VALUE,
|
|
799
|
+
DEFAULT_TENANT_KEY,
|
|
618
800
|
DbRefusal,
|
|
619
801
|
appConnectionString,
|
|
620
802
|
appRoleStatements,
|
|
621
803
|
concurrentTenantsConformance,
|
|
804
|
+
connectionsConformance,
|
|
622
805
|
createConnections,
|
|
623
806
|
createSessionSeam,
|
|
624
807
|
forcedRowLevelSecurityConformance,
|
|
625
808
|
nodePostgresDriver,
|
|
626
809
|
perStepConnection,
|
|
810
|
+
scopedQueryConformance,
|
|
627
811
|
sessionConformance,
|
|
628
812
|
statementCensusConformance,
|
|
629
813
|
tenantIsolationConformance,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geonosis/db",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"types": "./dist/index.d.ts",
|
|
5
5
|
"description": "The consumer side of the executor port: a query-in-a-session type a domain provider imports, a tenant session seam whose set_config is the first statement of its transaction, an RLS policy generator, an app role whose password never enters logged DDL, and one handle per invocation.",
|
|
6
6
|
"keywords": [
|