@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.
- package/README.md +14 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +9 -2
- package/dist/commands/deploy.d.ts +6 -0
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +48 -2
- package/dist/commands/deployment.d.ts.map +1 -1
- package/dist/commands/deployment.js +11 -0
- package/dist/commands/generate-client.d.ts +13 -1
- package/dist/commands/generate-client.d.ts.map +1 -1
- package/dist/commands/generate-client.js +41 -7
- package/dist/generators/client-contract/render.d.ts +7 -1
- package/dist/generators/client-contract/render.d.ts.map +1 -1
- package/dist/generators/client-contract/render.js +5 -3
- package/dist/generators/dataset-generator.d.ts.map +1 -1
- package/dist/generators/dataset-generator.js +77 -68
- package/dist/templates/cloud.d.ts.map +1 -1
- package/dist/templates/cloud.js +3 -0
- package/dist/utils/clickhouse-dimension-type.d.ts +27 -0
- package/dist/utils/clickhouse-dimension-type.d.ts.map +1 -0
- package/dist/utils/clickhouse-dimension-type.js +61 -0
- package/dist/utils/clickhouse-type-utils.d.ts.map +1 -1
- package/dist/utils/clickhouse-type-utils.js +9 -1
- package/dist/utils/client-contract-file.d.ts +5 -0
- package/dist/utils/client-contract-file.d.ts.map +1 -1
- package/dist/utils/client-contract-file.js +49 -5
- package/dist/utils/cloud-client-contract.d.ts +20 -0
- package/dist/utils/cloud-client-contract.d.ts.map +1 -0
- package/dist/utils/cloud-client-contract.js +52 -0
- package/dist/utils/deployment-upload.d.ts +6 -2
- package/dist/utils/deployment-upload.d.ts.map +1 -1
- package/dist/utils/deployment-upload.js +12 -2
- package/dist/utils/draft-deployment.d.ts +16 -0
- package/dist/utils/draft-deployment.d.ts.map +1 -0
- package/dist/utils/draft-deployment.js +35 -0
- package/dist/utils/field-namer.d.ts +10 -0
- package/dist/utils/field-namer.d.ts.map +1 -0
- package/dist/utils/field-namer.js +20 -0
- package/dist/utils/json-pointer.d.ts +3 -0
- package/dist/utils/json-pointer.d.ts.map +1 -0
- package/dist/utils/json-pointer.js +4 -0
- package/dist/utils/live-deployment.d.ts +1 -1
- package/dist/utils/live-deployment.d.ts.map +1 -1
- package/dist/utils/read-capped.d.ts +6 -0
- package/dist/utils/read-capped.d.ts.map +1 -0
- package/dist/utils/read-capped.js +36 -0
- package/dist/utils/shell-quote.d.ts +6 -0
- package/dist/utils/shell-quote.d.ts.map +1 -0
- package/dist/utils/shell-quote.js +7 -0
- package/dist/utils/telemetry/catalog.d.ts +1 -1
- package/dist/utils/telemetry/domains.d.ts +6 -6
- package/dist/utils/telemetry/domains.js +6 -6
- package/dist/utils/tenant-key-candidates.d.ts +19 -0
- package/dist/utils/tenant-key-candidates.d.ts.map +1 -0
- package/dist/utils/tenant-key-candidates.js +63 -0
- 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;
|
|
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
|
|
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;
|
|
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"}
|
package/dist/commands/deploy.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
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
|
|
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":"
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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,
|
|
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
|
|
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;
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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('${
|
|
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 =
|
|
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${
|
|
199
|
-
measureLines.push(` avg${
|
|
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('${
|
|
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,
|
|
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"}
|
package/dist/templates/cloud.js
CHANGED
|
@@ -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
|
`;
|