From ac02c0f70904409c82f45ec399c75742ffce5663 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 15 May 2026 08:23:01 +0100 Subject: [PATCH 1/2] fix(core): drop unique-symbol brand on LocalsKey to fix dual-package builds (#3626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `LocalsKey` (the type returned by `locals.create()`) was branded with a module-level `declare const __local: unique symbol`. Each such declaration is its own nominal type, and `tshy` emits separate `.d.ts` files for the ESM and CJS outputs — each gets its own `__local` symbol. Under certain pnpm hoisting layouts a single TypeScript compilation can resolve `LocalsKey` from both the ESM source path and the CJS dist path within the same call site, producing two structurally-incompatible variants of the same type. TS surfaces this as the misleading error: ``` Argument of type 'LocalsKey' is not assignable to parameter of type 'LocalsKey'. Property '[__local]' is missing in type 'LocalsKey' but required in type 'BrandLocal'. ``` The error has been hitting CI on PRs opened since the chat.agent stack landed (e.g. #3625 typecheck job), but doesn't reproduce on developer machines where the pnpm node_modules layout was built up incrementally. ## Fix Replace the `unique symbol` brand with an optional phantom field that carries `T` at the type level: ```ts // before declare const __local: unique symbol; type BrandLocal = { [__local]: T }; export type LocalsKey = BrandLocal & { readonly id: string; readonly __type: unique symbol; }; // after export type LocalsKey = { readonly id: string; readonly __type: symbol; /** Phantom carrier for the value type — never read at runtime. */ readonly __valueType?: T; }; ``` The ESM and CJS `.d.ts` outputs now produce structurally identical types, so cross-output resolution no longer produces a mismatch. `T` is still carried at the type level via the optional phantom field. The runtime shape is unchanged — `manager.ts` was already casting via `as unknown`, which is no longer needed. ## Test plan - [ ] `pnpm run typecheck --filter @trigger.dev/core --filter @trigger.dev/sdk` - [ ] `pnpm run build --filter @trigger.dev/core --filter @trigger.dev/sdk` (clean rebuild) — confirms the ESM and CJS dist `.d.ts` outputs no longer carry distinct `unique symbol` declarations - [ ] `pnpm --filter @trigger.dev/core test test/mockTaskContext.test.ts --run` - [ ] `pnpm --filter @trigger.dev/sdk test test/mockChatAgent.test.ts --run` --- .changeset/locals-key-dual-package-fix.md | 5 +++++ packages/core/src/v3/locals/manager.ts | 4 ++-- packages/core/src/v3/locals/types.ts | 24 +++++++++++++++++------ 3 files changed, 25 insertions(+), 8 deletions(-) create mode 100644 .changeset/locals-key-dual-package-fix.md diff --git a/.changeset/locals-key-dual-package-fix.md b/.changeset/locals-key-dual-package-fix.md new file mode 100644 index 00000000000..38d42e19dfb --- /dev/null +++ b/.changeset/locals-key-dual-package-fix.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +Fix `LocalsKey` type incompatibility across dual-package builds. The phantom value-type brand no longer uses a module-level `unique symbol`, so a single TypeScript compilation that resolves the type from both the ESM and CJS outputs (which can happen under certain pnpm hoisting layouts) no longer sees two structurally-incompatible variants of the same type. diff --git a/packages/core/src/v3/locals/manager.ts b/packages/core/src/v3/locals/manager.ts index 6984d9f4146..3cd0f80d84a 100644 --- a/packages/core/src/v3/locals/manager.ts +++ b/packages/core/src/v3/locals/manager.ts @@ -5,7 +5,7 @@ export class NoopLocalsManager implements LocalsManager { return { __type: Symbol(), id, - } as unknown as LocalsKey; + }; } getLocal(key: LocalsKey): T | undefined { @@ -23,7 +23,7 @@ export class StandardLocalsManager implements LocalsManager { return { __type: key, id, - } as unknown as LocalsKey; + }; } getLocal(key: LocalsKey): T | undefined { diff --git a/packages/core/src/v3/locals/types.ts b/packages/core/src/v3/locals/types.ts index aab683df091..84abc4c70f5 100644 --- a/packages/core/src/v3/locals/types.ts +++ b/packages/core/src/v3/locals/types.ts @@ -1,10 +1,22 @@ -declare const __local: unique symbol; -type BrandLocal = { [__local]: T }; - -// Create a type-safe store for your locals -export type LocalsKey = BrandLocal & { +/** + * A type-safe key for `locals`. Carries the value type `T` as a phantom + * marker on the optional `__valueType` field so two keys with different + * value types are distinguishable at the type level. + * + * The phantom field is intentionally not anchored to a `unique symbol`: + * dual-package builds (`tshy`) emit separate `.d.ts` files for ESM and + * CJS outputs, and each `unique symbol` declaration in a `.d.ts` is its + * own nominal type. If a single compilation ever resolves `LocalsKey` + * from both the ESM and CJS paths — which happens under certain pnpm + * hoisting layouts — `unique symbol` brands produce structurally + * incompatible variants of the same type. A plain string brand avoids + * the hazard. + */ +export type LocalsKey = { readonly id: string; - readonly __type: unique symbol; + readonly __type: symbol; + /** Phantom carrier for the value type — never read at runtime. */ + readonly __valueType?: T; }; export interface LocalsManager { From bff4b46b2281cceb343e1fa1ab5268512ab6ff8a Mon Sep 17 00:00:00 2001 From: Daniel Sutton <45313566+d-cs@users.noreply.github.com> Date: Fri, 15 May 2026 10:42:17 +0100 Subject: [PATCH 2/2] fix(webapp): log Google auth conflict as warn instead of error (#3627) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary A "Google auth conflict" Sentry alert fires whenever a user signs in via Google whose Google account is linked to one user row but whose Google-provided email is now on a *different* user row. The handler in `apps/webapp/app/models/user.server.ts:236` already does the right thing — it returns the existing auth-linked user and skips the update path so neither row gets mutated — but it logs the situation with `logger.error`, which routes to Sentry as an exception and pages the on-call channel. There's no exception to chase here: the branch is the intended outcome for a known data shape (user changed their email on one account after originally signing up via Google on another). Downgrading the call to `logger.warn` keeps the diagnostic record in our logs (with all the same context fields — email, both user IDs, authIdentifier) but stops it firing the production error alert. ## Change - `logger.error` → `logger.warn` for the conflict branch in `findOrCreateGoogleUser`. Context payload is unchanged. ## Test plan - [x] Typecheck only — there's no behavioural change to test, the log level is the entire diff. --- .server-changes/google-auth-conflict-warn.md | 6 ++++++ apps/webapp/app/models/user.server.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .server-changes/google-auth-conflict-warn.md diff --git a/.server-changes/google-auth-conflict-warn.md b/.server-changes/google-auth-conflict-warn.md new file mode 100644 index 00000000000..4e6b630ab21 --- /dev/null +++ b/.server-changes/google-auth-conflict-warn.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Downgrade the "Google auth conflict" log from `error` to `warn`. This branch handles an expected user-state mismatch (Google ID belongs to one user, email is on another) by returning the existing auth user — there's no exception to chase, so it shouldn't page on the Sentry error channel. diff --git a/apps/webapp/app/models/user.server.ts b/apps/webapp/app/models/user.server.ts index 68550f6e98c..c48221c4b61 100644 --- a/apps/webapp/app/models/user.server.ts +++ b/apps/webapp/app/models/user.server.ts @@ -233,7 +233,7 @@ export async function findOrCreateGoogleUser({ // Check if email user and auth user are the same if (existingEmailUser.id !== existingUser.id) { // Different users: email is taken by one user, Google auth belongs to another - logger.error( + logger.warn( `Google auth conflict: Google ID ${authenticationProfile.id} belongs to user ${existingUser.id} but email ${email} is taken by user ${existingEmailUser.id}`, { email,