@retaw106/mmc-oc 2.2.0 → 2.3.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/dist/cli.js +2 -2
- package/dist/config/deprecated-config.d.ts +4 -0
- package/dist/config/deprecated-config.d.ts.map +1 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/project-security.d.ts +2 -2
- package/dist/config/schema/magic-context.d.ts +3 -3
- package/dist/config/schema/magic-context.d.ts.map +1 -1
- package/dist/config-init.js +2 -2
- package/dist/{context-mrvm92n8.js → context-wt7g4f8k.js} +1641 -1543
- package/dist/{derive-budgets-rjjz5f7y.js → derive-budgets-s1p37t2g.js} +2 -2
- package/dist/features/magic-context/compaction-marker.d.ts.map +1 -1
- package/dist/features/magic-context/historian-failure-reason.d.ts +7 -0
- package/dist/features/magic-context/historian-failure-reason.d.ts.map +1 -0
- package/dist/features/magic-context/message-index-async.d.ts +21 -3
- package/dist/features/magic-context/message-index-async.d.ts.map +1 -1
- package/dist/features/magic-context/message-index.d.ts +7 -1
- package/dist/features/magic-context/message-index.d.ts.map +1 -1
- package/dist/features/magic-context/migrations.d.ts.map +1 -1
- package/dist/features/magic-context/pi-content-decisions.d.ts +7 -1
- package/dist/features/magic-context/pi-content-decisions.d.ts.map +1 -1
- package/dist/features/magic-context/search.d.ts +29 -0
- package/dist/features/magic-context/search.d.ts.map +1 -1
- package/dist/features/magic-context/storage-clone.d.ts +2 -0
- package/dist/features/magic-context/storage-clone.d.ts.map +1 -1
- package/dist/features/magic-context/storage-compartment-history-version.d.ts +4 -0
- package/dist/features/magic-context/storage-compartment-history-version.d.ts.map +1 -0
- package/dist/features/magic-context/storage-db.d.ts +1 -1
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/storage-historian-runs.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-persisted.d.ts +23 -5
- package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-session.d.ts +7 -1
- package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
- package/dist/features/magic-context/storage-session-tables.d.ts +5 -1
- package/dist/features/magic-context/storage-session-tables.d.ts.map +1 -1
- package/dist/features/magic-context/storage-tag-identity-revision.d.ts +50 -0
- package/dist/features/magic-context/storage-tag-identity-revision.d.ts.map +1 -0
- package/dist/features/magic-context/storage-tags.d.ts +155 -5
- package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
- package/dist/features/magic-context/tagger.d.ts +2 -0
- package/dist/features/magic-context/tagger.d.ts.map +1 -1
- package/dist/features/magic-context/transform-decision-log.d.ts +3 -0
- package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
- package/dist/hooks/magic-context/active-anthropic-turn.d.ts +2 -0
- package/dist/hooks/magic-context/active-anthropic-turn.d.ts.map +1 -1
- package/dist/hooks/magic-context/apply-operations.d.ts +7 -0
- package/dist/hooks/magic-context/apply-operations.d.ts.map +1 -1
- package/dist/hooks/magic-context/auto-search-runner.d.ts +2 -2
- package/dist/hooks/magic-context/auto-search-runner.d.ts.map +1 -1
- package/dist/hooks/magic-context/heuristic-cleanup.d.ts.map +1 -1
- package/dist/hooks/magic-context/history-boundary-repair.d.ts +11 -2
- package/dist/hooks/magic-context/history-boundary-repair.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
- package/dist/hooks/magic-context/lkg-measured-request.d.ts +3 -1
- package/dist/hooks/magic-context/lkg-measured-request.d.ts.map +1 -1
- package/dist/hooks/magic-context/lkg-persist.d.ts +10 -13
- package/dist/hooks/magic-context/lkg-persist.d.ts.map +1 -1
- package/dist/hooks/magic-context/lkg-recovery-fence.d.ts +62 -0
- package/dist/hooks/magic-context/lkg-recovery-fence.d.ts.map +1 -0
- package/dist/hooks/magic-context/lkg-recovery-prefix.d.ts +38 -0
- package/dist/hooks/magic-context/lkg-recovery-prefix.d.ts.map +1 -0
- package/dist/hooks/magic-context/lkg-replay.d.ts +16 -9
- package/dist/hooks/magic-context/lkg-replay.d.ts.map +1 -1
- package/dist/hooks/magic-context/lkg-slot.d.ts +141 -2
- package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -1
- package/dist/hooks/magic-context/opaque-thinking.d.ts +12 -0
- package/dist/hooks/magic-context/opaque-thinking.d.ts.map +1 -0
- package/dist/hooks/magic-context/pass-outcome.d.ts +1 -0
- package/dist/hooks/magic-context/pass-outcome.d.ts.map +1 -1
- package/dist/hooks/magic-context/pending-materialization.d.ts +8 -0
- package/dist/hooks/magic-context/pending-materialization.d.ts.map +1 -0
- package/dist/hooks/magic-context/postprocess-read-cache.d.ts.map +1 -1
- package/dist/hooks/magic-context/provider-visible-parts.d.ts +45 -0
- package/dist/hooks/magic-context/provider-visible-parts.d.ts.map +1 -0
- package/dist/hooks/magic-context/read-session-chunk.d.ts +22 -1
- package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-db.d.ts +3 -0
- package/dist/hooks/magic-context/read-session-db.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-formatting.d.ts +5 -0
- package/dist/hooks/magic-context/read-session-formatting.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-raw.d.ts +123 -1
- package/dist/hooks/magic-context/read-session-raw.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-true-raw-tokens.d.ts.map +1 -1
- package/dist/hooks/magic-context/reasoning-budget.d.ts.map +1 -1
- package/dist/hooks/magic-context/sentinel.d.ts +41 -1
- package/dist/hooks/magic-context/sentinel.d.ts.map +1 -1
- package/dist/hooks/magic-context/strict-thinking-prefix.d.ts +8 -0
- package/dist/hooks/magic-context/strict-thinking-prefix.d.ts.map +1 -0
- package/dist/hooks/magic-context/strip-content.d.ts +38 -24
- package/dist/hooks/magic-context/strip-content.d.ts.map +1 -1
- package/dist/hooks/magic-context/system-date-refresh.d.ts +29 -0
- package/dist/hooks/magic-context/system-date-refresh.d.ts.map +1 -0
- package/dist/hooks/magic-context/system-prompt-dates.d.ts +6 -0
- package/dist/hooks/magic-context/system-prompt-dates.d.ts.map +1 -0
- package/dist/hooks/magic-context/system-prompt-delivery.d.ts +47 -0
- package/dist/hooks/magic-context/system-prompt-delivery.d.ts.map +1 -0
- package/dist/hooks/magic-context/system-prompt-hash.d.ts +18 -5
- package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
- package/dist/hooks/magic-context/tag-messages.d.ts +19 -0
- package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
- package/dist/hooks/magic-context/tag-owner-warmup.d.ts +14 -0
- package/dist/hooks/magic-context/tag-owner-warmup.d.ts.map +1 -0
- package/dist/hooks/magic-context/token-count-exact.d.ts +35 -0
- package/dist/hooks/magic-context/token-count-exact.d.ts.map +1 -0
- package/dist/hooks/magic-context/tool-drop-target.d.ts +2 -0
- package/dist/hooks/magic-context/tool-drop-target.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +7 -0
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts +21 -4
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/index-18yrysyj.js +1477 -0
- package/dist/{index-g2z2k5m4.js → index-3gj1ak4c.js} +2 -2
- package/dist/{index-5cep2r9n.js → index-5ty9rnbx.js} +60 -258
- package/dist/{index-amycb7b1.js → index-a5mw0xp3.js} +3 -3
- package/dist/{index-1j34a56j.js → index-czba8tw3.js} +266 -32
- package/dist/{index-qbkzdkk0.js → index-k59af64t.js} +6 -2
- package/dist/{index-53g38ggt.js → index-kmbme1v1.js} +1 -1
- package/dist/{index-5b7x80ec.js → index-kw21z5pe.js} +6055 -3973
- package/dist/{index-33gprt9g.js → index-yjv8dpht.js} +1 -1
- package/dist/{index-btp5tdc8.js → index-z2w1eq3y.js} +840 -456
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +114 -2
- package/dist/{message-time-backfill-phrwp2fz.js → message-time-backfill-sykevdax.js} +3 -3
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/{read-session-chunk-2xm4sc4y.js → read-session-chunk-863d6c06.js} +6 -4
- package/dist/recomp-orchestrator-wrjy3qym.js +21 -0
- package/dist/{safe-notification-target-ph3xd1ve.js → safe-notification-target-1jkf0m82.js} +2 -2
- package/dist/{send-session-notification-rnc4nrsr.js → send-session-notification-cz6ev088.js} +5 -5
- package/dist/shared/auto-search-lifecycle.d.ts +14 -0
- package/dist/shared/auto-search-lifecycle.d.ts.map +1 -1
- package/dist/shared/logger.d.ts.map +1 -1
- package/dist/shared/models-dev-cache.d.ts.map +1 -1
- package/dist/shared/models-dev-temperature.d.ts +18 -0
- package/dist/shared/models-dev-temperature.d.ts.map +1 -0
- package/dist/shared/processed-image-eligibility.d.ts +22 -0
- package/dist/shared/processed-image-eligibility.d.ts.map +1 -0
- package/dist/shared/project-directory-key.d.ts +16 -0
- package/dist/shared/project-directory-key.d.ts.map +1 -1
- package/dist/shared/recovery-reasoning-prefix.d.ts +9 -0
- package/dist/shared/recovery-reasoning-prefix.d.ts.map +1 -0
- package/dist/shared/sqlite.d.ts +33 -1
- package/dist/shared/sqlite.d.ts.map +1 -1
- package/dist/shared/stable-json.d.ts.map +1 -1
- package/dist/shared/storage-file-mutex.d.ts +9 -0
- package/dist/shared/storage-file-mutex.d.ts.map +1 -0
- package/dist/shared/storage-permissions.d.ts +6 -0
- package/dist/shared/storage-permissions.d.ts.map +1 -1
- package/dist/shared/subagent-temperature.d.ts +46 -0
- package/dist/shared/subagent-temperature.d.ts.map +1 -0
- package/dist/shared/tag-owner-id.d.ts +1 -0
- package/dist/shared/tag-owner-id.d.ts.map +1 -1
- package/dist/shared/tag-transcript.d.ts +9 -0
- package/dist/shared/tag-transcript.d.ts.map +1 -1
- package/dist/shared/window-geometry.d.ts +9 -66
- package/dist/shared/window-geometry.d.ts.map +1 -1
- package/dist/standalone-plugin-marker.d.ts +8 -0
- package/dist/standalone-plugin-marker.d.ts.map +1 -0
- package/dist/tools/ctx-expand/search-archive.d.ts +4 -0
- package/dist/tools/ctx-expand/search-archive.d.ts.map +1 -0
- package/dist/tools/ctx-expand/tools.d.ts.map +1 -1
- package/dist/tools/ctx-reduce/constants.d.ts +1 -2
- package/dist/tools/ctx-reduce/constants.d.ts.map +1 -1
- package/dist/tools/ctx-reduce/tools.d.ts +1 -1
- package/dist/tools/ctx-reduce/tools.d.ts.map +1 -1
- package/dist/v2/hooks/context.d.ts +1 -1
- package/dist/v2/hooks/context.d.ts.map +1 -1
- package/dist/v2/hooks/copied-tag-notation.d.ts +19 -0
- package/dist/v2/hooks/copied-tag-notation.d.ts.map +1 -0
- package/dist/v2/hooks/generate.d.ts +9 -2
- package/dist/v2/hooks/generate.d.ts.map +1 -1
- package/dist/v2/hooks/payload.d.ts.map +1 -1
- package/dist/v2/hooks/system-prompt-response.d.ts +5 -0
- package/dist/v2/hooks/system-prompt-response.d.ts.map +1 -0
- package/dist/v2/server.d.ts.map +1 -1
- package/dist/v2/server.js +34550 -30855
- package/dist/web/main.js +373 -19
- package/dist/{wrapup-orchestrator-yknks3ad.js → wrapup-orchestrator-bmybc5p5.js} +10 -10
- package/package.json +1 -1
- package/src/config/deprecated-config.ts +18 -0
- package/src/config/index.test.ts +13 -37
- package/src/config/index.ts +2 -2
- package/src/config/project-security.ts +3 -3
- package/src/config/schema/magic-context.ts +10 -6
- package/src/shared/auto-search-lifecycle.ts +43 -0
- package/src/shared/logger.ts +10 -2
- package/src/shared/models-dev-cache.ts +1 -7
- package/src/shared/models-dev-temperature.ts +107 -0
- package/src/shared/processed-image-eligibility.test.ts +68 -0
- package/src/shared/processed-image-eligibility.ts +41 -0
- package/src/shared/processed-image-position-review.json +20 -0
- package/src/shared/processed-image-position-review.test.ts +19 -0
- package/src/shared/project-directory-key.ts +20 -0
- package/src/shared/recovery-reasoning-prefix.test.ts +34 -0
- package/src/shared/recovery-reasoning-prefix.ts +35 -0
- package/src/shared/sqlite-acquisition-routing.test.ts +4 -3
- package/src/shared/sqlite-foreground-wait.test.ts +68 -0
- package/src/shared/sqlite-pass-scope.test.ts +4 -4
- package/src/shared/sqlite-writer-diagnostics.test.ts +52 -12
- package/src/shared/sqlite.ts +380 -39
- package/src/shared/stable-json.ts +37 -7
- package/src/shared/storage-file-mutex.test.ts +50 -0
- package/src/shared/storage-file-mutex.ts +130 -0
- package/src/shared/storage-permissions.ts +46 -0
- package/src/shared/subagent-temperature.ts +110 -0
- package/src/shared/tag-owner-id.ts +13 -0
- package/src/shared/tag-transcript.ts +118 -0
- package/src/shared/window-geometry.test.ts +102 -236
- package/src/shared/window-geometry.ts +23 -413
- package/dist/index-ramz5kkm.js +0 -514
- package/dist/recomp-orchestrator-frmwj9hz.js +0 -21
- package/src/shared/__fixtures__/window-overlay.v1.json +0 -337
package/src/shared/sqlite.ts
CHANGED
|
@@ -71,14 +71,21 @@ export function registerSlowWriteReporter(reporter: SlowWriteReporter): void {
|
|
|
71
71
|
*/
|
|
72
72
|
type SqliteDiagnosticSink = (message: string) => void;
|
|
73
73
|
let sqliteDiagnosticSink: SqliteDiagnosticSink | undefined;
|
|
74
|
+
let sqliteDiagnosticHost: () => string = () => "unknown";
|
|
74
75
|
|
|
75
|
-
export function registerSqliteDiagnosticSink(
|
|
76
|
+
export function registerSqliteDiagnosticSink(
|
|
77
|
+
sink: SqliteDiagnosticSink,
|
|
78
|
+
host?: () => string,
|
|
79
|
+
): void {
|
|
76
80
|
sqliteDiagnosticSink = sink;
|
|
81
|
+
if (host) sqliteDiagnosticHost = host;
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
function reportSqliteDiagnostic(message: string): void {
|
|
80
85
|
try {
|
|
81
|
-
sqliteDiagnosticSink?.(
|
|
86
|
+
sqliteDiagnosticSink?.(
|
|
87
|
+
`[magic-context] ${message} pid=${process.pid} host=${sqliteDiagnosticHost()}`,
|
|
88
|
+
);
|
|
82
89
|
} catch {
|
|
83
90
|
// Diagnostics must never change the outcome of the write they describe.
|
|
84
91
|
}
|
|
@@ -169,13 +176,51 @@ export class SqliteRuntimeUnavailableError extends Error {
|
|
|
169
176
|
}
|
|
170
177
|
}
|
|
171
178
|
|
|
179
|
+
// Node requests this warning during node:sqlite's module evaluation, although
|
|
180
|
+
// warning delivery happens later. Intercept only the request while importing;
|
|
181
|
+
// replacing warning listeners would not suppress the default stderr output.
|
|
182
|
+
function isNodeSqliteExperimentalWarning(args: unknown[]): boolean {
|
|
183
|
+
const [warning, options] = args;
|
|
184
|
+
const message = warning instanceof Error ? warning.message : warning;
|
|
185
|
+
const type =
|
|
186
|
+
typeof options === "string"
|
|
187
|
+
? options
|
|
188
|
+
: options && typeof options === "object"
|
|
189
|
+
? (options as { type?: unknown }).type
|
|
190
|
+
: undefined;
|
|
191
|
+
return (
|
|
192
|
+
type === "ExperimentalWarning" &&
|
|
193
|
+
typeof message === "string" &&
|
|
194
|
+
message.startsWith("SQLite is an experimental feature")
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function importSqliteModuleForRuntime(
|
|
199
|
+
runtime: SqliteRuntime,
|
|
200
|
+
specifier: string,
|
|
201
|
+
importer: (specifier: string) => Promise<SqliteModule>,
|
|
202
|
+
): Promise<SqliteModule> {
|
|
203
|
+
if (runtime !== "Node.js") return importer(specifier);
|
|
204
|
+
|
|
205
|
+
const originalEmitWarning = process.emitWarning;
|
|
206
|
+
process.emitWarning = function (this: unknown, ...args: unknown[]): void {
|
|
207
|
+
if (isNodeSqliteExperimentalWarning(args)) return;
|
|
208
|
+
Reflect.apply(originalEmitWarning, this, args);
|
|
209
|
+
} as typeof process.emitWarning;
|
|
210
|
+
try {
|
|
211
|
+
return await importer(specifier);
|
|
212
|
+
} finally {
|
|
213
|
+
process.emitWarning = originalEmitWarning;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
172
217
|
export async function loadSqliteModule(
|
|
173
218
|
runtime: SqliteRuntime = detectSqliteRuntime(),
|
|
174
219
|
importer: (specifier: string) => Promise<SqliteModule> = importSqliteModule,
|
|
175
220
|
): Promise<SqliteModule> {
|
|
176
221
|
const specifier = runtime === "Bun" ? bunSpec : nodeSpec;
|
|
177
222
|
try {
|
|
178
|
-
return await
|
|
223
|
+
return await importSqliteModuleForRuntime(runtime, specifier, importer);
|
|
179
224
|
} catch (error) {
|
|
180
225
|
if (isModuleNotFoundError(error, specifier)) {
|
|
181
226
|
throw new SqliteRuntimeUnavailableError(runtime, specifier, error);
|
|
@@ -234,6 +279,110 @@ export interface SqliteMemoryStats {
|
|
|
234
279
|
const trackedSqliteConnections = new Map<number, TrackedSqliteConnection>();
|
|
235
280
|
let nextSqliteConnectionSequence = 1;
|
|
236
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Columns of Magic Context's `tags` table (context.db) that never decide which
|
|
284
|
+
* message a tag belongs to, its number, its type, or its reasoning token estimate.
|
|
285
|
+
*/
|
|
286
|
+
const TAG_COLUMNS_OUTSIDE_IDENTITY = new Set([
|
|
287
|
+
"status",
|
|
288
|
+
"drop_mode",
|
|
289
|
+
"byte_size",
|
|
290
|
+
"input_byte_size",
|
|
291
|
+
"reasoning_byte_size",
|
|
292
|
+
"token_count",
|
|
293
|
+
"input_token_count",
|
|
294
|
+
"caveman_depth",
|
|
295
|
+
"entry_fingerprint",
|
|
296
|
+
"tool_name",
|
|
297
|
+
"harness",
|
|
298
|
+
]);
|
|
299
|
+
|
|
300
|
+
let tagIdentityWriteGeneration = 0;
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Whether a write statement may change an existing `tags` row's identity
|
|
304
|
+
* (session, number, type, owner message, reasoning token count) or remove a
|
|
305
|
+
* row. Plain appends without an explicit `id`, and updates that assign only
|
|
306
|
+
* columns in {@link TAG_COLUMNS_OUTSIDE_IDENTITY}, cannot; everything else that
|
|
307
|
+
* names the table is treated as if it could, including DDL and multi-statement
|
|
308
|
+
* scripts. Erring towards true only costs a cache rebuild.
|
|
309
|
+
*/
|
|
310
|
+
export function mayChangeTagIdentity(sql: string): boolean {
|
|
311
|
+
if (!/\btags\b/i.test(sql) || /^\s*(?:SELECT|EXPLAIN|PRAGMA)\b/i.test(sql)) return false;
|
|
312
|
+
if (/^\s*WITH\b/i.test(sql) && !/\b(?:INSERT|UPDATE|DELETE|REPLACE)\b/i.test(sql)) return false;
|
|
313
|
+
// One statement only: a script can hide a DELETE behind a harmless first statement.
|
|
314
|
+
if (/;\s*\S/.test(sql) || /\bREPLACE\b/i.test(sql) || /\bON\s+CONFLICT\b/i.test(sql))
|
|
315
|
+
return true;
|
|
316
|
+
const insert = /^\s*INSERT\s+INTO\s+(?:main\.)?["`]?tags["`]?\s*\(([^)]*)\)/i.exec(sql);
|
|
317
|
+
if (insert) {
|
|
318
|
+
const columns = (insert[1] ?? "").split(",").map((column) =>
|
|
319
|
+
column
|
|
320
|
+
.trim()
|
|
321
|
+
.replace(/["`[\]]/g, "")
|
|
322
|
+
.toLowerCase(),
|
|
323
|
+
);
|
|
324
|
+
return columns.includes("id") || columns.includes("rowid");
|
|
325
|
+
}
|
|
326
|
+
const update = /^\s*UPDATE\s+(?:main\.)?["`]?tags["`]?\s+SET\s+([\s\S]*?)\bWHERE\b/i.exec(sql);
|
|
327
|
+
if (update && !/\bSELECT\b/i.test(sql)) {
|
|
328
|
+
// Provably identity-free only when every top-level assignment is a plain
|
|
329
|
+
// `column = ...` naming an allowed column. A tuple assignment such as
|
|
330
|
+
// `(tag_number, reasoning_token_count) = (...)`, or anything this cannot
|
|
331
|
+
// read, counts as an identity write.
|
|
332
|
+
const assignments = topLevelCommaSegments(update[1] ?? "");
|
|
333
|
+
if (!assignments) return true;
|
|
334
|
+
return !assignments.every((assignment) => {
|
|
335
|
+
const column = /^\s*["`]?(\w+)["`]?\s*=(?!=)/.exec(assignment)?.[1];
|
|
336
|
+
return column !== undefined && TAG_COLUMNS_OUTSIDE_IDENTITY.has(column.toLowerCase());
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Split SQL text at commas outside parentheses and quotes. Returns null when
|
|
344
|
+
* the parentheses or quotes do not balance.
|
|
345
|
+
*/
|
|
346
|
+
function topLevelCommaSegments(text: string): string[] | null {
|
|
347
|
+
const segments: string[] = [];
|
|
348
|
+
let depth = 0;
|
|
349
|
+
let quote: string | null = null;
|
|
350
|
+
let start = 0;
|
|
351
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
352
|
+
const char = text[index];
|
|
353
|
+
if (quote) {
|
|
354
|
+
// A doubled quote inside a quoted run is an escaped quote.
|
|
355
|
+
if (char === quote) {
|
|
356
|
+
if (text[index + 1] === quote) index += 1;
|
|
357
|
+
else quote = null;
|
|
358
|
+
}
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (char === "'" || char === '"' || char === "`") quote = char;
|
|
362
|
+
else if (char === "(") depth += 1;
|
|
363
|
+
else if (char === ")") {
|
|
364
|
+
depth -= 1;
|
|
365
|
+
if (depth < 0) return null;
|
|
366
|
+
} else if (char === "," && depth === 0) {
|
|
367
|
+
segments.push(text.slice(start, index));
|
|
368
|
+
start = index + 1;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (quote || depth !== 0) return null;
|
|
372
|
+
segments.push(text.slice(start));
|
|
373
|
+
return segments;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Counts executions, on any connection this process opened, of statements for
|
|
378
|
+
* which {@link mayChangeTagIdentity} is true. A cache built from `tags` stays
|
|
379
|
+
* valid for this process's own writes while the count is unchanged; commits by
|
|
380
|
+
* other connections show up in `PRAGMA data_version` instead.
|
|
381
|
+
*/
|
|
382
|
+
export function getTagIdentityWriteGeneration(): number {
|
|
383
|
+
return tagIdentityWriteGeneration;
|
|
384
|
+
}
|
|
385
|
+
|
|
237
386
|
/** Route native Bun and Node transaction entry through the same acquisition retry.
|
|
238
387
|
* Native Bun transaction wrappers execute BEGIN internally, bypassing an exec override,
|
|
239
388
|
* so both runtimes use this small synchronous wrapper instead. Writable handles
|
|
@@ -250,15 +399,31 @@ function installTransactionRouting(db: BetterSqlite3.Database, readonly: boolean
|
|
|
250
399
|
// A transaction that ended without passing through exec (for
|
|
251
400
|
// example an automatic rollback) must not be timed as this one.
|
|
252
401
|
openWriterTransactions.delete(db);
|
|
253
|
-
if (transformPassScope.getStore()?.active || backgroundWriterScope.getStore())
|
|
402
|
+
if (transformPassScope.getStore()?.active || backgroundWriterScope.getStore()) {
|
|
254
403
|
acquireShort(db, () => nativeExec(sql), sql.trim());
|
|
255
|
-
else
|
|
404
|
+
} else {
|
|
405
|
+
nativeExec(sql);
|
|
406
|
+
openWriterTransactions.set(db, {
|
|
407
|
+
site: sql.trim(),
|
|
408
|
+
lane: "background",
|
|
409
|
+
acquireMs: 0,
|
|
410
|
+
attempts: 1,
|
|
411
|
+
acquiredAt: performance.now(),
|
|
412
|
+
});
|
|
413
|
+
}
|
|
256
414
|
return db;
|
|
257
415
|
}
|
|
258
416
|
if (WRITER_TRANSACTION_END.test(sql) && openWriterTransactions.has(db))
|
|
259
417
|
return endWriterTransaction(db, () => nativeExec(sql), sql);
|
|
260
|
-
if (
|
|
261
|
-
|
|
418
|
+
if (
|
|
419
|
+
!/^\s*(?:SELECT|EXPLAIN|PRAGMA|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(sql)
|
|
420
|
+
) {
|
|
421
|
+
try {
|
|
422
|
+
return withAutocommitTimeout(db, () => nativeExec(sql));
|
|
423
|
+
} finally {
|
|
424
|
+
if (mayChangeTagIdentity(sql)) tagIdentityWriteGeneration += 1;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
262
427
|
return nativeExec(sql);
|
|
263
428
|
},
|
|
264
429
|
});
|
|
@@ -271,15 +436,22 @@ function installTransactionRouting(db: BetterSqlite3.Database, readonly: boolean
|
|
|
271
436
|
// SELECT/EXPLAIN cannot acquire a writer. Treat CTEs conservatively:
|
|
272
437
|
// WITH can introduce INSERT/UPDATE/DELETE as well as a read query.
|
|
273
438
|
if (!readonly && !/^\s*(?:SELECT|EXPLAIN|PRAGMA)\b/i.test(sql)) {
|
|
439
|
+
const changesTagIdentity = mayChangeTagIdentity(sql);
|
|
274
440
|
for (const method of ["run", "get", "all"] as const) {
|
|
275
441
|
const execute = statement[method].bind(statement);
|
|
276
442
|
Object.defineProperty(statement, method, {
|
|
277
443
|
configurable: true,
|
|
278
444
|
// biome-ignore lint/suspicious/noExplicitAny: retain SQLite's native binding overloads.
|
|
279
|
-
value: (...args: any[]) =>
|
|
280
|
-
|
|
445
|
+
value: (...args: any[]) => {
|
|
446
|
+
try {
|
|
447
|
+
return withAutocommitTimeout(db, () => execute(...args));
|
|
448
|
+
} finally {
|
|
449
|
+
if (changesTagIdentity) tagIdentityWriteGeneration += 1;
|
|
450
|
+
}
|
|
451
|
+
},
|
|
281
452
|
});
|
|
282
453
|
}
|
|
454
|
+
if (changesTagIdentity) wrapRemainingTagWriteForms(statement);
|
|
283
455
|
}
|
|
284
456
|
return statement;
|
|
285
457
|
},
|
|
@@ -330,6 +502,43 @@ function installTransactionRouting(db: BetterSqlite3.Database, readonly: boolean
|
|
|
330
502
|
});
|
|
331
503
|
}
|
|
332
504
|
|
|
505
|
+
/**
|
|
506
|
+
* Count tag identity writes made through the statement forms the transaction
|
|
507
|
+
* routing does not wrap. `iterate()` runs the statement while its result is
|
|
508
|
+
* consumed, so the generation moves both when iteration starts and when it
|
|
509
|
+
* ends: a summary read in between is then rebuilt on its next read.
|
|
510
|
+
*/
|
|
511
|
+
// biome-ignore lint/suspicious/noExplicitAny: statements from both backends share these method names.
|
|
512
|
+
function wrapRemainingTagWriteForms(statement: any): void {
|
|
513
|
+
if (typeof statement.iterate === "function") {
|
|
514
|
+
const iterate = statement.iterate.bind(statement);
|
|
515
|
+
Object.defineProperty(statement, "iterate", {
|
|
516
|
+
configurable: true,
|
|
517
|
+
value: function* (...args: unknown[]) {
|
|
518
|
+
tagIdentityWriteGeneration += 1;
|
|
519
|
+
try {
|
|
520
|
+
yield* iterate(...args);
|
|
521
|
+
} finally {
|
|
522
|
+
tagIdentityWriteGeneration += 1;
|
|
523
|
+
}
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
if (typeof statement.values === "function") {
|
|
528
|
+
const values = statement.values.bind(statement);
|
|
529
|
+
Object.defineProperty(statement, "values", {
|
|
530
|
+
configurable: true,
|
|
531
|
+
value: (...args: unknown[]) => {
|
|
532
|
+
try {
|
|
533
|
+
return values(...args);
|
|
534
|
+
} finally {
|
|
535
|
+
tagIdentityWriteGeneration += 1;
|
|
536
|
+
}
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
333
542
|
function trackSqliteConnection(
|
|
334
543
|
db: BetterSqlite3.Database,
|
|
335
544
|
filename: unknown,
|
|
@@ -431,6 +640,23 @@ function buildNodeSqliteDatabaseClass(DatabaseSync: any): typeof BetterSqlite3 {
|
|
|
431
640
|
|
|
432
641
|
export const Database: typeof BetterSqlite3 = TrackedDatabase;
|
|
433
642
|
|
|
643
|
+
/** The constructor path, without querying SQLite on the prompt thread. */
|
|
644
|
+
export function getSqliteDatabasePath(db: Database): string | null {
|
|
645
|
+
for (const connection of trackedSqliteConnections.values()) {
|
|
646
|
+
if (connection.reference.deref() === db) {
|
|
647
|
+
return connection.filename === ":memory:" || connection.filename === "<buffer>"
|
|
648
|
+
? null
|
|
649
|
+
: connection.filename;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
// Native Bun/better-sqlite3 handles can reach readers without our constructor
|
|
653
|
+
// wrapper. Their filename is still available without a SQLite query.
|
|
654
|
+
const native = db as unknown as { filename?: unknown; name?: unknown };
|
|
655
|
+
const path = native.filename ?? native.name;
|
|
656
|
+
if (typeof path === "string" && path !== ":memory:" && path !== "") return path;
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
|
|
434
660
|
function pragmaValue(db: BetterSqlite3.Database, name: string): unknown {
|
|
435
661
|
const row = db.prepare(`PRAGMA ${name}`).get() as Record<string, unknown> | undefined;
|
|
436
662
|
if (!row) return undefined;
|
|
@@ -665,22 +891,31 @@ export function withSqliteBackgroundWriter<T>(operation: () => T): T {
|
|
|
665
891
|
export function withSqliteTransformPass<T>(operation: () => T): T {
|
|
666
892
|
const existing = transformPassScope.getStore();
|
|
667
893
|
if (existing?.active) return operation();
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
894
|
+
const reservation = reserveForegroundSqliteTurn();
|
|
895
|
+
const run = (release: () => void): T => {
|
|
896
|
+
const lease = { active: true, remainingWaitMs: FOREGROUND_IN_PASS_BUSY_TIMEOUT_MS };
|
|
897
|
+
return transformPassScope.run(lease, () => {
|
|
898
|
+
try {
|
|
899
|
+
const result = operation();
|
|
900
|
+
if (result && typeof (result as { then?: unknown }).then === "function") {
|
|
901
|
+
return Promise.resolve(result).finally(() => {
|
|
902
|
+
lease.active = false;
|
|
903
|
+
release();
|
|
904
|
+
}) as T;
|
|
905
|
+
}
|
|
906
|
+
lease.active = false;
|
|
907
|
+
release();
|
|
908
|
+
return result;
|
|
909
|
+
} catch (error) {
|
|
910
|
+
lease.active = false;
|
|
911
|
+
release();
|
|
912
|
+
throw error;
|
|
676
913
|
}
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
}
|
|
683
|
-
});
|
|
914
|
+
});
|
|
915
|
+
};
|
|
916
|
+
// Foreground callers racing maintenance are awaited transforms. Idle
|
|
917
|
+
// synchronous callers keep the existing immediate return/throw behavior.
|
|
918
|
+
return typeof reservation === "function" ? run(reservation) : (reservation.then(run) as T);
|
|
684
919
|
}
|
|
685
920
|
|
|
686
921
|
/** Start detached maintenance work without borrowing a foreground pass's budget. */
|
|
@@ -720,10 +955,10 @@ export class SqliteAcquisitionBusyError extends Error {
|
|
|
720
955
|
}
|
|
721
956
|
|
|
722
957
|
const SHORT_BUSY_TIMEOUT_MS = 25;
|
|
723
|
-
const FOREGROUND_IN_PASS_BUSY_TIMEOUT_MS =
|
|
958
|
+
const FOREGROUND_IN_PASS_BUSY_TIMEOUT_MS = 2_000;
|
|
724
959
|
const FOREGROUND_ACQUISITION_BUDGET_MS = 16_500;
|
|
725
960
|
/** Waiting this long for the writer lock, or holding it this long, is logged. */
|
|
726
|
-
const SLOW_WRITER_LOG_MS =
|
|
961
|
+
const SLOW_WRITER_LOG_MS = 100;
|
|
727
962
|
const WRITER_TRANSACTION_END = /^\s*(?:COMMIT|END|ROLLBACK)(?:\s+TRANSACTION)?\s*;?\s*$/i;
|
|
728
963
|
|
|
729
964
|
type WriterLane = "foreground" | "background";
|
|
@@ -771,25 +1006,33 @@ function foregroundWaitLease() {
|
|
|
771
1006
|
function withAutocommitTimeout<T>(db: Database, operation: () => T): T {
|
|
772
1007
|
assertTransformWrite();
|
|
773
1008
|
const lease = foregroundWaitLease();
|
|
774
|
-
if (isInTransaction(db)
|
|
775
|
-
const
|
|
776
|
-
db
|
|
777
|
-
|
|
778
|
-
|
|
1009
|
+
if (isInTransaction(db)) return operation();
|
|
1010
|
+
const scoped = lease || backgroundWriterScope.getStore();
|
|
1011
|
+
const previous = scoped ? pragmaNumber(db, "busy_timeout") : null;
|
|
1012
|
+
if (scoped)
|
|
1013
|
+
db.exec(
|
|
1014
|
+
`PRAGMA busy_timeout=${lease ? Math.floor(Math.min(lease.remainingWaitMs, lease.guard?.remainingMs() ?? Infinity)) : SHORT_BUSY_TIMEOUT_MS}`,
|
|
1015
|
+
);
|
|
779
1016
|
const started = performance.now();
|
|
1017
|
+
let completed = false;
|
|
780
1018
|
try {
|
|
781
|
-
|
|
1019
|
+
const result = operation();
|
|
1020
|
+
completed = true;
|
|
1021
|
+
return result;
|
|
782
1022
|
} catch (error) {
|
|
783
1023
|
if (lease && isTransientSqliteError(error))
|
|
784
1024
|
throw new SqliteAcquisitionBusyError(error, "autocommit");
|
|
785
1025
|
throw error;
|
|
786
1026
|
} finally {
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
0,
|
|
790
|
-
lease.remainingWaitMs - (performance.now() - started),
|
|
791
|
-
);
|
|
1027
|
+
const elapsed = performance.now() - started;
|
|
1028
|
+
if (lease) lease.remainingWaitMs = Math.max(0, lease.remainingWaitMs - elapsed);
|
|
792
1029
|
if (previous !== null) db.exec(`PRAGMA busy_timeout=${previous}`);
|
|
1030
|
+
// Native autocommit does not expose the instant its lock was acquired.
|
|
1031
|
+
// Label the upper bound so a waiting statement is not blamed as a holder.
|
|
1032
|
+
if (elapsed >= SLOW_WRITER_LOG_MS)
|
|
1033
|
+
reportSqliteDiagnostic(
|
|
1034
|
+
`sqlite writer site=autocommit lane=${lease ? "foreground" : "background"} elapsed_ms=${Math.round(elapsed)} timing=statement_including_acquisition outcome=${completed ? "completed" : "failed"} caller=${writerCaller()}`,
|
|
1035
|
+
);
|
|
793
1036
|
}
|
|
794
1037
|
}
|
|
795
1038
|
|
|
@@ -851,7 +1094,9 @@ function acquireShort(db: Database, acquire: () => unknown, site: string): void
|
|
|
851
1094
|
* `acquireShort`, then report how long the writer waited for the lock
|
|
852
1095
|
* (`acquire_ms`) and how long it then held it (`hold_ms`) when either crosses
|
|
853
1096
|
* the threshold. A long hold blocks every other process's writer, so it is
|
|
854
|
-
* worth seeing even when the lock itself came quickly.
|
|
1097
|
+
* worth seeing even when the lock itself came quickly. This interval includes
|
|
1098
|
+
* COMMIT: an automatic PASSIVE checkpoint can run after writer-lock release,
|
|
1099
|
+
* so hold_ms is a conservative upper bound, not proof of continuous ownership.
|
|
855
1100
|
*/
|
|
856
1101
|
function endWriterTransaction(db: Database, end: () => unknown, sql: string): unknown {
|
|
857
1102
|
let ended = false;
|
|
@@ -872,13 +1117,32 @@ function endWriterTransaction(db: Database, end: () => unknown, sql: string): un
|
|
|
872
1117
|
? "committed"
|
|
873
1118
|
: "failed";
|
|
874
1119
|
reportSqliteDiagnostic(
|
|
875
|
-
`sqlite writer site=${open.site} lane=${open.lane} acquire_ms=${Math.round(open.acquireMs)} hold_ms=${Math.round(holdMs)} attempts=${open.attempts} outcome=${outcome}`,
|
|
1120
|
+
`sqlite writer site=${open.site} lane=${open.lane} acquire_ms=${Math.round(open.acquireMs)} hold_ms=${Math.round(holdMs)} attempts=${open.attempts} outcome=${outcome} timing=through_commit_upper_bound caller=${writerCaller()}`,
|
|
876
1121
|
);
|
|
877
1122
|
}
|
|
878
1123
|
}
|
|
879
1124
|
}
|
|
880
1125
|
}
|
|
881
1126
|
|
|
1127
|
+
// Capture a stack only for slow writes. Synchronous COMMIT retains the writer's
|
|
1128
|
+
// caller, so fast transactions need neither stack allocation nor source mapping.
|
|
1129
|
+
function writerCaller(): string {
|
|
1130
|
+
return (
|
|
1131
|
+
new Error().stack
|
|
1132
|
+
?.split("\n")
|
|
1133
|
+
.find(
|
|
1134
|
+
(line) =>
|
|
1135
|
+
line.includes(" at ") &&
|
|
1136
|
+
!/[/\\]sqlite\.[tj]s[:)]/.test(line) &&
|
|
1137
|
+
!/\b(?:writerCaller|endWriterTransaction|withAutocommitTimeout|value)\b/.test(
|
|
1138
|
+
line,
|
|
1139
|
+
),
|
|
1140
|
+
)
|
|
1141
|
+
?.trim()
|
|
1142
|
+
.replace(/\s+/g, "_") ?? "unknown"
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
882
1146
|
/** Begin an immediate transaction with bounded asynchronous retries on writer contention.
|
|
883
1147
|
* Callers must recheck any lease or source snapshot after this function returns.
|
|
884
1148
|
* Resolves with the `performance.now()` time at which BEGIN IMMEDIATE succeeded,
|
|
@@ -1071,3 +1335,80 @@ export function withPrivilegedWriter<T>(db: Database, operation: () => T): T {
|
|
|
1071
1335
|
throw error;
|
|
1072
1336
|
}
|
|
1073
1337
|
}
|
|
1338
|
+
|
|
1339
|
+
// Process-local admission only; SQLite remains the cross-process writer arbiter.
|
|
1340
|
+
const indexCoordinationKey = Symbol.for("magic-context.index-foreground-coordination.v1");
|
|
1341
|
+
const indexCoordinationHeap = globalThis as unknown as Record<
|
|
1342
|
+
symbol,
|
|
1343
|
+
SharedArrayBuffer | undefined
|
|
1344
|
+
>;
|
|
1345
|
+
const indexCoordinationBuffer =
|
|
1346
|
+
indexCoordinationHeap[indexCoordinationKey] ??
|
|
1347
|
+
new SharedArrayBuffer(3 * Int32Array.BYTES_PER_ELEMENT);
|
|
1348
|
+
indexCoordinationHeap[indexCoordinationKey] = indexCoordinationBuffer;
|
|
1349
|
+
const indexCoordinationState = new Int32Array(indexCoordinationBuffer);
|
|
1350
|
+
// Slots: foreground interest, current maintenance owner, next owner token.
|
|
1351
|
+
|
|
1352
|
+
export function indexCoordinationInput(): {
|
|
1353
|
+
indexCoordination: SharedArrayBuffer;
|
|
1354
|
+
indexOwner: number;
|
|
1355
|
+
} {
|
|
1356
|
+
return {
|
|
1357
|
+
indexCoordination: indexCoordinationBuffer,
|
|
1358
|
+
indexOwner: Atomics.add(indexCoordinationState, 2, 1) + 1,
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
const yieldIndexTurn = () => new Promise<void>((resolve) => setTimeout(resolve, 1));
|
|
1363
|
+
|
|
1364
|
+
/** Announce interest before waiting, so no maintenance chunk overtakes a foreground turn. */
|
|
1365
|
+
function reserveForegroundSqliteTurn(): (() => void) | Promise<() => void> {
|
|
1366
|
+
Atomics.add(indexCoordinationState, 0, 1);
|
|
1367
|
+
const release = () => {
|
|
1368
|
+
Atomics.sub(indexCoordinationState, 0, 1);
|
|
1369
|
+
Atomics.notify(indexCoordinationState, 0);
|
|
1370
|
+
};
|
|
1371
|
+
if (Atomics.load(indexCoordinationState, 1) === 0) return release;
|
|
1372
|
+
return (async () => {
|
|
1373
|
+
while (Atomics.load(indexCoordinationState, 1) !== 0) await yieldIndexTurn();
|
|
1374
|
+
return release;
|
|
1375
|
+
})();
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
export function releaseIndexOwner(input: {
|
|
1379
|
+
indexCoordination?: SharedArrayBuffer;
|
|
1380
|
+
indexOwner?: number;
|
|
1381
|
+
}): void {
|
|
1382
|
+
if (!input.indexCoordination || !input.indexOwner) return;
|
|
1383
|
+
const shared = new Int32Array(input.indexCoordination);
|
|
1384
|
+
Atomics.compareExchange(shared, 1, input.indexOwner, 0);
|
|
1385
|
+
Atomics.notify(shared, 1);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/** One synchronous transaction per permit; release the permit before yielding. */
|
|
1389
|
+
export async function withIndexChunk<T>(
|
|
1390
|
+
input: { indexCoordination?: SharedArrayBuffer; indexOwner?: number },
|
|
1391
|
+
operation: () => T,
|
|
1392
|
+
): Promise<T> {
|
|
1393
|
+
if (!input.indexCoordination || !input.indexOwner) return operation();
|
|
1394
|
+
const shared = new Int32Array(input.indexCoordination);
|
|
1395
|
+
for (;;) {
|
|
1396
|
+
if (
|
|
1397
|
+
Atomics.load(shared, 0) === 0 &&
|
|
1398
|
+
Atomics.compareExchange(shared, 1, 0, input.indexOwner) === 0
|
|
1399
|
+
) {
|
|
1400
|
+
if (Atomics.load(shared, 0) === 0) break;
|
|
1401
|
+
releaseIndexOwner(input);
|
|
1402
|
+
}
|
|
1403
|
+
await yieldIndexTurn();
|
|
1404
|
+
}
|
|
1405
|
+
try {
|
|
1406
|
+
return await operation();
|
|
1407
|
+
} finally {
|
|
1408
|
+
releaseIndexOwner(input);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
export async function yieldIndexChunk(): Promise<void> {
|
|
1413
|
+
await yieldIndexTurn();
|
|
1414
|
+
}
|
|
@@ -18,12 +18,38 @@
|
|
|
18
18
|
* signing), build a separate utility — do NOT widen this contract.
|
|
19
19
|
*/
|
|
20
20
|
export function stableStringify(value: unknown, seen = new WeakSet<object>()): string {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
// Collect pieces and join once. Joining at every level copied each nested
|
|
22
|
+
// value's text once per enclosing level, so a large value N levels deep cost
|
|
23
|
+
// N copies of itself.
|
|
24
|
+
const pieces: string[] = [];
|
|
25
|
+
writeStable(value, seen, pieces);
|
|
26
|
+
return pieces.join("");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function writeStable(value: unknown, seen: WeakSet<object>, pieces: string[]): void {
|
|
30
|
+
if (value === undefined) {
|
|
31
|
+
pieces.push("undefined");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (value === null || typeof value !== "object") {
|
|
35
|
+
pieces.push(JSON.stringify(value) ?? String(value));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
// Any object met a second time, shared or cyclic, is written as "[Circular]".
|
|
39
|
+
if (seen.has(value)) {
|
|
40
|
+
pieces.push('"[Circular]"');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
24
43
|
seen.add(value);
|
|
25
44
|
if (Array.isArray(value)) {
|
|
26
|
-
|
|
45
|
+
pieces.push("[");
|
|
46
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
47
|
+
if (index > 0) pieces.push(",");
|
|
48
|
+
// A hole writes nothing between its commas, as joining a mapped array did.
|
|
49
|
+
if (index in value) writeStable(value[index], seen, pieces);
|
|
50
|
+
}
|
|
51
|
+
pieces.push("]");
|
|
52
|
+
return;
|
|
27
53
|
}
|
|
28
54
|
// Code-point sort (NOT localeCompare). Stable across runtimes/locales.
|
|
29
55
|
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => {
|
|
@@ -31,7 +57,11 @@ export function stableStringify(value: unknown, seen = new WeakSet<object>()): s
|
|
|
31
57
|
if (a > b) return 1;
|
|
32
58
|
return 0;
|
|
33
59
|
});
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
.
|
|
60
|
+
pieces.push("{");
|
|
61
|
+
entries.forEach(([key, child], index) => {
|
|
62
|
+
if (index > 0) pieces.push(",");
|
|
63
|
+
pieces.push(JSON.stringify(key), ":");
|
|
64
|
+
writeStable(child, seen, pieces);
|
|
65
|
+
});
|
|
66
|
+
pieces.push("}");
|
|
37
67
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { withStorageFileMutex } from "./storage-file-mutex";
|
|
7
|
+
|
|
8
|
+
const directories: string[] = [];
|
|
9
|
+
const newDirectory = () => {
|
|
10
|
+
const directory = mkdtempSync(join(tmpdir(), "mmc-storage-file-mutex-"));
|
|
11
|
+
directories.push(directory);
|
|
12
|
+
return directory;
|
|
13
|
+
};
|
|
14
|
+
afterAll(() => {
|
|
15
|
+
for (const directory of directories) rmSync(directory, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("withStorageFileMutex", () => {
|
|
19
|
+
test("serializes two critical sections", () => {
|
|
20
|
+
const directory = newDirectory();
|
|
21
|
+
const orderPath = join(directory, "order");
|
|
22
|
+
withStorageFileMutex(directory, () => {
|
|
23
|
+
// Only our own contender file may remain while we hold the mutex.
|
|
24
|
+
const locks = readdirSync(directory).filter((name) => name.endsWith(".lock"));
|
|
25
|
+
expect(locks.length).toBe(1);
|
|
26
|
+
writeFileSync(orderPath, "first");
|
|
27
|
+
});
|
|
28
|
+
withStorageFileMutex(directory, () => {
|
|
29
|
+
expect(readFileSync(orderPath, "utf8")).toBe("first");
|
|
30
|
+
writeFileSync(orderPath, "first,second");
|
|
31
|
+
});
|
|
32
|
+
expect(readFileSync(orderPath, "utf8")).toBe("first,second");
|
|
33
|
+
expect(readdirSync(directory).filter((name) => name.endsWith(".lock")).length).toBe(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("reclaims a dead holder's ticket", () => {
|
|
37
|
+
const directory = newDirectory();
|
|
38
|
+
const deadName = `0-${randomUUID()}.lock`;
|
|
39
|
+
const ticket = { choosing: false, number: 999, birth: null };
|
|
40
|
+
const checksum = createHash("sha256")
|
|
41
|
+
.update(JSON.stringify([deadName, ticket.choosing, ticket.number, ticket.birth]))
|
|
42
|
+
.digest("hex");
|
|
43
|
+
writeFileSync(join(directory, deadName), JSON.stringify({ ...ticket, checksum }));
|
|
44
|
+
// pid 0 is never alive, so the stale ticket must be reclaimed and the
|
|
45
|
+
// critical section must run despite the holder's high ticket number.
|
|
46
|
+
const value = withStorageFileMutex(directory, () => 42);
|
|
47
|
+
expect(value).toBe(42);
|
|
48
|
+
expect(readdirSync(directory).filter((name) => name.endsWith(".lock")).length).toBe(0);
|
|
49
|
+
});
|
|
50
|
+
});
|