@objectstack/core 17.1.0 → 17.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/CHANGELOG.md +196 -0
- package/dist/index.cjs +170 -77
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +50 -16
- package/dist/index.d.ts +50 -16
- package/dist/index.js +170 -77
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,201 @@
|
|
|
1
1
|
# @objectstack/core
|
|
2
2
|
|
|
3
|
+
## 17.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- ee2ff45: `ObjectKernel` no longer pre-injects the in-memory `job` fallback for the `job` core-service slot — a fallback must not fake capability (#10746, maintainer ruling 2026-08-22). `createMemoryJob()`'s `schedule()` records a job and never fires it (it owns no timer), so pre-injecting it made every "prefer the platform job service, else own a timer" consumer take the job-service branch on a kernel without `@objectstack/service-job` and then silently never run: `plugin-reports` logged `dispatcher registered with job service` and dispatched nothing, ever.
|
|
8
|
+
|
|
9
|
+
Behavior change, FROM → TO: on an `ObjectKernel` without a registered `job` service, `getService('job')` FROM resolving a non-scheduling in-memory registry TO throwing `Service 'job' not found`. Consumers' documented no-job-service paths take over (`plugin-reports` falls through to its own `setInterval` and scheduled reports actually dispatch; schedule triggers and declarative jobs warn loudly instead of scheduling into the void), and the kernel says the absence out loud at boot: `Core service missing, functionality may be degraded: job`.
|
|
10
|
+
|
|
11
|
+
One-line fix if you relied on the old behavior: install `@objectstack/service-job` for real scheduling, or — if you deliberately want the manual-trigger in-memory registry — register it explicitly: `kernel.registerService('job', createMemoryJob())` (the factory is still exported from `@objectstack/core`).
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 3b2af5e: `resolveUserAuthzGrants` issues its five independent reads (sys_user, both sys_member reads, sys_user_position, sys_user_permission_set) concurrently (#10825) — 8 sequential round trips become 4 waves on the fullest path (2 on the lightest), with byte-identical rows, filters, limits and tenancy scoping, pinned by a differential golden suite captured from the sequential implementation. No caching; nothing survives a request; authorization semantics unchanged by construction.
|
|
16
|
+
- 47cd3ec: The kernel's two `Promise.race` timeout guards — the startup guard around each
|
|
17
|
+
plugin's `init`/`start`, and the shutdown guard around `performShutdown()` —
|
|
18
|
+
now reclaim **both** halves of the guard when the race settles: the timer is
|
|
19
|
+
cleared *and* the losing promise is settled (#10604).
|
|
20
|
+
|
|
21
|
+
Neither site settled its loser, so the timeout promise and the reaction
|
|
22
|
+
`Promise.race` held on it were retained for the life of the process — four
|
|
23
|
+
leaking promises per showcase test run under `vitest --detectAsyncLeaks`, now
|
|
24
|
+
zero. The two hand-rolled copies had also drifted into doing opposite halves of
|
|
25
|
+
the same cleanup: the startup site cleared its timer and never `unref`'d, the
|
|
26
|
+
shutdown site `unref`'d and never cleared. Both now go through one internal
|
|
27
|
+
`TimeoutGuard`, so they cannot drift apart again. No exported API changes.
|
|
28
|
+
|
|
29
|
+
**Behaviour change, at the shutdown guard:** the shutdown timer is no longer
|
|
30
|
+
`unref()`d. Two consequences for an embedding host (CLI, auth-proxy, test
|
|
31
|
+
runner):
|
|
32
|
+
|
|
33
|
+
- After a **successful** shutdown, no timer is left armed. Previously the guard
|
|
34
|
+
survived its own race and stayed scheduled to fire against a kernel already
|
|
35
|
+
`'stopped'`. That late rejection was *handled* — `Promise.race` had attached a
|
|
36
|
+
rejection handler to it — so this was never an unhandled-rejection risk; it
|
|
37
|
+
was retained work and a wakeup after teardown.
|
|
38
|
+
- When teardown **hangs**, the guard now actually fires. An unref'd timer does
|
|
39
|
+
not keep the event loop alive, so a process with nothing else to run could
|
|
40
|
+
exit silently — status 0, teardown incomplete — before `shutdownTimeout`
|
|
41
|
+
elapsed, leaving `Shutdown timed out — forcing exit` and its `exit(1)`
|
|
42
|
+
unreachable in exactly the case they exist for. Reclaiming on settle keeps the
|
|
43
|
+
guard ref'd exactly as long as the race is undecided, which is the guarantee
|
|
44
|
+
the startup guard already had (#4813).
|
|
45
|
+
|
|
46
|
+
If your host relied on a hung `shutdown()` letting the process fall out of the
|
|
47
|
+
event loop on its own, it will now wait up to `shutdownTimeout` (default 60s)
|
|
48
|
+
and then hard-exit with status 1. Lower `shutdownTimeout` in the kernel config
|
|
49
|
+
to shorten that window.
|
|
50
|
+
- 9d7d2de: `resolveLocalizationContext` now memoizes a FAILED read's fallback per `(ql, tenantId, userId)` for 30s (#10221).
|
|
51
|
+
|
|
52
|
+
On a fresh environment whose `sys_setting` table hasn't been created/migrated yet, every authenticated request re-ran the same `sys_setting` localization read, and every one of those reads failed the same way ("no such table"). The `#2409` batching had already collapsed the three per-key reads a single request used to issue into one query, but that one query still repeated on every subsequent request, and `driver-sql`'s `backendStatementFault` logs a `[sql-driver] DATABASE_ERROR` warning on every failed read — so the identical warning printed once per request and buried real errors in between.
|
|
53
|
+
|
|
54
|
+
Only the case where the underlying read genuinely fails (a backend fault, e.g. the missing table) is cached; a successful read — including a legitimate "nothing configured yet" empty result — is never cached and always re-reads on the next call, so a settings write takes effect immediately. (An earlier version of this fix cached every outcome, mirroring `packages/plugins/plugin-audit/src/audit-writers.ts`'s existing TTL cache of this same read — safe there because audit-trail enrichment is best-effort, but not safe for `@objectstack/rest`'s use of this function: analytics date-bucketing reads the org timezone on every query and `packages/qa/dogfood/test/analytics-timezone.dogfood.test.ts` — the #1982/#2018 golden regression — asserts the very next read reflects a just-written timezone.) The `UTC` / `en-US` fallback behavior itself is unchanged; this only stops the failing query — and its log line — from re-running every request. The cache is keyed on the `ql` engine instance first, so two environments/tenants sharing one process never share a cached outcome, and self-heals within one TTL window once `sys_setting` exists.
|
|
55
|
+
- c815c50: `resolveLocalizationContext` prefers `settings.getMany` — one grouped namespace read instead of three per-key `get()`s (#10826); older services without `getMany` keep the three parallel gets, and a thrown `getMany` lands in the same direct `$in` fallback a thrown `get` did.
|
|
56
|
+
- 795ea05: A lapsed `sys_member` row now confers no org role either — one row, one answer (#10982)
|
|
57
|
+
|
|
58
|
+
`resolveUserAuthzGrants` reads `sys_member` once and derives two facts from it:
|
|
59
|
+
`accessible_org_ids` (the `group` posture's read reach, ADR-0105 D2) and the
|
|
60
|
+
org-administration role projection into `positions` (ADR-0095 D3). Only the
|
|
61
|
+
first applied the ADR-0091 validity window. A membership outside
|
|
62
|
+
`[valid_from, valid_until)` was therefore excluded from org access while still
|
|
63
|
+
projecting its better-auth role — two answers from one read, and with
|
|
64
|
+
`role: 'owner'` the role reaches the `organization_admin` capability that
|
|
65
|
+
`derivePosture` reads for `TENANT_ADMIN`.
|
|
66
|
+
|
|
67
|
+
The role projection now drops out-of-window rows **before** the derivation, the
|
|
68
|
+
same shape `sys_user_permission_set` already had, so an expired membership can
|
|
69
|
+
no more yield `org_owner` than an expired `admin_full_access` can yield
|
|
70
|
+
`platform_admin`. Fail-closed per ADR-0091 D2. Maintainer ruling, 2026-08-22
|
|
71
|
+
live session (item 2): a lapsed membership is *no membership*, not merely *no
|
|
72
|
+
org access*.
|
|
73
|
+
|
|
74
|
+
**Why `patch` and not a breaking bump, argued in the open.** This is a real
|
|
75
|
+
change of authorization semantics — a membership that used to confer a role
|
|
76
|
+
stops conferring it — so the direction is a tightening, and tightenings are the
|
|
77
|
+
kind of change that normally earns a major. It is nevertheless `patch` because
|
|
78
|
+
the population it can affect is provably empty: `sys_member` declares neither
|
|
79
|
+
`valid_from` nor `valid_until` (see `sys-member.object.ts`), and `isGrantActive`
|
|
80
|
+
reads an absent bound as unbounded, so **no row any deployment can currently
|
|
81
|
+
store is lapsed** and every existing membership resolves exactly as before. That
|
|
82
|
+
is asserted directly rather than reasoned about, in
|
|
83
|
+
`resolve-authz-context.test.ts` ("a membership with NO bounds is unbounded —
|
|
84
|
+
every shipped row is unaffected"), alongside the load-bearing leg that an
|
|
85
|
+
in-window membership still projects its role. Landing it now is the cheap
|
|
86
|
+
moment: once the columns exist, the same change becomes a migration carrying
|
|
87
|
+
live semantics.
|
|
88
|
+
|
|
89
|
+
**Not in scope, and deliberately so.** This does not add the validity columns to
|
|
90
|
+
`sys_member`, and it does not reach into `sys_user_permission_set` rows that
|
|
91
|
+
plugin-security's `reconcileOrgAdminGrant` provisioned from a membership role.
|
|
92
|
+
Such a grant is standing authority in its own right with its own ADR-0091
|
|
93
|
+
window; the role is only its provisioning source (ADR-0095 D3). The boundary is
|
|
94
|
+
pinned as a measured fact rather than left as an assumption.
|
|
95
|
+
- 504c8d5: Materialize the RBAC catalog **per organization**, so a walled deployment can
|
|
96
|
+
administer positions, permission sets and sharing rules again (#10103).
|
|
97
|
+
|
|
98
|
+
On a walled deployment (`group` / `isolated`) every principal — an organization
|
|
99
|
+
owner and a platform admin alike — listed **zero** positions, permission sets
|
|
100
|
+
and sharing rules while the tables held rows. Nothing could be bound through
|
|
101
|
+
Setup, and a declared `hierarchy-security` could never be armed by an operator
|
|
102
|
+
however loudly an app declared it.
|
|
103
|
+
|
|
104
|
+
Every row in those three tables was organization-less. plugin-security's Layer 0
|
|
105
|
+
composes a strict `organization_id = :tenant` for a walled posture and the
|
|
106
|
+
middleware ANDs it into the read AST over the driver's
|
|
107
|
+
`(organization_id = :tenant OR organization_id IS NULL)`; the conjunction of the
|
|
108
|
+
two is the strict equality alone, so the driver's null arm was annihilated on
|
|
109
|
+
every authenticated read.
|
|
110
|
+
|
|
111
|
+
**The wall is not changed, at either layer.** The rows get an owner instead:
|
|
112
|
+
|
|
113
|
+
- `bootstrapDeclaredPositions`, `bootstrapBuiltinRoles`,
|
|
114
|
+
`bootstrapDeclaredPermissions` (plugin-security) and
|
|
115
|
+
`bootstrapDeclaredSharingRules` (plugin-sharing) upsert by
|
|
116
|
+
`(name, organization_id)` and run **one pass per organization** under a walled
|
|
117
|
+
posture — the framework built-ins (`platform_admin`, `org_*`, `everyone`,
|
|
118
|
+
`guest`) included, matching `sys_user_position`, which is already
|
|
119
|
+
per-organization, and matching both objects' own `unique: 'organization'` name
|
|
120
|
+
index.
|
|
121
|
+
- Seeding also fires on **organization creation**, not only at `kernel:ready`, so
|
|
122
|
+
a tenant created after startup does not administer an empty catalog until the
|
|
123
|
+
next restart.
|
|
124
|
+
- `single` posture is **unchanged**: exactly one organization-less pass, which is
|
|
125
|
+
the correct shape there.
|
|
126
|
+
|
|
127
|
+
An organization-less row is now invalid state under a walled posture. Nothing is
|
|
128
|
+
reaped — grants (`sys_user_position`, `sys_position_permission_set`,
|
|
129
|
+
`sys_user_permission_set`, `sys_record_share`) point at these rows by id, so
|
|
130
|
+
deleting them would revoke standing access with no signal at the moment of loss.
|
|
131
|
+
Instead a per-organization pass that meets pre-fix organization-less rows for
|
|
132
|
+
names it seeds **says so loudly**, naming the rows and the remedy, and still
|
|
133
|
+
creates that organization's own copies. The failure this closes is the silent
|
|
134
|
+
no-op: a tenant-threaded pass that sees the old row through the driver's
|
|
135
|
+
compatibility arm, reads the name as already represented, and creates nothing
|
|
136
|
+
while reporting success.
|
|
137
|
+
|
|
138
|
+
Two enforcement-plane reads are scoped in the same change, because the exposure
|
|
139
|
+
they carry only exists once per-organization copies exist:
|
|
140
|
+
|
|
141
|
+
- `resolveUserAuthzContext`'s position name-sweep (`@objectstack/core`) resolved
|
|
142
|
+
`sys_position` by name across **every** organization, so the junction read
|
|
143
|
+
behind it collected another organization's `everyone` binding — a cross-organization
|
|
144
|
+
grant bleed, and an O(organizations) read on the per-request path. It is now
|
|
145
|
+
threaded through the driver's tenant chokepoint, keeping per-request resolution
|
|
146
|
+
O(the caller's own organization's catalog).
|
|
147
|
+
- plugin-security's permission-set `dbLoader` resolved sets by name unscoped,
|
|
148
|
+
with a `limit` equal to the number of names — correct while one row existed per
|
|
149
|
+
name, a truncation the moment copies exist. It is now scoped to the caller's
|
|
150
|
+
organization and its bound widened.
|
|
151
|
+
|
|
152
|
+
Boot reconciliation is O(changed declarations): each pass reads what its
|
|
153
|
+
organization already has and writes only where a declaration actually differs, so
|
|
154
|
+
the common boot performs no writes at all. Steady state rides the
|
|
155
|
+
organization-creation hook.
|
|
156
|
+
|
|
157
|
+
Cross-links #10119 / PR #10422, whose criteria-sweep scoping makes per-organization
|
|
158
|
+
sharing rules cheaper than the unscoped sweep they replace.
|
|
159
|
+
- Updated dependencies [6936d07]
|
|
160
|
+
- Updated dependencies [59eb04d]
|
|
161
|
+
- Updated dependencies [9f05b7d]
|
|
162
|
+
- Updated dependencies [7d2d112]
|
|
163
|
+
- Updated dependencies [5fa0d72]
|
|
164
|
+
- Updated dependencies [02b3b07]
|
|
165
|
+
- Updated dependencies [914c413]
|
|
166
|
+
- Updated dependencies [55809a0]
|
|
167
|
+
- Updated dependencies [52db1d1]
|
|
168
|
+
- Updated dependencies [5649efb]
|
|
169
|
+
- Updated dependencies [2306a76]
|
|
170
|
+
- Updated dependencies [e5ea701]
|
|
171
|
+
- Updated dependencies [a40dcc1]
|
|
172
|
+
- Updated dependencies [def0d3e]
|
|
173
|
+
- Updated dependencies [8d0bb79]
|
|
174
|
+
- Updated dependencies [5acb58d]
|
|
175
|
+
- Updated dependencies [2e3cf95]
|
|
176
|
+
- Updated dependencies [4c93387]
|
|
177
|
+
- Updated dependencies [a037f7c]
|
|
178
|
+
- Updated dependencies [3ee8ddf]
|
|
179
|
+
- Updated dependencies [16cef97]
|
|
180
|
+
- Updated dependencies [a79bd35]
|
|
181
|
+
- Updated dependencies [6ceaa4b]
|
|
182
|
+
- Updated dependencies [15ea214]
|
|
183
|
+
- Updated dependencies [de19489]
|
|
184
|
+
- Updated dependencies [c684d00]
|
|
185
|
+
- Updated dependencies [923c424]
|
|
186
|
+
- Updated dependencies [1ec36b7]
|
|
187
|
+
- Updated dependencies [5f2e54c]
|
|
188
|
+
- Updated dependencies [189373b]
|
|
189
|
+
- Updated dependencies [35ad101]
|
|
190
|
+
- Updated dependencies [ceb33a9]
|
|
191
|
+
- Updated dependencies [73d9795]
|
|
192
|
+
- Updated dependencies [8012960]
|
|
193
|
+
- Updated dependencies [f34f56b]
|
|
194
|
+
- Updated dependencies [f399618]
|
|
195
|
+
- Updated dependencies [75e9301]
|
|
196
|
+
- Updated dependencies [2810695]
|
|
197
|
+
- @objectstack/spec@17.2.0
|
|
198
|
+
|
|
3
199
|
## 17.1.0
|
|
4
200
|
|
|
5
201
|
### Minor Changes
|
package/dist/index.cjs
CHANGED
|
@@ -1494,35 +1494,6 @@ function createMemoryQueue() {
|
|
|
1494
1494
|
};
|
|
1495
1495
|
}
|
|
1496
1496
|
|
|
1497
|
-
// src/fallbacks/memory-job.ts
|
|
1498
|
-
function createMemoryJob() {
|
|
1499
|
-
const jobs = /* @__PURE__ */ new Map();
|
|
1500
|
-
return {
|
|
1501
|
-
__serviceInfo: {
|
|
1502
|
-
status: "degraded",
|
|
1503
|
-
handlerReady: false,
|
|
1504
|
-
message: "In-process job registry \u2014 trigger() runs handlers, but scheduled jobs never fire on their own (no timer). Register a job plugin (e.g. Agenda) for real scheduling."
|
|
1505
|
-
},
|
|
1506
|
-
_serviceName: "job",
|
|
1507
|
-
async schedule(name, schedule, handler) {
|
|
1508
|
-
jobs.set(name, { schedule, handler });
|
|
1509
|
-
},
|
|
1510
|
-
async cancel(name) {
|
|
1511
|
-
jobs.delete(name);
|
|
1512
|
-
},
|
|
1513
|
-
async trigger(name, data) {
|
|
1514
|
-
const job = jobs.get(name);
|
|
1515
|
-
if (job?.handler) await job.handler({ jobId: name, data });
|
|
1516
|
-
},
|
|
1517
|
-
async getExecutions() {
|
|
1518
|
-
return [];
|
|
1519
|
-
},
|
|
1520
|
-
async listJobs() {
|
|
1521
|
-
return [...jobs.keys()];
|
|
1522
|
-
}
|
|
1523
|
-
};
|
|
1524
|
-
}
|
|
1525
|
-
|
|
1526
1497
|
// src/fallbacks/memory-i18n.ts
|
|
1527
1498
|
var import_system = require("@objectstack/spec/system");
|
|
1528
1499
|
function deepMerge(target, source) {
|
|
@@ -1750,6 +1721,35 @@ function createMemoryMetadata() {
|
|
|
1750
1721
|
};
|
|
1751
1722
|
}
|
|
1752
1723
|
|
|
1724
|
+
// src/fallbacks/memory-job.ts
|
|
1725
|
+
function createMemoryJob() {
|
|
1726
|
+
const jobs = /* @__PURE__ */ new Map();
|
|
1727
|
+
return {
|
|
1728
|
+
__serviceInfo: {
|
|
1729
|
+
status: "degraded",
|
|
1730
|
+
handlerReady: false,
|
|
1731
|
+
message: "In-process job registry \u2014 trigger() runs handlers, but scheduled jobs never fire on their own (no timer). Register a job plugin (e.g. Agenda) for real scheduling."
|
|
1732
|
+
},
|
|
1733
|
+
_serviceName: "job",
|
|
1734
|
+
async schedule(name, schedule, handler) {
|
|
1735
|
+
jobs.set(name, { schedule, handler });
|
|
1736
|
+
},
|
|
1737
|
+
async cancel(name) {
|
|
1738
|
+
jobs.delete(name);
|
|
1739
|
+
},
|
|
1740
|
+
async trigger(name, data) {
|
|
1741
|
+
const job = jobs.get(name);
|
|
1742
|
+
if (job?.handler) await job.handler({ jobId: name, data });
|
|
1743
|
+
},
|
|
1744
|
+
async getExecutions() {
|
|
1745
|
+
return [];
|
|
1746
|
+
},
|
|
1747
|
+
async listJobs() {
|
|
1748
|
+
return [...jobs.keys()];
|
|
1749
|
+
}
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
1753
|
// src/fallbacks/authored-translation-sync.ts
|
|
1754
1754
|
var import_system2 = require("@objectstack/spec/system");
|
|
1755
1755
|
var OWNER_PROP = "__authoredTranslationSyncOwner";
|
|
@@ -1882,7 +1882,6 @@ var CORE_FALLBACK_FACTORIES = {
|
|
|
1882
1882
|
metadata: createMemoryMetadata,
|
|
1883
1883
|
cache: createMemoryCache,
|
|
1884
1884
|
queue: createMemoryQueue,
|
|
1885
|
-
job: createMemoryJob,
|
|
1886
1885
|
i18n: createMemoryI18n
|
|
1887
1886
|
};
|
|
1888
1887
|
|
|
@@ -1906,6 +1905,48 @@ function registerPluginByName(registry, plugin, logger) {
|
|
|
1906
1905
|
return previous;
|
|
1907
1906
|
}
|
|
1908
1907
|
|
|
1908
|
+
// src/timeout-guard.ts
|
|
1909
|
+
var TimeoutGuard = class {
|
|
1910
|
+
constructor(timeoutMs, createTimeoutError) {
|
|
1911
|
+
/**
|
|
1912
|
+
* Settles `expiry` without a value. `Promise<never>` has no resolvable
|
|
1913
|
+
* value in the type system, but settling it is the entire point: it is
|
|
1914
|
+
* only ever called from `reclaim()`, i.e. after the race it guarded has
|
|
1915
|
+
* already been decided, so the resolution is discarded by construction and
|
|
1916
|
+
* can never become a race winner. The cast localises that argument here
|
|
1917
|
+
* rather than pushing a lie into every caller's return type.
|
|
1918
|
+
*/
|
|
1919
|
+
this.settleExpiry = () => {
|
|
1920
|
+
};
|
|
1921
|
+
this.expiry = new Promise((resolve, reject) => {
|
|
1922
|
+
this.settleExpiry = resolve;
|
|
1923
|
+
this.timer = setTimeout(() => reject(createTimeoutError()), timeoutMs);
|
|
1924
|
+
});
|
|
1925
|
+
}
|
|
1926
|
+
/**
|
|
1927
|
+
* Reclaim the guard once the race it protects has been decided. Both
|
|
1928
|
+
* halves, always: the timer is cleared so it cannot fire against a
|
|
1929
|
+
* lifecycle phase that is already over, and `expiry` is settled so neither
|
|
1930
|
+
* it nor the race's reaction on it is retained.
|
|
1931
|
+
*
|
|
1932
|
+
* Idempotent — `clearTimeout` on a cleared handle and a second resolve on
|
|
1933
|
+
* a settled promise are both no-ops.
|
|
1934
|
+
*/
|
|
1935
|
+
reclaim() {
|
|
1936
|
+
clearTimeout(this.timer);
|
|
1937
|
+
this.timer = void 0;
|
|
1938
|
+
this.settleExpiry();
|
|
1939
|
+
}
|
|
1940
|
+
};
|
|
1941
|
+
async function raceWithTimeout(operation, timeoutMs, createTimeoutError) {
|
|
1942
|
+
const guard = new TimeoutGuard(timeoutMs, createTimeoutError);
|
|
1943
|
+
try {
|
|
1944
|
+
return await Promise.race([operation, guard.expiry]);
|
|
1945
|
+
} finally {
|
|
1946
|
+
guard.reclaim();
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1909
1950
|
// src/kernel.ts
|
|
1910
1951
|
var ObjectKernel = class {
|
|
1911
1952
|
constructor(config = {}) {
|
|
@@ -2176,14 +2217,11 @@ var ObjectKernel = class {
|
|
|
2176
2217
|
this.logger.info("Graceful shutdown started");
|
|
2177
2218
|
const shutdownTimeoutError = new Error("Shutdown timeout exceeded");
|
|
2178
2219
|
try {
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
if (t.unref) t.unref();
|
|
2185
|
-
});
|
|
2186
|
-
await Promise.race([shutdownPromise, timeoutPromise]);
|
|
2220
|
+
await raceWithTimeout(
|
|
2221
|
+
this.performShutdown(),
|
|
2222
|
+
this.config.shutdownTimeout,
|
|
2223
|
+
() => shutdownTimeoutError
|
|
2224
|
+
);
|
|
2187
2225
|
this.state = "stopped";
|
|
2188
2226
|
this.logger.info("\u2705 Graceful shutdown complete");
|
|
2189
2227
|
} catch (error) {
|
|
@@ -2301,25 +2339,17 @@ var ObjectKernel = class {
|
|
|
2301
2339
|
* as well: if the hook never settles and nothing else keeps the loop alive,
|
|
2302
2340
|
* Node exits before the timer can fire and the timeout is never reported.
|
|
2303
2341
|
* The guard has to stay ref'd exactly as long as the race is undecided,
|
|
2304
|
-
* which is what
|
|
2342
|
+
* which is what clearing on settle expresses.
|
|
2305
2343
|
*
|
|
2306
|
-
*
|
|
2307
|
-
*
|
|
2308
|
-
*
|
|
2309
|
-
*
|
|
2344
|
+
* Clearing the timer was only half of it, though (#10604): the promise the
|
|
2345
|
+
* race still holds a reaction on has to SETTLE, or it and that reaction are
|
|
2346
|
+
* retained past the end of the run — two leaking promises per boot, which
|
|
2347
|
+
* is what `vitest --detectAsyncLeaks` names here. Both halves now live in
|
|
2348
|
+
* `TimeoutGuard.reclaim()`, shared with `shutdown()`, so the two sites
|
|
2349
|
+
* cannot drift into doing one half each again.
|
|
2310
2350
|
*/
|
|
2311
2351
|
async raceStartupTimeout(operation, timeout, message) {
|
|
2312
|
-
|
|
2313
|
-
const timeoutPromise = new Promise((_, reject) => {
|
|
2314
|
-
guard = setTimeout(() => {
|
|
2315
|
-
reject(new Error(message));
|
|
2316
|
-
}, timeout);
|
|
2317
|
-
});
|
|
2318
|
-
try {
|
|
2319
|
-
return await Promise.race([operation, timeoutPromise]);
|
|
2320
|
-
} finally {
|
|
2321
|
-
clearTimeout(guard);
|
|
2322
|
-
}
|
|
2352
|
+
return raceWithTimeout(operation, timeout, () => new Error(message));
|
|
2323
2353
|
}
|
|
2324
2354
|
/**
|
|
2325
2355
|
* Whether a service is resolvable on this kernel right now — direct
|
|
@@ -4530,10 +4560,11 @@ function safeJsonParse2(s, fallback) {
|
|
|
4530
4560
|
return fallback;
|
|
4531
4561
|
}
|
|
4532
4562
|
}
|
|
4533
|
-
async function tryFind(ql, object, where, limit = 100) {
|
|
4563
|
+
async function tryFind(ql, object, where, limit = 100, organizationId) {
|
|
4534
4564
|
if (!ql || typeof ql.find !== "function") return [];
|
|
4535
4565
|
try {
|
|
4536
|
-
|
|
4566
|
+
const context = organizationId ? { isSystem: true, tenantId: organizationId } : { isSystem: true };
|
|
4567
|
+
let rows = await ql.find(object, { where, limit, context });
|
|
4537
4568
|
if (rows && rows.value) rows = rows.value;
|
|
4538
4569
|
return Array.isArray(rows) ? rows : [];
|
|
4539
4570
|
} catch {
|
|
@@ -4631,12 +4662,19 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4631
4662
|
}
|
|
4632
4663
|
return userRow;
|
|
4633
4664
|
};
|
|
4665
|
+
const needsUserRow = !grants.email || !grants.permissions.includes("ai_seat");
|
|
4666
|
+
const [, members, userPositionRows, orgMembersLeg, upsRowsAll] = await Promise.all([
|
|
4667
|
+
needsUserRow ? getUserRow() : Promise.resolve(void 0),
|
|
4668
|
+
tryFind(ql, "sys_member", { user_id: userId }, 200),
|
|
4669
|
+
tryFind(ql, "sys_user_position", { user_id: userId }, 200),
|
|
4670
|
+
tenantId ? tryFind(ql, "sys_member", { organization_id: tenantId }, 1e3) : Promise.resolve([]),
|
|
4671
|
+
tryFind(ql, "sys_user_permission_set", { user_id: userId }, 100)
|
|
4672
|
+
]);
|
|
4634
4673
|
if (!grants.email) {
|
|
4635
4674
|
const u = await getUserRow();
|
|
4636
4675
|
if (u?.email) grants.email = String(u.email);
|
|
4637
4676
|
}
|
|
4638
4677
|
const nowMs = opts.nowMs ?? Date.now();
|
|
4639
|
-
const members = await tryFind(ql, "sys_member", { user_id: userId }, 200);
|
|
4640
4678
|
const accessibleOrgIds = /* @__PURE__ */ new Set();
|
|
4641
4679
|
for (const m of members) {
|
|
4642
4680
|
if (!isGrantActive(m, nowMs)) continue;
|
|
@@ -4644,7 +4682,9 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4644
4682
|
if (typeof org === "string" && org) accessibleOrgIds.add(org);
|
|
4645
4683
|
}
|
|
4646
4684
|
grants.accessible_org_ids = Array.from(accessibleOrgIds);
|
|
4647
|
-
const activeMembers =
|
|
4685
|
+
const activeMembers = members.filter(
|
|
4686
|
+
(m) => isGrantActive(m, nowMs) && (!tenantId || (m.organization_id ?? m.organizationId) === tenantId)
|
|
4687
|
+
);
|
|
4648
4688
|
for (const m of activeMembers) {
|
|
4649
4689
|
if (m.role && typeof m.role === "string") {
|
|
4650
4690
|
for (const raw of m.role.split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
@@ -4653,7 +4693,6 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4653
4693
|
}
|
|
4654
4694
|
}
|
|
4655
4695
|
}
|
|
4656
|
-
const userPositionRows = await tryFind(ql, "sys_user_position", { user_id: userId }, 200);
|
|
4657
4696
|
for (const ur of userPositionRows) {
|
|
4658
4697
|
const org = ur.organization_id ?? null;
|
|
4659
4698
|
if (org && tenantId && org !== tenantId) continue;
|
|
@@ -4662,14 +4701,13 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4662
4701
|
if (typeof r === "string" && r && !grants.positions.includes(r)) grants.positions.push(r);
|
|
4663
4702
|
}
|
|
4664
4703
|
if (tenantId) {
|
|
4665
|
-
const orgMembers =
|
|
4704
|
+
const orgMembers = orgMembersLeg;
|
|
4666
4705
|
const ids = new Set(
|
|
4667
4706
|
orgMembers.map((m) => m.user_id ?? m.userId).filter((v) => typeof v === "string" && v.length > 0)
|
|
4668
4707
|
);
|
|
4669
4708
|
ids.add(userId);
|
|
4670
4709
|
grants.org_user_ids = Array.from(ids);
|
|
4671
4710
|
}
|
|
4672
|
-
const upsRowsAll = await tryFind(ql, "sys_user_permission_set", { user_id: userId }, 100);
|
|
4673
4711
|
const upsRows = upsRowsAll.filter((r) => isGrantActive(r, nowMs));
|
|
4674
4712
|
const psIds = new Set(
|
|
4675
4713
|
upsRows.filter((r) => {
|
|
@@ -4683,7 +4721,7 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
|
|
|
4683
4721
|
let hasPlatformAdminGrant = false;
|
|
4684
4722
|
if (!grants.positions.includes("everyone")) grants.positions.push("everyone");
|
|
4685
4723
|
if (grants.positions.length > 0) {
|
|
4686
|
-
const positionRows = await tryFind(ql, "sys_position", { name: { $in: grants.positions } },
|
|
4724
|
+
const positionRows = await tryFind(ql, "sys_position", { name: { $in: grants.positions } }, 200, tenantId);
|
|
4687
4725
|
const deactivatedNames = new Set(
|
|
4688
4726
|
positionRows.filter((r) => !isRowActive(r)).map((r) => r.name).filter(Boolean)
|
|
4689
4727
|
);
|
|
@@ -4761,34 +4799,89 @@ function coerceCurrency(value) {
|
|
|
4761
4799
|
const s = typeof value === "string" ? value.trim().toUpperCase() : "";
|
|
4762
4800
|
return /^[A-Z]{3}$/.test(s) ? s : void 0;
|
|
4763
4801
|
}
|
|
4802
|
+
var LOCALIZATION_FAILURE_CACHE_TTL_MS = 3e4;
|
|
4803
|
+
var localizationFailureCache = /* @__PURE__ */ new WeakMap();
|
|
4764
4804
|
async function resolveLocalizationContext(input) {
|
|
4805
|
+
const { ql, tenantId, userId } = input;
|
|
4806
|
+
const cacheKey = `${tenantId ?? ""}|${userId ?? ""}`;
|
|
4807
|
+
if (ql && typeof ql === "object") {
|
|
4808
|
+
const hit = localizationFailureCache.get(ql)?.get(cacheKey);
|
|
4809
|
+
if (hit && hit.expiresAt > Date.now()) return hit.value;
|
|
4810
|
+
}
|
|
4811
|
+
const { value, failed } = await resolveLocalizationContextUncached(input);
|
|
4812
|
+
if (failed && ql && typeof ql === "object") {
|
|
4813
|
+
const bucket = localizationFailureCache.get(ql) ?? /* @__PURE__ */ new Map();
|
|
4814
|
+
bucket.set(cacheKey, { value, expiresAt: Date.now() + LOCALIZATION_FAILURE_CACHE_TTL_MS });
|
|
4815
|
+
localizationFailureCache.set(ql, bucket);
|
|
4816
|
+
}
|
|
4817
|
+
return value;
|
|
4818
|
+
}
|
|
4819
|
+
async function resolveLocalizationContextUncached(input) {
|
|
4765
4820
|
const { ql, settings, tenantId, userId } = input;
|
|
4821
|
+
let failed = false;
|
|
4766
4822
|
try {
|
|
4767
4823
|
if (settings && typeof settings.get === "function") {
|
|
4768
4824
|
const sctx = { tenantId, userId };
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
|
|
4825
|
+
let tzRes;
|
|
4826
|
+
let localeRes;
|
|
4827
|
+
let currencyRes;
|
|
4828
|
+
if (typeof settings.getMany === "function") {
|
|
4829
|
+
try {
|
|
4830
|
+
const many = await settings.getMany("localization", ["timezone", "locale", "currency"], sctx);
|
|
4831
|
+
tzRes = many.timezone;
|
|
4832
|
+
localeRes = many.locale;
|
|
4833
|
+
currencyRes = many.currency;
|
|
4834
|
+
} catch {
|
|
4835
|
+
failed = true;
|
|
4836
|
+
}
|
|
4837
|
+
} else {
|
|
4838
|
+
[tzRes, localeRes, currencyRes] = await Promise.all([
|
|
4839
|
+
settings.get("localization", "timezone", sctx).catch(() => {
|
|
4840
|
+
failed = true;
|
|
4841
|
+
return void 0;
|
|
4842
|
+
}),
|
|
4843
|
+
settings.get("localization", "locale", sctx).catch(() => {
|
|
4844
|
+
failed = true;
|
|
4845
|
+
return void 0;
|
|
4846
|
+
}),
|
|
4847
|
+
settings.get("localization", "currency", sctx).catch(() => {
|
|
4848
|
+
failed = true;
|
|
4849
|
+
return void 0;
|
|
4850
|
+
})
|
|
4851
|
+
]);
|
|
4852
|
+
}
|
|
4774
4853
|
const tz = coerceTimeZone(tzRes?.value);
|
|
4775
4854
|
const locale = coerceLocale(localeRes?.value);
|
|
4776
4855
|
const currency = coerceCurrency(currencyRes?.value);
|
|
4777
|
-
if (tz || locale || currency)
|
|
4856
|
+
if (tz || locale || currency) {
|
|
4857
|
+
return { value: { timezone: tz ?? "UTC", locale: locale ?? "en-US", currency }, failed: false };
|
|
4858
|
+
}
|
|
4778
4859
|
}
|
|
4779
4860
|
} catch {
|
|
4861
|
+
failed = true;
|
|
4862
|
+
}
|
|
4863
|
+
let rows = [];
|
|
4864
|
+
if (ql && typeof ql.find === "function") {
|
|
4865
|
+
try {
|
|
4866
|
+
let result = await ql.find("sys_setting", {
|
|
4867
|
+
where: { namespace: "localization", key: { $in: ["timezone", "locale", "currency"] }, scope: "tenant" },
|
|
4868
|
+
limit: 10,
|
|
4869
|
+
context: { isSystem: true }
|
|
4870
|
+
});
|
|
4871
|
+
if (result && result.value) result = result.value;
|
|
4872
|
+
rows = Array.isArray(result) ? result : [];
|
|
4873
|
+
} catch {
|
|
4874
|
+
failed = true;
|
|
4875
|
+
}
|
|
4780
4876
|
}
|
|
4781
|
-
const rows = await tryFind(
|
|
4782
|
-
ql,
|
|
4783
|
-
"sys_setting",
|
|
4784
|
-
{ namespace: "localization", key: { $in: ["timezone", "locale", "currency"] }, scope: "tenant" },
|
|
4785
|
-
10
|
|
4786
|
-
);
|
|
4787
4877
|
const valueOf = (k) => rows.find((r) => r.key === k)?.value;
|
|
4788
4878
|
return {
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
4879
|
+
value: {
|
|
4880
|
+
timezone: coerceTimeZone(valueOf("timezone")) ?? "UTC",
|
|
4881
|
+
locale: coerceLocale(valueOf("locale")) ?? "en-US",
|
|
4882
|
+
currency: coerceCurrency(valueOf("currency"))
|
|
4883
|
+
},
|
|
4884
|
+
failed
|
|
4792
4885
|
};
|
|
4793
4886
|
}
|
|
4794
4887
|
|