@kaelio/ktx 0.13.0 → 0.14.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.
Files changed (243) hide show
  1. package/assets/python/{kaelio_ktx-0.13.0-py3-none-any.whl → kaelio_ktx-0.14.0-py3-none-any.whl} +0 -0
  2. package/assets/python/manifest.json +4 -4
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/cli-program.js +1 -1
  5. package/dist/commands/ingest-commands.d.ts +9 -0
  6. package/dist/commands/ingest-commands.js +37 -1
  7. package/dist/commands/knowledge-commands.js +3 -0
  8. package/dist/commands/setup-commands.js +14 -1
  9. package/dist/connection-drivers.d.ts +2 -0
  10. package/dist/connection-drivers.js +7 -3
  11. package/dist/connection.d.ts +3 -0
  12. package/dist/connection.js +27 -0
  13. package/dist/connectors/bigquery/connector.d.ts +17 -6
  14. package/dist/connectors/bigquery/connector.js +152 -76
  15. package/dist/connectors/bigquery/dialect.d.ts +2 -2
  16. package/dist/connectors/clickhouse/connector.d.ts +1 -0
  17. package/dist/connectors/clickhouse/connector.js +45 -16
  18. package/dist/connectors/clickhouse/dialect.d.ts +2 -2
  19. package/dist/connectors/mongodb/connector.d.ts +69 -0
  20. package/dist/connectors/mongodb/connector.js +307 -0
  21. package/dist/connectors/mongodb/dialect.d.ts +17 -0
  22. package/dist/connectors/mongodb/dialect.js +50 -0
  23. package/dist/connectors/mongodb/live-database-introspection.d.ts +10 -0
  24. package/dist/connectors/mongodb/live-database-introspection.js +24 -0
  25. package/dist/connectors/mongodb/schema-inference.d.ts +20 -0
  26. package/dist/connectors/mongodb/schema-inference.js +110 -0
  27. package/dist/connectors/mysql/connector.d.ts +1 -0
  28. package/dist/connectors/mysql/connector.js +18 -2
  29. package/dist/connectors/mysql/dialect.d.ts +2 -2
  30. package/dist/connectors/postgres/connector.d.ts +1 -0
  31. package/dist/connectors/postgres/connector.js +20 -3
  32. package/dist/connectors/postgres/dialect.d.ts +2 -2
  33. package/dist/connectors/snowflake/connector.d.ts +1 -0
  34. package/dist/connectors/snowflake/connector.js +27 -3
  35. package/dist/connectors/snowflake/dialect.d.ts +2 -2
  36. package/dist/connectors/sqlite/connector.d.ts +11 -1
  37. package/dist/connectors/sqlite/connector.js +120 -17
  38. package/dist/connectors/sqlite/dialect.d.ts +2 -2
  39. package/dist/connectors/sqlite/read-query-child.d.ts +1 -0
  40. package/dist/connectors/sqlite/read-query-child.js +23 -0
  41. package/dist/connectors/sqlserver/connector.d.ts +2 -0
  42. package/dist/connectors/sqlserver/connector.js +23 -5
  43. package/dist/connectors/sqlserver/dialect.d.ts +2 -2
  44. package/dist/context/cache/content-result-cache.d.ts +36 -0
  45. package/dist/context/cache/content-result-cache.js +14 -0
  46. package/dist/context/cache/sqlite-content-result-cache.d.ts +18 -0
  47. package/dist/context/cache/sqlite-content-result-cache.js +223 -0
  48. package/dist/context/connections/bigquery-identifiers.d.ts +1 -0
  49. package/dist/context/connections/bigquery-identifiers.js +7 -0
  50. package/dist/context/connections/configured-connections.d.ts +9 -0
  51. package/dist/context/connections/configured-connections.js +18 -0
  52. package/dist/context/connections/dialects.d.ts +30 -4
  53. package/dist/context/connections/dialects.js +38 -11
  54. package/dist/context/connections/drivers.js +21 -0
  55. package/dist/context/connections/gdrive-config.d.ts +19 -0
  56. package/dist/context/connections/gdrive-config.js +57 -0
  57. package/dist/context/connections/query-deadline.d.ts +31 -0
  58. package/dist/context/connections/query-deadline.js +37 -0
  59. package/dist/context/connections/read-only-sql.d.ts +5 -0
  60. package/dist/context/connections/read-only-sql.js +139 -1
  61. package/dist/context/ingest/adapters/gdrive/chunk.d.ts +3 -0
  62. package/dist/context/ingest/adapters/gdrive/chunk.js +74 -0
  63. package/dist/context/ingest/adapters/gdrive/detect.d.ts +1 -0
  64. package/dist/context/ingest/adapters/gdrive/detect.js +20 -0
  65. package/dist/context/ingest/adapters/gdrive/fetch.d.ts +6 -0
  66. package/dist/context/ingest/adapters/gdrive/fetch.js +95 -0
  67. package/dist/context/ingest/adapters/gdrive/gdrive-client.d.ts +30 -0
  68. package/dist/context/ingest/adapters/gdrive/gdrive-client.js +132 -0
  69. package/dist/context/ingest/adapters/gdrive/gdrive.adapter.d.ts +11 -0
  70. package/dist/context/ingest/adapters/gdrive/gdrive.adapter.js +27 -0
  71. package/dist/context/ingest/adapters/gdrive/normalize.d.ts +2 -0
  72. package/dist/context/ingest/adapters/gdrive/normalize.js +256 -0
  73. package/dist/context/ingest/adapters/gdrive/types.d.ts +153 -0
  74. package/dist/context/ingest/adapters/gdrive/types.js +36 -0
  75. package/dist/context/ingest/adapters/live-database/daemon-introspection.js +24 -0
  76. package/dist/context/ingest/adapters/live-database/fetch-report.d.ts +8 -0
  77. package/dist/context/ingest/adapters/live-database/fetch-report.js +37 -0
  78. package/dist/context/ingest/adapters/live-database/live-database.adapter.d.ts +2 -1
  79. package/dist/context/ingest/adapters/live-database/live-database.adapter.js +9 -2
  80. package/dist/context/ingest/adapters/live-database/manifest.d.ts +2 -0
  81. package/dist/context/ingest/adapters/live-database/manifest.js +8 -4
  82. package/dist/context/ingest/adapters/live-database/scan-outcome.d.ts +17 -0
  83. package/dist/context/ingest/adapters/live-database/scan-outcome.js +40 -0
  84. package/dist/context/ingest/adapters/live-database/stage.js +5 -5
  85. package/dist/context/ingest/adapters/metabase/types.d.ts +1 -1
  86. package/dist/context/ingest/adapters/metabase/types.js +1 -1
  87. package/dist/context/ingest/artifact-gates.d.ts +32 -1
  88. package/dist/context/ingest/artifact-gates.js +85 -18
  89. package/dist/context/ingest/final-gate-prune.d.ts +35 -0
  90. package/dist/context/ingest/final-gate-prune.js +229 -0
  91. package/dist/context/ingest/ingest-bundle.runner.d.ts +3 -0
  92. package/dist/context/ingest/ingest-bundle.runner.js +459 -240
  93. package/dist/context/ingest/isolated-diff/patch-integrator.d.ts +0 -11
  94. package/dist/context/ingest/isolated-diff/patch-integrator.js +0 -44
  95. package/dist/context/ingest/isolated-diff/work-unit-executor.d.ts +1 -0
  96. package/dist/context/ingest/isolated-diff/work-unit-executor.js +13 -4
  97. package/dist/context/ingest/local-adapters.js +6 -0
  98. package/dist/context/ingest/local-bundle-runtime.js +7 -2
  99. package/dist/context/ingest/local-ingest.js +1 -1
  100. package/dist/context/ingest/local-stage-ingest.d.ts +3 -1
  101. package/dist/context/ingest/local-stage-ingest.js +3 -1
  102. package/dist/context/ingest/ports.d.ts +3 -0
  103. package/dist/context/ingest/report-snapshot.js +13 -3
  104. package/dist/context/ingest/reports.d.ts +3 -3
  105. package/dist/context/ingest/reports.js +3 -1
  106. package/dist/context/ingest/stages/build-wu-context.d.ts +1 -0
  107. package/dist/context/ingest/stages/build-wu-context.js +2 -1
  108. package/dist/context/ingest/stages/stage-3-work-units.d.ts +2 -1
  109. package/dist/context/ingest/stages/stage-3-work-units.js +4 -10
  110. package/dist/context/ingest/stages/validate-wu-sources.d.ts +12 -0
  111. package/dist/context/ingest/stages/validate-wu-sources.js +23 -8
  112. package/dist/context/ingest/tools/read-raw-file.tool.js +10 -5
  113. package/dist/context/ingest/tools/read-raw-span.tool.js +10 -5
  114. package/dist/context/ingest/tools/warehouse-verification/discover-data.tool.js +1 -1
  115. package/dist/context/ingest/tools/warehouse-verification/entity-details.tool.js +1 -1
  116. package/dist/context/ingest/tools/warehouse-verification/sql-execution.tool.js +1 -1
  117. package/dist/context/ingest/types.d.ts +3 -0
  118. package/dist/context/ingest/wiki-body-refs.d.ts +28 -0
  119. package/dist/context/ingest/wiki-body-refs.js +37 -8
  120. package/dist/context/ingest/wiki-sl-ref-repair.d.ts +1 -0
  121. package/dist/context/ingest/wiki-sl-ref-repair.js +4 -0
  122. package/dist/context/ingest/work-unit-cache.d.ts +67 -0
  123. package/dist/context/ingest/work-unit-cache.js +230 -0
  124. package/dist/context/llm/ai-sdk-runtime.d.ts +1 -0
  125. package/dist/context/llm/ai-sdk-runtime.js +5 -0
  126. package/dist/context/llm/claude-code-runtime.d.ts +4 -1
  127. package/dist/context/llm/claude-code-runtime.js +38 -8
  128. package/dist/context/llm/codex-runtime.d.ts +4 -1
  129. package/dist/context/llm/codex-runtime.js +48 -35
  130. package/dist/context/llm/runtime-port.d.ts +26 -0
  131. package/dist/context/llm/subprocess-generate-object-child.d.ts +1 -0
  132. package/dist/context/llm/subprocess-generate-object-child.js +34 -0
  133. package/dist/context/llm/subprocess-generate-object.d.ts +42 -0
  134. package/dist/context/llm/subprocess-generate-object.js +122 -0
  135. package/dist/context/mcp/context-tools.d.ts +2 -0
  136. package/dist/context/mcp/context-tools.js +111 -17
  137. package/dist/context/mcp/local-project-ports.d.ts +7 -0
  138. package/dist/context/mcp/local-project-ports.js +29 -0
  139. package/dist/context/mcp/logger.d.ts +24 -0
  140. package/dist/context/mcp/logger.js +49 -0
  141. package/dist/context/mcp/server.js +2 -0
  142. package/dist/context/mcp/types.d.ts +17 -0
  143. package/dist/context/memory/memory-agent.service.js +1 -1
  144. package/dist/context/project/config.d.ts +42 -0
  145. package/dist/context/project/config.js +5 -0
  146. package/dist/context/project/driver-schemas.d.ts +20 -0
  147. package/dist/context/project/driver-schemas.js +50 -1
  148. package/dist/context/project/project.js +1 -1
  149. package/dist/context/project/setup-config.js +1 -0
  150. package/dist/context/scan/description-generation.d.ts +4 -0
  151. package/dist/context/scan/description-generation.js +100 -13
  152. package/dist/context/scan/enabled-tables.d.ts +5 -0
  153. package/dist/context/scan/enabled-tables.js +13 -1
  154. package/dist/context/scan/enrichment-state.d.ts +50 -7
  155. package/dist/context/scan/enrichment-state.js +30 -13
  156. package/dist/context/scan/local-enrichment-artifacts.d.ts +41 -0
  157. package/dist/context/scan/local-enrichment-artifacts.js +155 -32
  158. package/dist/context/scan/local-enrichment.d.ts +39 -4
  159. package/dist/context/scan/local-enrichment.js +345 -92
  160. package/dist/context/scan/local-scan.d.ts +4 -1
  161. package/dist/context/scan/local-scan.js +54 -13
  162. package/dist/context/scan/local-structural-artifacts.d.ts +3 -1
  163. package/dist/context/scan/local-structural-artifacts.js +5 -0
  164. package/dist/context/scan/object-introspection.d.ts +21 -0
  165. package/dist/context/scan/object-introspection.js +35 -0
  166. package/dist/context/scan/relationship-benchmarks.js +5 -3
  167. package/dist/context/scan/relationship-composite-candidates.d.ts +6 -3
  168. package/dist/context/scan/relationship-composite-candidates.js +13 -1
  169. package/dist/context/scan/relationship-detection-budget.d.ts +35 -0
  170. package/dist/context/scan/relationship-detection-budget.js +53 -0
  171. package/dist/context/scan/relationship-diagnostics.d.ts +5 -0
  172. package/dist/context/scan/relationship-diagnostics.js +2 -0
  173. package/dist/context/scan/relationship-discovery.d.ts +9 -3
  174. package/dist/context/scan/relationship-discovery.js +27 -4
  175. package/dist/context/scan/relationship-llm-proposal.js +36 -27
  176. package/dist/context/scan/relationship-profiling.d.ts +7 -3
  177. package/dist/context/scan/relationship-profiling.js +53 -41
  178. package/dist/context/scan/relationship-validation.d.ts +6 -4
  179. package/dist/context/scan/relationship-validation.js +46 -32
  180. package/dist/context/scan/sqlite-local-enrichment-state-store.d.ts +8 -1
  181. package/dist/context/scan/sqlite-local-enrichment-state-store.js +71 -159
  182. package/dist/context/scan/types.d.ts +5 -3
  183. package/dist/context/scan/types.js +2 -0
  184. package/dist/context/sl/local-query.js +5 -0
  185. package/dist/context/sl/pglite-sl-search-prototype.js +1 -1
  186. package/dist/context/sl/semantic-layer.service.js +1 -1
  187. package/dist/context/sl/source-files.d.ts +5 -0
  188. package/dist/context/sl/source-files.js +17 -11
  189. package/dist/context/sl/tools/connection-id-schema.js +1 -1
  190. package/dist/context/sql-analysis/dialect-notes.d.ts +9 -0
  191. package/dist/context/sql-analysis/dialect-notes.js +40 -0
  192. package/dist/context/sql-analysis/dialects/bigquery.md +13 -0
  193. package/dist/context/sql-analysis/dialects/clickhouse.md +9 -0
  194. package/dist/context/sql-analysis/dialects/mysql.md +9 -0
  195. package/dist/context/sql-analysis/dialects/postgres.md +10 -0
  196. package/dist/context/sql-analysis/dialects/snowflake.md +10 -0
  197. package/dist/context/sql-analysis/dialects/sqlite.md +11 -0
  198. package/dist/context/sql-analysis/dialects/tsql.md +10 -0
  199. package/dist/context/wiki/keys.js +1 -1
  200. package/dist/context/wiki/knowledge-wiki.service.d.ts +4 -4
  201. package/dist/context/wiki/knowledge-wiki.service.js +2 -1
  202. package/dist/context/wiki/local-knowledge.d.ts +13 -0
  203. package/dist/context/wiki/local-knowledge.js +50 -6
  204. package/dist/context/wiki/sqlite-knowledge-index.d.ts +2 -0
  205. package/dist/context/wiki/sqlite-knowledge-index.js +21 -5
  206. package/dist/context/wiki/tools/wiki-write.tool.d.ts +2 -0
  207. package/dist/context/wiki/tools/wiki-write.tool.js +27 -0
  208. package/dist/context/wiki/types.d.ts +6 -0
  209. package/dist/context-build-view.d.ts +3 -0
  210. package/dist/context-build-view.js +1 -0
  211. package/dist/knowledge.d.ts +2 -0
  212. package/dist/knowledge.js +12 -1
  213. package/dist/local-adapters.js +11 -0
  214. package/dist/mcp-http-server.js +15 -1
  215. package/dist/mcp-server-factory.d.ts +2 -0
  216. package/dist/mcp-server-factory.js +15 -1
  217. package/dist/mcp-stdio-server.js +10 -2
  218. package/dist/notion-page-picker.js +1 -1
  219. package/dist/prompts/memory_agent_external_ingest.md +2 -0
  220. package/dist/public-ingest.d.ts +3 -1
  221. package/dist/public-ingest.js +3 -0
  222. package/dist/scan.d.ts +3 -1
  223. package/dist/scan.js +7 -0
  224. package/dist/setup-databases.d.ts +1 -1
  225. package/dist/setup-databases.js +43 -1
  226. package/dist/setup-sources.d.ts +5 -1
  227. package/dist/setup-sources.js +124 -48
  228. package/dist/setup.d.ts +3 -0
  229. package/dist/setup.js +6 -1
  230. package/dist/skills/analytics/SKILL.md +200 -2
  231. package/dist/skills/gdrive_synthesize/SKILL.md +97 -0
  232. package/dist/skills/wiki_capture/SKILL.md +24 -0
  233. package/dist/sql.js +4 -0
  234. package/dist/status-project.d.ts +4 -0
  235. package/dist/status-project.js +54 -2
  236. package/dist/telemetry/events.d.ts +2 -2
  237. package/dist/text-ingest.d.ts +4 -0
  238. package/dist/text-ingest.js +58 -29
  239. package/dist/verbatim-ingest.d.ts +46 -0
  240. package/dist/verbatim-ingest.js +193 -0
  241. package/package.json +5 -1
  242. package/dist/context/ingest/final-gate-repair.d.ts +0 -28
  243. package/dist/context/ingest/final-gate-repair.js +0 -91
@@ -3,8 +3,11 @@ import { tmpdir } from 'node:os';
3
3
  import { join, relative, resolve } from 'node:path';
4
4
  import { fileURLToPath, pathToFileURL } from 'node:url';
5
5
  import { localConnectionTypeForConfig } from './context/connections/local-warehouse-descriptor.js';
6
+ import { parseGdriveConnectionConfig, resolveGdriveServiceAccountKey, } from './context/connections/gdrive-config.js';
6
7
  import { resolveNotionConnectionAuthToken } from './context/connections/notion-config.js';
7
8
  import { resolveKtxConfigReference } from './context/core/config-reference.js';
9
+ import { createGoogleDocsClients, verifyGdriveFolderAndCountDocs, } from './context/ingest/adapters/gdrive/gdrive-client.js';
10
+ import { gdriveServiceAccountKeySchema } from './context/ingest/adapters/gdrive/types.js';
8
11
  import { cloneOrPull, testRepoConnection } from './context/ingest/repo-fetch.js';
9
12
  import { DEFAULT_METABASE_CLIENT_CONFIG, MetabaseClient } from './context/ingest/adapters/metabase/client.js';
10
13
  import { discoverMetabaseDatabases } from './context/ingest/adapters/metabase/mapping.js';
@@ -35,6 +38,7 @@ const SOURCE_OPTIONS = [
35
38
  { value: 'metricflow', label: 'MetricFlow' },
36
39
  { value: 'looker', label: 'Looker' },
37
40
  { value: 'lookml', label: 'LookML' },
41
+ { value: 'gdrive', label: 'Google Drive' },
38
42
  ];
39
43
  const SOURCE_LABELS = Object.fromEntries(SOURCE_OPTIONS.map((option) => [option.value, option.label]));
40
44
  const PRIMARY_SOURCE_DRIVERS = new Set([
@@ -120,6 +124,7 @@ const SOURCE_CREDENTIAL_FLAG = {
120
124
  notion: { field: 'sourceAuthTokenRef', flag: '--source-auth-token-ref' },
121
125
  metabase: { field: 'sourceApiKeyRef', flag: '--source-api-key-ref' },
122
126
  looker: { field: 'sourceClientSecretRef', flag: '--source-client-secret-ref' },
127
+ gdrive: { field: null, flag: '--gdrive-service-account-key-ref' },
123
128
  };
124
129
  const ALL_SOURCE_CREDENTIAL_FLAGS = [
125
130
  { field: 'sourceAuthTokenRef', flag: '--source-auth-token-ref' },
@@ -417,6 +422,18 @@ function buildNotionConnection(args) {
417
422
  max_knowledge_updates_per_run: 20,
418
423
  };
419
424
  }
425
+ function buildGdriveConnection(args) {
426
+ const folderId = args.gdriveFolderId?.trim();
427
+ if (!folderId) {
428
+ throw new Error('Google Drive setup requires --gdrive-folder-id.');
429
+ }
430
+ return {
431
+ driver: 'gdrive',
432
+ service_account_key_ref: credentialRef(args.gdriveServiceAccountKeyRef, 'Google Drive service account key ref'),
433
+ folder_id: folderId,
434
+ recursive: args.gdriveRecursive === true,
435
+ };
436
+ }
420
437
  function sourcePathFromFileRepoUrl(repoUrl, subpath) {
421
438
  const root = fileURLToPath(repoUrl);
422
439
  return subpath ? join(root, subpath) : root;
@@ -527,6 +544,13 @@ async function defaultValidateNotion(connection) {
527
544
  }
528
545
  return { ok: true, detail: `roots=${roots.length}` };
529
546
  }
547
+ async function defaultValidateGdrive(connection) {
548
+ const config = parseGdriveConnectionConfig(connection);
549
+ const keyText = await resolveGdriveServiceAccountKey(config.service_account_key_ref);
550
+ const clients = createGoogleDocsClients(gdriveServiceAccountKeySchema.parse(JSON.parse(keyText)));
551
+ const docs = await verifyGdriveFolderAndCountDocs(clients.drive, config.folder_id);
552
+ return { ok: true, detail: `docs=${docs}` };
553
+ }
530
554
  function splitOutputLines(output) {
531
555
  return output
532
556
  .split('\n')
@@ -1086,66 +1110,106 @@ async function promptForInteractiveSource(args, source, prompts, io, deps, defau
1086
1110
  },
1087
1111
  ]);
1088
1112
  }
1089
- return await runSourcePromptSteps(initialState, (state) => [
1113
+ if (source === 'notion') {
1114
+ return await runSourcePromptSteps(initialState, (state) => [
1115
+ ...connectionSteps,
1116
+ async (currentState) => {
1117
+ const ref = await chooseSourceCredentialRef({
1118
+ prompts,
1119
+ projectDir: args.projectDir,
1120
+ label: 'Notion integration token',
1121
+ envName: 'NOTION_TOKEN',
1122
+ secretFileName: `${currentState.sourceConnectionId ?? 'notion-main'}-token`,
1123
+ existingRef: currentState.sourceAuthTokenRef,
1124
+ });
1125
+ if (ref === 'back')
1126
+ return 'back';
1127
+ currentState.sourceAuthTokenRef = ref;
1128
+ return 'next';
1129
+ },
1130
+ async (currentState) => {
1131
+ const crawlMode = await prompts.select({
1132
+ message: 'Which Notion pages should ktx ingest?',
1133
+ options: [
1134
+ { value: 'all_accessible', label: 'All pages the integration can access' },
1135
+ { value: 'selected_roots', label: 'Specific pages and their subpages (choose them in a picker)' },
1136
+ { value: 'back', label: 'Back' },
1137
+ ],
1138
+ });
1139
+ if (crawlMode === 'back')
1140
+ return 'back';
1141
+ currentState.notionCrawlMode = crawlMode === 'all_accessible' ? 'all_accessible' : 'selected_roots';
1142
+ if (currentState.notionCrawlMode === 'all_accessible') {
1143
+ delete currentState.notionRootPageIds;
1144
+ }
1145
+ return 'next';
1146
+ },
1147
+ ...(state.notionCrawlMode === 'selected_roots'
1148
+ ? [
1149
+ async (currentState) => {
1150
+ const connectionId = currentState.sourceConnectionId ?? 'notion-main';
1151
+ const result = await (deps.pickNotionRootPages ?? pickNotionRootPages)({
1152
+ connectionId,
1153
+ connection: {
1154
+ driver: 'notion',
1155
+ auth_token_ref: credentialRef(currentState.sourceAuthTokenRef, 'Notion token ref'),
1156
+ crawl_mode: 'selected_roots',
1157
+ root_page_ids: currentState.notionRootPageIds ?? [],
1158
+ root_database_ids: [],
1159
+ root_data_source_ids: [],
1160
+ },
1161
+ }, io);
1162
+ if (result.kind === 'back') {
1163
+ return 'back';
1164
+ }
1165
+ if (result.kind === 'unavailable') {
1166
+ io.stderr.write(`${result.message}\n`);
1167
+ return 'back';
1168
+ }
1169
+ currentState.notionRootPageIds = result.rootPageIds;
1170
+ return 'next';
1171
+ },
1172
+ ]
1173
+ : []),
1174
+ ]);
1175
+ }
1176
+ return await runSourcePromptSteps(initialState, () => [
1090
1177
  ...connectionSteps,
1091
1178
  async (currentState) => {
1092
- const ref = await chooseSourceCredentialRef({
1093
- prompts,
1094
- projectDir: args.projectDir,
1095
- label: 'Notion integration token',
1096
- envName: 'NOTION_TOKEN',
1097
- secretFileName: `${currentState.sourceConnectionId ?? 'notion-main'}-token`,
1098
- existingRef: currentState.sourceAuthTokenRef,
1179
+ const keyRef = await promptText(prompts, {
1180
+ message: 'Google Drive service account key file reference',
1181
+ placeholder: 'file:/absolute/path/to/key.json',
1182
+ ...(currentState.gdriveServiceAccountKeyRef ? { initialValue: currentState.gdriveServiceAccountKeyRef } : {}),
1099
1183
  });
1100
- if (ref === 'back')
1184
+ if (keyRef === undefined)
1101
1185
  return 'back';
1102
- currentState.sourceAuthTokenRef = ref;
1186
+ currentState.gdriveServiceAccountKeyRef = keyRef.trim();
1103
1187
  return 'next';
1104
1188
  },
1105
1189
  async (currentState) => {
1106
- const crawlMode = await prompts.select({
1107
- message: 'Which Notion pages should ktx ingest?',
1190
+ const folderId = await promptText(prompts, {
1191
+ message: 'Google Drive folder id',
1192
+ ...(currentState.gdriveFolderId ? { initialValue: currentState.gdriveFolderId } : {}),
1193
+ });
1194
+ if (folderId === undefined)
1195
+ return 'back';
1196
+ currentState.gdriveFolderId = folderId.trim();
1197
+ return 'next';
1198
+ },
1199
+ async (currentState) => {
1200
+ const recursive = await prompts.select({
1201
+ message: 'Include Google Docs from subfolders?',
1108
1202
  options: [
1109
- { value: 'all_accessible', label: 'All pages the integration can access' },
1110
- { value: 'selected_roots', label: 'Specific pages and their subpages (choose them in a picker)' },
1203
+ { value: 'false', label: 'No' },
1204
+ { value: 'true', label: 'Yes' },
1111
1205
  { value: 'back', label: 'Back' },
1112
1206
  ],
1113
1207
  });
1114
- if (crawlMode === 'back')
1208
+ if (recursive === 'back')
1115
1209
  return 'back';
1116
- currentState.notionCrawlMode = crawlMode === 'all_accessible' ? 'all_accessible' : 'selected_roots';
1117
- if (currentState.notionCrawlMode === 'all_accessible') {
1118
- delete currentState.notionRootPageIds;
1119
- }
1210
+ currentState.gdriveRecursive = recursive === 'true';
1120
1211
  return 'next';
1121
1212
  },
1122
- ...(state.notionCrawlMode === 'selected_roots'
1123
- ? [
1124
- async (currentState) => {
1125
- const connectionId = currentState.sourceConnectionId ?? 'notion-main';
1126
- const result = await (deps.pickNotionRootPages ?? pickNotionRootPages)({
1127
- connectionId,
1128
- connection: {
1129
- driver: 'notion',
1130
- auth_token_ref: credentialRef(currentState.sourceAuthTokenRef, 'Notion token ref'),
1131
- crawl_mode: 'selected_roots',
1132
- root_page_ids: currentState.notionRootPageIds ?? [],
1133
- root_database_ids: [],
1134
- root_data_source_ids: [],
1135
- },
1136
- }, io);
1137
- if (result.kind === 'back') {
1138
- return 'back';
1139
- }
1140
- if (result.kind === 'unavailable') {
1141
- io.stderr.write(`${result.message}\n`);
1142
- return 'back';
1143
- }
1144
- currentState.notionRootPageIds = result.rootPageIds;
1145
- return 'next';
1146
- },
1147
- ]
1148
- : []),
1149
1213
  ]);
1150
1214
  }
1151
1215
  function existingConnectionIdsBySource(connections, source) {
@@ -1291,6 +1355,12 @@ function sourceArgsFromExistingConnection(input) {
1291
1355
  }
1292
1356
  return sourceArgs;
1293
1357
  }
1358
+ if (input.source === 'gdrive') {
1359
+ sourceArgs.gdriveServiceAccountKeyRef = stringField(input.connection.service_account_key_ref);
1360
+ sourceArgs.gdriveFolderId = stringField(input.connection.folder_id);
1361
+ sourceArgs.gdriveRecursive = input.connection.recursive === true;
1362
+ return sourceArgs;
1363
+ }
1294
1364
  sourceArgs.sourceAuthTokenRef = stringField(input.connection.auth_token_ref);
1295
1365
  sourceArgs.notionCrawlMode =
1296
1366
  input.connection.crawl_mode === 'all_accessible' ? 'all_accessible' : 'selected_roots';
@@ -1418,7 +1488,10 @@ function buildConnection(source, args) {
1418
1488
  if (source === 'lookml') {
1419
1489
  return buildLookmlConnection(args);
1420
1490
  }
1421
- return buildNotionConnection(args);
1491
+ if (source === 'notion') {
1492
+ return buildNotionConnection(args);
1493
+ }
1494
+ return buildGdriveConnection(args);
1422
1495
  }
1423
1496
  async function validateSource(source, args, deps) {
1424
1497
  if (source === 'dbt') {
@@ -1438,7 +1511,10 @@ async function validateSource(source, args, deps) {
1438
1511
  if (source === 'lookml') {
1439
1512
  return await (deps.validateLookml ?? defaultValidateLookml)(args.connection);
1440
1513
  }
1441
- return await (deps.validateNotion ?? defaultValidateNotion)(args.connection);
1514
+ if (source === 'notion') {
1515
+ return await (deps.validateNotion ?? defaultValidateNotion)(args.connection);
1516
+ }
1517
+ return await (deps.validateGdrive ?? defaultValidateGdrive)(args.connection);
1442
1518
  }
1443
1519
  async function createSourceSetupRollback(projectDir) {
1444
1520
  const project = await loadKtxProject({ projectDir });
package/dist/setup.d.ts CHANGED
@@ -103,6 +103,9 @@ export type KtxSetupArgs = {
103
103
  metabaseDatabaseId?: number;
104
104
  notionCrawlMode?: 'all_accessible' | 'selected_roots';
105
105
  notionRootPageIds?: string[];
106
+ gdriveServiceAccountKeyRef?: string;
107
+ gdriveFolderId?: string;
108
+ gdriveRecursive?: boolean;
106
109
  runInitialSourceIngest?: boolean;
107
110
  skipSources?: boolean;
108
111
  showEntryMenu?: boolean;
package/dist/setup.js CHANGED
@@ -22,7 +22,7 @@ import { runKtxSetupSourcesStep } from './setup-sources.js';
22
22
  import { runKtxSetupRuntimeStep, } from './setup-runtime.js';
23
23
  import { createKtxSetupPromptAdapter, createKtxSetupUiAdapter, } from './setup-prompts.js';
24
24
  import { readKtxSetupContextState, runKtxSetupContextStep, setupContextStatusFromState, } from './setup-context.js';
25
- const SOURCE_DRIVERS = new Set(['dbt', 'metricflow', 'metabase', 'looker', 'lookml', 'notion']);
25
+ const SOURCE_DRIVERS = new Set(['dbt', 'metricflow', 'metabase', 'looker', 'lookml', 'notion', 'gdrive']);
26
26
  const KTX_DOCS_URL = 'https://docs.kaelio.com/ktx';
27
27
  function createEntryMenuPromptAdapter() {
28
28
  return createKtxSetupPromptAdapter({
@@ -587,6 +587,11 @@ async function runKtxSetupInner(args, io, deps = {}) {
587
587
  ...(args.metabaseDatabaseId !== undefined ? { metabaseDatabaseId: args.metabaseDatabaseId } : {}),
588
588
  ...(args.notionCrawlMode ? { notionCrawlMode: args.notionCrawlMode } : {}),
589
589
  ...(args.notionRootPageIds ? { notionRootPageIds: args.notionRootPageIds } : {}),
590
+ ...(args.gdriveServiceAccountKeyRef
591
+ ? { gdriveServiceAccountKeyRef: args.gdriveServiceAccountKeyRef }
592
+ : {}),
593
+ ...(args.gdriveFolderId ? { gdriveFolderId: args.gdriveFolderId } : {}),
594
+ ...(args.gdriveRecursive !== undefined ? { gdriveRecursive: args.gdriveRecursive } : {}),
590
595
  runInitialSourceIngest: args.runInitialSourceIngest ?? false,
591
596
  skipSources: args.skipSources === true || !shouldRunSources || skipSourcesFromDatabaseMenu,
592
597
  }, io);
@@ -13,12 +13,15 @@ You have access to ktx MCP tools for data discovery, semantic-layer analysis, ra
13
13
  - `kind: 'wiki'` -> `wiki_read`
14
14
  - `kind: 'sl_source'`, `kind: 'sl_measure'`, or `kind: 'sl_dimension'` -> `sl_read_source`
15
15
  - `kind: 'table'` or `kind: 'column'` -> `entity_details`
16
+ - For tables you intend to query, sample a few rows (`entity_details` plus a small `sql_execution` sample) to confirm date encoding, null prevalence in join/filter keys, and the real enum values — see the `<sql_craft>` Schema-discovery rules.
16
17
  3. **Resolve business values** - if the user named a value such as "Acme Corp", "enterprise", or "status=shipped", call `dictionary_search` to find which column holds it.
17
- 4. **Plan the analysis** - identify the grain, metrics, dimensions, filters, time window, and expected row limits before querying.
18
+ 4. **Plan the analysis** - identify the grain, metrics, dimensions, filters, time window, and expected row limits before querying. Confirm each filter/join column's real type before comparing it (see the `<sql_craft>` Schema-discovery rules). **Write down the exact output-column list first** — enumerate, from the question, every column the answer must have (each requested metric/attribute; for every grouped or named entity BOTH its id and its name; every input to each derived value) and treat that list as the contract your final `SELECT` must match column-for-column. Decide this list *before* writing SQL, not after — building the projection to a pre-stated list is far more reliable than reviewing for omissions at the end.
18
19
  5. **Query** -
19
20
  - Prefer `sl_query` when the semantic layer covers the question.
20
21
  - Use `sql_execution` only for questions the semantic layer does not cover.
21
- 6. **Validate and explain** - sanity-check totals, filters, null handling, and time zones. State the source tables or semantic-layer objects used.
22
+ - Before writing raw `sql_execution` SQL against a connection, call `sql_dialect_notes` with its connection id to get that engine's FQTN, identifier-quoting, date, top-N, series/calendar, rolling-window, safe-cast, and JSON conventions.
23
+ - When authoring raw SQL, apply the `<sql_craft>` rules: build incrementally, keep window ordering deterministic, compute at full precision, and match the answer's grain to the question.
24
+ 6. **Validate and explain** - sanity-check totals, filters, null handling, and time zones. **Always run the final completeness check before emitting:** re-read the question and confirm every requested output, each named entity's identity, each derived value's inputs, and the question's grain are all in the projection — see the `<sql_craft>` Final completeness check. If a result is unexpectedly empty or its grain looks wrong, work through the `<sql_craft>` Answer-completeness rules to diagnose. State the source tables or semantic-layer objects used.
22
25
  7. **Capture durable learnings** - call `memory_ingest` whenever a turn produces something worth remembering (business rules, metric definitions, schema gotchas, recurring findings) **or** whenever the user asks you to remember something. Pass markdown in `content` including any source context the memory agent should weigh. Each call is a feedback loop; better notes today mean smarter `discover_data` and `wiki_search` results tomorrow.
23
26
  </workflow>
24
27
 
@@ -38,6 +41,201 @@ You have access to ktx MCP tools for data discovery, semantic-layer analysis, ra
38
41
  - Ask a concise clarification only when the metric, date range, entity, or grain is genuinely ambiguous and cannot be inferred from context.
39
42
  </rules>
40
43
 
44
+ <sql_craft>
45
+ Heuristics for writing *correct* (not merely runnable) SQL. Each is a default plus the reason it holds on any database; apply judgment to the question and the data.
46
+
47
+ **Schema discovery before writing SQL**
48
+ - **Sample before you compose.** Inspect representative rows of every table you will touch (`entity_details` plus a small `sql_execution` sample) to confirm date/time encoding (`YYYYMMDD` integer vs ISO text vs epoch), null prevalence in join/filter keys, and the real set of categorical/enum values. Assumptions about encoding and nullability are the most common source of silently-wrong filters.
49
+ - **Cast to the real type before comparing.** Compare a column against a literal of its actual type in `WHERE`/`JOIN`. A string column compared to a numeric literal (or the reverse) can silently match nothing instead of raising an error.
50
+ - **Parse text-encoded numerics before doing math on them.** When a column the question treats as a number is stored as text, sample its **distinct** values (the *Sample before you compose* habit) to learn the encodings actually present — unit suffixes (`K`/`M`/`B`), currency symbols, thousands separators, percent signs, and non-numeric sentinels (`-`, `N/A`, empty) — and never infer the format from the column name. *Why:* aggregated or compared as-is the text sorts lexically (`'100' < '9'`) and a naive cast collapses formatted values to `0`/NULL, so the query runs but the number is silently wrong instead of erroring.
51
+ - **Strip, scale, and cast in one early CTE.** Strip currency/separator/percent characters, multiply by the suffix scale (`K`=10^3, `M`=10^6, `B`=10^9), map sentinels to `0` **or** `NULL` (by the *Default by additivity* rule below), then cast to a numeric type — all in a single early CTE so every layer above sees clean numbers. This is the *meaning-is-numeric* complement to *Cast to the real type before comparing*. *Why:* one clean conversion at the base keeps the lexical-sort-and-cast-to-0 failure out of every downstream layer.
52
+ - **Confirm the parse covered every value.** After parsing, count the non-sentinel rows that failed to parse — a failed parse should surface as `NULL`, visible only with a **failure-detecting cast** from `sql_dialect_notes` (a plain `CAST` errors on some engines and on sqlite silently returns `0`/partial, so an `IS NULL` check is meaningless there). *Why:* an encoding the sample missed would otherwise vanish into `0`/NULL instead of being caught.
53
+ - **Parse code/dependency text by its real grammar, not one broad regex.** When a question extracts imported/required/loaded packages or modules from stored source text or dependency manifests, parse by the *language or format*, not a single pattern: Java `import`/`import static` — drop the terminal class/member, keep the package path, and allow valid identifier segments with underscores and mixed case (e.g. com.planet_ink.coffee_mud); Python — handle both `import a, b as c` and `from a.b import c`, stripping aliases; R — handle `library(...)` and `require(...)`; notebooks (`.ipynb`) — parse the JSON and read each cell's `source` lines *before* applying the language rules (never regex the raw notebook file, whose prose contains the words "import"/"from"); JSON/manifest files — `PARSE_JSON` and flatten the dependency object's keys (e.g. `require`). Strip comments/prose lines first and split multi-import lines so each declared dependency is counted once. *Why:* a single lowercase-segment regex silently drops real identifiers and matches prose, so the ranking is wrong though the query runs.
54
+ - **Decide the counting population explicitly when a table is deduplicated.** If the source table is de-duplicated and carries a documented copy/occurrence count (e.g. a `copies` column = "repositories sharing this exact content"), the count grain is a real modeling choice: weight by that column only when the question's population is clearly the represented files/repositories; otherwise count the distinct stored rows. State which population the question names and match it — do not default to one silently. *Why:* on a deduplicated table `COUNT(*)` and `SUM(copies)` give different rankings, so the right metric depends on the population the question asks about, not on which is larger.
55
+
56
+ ```sql
57
+ -- "Total trade volume" where value_text holds '1.2K', '3M', '$1,200', '-'.
58
+ -- WRONG: a naive cast collapses the formatted values ('1.2K'->1.2, '$1,200'->0,
59
+ -- '-'->0) instead of erroring, so the SUM comes back silently far too low.
60
+ SELECT SUM(CAST(value_text AS REAL)) AS total_volume FROM metrics;
61
+
62
+ -- RIGHT: strip symbols/suffixes, scale by the K/M/B suffix, map sentinels to 0, and
63
+ -- cast once in an early CTE; the SUM then runs over clean numbers.
64
+ WITH parsed AS (
65
+ SELECT CASE WHEN value_text IN ('-', 'N/A', '') THEN 0
66
+ ELSE CAST(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(value_text,
67
+ '$', ''), ',', ''), 'K', ''), 'M', ''), 'B', '') AS DECIMAL(18, 4))
68
+ * CASE WHEN value_text LIKE '%K' THEN 1000
69
+ WHEN value_text LIKE '%M' THEN 1000000
70
+ WHEN value_text LIKE '%B' THEN 1000000000 ELSE 1 END
71
+ END AS volume
72
+ FROM metrics
73
+ )
74
+ SELECT SUM(volume) AS total_volume FROM parsed;
75
+ ```
76
+
77
+ - **Canonicalize observed URL-path variants before page-level analysis.** When a question groups, filters, or sequences web pages by a `path`/`url` column, sample its distinct values first. If the data itself shows route-label variants — `/route` and `/route/` for the same page context — define a canonical page-path expression in an early CTE and use it everywhere above that CTE: preserve `/` as root, strip trailing slashes only from non-root paths, and map an observed empty path to `/` *only* when the column is a URL path and the sampled rows show blank root-page events. Do **not** merge different route names (`/input` ≠ `/regist/input`), strip query strings/fragments/host/scheme, lowercase paths, or canonicalize at all when the question asks for the raw stored URL/path or for slash-vs-no-slash differences. *Why:* raw request logs routinely store the same user-visible page both with and without a trailing slash, so grouping or sequencing the raw labels silently splits one page into several — but inventing aliases the data doesn't show would just as silently merge distinct pages.
78
+
79
+ **Composition**
80
+ - **Build incrementally.** Assemble complex queries one CTE at a time, checking each layer's output on a small sample before stacking the next; a wrong intermediate layer is far cheaper to catch early than to debug in the final number.
81
+ - **Avoid fan-out joins — the danger is cumulative.** Any one-to-many hop on the path between a measure's owning table and the aggregate inflates that measure, even when the offending join sits several hops below the `SUM`/`COUNT` and is easy to miss. The fix is the single-hop one applied per measure-owning table along the whole chain: pre-aggregate each coarse-grained measure to its own grain in a CTE, then join the already-aggregated result.
82
+ - **Verify the grain holds across each join.** As you compose, confirm a join you intend to be one-to-one / many-to-one did not change the grain you aggregate at — e.g. the row count (or the count of the aggregate's key) is unchanged across it. When a join is genuinely one-to-many, reach for the default fix (pre-aggregate to grain); for a pure count, `COUNT(DISTINCT key)` is an acceptable escape hatch. A `SUM`/`AVG` of a fanned-out measure must pre-aggregate — `DISTINCT` cannot de-duplicate a sum.
83
+ - **A join that only attaches a label must not drop rows — `LEFT JOIN` it, and key the aggregate on the fact column.** Fan-out's mirror image is just as silent: when you join a dimension table *only to fetch a display attribute* (a name for an id, a category for a product), an **incomplete** dimension — and dimensions are routinely incomplete: trimmed catalogs, late-arriving rows, slowly-changing-dimension gaps — makes a plain inner `JOIN` quietly **discard every fact row whose key has no parent**, shrinking the counts, sums, and the universe over which any share / average / median is computed (a measure halves with no error and no empty result). Two guards: (1) inner-join a dimension only when you *intend it as a filter* — you want exactly the rows that have a parent — never merely to read a column off it; for pure enrichment use `LEFT JOIN`. (2) Key the aggregation and `GROUP BY` on the **fact** column (`sales.prod_id`), not the dimension column (`products.prod_id`), so an unmatched key yields a `NULL` label on its own row rather than dropping or collapsing it. Use the same row-count check as above, but for an enrichment join confirm the fact row count is *unchanged* (not merely un-inflated); if a dimension you only wanted a name from removed rows, that is the bug.
84
+ - **Source each filter, date, and measure from the table that OWNS it at the question's grain.** When two joined fact tables carry similarly-named columns at *different* grains — a parent (one row per order: its `status`, placement `created_at`, `num_of_item`) and its child (one row per line item: line `created_at`, `sale_price`, `cost`) — read each predicate/measure from the table whose grain the question names, not from whichever is in scope after the join. "Orders that are Complete", "for each month of the orders", "the order's creation date" are *order*-grain, so the status filter and the month bucket come from the parent order row, even though the child also has `status`/`created_at` columns; line price and cost come from the child. *Why:* the parent's and child's copies of a column diverge (an item's placement month or status can differ from its order's), so anchoring an order-grain filter or calendar on the line table silently buckets/filters the wrong rows. The mirror at metric grain: never combine a parent-grain count with child rows after the join (e.g. `num_of_item * SUM(line_price)` once per line) — compute each measure at its own grain (sum line prices to the order, take `num_of_item` once per order) before combining.
85
+
86
+ ```sql
87
+ -- "How many orders per region contain a returned item?" — count each order once.
88
+ -- WRONG: order_lines is joined to apply the line-level filter, which multiplies
89
+ -- orders; an order with two returned lines is counted twice, three joins below
90
+ -- the COUNT, where the inflation is easy to miss.
91
+ SELECT r.region_id, COUNT(*) AS n_orders
92
+ FROM regions r
93
+ JOIN stores s ON s.region_id = r.region_id
94
+ JOIN orders o ON o.store_id = s.store_id
95
+ JOIN order_lines l ON l.order_id = o.order_id
96
+ WHERE l.status = 'returned'
97
+ GROUP BY r.region_id;
98
+
99
+ -- RIGHT: collapse order_lines to one row per qualifying order first, then join up
100
+ -- so each order contributes exactly once.
101
+ WITH returned_orders AS (
102
+ SELECT order_id FROM order_lines WHERE status = 'returned' GROUP BY order_id
103
+ )
104
+ SELECT r.region_id, COUNT(*) AS n_orders
105
+ FROM regions r
106
+ JOIN stores s ON s.region_id = r.region_id
107
+ JOIN orders o ON o.store_id = s.store_id
108
+ JOIN returned_orders ro ON ro.order_id = o.order_id
109
+ GROUP BY r.region_id;
110
+ -- A pure count could also use COUNT(DISTINCT o.order_id); a SUM/AVG of an
111
+ -- order-level measure fanned out this way must pre-aggregate — DISTINCT can't
112
+ -- de-duplicate a sum.
113
+ ```
114
+
115
+ **Ordering & aggregation determinism**
116
+ - **Make the ordering deterministic.** Give every ranking/ordering window a complete tie-breaker by appending unique key column(s) to `ORDER BY`, so `RANK`/`ROW_NUMBER`/`LAG` results are stable instead of flickering between runs.
117
+ - **Order inside string/array aggregation.** When concatenating rows into a delimited string or building an ordered array (`GROUP_CONCAT` / `string_agg` / `array_agg`), the element order is **undefined unless you specify it** — put an explicit `ORDER BY` on the aggregate. Be deliberate about collation: the default text sort is **binary/case-sensitive** (so `'BBQ'` sorts before `'Bacon'` because uppercase code points precede lowercase), which differs from a case-insensitive sort; pick the one the question implies and apply it consistently (`ORDER BY ... COLLATE NOCASE` for case-insensitive). *Why:* an unordered or differently-collated concatenation produces a string with the right elements in the wrong order — runnable but not matching the expected text.
118
+ - **Emit a list-valued answer cell as a delimited STRING, not a raw ARRAY/repeated column.** When the answer needs several values in one cell (a set of names/codes/tags for an entity), build a delimited scalar with `STRING_AGG(x, ',' ORDER BY x)` (or `ARRAY_TO_STRING(ARRAY_AGG(x ORDER BY x), ',')`) — do not return a SQL `ARRAY`/repeated column. *Why:* an array column serializes to an engine-specific representation (e.g. `['a' 'b']` or `["a","b"]`) that won't compare equal to a plain delimited list (`a,b`), so a values-correct answer still mismatches when materialized to rows.
119
+ - **Filter after the window, not before**, for sequence / "first" / "most recent" / "since" questions: compute the window over the full partition, then keep the rows you want. A pre-filter shrinks the partition the window ranks over, so "first"/"most recent" is measured against the wrong set.
120
+
121
+ ```sql
122
+ -- "Each customer's first order, restricted to orders since 2024-01-01."
123
+ -- Wrong: the filter runs before the window, so it ranks only 2024 rows and
124
+ -- misses customers whose true first order was earlier.
125
+ SELECT customer_id, order_id,
126
+ ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date, order_id) AS seq
127
+ FROM orders
128
+ WHERE order_date >= '2024-01-01'; -- then keep seq = 1
129
+
130
+ -- Right: rank the full partition in a CTE, then filter in the outer query.
131
+ WITH ranked AS (
132
+ SELECT customer_id, order_id, order_date,
133
+ ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date, order_id) AS seq
134
+ FROM orders
135
+ )
136
+ SELECT customer_id, order_id, order_date
137
+ FROM ranked
138
+ WHERE seq = 1 AND order_date >= '2024-01-01';
139
+ ```
140
+
141
+ - **Cumulative / running total.** Use an explicit frame — `SUM(x) OVER (PARTITION BY k ORDER BY t ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)` — with a complete tie-breaker on the `ORDER BY` (per the deterministic-ordering rule above). *Why:* a bare `ORDER BY` defaults to a `RANGE`-based frame bounded at the current row, which on ties in the order key folds every tied peer into one cumulative value — it runs and looks plausible, but the running total jumps at each tie boundary.
142
+ - **Rolling window over calendar time, plus minimum periods.** "Rolling N days/months" spans a *calendar range*, not a fixed row count: a `ROWS BETWEEN n-1 PRECEDING` frame silently measures the wrong span when days are missing. Two sanctioned paths — (a) build a gap-free date spine first (the **Series** idiom from `sql_dialect_notes`) so one row exists per calendar unit, then a `ROWS BETWEEN n-1 PRECEDING AND CURRENT ROW` frame equals the intended span (fully portable); or (b) where the engine supports it, a native calendar range frame — or a date-keyed self-join — expresses the window directly: get the rolling-window idiom from `sql_dialect_notes`, do not inline it. For **minimum periods** ("only after N periods of data"), emit `NULL` until the window is full — guard on `COUNT(*) OVER (<same frame>) = N`, counting non-null observations instead when "N periods" means N data points rather than N calendar slots. *Why:* a row-count frame over missing dates measures the wrong span, and a partial early window is not the requested metric.
143
+ - **Period-over-period.** Compare against the prior period with `LAG(metric) OVER (PARTITION BY k ORDER BY period)`; compute growth as `(cur - prev) / prev` at full precision, rounding only in the final projection (per the round-at-the-end rule below), and guard the divide against a zero or absent prior — e.g. `… / NULLIF(prev, 0)`. *Why:* without `LAG`, or ordered against the wrong neighbor, the comparison lands on the wrong period, and an unguarded ratio errors or returns garbage when the prior period is zero or missing.
144
+
145
+ ```sql
146
+ -- "Each account's running balance over time" — a cumulative sum of net per
147
+ -- account, in date order.
148
+ -- WRONG: a bare ORDER BY defaults to a RANGE-based frame, so two txns dated the
149
+ -- same day share one inflated balance (every tied peer folds into that value).
150
+ SELECT account_id, txn_date, net,
151
+ SUM(net) OVER (PARTITION BY account_id ORDER BY txn_date) AS running_balance
152
+ FROM account_txns;
153
+
154
+ -- RIGHT: an explicit ROWS frame accumulates row by row, and a complete tie-breaker
155
+ -- (txn_id) makes the order — and the running total — deterministic across ties.
156
+ SELECT account_id, txn_date, net,
157
+ SUM(net) OVER (PARTITION BY account_id ORDER BY txn_date, txn_id
158
+ ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_balance
159
+ FROM account_txns;
160
+ ```
161
+
162
+ **Numeric precision**
163
+ - **Integer division truncates on postgres/sqlite/tsql.** The `/` operator between two integers does integer division on **postgres, sqlite, and SQL Server** — `5 / 2` is `2`, `wins / games` is `0` — so a rate, share, or `SUM(a) / COUNT(*)` silently floors to an integer. Cast one operand to a fractional type before dividing: `wins * 1.0 / games`, `CAST(wins AS REAL) / games`, or `SUM(a)::numeric / COUNT(*)`, then round at the end. mysql and bigquery already return a fractional result from `/` (on bigquery prefer `SAFE_DIVIDE` to also guard a zero denominator).
164
+ - **Round only at the end.** Compute at full precision and round in the final projection, never inside intermediate CTEs. Be explicit about truncation: an integer cast (`CAST(x AS INT)`) truncates toward zero, so use explicit rounding when rounding is what you mean.
165
+ - **Macro vs micro average.** Match the average to the wording. "Average of per-group averages" is `AVG(group_metric)`; an "overall" or "weighted" average is `SUM(numerator) / SUM(denominator)`. The two diverge whenever group sizes differ.
166
+
167
+ **Answer completeness / interpretation**
168
+ - **"Top / highest / most / lowest"** returns only the winning row(s) — keep the top-ranked row from the window result — not the full ranked list, unless the question asks for a list.
169
+ - **"For each X / per X / by X"** returns exactly one row per X. Do not collapse to a single value unless the question says "overall" or "total across X".
170
+ - **A named business measure means its amount, not a row count.** When a question asks for "sales", "revenue", "spend", "value", or "volume" of money/goods without an explicit "number / count of", aggregate the monetary/quantity **amount** (`SUM(price)` / `SUM(amount)`), not `COUNT(*)` of rows. *Why:* "toy sales" reads as sales revenue; counting order rows silently answers a different question.
171
+ - **Answer literally — do not add unrequested transformations.** Apply exactly the filters, joins, grouping, and computation the question (and any `external_knowledge` doc) states; do not add "helpful" extras the task never asked for — extra status/category predicates, area/residential *weighting* of an average the question states plainly, entity-name *normalization* that forces joins the source leaves unmatched, or a re-derived value where the question names a specific stored measure/column. When the wording bounds an **aggregate** ("committees whose *total* is between $0 and $200", "entities with 5+ orders"), filter the aggregate with `HAVING`, not each row with `WHERE`. When an `external_knowledge` doc gives an explicit formula or function/UDF definition, implement it **verbatim** — same operators, constants, and ordering — rather than substituting your own "more correct" math. *Why:* each unrequested predicate silently drops valid rows, each unrequested weighting/normalization or re-derivation changes the value, and a row-level filter for an aggregate bound answers a different question — so a more-sophisticated-looking query is wrong against the literal ask. Prefer the simplest reading that satisfies the question.
172
+ - **Don't project free-text columns the question didn't ask for.** A description/body/comment/notes column whose values contain commas or newlines corrupts the row-delimited output and is almost never the requested value — leave it out of the final projection unless the question explicitly asks for it.
173
+ - **"Inter-event duration / gap / interval" is the time between consecutive events, not a magnitude.** When the question asks the typical gap/interval/time *between* occurrences (releases, visits, orders), order rows by the event timestamp and take `LEAD`/`LAG` date differences, then aggregate — never a duration/length/runtime *column*.
174
+ - **Anchor a period bucket to the lifecycle event the wording names.** When a record carries several lifecycle timestamps (created/placed, approved, shipped, delivered, completed, settled) and the question counts/measures records in a *named completed state* by period ("delivered orders by month", "shipped items per week", "completed payments by day"), bucket the period by that named event's own timestamp (`order_delivered_customer_date`, `shipped_at`, `settled_at`) — the state value is the qualifying filter, the matching timestamp is the time anchor. Use the creation/placed/purchased/submitted timestamp only when the question names that *start* event (purchased, placed, created, ordered, submitted) or no matching event timestamp exists. If several timestamps fit, pick the one for the event as experienced by the question's subject (customer delivery = the customer-receipt date, not the carrier-handoff or estimated date). If the named state is used only as a non-temporal filter (counts by customer/city/seller with no period bucket), it is just a filter — introduce no date anchor. Confirm each timestamp's meaning from column names, semantic-layer descriptions, and sample rows first. *Why:* bucketing a completed-state count by the record's creation date silently answers a different question — "records that later reached that state, grouped by when they started" — than the one asked.
175
+ - **"Highest / most across several achievements" aggregates per metric over the whole history.** When a question asks for top values across multiple metrics or a career/lifetime total ("most runs, most wickets, longest span"), emit one row per metric with that metric summed/maxed over all the entity's records — not a single top-season or top-row snapshot.
176
+ - **An aggregate scoped to a per-entity selected set is computed across that set.** "The average revenue per actor **in those top-3 films**", "the mean order value over each customer's **last 5 orders**" means, per entity, the aggregate over the items it selected — one value per entity spanning its chosen items — NOT the per-item value. The per-item formula the question gives ("divide film revenue among its actors") computes each item's contribution; the average/total then spans the selected items. When the question states both a per-item computation AND an aggregate over the items, compute and project BOTH (the per-item value and the across-set aggregate, e.g. `AVG(item_value) OVER (PARTITION BY entity)`). The set is chosen by the ranking measure the question names — "top-N **revenue-generating** films" ranks each entity's items by the item's **own total revenue** — and that ranking is independent of the per-item value (the share), which feeds only the aggregate, never the top-N selection.
177
+ - **Coverage over a selected group is a set-membership aggregate (one value for the whole group), not a per-entity metric.** When a question first selects a group of entities ("the top 5 actors", "these products", "the eligible stores") and then asks what count/share/percentage of a **different** subject domain has any relationship to *these* selected entities ("what % of **customers** rented films featuring these actors"), the subject set is the **UNION across the whole group**: select the entity ids in a CTE, join to the subject facts, `COUNT(DISTINCT subject_id)` **once** across the group, and return one aggregate at the subject-domain grain (with the numerator/denominator projected if the question states a ratio). Counting the subject per selected entity and reporting N rows answers a different question and double-counts subjects that relate to more than one entity. This is the **collective-coverage** cousin of the per-entity rule above: emit one row per selected entity **only** when the wording says "for each / per / by / list" or asks for each entity's *own* metric ("top 5 players **and their** batting averages"); a bare "what share … of these" is one collective value.
178
+ - **Complete the panel for "each / every / all / per <period or category>".** These cues mean the answer's rows should be the *full expected domain* — every month in the asked range, every region in the dimension — not only the groups that happen to have fact rows; a plain inner `GROUP BY` emits only non-empty groups, so empty periods/categories silently drop and a "12 months" answer comes back short. Build the full set of groups (the **spine**), `LEFT JOIN` the aggregated facts onto it, then default the gaps:
179
+ - **Spine source.** For a category, take the distinct domain from the **dimension/entity table** (e.g. every region from `regions`) — not `SELECT DISTINCT` over the facts, which can only list categories that already occur; with no dimension table, distinct values from the *unfiltered* facts are the best available domain. For a period or number range, generate the series across the question's stated range (when the range is "all periods present", derive its bounds from `MIN`/`MAX` over the *unfiltered* facts). Series syntax is engine-specific — get the series/calendar idiom from `sql_dialect_notes` rather than inlining one dialect's generator.
180
+ - **Default by additivity.** `COALESCE(metric, 0)` only for **additive** measures (a `COUNT`/`SUM` of events or amounts, where "no activity" genuinely reads as 0); leave **non-additive** measures (`AVG`, a rate, a ratio, a price, a running balance) as `NULL` — absence is "no data", and 0 would be a wrong reading.
181
+ - **Don't over-apply.** *each / every / all* wants the complete domain; *which / that have* ("which months had orders") wants only the groups that exist — there the spine is wrong, so emit observed groups only.
182
+ - **Selecting the extreme group needs the spine too.** When you pick the group with the highest/lowest count or total over a period/category domain ("the month with the **lowest** number of active customers", "the region with the **fewest** orders"), rank over the COMPLETE spine, not only groups that have fact rows — an empty period/category is a genuine 0 and is frequently the true minimum, yet ranking over observed groups alone silently makes it unselectable and returns the wrong extreme. A period with NO rows at all never appears in a `GROUP BY` of the facts: generate the full calendar of the stated range first ("each month of 2020" → all 12 months, even if only 4 have transactions), `LEFT JOIN` the per-group aggregates, `COALESCE` the count to 0, and only THEN rank — otherwise a zero-activity month that is the true lowest is invisible to the ranking.
183
+ - **Answer every requested output.** When a question asks for several things — a list ("A, B, and C"), paired extremes ("the highest *and* the lowest"), or a value plus its components ("X, Y, and their ratio") — the projection needs one column per requested output, not just the first clause. *Why:* answering only the first clause is the most common way a runnable query is still wrong — the grain and methodology can be perfect yet the answer is short by columns. This is the umbrella over the next two rules: *keep the inputs* is its "value + components" case and *expose identity* is its "entity identity" case, so a **complete projection** is exactly every requested metric/attribute, plus the identifier of each named entity, plus the inputs to each derived value, at the question's grain. It governs *which columns* appear — distinct from *Top …* and *For each X* above, which govern *which rows* — and composes with them ("highest and lowest per region" needs one row per region and a column per clause).
184
+ - **Keep the inputs to a derived value.** When the question asks for inputs and something derived from them ("X, Y, and their ratio"), project the inputs as columns alongside the derived value.
185
+ - **A comparison BETWEEN two specific extremes is one wide row.** When the question asks for a single value derived by comparing two named extremes — "the **difference between** the highest and the lowest month", "the ratio of the best to the worst" — present BOTH extremes side by side in ONE row: each extreme's attributes as their own columns (e.g. `highest_month`, `highest_value`, `lowest_month`, `lowest_value`) plus the comparison as a column (`difference`). The comparison is a single fact about the pair, so the answer is one wide row — NOT one row per extreme with the comparison repeated. (Contrast: "report a metric **for each** group/category" — e.g. "a percentage for each helmet group", "the top player for each outcome" — has no cross-item comparison and stays long, one row per group.)
186
+ - **Project BOTH identity and label.** When the result is per-entity, project the entity's **identifier and its human-readable name together** — whichever you grouped by, add the other. The id disambiguates duplicate names, and a consumer may legitimately expect either; supplying both is the safe, complete choice (a per-entity answer that gives only one is a frequent cause of an otherwise-correct result not matching).
187
+ - **Diagnose empty results.** When a result is unexpectedly empty, relax filters one at a time to find which predicate removed the rows instead of guessing.
188
+ - **Spatial predicates ("within area / within N meters / inside this polygon / nearest").** When a question filters or relates rows by geography, use the engine's geospatial functions — get the exact ones from `sql_dialect_notes` — rather than hand-rolling latitude/longitude `BETWEEN` boxes (which are wrong off the equator and ignore polygon shape). Recipe: (1) turn each location into a geography point with the point constructor — **mind argument order, most take longitude before latitude**; (2) for an area of interest build a polygon from its boundary/corner coordinates, closing the ring (first point repeated last); (3) test the relation with the engine's containment (`contains`/`within`), proximity (`dwithin(g1,g2,meters)`), or overlap (`intersects`) predicate. For "the features within the same area as entity X", first resolve X's own geometry in a CTE, then join candidates on the spatial predicate against it. *Why:* spatial relationships are not axis-aligned ranges; the geodesic predicates are both correct and index-assisted, while a raw coordinate box silently includes/excludes the wrong rows.
189
+ - **Collapse a multi-valued attribute to one representative per entity before counting classes or a concentration metric.** When an entity carries a multi-valued classification array (IPC/CPC codes, tags, categories) and the methodology counts *entities per class* or computes a concentration/diversity measure (HHI, originality, a share), pick exactly **one representative value per entity** in a CTE first — use the array's `main`/`primary`/`first` flag when present, else a defined fallback (e.g. the most-frequent value) — then aggregate. Equally, when a metric's denominator is defined as a count of **entities** ("the number of patents cited"), use `COUNT(DISTINCT entity)`, not the count of exploded array rows. *Why:* `LATERAL FLATTEN`/unnest of the array multiplies an entity's weight by how many codes it has, inflating per-class frequencies and skewing any concentration metric — the query runs but the ranking/score is wrong. (Take the representative rule from the methodology/`external_knowledge` doc when it specifies one; do not invent a selection the source does not state.)
190
+ - **Final completeness check.** Before emitting the final SQL, re-read the question and confirm the projection covers: (1) every named **metric / attribute** asked for (→ *answer every requested output*); (2) the **identifier** of each grouped or named entity (→ *expose identity*); (3) every **input** to each derived value (→ *keep the inputs*); (4) all at the **grain** the question specifies (→ *for each X* / *complete the panel*). Run this on every query, not only when a result looks off. **Don't over-project:** anything outside that set — a column the question never asked for, added "to be safe" — adds noise, misleads the reader into thinking it matters, and makes the result harder to consume. Match the request exactly: neither short nor padded.
191
+
192
+ ```sql
193
+ -- "How many orders per region, including regions with no orders?" — every region
194
+ -- must appear, even one with zero orders.
195
+ -- WRONG: grouping the facts can only emit regions that have at least one order,
196
+ -- so a zero-order region silently drops and the panel comes back short a row.
197
+ SELECT region_id, COUNT(*) AS n_orders
198
+ FROM orders
199
+ GROUP BY region_id;
200
+
201
+ -- RIGHT: start from the full region domain (the dimension table), LEFT JOIN the
202
+ -- per-region counts onto it, and COALESCE the additive count to 0 so empty
203
+ -- regions read 0 instead of vanishing.
204
+ WITH region_domain AS (
205
+ SELECT DISTINCT region_id FROM regions
206
+ ),
207
+ region_orders AS (
208
+ SELECT region_id, COUNT(*) AS n_orders
209
+ FROM orders
210
+ GROUP BY region_id
211
+ )
212
+ SELECT d.region_id, COALESCE(ro.n_orders, 0) AS n_orders
213
+ FROM region_domain d
214
+ LEFT JOIN region_orders ro ON ro.region_id = d.region_id;
215
+ ```
216
+
217
+ ```sql
218
+ -- "For each region, report the highest and the lowest monthly order count and the
219
+ -- difference between them." A complete answer is five columns: the region's id and
220
+ -- name, the highest, the lowest, and their difference.
221
+ -- WRONG: answers only the first clause and drops the region id, the lowest, and the
222
+ -- difference — four of the five requested columns are missing.
223
+ SELECT region_name, MAX(monthly_orders) AS highest
224
+ FROM region_monthly
225
+ GROUP BY region_name;
226
+
227
+ -- RIGHT: one column per requested output plus the entity's identity, at the region
228
+ -- grain — id and name, the highest, the lowest, and their difference.
229
+ SELECT r.region_id, r.region_name,
230
+ MAX(rm.monthly_orders) AS highest,
231
+ MIN(rm.monthly_orders) AS lowest,
232
+ MAX(rm.monthly_orders) - MIN(rm.monthly_orders) AS order_count_range
233
+ FROM regions r
234
+ JOIN region_monthly rm ON rm.region_id = r.region_id
235
+ GROUP BY r.region_id, r.region_name;
236
+ ```
237
+ </sql_craft>
238
+
41
239
  <examples>
42
240
  **Input:** "How many orders did Acme Corp place last month?"
43
241