tina4-nodejs 3.11.18 → 3.11.32
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/CLAUDE.md +1 -1
- package/package.json +1 -1
- package/packages/core/public/js/tina4-dev-admin.js +121 -121
- package/packages/core/public/js/tina4-dev-admin.min.js +121 -121
- package/packages/core/src/devAdmin.ts +119 -1
- package/packages/core/src/docs.ts +1231 -0
- package/packages/core/src/docsAutoDiscovery.ts +73 -0
- package/packages/core/src/index.ts +3 -0
- package/packages/core/src/mcp.ts +67 -0
- package/packages/core/src/server.ts +7 -0
- package/packages/orm/src/database.ts +49 -6
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live Docs MCP discovery — writes `.tina4/mcp.json` so MCP-aware AI tools
|
|
3
|
+
* (Claude Code, Cursor, etc.) auto-discover this server's docs MCP endpoint.
|
|
4
|
+
*
|
|
5
|
+
* Idempotent — only writes if content has changed.
|
|
6
|
+
*
|
|
7
|
+
* Spec: plan/v3/22-LIVE-API-RAG.md (Auto-discovery section)
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
|
|
12
|
+
interface McpDiscovery {
|
|
13
|
+
mcpServers: Record<string, { url: string; description: string }>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const TINA4_DIR = ".tina4";
|
|
17
|
+
const MCP_FILE = "mcp.json";
|
|
18
|
+
const GITIGNORE_LINE = ".tina4/";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Write `.tina4/mcp.json` and (if `.git/` exists) append `.tina4/` to .gitignore.
|
|
22
|
+
*
|
|
23
|
+
* Returns true if the discovery file was written or updated.
|
|
24
|
+
*/
|
|
25
|
+
export function writeMcpDiscovery(projectRoot: string, port: number): boolean {
|
|
26
|
+
const root = path.resolve(projectRoot);
|
|
27
|
+
const tinaDir = path.join(root, TINA4_DIR);
|
|
28
|
+
const mcpPath = path.join(tinaDir, MCP_FILE);
|
|
29
|
+
|
|
30
|
+
const portStr = String(
|
|
31
|
+
port || Number(process.env.TINA4_PORT) || Number(process.env.PORT) || 7148,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const desired: McpDiscovery = {
|
|
35
|
+
mcpServers: {
|
|
36
|
+
"tina4-live-docs": {
|
|
37
|
+
url: `http://localhost:${portStr}/__dev/api/mcp`,
|
|
38
|
+
description: "Live API docs for this Tina4 project (framework + user code)",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Idempotent write — compare before overwriting.
|
|
44
|
+
let existing: string | null = null;
|
|
45
|
+
if (fs.existsSync(mcpPath)) {
|
|
46
|
+
try { existing = fs.readFileSync(mcpPath, "utf-8"); } catch { /* fall through */ }
|
|
47
|
+
}
|
|
48
|
+
const desiredJson = JSON.stringify(desired, null, 2) + "\n";
|
|
49
|
+
let wrote = false;
|
|
50
|
+
if (existing !== desiredJson) {
|
|
51
|
+
fs.mkdirSync(tinaDir, { recursive: true });
|
|
52
|
+
fs.writeFileSync(mcpPath, desiredJson, "utf-8");
|
|
53
|
+
wrote = true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Only append .gitignore entry inside a real git repo.
|
|
57
|
+
const gitDir = path.join(root, ".git");
|
|
58
|
+
if (fs.existsSync(gitDir)) {
|
|
59
|
+
const gitignorePath = path.join(root, ".gitignore");
|
|
60
|
+
let contents = "";
|
|
61
|
+
if (fs.existsSync(gitignorePath)) {
|
|
62
|
+
try { contents = fs.readFileSync(gitignorePath, "utf-8"); } catch { /* leave empty */ }
|
|
63
|
+
}
|
|
64
|
+
const lines = contents.split(/\r?\n/);
|
|
65
|
+
const already = lines.some((l) => l.trim() === GITIGNORE_LINE || l.trim() === ".tina4");
|
|
66
|
+
if (!already) {
|
|
67
|
+
const sep = contents.endsWith("\n") || contents === "" ? "" : "\n";
|
|
68
|
+
fs.writeFileSync(gitignorePath, `${contents}${sep}${GITIGNORE_LINE}\n`, "utf-8");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return wrote;
|
|
73
|
+
}
|
|
@@ -116,3 +116,6 @@ export { Plan } from "./plan.js";
|
|
|
116
116
|
export type { PlanStep, ParsedPlan, PlanSummary, ExecutionSummary, CurrentPlan } from "./plan.js";
|
|
117
117
|
export { ProjectIndex } from "./projectIndex.js";
|
|
118
118
|
export type { FileEntry, FileRoute } from "./projectIndex.js";
|
|
119
|
+
export { Docs } from "./docs.js";
|
|
120
|
+
export type { DocsHit, ClassSpec, MethodSpec, IndexEntry, DriftHit } from "./docs.js";
|
|
121
|
+
export { writeMcpDiscovery } from "./docsAutoDiscovery.js";
|
package/packages/core/src/mcp.ts
CHANGED
|
@@ -1332,6 +1332,73 @@ export function registerDevTools(server: McpServer): void {
|
|
|
1332
1332
|
"List this project's declared Node.js dependencies",
|
|
1333
1333
|
schemaFromParams([]),
|
|
1334
1334
|
);
|
|
1335
|
+
|
|
1336
|
+
// ── Live API RAG (Docs) — plan/v3/22-LIVE-API-RAG.md ──────────
|
|
1337
|
+
|
|
1338
|
+
server.registerTool(
|
|
1339
|
+
"api_search",
|
|
1340
|
+
(args) => {
|
|
1341
|
+
try {
|
|
1342
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1343
|
+
const { Docs } = require("./docs.js") as typeof import("./docs.js");
|
|
1344
|
+
return Docs.mcpSearch(
|
|
1345
|
+
(args.query as string) || "",
|
|
1346
|
+
parseInt(String(args.k ?? 5), 10) || 5,
|
|
1347
|
+
undefined,
|
|
1348
|
+
(args.source as string) || "all",
|
|
1349
|
+
Boolean(args.include_private),
|
|
1350
|
+
);
|
|
1351
|
+
} catch (e) {
|
|
1352
|
+
return { error: (e as Error).message };
|
|
1353
|
+
}
|
|
1354
|
+
},
|
|
1355
|
+
"Search the live API index (framework + user code) for matching classes/methods",
|
|
1356
|
+
schemaFromParams([
|
|
1357
|
+
{ name: "query", type: "string" },
|
|
1358
|
+
{ name: "k", type: "integer", default: 5 },
|
|
1359
|
+
{ name: "source", type: "string", default: "all" },
|
|
1360
|
+
{ name: "include_private", type: "boolean", default: false },
|
|
1361
|
+
]),
|
|
1362
|
+
);
|
|
1363
|
+
|
|
1364
|
+
server.registerTool(
|
|
1365
|
+
"api_class",
|
|
1366
|
+
(args) => {
|
|
1367
|
+
try {
|
|
1368
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1369
|
+
const { Docs } = require("./docs.js") as typeof import("./docs.js");
|
|
1370
|
+
const spec = Docs.mcpClass((args.name as string) || "");
|
|
1371
|
+
return spec ?? { error: `class not found: ${args.name}` };
|
|
1372
|
+
} catch (e) {
|
|
1373
|
+
return { error: (e as Error).message };
|
|
1374
|
+
}
|
|
1375
|
+
},
|
|
1376
|
+
"Return the full class spec (methods + properties) for a single class FQN",
|
|
1377
|
+
schemaFromParams([{ name: "name", type: "string" }]),
|
|
1378
|
+
);
|
|
1379
|
+
|
|
1380
|
+
server.registerTool(
|
|
1381
|
+
"api_method",
|
|
1382
|
+
(args) => {
|
|
1383
|
+
try {
|
|
1384
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1385
|
+
const { Docs } = require("./docs.js") as typeof import("./docs.js");
|
|
1386
|
+
// PHP names the param `class`, Python names it `class_` — Node.js MCP
|
|
1387
|
+
// accepts the raw `class` field from the JSON-RPC payload.
|
|
1388
|
+
const cls = (args.class as string) || (args.class_name as string) || "";
|
|
1389
|
+
const name = (args.name as string) || "";
|
|
1390
|
+
const spec = Docs.mcpMethod(cls, name);
|
|
1391
|
+
return spec ?? { error: `method not found: ${cls}.${name}` };
|
|
1392
|
+
} catch (e) {
|
|
1393
|
+
return { error: (e as Error).message };
|
|
1394
|
+
}
|
|
1395
|
+
},
|
|
1396
|
+
"Return the full spec for a single method (signature, file, line, visibility)",
|
|
1397
|
+
schemaFromParams([
|
|
1398
|
+
{ name: "class", type: "string" },
|
|
1399
|
+
{ name: "name", type: "string" },
|
|
1400
|
+
]),
|
|
1401
|
+
);
|
|
1335
1402
|
}
|
|
1336
1403
|
|
|
1337
1404
|
/** Alias for registerDevTools — parity with PHP/Ruby/Python. */
|
|
@@ -715,6 +715,13 @@ ${reset}
|
|
|
715
715
|
if (DevAdmin.isEnabled()) {
|
|
716
716
|
DevAdmin.register(router);
|
|
717
717
|
console.log(` Dev dashboard at \x1b[36mhttp://localhost:${port}/__dev\x1b[0m`);
|
|
718
|
+
// Live Docs MCP discovery — write .tina4/mcp.json so AI tools find this server.
|
|
719
|
+
try {
|
|
720
|
+
const { writeMcpDiscovery } = await import("./docsAutoDiscovery.js");
|
|
721
|
+
writeMcpDiscovery(process.cwd(), Number(port));
|
|
722
|
+
} catch (e) {
|
|
723
|
+
console.log(` (mcp discovery skipped: ${(e as Error).message})`);
|
|
724
|
+
}
|
|
718
725
|
}
|
|
719
726
|
|
|
720
727
|
async function dispatch(rawReq: IncomingMessage, rawRes: ServerResponse): Promise<void> {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import type { DatabaseAdapter, DatabaseResult as DatabaseWriteResult } from "./types.js";
|
|
2
3
|
import { DatabaseResult } from "./databaseResult.js";
|
|
3
4
|
|
|
@@ -256,6 +257,23 @@ export class Database {
|
|
|
256
257
|
/** Database engine type (sqlite, postgres, mysql, mssql, firebird) */
|
|
257
258
|
private dbType: string = "sqlite";
|
|
258
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Async-local storage for the adapter pinned to the current transaction.
|
|
262
|
+
*
|
|
263
|
+
* With pooling enabled, ordinary calls round-robin through the pool. Inside
|
|
264
|
+
* a transaction, however, all calls must land on the SAME adapter — otherwise
|
|
265
|
+
* startTransaction(), execute() and commit() each rotate to a different
|
|
266
|
+
* connection and the transaction is meaningless (executes autocommit on
|
|
267
|
+
* whatever adapter they hit; the final commit lands on yet another adapter
|
|
268
|
+
* that has nothing to commit; rollback() is silently no-op'd).
|
|
269
|
+
*
|
|
270
|
+
* AsyncLocalStorage is the Node analog of Python's threading.local. It pins
|
|
271
|
+
* the adapter to the current async task tree so concurrent transactions on
|
|
272
|
+
* the same Database don't clobber each other. startTransaction() sets the
|
|
273
|
+
* pin via .enterWith(); commit()/rollback() clear it.
|
|
274
|
+
*/
|
|
275
|
+
private txStore: AsyncLocalStorage<{ adapter: DatabaseAdapter | null }> = new AsyncLocalStorage();
|
|
276
|
+
|
|
259
277
|
/**
|
|
260
278
|
* Create a Database wrapping an existing adapter.
|
|
261
279
|
* For creating a Database from a URL, use the async static factories:
|
|
@@ -320,8 +338,15 @@ export class Database {
|
|
|
320
338
|
|
|
321
339
|
/**
|
|
322
340
|
* Get the next adapter — from pool (round-robin) or single connection.
|
|
341
|
+
*
|
|
342
|
+
* If a transaction is active (an adapter is pinned in async-local storage),
|
|
343
|
+
* that adapter is returned for every call so the whole transaction is
|
|
344
|
+
* atomic on one connection. Otherwise pooled mode round-robins.
|
|
323
345
|
*/
|
|
324
346
|
private getNextAdapter(): DatabaseAdapter {
|
|
347
|
+
const pinned = this.txStore.getStore()?.adapter;
|
|
348
|
+
if (pinned) return pinned;
|
|
349
|
+
|
|
325
350
|
if (this._poolSize > 0) {
|
|
326
351
|
const idx = this.poolIndex;
|
|
327
352
|
this.poolIndex = (this.poolIndex + 1) % this._poolSize;
|
|
@@ -457,19 +482,37 @@ export class Database {
|
|
|
457
482
|
}
|
|
458
483
|
}
|
|
459
484
|
|
|
460
|
-
/**
|
|
485
|
+
/**
|
|
486
|
+
* Start a transaction. Pins the adapter to the current async context for
|
|
487
|
+
* the whole transaction so executes and the final commit/rollback all run
|
|
488
|
+
* on the same connection (critical when pool > 0).
|
|
489
|
+
*/
|
|
461
490
|
startTransaction(): void {
|
|
462
|
-
|
|
491
|
+
// Pick an adapter using the normal selection logic, then pin it.
|
|
492
|
+
const adapter = this.getNextAdapter();
|
|
493
|
+
let store = this.txStore.getStore();
|
|
494
|
+
if (store) {
|
|
495
|
+
store.adapter = adapter;
|
|
496
|
+
} else {
|
|
497
|
+
this.txStore.enterWith({ adapter });
|
|
498
|
+
}
|
|
499
|
+
adapter.startTransaction();
|
|
463
500
|
}
|
|
464
501
|
|
|
465
|
-
/** Commit the current transaction. */
|
|
502
|
+
/** Commit the current transaction and release the adapter pin. */
|
|
466
503
|
commit(): void {
|
|
467
|
-
this.getNextAdapter()
|
|
504
|
+
const adapter = this.getNextAdapter();
|
|
505
|
+
adapter.commit();
|
|
506
|
+
const store = this.txStore.getStore();
|
|
507
|
+
if (store) store.adapter = null;
|
|
468
508
|
}
|
|
469
509
|
|
|
470
|
-
/** Rollback the current transaction. */
|
|
510
|
+
/** Rollback the current transaction and release the adapter pin. */
|
|
471
511
|
rollback(): void {
|
|
472
|
-
this.getNextAdapter()
|
|
512
|
+
const adapter = this.getNextAdapter();
|
|
513
|
+
adapter.rollback();
|
|
514
|
+
const store = this.txStore.getStore();
|
|
515
|
+
if (store) store.adapter = null;
|
|
473
516
|
}
|
|
474
517
|
|
|
475
518
|
/** Check if a table exists. */
|