@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 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
- const shutdownPromise = this.performShutdown();
2180
- const timeoutPromise = new Promise((_, reject) => {
2181
- const t = setTimeout(() => {
2182
- reject(shutdownTimeoutError);
2183
- }, this.config.shutdownTimeout);
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 `clearTimeout` in a `finally` expresses.
2342
+ * which is what clearing on settle expresses.
2305
2343
  *
2306
- * `operation` is widened to `T | PromiseLike<T>` because the Plugin
2307
- * contract permits a synchronous hook (`init`/`start` return
2308
- * `void | Promise<void>`); such a hook wins the race immediately and the
2309
- * guard is reclaimed on the same turn.
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
- let guard;
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
- let rows = await ql.find(object, { where, limit, context: { isSystem: true } });
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 = tenantId ? members.filter((m) => (m.organization_id ?? m.organizationId) === tenantId) : members;
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 = await tryFind(ql, "sys_member", { organization_id: tenantId }, 1e3);
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 } }, 100);
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
- const [tzRes, localeRes, currencyRes] = await Promise.all([
4770
- settings.get("localization", "timezone", sctx).catch(() => void 0),
4771
- settings.get("localization", "locale", sctx).catch(() => void 0),
4772
- settings.get("localization", "currency", sctx).catch(() => void 0)
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) return { timezone: tz ?? "UTC", locale: locale ?? "en-US", 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
- timezone: coerceTimeZone(valueOf("timezone")) ?? "UTC",
4790
- locale: coerceLocale(valueOf("locale")) ?? "en-US",
4791
- currency: coerceCurrency(valueOf("currency"))
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