@ultimat3/core 25.0.0 → 25.2.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 +11 -0
- package/package.json +2 -2
- package/src/actor.ts +12 -0
- package/src/index.ts +10 -0
- package/src/pg-executor.ts +2 -2
- package/src/probe-database-sweep.ts +68 -0
- package/src/probe-database.ts +44 -0
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ Zero dependencies, zero `@ultimat3/*` imports.
|
|
|
11
11
|
| the one lazy `AsyncLocalStorage`, every ambient scope in the framework | `async-context.ts` |
|
|
12
12
|
| request context on that seam | `context.ts` |
|
|
13
13
|
| `Actor` (`user \| service \| agent \| anonymous`) | `actor.ts` |
|
|
14
|
+
| whether a permission grant reaches a name — `grantCovers`, `*` and `<prefix>:*` | `actor.ts` |
|
|
14
15
|
| acting as another actor, with an origin and a reason | `impersonate.ts` |
|
|
15
16
|
| is an error worth retrying? one classification per code | `error-retry.ts` |
|
|
16
17
|
| how long to wait before the next attempt — one curve, one jitter table | `backoff.ts` |
|
|
@@ -495,6 +496,16 @@ match with `sealAll()`; uniqueness cannot be held across keys.
|
|
|
495
496
|
before trusting an empty `checks`.
|
|
496
497
|
- Anything that opens a socket calls `markListening(server.url.origin)` and releases it on close.
|
|
497
498
|
That is what tells the sealed test network a loopback request is this process, not egress.
|
|
499
|
+
- **A live suite's throwaway database is named by `probeDatabaseName(prefix)`** — test support,
|
|
500
|
+
here because tier 0 is the one tier every package with a live suite can import. Prefix, pid,
|
|
501
|
+
creation second (base 36), eight random hex characters, at most `PROBE_DATABASE_NAME_MAX` (63) bytes, valid unquoted: a
|
|
502
|
+
fixed name let two runs against one server drop each other's database.
|
|
503
|
+
`bun run probe-databases` refuses `create database` on any other name (`X_PROBE_DATABASE_FIXED`).
|
|
504
|
+
A killed run never reaches its `afterAll`, so call `sweepProbeDatabases(executor, PROBE_DB)`
|
|
505
|
+
before the `create`: it drops the same prefix's probe databases whose pid is dead on this host
|
|
506
|
+
AND which no backend is connected to AND whose creation second, carried in the name, is older than
|
|
507
|
+
`PROBE_DATABASE_MIN_AGE_MS` (10 minutes) — a concurrent run on any host is left alone, even between
|
|
508
|
+
its `create` and its first connection.
|
|
498
509
|
|
|
499
510
|
## Metrics: same seam as tracing, one signal over
|
|
500
511
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/core",
|
|
3
|
-
"version": "25.
|
|
3
|
+
"version": "25.2.0",
|
|
4
4
|
"description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,6 +37,6 @@
|
|
|
37
37
|
"test": "bun test"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@ultimat3/schema": "25.
|
|
40
|
+
"@ultimat3/schema": "25.2.0"
|
|
41
41
|
}
|
|
42
42
|
}
|
package/src/actor.ts
CHANGED
|
@@ -202,6 +202,18 @@ export function hasScope(actor: Actor, scope: string): boolean {
|
|
|
202
202
|
return actor.scopes.includes(scope);
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Whether one permission grant reaches `wanted`: `*` reaches everything, `<prefix>:*` every name
|
|
207
|
+
* beginning `<prefix>:` (any depth), anything else only itself. The PREFIX, never the first
|
|
208
|
+
* segment: `billing:invoice:*` read as "all of `billing`" granted refunds to an invoice clerk.
|
|
209
|
+
* Here, at tier 0, because `@ultimat3/policy` (`can()`) and `@ultimat3/auth` (an API key's cut
|
|
210
|
+
* to its owner) are one tier and may not share a copy — two copies were how it drifted.
|
|
211
|
+
*/
|
|
212
|
+
export function grantCovers(grant: string, wanted: string): boolean {
|
|
213
|
+
if (grant === '*' || grant === wanted) return true;
|
|
214
|
+
return grant.endsWith(':*') && wanted.startsWith(grant.slice(0, -1));
|
|
215
|
+
}
|
|
216
|
+
|
|
205
217
|
/**
|
|
206
218
|
* Attach resolved facts to an actor, once, at the request boundary — the one place that already
|
|
207
219
|
* awaited the database. Returns a new frozen actor, so the actor a predicate reads later cannot
|
package/src/index.ts
CHANGED
|
@@ -36,6 +36,7 @@ export {
|
|
|
36
36
|
actorOrigin,
|
|
37
37
|
agentActor,
|
|
38
38
|
anonymousActor,
|
|
39
|
+
grantCovers,
|
|
39
40
|
hasRole,
|
|
40
41
|
hasScope,
|
|
41
42
|
isActorKind,
|
|
@@ -675,6 +676,15 @@ export {
|
|
|
675
676
|
} from './page-meta';
|
|
676
677
|
/** The structural Postgres seam http, auth, action and jobs share without a `@ultimat3/db` edge. */
|
|
677
678
|
export type { PgExecutor } from './pg-executor';
|
|
679
|
+
/** Test support: the one name a live suite gives the database it creates (`probe-databases`). */
|
|
680
|
+
export type { ProbeDatabaseEntropy } from './probe-database';
|
|
681
|
+
export { PROBE_DATABASE_NAME_MAX, probeDatabaseName } from './probe-database';
|
|
682
|
+
export type { ProbeDatabaseSweepOptions } from './probe-database-sweep';
|
|
683
|
+
export {
|
|
684
|
+
PROBE_DATABASE_MIN_AGE_MS,
|
|
685
|
+
probeDatabaseAlive,
|
|
686
|
+
sweepProbeDatabases,
|
|
687
|
+
} from './probe-database-sweep';
|
|
678
688
|
export type { ProcessMetricsOptions, ProcessReading } from './process-metrics';
|
|
679
689
|
export { readProcess, resetProcessMetrics, startProcessMetrics } from './process-metrics';
|
|
680
690
|
export { hasPublicCause, registerPublicCause, resetPublicCauses } from './public-cause';
|
package/src/pg-executor.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* One method, positional parameters. **`Bun.sql` does not satisfy it** — `Bun.sql.query` is
|
|
7
7
|
* `undefined`; it is a tagged template whose positional form is `unsafe`, so `{ executor: Bun.sql }`
|
|
8
8
|
* would `TypeError` on the first statement. What satisfies it is a client that already speaks
|
|
9
|
-
* `(text, values)
|
|
10
|
-
*
|
|
9
|
+
* `(text, values)` — `@ultimat3/db`'s `dbExecutor()` over `DbClient.query({ text, values })` is
|
|
10
|
+
* the framework's own, and its one builder — or a transaction
|
|
11
11
|
* handle, which is a client on its own connection. It answers rows, never a command tag.
|
|
12
12
|
*/
|
|
13
13
|
export interface PgExecutor {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Test support: drop the probe databases a KILLED run of the same suite left behind. A per-run name
|
|
2
|
+
// (`probe-database.ts`) is never reused, so nothing else would ever drop it — the fixed name it
|
|
3
|
+
// replaced was dropped by the next run. Only a database whose run is provably over goes: its pid
|
|
4
|
+
// dead on this host, no backend connected, AND older than `PROBE_DATABASE_MIN_AGE_MS` — a run on
|
|
5
|
+
// another host that has created its database and not yet connected reads dead and idle here.
|
|
6
|
+
|
|
7
|
+
import { stringField } from './error-render';
|
|
8
|
+
import type { PgExecutor } from './pg-executor';
|
|
9
|
+
|
|
10
|
+
/** `<prefix>_<pid>_<seconds, base 36>_<8 hex>` — exactly what `probeDatabaseName` returns. */
|
|
11
|
+
const PROBE_SHAPE = /^([a-z_][a-z0-9_]*)_(\d+)_([0-9a-z]+)_[0-9a-f]{8}$/;
|
|
12
|
+
|
|
13
|
+
/** A leftover younger than this is left alone: no suite takes ten minutes to make its first query. */
|
|
14
|
+
export const PROBE_DATABASE_MIN_AGE_MS = 10 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
export interface ProbeDatabaseSweepOptions {
|
|
17
|
+
/** Whether a pid is a live process on this host. Defaults to `probeDatabaseAlive`. */
|
|
18
|
+
readonly isAlive?: ((pid: number) => boolean) | undefined;
|
|
19
|
+
/** The clock a leftover's age is read against. Defaults to `Date.now`. */
|
|
20
|
+
readonly now?: (() => number) | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `kill(pid, 0)` delivers nothing and answers whether the process exists: `EPERM` is a process
|
|
25
|
+
* someone else owns, which is alive. Another host's pid reads as dead here, which is why the sweep
|
|
26
|
+
* also demands that nothing is connected.
|
|
27
|
+
*/
|
|
28
|
+
export function probeDatabaseAlive(pid: number): boolean {
|
|
29
|
+
try {
|
|
30
|
+
process.kill(pid, 0);
|
|
31
|
+
return true;
|
|
32
|
+
} catch (thrown) {
|
|
33
|
+
return stringField(thrown, 'code') === 'EPERM';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Drop every database sharing `probeName`'s prefix whose run is over, never `probeName` itself.
|
|
39
|
+
* Call it beside the `create database`; it answers the names it dropped. A name not shaped like
|
|
40
|
+
* `probeDatabaseName`'s answer has no siblings to find, and nothing is sent.
|
|
41
|
+
*/
|
|
42
|
+
export async function sweepProbeDatabases(
|
|
43
|
+
executor: PgExecutor,
|
|
44
|
+
probeName: string,
|
|
45
|
+
options: ProbeDatabaseSweepOptions = {},
|
|
46
|
+
): Promise<readonly string[]> {
|
|
47
|
+
const prefix = PROBE_SHAPE.exec(probeName)?.[1];
|
|
48
|
+
if (prefix === undefined) return [];
|
|
49
|
+
const isAlive = options.isAlive ?? probeDatabaseAlive;
|
|
50
|
+
const now = (options.now ?? Date.now)();
|
|
51
|
+
const rows = await executor.query<{ datname: string; backends: number | string }>(
|
|
52
|
+
`select d.datname, (select count(*) from pg_stat_activity a where a.datname = d.datname) as backends
|
|
53
|
+
from pg_database d where d.datname like $1`,
|
|
54
|
+
[`${prefix.replaceAll('_', '\\_')}\\_%`],
|
|
55
|
+
);
|
|
56
|
+
const swept: string[] = [];
|
|
57
|
+
for (const row of rows) {
|
|
58
|
+
const shape = PROBE_SHAPE.exec(row.datname);
|
|
59
|
+
if (row.datname === probeName || shape?.[1] !== prefix) continue;
|
|
60
|
+
if (Number(row.backends) > 0 || isAlive(Number(shape[2]))) continue;
|
|
61
|
+
const createdMs = Number.parseInt(shape[3] ?? '', 36) * 1000;
|
|
62
|
+
if (!Number.isFinite(createdMs) || now - createdMs < PROBE_DATABASE_MIN_AGE_MS) continue;
|
|
63
|
+
// The name matched `PROBE_SHAPE`, so it is `[a-z0-9_]` only — quoting it needs no escape.
|
|
64
|
+
await executor.query(`drop database if exists "${row.datname}" with (force)`, []);
|
|
65
|
+
swept.push(row.datname);
|
|
66
|
+
}
|
|
67
|
+
return swept;
|
|
68
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Test support: the ONE name a live or contract suite gives the database it creates and drops —
|
|
2
|
+
// prefix, pid, creation second (base 36), eight random hex characters. A fixed name let two runs against one server drop each
|
|
3
|
+
// other's database mid-suite (#705). Here, at tier 0, because every package with such a suite can
|
|
4
|
+
// import core and `@ultimat3/testing` (tier 5) is out of reach below it. `bun run probe-databases`.
|
|
5
|
+
|
|
6
|
+
/** Postgres's NAMEDATALEN - 1: a longer identifier is silently truncated, so two names could meet. */
|
|
7
|
+
export const PROBE_DATABASE_NAME_MAX = 63;
|
|
8
|
+
|
|
9
|
+
const RANDOM_HEX = 8;
|
|
10
|
+
|
|
11
|
+
/** Where a probe name's uniqueness comes from — injectable so a test can pin the answer. */
|
|
12
|
+
export interface ProbeDatabaseEntropy {
|
|
13
|
+
/** Defaults to `process.pid`: two concurrent runs are two processes. */
|
|
14
|
+
readonly pid?: number | undefined;
|
|
15
|
+
/** Defaults to a fresh `crypto.randomUUID()`: two suites in one process differ by this. */
|
|
16
|
+
readonly random?: string | undefined;
|
|
17
|
+
/** Defaults to `Date.now()`: the creation second the sweep ages a leftover by. */
|
|
18
|
+
readonly now?: number | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Lowercase `[a-z0-9_]`, starting with a letter or `_` — an identifier no statement must quote. */
|
|
22
|
+
function identifierPrefix(prefix: string): string {
|
|
23
|
+
const folded = prefix.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
24
|
+
if (folded === '') return 'x_probe';
|
|
25
|
+
return /^[a-z_]/.test(folded) ? folded : `x_${folded}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A database name unique to this call, valid unquoted, at most 63 bytes — truncation takes the
|
|
30
|
+
* prefix, never the suffix. The creation second is in the name because `pg_database` records none,
|
|
31
|
+
* and `sweepProbeDatabases` must not drop a run's database in the moment between its `create` and
|
|
32
|
+
* its first connection (`<prefix>_<pid>_<seconds, base 36>_<8 hex>`).
|
|
33
|
+
*/
|
|
34
|
+
export function probeDatabaseName(prefix: string, entropy: ProbeDatabaseEntropy = {}): string {
|
|
35
|
+
const pid = Math.trunc(Math.abs(entropy.pid ?? process.pid));
|
|
36
|
+
const hex = (entropy.random ?? crypto.randomUUID())
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.replace(/[^0-9a-f]/g, '')
|
|
39
|
+
.slice(0, RANDOM_HEX)
|
|
40
|
+
.padEnd(RANDOM_HEX, '0');
|
|
41
|
+
const seconds = Math.floor(Math.max(0, entropy.now ?? Date.now()) / 1000).toString(36);
|
|
42
|
+
const suffix = `_${String(pid)}_${seconds}_${hex}`;
|
|
43
|
+
return `${identifierPrefix(prefix).slice(0, PROBE_DATABASE_NAME_MAX - suffix.length)}${suffix}`;
|
|
44
|
+
}
|