@geonosis/db 0.3.0 → 0.5.1

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