@tuturuuu/utils 0.25.0 → 0.25.2
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 +14 -0
- package/package.json +2 -2
- package/src/__tests__/task-helper-identifier.test.ts +13 -0
- package/src/next-config.test.ts +36 -0
- package/src/next-config.ts +18 -2
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- package/src/task-helper/shared.ts +4 -1
- package/src/workspace-user-link.test.ts +51 -0
- package/src/workspace-user-link.ts +42 -19
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.25.2](https://github.com/tutur3u/platform/compare/utils-v0.25.1...utils-v0.25.2) (2026-08-16)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **tasks:** reconcile external task ordering ([2ebeefa](https://github.com/tutur3u/platform/commit/2ebeefa4c13ea9d0b62c33e50cf4f6fed7fac76a))
|
|
9
|
+
|
|
10
|
+
## [0.25.1](https://github.com/tutur3u/platform/compare/utils-v0.25.0...utils-v0.25.1) (2026-08-15)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **contacts:** harden invited profile access ([19878d0](https://github.com/tutur3u/platform/commit/19878d06466eafc8cef74af52e14ddbdb6eef510))
|
|
16
|
+
|
|
3
17
|
## [0.25.0](https://github.com/tutur3u/platform/compare/utils-v0.24.2...utils-v0.25.0) (2026-08-14)
|
|
4
18
|
|
|
5
19
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/utils",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "0.25.
|
|
4
|
+
"version": "0.25.2",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/tutur3u/platform",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
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",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getTicketIdentifier } from '../task-helper';
|
|
3
|
+
|
|
4
|
+
describe('getTicketIdentifier', () => {
|
|
5
|
+
it('keeps only the prefix while an optimistic task has no display number', () => {
|
|
6
|
+
expect(getTicketIdentifier('VHP', undefined)).toBe('VHP');
|
|
7
|
+
expect(getTicketIdentifier(null, undefined)).toBe('TASK');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('keeps the full identifier after the display number is assigned', () => {
|
|
11
|
+
expect(getTicketIdentifier('VHP', 247)).toBe('VHP-247');
|
|
12
|
+
});
|
|
13
|
+
});
|
package/src/next-config.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ 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,
|
|
@@ -174,6 +175,25 @@ describe('createTuturuuuNextConfig', () => {
|
|
|
174
175
|
]);
|
|
175
176
|
});
|
|
176
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
|
+
|
|
177
197
|
it('dedupes image remote patterns while preserving app additions', () => {
|
|
178
198
|
const config = createTuturuuuNextConfig({
|
|
179
199
|
images: {
|
|
@@ -307,6 +327,22 @@ describe('isTuturuuuTurbopackRustReactCompilerEnabled', () => {
|
|
|
307
327
|
});
|
|
308
328
|
});
|
|
309
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
|
+
|
|
310
346
|
describe('isTuturuuuNextReactCompilerEnabled', () => {
|
|
311
347
|
it('keeps React Compiler enabled for next dev', () => {
|
|
312
348
|
expect(
|
package/src/next-config.ts
CHANGED
|
@@ -96,6 +96,20 @@ export function isTuturuuuTurbopackRustReactCompilerEnabled(
|
|
|
96
96
|
return env.NEXT_WEBPACK_BUILD !== '1';
|
|
97
97
|
}
|
|
98
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
|
+
|
|
99
113
|
export function createTuturuuuNextConfig(config: NextConfig = {}): NextConfig {
|
|
100
114
|
const experimentalConfig = config.experimental ?? {};
|
|
101
115
|
const imageConfig = config.images ?? {};
|
|
@@ -134,8 +148,10 @@ export function createTuturuuuNextConfig(config: NextConfig = {}): NextConfig {
|
|
|
134
148
|
turbopackRustReactCompiler:
|
|
135
149
|
experimentalConfig.turbopackRustReactCompiler ??
|
|
136
150
|
isTuturuuuTurbopackRustReactCompilerEnabled(),
|
|
137
|
-
|
|
138
|
-
|
|
151
|
+
devMemoryThresholdRestart:
|
|
152
|
+
experimentalConfig.devMemoryThresholdRestart ??
|
|
153
|
+
process.env.NEXT_WEBPACK_BUILD !== '1',
|
|
154
|
+
optimizePackageImports: getTuturuuuNextOptimizePackageImports(
|
|
139
155
|
experimentalConfig.optimizePackageImports
|
|
140
156
|
),
|
|
141
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.39.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.39.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. */
|
|
@@ -4,9 +4,12 @@ import type { Task } from '@tuturuuu/types/primitives/Task';
|
|
|
4
4
|
|
|
5
5
|
export function getTicketIdentifier(
|
|
6
6
|
prefix: string | null | undefined,
|
|
7
|
-
displayNumber: number
|
|
7
|
+
displayNumber: number | null | undefined
|
|
8
8
|
): string {
|
|
9
9
|
const effectivePrefix = prefix?.trim() || 'TASK';
|
|
10
|
+
if (typeof displayNumber !== 'number' || !Number.isFinite(displayNumber)) {
|
|
11
|
+
return effectivePrefix.toUpperCase();
|
|
12
|
+
}
|
|
10
13
|
return `${effectivePrefix}-${displayNumber}`.toUpperCase();
|
|
11
14
|
}
|
|
12
15
|
|
|
@@ -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:',
|