diff --git a/docs-mintlify/api-reference/api.yaml b/docs-mintlify/api-reference/api.yaml
index 1b976a0622290..e8c745cd9a9eb 100644
--- a/docs-mintlify/api-reference/api.yaml
+++ b/docs-mintlify/api-reference/api.yaml
@@ -33,6 +33,7 @@ tags:
- name: Workbooks
- name: Notifications
- name: Workspace
+ - name: Users
- name: Users Admin
- name: User Attributes
- name: User Attribute Values
@@ -951,56 +952,56 @@ paths:
oneOf:
- minimum: 1
type: integer
- description: >-
- Optional filter: only return notifications for this numeric dashboard id. Provide
- this OR dashboardPublicId, not both.
- type: 'null'
+ description: >-
+ Optional filter: only return notifications for this numeric dashboard id. Provide this
+ OR dashboardPublicId, not both.
- in: query
name: dashboardPublicId
schema:
oneOf:
- type: string
- description: >-
- Optional filter: only return notifications for this dashboard public id. Provide
- this OR dashboardId, not both.
- type: 'null'
+ description: >-
+ Optional filter: only return notifications for this dashboard public id. Provide this
+ OR dashboardId, not both.
- in: query
name: recipientUserId
schema:
oneOf:
- minimum: 1
type: integer
- description: >-
- Optional filter: only return notifications received by this user, by user id.
- Mutually exclusive with recipientEmail and the embed-user filter.
- type: 'null'
+ description: >-
+ Optional filter: only return notifications received by this user, by user id. Mutually
+ exclusive with recipientEmail and the embed-user filter.
- in: query
name: recipientEmail
schema:
oneOf:
- type: string
- description: >-
- Optional filter: only return notifications received by this user, by email.
- Mutually exclusive with recipientUserId and the embed-user filter.
- type: 'null'
+ description: >-
+ Optional filter: only return notifications received by this user, by email. Mutually
+ exclusive with recipientUserId and the embed-user filter.
- in: query
name: recipientEmbedTenantName
schema:
oneOf:
- type: string
- description: >-
- Optional filter: only return notifications received by this embed user. Required
- together with recipientExternalId; mutually exclusive with the user filters.
- type: 'null'
+ description: >-
+ Optional filter: only return notifications received by this embed user. Required
+ together with recipientExternalId; mutually exclusive with the user filters.
- in: query
name: recipientExternalId
schema:
oneOf:
- type: string
- description: >-
- Optional filter: only return notifications received by this embed user. Required
- together with recipientEmbedTenantName; mutually exclusive with the user filters.
- type: 'null'
+ description: >-
+ Optional filter: only return notifications received by this embed user. Required
+ together with recipientEmbedTenantName; mutually exclusive with the user filters.
- in: query
name: after
schema:
@@ -1906,6 +1907,13 @@ paths:
required: true
schema:
type: integer
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateDeploymentTokenInput'
+ description: CreateDeploymentTokenInput
+ required: false
responses:
'200':
content:
@@ -2202,6 +2210,37 @@ paths:
summary: Update workbook dashboard
tags:
- Workbooks
+ /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/dashboard/ai-widget-thread:
+ post:
+ operationId: updatePublishedDashboardAiWidgetThread
+ parameters:
+ - in: path
+ name: deploymentId
+ required: true
+ schema:
+ type: number
+ - in: path
+ name: workbookId
+ required: true
+ schema:
+ type: number
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UpdatePublishedAiWidgetThreadInput'
+ description: UpdatePublishedAiWidgetThreadInput
+ required: false
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Dashboard'
+ description: ''
+ summary: Update published dashboard AI widget thread
+ tags:
+ - Workbooks
/api/v1/deployments/{deploymentId}/workbooks/{workbookId}/duplicate:
post:
operationId: duplicateWorkbook
@@ -3110,8 +3149,8 @@ paths:
schema:
oneOf:
- type: string
- description: Case-insensitive substring match against the embed user’s email or external id.
- type: 'null'
+ description: Case-insensitive substring match against the embed user’s email or external id.
- in: query
name: after
schema:
@@ -3822,8 +3861,8 @@ paths:
[Cube REST API](https://cube.dev/docs/product/apis-integrations/rest-api)).
- Tokens expire after ~24 hours (see `expiresAt`). Issue a fresh one per session or
- scheduled refresh — issuing is idempotent and cheap.
+ Tokens expire after 1 hour (see `expiresAt`). Issue a fresh one per session or scheduled
+ refresh — issuing is idempotent and cheap.
Requires Usage Analytics to be enabled for the account, otherwise `404` is returned.
@@ -4116,6 +4155,30 @@ paths:
Reapplying an action a user is already in (deactivating a deactivated user) succeeds as a
no-op.
+ /api/v1/users/me/settings:
+ patch:
+ operationId: updateMySettings
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UserSettingsInput'
+ description: UserSettings
+ required: false
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/User'
+ description: ''
+ summary: Update my settings
+ tags:
+ - Users
+ x-mint:
+ content: >-
+ Merge a partial patch into the authenticated user's own settings. Omitted fields are left
+ as they are; `sheets` merges field-by-field, and an explicit `null` clears it.
/api/v1/users/{id}:
delete:
operationId: deleteUser
@@ -4883,16 +4946,35 @@ components:
properties:
dark:
$ref: '#/components/schemas/AppThemeScheme'
+ deprecated: true
+ description: 'Deprecated: use palette and logoUrl instead.'
light:
$ref: '#/components/schemas/AppThemeScheme'
+ deprecated: true
+ description: 'Deprecated: use palette and logoUrl instead.'
+ logoUrl:
+ type: string
+ palette:
+ $ref: '#/components/schemas/AppThemePalette'
typography:
oneOf:
- $ref: '#/components/schemas/AppThemeTypography'
- type: 'null'
required:
+ - palette
+ - logoUrl
- light
- dark
type: object
+ AppThemeCodePalette:
+ properties:
+ saturation:
+ oneOf:
+ - maximum: 100
+ type: number
+ minimum: 0
+ - type: 'null'
+ type: object
AppThemeFontRef:
properties:
fontRef:
@@ -4903,6 +4985,77 @@ components:
- fontRef
- fontWeight
type: object
+ AppThemePalette:
+ properties:
+ accent:
+ oneOf:
+ - $ref: '#/components/schemas/AppThemePaletteSeed'
+ - type: 'null'
+ base:
+ oneOf:
+ - $ref: '#/components/schemas/AppThemePaletteSeed'
+ - type: 'null'
+ contrastLevel:
+ oneOf:
+ - maximum: 100
+ type: number
+ minimum: 0
+ - type: 'null'
+ pastel:
+ oneOf:
+ - type: boolean
+ - type: 'null'
+ surfaceMode:
+ oneOf:
+ - $ref: '#/components/schemas/AppThemeSurfaceMode'
+ - type: 'null'
+ themes:
+ oneOf:
+ - $ref: '#/components/schemas/AppThemePaletteThemes'
+ - type: 'null'
+ type: object
+ AppThemePaletteSeed:
+ properties:
+ color:
+ oneOf:
+ - type: string
+ - type: 'null'
+ hue:
+ oneOf:
+ - maximum: 360
+ type: number
+ minimum: 0
+ - type: 'null'
+ saturation:
+ oneOf:
+ - maximum: 100
+ type: number
+ minimum: 0
+ - type: 'null'
+ type: object
+ AppThemePaletteThemes:
+ properties:
+ code:
+ oneOf:
+ - $ref: '#/components/schemas/AppThemeCodePalette'
+ - type: 'null'
+ danger:
+ oneOf:
+ - $ref: '#/components/schemas/AppThemePaletteSeed'
+ - type: 'null'
+ note:
+ oneOf:
+ - $ref: '#/components/schemas/AppThemePaletteSeed'
+ - type: 'null'
+ success:
+ oneOf:
+ - $ref: '#/components/schemas/AppThemePaletteSeed'
+ - type: 'null'
+ warning:
+ oneOf:
+ - $ref: '#/components/schemas/AppThemePaletteSeed'
+ - type: 'null'
+ type: object
AppThemeScheme:
properties:
accentColor:
@@ -4922,6 +5075,11 @@ components:
- contrast
- logoUrl
type: object
+ AppThemeSurfaceMode:
+ enum:
+ - neutral
+ - tinted
+ type: string
AppThemeTypography:
properties:
body:
@@ -5380,14 +5538,27 @@ components:
- git
- cli
type: string
+ CreateDeploymentTokenInput:
+ properties:
+ expiresIn:
+ oneOf:
+ - maximum: 86400
+ type: integer
+ minimum: 60
+ - type: 'null'
+ description: >-
+ Token lifetime in seconds, between 60 and 86400. Omit to keep the historical default of
+ 24 hours (86400). Callers that hold the token outside a browser session — scripts, BI
+ tools, scheduled jobs — should request a short lifetime and re-issue tokens instead.
+ type: object
CreateEmbedGroupInput:
properties:
description:
oneOf:
- maxLength: 500
type: string
- description: Optional human-readable description.
- type: 'null'
+ description: Optional human-readable description.
name:
description: >-
Group name, unique within the embed tenant. The `system:` prefix is reserved and
@@ -5419,58 +5590,56 @@ components:
customCron:
oneOf:
- type: string
- description: Custom cron expression (required if scheduleType is CUSTOM)
- type: 'null'
+ description: Custom cron expression (required if scheduleType is CUSTOM)
dashboardId:
oneOf:
- type: integer
- description: >-
- Numeric id of the dashboard to run on the schedule. Provide this OR
- dashboardPublicId (exactly one).
- type: 'null'
+ description: >-
+ Numeric id of the dashboard to run on the schedule. Provide this OR dashboardPublicId
+ (exactly one).
dashboardPublicId:
oneOf:
- type: string
- description: >-
- Public id of the dashboard to run on the schedule. Provide this OR dashboardId
- (exactly one).
- type: 'null'
+ description: >-
+ Public id of the dashboard to run on the schedule. Provide this OR dashboardId (exactly
+ one).
dayOfMonth:
oneOf:
- type: integer
- description: Day of month (1-31)
- type: 'null'
+ description: Day of month (1-31)
dayOfWeek:
oneOf:
- type: integer
- description: Day of week (0-6, Sunday=0)
- type: 'null'
+ description: Day of week (0-6, Sunday=0)
filters:
oneOf:
- items:
$ref: '#/components/schemas/DashboardFilterInput'
type: array
- description: >-
- Dimension filters applied to the dashboard when the notification is rendered
- (substituted into the screenshot/PDF for every recipient).
- type: 'null'
+ description: >-
+ Dimension filters applied to the dashboard when the notification is rendered
+ (substituted into the screenshot/PDF for every recipient).
hour:
oneOf:
- type: integer
- description: Hour of the day (0-23)
- type: 'null'
+ description: Hour of the day (0-23)
minute:
oneOf:
- type: integer
- description: Minute of the hour (0-59)
- type: 'null'
+ description: Minute of the hour (0-59)
notificationAiSummary:
oneOf:
- type: boolean
- description: >-
- Include an AI-generated "what changed" summary in the notification body. Off by
- default.
- type: 'null'
+ description: Include an AI-generated "what changed" summary in the notification body. Off by default.
notificationEnabled:
oneOf:
- type: boolean
@@ -5486,13 +5655,13 @@ components:
- items:
$ref: '#/components/schemas/DashboardTimeGrainInput'
type: array
- description: Time-grain overrides applied to the dashboard when the notification is rendered.
- type: 'null'
+ description: Time-grain overrides applied to the dashboard when the notification is rendered.
timezone:
oneOf:
- type: string
- description: Timezone for the schedule (e.g. "America/New_York")
- type: 'null'
+ description: Timezone for the schedule (e.g. "America/New_York")
required:
- scheduleType
type: object
@@ -6189,12 +6358,14 @@ components:
properties:
caseSensitive:
oneOf:
- - description: 'For string filters: whether matching is case-sensitive'
+ - {}
- type: 'null'
+ description: 'For string filters: whether matching is case-sensitive'
endInclusive:
oneOf:
- - description: 'For between filters: whether the end bound is inclusive'
+ - {}
- type: 'null'
+ description: 'For between filters: whether the end bound is inclusive'
member:
description: Dimension path, e.g. "Orders.status"
pattern: .+\..+
@@ -6205,20 +6376,21 @@ components:
- type: 'null'
startInclusive:
oneOf:
- - description: 'For between filters: whether the start bound is inclusive'
+ - {}
- type: 'null'
+ description: 'For between filters: whether the start bound is inclusive'
value:
oneOf:
- - description: >-
- Filter value. Omit for is_null / is_not_null / is_empty / is_not_empty; provide a
- 2-element [start, end] array for between.
- oneOf:
+ - oneOf:
- type: string
- type: number
- type: boolean
- type: array
items: {}
- type: 'null'
+ description: >-
+ Filter value. Omit for is_null / is_not_null / is_empty / is_not_empty; provide a
+ 2-element [start, end] array for between.
required:
- member
type: object
@@ -6463,6 +6635,7 @@ components:
- FILTER
- AI
- TIME_GRAIN
+ - FIELD
- PARENT
- SPACER
- DIVIDER
@@ -6503,6 +6676,7 @@ components:
- FILTER
- AI
- TIME_GRAIN
+ - FIELD
- PARENT
- SPACER
- DIVIDER
@@ -6647,8 +6821,8 @@ components:
durationMs:
oneOf:
- type: integer
- description: For a phase line, how long the phase took, in milliseconds.
- type: 'null'
+ description: For a phase line, how long the phase took, in milliseconds.
level:
description: info or error.
type: string
@@ -6657,8 +6831,8 @@ components:
phase:
oneOf:
- type: string
- description: The phase this line belongs to, e.g. dbt-compile.
- type: 'null'
+ description: The phase this line belongs to, e.g. dbt-compile.
stream:
description: >-
Always "system" in this version. "stdout" and "stderr" join it once dbt’s own output is
@@ -6691,8 +6865,8 @@ components:
message:
oneOf:
- type: string
- description: Human-readable description of what the sync is doing right now.
- type: 'null'
+ description: Human-readable description of what the sync is doing right now.
percentComplete:
description: >-
Rough completion percentage derived from the stage. Progress reporting only — do not
@@ -6754,50 +6928,50 @@ components:
completedAt:
oneOf:
- type: string
- description: >-
- When the sync finished, as an ISO 8601 timestamp, or null while it is still running.
- This is stamped by the process that ran the sync whereas startedAt is stamped by the
- process that launched it — two different clocks — so for a run that failed moments
- after starting, completedAt can even precede startedAt. Use durationMs to show how
- long a run took rather than subtracting these.
- type: 'null'
+ description: >-
+ When the sync finished, as an ISO 8601 timestamp, or null while it is still running.
+ This is stamped by the process that ran the sync whereas startedAt is stamped by the
+ process that launched it — two different clocks — so for a run that failed moments after
+ starting, completedAt can even precede startedAt. Use durationMs to show how long a run
+ took rather than subtracting these.
deploymentId:
type: integer
durationMs:
oneOf:
- type: integer
- description: >-
- How long the sync took, in milliseconds, and the value to use when showing a
- duration. Measured from a single clock, so it is never negative — prefer it over
- subtracting startedAt from completedAt, which can disagree with it by a small skew.
- type: 'null'
+ description: >-
+ How long the sync took, in milliseconds, and the value to use when showing a duration.
+ Measured from a single clock, so it is never negative — prefer it over subtracting
+ startedAt from completedAt, which can disagree with it by a small skew.
errorMessage:
oneOf:
- type: string
- description: Why the sync stopped. Present only for a failed run.
- type: 'null'
+ description: Why the sync stopped. Present only for a failed run.
failedPhase:
oneOf:
- type: string
- description: The phase that failed, e.g. dbt-compile.
- type: 'null'
+ description: The phase that failed, e.g. dbt-compile.
gitRef:
oneOf:
- type: string
- description: The dbt-repository ref this sync was run against, when pinned.
- type: 'null'
+ description: The dbt-repository ref this sync was run against, when pinned.
lastStage:
oneOf:
- type: string
- description: The pipeline stage the run reached, e.g. COMPILING_DBT.
- type: 'null'
+ description: The pipeline stage the run reached, e.g. COMPILING_DBT.
phases:
oneOf:
- items:
$ref: '#/components/schemas/DbtSyncRunPhase'
type: array
- description: Per-phase timings, in the order they ran. Absent while the sync is running.
- type: 'null'
+ description: Per-phase timings, in the order they ran. Absent while the sync is running.
startedAt:
description: >-
When the sync started, as an ISO 8601 timestamp. See completedAt before comparing the
@@ -6828,8 +7002,8 @@ components:
userId:
oneOf:
- type: integer
- description: The Cube user who started the sync, when a user started it.
- type: 'null'
+ description: The Cube user who started the sync, when a user started it.
required:
- syncJobId
- deploymentId
@@ -6875,28 +7049,28 @@ components:
cubeCount:
oneOf:
- type: integer
- description: How many cubes the sync generated.
- type: 'null'
+ description: How many cubes the sync generated.
generatedFileCount:
oneOf:
- type: integer
- description: How many files the sync wrote to the branch it created.
- type: 'null'
+ description: How many files the sync wrote to the branch it created.
macros:
oneOf:
- type: integer
- description: dbt macros found in the manifest.
- type: 'null'
+ description: dbt macros found in the manifest.
models:
oneOf:
- type: integer
- description: dbt models found in the manifest.
- type: 'null'
+ description: dbt models found in the manifest.
sources:
oneOf:
- type: integer
- description: dbt sources found in the manifest.
- type: 'null'
+ description: dbt sources found in the manifest.
type: object
DbtSyncRunTriggerContext:
properties:
@@ -6934,8 +7108,8 @@ components:
error:
oneOf:
- type: string
- description: Why the sync stopped. Present only when the status is FAILED.
- type: 'null'
+ description: Why the sync stopped. Present only when the status is FAILED.
progress:
$ref: '#/components/schemas/DbtSyncProgress'
status:
@@ -7078,9 +7252,9 @@ components:
pagination:
oneOf:
- $ref: '#/components/schemas/DeploymentsPagination'
- deprecated: true
- description: 'Deprecated: use `pageInfo` instead. Kept for backward compatibility.'
- type: 'null'
+ description: 'Deprecated: use `pageInfo` instead. Kept for backward compatibility.'
+ deprecated: true
required:
- items
- data
@@ -7110,9 +7284,9 @@ components:
pagination:
oneOf:
- $ref: '#/components/schemas/DeploymentsPagination'
- deprecated: true
- description: 'Deprecated: use `pageInfo` instead. Kept for backward compatibility.'
- type: 'null'
+ description: 'Deprecated: use `pageInfo` instead. Kept for backward compatibility.'
+ deprecated: true
required:
- items
- data
@@ -7396,11 +7570,11 @@ components:
oneOf:
- minimum: 0
type: integer
- deprecated: true
- description: >-
- Deprecated: total number of accessible deployments, ignoring pagination. Kept for
- backward compatibility.
- type: 'null'
+ description: >-
+ Deprecated: total number of accessible deployments, ignoring pagination. Kept for
+ backward compatibility.
+ deprecated: true
data:
deprecated: true
description: 'Deprecated: use `items` instead. Kept for backward compatibility.'
@@ -7423,9 +7597,9 @@ components:
oneOf:
- minimum: 0
type: integer
- deprecated: true
- description: 'Deprecated: use `count`. Kept for backward compatibility.'
- type: 'null'
+ description: 'Deprecated: use `count`. Kept for backward compatibility.'
+ deprecated: true
required:
- items
- data
@@ -7554,16 +7728,16 @@ components:
oneOf:
- minimum: 1
type: integer
- description: Numeric id of an existing embed user. Provide this OR `externalId`.
- type: 'null'
+ description: Numeric id of an existing embed user. Provide this OR `externalId`.
externalId:
oneOf:
- minLength: 1
type: string
- description: >-
- External id of the embed user (the `externalId` passed to `generate-session`).
- Provide this OR `embedUserId`.
- type: 'null'
+ description: >-
+ External id of the embed user (the `externalId` passed to `generate-session`). Provide
+ this OR `embedUserId`.
type: object
EmbedGroupMembersInput:
properties:
@@ -7609,27 +7783,25 @@ components:
allowChatWorkspaceAuthoring:
oneOf:
- type: boolean
- description: >-
- Whether AI Chat authenticated with this session may create or modify persistent Cube
- Workspace content. Set to `false` for headless chat integrations that render answers
- in their own UI: ad-hoc data analysis and inline tables/charts remain available, but
- the agent cannot save or update standalone explorations/reports, create or modify
- workbooks, create or publish dashboards, or direct users to those Cube UI surfaces.
- Omit or set to `true` to preserve the session user's role-derived authoring
- capabilities; `true` never grants access the user does not already have. This
- setting changes only AI Chat tools and instructions. It does not change user roles
- or permissions for direct API calls.
- type: 'null'
+ description: >-
+ Whether AI Chat authenticated with this session may create or modify persistent Cube
+ Workspace content. Set to `false` for headless chat integrations that render answers in
+ their own UI: ad-hoc data analysis and inline tables/charts remain available, but the
+ agent cannot save or update standalone explorations/reports, create or modify workbooks,
+ create or publish dashboards, or direct users to those Cube UI surfaces. Omit or set to
+ `true` to preserve the session user's role-derived authoring capabilities; `true` never
+ grants access the user does not already have. This setting changes only AI Chat tools
+ and instructions. It does not change user roles or permissions for direct API calls.
showDashboardChat:
oneOf:
- type: boolean
- description: >-
- Whether embedded published dashboards viewed with this session show the AI chat
- (agent panel and launcher bubble). Omit to inherit the account-wide embed setting
- (shown by default); `false` hides the chat even if it is enabled account-wide,
- `true` shows it even if it is disabled account-wide. Only affects the dashboard
- surface.
- type: 'null'
+ description: >-
+ Whether embedded published dashboards viewed with this session show the AI chat (agent
+ panel and launcher bubble). Omit to inherit the account-wide embed setting (shown by
+ default); `false` hides the chat even if it is enabled account-wide, `true` shows it
+ even if it is disabled account-wide. Only affects the dashboard surface.
type: object
EmbedSettings:
properties:
@@ -7947,11 +8119,11 @@ components:
oneOf:
- minimum: 0
type: integer
- deprecated: true
- description: >-
- Deprecated: total number of accessible folders, ignoring pagination. Kept for
- backward compatibility.
- type: 'null'
+ description: >-
+ Deprecated: total number of accessible folders, ignoring pagination. Kept for backward
+ compatibility.
+ deprecated: true
data:
deprecated: true
description: 'Deprecated: use `items` instead. Kept for backward compatibility.'
@@ -8013,30 +8185,29 @@ components:
- items:
$ref: '#/components/schemas/GroupDefinition'
type: array
- deprecated: true
- description: >-
- Deprecated and ignored. Global groups can no longer be created through this endpoint
- — define them beforehand via the Cube UI or admin API. Still accepted for backward
- compatibility (no error), but it has no effect. To create per-embed-tenant groups,
- use `tenantGroupDefinitions`.
- type: 'null'
+ description: >-
+ Deprecated and ignored. Global groups can no longer be created through this endpoint —
+ define them beforehand via the Cube UI or admin API. Still accepted for backward
+ compatibility (no error), but it has no effect. To create per-embed-tenant groups, use
+ `tenantGroupDefinitions`.
+ deprecated: true
groups:
oneOf:
- items:
type: string
type: array
- description: >-
- Global user groups — defined once at the tenant level and shared across every embed
- tenant — to assign this embed user to. Use `groups` for **data-model access
- control**: each name is placed verbatim into the Cube security context as
- `cubeCloud.groups`, where your data model's `access_policy` rules reference it to
- gate cubes, views, members, and row-/column-level filters. The groups must already
- exist in the tenant (create them via the Cube UI or admin API beforehand) — this
- endpoint never creates global groups, and names that do not resolve to an existing
- group are rejected. Global groups are NOT shown in an embed tenant’s Creator Mode
- UI. To share or organize content inside a single embed tenant, use `tenantGroups`
- instead.
- type: 'null'
+ description: >-
+ Global user groups — defined once at the tenant level and shared across every embed
+ tenant — to assign this embed user to. Use `groups` for **data-model access control**:
+ each name is placed verbatim into the Cube security context as `cubeCloud.groups`, where
+ your data model's `access_policy` rules reference it to gate cubes, views, members, and
+ row-/column-level filters. The groups must already exist in the tenant (create them via
+ the Cube UI or admin API beforehand) — this endpoint never creates global groups, and
+ names that do not resolve to an existing group are rejected. Global groups are NOT shown
+ in an embed tenant’s Creator Mode UI. To share or organize content inside a single embed
+ tenant, use `tenantGroups` instead.
internalId:
oneOf:
- type: string
@@ -8064,40 +8235,40 @@ components:
oneOf:
- $ref: '#/components/schemas/EmbedSessionSettings'
type: object
- description: >-
- Per-session overrides for embed behavior, stored in the signed embed token. Each
- documented key applies only to this session; omitted keys preserve their existing
- default behavior.
- type: 'null'
+ description: >-
+ Per-session overrides for embed behavior, stored in the signed embed token. Each
+ documented key applies only to this session; omitted keys preserve their existing
+ default behavior.
tenantGroupDefinitions:
oneOf:
- items:
$ref: '#/components/schemas/GroupDefinition'
type: array
- description: >-
- Idempotently create or update the per-embed-tenant groups referenced by
- `tenantGroups`, before they are assigned. Requires `creatorMode: true` and
- `embedTenantName`. Use this to declare a tenant’s groups in the same call that
- assigns them, so you do not need a separate admin request. Applies only to
- per-embed-tenant groups; global groups must be defined beforehand.
- type: 'null'
+ description: >-
+ Idempotently create or update the per-embed-tenant groups referenced by `tenantGroups`,
+ before they are assigned. Requires `creatorMode: true` and `embedTenantName`. Use this
+ to declare a tenant’s groups in the same call that assigns them, so you do not need a
+ separate admin request. Applies only to per-embed-tenant groups; global groups must be
+ defined beforehand.
tenantGroups:
oneOf:
- items:
type: string
type: array
- description: >-
- Per-embed-tenant user groups — scoped to the single embed tenant named by
- `embedTenantName` — to assign this embed user to. Use `tenantGroups` for **content
- sharing and organization within one embed tenant**: for example, so a creator can
- share a workbook, dashboard, or folder with a group of that tenant’s users. These
- are the only groups shown in the embed tenant’s Creator Mode UI. Requires
- `creatorMode: true` and `embedTenantName`. Define the groups beforehand — or in the
- same request — via `tenantGroupDefinitions`. In the Cube security context they
- appear namespaced as `system:tenant:{embedTenantName}:group:{groupName}`, so a
- tenant group can never collide with — or be mistaken for — a global `groups` entry
- of the same name. For organization-wide data-model access policies, use `groups`.
- type: 'null'
+ description: >-
+ Per-embed-tenant user groups — scoped to the single embed tenant named by
+ `embedTenantName` — to assign this embed user to. Use `tenantGroups` for **content
+ sharing and organization within one embed tenant**: for example, so a creator can share
+ a workbook, dashboard, or folder with a group of that tenant’s users. These are the only
+ groups shown in the embed tenant’s Creator Mode UI. Requires `creatorMode: true` and
+ `embedTenantName`. Define the groups beforehand — or in the same request — via
+ `tenantGroupDefinitions`. In the Cube security context they appear namespaced as
+ `system:tenant:{embedTenantName}:group:{groupName}`, so a tenant group can never collide
+ with — or be mistaken for — a global `groups` entry of the same name. For
+ organization-wide data-model access policies, use `groups`.
userAttributeDefinitions:
oneOf:
- items:
@@ -8379,8 +8550,8 @@ components:
- items:
$ref: '#/components/schemas/DashboardFilter'
type: array
- description: Dimension filters applied when the notification is rendered.
- type: 'null'
+ description: Dimension filters applied when the notification is rendered.
humanReadableSchedule:
description: Human-readable description of the cron schedule
type: string
@@ -8400,8 +8571,8 @@ components:
- items:
$ref: '#/components/schemas/DashboardTimeGrain'
type: array
- description: Time-grain overrides applied when the notification is rendered.
- type: 'null'
+ description: Time-grain overrides applied when the notification is rendered.
timezone:
type: string
required:
@@ -8460,45 +8631,45 @@ components:
channelId:
oneOf:
- type: string
- description: Slack channel id (for type=SLACK)
- type: 'null'
+ description: Slack channel id (for type=SLACK)
channelName:
oneOf:
- type: string
- description: Slack channel display name (optional, for type=SLACK)
- type: 'null'
+ description: Slack channel display name (optional, for type=SLACK)
email:
oneOf:
- type: string
- description: Main user email (for type=USER; provide this OR userId)
- type: 'null'
+ description: Main user email (for type=USER; provide this OR userId)
embedTenantName:
oneOf:
- type: string
- description: Embed tenant name (for type=EMBED_USER)
- type: 'null'
+ description: Embed tenant name (for type=EMBED_USER)
externalId:
oneOf:
- type: string
- description: Embed user external id (for type=EMBED_USER)
- type: 'null'
+ description: Embed user external id (for type=EMBED_USER)
groups:
oneOf:
- items:
type: string
type: array
- description: >-
- Embed user groups (type=EMBED_USER). Must reference groups that already exist;
- drives per-recipient access when the report is rendered.
- type: 'null'
+ description: >-
+ Embed user groups (type=EMBED_USER). Must reference groups that already exist; drives
+ per-recipient access when the report is rendered.
securityContext:
oneOf:
- type: object
additionalProperties: true
- description: >-
- Embed user security context (type=EMBED_USER). Applied for per-recipient row-level
- security when the report is rendered.
- type: 'null'
+ description: >-
+ Embed user security context (type=EMBED_USER). Applied for per-recipient row-level
+ security when the report is rendered.
type:
$ref: '#/components/schemas/NotificationRecipientInputType'
userAttributes:
@@ -8506,15 +8677,15 @@ components:
- items:
$ref: '#/components/schemas/UserAttributeInput'
type: array
- description: >-
- Embed user attribute values (type=EMBED_USER). Names must reference attribute
- definitions that already exist.
- type: 'null'
+ description: >-
+ Embed user attribute values (type=EMBED_USER). Names must reference attribute
+ definitions that already exist.
userId:
oneOf:
- type: integer
- description: Main user id (for type=USER; provide this OR email)
- type: 'null'
+ description: Main user id (for type=USER; provide this OR email)
required:
- type
type: object
@@ -8808,6 +8979,89 @@ components:
- before
- after
type: string
+ Policy:
+ properties:
+ actions:
+ items:
+ enum:
+ - All
+ - DeploymentsManage
+ - DeploymentCreate
+ - DeploymentRead
+ - DeploymentUpdate
+ - DeploymentDelete
+ - SecretsManage
+ - DownloadData
+ - PlaygroundRead
+ - SchemaRead
+ - SchemaUpdate
+ - SchemaUpdateDevBranches
+ - APMRead
+ - PreAggregationBuild
+ - AlertsCreate
+ - AlertsRead
+ - AlertsUpdate
+ - AlertsDelete
+ - AuditLogManage
+ - BillingRead
+ - SqlRunnerRead
+ - DataAssetsRead
+ - DataAssetsManage
+ - CubeNetworkConnect
+ - ReportRead
+ - ReportEdit
+ - ReportManage
+ - WorkbookManage
+ - WorkbookRead
+ - WorkbookEdit
+ - ChatThreadRead
+ - AgentManage
+ - AgentRead
+ - AgentSpaceManage
+ - AgentAdmin
+ - DeploymentAgentRead
+ - OAuthIntegrationsManage
+ - OAuthIntegrationsIssueTokens
+ - McpToolsManage
+ - AIBIDevelop
+ - AIBIExplore
+ - AIBIView
+ - ChartPalettesManage
+ - DashboardThemesManage
+ - AIBIDeveloper
+ - AIBIUser
+ - AIBIViewer
+ - EmbedDeploymentRead
+ - EmbedDashboardRead
+ - FolderRead
+ - FolderEdit
+ - FolderManage
+ type: string
+ type: array
+ resourceType:
+ $ref: '#/components/schemas/PolicyResourceType'
+ resources:
+ items:
+ type: string
+ type: array
+ required:
+ - resourceType
+ - actions
+ - resources
+ type: object
+ PolicyResourceType:
+ enum:
+ - Global
+ - Deployment
+ - Report
+ - ReportFolder
+ - Agent
+ - AgentSpace
+ - Workbook
+ - Dashboard
+ - Folder
+ - ChatThread
+ type: string
PostTokenBySessionIdInput:
properties:
sessionId:
@@ -8832,13 +9086,13 @@ components:
oneOf:
- format: email
type: string
- description: >-
- Email address, shown wherever the user is listed and searchable through `GET
- /embed-tenants/{embedTenantName}/users`. Must be a valid address, and is stored
- lowercased. Omit it and Cube derives a synthetic `{externalId}@cubecloud.dev`
- placeholder instead, which is what makes a user hard to recognise in a list.
- Supplying it again later updates the stored address.
- type: 'null'
+ description: >-
+ Email address, shown wherever the user is listed and searchable through `GET
+ /embed-tenants/{embedTenantName}/users`. Must be a valid address, and is stored
+ lowercased. Omit it and Cube derives a synthetic `{externalId}@cubecloud.dev`
+ placeholder instead, which is what makes a user hard to recognise in a list. Supplying
+ it again later updates the stored address.
externalId:
description: >-
The id your own system knows this user by — the same `externalId` you will pass to
@@ -8851,31 +9105,30 @@ components:
- items:
type: string
type: array
- description: >-
- Global, account-wide groups (the `groups` field of `generate-session`) that gate
- data-model access. They must already exist. Supplying the field REPLACES the user’s
- global groups; omit it to leave them untouched, pass `[]` to clear them.
- type: 'null'
+ description: >-
+ Global, account-wide groups (the `groups` field of `generate-session`) that gate
+ data-model access. They must already exist. Supplying the field REPLACES the user’s
+ global groups; omit it to leave them untouched, pass `[]` to clear them.
tenantGroups:
oneOf:
- items:
type: string
type: array
- description: >-
- Groups belonging to this embed tenant (the `tenantGroups` field of
- `generate-session`), which scope content sharing and organization within the tenant.
- Create them first via `POST /embed-tenants/{embedTenantName}/groups`. Supplying the
- field REPLACES the user’s tenant groups; omit it to leave them untouched, pass `[]`
- to clear them.
- type: 'null'
+ description: >-
+ Groups belonging to this embed tenant (the `tenantGroups` field of `generate-session`),
+ which scope content sharing and organization within the tenant. Create them first via
+ `POST /embed-tenants/{embedTenantName}/groups`. Supplying the field REPLACES the user’s
+ tenant groups; omit it to leave them untouched, pass `[]` to clear them.
userProfile:
oneOf:
- $ref: '#/components/schemas/EmbedUserProfile'
type: object
- description: >-
- Display name and avatar. `displayName` is the name shown wherever the user appears,
- including on content they author. Omitted fields keep their current value.
- type: 'null'
+ description: >-
+ Display name and avatar. `displayName` is the name shown wherever the user appears,
+ including on content they author. Omitted fields keep their current value.
required:
- externalId
type: object
@@ -9071,8 +9324,8 @@ components:
embedTenantName:
oneOf:
- type: string
- description: Embed tenant name (required for type=EMBED_USER; resolves the storage partition)
- type: 'null'
+ description: Embed tenant name (required for type=EMBED_USER; resolves the storage partition)
id:
description: >-
Recipient id: userId (type=USER), embedUserId (type=EMBED_USER), or channelId
@@ -9137,6 +9390,10 @@ components:
type: string
createdBy:
type: integer
+ currentQueryChecksum:
+ oneOf:
+ - type: string
+ - type: 'null'
deploymentId:
type: integer
description:
@@ -9286,6 +9543,10 @@ components:
oneOf:
- type: integer
- type: 'null'
+ queryChecksum:
+ oneOf:
+ - type: string
+ - type: 'null'
refreshedAt:
oneOf:
- type: string
@@ -9304,6 +9565,10 @@ components:
oneOf:
- type: string
- type: 'null'
+ syncStatus:
+ oneOf:
+ - $ref: '#/components/schemas/ReportPlacementSyncStatus'
+ - type: 'null'
workbookId:
type: string
workbookName:
@@ -9319,6 +9584,12 @@ components:
- GOOGLE_SHEETS
- EXCEL
type: string
+ ReportPlacementSyncStatus:
+ enum:
+ - UP_TO_DATE
+ - CHANGED
+ - UNKNOWN
+ type: string
ReportSnapshot:
properties:
description:
@@ -9409,11 +9680,11 @@ components:
oneOf:
- minimum: 0
type: integer
- deprecated: true
- description: >-
- Deprecated: total number of accessible reports, ignoring pagination. Kept for
- backward compatibility.
- type: 'null'
+ description: >-
+ Deprecated: total number of accessible reports, ignoring pagination. Kept for backward
+ compatibility.
+ deprecated: true
data:
deprecated: true
description: 'Deprecated: use `items` instead. Kept for backward compatibility.'
@@ -9556,6 +9827,29 @@ components:
- type: boolean
- type: 'null'
type: object
+ SheetsUserSettingsInput:
+ properties:
+ autoRunQueryOnChange:
+ oneOf:
+ - type: boolean
+ - type: 'null'
+ openExplorationFromSheet:
+ oneOf:
+ - type: boolean
+ - type: 'null'
+ revealSheetOnOpen:
+ oneOf:
+ - type: boolean
+ - type: 'null'
+ showAppliedFilters:
+ oneOf:
+ - type: boolean
+ - type: 'null'
+ suppressDuplicateValues:
+ oneOf:
+ - type: boolean
+ - type: 'null'
+ type: object
SourceTreeResponse:
properties:
data:
@@ -9587,12 +9881,12 @@ components:
ref:
oneOf:
- type: string
- description: >-
- Git ref in the dbt repository to sync from — a branch or a tag, NOT a commit SHA.
- Overrides the branch saved on the deployment’s dbt git integration for this sync
- only, so CI can sync the ref under review (e.g. a pull request’s head branch)
- instead of the tracked branch. The generated Cube branch is named after it.
- type: 'null'
+ description: >-
+ Git ref in the dbt repository to sync from — a branch or a tag, NOT a commit SHA.
+ Overrides the branch saved on the deployment’s dbt git integration for this sync only,
+ so CI can sync the ref under review (e.g. a pull request’s head branch) instead of the
+ tracked branch. The generated Cube branch is named after it.
type: object
StartDevModeRequest:
properties:
@@ -9871,8 +10165,8 @@ components:
oneOf:
- maxLength: 500
type: string
- description: New description. Pass an empty string to clear it. Omit to leave it unchanged.
- type: 'null'
+ description: New description. Pass an empty string to clear it. Omit to leave it unchanged.
type: object
UpdateFolderInput:
properties:
@@ -9905,10 +10199,10 @@ components:
- items:
$ref: '#/components/schemas/DashboardFilterInput'
type: array
- description: >-
- Dimension filters applied to the dashboard when the notification is rendered.
- Replaces the existing set when provided.
- type: 'null'
+ description: >-
+ Dimension filters applied to the dashboard when the notification is rendered. Replaces
+ the existing set when provided.
hour:
oneOf:
- type: integer
@@ -9916,8 +10210,8 @@ components:
isEnabled:
oneOf:
- type: boolean
- description: Enable or disable the schedule
- type: 'null'
+ description: Enable or disable the schedule
minute:
oneOf:
- type: integer
@@ -9925,10 +10219,8 @@ components:
notificationAiSummary:
oneOf:
- type: boolean
- description: >-
- Include an AI-generated "what changed" summary in the notification body. Off by
- default.
- type: 'null'
+ description: Include an AI-generated "what changed" summary in the notification body. Off by default.
notificationEnabled:
oneOf:
- type: boolean
@@ -9946,10 +10238,10 @@ components:
- items:
$ref: '#/components/schemas/DashboardTimeGrainInput'
type: array
- description: >-
- Time-grain overrides applied to the dashboard when the notification is rendered.
- Replaces the existing set when provided.
- type: 'null'
+ description: >-
+ Time-grain overrides applied to the dashboard when the notification is rendered.
+ Replaces the existing set when provided.
timezone:
oneOf:
- type: string
@@ -10036,6 +10328,20 @@ components:
maxLength: 128
- type: 'null'
type: object
+ UpdatePublishedAiWidgetThreadInput:
+ properties:
+ checksum:
+ oneOf:
+ - type: string
+ - type: 'null'
+ threadId:
+ type: string
+ widgetId:
+ type: string
+ required:
+ - widgetId
+ - threadId
+ type: object
UpdateReportInput:
properties:
endResultCell:
@@ -10166,10 +10472,10 @@ components:
expiresAt:
oneOf:
- type: string
- description: >-
- When the token expires, as an ISO-8601 timestamp. Request a new token before this
- moment — issuing one is idempotent and cheap.
- type: 'null'
+ description: >-
+ When the token expires, as an ISO-8601 timestamp. Request a new token before this moment
+ — issuing one is idempotent and cheap.
token:
description: >-
A short-lived JWT for the Usage Analytics deployment, carrying this tenant’s security
@@ -10271,6 +10577,15 @@ components:
- format: date-time
type: string
- type: 'null'
+ userPolicies:
+ oneOf:
+ - items:
+ $ref: '#/components/schemas/Policy'
+ type: array
+ - type: 'null'
+ description: >-
+ The caller's effective resource policies: direct grants, organization-wide grants, and
+ grants inherited from their groups. Only populated by `GET /api/v1/users/me`.
username:
type: string
required:
@@ -10686,10 +11001,19 @@ components:
oneOf:
- type: integer
- type: 'null'
+ description: >-
+ Deprecated and ignored. The changelog now arrives in the notification inbox, which
+ carries its own read state. Accepted for backward compatibility but never read or
+ written.
+ deprecated: true
locale:
oneOf:
- type: string
- type: 'null'
+ sheets:
+ oneOf:
+ - $ref: '#/components/schemas/SheetsUserSettingsInput'
+ - type: 'null'
theme:
oneOf:
- type: string
@@ -10943,11 +11267,11 @@ components:
oneOf:
- minimum: 0
type: integer
- deprecated: true
- description: >-
- Deprecated: total number of accessible workbooks, ignoring pagination. Kept for
- backward compatibility.
- type: 'null'
+ description: >-
+ Deprecated: total number of accessible workbooks, ignoring pagination. Kept for backward
+ compatibility.
+ deprecated: true
data:
deprecated: true
description: 'Deprecated: use `items` instead. Kept for backward compatibility.'
diff --git a/docs-mintlify/api-reference/changelog.mdx b/docs-mintlify/api-reference/changelog.mdx
index b7a77669bbf9b..dde2b759b6d3e 100644
--- a/docs-mintlify/api-reference/changelog.mdx
+++ b/docs-mintlify/api-reference/changelog.mdx
@@ -7,6 +7,27 @@ rss: true
{/* GENERATED FILE — do not edit by hand. */}
{/* Run scripts/extract-changelog.js against the platform client CHANGELOG.md. */}
+
+ ### Added
+
+ - `User` (`GET /api/v1/users/me`, `UsersPublicController.getMe`) gained `userPolicies` — the caller's effective resource policies: direct grants, organization-wide grants and grants inherited from their groups. This is the set the console has always read over GraphQL; on REST it lets an embedded Creator Mode session resolve a workbook another embed user shared with it, which previously granted nothing beyond viewing. New schemas: `Policy`, `PolicyResourceType`.
+ - Re-added `POST /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/dashboard/ai-widget-thread` (`WorkbooksPublicController.updatePublishedDashboardAiWidgetThread`) and its `UpdatePublishedAiWidgetThreadInput` schema (`widgetId`, `threadId`, optional `checksum`) — reverting the `0.4.0` removal. Regenerating an AI-analysis widget on a **published** dashboard persists the new thread id (+ checksum) back to the published config so an immediate reload shows the fresh result instead of the stale one. It stores **only** the thread id and checksum — never summary text (CUB-4031).
+ - `PATCH /api/v1/users/me/settings` (`UsersPublicController.updateMySettings`) — merge a partial patch into your own settings; omitted fields are left as they are, and `sheets` merges field-by-field (an explicit `null` clears it). `UserSettingsInput` gained `sheets`. New schema: `SheetsUserSettingsInput` (`autoRunQueryOnChange`, `openExplorationFromSheet`, `revealSheetOnOpen`, `showAppliedFilters`, `suppressDuplicateValues`).
+ - `POST /api/v1/deployments/{id}/token` (`DeploymentsPublicController.deploymentToken`) now accepts an optional body to request a shorter token lifetime than the 24-hour default. New schema: `CreateDeploymentTokenInput` (`expiresIn`, 60–86400 seconds) — recommended for tokens held outside a browser session (scripts, BI tools, scheduled jobs).
+ - `AppTheme` / `AppThemeResponse` (`GET /api/v1/app-theme`, `GET /api/v1/app-config`) gained `palette` and `logoUrl` — the new theme model: base/accent color seeds, contrast level, a pastel toggle, surface mode, and per-semantic color overrides (danger/warning/success/note/code). New schemas: `AppThemePalette`, `AppThemePaletteSeed`, `AppThemePaletteThemes`, `AppThemeCodePalette`, `AppThemeSurfaceMode`.
+ - `DashboardWidgetDtoType` / `DashboardWidgetInputType` gained a new `"FIELD"` enum value, for the Field switcher dashboard control.
+ - `ReportPlacement` gained `queryChecksum` and `syncStatus` (new schema `ReportPlacementSyncStatus`: `"UP_TO_DATE"` | `"CHANGED"` | `"UNKNOWN"`), and `Report` gained `currentQueryChecksum` — together they let a client tell whether the cells at a placement still reflect the exploration as it stands now.
+
+ ### Changed
+
+ - `POST /api/v1/usage-analytics/token` — corrected docs: tokens expire after 1 hour, not ~24 hours as previously stated. `expiresAt` on the response has always reflected the real value.
+
+ ### Deprecated
+
+ - `AppTheme.light` / `AppTheme.dark` — use `palette` and `logoUrl` instead.
+ - `UserSettingsInput.lastSeenChangelogId` — ignored; the changelog now arrives in the notification inbox, which carries its own read state. Accepted for backward compatibility but never read or written.
+
+
### Added
@@ -72,7 +93,7 @@ rss: true
### Removed
- - **BREAKING:** `POST /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/dashboard/ai-widget-thread` (`WorkbooksPublicController.updatePublishedDashboardAiWidgetThread`) and its `UpdatePublishedAiWidgetThreadInput` schema (added in `0.2.0`). It persisted an AI-analysis thread id + checksum into the published dashboard config — the mechanism that caused read-only/anonymous viewers to hit `WorkbookEdit`/403s and let one viewer's filter state clobber the shared config (CUB-3898). AI-analysis results are now cached server-side keyed by dashboard state, so no client-facing endpoint replaces it. This is a deliberate breaking removal; SDK consumers referencing the operation or schema should drop those references.
+ - **BREAKING:** `POST /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/dashboard/ai-widget-thread` (`WorkbooksPublicController.updatePublishedDashboardAiWidgetThread`) and its `UpdatePublishedAiWidgetThreadInput` schema (added in `0.2.0`). It persisted an AI-analysis thread id + checksum into the published dashboard config — the mechanism that caused read-only/anonymous viewers to hit `WorkbookEdit`/403s and let one viewer's filter state clobber the shared config (CUB-3898). AI-analysis results are recovered by replaying the chat thread whose id the dashboard config stores, so no client-facing endpoint replaces it (CUB-4031). This is a deliberate breaking removal; SDK consumers referencing the operation or schema should drop those references.
diff --git a/docs-mintlify/api-reference/introduction.mdx b/docs-mintlify/api-reference/introduction.mdx
index ac5db19a18b47..22dd526ff875e 100644
--- a/docs-mintlify/api-reference/introduction.mdx
+++ b/docs-mintlify/api-reference/introduction.mdx
@@ -85,6 +85,7 @@ Resources by entity:
| [Workbooks](/api-reference/workbooks/get-workbooks) | `/api/v1/deployments/{deploymentId}/workbooks` | v1 |
| [Notifications](/api-reference/notifications/list-scheduled-notifications) | `/api/v1/deployments/{deploymentId}/notifications` | v1 |
| [Workspace](/api-reference/workspace/list-shared-workspace-items) | `/api/v1/deployments/{deploymentId}` | v1 |
+| [Users](/api-reference/users/update-my-settings) | `/api/v1/users/me/settings` | v1 |
| [Users Admin](/api-reference/users-admin/create-user) | `/api/v1/users` | v1 |
| [User Attributes](/api-reference/user-attributes/get-user-attributes) | `/api/v1/user-attributes` | v1 |
| [User Attribute Values](/api-reference/user-attribute-values/upsert-user-attribute-value) | `/api/v1/user-attribute-values` | v1 |
diff --git a/docs-mintlify/docs.json b/docs-mintlify/docs.json
index 30181fd50ee9f..12a948fbfabd9 100644
--- a/docs-mintlify/docs.json
+++ b/docs-mintlify/docs.json
@@ -901,6 +901,7 @@
"PUT /api/v1/deployments/{deploymentId}/workbooks/{workbookId}",
"DELETE /api/v1/deployments/{deploymentId}/workbooks/{workbookId}",
"PUT /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/dashboard",
+ "POST /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/dashboard/ai-widget-thread",
"POST /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/duplicate",
"POST /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/publish"
]
@@ -930,6 +931,13 @@
"POST /api/v1/deployments/{deploymentId}/workspace/move"
]
},
+ {
+ "group": "Users",
+ "openapi": "/api-reference/api.yaml",
+ "pages": [
+ "PATCH /api/v1/users/me/settings"
+ ]
+ },
{
"group": "Users Admin",
"openapi": "/api-reference/api.yaml",
@@ -1057,7 +1065,7 @@
]
},
{
- "group": "Users",
+ "group": "Users (SCIM)",
"openapi": "/api-reference/scim.yaml",
"pages": [
"GET /scim/v2/Users",
@@ -1069,7 +1077,7 @@
]
},
{
- "group": "Groups",
+ "group": "Groups (SCIM)",
"openapi": "/api-reference/scim.yaml",
"pages": [
"GET /scim/v2/Groups",
diff --git a/docs-mintlify/docs/explore-analyze/dashboards/index.mdx b/docs-mintlify/docs/explore-analyze/dashboards/index.mdx
index 2a3cbdec0b95e..704731677fdbf 100644
--- a/docs-mintlify/docs/explore-analyze/dashboards/index.mdx
+++ b/docs-mintlify/docs/explore-analyze/dashboards/index.mdx
@@ -81,9 +81,12 @@ title.
PNG and PDF downloads are server-rendered snapshots — Cube re-opens the
dashboard (or, for a single chart, that chart on its own), waits for rendering
to finish, and captures the result. This can take up to a couple of minutes
-for large dashboards. The filter and time-grain selections you currently have
-applied in your browser are carried into the export. (CSV is generated from the
-data already loaded in the chart and downloads immediately.)
+for large dashboards. The control selections you currently have applied in your
+browser are carried into a download you start yourself — filters, time
+granularity switchers and [field switchers][ref-controls] alike. A scheduled
+notification has no browser session, so its attachment renders the dashboard's
+own defaults, unless that notification carries selections of its own. (CSV is
+generated from the data already loaded in the chart and downloads immediately.)
Downloading the whole dashboard requires **Manage** permission on the workbook
that owns it; exporting a single chart requires the **Download data**
@@ -94,6 +97,7 @@ after a [scheduled refresh][ref-scheduled-refreshes].
[ref-workbooks]: /docs/explore-analyze/workbooks
[ref-widgets]: /docs/explore-analyze/dashboards/widgets
+[ref-controls]: /docs/explore-analyze/dashboards/widgets/controls
[ref-dimension-links]: /docs/data-modeling/dimensions#links
[ref-notifications]: /docs/explore-analyze/notifications
[ref-scheduled-refreshes]: /docs/explore-analyze/scheduled-refreshes
diff --git a/docs-mintlify/docs/explore-analyze/dashboards/widgets/charts.mdx b/docs-mintlify/docs/explore-analyze/dashboards/widgets/charts.mdx
index 67a86aa6f28fe..e20cd823b9a09 100644
--- a/docs-mintlify/docs/explore-analyze/dashboards/widgets/charts.mdx
+++ b/docs-mintlify/docs/explore-analyze/dashboards/widgets/charts.mdx
@@ -13,7 +13,7 @@ If the picker is empty, create the report first in a workbook tab — only publi
## Interaction with controls
-Charts respect the [controls][ref-controls] placed on the same dashboard — filters and time granularity switchers. A single control can drive multiple charts at once: its value is applied to every chart whose query references the targeted dimension.
+Charts respect the [controls][ref-controls] placed on the same dashboard — filters, time granularity switchers and field switchers. A single control can drive multiple charts at once: its value is applied to every chart whose query references the targeted dimension.
If some controls are incompatible with a chart's query, the chart skips them and shows a warning icon. See [Incompatible controls][ref-incompatible-controls] for details.
@@ -21,6 +21,24 @@ If some controls are incompatible with a chart's query, the chart skips them and
Charts on a published dashboard reflect the most recent published version of the workbook. To change the query, switch the chart type, or restyle the visualization, edit the underlying report in the workbook and publish a new version of the dashboard.
+**Edit in Workbook** in the chart's settings menu opens that report directly, on its own workbook tab.
+
+### Filters carried into the workbook
+
+The dashboard's [filters][ref-controls] come along, so the report opens showing the same slice of data the chart was showing rather than re-running over everything.
+
+They sit in the report's filter bar next to its own filters, tinted violet to set them apart, and read the same way — member, operator, value. Hovering one says **From dashboard**. What is different is that they are not the report's:
+
+- they apply to the results on screen, and are **not** saved to the report. Publishing the workbook again will not pin them onto the chart for everyone.
+- their values cannot be changed in the workbook. To explore a different value, add your own filter on the same field, or change the dashboard's control and reopen the chart.
+- they can be dropped, but not kept: **Clear filter** on the chip removes it for the rest of the session and the report re-runs without it. Reopening the chart from the dashboard brings the current values back.
+
+To change what a viewer of the dashboard can filter by, edit the [filter control][ref-controls] there — not the report.
+
+When the report already filters the same field, both filters apply — exactly as they do on the dashboard, so the numbers match the chart you came from. A carried filter that says precisely what the report already says is left out rather than shown twice.
+
+Only filters are carried. A [time granularity switcher or field switcher][ref-controls] on the dashboard does not follow into the workbook, so a report opened this way shows its own granularity and its own fields.
+
## Title
Each chart shows the name of the underlying workbook tab as its title. To rename it, open the tab in the workbook, rename the tab, and republish the dashboard — the new name flows through to every chart backed by that tab.
diff --git a/docs-mintlify/docs/explore-analyze/dashboards/widgets/controls.mdx b/docs-mintlify/docs/explore-analyze/dashboards/widgets/controls.mdx
index ed8b68add7a4d..43c3b839b1731 100644
--- a/docs-mintlify/docs/explore-analyze/dashboards/widgets/controls.mdx
+++ b/docs-mintlify/docs/explore-analyze/dashboards/widgets/controls.mdx
@@ -37,7 +37,7 @@ You can set a default value that's applied when the dashboard loads. Defaults ar
There are two ways to set a default:
- **Static default** — pick a value (or values) directly in the filter. Every viewer sees the same default.
-- **User attribute default** — resolve the default from the viewer's [user attribute][ref-user-attributes] at load time, so each viewer sees their own personalized default. [Parent controls](#parent-user-attribute-default) support this too.
+- **User attribute default** — resolve the default from the viewer's [user attribute][ref-user-attributes] at load time, so each viewer sees their own personalized default. [Time granularity switchers](#time-granularity-user-attribute-default), [field switchers](#field-switcher-user-attribute-default) and [parent controls](#parent-user-attribute-default) support this too.
Static defaults are configured by interacting with the filter in the dashboard builder — the value you select is saved on the widget and applied to every viewer when the dashboard loads.
@@ -96,6 +96,41 @@ Custom granularities defined in the [data model][ref-granularities] aren't offer
You can configure a default granularity that's applied when the dashboard loads. If no default is set, charts use the granularity that was saved on the underlying report — viewers can still switch granularities, but the dashboard opens with each chart at its original granularity.
+#### User attribute default {#time-granularity-user-attribute-default}
+
+The default above is one granularity for everyone. To give each viewer their own, turn on **User attribute default** in the switcher's settings and pick a [user attribute][ref-user-attributes]. When the dashboard loads, Cube reads that attribute for the current viewer and opens the control on the granularity it names.
+
+This is how one dashboard opens at the interval each audience works in — daily for the operations team, monthly for the executives who read the same charts — from a single published dashboard.
+
+To configure it:
+
+
+
+ In the dashboard builder, open the widget's settings menu and choose **Edit Control**.
+
+
+ Below **Visibility**, turn on the **User attribute default** switch.
+
+
+ Select the [user attribute][ref-user-attributes] to resolve. Only attributes defined in your account appear in the picker.
+
+
+
+The attribute value is matched against the **granularity names** the switcher [allows](#allowed-granularities) — `day`, `week`, `month`, `quarter`, `year`, and `second`, `minute`, `hour` where the dimension exposes them — ignoring case and surrounding spaces, so an attribute reading `Week` resolves to `week`. The names are matched, not the labels the control displays, so one attribute works the same for viewers in every language.
+
+| Attribute type | How it's applied |
+|---|---|
+| **String**, **Number** | Matched against the granularity names as a single value. |
+| **String array**, **Number array** | The first entry that names an allowed granularity wins. The switcher is single-select, so the rest are ignored. |
+
+A value that isn't one of the switcher's [allowed granularities](#allowed-granularities) — or is empty, `null`, or unresolvable — is ignored rather than forced, and the control falls back to the [default granularity](#default-granularity). Attributes are set per user and the allowed list per dashboard, so the two can drift apart without anyone editing either; the safe reading of an unusable value is "no opinion".
+
+
+The attribute is resolved for the viewer, not baked into the dashboard. Editing the attribute's value changes what that viewer opens on the next time the dashboard loads; it never rewrites the published dashboard, so the default granularity you set in the builder stays intact for everyone else.
+
+
+Viewers can still switch granularities unless the control's [visibility](#visibility) is set to **Disabled**, and their own pick outranks the attribute for the rest of the session. A granularity passed [in the URL](#sharing-the-current-selection) outranks both, so deep links continue to work. If a [parent control](#parent) drives this switcher and the option the viewer is on maps a granularity to it, that mapping decides the granularity — a mapping the author made for that arrangement is more specific than a per-viewer starting point. An option that [leaves the switcher empty](#children) has no opinion, so the attribute still seeds it; one set to **Reset to default** sends the switcher to the [default granularity](#default-granularity) saved on it rather than re-resolving the attribute, and leaves the switcher untouched when no default granularity was saved.
+
## Field switcher
A field switcher lets viewers change *which* dimension or measure the charts are built on — swapping a revenue chart's breakdown from **Status** to **City**, or its measure from **Order count** to **Total revenue** — without leaving the dashboard or opening the report.
@@ -134,11 +169,11 @@ If the dashboard also has a [time granularity switcher](#time-granularity-switch
### User attribute default {#field-switcher-user-attribute-default}
-Like [filters](#user-attribute-default) and [parent controls](#parent-user-attribute-default), a field switcher can start each viewer on their own member. Turn on **User attribute default** in the control's settings and pick a [user attribute][ref-user-attributes]; when the dashboard loads, Cube reads that attribute for the current viewer and opens the control on the member it names.
+Like [filters](#user-attribute-default), [time granularity switchers](#time-granularity-user-attribute-default) and [parent controls](#parent-user-attribute-default), a field switcher can start each viewer on their own member. Turn on **User attribute default** in the control's settings and pick a [user attribute][ref-user-attributes]; when the dashboard loads, Cube reads that attribute for the current viewer and opens the control on the member it names.
This is how one dashboard opens on the breakdown each audience cares about — a **Breakdown** switcher opening on `region` for one team and `channel` for another, from a single published dashboard.
-The attribute seeds the *selection*, exactly as the [default option](#field-switcher-default-option) does, and loses to a pick the viewer has already made. A value that isn't among the **Alternatives** is ignored rather than forced: the attribute is set per user and the options are set per dashboard, so the two can drift apart without anyone editing either, and the safe reading of an unusable value is "no opinion" — the control falls back to the default option.
+The attribute seeds the *selection*, exactly as the [default option](#field-switcher-default-option) does, and loses to a pick the viewer has already made. A member passed [in the URL](#sharing-the-current-selection) outranks both, so deep links keep working. If a [parent control](#parent) drives this switcher and the option the viewer is on maps a member to it, that mapping decides the member; an option that [leaves the switcher empty](#children) has no opinion, so the attribute still seeds it, and one set to **Reset to default** sends the switcher to its [default option](#field-switcher-default-option) rather than re-resolving the attribute. A value that isn't among the **Alternatives** is ignored rather than forced: the attribute is set per user and the options are set per dashboard, so the two can drift apart without anyone editing either, and the safe reading of an unusable value is "no opinion" — the control falls back to the default option.
### What the swap preserves
@@ -154,15 +189,11 @@ A chart whose query doesn't use the replaced member at all is a different case:
A chart with a [period comparison][ref-charts] is a narrower case: the comparison can't follow a member switch, so the chart applies the switch and drops the comparison, saying so in its own notice rather than silently showing a comparison that no longer matches the data.
-
-A field switcher can't be a [parent control's](#parent) child, and a viewer's choice in one isn't carried in the [shared URL](#sharing-the-current-selection). Both are current limitations rather than deliberate design.
-
-
## Parent
A parent control is a dropdown of options you define. Picking one re-points a whole row of other controls at once — so a viewer makes a single choice instead of adjusting three or four filters by hand.
-Unlike the other control types, a parent control targets no member and never touches a chart query directly. It applies values to the controls it *drives* — its **children** — and those children then apply themselves to charts exactly as if the viewer had operated each one. Filters and time granularity switchers can both be children; a [field switcher](#field-switcher) cannot yet, and a parent control cannot be a child of another parent control.
+Unlike the other control types, a parent control targets no member and never touches a chart query directly. It applies values to the controls it *drives* — its **children** — and those children then apply themselves to charts exactly as if the viewer had operated each one. Filters, time granularity switchers and [field switchers](#field-switcher) can all be children; a parent control cannot be a child of another parent control.
For example, an **Analysis** parent with the options `Retail`, `Wholesale` and `Promo` can set a **Channel** filter, a **Minimum order value** filter, and a **Date range** filter to a different combination for each option. Viewers see one dropdown; you can [hide](#visibility) the children if the individual values aren't worth showing.
@@ -180,14 +211,14 @@ Renaming an option later doesn't disturb the values you've mapped to it, so you
### Children
-On the **Children** tab, pick a control from **Child control**, then give each of the parent's options a value for it. Each row renders *that child's own control* — a time granularity switcher's row shows its granularity picker, limited to the granularities that switcher allows; a filter's row shows that filter's operator and value inputs. So the values you can offer are exactly the ones a viewer could pick in the child itself.
+On the **Children** tab, pick a control from **Child control**, then give each of the parent's options a value for it. Each row renders *that child's own control* — a time granularity switcher's row shows its granularity picker, limited to the granularities that switcher allows; a filter's row shows that filter's operator and value inputs; a field switcher's row shows its member picker, limited to the members that switcher offers — its **Alternatives**, plus the replaced member. So the values you can offer are exactly the ones a viewer could pick in the child itself.
Repeat for each control you want the parent to drive. Every option/child pair can be in one of three states:
| State | What happens when the viewer picks that option |
|---|---|
| **A value** | The child is set to that value. |
-| **Reset to default** | The child is cleared back to its own default. For a filter that means no filtering on that dimension. Turn on the row's **Reset to default** switch. |
+| **Reset to default** | The child is put back to its own default. For a filter that means no filtering on that dimension; for a time granularity switcher, its [default granularity](#default-granularity); for a field switcher, its [default option](#field-switcher-default-option). Turn on the row's **Reset to default** switch. |
| **Left empty** | The child is left alone — it keeps whatever value the viewer already had. Use this deliberately when an option shouldn't have an opinion about a particular child. |
While the parent's settings are open, the children it drives are highlighted on the canvas, so you can see the scope of the mapping at a glance.
@@ -198,7 +229,7 @@ A control can be driven by only one parent control at a time. Mapping a child th
### Mapping status on child controls
-Once a dashboard has at least one parent control, every filter and time granularity switcher on it shows a small indicator reporting how it's driven. [Field switchers](#field-switcher) don't, since a parent can't drive one:
+Once a dashboard has at least one parent control, every filter, time granularity switcher and [field switcher](#field-switcher) on it shows a small indicator reporting how it's driven:
| Status | Meaning |
|---|---|
@@ -220,7 +251,7 @@ If you never pick an option, the parent opens with nothing selected and the chil
The default above is one arrangement for everyone. To give each viewer their own, turn on **User attribute default** in the parent control's settings and pick a [user attribute][ref-user-attributes]. When the dashboard loads, Cube reads that attribute for the current viewer and opens the control on the option it names — and drives the children with it, exactly as if the viewer had picked that option themselves.
-This is how you ship one dashboard that opens differently per audience: a **Reporting period** parent whose options are `Month` and `Quarter`, opening on whichever one the viewer's `reporting_period` attribute says, with every filter and time granularity switcher behind it already set to match.
+This is how you ship one dashboard that opens differently per audience: a **Reporting period** parent whose options are `Month` and `Quarter`, opening on whichever one the viewer's `reporting_period` attribute says, with every control behind it already set to match.
To configure it:
@@ -257,7 +288,7 @@ Viewers can still switch to another option unless the control's [visibility](#vi
## Sharing the current selection
-On a published dashboard, the values a viewer picks in the controls are reflected in the URL, so the view they are looking at is bookmarkable and shareable. Copy the address bar, send it on, and the recipient opens the dashboard with the same filters and granularities applied.
+On a published dashboard, the values a viewer picks in the controls are reflected in the URL, so the view they are looking at is bookmarkable and shareable. Copy the address bar, send it on, and the recipient opens the dashboard with the same filters, granularities and member choices applied.
Each control type has its own parameter:
@@ -265,20 +296,20 @@ Each control type has its own parameter:
|---|---|---|
| [Filter](#filter) | `f_.=` | `f_orders.status={"value":"shipped"}` |
| [Time granularity switcher](#time-granularity-switcher) | `tg_.=` | `tg_orders.created_at=week` |
+| [Field switcher](#field-switcher) | `ms_.=` | `ms_orders.status=users_city` |
-The semantic view and dimension are the **internal names** configured on the control — not the display titles you see in the picker. A view shown as `Orders` is usually `orders` in the parameter. Granularities are lowercase and must be one of the switcher's [allowed granularities](#allowed-granularities) — `day`, `week`, `month`, `quarter`, `year`, plus `second`, `minute`, and `hour` for time dimensions that expose them.
+The semantic view and member are the **internal names** configured on the control — not the display titles you see in the picker. A view shown as `Orders` is usually `orders` in the parameter. The same goes for the member on the right-hand side of `ms_`: it is the internal name of the member to switch to, and it is a measure rather than a dimension when the switcher's **Field Type** is **Measure**. Granularities are lowercase and must be one of the switcher's [allowed granularities](#allowed-granularities) — `day`, `week`, `month`, `quarter`, `year`, plus `second`, `minute`, and `hour` for time dimensions that expose them.
You can also write these parameters by hand to open a dashboard in a particular state — see [Pre-set dashboard filters and granularities via URL][ref-embed-url-filters] for the embedded case, which uses the same format.
What does and doesn't travel in the link:
- **Only what the viewer chose.** Values that came from the control's own configuration — a static default, a [default granularity](#default-granularity) — are not written into the URL. Every viewer already gets those from the dashboard itself, and leaving them out means a link stays correct after the dashboard's defaults change.
-- **Never a personalized default.** A value resolved from a [user attribute](#user-attribute-default) stays out of the link — whether it seeded a filter directly or reached one through a [parent control](#parent) opening on the viewer's own option. Sharing a dashboard never pins your attribute value onto the recipient; they see it through their own attributes.
-- **Filters and granularities together.** Picking both puts both in the link, including when a [parent control](#parent) sets several children at once. A parent control isn't serialized itself — the link carries the values its children ended up with, so the recipient sees the same data while the parent dropdown opens on whatever default it resolves for them, which may not be the option the sharer picked.
-- **Not the field switcher, yet.** A [field switcher](#field-switcher) has no parameter of its own, so a viewer's member choice doesn't travel in the link. The recipient opens on the control's [default option](#field-switcher-default-option), or on their own [user attribute](#field-switcher-user-attribute-default) where one is set — and on the charts the sharer was looking at, built on a different member than the sharer saw.
+- **Never a personalized default.** A value resolved from a [user attribute](#user-attribute-default) stays out of the link — whether it seeded a filter, a [time granularity switcher](#time-granularity-user-attribute-default) or a [field switcher](#field-switcher-user-attribute-default) directly, or reached one through a [parent control](#parent) opening on the viewer's own option. Sharing a dashboard never pins your attribute value onto the recipient; they see it through their own attributes.
+- **Every control's pick, together.** Picking in several controls puts them all in the link, including when a [parent control](#parent) sets several children at once — filters, time granularity switchers and [field switchers](#field-switcher) alike. A parent control isn't serialized itself — the link carries the values its children ended up with, so the recipient sees the same data while the parent dropdown opens on whatever default it resolves for them, which may not be the option the sharer picked.
- **Written out on published dashboards only.** Reading these parameters works anywhere, including [embedded][ref-embed-url-filters] dashboards; it's the writing that is published-only. In the dashboard builder the URL is left to the editing session, so changing a control there doesn't rewrite it.
-When a dashboard opens with these parameters, they are applied on top of whatever defaults the controls carry. A parameter is ignored when nothing on the dashboard can honor it — there is no matching control for that dimension, or the requested granularity isn't in the switcher's [allowed granularities](#allowed-granularities).
+When a dashboard opens with these parameters, they are applied on top of whatever defaults the controls carry. A parameter is ignored when nothing on the dashboard can honor it — there is no matching control for that member, the requested granularity isn't in the switcher's [allowed granularities](#allowed-granularities), or the selected member is one the field switcher doesn't offer — its **Alternatives**, plus the replaced member.
## Visibility
diff --git a/docs-mintlify/embedding/iframe/dashboards.mdx b/docs-mintlify/embedding/iframe/dashboards.mdx
index 8cdaccff23ccd..7345cc139e771 100644
--- a/docs-mintlify/embedding/iframe/dashboards.mdx
+++ b/docs-mintlify/embedding/iframe/dashboards.mdx
@@ -68,11 +68,13 @@ URL parameters:
|---|---|---|
| Filter | `f_.=` | `f_orders_transactions.users_country={"value":"USA"}` |
| Time granularity switcher | `tg_.=` | `tg_orders_transactions.created_at=week` |
+| Field switcher | `ms_.=` | `ms_orders_transactions.status=users_city` |
-The `` and `` must match the internal names (not
-display titles) of the semantic view and dimension configured on the widget. For
-filters, an omitted filter type defaults to `equals`. Granularities are lowercase
-and must be one of the switcher's [allowed
+The semantic view and member must match the internal names (not display titles)
+configured on the widget — as must the member on the right-hand side of `ms_`,
+which is a measure rather than a dimension when the field switcher's **Field
+Type** is **Measure**. For filters, an omitted filter type defaults to `equals`.
+Granularities are lowercase and must be one of the switcher's [allowed
granularities](/docs/explore-analyze/dashboards/widgets/controls#allowed-granularities) —
`day`, `week`, `month`, `quarter`, `year`, plus `second`, `minute`, and `hour`
for time dimensions that expose them.
@@ -88,8 +90,11 @@ before the URL goes anywhere real — pasted as-is into the `src="…"` of the i
snippet above, its raw `"` closes the attribute and truncates the URL.
This works on both regular and published (embedded) dashboards. A parameter is
-applied only if a matching control for that dimension already exists on the
-dashboard, and a granularity outside the switcher's allowed list is ignored.
+applied only if a matching control for that member already exists on the
+dashboard; a granularity outside the switcher's allowed list is ignored, as is a
+member the field switcher doesn't offer — its
+[**Alternatives**](/docs/explore-analyze/dashboards/widgets/controls#field-switcher),
+plus the member it replaces.
The reverse direction works on published dashboards: when a viewer changes a
control there, the new value is written back into the dashboard's own URL, so the
diff --git a/docs-mintlify/reference/cli.mdx b/docs-mintlify/reference/cli.mdx
index 058c157858d9e..86435a4f06824 100644
--- a/docs-mintlify/reference/cli.mdx
+++ b/docs-mintlify/reference/cli.mdx
@@ -177,7 +177,7 @@ Run `cube --help` for the full options of any command.
| `regions` | List available deployment regions |
| `github` (`gh`) | GitHub integration: `status`, `installations`, `repos`, `branches`, `connect` |
| `data-model` | Data model files and Git workflow: `list`, `get`, `put`, `delete`, `rename`, `file-hashes`, `branches`, `create-branch`, `delete-branch`, `enable-branch`/`disable-branch`, `dev-mode`, `commit`, `pull`, `merge`, `merge-to-default` |
-| `dbt` | dbt sync: `sync` (`--ref`, `--wait`), `status`, `result`, `cancel` |
+| `dbt` | dbt sync: `sync` (`--ref`, `--wait`), `status`, `result`, `logs`, `history` (`--status`, `--trigger`), `cancel` |
| `environments` | Deployment environments and environment tokens |
| `variables` | Deployment environment variables |
| `folders`, `workbooks`, `reports`, `workspace` | Workspace content management |
@@ -368,6 +368,52 @@ commit.
+### Sync history and logs
+
+`history` lists a deployment's recent syncs — how each one was triggered, how it
+ended, and how long it took — and `logs` prints one sync's phase timeline,
+including the text a failed phase produced:
+
+```bash
+cube dbt history DEPLOYMENT_ID
+cube dbt logs DEPLOYMENT_ID SYNC_JOB_ID
+```
+
+`history` narrows with `--status` (`RUNNING`, `COMPLETED`, `FAILED`, `CANCELLED`,
+`UNKNOWN`) and `--trigger` (`manual`, `api`, `webhook`, `agent`, `unknown`) — both
+case-sensitive as spelled here — and pages with `--first`/`--after`, taking the cursor
+from `pageInfo.endCursor` in `--json` output. A page holds at most 100 runs, so a
+larger `--first` returns 100 with `pageInfo.hasNextPage` set. `logs` takes no paging
+flags — one sync's timeline is one page, and each line carries the phase it belongs
+to and how long that phase took.
+
+Both need only `SchemaRead`. Durations are the server's own single-clock figure, so
+they never disagree with the run they describe. `--json` carries the rest of each
+record — the dbt ref that was synced, the phase that failed, per-phase timings and
+manifest counts.
+
+`logs` is what turns a red CI step into something self-explaining: a failed
+`--wait` reports the reason, and the timeline says which phase produced it.
+
+```bash
+cube dbt sync "$DEPLOYMENT_ID" --ref "$GITHUB_HEAD_REF" --wait --json > sync.json || {
+ SYNC_JOB_ID=$(jq -r '.syncJobId // empty' sync.json)
+ [ -n "$SYNC_JOB_ID" ] && cube dbt logs "$DEPLOYMENT_ID" "$SYNC_JOB_ID"
+ exit 1
+}
+```
+
+A failed `--wait --json` still writes its document before exiting non-zero, which
+is what leaves the `syncJobId` there to follow up on.
+
+
+
+A cancelled sync is listed as `CANCELLED` by `history`, but reported as a failure by
+`status` and by `sync --wait` — a gate polling for a terminal answer needs one, and the
+reason it prints says the sync was cancelled.
+
+
+
### dbt sync as a CI test gate
Sync the branch under review, compile it, query it, and fail the job if any step
diff --git a/docs-mintlify/scripts/extract-api.mjs b/docs-mintlify/scripts/extract-api.mjs
index 67b29fca63b2d..31c08489804fd 100644
--- a/docs-mintlify/scripts/extract-api.mjs
+++ b/docs-mintlify/scripts/extract-api.mjs
@@ -425,6 +425,34 @@ if (missing.length) {
process.exit(1);
}
+// 2b. Hoist `description`/`deprecated` off a nullable field's non-null `oneOf`
+// branch, where class-validator-jsonschema puts them. Renderers read both off the
+// property schema, not a branch, so they otherwise never render. Scoped to exactly
+// a two-branch, one-bare-null shape, so a genuine polymorphic oneOf — several real
+// alternatives, each with its own description — is left alone.
+function hoistNullableMeta(node) {
+ if (Array.isArray(node)) { node.forEach(hoistNullableMeta); return; }
+ if (!node || typeof node !== 'object') return;
+ const branches = node.oneOf;
+ if (Array.isArray(branches) && branches.length === 2) {
+ const isBareNull = (b) => b && Object.keys(b).length === 1 && b.type === 'null';
+ const branch = isBareNull(branches[0]) ? branches[1] : isBareNull(branches[1]) ? branches[0] : null;
+ if (branch && typeof branch === 'object') {
+ if (branch.description !== undefined && node.description === undefined) {
+ node.description = branch.description;
+ delete branch.description;
+ }
+ if (branch.deprecated !== undefined && node.deprecated === undefined) {
+ node.deprecated = branch.deprecated;
+ delete branch.deprecated;
+ }
+ }
+ }
+ for (const v of Object.values(node)) hoistNullableMeta(v);
+}
+hoistNullableMeta(paths);
+hoistNullableMeta(schemas);
+
// 3. Determine tag set + order (preferred order first, then any extras A–Z).
const presentTags = new Set();
for (const val of Object.values(paths)) {
diff --git a/packages/cubejs-client-core/package.json b/packages/cubejs-client-core/package.json
index 879cc60d3ea99..dfd3bb8e953ea 100644
--- a/packages/cubejs-client-core/package.json
+++ b/packages/cubejs-client-core/package.json
@@ -1,7 +1,9 @@
{
"name": "@cubejs-client/core",
"version": "1.7.32",
- "engines": {},
+ "engines": {
+ "node": ">=20.0.0"
+ },
"type": "module",
"repository": {
"type": "git",
@@ -34,7 +36,6 @@
},
"dependencies": {
"core-js": "^3.6.5",
- "cross-fetch": "^3.0.2",
"d3-format": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.10.4",
diff --git a/packages/cubejs-client-core/src/HttpTransport.ts b/packages/cubejs-client-core/src/HttpTransport.ts
index 306daec4cba2c..b0fac22ad2bb2 100644
--- a/packages/cubejs-client-core/src/HttpTransport.ts
+++ b/packages/cubejs-client-core/src/HttpTransport.ts
@@ -1,4 +1,3 @@
-import fetch from 'cross-fetch';
import 'url-search-params-polyfill';
import { responseChunks } from './streaming.js';
diff --git a/packages/cubejs-client-core/src/streaming.ts b/packages/cubejs-client-core/src/streaming.ts
index c63f54ff1641d..71dad13155ee0 100644
--- a/packages/cubejs-client-core/src/streaming.ts
+++ b/packages/cubejs-client-core/src/streaming.ts
@@ -1,33 +1,17 @@
export async function* responseChunks(res: Response): AsyncIterable {
- // eslint-disable-next-line prefer-destructuring
- const body: any = res.body;
-
- if (body && typeof body.getReader === 'function') {
- const reader = body.getReader(); // Browser / Node native fetch
- try {
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- if (value) yield value; // Uint8Array
- }
- } finally {
- reader.releaseLock?.();
- }
- return;
+ if (!res.body) {
+ throw new Error('Unsupported response body type for streaming');
}
- // Node.js Readable (node-fetch v2 via cross-fetch)
- if (body && Symbol.asyncIterator in body) {
- for await (const chunk of body as AsyncIterable) {
- if (typeof chunk === 'string') {
- // Convert string chunks to bytes (rare, but safe)
- yield new TextEncoder().encode(chunk);
- } else {
- yield new Uint8Array(chunk);
- }
+ const reader = res.body.getReader();
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ if (value) yield value;
}
- return;
+ } finally {
+ reader.releaseLock();
}
-
- throw new Error('Unsupported response body type for streaming');
}
diff --git a/packages/cubejs-client-core/test/HttpTransport.test.ts b/packages/cubejs-client-core/test/HttpTransport.test.ts
index 26b43b0963079..6d93792d557cd 100644
--- a/packages/cubejs-client-core/test/HttpTransport.test.ts
+++ b/packages/cubejs-client-core/test/HttpTransport.test.ts
@@ -1,12 +1,7 @@
-/* eslint-disable import/first */
import { vi, MockedFunction } from 'vitest';
-import fetch from 'cross-fetch';
-
-vi.mock('cross-fetch');
-
import HttpTransport from '../src/HttpTransport.js';
-const mockedFetch = fetch as MockedFunction;
+const mockedFetch = vi.fn() as MockedFunction;
describe('HttpTransport', () => {
const apiUrl = 'http://localhost:3000/cubejs-api/v1';
@@ -33,9 +28,14 @@ describe('HttpTransport', () => {
const largeQueryJson = `{"query":{"measures":["Orders.count"],"dimensions":["Users.country"],"filters":[{"member":"Users.id","operator":"equals","values":${JSON.stringify(ids)}}]}}`;
beforeAll(() => {
+ vi.stubGlobal('fetch', mockedFetch);
mockedFetch.mockReturnValue(Promise.resolve({ ok: true } as Response));
});
+ afterAll(() => {
+ vi.unstubAllGlobals();
+ });
+
afterEach(() => {
mockedFetch.mockClear();
});
@@ -47,8 +47,8 @@ describe('HttpTransport', () => {
});
const req = transport.request('load', { query });
await req.subscribe(() => { console.log('subscribe cb'); });
- expect(fetch).toHaveBeenCalledTimes(1);
- expect(fetch).toHaveBeenCalledWith(`${apiUrl}/load?query=${queryUrlEncoded}`, {
+ expect(mockedFetch).toHaveBeenCalledTimes(1);
+ expect(mockedFetch).toHaveBeenCalledWith(`${apiUrl}/load?query=${queryUrlEncoded}`, {
method: 'GET',
headers: {
Authorization: 'token',
@@ -69,8 +69,8 @@ describe('HttpTransport', () => {
});
const req = transport.request('meta', { extraParams });
await req.subscribe(() => { console.log('subscribe cb'); });
- expect(fetch).toHaveBeenCalledTimes(1);
- expect(fetch).toHaveBeenCalledWith(`${apiUrl}/meta?extraParams=${serializedExtraParams}`, {
+ expect(mockedFetch).toHaveBeenCalledTimes(1);
+ expect(mockedFetch).toHaveBeenCalledWith(`${apiUrl}/meta?extraParams=${serializedExtraParams}`, {
method: 'GET',
headers: {
Authorization: 'token',
@@ -87,7 +87,7 @@ describe('HttpTransport', () => {
});
const req = transport.request('meta', { signal: undefined, baseRequestId: undefined });
await req.subscribe(() => { console.log('subscribe cb'); });
- expect(fetch).toHaveBeenCalledTimes(1);
+ expect(mockedFetch).toHaveBeenCalledTimes(1);
expect(mockedFetch.mock.calls[0]?.[0]).toBe(`${apiUrl}/meta`);
});
@@ -100,7 +100,7 @@ describe('HttpTransport', () => {
});
const req = transport.request('meta', { signal: undefined, baseRequestId: undefined, onlyViews: true });
await req.subscribe(() => { console.log('subscribe cb'); });
- expect(fetch).toHaveBeenCalledTimes(1);
+ expect(mockedFetch).toHaveBeenCalledTimes(1);
expect(mockedFetch.mock.calls[0]?.[0]).toBe(`${apiUrl}/meta?onlyViews=true`);
});
@@ -112,8 +112,8 @@ describe('HttpTransport', () => {
});
const req = transport.request('load', { query });
await req.subscribe(() => { console.log('subscribe cb'); });
- expect(fetch).toHaveBeenCalledTimes(1);
- expect(fetch).toHaveBeenCalledWith(`${apiUrl}/load`, {
+ expect(mockedFetch).toHaveBeenCalledTimes(1);
+ expect(mockedFetch).toHaveBeenCalledWith(`${apiUrl}/load`, {
method: 'POST',
headers: {
Authorization: 'token',
@@ -130,8 +130,8 @@ describe('HttpTransport', () => {
});
const req = transport.request('load', { query: LargeQuery });
await req.subscribe(() => { console.log('subscribe cb'); });
- expect(fetch).toHaveBeenCalledTimes(1);
- expect(fetch).toHaveBeenCalledWith(`${apiUrl}/load`, {
+ expect(mockedFetch).toHaveBeenCalledTimes(1);
+ expect(mockedFetch).toHaveBeenCalledWith(`${apiUrl}/load`, {
method: 'POST',
headers: {
Authorization: 'token',
diff --git a/rust/cube-cli/src/commands/dbt.rs b/rust/cube-cli/src/commands/dbt.rs
index 85487bd2f76d1..5b697736c44c2 100644
--- a/rust/cube-cli/src/commands/dbt.rs
+++ b/rust/cube-cli/src/commands/dbt.rs
@@ -2,6 +2,7 @@ use std::time::{Duration, Instant};
use anyhow::{bail, Result};
use clap::Subcommand;
+use owo_colors::OwoColorize;
use serde_json::Value;
use crate::client::{Client, Query};
@@ -64,6 +65,36 @@ enum Cmd {
/// Sync job id, as returned by `sync`
sync_job_id: String,
},
+ /// Show a dbt sync's phase timeline, including the text a failed phase produced
+ ///
+ /// No paging flags: one sync's timeline is one page, bounded by the number of
+ /// phases it ran, so flags would be accepted here and ignored by the server.
+ Logs {
+ /// Deployment id
+ deployment: i64,
+ /// Sync job id, as returned by `sync`
+ sync_job_id: String,
+ },
+ /// List a deployment's recent dbt syncs
+ #[command(aliases = ["list", "ls"])]
+ History {
+ /// Deployment id
+ deployment: i64,
+ /// Only runs with this status, case-sensitive: RUNNING, COMPLETED, FAILED,
+ /// CANCELLED, UNKNOWN
+ #[arg(long, value_parser = util::nonempty_filter)]
+ status: Option,
+ /// Only runs started this way, case-sensitive: manual, api, webhook, agent,
+ /// unknown
+ #[arg(long, value_parser = util::nonempty_filter)]
+ trigger: Option,
+ /// Page size for cursor-based pagination (at most 100 per page)
+ #[arg(long)]
+ first: Option,
+ /// Cursor for the next page (from a previous pageInfo.endCursor)
+ #[arg(long)]
+ after: Option,
+ },
/// Cancel a running dbt sync
Cancel {
/// Deployment id
@@ -195,6 +226,245 @@ fn print_prune_hint(sync_created_it: bool, deployment: i64, branch_name: &str) {
}
}
+/// The error a terminal FAILED leaves behind, from both wait paths through one function
+/// so the two cannot drift.
+///
+/// Two sentences, because the reason alone is not the whole answer. The first is the
+/// workflow's own words, which is what a human reading a failed job wants — collapsed
+/// like the build failure in `deployments`, since a dbt reason is a compile or warehouse
+/// error that arrives multi-line and `main` renders an anyhow chain on one line with
+/// `{err:#}`; and `is_blank` rather than `is_empty`, because a reason of blanks would
+/// fill the slot without answering it on the one line somebody reads when the gate goes
+/// red. The second says WHICH PHASE produced it, which is the difference between a red
+/// CI step that explains itself and one that only says "dbt sync failed" — and it earns
+/// its place most in exactly the case the first sentence cannot fill.
+///
+/// In the message rather than printed beside it, so it survives `--json` (where advice
+/// has no place in the document, but stderr still carries it into the job log) and lands
+/// after the reason rather than above it. The only signal a gate itself needs is still
+/// the non-zero exit.
+fn failure(deployment: i64, sync_job_id: &str, status: &Value) -> anyhow::Error {
+ let error = util::one_line(&output::field(status, "error"), util::REASON_LIMIT);
+ let reason = if util::is_blank(&error) {
+ "(no reason reported)"
+ } else {
+ &error
+ };
+
+ anyhow::anyhow!(
+ "dbt sync {sync_job_id} failed: {reason}. See which phase failed with \
+ `cube dbt logs {deployment} {}`",
+ util::shell_quote(sync_job_id)
+ )
+}
+
+/// Server text as a terminal may safely show it: every control character except the
+/// line breaks and tabs the timeline keeps on purpose is dropped.
+///
+/// This is text the CLI did not write — dbt compile output, warehouse messages, model
+/// names — and an ESC sequence in it can retitle a window, move the cursor, or overwrite
+/// the lines above it in a CI log. `one_line` is not the guard it looks like: it drops
+/// control characters that are WHITESPACE as a side effect of splitting on it, and ESC
+/// is not whitespace. Printing raw would be safe only under `--json`, where `serde_json`
+/// escapes them.
+fn printable(text: &str) -> String {
+ text.chars()
+ .filter(|c| !c.is_control() || *c == '\n' || *c == '\t')
+ .collect()
+}
+
+/// How much of one server-supplied value a table cell or a line prefix keeps. Long
+/// enough for a branch name, a timestamp or a trigger with room to spare, short enough
+/// that one row stays one row: a table is laid out to its widest cell, so an unbounded
+/// one would push every other column off the screen.
+const CELL_LIMIT: usize = 120;
+
+/// One bounded, printable line of server text — what a table cell and a timeline
+/// prefix both need, and where trimming comes from: `one_line` splits on whitespace, so
+/// padding and interior newlines go the same way.
+fn one_cell(text: &str) -> String {
+ util::one_line(&printable(text), CELL_LIMIT)
+}
+
+/// A `durationMs` rendered as time, because a sync runs for minutes and `912345` is
+/// not a thing anyone reads off a table.
+///
+/// Anything that is not a plain count of milliseconds is passed through untouched:
+/// blank stays blank, and a value this build cannot parse is shown as it arrived
+/// rather than turned into a confident `0s`.
+fn human_duration_ms(raw: &str) -> String {
+ let raw = raw.trim();
+ let ms = match raw.parse::() {
+ Ok(ms) => ms,
+ // A whole number of milliseconds that arrived as a float (`912345.0`) is still
+ // a duration; a negative or non-numeric one is not, and falls through.
+ //
+ // Bounded, not merely non-negative: an `as` cast SATURATES, so `1e30` would
+ // otherwise render as a confident five-billion-hour duration instead of passing
+ // through as the nonsense it is.
+ Err(_) => match raw.parse::() {
+ Ok(value) if value.is_finite() && value >= 0.0 && value < u64::MAX as f64 => {
+ value.round() as u64
+ }
+ _ => return raw.to_string(),
+ },
+ };
+
+ let seconds = ms / 1000;
+ let (minutes, seconds) = (seconds / 60, seconds % 60);
+ let (hours, minutes) = (minutes / 60, minutes % 60);
+ match (hours, minutes) {
+ (0, 0) if ms < 1000 => format!("{ms}ms"),
+ (0, 0) => format!("{seconds}s"),
+ (0, _) => format!("{minutes}m {seconds}s"),
+ _ => format!("{hours}h {minutes}m"),
+ }
+}
+
+/// The columns of `history`, paired with the row `history_row` builds — the two are
+/// positional, so they are declared next to each other and a test holds them the same
+/// width.
+const HISTORY_COLUMNS: [&str; 6] = [
+ "SYNC JOB ID",
+ "STATUS",
+ "TRIGGER",
+ "STARTED",
+ "DURATION",
+ "BRANCH",
+];
+
+/// The column a row has to fill to be usable at all: without an id, nothing in it can
+/// be passed to `logs` or `result`.
+const ID_COLUMN: usize = 0;
+
+/// One run as a table row, under the names the list endpoint publishes.
+///
+/// The columns are the six a run is identified and judged by; the rest of the record —
+/// `gitRef`, `failedPhase`, `lastStage`, per-phase timings, manifest counts — is in
+/// `--json`, which is where a table would stop being one.
+fn history_row(run: &Value) -> Vec {
+ // Every cell through `one_cell`: these are server strings landing in a laid-out
+ // table, where an interior newline breaks the row and an unbounded value pushes the
+ // other columns off the screen. Padding goes with them, so a `COMPLETED ` cannot sit
+ // beside a `COMPLETED` and read as two outcomes.
+ let cell = |field: &str| one_cell(&output::field(run, field));
+
+ vec![
+ cell("syncJobId"),
+ // Five values here, not the two the status endpoint calls terminal: a listed run
+ // can also be CANCELLED or UNKNOWN, and nothing in this command acts on them —
+ // it shows what the row says.
+ cell("status"),
+ cell("trigger"),
+ cell("startedAt"),
+ // `durationMs` ONLY — never `completedAt` minus `startedAt`. Those two stamps
+ // are written by different processes, so their difference can disagree with the
+ // server's own figure and, for a run that fails moments after starting, be
+ // negative. A run that reports no `durationMs` gets an empty cell, which is the
+ // honest answer; a computed one would be a plausible wrong number.
+ human_duration_ms(&cell("durationMs")),
+ cell("branchName"),
+ ]
+}
+
+/// One line of a sync's timeline, read out of whichever fields the entry carried.
+///
+/// Reading is separated from printing so it can be tested: colour is applied at the
+/// call site, where the terminal is, and asserting on ANSI escapes would test
+/// owo-colors rather than this.
+struct LogEntry {
+ /// When it happened; blank when the entry did not say.
+ time: String,
+ /// The phase it belongs to. Blank stays blank rather than becoming empty brackets,
+ /// for the same reason `status_label` does not print them either.
+ phase: String,
+ /// How long that phase took, on the lines that measure one — the timings are half
+ /// of what makes this a timeline rather than a list of remarks.
+ duration: String,
+ message: String,
+ /// Whether this entry is a failure, so the line can be red. A level this build
+ /// does not recognise leaves it plain: colouring an unknown level red would
+ /// announce a failure the server never reported.
+ error: bool,
+}
+
+fn log_entry(value: &Value) -> LogEntry {
+ LogEntry {
+ time: one_cell(&output::field(value, "timestamp")),
+ phase: one_cell(&output::field(value, "phase")),
+ duration: human_duration_ms(&one_cell(&output::field(value, "durationMs"))),
+ // Kept whole, unlike the prefix beside it and the poll label's `one_line`: this
+ // is the failure text itself, printed once, and a dbt compile error means its
+ // line breaks. Collapsing them would apply the label's rule where it does harm —
+ // so the control characters `one_line` would have taken with the newlines are
+ // dropped deliberately instead.
+ message: printable(output::field(value, "message").trim_end()),
+ // `error` is the level the endpoint documents beside `info`. The other two cost
+ // nothing and lean the safe way: colour is not a decision anything acts on, so a
+ // level this build has not met yet is better red than silently ordinary.
+ error: matches!(
+ output::field(value, "level")
+ .trim()
+ .to_ascii_uppercase()
+ .as_str(),
+ "ERROR" | "FATAL" | "CRITICAL"
+ ),
+ }
+}
+
+/// A failure's text is red wherever it lands, the raw-entry fallback below included:
+/// the entry this build understood least is the last place to drop the signal that it
+/// is a failure.
+fn paint_failure(text: String, error: bool) -> String {
+ if error {
+ text.red().to_string()
+ } else {
+ text
+ }
+}
+
+/// One rendered line of the timeline.
+///
+/// The colour lives here rather than at the call site, next to the choice of what to
+/// show: the fallback below has to drop a prefix as well as swap the text, and those
+/// are one decision rather than two.
+fn log_line(value: &Value) -> String {
+ let LogEntry {
+ time,
+ phase,
+ duration,
+ message,
+ error,
+ } = log_entry(value);
+
+ // The text is why somebody ran this command, so an entry this build cannot find it
+ // in is shown as it arrived rather than dropped — and on its own: the raw JSON
+ // already carries the timestamp and the phase that would otherwise prefix it, and
+ // `serde_json` escapes the control characters `printable` exists to drop.
+ if util::is_blank(&message) {
+ return paint_failure(value.to_string(), error);
+ }
+
+ let mut parts = Vec::new();
+ if !util::is_blank(&time) {
+ parts.push(time.dimmed().to_string());
+ }
+ // The phase and its timing share one bracket — metadata on one side, the line's own
+ // text on the other, so a multi-line failure is not interrupted by either. Whichever
+ // of the two is missing is simply absent: no empty brackets, for the reason
+ // `status_label` prints none, and no bare parenthesis where a timing would go.
+ let labels: Vec = [phase, duration]
+ .into_iter()
+ .filter(|label| !util::is_blank(label))
+ .collect();
+ if !labels.is_empty() {
+ parts.push(format!("[{}]", labels.join(" ")).cyan().to_string());
+ }
+ parts.push(paint_failure(message, error));
+
+ parts.join(" ")
+}
+
/// A result is available only when it is a non-empty object. Some deployments return
/// `200 null` or `{}` while the completed workflow is still publishing its result.
fn available_result(result: Option) -> Option {
@@ -316,23 +586,7 @@ pub async fn command(args: Args, ctx: &Ctx) -> Result<()> {
output::print_json(&wait_json(&started, &status, &branch_name, None));
}
- // The only signal a CI gate needs is the non-zero exit. The message
- // carries the workflow's own reason, which is what a human reading
- // the failed job actually wants — and `is_blank` rather than `is_empty`
- // because a reason of blanks would fill the slot without answering it,
- // on the one line somebody reads when the gate goes red.
- // Collapsed like the build failure in `deployments`: a dbt reason is a
- // compile or warehouse error that arrives multi-line, and this is a
- // `bail!` whose chain `main` renders on one line with `{err:#}`.
- let error = util::one_line(&output::field(&status, "error"), util::REASON_LIMIT);
- bail!(
- "dbt sync {sync_job_id} failed: {}",
- if util::is_blank(&error) {
- "(no reason reported)".to_string()
- } else {
- error
- }
- );
+ return Err(failure(deployment, &sync_job_id, &status));
}
// COMPLETED from here on, so the result is a value rather than a
@@ -424,19 +678,7 @@ pub async fn command(args: Args, ctx: &Ctx) -> Result<()> {
let status = wait_for_sync(&api, deployment, &sync_job_id, timeout, poll).await?;
print_status(ctx.json, &status);
if util::status_of(&status, "status") == FAILED {
- // Collapsed like the build failure in `deployments`: a dbt reason is a
- // compile or warehouse error that arrives multi-line, and this is a
- // `bail!` whose chain `main` renders on one line with `{err:#}`.
- let error =
- util::one_line(&output::field(&status, "error"), util::REASON_LIMIT);
- bail!(
- "dbt sync {sync_job_id} failed: {}",
- if util::is_blank(&error) {
- "(no reason reported)".to_string()
- } else {
- error
- }
- );
+ return Err(failure(deployment, &sync_job_id, &status));
}
return Ok(());
@@ -467,6 +709,96 @@ pub async fn command(args: Args, ctx: &Ctx) -> Result<()> {
),
}
}
+ Cmd::Logs {
+ deployment,
+ sync_job_id,
+ } => {
+ let path = format!("{}/{sync_job_id}/logs", base(deployment));
+ // 404 is the tenant answering rather than a transport failure, and the three
+ // things it can mean are all actionable — so say them, the way `status` does,
+ // instead of leaving a bare status line to be interpreted.
+ let Some(res) = api.get_optional(&path, &Vec::new()).await? else {
+ bail!(
+ "no logs for dbt sync {sync_job_id} on deployment {deployment}. It may \
+ belong to another deployment, have aged out, or this tenant may not \
+ serve the dbt sync history endpoints yet"
+ );
+ };
+
+ if ctx.json {
+ output::print_json(&res);
+
+ return Ok(());
+ }
+
+ let entries = output::items(&res);
+ if entries.is_empty() {
+ // Not a failure: a sync that has only just started has no timeline yet.
+ eprintln!("{}", "No log entries".dimmed());
+
+ return Ok(());
+ }
+
+ for entry in entries {
+ println!("{}", log_line(&entry));
+ }
+ }
+ Cmd::History {
+ deployment,
+ status,
+ trigger,
+ first,
+ after,
+ } => {
+ let mut query = Vec::new();
+ // Sent as given, unchecked against a list: the two vocabularies are the
+ // server's, and a filter this build has not heard of is one the server can
+ // still honour, where a list hard-coded here would refuse a run this tenant
+ // has. Only the empty string is refused, and at parse time — `push` does not
+ // drop it, it sends `status=`, and what that selects is not ours to guess.
+ //
+ // The case is the server's as well, and the two vocabularies do not share it
+ // — statuses upper, triggers lower. Hence the help spelling both out and
+ // saying so: a mis-cased value is the one mistake that may come back as an
+ // empty table rather than as a complaint, and an empty table reads as an
+ // answer.
+ util::push(&mut query, "status", &status);
+ util::push(&mut query, "trigger", &trigger);
+ util::push(&mut query, "first", &first);
+ util::push(&mut query, "after", &after);
+ let Some(res) = api.get_optional(&base(deployment), &query).await? else {
+ bail!(
+ "no dbt sync history for deployment {deployment}. The deployment may \
+ not exist or may not be visible to this credential, or this tenant \
+ may not serve the dbt sync history endpoints yet"
+ );
+ };
+
+ if ctx.json {
+ output::print_json(&res);
+
+ return Ok(());
+ }
+
+ let rows: Vec> = output::items(&res).iter().map(history_row).collect();
+ // A page whose rows name no sync at all is one this build could not read:
+ // every run has an id, and a row without one cannot be passed on to `logs` or
+ // `result` either. Printed as blanks it would read as "these syncs are empty"
+ // rather than "this CLI did not understand them", so say so and point at
+ // `--json`, which answers whatever the columns cannot name.
+ //
+ // Keyed on the id rather than on every cell being blank, which a single
+ // filled column was enough to defeat. It still does not claim to catch ONE
+ // renamed field: a column of blanks beside filled ones is visible on its own,
+ // and a warning per column would fire on every legitimately empty one.
+ if !rows.is_empty() && rows.iter().all(|row| util::is_blank(&row[ID_COLUMN])) {
+ eprintln!(
+ "warning: these sync rows name no sync job id — re-run with --json, \
+ or update the CLI with `cube update`"
+ );
+ }
+ output::table(&HISTORY_COLUMNS, rows);
+ }
Cmd::Cancel {
deployment,
sync_job_id,
@@ -490,6 +822,231 @@ mod tests {
use super::*;
use serde_json::json;
+ /// The cell a named column holds, so a test names the column rather than an index
+ /// that a reordering would quietly point somewhere else.
+ fn cell(row: &[String], column: &str) -> String {
+ let index = HISTORY_COLUMNS
+ .iter()
+ .position(|header| *header == column)
+ .unwrap_or_else(|| panic!("no {column} column"));
+
+ row[index].clone()
+ }
+
+ #[test]
+ fn a_history_row_fills_every_column() {
+ // Positional, so a column added to one and not the other would shift every cell
+ // after it into the wrong header.
+ let row = history_row(&json!({}));
+ assert_eq!(row.len(), HISTORY_COLUMNS.len());
+ assert!(row.iter().all(|value| value.is_empty()));
+ // And the column `history`'s warning reads is the one it means.
+ assert_eq!(HISTORY_COLUMNS[ID_COLUMN], "SYNC JOB ID");
+ }
+
+ #[test]
+ fn a_run_renders_the_record_the_list_endpoint_publishes() {
+ let run = json!({
+ "syncJobId": "abc", "deploymentId": 42, "status": "COMPLETED", "trigger": "api",
+ "branchName": "dbt-sync/main-1", "gitRef": "feature/orders",
+ "startedAt": "2026-08-24T10:00:00Z", "completedAt": "2026-08-24T10:15:12Z",
+ "durationMs": 912_345, "stats": { "cubeCount": 12 }
+ });
+ assert_eq!(
+ history_row(&run),
+ vec![
+ "abc",
+ "COMPLETED",
+ "api",
+ "2026-08-24T10:00:00Z",
+ "15m 12s",
+ "dbt-sync/main-1"
+ ]
+ );
+ // A run still in flight reports no duration and no branch of its own yet; the
+ // cells it cannot fill stay empty rather than being derived from its stamps.
+ let running = json!({
+ "syncJobId": "abc", "status": "RUNNING", "trigger": "webhook",
+ "startedAt": "2026-08-24T10:00:00Z", "durationMs": null, "completedAt": null
+ });
+ assert_eq!(cell(&history_row(&running), "DURATION"), "");
+ assert_eq!(cell(&history_row(&running), "STATUS"), "RUNNING");
+ // The two values a listed run can carry that the status endpoint never reports:
+ // nothing here acts on a status, so they pass through as they arrived.
+ for status in ["CANCELLED", "UNKNOWN"] {
+ let row = history_row(&json!({"syncJobId": "abc", "status": status}));
+ assert_eq!(cell(&row, "STATUS"), status);
+ }
+ // And a padded status names the state it reports, like everywhere else here.
+ assert_eq!(
+ cell(&history_row(&json!({"status": " FAILED\n"})), "STATUS"),
+ "FAILED"
+ );
+ }
+
+ #[test]
+ fn a_duration_is_the_servers_own_figure_or_nothing() {
+ // The trap this column exists to avoid: both stamps present, no `durationMs`.
+ // They are written by different processes, so their difference can disagree with
+ // the server's figure and — for a run that fails moments after starting — be
+ // negative. An empty cell is the honest answer; a computed one would be a
+ // plausible wrong number.
+ let stamps_only = json!({
+ "syncJobId": "abc",
+ "startedAt": "2026-08-24T10:00:00Z",
+ "completedAt": "2026-08-24T10:05:00Z"
+ });
+ assert_eq!(cell(&history_row(&stamps_only), "DURATION"), "");
+ }
+
+ #[test]
+ fn a_duration_reads_as_time() {
+ // A sync runs for minutes, so the unit has to survive being read off a table.
+ assert_eq!(human_duration_ms("999"), "999ms");
+ assert_eq!(human_duration_ms("1000"), "1s");
+ assert_eq!(human_duration_ms("59999"), "59s");
+ assert_eq!(human_duration_ms("60000"), "1m 0s");
+ assert_eq!(human_duration_ms("912345"), "15m 12s");
+ assert_eq!(human_duration_ms("3600000"), "1h 0m");
+ assert_eq!(human_duration_ms("5430000"), "1h 30m");
+ // Serialised as a float, which is still a duration.
+ assert_eq!(human_duration_ms("912345.0"), "15m 12s");
+ // Not a count of milliseconds: shown as it arrived rather than as a confident
+ // `0s`, which would report a run that took a quarter of an hour as instant.
+ assert_eq!(human_duration_ms(""), "");
+ assert_eq!(human_duration_ms(" "), "");
+ assert_eq!(human_duration_ms("-1"), "-1");
+ assert_eq!(human_duration_ms("PT15M"), "PT15M");
+ // An `as` cast saturates, so this has to be rejected before it becomes a
+ // confident five-billion-hour duration.
+ assert_eq!(human_duration_ms("1e30"), "1e30");
+ assert_eq!(human_duration_ms("inf"), "inf");
+ }
+
+ #[test]
+ fn a_failure_names_the_reason_and_where_the_phase_is() {
+ let message = failure(
+ 42,
+ "sync-1",
+ &json!({"error": "Compilation Error in model fct_orders\n depends on 'stg_orders'"}),
+ )
+ .to_string();
+ // One line, because `main` renders the chain with `{err:#}`.
+ assert_eq!(
+ message,
+ "dbt sync sync-1 failed: Compilation Error in model fct_orders depends on \
+ 'stg_orders'. See which phase failed with `cube dbt logs 42 'sync-1'`"
+ );
+ // A reason of blanks fills the slot without answering it, so it is not a reason —
+ // and this is the case where the second sentence is the only answer there is.
+ for status in [json!({}), json!({"error": " "})] {
+ let message = failure(42, "sync-1", &status).to_string();
+ assert!(message.contains("(no reason reported)"), "{message}");
+ assert!(message.contains("cube dbt logs 42 'sync-1'"), "{message}");
+ }
+ // Quoted, like every other suggested command here: an id is opaque in practice,
+ // but these are copied out of CI logs without being reread.
+ assert!(failure(42, "a;rm -rf b", &json!({}))
+ .to_string()
+ .contains("`cube dbt logs 42 'a;rm -rf b'`"));
+ }
+
+ #[test]
+ fn a_log_entry_says_only_what_it_carried() {
+ let entry = log_entry(&json!({
+ "timestamp": "2026-08-24T10:00:01Z",
+ "phase": "dbt-compile",
+ "level": "info",
+ "stream": "system",
+ "message": "Parsing dbt project\n"
+ }));
+ assert_eq!(entry.time, "2026-08-24T10:00:01Z");
+ assert_eq!(entry.phase, "dbt-compile");
+ // A line that measures no phase carries no timing, rather than a `0ms` it would
+ // read as having measured one.
+ assert_eq!(entry.duration, "");
+ // Trailing whitespace only: this is the failure text itself, printed once, and a
+ // dbt compile error means its line breaks — unlike a poll label, which repeats.
+ assert_eq!(entry.message, "Parsing dbt project");
+ assert!(!entry.error);
+ // A null phase — the endpoint's shape for a line that belongs to none — stays
+ // blank, so the line cannot render as empty brackets.
+ assert!(log_entry(&json!({"phase": null})).phase.is_empty());
+ assert!(log_entry(&json!({"phase": " "})).phase.is_empty());
+ // Failure levels colour the line; anything else is left plain rather than
+ // announcing a failure the server never reported.
+ for level in ["error", "ERROR", " Fatal ", "critical"] {
+ assert!(log_entry(&json!({"level": level})).error, "{level}");
+ }
+ for level in ["warn", "WARNING", "info", "", " "] {
+ assert!(!log_entry(&json!({"level": level})).error, "{level}");
+ }
+ }
+
+ #[test]
+ fn a_log_line_carries_the_phase_and_its_timing() {
+ let line = log_line(&json!({
+ "timestamp": "2026-08-24T10:00:01Z",
+ "level": "info",
+ "phase": "dbt-compile",
+ "stream": "system",
+ "message": "dbt compile finished",
+ "durationMs": 1_200
+ }));
+ assert!(line.contains("2026-08-24T10:00:01Z"), "{line}");
+ // One bracket for both, so a multi-line failure below is interrupted by neither.
+ assert!(line.contains("[dbt-compile 1s]"), "{line}");
+ // Uncoloured, so the text arrives verbatim rather than wrapped.
+ assert!(line.ends_with("dbt compile finished"), "{line}");
+ // Either half alone still reads, and neither absent leaves a hole.
+ assert!(log_line(&json!({"phase": "dbt-deps", "message": "x"})).contains("[dbt-deps]"));
+ assert!(log_line(&json!({"durationMs": 340, "message": "x"})).contains("[340ms]"));
+ let bare = log_line(&json!({"phase": " ", "durationMs": null, "message": "x"}));
+ assert!(!bare.contains('['), "no empty brackets: {bare}");
+ assert!(bare.ends_with('x'), "{bare}");
+ }
+
+ #[test]
+ fn an_entry_whose_text_this_build_cannot_find_is_shown_as_it_arrived() {
+ // Exactly the entry, once: the JSON already carries whatever a prefix would
+ // repeat, so it is printed alone rather than after a timestamp and a phase.
+ let unknown = json!({"ts": "2026-08-24T10:00:01Z", "detail": "a newer shape"});
+ assert_eq!(log_line(&unknown), unknown.to_string());
+ // A failure keeps its colour even here — the entry this build understood least is
+ // the last place to drop the signal that it is one.
+ let failed = json!({"level": "error", "detail": "no message field"});
+ let line = log_line(&failed);
+ assert!(line.contains(&failed.to_string()), "{line}");
+ assert_ne!(line, failed.to_string(), "still coloured: {line}");
+ }
+
+ #[test]
+ fn server_text_cannot_drive_the_terminal() {
+ // dbt output and warehouse errors are text this CLI did not write, and an ESC
+ // sequence in one can retitle a window, move the cursor, or overwrite the lines
+ // above it in a CI log. The line breaks a compile error means are kept; the rest
+ // of the control characters are not.
+ let entry = log_entry(&json!({
+ "phase": "dbt-compile\u{1b}[2J",
+ "message": "Compilation Error\n\u{1b}]0;retitled\u{7} in model fct_orders\tx"
+ }));
+ assert_eq!(entry.phase, "dbt-compile[2J");
+ assert_eq!(
+ entry.message,
+ "Compilation Error\n]0;retitled in model fct_orders\tx"
+ );
+ // The same for a cell, which is one line as well: a newline in a value would
+ // otherwise break the row it sits in, and an unbounded one the whole layout.
+ let row = history_row(&json!({
+ "branchName": "dbt-sync/a\u{1b}[2J\nb",
+ "trigger": "T".repeat(500)
+ }));
+ assert_eq!(cell(&row, "BRANCH"), "dbt-sync/a[2J b");
+ let trigger = cell(&row, "TRIGGER");
+ assert!(trigger.ends_with('…'), "bounded: {trigger}");
+ assert_eq!(trigger.chars().count(), CELL_LIMIT + 1);
+ }
+
#[test]
fn only_nonempty_objects_are_results() {
assert!(available_result(None).is_none());
diff --git a/rust/cube-cli/src/util.rs b/rust/cube-cli/src/util.rs
index 4dc6cde86801d..b751c205aa683 100644
--- a/rust/cube-cli/src/util.rs
+++ b/rust/cube-cli/src/util.rs
@@ -178,6 +178,35 @@ pub fn nonempty_ref(s: &str) -> Result {
Ok(s.to_string())
}
+/// `nonempty` with a message specific to a LIST FILTER — `dbt history --status`, and
+/// anything that follows it — where an empty value is neither a filter nor nothing at
+/// all: `push` sends `status=`, and every runs / no runs / a complaint are three answers
+/// a server could reasonably give it.
+///
+/// The reachable case is the same one the two above were written for: a CI script whose
+/// `$STATUS` did not expand, where the run this refuses would otherwise have listed
+/// whatever the server made of an empty field.
+///
+/// Padding is TRIMMED here, where the two above deliberately keep it — see
+/// `branch_or_placeholder` for why they must. The difference is what the value is: a
+/// branch name is the caller's own, `--branch ' x '` can name a branch that really
+/// exists, and only the caller knows. A filter is one word out of a vocabulary the
+/// server publishes, and no member of it has a space in it — so padding cannot be
+/// meaningful, and passing it on lands in the very failure this argument's help text
+/// was written to prevent: an empty table that reads as an answer rather than as a
+/// value nothing could match. `$(jq -r …)` and a value read out of a file are the
+/// ordinary ways to acquire it.
+pub fn nonempty_filter(s: &str) -> Result {
+ if s.trim().is_empty() {
+ return Err(format!(
+ "{EMPTY_VALUE_REFUSED} selects nothing — and it is not dropped, but sent as an \
+ empty filter, leaving what that matches to the server rather than to you"
+ ));
+ }
+
+ Ok(s.trim().to_string())
+}
+
/// A branch name to PRINT, when the payload might not have carried one.
///
/// Only for prose and suggested commands, never for a JSON document: a gate reading
@@ -525,6 +554,21 @@ mod tests {
assert!(nonempty_ref("main").is_ok());
assert!(nonempty_ref("").is_err());
assert!(nonempty_ref("\t").is_err());
+ assert_eq!(nonempty_filter("FAILED").unwrap(), "FAILED");
+ assert!(nonempty_filter("").is_err());
+ assert!(nonempty_filter(" ").is_err());
+ // A filter is trimmed and a branch name is not, and the divergence is the point:
+ // no value in the server's filter vocabulary has a space in it, so ` FAILED`
+ // could only ever match nothing — while `--branch ' x '` can name a branch that
+ // exists, and the messages carrying that name also carry a command addressing it.
+ assert_eq!(nonempty_filter(" FAILED\n").unwrap(), "FAILED");
+ assert_eq!(nonempty(" main ").unwrap(), " main ");
+ assert_eq!(nonempty_ref(" main ").unwrap(), " main ");
+ // All three open with the phrase the command-tree walk partitions on, so a guard
+ // that ends up on a branch argument is recognised as one wherever it came from.
+ for refusal in [nonempty(""), nonempty_ref(""), nonempty_filter("")] {
+ assert!(refusal.unwrap_err().contains(EMPTY_VALUE_REFUSED));
+ }
}
#[test]
diff --git a/yarn.lock b/yarn.lock
index 08be06a73eb37..7640f218fbe07 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -11658,13 +11658,6 @@ cron-validator@^1.2.1:
resolved "https://registry.yarnpkg.com/cron-validator/-/cron-validator-1.2.1.tgz#0f0de2de36d231a6ace0e43ffc6c0564fe6edf1a"
integrity sha512-RqdpGSokGFICPc8qAkT38aXqZLLanXghQTK2q7a2x2FabSwDd2ARrazd5ElEWAXzToUcMG4cZIwDH+5RM0q1mA==
-cross-fetch@^3.0.2:
- version "3.1.5"
- resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f"
- integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==
- dependencies:
- node-fetch "2.6.7"
-
cross-spawn@^6.0.0, cross-spawn@^6.0.5:
version "6.0.6"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.6.tgz#30d0efa0712ddb7eb5a76e1e8721bffafa6b5d57"
@@ -18382,13 +18375,6 @@ node-fetch@2, node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.6, node-fetc
dependencies:
whatwg-url "^5.0.0"
-node-fetch@2.6.7:
- version "2.6.7"
- resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
- integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
- dependencies:
- whatwg-url "^5.0.0"
-
node-fetch@^1.0.1:
version "1.7.3"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef"