@mandujs/core 0.53.1 → 0.53.3

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.
@@ -69,7 +69,7 @@ import type {
69
69
  PendingMigration,
70
70
  SqlProvider,
71
71
  } from "../../resource/ddl/types";
72
- import type { Db } from "../index";
72
+ import { withPinnedDbHandle, type Db } from "../index";
73
73
  import {
74
74
  DEFAULT_HISTORY_TABLE,
75
75
  SAFE_HISTORY_TABLE_RE,
@@ -128,6 +128,24 @@ export class MigrationTimeoutError extends Error {
128
128
  }
129
129
  }
130
130
 
131
+ function assertNoTamperedHistory(
132
+ history: HistoryRow[],
133
+ diskByVersion: Map<string, PendingMigration>,
134
+ ): void {
135
+ for (const row of history) {
136
+ if (row.success !== 1) continue;
137
+ const disk = diskByVersion.get(row.version);
138
+ if (!disk) continue; // orphan on the history side — surfaced via status(), not apply()
139
+ if (disk.checksum !== row.checksum) {
140
+ throw new MigrationTamperedError(
141
+ disk.filename,
142
+ row.checksum,
143
+ disk.checksum,
144
+ );
145
+ }
146
+ }
147
+ }
148
+
131
149
  // ─── Public API ─────────────────────────────────────────────────────────────
132
150
 
133
151
  /** Options for {@link createMigrationRunner}. */
@@ -246,34 +264,18 @@ export function createMigrationRunner(
246
264
  async function apply(
247
265
  opts: { dryRun?: boolean } = {},
248
266
  ): Promise<AppliedMigration[]> {
249
- await ensureReady();
250
-
251
- // Tamper check BEFORE acquiring the lock so the fast-fail path
252
- // doesn't hold the advisory lock longer than necessary.
253
- const history = await readAllHistory(db, historyTable);
254
267
  const diskFiles = await readMigrationsFromDisk(migrationsDir);
255
268
  const diskByVersion = new Map(diskFiles.map((f) => [f.version, f]));
256
- for (const row of history) {
257
- if (row.success !== 1) continue;
258
- const disk = diskByVersion.get(row.version);
259
- if (!disk) continue; // orphan on the history side — surfaced via status(), not apply()
260
- if (disk.checksum !== row.checksum) {
261
- throw new MigrationTamperedError(
262
- disk.filename,
263
- row.checksum,
264
- disk.checksum,
265
- );
266
- }
267
- }
268
269
 
269
- const appliedVersions = new Set(
270
- history.filter((h) => h.success === 1).map((h) => h.version),
271
- );
272
- const pending = diskFiles.filter((f) => !appliedVersions.has(f.version));
273
- if (pending.length === 0) return [];
274
-
275
- // Dry-run: report what we WOULD apply, no IO, no history.
276
270
  if (opts.dryRun === true) {
271
+ await ensureReady();
272
+ const history = await readAllHistory(db, historyTable);
273
+ assertNoTamperedHistory(history, diskByVersion);
274
+
275
+ const appliedVersions = new Set(
276
+ history.filter((h) => h.success === 1).map((h) => h.version),
277
+ );
278
+ const pending = diskFiles.filter((f) => !appliedVersions.has(f.version));
277
279
  return pending.map<AppliedMigration>((p) => ({
278
280
  version: p.version,
279
281
  filename: p.filename,
@@ -290,100 +292,115 @@ export function createMigrationRunner(
290
292
 
291
293
  const applied: AppliedMigration[] = [];
292
294
 
293
- heldLock = await acquireMigrationLock(db, lockStrategy);
294
- try {
295
- for (const migration of pending) {
296
- const start = Date.now();
297
-
298
- const statements = splitStatements(migration.sql);
299
- if (statements.length === 0) {
300
- // Empty migration still record a history row so we don't
301
- // re-run it. execution_ms = 0 reflects reality.
295
+ const runLockedApply = async (): Promise<AppliedMigration[]> => {
296
+ heldLock = await acquireMigrationLock(db, lockStrategy);
297
+ try {
298
+ await ensureReady();
299
+ const lockedHistory = await readAllHistory(db, historyTable);
300
+ assertNoTamperedHistory(lockedHistory, diskByVersion);
301
+ const lockedAppliedVersions = new Set(
302
+ lockedHistory.filter((h) => h.success === 1).map((h) => h.version),
303
+ );
304
+ const lockedPending = diskFiles.filter((f) => !lockedAppliedVersions.has(f.version));
305
+
306
+ for (const migration of lockedPending) {
307
+ const start = Date.now();
308
+
309
+ const statements = splitStatements(migration.sql);
310
+ if (statements.length === 0) {
311
+ // Empty migration — still record a history row so we don't
312
+ // re-run it. execution_ms = 0 reflects reality.
313
+ await insertHistory(db, historyTable, {
314
+ version: migration.version,
315
+ filename: migration.filename,
316
+ checksum: migration.checksum,
317
+ applied_at: new Date(),
318
+ execution_ms: 0,
319
+ success: 1,
320
+ installed_by: installedBy,
321
+ });
322
+ applied.push({
323
+ version: migration.version,
324
+ filename: migration.filename,
325
+ checksum: migration.checksum,
326
+ appliedAt: new Date(),
327
+ executionMs: 0,
328
+ success: true,
329
+ });
330
+ continue;
331
+ }
332
+
333
+ try {
334
+ await db.transaction(async (tx) => {
335
+ for (const stmt of statements) {
336
+ await execRaw(tx, stmt);
337
+ const elapsed = Date.now() - start;
338
+ if (elapsed > applyTimeoutMs) {
339
+ throw new MigrationTimeoutError(
340
+ migration.filename,
341
+ elapsed,
342
+ applyTimeoutMs,
343
+ );
344
+ }
345
+ }
346
+ });
347
+ } catch (err) {
348
+ if (err instanceof MigrationTimeoutError) throw err;
349
+ // Wrap with migration context so downstream callers know
350
+ // which file blew up. Preserve the original stack where
351
+ // possible via `cause`.
352
+ const msg = err instanceof Error ? err.message : String(err);
353
+ const wrapped = new Error(
354
+ `[@mandujs/core/db/migrations] Failed to apply ${migration.filename}: ${msg}`,
355
+ );
356
+ // Preserve the original as a `cause` chain for diagnostics.
357
+ (wrapped as { cause?: unknown }).cause = err;
358
+ throw wrapped;
359
+ }
360
+
361
+ const executionMs = Date.now() - start;
362
+ const appliedAt = new Date();
363
+
364
+ // History row is written AFTER the SQL transaction commits.
365
+ // If this INSERT itself fails, the migration has run but we
366
+ // have no record — the user will see it as pending again.
367
+ // Mitigation: the insert is a single tiny statement; in
368
+ // practice it either succeeds or the whole connection is
369
+ // dead (in which case subsequent apply() calls will also fail
370
+ // and the user will debug from the DB side).
302
371
  await insertHistory(db, historyTable, {
303
372
  version: migration.version,
304
373
  filename: migration.filename,
305
374
  checksum: migration.checksum,
306
- applied_at: new Date(),
307
- execution_ms: 0,
375
+ applied_at: appliedAt,
376
+ execution_ms: executionMs,
308
377
  success: 1,
309
378
  installed_by: installedBy,
310
379
  });
380
+
311
381
  applied.push({
312
382
  version: migration.version,
313
383
  filename: migration.filename,
314
384
  checksum: migration.checksum,
315
- appliedAt: new Date(),
316
- executionMs: 0,
385
+ appliedAt,
386
+ executionMs,
317
387
  success: true,
318
388
  });
319
- continue;
320
389
  }
321
-
322
- try {
323
- await db.transaction(async (tx) => {
324
- for (const stmt of statements) {
325
- await execRaw(tx, stmt);
326
- const elapsed = Date.now() - start;
327
- if (elapsed > applyTimeoutMs) {
328
- throw new MigrationTimeoutError(
329
- migration.filename,
330
- elapsed,
331
- applyTimeoutMs,
332
- );
333
- }
334
- }
335
- });
336
- } catch (err) {
337
- if (err instanceof MigrationTimeoutError) throw err;
338
- // Wrap with migration context so downstream callers know
339
- // which file blew up. Preserve the original stack where
340
- // possible via `cause`.
341
- const msg = err instanceof Error ? err.message : String(err);
342
- const wrapped = new Error(
343
- `[@mandujs/core/db/migrations] Failed to apply ${migration.filename}: ${msg}`,
344
- );
345
- // Preserve the original as a `cause` chain for diagnostics.
346
- (wrapped as { cause?: unknown }).cause = err;
347
- throw wrapped;
390
+ } finally {
391
+ if (heldLock) {
392
+ await heldLock.release();
393
+ heldLock = null;
348
394
  }
395
+ }
349
396
 
350
- const executionMs = Date.now() - start;
351
- const appliedAt = new Date();
352
-
353
- // History row is written AFTER the SQL transaction commits.
354
- // If this INSERT itself fails, the migration has run but we
355
- // have no record — the user will see it as pending again.
356
- // Mitigation: the insert is a single tiny statement; in
357
- // practice it either succeeds or the whole connection is
358
- // dead (in which case subsequent apply() calls will also fail
359
- // and the user will debug from the DB side).
360
- await insertHistory(db, historyTable, {
361
- version: migration.version,
362
- filename: migration.filename,
363
- checksum: migration.checksum,
364
- applied_at: appliedAt,
365
- execution_ms: executionMs,
366
- success: 1,
367
- installed_by: installedBy,
368
- });
397
+ return applied;
398
+ };
369
399
 
370
- applied.push({
371
- version: migration.version,
372
- filename: migration.filename,
373
- checksum: migration.checksum,
374
- appliedAt,
375
- executionMs,
376
- success: true,
377
- });
378
- }
379
- } finally {
380
- if (heldLock) {
381
- await heldLock.release();
382
- heldLock = null;
383
- }
400
+ if (lockStrategy === "mysql_get_lock") {
401
+ return await withPinnedDbHandle(db, runLockedApply);
384
402
  }
385
-
386
- return applied;
403
+ return await runLockedApply();
387
404
  }
388
405
 
389
406
  async function status(): Promise<MigrationStatus> {
@@ -17,6 +17,7 @@ import {
17
17
  checkCloneElementWarnings,
18
18
  checkDevArtifactsInProd,
19
19
  checkPackageExportGaps,
20
+ checkNestedInternalCore,
20
21
  } from "../checks";
21
22
  import { runExtendedDiagnose, buildReport } from "../run";
22
23
 
@@ -319,6 +320,75 @@ describe("checkPackageExportGaps", () => {
319
320
  });
320
321
  });
321
322
 
323
+ // ──────────────────────────────────────────────────────────────────
324
+ // nested_internal_core (#261)
325
+ // ──────────────────────────────────────────────────────────────────
326
+
327
+ describe("checkNestedInternalCore", () => {
328
+ let rootDir: string;
329
+ beforeEach(async () => { rootDir = await mkTmpRoot(); });
330
+ afterEach(async () => { await fs.rm(rootDir, { recursive: true, force: true }); });
331
+
332
+ async function seedHoistedCore(version: string): Promise<void> {
333
+ await writeFile(rootDir, "node_modules/@mandujs/core/package.json", JSON.stringify({
334
+ name: "@mandujs/core", version,
335
+ }));
336
+ }
337
+
338
+ async function seedNestedCore(parent: string, version: string): Promise<void> {
339
+ await writeFile(
340
+ rootDir,
341
+ `node_modules/@mandujs/${parent}/node_modules/@mandujs/core/package.json`,
342
+ JSON.stringify({ name: "@mandujs/core", version }),
343
+ );
344
+ }
345
+
346
+ it("skips when hoisted @mandujs/core is not installed", async () => {
347
+ const result = await checkNestedInternalCore(rootDir);
348
+ expect(result.ok).toBe(true);
349
+ expect(result.details?.skipped).toBe(true);
350
+ });
351
+
352
+ it("passes when no nested core exists", async () => {
353
+ await seedHoistedCore("0.53.2");
354
+ // Seed a sibling without nested core
355
+ await writeFile(rootDir, "node_modules/@mandujs/mcp/package.json", JSON.stringify({
356
+ name: "@mandujs/mcp", version: "0.36.2",
357
+ }));
358
+ const result = await checkNestedInternalCore(rootDir);
359
+ expect(result.ok).toBe(true);
360
+ expect(result.details?.scannedSiblings).toBe(1);
361
+ });
362
+
363
+ it("passes when nested core matches hoisted version exactly", async () => {
364
+ await seedHoistedCore("0.53.2");
365
+ await seedNestedCore("mcp", "0.53.2");
366
+ const result = await checkNestedInternalCore(rootDir);
367
+ expect(result.ok).toBe(true);
368
+ });
369
+
370
+ it("flags a stale nested core that shadows hoisted (#261 repro)", async () => {
371
+ await seedHoistedCore("0.53.2");
372
+ await seedNestedCore("mcp", "0.47.0");
373
+ const result = await checkNestedInternalCore(rootDir);
374
+ expect(result.ok).toBe(false);
375
+ expect(result.severity).toBe("error");
376
+ expect(result.message).toMatch(/@mandujs\/mcp/);
377
+ expect(result.message).toMatch(/0\.47\.0/);
378
+ expect(result.suggestion).toMatch(/rm -rf node_modules\/@mandujs\/mcp\/node_modules/);
379
+ expect(result.details?.mismatchCount).toBe(1);
380
+ });
381
+
382
+ it("reports every mismatched sibling, not just one", async () => {
383
+ await seedHoistedCore("0.53.2");
384
+ await seedNestedCore("mcp", "0.47.0");
385
+ await seedNestedCore("ate", "0.50.0");
386
+ const result = await checkNestedInternalCore(rootDir);
387
+ expect(result.ok).toBe(false);
388
+ expect(result.details?.mismatchCount).toBe(2);
389
+ });
390
+ });
391
+
322
392
  // ──────────────────────────────────────────────────────────────────
323
393
  // aggregator
324
394
  // ──────────────────────────────────────────────────────────────────
@@ -328,10 +398,10 @@ describe("runExtendedDiagnose", () => {
328
398
  beforeEach(async () => { rootDir = await mkTmpRoot(); });
329
399
  afterEach(async () => { await fs.rm(rootDir, { recursive: true, force: true }); });
330
400
 
331
- it("runs all 6 extended checks and returns a structured report", async () => {
401
+ it("runs all 7 extended checks and returns a structured report", async () => {
332
402
  const report = await runExtendedDiagnose(rootDir);
333
- // Phase 18.χ added `a11y_hints` — total is now 6.
334
- expect(report.summary.total).toBe(6);
403
+ // #261 added `nested_internal_core` — total is now 7.
404
+ expect(report.summary.total).toBe(7);
335
405
  // manifest is missing → at least one error
336
406
  expect(report.healthy).toBe(false);
337
407
  expect(report.errorCount).toBeGreaterThanOrEqual(1);
@@ -341,6 +411,7 @@ describe("runExtendedDiagnose", () => {
341
411
  expect(rules).toContain("cloneelement_warnings");
342
412
  expect(rules).toContain("dev_artifacts_in_prod");
343
413
  expect(rules).toContain("package_export_gaps");
414
+ expect(rules).toContain("nested_internal_core");
344
415
  expect(rules).toContain("a11y_hints");
345
416
  });
346
417
 
@@ -718,3 +718,115 @@ export async function checkA11yHints(rootDir: string): Promise<DiagnoseCheckResu
718
718
  },
719
719
  };
720
720
  }
721
+
722
+ // ────────────────────────────────────────────────────────────────────────
723
+ // 7. nested_internal_core (#261)
724
+ // ────────────────────────────────────────────────────────────────────────
725
+
726
+ interface NestedCoreSite {
727
+ parentPackage: string;
728
+ nestedVersion: string;
729
+ relativePath: string;
730
+ }
731
+
732
+ async function readPackageVersion(pkgJsonPath: string): Promise<string | null> {
733
+ try {
734
+ const raw = await fs.readFile(pkgJsonPath, "utf-8");
735
+ const parsed = JSON.parse(raw) as { version?: string };
736
+ return typeof parsed.version === "string" ? parsed.version : null;
737
+ } catch {
738
+ return null;
739
+ }
740
+ }
741
+
742
+ async function listMandujsSiblings(rootDir: string): Promise<string[]> {
743
+ const dir = path.join(rootDir, "node_modules", "@mandujs");
744
+ try {
745
+ const entries = (await fs.readdir(dir, { withFileTypes: true })) as Dirent[];
746
+ return entries
747
+ .filter((e) => e.isDirectory() && e.name !== "core")
748
+ .map((e) => e.name);
749
+ } catch {
750
+ return [];
751
+ }
752
+ }
753
+
754
+ /**
755
+ * #261 check 7: detect a stale `@mandujs/core` nested inside another
756
+ * `@mandujs/*` package's `node_modules`.
757
+ *
758
+ * Symptom: `bunx @mandujs/mcp` fails with `Cannot find module
759
+ * @mandujs/core/<subpath>` even though the project has the latest
760
+ * `@mandujs/core` hoisted at the top level. Cause: a previous install
761
+ * left an older core nested under `@mandujs/<sibling>/node_modules`,
762
+ * which wins module resolution from inside that sibling's code, and the
763
+ * older core's `exports` map lacks the subpath the sibling now imports.
764
+ *
765
+ * This check walks `node_modules/@mandujs/*` siblings, looks for a
766
+ * nested `node_modules/@mandujs/core/package.json`, and compares the
767
+ * version to the hoisted core. A mismatch is reported as `error`
768
+ * (boot-breaking on the user's machine) with a copy-pastable fix.
769
+ */
770
+ export async function checkNestedInternalCore(rootDir: string): Promise<DiagnoseCheckResult> {
771
+ const hoistedPath = path.join(rootDir, "node_modules", "@mandujs", "core", "package.json");
772
+ const hoistedVersion = await readPackageVersion(hoistedPath);
773
+
774
+ if (!hoistedVersion) {
775
+ return {
776
+ ok: true,
777
+ rule: "nested_internal_core",
778
+ message: "@mandujs/core not installed at the project root — skipping nested-version check.",
779
+ details: { skipped: true },
780
+ };
781
+ }
782
+
783
+ const siblings = await listMandujsSiblings(rootDir);
784
+ const mismatches: NestedCoreSite[] = [];
785
+
786
+ for (const sibling of siblings) {
787
+ const nestedPkgJson = path.join(
788
+ rootDir,
789
+ "node_modules",
790
+ "@mandujs",
791
+ sibling,
792
+ "node_modules",
793
+ "@mandujs",
794
+ "core",
795
+ "package.json",
796
+ );
797
+ const nestedVersion = await readPackageVersion(nestedPkgJson);
798
+ if (!nestedVersion) continue;
799
+ if (nestedVersion === hoistedVersion) continue;
800
+ mismatches.push({
801
+ parentPackage: `@mandujs/${sibling}`,
802
+ nestedVersion,
803
+ relativePath: path.relative(rootDir, nestedPkgJson),
804
+ });
805
+ }
806
+
807
+ if (mismatches.length === 0) {
808
+ return {
809
+ ok: true,
810
+ rule: "nested_internal_core",
811
+ message: `No stale nested @mandujs/core found (hoisted: ${hoistedVersion}, scanned ${siblings.length} sibling(s)).`,
812
+ details: { hoistedVersion, scannedSiblings: siblings.length },
813
+ };
814
+ }
815
+
816
+ const first = mismatches[0];
817
+ const fixCommand = `rm -rf node_modules/${first.parentPackage}/node_modules`;
818
+ return {
819
+ ok: false,
820
+ rule: "nested_internal_core",
821
+ severity: "error",
822
+ message:
823
+ `${mismatches.length} stale nested @mandujs/core install(s) shadow the hoisted ${hoistedVersion}. ` +
824
+ `First: ${first.parentPackage} pinned to ${first.nestedVersion} (${first.relativePath}).`,
825
+ suggestion: `Run \`${fixCommand}\` and re-test \`bunx @mandujs/mcp\`, or remove node_modules and bun.lock entirely and \`bun install\`.`,
826
+ details: {
827
+ hoistedVersion,
828
+ mismatchCount: mismatches.length,
829
+ mismatches,
830
+ },
831
+ };
832
+ }
@@ -11,6 +11,7 @@ export {
11
11
  checkCloneElementWarnings,
12
12
  checkDevArtifactsInProd,
13
13
  checkPackageExportGaps,
14
+ checkNestedInternalCore,
14
15
  checkA11yHints,
15
16
  } from "./checks";
16
17
  export { EXTENDED_CHECKS, runExtendedDiagnose, buildReport } from "./run";
@@ -12,6 +12,7 @@ import {
12
12
  checkCloneElementWarnings,
13
13
  checkDevArtifactsInProd,
14
14
  checkPackageExportGaps,
15
+ checkNestedInternalCore,
15
16
  checkA11yHints,
16
17
  } from "./checks";
17
18
 
@@ -30,6 +31,7 @@ export const EXTENDED_CHECKS = [
30
31
  { name: "cloneelement_warnings", run: checkCloneElementWarnings },
31
32
  { name: "dev_artifacts_in_prod", run: checkDevArtifactsInProd },
32
33
  { name: "package_export_gaps", run: checkPackageExportGaps },
34
+ { name: "nested_internal_core", run: checkNestedInternalCore },
33
35
  { name: "a11y_hints", run: checkA11yHints },
34
36
  ] as const;
35
37