Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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