@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/dist/index.d.cts CHANGED
@@ -320,12 +320,14 @@ declare class ObjectKernel {
320
320
  * as well: if the hook never settles and nothing else keeps the loop alive,
321
321
  * Node exits before the timer can fire and the timeout is never reported.
322
322
  * The guard has to stay ref'd exactly as long as the race is undecided,
323
- * which is what `clearTimeout` in a `finally` expresses.
323
+ * which is what clearing on settle expresses.
324
324
  *
325
- * `operation` is widened to `T | PromiseLike<T>` because the Plugin
326
- * contract permits a synchronous hook (`init`/`start` return
327
- * `void | Promise<void>`); such a hook wins the race immediately and the
328
- * guard is reclaimed on the same turn.
325
+ * Clearing the timer was only half of it, though (#10604): the promise the
326
+ * race still holds a reaction on has to SETTLE, or it and that reaction are
327
+ * retained past the end of the run two leaking promises per boot, which
328
+ * is what `vitest --detectAsyncLeaks` names here. Both halves now live in
329
+ * `TimeoutGuard.reclaim()`, shared with `shutdown()`, so the two sites
330
+ * cannot drift into doing one half each again.
329
331
  */
330
332
  private raceStartupTimeout;
331
333
  /**
@@ -2021,17 +2023,27 @@ interface ResolveLocalizationInput {
2021
2023
  tenantId?: string;
2022
2024
  userId?: string;
2023
2025
  }
2026
+ type LocalizationResult = {
2027
+ timezone: string;
2028
+ locale: string;
2029
+ currency?: string;
2030
+ };
2024
2031
  /**
2025
2032
  * Resolve workspace localization defaults (reference `timezone` / `locale` /
2026
2033
  * `currency`). Canonical path is the `localization` SettingsManifest (cascade:
2027
2034
  * platform default → global → tenant); falls back to direct tenant-scoped
2028
2035
  * `sys_setting` rows, then the built-ins `UTC` / `en-US`. Never throws.
2036
+ *
2037
+ * A read that fails outright (backend fault — table missing, connection
2038
+ * refused, etc.) is memoized for {@link LOCALIZATION_FAILURE_CACHE_TTL_MS}
2039
+ * per `(ql, tenantId, userId)` so the failing query — and the driver's log
2040
+ * line for it — does not repeat every request (#10221). A successful read,
2041
+ * including a legitimate "no settings configured yet" empty result, is NEVER
2042
+ * cached: the next call always re-reads, so a settings write takes effect
2043
+ * immediately (see the cache doc above for why — the dogfood analytics
2044
+ * bucketing test pins this).
2029
2045
  */
2030
- declare function resolveLocalizationContext(input: ResolveLocalizationInput): Promise<{
2031
- timezone: string;
2032
- locale: string;
2033
- currency?: string;
2034
- }>;
2046
+ declare function resolveLocalizationContext(input: ResolveLocalizationInput): Promise<LocalizationResult>;
2035
2047
 
2036
2048
  /**
2037
2049
  * ADR-0069 — authentication-policy session gate.
@@ -3510,11 +3522,15 @@ declare function createMemoryQueue(): {
3510
3522
  };
3511
3523
 
3512
3524
  /**
3513
- * In-memory job scheduler fallback.
3525
+ * In-memory job registry — schedule/cancel/trigger bookkeeping with NO timer.
3514
3526
  *
3515
- * Implements the IJobService contract with basic schedule/cancel/trigger
3516
- * operations. Used by ObjectKernel as an automatic fallback when no real
3517
- * job plugin (e.g. Agenda / BullMQ) is registered.
3527
+ * [#10746] NOT pre-injected by ObjectKernel any more (it used to be, via
3528
+ * `CORE_FALLBACK_FACTORIES`): a fallback must not fake capability (maintainer
3529
+ * ruling 2026-08-22). Advertising a `schedule()` that records and never fires
3530
+ * made every "prefer the platform job service, else own a timer" consumer
3531
+ * take the job-service branch and then silently never run. The export remains
3532
+ * for embedders who deliberately want a manual-trigger job registry — e.g. in
3533
+ * tests that drive handlers via `trigger()` — and have read this docblock.
3518
3534
  *
3519
3535
  * [#4058] `degraded` (ADR-0076 D12), with the missing half named in the
3520
3536
  * message rather than left for a deployer to discover: `trigger()` really runs
@@ -3666,8 +3682,26 @@ declare function wireAuthoredTranslationSync(ctx: MinimalCtx): void;
3666
3682
 
3667
3683
  /**
3668
3684
  * Map of core-criticality service names to their in-memory fallback factories.
3669
- * Used by ObjectKernel.validateSystemRequirements() to auto-inject fallbacks
3670
- * when no real plugin provides the service.
3685
+ * This IS the kernel's pre-injection list: `ObjectKernel.preInjectCoreFallbacks()`
3686
+ * registers an entry for every unprovided `core` service before Phase 2, and
3687
+ * `validateSystemRequirements()` consults the same map as its final check.
3688
+ *
3689
+ * [#10746] `job` is deliberately ABSENT — a fallback must not fake capability
3690
+ * (maintainer ruling 2026-08-22). `createMemoryJob()`'s `schedule()` records a
3691
+ * job and never fires it, so pre-injecting it made every "prefer the platform
3692
+ * job service, else own a timer" consumer take the job-service branch and then
3693
+ * silently never run: `plugin-reports` logged `dispatcher registered with job
3694
+ * service` and dispatched nothing, ever (measured: 0 reads of
3695
+ * `sys_report_schedule` in 5600 ms with the success line present). With no
3696
+ * entry here, `getService('job')` throws when no job plugin is installed,
3697
+ * every consumer's documented no-job-service path becomes reachable (they all
3698
+ * already run on `LiteKernel`, which injects no fallbacks), and the kernel
3699
+ * says the absence out loud at boot: `validateSystemRequirements()` warns
3700
+ * "Core service missing, functionality may be degraded: job". Do NOT re-add
3701
+ * the entry to quiet that warning — install `@objectstack/service-job`, or
3702
+ * register a real scheduler, instead. `createMemoryJob` stays exported below
3703
+ * for embedders who deliberately want a manual-trigger job registry and have
3704
+ * read its docblock.
3671
3705
  */
3672
3706
  declare const CORE_FALLBACK_FACTORIES: Record<string, () => Record<string, any>>;
3673
3707
 
package/dist/index.d.ts CHANGED
@@ -320,12 +320,14 @@ declare class ObjectKernel {
320
320
  * as well: if the hook never settles and nothing else keeps the loop alive,
321
321
  * Node exits before the timer can fire and the timeout is never reported.
322
322
  * The guard has to stay ref'd exactly as long as the race is undecided,
323
- * which is what `clearTimeout` in a `finally` expresses.
323
+ * which is what clearing on settle expresses.
324
324
  *
325
- * `operation` is widened to `T | PromiseLike<T>` because the Plugin
326
- * contract permits a synchronous hook (`init`/`start` return
327
- * `void | Promise<void>`); such a hook wins the race immediately and the
328
- * guard is reclaimed on the same turn.
325
+ * Clearing the timer was only half of it, though (#10604): the promise the
326
+ * race still holds a reaction on has to SETTLE, or it and that reaction are
327
+ * retained past the end of the run two leaking promises per boot, which
328
+ * is what `vitest --detectAsyncLeaks` names here. Both halves now live in
329
+ * `TimeoutGuard.reclaim()`, shared with `shutdown()`, so the two sites
330
+ * cannot drift into doing one half each again.
329
331
  */
330
332
  private raceStartupTimeout;
331
333
  /**
@@ -2021,17 +2023,27 @@ interface ResolveLocalizationInput {
2021
2023
  tenantId?: string;
2022
2024
  userId?: string;
2023
2025
  }
2026
+ type LocalizationResult = {
2027
+ timezone: string;
2028
+ locale: string;
2029
+ currency?: string;
2030
+ };
2024
2031
  /**
2025
2032
  * Resolve workspace localization defaults (reference `timezone` / `locale` /
2026
2033
  * `currency`). Canonical path is the `localization` SettingsManifest (cascade:
2027
2034
  * platform default → global → tenant); falls back to direct tenant-scoped
2028
2035
  * `sys_setting` rows, then the built-ins `UTC` / `en-US`. Never throws.
2036
+ *
2037
+ * A read that fails outright (backend fault — table missing, connection
2038
+ * refused, etc.) is memoized for {@link LOCALIZATION_FAILURE_CACHE_TTL_MS}
2039
+ * per `(ql, tenantId, userId)` so the failing query — and the driver's log
2040
+ * line for it — does not repeat every request (#10221). A successful read,
2041
+ * including a legitimate "no settings configured yet" empty result, is NEVER
2042
+ * cached: the next call always re-reads, so a settings write takes effect
2043
+ * immediately (see the cache doc above for why — the dogfood analytics
2044
+ * bucketing test pins this).
2029
2045
  */
2030
- declare function resolveLocalizationContext(input: ResolveLocalizationInput): Promise<{
2031
- timezone: string;
2032
- locale: string;
2033
- currency?: string;
2034
- }>;
2046
+ declare function resolveLocalizationContext(input: ResolveLocalizationInput): Promise<LocalizationResult>;
2035
2047
 
2036
2048
  /**
2037
2049
  * ADR-0069 — authentication-policy session gate.
@@ -3510,11 +3522,15 @@ declare function createMemoryQueue(): {
3510
3522
  };
3511
3523
 
3512
3524
  /**
3513
- * In-memory job scheduler fallback.
3525
+ * In-memory job registry — schedule/cancel/trigger bookkeeping with NO timer.
3514
3526
  *
3515
- * Implements the IJobService contract with basic schedule/cancel/trigger
3516
- * operations. Used by ObjectKernel as an automatic fallback when no real
3517
- * job plugin (e.g. Agenda / BullMQ) is registered.
3527
+ * [#10746] NOT pre-injected by ObjectKernel any more (it used to be, via
3528
+ * `CORE_FALLBACK_FACTORIES`): a fallback must not fake capability (maintainer
3529
+ * ruling 2026-08-22). Advertising a `schedule()` that records and never fires
3530
+ * made every "prefer the platform job service, else own a timer" consumer
3531
+ * take the job-service branch and then silently never run. The export remains
3532
+ * for embedders who deliberately want a manual-trigger job registry — e.g. in
3533
+ * tests that drive handlers via `trigger()` — and have read this docblock.
3518
3534
  *
3519
3535
  * [#4058] `degraded` (ADR-0076 D12), with the missing half named in the
3520
3536
  * message rather than left for a deployer to discover: `trigger()` really runs
@@ -3666,8 +3682,26 @@ declare function wireAuthoredTranslationSync(ctx: MinimalCtx): void;
3666
3682
 
3667
3683
  /**
3668
3684
  * Map of core-criticality service names to their in-memory fallback factories.
3669
- * Used by ObjectKernel.validateSystemRequirements() to auto-inject fallbacks
3670
- * when no real plugin provides the service.
3685
+ * This IS the kernel's pre-injection list: `ObjectKernel.preInjectCoreFallbacks()`
3686
+ * registers an entry for every unprovided `core` service before Phase 2, and
3687
+ * `validateSystemRequirements()` consults the same map as its final check.
3688
+ *
3689
+ * [#10746] `job` is deliberately ABSENT — a fallback must not fake capability
3690
+ * (maintainer ruling 2026-08-22). `createMemoryJob()`'s `schedule()` records a
3691
+ * job and never fires it, so pre-injecting it made every "prefer the platform
3692
+ * job service, else own a timer" consumer take the job-service branch and then
3693
+ * silently never run: `plugin-reports` logged `dispatcher registered with job
3694
+ * service` and dispatched nothing, ever (measured: 0 reads of
3695
+ * `sys_report_schedule` in 5600 ms with the success line present). With no
3696
+ * entry here, `getService('job')` throws when no job plugin is installed,
3697
+ * every consumer's documented no-job-service path becomes reachable (they all
3698
+ * already run on `LiteKernel`, which injects no fallbacks), and the kernel
3699
+ * says the absence out loud at boot: `validateSystemRequirements()` warns
3700
+ * "Core service missing, functionality may be degraded: job". Do NOT re-add
3701
+ * the entry to quiet that warning — install `@objectstack/service-job`, or
3702
+ * register a real scheduler, instead. `createMemoryJob` stays exported below
3703
+ * for embedders who deliberately want a manual-trigger job registry and have
3704
+ * read its docblock.
3671
3705
  */
3672
3706
  declare const CORE_FALLBACK_FACTORIES: Record<string, () => Record<string, any>>;
3673
3707
 
package/dist/index.js CHANGED
@@ -1355,35 +1355,6 @@ function createMemoryQueue() {
1355
1355
  };
1356
1356
  }
1357
1357
 
1358
- // src/fallbacks/memory-job.ts
1359
- function createMemoryJob() {
1360
- const jobs = /* @__PURE__ */ new Map();
1361
- return {
1362
- __serviceInfo: {
1363
- status: "degraded",
1364
- handlerReady: false,
1365
- 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."
1366
- },
1367
- _serviceName: "job",
1368
- async schedule(name, schedule, handler) {
1369
- jobs.set(name, { schedule, handler });
1370
- },
1371
- async cancel(name) {
1372
- jobs.delete(name);
1373
- },
1374
- async trigger(name, data) {
1375
- const job = jobs.get(name);
1376
- if (job?.handler) await job.handler({ jobId: name, data });
1377
- },
1378
- async getExecutions() {
1379
- return [];
1380
- },
1381
- async listJobs() {
1382
- return [...jobs.keys()];
1383
- }
1384
- };
1385
- }
1386
-
1387
1358
  // src/fallbacks/memory-i18n.ts
1388
1359
  import { normalizeSupportedLocales } from "@objectstack/spec/system";
1389
1360
  function deepMerge(target, source) {
@@ -1611,6 +1582,35 @@ function createMemoryMetadata() {
1611
1582
  };
1612
1583
  }
1613
1584
 
1585
+ // src/fallbacks/memory-job.ts
1586
+ function createMemoryJob() {
1587
+ const jobs = /* @__PURE__ */ new Map();
1588
+ return {
1589
+ __serviceInfo: {
1590
+ status: "degraded",
1591
+ handlerReady: false,
1592
+ 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."
1593
+ },
1594
+ _serviceName: "job",
1595
+ async schedule(name, schedule, handler) {
1596
+ jobs.set(name, { schedule, handler });
1597
+ },
1598
+ async cancel(name) {
1599
+ jobs.delete(name);
1600
+ },
1601
+ async trigger(name, data) {
1602
+ const job = jobs.get(name);
1603
+ if (job?.handler) await job.handler({ jobId: name, data });
1604
+ },
1605
+ async getExecutions() {
1606
+ return [];
1607
+ },
1608
+ async listJobs() {
1609
+ return [...jobs.keys()];
1610
+ }
1611
+ };
1612
+ }
1613
+
1614
1614
  // src/fallbacks/authored-translation-sync.ts
1615
1615
  import { LEGACY_OBJECT_FIRST_KEYS } from "@objectstack/spec/system";
1616
1616
  var OWNER_PROP = "__authoredTranslationSyncOwner";
@@ -1743,7 +1743,6 @@ var CORE_FALLBACK_FACTORIES = {
1743
1743
  metadata: createMemoryMetadata,
1744
1744
  cache: createMemoryCache,
1745
1745
  queue: createMemoryQueue,
1746
- job: createMemoryJob,
1747
1746
  i18n: createMemoryI18n
1748
1747
  };
1749
1748
 
@@ -1767,6 +1766,48 @@ function registerPluginByName(registry, plugin, logger) {
1767
1766
  return previous;
1768
1767
  }
1769
1768
 
1769
+ // src/timeout-guard.ts
1770
+ var TimeoutGuard = class {
1771
+ constructor(timeoutMs, createTimeoutError) {
1772
+ /**
1773
+ * Settles `expiry` without a value. `Promise<never>` has no resolvable
1774
+ * value in the type system, but settling it is the entire point: it is
1775
+ * only ever called from `reclaim()`, i.e. after the race it guarded has
1776
+ * already been decided, so the resolution is discarded by construction and
1777
+ * can never become a race winner. The cast localises that argument here
1778
+ * rather than pushing a lie into every caller's return type.
1779
+ */
1780
+ this.settleExpiry = () => {
1781
+ };
1782
+ this.expiry = new Promise((resolve, reject) => {
1783
+ this.settleExpiry = resolve;
1784
+ this.timer = setTimeout(() => reject(createTimeoutError()), timeoutMs);
1785
+ });
1786
+ }
1787
+ /**
1788
+ * Reclaim the guard once the race it protects has been decided. Both
1789
+ * halves, always: the timer is cleared so it cannot fire against a
1790
+ * lifecycle phase that is already over, and `expiry` is settled so neither
1791
+ * it nor the race's reaction on it is retained.
1792
+ *
1793
+ * Idempotent — `clearTimeout` on a cleared handle and a second resolve on
1794
+ * a settled promise are both no-ops.
1795
+ */
1796
+ reclaim() {
1797
+ clearTimeout(this.timer);
1798
+ this.timer = void 0;
1799
+ this.settleExpiry();
1800
+ }
1801
+ };
1802
+ async function raceWithTimeout(operation, timeoutMs, createTimeoutError) {
1803
+ const guard = new TimeoutGuard(timeoutMs, createTimeoutError);
1804
+ try {
1805
+ return await Promise.race([operation, guard.expiry]);
1806
+ } finally {
1807
+ guard.reclaim();
1808
+ }
1809
+ }
1810
+
1770
1811
  // src/kernel.ts
1771
1812
  var ObjectKernel = class {
1772
1813
  constructor(config = {}) {
@@ -2037,14 +2078,11 @@ var ObjectKernel = class {
2037
2078
  this.logger.info("Graceful shutdown started");
2038
2079
  const shutdownTimeoutError = new Error("Shutdown timeout exceeded");
2039
2080
  try {
2040
- const shutdownPromise = this.performShutdown();
2041
- const timeoutPromise = new Promise((_, reject) => {
2042
- const t = setTimeout(() => {
2043
- reject(shutdownTimeoutError);
2044
- }, this.config.shutdownTimeout);
2045
- if (t.unref) t.unref();
2046
- });
2047
- await Promise.race([shutdownPromise, timeoutPromise]);
2081
+ await raceWithTimeout(
2082
+ this.performShutdown(),
2083
+ this.config.shutdownTimeout,
2084
+ () => shutdownTimeoutError
2085
+ );
2048
2086
  this.state = "stopped";
2049
2087
  this.logger.info("\u2705 Graceful shutdown complete");
2050
2088
  } catch (error) {
@@ -2162,25 +2200,17 @@ var ObjectKernel = class {
2162
2200
  * as well: if the hook never settles and nothing else keeps the loop alive,
2163
2201
  * Node exits before the timer can fire and the timeout is never reported.
2164
2202
  * The guard has to stay ref'd exactly as long as the race is undecided,
2165
- * which is what `clearTimeout` in a `finally` expresses.
2203
+ * which is what clearing on settle expresses.
2166
2204
  *
2167
- * `operation` is widened to `T | PromiseLike<T>` because the Plugin
2168
- * contract permits a synchronous hook (`init`/`start` return
2169
- * `void | Promise<void>`); such a hook wins the race immediately and the
2170
- * guard is reclaimed on the same turn.
2205
+ * Clearing the timer was only half of it, though (#10604): the promise the
2206
+ * race still holds a reaction on has to SETTLE, or it and that reaction are
2207
+ * retained past the end of the run two leaking promises per boot, which
2208
+ * is what `vitest --detectAsyncLeaks` names here. Both halves now live in
2209
+ * `TimeoutGuard.reclaim()`, shared with `shutdown()`, so the two sites
2210
+ * cannot drift into doing one half each again.
2171
2211
  */
2172
2212
  async raceStartupTimeout(operation, timeout, message) {
2173
- let guard;
2174
- const timeoutPromise = new Promise((_, reject) => {
2175
- guard = setTimeout(() => {
2176
- reject(new Error(message));
2177
- }, timeout);
2178
- });
2179
- try {
2180
- return await Promise.race([operation, timeoutPromise]);
2181
- } finally {
2182
- clearTimeout(guard);
2183
- }
2213
+ return raceWithTimeout(operation, timeout, () => new Error(message));
2184
2214
  }
2185
2215
  /**
2186
2216
  * Whether a service is resolvable on this kernel right now — direct
@@ -4396,10 +4426,11 @@ function safeJsonParse2(s, fallback) {
4396
4426
  return fallback;
4397
4427
  }
4398
4428
  }
4399
- async function tryFind(ql, object, where, limit = 100) {
4429
+ async function tryFind(ql, object, where, limit = 100, organizationId) {
4400
4430
  if (!ql || typeof ql.find !== "function") return [];
4401
4431
  try {
4402
- let rows = await ql.find(object, { where, limit, context: { isSystem: true } });
4432
+ const context = organizationId ? { isSystem: true, tenantId: organizationId } : { isSystem: true };
4433
+ let rows = await ql.find(object, { where, limit, context });
4403
4434
  if (rows && rows.value) rows = rows.value;
4404
4435
  return Array.isArray(rows) ? rows : [];
4405
4436
  } catch {
@@ -4497,12 +4528,19 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
4497
4528
  }
4498
4529
  return userRow;
4499
4530
  };
4531
+ const needsUserRow = !grants.email || !grants.permissions.includes("ai_seat");
4532
+ const [, members, userPositionRows, orgMembersLeg, upsRowsAll] = await Promise.all([
4533
+ needsUserRow ? getUserRow() : Promise.resolve(void 0),
4534
+ tryFind(ql, "sys_member", { user_id: userId }, 200),
4535
+ tryFind(ql, "sys_user_position", { user_id: userId }, 200),
4536
+ tenantId ? tryFind(ql, "sys_member", { organization_id: tenantId }, 1e3) : Promise.resolve([]),
4537
+ tryFind(ql, "sys_user_permission_set", { user_id: userId }, 100)
4538
+ ]);
4500
4539
  if (!grants.email) {
4501
4540
  const u = await getUserRow();
4502
4541
  if (u?.email) grants.email = String(u.email);
4503
4542
  }
4504
4543
  const nowMs = opts.nowMs ?? Date.now();
4505
- const members = await tryFind(ql, "sys_member", { user_id: userId }, 200);
4506
4544
  const accessibleOrgIds = /* @__PURE__ */ new Set();
4507
4545
  for (const m of members) {
4508
4546
  if (!isGrantActive(m, nowMs)) continue;
@@ -4510,7 +4548,9 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
4510
4548
  if (typeof org === "string" && org) accessibleOrgIds.add(org);
4511
4549
  }
4512
4550
  grants.accessible_org_ids = Array.from(accessibleOrgIds);
4513
- const activeMembers = tenantId ? members.filter((m) => (m.organization_id ?? m.organizationId) === tenantId) : members;
4551
+ const activeMembers = members.filter(
4552
+ (m) => isGrantActive(m, nowMs) && (!tenantId || (m.organization_id ?? m.organizationId) === tenantId)
4553
+ );
4514
4554
  for (const m of activeMembers) {
4515
4555
  if (m.role && typeof m.role === "string") {
4516
4556
  for (const raw of m.role.split(",").map((s) => s.trim()).filter(Boolean)) {
@@ -4519,7 +4559,6 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
4519
4559
  }
4520
4560
  }
4521
4561
  }
4522
- const userPositionRows = await tryFind(ql, "sys_user_position", { user_id: userId }, 200);
4523
4562
  for (const ur of userPositionRows) {
4524
4563
  const org = ur.organization_id ?? null;
4525
4564
  if (org && tenantId && org !== tenantId) continue;
@@ -4528,14 +4567,13 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
4528
4567
  if (typeof r === "string" && r && !grants.positions.includes(r)) grants.positions.push(r);
4529
4568
  }
4530
4569
  if (tenantId) {
4531
- const orgMembers = await tryFind(ql, "sys_member", { organization_id: tenantId }, 1e3);
4570
+ const orgMembers = orgMembersLeg;
4532
4571
  const ids = new Set(
4533
4572
  orgMembers.map((m) => m.user_id ?? m.userId).filter((v) => typeof v === "string" && v.length > 0)
4534
4573
  );
4535
4574
  ids.add(userId);
4536
4575
  grants.org_user_ids = Array.from(ids);
4537
4576
  }
4538
- const upsRowsAll = await tryFind(ql, "sys_user_permission_set", { user_id: userId }, 100);
4539
4577
  const upsRows = upsRowsAll.filter((r) => isGrantActive(r, nowMs));
4540
4578
  const psIds = new Set(
4541
4579
  upsRows.filter((r) => {
@@ -4549,7 +4587,7 @@ async function resolveUserAuthzGrants(ql, userId, opts = {}) {
4549
4587
  let hasPlatformAdminGrant = false;
4550
4588
  if (!grants.positions.includes("everyone")) grants.positions.push("everyone");
4551
4589
  if (grants.positions.length > 0) {
4552
- const positionRows = await tryFind(ql, "sys_position", { name: { $in: grants.positions } }, 100);
4590
+ const positionRows = await tryFind(ql, "sys_position", { name: { $in: grants.positions } }, 200, tenantId);
4553
4591
  const deactivatedNames = new Set(
4554
4592
  positionRows.filter((r) => !isRowActive(r)).map((r) => r.name).filter(Boolean)
4555
4593
  );
@@ -4627,34 +4665,89 @@ function coerceCurrency(value) {
4627
4665
  const s = typeof value === "string" ? value.trim().toUpperCase() : "";
4628
4666
  return /^[A-Z]{3}$/.test(s) ? s : void 0;
4629
4667
  }
4668
+ var LOCALIZATION_FAILURE_CACHE_TTL_MS = 3e4;
4669
+ var localizationFailureCache = /* @__PURE__ */ new WeakMap();
4630
4670
  async function resolveLocalizationContext(input) {
4671
+ const { ql, tenantId, userId } = input;
4672
+ const cacheKey = `${tenantId ?? ""}|${userId ?? ""}`;
4673
+ if (ql && typeof ql === "object") {
4674
+ const hit = localizationFailureCache.get(ql)?.get(cacheKey);
4675
+ if (hit && hit.expiresAt > Date.now()) return hit.value;
4676
+ }
4677
+ const { value, failed } = await resolveLocalizationContextUncached(input);
4678
+ if (failed && ql && typeof ql === "object") {
4679
+ const bucket = localizationFailureCache.get(ql) ?? /* @__PURE__ */ new Map();
4680
+ bucket.set(cacheKey, { value, expiresAt: Date.now() + LOCALIZATION_FAILURE_CACHE_TTL_MS });
4681
+ localizationFailureCache.set(ql, bucket);
4682
+ }
4683
+ return value;
4684
+ }
4685
+ async function resolveLocalizationContextUncached(input) {
4631
4686
  const { ql, settings, tenantId, userId } = input;
4687
+ let failed = false;
4632
4688
  try {
4633
4689
  if (settings && typeof settings.get === "function") {
4634
4690
  const sctx = { tenantId, userId };
4635
- const [tzRes, localeRes, currencyRes] = await Promise.all([
4636
- settings.get("localization", "timezone", sctx).catch(() => void 0),
4637
- settings.get("localization", "locale", sctx).catch(() => void 0),
4638
- settings.get("localization", "currency", sctx).catch(() => void 0)
4639
- ]);
4691
+ let tzRes;
4692
+ let localeRes;
4693
+ let currencyRes;
4694
+ if (typeof settings.getMany === "function") {
4695
+ try {
4696
+ const many = await settings.getMany("localization", ["timezone", "locale", "currency"], sctx);
4697
+ tzRes = many.timezone;
4698
+ localeRes = many.locale;
4699
+ currencyRes = many.currency;
4700
+ } catch {
4701
+ failed = true;
4702
+ }
4703
+ } else {
4704
+ [tzRes, localeRes, currencyRes] = await Promise.all([
4705
+ settings.get("localization", "timezone", sctx).catch(() => {
4706
+ failed = true;
4707
+ return void 0;
4708
+ }),
4709
+ settings.get("localization", "locale", sctx).catch(() => {
4710
+ failed = true;
4711
+ return void 0;
4712
+ }),
4713
+ settings.get("localization", "currency", sctx).catch(() => {
4714
+ failed = true;
4715
+ return void 0;
4716
+ })
4717
+ ]);
4718
+ }
4640
4719
  const tz = coerceTimeZone(tzRes?.value);
4641
4720
  const locale = coerceLocale(localeRes?.value);
4642
4721
  const currency = coerceCurrency(currencyRes?.value);
4643
- if (tz || locale || currency) return { timezone: tz ?? "UTC", locale: locale ?? "en-US", currency };
4722
+ if (tz || locale || currency) {
4723
+ return { value: { timezone: tz ?? "UTC", locale: locale ?? "en-US", currency }, failed: false };
4724
+ }
4644
4725
  }
4645
4726
  } catch {
4727
+ failed = true;
4728
+ }
4729
+ let rows = [];
4730
+ if (ql && typeof ql.find === "function") {
4731
+ try {
4732
+ let result = await ql.find("sys_setting", {
4733
+ where: { namespace: "localization", key: { $in: ["timezone", "locale", "currency"] }, scope: "tenant" },
4734
+ limit: 10,
4735
+ context: { isSystem: true }
4736
+ });
4737
+ if (result && result.value) result = result.value;
4738
+ rows = Array.isArray(result) ? result : [];
4739
+ } catch {
4740
+ failed = true;
4741
+ }
4646
4742
  }
4647
- const rows = await tryFind(
4648
- ql,
4649
- "sys_setting",
4650
- { namespace: "localization", key: { $in: ["timezone", "locale", "currency"] }, scope: "tenant" },
4651
- 10
4652
- );
4653
4743
  const valueOf = (k) => rows.find((r) => r.key === k)?.value;
4654
4744
  return {
4655
- timezone: coerceTimeZone(valueOf("timezone")) ?? "UTC",
4656
- locale: coerceLocale(valueOf("locale")) ?? "en-US",
4657
- currency: coerceCurrency(valueOf("currency"))
4745
+ value: {
4746
+ timezone: coerceTimeZone(valueOf("timezone")) ?? "UTC",
4747
+ locale: coerceLocale(valueOf("locale")) ?? "en-US",
4748
+ currency: coerceCurrency(valueOf("currency"))
4749
+ },
4750
+ failed
4658
4751
  };
4659
4752
  }
4660
4753