Skip to content
23 changes: 23 additions & 0 deletions .changeset/deprecate-update-user-metadata.md
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's split this into separate changesets so each package changelog stays focused 👍🏼

  1. for backend
  2. for clerk-js and shared

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@clerk/backend': minor
---

Add `clerkClient.users.replaceUserMetadata(userId, params)` for replacing a user's metadata fields in full.

Use `replaceUserMetadata` when the provided metadata should become the complete value for that metadata field:

```ts
await clerkClient.users.replaceUserMetadata(userId, {
publicMetadata: { plan: 'pro' },
});
```

Use `clerkClient.users.updateUserMetadata(userId, params)` when you want to partially update metadata with deep-merge semantics:

```ts
await clerkClient.users.updateUserMetadata(userId, {
publicMetadata: { onboardingComplete: true },
});
```

The `publicMetadata`, `privateMetadata`, and `unsafeMetadata` parameters on `clerkClient.users.updateUser()` are now deprecated. They continue to work, but new code should use `updateUserMetadata()` for partial updates or `replaceUserMetadata()` for full replacement.
24 changes: 24 additions & 0 deletions .changeset/route-unsafe-metadata-to-merge-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@clerk/clerk-js': patch
'@clerk/shared': patch
---

Deprecate passing `unsafeMetadata` to `user.update()`.

Use `user.updateMetadata()` when you want to partially update unsafe metadata with deep-merge semantics:

```ts
await user.updateMetadata({
unsafeMetadata: { onboardingComplete: true },
});
```

`user.update({ unsafeMetadata })` continues to work for now and preserves its existing full-replacement behavior:

```ts
await user.update({
unsafeMetadata: { theme: 'dark' },
});
```

New code should prefer `user.updateMetadata({ unsafeMetadata })` for metadata-only updates.
101 changes: 101 additions & 0 deletions integration/tests/unsafeMetadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,105 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('unsafeMet

await fakeUser.deleteIfExists();
});

// Helper: sign up a user via the UI and return the BAPI user id once the
// client session is established. Mirrors the existing sign-up test flow so
// these specs share the same baseline (`unsafeMetadata: { position: 'goalie' }`).
const signUpAndGetUser = async ({ page, context }: { page: any; context: any }) => {
const u = createTestUtils({ app, page, context });
const fakeUser = u.services.users.createFakeUser({
fictionalEmail: true,
withPhoneNumber: true,
withUsername: true,
});

await u.po.signUp.goTo();
await u.po.signUp.signUpWithEmailAndPassword({
email: fakeUser.email,
password: fakeUser.password,
});
await u.po.signUp.enterTestOtpCode();
await u.po.expect.toBeSignedIn();

const bapiUser = await u.services.users.getUser({ email: fakeUser.email });
expect(bapiUser?.unsafeMetadata).toEqual({ position: 'goalie' });

return { u, fakeUser, bapiUser: bapiUser! };
};

test('user.update({ unsafeMetadata }) preserves replace semantics end-to-end', async ({ page, context }) => {
const { u, fakeUser, bapiUser } = await signUpAndGetUser({ page, context });

// Drive the deprecated path from the browser. The SDK should route
// metadata through PATCH /v1/me/metadata after computing a merge patch
// against the locally-cached value; the server-side outcome must match
// a true replace (the original `position` key is gone).
await page.evaluate(async () => {
await window.Clerk.user.update({ unsafeMetadata: { city: 'Toronto' } });
});

const refreshed = await u.services.users.getUser({ id: bapiUser.id });
expect(refreshed?.unsafeMetadata).toEqual({ city: 'Toronto' });

await fakeUser.deleteIfExists();
});

test('user.updateMetadata({ unsafeMetadata }) deep-merges (recommended path)', async ({ page, context }) => {
const { u, fakeUser, bapiUser } = await signUpAndGetUser({ page, context });

// The recommended migration target. Unlike `update(...)`, this is a
// partial update — the original `position` key must survive.
await page.evaluate(async () => {
await window.Clerk.user.updateMetadata({ unsafeMetadata: { city: 'Toronto' } });
});

const refreshed = await u.services.users.getUser({ id: bapiUser.id });
expect(refreshed?.unsafeMetadata).toEqual({ position: 'goalie', city: 'Toronto' });

await fakeUser.deleteIfExists();
});

test('user.update with metadata + non-metadata fields persists both', async ({ page, context }) => {
const { u, fakeUser, bapiUser } = await signUpAndGetUser({ page, context });

// Mixed call: PATCH /v1/me for the non-metadata field, then
// PATCH /v1/me/metadata for the computed patch. Both must land.
await page.evaluate(async () => {
await window.Clerk.user.update({
firstName: 'Updated',
unsafeMetadata: { city: 'Toronto' },
});
});

const refreshed = await u.services.users.getUser({ id: bapiUser.id });
expect(refreshed?.firstName).toBe('Updated');
expect(refreshed?.unsafeMetadata).toEqual({ city: 'Toronto' });

await fakeUser.deleteIfExists();
});

test('user.update reloads before diffing so server-side mutations are not lost', async ({ page, context }) => {
const { u, fakeUser, bapiUser } = await signUpAndGetUser({ page, context });

// Simulate a server-side mutation made by *another* actor
// after the browser cached the user.
// The browser's local `unsafeMetadata` is now stale,
// missing the `adminAdded` key.
await u.services.clerk.users.updateUserMetadata(bapiUser.id, {
unsafeMetadata: { adminAdded: 'yes' },
});

// From the browser, call the deprecated path with replace intent.
// Without the pre-diff reload, the SDK would diff against stale `{ position: 'goalie' }`
// send `{ position: null, city: 'Toronto' }`, and the server-side `adminAdded` would silently survive violating replace semantics.
// The reload makes the SDK observe the fresh state and null-delete the server-added key too.
await page.evaluate(async () => {
await window.Clerk.user.update({ unsafeMetadata: { city: 'Toronto' } });
});

const refreshed = await u.services.users.getUser({ id: bapiUser.id });
expect(refreshed?.unsafeMetadata).toEqual({ city: 'Toronto' });

await fakeUser.deleteIfExists();
});
});
179 changes: 179 additions & 0 deletions packages/backend/src/api/__tests__/UserApi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { http, HttpResponse } from 'msw';
import { describe, expect, it, vi } from 'vitest';

import { server, validateHeaders } from '../../mock-server';
import { createBackendApiClient } from '../factory';

describe('UserAPI', () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
secretKey: 'deadbeef',
});

const mockUserResponse = {
object: 'user',
id: 'user_123',
public_metadata: {},
private_metadata: {},
unsafe_metadata: {},
};

describe('updateUser', () => {
it('calls PATCH /users/{id} when no metadata fields are provided', async () => {
const patchHandler = vi.fn(async ({ request }: { request: Request }) => {
const body = await request.json();
expect(body).toEqual({ first_name: 'Jane' });
return HttpResponse.json(mockUserResponse);
});

server.use(http.patch('https://api.clerk.test/v1/users/user_123', validateHeaders(patchHandler)));

const response = await apiClient.users.updateUser('user_123', { firstName: 'Jane' });

expect(patchHandler).toHaveBeenCalledTimes(1);
expect(response.id).toBe('user_123');
});

it('routes metadata to PUT /users/{id}/metadata when only metadata is provided', async () => {
const patchHandler = vi.fn(() => HttpResponse.json(mockUserResponse));
const putHandler = vi.fn(async ({ request }: { request: Request }) => {
const body = await request.json();
expect(body).toEqual({
public_metadata: { foo: 'bar' },
});
return HttpResponse.json({
...mockUserResponse,
public_metadata: { foo: 'bar' },
});
});

server.use(
http.patch('https://api.clerk.test/v1/users/user_123', validateHeaders(patchHandler)),
http.put('https://api.clerk.test/v1/users/user_123/metadata', validateHeaders(putHandler)),
);

const response = await apiClient.users.updateUser('user_123', {
publicMetadata: { foo: 'bar' },
});

expect(patchHandler).not.toHaveBeenCalled();
expect(putHandler).toHaveBeenCalledTimes(1);
expect(response.publicMetadata).toEqual({ foo: 'bar' });
});

it('splits mixed calls: PATCH for non-metadata, then PUT for metadata', async () => {
const calls: string[] = [];

const patchHandler = vi.fn(async ({ request }: { request: Request }) => {
calls.push('patch');
const body = await request.json();
expect(body).toEqual({ first_name: 'Jane' });
return HttpResponse.json(mockUserResponse);
});

const putHandler = vi.fn(async ({ request }: { request: Request }) => {
calls.push('put');
const body = await request.json();
expect(body).toEqual({
public_metadata: { plan: 'pro' },
private_metadata: { invoice: 'inv_1' },
});
return HttpResponse.json({
...mockUserResponse,
first_name: 'Jane',
public_metadata: { plan: 'pro' },
private_metadata: { invoice: 'inv_1' },
});
});

server.use(
http.patch('https://api.clerk.test/v1/users/user_123', validateHeaders(patchHandler)),
http.put('https://api.clerk.test/v1/users/user_123/metadata', validateHeaders(putHandler)),
);

const response = await apiClient.users.updateUser('user_123', {
firstName: 'Jane',
publicMetadata: { plan: 'pro' },
privateMetadata: { invoice: 'inv_1' },
});

expect(patchHandler).toHaveBeenCalledTimes(1);
expect(putHandler).toHaveBeenCalledTimes(1);
// PATCH must run before PUT so the user state from PUT is the latest.
expect(calls).toEqual(['patch', 'put']);
expect(response.firstName).toBe('Jane');
expect(response.publicMetadata).toEqual({ plan: 'pro' });
});

it('passes only metadata fields that were explicitly provided to PUT', async () => {
const putHandler = vi.fn(async ({ request }: { request: Request }) => {
const body = (await request.json()) as Record<string, unknown>;
// Only unsafe_metadata was provided. The other two should be undefined,
// which serializes to "field omitted" on the wire — leaving those
// columns untouched server-side.
expect(body.unsafe_metadata).toEqual({ device: 'mobile' });
expect(body).not.toHaveProperty('public_metadata');
expect(body).not.toHaveProperty('private_metadata');
return HttpResponse.json({
...mockUserResponse,
unsafe_metadata: { device: 'mobile' },
});
});

server.use(http.put('https://api.clerk.test/v1/users/user_123/metadata', validateHeaders(putHandler)));

await apiClient.users.updateUser('user_123', {
unsafeMetadata: { device: 'mobile' },
});

expect(putHandler).toHaveBeenCalledTimes(1);
});
});

describe('updateUserMetadata', () => {
it('still hits PATCH /users/{id}/metadata (unchanged)', async () => {
const patchHandler = vi.fn(async ({ request }: { request: Request }) => {
const body = await request.json();
expect(body).toEqual({
public_metadata: { merge: true },
});
return HttpResponse.json({
...mockUserResponse,
public_metadata: { merge: true },
});
});

server.use(http.patch('https://api.clerk.test/v1/users/user_123/metadata', validateHeaders(patchHandler)));

await apiClient.users.updateUserMetadata('user_123', {
publicMetadata: { merge: true },
});

expect(patchHandler).toHaveBeenCalledTimes(1);
});
});

describe('replaceUserMetadata', () => {
it('hits PUT /users/{id}/metadata', async () => {
const putHandler = vi.fn(async ({ request }: { request: Request }) => {
const body = await request.json();
expect(body).toEqual({
public_metadata: { replaced: true },
});
return HttpResponse.json({
...mockUserResponse,
public_metadata: { replaced: true },
});
});

server.use(http.put('https://api.clerk.test/v1/users/user_123/metadata', validateHeaders(putHandler)));

const response = await apiClient.users.replaceUserMetadata('user_123', {
publicMetadata: { replaced: true },
});

expect(putHandler).toHaveBeenCalledTimes(1);
expect(response.publicMetadata).toEqual({ replaced: true });
});
});
});
Loading
Loading