Skip to content

refactor(appkit): own asUser OBO wrapping in Plugin proxy#385

Merged
MarioCadenas merged 4 commits into
mainfrom
mario/refactor-asuser-proxy
May 20, 2026
Merged

refactor(appkit): own asUser OBO wrapping in Plugin proxy#385
MarioCadenas merged 4 commits into
mainfrom
mario/refactor-asuser-proxy

Conversation

@MarioCadenas
Copy link
Copy Markdown
Collaborator

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).

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>
@MarioCadenas MarioCadenas requested a review from a team as a code owner May 14, 2026 14:36
@MarioCadenas MarioCadenas requested a review from pkosiec May 14, 2026 14:36
Copy link
Copy Markdown
Member

@pkosiec pkosiec left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice one! Tested Lakebase OBO - still works 👍

Before merge, please take a look at the agentic review summary.

Comment thread packages/appkit/src/plugin/plugin.ts
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>
@MarioCadenas MarioCadenas merged commit 0900ea9 into main May 20, 2026
9 checks passed
@MarioCadenas MarioCadenas deleted the mario/refactor-asuser-proxy branch May 20, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants