@dypai-ai/mcp 1.5.12 → 1.5.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dypai-ai/mcp",
3
- "version": "1.5.12",
3
+ "version": "1.5.14",
4
4
  "description": "DYPAI MCP Server — AI agent toolkit for building and deploying full-stack apps",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -1121,7 +1121,7 @@ async function handleRequest(msg) {
1121
1121
  return makeResponse(id, {
1122
1122
  protocolVersion: "2024-11-05",
1123
1123
  capabilities: { tools: {} },
1124
- serverInfo: { name: "dypai", version: "1.5.12" },
1124
+ serverInfo: { name: "dypai", version: "1.5.14" },
1125
1125
  instructions: SERVER_INSTRUCTIONS,
1126
1126
  })
1127
1127
  }
@@ -1,9 +1,14 @@
1
+ import crypto from "crypto"
1
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, copyFileSync } from "fs"
3
+ import { tmpdir } from "os"
2
4
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "path"
3
5
  import { fileURLToPath } from "url"
6
+ import { proxyToolCall } from "./proxy.js"
4
7
 
5
8
  const __dirname = dirname(fileURLToPath(import.meta.url))
6
9
  const DEFAULT_LIMIT = 5
10
+ const DEFAULT_R2_BUCKET = "organizations-storage"
11
+ const DEFAULT_R2_PREFIX = "capability-kits"
7
12
 
8
13
  function readJson(path) {
9
14
  return JSON.parse(readFileSync(path, "utf8"))
@@ -46,10 +51,220 @@ function resolveKitsRoot(inputRoot) {
46
51
  if (fromThisFile) return join(fromThisFile, "dypai-capability-kits")
47
52
 
48
53
  throw new Error(
49
- "Capability kit repo not found. Set DYPAI_CAPABILITY_KITS_ROOT to the local dypai-capability-kits directory.",
54
+ "Capability kit repo not found locally. Set DYPAI_CAPABILITY_KITS_ROOT for local authoring or configure R2 credentials for remote kit install.",
50
55
  )
51
56
  }
52
57
 
58
+ function tryResolveKitsRoot(inputRoot) {
59
+ try {
60
+ return resolveKitsRoot(inputRoot)
61
+ } catch {
62
+ return null
63
+ }
64
+ }
65
+
66
+ function hasLocalKit(root, slug, version) {
67
+ return Boolean(root) && existsSync(join(root, "kits", slug, version, "kit.json"))
68
+ }
69
+
70
+ function sha256Buffer(buffer) {
71
+ return crypto.createHash("sha256").update(buffer).digest("hex")
72
+ }
73
+
74
+ function safeLogicalPath(path) {
75
+ if (typeof path !== "string" || !path.trim()) throw new Error("Remote kit file path is invalid")
76
+ const normalized = path.replace(/\\/g, "/").replace(/^\/+/, "")
77
+ if (!normalized || normalized.includes("..") || isAbsolute(normalized)) {
78
+ throw new Error(`Unsafe remote kit file path: ${path}`)
79
+ }
80
+ return normalized
81
+ }
82
+
83
+ function parseR2SourceUri(sourceUri, slug) {
84
+ if (typeof sourceUri !== "string" || !sourceUri.startsWith("r2://")) return null
85
+ const withoutScheme = sourceUri.slice("r2://".length)
86
+ const slash = withoutScheme.indexOf("/")
87
+ if (slash <= 0) throw new Error(`Invalid capability kit source_uri: ${sourceUri}`)
88
+ const bucket = withoutScheme.slice(0, slash)
89
+ const rootKey = withoutScheme.slice(slash + 1).replace(/^\/+|\/+$/g, "")
90
+ if (!rootKey || !rootKey.split("/").includes(slug)) {
91
+ throw new Error(`Capability kit source_uri does not look like a kit root for ${slug}: ${sourceUri}`)
92
+ }
93
+ return { bucket, rootKey }
94
+ }
95
+
96
+ function resolveRemoteKitLocation(slug, sourceUri) {
97
+ const parsed = parseR2SourceUri(sourceUri, slug)
98
+ if (parsed) return parsed
99
+ const bucket = process.env.CAPABILITY_KITS_R2_BUCKET
100
+ || process.env.DYPAI_CAPABILITY_KITS_R2_BUCKET
101
+ || process.env.CLOUDFLARE_R2_CAPABILITY_KITS_BUCKET
102
+ || DEFAULT_R2_BUCKET
103
+ const prefix = (process.env.CAPABILITY_KITS_R2_PREFIX || DEFAULT_R2_PREFIX).replace(/^\/+|\/+$/g, "")
104
+ return {
105
+ bucket,
106
+ rootKey: `${prefix}/${slug}`.replace(/^\/+|\/+$/g, "").replace(/\/+/g, "/"),
107
+ }
108
+ }
109
+
110
+ function resolveR2Config(slug, sourceUri) {
111
+ const endpoint = (process.env.CLOUDFLARE_R2_ENDPOINT_URL || "").replace(/\/$/, "")
112
+ const accessKeyId = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID || ""
113
+ const secretAccessKey = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY || ""
114
+ if (!endpoint || !accessKeyId || !secretAccessKey) {
115
+ throw new Error("Capability kit R2 storage is not configured. Set CLOUDFLARE_R2_ENDPOINT_URL, CLOUDFLARE_R2_ACCESS_KEY_ID, and CLOUDFLARE_R2_SECRET_ACCESS_KEY.")
116
+ }
117
+ return { endpoint, accessKeyId, secretAccessKey, ...resolveRemoteKitLocation(slug, sourceUri) }
118
+ }
119
+
120
+ function hmac(key, value, encoding) {
121
+ return crypto.createHmac("sha256", key).update(value).digest(encoding)
122
+ }
123
+
124
+ function sha256Hex(value) {
125
+ return crypto.createHash("sha256").update(value).digest("hex")
126
+ }
127
+
128
+ function encodeRfc3986(value) {
129
+ return encodeURIComponent(value).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)
130
+ }
131
+
132
+ function r2Url(config, key) {
133
+ const encodedKey = key.split("/").map(encodeRfc3986).join("/")
134
+ return new URL(`${config.endpoint}/${encodeRfc3986(config.bucket)}/${encodedKey}`)
135
+ }
136
+
137
+ function signingKey(secretAccessKey, dateStamp) {
138
+ const dateKey = hmac(`AWS4${secretAccessKey}`, dateStamp)
139
+ const dateRegionKey = hmac(dateKey, "auto")
140
+ const dateRegionServiceKey = hmac(dateRegionKey, "s3")
141
+ return hmac(dateRegionServiceKey, "aws4_request")
142
+ }
143
+
144
+ function signedR2Headers(config, method, url, payloadHash) {
145
+ const now = new Date()
146
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "")
147
+ const dateStamp = amzDate.slice(0, 8)
148
+ const canonicalHeaders = [
149
+ `host:${url.host}`,
150
+ `x-amz-content-sha256:${payloadHash}`,
151
+ `x-amz-date:${amzDate}`,
152
+ "",
153
+ ].join("\n")
154
+ const signedHeaders = "host;x-amz-content-sha256;x-amz-date"
155
+ const canonicalRequest = [
156
+ method,
157
+ url.pathname,
158
+ url.searchParams.toString(),
159
+ canonicalHeaders,
160
+ signedHeaders,
161
+ payloadHash,
162
+ ].join("\n")
163
+ const credentialScope = `${dateStamp}/auto/s3/aws4_request`
164
+ const stringToSign = [
165
+ "AWS4-HMAC-SHA256",
166
+ amzDate,
167
+ credentialScope,
168
+ sha256Hex(canonicalRequest),
169
+ ].join("\n")
170
+ const signature = hmac(signingKey(config.secretAccessKey, dateStamp), stringToSign, "hex")
171
+
172
+ return {
173
+ Authorization: `AWS4-HMAC-SHA256 Credential=${config.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
174
+ "x-amz-content-sha256": payloadHash,
175
+ "x-amz-date": amzDate,
176
+ }
177
+ }
178
+
179
+ async function r2Get(config, key) {
180
+ const url = r2Url(config, key)
181
+ const response = await fetch(url, {
182
+ method: "GET",
183
+ headers: signedR2Headers(config, "GET", url, sha256Hex(Buffer.alloc(0))),
184
+ })
185
+ if (!response.ok) {
186
+ throw new Error(`R2 GET failed for ${key}: ${response.status} ${await response.text()}`)
187
+ }
188
+ return Buffer.from(await response.arrayBuffer())
189
+ }
190
+
191
+ function parseRemoteFilesManifest(value, slug, requestedVersion) {
192
+ const parsed = JSON.parse(value)
193
+ if (!isObject(parsed)) throw new Error("Remote kit manifest must be a JSON object")
194
+ if (parsed.slug && parsed.slug !== slug) throw new Error(`Remote kit manifest slug mismatch: expected ${slug}, got ${parsed.slug}`)
195
+ if (parsed.version && parsed.version !== requestedVersion) {
196
+ throw new Error(`Remote kit manifest version mismatch: expected ${requestedVersion}, got ${parsed.version}`)
197
+ }
198
+ if (!Array.isArray(parsed.files) || !parsed.files.length) {
199
+ throw new Error(`Remote kit manifest for ${slug} has no files`)
200
+ }
201
+ return parsed
202
+ }
203
+
204
+ function cachedRemoteKitComplete(kitDirPath, manifest) {
205
+ return (manifest.files || []).every((file) => {
206
+ try {
207
+ const rel = safeLogicalPath(file.path)
208
+ const path = join(kitDirPath, rel)
209
+ if (!existsSync(path)) return false
210
+ return file.sha256 ? sha256Buffer(readFileSync(path)) === file.sha256 : true
211
+ } catch {
212
+ return false
213
+ }
214
+ })
215
+ }
216
+
217
+ function remoteKitKey(config, logicalPath) {
218
+ return `${config.rootKey}/${logicalPath}`.replace(/\/+/g, "/").replace(/^\/+/, "")
219
+ }
220
+
221
+ async function downloadRemoteCapabilityKit(slug, version, sourceUri) {
222
+ const config = resolveR2Config(slug, sourceUri)
223
+ const manifestKey = remoteKitKey(config, "manifest.files.json")
224
+ const manifestText = (await r2Get(config, manifestKey)).toString("utf8")
225
+ const manifest = parseRemoteFilesManifest(manifestText, slug, version)
226
+ const contentHash = typeof manifest.contentHash === "string" && manifest.contentHash.trim()
227
+ ? manifest.contentHash.trim()
228
+ : sha256Buffer(Buffer.from(manifestText))
229
+ const cacheRoot = join(tmpdir(), "dypai-capability-kits", slug, contentHash)
230
+ const kitDirPath = join(cacheRoot, "kits", slug, version)
231
+ const localManifestPath = join(kitDirPath, "manifest.files.json")
232
+
233
+ if (existsSync(localManifestPath) && cachedRemoteKitComplete(kitDirPath, manifest)) {
234
+ return cacheRoot
235
+ }
236
+
237
+ mkdirSync(kitDirPath, { recursive: true })
238
+ for (const file of manifest.files || []) {
239
+ const rel = safeLogicalPath(file.path)
240
+ const buffer = await r2Get(config, remoteKitKey(config, rel))
241
+ if (file.sha256 && sha256Buffer(buffer) !== file.sha256) {
242
+ throw new Error(`Capability kit file hash mismatch for ${slug}/${rel}`)
243
+ }
244
+ const target = join(kitDirPath, rel)
245
+ mkdirSync(dirname(target), { recursive: true })
246
+ writeFileSync(target, buffer)
247
+ }
248
+ writeFileSync(localManifestPath, manifestText)
249
+ return cacheRoot
250
+ }
251
+
252
+ async function resolveKitsRootForKit({ kits_root, slug, version, source_uri }) {
253
+ const sourceMode = (process.env.DYPAI_CAPABILITY_KITS_SOURCE || "").toLowerCase()
254
+ const localRoot = sourceMode === "remote" ? null : tryResolveKitsRoot(kits_root)
255
+ if (hasLocalKit(localRoot, slug, version)) return { root: localRoot, source: "local_repo" }
256
+ const remoteRoot = await downloadRemoteCapabilityKit(slug, version, source_uri)
257
+ return { root: remoteRoot, source: "r2" }
258
+ }
259
+
260
+ function shouldSearchRemote(kitsRoot) {
261
+ const source = (process.env.DYPAI_CAPABILITY_KITS_SOURCE || "").toLowerCase()
262
+ if (source === "local") return false
263
+ if (source === "remote") return true
264
+ if (kitsRoot || process.env.DYPAI_CAPABILITY_KITS_ROOT) return false
265
+ return Boolean(process.env.DYPAI_TOKEN)
266
+ }
267
+
53
268
  function resolveWorkspaceRoot(inputRoot) {
54
269
  if (inputRoot) {
55
270
  const root = resolve(inputRoot)
@@ -306,7 +521,7 @@ function writeTemplateFile({ sourceAbs, targetRel, workspaceRoot, manifest, nami
306
521
  if (overwrite === "fail") throw new Error(`Target already exists: ${targetRel}`)
307
522
  if (overwrite !== "replace" || !ownedTargets(record, manifest.slug, manifest.version).has(targetRel)) {
308
523
  skipped.push(targetRel)
309
- return
524
+ return false
310
525
  }
311
526
  }
312
527
 
@@ -319,6 +534,7 @@ function writeTemplateFile({ sourceAbs, targetRel, workspaceRoot, manifest, nami
319
534
  copyFileSync(sourceAbs, targetAbs)
320
535
  }
321
536
  copied.push(targetRel)
537
+ return true
322
538
  }
323
539
 
324
540
  export const searchCapabilityKitsTool = {
@@ -330,11 +546,24 @@ export const searchCapabilityKitsTool = {
330
546
  query: { type: "string", description: "Feature need and app domain, e.g. booking calendar for hotel reservations." },
331
547
  limit: { type: "integer", default: DEFAULT_LIMIT, minimum: 1, maximum: 10 },
332
548
  filters: { type: "object", description: "Optional filters: category, maturity, app_type, screen_type, feature_tag, requires." },
333
- kits_root: { type: "string", description: "Optional local path to dypai-capability-kits. Defaults to DYPAI_CAPABILITY_KITS_ROOT or sibling repo." },
549
+ kits_root: { type: "string", description: "Optional local path to dypai-capability-kits. When omitted, the tool may use the remote MCP registry so local behavior matches production." },
334
550
  },
335
551
  required: ["query"],
336
552
  },
337
553
  async execute({ query, limit = DEFAULT_LIMIT, filters = {}, kits_root }) {
554
+ if (shouldSearchRemote(kits_root)) {
555
+ try {
556
+ const remote = await proxyToolCall("search_capability_kits", { query, limit, filters })
557
+ if (isObject(remote)) {
558
+ return { ...remote, source: remote.source || "mcp_cloud" }
559
+ }
560
+ return remote
561
+ } catch (error) {
562
+ if (!tryResolveKitsRoot(kits_root)) throw error
563
+ process.stderr.write(`Capability kit remote search failed, falling back to local repo: ${error.message}\n`)
564
+ }
565
+ }
566
+
338
567
  const root = resolveKitsRoot(kits_root)
339
568
  const kits = listKits(root)
340
569
  .filter(({ manifest }) => matchesFilter(manifest, filters))
@@ -368,13 +597,13 @@ export const searchCapabilityKitsTool = {
368
597
  },
369
598
  }
370
599
 
371
- function inspectCapabilityKit({ slug, version = "1.0.0", kits_root }) {
372
- const root = resolveKitsRoot(kits_root)
600
+ async function inspectCapabilityKit({ slug, version = "1.0.0", kits_root, source_uri }) {
601
+ const { root, source } = await resolveKitsRootForKit({ kits_root, slug, version, source_uri })
373
602
  const { dir, manifest } = loadKit(root, slug, version)
374
603
  return {
375
604
  ok: true,
376
605
  operation: "inspect",
377
- source: "local_repo",
606
+ source,
378
607
  kitsRoot: root,
379
608
  kit: manifest,
380
609
  assets: assetIndex(dir),
@@ -395,13 +624,20 @@ async function applyCapabilityKit({
395
624
  overwrite = "skip",
396
625
  workspace_root,
397
626
  kits_root,
627
+ source_uri,
398
628
  }) {
399
- const root = resolveKitsRoot(kits_root)
629
+ const { root, source } = await resolveKitsRootForKit({ kits_root, slug, version, source_uri })
400
630
  const workspaceRoot = resolveWorkspaceRoot(workspace_root)
401
631
  const { dir, manifest } = loadKit(root, slug, version)
402
632
  const record = readInstallRecord(workspaceRoot)
403
633
  const previousEntries = (record.kits || []).filter((item) => item.slug === manifest.slug && item.version === manifest.version)
404
634
  const copied = []
635
+ const copiedByKind = {
636
+ frontend: [],
637
+ backend: [],
638
+ database: [],
639
+ }
640
+ const databaseTargetsBySource = new Map()
405
641
  const skipped = []
406
642
 
407
643
  const installFrontend = install.frontend !== false
@@ -416,7 +652,9 @@ async function applyCapabilityKit({
416
652
  if (target.frontendDir) {
417
653
  targetRel = join(target.frontendDir, stripFrontendPrefix(asset.source)).split(sep).join("/")
418
654
  }
419
- writeTemplateFile({ sourceAbs, targetRel, workspaceRoot, manifest, naming, kind: "frontend", overwrite, record, copied, skipped })
655
+ if (writeTemplateFile({ sourceAbs, targetRel, workspaceRoot, manifest, naming, kind: "frontend", overwrite, record, copied, skipped })) {
656
+ copiedByKind.frontend.push(targetRel)
657
+ }
420
658
  }
421
659
  }
422
660
 
@@ -429,7 +667,9 @@ async function applyCapabilityKit({
429
667
  if (target.endpointDir) {
430
668
  targetRel = join(target.endpointDir, adaptTarget(asset.source.split("/").pop(), manifest, naming)).split(sep).join("/")
431
669
  }
432
- writeTemplateFile({ sourceAbs, targetRel, workspaceRoot, manifest, naming, kind: "backend", overwrite, record, copied, skipped })
670
+ if (writeTemplateFile({ sourceAbs, targetRel, workspaceRoot, manifest, naming, kind: "backend", overwrite, record, copied, skipped })) {
671
+ copiedByKind.backend.push(targetRel)
672
+ }
433
673
  }
434
674
  }
435
675
 
@@ -443,7 +683,10 @@ async function applyCapabilityKit({
443
683
  if (target.databaseDir) {
444
684
  targetRel = join(target.databaseDir, source.split("/").pop()).split(sep).join("/")
445
685
  }
446
- writeTemplateFile({ sourceAbs, targetRel, workspaceRoot, manifest, naming, kind: "database", overwrite, record, copied, skipped })
686
+ if (writeTemplateFile({ sourceAbs, targetRel, workspaceRoot, manifest, naming, kind: "database", overwrite, record, copied, skipped })) {
687
+ copiedByKind.database.push(targetRel)
688
+ databaseTargetsBySource.set(source, targetRel)
689
+ }
447
690
  }
448
691
  }
449
692
 
@@ -460,6 +703,14 @@ async function applyCapabilityKit({
460
703
  const databaseTables = normalizeList(manifest.databaseManifest?.tables).map((table) =>
461
704
  adaptTarget(table, manifest, naming),
462
705
  )
706
+ const schemaSource = installDatabase && isObject(manifest.databaseManifest) && typeof manifest.databaseManifest.schema === "string"
707
+ ? manifest.databaseManifest.schema
708
+ : ""
709
+ const seedSource = installDatabase && isObject(manifest.databaseManifest) && typeof manifest.databaseManifest.seed === "string"
710
+ ? manifest.databaseManifest.seed
711
+ : ""
712
+ const schemaFile = schemaSource ? databaseTargetsBySource.get(schemaSource) : undefined
713
+ const seedFile = seedSource ? databaseTargetsBySource.get(seedSource) : undefined
463
714
 
464
715
  const previousFiles = previousEntries.flatMap((item) => Array.isArray(item.files) ? item.files : [])
465
716
  const fileMap = new Map()
@@ -488,11 +739,15 @@ async function applyCapabilityKit({
488
739
  return {
489
740
  ok: true,
490
741
  operation: "apply",
742
+ source,
491
743
  slug: manifest.slug,
492
744
  version: manifest.version,
493
745
  workspaceRoot,
494
746
  installed: {
495
747
  files: copied,
748
+ frontendFiles: copiedByKind.frontend,
749
+ backendFiles: copiedByKind.backend,
750
+ databaseFiles: copiedByKind.database,
496
751
  skipped,
497
752
  recordFile: ".dypai/kits/installed.json",
498
753
  },
@@ -503,7 +758,12 @@ async function applyCapabilityKit({
503
758
  },
504
759
  database: {
505
760
  tables: databaseTables,
761
+ sqlFiles: copiedByKind.database,
762
+ schemaFile,
763
+ seedFile,
506
764
  requiresExecuteSql: Boolean(manifest.install?.requiresExecuteSql || databaseTables.length),
765
+ applyWith: "execute_sql",
766
+ schemaRefresh: "automatic_after_successful_ddl",
507
767
  },
508
768
  dependencies,
509
769
  nextSteps: [
@@ -511,7 +771,9 @@ async function applyCapabilityKit({
511
771
  ? [`Add any missing package dependencies to the target workspace package.json, then install them locally before building: ${dependencies.join(", ")}.`]
512
772
  : []),
513
773
  "Wire installed frontend components into the selected app page or route.",
514
- ...(databaseTables.length ? ["Apply the installed schema SQL with execute_sql if the tables do not already exist."] : []),
774
+ ...(databaseTables.length ? [
775
+ `Before backend tests, ensure tables exist (${databaseTables.join(", ")}): read ${schemaFile || "the installed schema SQL"} and execute its safe CREATE/ALTER statements with execute_sql. Do not edit dypai/schema.sql; it refreshes automatically.`,
776
+ ] : []),
515
777
  ...(backendEndpoints.length ? ["Run backend validation and endpoint tests for installed/renamed endpoints."] : []),
516
778
  "Run frontend verification after wiring imports and data callbacks.",
517
779
  ],
@@ -527,6 +789,7 @@ export const manageCapabilityKitTool = {
527
789
  operation: { type: "string", enum: ["inspect", "apply"], description: "inspect returns manifest/assets. apply copies kit files into the workspace." },
528
790
  slug: { type: "string" },
529
791
  version: { type: "string", default: "1.0.0" },
792
+ source_uri: { type: "string", description: "Optional r2://... kit root from search results. Used when the kit is not available locally." },
530
793
  targetFeature: { type: "string", description: "Domain/feature being built, e.g. hotel reservations." },
531
794
  install: {
532
795
  type: "object",
@@ -555,7 +818,7 @@ export const manageCapabilityKitTool = {
555
818
  },
556
819
  overwrite: { type: "string", enum: ["skip", "fail", "replace"], default: "skip" },
557
820
  workspace_root: { type: "string", description: "Optional absolute path to the target app workspace." },
558
- kits_root: { type: "string", description: "Optional local path to dypai-capability-kits." },
821
+ kits_root: { type: "string", description: "Optional local path to dypai-capability-kits. Pass this to force local authoring source; omit it to allow R2 fallback." },
559
822
  },
560
823
  required: ["operation", "slug"],
561
824
  },
@@ -397,6 +397,46 @@ function isWordAt(sql, index, word) {
397
397
  return !/[A-Za-z0-9_]/.test(before) && !/[A-Za-z0-9_]/.test(after)
398
398
  }
399
399
 
400
+ function skipDoubleQuotedIdentifier(sql, index) {
401
+ if (sql[index] !== "\"") return index
402
+ for (let i = index + 1; i < sql.length; i++) {
403
+ if (sql[i] !== "\"") continue
404
+ if (sql[i + 1] === "\"") {
405
+ i++
406
+ continue
407
+ }
408
+ return i
409
+ }
410
+ return sql.length - 1
411
+ }
412
+
413
+ function skipSqlSpaces(sql, index) {
414
+ let i = index
415
+ while (i < sql.length && /\s/.test(sql[i])) i++
416
+ return i
417
+ }
418
+
419
+ function findMatchingParen(sql, openIndex) {
420
+ if (sql[openIndex] !== "(") return -1
421
+ let depth = 0
422
+ for (let i = openIndex; i < sql.length; i++) {
423
+ const ch = sql[i]
424
+ if (ch === "\"") {
425
+ i = skipDoubleQuotedIdentifier(sql, i)
426
+ continue
427
+ }
428
+ if (ch === "(") {
429
+ depth++
430
+ continue
431
+ }
432
+ if (ch === ")") {
433
+ depth--
434
+ if (depth === 0) return i
435
+ }
436
+ }
437
+ return -1
438
+ }
439
+
400
440
  function findOuterSelectList(sqlText) {
401
441
  const sql = stripSqlForInference(sqlText)
402
442
  if (!sql) return null
@@ -404,6 +444,10 @@ function findOuterSelectList(sqlText) {
404
444
  let selectStart = -1
405
445
  for (let i = 0; i < sql.length; i++) {
406
446
  const ch = sql[i]
447
+ if (ch === "\"") {
448
+ i = skipDoubleQuotedIdentifier(sql, i)
449
+ continue
450
+ }
407
451
  if (ch === "(") depth++
408
452
  else if (ch === ")") depth = Math.max(0, depth - 1)
409
453
 
@@ -417,15 +461,183 @@ function findOuterSelectList(sqlText) {
417
461
  return sql.slice(selectStart, i).trim()
418
462
  }
419
463
  }
464
+ if (selectStart >= 0) return sql.slice(selectStart).trim()
420
465
  return null
421
466
  }
422
467
 
468
+ function hasTopLevelLimitOne(sqlText) {
469
+ const sql = stripSqlForInference(sqlText)
470
+ let depth = 0
471
+ for (let i = 0; i < sql.length; i++) {
472
+ const ch = sql[i]
473
+ if (ch === "\"") {
474
+ i = skipDoubleQuotedIdentifier(sql, i)
475
+ continue
476
+ }
477
+ if (ch === "(") {
478
+ depth++
479
+ continue
480
+ }
481
+ if (ch === ")") {
482
+ depth = Math.max(0, depth - 1)
483
+ continue
484
+ }
485
+ if (depth !== 0 || !isWordAt(sql, i, "limit")) continue
486
+
487
+ const tail = sql.slice(skipSqlSpaces(sql, i + "limit".length))
488
+ return /^1(?:\D|$)/.test(tail)
489
+ }
490
+ return false
491
+ }
492
+
493
+ function hasTopLevelFetchOne(sqlText) {
494
+ const sql = stripSqlForInference(sqlText)
495
+ let depth = 0
496
+ for (let i = 0; i < sql.length; i++) {
497
+ const ch = sql[i]
498
+ if (ch === "\"") {
499
+ i = skipDoubleQuotedIdentifier(sql, i)
500
+ continue
501
+ }
502
+ if (ch === "(") {
503
+ depth++
504
+ continue
505
+ }
506
+ if (ch === ")") {
507
+ depth = Math.max(0, depth - 1)
508
+ continue
509
+ }
510
+ if (depth !== 0 || !isWordAt(sql, i, "fetch")) continue
511
+
512
+ let cursor = skipSqlSpaces(sql, i + "fetch".length)
513
+ if (isWordAt(sql, cursor, "first")) cursor += "first".length
514
+ else if (isWordAt(sql, cursor, "next")) cursor += "next".length
515
+ else continue
516
+ cursor = skipSqlSpaces(sql, cursor)
517
+ return /^1(?:\D|$)/.test(sql.slice(cursor))
518
+ }
519
+ return false
520
+ }
521
+
522
+ function hasTopLevelGroupBy(sqlText) {
523
+ const sql = stripSqlForInference(sqlText)
524
+ let depth = 0
525
+ for (let i = 0; i < sql.length; i++) {
526
+ const ch = sql[i]
527
+ if (ch === "\"") {
528
+ i = skipDoubleQuotedIdentifier(sql, i)
529
+ continue
530
+ }
531
+ if (ch === "(") {
532
+ depth++
533
+ continue
534
+ }
535
+ if (ch === ")") {
536
+ depth = Math.max(0, depth - 1)
537
+ continue
538
+ }
539
+ if (depth !== 0 || !isWordAt(sql, i, "group")) continue
540
+
541
+ const restStart = i + "group".length
542
+ const rest = sql.slice(restStart)
543
+ const byOffset = rest.search(/\S/)
544
+ if (byOffset >= 0 && isWordAt(sql, restStart + byOffset, "by")) return true
545
+ }
546
+ return false
547
+ }
548
+
549
+ function hasTopLevelSetOperation(sqlText) {
550
+ const sql = stripSqlForInference(sqlText)
551
+ let depth = 0
552
+ for (let i = 0; i < sql.length; i++) {
553
+ const ch = sql[i]
554
+ if (ch === "\"") {
555
+ i = skipDoubleQuotedIdentifier(sql, i)
556
+ continue
557
+ }
558
+ if (ch === "(") {
559
+ depth++
560
+ continue
561
+ }
562
+ if (ch === ")") {
563
+ depth = Math.max(0, depth - 1)
564
+ continue
565
+ }
566
+ if (depth !== 0) continue
567
+ if (isWordAt(sql, i, "union") || isWordAt(sql, i, "intersect") || isWordAt(sql, i, "except")) {
568
+ return true
569
+ }
570
+ }
571
+ return false
572
+ }
573
+
574
+ const AGGREGATE_FUNCTIONS = ["count", "sum", "avg", "min", "max", "json_agg", "jsonb_agg", "array_agg", "string_agg"]
575
+
576
+ function isWindowAggregateCall(sql, openIndex) {
577
+ const closeIndex = findMatchingParen(sql, openIndex)
578
+ if (closeIndex < 0) return false
579
+
580
+ let cursor = skipSqlSpaces(sql, closeIndex + 1)
581
+ if (isWordAt(sql, cursor, "filter")) {
582
+ cursor = skipSqlSpaces(sql, cursor + "filter".length)
583
+ if (sql[cursor] === "(") {
584
+ const filterClose = findMatchingParen(sql, cursor)
585
+ if (filterClose >= 0) cursor = skipSqlSpaces(sql, filterClose + 1)
586
+ }
587
+ }
588
+
589
+ return isWordAt(sql, cursor, "over")
590
+ }
591
+
592
+ function hasOuterAggregateFunction(selectList) {
593
+ const sql = String(selectList || "")
594
+ let depth = 0
595
+ const activeSubqueryDepths = []
596
+
597
+ const insideSubquery = () => activeSubqueryDepths.some(d => d <= depth)
598
+
599
+ for (let i = 0; i < sql.length; i++) {
600
+ const ch = sql[i]
601
+ if (ch === "\"") {
602
+ i = skipDoubleQuotedIdentifier(sql, i)
603
+ continue
604
+ }
605
+ if (ch === "(") {
606
+ depth++
607
+ continue
608
+ }
609
+ if (ch === ")") {
610
+ activeSubqueryDepths.splice(0, activeSubqueryDepths.length, ...activeSubqueryDepths.filter(d => d < depth))
611
+ depth = Math.max(0, depth - 1)
612
+ continue
613
+ }
614
+
615
+ if (depth > 0 && isWordAt(sql, i, "select")) {
616
+ activeSubqueryDepths.push(depth)
617
+ i += "select".length - 1
618
+ continue
619
+ }
620
+
621
+ for (const fn of AGGREGATE_FUNCTIONS) {
622
+ if (!isWordAt(sql, i, fn)) continue
623
+ const openIndex = skipSqlSpaces(sql, i + fn.length)
624
+ if (sql[openIndex] === "(" && !insideSubquery() && !isWindowAggregateCall(sql, openIndex)) return true
625
+ }
626
+ }
627
+
628
+ return false
629
+ }
630
+
423
631
  function splitTopLevelComma(list) {
424
632
  const parts = []
425
633
  let depth = 0
426
634
  let start = 0
427
635
  for (let i = 0; i < list.length; i++) {
428
636
  const ch = list[i]
637
+ if (ch === "\"") {
638
+ i = skipDoubleQuotedIdentifier(list, i)
639
+ continue
640
+ }
429
641
  if (ch === "(") depth++
430
642
  else if (ch === ")") depth = Math.max(0, depth - 1)
431
643
  else if (ch === "," && depth === 0) {
@@ -478,10 +690,12 @@ function inferSelectOutputColumns(sqlText) {
478
690
  function inferSqlCardinality(sqlText) {
479
691
  const sql = stripSqlForInference(sqlText)
480
692
  if (!sql) return null
481
- if (/\blimit\s+1(?:\D|$)/i.test(sql)) return "single"
693
+ if (hasTopLevelLimitOne(sql) || hasTopLevelFetchOne(sql)) return "single"
482
694
  const startsLikeRead = /^(select|with)\b/i.test(sql)
483
- const hasAggregate = /\b(count|sum|avg|min|max|json_agg|jsonb_agg|array_agg|string_agg)\s*\(/i.test(sql)
484
- const hasGroupBy = /\bgroup\s+by\b/i.test(sql)
695
+ if (startsLikeRead && hasTopLevelSetOperation(sql)) return "many"
696
+ const outerSelectList = findOuterSelectList(sql)
697
+ const hasAggregate = outerSelectList ? hasOuterAggregateFunction(outerSelectList) : false
698
+ const hasGroupBy = hasTopLevelGroupBy(sql)
485
699
  if (startsLikeRead && hasAggregate && !hasGroupBy) return "single"
486
700
  return "many"
487
701
  }