@gmickel/gno 1.23.0 → 1.24.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.
@@ -13,15 +13,27 @@ import {
13
13
  normalizeConfigContentTypes,
14
14
  saveConfig,
15
15
  } from "../config";
16
+ import { withWriteLock } from "./file-lock";
16
17
 
17
18
  export interface ConfigMutationContext {
18
19
  store: SqliteAdapter;
19
20
  configPath?: string;
20
21
  onConfigUpdated: (config: Config) => void;
22
+ /**
23
+ * Optional cross-process serialization boundary. The in-memory mutex remains
24
+ * authoritative within one process; callers sharing a config across
25
+ * processes must additionally share this OS-backed lock path.
26
+ */
27
+ writeLockPath?: string;
28
+ /**
29
+ * Runs after the selected config is durably present and before store projection.
30
+ * Setup recovery uses this boundary to persist a truthful resumable receipt.
31
+ */
32
+ afterConfigSaved?: (config: Config) => Promise<void> | void;
21
33
  }
22
34
 
23
35
  export type MutationResult<T = void> =
24
- | { ok: true; config: Config; value?: T }
36
+ | { ok: true; config: Config; value?: T; skipSave?: boolean }
25
37
  | { ok: false; error: string; code: string };
26
38
 
27
39
  export type ApplyConfigResult<T = void> =
@@ -50,72 +62,90 @@ export async function applyConfigChange<T = void>(
50
62
  try {
51
63
  await previousMutex;
52
64
 
53
- const loadResult = await loadConfig(ctx.configPath);
54
- if (!loadResult.ok) {
55
- return {
56
- ok: false,
57
- error: loadResult.error.message,
58
- code: "LOAD_ERROR",
59
- };
60
- }
61
- for (const warning of formatConfigWarnings(loadResult.warnings)) {
62
- console.warn(warning);
63
- }
64
-
65
- const mutationResult = await mutate(loadResult.value);
66
- if (!mutationResult.ok) {
67
- return {
68
- ok: false,
69
- error: mutationResult.error,
70
- code: mutationResult.code,
71
- };
72
- }
73
-
74
- const normalized = normalizeConfigContentTypes(mutationResult.config);
75
- for (const warning of formatConfigWarnings(normalized.warnings)) {
76
- console.warn(warning);
77
- }
78
- const newConfig = normalized.config;
79
- const saveResult = await saveConfig(newConfig, ctx.configPath);
80
- if (!saveResult.ok) {
81
- return {
82
- ok: false,
83
- error: saveResult.error.message,
84
- code: "SAVE_ERROR",
85
- };
86
- }
87
-
88
- const syncCollResult = await ctx.store.syncCollections(
89
- newConfig.collections
90
- );
91
- if (!syncCollResult.ok) {
92
- console.warn(
93
- `Config saved but DB sync failed: ${syncCollResult.error.message}`
65
+ const applyFreshConfigChange = async (): Promise<ApplyConfigResult<T>> => {
66
+ const loadResult = await loadConfig(ctx.configPath);
67
+ if (!loadResult.ok) {
68
+ return {
69
+ ok: false,
70
+ error: loadResult.error.message,
71
+ code: "LOAD_ERROR",
72
+ };
73
+ }
74
+ for (const warning of formatConfigWarnings(loadResult.warnings)) {
75
+ console.warn(warning);
76
+ }
77
+
78
+ const mutationResult = await mutate(loadResult.value);
79
+ if (!mutationResult.ok) {
80
+ return {
81
+ ok: false,
82
+ error: mutationResult.error,
83
+ code: mutationResult.code,
84
+ };
85
+ }
86
+
87
+ const normalized = normalizeConfigContentTypes(mutationResult.config);
88
+ for (const warning of formatConfigWarnings(normalized.warnings)) {
89
+ console.warn(warning);
90
+ }
91
+ const newConfig = normalized.config;
92
+ if (!mutationResult.skipSave) {
93
+ const saveResult = await saveConfig(newConfig, ctx.configPath);
94
+ if (!saveResult.ok) {
95
+ return {
96
+ ok: false,
97
+ error: saveResult.error.message,
98
+ code: "SAVE_ERROR",
99
+ };
100
+ }
101
+ }
102
+
103
+ await ctx.afterConfigSaved?.(newConfig);
104
+
105
+ const syncCollResult = await ctx.store.syncCollections(
106
+ newConfig.collections
94
107
  );
95
- return {
96
- ok: false,
97
- error: `DB sync failed: ${syncCollResult.error.message}`,
98
- code: "SYNC_ERROR",
99
- };
100
- }
101
-
102
- const syncCtxResult = await ctx.store.syncContexts(
103
- newConfig.contexts ?? []
104
- );
105
- if (!syncCtxResult.ok) {
106
- console.warn(
107
- `Config saved but context sync failed: ${syncCtxResult.error.message}`
108
+ if (!syncCollResult.ok) {
109
+ console.warn(
110
+ `Config saved but DB sync failed: ${syncCollResult.error.message}`
111
+ );
112
+ return {
113
+ ok: false,
114
+ error: `DB sync failed: ${syncCollResult.error.message}`,
115
+ code: "SYNC_ERROR",
116
+ };
117
+ }
118
+
119
+ const syncCtxResult = await ctx.store.syncContexts(
120
+ newConfig.contexts ?? []
108
121
  );
109
- return {
110
- ok: false,
111
- error: `Context sync failed: ${syncCtxResult.error.message}`,
112
- code: "SYNC_ERROR",
113
- };
122
+ if (!syncCtxResult.ok) {
123
+ console.warn(
124
+ `Config saved but context sync failed: ${syncCtxResult.error.message}`
125
+ );
126
+ return {
127
+ ok: false,
128
+ error: `Context sync failed: ${syncCtxResult.error.message}`,
129
+ code: "SYNC_ERROR",
130
+ };
131
+ }
132
+
133
+ ctx.onConfigUpdated(newConfig);
134
+
135
+ return { ok: true, config: newConfig, value: mutationResult.value };
136
+ };
137
+
138
+ if (!ctx.writeLockPath) {
139
+ return await applyFreshConfigChange();
140
+ }
141
+ try {
142
+ return await withWriteLock(ctx.writeLockPath, applyFreshConfigChange);
143
+ } catch (error) {
144
+ if (error instanceof Error && error.message.startsWith("LOCKED:")) {
145
+ return { ok: false, error: error.message, code: "LOCKED" };
146
+ }
147
+ throw error;
114
148
  }
115
-
116
- ctx.onConfigUpdated(newConfig);
117
-
118
- return { ok: true, config: newConfig, value: mutationResult.value };
119
149
  } finally {
120
150
  resolveMutex();
121
151
  }
@@ -4,8 +4,9 @@
4
4
  * @module src/core/file-lock
5
5
  */
6
6
 
7
- // node:fs/promises for mkdir/rm (no Bun equivalent for filesystem structure ops)
8
- import { mkdir, rm } from "node:fs/promises";
7
+ import { Database } from "bun:sqlite";
8
+ // node:fs/promises provides recursive directory creation without a Bun equivalent.
9
+ import { mkdir } from "node:fs/promises";
9
10
  // node:path for dirname (no Bun path utils)
10
11
  import { dirname } from "node:path";
11
12
 
@@ -13,8 +14,8 @@ import { MCP_ERRORS } from "./errors";
13
14
  const DEFAULT_TIMEOUT_MS = 5000;
14
15
  const HOLD_SECONDS = 60 * 60 * 24 * 365;
15
16
  const READY_TOKEN = "READY";
16
- const DIRECTORY_LOCK_SUFFIX = ".dir";
17
- const DIRECTORY_LOCK_POLL_MS = 50;
17
+ const SQLITE_LOCK_SUFFIX = ".sqlite";
18
+ const MAX_BUSY_TIMEOUT_MS = 60_000;
18
19
 
19
20
  export interface WriteLockHandle {
20
21
  release: () => Promise<void>;
@@ -67,10 +68,6 @@ function buildHoldCommand(): string {
67
68
  return `printf '${READY_TOKEN}\\n'; exec sleep ${HOLD_SECONDS}`;
68
69
  }
69
70
 
70
- function delay(ms: number): Promise<void> {
71
- return new Promise((resolve) => setTimeout(resolve, ms));
72
- }
73
-
74
71
  async function waitForReady(
75
72
  proc: ReturnType<typeof Bun.spawn>
76
73
  ): Promise<boolean> {
@@ -96,33 +93,58 @@ async function waitForReady(
96
93
  }
97
94
  }
98
95
 
99
- async function acquireDirectoryLock(
96
+ function sqliteLockPath(lockPath: string): string {
97
+ return `${lockPath}${SQLITE_LOCK_SUFFIX}`;
98
+ }
99
+
100
+ function normalizedBusyTimeout(timeoutMs: number): number {
101
+ if (!Number.isFinite(timeoutMs)) {
102
+ return DEFAULT_TIMEOUT_MS;
103
+ }
104
+ return Math.min(Math.max(0, Math.floor(timeoutMs)), MAX_BUSY_TIMEOUT_MS);
105
+ }
106
+
107
+ function isSqliteLockContention(cause: unknown): boolean {
108
+ if (cause === null || typeof cause !== "object") {
109
+ return false;
110
+ }
111
+ const code = "code" in cause ? cause.code : undefined;
112
+ return code === "SQLITE_BUSY" || code === "SQLITE_LOCKED";
113
+ }
114
+
115
+ export async function acquireSqliteWriteLock(
100
116
  lockPath: string,
101
117
  timeoutMs: number
102
118
  ): Promise<WriteLockHandle | null> {
103
- const directoryLockPath = `${lockPath}${DIRECTORY_LOCK_SUFFIX}`;
104
- await mkdir(dirname(directoryLockPath), { recursive: true });
105
-
106
- const deadline = Date.now() + Math.max(0, timeoutMs);
107
- while (true) {
108
- try {
109
- await mkdir(directoryLockPath);
110
- return {
111
- release: async () => {
112
- await rm(directoryLockPath, { force: true, recursive: true });
113
- },
114
- };
115
- } catch (error) {
116
- const code = (error as { code?: string }).code;
117
- if (code !== "EEXIST") {
118
- throw error;
119
- }
120
- if (Date.now() >= deadline) {
121
- return null;
122
- }
123
- await delay(Math.min(DIRECTORY_LOCK_POLL_MS, deadline - Date.now()));
119
+ const databasePath = sqliteLockPath(lockPath);
120
+ await mkdir(dirname(databasePath), { recursive: true });
121
+
122
+ const database = new Database(databasePath, { create: true });
123
+ try {
124
+ database.exec(`PRAGMA busy_timeout = ${normalizedBusyTimeout(timeoutMs)}`);
125
+ database.exec("BEGIN IMMEDIATE");
126
+ } catch (cause) {
127
+ database.close();
128
+ if (isSqliteLockContention(cause)) {
129
+ return null;
124
130
  }
131
+ throw cause;
125
132
  }
133
+
134
+ let released = false;
135
+ return {
136
+ release: async () => {
137
+ if (released) {
138
+ return;
139
+ }
140
+ released = true;
141
+ try {
142
+ database.exec("ROLLBACK");
143
+ } finally {
144
+ database.close();
145
+ }
146
+ },
147
+ };
126
148
  }
127
149
 
128
150
  export async function acquireWriteLock(
@@ -131,7 +153,7 @@ export async function acquireWriteLock(
131
153
  ): Promise<WriteLockHandle | null> {
132
154
  const cmd = resolveLockCommand();
133
155
  if (!cmd) {
134
- return acquireDirectoryLock(lockPath, timeoutMs);
156
+ return acquireSqliteWriteLock(lockPath, timeoutMs);
135
157
  }
136
158
 
137
159
  await mkdir(dirname(lockPath), { recursive: true });
@@ -161,6 +183,23 @@ export async function acquireWriteLock(
161
183
  };
162
184
  }
163
185
 
186
+ export async function withSqliteWriteLock<T>(
187
+ lockPath: string,
188
+ fn: () => Promise<T>,
189
+ timeoutMs: number = DEFAULT_TIMEOUT_MS
190
+ ): Promise<T> {
191
+ const lock = await acquireSqliteWriteLock(lockPath, timeoutMs);
192
+ if (!lock) {
193
+ throw new Error(`${MCP_ERRORS.LOCKED.code}: ${MCP_ERRORS.LOCKED.message}`);
194
+ }
195
+
196
+ try {
197
+ return await fn();
198
+ } finally {
199
+ await lock.release();
200
+ }
201
+ }
202
+
164
203
  export async function withWriteLock<T>(
165
204
  lockPath: string,
166
205
  fn: () => Promise<T>,