@walkhi/code-relax 0.1.0-beta.50 → 0.1.0-beta.51

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.
Files changed (89) hide show
  1. package/README.md +105 -105
  2. package/dist/bin/cleanup-accounts.mjs +63 -63
  3. package/dist/bin/codex-remote.mjs +403 -403
  4. package/dist/bin/publish-android-release.mjs +63 -63
  5. package/dist/bin/release-upload-server.mjs +190 -190
  6. package/dist/build-platform.json +1 -1
  7. package/dist/get-shared-service-url.ps1 +14 -14
  8. package/dist/shared/code-browser.cjs +159 -159
  9. package/dist/shared/file-changes.cjs +31 -31
  10. package/dist/shared/file-citations.cjs +117 -117
  11. package/dist/shared/file-mentions.cjs +92 -92
  12. package/dist/shared/message-delivery.cjs +115 -115
  13. package/dist/shared/p2p-file-channel.cjs +87 -87
  14. package/dist/shared/server-requests.cjs +311 -311
  15. package/dist/shared/timeline-sync.cjs +173 -173
  16. package/dist/skills/code-relax/SKILL.md +22 -22
  17. package/dist/src/bounded-process-log.mjs +63 -63
  18. package/dist/src/celsius-client.mjs +107 -107
  19. package/dist/src/dots-approval-timeline.mjs +99 -99
  20. package/dist/src/dots-avatar.mjs +42 -42
  21. package/dist/src/dots-chat-projection.mjs +50 -50
  22. package/dist/src/dots-chat-server.mjs +483 -483
  23. package/dist/src/dots-message-images.mjs +319 -319
  24. package/dist/src/dots-profile.mjs +77 -77
  25. package/dist/src/dots-room-activity.mjs +172 -172
  26. package/dist/src/dots-room-client.mjs +135 -135
  27. package/dist/src/dots-room-projection.mjs +94 -94
  28. package/dist/src/dots-schedules.mjs +66 -66
  29. package/dist/src/dots-summary-tasks.mjs +32 -32
  30. package/dist/src/dots-task-catalog.mjs +430 -430
  31. package/dist/src/file-transfer-range.mjs +22 -22
  32. package/dist/src/file-upload-session.mjs +130 -130
  33. package/dist/src/hosted-history-app-server.mjs +103 -103
  34. package/dist/src/hosted-history-cache.mjs +113 -113
  35. package/dist/src/lan-socket-server.mjs +266 -266
  36. package/dist/src/managed-app-server.mjs +373 -373
  37. package/dist/src/managed-endpoint.mjs +25 -25
  38. package/dist/src/message-upload-files.mjs +109 -109
  39. package/dist/src/onboarding.mjs +12 -12
  40. package/dist/src/platform/README.md +1 -1
  41. package/dist/src/platform/codex-protocol.mjs +46 -46
  42. package/dist/src/platform/desktop-project-store.mjs +208 -208
  43. package/dist/src/platform/linux/desktop-host.mjs +87 -87
  44. package/dist/src/platform/linux/process.mjs +53 -53
  45. package/dist/src/platform/linux/system.mjs +50 -50
  46. package/dist/src/platform/linux/user-environment.mjs +9 -9
  47. package/dist/src/platform/macos/codex-runtime.mjs +125 -125
  48. package/dist/src/platform/macos/desktop-host.mjs +86 -86
  49. package/dist/src/platform/macos/user-environment.mjs +9 -9
  50. package/dist/src/platform/native-package.mjs +58 -58
  51. package/dist/src/platform/windows/activate-desktop.ps1 +49 -49
  52. package/dist/src/platform/windows/codex-runtime.mjs +215 -215
  53. package/dist/src/platform/windows/credential-store.mjs +27 -27
  54. package/dist/src/platform/windows/credential-store.ps1 +49 -49
  55. package/dist/src/platform/windows/desktop-host.mjs +65 -65
  56. package/dist/src/platform/windows/desktop-info.ps1 +38 -38
  57. package/dist/src/platform/windows/desktop-project-registration.mjs +4 -4
  58. package/dist/src/platform/windows/desktop-shortcuts.ps1 +14 -14
  59. package/dist/src/platform/windows/dots-profile.mjs +49 -49
  60. package/dist/src/platform/windows/launch-worker.mjs +41 -41
  61. package/dist/src/platform/windows/project-locations.mjs +16 -16
  62. package/dist/src/platform/windows/service-host.mjs +35 -35
  63. package/dist/src/platform/windows/user-environment.mjs +52 -52
  64. package/dist/src/project-management.mjs +58 -58
  65. package/dist/src/runtime-discovery.mjs +47 -47
  66. package/dist/src/self-relay/account-cleanup.mjs +30 -30
  67. package/dist/src/self-relay/admin-page.mjs +80 -80
  68. package/dist/src/self-relay/admin-style.mjs +14 -14
  69. package/dist/src/self-relay/connector.mjs +439 -439
  70. package/dist/src/self-relay/demo.mjs +31 -31
  71. package/dist/src/self-relay/diagnostics.mjs +54 -54
  72. package/dist/src/self-relay/email-accounts.mjs +183 -183
  73. package/dist/src/self-relay/file-wire.mjs +13 -13
  74. package/dist/src/self-relay/p2p-probe.mjs +168 -168
  75. package/dist/src/self-relay/privacy-page.mjs +26 -26
  76. package/dist/src/self-relay/public-releases.mjs +67 -67
  77. package/dist/src/self-relay/rtc-file-bridge.mjs +41 -41
  78. package/dist/src/self-relay/usage-meter.mjs +141 -141
  79. package/dist/src/server.mjs +5 -4
  80. package/dist/src/service-doctor.mjs +148 -148
  81. package/dist/src/service-install.mjs +97 -97
  82. package/dist/src/service-lifecycle.mjs +139 -139
  83. package/dist/src/shared-app-server.mjs +666 -666
  84. package/dist/src/shared-catalog.mjs +132 -132
  85. package/dist/src/shared-thread-stream.mjs +165 -165
  86. package/dist/src/usage-reset.mjs +17 -17
  87. package/dist/src/workstation-downloads.mjs +120 -120
  88. package/package.json +4 -4
  89. package/tools/postinstall.mjs +186 -186
@@ -1,52 +1,52 @@
1
- import path from 'node:path';
2
- import fs from 'node:fs';
3
- import { readRecord, writeRecord } from '../../service-lifecycle.mjs';
4
- import { runScript } from './desktop-host.mjs';
5
- import { managedEndpointUrl } from '../../managed-endpoint.mjs';
6
-
7
- const ownershipFile = 'managed-environment.json';
8
-
9
- export async function readManagedEnvironment() {
10
- return JSON.parse(await runScript('user-environment.ps1', ['-Action', 'Read']));
11
- }
12
-
13
- export async function configureManagedEnvironment(stateRoot, url) {
14
- const current = await readManagedEnvironment();
15
- if (!managedEndpointUrl(url)) throw new Error('未确认 Codex 内核地址,未修改环境变量。');
16
- const ownershipPath = path.join(stateRoot, ownershipFile);
17
- const ownership = readRecord(ownershipPath);
18
- if (current.forceCliUser === '1' || current.forceCliMachine === '1') {
19
- throw new Error('CODEX_APP_SERVER_FORCE_CLI=1 会让 Codex 忽略当前内核地址;未修改该配置。');
20
- }
21
- if (current.userValue && current.userValue !== url && current.userValue !== ownership?.value) {
22
- throw new Error(`用户已有 CODEX_APP_SERVER_WS_URL=${current.userValue};未覆盖该配置。`);
23
- }
24
- if (current.userValue === url) {
25
- const owned = ownership?.value === url;
26
- return { configured: true, value: current.userValue, changed: false, owned };
27
- }
28
- const applied = JSON.parse(await runScript('user-environment.ps1', ['-Action', 'Set', '-Value', url]));
29
- if (applied.userValue !== url) throw new Error('CODEX_APP_SERVER_WS_URL 写入后读回不一致。');
30
- writeRecord(ownershipPath, { name: 'CODEX_APP_SERVER_WS_URL', value: url,
31
- configuredAt: new Date().toISOString() });
32
- return { configured: true, value: applied.userValue, changed: true, owned: true };
33
- }
34
-
35
- export async function clearManagedEnvironment(stateRoot) {
36
- const ownershipPath = path.join(stateRoot, ownershipFile);
37
- const ownership = readRecord(ownershipPath);
38
- const current = await readManagedEnvironment();
39
- if (!current.userValue) {
40
- fs.rmSync(ownershipPath, { force: true });
41
- return { removed: false, preserved: false, reason: 'already-empty' };
42
- }
43
- if (!managedEndpointUrl(ownership?.value) || current.userValue !== ownership.value) {
44
- fs.rmSync(ownershipPath, { force: true });
45
- return { removed: false, preserved: true, value: current.userValue,
46
- reason: managedEndpointUrl(ownership?.value) ? 'user-modified' : 'not-owned' };
47
- }
48
- const result = JSON.parse(await runScript('user-environment.ps1', ['-Action', 'Clear']));
49
- if (result.userValue) throw new Error('CODEX_APP_SERVER_WS_URL 清理后读回仍不为空。');
50
- fs.rmSync(ownershipPath, { force: true });
51
- return { removed: true, preserved: false, reason: 'removed' };
52
- }
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import { readRecord, writeRecord } from '../../service-lifecycle.mjs';
4
+ import { runScript } from './desktop-host.mjs';
5
+ import { managedEndpointUrl } from '../../managed-endpoint.mjs';
6
+
7
+ const ownershipFile = 'managed-environment.json';
8
+
9
+ export async function readManagedEnvironment() {
10
+ return JSON.parse(await runScript('user-environment.ps1', ['-Action', 'Read']));
11
+ }
12
+
13
+ export async function configureManagedEnvironment(stateRoot, url) {
14
+ const current = await readManagedEnvironment();
15
+ if (!managedEndpointUrl(url)) throw new Error('未确认 Codex 内核地址,未修改环境变量。');
16
+ const ownershipPath = path.join(stateRoot, ownershipFile);
17
+ const ownership = readRecord(ownershipPath);
18
+ if (current.forceCliUser === '1' || current.forceCliMachine === '1') {
19
+ throw new Error('CODEX_APP_SERVER_FORCE_CLI=1 会让 Codex 忽略当前内核地址;未修改该配置。');
20
+ }
21
+ if (current.userValue && current.userValue !== url && current.userValue !== ownership?.value) {
22
+ throw new Error(`用户已有 CODEX_APP_SERVER_WS_URL=${current.userValue};未覆盖该配置。`);
23
+ }
24
+ if (current.userValue === url) {
25
+ const owned = ownership?.value === url;
26
+ return { configured: true, value: current.userValue, changed: false, owned };
27
+ }
28
+ const applied = JSON.parse(await runScript('user-environment.ps1', ['-Action', 'Set', '-Value', url]));
29
+ if (applied.userValue !== url) throw new Error('CODEX_APP_SERVER_WS_URL 写入后读回不一致。');
30
+ writeRecord(ownershipPath, { name: 'CODEX_APP_SERVER_WS_URL', value: url,
31
+ configuredAt: new Date().toISOString() });
32
+ return { configured: true, value: applied.userValue, changed: true, owned: true };
33
+ }
34
+
35
+ export async function clearManagedEnvironment(stateRoot) {
36
+ const ownershipPath = path.join(stateRoot, ownershipFile);
37
+ const ownership = readRecord(ownershipPath);
38
+ const current = await readManagedEnvironment();
39
+ if (!current.userValue) {
40
+ fs.rmSync(ownershipPath, { force: true });
41
+ return { removed: false, preserved: false, reason: 'already-empty' };
42
+ }
43
+ if (!managedEndpointUrl(ownership?.value) || current.userValue !== ownership.value) {
44
+ fs.rmSync(ownershipPath, { force: true });
45
+ return { removed: false, preserved: true, value: current.userValue,
46
+ reason: managedEndpointUrl(ownership?.value) ? 'user-modified' : 'not-owned' };
47
+ }
48
+ const result = JSON.parse(await runScript('user-environment.ps1', ['-Action', 'Clear']));
49
+ if (result.userValue) throw new Error('CODEX_APP_SERVER_WS_URL 清理后读回仍不为空。');
50
+ fs.rmSync(ownershipPath, { force: true });
51
+ return { removed: true, preserved: false, reason: 'removed' };
52
+ }
@@ -1,59 +1,59 @@
1
- import path from 'node:path';
2
-
3
- const fail = (message, statusCode = 400) => { throw Object.assign(new Error(message), { statusCode }); };
4
- const pathKey = value => process.platform === 'win32' ? value.toLowerCase() : value;
5
-
6
- function absoluteDirectory(value) {
7
- if (typeof value !== 'string' || !value.trim() || !path.isAbsolute(value.trim())) fail('请输入电脑上的绝对文件夹路径。');
8
- return path.normalize(value.trim());
9
- }
10
-
11
- async function directory(rpc, value) {
12
- const target = absoluteDirectory(value);
13
- const stat = await rpc('fs/getMetadata', { path: target });
14
- if (!stat.isDirectory) fail(`此路径不是文件夹:${target}`);
15
- return target;
16
- }
17
-
18
- function folderName(value) {
19
- if (typeof value !== 'string' || !value.trim() || /[<>:"/\\|?*\x00-\x1f]/.test(value) || /[ .]$/.test(value)
20
- || /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(value) || ['.', '..'].includes(value)) {
21
- fail('文件夹名称无效,请勿使用路径分隔符或系统保留名称。');
22
- }
23
- return value;
24
- }
25
-
26
- export async function projectRoots(rpc, roots) {
27
- if (!Array.isArray(roots)) fail('项目文件夹列表无效。');
28
- const result = [], seen = new Set();
29
- for (const root of roots) {
30
- const target = await directory(rpc, root?.path);
31
- const key = pathKey(target);
32
- if (seen.has(key)) fail(`重复的项目文件夹:${target}`);
33
- seen.add(key); result.push({ path: target });
34
- }
35
- return result;
36
- }
37
-
38
- export function projectSummary(project) {
39
- return { projectId: project.id, name: project.name, label: project.name, roots: project.roots,
40
- path: project.roots[0]?.path || '', cwd: project.roots[0]?.path || '', hostId: 'local' };
41
- }
42
-
43
- export async function browseProjectFolders(rpc, locations, value) {
44
- const target = absoluteDirectory(value || locations?.home);
45
- const entries = (await rpc('fs/readDirectory', { path: target })).entries
46
- .filter(entry => entry.isDirectory).map(entry => ({ name: entry.fileName, path: path.join(target, entry.fileName) }))
47
- .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { numeric: true }));
48
- return { path: target, parent: path.dirname(target) === target ? null : path.dirname(target), entries, ...(locations || {}) };
49
- }
50
-
51
- export async function createProjectFolder(rpc, body) {
52
- const parent = await directory(rpc, body.parent);
53
- const name = folderName(body.name);
54
- const entries = (await rpc('fs/readDirectory', { path: parent })).entries;
55
- if (entries.some(entry => pathKey(entry.fileName) === pathKey(name))) fail('同名文件夹已存在,请选择它或使用其他名称。', 409);
56
- const target = path.join(parent, name);
57
- await rpc('fs/createDirectory', { path: target, recursive: false });
58
- return { path: target };
1
+ import path from 'node:path';
2
+
3
+ const fail = (message, statusCode = 400) => { throw Object.assign(new Error(message), { statusCode }); };
4
+ const pathKey = value => process.platform === 'win32' ? value.toLowerCase() : value;
5
+
6
+ function absoluteDirectory(value) {
7
+ if (typeof value !== 'string' || !value.trim() || !path.isAbsolute(value.trim())) fail('请输入电脑上的绝对文件夹路径。');
8
+ return path.normalize(value.trim());
9
+ }
10
+
11
+ async function directory(rpc, value) {
12
+ const target = absoluteDirectory(value);
13
+ const stat = await rpc('fs/getMetadata', { path: target });
14
+ if (!stat.isDirectory) fail(`此路径不是文件夹:${target}`);
15
+ return target;
16
+ }
17
+
18
+ function folderName(value) {
19
+ if (typeof value !== 'string' || !value.trim() || /[<>:"/\\|?*\x00-\x1f]/.test(value) || /[ .]$/.test(value)
20
+ || /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(value) || ['.', '..'].includes(value)) {
21
+ fail('文件夹名称无效,请勿使用路径分隔符或系统保留名称。');
22
+ }
23
+ return value;
24
+ }
25
+
26
+ export async function projectRoots(rpc, roots) {
27
+ if (!Array.isArray(roots)) fail('项目文件夹列表无效。');
28
+ const result = [], seen = new Set();
29
+ for (const root of roots) {
30
+ const target = await directory(rpc, root?.path);
31
+ const key = pathKey(target);
32
+ if (seen.has(key)) fail(`重复的项目文件夹:${target}`);
33
+ seen.add(key); result.push({ path: target });
34
+ }
35
+ return result;
36
+ }
37
+
38
+ export function projectSummary(project) {
39
+ return { projectId: project.id, name: project.name, label: project.name, roots: project.roots,
40
+ path: project.roots[0]?.path || '', cwd: project.roots[0]?.path || '', hostId: 'local' };
41
+ }
42
+
43
+ export async function browseProjectFolders(rpc, locations, value) {
44
+ const target = absoluteDirectory(value || locations?.home);
45
+ const entries = (await rpc('fs/readDirectory', { path: target })).entries
46
+ .filter(entry => entry.isDirectory).map(entry => ({ name: entry.fileName, path: path.join(target, entry.fileName) }))
47
+ .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { numeric: true }));
48
+ return { path: target, parent: path.dirname(target) === target ? null : path.dirname(target), entries, ...(locations || {}) };
49
+ }
50
+
51
+ export async function createProjectFolder(rpc, body) {
52
+ const parent = await directory(rpc, body.parent);
53
+ const name = folderName(body.name);
54
+ const entries = (await rpc('fs/readDirectory', { path: parent })).entries;
55
+ if (entries.some(entry => pathKey(entry.fileName) === pathKey(name))) fail('同名文件夹已存在,请选择它或使用其他名称。', 409);
56
+ const target = path.join(parent, name);
57
+ await rpc('fs/createDirectory', { path: target, recursive: false });
58
+ return { path: target };
59
59
  }
@@ -1,47 +1,47 @@
1
- import { discoverCodex, resolveCodexRuntime, runtimeFingerprint } from './platform/codex-runtime.mjs';
2
-
3
- // Advisory discovery is independent of execution health. Failure here must never
4
- // disable an already running app-server or cause an automatic process restart.
5
- export class RuntimeDiscovery {
6
- constructor({ stateRoot, host, url, interval = 60000 }) {
7
- Object.assign(this, { stateRoot, host, url, interval });
8
- this.state = {};
9
- }
10
- start() { this.stopped = false; void this.tick(); }
11
- stop() { this.stopped = true; clearTimeout(this.timer); }
12
- async sample() {
13
- const errors = [];
14
- try {
15
- const candidates = discoverCodex();
16
- const signature = JSON.stringify(candidates.map(candidate => {
17
- try { return [candidate.executable, runtimeFingerprint(candidate.executable)]; }
18
- catch (error) { return [candidate.executable, error.message]; }
19
- }));
20
- if (signature !== this.signature) {
21
- await resolveCodexRuntime(this.stateRoot, { refresh: true });
22
- this.signature = signature;
23
- }
24
- } catch (error) { errors.push(error.message); }
25
- try {
26
- if (this.host.desktopIntegration === false) {
27
- this.state = { desktop: { state: 'unsupported', integrationSupported: false }, checkedAt: new Date().toISOString() };
28
- if (errors.length) this.state.discoveryError = errors.join('\n');
29
- return this.state;
30
- }
31
- const desktop = await this.host.inspect();
32
- const attached = desktop.running && await this.host.sharedServiceUrl() === (typeof this.url === 'function' ? this.url() : this.url);
33
- this.state = { desktop: { state: !desktop.running ? 'stopped' : attached ? 'managed' : 'ordinary',
34
- installed: Boolean(desktop.desktop), version: desktop.version || '', attachedToManagedServer: Boolean(attached) },
35
- desktopRestartRecommended: Boolean(desktop.running && !attached), checkedAt: new Date().toISOString() };
36
- } catch (error) {
37
- errors.push(error.message);
38
- this.state = { checkedAt: new Date().toISOString() };
39
- }
40
- if (errors.length) this.state.discoveryError = errors.join('\n');
41
- return this.state;
42
- }
43
- async tick() {
44
- await this.sample();
45
- if (!this.stopped) { this.timer = setTimeout(() => this.tick(), this.interval); this.timer.unref?.(); }
46
- }
47
- }
1
+ import { discoverCodex, resolveCodexRuntime, runtimeFingerprint } from './platform/codex-runtime.mjs';
2
+
3
+ // Advisory discovery is independent of execution health. Failure here must never
4
+ // disable an already running app-server or cause an automatic process restart.
5
+ export class RuntimeDiscovery {
6
+ constructor({ stateRoot, host, url, interval = 60000 }) {
7
+ Object.assign(this, { stateRoot, host, url, interval });
8
+ this.state = {};
9
+ }
10
+ start() { this.stopped = false; void this.tick(); }
11
+ stop() { this.stopped = true; clearTimeout(this.timer); }
12
+ async sample() {
13
+ const errors = [];
14
+ try {
15
+ const candidates = discoverCodex();
16
+ const signature = JSON.stringify(candidates.map(candidate => {
17
+ try { return [candidate.executable, runtimeFingerprint(candidate.executable)]; }
18
+ catch (error) { return [candidate.executable, error.message]; }
19
+ }));
20
+ if (signature !== this.signature) {
21
+ await resolveCodexRuntime(this.stateRoot, { refresh: true });
22
+ this.signature = signature;
23
+ }
24
+ } catch (error) { errors.push(error.message); }
25
+ try {
26
+ if (this.host.desktopIntegration === false) {
27
+ this.state = { desktop: { state: 'unsupported', integrationSupported: false }, checkedAt: new Date().toISOString() };
28
+ if (errors.length) this.state.discoveryError = errors.join('\n');
29
+ return this.state;
30
+ }
31
+ const desktop = await this.host.inspect();
32
+ const attached = desktop.running && await this.host.sharedServiceUrl() === (typeof this.url === 'function' ? this.url() : this.url);
33
+ this.state = { desktop: { state: !desktop.running ? 'stopped' : attached ? 'managed' : 'ordinary',
34
+ installed: Boolean(desktop.desktop), version: desktop.version || '', hasWindow: desktop.hasWindow, attachedToManagedServer: Boolean(attached) },
35
+ desktopRestartRecommended: Boolean(desktop.running && !attached), checkedAt: new Date().toISOString() };
36
+ } catch (error) {
37
+ errors.push(error.message);
38
+ this.state = { checkedAt: new Date().toISOString() };
39
+ }
40
+ if (errors.length) this.state.discoveryError = errors.join('\n');
41
+ return this.state;
42
+ }
43
+ async tick() {
44
+ await this.sample();
45
+ if (!this.stopped) { this.timer = setTimeout(() => this.tick(), this.interval); this.timer.unref?.(); }
46
+ }
47
+ }
@@ -1,30 +1,30 @@
1
- // Process one account per transaction; foreign keys keep incomplete deletion atomic.
2
- export async function purgeCancelledAccounts(pool, scopeHash) {
3
- let purged = 0;
4
- for (;;) {
5
- const db = await pool.getConnection();
6
- try {
7
- await db.beginTransaction();
8
- const [rows] = await db.query(`SELECT id FROM users WHERE status='cancelled'
9
- AND cancelled_at <= UTC_TIMESTAMP(3) - INTERVAL 1 DAY
10
- ORDER BY cancelled_at LIMIT 1 FOR UPDATE`);
11
- if (!rows.length) { await db.commit(); return purged; }
12
- const id = rows[0].id;
13
- const [identities] = await db.execute("SELECT normalized_value FROM user_login_identities WHERE user_id=? AND kind='email'", [id]);
14
- for (const { normalized_value: email } of identities) {
15
- await db.execute('DELETE FROM email_login_challenges WHERE email=?', [email]);
16
- await db.execute('DELETE FROM email_send_limits WHERE scope_hash=?', [scopeHash(`email:${email}`)]);
17
- await db.execute('DELETE FROM relay_admin_emails WHERE email=?', [email]);
18
- }
19
- for (const table of ['user_sessions', 'relay_usage_actions', 'relay_usage_sessions',
20
- 'relay_usage_user_days', 'relay_usage_budgets', 'relay_usage_entitlements',
21
- 'relay_usage_totals', 'user_login_identities']) {
22
- await db.execute(`DELETE FROM ${table} WHERE user_id=?`, [id]);
23
- }
24
- await db.execute('UPDATE computers SET owner_user_id=NULL WHERE owner_user_id=?', [id]);
25
- await db.execute('DELETE FROM users WHERE id=?', [id]);
26
- await db.commit(); purged++;
27
- } catch (error) { await db.rollback(); throw error; }
28
- finally { db.release(); }
29
- }
30
- }
1
+ // Process one account per transaction; foreign keys keep incomplete deletion atomic.
2
+ export async function purgeCancelledAccounts(pool, scopeHash) {
3
+ let purged = 0;
4
+ for (;;) {
5
+ const db = await pool.getConnection();
6
+ try {
7
+ await db.beginTransaction();
8
+ const [rows] = await db.query(`SELECT id FROM users WHERE status='cancelled'
9
+ AND cancelled_at <= UTC_TIMESTAMP(3) - INTERVAL 1 DAY
10
+ ORDER BY cancelled_at LIMIT 1 FOR UPDATE`);
11
+ if (!rows.length) { await db.commit(); return purged; }
12
+ const id = rows[0].id;
13
+ const [identities] = await db.execute("SELECT normalized_value FROM user_login_identities WHERE user_id=? AND kind='email'", [id]);
14
+ for (const { normalized_value: email } of identities) {
15
+ await db.execute('DELETE FROM email_login_challenges WHERE email=?', [email]);
16
+ await db.execute('DELETE FROM email_send_limits WHERE scope_hash=?', [scopeHash(`email:${email}`)]);
17
+ await db.execute('DELETE FROM relay_admin_emails WHERE email=?', [email]);
18
+ }
19
+ for (const table of ['user_sessions', 'relay_usage_actions', 'relay_usage_sessions',
20
+ 'relay_usage_user_days', 'relay_usage_budgets', 'relay_usage_entitlements',
21
+ 'relay_usage_totals', 'user_login_identities']) {
22
+ await db.execute(`DELETE FROM ${table} WHERE user_id=?`, [id]);
23
+ }
24
+ await db.execute('UPDATE computers SET owner_user_id=NULL WHERE owner_user_id=?', [id]);
25
+ await db.execute('DELETE FROM users WHERE id=?', [id]);
26
+ await db.commit(); purged++;
27
+ } catch (error) { await db.rollback(); throw error; }
28
+ finally { db.release(); }
29
+ }
30
+ }