@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 +21 -0
- package/biome.json +1 -1
- package/package.json +9 -9
- package/src/__tests__/optimistic-task-cache.test.ts +117 -0
- package/src/__tests__/task-reorder-cache.test.ts +18 -0
- package/src/next-config.test.ts +47 -0
- package/src/next-config.ts +24 -2
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- package/src/task/reorder.ts +40 -15
- package/src/task-helper/optimistic-task-cache.ts +105 -0
- package/src/task-helper/task-hooks-basic.ts +20 -47
- package/src/task-helper.ts +1 -0
- package/src/workspace-helper.ts +2 -2
- package/src/workspace-user-link.test.ts +51 -0
- package/src/workspace-user-link.ts +42 -19
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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/utils",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "0.
|
|
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.
|
|
21
|
-
"@noble/hashes": "^2.
|
|
20
|
+
"@noble/ciphers": "^2.3.0",
|
|
21
|
+
"@noble/hashes": "^2.3.0",
|
|
22
22
|
"@tanstack/react-query": "^5.101.4",
|
|
23
|
-
"@tiptap/core": "3.
|
|
24
|
-
"@tiptap/react": "3.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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'));
|
package/src/next-config.test.ts
CHANGED
|
@@ -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(
|
package/src/next-config.ts
CHANGED
|
@@ -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
|
-
|
|
132
|
-
|
|
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.
|
|
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', () => {
|
package/src/platform-release.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { PLATFORM_BUILD_METADATA } from './generated/platform-build-metadata';
|
|
2
2
|
|
|
3
|
-
export const TUTURUUU_PLATFORM_VERSION = '0.
|
|
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. */
|
package/src/task/reorder.ts
CHANGED
|
@@ -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
|
-
|
|
97
|
-
|
|
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
|
|
149
|
-
'tasks-full',
|
|
150
|
-
|
|
151
|
-
]);
|
|
166
|
+
const previousFullTaskEntries = queryClient.getQueriesData<Task[]>({
|
|
167
|
+
queryKey: ['tasks-full', boardId],
|
|
168
|
+
});
|
|
152
169
|
|
|
153
|
-
//
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
updated_at: new Date().toISOString(),
|
|
182
|
-
assignees: [],
|
|
183
|
-
} as Task;
|
|
181
|
+
});
|
|
184
182
|
|
|
185
|
-
queryClient
|
|
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 {
|
|
185
|
+
return { optimisticTask };
|
|
194
186
|
},
|
|
195
187
|
onError: (err, _, context) => {
|
|
196
|
-
if (context?.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
});
|
package/src/task-helper.ts
CHANGED
|
@@ -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';
|
package/src/workspace-helper.ts
CHANGED
|
@@ -676,7 +676,7 @@ export interface PermissionsResult {
|
|
|
676
676
|
withoutPermission(permission: PermissionId): boolean;
|
|
677
677
|
}
|
|
678
678
|
|
|
679
|
-
async function
|
|
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
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
.limit(1)
|
|
69
|
-
.maybeSingle();
|
|
82
|
+
const initialLookup = await readWorkspaceUserLink(
|
|
83
|
+
supabase,
|
|
84
|
+
resolvedWsId,
|
|
85
|
+
userId
|
|
86
|
+
);
|
|
70
87
|
|
|
71
|
-
if (
|
|
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
|
-
|
|
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:',
|