tina4-nodejs 3.13.135 → 3.13.137

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 CHANGED
@@ -13,13 +13,13 @@ Even if the skill text is not currently loaded, these are non-negotiable:
13
13
 
14
14
  The full discipline lives in `.claude/skills/tina4-maintainer/SKILL.md`; this block is the always-on floor.
15
15
 
16
- # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.135)
16
+ # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.137)
17
17
 
18
18
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
19
19
 
20
20
  ## What This Project Is
21
21
 
22
- Tina4 for Node.js/TypeScript v3.13.135 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
22
+ Tina4 for Node.js/TypeScript v3.13.137 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
23
23
 
24
24
  The philosophy: zero ceremony, batteries included, file system as source of truth.
25
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.13.135",
3
+ "version": "3.13.137",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - native TypeScript conventions and shared Tina4 contracts",
6
6
  "keywords": [
@@ -12388,6 +12388,10 @@ async function recordApplied(db, name, batch, passed = 1) {
12388
12388
  insertCols.push("migration_id");
12389
12389
  values.push(name);
12390
12390
  }
12391
+ if (cols.has("name")) {
12392
+ insertCols.push("name");
12393
+ values.push(name);
12394
+ }
12391
12395
  if (isFirebirdAdapter(db)) {
12392
12396
  const rows = await adapterQuery(
12393
12397
  db,
@@ -37955,13 +37959,37 @@ function swaggerEnabled() {
37955
37959
  return ["true", "1", "yes", "on"].includes(raw);
37956
37960
  }
37957
37961
  function createSwaggerRoutes(getSpec) {
37962
+ const serveUi = async (_req, res) => {
37963
+ res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
37964
+ };
37958
37965
  return [
37959
37966
  {
37960
37967
  method: "GET",
37961
37968
  pattern: "/swagger",
37962
- handler: async (_req, res) => {
37963
- res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
37964
- }
37969
+ handler: serveUi
37970
+ },
37971
+ {
37972
+ // The trailing-slash form, registered rather than left to fall through.
37973
+ //
37974
+ // Matching "/foo/" against a "/foo" route is opt-in via
37975
+ // TINA4_TRAILING_SLASH_REDIRECT and OFF by default, so /swagger/ missed
37976
+ // this route and was answered by the framework-bundled
37977
+ // public/swagger/index.html instead. That mattered twice over. It used to
37978
+ // be a 200 carrying a permanently empty UI, because the bundled file asked
37979
+ // for an unsubstituted {SWAGGER_ROUTE}/swagger.json -- fixed in that file.
37980
+ // And it is a SECOND Swagger UI implementation: the bundled one hardcodes
37981
+ // cdnjs, while the page this handler renders loads from
37982
+ // TINA4_SWAGGER_UI_CDN, so an air-gapped deployment pointing that at a
37983
+ // local mirror silently kept reaching cdnjs on this one path.
37984
+ //
37985
+ // Registering it keeps the fix inside swagger rather than changing how
37986
+ // every route treats trailing slashes, satisfies the shared contract that
37987
+ // already requires a 200 here, and matches python and ruby, which both
37988
+ // serve /swagger and /swagger/ with no env var set. Excluded from the
37989
+ // generated document by INTERNAL_PREFIXES like /swagger itself.
37990
+ method: "GET",
37991
+ pattern: "/swagger/",
37992
+ handler: serveUi
37965
37993
  },
37966
37994
  {
37967
37995
  method: "GET",
@@ -44682,13 +44710,14 @@ var init_aiClient = __esm({
44682
44710
  }
44683
44711
  static config(capability, options) {
44684
44712
  const provider = (options.provider ?? process.env.TINA4_AI_PROVIDER ?? "local").trim().toLowerCase();
44685
- if (provider !== "local" && provider !== "openai" && provider !== "anthropic") throw new AiConfigError("TINA4_AI_PROVIDER must be local, openai, or anthropic");
44713
+ if (provider !== "local" && provider !== "openai" && provider !== "anthropic" && provider !== "gemini") throw new AiConfigError("TINA4_AI_PROVIDER must be local, openai, anthropic, or gemini");
44686
44714
  const key = process.env.TINA4_AI_KEY || null;
44687
- if ((provider === "openai" || provider === "anthropic") && !key) throw new AiConfigError(`TINA4_AI_KEY is required for the ${provider} provider`);
44715
+ if ((provider === "openai" || provider === "anthropic" || provider === "gemini") && !key) throw new AiConfigError(`TINA4_AI_KEY is required for the ${provider} provider`);
44688
44716
  const defaults = {
44689
44717
  local: ["http://localhost:11437", "llama3.2"],
44690
44718
  openai: ["https://api.openai.com/v1", "gpt-4o-mini"],
44691
- anthropic: ["https://api.anthropic.com/v1", "claude-3-5-haiku-latest"]
44719
+ anthropic: ["https://api.anthropic.com/v1", "claude-3-5-haiku-latest"],
44720
+ gemini: ["https://generativelanguage.googleapis.com/v1beta/openai", "gemini-2.5-flash"]
44692
44721
  };
44693
44722
  const rawUrl = capability === "embed" && process.env.TINA4_EMBED_URL ? process.env.TINA4_EMBED_URL : process.env.TINA4_AI_URL ?? defaults[provider][0];
44694
44723
  const model = (options.model ?? process.env.TINA4_AI_MODEL ?? defaults[provider][1]).trim();
@@ -44706,7 +44735,7 @@ var init_aiClient = __esm({
44706
44735
  }
44707
44736
  if (url.protocol !== "http:" && url.protocol !== "https:") throw new AiConfigError("AI URL must be an http or https URL");
44708
44737
  const path10 = url.pathname.replace(/\/+$/, "");
44709
- if (path10 === "" || path10 === "/v1" || path10 === "/api") {
44738
+ if (path10 === "" || path10 === "/v1" || path10 === "/api" || path10 === "/v1beta/openai") {
44710
44739
  const suffix = provider === "anthropic" ? "/messages" : capability === "embed" ? "/embeddings" : "/chat/completions";
44711
44740
  url.pathname = (path10 || "/v1") + suffix;
44712
44741
  }
@@ -44714,7 +44743,7 @@ var init_aiClient = __esm({
44714
44743
  }
44715
44744
  static headers(config) {
44716
44745
  const headers = { "content-type": "application/json", accept: "application/json" };
44717
- if (config.provider === "openai") headers.authorization = `Bearer ${config.key}`;
44746
+ if (config.provider === "openai" || config.provider === "gemini") headers.authorization = `Bearer ${config.key}`;
44718
44747
  if (config.provider === "anthropic") {
44719
44748
  headers["x-api-key"] = config.key;
44720
44749
  headers["anthropic-version"] = "2023-06-01";
@@ -12387,6 +12387,10 @@ async function recordApplied(db, name, batch, passed = 1) {
12387
12387
  insertCols.push("migration_id");
12388
12388
  values.push(name);
12389
12389
  }
12390
+ if (cols.has("name")) {
12391
+ insertCols.push("name");
12392
+ values.push(name);
12393
+ }
12390
12394
  if (isFirebirdAdapter(db)) {
12391
12395
  const rows = await adapterQuery(
12392
12396
  db,
@@ -37934,13 +37938,37 @@ function swaggerEnabled() {
37934
37938
  return ["true", "1", "yes", "on"].includes(raw);
37935
37939
  }
37936
37940
  function createSwaggerRoutes(getSpec) {
37941
+ const serveUi = async (_req, res) => {
37942
+ res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
37943
+ };
37937
37944
  return [
37938
37945
  {
37939
37946
  method: "GET",
37940
37947
  pattern: "/swagger",
37941
- handler: async (_req, res) => {
37942
- res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
37943
- }
37948
+ handler: serveUi
37949
+ },
37950
+ {
37951
+ // The trailing-slash form, registered rather than left to fall through.
37952
+ //
37953
+ // Matching "/foo/" against a "/foo" route is opt-in via
37954
+ // TINA4_TRAILING_SLASH_REDIRECT and OFF by default, so /swagger/ missed
37955
+ // this route and was answered by the framework-bundled
37956
+ // public/swagger/index.html instead. That mattered twice over. It used to
37957
+ // be a 200 carrying a permanently empty UI, because the bundled file asked
37958
+ // for an unsubstituted {SWAGGER_ROUTE}/swagger.json -- fixed in that file.
37959
+ // And it is a SECOND Swagger UI implementation: the bundled one hardcodes
37960
+ // cdnjs, while the page this handler renders loads from
37961
+ // TINA4_SWAGGER_UI_CDN, so an air-gapped deployment pointing that at a
37962
+ // local mirror silently kept reaching cdnjs on this one path.
37963
+ //
37964
+ // Registering it keeps the fix inside swagger rather than changing how
37965
+ // every route treats trailing slashes, satisfies the shared contract that
37966
+ // already requires a 200 here, and matches python and ruby, which both
37967
+ // serve /swagger and /swagger/ with no env var set. Excluded from the
37968
+ // generated document by INTERNAL_PREFIXES like /swagger itself.
37969
+ method: "GET",
37970
+ pattern: "/swagger/",
37971
+ handler: serveUi
37944
37972
  },
37945
37973
  {
37946
37974
  method: "GET",
@@ -44643,13 +44671,14 @@ var init_aiClient = __esm({
44643
44671
  }
44644
44672
  static config(capability, options) {
44645
44673
  const provider = (options.provider ?? process.env.TINA4_AI_PROVIDER ?? "local").trim().toLowerCase();
44646
- if (provider !== "local" && provider !== "openai" && provider !== "anthropic") throw new AiConfigError("TINA4_AI_PROVIDER must be local, openai, or anthropic");
44674
+ if (provider !== "local" && provider !== "openai" && provider !== "anthropic" && provider !== "gemini") throw new AiConfigError("TINA4_AI_PROVIDER must be local, openai, anthropic, or gemini");
44647
44675
  const key = process.env.TINA4_AI_KEY || null;
44648
- if ((provider === "openai" || provider === "anthropic") && !key) throw new AiConfigError(`TINA4_AI_KEY is required for the ${provider} provider`);
44676
+ if ((provider === "openai" || provider === "anthropic" || provider === "gemini") && !key) throw new AiConfigError(`TINA4_AI_KEY is required for the ${provider} provider`);
44649
44677
  const defaults = {
44650
44678
  local: ["http://localhost:11437", "llama3.2"],
44651
44679
  openai: ["https://api.openai.com/v1", "gpt-4o-mini"],
44652
- anthropic: ["https://api.anthropic.com/v1", "claude-3-5-haiku-latest"]
44680
+ anthropic: ["https://api.anthropic.com/v1", "claude-3-5-haiku-latest"],
44681
+ gemini: ["https://generativelanguage.googleapis.com/v1beta/openai", "gemini-2.5-flash"]
44653
44682
  };
44654
44683
  const rawUrl = capability === "embed" && process.env.TINA4_EMBED_URL ? process.env.TINA4_EMBED_URL : process.env.TINA4_AI_URL ?? defaults[provider][0];
44655
44684
  const model = (options.model ?? process.env.TINA4_AI_MODEL ?? defaults[provider][1]).trim();
@@ -44667,7 +44696,7 @@ var init_aiClient = __esm({
44667
44696
  }
44668
44697
  if (url.protocol !== "http:" && url.protocol !== "https:") throw new AiConfigError("AI URL must be an http or https URL");
44669
44698
  const path10 = url.pathname.replace(/\/+$/, "");
44670
- if (path10 === "" || path10 === "/v1" || path10 === "/api") {
44699
+ if (path10 === "" || path10 === "/v1" || path10 === "/api" || path10 === "/v1beta/openai") {
44671
44700
  const suffix = provider === "anthropic" ? "/messages" : capability === "embed" ? "/embeddings" : "/chat/completions";
44672
44701
  url.pathname = (path10 || "/v1") + suffix;
44673
44702
  }
@@ -44675,7 +44704,7 @@ var init_aiClient = __esm({
44675
44704
  }
44676
44705
  static headers(config) {
44677
44706
  const headers = { "content-type": "application/json", accept: "application/json" };
44678
- if (config.provider === "openai") headers.authorization = `Bearer ${config.key}`;
44707
+ if (config.provider === "openai" || config.provider === "gemini") headers.authorization = `Bearer ${config.key}`;
44679
44708
  if (config.provider === "anthropic") {
44680
44709
  headers["x-api-key"] = config.key;
44681
44710
  headers["anthropic-version"] = "2023-06-01";
@@ -71,7 +71,7 @@
71
71
 
72
72
  // Build a system
73
73
  const ui = SwaggerUIBundle({
74
- url: "{SWAGGER_ROUTE}/swagger.json",
74
+ url: "/swagger/openapi.json",
75
75
  dom_id: '#swagger-ui',
76
76
  deepLinking: true,
77
77
  presets: [
@@ -87,7 +87,7 @@ export interface AiChatOptions {
87
87
  maxTokens?: number;
88
88
  stream?: boolean;
89
89
  timeout?: number;
90
- provider?: "local" | "openai" | "anthropic";
90
+ provider?: "local" | "openai" | "anthropic" | "gemini";
91
91
  /** Tools the model may call. ADR-0061 — translated per provider. */
92
92
  tools?: AiToolDeclaration[];
93
93
  /**
@@ -97,10 +97,10 @@ export interface AiChatOptions {
97
97
  */
98
98
  toolChoice?: AiToolChoice;
99
99
  }
100
- export interface AiEmbedOptions { model?: string; timeout?: number; provider?: "local" | "openai" | "anthropic" }
100
+ export interface AiEmbedOptions { model?: string; timeout?: number; provider?: "local" | "openai" | "anthropic" | "gemini" }
101
101
 
102
102
  interface Config {
103
- provider: "local" | "openai" | "anthropic";
103
+ provider: "local" | "openai" | "anthropic" | "gemini";
104
104
  url: string;
105
105
  model: string;
106
106
  key: string | null;
@@ -288,13 +288,16 @@ export class Ai {
288
288
 
289
289
  private static config(capability: "chat" | "embed", options: AiChatOptions | AiEmbedOptions): Config {
290
290
  const provider = (options.provider ?? process.env.TINA4_AI_PROVIDER ?? "local").trim().toLowerCase();
291
- if (provider !== "local" && provider !== "openai" && provider !== "anthropic") throw new AiConfigError("TINA4_AI_PROVIDER must be local, openai, or anthropic");
291
+ if (provider !== "local" && provider !== "openai" && provider !== "anthropic" && provider !== "gemini") throw new AiConfigError("TINA4_AI_PROVIDER must be local, openai, anthropic, or gemini");
292
292
  const key = process.env.TINA4_AI_KEY || null;
293
- if ((provider === "openai" || provider === "anthropic") && !key) throw new AiConfigError(`TINA4_AI_KEY is required for the ${provider} provider`);
293
+ if ((provider === "openai" || provider === "anthropic" || provider === "gemini") && !key) throw new AiConfigError(`TINA4_AI_KEY is required for the ${provider} provider`);
294
+ // Gemini speaks the OpenAI wire format at its OpenAI-compatible base, so it rides the openai
295
+ // body/parse/stream path - only the base URL, the endpoint suffix append, and the Bearer key differ.
294
296
  const defaults: Record<Config["provider"], [string, string]> = {
295
297
  local: ["http://localhost:11437", "llama3.2"],
296
298
  openai: ["https://api.openai.com/v1", "gpt-4o-mini"],
297
299
  anthropic: ["https://api.anthropic.com/v1", "claude-3-5-haiku-latest"],
300
+ gemini: ["https://generativelanguage.googleapis.com/v1beta/openai", "gemini-2.5-flash"],
298
301
  };
299
302
  const rawUrl = capability === "embed" && process.env.TINA4_EMBED_URL ? process.env.TINA4_EMBED_URL : (process.env.TINA4_AI_URL ?? defaults[provider][0]);
300
303
  const model = (options.model ?? process.env.TINA4_AI_MODEL ?? defaults[provider][1]).trim();
@@ -309,7 +312,11 @@ export class Ai {
309
312
  try { url = new URL(value); } catch { throw new AiConfigError("AI URL must be an http or https URL"); }
310
313
  if (url.protocol !== "http:" && url.protocol !== "https:") throw new AiConfigError("AI URL must be an http or https URL");
311
314
  const path = url.pathname.replace(/\/+$/, "");
312
- if (path === "" || path === "/v1" || path === "/api") {
315
+ // "/v1beta/openai" is Gemini's OpenAI-compatible base; append the suffix onto it exactly as
316
+ // onto a bare host / "/v1" / "/api", so the default gemini base resolves to
317
+ // .../v1beta/openai/chat/completions (or /embeddings). A full endpoint URL the caller supplies
318
+ // (any other path) still passes through verbatim.
319
+ if (path === "" || path === "/v1" || path === "/api" || path === "/v1beta/openai") {
313
320
  const suffix = provider === "anthropic" ? "/messages" : capability === "embed" ? "/embeddings" : "/chat/completions";
314
321
  url.pathname = (path || "/v1") + suffix;
315
322
  }
@@ -318,7 +325,7 @@ export class Ai {
318
325
 
319
326
  private static headers(config: Config): Record<string, string> {
320
327
  const headers: Record<string, string> = { "content-type": "application/json", accept: "application/json" };
321
- if (config.provider === "openai") headers.authorization = `Bearer ${config.key}`;
328
+ if (config.provider === "openai" || config.provider === "gemini") headers.authorization = `Bearer ${config.key}`;
322
329
  if (config.provider === "anthropic") { headers["x-api-key"] = config.key!; headers["anthropic-version"] = "2023-06-01"; }
323
330
  return headers;
324
331
  }
@@ -26223,13 +26223,37 @@ function swaggerEnabled() {
26223
26223
  return ["true", "1", "yes", "on"].includes(raw);
26224
26224
  }
26225
26225
  function createSwaggerRoutes(getSpec) {
26226
+ const serveUi = async (_req, res) => {
26227
+ res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
26228
+ };
26226
26229
  return [
26227
26230
  {
26228
26231
  method: "GET",
26229
26232
  pattern: "/swagger",
26230
- handler: async (_req, res) => {
26231
- res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
26232
- }
26233
+ handler: serveUi
26234
+ },
26235
+ {
26236
+ // The trailing-slash form, registered rather than left to fall through.
26237
+ //
26238
+ // Matching "/foo/" against a "/foo" route is opt-in via
26239
+ // TINA4_TRAILING_SLASH_REDIRECT and OFF by default, so /swagger/ missed
26240
+ // this route and was answered by the framework-bundled
26241
+ // public/swagger/index.html instead. That mattered twice over. It used to
26242
+ // be a 200 carrying a permanently empty UI, because the bundled file asked
26243
+ // for an unsubstituted {SWAGGER_ROUTE}/swagger.json -- fixed in that file.
26244
+ // And it is a SECOND Swagger UI implementation: the bundled one hardcodes
26245
+ // cdnjs, while the page this handler renders loads from
26246
+ // TINA4_SWAGGER_UI_CDN, so an air-gapped deployment pointing that at a
26247
+ // local mirror silently kept reaching cdnjs on this one path.
26248
+ //
26249
+ // Registering it keeps the fix inside swagger rather than changing how
26250
+ // every route treats trailing slashes, satisfies the shared contract that
26251
+ // already requires a 200 here, and matches python and ruby, which both
26252
+ // serve /swagger and /swagger/ with no env var set. Excluded from the
26253
+ // generated document by INTERNAL_PREFIXES like /swagger itself.
26254
+ method: "GET",
26255
+ pattern: "/swagger/",
26256
+ handler: serveUi
26233
26257
  },
26234
26258
  {
26235
26259
  method: "GET",
@@ -33463,13 +33487,14 @@ var init_aiClient = __esm({
33463
33487
  }
33464
33488
  static config(capability, options) {
33465
33489
  const provider = (options.provider ?? process.env.TINA4_AI_PROVIDER ?? "local").trim().toLowerCase();
33466
- if (provider !== "local" && provider !== "openai" && provider !== "anthropic") throw new AiConfigError("TINA4_AI_PROVIDER must be local, openai, or anthropic");
33490
+ if (provider !== "local" && provider !== "openai" && provider !== "anthropic" && provider !== "gemini") throw new AiConfigError("TINA4_AI_PROVIDER must be local, openai, anthropic, or gemini");
33467
33491
  const key = process.env.TINA4_AI_KEY || null;
33468
- if ((provider === "openai" || provider === "anthropic") && !key) throw new AiConfigError(`TINA4_AI_KEY is required for the ${provider} provider`);
33492
+ if ((provider === "openai" || provider === "anthropic" || provider === "gemini") && !key) throw new AiConfigError(`TINA4_AI_KEY is required for the ${provider} provider`);
33469
33493
  const defaults = {
33470
33494
  local: ["http://localhost:11437", "llama3.2"],
33471
33495
  openai: ["https://api.openai.com/v1", "gpt-4o-mini"],
33472
- anthropic: ["https://api.anthropic.com/v1", "claude-3-5-haiku-latest"]
33496
+ anthropic: ["https://api.anthropic.com/v1", "claude-3-5-haiku-latest"],
33497
+ gemini: ["https://generativelanguage.googleapis.com/v1beta/openai", "gemini-2.5-flash"]
33473
33498
  };
33474
33499
  const rawUrl = capability === "embed" && process.env.TINA4_EMBED_URL ? process.env.TINA4_EMBED_URL : process.env.TINA4_AI_URL ?? defaults[provider][0];
33475
33500
  const model = (options.model ?? process.env.TINA4_AI_MODEL ?? defaults[provider][1]).trim();
@@ -33487,7 +33512,7 @@ var init_aiClient = __esm({
33487
33512
  }
33488
33513
  if (url.protocol !== "http:" && url.protocol !== "https:") throw new AiConfigError("AI URL must be an http or https URL");
33489
33514
  const path10 = url.pathname.replace(/\/+$/, "");
33490
- if (path10 === "" || path10 === "/v1" || path10 === "/api") {
33515
+ if (path10 === "" || path10 === "/v1" || path10 === "/api" || path10 === "/v1beta/openai") {
33491
33516
  const suffix = provider === "anthropic" ? "/messages" : capability === "embed" ? "/embeddings" : "/chat/completions";
33492
33517
  url.pathname = (path10 || "/v1") + suffix;
33493
33518
  }
@@ -33495,7 +33520,7 @@ var init_aiClient = __esm({
33495
33520
  }
33496
33521
  static headers(config) {
33497
33522
  const headers = { "content-type": "application/json", accept: "application/json" };
33498
- if (config.provider === "openai") headers.authorization = `Bearer ${config.key}`;
33523
+ if (config.provider === "openai" || config.provider === "gemini") headers.authorization = `Bearer ${config.key}`;
33499
33524
  if (config.provider === "anthropic") {
33500
33525
  headers["x-api-key"] = config.key;
33501
33526
  headers["anthropic-version"] = "2023-06-01";
@@ -41775,6 +41800,10 @@ async function recordApplied(db, name, batch, passed = 1) {
41775
41800
  insertCols.push("migration_id");
41776
41801
  values.push(name);
41777
41802
  }
41803
+ if (cols.has("name")) {
41804
+ insertCols.push("name");
41805
+ values.push(name);
41806
+ }
41778
41807
  if (isFirebirdAdapter(db)) {
41779
41808
  const rows = await adapterQuery(
41780
41809
  db,
@@ -524,6 +524,19 @@ async function recordApplied(
524
524
  values.push(name);
525
525
  }
526
526
 
527
+ // A table created by an OLDER Node v3 (<= 3.13.54) carries `name VARCHAR(500)
528
+ // NOT NULL` (the pre-3.13.55 tracking DDL — see git history). upgradeMigrationTable()
529
+ // copies its values into migration_name and leaves the `name` column in place
530
+ // (SQLite cannot drop a NOT NULL column without a full rebuild). Exactly like the
531
+ // python#93 `migration_id` case above, an insert that omits `name` fails its
532
+ // NOT NULL constraint, wedging EVERY migration recorded after the upgrade — so
533
+ // populate it here too, mirroring migration_name. A fresh canonical table has no
534
+ // `name` column, so this only fires on an upgraded legacy table.
535
+ if (cols.has("name")) {
536
+ insertCols.push("name");
537
+ values.push(name);
538
+ }
539
+
527
540
  if (isFirebirdAdapter(db)) {
528
541
  // Firebird: generate the id from the sequence.
529
542
  const rows = await adapterQuery<{ next_id: number }>(db,
@@ -521,13 +521,37 @@ function swaggerEnabled() {
521
521
  return ["true", "1", "yes", "on"].includes(raw);
522
522
  }
523
523
  function createSwaggerRoutes(getSpec) {
524
+ const serveUi = async (_req, res) => {
525
+ res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
526
+ };
524
527
  return [
525
528
  {
526
529
  method: "GET",
527
530
  pattern: "/swagger",
528
- handler: async (_req, res) => {
529
- res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
530
- }
531
+ handler: serveUi
532
+ },
533
+ {
534
+ // The trailing-slash form, registered rather than left to fall through.
535
+ //
536
+ // Matching "/foo/" against a "/foo" route is opt-in via
537
+ // TINA4_TRAILING_SLASH_REDIRECT and OFF by default, so /swagger/ missed
538
+ // this route and was answered by the framework-bundled
539
+ // public/swagger/index.html instead. That mattered twice over. It used to
540
+ // be a 200 carrying a permanently empty UI, because the bundled file asked
541
+ // for an unsubstituted {SWAGGER_ROUTE}/swagger.json -- fixed in that file.
542
+ // And it is a SECOND Swagger UI implementation: the bundled one hardcodes
543
+ // cdnjs, while the page this handler renders loads from
544
+ // TINA4_SWAGGER_UI_CDN, so an air-gapped deployment pointing that at a
545
+ // local mirror silently kept reaching cdnjs on this one path.
546
+ //
547
+ // Registering it keeps the fix inside swagger rather than changing how
548
+ // every route treats trailing slashes, satisfies the shared contract that
549
+ // already requires a 200 here, and matches python and ruby, which both
550
+ // serve /swagger and /swagger/ with no env var set. Excluded from the
551
+ // generated document by INTERNAL_PREFIXES like /swagger itself.
552
+ method: "GET",
553
+ pattern: "/swagger/",
554
+ handler: serveUi
531
555
  },
532
556
  {
533
557
  method: "GET",
@@ -56,13 +56,38 @@ export function swaggerEnabled(): boolean {
56
56
  export function createSwaggerRoutes(
57
57
  getSpec: () => unknown
58
58
  ): RouteDefinition[] {
59
+ const serveUi = async (_req: Tina4Request, res: Tina4Response): Promise<void> => {
60
+ res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
61
+ };
62
+
59
63
  return [
60
64
  {
61
65
  method: "GET",
62
66
  pattern: "/swagger",
63
- handler: async (_req: Tina4Request, res: Tina4Response) => {
64
- res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
65
- },
67
+ handler: serveUi,
68
+ },
69
+ {
70
+ // The trailing-slash form, registered rather than left to fall through.
71
+ //
72
+ // Matching "/foo/" against a "/foo" route is opt-in via
73
+ // TINA4_TRAILING_SLASH_REDIRECT and OFF by default, so /swagger/ missed
74
+ // this route and was answered by the framework-bundled
75
+ // public/swagger/index.html instead. That mattered twice over. It used to
76
+ // be a 200 carrying a permanently empty UI, because the bundled file asked
77
+ // for an unsubstituted {SWAGGER_ROUTE}/swagger.json -- fixed in that file.
78
+ // And it is a SECOND Swagger UI implementation: the bundled one hardcodes
79
+ // cdnjs, while the page this handler renders loads from
80
+ // TINA4_SWAGGER_UI_CDN, so an air-gapped deployment pointing that at a
81
+ // local mirror silently kept reaching cdnjs on this one path.
82
+ //
83
+ // Registering it keeps the fix inside swagger rather than changing how
84
+ // every route treats trailing slashes, satisfies the shared contract that
85
+ // already requires a 200 here, and matches python and ruby, which both
86
+ // serve /swagger and /swagger/ with no env var set. Excluded from the
87
+ // generated document by INTERNAL_PREFIXES like /swagger itself.
88
+ method: "GET",
89
+ pattern: "/swagger/",
90
+ handler: serveUi,
66
91
  },
67
92
  {
68
93
  method: "GET",
@@ -113,7 +113,7 @@ export interface AiChatOptions {
113
113
  maxTokens?: number;
114
114
  stream?: boolean;
115
115
  timeout?: number;
116
- provider?: "local" | "openai" | "anthropic";
116
+ provider?: "local" | "openai" | "anthropic" | "gemini";
117
117
  /** Tools the model may call. ADR-0061 — translated per provider. */
118
118
  tools?: AiToolDeclaration[];
119
119
  /**
@@ -126,7 +126,7 @@ export interface AiChatOptions {
126
126
  export interface AiEmbedOptions {
127
127
  model?: string;
128
128
  timeout?: number;
129
- provider?: "local" | "openai" | "anthropic";
129
+ provider?: "local" | "openai" | "anthropic" | "gemini";
130
130
  }
131
131
  export declare class Ai {
132
132
  static chat(messages: AiMessage[], options: AiChatOptions & {