@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 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
- | `sessionConformance` | all four |
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.connection;
441
- const held = opened();
442
- frame.set(config.connectionString, held);
443
- return held.connection;
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 held = /* @__PURE__ */ new Map();
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(held, run);
609
+ return await invocation.run(frame, run);
450
610
  } finally {
451
- release(Promise.all([...held.values()].map((one) => one.close())).then(() => void 0));
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.tenantId, (tx) => query(tx, 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. The engine's own writes the trail, the closing
181
- * batch are not units of work and stay outside, on whatever connection the runner holds.
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
- type SessionSeam = {
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. The engine's own writes the trail, the closing
181
- * batch are not units of work and stay outside, on whatever connection the runner holds.
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
- type SessionSeam = {
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.connection;
399
- const held = opened();
400
- frame.set(config.connectionString, held);
401
- return held.connection;
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 held = /* @__PURE__ */ new Map();
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(held, run);
564
+ return await invocation.run(frame, run);
408
565
  } finally {
409
- release(Promise.all([...held.values()].map((one) => one.close())).then(() => void 0));
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.tenantId, (tx) => query(tx, 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.2.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": [