@ultimat3/entity 5.0.1 → 7.0.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/CLAUDE.md +18 -3
- package/package.json +5 -5
- package/src/columns.ts +13 -12
- package/src/cross-tenant.ts +7 -7
package/CLAUDE.md
CHANGED
|
@@ -4,7 +4,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
4
4
|
|
|
5
5
|
## Boundary
|
|
6
6
|
|
|
7
|
-
- May import `@ultimat3/core`, `@ultimat3/schema` and `@ultimat3/
|
|
7
|
+
- May import `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/db` and `@ultimat3/time` (tier 1 — `columns.ts` and `columns-data.ts` read its `isValidTimeZone`, so a zone this package accepts is one `@ultimat3/time` can do arithmetic in). Nothing else — `http`,
|
|
8
8
|
`policy` and `auth` are the same tier.
|
|
9
9
|
- `db` is tier 1 (it imports only `core`), which is what lets the Postgres driver live **here**
|
|
10
10
|
rather than in a tier-3 package: `Driver` and its production implementation stay in one place.
|
|
@@ -564,13 +564,28 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
564
564
|
hole open on exactly the unauthenticated path. **Outside every request context there is no actor
|
|
565
565
|
to derive from** — a script, a seed, a test harness — so the caller names the tenant itself and
|
|
566
566
|
`X_TENANCY_UNSCOPED` still refuses a plan that names none. There is no build-time tenancy step in
|
|
567
|
-
`x verify` (its
|
|
567
|
+
`x verify` (its 19 steps check none) and the old comment in `tenancy.ts` claiming one was wrong:
|
|
568
568
|
the tenant is a request-time value, so the seam is the enforcement.
|
|
569
569
|
- **`crossTenant(reason, fn)` (`cross-tenant.ts`) is the ONE way to read across tenants**, for the
|
|
570
570
|
three cases that have no single one: an admin surface over every org, background reconciliation,
|
|
571
|
-
support tooling. An
|
|
571
|
+
support tooling. An async-context scope with a written reason, the same shape
|
|
572
572
|
`@ultimat3/db`'s `expectedQueryLoop` has, never a boolean argument on a repository call — which
|
|
573
573
|
reads exactly like forgetting the tenant — and never a config list of exempt entities (axiom 1).
|
|
574
|
+
The scope opens through `asyncContext<string>('the cross-tenant reason')` from `@ultimat3/core`,
|
|
575
|
+
**never a `new AsyncLocalStorage` here, and that is a build error rather than a convention `As of
|
|
576
|
+
2026-08`** — `scripts/async-context-guard.ts` refuses the construction *and* the import that
|
|
577
|
+
binds the class, anywhere but `packages/core/src/async-context.ts`, and
|
|
578
|
+
`scripts/async-context-guard.test.ts` runs it over the tree in the gate's `unit` step. The
|
|
579
|
+
module-scope `new` this replaced threw `TypeError: undefined is not a constructor` at module
|
|
580
|
+
**evaluation** in a browser bundle, where the bundler stubs `node:async_hooks` to `{}`, taking
|
|
581
|
+
every importer of `cross-tenant.ts` with it. Now the module evaluates and `crossTenantReason()`
|
|
582
|
+
answers `undefined` there — in a browser nothing IS in flight, so that is the true answer. A
|
|
583
|
+
write is the case that names itself: `storage.run` throws `X_ASYNC_CONTEXT_UNAVAILABLE` instead
|
|
584
|
+
of a bare `TypeError`, though `crossTenant()` reaches it only past `assertCrossTenant`, which
|
|
585
|
+
wants a request context a browser does not have. A server saves no allocation — the store is
|
|
586
|
+
built on the first `get()` **or** `run()`, so a read constructs it too; what the laziness costs
|
|
587
|
+
is nothing observable, since `getStore()` outside a scope answers `undefined` whether the storage
|
|
588
|
+
existed or not.
|
|
574
589
|
**The capability is proven twice**: `CROSS_TENANT_SCOPE` (`tenancy:cross`) on the actor, at the
|
|
575
590
|
call and again at every plan built inside it, because `withChildContext({ actor })` swaps the
|
|
576
591
|
actor without closing the scope and an impersonated caller must not inherit it —
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/entity",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.0",
|
|
4
4
|
"description": "A table + its domain type + invariants the database also enforces",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/db": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
37
|
-
"@ultimat3/time": "
|
|
34
|
+
"@ultimat3/core": "7.0.0",
|
|
35
|
+
"@ultimat3/db": "7.0.0",
|
|
36
|
+
"@ultimat3/schema": "7.0.0",
|
|
37
|
+
"@ultimat3/time": "7.0.0"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/columns.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
isMoneyScale,
|
|
11
11
|
MAX_MONEY_SCALE,
|
|
12
12
|
} from '@ultimat3/schema';
|
|
13
|
+
import { isValidTimeZone } from '@ultimat3/time';
|
|
13
14
|
import {
|
|
14
15
|
assertColumnName,
|
|
15
16
|
BARE,
|
|
@@ -176,22 +177,22 @@ export const url = (): Column<string> =>
|
|
|
176
177
|
{ check: (name) => `${name} ~ '^https?://'` },
|
|
177
178
|
);
|
|
178
179
|
|
|
179
|
-
const isIanaZone = (value: string): boolean => {
|
|
180
|
-
try {
|
|
181
|
-
new Intl.DateTimeFormat('en', { timeZone: value }).format(0);
|
|
182
|
-
return true;
|
|
183
|
-
} catch {
|
|
184
|
-
return false;
|
|
185
|
-
}
|
|
186
|
-
};
|
|
187
|
-
|
|
188
180
|
/**
|
|
189
|
-
* IANA identifiers, checked
|
|
190
|
-
* is a startup error rather than a row nobody can format.
|
|
181
|
+
* IANA identifiers, checked when the column is declared — a typo, an abbreviation or a UTC offset
|
|
182
|
+
* is a startup error rather than a row nobody can format.
|
|
183
|
+
*
|
|
184
|
+
* The check is `@ultimat3/time`'s own `isValidTimeZone`, imported and never restated: `time` is
|
|
185
|
+
* tier 1 and this package is tier 2, so the one rule is a downward import away. A local
|
|
186
|
+
* `new Intl.DateTimeFormat(…)` probe was the second answer to that question, and the two stopped
|
|
187
|
+
* agreeing — `Intl` answers "can I format this", never "is this IANA", and ICU 78 (Bun 1.4)
|
|
188
|
+
* resolves `CET`, `EST`, `GMT`, `Zulu` and `Japan`, so `tz(['CET'])` declared a column every
|
|
189
|
+
* `format` call above it then refused with `X_TIMEZONE_INVALID`. A leading-sign offset (`+01:00`)
|
|
190
|
+
* resolved under every runtime this framework has shipped on, and carries no DST rule at all, so
|
|
191
|
+
* it is wrong twice a year on top of being unformattable.
|
|
191
192
|
*/
|
|
192
193
|
export const tz = <const Z extends readonly string[]>(zones: Z): Column<Z[number]> => {
|
|
193
194
|
for (const zone of zones) {
|
|
194
|
-
if (!
|
|
195
|
+
if (!isValidTimeZone(zone)) reject('iana-tz', `${zone} is not an IANA time zone`);
|
|
195
196
|
}
|
|
196
197
|
const allowed = new Set<string>(zones);
|
|
197
198
|
return column<Z[number]>(
|
package/src/cross-tenant.ts
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
// reads exactly like forgetting the tenant, and never a config list of exempt entities (axiom 1):
|
|
4
4
|
// both put the argument somewhere other than the read it defends.
|
|
5
5
|
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
import { actorLabel, assert, hasScope, tryUseContext } from '@ultimat3/core';
|
|
6
|
+
// The scope has to outlive every `await` inside it and a module-scope flag would be shared by two
|
|
7
|
+
// concurrent requests — one of them ordinary — so it needs an async context. Opened through core's
|
|
8
|
+
// one lazy seam rather than a `node:async_hooks` construction here, which threw at module
|
|
9
|
+
// EVALUATION in a browser bundle (the bundler stubs the module to `{}`).
|
|
10
|
+
import { actorLabel, assert, asyncContext, hasScope, tryUseContext } from '@ultimat3/core';
|
|
11
11
|
import { crossTenantDenied } from './errors';
|
|
12
12
|
|
|
13
13
|
/**
|
|
@@ -18,7 +18,7 @@ import { crossTenantDenied } from './errors';
|
|
|
18
18
|
*/
|
|
19
19
|
export const CROSS_TENANT_SCOPE = 'tenancy:cross';
|
|
20
20
|
|
|
21
|
-
const storage =
|
|
21
|
+
const storage = asyncContext<string>('the cross-tenant reason');
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Run `fn` with the tenant guard lifted — every read and write it issues, at any depth and across
|
|
@@ -54,7 +54,7 @@ export function crossTenant<T>(reason: string, fn: () => T): T {
|
|
|
54
54
|
* The innermost enclosing reason, or `undefined` outside every scope — which is every query in an
|
|
55
55
|
* app that never calls `crossTenant`. Read by the tenant guard, and by nothing else.
|
|
56
56
|
*/
|
|
57
|
-
export const crossTenantReason = (): string | undefined => storage.
|
|
57
|
+
export const crossTenantReason = (): string | undefined => storage.get();
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
60
|
* The capability check itself, run at `crossTenant()` and again for every plan built inside it.
|