diff --git a/.changeset/add-cip-topic-and-reports.md b/.changeset/add-cip-topic-and-reports.md new file mode 100644 index 00000000..2b0ea197 --- /dev/null +++ b/.changeset/add-cip-topic-and-reports.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': minor +'@salesforce/b2c-tooling-sdk': minor +--- + +Add a new `cip` command topic for Commerce Intelligence platform (CCAC - Commerce Cloud Analytics) with `cip query` for raw SQL and curated `cip report ` subcommands for analytics workflows, including CIP host override support and tenant-based CIP instance targeting. diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 39f546a7..c311eb3d 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -49,6 +49,7 @@ const guideSidebar = [ items: [ {text: 'Authentication Setup', link: '/guide/authentication'}, {text: 'Account Manager', link: '/guide/account-manager'}, + {text: 'Analytics Reports (CIP/CCAC)', link: '/guide/analytics-reports-cip-ccac'}, {text: 'IDE Integration', link: '/guide/ide-integration'}, {text: 'Scaffolding', link: '/guide/scaffolding'}, {text: 'Security', link: '/guide/security'}, @@ -68,6 +69,7 @@ const guideSidebar = [ {text: 'Overview', link: '/cli/'}, {text: 'Code Commands', link: '/cli/code'}, {text: 'Content Commands', link: '/cli/content'}, + {text: 'CIP Commands', link: '/cli/cip'}, {text: 'Job Commands', link: '/cli/jobs'}, {text: 'Logs Commands', link: '/cli/logs'}, {text: 'Sites Commands', link: '/cli/sites'}, diff --git a/docs/api-readme.md b/docs/api-readme.md index 0dee5a72..acec4f26 100644 --- a/docs/api-readme.md +++ b/docs/api-readme.md @@ -24,6 +24,7 @@ The SDK is organized into focused submodules that can be imported individually: ├── /logging # Pino-based logging configuration │ ├── /operations/code # Code deployment, cartridge management +├── /operations/cip # Curated CIP analytics reports and SQL helpers ├── /operations/jobs # Job execution, site archive import/export ├── /operations/logs # Log tailing and retrieval ├── /operations/mrt # Managed Runtime bundle operations @@ -37,9 +38,9 @@ The SDK is organized into focused submodules that can be imported individually: Import from specific submodules to access their functionality: ```typescript -import { resolveConfig } from '@salesforce/b2c-tooling-sdk/config'; -import { findAndDeployCartridges } from '@salesforce/b2c-tooling-sdk/operations/code'; -import { tailLogs } from '@salesforce/b2c-tooling-sdk/operations/logs'; +import {resolveConfig} from '@salesforce/b2c-tooling-sdk/config'; +import {findAndDeployCartridges} from '@salesforce/b2c-tooling-sdk/operations/code'; +import {tailLogs} from '@salesforce/b2c-tooling-sdk/operations/logs'; ``` ## Quick Start @@ -47,24 +48,24 @@ import { tailLogs } from '@salesforce/b2c-tooling-sdk/operations/logs'; ### B2C Instance Operations ```typescript -import { B2CInstance } from '@salesforce/b2c-tooling-sdk'; +import {B2CInstance} from '@salesforce/b2c-tooling-sdk'; const instance = new B2CInstance( - { hostname: 'your-sandbox.demandware.net', codeVersion: 'v1' }, - { oauth: { clientId: 'your-client-id', clientSecret: 'your-client-secret' } } + {hostname: 'your-sandbox.demandware.net', codeVersion: 'v1'}, + {oauth: {clientId: 'your-client-id', clientSecret: 'your-client-secret'}}, ); // Typed WebDAV client await instance.webdav.put('Cartridges/v1/app.zip', zipBuffer); // Typed OCAPI client (openapi-fetch) -const { data } = await instance.ocapi.GET('/sites'); +const {data} = await instance.ocapi.GET('/sites'); ``` ### Job Execution ```typescript -import { executeJob, waitForJob } from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import {executeJob, waitForJob} from '@salesforce/b2c-tooling-sdk/operations/jobs'; const execution = await executeJob(instance, 'MyCustomJob'); const result = await waitForJob(instance, 'MyCustomJob', execution.id!); @@ -73,30 +74,33 @@ const result = await waitForJob(instance, 'MyCustomJob', execution.id!); ### Platform Service Clients ```typescript -import { createSlasClient, OAuthStrategy } from '@salesforce/b2c-tooling-sdk'; +import {createSlasClient, OAuthStrategy} from '@salesforce/b2c-tooling-sdk'; const auth = new OAuthStrategy({ clientId: 'your-client-id', clientSecret: 'your-client-secret', }); -const slasClient = createSlasClient({ shortCode: 'kv7kzm78' }, auth); -const { data } = await slasClient.GET('/tenants/{tenantId}/clients', { - params: { path: { tenantId: 'your-tenant' } }, +const slasClient = createSlasClient({shortCode: 'kv7kzm78'}, auth); +const {data} = await slasClient.GET('/tenants/{tenantId}/clients', { + params: {path: {tenantId: 'your-tenant'}}, }); ``` ### MRT Operations ```typescript -import { pushBundle, ApiKeyStrategy } from '@salesforce/b2c-tooling-sdk'; +import {pushBundle, ApiKeyStrategy} from '@salesforce/b2c-tooling-sdk'; const auth = new ApiKeyStrategy(process.env.MRT_API_KEY!); -const result = await pushBundle({ - projectSlug: 'my-storefront', - buildDirectory: './build', - target: 'staging', -}, auth); +const result = await pushBundle( + { + projectSlug: 'my-storefront', + buildDirectory: './build', + target: 'staging', + }, + auth, +); ``` ## Configuration Resolution @@ -112,12 +116,12 @@ Configuration is loaded from multiple sources with the following priority (highe 3. **~/.mobify** - Home directory file for MRT API key ```typescript -import { resolveConfig } from '@salesforce/b2c-tooling-sdk/config'; +import {resolveConfig} from '@salesforce/b2c-tooling-sdk/config'; // Override specific values, rest loaded from dw.json const config = resolveConfig({ - hostname: process.env.SFCC_SERVER, // Override hostname - clientId: process.env.SFCC_CLIENT_ID, // Override from env + hostname: process.env.SFCC_SERVER, // Override hostname + clientId: process.env.SFCC_CLIENT_ID, // Override from env clientSecret: process.env.SFCC_CLIENT_SECRET, }); ``` @@ -140,8 +144,8 @@ if (config.hasMrtConfig()) { } // Other validation methods -config.hasOAuthConfig(); // OAuth credentials available? -config.hasBasicAuthConfig(); // Basic auth credentials available? +config.hasOAuthConfig(); // OAuth credentials available? +config.hasBasicAuthConfig(); // Basic auth credentials available? ``` ## Authentication @@ -170,7 +174,7 @@ Used for WebDAV operations (Business Manager credentials): const config = resolveConfig({ username: 'admin', password: 'your-access-key', - clientId: 'your-client-id', // Still needed for OCAPI + clientId: 'your-client-id', // Still needed for OCAPI clientSecret: 'your-client-secret', }); @@ -187,24 +191,62 @@ The SDK provides typed clients for B2C Commerce APIs. All clients use [openapi-f These clients are accessed via `B2CInstance` for operations on a specific B2C Commerce instance: -| Client | Description | API Reference | -|--------|-------------|---------------| -| [WebDavClient](./clients/classes/WebDavClient.md) | File operations (upload, download, list) | WebDAV | +| Client | Description | API Reference | +| ---------------------------------------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| [WebDavClient](./clients/classes/WebDavClient.md) | File operations (upload, download, list) | WebDAV | | [OcapiClient](./clients/type-aliases/OcapiClient.md) | Data API operations (sites, jobs, code versions) | [OCAPI Data API](https://developer.salesforce.com/docs/commerce/b2c-commerce/references/b2c-commerce-ocapi/b2c-api-doc.html) | ### Platform Service Clients These clients are created directly for platform-wide services: -| Client | Description | API Reference | -|--------|-------------|---------------| -| [SlasClient](./clients/type-aliases/SlasClient.md) | SLAS tenant and client management | [SLAS Admin API](https://developer.salesforce.com/docs/commerce/commerce-api/references/slas-admin?meta=Summary) | -| [OdsClient](./clients/type-aliases/OdsClient.md) | On-demand sandbox management | [ODS REST API](https://developer.salesforce.com/docs/commerce/b2c-commerce/references/ods-rest-api?meta=Summary) | -| [MrtClient](./clients/type-aliases/MrtClient.md) | Managed Runtime projects and deployments | [MRT Admin API](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/references/mrt-admin?meta=Summary) | -| [MrtB2CClient](./clients/type-aliases/MrtB2CClient.md) | MRT B2C Commerce integration | [MRT B2C Config API](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/references/mrt-b2c-config?meta=Summary) | -| [CdnZonesClient](./clients/type-aliases/CdnZonesClient.md) | eCDN zone and cache management | [CDN Zones API](https://developer.salesforce.com/docs/commerce/commerce-api/references/cdn-api-process-apis?meta=Summary) | -| [ScapiSchemasClient](./clients/type-aliases/ScapiSchemasClient.md) | SCAPI schema discovery | [SCAPI Schemas API](https://developer.salesforce.com/docs/commerce/commerce-api/references/scapi-schemas?meta=Summary) | -| [CustomApisClient](./clients/type-aliases/CustomApisClient.md) | Custom SCAPI endpoint status | [Custom APIs](https://developer.salesforce.com/docs/commerce/commerce-api/references/custom-apis?meta=Summary) | +| Client | Description | API Reference | +| ------------------------------------------------------------------ | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| [SlasClient](./clients/type-aliases/SlasClient.md) | SLAS tenant and client management | [SLAS Admin API](https://developer.salesforce.com/docs/commerce/commerce-api/references/slas-admin?meta=Summary) | +| [OdsClient](./clients/type-aliases/OdsClient.md) | On-demand sandbox management | [ODS REST API](https://developer.salesforce.com/docs/commerce/b2c-commerce/references/ods-rest-api?meta=Summary) | +| [MrtClient](./clients/type-aliases/MrtClient.md) | Managed Runtime projects and deployments | [MRT Admin API](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/references/mrt-admin?meta=Summary) | +| [MrtB2CClient](./clients/type-aliases/MrtB2CClient.md) | MRT B2C Commerce integration | [MRT B2C Config API](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/references/mrt-b2c-config?meta=Summary) | +| [CdnZonesClient](./clients/type-aliases/CdnZonesClient.md) | eCDN zone and cache management | [CDN Zones API](https://developer.salesforce.com/docs/commerce/commerce-api/references/cdn-api-process-apis?meta=Summary) | +| [ScapiSchemasClient](./clients/type-aliases/ScapiSchemasClient.md) | SCAPI schema discovery | [SCAPI Schemas API](https://developer.salesforce.com/docs/commerce/commerce-api/references/scapi-schemas?meta=Summary) | +| [CustomApisClient](./clients/type-aliases/CustomApisClient.md) | Custom SCAPI endpoint status | [Custom APIs](https://developer.salesforce.com/docs/commerce/commerce-api/references/custom-apis?meta=Summary) | +| `CipClient` | B2C Commerce Intelligence (CIP/CCAC) query execution | [JDBC Driver Intro](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_intro.html) | + +### CIP Analytics (SDK) + +Use the CIP client directly for raw SQL, or combine it with metadata + curated report operations. + +```typescript +import { + OAuthStrategy, + createCipClient, + describeCipTable, + executeCipReport, + listCipTables, +} from '@salesforce/b2c-tooling-sdk'; + +const auth = new OAuthStrategy({ + clientId: process.env.SFCC_CLIENT_ID!, + clientSecret: process.env.SFCC_CLIENT_SECRET!, +}); + +const cip = createCipClient({instance: 'zzxy_prd'}, auth); + +// Metadata discovery +const tables = await listCipTables(cip, {schema: 'warehouse', tableNamePattern: 'ccdw_aggr_%'}); +const columns = await describeCipTable(cip, 'ccdw_aggr_ocapi_request', {schema: 'warehouse'}); + +// Curated report execution +const report = await executeCipReport(cip, 'sales-analytics', { + params: { + siteId: 'Sites-RefArch-Site', + from: '2025-01-01', + to: '2025-01-31', + }, +}); + +// Or run raw SQL directly +const raw = await cip.query('SELECT submit_date, num_orders FROM ccdw_aggr_sales_summary LIMIT 10'); +``` ### WebDAV Client @@ -225,19 +267,19 @@ const content = await instance.webdav.get('Cartridges/v1/app.zip'); ```typescript // List sites -const { data, error } = await instance.ocapi.GET('/sites', { - params: { query: { select: '(**)' } }, +const {data, error} = await instance.ocapi.GET('/sites', { + params: {query: {select: '(**)'}}, }); // Get a specific site -const { data, error } = await instance.ocapi.GET('/sites/{site_id}', { - params: { path: { site_id: 'RefArch' } }, +const {data, error} = await instance.ocapi.GET('/sites/{site_id}', { + params: {path: {site_id: 'RefArch'}}, }); // Activate a code version -const { data, error } = await instance.ocapi.PATCH('/code_versions/{code_version_id}', { - params: { path: { code_version_id: 'v1' } }, - body: { active: true }, +const {data, error} = await instance.ocapi.PATCH('/code_versions/{code_version_id}', { + params: {path: {code_version_id: 'v1'}}, + body: {active: true}, }); ``` @@ -256,8 +298,8 @@ For CI/CD and automation, you can also use **OAuth client credentials flow** (re The recommended approach is to use the unified `createAccountManagerClient`, which provides access to all Account Manager APIs (users, roles, organizations, and API clients): ```typescript -import { createAccountManagerClient } from '@salesforce/b2c-tooling-sdk/clients'; -import { ImplicitOAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; +import {createAccountManagerClient} from '@salesforce/b2c-tooling-sdk/clients'; +import {ImplicitOAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; // Create Account Manager client with implicit OAuth (opens browser for login) const auth = new ImplicitOAuthStrategy({ @@ -265,13 +307,10 @@ const auth = new ImplicitOAuthStrategy({ // No clientSecret needed for implicit flow }); -const client = createAccountManagerClient( - { accountManagerHost: 'account.demandware.com' }, - auth, -); +const client = createAccountManagerClient({accountManagerHost: 'account.demandware.com'}, auth); // Users API -const users = await client.listUsers({ size: 25, page: 0 }); +const users = await client.listUsers({size: 25, page: 0}); const user = await client.getUser('user-id'); const userByLogin = await client.findUserByLogin('user@example.com'); await client.createUser({ @@ -281,24 +320,24 @@ await client.createUser({ organizations: ['org-id'], primaryOrganization: 'org-id', }); -await client.updateUser('user-id', { firstName: 'Jane' }); +await client.updateUser('user-id', {firstName: 'Jane'}); await client.grantRole('user-id', 'bm-admin', 'tenant1,tenant2'); await client.revokeRole('user-id', 'bm-admin', 'tenant1'); await client.resetUser('user-id'); await client.deleteUser('user-id'); // Roles API -const roles = await client.listRoles({ size: 20, page: 0 }); +const roles = await client.listRoles({size: 20, page: 0}); const role = await client.getRole('bm-admin'); // Organizations API -const orgs = await client.listOrgs({ size: 25, page: 0 }); +const orgs = await client.listOrgs({size: 25, page: 0}); const org = await client.getOrg('org-id'); const orgByName = await client.getOrgByName('My Organization'); const auditLogs = await client.getOrgAuditLogs('org-id'); // API Clients API (service accounts for programmatic access) -const apiClients = await client.listApiClients({ size: 20, page: 0 }); +const apiClients = await client.listApiClients({size: 20, page: 0}); const apiClient = await client.getApiClient('api-client-uuid', ['organizations', 'roles']); await client.createApiClient({ name: 'my-client', @@ -306,7 +345,7 @@ await client.createApiClient({ password: 'SecureP@ss12', active: false, }); -await client.updateApiClient('api-client-uuid', { name: 'new-name', active: true }); +await client.updateApiClient('api-client-uuid', {name: 'new-name', active: true}); await client.changeApiClientPassword('api-client-uuid', 'oldPassword', 'newPassword12'); await client.deleteApiClient('api-client-uuid'); // Client must be disabled 7+ days first ``` @@ -316,8 +355,8 @@ await client.deleteApiClient('api-client-uuid'); // Client must be disabled 7+ d For automation and CI/CD, you can use client credentials flow: ```typescript -import { createAccountManagerClient } from '@salesforce/b2c-tooling-sdk/clients'; -import { OAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; +import {createAccountManagerClient} from '@salesforce/b2c-tooling-sdk/clients'; +import {OAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; // Create Account Manager client with client credentials OAuth const auth = new OAuthStrategy({ @@ -325,10 +364,7 @@ const auth = new OAuthStrategy({ clientSecret: 'your-client-secret', }); -const client = createAccountManagerClient( - { accountManagerHost: 'account.demandware.com' }, - auth, -); +const client = createAccountManagerClient({accountManagerHost: 'account.demandware.com'}, auth); // Use the unified client as shown above ``` @@ -344,48 +380,36 @@ import { createAccountManagerOrgsClient, createAccountManagerApiClientsClient, } from '@salesforce/b2c-tooling-sdk/clients'; -import { ImplicitOAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; +import {ImplicitOAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; const auth = new ImplicitOAuthStrategy({ clientId: 'your-client-id', }); // Users client -const usersClient = createAccountManagerUsersClient( - { accountManagerHost: 'account.demandware.com' }, - auth, -); +const usersClient = createAccountManagerUsersClient({accountManagerHost: 'account.demandware.com'}, auth); // Roles client -const rolesClient = createAccountManagerRolesClient( - { accountManagerHost: 'account.demandware.com' }, - auth, -); +const rolesClient = createAccountManagerRolesClient({accountManagerHost: 'account.demandware.com'}, auth); // Organizations client -const orgsClient = createAccountManagerOrgsClient( - { accountManagerHost: 'account.demandware.com' }, - auth, -); +const orgsClient = createAccountManagerOrgsClient({accountManagerHost: 'account.demandware.com'}, auth); // API Clients client (service accounts) -const apiClientsClient = createAccountManagerApiClientsClient( - { accountManagerHost: 'account.demandware.com' }, - auth, -); +const apiClientsClient = createAccountManagerApiClientsClient({accountManagerHost: 'account.demandware.com'}, auth); ``` ### User Operations ```typescript -import { createAccountManagerClient } from '@salesforce/b2c-tooling-sdk/clients'; -import { ImplicitOAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; +import {createAccountManagerClient} from '@salesforce/b2c-tooling-sdk/clients'; +import {ImplicitOAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; -const auth = new ImplicitOAuthStrategy({ clientId: 'your-client-id' }); +const auth = new ImplicitOAuthStrategy({clientId: 'your-client-id'}); const client = createAccountManagerClient({}, auth); // List users with pagination -const users = await client.listUsers({ size: 25, page: 0 }); +const users = await client.listUsers({size: 25, page: 0}); // Get user by email/login const user = await client.findUserByLogin('user@example.com'); @@ -403,7 +427,7 @@ const newUser = await client.createUser({ }); // Update a user -await client.updateUser('user-id', { firstName: 'Jane' }); +await client.updateUser('user-id', {firstName: 'Jane'}); // Grant a role to a user await client.grantRole('user-id', 'bm-admin', 'tenant1,tenant2'); // Optional tenant filter @@ -421,17 +445,17 @@ await client.deleteUser('user-id'); ### Role Operations ```typescript -import { createAccountManagerClient } from '@salesforce/b2c-tooling-sdk/clients'; -import { ImplicitOAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; +import {createAccountManagerClient} from '@salesforce/b2c-tooling-sdk/clients'; +import {ImplicitOAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; -const auth = new ImplicitOAuthStrategy({ clientId: 'your-client-id' }); +const auth = new ImplicitOAuthStrategy({clientId: 'your-client-id'}); const client = createAccountManagerClient({}, auth); // Get role details by ID const role = await client.getRole('bm-admin'); // List all roles with pagination -const roles = await client.listRoles({ size: 25, page: 0 }); +const roles = await client.listRoles({size: 25, page: 0}); // List roles filtered by target type const userRoles = await client.listRoles({ @@ -444,10 +468,10 @@ const userRoles = await client.listRoles({ ### Organization Operations ```typescript -import { createAccountManagerClient } from '@salesforce/b2c-tooling-sdk/clients'; -import { ImplicitOAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; +import {createAccountManagerClient} from '@salesforce/b2c-tooling-sdk/clients'; +import {ImplicitOAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; -const auth = new ImplicitOAuthStrategy({ clientId: 'your-client-id' }); +const auth = new ImplicitOAuthStrategy({clientId: 'your-client-id'}); const client = createAccountManagerClient({}, auth); // Get organization by ID @@ -457,10 +481,10 @@ const org = await client.getOrg('org-123'); const orgByName = await client.getOrgByName('My Organization'); // List organizations with pagination -const orgs = await client.listOrgs({ size: 25, page: 0 }); +const orgs = await client.listOrgs({size: 25, page: 0}); // List all organizations (uses max page size of 5000) -const allOrgs = await client.listOrgs({ all: true }); +const allOrgs = await client.listOrgs({all: true}); // Get audit logs for an organization const auditLogs = await client.getOrgAuditLogs('org-123'); @@ -471,14 +495,14 @@ const auditLogs = await client.getOrgAuditLogs('org-123'); Manage Account Manager API clients (service accounts for programmatic access). API clients are created inactive by default and must be disabled for at least 7 days before deletion. ```typescript -import { createAccountManagerClient } from '@salesforce/b2c-tooling-sdk/clients'; -import { ImplicitOAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; +import {createAccountManagerClient} from '@salesforce/b2c-tooling-sdk/clients'; +import {ImplicitOAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; -const auth = new ImplicitOAuthStrategy({ clientId: 'your-client-id' }); +const auth = new ImplicitOAuthStrategy({clientId: 'your-client-id'}); const client = createAccountManagerClient({}, auth); // List API clients with pagination -const result = await client.listApiClients({ size: 20, page: 0 }); +const result = await client.listApiClients({size: 20, page: 0}); // Get API client by ID (optionally expand organizations and roles) const apiClient = await client.getApiClient('api-client-uuid', ['organizations', 'roles']); @@ -492,7 +516,7 @@ const newClient = await client.createApiClient({ }); // Update an API client (only provided fields are updated) -await client.updateApiClient('api-client-uuid', { name: 'new-name', active: true }); +await client.updateApiClient('api-client-uuid', {name: 'new-name', active: true}); // Change API client password await client.changeApiClientPassword('api-client-uuid', 'oldPassword', 'newPassword12'); @@ -504,6 +528,7 @@ await client.deleteApiClient('api-client-uuid'); ### Required Permissions Account Manager operations require: + - Account Manager hostname configuration - For implicit flow: roles configured on your **user account** - For client credentials flow: roles configured on the **API client** @@ -513,11 +538,11 @@ Account Manager operations require: Configure logging for debugging HTTP requests: ```typescript -import { configureLogger } from '@salesforce/b2c-tooling-sdk/logging'; +import {configureLogger} from '@salesforce/b2c-tooling-sdk/logging'; // Enable debug logging (shows HTTP request summaries) -configureLogger({ level: 'debug' }); +configureLogger({level: 'debug'}); // Enable trace logging (shows full request/response with headers and bodies) -configureLogger({ level: 'trace' }); +configureLogger({level: 'trace'}); ``` diff --git a/docs/cli/cip.md b/docs/cli/cip.md new file mode 100644 index 00000000..1f947d47 --- /dev/null +++ b/docs/cli/cip.md @@ -0,0 +1,246 @@ +--- +description: Run raw SQL and curated analytics reports against Commerce Intelligence Platform (CIP). +--- + +# CIP Commands + +Use `b2c cip` to query Commerce Intelligence Platform (CIP/CCAC) analytics data. + +::: tip Production and Non-Production Hosts +By default, CIP uses the production analytics host for tenants ending in `_prd` and the staging analytics host for other tenant IDs. Use `--staging` to force the staging host, or `--cip-host` for an explicit override. +::: + +## Command Overview + +| Command | Description | +| --------------------------------- | ---------------------------------------------- | +| `b2c cip tables` | List tables from the CIP metadata catalog | +| `b2c cip describe ` | Describe columns for a CIP table | +| `b2c cip query` | Run raw SQL (argument, file, or stdin) | +| `b2c cip report` | Report topic help and report command discovery | +| `b2c cip report ` | Run a curated report command | + +::: warning Availability +These commands target Commerce Cloud Analytics (CCAC) data and are primarily used with production analytics tenants. Non-production access is available when Reports & Dashboards data tracking is enabled for supported 26.1+ environments. +::: + +## Authentication + +CIP commands use **OAuth client credentials only**. + +| Requirement | How to provide | +| --------------------- | ---------------------------------------------- | +| Client ID | `--client-id` or `SFCC_CLIENT_ID` | +| Client Secret | `--client-secret` or `SFCC_CLIENT_SECRET` | +| Tenant (CIP instance) | `--tenant-id` / `--tenant` or `SFCC_TENANT_ID` | + +Your API client must include the **Salesforce Commerce API** role with a tenant filter that includes your target instance. + +## Connection and Output Flags + +These flags are available on all CIP commands: + +| Flag | Description | Default | +| -------------- | ------------------------------------- | --------------------------------------------- | +| `--format` | Output format: `table`, `csv`, `json` | `table` | +| `--fetch-size` | Frame fetch size for paging | `1000` | +| `--cip-host` | CIP host override | `jdbc.analytics.commercecloud.salesforce.com` | +| `--staging` | Use staging analytics host | `false` | + +## Query and Report Date Flags + +These flags are available on `cip query` and `cip report ` commands: + +| Flag | Description | Default | +| -------- | --------------------------------- | -------------------------- | +| `--from` | Inclusive start date (YYYY-MM-DD) | First day of current month | +| `--to` | Inclusive end date (YYYY-MM-DD) | Today | + +## b2c cip tables + +List tables from the CIP metadata catalog. + +### Usage + +```bash +b2c cip tables [flags] +``` + +### Flags + +| Flag | Description | +| ----------- | ---------------------------------------------------- | +| `--schema` | Metadata schema to inspect (default: `warehouse`) | +| `--pattern` | Table name pattern using SQL `LIKE` semantics | +| `--all` | Include all table types (default filters to `TABLE`) | + +### Examples + +```bash +# List warehouse tables +b2c cip tables --tenant-id zzxy_prd --client-id --client-secret + +# Filter by table prefix +b2c cip tables --tenant-id zzxy_prd --pattern "ccdw_aggr_%" --client-id --client-secret + +# Include metadata/system tables +b2c cip tables --tenant-id zzxy_prd --schema metadata --all --client-id --client-secret +``` + +## b2c cip describe + +Describe table columns using CIP metadata catalog. + +### Usage + +```bash +b2c cip describe
[flags] +``` + +### Flags + +| Flag | Description | +| ---------- | ----------------------------------------------------------- | +| `--schema` | Metadata schema containing the table (default: `warehouse`) | + +### Examples + +```bash +# Describe a warehouse table +b2c cip describe ccdw_aggr_ocapi_request --tenant-id zzxy_prd --client-id --client-secret + +# Describe metadata system table +b2c cip describe COLUMNS --schema metadata --tenant-id zzxy_prd --client-id --client-secret +``` + +## b2c cip query + +Run raw SQL directly against CIP. + +### Usage + +```bash +b2c cip query [SQL] [flags] +``` + +### SQL Input Sources + +Provide SQL from exactly one source: + +1. Positional argument (`b2c cip query "SELECT ..."`) +2. `--file ` +3. Piped stdin (for example `cat query.sql | b2c cip query ...`) + +### Query-Specific Flags + +| Flag | Description | +| -------------- | ------------------------ | +| `--file`, `-f` | Read SQL query from file | + +### Placeholders + +`cip query` supports placeholder substitution: + +- `` is replaced by `--from` +- `` is replaced by `--to` + +### Examples + +```bash +# Inline SQL argument +b2c cip query \ + --tenant-id zzxy_prd \ + --client-id \ + --client-secret \ + "SELECT submit_date, num_orders FROM ccdw_aggr_sales_summary LIMIT 10" + +# Non-production / staging analytics host +b2c cip query \ + --tenant-id zzxy_stg \ + --staging \ + --client-id \ + --client-secret \ + "SELECT submit_date, num_orders FROM ccdw_aggr_sales_summary LIMIT 10" + +# Read SQL from file +b2c cip query --file ./query.sql --tenant-id zzxy_prd --client-id --client-secret + +# Read SQL from stdin +cat ./query.sql | b2c cip query --tenant-id zzxy_prd --client-id --client-secret +``` + +## b2c cip report + +Run curated reports using dedicated subcommands. + +### Usage + +```bash +b2c cip report --help +b2c cip report [flags] +``` + +### Shared Report Flags + +| Flag | Description | +| ------------ | ------------------------------------------- | +| `--describe` | Show report metadata and parameter contract | +| `--sql` | Print generated SQL and exit | + +Use `--sql` to pipe into `cip query`: + +```bash +b2c cip report sales-analytics --site-id Sites-RefArch-Site --sql \ + | b2c cip query --tenant-id zzxy_prd --client-id --client-secret +``` + +### Report Commands + +| Command | Description | Extra Flags | +| ------------------------------ | ------------------------------------- | ---------------------------- | +| `sales-analytics` | Daily sales performance with AOV/AOS | `--site-id` | +| `sales-summary` | Detailed sales records | `--site-id` (optional) | +| `ocapi-requests` | OCAPI request volume and latency | `--site-id` | +| `top-selling-products` | Top products by units/revenue | `--site-id` | +| `product-co-purchase-analysis` | Frequently co-purchased products | `--site-id` | +| `promotion-discount-analysis` | Promotion discount impact | none | +| `search-query-performance` | Search revenue and conversion metrics | `--site-id`, `--has-results` | +| `payment-method-performance` | Payment method adoption/performance | `--site-id` | +| `customer-registration-trends` | Registration trends by date/device | `--site-id` | +| `top-referrers` | Referrer traffic share | `--site-id`, `--limit` | + +### Site ID Format + +For report commands that accept `--site-id`, the common CIP format is: + +`Sites-{siteId}-Site` + +If your value does not match this pattern, the command warns and still uses your provided value. + +### Examples + +```bash +# Run a report +b2c cip report sales-analytics \ + --site-id Sites-RefArch-Site \ + --from 2025-01-01 \ + --to 2025-01-31 \ + --tenant-id zzxy_prd \ + --client-id \ + --client-secret + +# Show report parameter contract +b2c cip report top-referrers --describe + +# Generate SQL only +b2c cip report top-referrers --site-id Sites-RefArch-Site --limit 25 --sql +``` + +## Output Formats + +Both `cip query` and report commands support: + +- `--format table` (default) +- `--format csv` (writes CSV to stdout) +- `--format json` (writes JSON to stdout) +- `--json` (global JSON mode) diff --git a/docs/cli/index.md b/docs/cli/index.md index 6f9bce6f..2a274672 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -47,6 +47,7 @@ These flags are available on all commands that interact with B2C instances: - [Sandbox Commands](./sandbox) - Create and manage On-Demand Sandboxes - [MRT Commands](./mrt) - Manage Managed Runtime (MRT) projects and deployments - [SLAS Commands](./slas) - Manage Shopper Login and Access Service (SLAS) API clients +- [CIP Commands](./cip) - Run CIP SQL queries and curated analytics reports - [Custom APIs](./custom-apis) - SCAPI Custom API endpoint status ### Development diff --git a/docs/guide/analytics-reports-cip-ccac.md b/docs/guide/analytics-reports-cip-ccac.md new file mode 100644 index 00000000..839bd619 --- /dev/null +++ b/docs/guide/analytics-reports-cip-ccac.md @@ -0,0 +1,267 @@ +--- +description: User guide for running CIP/CCAC analytics reports and SQL queries with the B2C CLI. +--- + +# Analytics Reports (CIP/CCAC) + +The B2C CLI includes a `cip` command family for **B2C Commerce Intelligence (CIP)**, also known as **Commerce Cloud Analytics (CCAC)** reporting. + +It is based on the **B2C Commerce Intelligence JDBC Driver** and gives you three practical workflows: + +- curated report commands (`b2c cip report `) for common analytics use cases +- raw SQL (`b2c cip query`) for custom exploration +- metadata discovery (`b2c cip tables`, `b2c cip describe`) for schema/table inspection + +Official JDBC reference: + +- [B2C Commerce Intelligence JDBC Driver](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_intro.html) + +::: warning Availability +Reports and dashboards are typically used with production tenants (for example, `abcd_prd`) and require Commerce Cloud Analytics (CCAC) to be enabled. +::: + +## Authentication and Access + +`cip` commands require an Account Manager API client configured for **client credentials** authentication. + +Minimum requirements: + +- API client with **Salesforce Commerce API** role +- role tenant filter includes your target production instance (for example `abcd_prd`) +- client ID and client secret available to the CLI + +Recommended environment setup: + +```bash +export SFCC_CLIENT_ID= +export SFCC_CLIENT_SECRET= +export SFCC_TENANT_ID= +``` + +See also: [Authentication Setup](/guide/authentication) + +### Non-Production Support (26.1+) + +Starting with B2C Commerce release **26.1**, reports and dashboards data can also be available for non-production instances, including: + +- On-Demand Sandboxes (ODS) +- Development instances +- Staging instances +- Production instances in designated test realms (realms not serving live traffic) + +To enable this, turn on **Enable Reports & Dashboards Data Tracking** in Business Manager feature switches. + +- Reference: [Set Feature Switches (Toggles) in B2C Commerce](https://help.salesforce.com/s/articleView?id=cc.b2c_feature_switches.htm&type=5) +- Provisioning can take up to **2 hours** after enabling + +Reports & Dashboards non-production URL: + +- `https://ccac.stg.analytics.commercecloud.salesforce.com` + +For CLI commands, you can target the staging analytics host with `--staging`. + +### Host Selection Behavior + +- tenant IDs ending in `_prd` use production host by default +- other tenant IDs use staging analytics host by default +- `--staging` forces staging host +- `--cip-host` overrides host selection explicitly + +## Quick Start + +### Curated reports (`cip report`) + +Start by discovering and running a curated report command: + +```bash +# discover available report commands +b2c cip report --help + +# run a report +b2c cip report sales-analytics \ + --tenant-id abcd_prd \ + --site-id Sites-RefArch-Site \ + --from 2025-01-01 \ + --to 2025-01-31 +``` + +Example output: + +```text +date std_revenue orders std_aov units aos std_tax std_shipping +───────────────────────────────────────────────────────────────────────────── +2026-02-03 227.92 1 227.92 2 2 11.7 13.98 +2026-02-04 227.92 1 227.92 2 2 11.4 9.99 +``` + +Inspect generated SQL before running: + +```bash +b2c cip report sales-analytics --tenant-id abcd_prd --site-id Sites-RefArch-Site --sql +``` + +Pipe generated SQL into raw query execution: + +```bash +b2c cip report sales-analytics --tenant-id abcd_prd --site-id Sites-RefArch-Site --sql \ + | b2c cip query --tenant-id abcd_prd + +# force staging analytics host +b2c cip report sales-analytics --tenant-id abcd_prd --site-id Sites-RefArch-Site --staging --sql +``` + +For machine-readable report output: + +```bash +b2c cip report sales-analytics --tenant-id abcd_prd --site-id Sites-RefArch-Site --format json +b2c cip report sales-analytics --tenant-id abcd_prd --site-id Sites-RefArch-Site --format csv +``` + +### Raw SQL (`cip query`) + +You can run direct SQL with `b2c cip query`. This is useful for custom questions or lightweight troubleshooting. + +The example below is a simplified OCAPI traffic query (similar in intent to the curated `ocapi-requests` report command): + +```bash +b2c cip query \ + --tenant-id abcd_prd \ + --from 2026-02-03 \ + --to 2026-02-04 \ + "SELECT request_date, api_name, SUM(num_requests) AS total_requests + FROM ccdw_aggr_ocapi_request + WHERE request_date >= '' + AND request_date <= '' + GROUP BY request_date, api_name + ORDER BY request_date, total_requests DESC + LIMIT 5" +``` + +`cip query` supports token substitution for date filters: + +- `` -> value of `--from` +- `` -> value of `--to` + +If your SQL does not include these tokens, the query is sent unchanged. + +Example output: + +```text +request_date api_name total_requests +────────────────────────────────────── +2026-02-03 shop 120 +2026-02-04 data 98 +``` + +Use `--format json` or `--format csv` when you need machine-readable output. + +### Metadata discovery (`cip tables`, `cip describe`) + +Use metadata commands to discover table names and inspect columns before writing larger SQL queries. + +```bash +# list table names in warehouse schema +b2c cip tables --tenant-id abcd_prd --pattern "ccdw_aggr_%" + +# inspect table columns +b2c cip describe ccdw_aggr_ocapi_request --tenant-id abcd_prd +``` + +## Choosing Query vs Report + +Use `cip report` when you want: + +- stable, reusable report semantics +- safer parameter handling +- fast onboarding for common sales/search/payment analytics + +Use `cip query` when you need: + +- fully custom SQL +- exploratory analysis over additional tables/joins +- iterative SQL tuning + +## Rate Limits and Query Discipline + +::: warning Tight Limits +The JDBC analytics service enforces query timeout, quota, and rate limits, and these limits can change over time. + +Always check the official documentation before designing high-volume workloads. + +- [B2C Commerce Intelligence JDBC Access Guide](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_access_guide.html) +- See the **Non-Functional Requirements (NFR)** section (Query Execution Timeout, Quota Limit, Rate Limit) + ::: + +Practical guidance: + +- prefer aggregate tables over large fact tables when possible +- avoid `SELECT *`; request only required columns +- keep date ranges narrow and run incremental windows +- test with smaller windows first, then scale up + +Reference source for limits and best practices: + +- [Setting Up the B2C Commerce Intelligence JDBC Driver](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_access_guide.html) + +## Site ID Parameter Note + +Many curated reports use `--site-id`. + +Common CIP format: + +`Sites-{siteId}-Site` + +The CLI warns if the value does not match that pattern, but it does not rewrite your input. + +## JSON Output + +For scripting and automation, use: + +- `--json` for standard CLI JSON mode +- `--format json` to print JSON to stdout for query/report output paths + +## SDK Support + +If you're building applications or automation directly in TypeScript/Node.js, the SDK exposes CIP support: + +- `createCipClient` for raw SQL execution +- `listCipTables`, `describeCipTable` for table/column metadata discovery +- `buildCipReportSql`, `listCipReports`, `executeCipReport` for curated report workflows + +Example: + +```ts +import {OAuthStrategy, createCipClient, executeCipReport} from '@salesforce/b2c-tooling-sdk'; + +const auth = new OAuthStrategy({ + clientId: process.env.SFCC_CLIENT_ID!, + clientSecret: process.env.SFCC_CLIENT_SECRET!, +}); + +const cip = createCipClient({instance: 'abcd_prd'}, auth); + +// Raw SQL +const raw = await cip.query('SELECT submit_date, num_orders FROM ccdw_aggr_sales_summary LIMIT 10'); + +// Curated report +const report = await executeCipReport(cip, 'sales-analytics', { + params: { + siteId: 'Sites-RefArch-Site', + from: '2025-01-01', + to: '2025-01-31', + }, +}); +``` + +See the SDK API reference: + +- [API Reference](/api/) +- [CipClient class](/api/clients/classes/CipClient) +- [createCipClient helper](/api/clients/functions/createCipClient) +- [CIP Operations API](/api/operations/cip/) + +## Next Steps + +- [CIP Commands](/cli/cip) for full command reference and flags +- [Configuration](/guide/configuration) for env vars and `dw.json` settings +- [Authentication Setup](/guide/authentication) for API client role and tenant filter setup diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md index a504367d..69211ac9 100644 --- a/docs/guide/authentication.md +++ b/docs/guide/authentication.md @@ -10,16 +10,17 @@ This guide covers setting up authentication for the B2C CLI, including Account M The CLI uses different authentication mechanisms depending on the operation: -| Operation | Auth Method | Setup Required | -|-----------|-------------|----------------| -| [Code](/cli/code) deploy, watch (file upload) | WebDAV (Basic Auth or OAuth) | [WebDAV Access](#webdav-access) | -| [Code](/cli/code) list, activate, delete | OAuth + OCAPI | [API Client](#account-manager-api-client) + [OCAPI](#ocapi-configuration) | -| [Jobs](/cli/jobs), [Sites](/cli/sites) | OAuth + OCAPI | [API Client](#account-manager-api-client) + [OCAPI](#ocapi-configuration) | -| SCAPI commands ([schemas](/cli/scapi-schemas), [custom-apis](/cli/custom-apis), [eCDN](/cli/ecdn)) | OAuth + SCAPI scopes | [API Client](#account-manager-api-client) + [SCAPI Scopes](#scapi-authentication) | -| [SLAS](/cli/slas) client management | OAuth | None (uses built-in client) or [API Client](#account-manager-api-client) | -| [Sandbox](/cli/sandbox) management | OAuth | None (uses built-in client) or [API Client](#account-manager-api-client) | -| [Account Manager](/cli/account-manager) | OAuth | None (uses built-in client) or [API Client](#account-manager-api-client) | -| [MRT](/cli/mrt) commands | MRT API Key | [MRT API Key](#managed-runtime-api-key) | +| Operation | Auth Method | Setup Required | +| -------------------------------------------------------------------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------- | +| [Code](/cli/code) deploy, watch (file upload) | WebDAV (Basic Auth or OAuth) | [WebDAV Access](#webdav-access) | +| [Code](/cli/code) list, activate, delete | OAuth + OCAPI | [API Client](#account-manager-api-client) + [OCAPI](#ocapi-configuration) | +| [Jobs](/cli/jobs), [Sites](/cli/sites) | OAuth + OCAPI | [API Client](#account-manager-api-client) + [OCAPI](#ocapi-configuration) | +| SCAPI commands ([schemas](/cli/scapi-schemas), [custom-apis](/cli/custom-apis), [eCDN](/cli/ecdn)) | OAuth + SCAPI scopes | [API Client](#account-manager-api-client) + [SCAPI Scopes](#scapi-authentication) | +| [CIP analytics](/cli/cip) (`cip query`, `cip report`) | OAuth + Client Credentials | [API Client](#account-manager-api-client) + Salesforce Commerce API role + tenant filter | +| [SLAS](/cli/slas) client management | OAuth | None (uses built-in client) or [API Client](#account-manager-api-client) | +| [Sandbox](/cli/sandbox) management | OAuth | None (uses built-in client) or [API Client](#account-manager-api-client) | +| [Account Manager](/cli/account-manager) | OAuth | None (uses built-in client) or [API Client](#account-manager-api-client) | +| [MRT](/cli/mrt) commands | MRT API Key | [MRT API Key](#managed-runtime-api-key) | ::: tip Zero-Config for Platform Commands Sandbox, SLAS, and Account Manager commands work out of the box without any client configuration. The CLI includes a built-in public client that authenticates via browser login (implicit flow). You only need to configure an API client if you want to use client credentials for automation/CI or need specific scopes. @@ -37,10 +38,10 @@ Most CLI operations require an Account Manager API Client. This is configured in The CLI supports two authentication methods: -| Method | When Used | Role Configuration | -|--------|-----------|-------------------| +| Method | When Used | Role Configuration | +| ----------------------- | -------------------------------------------------------------------------------- | ----------------------------------------- | | **User Authentication** | When `--user-auth` is passed, or when only `--client-id` is provided (no secret) | Roles configured on your **user account** | -| **Client Credentials** | When both `--client-id` and `--client-secret` are provided | Roles configured on the **API client** | +| **Client Credentials** | When both `--client-id` and `--client-secret` are provided | Roles configured on the **API client** | **User Authentication** opens a browser for interactive login and uses roles assigned to your user account. This is ideal for development and manual operations. Use `--user-auth` as a shorthand for `--auth-methods implicit` on any OAuth command. @@ -69,11 +70,11 @@ Roles grant permission to perform specific operations. Roles are configured diff Most roles require a **tenant filter** that specifies which tenants/realms the role applies to. This is configured alongside the role assignment. -| Role | Operations | Notes | -|------|------------|-------| -| `Salesforce Commerce API` | SCAPI commands (eCDN, schemas, custom-apis) | API Clients only. Requires tenant filter. | -| `Sandbox API User` | ODS management, SLAS client management | Requires tenant filter with realm/org IDs. | -| `SLAS Organization Administrator` | SLAS client management (user auth only) | User accounts only. Requires tenant filter. | +| Role | Operations | Notes | +| --------------------------------- | ----------------------------------------- | ------------------------------------------- | +| `Salesforce Commerce API` | SCAPI commands and CIP analytics commands | API Clients only. Requires tenant filter. | +| `Sandbox API User` | ODS management, SLAS client management | Requires tenant filter with realm/org IDs. | +| `SLAS Organization Administrator` | SLAS client management (user auth only) | User accounts only. Requires tenant filter. | #### For Client Credentials (roles on API Client) @@ -92,21 +93,21 @@ In Account Manager, navigate to your user account and add roles. Note that some Under **Allowed Scopes**, add the following scopes based on your needs: -| Scope | Purpose | -|-------|---------| -| `mail` | Required for user info in authentication flows | -| `roles` | Critical - returns role information in the token | +| Scope | Purpose | +| -------------- | --------------------------------------------------------- | +| `mail` | Required for user info in authentication flows | +| `roles` | Critical - returns role information in the token | | `tenantFilter` | Critical - returns tenant access information in the token | -| `openid` | Required for OpenID Connect | +| `openid` | Required for OpenID Connect | For SCAPI commands, also add the relevant API scopes: -| Scope | Commands | Reference | -|-------|----------|-----------| -| `sfcc.cdn-zones` | eCDN read operations | [eCDN Commands](/cli/ecdn) | -| `sfcc.cdn-zones.rw` | eCDN write operations | [eCDN Commands](/cli/ecdn) | +| Scope | Commands | Reference | +| -------------------- | --------------------- | ----------------------------------- | +| `sfcc.cdn-zones` | eCDN read operations | [eCDN Commands](/cli/ecdn) | +| `sfcc.cdn-zones.rw` | eCDN write operations | [eCDN Commands](/cli/ecdn) | | `sfcc.scapi-schemas` | SCAPI schema browsing | [SCAPI Schemas](/cli/scapi-schemas) | -| `sfcc.custom-apis` | Custom API status | [Custom APIs](/cli/custom-apis) | +| `sfcc.custom-apis` | Custom API status | [Custom APIs](/cli/custom-apis) | **Note:** Do NOT add `SALESFORCE_COMMERCE_API` as a scope. This is a role, not a scope. @@ -136,9 +137,9 @@ These scopes ensure proper authentication and authorization for CLI operations. For **User Authentication** (implicit flow), configure redirect URLs in your API client: -| Redirect URL | Purpose | -|-------------|---------| -| `http://localhost:8080` | Required for B2C CLI user authentication | +| Redirect URL | Purpose | +| -------------------------------------------------------------------- | --------------------------------------------------------- | +| `http://localhost:8080` | Required for B2C CLI user authentication | | `https://admin.dx.commercecloud.salesforce.com/oauth2-redirect.html` | Optional - enables ODS Swagger interface with same client | **Note:** Redirect URLs are not required for API clients using only Client Credentials authentication. @@ -214,6 +215,7 @@ For operations that interact with B2C Commerce instances (code deployment, jobs, ### Minimal Configuration by Feature **Code management only:** + ```json { "resource_id": "/code_versions", @@ -226,6 +228,7 @@ For operations that interact with B2C Commerce instances (code deployment, jobs, ``` **Job execution only:** + ```json { "resource_id": "/jobs/*/executions", @@ -242,6 +245,7 @@ For operations that interact with B2C Commerce instances (code deployment, jobs, ``` **Site listing only:** + ```json { "resource_id": "/sites", @@ -264,12 +268,12 @@ SCAPI commands (eCDN, SCAPI schemas, custom APIs) require OAuth authentication w ### Scopes by Command -| Command | Required Scope | Reference | -|---------|---------------|-----------| -| `b2c scapi schemas list/get` | `sfcc.scapi-schemas` | [SCAPI Schemas](/cli/scapi-schemas) | -| `b2c scapi custom status` | `sfcc.custom-apis` | [Custom APIs](/cli/custom-apis) | -| `b2c ecdn` (read operations) | `sfcc.cdn-zones` | [eCDN](/cli/ecdn) | -| `b2c ecdn` (write operations) | `sfcc.cdn-zones.rw` | [eCDN](/cli/ecdn) | +| Command | Required Scope | Reference | +| ----------------------------- | -------------------- | ----------------------------------- | +| `b2c scapi schemas list/get` | `sfcc.scapi-schemas` | [SCAPI Schemas](/cli/scapi-schemas) | +| `b2c scapi custom status` | `sfcc.custom-apis` | [Custom APIs](/cli/custom-apis) | +| `b2c ecdn` (read operations) | `sfcc.cdn-zones` | [eCDN](/cli/ecdn) | +| `b2c ecdn` (write operations) | `sfcc.cdn-zones.rw` | [eCDN](/cli/ecdn) | The CLI automatically requests these scopes. Your API client must have them in the Allowed Scopes list. @@ -323,9 +327,9 @@ If you prefer to use OAuth credentials for WebDAV (instead of basic auth), you m { "client_id": "your-client-id", "permissions": [ - { "path": "/cartridges", "operations": ["read_write"] }, - { "path": "/impex", "operations": ["read_write"] }, - { "path": "/logs", "operations": ["read_write"] } + {"path": "/cartridges", "operations": ["read_write"]}, + {"path": "/impex", "operations": ["read_write"]}, + {"path": "/logs", "operations": ["read_write"]} ] } ] @@ -334,12 +338,12 @@ If you prefer to use OAuth credentials for WebDAV (instead of basic auth), you m Common paths for CLI operations: -| Path | Operations | -|------|------------| -| `/cartridges` | Code deployment | -| `/impex` | Site import/export | -| `/logs` | Log file access | -| `/catalogs/` | Catalog file access | +| Path | Operations | +| ------------------------- | ---------------------- | +| `/cartridges` | Code deployment | +| `/impex` | Site import/export | +| `/logs` | Log file access | +| `/catalogs/` | Catalog file access | | `/libraries/` | Content library access | **Note:** This configuration is only needed when using OAuth for WebDAV. It is not required when using basic authentication with username/access key. @@ -390,6 +394,7 @@ Add the JSON configuration shown in [OCAPI Configuration](#ocapi-configuration) ### 3. Configure WebDAV Access (for code deploy/watch, webdav commands) Either: + - Use your BM username + WebDAV access key (recommended), or - Configure WebDAV Client Permissions for OAuth diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 1374f5f0..c1eb36c2 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -55,31 +55,33 @@ See [Configure WebDAV File Access](https://help.salesforce.com/s/articleView?id= You can configure the CLI using environment variables: -| Variable | Description | -|----------|-------------| -| `SFCC_WORKING_DIRECTORY` | Project working directory | -| `SFCC_CONFIG` | Path to config file (dw.json format) | -| `SFCC_INSTANCE` | Instance name from config file | -| `SFCC_SERVER` | The B2C instance hostname | -| `SFCC_WEBDAV_SERVER` | Separate hostname for WebDAV (if different from main hostname) | -| `SFCC_CODE_VERSION` | Code version for deployments | -| `SFCC_CLIENT_ID` | OAuth client ID | -| `SFCC_CLIENT_SECRET` | OAuth client secret | -| `SFCC_OAUTH_SCOPES` | OAuth scopes to request | -| `SFCC_AUTH_METHODS` | Comma-separated list of allowed auth methods | -| `SFCC_SHORTCODE` | SCAPI short code | -| `SFCC_TENANT_ID` | Organization/tenant ID for SCAPI | -| `SFCC_ACCOUNT_MANAGER_HOST` | Account Manager hostname for OAuth | -| `SFCC_USERNAME` | Basic auth username | -| `SFCC_PASSWORD` | Basic auth password | -| `SFCC_CERTIFICATE` | Path to PKCS12 certificate for two-factor auth (mTLS) | -| `SFCC_CERTIFICATE_PASSPHRASE` | Passphrase for the certificate | -| `SFCC_SELFSIGNED` | Allow self-signed server certificates | -| `SFCC_SANDBOX_API_HOST` | ODS (sandbox) API hostname | -| `SFCC_MRT_API_KEY` | MRT API key | -| `SFCC_MRT_PROJECT` | MRT project slug | -| `SFCC_MRT_ENVIRONMENT` | MRT environment name | -| `SFCC_MRT_CLOUD_ORIGIN` | MRT API origin URL override | +| Variable | Description | +| ----------------------------- | -------------------------------------------------------------- | +| `SFCC_WORKING_DIRECTORY` | Project working directory | +| `SFCC_CONFIG` | Path to config file (dw.json format) | +| `SFCC_INSTANCE` | Instance name from config file | +| `SFCC_SERVER` | The B2C instance hostname | +| `SFCC_WEBDAV_SERVER` | Separate hostname for WebDAV (if different from main hostname) | +| `SFCC_CODE_VERSION` | Code version for deployments | +| `SFCC_CLIENT_ID` | OAuth client ID | +| `SFCC_CLIENT_SECRET` | OAuth client secret | +| `SFCC_OAUTH_SCOPES` | OAuth scopes to request | +| `SFCC_AUTH_METHODS` | Comma-separated list of allowed auth methods | +| `SFCC_SHORTCODE` | SCAPI short code | +| `SFCC_TENANT_ID` | Organization/tenant ID for SCAPI | +| `SFCC_ACCOUNT_MANAGER_HOST` | Account Manager hostname for OAuth | +| `SFCC_USERNAME` | Basic auth username | +| `SFCC_PASSWORD` | Basic auth password | +| `SFCC_CERTIFICATE` | Path to PKCS12 certificate for two-factor auth (mTLS) | +| `SFCC_CERTIFICATE_PASSPHRASE` | Passphrase for the certificate | +| `SFCC_SELFSIGNED` | Allow self-signed server certificates | +| `SFCC_SANDBOX_API_HOST` | ODS (sandbox) API hostname | +| `SFCC_CIP_HOST` | CIP analytics host override | +| `SFCC_CIP_STAGING` | Use staging CIP analytics host (`true`/`false`) | +| `SFCC_MRT_API_KEY` | MRT API key | +| `SFCC_MRT_PROJECT` | MRT project slug | +| `SFCC_MRT_ENVIRONMENT` | MRT environment name | +| `SFCC_MRT_CLOUD_ORIGIN` | MRT API origin URL override | ## .env File @@ -203,29 +205,30 @@ For the full command reference with all flags, see [Setup Commands](/cli/setup). ### Supported Fields -| Field | Description | -|-------|-------------| -| `hostname` | B2C instance hostname. Also accepts `server`. | -| `webdav-hostname` | Separate hostname for WebDAV (if different from main hostname). Also accepts `webdav-server`, `secureHostname`, or `secure-server`. | -| `code-version` | Code version for deployments | -| `client-id` | OAuth client ID | -| `client-secret` | OAuth client secret | -| `username` | Basic auth username (WebDAV) | -| `password` | Basic auth access key (WebDAV) | -| `oauth-scopes` | OAuth scopes (array of strings) | -| `auth-methods` | Authentication methods in priority order (array of strings) | -| `account-manager-host` | Account Manager hostname for OAuth | -| `shortCode` | SCAPI short code. Also accepts `short-code` or `scapi-shortcode`. | -| `content-library` | Default content library ID for `content export` and `content list` commands | -| `tenant-id` | Organization/tenant ID for SCAPI | -| `sandbox-api-host` | ODS (sandbox) API hostname | -| `mrtApiKey` | MRT API key | -| `mrtProject` | MRT project slug | -| `mrtEnvironment` | MRT environment name | -| `mrtOrigin` | MRT API origin URL override. Also accepts `cloudOrigin`. | -| `certificate` | Path to PKCS12 certificate for two-factor auth (mTLS) | -| `certificate-passphrase` | Passphrase for the certificate. Also accepts `passphrase`. | -| `self-signed` | Allow self-signed server certificates. Also accepts `selfsigned`. | +| Field | Description | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | +| `hostname` | B2C instance hostname. Also accepts `server`. | +| `webdav-hostname` | Separate hostname for WebDAV (if different from main hostname). Also accepts `webdav-server`, `secureHostname`, or `secure-server`. | +| `code-version` | Code version for deployments | +| `client-id` | OAuth client ID | +| `client-secret` | OAuth client secret | +| `username` | Basic auth username (WebDAV) | +| `password` | Basic auth access key (WebDAV) | +| `oauth-scopes` | OAuth scopes (array of strings) | +| `auth-methods` | Authentication methods in priority order (array of strings) | +| `account-manager-host` | Account Manager hostname for OAuth | +| `shortCode` | SCAPI short code. Also accepts `short-code` or `scapi-shortcode`. | +| `content-library` | Default content library ID for `content export` and `content list` commands | +| `tenant-id` | Organization/tenant ID for SCAPI | +| `sandbox-api-host` | ODS (sandbox) API hostname | +| `cip-host` | CIP analytics host override | +| `mrtApiKey` | MRT API key | +| `mrtProject` | MRT project slug | +| `mrtEnvironment` | MRT environment name | +| `mrtOrigin` | MRT API origin URL override. Also accepts `cloudOrigin`. | +| `certificate` | Path to PKCS12 certificate for two-factor auth (mTLS) | +| `certificate-passphrase` | Passphrase for the certificate. Also accepts `passphrase`. | +| `self-signed` | Allow self-signed server certificates. Also accepts `selfsigned`. | ### Two-Factor Authentication (mTLS) @@ -251,10 +254,10 @@ MRT API key can also be loaded from `~/.mobify`. See [MRT API Key](#mrt-api-key) For multi-instance configurations, each config object also supports: -| Field | Description | -|-------|-------------| -| `name` | Instance name for selection with `-i`/`--instance` | -| `active` | Set to `true` to use this config by default | +| Field | Description | +| -------- | -------------------------------------------------- | +| `name` | Instance name for selection with `-i`/`--instance` | +| `active` | Set to `true` to use this config by default | ## Project Configuration (package.json) @@ -277,15 +280,15 @@ You can store project-level defaults in your `package.json` file under the `b2c` Only non-sensitive, project-level fields can be configured in `package.json`. Both camelCase and kebab-case are accepted (e.g., `shortCode` or `short-code`): -| Field | Description | -|-------|-------------| -| `shortCode` | SCAPI short code | -| `clientId` | OAuth client ID (for implicit login discovery) | -| `contentLibrary` | Default content library ID for `content export` and `content list` commands | -| `mrtProject` | MRT project slug | -| `mrtOrigin` | MRT API origin URL override | -| `accountManagerHost` | Account Manager hostname for OAuth | -| `sandboxApiHost` | ODS (sandbox) API hostname | +| Field | Description | +| -------------------- | --------------------------------------------------------------------------- | +| `shortCode` | SCAPI short code | +| `clientId` | OAuth client ID (for implicit login discovery) | +| `contentLibrary` | Default content library ID for `content export` and `content list` commands | +| `mrtProject` | MRT project slug | +| `mrtOrigin` | MRT API origin URL override | +| `accountManagerHost` | Account Manager hostname for OAuth | +| `sandboxApiHost` | ODS (sandbox) API hostname | ::: warning Security Note Sensitive fields like `hostname`, `password`, `clientSecret`, `username`, and `mrtApiKey` are intentionally **not** supported in `package.json`. These should be configured via `dw.json` (which should be in `.gitignore`), environment variables, or secure credential stores. @@ -320,6 +323,7 @@ To prevent mixing credentials from different sources, certain fields are treated If any field in a group is set by a higher-priority source, all fields in that group from lower-priority sources are ignored. This ensures credential pairs always come from the same source. **Example:** + - dw.json provides `clientId` only - A plugin provides `clientSecret` - Result: Only `clientId` is used; the plugin's `clientSecret` is ignored to prevent mismatched credentials @@ -394,6 +398,7 @@ b2c setup inspect --json ``` This command helps troubleshoot issues like: + - Verifying which configuration file is being used - Checking if environment variables are being read - Understanding credential source priority diff --git a/docs/guide/index.md b/docs/guide/index.md index 0390db29..4ced3541 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -29,6 +29,7 @@ See [Installation](./installation) for more options. ## Next Steps - [Authentication Setup](./authentication) - Set up Account Manager, OCAPI, and WebDAV +- [Analytics Reports (CIP/CCAC)](./analytics-reports-cip-ccac) - Run curated analytics reports and SQL queries - [Configuration](./configuration) - Configure instances and credentials - [IDE Integration](./ide-integration) - Connect Prophet VS Code to B2C CLI configuration - [CLI Reference](/cli/) - Browse available commands diff --git a/packages/b2c-cli/package.json b/packages/b2c-cli/package.json index 0c08cfab..d876f226 100644 --- a/packages/b2c-cli/package.json +++ b/packages/b2c-cli/package.json @@ -107,6 +107,9 @@ "content": { "description": "Export and manage Page Designer content libraries" }, + "cip": { + "description": "Run CIP analytics SQL and curated reports\n\nDocs: https://salesforcecommercecloud.github.io/b2c-developer-tooling/cli/cip.html" + }, "docs": { "description": "Search and read Script API documentation" }, diff --git a/packages/b2c-cli/src/commands/cip/describe.ts b/packages/b2c-cli/src/commands/cip/describe.ts new file mode 100644 index 00000000..4b0d8fca --- /dev/null +++ b/packages/b2c-cli/src/commands/cip/describe.ts @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {Args, Flags} from '@oclif/core'; +import {describeCipTable} from '@salesforce/b2c-tooling-sdk'; +import {withDocs} from '../../i18n/index.js'; +import {CipCommand} from '../../utils/cip/command.js'; +import {renderTable, toCsv, type CipOutputFormat} from '../../utils/cip/format.js'; + +const {from: _unusedFrom, to: _unusedTo, ...cipMetadataFlags} = CipCommand.baseFlags; + +interface CipDescribeCommandResult { + columnCount: number; + columns: Array<{ + column: string; + dataType: string; + isNullable: boolean; + position: number; + }>; + tableName: string; + tableSchema: string; +} + +export default class CipDescribe extends CipCommand { + static args = { + table: Args.string({ + description: 'Table name to describe', + required: true, + }), + }; + + static description = withDocs('Describe columns for a CIP table', '/cli/cip.html#b2c-cip-describe'); + + static enableJsonFlag = true; + + static flags = { + ...cipMetadataFlags, + schema: Flags.string({ + description: 'Metadata schema name', + default: 'warehouse', + helpGroup: 'QUERY', + }), + }; + + async run(): Promise { + this.requireCipCredentials(); + + const client = this.getCipClient(); + const result = await describeCipTable(client, this.args.table, { + fetchSize: this.flags['fetch-size'], + schema: this.flags.schema, + }); + + if (result.columnCount === 0) { + this.error(`No columns found for table "${this.args.table}" in schema "${this.flags.schema}".`); + } + + const rows = result.columns.map((column) => ({ + column: column.columnName, + dataType: column.dataType, + isNullable: column.isNullable, + position: column.ordinalPosition, + })); + + const output: CipDescribeCommandResult = { + columnCount: result.columnCount, + columns: rows, + tableName: result.tableName, + tableSchema: result.tableSchema, + }; + + if (this.jsonEnabled()) { + return output; + } + + if (this.flags.format === 'json') { + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); + return output; + } + + this.renderRows(rows, this.flags.format as CipOutputFormat); + return output; + } + + private renderRows(rows: CipDescribeCommandResult['columns'], format: CipOutputFormat): void { + const columns = ['column', 'dataType', 'isNullable']; + if (format === 'csv') { + process.stdout.write(`${toCsv(columns, rows)}\n`); + return; + } + + renderTable(columns, rows); + } +} diff --git a/packages/b2c-cli/src/commands/cip/query.ts b/packages/b2c-cli/src/commands/cip/query.ts new file mode 100644 index 00000000..4916df5a --- /dev/null +++ b/packages/b2c-cli/src/commands/cip/query.ts @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import fs from 'node:fs'; +import {Args, Flags} from '@oclif/core'; +import {withDocs} from '../../i18n/index.js'; +import {CipCommand} from '../../utils/cip/command.js'; +import {renderTable, toCsv, type CipOutputFormat} from '../../utils/cip/format.js'; + +interface CipQueryCommandResult { + columns: string[]; + rowCount: number; + rows: Array>; + sql: string; +} + +export default class CipQuery extends CipCommand { + static args = { + sql: Args.string({ + description: 'SQL query text', + required: false, + }), + }; + + static description = withDocs('Execute raw SQL against CIP analytics', '/cli/cip.html#b2c-cip-query'); + + static enableJsonFlag = true; + + static flags = { + ...CipCommand.baseFlags, + file: Flags.string({ + char: 'f', + description: 'Read SQL query from file', + helpGroup: 'QUERY', + }), + }; + + async run(): Promise { + const sql = this.resolveSql(); + + this.requireCipCredentials(); + const client = this.getCipClient(); + const result = await client.query(sql, {fetchSize: this.flags['fetch-size']}); + const output: CipQueryCommandResult = { + columns: result.columns, + rows: result.rows, + rowCount: result.rowCount, + sql, + }; + + if (this.jsonEnabled()) { + return output; + } + + if (this.flags.format === 'json') { + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); + return output; + } + + this.renderRows(result.columns, result.rows, this.flags.format as CipOutputFormat); + return output; + } + + private renderRows(columns: string[], rows: Array>, format: CipOutputFormat): void { + if (format === 'csv') { + process.stdout.write(`${toCsv(columns, rows)}\n`); + return; + } + + renderTable(columns, rows); + } + + private resolveSql(): string { + const positionalSql = this.args.sql; + const hasPositional = Boolean(positionalSql); + const hasFile = Boolean(this.flags.file); + const sourceCount = [hasPositional, hasFile].filter(Boolean).length; + + if (sourceCount === 0) { + this.error('No SQL provided. Pass SQL as an argument, pipe SQL through stdin, or use --file.'); + } + + if (sourceCount > 1) { + this.error('Provide SQL from exactly one source: positional argument/stdin, or --file.'); + } + + const rawSql = hasFile ? fs.readFileSync(this.flags.file as string, 'utf8') : positionalSql; + + if (!rawSql || rawSql.trim().length === 0) { + this.error('SQL input is empty.'); + } + + let sql = rawSql; + if (this.flags.from) { + sql = sql.replaceAll('', this.flags.from); + } + if (this.flags.to) { + sql = sql.replaceAll('', this.flags.to); + } + + return sql.trim(); + } +} diff --git a/packages/b2c-cli/src/commands/cip/report.ts b/packages/b2c-cli/src/commands/cip/report.ts new file mode 100644 index 00000000..17278f76 --- /dev/null +++ b/packages/b2c-cli/src/commands/cip/report.ts @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {withDocs} from '../../i18n/index.js'; + +/** + * Default report command - shows topic help for CIP report subcommands. + */ +export default class CipReportIndex extends BaseCommand { + static description = withDocs('Run curated CIP analytics reports', '/cli/cip.html#b2c-cip-report'); + + static examples = [ + '<%= config.bin %> cip report --help', + '<%= config.bin %> cip report sales-analytics --site-id Sites-SiteGenesis-Site', + '<%= config.bin %> cip report sales-analytics --site-id Sites-SiteGenesis-Site --sql', + ]; + + async run(): Promise { + await this.config.runCommand('help', ['cip', 'report']); + } +} diff --git a/packages/b2c-cli/src/commands/cip/report/customer-registration-trends.ts b/packages/b2c-cli/src/commands/cip/report/customer-registration-trends.ts new file mode 100644 index 00000000..0fcf8754 --- /dev/null +++ b/packages/b2c-cli/src/commands/cip/report/customer-registration-trends.ts @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {withDocs} from '../../../i18n/index.js'; +import {CipReportCommand} from '../../../utils/cip/report-command.js'; +import {createSiteIdFlag} from '../../../utils/cip/report-flags.js'; + +export default class CipReportCustomerRegistrationTrends extends CipReportCommand< + typeof CipReportCustomerRegistrationTrends +> { + static description = withDocs( + 'Track customer registration trends by date and device', + '/cli/cip.html#b2c-cip-report-customer-registration-trends', + ); + + static enableJsonFlag = true; + + static flags = { + ...CipReportCommand.baseFlags, + ...CipReportCommand.reportFlags, + 'site-id': createSiteIdFlag(), + }; + + protected readonly reportName = 'customer-registration-trends'; + + protected getReportParams(): Record { + return this.getBaseReportParams(); + } +} diff --git a/packages/b2c-cli/src/commands/cip/report/ocapi-requests.ts b/packages/b2c-cli/src/commands/cip/report/ocapi-requests.ts new file mode 100644 index 00000000..c885a864 --- /dev/null +++ b/packages/b2c-cli/src/commands/cip/report/ocapi-requests.ts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {withDocs} from '../../../i18n/index.js'; +import {CipReportCommand} from '../../../utils/cip/report-command.js'; +import {createSiteIdFlag} from '../../../utils/cip/report-flags.js'; + +export default class CipReportOcapiRequests extends CipReportCommand { + static description = withDocs( + 'Analyze OCAPI request volume and response latency', + '/cli/cip.html#b2c-cip-report-ocapi-requests', + ); + + static enableJsonFlag = true; + + static flags = { + ...CipReportCommand.baseFlags, + ...CipReportCommand.reportFlags, + 'site-id': createSiteIdFlag(), + }; + + protected readonly reportName = 'ocapi-requests'; + + protected getReportParams(): Record { + return this.getBaseReportParams(); + } +} diff --git a/packages/b2c-cli/src/commands/cip/report/payment-method-performance.ts b/packages/b2c-cli/src/commands/cip/report/payment-method-performance.ts new file mode 100644 index 00000000..1de57b04 --- /dev/null +++ b/packages/b2c-cli/src/commands/cip/report/payment-method-performance.ts @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {withDocs} from '../../../i18n/index.js'; +import {CipReportCommand} from '../../../utils/cip/report-command.js'; +import {createSiteIdFlag} from '../../../utils/cip/report-flags.js'; + +export default class CipReportPaymentMethodPerformance extends CipReportCommand< + typeof CipReportPaymentMethodPerformance +> { + static description = withDocs( + 'Track payment method adoption and transaction metrics', + '/cli/cip.html#b2c-cip-report-payment-method-performance', + ); + + static enableJsonFlag = true; + + static flags = { + ...CipReportCommand.baseFlags, + ...CipReportCommand.reportFlags, + 'site-id': createSiteIdFlag(), + }; + + protected readonly reportName = 'payment-method-performance'; + + protected getReportParams(): Record { + return this.getBaseReportParams(); + } +} diff --git a/packages/b2c-cli/src/commands/cip/report/product-co-purchase-analysis.ts b/packages/b2c-cli/src/commands/cip/report/product-co-purchase-analysis.ts new file mode 100644 index 00000000..62bb4045 --- /dev/null +++ b/packages/b2c-cli/src/commands/cip/report/product-co-purchase-analysis.ts @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {withDocs} from '../../../i18n/index.js'; +import {CipReportCommand} from '../../../utils/cip/report-command.js'; +import {createSiteIdFlag} from '../../../utils/cip/report-flags.js'; + +export default class CipReportProductCoPurchaseAnalysis extends CipReportCommand< + typeof CipReportProductCoPurchaseAnalysis +> { + static description = withDocs( + 'Analyze frequently co-purchased products', + '/cli/cip.html#b2c-cip-report-product-co-purchase-analysis', + ); + + static enableJsonFlag = true; + + static flags = { + ...CipReportCommand.baseFlags, + ...CipReportCommand.reportFlags, + 'site-id': createSiteIdFlag(), + }; + + protected readonly reportName = 'product-co-purchase-analysis'; + + protected getReportParams(): Record { + return this.getBaseReportParams(); + } +} diff --git a/packages/b2c-cli/src/commands/cip/report/promotion-discount-analysis.ts b/packages/b2c-cli/src/commands/cip/report/promotion-discount-analysis.ts new file mode 100644 index 00000000..93f73fb9 --- /dev/null +++ b/packages/b2c-cli/src/commands/cip/report/promotion-discount-analysis.ts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {withDocs} from '../../../i18n/index.js'; +import {CipReportCommand} from '../../../utils/cip/report-command.js'; + +export default class CipReportPromotionDiscountAnalysis extends CipReportCommand< + typeof CipReportPromotionDiscountAnalysis +> { + static description = withDocs( + 'Measure promotional discount impact on orders', + '/cli/cip.html#b2c-cip-report-promotion-discount-analysis', + ); + + static enableJsonFlag = true; + + static flags = { + ...CipReportCommand.baseFlags, + ...CipReportCommand.reportFlags, + }; + + protected readonly reportName = 'promotion-discount-analysis'; + + protected getReportParams(): Record { + return this.getBaseReportParams(); + } +} diff --git a/packages/b2c-cli/src/commands/cip/report/sales-analytics.ts b/packages/b2c-cli/src/commands/cip/report/sales-analytics.ts new file mode 100644 index 00000000..0e3207bc --- /dev/null +++ b/packages/b2c-cli/src/commands/cip/report/sales-analytics.ts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {withDocs} from '../../../i18n/index.js'; +import {CipReportCommand} from '../../../utils/cip/report-command.js'; +import {createSiteIdFlag} from '../../../utils/cip/report-flags.js'; + +export default class CipReportSalesAnalytics extends CipReportCommand { + static description = withDocs( + 'Track daily sales performance with AOV and AOS metrics', + '/cli/cip.html#b2c-cip-report-sales-analytics', + ); + + static enableJsonFlag = true; + + static flags = { + ...CipReportCommand.baseFlags, + ...CipReportCommand.reportFlags, + 'site-id': createSiteIdFlag(), + }; + + protected readonly reportName = 'sales-analytics'; + + protected getReportParams(): Record { + return this.getBaseReportParams(); + } +} diff --git a/packages/b2c-cli/src/commands/cip/report/sales-summary.ts b/packages/b2c-cli/src/commands/cip/report/sales-summary.ts new file mode 100644 index 00000000..af458aa8 --- /dev/null +++ b/packages/b2c-cli/src/commands/cip/report/sales-summary.ts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {withDocs} from '../../../i18n/index.js'; +import {CipReportCommand} from '../../../utils/cip/report-command.js'; +import {createSiteIdFlag} from '../../../utils/cip/report-flags.js'; + +export default class CipReportSalesSummary extends CipReportCommand { + static description = withDocs( + 'Query detailed sales records for custom analysis', + '/cli/cip.html#b2c-cip-report-sales-summary', + ); + + static enableJsonFlag = true; + + static flags = { + ...CipReportCommand.baseFlags, + ...CipReportCommand.reportFlags, + 'site-id': createSiteIdFlag(), + }; + + protected readonly reportName = 'sales-summary'; + + protected getReportParams(): Record { + return this.getBaseReportParams(); + } +} diff --git a/packages/b2c-cli/src/commands/cip/report/search-query-performance.ts b/packages/b2c-cli/src/commands/cip/report/search-query-performance.ts new file mode 100644 index 00000000..097a3679 --- /dev/null +++ b/packages/b2c-cli/src/commands/cip/report/search-query-performance.ts @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {Flags} from '@oclif/core'; +import {withDocs} from '../../../i18n/index.js'; +import {CipReportCommand} from '../../../utils/cip/report-command.js'; +import {createSiteIdFlag} from '../../../utils/cip/report-flags.js'; + +export default class CipReportSearchQueryPerformance extends CipReportCommand { + static description = withDocs( + 'Identify search terms driving revenue and conversion', + '/cli/cip.html#b2c-cip-report-search-query-performance', + ); + + static enableJsonFlag = true; + + static flags = { + ...CipReportCommand.baseFlags, + ...CipReportCommand.reportFlags, + 'has-results': Flags.string({ + description: 'Filter by result-bearing searches', + helpGroup: 'QUERY', + options: ['false', 'true'], + required: false, + }), + 'site-id': createSiteIdFlag(), + }; + + protected readonly reportName = 'search-query-performance'; + + protected getReportParams(): Record { + if (!this.flags['has-results']) { + this.error('--has-results is required for this report. Use true or false.'); + } + + return { + ...this.getBaseReportParams(), + hasResults: this.flags['has-results'], + }; + } +} diff --git a/packages/b2c-cli/src/commands/cip/report/top-referrers.ts b/packages/b2c-cli/src/commands/cip/report/top-referrers.ts new file mode 100644 index 00000000..1d0f6c5c --- /dev/null +++ b/packages/b2c-cli/src/commands/cip/report/top-referrers.ts @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {withDocs} from '../../../i18n/index.js'; +import {CipReportCommand} from '../../../utils/cip/report-command.js'; +import {createLimitFlag, createSiteIdFlag} from '../../../utils/cip/report-flags.js'; + +export default class CipReportTopReferrers extends CipReportCommand { + static description = withDocs( + 'Identify top traffic referrers and visit share', + '/cli/cip.html#b2c-cip-report-top-referrers', + ); + + static enableJsonFlag = true; + + static flags = { + ...CipReportCommand.baseFlags, + ...CipReportCommand.reportFlags, + limit: createLimitFlag(), + 'site-id': createSiteIdFlag(), + }; + + protected readonly reportName = 'top-referrers'; + + protected getReportParams(): Record { + return this.getBaseReportParams(); + } +} diff --git a/packages/b2c-cli/src/commands/cip/report/top-selling-products.ts b/packages/b2c-cli/src/commands/cip/report/top-selling-products.ts new file mode 100644 index 00000000..c56b0edc --- /dev/null +++ b/packages/b2c-cli/src/commands/cip/report/top-selling-products.ts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {withDocs} from '../../../i18n/index.js'; +import {CipReportCommand} from '../../../utils/cip/report-command.js'; +import {createSiteIdFlag} from '../../../utils/cip/report-flags.js'; + +export default class CipReportTopSellingProducts extends CipReportCommand { + static description = withDocs( + 'Identify top selling products across channels', + '/cli/cip.html#b2c-cip-report-top-selling-products', + ); + + static enableJsonFlag = true; + + static flags = { + ...CipReportCommand.baseFlags, + ...CipReportCommand.reportFlags, + 'site-id': createSiteIdFlag(), + }; + + protected readonly reportName = 'top-selling-products'; + + protected getReportParams(): Record { + return this.getBaseReportParams(); + } +} diff --git a/packages/b2c-cli/src/commands/cip/tables.ts b/packages/b2c-cli/src/commands/cip/tables.ts new file mode 100644 index 00000000..1d5267da --- /dev/null +++ b/packages/b2c-cli/src/commands/cip/tables.ts @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {Flags} from '@oclif/core'; +import {listCipTables} from '@salesforce/b2c-tooling-sdk'; +import {withDocs} from '../../i18n/index.js'; +import {CipCommand} from '../../utils/cip/command.js'; +import {renderTable, toCsv, type CipOutputFormat} from '../../utils/cip/format.js'; + +const {from: _unusedFrom, to: _unusedTo, ...cipMetadataFlags} = CipCommand.baseFlags; + +interface CipTablesCommandResult { + schema: string; + tableCount: number; + tables: Array<{ + schema: string; + table: string; + type: string; + }>; +} + +export default class CipTables extends CipCommand { + static description = withDocs('List tables from CIP metadata catalog', '/cli/cip.html#b2c-cip-tables'); + + static enableJsonFlag = true; + + static flags = { + ...cipMetadataFlags, + all: Flags.boolean({ + description: 'Include all table types (default only TABLE)', + default: false, + helpGroup: 'QUERY', + }), + pattern: Flags.string({ + description: 'Table name pattern (SQL LIKE)', + helpGroup: 'QUERY', + }), + schema: Flags.string({ + description: 'Metadata schema name', + default: 'warehouse', + helpGroup: 'QUERY', + }), + }; + + async run(): Promise { + this.requireCipCredentials(); + + const client = this.getCipClient(); + const result = await listCipTables(client, { + fetchSize: this.flags['fetch-size'], + schema: this.flags.schema, + tableNamePattern: this.flags.pattern, + tableType: this.flags.all ? undefined : 'TABLE', + }); + + const rows = result.tables.map((table) => ({ + schema: table.tableSchema, + table: table.tableName, + type: table.tableType, + })); + + const output: CipTablesCommandResult = { + schema: this.flags.schema, + tableCount: result.tableCount, + tables: rows, + }; + + if (this.jsonEnabled()) { + return output; + } + + if (this.flags.format === 'json') { + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); + return output; + } + + this.renderRows(rows, this.flags.format as CipOutputFormat); + return output; + } + + private renderRows(rows: CipTablesCommandResult['tables'], format: CipOutputFormat): void { + const columns = ['table', 'type']; + if (format === 'csv') { + process.stdout.write(`${toCsv(columns, rows)}\n`); + return; + } + + renderTable(columns, rows); + } +} diff --git a/packages/b2c-cli/src/utils/cip/command.ts b/packages/b2c-cli/src/utils/cip/command.ts new file mode 100644 index 00000000..20915536 --- /dev/null +++ b/packages/b2c-cli/src/utils/cip/command.ts @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {Command, Flags} from '@oclif/core'; +import type {AuthMethod} from '@salesforce/b2c-tooling-sdk/auth'; +import { + createCipClient, + DEFAULT_CIP_HOST, + DEFAULT_CIP_STAGING_HOST, + type CipClient, +} from '@salesforce/b2c-tooling-sdk/clients'; +import {extractOAuthFlags, loadConfig, OAuthCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import type {ResolvedB2CConfig} from '@salesforce/b2c-tooling-sdk/config'; +import {t} from '../../i18n/index.js'; + +const CIP_AUTH_METHODS: AuthMethod[] = ['client-credentials']; + +function toIsoDate(value: Date): string { + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, '0'); + const day = String(value.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function getDefaultFromDate(): string { + const now = new Date(); + return toIsoDate(new Date(now.getFullYear(), now.getMonth(), 1)); +} + +function getDefaultToDate(): string { + return toIsoDate(new Date()); +} + +function isLikelyProductionTenantId(tenantId: string): boolean { + return tenantId.toLowerCase().endsWith('_prd'); +} + +export abstract class CipCommand extends OAuthCommand { + static baseFlags = { + ...OAuthCommand.baseFlags, + 'cip-host': Flags.string({ + description: `CIP host override (default: ${DEFAULT_CIP_HOST})`, + env: 'SFCC_CIP_HOST', + helpGroup: 'QUERY', + }), + staging: Flags.boolean({ + description: `Use staging analytics host (${DEFAULT_CIP_STAGING_HOST})`, + env: 'SFCC_CIP_STAGING', + default: false, + helpGroup: 'QUERY', + }), + from: Flags.string({ + description: 'Inclusive start date (YYYY-MM-DD)', + default: getDefaultFromDate(), + helpGroup: 'QUERY', + }), + to: Flags.string({ + description: 'Inclusive end date (YYYY-MM-DD)', + default: getDefaultToDate(), + helpGroup: 'QUERY', + }), + format: Flags.string({ + description: 'Output format', + options: ['table', 'json', 'csv'], + default: 'table', + helpGroup: 'QUERY', + }), + 'fetch-size': Flags.integer({ + description: 'Frame fetch size for CIP paging', + default: 1000, + min: 1, + helpGroup: 'QUERY', + }), + }; + + protected getCipClient(): CipClient { + this.validateCipAuthMethods(); + + const cipInstance = this.requireTenantId(); + const cipHost = this.resolveCipHost(cipInstance); + return createCipClient( + { + instance: cipInstance, + host: cipHost, + }, + this.getOAuthStrategy(), + ); + } + + protected override getDefaultAuthMethods(): AuthMethod[] { + return CIP_AUTH_METHODS; + } + + protected override loadConfiguration(): ResolvedB2CConfig { + const flags = this.flags as Record; + return loadConfig( + { + ...extractOAuthFlags(flags), + cipHost: flags['cip-host'] as string | undefined, + }, + this.getBaseConfigOptions(), + this.getPluginSources(), + ); + } + + protected requireCipCredentials(): void { + this.validateCipAuthMethods(); + + if (!this.hasFullOAuthCredentials()) { + this.error( + t( + 'error.oauthClientSecretRequired', + 'CIP requires OAuth client credentials. Provide --client-id and --client-secret, or set SFCC_CLIENT_ID and SFCC_CLIENT_SECRET.', + ), + ); + } + } + + protected resolveCipHost(cipInstance: string): string { + const configuredHost = this.flags['cip-host'] ?? this.resolvedConfig.values.cipHost; + if (configuredHost) { + return configuredHost; + } + + if (this.flags.staging || !isLikelyProductionTenantId(cipInstance)) { + return DEFAULT_CIP_STAGING_HOST; + } + + return DEFAULT_CIP_HOST; + } + + protected validateCipAuthMethods(): void { + const methods = this.resolvedConfig.values.authMethods; + if (!methods || methods.length === 0) { + return; + } + + const invalidMethods = methods.filter((method) => method !== 'client-credentials'); + if (invalidMethods.length > 0) { + this.error( + t( + 'error.cipAuthMethodNotSupported', + 'CIP only supports client-credentials auth. Remove --user-auth/--auth-methods overrides and provide --client-id and --client-secret.', + ), + ); + } + } +} diff --git a/packages/b2c-cli/src/utils/cip/format.ts b/packages/b2c-cli/src/utils/cip/format.ts new file mode 100644 index 00000000..75a0e211 --- /dev/null +++ b/packages/b2c-cli/src/utils/cip/format.ts @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {ux} from '@oclif/core'; +import {createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; + +export type CipOutputFormat = 'csv' | 'json' | 'table'; + +function toDisplayValue(value: unknown): string { + if (value === null || value === undefined) return ''; + if (value instanceof Date) return value.toISOString(); + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} + +function escapeCsv(value: string): string { + if (value.includes(',') || value.includes('"') || value.includes('\n')) { + return `"${value.replaceAll('"', '""')}"`; + } + + return value; +} + +export function toCsv(columns: string[], rows: Array>): string { + const lines: string[] = []; + lines.push(columns.map((column) => escapeCsv(column)).join(',')); + + for (const row of rows) { + lines.push(columns.map((column) => escapeCsv(toDisplayValue(row[column]))).join(',')); + } + + return lines.join('\n'); +} + +export function renderTable(columns: string[], rows: Array>): void { + if (columns.length === 0) { + ux.stdout('No columns returned.'); + return; + } + + const columnKeys = columns.map((_, index) => `column_${index + 1}`); + const tableColumns: Record>> = {}; + + for (const [index, key] of columnKeys.entries()) { + const columnName = columns[index]; + tableColumns[key] = { + header: columnName, + get: (row) => toDisplayValue(row[key]), + }; + } + + const tableRows = rows.map((row) => { + const mapped: Record = {}; + for (const [index, key] of columnKeys.entries()) { + mapped[key] = row[columns[index]]; + } + + return mapped; + }); + + createTable(tableColumns).render(tableRows, columnKeys); +} diff --git a/packages/b2c-cli/src/utils/cip/report-command.ts b/packages/b2c-cli/src/utils/cip/report-command.ts new file mode 100644 index 00000000..6110fefd --- /dev/null +++ b/packages/b2c-cli/src/utils/cip/report-command.ts @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {Command, Flags} from '@oclif/core'; +import { + buildCipReportSql, + executeCipReport, + getCipReportByName, + type CipReportDefinition, + type CipReportParamDefinition, +} from '@salesforce/b2c-tooling-sdk'; +import {CipCommand} from './command.js'; +import {renderTable, toCsv, type CipOutputFormat} from './format.js'; + +interface CipReportDescribeOutput { + description: string; + name: string; + parameters: CipReportParamDefinition[]; +} + +interface CipReportQueryOutput { + columns: string[]; + reportName: string; + rowCount: number; + rows: Array>; +} + +interface CipReportSqlOutput { + reportName: string; + sql: string; +} + +export function isLikelyQualifiedCipSiteId(siteId: string): boolean { + return siteId.startsWith('Sites-') && siteId.endsWith('-Site'); +} + +export abstract class CipReportCommand extends CipCommand { + static reportFlags = { + describe: Flags.boolean({ + description: 'Show report details and expected parameters', + exclusive: ['sql'], + helpGroup: 'QUERY', + }), + sql: Flags.boolean({ + description: 'Print generated SQL and exit', + exclusive: ['describe'], + helpGroup: 'QUERY', + }), + }; + + protected abstract readonly reportName: string; + + protected getBaseReportParams(): Record { + const params: Record = {}; + + if (this.flags.from) { + params.from = this.flags.from; + } + + if (this.flags.to) { + params.to = this.flags.to; + } + + const flags = this.flags as Record; + + if (typeof flags['site-id'] === 'string' && flags['site-id'].length > 0) { + params.siteId = flags['site-id']; + } + + if (typeof flags.limit === 'number') { + params.limit = String(flags.limit); + } + + return params; + } + + protected abstract getReportParams(): Record; + + async run(): Promise { + this.validateCipAuthMethods(); + + const report = this.getReportDefinition(); + const flags = this.flags as Record; + + if (flags.describe === true) { + return this.describeReport(report); + } + + const params = this.getReportParams(); + this.warnIfSiteIdLooksUnqualified(params); + + const sqlResult = buildCipReportSql(this.reportName, params); + if (flags.sql === true) { + process.stdout.write(`${sqlResult.sql}\n`); + return { + reportName: sqlResult.report.name, + sql: sqlResult.sql, + }; + } + + this.requireCipCredentials(); + + const client = this.getCipClient(); + const result = await executeCipReport(client, this.reportName, { + fetchSize: this.flags['fetch-size'], + params, + }); + + const output: CipReportQueryOutput = { + columns: result.columns, + reportName: result.reportName, + rowCount: result.rowCount, + rows: result.rows, + }; + + if (this.jsonEnabled()) { + return output; + } + + if (this.flags.format === 'json') { + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); + return output; + } + + this.renderRows(result.columns, result.rows, this.flags.format as CipOutputFormat); + return output; + } + + private describeReport(report: CipReportDefinition): CipReportDescribeOutput { + const output: CipReportDescribeOutput = { + description: report.description, + name: report.name, + parameters: report.parameters, + }; + + if (this.jsonEnabled()) { + return output; + } + + this.log(`${report.name}: ${report.description}`); + if (report.parameters.length === 0) { + this.log('No parameters.'); + return output; + } + + this.renderRows( + ['name', 'type', 'required', 'description'], + report.parameters.map((parameter) => ({ + description: parameter.description, + name: parameter.name, + required: parameter.required ? 'yes' : 'no', + type: parameter.type, + })), + 'table', + ); + + return output; + } + + private getReportDefinition(): CipReportDefinition { + const report = getCipReportByName(this.reportName); + if (!report) { + this.error(`Unknown CIP report: ${this.reportName}`); + } + + return report; + } + + private renderRows(columns: string[], rows: Array>, format: CipOutputFormat): void { + if (format === 'csv') { + process.stdout.write(`${toCsv(columns, rows)}\n`); + return; + } + + renderTable(columns, rows); + } + + private warnIfSiteIdLooksUnqualified(params: Record): void { + const siteId = params.siteId; + if (!siteId) { + return; + } + + if (!isLikelyQualifiedCipSiteId(siteId)) { + this.warn( + `The siteId "${siteId}" does not match the typical CIP format "Sites-{siteId}-Site". Continuing with the provided value.`, + ); + } + } +} diff --git a/packages/b2c-cli/src/utils/cip/report-flags.ts b/packages/b2c-cli/src/utils/cip/report-flags.ts new file mode 100644 index 00000000..69282e3a --- /dev/null +++ b/packages/b2c-cli/src/utils/cip/report-flags.ts @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {Flags} from '@oclif/core'; + +interface SiteIdFlagOptions { + required?: boolean; +} + +interface LimitFlagOptions { + max?: number; + min?: number; +} + +export function createSiteIdFlag(options: SiteIdFlagOptions = {}) { + return Flags.string({ + description: 'Site ID parameter for CIP report filters', + helpGroup: 'QUERY', + required: options.required ?? false, + }); +} + +export function createLimitFlag(options: LimitFlagOptions = {}) { + return Flags.integer({ + description: 'Maximum number of rows', + helpGroup: 'QUERY', + max: options.max ?? 500, + min: options.min ?? 1, + required: false, + }); +} diff --git a/packages/b2c-cli/test/commands/cip/describe.test.ts b/packages/b2c-cli/test/commands/cip/describe.test.ts new file mode 100644 index 00000000..021ca447 --- /dev/null +++ b/packages/b2c-cli/test/commands/cip/describe.test.ts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {runCommand} from '@oclif/test'; +import {expect} from 'chai'; +import {createIsolatedEnvHooks} from '../../helpers/test-setup.js'; + +describe('cip describe', () => { + const hooks = createIsolatedEnvHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + it('shows help without errors', async () => { + const {error} = await runCommand('cip describe --help'); + expect(error).to.be.undefined; + }); + + it('requires a table argument', async () => { + const {error} = await runCommand('cip describe'); + expect(error).to.not.be.undefined; + expect(error?.message).to.include('Missing 1 required arg'); + }); +}); diff --git a/packages/b2c-cli/test/commands/cip/query.test.ts b/packages/b2c-cli/test/commands/cip/query.test.ts new file mode 100644 index 00000000..64e522f7 --- /dev/null +++ b/packages/b2c-cli/test/commands/cip/query.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {runCommand} from '@oclif/test'; +import {expect} from 'chai'; +import {createIsolatedEnvHooks} from '../../helpers/test-setup.js'; + +describe('cip query', () => { + const hooks = createIsolatedEnvHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + it('shows help without errors', async () => { + const {error} = await runCommand('cip query --help'); + expect(error).to.be.undefined; + }); + + it('requires one SQL source', async () => { + const {error} = await runCommand('cip query --tenant-id zzxy_prd --client-id test --client-secret secret'); + expect(error).to.not.be.undefined; + expect(error?.message).to.include('No SQL provided'); + }); + + it('rejects positional SQL when --file is also set', async () => { + const {error} = await runCommand('cip query "SELECT 1" --file ./query.sql'); + expect(error).to.not.be.undefined; + expect(error?.message).to.include('exactly one source'); + }); +}); diff --git a/packages/b2c-cli/test/commands/cip/report.test.ts b/packages/b2c-cli/test/commands/cip/report.test.ts new file mode 100644 index 00000000..c89bd14c --- /dev/null +++ b/packages/b2c-cli/test/commands/cip/report.test.ts @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {runCommand} from '@oclif/test'; +import {expect} from 'chai'; +import {isLikelyQualifiedCipSiteId} from '../../../src/utils/cip/report-command.js'; +import {createIsolatedEnvHooks} from '../../helpers/test-setup.js'; + +describe('cip report', () => { + const hooks = createIsolatedEnvHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + it('shows help without errors', async () => { + const {error} = await runCommand('cip report --help'); + expect(error).to.be.undefined; + }); + + it('shows report subcommand help without errors', async () => { + const {error} = await runCommand('cip report sales-analytics --help'); + expect(error).to.be.undefined; + }); + + it('supports --describe without credentials', async () => { + const {error} = await runCommand('cip report sales-analytics --describe'); + expect(error).to.be.undefined; + }); + + it('supports --sql and exits without credentials', async () => { + const {error, stdout} = await runCommand('cip report sales-analytics --site-id Sites-SiteGenesis-Site --sql'); + expect(error).to.be.undefined; + expect(stdout).to.include('ccdw_aggr_sales_summary'); + }); + + it('rejects --user-auth for CIP commands', async () => { + const {error} = await runCommand('cip report sales-analytics --site-id Sites-SiteGenesis-Site --sql --user-auth'); + expect(error).to.not.be.undefined; + expect(error?.message).to.include('client-credentials'); + }); + + it('rejects non client-credentials auth methods', async () => { + const {error} = await runCommand( + 'cip report sales-analytics --site-id Sites-SiteGenesis-Site --sql --auth-methods implicit', + ); + expect(error).to.not.be.undefined; + expect(error?.message).to.include('client-credentials'); + }); + + it('warns when siteId is not in Sites-{siteId}-Site format', () => { + expect(isLikelyQualifiedCipSiteId('SiteGenesis')).to.equal(false); + }); + + it('does not warn when siteId uses Sites-{siteId}-Site format', () => { + expect(isLikelyQualifiedCipSiteId('Sites-SiteGenesis-Site')).to.equal(true); + }); +}); diff --git a/packages/b2c-cli/test/commands/cip/tables.test.ts b/packages/b2c-cli/test/commands/cip/tables.test.ts new file mode 100644 index 00000000..019f076a --- /dev/null +++ b/packages/b2c-cli/test/commands/cip/tables.test.ts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {runCommand} from '@oclif/test'; +import {expect} from 'chai'; +import {createIsolatedEnvHooks} from '../../helpers/test-setup.js'; + +describe('cip tables', () => { + const hooks = createIsolatedEnvHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + it('shows help without errors', async () => { + const {error} = await runCommand('cip tables --help'); + expect(error).to.be.undefined; + }); + + it('rejects --user-auth for CIP metadata commands', async () => { + const {error} = await runCommand('cip tables --tenant-id zzxy_prd --user-auth'); + expect(error).to.not.be.undefined; + expect(error?.message).to.include('client-credentials'); + }); +}); diff --git a/packages/b2c-tooling-sdk/data/cip-proto/common.proto b/packages/b2c-tooling-sdk/data/cip-proto/common.proto new file mode 100644 index 00000000..04d0a857 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/cip-proto/common.proto @@ -0,0 +1,281 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +syntax = "proto3"; + +option java_package = "org.apache.calcite.avatica.proto"; + +// Details about a connection +message ConnectionProperties { + bool is_dirty = 1; + bool auto_commit = 2; + bool has_auto_commit = 7; // field is a Boolean, need to discern null and default value + bool read_only = 3; + bool has_read_only = 8; // field is a Boolean, need to discern null and default value + uint32 transaction_isolation = 4; + string catalog = 5; + string schema = 6; +} + +// Statement handle +message StatementHandle { + string connection_id = 1; + uint32 id = 2; + Signature signature = 3; +} + +// Results of preparing a statement +message Signature { + repeated ColumnMetaData columns = 1; + string sql = 2; + repeated AvaticaParameter parameters = 3; + CursorFactory cursor_factory = 4; + StatementType statementType = 5; +} + +// Has to be consistent with Meta.StatementType +enum StatementType { + SELECT = 0; + INSERT = 1; + UPDATE = 2; + DELETE = 3; + UPSERT = 4; + MERGE = 5; + OTHER_DML = 6; + CREATE = 7; + DROP = 8; + ALTER = 9; + OTHER_DDL = 10; + CALL = 11; +} + +message ColumnMetaData { + uint32 ordinal = 1; + bool auto_increment = 2; + bool case_sensitive = 3; + bool searchable = 4; + bool currency = 5; + uint32 nullable = 6; + bool signed = 7; + uint32 display_size = 8; + string label = 9; + string column_name = 10; + string schema_name = 11; + uint32 precision = 12; + uint32 scale = 13; + string table_name = 14; + string catalog_name = 15; + bool read_only = 16; + bool writable = 17; + bool definitely_writable = 18; + string column_class_name = 19; + AvaticaType type = 20; +} + +enum Rep { + PRIMITIVE_BOOLEAN = 0; + PRIMITIVE_BYTE = 1; + PRIMITIVE_CHAR = 2; + PRIMITIVE_SHORT = 3; + PRIMITIVE_INT = 4; + PRIMITIVE_LONG = 5; + PRIMITIVE_FLOAT = 6; + PRIMITIVE_DOUBLE = 7; + BOOLEAN = 8; + BYTE = 9; + CHARACTER = 10; + SHORT = 11; + INTEGER = 12; + LONG = 13; + FLOAT = 14; + DOUBLE = 15; + BIG_INTEGER = 25; + BIG_DECIMAL = 26; + JAVA_SQL_TIME = 16; + JAVA_SQL_TIMESTAMP = 17; + JAVA_SQL_DATE = 18; + JAVA_UTIL_DATE = 19; + BYTE_STRING = 20; + STRING = 21; + NUMBER = 22; + OBJECT = 23; + NULL = 24; + ARRAY = 27; + STRUCT = 28; + MULTISET = 29; +} + +// Base class for a column type +message AvaticaType { + uint32 id = 1; + string name = 2; + Rep rep = 3; + + repeated ColumnMetaData columns = 4; // Only present when name = STRUCT + AvaticaType component = 5; // Only present when name = ARRAY +} + +// Metadata for a parameter +message AvaticaParameter { + bool signed = 1; + uint32 precision = 2; + uint32 scale = 3; + uint32 parameter_type = 4; + string type_name = 5; + string class_name = 6; + string name = 7; +} + +// Information necessary to convert an Iterable into a Calcite Cursor +message CursorFactory { + enum Style { + OBJECT = 0; + RECORD = 1; + RECORD_PROJECTION = 2; + ARRAY = 3; + LIST = 4; + MAP = 5; + } + + Style style = 1; + string class_name = 2; + repeated string field_names = 3; +} + +// A collection of rows +message Frame { + uint64 offset = 1; + bool done = 2; + repeated Row rows = 3; +} + +// A row is a collection of values +message Row { + repeated ColumnValue value = 1; +} + +// Database property, list of functions the database provides for a certain operation +message DatabaseProperty { + string name = 1; + repeated string functions = 2; +} + +// Message which encapsulates another message to support a single RPC endpoint +message WireMessage { + string name = 1; + bytes wrapped_message = 2; +} + +// A value might be a TypedValue or an Array of TypedValue's +message ColumnValue { + repeated TypedValue value = 1; // deprecated, use array_value or scalar_value + repeated TypedValue array_value = 2; + bool has_array_value = 3; // Is an array value set? + TypedValue scalar_value = 4; +} + +// Generic wrapper to support any SQL type. Struct-like to work around no polymorphism construct. +message TypedValue { + Rep type = 1; // The actual type that was serialized in the general attribute below + + bool bool_value = 2; // boolean + string string_value = 3; // char/varchar + sint64 number_value = 4; // var-len encoding lets us shove anything from byte to long + // includes numeric types and date/time types. + bytes bytes_value = 5; // binary/varbinary + double double_value = 6; // big numbers + bool null = 7; // a null object + + repeated TypedValue array_value = 8; // The Array + Rep component_type = 9; // If an Array, the representation for the array values + + bool implicitly_null = 10; // Differentiate between explicitly null (user-set) and implicitly null + // (un-set by the user) +} + +// The severity of some unexpected outcome to an operation. +// Protobuf enum values must be unique across all other enums +enum Severity { + UNKNOWN_SEVERITY = 0; + FATAL_SEVERITY = 1; + ERROR_SEVERITY = 2; + WARNING_SEVERITY = 3; +} + +// Enumeration corresponding to DatabaseMetaData operations +enum MetaDataOperation { + GET_ATTRIBUTES = 0; + GET_BEST_ROW_IDENTIFIER = 1; + GET_CATALOGS = 2; + GET_CLIENT_INFO_PROPERTIES = 3; + GET_COLUMN_PRIVILEGES = 4; + GET_COLUMNS = 5; + GET_CROSS_REFERENCE = 6; + GET_EXPORTED_KEYS = 7; + GET_FUNCTION_COLUMNS = 8; + GET_FUNCTIONS = 9; + GET_IMPORTED_KEYS = 10; + GET_INDEX_INFO = 11; + GET_PRIMARY_KEYS = 12; + GET_PROCEDURE_COLUMNS = 13; + GET_PROCEDURES = 14; + GET_PSEUDO_COLUMNS = 15; + GET_SCHEMAS = 16; + GET_SCHEMAS_WITH_ARGS = 17; + GET_SUPER_TABLES = 18; + GET_SUPER_TYPES = 19; + GET_TABLE_PRIVILEGES = 20; + GET_TABLES = 21; + GET_TABLE_TYPES = 22; + GET_TYPE_INFO = 23; + GET_UDTS = 24; + GET_VERSION_COLUMNS = 25; +} + +// Represents the breadth of arguments to DatabaseMetaData functions +message MetaDataOperationArgument { + enum ArgumentType { + STRING = 0; + BOOL = 1; + INT = 2; + REPEATED_STRING = 3; + REPEATED_INT = 4; + NULL = 5; + } + + string string_value = 1; + bool bool_value = 2; + sint32 int_value = 3; + repeated string string_array_values = 4; + repeated sint32 int_array_values = 5; + ArgumentType type = 6; +} + +enum StateType { + SQL = 0; + METADATA = 1; +} + +message QueryState { + StateType type = 1; + string sql = 2; + MetaDataOperation op = 3; + repeated MetaDataOperationArgument args = 4; + bool has_args = 5; + bool has_sql = 6; + bool has_op = 7; +} diff --git a/packages/b2c-tooling-sdk/data/cip-proto/requests.proto b/packages/b2c-tooling-sdk/data/cip-proto/requests.proto new file mode 100644 index 00000000..c56011d4 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/cip-proto/requests.proto @@ -0,0 +1,178 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +syntax = "proto3"; + +option java_package = "org.apache.calcite.avatica.proto"; + +import "common.proto"; + +// Request for Meta#getCatalogs() +message CatalogsRequest { + string connection_id = 1; +} + +// Request for Meta#getDatabaseProperties() +message DatabasePropertyRequest { + string connection_id = 1; +} + +// Request for Meta#getSchemas(String, org.apache.calcite.avatica.Meta.Pat)} +message SchemasRequest { + string catalog = 1; + string schema_pattern = 2; + string connection_id = 3; + bool has_catalog = 4; + bool has_schema_pattern = 5; +} + +// Request for Request for Meta#getTables(String, org.apache.calcite.avatica.Meta.Pat, +// org.apache.calcite.avatica.Meta.Pat, java.util.List) +message TablesRequest { + string catalog = 1; + string schema_pattern = 2; + string table_name_pattern = 3; + repeated string type_list = 4; + bool has_type_list = 6; // Having an empty type_list is distinct from a null type_list + string connection_id = 7; + bool has_catalog = 8; + bool has_schema_pattern = 9; + bool has_table_name_pattern = 10; +} + +// Request for Meta#getTableTypes() +message TableTypesRequest { + string connection_id = 1; +} + +// Request for Meta#getColumns(String, org.apache.calcite.avatica.Meta.Pat, +// org.apache.calcite.avatica.Meta.Pat, org.apache.calcite.avatica.Meta.Pat). +message ColumnsRequest { + string catalog = 1; + string schema_pattern = 2; + string table_name_pattern = 3; + string column_name_pattern = 4; + string connection_id = 5; + bool has_catalog = 6; + bool has_schema_pattern = 7; + bool has_table_name_pattern = 8; + bool has_column_name_pattern = 9; +} + +// Request for Meta#getTypeInfo() +message TypeInfoRequest { + string connection_id = 1; +} + +// Request for Meta#prepareAndExecute(Meta.StatementHandle, String, long, Meta.PrepareCallback) +message PrepareAndExecuteRequest { + string connection_id = 1; + string sql = 2; + uint64 max_row_count = 3; // Deprecated + uint32 statement_id = 4; + int64 max_rows_total = 5; // The maximum number of rows that will be allowed for this query + int32 first_frame_max_size = 6; // The maximum number of rows that will be returned in the + // first Frame returned for this query. +} + +// Request for Meta.prepare(Meta.ConnectionHandle, String, long) +message PrepareRequest { + string connection_id = 1; + string sql = 2; + uint64 max_row_count = 3; // Deprecated + int64 max_rows_total = 4; // The maximum number of rows that will be allowed for this query +} + +// Request for Meta#fetch(Meta.StatementHandle, List, long, int) +message FetchRequest { + string connection_id = 1; + uint32 statement_id = 2; + uint64 offset = 3; + uint32 fetch_max_row_count = 4; // Maximum number of rows to be returned in the frame. Negative means no limit. Deprecated! + int32 frame_max_size = 5; +} + +// Request for Meta#createStatement(Meta.ConnectionHandle) +message CreateStatementRequest { + string connection_id = 1; +} + +// Request for Meta#closeStatement(Meta.StatementHandle) +message CloseStatementRequest { + string connection_id = 1; + uint32 statement_id = 2; +} + +// Request for Meta#openConnection(Meta.ConnectionHandle, Map) +message OpenConnectionRequest { + string connection_id = 1; + map info = 2; +} + +// Request for Meta#closeConnection(Meta.ConnectionHandle) +message CloseConnectionRequest { + string connection_id = 1; +} + +message ConnectionSyncRequest { + string connection_id = 1; + ConnectionProperties conn_props = 2; +} + +// Request for Meta#execute(Meta.ConnectionHandle, list, long) +message ExecuteRequest { + StatementHandle statementHandle = 1; + repeated TypedValue parameter_values = 2; + uint64 deprecated_first_frame_max_size = 3; // Deprecated, use the signed int instead. + bool has_parameter_values = 4; + int32 first_frame_max_size = 5; // The maximum number of rows to return in the first Frame +} + + +message SyncResultsRequest { + string connection_id = 1; + uint32 statement_id = 2; + QueryState state = 3; + uint64 offset = 4; +} + +// Request to invoke a commit on a Connection +message CommitRequest { + string connection_id = 1; +} + +// Request to invoke rollback on a Connection +message RollbackRequest { + string connection_id = 1; +} + +// Request to prepare and execute a collection of sql statements. +message PrepareAndExecuteBatchRequest { + string connection_id = 1; + uint32 statement_id = 2; + repeated string sql_commands = 3; +} + +// Each command is a list of TypedValues +message UpdateBatch { + repeated TypedValue parameter_values = 1; +} + +message ExecuteBatchRequest { + string connection_id = 1; + uint32 statement_id = 2; + repeated UpdateBatch updates = 3; // A batch of updates is a list> +} diff --git a/packages/b2c-tooling-sdk/data/cip-proto/responses.proto b/packages/b2c-tooling-sdk/data/cip-proto/responses.proto new file mode 100644 index 00000000..a3cd3d2a --- /dev/null +++ b/packages/b2c-tooling-sdk/data/cip-proto/responses.proto @@ -0,0 +1,135 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +syntax = "proto3"; + +option java_package = "org.apache.calcite.avatica.proto"; + +import "common.proto"; + +// Response that contains a result set. +message ResultSetResponse { + string connection_id = 1; + uint32 statement_id = 2; + bool own_statement = 3; + Signature signature = 4; + Frame first_frame = 5; + uint64 update_count = 6; // -1 for normal result sets, else this response contains a dummy result set + // with no signature nor other data. + RpcMetadata metadata = 7; +} + +// Response to PrepareAndExecuteRequest +message ExecuteResponse { + repeated ResultSetResponse results = 1; + bool missing_statement = 2; // Did the request fail because of no-cached statement + RpcMetadata metadata = 3; +} + +// Response to PrepareRequest +message PrepareResponse { + StatementHandle statement = 1; + RpcMetadata metadata = 2; +} + +// Response to FetchRequest +message FetchResponse { + Frame frame = 1; + bool missing_statement = 2; // Did the request fail because of no-cached statement + bool missing_results = 3; // Did the request fail because of a cached-statement w/o ResultSet + RpcMetadata metadata = 4; +} + +// Response to CreateStatementRequest +message CreateStatementResponse { + string connection_id = 1; + uint32 statement_id = 2; + RpcMetadata metadata = 3; +} + +// Response to CloseStatementRequest +message CloseStatementResponse { + RpcMetadata metadata = 1; +} + +// Response to OpenConnectionRequest { +message OpenConnectionResponse { + RpcMetadata metadata = 1; +} + +// Response to CloseConnectionRequest { +message CloseConnectionResponse { + RpcMetadata metadata = 1; +} + +// Response to ConnectionSyncRequest +message ConnectionSyncResponse { + ConnectionProperties conn_props = 1; + RpcMetadata metadata = 2; +} + +message DatabasePropertyElement { + DatabaseProperty key = 1; + TypedValue value = 2; + RpcMetadata metadata = 3; +} + +// Response for Meta#getDatabaseProperties() +message DatabasePropertyResponse { + repeated DatabasePropertyElement props = 1; + RpcMetadata metadata = 2; +} + +// Send contextual information about some error over the wire from the server. +message ErrorResponse { + repeated string exceptions = 1; // exception stacktraces, many for linked exceptions. + bool has_exceptions = 7; // are there stacktraces contained? + string error_message = 2; // human readable description + Severity severity = 3; + uint32 error_code = 4; // numeric identifier for error + string sql_state = 5; // five-character standard-defined value + RpcMetadata metadata = 6; +} + +message SyncResultsResponse { + bool missing_statement = 1; // Server doesn't have the statement with the ID from the request + bool more_results = 2; // Should the client fetch() to get more results + RpcMetadata metadata = 3; +} + +// Generic metadata for the server to return with each response. +message RpcMetadata { + string server_address = 1; // The host:port of the server +} + +// Response to a commit request +message CommitResponse { + +} + +// Response to a rollback request +message RollbackResponse { + +} + +// Response to a batch update request +message ExecuteBatchResponse { + string connection_id = 1; + uint32 statement_id = 2; + repeated uint64 update_counts = 3; + bool missing_statement = 4; // Did the request fail because of no-cached statement + RpcMetadata metadata = 5; +} diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index 07ccb47d..93273436 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -167,6 +167,17 @@ "default": "./dist/cjs/operations/content/index.js" } }, + "./operations/cip": { + "development": "./src/operations/cip/index.ts", + "import": { + "types": "./dist/esm/operations/cip/index.d.ts", + "default": "./dist/esm/operations/cip/index.js" + }, + "require": { + "types": "./dist/cjs/operations/cip/index.d.ts", + "default": "./dist/cjs/operations/cip/index.js" + } + }, "./cli": { "development": "./src/cli/index.ts", "import": { @@ -362,6 +373,7 @@ "openapi-fetch": "0.15.0", "pino": "10.1.0", "pino-pretty": "13.1.2", + "protobufjs": "7.5.4", "undici": "7.19.2", "xml2js": "0.6.2" } diff --git a/packages/b2c-tooling-sdk/src/cli/base-command.ts b/packages/b2c-tooling-sdk/src/cli/base-command.ts index 5cd259fd..83a2b3c5 100644 --- a/packages/b2c-tooling-sdk/src/cli/base-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/base-command.ts @@ -67,7 +67,7 @@ export abstract class BaseCommand extends Command { helpGroup: 'GLOBAL', }), json: Flags.boolean({ - description: 'Output logs as JSON lines', + description: 'Output as JSON, including log messages as JSONL', default: false, helpGroup: 'GLOBAL', }), diff --git a/packages/b2c-tooling-sdk/src/cli/config.ts b/packages/b2c-tooling-sdk/src/cli/config.ts index cba04a02..fd5cf79b 100644 --- a/packages/b2c-tooling-sdk/src/cli/config.ts +++ b/packages/b2c-tooling-sdk/src/cli/config.ts @@ -105,6 +105,7 @@ export function extractInstanceFlags(flags: ParsedFlags): Partial number}).toNumber()); + } + + return Number(value ?? 0); +} + +/** CIP client configuration. */ +export interface CipClientConfig { + /** CIP instance identifier (for example `zzxy_prd`). */ + instance: string; + /** Optional CIP host override. */ + host?: string; + /** Middleware registry to use for this client. Defaults to global registry. */ + middlewareRegistry?: MiddlewareRegistry; +} + +/** Column metadata for a CIP result set. */ +export interface CipColumn { + /** Preferred output label for the column. */ + label: string; + /** Original column name, when provided by CIP. */ + name?: string; + /** Avatica type name (for example `VARCHAR`, `DATE`). */ + typeName?: string; +} + +/** Decoded CIP frame. */ +export interface CipFrame { + /** Row offset in the full result set. */ + offset: number; + /** Whether this is the terminal frame. */ + done: boolean; + /** Column metadata for row decoding. */ + columns: CipColumn[]; + /** Decoded row objects. */ + rows: Array>; +} + +/** Execute response with decoded first frame. */ +export interface CipExecuteResponse { + /** Statement id used for this execution. */ + statementId: number; + /** Decoded first frame when present. */ + frame?: CipFrame; +} + +/** Fetch response with decoded frame. */ +export interface CipFetchResponse { + /** Decoded fetched frame when present. */ + frame?: CipFrame; +} + +/** Convenience result for full query execution. */ +export interface CipQueryResult { + /** Ordered columns for output formatting. */ + columns: string[]; + /** Decoded result rows. */ + rows: Array>; + /** Total number of rows returned. */ + rowCount: number; +} + +/** Options for high-level query execution. */ +export interface CipQueryOptions { + /** Initial and subsequent frame fetch size. */ + fetchSize?: number; + /** Optional Avatica connection properties. */ + connectionProperties?: Record; +} + +interface WireMessage { + name?: string; + wrappedMessage?: Uint8Array; +} + +interface SignatureLike { + columns?: Array>; +} + +interface FrameLike { + offset?: unknown; + done?: boolean; + rows?: Array>; +} + +/** + * CIP Avatica client with protobuf transport. + * + * Use this client for raw SQL execution against B2C Commerce Intelligence + * (CIP/CCAC) data. + * + * See {@link createCipClient} for the recommended construction helper. + * + * @example + * ```ts + * import {OAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; + * import {createCipClient} from '@salesforce/b2c-tooling-sdk/clients'; + * + * const auth = new OAuthStrategy({ + * clientId: process.env.SFCC_CLIENT_ID!, + * clientSecret: process.env.SFCC_CLIENT_SECRET!, + * }); + * + * const cip = createCipClient({instance: 'zzxy_prd'}, auth); + * + * const result = await cip.query( + * 'SELECT submit_date, num_orders FROM ccdw_aggr_sales_summary LIMIT 10', + * {fetchSize: 500}, + * ); + * + * console.log(result.rowCount, result.columns, result.rows[0]); + * ``` + */ +export class CipClient { + private readonly baseUrl: string; + private readonly middlewareRegistry: MiddlewareRegistry; + private protoRoot?: protobuf.Root; + private connectionId?: string; + private sessionId?: string; + private readonly signatureByStatementId = new Map(); + + constructor( + private readonly config: CipClientConfig, + private readonly auth: AuthStrategy, + ) { + this.baseUrl = `https://${normalizeCipHost(config.host)}/${config.instance}`; + this.middlewareRegistry = config.middlewareRegistry ?? globalMiddlewareRegistry; + } + + /** + * Opens a new Avatica connection. + */ + async openConnection(info: Record = {}): Promise { + if (this.connectionId) { + throw new Error('CIP connection is already open'); + } + + const connectionId = randomUUID(); + await this.sendRequest('OpenConnectionRequest', {connectionId, info}); + this.connectionId = connectionId; + } + + /** + * Closes the current Avatica connection (no-op if not open). + */ + async closeConnection(): Promise { + if (!this.connectionId) { + return; + } + + const connectionId = this.connectionId; + + try { + await this.sendRequest('CloseConnectionRequest', {connectionId}); + } finally { + this.connectionId = undefined; + this.sessionId = undefined; + this.signatureByStatementId.clear(); + } + } + + /** + * Creates a statement in the currently open connection. + */ + async createStatement(): Promise { + this.requireConnection(); + + const response = (await this.sendRequest('CreateStatementRequest', { + connectionId: this.connectionId, + })) as {statementId?: number}; + + if (typeof response.statementId !== 'number') { + throw new Error('CIP create statement failed: missing statement id'); + } + + return response.statementId; + } + + /** + * Closes a statement. + */ + async closeStatement(statementId: number): Promise { + this.requireConnection(); + + try { + await this.sendRequest('CloseStatementRequest', { + connectionId: this.connectionId, + statementId, + }); + } finally { + this.signatureByStatementId.delete(statementId); + } + } + + /** + * Executes SQL and returns the first decoded frame. + */ + async execute(statementId: number, sql: string, firstFrameMaxSize: number = 1000): Promise { + this.requireConnection(); + getLogger().debug({statementId, sql}, `[CIP SQL] statement=${statementId}`); + + const response = (await this.sendRequest('PrepareAndExecuteRequest', { + connectionId: this.connectionId, + statementId, + sql, + maxRowCount: firstFrameMaxSize, + firstFrameMaxSize, + })) as { + results?: Array<{ + statementId?: number; + signature?: SignatureLike; + firstFrame?: FrameLike; + }>; + }; + + const result = response.results?.[0]; + if (!result) { + return {statementId}; + } + + const resolvedStatementId = result.statementId ?? statementId; + if (result.signature) { + this.signatureByStatementId.set(resolvedStatementId, result.signature); + } + + return { + statementId: resolvedStatementId, + frame: this.decodeFrame( + result.signature ?? this.signatureByStatementId.get(resolvedStatementId), + result.firstFrame, + ), + }; + } + + /** + * Fetches an additional frame for an existing statement. + */ + async fetch(statementId: number, offset: number, fetchMaxRowCount: number = 1000): Promise { + this.requireConnection(); + + const response = (await this.sendRequest('FetchRequest', { + connectionId: this.connectionId, + statementId, + offset, + fetchMaxRowCount, + })) as { + frame?: FrameLike; + }; + + return { + frame: this.decodeFrame(this.signatureByStatementId.get(statementId), response.frame), + }; + } + + /** + * Executes SQL and returns the full decoded row set. + * + * This helper opens and closes the connection automatically. + */ + async query(sql: string, options: CipQueryOptions = {}): Promise { + const fetchSize = options.fetchSize ?? 1000; + const rows: Array> = []; + let columns: string[] = []; + + await this.openConnection(options.connectionProperties); + const statementId = await this.createStatement(); + + try { + const executeResponse = await this.execute(statementId, sql, fetchSize); + let frame = executeResponse.frame; + + if (frame) { + rows.push(...frame.rows); + columns = frame.columns.map((column) => column.label); + } + + while (frame && !frame.done) { + const nextOffset = frame.offset + frame.rows.length; + const fetchResponse = await this.fetch(executeResponse.statementId, nextOffset, fetchSize); + frame = fetchResponse.frame; + + if (frame) { + rows.push(...frame.rows); + if (columns.length === 0) { + columns = frame.columns.map((column) => column.label); + } + } + } + + return { + columns, + rows, + rowCount: rows.length, + }; + } finally { + await this.closeStatement(statementId).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + getLogger().debug({error: message}, 'Failed to close CIP statement during cleanup'); + }); + + await this.closeConnection().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + getLogger().debug({error: message}, 'Failed to close CIP connection during cleanup'); + }); + } + } + + private requireConnection(): void { + if (!this.connectionId) { + throw new Error('No CIP connection is open. Call openConnection() first.'); + } + } + + private getMiddleware(): UnifiedMiddleware[] { + return this.middlewareRegistry.getMiddleware('cip'); + } + + private async getProtoRoot(): Promise { + if (!this.protoRoot) { + this.protoRoot = await protobuf.load(CIP_PROTO_FILES); + } + + return this.protoRoot; + } + + private async sendRequest(requestTypeName: string, payload: object): Promise { + const logger = getLogger(); + const root = await this.getProtoRoot(); + + const requestType = root.lookupType(requestTypeName); + const requestMessage = requestType.create(payload); + const serializedRequest = requestType.encode(requestMessage).finish(); + + const wireType = root.lookupType('WireMessage'); + const wireRequest = wireType.create({ + name: `org.apache.calcite.avatica.proto.Requests$${requestTypeName}`, + wrappedMessage: serializedRequest, + }); + const serializedWireRequest = wireType.encode(wireRequest).finish(); + + let request = new Request(this.baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-protobuf', + 'X-Client-Version': CIP_CLIENT_VERSION, + InstanceId: this.config.instance, + ...(this.sessionId ? {'x-session-id': this.sessionId} : {}), + }, + body: serializedWireRequest, + }); + + const middleware = this.getMiddleware(); + const middlewareParams = { + request, + schemaPath: '/cip', + options: {baseUrl: this.baseUrl}, + params: {}, + id: 'cip', + }; + + for (const middlewareItem of middleware) { + if (!middlewareItem.onRequest) continue; + const nextRequest = await middlewareItem.onRequest( + middlewareParams as Parameters>[0], + ); + if (nextRequest instanceof Request) { + request = nextRequest; + middlewareParams.request = nextRequest; + } + } + + let body: Uint8Array | ArrayBuffer | undefined = serializedWireRequest; + if (request.body) { + body = await request.clone().arrayBuffer(); + } + + const requestStartTime = Date.now(); + + logger.debug({type: requestTypeName, url: request.url}, `[CIP REQ] ${requestTypeName}`); + logger.trace( + { + method: request.method, + url: request.url, + headers: this.headersToObject(request.headers), + body: this.formatProtobufBody(body, { + requestTypeName, + wireMessageName: `org.apache.calcite.avatica.proto.Requests$${requestTypeName}`, + payload, + }), + }, + `[CIP REQ BODY] ${request.method} ${request.url}`, + ); + + let response = await this.auth.fetch(request.url, { + method: request.method, + headers: request.headers, + body, + } as FetchInit); + const duration = Date.now() - requestStartTime; + + const responseParams = { + ...middlewareParams, + request, + response, + }; + + for (const middlewareItem of middleware) { + if (!middlewareItem.onResponse) continue; + const nextResponse = await middlewareItem.onResponse( + responseParams as Parameters>[0], + ); + if (nextResponse instanceof Response) { + response = nextResponse; + responseParams.response = nextResponse; + } + } + + const sessionId = response.headers.get('x-session-id'); + if (sessionId) { + this.sessionId = sessionId; + } + + if (!response.ok) { + const bodyText = await response.text(); + logger.debug( + {method: request.method, url: request.url, status: response.status, duration}, + `[CIP RESP] ${requestTypeName} ${response.status} ${duration}ms`, + ); + logger.trace( + { + method: request.method, + url: request.url, + status: response.status, + headers: this.headersToObject(response.headers), + body: bodyText, + }, + `[CIP RESP BODY] ${request.method} ${request.url}`, + ); + throw new Error(`CIP Avatica request failed (${response.status} ${response.statusText}): ${bodyText}`); + } + + const responseBytes = new Uint8Array(await response.arrayBuffer()); + const wireResponse = wireType.decode(responseBytes) as WireMessage; + + const responseClassName = wireResponse.name ?? ''; + const responseTypeName = responseClassName.split('$').pop() ?? ''; + const wrappedResponse = wireResponse.wrappedMessage; + + logger.debug( + {method: request.method, url: request.url, status: response.status, duration, responseTypeName}, + `[CIP RESP] ${requestTypeName} ${response.status} ${duration}ms`, + ); + logger.trace( + { + method: request.method, + url: request.url, + status: response.status, + headers: this.headersToObject(response.headers), + body: this.formatProtobufBody(responseBytes, { + wireMessageName: wireResponse.name, + responseTypeName, + }), + }, + `[CIP RESP BODY] ${request.method} ${request.url}`, + ); + + if (!wrappedResponse) { + throw new Error('CIP Avatica response did not contain a wrapped protobuf message'); + } + + if (responseTypeName === 'ErrorResponse') { + const errorType = root.lookupType('ErrorResponse'); + const decodedError = errorType.decode(wrappedResponse) as { + errorMessage?: string; + sqlState?: string; + errorCode?: number; + }; + + const errorMessage = decodedError.errorMessage ?? 'Unknown Avatica error'; + const sqlState = decodedError.sqlState ? ` SQLState=${decodedError.sqlState}` : ''; + const errorCode = decodedError.errorCode !== undefined ? ` ErrorCode=${decodedError.errorCode}` : ''; + throw new Error(`CIP Avatica error: ${errorMessage}${sqlState}${errorCode}`); + } + + const responseType = root.lookupType(responseTypeName); + return responseType.decode(wrappedResponse); + } + + private decodeFrame(signature: SignatureLike | undefined, frame: FrameLike | undefined): CipFrame | undefined { + if (!frame) { + return undefined; + } + + const columns = this.getColumnsFromSignature(signature); + const rows = (frame.rows ?? []).map((row) => { + const values = Array.isArray(row.value) ? row.value : []; + const decoded: Record = {}; + + for (let index = 0; index < columns.length; index++) { + const column = columns[index]; + const value = values[index] as Record | undefined; + const columnMeta = signature?.columns?.[index]; + decoded[column.label] = this.decodeColumnValue(value, columnMeta); + } + + return decoded; + }); + + return { + offset: toNumber(frame.offset), + done: Boolean(frame.done), + columns, + rows, + }; + } + + private getColumnsFromSignature(signature: SignatureLike | undefined): CipColumn[] { + const columns = signature?.columns; + if (!columns || columns.length === 0) { + return []; + } + + return columns.map((column, index) => { + const label = typeof column.label === 'string' && column.label.length > 0 ? column.label : undefined; + const columnName = + typeof column.columnName === 'string' && column.columnName.length > 0 ? column.columnName : undefined; + + return { + label: label ?? columnName ?? `column_${index + 1}`, + name: columnName, + typeName: this.getColumnTypeName(column), + }; + }); + } + + private getColumnTypeName(column: Record): string | undefined { + if ( + typeof column.type === 'object' && + column.type !== null && + typeof (column.type as {name?: unknown}).name === 'string' + ) { + return (column.type as {name: string}).name; + } + + return undefined; + } + + private decodeColumnValue(columnValue: Record | undefined, columnMetadata?: unknown): unknown { + if (!columnValue) { + return null; + } + + if (columnValue.hasArrayValue === true && Array.isArray(columnValue.arrayValue)) { + return columnValue.arrayValue.map((value) => this.decodeTypedValue(value, columnMetadata)); + } + + if (typeof columnValue.scalarValue === 'object' && columnValue.scalarValue !== null) { + return this.decodeTypedValue(columnValue.scalarValue, columnMetadata); + } + + if (Array.isArray(columnValue.value) && columnValue.value.length > 0) { + return this.decodeTypedValue(columnValue.value[0], columnMetadata); + } + + return null; + } + + private decodeTypedValue(typedValue: unknown, columnMetadata?: unknown): unknown { + if (typeof typedValue !== 'object' || typedValue === null) { + return null; + } + + const typed = typedValue as Record; + const rep = hasOwn(typed, 'type') ? toNumber(typed.type) : undefined; + + if (typed.null === true || typed.implicitlyNull === true) { + return null; + } + + if (rep === REP.ARRAY && Array.isArray(typed.arrayValue)) { + return typed.arrayValue.map((value) => this.decodeTypedValue(value, undefined)); + } + + if (rep === REP.BOOLEAN || rep === REP.PRIMITIVE_BOOLEAN) { + return Boolean(typed.boolValue); + } + + if (rep === REP.STRING) { + return hasOwn(typed, 'stringValue') ? typed.stringValue : ''; + } + + if ( + rep === REP.LONG || + rep === REP.INTEGER || + rep === REP.PRIMITIVE_INT || + rep === REP.PRIMITIVE_LONG || + rep === REP.NUMBER || + rep === REP.BIG_INTEGER + ) { + return this.decodeNumericValue(typed.numberValue, columnMetadata); + } + + if (rep === REP.FLOAT || rep === REP.DOUBLE || rep === REP.PRIMITIVE_FLOAT || rep === REP.PRIMITIVE_DOUBLE) { + return hasOwn(typed, 'doubleValue') ? Number(typed.doubleValue) : Number(typed.numberValue ?? 0); + } + + if (rep === REP.BIG_DECIMAL) { + if (hasOwn(typed, 'stringValue') && typeof typed.stringValue === 'string') { + return Number.parseFloat(typed.stringValue); + } + + return Number(typed.doubleValue ?? typed.numberValue ?? 0); + } + + if (rep === REP.BYTE_STRING && typed.bytesValue instanceof Uint8Array) { + return Buffer.from(typed.bytesValue).toString('base64'); + } + + if (rep === REP.JAVA_SQL_DATE || rep === REP.JAVA_UTIL_DATE || rep === REP.JAVA_SQL_TIMESTAMP) { + return new Date(toNumber(typed.numberValue)); + } + + if (rep === REP.JAVA_SQL_TIME) { + return toNumber(typed.numberValue); + } + + if (hasOwn(typed, 'stringValue')) { + return typed.stringValue; + } + + if (hasOwn(typed, 'doubleValue')) { + return Number(typed.doubleValue); + } + + if (hasOwn(typed, 'numberValue')) { + return Number(typed.numberValue); + } + + if (hasOwn(typed, 'boolValue')) { + return Boolean(typed.boolValue); + } + + return null; + } + + private decodeNumericValue(value: unknown, columnMetadata?: unknown): unknown { + const numeric = toNumber(value); + + if (typeof columnMetadata === 'object' && columnMetadata !== null) { + const column = columnMetadata as {type?: {name?: unknown; id?: unknown}}; + const typeName = typeof column.type?.name === 'string' ? column.type.name.toUpperCase() : undefined; + const typeId = column.type?.id !== undefined ? toNumber(column.type.id) : undefined; + + if (typeName === 'DATE' || typeId === 91) { + return new Date(numeric * 24 * 60 * 60 * 1000); + } + + if (typeName === 'TIMESTAMP' || typeId === 93) { + return new Date(numeric); + } + } + + return numeric; + } + + private formatProtobufBody( + body: Uint8Array | ArrayBuffer | undefined, + metadata?: Record, + ): Record | undefined { + if (!body) { + return undefined; + } + + const bytes = body instanceof Uint8Array ? body : new Uint8Array(body); + const preview = bytes.subarray(0, PROTOBUF_PREVIEW_BYTES); + + return { + kind: 'protobuf', + sizeBytes: bytes.byteLength, + previewHex: Buffer.from(preview).toString('hex'), + previewBase64: Buffer.from(preview).toString('base64'), + previewSize: preview.byteLength, + truncated: bytes.byteLength > preview.byteLength, + ...metadata, + }; + } + + private headersToObject(headers: Headers): Record { + const result: Record = {}; + headers.forEach((value, key) => { + result[key] = value; + }); + return result; + } +} + +/** + * Creates a CIP client and ensures the required CIP scope on OAuth strategies. + * + * @example + * ```ts + * import {OAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; + * import {createCipClient} from '@salesforce/b2c-tooling-sdk/clients'; + * + * const auth = new OAuthStrategy({ + * clientId: process.env.SFCC_CLIENT_ID!, + * clientSecret: process.env.SFCC_CLIENT_SECRET!, + * }); + * + * const cip = createCipClient({instance: 'zzxy_prd'}, auth); + * const query = await cip.query('SELECT submit_date FROM ccdw_aggr_sales_summary LIMIT 1'); + * ``` + */ +export function createCipClient(config: CipClientConfig, auth: AuthStrategy): CipClient { + const cipScope = `SALESFORCE_COMMERCE_API:${config.instance}`; + const scopedAuth = auth instanceof OAuthStrategy ? auth.withAdditionalScopes([cipScope]) : auth; + return new CipClient(config, scopedAuth); +} diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index 949e6d4c..a509d584 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -15,6 +15,7 @@ * - {@link OcapiClient} - Data API operations via OCAPI (openapi-fetch Client) * - {@link SlasClient} - SLAS Admin API for managing tenants and clients * - {@link OdsClient} - On-Demand Sandbox API for managing developer sandboxes + * - {@link CipClient} - B2C Commerce Intelligence (CIP/CCAC) query client * - {@link CustomApisClient} - Custom APIs DX API for retrieving endpoint status * - {@link ScapiSchemasClient} - SCAPI Schemas API for discovering and retrieving OpenAPI schemas * @@ -295,6 +296,17 @@ export type { components as MrtB2CComponents, } from './mrt-b2c.js'; +export {createCipClient, CipClient, DEFAULT_CIP_HOST, DEFAULT_CIP_STAGING_HOST} from './cip.js'; +export type { + CipClientConfig, + CipColumn, + CipExecuteResponse, + CipFetchResponse, + CipFrame, + CipQueryOptions, + CipQueryResult, +} from './cip.js'; + export {getApiErrorMessage} from './error-utils.js'; export {createTlsDispatcher} from './tls-dispatcher.js'; diff --git a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts index 1c3d391d..1174b6e1 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts @@ -45,6 +45,7 @@ import type {Middleware} from 'openapi-fetch'; * Types of HTTP clients that can receive middleware. */ export type HttpClientType = + | 'cip' | 'ocapi' | 'slas' | 'ods' diff --git a/packages/b2c-tooling-sdk/src/config/dw-json.ts b/packages/b2c-tooling-sdk/src/config/dw-json.ts index 37bd3b1f..33db8ed7 100644 --- a/packages/b2c-tooling-sdk/src/config/dw-json.ts +++ b/packages/b2c-tooling-sdk/src/config/dw-json.ts @@ -69,6 +69,8 @@ export interface DwJsonConfig { sandboxApiHost?: string; /** Default content library ID for content export/list commands */ contentLibrary?: string; + /** Optional CIP analytics host override */ + cipHost?: string; /** Path to PKCS12 certificate file for mTLS (two-factor auth) */ certificate?: string; /** Passphrase for the certificate */ diff --git a/packages/b2c-tooling-sdk/src/config/mapping.ts b/packages/b2c-tooling-sdk/src/config/mapping.ts index 98001409..4982e0f5 100644 --- a/packages/b2c-tooling-sdk/src/config/mapping.ts +++ b/packages/b2c-tooling-sdk/src/config/mapping.ts @@ -48,6 +48,7 @@ export const CONFIG_KEY_ALIASES: Record = { selfsigned: 'selfSigned', 'oauth-scopes': 'oauthScopes', 'auth-methods': 'authMethods', + 'cip-host': 'cipHost', }; /** @@ -116,6 +117,7 @@ export function mapDwJsonToNormalizedConfig(json: DwJsonConfig): NormalizedConfi tenantId: json.tenantId, sandboxApiHost: json.sandboxApiHost, contentLibrary: json.contentLibrary, + cipHost: json.cipHost, instanceName: json.name, authMethods: json.authMethods, accountManagerHost: json.accountManagerHost, @@ -189,6 +191,9 @@ export function mapNormalizedConfigToDwJson(config: Partial, n if (config.accountManagerHost !== undefined) { result.accountManagerHost = config.accountManagerHost; } + if (config.cipHost !== undefined) { + result.cipHost = config.cipHost; + } if (config.mrtProject !== undefined) { result.mrtProject = config.mrtProject; } @@ -308,6 +313,7 @@ export function mergeConfigsWithProtection( shortCode: overrides.shortCode ?? base.shortCode, tenantId: overrides.tenantId ?? base.tenantId, contentLibrary: overrides.contentLibrary ?? base.contentLibrary, + cipHost: overrides.cipHost ?? base.cipHost, sandboxApiHost: overrides.sandboxApiHost ?? base.sandboxApiHost, instanceName: overrides.instanceName ?? base.instanceName, mrtProject: overrides.mrtProject ?? base.mrtProject, diff --git a/packages/b2c-tooling-sdk/src/config/types.ts b/packages/b2c-tooling-sdk/src/config/types.ts index 44498622..553bef72 100644 --- a/packages/b2c-tooling-sdk/src/config/types.ts +++ b/packages/b2c-tooling-sdk/src/config/types.ts @@ -72,6 +72,10 @@ export interface NormalizedConfig { /** Default content library ID for content export/list commands */ contentLibrary?: string; + // CIP + /** Optional CIP analytics host override */ + cipHost?: string; + // Metadata /** Instance name (from multi-config supporting sources) */ instanceName?: string; diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index f0694a2c..c6619926 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -71,6 +71,7 @@ export { createAccountManagerApiClientsClient, createAccountManagerOrgsClient, createCdnZonesClient, + createCipClient, toOrganizationId, toTenantId, buildTenantScope, @@ -85,6 +86,8 @@ export { CUSTOM_APIS_DEFAULT_SCOPES, CDN_ZONES_READ_SCOPES, CDN_ZONES_RW_SCOPES, + DEFAULT_CIP_HOST, + DEFAULT_CIP_STAGING_HOST, } from './clients/index.js'; export type { PropfindEntry, @@ -150,6 +153,14 @@ export type { ZonesEnvelope, CdnZonesPaths, CdnZonesComponents, + CipClient, + CipClientConfig, + CipColumn, + CipExecuteResponse, + CipFetchResponse, + CipFrame, + CipQueryOptions, + CipQueryResult, } from './clients/index.js'; // Operations - Code @@ -248,6 +259,31 @@ export { export type {SandboxState, WaitForSandboxOptions, WaitForSandboxPollInfo} from './operations/ods/index.js'; +// Operations - CIP +export { + buildCipReportSql, + describeCipTable, + executeCipReport, + getCipReportByName, + listCipReports, + listCipTables, +} from './operations/cip/index.js'; +export type { + CipColumnMetadata, + CipDescribeTableOptions, + CipDescribeTableResult, + CipListTablesOptions, + CipListTablesResult, + CipReportDefinition, + CipReportExecutionOptions, + CipReportParamType, + CipReportParamDefinition, + CipReportQueryExecutor, + CipReportQueryResult, + CipReportSqlResult, + CipTableMetadata, +} from './operations/cip/index.js'; + // Operations - Users export { getUser, diff --git a/packages/b2c-tooling-sdk/src/operations/cip/index.ts b/packages/b2c-tooling-sdk/src/operations/cip/index.ts new file mode 100644 index 00000000..9b13c1ad --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/cip/index.ts @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import type {CipClient} from '../../clients/cip.js'; +import {CIP_REPORTS} from './reports.js'; +import type { + CipDescribeTableOptions, + CipDescribeTableResult, + CipColumnMetadata, + CipListTablesOptions, + CipListTablesResult, + CipReportDefinition, + CipReportExecutionOptions, + CipReportQueryResult, + CipReportSqlResult, + CipTableMetadata, +} from './types.js'; + +/** + * Curated CIP report operations. + * + * This module exposes report catalog discovery, SQL generation, and + * report execution helpers on top of {@link CipClient}. + * + * @module operations/cip + */ + +export type { + CipColumnMetadata, + CipDescribeTableOptions, + CipDescribeTableResult, + CipListTablesOptions, + CipListTablesResult, + CipReportDefinition, + CipReportExecutionOptions, + CipReportParamType, + CipReportParamDefinition, + CipReportQueryExecutor, + CipReportQueryResult, + CipReportSqlResult, + CipTableMetadata, +} from './types.js'; + +function toStringOrEmpty(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function toBoolean(value: unknown): boolean { + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'string') { + return value.toUpperCase() === 'YES' || value.toLowerCase() === 'true'; + } + + return false; +} + +function toNumber(value: unknown): number { + if (typeof value === 'number') { + return value; + } + + return Number(value ?? 0); +} + +function escapeSqlLiteral(value: string): string { + return value.replaceAll("'", "''"); +} + +/** + * Lists tables from the CIP metadata catalog. + */ +export async function listCipTables( + client: CipClient, + options: CipListTablesOptions = {}, +): Promise { + const whereClauses: string[] = []; + + if (options.schema) { + whereClauses.push(`tableSchem = '${escapeSqlLiteral(options.schema)}'`); + } + + if (options.tableNamePattern) { + whereClauses.push(`tableName LIKE '${escapeSqlLiteral(options.tableNamePattern)}'`); + } + + if (options.tableType) { + whereClauses.push(`tableType = '${escapeSqlLiteral(options.tableType)}'`); + } + + const whereClauseSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : ''; + const sql = `SELECT tableSchem, tableName, tableType FROM metadata.TABLES${whereClauseSql} ORDER BY tableSchem, tableName`; + const result = await client.query(sql, {fetchSize: options.fetchSize}); + + const tables: CipTableMetadata[] = result.rows.map((row) => ({ + tableName: toStringOrEmpty(row.tableName), + tableSchema: toStringOrEmpty(row.tableSchem), + tableType: toStringOrEmpty(row.tableType), + })); + + return { + schema: options.schema, + tableCount: tables.length, + tables, + }; +} + +/** + * Describes table columns from the CIP metadata catalog. + */ +export async function describeCipTable( + client: CipClient, + tableName: string, + options: CipDescribeTableOptions = {}, +): Promise { + const tableSchema = options.schema ?? 'warehouse'; + const sql = + `SELECT tableSchem, tableName, columnName, typeName, isNullable, ordinalPosition FROM metadata.COLUMNS ` + + `WHERE tableSchem = '${escapeSqlLiteral(tableSchema)}' AND tableName = '${escapeSqlLiteral(tableName)}' ` + + `ORDER BY ordinalPosition`; + + const result = await client.query(sql, {fetchSize: options.fetchSize}); + + const columns: CipColumnMetadata[] = result.rows.map((row) => ({ + columnName: toStringOrEmpty(row.columnName), + dataType: toStringOrEmpty(row.typeName), + isNullable: toBoolean(row.isNullable), + ordinalPosition: toNumber(row.ordinalPosition), + tableName: toStringOrEmpty(row.tableName), + tableSchema: toStringOrEmpty(row.tableSchem), + })); + + return { + columnCount: columns.length, + columns, + tableName, + tableSchema, + }; +} + +/** + * Lists all curated CIP reports. + */ +export function listCipReports(): CipReportDefinition[] { + return [...CIP_REPORTS]; +} + +/** + * Looks up a curated CIP report by name. + */ +export function getCipReportByName(name: string): CipReportDefinition | undefined { + return CIP_REPORTS.find((report) => report.name === name); +} + +function validateReportParams(report: CipReportDefinition, params: Record): void { + const unknownParams = Object.keys(params).filter( + (key) => !report.parameters.some((parameter) => parameter.name === key), + ); + if (unknownParams.length > 0) { + throw new Error(`Unknown parameters for report "${report.name}": ${unknownParams.join(', ')}`); + } + + for (const parameter of report.parameters) { + if (parameter.required && !params[parameter.name]) { + throw new Error(`Missing required parameter for report "${report.name}": ${parameter.name}`); + } + } +} + +/** + * Builds SQL for a curated report after validating provided parameters. + */ +export function buildCipReportSql(name: string, params: Record): CipReportSqlResult { + const report = getCipReportByName(name); + if (!report) { + throw new Error(`Unknown CIP report: ${name}`); + } + + validateReportParams(report, params); + + return { + report, + sql: report.buildSql(params), + }; +} + +/** + * Executes a curated report query and returns decoded rows. + */ +export async function executeCipReport( + client: CipClient, + reportName: string, + options: CipReportExecutionOptions, +): Promise { + const {report, sql} = buildCipReportSql(reportName, options.params); + const queryResult = await client.query(sql, options); + + return { + ...queryResult, + reportName: report.name, + }; +} diff --git a/packages/b2c-tooling-sdk/src/operations/cip/reports.ts b/packages/b2c-tooling-sdk/src/operations/cip/reports.ts new file mode 100644 index 00000000..f77cd21d --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/cip/reports.ts @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import type {CipReportDefinition} from './types.js'; + +function escapeSqlString(value: string): string { + return value.replaceAll("'", "''"); +} + +function getRequiredString(params: Record, key: string): string { + const value = params[key]; + if (!value) { + throw new Error(`Missing required report parameter: ${key}`); + } + + return value; +} + +function getDateLiteral(params: Record, key: string): string { + const value = getRequiredString(params, key); + if (!/^\d{4}-\d{2}-\d{2}$/u.test(value)) { + throw new Error(`Invalid date for parameter "${key}": expected YYYY-MM-DD`); + } + + return `'${escapeSqlString(value)}'`; +} + +function getStringLiteral(params: Record, key: string): string { + const value = getRequiredString(params, key); + return `'${escapeSqlString(value)}'`; +} + +function getBooleanLiteral(params: Record, key: string): string { + const value = getRequiredString(params, key).toLowerCase(); + if (value !== 'true' && value !== 'false') { + throw new Error(`Invalid boolean for parameter "${key}": expected true or false`); + } + + return value; +} + +function getOptionalIntegerLiteral( + params: Record, + key: string, + fallback: number, + min: number, + max: number, +): string { + const raw = params[key]; + if (!raw) { + return String(fallback); + } + + const parsed = Number.parseInt(raw, 10); + if (!Number.isInteger(parsed) || parsed < min || parsed > max) { + throw new Error(`Invalid integer for parameter "${key}": expected ${min}-${max}`); + } + + return String(parsed); +} + +export const CIP_REPORTS: CipReportDefinition[] = [ + { + name: 'sales-analytics', + description: 'Track daily sales performance with AOV and AOS metrics', + category: 'Sales Analytics', + parameters: [ + {name: 'siteId', description: 'Natural site id', type: 'string', required: true}, + {name: 'from', description: 'Inclusive start date (YYYY-MM-DD)', type: 'date', required: true}, + {name: 'to', description: 'Inclusive end date (YYYY-MM-DD)', type: 'date', required: true}, + ], + buildSql(params) { + const siteId = getStringLiteral(params, 'siteId'); + const from = getDateLiteral(params, 'from'); + const to = getDateLiteral(params, 'to'); + return `SELECT CAST(ss.submit_date AS VARCHAR) AS "date", SUM(std_revenue) AS std_revenue, SUM(num_orders) AS orders, CAST(SUM(std_revenue) / SUM(num_orders) AS DECIMAL(15,2)) AS std_aov, SUM(num_units) AS units, CAST(SUM(num_units) / SUM(num_orders) AS DECIMAL(15,2)) AS aos, SUM(std_tax) AS std_tax, SUM(std_shipping) AS std_shipping FROM ccdw_aggr_sales_summary ss JOIN ccdw_dim_site s ON s.site_id = ss.site_id WHERE ss.submit_date >= ${from} AND ss.submit_date <= ${to} AND s.nsite_id = ${siteId} GROUP BY ss.submit_date ORDER BY ss.submit_date`; + }, + }, + { + name: 'sales-summary', + description: 'Query detailed sales records for custom analysis', + category: 'Sales Analytics', + parameters: [ + {name: 'from', description: 'Inclusive start date (YYYY-MM-DD)', type: 'date', required: true}, + {name: 'to', description: 'Inclusive end date (YYYY-MM-DD)', type: 'date', required: true}, + {name: 'siteId', description: 'Optional natural site id', type: 'string'}, + ], + buildSql(params) { + const from = getDateLiteral(params, 'from'); + const to = getDateLiteral(params, 'to'); + const siteId = params.siteId ? ` AND s.nsite_id = ${getStringLiteral(params, 'siteId')}` : ''; + const joinSite = params.siteId ? ' JOIN ccdw_dim_site s ON s.site_id = ss.site_id' : ''; + return `SELECT ss.submit_date, ss.site_id, ss.business_channel_id, ss.registered, ss.first_time_buyer, ss.device_class_code, ss.locale_code, ss.std_revenue, ss.num_orders, ss.num_units, ss.std_tax, ss.std_shipping, ss.std_total_discount FROM ccdw_aggr_sales_summary ss${joinSite} WHERE ss.submit_date >= ${from} AND ss.submit_date <= ${to}${siteId}`; + }, + }, + { + name: 'ocapi-requests', + description: 'Analyze OCAPI request volume and response latency', + category: 'Technical Analytics', + parameters: [ + {name: 'siteId', description: 'Natural site id', type: 'string', required: true}, + {name: 'from', description: 'Inclusive start date (YYYY-MM-DD)', type: 'date', required: true}, + {name: 'to', description: 'Inclusive end date (YYYY-MM-DD)', type: 'date', required: true}, + ], + buildSql(params) { + const siteId = getStringLiteral(params, 'siteId'); + const from = getDateLiteral(params, 'from'); + const to = getDateLiteral(params, 'to'); + return `SELECT o.request_date, o.api_name, o.api_resource, SUM(o.num_requests) AS total_requests, SUM(o.response_time) AS total_response_time, CASE WHEN SUM(o.num_requests) > 0 THEN SUM(o.response_time) / SUM(o.num_requests) ELSE 0 END AS avg_response_time, o.client_id FROM ccdw_aggr_ocapi_request o JOIN ccdw_dim_site s ON s.site_id = o.site_id WHERE o.request_date >= ${from} AND o.request_date <= ${to} AND s.nsite_id = ${siteId} GROUP BY o.request_date, o.api_name, o.api_resource, o.client_id ORDER BY total_requests DESC`; + }, + }, + { + name: 'top-selling-products', + description: 'Identify top selling products across channels', + category: 'Product Analytics', + parameters: [ + {name: 'siteId', description: 'Natural site id', type: 'string', required: true}, + {name: 'from', description: 'Inclusive start date (YYYY-MM-DD)', type: 'date', required: true}, + {name: 'to', description: 'Inclusive end date (YYYY-MM-DD)', type: 'date', required: true}, + ], + buildSql(params) { + const siteId = getStringLiteral(params, 'siteId'); + const from = getDateLiteral(params, 'from'); + const to = getDateLiteral(params, 'to'); + return `SELECT p.nproduct_id, p.product_display_name, SUM(pss.num_units) AS units_sold, SUM(pss.std_revenue) AS std_revenue, SUM(pss.num_orders) AS order_count, pss.device_class_code, pss.registered, s.nsite_id FROM ccdw_aggr_product_sales_summary pss JOIN ccdw_dim_product p ON p.product_id = pss.product_id JOIN ccdw_dim_site s ON s.site_id = pss.site_id WHERE pss.submit_date >= ${from} AND pss.submit_date <= ${to} AND s.nsite_id = ${siteId} GROUP BY p.nproduct_id, p.product_display_name, pss.device_class_code, pss.registered, s.nsite_id ORDER BY std_revenue DESC`; + }, + }, + { + name: 'product-co-purchase-analysis', + description: 'Analyze frequently co-purchased products', + category: 'Product Analytics', + parameters: [ + {name: 'siteId', description: 'Natural site id', type: 'string', required: true}, + {name: 'from', description: 'Inclusive start date (YYYY-MM-DD)', type: 'date', required: true}, + {name: 'to', description: 'Inclusive end date (YYYY-MM-DD)', type: 'date', required: true}, + ], + buildSql(params) { + const siteId = getStringLiteral(params, 'siteId'); + const from = getDateLiteral(params, 'from'); + const to = getDateLiteral(params, 'to'); + return `SELECT p1.nproduct_id AS product_1_id, p1.product_display_name AS product_1_name, p2.nproduct_id AS product_2_id, p2.product_display_name AS product_2_name, pcb.frequency_count AS co_purchase_count, pcb.std_cobuy_revenue FROM ccdw_aggr_product_cobuy pcb JOIN ccdw_dim_product p1 ON p1.product_id = pcb.product_one_id JOIN ccdw_dim_product p2 ON p2.product_id = pcb.product_two_id JOIN ccdw_dim_site s ON s.nsite_id = pcb.nsite_id WHERE pcb.submit_date >= ${from} AND pcb.submit_date <= ${to} AND s.nsite_id = ${siteId} ORDER BY pcb.frequency_count DESC`; + }, + }, + { + name: 'promotion-discount-analysis', + description: 'Measure promotional discount impact on orders', + category: 'Promotion Analytics', + parameters: [ + {name: 'from', description: 'Inclusive start date (YYYY-MM-DD)', type: 'date', required: true}, + {name: 'to', description: 'Inclusive end date (YYYY-MM-DD)', type: 'date', required: true}, + ], + buildSql(params) { + const from = getDateLiteral(params, 'from'); + const to = getDateLiteral(params, 'to'); + return `WITH TOTAL_ORDERS AS (SELECT ss.submit_date AS submit_day, SUM(num_orders) AS total_orders FROM ccdw_aggr_sales_summary ss WHERE ss.submit_date >= ${from} AND ss.submit_date <= ${to} GROUP BY ss.submit_date), PROMOTION_DISCOUNT AS (SELECT pss.submit_date AS submit_day, p.promotion_class AS promotion_class, SUM(std_total_discount) AS std_total_discount, SUM(num_orders) AS promotion_orders FROM ccdw_aggr_promotion_sales_summary pss JOIN ccdw_dim_promotion p ON p.promotion_id = pss.promotion_id WHERE pss.submit_date >= ${from} AND pss.submit_date <= ${to} GROUP BY pss.submit_date, p.promotion_class) SELECT t.submit_day, t.total_orders, p.promotion_class, p.std_total_discount, p.promotion_orders, p.std_total_discount / p.promotion_orders AS avg_discount_per_order FROM TOTAL_ORDERS t LEFT JOIN PROMOTION_DISCOUNT p ON t.submit_day = p.submit_day`; + }, + }, + { + name: 'search-query-performance', + description: 'Identify search terms driving revenue and conversion', + category: 'Search Analytics', + parameters: [ + {name: 'siteId', description: 'Natural site id', type: 'string', required: true}, + {name: 'hasResults', description: 'Filter successful/unsuccessful searches', type: 'boolean', required: true}, + {name: 'from', description: 'Inclusive start date (YYYY-MM-DD)', type: 'date', required: true}, + {name: 'to', description: 'Inclusive end date (YYYY-MM-DD)', type: 'date', required: true}, + ], + buildSql(params) { + const siteId = getStringLiteral(params, 'siteId'); + const hasResults = getBooleanLiteral(params, 'hasResults'); + const from = getDateLiteral(params, 'from'); + const to = getDateLiteral(params, 'to'); + return `WITH conversion AS (SELECT LOWER(sc.query) AS query, SUM(sc.num_searches) AS converted_searches, SUM(sc.num_orders) AS orders, SUM(sc.std_revenue) AS std_revenue, SUM(sc.std_revenue) / NULLIF(CAST(SUM(sc.num_orders) AS FLOAT), 0) AS std_revenue_per_order FROM ccdw_aggr_search_conversion sc JOIN ccdw_dim_site s ON s.site_id = sc.site_id WHERE sc.search_date >= ${from} AND sc.search_date <= ${to} AND s.nsite_id = ${siteId} AND sc.has_results = ${hasResults} GROUP BY LOWER(sc.query)) SELECT query, converted_searches, orders, std_revenue, std_revenue_per_order, CASE WHEN converted_searches > 0 THEN (CAST(orders AS FLOAT) / converted_searches) * 100 ELSE 0 END AS conversion_rate FROM conversion ORDER BY std_revenue DESC`; + }, + }, + { + name: 'payment-method-performance', + description: 'Track payment method adoption and transaction metrics', + category: 'Payment Analytics', + parameters: [ + {name: 'siteId', description: 'Natural site id', type: 'string', required: true}, + {name: 'from', description: 'Inclusive start date (YYYY-MM-DD)', type: 'date', required: true}, + {name: 'to', description: 'Inclusive end date (YYYY-MM-DD)', type: 'date', required: true}, + ], + buildSql(params) { + const siteId = getStringLiteral(params, 'siteId'); + const from = getDateLiteral(params, 'from'); + const to = getDateLiteral(params, 'to'); + return `SELECT pm.display_name AS payment_method, SUM(pss.num_payments) AS total_payments, SUM(pss.num_orders) AS orders_with_payment, SUM(pss.std_captured_amount) AS std_captured_amount, SUM(pss.std_refunded_amount) AS std_refunded_amount, SUM(pss.std_transaction_amount) AS std_transaction_amount, (SUM(pss.std_captured_amount) / SUM(pss.num_payments)) AS avg_payment_amount FROM ccdw_aggr_payment_sales_summary pss JOIN ccdw_dim_payment_method pm ON pm.payment_method_id = pss.payment_method_id JOIN ccdw_dim_site s ON s.site_id = pss.site_id WHERE pss.submit_date >= ${from} AND pss.submit_date <= ${to} AND s.nsite_id = ${siteId} GROUP BY pm.display_name ORDER BY std_captured_amount DESC`; + }, + }, + { + name: 'customer-registration-trends', + description: 'Track customer registration trends by date and device', + category: 'Customer Analytics', + parameters: [ + {name: 'siteId', description: 'Natural site id', type: 'string', required: true}, + {name: 'from', description: 'Inclusive start date (YYYY-MM-DD)', type: 'date', required: true}, + {name: 'to', description: 'Inclusive end date (YYYY-MM-DD)', type: 'date', required: true}, + ], + buildSql(params) { + const siteId = getStringLiteral(params, 'siteId'); + const from = getDateLiteral(params, 'from'); + const to = getDateLiteral(params, 'to'); + return `SELECT r.registration_date AS "date", SUM(r.num_registrations) AS new_registrations, r.device_class_code, s.nsite_id FROM ccdw_aggr_registration r JOIN ccdw_dim_site s ON s.site_id = r.site_id WHERE r.registration_date >= ${from} AND r.registration_date <= ${to} AND s.nsite_id = ${siteId} GROUP BY r.registration_date, r.device_class_code, s.nsite_id ORDER BY r.registration_date`; + }, + }, + { + name: 'top-referrers', + description: 'Identify top traffic referrers and visit share', + category: 'Traffic Analytics', + parameters: [ + {name: 'siteId', description: 'Natural site id', type: 'string', required: true}, + {name: 'from', description: 'Inclusive start date (YYYY-MM-DD)', type: 'date', required: true}, + {name: 'to', description: 'Inclusive end date (YYYY-MM-DD)', type: 'date', required: true}, + {name: 'limit', description: 'Max rows (1-500)', type: 'number', min: 1, max: 500}, + ], + buildSql(params) { + const siteId = getStringLiteral(params, 'siteId'); + const from = getDateLiteral(params, 'from'); + const to = getDateLiteral(params, 'to'); + const limit = getOptionalIntegerLiteral(params, 'limit', 20, 1, 500); + return `WITH total AS (SELECT SUM(num_visits) AS total_visits FROM ccdw_aggr_visit_referrer WHERE visit_date >= ${from} AND visit_date <= ${to}) SELECT vr.referrer_medium AS traffic_medium, vr.referrer_source AS traffic_source, SUM(vr.num_visits) AS total_visits, SUM(vr.num_visits) * 100.0 / total.total_visits AS visit_percentage FROM ccdw_aggr_visit_referrer vr JOIN ccdw_dim_site s ON s.site_id = vr.site_id JOIN total ON TRUE WHERE vr.visit_date >= ${from} AND vr.visit_date <= ${to} AND s.nsite_id = ${siteId} GROUP BY vr.referrer_medium, vr.referrer_source, total.total_visits ORDER BY total_visits DESC LIMIT ${limit}`; + }, + }, +]; diff --git a/packages/b2c-tooling-sdk/src/operations/cip/types.ts b/packages/b2c-tooling-sdk/src/operations/cip/types.ts new file mode 100644 index 00000000..6b2adafd --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/cip/types.ts @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import type {CipClient, CipQueryOptions, CipQueryResult} from '../../clients/cip.js'; + +/** Supported curated report parameter types. */ +export type CipReportParamType = 'string' | 'number' | 'boolean' | 'date'; + +/** + * Parameter contract for a curated CIP report. + */ +export interface CipReportParamDefinition { + name: string; + description: string; + type: CipReportParamType; + required?: boolean; + min?: number; + max?: number; +} + +/** + * Curated CIP report definition. + */ +export interface CipReportDefinition { + name: string; + description: string; + category: string; + parameters: CipReportParamDefinition[]; + buildSql: (params: Record) => string; +} + +/** + * Generated SQL for a curated report execution. + */ +export interface CipReportSqlResult { + report: CipReportDefinition; + sql: string; +} + +/** + * Options for executing a curated report. + */ +export interface CipReportExecutionOptions extends CipQueryOptions { + params: Record; +} + +/** + * Result of a curated report query. + */ +export interface CipReportQueryResult extends CipQueryResult { + reportName: string; +} + +/** + * Function signature for custom report query executors. + */ +export type CipReportQueryExecutor = ( + client: CipClient, + options: CipReportExecutionOptions, +) => Promise; + +/** + * Table metadata record from CIP metadata catalog. + */ +export interface CipTableMetadata { + tableName: string; + tableSchema: string; + tableType: string; +} + +/** + * Column metadata record from CIP metadata catalog. + */ +export interface CipColumnMetadata { + columnName: string; + dataType: string; + isNullable: boolean; + ordinalPosition: number; + tableName: string; + tableSchema: string; +} + +/** + * Options for listing tables from metadata catalog. + */ +export interface CipListTablesOptions extends Pick { + schema?: string; + tableNamePattern?: string; + tableType?: string; +} + +/** + * Result for table listing operation. + */ +export interface CipListTablesResult { + schema?: string; + tableCount: number; + tables: CipTableMetadata[]; +} + +/** + * Options for describing table columns from metadata catalog. + */ +export interface CipDescribeTableOptions extends Pick { + schema?: string; +} + +/** + * Result for table describe operation. + */ +export interface CipDescribeTableResult { + columnCount: number; + columns: CipColumnMetadata[]; + tableName: string; + tableSchema: string; +} diff --git a/packages/b2c-tooling-sdk/test/clients/cip.test.ts b/packages/b2c-tooling-sdk/test/clients/cip.test.ts new file mode 100644 index 00000000..ecf90017 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/clients/cip.test.ts @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {OAuthStrategy} from '../../src/auth/oauth.js'; +import {createCipClient, DEFAULT_CIP_HOST, DEFAULT_CIP_STAGING_HOST} from '../../src/clients/cip.js'; +import {MockAuthStrategy} from '../helpers/mock-auth.js'; + +class TrackingOAuthStrategy extends OAuthStrategy { + public capturedAdditionalScopes: string[] = []; + + public override withAdditionalScopes(scopes: string[]): OAuthStrategy { + this.capturedAdditionalScopes = scopes; + return super.withAdditionalScopes(scopes); + } +} + +describe('clients/cip', () => { + it('adds CIP tenant scope when using OAuth strategy', () => { + const auth = new TrackingOAuthStrategy({ + clientId: 'test-client', + clientSecret: 'test-secret', + }); + + const client = createCipClient({instance: 'zzxy_prd'}, auth); + + expect(auth.capturedAdditionalScopes).to.deep.equal(['SALESFORCE_COMMERCE_API:zzxy_prd']); + expect((client as unknown as {auth: unknown}).auth).to.not.equal(auth); + }); + + it('keeps non-OAuth auth strategy unchanged', () => { + const auth = new MockAuthStrategy(); + + const client = createCipClient({instance: 'zzxy_prd'}, auth); + + expect((client as unknown as {auth: unknown}).auth).to.equal(auth); + }); + + it('builds base URL with default and normalized host', () => { + const auth = new MockAuthStrategy(); + + const defaultHostClient = createCipClient({instance: 'zzxy_prd'}, auth); + expect((defaultHostClient as unknown as {baseUrl: string}).baseUrl).to.equal( + `https://${DEFAULT_CIP_HOST}/zzxy_prd`, + ); + + const normalizedHostClient = createCipClient( + { + instance: 'zzxy_prd', + host: `https://${DEFAULT_CIP_STAGING_HOST}/some/path`, + }, + auth, + ); + expect((normalizedHostClient as unknown as {baseUrl: string}).baseUrl).to.equal( + `https://${DEFAULT_CIP_STAGING_HOST}/zzxy_prd`, + ); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/operations/cip/metadata.test.ts b/packages/b2c-tooling-sdk/test/operations/cip/metadata.test.ts new file mode 100644 index 00000000..baa025f1 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/cip/metadata.test.ts @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {describeCipTable, listCipTables} from '@salesforce/b2c-tooling-sdk/operations/cip'; +import type {CipClient} from '../../../src/clients/cip.js'; + +describe('operations/cip metadata', () => { + it('lists tables from metadata catalog', async () => { + const calls: Array<{options: unknown; sql: string}> = []; + const client = { + query: async (sql: string, options: unknown) => { + calls.push({sql, options}); + return { + columns: ['tableSchem', 'tableName', 'tableType'], + rowCount: 2, + rows: [ + {tableName: 'ccdw_aggr_sales_summary', tableSchem: 'warehouse', tableType: 'TABLE'}, + {tableName: 'ccdw_dim_site', tableSchem: 'warehouse', tableType: 'TABLE'}, + ], + }; + }, + } as unknown as CipClient; + + const result = await listCipTables(client, { + fetchSize: 500, + schema: 'warehouse', + tableNamePattern: 'ccdw_%', + tableType: 'TABLE', + }); + + expect(result.tableCount).to.equal(2); + expect(result.tables[0]?.tableSchema).to.equal('warehouse'); + expect(result.tables[0]?.tableName).to.equal('ccdw_aggr_sales_summary'); + expect(calls).to.have.length(1); + expect(calls[0]?.sql).to.include('FROM metadata.TABLES'); + expect(calls[0]?.sql).to.include("tableSchem = 'warehouse'"); + expect(calls[0]?.sql).to.include("tableName LIKE 'ccdw_%'"); + expect(calls[0]?.sql).to.include("tableType = 'TABLE'"); + expect((calls[0]?.options as {fetchSize?: number})?.fetchSize).to.equal(500); + }); + + it('describes table columns from metadata catalog', async () => { + const calls: Array<{options: unknown; sql: string}> = []; + const client = { + query: async (sql: string, options: unknown) => { + calls.push({sql, options}); + return { + columns: ['columnName', 'typeName', 'isNullable', 'ordinalPosition', 'tableName', 'tableSchem'], + rowCount: 2, + rows: [ + { + columnName: 'request_date', + isNullable: 'NO', + ordinalPosition: 1, + tableName: 'ccdw_aggr_ocapi_request', + tableSchem: 'warehouse', + typeName: 'TIMESTAMP(3) NOT NULL', + }, + { + columnName: 'api_name', + isNullable: 'YES', + ordinalPosition: 2, + tableName: 'ccdw_aggr_ocapi_request', + tableSchem: 'warehouse', + typeName: 'VARCHAR(8)', + }, + ], + }; + }, + } as unknown as CipClient; + + const result = await describeCipTable(client, 'ccdw_aggr_ocapi_request', { + fetchSize: 250, + schema: 'warehouse', + }); + + expect(result.columnCount).to.equal(2); + expect(result.columns[0]?.columnName).to.equal('request_date'); + expect(result.columns[0]?.isNullable).to.equal(false); + expect(result.columns[1]?.isNullable).to.equal(true); + expect(calls).to.have.length(1); + expect(calls[0]?.sql).to.include('FROM metadata.COLUMNS'); + expect(calls[0]?.sql).to.include("tableSchem = 'warehouse'"); + expect(calls[0]?.sql).to.include("tableName = 'ccdw_aggr_ocapi_request'"); + expect((calls[0]?.options as {fetchSize?: number})?.fetchSize).to.equal(250); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/operations/cip/reports.test.ts b/packages/b2c-tooling-sdk/test/operations/cip/reports.test.ts new file mode 100644 index 00000000..cbcba8f2 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/cip/reports.test.ts @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import { + buildCipReportSql, + executeCipReport, + getCipReportByName, + listCipReports, +} from '@salesforce/b2c-tooling-sdk/operations/cip'; +import type {CipClient} from '../../../src/clients/cip.js'; + +describe('operations/cip', () => { + it('lists the curated report catalog', () => { + const reports = listCipReports(); + expect(reports.length).to.equal(10); + }); + + it('gets a known report by name', () => { + const report = getCipReportByName('sales-analytics'); + expect(report).to.not.equal(undefined); + expect(report?.name).to.equal('sales-analytics'); + }); + + it('builds SQL for a known report', () => { + const result = buildCipReportSql('sales-analytics', { + siteId: 'Sites-RefArch-Site', + from: '2025-01-01', + to: '2025-01-31', + }); + expect(result.sql).to.include('ccdw_aggr_sales_summary'); + expect(result.sql).to.include("'Sites-RefArch-Site'"); + }); + + it('rejects unknown parameters', () => { + expect(() => + buildCipReportSql('sales-analytics', { + siteId: 'Sites-RefArch-Site', + from: '2025-01-01', + to: '2025-01-31', + bad: 'x', + }), + ).to.throw('Unknown parameters'); + }); + + it('rejects missing required parameters', () => { + expect(() => + buildCipReportSql('sales-analytics', { + from: '2025-01-01', + to: '2025-01-31', + }), + ).to.throw('Missing required parameter'); + }); + + it('executes report and returns decoded query payload', async () => { + const queryCalls: Array<{options: unknown; sql: string}> = []; + const client = { + query: async (sql: string, options: unknown) => { + queryCalls.push({sql, options}); + return { + columns: ['date', 'orders'], + rows: [{date: '2025-01-01', orders: 5}], + rowCount: 1, + }; + }, + } as unknown as CipClient; + + const result = await executeCipReport(client, 'sales-analytics', { + params: { + siteId: 'Sites-RefArch-Site', + from: '2025-01-01', + to: '2025-01-31', + }, + fetchSize: 500, + }); + + expect(result.reportName).to.equal('sales-analytics'); + expect(result.columns).to.deep.equal(['date', 'orders']); + expect(result.rowCount).to.equal(1); + expect(queryCalls).to.have.length(1); + expect(queryCalls[0]?.sql).to.include('ccdw_aggr_sales_summary'); + expect((queryCalls[0]?.options as {fetchSize?: number})?.fetchSize).to.equal(500); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35c6adfb..c45ad953 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -337,6 +337,9 @@ importers: pino-pretty: specifier: 13.1.2 version: 13.1.2 + protobufjs: + specifier: 7.5.4 + version: 7.5.4 undici: specifier: 7.19.2 version: 7.19.2 @@ -6279,6 +6282,10 @@ packages: resolution: {integrity: sha512-dgJaEDDL6x8ASUZ1YqWciTRrdOuYNzoOf27oHNfdyvKqHr5i0FV7FSLU+aIeFjyFgVxrpTOtQUi0BLLBymZaBw==} engines: {node: '>=12.0.0'} + protobufjs@7.5.4: + resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -14484,6 +14491,21 @@ snapshots: '@types/node': 22.19.0 long: 5.3.2 + protobufjs@7.5.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 22.19.0 + long: 5.3.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 diff --git a/skills/b2c-cli/skills/b2c-cip/SKILL.md b/skills/b2c-cli/skills/b2c-cip/SKILL.md new file mode 100644 index 00000000..71ce1546 --- /dev/null +++ b/skills/b2c-cli/skills/b2c-cip/SKILL.md @@ -0,0 +1,170 @@ +--- +name: b2c-cip +description: Run Commerce Intelligence Platform (CIP/CCAC) analytics reports, metadata discovery, and SQL queries with the b2c cli. Use when users ask for sales/search/payment analytics, KPI validation, table discovery, schema inspection, report exports, or custom SQL against B2C Commerce Intelligence data. +--- + +# B2C CIP Skill + +Use `b2c cip` commands to query B2C Commerce Intelligence (CIP), also known as Commerce Cloud Analytics (CCAC). + +> **Tip:** If `b2c` is not installed globally, use `npx @salesforce/b2c-cli`. + +## Command Structure + +```text +cip +├── tables - list metadata catalog tables +├── describe
- describe table columns +├── query - raw SQL execution +└── report - curated report topic + ├── sales-analytics + ├── sales-summary + ├── ocapi-requests + ├── top-selling-products + ├── product-co-purchase-analysis + ├── promotion-discount-analysis + ├── search-query-performance + ├── payment-method-performance + ├── customer-registration-trends + └── top-referrers +``` + +## Requirements + +- OAuth client credentials: `--client-id`, `--client-secret` +- CIP tenant: `--tenant-id` (or `--tenant`) +- API client has `Salesforce Commerce API` role with tenant filter for your instance + +Optional: + +- `--cip-host` (or `SFCC_CIP_HOST`) to override the default host +- `--staging` (or `SFCC_CIP_STAGING`) to force staging analytics host + +::: warning Availability +This feature is typically used with production analytics tenants (for example `abcd_prd`). + +Starting with release 26.1, reports and dashboards data can also be enabled for non-production instances (ODS/dev/staging and designated test realms) using the **Enable Reports & Dashboards Data Tracking** feature switch. + +Reports & Dashboards non-production URL: `https://ccac.stg.analytics.commercecloud.salesforce.com` +::: + +## Quick Workflow + +1. Discover available tables (`b2c cip tables`) or curated reports (`b2c cip report --help`). +2. Use `b2c cip describe
` or report `--describe` to inspect structure/parameters. +3. Use report `--sql` to preview generated SQL. +4. Pipe SQL into `cip query` when you need custom execution/output handling. + +## Metadata Discovery Examples + +```bash +# List warehouse tables +b2c cip tables --tenant-id abcd_prd --client-id --client-secret + +# Filter table names +b2c cip tables --tenant-id abcd_prd --pattern "ccdw_aggr_%" --client-id --client-secret + +# Describe table columns +b2c cip describe ccdw_aggr_ocapi_request --tenant-id abcd_prd --client-id --client-secret +``` + +## Known Tables + +For an efficient table catalog grouped by aggregate/dimension/fact families, use: + +- `references/KNOWN_TABLES.md` + +For a general-purpose starter query pack with ready-to-run SQL patterns, use: + +- `references/STARTER_QUERIES.md` + +The list is derived from official JDBC documentation and intended as a quick discovery aid. + +## Curated Report Examples + +```bash +# Show report commands +b2c cip report --help + +# Run a report +b2c cip report sales-analytics \ + --site-id Sites-RefArch-Site \ + --from 2025-01-01 \ + --to 2025-01-31 \ + --tenant-id abcd_prd \ + --client-id \ + --client-secret + +# Show report parameter contract +b2c cip report top-referrers --describe + +# Print generated SQL and stop +b2c cip report top-referrers --site-id Sites-RefArch-Site --limit 25 --sql + +# Force staging analytics host +b2c cip report top-referrers --site-id Sites-RefArch-Site --limit 25 --staging --sql +``` + +### SQL Pipeline Pattern + +```bash +b2c cip report sales-analytics --site-id Sites-RefArch-Site --sql \ + | b2c cip query --tenant-id abcd_prd --client-id --client-secret +``` + +## Raw SQL Query Examples + +```bash +b2c cip query \ + --tenant-id abcd_prd \ + --client-id \ + --client-secret \ + "SELECT * FROM ccdw_aggr_sales_summary LIMIT 10" +``` + +You can also use: + +- `--file ./query.sql` +- pipe query text from standard input (for example `cat query.sql | b2c cip query ...`) + +### Date Placeholders + +`b2c cip query` supports placeholder replacement: + +- `` with `--from YYYY-MM-DD` +- `` with `--to YYYY-MM-DD` +- `--from` defaults to first day of current month +- `--to` defaults to today + +If you provide `--site-id`, the common CIP format is `Sites-{siteId}-Site`. The command warns when `siteId` does not match that pattern but still runs with your input. + +## Output Formats + +Both raw query and report commands support: + +- `--format table` (default) +- `--format csv` +- `--format json` +- `--json` (global JSON mode) + +## Service Limits and Best Practices + +The underlying JDBC analytics service has strict limits. Keep requests scoped: + +- avoid broad `SELECT *` queries +- use narrow date ranges and incremental windows +- prefer aggregate tables when possible +- use report commands for common KPI workflows + +Limits can change over time. Use the official JDBC access guide for current NFR limits: + +- https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_access_guide.html + +## Troubleshooting + +- **`tenant-id is required`**: set `--tenant-id` (or `SFCC_TENANT_ID`) +- **Auth method error**: CIP supports client credentials only; remove `--user-auth` +- **403/unauthorized**: verify API client role and tenant filter include target instance +- **Rate/timeout failures**: reduce date window, select fewer columns, query aggregate tables + +For full command reference, use `b2c cip --help` and [CLI docs](/cli/cip). diff --git a/skills/b2c-cli/skills/b2c-cip/references/KNOWN_TABLES.md b/skills/b2c-cli/skills/b2c-cip/references/KNOWN_TABLES.md new file mode 100644 index 00000000..69216a4e --- /dev/null +++ b/skills/b2c-cli/skills/b2c-cip/references/KNOWN_TABLES.md @@ -0,0 +1,105 @@ +# Known CIP Tables + +This quick catalog is based on the official JDBC schema documentation. + +Use this as a starting point. Always verify actual availability in your tenant with: + +```bash +b2c cip tables --tenant-id +b2c cip describe --tenant-id +``` + +Official schema references: + +- https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_lakehouse_schema.html +- https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_aggregate_tables.html +- https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_dimension_tables.html +- https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_fact_tables.html + +## Aggregate Tables (`ccdw_aggr_*`) + +Best for KPI/reporting use cases (already summarized): + +- [`ccdw_aggr_sales_summary`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_sales_summary.html) +- [`ccdw_aggr_product_sales_summary`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_product_sales_summary.html) +- [`ccdw_aggr_promotion_sales_summary`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_promotion_sales_summary.html) +- [`ccdw_aggr_payment_sales_summary`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_payment_sales_summary.html) +- [`ccdw_aggr_registration`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_registration.html) +- [`ccdw_aggr_search`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_search.html) +- [`ccdw_aggr_search_query`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_search_query.html) +- [`ccdw_aggr_search_conversion`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_search_conversion.html) +- [`ccdw_aggr_ocapi_request`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_ocapi_request.html) +- [`ccdw_aggr_scapi_request`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_scapi_request.html) +- [`ccdw_aggr_visit`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_visit.html) +- [`ccdw_aggr_visit_checkout`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_visit_checkout.html) +- [`ccdw_aggr_visit_referrer`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_visit_referrer.html) +- [`ccdw_aggr_visit_ip_address`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_visit_ip_address.html) +- [`ccdw_aggr_visit_user_agent`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_visit_user_agent.html) +- [`ccdw_aggr_visit_robot`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_visit_robot.html) +- [`ccdw_aggr_controller_request`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_controller_request.html) +- [`ccdw_aggr_include_controller_request`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_include_controller_request.html) +- [`ccdw_aggr_source_code_activation`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_source_code_activation.html) +- [`ccdw_aggr_source_code_sales`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_source_code_sales.html) +- [`ccdw_aggr_product_cobuy`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_product_cobuy.html) +- [`ccdw_aggr_product_recommendation`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_product_recommendation.html) +- [`ccdw_aggr_product_recommendation_recommender`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_product_recommendation_recommender.html) +- [`ccdw_aggr_detail_product_recommendation_recommender`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_detail_product_recommendation_recommender.html) +- [`ccdw_aggr_daily_detail_product_recommendation_recommender`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_daily_detail_product_recommendation_recommender.html) +- [`ccdw_aggr_inventory_by_location`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_inventory_by_location.html) +- [`ccdw_aggr_inventory_by_location_group`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_inventory_by_location_group.html) +- [`ccdw_aggr_promotion_activation`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_promotion_activation.html) +- [`ccdw_aggr_promotion_cobuy`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_aggr_promotion_cobuy.html) + +## Dimension Tables (`ccdw_dim_*`) + +Reference/context entities used for joins: + +- [`ccdw_dim_site`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_dim_site.html) +- [`ccdw_dim_product`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_dim_product.html) +- [`ccdw_dim_customer`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_dim_customer.html) +- [`ccdw_dim_campaign`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_dim_campaign.html) +- [`ccdw_dim_coupon`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_dim_coupon.html) +- [`ccdw_dim_promotion`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_dim_promotion.html) +- [`ccdw_dim_payment_method`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_dim_payment_method.html) +- [`ccdw_dim_business_channel`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_dim_business_channel.html) +- [`ccdw_dim_locale`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_dim_locale.html) +- [`ccdw_dim_geography`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_dim_geography.html) +- [`ccdw_dim_currency`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_dim_currency.html) +- [`ccdw_dim_source_code_group`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_dim_source_code_group.html) +- [`ccdw_dim_location`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_dim_location.html) +- [`ccdw_dim_location_group`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_dim_location_group.html) +- [`ccdw_dim_date`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_dim_date.html) +- [`ccdw_dim_time`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_dim_time.html) +- [`ccdw_dim_timezone`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_dim_timezone.html) +- [`ccdw_dim_user_agent`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_dim_user_agent.html) + +## Fact Tables (`ccdw_fact_*`) + +Most granular event-level tables (typically larger): + +- [`ccdw_fact_line_item`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_fact_line_item.html) +- [`ccdw_fact_order_payments`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_fact_order_payments.html) +- [`ccdw_fact_customer_registration`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_fact_customer_registration.html) +- [`ccdw_fact_customer_list_snapshot`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_fact_customer_list_snapshot.html) +- [`ccdw_fact_promotion_line_item`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_fact_promotion_line_item.html) +- [`ccdw_fact_promotion_activation`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_fact_promotion_activation.html) +- [`ccdw_fact_source_codes_activation`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_fact_source_codes_activation.html) +- [`ccdw_fact_inventory_record_snapshot`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_fact_inventory_record_snapshot.html) +- [`ccdw_fact_inventory_record_snapshot_hourly`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_fact_inventory_record_snapshot_hourly.html) +- [`ccdw_fact_realtime_metric`](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/jdbc_ccdw_fact_realtime_metric.html) + +## Practical Starters + +When users ask for common analysis, start with: + +- **Sales trends:** `ccdw_aggr_sales_summary` +- **Product performance:** `ccdw_aggr_product_sales_summary`, join `ccdw_dim_product` +- **Promotion impact:** `ccdw_aggr_promotion_sales_summary`, join `ccdw_dim_promotion` +- **Search conversion:** `ccdw_aggr_search_conversion`, `ccdw_aggr_search_query` +- **Traffic source:** `ccdw_aggr_visit_referrer` +- **API performance:** `ccdw_aggr_ocapi_request`, `ccdw_aggr_scapi_request` + +## Notes + +- Some doc pages may contain naming variance/typos (for example `..._couse` vs `..._cobuy`). Prefer actual tenant metadata output from `cip tables`. +- Non-table helper objects may appear in some tenants (for example `all_calcite_types`). diff --git a/skills/b2c-cli/skills/b2c-cip/references/STARTER_QUERIES.md b/skills/b2c-cli/skills/b2c-cip/references/STARTER_QUERIES.md new file mode 100644 index 00000000..78b82f35 --- /dev/null +++ b/skills/b2c-cli/skills/b2c-cip/references/STARTER_QUERIES.md @@ -0,0 +1,137 @@ +# CIP Starter Queries + +Use these as general-purpose starting points for exploration and troubleshooting. + +Defaults used in examples: + +- `siteId`: `Sites-RefArch-Site` +- keep `LIMIT` clauses to stay lightweight + +## 1) Recent Daily Sales Snapshot + +```sql +SELECT submit_date, num_orders, std_revenue, std_tax, std_shipping +FROM ccdw_aggr_sales_summary +ORDER BY submit_date DESC +LIMIT 20 +``` + +## 2) Sales by Site (Joined) + +```sql +SELECT ss.submit_date, ds.nsite_id, SUM(ss.num_orders) AS orders, SUM(ss.std_revenue) AS revenue +FROM ccdw_aggr_sales_summary ss +JOIN ccdw_dim_site ds ON ss.site_id = ds.site_id +WHERE ds.nsite_id = 'Sites-RefArch-Site' +GROUP BY ss.submit_date, ds.nsite_id +ORDER BY ss.submit_date DESC +LIMIT 30 +``` + +## 3) Top-Selling Products by Revenue + +```sql +SELECT p.nproduct_id, p.product_display_name, SUM(pss.std_revenue) AS revenue, SUM(pss.num_units) AS units +FROM ccdw_aggr_product_sales_summary pss +JOIN ccdw_dim_product p ON p.product_id = pss.product_id +JOIN ccdw_dim_site s ON s.site_id = pss.site_id +WHERE s.nsite_id = 'Sites-RefArch-Site' +GROUP BY p.nproduct_id, p.product_display_name +ORDER BY revenue DESC +LIMIT 25 +``` + +## 4) Promotion Impact Summary + +```sql +SELECT p.promotion_class, SUM(pss.std_revenue) AS revenue, SUM(pss.std_total_discount) AS discount +FROM ccdw_aggr_promotion_sales_summary pss +JOIN ccdw_dim_promotion p ON p.promotion_id = pss.promotion_id +JOIN ccdw_dim_site s ON s.site_id = pss.site_id +WHERE s.nsite_id = 'Sites-RefArch-Site' +GROUP BY p.promotion_class +ORDER BY revenue DESC +LIMIT 20 +``` + +## 5) OCAPI Request Volume + +```sql +SELECT request_date, api_name, api_resource, SUM(num_requests) AS total_requests, SUM(response_time) AS total_response_time +FROM ccdw_aggr_ocapi_request +GROUP BY request_date, api_name, api_resource +ORDER BY request_date DESC, total_requests DESC +LIMIT 25 +``` + +## 6) SCAPI Request Volume + +```sql +SELECT request_date, api_name, api_resource, SUM(num_requests) AS total_requests, SUM(response_time) AS total_response_time +FROM ccdw_aggr_scapi_request +GROUP BY request_date, api_name, api_resource +ORDER BY request_date DESC, total_requests DESC +LIMIT 25 +``` + +## 7) Top Search Terms by Revenue + +```sql +SELECT LOWER(sc.query) AS search_term, SUM(sc.num_searches) AS searches, SUM(sc.num_orders) AS orders, SUM(sc.std_revenue) AS revenue +FROM ccdw_aggr_search_conversion sc +JOIN ccdw_dim_site s ON s.site_id = sc.site_id +WHERE s.nsite_id = 'Sites-RefArch-Site' +GROUP BY LOWER(sc.query) +ORDER BY revenue DESC +LIMIT 30 +``` + +## 8) Referrer Mix + +```sql +SELECT referrer_medium, referrer_source, SUM(num_visits) AS visits +FROM ccdw_aggr_visit_referrer vr +JOIN ccdw_dim_site s ON s.site_id = vr.site_id +WHERE s.nsite_id = 'Sites-RefArch-Site' +GROUP BY referrer_medium, referrer_source +ORDER BY visits DESC +LIMIT 30 +``` + +## 9) Customer Registration Trend + +```sql +SELECT registration_date, SUM(num_registrations) AS registrations +FROM ccdw_aggr_registration r +JOIN ccdw_dim_site s ON s.site_id = r.site_id +WHERE s.nsite_id = 'Sites-RefArch-Site' +GROUP BY registration_date +ORDER BY registration_date DESC +LIMIT 30 +``` + +## 10) Payment Method Performance + +```sql +SELECT pm.display_name AS payment_method, SUM(pss.num_payments) AS payments, SUM(pss.std_captured_amount) AS captured_amount +FROM ccdw_aggr_payment_sales_summary pss +JOIN ccdw_dim_payment_method pm ON pm.payment_method_id = pss.payment_method_id +JOIN ccdw_dim_site s ON s.site_id = pss.site_id +WHERE s.nsite_id = 'Sites-RefArch-Site' +GROUP BY pm.display_name +ORDER BY captured_amount DESC +LIMIT 20 +``` + +## Placeholder Pattern for Date Windows + +Use this pattern when running with `b2c cip query --from ... --to ...`: + +```sql +SELECT submit_date, num_orders, std_revenue +FROM ccdw_aggr_sales_summary +WHERE submit_date >= '' + AND submit_date <= '' +ORDER BY submit_date DESC +LIMIT 20 +``` diff --git a/typedoc.json b/typedoc.json index 8474b1dc..b5f8ac8f 100644 --- a/typedoc.json +++ b/typedoc.json @@ -7,6 +7,7 @@ "./packages/b2c-tooling-sdk/src/instance/index.ts", "./packages/b2c-tooling-sdk/src/logging/index.ts", "./packages/b2c-tooling-sdk/src/operations/code/index.ts", + "./packages/b2c-tooling-sdk/src/operations/cip/index.ts", "./packages/b2c-tooling-sdk/src/operations/jobs/index.ts", "./packages/b2c-tooling-sdk/src/operations/logs/index.ts", "./packages/b2c-tooling-sdk/src/operations/mrt/index.ts",