refactor(appkit): own asUser OBO wrapping in Plugin proxy#385
Merged
Conversation
Move the OBO-side exports() wrapping out of AppKit#wrapWithAsUser and into Plugin.asUser's proxy. The proxy now intercepts reads of `exports` and returns the underlying exports() result with every function pre-wrapped in the user-context scope. The cross-file USER_CONTEXT_SYMBOL bridge between plugin.ts and appkit.ts is removed, and wrapExportsInUserContext is deleted from appkit.ts. The two inline Proxy literals in Plugin.asUser (real OBO and dev-mode fallback) collapse into a single _createAsUserProxy(wrapCall) factory that takes a per-call wrap strategy. The real OBO branch uses runInUserContext(userContext, ...); the dev fallback uses otelContext.with(DEV_OBO_FALLBACK_KEY=true, ...). Behavior preserving: full appkit test suite passes and typecheck is clean. Adds a focused 26-test file covering proxy mechanics: real OBO method calls, exports() interception (class methods, arrows, nested plain objects, class-instance values not recursed, callable exports, null/undefined), AsyncLocalStorage propagation across Promise.all, concurrent user isolation, error cleanup, dev-fallback exports(), and escape-the-proxy boundaries (returned functions not auto-wrapped, `return this` returns the unwrapped target). Also drop the now-unused UserContext type re-export from the internal context barrel (not part of the public package entry). Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
pkosiec
approved these changes
May 18, 2026
Member
pkosiec
left a comment
There was a problem hiding this comment.
Nice one! Tested Lakebase OBO - still works 👍
Before merge, please take a look at the agentic review summary.
Three findings from the agentic review summary on PR #385: 1. wrapExportFunctions no longer mutates its input. It now returns a freshly built object, walking nested plain objects recursively. This removes a latent footgun for plugins that memoize exports() — the proxy's OBO wrapping previously would have permanently corrupted any cached export shape on first call. 2. The redundant Object.hasOwn() guard inside the loop is dropped. Object.keys() already returns only own enumerable string keys, so the guard could never be true. 3. The duplicate isPlainObject implementation in core/appkit.ts is removed in favour of importing the helper from plugin/plugin.ts (now exported as @internal). Both helpers had identical logic; a single source of truth keeps the predicate consistent if it ever needs to evolve (e.g. cross-realm objects). Adds two focused tests in asUser-proxy.test.ts: - exports() interception does not mutate the underlying object, even when the plugin memoizes it (regression for finding #1). - exports() returning a top-level non-plain, non-function value (e.g. an Array) passes through as-is via the fallthrough branch. Full appkit test suite (2007/2007) passes and typecheck is clean. Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
The earlier commit on this branch (ec34a81) removed the `export type { UserContext }` from `context/index.ts` because knip flagged it as unused after the asUser refactor. Since then, main gained a real internal consumer: packages/appkit/src/plugins/files/plugin.ts imports { ..., type UserContext } from "../../context" CI typecheck (which runs against the merged result) caught the breakage. Restoring the re-export makes the merge clean and keeps knip happy now that there is a legitimate consumer. Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Move the OBO-side exports() wrapping out of AppKit#wrapWithAsUser and into Plugin.asUser's proxy. The proxy now intercepts reads of
exportsand returns the underlying exports() result with every function pre-wrapped in the user-context scope. The cross-file USER_CONTEXT_SYMBOL bridge between plugin.ts and appkit.ts is removed, and wrapExportsInUserContext is deleted from appkit.ts.The two inline Proxy literals in Plugin.asUser (real OBO and dev-mode fallback) collapse into a single _createAsUserProxy(wrapCall) factory that takes a per-call wrap strategy. The real OBO branch uses runInUserContext(userContext, ...); the dev fallback uses otelContext.with(DEV_OBO_FALLBACK_KEY=true, ...).
Behavior preserving: full appkit test suite passes and typecheck is clean. Adds a focused 26-test file covering proxy mechanics: real OBO method calls, exports() interception (class methods, arrows, nested plain objects, class-instance values not recursed, callable exports, null/undefined), AsyncLocalStorage propagation across Promise.all, concurrent user isolation, error cleanup, dev-fallback exports(), and escape-the-proxy boundaries (returned functions not auto-wrapped,
return thisreturns the unwrapped target).Also drop the now-unused UserContext type re-export from the internal context barrel (not part of the public package entry).