@objectstack/runtime@16.0.0
Major Changes
-
6c270a6: BREAKING: remove the deprecated
ctx.session.tenantId/ctx.user.tenantIdalias from the hook & action authoring surface — converge onorganizationId(#3290).#3280 made
organizationIdthe blessed developer-facing name for the caller's active org across the JS authoring surface and kepttenantIdas a@deprecatedalias carrying the identical value. That alias is now removed from the hookctx.session, the action-bodyctx.session, and the action-bodyctx.user. Read the caller's active org under the single blessed name:- const org = ctx.session.tenantId; // hook or action body + const org = ctx.user?.organizationId ?? ctx.session?.organizationId;
FROM → TO migration (in any
*.hook.ts/*.action.tsbody):ctx.session.tenantId→ctx.session.organizationIdctx.user.tenantId(action body) →ctx.user.organizationId
The value is unchanged —
organizationIdis the same active-org id, matching theorganization_idcolumn andcurrent_user.organizationIdin RLS/sharing.ctx.userisundefinedfor system / unauthenticated writes, so readctx.session?.organizationIdwhen a hook or action must work regardless of a resolved user.What changed internally:
@objectstack/spec—HookContextSchema.sessiondrops thetenantIdfield (onlyorganizationIdremains). A straytenantIdon a constructed session is now stripped by the schema.@objectstack/objectql— the engine'sbuildSession()no longer emitssession.tenantId; the audit-stamp plugin sources thetenant_idcolumn fromsession.organizationId.@objectstack/runtime—buildActionSession()and the REST actionctx.userno longer emittenantId.@objectstack/trigger-record-change— readssession.organizationId(wassession.tenantId) when forwarding the writer's org to arunAs:'user'flow; behavior is identical.
Explicit non-goal (unchanged): the generic driver-layer tenancy abstraction is not touched —
ExecutionContext.tenantId,DriverOptions.tenantId,SqlDriver.applyTenantScope/TenancyConfig.tenantField, andExecutionLog.tenantId. That isolation column is configurable and legitimately carries an environment id in database-per-tenant kernels; it is a distinct axis from the developer-facing org. The build-timecheck:org-identifierguard now also coverspackages/**to keep reference bodies off the removed name.
Minor Changes
-
2049b6a: feat(observability): admin-gated per-request
Server-TimingviaX-OS-Debug-Timing(#2408)Perf-tuning mode was previously global-only (
serverTimingoption /
OS_SERVER_TIMING), which discloses internal phase durations — a mild
backend-fingerprinting surface — to every caller. This adds the per-request
gating path from the design so an operator can pull a single request's
Server-Timingbreakdown on a live environment without turning the header on
for everyone.- observability: a request-scoped disclosure gate (
runWithPerfDisclosure,
allowPerfDisclosure,isPerfDisclosureAllowed,PerfDisclosureGate) kept
separate from the purePerfTimingcollector and pinned to its own
Symbol.forstore so the middleware and dispatcher share it across module
copies. - plugin-hono-server: the Server-Timing middleware is registered by default
(unlessserverTiming: false). It runs the collector when timing is global
or the request sendsX-OS-Debug-Timing: 1, and emits the header only
when the gate is open.OS_PERF_TIMING=1now also enables global mode. - runtime: after resolving the execution context, the dispatcher opens the
gate for admin/service/system principals, so ordinary callers never receive
the header even if they send the debug header.
Existing global-mode behavior is unchanged.
- observability: a request-scoped disclosure gate (
-
92f5f19: feat(runtime): sandbox budget is script CPU-time, not wall clock (ADR-0102 D1, #3295)
The QuickJS sandbox now meters each hook/action invocation against how much
VM-active (CPU) time the body burns, not wall clock. Idle host-await time and
a nested hook's own execution (which runs host-side while the caller's VM is
parked) are no longer charged to the caller — so a slow/loaded host or a deep
nested-write chain can't trip the budget while a script is merely waiting (the
root cause of the #3259 CI flake). A separate, generous wall-clock ceiling
(default 30s,max(ceiling, cpuBudget)) remains as the backstop for a body stuck
on a host call that never settles.What changes for consumers (behaviour, not API signatures):
- Meaning of the timeout knobs.
body.timeoutMs, thehookTimeoutMs/
actionTimeoutMsrunner options, andOS_SANDBOX_HOOK_TIMEOUT_MS/
OS_SANDBOX_ACTION_TIMEOUT_MSkeep their names, defaults (250ms / 5000ms),
and precedence — but now bound CPU-time instead of wall-clock. In practice
this only loosens legitimate slow/nested work; a runaway synchronous script
is still cut at the same budget. - Error messages.
exceeded timeout of Nms→ eitherexceeded CPU budget of Nms(script burned its CPU budget) orexceeded wall-clock ceiling of Nms while awaiting host calls(stuck on a never-settling host call). Update any
code/tests matching the old string.
New knobs (additive):
QuickJSScriptRunneroptionwallCeilingMsand envOS_SANDBOX_WALL_CEILING_MS
— tune the wall ceiling (explicit option › env › 30s).resolveSandboxTimeoutMs(@objectstack/types) gains a'wallCeiling'kind.
Also fixes a latent init bug in the new accounting where the interrupt handler
could fire duringinstallCtxand corrupt ctx marshalling. The nested-write
integration suites now run at the stock 250ms budget (previously forced to 10s),
which is itself the regression guard for the nested-charging fix. - Meaning of the timeout knobs.
-
32899e6: feat(runtime): env-overridable sandbox hook/action timeout default (#3259)
The QuickJS sandbox enforces a wall-clock deadline on every hook/action
invocation (250ms hooks / 5000ms actions). Each invocation compiles a fresh
WASM module, and a nested hook compiles ANOTHER one inside the parent's budget,
so on a heavily loaded or slow host — an oversubscribed CI runner, constrained
production hardware — that fixed VM-creation cost alone can trip the hook
default even while the VM is still making progress. On CI this surfaced as an
intermittenthook '…' exceeded timeout of 250msflake on PRs that never
touched the sandbox path.The per-invocation timeout DEFAULT is now resolvable from the environment via
resolveSandboxTimeoutMs(@objectstack/types), whichQuickJSScriptRunner
consults, so an operator can raise the floor once, deployment-wide, instead of
re-tuning every call site:OS_SANDBOX_HOOK_TIMEOUT_MS— default hook budget (ms)OS_SANDBOX_ACTION_TIMEOUT_MS— default action budget (ms)
Precedence is unchanged: an explicit
hookTimeoutMs/actionTimeoutMspassed
to the runner still wins over the env var, and a body's own declaredtimeoutMs
still wins over the resolved default (the smaller of the explicit values). Only
a positive integer is honored; unset / empty / non-numeric / non-positive keeps
the built-in 250ms / 5000ms defaults, so behaviour is byte-for-byte unchanged
when the vars are absent — production is unaffected unless it opts in.CI's Test Core now sets
OS_SANDBOX_HOOK_TIMEOUT_MS=10000so the shared-runner
load flake can't recur; genuine hangs stay bounded by each test's own timeout.
Patch Changes
-
b39c65d: Extend the blessed
organizationIdorg name to the action-body surface (follow-up to #3280). Hooks now teachctx.user.organizationId/ctx.session.organizationIdas the blessed name for the caller's active org; action bodies — the sibling authoring surface that shares the same sandbox runner — were left behind: the REST dispatch path exposed onlyctx.user.tenantId(the deprecated name) and noctx.sessionat all, and the MCPrun_actionpath exposed neither.Both action-dispatch sites (
handleActions, MCPrunAction) now populate:ctx.user.organizationId— the blessed name (matches theorganization_idcolumn andcurrent_user.organizationIdin RLS);ctx.user.tenantIdis kept as a deprecated alias with the identical value on the REST path.ctx.session({ userId, organizationId, tenantId, roles? }) — mirrors the hookctx.sessionshape,undefinedfor a context-less / self-invoked call.
Action bodies execute trusted (the
ctx.engine/ctx.apifacade bypasses RLS/FLS), so a body that must scope by org has to read it fromctx— now under the same name a hook author uses. Additive and behavior-preserving; the objectstack-ui skill documents the action-bodyctxand theorganizationIdread. -
fdc244e: Dev-loop DX fixes from the 15.1 third-party evaluation (P2 batch):
- Hot-added objects are now queryable without a restart. Adding a
*.object.tsunderos devused to recompile "green" while every query answeredno such table(ornot registered) until a manual restart: the artifact reload never notified the ObjectQL registry, tables were only created at boot, and seeds only loaded from the boot-time bundle. Themetadata:reloadedpayload now carries the parsed artifact; ObjectQL ingests the object definitions and re-runs the idempotent schema sync (sameskipSchemaSyncopt-out as boot), and the runtime loads seeds for first-seen objects (dev, single-tenant).os devalso prints✚ new object(s): …on recompile. - Dev admin credentials stay visible. The
os devstartup banner only showedadmin@objectos.ai / admin123on the boot that actually seeded it; with the persistent default DB every later boot hid it, and the Console login page never knew it existed. The hint now re-arms on every dev boot for as long as the account still verifies against the default password, andGET /api/v1/auth/configexposes a dev-gateddevSeedAdminfield (never present outsideNODE_ENV=development) so the login page can show it. os doctorreference analysis understands current metadata shapes. Objects bound throughdefineViewcontainers (list/listViews/form/formViews→data.object, subformchildObject, lookup form fields) and app navigation (objectName, nestedchildren,areas) were reported as "defined but not referenced". The collector now walks the canonical shapes (plus flow nodeconfig.object/objectName) and the orphan-view check descends into containers.
- Hot-added objects are now queryable without a restart. Adding a
-
2ea08ee: Flow trigger observability — kill the four-layer silence around record-change flows that never fire (2026-07-17 third-party eval).
A misauthored auto-launched flow (wrong
objectName, missingrequires: ['automation','triggers'], failing start condition) produced ZERO output at every layer: the engine's own registration/binding logs land inside the CLI's boot-quiet stdout window (which swallows debug/info/warn — only error/fatal reach stderr), and each "didn't happen" path was itself silent. Fixes:- Startup banner
Flows:section (os serve/os dev/os start): flow count, bound-to-trigger count, registered trigger types, draft count — plus loud⚠lines for flows declared with no automation engine enabled (requiresmissing), flows whose trigger type has no registered trigger, and bound record-change flows targeting an unknown object (dead binding). Printed after stdout is restored, so it is immune to the boot-quiet window. - Trigger-fired run failures now log at ERROR (stderr — always visible): the automation engine no longer drops the AutomationResult of a trigger-fired execution; condition-evaluation faults and node failures surface with the flow name. Condition-not-met skips stay at debug (high-frequency, intentional).
RecordChangeTriggerprobes object existence at bind time and warns when a flow'sobjectNamematches no registered object (exact-name matching), instead of silently arming a hook that can never fire.kernel:bootstrappedbinding audit in the automation plugin: warns per enabled-but-unbound triggered flow with the reason, and reports registered/bound/draft counts (AutomationEngine.getTriggerBindingAudit(), extendedgetFlowRuntimeStates()withstatus/triggerType/object).os validateflow-wiring advisories (@objectstack/lintvalidateFlowTriggerReadiness): warns when a record-triggered flow targets an object the stack does not define, and when an auto-triggered flow's status isdraft(authored or defaulted — draft flows still fire; declareactiveorobsolete).- Removed leftover boot-debug writes (
registerApp/AppPlugin/StandaloneStack/AuditPluginstderr noise) that previous debugging of this same silence had left behind.
- Startup banner
-
d1d1c40: fix(runtime): honor a hook body's declared
timeoutMsso nested cross-object writes aren't clamped to 250ms (#1867)Hook bodies run in the QuickJS sandbox with a default 250ms timeout. The runner
folded that engine default straight intoMath.min(...)when resolving the
effective timeout, so it always dominated for hooks: a body that declared a
largertimeoutMs(the spec permits up to 30_000ms —ScriptBody.timeoutMs) to
give a legitimate nested write — "when a child changes, update the parent" —
room to settle was silently clamped back to 250ms and killed mid-flight. The
declared knob was never enforced.The engine default is now a FALLBACK used only when no explicit timeout is
supplied, not a hard ceiling. An explicitbody.timeoutMs(and/or an enclosing
hook/action timeout) is honored; when both are present the smaller wins. Bodies
that declare nothing still get the 250ms hook / 5000ms action default, and a
body may still LOWER its own timeout below the default.This clears the last reliability blocker for nested cross-object writes from
hooks — the sandbox crash itself (memory access out of bounds) was already
fixed by the deferred-promise host-call model — so header/rollup fields no
longer need denormalized, hand-maintained workarounds. -
ee0a499: feat(i18n): localize collaboration notification titles and the storage objects; wire the notifications REST routes
Three gaps behind one report (a
sys_file "repro.png" assigned to you
notification that was English on an all-Chinese workspace, opened an English
detail page, and never cleared its unread state):-
plugin-audit — the assignment (
collab.assignment) and @mention
(collab.mention) bell titles were hardcoded English literals built from the
raw object API name. They now resolve through the i18n service with the same
key shapes as the activity summaries (framework#3039): new
messages.assignedToYou/messages.mentionedYou/
messages.mentionedYouAnonymoustemplates (en / zh-CN / ja-JP / es-ES), the
object named by its translated label (objects.{name}.label→ authored def
label → API name), and the locale resolved for the recipient (they read
the bell), not the acting user. Every step stays best-effort: no locale / no
i18n / key miss degrades to the English literal — which now also prefers the
authored object label over the API name. -
service-storage —
sys_file/sys_upload_sessionhad no translation
bundle at all, so the file detail page (labels, and the Pending Upload /
Committed / Deleted status pipeline) rendered English on every locale. The
service now ships its own ADR-0029 D8 bundle (en / zh-CN / ja-JP / es-ES,
src/translations+scripts/i18n-extract.config.ts) and contributes it via
i18n.loadTranslationsonkernel:ready, matching service-messaging.
(sys_attachmentstays in platform-objects' bundles pending the
storage-domain decomposition.) -
runtime — the in-app notifications REST surface (
GET /api/v1/notifications,POST /api/v1/notifications/read,POST /api/v1/notifications/read/all; ADR-0030) had itshandleNotification
dispatch branch and discovery entry, but noserver.<verb>()mount in
dispatcher-plugin, so only the cloud hosts' hono catch-all reached it — the
standalone /os devserver 404'd every request. That left mark-read with no
working endpoint (the console's directsys_notification_receiptwrite is
rejected by ADR-0103's engine-owned gate), so unread notifications could never
clear. The three routes are now mounted explicitly, guarded by the
route-registration regression test.
-
-
a2d6555: perf(runtime): drop asyncify — sandbox runs on the sync QuickJS variant (ADR-0102 D2, #3296)
Phase 2 of #3275. The QuickJS sandbox switches from the asyncify build
(newAsyncContext) to the already-installed sync release variant
(newQuickJSWASMModule().newContext()), keeping one physically isolated WASM
module per invocation (ADR-0102 D2/D4). Asyncify's only justification — suspending
the WASM stack across a host call — disappeared with the #1867 deferred-promise +
pump redesign, so nothing depended on it. Wins: smaller binary, faster
compile/instantiate, faster per-instruction, and removal of an entire class of
suspended-stack failure modes.Also fixes a latent resource leak surfaced by the stricter sync teardown: host
ctx.apicalls hand the VM avm.newPromise()deferred that was never
dispose()d (the newPromise contract requires it). The asyncify build tolerated
the leak; the sync build'sJS_FreeRuntimeaborted (Assertion failed: list_empty) when a context was torn down with a pending, never-settled host call
(the timeout path). Deferreds are now tracked and disposed before context
teardown.Memory: the sync
QuickJSWASMModulehas nodispose(); its WebAssembly instance- linear memory are GC-reclaimed when the reference is dropped. A new RSS soak
test guards that per-invocation modules don't ratchet RSS.
- linear memory are GC-reclaimed when the reference is dropped. A new RSS soak
-
3a6310c: perf(runtime): stop the sandbox pump loop from idle-spinning while awaiting a host call (#3233)
The QuickJS hook/action runner drives a script's async continuations with a
pump loop that, on every iteration, yielded viasetImmediateand then drained
the VM job queue. While the body was only waiting on an in-flight host promise
(a slowctx.apiread/write, or one call that settles after many event-loop
turns), that queue was empty every iteration, so the loop woke ~200k times/sec
doing nothing — a ~50,000-iteration burn for a 250ms wait.The yield is now adaptive: it stays on
setImmediate(near-zero latency) while
the script is making progress, and once a pump executes zero VM jobs it ramps up
to a small cappedsetTimeout(≤8ms). Any executed job — a settled host call, a
resumed continuation — resets it to the fast path, so sequential host calls and
multi-turn work keep their low latency; only a genuinely idle wait backs off.
Deadline enforcement and every existing pump-budget/timeout/transaction
guarantee are unchanged. -
515f11a: fix(seed): replaying seeds no longer corrupts lookup natural keys on the upsert update path
Every dev-server restart replayed package seeds in upsert mode, and any record whose
lookup/master_detail was authored as a natural key could have that reference overwritten
with NULL on the update path (NOT NULL constraint failedon required columns; silent
link loss on nullable ones). Four fixes:- An unresolved reference now leaves the column untouched (deferred to pass 2) or drops
the record loudly — it is never written as NULL over an existing row. - DB-side reference resolution probes the target dataset's declared
externalId(e.g.
email) before falling back tonameandid, matching how in-memory resolution
already keyed records. - A rejected update (e.g. a
state_machinerule vetoing the replay) no longer severs
natural-key resolution for downstream child datasets. - Replays are idempotent: an upsert/update whose declared fields already match the
existing row is skipped instead of rewritten (no moreupdated_atchurn or lifecycle
re-validation on every boot).
- An unresolved reference now leaves the column untouched (deferred to pass 2) or drops
-
4174a07: feat(runtime): seed-replayer reports
skippedso hosts can stamp seed-once on progressThe
seed-replayerkernel service returned{ inserted, updated, errors }but
notskipped. A cloud host therefore could not tell an all-skip replay
(the env's seed data is already present — a no-op) apart from the
zero-summary early-returns that never ran the loader (no organization, no
metadata service, no datasets). Both looked likeinserted = updated = 0, so
the host could not safely stamp its seed-once record for the all-skip case and
re-ran the full remote replay on every cold boot.Add
skipped: result.summary.totalSkippedto the replayer's return; the
early-returns reportskipped: 0. This lets a host (cloud#853's
decideSeedStamp) stamp on progress — including an all-skip replay — while
still declining to stamp a genuine no-loader zero-summary. Additive and
backward compatible; existing consumers ignore the new field. -
ce468c8: feat(observability): decompose
Server-Timinginto auth / db / hooks / serialize spans (perf-tuning mode)The opt-in
Server-Timingheader now breaks a request's server time into the phases that actually explain it, so an operator can open DevTools → Network → Timing and see where the time went without standing up an external tracing backend:db— total SQL time with a query count. The SQL driver wires knex'squery/query-responseevents (keyed by__knexQueryUid) and folds each query into one aggregate member (db;dur=210;desc="6 queries") — the query count is the number most useful for spotting N sequential round-trips. Timing is attributed to the originating request viaAsyncLocalStorage, so it is correct under concurrency and never cross-attributes. SQL text is never emitted, only durations and a count.auth— identity / session resolution in the dispatcher, the prime suspect for unexplained data-API overhead.hooks— total business-hook execution time with a hook count, fed through the engine's existingHookMetricsRecorderseam (wired from the runtime, so@objectstack/objectql's leancoretier stays observability-free).serialize— response JSON encoding in the HTTP adapter.
Adds
countServerTiming(name, dur, unit)(andPerfTiming.count) to fold high-frequency phases into a single aggregate member instead of flooding the header. Every phase is a no-op when perf-tuning is off (serverTiming: true/OS_SERVER_TIMING=true), so there is zero measurable overhead on the normal path.Closes #2408.
-
Updated dependencies [f972574]
-
Updated dependencies [6289ec3]
-
Updated dependencies [2f3c641]
-
Updated dependencies [e38da5b]
-
Updated dependencies [f9b118d]
-
Updated dependencies [22013aa]
-
Updated dependencies [3ad3dd5]
-
Updated dependencies [8efa395]
-
Updated dependencies [3a18b60]
-
Updated dependencies [deb7e7e]
-
Updated dependencies [a8aa34c]
-
Updated dependencies [e057f42]
-
Updated dependencies [a3823b2]
-
Updated dependencies [43a3efb]
-
Updated dependencies [524696a]
-
Updated dependencies [6b51346]
-
Updated dependencies [80273c8]
-
Updated dependencies [fdc244e]
-
Updated dependencies [bfa3c3f]
-
Updated dependencies [5e3301d]
-
Updated dependencies [dd9f223]
-
Updated dependencies [47d923c]
-
Updated dependencies [46e876c]
-
Updated dependencies [2ea08ee]
-
Updated dependencies [7125007]
-
Updated dependencies [616e839]
-
Updated dependencies [5f05de2]
-
Updated dependencies [021ba4c]
-
Updated dependencies [158aa14]
-
Updated dependencies [9d897b3]
-
Updated dependencies [62a2117]
-
Updated dependencies [83e8f7d]
-
Updated dependencies [d2723e2]
-
Updated dependencies [674457a]
-
Updated dependencies [fefcd54]
-
Updated dependencies [efbcfe1]
-
Updated dependencies [2049b6a]
-
Updated dependencies [beaf2de]
-
Updated dependencies [06cb319]
-
Updated dependencies [369eb6e]
-
Updated dependencies [06ff734]
-
Updated dependencies [b659111]
-
Updated dependencies [5754a23]
-
Updated dependencies [6c270a6]
-
Updated dependencies [290e2f0]
-
Updated dependencies [668dd17]
-
Updated dependencies [8abf133]
-
Updated dependencies [e0859b1]
-
Updated dependencies [92f5f19]
-
Updated dependencies [32899e6]
-
Updated dependencies [ce468c8]
-
Updated dependencies [04ecd4e]
-
Updated dependencies [4d5a892]
-
Updated dependencies [16cebeb]
-
Updated dependencies [86d30af]
-
Updated dependencies [8923843]
-
Updated dependencies [ea32ec7]
-
Updated dependencies [a2795f6]
-
Updated dependencies [f16b492]
-
Updated dependencies [4b6fde8]
-
Updated dependencies [2018df9]
-
Updated dependencies [fc5a3a2]
-
Updated dependencies [8ff9210]
- @objectstack/spec@16.0.0
- @objectstack/plugin-security@16.0.0
- @objectstack/objectql@16.0.0
- @objectstack/rest@16.0.0
- @objectstack/plugin-auth@16.0.0
- @objectstack/core@16.0.0
- @objectstack/formula@16.0.0
- @objectstack/metadata@16.0.0
- @objectstack/driver-sql@16.0.0
- @objectstack/metadata-core@16.0.0
- @objectstack/types@16.0.0
- @objectstack/observability@16.0.0
- @objectstack/driver-memory@16.0.0
- @objectstack/driver-sqlite-wasm@16.0.0
- @objectstack/service-cluster@16.0.0
- @objectstack/service-datasource@16.0.0
- @objectstack/service-i18n@16.0.0