@hypequery/cli 1.26.0 → 1.28.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 (56) hide show
  1. package/README.md +14 -1
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +9 -2
  4. package/dist/commands/deploy.d.ts +6 -0
  5. package/dist/commands/deploy.d.ts.map +1 -1
  6. package/dist/commands/deploy.js +48 -2
  7. package/dist/commands/deployment.d.ts.map +1 -1
  8. package/dist/commands/deployment.js +11 -0
  9. package/dist/commands/generate-client.d.ts +13 -1
  10. package/dist/commands/generate-client.d.ts.map +1 -1
  11. package/dist/commands/generate-client.js +41 -7
  12. package/dist/generators/client-contract/render.d.ts +7 -1
  13. package/dist/generators/client-contract/render.d.ts.map +1 -1
  14. package/dist/generators/client-contract/render.js +5 -3
  15. package/dist/generators/dataset-generator.d.ts.map +1 -1
  16. package/dist/generators/dataset-generator.js +77 -68
  17. package/dist/templates/cloud.d.ts.map +1 -1
  18. package/dist/templates/cloud.js +3 -0
  19. package/dist/utils/clickhouse-dimension-type.d.ts +27 -0
  20. package/dist/utils/clickhouse-dimension-type.d.ts.map +1 -0
  21. package/dist/utils/clickhouse-dimension-type.js +61 -0
  22. package/dist/utils/clickhouse-type-utils.d.ts.map +1 -1
  23. package/dist/utils/clickhouse-type-utils.js +9 -1
  24. package/dist/utils/client-contract-file.d.ts +5 -0
  25. package/dist/utils/client-contract-file.d.ts.map +1 -1
  26. package/dist/utils/client-contract-file.js +49 -5
  27. package/dist/utils/cloud-client-contract.d.ts +20 -0
  28. package/dist/utils/cloud-client-contract.d.ts.map +1 -0
  29. package/dist/utils/cloud-client-contract.js +52 -0
  30. package/dist/utils/deployment-upload.d.ts +6 -2
  31. package/dist/utils/deployment-upload.d.ts.map +1 -1
  32. package/dist/utils/deployment-upload.js +12 -2
  33. package/dist/utils/draft-deployment.d.ts +16 -0
  34. package/dist/utils/draft-deployment.d.ts.map +1 -0
  35. package/dist/utils/draft-deployment.js +35 -0
  36. package/dist/utils/field-namer.d.ts +10 -0
  37. package/dist/utils/field-namer.d.ts.map +1 -0
  38. package/dist/utils/field-namer.js +20 -0
  39. package/dist/utils/json-pointer.d.ts +3 -0
  40. package/dist/utils/json-pointer.d.ts.map +1 -0
  41. package/dist/utils/json-pointer.js +4 -0
  42. package/dist/utils/live-deployment.d.ts +1 -1
  43. package/dist/utils/live-deployment.d.ts.map +1 -1
  44. package/dist/utils/read-capped.d.ts +6 -0
  45. package/dist/utils/read-capped.d.ts.map +1 -0
  46. package/dist/utils/read-capped.js +36 -0
  47. package/dist/utils/shell-quote.d.ts +6 -0
  48. package/dist/utils/shell-quote.d.ts.map +1 -0
  49. package/dist/utils/shell-quote.js +7 -0
  50. package/dist/utils/telemetry/catalog.d.ts +1 -1
  51. package/dist/utils/telemetry/domains.d.ts +6 -6
  52. package/dist/utils/telemetry/domains.js +6 -6
  53. package/dist/utils/tenant-key-candidates.d.ts +19 -0
  54. package/dist/utils/tenant-key-candidates.d.ts.map +1 -0
  55. package/dist/utils/tenant-key-candidates.js +63 -0
  56. package/package.json +7 -7
package/README.md CHANGED
@@ -37,13 +37,26 @@ npx hypequery init \
37
37
  | `hypequery dev` | Local API, docs, and hot reload |
38
38
  | `hypequery generate` | Fresh TypeScript types from ClickHouse or chDB |
39
39
  | `hypequery generate:datasets` | Dataset definitions scaffolded from tables |
40
- | `hypequery generate:client` | TypeScript API types and routes from a Python client contract |
40
+ | `hypequery generate:client` | TypeScript API types and routes from a Python client contract or the live Cloud release (`--cloud`) |
41
41
  | `hypequery generate:manifest` | A browser-safe route manifest for React hooks |
42
42
  | `hypequery pull` / `diff` | Live source inspection and comparison |
43
43
  | `hypequery deployment:status` | Live release and Cloud-provided connection URLs |
44
44
 
45
45
  Non-interactive ClickHouse commands read `CLICKHOUSE_URL`, `CLICKHOUSE_DATABASE`, `CLICKHOUSE_USERNAME`, and `CLICKHOUSE_PASSWORD`.
46
46
 
47
+ ## Generate a client for Cloud datasets
48
+
49
+ After `hypequery login`, generate typed React routes and types from the live
50
+ Cloud release, or have every publish regenerate them:
51
+
52
+ ```bash
53
+ npx hypequery generate:client --cloud --output src/hypequery-client.ts
54
+ npx hypequery deploy analytics/cloud.ts --client src/hypequery-client.ts
55
+ ```
56
+
57
+ The file also exports `clientBaseUrl`. See
58
+ [Connect React to hypequery Cloud](https://hypequery.com/docs/react/cloud).
59
+
47
60
  ## Generate a client for Python endpoints
48
61
 
49
62
  Export a build contract from your Python definition-only factory, then generate
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AA0CpC,QAAA,MAAM,OAAO,SAAgB,CAAC;AAE9B,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;;EAKpE;AAySD,OAAO,EAAE,OAAO,EAAE,CAAC"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AA0CpC,QAAA,MAAM,OAAO,SAAgB,CAAC;AAE9B,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;;EAKpE;AAgTD,OAAO,EAAE,OAAO,EAAE,CAAC"}
package/dist/cli.js CHANGED
@@ -166,11 +166,15 @@ program
166
166
  await generateDatasetsCommand(options);
167
167
  }));
168
168
  program
169
- .command('generate:client <contract>')
170
- .description('Generate browser-safe TypeScript API types and routes from a client contract JSON file')
169
+ .command('generate:client [contract]')
170
+ .description('Generate browser-safe TypeScript API types and routes from a client contract JSON file or the live Cloud release')
171
171
  .option('-o, --output <path>', 'Output TypeScript file (default: analytics/hypequery-client.ts)')
172
172
  .option('--check', 'Check for drift without writing')
173
173
  .option('--force', 'Replace an existing output file')
174
+ .option('--cloud', 'Use the live Cloud release of the target from `hypequery login`')
175
+ .option('--project <project>', 'With --cloud: target project identifier (advanced override)')
176
+ .option('--environment <environment>', 'With --cloud: target environment identifier (advanced override)')
177
+ .option('--endpoint <url>', 'With --cloud: HTTPS submission endpoint; requires HYPEQUERY_API_TOKEN')
174
178
  .action(runCommand(async (contract, options) => {
175
179
  await generateClientCommand(contract, options);
176
180
  }));
@@ -208,6 +212,7 @@ program
208
212
  .command('deployment:submit <bundle>')
209
213
  .description('Submit a prebuilt deployment bundle and release')
210
214
  .requiredOption('--release <path>', 'Target-bound release JSON path')
215
+ .option('--draft <name>', 'Save a named Cloud draft without publishing Production')
211
216
  .option('--endpoint <url>', 'HTTPS submission endpoint; requires HYPEQUERY_API_TOKEN')
212
217
  .option('--replace-restored', 'Intentionally replace a restored live release')
213
218
  .action(runCommand(async (bundle, options) => {
@@ -231,9 +236,11 @@ program
231
236
  .option('--project <project>', 'Target project identifier (advanced override)')
232
237
  .option('--environment <environment>', 'Target environment identifier (advanced override)')
233
238
  .option('--no-source', 'Exclude project source files from the deployment bundle')
239
+ .option('--draft <name>', 'Save a named Cloud draft without publishing Production')
234
240
  .option('--endpoint <url>', 'HTTPS submission endpoint; requires HYPEQUERY_API_TOKEN')
235
241
  .option('--replace-restored', 'Intentionally replace a restored live release')
236
242
  .option('--mcp-config', 'Also print MCP client configuration for the hosted endpoint')
243
+ .option('--client <path>', 'Regenerate this typed client file from the new live release')
237
244
  .action(runCommand(async (source, options) => {
238
245
  await deployCommand(source, options);
239
246
  }));
@@ -3,22 +3,27 @@ import { type StoredCloudCredential } from '../utils/cloud-credential-store.js';
3
3
  import { fetchLiveDeployment } from '../utils/live-deployment.js';
4
4
  import { fetchHostedEndpoints } from '../utils/hosted-endpoints.js';
5
5
  import { buildDeploymentCommand, prepareDeploymentReleaseCommand } from './deployment.js';
6
+ import { generateClientCommand } from './generate-client.js';
6
7
  export interface SubmitDeploymentOptions {
7
8
  release?: string;
8
9
  endpoint?: string;
9
10
  replaceRestored?: boolean;
11
+ draft?: string;
10
12
  /** Also print MCP client configuration for the hosted endpoint. */
11
13
  mcpConfig?: boolean;
12
14
  }
13
15
  export interface DeployOptions {
14
16
  endpoint?: string;
15
17
  replaceRestored?: boolean;
18
+ draft?: string;
16
19
  project?: string;
17
20
  environment?: string;
18
21
  bundleOutput?: string;
19
22
  releaseOutput?: string;
20
23
  source?: boolean;
21
24
  mcpConfig?: boolean;
25
+ /** Regenerate this typed client file from the new live release. */
26
+ client?: string;
22
27
  }
23
28
  export interface SubmitDeploymentDependencies {
24
29
  readonly env?: Readonly<Record<string, string | undefined>>;
@@ -31,6 +36,7 @@ export interface DeployDependencies extends SubmitDeploymentDependencies {
31
36
  readonly buildDeployment?: typeof buildDeploymentCommand;
32
37
  readonly prepareDeploymentRelease?: typeof prepareDeploymentReleaseCommand;
33
38
  readonly submitDeployment?: typeof submitDeploymentCommand;
39
+ readonly generateClient?: typeof generateClientCommand;
34
40
  }
35
41
  export declare function submitDeploymentCommand(bundlePath: string | undefined, options?: SubmitDeploymentOptions, dependencies?: SubmitDeploymentDependencies): Promise<DeploymentSubmissionResponse>;
36
42
  export declare function deployCommand(sourcePath: string | undefined, options?: DeployOptions, dependencies?: DeployDependencies): Promise<DeploymentSubmissionResponse>;
@@ -1 +1 @@
1
- {"version":3,"file":"deploy.d.ts","sourceRoot":"","sources":["../../src/commands/deploy.ts"],"names":[],"mappings":"AASA,OAAO,EACL,mCAAmC,EAEnC,KAAK,4BAA4B,EAElC,MAAM,+BAA+B,CAAC;AAEvC,OAAO,EACL,KAAK,qBAAqB,EAC3B,MAAM,oCAAoC,CAAC;AAE5C,OAAO,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAC;AAClE,OAAO,EACL,oBAAoB,EACrB,MAAM,8BAA8B,CAAC;AAEtC,OAAO,EACL,sBAAsB,EACtB,+BAA+B,EAChC,MAAM,iBAAiB,CAAC;AAMzB,MAAM,WAAW,uBAAuB;IACtC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,mEAAmE;IACnE,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,4BAA4B;IAC3C,QAAQ,CAAC,GAAG,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;IAC5D,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,mCAAmC,CAAC;IACtE,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,OAAO,CAAC,qBAAqB,GAAG,IAAI,CAAC,CAAC;IACtE,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,mBAAmB,CAAC;IAChD,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,oBAAoB,CAAC;CACvD;AAED,MAAM,WAAW,kBAAmB,SAAQ,4BAA4B;IACtE,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,sBAAsB,CAAC;IACzD,QAAQ,CAAC,wBAAwB,CAAC,EAAE,OAAO,+BAA+B,CAAC;IAC3E,QAAQ,CAAC,gBAAgB,CAAC,EAAE,OAAO,uBAAuB,CAAC;CAC5D;AA4DD,wBAAsB,uBAAuB,CAC3C,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,OAAO,GAAE,uBAA4B,EACrC,YAAY,GAAE,4BAAiC,GAC9C,OAAO,CAAC,4BAA4B,CAAC,CAiFvC;AAsBD,wBAAsB,aAAa,CACjC,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,OAAO,GAAE,aAAkB,EAC3B,YAAY,GAAE,kBAAuB,GACpC,OAAO,CAAC,4BAA4B,CAAC,CAwCvC"}
1
+ {"version":3,"file":"deploy.d.ts","sourceRoot":"","sources":["../../src/commands/deploy.ts"],"names":[],"mappings":"AASA,OAAO,EACL,mCAAmC,EAEnC,KAAK,4BAA4B,EAElC,MAAM,+BAA+B,CAAC;AAIvC,OAAO,EACL,KAAK,qBAAqB,EAC3B,MAAM,oCAAoC,CAAC;AAE5C,OAAO,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAC;AAClE,OAAO,EACL,oBAAoB,EACrB,MAAM,8BAA8B,CAAC;AAEtC,OAAO,EACL,sBAAsB,EACtB,+BAA+B,EAChC,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAM7D,MAAM,WAAW,uBAAuB;IACtC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mEAAmE;IACnE,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,mEAAmE;IACnE,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,4BAA4B;IAC3C,QAAQ,CAAC,GAAG,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;IAC5D,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,mCAAmC,CAAC;IACtE,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,OAAO,CAAC,qBAAqB,GAAG,IAAI,CAAC,CAAC;IACtE,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,mBAAmB,CAAC;IAChD,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,oBAAoB,CAAC;CACvD;AAED,MAAM,WAAW,kBAAmB,SAAQ,4BAA4B;IACtE,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,sBAAsB,CAAC;IACzD,QAAQ,CAAC,wBAAwB,CAAC,EAAE,OAAO,+BAA+B,CAAC;IAC3E,QAAQ,CAAC,gBAAgB,CAAC,EAAE,OAAO,uBAAuB,CAAC;IAC3D,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,qBAAqB,CAAC;CACxD;AA4DD,wBAAsB,uBAAuB,CAC3C,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,OAAO,GAAE,uBAA4B,EACrC,YAAY,GAAE,4BAAiC,GAC9C,OAAO,CAAC,4BAA4B,CAAC,CA8FvC;AAsBD,wBAAsB,aAAa,CACjC,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,OAAO,GAAE,aAAkB,EAC3B,YAAY,GAAE,kBAAuB,GACpC,OAAO,CAAC,4BAA4B,CAAC,CAyEvC"}
@@ -3,12 +3,15 @@ import path from 'node:path';
3
3
  import { prepareProtocolDeploymentReleaseEnvelope, } from '@hypequery/protocol';
4
4
  import { DEPLOYMENT_BUNDLE_MANIFEST, verifyDeploymentBundle, } from '../utils/deployment-bundle.js';
5
5
  import { createHttpDeploymentUploadTransport, DeploymentUploadError, } from '../utils/deployment-upload.js';
6
+ import { validateDraftDeploymentOptions } from '../utils/draft-deployment.js';
7
+ import { shellQuote } from '../utils/shell-quote.js';
6
8
  import { logger } from '../utils/logger.js';
7
9
  import { resolveDeploymentCredential } from '../utils/cloud-deployment-access.js';
8
10
  import { fetchLiveDeployment } from '../utils/live-deployment.js';
9
11
  import { fetchHostedEndpoints, } from '../utils/hosted-endpoints.js';
10
12
  import { printHostedEndpoints } from '../utils/hosted-endpoints-output.js';
11
13
  import { buildDeploymentCommand, prepareDeploymentReleaseCommand, } from './deployment.js';
14
+ import { generateClientCommand } from './generate-client.js';
12
15
  const MAX_RELEASE_FILE_BYTES = 16 * 1024;
13
16
  const DEFAULT_BUNDLE_PATH = 'analytics/hypequery-deployment';
14
17
  const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
@@ -73,6 +76,7 @@ export async function submitDeploymentCommand(bundlePath, options = {}, dependen
73
76
  + 'Usage: hypequery deployment:submit analytics/hypequery-deployment '
74
77
  + '--release analytics/hypequery-deployment.release.json');
75
78
  }
79
+ validateDraftDeploymentOptions(options);
76
80
  const releasePath = requiredConfiguration(options.release, 'Missing required --release <path>.');
77
81
  const { endpoint: deploymentEndpoint, token } = await resolveDeploymentCredential(options.endpoint, dependencies);
78
82
  let bundle;
@@ -95,7 +99,7 @@ export async function submitDeploymentCommand(bundlePath, options = {}, dependen
95
99
  if (release.release.bundleIdentity !== bundle.identity) {
96
100
  throw new DeploymentUploadError('HQ_UPLOAD_IDENTITY_MISMATCH', 'Release bundle identity does not match the verified deployment bundle.');
97
101
  }
98
- const live = await (dependencies.fetchLive ?? fetchLiveDeployment)({
102
+ const live = options.draft !== undefined ? null : await (dependencies.fetchLive ?? fetchLiveDeployment)({
99
103
  endpoint: deploymentEndpoint,
100
104
  token,
101
105
  target: release.release.target,
@@ -115,8 +119,20 @@ export async function submitDeploymentCommand(bundlePath, options = {}, dependen
115
119
  ? { expectedActivationRevision: live.active?.revision ?? null }
116
120
  : {}),
117
121
  ...(options.replaceRestored ? { replaceRestored: true } : {}),
122
+ ...(options.draft !== undefined ? { draft: options.draft } : {}),
118
123
  };
119
124
  const result = await createTransport(transportOptions).submit(bundle, release);
125
+ if (options.draft !== undefined) {
126
+ if (!result.draft || result.draft.name !== options.draft) {
127
+ throw new DeploymentUploadError('HQ_UPLOAD_INVALID_RESPONSE', 'Deployment service did not acknowledge the requested draft.');
128
+ }
129
+ logger.success(`Draft saved: ${result.draft.name}`);
130
+ logger.info(`Draft ID: ${result.draft.draftId}`);
131
+ logger.info(`Staging endpoint: ${result.draft.stagingUrl}`);
132
+ logger.info(`Release identity: ${result.releaseIdentity}`);
133
+ logger.info('Review and publish this draft in Cloud when ready.');
134
+ return result;
135
+ }
120
136
  logger.success(result.status === 'accepted'
121
137
  ? 'Deployment release accepted'
122
138
  : 'Deployment release already exists');
@@ -150,6 +166,7 @@ async function rejectBundleDirectorySource(sourcePath) {
150
166
  + `${sourcePath} --release <file>\`.`);
151
167
  }
152
168
  export async function deployCommand(sourcePath, options = {}, dependencies = {}) {
169
+ validateDraftDeploymentOptions(options);
153
170
  if (!sourcePath) {
154
171
  throw new Error('Missing Cloud publication module path.\n\n'
155
172
  + 'Usage: hypequery deploy analytics/cloud.ts');
@@ -177,10 +194,39 @@ export async function deployCommand(sourcePath, options = {}, dependencies = {})
177
194
  loadCredential: dependencies.loadCredential,
178
195
  outputFlagLabel: '--release-output',
179
196
  });
180
- return submit(bundlePath, {
197
+ const result = await submit(bundlePath, {
181
198
  release: releasePath,
182
199
  endpoint: options.endpoint,
183
200
  replaceRestored: options.replaceRestored,
184
201
  mcpConfig: options.mcpConfig,
202
+ ...(options.draft !== undefined ? { draft: options.draft } : {}),
185
203
  }, dependencies);
204
+ // A draft isn't live, so the client keeps matching Production.
205
+ if (options.client !== undefined && options.draft !== undefined) {
206
+ logger.info(`${options.client} was not regenerated: drafts are not live, so the client keeps matching the live release.`);
207
+ }
208
+ if (options.client !== undefined && options.draft === undefined) {
209
+ try {
210
+ await (dependencies.generateClient ?? generateClientCommand)(undefined, {
211
+ cloud: true,
212
+ output: options.client,
213
+ force: true,
214
+ endpoint: options.endpoint,
215
+ project: options.project,
216
+ environment: options.environment,
217
+ }, dependencies);
218
+ }
219
+ catch (error) {
220
+ // The release is live either way; say how to catch the client up.
221
+ logger.warn(`Deployed, but ${options.client} was not regenerated: `
222
+ + (error instanceof Error ? error.message : String(error)));
223
+ const flags = [
224
+ ['--project', options.project],
225
+ ['--environment', options.environment],
226
+ ['--endpoint', options.endpoint],
227
+ ].flatMap(([flag, value]) => value === undefined ? [] : [` ${flag} ${shellQuote(value)}`]).join('');
228
+ logger.indent(`Run \`hypequery generate:client --cloud --output ${shellQuote(options.client)} --force${flags}\` to retry.`);
229
+ }
230
+ }
231
+ return result;
186
232
  }
@@ -1 +1 @@
1
- {"version":3,"file":"deployment.d.ts","sourceRoot":"","sources":["../../src/commands/deployment.ts"],"names":[],"mappings":"AAEA,OAAO,EAGL,KAAK,0BAA0B,EAC/B,KAAK,iCAAiC,EACvC,MAAM,qBAAqB,CAAC;AAQ7B,OAAO,EAEL,KAAK,qBAAqB,EAC3B,MAAM,oCAAoC,CAAC;AAE5C,MAAM,WAAW,sBAAsB;IACrC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,+BAA+B;IAC9C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,oCAAoC;IACnD,cAAc,CAAC,EAAE,MAAM,OAAO,CAAC,qBAAqB,GAAG,IAAI,CAAC,CAAC;IAC7D;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAID,wBAAsB,sBAAsB,CAC1C,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,OAAO,GAAE,sBAA2B,GACnC,OAAO,CAAC,0BAA0B,CAAC,CAkCrC;AAUD,wBAAsB,yBAAyB,CAC7C,YAAY,EAAE,MAAM,GAAG,SAAS,GAC/B,OAAO,CAAC,0BAA0B,CAAC,CAqErC;AAiED,wBAAsB,+BAA+B,CACnD,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,OAAO,GAAE,+BAAoC,EAC7C,YAAY,GAAE,oCAAyC,GACtD,OAAO,CAAC,iCAAiC,CAAC,CAkE5C"}
1
+ {"version":3,"file":"deployment.d.ts","sourceRoot":"","sources":["../../src/commands/deployment.ts"],"names":[],"mappings":"AAEA,OAAO,EAGL,KAAK,0BAA0B,EAC/B,KAAK,iCAAiC,EACvC,MAAM,qBAAqB,CAAC;AAQ7B,OAAO,EAEL,KAAK,qBAAqB,EAC3B,MAAM,oCAAoC,CAAC;AAG5C,MAAM,WAAW,sBAAsB;IACrC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,+BAA+B;IAC9C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,oCAAoC;IACnD,cAAc,CAAC,EAAE,MAAM,OAAO,CAAC,qBAAqB,GAAG,IAAI,CAAC,CAAC;IAC7D;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAgBD,wBAAsB,sBAAsB,CAC1C,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,OAAO,GAAE,sBAA2B,GACnC,OAAO,CAAC,0BAA0B,CAAC,CAmCrC;AAUD,wBAAsB,yBAAyB,CAC7C,YAAY,EAAE,MAAM,GAAG,SAAS,GAC/B,OAAO,CAAC,0BAA0B,CAAC,CAqErC;AAiED,wBAAsB,+BAA+B,CACnD,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,OAAO,GAAE,+BAAoC,EAC7C,YAAY,GAAE,oCAAyC,GACtD,OAAO,CAAC,iCAAiC,CAAC,CAkE5C"}
@@ -6,7 +6,17 @@ import { captureDeploymentSourceSnapshot } from '../utils/deployment-source-snap
6
6
  import { loadCloudPublication } from '../utils/load-cloud-publication.js';
7
7
  import { logger } from '../utils/logger.js';
8
8
  import { loadCloudCredential, } from '../utils/cloud-credential-store.js';
9
+ import { unscopedTenantCandidates } from '../utils/tenant-key-candidates.js';
9
10
  const DEFAULT_BUNDLE_OUTPUT = 'analytics/hypequery-deployment';
11
+ /** Name datasets that would show every customer's rows; Cloud asks the same in Customer access. */
12
+ function warnUnscopedTenantCandidates(contract) {
13
+ for (const { dataset, columns } of unscopedTenantCandidates(contract)) {
14
+ logger.warn(`Dataset "${dataset}" has a customer column (${columns.join(', ')}) but no tenantKey, `
15
+ + 'so every caller can read every customer\'s rows.');
16
+ logger.indent(`If each customer should see only their own rows, set tenantKey: '${columns[0]}' on the dataset and deploy again.`);
17
+ logger.indent('If every customer may read all of it, mark it shared when Cloud asks during onboarding, or under Customer access.');
18
+ }
19
+ }
10
20
  export async function buildDeploymentCommand(sourcePath, options = {}) {
11
21
  if (!sourcePath) {
12
22
  throw new Error('Missing Cloud publication module path.\n\n'
@@ -33,6 +43,7 @@ export async function buildDeploymentCommand(sourcePath, options = {}) {
33
43
  + `${prepared.contract.datasets.length === 1 ? 'dataset' : 'datasets'}, 0 runtime artifacts`);
34
44
  logger.info(`Bundle identity: ${bundle.identity}`);
35
45
  logger.info(`Deployment identity: ${prepared.identity}`);
46
+ warnUnscopedTenantCandidates(prepared.contract);
36
47
  return prepared.contract;
37
48
  }
38
49
  function describeContract(contract) {
@@ -1,7 +1,19 @@
1
+ import { type CloudDeploymentAccessDependencies } from '../utils/cloud-deployment-access.js';
2
+ import { fetchHostedEndpoints } from '../utils/hosted-endpoints.js';
3
+ import type { LiveDeploymentFetch } from '../utils/live-deployment.js';
1
4
  export interface GenerateClientOptions {
2
5
  output?: string;
3
6
  check?: boolean;
4
7
  force?: boolean;
8
+ /** Read the live Cloud release's contract instead of a file (target and credential from `hypequery login`). */
9
+ cloud?: boolean;
10
+ project?: string;
11
+ environment?: string;
12
+ endpoint?: string;
5
13
  }
6
- export declare function generateClientCommand(artifact: string, options?: GenerateClientOptions): Promise<void>;
14
+ export interface GenerateClientDependencies extends CloudDeploymentAccessDependencies {
15
+ readonly fetch?: LiveDeploymentFetch;
16
+ readonly fetchEndpoints?: typeof fetchHostedEndpoints;
17
+ }
18
+ export declare function generateClientCommand(artifact: string | undefined, options?: GenerateClientOptions, dependencies?: GenerateClientDependencies): Promise<void>;
7
19
  //# sourceMappingURL=generate-client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"generate-client.d.ts","sourceRoot":"","sources":["../../src/commands/generate-client.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,qBAAqB;IAAG,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE;AAE5F,wBAAsB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,qBAA0B,GAAG,OAAO,CAAC,IAAI,CAAC,CA8BhH"}
1
+ {"version":3,"file":"generate-client.d.ts","sourceRoot":"","sources":["../../src/commands/generate-client.ts"],"names":[],"mappings":"AAKA,OAAO,EAA+B,KAAK,iCAAiC,EAAE,MAAM,qCAAqC,CAAC;AAG1H,OAAO,EAAE,oBAAoB,EAAE,MAAM,8BAA8B,CAAC;AACpE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAC;AAKvE,MAAM,WAAW,qBAAqB;IACpC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,+GAA+G;IAC/G,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,0BAA2B,SAAQ,iCAAiC;IACnF,QAAQ,CAAC,KAAK,CAAC,EAAE,mBAAmB,CAAC;IACrC,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,oBAAoB,CAAC;CACvD;AAcD,wBAAsB,qBAAqB,CACzC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,OAAO,GAAE,qBAA0B,EACnC,YAAY,GAAE,0BAA+B,GAC5C,OAAO,CAAC,IAAI,CAAC,CAgDf"}
@@ -1,25 +1,56 @@
1
1
  import path from 'node:path';
2
2
  import { decodeProtocolClientContract } from '@hypequery/protocol';
3
- import { renderClientContract } from '../generators/client-contract/render.js';
4
- import { checkClientOutputPath, readClientArtifact, readClientOutput } from '../utils/client-contract-file.js';
3
+ import { GENERATED_CLIENT_HEADER, renderClientContract } from '../generators/client-contract/render.js';
4
+ import { checkClientOutputPath, describeClientContractError, readClientArtifact, readClientOutput } from '../utils/client-contract-file.js';
5
+ import { clientOrigin, fetchCloudClientContract } from '../utils/cloud-client-contract.js';
6
+ import { resolveDeploymentCredential } from '../utils/cloud-deployment-access.js';
7
+ import { deploymentTargetFromOptions } from '../utils/deployment-target.js';
5
8
  import { writeGeneratedFileAtomically } from '../utils/generated-file.js';
9
+ import { fetchHostedEndpoints } from '../utils/hosted-endpoints.js';
6
10
  import { logger } from '../utils/logger.js';
7
11
  import { updateCommandTelemetry, setCommandTelemetryError } from '../utils/telemetry/command-context.js';
8
12
  import { countBucket } from '../utils/telemetry/buckets.js';
9
- export async function generateClientCommand(artifact, options = {}) {
13
+ /** The live release's contract, and the origin it is served from when Cloud says. */
14
+ async function readCloudClientContract(options, dependencies) {
15
+ const access = await resolveDeploymentCredential(options.endpoint, dependencies);
16
+ const target = deploymentTargetFromOptions(options, access.storedCredential);
17
+ const request = { endpoint: access.endpoint, token: access.token, target, fetch: dependencies.fetch };
18
+ const [text, endpoints] = await Promise.all([
19
+ fetchCloudClientContract(request),
20
+ (dependencies.fetchEndpoints ?? fetchHostedEndpoints)(request),
21
+ ]);
22
+ return { text, baseUrl: clientOrigin(endpoints?.rest.baseUrl) };
23
+ }
24
+ export async function generateClientCommand(artifact, options = {}, dependencies = {}) {
10
25
  updateCommandTelemetry('generate:client', { mode: options.check ? 'check' : 'write', force: !!options.force, custom_output: options.output !== undefined });
11
26
  if (options.force && options.check)
12
27
  throw new Error('--force cannot be combined with --check.');
13
- const input = path.resolve(artifact);
28
+ if (options.cloud ? artifact !== undefined : artifact === undefined) {
29
+ throw new Error(options.cloud
30
+ ? 'Pass a client contract file or --cloud, not both.'
31
+ : 'Missing client contract. Pass a contract JSON file, or --cloud for the live Cloud release.');
32
+ }
33
+ if (!options.cloud && (options.project ?? options.environment ?? options.endpoint) !== undefined) {
34
+ throw new Error('--project, --environment and --endpoint apply only with --cloud.');
35
+ }
14
36
  const output = path.resolve(options.output ?? 'analytics/hypequery-client.ts');
15
- if (input === output)
37
+ if (artifact !== undefined && path.resolve(artifact) === output)
16
38
  throw new Error('Client input and output must be different files.');
17
39
  await checkClientOutputPath(output);
18
40
  const current = await readClientOutput(output);
19
- const contract = decodeProtocolClientContract(await readClientArtifact(input));
41
+ const { text, baseUrl } = artifact === undefined
42
+ ? await readCloudClientContract(options, dependencies)
43
+ : { text: await readClientArtifact(path.resolve(artifact)), baseUrl: undefined };
44
+ let contract;
45
+ try {
46
+ contract = decodeProtocolClientContract(text);
47
+ }
48
+ catch (error) {
49
+ throw describeClientContractError(error);
50
+ }
20
51
  if (contract.view !== 'build')
21
52
  throw new Error('Client generation requires a build view exported by the definition owner.');
22
- const contents = renderClientContract(contract);
53
+ const contents = renderClientContract(contract, { baseUrl });
23
54
  if (Buffer.byteLength(contents) > 16 * 1_048_576)
24
55
  throw new Error('Generated client exceeds the 16 MiB output limit.');
25
56
  updateCommandTelemetry('generate:client', { endpoint_count_bucket: countBucket(contract.endpoints.length) });
@@ -37,6 +68,9 @@ export async function generateClientCommand(artifact, options = {}) {
37
68
  }
38
69
  if (current !== undefined && !options.force)
39
70
  throw new Error('Refusing to overwrite client types; use --check or --force.');
71
+ if (current !== undefined && !current.startsWith(GENERATED_CLIENT_HEADER)) {
72
+ throw new Error(`Refusing to overwrite ${output}: it was not generated by hypequery generate:client.`);
73
+ }
40
74
  await checkClientOutputPath(output);
41
75
  if (await readClientOutput(output) !== current)
42
76
  throw new Error('Client output changed during generation; retry after reviewing it.');
@@ -1,3 +1,9 @@
1
+ export interface RenderClientContractOptions {
2
+ /** Where the endpoints are served; emitted as `clientBaseUrl` (its origin, since paths are absolute). */
3
+ readonly baseUrl?: string;
4
+ }
5
+ /** First line of every generated client; overwrites are refused for files without it. */
6
+ export declare const GENERATED_CLIENT_HEADER = "// Generated by hypequery generate:client.";
1
7
  /** Data-only generator: no API module evaluation, credentials, drivers or server dependencies. */
2
- export declare function renderClientContract(source: unknown): string;
8
+ export declare function renderClientContract(source: unknown, options?: RenderClientContractOptions): string;
3
9
  //# sourceMappingURL=render.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../../../src/generators/client-contract/render.ts"],"names":[],"mappings":"AAmCA,kGAAkG;AAClG,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,CAqB5D"}
1
+ {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../../../src/generators/client-contract/render.ts"],"names":[],"mappings":"AAmCA,MAAM,WAAW,2BAA2B;IAC1C,yGAAyG;IACzG,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,yFAAyF;AACzF,eAAO,MAAM,uBAAuB,+CAA+C,CAAC;AAEpF,kGAAkG;AAClG,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,OAAO,EAAE,OAAO,GAAE,2BAAgC,GAAG,MAAM,CAqBvG"}
@@ -29,12 +29,14 @@ function endpointDoc(endpoint) {
29
29
  const metric = endpoint.kind === 'metric' ? endpoint.annotations?.fields?.[Object.keys(endpoint.measures)[0]] : undefined;
30
30
  return metric ?? endpoint.annotations;
31
31
  }
32
+ /** First line of every generated client; overwrites are refused for files without it. */
33
+ export const GENERATED_CLIENT_HEADER = '// Generated by hypequery generate:client.';
32
34
  /** Data-only generator: no API module evaluation, credentials, drivers or server dependencies. */
33
- export function renderClientContract(source) {
35
+ export function renderClientContract(source, options = {}) {
34
36
  const contract = validateProtocolClientContract(source);
35
37
  const endpoints = [...contract.endpoints].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
36
38
  const entries = endpoints.map(endpoint => ({ endpoint, key: endpoint.kind === 'dataset' ? `dataset:${endpoint.id}` : endpoint.id }));
37
- return `// Generated by hypequery generate:client. Do not edit.
39
+ return `${GENERATED_CLIENT_HEADER} Do not edit.
38
40
  // Runtime validation still enforces selection dependencies, timezones and query budgets.
39
41
  export interface ClientResponseMeta {
40
42
  requestId: string;
@@ -50,5 +52,5 @@ export const clientManifest = {
50
52
  ${entries.map(({ endpoint, key }) => ` [${literal(key)}]: { method: 'POST', path: ${literal(endpoint.path)}, schemaHash: ${literal(endpoint.schemaHash)} },`).join('\n')}
51
53
  } as const;
52
54
  export const clientContractHash = ${literal(contract.contentHash)} as const;
53
- `;
55
+ ${options.baseUrl === undefined ? '' : `export const clientBaseUrl = ${literal(new URL(options.baseUrl).origin)} as const;\n`}`;
54
56
  }
@@ -1 +1 @@
1
- {"version":3,"file":"dataset-generator.d.ts","sourceRoot":"","sources":["../../src/generators/dataset-generator.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,KAAK,EAAE,8BAA8B,EAAE,MAAM,qBAAqB,CAAC;AAG1E,MAAM,WAAW,uBAAuB;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,MAAM,CAAC,EAAE,8BAA8B,CAAC;IACxC;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,8EAA8E;IAC9E,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,MAAM,MAAM,wBAAwB,GAChC;IACE,IAAI,EAAE,sBAAsB,CAAC;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;CACjB,GACD;IACE,IAAI,EAAE,uBAAuB,CAAC;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAkSN;;GAEG;AACH,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,uBAAuB;;;;;;;;GAyEtE"}
1
+ {"version":3,"file":"dataset-generator.d.ts","sourceRoot":"","sources":["../../src/generators/dataset-generator.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,KAAK,EAAE,8BAA8B,EAAE,MAAM,qBAAqB,CAAC;AAU1E,MAAM,WAAW,uBAAuB;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,MAAM,CAAC,EAAE,8BAA8B,CAAC;IACxC;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,8EAA8E;IAC9E,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,MAAM,MAAM,wBAAwB,GAChC;IACE,IAAI,EAAE,sBAAsB,CAAC;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;CACjB,GACD;IACE,IAAI,EAAE,uBAAuB,CAAC;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAySN;;GAEG;AACH,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,uBAAuB;;;;;;;;GAyEtE"}
@@ -7,69 +7,24 @@
7
7
  import fs from 'fs/promises';
8
8
  import path from 'path';
9
9
  import { getClickHouseClient } from '../utils/clickhouse-client.js';
10
+ import { clickHouseDimensionType, namedTupleElements, } from '../utils/clickhouse-dimension-type.js';
11
+ import { createFieldNamer } from '../utils/field-namer.js';
12
+ import { isTenantKeyCandidateName } from '../utils/tenant-key-candidates.js';
13
+ const SQL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
10
14
  /**
11
- * Determine the dataset dimension type from a ClickHouse type
12
- */
13
- function clickhouseToDimensionType(chType) {
14
- const normalized = chType.toLowerCase();
15
- // String types
16
- if (normalized.includes('string') || normalized.includes('enum')) {
17
- return 'string';
18
- }
19
- // Numeric types
20
- if (normalized.includes('int') ||
21
- normalized.includes('float') ||
22
- normalized.includes('double') ||
23
- normalized.includes('decimal')) {
24
- return 'number';
25
- }
26
- // Date/Time types
27
- if (normalized.includes('date') || normalized.includes('datetime')) {
28
- return 'timestamp';
29
- }
30
- // Boolean
31
- if (normalized.includes('bool')) {
32
- return 'boolean';
33
- }
34
- // Default to string for unknown types
35
- return 'string';
36
- }
37
- /**
38
- * Check if a column is likely a timestamp column
15
+ * Whether a column can be the dataset's timeKey: its values are dates or times.
39
16
  */
40
17
  function isTimestampColumn(column) {
41
- const name = column.name.toLowerCase();
42
- const type = column.type.toLowerCase();
43
- return (type.includes('date') ||
44
- type.includes('datetime') ||
45
- name.includes('timestamp') ||
46
- name.includes('_at') ||
47
- name === 'created' ||
48
- name === 'updated');
18
+ return clickHouseDimensionType(column.type) === 'timestamp';
49
19
  }
50
- /**
51
- * Check if a column name is worth presenting as a possible tenant key.
52
- *
53
- * A naming match is never enough to activate tenant enforcement. `tenantKey`
54
- * is a security boundary that must be chosen explicitly by the application.
55
- */
56
20
  function isTenantKeyCandidate(column) {
57
- const name = column.name.toLowerCase();
58
- return (name === 'tenant_id' ||
59
- name === 'organization_id' ||
60
- name === 'org_id' ||
61
- name === 'account_id' ||
62
- name === 'customer_id');
21
+ return isTenantKeyCandidateName(column.name);
63
22
  }
64
23
  /**
65
24
  * Check if a column is numeric and suitable for measures
66
25
  */
67
26
  function isNumericColumn(column) {
68
- const type = column.type.toLowerCase();
69
- return (type.includes('int') ||
70
- type.includes('float') ||
71
- type.includes('double') ||
72
- type.includes('decimal'));
27
+ return clickHouseDimensionType(column.type) === 'number';
73
28
  }
74
29
  /**
75
30
  * Check if a numeric column is more likely an identifier than an additive value.
@@ -169,39 +124,93 @@ async function generateDatasetForTable(client, tableName, tenantColumn) {
169
124
  'Review them and add tenantKey explicitly only after configuring trusted runtime tenant scope.',
170
125
  });
171
126
  }
172
- // Generate dimensions
127
+ // Generate dimensions. A named tuple becomes one dimension per scalar
128
+ // element; columns no dimension type describes are left out with a note.
129
+ const fields = [];
130
+ const skippedColumns = [];
131
+ for (const column of columns) {
132
+ const dimensionType = clickHouseDimensionType(column.type);
133
+ if (dimensionType) {
134
+ fields.push({ column, type: dimensionType, name: columnToCamelCase(column.name) });
135
+ continue;
136
+ }
137
+ const elements = SQL_IDENTIFIER.test(column.name) ? namedTupleElements(column.type) : null;
138
+ if (elements?.length) {
139
+ for (const element of elements) {
140
+ fields.push({
141
+ column,
142
+ element: element.name,
143
+ type: element.type,
144
+ name: columnToCamelCase(`${column.name}_${element.name}`),
145
+ });
146
+ }
147
+ continue;
148
+ }
149
+ skippedColumns.push(column);
150
+ }
151
+ // Field names are SQL aliases, so none may shadow a source column another
152
+ // field reads. A column spelled like its field keeps that name, then other
153
+ // columns, then tuple elements; a clash takes a numeric suffix instead.
154
+ const nameField = createFieldNamer(columns.map((column) => column.name));
155
+ const naming = [
156
+ ...fields.filter((field) => !field.element && field.name === field.column.name),
157
+ ...fields.filter((field) => !field.element && field.name !== field.column.name),
158
+ ...fields.filter((field) => field.element),
159
+ ];
160
+ for (const field of naming) {
161
+ const name = nameField(field.name, field.element ? undefined : field.column.name);
162
+ if (name !== field.name)
163
+ field.renamedFrom = field.name;
164
+ field.name = name;
165
+ }
166
+ const fieldNameByColumn = new Map(fields.filter((field) => !field.element).map((field) => [field.column.name, field.name]));
173
167
  const dimensionLines = [];
174
168
  for (const column of columns) {
175
- const fieldName = columnToCamelCase(column.name);
176
- const dimensionType = clickhouseToDimensionType(column.type);
177
- const label = generateLabel(column.name);
178
- // Skip if column name === fieldName (no transformation needed)
179
- if (column.name === fieldName) {
180
- dimensionLines.push(` ${fieldName}: dimension.${dimensionType}({ label: '${label}' }),`);
169
+ if (skippedColumns.includes(column)) {
170
+ // ClickHouse can spell a nested type over several lines; keep the note on one comment line.
171
+ const type = column.type.replace(/\s+/g, ' ');
172
+ dimensionLines.push(` // ${column.name} (${type}) is not included: no dimension type describes its values.`);
173
+ continue;
181
174
  }
182
- else {
183
- dimensionLines.push(` ${fieldName}: dimension.${dimensionType}({ column: '${column.name}', label: '${label}' }),`);
175
+ for (const field of fields.filter((candidate) => candidate.column === column)) {
176
+ const label = generateLabel(column.name);
177
+ if (field.renamedFrom) {
178
+ dimensionLines.push(` // ${field.element ? `${column.name}.${field.element}` : column.name} is ${field.name}: ${field.renamedFrom} is taken by another field or column.`);
179
+ }
180
+ if (field.element) {
181
+ dimensionLines.push(` ${field.name}: dimension.${field.type}({ sql: "tupleElement(${column.name}, '${field.element}')", ` +
182
+ `dependencies: ['${column.name}'], label: '${label} ${generateLabel(field.element)}' }),`);
183
+ }
184
+ else if (column.name === field.name) {
185
+ // No column option needed when the field is spelled like the column
186
+ dimensionLines.push(` ${field.name}: dimension.${field.type}({ label: '${label}' }),`);
187
+ }
188
+ else {
189
+ dimensionLines.push(` ${field.name}: dimension.${field.type}({ column: '${column.name}', label: '${label}' }),`);
190
+ }
184
191
  }
185
192
  }
186
193
  // Generate measures for numeric value columns only. IDs and coordinates stay
187
194
  // dimensions because summing or averaging them is usually noise.
188
195
  const numericColumns = columns.filter(isMeasureCandidate);
189
196
  const measureLines = [];
197
+ const countField = fields[0]?.name;
190
198
  if (numericColumns.length > 0) {
191
199
  measureLines.push(` // Count measures`);
192
- measureLines.push(` totalCount: measure.count('${columnToCamelCase(columns[0].name)}', { label: 'Total Count' }),`);
200
+ measureLines.push(` ${nameField('totalCount')}: measure.count('${countField}', { label: 'Total Count' }),`);
193
201
  measureLines.push(``);
194
202
  for (const column of numericColumns) {
195
- const fieldName = columnToCamelCase(column.name);
203
+ const fieldName = fieldNameByColumn.get(column.name);
204
+ const measureSuffix = fieldName.charAt(0).toUpperCase() + fieldName.slice(1);
196
205
  const label = generateLabel(column.name);
197
206
  measureLines.push(` // ${label} measures`);
198
- measureLines.push(` total${tableToPascalCase(column.name)}: measure.sum('${fieldName}', { label: 'Total ${label}' }),`);
199
- measureLines.push(` avg${tableToPascalCase(column.name)}: measure.avg('${fieldName}', { label: 'Average ${label}' }),`);
207
+ measureLines.push(` ${nameField(`total${measureSuffix}`)}: measure.sum('${fieldName}', { label: 'Total ${label}' }),`);
208
+ measureLines.push(` ${nameField(`avg${measureSuffix}`)}: measure.avg('${fieldName}', { label: 'Average ${label}' }),`);
200
209
  measureLines.push(``);
201
210
  }
202
211
  }
203
- else {
204
- measureLines.push(` totalCount: measure.count('${columnToCamelCase(columns[0].name)}', { label: 'Total Count' }),`);
212
+ else if (countField) {
213
+ measureLines.push(` ${nameField('totalCount')}: measure.count('${countField}', { label: 'Total Count' }),`);
205
214
  }
206
215
  // Build the dataset definition
207
216
  const datasetName = tableToPascalCase(tableName);
@@ -1 +1 @@
1
- {"version":3,"file":"cloud.d.ts","sourceRoot":"","sources":["../../src/templates/cloud.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,wBAAgB,qBAAqB,IAAI,MAAM,CAS9C"}
1
+ {"version":3,"file":"cloud.d.ts","sourceRoot":"","sources":["../../src/templates/cloud.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,wBAAgB,qBAAqB,IAAI,MAAM,CAY9C"}
@@ -5,6 +5,9 @@ import { datasets } from './datasets.js';
5
5
 
6
6
  export const cloud = publishToCloud({
7
7
  datasets,
8
+ // Empty roles and scopes still require authentication, but let any
9
+ // authenticated caller read every dataset. A dataset without tenantKey shows
10
+ // every customer's rows; review both in Cloud under Customer access.
8
11
  access: { roles: [], scopes: [] },
9
12
  });
10
13
  `;