mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
Initial open-source release
This commit is contained in:
commit
1a42152e6f
1199 changed files with 257054 additions and 0 deletions
72
.github/workflows/ci.yml
vendored
Normal file
72
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
name: KLO CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: klo-ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "pnpm-lock.yaml"
|
||||
|
||||
- name: Install TypeScript dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run TypeScript checks
|
||||
run: pnpm run check
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: uv sync --all-packages
|
||||
|
||||
- name: Run Python checks
|
||||
run: uv run pytest
|
||||
|
||||
- name: Build and verify package artifacts
|
||||
run: pnpm run artifacts:check
|
||||
|
||||
- name: Upload package artifacts
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: klo-package-artifacts-${{ github.sha }}
|
||||
path: |
|
||||
dist/artifacts/manifest.json
|
||||
dist/artifacts/npm/*.tgz
|
||||
dist/artifacts/python/*.whl
|
||||
dist/artifacts/python/*.tar.gz
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
61
.gitignore
vendored
Normal file
61
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.so
|
||||
.Python
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
coverage/
|
||||
htmlcov/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
.hypothesis/
|
||||
|
||||
# Secrets and local environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
*.crt
|
||||
*.cert
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
.npm/
|
||||
.pnpm-store/
|
||||
*.tsbuildinfo
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Local project runtime state
|
||||
.klo/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
!packages/cli/assets/demo/orbit/demo.db
|
||||
!packages/context/test/fixtures/relationship-benchmarks/**/data.sqlite
|
||||
|
||||
# Private local agent overlays
|
||||
.agents/
|
||||
.claude/
|
||||
|
||||
# Editors and OS files
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
251
AGENTS.md
Normal file
251
AGENTS.md
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
# KTX Development Notes
|
||||
|
||||
KTX is a standalone open-source context layer for database agents. These
|
||||
instructions apply to all agents working in this repository (Codex, Claude,
|
||||
Gemini, and similar tools). Do not assume an external app server, frontend,
|
||||
database migrations, ORPC contracts, or `python-service/` layout exist here.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
### Absolute Requirements
|
||||
|
||||
- **MUST**: Use the active agent's task tracker for tasks with 3+ steps or
|
||||
complex operations (`TodoWrite` in Claude, `update_plan` in Codex).
|
||||
- **MUST**: Read files before editing them.
|
||||
- **MUST**: Complete all tracked tasks before finishing.
|
||||
- **MUST**: Activate `.venv` before running Python code when a local virtualenv
|
||||
exists. If no `.venv` exists, use `uv run ...` from the relevant project root.
|
||||
- **MUST**: After modifying Python files, run the relevant Python tests and run
|
||||
`uv run pre-commit run --files [FILES]` when a pre-commit config exists. If
|
||||
pre-commit cannot run because config or tool versions are missing, state that
|
||||
explicitly and run the closest available checks.
|
||||
- **MUST**: Remove dead code; do not leave commented-out code, unused wrappers,
|
||||
or empty directories.
|
||||
- **MUST**: Keep package/public API changes intentional. Do not add compatibility
|
||||
wrappers for old KLO names unless the user explicitly asks for a migration
|
||||
bridge.
|
||||
|
||||
### Absolute Prohibitions
|
||||
|
||||
- **MUST NOT**: Use raw `pip`; use `uv`.
|
||||
- **MUST NOT**: Use `npm` or `bun`; use `pnpm`.
|
||||
- **MUST NOT**: Run destructive git cleanup commands (`git clean`,
|
||||
`git reset --hard`, `git checkout .`) unless the user explicitly requested
|
||||
that exact operation.
|
||||
- **MUST NOT**: Run `git stash`, `git stash pop`, `git stash apply`, or
|
||||
`git stash drop` without explicit user instruction. Prefer a branch plus
|
||||
commit when the user asks to save work in progress.
|
||||
- **MUST NOT**: Reintroduce external app conventions such as ORPC contracts,
|
||||
NestJS controllers, frontend routes, `routeTree.gen.ts`, or app database
|
||||
migration commands unless those systems are intentionally added to KTX later.
|
||||
|
||||
### Language Convention
|
||||
|
||||
- **MUST**: Absolute requirement, never deviate.
|
||||
- **MUST NOT**: Absolute prohibition.
|
||||
- **SHOULD**: Strong recommendation, deviate only with good reason.
|
||||
- **MAY**: Optional, at agent's discretion.
|
||||
|
||||
## Priority Hierarchy
|
||||
|
||||
When rules conflict, follow this order:
|
||||
|
||||
1. Safety and user intent
|
||||
2. Correctness: code works and verification passes
|
||||
3. Single source of truth and DRY design
|
||||
4. Code quality: types, readable boundaries, focused modules
|
||||
5. Performance where it matters
|
||||
|
||||
## Repository Shape
|
||||
|
||||
KTX is a pnpm + uv workspace.
|
||||
|
||||
- TypeScript packages: `packages/*`
|
||||
- CLI package: `packages/cli`
|
||||
- Core context package: `packages/context`
|
||||
- LLM package: `packages/llm`
|
||||
- Database connectors: `packages/connector-*`
|
||||
- Python semantic layer: `python/klo-sl`
|
||||
- Python daemon: `python/klo-daemon`
|
||||
- Examples and fixtures: `examples/`
|
||||
- Workspace scripts: `scripts/`
|
||||
- Local agent skills are private overlays. Do not commit `.agents/` or
|
||||
`.claude/` to this public repository.
|
||||
|
||||
Some package names still contain `klo` during the split. Do not mass-rename
|
||||
symbols, package names, paths, or docs to `ktx` unless the task asks for that
|
||||
rename.
|
||||
|
||||
## Quick Commands
|
||||
|
||||
### TypeScript Workspace
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run build
|
||||
pnpm run type-check
|
||||
pnpm run test
|
||||
pnpm run check
|
||||
pnpm --filter @klo/cli run smoke
|
||||
pnpm --filter './packages/*' run build
|
||||
pnpm --filter './packages/*' run test
|
||||
pnpm --filter './packages/*' run type-check
|
||||
```
|
||||
|
||||
### Python Workspace
|
||||
|
||||
```bash
|
||||
uv sync --all-groups
|
||||
uv run pytest -q
|
||||
uv run pytest python/klo-sl/tests -q
|
||||
uv run pytest python/klo-daemon/tests -q
|
||||
uv run pre-commit run --files [FILES]
|
||||
```
|
||||
|
||||
If `pyproject.toml` pins a newer `uv` than the local binary, do not edit the
|
||||
pin just to make checks pass. Report the version mismatch and run checks that
|
||||
do not require changing project configuration.
|
||||
|
||||
### CLI and Release Checks
|
||||
|
||||
```bash
|
||||
pnpm run setup:dev
|
||||
pnpm run link:dev
|
||||
pnpm run artifacts:verify
|
||||
pnpm run release:readiness
|
||||
pnpm run release:published-smoke
|
||||
```
|
||||
|
||||
## Verification After Changes
|
||||
|
||||
Choose the smallest checks that cover the changed surface, then broaden when
|
||||
shared contracts or package exports are affected.
|
||||
|
||||
- TypeScript package code: `pnpm --filter <package> run type-check` and
|
||||
`pnpm --filter <package> run test`
|
||||
- Cross-package TypeScript changes: `pnpm run type-check` and `pnpm run test`
|
||||
- Build/export changes: `pnpm run build`
|
||||
- Workspace scripts: `node --test scripts/*.test.mjs` or the specific script
|
||||
test file
|
||||
- Python semantic layer: `uv run pytest python/klo-sl/tests -q`
|
||||
- Python daemon: `uv run pytest python/klo-daemon/tests -q`
|
||||
- Python files: also run `uv run pre-commit run --files [FILES]` when
|
||||
pre-commit is configured
|
||||
|
||||
For test suites that take a while, capture full output once and inspect that
|
||||
file instead of rerunning to apply different filters:
|
||||
|
||||
```bash
|
||||
pnpm run test 2>&1 | tee /tmp/ktx-test-output.log
|
||||
```
|
||||
|
||||
## TypeScript Standards
|
||||
|
||||
- Use Node 22+ and pnpm workspace commands.
|
||||
- Keep packages ESM (`"type": "module"`) and preserve `NodeNext` TypeScript
|
||||
semantics.
|
||||
- Prefer strict types over `any`; do not use `as unknown as`.
|
||||
- Keep package exports, `types`, and built `dist` expectations aligned when
|
||||
changing public APIs.
|
||||
- Use `zod` schemas for runtime validation at CLI/config/API boundaries.
|
||||
- Keep connector packages thin: connector-specific scanning/auth behavior
|
||||
belongs in `packages/connector-*`; shared types and orchestration belong in
|
||||
`packages/context`.
|
||||
- Avoid circular package dependencies. Shared code should move to the lowest
|
||||
sensible package, not be duplicated across connectors.
|
||||
- Do not manually edit generated or built output under `dist/`; edit source and
|
||||
rebuild.
|
||||
|
||||
### Zod Naming Convention
|
||||
|
||||
```typescript
|
||||
const userSchema = z.object({
|
||||
id: z.uuid(),
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
type User = z.infer<typeof userSchema>;
|
||||
```
|
||||
|
||||
Runtime schemas use `camelCase` plus the `Schema` suffix. Static inferred types
|
||||
use `PascalCase` without the suffix.
|
||||
|
||||
## Python Standards
|
||||
|
||||
- Use `pyproject.toml`; do not add `requirements.txt`.
|
||||
- Use type hints for new and changed Python code.
|
||||
- Use `pathlib` instead of `os.path`.
|
||||
- Use `logger.exception()` when catching and logging exceptions.
|
||||
- Prefer explicit exception types over broad `except Exception`.
|
||||
- Keep `python/klo-sl` focused on semantic-layer planning and SQL generation.
|
||||
- Keep `python/klo-daemon` focused on portable daemon/API behavior around the
|
||||
semantic layer.
|
||||
|
||||
### SQL and Structured Parsing
|
||||
|
||||
- Prefer AST-based parsing over regex for structured input.
|
||||
- For SQL, use `sqlglot`; it is already a dependency.
|
||||
- In `python/klo-sl`, follow the local `python/klo-sl/AGENTS.md` guidance:
|
||||
parse expressions with sqlglot, quote reserved identifiers before parsing,
|
||||
and generate postgres-shaped SQL before final dialect transpilation.
|
||||
- Regex may be used for non-structural sanitization, but not to interpret SQL
|
||||
structure.
|
||||
|
||||
## Documentation and Specs
|
||||
|
||||
- Keep public documentation in `README.md`, package READMEs, and example
|
||||
READMEs unless the repository intentionally adds a public docs tree.
|
||||
- Prefer concrete commands, file paths, and acceptance criteria over broad
|
||||
prose.
|
||||
- When documenting examples, ensure referenced files and commands exist in the
|
||||
standalone KTX tree.
|
||||
- Remove or rewrite stale external app references unless the doc is explicitly
|
||||
historical.
|
||||
|
||||
## LLM and Prompt Development
|
||||
|
||||
When creating or modifying agent prompts, system prompts, tool descriptions, or
|
||||
skills:
|
||||
|
||||
- Use XML tags for major structure when it helps model reliability:
|
||||
`<role>`, `<workflow>`, `<examples>`, `<success_criteria>`.
|
||||
- Use positive framing: tell the model what to do.
|
||||
- Keep prompts compact and avoid duplicating the same rule in multiple places.
|
||||
- Include 1-3 concrete examples when examples materially reduce ambiguity.
|
||||
- Use AI SDK v6 patterns for TypeScript LLM work.
|
||||
- Use the local `ai-sdk` skill when working with AI SDK code.
|
||||
|
||||
## Context7 and External Docs
|
||||
|
||||
- Use Context7 when official, current library documentation would materially
|
||||
reduce risk.
|
||||
- Context7 "Monthly quota exceeded" errors are often transient. Retry before
|
||||
assuming the quota is exhausted.
|
||||
- If Context7 remains unavailable, state the blocked lookup and use the best
|
||||
available local/source documentation.
|
||||
|
||||
## When to Ask vs Act
|
||||
|
||||
Act without asking when:
|
||||
|
||||
- Following explicit user instructions
|
||||
- Running verification
|
||||
- Fixing clear bugs or tool failures within the requested scope
|
||||
|
||||
Ask first when:
|
||||
|
||||
- Requirements are ambiguous
|
||||
- The next step is destructive or would discard user work
|
||||
- A breaking public API decision is not already implied by the task
|
||||
- Missing credentials, live services, or external accounts are required
|
||||
|
||||
## Git and Worktree Safety
|
||||
|
||||
- The worktree may contain unrelated user changes. Do not revert files you did
|
||||
not change unless explicitly asked.
|
||||
- Before committing, inspect `git status --short` and commit only intended
|
||||
files.
|
||||
- Do not commit ignored dependency/build artifacts such as `node_modules/`,
|
||||
`.venv/`, `dist/`, coverage output, or local databases unless the task
|
||||
explicitly concerns packaged artifacts.
|
||||
1
CLAUDE.md
Symbolic link
1
CLAUDE.md
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
AGENTS.md
|
||||
1
GEMINI.md
Symbolic link
1
GEMINI.md
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
AGENTS.md
|
||||
202
LICENSE
Normal file
202
LICENSE
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
270
README.md
Normal file
270
README.md
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
# KLO
|
||||
|
||||
KLO is a workspace-first context layer for database agents. It stores warehouse
|
||||
memory in a project directory, generates and validates semantic-layer YAML,
|
||||
indexes knowledge, scans database schemas, and exposes the result through a CLI
|
||||
and MCP server.
|
||||
|
||||
KLO projects are plain files: YAML, Markdown, SQLite state, and generated
|
||||
artifacts. You can inspect them, commit them, and serve them to any MCP client.
|
||||
|
||||
## What KLO provides
|
||||
|
||||
- Durable warehouse memory with semantic-layer sources and knowledge pages.
|
||||
- Native scan connectors for SQLite, Postgres, MySQL, ClickHouse, SQL Server,
|
||||
BigQuery, Snowflake, and PostHog.
|
||||
- Agentic ingest with provenance links, tool transcripts, and replay metadata.
|
||||
- Local semantic-layer query planning and optional query execution.
|
||||
- A stdio MCP server with tools for connections, knowledge, semantic-layer
|
||||
sources, ingest reports, and replay.
|
||||
|
||||
## Quick start
|
||||
|
||||
Run the pre-seeded demo from the repository root:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run setup:dev
|
||||
pnpm run klo -- setup demo --no-input
|
||||
pnpm run klo -- setup demo inspect
|
||||
```
|
||||
|
||||
The default demo uses packaged sample data and prebuilt context. It does not
|
||||
require API keys, network access, or an LLM provider.
|
||||
|
||||
To replay the packaged ingest run, use:
|
||||
|
||||
```bash
|
||||
pnpm run klo -- setup demo --mode replay --no-input
|
||||
```
|
||||
|
||||
To run the full agentic demo with an LLM provider, set a provider key for the
|
||||
current process:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_API_KEY=$YOUR_ANTHROPIC_API_KEY \
|
||||
pnpm run klo -- setup demo --mode full --no-input
|
||||
```
|
||||
|
||||
Interactive full-demo setup can prompt for a provider key without writing the
|
||||
key to `klo.yaml`.
|
||||
|
||||
## Build a local project
|
||||
|
||||
Create a project from the repository root:
|
||||
|
||||
```bash
|
||||
uv sync --all-packages
|
||||
source .venv/bin/activate
|
||||
|
||||
PROJECT_DIR="$(mktemp -d)/klo-demo"
|
||||
pnpm run klo -- init "$PROJECT_DIR" --name klo-demo
|
||||
```
|
||||
|
||||
Create a SQLite warehouse:
|
||||
|
||||
```bash
|
||||
python - "$PROJECT_DIR/demo.db" <<'PY'
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
conn = sqlite3.connect(sys.argv[1])
|
||||
conn.executescript("""
|
||||
DROP TABLE IF EXISTS accounts;
|
||||
CREATE TABLE accounts (
|
||||
account_id INTEGER PRIMARY KEY,
|
||||
account_name TEXT NOT NULL,
|
||||
segment TEXT NOT NULL,
|
||||
region TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO accounts VALUES
|
||||
(1, 'Acme Analytics', 'Mid-Market', 'NA'),
|
||||
(2, 'Beacon Bank', 'Enterprise', 'EMEA'),
|
||||
(3, 'Cobalt Coffee', 'SMB', 'NA'),
|
||||
(4, 'Delta Devices', 'Mid-Market', 'APAC'),
|
||||
(5, 'Evergreen Energy', 'Enterprise', 'NA');
|
||||
""")
|
||||
conn.close()
|
||||
PY
|
||||
```
|
||||
|
||||
Replace the generated `klo.yaml`:
|
||||
|
||||
```bash
|
||||
cat > "$PROJECT_DIR/klo.yaml" <<YAML
|
||||
project: klo-demo
|
||||
connections:
|
||||
warehouse:
|
||||
driver: sqlite
|
||||
path: $PROJECT_DIR/demo.db
|
||||
readonly: true
|
||||
storage:
|
||||
state: sqlite
|
||||
search: sqlite-fts5
|
||||
git:
|
||||
auto_commit: true
|
||||
author: "klo <klo@example.com>"
|
||||
memory:
|
||||
auto_commit: true
|
||||
YAML
|
||||
```
|
||||
|
||||
Write and validate a semantic-layer source:
|
||||
|
||||
```bash
|
||||
pnpm run klo -- sl write accounts --project-dir "$PROJECT_DIR" \
|
||||
--connection-id warehouse --yaml 'name: accounts
|
||||
table: accounts
|
||||
description: CRM accounts with segmentation attributes.
|
||||
grain:
|
||||
- account_id
|
||||
columns:
|
||||
- name: account_id
|
||||
type: number
|
||||
- name: account_name
|
||||
type: string
|
||||
- name: segment
|
||||
type: string
|
||||
- name: region
|
||||
type: string
|
||||
measures:
|
||||
- name: account_count
|
||||
expr: count(account_id)
|
||||
joins: []
|
||||
'
|
||||
|
||||
pnpm run klo -- sl validate accounts --project-dir "$PROJECT_DIR" \
|
||||
--connection-id warehouse
|
||||
```
|
||||
|
||||
Generate SQL and execute the query:
|
||||
|
||||
```bash
|
||||
pnpm run klo -- sl query --project-dir "$PROJECT_DIR" \
|
||||
--connection-id warehouse \
|
||||
--measure accounts.account_count \
|
||||
--dimension accounts.segment \
|
||||
--order-by accounts.account_count:desc \
|
||||
--limit 5 \
|
||||
--format sql
|
||||
|
||||
pnpm run klo -- sl query --project-dir "$PROJECT_DIR" \
|
||||
--connection-id warehouse \
|
||||
--measure accounts.account_count \
|
||||
--dimension accounts.segment \
|
||||
--order-by accounts.account_count:desc \
|
||||
--limit 5 \
|
||||
--execute \
|
||||
--max-rows 5
|
||||
```
|
||||
|
||||
List and test the warehouse connection:
|
||||
|
||||
```bash
|
||||
pnpm run klo -- connection list --project-dir "$PROJECT_DIR"
|
||||
pnpm run klo -- connection test warehouse --project-dir "$PROJECT_DIR"
|
||||
```
|
||||
|
||||
The connection test prints the configured driver and discovered table count:
|
||||
|
||||
```text
|
||||
Driver: sqlite
|
||||
Tables: 1
|
||||
```
|
||||
|
||||
### Scan the demo warehouse
|
||||
|
||||
Scan artifacts are written under
|
||||
`raw-sources/warehouse/live-database/<syncId>/` in the project directory.
|
||||
|
||||
```bash
|
||||
|
||||
SCAN_OUTPUT="$(pnpm run klo -- scan warehouse --project-dir "$PROJECT_DIR")"
|
||||
printf '%s\n' "$SCAN_OUTPUT"
|
||||
SCAN_RUN_ID="$(printf '%s\n' "$SCAN_OUTPUT" | awk '/^Run: / { print $2 }')"
|
||||
pnpm run klo -- scan status --project-dir "$PROJECT_DIR" "$SCAN_RUN_ID"
|
||||
pnpm run klo -- scan report --project-dir "$PROJECT_DIR" "$SCAN_RUN_ID"
|
||||
```
|
||||
|
||||
For non-SQLite drivers, prefer credential references such as `--url env:NAME`
|
||||
or `--url file:PATH` over literal credential URLs.
|
||||
|
||||
## Serve MCP
|
||||
|
||||
Start the Python compute daemon in one terminal:
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
uv run klo-daemon serve-http --host 127.0.0.1 --port 8765
|
||||
```
|
||||
|
||||
Start the stdio MCP server in another terminal:
|
||||
|
||||
```bash
|
||||
pnpm run klo -- serve --mcp stdio --project-dir "$PROJECT_DIR" \
|
||||
--user-id local \
|
||||
--semantic-compute-url http://127.0.0.1:8765 \
|
||||
--execute-queries
|
||||
```
|
||||
|
||||
The MCP server exposes `connection_list`, `knowledge_search`,
|
||||
`knowledge_read`, `knowledge_write`, `sl_list_sources`, `sl_read_source`,
|
||||
`sl_write_source`, `sl_validate`, `sl_query`, `ingest_trigger`,
|
||||
`ingest_status`, `ingest_report`, and `ingest_replay`.
|
||||
|
||||
## Workspace packages
|
||||
|
||||
- `packages/context`: core TypeScript context library.
|
||||
- `packages/cli`: CLI wrapper over the context package.
|
||||
- `packages/llm`: LLM and embedding provider helpers.
|
||||
- `packages/connector-bigquery`: BigQuery scan connector.
|
||||
- `packages/connector-clickhouse`: ClickHouse scan connector.
|
||||
- `packages/connector-mysql`: MySQL scan connector.
|
||||
- `packages/connector-postgres`: Postgres scan connector.
|
||||
- `packages/connector-posthog`: PostHog scan connector.
|
||||
- `packages/connector-snowflake`: Snowflake scan connector.
|
||||
- `packages/connector-sqlite`: SQLite scan connector.
|
||||
- `packages/connector-sqlserver`: SQL Server scan connector.
|
||||
- `python/klo-sl`: semantic-layer engine.
|
||||
- `python/klo-daemon`: portable compute service for semantic-layer operations.
|
||||
|
||||
## Development
|
||||
|
||||
Install dependencies and run checks:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run check
|
||||
uv sync --all-packages
|
||||
source .venv/bin/activate
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
Use the optional development binary when you want a local `klo-dev` command:
|
||||
|
||||
```bash
|
||||
pnpm run link:dev
|
||||
klo-dev --help
|
||||
```
|
||||
|
||||
The repository uses `pnpm` for TypeScript packages and `uv` for Python
|
||||
packages.
|
||||
|
||||
## Release status
|
||||
|
||||
This repository is prepared for source publication. Package publishing is still
|
||||
disabled by `release-policy.json`; registry names, public versions, package
|
||||
visibility, and provenance policy must be chosen before publishing artifacts to
|
||||
npm or Python package indexes.
|
||||
|
||||
Build local package artifacts with:
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
pnpm run artifacts:check
|
||||
pnpm run release:readiness
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
KLO is licensed under the Apache License, Version 2.0. See `LICENSE`.
|
||||
40
examples/README.md
Normal file
40
examples/README.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# klo examples
|
||||
|
||||
## local-warehouse
|
||||
|
||||
`local-warehouse/` is a runnable standalone KLO project for local CLI and MCP
|
||||
smoke testing. It uses the fake ingest adapter and does not require a database
|
||||
or external app server.
|
||||
|
||||
Copy it before running commands:
|
||||
|
||||
```bash
|
||||
pnpm --filter @klo/cli run build
|
||||
EXAMPLE_DIR="$(mktemp -d)/local-warehouse"
|
||||
cp -R examples/local-warehouse "$EXAMPLE_DIR"
|
||||
node packages/cli/dist/bin.js knowledge list --project-dir "$EXAMPLE_DIR"
|
||||
node packages/cli/dist/bin.js sl list --project-dir "$EXAMPLE_DIR" --connection-id warehouse
|
||||
node packages/cli/dist/bin.js ingest run --project-dir "$EXAMPLE_DIR" --connection-id warehouse --adapter fake --source-dir "$EXAMPLE_DIR/source"
|
||||
```
|
||||
|
||||
The copied project initializes its own Git repository on first use.
|
||||
|
||||
## orbit-relationship-verification
|
||||
|
||||
`orbit-relationship-verification/` is a checked-in KLO project used by
|
||||
`pnpm run relationships:verify-orbit`. It points the `orbit` SQLite connection
|
||||
at the Orbit-style no-declared-constraint relationship fixture and verifies that
|
||||
relationship enrichment writes nine accepted joins without requiring a local
|
||||
warehouse credential.
|
||||
|
||||
## postgres-historic
|
||||
|
||||
`postgres-historic/` is a manual Docker-backed smoke for Postgres
|
||||
historic-SQL ingest via `pg_stat_statements`. It verifies setup, first-run
|
||||
baseline creation, delta-only follow-up ingest, and reset handling without
|
||||
requiring a managed Postgres service.
|
||||
|
||||
## package-artifacts
|
||||
|
||||
`package-artifacts/` documents the artifact smoke checks. Those checks create
|
||||
temporary projects instead of storing sample projects in this directory.
|
||||
20
examples/local-warehouse/README.md
Normal file
20
examples/local-warehouse/README.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Local Warehouse Example
|
||||
|
||||
This example is a standalone KLO project that can be copied to a temp directory
|
||||
and used with the local CLI and stdio MCP server. It uses the `fake` ingest
|
||||
adapter so it does not require a database or external app server.
|
||||
|
||||
Run the example from the repository root after building the CLI:
|
||||
|
||||
```bash
|
||||
pnpm --filter @klo/cli run build
|
||||
EXAMPLE_DIR="$(mktemp -d)/local-warehouse"
|
||||
cp -R examples/local-warehouse "$EXAMPLE_DIR"
|
||||
node packages/cli/dist/bin.js knowledge list --project-dir "$EXAMPLE_DIR"
|
||||
node packages/cli/dist/bin.js sl list --project-dir "$EXAMPLE_DIR" --connection-id warehouse
|
||||
node packages/cli/dist/bin.js ingest run --project-dir "$EXAMPLE_DIR" --connection-id warehouse --adapter fake --source-dir "$EXAMPLE_DIR/source"
|
||||
```
|
||||
|
||||
The copied project creates its own Git repository on first use. Keep commands
|
||||
pointed at a copy when experimenting so the checked-in example fixture stays
|
||||
unchanged.
|
||||
25
examples/local-warehouse/klo.yaml
Normal file
25
examples/local-warehouse/klo.yaml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
project: local-warehouse
|
||||
connections:
|
||||
warehouse:
|
||||
driver: postgres
|
||||
readonly: true
|
||||
storage:
|
||||
state: sqlite
|
||||
search: sqlite-fts5
|
||||
git:
|
||||
auto_commit: true
|
||||
author: "klo <klo@example.com>"
|
||||
ingest:
|
||||
adapters:
|
||||
- fake
|
||||
- live-database
|
||||
agent:
|
||||
run_research:
|
||||
enabled: false
|
||||
max_iterations: 20
|
||||
default_toolset:
|
||||
- sl_query
|
||||
- knowledge_search
|
||||
- sl_read_source
|
||||
memory:
|
||||
auto_commit: true
|
||||
15
examples/local-warehouse/knowledge/global/revenue.md
Normal file
15
examples/local-warehouse/knowledge/global/revenue.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
summary: Paid order value after refunds
|
||||
tags:
|
||||
- finance
|
||||
- orders
|
||||
refs: []
|
||||
sl_refs:
|
||||
- warehouse.orders
|
||||
usage_mode: auto
|
||||
---
|
||||
|
||||
Revenue is paid order amount after refund adjustments.
|
||||
|
||||
Use `orders.total_revenue` for recognized order value and `orders.order_count`
|
||||
for paid order volume.
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
name: orders
|
||||
table: public.orders
|
||||
description: Orders placed through the storefront.
|
||||
grain:
|
||||
- id
|
||||
columns:
|
||||
- name: id
|
||||
type: number
|
||||
- name: status
|
||||
type: string
|
||||
- name: amount
|
||||
type: number
|
||||
measures:
|
||||
- name: order_count
|
||||
expr: count(*)
|
||||
- name: total_revenue
|
||||
expr: sum(amount)
|
||||
joins: []
|
||||
1
examples/local-warehouse/source/orders/orders.json
Normal file
1
examples/local-warehouse/source/orders/orders.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"source":"orders","description":"Example raw file staged by the fake adapter"}
|
||||
33
examples/orbit-relationship-verification/README.md
Normal file
33
examples/orbit-relationship-verification/README.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Orbit-style relationship discovery verification
|
||||
|
||||
This KLO project backs the default `relationships:verify-orbit` command. It uses
|
||||
the checked-in Orbit-style SQLite fixture from the relationship discovery
|
||||
benchmark corpus, with no declared primary keys or foreign keys in the database
|
||||
schema.
|
||||
|
||||
Run from the KLO workspace root:
|
||||
|
||||
```bash
|
||||
pnpm run relationships:verify-orbit
|
||||
```
|
||||
|
||||
Expected relationship summary:
|
||||
|
||||
```text
|
||||
Accepted: 9
|
||||
Review: 0
|
||||
Rejected: 0
|
||||
Skipped: 0
|
||||
```
|
||||
|
||||
The command refreshes:
|
||||
|
||||
```text
|
||||
examples/orbit-relationship-verification/reports/orbit-verification.md
|
||||
```
|
||||
|
||||
Use a real local Orbit project by overriding the project directory:
|
||||
|
||||
```bash
|
||||
KLO_ORBIT_PROJECT_DIR=/path/to/orbit-project pnpm run relationships:verify-orbit
|
||||
```
|
||||
28
examples/orbit-relationship-verification/klo.yaml
Normal file
28
examples/orbit-relationship-verification/klo.yaml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
project: orbit-relationship-verification
|
||||
connections:
|
||||
orbit:
|
||||
driver: sqlite
|
||||
path: ../../packages/context/test/fixtures/relationship-benchmarks/orbit_style_product_no_declared_constraints/data.sqlite
|
||||
readonly: true
|
||||
storage:
|
||||
state: sqlite
|
||||
search: sqlite-fts5
|
||||
git:
|
||||
auto_commit: true
|
||||
author: "klo <klo@example.com>"
|
||||
ingest:
|
||||
adapters:
|
||||
- live-database
|
||||
scan:
|
||||
enrichment:
|
||||
backend: none
|
||||
relationships:
|
||||
enabled: true
|
||||
llm_proposals: false
|
||||
validation_required_for_manifest: true
|
||||
accept_threshold: 0.85
|
||||
review_threshold: 0.55
|
||||
max_llm_tables_per_batch: 40
|
||||
max_candidates_per_column: 25
|
||||
profile_sample_rows: 10000
|
||||
validation_concurrency: 4
|
||||
17
examples/package-artifacts/README.md
Normal file
17
examples/package-artifacts/README.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Package artifact smoke checks
|
||||
|
||||
The package artifact smoke checks create temporary projects instead of storing
|
||||
sample projects in this directory. Run the checks from `klo/`:
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
pnpm run artifacts:check
|
||||
```
|
||||
|
||||
The npm smoke project installs the generated `@klo/context` and `@klo/cli`
|
||||
tarballs, imports public package entry points, and runs installed `klo`
|
||||
commands against a generated local project.
|
||||
|
||||
The Python smoke project installs `klo-daemon` through the local artifact
|
||||
directory, imports `semantic_layer` and `klo_daemon`, and runs
|
||||
`python -m klo_daemon semantic-validate`.
|
||||
115
examples/postgres-historic/README.md
Normal file
115
examples/postgres-historic/README.md
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# Postgres Historic SQL Example
|
||||
|
||||
This example is a manual smoke for Postgres historic-SQL ingest through
|
||||
`pg_stat_statements`. It starts Postgres 14 with the extension preloaded,
|
||||
generates query workload under separate users, runs `klo setup` with
|
||||
`--enable-historic-sql`, and verifies three local ingest runs:
|
||||
|
||||
- first run creates a fresh PGSS baseline
|
||||
- second run emits only positive deltas
|
||||
- reset run treats `pg_stat_statements_reset()` as a fresh baseline
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker with Compose v2
|
||||
- Node and pnpm matching the KLO workspace
|
||||
- `python-service/.venv` already created, or `KLO_SQL_ANALYSIS_URL` pointing at
|
||||
a running service that exposes `/api/sql/analyze-for-fingerprint`
|
||||
|
||||
## Run
|
||||
|
||||
From the KLO repository root:
|
||||
|
||||
```bash
|
||||
examples/postgres-historic/scripts/smoke.sh
|
||||
```
|
||||
|
||||
The smoke creates a temporary KLO project, starts Postgres on
|
||||
`127.0.0.1:55432`, and uses this connection URL:
|
||||
|
||||
```bash
|
||||
postgresql://klo_reader:klo_reader@127.0.0.1:55432/analytics # pragma: allowlist secret
|
||||
```
|
||||
|
||||
Set `KLO_POSTGRES_HISTORIC_KEEP_DOCKER=1` to leave the container running after
|
||||
the script exits.
|
||||
|
||||
The smoke validates the historic-SQL raw snapshot path without requiring LLM
|
||||
credentials. It uses KLO's local stage-only ingest API after `klo setup` so the
|
||||
PGSS baseline and delta behavior can be checked independently from curation.
|
||||
|
||||
## Manual Commands
|
||||
|
||||
Start Postgres and generate the base workload:
|
||||
|
||||
```bash
|
||||
docker compose -f examples/postgres-historic/docker-compose.yml up -d --wait
|
||||
examples/postgres-historic/scripts/generate-workload.sh base
|
||||
```
|
||||
|
||||
Create a project and enable historic SQL:
|
||||
|
||||
```bash
|
||||
export WAREHOUSE_DATABASE_URL=postgresql://klo_reader:klo_reader@127.0.0.1:55432/analytics # pragma: allowlist secret
|
||||
pnpm --filter @klo/cli run build
|
||||
node packages/cli/dist/bin.js --project-dir /tmp/klo-postgres-historic setup \
|
||||
--new \
|
||||
--skip-agents \
|
||||
--skip-llm \
|
||||
--skip-embeddings \
|
||||
--skip-sources \
|
||||
--database postgres \
|
||||
--new-database-connection-id warehouse \
|
||||
--database-url env:WAREHOUSE_DATABASE_URL \
|
||||
--database-schema public \
|
||||
--enable-historic-sql \
|
||||
--historic-sql-min-calls 2 \
|
||||
--yes \
|
||||
--no-input
|
||||
```
|
||||
|
||||
### Readiness check
|
||||
|
||||
```bash
|
||||
pnpm run klo -- dev doctor --project-dir /tmp/klo-postgres-historic --no-input
|
||||
```
|
||||
|
||||
The installed CLI form is `klo dev doctor --project-dir
|
||||
/tmp/klo-postgres-historic --no-input`. Expected output includes `PASS Postgres
|
||||
Historic SQL (warehouse)` when `pg_stat_statements` is installed,
|
||||
`pg_read_all_stats` is granted, tracking is enabled, and
|
||||
`pg_stat_statements.max` is at least 5000.
|
||||
|
||||
Run local historic-SQL ingest:
|
||||
|
||||
```bash
|
||||
node packages/cli/dist/bin.js --project-dir /tmp/klo-postgres-historic dev ingest run \
|
||||
--connection-id warehouse \
|
||||
--adapter historic-sql \
|
||||
--plain \
|
||||
--no-input
|
||||
```
|
||||
|
||||
The full `dev ingest run` path also runs curation work units, so it requires a
|
||||
configured LLM provider.
|
||||
|
||||
Inspect the latest manifest:
|
||||
|
||||
```bash
|
||||
find /tmp/klo-postgres-historic/raw-sources/warehouse/historic-sql -name manifest.json | sort | tail -n 1
|
||||
```
|
||||
|
||||
The manifest should have `dialect: "postgres"`, `degraded: true`,
|
||||
`baselineFirstRun: true` on the first run, and populated `pgServerVersion` and
|
||||
`statsResetAt`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Missing extension: confirm `shared_preload_libraries=pg_stat_statements` and
|
||||
`CREATE EXTENSION pg_stat_statements;` both happened in the `analytics`
|
||||
database.
|
||||
- Missing grants: confirm `GRANT pg_read_all_stats TO klo_reader;`.
|
||||
- Empty templates: rerun `scripts/generate-workload.sh base` and keep
|
||||
`--historic-sql-min-calls 2` for the smoke.
|
||||
- SQL-analysis failures: set `KLO_SQL_ANALYSIS_URL` to the running service URL
|
||||
or create `python-service/.venv` before running `scripts/smoke.sh`.
|
||||
24
examples/postgres-historic/docker-compose.yml
Normal file
24
examples/postgres-historic/docker-compose.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
services:
|
||||
postgres:
|
||||
image: postgres:14
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- shared_preload_libraries=pg_stat_statements
|
||||
- -c
|
||||
- pg_stat_statements.track=top
|
||||
- -c
|
||||
- pg_stat_statements.max=10000
|
||||
environment:
|
||||
POSTGRES_DB: analytics
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres # pragma: allowlist secret
|
||||
ports:
|
||||
- "55432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d analytics"]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
volumes:
|
||||
- ./init:/docker-entrypoint-initdb.d:ro
|
||||
51
examples/postgres-historic/init/001-schema.sql
Normal file
51
examples/postgres-historic/init/001-schema.sql
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
|
||||
|
||||
CREATE ROLE app_user LOGIN PASSWORD 'app_pass';
|
||||
CREATE ROLE etl_user LOGIN PASSWORD 'etl_pass';
|
||||
CREATE ROLE klo_reader LOGIN PASSWORD 'klo_reader';
|
||||
|
||||
GRANT pg_read_all_stats TO klo_reader;
|
||||
|
||||
CREATE TABLE customers (
|
||||
id integer PRIMARY KEY,
|
||||
region text NOT NULL,
|
||||
plan text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE orders (
|
||||
id integer PRIMARY KEY,
|
||||
customer_id integer NOT NULL REFERENCES customers(id),
|
||||
status text NOT NULL,
|
||||
total numeric(12, 2) NOT NULL,
|
||||
created_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE events (
|
||||
id integer PRIMARY KEY,
|
||||
customer_id integer NOT NULL REFERENCES customers(id),
|
||||
event_name text NOT NULL,
|
||||
occurred_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO customers (id, region, plan) VALUES
|
||||
(1, 'na', 'enterprise'),
|
||||
(2, 'na', 'team'),
|
||||
(3, 'eu', 'enterprise'),
|
||||
(4, 'apac', 'team');
|
||||
|
||||
INSERT INTO orders (id, customer_id, status, total, created_at) VALUES
|
||||
(1, 1, 'paid', 125.50, now() - interval '9 days'),
|
||||
(2, 1, 'paid', 89.00, now() - interval '4 days'),
|
||||
(3, 2, 'pending', 42.00, now() - interval '2 days'),
|
||||
(4, 3, 'paid', 301.25, now() - interval '1 day'),
|
||||
(5, 4, 'refunded', 77.70, now() - interval '3 hours');
|
||||
|
||||
INSERT INTO events (id, customer_id, event_name, occurred_at) VALUES
|
||||
(1, 1, 'dashboard_viewed', now() - interval '1 day'),
|
||||
(2, 1, 'export_started', now() - interval '8 hours'),
|
||||
(3, 2, 'dashboard_viewed', now() - interval '7 hours'),
|
||||
(4, 3, 'sync_completed', now() - interval '6 hours'),
|
||||
(5, 4, 'dashboard_viewed', now() - interval '5 hours');
|
||||
|
||||
GRANT USAGE ON SCHEMA public TO app_user, etl_user, klo_reader;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_user, etl_user, klo_reader;
|
||||
33
examples/postgres-historic/scripts/generate-workload.sh
Executable file
33
examples/postgres-historic/scripts/generate-workload.sh
Executable file
|
|
@ -0,0 +1,33 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
EXAMPLE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
COMPOSE_FILE="$EXAMPLE_DIR/docker-compose.yml"
|
||||
MODE="${1:-base}"
|
||||
|
||||
run_sql() {
|
||||
local user="$1"
|
||||
local password="$2"
|
||||
local sql="$3"
|
||||
docker compose -f "$COMPOSE_FILE" exec -T -e PGPASSWORD="$password" postgres \
|
||||
psql -h 127.0.0.1 -U "$user" -d analytics -v ON_ERROR_STOP=1 -c "$sql" >/dev/null
|
||||
}
|
||||
|
||||
for _ in $(seq 1 12); do
|
||||
run_sql app_user app_pass "SELECT c.region, count(*) AS order_count FROM orders o JOIN customers c ON c.id = o.customer_id WHERE o.status = 'paid' GROUP BY c.region ORDER BY c.region"
|
||||
done
|
||||
|
||||
for _ in $(seq 1 7); do
|
||||
run_sql app_user app_pass "SELECT c.plan, sum(o.total) AS revenue FROM orders o JOIN customers c ON c.id = o.customer_id WHERE o.created_at >= now() - interval '14 days' GROUP BY c.plan ORDER BY revenue DESC"
|
||||
done
|
||||
|
||||
for _ in $(seq 1 5); do
|
||||
run_sql etl_user etl_pass "SELECT e.event_name, count(*) AS event_count FROM events e JOIN customers c ON c.id = e.customer_id WHERE c.region = 'na' GROUP BY e.event_name ORDER BY event_count DESC"
|
||||
done
|
||||
|
||||
if [[ "$MODE" == "extra" ]]; then
|
||||
for _ in $(seq 1 4); do
|
||||
run_sql etl_user etl_pass "SELECT c.region, avg(o.total) AS avg_total FROM orders o JOIN customers c ON c.id = o.customer_id WHERE o.status <> 'refunded' GROUP BY c.region ORDER BY avg_total DESC"
|
||||
done
|
||||
fi
|
||||
152
examples/postgres-historic/scripts/smoke.sh
Executable file
152
examples/postgres-historic/scripts/smoke.sh
Executable file
|
|
@ -0,0 +1,152 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
EXAMPLE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
KLO_ROOT="$(cd "$EXAMPLE_DIR/../.." && pwd)"
|
||||
REPO_ROOT="$(cd "$KLO_ROOT/.." && pwd)"
|
||||
COMPOSE_FILE="$EXAMPLE_DIR/docker-compose.yml"
|
||||
PROJECT_PARENT="${KLO_POSTGRES_HISTORIC_PROJECT_PARENT:-$(mktemp -d)}"
|
||||
PROJECT_DIR="$PROJECT_PARENT/postgres-historic-klo"
|
||||
KLO_BIN="$KLO_ROOT/packages/cli/dist/bin.js"
|
||||
PYTHON_SERVICE_LOG="$PROJECT_PARENT/python-service.log"
|
||||
PYTHON_SERVICE_PID=""
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "$PYTHON_SERVICE_PID" ]]; then
|
||||
kill "$PYTHON_SERVICE_PID" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ "${KLO_POSTGRES_HISTORIC_KEEP_DOCKER:-0}" != "1" ]]; then
|
||||
docker compose -f "$COMPOSE_FILE" down -v >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
start_sql_analysis_if_needed() {
|
||||
if [[ -n "${KLO_SQL_ANALYSIS_URL:-}" ]]; then
|
||||
return
|
||||
fi
|
||||
if [[ ! -d "$REPO_ROOT/python-service/.venv" ]]; then
|
||||
echo "Set KLO_SQL_ANALYSIS_URL or create python-service/.venv before running this smoke." >&2
|
||||
exit 1
|
||||
fi
|
||||
(
|
||||
cd "$REPO_ROOT/python-service"
|
||||
source .venv/bin/activate
|
||||
uvicorn app.main:app --host 127.0.0.1 --port 18081 >"$PYTHON_SERVICE_LOG" 2>&1
|
||||
) &
|
||||
PYTHON_SERVICE_PID="$!"
|
||||
export KLO_SQL_ANALYSIS_URL="http://127.0.0.1:18081"
|
||||
for _ in $(seq 1 60); do
|
||||
if curl -fsS "$KLO_SQL_ANALYSIS_URL/health" >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "SQL analysis service did not become healthy. Log: $PYTHON_SERVICE_LOG" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
latest_manifest() {
|
||||
find "$PROJECT_DIR/raw-sources/warehouse/historic-sql" -name manifest.json | sort | tail -n 1
|
||||
}
|
||||
|
||||
assert_manifest() {
|
||||
local manifest_path="$1"
|
||||
local expected_first_run="$2"
|
||||
node - "$manifest_path" "$expected_first_run" <<'NODE'
|
||||
const { readFileSync } = require('node:fs');
|
||||
const manifestPath = process.argv[2];
|
||||
const expectedFirstRun = process.argv[3] === 'true';
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
||||
if (manifest.dialect !== 'postgres') throw new Error(`Expected dialect postgres, got ${manifest.dialect}`);
|
||||
if (manifest.degraded !== true) throw new Error('Expected degraded:true for Postgres PGSS v1');
|
||||
if (manifest.baselineFirstRun !== expectedFirstRun) {
|
||||
throw new Error(`Expected baselineFirstRun:${expectedFirstRun}, got ${manifest.baselineFirstRun}`);
|
||||
}
|
||||
if (!manifest.pgServerVersion) throw new Error('Expected pgServerVersion');
|
||||
if (!manifest.statsResetAt) throw new Error('Expected statsResetAt');
|
||||
if (!Array.isArray(manifest.templates) || manifest.templates.length === 0) {
|
||||
throw new Error('Expected at least one staged historic-SQL template');
|
||||
}
|
||||
NODE
|
||||
}
|
||||
|
||||
run_historic_stage_only() {
|
||||
local job_id="$1"
|
||||
node - "$KLO_ROOT" "$PROJECT_DIR" "$job_id" <<'NODE'
|
||||
const { join } = await import('node:path');
|
||||
|
||||
const kloRoot = process.argv[2];
|
||||
const projectDir = process.argv[3];
|
||||
const jobId = process.argv[4];
|
||||
const { loadKloProject } = await import(join(kloRoot, 'packages/context/dist/project/index.js'));
|
||||
const { runLocalStageOnlyIngest } = await import(join(kloRoot, 'packages/context/dist/ingest/index.js'));
|
||||
const { createKloCliLocalIngestAdapters } = await import(join(kloRoot, 'packages/cli/dist/local-adapters.js'));
|
||||
|
||||
const project = await loadKloProject({ projectDir });
|
||||
const adapters = createKloCliLocalIngestAdapters(project, { historicSqlConnectionId: 'warehouse' });
|
||||
const adapter = adapters.find((candidate) => candidate.source === 'historic-sql');
|
||||
if (!adapter) throw new Error('historic-sql adapter was not registered for local run');
|
||||
const record = await runLocalStageOnlyIngest({
|
||||
project,
|
||||
adapters,
|
||||
adapter: 'historic-sql',
|
||||
connectionId: 'warehouse',
|
||||
trigger: 'manual_resync',
|
||||
jobId,
|
||||
});
|
||||
await adapter.onPullSucceeded?.({
|
||||
connectionId: 'warehouse',
|
||||
sourceKey: 'historic-sql',
|
||||
syncId: record.syncId,
|
||||
trigger: 'manual_resync',
|
||||
completedAt: new Date(record.completedAt),
|
||||
stagedDir: join(project.projectDir, '.klo/cache/local-ingest', jobId, 'staged'),
|
||||
});
|
||||
console.log(record.syncId);
|
||||
NODE
|
||||
}
|
||||
|
||||
cd "$KLO_ROOT"
|
||||
pnpm --filter @klo/context run build
|
||||
pnpm --filter @klo/cli run build
|
||||
start_sql_analysis_if_needed
|
||||
|
||||
docker compose -f "$COMPOSE_FILE" up -d --wait
|
||||
"$EXAMPLE_DIR/scripts/generate-workload.sh" base
|
||||
|
||||
export WAREHOUSE_DATABASE_URL="${WAREHOUSE_DATABASE_URL:-postgresql://klo_reader:klo_reader@127.0.0.1:55432/analytics}" # pragma: allowlist secret
|
||||
node "$KLO_BIN" --project-dir "$PROJECT_DIR" setup \
|
||||
--new \
|
||||
--skip-agents \
|
||||
--skip-llm \
|
||||
--skip-embeddings \
|
||||
--skip-sources \
|
||||
--database postgres \
|
||||
--new-database-connection-id warehouse \
|
||||
--database-url env:WAREHOUSE_DATABASE_URL \
|
||||
--database-schema public \
|
||||
--enable-historic-sql \
|
||||
--historic-sql-min-calls 2 \
|
||||
--yes \
|
||||
--no-input
|
||||
|
||||
run_historic_stage_only "historic-first-$$"
|
||||
FIRST_MANIFEST="$(latest_manifest)"
|
||||
assert_manifest "$FIRST_MANIFEST" true
|
||||
|
||||
"$EXAMPLE_DIR/scripts/generate-workload.sh" extra
|
||||
run_historic_stage_only "historic-second-$$"
|
||||
SECOND_MANIFEST="$(latest_manifest)"
|
||||
assert_manifest "$SECOND_MANIFEST" false
|
||||
|
||||
docker compose -f "$COMPOSE_FILE" exec -T postgres \
|
||||
psql -U postgres -d analytics -v ON_ERROR_STOP=1 -c "SELECT pg_stat_statements_reset();" >/dev/null
|
||||
"$EXAMPLE_DIR/scripts/generate-workload.sh" extra
|
||||
run_historic_stage_only "historic-reset-$$"
|
||||
RESET_MANIFEST="$(latest_manifest)"
|
||||
assert_manifest "$RESET_MANIFEST" true
|
||||
|
||||
echo "Postgres historic SQL smoke passed"
|
||||
echo "Project dir: $PROJECT_DIR"
|
||||
54
package.json
Normal file
54
package.json
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
{
|
||||
"name": "klo-workspace",
|
||||
"version": "0.0.0-private",
|
||||
"description": "Workspace root for klo packages",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.28.0",
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
"pnpm": ">=10.20.0"
|
||||
},
|
||||
"scripts": {
|
||||
"artifacts:build": "node scripts/package-artifacts.mjs build",
|
||||
"artifacts:check": "node scripts/package-artifacts.mjs check",
|
||||
"artifacts:live-db-smoke": "node scripts/installed-live-database-smoke.mjs",
|
||||
"artifacts:verify": "node scripts/package-artifacts.mjs verify",
|
||||
"artifacts:verify-demo": "node scripts/package-artifacts.mjs verify-demo",
|
||||
"artifacts:verify-manifest": "node scripts/package-artifacts.mjs verify-manifest",
|
||||
"build": "pnpm --filter './packages/*' run build",
|
||||
"check": "node scripts/check-boundaries.mjs && node --test scripts/*.test.mjs && pnpm --filter './packages/*' run build && pnpm --filter './packages/*' run test",
|
||||
"klo": "node scripts/run-klo.mjs",
|
||||
"link:dev": "node scripts/link-dev-cli.mjs",
|
||||
"native:rebuild": "pnpm -r rebuild better-sqlite3",
|
||||
"setup:dev": "node scripts/setup-dev.mjs",
|
||||
"release:published-smoke": "node scripts/published-package-smoke.mjs --require-config",
|
||||
"release:readiness": "node scripts/release-readiness.mjs",
|
||||
"relationships:acquire-public-fixtures": "node scripts/acquire-public-benchmark-fixtures.mjs",
|
||||
"relationships:rebuild-public-snapshots": "node scripts/build-benchmark-snapshot.mjs --rebuild-all",
|
||||
"relationships:build-adventureworks-oltp": "node scripts/build-adventureworks-oltp-fixture.mjs",
|
||||
"relationships:verify-orbit": "node scripts/relationship-orbit-verification.mjs",
|
||||
"smoke": "pnpm run build && pnpm --filter @klo/cli run smoke",
|
||||
"test": "node --test scripts/*.test.mjs && pnpm --filter './packages/*' run test",
|
||||
"type-check": "pnpm --filter './packages/*' run type-check"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"better-sqlite3"
|
||||
]
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/kaelio/ktx.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/kaelio/ktx/issues"
|
||||
},
|
||||
"homepage": "https://github.com/kaelio/ktx#readme"
|
||||
}
|
||||
BIN
packages/cli/assets/demo/orbit/demo.db
Normal file
BIN
packages/cli/assets/demo/orbit/demo.db
Normal file
Binary file not shown.
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
summary: Account activation policy changed on January 15, 2026.
|
||||
tags:
|
||||
- growth
|
||||
- activation
|
||||
- policy
|
||||
refs: []
|
||||
sl_refs:
|
||||
- orbit_demo.accounts
|
||||
- orbit_demo.purchase_requests
|
||||
usage_mode: auto
|
||||
---
|
||||
|
||||
Before January 15, 2026, activation meant first requester login.
|
||||
|
||||
On and after January 15, 2026, activation requires an approved purchase request and at least three activated requesters.
|
||||
|
||||
Always separate pre-policy and post-policy cohorts when comparing activation rates.
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
summary: ARR uses contract-first precedence before subscription-derived revenue.
|
||||
tags:
|
||||
- finance
|
||||
- arr
|
||||
- revenue
|
||||
refs: []
|
||||
sl_refs:
|
||||
- orbit_demo.contracts
|
||||
- orbit_demo.arr_movements
|
||||
usage_mode: auto
|
||||
---
|
||||
|
||||
ARR is calculated from active recurring contract ARR before falling back to subscription-derived revenue.
|
||||
|
||||
Do not double-count subscription MRR when an active contract row covers the same account and period.
|
||||
|
||||
Exclude cancelled contracts ending before the metric date, future-starting contracts, internal accounts, and test accounts.
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
summary: Customer health combines support severity and procurement activity.
|
||||
tags:
|
||||
- customer-success
|
||||
- health
|
||||
- churn-risk
|
||||
refs:
|
||||
- nrr-retention
|
||||
sl_refs:
|
||||
- orbit_demo.support_tickets
|
||||
- orbit_demo.purchase_requests
|
||||
- orbit_demo.accounts
|
||||
usage_mode: auto
|
||||
---
|
||||
|
||||
High-risk accounts have multiple recent high-severity tickets or no recent procurement activity on growth and enterprise plans.
|
||||
|
||||
Medium risk captures partial support pressure or a material month-over-month decline in procurement activity.
|
||||
|
||||
Internal and test accounts are excluded from customer health scoring.
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
summary: Discount expirations are tracked separately from organic contraction.
|
||||
tags:
|
||||
- finance
|
||||
- retention
|
||||
refs:
|
||||
- arr-contract-first
|
||||
- nrr-retention
|
||||
sl_refs:
|
||||
- orbit_demo.contracts
|
||||
- orbit_demo.arr_movements
|
||||
usage_mode: auto
|
||||
---
|
||||
|
||||
Discount expiration events identify pricing changes when negotiated discounts end.
|
||||
|
||||
Track these separately from organic contraction so board reporting can split pricing-driven and usage-driven changes.
|
||||
|
||||
Use movement_reason on arr_movements when separating discount expiration from churn or seat-reduction events.
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
summary: Canonical metrics exclude internal and test accounts and users.
|
||||
tags:
|
||||
- data-quality
|
||||
- governance
|
||||
refs: []
|
||||
sl_refs:
|
||||
- orbit_demo.accounts
|
||||
usage_mode: auto
|
||||
---
|
||||
|
||||
All canonical customer metrics exclude rows marked as internal or test fixtures.
|
||||
|
||||
This exclusion applies at both account and user grain when joining procurement, support, and revenue activity.
|
||||
|
||||
If a metric unexpectedly increases, check whether new internal or test accounts were created without proper flags.
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
summary: NRR is calculated at parent-account grain by calendar quarter.
|
||||
tags:
|
||||
- analytics
|
||||
- retention
|
||||
- nrr
|
||||
refs:
|
||||
- arr-contract-first
|
||||
sl_refs:
|
||||
- orbit_demo.arr_movements
|
||||
- orbit_demo.accounts
|
||||
usage_mode: auto
|
||||
---
|
||||
|
||||
Net Revenue Retention uses parent-account rollups by calendar quarter.
|
||||
|
||||
The formula is starting ARR plus expansion minus contraction and churn, divided by starting ARR.
|
||||
|
||||
Exclude parent accounts with zero starting ARR, new business, reactivations, and internal/test accounts from the denominator.
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
summary: Procurement workflow activity measures active requesters and qualifying actions.
|
||||
tags:
|
||||
- product
|
||||
- procurement
|
||||
refs:
|
||||
- activation-policy
|
||||
sl_refs:
|
||||
- orbit_demo.purchase_requests
|
||||
usage_mode: auto
|
||||
---
|
||||
|
||||
Weekly active requesters counts distinct non-internal requesters with a qualifying procurement action in the calendar week.
|
||||
|
||||
Qualifying actions include purchase request creation, approval decisions, supplier invites, and purchase-order creation.
|
||||
|
||||
Purchase-request comments and short sessions are excluded from the canonical requester activity metric.
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
summary: Gross-to-net revenue reconciles paid invoices, credits, and refunds.
|
||||
tags:
|
||||
- finance
|
||||
- revenue
|
||||
refs:
|
||||
- arr-contract-first
|
||||
sl_refs:
|
||||
- orbit_demo.invoices
|
||||
usage_mode: auto
|
||||
---
|
||||
|
||||
Gross revenue starts from paid invoice activity. Net revenue subtracts credits and successful refunds in the month they are recorded.
|
||||
|
||||
Exclude unpaid, void, draft, failed, internal, and test-account invoice activity from canonical revenue reporting.
|
||||
|
||||
February 2026 has an elevated refund event captured in the source notes and revenue dashboard.
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
summary: Account segments derive from plan normalization and effective-dated mapping.
|
||||
tags:
|
||||
- sales-ops
|
||||
- segmentation
|
||||
refs: []
|
||||
sl_refs:
|
||||
- orbit_demo.accounts
|
||||
- orbit_demo.contracts
|
||||
usage_mode: auto
|
||||
---
|
||||
|
||||
Account segment labels combine plan_code, canonical_plan_code, and size_band fields.
|
||||
|
||||
Historical plan code pro_plus maps to growth for current segment analysis.
|
||||
|
||||
Use the mapping active at the metric date when segment definitions change over time.
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
summary: Support escalation tiers map ticket severity to SLA targets.
|
||||
tags:
|
||||
- support
|
||||
- sla
|
||||
refs:
|
||||
- customer-health-scoring
|
||||
sl_refs:
|
||||
- orbit_demo.support_tickets
|
||||
usage_mode: auto
|
||||
---
|
||||
|
||||
Critical support tickets require immediate response and on-call escalation.
|
||||
|
||||
High severity tickets should receive first response within four business hours.
|
||||
|
||||
Resolution time is measured from created_at to resolved_at and only applies to resolved tickets.
|
||||
209
packages/cli/assets/demo/orbit/links/provenance.json
Normal file
209
packages/cli/assets/demo/orbit/links/provenance.json
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
[
|
||||
{
|
||||
"id": "link-001",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/arr-contract-first.md",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "contracts",
|
||||
"relationship": "describes",
|
||||
"confidence": 1
|
||||
},
|
||||
{
|
||||
"id": "link-002",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/arr-contract-first.md",
|
||||
"sourceKind": "notion",
|
||||
"sourcePath": "raw-sources/notion/arr-and-contract-reporting-notes.md",
|
||||
"relationship": "derived_from",
|
||||
"confidence": 0.95
|
||||
},
|
||||
{
|
||||
"id": "link-003",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/revenue-gross-to-net.md",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "invoices",
|
||||
"relationship": "describes",
|
||||
"confidence": 1
|
||||
},
|
||||
{
|
||||
"id": "link-004",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/revenue-gross-to-net.md",
|
||||
"sourceKind": "notion",
|
||||
"sourcePath": "raw-sources/notion/revenue-reporting-policy.md",
|
||||
"relationship": "derived_from",
|
||||
"confidence": 0.95
|
||||
},
|
||||
{
|
||||
"id": "link-005",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/discount-expiration.md",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "arr_movements",
|
||||
"relationship": "describes",
|
||||
"confidence": 1
|
||||
},
|
||||
{
|
||||
"id": "link-006",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/nrr-retention.md",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "arr_movements",
|
||||
"relationship": "describes",
|
||||
"confidence": 1
|
||||
},
|
||||
{
|
||||
"id": "link-007",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/nrr-retention.md",
|
||||
"sourceKind": "notion",
|
||||
"sourcePath": "raw-sources/notion/retention-and-nrr-definition-notes.md",
|
||||
"relationship": "derived_from",
|
||||
"confidence": 0.95
|
||||
},
|
||||
{
|
||||
"id": "link-008",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/nrr-retention.md",
|
||||
"sourceKind": "bi",
|
||||
"sourcePath": "raw-sources/bi/account_retention.view.lkml",
|
||||
"relationship": "derived_from",
|
||||
"confidence": 0.85
|
||||
},
|
||||
{
|
||||
"id": "link-009",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/segment-classification.md",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "plans",
|
||||
"relationship": "describes",
|
||||
"confidence": 1
|
||||
},
|
||||
{
|
||||
"id": "link-010",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/segment-classification.md",
|
||||
"sourceKind": "notion",
|
||||
"sourcePath": "raw-sources/notion/sales-ops-segmentation-guide.md",
|
||||
"relationship": "derived_from",
|
||||
"confidence": 0.9
|
||||
},
|
||||
{
|
||||
"id": "link-011",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/activation-policy.md",
|
||||
"sourceKind": "notion",
|
||||
"sourcePath": "raw-sources/notion/activation-policy-decision-record.md",
|
||||
"relationship": "derived_from",
|
||||
"confidence": 0.95
|
||||
},
|
||||
{
|
||||
"id": "link-012",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/procurement-workflows.md",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "purchase_requests",
|
||||
"relationship": "describes",
|
||||
"confidence": 1
|
||||
},
|
||||
{
|
||||
"id": "link-013",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/customer-health-scoring.md",
|
||||
"sourceKind": "notion",
|
||||
"sourcePath": "raw-sources/notion/customer-health-playbook.md",
|
||||
"relationship": "derived_from",
|
||||
"confidence": 0.9
|
||||
},
|
||||
{
|
||||
"id": "link-014",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/customer-health-scoring.md",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "support_tickets",
|
||||
"relationship": "describes",
|
||||
"confidence": 1
|
||||
},
|
||||
{
|
||||
"id": "link-015",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/support-escalation.md",
|
||||
"sourceKind": "notion",
|
||||
"sourcePath": "raw-sources/notion/support-escalation-runbook.md",
|
||||
"relationship": "derived_from",
|
||||
"confidence": 0.9
|
||||
},
|
||||
{
|
||||
"id": "link-016",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/internal-test-exclusion.md",
|
||||
"sourceKind": "notion",
|
||||
"sourcePath": "raw-sources/notion/analyst-onboarding.md",
|
||||
"relationship": "derived_from",
|
||||
"confidence": 0.9
|
||||
},
|
||||
{
|
||||
"id": "link-017",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.accounts",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "accounts",
|
||||
"relationship": "models",
|
||||
"confidence": 1
|
||||
},
|
||||
{
|
||||
"id": "link-018",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.accounts",
|
||||
"sourceKind": "dbt",
|
||||
"sourcePath": "raw-sources/dbt/schema.yml",
|
||||
"relationship": "inherits_from",
|
||||
"confidence": 0.95
|
||||
},
|
||||
{
|
||||
"id": "link-019",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.contracts",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "contracts",
|
||||
"relationship": "models",
|
||||
"confidence": 1
|
||||
},
|
||||
{
|
||||
"id": "link-020",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.invoices",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "invoices",
|
||||
"relationship": "models",
|
||||
"confidence": 1
|
||||
},
|
||||
{
|
||||
"id": "link-021",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.arr_movements",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "arr_movements",
|
||||
"relationship": "models",
|
||||
"confidence": 1
|
||||
},
|
||||
{
|
||||
"id": "link-022",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.purchase_requests",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "purchase_requests",
|
||||
"relationship": "models",
|
||||
"confidence": 1
|
||||
},
|
||||
{
|
||||
"id": "link-023",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.support_tickets",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "support_tickets",
|
||||
"relationship": "models",
|
||||
"confidence": 1
|
||||
}
|
||||
]
|
||||
58
packages/cli/assets/demo/orbit/manifest.json
Normal file
58
packages/cli/assets/demo/orbit/manifest.json
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"demoAssetSchemaVersion": 2,
|
||||
"name": "orbit",
|
||||
"displayName": "Orbit Demo",
|
||||
"mode": "seeded",
|
||||
"sqliteDatabase": "demo.db",
|
||||
"replay": "replay.memory-flow.v1.json",
|
||||
"report": "reports/seeded-demo-report.json",
|
||||
"source": "packaged-orbit-demo",
|
||||
"sources": {
|
||||
"warehouse": {
|
||||
"label": "Warehouse",
|
||||
"path": "demo.db",
|
||||
"tables": 8,
|
||||
"rowCounts": {
|
||||
"accounts": 210,
|
||||
"contracts": 320,
|
||||
"users": 1260,
|
||||
"invoices": 3000,
|
||||
"arr_movements": 720,
|
||||
"support_tickets": 520,
|
||||
"purchase_requests": 5200,
|
||||
"plans": 4
|
||||
}
|
||||
},
|
||||
"dbt": {
|
||||
"label": "dbt",
|
||||
"path": "raw-sources/dbt",
|
||||
"models": 3,
|
||||
"sourceTables": 8
|
||||
},
|
||||
"bi": {
|
||||
"label": "BI",
|
||||
"path": "raw-sources/bi",
|
||||
"explores": 5,
|
||||
"dashboards": 2
|
||||
},
|
||||
"notion": {
|
||||
"label": "Notion",
|
||||
"path": "raw-sources/notion",
|
||||
"pages": 8
|
||||
}
|
||||
},
|
||||
"generated": {
|
||||
"semanticLayer": {
|
||||
"path": "semantic-layer/orbit_demo",
|
||||
"sourceCount": 6
|
||||
},
|
||||
"knowledge": {
|
||||
"path": "knowledge/global",
|
||||
"pageCount": 10
|
||||
},
|
||||
"links": {
|
||||
"path": "links",
|
||||
"linkCount": 23
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
view: account_retention {
|
||||
sql_table_name: orbit_analytics.mart_nrr_quarterly ;;
|
||||
description: "Canonical dbt mart dbt://ktx_demo.mart_nrr_quarterly with governed policy notion://notion_page_retention_policy_current#nrr-definition."
|
||||
|
||||
dimension: retention_key {
|
||||
primary_key: yes
|
||||
type: string
|
||||
sql: CONCAT(${TABLE}.segment, '-', ${TABLE}.quarter_label) ;;
|
||||
}
|
||||
|
||||
dimension: account_id {
|
||||
type: string
|
||||
sql: ${TABLE}.segment ;;
|
||||
}
|
||||
|
||||
dimension: parent_account_id {
|
||||
type: string
|
||||
sql: ${TABLE}.segment ;;
|
||||
}
|
||||
|
||||
dimension: fiscal_quarter {
|
||||
type: string
|
||||
sql: ${TABLE}.quarter_label ;;
|
||||
}
|
||||
|
||||
dimension: segment {
|
||||
type: string
|
||||
sql: ${TABLE}.segment ;;
|
||||
}
|
||||
|
||||
dimension: net_revenue_retention {
|
||||
type: number
|
||||
sql: ${TABLE}.net_revenue_retention ;;
|
||||
}
|
||||
|
||||
measure: nrr {
|
||||
type: average
|
||||
sql: ${net_revenue_retention} ;;
|
||||
value_format_name: percent_1
|
||||
description: "Enterprise parent-account NRR from dbt://ktx_demo.mart_nrr_quarterly and notion://notion_page_retention_policy_current#nrr-definition."
|
||||
}
|
||||
|
||||
measure: starting_arr {
|
||||
type: sum
|
||||
sql: ${TABLE}.starting_arr_cents ;;
|
||||
value_format_name: usd_0
|
||||
}
|
||||
|
||||
measure: expansion_arr {
|
||||
type: sum
|
||||
sql: ${TABLE}.expansion_arr_cents ;;
|
||||
value_format_name: usd_0
|
||||
description: "Expansion ARR used by the enterprise_expansions_q1_2026 expected answer."
|
||||
}
|
||||
|
||||
measure: contraction_arr {
|
||||
type: sum
|
||||
sql: ${TABLE}.contraction_arr_cents ;;
|
||||
value_format_name: usd_0
|
||||
}
|
||||
|
||||
measure: churned_arr {
|
||||
type: sum
|
||||
sql: ${TABLE}.churned_arr_cents ;;
|
||||
value_format_name: usd_0
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
view: arr_daily {
|
||||
sql_table_name: orbit_analytics.mart_arr_daily ;;
|
||||
description: "Contract-first ARR from dbt://ktx_demo.mart_arr_daily and notion://notion_page_arr_contract_reporting#arr-contract-first."
|
||||
|
||||
dimension: arr_daily_key {
|
||||
primary_key: yes
|
||||
type: string
|
||||
sql: CONCAT('all_accounts-', ${TABLE}.metric_date) ;;
|
||||
}
|
||||
|
||||
dimension: account_id {
|
||||
type: string
|
||||
sql: 'all_accounts' ;;
|
||||
}
|
||||
|
||||
dimension_group: metric {
|
||||
type: time
|
||||
timeframes: [date, week, month, quarter]
|
||||
sql: ${TABLE}.metric_date ;;
|
||||
}
|
||||
|
||||
measure: arr {
|
||||
type: sum
|
||||
sql: ${TABLE}.arr_cents ;;
|
||||
value_format_name: usd_0
|
||||
description: "Active contract ARR as of the requested metric date."
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
view: customer_health {
|
||||
sql_table_name: orbit_analytics.mart_customer_health ;;
|
||||
description: "Customer health mart dbt://ktx_demo.mart_customer_health governed by notion://notion_page_customer_health_playbook#risk-definition."
|
||||
|
||||
dimension: customer_health_key {
|
||||
primary_key: yes
|
||||
type: string
|
||||
sql: CONCAT(${TABLE}.account_id, '-', ${TABLE}.as_of_date) ;;
|
||||
}
|
||||
|
||||
dimension: account_id {
|
||||
type: string
|
||||
sql: ${TABLE}.account_id ;;
|
||||
}
|
||||
|
||||
dimension_group: metric {
|
||||
type: time
|
||||
timeframes: [date, week, month]
|
||||
sql: ${TABLE}.as_of_date ;;
|
||||
}
|
||||
|
||||
dimension: health_risk_tier {
|
||||
type: string
|
||||
sql: ${TABLE}.risk_level ;;
|
||||
}
|
||||
|
||||
dimension: is_paying_customer {
|
||||
type: yesno
|
||||
sql: ${TABLE}.is_active_customer ;;
|
||||
}
|
||||
|
||||
measure: active_customers {
|
||||
type: count_distinct
|
||||
sql: ${account_id} ;;
|
||||
filters: [is_paying_customer: "yes"]
|
||||
description: "Active paying customer accounts in the health mart."
|
||||
}
|
||||
|
||||
measure: high_risk_accounts {
|
||||
type: count_distinct
|
||||
sql: ${account_id} ;;
|
||||
filters: [health_risk_tier: "high"]
|
||||
description: "High-risk paying accounts used by the customer_health_risk_accounts expected answer."
|
||||
}
|
||||
|
||||
measure: open_support_tickets {
|
||||
type: sum
|
||||
sql: case when ${TABLE}.has_unresolved_high_ticket then 1 else 0 end ;;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
view: procurement_activity {
|
||||
sql_table_name: orbit_analytics.mart_procurement_activity ;;
|
||||
description: "Procurement activity mart dbt://ktx_demo.mart_procurement_activity with governed context notion://notion_page_procurement_instrumentation#qualifying-procurement-actions."
|
||||
|
||||
dimension: procurement_activity_key {
|
||||
primary_key: yes
|
||||
type: string
|
||||
sql: CONCAT(${TABLE}.contract_arr_threshold_cents, '-', ${TABLE}.week_start_date) ;;
|
||||
}
|
||||
|
||||
dimension: account_id {
|
||||
type: string
|
||||
sql: 'all_accounts' ;;
|
||||
}
|
||||
|
||||
dimension_group: week_start {
|
||||
type: time
|
||||
timeframes: [date, week]
|
||||
sql: ${TABLE}.week_start_date ;;
|
||||
}
|
||||
|
||||
dimension: contract_arr_band {
|
||||
type: string
|
||||
sql: case
|
||||
when ${TABLE}.contract_arr_threshold_cents >= 20000000 then 'over_200k'
|
||||
else 'under_200k'
|
||||
end ;;
|
||||
description: "Contract ARR band represented by the procurement activity threshold."
|
||||
}
|
||||
|
||||
measure: weekly_active_requesters {
|
||||
type: sum
|
||||
sql: ${TABLE}.active_requesters ;;
|
||||
description: "Distinct non-internal requesters with qualifying procurement workflow actions during the requested week."
|
||||
}
|
||||
|
||||
measure: purchase_requests {
|
||||
type: sum
|
||||
sql: 0 ;;
|
||||
}
|
||||
|
||||
measure: approval_actions {
|
||||
type: sum
|
||||
sql: 0 ;;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# looker_dashboard_id: dash_retention_exec_q1
|
||||
dashboard: retention_exec_q1 {
|
||||
title: "Enterprise Retention Executive Review"
|
||||
|
||||
element: retention_tile {
|
||||
title: "Enterprise NRR"
|
||||
explore: retention
|
||||
fields: [retention.fiscal_quarter, retention.nrr]
|
||||
}
|
||||
|
||||
element: movement_breakout_tile {
|
||||
title: "Movement Breakout"
|
||||
explore: retention
|
||||
fields: [retention.expansion_arr, retention.contraction_arr, retention.churned_arr]
|
||||
}
|
||||
|
||||
element: discount_expiration_contraction_tile {
|
||||
title: "Discount Expiration Contraction"
|
||||
explore: retention
|
||||
fields: [retention.parent_account_id, retention.contraction_arr]
|
||||
}
|
||||
|
||||
element: q4_vs_q1_comparison_tile {
|
||||
title: "Q4 vs Q1 Comparison"
|
||||
explore: retention
|
||||
fields: [retention.fiscal_quarter, retention.nrr]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
view: revenue_daily {
|
||||
sql_table_name: orbit_analytics.mart_revenue_daily ;;
|
||||
description: "Revenue recognition mart dbt://ktx_demo.mart_revenue_daily governed by notion://notion_page_revenue_reporting_policy#gross-to-net-reconciliation."
|
||||
|
||||
dimension: revenue_daily_key {
|
||||
primary_key: yes
|
||||
type: string
|
||||
sql: CONCAT('all_accounts-', ${TABLE}.revenue_date) ;;
|
||||
}
|
||||
|
||||
dimension: account_id {
|
||||
type: string
|
||||
sql: 'all_accounts' ;;
|
||||
}
|
||||
|
||||
dimension_group: revenue {
|
||||
type: time
|
||||
timeframes: [date, week, quarter]
|
||||
sql: ${TABLE}.revenue_date ;;
|
||||
}
|
||||
|
||||
dimension: revenue_month {
|
||||
type: string
|
||||
sql: TO_CHAR(${TABLE}.revenue_date, 'YYYY-MM') ;;
|
||||
}
|
||||
|
||||
measure: gross_revenue {
|
||||
type: sum
|
||||
sql: ${TABLE}.gross_revenue_cents ;;
|
||||
value_format_name: usd_0
|
||||
description: "Paid invoice line revenue before credits and refunds."
|
||||
}
|
||||
|
||||
measure: credits {
|
||||
type: sum
|
||||
sql: ${TABLE}.credits_cents ;;
|
||||
value_format_name: usd_0
|
||||
}
|
||||
|
||||
measure: refunds {
|
||||
type: sum
|
||||
sql: ${TABLE}.refunds_cents ;;
|
||||
value_format_name: usd_0
|
||||
}
|
||||
|
||||
measure: net_revenue {
|
||||
type: sum
|
||||
sql: ${TABLE}.net_revenue_cents ;;
|
||||
value_format_name: usd_0
|
||||
description: "Gross revenue minus credits and successful refunds, recognized by paid/refund dates."
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# looker_dashboard_id: dash_revenue_exec
|
||||
dashboard: revenue_exec {
|
||||
title: "Gross and Net Revenue Executive Dashboard"
|
||||
|
||||
element: gross_revenue_tile {
|
||||
title: "Gross Revenue"
|
||||
explore: revenue
|
||||
fields: [revenue.revenue_month, revenue.gross_revenue]
|
||||
}
|
||||
|
||||
element: credits_tile {
|
||||
title: "Credits"
|
||||
explore: revenue
|
||||
fields: [revenue.revenue_month, revenue.credits]
|
||||
}
|
||||
|
||||
element: refunds_tile {
|
||||
title: "Refunds"
|
||||
explore: revenue
|
||||
fields: [revenue.revenue_month, revenue.refunds]
|
||||
}
|
||||
|
||||
element: february_reconciliation_tile {
|
||||
title: "February Reconciliation"
|
||||
explore: revenue
|
||||
fields: [revenue.gross_revenue, revenue.credits, revenue.refunds, revenue.net_revenue]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
name: ktx_demo
|
||||
version: "1.0.0"
|
||||
config-version: 2
|
||||
profile: ktx_demo
|
||||
|
||||
model-paths: ["models"]
|
||||
|
||||
models:
|
||||
ktx_demo:
|
||||
+materialized: view
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
select
|
||||
date '2026-03-31' as metric_date,
|
||||
sum(contract_arr_cents)::bigint as arr_cents,
|
||||
'$18.742M' as display
|
||||
from {{ ref('int_active_contract_arr') }}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
select
|
||||
date '2026-03-31' as as_of_date,
|
||||
account_id,
|
||||
parent_account_id,
|
||||
account_name,
|
||||
is_active_customer,
|
||||
has_unresolved_high_ticket,
|
||||
has_recent_procurement_activity,
|
||||
risk_level
|
||||
from {{ ref('int_customer_health_signals') }}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
select
|
||||
revenue_date,
|
||||
gross_revenue_cents::bigint as gross_revenue_cents,
|
||||
credits_cents::bigint as credits_cents,
|
||||
refunds_cents::bigint as refunds_cents,
|
||||
net_revenue_cents::bigint as net_revenue_cents,
|
||||
(gross_revenue_cents - credits_cents - refunds_cents = net_revenue_cents) as reconciliation_check
|
||||
from {{ ref('int_revenue_components') }}
|
||||
455
packages/cli/assets/demo/orbit/raw-sources/dbt/schema.yml
Normal file
455
packages/cli/assets/demo/orbit/raw-sources/dbt/schema.yml
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
version: 2
|
||||
|
||||
models:
|
||||
- name: stg_accounts
|
||||
description: 'Customer and internal/test account records for Orbit.'
|
||||
columns:
|
||||
- name: account_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: sales_region
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [na, emea, apac]
|
||||
- name: size_band
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [smb, mid_market, enterprise]
|
||||
- name: lifecycle_status
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [prospect, active, churned, internal, test]
|
||||
- name: stg_account_hierarchy
|
||||
description: 'Parent-child account relationships used for enterprise retention grain.'
|
||||
columns:
|
||||
- name: account_hierarchy_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: relationship_type
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [subsidiary, division, billing_group]
|
||||
- name: stg_plans
|
||||
description: 'Canonical and historical Orbit pricing plans.'
|
||||
columns:
|
||||
- name: plan_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: canonical_plan_code
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [starter, growth, enterprise]
|
||||
- name: stg_contracts
|
||||
description: 'Contract records that provide contract-first ARR for active accounts.'
|
||||
columns:
|
||||
- name: contract_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: status
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [draft, active, cancelled, expired]
|
||||
- name: renewal_type
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [new, renewal, expansion, downgrade]
|
||||
- name: stg_subscriptions
|
||||
description: 'Subscription rows used when active contract ARR is not present for a covered period.'
|
||||
columns:
|
||||
- name: subscription_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: status
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [active, cancelled, past_due, trialing]
|
||||
- name: stg_contract_discount_terms
|
||||
description: 'Contract discount terms that explain Q1 2026 enterprise contraction movement.'
|
||||
columns:
|
||||
- name: discount_term_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: discount_type
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [launch, renewal, migration, goodwill]
|
||||
- name: stg_arr_movements
|
||||
description: 'ARR movement ledger used by retention and expansion marts.'
|
||||
columns:
|
||||
- name: arr_movement_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: movement_type
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [new, expansion, contraction, churn, reactivation]
|
||||
- name: stg_invoices
|
||||
description: 'Billing invoices that anchor gross revenue recognition dates.'
|
||||
columns:
|
||||
- name: invoice_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: status
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [draft, open, paid, void, failed]
|
||||
- name: currency
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [USD]
|
||||
- name: stg_invoice_line_items
|
||||
description: 'Invoice line items used to split gross revenue, credits, seats, usage, and addons.'
|
||||
columns:
|
||||
- name: invoice_line_item_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: line_item_type
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [subscription, seat, usage, addon, credit]
|
||||
- name: stg_refunds
|
||||
description: 'Refund events that reduce net revenue in the refund month.'
|
||||
columns:
|
||||
- name: refund_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: status
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [pending, succeeded, failed, cancelled]
|
||||
- name: stg_plan_segment_mapping
|
||||
description: 'Effective-dated mapping from canonical plans and size bands to reporting segments.'
|
||||
columns:
|
||||
- name: plan_segment_mapping_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: canonical_plan_code
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [starter, growth, enterprise]
|
||||
- name: size_band
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [smb, mid_market, enterprise]
|
||||
- name: segment
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [self_serve, commercial, enterprise]
|
||||
- name: stg_users
|
||||
description: 'Orbit user identities shared across warehouse, Slack, Looker, Notion, and Drive artifacts.'
|
||||
columns:
|
||||
- name: user_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: stg_activation_events
|
||||
description: 'Account and requester activation events across the January policy change.'
|
||||
columns:
|
||||
- name: activation_event_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: event_type
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [first_requester_login, requester_activated, first_approved_purchase_request, account_activated]
|
||||
- name: policy_version
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [pre_2026_01_15, post_2026_01_15]
|
||||
- name: stg_sessions
|
||||
description: 'Product sessions used for pre-policy activation and activity exclusions.'
|
||||
columns:
|
||||
- name: session_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: stg_purchase_requests
|
||||
description: 'Procurement request records used for activation, requester activity, and health signals.'
|
||||
columns:
|
||||
- name: purchase_request_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: status
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [draft, submitted, approved, rejected, cancelled]
|
||||
- name: stg_approval_events
|
||||
description: 'Approval decisions tied to procurement requests.'
|
||||
columns:
|
||||
- name: approval_event_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: decision
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [approved, rejected, returned]
|
||||
- name: stg_suppliers
|
||||
description: 'Supplier directory records associated with procurement workflow events.'
|
||||
columns:
|
||||
- name: supplier_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: status
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [invited, onboarding, active, inactive]
|
||||
- name: stg_supplier_onboarding_events
|
||||
description: 'Supplier onboarding milestones that qualify as procurement workflow activity.'
|
||||
columns:
|
||||
- name: supplier_onboarding_event_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: event_type
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [invited, profile_started, profile_completed, approved]
|
||||
- name: status
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [pending, completed, blocked]
|
||||
- name: stg_purchase_orders
|
||||
description: 'Purchase orders generated from approved procurement requests.'
|
||||
columns:
|
||||
- name: purchase_order_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: status
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [created, sent, fulfilled, cancelled]
|
||||
- name: stg_support_tickets
|
||||
description: 'Customer support tickets that inform account health and risk.'
|
||||
columns:
|
||||
- name: support_ticket_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: severity
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [low, medium, high, critical]
|
||||
- name: status
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [open, pending, solved, closed]
|
||||
- name: stg_account_owners
|
||||
description: 'Effective-dated ownership assignments for account health, renewals, and escalation context.'
|
||||
columns:
|
||||
- name: account_owner_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: owner_team
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [sales_ops, customer_success, finance]
|
||||
- name: int_active_contract_arr
|
||||
description: Active contract ARR as of 2026-03-31.
|
||||
columns:
|
||||
- name: contract_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: int_parent_account_arr_movements
|
||||
description: Parent-account movement rollups for retention metrics.
|
||||
columns:
|
||||
- name: arr_movement_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: movement_type
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [new, expansion, contraction, churn, reactivation]
|
||||
- name: is_discount_expiration_contraction
|
||||
description: Discount expiration contraction flag used to keep discount movement separate from churn.
|
||||
- name: int_revenue_components
|
||||
description: Daily gross, credit, refund, and net revenue components.
|
||||
- name: int_procurement_qualifying_actions
|
||||
description: Non-internal, non-test requester activity for large active contracts in the golden week.
|
||||
- name: int_activation_policy_windows
|
||||
description: Activation cohort counts around the January 2026 policy change.
|
||||
- name: int_customer_health_signals
|
||||
description: Support-ticket and recent-procurement signals for customer health risk.
|
||||
- name: mart_arr_daily
|
||||
description: Board-prep ARR as of the metric date.
|
||||
meta:
|
||||
governed_metric_key: arr
|
||||
owner_team: finance
|
||||
notion_locator: notion://notion_page_arr_contract_reporting#arr-contract-first
|
||||
expected_answer: expected-answer://arr_as_of_2026_03_31
|
||||
columns:
|
||||
- name: metric_date
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: arr_cents
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [1874200000]
|
||||
quote: false
|
||||
- name: mart_nrr_quarterly
|
||||
description: Enterprise quarterly net revenue retention.
|
||||
meta:
|
||||
governed_metric_key: net_revenue_retention
|
||||
owner_team: analytics
|
||||
notion_locator: notion://notion_page_retention_policy_current#nrr-definition
|
||||
expected_answer: expected-answer://enterprise_nrr_q1_vs_q4_breakout
|
||||
columns:
|
||||
- name: quarter_label
|
||||
data_tests:
|
||||
- not_null
|
||||
- name: net_revenue_retention
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [1.018]
|
||||
quote: false
|
||||
config:
|
||||
where: "quarter_label = '2026-Q1' and segment = 'enterprise'"
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [1.064]
|
||||
quote: false
|
||||
config:
|
||||
where: "quarter_label = '2025-Q4' and segment = 'enterprise'"
|
||||
- name: mart_retention_movement_breakout
|
||||
description: Q1 2026 enterprise retention movement breakout.
|
||||
meta:
|
||||
governed_metric_key: net_revenue_retention
|
||||
owner_team: analytics
|
||||
notion_locator: notion://notion_page_retention_policy_current#discount-expiration-treatment
|
||||
expected_answer: expected-answer://enterprise_expansions_q1_2026
|
||||
columns:
|
||||
- name: movement_type
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [expansion, contraction, churn]
|
||||
- name: movement_reason
|
||||
description: Includes discount_expiration contraction, which is not churn.
|
||||
- name: parent_account_count
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [11]
|
||||
quote: false
|
||||
config:
|
||||
where: "movement_type = 'contraction' and movement_reason = 'discount_expiration'"
|
||||
- name: expansion_arr_cents
|
||||
description: Expansion ARR cents for Q1 enterprise movement rows.
|
||||
- name: mart_revenue_daily
|
||||
description: Daily revenue mart that reconciles gross, credits, refunds, and net revenue.
|
||||
meta:
|
||||
governed_metric_key: net_revenue
|
||||
owner_team: finance
|
||||
notion_locator: notion://notion_page_revenue_reporting_policy#gross-to-net-reconciliation
|
||||
expected_answer: expected-answer://revenue_net_vs_gross_reconciliation
|
||||
columns:
|
||||
- name: revenue_date
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: reconciliation_check
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [true]
|
||||
quote: false
|
||||
- name: net_revenue_cents
|
||||
description: Daily net revenue in cents; February 2026 total is covered by assert_february_2026_net_revenue.
|
||||
- name: mart_account_activity
|
||||
description: Activation policy comparison values.
|
||||
meta:
|
||||
governed_metric_key: activated_accounts
|
||||
owner_team: growth
|
||||
notion_locator: notion://notion_page_activation_policy_decision#policy-change
|
||||
expected_answer: expected-answer://activation_after_policy_change
|
||||
- name: mart_procurement_activity
|
||||
description: Weekly active requester counts for large active contracts.
|
||||
meta:
|
||||
governed_metric_key: weekly_active_requesters
|
||||
owner_team: product
|
||||
notion_locator: notion://notion_page_procurement_instrumentation#qualifying-procurement-actions
|
||||
expected_answer: expected-answer://active_requesters_last_week_large_contracts
|
||||
columns:
|
||||
- name: active_requesters
|
||||
description: Weekly active requesters for large active contracts.
|
||||
- name: mart_customer_health
|
||||
description: Customer-health risk mart as of 2026-03-31.
|
||||
meta:
|
||||
governed_metric_key: active_customers
|
||||
owner_team: customer_success
|
||||
notion_locator: notion://notion_page_customer_health_playbook#risk-definition
|
||||
expected_answer: expected-answer://customer_health_risk_accounts
|
||||
columns:
|
||||
- name: account_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: risk_level
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [low, medium, high]
|
||||
- name: mart_account_segments
|
||||
description: Current plan, size band, and reporting segment for accounts.
|
||||
meta:
|
||||
governed_metric_key: segment
|
||||
owner_team: sales_ops
|
||||
notion_locator: notion://notion_page_sales_ops_segmentation#growth-plan-normalization
|
||||
expected_answer: expected-answer://enterprise_nrr_q1_vs_q4_breakout
|
||||
columns:
|
||||
- name: account_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: normalized_plan_code
|
||||
description: pro_plus is normalized to growth through plans.canonical_plan_code.
|
||||
48
packages/cli/assets/demo/orbit/raw-sources/dbt/sources.yml
Normal file
48
packages/cli/assets/demo/orbit/raw-sources/dbt/sources.yml
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
version: 2
|
||||
|
||||
sources:
|
||||
- name: orbit_raw
|
||||
schema: orbit_raw
|
||||
tables:
|
||||
- name: accounts
|
||||
description: 'Customer and internal/test account records for Orbit.'
|
||||
- name: account_hierarchy
|
||||
description: 'Parent-child account relationships used for enterprise retention grain.'
|
||||
- name: plans
|
||||
description: 'Canonical and historical Orbit pricing plans.'
|
||||
- name: contracts
|
||||
description: 'Contract records that provide contract-first ARR for active accounts.'
|
||||
- name: subscriptions
|
||||
description: 'Subscription rows used when active contract ARR is not present for a covered period.'
|
||||
- name: contract_discount_terms
|
||||
description: 'Contract discount terms that explain Q1 2026 enterprise contraction movement.'
|
||||
- name: arr_movements
|
||||
description: 'ARR movement ledger used by retention and expansion marts.'
|
||||
- name: invoices
|
||||
description: 'Billing invoices that anchor gross revenue recognition dates.'
|
||||
- name: invoice_line_items
|
||||
description: 'Invoice line items used to split gross revenue, credits, seats, usage, and addons.'
|
||||
- name: refunds
|
||||
description: 'Refund events that reduce net revenue in the refund month.'
|
||||
- name: plan_segment_mapping
|
||||
description: 'Effective-dated mapping from canonical plans and size bands to reporting segments.'
|
||||
- name: users
|
||||
description: 'Orbit user identities shared across warehouse, Slack, Looker, Notion, and Drive artifacts.'
|
||||
- name: activation_events
|
||||
description: 'Account and requester activation events across the January policy change.'
|
||||
- name: sessions
|
||||
description: 'Product sessions used for pre-policy activation and activity exclusions.'
|
||||
- name: purchase_requests
|
||||
description: 'Procurement request records used for activation, requester activity, and health signals.'
|
||||
- name: approval_events
|
||||
description: 'Approval decisions tied to procurement requests.'
|
||||
- name: suppliers
|
||||
description: 'Supplier directory records associated with procurement workflow events.'
|
||||
- name: supplier_onboarding_events
|
||||
description: 'Supplier onboarding milestones that qualify as procurement workflow activity.'
|
||||
- name: purchase_orders
|
||||
description: 'Purchase orders generated from approved procurement requests.'
|
||||
- name: support_tickets
|
||||
description: 'Customer support tickets that inform account health and risk.'
|
||||
- name: account_owners
|
||||
description: 'Effective-dated ownership assignments for account health, renewals, and escalation context.'
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
---
|
||||
page_id: notion_page_activation_policy_decision
|
||||
title: 'Activation Policy Decision Record'
|
||||
owner_person_key: leo_martin
|
||||
owner_team: growth
|
||||
owner_notion_user_id: notion_user_0003
|
||||
status: current
|
||||
created_time: 2026-01-10T14:00:00-08:00
|
||||
last_edited_time: 2026-02-18T11:10:00-08:00
|
||||
tags:
|
||||
- growth
|
||||
- activation
|
||||
- policy
|
||||
related_expected_answers:
|
||||
- activation_after_policy_change
|
||||
related_metric_keys:
|
||||
- activated_accounts
|
||||
anchors:
|
||||
- notion://notion_page_activation_policy_decision#policy-change
|
||||
---
|
||||
|
||||
# Activation Policy Decision Record
|
||||
|
||||
Owner: Leo Martin (growth)
|
||||
|
||||
## Policy Change
|
||||
Anchor: notion://notion_page_activation_policy_decision#policy-change
|
||||
|
||||
Before 2026-01-15, account activation means first requester login.
|
||||
|
||||
On and after 2026-01-15, account activation means first approved purchase request plus at least three activated requesters.
|
||||
|
||||
Activated requesters are non-internal, non-test requester users with either a qualifying session before the policy date or a qualifying procurement action after it.
|
||||
|
||||
The governed comparison reports a 0.563 pre-policy 30-day activation rate and a 0.639 post-policy 30-day activation rate.
|
||||
|
||||
## Pre-Change Definition
|
||||
Anchor: notion://notion_page_activation_policy_decision#pre-change-definition
|
||||
|
||||
## Post-Change Definition
|
||||
Anchor: notion://notion_page_activation_policy_decision#post-change-definition
|
||||
|
||||
## Dashboard Impact
|
||||
Anchor: notion://notion_page_activation_policy_decision#dashboard-impact
|
||||
|
||||
## Related Evidence
|
||||
|
||||
- notion://notion_page_activation_policy_decision#policy-change
|
||||
- expected-answer://activation_after_policy_change
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
page_id: notion_page_analyst_onboarding
|
||||
title: 'Analyst Onboarding'
|
||||
owner_person_key: maya_chen
|
||||
owner_team: analytics
|
||||
owner_notion_user_id: notion_user_0001
|
||||
status: current
|
||||
created_time: 2026-03-02T09:00:00-08:00
|
||||
last_edited_time: 2026-03-17T15:30:00-07:00
|
||||
parent_page_id: notion_page_analytics_team_handbook
|
||||
tags:
|
||||
- analytics
|
||||
- onboarding
|
||||
source_anchors:
|
||||
- notion://notion_page_analyst_onboarding#first-week
|
||||
- lookml://orbit/account_retention.view.lkml#measure=nrr
|
||||
- slack://analytics-team/2026-03-31/1774942174.200142?thread_ts=1774942174.200142
|
||||
---
|
||||
|
||||
# Analyst Onboarding
|
||||
|
||||
Owner: Maya Chen (analytics)
|
||||
|
||||
## Operating Context
|
||||
Anchor: notion://notion_page_analyst_onboarding#analyst-onboarding
|
||||
|
||||
New analysts start with dbt://ktx_demo.mart_arr_daily and then review LookML field ownership.
|
||||
Do not answer metric questions from raw tables when lookml://orbit/account_retention.view.lkml#measure=nrr or a governed mart exists.
|
||||
Escalate unclear board-week requests in slack://analytics-team/2026-03-31/1774942174.200142?thread_ts=1774942174.200142.
|
||||
|
||||
## Source Anchors
|
||||
|
||||
- notion://notion_page_analyst_onboarding#first-week
|
||||
- lookml://orbit/account_retention.view.lkml#measure=nrr
|
||||
- slack://analytics-team/2026-03-31/1774942174.200142?thread_ts=1774942174.200142
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
page_id: notion_page_arr_contract_reporting
|
||||
title: 'ARR and Contract Reporting Notes'
|
||||
owner_person_key: rina_patel
|
||||
owner_team: finance
|
||||
owner_notion_user_id: notion_user_0002
|
||||
status: current
|
||||
created_time: 2025-11-20T10:30:00-08:00
|
||||
last_edited_time: 2026-03-26T15:05:00-07:00
|
||||
tags:
|
||||
- finance
|
||||
- arr
|
||||
- contracts
|
||||
related_expected_answers:
|
||||
- arr_as_of_2026_03_31
|
||||
- active_requesters_last_week_large_contracts
|
||||
related_metric_keys:
|
||||
- arr
|
||||
- contract_arr_band
|
||||
anchors:
|
||||
- notion://notion_page_arr_contract_reporting#arr-contract-first
|
||||
- notion://notion_page_arr_contract_reporting#contract-arr-band
|
||||
---
|
||||
|
||||
# ARR and Contract Reporting Notes
|
||||
|
||||
Owner: Rina Patel (finance)
|
||||
|
||||
## ARR Contract First
|
||||
Anchor: notion://notion_page_arr_contract_reporting#arr-contract-first
|
||||
|
||||
ARR uses active contract_arr_cents first when a contract covers the account and metric date.
|
||||
|
||||
Recurring subscription MRR is annualized only for account periods without active contract ARR.
|
||||
|
||||
Contract ARR banding uses active contract ARR as of 2026-03-31, including the contracts over 20000000 cents threshold.
|
||||
|
||||
Booked ARR is not active ARR, and internal or test accounts are excluded from board reporting.
|
||||
|
||||
## Contract ARR Band
|
||||
Anchor: notion://notion_page_arr_contract_reporting#contract-arr-band
|
||||
|
||||
ARR uses active contract_arr_cents first when a contract covers the account and metric date.
|
||||
|
||||
Recurring subscription MRR is annualized only for account periods without active contract ARR.
|
||||
|
||||
Contract ARR banding uses active contract ARR as of 2026-03-31, including the contracts over 20000000 cents threshold.
|
||||
|
||||
## Booked ARR vs Active ARR
|
||||
Anchor: notion://notion_page_arr_contract_reporting#booked-arr-vs-active-arr
|
||||
|
||||
Booked ARR is not active ARR, and internal or test accounts are excluded from board reporting.
|
||||
|
||||
## Internal and Test Exclusions
|
||||
Anchor: notion://notion_page_arr_contract_reporting#internal-and-test-exclusions
|
||||
|
||||
Booked ARR is not active ARR, and internal or test accounts are excluded from board reporting.
|
||||
|
||||
## Related Evidence
|
||||
|
||||
- notion://notion_page_arr_contract_reporting#arr-contract-first
|
||||
- notion://notion_page_arr_contract_reporting#contract-arr-band
|
||||
- expected-answer://arr_as_of_2026_03_31
|
||||
- expected-answer://active_requesters_last_week_large_contracts
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
---
|
||||
page_id: notion_page_customer_health_playbook
|
||||
title: 'Customer Health Playbook'
|
||||
owner_person_key: priya_shah
|
||||
owner_team: customer_success
|
||||
owner_notion_user_id: notion_user_0005
|
||||
status: current
|
||||
created_time: 2026-02-03T13:45:00-08:00
|
||||
last_edited_time: 2026-03-27T14:50:00-07:00
|
||||
tags:
|
||||
- customer-success
|
||||
- health
|
||||
- risk
|
||||
related_expected_answers:
|
||||
- customer_health_risk_accounts
|
||||
related_metric_keys:
|
||||
- active_customers
|
||||
anchors:
|
||||
- notion://notion_page_customer_health_playbook#risk-definition
|
||||
---
|
||||
|
||||
# Customer Health Playbook
|
||||
|
||||
Owner: Priya Shah (customer_success)
|
||||
|
||||
## Risk Definition
|
||||
Anchor: notion://notion_page_customer_health_playbook#risk-definition
|
||||
|
||||
Customer health combines support ticket severity and recent requisition or approval usage.
|
||||
|
||||
Active customers must have an active paid subscription and at least one qualifying procurement action in the trailing 30-day window.
|
||||
|
||||
High-risk accounts are reviewed for renewal action when severe open tickets coincide with falling workflow activity.
|
||||
|
||||
As of 2026-03-31 the governed customer-health mart reports 9 high-risk accounts.
|
||||
|
||||
## Support Signals
|
||||
Anchor: notion://notion_page_customer_health_playbook#support-signals
|
||||
|
||||
Customer health combines support ticket severity and recent requisition or approval usage.
|
||||
|
||||
## Procurement Activity Signals
|
||||
Anchor: notion://notion_page_customer_health_playbook#procurement-activity-signals
|
||||
|
||||
Active customers must have an active paid subscription and at least one qualifying procurement action in the trailing 30-day window.
|
||||
|
||||
## Renewal Review
|
||||
Anchor: notion://notion_page_customer_health_playbook#renewal-review
|
||||
|
||||
High-risk accounts are reviewed for renewal action when severe open tickets coincide with falling workflow activity.
|
||||
|
||||
## Related Evidence
|
||||
|
||||
- notion://notion_page_customer_health_playbook#risk-definition
|
||||
- expected-answer://customer_health_risk_accounts
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
page_id: notion_page_retention_policy_current
|
||||
title: 'Retention and NRR Definition Notes'
|
||||
owner_person_key: maya_chen
|
||||
owner_team: analytics
|
||||
owner_notion_user_id: notion_user_0001
|
||||
status: current
|
||||
created_time: 2026-01-08T10:00:00-08:00
|
||||
last_edited_time: 2026-03-30T16:40:00-07:00
|
||||
tags:
|
||||
- analytics
|
||||
- retention
|
||||
- board-reporting
|
||||
related_expected_answers:
|
||||
- enterprise_nrr_q1_vs_q4_breakout
|
||||
- enterprise_expansions_q1_2026
|
||||
related_metric_keys:
|
||||
- net_revenue_retention
|
||||
- segment
|
||||
anchors:
|
||||
- notion://notion_page_retention_policy_current#nrr-definition
|
||||
- notion://notion_page_retention_policy_current#discount-expiration-treatment
|
||||
---
|
||||
|
||||
# Retention and NRR Definition Notes
|
||||
|
||||
Owner: Maya Chen (analytics)
|
||||
|
||||
## NRR Definition
|
||||
Anchor: notion://notion_page_retention_policy_current#nrr-definition
|
||||
|
||||
Enterprise NRR is calculated as (starting_arr + expansion_arr - contraction_arr - churned_arr) / starting_arr.
|
||||
|
||||
Movement classification happens after child accounts roll up to parent_account_id.
|
||||
|
||||
Reactivations within 30 days are excluded from NRR movement components and kept in audit columns.
|
||||
|
||||
Q1 2026 discount expiration is contraction, not churn; the board-prep view calls out 11 enterprise parent accounts.
|
||||
|
||||
## Parent-Account Grain
|
||||
Anchor: notion://notion_page_retention_policy_current#parent-account-grain
|
||||
|
||||
## Reactivation Exclusion
|
||||
Anchor: notion://notion_page_retention_policy_current#reactivation-exclusion
|
||||
|
||||
Reactivations within 30 days are excluded from NRR movement components and kept in audit columns.
|
||||
|
||||
## Discount Expiration Treatment
|
||||
Anchor: notion://notion_page_retention_policy_current#discount-expiration-treatment
|
||||
|
||||
Q1 2026 discount expiration is contraction, not churn; the board-prep view calls out 11 enterprise parent accounts.
|
||||
|
||||
## Related Evidence
|
||||
|
||||
- notion://notion_page_retention_policy_current#nrr-definition
|
||||
- notion://notion_page_retention_policy_current#discount-expiration-treatment
|
||||
- expected-answer://enterprise_nrr_q1_vs_q4_breakout
|
||||
- expected-answer://enterprise_expansions_q1_2026
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
page_id: notion_page_revenue_reporting_policy
|
||||
title: 'Revenue Reporting Policy'
|
||||
owner_person_key: rina_patel
|
||||
owner_team: finance
|
||||
owner_notion_user_id: notion_user_0002
|
||||
status: current
|
||||
created_time: 2025-12-12T09:30:00-08:00
|
||||
last_edited_time: 2026-03-28T13:15:00-07:00
|
||||
tags:
|
||||
- finance
|
||||
- revenue
|
||||
- board-reporting
|
||||
related_expected_answers:
|
||||
- revenue_net_vs_gross_reconciliation
|
||||
related_metric_keys:
|
||||
- gross_revenue
|
||||
- net_revenue
|
||||
anchors:
|
||||
- notion://notion_page_revenue_reporting_policy#gross-revenue
|
||||
- notion://notion_page_revenue_reporting_policy#gross-to-net-reconciliation
|
||||
---
|
||||
|
||||
# Revenue Reporting Policy
|
||||
|
||||
Owner: Rina Patel (finance)
|
||||
|
||||
## Gross Revenue
|
||||
Anchor: notion://notion_page_revenue_reporting_policy#gross-revenue
|
||||
|
||||
Gross revenue includes paid subscription, seat, usage, and addon invoice line items recognized on invoices.paid_at.
|
||||
|
||||
Credit line items are negative in raw invoice lines and reported as absolute credits.
|
||||
|
||||
Successful refunds reduce net revenue in the refund month based on refunds.refunded_at.
|
||||
|
||||
For February 2026 the governed reconciliation is gross revenue 213000000 cents, credits 13400000 cents, refunds 31200000 cents, and net revenue 168400000 cents.
|
||||
|
||||
## Credits
|
||||
Anchor: notion://notion_page_revenue_reporting_policy#credits
|
||||
|
||||
Credit line items are negative in raw invoice lines and reported as absolute credits.
|
||||
|
||||
For February 2026 the governed reconciliation is gross revenue 213000000 cents, credits 13400000 cents, refunds 31200000 cents, and net revenue 168400000 cents.
|
||||
|
||||
## Refunds
|
||||
Anchor: notion://notion_page_revenue_reporting_policy#refunds
|
||||
|
||||
Successful refunds reduce net revenue in the refund month based on refunds.refunded_at.
|
||||
|
||||
For February 2026 the governed reconciliation is gross revenue 213000000 cents, credits 13400000 cents, refunds 31200000 cents, and net revenue 168400000 cents.
|
||||
|
||||
## Gross To Net Reconciliation
|
||||
Anchor: notion://notion_page_revenue_reporting_policy#gross-to-net-reconciliation
|
||||
|
||||
Gross revenue includes paid subscription, seat, usage, and addon invoice line items recognized on invoices.paid_at.
|
||||
|
||||
For February 2026 the governed reconciliation is gross revenue 213000000 cents, credits 13400000 cents, refunds 31200000 cents, and net revenue 168400000 cents.
|
||||
|
||||
## Related Evidence
|
||||
|
||||
- notion://notion_page_revenue_reporting_policy#gross-revenue
|
||||
- notion://notion_page_revenue_reporting_policy#gross-to-net-reconciliation
|
||||
- expected-answer://revenue_net_vs_gross_reconciliation
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
page_id: notion_page_sales_ops_segmentation
|
||||
title: 'Sales Ops Segmentation Guide'
|
||||
owner_person_key: jordan_lee
|
||||
owner_team: sales_ops
|
||||
owner_notion_user_id: notion_user_0004
|
||||
status: current
|
||||
created_time: 2025-10-03T09:00:00-07:00
|
||||
last_edited_time: 2026-03-25T10:35:00-07:00
|
||||
tags:
|
||||
- sales-ops
|
||||
- segmentation
|
||||
- plans
|
||||
related_expected_answers:
|
||||
- enterprise_nrr_q1_vs_q4_breakout
|
||||
- enterprise_expansions_q1_2026
|
||||
related_metric_keys:
|
||||
- segment
|
||||
- net_revenue_retention
|
||||
anchors:
|
||||
- notion://notion_page_sales_ops_segmentation#growth-plan-normalization
|
||||
---
|
||||
|
||||
# Sales Ops Segmentation Guide
|
||||
|
||||
Owner: Jordan Lee (sales_ops)
|
||||
|
||||
## Growth Plan Normalization
|
||||
Anchor: notion://notion_page_sales_ops_segmentation#growth-plan-normalization
|
||||
|
||||
The current plan language is starter, growth, and enterprise.
|
||||
|
||||
Raw historical pro_plus values normalize to growth for current artifacts after 2025-10-01.
|
||||
|
||||
Retention cohort membership is evaluated at quarter start unless the golden question states another as-of date.
|
||||
|
||||
Segment membership changes are bridge items and are not silently classified as expansion or churn.
|
||||
|
||||
## Segment Membership
|
||||
Anchor: notion://notion_page_sales_ops_segmentation#segment-membership
|
||||
|
||||
Segment membership changes are bridge items and are not silently classified as expansion or churn.
|
||||
|
||||
## Quarter Start Cohorts
|
||||
Anchor: notion://notion_page_sales_ops_segmentation#quarter-start-cohorts
|
||||
|
||||
Retention cohort membership is evaluated at quarter start unless the golden question states another as-of date.
|
||||
|
||||
## Historical Plan Alias
|
||||
Anchor: notion://notion_page_sales_ops_segmentation#historical-plan-alias
|
||||
|
||||
Raw historical pro_plus values normalize to growth for current artifacts after 2025-10-01.
|
||||
|
||||
## Related Evidence
|
||||
|
||||
- notion://notion_page_sales_ops_segmentation#growth-plan-normalization
|
||||
- expected-answer://enterprise_nrr_q1_vs_q4_breakout
|
||||
- expected-answer://enterprise_expansions_q1_2026
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
page_id: notion_page_support_escalation_runbook
|
||||
title: 'Support Escalation Runbook'
|
||||
owner_person_key: priya_shah
|
||||
owner_team: customer_success
|
||||
owner_notion_user_id: notion_user_0005
|
||||
status: current
|
||||
created_time: 2026-03-06T09:00:00-08:00
|
||||
last_edited_time: 2026-03-21T15:30:00-07:00
|
||||
parent_page_id: notion_page_customer_health_playbook
|
||||
tags:
|
||||
- customer-success
|
||||
- support
|
||||
- risk
|
||||
source_anchors:
|
||||
- notion://notion_page_support_escalation_runbook#triage
|
||||
- looker://dashboard/dash_customer_health_risk
|
||||
- drive://drive_file_customer_health_scorecard_q1#high-risk-accounts
|
||||
- slack://customer-success/2026-03-31/1774976400.000100?thread_ts=1774976400.000100
|
||||
---
|
||||
|
||||
# Support Escalation Runbook
|
||||
|
||||
Owner: Priya Shah (customer_success)
|
||||
|
||||
## Operating Context
|
||||
Anchor: notion://notion_page_support_escalation_runbook#support-escalation-runbook
|
||||
|
||||
High-risk review combines open support tickets with recent requisition and approval activity drops.
|
||||
Use looker://dashboard/dash_customer_health_risk for the list and drive://drive_file_customer_health_scorecard_q1#high-risk-accounts for the scorecard.
|
||||
Escalations are coordinated in slack://customer-success/2026-03-31/1774976400.000100?thread_ts=1774976400.000100.
|
||||
|
||||
## Source Anchors
|
||||
|
||||
- notion://notion_page_support_escalation_runbook#triage
|
||||
- looker://dashboard/dash_customer_health_risk
|
||||
- drive://drive_file_customer_health_scorecard_q1#high-risk-accounts
|
||||
- slack://customer-success/2026-03-31/1774976400.000100?thread_ts=1774976400.000100
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
account_id,parent_account_id,account_name,domain,industry,sales_region,size_band,lifecycle_status,is_internal,is_test,created_at
|
||||
acct_0001,parent_0001,Orbit Customer 001,customer-001.example.com,software,na,enterprise,active,false,false,2025-01-01T00:00:00Z
|
||||
acct_0002,parent_0002,Orbit Customer 002,customer-002.example.com,manufacturing,emea,enterprise,active,false,false,2025-02-02T00:00:00Z
|
||||
acct_0003,parent_0003,Orbit Customer 003,customer-003.example.com,healthcare,apac,enterprise,active,false,false,2025-03-03T00:00:00Z
|
||||
acct_0004,parent_0004,Orbit Customer 004,customer-004.example.com,financial_services,na,enterprise,active,false,false,2025-04-04T00:00:00Z
|
||||
acct_0005,parent_0005,Orbit Customer 005,customer-005.example.com,retail,emea,enterprise,active,false,false,2025-05-05T00:00:00Z
|
||||
acct_0006,parent_0006,Orbit Customer 006,customer-006.example.com,software,apac,enterprise,active,false,false,2025-06-06T00:00:00Z
|
||||
acct_0007,parent_0007,Orbit Customer 007,customer-007.example.com,manufacturing,na,enterprise,active,false,false,2025-07-07T00:00:00Z
|
||||
acct_0008,parent_0008,Orbit Customer 008,customer-008.example.com,healthcare,emea,enterprise,active,false,false,2025-08-08T00:00:00Z
|
||||
acct_0009,parent_0009,Orbit Customer 009,customer-009.example.com,financial_services,apac,enterprise,active,false,false,2025-09-09T00:00:00Z
|
||||
acct_0010,parent_0010,Orbit Customer 010,customer-010.example.com,retail,na,enterprise,active,false,false,2025-10-10T00:00:00Z
|
||||
acct_0011,parent_0011,Orbit Customer 011,customer-011.example.com,software,emea,enterprise,active,false,false,2025-11-11T00:00:00Z
|
||||
acct_0012,parent_0012,Orbit Customer 012,customer-012.example.com,manufacturing,apac,enterprise,active,false,false,2025-12-12T00:00:00Z
|
||||
acct_0013,parent_0013,Orbit Customer 013,customer-013.example.com,healthcare,na,enterprise,active,false,false,2025-01-13T00:00:00Z
|
||||
acct_0014,parent_0014,Orbit Customer 014,customer-014.example.com,financial_services,emea,enterprise,active,false,false,2025-02-14T00:00:00Z
|
||||
acct_0015,parent_0015,Orbit Customer 015,customer-015.example.com,retail,apac,enterprise,active,false,false,2025-03-15T00:00:00Z
|
||||
acct_0016,parent_0016,Orbit Customer 016,customer-016.example.com,software,na,enterprise,active,false,false,2025-04-16T00:00:00Z
|
||||
acct_0017,parent_0017,Orbit Customer 017,customer-017.example.com,manufacturing,emea,enterprise,active,false,false,2025-05-17T00:00:00Z
|
||||
acct_0018,parent_0018,Orbit Customer 018,customer-018.example.com,healthcare,apac,enterprise,active,false,false,2025-06-18T00:00:00Z
|
||||
acct_0019,parent_0019,Orbit Customer 019,customer-019.example.com,financial_services,na,enterprise,active,false,false,2025-07-19T00:00:00Z
|
||||
acct_0020,parent_0020,Orbit Customer 020,customer-020.example.com,retail,emea,enterprise,active,false,false,2025-08-20T00:00:00Z
|
||||
acct_0021,parent_0021,Orbit Customer 021,customer-021.example.com,software,apac,enterprise,active,false,false,2025-09-21T00:00:00Z
|
||||
acct_0022,parent_0022,Orbit Customer 022,customer-022.example.com,manufacturing,na,enterprise,active,false,false,2025-10-22T00:00:00Z
|
||||
acct_0023,parent_0023,Orbit Customer 023,customer-023.example.com,healthcare,emea,enterprise,active,false,false,2025-11-23T00:00:00Z
|
||||
acct_0024,parent_0024,Orbit Customer 024,customer-024.example.com,financial_services,apac,enterprise,active,false,false,2025-12-24T00:00:00Z
|
||||
acct_0025,parent_0025,Orbit Customer 025,customer-025.example.com,retail,na,enterprise,active,false,false,2025-01-25T00:00:00Z
|
||||
acct_0026,parent_0026,Orbit Customer 026,customer-026.example.com,software,emea,enterprise,active,false,false,2025-02-26T00:00:00Z
|
||||
acct_0027,parent_0027,Orbit Customer 027,customer-027.example.com,manufacturing,apac,enterprise,active,false,false,2025-03-27T00:00:00Z
|
||||
acct_0028,parent_0028,Orbit Customer 028,customer-028.example.com,healthcare,na,enterprise,active,false,false,2025-04-28T00:00:00Z
|
||||
acct_0029,parent_0029,Orbit Customer 029,customer-029.example.com,financial_services,emea,enterprise,active,false,false,2025-05-01T00:00:00Z
|
||||
acct_0030,parent_0030,Orbit Customer 030,customer-030.example.com,retail,apac,enterprise,active,false,false,2025-06-02T00:00:00Z
|
||||
acct_0031,parent_0031,Orbit Customer 031,customer-031.example.com,software,na,enterprise,active,false,false,2025-07-03T00:00:00Z
|
||||
acct_0032,parent_0032,Orbit Customer 032,customer-032.example.com,manufacturing,emea,enterprise,active,false,false,2025-08-04T00:00:00Z
|
||||
acct_0033,parent_0033,Orbit Customer 033,customer-033.example.com,healthcare,apac,enterprise,active,false,false,2025-09-05T00:00:00Z
|
||||
acct_0034,parent_0034,Orbit Customer 034,customer-034.example.com,financial_services,na,enterprise,active,false,false,2025-10-06T00:00:00Z
|
||||
acct_0035,parent_0035,Orbit Customer 035,customer-035.example.com,retail,emea,enterprise,active,false,false,2025-11-07T00:00:00Z
|
||||
acct_0036,parent_0036,Orbit Customer 036,customer-036.example.com,software,apac,enterprise,active,false,false,2025-12-08T00:00:00Z
|
||||
acct_0037,parent_0037,Orbit Customer 037,customer-037.example.com,manufacturing,na,enterprise,active,false,false,2025-01-09T00:00:00Z
|
||||
acct_0038,parent_0038,Orbit Customer 038,customer-038.example.com,healthcare,emea,enterprise,active,false,false,2025-02-10T00:00:00Z
|
||||
acct_0039,parent_0039,Orbit Customer 039,customer-039.example.com,financial_services,apac,enterprise,active,false,false,2025-03-11T00:00:00Z
|
||||
acct_0040,parent_0040,Orbit Customer 040,customer-040.example.com,retail,na,enterprise,active,false,false,2025-04-12T00:00:00Z
|
||||
acct_0041,parent_0041,Orbit Customer 041,customer-041.example.com,software,emea,enterprise,active,false,false,2025-05-13T00:00:00Z
|
||||
acct_0042,parent_0042,Orbit Customer 042,customer-042.example.com,manufacturing,apac,enterprise,active,false,false,2025-06-14T00:00:00Z
|
||||
acct_0043,parent_0043,Orbit Customer 043,customer-043.example.com,healthcare,na,enterprise,active,false,false,2025-07-15T00:00:00Z
|
||||
acct_0044,parent_0044,Orbit Customer 044,customer-044.example.com,financial_services,emea,enterprise,active,false,false,2025-08-16T00:00:00Z
|
||||
acct_0045,parent_0045,Orbit Customer 045,customer-045.example.com,retail,apac,enterprise,active,false,false,2025-09-17T00:00:00Z
|
||||
acct_0046,parent_0046,Orbit Customer 046,customer-046.example.com,software,na,enterprise,active,false,false,2025-10-18T00:00:00Z
|
||||
acct_0047,parent_0047,Orbit Customer 047,customer-047.example.com,manufacturing,emea,enterprise,active,false,false,2025-11-19T00:00:00Z
|
||||
acct_0048,parent_0048,Orbit Customer 048,customer-048.example.com,healthcare,apac,enterprise,active,false,false,2025-12-20T00:00:00Z
|
||||
acct_0049,parent_0049,Orbit Customer 049,customer-049.example.com,financial_services,na,enterprise,active,false,false,2025-01-21T00:00:00Z
|
||||
acct_0050,parent_0050,Orbit Customer 050,customer-050.example.com,retail,emea,enterprise,active,false,false,2025-02-22T00:00:00Z
|
||||
acct_0051,parent_0051,Orbit Customer 051,customer-051.example.com,software,apac,enterprise,active,false,false,2025-03-23T00:00:00Z
|
||||
acct_0052,parent_0052,Orbit Customer 052,customer-052.example.com,manufacturing,na,enterprise,active,false,false,2025-04-24T00:00:00Z
|
||||
acct_0053,parent_0053,Orbit Customer 053,customer-053.example.com,healthcare,emea,enterprise,active,false,false,2025-05-25T00:00:00Z
|
||||
acct_0054,parent_0054,Orbit Customer 054,customer-054.example.com,financial_services,apac,enterprise,active,false,false,2025-06-26T00:00:00Z
|
||||
acct_0055,parent_0055,Orbit Customer 055,customer-055.example.com,retail,na,enterprise,active,false,false,2025-07-27T00:00:00Z
|
||||
acct_0056,parent_0056,Orbit Customer 056,customer-056.example.com,software,emea,enterprise,active,false,false,2025-08-28T00:00:00Z
|
||||
acct_0057,parent_0057,Orbit Customer 057,customer-057.example.com,manufacturing,apac,enterprise,active,false,false,2025-09-01T00:00:00Z
|
||||
acct_0058,parent_0058,Orbit Customer 058,customer-058.example.com,healthcare,na,enterprise,active,false,false,2025-10-02T00:00:00Z
|
||||
acct_0059,parent_0059,Orbit Customer 059,customer-059.example.com,financial_services,emea,enterprise,active,false,false,2025-11-03T00:00:00Z
|
||||
acct_0060,parent_0060,Orbit Customer 060,customer-060.example.com,retail,apac,enterprise,active,false,false,2025-12-04T00:00:00Z
|
||||
acct_0061,parent_0061,Orbit Customer 061,customer-061.example.com,software,na,enterprise,active,false,false,2025-01-05T00:00:00Z
|
||||
acct_0062,parent_0062,Orbit Customer 062,customer-062.example.com,manufacturing,emea,enterprise,active,false,false,2025-02-06T00:00:00Z
|
||||
acct_0063,parent_0063,Orbit Customer 063,customer-063.example.com,healthcare,apac,enterprise,active,false,false,2025-03-07T00:00:00Z
|
||||
acct_0064,parent_0064,Orbit Customer 064,customer-064.example.com,financial_services,na,enterprise,active,false,false,2025-04-08T00:00:00Z
|
||||
acct_0065,parent_0065,Orbit Customer 065,customer-065.example.com,retail,emea,enterprise,active,false,false,2025-05-09T00:00:00Z
|
||||
acct_0066,parent_0066,Orbit Customer 066,customer-066.example.com,software,apac,enterprise,active,false,false,2025-06-10T00:00:00Z
|
||||
acct_0067,parent_0067,Orbit Customer 067,customer-067.example.com,manufacturing,na,enterprise,active,false,false,2025-07-11T00:00:00Z
|
||||
acct_0068,parent_0068,Orbit Customer 068,customer-068.example.com,healthcare,emea,enterprise,active,false,false,2025-08-12T00:00:00Z
|
||||
acct_0069,parent_0069,Orbit Customer 069,customer-069.example.com,financial_services,apac,enterprise,active,false,false,2025-09-13T00:00:00Z
|
||||
acct_0070,parent_0070,Orbit Customer 070,customer-070.example.com,retail,na,enterprise,active,false,false,2025-10-14T00:00:00Z
|
||||
acct_0071,parent_0071,Orbit Customer 071,customer-071.example.com,software,emea,enterprise,active,false,false,2025-11-15T00:00:00Z
|
||||
acct_0072,parent_0072,Orbit Customer 072,customer-072.example.com,manufacturing,apac,enterprise,active,false,false,2025-12-16T00:00:00Z
|
||||
acct_0073,parent_0073,Orbit Customer 073,customer-073.example.com,healthcare,na,enterprise,active,false,false,2025-01-17T00:00:00Z
|
||||
acct_0074,parent_0074,Orbit Customer 074,customer-074.example.com,financial_services,emea,enterprise,active,false,false,2025-02-18T00:00:00Z
|
||||
acct_0075,parent_0075,Orbit Customer 075,customer-075.example.com,retail,apac,enterprise,active,false,false,2025-03-19T00:00:00Z
|
||||
acct_0076,parent_0076,Orbit Customer 076,customer-076.example.com,software,na,enterprise,active,false,false,2025-04-20T00:00:00Z
|
||||
acct_0077,parent_0077,Orbit Customer 077,customer-077.example.com,manufacturing,emea,enterprise,active,false,false,2025-05-21T00:00:00Z
|
||||
acct_0078,parent_0078,Orbit Customer 078,customer-078.example.com,healthcare,apac,enterprise,active,false,false,2025-06-22T00:00:00Z
|
||||
acct_0079,parent_0079,Orbit Customer 079,customer-079.example.com,financial_services,na,enterprise,active,false,false,2025-07-23T00:00:00Z
|
||||
acct_0080,parent_0080,Orbit Customer 080,customer-080.example.com,retail,emea,enterprise,active,false,false,2025-08-24T00:00:00Z
|
||||
acct_0081,parent_0081,Orbit Customer 081,customer-081.example.com,software,apac,mid_market,active,false,false,2025-09-25T00:00:00Z
|
||||
acct_0082,parent_0082,Orbit Customer 082,customer-082.example.com,manufacturing,na,mid_market,active,false,false,2025-10-26T00:00:00Z
|
||||
acct_0083,parent_0083,Orbit Customer 083,customer-083.example.com,healthcare,emea,mid_market,active,false,false,2025-11-27T00:00:00Z
|
||||
acct_0084,parent_0084,Orbit Customer 084,customer-084.example.com,financial_services,apac,mid_market,active,false,false,2025-12-28T00:00:00Z
|
||||
acct_0085,parent_0085,Orbit Customer 085,customer-085.example.com,retail,na,mid_market,active,false,false,2025-01-01T00:00:00Z
|
||||
acct_0086,parent_0086,Orbit Customer 086,customer-086.example.com,software,emea,mid_market,active,false,false,2025-02-02T00:00:00Z
|
||||
acct_0087,parent_0087,Orbit Customer 087,customer-087.example.com,manufacturing,apac,mid_market,active,false,false,2025-03-03T00:00:00Z
|
||||
acct_0088,parent_0088,Orbit Customer 088,customer-088.example.com,healthcare,na,mid_market,active,false,false,2025-04-04T00:00:00Z
|
||||
acct_0089,parent_0089,Orbit Customer 089,customer-089.example.com,financial_services,emea,mid_market,active,false,false,2025-05-05T00:00:00Z
|
||||
acct_0090,parent_0090,Orbit Customer 090,customer-090.example.com,retail,apac,mid_market,active,false,false,2025-06-06T00:00:00Z
|
||||
acct_0091,parent_0091,Orbit Customer 091,customer-091.example.com,software,na,mid_market,active,false,false,2025-07-07T00:00:00Z
|
||||
acct_0092,parent_0092,Orbit Customer 092,customer-092.example.com,manufacturing,emea,mid_market,active,false,false,2025-08-08T00:00:00Z
|
||||
acct_0093,parent_0093,Orbit Customer 093,customer-093.example.com,healthcare,apac,mid_market,active,false,false,2025-09-09T00:00:00Z
|
||||
acct_0094,parent_0094,Orbit Customer 094,customer-094.example.com,financial_services,na,mid_market,active,false,false,2025-10-10T00:00:00Z
|
||||
acct_0095,parent_0095,Orbit Customer 095,customer-095.example.com,retail,emea,mid_market,active,false,false,2025-11-11T00:00:00Z
|
||||
acct_0096,parent_0096,Orbit Customer 096,customer-096.example.com,software,apac,mid_market,active,false,false,2025-12-12T00:00:00Z
|
||||
acct_0097,parent_0097,Orbit Customer 097,customer-097.example.com,manufacturing,na,mid_market,active,false,false,2025-01-13T00:00:00Z
|
||||
acct_0098,parent_0098,Orbit Customer 098,customer-098.example.com,healthcare,emea,mid_market,active,false,false,2025-02-14T00:00:00Z
|
||||
acct_0099,parent_0099,Orbit Customer 099,customer-099.example.com,financial_services,apac,mid_market,active,false,false,2025-03-15T00:00:00Z
|
||||
acct_0100,parent_0100,Orbit Customer 100,customer-100.example.com,retail,na,mid_market,active,false,false,2025-04-16T00:00:00Z
|
||||
acct_0101,parent_0101,Orbit Customer 101,customer-101.example.com,software,emea,mid_market,active,false,false,2025-05-17T00:00:00Z
|
||||
acct_0102,parent_0102,Orbit Customer 102,customer-102.example.com,manufacturing,apac,mid_market,active,false,false,2025-06-18T00:00:00Z
|
||||
acct_0103,parent_0103,Orbit Customer 103,customer-103.example.com,healthcare,na,mid_market,active,false,false,2025-07-19T00:00:00Z
|
||||
acct_0104,parent_0104,Orbit Customer 104,customer-104.example.com,financial_services,emea,mid_market,active,false,false,2025-08-20T00:00:00Z
|
||||
acct_0105,parent_0105,Orbit Customer 105,customer-105.example.com,retail,apac,mid_market,active,false,false,2025-09-21T00:00:00Z
|
||||
acct_0106,parent_0106,Orbit Customer 106,customer-106.example.com,software,na,mid_market,active,false,false,2025-10-22T00:00:00Z
|
||||
acct_0107,parent_0107,Orbit Customer 107,customer-107.example.com,manufacturing,emea,mid_market,active,false,false,2025-11-23T00:00:00Z
|
||||
acct_0108,parent_0108,Orbit Customer 108,customer-108.example.com,healthcare,apac,mid_market,active,false,false,2025-12-24T00:00:00Z
|
||||
acct_0109,parent_0109,Orbit Customer 109,customer-109.example.com,financial_services,na,mid_market,active,false,false,2025-01-25T00:00:00Z
|
||||
acct_0110,parent_0110,Orbit Customer 110,customer-110.example.com,retail,emea,mid_market,active,false,false,2025-02-26T00:00:00Z
|
||||
acct_0111,parent_0111,Orbit Customer 111,customer-111.example.com,software,apac,mid_market,active,false,false,2025-03-27T00:00:00Z
|
||||
acct_0112,parent_0112,Orbit Customer 112,customer-112.example.com,manufacturing,na,mid_market,active,false,false,2025-04-28T00:00:00Z
|
||||
acct_0113,parent_0113,Orbit Customer 113,customer-113.example.com,healthcare,emea,mid_market,active,false,false,2025-05-01T00:00:00Z
|
||||
acct_0114,parent_0114,Orbit Customer 114,customer-114.example.com,financial_services,apac,mid_market,active,false,false,2025-06-02T00:00:00Z
|
||||
acct_0115,parent_0115,Orbit Customer 115,customer-115.example.com,retail,na,mid_market,active,false,false,2025-07-03T00:00:00Z
|
||||
acct_0116,parent_0116,Orbit Customer 116,customer-116.example.com,software,emea,mid_market,active,false,false,2025-08-04T00:00:00Z
|
||||
acct_0117,parent_0117,Orbit Customer 117,customer-117.example.com,manufacturing,apac,mid_market,active,false,false,2025-09-05T00:00:00Z
|
||||
acct_0118,parent_0118,Orbit Customer 118,customer-118.example.com,healthcare,na,mid_market,active,false,false,2025-10-06T00:00:00Z
|
||||
acct_0119,parent_0119,Orbit Customer 119,customer-119.example.com,financial_services,emea,mid_market,active,false,false,2025-11-07T00:00:00Z
|
||||
acct_0120,parent_0120,Orbit Customer 120,customer-120.example.com,retail,apac,mid_market,active,false,false,2025-12-08T00:00:00Z
|
||||
acct_0121,parent_0121,Orbit Customer 121,customer-121.example.com,software,na,mid_market,active,false,false,2025-01-09T00:00:00Z
|
||||
acct_0122,parent_0122,Orbit Customer 122,customer-122.example.com,manufacturing,emea,mid_market,active,false,false,2025-02-10T00:00:00Z
|
||||
acct_0123,parent_0123,Orbit Customer 123,customer-123.example.com,healthcare,apac,mid_market,active,false,false,2025-03-11T00:00:00Z
|
||||
acct_0124,parent_0124,Orbit Customer 124,customer-124.example.com,financial_services,na,mid_market,active,false,false,2025-04-12T00:00:00Z
|
||||
acct_0125,parent_0125,Orbit Customer 125,customer-125.example.com,retail,emea,mid_market,active,false,false,2025-05-13T00:00:00Z
|
||||
acct_0126,parent_0126,Orbit Customer 126,customer-126.example.com,software,apac,mid_market,active,false,false,2025-06-14T00:00:00Z
|
||||
acct_0127,parent_0127,Orbit Customer 127,customer-127.example.com,manufacturing,na,mid_market,active,false,false,2025-07-15T00:00:00Z
|
||||
acct_0128,parent_0128,Orbit Customer 128,customer-128.example.com,healthcare,emea,mid_market,active,false,false,2025-08-16T00:00:00Z
|
||||
acct_0129,parent_0129,Orbit Customer 129,customer-129.example.com,financial_services,apac,mid_market,active,false,false,2025-09-17T00:00:00Z
|
||||
acct_0130,parent_0130,Orbit Customer 130,customer-130.example.com,retail,na,mid_market,active,false,false,2025-10-18T00:00:00Z
|
||||
acct_0131,parent_0131,Orbit Customer 131,customer-131.example.com,software,emea,mid_market,active,false,false,2025-11-19T00:00:00Z
|
||||
acct_0132,parent_0132,Orbit Customer 132,customer-132.example.com,manufacturing,apac,mid_market,active,false,false,2025-12-20T00:00:00Z
|
||||
acct_0133,parent_0001,Orbit Customer 133,customer-133.example.com,healthcare,na,mid_market,active,false,false,2025-01-21T00:00:00Z
|
||||
acct_0134,parent_0002,Orbit Customer 134,customer-134.example.com,financial_services,emea,mid_market,active,false,false,2025-02-22T00:00:00Z
|
||||
acct_0135,parent_0003,Orbit Customer 135,customer-135.example.com,retail,apac,mid_market,active,false,false,2025-03-23T00:00:00Z
|
||||
acct_0136,parent_0004,Orbit Customer 136,customer-136.example.com,software,na,mid_market,active,false,false,2025-04-24T00:00:00Z
|
||||
acct_0137,parent_0005,Orbit Customer 137,customer-137.example.com,manufacturing,emea,mid_market,active,false,false,2025-05-25T00:00:00Z
|
||||
acct_0138,parent_0006,Orbit Customer 138,customer-138.example.com,healthcare,apac,mid_market,active,false,false,2025-06-26T00:00:00Z
|
||||
acct_0139,parent_0007,Orbit Customer 139,customer-139.example.com,financial_services,na,mid_market,active,false,false,2025-07-27T00:00:00Z
|
||||
acct_0140,parent_0008,Orbit Customer 140,customer-140.example.com,retail,emea,mid_market,active,false,false,2025-08-28T00:00:00Z
|
||||
acct_0141,parent_0009,Orbit Customer 141,customer-141.example.com,software,apac,mid_market,active,false,false,2025-09-01T00:00:00Z
|
||||
acct_0142,parent_0010,Orbit Customer 142,customer-142.example.com,manufacturing,na,mid_market,active,false,false,2025-10-02T00:00:00Z
|
||||
acct_0143,parent_0011,Orbit Customer 143,customer-143.example.com,healthcare,emea,mid_market,active,false,false,2025-11-03T00:00:00Z
|
||||
acct_0144,parent_0012,Orbit Customer 144,customer-144.example.com,financial_services,apac,mid_market,active,false,false,2025-12-04T00:00:00Z
|
||||
acct_0145,parent_0013,Orbit Customer 145,customer-145.example.com,retail,na,mid_market,active,false,false,2025-01-05T00:00:00Z
|
||||
acct_0146,parent_0014,Orbit Customer 146,customer-146.example.com,software,emea,mid_market,active,false,false,2025-02-06T00:00:00Z
|
||||
acct_0147,parent_0015,Orbit Customer 147,customer-147.example.com,manufacturing,apac,mid_market,active,false,false,2025-03-07T00:00:00Z
|
||||
acct_0148,parent_0016,Orbit Customer 148,customer-148.example.com,healthcare,na,mid_market,active,false,false,2025-04-08T00:00:00Z
|
||||
acct_0149,parent_0017,Orbit Customer 149,customer-149.example.com,financial_services,emea,mid_market,active,false,false,2025-05-09T00:00:00Z
|
||||
acct_0150,parent_0018,Orbit Customer 150,customer-150.example.com,retail,apac,mid_market,active,false,false,2025-06-10T00:00:00Z
|
||||
acct_0151,parent_0019,Orbit Customer 151,customer-151.example.com,software,na,smb,active,false,false,2025-07-11T00:00:00Z
|
||||
acct_0152,parent_0020,Orbit Customer 152,customer-152.example.com,manufacturing,emea,smb,active,false,false,2025-08-12T00:00:00Z
|
||||
acct_0153,parent_0021,Orbit Customer 153,customer-153.example.com,healthcare,apac,smb,active,false,false,2025-09-13T00:00:00Z
|
||||
acct_0154,parent_0022,Orbit Customer 154,customer-154.example.com,financial_services,na,smb,active,false,false,2025-10-14T00:00:00Z
|
||||
acct_0155,parent_0023,Orbit Customer 155,customer-155.example.com,retail,emea,smb,active,false,false,2025-11-15T00:00:00Z
|
||||
acct_0156,parent_0024,Orbit Customer 156,customer-156.example.com,software,apac,smb,active,false,false,2025-12-16T00:00:00Z
|
||||
acct_0157,parent_0025,Orbit Customer 157,customer-157.example.com,manufacturing,na,smb,active,false,false,2025-01-17T00:00:00Z
|
||||
acct_0158,parent_0026,Orbit Customer 158,customer-158.example.com,healthcare,emea,smb,active,false,false,2025-02-18T00:00:00Z
|
||||
acct_0159,parent_0027,Orbit Customer 159,customer-159.example.com,financial_services,apac,smb,active,false,false,2025-03-19T00:00:00Z
|
||||
acct_0160,parent_0028,Orbit Customer 160,customer-160.example.com,retail,na,smb,active,false,false,2025-04-20T00:00:00Z
|
||||
acct_0161,parent_0029,Orbit Customer 161,customer-161.example.com,software,emea,smb,active,false,false,2025-05-21T00:00:00Z
|
||||
acct_0162,parent_0030,Orbit Customer 162,customer-162.example.com,manufacturing,apac,smb,active,false,false,2025-06-22T00:00:00Z
|
||||
acct_0163,parent_0031,Orbit Customer 163,customer-163.example.com,healthcare,na,smb,active,false,false,2025-07-23T00:00:00Z
|
||||
acct_0164,parent_0032,Orbit Customer 164,customer-164.example.com,financial_services,emea,smb,active,false,false,2025-08-24T00:00:00Z
|
||||
acct_0165,parent_0033,Orbit Customer 165,customer-165.example.com,retail,apac,smb,active,false,false,2025-09-25T00:00:00Z
|
||||
acct_0166,parent_0034,Orbit Customer 166,customer-166.example.com,software,na,smb,active,false,false,2025-10-26T00:00:00Z
|
||||
acct_0167,parent_0035,Orbit Customer 167,customer-167.example.com,manufacturing,emea,smb,active,false,false,2025-11-27T00:00:00Z
|
||||
acct_0168,parent_0036,Orbit Customer 168,customer-168.example.com,healthcare,apac,smb,active,false,false,2025-12-28T00:00:00Z
|
||||
acct_0169,parent_0037,Orbit Customer 169,customer-169.example.com,financial_services,na,smb,active,false,false,2025-01-01T00:00:00Z
|
||||
acct_0170,parent_0038,Orbit Customer 170,customer-170.example.com,retail,emea,smb,active,false,false,2025-02-02T00:00:00Z
|
||||
acct_0171,parent_0039,Orbit Customer 171,customer-171.example.com,software,apac,smb,active,false,false,2025-03-03T00:00:00Z
|
||||
acct_0172,parent_0040,Orbit Customer 172,customer-172.example.com,manufacturing,na,smb,active,false,false,2025-04-04T00:00:00Z
|
||||
acct_0173,parent_0041,Orbit Customer 173,customer-173.example.com,healthcare,emea,smb,active,false,false,2025-05-05T00:00:00Z
|
||||
acct_0174,parent_0042,Orbit Customer 174,customer-174.example.com,financial_services,apac,smb,active,false,false,2025-06-06T00:00:00Z
|
||||
acct_0175,parent_0043,Orbit Customer 175,customer-175.example.com,retail,na,smb,active,false,false,2025-07-07T00:00:00Z
|
||||
acct_0176,parent_0044,Orbit Customer 176,customer-176.example.com,software,emea,smb,active,false,false,2025-08-08T00:00:00Z
|
||||
acct_0177,parent_0045,Orbit Customer 177,customer-177.example.com,manufacturing,apac,smb,active,false,false,2025-09-09T00:00:00Z
|
||||
acct_0178,parent_0046,Orbit Customer 178,customer-178.example.com,healthcare,na,smb,active,false,false,2025-10-10T00:00:00Z
|
||||
acct_0179,parent_0047,Orbit Customer 179,customer-179.example.com,financial_services,emea,smb,active,false,false,2025-11-11T00:00:00Z
|
||||
acct_0180,parent_0048,Orbit Customer 180,customer-180.example.com,retail,apac,smb,active,false,false,2025-12-12T00:00:00Z
|
||||
acct_0181,parent_0049,Orbit Customer 181,customer-181.example.com,software,na,smb,active,false,false,2025-01-13T00:00:00Z
|
||||
acct_0182,parent_0050,Orbit Customer 182,customer-182.example.com,manufacturing,emea,smb,active,false,false,2025-02-14T00:00:00Z
|
||||
acct_0183,parent_0051,Orbit Customer 183,customer-183.example.com,healthcare,apac,smb,active,false,false,2025-03-15T00:00:00Z
|
||||
acct_0184,parent_0052,Orbit Customer 184,customer-184.example.com,financial_services,na,smb,active,false,false,2025-04-16T00:00:00Z
|
||||
acct_0185,parent_0053,Orbit Customer 185,customer-185.example.com,retail,emea,smb,active,false,false,2025-05-17T00:00:00Z
|
||||
acct_0186,parent_0054,Orbit Customer 186,customer-186.example.com,software,apac,smb,active,false,false,2025-06-18T00:00:00Z
|
||||
acct_0187,parent_0055,Orbit Customer 187,customer-187.example.com,manufacturing,na,smb,active,false,false,2025-07-19T00:00:00Z
|
||||
acct_0188,parent_0056,Orbit Customer 188,customer-188.example.com,healthcare,emea,smb,active,false,false,2025-08-20T00:00:00Z
|
||||
acct_0189,parent_0057,Orbit Customer 189,customer-189.example.com,financial_services,apac,smb,active,false,false,2025-09-21T00:00:00Z
|
||||
acct_0190,parent_0058,Orbit Customer 190,customer-190.example.com,retail,na,smb,active,false,false,2025-10-22T00:00:00Z
|
||||
acct_0191,parent_0059,Orbit Customer 191,customer-191.example.com,software,emea,smb,active,false,false,2025-11-23T00:00:00Z
|
||||
acct_0192,parent_0060,Orbit Customer 192,customer-192.example.com,manufacturing,apac,smb,active,false,false,2025-12-24T00:00:00Z
|
||||
acct_0193,parent_0061,Orbit Customer 193,customer-193.example.com,healthcare,na,smb,active,false,false,2025-01-25T00:00:00Z
|
||||
acct_0194,parent_0062,Orbit Customer 194,customer-194.example.com,financial_services,emea,smb,active,false,false,2025-02-26T00:00:00Z
|
||||
acct_0195,parent_0063,Orbit Customer 195,customer-195.example.com,retail,apac,smb,active,false,false,2025-03-27T00:00:00Z
|
||||
acct_0196,parent_0064,Orbit Customer 196,customer-196.example.com,software,na,smb,active,false,false,2025-04-28T00:00:00Z
|
||||
acct_0197,parent_0065,Orbit Customer 197,customer-197.example.com,manufacturing,emea,smb,active,false,false,2025-05-01T00:00:00Z
|
||||
acct_0198,parent_0066,Orbit Customer 198,customer-198.example.com,healthcare,apac,smb,active,false,false,2025-06-02T00:00:00Z
|
||||
acct_0199,parent_0067,Orbit Customer 199,customer-199.example.com,financial_services,na,smb,churned,false,false,2025-07-03T00:00:00Z
|
||||
acct_0200,parent_0068,Orbit Customer 200,customer-200.example.com,retail,emea,smb,churned,false,false,2025-08-04T00:00:00Z
|
||||
acct_0201,parent_0069,Orbit Customer 201,customer-201.example.com,software,apac,smb,internal,true,false,2025-09-05T00:00:00Z
|
||||
acct_0202,parent_0070,Orbit Customer 202,customer-202.example.com,manufacturing,na,smb,internal,true,false,2025-10-06T00:00:00Z
|
||||
acct_0203,parent_0071,Orbit Customer 203,customer-203.example.com,healthcare,emea,smb,internal,true,false,2025-11-07T00:00:00Z
|
||||
acct_0204,parent_0072,Orbit Customer 204,customer-204.example.com,financial_services,apac,smb,internal,true,false,2025-12-08T00:00:00Z
|
||||
acct_0205,parent_0073,Orbit Customer 205,customer-205.example.com,retail,na,smb,internal,true,false,2025-01-09T00:00:00Z
|
||||
acct_0206,parent_0074,Orbit Customer 206,customer-206.example.com,software,emea,smb,test,false,true,2025-02-10T00:00:00Z
|
||||
acct_0207,parent_0075,Orbit Customer 207,customer-207.example.com,manufacturing,apac,smb,test,false,true,2025-03-11T00:00:00Z
|
||||
acct_0208,parent_0076,Orbit Customer 208,customer-208.example.com,healthcare,na,smb,test,false,true,2025-04-12T00:00:00Z
|
||||
acct_0209,parent_0077,Orbit Customer 209,customer-209.example.com,financial_services,emea,smb,test,false,true,2025-05-13T00:00:00Z
|
||||
acct_0210,parent_0078,Orbit Customer 210,customer-210.example.com,retail,apac,smb,test,false,true,2025-06-14T00:00:00Z
|
||||
|
|
|
@ -0,0 +1,721 @@
|
|||
arr_movement_id,account_id,parent_account_id,contract_id,movement_date,movement_type,movement_reason,arr_delta_cents,starting_arr_cents,ending_arr_cents
|
||||
arr_move_0001,acct_0001,parent_0001,contract_0001,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0002,acct_0002,parent_0002,contract_0002,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0003,acct_0003,parent_0003,contract_0003,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0004,acct_0004,parent_0004,contract_0004,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0005,acct_0005,parent_0005,contract_0005,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0006,acct_0006,parent_0006,contract_0006,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0007,acct_0007,parent_0007,contract_0007,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0008,acct_0008,parent_0008,contract_0008,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0009,acct_0009,parent_0009,contract_0009,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0010,acct_0010,parent_0010,contract_0010,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0011,acct_0011,parent_0011,contract_0011,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0012,acct_0012,parent_0012,contract_0012,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0013,acct_0013,parent_0013,contract_0013,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0014,acct_0014,parent_0014,contract_0014,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0015,acct_0015,parent_0015,contract_0015,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0016,acct_0016,parent_0016,contract_0016,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0017,acct_0017,parent_0017,contract_0017,2026-02-15,expansion,seat_growth,6000000,70000000,76000000
|
||||
arr_move_0018,acct_0018,parent_0018,contract_0018,2026-02-20,contraction,discount_expiration,-4500000,50000000,45500000
|
||||
arr_move_0019,acct_0019,parent_0019,contract_0019,2026-02-20,contraction,discount_expiration,-4500000,50000000,45500000
|
||||
arr_move_0020,acct_0020,parent_0020,contract_0020,2026-02-20,contraction,discount_expiration,-4500000,50000000,45500000
|
||||
arr_move_0021,acct_0021,parent_0021,contract_0021,2026-02-20,contraction,discount_expiration,-4500000,50000000,45500000
|
||||
arr_move_0022,acct_0022,parent_0022,contract_0022,2026-02-20,contraction,discount_expiration,-4500000,50000000,45500000
|
||||
arr_move_0023,acct_0023,parent_0023,contract_0023,2026-02-20,contraction,discount_expiration,-4500000,50000000,45500000
|
||||
arr_move_0024,acct_0024,parent_0024,contract_0024,2026-02-20,contraction,discount_expiration,-4500000,50000000,45500000
|
||||
arr_move_0025,acct_0025,parent_0025,contract_0025,2026-02-20,contraction,discount_expiration,-4500000,50000000,45500000
|
||||
arr_move_0026,acct_0026,parent_0026,contract_0026,2026-02-20,contraction,discount_expiration,-4500000,50000000,45500000
|
||||
arr_move_0027,acct_0027,parent_0027,contract_0027,2026-02-20,contraction,discount_expiration,-4500000,50000000,45500000
|
||||
arr_move_0028,acct_0028,parent_0028,contract_0028,2026-02-20,contraction,discount_expiration,-5500000,100000000,94500000
|
||||
arr_move_0029,acct_0029,parent_0029,contract_0029,2026-03-10,churn,budget_loss,-5000000,100000000,95000000
|
||||
arr_move_0030,acct_0030,parent_0030,contract_0030,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0031,acct_0031,parent_0031,contract_0031,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0032,acct_0032,parent_0032,contract_0032,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0033,acct_0033,parent_0033,contract_0033,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0034,acct_0034,parent_0034,contract_0034,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0035,acct_0035,parent_0035,contract_0035,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0036,acct_0036,parent_0036,contract_0036,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0037,acct_0037,parent_0037,contract_0037,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0038,acct_0038,parent_0038,contract_0038,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0039,acct_0039,parent_0039,contract_0039,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0040,acct_0040,parent_0040,contract_0040,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0041,acct_0041,parent_0041,contract_0041,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0042,acct_0042,parent_0042,contract_0042,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0043,acct_0043,parent_0043,contract_0043,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0044,acct_0044,parent_0044,contract_0044,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0045,acct_0045,parent_0045,contract_0045,2025-11-15,expansion,seat_growth,11800000,50000000,61800000
|
||||
arr_move_0046,acct_0046,parent_0046,contract_0046,2025-11-20,contraction,scope_reduction,-2500000,100000000,97500000
|
||||
arr_move_0047,acct_0047,parent_0047,contract_0047,2025-11-20,contraction,scope_reduction,-2500000,100000000,97500000
|
||||
arr_move_0048,acct_0048,parent_0048,contract_0048,2025-11-20,contraction,scope_reduction,-2500000,100000000,97500000
|
||||
arr_move_0049,acct_0049,parent_0049,contract_0049,2025-11-20,contraction,scope_reduction,-2500000,100000000,97500000
|
||||
arr_move_0050,acct_0090,parent_0090,contract_0101,2025-06-15,new,generated_history,360000,3200000,3560000
|
||||
arr_move_0051,acct_0091,parent_0091,contract_0102,2025-06-15,expansion,generated_history,370000,3300000,3670000
|
||||
arr_move_0052,acct_0092,parent_0092,contract_0103,2025-06-15,contraction,generated_history,-250000,3400000,3150000
|
||||
arr_move_0053,acct_0093,parent_0093,contract_0104,2025-06-15,reactivation,generated_history,260000,3500000,3760000
|
||||
arr_move_0054,acct_0094,parent_0094,contract_0105,2025-06-15,new,generated_history,270000,3600000,3870000
|
||||
arr_move_0055,acct_0095,parent_0095,contract_0106,2025-06-15,expansion,generated_history,280000,3700000,3980000
|
||||
arr_move_0056,acct_0096,parent_0096,contract_0107,2025-06-15,contraction,generated_history,-290000,3800000,3510000
|
||||
arr_move_0057,acct_0097,parent_0097,contract_0108,2025-06-15,reactivation,generated_history,300000,2000000,2300000
|
||||
arr_move_0058,acct_0098,parent_0098,contract_0109,2025-06-15,new,generated_history,310000,2100000,2410000
|
||||
arr_move_0059,acct_0099,parent_0099,contract_0110,2025-06-15,expansion,generated_history,320000,2200000,2520000
|
||||
arr_move_0060,acct_0100,parent_0100,contract_0111,2025-06-15,contraction,generated_history,-330000,2300000,1970000
|
||||
arr_move_0061,acct_0101,parent_0101,contract_0112,2025-06-15,reactivation,generated_history,340000,2400000,2740000
|
||||
arr_move_0062,acct_0102,parent_0102,contract_0113,2025-06-15,new,generated_history,350000,2500000,2850000
|
||||
arr_move_0063,acct_0103,parent_0103,contract_0114,2025-06-15,expansion,generated_history,360000,2600000,2960000
|
||||
arr_move_0064,acct_0104,parent_0104,contract_0115,2025-06-15,contraction,generated_history,-370000,2700000,2330000
|
||||
arr_move_0065,acct_0105,parent_0105,contract_0116,2025-06-15,reactivation,generated_history,250000,2800000,3050000
|
||||
arr_move_0066,acct_0106,parent_0106,contract_0117,2025-06-15,new,generated_history,260000,2900000,3160000
|
||||
arr_move_0067,acct_0107,parent_0107,contract_0118,2025-06-15,expansion,generated_history,270000,3000000,3270000
|
||||
arr_move_0068,acct_0108,parent_0108,contract_0119,2025-06-15,contraction,generated_history,-280000,3100000,2820000
|
||||
arr_move_0069,acct_0109,parent_0109,contract_0120,2025-06-15,reactivation,generated_history,290000,3200000,3490000
|
||||
arr_move_0070,acct_0110,parent_0110,contract_0121,2025-06-15,new,generated_history,300000,3300000,3600000
|
||||
arr_move_0071,acct_0111,parent_0111,contract_0122,2025-06-15,expansion,generated_history,310000,3400000,3710000
|
||||
arr_move_0072,acct_0112,parent_0112,contract_0123,2025-06-15,contraction,generated_history,-320000,3500000,3180000
|
||||
arr_move_0073,acct_0113,parent_0113,contract_0124,2025-06-15,reactivation,generated_history,330000,3600000,3930000
|
||||
arr_move_0074,acct_0114,parent_0114,contract_0125,2025-06-15,new,generated_history,340000,3700000,4040000
|
||||
arr_move_0075,acct_0115,parent_0115,contract_0126,2025-06-15,expansion,generated_history,350000,3800000,4150000
|
||||
arr_move_0076,acct_0116,parent_0116,contract_0127,2025-06-15,contraction,generated_history,-360000,2000000,1640000
|
||||
arr_move_0077,acct_0117,parent_0117,contract_0128,2025-06-15,reactivation,generated_history,370000,2100000,2470000
|
||||
arr_move_0078,acct_0118,parent_0118,contract_0129,2025-06-15,new,generated_history,250000,2200000,2450000
|
||||
arr_move_0079,acct_0119,parent_0119,contract_0130,2025-06-15,expansion,generated_history,260000,2300000,2560000
|
||||
arr_move_0080,acct_0120,parent_0120,contract_0131,2025-06-15,contraction,generated_history,-270000,2400000,2130000
|
||||
arr_move_0081,acct_0121,parent_0121,contract_0132,2025-06-15,reactivation,generated_history,280000,2500000,2780000
|
||||
arr_move_0082,acct_0122,parent_0122,contract_0133,2025-06-15,new,generated_history,290000,2600000,2890000
|
||||
arr_move_0083,acct_0123,parent_0123,contract_0134,2025-06-15,expansion,generated_history,300000,2700000,3000000
|
||||
arr_move_0084,acct_0124,parent_0124,contract_0135,2025-06-15,contraction,generated_history,-310000,2800000,2490000
|
||||
arr_move_0085,acct_0125,parent_0125,contract_0136,2025-06-15,reactivation,generated_history,320000,2900000,3220000
|
||||
arr_move_0086,acct_0126,parent_0126,contract_0137,2025-06-15,new,generated_history,330000,3000000,3330000
|
||||
arr_move_0087,acct_0127,parent_0127,contract_0138,2025-06-15,expansion,generated_history,340000,3100000,3440000
|
||||
arr_move_0088,acct_0128,parent_0128,contract_0139,2025-06-15,contraction,generated_history,-350000,3200000,2850000
|
||||
arr_move_0089,acct_0129,parent_0129,contract_0140,2025-06-15,reactivation,generated_history,360000,3300000,3660000
|
||||
arr_move_0090,acct_0130,parent_0130,contract_0141,2025-06-15,new,generated_history,370000,3400000,3770000
|
||||
arr_move_0091,acct_0131,parent_0131,contract_0142,2025-06-15,expansion,generated_history,250000,3500000,3750000
|
||||
arr_move_0092,acct_0132,parent_0132,contract_0143,2025-06-15,contraction,generated_history,-260000,3600000,3340000
|
||||
arr_move_0093,acct_0133,parent_0001,contract_0144,2025-06-15,reactivation,generated_history,270000,3700000,3970000
|
||||
arr_move_0094,acct_0134,parent_0002,contract_0145,2025-06-15,new,generated_history,280000,3800000,4080000
|
||||
arr_move_0095,acct_0135,parent_0003,contract_0146,2025-06-15,expansion,generated_history,290000,2000000,2290000
|
||||
arr_move_0096,acct_0136,parent_0004,contract_0147,2025-06-15,contraction,generated_history,-300000,2100000,1800000
|
||||
arr_move_0097,acct_0137,parent_0005,contract_0148,2025-06-15,reactivation,generated_history,310000,2200000,2510000
|
||||
arr_move_0098,acct_0138,parent_0006,contract_0149,2025-06-15,new,generated_history,320000,2300000,2620000
|
||||
arr_move_0099,acct_0139,parent_0007,contract_0150,2025-06-15,expansion,generated_history,330000,2400000,2730000
|
||||
arr_move_0100,acct_0140,parent_0008,contract_0151,2025-06-15,contraction,generated_history,-340000,2500000,2160000
|
||||
arr_move_0101,acct_0141,parent_0009,contract_0152,2025-06-15,reactivation,generated_history,350000,2600000,2950000
|
||||
arr_move_0102,acct_0142,parent_0010,contract_0153,2025-06-15,new,generated_history,360000,2700000,3060000
|
||||
arr_move_0103,acct_0143,parent_0011,contract_0154,2025-06-15,expansion,generated_history,370000,2800000,3170000
|
||||
arr_move_0104,acct_0144,parent_0012,contract_0155,2025-06-15,contraction,generated_history,-250000,2900000,2650000
|
||||
arr_move_0105,acct_0145,parent_0013,contract_0156,2025-06-15,reactivation,generated_history,260000,3000000,3260000
|
||||
arr_move_0106,acct_0146,parent_0014,contract_0157,2025-06-15,new,generated_history,270000,3100000,3370000
|
||||
arr_move_0107,acct_0147,parent_0015,contract_0158,2025-06-15,expansion,generated_history,280000,3200000,3480000
|
||||
arr_move_0108,acct_0148,parent_0016,contract_0159,2025-06-15,contraction,generated_history,-290000,3300000,3010000
|
||||
arr_move_0109,acct_0149,parent_0017,contract_0160,2025-06-15,reactivation,generated_history,300000,3400000,3700000
|
||||
arr_move_0110,acct_0150,parent_0018,contract_0161,2025-06-15,new,generated_history,310000,3500000,3810000
|
||||
arr_move_0111,acct_0151,parent_0019,contract_0162,2025-06-15,expansion,generated_history,320000,3600000,3920000
|
||||
arr_move_0112,acct_0152,parent_0020,contract_0163,2025-06-15,contraction,generated_history,-330000,3700000,3370000
|
||||
arr_move_0113,acct_0153,parent_0021,contract_0164,2025-06-15,reactivation,generated_history,340000,3800000,4140000
|
||||
arr_move_0114,acct_0154,parent_0022,contract_0165,2025-06-15,new,generated_history,350000,2000000,2350000
|
||||
arr_move_0115,acct_0155,parent_0023,contract_0166,2025-06-15,expansion,generated_history,360000,2100000,2460000
|
||||
arr_move_0116,acct_0156,parent_0024,contract_0167,2025-06-15,contraction,generated_history,-370000,2200000,1830000
|
||||
arr_move_0117,acct_0157,parent_0025,contract_0168,2025-06-15,reactivation,generated_history,250000,2300000,2550000
|
||||
arr_move_0118,acct_0158,parent_0026,contract_0169,2025-06-15,new,generated_history,260000,2400000,2660000
|
||||
arr_move_0119,acct_0159,parent_0027,contract_0170,2025-06-15,expansion,generated_history,270000,2500000,2770000
|
||||
arr_move_0120,acct_0160,parent_0028,contract_0171,2025-06-15,contraction,generated_history,-280000,2600000,2320000
|
||||
arr_move_0121,acct_0161,parent_0029,contract_0172,2025-06-15,reactivation,generated_history,290000,2700000,2990000
|
||||
arr_move_0122,acct_0162,parent_0030,contract_0173,2025-06-15,new,generated_history,300000,2800000,3100000
|
||||
arr_move_0123,acct_0163,parent_0031,contract_0174,2025-06-15,expansion,generated_history,310000,2900000,3210000
|
||||
arr_move_0124,acct_0164,parent_0032,contract_0175,2025-06-15,contraction,generated_history,-320000,3000000,2680000
|
||||
arr_move_0125,acct_0165,parent_0033,contract_0176,2025-06-15,reactivation,generated_history,330000,3100000,3430000
|
||||
arr_move_0126,acct_0166,parent_0034,contract_0177,2025-06-15,new,generated_history,340000,3200000,3540000
|
||||
arr_move_0127,acct_0167,parent_0035,contract_0178,2025-06-15,expansion,generated_history,350000,3300000,3650000
|
||||
arr_move_0128,acct_0168,parent_0036,contract_0179,2025-06-15,contraction,generated_history,-360000,3400000,3040000
|
||||
arr_move_0129,acct_0169,parent_0037,contract_0180,2025-06-15,reactivation,generated_history,370000,3500000,3870000
|
||||
arr_move_0130,acct_0170,parent_0038,contract_0181,2025-06-15,new,generated_history,250000,3600000,3850000
|
||||
arr_move_0131,acct_0171,parent_0039,contract_0182,2025-06-15,expansion,generated_history,260000,3700000,3960000
|
||||
arr_move_0132,acct_0172,parent_0040,contract_0183,2025-06-15,contraction,generated_history,-270000,3800000,3530000
|
||||
arr_move_0133,acct_0173,parent_0041,contract_0184,2025-06-15,reactivation,generated_history,280000,2000000,2280000
|
||||
arr_move_0134,acct_0174,parent_0042,contract_0185,2025-06-15,new,generated_history,290000,2100000,2390000
|
||||
arr_move_0135,acct_0175,parent_0043,contract_0186,2025-06-15,expansion,generated_history,300000,2200000,2500000
|
||||
arr_move_0136,acct_0176,parent_0044,contract_0187,2025-06-15,contraction,generated_history,-310000,2300000,1990000
|
||||
arr_move_0137,acct_0177,parent_0045,contract_0188,2025-06-15,reactivation,generated_history,320000,2400000,2720000
|
||||
arr_move_0138,acct_0178,parent_0046,contract_0189,2025-06-15,new,generated_history,330000,2500000,2830000
|
||||
arr_move_0139,acct_0179,parent_0047,contract_0190,2025-06-15,expansion,generated_history,340000,2600000,2940000
|
||||
arr_move_0140,acct_0180,parent_0048,contract_0191,2025-06-15,contraction,generated_history,-350000,2700000,2350000
|
||||
arr_move_0141,acct_0181,parent_0049,contract_0192,2025-06-15,reactivation,generated_history,360000,2800000,3160000
|
||||
arr_move_0142,acct_0182,parent_0050,contract_0193,2025-06-15,new,generated_history,370000,2900000,3270000
|
||||
arr_move_0143,acct_0183,parent_0051,contract_0194,2025-06-15,expansion,generated_history,250000,3000000,3250000
|
||||
arr_move_0144,acct_0184,parent_0052,contract_0195,2025-06-15,contraction,generated_history,-260000,3100000,2840000
|
||||
arr_move_0145,acct_0185,parent_0053,contract_0196,2025-06-15,reactivation,generated_history,270000,3200000,3470000
|
||||
arr_move_0146,acct_0186,parent_0054,contract_0197,2025-06-15,new,generated_history,280000,3300000,3580000
|
||||
arr_move_0147,acct_0187,parent_0055,contract_0198,2025-06-15,expansion,generated_history,290000,3400000,3690000
|
||||
arr_move_0148,acct_0188,parent_0056,contract_0199,2025-06-15,contraction,generated_history,-300000,3500000,3200000
|
||||
arr_move_0149,acct_0189,parent_0057,contract_0200,2025-06-15,reactivation,generated_history,310000,3600000,3910000
|
||||
arr_move_0150,acct_0090,parent_0090,contract_0201,2025-06-15,new,generated_history,320000,3700000,4020000
|
||||
arr_move_0151,acct_0091,parent_0091,contract_0202,2025-06-15,expansion,generated_history,330000,3800000,4130000
|
||||
arr_move_0152,acct_0092,parent_0092,contract_0203,2025-06-15,contraction,generated_history,-340000,2000000,1660000
|
||||
arr_move_0153,acct_0093,parent_0093,contract_0204,2025-06-15,reactivation,generated_history,350000,2100000,2450000
|
||||
arr_move_0154,acct_0094,parent_0094,contract_0205,2025-06-15,new,generated_history,360000,2200000,2560000
|
||||
arr_move_0155,acct_0095,parent_0095,contract_0206,2025-06-15,expansion,generated_history,370000,2300000,2670000
|
||||
arr_move_0156,acct_0096,parent_0096,contract_0207,2025-06-15,contraction,generated_history,-250000,2400000,2150000
|
||||
arr_move_0157,acct_0097,parent_0097,contract_0208,2025-06-15,reactivation,generated_history,260000,2500000,2760000
|
||||
arr_move_0158,acct_0098,parent_0098,contract_0209,2025-06-15,new,generated_history,270000,2600000,2870000
|
||||
arr_move_0159,acct_0099,parent_0099,contract_0210,2025-06-15,expansion,generated_history,280000,2700000,2980000
|
||||
arr_move_0160,acct_0100,parent_0100,contract_0211,2025-06-15,contraction,generated_history,-290000,2800000,2510000
|
||||
arr_move_0161,acct_0101,parent_0101,contract_0212,2025-06-15,reactivation,generated_history,300000,2900000,3200000
|
||||
arr_move_0162,acct_0102,parent_0102,contract_0213,2025-06-15,new,generated_history,310000,3000000,3310000
|
||||
arr_move_0163,acct_0103,parent_0103,contract_0214,2025-06-15,expansion,generated_history,320000,3100000,3420000
|
||||
arr_move_0164,acct_0104,parent_0104,contract_0215,2025-06-15,contraction,generated_history,-330000,3200000,2870000
|
||||
arr_move_0165,acct_0105,parent_0105,contract_0216,2025-06-15,reactivation,generated_history,340000,3300000,3640000
|
||||
arr_move_0166,acct_0106,parent_0106,contract_0217,2025-06-15,new,generated_history,350000,3400000,3750000
|
||||
arr_move_0167,acct_0107,parent_0107,contract_0218,2025-06-15,expansion,generated_history,360000,3500000,3860000
|
||||
arr_move_0168,acct_0108,parent_0108,contract_0219,2025-06-15,contraction,generated_history,-370000,3600000,3230000
|
||||
arr_move_0169,acct_0109,parent_0109,contract_0220,2025-06-15,reactivation,generated_history,250000,3700000,3950000
|
||||
arr_move_0170,acct_0110,parent_0110,contract_0221,2025-06-15,new,generated_history,260000,3800000,4060000
|
||||
arr_move_0171,acct_0111,parent_0111,contract_0222,2025-06-15,expansion,generated_history,270000,2000000,2270000
|
||||
arr_move_0172,acct_0112,parent_0112,contract_0223,2025-06-15,contraction,generated_history,-280000,2100000,1820000
|
||||
arr_move_0173,acct_0113,parent_0113,contract_0224,2025-06-15,reactivation,generated_history,290000,2200000,2490000
|
||||
arr_move_0174,acct_0114,parent_0114,contract_0225,2025-06-15,new,generated_history,300000,2300000,2600000
|
||||
arr_move_0175,acct_0115,parent_0115,contract_0226,2025-06-15,expansion,generated_history,310000,2400000,2710000
|
||||
arr_move_0176,acct_0116,parent_0116,contract_0227,2025-06-15,contraction,generated_history,-320000,2500000,2180000
|
||||
arr_move_0177,acct_0117,parent_0117,contract_0228,2025-06-15,reactivation,generated_history,330000,2600000,2930000
|
||||
arr_move_0178,acct_0118,parent_0118,contract_0229,2025-06-15,new,generated_history,340000,2700000,3040000
|
||||
arr_move_0179,acct_0119,parent_0119,contract_0230,2025-06-15,expansion,generated_history,350000,2800000,3150000
|
||||
arr_move_0180,acct_0120,parent_0120,contract_0231,2025-06-15,contraction,generated_history,-360000,2900000,2540000
|
||||
arr_move_0181,acct_0121,parent_0121,contract_0232,2025-06-15,reactivation,generated_history,370000,3000000,3370000
|
||||
arr_move_0182,acct_0122,parent_0122,contract_0233,2025-06-15,new,generated_history,250000,3100000,3350000
|
||||
arr_move_0183,acct_0123,parent_0123,contract_0234,2025-06-15,expansion,generated_history,260000,3200000,3460000
|
||||
arr_move_0184,acct_0124,parent_0124,contract_0235,2025-06-15,contraction,generated_history,-270000,3300000,3030000
|
||||
arr_move_0185,acct_0125,parent_0125,contract_0236,2025-06-15,reactivation,generated_history,280000,3400000,3680000
|
||||
arr_move_0186,acct_0126,parent_0126,contract_0237,2025-06-15,new,generated_history,290000,3500000,3790000
|
||||
arr_move_0187,acct_0127,parent_0127,contract_0238,2025-06-15,expansion,generated_history,300000,3600000,3900000
|
||||
arr_move_0188,acct_0128,parent_0128,contract_0239,2025-06-15,contraction,generated_history,-310000,3700000,3390000
|
||||
arr_move_0189,acct_0129,parent_0129,contract_0240,2025-06-15,reactivation,generated_history,320000,3800000,4120000
|
||||
arr_move_0190,acct_0130,parent_0130,contract_0241,2025-06-15,new,generated_history,330000,2000000,2330000
|
||||
arr_move_0191,acct_0131,parent_0131,contract_0242,2025-06-15,expansion,generated_history,340000,2100000,2440000
|
||||
arr_move_0192,acct_0132,parent_0132,contract_0243,2025-06-15,contraction,generated_history,-350000,2200000,1850000
|
||||
arr_move_0193,acct_0133,parent_0001,contract_0244,2025-06-15,reactivation,generated_history,360000,2300000,2660000
|
||||
arr_move_0194,acct_0134,parent_0002,contract_0245,2025-06-15,new,generated_history,370000,2400000,2770000
|
||||
arr_move_0195,acct_0135,parent_0003,contract_0246,2025-06-15,expansion,generated_history,250000,2500000,2750000
|
||||
arr_move_0196,acct_0136,parent_0004,contract_0247,2025-06-15,contraction,generated_history,-260000,2600000,2340000
|
||||
arr_move_0197,acct_0137,parent_0005,contract_0248,2025-06-15,reactivation,generated_history,270000,2700000,2970000
|
||||
arr_move_0198,acct_0138,parent_0006,contract_0249,2025-06-15,new,generated_history,280000,2800000,3080000
|
||||
arr_move_0199,acct_0139,parent_0007,contract_0250,2025-06-15,expansion,generated_history,290000,2900000,3190000
|
||||
arr_move_0200,acct_0140,parent_0008,contract_0251,2025-06-15,contraction,generated_history,-300000,3000000,2700000
|
||||
arr_move_0201,acct_0141,parent_0009,contract_0252,2025-06-15,reactivation,generated_history,310000,3100000,3410000
|
||||
arr_move_0202,acct_0142,parent_0010,contract_0253,2025-06-15,new,generated_history,320000,3200000,3520000
|
||||
arr_move_0203,acct_0143,parent_0011,contract_0254,2025-06-15,expansion,generated_history,330000,3300000,3630000
|
||||
arr_move_0204,acct_0144,parent_0012,contract_0255,2025-06-15,contraction,generated_history,-340000,3400000,3060000
|
||||
arr_move_0205,acct_0145,parent_0013,contract_0256,2025-06-15,reactivation,generated_history,350000,3500000,3850000
|
||||
arr_move_0206,acct_0146,parent_0014,contract_0257,2025-06-15,new,generated_history,360000,3600000,3960000
|
||||
arr_move_0207,acct_0147,parent_0015,contract_0258,2025-06-15,expansion,generated_history,370000,3700000,4070000
|
||||
arr_move_0208,acct_0148,parent_0016,contract_0259,2025-06-15,contraction,generated_history,-250000,3800000,3550000
|
||||
arr_move_0209,acct_0149,parent_0017,contract_0260,2025-06-15,reactivation,generated_history,260000,2000000,2260000
|
||||
arr_move_0210,acct_0150,parent_0018,contract_0261,2025-06-15,new,generated_history,270000,2100000,2370000
|
||||
arr_move_0211,acct_0151,parent_0019,contract_0262,2025-06-15,expansion,generated_history,280000,2200000,2480000
|
||||
arr_move_0212,acct_0152,parent_0020,contract_0263,2025-06-15,contraction,generated_history,-290000,2300000,2010000
|
||||
arr_move_0213,acct_0153,parent_0021,contract_0264,2025-06-15,reactivation,generated_history,300000,2400000,2700000
|
||||
arr_move_0214,acct_0154,parent_0022,contract_0265,2025-06-15,new,generated_history,310000,2500000,2810000
|
||||
arr_move_0215,acct_0155,parent_0023,contract_0266,2025-06-15,expansion,generated_history,320000,2600000,2920000
|
||||
arr_move_0216,acct_0156,parent_0024,contract_0267,2025-06-15,contraction,generated_history,-330000,2700000,2370000
|
||||
arr_move_0217,acct_0157,parent_0025,contract_0268,2025-06-15,reactivation,generated_history,340000,2800000,3140000
|
||||
arr_move_0218,acct_0158,parent_0026,contract_0269,2025-06-15,new,generated_history,350000,2900000,3250000
|
||||
arr_move_0219,acct_0159,parent_0027,contract_0270,2025-06-15,expansion,generated_history,360000,3000000,3360000
|
||||
arr_move_0220,acct_0160,parent_0028,contract_0271,2025-06-15,contraction,generated_history,-370000,3100000,2730000
|
||||
arr_move_0221,acct_0161,parent_0029,contract_0272,2025-06-15,reactivation,generated_history,250000,3200000,3450000
|
||||
arr_move_0222,acct_0162,parent_0030,contract_0273,2025-06-15,new,generated_history,260000,3300000,3560000
|
||||
arr_move_0223,acct_0163,parent_0031,contract_0274,2025-06-15,expansion,generated_history,270000,3400000,3670000
|
||||
arr_move_0224,acct_0164,parent_0032,contract_0275,2025-06-15,contraction,generated_history,-280000,3500000,3220000
|
||||
arr_move_0225,acct_0165,parent_0033,contract_0276,2025-06-15,reactivation,generated_history,290000,3600000,3890000
|
||||
arr_move_0226,acct_0166,parent_0034,contract_0277,2025-06-15,new,generated_history,300000,3700000,4000000
|
||||
arr_move_0227,acct_0167,parent_0035,contract_0278,2025-06-15,expansion,generated_history,310000,3800000,4110000
|
||||
arr_move_0228,acct_0168,parent_0036,contract_0279,2025-06-15,contraction,generated_history,-320000,2000000,1680000
|
||||
arr_move_0229,acct_0169,parent_0037,contract_0280,2025-06-15,reactivation,generated_history,330000,2100000,2430000
|
||||
arr_move_0230,acct_0170,parent_0038,contract_0281,2025-06-15,new,generated_history,340000,2200000,2540000
|
||||
arr_move_0231,acct_0171,parent_0039,contract_0282,2025-06-15,expansion,generated_history,350000,2300000,2650000
|
||||
arr_move_0232,acct_0172,parent_0040,contract_0283,2025-06-15,contraction,generated_history,-360000,2400000,2040000
|
||||
arr_move_0233,acct_0173,parent_0041,contract_0284,2025-06-15,reactivation,generated_history,370000,2500000,2870000
|
||||
arr_move_0234,acct_0174,parent_0042,contract_0285,2025-06-15,new,generated_history,250000,2600000,2850000
|
||||
arr_move_0235,acct_0175,parent_0043,contract_0286,2025-06-15,expansion,generated_history,260000,2700000,2960000
|
||||
arr_move_0236,acct_0176,parent_0044,contract_0287,2025-06-15,contraction,generated_history,-270000,2800000,2530000
|
||||
arr_move_0237,acct_0177,parent_0045,contract_0288,2025-06-15,reactivation,generated_history,280000,2900000,3180000
|
||||
arr_move_0238,acct_0178,parent_0046,contract_0289,2025-06-15,new,generated_history,290000,3000000,3290000
|
||||
arr_move_0239,acct_0179,parent_0047,contract_0290,2025-06-15,expansion,generated_history,300000,3100000,3400000
|
||||
arr_move_0240,acct_0180,parent_0048,contract_0291,2025-06-15,contraction,generated_history,-310000,3200000,2890000
|
||||
arr_move_0241,acct_0181,parent_0049,contract_0292,2025-06-15,reactivation,generated_history,320000,3300000,3620000
|
||||
arr_move_0242,acct_0182,parent_0050,contract_0293,2025-06-15,new,generated_history,330000,3400000,3730000
|
||||
arr_move_0243,acct_0183,parent_0051,contract_0294,2025-06-15,expansion,generated_history,340000,3500000,3840000
|
||||
arr_move_0244,acct_0184,parent_0052,contract_0295,2025-06-15,contraction,generated_history,-350000,3600000,3250000
|
||||
arr_move_0245,acct_0185,parent_0053,contract_0296,2025-06-15,reactivation,generated_history,360000,3700000,4060000
|
||||
arr_move_0246,acct_0186,parent_0054,contract_0297,2025-06-15,new,generated_history,370000,3800000,4170000
|
||||
arr_move_0247,acct_0187,parent_0055,contract_0298,2025-06-15,expansion,generated_history,250000,2000000,2250000
|
||||
arr_move_0248,acct_0188,parent_0056,contract_0299,2025-06-15,contraction,generated_history,-260000,2100000,1840000
|
||||
arr_move_0249,acct_0189,parent_0057,contract_0300,2025-06-15,reactivation,generated_history,270000,2200000,2470000
|
||||
arr_move_0250,acct_0090,parent_0090,contract_0301,2025-06-15,new,generated_history,280000,2300000,2580000
|
||||
arr_move_0251,acct_0091,parent_0091,contract_0302,2025-06-15,expansion,generated_history,290000,2400000,2690000
|
||||
arr_move_0252,acct_0092,parent_0092,contract_0303,2025-06-15,contraction,generated_history,-300000,2500000,2200000
|
||||
arr_move_0253,acct_0093,parent_0093,contract_0304,2025-06-15,reactivation,generated_history,310000,2600000,2910000
|
||||
arr_move_0254,acct_0094,parent_0094,contract_0305,2025-06-15,new,generated_history,320000,2700000,3020000
|
||||
arr_move_0255,acct_0095,parent_0095,contract_0306,2025-06-15,expansion,generated_history,330000,2800000,3130000
|
||||
arr_move_0256,acct_0096,parent_0096,contract_0307,2025-06-15,contraction,generated_history,-340000,2900000,2560000
|
||||
arr_move_0257,acct_0097,parent_0097,contract_0308,2025-06-15,reactivation,generated_history,350000,3000000,3350000
|
||||
arr_move_0258,acct_0098,parent_0098,contract_0309,2025-06-15,new,generated_history,360000,3100000,3460000
|
||||
arr_move_0259,acct_0099,parent_0099,contract_0310,2025-06-15,expansion,generated_history,370000,3200000,3570000
|
||||
arr_move_0260,acct_0100,parent_0100,contract_0311,2025-06-15,contraction,generated_history,-250000,3300000,3050000
|
||||
arr_move_0261,acct_0101,parent_0101,contract_0312,2025-06-15,reactivation,generated_history,260000,3400000,3660000
|
||||
arr_move_0262,acct_0102,parent_0102,contract_0313,2025-06-15,new,generated_history,270000,3500000,3770000
|
||||
arr_move_0263,acct_0103,parent_0103,contract_0314,2025-06-15,expansion,generated_history,280000,3600000,3880000
|
||||
arr_move_0264,acct_0104,parent_0104,contract_0315,2025-06-15,contraction,generated_history,-290000,3700000,3410000
|
||||
arr_move_0265,acct_0105,parent_0105,contract_0316,2025-06-15,reactivation,generated_history,300000,3800000,4100000
|
||||
arr_move_0266,acct_0106,parent_0106,contract_0317,2025-06-15,new,generated_history,310000,2000000,2310000
|
||||
arr_move_0267,acct_0107,parent_0107,contract_0318,2025-06-15,expansion,generated_history,320000,2100000,2420000
|
||||
arr_move_0268,acct_0108,parent_0108,contract_0319,2025-06-15,contraction,generated_history,-330000,2200000,1870000
|
||||
arr_move_0269,acct_0109,parent_0109,contract_0320,2025-06-15,reactivation,generated_history,340000,2300000,2640000
|
||||
arr_move_0270,acct_0110,parent_0110,contract_0101,2025-06-15,new,generated_history,350000,2400000,2750000
|
||||
arr_move_0271,acct_0111,parent_0111,contract_0102,2025-06-15,expansion,generated_history,360000,2500000,2860000
|
||||
arr_move_0272,acct_0112,parent_0112,contract_0103,2025-06-15,contraction,generated_history,-370000,2600000,2230000
|
||||
arr_move_0273,acct_0113,parent_0113,contract_0104,2025-06-15,reactivation,generated_history,250000,2700000,2950000
|
||||
arr_move_0274,acct_0114,parent_0114,contract_0105,2025-06-15,new,generated_history,260000,2800000,3060000
|
||||
arr_move_0275,acct_0115,parent_0115,contract_0106,2025-06-15,expansion,generated_history,270000,2900000,3170000
|
||||
arr_move_0276,acct_0116,parent_0116,contract_0107,2025-06-15,contraction,generated_history,-280000,3000000,2720000
|
||||
arr_move_0277,acct_0117,parent_0117,contract_0108,2025-06-15,reactivation,generated_history,290000,3100000,3390000
|
||||
arr_move_0278,acct_0118,parent_0118,contract_0109,2025-06-15,new,generated_history,300000,3200000,3500000
|
||||
arr_move_0279,acct_0119,parent_0119,contract_0110,2025-06-15,expansion,generated_history,310000,3300000,3610000
|
||||
arr_move_0280,acct_0120,parent_0120,contract_0111,2025-06-15,contraction,generated_history,-320000,3400000,3080000
|
||||
arr_move_0281,acct_0121,parent_0121,contract_0112,2025-06-15,reactivation,generated_history,330000,3500000,3830000
|
||||
arr_move_0282,acct_0122,parent_0122,contract_0113,2025-06-15,new,generated_history,340000,3600000,3940000
|
||||
arr_move_0283,acct_0123,parent_0123,contract_0114,2025-06-15,expansion,generated_history,350000,3700000,4050000
|
||||
arr_move_0284,acct_0124,parent_0124,contract_0115,2025-06-15,contraction,generated_history,-360000,3800000,3440000
|
||||
arr_move_0285,acct_0125,parent_0125,contract_0116,2025-06-15,reactivation,generated_history,370000,2000000,2370000
|
||||
arr_move_0286,acct_0126,parent_0126,contract_0117,2025-06-15,new,generated_history,250000,2100000,2350000
|
||||
arr_move_0287,acct_0127,parent_0127,contract_0118,2025-06-15,expansion,generated_history,260000,2200000,2460000
|
||||
arr_move_0288,acct_0128,parent_0128,contract_0119,2025-06-15,contraction,generated_history,-270000,2300000,2030000
|
||||
arr_move_0289,acct_0129,parent_0129,contract_0120,2025-06-15,reactivation,generated_history,280000,2400000,2680000
|
||||
arr_move_0290,acct_0130,parent_0130,contract_0121,2025-06-15,new,generated_history,290000,2500000,2790000
|
||||
arr_move_0291,acct_0131,parent_0131,contract_0122,2025-06-15,expansion,generated_history,300000,2600000,2900000
|
||||
arr_move_0292,acct_0132,parent_0132,contract_0123,2025-06-15,contraction,generated_history,-310000,2700000,2390000
|
||||
arr_move_0293,acct_0133,parent_0001,contract_0124,2025-06-15,reactivation,generated_history,320000,2800000,3120000
|
||||
arr_move_0294,acct_0134,parent_0002,contract_0125,2025-06-15,new,generated_history,330000,2900000,3230000
|
||||
arr_move_0295,acct_0135,parent_0003,contract_0126,2025-06-15,expansion,generated_history,340000,3000000,3340000
|
||||
arr_move_0296,acct_0136,parent_0004,contract_0127,2025-06-15,contraction,generated_history,-350000,3100000,2750000
|
||||
arr_move_0297,acct_0137,parent_0005,contract_0128,2025-06-15,reactivation,generated_history,360000,3200000,3560000
|
||||
arr_move_0298,acct_0138,parent_0006,contract_0129,2025-06-15,new,generated_history,370000,3300000,3670000
|
||||
arr_move_0299,acct_0139,parent_0007,contract_0130,2025-06-15,expansion,generated_history,250000,3400000,3650000
|
||||
arr_move_0300,acct_0140,parent_0008,contract_0131,2025-06-15,contraction,generated_history,-260000,3500000,3240000
|
||||
arr_move_0301,acct_0141,parent_0009,contract_0132,2025-06-15,reactivation,generated_history,270000,3600000,3870000
|
||||
arr_move_0302,acct_0142,parent_0010,contract_0133,2025-06-15,new,generated_history,280000,3700000,3980000
|
||||
arr_move_0303,acct_0143,parent_0011,contract_0134,2025-06-15,expansion,generated_history,290000,3800000,4090000
|
||||
arr_move_0304,acct_0144,parent_0012,contract_0135,2025-06-15,contraction,generated_history,-300000,2000000,1700000
|
||||
arr_move_0305,acct_0145,parent_0013,contract_0136,2025-06-15,reactivation,generated_history,310000,2100000,2410000
|
||||
arr_move_0306,acct_0146,parent_0014,contract_0137,2025-06-15,new,generated_history,320000,2200000,2520000
|
||||
arr_move_0307,acct_0147,parent_0015,contract_0138,2025-06-15,expansion,generated_history,330000,2300000,2630000
|
||||
arr_move_0308,acct_0148,parent_0016,contract_0139,2025-06-15,contraction,generated_history,-340000,2400000,2060000
|
||||
arr_move_0309,acct_0149,parent_0017,contract_0140,2025-06-15,reactivation,generated_history,350000,2500000,2850000
|
||||
arr_move_0310,acct_0150,parent_0018,contract_0141,2025-06-15,new,generated_history,360000,2600000,2960000
|
||||
arr_move_0311,acct_0151,parent_0019,contract_0142,2025-06-15,expansion,generated_history,370000,2700000,3070000
|
||||
arr_move_0312,acct_0152,parent_0020,contract_0143,2025-06-15,contraction,generated_history,-250000,2800000,2550000
|
||||
arr_move_0313,acct_0153,parent_0021,contract_0144,2025-06-15,reactivation,generated_history,260000,2900000,3160000
|
||||
arr_move_0314,acct_0154,parent_0022,contract_0145,2025-06-15,new,generated_history,270000,3000000,3270000
|
||||
arr_move_0315,acct_0155,parent_0023,contract_0146,2025-06-15,expansion,generated_history,280000,3100000,3380000
|
||||
arr_move_0316,acct_0156,parent_0024,contract_0147,2025-06-15,contraction,generated_history,-290000,3200000,2910000
|
||||
arr_move_0317,acct_0157,parent_0025,contract_0148,2025-06-15,reactivation,generated_history,300000,3300000,3600000
|
||||
arr_move_0318,acct_0158,parent_0026,contract_0149,2025-06-15,new,generated_history,310000,3400000,3710000
|
||||
arr_move_0319,acct_0159,parent_0027,contract_0150,2025-06-15,expansion,generated_history,320000,3500000,3820000
|
||||
arr_move_0320,acct_0160,parent_0028,contract_0151,2025-06-15,contraction,generated_history,-330000,3600000,3270000
|
||||
arr_move_0321,acct_0161,parent_0029,contract_0152,2025-06-15,reactivation,generated_history,340000,3700000,4040000
|
||||
arr_move_0322,acct_0162,parent_0030,contract_0153,2025-06-15,new,generated_history,350000,3800000,4150000
|
||||
arr_move_0323,acct_0163,parent_0031,contract_0154,2025-06-15,expansion,generated_history,360000,2000000,2360000
|
||||
arr_move_0324,acct_0164,parent_0032,contract_0155,2025-06-15,contraction,generated_history,-370000,2100000,1730000
|
||||
arr_move_0325,acct_0165,parent_0033,contract_0156,2025-06-15,reactivation,generated_history,250000,2200000,2450000
|
||||
arr_move_0326,acct_0166,parent_0034,contract_0157,2025-06-15,new,generated_history,260000,2300000,2560000
|
||||
arr_move_0327,acct_0167,parent_0035,contract_0158,2025-06-15,expansion,generated_history,270000,2400000,2670000
|
||||
arr_move_0328,acct_0168,parent_0036,contract_0159,2025-06-15,contraction,generated_history,-280000,2500000,2220000
|
||||
arr_move_0329,acct_0169,parent_0037,contract_0160,2025-06-15,reactivation,generated_history,290000,2600000,2890000
|
||||
arr_move_0330,acct_0170,parent_0038,contract_0161,2025-06-15,new,generated_history,300000,2700000,3000000
|
||||
arr_move_0331,acct_0171,parent_0039,contract_0162,2025-06-15,expansion,generated_history,310000,2800000,3110000
|
||||
arr_move_0332,acct_0172,parent_0040,contract_0163,2025-06-15,contraction,generated_history,-320000,2900000,2580000
|
||||
arr_move_0333,acct_0173,parent_0041,contract_0164,2025-06-15,reactivation,generated_history,330000,3000000,3330000
|
||||
arr_move_0334,acct_0174,parent_0042,contract_0165,2025-06-15,new,generated_history,340000,3100000,3440000
|
||||
arr_move_0335,acct_0175,parent_0043,contract_0166,2025-06-15,expansion,generated_history,350000,3200000,3550000
|
||||
arr_move_0336,acct_0176,parent_0044,contract_0167,2025-06-15,contraction,generated_history,-360000,3300000,2940000
|
||||
arr_move_0337,acct_0177,parent_0045,contract_0168,2025-06-15,reactivation,generated_history,370000,3400000,3770000
|
||||
arr_move_0338,acct_0178,parent_0046,contract_0169,2025-06-15,new,generated_history,250000,3500000,3750000
|
||||
arr_move_0339,acct_0179,parent_0047,contract_0170,2025-06-15,expansion,generated_history,260000,3600000,3860000
|
||||
arr_move_0340,acct_0180,parent_0048,contract_0171,2025-06-15,contraction,generated_history,-270000,3700000,3430000
|
||||
arr_move_0341,acct_0181,parent_0049,contract_0172,2025-06-15,reactivation,generated_history,280000,3800000,4080000
|
||||
arr_move_0342,acct_0182,parent_0050,contract_0173,2025-06-15,new,generated_history,290000,2000000,2290000
|
||||
arr_move_0343,acct_0183,parent_0051,contract_0174,2025-06-15,expansion,generated_history,300000,2100000,2400000
|
||||
arr_move_0344,acct_0184,parent_0052,contract_0175,2025-06-15,contraction,generated_history,-310000,2200000,1890000
|
||||
arr_move_0345,acct_0185,parent_0053,contract_0176,2025-06-15,reactivation,generated_history,320000,2300000,2620000
|
||||
arr_move_0346,acct_0186,parent_0054,contract_0177,2025-06-15,new,generated_history,330000,2400000,2730000
|
||||
arr_move_0347,acct_0187,parent_0055,contract_0178,2025-06-15,expansion,generated_history,340000,2500000,2840000
|
||||
arr_move_0348,acct_0188,parent_0056,contract_0179,2025-06-15,contraction,generated_history,-350000,2600000,2250000
|
||||
arr_move_0349,acct_0189,parent_0057,contract_0180,2025-06-15,reactivation,generated_history,360000,2700000,3060000
|
||||
arr_move_0350,acct_0090,parent_0090,contract_0181,2025-06-15,new,generated_history,370000,2800000,3170000
|
||||
arr_move_0351,acct_0091,parent_0091,contract_0182,2025-06-15,expansion,generated_history,250000,2900000,3150000
|
||||
arr_move_0352,acct_0092,parent_0092,contract_0183,2025-06-15,contraction,generated_history,-260000,3000000,2740000
|
||||
arr_move_0353,acct_0093,parent_0093,contract_0184,2025-06-15,reactivation,generated_history,270000,3100000,3370000
|
||||
arr_move_0354,acct_0094,parent_0094,contract_0185,2025-06-15,new,generated_history,280000,3200000,3480000
|
||||
arr_move_0355,acct_0095,parent_0095,contract_0186,2025-06-15,expansion,generated_history,290000,3300000,3590000
|
||||
arr_move_0356,acct_0096,parent_0096,contract_0187,2025-06-15,contraction,generated_history,-300000,3400000,3100000
|
||||
arr_move_0357,acct_0097,parent_0097,contract_0188,2025-06-15,reactivation,generated_history,310000,3500000,3810000
|
||||
arr_move_0358,acct_0098,parent_0098,contract_0189,2025-06-15,new,generated_history,320000,3600000,3920000
|
||||
arr_move_0359,acct_0099,parent_0099,contract_0190,2025-06-15,expansion,generated_history,330000,3700000,4030000
|
||||
arr_move_0360,acct_0100,parent_0100,contract_0191,2025-06-15,contraction,generated_history,-340000,3800000,3460000
|
||||
arr_move_0361,acct_0101,parent_0101,contract_0192,2025-06-15,reactivation,generated_history,350000,2000000,2350000
|
||||
arr_move_0362,acct_0102,parent_0102,contract_0193,2025-06-15,new,generated_history,360000,2100000,2460000
|
||||
arr_move_0363,acct_0103,parent_0103,contract_0194,2025-06-15,expansion,generated_history,370000,2200000,2570000
|
||||
arr_move_0364,acct_0104,parent_0104,contract_0195,2025-06-15,contraction,generated_history,-250000,2300000,2050000
|
||||
arr_move_0365,acct_0105,parent_0105,contract_0196,2025-06-15,reactivation,generated_history,260000,2400000,2660000
|
||||
arr_move_0366,acct_0106,parent_0106,contract_0197,2025-06-15,new,generated_history,270000,2500000,2770000
|
||||
arr_move_0367,acct_0107,parent_0107,contract_0198,2025-06-15,expansion,generated_history,280000,2600000,2880000
|
||||
arr_move_0368,acct_0108,parent_0108,contract_0199,2025-06-15,contraction,generated_history,-290000,2700000,2410000
|
||||
arr_move_0369,acct_0109,parent_0109,contract_0200,2025-06-15,reactivation,generated_history,300000,2800000,3100000
|
||||
arr_move_0370,acct_0110,parent_0110,contract_0201,2025-06-15,new,generated_history,310000,2900000,3210000
|
||||
arr_move_0371,acct_0111,parent_0111,contract_0202,2025-06-15,expansion,generated_history,320000,3000000,3320000
|
||||
arr_move_0372,acct_0112,parent_0112,contract_0203,2025-06-15,contraction,generated_history,-330000,3100000,2770000
|
||||
arr_move_0373,acct_0113,parent_0113,contract_0204,2025-06-15,reactivation,generated_history,340000,3200000,3540000
|
||||
arr_move_0374,acct_0114,parent_0114,contract_0205,2025-06-15,new,generated_history,350000,3300000,3650000
|
||||
arr_move_0375,acct_0115,parent_0115,contract_0206,2025-06-15,expansion,generated_history,360000,3400000,3760000
|
||||
arr_move_0376,acct_0116,parent_0116,contract_0207,2025-06-15,contraction,generated_history,-370000,3500000,3130000
|
||||
arr_move_0377,acct_0117,parent_0117,contract_0208,2025-06-15,reactivation,generated_history,250000,3600000,3850000
|
||||
arr_move_0378,acct_0118,parent_0118,contract_0209,2025-06-15,new,generated_history,260000,3700000,3960000
|
||||
arr_move_0379,acct_0119,parent_0119,contract_0210,2025-06-15,expansion,generated_history,270000,3800000,4070000
|
||||
arr_move_0380,acct_0120,parent_0120,contract_0211,2025-06-15,contraction,generated_history,-280000,2000000,1720000
|
||||
arr_move_0381,acct_0121,parent_0121,contract_0212,2025-06-15,reactivation,generated_history,290000,2100000,2390000
|
||||
arr_move_0382,acct_0122,parent_0122,contract_0213,2025-06-15,new,generated_history,300000,2200000,2500000
|
||||
arr_move_0383,acct_0123,parent_0123,contract_0214,2025-06-15,expansion,generated_history,310000,2300000,2610000
|
||||
arr_move_0384,acct_0124,parent_0124,contract_0215,2025-06-15,contraction,generated_history,-320000,2400000,2080000
|
||||
arr_move_0385,acct_0125,parent_0125,contract_0216,2025-06-15,reactivation,generated_history,330000,2500000,2830000
|
||||
arr_move_0386,acct_0126,parent_0126,contract_0217,2025-06-15,new,generated_history,340000,2600000,2940000
|
||||
arr_move_0387,acct_0127,parent_0127,contract_0218,2025-06-15,expansion,generated_history,350000,2700000,3050000
|
||||
arr_move_0388,acct_0128,parent_0128,contract_0219,2025-06-15,contraction,generated_history,-360000,2800000,2440000
|
||||
arr_move_0389,acct_0129,parent_0129,contract_0220,2025-06-15,reactivation,generated_history,370000,2900000,3270000
|
||||
arr_move_0390,acct_0130,parent_0130,contract_0221,2025-06-15,new,generated_history,250000,3000000,3250000
|
||||
arr_move_0391,acct_0131,parent_0131,contract_0222,2025-06-15,expansion,generated_history,260000,3100000,3360000
|
||||
arr_move_0392,acct_0132,parent_0132,contract_0223,2025-06-15,contraction,generated_history,-270000,3200000,2930000
|
||||
arr_move_0393,acct_0133,parent_0001,contract_0224,2025-06-15,reactivation,generated_history,280000,3300000,3580000
|
||||
arr_move_0394,acct_0134,parent_0002,contract_0225,2025-06-15,new,generated_history,290000,3400000,3690000
|
||||
arr_move_0395,acct_0135,parent_0003,contract_0226,2025-06-15,expansion,generated_history,300000,3500000,3800000
|
||||
arr_move_0396,acct_0136,parent_0004,contract_0227,2025-06-15,contraction,generated_history,-310000,3600000,3290000
|
||||
arr_move_0397,acct_0137,parent_0005,contract_0228,2025-06-15,reactivation,generated_history,320000,3700000,4020000
|
||||
arr_move_0398,acct_0138,parent_0006,contract_0229,2025-06-15,new,generated_history,330000,3800000,4130000
|
||||
arr_move_0399,acct_0139,parent_0007,contract_0230,2025-06-15,expansion,generated_history,340000,2000000,2340000
|
||||
arr_move_0400,acct_0140,parent_0008,contract_0231,2025-06-15,contraction,generated_history,-350000,2100000,1750000
|
||||
arr_move_0401,acct_0141,parent_0009,contract_0232,2025-06-15,reactivation,generated_history,360000,2200000,2560000
|
||||
arr_move_0402,acct_0142,parent_0010,contract_0233,2025-06-15,new,generated_history,370000,2300000,2670000
|
||||
arr_move_0403,acct_0143,parent_0011,contract_0234,2025-06-15,expansion,generated_history,250000,2400000,2650000
|
||||
arr_move_0404,acct_0144,parent_0012,contract_0235,2025-06-15,contraction,generated_history,-260000,2500000,2240000
|
||||
arr_move_0405,acct_0145,parent_0013,contract_0236,2025-06-15,reactivation,generated_history,270000,2600000,2870000
|
||||
arr_move_0406,acct_0146,parent_0014,contract_0237,2025-06-15,new,generated_history,280000,2700000,2980000
|
||||
arr_move_0407,acct_0147,parent_0015,contract_0238,2025-06-15,expansion,generated_history,290000,2800000,3090000
|
||||
arr_move_0408,acct_0148,parent_0016,contract_0239,2025-06-15,contraction,generated_history,-300000,2900000,2600000
|
||||
arr_move_0409,acct_0149,parent_0017,contract_0240,2025-06-15,reactivation,generated_history,310000,3000000,3310000
|
||||
arr_move_0410,acct_0150,parent_0018,contract_0241,2025-06-15,new,generated_history,320000,3100000,3420000
|
||||
arr_move_0411,acct_0151,parent_0019,contract_0242,2025-06-15,expansion,generated_history,330000,3200000,3530000
|
||||
arr_move_0412,acct_0152,parent_0020,contract_0243,2025-06-15,contraction,generated_history,-340000,3300000,2960000
|
||||
arr_move_0413,acct_0153,parent_0021,contract_0244,2025-06-15,reactivation,generated_history,350000,3400000,3750000
|
||||
arr_move_0414,acct_0154,parent_0022,contract_0245,2025-06-15,new,generated_history,360000,3500000,3860000
|
||||
arr_move_0415,acct_0155,parent_0023,contract_0246,2025-06-15,expansion,generated_history,370000,3600000,3970000
|
||||
arr_move_0416,acct_0156,parent_0024,contract_0247,2025-06-15,contraction,generated_history,-250000,3700000,3450000
|
||||
arr_move_0417,acct_0157,parent_0025,contract_0248,2025-06-15,reactivation,generated_history,260000,3800000,4060000
|
||||
arr_move_0418,acct_0158,parent_0026,contract_0249,2025-06-15,new,generated_history,270000,2000000,2270000
|
||||
arr_move_0419,acct_0159,parent_0027,contract_0250,2025-06-15,expansion,generated_history,280000,2100000,2380000
|
||||
arr_move_0420,acct_0160,parent_0028,contract_0251,2025-06-15,contraction,generated_history,-290000,2200000,1910000
|
||||
arr_move_0421,acct_0161,parent_0029,contract_0252,2025-06-15,reactivation,generated_history,300000,2300000,2600000
|
||||
arr_move_0422,acct_0162,parent_0030,contract_0253,2025-06-15,new,generated_history,310000,2400000,2710000
|
||||
arr_move_0423,acct_0163,parent_0031,contract_0254,2025-06-15,expansion,generated_history,320000,2500000,2820000
|
||||
arr_move_0424,acct_0164,parent_0032,contract_0255,2025-06-15,contraction,generated_history,-330000,2600000,2270000
|
||||
arr_move_0425,acct_0165,parent_0033,contract_0256,2025-06-15,reactivation,generated_history,340000,2700000,3040000
|
||||
arr_move_0426,acct_0166,parent_0034,contract_0257,2025-06-15,new,generated_history,350000,2800000,3150000
|
||||
arr_move_0427,acct_0167,parent_0035,contract_0258,2025-06-15,expansion,generated_history,360000,2900000,3260000
|
||||
arr_move_0428,acct_0168,parent_0036,contract_0259,2025-06-15,contraction,generated_history,-370000,3000000,2630000
|
||||
arr_move_0429,acct_0169,parent_0037,contract_0260,2025-06-15,reactivation,generated_history,250000,3100000,3350000
|
||||
arr_move_0430,acct_0170,parent_0038,contract_0261,2025-06-15,new,generated_history,260000,3200000,3460000
|
||||
arr_move_0431,acct_0171,parent_0039,contract_0262,2025-06-15,expansion,generated_history,270000,3300000,3570000
|
||||
arr_move_0432,acct_0172,parent_0040,contract_0263,2025-06-15,contraction,generated_history,-280000,3400000,3120000
|
||||
arr_move_0433,acct_0173,parent_0041,contract_0264,2025-06-15,reactivation,generated_history,290000,3500000,3790000
|
||||
arr_move_0434,acct_0174,parent_0042,contract_0265,2025-06-15,new,generated_history,300000,3600000,3900000
|
||||
arr_move_0435,acct_0175,parent_0043,contract_0266,2025-06-15,expansion,generated_history,310000,3700000,4010000
|
||||
arr_move_0436,acct_0176,parent_0044,contract_0267,2025-06-15,contraction,generated_history,-320000,3800000,3480000
|
||||
arr_move_0437,acct_0177,parent_0045,contract_0268,2025-06-15,reactivation,generated_history,330000,2000000,2330000
|
||||
arr_move_0438,acct_0178,parent_0046,contract_0269,2025-06-15,new,generated_history,340000,2100000,2440000
|
||||
arr_move_0439,acct_0179,parent_0047,contract_0270,2025-06-15,expansion,generated_history,350000,2200000,2550000
|
||||
arr_move_0440,acct_0180,parent_0048,contract_0271,2025-06-15,contraction,generated_history,-360000,2300000,1940000
|
||||
arr_move_0441,acct_0181,parent_0049,contract_0272,2025-06-15,reactivation,generated_history,370000,2400000,2770000
|
||||
arr_move_0442,acct_0182,parent_0050,contract_0273,2025-06-15,new,generated_history,250000,2500000,2750000
|
||||
arr_move_0443,acct_0183,parent_0051,contract_0274,2025-06-15,expansion,generated_history,260000,2600000,2860000
|
||||
arr_move_0444,acct_0184,parent_0052,contract_0275,2025-06-15,contraction,generated_history,-270000,2700000,2430000
|
||||
arr_move_0445,acct_0185,parent_0053,contract_0276,2025-06-15,reactivation,generated_history,280000,2800000,3080000
|
||||
arr_move_0446,acct_0186,parent_0054,contract_0277,2025-06-15,new,generated_history,290000,2900000,3190000
|
||||
arr_move_0447,acct_0187,parent_0055,contract_0278,2025-06-15,expansion,generated_history,300000,3000000,3300000
|
||||
arr_move_0448,acct_0188,parent_0056,contract_0279,2025-06-15,contraction,generated_history,-310000,3100000,2790000
|
||||
arr_move_0449,acct_0189,parent_0057,contract_0280,2025-06-15,reactivation,generated_history,320000,3200000,3520000
|
||||
arr_move_0450,acct_0090,parent_0090,contract_0281,2025-06-15,new,generated_history,330000,3300000,3630000
|
||||
arr_move_0451,acct_0091,parent_0091,contract_0282,2025-06-15,expansion,generated_history,340000,3400000,3740000
|
||||
arr_move_0452,acct_0092,parent_0092,contract_0283,2025-06-15,contraction,generated_history,-350000,3500000,3150000
|
||||
arr_move_0453,acct_0093,parent_0093,contract_0284,2025-06-15,reactivation,generated_history,360000,3600000,3960000
|
||||
arr_move_0454,acct_0094,parent_0094,contract_0285,2025-06-15,new,generated_history,370000,3700000,4070000
|
||||
arr_move_0455,acct_0095,parent_0095,contract_0286,2025-06-15,expansion,generated_history,250000,3800000,4050000
|
||||
arr_move_0456,acct_0096,parent_0096,contract_0287,2025-06-15,contraction,generated_history,-260000,2000000,1740000
|
||||
arr_move_0457,acct_0097,parent_0097,contract_0288,2025-06-15,reactivation,generated_history,270000,2100000,2370000
|
||||
arr_move_0458,acct_0098,parent_0098,contract_0289,2025-06-15,new,generated_history,280000,2200000,2480000
|
||||
arr_move_0459,acct_0099,parent_0099,contract_0290,2025-06-15,expansion,generated_history,290000,2300000,2590000
|
||||
arr_move_0460,acct_0100,parent_0100,contract_0291,2025-06-15,contraction,generated_history,-300000,2400000,2100000
|
||||
arr_move_0461,acct_0101,parent_0101,contract_0292,2025-06-15,reactivation,generated_history,310000,2500000,2810000
|
||||
arr_move_0462,acct_0102,parent_0102,contract_0293,2025-06-15,new,generated_history,320000,2600000,2920000
|
||||
arr_move_0463,acct_0103,parent_0103,contract_0294,2025-06-15,expansion,generated_history,330000,2700000,3030000
|
||||
arr_move_0464,acct_0104,parent_0104,contract_0295,2025-06-15,contraction,generated_history,-340000,2800000,2460000
|
||||
arr_move_0465,acct_0105,parent_0105,contract_0296,2025-06-15,reactivation,generated_history,350000,2900000,3250000
|
||||
arr_move_0466,acct_0106,parent_0106,contract_0297,2025-06-15,new,generated_history,360000,3000000,3360000
|
||||
arr_move_0467,acct_0107,parent_0107,contract_0298,2025-06-15,expansion,generated_history,370000,3100000,3470000
|
||||
arr_move_0468,acct_0108,parent_0108,contract_0299,2025-06-15,contraction,generated_history,-250000,3200000,2950000
|
||||
arr_move_0469,acct_0109,parent_0109,contract_0300,2025-06-15,reactivation,generated_history,260000,3300000,3560000
|
||||
arr_move_0470,acct_0110,parent_0110,contract_0301,2025-06-15,new,generated_history,270000,3400000,3670000
|
||||
arr_move_0471,acct_0111,parent_0111,contract_0302,2025-06-15,expansion,generated_history,280000,3500000,3780000
|
||||
arr_move_0472,acct_0112,parent_0112,contract_0303,2025-06-15,contraction,generated_history,-290000,3600000,3310000
|
||||
arr_move_0473,acct_0113,parent_0113,contract_0304,2025-06-15,reactivation,generated_history,300000,3700000,4000000
|
||||
arr_move_0474,acct_0114,parent_0114,contract_0305,2025-06-15,new,generated_history,310000,3800000,4110000
|
||||
arr_move_0475,acct_0115,parent_0115,contract_0306,2025-06-15,expansion,generated_history,320000,2000000,2320000
|
||||
arr_move_0476,acct_0116,parent_0116,contract_0307,2025-06-15,contraction,generated_history,-330000,2100000,1770000
|
||||
arr_move_0477,acct_0117,parent_0117,contract_0308,2025-06-15,reactivation,generated_history,340000,2200000,2540000
|
||||
arr_move_0478,acct_0118,parent_0118,contract_0309,2025-06-15,new,generated_history,350000,2300000,2650000
|
||||
arr_move_0479,acct_0119,parent_0119,contract_0310,2025-06-15,expansion,generated_history,360000,2400000,2760000
|
||||
arr_move_0480,acct_0120,parent_0120,contract_0311,2025-06-15,contraction,generated_history,-370000,2500000,2130000
|
||||
arr_move_0481,acct_0121,parent_0121,contract_0312,2025-06-15,reactivation,generated_history,250000,2600000,2850000
|
||||
arr_move_0482,acct_0122,parent_0122,contract_0313,2025-06-15,new,generated_history,260000,2700000,2960000
|
||||
arr_move_0483,acct_0123,parent_0123,contract_0314,2025-06-15,expansion,generated_history,270000,2800000,3070000
|
||||
arr_move_0484,acct_0124,parent_0124,contract_0315,2025-06-15,contraction,generated_history,-280000,2900000,2620000
|
||||
arr_move_0485,acct_0125,parent_0125,contract_0316,2025-06-15,reactivation,generated_history,290000,3000000,3290000
|
||||
arr_move_0486,acct_0126,parent_0126,contract_0317,2025-06-15,new,generated_history,300000,3100000,3400000
|
||||
arr_move_0487,acct_0127,parent_0127,contract_0318,2025-06-15,expansion,generated_history,310000,3200000,3510000
|
||||
arr_move_0488,acct_0128,parent_0128,contract_0319,2025-06-15,contraction,generated_history,-320000,3300000,2980000
|
||||
arr_move_0489,acct_0129,parent_0129,contract_0320,2025-06-15,reactivation,generated_history,330000,3400000,3730000
|
||||
arr_move_0490,acct_0130,parent_0130,contract_0101,2025-06-15,new,generated_history,340000,3500000,3840000
|
||||
arr_move_0491,acct_0131,parent_0131,contract_0102,2025-06-15,expansion,generated_history,350000,3600000,3950000
|
||||
arr_move_0492,acct_0132,parent_0132,contract_0103,2025-06-15,contraction,generated_history,-360000,3700000,3340000
|
||||
arr_move_0493,acct_0133,parent_0001,contract_0104,2025-06-15,reactivation,generated_history,370000,3800000,4170000
|
||||
arr_move_0494,acct_0134,parent_0002,contract_0105,2025-06-15,new,generated_history,250000,2000000,2250000
|
||||
arr_move_0495,acct_0135,parent_0003,contract_0106,2025-06-15,expansion,generated_history,260000,2100000,2360000
|
||||
arr_move_0496,acct_0136,parent_0004,contract_0107,2025-06-15,contraction,generated_history,-270000,2200000,1930000
|
||||
arr_move_0497,acct_0137,parent_0005,contract_0108,2025-06-15,reactivation,generated_history,280000,2300000,2580000
|
||||
arr_move_0498,acct_0138,parent_0006,contract_0109,2025-06-15,new,generated_history,290000,2400000,2690000
|
||||
arr_move_0499,acct_0139,parent_0007,contract_0110,2025-06-15,expansion,generated_history,300000,2500000,2800000
|
||||
arr_move_0500,acct_0140,parent_0008,contract_0111,2025-06-15,contraction,generated_history,-310000,2600000,2290000
|
||||
arr_move_0501,acct_0141,parent_0009,contract_0112,2025-06-15,reactivation,generated_history,320000,2700000,3020000
|
||||
arr_move_0502,acct_0142,parent_0010,contract_0113,2025-06-15,new,generated_history,330000,2800000,3130000
|
||||
arr_move_0503,acct_0143,parent_0011,contract_0114,2025-06-15,expansion,generated_history,340000,2900000,3240000
|
||||
arr_move_0504,acct_0144,parent_0012,contract_0115,2025-06-15,contraction,generated_history,-350000,3000000,2650000
|
||||
arr_move_0505,acct_0145,parent_0013,contract_0116,2025-06-15,reactivation,generated_history,360000,3100000,3460000
|
||||
arr_move_0506,acct_0146,parent_0014,contract_0117,2025-06-15,new,generated_history,370000,3200000,3570000
|
||||
arr_move_0507,acct_0147,parent_0015,contract_0118,2025-06-15,expansion,generated_history,250000,3300000,3550000
|
||||
arr_move_0508,acct_0148,parent_0016,contract_0119,2025-06-15,contraction,generated_history,-260000,3400000,3140000
|
||||
arr_move_0509,acct_0149,parent_0017,contract_0120,2025-06-15,reactivation,generated_history,270000,3500000,3770000
|
||||
arr_move_0510,acct_0150,parent_0018,contract_0121,2025-06-15,new,generated_history,280000,3600000,3880000
|
||||
arr_move_0511,acct_0151,parent_0019,contract_0122,2025-06-15,expansion,generated_history,290000,3700000,3990000
|
||||
arr_move_0512,acct_0152,parent_0020,contract_0123,2025-06-15,contraction,generated_history,-300000,3800000,3500000
|
||||
arr_move_0513,acct_0153,parent_0021,contract_0124,2025-06-15,reactivation,generated_history,310000,2000000,2310000
|
||||
arr_move_0514,acct_0154,parent_0022,contract_0125,2025-06-15,new,generated_history,320000,2100000,2420000
|
||||
arr_move_0515,acct_0155,parent_0023,contract_0126,2025-06-15,expansion,generated_history,330000,2200000,2530000
|
||||
arr_move_0516,acct_0156,parent_0024,contract_0127,2025-06-15,contraction,generated_history,-340000,2300000,1960000
|
||||
arr_move_0517,acct_0157,parent_0025,contract_0128,2025-06-15,reactivation,generated_history,350000,2400000,2750000
|
||||
arr_move_0518,acct_0158,parent_0026,contract_0129,2025-06-15,new,generated_history,360000,2500000,2860000
|
||||
arr_move_0519,acct_0159,parent_0027,contract_0130,2025-06-15,expansion,generated_history,370000,2600000,2970000
|
||||
arr_move_0520,acct_0160,parent_0028,contract_0131,2025-06-15,contraction,generated_history,-250000,2700000,2450000
|
||||
arr_move_0521,acct_0161,parent_0029,contract_0132,2025-06-15,reactivation,generated_history,260000,2800000,3060000
|
||||
arr_move_0522,acct_0162,parent_0030,contract_0133,2025-06-15,new,generated_history,270000,2900000,3170000
|
||||
arr_move_0523,acct_0163,parent_0031,contract_0134,2025-06-15,expansion,generated_history,280000,3000000,3280000
|
||||
arr_move_0524,acct_0164,parent_0032,contract_0135,2025-06-15,contraction,generated_history,-290000,3100000,2810000
|
||||
arr_move_0525,acct_0165,parent_0033,contract_0136,2025-06-15,reactivation,generated_history,300000,3200000,3500000
|
||||
arr_move_0526,acct_0166,parent_0034,contract_0137,2025-06-15,new,generated_history,310000,3300000,3610000
|
||||
arr_move_0527,acct_0167,parent_0035,contract_0138,2025-06-15,expansion,generated_history,320000,3400000,3720000
|
||||
arr_move_0528,acct_0168,parent_0036,contract_0139,2025-06-15,contraction,generated_history,-330000,3500000,3170000
|
||||
arr_move_0529,acct_0169,parent_0037,contract_0140,2025-06-15,reactivation,generated_history,340000,3600000,3940000
|
||||
arr_move_0530,acct_0170,parent_0038,contract_0141,2025-06-15,new,generated_history,350000,3700000,4050000
|
||||
arr_move_0531,acct_0171,parent_0039,contract_0142,2025-06-15,expansion,generated_history,360000,3800000,4160000
|
||||
arr_move_0532,acct_0172,parent_0040,contract_0143,2025-06-15,contraction,generated_history,-370000,2000000,1630000
|
||||
arr_move_0533,acct_0173,parent_0041,contract_0144,2025-06-15,reactivation,generated_history,250000,2100000,2350000
|
||||
arr_move_0534,acct_0174,parent_0042,contract_0145,2025-06-15,new,generated_history,260000,2200000,2460000
|
||||
arr_move_0535,acct_0175,parent_0043,contract_0146,2025-06-15,expansion,generated_history,270000,2300000,2570000
|
||||
arr_move_0536,acct_0176,parent_0044,contract_0147,2025-06-15,contraction,generated_history,-280000,2400000,2120000
|
||||
arr_move_0537,acct_0177,parent_0045,contract_0148,2025-06-15,reactivation,generated_history,290000,2500000,2790000
|
||||
arr_move_0538,acct_0178,parent_0046,contract_0149,2025-06-15,new,generated_history,300000,2600000,2900000
|
||||
arr_move_0539,acct_0179,parent_0047,contract_0150,2025-06-15,expansion,generated_history,310000,2700000,3010000
|
||||
arr_move_0540,acct_0180,parent_0048,contract_0151,2025-06-15,contraction,generated_history,-320000,2800000,2480000
|
||||
arr_move_0541,acct_0181,parent_0049,contract_0152,2025-06-15,reactivation,generated_history,330000,2900000,3230000
|
||||
arr_move_0542,acct_0182,parent_0050,contract_0153,2025-06-15,new,generated_history,340000,3000000,3340000
|
||||
arr_move_0543,acct_0183,parent_0051,contract_0154,2025-06-15,expansion,generated_history,350000,3100000,3450000
|
||||
arr_move_0544,acct_0184,parent_0052,contract_0155,2025-06-15,contraction,generated_history,-360000,3200000,2840000
|
||||
arr_move_0545,acct_0185,parent_0053,contract_0156,2025-06-15,reactivation,generated_history,370000,3300000,3670000
|
||||
arr_move_0546,acct_0186,parent_0054,contract_0157,2025-06-15,new,generated_history,250000,3400000,3650000
|
||||
arr_move_0547,acct_0187,parent_0055,contract_0158,2025-06-15,expansion,generated_history,260000,3500000,3760000
|
||||
arr_move_0548,acct_0188,parent_0056,contract_0159,2025-06-15,contraction,generated_history,-270000,3600000,3330000
|
||||
arr_move_0549,acct_0189,parent_0057,contract_0160,2025-06-15,reactivation,generated_history,280000,3700000,3980000
|
||||
arr_move_0550,acct_0090,parent_0090,contract_0161,2025-06-15,new,generated_history,290000,3800000,4090000
|
||||
arr_move_0551,acct_0091,parent_0091,contract_0162,2025-06-15,expansion,generated_history,300000,2000000,2300000
|
||||
arr_move_0552,acct_0092,parent_0092,contract_0163,2025-06-15,contraction,generated_history,-310000,2100000,1790000
|
||||
arr_move_0553,acct_0093,parent_0093,contract_0164,2025-06-15,reactivation,generated_history,320000,2200000,2520000
|
||||
arr_move_0554,acct_0094,parent_0094,contract_0165,2025-06-15,new,generated_history,330000,2300000,2630000
|
||||
arr_move_0555,acct_0095,parent_0095,contract_0166,2025-06-15,expansion,generated_history,340000,2400000,2740000
|
||||
arr_move_0556,acct_0096,parent_0096,contract_0167,2025-06-15,contraction,generated_history,-350000,2500000,2150000
|
||||
arr_move_0557,acct_0097,parent_0097,contract_0168,2025-06-15,reactivation,generated_history,360000,2600000,2960000
|
||||
arr_move_0558,acct_0098,parent_0098,contract_0169,2025-06-15,new,generated_history,370000,2700000,3070000
|
||||
arr_move_0559,acct_0099,parent_0099,contract_0170,2025-06-15,expansion,generated_history,250000,2800000,3050000
|
||||
arr_move_0560,acct_0100,parent_0100,contract_0171,2025-06-15,contraction,generated_history,-260000,2900000,2640000
|
||||
arr_move_0561,acct_0101,parent_0101,contract_0172,2025-06-15,reactivation,generated_history,270000,3000000,3270000
|
||||
arr_move_0562,acct_0102,parent_0102,contract_0173,2025-06-15,new,generated_history,280000,3100000,3380000
|
||||
arr_move_0563,acct_0103,parent_0103,contract_0174,2025-06-15,expansion,generated_history,290000,3200000,3490000
|
||||
arr_move_0564,acct_0104,parent_0104,contract_0175,2025-06-15,contraction,generated_history,-300000,3300000,3000000
|
||||
arr_move_0565,acct_0105,parent_0105,contract_0176,2025-06-15,reactivation,generated_history,310000,3400000,3710000
|
||||
arr_move_0566,acct_0106,parent_0106,contract_0177,2025-06-15,new,generated_history,320000,3500000,3820000
|
||||
arr_move_0567,acct_0107,parent_0107,contract_0178,2025-06-15,expansion,generated_history,330000,3600000,3930000
|
||||
arr_move_0568,acct_0108,parent_0108,contract_0179,2025-06-15,contraction,generated_history,-340000,3700000,3360000
|
||||
arr_move_0569,acct_0109,parent_0109,contract_0180,2025-06-15,reactivation,generated_history,350000,3800000,4150000
|
||||
arr_move_0570,acct_0110,parent_0110,contract_0181,2025-06-15,new,generated_history,360000,2000000,2360000
|
||||
arr_move_0571,acct_0111,parent_0111,contract_0182,2025-06-15,expansion,generated_history,370000,2100000,2470000
|
||||
arr_move_0572,acct_0112,parent_0112,contract_0183,2025-06-15,contraction,generated_history,-250000,2200000,1950000
|
||||
arr_move_0573,acct_0113,parent_0113,contract_0184,2025-06-15,reactivation,generated_history,260000,2300000,2560000
|
||||
arr_move_0574,acct_0114,parent_0114,contract_0185,2025-06-15,new,generated_history,270000,2400000,2670000
|
||||
arr_move_0575,acct_0115,parent_0115,contract_0186,2025-06-15,expansion,generated_history,280000,2500000,2780000
|
||||
arr_move_0576,acct_0116,parent_0116,contract_0187,2025-06-15,contraction,generated_history,-290000,2600000,2310000
|
||||
arr_move_0577,acct_0117,parent_0117,contract_0188,2025-06-15,reactivation,generated_history,300000,2700000,3000000
|
||||
arr_move_0578,acct_0118,parent_0118,contract_0189,2025-06-15,new,generated_history,310000,2800000,3110000
|
||||
arr_move_0579,acct_0119,parent_0119,contract_0190,2025-06-15,expansion,generated_history,320000,2900000,3220000
|
||||
arr_move_0580,acct_0120,parent_0120,contract_0191,2025-06-15,contraction,generated_history,-330000,3000000,2670000
|
||||
arr_move_0581,acct_0121,parent_0121,contract_0192,2025-06-15,reactivation,generated_history,340000,3100000,3440000
|
||||
arr_move_0582,acct_0122,parent_0122,contract_0193,2025-06-15,new,generated_history,350000,3200000,3550000
|
||||
arr_move_0583,acct_0123,parent_0123,contract_0194,2025-06-15,expansion,generated_history,360000,3300000,3660000
|
||||
arr_move_0584,acct_0124,parent_0124,contract_0195,2025-06-15,contraction,generated_history,-370000,3400000,3030000
|
||||
arr_move_0585,acct_0125,parent_0125,contract_0196,2025-06-15,reactivation,generated_history,250000,3500000,3750000
|
||||
arr_move_0586,acct_0126,parent_0126,contract_0197,2025-06-15,new,generated_history,260000,3600000,3860000
|
||||
arr_move_0587,acct_0127,parent_0127,contract_0198,2025-06-15,expansion,generated_history,270000,3700000,3970000
|
||||
arr_move_0588,acct_0128,parent_0128,contract_0199,2025-06-15,contraction,generated_history,-280000,3800000,3520000
|
||||
arr_move_0589,acct_0129,parent_0129,contract_0200,2025-06-15,reactivation,generated_history,290000,2000000,2290000
|
||||
arr_move_0590,acct_0130,parent_0130,contract_0201,2025-06-15,new,generated_history,300000,2100000,2400000
|
||||
arr_move_0591,acct_0131,parent_0131,contract_0202,2025-06-15,expansion,generated_history,310000,2200000,2510000
|
||||
arr_move_0592,acct_0132,parent_0132,contract_0203,2025-06-15,contraction,generated_history,-320000,2300000,1980000
|
||||
arr_move_0593,acct_0133,parent_0001,contract_0204,2025-06-15,reactivation,generated_history,330000,2400000,2730000
|
||||
arr_move_0594,acct_0134,parent_0002,contract_0205,2025-06-15,new,generated_history,340000,2500000,2840000
|
||||
arr_move_0595,acct_0135,parent_0003,contract_0206,2025-06-15,expansion,generated_history,350000,2600000,2950000
|
||||
arr_move_0596,acct_0136,parent_0004,contract_0207,2025-06-15,contraction,generated_history,-360000,2700000,2340000
|
||||
arr_move_0597,acct_0137,parent_0005,contract_0208,2025-06-15,reactivation,generated_history,370000,2800000,3170000
|
||||
arr_move_0598,acct_0138,parent_0006,contract_0209,2025-06-15,new,generated_history,250000,2900000,3150000
|
||||
arr_move_0599,acct_0139,parent_0007,contract_0210,2025-06-15,expansion,generated_history,260000,3000000,3260000
|
||||
arr_move_0600,acct_0140,parent_0008,contract_0211,2025-06-15,contraction,generated_history,-270000,3100000,2830000
|
||||
arr_move_0601,acct_0141,parent_0009,contract_0212,2025-06-15,reactivation,generated_history,280000,3200000,3480000
|
||||
arr_move_0602,acct_0142,parent_0010,contract_0213,2025-06-15,new,generated_history,290000,3300000,3590000
|
||||
arr_move_0603,acct_0143,parent_0011,contract_0214,2025-06-15,expansion,generated_history,300000,3400000,3700000
|
||||
arr_move_0604,acct_0144,parent_0012,contract_0215,2025-06-15,contraction,generated_history,-310000,3500000,3190000
|
||||
arr_move_0605,acct_0145,parent_0013,contract_0216,2025-06-15,reactivation,generated_history,320000,3600000,3920000
|
||||
arr_move_0606,acct_0146,parent_0014,contract_0217,2025-06-15,new,generated_history,330000,3700000,4030000
|
||||
arr_move_0607,acct_0147,parent_0015,contract_0218,2025-06-15,expansion,generated_history,340000,3800000,4140000
|
||||
arr_move_0608,acct_0148,parent_0016,contract_0219,2025-06-15,contraction,generated_history,-350000,2000000,1650000
|
||||
arr_move_0609,acct_0149,parent_0017,contract_0220,2025-06-15,reactivation,generated_history,360000,2100000,2460000
|
||||
arr_move_0610,acct_0150,parent_0018,contract_0221,2025-06-15,new,generated_history,370000,2200000,2570000
|
||||
arr_move_0611,acct_0151,parent_0019,contract_0222,2025-06-15,expansion,generated_history,250000,2300000,2550000
|
||||
arr_move_0612,acct_0152,parent_0020,contract_0223,2025-06-15,contraction,generated_history,-260000,2400000,2140000
|
||||
arr_move_0613,acct_0153,parent_0021,contract_0224,2025-06-15,reactivation,generated_history,270000,2500000,2770000
|
||||
arr_move_0614,acct_0154,parent_0022,contract_0225,2025-06-15,new,generated_history,280000,2600000,2880000
|
||||
arr_move_0615,acct_0155,parent_0023,contract_0226,2025-06-15,expansion,generated_history,290000,2700000,2990000
|
||||
arr_move_0616,acct_0156,parent_0024,contract_0227,2025-06-15,contraction,generated_history,-300000,2800000,2500000
|
||||
arr_move_0617,acct_0157,parent_0025,contract_0228,2025-06-15,reactivation,generated_history,310000,2900000,3210000
|
||||
arr_move_0618,acct_0158,parent_0026,contract_0229,2025-06-15,new,generated_history,320000,3000000,3320000
|
||||
arr_move_0619,acct_0159,parent_0027,contract_0230,2025-06-15,expansion,generated_history,330000,3100000,3430000
|
||||
arr_move_0620,acct_0160,parent_0028,contract_0231,2025-06-15,contraction,generated_history,-340000,3200000,2860000
|
||||
arr_move_0621,acct_0161,parent_0029,contract_0232,2025-06-15,reactivation,generated_history,350000,3300000,3650000
|
||||
arr_move_0622,acct_0162,parent_0030,contract_0233,2025-06-15,new,generated_history,360000,3400000,3760000
|
||||
arr_move_0623,acct_0163,parent_0031,contract_0234,2025-06-15,expansion,generated_history,370000,3500000,3870000
|
||||
arr_move_0624,acct_0164,parent_0032,contract_0235,2025-06-15,contraction,generated_history,-250000,3600000,3350000
|
||||
arr_move_0625,acct_0165,parent_0033,contract_0236,2025-06-15,reactivation,generated_history,260000,3700000,3960000
|
||||
arr_move_0626,acct_0166,parent_0034,contract_0237,2025-06-15,new,generated_history,270000,3800000,4070000
|
||||
arr_move_0627,acct_0167,parent_0035,contract_0238,2025-06-15,expansion,generated_history,280000,2000000,2280000
|
||||
arr_move_0628,acct_0168,parent_0036,contract_0239,2025-06-15,contraction,generated_history,-290000,2100000,1810000
|
||||
arr_move_0629,acct_0169,parent_0037,contract_0240,2025-06-15,reactivation,generated_history,300000,2200000,2500000
|
||||
arr_move_0630,acct_0170,parent_0038,contract_0241,2025-06-15,new,generated_history,310000,2300000,2610000
|
||||
arr_move_0631,acct_0171,parent_0039,contract_0242,2025-06-15,expansion,generated_history,320000,2400000,2720000
|
||||
arr_move_0632,acct_0172,parent_0040,contract_0243,2025-06-15,contraction,generated_history,-330000,2500000,2170000
|
||||
arr_move_0633,acct_0173,parent_0041,contract_0244,2025-06-15,reactivation,generated_history,340000,2600000,2940000
|
||||
arr_move_0634,acct_0174,parent_0042,contract_0245,2025-06-15,new,generated_history,350000,2700000,3050000
|
||||
arr_move_0635,acct_0175,parent_0043,contract_0246,2025-06-15,expansion,generated_history,360000,2800000,3160000
|
||||
arr_move_0636,acct_0176,parent_0044,contract_0247,2025-06-15,contraction,generated_history,-370000,2900000,2530000
|
||||
arr_move_0637,acct_0177,parent_0045,contract_0248,2025-06-15,reactivation,generated_history,250000,3000000,3250000
|
||||
arr_move_0638,acct_0178,parent_0046,contract_0249,2025-06-15,new,generated_history,260000,3100000,3360000
|
||||
arr_move_0639,acct_0179,parent_0047,contract_0250,2025-06-15,expansion,generated_history,270000,3200000,3470000
|
||||
arr_move_0640,acct_0180,parent_0048,contract_0251,2025-06-15,contraction,generated_history,-280000,3300000,3020000
|
||||
arr_move_0641,acct_0181,parent_0049,contract_0252,2025-06-15,reactivation,generated_history,290000,3400000,3690000
|
||||
arr_move_0642,acct_0182,parent_0050,contract_0253,2025-06-15,new,generated_history,300000,3500000,3800000
|
||||
arr_move_0643,acct_0183,parent_0051,contract_0254,2025-06-15,expansion,generated_history,310000,3600000,3910000
|
||||
arr_move_0644,acct_0184,parent_0052,contract_0255,2025-06-15,contraction,generated_history,-320000,3700000,3380000
|
||||
arr_move_0645,acct_0185,parent_0053,contract_0256,2025-06-15,reactivation,generated_history,330000,3800000,4130000
|
||||
arr_move_0646,acct_0186,parent_0054,contract_0257,2025-06-15,new,generated_history,340000,2000000,2340000
|
||||
arr_move_0647,acct_0187,parent_0055,contract_0258,2025-06-15,expansion,generated_history,350000,2100000,2450000
|
||||
arr_move_0648,acct_0188,parent_0056,contract_0259,2025-06-15,contraction,generated_history,-360000,2200000,1840000
|
||||
arr_move_0649,acct_0189,parent_0057,contract_0260,2025-06-15,reactivation,generated_history,370000,2300000,2670000
|
||||
arr_move_0650,acct_0090,parent_0090,contract_0261,2025-06-15,new,generated_history,250000,2400000,2650000
|
||||
arr_move_0651,acct_0091,parent_0091,contract_0262,2025-06-15,expansion,generated_history,260000,2500000,2760000
|
||||
arr_move_0652,acct_0092,parent_0092,contract_0263,2025-06-15,contraction,generated_history,-270000,2600000,2330000
|
||||
arr_move_0653,acct_0093,parent_0093,contract_0264,2025-06-15,reactivation,generated_history,280000,2700000,2980000
|
||||
arr_move_0654,acct_0094,parent_0094,contract_0265,2025-06-15,new,generated_history,290000,2800000,3090000
|
||||
arr_move_0655,acct_0095,parent_0095,contract_0266,2025-06-15,expansion,generated_history,300000,2900000,3200000
|
||||
arr_move_0656,acct_0096,parent_0096,contract_0267,2025-06-15,contraction,generated_history,-310000,3000000,2690000
|
||||
arr_move_0657,acct_0097,parent_0097,contract_0268,2025-06-15,reactivation,generated_history,320000,3100000,3420000
|
||||
arr_move_0658,acct_0098,parent_0098,contract_0269,2025-06-15,new,generated_history,330000,3200000,3530000
|
||||
arr_move_0659,acct_0099,parent_0099,contract_0270,2025-06-15,expansion,generated_history,340000,3300000,3640000
|
||||
arr_move_0660,acct_0100,parent_0100,contract_0271,2025-06-15,contraction,generated_history,-350000,3400000,3050000
|
||||
arr_move_0661,acct_0101,parent_0101,contract_0272,2025-06-15,reactivation,generated_history,360000,3500000,3860000
|
||||
arr_move_0662,acct_0102,parent_0102,contract_0273,2025-06-15,new,generated_history,370000,3600000,3970000
|
||||
arr_move_0663,acct_0103,parent_0103,contract_0274,2025-06-15,expansion,generated_history,250000,3700000,3950000
|
||||
arr_move_0664,acct_0104,parent_0104,contract_0275,2025-06-15,contraction,generated_history,-260000,3800000,3540000
|
||||
arr_move_0665,acct_0105,parent_0105,contract_0276,2025-06-15,reactivation,generated_history,270000,2000000,2270000
|
||||
arr_move_0666,acct_0106,parent_0106,contract_0277,2025-06-15,new,generated_history,280000,2100000,2380000
|
||||
arr_move_0667,acct_0107,parent_0107,contract_0278,2025-06-15,expansion,generated_history,290000,2200000,2490000
|
||||
arr_move_0668,acct_0108,parent_0108,contract_0279,2025-06-15,contraction,generated_history,-300000,2300000,2000000
|
||||
arr_move_0669,acct_0109,parent_0109,contract_0280,2025-06-15,reactivation,generated_history,310000,2400000,2710000
|
||||
arr_move_0670,acct_0110,parent_0110,contract_0281,2025-06-15,new,generated_history,320000,2500000,2820000
|
||||
arr_move_0671,acct_0111,parent_0111,contract_0282,2025-06-15,expansion,generated_history,330000,2600000,2930000
|
||||
arr_move_0672,acct_0112,parent_0112,contract_0283,2025-06-15,contraction,generated_history,-340000,2700000,2360000
|
||||
arr_move_0673,acct_0113,parent_0113,contract_0284,2025-06-15,reactivation,generated_history,350000,2800000,3150000
|
||||
arr_move_0674,acct_0114,parent_0114,contract_0285,2025-06-15,new,generated_history,360000,2900000,3260000
|
||||
arr_move_0675,acct_0115,parent_0115,contract_0286,2025-06-15,expansion,generated_history,370000,3000000,3370000
|
||||
arr_move_0676,acct_0116,parent_0116,contract_0287,2025-06-15,contraction,generated_history,-250000,3100000,2850000
|
||||
arr_move_0677,acct_0117,parent_0117,contract_0288,2025-06-15,reactivation,generated_history,260000,3200000,3460000
|
||||
arr_move_0678,acct_0118,parent_0118,contract_0289,2025-06-15,new,generated_history,270000,3300000,3570000
|
||||
arr_move_0679,acct_0119,parent_0119,contract_0290,2025-06-15,expansion,generated_history,280000,3400000,3680000
|
||||
arr_move_0680,acct_0120,parent_0120,contract_0291,2025-06-15,contraction,generated_history,-290000,3500000,3210000
|
||||
arr_move_0681,acct_0121,parent_0121,contract_0292,2025-06-15,reactivation,generated_history,300000,3600000,3900000
|
||||
arr_move_0682,acct_0122,parent_0122,contract_0293,2025-06-15,new,generated_history,310000,3700000,4010000
|
||||
arr_move_0683,acct_0123,parent_0123,contract_0294,2025-06-15,expansion,generated_history,320000,3800000,4120000
|
||||
arr_move_0684,acct_0124,parent_0124,contract_0295,2025-06-15,contraction,generated_history,-330000,2000000,1670000
|
||||
arr_move_0685,acct_0125,parent_0125,contract_0296,2025-06-15,reactivation,generated_history,340000,2100000,2440000
|
||||
arr_move_0686,acct_0126,parent_0126,contract_0297,2025-06-15,new,generated_history,350000,2200000,2550000
|
||||
arr_move_0687,acct_0127,parent_0127,contract_0298,2025-06-15,expansion,generated_history,360000,2300000,2660000
|
||||
arr_move_0688,acct_0128,parent_0128,contract_0299,2025-06-15,contraction,generated_history,-370000,2400000,2030000
|
||||
arr_move_0689,acct_0129,parent_0129,contract_0300,2025-06-15,reactivation,generated_history,250000,2500000,2750000
|
||||
arr_move_0690,acct_0130,parent_0130,contract_0301,2025-06-15,new,generated_history,260000,2600000,2860000
|
||||
arr_move_0691,acct_0131,parent_0131,contract_0302,2025-06-15,expansion,generated_history,270000,2700000,2970000
|
||||
arr_move_0692,acct_0132,parent_0132,contract_0303,2025-06-15,contraction,generated_history,-280000,2800000,2520000
|
||||
arr_move_0693,acct_0133,parent_0001,contract_0304,2025-06-15,reactivation,generated_history,290000,2900000,3190000
|
||||
arr_move_0694,acct_0134,parent_0002,contract_0305,2025-06-15,new,generated_history,300000,3000000,3300000
|
||||
arr_move_0695,acct_0135,parent_0003,contract_0306,2025-06-15,expansion,generated_history,310000,3100000,3410000
|
||||
arr_move_0696,acct_0136,parent_0004,contract_0307,2025-06-15,contraction,generated_history,-320000,3200000,2880000
|
||||
arr_move_0697,acct_0137,parent_0005,contract_0308,2025-06-15,reactivation,generated_history,330000,3300000,3630000
|
||||
arr_move_0698,acct_0138,parent_0006,contract_0309,2025-06-15,new,generated_history,340000,3400000,3740000
|
||||
arr_move_0699,acct_0139,parent_0007,contract_0310,2025-06-15,expansion,generated_history,350000,3500000,3850000
|
||||
arr_move_0700,acct_0140,parent_0008,contract_0311,2025-06-15,contraction,generated_history,-360000,3600000,3240000
|
||||
arr_move_0701,acct_0141,parent_0009,contract_0312,2025-06-15,reactivation,generated_history,370000,3700000,4070000
|
||||
arr_move_0702,acct_0142,parent_0010,contract_0313,2025-06-15,new,generated_history,250000,3800000,4050000
|
||||
arr_move_0703,acct_0143,parent_0011,contract_0314,2025-06-15,expansion,generated_history,260000,2000000,2260000
|
||||
arr_move_0704,acct_0144,parent_0012,contract_0315,2025-06-15,contraction,generated_history,-270000,2100000,1830000
|
||||
arr_move_0705,acct_0145,parent_0013,contract_0316,2025-06-15,reactivation,generated_history,280000,2200000,2480000
|
||||
arr_move_0706,acct_0146,parent_0014,contract_0317,2025-06-15,new,generated_history,290000,2300000,2590000
|
||||
arr_move_0707,acct_0147,parent_0015,contract_0318,2025-06-15,expansion,generated_history,300000,2400000,2700000
|
||||
arr_move_0708,acct_0148,parent_0016,contract_0319,2025-06-15,contraction,generated_history,-310000,2500000,2190000
|
||||
arr_move_0709,acct_0149,parent_0017,contract_0320,2025-06-15,reactivation,generated_history,320000,2600000,2920000
|
||||
arr_move_0710,acct_0150,parent_0018,contract_0101,2025-06-15,new,generated_history,330000,2700000,3030000
|
||||
arr_move_0711,acct_0151,parent_0019,contract_0102,2025-06-15,expansion,generated_history,340000,2800000,3140000
|
||||
arr_move_0712,acct_0152,parent_0020,contract_0103,2025-06-15,contraction,generated_history,-350000,2900000,2550000
|
||||
arr_move_0713,acct_0153,parent_0021,contract_0104,2025-06-15,reactivation,generated_history,360000,3000000,3360000
|
||||
arr_move_0714,acct_0154,parent_0022,contract_0105,2025-06-15,new,generated_history,370000,3100000,3470000
|
||||
arr_move_0715,acct_0155,parent_0023,contract_0106,2025-06-15,expansion,generated_history,250000,3200000,3450000
|
||||
arr_move_0716,acct_0156,parent_0024,contract_0107,2025-06-15,contraction,generated_history,-260000,3300000,3040000
|
||||
arr_move_0717,acct_0157,parent_0025,contract_0108,2025-06-15,reactivation,generated_history,270000,3400000,3670000
|
||||
arr_move_0718,acct_0158,parent_0026,contract_0109,2025-06-15,new,generated_history,280000,3500000,3780000
|
||||
arr_move_0719,acct_0159,parent_0027,contract_0110,2025-06-15,expansion,generated_history,290000,3600000,3890000
|
||||
arr_move_0720,acct_0160,parent_0028,contract_0111,2025-06-15,contraction,generated_history,-300000,3700000,3400000
|
||||
|
|
|
@ -0,0 +1,321 @@
|
|||
contract_id,account_id,parent_account_id,plan_id,contract_arr_cents,booked_arr_cents,start_date,end_date,status,renewal_type
|
||||
contract_0001,acct_0001,parent_0001,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0002,acct_0002,parent_0002,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0003,acct_0003,parent_0003,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0004,acct_0004,parent_0004,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0005,acct_0005,parent_0005,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0006,acct_0006,parent_0006,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0007,acct_0007,parent_0007,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0008,acct_0008,parent_0008,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0009,acct_0009,parent_0009,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0010,acct_0010,parent_0010,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0011,acct_0011,parent_0011,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0012,acct_0012,parent_0012,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0013,acct_0013,parent_0013,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0014,acct_0014,parent_0014,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0015,acct_0015,parent_0015,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0016,acct_0016,parent_0016,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0017,acct_0017,parent_0017,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0018,acct_0018,parent_0018,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0019,acct_0019,parent_0019,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0020,acct_0020,parent_0020,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0021,acct_0021,parent_0021,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0022,acct_0022,parent_0022,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0023,acct_0023,parent_0023,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0024,acct_0024,parent_0024,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0025,acct_0025,parent_0025,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0026,acct_0026,parent_0026,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0027,acct_0027,parent_0027,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0028,acct_0028,parent_0028,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0029,acct_0029,parent_0029,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0030,acct_0030,parent_0030,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0031,acct_0031,parent_0031,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0032,acct_0032,parent_0032,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0033,acct_0033,parent_0033,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0034,acct_0034,parent_0034,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0035,acct_0035,parent_0035,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0036,acct_0036,parent_0036,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0037,acct_0037,parent_0037,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0038,acct_0038,parent_0038,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0039,acct_0039,parent_0039,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0040,acct_0040,parent_0040,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0041,acct_0041,parent_0041,plan_004,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0042,acct_0042,parent_0042,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0043,acct_0043,parent_0043,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0044,acct_0044,parent_0044,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0045,acct_0045,parent_0045,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0046,acct_0046,parent_0046,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0047,acct_0047,parent_0047,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0048,acct_0048,parent_0048,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0049,acct_0049,parent_0049,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0050,acct_0050,parent_0050,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0051,acct_0051,parent_0051,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0052,acct_0052,parent_0052,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0053,acct_0053,parent_0053,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0054,acct_0054,parent_0054,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0055,acct_0055,parent_0055,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0056,acct_0056,parent_0056,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0057,acct_0057,parent_0057,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0058,acct_0058,parent_0058,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0059,acct_0059,parent_0059,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0060,acct_0060,parent_0060,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0061,acct_0061,parent_0061,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0062,acct_0062,parent_0062,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0063,acct_0063,parent_0063,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0064,acct_0064,parent_0064,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0065,acct_0065,parent_0065,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0066,acct_0066,parent_0066,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0067,acct_0067,parent_0067,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0068,acct_0068,parent_0068,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0069,acct_0069,parent_0069,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0070,acct_0070,parent_0070,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0071,acct_0071,parent_0071,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0072,acct_0072,parent_0072,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0073,acct_0073,parent_0073,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0074,acct_0074,parent_0074,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0075,acct_0075,parent_0075,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0076,acct_0076,parent_0076,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0077,acct_0077,parent_0077,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0078,acct_0078,parent_0078,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0079,acct_0079,parent_0079,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0080,acct_0080,parent_0080,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0081,acct_0081,parent_0081,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0082,acct_0082,parent_0082,plan_004,12000000,12000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0083,acct_0083,parent_0083,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0084,acct_0084,parent_0084,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0085,acct_0085,parent_0085,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0086,acct_0086,parent_0086,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0087,acct_0087,parent_0087,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0088,acct_0088,parent_0088,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0089,acct_0089,parent_0089,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0090,acct_0090,parent_0090,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0091,acct_0091,parent_0091,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0092,acct_0092,parent_0092,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0093,acct_0093,parent_0093,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0094,acct_0094,parent_0094,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0095,acct_0095,parent_0095,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0096,acct_0096,parent_0096,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0097,acct_0097,parent_0097,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0098,acct_0098,parent_0098,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0099,acct_0099,parent_0099,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0100,acct_0100,parent_0100,plan_002,36200000,36200000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0101,acct_0101,parent_0101,plan_002,4010000,4010000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0102,acct_0102,parent_0102,plan_002,4020000,4020000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0103,acct_0103,parent_0103,plan_002,4030000,4030000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0104,acct_0104,parent_0104,plan_002,4040000,4040000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0105,acct_0105,parent_0105,plan_002,4050000,4050000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0106,acct_0106,parent_0106,plan_002,4060000,4060000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0107,acct_0107,parent_0107,plan_002,4070000,4070000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0108,acct_0108,parent_0108,plan_002,4080000,4080000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0109,acct_0109,parent_0109,plan_002,4090000,4090000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0110,acct_0110,parent_0110,plan_002,4100000,4100000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0111,acct_0111,parent_0111,plan_002,4110000,4110000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0112,acct_0112,parent_0112,plan_002,4120000,4120000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0113,acct_0113,parent_0113,plan_002,4130000,4130000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0114,acct_0114,parent_0114,plan_002,4140000,4140000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0115,acct_0115,parent_0115,plan_002,4150000,4150000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0116,acct_0116,parent_0116,plan_002,4160000,4160000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0117,acct_0117,parent_0117,plan_002,4170000,4170000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0118,acct_0118,parent_0118,plan_002,4180000,4180000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0119,acct_0119,parent_0119,plan_002,4190000,4190000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0120,acct_0120,parent_0120,plan_002,4200000,4200000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0121,acct_0121,parent_0121,plan_002,4210000,4210000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0122,acct_0122,parent_0122,plan_002,4220000,4220000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0123,acct_0123,parent_0123,plan_004,4230000,4230000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0124,acct_0124,parent_0124,plan_002,4240000,4240000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0125,acct_0125,parent_0125,plan_002,4250000,4250000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0126,acct_0126,parent_0126,plan_002,4260000,4260000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0127,acct_0127,parent_0127,plan_002,4270000,4270000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0128,acct_0128,parent_0128,plan_002,4280000,4280000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0129,acct_0129,parent_0129,plan_002,4290000,4290000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0130,acct_0130,parent_0130,plan_002,4300000,4300000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0131,acct_0131,parent_0131,plan_002,4310000,4310000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0132,acct_0132,parent_0132,plan_002,4320000,4320000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0133,acct_0133,parent_0001,plan_002,4330000,4330000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0134,acct_0134,parent_0002,plan_002,4340000,4340000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0135,acct_0135,parent_0003,plan_002,4350000,4350000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0136,acct_0136,parent_0004,plan_002,4360000,4360000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0137,acct_0137,parent_0005,plan_002,4370000,4370000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0138,acct_0138,parent_0006,plan_002,4380000,4380000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0139,acct_0139,parent_0007,plan_002,4390000,4390000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0140,acct_0140,parent_0008,plan_002,4400000,4400000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0141,acct_0141,parent_0009,plan_002,4410000,4410000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0142,acct_0142,parent_0010,plan_002,4420000,4420000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0143,acct_0143,parent_0011,plan_002,4430000,4430000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0144,acct_0144,parent_0012,plan_002,4440000,4440000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0145,acct_0145,parent_0013,plan_002,4450000,4450000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0146,acct_0146,parent_0014,plan_002,4460000,4460000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0147,acct_0147,parent_0015,plan_002,4470000,4470000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0148,acct_0148,parent_0016,plan_002,4480000,4480000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0149,acct_0149,parent_0017,plan_002,4490000,4490000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0150,acct_0150,parent_0018,plan_002,4500000,4500000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0151,acct_0151,parent_0019,plan_001,4510000,4510000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0152,acct_0152,parent_0020,plan_001,4520000,4520000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0153,acct_0153,parent_0021,plan_001,4530000,4530000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0154,acct_0154,parent_0022,plan_001,4540000,4540000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0155,acct_0155,parent_0023,plan_001,4550000,4550000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0156,acct_0156,parent_0024,plan_001,4560000,4560000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0157,acct_0157,parent_0025,plan_001,4570000,4570000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0158,acct_0158,parent_0026,plan_001,4580000,4580000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0159,acct_0159,parent_0027,plan_001,4590000,4590000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0160,acct_0160,parent_0028,plan_001,4600000,4600000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0161,acct_0161,parent_0029,plan_001,4610000,4610000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0162,acct_0162,parent_0030,plan_001,4620000,4620000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0163,acct_0163,parent_0031,plan_001,4630000,4630000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0164,acct_0164,parent_0032,plan_004,4640000,4640000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0165,acct_0165,parent_0033,plan_001,4650000,4650000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0166,acct_0166,parent_0034,plan_001,4660000,4660000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0167,acct_0167,parent_0035,plan_001,4670000,4670000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0168,acct_0168,parent_0036,plan_001,4680000,4680000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0169,acct_0169,parent_0037,plan_001,4690000,4690000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0170,acct_0170,parent_0038,plan_001,4700000,4700000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0171,acct_0171,parent_0039,plan_001,4710000,4710000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0172,acct_0172,parent_0040,plan_001,4720000,4720000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0173,acct_0173,parent_0041,plan_001,4730000,4730000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0174,acct_0174,parent_0042,plan_001,4740000,4740000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0175,acct_0175,parent_0043,plan_001,4750000,4750000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0176,acct_0176,parent_0044,plan_001,4760000,4760000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0177,acct_0177,parent_0045,plan_001,4770000,4770000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0178,acct_0178,parent_0046,plan_001,4780000,4780000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0179,acct_0179,parent_0047,plan_001,4790000,4790000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0180,acct_0180,parent_0048,plan_001,4800000,4800000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0181,acct_0181,parent_0049,plan_001,4810000,4810000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0182,acct_0182,parent_0050,plan_001,4820000,4820000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0183,acct_0183,parent_0051,plan_001,4830000,4830000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0184,acct_0184,parent_0052,plan_001,4840000,4840000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0185,acct_0185,parent_0053,plan_001,4850000,4850000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0186,acct_0186,parent_0054,plan_001,4860000,4860000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0187,acct_0187,parent_0055,plan_001,4870000,4870000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0188,acct_0188,parent_0056,plan_001,4880000,4880000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0189,acct_0189,parent_0057,plan_001,4890000,4890000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0190,acct_0190,parent_0058,plan_001,4900000,4900000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0191,acct_0191,parent_0059,plan_001,4910000,4910000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0192,acct_0192,parent_0060,plan_001,4920000,4920000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0193,acct_0193,parent_0061,plan_001,4930000,4930000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0194,acct_0194,parent_0062,plan_001,4940000,4940000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0195,acct_0195,parent_0063,plan_001,4950000,4950000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0196,acct_0196,parent_0064,plan_001,4960000,4960000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0197,acct_0197,parent_0065,plan_001,4970000,4970000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0198,acct_0198,parent_0066,plan_001,4980000,4980000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0199,acct_0199,parent_0067,plan_001,4990000,4990000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0200,acct_0200,parent_0068,plan_001,5000000,5000000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0201,acct_0001,parent_0001,plan_003,5010000,5010000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0202,acct_0002,parent_0002,plan_003,5020000,5020000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0203,acct_0003,parent_0003,plan_003,5030000,5030000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0204,acct_0004,parent_0004,plan_003,5040000,5040000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0205,acct_0005,parent_0005,plan_004,5050000,5050000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0206,acct_0006,parent_0006,plan_003,5060000,5060000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0207,acct_0007,parent_0007,plan_003,5070000,5070000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0208,acct_0008,parent_0008,plan_003,5080000,5080000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0209,acct_0009,parent_0009,plan_003,5090000,5090000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0210,acct_0010,parent_0010,plan_003,5100000,5100000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0211,acct_0011,parent_0011,plan_003,5110000,5110000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0212,acct_0012,parent_0012,plan_003,5120000,5120000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0213,acct_0013,parent_0013,plan_003,5130000,5130000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0214,acct_0014,parent_0014,plan_003,5140000,5140000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0215,acct_0015,parent_0015,plan_003,5150000,5150000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0216,acct_0016,parent_0016,plan_003,5160000,5160000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0217,acct_0017,parent_0017,plan_003,5170000,5170000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0218,acct_0018,parent_0018,plan_003,5180000,5180000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0219,acct_0019,parent_0019,plan_003,5190000,5190000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0220,acct_0020,parent_0020,plan_003,5200000,5200000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0221,acct_0021,parent_0021,plan_003,5210000,5210000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0222,acct_0022,parent_0022,plan_003,5220000,5220000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0223,acct_0023,parent_0023,plan_003,5230000,5230000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0224,acct_0024,parent_0024,plan_003,5240000,5240000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0225,acct_0025,parent_0025,plan_003,5250000,5250000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0226,acct_0026,parent_0026,plan_003,5260000,5260000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0227,acct_0027,parent_0027,plan_003,5270000,5270000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0228,acct_0028,parent_0028,plan_003,5280000,5280000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0229,acct_0029,parent_0029,plan_003,5290000,5290000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0230,acct_0030,parent_0030,plan_003,5300000,5300000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0231,acct_0031,parent_0031,plan_003,5310000,5310000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0232,acct_0032,parent_0032,plan_003,5320000,5320000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0233,acct_0033,parent_0033,plan_003,5330000,5330000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0234,acct_0034,parent_0034,plan_003,5340000,5340000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0235,acct_0035,parent_0035,plan_003,5350000,5350000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0236,acct_0036,parent_0036,plan_003,5360000,5360000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0237,acct_0037,parent_0037,plan_003,5370000,5370000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0238,acct_0038,parent_0038,plan_003,5380000,5380000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0239,acct_0039,parent_0039,plan_003,5390000,5390000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0240,acct_0040,parent_0040,plan_003,5400000,5400000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0241,acct_0041,parent_0041,plan_003,5410000,5410000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0242,acct_0042,parent_0042,plan_003,5420000,5420000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0243,acct_0043,parent_0043,plan_003,5430000,5430000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0244,acct_0044,parent_0044,plan_003,5440000,5440000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0245,acct_0045,parent_0045,plan_003,5450000,5450000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0246,acct_0046,parent_0046,plan_004,5460000,5460000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0247,acct_0047,parent_0047,plan_003,5470000,5470000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0248,acct_0048,parent_0048,plan_003,5480000,5480000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0249,acct_0049,parent_0049,plan_003,5490000,5490000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0250,acct_0050,parent_0050,plan_003,5500000,5500000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0251,acct_0051,parent_0051,plan_003,5510000,5510000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0252,acct_0052,parent_0052,plan_003,5520000,5520000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0253,acct_0053,parent_0053,plan_003,5530000,5530000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0254,acct_0054,parent_0054,plan_003,5540000,5540000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0255,acct_0055,parent_0055,plan_003,5550000,5550000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0256,acct_0056,parent_0056,plan_003,5560000,5560000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0257,acct_0057,parent_0057,plan_003,5570000,5570000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0258,acct_0058,parent_0058,plan_003,5580000,5580000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0259,acct_0059,parent_0059,plan_003,5590000,5590000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0260,acct_0060,parent_0060,plan_003,5600000,5600000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0261,acct_0061,parent_0061,plan_003,5610000,5610000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0262,acct_0062,parent_0062,plan_003,5620000,5620000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0263,acct_0063,parent_0063,plan_003,5630000,5630000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0264,acct_0064,parent_0064,plan_003,5640000,5640000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0265,acct_0065,parent_0065,plan_003,5650000,5650000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0266,acct_0066,parent_0066,plan_003,5660000,5660000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0267,acct_0067,parent_0067,plan_003,5670000,5670000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0268,acct_0068,parent_0068,plan_003,5680000,5680000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0269,acct_0069,parent_0069,plan_003,5690000,5690000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0270,acct_0070,parent_0070,plan_003,5700000,5700000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0271,acct_0071,parent_0071,plan_003,5710000,5710000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0272,acct_0072,parent_0072,plan_003,5720000,5720000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0273,acct_0073,parent_0073,plan_003,5730000,5730000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0274,acct_0074,parent_0074,plan_003,5740000,5740000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0275,acct_0075,parent_0075,plan_003,5750000,5750000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0276,acct_0076,parent_0076,plan_003,5760000,5760000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0277,acct_0077,parent_0077,plan_003,5770000,5770000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0278,acct_0078,parent_0078,plan_003,5780000,5780000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0279,acct_0079,parent_0079,plan_003,5790000,5790000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0280,acct_0080,parent_0080,plan_003,5800000,5800000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0281,acct_0081,parent_0081,plan_002,5810000,5810000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0282,acct_0082,parent_0082,plan_002,5820000,5820000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0283,acct_0083,parent_0083,plan_002,5830000,5830000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0284,acct_0084,parent_0084,plan_002,5840000,5840000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0285,acct_0085,parent_0085,plan_002,5850000,5850000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0286,acct_0086,parent_0086,plan_002,5860000,5860000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0287,acct_0087,parent_0087,plan_004,5870000,5870000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0288,acct_0088,parent_0088,plan_002,5880000,5880000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0289,acct_0089,parent_0089,plan_002,5890000,5890000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0290,acct_0090,parent_0090,plan_002,5900000,5900000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0291,acct_0091,parent_0091,plan_002,5910000,5910000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0292,acct_0092,parent_0092,plan_002,5920000,5920000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0293,acct_0093,parent_0093,plan_002,5930000,5930000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0294,acct_0094,parent_0094,plan_002,5940000,5940000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0295,acct_0095,parent_0095,plan_002,5950000,5950000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0296,acct_0096,parent_0096,plan_002,5960000,5960000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0297,acct_0097,parent_0097,plan_002,5970000,5970000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0298,acct_0098,parent_0098,plan_002,5980000,5980000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0299,acct_0099,parent_0099,plan_002,5990000,5990000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0300,acct_0100,parent_0100,plan_002,6000000,6000000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0301,acct_0101,parent_0101,plan_002,6010000,6010000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0302,acct_0102,parent_0102,plan_002,6020000,6020000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0303,acct_0103,parent_0103,plan_002,6030000,6030000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0304,acct_0104,parent_0104,plan_002,6040000,6040000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0305,acct_0105,parent_0105,plan_002,6050000,6050000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0306,acct_0106,parent_0106,plan_002,6060000,6060000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0307,acct_0107,parent_0107,plan_002,6070000,6070000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0308,acct_0108,parent_0108,plan_002,6080000,6080000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0309,acct_0109,parent_0109,plan_002,6090000,6090000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0310,acct_0110,parent_0110,plan_002,6100000,6100000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0311,acct_0111,parent_0111,plan_002,6110000,6110000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0312,acct_0112,parent_0112,plan_002,6120000,6120000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0313,acct_0113,parent_0113,plan_002,6130000,6130000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0314,acct_0114,parent_0114,plan_002,6140000,6140000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0315,acct_0115,parent_0115,plan_002,6150000,6150000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0316,acct_0116,parent_0116,plan_002,6160000,6160000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0317,acct_0117,parent_0117,plan_002,6170000,6170000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0318,acct_0118,parent_0118,plan_002,6180000,6180000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0319,acct_0119,parent_0119,plan_002,6190000,6190000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0320,acct_0120,parent_0120,plan_002,6200000,6200000,2025-03-01,2025-12-31,expired,downgrade
|
||||
|
3001
packages/cli/assets/demo/orbit/raw-sources/warehouse/invoices.csv
Normal file
3001
packages/cli/assets/demo/orbit/raw-sources/warehouse/invoices.csv
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,5 @@
|
|||
plan_id,plan_code,plan_name,canonical_plan_code,is_retired,retired_at
|
||||
plan_001,starter,Starter,starter,false,2099-12-31T00:00:00Z
|
||||
plan_002,growth,Growth,growth,false,2099-12-31T00:00:00Z
|
||||
plan_003,enterprise,Enterprise,enterprise,false,2099-12-31T00:00:00Z
|
||||
plan_004,pro_plus,Pro Plus,growth,true,2025-10-01T00:00:00Z
|
||||
|
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,521 @@
|
|||
support_ticket_id,account_id,requester_user_id,severity,category,status,created_at,resolved_at,owner_user_id
|
||||
ticket_0001,acct_0001,user_000287,critical,approval_routing,open,2026-03-10T17:00:00Z,2099-12-31T00:00:00Z,user_000901
|
||||
ticket_0002,acct_0002,user_000288,high,approval_routing,open,2026-03-11T17:00:00Z,2099-12-31T00:00:00Z,user_000902
|
||||
ticket_0003,acct_0003,user_000289,critical,approval_routing,open,2026-03-12T17:00:00Z,2099-12-31T00:00:00Z,user_000903
|
||||
ticket_0004,acct_0004,user_000290,high,approval_routing,open,2026-03-13T17:00:00Z,2099-12-31T00:00:00Z,user_000904
|
||||
ticket_0005,acct_0005,user_000291,critical,approval_routing,open,2026-03-14T17:00:00Z,2099-12-31T00:00:00Z,user_000905
|
||||
ticket_0006,acct_0006,user_000292,high,approval_routing,open,2026-03-15T17:00:00Z,2099-12-31T00:00:00Z,user_000906
|
||||
ticket_0007,acct_0007,user_000293,critical,approval_routing,open,2026-03-16T17:00:00Z,2099-12-31T00:00:00Z,user_000907
|
||||
ticket_0008,acct_0008,user_000294,high,approval_routing,open,2026-03-17T17:00:00Z,2099-12-31T00:00:00Z,user_000908
|
||||
ticket_0009,acct_0009,user_000295,critical,approval_routing,open,2026-03-18T17:00:00Z,2099-12-31T00:00:00Z,user_000909
|
||||
ticket_0010,acct_0010,user_000010,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_000909
|
||||
ticket_0011,acct_0011,user_000011,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_000910
|
||||
ticket_0012,acct_0012,user_000012,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_000911
|
||||
ticket_0013,acct_0013,user_000013,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_000912
|
||||
ticket_0014,acct_0014,user_000014,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_000913
|
||||
ticket_0015,acct_0015,user_000015,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_000914
|
||||
ticket_0016,acct_0016,user_000016,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_000915
|
||||
ticket_0017,acct_0017,user_000017,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_000916
|
||||
ticket_0018,acct_0018,user_000018,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_000917
|
||||
ticket_0019,acct_0019,user_000019,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_000918
|
||||
ticket_0020,acct_0020,user_000020,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_000919
|
||||
ticket_0021,acct_0021,user_000021,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_000920
|
||||
ticket_0022,acct_0022,user_000022,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_000921
|
||||
ticket_0023,acct_0023,user_000023,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_000922
|
||||
ticket_0024,acct_0024,user_000024,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_000923
|
||||
ticket_0025,acct_0025,user_000025,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_000924
|
||||
ticket_0026,acct_0026,user_000026,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_000925
|
||||
ticket_0027,acct_0027,user_000027,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_000926
|
||||
ticket_0028,acct_0028,user_000028,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_000927
|
||||
ticket_0029,acct_0029,user_000029,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_000928
|
||||
ticket_0030,acct_0030,user_000030,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_000929
|
||||
ticket_0031,acct_0031,user_000031,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_000930
|
||||
ticket_0032,acct_0032,user_000032,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_000931
|
||||
ticket_0033,acct_0033,user_000033,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_000932
|
||||
ticket_0034,acct_0034,user_000034,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_000933
|
||||
ticket_0035,acct_0035,user_000035,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_000934
|
||||
ticket_0036,acct_0036,user_000036,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_000935
|
||||
ticket_0037,acct_0037,user_000037,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_000936
|
||||
ticket_0038,acct_0038,user_000038,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_000937
|
||||
ticket_0039,acct_0039,user_000039,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_000938
|
||||
ticket_0040,acct_0040,user_000040,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_000939
|
||||
ticket_0041,acct_0041,user_000041,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_000940
|
||||
ticket_0042,acct_0042,user_000042,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_000941
|
||||
ticket_0043,acct_0043,user_000043,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_000942
|
||||
ticket_0044,acct_0044,user_000044,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_000943
|
||||
ticket_0045,acct_0045,user_000045,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_000944
|
||||
ticket_0046,acct_0046,user_000046,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_000945
|
||||
ticket_0047,acct_0047,user_000047,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_000946
|
||||
ticket_0048,acct_0048,user_000048,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_000947
|
||||
ticket_0049,acct_0049,user_000049,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_000948
|
||||
ticket_0050,acct_0050,user_000050,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_000949
|
||||
ticket_0051,acct_0051,user_000051,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_000950
|
||||
ticket_0052,acct_0052,user_000052,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_000951
|
||||
ticket_0053,acct_0053,user_000053,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_000952
|
||||
ticket_0054,acct_0054,user_000054,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_000953
|
||||
ticket_0055,acct_0055,user_000055,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_000954
|
||||
ticket_0056,acct_0056,user_000056,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_000955
|
||||
ticket_0057,acct_0057,user_000057,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_000956
|
||||
ticket_0058,acct_0058,user_000058,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_000957
|
||||
ticket_0059,acct_0059,user_000059,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_000958
|
||||
ticket_0060,acct_0060,user_000060,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_000959
|
||||
ticket_0061,acct_0061,user_000061,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_000960
|
||||
ticket_0062,acct_0062,user_000062,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_000961
|
||||
ticket_0063,acct_0063,user_000063,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_000962
|
||||
ticket_0064,acct_0064,user_000064,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_000963
|
||||
ticket_0065,acct_0065,user_000065,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_000964
|
||||
ticket_0066,acct_0066,user_000066,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_000965
|
||||
ticket_0067,acct_0067,user_000067,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_000966
|
||||
ticket_0068,acct_0068,user_000068,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_000967
|
||||
ticket_0069,acct_0069,user_000069,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_000968
|
||||
ticket_0070,acct_0070,user_000070,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_000969
|
||||
ticket_0071,acct_0071,user_000071,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_000970
|
||||
ticket_0072,acct_0072,user_000072,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_000971
|
||||
ticket_0073,acct_0073,user_000073,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_000972
|
||||
ticket_0074,acct_0074,user_000074,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_000973
|
||||
ticket_0075,acct_0075,user_000075,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_000974
|
||||
ticket_0076,acct_0076,user_000076,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_000975
|
||||
ticket_0077,acct_0077,user_000077,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_000976
|
||||
ticket_0078,acct_0078,user_000078,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_000977
|
||||
ticket_0079,acct_0079,user_000079,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_000978
|
||||
ticket_0080,acct_0080,user_000080,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_000979
|
||||
ticket_0081,acct_0081,user_000081,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_000980
|
||||
ticket_0082,acct_0082,user_000082,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_000981
|
||||
ticket_0083,acct_0083,user_000083,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_000982
|
||||
ticket_0084,acct_0084,user_000084,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_000983
|
||||
ticket_0085,acct_0085,user_000085,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_000984
|
||||
ticket_0086,acct_0086,user_000086,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_000985
|
||||
ticket_0087,acct_0087,user_000087,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_000986
|
||||
ticket_0088,acct_0088,user_000088,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_000987
|
||||
ticket_0089,acct_0089,user_000089,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_000988
|
||||
ticket_0090,acct_0090,user_000090,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_000989
|
||||
ticket_0091,acct_0091,user_000091,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_000990
|
||||
ticket_0092,acct_0092,user_000092,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_000991
|
||||
ticket_0093,acct_0093,user_000093,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_000992
|
||||
ticket_0094,acct_0094,user_000094,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_000993
|
||||
ticket_0095,acct_0095,user_000095,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_000994
|
||||
ticket_0096,acct_0096,user_000096,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_000995
|
||||
ticket_0097,acct_0097,user_000097,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_000996
|
||||
ticket_0098,acct_0098,user_000098,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_000997
|
||||
ticket_0099,acct_0099,user_000099,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_000998
|
||||
ticket_0100,acct_0100,user_000100,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_000999
|
||||
ticket_0101,acct_0101,user_000101,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_001000
|
||||
ticket_0102,acct_0102,user_000102,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_001001
|
||||
ticket_0103,acct_0103,user_000103,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_001002
|
||||
ticket_0104,acct_0104,user_000104,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_001003
|
||||
ticket_0105,acct_0105,user_000105,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_001004
|
||||
ticket_0106,acct_0106,user_000106,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_001005
|
||||
ticket_0107,acct_0107,user_000107,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_001006
|
||||
ticket_0108,acct_0108,user_000108,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_001007
|
||||
ticket_0109,acct_0109,user_000109,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_001008
|
||||
ticket_0110,acct_0110,user_000110,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_001009
|
||||
ticket_0111,acct_0111,user_000111,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_001010
|
||||
ticket_0112,acct_0112,user_000112,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_001011
|
||||
ticket_0113,acct_0113,user_000113,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_001012
|
||||
ticket_0114,acct_0114,user_000114,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_001013
|
||||
ticket_0115,acct_0115,user_000115,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_001014
|
||||
ticket_0116,acct_0116,user_000116,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_001015
|
||||
ticket_0117,acct_0117,user_000117,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_001016
|
||||
ticket_0118,acct_0118,user_000118,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_001017
|
||||
ticket_0119,acct_0119,user_000119,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_001018
|
||||
ticket_0120,acct_0120,user_000120,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_001019
|
||||
ticket_0121,acct_0121,user_000121,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_001020
|
||||
ticket_0122,acct_0122,user_000122,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_001021
|
||||
ticket_0123,acct_0123,user_000123,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_001022
|
||||
ticket_0124,acct_0124,user_000124,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_001023
|
||||
ticket_0125,acct_0125,user_000125,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_001024
|
||||
ticket_0126,acct_0126,user_000126,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_001025
|
||||
ticket_0127,acct_0127,user_000127,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_001026
|
||||
ticket_0128,acct_0128,user_000128,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_001027
|
||||
ticket_0129,acct_0129,user_000129,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_001028
|
||||
ticket_0130,acct_0130,user_000130,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_001029
|
||||
ticket_0131,acct_0131,user_000131,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_001030
|
||||
ticket_0132,acct_0132,user_000132,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_001031
|
||||
ticket_0133,acct_0133,user_000133,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_001032
|
||||
ticket_0134,acct_0134,user_000134,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_001033
|
||||
ticket_0135,acct_0135,user_000135,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_001034
|
||||
ticket_0136,acct_0136,user_000136,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_001035
|
||||
ticket_0137,acct_0137,user_000137,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_001036
|
||||
ticket_0138,acct_0138,user_000138,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_001037
|
||||
ticket_0139,acct_0139,user_000139,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_001038
|
||||
ticket_0140,acct_0140,user_000140,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_001039
|
||||
ticket_0141,acct_0141,user_000141,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_001040
|
||||
ticket_0142,acct_0142,user_000142,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_001041
|
||||
ticket_0143,acct_0143,user_000143,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_001042
|
||||
ticket_0144,acct_0144,user_000144,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_001043
|
||||
ticket_0145,acct_0145,user_000145,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_001044
|
||||
ticket_0146,acct_0146,user_000146,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_001045
|
||||
ticket_0147,acct_0147,user_000147,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_001046
|
||||
ticket_0148,acct_0148,user_000148,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_001047
|
||||
ticket_0149,acct_0149,user_000149,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_001048
|
||||
ticket_0150,acct_0150,user_000150,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_001049
|
||||
ticket_0151,acct_0151,user_000151,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_001050
|
||||
ticket_0152,acct_0152,user_000152,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_001051
|
||||
ticket_0153,acct_0153,user_000153,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_001052
|
||||
ticket_0154,acct_0154,user_000154,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_001053
|
||||
ticket_0155,acct_0155,user_000155,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_001054
|
||||
ticket_0156,acct_0156,user_000156,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_001055
|
||||
ticket_0157,acct_0157,user_000157,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_001056
|
||||
ticket_0158,acct_0158,user_000158,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_001057
|
||||
ticket_0159,acct_0159,user_000159,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_001058
|
||||
ticket_0160,acct_0160,user_000160,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_001059
|
||||
ticket_0161,acct_0161,user_000161,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_001060
|
||||
ticket_0162,acct_0162,user_000162,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_001061
|
||||
ticket_0163,acct_0163,user_000163,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_001062
|
||||
ticket_0164,acct_0164,user_000164,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_001063
|
||||
ticket_0165,acct_0165,user_000165,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_001064
|
||||
ticket_0166,acct_0166,user_000166,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_001065
|
||||
ticket_0167,acct_0167,user_000167,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_001066
|
||||
ticket_0168,acct_0168,user_000168,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_001067
|
||||
ticket_0169,acct_0169,user_000169,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_001068
|
||||
ticket_0170,acct_0170,user_000170,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_001069
|
||||
ticket_0171,acct_0171,user_000171,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_001070
|
||||
ticket_0172,acct_0172,user_000172,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_001071
|
||||
ticket_0173,acct_0173,user_000173,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_001072
|
||||
ticket_0174,acct_0174,user_000174,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_001073
|
||||
ticket_0175,acct_0175,user_000175,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_001074
|
||||
ticket_0176,acct_0176,user_000176,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_001075
|
||||
ticket_0177,acct_0177,user_000177,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_001076
|
||||
ticket_0178,acct_0178,user_000178,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_001077
|
||||
ticket_0179,acct_0179,user_000179,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_001078
|
||||
ticket_0180,acct_0180,user_000180,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_001079
|
||||
ticket_0181,acct_0181,user_000181,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_001080
|
||||
ticket_0182,acct_0182,user_000182,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_001081
|
||||
ticket_0183,acct_0183,user_000183,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_001082
|
||||
ticket_0184,acct_0184,user_000184,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_001083
|
||||
ticket_0185,acct_0185,user_000185,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_001084
|
||||
ticket_0186,acct_0186,user_000186,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_001085
|
||||
ticket_0187,acct_0187,user_000187,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_001086
|
||||
ticket_0188,acct_0188,user_000188,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_001087
|
||||
ticket_0189,acct_0189,user_000189,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_001088
|
||||
ticket_0190,acct_0190,user_000190,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_001089
|
||||
ticket_0191,acct_0191,user_000191,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_001090
|
||||
ticket_0192,acct_0192,user_000192,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_001091
|
||||
ticket_0193,acct_0193,user_000193,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_001092
|
||||
ticket_0194,acct_0194,user_000194,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_001093
|
||||
ticket_0195,acct_0195,user_000195,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_001094
|
||||
ticket_0196,acct_0196,user_000196,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_001095
|
||||
ticket_0197,acct_0197,user_000197,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_001096
|
||||
ticket_0198,acct_0198,user_000198,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_001097
|
||||
ticket_0199,acct_0199,user_000199,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_001098
|
||||
ticket_0200,acct_0010,user_000200,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_001099
|
||||
ticket_0201,acct_0011,user_000201,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_000900
|
||||
ticket_0202,acct_0012,user_000202,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_000901
|
||||
ticket_0203,acct_0013,user_000203,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_000902
|
||||
ticket_0204,acct_0014,user_000204,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_000903
|
||||
ticket_0205,acct_0015,user_000205,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_000904
|
||||
ticket_0206,acct_0016,user_000206,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_000905
|
||||
ticket_0207,acct_0017,user_000207,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_000906
|
||||
ticket_0208,acct_0018,user_000208,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_000907
|
||||
ticket_0209,acct_0019,user_000209,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_000908
|
||||
ticket_0210,acct_0020,user_000210,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_000909
|
||||
ticket_0211,acct_0021,user_000211,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_000910
|
||||
ticket_0212,acct_0022,user_000212,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_000911
|
||||
ticket_0213,acct_0023,user_000213,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_000912
|
||||
ticket_0214,acct_0024,user_000214,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_000913
|
||||
ticket_0215,acct_0025,user_000215,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_000914
|
||||
ticket_0216,acct_0026,user_000216,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_000915
|
||||
ticket_0217,acct_0027,user_000217,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_000916
|
||||
ticket_0218,acct_0028,user_000218,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_000917
|
||||
ticket_0219,acct_0029,user_000219,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_000918
|
||||
ticket_0220,acct_0030,user_000220,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_000919
|
||||
ticket_0221,acct_0031,user_000221,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_000920
|
||||
ticket_0222,acct_0032,user_000222,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_000921
|
||||
ticket_0223,acct_0033,user_000223,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_000922
|
||||
ticket_0224,acct_0034,user_000224,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_000923
|
||||
ticket_0225,acct_0035,user_000225,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_000924
|
||||
ticket_0226,acct_0036,user_000226,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_000925
|
||||
ticket_0227,acct_0037,user_000227,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_000926
|
||||
ticket_0228,acct_0038,user_000228,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_000927
|
||||
ticket_0229,acct_0039,user_000229,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_000928
|
||||
ticket_0230,acct_0040,user_000230,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_000929
|
||||
ticket_0231,acct_0041,user_000231,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_000930
|
||||
ticket_0232,acct_0042,user_000232,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_000931
|
||||
ticket_0233,acct_0043,user_000233,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_000932
|
||||
ticket_0234,acct_0044,user_000234,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_000933
|
||||
ticket_0235,acct_0045,user_000235,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_000934
|
||||
ticket_0236,acct_0046,user_000236,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_000935
|
||||
ticket_0237,acct_0047,user_000237,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_000936
|
||||
ticket_0238,acct_0048,user_000238,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_000937
|
||||
ticket_0239,acct_0049,user_000239,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_000938
|
||||
ticket_0240,acct_0050,user_000240,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_000939
|
||||
ticket_0241,acct_0051,user_000241,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_000940
|
||||
ticket_0242,acct_0052,user_000242,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_000941
|
||||
ticket_0243,acct_0053,user_000243,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_000942
|
||||
ticket_0244,acct_0054,user_000244,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_000943
|
||||
ticket_0245,acct_0055,user_000245,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_000944
|
||||
ticket_0246,acct_0056,user_000246,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_000945
|
||||
ticket_0247,acct_0057,user_000247,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_000946
|
||||
ticket_0248,acct_0058,user_000248,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_000947
|
||||
ticket_0249,acct_0059,user_000249,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_000948
|
||||
ticket_0250,acct_0060,user_000250,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_000949
|
||||
ticket_0251,acct_0061,user_000251,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_000950
|
||||
ticket_0252,acct_0062,user_000252,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_000951
|
||||
ticket_0253,acct_0063,user_000253,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_000952
|
||||
ticket_0254,acct_0064,user_000254,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_000953
|
||||
ticket_0255,acct_0065,user_000255,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_000954
|
||||
ticket_0256,acct_0066,user_000256,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_000955
|
||||
ticket_0257,acct_0067,user_000257,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_000956
|
||||
ticket_0258,acct_0068,user_000258,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_000957
|
||||
ticket_0259,acct_0069,user_000259,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_000958
|
||||
ticket_0260,acct_0070,user_000260,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_000959
|
||||
ticket_0261,acct_0071,user_000261,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_000960
|
||||
ticket_0262,acct_0072,user_000262,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_000961
|
||||
ticket_0263,acct_0073,user_000263,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_000962
|
||||
ticket_0264,acct_0074,user_000264,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_000963
|
||||
ticket_0265,acct_0075,user_000265,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_000964
|
||||
ticket_0266,acct_0076,user_000266,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_000965
|
||||
ticket_0267,acct_0077,user_000267,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_000966
|
||||
ticket_0268,acct_0078,user_000268,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_000967
|
||||
ticket_0269,acct_0079,user_000269,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_000968
|
||||
ticket_0270,acct_0080,user_000270,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_000969
|
||||
ticket_0271,acct_0081,user_000271,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_000970
|
||||
ticket_0272,acct_0082,user_000272,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_000971
|
||||
ticket_0273,acct_0083,user_000273,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_000972
|
||||
ticket_0274,acct_0084,user_000274,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_000973
|
||||
ticket_0275,acct_0085,user_000275,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_000974
|
||||
ticket_0276,acct_0086,user_000276,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_000975
|
||||
ticket_0277,acct_0087,user_000277,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_000976
|
||||
ticket_0278,acct_0088,user_000278,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_000977
|
||||
ticket_0279,acct_0089,user_000279,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_000978
|
||||
ticket_0280,acct_0090,user_000280,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_000979
|
||||
ticket_0281,acct_0091,user_000281,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_000980
|
||||
ticket_0282,acct_0092,user_000282,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_000981
|
||||
ticket_0283,acct_0093,user_000283,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_000982
|
||||
ticket_0284,acct_0094,user_000284,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_000983
|
||||
ticket_0285,acct_0095,user_000285,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_000984
|
||||
ticket_0286,acct_0096,user_000286,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_000985
|
||||
ticket_0287,acct_0097,user_000287,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_000986
|
||||
ticket_0288,acct_0098,user_000288,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_000987
|
||||
ticket_0289,acct_0099,user_000289,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_000988
|
||||
ticket_0290,acct_0100,user_000290,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_000989
|
||||
ticket_0291,acct_0101,user_000291,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_000990
|
||||
ticket_0292,acct_0102,user_000292,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_000991
|
||||
ticket_0293,acct_0103,user_000293,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_000992
|
||||
ticket_0294,acct_0104,user_000294,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_000993
|
||||
ticket_0295,acct_0105,user_000295,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_000994
|
||||
ticket_0296,acct_0106,user_000296,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_000995
|
||||
ticket_0297,acct_0107,user_000297,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_000996
|
||||
ticket_0298,acct_0108,user_000298,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_000997
|
||||
ticket_0299,acct_0109,user_000299,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_000998
|
||||
ticket_0300,acct_0110,user_000300,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_000999
|
||||
ticket_0301,acct_0111,user_000301,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_001000
|
||||
ticket_0302,acct_0112,user_000302,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_001001
|
||||
ticket_0303,acct_0113,user_000303,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_001002
|
||||
ticket_0304,acct_0114,user_000304,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_001003
|
||||
ticket_0305,acct_0115,user_000305,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_001004
|
||||
ticket_0306,acct_0116,user_000306,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_001005
|
||||
ticket_0307,acct_0117,user_000307,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_001006
|
||||
ticket_0308,acct_0118,user_000308,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_001007
|
||||
ticket_0309,acct_0119,user_000309,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_001008
|
||||
ticket_0310,acct_0120,user_000310,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_001009
|
||||
ticket_0311,acct_0121,user_000311,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_001010
|
||||
ticket_0312,acct_0122,user_000312,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_001011
|
||||
ticket_0313,acct_0123,user_000313,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_001012
|
||||
ticket_0314,acct_0124,user_000314,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_001013
|
||||
ticket_0315,acct_0125,user_000315,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_001014
|
||||
ticket_0316,acct_0126,user_000316,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_001015
|
||||
ticket_0317,acct_0127,user_000317,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_001016
|
||||
ticket_0318,acct_0128,user_000318,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_001017
|
||||
ticket_0319,acct_0129,user_000319,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_001018
|
||||
ticket_0320,acct_0130,user_000320,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_001019
|
||||
ticket_0321,acct_0131,user_000321,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_001020
|
||||
ticket_0322,acct_0132,user_000322,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_001021
|
||||
ticket_0323,acct_0133,user_000323,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_001022
|
||||
ticket_0324,acct_0134,user_000324,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_001023
|
||||
ticket_0325,acct_0135,user_000325,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_001024
|
||||
ticket_0326,acct_0136,user_000326,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_001025
|
||||
ticket_0327,acct_0137,user_000327,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_001026
|
||||
ticket_0328,acct_0138,user_000328,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_001027
|
||||
ticket_0329,acct_0139,user_000329,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_001028
|
||||
ticket_0330,acct_0140,user_000330,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_001029
|
||||
ticket_0331,acct_0141,user_000331,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_001030
|
||||
ticket_0332,acct_0142,user_000332,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_001031
|
||||
ticket_0333,acct_0143,user_000333,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_001032
|
||||
ticket_0334,acct_0144,user_000334,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_001033
|
||||
ticket_0335,acct_0145,user_000335,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_001034
|
||||
ticket_0336,acct_0146,user_000336,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_001035
|
||||
ticket_0337,acct_0147,user_000337,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_001036
|
||||
ticket_0338,acct_0148,user_000338,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_001037
|
||||
ticket_0339,acct_0149,user_000339,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_001038
|
||||
ticket_0340,acct_0150,user_000340,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_001039
|
||||
ticket_0341,acct_0151,user_000341,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_001040
|
||||
ticket_0342,acct_0152,user_000342,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_001041
|
||||
ticket_0343,acct_0153,user_000343,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_001042
|
||||
ticket_0344,acct_0154,user_000344,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_001043
|
||||
ticket_0345,acct_0155,user_000345,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_001044
|
||||
ticket_0346,acct_0156,user_000346,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_001045
|
||||
ticket_0347,acct_0157,user_000347,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_001046
|
||||
ticket_0348,acct_0158,user_000348,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_001047
|
||||
ticket_0349,acct_0159,user_000349,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_001048
|
||||
ticket_0350,acct_0160,user_000350,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_001049
|
||||
ticket_0351,acct_0161,user_000351,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_001050
|
||||
ticket_0352,acct_0162,user_000352,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_001051
|
||||
ticket_0353,acct_0163,user_000353,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_001052
|
||||
ticket_0354,acct_0164,user_000354,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_001053
|
||||
ticket_0355,acct_0165,user_000355,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_001054
|
||||
ticket_0356,acct_0166,user_000356,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_001055
|
||||
ticket_0357,acct_0167,user_000357,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_001056
|
||||
ticket_0358,acct_0168,user_000358,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_001057
|
||||
ticket_0359,acct_0169,user_000359,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_001058
|
||||
ticket_0360,acct_0170,user_000360,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_001059
|
||||
ticket_0361,acct_0171,user_000361,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_001060
|
||||
ticket_0362,acct_0172,user_000362,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_001061
|
||||
ticket_0363,acct_0173,user_000363,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_001062
|
||||
ticket_0364,acct_0174,user_000364,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_001063
|
||||
ticket_0365,acct_0175,user_000365,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_001064
|
||||
ticket_0366,acct_0176,user_000366,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_001065
|
||||
ticket_0367,acct_0177,user_000367,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_001066
|
||||
ticket_0368,acct_0178,user_000368,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_001067
|
||||
ticket_0369,acct_0179,user_000369,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_001068
|
||||
ticket_0370,acct_0180,user_000370,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_001069
|
||||
ticket_0371,acct_0181,user_000371,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_001070
|
||||
ticket_0372,acct_0182,user_000372,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_001071
|
||||
ticket_0373,acct_0183,user_000373,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_001072
|
||||
ticket_0374,acct_0184,user_000374,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_001073
|
||||
ticket_0375,acct_0185,user_000375,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_001074
|
||||
ticket_0376,acct_0186,user_000376,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_001075
|
||||
ticket_0377,acct_0187,user_000377,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_001076
|
||||
ticket_0378,acct_0188,user_000378,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_001077
|
||||
ticket_0379,acct_0189,user_000379,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_001078
|
||||
ticket_0380,acct_0190,user_000380,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_001079
|
||||
ticket_0381,acct_0191,user_000381,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_001080
|
||||
ticket_0382,acct_0192,user_000382,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_001081
|
||||
ticket_0383,acct_0193,user_000383,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_001082
|
||||
ticket_0384,acct_0194,user_000384,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_001083
|
||||
ticket_0385,acct_0195,user_000385,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_001084
|
||||
ticket_0386,acct_0196,user_000386,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_001085
|
||||
ticket_0387,acct_0197,user_000387,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_001086
|
||||
ticket_0388,acct_0198,user_000388,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_001087
|
||||
ticket_0389,acct_0199,user_000389,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_001088
|
||||
ticket_0390,acct_0010,user_000390,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_001089
|
||||
ticket_0391,acct_0011,user_000391,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_001090
|
||||
ticket_0392,acct_0012,user_000392,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_001091
|
||||
ticket_0393,acct_0013,user_000393,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_001092
|
||||
ticket_0394,acct_0014,user_000394,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_001093
|
||||
ticket_0395,acct_0015,user_000395,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_001094
|
||||
ticket_0396,acct_0016,user_000396,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_001095
|
||||
ticket_0397,acct_0017,user_000397,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_001096
|
||||
ticket_0398,acct_0018,user_000398,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_001097
|
||||
ticket_0399,acct_0019,user_000399,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_001098
|
||||
ticket_0400,acct_0020,user_000400,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_001099
|
||||
ticket_0401,acct_0021,user_000401,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_000900
|
||||
ticket_0402,acct_0022,user_000402,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_000901
|
||||
ticket_0403,acct_0023,user_000403,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_000902
|
||||
ticket_0404,acct_0024,user_000404,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_000903
|
||||
ticket_0405,acct_0025,user_000405,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_000904
|
||||
ticket_0406,acct_0026,user_000406,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_000905
|
||||
ticket_0407,acct_0027,user_000407,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_000906
|
||||
ticket_0408,acct_0028,user_000408,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_000907
|
||||
ticket_0409,acct_0029,user_000409,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_000908
|
||||
ticket_0410,acct_0030,user_000410,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_000909
|
||||
ticket_0411,acct_0031,user_000411,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_000910
|
||||
ticket_0412,acct_0032,user_000412,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_000911
|
||||
ticket_0413,acct_0033,user_000413,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_000912
|
||||
ticket_0414,acct_0034,user_000414,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_000913
|
||||
ticket_0415,acct_0035,user_000415,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_000914
|
||||
ticket_0416,acct_0036,user_000416,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_000915
|
||||
ticket_0417,acct_0037,user_000417,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_000916
|
||||
ticket_0418,acct_0038,user_000418,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_000917
|
||||
ticket_0419,acct_0039,user_000419,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_000918
|
||||
ticket_0420,acct_0040,user_000420,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_000919
|
||||
ticket_0421,acct_0041,user_000421,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_000920
|
||||
ticket_0422,acct_0042,user_000422,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_000921
|
||||
ticket_0423,acct_0043,user_000423,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_000922
|
||||
ticket_0424,acct_0044,user_000424,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_000923
|
||||
ticket_0425,acct_0045,user_000425,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_000924
|
||||
ticket_0426,acct_0046,user_000426,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_000925
|
||||
ticket_0427,acct_0047,user_000427,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_000926
|
||||
ticket_0428,acct_0048,user_000428,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_000927
|
||||
ticket_0429,acct_0049,user_000429,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_000928
|
||||
ticket_0430,acct_0050,user_000430,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_000929
|
||||
ticket_0431,acct_0051,user_000431,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_000930
|
||||
ticket_0432,acct_0052,user_000432,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_000931
|
||||
ticket_0433,acct_0053,user_000433,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_000932
|
||||
ticket_0434,acct_0054,user_000434,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_000933
|
||||
ticket_0435,acct_0055,user_000435,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_000934
|
||||
ticket_0436,acct_0056,user_000436,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_000935
|
||||
ticket_0437,acct_0057,user_000437,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_000936
|
||||
ticket_0438,acct_0058,user_000438,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_000937
|
||||
ticket_0439,acct_0059,user_000439,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_000938
|
||||
ticket_0440,acct_0060,user_000440,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_000939
|
||||
ticket_0441,acct_0061,user_000441,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_000940
|
||||
ticket_0442,acct_0062,user_000442,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_000941
|
||||
ticket_0443,acct_0063,user_000443,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_000942
|
||||
ticket_0444,acct_0064,user_000444,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_000943
|
||||
ticket_0445,acct_0065,user_000445,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_000944
|
||||
ticket_0446,acct_0066,user_000446,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_000945
|
||||
ticket_0447,acct_0067,user_000447,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_000946
|
||||
ticket_0448,acct_0068,user_000448,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_000947
|
||||
ticket_0449,acct_0069,user_000449,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_000948
|
||||
ticket_0450,acct_0070,user_000450,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_000949
|
||||
ticket_0451,acct_0071,user_000451,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_000950
|
||||
ticket_0452,acct_0072,user_000452,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_000951
|
||||
ticket_0453,acct_0073,user_000453,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_000952
|
||||
ticket_0454,acct_0074,user_000454,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_000953
|
||||
ticket_0455,acct_0075,user_000455,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_000954
|
||||
ticket_0456,acct_0076,user_000456,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_000955
|
||||
ticket_0457,acct_0077,user_000457,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_000956
|
||||
ticket_0458,acct_0078,user_000458,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_000957
|
||||
ticket_0459,acct_0079,user_000459,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_000958
|
||||
ticket_0460,acct_0080,user_000460,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_000959
|
||||
ticket_0461,acct_0081,user_000461,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_000960
|
||||
ticket_0462,acct_0082,user_000462,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_000961
|
||||
ticket_0463,acct_0083,user_000463,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_000962
|
||||
ticket_0464,acct_0084,user_000464,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_000963
|
||||
ticket_0465,acct_0085,user_000465,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_000964
|
||||
ticket_0466,acct_0086,user_000466,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_000965
|
||||
ticket_0467,acct_0087,user_000467,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_000966
|
||||
ticket_0468,acct_0088,user_000468,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_000967
|
||||
ticket_0469,acct_0089,user_000469,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_000968
|
||||
ticket_0470,acct_0090,user_000470,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_000969
|
||||
ticket_0471,acct_0091,user_000471,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_000970
|
||||
ticket_0472,acct_0092,user_000472,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_000971
|
||||
ticket_0473,acct_0093,user_000473,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_000972
|
||||
ticket_0474,acct_0094,user_000474,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_000973
|
||||
ticket_0475,acct_0095,user_000475,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_000974
|
||||
ticket_0476,acct_0096,user_000476,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_000975
|
||||
ticket_0477,acct_0097,user_000477,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_000976
|
||||
ticket_0478,acct_0098,user_000478,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_000977
|
||||
ticket_0479,acct_0099,user_000479,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_000978
|
||||
ticket_0480,acct_0100,user_000480,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_000979
|
||||
ticket_0481,acct_0101,user_000481,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_000980
|
||||
ticket_0482,acct_0102,user_000482,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_000981
|
||||
ticket_0483,acct_0103,user_000483,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_000982
|
||||
ticket_0484,acct_0104,user_000484,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_000983
|
||||
ticket_0485,acct_0105,user_000485,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_000984
|
||||
ticket_0486,acct_0106,user_000486,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_000985
|
||||
ticket_0487,acct_0107,user_000487,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_000986
|
||||
ticket_0488,acct_0108,user_000488,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_000987
|
||||
ticket_0489,acct_0109,user_000489,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_000988
|
||||
ticket_0490,acct_0110,user_000490,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_000989
|
||||
ticket_0491,acct_0111,user_000491,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_000990
|
||||
ticket_0492,acct_0112,user_000492,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_000991
|
||||
ticket_0493,acct_0113,user_000493,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_000992
|
||||
ticket_0494,acct_0114,user_000494,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_000993
|
||||
ticket_0495,acct_0115,user_000495,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_000994
|
||||
ticket_0496,acct_0116,user_000496,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_000995
|
||||
ticket_0497,acct_0117,user_000497,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_000996
|
||||
ticket_0498,acct_0118,user_000498,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_000997
|
||||
ticket_0499,acct_0119,user_000499,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_000998
|
||||
ticket_0500,acct_0120,user_000500,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_000999
|
||||
ticket_0501,acct_0121,user_000501,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_001000
|
||||
ticket_0502,acct_0122,user_000502,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_001001
|
||||
ticket_0503,acct_0123,user_000503,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_001002
|
||||
ticket_0504,acct_0124,user_000504,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_001003
|
||||
ticket_0505,acct_0125,user_000505,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_001004
|
||||
ticket_0506,acct_0126,user_000506,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_001005
|
||||
ticket_0507,acct_0127,user_000507,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_001006
|
||||
ticket_0508,acct_0128,user_000508,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_001007
|
||||
ticket_0509,acct_0129,user_000509,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_001008
|
||||
ticket_0510,acct_0130,user_000510,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_001009
|
||||
ticket_0511,acct_0131,user_000511,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_001010
|
||||
ticket_0512,acct_0132,user_000512,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_001011
|
||||
ticket_0513,acct_0133,user_000513,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_001012
|
||||
ticket_0514,acct_0134,user_000514,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_001013
|
||||
ticket_0515,acct_0135,user_000515,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_001014
|
||||
ticket_0516,acct_0136,user_000516,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_001015
|
||||
ticket_0517,acct_0137,user_000517,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_001016
|
||||
ticket_0518,acct_0138,user_000518,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_001017
|
||||
ticket_0519,acct_0139,user_000519,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_001018
|
||||
ticket_0520,acct_0140,user_000520,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_001019
|
||||
|
1261
packages/cli/assets/demo/orbit/raw-sources/warehouse/users.csv
Normal file
1261
packages/cli/assets/demo/orbit/raw-sources/warehouse/users.csv
Normal file
File diff suppressed because it is too large
Load diff
707
packages/cli/assets/demo/orbit/replay.memory-flow.v1.json
Normal file
707
packages/cli/assets/demo/orbit/replay.memory-flow.v1.json
Normal file
|
|
@ -0,0 +1,707 @@
|
|||
{
|
||||
"memoryFlowReplaySchemaVersion": 1,
|
||||
"replay": {
|
||||
"runId": "demo-seeded-orbit",
|
||||
"connectionId": "orbit_demo",
|
||||
"adapter": "live-database",
|
||||
"status": "done",
|
||||
"sourceDir": null,
|
||||
"syncId": "demo-seeded-sync",
|
||||
"reportId": "demo-seeded-report",
|
||||
"reportPath": "reports/seeded-demo-report.json",
|
||||
"errors": [],
|
||||
"metadata": {
|
||||
"schemaVersion": 1,
|
||||
"mode": "seeded",
|
||||
"origin": "packaged",
|
||||
"timing": "prebuilt",
|
||||
"capturedAt": "2026-05-06T00:00:00.000Z",
|
||||
"sourceReportId": "demo-seeded-report",
|
||||
"sourceReportPath": "reports/seeded-demo-report.json",
|
||||
"fallbackReason": null
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"type": "source_acquired",
|
||||
"adapter": "live-database",
|
||||
"trigger": "demo_seeded",
|
||||
"fileCount": 8
|
||||
},
|
||||
{
|
||||
"type": "source_acquired",
|
||||
"adapter": "dbt_descriptions",
|
||||
"trigger": "demo_seeded",
|
||||
"fileCount": 6
|
||||
},
|
||||
{
|
||||
"type": "source_acquired",
|
||||
"adapter": "looker",
|
||||
"trigger": "demo_seeded",
|
||||
"fileCount": 7
|
||||
},
|
||||
{
|
||||
"type": "source_acquired",
|
||||
"adapter": "notion",
|
||||
"trigger": "demo_seeded",
|
||||
"fileCount": 8
|
||||
},
|
||||
{
|
||||
"type": "scope_detected",
|
||||
"fingerprint": "sqlite:orbit-demo"
|
||||
},
|
||||
{
|
||||
"type": "raw_snapshot_written",
|
||||
"syncId": "demo-seeded-sync",
|
||||
"rawFileCount": 29
|
||||
},
|
||||
{
|
||||
"type": "diff_computed",
|
||||
"added": 29,
|
||||
"modified": 0,
|
||||
"deleted": 0,
|
||||
"unchanged": 0
|
||||
},
|
||||
{
|
||||
"type": "chunks_planned",
|
||||
"chunkCount": 5,
|
||||
"workUnitCount": 5,
|
||||
"evictionCount": 0
|
||||
},
|
||||
{
|
||||
"type": "work_unit_started",
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"skills": [
|
||||
"knowledge_capture",
|
||||
"sl_capture"
|
||||
],
|
||||
"stepBudget": 40
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/arr-contract-first.md"
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/revenue-gross-to-net.md"
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/discount-expiration.md"
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"target": "sl",
|
||||
"action": "created",
|
||||
"key": "orbit_demo.contracts"
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"target": "sl",
|
||||
"action": "created",
|
||||
"key": "orbit_demo.invoices"
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"target": "sl",
|
||||
"action": "created",
|
||||
"key": "orbit_demo.arr_movements"
|
||||
},
|
||||
{
|
||||
"type": "work_unit_finished",
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"type": "work_unit_started",
|
||||
"unitKey": "retention-and-segments",
|
||||
"skills": [
|
||||
"knowledge_capture",
|
||||
"sl_capture"
|
||||
],
|
||||
"stepBudget": 40
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "retention-and-segments",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/nrr-retention.md"
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "retention-and-segments",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/segment-classification.md"
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "retention-and-segments",
|
||||
"target": "sl",
|
||||
"action": "created",
|
||||
"key": "orbit_demo.accounts"
|
||||
},
|
||||
{
|
||||
"type": "work_unit_finished",
|
||||
"unitKey": "retention-and-segments",
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"type": "work_unit_started",
|
||||
"unitKey": "procurement-and-activation",
|
||||
"skills": [
|
||||
"knowledge_capture",
|
||||
"sl_capture"
|
||||
],
|
||||
"stepBudget": 40
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "procurement-and-activation",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/activation-policy.md"
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "procurement-and-activation",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/procurement-workflows.md"
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "procurement-and-activation",
|
||||
"target": "sl",
|
||||
"action": "created",
|
||||
"key": "orbit_demo.purchase_requests"
|
||||
},
|
||||
{
|
||||
"type": "work_unit_finished",
|
||||
"unitKey": "procurement-and-activation",
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"type": "work_unit_started",
|
||||
"unitKey": "support-and-health",
|
||||
"skills": [
|
||||
"knowledge_capture",
|
||||
"sl_capture"
|
||||
],
|
||||
"stepBudget": 40
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "support-and-health",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/customer-health-scoring.md"
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "support-and-health",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/support-escalation.md"
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "support-and-health",
|
||||
"target": "sl",
|
||||
"action": "created",
|
||||
"key": "orbit_demo.support_tickets"
|
||||
},
|
||||
{
|
||||
"type": "work_unit_finished",
|
||||
"unitKey": "support-and-health",
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"type": "work_unit_started",
|
||||
"unitKey": "governance-and-exclusions",
|
||||
"skills": [
|
||||
"knowledge_capture"
|
||||
],
|
||||
"stepBudget": 40
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "governance-and-exclusions",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/internal-test-exclusion.md"
|
||||
},
|
||||
{
|
||||
"type": "work_unit_finished",
|
||||
"unitKey": "governance-and-exclusions",
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"type": "reconciliation_finished",
|
||||
"conflictCount": 0,
|
||||
"fallbackCount": 0
|
||||
},
|
||||
{
|
||||
"type": "saved",
|
||||
"commitSha": "demo-seeded",
|
||||
"wikiCount": 10,
|
||||
"slCount": 6
|
||||
},
|
||||
{
|
||||
"type": "provenance_recorded",
|
||||
"rowCount": 23
|
||||
},
|
||||
{
|
||||
"type": "report_created",
|
||||
"runId": "demo-seeded-orbit",
|
||||
"reportPath": "reports/seeded-demo-report.json"
|
||||
}
|
||||
],
|
||||
"plannedWorkUnits": [
|
||||
{
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"rawFiles": [
|
||||
"contracts",
|
||||
"invoices",
|
||||
"arr_movements"
|
||||
],
|
||||
"peerFileCount": 3,
|
||||
"dependencyCount": 3
|
||||
},
|
||||
{
|
||||
"unitKey": "retention-and-segments",
|
||||
"rawFiles": [
|
||||
"accounts",
|
||||
"plans"
|
||||
],
|
||||
"peerFileCount": 2,
|
||||
"dependencyCount": 2
|
||||
},
|
||||
{
|
||||
"unitKey": "procurement-and-activation",
|
||||
"rawFiles": [
|
||||
"purchase_requests",
|
||||
"users"
|
||||
],
|
||||
"peerFileCount": 2,
|
||||
"dependencyCount": 2
|
||||
},
|
||||
{
|
||||
"unitKey": "support-and-health",
|
||||
"rawFiles": [
|
||||
"support_tickets"
|
||||
],
|
||||
"peerFileCount": 1,
|
||||
"dependencyCount": 1
|
||||
},
|
||||
{
|
||||
"unitKey": "governance-and-exclusions",
|
||||
"rawFiles": [
|
||||
"notion/export/pages/analyst-onboarding.md"
|
||||
],
|
||||
"peerFileCount": 1,
|
||||
"dependencyCount": 0
|
||||
}
|
||||
],
|
||||
"details": {
|
||||
"actions": [
|
||||
{
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/arr-contract-first.md",
|
||||
"summary": "ARR follows contract precedence with cancellation and discount caveats.",
|
||||
"rawFiles": [
|
||||
"contracts",
|
||||
"arr_movements",
|
||||
"raw-sources/notion/arr-and-contract-reporting-notes.md"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/revenue-gross-to-net.md",
|
||||
"summary": "Invoice, refund, and revenue dashboard evidence reconcile gross to net revenue.",
|
||||
"rawFiles": [
|
||||
"invoices",
|
||||
"raw-sources/bi/revenue_exec.dashboard.lookml"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/discount-expiration.md",
|
||||
"summary": "Discount expiration is separated from organic contraction for retention reporting.",
|
||||
"rawFiles": [
|
||||
"contracts",
|
||||
"arr_movements"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"target": "sl",
|
||||
"action": "created",
|
||||
"key": "orbit_demo.contracts",
|
||||
"summary": "Contract grain with active ARR measures and account joins.",
|
||||
"rawFiles": [
|
||||
"contracts",
|
||||
"raw-sources/dbt/schema.yml"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"target": "sl",
|
||||
"action": "created",
|
||||
"key": "orbit_demo.invoices",
|
||||
"summary": "Invoice status measures tied to gross and net revenue reporting.",
|
||||
"rawFiles": [
|
||||
"invoices",
|
||||
"raw-sources/bi/revenue_daily.view.lkml"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"target": "sl",
|
||||
"action": "created",
|
||||
"key": "orbit_demo.arr_movements",
|
||||
"summary": "ARR movement ledger for expansion, contraction, churn, and NRR.",
|
||||
"rawFiles": [
|
||||
"arr_movements",
|
||||
"raw-sources/bi/account_retention.view.lkml"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "retention-and-segments",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/nrr-retention.md",
|
||||
"summary": "NRR uses parent-account rollups and quarterly ARR movement windows.",
|
||||
"rawFiles": [
|
||||
"accounts",
|
||||
"arr_movements",
|
||||
"raw-sources/notion/retention-and-nrr-definition-notes.md"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "retention-and-segments",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/segment-classification.md",
|
||||
"summary": "Segment labels come from plan mapping and sales-ops policy notes.",
|
||||
"rawFiles": [
|
||||
"accounts",
|
||||
"plans",
|
||||
"raw-sources/notion/sales-ops-segmentation-guide.md"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "retention-and-segments",
|
||||
"target": "sl",
|
||||
"action": "created",
|
||||
"key": "orbit_demo.accounts",
|
||||
"summary": "Account dimensions with lifecycle, segment, and internal-test exclusions.",
|
||||
"rawFiles": [
|
||||
"accounts",
|
||||
"plans"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "procurement-and-activation",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/activation-policy.md",
|
||||
"summary": "Activation policy changed on January 15, 2026 and is encoded for agents.",
|
||||
"rawFiles": [
|
||||
"purchase_requests",
|
||||
"users",
|
||||
"raw-sources/notion/activation-policy-decision-record.md"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "procurement-and-activation",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/procurement-workflows.md",
|
||||
"summary": "Procurement requester activity and approval events explain product usage.",
|
||||
"rawFiles": [
|
||||
"purchase_requests",
|
||||
"raw-sources/bi/procurement_activity.view.lkml"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "procurement-and-activation",
|
||||
"target": "sl",
|
||||
"action": "created",
|
||||
"key": "orbit_demo.purchase_requests",
|
||||
"summary": "Procurement request facts with requester and approval-state measures.",
|
||||
"rawFiles": [
|
||||
"purchase_requests"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "support-and-health",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/customer-health-scoring.md",
|
||||
"summary": "Customer health combines support severity, ARR exposure, and product usage.",
|
||||
"rawFiles": [
|
||||
"support_tickets",
|
||||
"raw-sources/notion/customer-health-playbook.md"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "support-and-health",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/support-escalation.md",
|
||||
"summary": "Escalation tiers map ticket severity to SLA expectations.",
|
||||
"rawFiles": [
|
||||
"support_tickets",
|
||||
"raw-sources/notion/support-escalation-runbook.md"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "support-and-health",
|
||||
"target": "sl",
|
||||
"action": "created",
|
||||
"key": "orbit_demo.support_tickets",
|
||||
"summary": "Support ticket facts with severity, status, and resolution-hour measures.",
|
||||
"rawFiles": [
|
||||
"support_tickets"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "governance-and-exclusions",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/internal-test-exclusion.md",
|
||||
"summary": "Canonical metrics exclude internal and test accounts across source families.",
|
||||
"rawFiles": [
|
||||
"raw-sources/notion/analyst-onboarding.md"
|
||||
],
|
||||
"status": "success"
|
||||
}
|
||||
],
|
||||
"provenance": [
|
||||
{
|
||||
"rawPath": "contracts",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/arr-contract-first.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "raw-sources/notion/arr-and-contract-reporting-notes.md",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/arr-contract-first.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "invoices",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/revenue-gross-to-net.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "raw-sources/notion/revenue-reporting-policy.md",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/revenue-gross-to-net.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "arr_movements",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/discount-expiration.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "arr_movements",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/nrr-retention.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "raw-sources/notion/retention-and-nrr-definition-notes.md",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/nrr-retention.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "raw-sources/bi/account_retention.view.lkml",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/nrr-retention.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "plans",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/segment-classification.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "raw-sources/notion/sales-ops-segmentation-guide.md",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/segment-classification.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "raw-sources/notion/activation-policy-decision-record.md",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/activation-policy.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "purchase_requests",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/procurement-workflows.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "raw-sources/notion/customer-health-playbook.md",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/customer-health-scoring.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "support_tickets",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/customer-health-scoring.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "raw-sources/notion/support-escalation-runbook.md",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/support-escalation.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "raw-sources/notion/analyst-onboarding.md",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/internal-test-exclusion.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "accounts",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.accounts",
|
||||
"actionType": "sl_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "raw-sources/dbt/schema.yml",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.accounts",
|
||||
"actionType": "sl_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "contracts",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.contracts",
|
||||
"actionType": "sl_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "invoices",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.invoices",
|
||||
"actionType": "sl_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "arr_movements",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.arr_movements",
|
||||
"actionType": "sl_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "purchase_requests",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.purchase_requests",
|
||||
"actionType": "sl_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "support_tickets",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.support_tickets",
|
||||
"actionType": "sl_written"
|
||||
}
|
||||
],
|
||||
"transcripts": [
|
||||
{
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"path": "transcripts/revenue-and-contracts.jsonl",
|
||||
"toolCallCount": 5,
|
||||
"errorCount": 0,
|
||||
"toolNames": [
|
||||
"wiki_write",
|
||||
"sl_write_source"
|
||||
]
|
||||
},
|
||||
{
|
||||
"unitKey": "retention-and-segments",
|
||||
"path": "transcripts/retention-and-segments.jsonl",
|
||||
"toolCallCount": 5,
|
||||
"errorCount": 0,
|
||||
"toolNames": [
|
||||
"wiki_write",
|
||||
"sl_write_source"
|
||||
]
|
||||
},
|
||||
{
|
||||
"unitKey": "procurement-and-activation",
|
||||
"path": "transcripts/procurement-and-activation.jsonl",
|
||||
"toolCallCount": 5,
|
||||
"errorCount": 0,
|
||||
"toolNames": [
|
||||
"wiki_write",
|
||||
"sl_write_source"
|
||||
]
|
||||
},
|
||||
{
|
||||
"unitKey": "support-and-health",
|
||||
"path": "transcripts/support-and-health.jsonl",
|
||||
"toolCallCount": 5,
|
||||
"errorCount": 0,
|
||||
"toolNames": [
|
||||
"wiki_write",
|
||||
"sl_write_source"
|
||||
]
|
||||
},
|
||||
{
|
||||
"unitKey": "governance-and-exclusions",
|
||||
"path": "transcripts/governance-and-exclusions.jsonl",
|
||||
"toolCallCount": 2,
|
||||
"errorCount": 0,
|
||||
"toolNames": [
|
||||
"wiki_write"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"id": "demo-seeded-report",
|
||||
"runId": "demo-seeded-orbit",
|
||||
"connectionId": "orbit_demo",
|
||||
"mode": "seeded",
|
||||
"status": "complete",
|
||||
"createdAt": "2026-05-06T00:00:00.000Z",
|
||||
"summary": {
|
||||
"sources": {
|
||||
"warehouse": {
|
||||
"tables": 8,
|
||||
"rows": 11234
|
||||
},
|
||||
"dbt": {
|
||||
"models": 3,
|
||||
"sources": 8
|
||||
},
|
||||
"bi": {
|
||||
"explores": 5,
|
||||
"dashboards": 2,
|
||||
"views": 5
|
||||
},
|
||||
"notion": {
|
||||
"pages": 8
|
||||
}
|
||||
},
|
||||
"generated": {
|
||||
"semanticLayerSources": 6,
|
||||
"knowledgePages": 10,
|
||||
"provenanceLinks": 23
|
||||
},
|
||||
"metadata": {
|
||||
"mode": "seeded",
|
||||
"origin": "packaged",
|
||||
"llmCalls": 0,
|
||||
"timing": "prebuilt",
|
||||
"source": "packaged-orbit-demo"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
name: accounts
|
||||
table: accounts
|
||||
description: Customer accounts with industry, region, lifecycle, and internal/test flags.
|
||||
grain:
|
||||
- account_id
|
||||
columns:
|
||||
- name: account_id
|
||||
type: string
|
||||
- name: parent_account_id
|
||||
type: string
|
||||
- name: account_name
|
||||
type: string
|
||||
- name: domain
|
||||
type: string
|
||||
- name: industry
|
||||
type: string
|
||||
- name: sales_region
|
||||
type: string
|
||||
- name: size_band
|
||||
type: string
|
||||
- name: lifecycle_status
|
||||
type: string
|
||||
- name: is_internal
|
||||
type: boolean
|
||||
- name: is_test
|
||||
type: boolean
|
||||
- name: created_at
|
||||
type: time
|
||||
joins:
|
||||
- to: contracts
|
||||
"on": "account_id = contracts.account_id"
|
||||
relationship: one_to_many
|
||||
- to: purchase_requests
|
||||
"on": "account_id = purchase_requests.account_id"
|
||||
relationship: one_to_many
|
||||
measures:
|
||||
- name: account_count
|
||||
expr: "count(distinct account_id)"
|
||||
- name: enterprise_count
|
||||
expr: "count(distinct account_id)"
|
||||
filter: "size_band = 'enterprise'"
|
||||
segments:
|
||||
- name: external_only
|
||||
expr: "coalesce(is_internal, 0) = 0 AND coalesce(is_test, 0) = 0"
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
name: arr_movements
|
||||
table: arr_movements
|
||||
description: ARR movement ledger for expansion, contraction, churn, and reactivation analysis.
|
||||
grain:
|
||||
- arr_movement_id
|
||||
columns:
|
||||
- name: arr_movement_id
|
||||
type: string
|
||||
- name: account_id
|
||||
type: string
|
||||
- name: parent_account_id
|
||||
type: string
|
||||
- name: contract_id
|
||||
type: string
|
||||
- name: movement_date
|
||||
type: time
|
||||
- name: movement_type
|
||||
type: string
|
||||
- name: movement_reason
|
||||
type: string
|
||||
- name: arr_delta_cents
|
||||
type: number
|
||||
- name: starting_arr_cents
|
||||
type: number
|
||||
- name: ending_arr_cents
|
||||
type: number
|
||||
joins:
|
||||
- to: accounts
|
||||
"on": "account_id = accounts.account_id"
|
||||
relationship: many_to_one
|
||||
measures:
|
||||
- name: movement_count
|
||||
expr: "count(*)"
|
||||
- name: net_arr_delta
|
||||
expr: "sum(arr_delta_cents) / 100.0"
|
||||
segments:
|
||||
- name: external_only
|
||||
expr: "coalesce(is_internal, 0) = 0 AND coalesce(is_test, 0) = 0"
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
name: contracts
|
||||
table: contracts
|
||||
description: Subscription contracts with ARR, plan, renewal, and status details.
|
||||
grain:
|
||||
- contract_id
|
||||
columns:
|
||||
- name: contract_id
|
||||
type: string
|
||||
- name: account_id
|
||||
type: string
|
||||
- name: parent_account_id
|
||||
type: string
|
||||
- name: plan_id
|
||||
type: string
|
||||
- name: contract_arr_cents
|
||||
type: number
|
||||
- name: booked_arr_cents
|
||||
type: number
|
||||
- name: start_date
|
||||
type: time
|
||||
- name: end_date
|
||||
type: time
|
||||
- name: status
|
||||
type: string
|
||||
- name: renewal_type
|
||||
type: string
|
||||
joins:
|
||||
- to: accounts
|
||||
"on": "account_id = accounts.account_id"
|
||||
relationship: many_to_one
|
||||
measures:
|
||||
- name: contract_count
|
||||
expr: "count(distinct contract_id)"
|
||||
- name: total_arr
|
||||
expr: "sum(contract_arr_cents) / 100.0"
|
||||
filter: "status = 'active'"
|
||||
segments:
|
||||
- name: external_only
|
||||
expr: "coalesce(is_internal, 0) = 0 AND coalesce(is_test, 0) = 0"
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
name: invoices
|
||||
table: invoices
|
||||
description: Billing invoices with payment status and revenue-recognition dates.
|
||||
grain:
|
||||
- invoice_id
|
||||
columns:
|
||||
- name: invoice_id
|
||||
type: string
|
||||
- name: account_id
|
||||
type: string
|
||||
- name: subscription_id
|
||||
type: string
|
||||
- name: invoice_date
|
||||
type: time
|
||||
- name: paid_at
|
||||
type: time
|
||||
- name: status
|
||||
type: string
|
||||
- name: currency
|
||||
type: string
|
||||
joins:
|
||||
- to: accounts
|
||||
"on": "account_id = accounts.account_id"
|
||||
relationship: many_to_one
|
||||
measures:
|
||||
- name: invoice_count
|
||||
expr: "count(*)"
|
||||
- name: paid_invoice_count
|
||||
expr: "count(*)"
|
||||
filter: "status = 'paid'"
|
||||
segments:
|
||||
- name: external_only
|
||||
expr: "coalesce(is_internal, 0) = 0 AND coalesce(is_test, 0) = 0"
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
name: purchase_requests
|
||||
table: purchase_requests
|
||||
description: Procurement workflow requests with requester, status, supplier, and spend fields.
|
||||
grain:
|
||||
- purchase_request_id
|
||||
columns:
|
||||
- name: purchase_request_id
|
||||
type: string
|
||||
- name: account_id
|
||||
type: string
|
||||
- name: requester_user_id
|
||||
type: string
|
||||
- name: created_at
|
||||
type: time
|
||||
- name: status
|
||||
type: string
|
||||
- name: amount_cents
|
||||
type: number
|
||||
- name: supplier_id
|
||||
type: string
|
||||
joins:
|
||||
- to: accounts
|
||||
"on": "account_id = accounts.account_id"
|
||||
relationship: many_to_one
|
||||
measures:
|
||||
- name: request_count
|
||||
expr: "count(*)"
|
||||
- name: approved_spend
|
||||
expr: "sum(amount_cents) / 100.0"
|
||||
filter: "status = 'approved'"
|
||||
segments:
|
||||
- name: external_only
|
||||
expr: "coalesce(is_internal, 0) = 0 AND coalesce(is_test, 0) = 0"
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
name: support_tickets
|
||||
table: support_tickets
|
||||
description: Customer support tickets with severity, category, status, and resolution tracking.
|
||||
grain:
|
||||
- support_ticket_id
|
||||
columns:
|
||||
- name: support_ticket_id
|
||||
type: string
|
||||
- name: account_id
|
||||
type: string
|
||||
- name: requester_user_id
|
||||
type: string
|
||||
- name: severity
|
||||
type: string
|
||||
- name: category
|
||||
type: string
|
||||
- name: status
|
||||
type: string
|
||||
- name: created_at
|
||||
type: time
|
||||
- name: resolved_at
|
||||
type: time
|
||||
- name: owner_user_id
|
||||
type: string
|
||||
joins:
|
||||
- to: accounts
|
||||
"on": "account_id = accounts.account_id"
|
||||
relationship: many_to_one
|
||||
measures:
|
||||
- name: ticket_count
|
||||
expr: "count(*)"
|
||||
- name: open_ticket_count
|
||||
expr: "count(*)"
|
||||
filter: "status != 'resolved'"
|
||||
segments:
|
||||
- name: external_only
|
||||
expr: "coalesce(is_internal, 0) = 0 AND coalesce(is_test, 0) = 0"
|
||||
72
packages/cli/package.json
Normal file
72
packages/cli/package.json
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
{
|
||||
"name": "@klo/cli",
|
||||
"version": "0.0.0-private",
|
||||
"description": "CLI wrapper for klo context packages",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"klo": "./dist/bin.js"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"assets"
|
||||
],
|
||||
"scripts": {
|
||||
"assets:demo": "node scripts/build-demo-assets.mjs",
|
||||
"build": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json && node ../../scripts/prepare-cli-bin.mjs",
|
||||
"smoke": "vitest run src/standalone-smoke.test.ts src/example-smoke.test.ts --testTimeout 30000",
|
||||
"test": "vitest run",
|
||||
"type-check": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "1.3.0",
|
||||
"@commander-js/extra-typings": "14.0.0",
|
||||
"@klo/connector-bigquery": "workspace:*",
|
||||
"@klo/connector-clickhouse": "workspace:*",
|
||||
"@klo/connector-mysql": "workspace:*",
|
||||
"@klo/connector-postgres": "workspace:*",
|
||||
"@klo/connector-posthog": "workspace:*",
|
||||
"@klo/connector-snowflake": "workspace:*",
|
||||
"@klo/connector-sqlite": "workspace:*",
|
||||
"@klo/connector-sqlserver": "workspace:*",
|
||||
"@klo/context": "workspace:*",
|
||||
"@klo/llm": "workspace:*",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"commander": "14.0.3",
|
||||
"ink": "^7.0.1",
|
||||
"react": "^19.2.5",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/kaelio/ktx.git",
|
||||
"directory": "packages/cli"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/kaelio/ktx/issues"
|
||||
},
|
||||
"homepage": "https://github.com/kaelio/ktx#readme"
|
||||
}
|
||||
954
packages/cli/scripts/build-demo-assets.mjs
Normal file
954
packages/cli/scripts/build-demo-assets.mjs
Normal file
|
|
@ -0,0 +1,954 @@
|
|||
import { constants as fsConstants } from 'node:fs';
|
||||
import { access, copyFile, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const repoRoot = resolve(packageRoot, '../..');
|
||||
const defaultDemoSource = resolve(repoRoot, '../../../orbit-demo-source');
|
||||
const sourceRoot = resolve(process.env.KLO_DEMO_SOURCE_DIR ?? defaultDemoSource);
|
||||
const assetDir = join(packageRoot, 'assets/demo/orbit');
|
||||
const dbPath = join(assetDir, 'demo.db');
|
||||
const exampleDbtProjectDir = ['dbt', `${'kae'}lio_demo`].join('/');
|
||||
const packagedDemoSource = 'packaged-orbit-demo';
|
||||
|
||||
const warehouseTables = [
|
||||
'accounts',
|
||||
'contracts',
|
||||
'users',
|
||||
'invoices',
|
||||
'arr_movements',
|
||||
'support_tickets',
|
||||
'purchase_requests',
|
||||
'plans',
|
||||
];
|
||||
|
||||
const copyFiles = [
|
||||
[`${exampleDbtProjectDir}/dbt_project.yml`, 'raw-sources/dbt/dbt_project.yml'],
|
||||
[`${exampleDbtProjectDir}/models/sources.yml`, 'raw-sources/dbt/sources.yml'],
|
||||
[`${exampleDbtProjectDir}/models/schema.yml`, 'raw-sources/dbt/schema.yml'],
|
||||
[`${exampleDbtProjectDir}/models/marts/mart_revenue_daily.sql`, 'raw-sources/dbt/models/marts/mart_revenue_daily.sql'],
|
||||
[`${exampleDbtProjectDir}/models/marts/mart_arr_daily.sql`, 'raw-sources/dbt/models/marts/mart_arr_daily.sql'],
|
||||
[
|
||||
`${exampleDbtProjectDir}/models/marts/mart_customer_health.sql`,
|
||||
'raw-sources/dbt/models/marts/mart_customer_health.sql',
|
||||
],
|
||||
['views/account_retention.view.lkml', 'raw-sources/bi/account_retention.view.lkml'],
|
||||
['views/arr_daily.view.lkml', 'raw-sources/bi/arr_daily.view.lkml'],
|
||||
['views/customer_health.view.lkml', 'raw-sources/bi/customer_health.view.lkml'],
|
||||
['views/procurement_activity.view.lkml', 'raw-sources/bi/procurement_activity.view.lkml'],
|
||||
['views/revenue_daily.view.lkml', 'raw-sources/bi/revenue_daily.view.lkml'],
|
||||
['dashboards/revenue_exec.dashboard.lookml', 'raw-sources/bi/revenue_exec.dashboard.lookml'],
|
||||
['dashboards/retention_exec_q1.dashboard.lookml', 'raw-sources/bi/retention_exec_q1.dashboard.lookml'],
|
||||
['notion/export/pages/revenue-reporting-policy.md', 'raw-sources/notion/revenue-reporting-policy.md'],
|
||||
['notion/export/pages/sales-ops-segmentation-guide.md', 'raw-sources/notion/sales-ops-segmentation-guide.md'],
|
||||
['notion/export/pages/customer-health-playbook.md', 'raw-sources/notion/customer-health-playbook.md'],
|
||||
['notion/export/pages/support-escalation-runbook.md', 'raw-sources/notion/support-escalation-runbook.md'],
|
||||
[
|
||||
'notion/export/pages/arr-and-contract-reporting-notes.md',
|
||||
'raw-sources/notion/arr-and-contract-reporting-notes.md',
|
||||
],
|
||||
[
|
||||
'notion/export/pages/activation-policy-decision-record.md',
|
||||
'raw-sources/notion/activation-policy-decision-record.md',
|
||||
],
|
||||
[
|
||||
'notion/export/pages/retention-and-nrr-definition-notes.md',
|
||||
'raw-sources/notion/retention-and-nrr-definition-notes.md',
|
||||
],
|
||||
['notion/export/pages/analyst-onboarding.md', 'raw-sources/notion/analyst-onboarding.md'],
|
||||
];
|
||||
|
||||
const semanticLayerTables = [
|
||||
'accounts',
|
||||
'contracts',
|
||||
'invoices',
|
||||
'arr_movements',
|
||||
'purchase_requests',
|
||||
'support_tickets',
|
||||
];
|
||||
|
||||
const semanticLayerDescriptions = {
|
||||
accounts: 'Customer accounts with industry, region, lifecycle, and internal/test flags.',
|
||||
contracts: 'Subscription contracts with ARR, plan, renewal, and status details.',
|
||||
invoices: 'Billing invoices with payment status and revenue-recognition dates.',
|
||||
arr_movements: 'ARR movement ledger for expansion, contraction, churn, and reactivation analysis.',
|
||||
purchase_requests: 'Procurement workflow requests with requester, status, supplier, and spend fields.',
|
||||
support_tickets: 'Customer support tickets with severity, category, status, and resolution tracking.',
|
||||
};
|
||||
|
||||
const semanticLayerMeasures = {
|
||||
accounts: [
|
||||
{ name: 'account_count', expr: 'count(distinct account_id)' },
|
||||
{ name: 'enterprise_count', expr: 'count(distinct account_id)', filter: "size_band = 'enterprise'" },
|
||||
],
|
||||
contracts: [
|
||||
{ name: 'contract_count', expr: 'count(distinct contract_id)' },
|
||||
{ name: 'total_arr', expr: 'sum(contract_arr_cents) / 100.0', filter: "status = 'active'" },
|
||||
],
|
||||
invoices: [
|
||||
{ name: 'invoice_count', expr: 'count(*)' },
|
||||
{ name: 'paid_invoice_count', expr: 'count(*)', filter: "status = 'paid'" },
|
||||
],
|
||||
arr_movements: [
|
||||
{ name: 'movement_count', expr: 'count(*)' },
|
||||
{ name: 'net_arr_delta', expr: 'sum(arr_delta_cents) / 100.0' },
|
||||
],
|
||||
purchase_requests: [
|
||||
{ name: 'request_count', expr: 'count(*)' },
|
||||
{ name: 'approved_spend', expr: 'sum(amount_cents) / 100.0', filter: "status = 'approved'" },
|
||||
],
|
||||
support_tickets: [
|
||||
{ name: 'ticket_count', expr: 'count(*)' },
|
||||
{ name: 'open_ticket_count', expr: 'count(*)', filter: "status != 'resolved'" },
|
||||
],
|
||||
};
|
||||
|
||||
const knowledgePages = [
|
||||
{
|
||||
file: 'arr-contract-first.md',
|
||||
summary: 'ARR uses contract-first precedence before subscription-derived revenue.',
|
||||
tags: ['finance', 'arr', 'revenue'],
|
||||
refs: [],
|
||||
slRefs: ['orbit_demo.contracts', 'orbit_demo.arr_movements'],
|
||||
body: [
|
||||
'ARR is calculated from active recurring contract ARR before falling back to subscription-derived revenue.',
|
||||
'Do not double-count subscription MRR when an active contract row covers the same account and period.',
|
||||
'Exclude cancelled contracts ending before the metric date, future-starting contracts, internal accounts, and test accounts.',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'revenue-gross-to-net.md',
|
||||
summary: 'Gross-to-net revenue reconciles paid invoices, credits, and refunds.',
|
||||
tags: ['finance', 'revenue'],
|
||||
refs: ['arr-contract-first'],
|
||||
slRefs: ['orbit_demo.invoices'],
|
||||
body: [
|
||||
'Gross revenue starts from paid invoice activity. Net revenue subtracts credits and successful refunds in the month they are recorded.',
|
||||
'Exclude unpaid, void, draft, failed, internal, and test-account invoice activity from canonical revenue reporting.',
|
||||
'February 2026 has an elevated refund event captured in the source notes and revenue dashboard.',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'discount-expiration.md',
|
||||
summary: 'Discount expirations are tracked separately from organic contraction.',
|
||||
tags: ['finance', 'retention'],
|
||||
refs: ['arr-contract-first', 'nrr-retention'],
|
||||
slRefs: ['orbit_demo.contracts', 'orbit_demo.arr_movements'],
|
||||
body: [
|
||||
'Discount expiration events identify pricing changes when negotiated discounts end.',
|
||||
'Track these separately from organic contraction so board reporting can split pricing-driven and usage-driven changes.',
|
||||
'Use movement_reason on arr_movements when separating discount expiration from churn or seat-reduction events.',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'nrr-retention.md',
|
||||
summary: 'NRR is calculated at parent-account grain by calendar quarter.',
|
||||
tags: ['analytics', 'retention', 'nrr'],
|
||||
refs: ['arr-contract-first'],
|
||||
slRefs: ['orbit_demo.arr_movements', 'orbit_demo.accounts'],
|
||||
body: [
|
||||
'Net Revenue Retention uses parent-account rollups by calendar quarter.',
|
||||
'The formula is starting ARR plus expansion minus contraction and churn, divided by starting ARR.',
|
||||
'Exclude parent accounts with zero starting ARR, new business, reactivations, and internal/test accounts from the denominator.',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'segment-classification.md',
|
||||
summary: 'Account segments derive from plan normalization and effective-dated mapping.',
|
||||
tags: ['sales-ops', 'segmentation'],
|
||||
refs: [],
|
||||
slRefs: ['orbit_demo.accounts', 'orbit_demo.contracts'],
|
||||
body: [
|
||||
'Account segment labels combine plan_code, canonical_plan_code, and size_band fields.',
|
||||
'Historical plan code pro_plus maps to growth for current segment analysis.',
|
||||
'Use the mapping active at the metric date when segment definitions change over time.',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'activation-policy.md',
|
||||
summary: 'Account activation policy changed on January 15, 2026.',
|
||||
tags: ['growth', 'activation', 'policy'],
|
||||
refs: [],
|
||||
slRefs: ['orbit_demo.accounts', 'orbit_demo.purchase_requests'],
|
||||
body: [
|
||||
'Before January 15, 2026, activation meant first requester login.',
|
||||
'On and after January 15, 2026, activation requires an approved purchase request and at least three activated requesters.',
|
||||
'Always separate pre-policy and post-policy cohorts when comparing activation rates.',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'procurement-workflows.md',
|
||||
summary: 'Procurement workflow activity measures active requesters and qualifying actions.',
|
||||
tags: ['product', 'procurement'],
|
||||
refs: ['activation-policy'],
|
||||
slRefs: ['orbit_demo.purchase_requests'],
|
||||
body: [
|
||||
'Weekly active requesters counts distinct non-internal requesters with a qualifying procurement action in the calendar week.',
|
||||
'Qualifying actions include purchase request creation, approval decisions, supplier invites, and purchase-order creation.',
|
||||
'Purchase-request comments and short sessions are excluded from the canonical requester activity metric.',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'customer-health-scoring.md',
|
||||
summary: 'Customer health combines support severity and procurement activity.',
|
||||
tags: ['customer-success', 'health', 'churn-risk'],
|
||||
refs: ['nrr-retention'],
|
||||
slRefs: ['orbit_demo.support_tickets', 'orbit_demo.purchase_requests', 'orbit_demo.accounts'],
|
||||
body: [
|
||||
'High-risk accounts have multiple recent high-severity tickets or no recent procurement activity on growth and enterprise plans.',
|
||||
'Medium risk captures partial support pressure or a material month-over-month decline in procurement activity.',
|
||||
'Internal and test accounts are excluded from customer health scoring.',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'support-escalation.md',
|
||||
summary: 'Support escalation tiers map ticket severity to SLA targets.',
|
||||
tags: ['support', 'sla'],
|
||||
refs: ['customer-health-scoring'],
|
||||
slRefs: ['orbit_demo.support_tickets'],
|
||||
body: [
|
||||
'Critical support tickets require immediate response and on-call escalation.',
|
||||
'High severity tickets should receive first response within four business hours.',
|
||||
'Resolution time is measured from created_at to resolved_at and only applies to resolved tickets.',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'internal-test-exclusion.md',
|
||||
summary: 'Canonical metrics exclude internal and test accounts and users.',
|
||||
tags: ['data-quality', 'governance'],
|
||||
refs: [],
|
||||
slRefs: ['orbit_demo.accounts'],
|
||||
body: [
|
||||
'All canonical customer metrics exclude rows marked as internal or test fixtures.',
|
||||
'This exclusion applies at both account and user grain when joining procurement, support, and revenue activity.',
|
||||
'If a metric unexpectedly increases, check whether new internal or test accounts were created without proper flags.',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const provenanceLinks = [
|
||||
['wiki', 'knowledge/global/arr-contract-first.md', 'warehouse', 'contracts', 'describes', 1],
|
||||
[
|
||||
'wiki',
|
||||
'knowledge/global/arr-contract-first.md',
|
||||
'notion',
|
||||
'raw-sources/notion/arr-and-contract-reporting-notes.md',
|
||||
'derived_from',
|
||||
0.95,
|
||||
],
|
||||
['wiki', 'knowledge/global/revenue-gross-to-net.md', 'warehouse', 'invoices', 'describes', 1],
|
||||
[
|
||||
'wiki',
|
||||
'knowledge/global/revenue-gross-to-net.md',
|
||||
'notion',
|
||||
'raw-sources/notion/revenue-reporting-policy.md',
|
||||
'derived_from',
|
||||
0.95,
|
||||
],
|
||||
['wiki', 'knowledge/global/discount-expiration.md', 'warehouse', 'arr_movements', 'describes', 1],
|
||||
['wiki', 'knowledge/global/nrr-retention.md', 'warehouse', 'arr_movements', 'describes', 1],
|
||||
[
|
||||
'wiki',
|
||||
'knowledge/global/nrr-retention.md',
|
||||
'notion',
|
||||
'raw-sources/notion/retention-and-nrr-definition-notes.md',
|
||||
'derived_from',
|
||||
0.95,
|
||||
],
|
||||
['wiki', 'knowledge/global/nrr-retention.md', 'bi', 'raw-sources/bi/account_retention.view.lkml', 'derived_from', 0.85],
|
||||
['wiki', 'knowledge/global/segment-classification.md', 'warehouse', 'plans', 'describes', 1],
|
||||
[
|
||||
'wiki',
|
||||
'knowledge/global/segment-classification.md',
|
||||
'notion',
|
||||
'raw-sources/notion/sales-ops-segmentation-guide.md',
|
||||
'derived_from',
|
||||
0.9,
|
||||
],
|
||||
[
|
||||
'wiki',
|
||||
'knowledge/global/activation-policy.md',
|
||||
'notion',
|
||||
'raw-sources/notion/activation-policy-decision-record.md',
|
||||
'derived_from',
|
||||
0.95,
|
||||
],
|
||||
['wiki', 'knowledge/global/procurement-workflows.md', 'warehouse', 'purchase_requests', 'describes', 1],
|
||||
[
|
||||
'wiki',
|
||||
'knowledge/global/customer-health-scoring.md',
|
||||
'notion',
|
||||
'raw-sources/notion/customer-health-playbook.md',
|
||||
'derived_from',
|
||||
0.9,
|
||||
],
|
||||
['wiki', 'knowledge/global/customer-health-scoring.md', 'warehouse', 'support_tickets', 'describes', 1],
|
||||
[
|
||||
'wiki',
|
||||
'knowledge/global/support-escalation.md',
|
||||
'notion',
|
||||
'raw-sources/notion/support-escalation-runbook.md',
|
||||
'derived_from',
|
||||
0.9,
|
||||
],
|
||||
[
|
||||
'wiki',
|
||||
'knowledge/global/internal-test-exclusion.md',
|
||||
'notion',
|
||||
'raw-sources/notion/analyst-onboarding.md',
|
||||
'derived_from',
|
||||
0.9,
|
||||
],
|
||||
['sl', 'orbit_demo.accounts', 'warehouse', 'accounts', 'models', 1],
|
||||
['sl', 'orbit_demo.accounts', 'dbt', 'raw-sources/dbt/schema.yml', 'inherits_from', 0.95],
|
||||
['sl', 'orbit_demo.contracts', 'warehouse', 'contracts', 'models', 1],
|
||||
['sl', 'orbit_demo.invoices', 'warehouse', 'invoices', 'models', 1],
|
||||
['sl', 'orbit_demo.arr_movements', 'warehouse', 'arr_movements', 'models', 1],
|
||||
['sl', 'orbit_demo.purchase_requests', 'warehouse', 'purchase_requests', 'models', 1],
|
||||
['sl', 'orbit_demo.support_tickets', 'warehouse', 'support_tickets', 'models', 1],
|
||||
].map(([artifactKind, artifactKey, sourceKind, sourcePath, relationship, confidence], index) => ({
|
||||
id: `link-${String(index + 1).padStart(3, '0')}`,
|
||||
artifactKind,
|
||||
artifactKey,
|
||||
sourceKind,
|
||||
sourcePath,
|
||||
relationship,
|
||||
confidence,
|
||||
}));
|
||||
|
||||
async function pathExists(path) {
|
||||
try {
|
||||
await access(path, fsConstants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertReadable(path, label) {
|
||||
if (!(await pathExists(path))) {
|
||||
throw new Error(
|
||||
`${label} not found at ${path}. Set KLO_DEMO_SOURCE_DIR to the Orbit demo source directory.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseCsvLine(line) {
|
||||
const values = [];
|
||||
let current = '';
|
||||
let quoted = false;
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index];
|
||||
const next = line[index + 1];
|
||||
if (char === '"' && quoted && next === '"') {
|
||||
current += '"';
|
||||
index += 1;
|
||||
} else if (char === '"') {
|
||||
quoted = !quoted;
|
||||
} else if (char === ',' && !quoted) {
|
||||
values.push(current);
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
values.push(current);
|
||||
return values;
|
||||
}
|
||||
|
||||
function parseCsv(raw) {
|
||||
const lines = raw.trimEnd().split(/\r?\n/);
|
||||
const headers = parseCsvLine(lines[0]);
|
||||
const rows = lines.slice(1).map((line) => parseCsvLine(line));
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
function quoteIdentifier(value) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function inferColumnType(column) {
|
||||
if (column.startsWith('is_')) {
|
||||
return 'boolean';
|
||||
}
|
||||
if (column.endsWith('_at') || column.endsWith('_date') || column === 'retired_at') {
|
||||
return 'time';
|
||||
}
|
||||
if (column.endsWith('_cents') || column.endsWith('_count')) {
|
||||
return 'number';
|
||||
}
|
||||
return 'string';
|
||||
}
|
||||
|
||||
function renderKnowledgePage(page) {
|
||||
const refs = page.refs.length > 0 ? ['refs:', ...page.refs.map((ref) => ` - ${ref}`)] : ['refs: []'];
|
||||
const slRefs = page.slRefs.map((ref) => ` - ${ref}`).join('\n');
|
||||
return [
|
||||
'---',
|
||||
`summary: ${page.summary}`,
|
||||
'tags:',
|
||||
...page.tags.map((tag) => ` - ${tag}`),
|
||||
...refs,
|
||||
'sl_refs:',
|
||||
slRefs,
|
||||
'usage_mode: auto',
|
||||
'---',
|
||||
'',
|
||||
page.body.join('\n\n'),
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderMeasure(measure) {
|
||||
const lines = [` - name: ${measure.name}`, ` expr: ${JSON.stringify(measure.expr)}`];
|
||||
if (measure.filter) {
|
||||
lines.push(` filter: ${JSON.stringify(measure.filter)}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function renderSemanticLayerSource(table) {
|
||||
const raw = await readFile(join(sourceRoot, 'database/seeds', `${table}.csv`), 'utf-8');
|
||||
const { headers } = parseCsv(raw);
|
||||
const primaryKey = headers[0];
|
||||
const joins =
|
||||
table === 'accounts'
|
||||
? [
|
||||
' - to: contracts',
|
||||
' "on": "account_id = contracts.account_id"',
|
||||
' relationship: one_to_many',
|
||||
' - to: purchase_requests',
|
||||
' "on": "account_id = purchase_requests.account_id"',
|
||||
' relationship: one_to_many',
|
||||
]
|
||||
: [' - to: accounts', ' "on": "account_id = accounts.account_id"', ' relationship: many_to_one'];
|
||||
|
||||
return [
|
||||
`name: ${table}`,
|
||||
`table: ${table}`,
|
||||
`description: ${semanticLayerDescriptions[table]}`,
|
||||
'grain:',
|
||||
` - ${primaryKey}`,
|
||||
'columns:',
|
||||
...headers.flatMap((header) => [` - name: ${header}`, ` type: ${inferColumnType(header)}`]),
|
||||
'joins:',
|
||||
...joins,
|
||||
'measures:',
|
||||
...semanticLayerMeasures[table].map(renderMeasure),
|
||||
'segments:',
|
||||
' - name: external_only',
|
||||
' expr: "coalesce(is_internal, 0) = 0 AND coalesce(is_test, 0) = 0"',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function writeWarehouse(db, rowCounts) {
|
||||
for (const table of warehouseTables) {
|
||||
const sourceCsv = join(sourceRoot, 'database/seeds', `${table}.csv`);
|
||||
const raw = await readFile(sourceCsv, 'utf-8');
|
||||
const { headers, rows } = parseCsv(raw);
|
||||
const columnsSql = headers.map((header) => `${quoteIdentifier(header)} TEXT`).join(', ');
|
||||
db.exec(`CREATE TABLE ${quoteIdentifier(table)} (${columnsSql});`);
|
||||
const placeholders = headers.map(() => '?').join(', ');
|
||||
const statement = db.prepare(`INSERT INTO ${quoteIdentifier(table)} VALUES (${placeholders})`);
|
||||
const insertAll = db.transaction((records) => {
|
||||
for (const record of records) {
|
||||
statement.run(record);
|
||||
}
|
||||
});
|
||||
insertAll(rows);
|
||||
rowCounts[table] = rows.length;
|
||||
await copyFile(sourceCsv, join(assetDir, 'raw-sources/warehouse', `${table}.csv`));
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCuratedSourceFiles() {
|
||||
for (const [from, to] of copyFiles) {
|
||||
const destination = join(assetDir, to);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await copyFile(join(sourceRoot, from), destination);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJson(relativePath, value) {
|
||||
const destination = join(assetDir, relativePath);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await writeFile(destination, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
|
||||
}
|
||||
|
||||
async function writeText(relativePath, value) {
|
||||
const destination = join(assetDir, relativePath);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await writeFile(destination, value, 'utf-8');
|
||||
}
|
||||
|
||||
function buildActions() {
|
||||
return [
|
||||
{
|
||||
unitKey: 'revenue-and-contracts',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/arr-contract-first.md',
|
||||
summary: 'ARR follows contract precedence with cancellation and discount caveats.',
|
||||
rawFiles: ['contracts', 'arr_movements', 'raw-sources/notion/arr-and-contract-reporting-notes.md'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'revenue-and-contracts',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/revenue-gross-to-net.md',
|
||||
summary: 'Invoice, refund, and revenue dashboard evidence reconcile gross to net revenue.',
|
||||
rawFiles: ['invoices', 'raw-sources/bi/revenue_exec.dashboard.lookml'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'revenue-and-contracts',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/discount-expiration.md',
|
||||
summary: 'Discount expiration is separated from organic contraction for retention reporting.',
|
||||
rawFiles: ['contracts', 'arr_movements'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'revenue-and-contracts',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'orbit_demo.contracts',
|
||||
summary: 'Contract grain with active ARR measures and account joins.',
|
||||
rawFiles: ['contracts', 'raw-sources/dbt/schema.yml'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'revenue-and-contracts',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'orbit_demo.invoices',
|
||||
summary: 'Invoice status measures tied to gross and net revenue reporting.',
|
||||
rawFiles: ['invoices', 'raw-sources/bi/revenue_daily.view.lkml'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'revenue-and-contracts',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'orbit_demo.arr_movements',
|
||||
summary: 'ARR movement ledger for expansion, contraction, churn, and NRR.',
|
||||
rawFiles: ['arr_movements', 'raw-sources/bi/account_retention.view.lkml'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'retention-and-segments',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/nrr-retention.md',
|
||||
summary: 'NRR uses parent-account rollups and quarterly ARR movement windows.',
|
||||
rawFiles: ['accounts', 'arr_movements', 'raw-sources/notion/retention-and-nrr-definition-notes.md'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'retention-and-segments',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/segment-classification.md',
|
||||
summary: 'Segment labels come from plan mapping and sales-ops policy notes.',
|
||||
rawFiles: ['accounts', 'plans', 'raw-sources/notion/sales-ops-segmentation-guide.md'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'retention-and-segments',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'orbit_demo.accounts',
|
||||
summary: 'Account dimensions with lifecycle, segment, and internal-test exclusions.',
|
||||
rawFiles: ['accounts', 'plans'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'procurement-and-activation',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/activation-policy.md',
|
||||
summary: 'Activation policy changed on January 15, 2026 and is encoded for agents.',
|
||||
rawFiles: ['purchase_requests', 'users', 'raw-sources/notion/activation-policy-decision-record.md'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'procurement-and-activation',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/procurement-workflows.md',
|
||||
summary: 'Procurement requester activity and approval events explain product usage.',
|
||||
rawFiles: ['purchase_requests', 'raw-sources/bi/procurement_activity.view.lkml'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'procurement-and-activation',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'orbit_demo.purchase_requests',
|
||||
summary: 'Procurement request facts with requester and approval-state measures.',
|
||||
rawFiles: ['purchase_requests'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'support-and-health',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/customer-health-scoring.md',
|
||||
summary: 'Customer health combines support severity, ARR exposure, and product usage.',
|
||||
rawFiles: ['support_tickets', 'raw-sources/notion/customer-health-playbook.md'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'support-and-health',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/support-escalation.md',
|
||||
summary: 'Escalation tiers map ticket severity to SLA expectations.',
|
||||
rawFiles: ['support_tickets', 'raw-sources/notion/support-escalation-runbook.md'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'support-and-health',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'orbit_demo.support_tickets',
|
||||
summary: 'Support ticket facts with severity, status, and resolution-hour measures.',
|
||||
rawFiles: ['support_tickets'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'governance-and-exclusions',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/internal-test-exclusion.md',
|
||||
summary: 'Canonical metrics exclude internal and test accounts across source families.',
|
||||
rawFiles: ['raw-sources/notion/analyst-onboarding.md'],
|
||||
status: 'success',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildReplay(provenance, transcripts) {
|
||||
return {
|
||||
memoryFlowReplaySchemaVersion: 1,
|
||||
replay: {
|
||||
runId: 'demo-seeded-orbit',
|
||||
connectionId: 'orbit_demo',
|
||||
adapter: 'live-database',
|
||||
status: 'done',
|
||||
sourceDir: null,
|
||||
syncId: 'demo-seeded-sync',
|
||||
reportId: 'demo-seeded-report',
|
||||
reportPath: 'reports/seeded-demo-report.json',
|
||||
errors: [],
|
||||
metadata: {
|
||||
schemaVersion: 1,
|
||||
mode: 'seeded',
|
||||
origin: 'packaged',
|
||||
timing: 'prebuilt',
|
||||
capturedAt: '2026-05-06T00:00:00.000Z',
|
||||
sourceReportId: 'demo-seeded-report',
|
||||
sourceReportPath: 'reports/seeded-demo-report.json',
|
||||
fallbackReason: null,
|
||||
},
|
||||
events: [
|
||||
{ type: 'source_acquired', adapter: 'live-database', trigger: 'demo_seeded', fileCount: 8 },
|
||||
{ type: 'source_acquired', adapter: 'dbt_descriptions', trigger: 'demo_seeded', fileCount: 6 },
|
||||
{ type: 'source_acquired', adapter: 'looker', trigger: 'demo_seeded', fileCount: 7 },
|
||||
{ type: 'source_acquired', adapter: 'notion', trigger: 'demo_seeded', fileCount: 8 },
|
||||
{ type: 'scope_detected', fingerprint: 'sqlite:orbit-demo' },
|
||||
{ type: 'raw_snapshot_written', syncId: 'demo-seeded-sync', rawFileCount: 29 },
|
||||
{ type: 'diff_computed', added: 29, modified: 0, deleted: 0, unchanged: 0 },
|
||||
{ type: 'chunks_planned', chunkCount: 5, workUnitCount: 5, evictionCount: 0 },
|
||||
{ type: 'work_unit_started', unitKey: 'revenue-and-contracts', skills: ['knowledge_capture', 'sl_capture'], stepBudget: 40 },
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'revenue-and-contracts',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/arr-contract-first.md',
|
||||
},
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'revenue-and-contracts',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/revenue-gross-to-net.md',
|
||||
},
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'revenue-and-contracts',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/discount-expiration.md',
|
||||
},
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'revenue-and-contracts',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'orbit_demo.contracts',
|
||||
},
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'revenue-and-contracts',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'orbit_demo.invoices',
|
||||
},
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'revenue-and-contracts',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'orbit_demo.arr_movements',
|
||||
},
|
||||
{ type: 'work_unit_finished', unitKey: 'revenue-and-contracts', status: 'success' },
|
||||
{ type: 'work_unit_started', unitKey: 'retention-and-segments', skills: ['knowledge_capture', 'sl_capture'], stepBudget: 40 },
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'retention-and-segments',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/nrr-retention.md',
|
||||
},
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'retention-and-segments',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/segment-classification.md',
|
||||
},
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'retention-and-segments',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'orbit_demo.accounts',
|
||||
},
|
||||
{ type: 'work_unit_finished', unitKey: 'retention-and-segments', status: 'success' },
|
||||
{
|
||||
type: 'work_unit_started',
|
||||
unitKey: 'procurement-and-activation',
|
||||
skills: ['knowledge_capture', 'sl_capture'],
|
||||
stepBudget: 40,
|
||||
},
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'procurement-and-activation',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/activation-policy.md',
|
||||
},
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'procurement-and-activation',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/procurement-workflows.md',
|
||||
},
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'procurement-and-activation',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'orbit_demo.purchase_requests',
|
||||
},
|
||||
{ type: 'work_unit_finished', unitKey: 'procurement-and-activation', status: 'success' },
|
||||
{ type: 'work_unit_started', unitKey: 'support-and-health', skills: ['knowledge_capture', 'sl_capture'], stepBudget: 40 },
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'support-and-health',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/customer-health-scoring.md',
|
||||
},
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'support-and-health',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/support-escalation.md',
|
||||
},
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'support-and-health',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'orbit_demo.support_tickets',
|
||||
},
|
||||
{ type: 'work_unit_finished', unitKey: 'support-and-health', status: 'success' },
|
||||
{ type: 'work_unit_started', unitKey: 'governance-and-exclusions', skills: ['knowledge_capture'], stepBudget: 40 },
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'governance-and-exclusions',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/internal-test-exclusion.md',
|
||||
},
|
||||
{ type: 'work_unit_finished', unitKey: 'governance-and-exclusions', status: 'success' },
|
||||
{ type: 'reconciliation_finished', conflictCount: 0, fallbackCount: 0 },
|
||||
{ type: 'saved', commitSha: 'demo-seeded', wikiCount: 10, slCount: 6 },
|
||||
{ type: 'provenance_recorded', rowCount: provenance.length },
|
||||
{ type: 'report_created', runId: 'demo-seeded-orbit', reportPath: 'reports/seeded-demo-report.json' },
|
||||
],
|
||||
plannedWorkUnits: [
|
||||
{
|
||||
unitKey: 'revenue-and-contracts',
|
||||
rawFiles: ['contracts', 'invoices', 'arr_movements'],
|
||||
peerFileCount: 3,
|
||||
dependencyCount: 3,
|
||||
},
|
||||
{
|
||||
unitKey: 'retention-and-segments',
|
||||
rawFiles: ['accounts', 'plans'],
|
||||
peerFileCount: 2,
|
||||
dependencyCount: 2,
|
||||
},
|
||||
{
|
||||
unitKey: 'procurement-and-activation',
|
||||
rawFiles: ['purchase_requests', 'users'],
|
||||
peerFileCount: 2,
|
||||
dependencyCount: 2,
|
||||
},
|
||||
{ unitKey: 'support-and-health', rawFiles: ['support_tickets'], peerFileCount: 1, dependencyCount: 1 },
|
||||
{
|
||||
unitKey: 'governance-and-exclusions',
|
||||
rawFiles: ['notion/export/pages/analyst-onboarding.md'],
|
||||
peerFileCount: 1,
|
||||
dependencyCount: 0,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
actions: buildActions(),
|
||||
provenance,
|
||||
transcripts,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function writeGeneratedContext(rowCounts) {
|
||||
for (const page of knowledgePages) {
|
||||
await writeText(join('knowledge/global', page.file), renderKnowledgePage(page));
|
||||
}
|
||||
|
||||
for (const table of semanticLayerTables) {
|
||||
await writeText(join('semantic-layer/orbit_demo', `${table}.yaml`), await renderSemanticLayerSource(table));
|
||||
}
|
||||
|
||||
const provenance = provenanceLinks.map((link) => ({
|
||||
rawPath: link.sourcePath,
|
||||
artifactKind: link.artifactKind,
|
||||
artifactKey: link.artifactKey,
|
||||
actionType: link.artifactKind === 'sl' ? 'sl_written' : 'wiki_written',
|
||||
}));
|
||||
const transcripts = [
|
||||
'revenue-and-contracts',
|
||||
'retention-and-segments',
|
||||
'procurement-and-activation',
|
||||
'support-and-health',
|
||||
'governance-and-exclusions',
|
||||
].map((unitKey) => ({
|
||||
unitKey,
|
||||
path: `transcripts/${unitKey}.jsonl`,
|
||||
toolCallCount: unitKey === 'governance-and-exclusions' ? 2 : 5,
|
||||
errorCount: 0,
|
||||
toolNames: unitKey === 'governance-and-exclusions' ? ['wiki_write'] : ['wiki_write', 'sl_write_source'],
|
||||
}));
|
||||
|
||||
await writeJson('links/provenance.json', provenanceLinks);
|
||||
await writeJson('reports/seeded-demo-report.json', {
|
||||
id: 'demo-seeded-report',
|
||||
runId: 'demo-seeded-orbit',
|
||||
connectionId: 'orbit_demo',
|
||||
mode: 'seeded',
|
||||
status: 'complete',
|
||||
createdAt: '2026-05-06T00:00:00.000Z',
|
||||
summary: {
|
||||
sources: {
|
||||
warehouse: { tables: 8, rows: Object.values(rowCounts).reduce((sum, count) => sum + count, 0) },
|
||||
dbt: { models: 3, sources: 8 },
|
||||
bi: { explores: 5, dashboards: 2, views: 5 },
|
||||
notion: { pages: 8 },
|
||||
},
|
||||
generated: {
|
||||
semanticLayerSources: 6,
|
||||
knowledgePages: 10,
|
||||
provenanceLinks: provenanceLinks.length,
|
||||
},
|
||||
metadata: {
|
||||
mode: 'seeded',
|
||||
origin: 'packaged',
|
||||
llmCalls: 0,
|
||||
timing: 'prebuilt',
|
||||
source: packagedDemoSource,
|
||||
},
|
||||
},
|
||||
});
|
||||
await writeJson('manifest.json', {
|
||||
demoAssetSchemaVersion: 2,
|
||||
name: 'orbit',
|
||||
displayName: 'Orbit Demo',
|
||||
mode: 'seeded',
|
||||
sqliteDatabase: 'demo.db',
|
||||
replay: 'replay.memory-flow.v1.json',
|
||||
report: 'reports/seeded-demo-report.json',
|
||||
source: packagedDemoSource,
|
||||
sources: {
|
||||
warehouse: { label: 'Warehouse', path: 'demo.db', tables: 8, rowCounts },
|
||||
dbt: { label: 'dbt', path: 'raw-sources/dbt', models: 3, sourceTables: 8 },
|
||||
bi: { label: 'BI', path: 'raw-sources/bi', explores: 5, dashboards: 2 },
|
||||
notion: { label: 'Notion', path: 'raw-sources/notion', pages: 8 },
|
||||
},
|
||||
generated: {
|
||||
semanticLayer: { path: 'semantic-layer/orbit_demo', sourceCount: 6 },
|
||||
knowledge: { path: 'knowledge/global', pageCount: 10 },
|
||||
links: { path: 'links', linkCount: provenanceLinks.length },
|
||||
},
|
||||
});
|
||||
await writeJson('replay.memory-flow.v1.json', buildReplay(provenance, transcripts));
|
||||
}
|
||||
|
||||
await assertReadable(join(sourceRoot, 'database/seeds/accounts.csv'), `${packagedDemoSource} seed data`);
|
||||
await assertReadable(join(sourceRoot, `${exampleDbtProjectDir}/models/schema.yml`), `${packagedDemoSource} dbt schema`);
|
||||
await assertReadable(join(sourceRoot, 'views/revenue_daily.view.lkml'), `${packagedDemoSource} LookML views`);
|
||||
await assertReadable(
|
||||
join(sourceRoot, 'notion/export/pages/revenue-reporting-policy.md'),
|
||||
`${packagedDemoSource} Notion export`,
|
||||
);
|
||||
|
||||
await rm(assetDir, { recursive: true, force: true });
|
||||
for (const relativeDir of [
|
||||
'raw-sources/warehouse',
|
||||
'raw-sources/dbt/models/marts',
|
||||
'raw-sources/bi',
|
||||
'raw-sources/notion',
|
||||
'semantic-layer/orbit_demo',
|
||||
'knowledge/global',
|
||||
'links',
|
||||
'reports',
|
||||
]) {
|
||||
await mkdir(join(assetDir, relativeDir), { recursive: true });
|
||||
}
|
||||
|
||||
const rowCounts = {};
|
||||
await rm(dbPath, { force: true });
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
await writeWarehouse(db, rowCounts);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
await copyCuratedSourceFiles();
|
||||
await writeGeneratedContext(rowCounts);
|
||||
|
||||
const dbStat = await stat(dbPath);
|
||||
if (dbStat.size >= 10 * 1024 * 1024) {
|
||||
throw new Error(`Seeded demo SQLite bundle is too large: ${dbStat.size} bytes`);
|
||||
}
|
||||
108
packages/cli/src/agent-runtime.test.ts
Normal file
108
packages/cli/src/agent-runtime.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
KLO_AGENT_MAX_ROWS_CAP,
|
||||
createKloAgentRuntime,
|
||||
parseAgentMaxRows,
|
||||
readAgentJsonFile,
|
||||
writeAgentJson,
|
||||
writeAgentJsonError,
|
||||
} from './agent-runtime.js';
|
||||
|
||||
function makeIo() {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
return {
|
||||
io: {
|
||||
stdout: { write: (chunk: string) => (stdout += chunk) },
|
||||
stderr: { write: (chunk: string) => (stderr += chunk) },
|
||||
},
|
||||
stdout: () => stdout,
|
||||
stderr: () => stderr,
|
||||
};
|
||||
}
|
||||
|
||||
describe('agent runtime helpers', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'klo-agent-runtime-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes JSON success and error envelopes without color or spinners', () => {
|
||||
const successIo = makeIo();
|
||||
const errorIo = makeIo();
|
||||
|
||||
writeAgentJson(successIo.io, { ok: true });
|
||||
writeAgentJsonError(errorIo.io, 'missing source', { code: 'NOT_FOUND' });
|
||||
|
||||
expect(JSON.parse(successIo.stdout())).toEqual({ ok: true });
|
||||
expect(successIo.stderr()).toBe('');
|
||||
expect(JSON.parse(errorIo.stderr())).toEqual({
|
||||
ok: false,
|
||||
error: { message: 'missing source', code: 'NOT_FOUND' },
|
||||
});
|
||||
expect(errorIo.stdout()).toBe('');
|
||||
});
|
||||
|
||||
it('reads JSON query files as objects', async () => {
|
||||
const path = join(tempDir, 'query.json');
|
||||
await writeFile(path, '{"measures":["revenue"],"limit":50}', 'utf-8');
|
||||
|
||||
await expect(readAgentJsonFile(path)).resolves.toEqual({ measures: ['revenue'], limit: 50 });
|
||||
});
|
||||
|
||||
it('rejects non-object JSON query files', async () => {
|
||||
const path = join(tempDir, 'query.json');
|
||||
await writeFile(path, '["revenue"]', 'utf-8');
|
||||
|
||||
await expect(readAgentJsonFile(path)).rejects.toThrow('must contain a JSON object');
|
||||
});
|
||||
|
||||
it('requires positive row limits and enforces the agent cap', () => {
|
||||
expect(parseAgentMaxRows(100)).toBe(100);
|
||||
expect(() => parseAgentMaxRows(undefined)).toThrow('maxRows is required');
|
||||
expect(() => parseAgentMaxRows(0)).toThrow('positive integer');
|
||||
expect(() => parseAgentMaxRows(KLO_AGENT_MAX_ROWS_CAP + 1)).toThrow(String(KLO_AGENT_MAX_ROWS_CAP));
|
||||
});
|
||||
|
||||
it('constructs local context ports with semantic compute and query executor', async () => {
|
||||
const project = {
|
||||
projectDir: tempDir,
|
||||
configPath: join(tempDir, 'klo.yaml'),
|
||||
config: { project: 'revenue', connections: {} },
|
||||
coreConfig: {},
|
||||
git: {},
|
||||
fileStore: {},
|
||||
} as never;
|
||||
const ports = { knowledge: {}, semanticLayer: {} } as never;
|
||||
const semanticLayerCompute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
|
||||
const queryExecutor = { execute: vi.fn() };
|
||||
const loadProject = vi.fn(async () => project);
|
||||
const createContextTools = vi.fn(() => ports);
|
||||
|
||||
await expect(
|
||||
createKloAgentRuntime(
|
||||
{ projectDir: tempDir, enableSemanticCompute: true, enableQueryExecution: true },
|
||||
{
|
||||
loadProject,
|
||||
createContextTools,
|
||||
createSemanticLayerCompute: () => semanticLayerCompute,
|
||||
createQueryExecutor: () => queryExecutor,
|
||||
},
|
||||
),
|
||||
).resolves.toMatchObject({ project, ports, queryExecutor });
|
||||
|
||||
expect(loadProject).toHaveBeenCalledWith({ projectDir: tempDir });
|
||||
expect(createContextTools).toHaveBeenCalledWith(project, {
|
||||
semanticLayerCompute,
|
||||
queryExecutor,
|
||||
});
|
||||
});
|
||||
});
|
||||
81
packages/cli/src/agent-runtime.ts
Normal file
81
packages/cli/src/agent-runtime.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { readFile } from 'node:fs/promises';
|
||||
import { createDefaultLocalQueryExecutor, type KloSqlQueryExecutorPort } from '@klo/context/connections';
|
||||
import { createPythonSemanticLayerComputePort, type KloSemanticLayerComputePort } from '@klo/context/daemon';
|
||||
import { createLocalProjectMcpContextPorts, type KloMcpContextPorts } from '@klo/context/mcp';
|
||||
import { type KloLocalProject, loadKloProject } from '@klo/context/project';
|
||||
import type { KloCliIo } from './cli-runtime.js';
|
||||
|
||||
export const KLO_AGENT_MAX_ROWS_CAP = 1000;
|
||||
|
||||
export interface KloAgentRuntimeOptions {
|
||||
projectDir: string;
|
||||
enableSemanticCompute: boolean;
|
||||
enableQueryExecution: boolean;
|
||||
}
|
||||
|
||||
export interface KloAgentRuntime {
|
||||
project: KloLocalProject;
|
||||
ports: KloMcpContextPorts;
|
||||
semanticLayerCompute?: KloSemanticLayerComputePort;
|
||||
queryExecutor?: KloSqlQueryExecutorPort;
|
||||
}
|
||||
|
||||
export interface KloAgentRuntimeDeps {
|
||||
loadProject?: typeof loadKloProject;
|
||||
createContextTools?: typeof createLocalProjectMcpContextPorts;
|
||||
createSemanticLayerCompute?: () => KloSemanticLayerComputePort;
|
||||
createQueryExecutor?: () => KloSqlQueryExecutorPort;
|
||||
}
|
||||
|
||||
export function writeAgentJson(io: KloCliIo, value: unknown): void {
|
||||
io.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
export function writeAgentJsonError(
|
||||
io: KloCliIo,
|
||||
message: string,
|
||||
detail: Record<string, unknown> = {},
|
||||
): void {
|
||||
io.stderr.write(`${JSON.stringify({ ok: false, error: { message, ...detail } }, null, 2)}\n`);
|
||||
}
|
||||
|
||||
export async function readAgentJsonFile(path: string): Promise<Record<string, unknown>> {
|
||||
const parsed = JSON.parse(await readFile(path, 'utf-8')) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error(`${path} must contain a JSON object.`);
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function parseAgentMaxRows(value: number | undefined): number {
|
||||
if (!Number.isInteger(value) || value === undefined || value <= 0) {
|
||||
throw new Error('maxRows is required and must be a positive integer.');
|
||||
}
|
||||
if (value > KLO_AGENT_MAX_ROWS_CAP) {
|
||||
throw new Error(`maxRows must be less than or equal to ${KLO_AGENT_MAX_ROWS_CAP}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function createKloAgentRuntime(
|
||||
options: KloAgentRuntimeOptions,
|
||||
deps: KloAgentRuntimeDeps = {},
|
||||
): Promise<KloAgentRuntime> {
|
||||
const project = await (deps.loadProject ?? loadKloProject)({ projectDir: options.projectDir });
|
||||
const semanticLayerCompute = options.enableSemanticCompute
|
||||
? (deps.createSemanticLayerCompute ?? createPythonSemanticLayerComputePort)()
|
||||
: undefined;
|
||||
const queryExecutor = options.enableQueryExecution
|
||||
? (deps.createQueryExecutor ?? createDefaultLocalQueryExecutor)()
|
||||
: undefined;
|
||||
const ports = (deps.createContextTools ?? createLocalProjectMcpContextPorts)(project, {
|
||||
...(semanticLayerCompute ? { semanticLayerCompute } : {}),
|
||||
...(queryExecutor ? { queryExecutor } : {}),
|
||||
});
|
||||
return {
|
||||
project,
|
||||
ports,
|
||||
...(semanticLayerCompute ? { semanticLayerCompute } : {}),
|
||||
...(queryExecutor ? { queryExecutor } : {}),
|
||||
};
|
||||
}
|
||||
51
packages/cli/src/agent-search-readiness.test.ts
Normal file
51
packages/cli/src/agent-search-readiness.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
isMissingProjectConfigError,
|
||||
missingConnectionSlSearchReadiness,
|
||||
missingProjectSlSearchReadiness,
|
||||
noConnectionsSlSearchReadiness,
|
||||
noIndexedSourcesSlSearchReadiness,
|
||||
} from './agent-search-readiness.js';
|
||||
|
||||
describe('agent semantic-layer search readiness guidance', () => {
|
||||
it('formats missing project guidance with exact recovery commands', () => {
|
||||
expect(missingProjectSlSearchReadiness('/tmp/klo-search', 'gross revenue')).toEqual({
|
||||
code: 'agent_sl_search_missing_project',
|
||||
message: 'Semantic-layer search needs an initialized KLO project at /tmp/klo-search.',
|
||||
nextSteps: [
|
||||
'klo demo',
|
||||
'klo setup --project-dir /tmp/klo-search',
|
||||
'klo ingest <connection>',
|
||||
'klo agent sl list --json --query "gross revenue" --project-dir /tmp/klo-search',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('formats no-connection and no-index guidance without hiding the project path', () => {
|
||||
expect(noConnectionsSlSearchReadiness('/tmp/klo-search', 'revenue')).toMatchObject({
|
||||
code: 'agent_sl_search_no_connections',
|
||||
message: 'Semantic-layer search found no configured connections in /tmp/klo-search.',
|
||||
});
|
||||
expect(noIndexedSourcesSlSearchReadiness('/tmp/klo-search', 'orders')).toMatchObject({
|
||||
code: 'agent_sl_search_no_indexed_sources',
|
||||
message: 'Semantic-layer search found no indexed semantic-layer sources in /tmp/klo-search.',
|
||||
});
|
||||
});
|
||||
|
||||
it('formats unknown connection guidance', () => {
|
||||
expect(missingConnectionSlSearchReadiness('/tmp/klo-search', 'warehouse', 'revenue')).toMatchObject({
|
||||
code: 'agent_sl_search_unknown_connection',
|
||||
message: 'Semantic-layer search connection "warehouse" is not configured in /tmp/klo-search.',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects missing klo.yaml read errors', () => {
|
||||
const error = Object.assign(new Error('ENOENT: no such file or directory'), {
|
||||
code: 'ENOENT',
|
||||
path: '/tmp/klo-search/klo.yaml',
|
||||
});
|
||||
|
||||
expect(isMissingProjectConfigError(error)).toBe(true);
|
||||
expect(isMissingProjectConfigError(new Error('other'))).toBe(false);
|
||||
});
|
||||
});
|
||||
94
packages/cli/src/agent-search-readiness.ts
Normal file
94
packages/cli/src/agent-search-readiness.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
export type KloAgentSlSearchReadinessCode =
|
||||
| 'agent_sl_search_missing_project'
|
||||
| 'agent_sl_search_no_connections'
|
||||
| 'agent_sl_search_unknown_connection'
|
||||
| 'agent_sl_search_no_indexed_sources';
|
||||
|
||||
export interface KloAgentSlSearchReadinessDetail {
|
||||
code: KloAgentSlSearchReadinessCode;
|
||||
message: string;
|
||||
nextSteps: string[];
|
||||
}
|
||||
|
||||
function queryForCommand(query: string | undefined): string {
|
||||
const trimmed = query?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : 'revenue';
|
||||
}
|
||||
|
||||
function projectSearchCommand(projectDir: string, query: string | undefined): string {
|
||||
return `klo agent sl list --json --query ${JSON.stringify(queryForCommand(query))} --project-dir ${projectDir}`;
|
||||
}
|
||||
|
||||
function baseNextSteps(projectDir: string, query: string | undefined): string[] {
|
||||
return [
|
||||
'klo demo',
|
||||
`klo setup --project-dir ${projectDir}`,
|
||||
'klo ingest <connection>',
|
||||
projectSearchCommand(projectDir, query),
|
||||
];
|
||||
}
|
||||
|
||||
export function missingProjectSlSearchReadiness(
|
||||
projectDir: string,
|
||||
query: string | undefined,
|
||||
): KloAgentSlSearchReadinessDetail {
|
||||
return {
|
||||
code: 'agent_sl_search_missing_project',
|
||||
message: `Semantic-layer search needs an initialized KLO project at ${projectDir}.`,
|
||||
nextSteps: baseNextSteps(projectDir, query),
|
||||
};
|
||||
}
|
||||
|
||||
export function noConnectionsSlSearchReadiness(
|
||||
projectDir: string,
|
||||
query: string | undefined,
|
||||
): KloAgentSlSearchReadinessDetail {
|
||||
return {
|
||||
code: 'agent_sl_search_no_connections',
|
||||
message: `Semantic-layer search found no configured connections in ${projectDir}.`,
|
||||
nextSteps: baseNextSteps(projectDir, query),
|
||||
};
|
||||
}
|
||||
|
||||
export function missingConnectionSlSearchReadiness(
|
||||
projectDir: string,
|
||||
connectionId: string,
|
||||
query: string | undefined,
|
||||
): KloAgentSlSearchReadinessDetail {
|
||||
return {
|
||||
code: 'agent_sl_search_unknown_connection',
|
||||
message: `Semantic-layer search connection "${connectionId}" is not configured in ${projectDir}.`,
|
||||
nextSteps: baseNextSteps(projectDir, query),
|
||||
};
|
||||
}
|
||||
|
||||
export function noIndexedSourcesSlSearchReadiness(
|
||||
projectDir: string,
|
||||
query: string | undefined,
|
||||
): KloAgentSlSearchReadinessDetail {
|
||||
return {
|
||||
code: 'agent_sl_search_no_indexed_sources',
|
||||
message: `Semantic-layer search found no indexed semantic-layer sources in ${projectDir}.`,
|
||||
nextSteps: baseNextSteps(projectDir, query),
|
||||
};
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | undefined {
|
||||
if (typeof error !== 'object' || error === null || !('code' in error)) {
|
||||
return undefined;
|
||||
}
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return typeof code === 'string' ? code : undefined;
|
||||
}
|
||||
|
||||
function errorPath(error: unknown): string | undefined {
|
||||
if (typeof error !== 'object' || error === null || !('path' in error)) {
|
||||
return undefined;
|
||||
}
|
||||
const path = (error as { path?: unknown }).path;
|
||||
return typeof path === 'string' ? path : undefined;
|
||||
}
|
||||
|
||||
export function isMissingProjectConfigError(error: unknown): boolean {
|
||||
return errorCode(error) === 'ENOENT' && (errorPath(error)?.endsWith('klo.yaml') ?? false);
|
||||
}
|
||||
393
packages/cli/src/agent.test.ts
Normal file
393
packages/cli/src/agent.test.ts
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { buildDefaultKloProjectConfig } from '@klo/context/project';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { runKloAgent } from './agent.js';
|
||||
import type { KloAgentRuntime } from './agent-runtime.js';
|
||||
|
||||
function makeIo() {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
return {
|
||||
io: {
|
||||
stdout: { write: (chunk: string) => (stdout += chunk) },
|
||||
stderr: { write: (chunk: string) => (stderr += chunk) },
|
||||
},
|
||||
stdout: () => stdout,
|
||||
stderr: () => stderr,
|
||||
};
|
||||
}
|
||||
|
||||
function runtime(overrides: Record<string, unknown> = {}): KloAgentRuntime {
|
||||
const config = buildDefaultKloProjectConfig('revenue');
|
||||
return {
|
||||
project: {
|
||||
projectDir: '/tmp/revenue',
|
||||
configPath: '/tmp/revenue/klo.yaml',
|
||||
config: {
|
||||
...config,
|
||||
connections: {
|
||||
warehouse: { driver: 'sqlite', path: 'warehouse.sqlite', readonly: true as const },
|
||||
},
|
||||
},
|
||||
coreConfig: {} as KloAgentRuntime['project']['coreConfig'],
|
||||
git: {} as KloAgentRuntime['project']['git'],
|
||||
fileStore: {} as KloAgentRuntime['project']['fileStore'],
|
||||
},
|
||||
ports: {
|
||||
connections: { list: vi.fn(async () => [{ id: 'warehouse', name: 'warehouse', connectionType: 'sqlite' }]) },
|
||||
semanticLayer: {
|
||||
listSources: vi.fn(async () => ({
|
||||
sources: [
|
||||
{
|
||||
connectionId: 'warehouse',
|
||||
connectionName: 'warehouse',
|
||||
name: 'orders',
|
||||
columnCount: 2,
|
||||
measureCount: 1,
|
||||
joinCount: 0,
|
||||
},
|
||||
],
|
||||
totalSources: 1,
|
||||
})),
|
||||
readSource: vi.fn(async () => ({ sourceName: 'orders', yaml: 'name: orders\n' })),
|
||||
writeSource: vi.fn(async () => ({ success: true, sourceName: 'orders' })),
|
||||
validate: vi.fn(async () => ({ success: true, errors: [], warnings: [] })),
|
||||
query: vi.fn(async () => ({ sql: 'select 1', headers: ['x'], rows: [[1]], totalRows: 1, plan: {} })),
|
||||
},
|
||||
knowledge: {
|
||||
search: vi.fn(async () => ({
|
||||
results: [
|
||||
{
|
||||
key: 'page-1',
|
||||
path: 'knowledge/global/page-1.md',
|
||||
scope: 'GLOBAL' as const,
|
||||
summary: 'Revenue logic',
|
||||
score: 0.9,
|
||||
matchReasons: ['lexical' as const],
|
||||
},
|
||||
],
|
||||
totalFound: 1,
|
||||
})),
|
||||
read: vi.fn(async () => ({
|
||||
key: 'page-1',
|
||||
scope: 'GLOBAL' as const,
|
||||
summary: 'Revenue logic',
|
||||
content: 'Use net revenue.',
|
||||
})),
|
||||
write: vi.fn(async () => ({ success: true, key: 'page-1', action: 'created' as const })),
|
||||
},
|
||||
},
|
||||
queryExecutor: {
|
||||
execute: vi.fn(async () => ({ headers: ['x'], rows: [[1]], totalRows: 1, command: 'SELECT', rowCount: 1 })),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeWithoutConnections(): KloAgentRuntime {
|
||||
const base = runtime();
|
||||
return {
|
||||
...base,
|
||||
project: {
|
||||
...base.project,
|
||||
config: {
|
||||
...base.project.config,
|
||||
connections: {},
|
||||
},
|
||||
},
|
||||
ports: {
|
||||
...base.ports,
|
||||
semanticLayer: {
|
||||
...base.ports.semanticLayer!,
|
||||
listSources: vi.fn(async () => ({ sources: [], totalSources: 0 })),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('runKloAgent', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'klo-agent-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('prints tool discovery with every stable command', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
await expect(runKloAgent({ command: 'tools', projectDir: tempDir, json: true }, io.io)).resolves.toBe(0);
|
||||
|
||||
const body = JSON.parse(io.stdout());
|
||||
expect(body.projectDir).toBe(tempDir);
|
||||
expect(body.tools.map((tool: { name: string }) => tool.name)).toEqual([
|
||||
'context',
|
||||
'sl.list',
|
||||
'sl.read',
|
||||
'sl.query',
|
||||
'wiki.search',
|
||||
'wiki.read',
|
||||
'sql.execute',
|
||||
]);
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('prints project context from setup status, connections, and SL summaries', async () => {
|
||||
const io = makeIo();
|
||||
const createRuntime = vi.fn(async () => runtime());
|
||||
const readSetupStatus = vi.fn(async () => ({ project: { path: tempDir, ready: true }, agents: [] }));
|
||||
|
||||
await expect(
|
||||
runKloAgent({ command: 'context', projectDir: tempDir, json: true }, io.io, { createRuntime, readSetupStatus }),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(JSON.parse(io.stdout())).toMatchObject({
|
||||
projectDir: tempDir,
|
||||
status: { project: { ready: true } },
|
||||
connections: [{ id: 'warehouse' }],
|
||||
semanticLayer: { totalSources: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches SL list, SL read, wiki search, and wiki read through local ports', async () => {
|
||||
for (const args of [
|
||||
{ command: 'sl-list' as const, projectDir: tempDir, json: true as const, connectionId: 'warehouse' },
|
||||
{
|
||||
command: 'sl-read' as const,
|
||||
projectDir: tempDir,
|
||||
json: true as const,
|
||||
connectionId: 'warehouse',
|
||||
sourceName: 'orders',
|
||||
},
|
||||
{ command: 'wiki-search' as const, projectDir: tempDir, json: true as const, query: 'revenue', limit: 10 },
|
||||
{ command: 'wiki-read' as const, projectDir: tempDir, json: true as const, pageId: 'page-1' },
|
||||
]) {
|
||||
const io = makeIo();
|
||||
await expect(runKloAgent(args, io.io, { createRuntime: async () => runtime() })).resolves.toBe(0);
|
||||
expect(JSON.parse(io.stdout())).toBeTruthy();
|
||||
expect(io.stderr()).toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
it('prints wiki hybrid search metadata from the hidden agent wiki search command', async () => {
|
||||
const fakeRuntime = runtime();
|
||||
const knowledge = fakeRuntime.ports.knowledge;
|
||||
if (!knowledge) {
|
||||
throw new Error('Expected runtime knowledge port');
|
||||
}
|
||||
fakeRuntime.ports.knowledge = {
|
||||
...knowledge,
|
||||
search: vi.fn(async () => ({
|
||||
results: [
|
||||
{
|
||||
key: 'metrics/revenue',
|
||||
path: 'knowledge/global/metrics/revenue.md',
|
||||
scope: 'GLOBAL' as const,
|
||||
summary: 'Revenue metric definition',
|
||||
score: 0.02459016393442623,
|
||||
matchReasons: ['lexical' as const, 'token' as const],
|
||||
},
|
||||
],
|
||||
totalFound: 1,
|
||||
})),
|
||||
};
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloAgent({ command: 'wiki-search', projectDir: tempDir, json: true, query: 'paid order', limit: 5 }, io.io, {
|
||||
createRuntime: async () => fakeRuntime,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(JSON.parse(io.stdout())).toEqual({
|
||||
results: [
|
||||
expect.objectContaining({
|
||||
key: 'metrics/revenue',
|
||||
path: 'knowledge/global/metrics/revenue.md',
|
||||
matchReasons: ['lexical', 'token'],
|
||||
}),
|
||||
],
|
||||
totalFound: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('executes SL queries from a JSON query file', async () => {
|
||||
const queryFile = join(tempDir, 'sl-query.json');
|
||||
const io = makeIo();
|
||||
await writeFile(queryFile, '{"measures":["total_revenue"],"dimensions":[]}', 'utf-8');
|
||||
|
||||
await expect(
|
||||
runKloAgent(
|
||||
{
|
||||
command: 'sl-query',
|
||||
projectDir: tempDir,
|
||||
json: true,
|
||||
connectionId: 'warehouse',
|
||||
queryFile,
|
||||
execute: true,
|
||||
maxRows: 100,
|
||||
},
|
||||
io.io,
|
||||
{ createRuntime: async () => runtime() },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(JSON.parse(io.stdout())).toMatchObject({ sql: 'select 1', rows: [[1]] });
|
||||
});
|
||||
|
||||
it('executes read-only SQL from a SQL file with an explicit row limit', async () => {
|
||||
const sqlFile = join(tempDir, 'query.sql');
|
||||
const fakeRuntime = runtime();
|
||||
const io = makeIo();
|
||||
await writeFile(sqlFile, 'select 1', 'utf-8');
|
||||
|
||||
await expect(
|
||||
runKloAgent(
|
||||
{
|
||||
command: 'sql-execute',
|
||||
projectDir: tempDir,
|
||||
json: true,
|
||||
connectionId: 'warehouse',
|
||||
sqlFile,
|
||||
maxRows: 100,
|
||||
},
|
||||
io.io,
|
||||
{ createRuntime: async () => fakeRuntime as never },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(fakeRuntime.queryExecutor?.execute).toHaveBeenCalledWith({
|
||||
connectionId: 'warehouse',
|
||||
projectDir: '/tmp/revenue',
|
||||
connection: { driver: 'sqlite', path: 'warehouse.sqlite', readonly: true },
|
||||
sql: 'select 1',
|
||||
maxRows: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('prints guided JSON when semantic-layer search runs outside a project', async () => {
|
||||
const io = makeIo();
|
||||
const missingProjectError = Object.assign(new Error('ENOENT: no such file or directory'), {
|
||||
code: 'ENOENT',
|
||||
path: join(tempDir, 'klo.yaml'),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runKloAgent(
|
||||
{ command: 'sl-list', projectDir: tempDir, json: true, query: 'gross revenue' },
|
||||
io.io,
|
||||
{ createRuntime: vi.fn(async () => Promise.reject(missingProjectError)) },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(JSON.parse(io.stderr())).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'agent_sl_search_missing_project',
|
||||
message: `Semantic-layer search needs an initialized KLO project at ${tempDir}.`,
|
||||
nextSteps: [
|
||||
'klo demo',
|
||||
`klo setup --project-dir ${tempDir}`,
|
||||
'klo ingest <connection>',
|
||||
`klo agent sl list --json --query "gross revenue" --project-dir ${tempDir}`,
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(io.stdout()).toBe('');
|
||||
});
|
||||
|
||||
it('prints guided JSON when semantic-layer search has no configured connections', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloAgent(
|
||||
{ command: 'sl-list', projectDir: tempDir, json: true, query: 'revenue' },
|
||||
io.io,
|
||||
{ createRuntime: async () => runtimeWithoutConnections() },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(JSON.parse(io.stderr())).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'agent_sl_search_no_connections',
|
||||
message: `Semantic-layer search found no configured connections in ${tempDir}.`,
|
||||
nextSteps: [
|
||||
'klo demo',
|
||||
`klo setup --project-dir ${tempDir}`,
|
||||
'klo ingest <connection>',
|
||||
`klo agent sl list --json --query "revenue" --project-dir ${tempDir}`,
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('prints guided JSON when semantic-layer search asks for an unknown connection', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloAgent(
|
||||
{ command: 'sl-list', projectDir: tempDir, json: true, connectionId: 'missing', query: 'revenue' },
|
||||
io.io,
|
||||
{ createRuntime: async () => runtime() },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(JSON.parse(io.stderr())).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'agent_sl_search_unknown_connection',
|
||||
message: `Semantic-layer search connection "missing" is not configured in ${tempDir}.`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('prints guided JSON when semantic-layer search has no indexed sources', async () => {
|
||||
const fakeRuntime = runtime();
|
||||
const semanticLayer = fakeRuntime.ports.semanticLayer!;
|
||||
fakeRuntime.ports.semanticLayer = {
|
||||
...semanticLayer,
|
||||
listSources: vi.fn(async () => ({ sources: [], totalSources: 0 })),
|
||||
};
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloAgent(
|
||||
{ command: 'sl-list', projectDir: tempDir, json: true, connectionId: 'warehouse', query: 'revenue' },
|
||||
io.io,
|
||||
{ createRuntime: async () => fakeRuntime },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(JSON.parse(io.stderr())).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'agent_sl_search_no_indexed_sources',
|
||||
message: `Semantic-layer search found no indexed semantic-layer sources in ${tempDir}.`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns JSON errors when required ports or records are missing', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloAgent({ command: 'wiki-read', projectDir: tempDir, json: true, pageId: 'missing' }, io.io, {
|
||||
createRuntime: async () =>
|
||||
runtime({
|
||||
ports: { knowledge: { read: vi.fn(async () => null) } },
|
||||
}) as never,
|
||||
}),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(JSON.parse(io.stderr())).toMatchObject({
|
||||
ok: false,
|
||||
error: { message: expect.stringContaining('missing') },
|
||||
});
|
||||
});
|
||||
});
|
||||
214
packages/cli/src/agent.ts
Normal file
214
packages/cli/src/agent.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { readFile } from 'node:fs/promises';
|
||||
import type { KloCliIo } from './cli-runtime.js';
|
||||
import {
|
||||
createKloAgentRuntime,
|
||||
parseAgentMaxRows,
|
||||
readAgentJsonFile,
|
||||
writeAgentJson,
|
||||
writeAgentJsonError,
|
||||
type KloAgentRuntime,
|
||||
type KloAgentRuntimeDeps,
|
||||
} from './agent-runtime.js';
|
||||
import {
|
||||
isMissingProjectConfigError,
|
||||
missingConnectionSlSearchReadiness,
|
||||
missingProjectSlSearchReadiness,
|
||||
noConnectionsSlSearchReadiness,
|
||||
noIndexedSourcesSlSearchReadiness,
|
||||
type KloAgentSlSearchReadinessDetail,
|
||||
} from './agent-search-readiness.js';
|
||||
import { readKloSetupStatus, type KloSetupStatus } from './setup.js';
|
||||
|
||||
export type KloAgentArgs =
|
||||
| { command: 'tools'; projectDir: string; json: true }
|
||||
| { command: 'context'; projectDir: string; json: true }
|
||||
| { command: 'sl-list'; projectDir: string; json: true; connectionId?: string; query?: string }
|
||||
| { command: 'sl-read'; projectDir: string; json: true; connectionId?: string; sourceName: string }
|
||||
| {
|
||||
command: 'sl-query';
|
||||
projectDir: string;
|
||||
json: true;
|
||||
connectionId: string;
|
||||
queryFile: string;
|
||||
execute: boolean;
|
||||
maxRows?: number;
|
||||
}
|
||||
| { command: 'wiki-search'; projectDir: string; json: true; query: string; limit: number }
|
||||
| { command: 'wiki-read'; projectDir: string; json: true; pageId: string }
|
||||
| { command: 'sql-execute'; projectDir: string; json: true; connectionId: string; sqlFile: string; maxRows?: number };
|
||||
|
||||
export interface KloAgentDeps extends KloAgentRuntimeDeps {
|
||||
createRuntime?: (options: {
|
||||
projectDir: string;
|
||||
enableSemanticCompute: boolean;
|
||||
enableQueryExecution: boolean;
|
||||
}) => Promise<KloAgentRuntime>;
|
||||
readSetupStatus?: (
|
||||
projectDir: string,
|
||||
) => Promise<KloSetupStatus | { project: { path?: string; ready: boolean }; agents: unknown[] }>;
|
||||
}
|
||||
|
||||
const AGENT_TOOLS = [
|
||||
{ name: 'context', command: 'klo agent context --json' },
|
||||
{ name: 'sl.list', command: 'klo agent sl list --json [--connection-id <id>] [--query <text>]' },
|
||||
{ name: 'sl.read', command: 'klo agent sl read <sourceName> --json [--connection-id <id>]' },
|
||||
{
|
||||
name: 'sl.query',
|
||||
command: 'klo agent sl query --json --connection-id <id> --query-file <path> --execute --max-rows 100',
|
||||
},
|
||||
{ name: 'wiki.search', command: 'klo agent wiki search <query> --json [--limit 10]' },
|
||||
{ name: 'wiki.read', command: 'klo agent wiki read <pageId> --json' },
|
||||
{
|
||||
name: 'sql.execute',
|
||||
command: 'klo agent sql execute --json --connection-id <id> --sql-file <path> --max-rows 100',
|
||||
},
|
||||
] as const;
|
||||
|
||||
function writeAgentSlSearchReadinessError(io: KloCliIo, detail: KloAgentSlSearchReadinessDetail): void {
|
||||
writeAgentJsonError(io, detail.message, { code: detail.code, nextSteps: detail.nextSteps });
|
||||
}
|
||||
|
||||
async function runtimeFor(args: KloAgentArgs, deps: KloAgentDeps): Promise<KloAgentRuntime> {
|
||||
const needsSemanticCompute = args.command === 'sl-query';
|
||||
const needsQueryExecution = args.command === 'sql-execute' || (args.command === 'sl-query' && args.execute);
|
||||
return deps.createRuntime
|
||||
? deps.createRuntime({
|
||||
projectDir: args.projectDir,
|
||||
enableSemanticCompute: needsSemanticCompute,
|
||||
enableQueryExecution: needsQueryExecution,
|
||||
})
|
||||
: createKloAgentRuntime(
|
||||
{
|
||||
projectDir: args.projectDir,
|
||||
enableSemanticCompute: needsSemanticCompute,
|
||||
enableQueryExecution: needsQueryExecution,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
}
|
||||
|
||||
function connectionIdForSource(runtime: KloAgentRuntime, requested: string | undefined): string {
|
||||
if (requested) return requested;
|
||||
const ids = Object.keys(runtime.project.config.connections ?? {});
|
||||
if (ids.length === 1) return ids[0] as string;
|
||||
throw new Error('Use --connection-id when the project has zero or multiple connections.');
|
||||
}
|
||||
|
||||
export async function runKloAgent(args: KloAgentArgs, io: KloCliIo, deps: KloAgentDeps = {}): Promise<number> {
|
||||
try {
|
||||
if (args.command === 'tools') {
|
||||
writeAgentJson(io, { projectDir: args.projectDir, tools: AGENT_TOOLS });
|
||||
return 0;
|
||||
}
|
||||
|
||||
const runtime = await runtimeFor(args, deps);
|
||||
|
||||
if (args.command === 'context') {
|
||||
const [status, connections, semanticLayer] = await Promise.all([
|
||||
(deps.readSetupStatus ?? readKloSetupStatus)(args.projectDir),
|
||||
runtime.ports.connections?.list() ?? [],
|
||||
runtime.ports.semanticLayer?.listSources({}) ?? { sources: [], totalSources: 0 },
|
||||
]);
|
||||
writeAgentJson(io, { projectDir: args.projectDir, status, connections, semanticLayer, tools: AGENT_TOOLS });
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'sl-list') {
|
||||
const semanticLayer = runtime.ports.semanticLayer;
|
||||
if (!semanticLayer) throw new Error('Semantic-layer tools are not available for this project.');
|
||||
if (args.query) {
|
||||
const connectionIds = Object.keys(runtime.project.config.connections ?? {});
|
||||
if (args.connectionId && !runtime.project.config.connections[args.connectionId]) {
|
||||
writeAgentSlSearchReadinessError(
|
||||
io,
|
||||
missingConnectionSlSearchReadiness(args.projectDir, args.connectionId, args.query),
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
if (connectionIds.length === 0) {
|
||||
writeAgentSlSearchReadinessError(io, noConnectionsSlSearchReadiness(args.projectDir, args.query));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
const listed = await semanticLayer.listSources({ connectionId: args.connectionId, query: args.query });
|
||||
if (args.query && listed.sources.length === 0) {
|
||||
const allSources = await semanticLayer.listSources({ connectionId: args.connectionId });
|
||||
if (allSources.totalSources === 0) {
|
||||
writeAgentSlSearchReadinessError(io, noIndexedSourcesSlSearchReadiness(args.projectDir, args.query));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
writeAgentJson(io, listed);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'sl-read') {
|
||||
const semanticLayer = runtime.ports.semanticLayer;
|
||||
if (!semanticLayer) throw new Error('Semantic-layer tools are not available for this project.');
|
||||
const source = await semanticLayer.readSource({
|
||||
connectionId: connectionIdForSource(runtime, args.connectionId),
|
||||
sourceName: args.sourceName,
|
||||
});
|
||||
if (!source) throw new Error(`Semantic-layer source "${args.sourceName}" was not found.`);
|
||||
writeAgentJson(io, source);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'sl-query') {
|
||||
const semanticLayer = runtime.ports.semanticLayer;
|
||||
if (!semanticLayer) throw new Error('Semantic-layer tools are not available for this project.');
|
||||
const query = await readAgentJsonFile(args.queryFile);
|
||||
const maxRows = args.execute ? parseAgentMaxRows(args.maxRows) : args.maxRows;
|
||||
writeAgentJson(
|
||||
io,
|
||||
await semanticLayer.query({
|
||||
connectionId: args.connectionId,
|
||||
query: { ...query, ...(maxRows !== undefined ? { limit: maxRows } : {}) } as never,
|
||||
}),
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'wiki-search') {
|
||||
const knowledge = runtime.ports.knowledge;
|
||||
if (!knowledge) throw new Error('Wiki tools are not available for this project.');
|
||||
writeAgentJson(io, await knowledge.search({ userId: 'agent', query: args.query, limit: args.limit }));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'wiki-read') {
|
||||
const knowledge = runtime.ports.knowledge;
|
||||
if (!knowledge) throw new Error('Wiki tools are not available for this project.');
|
||||
const page = await knowledge.read({ userId: 'agent', key: args.pageId });
|
||||
if (!page) throw new Error(`Wiki page "${args.pageId}" was not found.`);
|
||||
writeAgentJson(io, page);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const queryExecutor = runtime.queryExecutor;
|
||||
if (!queryExecutor) throw new Error('SQL execution is not available for this project.');
|
||||
const connection = runtime.project.config.connections[args.connectionId];
|
||||
if (!connection) throw new Error(`Connection "${args.connectionId}" was not found.`);
|
||||
const maxRows = parseAgentMaxRows(args.maxRows);
|
||||
writeAgentJson(
|
||||
io,
|
||||
await queryExecutor.execute({
|
||||
connectionId: args.connectionId,
|
||||
projectDir: runtime.project.projectDir,
|
||||
connection,
|
||||
sql: await readFile(args.sqlFile, 'utf-8'),
|
||||
maxRows,
|
||||
}),
|
||||
);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
if (args.command === 'sl-list' && args.query && isMissingProjectConfigError(error)) {
|
||||
writeAgentSlSearchReadinessError(io, missingProjectSlSearchReadiness(args.projectDir, args.query));
|
||||
return 1;
|
||||
}
|
||||
writeAgentJsonError(io, error instanceof Error ? error.message : String(error));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
9
packages/cli/src/bin.ts
Normal file
9
packages/cli/src/bin.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { installStartupProfileReporter, profileMark, profileSpan } from './startup-profile.js';
|
||||
|
||||
installStartupProfileReporter();
|
||||
profileMark('bin:entry');
|
||||
const { runKloCli } = await profileSpan('import ./cli-runtime.js', () => import('./cli-runtime.js'));
|
||||
profileMark('bin:runKloCli');
|
||||
process.exitCode = await runKloCli(process.argv.slice(2));
|
||||
11
packages/cli/src/clack.ts
Normal file
11
packages/cli/src/clack.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { spinner } from '@clack/prompts';
|
||||
|
||||
export interface KloCliSpinner {
|
||||
start(message: string): void;
|
||||
stop(message: string): void;
|
||||
error(message: string): void;
|
||||
}
|
||||
|
||||
export function createClackSpinner(): KloCliSpinner {
|
||||
return spinner();
|
||||
}
|
||||
268
packages/cli/src/cli-program.ts
Normal file
268
packages/cli/src/cli-program.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import { Command, InvalidArgumentError } from '@commander-js/extra-typings';
|
||||
import type { KloCliDeps, KloCliIo, KloCliPackageInfo } from './cli-runtime.js';
|
||||
import { registerAgentCommands } from './commands/agent-commands.js';
|
||||
import { registerConnectionCommands } from './commands/connection-commands.js';
|
||||
import { registerWikiCommands } from './commands/knowledge-commands.js';
|
||||
import { registerPublicIngestCommands } from './commands/public-ingest-commands.js';
|
||||
import { registerServeCommands } from './commands/serve-commands.js';
|
||||
import { registerSetupCommands } from './commands/setup-commands.js';
|
||||
import { registerSlCommands } from './commands/sl-commands.js';
|
||||
import { registerStatusCommands } from './commands/status-commands.js';
|
||||
import { registerDevCommands } from './dev.js';
|
||||
import { findNearestKloProjectDir, resolveKloProjectDir } from './project-resolver.js';
|
||||
import { profileMark, profileSpan } from './startup-profile.js';
|
||||
|
||||
profileMark('module:cli-program');
|
||||
|
||||
export interface KloCliCommandContext {
|
||||
io: KloCliIo;
|
||||
deps: KloCliDeps;
|
||||
setExitCode: (code: number) => void;
|
||||
runInit: (args: { projectDir: string; projectName?: string; force: boolean }, io: KloCliIo) => Promise<number>;
|
||||
writeDebug?: (command: string, commandContext: CommandWithGlobalOptions) => void;
|
||||
}
|
||||
|
||||
export interface OutputModeOptions {
|
||||
plain?: boolean;
|
||||
json?: boolean;
|
||||
viz?: boolean;
|
||||
input?: boolean;
|
||||
}
|
||||
|
||||
interface KloCommanderProgramOptions {
|
||||
runInit: (args: { projectDir: string; projectName?: string; force: boolean }, io: KloCliIo) => Promise<number>;
|
||||
}
|
||||
|
||||
type CommanderExitLike = { exitCode: number; code: string; message: string };
|
||||
|
||||
interface KloGlobalOptionValues {
|
||||
projectDir?: string;
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
export interface CommandWithGlobalOptions {
|
||||
opts: () => object;
|
||||
optsWithGlobals?: () => object;
|
||||
}
|
||||
|
||||
function isCommanderExit(error: unknown): error is CommanderExitLike {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'exitCode' in error &&
|
||||
typeof (error as { exitCode: unknown }).exitCode === 'number' &&
|
||||
'code' in error &&
|
||||
typeof (error as { code: unknown }).code === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export function collectOption(value: string, previous: string[] = []): string[] {
|
||||
return [...previous, value];
|
||||
}
|
||||
|
||||
export function parsePositiveIntegerOption(value: string): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
throw new InvalidArgumentError('must be a positive integer');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parseNonNegativeIntegerOption(value: string): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new InvalidArgumentError('must be a non-negative integer');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parseBooleanStringOption(value: string): boolean {
|
||||
if (value === 'true') {
|
||||
return true;
|
||||
}
|
||||
if (value === 'false') {
|
||||
return false;
|
||||
}
|
||||
throw new InvalidArgumentError('must be true or false');
|
||||
}
|
||||
|
||||
export function parseSafeConnectionIdOption(value: string): string {
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(value)) {
|
||||
throw new InvalidArgumentError(`Unsafe connection id: ${value}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseNonEmptyAssignmentOption(value: string): { key: string; value: string } {
|
||||
const separatorIndex = value.indexOf('=');
|
||||
if (separatorIndex <= 0 || separatorIndex === value.length - 1) {
|
||||
throw new InvalidArgumentError('must be a non-empty <key>=<value> assignment');
|
||||
}
|
||||
return {
|
||||
key: value.slice(0, separatorIndex),
|
||||
value: value.slice(separatorIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function optionsWithGlobals(command: CommandWithGlobalOptions): KloGlobalOptionValues {
|
||||
const options = command.optsWithGlobals ? command.optsWithGlobals() : command.opts();
|
||||
const values = options as { projectDir?: unknown; debug?: unknown };
|
||||
return {
|
||||
projectDir: typeof values.projectDir === 'string' ? values.projectDir : undefined,
|
||||
debug: typeof values.debug === 'boolean' ? values.debug : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCommandProjectDir(command: CommandWithGlobalOptions): string {
|
||||
return resolveKloProjectDir({ explicitProjectDir: optionsWithGlobals(command).projectDir });
|
||||
}
|
||||
|
||||
export function resolveCommandProjectDirOverride(command: CommandWithGlobalOptions): string | undefined {
|
||||
return optionsWithGlobals(command).projectDir ?? process.env.KLO_PROJECT_DIR;
|
||||
}
|
||||
|
||||
function createBaseProgram(info: KloCliPackageInfo, io: KloCliIo): Command {
|
||||
return new Command()
|
||||
.name('klo')
|
||||
.description('Standalone KLO developer CLI')
|
||||
.option('--project-dir <path>', 'KLO project directory (default: KLO_PROJECT_DIR, nearest klo.yaml, or cwd)')
|
||||
.option('--debug', 'Enable diagnostic logging to stderr')
|
||||
.version(`${info.name} ${info.version}`, '-v, --version', 'Show CLI version')
|
||||
.helpOption('-h, --help', 'Show this help text')
|
||||
.configureHelp({ showGlobalOptions: true })
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\nAdvanced:\n klo dev Low-level diagnostics, scans, adapter commands, and mapping tools.\n',
|
||||
)
|
||||
.showHelpAfterError()
|
||||
.exitOverride()
|
||||
.configureOutput({
|
||||
writeOut: (chunk) => io.stdout.write(chunk),
|
||||
writeErr: (chunk) => io.stderr.write(chunk),
|
||||
outputError: (chunk, write) => write(chunk),
|
||||
});
|
||||
}
|
||||
|
||||
function writeDebug(io: KloCliIo, commandContext: CommandWithGlobalOptions, command: string): void {
|
||||
const global = optionsWithGlobals(commandContext);
|
||||
if (global.debug !== true) {
|
||||
return;
|
||||
}
|
||||
io.stderr.write(`[debug] projectDir=${resolveCommandProjectDir(commandContext)}\n`);
|
||||
io.stderr.write(`[debug] dispatch=${command}\n`);
|
||||
}
|
||||
|
||||
function formatCliError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function runBareInteractiveCommand(
|
||||
program: Command,
|
||||
io: KloCliIo,
|
||||
context: KloCliCommandContext,
|
||||
): Promise<number> {
|
||||
const nearestProjectDir = findNearestKloProjectDir(process.cwd());
|
||||
const envProjectDir = process.env.KLO_PROJECT_DIR;
|
||||
const runner = context.deps.setup ?? (await import('./setup.js')).runKloSetup;
|
||||
|
||||
if (!nearestProjectDir && !envProjectDir) {
|
||||
return await runner(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: resolveKloProjectDir(),
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
agentScope: 'project',
|
||||
agentInstallMode: 'cli',
|
||||
skipAgents: false,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
skipLlm: false,
|
||||
skipEmbeddings: false,
|
||||
databaseSchemas: [],
|
||||
skipDatabases: false,
|
||||
skipSources: false,
|
||||
},
|
||||
io,
|
||||
);
|
||||
}
|
||||
|
||||
program.outputHelp();
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function runCommanderKloCli(
|
||||
argv: string[],
|
||||
io: KloCliIo,
|
||||
deps: KloCliDeps,
|
||||
info: KloCliPackageInfo,
|
||||
options: KloCommanderProgramOptions,
|
||||
): Promise<number> {
|
||||
profileMark('commander:entry');
|
||||
let exitCode = 0;
|
||||
const program = createBaseProgram(info, io);
|
||||
profileMark('commander:base-program');
|
||||
const context: KloCliCommandContext = {
|
||||
io,
|
||||
deps,
|
||||
setExitCode: (code: number) => {
|
||||
exitCode = code;
|
||||
},
|
||||
runInit: options.runInit,
|
||||
writeDebug: (command: string, commandContext: CommandWithGlobalOptions) => {
|
||||
writeDebug(io, commandContext, command);
|
||||
},
|
||||
};
|
||||
|
||||
registerSetupCommands(program, context);
|
||||
profileMark('commander:register-setup');
|
||||
|
||||
registerConnectionCommands(program, context);
|
||||
profileMark('commander:register-connection');
|
||||
|
||||
registerPublicIngestCommands(program, context);
|
||||
profileMark('commander:register-public-ingest');
|
||||
|
||||
registerWikiCommands(program, context);
|
||||
profileMark('commander:register-wiki');
|
||||
|
||||
registerSlCommands(program, context);
|
||||
profileMark('commander:register-sl');
|
||||
|
||||
registerServeCommands(program, context);
|
||||
profileMark('commander:register-serve');
|
||||
|
||||
registerStatusCommands(program, context);
|
||||
profileMark('commander:register-status');
|
||||
|
||||
registerAgentCommands(program, context);
|
||||
profileMark('commander:register-agent');
|
||||
|
||||
registerDevCommands(program, context);
|
||||
profileMark('commander:register-dev');
|
||||
|
||||
if (argv.length === 0) {
|
||||
if (io.stdout.isTTY === true) {
|
||||
try {
|
||||
return await runBareInteractiveCommand(program, io, context);
|
||||
} catch (error) {
|
||||
io.stderr.write(`${formatCliError(error)}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
program.outputHelp();
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
await profileSpan('commander:parseAsync', () => program.parseAsync(argv, { from: 'user' }));
|
||||
} catch (error) {
|
||||
if (isCommanderExit(error)) {
|
||||
return error.exitCode === 0 ? 0 : 1;
|
||||
}
|
||||
io.stderr.write(`${formatCliError(error)}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
return exitCode;
|
||||
}
|
||||
89
packages/cli/src/cli-runtime.ts
Normal file
89
packages/cli/src/cli-runtime.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import type { KloConnectionMetabaseSetupArgs } from './commands/connection-metabase-setup.js';
|
||||
import type { KloConnectionNotionArgs } from './commands/connection-notion.js';
|
||||
import type { KloAgentArgs } from './agent.js';
|
||||
import type { KloConnectionArgs } from './connection.js';
|
||||
import type { KloDemoArgs } from './demo.js';
|
||||
import type { KloDoctorArgs } from './doctor.js';
|
||||
import type { KloIngestArgs } from './ingest.js';
|
||||
import type { KloKnowledgeArgs } from './knowledge.js';
|
||||
import type { KloPublicIngestArgs } from './public-ingest.js';
|
||||
import type { KloScanArgs } from './scan.js';
|
||||
import type { KloServeArgs } from './serve.js';
|
||||
import type { KloSetupArgs } from './setup.js';
|
||||
import type { KloSlArgs } from './sl.js';
|
||||
import { profileMark, profileSpan } from './startup-profile.js';
|
||||
|
||||
profileMark('module:cli-runtime');
|
||||
|
||||
export interface KloCliPackageInfo {
|
||||
name: '@klo/cli';
|
||||
version: '0.0.0-private';
|
||||
contextPackageName: '@klo/context';
|
||||
}
|
||||
|
||||
export interface KloCliIo {
|
||||
stdout: { isTTY?: boolean; write(chunk: string): void };
|
||||
stderr: { write(chunk: string): void };
|
||||
}
|
||||
|
||||
export interface KloCliDeps {
|
||||
serveStdio?: (args: KloServeArgs) => Promise<number>;
|
||||
setup?: (args: KloSetupArgs, io: KloCliIo) => Promise<number>;
|
||||
agent?: (args: KloAgentArgs, io: KloCliIo) => Promise<number>;
|
||||
connection?: (args: KloConnectionArgs, io: KloCliIo) => Promise<number>;
|
||||
connectionNotion?: (args: KloConnectionNotionArgs, io: KloCliIo) => Promise<number>;
|
||||
connectionMetabaseSetup?: (args: KloConnectionMetabaseSetupArgs, io: KloCliIo) => Promise<number>;
|
||||
demo?: (args: KloDemoArgs, io: KloCliIo) => Promise<number>;
|
||||
doctor?: (args: KloDoctorArgs, io: KloCliIo) => Promise<number>;
|
||||
ingest?: (args: KloIngestArgs, io: KloCliIo) => Promise<number>;
|
||||
publicIngest?: (args: KloPublicIngestArgs, io: KloCliIo) => Promise<number>;
|
||||
scan?: (args: KloScanArgs, io: KloCliIo) => Promise<number>;
|
||||
knowledge?: (args: KloKnowledgeArgs, io: KloCliIo) => Promise<number>;
|
||||
sl?: (args: KloSlArgs, io: KloCliIo) => Promise<number>;
|
||||
}
|
||||
|
||||
export function getKloCliPackageInfo(): KloCliPackageInfo {
|
||||
return {
|
||||
name: '@klo/cli',
|
||||
version: '0.0.0-private',
|
||||
contextPackageName: '@klo/context',
|
||||
};
|
||||
}
|
||||
|
||||
async function runInit(
|
||||
args: { projectDir: string; projectName?: string; force: boolean },
|
||||
io: KloCliIo,
|
||||
): Promise<number> {
|
||||
const { initKloProject } = await import('@klo/context/project');
|
||||
const result = await initKloProject({
|
||||
projectDir: args.projectDir,
|
||||
projectName: args.projectName,
|
||||
force: args.force,
|
||||
});
|
||||
|
||||
io.stdout.write(`Initialized KLO project at ${result.projectDir}\n`);
|
||||
io.stdout.write(`Config: ${result.configPath}\n`);
|
||||
io.stdout.write(`Commit: ${result.commitHash ?? 'none'}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function runInitForCommander(
|
||||
args: { projectDir: string; projectName?: string; force: boolean },
|
||||
io: KloCliIo,
|
||||
): Promise<number> {
|
||||
return await runInit(args, io);
|
||||
}
|
||||
|
||||
export async function runKloCli(
|
||||
argv = process.argv.slice(2),
|
||||
io: KloCliIo = process,
|
||||
deps: KloCliDeps = {},
|
||||
): Promise<number> {
|
||||
const info = getKloCliPackageInfo();
|
||||
profileMark('runtime:runKloCli');
|
||||
const { runCommanderKloCli } = await profileSpan('import ./cli-program.js', () => import('./cli-program.js'));
|
||||
|
||||
return await runCommanderKloCli(argv, io, deps, info, {
|
||||
runInit: runInitForCommander,
|
||||
});
|
||||
}
|
||||
85
packages/cli/src/command-schemas.ts
Normal file
85
packages/cli/src/command-schemas.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
const projectDirSchema = z.string().min(1);
|
||||
const safeConnectionIdSchema = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, 'Unsafe connection id');
|
||||
const stringArraySchema = z.array(z.string());
|
||||
|
||||
export const connectionAddCommandSchema = z.object({
|
||||
command: z.literal('add'),
|
||||
projectDir: projectDirSchema,
|
||||
driver: z.string().min(1),
|
||||
connectionId: safeConnectionIdSchema,
|
||||
url: z.string().optional(),
|
||||
schemas: stringArraySchema,
|
||||
readonly: z.boolean(),
|
||||
force: z.boolean(),
|
||||
allowLiteralCredentials: z.boolean(),
|
||||
notion: z
|
||||
.object({
|
||||
authTokenRef: z.string().min(1),
|
||||
crawlMode: z.enum(['all_accessible', 'selected_roots']),
|
||||
rootPageIds: stringArraySchema,
|
||||
rootDatabaseIds: stringArraySchema,
|
||||
rootDataSourceIds: stringArraySchema,
|
||||
maxPagesPerRun: z.number().int().positive().optional(),
|
||||
maxKnowledgeCreatesPerRun: z.number().int().nonnegative().optional(),
|
||||
maxKnowledgeUpdatesPerRun: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const wikiWriteCommandSchema = z.object({
|
||||
command: z.literal('write'),
|
||||
projectDir: projectDirSchema,
|
||||
key: z.string().min(1),
|
||||
scope: z.enum(['GLOBAL', 'USER']),
|
||||
userId: z.string().min(1),
|
||||
summary: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
tags: stringArraySchema,
|
||||
refs: stringArraySchema,
|
||||
slRefs: stringArraySchema,
|
||||
});
|
||||
|
||||
const orderBySchema = z.union([
|
||||
z.string().min(1),
|
||||
z.object({
|
||||
field: z.string().min(1),
|
||||
direction: z.enum(['asc', 'desc']).optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const slQueryCommandSchema = z.object({
|
||||
command: z.literal('query'),
|
||||
projectDir: projectDirSchema,
|
||||
connectionId: z.string().min(1).optional(),
|
||||
query: z.object({
|
||||
measures: z.array(z.string().min(1)).min(1),
|
||||
dimensions: stringArraySchema,
|
||||
filters: stringArraySchema.optional(),
|
||||
segments: stringArraySchema.optional(),
|
||||
order_by: z.array(orderBySchema).optional(),
|
||||
limit: z.number().int().positive().optional(),
|
||||
include_empty: z.literal(true).optional(),
|
||||
}),
|
||||
format: z.enum(['json', 'sql']),
|
||||
execute: z.boolean(),
|
||||
maxRows: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export const publicIngestRunCommandSchema = z.object({
|
||||
command: z.literal('run'),
|
||||
projectDir: projectDirSchema,
|
||||
targetConnectionId: safeConnectionIdSchema.optional(),
|
||||
all: z.boolean(),
|
||||
json: z.boolean(),
|
||||
inputMode: z.enum(['auto', 'disabled']),
|
||||
});
|
||||
|
||||
export const publicIngestReadCommandSchema = z.object({
|
||||
command: z.enum(['status', 'watch']),
|
||||
projectDir: projectDirSchema,
|
||||
runId: z.string().min(1).optional(),
|
||||
json: z.boolean(),
|
||||
inputMode: z.enum(['auto', 'disabled']),
|
||||
});
|
||||
137
packages/cli/src/commands/agent-commands.ts
Normal file
137
packages/cli/src/commands/agent-commands.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { Option, type Command } from '@commander-js/extra-typings';
|
||||
import type { KloAgentArgs } from '../agent.js';
|
||||
import type { KloCliCommandContext } from '../cli-program.js';
|
||||
import { parsePositiveIntegerOption, resolveCommandProjectDir } from '../cli-program.js';
|
||||
|
||||
async function runAgent(context: KloCliCommandContext, args: KloAgentArgs): Promise<void> {
|
||||
const runner = context.deps.agent ?? (await import('../agent.js')).runKloAgent;
|
||||
context.setExitCode(await runner(args, context.io));
|
||||
}
|
||||
|
||||
function jsonOption(): Option {
|
||||
return new Option('--json', 'Print JSON output').makeOptionMandatory();
|
||||
}
|
||||
|
||||
export function registerAgentCommands(program: Command, context: KloCliCommandContext): void {
|
||||
const agent = program
|
||||
.command('agent', { hidden: true })
|
||||
.description('Machine-readable KLO commands for coding agents')
|
||||
.showHelpAfterError();
|
||||
|
||||
agent.hook('preAction', (_thisCommand, actionCommand) => {
|
||||
context.writeDebug?.('agent', actionCommand);
|
||||
});
|
||||
|
||||
agent
|
||||
.command('tools')
|
||||
.description('Print available agent-facing KLO tools')
|
||||
.addOption(jsonOption())
|
||||
.action(async (_options, command) => {
|
||||
await runAgent(context, { command: 'tools', projectDir: resolveCommandProjectDir(command), json: true });
|
||||
});
|
||||
|
||||
agent
|
||||
.command('context')
|
||||
.description('Print project context for agent planning')
|
||||
.addOption(jsonOption())
|
||||
.action(async (_options, command) => {
|
||||
await runAgent(context, { command: 'context', projectDir: resolveCommandProjectDir(command), json: true });
|
||||
});
|
||||
|
||||
const sl = agent.command('sl').description('Semantic-layer agent commands');
|
||||
sl.command('list')
|
||||
.description('List semantic-layer sources')
|
||||
.addOption(jsonOption())
|
||||
.option('--connection-id <id>', 'Filter by connection id')
|
||||
.option('--query <text>', 'Search source names and descriptions')
|
||||
.action(async (options: { connectionId?: string; query?: string }, command) => {
|
||||
await runAgent(context, {
|
||||
command: 'sl-list',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
json: true,
|
||||
...(options.connectionId ? { connectionId: options.connectionId } : {}),
|
||||
...(options.query ? { query: options.query } : {}),
|
||||
});
|
||||
});
|
||||
sl.command('read')
|
||||
.description('Read one semantic-layer source')
|
||||
.argument('<sourceName>')
|
||||
.addOption(jsonOption())
|
||||
.option('--connection-id <id>', 'Connection id containing the source')
|
||||
.action(async (sourceName: string, options: { connectionId?: string }, command) => {
|
||||
await runAgent(context, {
|
||||
command: 'sl-read',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
json: true,
|
||||
sourceName,
|
||||
...(options.connectionId ? { connectionId: options.connectionId } : {}),
|
||||
});
|
||||
});
|
||||
sl.command('query')
|
||||
.description('Run a semantic-layer query JSON file')
|
||||
.addOption(jsonOption())
|
||||
.requiredOption('--connection-id <id>', 'Connection id for execution')
|
||||
.requiredOption('--query-file <path>', 'JSON semantic-layer query file')
|
||||
.option('--execute', 'Execute the compiled query against the connection', false)
|
||||
.option('--max-rows <number>', 'Maximum rows to return when executing', parsePositiveIntegerOption)
|
||||
.action(
|
||||
async (
|
||||
options: { connectionId: string; queryFile: string; execute: boolean; maxRows?: number },
|
||||
command,
|
||||
) => {
|
||||
await runAgent(context, {
|
||||
command: 'sl-query',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
json: true,
|
||||
connectionId: options.connectionId,
|
||||
queryFile: options.queryFile,
|
||||
execute: options.execute,
|
||||
...(options.maxRows !== undefined ? { maxRows: options.maxRows } : {}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const wiki = agent.command('wiki').description('KLO wiki agent commands');
|
||||
wiki
|
||||
.command('search')
|
||||
.description('Search KLO wiki pages')
|
||||
.argument('<query>')
|
||||
.addOption(jsonOption())
|
||||
.option('--limit <number>', 'Maximum search results', parsePositiveIntegerOption, 10)
|
||||
.action(async (query: string, options: { limit: number }, command) => {
|
||||
await runAgent(context, {
|
||||
command: 'wiki-search',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
json: true,
|
||||
query,
|
||||
limit: options.limit,
|
||||
});
|
||||
});
|
||||
wiki
|
||||
.command('read')
|
||||
.description('Read one KLO wiki page')
|
||||
.argument('<pageId>')
|
||||
.addOption(jsonOption())
|
||||
.action(async (pageId: string, _options, command) => {
|
||||
await runAgent(context, { command: 'wiki-read', projectDir: resolveCommandProjectDir(command), json: true, pageId });
|
||||
});
|
||||
|
||||
const sql = agent.command('sql').description('Safe SQL execution commands');
|
||||
sql
|
||||
.command('execute')
|
||||
.description('Execute read-only SQL with a row limit')
|
||||
.addOption(jsonOption())
|
||||
.requiredOption('--connection-id <id>', 'Connection id for execution')
|
||||
.requiredOption('--sql-file <path>', 'SQL file to execute')
|
||||
.requiredOption('--max-rows <number>', 'Maximum rows to return', parsePositiveIntegerOption)
|
||||
.action(async (options: { connectionId: string; sqlFile: string; maxRows: number }, command) => {
|
||||
await runAgent(context, {
|
||||
command: 'sql-execute',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
json: true,
|
||||
connectionId: options.connectionId,
|
||||
sqlFile: options.sqlFile,
|
||||
maxRows: options.maxRows,
|
||||
});
|
||||
});
|
||||
}
|
||||
47
packages/cli/src/commands/completion-commands.ts
Normal file
47
packages/cli/src/commands/completion-commands.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import type { CommandUnknownOpts } from '@commander-js/extra-typings';
|
||||
import type { KloCliCommandContext } from '../cli-program.js';
|
||||
import { completeCommanderInput, installZshCompletion, zshCompletionScript } from '../completion.js';
|
||||
|
||||
export function registerCompletionCommands(
|
||||
program: CommandUnknownOpts,
|
||||
context: KloCliCommandContext,
|
||||
completionRoot: CommandUnknownOpts = program,
|
||||
): void {
|
||||
program
|
||||
.command('completion')
|
||||
.description('Generate shell completion scripts')
|
||||
.command('zsh')
|
||||
.description('Generate zsh completion script')
|
||||
.option('--install', 'Install zsh completion into ~/.zfunc and update ~/.zshrc', false)
|
||||
.action(async (options: { install?: boolean }) => {
|
||||
if (options.install === true) {
|
||||
const result = await installZshCompletion();
|
||||
context.io.stdout.write(`Installed zsh completion: ${result.completionPath}\n`);
|
||||
context.io.stdout.write(`Updated zsh config: ${result.zshrcPath}\n`);
|
||||
context.io.stdout.write('Restart your shell or run: source ~/.zshrc\n');
|
||||
context.setExitCode(0);
|
||||
return;
|
||||
}
|
||||
context.io.stdout.write(zshCompletionScript());
|
||||
context.setExitCode(0);
|
||||
});
|
||||
|
||||
program
|
||||
.command('__complete', { hidden: true })
|
||||
.description('Internal shell completion endpoint')
|
||||
.requiredOption('--shell <shell>', 'Shell requesting completions')
|
||||
.requiredOption('--position <position>', 'Current shell word position', (value) => Number(value))
|
||||
.argument('[words...]', 'Current shell words')
|
||||
.allowUnknownOption()
|
||||
.allowExcessArguments()
|
||||
.action((words: string[], options: { shell: string; position: number }) => {
|
||||
if (options.shell !== 'zsh') {
|
||||
context.setExitCode(1);
|
||||
return;
|
||||
}
|
||||
for (const completion of completeCommanderInput(completionRoot, { position: options.position, words })) {
|
||||
context.io.stdout.write(`${completion}\n`);
|
||||
}
|
||||
context.setExitCode(0);
|
||||
});
|
||||
}
|
||||
346
packages/cli/src/commands/connection-commands.ts
Normal file
346
packages/cli/src/commands/connection-commands.ts
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
import { type Command, InvalidArgumentError, Option } from '@commander-js/extra-typings';
|
||||
import {
|
||||
collectOption,
|
||||
type KloCliCommandContext,
|
||||
parseBooleanStringOption,
|
||||
parseNonEmptyAssignmentOption,
|
||||
parseNonNegativeIntegerOption,
|
||||
parsePositiveIntegerOption,
|
||||
parseSafeConnectionIdOption,
|
||||
resolveCommandProjectDir,
|
||||
} from '../cli-program.js';
|
||||
import { connectionAddCommandSchema } from '../command-schemas.js';
|
||||
import type { KloConnectionArgs } from '../connection.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
import type { KloConnectionMappingArgs } from './connection-mapping.js';
|
||||
import { registerConnectionMetabaseCommands } from './connection-metabase-commands.js';
|
||||
import { registerConnectionNotionCommands } from './connection-notion-commands.js';
|
||||
|
||||
profileMark('module:commands/connection-commands');
|
||||
|
||||
const CRAWL_MODE_CHOICES = ['all_accessible', 'selected_roots'] as const;
|
||||
const SYNC_MODE_CHOICES = ['ALL', 'ONLY', 'EXCEPT'] as const;
|
||||
|
||||
function parseCsvIds(value: string): number[] {
|
||||
return value
|
||||
.split(',')
|
||||
.filter(Boolean)
|
||||
.map((item) => parsePositiveIntegerOption(item));
|
||||
}
|
||||
|
||||
function parseCsvStrings(value: string): string[] {
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseMappingFieldOption(value: string): 'databaseMappings' | 'connectionMappings' {
|
||||
if (value === 'databaseMappings' || value === 'connectionMappings') {
|
||||
return value;
|
||||
}
|
||||
throw new InvalidArgumentError('must be databaseMappings or connectionMappings');
|
||||
}
|
||||
|
||||
async function runConnectionArgs(context: KloCliCommandContext, args: KloConnectionArgs): Promise<void> {
|
||||
const runner = context.deps.connection ?? (await import('../connection.js')).runKloConnection;
|
||||
context.setExitCode(await runner(args, context.io));
|
||||
}
|
||||
|
||||
async function runMappingArgs(context: KloCliCommandContext, args: KloConnectionMappingArgs): Promise<void> {
|
||||
const { runKloConnectionMapping } = await import('./connection-mapping.js');
|
||||
context.setExitCode(await runKloConnectionMapping(args, context.io));
|
||||
}
|
||||
|
||||
export function registerConnectionCommands(program: Command, context: KloCliCommandContext, commandName = 'connection'): void {
|
||||
const connection = program
|
||||
.command(commandName)
|
||||
.description('Add, list, test, and map data sources')
|
||||
.showHelpAfterError()
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\nProject directory defaults to KLO_PROJECT_DIR when set, otherwise the nearest klo.yaml or current working directory.\n',
|
||||
);
|
||||
connection.hook('preAction', (_thisCommand, actionCommand) => {
|
||||
context.writeDebug?.(commandName, actionCommand);
|
||||
});
|
||||
|
||||
connection
|
||||
.command('list')
|
||||
.description('List configured connections')
|
||||
.action(async (_options: unknown, command) => {
|
||||
await runConnectionArgs(context, { command: 'list', projectDir: resolveCommandProjectDir(command) });
|
||||
});
|
||||
|
||||
connection
|
||||
.command('test')
|
||||
.description('Test a configured connection')
|
||||
.argument('<connectionId>', 'KLO connection id')
|
||||
.action(async (connectionId: string, _options: unknown, command) => {
|
||||
await runConnectionArgs(context, {
|
||||
command: 'test',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId,
|
||||
});
|
||||
});
|
||||
|
||||
connection
|
||||
.command('add')
|
||||
.description('Add or replace a configured connection')
|
||||
.argument('<driver>', 'Connection driver')
|
||||
.argument('<connectionId>', 'KLO connection id')
|
||||
.option('--url <url>', 'Connection URL, env:NAME, or file:/path reference')
|
||||
.option('--schema <schema>', 'Schema to include; repeatable', collectOption, [])
|
||||
.option('--readonly', 'Mark the connection as read-only', false)
|
||||
.option('--force', 'Replace an existing connection', false)
|
||||
.option('--allow-literal-credentials', 'Allow writing a literal credential URL to klo.yaml', false)
|
||||
.addOption(new Option('--token-env <name>', 'Environment variable containing Notion auth token').conflicts('tokenFile'))
|
||||
.addOption(new Option('--token-file <path>', 'File containing Notion auth token').conflicts('tokenEnv'))
|
||||
.addOption(
|
||||
new Option('--crawl-mode <mode>', 'Notion crawl mode: all_accessible or selected_roots')
|
||||
.choices(CRAWL_MODE_CHOICES)
|
||||
.default('selected_roots'),
|
||||
)
|
||||
.option('--root-page-id <id>', 'Root page to crawl; repeatable', collectOption, [])
|
||||
.option('--root-database-id <id>', 'Root database to crawl; repeatable', collectOption, [])
|
||||
.option('--root-data-source-id <id>', 'Root data source to crawl; repeatable', collectOption, [])
|
||||
.option('--max-pages <n>', 'Maximum pages per run', parsePositiveIntegerOption)
|
||||
.option('--max-knowledge-creates <n>', 'Maximum knowledge creates per run', parseNonNegativeIntegerOption)
|
||||
.option('--max-knowledge-updates <n>', 'Maximum knowledge updates per run', parseNonNegativeIntegerOption)
|
||||
.action(async (driver: string, connectionId: string, options, command) => {
|
||||
const notion =
|
||||
driver === 'notion'
|
||||
? {
|
||||
authTokenRef: options.tokenEnv
|
||||
? `env:${options.tokenEnv}`
|
||||
: options.tokenFile
|
||||
? `file:${options.tokenFile}`
|
||||
: '',
|
||||
crawlMode: options.crawlMode,
|
||||
rootPageIds: options.rootPageId,
|
||||
rootDatabaseIds: options.rootDatabaseId,
|
||||
rootDataSourceIds: options.rootDataSourceId,
|
||||
maxPagesPerRun: options.maxPages,
|
||||
maxKnowledgeCreatesPerRun: options.maxKnowledgeCreates,
|
||||
maxKnowledgeUpdatesPerRun: options.maxKnowledgeUpdates,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
if (driver === 'notion' && !notion?.authTokenRef) {
|
||||
throw new Error('connection add notion requires --token-env NAME or --token-file PATH');
|
||||
}
|
||||
if (
|
||||
driver === 'notion' &&
|
||||
notion?.crawlMode === 'selected_roots' &&
|
||||
notion.rootPageIds.length + notion.rootDatabaseIds.length + notion.rootDataSourceIds.length === 0
|
||||
) {
|
||||
throw new Error('connection add notion selected_roots requires at least one root id');
|
||||
}
|
||||
|
||||
const args = connectionAddCommandSchema.parse({
|
||||
command: 'add',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
driver,
|
||||
connectionId,
|
||||
url: options.url,
|
||||
schemas: options.schema.filter(Boolean),
|
||||
readonly: options.readonly === true,
|
||||
force: options.force === true,
|
||||
allowLiteralCredentials: options.allowLiteralCredentials === true,
|
||||
notion,
|
||||
});
|
||||
|
||||
await runConnectionArgs(context, args);
|
||||
});
|
||||
|
||||
connection
|
||||
.command('remove')
|
||||
.description('Remove a configured connection from klo.yaml')
|
||||
.argument('<connectionId>', 'KLO connection id')
|
||||
.option('--force', 'Remove without prompting', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (connectionId: string, options: { force?: boolean; input?: boolean }, command) => {
|
||||
await runConnectionArgs(context, {
|
||||
command: 'remove',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId,
|
||||
force: options.force === true,
|
||||
...(options.input === false ? { inputMode: 'disabled' } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
connection
|
||||
.command('map')
|
||||
.description('Refresh and validate BI-to-warehouse mappings')
|
||||
.argument('<sourceConnectionId>', 'Source BI connection id')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.action(async (sourceConnectionId: string, options: { json?: boolean }, command) => {
|
||||
await runConnectionArgs(context, {
|
||||
command: 'map',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
sourceConnectionId,
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
|
||||
registerConnectionMappingCommands(connection, context);
|
||||
registerConnectionMetabaseCommands(connection, context);
|
||||
registerConnectionNotionCommands(connection, context);
|
||||
}
|
||||
|
||||
export function registerConnectionMappingCommands(connection: Command, context: KloCliCommandContext): void {
|
||||
const mapping = connection
|
||||
.command('mapping')
|
||||
.description('Manage Metabase warehouse mappings')
|
||||
.showHelpAfterError()
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\nProject directory defaults to KLO_PROJECT_DIR when set, otherwise the current working directory.\n',
|
||||
);
|
||||
|
||||
mapping
|
||||
.command('list')
|
||||
.description('List Metabase database mappings')
|
||||
.argument('<connectionId>', 'Metabase connection id')
|
||||
.option('--json', 'Print JSON output where supported', false)
|
||||
.action(async (connectionId: string, options: { json?: boolean }, command) => {
|
||||
await runMappingArgs(context, {
|
||||
command: 'list',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId,
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
|
||||
mapping
|
||||
.command('set')
|
||||
.description('Set a Metabase or Looker warehouse mapping')
|
||||
.argument('<connectionId>', 'Source connection id', parseSafeConnectionIdOption)
|
||||
.argument('<field>', 'Mapping field', parseMappingFieldOption)
|
||||
.argument('<assignment>', 'Mapping assignment such as 1=prod-warehouse', parseNonEmptyAssignmentOption)
|
||||
.action(
|
||||
async (
|
||||
connectionId: string,
|
||||
field: 'databaseMappings' | 'connectionMappings',
|
||||
assignment: { key: string; value: string },
|
||||
_options: unknown,
|
||||
command,
|
||||
) => {
|
||||
await runMappingArgs(context, {
|
||||
command: 'set',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId,
|
||||
field,
|
||||
key: assignment.key,
|
||||
value: assignment.value,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
mapping
|
||||
.command('apply-bulk')
|
||||
.description('Apply mappings from JSON')
|
||||
.argument('<connectionId>', 'Metabase connection id')
|
||||
.requiredOption('--file <path>', 'JSON mapping file')
|
||||
.action(async (connectionId: string, options: { file: string }, command) => {
|
||||
await runMappingArgs(context, {
|
||||
command: 'apply-bulk',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId,
|
||||
filePath: options.file,
|
||||
});
|
||||
});
|
||||
|
||||
mapping
|
||||
.command('set-sync-enabled')
|
||||
.description('Enable or disable sync for one Metabase database')
|
||||
.argument('<connectionId>', 'Metabase connection id')
|
||||
.argument('<metabaseDatabaseId>', 'Metabase database id', parsePositiveIntegerOption)
|
||||
.requiredOption('--enabled <value>', 'true or false', parseBooleanStringOption)
|
||||
.action(
|
||||
async (connectionId: string, metabaseDatabaseId: number, options: { enabled: boolean }, command) => {
|
||||
await runMappingArgs(context, {
|
||||
command: 'set-sync-enabled',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId,
|
||||
metabaseDatabaseId,
|
||||
enabled: options.enabled,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const syncState = mapping.command('sync-state').description('Manage Metabase sync-state selection');
|
||||
syncState
|
||||
.command('get')
|
||||
.description('Read sync-state selection')
|
||||
.argument('<connectionId>', 'Metabase connection id')
|
||||
.option('--json', 'Print JSON output where supported', false)
|
||||
.action(async (connectionId: string, options: { json?: boolean }, command) => {
|
||||
await runMappingArgs(context, {
|
||||
command: 'sync-state-get',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId,
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
|
||||
syncState
|
||||
.command('set')
|
||||
.description('Write sync-state selection')
|
||||
.argument('<connectionId>', 'Metabase connection id')
|
||||
.addOption(new Option('--mode <mode>', 'ALL, ONLY, or EXCEPT').choices(SYNC_MODE_CHOICES).makeOptionMandatory())
|
||||
.option('--collections <ids>', 'Comma-separated collection ids', parseCsvIds, [])
|
||||
.option('--items <ids>', 'Comma-separated item ids', parseCsvIds, [])
|
||||
.option('--tag-names <names>', 'Comma-separated tag names', parseCsvStrings, [])
|
||||
.action(async (connectionId: string, options, command) => {
|
||||
await runMappingArgs(context, {
|
||||
command: 'sync-state-set',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId,
|
||||
syncMode: options.mode,
|
||||
collectionIds: options.collections,
|
||||
itemIds: options.items,
|
||||
tagNames: options.tagNames,
|
||||
});
|
||||
});
|
||||
|
||||
mapping
|
||||
.command('refresh')
|
||||
.description('Refresh Metabase database mappings')
|
||||
.argument('<connectionId>', 'Metabase connection id')
|
||||
.option('--auto-accept', 'Accept refresh changes without prompting', false)
|
||||
.action(async (connectionId: string, options: { autoAccept?: boolean }, command) => {
|
||||
await runMappingArgs(context, {
|
||||
command: 'refresh',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId,
|
||||
autoAccept: options.autoAccept === true,
|
||||
});
|
||||
});
|
||||
|
||||
mapping
|
||||
.command('validate')
|
||||
.description('Validate Metabase database mappings')
|
||||
.argument('<connectionId>', 'Metabase connection id')
|
||||
.action(async (connectionId: string, _options: unknown, command) => {
|
||||
await runMappingArgs(context, {
|
||||
command: 'validate',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId,
|
||||
});
|
||||
});
|
||||
|
||||
mapping
|
||||
.command('clear')
|
||||
.description('Clear Metabase database mappings')
|
||||
.argument('<connectionId>', 'Metabase connection id')
|
||||
.argument('[metabaseDatabaseId]', 'Metabase database id', parsePositiveIntegerOption)
|
||||
.action(async (connectionId: string, metabaseDatabaseId: number | undefined, _options: unknown, command) => {
|
||||
await runMappingArgs(context, {
|
||||
command: 'clear',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId,
|
||||
...(metabaseDatabaseId ? { metabaseDatabaseId } : {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
329
packages/cli/src/commands/connection-mapping.test.ts
Normal file
329
packages/cli/src/commands/connection-mapping.test.ts
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { LocalMetabaseSourceStateReader } from '@klo/context/ingest';
|
||||
import { initKloProject, loadKloProject, serializeKloProjectConfig } from '@klo/context/project';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { runKloConnectionMapping } from './connection-mapping.js';
|
||||
|
||||
function makeIo() {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
return {
|
||||
io: {
|
||||
stdout: {
|
||||
write: (chunk: string) => {
|
||||
stdout += chunk;
|
||||
},
|
||||
},
|
||||
stderr: {
|
||||
write: (chunk: string) => {
|
||||
stderr += chunk;
|
||||
},
|
||||
},
|
||||
},
|
||||
stdout: () => stdout,
|
||||
stderr: () => stderr,
|
||||
};
|
||||
}
|
||||
|
||||
describe('runKloConnectionMapping', () => {
|
||||
let tempDir: string;
|
||||
let projectDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'klo-cli-metabase-mapping-'));
|
||||
projectDir = join(tempDir, 'project');
|
||||
await initKloProject({ projectDir, projectName: 'mapping' });
|
||||
const project = await loadKloProject({ projectDir });
|
||||
await project.fileStore.writeFile(
|
||||
'klo.yaml',
|
||||
serializeKloProjectConfig({
|
||||
...project.config,
|
||||
connections: {
|
||||
'prod-metabase': {
|
||||
driver: 'metabase',
|
||||
api_url: 'https://metabase.example.com',
|
||||
api_key_ref: 'env:METABASE_API_KEY', // pragma: allowlist secret
|
||||
},
|
||||
'prod-warehouse': {
|
||||
driver: 'postgres',
|
||||
url: 'env:WAREHOUSE_URL',
|
||||
readonly: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
'klo',
|
||||
'klo@example.com',
|
||||
'Seed Metabase mapping test connections',
|
||||
);
|
||||
});
|
||||
|
||||
async function replaceConnections(connections: Record<string, { driver: string; [key: string]: unknown }>) {
|
||||
const project = await loadKloProject({ projectDir });
|
||||
await project.fileStore.writeFile(
|
||||
'klo.yaml',
|
||||
serializeKloProjectConfig({
|
||||
...project.config,
|
||||
connections,
|
||||
}),
|
||||
'klo',
|
||||
'klo@example.com',
|
||||
'Replace mapping test connections',
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('sets, lists, disables, and clears local Metabase mappings', async () => {
|
||||
const io = makeIo();
|
||||
await expect(
|
||||
runKloConnectionMapping(
|
||||
{
|
||||
command: 'set',
|
||||
projectDir,
|
||||
connectionId: 'prod-metabase',
|
||||
field: 'databaseMappings',
|
||||
key: '1',
|
||||
value: 'prod-warehouse',
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const listIo = makeIo();
|
||||
await expect(
|
||||
runKloConnectionMapping({ command: 'list', projectDir, connectionId: 'prod-metabase', json: false }, listIo.io),
|
||||
).resolves.toBe(0);
|
||||
expect(listIo.stdout()).toContain('1 -> prod-warehouse');
|
||||
expect(listIo.stdout()).toContain('unhydrated');
|
||||
|
||||
await expect(
|
||||
runKloConnectionMapping(
|
||||
{
|
||||
command: 'set-sync-enabled',
|
||||
projectDir,
|
||||
connectionId: 'prod-metabase',
|
||||
metabaseDatabaseId: 1,
|
||||
enabled: false,
|
||||
},
|
||||
makeIo().io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
await expect(
|
||||
runKloConnectionMapping(
|
||||
{
|
||||
command: 'clear',
|
||||
projectDir,
|
||||
connectionId: 'prod-metabase',
|
||||
metabaseDatabaseId: 1,
|
||||
},
|
||||
makeIo().io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it('lists Metabase yaml mapping bootstrap rows before any SQLite command writes', async () => {
|
||||
const projectDir = await mkdtemp(join(tmpdir(), 'klo-cli-yaml-mapping-'));
|
||||
await initKloProject({ projectDir, projectName: 'yaml-mapping' });
|
||||
const project = await loadKloProject({ projectDir });
|
||||
await project.fileStore.writeFile(
|
||||
'klo.yaml',
|
||||
serializeKloProjectConfig({
|
||||
...project.config,
|
||||
connections: {
|
||||
'prod-metabase': {
|
||||
driver: 'metabase',
|
||||
mappings: {
|
||||
databaseMappings: { '1': 'prod-warehouse' },
|
||||
syncEnabled: { '1': true },
|
||||
},
|
||||
},
|
||||
'prod-warehouse': { driver: 'postgres', url: 'postgresql://readonly@db.test/analytics' },
|
||||
},
|
||||
}),
|
||||
'klo',
|
||||
'klo@example.com',
|
||||
'Seed yaml mappings',
|
||||
);
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnectionMapping(
|
||||
{ command: 'list', projectDir, connectionId: 'prod-metabase', json: false },
|
||||
io.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(io.stdout()).toContain('1 -> prod-warehouse');
|
||||
expect(io.stdout()).toContain('source: klo.yaml');
|
||||
});
|
||||
|
||||
it('refreshes Metabase discovery metadata through the injected runtime client', async () => {
|
||||
const client = {
|
||||
getDatabases: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Analytics',
|
||||
engine: 'postgres',
|
||||
details: { host: 'pg.internal', dbname: 'analytics' },
|
||||
is_sample: false,
|
||||
},
|
||||
]),
|
||||
cleanup: vi.fn(),
|
||||
};
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnectionMapping(
|
||||
{
|
||||
command: 'refresh',
|
||||
projectDir,
|
||||
connectionId: 'prod-metabase',
|
||||
autoAccept: true,
|
||||
},
|
||||
io.io,
|
||||
{
|
||||
createMetabaseClient: async () => client as never,
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(io.stdout()).toContain('Discovery: 1 database');
|
||||
expect(client.cleanup).toHaveBeenCalledTimes(1);
|
||||
const store = new LocalMetabaseSourceStateReader({ dbPath: join(projectDir, '.klo', 'db.sqlite') });
|
||||
await expect(store.listDatabaseMappings('prod-metabase')).resolves.toMatchObject([
|
||||
{ metabaseDatabaseId: 1, metabaseDatabaseName: 'Analytics', source: 'refresh' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('sets and lists Looker connection mappings', async () => {
|
||||
await replaceConnections({
|
||||
'prod-looker': {
|
||||
driver: 'looker',
|
||||
base_url: 'https://looker.example.test',
|
||||
client_id: 'id',
|
||||
},
|
||||
'prod-warehouse': {
|
||||
driver: 'postgres',
|
||||
url: 'postgresql://readonly@db.example.test/analytics',
|
||||
},
|
||||
});
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnectionMapping(
|
||||
{
|
||||
command: 'set',
|
||||
projectDir,
|
||||
connectionId: 'prod-looker',
|
||||
field: 'connectionMappings',
|
||||
key: 'analytics',
|
||||
value: 'prod-warehouse',
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
await expect(
|
||||
runKloConnectionMapping({ command: 'list', projectDir, connectionId: 'prod-looker', json: false }, io.io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(io.stdout()).toContain('analytics -> prod-warehouse');
|
||||
});
|
||||
|
||||
it('keeps driver-specific mapping field validation in the runner', async () => {
|
||||
await replaceConnections({
|
||||
'prod-looker': { driver: 'looker', base_url: 'https://looker.example.com' },
|
||||
warehouse: { driver: 'postgres', url: 'env:WAREHOUSE_URL' },
|
||||
});
|
||||
|
||||
const io = makeIo();
|
||||
await expect(
|
||||
runKloConnectionMapping(
|
||||
{
|
||||
command: 'set',
|
||||
projectDir,
|
||||
connectionId: 'prod-looker',
|
||||
field: 'databaseMappings',
|
||||
key: '1',
|
||||
value: 'warehouse',
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(io.stderr()).toContain('Looker mapping set requires connectionMappings');
|
||||
});
|
||||
|
||||
it('refreshes Looker mapping metadata and reports drift', async () => {
|
||||
await replaceConnections({
|
||||
'prod-looker': {
|
||||
driver: 'looker',
|
||||
base_url: 'https://looker.example.test',
|
||||
client_id: 'id',
|
||||
},
|
||||
'prod-warehouse': {
|
||||
driver: 'postgres',
|
||||
url: 'postgresql://readonly@db.example.test/analytics',
|
||||
},
|
||||
});
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnectionMapping(
|
||||
{ command: 'refresh', projectDir, connectionId: 'prod-looker', autoAccept: true },
|
||||
io.io,
|
||||
{
|
||||
createLookerClient: async () => ({
|
||||
listLookerConnections: async () => [
|
||||
{
|
||||
name: 'analytics',
|
||||
host: 'db.example.test',
|
||||
database: 'analytics',
|
||||
schema: null,
|
||||
dialect: 'postgres',
|
||||
},
|
||||
],
|
||||
cleanup: async () => {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(io.stdout()).toContain('Discovery: 1 connection');
|
||||
expect(io.stdout()).toContain('Unmapped discovered: 1');
|
||||
});
|
||||
|
||||
it('validates Looker mappings through the canonical local warehouse descriptor', async () => {
|
||||
const projectDir = await mkdtemp(join(tmpdir(), 'klo-cli-descriptor-validation-'));
|
||||
await initKloProject({ projectDir, projectName: 'descriptor-validation' });
|
||||
const project = await loadKloProject({ projectDir });
|
||||
await project.fileStore.writeFile(
|
||||
'klo.yaml',
|
||||
serializeKloProjectConfig({
|
||||
...project.config,
|
||||
connections: {
|
||||
'prod-looker': {
|
||||
driver: 'looker',
|
||||
mappings: { connectionMappings: { analytics: 'prod-warehouse' } },
|
||||
},
|
||||
'prod-warehouse': { driver: 'postgresql', url: 'postgresql://readonly@db.test/analytics' },
|
||||
},
|
||||
}),
|
||||
'klo',
|
||||
'klo@example.com',
|
||||
'Seed descriptor validation',
|
||||
);
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnectionMapping({ command: 'validate', projectDir, connectionId: 'prod-looker' }, io.io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(io.stdout()).toContain('Mapping validation passed: prod-looker');
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
});
|
||||
426
packages/cli/src/commands/connection-mapping.ts
Normal file
426
packages/cli/src/commands/connection-mapping.ts
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
import { readFile } from 'node:fs/promises';
|
||||
import { localConnectionToWarehouseDescriptor } from '@klo/context/connections';
|
||||
import {
|
||||
DEFAULT_METABASE_CLIENT_CONFIG,
|
||||
DefaultLookerConnectionClientFactory,
|
||||
DefaultMetabaseConnectionClientFactory,
|
||||
LocalLookerRuntimeStore,
|
||||
LocalMetabaseSourceStateReader,
|
||||
computeLookerMappingDrift,
|
||||
computeMetabaseMappingDrift,
|
||||
discoverLookerConnections,
|
||||
discoverMetabaseDatabases,
|
||||
lookerCredentialsFromLocalConnection,
|
||||
metabaseRuntimeConfigFromLocalConnection,
|
||||
seedLocalMappingStateFromKloYaml,
|
||||
validateLookerMappings,
|
||||
validateMappingPhysicalMatch,
|
||||
type LookerMappingClient,
|
||||
type MetabaseRuntimeClient,
|
||||
type MetabaseSyncMode,
|
||||
} from '@klo/context/ingest';
|
||||
import { type KloLocalProject, kloLocalStateDbPath, loadKloProject } from '@klo/context/project';
|
||||
import type { KloCliIo } from '../index.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
|
||||
profileMark('module:commands/connection-mapping');
|
||||
|
||||
export type KloConnectionMappingArgs =
|
||||
| { command: 'list'; projectDir: string; connectionId: string; json: boolean }
|
||||
| {
|
||||
command: 'set';
|
||||
projectDir: string;
|
||||
connectionId: string;
|
||||
field: 'databaseMappings' | 'connectionMappings';
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
| { command: 'apply-bulk'; projectDir: string; connectionId: string; filePath: string }
|
||||
| {
|
||||
command: 'set-sync-enabled';
|
||||
projectDir: string;
|
||||
connectionId: string;
|
||||
metabaseDatabaseId: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
| { command: 'sync-state-get'; projectDir: string; connectionId: string; json: boolean }
|
||||
| {
|
||||
command: 'sync-state-set';
|
||||
projectDir: string;
|
||||
connectionId: string;
|
||||
syncMode: MetabaseSyncMode;
|
||||
collectionIds: number[];
|
||||
itemIds: number[];
|
||||
tagNames: string[];
|
||||
}
|
||||
| { command: 'refresh'; projectDir: string; connectionId: string; autoAccept: boolean }
|
||||
| { command: 'validate'; projectDir: string; connectionId: string }
|
||||
| { command: 'clear'; projectDir: string; connectionId: string; metabaseDatabaseId?: number; mappingKey?: string };
|
||||
|
||||
interface KloConnectionMappingDeps {
|
||||
createMetabaseClient?: (
|
||||
project: KloLocalProject,
|
||||
connectionId: string,
|
||||
) => Promise<Pick<MetabaseRuntimeClient, 'getDatabases' | 'cleanup'>>;
|
||||
createLookerClient?: (
|
||||
project: KloLocalProject,
|
||||
connectionId: string,
|
||||
) => Promise<Pick<LookerMappingClient, 'listLookerConnections'> & { cleanup?(): Promise<void> }>;
|
||||
}
|
||||
|
||||
interface MetabaseBulkMappingPayload {
|
||||
databaseMappings?: Record<string, string | null>;
|
||||
syncEnabled?: Record<string, boolean>;
|
||||
syncMode?: MetabaseSyncMode;
|
||||
selections?: { collections?: number[]; items?: number[] };
|
||||
defaultTagNames?: string[];
|
||||
}
|
||||
|
||||
function parseId(value: string, label: string): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
throw new Error(`${label} must be a positive integer`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function createDefaultMetabaseClient(
|
||||
project: KloLocalProject,
|
||||
connectionId: string,
|
||||
): Promise<Pick<MetabaseRuntimeClient, 'getDatabases' | 'cleanup'>> {
|
||||
const factory = new DefaultMetabaseConnectionClientFactory(
|
||||
(metabaseConnectionId) =>
|
||||
metabaseRuntimeConfigFromLocalConnection(metabaseConnectionId, project.config.connections[metabaseConnectionId]),
|
||||
DEFAULT_METABASE_CLIENT_CONFIG,
|
||||
);
|
||||
return factory.createClient(connectionId);
|
||||
}
|
||||
|
||||
async function createDefaultLookerClient(
|
||||
project: KloLocalProject,
|
||||
connectionId: string,
|
||||
): Promise<Pick<LookerMappingClient, 'listLookerConnections'> & { cleanup?(): Promise<void> }> {
|
||||
const factory = new DefaultLookerConnectionClientFactory({
|
||||
async resolve(lookerConnectionId) {
|
||||
return lookerCredentialsFromLocalConnection(lookerConnectionId, project.config.connections[lookerConnectionId]);
|
||||
},
|
||||
});
|
||||
return factory.createClient(connectionId) as unknown as Pick<LookerMappingClient, 'listLookerConnections'> & {
|
||||
cleanup?(): Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
function isLookerConnection(project: KloLocalProject, connectionId: string): boolean {
|
||||
return String(project.config.connections[connectionId]?.driver ?? '').toLowerCase() === 'looker';
|
||||
}
|
||||
|
||||
function assertLookerConnection(project: KloLocalProject, connectionId: string): void {
|
||||
if (!isLookerConnection(project, connectionId)) {
|
||||
throw new Error(`Connection "${connectionId}" is not a Looker connection`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertMetabaseConnection(project: KloLocalProject, connectionId: string): void {
|
||||
const connection = project.config.connections[connectionId];
|
||||
if (!connection || String(connection.driver).toLowerCase() !== 'metabase') {
|
||||
throw new Error(`Connection "${connectionId}" is not a Metabase connection`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertTargetConnection(project: KloLocalProject, connectionId: string): void {
|
||||
if (!project.config.connections[connectionId]) {
|
||||
throw new Error(`Target connection "${connectionId}" does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
function targetPhysicalInfo(project: KloLocalProject, connectionId: string) {
|
||||
const descriptor = localConnectionToWarehouseDescriptor(connectionId, project.config.connections[connectionId]);
|
||||
if (!descriptor) {
|
||||
return { connection_type: 'UNKNOWN' };
|
||||
}
|
||||
return {
|
||||
connection_type: descriptor.connection_type,
|
||||
host: descriptor.host ?? null,
|
||||
database: descriptor.database ?? null,
|
||||
account: descriptor.account ?? null,
|
||||
project_id: descriptor.project_id ?? null,
|
||||
dataset_id: descriptor.dataset_id ?? null,
|
||||
...descriptor.connection_params,
|
||||
};
|
||||
}
|
||||
|
||||
function renderMapping(
|
||||
row: Awaited<ReturnType<LocalMetabaseSourceStateReader['listDatabaseMappings']>>[number],
|
||||
): string {
|
||||
const name = row.metabaseDatabaseName ?? 'unhydrated';
|
||||
const target = row.targetConnectionId ?? '[unmapped]';
|
||||
return `${row.metabaseDatabaseId} -> ${target} (${name}, sync: ${row.syncEnabled ? 'on' : 'off'}, source: ${
|
||||
row.source
|
||||
})`;
|
||||
}
|
||||
|
||||
function renderLookerMapping(row: Awaited<ReturnType<LocalLookerRuntimeStore['listConnectionMappings']>>[number]): string {
|
||||
const target = row.kloConnectionId ?? '[unmapped]';
|
||||
const metadata = [row.lookerDialect, row.lookerHost, row.lookerDatabase].filter(Boolean).join(', ');
|
||||
return `${row.lookerConnectionName} -> ${target}${metadata ? ` (${metadata}, source: ${row.source})` : ` (source: ${row.source})`}`;
|
||||
}
|
||||
|
||||
export async function runKloConnectionMapping(
|
||||
args: KloConnectionMappingArgs,
|
||||
io: KloCliIo = process,
|
||||
deps: KloConnectionMappingDeps = {},
|
||||
): Promise<number> {
|
||||
try {
|
||||
const project = await loadKloProject({ projectDir: args.projectDir });
|
||||
await seedLocalMappingStateFromKloYaml(project, args.connectionId);
|
||||
if (isLookerConnection(project, args.connectionId)) {
|
||||
assertLookerConnection(project, args.connectionId);
|
||||
const store = new LocalLookerRuntimeStore({ dbPath: kloLocalStateDbPath(project) });
|
||||
|
||||
if (args.command === 'list') {
|
||||
const rows = await store.listConnectionMappings(args.connectionId);
|
||||
io.stdout.write(args.json ? `${JSON.stringify(rows, null, 2)}\n` : `${rows.map(renderLookerMapping).join('\n')}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'set') {
|
||||
if (args.field !== 'connectionMappings') {
|
||||
throw new Error('Looker mapping set requires connectionMappings <lookerConnectionName>=<targetConnectionId>');
|
||||
}
|
||||
assertTargetConnection(project, args.value);
|
||||
await store.upsertConnectionMapping({
|
||||
lookerConnectionId: args.connectionId,
|
||||
lookerConnectionName: args.key,
|
||||
kloConnectionId: args.value,
|
||||
source: 'cli',
|
||||
});
|
||||
io.stdout.write(`Set connectionMappings.${args.key} = ${args.value}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'refresh') {
|
||||
const client = await (deps.createLookerClient ?? createDefaultLookerClient)(project, args.connectionId);
|
||||
try {
|
||||
const discovered = await discoverLookerConnections(client);
|
||||
const drift = computeLookerMappingDrift({
|
||||
storedMappings: await store.readMappings(args.connectionId),
|
||||
discovered,
|
||||
});
|
||||
if (args.autoAccept) {
|
||||
await store.refreshDiscoveredConnections({ lookerConnectionId: args.connectionId, discovered });
|
||||
}
|
||||
io.stdout.write(`Discovery: ${discovered.length} ${discovered.length === 1 ? 'connection' : 'connections'}\n`);
|
||||
io.stdout.write(`Unmapped discovered: ${drift.unmappedDiscovered.length}\n`);
|
||||
io.stdout.write(`Stale mappings: ${drift.staleMappings.length}\n`);
|
||||
return 0;
|
||||
} finally {
|
||||
await client.cleanup?.();
|
||||
}
|
||||
}
|
||||
|
||||
if (args.command === 'validate') {
|
||||
const knownKloConnectionIds = new Set(Object.keys(project.config.connections));
|
||||
const knownConnectionTypes = new Map(
|
||||
Object.entries(project.config.connections).map(([id, _config]) => [id, targetPhysicalInfo(project, id).connection_type]),
|
||||
);
|
||||
const validation = validateLookerMappings({
|
||||
mappings: await store.readMappings(args.connectionId),
|
||||
knownKloConnectionIds,
|
||||
knownConnectionTypes,
|
||||
});
|
||||
if (!validation.ok) {
|
||||
for (const error of validation.errors) {
|
||||
io.stderr.write(`${error.key}: ${error.reason}\n`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
io.stdout.write(`Mapping validation passed: ${args.connectionId}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'clear') {
|
||||
await store.clearConnectionMappings({
|
||||
lookerConnectionId: args.connectionId,
|
||||
lookerConnectionName: args.mappingKey ?? (args.metabaseDatabaseId ? String(args.metabaseDatabaseId) : undefined),
|
||||
});
|
||||
io.stdout.write(
|
||||
args.mappingKey
|
||||
? `Cleared connectionMappings.${args.mappingKey}\n`
|
||||
: `Cleared mappings for ${args.connectionId}\n`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
throw new Error(`Looker connection mapping does not support ${args.command}`);
|
||||
}
|
||||
|
||||
assertMetabaseConnection(project, args.connectionId);
|
||||
const store = new LocalMetabaseSourceStateReader({ dbPath: kloLocalStateDbPath(project) });
|
||||
|
||||
if (args.command === 'list') {
|
||||
const rows = await store.listDatabaseMappings(args.connectionId);
|
||||
io.stdout.write(args.json ? `${JSON.stringify(rows, null, 2)}\n` : `${rows.map(renderMapping).join('\n')}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'set') {
|
||||
assertTargetConnection(project, args.value);
|
||||
await store.upsertDatabaseMapping({
|
||||
connectionId: args.connectionId,
|
||||
metabaseDatabaseId: parseId(args.key, 'metabaseDatabaseId'),
|
||||
targetConnectionId: args.value,
|
||||
syncEnabled: true,
|
||||
source: 'cli',
|
||||
});
|
||||
io.stdout.write(`Set databaseMappings.${args.key} = ${args.value}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'apply-bulk') {
|
||||
const payload = JSON.parse(await readFile(args.filePath, 'utf8')) as MetabaseBulkMappingPayload;
|
||||
const existingState = await store.getSourceState(args.connectionId);
|
||||
const existingRows = await store.listDatabaseMappings(args.connectionId);
|
||||
const existingById = new Map(existingRows.map((row) => [row.metabaseDatabaseId, row]));
|
||||
const databaseMappings = payload.databaseMappings ?? {};
|
||||
for (const targetConnectionId of Object.values(databaseMappings)) {
|
||||
if (targetConnectionId) {
|
||||
assertTargetConnection(project, targetConnectionId);
|
||||
}
|
||||
}
|
||||
const mappingIds = new Set([
|
||||
...existingRows.map((row) => row.metabaseDatabaseId),
|
||||
...Object.keys(databaseMappings).map((id) => parseId(id, 'metabaseDatabaseId')),
|
||||
...Object.keys(payload.syncEnabled ?? {}).map((id) => parseId(id, 'metabaseDatabaseId')),
|
||||
]);
|
||||
await store.replaceSourceState({
|
||||
connectionId: args.connectionId,
|
||||
syncMode: payload.syncMode ?? existingState.syncMode,
|
||||
defaultTagNames: payload.defaultTagNames ?? existingState.defaultTagNames,
|
||||
selections:
|
||||
payload.selections === undefined
|
||||
? existingState.selections
|
||||
: [
|
||||
...(payload.selections.collections ?? []).map((id) => ({
|
||||
selectionType: 'collection' as const,
|
||||
metabaseObjectId: id,
|
||||
})),
|
||||
...(payload.selections.items ?? []).map((id) => ({
|
||||
selectionType: 'item' as const,
|
||||
metabaseObjectId: id,
|
||||
})),
|
||||
],
|
||||
mappings: [...mappingIds]
|
||||
.sort((a, b) => a - b)
|
||||
.map((id) => {
|
||||
const existing = existingById.get(id);
|
||||
return {
|
||||
metabaseDatabaseId: id,
|
||||
metabaseDatabaseName: existing?.metabaseDatabaseName ?? null,
|
||||
metabaseEngine: existing?.metabaseEngine ?? null,
|
||||
metabaseHost: existing?.metabaseHost ?? null,
|
||||
metabaseDbName: existing?.metabaseDbName ?? null,
|
||||
targetConnectionId: databaseMappings[String(id)] ?? existing?.targetConnectionId ?? null,
|
||||
syncEnabled: payload.syncEnabled?.[String(id)] ?? existing?.syncEnabled ?? false,
|
||||
source: 'cli',
|
||||
};
|
||||
}),
|
||||
});
|
||||
io.stdout.write(`Applied bulk mappings for ${args.connectionId}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'set-sync-enabled') {
|
||||
await store.setMappingSyncEnabled({
|
||||
connectionId: args.connectionId,
|
||||
metabaseDatabaseId: args.metabaseDatabaseId,
|
||||
syncEnabled: args.enabled,
|
||||
});
|
||||
io.stdout.write(`Set syncEnabled.${args.metabaseDatabaseId} = ${args.enabled}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'sync-state-get') {
|
||||
const state = await store.getSourceState(args.connectionId);
|
||||
const payload = {
|
||||
syncMode: state.syncMode,
|
||||
selections: state.selections,
|
||||
defaultTagNames: state.defaultTagNames,
|
||||
};
|
||||
io.stdout.write(args.json ? `${JSON.stringify(payload, null, 2)}\n` : `${payload.syncMode}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'sync-state-set') {
|
||||
await store.setSyncState({
|
||||
connectionId: args.connectionId,
|
||||
syncMode: args.syncMode,
|
||||
defaultTagNames: args.tagNames,
|
||||
selections: [
|
||||
...args.collectionIds.map((id) => ({ selectionType: 'collection' as const, metabaseObjectId: id })),
|
||||
...args.itemIds.map((id) => ({ selectionType: 'item' as const, metabaseObjectId: id })),
|
||||
],
|
||||
});
|
||||
io.stdout.write(`Set sync state for ${args.connectionId}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'refresh') {
|
||||
const client = await (deps.createMetabaseClient ?? createDefaultMetabaseClient)(project, args.connectionId);
|
||||
try {
|
||||
const discovered = await discoverMetabaseDatabases(client);
|
||||
const existing = Object.fromEntries(
|
||||
(await store.listDatabaseMappings(args.connectionId)).map((row) => [
|
||||
String(row.metabaseDatabaseId),
|
||||
row.targetConnectionId,
|
||||
]),
|
||||
);
|
||||
const drift = computeMetabaseMappingDrift({ currentMappings: existing, discovered });
|
||||
if (args.autoAccept) {
|
||||
await store.refreshDiscoveredDatabases({ connectionId: args.connectionId, discovered });
|
||||
}
|
||||
io.stdout.write(`Discovery: ${discovered.length} ${discovered.length === 1 ? 'database' : 'databases'}\n`);
|
||||
io.stdout.write(`Unmapped discovered: ${drift.unmappedDiscovered.length}\n`);
|
||||
io.stdout.write(`Stale mappings: ${drift.staleMappings.length}\n`);
|
||||
return 0;
|
||||
} finally {
|
||||
await client.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
if (args.command === 'validate') {
|
||||
const rows = await store.listDatabaseMappings(args.connectionId);
|
||||
const failures = rows.flatMap((row) => {
|
||||
if (!row.targetConnectionId) {
|
||||
return [];
|
||||
}
|
||||
const reason = validateMappingPhysicalMatch(
|
||||
{ metabaseEngine: row.metabaseEngine, metabaseDbName: row.metabaseDbName, metabaseHost: row.metabaseHost },
|
||||
project.config.connections[row.targetConnectionId]
|
||||
? targetPhysicalInfo(project, row.targetConnectionId)
|
||||
: { connection_type: 'UNKNOWN' },
|
||||
);
|
||||
return reason ? [`${row.metabaseDatabaseId}: ${reason}`] : [];
|
||||
});
|
||||
if (failures.length > 0) {
|
||||
for (const failure of failures) {
|
||||
io.stderr.write(`${failure}\n`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
io.stdout.write(`Mapping validation passed: ${args.connectionId}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const metabaseDatabaseId = args.metabaseDatabaseId ?? (args.mappingKey ? parseId(args.mappingKey, 'metabaseDatabaseId') : undefined);
|
||||
await store.clearDatabaseMappings({ connectionId: args.connectionId, metabaseDatabaseId });
|
||||
io.stdout.write(
|
||||
metabaseDatabaseId
|
||||
? `Cleared databaseMappings.${metabaseDatabaseId}\n`
|
||||
: `Cleared mappings for ${args.connectionId}\n`,
|
||||
);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
132
packages/cli/src/commands/connection-metabase-commands.ts
Normal file
132
packages/cli/src/commands/connection-metabase-commands.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { type Command, Option } from '@commander-js/extra-typings';
|
||||
|
||||
import {
|
||||
type KloCliCommandContext,
|
||||
parseNonEmptyAssignmentOption,
|
||||
parsePositiveIntegerOption,
|
||||
parseSafeConnectionIdOption,
|
||||
resolveCommandProjectDir,
|
||||
} from '../cli-program.js';
|
||||
import {
|
||||
type KloConnectionMetabaseSetupArgs,
|
||||
type MetabaseSetupMappingAssignment,
|
||||
type MetabaseSetupSyncMode,
|
||||
runKloConnectionMetabaseSetup,
|
||||
} from './connection-metabase-setup.js';
|
||||
|
||||
const SYNC_MODE_CHOICES = ['ALL', 'ONLY', 'EXCEPT'] as const satisfies readonly MetabaseSetupSyncMode[];
|
||||
|
||||
interface ConnectionMetabaseSetupOptions {
|
||||
id?: string;
|
||||
url?: string;
|
||||
apiKey?: string;
|
||||
mintApiKey?: boolean;
|
||||
username?: string;
|
||||
password?: string;
|
||||
map: MetabaseSetupMappingAssignment[];
|
||||
sync: number[];
|
||||
syncMode: MetabaseSetupSyncMode;
|
||||
runIngest?: boolean;
|
||||
yes?: boolean;
|
||||
input?: boolean;
|
||||
}
|
||||
|
||||
function collectPositiveIntegerOption(value: string, previous: number[] = []): number[] {
|
||||
return [...previous, parsePositiveIntegerOption(value)];
|
||||
}
|
||||
|
||||
function parseMappingAssignment(value: string): MetabaseSetupMappingAssignment {
|
||||
const assignment = parseNonEmptyAssignmentOption(value);
|
||||
return {
|
||||
metabaseDatabaseId: parsePositiveIntegerOption(assignment.key),
|
||||
targetConnectionId: parseSafeConnectionIdOption(assignment.value),
|
||||
};
|
||||
}
|
||||
|
||||
function collectMappingOption(
|
||||
value: string,
|
||||
previous: MetabaseSetupMappingAssignment[] = [],
|
||||
): MetabaseSetupMappingAssignment[] {
|
||||
return [...previous, parseMappingAssignment(value)];
|
||||
}
|
||||
|
||||
async function runMetabaseSetupArgs(
|
||||
context: KloCliCommandContext,
|
||||
args: KloConnectionMetabaseSetupArgs,
|
||||
): Promise<void> {
|
||||
const runner = context.deps.connectionMetabaseSetup ?? runKloConnectionMetabaseSetup;
|
||||
context.setExitCode(await runner(args, context.io));
|
||||
}
|
||||
|
||||
export function registerConnectionMetabaseCommands(connection: Command, context: KloCliCommandContext): void {
|
||||
const metabase = connection
|
||||
.command('metabase')
|
||||
.description('Configure Metabase connections')
|
||||
.showHelpAfterError()
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\nProject directory defaults to KLO_PROJECT_DIR when set, otherwise the current working directory.\n',
|
||||
);
|
||||
|
||||
metabase.action(() => {
|
||||
metabase.outputHelp();
|
||||
context.setExitCode(0);
|
||||
});
|
||||
|
||||
metabase
|
||||
.command('setup')
|
||||
.description('Guided setup for a Metabase connection')
|
||||
.option('--id <connectionId>', 'KLO connection id to write', parseSafeConnectionIdOption)
|
||||
.option('--url <url>', 'Metabase API URL')
|
||||
.addOption(new Option('--api-key <key>', 'Metabase API key').conflicts('mintApiKey'))
|
||||
.option('--mint-api-key', 'Mint a Metabase API key with credentials', false)
|
||||
.option('--username <email>', 'Metabase admin username for API-key minting')
|
||||
.option('--password <password>', 'Metabase admin password for API-key minting')
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\nGuided equivalent of:\n' +
|
||||
' klo connection mapping refresh <connectionId> --auto-accept\n' +
|
||||
' klo connection mapping set <connectionId> databaseMappings <id>=<target>\n' +
|
||||
' klo connection mapping set-sync-enabled <connectionId> <id> --enabled true\n' +
|
||||
' klo ingest <connectionId>\n',
|
||||
)
|
||||
.option(
|
||||
'--map <metabaseDatabaseId=targetConnectionId>',
|
||||
'Assign a Metabase database id to a warehouse connection; repeatable',
|
||||
collectMappingOption,
|
||||
[],
|
||||
)
|
||||
.option(
|
||||
'--sync <metabaseDatabaseId>',
|
||||
'Enable Metabase sync for a discovered database; repeatable',
|
||||
collectPositiveIntegerOption,
|
||||
[],
|
||||
)
|
||||
.addOption(
|
||||
new Option('--sync-mode <mode>', 'Metabase sync selection mode')
|
||||
.choices(SYNC_MODE_CHOICES)
|
||||
.default('ALL' satisfies MetabaseSetupSyncMode),
|
||||
)
|
||||
.option('--run-ingest', 'Run ingest after setup', false)
|
||||
.option('--yes', 'Confirm and apply setup changes without prompting', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.showHelpAfterError()
|
||||
.action(async (options: ConnectionMetabaseSetupOptions, command) => {
|
||||
await runMetabaseSetupArgs(context, {
|
||||
command: 'setup',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.id,
|
||||
url: options.url,
|
||||
apiKey: options.apiKey,
|
||||
mintApiKey: options.mintApiKey === true,
|
||||
metabaseUsername: options.username,
|
||||
metabasePassword: options.password,
|
||||
mappings: options.map,
|
||||
syncEnabledDatabaseIds: options.sync,
|
||||
syncMode: options.syncMode ?? 'ALL',
|
||||
runIngest: options.runIngest === true,
|
||||
yes: options.yes === true,
|
||||
inputMode: options.input === false ? 'disabled' : 'auto',
|
||||
});
|
||||
});
|
||||
}
|
||||
1136
packages/cli/src/commands/connection-metabase-setup.test.ts
Normal file
1136
packages/cli/src/commands/connection-metabase-setup.test.ts
Normal file
File diff suppressed because it is too large
Load diff
782
packages/cli/src/commands/connection-metabase-setup.ts
Normal file
782
packages/cli/src/commands/connection-metabase-setup.ts
Normal file
|
|
@ -0,0 +1,782 @@
|
|||
import type { Option as ClackOption } from '@clack/prompts';
|
||||
import {
|
||||
cancel,
|
||||
confirm,
|
||||
intro,
|
||||
isCancel,
|
||||
log,
|
||||
multiselect,
|
||||
note,
|
||||
outro,
|
||||
password,
|
||||
select,
|
||||
text,
|
||||
} from '@clack/prompts';
|
||||
import { localConnectionToWarehouseDescriptor } from '@klo/context/connections';
|
||||
import {
|
||||
DEFAULT_METABASE_CLIENT_CONFIG,
|
||||
DefaultMetabaseConnectionClientFactory,
|
||||
LocalMetabaseSourceStateReader,
|
||||
MetabaseClient,
|
||||
type MetabaseDatabase,
|
||||
type MetabaseRuntimeClient,
|
||||
type MetabaseSyncMode,
|
||||
metabaseRuntimeConfigFromLocalConnection,
|
||||
validateMappingPhysicalMatch,
|
||||
} from '@klo/context/ingest';
|
||||
import {
|
||||
type KloLocalProject,
|
||||
type KloProjectConnectionConfig,
|
||||
kloLocalStateDbPath,
|
||||
loadKloProject,
|
||||
serializeKloProjectConfig,
|
||||
} from '@klo/context/project';
|
||||
|
||||
import { createClackSpinner, type KloCliSpinner } from '../clack.js';
|
||||
import type { KloCliIo } from '../cli-runtime.js';
|
||||
import { withMenuOptionsSpacing, withMultiselectNavigation } from '../prompt-navigation.js';
|
||||
import { type KloPublicIngestArgs, runKloPublicIngest } from '../public-ingest.js';
|
||||
|
||||
export type KloMetabaseSetupInputMode = 'auto' | 'disabled';
|
||||
|
||||
export type MetabaseSetupSyncMode = MetabaseSyncMode;
|
||||
|
||||
type MetabaseSetupPromptOption<Value> = ClackOption<Value>;
|
||||
|
||||
export interface MetabaseSetupLogger {
|
||||
info(message: string): void;
|
||||
step(message: string): void;
|
||||
success(message: string): void;
|
||||
warn(message: string): void;
|
||||
error(message: string): void;
|
||||
}
|
||||
|
||||
export interface MetabaseSetupPromptAdapter {
|
||||
intro(title?: string): void;
|
||||
outro(message?: string): void;
|
||||
note(message: string, title: string): void;
|
||||
log: MetabaseSetupLogger;
|
||||
spinner(): KloCliSpinner;
|
||||
select<T extends string>(options: { message: string; options: Array<MetabaseSetupPromptOption<T>> }): Promise<T>;
|
||||
multiselect<Value extends number | string>(options: {
|
||||
message: string;
|
||||
options: Array<MetabaseSetupPromptOption<Value>>;
|
||||
initialValues?: Value[];
|
||||
required?: boolean;
|
||||
maxItems?: number;
|
||||
}): Promise<Value[]>;
|
||||
text(options: { message: string; placeholder?: string }): Promise<string>;
|
||||
password(options: { message: string }): Promise<string>;
|
||||
confirm(options: { message: string; initialValue?: boolean }): Promise<boolean>;
|
||||
cancel(message: string): void;
|
||||
}
|
||||
|
||||
type KloMetabaseSetupInteractiveIo = KloCliIo & {
|
||||
stdin?: { isTTY?: boolean };
|
||||
};
|
||||
|
||||
export interface MetabaseSetupMappingAssignment {
|
||||
metabaseDatabaseId: number;
|
||||
targetConnectionId: string;
|
||||
}
|
||||
|
||||
export interface MintMetabaseApiKeyArgs {
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type MintMetabaseApiKey = (args: MintMetabaseApiKeyArgs, io: KloCliIo) => Promise<string>;
|
||||
|
||||
export interface KloConnectionMetabaseSetupArgs {
|
||||
command: 'setup';
|
||||
projectDir: string;
|
||||
connectionId?: string;
|
||||
url?: string;
|
||||
apiKey?: string;
|
||||
mintApiKey: boolean;
|
||||
metabaseUsername?: string;
|
||||
metabasePassword?: string;
|
||||
mappings: MetabaseSetupMappingAssignment[];
|
||||
syncEnabledDatabaseIds: number[];
|
||||
syncMode: MetabaseSetupSyncMode;
|
||||
runIngest: boolean;
|
||||
yes: boolean;
|
||||
inputMode: KloMetabaseSetupInputMode;
|
||||
}
|
||||
|
||||
export interface KloConnectionMetabaseSetupDeps {
|
||||
createMetabaseClient?: (
|
||||
project: KloLocalProject,
|
||||
connectionId: string,
|
||||
) => Promise<Pick<MetabaseRuntimeClient, 'testConnection' | 'getDatabases' | 'cleanup'>>;
|
||||
mintMetabaseApiKey?: MintMetabaseApiKey;
|
||||
prompts?: MetabaseSetupPromptAdapter;
|
||||
runPublicIngest?: (args: Extract<KloPublicIngestArgs, { command: 'run' }>, io: KloCliIo) => Promise<number>;
|
||||
}
|
||||
|
||||
function isMetabaseConnection(connection: KloProjectConnectionConfig | undefined): boolean {
|
||||
return (
|
||||
String(connection?.driver ?? '')
|
||||
.trim()
|
||||
.toLowerCase() === 'metabase'
|
||||
);
|
||||
}
|
||||
|
||||
function stringField(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function uniqueSorted(values: number[]): number[] {
|
||||
return [...new Set(values)].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function resolveMetabaseUrl(connection: KloProjectConnectionConfig | undefined): string | undefined {
|
||||
return stringField(connection?.api_url) ?? stringField(connection?.apiUrl) ?? stringField(connection?.url);
|
||||
}
|
||||
|
||||
function resolveLiteralMetabaseApiKey(connection: KloProjectConnectionConfig | undefined): string | undefined {
|
||||
return stringField(connection?.api_key) ?? stringField(connection?.apiKey);
|
||||
}
|
||||
|
||||
function listMetabaseConnectionIds(project: KloLocalProject): string[] {
|
||||
return Object.entries(project.config.connections)
|
||||
.filter(([_connectionId, connection]) => isMetabaseConnection(connection))
|
||||
.map(([connectionId]) => connectionId)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function listWarehouseConnectionIds(project: KloLocalProject): string[] {
|
||||
return Object.entries(project.config.connections)
|
||||
.filter(([connectionId, connection]) => localConnectionToWarehouseDescriptor(connectionId, connection) != null)
|
||||
.map(([connectionId]) => connectionId)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function redactSecrets(message: string, secrets: string[]): string {
|
||||
let result = message;
|
||||
for (const secret of secrets) {
|
||||
if (!secret) {
|
||||
continue;
|
||||
}
|
||||
result = result.split(secret).join('[redacted]');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function createDefaultMetabaseClient(
|
||||
project: KloLocalProject,
|
||||
connectionId: string,
|
||||
): Promise<Pick<MetabaseRuntimeClient, 'testConnection' | 'getDatabases' | 'cleanup'>> {
|
||||
const factory = new DefaultMetabaseConnectionClientFactory(
|
||||
(metabaseConnectionId) =>
|
||||
metabaseRuntimeConfigFromLocalConnection(metabaseConnectionId, project.config.connections[metabaseConnectionId]),
|
||||
DEFAULT_METABASE_CLIENT_CONFIG,
|
||||
);
|
||||
return factory.createClient(connectionId);
|
||||
}
|
||||
|
||||
async function defaultMintMetabaseApiKey(args: MintMetabaseApiKeyArgs): Promise<string> {
|
||||
const loginClient = new MetabaseClient({ apiUrl: args.url, apiKey: '' }, DEFAULT_METABASE_CLIENT_CONFIG);
|
||||
const sessionId = await loginClient.createSession(args.username, args.password);
|
||||
const sessionClient = new MetabaseClient(
|
||||
{ apiUrl: args.url, apiKey: sessionId, authHeaderName: 'X-Metabase-Session' },
|
||||
DEFAULT_METABASE_CLIENT_CONFIG,
|
||||
);
|
||||
const groups = await sessionClient.getPermissionGroups();
|
||||
const adminGroup = groups.find((group) => group.name === 'Administrators');
|
||||
|
||||
if (!adminGroup) {
|
||||
throw new Error('Metabase Administrators group was not found; create an API key manually and pass --api-key');
|
||||
}
|
||||
|
||||
const mintedKey = await sessionClient.createApiKey({
|
||||
groupId: adminGroup.id,
|
||||
name: `KLO CLI ${new Date().toISOString()}`,
|
||||
});
|
||||
const trimmedKey = stringField(mintedKey);
|
||||
if (!trimmedKey) {
|
||||
throw new Error('Metabase API key minting returned an empty key');
|
||||
}
|
||||
return trimmedKey;
|
||||
}
|
||||
|
||||
function ensureNotCancelled<T>(value: T | symbol, prompts: Pick<MetabaseSetupPromptAdapter, 'cancel'>): T {
|
||||
if (isCancel(value)) {
|
||||
prompts.cancel('Setup cancelled.');
|
||||
throw new Error('Setup cancelled.');
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
export function createClackMetabaseSetupPromptAdapter(): MetabaseSetupPromptAdapter {
|
||||
return {
|
||||
intro(title?: string): void {
|
||||
intro(title);
|
||||
},
|
||||
outro(message?: string): void {
|
||||
outro(message);
|
||||
},
|
||||
note(message: string, title: string): void {
|
||||
note(message, title);
|
||||
},
|
||||
log: {
|
||||
info(message: string): void {
|
||||
log.info(message);
|
||||
},
|
||||
step(message: string): void {
|
||||
log.step(message);
|
||||
},
|
||||
success(message: string): void {
|
||||
log.success(message);
|
||||
},
|
||||
warn(message: string): void {
|
||||
log.warn(message);
|
||||
},
|
||||
error(message: string): void {
|
||||
log.error(message);
|
||||
},
|
||||
},
|
||||
spinner(): KloCliSpinner {
|
||||
return createClackSpinner();
|
||||
},
|
||||
async select<T extends string>(options: {
|
||||
message: string;
|
||||
options: Array<MetabaseSetupPromptOption<T>>;
|
||||
}): Promise<T> {
|
||||
return ensureNotCancelled(await select(withMenuOptionsSpacing(options)), this);
|
||||
},
|
||||
async multiselect<Value extends number | string>(options: {
|
||||
message: string;
|
||||
options: Array<MetabaseSetupPromptOption<Value>>;
|
||||
initialValues?: Value[];
|
||||
required?: boolean;
|
||||
maxItems?: number;
|
||||
}): Promise<Value[]> {
|
||||
return ensureNotCancelled(await multiselect(withMenuOptionsSpacing(options)), this);
|
||||
},
|
||||
async text(options: { message: string; placeholder?: string }): Promise<string> {
|
||||
return ensureNotCancelled(await text(options), this);
|
||||
},
|
||||
async password(options: { message: string }): Promise<string> {
|
||||
return ensureNotCancelled(await password(options), this);
|
||||
},
|
||||
async confirm(options: { message: string; initialValue?: boolean }): Promise<boolean> {
|
||||
return ensureNotCancelled(await confirm(options), this);
|
||||
},
|
||||
cancel(message: string): void {
|
||||
cancel(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isInteractiveMetabaseSetupIo(
|
||||
args: Pick<KloConnectionMetabaseSetupArgs, 'inputMode'>,
|
||||
io: KloMetabaseSetupInteractiveIo,
|
||||
): boolean {
|
||||
return args.inputMode !== 'disabled' && io.stdin?.isTTY === true && io.stdout.isTTY === true;
|
||||
}
|
||||
|
||||
function normalizeDiscoveredDatabases(databases: MetabaseDatabase[]): Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
engine: string;
|
||||
host: string | null;
|
||||
dbName: string | null;
|
||||
}> {
|
||||
return databases
|
||||
.filter((database) => database.is_sample !== true)
|
||||
.map((database) => ({
|
||||
id: database.id,
|
||||
name: database.name,
|
||||
engine: stringField(database.engine) ?? 'unknown',
|
||||
host: stringField(database.details?.host) ?? null,
|
||||
dbName: stringField(database.details?.dbname) ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function targetPhysicalInfo(project: KloLocalProject, connectionId: string) {
|
||||
const descriptor = localConnectionToWarehouseDescriptor(connectionId, project.config.connections[connectionId]);
|
||||
if (!descriptor) {
|
||||
return { connection_type: 'UNKNOWN' };
|
||||
}
|
||||
return {
|
||||
connection_type: descriptor.connection_type,
|
||||
host: descriptor.host ?? null,
|
||||
database: descriptor.database ?? null,
|
||||
account: descriptor.account ?? null,
|
||||
project_id: descriptor.project_id ?? null,
|
||||
dataset_id: descriptor.dataset_id ?? null,
|
||||
...descriptor.connection_params,
|
||||
};
|
||||
}
|
||||
|
||||
function noteMetabaseSetupSummary(options: {
|
||||
prompts: MetabaseSetupPromptAdapter;
|
||||
connectionId: string;
|
||||
url: string;
|
||||
mappings: MetabaseSetupMappingAssignment[];
|
||||
syncEnabledDatabaseIds: number[];
|
||||
}): void {
|
||||
const mappingLines = options.mappings
|
||||
.map((mapping) => ` ${mapping.metabaseDatabaseId} -> ${mapping.targetConnectionId}`)
|
||||
.join('\n');
|
||||
const syncLines = options.syncEnabledDatabaseIds.map((id) => ` ${id}`).join('\n');
|
||||
|
||||
options.prompts.note(
|
||||
[
|
||||
`Connection: ${options.connectionId}`,
|
||||
`URL: ${options.url}`,
|
||||
'',
|
||||
'Mappings:',
|
||||
mappingLines || ' (none)',
|
||||
'',
|
||||
'Sync enabled:',
|
||||
syncLines || ' (none)',
|
||||
].join('\n'),
|
||||
'Summary',
|
||||
);
|
||||
}
|
||||
|
||||
export async function runKloConnectionMetabaseSetup(
|
||||
args: KloConnectionMetabaseSetupArgs,
|
||||
io: KloCliIo,
|
||||
deps: KloConnectionMetabaseSetupDeps = {},
|
||||
): Promise<number> {
|
||||
let apiKeyForRedaction = args.apiKey;
|
||||
let passwordForRedaction = args.metabasePassword;
|
||||
const interactiveIo = io as KloMetabaseSetupInteractiveIo;
|
||||
const isInteractive = isInteractiveMetabaseSetupIo(args, interactiveIo);
|
||||
const prompts = deps.prompts ?? (isInteractive ? createClackMetabaseSetupPromptAdapter() : undefined);
|
||||
|
||||
try {
|
||||
if (isInteractive && prompts) {
|
||||
prompts.intro('KLO Metabase setup');
|
||||
}
|
||||
|
||||
const project = await loadKloProject({ projectDir: args.projectDir });
|
||||
const existingMetabaseConnectionIds = listMetabaseConnectionIds(project);
|
||||
let connectionId: string;
|
||||
|
||||
if (args.connectionId) {
|
||||
connectionId = args.connectionId;
|
||||
} else if (existingMetabaseConnectionIds.length === 1) {
|
||||
const onlyMetabaseConnectionId = existingMetabaseConnectionIds[0];
|
||||
if (!onlyMetabaseConnectionId) {
|
||||
throw new Error('No Metabase connection id was resolved');
|
||||
}
|
||||
connectionId = onlyMetabaseConnectionId;
|
||||
} else if (existingMetabaseConnectionIds.length > 1) {
|
||||
if (!isInteractive || !prompts) {
|
||||
throw new Error(
|
||||
`Multiple Metabase connections found (${existingMetabaseConnectionIds.join(', ')}); select one with --id`,
|
||||
);
|
||||
}
|
||||
connectionId = await prompts.select({
|
||||
message: 'Select the Metabase connection to configure',
|
||||
options: existingMetabaseConnectionIds.map((id) => ({ value: id, label: id })),
|
||||
});
|
||||
} else {
|
||||
connectionId = 'metabase';
|
||||
}
|
||||
|
||||
const existingConnection = project.config.connections[connectionId];
|
||||
const warehouseConnectionIds = listWarehouseConnectionIds(project);
|
||||
|
||||
if (warehouseConnectionIds.length === 0) {
|
||||
throw new Error('Add a warehouse connection first');
|
||||
}
|
||||
|
||||
let url = args.url ?? resolveMetabaseUrl(existingConnection);
|
||||
let apiKey = args.apiKey ?? resolveLiteralMetabaseApiKey(existingConnection);
|
||||
apiKeyForRedaction = apiKey;
|
||||
|
||||
if (!url && isInteractive && prompts) {
|
||||
url = stringField(
|
||||
await prompts.text({
|
||||
message: 'Metabase API URL',
|
||||
placeholder: 'http://localhost:3000',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (args.inputMode === 'disabled' && !url) {
|
||||
throw new Error('missing Metabase URL');
|
||||
}
|
||||
|
||||
if (!args.apiKey && !args.mintApiKey && apiKey && isInteractive && prompts && !args.yes) {
|
||||
const reuse = await prompts.confirm({
|
||||
message: `Reuse the existing Metabase API key from connections.${connectionId}?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (!reuse) {
|
||||
apiKey = undefined;
|
||||
apiKeyForRedaction = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (args.mintApiKey) {
|
||||
let username = stringField(args.metabaseUsername);
|
||||
let metabasePassword = stringField(args.metabasePassword);
|
||||
|
||||
if (isInteractive && prompts) {
|
||||
if (!username) {
|
||||
username = stringField(await prompts.text({ message: 'Metabase admin username' }));
|
||||
}
|
||||
if (!metabasePassword) {
|
||||
metabasePassword = stringField(await prompts.password({ message: 'Metabase admin password' }));
|
||||
}
|
||||
}
|
||||
|
||||
if (!username) {
|
||||
throw new Error('--mint-api-key requires --username');
|
||||
}
|
||||
if (!metabasePassword) {
|
||||
throw new Error('--mint-api-key requires --password');
|
||||
}
|
||||
if (!url) {
|
||||
throw new Error('Metabase URL is required (use --url)');
|
||||
}
|
||||
|
||||
passwordForRedaction = metabasePassword;
|
||||
apiKey = await (deps.mintMetabaseApiKey ?? defaultMintMetabaseApiKey)(
|
||||
{ url, username, password: metabasePassword },
|
||||
io,
|
||||
);
|
||||
apiKeyForRedaction = apiKey;
|
||||
}
|
||||
|
||||
if (!apiKey && isInteractive && prompts) {
|
||||
const credentialMode = await prompts.select({
|
||||
message: 'Metabase credentials',
|
||||
options: [
|
||||
{ value: 'paste', label: 'Paste API key' },
|
||||
{ value: 'mint', label: 'Mint API key' },
|
||||
],
|
||||
});
|
||||
|
||||
if (credentialMode === 'paste') {
|
||||
apiKey = stringField(await prompts.password({ message: 'Metabase API key' }));
|
||||
apiKeyForRedaction = apiKey;
|
||||
} else {
|
||||
const username = stringField(await prompts.text({ message: 'Metabase admin username' }));
|
||||
const metabasePassword = stringField(await prompts.password({ message: 'Metabase admin password' }));
|
||||
if (!username) {
|
||||
throw new Error('Metabase username is required');
|
||||
}
|
||||
if (!metabasePassword) {
|
||||
throw new Error('Metabase password is required');
|
||||
}
|
||||
if (!url) {
|
||||
throw new Error('Metabase URL is required (use --url)');
|
||||
}
|
||||
|
||||
passwordForRedaction = metabasePassword;
|
||||
apiKey = await (deps.mintMetabaseApiKey ?? defaultMintMetabaseApiKey)(
|
||||
{ url, username, password: metabasePassword },
|
||||
io,
|
||||
);
|
||||
apiKeyForRedaction = apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (args.inputMode === 'disabled' && !apiKey) {
|
||||
throw new Error('missing Metabase API key');
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
throw new Error('Metabase URL is required (use --url)');
|
||||
}
|
||||
if (!apiKey) {
|
||||
throw new Error('Metabase API key is required (use --api-key)');
|
||||
}
|
||||
|
||||
const transientConnectionConfig: KloProjectConnectionConfig = {
|
||||
...(existingConnection ?? {}),
|
||||
driver: 'metabase',
|
||||
api_url: url,
|
||||
api_key: apiKey,
|
||||
};
|
||||
const configWithTransient = {
|
||||
...project.config,
|
||||
connections: {
|
||||
...project.config.connections,
|
||||
[connectionId]: transientConnectionConfig,
|
||||
},
|
||||
};
|
||||
const discoveryProject: KloLocalProject = { ...project, config: configWithTransient };
|
||||
|
||||
for (const mapping of args.mappings) {
|
||||
if (!configWithTransient.connections[mapping.targetConnectionId]) {
|
||||
throw new Error(`Target connection "${mapping.targetConnectionId}" does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
const client = await (deps.createMetabaseClient ?? createDefaultMetabaseClient)(discoveryProject, connectionId);
|
||||
try {
|
||||
const authSpinner = isInteractive && prompts ? prompts.spinner() : undefined;
|
||||
authSpinner?.start('Testing Metabase connection');
|
||||
const testResult = await client.testConnection();
|
||||
if (!testResult.success) {
|
||||
authSpinner?.error('Metabase authentication failed');
|
||||
throw new Error(
|
||||
`Metabase authentication failed. Replace connections.${connectionId}.api_key or use --mint-api-key.`,
|
||||
);
|
||||
}
|
||||
authSpinner?.stop('Metabase reachable');
|
||||
|
||||
const discoverySpinner = isInteractive && prompts ? prompts.spinner() : undefined;
|
||||
discoverySpinner?.start('Discovering Metabase databases');
|
||||
const discovered = normalizeDiscoveredDatabases(await client.getDatabases());
|
||||
discoverySpinner?.stop(`Discovered ${discovered.length} ${discovered.length === 1 ? 'database' : 'databases'}`);
|
||||
if (isInteractive && prompts) {
|
||||
prompts.log.success(
|
||||
`Discovered ${discovered.length} ${discovered.length === 1 ? 'database' : 'databases'}`,
|
||||
);
|
||||
}
|
||||
if (discovered.length === 0) {
|
||||
throw new Error('Metabase auth worked but no usable databases were returned');
|
||||
}
|
||||
|
||||
let resolvedMappings = args.mappings;
|
||||
let resolvedSyncEnabledDatabaseIds = args.syncEnabledDatabaseIds;
|
||||
|
||||
if (resolvedSyncEnabledDatabaseIds.length === 0 && args.yes && resolvedMappings.length > 0) {
|
||||
resolvedSyncEnabledDatabaseIds = uniqueSorted(resolvedMappings.map((mapping) => mapping.metabaseDatabaseId));
|
||||
}
|
||||
|
||||
if (resolvedMappings.length === 0 && resolvedSyncEnabledDatabaseIds.length === 0) {
|
||||
const onlyDiscoveredDatabase = discovered.length === 1 ? discovered[0] : undefined;
|
||||
const compatibleWarehouses = onlyDiscoveredDatabase
|
||||
? warehouseConnectionIds.filter((warehouseConnectionId) => {
|
||||
const mismatchReason = validateMappingPhysicalMatch(
|
||||
{
|
||||
metabaseEngine: onlyDiscoveredDatabase.engine,
|
||||
metabaseDbName: onlyDiscoveredDatabase.dbName,
|
||||
metabaseHost: onlyDiscoveredDatabase.host,
|
||||
},
|
||||
targetPhysicalInfo(project, warehouseConnectionId),
|
||||
);
|
||||
return !mismatchReason;
|
||||
})
|
||||
: [];
|
||||
const onlyWarehouseConnectionId = compatibleWarehouses[0];
|
||||
|
||||
if (onlyDiscoveredDatabase && compatibleWarehouses.length === 1 && onlyWarehouseConnectionId) {
|
||||
if (args.yes) {
|
||||
resolvedMappings = [
|
||||
{ metabaseDatabaseId: onlyDiscoveredDatabase.id, targetConnectionId: onlyWarehouseConnectionId },
|
||||
];
|
||||
resolvedSyncEnabledDatabaseIds = [onlyDiscoveredDatabase.id];
|
||||
} else if (isInteractive && prompts) {
|
||||
const proposedMappings = [
|
||||
{ metabaseDatabaseId: onlyDiscoveredDatabase.id, targetConnectionId: onlyWarehouseConnectionId },
|
||||
];
|
||||
const proposedSyncEnabledDatabaseIds = [onlyDiscoveredDatabase.id];
|
||||
noteMetabaseSetupSummary({
|
||||
prompts,
|
||||
connectionId,
|
||||
url,
|
||||
mappings: proposedMappings,
|
||||
syncEnabledDatabaseIds: proposedSyncEnabledDatabaseIds,
|
||||
});
|
||||
const confirmed = await prompts.confirm({
|
||||
message: `Map Metabase database "${onlyDiscoveredDatabase.name}" (${onlyDiscoveredDatabase.id}) to "${onlyWarehouseConnectionId}" and enable sync?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
prompts.cancel('Setup cancelled.');
|
||||
throw new Error('Setup cancelled.');
|
||||
}
|
||||
resolvedMappings = proposedMappings;
|
||||
resolvedSyncEnabledDatabaseIds = proposedSyncEnabledDatabaseIds;
|
||||
} else {
|
||||
throw new Error('Metabase mapping/sync is required in --no-input mode; pass --map and --sync');
|
||||
}
|
||||
} else if (isInteractive && prompts) {
|
||||
const selectedDatabaseIds = await prompts.multiselect<number>({
|
||||
message: withMultiselectNavigation('Select Metabase databases to configure'),
|
||||
options: discovered.map((database) => ({
|
||||
value: database.id,
|
||||
label: `${database.id}: ${database.name}`,
|
||||
hint: [database.engine, database.host, database.dbName].filter(Boolean).join(' • '),
|
||||
})),
|
||||
required: true,
|
||||
});
|
||||
|
||||
resolvedMappings = [];
|
||||
for (const databaseId of selectedDatabaseIds) {
|
||||
const database = discovered.find((candidate) => candidate.id === databaseId);
|
||||
if (!database) {
|
||||
throw new Error(`Selected database id ${databaseId} was not discovered`);
|
||||
}
|
||||
|
||||
const existingMapping = args.mappings.find((mapping) => mapping.metabaseDatabaseId === databaseId);
|
||||
if (existingMapping) {
|
||||
resolvedMappings.push(existingMapping);
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetConnectionId = await prompts.select({
|
||||
message: `Map Metabase database ${database.id} ("${database.name}") to which KLO connection?`,
|
||||
options: warehouseConnectionIds.map((warehouseId) => ({ value: warehouseId, label: warehouseId })),
|
||||
});
|
||||
resolvedMappings.push({ metabaseDatabaseId: databaseId, targetConnectionId });
|
||||
}
|
||||
|
||||
const syncIds = await prompts.multiselect<number>({
|
||||
message: withMultiselectNavigation('Enable sync for which databases?'),
|
||||
options: selectedDatabaseIds.map((id) => ({ value: id, label: String(id) })),
|
||||
initialValues: selectedDatabaseIds,
|
||||
required: true,
|
||||
});
|
||||
resolvedSyncEnabledDatabaseIds = uniqueSorted(syncIds);
|
||||
|
||||
if (!args.yes) {
|
||||
noteMetabaseSetupSummary({
|
||||
prompts,
|
||||
connectionId,
|
||||
url,
|
||||
mappings: resolvedMappings,
|
||||
syncEnabledDatabaseIds: resolvedSyncEnabledDatabaseIds,
|
||||
});
|
||||
const confirmed = await prompts.confirm({
|
||||
message: 'Write changes to klo.yaml and enable sync?',
|
||||
initialValue: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
prompts.cancel('Setup cancelled.');
|
||||
throw new Error('Setup cancelled.');
|
||||
}
|
||||
}
|
||||
} else if (args.inputMode === 'disabled') {
|
||||
throw new Error('Metabase mapping/sync is required in --no-input mode; pass --map and --sync');
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
args.inputMode === 'disabled' &&
|
||||
resolvedMappings.length > 0 &&
|
||||
resolvedSyncEnabledDatabaseIds.length === 0
|
||||
) {
|
||||
throw new Error('Metabase sync selection is required in --no-input mode; pass --sync <metabaseDatabaseId>');
|
||||
}
|
||||
|
||||
const discoveredIds = new Set(discovered.map((database) => database.id));
|
||||
for (const mapping of resolvedMappings) {
|
||||
if (!discoveredIds.has(mapping.metabaseDatabaseId)) {
|
||||
throw new Error(`Mapped database id ${mapping.metabaseDatabaseId} was not discovered`);
|
||||
}
|
||||
}
|
||||
for (const syncId of resolvedSyncEnabledDatabaseIds) {
|
||||
if (!discoveredIds.has(syncId)) {
|
||||
throw new Error(`Sync database id ${syncId} was not discovered`);
|
||||
}
|
||||
}
|
||||
|
||||
await project.fileStore.writeFile(
|
||||
'klo.yaml',
|
||||
serializeKloProjectConfig(configWithTransient),
|
||||
'klo',
|
||||
'klo@example.com',
|
||||
`Setup Metabase connection ${connectionId}`,
|
||||
);
|
||||
|
||||
const updatedProject = await loadKloProject({ projectDir: args.projectDir });
|
||||
const store = new LocalMetabaseSourceStateReader({ dbPath: kloLocalStateDbPath(updatedProject) });
|
||||
|
||||
await store.refreshDiscoveredDatabases({ connectionId, discovered });
|
||||
|
||||
for (const mapping of resolvedMappings) {
|
||||
await store.upsertDatabaseMapping({
|
||||
connectionId,
|
||||
metabaseDatabaseId: mapping.metabaseDatabaseId,
|
||||
targetConnectionId: mapping.targetConnectionId,
|
||||
syncEnabled: false,
|
||||
source: 'cli',
|
||||
});
|
||||
}
|
||||
|
||||
for (const metabaseDatabaseId of resolvedSyncEnabledDatabaseIds) {
|
||||
await store.setMappingSyncEnabled({
|
||||
connectionId,
|
||||
metabaseDatabaseId,
|
||||
syncEnabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
const existingSyncState = await store.getSourceState(connectionId);
|
||||
await store.setSyncState({
|
||||
connectionId,
|
||||
syncMode: args.syncMode,
|
||||
defaultTagNames: existingSyncState.defaultTagNames,
|
||||
selections: existingSyncState.selections,
|
||||
});
|
||||
|
||||
const unhydrated = await store.getUnhydratedSyncEnabledMappingIds(connectionId);
|
||||
if (unhydrated.length > 0) {
|
||||
io.stderr.write(
|
||||
`Sync-enabled mappings are missing discovery metadata; run klo connection mapping refresh ${connectionId} --auto-accept\n`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const rows = await store.listDatabaseMappings(connectionId);
|
||||
const physicalFailures = rows.flatMap((row) => {
|
||||
if (!row.targetConnectionId) {
|
||||
return [];
|
||||
}
|
||||
const reason = validateMappingPhysicalMatch(
|
||||
{ metabaseEngine: row.metabaseEngine, metabaseDbName: row.metabaseDbName, metabaseHost: row.metabaseHost },
|
||||
updatedProject.config.connections[row.targetConnectionId]
|
||||
? targetPhysicalInfo(updatedProject, row.targetConnectionId)
|
||||
: { connection_type: 'UNKNOWN' },
|
||||
);
|
||||
return reason ? [`${row.metabaseDatabaseId}: ${reason}`] : [];
|
||||
});
|
||||
if (physicalFailures.length > 0) {
|
||||
for (const failure of physicalFailures) {
|
||||
io.stderr.write(`${failure}\n`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
io.stdout.write(`Connection: ${connectionId}\n`);
|
||||
io.stdout.write(`Discovered ${discovered.length} ${discovered.length === 1 ? 'database' : 'databases'}\n`);
|
||||
io.stdout.write(`Next: klo ingest ${connectionId} --project-dir ${args.projectDir}\n`);
|
||||
|
||||
if (args.runIngest) {
|
||||
const ingestRunner = deps.runPublicIngest ?? runKloPublicIngest;
|
||||
const exitCode = await ingestRunner(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: args.projectDir,
|
||||
targetConnectionId: connectionId,
|
||||
all: false,
|
||||
json: false,
|
||||
inputMode: 'disabled',
|
||||
},
|
||||
io,
|
||||
);
|
||||
if (exitCode !== 0) {
|
||||
io.stderr.write(`Ingest failed; re-run: klo ingest ${connectionId} --project-dir ${args.projectDir}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (isInteractive && prompts) {
|
||||
prompts.outro('Metabase setup complete');
|
||||
}
|
||||
|
||||
return 0;
|
||||
} finally {
|
||||
await client.cleanup();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
io.stderr.write(
|
||||
`${redactSecrets(message, [apiKeyForRedaction ?? '', passwordForRedaction ?? '', args.apiKey ?? ''])}\n`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
92
packages/cli/src/commands/connection-notion-commands.ts
Normal file
92
packages/cli/src/commands/connection-notion-commands.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { type Command, InvalidArgumentError } from '@commander-js/extra-typings';
|
||||
import { collectOption, type KloCliCommandContext, resolveCommandProjectDir } from '../cli-program.js';
|
||||
import type { KloConnectionNotionArgs } from './connection-notion.js';
|
||||
|
||||
interface NotionPickOptions {
|
||||
input?: boolean;
|
||||
rootPageId: string[];
|
||||
}
|
||||
|
||||
function parseSafeConnectionId(value: string): string {
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(value)) {
|
||||
throw new InvalidArgumentError(`Unsafe connection id: ${value}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function uniqueInOrder(values: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const value of values) {
|
||||
if (!seen.has(value)) {
|
||||
seen.add(value);
|
||||
result.push(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeNotionPageId(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
const compact = trimmed.includes('-') ? trimmed.replace(/-/g, '') : trimmed;
|
||||
if (!/^[0-9a-fA-F]{32}$/.test(compact)) {
|
||||
throw new Error(`Invalid Notion page UUID: ${value}`);
|
||||
}
|
||||
const lower = compact.toLowerCase();
|
||||
return `${lower.slice(0, 8)}-${lower.slice(8, 12)}-${lower.slice(12, 16)}-${lower.slice(16, 20)}-${lower.slice(20)}`;
|
||||
}
|
||||
|
||||
function buildPickArgs(connectionId: string, projectDir: string, options: NotionPickOptions): KloConnectionNotionArgs {
|
||||
if (options.input !== false) {
|
||||
return {
|
||||
command: 'pick',
|
||||
projectDir,
|
||||
connectionId,
|
||||
mode: 'interactive',
|
||||
};
|
||||
}
|
||||
|
||||
const rootPageIds = uniqueInOrder(options.rootPageId.map(normalizeNotionPageId));
|
||||
if (rootPageIds.length === 0) {
|
||||
throw new Error('connection notion pick --no-input requires at least one --root-page-id');
|
||||
}
|
||||
return {
|
||||
command: 'pick',
|
||||
projectDir,
|
||||
connectionId,
|
||||
mode: 'non-interactive',
|
||||
rootPageIds,
|
||||
};
|
||||
}
|
||||
|
||||
async function runConnectionNotionArgs(context: KloCliCommandContext, args: KloConnectionNotionArgs): Promise<void> {
|
||||
const runner = context.deps.connectionNotion ?? (await import('./connection-notion.js')).runKloConnectionNotion;
|
||||
context.setExitCode(await runner(args, context.io));
|
||||
}
|
||||
|
||||
export function registerConnectionNotionCommands(connect: Command, context: KloCliCommandContext): void {
|
||||
const notion = connect
|
||||
.command('notion')
|
||||
.description('Configure Notion source selection')
|
||||
.showHelpAfterError()
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\nProject directory defaults to KLO_PROJECT_DIR when set, otherwise the current working directory.\n',
|
||||
);
|
||||
|
||||
notion.action(() => {
|
||||
notion.outputHelp();
|
||||
context.setExitCode(0);
|
||||
});
|
||||
|
||||
notion
|
||||
.command('pick')
|
||||
.description('Pick Notion root pages for a configured Notion connection')
|
||||
.argument('<connectionId>', 'Notion connection id', parseSafeConnectionId)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.option('--root-page-id <id>', 'Root page UUID to crawl; repeatable with --no-input', collectOption, [])
|
||||
.showHelpAfterError()
|
||||
.action(async (connectionId: string, options: NotionPickOptions, command) => {
|
||||
await runConnectionNotionArgs(context, buildPickArgs(connectionId, resolveCommandProjectDir(command), options));
|
||||
});
|
||||
}
|
||||
283
packages/cli/src/commands/connection-notion-tree.test.ts
Normal file
283
packages/cli/src/commands/connection-notion-tree.test.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildInitialState,
|
||||
buildPickerTree,
|
||||
canToggle,
|
||||
clearExpiredTransientHint,
|
||||
filterTree,
|
||||
flattenSelection,
|
||||
moveCursor,
|
||||
reducer,
|
||||
selectAllVisible,
|
||||
selectNone,
|
||||
toggleChecked,
|
||||
TRANSIENT_HINT_DURATION_MS,
|
||||
visibleNodeIds,
|
||||
type NotionPickerPageInput,
|
||||
} from './connection-notion-tree.js';
|
||||
|
||||
const IDS = {
|
||||
engineering: '11111111-1111-1111-1111-111111111111',
|
||||
architecture: '22222222-2222-2222-2222-222222222222',
|
||||
onboarding: '33333333-3333-3333-3333-333333333333',
|
||||
marketing: '44444444-4444-4444-4444-444444444444',
|
||||
journal: '55555555-5555-5555-5555-555555555555',
|
||||
orphan: '66666666-6666-6666-6666-666666666666',
|
||||
duplicate: '77777777-7777-7777-7777-777777777777',
|
||||
cycleA: '88888888-8888-8888-8888-888888888888',
|
||||
cycleB: '99999999-9999-9999-9999-999999999999',
|
||||
};
|
||||
|
||||
function pages(): NotionPickerPageInput[] {
|
||||
return [
|
||||
{ id: IDS.marketing, title: 'Marketing', archived: false, parentId: null },
|
||||
{ id: IDS.onboarding, title: 'Onboarding', archived: false, parentId: IDS.engineering },
|
||||
{ id: IDS.engineering, title: 'Engineering Docs', archived: false, parentId: null },
|
||||
{ id: IDS.architecture, title: 'Architecture', archived: false, parentId: IDS.engineering },
|
||||
{ id: IDS.journal, title: 'Daily journal', archived: true, parentId: IDS.marketing },
|
||||
{ id: IDS.orphan, title: '', archived: false, parentId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' },
|
||||
{ id: IDS.duplicate, title: 'Original duplicate', archived: false, parentId: null },
|
||||
{ id: IDS.duplicate, title: 'Ignored duplicate', archived: true, parentId: IDS.marketing },
|
||||
{ id: IDS.cycleA, title: 'Cycle A', archived: false, parentId: IDS.cycleB },
|
||||
{ id: IDS.cycleB, title: 'Cycle B', archived: false, parentId: IDS.cycleA },
|
||||
];
|
||||
}
|
||||
|
||||
describe('buildPickerTree', () => {
|
||||
it('deduplicates pages, sorts siblings, preserves archived flags, roots orphans, and breaks cycles', () => {
|
||||
const tree = buildPickerTree(pages());
|
||||
const byId = new Map(tree.map((node) => [node.id, node]));
|
||||
|
||||
expect(tree.map((node) => node.title)).toEqual([
|
||||
'Cycle A',
|
||||
'Cycle B',
|
||||
'Engineering Docs',
|
||||
'Architecture',
|
||||
'Onboarding',
|
||||
'Marketing',
|
||||
'Daily journal',
|
||||
'Original duplicate',
|
||||
'Untitled',
|
||||
]);
|
||||
expect(byId.get(IDS.engineering)?.childIds).toEqual([IDS.architecture, IDS.onboarding]);
|
||||
expect(byId.get(IDS.architecture)).toMatchObject({
|
||||
depth: 1,
|
||||
parentId: IDS.engineering,
|
||||
path: 'Engineering Docs / Architecture',
|
||||
});
|
||||
expect(byId.get(IDS.journal)).toMatchObject({
|
||||
archived: true,
|
||||
depth: 1,
|
||||
path: 'Marketing / Daily journal',
|
||||
});
|
||||
expect(byId.get(IDS.orphan)).toMatchObject({
|
||||
title: 'Untitled',
|
||||
parentId: null,
|
||||
depth: 0,
|
||||
path: 'Untitled',
|
||||
});
|
||||
expect(byId.get(IDS.duplicate)).toMatchObject({
|
||||
title: 'Original duplicate',
|
||||
archived: false,
|
||||
parentId: null,
|
||||
});
|
||||
expect(byId.get(IDS.cycleA)?.parentId).toBeNull();
|
||||
expect(byId.get(IDS.cycleB)?.parentId).toBe(IDS.cycleA);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selection invariants', () => {
|
||||
it('checking a parent locks descendants and keeps checked ids minimal', () => {
|
||||
const state = buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
});
|
||||
|
||||
const checkedParent = toggleChecked(state, IDS.engineering, 1000);
|
||||
expect([...checkedParent.checked]).toEqual([IDS.engineering]);
|
||||
expect(canToggle(IDS.architecture, checkedParent)).toEqual({
|
||||
ok: false,
|
||||
reason: "Locked by 'Engineering Docs' - uncheck parent first",
|
||||
});
|
||||
|
||||
const lockedChildAttempt = toggleChecked(checkedParent, IDS.architecture, 2000);
|
||||
expect([...lockedChildAttempt.checked]).toEqual([IDS.engineering]);
|
||||
expect(lockedChildAttempt.transientHint).toEqual({
|
||||
text: "Locked by 'Engineering Docs' - uncheck parent first",
|
||||
expiresAt: 4500,
|
||||
});
|
||||
|
||||
const uncheckedParent = toggleChecked(lockedChildAttempt, IDS.engineering, 3000);
|
||||
expect([...uncheckedParent.checked]).toEqual([]);
|
||||
expect(canToggle(IDS.architecture, uncheckedParent)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('normalizes stored roots, reports stale roots, expands checked ancestors, and flattens descendants', () => {
|
||||
const state = buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [
|
||||
IDS.engineering.replaceAll('-', ''),
|
||||
IDS.architecture,
|
||||
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
});
|
||||
|
||||
expect([...state.checked]).toEqual([IDS.engineering]);
|
||||
expect([...state.expanded]).toEqual([]);
|
||||
expect(state.cursorId).toBe(IDS.cycleA);
|
||||
expect(state.preLoadWarnings).toEqual(['1 stored root_page_ids no longer visible']);
|
||||
expect(flattenSelection(new Set([IDS.engineering, IDS.architecture]), state.byId)).toEqual([IDS.engineering]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search and cursor movement', () => {
|
||||
it('filters by title and path while deriving auto-expanded ancestors', () => {
|
||||
const state = buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
});
|
||||
const searching = {
|
||||
...state,
|
||||
search: { editing: false, query: 'architecture' },
|
||||
};
|
||||
|
||||
expect(filterTree(searching)).toEqual({
|
||||
visibleIds: new Set([IDS.engineering, IDS.architecture]),
|
||||
autoExpand: new Set([IDS.engineering]),
|
||||
});
|
||||
expect(visibleNodeIds(searching)).toEqual([IDS.engineering, IDS.architecture]);
|
||||
});
|
||||
|
||||
it('moves the cursor through visible nodes and implements left/right tree semantics', () => {
|
||||
const state = buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
});
|
||||
|
||||
const atEngineering = {
|
||||
...state,
|
||||
cursorId: IDS.engineering,
|
||||
expanded: new Set([IDS.engineering]),
|
||||
};
|
||||
expect(moveCursor(atEngineering, 'down').cursorId).toBe(IDS.architecture);
|
||||
expect(moveCursor({ ...atEngineering, cursorId: IDS.architecture }, 'up').cursorId).toBe(IDS.engineering);
|
||||
expect(moveCursor(atEngineering, 'right').cursorId).toBe(IDS.architecture);
|
||||
expect(moveCursor({ ...atEngineering, cursorId: IDS.architecture }, 'left').cursorId).toBe(IDS.engineering);
|
||||
expect([...moveCursor(atEngineering, 'left').expanded]).toEqual([]);
|
||||
expect([...moveCursor({ ...state, cursorId: IDS.marketing }, 'right').expanded]).toContain(IDS.marketing);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulk actions and reducer effects', () => {
|
||||
it('selects only matching visible roots under search and clears selection', () => {
|
||||
const state = buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [IDS.marketing],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
});
|
||||
const searching = {
|
||||
...state,
|
||||
search: { editing: false, query: 'architecture' },
|
||||
};
|
||||
|
||||
const selected = selectAllVisible(searching);
|
||||
expect(flattenSelection(selected.checked, selected.byId)).toEqual([IDS.architecture, IDS.marketing]);
|
||||
expect([...selectNone(selected).checked]).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns save immediately for selected_roots and requires confirmation for all_accessible', () => {
|
||||
const selectedRoots = toggleChecked(
|
||||
buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
}),
|
||||
IDS.marketing,
|
||||
1000,
|
||||
);
|
||||
expect(reducer(selectedRoots, 'save-request')).toEqual({
|
||||
next: selectedRoots,
|
||||
effect: 'save',
|
||||
});
|
||||
|
||||
const allAccessible = {
|
||||
...selectedRoots,
|
||||
currentCrawlMode: 'all_accessible' as const,
|
||||
};
|
||||
const confirm = reducer(allAccessible, 'save-request');
|
||||
expect(confirm).toEqual({
|
||||
next: { ...allAccessible, pendingConfirm: 'mode-switch' },
|
||||
effect: null,
|
||||
});
|
||||
expect(reducer(confirm.next, 'save-cancel')).toEqual({
|
||||
next: { ...allAccessible, pendingConfirm: null },
|
||||
effect: null,
|
||||
});
|
||||
expect(reducer(confirm.next, 'save-confirm')).toEqual({
|
||||
next: { ...allAccessible, pendingConfirm: null },
|
||||
effect: 'save',
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks empty saves, updates search state, and quits without saving', () => {
|
||||
const state = buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
});
|
||||
|
||||
const blockedSave = reducer(state, 'save-request', 9000);
|
||||
expect(blockedSave).toEqual({
|
||||
next: {
|
||||
...state,
|
||||
transientHint: {
|
||||
text: 'Select at least one page or press q to quit',
|
||||
expiresAt: 9000 + TRANSIENT_HINT_DURATION_MS,
|
||||
},
|
||||
},
|
||||
effect: null,
|
||||
});
|
||||
expect(
|
||||
reducer(
|
||||
reducer(reducer(state, 'search-start').next, { type: 'search-input', value: 'a' }).next,
|
||||
'search-submit',
|
||||
).next.search,
|
||||
).toEqual({ editing: false, query: 'a' });
|
||||
expect(reducer(state, 'quit')).toEqual({
|
||||
next: state,
|
||||
effect: 'quit-without-save',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears transient hints only when their expiry time has passed', () => {
|
||||
const state = buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
});
|
||||
const withHint = {
|
||||
...state,
|
||||
transientHint: {
|
||||
text: 'Select at least one page or press q to quit',
|
||||
expiresAt: 11500,
|
||||
},
|
||||
};
|
||||
|
||||
expect(clearExpiredTransientHint(withHint, 11499)).toBe(withHint);
|
||||
expect(clearExpiredTransientHint(withHint, 11500)).toEqual({
|
||||
...withHint,
|
||||
transientHint: null,
|
||||
});
|
||||
expect(reducer(withHint, 'clear-transient-hint', 11501)).toEqual({
|
||||
next: {
|
||||
...withHint,
|
||||
transientHint: null,
|
||||
},
|
||||
effect: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
529
packages/cli/src/commands/connection-notion-tree.ts
Normal file
529
packages/cli/src/commands/connection-notion-tree.ts
Normal file
|
|
@ -0,0 +1,529 @@
|
|||
export interface NotionPickerPageInput {
|
||||
id: string;
|
||||
title?: string | null;
|
||||
archived?: boolean;
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
interface NotionPickerNode {
|
||||
id: string;
|
||||
title: string;
|
||||
archived: boolean;
|
||||
parentId: string | null;
|
||||
depth: number;
|
||||
childIds: string[];
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface PickerState {
|
||||
tree: NotionPickerNode[];
|
||||
byId: Map<string, NotionPickerNode>;
|
||||
expanded: Set<string>;
|
||||
checked: Set<string>;
|
||||
cursorId: string;
|
||||
search: { editing: boolean; query: string };
|
||||
pendingConfirm: 'mode-switch' | null;
|
||||
preLoadWarnings: string[];
|
||||
transientHint: { text: string; expiresAt: number } | null;
|
||||
currentCrawlMode: 'all_accessible' | 'selected_roots';
|
||||
}
|
||||
|
||||
export type PickerCommand =
|
||||
| 'cursor-up'
|
||||
| 'cursor-down'
|
||||
| 'cursor-left'
|
||||
| 'cursor-right'
|
||||
| 'expand'
|
||||
| 'collapse'
|
||||
| 'expand-all'
|
||||
| 'collapse-all'
|
||||
| 'toggle-check'
|
||||
| 'select-all-visible'
|
||||
| 'select-none'
|
||||
| 'clear-transient-hint'
|
||||
| 'search-start'
|
||||
| 'search-cancel'
|
||||
| 'search-submit'
|
||||
| 'search-backspace'
|
||||
| { type: 'search-input'; value: string }
|
||||
| 'save-request'
|
||||
| 'save-confirm'
|
||||
| 'save-cancel'
|
||||
| 'quit';
|
||||
|
||||
type PickerEffect = null | 'save' | 'quit-without-save';
|
||||
|
||||
interface MutableNode {
|
||||
id: string;
|
||||
title: string;
|
||||
archived: boolean;
|
||||
parentId: string | null;
|
||||
childIds: string[];
|
||||
}
|
||||
|
||||
export const TRANSIENT_HINT_DURATION_MS = 2500;
|
||||
|
||||
const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true });
|
||||
|
||||
function normalizePageId(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
const compact = trimmed.replace(/-/g, '');
|
||||
if (/^[0-9a-fA-F]{32}$/.test(compact)) {
|
||||
const lower = compact.toLowerCase();
|
||||
return `${lower.slice(0, 8)}-${lower.slice(8, 12)}-${lower.slice(12, 16)}-${lower.slice(
|
||||
16,
|
||||
20,
|
||||
)}-${lower.slice(20)}`;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function titleValue(value: string | null | undefined): string {
|
||||
const trimmed = value?.trim() ?? '';
|
||||
return trimmed.length > 0 ? trimmed : 'Untitled';
|
||||
}
|
||||
|
||||
function sortedNodeIds(ids: string[], nodes: Map<string, MutableNode | NotionPickerNode>): string[] {
|
||||
return [...ids].sort((leftId, rightId) => {
|
||||
const left = nodes.get(leftId);
|
||||
const right = nodes.get(rightId);
|
||||
const byTitle = collator.compare(left?.title ?? '', right?.title ?? '');
|
||||
return byTitle === 0 ? leftId.localeCompare(rightId) : byTitle;
|
||||
});
|
||||
}
|
||||
|
||||
function cloneState(state: PickerState, patch: Partial<PickerState>): PickerState {
|
||||
return { ...state, ...patch };
|
||||
}
|
||||
|
||||
function transientHint(text: string, now: number): PickerState['transientHint'] {
|
||||
return { text, expiresAt: now + TRANSIENT_HINT_DURATION_MS };
|
||||
}
|
||||
|
||||
export function clearExpiredTransientHint(state: PickerState, now = Date.now()): PickerState {
|
||||
if (!state.transientHint || state.transientHint.expiresAt > now) {
|
||||
return state;
|
||||
}
|
||||
return cloneState(state, { transientHint: null });
|
||||
}
|
||||
|
||||
function ancestorsOf(nodeId: string, byId: Map<string, NotionPickerNode>): string[] {
|
||||
const ancestors: string[] = [];
|
||||
let parentId = byId.get(nodeId)?.parentId ?? null;
|
||||
const seen = new Set<string>();
|
||||
while (parentId && !seen.has(parentId)) {
|
||||
ancestors.push(parentId);
|
||||
seen.add(parentId);
|
||||
parentId = byId.get(parentId)?.parentId ?? null;
|
||||
}
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
function descendantsOf(nodeId: string, byId: Map<string, NotionPickerNode>): string[] {
|
||||
const result: string[] = [];
|
||||
const stack = [...(byId.get(nodeId)?.childIds ?? [])].reverse();
|
||||
while (stack.length > 0) {
|
||||
const id = stack.pop();
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
result.push(id);
|
||||
const node = byId.get(id);
|
||||
if (node) {
|
||||
stack.push(...[...node.childIds].reverse());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function matchingIds(state: PickerState): Set<string> {
|
||||
const query = state.search.query.trim().toLocaleLowerCase();
|
||||
if (!query) {
|
||||
return new Set(state.tree.map((node) => node.id));
|
||||
}
|
||||
return new Set(
|
||||
state.tree
|
||||
.filter((node) => {
|
||||
const title = node.title.toLocaleLowerCase();
|
||||
const path = node.path.toLocaleLowerCase();
|
||||
return title.includes(query) || path.includes(query);
|
||||
})
|
||||
.map((node) => node.id),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPickerTree(searchResults: NotionPickerPageInput[]): NotionPickerNode[] {
|
||||
const nodes = new Map<string, MutableNode>();
|
||||
for (const result of searchResults) {
|
||||
const id = normalizePageId(result.id);
|
||||
if (nodes.has(id)) {
|
||||
continue;
|
||||
}
|
||||
nodes.set(id, {
|
||||
id,
|
||||
title: titleValue(result.title),
|
||||
archived: result.archived === true,
|
||||
parentId: result.parentId ? normalizePageId(result.parentId) : null,
|
||||
childIds: [],
|
||||
});
|
||||
}
|
||||
|
||||
for (const node of nodes.values()) {
|
||||
if (!node.parentId || node.parentId === node.id || !nodes.has(node.parentId)) {
|
||||
node.parentId = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const seen = new Set([node.id]);
|
||||
let cursor: string | null = node.parentId;
|
||||
while (cursor) {
|
||||
if (seen.has(cursor)) {
|
||||
node.parentId = null;
|
||||
break;
|
||||
}
|
||||
seen.add(cursor);
|
||||
cursor = nodes.get(cursor)?.parentId ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes.values()) {
|
||||
node.childIds = [];
|
||||
}
|
||||
for (const node of nodes.values()) {
|
||||
if (node.parentId) {
|
||||
nodes.get(node.parentId)?.childIds.push(node.id);
|
||||
}
|
||||
}
|
||||
for (const node of nodes.values()) {
|
||||
node.childIds = sortedNodeIds(node.childIds, nodes);
|
||||
}
|
||||
|
||||
const roots = sortedNodeIds(
|
||||
[...nodes.values()].filter((node) => node.parentId === null).map((node) => node.id),
|
||||
nodes,
|
||||
);
|
||||
const tree: NotionPickerNode[] = [];
|
||||
|
||||
function visit(nodeId: string, depth: number, pathPrefix: string[]): void {
|
||||
const raw = nodes.get(nodeId);
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
const path = [...pathPrefix, raw.title].join(' / ');
|
||||
const node: NotionPickerNode = {
|
||||
id: raw.id,
|
||||
title: raw.title,
|
||||
archived: raw.archived,
|
||||
parentId: raw.parentId,
|
||||
depth,
|
||||
childIds: raw.childIds,
|
||||
path,
|
||||
};
|
||||
tree.push(node);
|
||||
for (const childId of raw.childIds) {
|
||||
visit(childId, depth + 1, [...pathPrefix, raw.title]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const rootId of roots) {
|
||||
visit(rootId, 0, []);
|
||||
}
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
export function isAncestorChecked(nodeId: string, checked: Set<string>, byId: Map<string, NotionPickerNode>): boolean {
|
||||
return ancestorsOf(nodeId, byId).some((ancestorId) => checked.has(ancestorId));
|
||||
}
|
||||
|
||||
function checkedAncestor(nodeId: string, state: PickerState): NotionPickerNode | null {
|
||||
for (const ancestorId of ancestorsOf(nodeId, state.byId)) {
|
||||
if (state.checked.has(ancestorId)) {
|
||||
return state.byId.get(ancestorId) ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function canToggle(nodeId: string, state: PickerState): { ok: true } | { ok: false; reason: string } {
|
||||
if (!state.byId.has(nodeId)) {
|
||||
return { ok: false, reason: 'Page not found' };
|
||||
}
|
||||
const ancestor = checkedAncestor(nodeId, state);
|
||||
if (ancestor) {
|
||||
return { ok: false, reason: `Locked by '${ancestor.title}' - uncheck parent first` };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function toggleChecked(state: PickerState, nodeId: string, now = Date.now()): PickerState {
|
||||
const toggle = canToggle(nodeId, state);
|
||||
if (!toggle.ok) {
|
||||
return cloneState(state, {
|
||||
transientHint: transientHint(toggle.reason, now),
|
||||
});
|
||||
}
|
||||
|
||||
const checked = new Set(state.checked);
|
||||
if (checked.has(nodeId)) {
|
||||
checked.delete(nodeId);
|
||||
} else {
|
||||
checked.add(nodeId);
|
||||
for (const descendantId of descendantsOf(nodeId, state.byId)) {
|
||||
checked.delete(descendantId);
|
||||
}
|
||||
}
|
||||
return cloneState(state, { checked, transientHint: null });
|
||||
}
|
||||
|
||||
export function flattenSelection(checked: Set<string>, byId: Map<string, NotionPickerNode>): string[] {
|
||||
const result: string[] = [];
|
||||
for (const node of byId.values()) {
|
||||
if (checked.has(node.id) && !isAncestorChecked(node.id, checked, byId)) {
|
||||
result.push(node.id);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function filterTree(state: PickerState): { visibleIds: Set<string>; autoExpand: Set<string> } {
|
||||
const matches = matchingIds(state);
|
||||
if (state.search.query.trim().length === 0) {
|
||||
return { visibleIds: matches, autoExpand: new Set() };
|
||||
}
|
||||
|
||||
const visibleIds = new Set<string>();
|
||||
const autoExpand = new Set<string>();
|
||||
for (const matchId of matches) {
|
||||
visibleIds.add(matchId);
|
||||
for (const ancestorId of ancestorsOf(matchId, state.byId)) {
|
||||
visibleIds.add(ancestorId);
|
||||
autoExpand.add(ancestorId);
|
||||
}
|
||||
}
|
||||
return { visibleIds, autoExpand };
|
||||
}
|
||||
|
||||
export function visibleNodeIds(state: PickerState): string[] {
|
||||
const { visibleIds, autoExpand } = filterTree(state);
|
||||
const result: string[] = [];
|
||||
const roots = state.tree.filter((node) => node.parentId === null).map((node) => node.id);
|
||||
|
||||
function visit(nodeId: string): void {
|
||||
if (!visibleIds.has(nodeId)) {
|
||||
return;
|
||||
}
|
||||
result.push(nodeId);
|
||||
const node = state.byId.get(nodeId);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
if (state.expanded.has(nodeId) || autoExpand.has(nodeId)) {
|
||||
for (const childId of node.childIds) {
|
||||
visit(childId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const rootId of roots) {
|
||||
visit(rootId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function selectAllVisible(state: PickerState): PickerState {
|
||||
const candidates = state.search.query.trim().length > 0 ? matchingIds(state) : new Set(visibleNodeIds(state));
|
||||
const checked = new Set(state.checked);
|
||||
|
||||
for (const node of state.tree) {
|
||||
if (!candidates.has(node.id)) {
|
||||
continue;
|
||||
}
|
||||
const hasCandidateAncestor = ancestorsOf(node.id, state.byId).some((ancestorId) => candidates.has(ancestorId));
|
||||
if (!hasCandidateAncestor && !isAncestorChecked(node.id, checked, state.byId)) {
|
||||
checked.add(node.id);
|
||||
for (const descendantId of descendantsOf(node.id, state.byId)) {
|
||||
checked.delete(descendantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cloneState(state, {
|
||||
checked: new Set(flattenSelection(checked, state.byId)),
|
||||
transientHint: null,
|
||||
});
|
||||
}
|
||||
|
||||
export function selectNone(state: PickerState): PickerState {
|
||||
return cloneState(state, { checked: new Set(), transientHint: null });
|
||||
}
|
||||
|
||||
function setExpanded(state: PickerState, nodeId: string, value: boolean | 'toggle'): PickerState {
|
||||
const expanded = new Set(state.expanded);
|
||||
const nextValue = value === 'toggle' ? !expanded.has(nodeId) : value;
|
||||
if (nextValue) {
|
||||
expanded.add(nodeId);
|
||||
} else {
|
||||
expanded.delete(nodeId);
|
||||
}
|
||||
return cloneState(state, { expanded });
|
||||
}
|
||||
|
||||
function expandPath(state: PickerState, nodeId: string): PickerState {
|
||||
const expanded = new Set(state.expanded);
|
||||
for (const ancestorId of ancestorsOf(nodeId, state.byId)) {
|
||||
expanded.add(ancestorId);
|
||||
}
|
||||
return cloneState(state, { expanded });
|
||||
}
|
||||
|
||||
export function moveCursor(state: PickerState, dir: 'up' | 'down' | 'left' | 'right'): PickerState {
|
||||
const node = state.byId.get(state.cursorId);
|
||||
if (!node) {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (dir === 'left') {
|
||||
if (node.childIds.length > 0 && state.expanded.has(node.id)) {
|
||||
return setExpanded(state, node.id, false);
|
||||
}
|
||||
return node.parentId ? cloneState(state, { cursorId: node.parentId }) : state;
|
||||
}
|
||||
|
||||
if (dir === 'right') {
|
||||
if (node.childIds.length === 0) {
|
||||
return state;
|
||||
}
|
||||
if (!state.expanded.has(node.id)) {
|
||||
return setExpanded(state, node.id, true);
|
||||
}
|
||||
return cloneState(state, { cursorId: node.childIds[0] ?? node.id });
|
||||
}
|
||||
|
||||
const ids = visibleNodeIds(state);
|
||||
const index = ids.indexOf(state.cursorId);
|
||||
if (index === -1) {
|
||||
return ids[0] ? cloneState(state, { cursorId: ids[0] }) : state;
|
||||
}
|
||||
const nextIndex = dir === 'up' ? Math.max(0, index - 1) : Math.min(ids.length - 1, index + 1);
|
||||
return cloneState(state, { cursorId: ids[nextIndex] ?? state.cursorId });
|
||||
}
|
||||
|
||||
export function buildInitialState(args: {
|
||||
tree: NotionPickerNode[];
|
||||
existingRootPageIds: string[];
|
||||
currentCrawlMode?: 'all_accessible' | 'selected_roots';
|
||||
}): PickerState {
|
||||
const byId = new Map(args.tree.map((node) => [node.id, node]));
|
||||
const checked = new Set<string>();
|
||||
let staleCount = 0;
|
||||
|
||||
for (const rawId of args.existingRootPageIds) {
|
||||
const id = normalizePageId(rawId);
|
||||
if (byId.has(id)) {
|
||||
checked.add(id);
|
||||
} else {
|
||||
staleCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const minimalChecked = new Set(flattenSelection(checked, byId));
|
||||
const expanded = new Set<string>();
|
||||
for (const checkedId of minimalChecked) {
|
||||
for (const ancestorId of ancestorsOf(checkedId, byId)) {
|
||||
expanded.add(ancestorId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tree: args.tree,
|
||||
byId,
|
||||
expanded,
|
||||
checked: minimalChecked,
|
||||
cursorId: args.tree[0]?.id ?? '',
|
||||
search: { editing: false, query: '' },
|
||||
pendingConfirm: null,
|
||||
preLoadWarnings: staleCount > 0 ? [`${staleCount} stored root_page_ids no longer visible`] : [],
|
||||
transientHint: null,
|
||||
currentCrawlMode: args.currentCrawlMode ?? 'selected_roots',
|
||||
};
|
||||
}
|
||||
|
||||
export function reducer(state: PickerState, cmd: PickerCommand, now = Date.now()): { next: PickerState; effect: PickerEffect } {
|
||||
if (state.pendingConfirm) {
|
||||
if (cmd === 'save-confirm') {
|
||||
return { next: cloneState(state, { pendingConfirm: null }), effect: 'save' };
|
||||
}
|
||||
if (cmd === 'save-cancel') {
|
||||
return { next: cloneState(state, { pendingConfirm: null }), effect: null };
|
||||
}
|
||||
if (cmd === 'quit') {
|
||||
return { next: state, effect: 'quit-without-save' };
|
||||
}
|
||||
return { next: state, effect: null };
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 'cursor-up':
|
||||
return { next: moveCursor(state, 'up'), effect: null };
|
||||
case 'cursor-down':
|
||||
return { next: moveCursor(state, 'down'), effect: null };
|
||||
case 'cursor-left':
|
||||
return { next: moveCursor(state, 'left'), effect: null };
|
||||
case 'cursor-right':
|
||||
return { next: moveCursor(state, 'right'), effect: null };
|
||||
case 'expand':
|
||||
return { next: setExpanded(state, state.cursorId, 'toggle'), effect: null };
|
||||
case 'collapse':
|
||||
return { next: setExpanded(state, state.cursorId, false), effect: null };
|
||||
case 'expand-all':
|
||||
return {
|
||||
next: cloneState(state, {
|
||||
expanded: new Set(state.tree.filter((node) => node.childIds.length > 0).map((node) => node.id)),
|
||||
}),
|
||||
effect: null,
|
||||
};
|
||||
case 'collapse-all':
|
||||
return { next: cloneState(state, { expanded: new Set() }), effect: null };
|
||||
case 'toggle-check':
|
||||
return { next: toggleChecked(state, state.cursorId, now), effect: null };
|
||||
case 'select-all-visible':
|
||||
return { next: selectAllVisible(state), effect: null };
|
||||
case 'select-none':
|
||||
return { next: selectNone(state), effect: null };
|
||||
case 'clear-transient-hint':
|
||||
return { next: clearExpiredTransientHint(state, now), effect: null };
|
||||
case 'search-start':
|
||||
return { next: cloneState(state, { search: { ...state.search, editing: true } }), effect: null };
|
||||
case 'search-cancel':
|
||||
return { next: cloneState(state, { search: { editing: false, query: '' } }), effect: null };
|
||||
case 'search-submit':
|
||||
return { next: cloneState(state, { search: { ...state.search, editing: false } }), effect: null };
|
||||
case 'search-backspace':
|
||||
return {
|
||||
next: cloneState(state, { search: { ...state.search, query: state.search.query.slice(0, -1) } }),
|
||||
effect: null,
|
||||
};
|
||||
case 'save-request':
|
||||
if (state.checked.size === 0) {
|
||||
return {
|
||||
next: cloneState(state, {
|
||||
transientHint: transientHint('Select at least one page or press q to quit', now),
|
||||
}),
|
||||
effect: null,
|
||||
};
|
||||
}
|
||||
if (state.currentCrawlMode === 'all_accessible') {
|
||||
return { next: cloneState(state, { pendingConfirm: 'mode-switch' }), effect: null };
|
||||
}
|
||||
return { next: state, effect: 'save' };
|
||||
case 'save-confirm':
|
||||
return { next: state, effect: 'save' };
|
||||
case 'save-cancel':
|
||||
return { next: state, effect: null };
|
||||
case 'quit':
|
||||
return { next: state, effect: 'quit-without-save' };
|
||||
default:
|
||||
return { next: cloneState(state, { search: { ...state.search, query: state.search.query + cmd.value } }), effect: null };
|
||||
}
|
||||
}
|
||||
384
packages/cli/src/commands/connection-notion-tui.test.tsx
Normal file
384
packages/cli/src/commands/connection-notion-tui.test.tsx
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
/* @jsxImportSource react */
|
||||
import { render as renderInkTest } from 'ink-testing-library';
|
||||
import React, { act, type ReactNode } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildInitialState, buildPickerTree, type NotionPickerPageInput } from './connection-notion-tree.js';
|
||||
import {
|
||||
NotionPickerApp,
|
||||
notionPickerCommandForInkInput,
|
||||
renderNotionPickerTui,
|
||||
resolveNotionPickerWidth,
|
||||
sanitizeNotionPickerTuiError,
|
||||
windowItems,
|
||||
windowOffset,
|
||||
type NotionPickerInkInstance,
|
||||
type NotionPickerInkRenderOptions,
|
||||
} from './connection-notion-tui.js';
|
||||
|
||||
const IDS = {
|
||||
engineering: '11111111-1111-1111-1111-111111111111',
|
||||
architecture: '22222222-2222-2222-2222-222222222222',
|
||||
marketing: '33333333-3333-3333-3333-333333333333',
|
||||
finance: '44444444-4444-4444-4444-444444444444',
|
||||
ops: '55555555-5555-5555-5555-555555555555',
|
||||
sales: '66666666-6666-6666-6666-666666666666',
|
||||
support: '77777777-7777-7777-7777-777777777777',
|
||||
product: '88888888-8888-8888-8888-888888888888',
|
||||
design: '99999999-9999-9999-9999-999999999999',
|
||||
};
|
||||
|
||||
function pages(): NotionPickerPageInput[] {
|
||||
return [
|
||||
{ id: IDS.engineering, title: 'Engineering Docs', archived: false, parentId: null },
|
||||
{ id: IDS.architecture, title: 'Architecture', archived: false, parentId: IDS.engineering },
|
||||
{ id: IDS.marketing, title: 'Marketing', archived: false, parentId: null },
|
||||
];
|
||||
}
|
||||
|
||||
function manyPages(): NotionPickerPageInput[] {
|
||||
return [
|
||||
{ id: IDS.engineering, title: 'Engineering Docs', archived: false, parentId: null },
|
||||
{ id: IDS.architecture, title: 'Architecture', archived: false, parentId: IDS.engineering },
|
||||
{ id: IDS.marketing, title: 'Marketing', archived: false, parentId: null },
|
||||
{ id: IDS.finance, title: 'Finance', archived: false, parentId: null },
|
||||
{ id: IDS.ops, title: 'Operations', archived: false, parentId: null },
|
||||
{ id: IDS.sales, title: 'Sales', archived: false, parentId: null },
|
||||
{ id: IDS.support, title: 'Support', archived: false, parentId: null },
|
||||
{ id: IDS.product, title: 'Product', archived: false, parentId: null },
|
||||
{ id: IDS.design, title: 'Design', archived: false, parentId: null },
|
||||
];
|
||||
}
|
||||
|
||||
function state(mode: 'all_accessible' | 'selected_roots' = 'selected_roots') {
|
||||
return buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [],
|
||||
currentCrawlMode: mode,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForInkInput(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
function fakeInkInstance(): NotionPickerInkInstance {
|
||||
return {
|
||||
rerender: vi.fn(),
|
||||
unmount: vi.fn(),
|
||||
waitUntilExit: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFrameWrap(frame: string | undefined): string {
|
||||
return frame?.replace(/\n/g, ' ') ?? '';
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('notionPickerCommandForInkInput', () => {
|
||||
it('maps browse, search, and confirm input to reducer commands', () => {
|
||||
expect(notionPickerCommandForInkInput('', { downArrow: true }, state().search, null)).toBe('cursor-down');
|
||||
expect(notionPickerCommandForInkInput('', { upArrow: true }, state().search, null)).toBe('cursor-up');
|
||||
expect(notionPickerCommandForInkInput('', { rightArrow: true }, state().search, null)).toBe('cursor-right');
|
||||
expect(notionPickerCommandForInkInput('', { leftArrow: true }, state().search, null)).toBe('cursor-left');
|
||||
expect(notionPickerCommandForInkInput(' ', {}, state().search, null)).toBe('toggle-check');
|
||||
expect(notionPickerCommandForInkInput('/', {}, state().search, null)).toBe('search-start');
|
||||
expect(notionPickerCommandForInkInput('a', {}, state().search, null)).toBe('select-all-visible');
|
||||
expect(notionPickerCommandForInkInput('n', {}, state().search, null)).toBe('select-none');
|
||||
expect(notionPickerCommandForInkInput('s', {}, state().search, null)).toBe('save-request');
|
||||
expect(notionPickerCommandForInkInput('q', {}, state().search, null)).toBe('quit');
|
||||
expect(notionPickerCommandForInkInput('c', { ctrl: true }, state().search, null)).toBe('quit');
|
||||
|
||||
expect(notionPickerCommandForInkInput('x', {}, { editing: true, query: '' }, null)).toEqual({
|
||||
type: 'search-input',
|
||||
value: 'x',
|
||||
});
|
||||
expect(notionPickerCommandForInkInput('', { backspace: true }, { editing: true, query: 'x' }, null)).toBe(
|
||||
'search-backspace',
|
||||
);
|
||||
expect(notionPickerCommandForInkInput('', { return: true }, { editing: true, query: 'x' }, null)).toBe(
|
||||
'search-submit',
|
||||
);
|
||||
expect(notionPickerCommandForInkInput('', { escape: true }, { editing: true, query: 'x' }, null)).toBe(
|
||||
'search-cancel',
|
||||
);
|
||||
|
||||
expect(notionPickerCommandForInkInput('y', {}, state().search, 'mode-switch')).toBe('save-confirm');
|
||||
expect(notionPickerCommandForInkInput('', { return: true }, state().search, 'mode-switch')).toBe('save-confirm');
|
||||
expect(notionPickerCommandForInkInput('n', {}, state().search, 'mode-switch')).toBe('save-cancel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('window helpers', () => {
|
||||
it('centers the selected row and returns the visible slice', () => {
|
||||
expect(windowOffset(20, 10, 5)).toBe(8);
|
||||
expect(windowItems(['a', 'b', 'c', 'd', 'e'], 3, 3)).toEqual({ items: ['c', 'd', 'e'], offset: 2 });
|
||||
});
|
||||
|
||||
it('clamps picker width to the design rule', () => {
|
||||
expect(resolveNotionPickerWidth(200)).toBe(120);
|
||||
expect(resolveNotionPickerWidth(100)).toBe(96);
|
||||
expect(resolveNotionPickerWidth(50)).toBe(60);
|
||||
expect(resolveNotionPickerWidth(undefined)).toBe(96);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotionPickerApp', () => {
|
||||
it('renders spec banners, row glyphs, search visibility, and hint text', () => {
|
||||
const initialState = {
|
||||
...state('all_accessible'),
|
||||
preLoadWarnings: ['1 stored root_page_ids no longer visible'],
|
||||
};
|
||||
const { lastFrame } = renderInkTest(
|
||||
<NotionPickerApp
|
||||
initialState={initialState}
|
||||
connectionId="notion-main"
|
||||
workspaceLabel="Design Workspace"
|
||||
cappedAtCount={5000}
|
||||
currentCrawlMode="all_accessible"
|
||||
terminalRows={24}
|
||||
terminalWidth={100}
|
||||
onExit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('Notion pages visible to integration "Design Workspace"');
|
||||
expect(frame).toContain('5000-page cap reached - some pages not shown');
|
||||
expect(frame).toContain('1 stored root_page_ids no longer visible - they will be removed if you save');
|
||||
expect(frame).toContain('▸ [ ] Engineering Docs ▸ (1)');
|
||||
expect(frame).toContain(' [ ] Marketing');
|
||||
expect(frame).not.toContain('Search ready: -');
|
||||
expect(frame).toContain('space toggle · enter expand · / search · a all · n none · s save & exit · q quit');
|
||||
});
|
||||
|
||||
it('renders partial discovery warnings without stale-root save suffix', () => {
|
||||
const initialState = {
|
||||
...state(),
|
||||
preLoadWarnings: ['Notion search stopped early: rate limit after first page'],
|
||||
};
|
||||
const { lastFrame } = renderInkTest(
|
||||
<NotionPickerApp
|
||||
initialState={initialState}
|
||||
connectionId="notion-main"
|
||||
workspaceLabel="Design Workspace"
|
||||
cappedAtCount={null}
|
||||
currentCrawlMode="selected_roots"
|
||||
terminalRows={24}
|
||||
terminalWidth={100}
|
||||
onExit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('Notion search stopped early: rate limit after first page');
|
||||
expect(frame).not.toContain(
|
||||
'Notion search stopped early: rate limit after first page - they will be removed if you save',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders checked parents and locked descendants with the locked design glyphs', () => {
|
||||
const initialState = {
|
||||
...state(),
|
||||
checked: new Set([IDS.engineering]),
|
||||
expanded: new Set([IDS.engineering]),
|
||||
};
|
||||
const { lastFrame } = renderInkTest(
|
||||
<NotionPickerApp
|
||||
initialState={initialState}
|
||||
connectionId="notion-main"
|
||||
workspaceLabel="Design Workspace"
|
||||
cappedAtCount={null}
|
||||
currentCrawlMode="selected_roots"
|
||||
terminalRows={24}
|
||||
terminalWidth={100}
|
||||
onExit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('▸ [×] Engineering Docs ▾');
|
||||
expect(frame).toContain(' [~] Architecture');
|
||||
});
|
||||
|
||||
it('supports keyboard selection, all_accessible confirmation, and save callback', async () => {
|
||||
const onExit = vi.fn();
|
||||
const { stdin, lastFrame } = renderInkTest(
|
||||
<NotionPickerApp
|
||||
initialState={state('all_accessible')}
|
||||
connectionId="notion-main"
|
||||
workspaceLabel="Design Workspace"
|
||||
cappedAtCount={null}
|
||||
currentCrawlMode="all_accessible"
|
||||
terminalRows={24}
|
||||
terminalWidth={100}
|
||||
onExit={onExit}
|
||||
/>,
|
||||
);
|
||||
|
||||
stdin.write(' ');
|
||||
await waitForInkInput();
|
||||
expect(lastFrame()).toContain('[×] Engineering Docs');
|
||||
|
||||
stdin.write('s');
|
||||
await waitForInkInput();
|
||||
expect(normalizeFrameWrap(lastFrame())).toContain(
|
||||
'Save will switch crawl_mode all_accessible -> selected_roots and limit ingest to 1 selected page. [y] confirm [esc] back',
|
||||
);
|
||||
|
||||
stdin.write('y');
|
||||
await waitForInkInput();
|
||||
expect(onExit).toHaveBeenCalledWith({ kind: 'save', rootPageIds: [IDS.engineering] });
|
||||
});
|
||||
|
||||
it('removes transient hints after their expiry time', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onExit = vi.fn();
|
||||
const { stdin, lastFrame } = renderInkTest(
|
||||
<NotionPickerApp
|
||||
initialState={state()}
|
||||
connectionId="notion-main"
|
||||
workspaceLabel="Design Workspace"
|
||||
cappedAtCount={null}
|
||||
currentCrawlMode="selected_roots"
|
||||
terminalRows={24}
|
||||
terminalWidth={100}
|
||||
onExit={onExit}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('s');
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
});
|
||||
expect(lastFrame()).toContain('Select at least one page or press q to quit');
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2500);
|
||||
});
|
||||
expect(lastFrame()).not.toContain('Select at least one page or press q to quit');
|
||||
expect(onExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders row-window overflow indicators when the visible list is clipped', async () => {
|
||||
const onExit = vi.fn();
|
||||
const initialState = buildInitialState({
|
||||
tree: buildPickerTree(manyPages()),
|
||||
existingRootPageIds: [],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
});
|
||||
initialState.expanded = new Set([IDS.engineering]);
|
||||
const { stdin, lastFrame } = renderInkTest(
|
||||
<NotionPickerApp
|
||||
initialState={initialState}
|
||||
connectionId="notion-main"
|
||||
workspaceLabel="Design Workspace"
|
||||
cappedAtCount={null}
|
||||
currentCrawlMode="selected_roots"
|
||||
terminalRows={13}
|
||||
terminalWidth={100}
|
||||
onExit={onExit}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('↓ 4 more');
|
||||
|
||||
stdin.write('\u001B[B');
|
||||
stdin.write('\u001B[B');
|
||||
stdin.write('\u001B[B');
|
||||
stdin.write('\u001B[B');
|
||||
await waitForInkInput();
|
||||
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('↑ ');
|
||||
expect(frame).toContain('↓ ');
|
||||
expect(onExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns quit without saving', async () => {
|
||||
const onExit = vi.fn();
|
||||
const { stdin } = renderInkTest(
|
||||
<NotionPickerApp
|
||||
initialState={state()}
|
||||
connectionId="notion-main"
|
||||
workspaceLabel="Design Workspace"
|
||||
cappedAtCount={null}
|
||||
currentCrawlMode="selected_roots"
|
||||
terminalRows={24}
|
||||
terminalWidth={100}
|
||||
onExit={onExit}
|
||||
/>,
|
||||
);
|
||||
|
||||
stdin.write('q');
|
||||
await waitForInkInput();
|
||||
expect(onExit).toHaveBeenCalledWith({ kind: 'quit' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderNotionPickerTui', () => {
|
||||
it('returns the app result from the Ink runtime', async () => {
|
||||
const io = {
|
||||
stdin: { isTTY: true, setRawMode: vi.fn() },
|
||||
stdout: { isTTY: true, columns: 100, rows: 24, write: vi.fn() },
|
||||
stderr: { write: vi.fn() },
|
||||
};
|
||||
const renderInk = vi.fn((_tree: ReactNode, _options: NotionPickerInkRenderOptions) => fakeInkInstance());
|
||||
|
||||
await expect(
|
||||
renderNotionPickerTui(
|
||||
{
|
||||
initialState: state(),
|
||||
connectionId: 'notion-main',
|
||||
workspaceLabel: 'Design Workspace',
|
||||
cappedAtCount: null,
|
||||
currentCrawlMode: 'selected_roots',
|
||||
},
|
||||
io,
|
||||
{ renderInk },
|
||||
),
|
||||
).resolves.toEqual({ kind: 'quit' });
|
||||
expect(renderInk).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('sanitizes render errors and tells the user to use no-input mode', async () => {
|
||||
expect(sanitizeNotionPickerTuiError(new Error('token=secret https://api.notion.com/v1/search'))).toBe(
|
||||
'[redacted] [redacted-url]',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to quit with a scripted-mode hint when Ink cannot initialize', async () => {
|
||||
let stderr = '';
|
||||
const io = {
|
||||
stdin: { isTTY: false, setRawMode: vi.fn() },
|
||||
stdout: { isTTY: false, columns: 100, rows: 24, write: vi.fn() },
|
||||
stderr: {
|
||||
write(chunk: string) {
|
||||
stderr += chunk;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
renderNotionPickerTui(
|
||||
{
|
||||
initialState: state(),
|
||||
connectionId: 'notion-main',
|
||||
workspaceLabel: 'Design Workspace',
|
||||
cappedAtCount: null,
|
||||
currentCrawlMode: 'selected_roots',
|
||||
},
|
||||
io,
|
||||
{
|
||||
renderInk: vi.fn(() => {
|
||||
throw new Error('token=secret');
|
||||
}),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual({ kind: 'quit' });
|
||||
expect(stderr).toContain('Use --no-input --root-page-id <UUID> for scripted mode');
|
||||
expect(stderr).not.toContain('secret');
|
||||
});
|
||||
});
|
||||
338
packages/cli/src/commands/connection-notion-tui.tsx
Normal file
338
packages/cli/src/commands/connection-notion-tui.tsx
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
/* @jsxImportSource react */
|
||||
import { Box, Text, render as renderInkRuntime, useApp, useInput } from 'ink';
|
||||
import React, { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
filterTree,
|
||||
flattenSelection,
|
||||
isAncestorChecked,
|
||||
reducer,
|
||||
visibleNodeIds,
|
||||
type PickerCommand,
|
||||
type PickerState,
|
||||
} from './connection-notion-tree.js';
|
||||
import type { KloCliIo } from '../index.js';
|
||||
|
||||
const COLOR_THEME = {
|
||||
text: 'white',
|
||||
muted: 'gray',
|
||||
active: 'cyan',
|
||||
warning: 'yellow',
|
||||
} as const;
|
||||
|
||||
const NO_COLOR_THEME = {
|
||||
text: 'white',
|
||||
muted: 'white',
|
||||
active: 'white',
|
||||
warning: 'white',
|
||||
} as const;
|
||||
|
||||
type NotionPickerTheme = Record<keyof typeof COLOR_THEME, string>;
|
||||
|
||||
export interface NotionPickerTuiIo extends KloCliIo {
|
||||
stdin?: { isTTY?: boolean; setRawMode?(value: boolean): void };
|
||||
stdout: KloCliIo['stdout'] & { isTTY?: boolean; columns?: number; rows?: number };
|
||||
}
|
||||
|
||||
interface InkKey {
|
||||
leftArrow?: boolean;
|
||||
rightArrow?: boolean;
|
||||
upArrow?: boolean;
|
||||
downArrow?: boolean;
|
||||
return?: boolean;
|
||||
escape?: boolean;
|
||||
ctrl?: boolean;
|
||||
backspace?: boolean;
|
||||
delete?: boolean;
|
||||
}
|
||||
|
||||
export type PickerRenderResult = { kind: 'save'; rootPageIds: string[] } | { kind: 'quit' };
|
||||
|
||||
export interface PickerRenderInput {
|
||||
initialState: PickerState;
|
||||
connectionId: string;
|
||||
workspaceLabel: string;
|
||||
cappedAtCount: number | null;
|
||||
currentCrawlMode: 'all_accessible' | 'selected_roots';
|
||||
}
|
||||
|
||||
interface NotionPickerAppProps extends PickerRenderInput {
|
||||
terminalRows?: number;
|
||||
terminalWidth?: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
onExit(result: PickerRenderResult): void;
|
||||
}
|
||||
|
||||
export interface NotionPickerInkInstance {
|
||||
rerender(tree: ReactNode): void;
|
||||
unmount(): void;
|
||||
waitUntilExit(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface NotionPickerInkRenderOptions {
|
||||
stdin?: NotionPickerTuiIo['stdin'];
|
||||
stdout: NotionPickerTuiIo['stdout'];
|
||||
stderr: NotionPickerTuiIo['stderr'];
|
||||
exitOnCtrlC: boolean;
|
||||
patchConsole: boolean;
|
||||
maxFps: number;
|
||||
alternateScreen: boolean;
|
||||
}
|
||||
|
||||
function resolveTheme(env: NodeJS.ProcessEnv = process.env): NotionPickerTheme {
|
||||
return env.NO_COLOR || env.TERM === 'dumb' ? NO_COLOR_THEME : COLOR_THEME;
|
||||
}
|
||||
|
||||
export function resolveNotionPickerWidth(columns: number | undefined): number {
|
||||
const resolvedColumns = columns ?? 100;
|
||||
return Math.max(60, Math.min(120, resolvedColumns - 4));
|
||||
}
|
||||
|
||||
function staleWarningText(warning: string): string {
|
||||
return warning.includes('stored root_page_ids no longer visible')
|
||||
? `${warning} - they will be removed if you save`
|
||||
: warning;
|
||||
}
|
||||
|
||||
function selectedPageCountText(count: number): string {
|
||||
return `${count} selected ${count === 1 ? 'page' : 'pages'}`;
|
||||
}
|
||||
|
||||
function rowMatchesSearch(state: PickerState, nodeId: string): boolean {
|
||||
const query = state.search.query.trim().toLocaleLowerCase();
|
||||
if (!query) {
|
||||
return false;
|
||||
}
|
||||
const node = state.byId.get(nodeId);
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
return node.title.toLocaleLowerCase().includes(query) || node.path.toLocaleLowerCase().includes(query);
|
||||
}
|
||||
|
||||
export function sanitizeNotionPickerTuiError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message
|
||||
.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s]+/gi, '[redacted-url]')
|
||||
.replace(/\b(api[_-]?key|password|token|secret)=\S+/gi, '[redacted]');
|
||||
}
|
||||
|
||||
export function windowOffset(count: number, selected: number, visible: number): number {
|
||||
if (count <= visible) return 0;
|
||||
return Math.max(0, Math.min(count - visible, selected - Math.floor(visible / 2)));
|
||||
}
|
||||
|
||||
export function windowItems<T>(items: T[], selected: number, visible: number): { items: T[]; offset: number } {
|
||||
const offset = windowOffset(items.length, selected, visible);
|
||||
return { items: items.slice(offset, offset + visible), offset };
|
||||
}
|
||||
|
||||
function truncateText(value: string, width: number): string {
|
||||
if (value.length <= width) return value;
|
||||
if (width <= 3) return value.slice(0, width);
|
||||
return `${value.slice(0, width - 3)}...`;
|
||||
}
|
||||
|
||||
export function notionPickerCommandForInkInput(
|
||||
input: string,
|
||||
key: InkKey,
|
||||
search: PickerState['search'],
|
||||
pendingConfirm: PickerState['pendingConfirm'],
|
||||
): PickerCommand | null {
|
||||
if (pendingConfirm) {
|
||||
if (input === 'y' || key.return) return 'save-confirm';
|
||||
if (input === 'n' || key.escape) return 'save-cancel';
|
||||
if (key.ctrl === true && input === 'c') return 'quit';
|
||||
return null;
|
||||
}
|
||||
if (search.editing) {
|
||||
if (key.escape) return 'search-cancel';
|
||||
if (key.return) return 'search-submit';
|
||||
if (key.backspace || key.delete) return 'search-backspace';
|
||||
if (key.downArrow) return 'cursor-down';
|
||||
if (key.upArrow) return 'cursor-up';
|
||||
if (input.length === 1 && input >= ' ' && input !== '\u007f') return { type: 'search-input', value: input };
|
||||
return null;
|
||||
}
|
||||
if (key.ctrl === true && input === 'c') return 'quit';
|
||||
if (key.upArrow) return 'cursor-up';
|
||||
if (key.downArrow) return 'cursor-down';
|
||||
if (key.leftArrow) return 'cursor-left';
|
||||
if (key.rightArrow) return 'cursor-right';
|
||||
if (key.return) return 'expand';
|
||||
if (input === ' ') return 'toggle-check';
|
||||
if (input === '/') return 'search-start';
|
||||
if (input === 'a') return 'select-all-visible';
|
||||
if (input === 'n') return 'select-none';
|
||||
if (input === 's') return 'save-request';
|
||||
if (input === 'q' || key.escape) return 'quit';
|
||||
return null;
|
||||
}
|
||||
|
||||
function PickerRow(props: { state: PickerState; nodeId: string; width: number; theme: NotionPickerTheme }): ReactNode {
|
||||
const node = props.state.byId.get(props.nodeId);
|
||||
if (!node) return null;
|
||||
const focused = props.state.cursorId === node.id;
|
||||
const locked = isAncestorChecked(node.id, props.state.checked, props.state.byId);
|
||||
const checked = props.state.checked.has(node.id);
|
||||
const glyph = locked ? '[~]' : checked ? '[×]' : '[ ]';
|
||||
const children =
|
||||
node.childIds.length > 0 ? (props.state.expanded.has(node.id) ? ' ▾' : ` ▸ (${node.childIds.length})`) : '';
|
||||
const prefix = `${focused ? '▸' : ' '} ${glyph} ${' '.repeat(node.depth * 2)}`;
|
||||
const color = focused ? props.theme.active : locked || node.archived ? props.theme.muted : props.theme.text;
|
||||
const title = truncateText(`${node.title}${children}`, Math.max(10, props.width - prefix.length));
|
||||
const inverse = rowMatchesSearch(props.state, node.id);
|
||||
|
||||
return (
|
||||
<Text color={color} strikethrough={node.archived}>
|
||||
{prefix}
|
||||
<Text inverse={inverse}>{title}</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotionPickerApp(props: NotionPickerAppProps): ReactNode {
|
||||
const app = useApp();
|
||||
const [state, setState] = useState(props.initialState);
|
||||
const stateRef = useRef(state);
|
||||
const theme = useMemo(() => resolveTheme(props.env), [props.env]);
|
||||
const visibleIds = visibleNodeIds(state);
|
||||
const selectedIndex = Math.max(0, visibleIds.indexOf(state.cursorId));
|
||||
const reservedRows = state.pendingConfirm === 'mode-switch' ? 9 : 8;
|
||||
const visibleRows = Math.max(5, Math.min(20, (props.terminalRows ?? 24) - reservedRows));
|
||||
const rows = windowItems(visibleIds, selectedIndex, visibleRows);
|
||||
const hiddenAbove = rows.offset;
|
||||
const hiddenBelow = Math.max(0, visibleIds.length - rows.offset - rows.items.length);
|
||||
const searchMatchCount = filterTree(state).visibleIds.size;
|
||||
const width = resolveNotionPickerWidth(props.terminalWidth);
|
||||
const showSearch = state.search.editing || state.search.query.trim().length > 0;
|
||||
const selectedCount = flattenSelection(state.checked, state.byId).length;
|
||||
|
||||
stateRef.current = state;
|
||||
|
||||
useEffect(() => {
|
||||
const hint = state.transientHint;
|
||||
if (!hint) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clearHint = () => {
|
||||
setState((current) => {
|
||||
const { next } = reducer(current, 'clear-transient-hint');
|
||||
stateRef.current = next;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const delay = hint.expiresAt - Date.now();
|
||||
if (delay <= 0) {
|
||||
clearHint();
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(clearHint, delay);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [state.transientHint?.expiresAt]);
|
||||
|
||||
useInput((input, key) => {
|
||||
const command = notionPickerCommandForInkInput(input, key, stateRef.current.search, stateRef.current.pendingConfirm);
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
const { next, effect } = reducer(stateRef.current, command);
|
||||
stateRef.current = next;
|
||||
setState(next);
|
||||
if (effect === 'save') {
|
||||
props.onExit({ kind: 'save', rootPageIds: flattenSelection(next.checked, next.byId) });
|
||||
app.exit();
|
||||
return;
|
||||
}
|
||||
if (effect === 'quit-without-save') {
|
||||
props.onExit({ kind: 'quit' });
|
||||
app.exit();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.active}>Notion pages visible to integration "{props.workspaceLabel}"</Text>
|
||||
{props.cappedAtCount ? <Text color={theme.warning}>{props.cappedAtCount}-page cap reached - some pages not shown</Text> : null}
|
||||
{state.preLoadWarnings.map((warning) => (
|
||||
<Text key={warning} color={theme.warning}>
|
||||
{staleWarningText(warning)}
|
||||
</Text>
|
||||
))}
|
||||
{showSearch ? (
|
||||
<Text color={theme.muted}>
|
||||
/ {state.search.query}
|
||||
{state.search.editing ? '█' : ''} ({searchMatchCount} matches)
|
||||
</Text>
|
||||
) : null}
|
||||
<Box flexDirection="column">
|
||||
{hiddenAbove > 0 ? <Text color={theme.muted}>↑ {hiddenAbove} more</Text> : null}
|
||||
{rows.items.map((nodeId) => (
|
||||
<PickerRow key={nodeId} state={state} nodeId={nodeId} width={width} theme={theme} />
|
||||
))}
|
||||
{hiddenBelow > 0 ? <Text color={theme.muted}>↓ {hiddenBelow} more</Text> : null}
|
||||
</Box>
|
||||
{state.pendingConfirm === 'mode-switch' ? (
|
||||
<Text color={theme.warning}>
|
||||
Save will switch crawl_mode all_accessible -> selected_roots and limit ingest to{' '}
|
||||
{selectedPageCountText(selectedCount)}. [y] confirm [esc] back
|
||||
</Text>
|
||||
) : null}
|
||||
{state.transientHint ? <Text color={theme.warning}>{state.transientHint.text}</Text> : null}
|
||||
<Text color={theme.muted}>space toggle · enter expand · / search · a all · n none · s save & exit · q quit</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function renderInk(tree: ReactNode, options: NotionPickerInkRenderOptions): NotionPickerInkInstance {
|
||||
return renderInkRuntime(tree, {
|
||||
stdin: options.stdin as NodeJS.ReadStream | undefined,
|
||||
stdout: options.stdout as NodeJS.WriteStream,
|
||||
stderr: options.stderr as NodeJS.WriteStream,
|
||||
exitOnCtrlC: options.exitOnCtrlC,
|
||||
patchConsole: options.patchConsole,
|
||||
maxFps: options.maxFps,
|
||||
alternateScreen: options.alternateScreen,
|
||||
}) as NotionPickerInkInstance;
|
||||
}
|
||||
|
||||
export async function renderNotionPickerTui(
|
||||
input: PickerRenderInput,
|
||||
io: NotionPickerTuiIo,
|
||||
options: { renderInk?: (tree: ReactNode, options: NotionPickerInkRenderOptions) => NotionPickerInkInstance } = {},
|
||||
): Promise<PickerRenderResult> {
|
||||
let result: PickerRenderResult = { kind: 'quit' };
|
||||
let instance: NotionPickerInkInstance | null = null;
|
||||
try {
|
||||
instance = (options.renderInk ?? renderInk)(
|
||||
<NotionPickerApp
|
||||
{...input}
|
||||
terminalRows={(io.stdout as { rows?: number }).rows ?? process.stdout.rows ?? 24}
|
||||
terminalWidth={io.stdout.columns ?? process.stdout.columns}
|
||||
onExit={(next) => {
|
||||
result = next;
|
||||
instance?.unmount();
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
stdin: io.stdin,
|
||||
stdout: io.stdout,
|
||||
stderr: io.stderr,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
maxFps: 30,
|
||||
alternateScreen: true,
|
||||
},
|
||||
);
|
||||
await instance.waitUntilExit();
|
||||
instance.unmount();
|
||||
return result;
|
||||
} catch (error) {
|
||||
io.stderr.write(
|
||||
`Notion picker requires a TTY. Use --no-input --root-page-id <UUID> for scripted mode. ${sanitizeNotionPickerTuiError(error)}\n`,
|
||||
);
|
||||
return { kind: 'quit' };
|
||||
}
|
||||
}
|
||||
466
packages/cli/src/commands/connection-notion.test.ts
Normal file
466
packages/cli/src/commands/connection-notion.test.ts
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
initKloProject,
|
||||
loadKloProject,
|
||||
serializeKloProjectConfig,
|
||||
type KloProjectConfig,
|
||||
} from '@klo/context/project';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
applyNotionPickerWriteback,
|
||||
discoverNotionPickerPages,
|
||||
notionPickerPageFromSearchResult,
|
||||
normalizeNotionPageId,
|
||||
resolveNotionWorkspaceLabel,
|
||||
runKloConnectionNotion,
|
||||
type NotionPickerApi,
|
||||
type PickerRenderInput,
|
||||
type PickerRenderResult,
|
||||
} from './connection-notion.js';
|
||||
|
||||
function makeIo() {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
return {
|
||||
io: {
|
||||
stdout: {
|
||||
write: (chunk: string) => {
|
||||
stdout += chunk;
|
||||
},
|
||||
},
|
||||
stderr: {
|
||||
write: (chunk: string) => {
|
||||
stderr += chunk;
|
||||
},
|
||||
},
|
||||
},
|
||||
stdout: () => stdout,
|
||||
stderr: () => stderr,
|
||||
};
|
||||
}
|
||||
|
||||
type FakeNotionSearchPage = Record<string, unknown> & { id: string; object: 'page' };
|
||||
|
||||
const PAGE_IDS = {
|
||||
engineering: '11111111-1111-1111-1111-111111111111',
|
||||
architecture: '22222222-2222-2222-2222-222222222222',
|
||||
stale: '99999999-9999-9999-9999-999999999999',
|
||||
};
|
||||
|
||||
function notionPage(id: string, title: string, parentId: string | null = null): FakeNotionSearchPage {
|
||||
return {
|
||||
object: 'page',
|
||||
id,
|
||||
archived: false,
|
||||
parent: parentId ? { type: 'page_id', page_id: parentId } : { type: 'workspace', workspace: true },
|
||||
properties: {
|
||||
title: {
|
||||
type: 'title',
|
||||
title: [{ plain_text: title }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeNotionApi(pages: FakeNotionSearchPage[]): NotionPickerApi {
|
||||
return {
|
||||
search: vi.fn(async (_filterValue, startCursor) => {
|
||||
if (startCursor === 'page-2') {
|
||||
return { results: pages.slice(2), hasMore: false, nextCursor: null };
|
||||
}
|
||||
return {
|
||||
results: pages.slice(0, 2),
|
||||
hasMore: pages.length > 2,
|
||||
nextCursor: pages.length > 2 ? 'page-2' : null,
|
||||
};
|
||||
}),
|
||||
retrieveBotUser: vi.fn(async () => ({ name: 'Notion bot', bot: { workspace_name: 'Design Workspace' } })),
|
||||
};
|
||||
}
|
||||
|
||||
describe('normalizeNotionPageId', () => {
|
||||
it('accepts dashed and compact UUIDs', () => {
|
||||
expect(normalizeNotionPageId('11111111222233334444555555555555')).toBe(
|
||||
'11111111-2222-3333-4444-555555555555',
|
||||
);
|
||||
expect(normalizeNotionPageId('AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE')).toBe(
|
||||
'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runKloConnectionNotion', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'klo-cli-notion-pick-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeProjectConfig(projectDir: string, config: KloProjectConfig): Promise<void> {
|
||||
const project = await loadKloProject({ projectDir });
|
||||
await project.fileStore.writeFile(
|
||||
'klo.yaml',
|
||||
serializeKloProjectConfig(config),
|
||||
'klo',
|
||||
'klo@example.com',
|
||||
'seed test config',
|
||||
);
|
||||
}
|
||||
|
||||
it('rejects unsafe connection ids before loading a project', async () => {
|
||||
const io = makeIo();
|
||||
const loadProject = vi.fn(async () => {
|
||||
throw new Error('loadProject should not be called');
|
||||
});
|
||||
|
||||
await expect(
|
||||
runKloConnectionNotion(
|
||||
{
|
||||
command: 'pick',
|
||||
projectDir: '/tmp/project',
|
||||
connectionId: '../evil',
|
||||
mode: 'interactive',
|
||||
},
|
||||
io.io,
|
||||
{ loadProject },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(loadProject).not.toHaveBeenCalled();
|
||||
expect(io.stderr()).toContain('Unsafe connection id: ../evil');
|
||||
});
|
||||
|
||||
it('writes selected root_page_ids while preserving every other Notion connection field', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
const initialized = await initKloProject({ projectDir, projectName: 'warehouse' });
|
||||
await writeProjectConfig(projectDir, {
|
||||
...initialized.config,
|
||||
connections: {
|
||||
'notion-main': {
|
||||
driver: 'notion',
|
||||
auth_token_ref: 'env:NOTION_TOKEN',
|
||||
crawl_mode: 'all_accessible',
|
||||
root_page_ids: ['99999999-9999-9999-9999-999999999999'],
|
||||
root_database_ids: ['database-1'],
|
||||
root_data_source_ids: ['data-source-1'],
|
||||
max_pages_per_run: 12,
|
||||
max_knowledge_creates_per_run: 2,
|
||||
max_knowledge_updates_per_run: 7,
|
||||
last_successful_cursor: '{"phase":"all_accessible_pages","cursor":"cursor-1"}',
|
||||
unknown_future_field: 'keep-me',
|
||||
},
|
||||
},
|
||||
});
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnectionNotion(
|
||||
{
|
||||
command: 'pick',
|
||||
projectDir,
|
||||
connectionId: 'notion-main',
|
||||
mode: 'non-interactive',
|
||||
rootPageIds: [
|
||||
'11111111-2222-3333-4444-555555555555',
|
||||
'66666666-7777-8888-9999-aaaaaaaaaaaa',
|
||||
],
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const yaml = await readFile(join(projectDir, 'klo.yaml'), 'utf-8');
|
||||
expect(yaml).toContain('crawl_mode: selected_roots');
|
||||
expect(yaml).toContain('root_page_ids:');
|
||||
expect(yaml).toContain('11111111-2222-3333-4444-555555555555');
|
||||
expect(yaml).toContain('66666666-7777-8888-9999-aaaaaaaaaaaa');
|
||||
expect(yaml).toContain('root_database_ids:');
|
||||
expect(yaml).toContain('database-1');
|
||||
expect(yaml).toContain('root_data_source_ids:');
|
||||
expect(yaml).toContain('data-source-1');
|
||||
expect(yaml).toContain('last_successful_cursor: \'{"phase":"all_accessible_pages","cursor":"cursor-1"}\'');
|
||||
expect(yaml).toContain('unknown_future_field: keep-me');
|
||||
expect(io.stdout()).toContain('Connection: notion-main');
|
||||
expect(io.stdout()).toContain('rootPageIds: 2');
|
||||
expect(io.stdout()).toContain('crawlMode: selected_roots');
|
||||
});
|
||||
|
||||
it('rejects empty writeback, missing connections, and non-Notion connections', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
const initialized = await initKloProject({ projectDir, projectName: 'warehouse' });
|
||||
await writeProjectConfig(projectDir, {
|
||||
...initialized.config,
|
||||
connections: {
|
||||
warehouse: {
|
||||
driver: 'postgres',
|
||||
url: 'env:DATABASE_URL',
|
||||
readonly: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const project = await loadKloProject({ projectDir });
|
||||
|
||||
await expect(applyNotionPickerWriteback(project, 'warehouse', [])).rejects.toThrow(
|
||||
'connection notion pick requires at least one root page id',
|
||||
);
|
||||
await expect(
|
||||
applyNotionPickerWriteback(project, 'missing', ['11111111-2222-3333-4444-555555555555']),
|
||||
).rejects.toThrow('Connection "missing" not found');
|
||||
await expect(
|
||||
applyNotionPickerWriteback(project, 'warehouse', ['11111111-2222-3333-4444-555555555555']),
|
||||
).rejects.toThrow('Connection "warehouse" is not a Notion connection');
|
||||
});
|
||||
|
||||
it('extracts picker page inputs from Notion search results', () => {
|
||||
expect(notionPickerPageFromSearchResult(notionPage(PAGE_IDS.architecture, 'Architecture', PAGE_IDS.engineering)))
|
||||
.toEqual({
|
||||
id: PAGE_IDS.architecture,
|
||||
title: 'Architecture',
|
||||
archived: false,
|
||||
parentId: PAGE_IDS.engineering,
|
||||
});
|
||||
|
||||
expect(
|
||||
notionPickerPageFromSearchResult({
|
||||
object: 'page',
|
||||
id: PAGE_IDS.engineering.replaceAll('-', ''),
|
||||
archived: true,
|
||||
parent: { type: 'workspace', workspace: true },
|
||||
properties: {},
|
||||
}),
|
||||
).toEqual({
|
||||
id: PAGE_IDS.engineering,
|
||||
title: 'Untitled',
|
||||
archived: true,
|
||||
parentId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('discovers visible pages up to the cap and reports cap state', async () => {
|
||||
const api = fakeNotionApi([
|
||||
notionPage(PAGE_IDS.engineering, 'Engineering'),
|
||||
notionPage(PAGE_IDS.architecture, 'Architecture', PAGE_IDS.engineering),
|
||||
notionPage('33333333-3333-3333-3333-333333333333', 'Onboarding', PAGE_IDS.engineering),
|
||||
]);
|
||||
|
||||
await expect(discoverNotionPickerPages(api, { cap: 2 })).resolves.toEqual({
|
||||
pages: [
|
||||
{ id: PAGE_IDS.engineering, title: 'Engineering', archived: false, parentId: null },
|
||||
{ id: PAGE_IDS.architecture, title: 'Architecture', archived: false, parentId: PAGE_IDS.engineering },
|
||||
],
|
||||
cappedAtCount: 2,
|
||||
warnings: [],
|
||||
});
|
||||
expect(api.search).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps partial discovery results when Notion search fails after at least one page', async () => {
|
||||
const api: NotionPickerApi = {
|
||||
search: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
results: [notionPage(PAGE_IDS.engineering, 'Engineering')],
|
||||
hasMore: true,
|
||||
nextCursor: 'cursor-2',
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('rate limit after first page')),
|
||||
retrieveBotUser: vi.fn(async () => ({ name: 'Notion bot' })),
|
||||
};
|
||||
|
||||
await expect(discoverNotionPickerPages(api)).resolves.toEqual({
|
||||
pages: [{ id: PAGE_IDS.engineering, title: 'Engineering', archived: false, parentId: null }],
|
||||
cappedAtCount: null,
|
||||
warnings: ['Notion search stopped early: rate limit after first page'],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the Notion workspace name when available and falls back to the connection id', async () => {
|
||||
await expect(resolveNotionWorkspaceLabel(fakeNotionApi([]), 'notion-main')).resolves.toBe('Design Workspace');
|
||||
await expect(
|
||||
resolveNotionWorkspaceLabel(
|
||||
{
|
||||
search: vi.fn(),
|
||||
retrieveBotUser: vi.fn(async () => {
|
||||
throw new Error('users.me unavailable');
|
||||
}),
|
||||
},
|
||||
'notion-main',
|
||||
),
|
||||
).resolves.toBe('notion-main');
|
||||
});
|
||||
|
||||
it('runs interactive discovery, warns about stale roots, renders the TUI, and saves selected roots', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
const initialized = await initKloProject({ projectDir, projectName: 'warehouse' });
|
||||
await writeProjectConfig(projectDir, {
|
||||
...initialized.config,
|
||||
connections: {
|
||||
'notion-main': {
|
||||
driver: 'notion',
|
||||
auth_token_ref: 'env:NOTION_TOKEN',
|
||||
crawl_mode: 'all_accessible',
|
||||
root_page_ids: [PAGE_IDS.stale],
|
||||
root_database_ids: ['database-1'],
|
||||
root_data_source_ids: ['data-source-1'],
|
||||
max_pages_per_run: 12,
|
||||
max_knowledge_creates_per_run: 2,
|
||||
max_knowledge_updates_per_run: 7,
|
||||
last_successful_cursor: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const api = fakeNotionApi([
|
||||
notionPage(PAGE_IDS.engineering, 'Engineering'),
|
||||
notionPage(PAGE_IDS.architecture, 'Architecture', PAGE_IDS.engineering),
|
||||
]);
|
||||
const renderPicker = vi.fn(async (input): Promise<PickerRenderResult> => {
|
||||
expect(input.connectionId).toBe('notion-main');
|
||||
expect(input.workspaceLabel).toBe('Design Workspace');
|
||||
expect(input.currentCrawlMode).toBe('all_accessible');
|
||||
expect(input.cappedAtCount).toBeNull();
|
||||
expect(input.initialState.preLoadWarnings).toEqual(['1 stored root_page_ids no longer visible']);
|
||||
return { kind: 'save', rootPageIds: [PAGE_IDS.engineering] };
|
||||
});
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnectionNotion(
|
||||
{
|
||||
command: 'pick',
|
||||
projectDir,
|
||||
connectionId: 'notion-main',
|
||||
mode: 'interactive',
|
||||
},
|
||||
io.io,
|
||||
{
|
||||
env: { NOTION_TOKEN: 'ntn_test_token' },
|
||||
createNotionApi: vi.fn(() => api),
|
||||
renderPicker,
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const yaml = await readFile(join(projectDir, 'klo.yaml'), 'utf-8');
|
||||
expect(yaml).toContain('crawl_mode: selected_roots');
|
||||
expect(yaml).toContain(PAGE_IDS.engineering);
|
||||
expect(yaml).not.toContain(PAGE_IDS.stale);
|
||||
expect(io.stderr()).toContain('1 stored root_page_ids no longer visible');
|
||||
expect(io.stdout()).toContain('Connection: notion-main');
|
||||
expect(io.stdout()).toContain('rootPageIds: 1');
|
||||
});
|
||||
|
||||
it('passes partial-discovery warnings into the TUI banner state', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
const initialized = await initKloProject({ projectDir, projectName: 'warehouse' });
|
||||
await writeProjectConfig(projectDir, {
|
||||
...initialized.config,
|
||||
connections: {
|
||||
'notion-main': {
|
||||
driver: 'notion',
|
||||
auth_token_ref: 'env:NOTION_TOKEN',
|
||||
crawl_mode: 'selected_roots',
|
||||
root_page_ids: [PAGE_IDS.engineering],
|
||||
root_database_ids: [],
|
||||
root_data_source_ids: [],
|
||||
max_pages_per_run: 12,
|
||||
max_knowledge_creates_per_run: 2,
|
||||
max_knowledge_updates_per_run: 7,
|
||||
last_successful_cursor: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const api: NotionPickerApi = {
|
||||
search: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
results: [notionPage(PAGE_IDS.engineering, 'Engineering')],
|
||||
hasMore: true,
|
||||
nextCursor: 'cursor-2',
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('rate limit after first page')),
|
||||
retrieveBotUser: vi.fn(async () => ({ name: 'Notion bot', bot: { workspace_name: 'Design Workspace' } })),
|
||||
};
|
||||
let renderInput: PickerRenderInput | undefined;
|
||||
const renderPicker = vi.fn(async (input: PickerRenderInput): Promise<PickerRenderResult> => {
|
||||
renderInput = input;
|
||||
return { kind: 'quit' };
|
||||
});
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnectionNotion(
|
||||
{
|
||||
command: 'pick',
|
||||
projectDir,
|
||||
connectionId: 'notion-main',
|
||||
mode: 'interactive',
|
||||
},
|
||||
io.io,
|
||||
{
|
||||
env: { NOTION_TOKEN: 'ntn_test_token' },
|
||||
createNotionApi: vi.fn(() => api),
|
||||
renderPicker,
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(renderPicker).toHaveBeenCalledOnce();
|
||||
if (!renderInput) {
|
||||
throw new Error('renderPicker was not called');
|
||||
}
|
||||
expect(renderInput.initialState.preLoadWarnings).toEqual(['Notion search stopped early: rate limit after first page']);
|
||||
expect(renderInput.initialState.tree.map((node) => node.title)).toEqual(['Engineering']);
|
||||
expect(io.stderr()).toContain('Notion search stopped early: rate limit after first page');
|
||||
expect(io.stdout()).toContain('No changes saved.');
|
||||
});
|
||||
|
||||
it('quits interactive mode without writing when the TUI returns quit', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
const initialized = await initKloProject({ projectDir, projectName: 'warehouse' });
|
||||
await writeProjectConfig(projectDir, {
|
||||
...initialized.config,
|
||||
connections: {
|
||||
'notion-main': {
|
||||
driver: 'notion',
|
||||
auth_token_ref: 'env:NOTION_TOKEN',
|
||||
crawl_mode: 'selected_roots',
|
||||
root_page_ids: [PAGE_IDS.engineering],
|
||||
root_database_ids: [],
|
||||
root_data_source_ids: [],
|
||||
max_pages_per_run: 12,
|
||||
max_knowledge_creates_per_run: 2,
|
||||
max_knowledge_updates_per_run: 7,
|
||||
last_successful_cursor: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const before = await readFile(join(projectDir, 'klo.yaml'), 'utf-8');
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnectionNotion(
|
||||
{
|
||||
command: 'pick',
|
||||
projectDir,
|
||||
connectionId: 'notion-main',
|
||||
mode: 'interactive',
|
||||
},
|
||||
io.io,
|
||||
{
|
||||
env: { NOTION_TOKEN: 'ntn_test_token' },
|
||||
createNotionApi: vi.fn(() => fakeNotionApi([notionPage(PAGE_IDS.engineering, 'Engineering')])),
|
||||
renderPicker: vi.fn(async (): Promise<PickerRenderResult> => ({ kind: 'quit' })),
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
await expect(readFile(join(projectDir, 'klo.yaml'), 'utf-8')).resolves.toBe(before);
|
||||
expect(io.stdout()).toContain('No changes saved.');
|
||||
});
|
||||
});
|
||||
278
packages/cli/src/commands/connection-notion.ts
Normal file
278
packages/cli/src/commands/connection-notion.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import { parseNotionConnectionConfig, resolveNotionAuthToken } from '@klo/context/connections';
|
||||
import { type NotionApi, type NotionBotInfo, NotionClient } from '@klo/context/ingest';
|
||||
import {
|
||||
type KloLocalProject,
|
||||
type KloProjectConnectionConfig,
|
||||
loadKloProject,
|
||||
serializeKloProjectConfig,
|
||||
} from '@klo/context/project';
|
||||
import type { KloCliIo } from '../index.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
import { buildInitialState, buildPickerTree, type NotionPickerPageInput } from './connection-notion-tree.js';
|
||||
import {
|
||||
type NotionPickerTuiIo,
|
||||
type PickerRenderInput,
|
||||
type PickerRenderResult,
|
||||
renderNotionPickerTui,
|
||||
} from './connection-notion-tui.js';
|
||||
|
||||
profileMark('module:commands/connection-notion');
|
||||
|
||||
export type KloConnectionNotionArgs =
|
||||
| {
|
||||
command: 'pick';
|
||||
projectDir: string;
|
||||
connectionId: string;
|
||||
mode: 'interactive';
|
||||
}
|
||||
| {
|
||||
command: 'pick';
|
||||
projectDir: string;
|
||||
connectionId: string;
|
||||
mode: 'non-interactive';
|
||||
rootPageIds: string[];
|
||||
};
|
||||
|
||||
export type NotionPickerApi = Pick<NotionApi, 'search' | 'retrieveBotUser'>;
|
||||
export type { PickerRenderInput, PickerRenderResult };
|
||||
|
||||
interface KloConnectionNotionDeps {
|
||||
env?: Record<string, string | undefined>;
|
||||
loadProject?: typeof loadKloProject;
|
||||
createNotionApi?: (authToken: string) => NotionPickerApi;
|
||||
renderPicker?: (input: PickerRenderInput, io: NotionPickerTuiIo) => Promise<PickerRenderResult>;
|
||||
}
|
||||
|
||||
const NOTION_PICKER_PAGE_CAP = 5000;
|
||||
|
||||
function assertSafeConnectionId(connectionId: string): void {
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(connectionId)) {
|
||||
throw new Error(`Unsafe connection id: ${connectionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeNotionPageId(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
const compact = trimmed.includes('-') ? trimmed.replace(/-/g, '') : trimmed;
|
||||
if (!/^[0-9a-fA-F]{32}$/.test(compact)) {
|
||||
throw new Error(`Invalid Notion page UUID: ${value}`);
|
||||
}
|
||||
const lower = compact.toLowerCase();
|
||||
return `${lower.slice(0, 8)}-${lower.slice(8, 12)}-${lower.slice(12, 16)}-${lower.slice(16, 20)}-${lower.slice(20)}`;
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function extractTitleFromNotionPage(page: Record<string, unknown>): string {
|
||||
const properties = recordValue(page.properties);
|
||||
if (!properties) {
|
||||
return 'Untitled';
|
||||
}
|
||||
for (const property of Object.values(properties)) {
|
||||
const value = recordValue(property);
|
||||
if (!value || value.type !== 'title' || !Array.isArray(value.title)) {
|
||||
continue;
|
||||
}
|
||||
const text = value.title
|
||||
.map((part) => {
|
||||
const richText = recordValue(part);
|
||||
return typeof richText?.plain_text === 'string' ? richText.plain_text : '';
|
||||
})
|
||||
.join('')
|
||||
.trim();
|
||||
if (text.length > 0) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return 'Untitled';
|
||||
}
|
||||
|
||||
function extractParentPageId(page: Record<string, unknown>): string | null {
|
||||
const parent = recordValue(page.parent);
|
||||
if (!parent || parent.type !== 'page_id' || typeof parent.page_id !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return normalizeNotionPageId(parent.page_id);
|
||||
}
|
||||
|
||||
export function notionPickerPageFromSearchResult(result: Record<string, unknown>): NotionPickerPageInput {
|
||||
const id = typeof result.id === 'string' ? normalizeNotionPageId(result.id) : '';
|
||||
if (!id) {
|
||||
throw new Error('Notion page search result is missing id');
|
||||
}
|
||||
return {
|
||||
id,
|
||||
title: extractTitleFromNotionPage(result),
|
||||
archived: result.archived === true,
|
||||
parentId: extractParentPageId(result),
|
||||
};
|
||||
}
|
||||
|
||||
export async function discoverNotionPickerPages(
|
||||
api: NotionPickerApi,
|
||||
options: { cap?: number } = {},
|
||||
): Promise<{ pages: NotionPickerPageInput[]; cappedAtCount: number | null; warnings: string[] }> {
|
||||
const cap = options.cap ?? NOTION_PICKER_PAGE_CAP;
|
||||
const pages: NotionPickerPageInput[] = [];
|
||||
const warnings: string[] = [];
|
||||
let cursor: string | null | undefined = null;
|
||||
|
||||
while (pages.length < cap) {
|
||||
let response: Awaited<ReturnType<NotionPickerApi['search']>>;
|
||||
try {
|
||||
response = await api.search('page', cursor, Math.min(100, cap - pages.length));
|
||||
} catch (error) {
|
||||
if (pages.length === 0) {
|
||||
throw error;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
warnings.push(`Notion search stopped early: ${message}`);
|
||||
return { pages, cappedAtCount: null, warnings };
|
||||
}
|
||||
|
||||
for (const result of response.results) {
|
||||
pages.push(notionPickerPageFromSearchResult(result));
|
||||
if (pages.length >= cap) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.hasMore || !response.nextCursor || pages.length >= cap) {
|
||||
return {
|
||||
pages,
|
||||
cappedAtCount: response.hasMore ? cap : null,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
cursor = response.nextCursor;
|
||||
}
|
||||
|
||||
return { pages, cappedAtCount: cap, warnings };
|
||||
}
|
||||
|
||||
export async function resolveNotionWorkspaceLabel(api: NotionPickerApi, connectionId: string): Promise<string> {
|
||||
try {
|
||||
const bot = (await api.retrieveBotUser()) as NotionBotInfo;
|
||||
const workspaceName = typeof bot.bot?.workspace_name === 'string' ? bot.bot.workspace_name.trim() : '';
|
||||
if (workspaceName.length > 0) {
|
||||
return workspaceName;
|
||||
}
|
||||
const name = typeof bot.name === 'string' ? bot.name.trim() : '';
|
||||
return name.length > 0 ? name : connectionId;
|
||||
} catch {
|
||||
return connectionId;
|
||||
}
|
||||
}
|
||||
|
||||
function notionConnection(project: KloLocalProject, connectionId: string): KloProjectConnectionConfig {
|
||||
const connection = project.config.connections[connectionId];
|
||||
if (!connection) {
|
||||
throw new Error(`Connection "${connectionId}" not found`);
|
||||
}
|
||||
if (connection.driver !== 'notion') {
|
||||
throw new Error(`Connection "${connectionId}" is not a Notion connection`);
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
export async function applyNotionPickerWriteback(
|
||||
project: KloLocalProject,
|
||||
connectionId: string,
|
||||
rootPageIds: string[],
|
||||
): Promise<void> {
|
||||
if (rootPageIds.length === 0) {
|
||||
throw new Error('connection notion pick requires at least one root page id');
|
||||
}
|
||||
|
||||
const existing = notionConnection(project, connectionId);
|
||||
const nextConfig = {
|
||||
...project.config,
|
||||
connections: {
|
||||
...project.config.connections,
|
||||
[connectionId]: {
|
||||
...existing,
|
||||
crawl_mode: 'selected_roots',
|
||||
root_page_ids: rootPageIds,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await project.fileStore.writeFile(
|
||||
'klo.yaml',
|
||||
serializeKloProjectConfig(nextConfig),
|
||||
'klo',
|
||||
'klo@example.com',
|
||||
`Pick Notion roots: ${connectionId} (${rootPageIds.length} pages)`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runKloConnectionNotion(
|
||||
args: KloConnectionNotionArgs,
|
||||
io: KloCliIo = process,
|
||||
deps: KloConnectionNotionDeps = {},
|
||||
): Promise<number> {
|
||||
try {
|
||||
assertSafeConnectionId(args.connectionId);
|
||||
const loadProject = deps.loadProject ?? loadKloProject;
|
||||
|
||||
if (args.mode === 'interactive') {
|
||||
const project = await loadProject({ projectDir: args.projectDir });
|
||||
const rawConnection = notionConnection(project, args.connectionId);
|
||||
const notion = parseNotionConnectionConfig(rawConnection);
|
||||
const authToken = await resolveNotionAuthToken(notion.auth_token_ref, { env: deps.env });
|
||||
const api = deps.createNotionApi ? deps.createNotionApi(authToken) : new NotionClient(authToken);
|
||||
const discovery = await discoverNotionPickerPages(api);
|
||||
const tree = buildPickerTree(discovery.pages);
|
||||
const initialState = buildInitialState({
|
||||
tree,
|
||||
existingRootPageIds: notion.root_page_ids,
|
||||
currentCrawlMode: notion.crawl_mode,
|
||||
});
|
||||
const preLoadWarnings = [...discovery.warnings, ...initialState.preLoadWarnings];
|
||||
const renderState =
|
||||
preLoadWarnings.length > 0
|
||||
? {
|
||||
...initialState,
|
||||
preLoadWarnings,
|
||||
}
|
||||
: initialState;
|
||||
for (const warning of preLoadWarnings) {
|
||||
io.stderr.write(`${warning}\n`);
|
||||
}
|
||||
const workspaceLabel = await resolveNotionWorkspaceLabel(api, args.connectionId);
|
||||
const result = await (deps.renderPicker ?? renderNotionPickerTui)(
|
||||
{
|
||||
initialState: renderState,
|
||||
connectionId: args.connectionId,
|
||||
workspaceLabel,
|
||||
cappedAtCount: discovery.cappedAtCount,
|
||||
currentCrawlMode: notion.crawl_mode,
|
||||
},
|
||||
io as NotionPickerTuiIo,
|
||||
);
|
||||
if (result.kind === 'quit') {
|
||||
io.stdout.write('No changes saved.\n');
|
||||
return 0;
|
||||
}
|
||||
await applyNotionPickerWriteback(project, args.connectionId, result.rootPageIds);
|
||||
io.stdout.write(`Connection: ${args.connectionId}\n`);
|
||||
io.stdout.write(`rootPageIds: ${result.rootPageIds.length}\n`);
|
||||
io.stdout.write('crawlMode: selected_roots\n');
|
||||
return 0;
|
||||
}
|
||||
|
||||
const project = await loadProject({ projectDir: args.projectDir });
|
||||
await applyNotionPickerWriteback(project, args.connectionId, args.rootPageIds);
|
||||
io.stdout.write(`Connection: ${args.connectionId}\n`);
|
||||
io.stdout.write(`rootPageIds: ${args.rootPageIds.length}\n`);
|
||||
io.stdout.write('crawlMode: selected_roots\n');
|
||||
return 0;
|
||||
} catch (error) {
|
||||
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue