@tuturuuu/utils 0.24.2 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.25.1](https://github.com/tutur3u/platform/compare/utils-v0.25.0...utils-v0.25.1) (2026-08-15)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **contacts:** harden invited profile access ([19878d0](https://github.com/tutur3u/platform/commit/19878d06466eafc8cef74af52e14ddbdb6eef510))
9
+
10
+ ## [0.25.0](https://github.com/tutur3u/platform/compare/utils-v0.24.2...utils-v0.25.0) (2026-08-14)
11
+
12
+
13
+ ### Features
14
+
15
+ * **workspaces:** assign roles to pending invites ([#5125](https://github.com/tutur3u/platform/issues/5125)) ([d4052fd](https://github.com/tutur3u/platform/commit/d4052fd40de66bda4e9535740bbcfd5a8da19123))
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **contacts:** harden satellite workspace access ([b160723](https://github.com/tutur3u/platform/commit/b160723b509062a4f8edf2694c0af27c25c05775))
21
+ * **tasks:** keep dropped cards in destination ([de9353b](https://github.com/tutur3u/platform/commit/de9353b0b3574b240c0caa478ba52935d3ad00bb))
22
+ * **tasks:** render dropped tasks immediately ([1dcdb65](https://github.com/tutur3u/platform/commit/1dcdb655ff960c59f91b93b7f13622d1b65f6f48))
23
+
3
24
  ## [0.24.2](https://github.com/tutur3u/platform/compare/utils-v0.24.1...utils-v0.24.2) (2026-08-11)
4
25
 
5
26
 
package/biome.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "root": false,
3
- "$schema": "https://biomejs.dev/schemas/2.5.7/schema.json",
3
+ "$schema": "https://biomejs.dev/schemas/2.5.8/schema.json",
4
4
  "extends": "//"
5
5
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tuturuuu/utils",
3
3
  "license": "MIT",
4
- "version": "0.24.2",
4
+ "version": "0.25.1",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/tutur3u/platform",
@@ -17,14 +17,14 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "@dnd-kit/core": "^6.3.1",
20
- "@noble/ciphers": "^2.2.0",
21
- "@noble/hashes": "^2.2.0",
20
+ "@noble/ciphers": "^2.3.0",
21
+ "@noble/hashes": "^2.3.0",
22
22
  "@tanstack/react-query": "^5.101.4",
23
- "@tiptap/core": "3.29.2",
24
- "@tiptap/react": "3.29.2",
23
+ "@tiptap/core": "3.30.0",
24
+ "@tiptap/react": "3.30.0",
25
25
  "@tuturuuu/google": "0.1.0",
26
26
  "@tuturuuu/icons": "0.1.0",
27
- "@tuturuuu/internal-api": "0.29.1",
27
+ "@tuturuuu/internal-api": "0.31.0",
28
28
  "@tuturuuu/supabase": "0.5.0",
29
29
  "@upstash/ratelimit": "^2.0.8",
30
30
  "@upstash/redis": "^1.38.2",
@@ -34,7 +34,7 @@
34
34
  "diff": "^9.0.0",
35
35
  "effect": "^3.22.1",
36
36
  "moment": "^2.30.1",
37
- "next": "^16.3.0",
37
+ "next": "^16.3.1",
38
38
  "prosemirror-model": "^1.25.11",
39
39
  "react": "^19.2.8",
40
40
  "react-dom": "^19.2.8",
@@ -48,11 +48,11 @@
48
48
  "@tuturuuu/types": "0.27.0",
49
49
  "@tuturuuu/typescript-config": "0.1.1",
50
50
  "@types/diff": "^8.0.0",
51
- "@types/node": "^26.1.2",
51
+ "@types/node": "^26.2.0",
52
52
  "@types/react": "^19.2.18",
53
53
  "@vitejs/plugin-react": "^6.0.5",
54
54
  "typescript": "7.0.2",
55
- "vite": "^8.2.0",
55
+ "vite": "^8.2.1",
56
56
  "vitest": "^4.1.10"
57
57
  },
58
58
  "exports": {
@@ -0,0 +1,117 @@
1
+ import { QueryClient } from '@tanstack/react-query';
2
+ import type { Task } from '@tuturuuu/types/primitives/Task';
3
+ import { describe, expect, it } from 'vitest';
4
+ import {
5
+ insertOptimisticTaskIntoBoardCaches,
6
+ reconcileOptimisticTaskInBoardCaches,
7
+ removeOptimisticTaskFromBoardCaches,
8
+ } from '../task-helper/optimistic-task-cache';
9
+
10
+ function task(id: string, listId = 'list-1'): Task {
11
+ return {
12
+ assignees: [],
13
+ created_at: '2026-08-14T00:00:00.000Z',
14
+ id,
15
+ labels: [],
16
+ list_id: listId,
17
+ name: id,
18
+ projects: [],
19
+ } as unknown as Task;
20
+ }
21
+
22
+ describe('optimistic task creation cache lifecycle', () => {
23
+ it('inserts the pending task into the board and every mounted full-task variant', () => {
24
+ const queryClient = new QueryClient();
25
+ const boardId = 'board-1';
26
+ const existingTask = task('existing');
27
+ const optimisticTask = {
28
+ ...task('optimistic-1'),
29
+ _isOptimistic: true,
30
+ } as Task;
31
+
32
+ queryClient.setQueryData(['tasks', boardId], [existingTask]);
33
+ queryClient.setQueryData(['tasks-full', boardId], [existingTask]);
34
+ queryClient.setQueryData(
35
+ ['tasks-full', boardId, 'filtered'],
36
+ [existingTask]
37
+ );
38
+ queryClient.setQueryData(['tasks-full', 'another-board'], [existingTask]);
39
+
40
+ insertOptimisticTaskIntoBoardCaches(queryClient, boardId, optimisticTask);
41
+
42
+ expect(queryClient.getQueryData(['tasks', boardId])).toEqual([
43
+ existingTask,
44
+ optimisticTask,
45
+ ]);
46
+ expect(queryClient.getQueryData(['tasks-full', boardId])).toEqual([
47
+ existingTask,
48
+ optimisticTask,
49
+ ]);
50
+ expect(
51
+ queryClient.getQueryData(['tasks-full', boardId, 'filtered'])
52
+ ).toEqual([existingTask, optimisticTask]);
53
+ expect(queryClient.getQueryData(['tasks-full', 'another-board'])).toEqual([
54
+ existingTask,
55
+ ]);
56
+ });
57
+
58
+ it('replaces the pending task without duplicating a realtime server task', () => {
59
+ const queryClient = new QueryClient();
60
+ const boardId = 'board-1';
61
+ const optimisticTask = {
62
+ ...task('optimistic-1'),
63
+ _isOptimistic: true,
64
+ } as Task;
65
+ const createdTask = task('created-1');
66
+
67
+ queryClient.setQueryData(['tasks', boardId], [optimisticTask]);
68
+ queryClient.setQueryData(
69
+ ['tasks-full', boardId, 'filtered'],
70
+ [optimisticTask, createdTask]
71
+ );
72
+
73
+ reconcileOptimisticTaskInBoardCaches(
74
+ queryClient,
75
+ boardId,
76
+ optimisticTask.id,
77
+ createdTask
78
+ );
79
+
80
+ expect(queryClient.getQueryData(['tasks', boardId])).toEqual([createdTask]);
81
+ expect(
82
+ queryClient.getQueryData(['tasks-full', boardId, 'filtered'])
83
+ ).toEqual([createdTask]);
84
+ });
85
+
86
+ it('rolls back only the failed optimistic task', () => {
87
+ const queryClient = new QueryClient();
88
+ const boardId = 'board-1';
89
+ const optimisticTask = {
90
+ ...task('optimistic-1'),
91
+ _isOptimistic: true,
92
+ } as Task;
93
+ const concurrentTask = task('concurrent');
94
+
95
+ queryClient.setQueryData(
96
+ ['tasks', boardId],
97
+ [optimisticTask, concurrentTask]
98
+ );
99
+ queryClient.setQueryData(
100
+ ['tasks-full', boardId, 'filtered'],
101
+ [optimisticTask, concurrentTask]
102
+ );
103
+
104
+ removeOptimisticTaskFromBoardCaches(
105
+ queryClient,
106
+ boardId,
107
+ optimisticTask.id
108
+ );
109
+
110
+ expect(queryClient.getQueryData(['tasks', boardId])).toEqual([
111
+ concurrentTask,
112
+ ]);
113
+ expect(
114
+ queryClient.getQueryData(['tasks-full', boardId, 'filtered'])
115
+ ).toEqual([concurrentTask]);
116
+ });
117
+ });
@@ -1,7 +1,9 @@
1
+ import { QueryClient } from '@tanstack/react-query';
1
2
  import type { Task } from '@tuturuuu/types/primitives/Task';
2
3
  import { describe, expect, it, vi } from 'vitest';
3
4
 
4
5
  import {
6
+ cancelStaleTaskBoardQueries,
5
7
  mergeOptimisticReorderedTaskIntoCache,
6
8
  mergeServerReorderedTaskIntoCache,
7
9
  } from '../task/reorder';
@@ -19,6 +21,22 @@ function createTask(overrides: Partial<Task> = {}): Task {
19
21
  }
20
22
 
21
23
  describe('reorder task cache helpers', () => {
24
+ it('cancels stale board fetches without reverting the dropped destination', () => {
25
+ const queryClient = new QueryClient();
26
+ const cancelQueries = vi.spyOn(queryClient, 'cancelQueries');
27
+
28
+ cancelStaleTaskBoardQueries(queryClient, 'board-1');
29
+
30
+ expect(cancelQueries).toHaveBeenCalledWith(
31
+ { queryKey: ['tasks', 'board-1'] },
32
+ { revert: false }
33
+ );
34
+ expect(cancelQueries).toHaveBeenCalledWith(
35
+ { queryKey: ['tasks-full', 'board-1'] },
36
+ { revert: false }
37
+ );
38
+ });
39
+
22
40
  it('marks optimistic list moves so stale list loads cannot move the card back', () => {
23
41
  vi.useFakeTimers();
24
42
  vi.setSystemTime(new Date('2026-05-07T01:00:00.000Z'));
@@ -3,11 +3,13 @@ import { describe, expect, it } from 'vitest';
3
3
  import {
4
4
  createTuturuuuNextConfig,
5
5
  createTuturuuuWebWorkspaceApiRewrites,
6
+ getTuturuuuNextOptimizePackageImports,
6
7
  isTuturuuuNextCacheComponentsEnabled,
7
8
  isTuturuuuNextReactCompilerEnabled,
8
9
  isTuturuuuTurbopackRustReactCompilerEnabled,
9
10
  resolveTuturuuuInfrastructureAppUrl,
10
11
  resolveTuturuuuWebAppUrl,
12
+ TUTURUUU_NEXT_IMAGE_MINIMUM_CACHE_TTL_SECONDS,
11
13
  TUTURUUU_NEXT_IMAGE_REMOTE_PATTERNS,
12
14
  TUTURUUU_NEXT_OPTIMIZE_PACKAGE_IMPORTS,
13
15
  trimTrailingSlashes,
@@ -26,6 +28,9 @@ describe('createTuturuuuNextConfig', () => {
26
28
  expect(config.images?.remotePatterns).toEqual(
27
29
  TUTURUUU_NEXT_IMAGE_REMOTE_PATTERNS
28
30
  );
31
+ expect(config.images?.minimumCacheTTL).toBe(
32
+ TUTURUUU_NEXT_IMAGE_MINIMUM_CACHE_TTL_SECONDS
33
+ );
29
34
  expect(config.poweredByHeader).toBe(false);
30
35
  expect(config.reactCompiler).toBe(true);
31
36
  expect(config.reactStrictMode).toBe(true);
@@ -170,6 +175,25 @@ describe('createTuturuuuNextConfig', () => {
170
175
  ]);
171
176
  });
172
177
 
178
+ it('stabilizes the webpack-only E2E satellite fallback', () => {
179
+ const originalWebpackBuild = process.env.NEXT_WEBPACK_BUILD;
180
+
181
+ try {
182
+ process.env.NEXT_WEBPACK_BUILD = '1';
183
+ const config = createTuturuuuNextConfig();
184
+
185
+ expect(config.experimental?.devMemoryThresholdRestart).toBe(false);
186
+ expect(config.experimental?.optimizePackageImports).toEqual([]);
187
+ expect(config.experimental?.turbopackRustReactCompiler).toBe(false);
188
+ } finally {
189
+ if (originalWebpackBuild === undefined) {
190
+ delete process.env.NEXT_WEBPACK_BUILD;
191
+ } else {
192
+ process.env.NEXT_WEBPACK_BUILD = originalWebpackBuild;
193
+ }
194
+ }
195
+ });
196
+
173
197
  it('dedupes image remote patterns while preserving app additions', () => {
174
198
  const config = createTuturuuuNextConfig({
175
199
  images: {
@@ -198,6 +222,7 @@ describe('createTuturuuuNextConfig', () => {
198
222
  partialPrefetching: false,
199
223
  reactCompiler: false,
200
224
  transpilePackages: ['@tuturuuu/ui'],
225
+ images: { minimumCacheTTL: 86_400 },
201
226
  experimental: {
202
227
  cpus: 2,
203
228
  turbopackFileSystemCacheForBuild: false,
@@ -213,6 +238,7 @@ describe('createTuturuuuNextConfig', () => {
213
238
  expect(config.partialPrefetching).toBe(false);
214
239
  expect(config.reactCompiler).toBe(true);
215
240
  expect(config.transpilePackages).toEqual(['@tuturuuu/ui']);
241
+ expect(config.images?.minimumCacheTTL).toBe(86_400);
216
242
  expect(config.experimental?.cpus).toBe(2);
217
243
  expect(config.experimental?.turbopackFileSystemCacheForBuild).toBe(false);
218
244
  expect(config.experimental?.turbopackRustReactCompiler).toBe(false);
@@ -259,6 +285,11 @@ describe('createTuturuuuWebWorkspaceApiRewrites', () => {
259
285
  source: '/api/workspaces/invitations',
260
286
  destination: 'https://tuturuuu.com/api/workspaces/invitations',
261
287
  },
288
+ {
289
+ source: '/api/v1/workspaces/:wsId/settings/permissions',
290
+ destination:
291
+ 'https://tuturuuu.com/api/v1/workspaces/:wsId/settings/permissions',
292
+ },
262
293
  ]);
263
294
  });
264
295
  });
@@ -296,6 +327,22 @@ describe('isTuturuuuTurbopackRustReactCompilerEnabled', () => {
296
327
  });
297
328
  });
298
329
 
330
+ describe('getTuturuuuNextOptimizePackageImports', () => {
331
+ it('keeps shared package optimization for Turbopack', () => {
332
+ expect(getTuturuuuNextOptimizePackageImports(['@tuturuuu/ui'], {})).toEqual(
333
+ [...TUTURUUU_NEXT_OPTIMIZE_PACKAGE_IMPORTS, '@tuturuuu/ui']
334
+ );
335
+ });
336
+
337
+ it('avoids webpack React Server Consumer Manifest barrel corruption', () => {
338
+ expect(
339
+ getTuturuuuNextOptimizePackageImports(['@tuturuuu/ui'], {
340
+ NEXT_WEBPACK_BUILD: '1',
341
+ })
342
+ ).toEqual(['@tuturuuu/ui']);
343
+ });
344
+ });
345
+
299
346
  describe('isTuturuuuNextReactCompilerEnabled', () => {
300
347
  it('keeps React Compiler enabled for next dev', () => {
301
348
  expect(
@@ -15,6 +15,7 @@ export const TUTURUUU_NEXT_OPTIMIZE_PACKAGE_IMPORTS = [
15
15
 
16
16
  export const TUTURUUU_WEB_WORKSPACE_API_RESERVED_PATHS = [
17
17
  '/api/workspaces/invitations',
18
+ '/api/v1/workspaces/:wsId/settings/permissions',
18
19
  ] as const;
19
20
 
20
21
  type NextImageConfig = NonNullable<NextConfig['images']>;
@@ -37,6 +38,8 @@ export const TUTURUUU_NEXT_IMAGE_REMOTE_PATTERNS = [
37
38
  },
38
39
  ] satisfies NextImageRemotePattern[];
39
40
 
41
+ export const TUTURUUU_NEXT_IMAGE_MINIMUM_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60;
42
+
40
43
  const TUTURUUU_ANTI_FRAMING_SOURCE =
41
44
  '/:path((?!api/v1/workspaces/[^/]+/external-projects/assets/[^/]+/webgl(?:/|$)).*)';
42
45
 
@@ -93,6 +96,20 @@ export function isTuturuuuTurbopackRustReactCompilerEnabled(
93
96
  return env.NEXT_WEBPACK_BUILD !== '1';
94
97
  }
95
98
 
99
+ export function getTuturuuuNextOptimizePackageImports(
100
+ appImports: readonly string[] | undefined,
101
+ env: Environment = process.env
102
+ ) {
103
+ // Next's webpack dev server can leave optimized workspace-package barrels
104
+ // out of the React Server Consumer Manifest after enough on-demand route
105
+ // compilations. E2E-owned satellites deliberately use webpack, so retain
106
+ // only explicit app imports there and keep the shared optimization for
107
+ // normal Turbopack development and production builds.
108
+ return env.NEXT_WEBPACK_BUILD === '1'
109
+ ? [...(appImports ?? [])]
110
+ : mergeStringArrays(TUTURUUU_NEXT_OPTIMIZE_PACKAGE_IMPORTS, appImports);
111
+ }
112
+
96
113
  export function createTuturuuuNextConfig(config: NextConfig = {}): NextConfig {
97
114
  const experimentalConfig = config.experimental ?? {};
98
115
  const imageConfig = config.images ?? {};
@@ -111,6 +128,9 @@ export function createTuturuuuNextConfig(config: NextConfig = {}): NextConfig {
111
128
  ),
112
129
  images: {
113
130
  ...imageConfig,
131
+ minimumCacheTTL:
132
+ imageConfig.minimumCacheTTL ??
133
+ TUTURUUU_NEXT_IMAGE_MINIMUM_CACHE_TTL_SECONDS,
114
134
  remotePatterns: mergeRemotePatterns(
115
135
  TUTURUUU_NEXT_IMAGE_REMOTE_PATTERNS,
116
136
  imageConfig.remotePatterns
@@ -128,8 +148,10 @@ export function createTuturuuuNextConfig(config: NextConfig = {}): NextConfig {
128
148
  turbopackRustReactCompiler:
129
149
  experimentalConfig.turbopackRustReactCompiler ??
130
150
  isTuturuuuTurbopackRustReactCompilerEnabled(),
131
- optimizePackageImports: mergeStringArrays(
132
- TUTURUUU_NEXT_OPTIMIZE_PACKAGE_IMPORTS,
151
+ devMemoryThresholdRestart:
152
+ experimentalConfig.devMemoryThresholdRestart ??
153
+ process.env.NEXT_WEBPACK_BUILD !== '1',
154
+ optimizePackageImports: getTuturuuuNextOptimizePackageImports(
133
155
  experimentalConfig.optimizePackageImports
134
156
  ),
135
157
  },
@@ -7,7 +7,7 @@ import {
7
7
 
8
8
  describe('platform release metadata', () => {
9
9
  it('uses the centralized shared browser app version', () => {
10
- expect(TUTURUUU_PLATFORM_VERSION).toBe('0.36.2'); // x-release-please-version
10
+ expect(TUTURUUU_PLATFORM_VERSION).toBe('0.38.0'); // x-release-please-version
11
11
  });
12
12
 
13
13
  it('preserves the source timestamp in builtAt and derives a short hash', () => {
@@ -1,6 +1,6 @@
1
1
  import { PLATFORM_BUILD_METADATA } from './generated/platform-build-metadata';
2
2
 
3
- export const TUTURUUU_PLATFORM_VERSION = '0.36.2'; // x-release-please-version
3
+ export const TUTURUUU_PLATFORM_VERSION = '0.38.0'; // x-release-please-version
4
4
 
5
5
  export type PlatformBuildMetadataInput = {
6
6
  /** ISO timestamp of the source commit used for the build, not deployment time. */
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  type QueryClient,
3
+ type QueryKey,
3
4
  useMutation,
4
5
  useQueryClient,
5
6
  } from '@tanstack/react-query';
@@ -31,6 +32,7 @@ type ReorderTaskMutationInput = {
31
32
  newSortKey: number;
32
33
  optimisticPreviousTasks?: Task[];
33
34
  optimisticPreviousFullTasks?: Task[];
35
+ optimisticPreviousFullTaskEntries?: [QueryKey, Task[] | undefined][];
34
36
  };
35
37
 
36
38
  export function mergeOptimisticReorderedTaskIntoCache(
@@ -93,9 +95,24 @@ function setReorderedTaskCache(
93
95
  ) {
94
96
  queryClient.setQueryData(['tasks', boardId], updater);
95
97
 
96
- if (queryClient.getQueryData<Task[]>(['tasks-full', boardId])) {
97
- queryClient.setQueryData(['tasks-full', boardId], updater);
98
- }
98
+ queryClient.setQueriesData<Task[]>(
99
+ { queryKey: ['tasks-full', boardId] },
100
+ updater
101
+ );
102
+ }
103
+
104
+ export function cancelStaleTaskBoardQueries(
105
+ queryClient: QueryClient,
106
+ boardId: string
107
+ ) {
108
+ void queryClient.cancelQueries(
109
+ { queryKey: ['tasks', boardId] },
110
+ { revert: false }
111
+ );
112
+ void queryClient.cancelQueries(
113
+ { queryKey: ['tasks-full', boardId] },
114
+ { revert: false }
115
+ );
99
116
  }
100
117
 
101
118
  // Reorder task within the same list or move to a different list with specific position
@@ -139,19 +156,21 @@ export function useReorderTask(boardId: string, wsId: string) {
139
156
  newSortKey,
140
157
  optimisticPreviousTasks,
141
158
  optimisticPreviousFullTasks,
159
+ optimisticPreviousFullTaskEntries,
142
160
  }) => {
143
161
  // Snapshot the previous value
144
162
  const previousTasks = queryClient.getQueryData<Task[]>([
145
163
  'tasks',
146
164
  boardId,
147
165
  ]);
148
- const previousFullTasks = queryClient.getQueryData<Task[]>([
149
- 'tasks-full',
150
- boardId,
151
- ]);
166
+ const previousFullTaskEntries = queryClient.getQueriesData<Task[]>({
167
+ queryKey: ['tasks-full', boardId],
168
+ });
152
169
 
153
- // Cancel any outgoing refetches without delaying the optimistic landing.
154
- void queryClient.cancelQueries({ queryKey: ['tasks', boardId] });
170
+ // Keep the drop preview visible while cancelling stale board responses.
171
+ // The default cancellation behavior reverts to the pre-fetch snapshot,
172
+ // which makes a dropped card briefly jump back to its source list.
173
+ cancelStaleTaskBoardQueries(queryClient, boardId);
155
174
 
156
175
  // Check if moving to a done or closed list
157
176
  const targetList = queryClient.getQueryData(['task_lists', boardId]) as
@@ -194,7 +213,16 @@ export function useReorderTask(boardId: string, wsId: string) {
194
213
 
195
214
  return {
196
215
  previousTasks: optimisticPreviousTasks ?? previousTasks,
197
- previousFullTasks: optimisticPreviousFullTasks ?? previousFullTasks,
216
+ previousFullTaskEntries:
217
+ optimisticPreviousFullTaskEntries ??
218
+ (optimisticPreviousFullTasks
219
+ ? [
220
+ [['tasks-full', boardId], optimisticPreviousFullTasks] as [
221
+ QueryKey,
222
+ Task[],
223
+ ],
224
+ ]
225
+ : previousFullTaskEntries),
198
226
  blockedTaskIdsPromise,
199
227
  };
200
228
  },
@@ -202,11 +230,8 @@ export function useReorderTask(boardId: string, wsId: string) {
202
230
  if (context?.previousTasks) {
203
231
  queryClient.setQueryData(['tasks', boardId], context.previousTasks);
204
232
  }
205
- if (context?.previousFullTasks) {
206
- queryClient.setQueryData(
207
- ['tasks-full', boardId],
208
- context.previousFullTasks
209
- );
233
+ for (const [queryKey, tasks] of context?.previousFullTaskEntries ?? []) {
234
+ queryClient.setQueryData(queryKey, tasks);
210
235
  }
211
236
 
212
237
  console.error('Failed to reorder task:', err);
@@ -0,0 +1,105 @@
1
+ import type { QueryClient } from '@tanstack/react-query';
2
+ import type { Task } from '@tuturuuu/types/primitives/Task';
3
+
4
+ export type OptimisticTask = Task & { _isOptimistic: true };
5
+
6
+ function updateOptimisticTaskCaches(
7
+ queryClient: QueryClient,
8
+ boardId: string,
9
+ updater: (tasks: Task[] | undefined) => Task[] | undefined
10
+ ) {
11
+ queryClient.setQueryData<Task[]>(['tasks', boardId], updater);
12
+ queryClient.setQueriesData<Task[]>(
13
+ { queryKey: ['tasks-full', boardId] },
14
+ updater
15
+ );
16
+ }
17
+
18
+ export function createOptimisticTask(
19
+ task: Partial<Task> & Pick<Task, 'list_id' | 'name'>,
20
+ options: { id?: string; now?: Date } = {}
21
+ ): OptimisticTask {
22
+ const now = options.now ?? new Date();
23
+ const id =
24
+ options.id ??
25
+ `optimistic-${
26
+ typeof crypto !== 'undefined' && 'randomUUID' in crypto
27
+ ? crypto.randomUUID()
28
+ : `${now.getTime()}-${Math.random().toString(36).slice(2)}`
29
+ }`;
30
+
31
+ return {
32
+ ...task,
33
+ _isOptimistic: true,
34
+ assignees: task.assignees ?? [],
35
+ created_at: task.created_at ?? now.toISOString(),
36
+ id,
37
+ labels: task.labels ?? [],
38
+ projects: task.projects ?? [],
39
+ } as OptimisticTask;
40
+ }
41
+
42
+ export function insertOptimisticTaskIntoBoardCaches(
43
+ queryClient: QueryClient,
44
+ boardId: string,
45
+ optimisticTask: Task
46
+ ) {
47
+ void queryClient.cancelQueries(
48
+ { queryKey: ['tasks', boardId] },
49
+ { revert: false }
50
+ );
51
+ void queryClient.cancelQueries(
52
+ { queryKey: ['tasks-full', boardId] },
53
+ { revert: false }
54
+ );
55
+
56
+ updateOptimisticTaskCaches(queryClient, boardId, (tasks) => {
57
+ const currentTasks = tasks ?? [];
58
+ if (currentTasks.some((task) => task.id === optimisticTask.id)) {
59
+ return currentTasks;
60
+ }
61
+ return [...currentTasks, optimisticTask];
62
+ });
63
+ }
64
+
65
+ export function reconcileOptimisticTaskInBoardCaches(
66
+ queryClient: QueryClient,
67
+ boardId: string,
68
+ optimisticTaskId: string,
69
+ createdTask: Task
70
+ ) {
71
+ updateOptimisticTaskCaches(queryClient, boardId, (tasks) => {
72
+ if (!tasks) return [createdTask];
73
+
74
+ const optimisticIndex = tasks.findIndex(
75
+ (task) => task.id === optimisticTaskId
76
+ );
77
+ const createdIndex = tasks.findIndex((task) => task.id === createdTask.id);
78
+ const insertionIndex =
79
+ optimisticIndex >= 0
80
+ ? optimisticIndex
81
+ : createdIndex >= 0
82
+ ? createdIndex
83
+ : tasks.length;
84
+ const nextTasks = tasks.filter(
85
+ (task) => task.id !== optimisticTaskId && task.id !== createdTask.id
86
+ );
87
+
88
+ nextTasks.splice(
89
+ Math.min(insertionIndex, nextTasks.length),
90
+ 0,
91
+ createdTask
92
+ );
93
+ return nextTasks;
94
+ });
95
+ }
96
+
97
+ export function removeOptimisticTaskFromBoardCaches(
98
+ queryClient: QueryClient,
99
+ boardId: string,
100
+ optimisticTaskId: string
101
+ ) {
102
+ updateOptimisticTaskCaches(queryClient, boardId, (tasks) =>
103
+ tasks?.filter((task) => task.id !== optimisticTaskId)
104
+ );
105
+ }
@@ -6,6 +6,12 @@ import {
6
6
  } from '@tuturuuu/internal-api/tasks';
7
7
  import type { Task } from '@tuturuuu/types/primitives/Task';
8
8
 
9
+ import {
10
+ createOptimisticTask,
11
+ insertOptimisticTaskIntoBoardCaches,
12
+ reconcileOptimisticTaskInBoardCaches,
13
+ removeOptimisticTaskFromBoardCaches,
14
+ } from './optimistic-task-cache';
9
15
  import { getBrowserApiOptions, toWorkspaceTaskUpdatePayload } from './shared';
10
16
 
11
17
  export function useUpdateTask(boardId: string, wsId?: string) {
@@ -165,69 +171,36 @@ export function useCreateTask(boardId: string, wsId?: string) {
165
171
  return createdTask as Task;
166
172
  },
167
173
  onMutate: async ({ listId, task }) => {
168
- await queryClient.cancelQueries({ queryKey: ['tasks', boardId] });
169
-
170
- const previousTasks = queryClient.getQueryData(['tasks', boardId]);
171
174
  const trimmedName = task.name?.trim() ?? '';
172
-
173
- const optimisticTask: Task = {
175
+ const optimisticTask = createOptimisticTask({
174
176
  ...task,
175
- id: `temp-${Date.now()}`,
176
177
  name: trimmedName,
177
178
  list_id: listId,
178
179
  closed_at: undefined,
179
180
  deleted_at: undefined,
180
- created_at: new Date().toISOString(),
181
- updated_at: new Date().toISOString(),
182
- assignees: [],
183
- } as Task;
181
+ });
184
182
 
185
- queryClient.setQueryData(
186
- ['tasks', boardId],
187
- (old: Task[] | undefined) => {
188
- if (!old) return [optimisticTask];
189
- return [...old, optimisticTask];
190
- }
191
- );
183
+ insertOptimisticTaskIntoBoardCaches(queryClient, boardId, optimisticTask);
192
184
 
193
- return { previousTasks, optimisticTask };
185
+ return { optimisticTask };
194
186
  },
195
187
  onError: (err, _, context) => {
196
- if (context?.previousTasks) {
197
- queryClient.setQueryData(['tasks', boardId], context.previousTasks);
198
- } else if (context?.optimisticTask) {
199
- queryClient.setQueryData(
200
- ['tasks', boardId],
201
- (old: Task[] | undefined) => {
202
- if (!old) return old;
203
- const nextTasks = old.filter(
204
- (task) => task.id !== context.optimisticTask.id
205
- );
206
- return nextTasks.length > 0 ? nextTasks : undefined;
207
- }
188
+ if (context?.optimisticTask) {
189
+ removeOptimisticTaskFromBoardCaches(
190
+ queryClient,
191
+ boardId,
192
+ context.optimisticTask.id
208
193
  );
209
194
  }
210
195
 
211
196
  console.error('Failed to create task:', err);
212
197
  },
213
198
  onSuccess: (newTask, _, context) => {
214
- queryClient.setQueryData(
215
- ['tasks', boardId],
216
- (old: Task[] | undefined) => {
217
- const optimisticTaskId = context?.optimisticTask.id;
218
-
219
- if (!old) return [newTask];
220
-
221
- const nextTasks = optimisticTaskId
222
- ? old.map((task) => (task.id === optimisticTaskId ? newTask : task))
223
- : [...old];
224
-
225
- if (nextTasks.some((task) => task.id === newTask.id)) {
226
- return nextTasks;
227
- }
228
-
229
- return [...nextTasks, newTask];
230
- }
199
+ reconcileOptimisticTaskInBoardCaches(
200
+ queryClient,
201
+ boardId,
202
+ context?.optimisticTask.id ?? newTask.id,
203
+ newTask
231
204
  );
232
205
  },
233
206
  });
@@ -2,6 +2,7 @@ export { reorderTask, useReorderTask } from './task/reorder';
2
2
  export { transformAssignees, transformTaskRecord } from './task/transformers';
3
3
  export * from './task-helper/board';
4
4
  export * from './task-helper/bulk-actions';
5
+ export * from './task-helper/optimistic-task-cache';
5
6
  export * from './task-helper/personal-external-staging';
6
7
  export * from './task-helper/recycle-bin';
7
8
  export * from './task-helper/relationships';
@@ -676,7 +676,7 @@ export interface PermissionsResult {
676
676
  withoutPermission(permission: PermissionId): boolean;
677
677
  }
678
678
 
679
- async function resolveWorkspaceIdForPermissions({
679
+ export async function resolveWorkspaceIdForPrincipal({
680
680
  authorizationClient,
681
681
  principal,
682
682
  wsId,
@@ -773,7 +773,7 @@ async function getPermissionsImpl({
773
773
  // Handle "personal" workspace slug by looking up the user's personal workspace
774
774
  let resolvedWorkspaceId: string;
775
775
  try {
776
- resolvedWorkspaceId = await resolveWorkspaceIdForPermissions({
776
+ resolvedWorkspaceId = await resolveWorkspaceIdForPrincipal({
777
777
  authorizationClient: sbAdmin,
778
778
  principal,
779
779
  wsId,
@@ -50,4 +50,55 @@ describe('getWorkspaceUserLinkForUser', () => {
50
50
  expect(mocks.createClient).not.toHaveBeenCalled();
51
51
  expect(mocks.createAdminClient).not.toHaveBeenCalled();
52
52
  });
53
+
54
+ it('returns a link created by a concurrent repair even when its own RPC errors', async () => {
55
+ const repairedLink = {
56
+ created_at: '2026-08-14T00:00:00.000Z',
57
+ platform_user_id: 'platform-user-1',
58
+ virtual_user_id: 'virtual-user-1',
59
+ ws_id: 'workspace-1',
60
+ };
61
+ const initialMaybeSingle = vi
62
+ .fn()
63
+ .mockResolvedValue({ data: null, error: null });
64
+ const repairedMaybeSingle = vi
65
+ .fn()
66
+ .mockResolvedValue({ data: repairedLink, error: null });
67
+ const createLookupClient = (maybeSingle: ReturnType<typeof vi.fn>) => ({
68
+ from: vi.fn(() => ({
69
+ select: vi.fn(() => ({
70
+ eq: vi.fn(() => ({
71
+ eq: vi.fn(() => ({
72
+ limit: vi.fn(() => ({ maybeSingle })),
73
+ })),
74
+ })),
75
+ })),
76
+ })),
77
+ });
78
+ const authorizationClient = createLookupClient(initialMaybeSingle);
79
+ const adminClient = {
80
+ ...createLookupClient(repairedMaybeSingle),
81
+ rpc: vi.fn().mockResolvedValue({
82
+ error: new Error('duplicate repair already completed'),
83
+ }),
84
+ };
85
+ mocks.verifyWorkspaceMembershipType.mockResolvedValue({
86
+ membershipType: 'MEMBER',
87
+ ok: true,
88
+ });
89
+ mocks.createAdminClient.mockResolvedValue(adminClient);
90
+
91
+ await expect(
92
+ getWorkspaceUserLinkForUser('workspace-1', 'platform-user-1', {
93
+ authorizationClient:
94
+ authorizationClient as unknown as TypedSupabaseClient,
95
+ })
96
+ ).resolves.toEqual(repairedLink);
97
+
98
+ expect(adminClient.rpc).toHaveBeenCalledWith('ensure_workspace_user_link', {
99
+ target_user_id: 'platform-user-1',
100
+ target_ws_id: 'workspace-1',
101
+ });
102
+ expect(repairedMaybeSingle).toHaveBeenCalledOnce();
103
+ });
53
104
  });
@@ -37,6 +37,25 @@ function toLink(row: LinkRow): WorkspaceUserLink {
37
37
  };
38
38
  }
39
39
 
40
+ async function readWorkspaceUserLink(
41
+ supabase: TypedSupabaseClient,
42
+ wsId: string,
43
+ userId: string
44
+ ) {
45
+ const { data, error } = await supabase
46
+ .from('workspace_user_linked_users')
47
+ .select(LINK_SELECT)
48
+ .eq('platform_user_id', userId)
49
+ .eq('ws_id', wsId)
50
+ .limit(1)
51
+ .maybeSingle();
52
+
53
+ return {
54
+ error,
55
+ link: data ? toLink(data as LinkRow) : null,
56
+ };
57
+ }
58
+
40
59
  /**
41
60
  * Resolves the workspace-user link for an EXPLICIT platform user id.
42
61
  *
@@ -60,15 +79,13 @@ export async function getWorkspaceUserLinkForUser(
60
79
  const supabase = authorizationClient ?? (await createClient());
61
80
  const resolvedWsId = resolveWorkspaceId(wsId);
62
81
 
63
- const { data: workspaceUser } = await supabase
64
- .from('workspace_user_linked_users')
65
- .select(LINK_SELECT)
66
- .eq('platform_user_id', userId)
67
- .eq('ws_id', resolvedWsId)
68
- .limit(1)
69
- .maybeSingle();
82
+ const initialLookup = await readWorkspaceUserLink(
83
+ supabase,
84
+ resolvedWsId,
85
+ userId
86
+ );
70
87
 
71
- if (workspaceUser) return toLink(workspaceUser as LinkRow);
88
+ if (initialLookup.link) return initialLookup.link;
72
89
 
73
90
  if (!autoRepair) return null;
74
91
 
@@ -94,23 +111,29 @@ export async function getWorkspaceUserLinkForUser(
94
111
  target_ws_id: resolvedWsId,
95
112
  });
96
113
 
114
+ // Always re-read with the service-role client, including after an RPC
115
+ // error. Another layout/page/API request may have repaired the same member
116
+ // concurrently, and returning null here would turn a valid membership into
117
+ // a route-level 404 or an empty profile-scoped module.
118
+ const repairedLookup = await readWorkspaceUserLink(
119
+ sbAdmin,
120
+ resolvedWsId,
121
+ userId
122
+ );
123
+
124
+ if (repairedLookup.link) return repairedLookup.link;
125
+
97
126
  if (repairError) {
98
127
  console.error(
99
128
  '[getWorkspaceUserLinkForUser] Failed to auto-repair workspace user link:',
100
129
  repairError
101
130
  );
102
- return null;
131
+ } else if (repairedLookup.error) {
132
+ console.error(
133
+ '[getWorkspaceUserLinkForUser] Failed to fetch repaired workspace user link:',
134
+ repairedLookup.error
135
+ );
103
136
  }
104
-
105
- const { data: repairedUser } = await supabase
106
- .from('workspace_user_linked_users')
107
- .select(LINK_SELECT)
108
- .eq('platform_user_id', userId)
109
- .eq('ws_id', resolvedWsId)
110
- .limit(1)
111
- .maybeSingle();
112
-
113
- if (repairedUser) return toLink(repairedUser as LinkRow);
114
137
  } catch (err) {
115
138
  console.error(
116
139
  '[getWorkspaceUserLinkForUser] Error during auto-repair:',