soja-cli 1.4.0
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/LICENSE +18 -0
- package/README.md +117 -0
- package/dist/application/chat.js +1 -0
- package/dist/application/filters.js +27 -0
- package/dist/application/import.js +1 -0
- package/dist/application/ports.js +1 -0
- package/dist/application/repository-root.js +18 -0
- package/dist/application/services/backup-service.js +93 -0
- package/dist/application/services/folder-service.js +116 -0
- package/dist/application/services/git-workflow-service.js +593 -0
- package/dist/application/services/index.js +24 -0
- package/dist/application/services/project-service.js +129 -0
- package/dist/application/services/session-service.js +52 -0
- package/dist/application/services/task-service.js +350 -0
- package/dist/application/services/update-service.js +60 -0
- package/dist/application/services/workspace-service.js +81 -0
- package/dist/application/timeline.js +83 -0
- package/dist/application/types.js +1 -0
- package/dist/application/validation.js +30 -0
- package/dist/bootstrap.js +91 -0
- package/dist/cli/commands/access.js +60 -0
- package/dist/cli/commands/account.js +203 -0
- package/dist/cli/commands/args.js +27 -0
- package/dist/cli/commands/backup.js +70 -0
- package/dist/cli/commands/chat.js +133 -0
- package/dist/cli/commands/dev.js +59 -0
- package/dist/cli/commands/folders.js +59 -0
- package/dist/cli/commands/git-ops.js +200 -0
- package/dist/cli/commands/import.js +48 -0
- package/dist/cli/commands/info.js +82 -0
- package/dist/cli/commands/project.js +108 -0
- package/dist/cli/commands/start.js +34 -0
- package/dist/cli/commands/sync.js +43 -0
- package/dist/cli/commands/task.js +309 -0
- package/dist/cli/commands/tui.js +43 -0
- package/dist/cli/commands/update.js +20 -0
- package/dist/cli/commands/workspace.js +55 -0
- package/dist/cli/index.js +103 -0
- package/dist/cli/output.js +56 -0
- package/dist/cli/prompt.js +28 -0
- package/dist/cli/runtime.js +43 -0
- package/dist/config/config.js +103 -0
- package/dist/config/credentials.js +68 -0
- package/dist/config/paths.js +18 -0
- package/dist/config/server.js +21 -0
- package/dist/config/version.js +7 -0
- package/dist/data/local/activity.js +33 -0
- package/dist/data/local/clock.js +1 -0
- package/dist/data/local/comments.js +27 -0
- package/dist/data/local/index.js +48 -0
- package/dist/data/local/projects.js +53 -0
- package/dist/data/local/tasks.js +104 -0
- package/dist/data/local/users.js +38 -0
- package/dist/data/local/workspaces.js +55 -0
- package/dist/data/remote/api-client.js +103 -0
- package/dist/data/remote/live.js +93 -0
- package/dist/data/repositories.js +1 -0
- package/dist/data/sync/chat.js +242 -0
- package/dist/data/sync/engine.js +459 -0
- package/dist/data/sync/importer.js +156 -0
- package/dist/data/sync/index.js +109 -0
- package/dist/data/sync/services.js +263 -0
- package/dist/data/sync/store.js +200 -0
- package/dist/database/client.js +76 -0
- package/dist/database/migrate.js +27 -0
- package/dist/database/migrations/0000_init.sql +100 -0
- package/dist/database/migrations/0001_git-operations.sql +19 -0
- package/dist/database/migrations/0002_merge-detection.sql +19 -0
- package/dist/database/migrations/0003_sync-replica.sql +40 -0
- package/dist/database/migrations/0004_chat-replica.sql +52 -0
- package/dist/database/migrations/0005_pr-events.sql +18 -0
- package/dist/database/migrations/0006_archive.sql +20 -0
- package/dist/database/migrations/meta/0000_snapshot.json +710 -0
- package/dist/database/migrations/meta/0001_snapshot.json +717 -0
- package/dist/database/migrations/meta/0002_snapshot.json +724 -0
- package/dist/database/migrations/meta/0003_snapshot.json +972 -0
- package/dist/database/migrations/meta/0004_snapshot.json +1168 -0
- package/dist/database/migrations/meta/0005_snapshot.json +1168 -0
- package/dist/database/migrations/meta/0006_snapshot.json +1183 -0
- package/dist/database/migrations/meta/_journal.json +55 -0
- package/dist/database/schema.js +170 -0
- package/dist/dev/seed.js +84 -0
- package/dist/domain/activity.js +22 -0
- package/dist/domain/chat.js +21 -0
- package/dist/domain/entities.js +1 -0
- package/dist/domain/errors.js +23 -0
- package/dist/domain/naming.js +33 -0
- package/dist/domain/task.js +140 -0
- package/dist/domain/workflow.js +23 -0
- package/dist/git/cli-git.js +363 -0
- package/dist/git/console.js +37 -0
- package/dist/git/diagnose.js +145 -0
- package/dist/git/merge-evidence.js +23 -0
- package/dist/git/releases.js +95 -0
- package/dist/git/types.js +16 -0
- package/dist/ui/App.js +71 -0
- package/dist/ui/Shell.js +211 -0
- package/dist/ui/app-state.js +213 -0
- package/dist/ui/branding/CompactLogo.js +8 -0
- package/dist/ui/branding/Logo.js +22 -0
- package/dist/ui/branding/Splash.js +21 -0
- package/dist/ui/branding/brand.js +23 -0
- package/dist/ui/chrome/Sidebar.js +50 -0
- package/dist/ui/chrome/StatusBar.js +26 -0
- package/dist/ui/chrome/Toast.js +11 -0
- package/dist/ui/chrome/TopBar.js +10 -0
- package/dist/ui/chrome/context.js +33 -0
- package/dist/ui/components/ConsoleLines.js +16 -0
- package/dist/ui/components/EmptyState.js +8 -0
- package/dist/ui/components/Labels.js +25 -0
- package/dist/ui/components/PullRequestLabel.js +27 -0
- package/dist/ui/components/TaskTable.js +41 -0
- package/dist/ui/components/TextField.js +11 -0
- package/dist/ui/components/TextInput.js +29 -0
- package/dist/ui/components/task-columns.js +27 -0
- package/dist/ui/copy.js +23 -0
- package/dist/ui/hooks/use-commands.js +58 -0
- package/dist/ui/hooks/use-flows.js +254 -0
- package/dist/ui/hooks/use-layout.js +36 -0
- package/dist/ui/hooks/use-list.js +28 -0
- package/dist/ui/hooks/use-measure.js +22 -0
- package/dist/ui/hooks/use-query.js +29 -0
- package/dist/ui/hooks/use-task-actions.js +450 -0
- package/dist/ui/hooks/use-terminal-size.js +19 -0
- package/dist/ui/input/KeyProvider.js +101 -0
- package/dist/ui/input/dispatcher.js +26 -0
- package/dist/ui/input/list-navigation.js +38 -0
- package/dist/ui/input/mouse.js +78 -0
- package/dist/ui/input/text-editing.js +62 -0
- package/dist/ui/kit/Badge.js +8 -0
- package/dist/ui/kit/Button.js +13 -0
- package/dist/ui/kit/Clickable.js +14 -0
- package/dist/ui/kit/Gauge.js +9 -0
- package/dist/ui/kit/Keycap.js +7 -0
- package/dist/ui/kit/Panel.js +30 -0
- package/dist/ui/kit/ScrollBar.js +14 -0
- package/dist/ui/kit/Spinner.js +9 -0
- package/dist/ui/kit/Tabs.js +12 -0
- package/dist/ui/mascot/AgentScene.js +30 -0
- package/dist/ui/mascot/Mascot.js +26 -0
- package/dist/ui/mascot/ansi.js +25 -0
- package/dist/ui/mascot/engine.js +146 -0
- package/dist/ui/mascot/index.js +6 -0
- package/dist/ui/mascot/palette.js +18 -0
- package/dist/ui/mascot/scenes/bugHunt.js +115 -0
- package/dist/ui/mascot/scenes/celebrate.js +76 -0
- package/dist/ui/mascot/scenes/cleaning.js +156 -0
- package/dist/ui/mascot/scenes/clockIn.js +105 -0
- package/dist/ui/mascot/scenes/deadline.js +125 -0
- package/dist/ui/mascot/scenes/empty.js +149 -0
- package/dist/ui/mascot/scenes/fire.js +138 -0
- package/dist/ui/mascot/scenes/index.js +25 -0
- package/dist/ui/mascot/scenes/planning.js +73 -0
- package/dist/ui/mascot/scenes/resting.js +90 -0
- package/dist/ui/mascot/scenes/syncing.js +116 -0
- package/dist/ui/mascot/scenes/working.js +193 -0
- package/dist/ui/mascot/selection.js +32 -0
- package/dist/ui/mascot/states.js +25 -0
- package/dist/ui/navigation/routes.js +17 -0
- package/dist/ui/overlays/CommitOverlay.js +93 -0
- package/dist/ui/overlays/ConfirmOverlay.js +42 -0
- package/dist/ui/overlays/GitLogOverlay.js +22 -0
- package/dist/ui/overlays/GitRunOverlay.js +94 -0
- package/dist/ui/overlays/NewTaskOverlay.js +117 -0
- package/dist/ui/overlays/OverlayFrame.js +35 -0
- package/dist/ui/overlays/PickerOverlay.js +72 -0
- package/dist/ui/overlays/PromptOverlay.js +51 -0
- package/dist/ui/overlays/SearchOverlay.js +49 -0
- package/dist/ui/overlays/options.js +29 -0
- package/dist/ui/overlays/types.js +1 -0
- package/dist/ui/screens/AccessScreen.js +91 -0
- package/dist/ui/screens/ChatScreen.js +450 -0
- package/dist/ui/screens/HelpScreen.js +99 -0
- package/dist/ui/screens/ProjectsScreen.js +66 -0
- package/dist/ui/screens/ScreenFrame.js +14 -0
- package/dist/ui/screens/SetupScreen.js +79 -0
- package/dist/ui/screens/TaskListScreen.js +152 -0
- package/dist/ui/screens/TaskScreen.js +193 -0
- package/dist/ui/screens/WelcomeScreen.js +254 -0
- package/dist/ui/screens/WorkspacesScreen.js +64 -0
- package/dist/ui/theme/detect.js +59 -0
- package/dist/ui/theme/theme.js +135 -0
- package/dist/utils/editor.js +37 -0
- package/dist/utils/errors.js +23 -0
- package/dist/utils/text.js +60 -0
- package/dist/utils/time.js +25 -0
- package/docs/NPM_PUBLISH.md +35 -0
- package/docs/PRIVACY.md +38 -0
- package/package.json +74 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import { GitWorkflowService } from '../../application/services/git-workflow-service.js';
|
|
3
|
+
import { FolderService } from '../../application/services/folder-service.js';
|
|
4
|
+
import { ProjectService } from '../../application/services/project-service.js';
|
|
5
|
+
import { TaskService } from '../../application/services/task-service.js';
|
|
6
|
+
import { createLocalRepositories } from '../local/index.js';
|
|
7
|
+
import { LiveConnection } from '../remote/live.js';
|
|
8
|
+
import { ReplicaChatService } from './chat.js';
|
|
9
|
+
import { LocalImporter } from './importer.js';
|
|
10
|
+
import { SyncEngine } from './engine.js';
|
|
11
|
+
import { ReplicaProjectService, ReplicaSessionService, ReplicaTaskService, ReplicaWorkspaceService } from './services.js';
|
|
12
|
+
import { ReplicaStore } from './store.js';
|
|
13
|
+
/** Replica file per server: `~/.local/share/soja/remote/<host>.db`. */
|
|
14
|
+
export function replicaFile(dataDir, apiUrl) {
|
|
15
|
+
const host = new URL(apiUrl).host.replace(/[^a-z0-9.-]/gi, '_');
|
|
16
|
+
return `${dataDir}/remote/${basename(host)}.db`;
|
|
17
|
+
}
|
|
18
|
+
export function createReplicaServices(api, handle, config, git, gitConsole, WebSocketImpl, localDatabaseFile, backups) {
|
|
19
|
+
const repos = createLocalRepositories(handle);
|
|
20
|
+
const store = new ReplicaStore(handle.db);
|
|
21
|
+
const engine = new SyncEngine(api, store);
|
|
22
|
+
let timer = null;
|
|
23
|
+
let workspaceForTimer = null;
|
|
24
|
+
const scheduleSync = (workspaceId, delayMs = 800) => {
|
|
25
|
+
workspaceForTimer = workspaceId;
|
|
26
|
+
if (timer)
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
timer = setTimeout(() => {
|
|
29
|
+
timer = null;
|
|
30
|
+
if (workspaceForTimer)
|
|
31
|
+
void engine.sync(workspaceForTimer);
|
|
32
|
+
}, delayMs);
|
|
33
|
+
timer.unref(); // never keep a finished CLI command alive
|
|
34
|
+
};
|
|
35
|
+
const context = {
|
|
36
|
+
api,
|
|
37
|
+
store,
|
|
38
|
+
engine,
|
|
39
|
+
repos,
|
|
40
|
+
config,
|
|
41
|
+
onWrite: () => {
|
|
42
|
+
const workspaceId = config.load()?.remote?.workspaceId;
|
|
43
|
+
if (workspaceId)
|
|
44
|
+
scheduleSync(workspaceId);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
const projects = new ReplicaProjectService(context, new ProjectService(repos, git));
|
|
48
|
+
const tasks = new ReplicaTaskService(context, new TaskService(repos));
|
|
49
|
+
// The server paced us (message rate limit): try the rest when its window has passed.
|
|
50
|
+
engine.subscribe((report) => {
|
|
51
|
+
const workspaceId = config.load()?.remote?.workspaceId;
|
|
52
|
+
if (report?.deferred && workspaceId)
|
|
53
|
+
scheduleSync(workspaceId, 10_000);
|
|
54
|
+
});
|
|
55
|
+
const connections = new Set();
|
|
56
|
+
const startLive = (workspaceId) => {
|
|
57
|
+
if (!api.token)
|
|
58
|
+
return () => undefined;
|
|
59
|
+
const live = new LiveConnection({
|
|
60
|
+
apiUrl: api.baseUrl,
|
|
61
|
+
token: api.token,
|
|
62
|
+
workspaceId,
|
|
63
|
+
...(WebSocketImpl ? { WebSocket: WebSocketImpl } : {}),
|
|
64
|
+
onReady: () => void engine.sync(workspaceId),
|
|
65
|
+
onEvent: (event) => {
|
|
66
|
+
void engine.receive(workspaceId, event).then((needsSync) => {
|
|
67
|
+
if (needsSync)
|
|
68
|
+
scheduleSync(workspaceId, 150);
|
|
69
|
+
}, () => scheduleSync(workspaceId, 150));
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
connections.add(live);
|
|
73
|
+
live.start();
|
|
74
|
+
return () => {
|
|
75
|
+
connections.delete(live);
|
|
76
|
+
live.stop();
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
session: new ReplicaSessionService(context),
|
|
81
|
+
workspaces: new ReplicaWorkspaceService(context),
|
|
82
|
+
projects,
|
|
83
|
+
tasks,
|
|
84
|
+
chat: new ReplicaChatService(context, tasks),
|
|
85
|
+
...(localDatabaseFile ? { importer: new LocalImporter(localDatabaseFile, config, api, store, engine) } : {}),
|
|
86
|
+
git: new GitWorkflowService(tasks, projects, git),
|
|
87
|
+
folders: new FolderService(config),
|
|
88
|
+
gitConsole,
|
|
89
|
+
environment: { mode: 'remote', server: api.baseUrl },
|
|
90
|
+
backups,
|
|
91
|
+
sync: {
|
|
92
|
+
status: (workspaceId) => engine.status(workspaceId),
|
|
93
|
+
syncNow: (workspaceId) => engine.sync(workspaceId),
|
|
94
|
+
scheduleSync,
|
|
95
|
+
subscribe: (listener) => engine.subscribe(listener),
|
|
96
|
+
notices: (workspaceId, taskId) => store.notices(workspaceId, taskId),
|
|
97
|
+
dismissNotice: (id) => store.dismissNotice(id),
|
|
98
|
+
startLive,
|
|
99
|
+
stop: () => {
|
|
100
|
+
if (timer)
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
timer = null;
|
|
103
|
+
for (const live of connections)
|
|
104
|
+
live.stop();
|
|
105
|
+
connections.clear();
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { NotFoundError, SojaError, ValidationError } from '../../domain/errors.js';
|
|
3
|
+
import { isClosed, formatTaskRef } from '../../domain/task.js';
|
|
4
|
+
import { OfflineError } from '../remote/api-client.js';
|
|
5
|
+
function settings(context) {
|
|
6
|
+
const remote = context.config.load()?.remote;
|
|
7
|
+
if (!remote)
|
|
8
|
+
throw new SojaError('No SOJA server configured.', { hint: 'Run `soja login --server <url>`.' });
|
|
9
|
+
return remote;
|
|
10
|
+
}
|
|
11
|
+
function saveSettings(context, patch) {
|
|
12
|
+
const current = context.config.load();
|
|
13
|
+
if (!current?.remote)
|
|
14
|
+
throw new SojaError('No SOJA server configured.', { hint: 'Run `soja login --server <url>`.' });
|
|
15
|
+
context.config.save({ ...current, remote: { ...current.remote, ...patch } });
|
|
16
|
+
}
|
|
17
|
+
/** Operations that change shared structure (projects, workspaces, members) need the server. */
|
|
18
|
+
async function online(what, work) {
|
|
19
|
+
try {
|
|
20
|
+
return await work();
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error instanceof OfflineError) {
|
|
24
|
+
throw new SojaError(`${what} needs a connection to the SOJA server.`, {
|
|
25
|
+
hint: 'Tasks and comments work offline; projects, workspaces and members do not.',
|
|
26
|
+
cause: error,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export class ReplicaSessionService {
|
|
33
|
+
context;
|
|
34
|
+
constructor(context) {
|
|
35
|
+
this.context = context;
|
|
36
|
+
}
|
|
37
|
+
async user() {
|
|
38
|
+
const { userId } = settings(this.context);
|
|
39
|
+
const local = userId ? await this.context.repos.users.findById(userId) : null;
|
|
40
|
+
return local ?? (await this.context.engine.refreshAccount()).user;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The signed-in user and active workspace from the replica, so SOJA opens
|
|
44
|
+
* offline. The first run downloads them from the server.
|
|
45
|
+
*/
|
|
46
|
+
async current() {
|
|
47
|
+
const remote = settings(this.context);
|
|
48
|
+
let user = remote.userId ? await this.context.repos.users.findById(remote.userId) : null;
|
|
49
|
+
let workspaces = user ? await this.context.repos.workspaces.listForUser(user.id) : [];
|
|
50
|
+
if (!user || workspaces.length === 0) {
|
|
51
|
+
const account = await this.context.engine.refreshAccount().catch((error) => {
|
|
52
|
+
if (error instanceof OfflineError) {
|
|
53
|
+
throw new SojaError('SOJA needs to connect once to download your workspace.', {
|
|
54
|
+
hint: 'Check your connection, or use `soja mode local`.',
|
|
55
|
+
cause: error,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
throw error;
|
|
59
|
+
});
|
|
60
|
+
user = account.user;
|
|
61
|
+
workspaces = account.workspaces;
|
|
62
|
+
saveSettings(this.context, { userId: user.id });
|
|
63
|
+
}
|
|
64
|
+
const workspace = workspaces.find((candidate) => candidate.id === remote.workspaceId) ?? workspaces[0];
|
|
65
|
+
if (!workspace) {
|
|
66
|
+
throw new SojaError(`You have no workspaces on ${this.context.api.baseUrl} yet.`, {
|
|
67
|
+
hint: 'Create one with `soja workspace create "<name>"`, or ask an owner to add you.',
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (workspace.id !== remote.workspaceId)
|
|
71
|
+
saveSettings(this.context, { workspaceId: workspace.id });
|
|
72
|
+
return { user, workspace };
|
|
73
|
+
}
|
|
74
|
+
async setup() {
|
|
75
|
+
throw new SojaError('Remote mode has no local setup.', { hint: 'Sign in with `soja login --server <url>`.' });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export class ReplicaWorkspaceService {
|
|
79
|
+
context;
|
|
80
|
+
constructor(context) {
|
|
81
|
+
this.context = context;
|
|
82
|
+
}
|
|
83
|
+
list(user) {
|
|
84
|
+
return this.context.repos.workspaces.listForUser(user.id);
|
|
85
|
+
}
|
|
86
|
+
async create(_owner, input) {
|
|
87
|
+
const workspace = await online('Creating a workspace', () => this.context.api.post('/v1/workspaces', input));
|
|
88
|
+
await this.context.engine.refreshAccount();
|
|
89
|
+
return workspace;
|
|
90
|
+
}
|
|
91
|
+
async switchTo(user, idOrSlug) {
|
|
92
|
+
let workspace = (await this.list(user)).find((candidate) => candidate.id === idOrSlug || candidate.slug === idOrSlug);
|
|
93
|
+
if (!workspace) {
|
|
94
|
+
const account = await online('Finding that workspace', () => this.context.engine.refreshAccount());
|
|
95
|
+
workspace = account.workspaces.find((candidate) => candidate.id === idOrSlug || candidate.slug === idOrSlug);
|
|
96
|
+
}
|
|
97
|
+
if (!workspace)
|
|
98
|
+
throw new NotFoundError(`You are not a member of a workspace called “${idOrSlug}”.`);
|
|
99
|
+
saveSettings(this.context, { workspaceId: workspace.id });
|
|
100
|
+
this.context.onWrite();
|
|
101
|
+
return { user, workspace };
|
|
102
|
+
}
|
|
103
|
+
async members(session) {
|
|
104
|
+
const members = await this.context.repos.workspaces.listMembers(session.workspace.id);
|
|
105
|
+
return members.map(({ id, username, displayName, role }) => ({ id, username, displayName, role }));
|
|
106
|
+
}
|
|
107
|
+
async findMember(session, username) {
|
|
108
|
+
const normalized = username.trim().replace(/^@/, '').toLowerCase();
|
|
109
|
+
const member = (await this.members(session)).find((candidate) => candidate.username === normalized);
|
|
110
|
+
if (!member)
|
|
111
|
+
throw new NotFoundError(`@${normalized} is not a member of ${session.workspace.name}.`);
|
|
112
|
+
return member;
|
|
113
|
+
}
|
|
114
|
+
async addMember(session, input) {
|
|
115
|
+
const member = await online('Adding a developer', () => this.context.api.post(`/v1/workspaces/${session.workspace.id}/members`, { username: input.username }));
|
|
116
|
+
await this.context.store.upsertMember(session.workspace.id, member);
|
|
117
|
+
return member;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export class ReplicaProjectService {
|
|
121
|
+
context;
|
|
122
|
+
local;
|
|
123
|
+
constructor(context, local) {
|
|
124
|
+
this.context = context;
|
|
125
|
+
this.local = local;
|
|
126
|
+
}
|
|
127
|
+
list(session) {
|
|
128
|
+
return this.local.list(session);
|
|
129
|
+
}
|
|
130
|
+
get(session, id) {
|
|
131
|
+
return this.local.get(session, id);
|
|
132
|
+
}
|
|
133
|
+
resolve(session, keyOrName) {
|
|
134
|
+
return this.local.resolve(session, keyOrName);
|
|
135
|
+
}
|
|
136
|
+
findByRepository(session, path) {
|
|
137
|
+
return this.local.findByRepository(session, path);
|
|
138
|
+
}
|
|
139
|
+
async create(session, input) {
|
|
140
|
+
const { repositoryPath, ...shared } = input;
|
|
141
|
+
const project = await online('Creating a project', () => this.context.api.post(`/v1/workspaces/${session.workspace.id}/projects`, shared));
|
|
142
|
+
await this.context.store.upsertProject(project);
|
|
143
|
+
const stored = await this.local.get(session, project.id);
|
|
144
|
+
return repositoryPath ? this.linkRepository(session, stored, repositoryPath) : stored;
|
|
145
|
+
}
|
|
146
|
+
/** The folder is stored in this machine's replica only; the origin URL is shared if the project had none. */
|
|
147
|
+
async linkRepository(session, project, path) {
|
|
148
|
+
const linked = await this.local.linkRepository(session, project, path);
|
|
149
|
+
if (!project.repositoryUrl && linked.repositoryUrl) {
|
|
150
|
+
await this.context.api
|
|
151
|
+
.patch(`/v1/workspaces/${session.workspace.id}/projects/${project.id}`, { repositoryUrl: linked.repositoryUrl })
|
|
152
|
+
.catch(() => undefined); // best effort: the URL is a convenience for teammates
|
|
153
|
+
}
|
|
154
|
+
return linked;
|
|
155
|
+
}
|
|
156
|
+
unlinkRepository(session, project) {
|
|
157
|
+
return this.local.unlinkRepository(session, project);
|
|
158
|
+
}
|
|
159
|
+
/** Shared with the team, so it needs the server (like creating one). */
|
|
160
|
+
async update(session, project, changes) {
|
|
161
|
+
const payload = Object.fromEntries(Object.entries(changes).filter(([, value]) => value !== undefined));
|
|
162
|
+
if (Object.keys(payload).length === 0)
|
|
163
|
+
return project;
|
|
164
|
+
const updated = await online('Editing a project', () => this.context.api.patch(`/v1/workspaces/${session.workspace.id}/projects/${project.id}`, payload));
|
|
165
|
+
await this.context.store.upsertProject(updated);
|
|
166
|
+
return this.local.get(session, project.id);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const CHANGE_FIELDS = ['title', 'description', 'projectId', 'type', 'priority', 'status', 'assigneeId', 'requester', 'branch', 'baseBranch', 'branchStart'];
|
|
170
|
+
/**
|
|
171
|
+
* Tasks in remote mode: read and written in the local replica (instant, works
|
|
172
|
+
* offline), with every write queued as an operation for the server.
|
|
173
|
+
*/
|
|
174
|
+
export class ReplicaTaskService {
|
|
175
|
+
context;
|
|
176
|
+
local;
|
|
177
|
+
constructor(context, local) {
|
|
178
|
+
this.context = context;
|
|
179
|
+
this.local = local;
|
|
180
|
+
}
|
|
181
|
+
list(session, filter, options = {}) {
|
|
182
|
+
return this.local.list(session, filter, options);
|
|
183
|
+
}
|
|
184
|
+
search(session, text, limit) {
|
|
185
|
+
return this.local.search(session, text, limit);
|
|
186
|
+
}
|
|
187
|
+
get(session, target) {
|
|
188
|
+
return this.local.get(session, target);
|
|
189
|
+
}
|
|
190
|
+
knownRequesters(session) {
|
|
191
|
+
return this.local.knownRequesters(session);
|
|
192
|
+
}
|
|
193
|
+
async create(session, input) {
|
|
194
|
+
const id = randomUUID();
|
|
195
|
+
const number = await this.context.store.nextProvisionalNumber(session.workspace.id);
|
|
196
|
+
const task = await this.local.create(session, input, { id, number });
|
|
197
|
+
await this.enqueue(session, 'task.create', id, { ...input }, null);
|
|
198
|
+
return task;
|
|
199
|
+
}
|
|
200
|
+
async update(session, target, changes) {
|
|
201
|
+
const before = await this.find(session, target);
|
|
202
|
+
const payload = Object.fromEntries(Object.entries(changes).filter(([, value]) => value !== undefined));
|
|
203
|
+
const base = Object.fromEntries(CHANGE_FIELDS.filter((field) => field in payload).map((field) => [field, before[field]]));
|
|
204
|
+
return this.write(session, before, 'task.change', payload, base, () => this.local.update(session, before, changes));
|
|
205
|
+
}
|
|
206
|
+
start(session, target, extra = {}) {
|
|
207
|
+
return this.update(session, target, { assigneeId: session.user.id, status: 'in_progress', ...extra });
|
|
208
|
+
}
|
|
209
|
+
complete(session, target) {
|
|
210
|
+
return this.update(session, target, { status: 'done' });
|
|
211
|
+
}
|
|
212
|
+
archive(session, target) {
|
|
213
|
+
return this.update(session, target, { archived: true });
|
|
214
|
+
}
|
|
215
|
+
restore(session, target) {
|
|
216
|
+
return this.update(session, target, { archived: false });
|
|
217
|
+
}
|
|
218
|
+
/** Deletes here right away (owners only, checked on the replica and again on the server) and queues it. */
|
|
219
|
+
async remove(session, target) {
|
|
220
|
+
const removed = await this.local.remove(session, target);
|
|
221
|
+
await this.context.store.enqueue({ opId: randomUUID(), workspaceId: session.workspace.id, type: 'task.delete', taskId: removed.id, payload: { number: removed.number }, base: null, occurredAt: new Date() }, []);
|
|
222
|
+
this.context.onWrite();
|
|
223
|
+
return removed;
|
|
224
|
+
}
|
|
225
|
+
async reopen(session, target) {
|
|
226
|
+
const task = await this.find(session, target);
|
|
227
|
+
if (!isClosed(task.status))
|
|
228
|
+
throw new ValidationError(`${formatTaskRef(task.number)} is already open.`);
|
|
229
|
+
return this.update(session, task, { status: 'todo' });
|
|
230
|
+
}
|
|
231
|
+
async comment(session, target, body) {
|
|
232
|
+
const task = await this.find(session, target);
|
|
233
|
+
const commentId = randomUUID();
|
|
234
|
+
await this.write(session, task, 'task.comment', { commentId, body }, null, async () => {
|
|
235
|
+
await this.local.comment(session, task, body, { id: commentId });
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
async recordGitEvent(session, target, event, fields = {}) {
|
|
239
|
+
const task = await this.find(session, target);
|
|
240
|
+
return this.write(session, task, 'task.git_event', { event, fields }, null, () => this.local.recordGitEvent(session, task, event, fields));
|
|
241
|
+
}
|
|
242
|
+
/** Applies locally, remembers which activity rows were optimistic, then queues the operation. */
|
|
243
|
+
async write(session, task, type, payload, base, apply) {
|
|
244
|
+
const before = await this.context.store.activityIds(task.id);
|
|
245
|
+
const result = await apply();
|
|
246
|
+
const after = await this.context.store.activityIds(task.id);
|
|
247
|
+
const optimistic = [...after].filter((id) => !before.has(id));
|
|
248
|
+
// Nothing changed locally (e.g. same value again): nothing to tell the server.
|
|
249
|
+
if (type === 'task.change' && optimistic.length === 0)
|
|
250
|
+
return result;
|
|
251
|
+
await this.context.store.enqueue({ opId: randomUUID(), workspaceId: session.workspace.id, type, taskId: task.id, payload, base, occurredAt: new Date() }, optimistic);
|
|
252
|
+
this.context.onWrite();
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
async enqueue(session, type, taskId, payload, base) {
|
|
256
|
+
const optimistic = await this.context.store.activityIds(taskId);
|
|
257
|
+
await this.context.store.enqueue({ opId: randomUUID(), workspaceId: session.workspace.id, type, taskId, payload, base, occurredAt: new Date() }, optimistic);
|
|
258
|
+
this.context.onWrite();
|
|
259
|
+
}
|
|
260
|
+
async find(session, target) {
|
|
261
|
+
return this.local.get(session, target);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { and, asc, eq, inArray, like } from 'drizzle-orm';
|
|
2
|
+
import { chatChannels, chatMessages, chatReads, projects, syncNotices, syncOutbox, syncPendingActivity, syncState, taskActivity, taskComments, tasks, users, workspaceMembers, workspaces, } from '../../database/schema.js';
|
|
3
|
+
export const isChatOp = (type) => !type.startsWith('task.');
|
|
4
|
+
/** Task fields as stored; server views carry extra presentation fields we drop. */
|
|
5
|
+
const TASK_COLUMNS = [
|
|
6
|
+
'id', 'number', 'workspaceId', 'projectId', 'title', 'description', 'type', 'priority', 'status', 'assigneeId',
|
|
7
|
+
'creatorId', 'requester', 'branch', 'baseBranch', 'branchStart', 'createdAt', 'updatedAt', 'startedAt', 'completedAt', 'archivedAt',
|
|
8
|
+
];
|
|
9
|
+
const pickTask = (task) => Object.fromEntries(TASK_COLUMNS.map((column) => [column, task[column]]));
|
|
10
|
+
/**
|
|
11
|
+
* The remote-mode replica: server state mirrored into the same SQLite schema
|
|
12
|
+
* local mode uses, plus the outbox of operations not yet acknowledged.
|
|
13
|
+
*/
|
|
14
|
+
export class ReplicaStore {
|
|
15
|
+
db;
|
|
16
|
+
constructor(db) {
|
|
17
|
+
this.db = db;
|
|
18
|
+
}
|
|
19
|
+
// ── Server state ─────────────────────────────────────────────────────────
|
|
20
|
+
async upsertUser(user) {
|
|
21
|
+
const now = new Date();
|
|
22
|
+
const row = { id: user.id, username: user.username, displayName: user.displayName, email: user.email ?? null, createdAt: user.createdAt ?? now, updatedAt: user.updatedAt ?? now };
|
|
23
|
+
await this.db.insert(users).values(row).onConflictDoUpdate({ target: users.id, set: { username: row.username, displayName: row.displayName } });
|
|
24
|
+
}
|
|
25
|
+
async upsertWorkspace(workspace) {
|
|
26
|
+
const row = { id: workspace.id, name: workspace.name, slug: workspace.slug, description: workspace.description, createdAt: workspace.createdAt, updatedAt: workspace.updatedAt };
|
|
27
|
+
await this.db.insert(workspaces).values(row).onConflictDoUpdate({ target: workspaces.id, set: { name: row.name, slug: row.slug, description: row.description, updatedAt: row.updatedAt } });
|
|
28
|
+
}
|
|
29
|
+
async upsertMember(workspaceId, member) {
|
|
30
|
+
await this.upsertUser(member);
|
|
31
|
+
await this.db
|
|
32
|
+
.insert(workspaceMembers)
|
|
33
|
+
.values({ workspaceId, userId: member.id, role: member.role })
|
|
34
|
+
.onConflictDoUpdate({ target: [workspaceMembers.workspaceId, workspaceMembers.userId], set: { role: member.role } });
|
|
35
|
+
}
|
|
36
|
+
/** Server projects have no local path; the one this machine linked is kept. */
|
|
37
|
+
async upsertProject(project) {
|
|
38
|
+
const shared = {
|
|
39
|
+
workspaceId: project.workspaceId, name: project.name, key: project.key, description: project.description,
|
|
40
|
+
repositoryUrl: project.repositoryUrl, createdAt: project.createdAt, updatedAt: project.updatedAt,
|
|
41
|
+
};
|
|
42
|
+
await this.db.insert(projects).values({ id: project.id, ...shared, repositoryPath: null }).onConflictDoUpdate({ target: projects.id, set: shared });
|
|
43
|
+
}
|
|
44
|
+
/** This machine's folder for a project (never sent to the server). */
|
|
45
|
+
async setRepositoryPath(projectId, path) {
|
|
46
|
+
await this.db.update(projects).set({ repositoryPath: path }).where(eq(projects.id, projectId));
|
|
47
|
+
}
|
|
48
|
+
async upsertTask(task) {
|
|
49
|
+
const row = pickTask(task);
|
|
50
|
+
// The server's number may belong to a provisional row we replaced; numbers are unique per workspace.
|
|
51
|
+
const clash = await this.db.select({ id: tasks.id }).from(tasks).where(and(eq(tasks.workspaceId, row.workspaceId), eq(tasks.number, row.number))).get();
|
|
52
|
+
if (clash && clash.id !== row.id)
|
|
53
|
+
await this.db.delete(tasks).where(eq(tasks.id, clash.id));
|
|
54
|
+
const { id: _id, ...rest } = row;
|
|
55
|
+
await this.db.insert(tasks).values(row).onConflictDoUpdate({ target: tasks.id, set: rest });
|
|
56
|
+
}
|
|
57
|
+
async upsertComment(comment) {
|
|
58
|
+
const { id: _id, ...rest } = comment;
|
|
59
|
+
await this.db.insert(taskComments).values(comment).onConflictDoUpdate({ target: taskComments.id, set: rest });
|
|
60
|
+
}
|
|
61
|
+
async upsertActivity(activity) {
|
|
62
|
+
await this.db
|
|
63
|
+
.insert(taskActivity)
|
|
64
|
+
.values({ id: activity.id, taskId: activity.taskId, userId: activity.userId, type: activity.type, metadata: activity.metadata, createdAt: activity.createdAt })
|
|
65
|
+
.onConflictDoNothing();
|
|
66
|
+
}
|
|
67
|
+
async patchTask(id, fields) {
|
|
68
|
+
if (Object.keys(fields).length === 0)
|
|
69
|
+
return;
|
|
70
|
+
await this.db.update(tasks).set(fields).where(eq(tasks.id, id));
|
|
71
|
+
}
|
|
72
|
+
async deleteTask(id) {
|
|
73
|
+
await this.db.delete(taskActivity).where(eq(taskActivity.taskId, id));
|
|
74
|
+
await this.db.delete(taskComments).where(eq(taskComments.taskId, id));
|
|
75
|
+
await this.db.delete(tasks).where(eq(tasks.id, id));
|
|
76
|
+
}
|
|
77
|
+
async findTask(id) {
|
|
78
|
+
return (await this.db.select().from(tasks).where(eq(tasks.id, id)).get()) ?? null;
|
|
79
|
+
}
|
|
80
|
+
async activityIds(taskId) {
|
|
81
|
+
const rows = await this.db.select({ id: taskActivity.id }).from(taskActivity).where(eq(taskActivity.taskId, taskId));
|
|
82
|
+
return new Set(rows.map((row) => row.id));
|
|
83
|
+
}
|
|
84
|
+
/** Next provisional number: -1, -2, … (shown as SOJA-?1, SOJA-?2). */
|
|
85
|
+
async nextProvisionalNumber(workspaceId) {
|
|
86
|
+
const rows = await this.db.select({ number: tasks.number }).from(tasks).where(eq(tasks.workspaceId, workspaceId));
|
|
87
|
+
return Math.min(0, ...rows.map((row) => row.number)) - 1;
|
|
88
|
+
}
|
|
89
|
+
// ── Outbox ───────────────────────────────────────────────────────────────
|
|
90
|
+
async enqueue(op, optimisticActivity) {
|
|
91
|
+
await this.db.insert(syncOutbox).values({ ...op, attempts: 0, lastError: null });
|
|
92
|
+
for (const activityId of optimisticActivity) {
|
|
93
|
+
await this.db.insert(syncPendingActivity).values({ activityId, opId: op.opId }).onConflictDoNothing();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async pending(workspaceId) {
|
|
97
|
+
const rows = await this.db.select().from(syncOutbox).where(eq(syncOutbox.workspaceId, workspaceId)).orderBy(asc(syncOutbox.seq));
|
|
98
|
+
return rows.map((row) => ({ ...row, type: row.type }));
|
|
99
|
+
}
|
|
100
|
+
/** Drops acknowledged operations and the optimistic activity they wrote (the server's arrives by pull). */
|
|
101
|
+
async acknowledge(opIds) {
|
|
102
|
+
if (opIds.length === 0)
|
|
103
|
+
return;
|
|
104
|
+
const optimistic = await this.db.select().from(syncPendingActivity).where(inArray(syncPendingActivity.opId, [...opIds]));
|
|
105
|
+
if (optimistic.length) {
|
|
106
|
+
await this.db.delete(taskActivity).where(inArray(taskActivity.id, optimistic.map((row) => row.activityId)));
|
|
107
|
+
await this.db.delete(syncPendingActivity).where(inArray(syncPendingActivity.opId, [...opIds]));
|
|
108
|
+
}
|
|
109
|
+
await this.db.delete(syncOutbox).where(inArray(syncOutbox.opId, [...opIds]));
|
|
110
|
+
}
|
|
111
|
+
async markAttempt(workspaceId, error) {
|
|
112
|
+
const rows = await this.pending(workspaceId);
|
|
113
|
+
for (const row of rows) {
|
|
114
|
+
await this.db.update(syncOutbox).set({ attempts: row.attempts + 1, lastError: error }).where(eq(syncOutbox.opId, row.opId));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// ── Cursor and notices ───────────────────────────────────────────────────
|
|
118
|
+
async state(workspaceId) {
|
|
119
|
+
const row = await this.db.select().from(syncState).where(eq(syncState.workspaceId, workspaceId)).get();
|
|
120
|
+
return { cursor: row?.cursor ?? 0, lastSyncAt: row?.lastSyncAt ?? null, lastError: row?.lastError ?? null };
|
|
121
|
+
}
|
|
122
|
+
async saveState(workspaceId, patch) {
|
|
123
|
+
const current = await this.state(workspaceId);
|
|
124
|
+
const row = { workspaceId, cursor: patch.cursor ?? current.cursor, lastSyncAt: patch.lastSyncAt ?? current.lastSyncAt, lastError: patch.lastError === undefined ? current.lastError : patch.lastError };
|
|
125
|
+
await this.db.insert(syncState).values(row).onConflictDoUpdate({ target: syncState.workspaceId, set: row });
|
|
126
|
+
}
|
|
127
|
+
async addNotice(workspaceId, notice) {
|
|
128
|
+
await this.db.insert(syncNotices).values({ ...notice, workspaceId, createdAt: new Date() }).onConflictDoNothing();
|
|
129
|
+
}
|
|
130
|
+
async notices(workspaceId, taskId) {
|
|
131
|
+
const rows = await this.db.select().from(syncNotices).where(eq(syncNotices.workspaceId, workspaceId)).orderBy(asc(syncNotices.createdAt));
|
|
132
|
+
return rows
|
|
133
|
+
.filter((row) => row.dismissedAt === null && (!taskId || row.taskId === taskId))
|
|
134
|
+
.map(({ workspaceId: _ws, dismissedAt: _dismissed, ...notice }) => notice);
|
|
135
|
+
}
|
|
136
|
+
async dismissNotice(id) {
|
|
137
|
+
await this.db.update(syncNotices).set({ dismissedAt: new Date() }).where(eq(syncNotices.id, id));
|
|
138
|
+
}
|
|
139
|
+
// ── Chat ─────────────────────────────────────────────────────────────────
|
|
140
|
+
async upsertChannel(channel) {
|
|
141
|
+
const row = pickChannel(channel);
|
|
142
|
+
const { id: _id, ...rest } = row;
|
|
143
|
+
await this.db.insert(chatChannels).values(row).onConflictDoUpdate({ target: chatChannels.id, set: rest });
|
|
144
|
+
}
|
|
145
|
+
async channels(workspaceId) {
|
|
146
|
+
return this.db.select().from(chatChannels).where(eq(chatChannels.workspaceId, workspaceId)).orderBy(asc(chatChannels.name));
|
|
147
|
+
}
|
|
148
|
+
async findChannel(id) {
|
|
149
|
+
return (await this.db.select().from(chatChannels).where(eq(chatChannels.id, id)).get()) ?? null;
|
|
150
|
+
}
|
|
151
|
+
async upsertMessage(message) {
|
|
152
|
+
const row = pickMessage(message);
|
|
153
|
+
const { id: _id, ...rest } = row;
|
|
154
|
+
await this.db.insert(chatMessages).values(row).onConflictDoUpdate({ target: chatMessages.id, set: rest });
|
|
155
|
+
}
|
|
156
|
+
async patchMessage(id, fields) {
|
|
157
|
+
await this.db.update(chatMessages).set(fields).where(eq(chatMessages.id, id));
|
|
158
|
+
}
|
|
159
|
+
async deleteMessage(id) {
|
|
160
|
+
await this.db.delete(chatMessages).where(eq(chatMessages.id, id));
|
|
161
|
+
}
|
|
162
|
+
async findMessage(id) {
|
|
163
|
+
return (await this.db.select().from(chatMessages).where(eq(chatMessages.id, id)).get()) ?? null;
|
|
164
|
+
}
|
|
165
|
+
async messagesIn(channelId) {
|
|
166
|
+
return this.db.select().from(chatMessages).where(eq(chatMessages.channelId, channelId));
|
|
167
|
+
}
|
|
168
|
+
/** Messages whose text contains `fragment` (a cheap prefilter; callers check the exact match). */
|
|
169
|
+
async messagesContaining(workspaceId, fragment) {
|
|
170
|
+
return this.db
|
|
171
|
+
.select()
|
|
172
|
+
.from(chatMessages)
|
|
173
|
+
.where(and(eq(chatMessages.workspaceId, workspaceId), like(chatMessages.body, `%${fragment.replace(/[%_]/g, '')}%`)));
|
|
174
|
+
}
|
|
175
|
+
async reads() {
|
|
176
|
+
const rows = await this.db.select().from(chatReads);
|
|
177
|
+
return new Map(rows.map((row) => [row.channelId, row.lastReadSeq]));
|
|
178
|
+
}
|
|
179
|
+
/** Read marks only move forward, as on the server. */
|
|
180
|
+
async markRead(channelId, lastReadSeq) {
|
|
181
|
+
const current = (await this.reads()).get(channelId) ?? 0;
|
|
182
|
+
if (lastReadSeq <= current)
|
|
183
|
+
return;
|
|
184
|
+
await this.db.insert(chatReads).values({ channelId, lastReadSeq }).onConflictDoUpdate({ target: chatReads.channelId, set: { lastReadSeq } });
|
|
185
|
+
}
|
|
186
|
+
/** Drops a queued operation that was never sent (e.g. an older read mark superseded by a newer one). */
|
|
187
|
+
async dropQueued(opId) {
|
|
188
|
+
await this.db.delete(syncOutbox).where(eq(syncOutbox.opId, opId));
|
|
189
|
+
}
|
|
190
|
+
async updateQueuedPayload(opId, payload) {
|
|
191
|
+
await this.db.update(syncOutbox).set({ payload }).where(eq(syncOutbox.opId, opId));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const CHANNEL_COLUMNS = ['id', 'workspaceId', 'name', 'topic', 'createdBy', 'createdAt', 'updatedAt', 'archivedAt'];
|
|
195
|
+
const MESSAGE_COLUMNS = [
|
|
196
|
+
'id', 'seq', 'workspaceId', 'channelId', 'authorId', 'body', 'replyToId', 'createdAt', 'editedAt', 'deletedAt',
|
|
197
|
+
];
|
|
198
|
+
/** Server views may carry extra fields (e.g. `lastReadSeq` on channels). */
|
|
199
|
+
const pickChannel = (channel) => Object.fromEntries(CHANNEL_COLUMNS.map((column) => [column, channel[column] ?? null]));
|
|
200
|
+
const pickMessage = (message) => Object.fromEntries(MESSAGE_COLUMNS.map((column) => [column, message[column] ?? null]));
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { drizzle } from 'drizzle-orm/sqlite-proxy';
|
|
5
|
+
import { StorageError } from '../domain/errors.js';
|
|
6
|
+
import * as schema from './schema.js';
|
|
7
|
+
/**
|
|
8
|
+
* Opens SQLite through Node's built-in `node:sqlite` and exposes it to
|
|
9
|
+
* Drizzle via the async proxy driver. Being async on the Drizzle side keeps
|
|
10
|
+
* repository signatures identical to a future HTTP-backed implementation.
|
|
11
|
+
*/
|
|
12
|
+
export function openDatabase(file, options = {}) {
|
|
13
|
+
let sqlite;
|
|
14
|
+
try {
|
|
15
|
+
if (file !== ':memory:')
|
|
16
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
17
|
+
sqlite = new DatabaseSync(file);
|
|
18
|
+
// The remote-mode replica mirrors the server, which enforces integrity; rows may arrive in any order.
|
|
19
|
+
sqlite.exec(`PRAGMA foreign_keys = ${options.foreignKeys === false ? 'OFF' : 'ON'}; PRAGMA busy_timeout = 5000;`);
|
|
20
|
+
if (file !== ':memory:')
|
|
21
|
+
sqlite.exec('PRAGMA journal_mode = WAL;');
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
throw new StorageError(`Could not open the database at ${file}.`, {
|
|
25
|
+
hint: 'Check that the directory exists and is writable.',
|
|
26
|
+
cause: error,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const statements = new Map();
|
|
30
|
+
const prepare = (sql) => {
|
|
31
|
+
let statement = statements.get(sql);
|
|
32
|
+
if (!statement) {
|
|
33
|
+
statement = sqlite.prepare(sql);
|
|
34
|
+
statements.set(sql, statement);
|
|
35
|
+
}
|
|
36
|
+
return statement;
|
|
37
|
+
};
|
|
38
|
+
const db = drizzle(async (sql, params, method) => {
|
|
39
|
+
try {
|
|
40
|
+
const statement = prepare(sql);
|
|
41
|
+
if (method === 'run') {
|
|
42
|
+
statement.run(...params);
|
|
43
|
+
return { rows: [] };
|
|
44
|
+
}
|
|
45
|
+
// Drizzle's proxy protocol expects positional rows (arrays), not objects.
|
|
46
|
+
statement.setReturnArrays(true);
|
|
47
|
+
if (method === 'get') {
|
|
48
|
+
// For `get`, `rows` is the single row itself, and "no row" must be
|
|
49
|
+
// `undefined` (an empty array would map to an all-undefined object).
|
|
50
|
+
// Drizzle's callback type does not model that, hence the casts.
|
|
51
|
+
const row = statement.get(...params);
|
|
52
|
+
return { rows: row };
|
|
53
|
+
}
|
|
54
|
+
return { rows: statement.all(...params) };
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
throw new StorageError('The database rejected the operation.', { cause: error });
|
|
58
|
+
}
|
|
59
|
+
}, { schema });
|
|
60
|
+
return {
|
|
61
|
+
db,
|
|
62
|
+
exec: (sql) => sqlite.exec(sql),
|
|
63
|
+
backupTo: (file) => {
|
|
64
|
+
try {
|
|
65
|
+
sqlite.prepare('VACUUM INTO ?').run(file);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
throw new StorageError(`Could not write the backup to ${file}.`, { hint: 'Check that the folder is writable and has space.', cause: error });
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
close: () => {
|
|
72
|
+
if (sqlite.isOpen)
|
|
73
|
+
sqlite.close();
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { migrate } from 'drizzle-orm/sqlite-proxy/migrator';
|
|
3
|
+
import { StorageError } from '../domain/errors.js';
|
|
4
|
+
// Shipped next to this module in both src/ (tsx) and dist/ (copied at build).
|
|
5
|
+
const MIGRATIONS_FOLDER = fileURLToPath(new URL('./migrations', import.meta.url));
|
|
6
|
+
/** Applies pending migrations in a single transaction. Cheap when there is nothing to do. */
|
|
7
|
+
export async function runMigrations(handle) {
|
|
8
|
+
try {
|
|
9
|
+
await migrate(handle.db, async (queries) => {
|
|
10
|
+
if (queries.length === 0)
|
|
11
|
+
return;
|
|
12
|
+
handle.exec('BEGIN');
|
|
13
|
+
try {
|
|
14
|
+
for (const query of queries)
|
|
15
|
+
handle.exec(query);
|
|
16
|
+
handle.exec('COMMIT');
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
handle.exec('ROLLBACK');
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}, { migrationsFolder: MIGRATIONS_FOLDER });
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
throw new StorageError('Could not upgrade the database schema.', { cause: error });
|
|
26
|
+
}
|
|
27
|
+
}
|