diff --git a/.env.example b/.env.example index 2e1091c..e1af09b 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,10 @@ API_PORT=8000 AI_INFERENCE_HOST=127.0.0.1 AI_INFERENCE_PORT=8001 REDIS_URL=redis://localhost:6379/0 -DATABASE_URL=mysql+pymysql://labsafe:change-me@localhost:3306/labsafe +wsq/i-01b-database-foundation +DATABASE_URL_FILE=.local-secrets/database-url +DATABASE_SCHEMA=labsafe_dev_local + AI_MODE=mock AI_TOKEN_FILE=.local-secrets/ai-token AI_ALLOWED_TENANTS=11111111-1111-4111-8111-111111111111 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb7f638..fe4dd37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,8 +30,10 @@ jobs: - run: python -m pip check - run: python -m unittest discover -s tests/business -t . -v - run: python -m unittest discover -s tests/protocol -t . -v - - run: ruff check apps packages tests - - run: ruff format --check apps packages tests + + - run: ruff check apps packages tests tools/database + - run: ruff format --check apps packages tests tools/database + ai-python311: runs-on: ubuntu-latest @@ -46,7 +48,9 @@ jobs: - run: python -m pip check - name: AI environment must not install business or model dependencies run: | - python -c "import importlib.util; names=['celery','redis','sqlalchemy','torch','paddle','onnxruntime']; assert all(importlib.util.find_spec(n) is None for n in names)" + + python -c "import importlib.util; names=['celery','redis','sqlalchemy','alembic','pymysql','argon2','torch','paddle','onnxruntime']; assert all(importlib.util.find_spec(n) is None for n in names)" + - run: python -m unittest discover -s tests/ai -t . -v - run: python -m unittest discover -s tests/protocol -t . -v @@ -63,6 +67,37 @@ jobs: - run: python -B tools/design/validate_specs.py - run: git diff --exit-code + + persistence-mysql: + runs-on: ubuntu-latest + services: + mysql: + image: mysql:8.0.33 + env: + # Disposable CI instance only; never use this test credential for deployments. + MYSQL_ROOT_PASSWORD: ${{ github.run_id }}-${{ github.run_attempt }}-mysql-test-only + MYSQL_ROOT_HOST: '%' + ports: + - 3306/tcp + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 --silent" + --health-interval=5s --health-timeout=5s --health-retries=30 + env: + APP_ENV: test + CI: 'true' + LABSAFE_MYSQL_INTEGRATION: '1' + LABSAFE_CI_MYSQL_PASSWORD: ${{ github.run_id }}-${{ github.run_attempt }}-mysql-test-only + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - run: python -m pip install -r requirements/dev.txt + - run: python tools/database/run_mysql_tests.py --ci + env: + LABSAFE_CI_MYSQL_PORT: ${{ job.services.mysql.ports[3306] }} + + web: runs-on: ubuntu-latest defaults: diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 0d8b5d0..a208714 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,12 +1,16 @@ -# LabSafe 开发指南(I-01A) + +# LabSafe 开发指南(I-01A / I-01B) + ## 1. 本阶段边界 所有 Python 服务统一使用 Python 3.11.x;业务与 AI 使用独立虚拟环境和独立进程。 依据见 [运行时决策](docs/02-architecture/07-python-runtime.md)。前端 CI 使用 Node.js 20。 -本阶段提供真实 FastAPI/Celery 入口、同协议开发 fixture、配置保护和 CI。 -不包含数据库迁移、用户会话、业务 API、持久任务、真实图像处理、训练权重或生产部署。 + +已提供真实 FastAPI/Celery 入口、同协议开发 fixture、配置保护,以及 I-01B 初始数据库迁移、租户初始化和真实 MySQL 测试。 +不包含用户会话、RBAC/仓储、业务 API、持久任务、真实图像处理、训练权重或生产部署。 + API 的 /ready 只检查 I-01A 进程配置,不代表数据库或完整系统已就绪。 所有后端进程和前端构建均拒绝 production;AI_MODE 只接受 mock。 @@ -112,6 +116,54 @@ npm run dev 两个环境变量必须相等且为 dev/test。开发页面始终显示“开发环境,结果不用于安全判断”。 构建命令是 npm run build;production 或不一致环境必须失败。 + +### 3.5 数据库迁移与初始化(I-01B) + +先在独立的开发 MySQL 8.0.16+ 实例创建专用空库 labsafe_dev_local。 +由数据库管理员提供仅对该库具有 SELECT/INSERT/UPDATE/DELETE/CREATE/ALTER/DROP/INDEX/REFERENCES 权限的迁移/初始化账户;不要给 API/Worker 或 AI 使用该管理账户。 +本阶段只允许本机回环连接;远端 TLS、应用最小权限账户和生产发布不在 I-01B 范围内。 + +将管理员提供的 mysql+pymysql 连接 URL 保存到 .local-secrets/database-url,包含用户名、URL 编码后的密码、回环地址、端口和准确的库名。 +URL 不允许 query 参数;程序只读取 DATABASE_URL_FILE,不读取明文 DATABASE_URL,也不自动加载 .env。 +该文件必须仅当前用户可读;不要把真实连接串放在命令行、终端历史、日志、PR 或文档中。 + +~~~powershell +$env:APP_ENV = 'dev' +$env:DATABASE_SCHEMA = 'labsafe_dev_local' +$env:DATABASE_URL_FILE = (Resolve-Path .local-secrets/database-url).Path +.\.venv-business\Scripts\python.exe -m packages.persistence.cli upgrade +.\.venv-business\Scripts\python.exe -m packages.persistence.cli verify +.\.venv-business\Scripts\python.exe -m packages.persistence.cli bootstrap-tenant --code my_org --name '机构名称' --timezone Asia/Shanghai --username admin --display-name '初始管理员' --reason '首次初始化' +~~~ + +最后一步交互式输入并确认 12–128 字符密码,不提供项目默认密码。 +非交互环境使用 --password-file 指向一次性 UTF-8 秘密文件:内容就是密码,不要额外添加 BOM 或结尾换行,程序不会 trim/规范化密码;成功后由提供该文件的操作者安全移除。 +用户名执行 NFKC+casefold;租户、六角色、租户级 safety_admin 授权与脱敏审计在一个事务中提交。 +重复初始化同一租户返回错误,绝不重置已有密码;本命令不是登录接口,也不是管理员重置工具。 +upgrade 成功不等于结构正确,必须继续 verify;JSON 中 errors 应为空,退出码为 0。 +CLI 退出码:0=成功,2=输入/保护检查失败,1=数据库/迁移/结构检查失败;错误不输出连接串或密码。 + +初始迁移仅允许空库(可以已有 Alembic 版本表);不使用 stamp 绕过保护。 +MySQL DDL 非整体事务:中途失败可能遗留部分表,不能承诺自动回滚。 +此时保留失败证据,并由操作者确认后丢弃整个专用测试库重建;不要对已有业务库执行重建。 +降级会删除全部 43 张业务表,只能在 APP_ENV=test 且 DATABASE_SCHEMA 为 labsafe_test_ 前缀的可丢弃库执行: + +~~~powershell +# 仅在已确认的专用测试库配置中执行,绝不能指向业务/开发数据库。 +$env:LABSAFE_ALLOW_DESTRUCTIVE_DOWNGRADE = $env:DATABASE_SCHEMA +.\.venv-business\Scripts\python.exe -m packages.persistence.cli downgrade +Remove-Item Env:LABSAFE_ALLOW_DESTRUCTIVE_DOWNGRADE +~~~ + +本机自动验证不需要配置或连接已有库: + +~~~powershell +.\.venv-business\Scripts\python.exe tools/database/run_mysql_tests.py --mysqld 'D:/SQL/MySQL/MySQL Server 8.0/bin/mysqld.exe' +~~~ + +把路径替换为实际 mysqld 可执行文件。脚本创建临时 data directory、随机回环端口和随机凭据,核对实例身份后才建库;结束后只关闭本次子进程并清理临时数据。 +不注册或修改 Windows 服务。Linux 本机模式需已安装 mysqld,并以允许运行 mysqld 的非 root 用户执行;CI 使用隔离 MySQL 容器模式,不依赖本机服务。 + ## 4. 验证 根目录、业务环境执行: @@ -119,8 +171,11 @@ npm run dev ~~~powershell .\.venv-business\Scripts\python.exe -m unittest discover -s tests/business -t . -v .\.venv-business\Scripts\python.exe -m unittest discover -s tests/protocol -t . -v -.\.venv-business\Scripts\python.exe -m ruff check apps packages tests -.\.venv-business\Scripts\python.exe -m ruff format --check apps packages tests + +.\.venv-business\Scripts\python.exe -m pytest tests/persistence/test_unit.py --tb=short +.\.venv-business\Scripts\python.exe -m ruff check apps packages tests tools/database +.\.venv-business\Scripts\python.exe -m ruff format --check apps packages tests tools/database + ~~~ AI 环境执行: @@ -130,8 +185,11 @@ AI 环境执行: .\.venv-ai\Scripts\python.exe -m unittest discover -s tests/protocol -t . -v ~~~ -测试自行在临时目录生成令牌和合成请求,不要求预设 APP_ENV,也不连接 Redis/MySQL/对象存储。 + +I-01A 的业务/AI/协议测试自行在临时目录生成令牌和合成请求,不要求预设 APP_ENV,也不连接 Redis/MySQL/对象存储。 包含实际 API/AI 子进程 HTTP smoke,不只是 import 非空检查。 +I-01B 的真实数据库测试必须运行 3.5 的隔离启动器;直接 pytest tests/persistence 会跳过未配置的 MySQL 用例,不能把跳过算作数据库通过。 + 设计校验使用独立工具环境,安装 tools/design/requirements.txt 后执行: @@ -148,4 +206,9 @@ CI 必须先 --check,不能先生成来掩盖漂移。设计校验不是应用 遵循 [Git 协作规范](CONTRIBUTING.md)。提交特性分支、创建 PR;不直接推送 main,不自行合并或代替独立审查。 不提交虚拟环境、.local-secrets、真实图片、权重或 .env。 -下一阶段从 I-01B 专用空库迁移开始;真实模型主线可按 I-ML-01 并行,但不扩大本 PR 验收范围。 + +I-01B 的实际结果与后续边界见 [验收记录](docs/08-delivery/08-i01b-acceptance.md)。 +I-01A PR #5 已合并;I-01B 在 wsq/i-01b-database-foundation 上通过 PR #7 提交,目标为最新 main。数据库/安全变更仍需按 CONTRIBUTING 独立评审;不直接推送 main。 +PR #7 的远程 CI 必须运行其自身的 persistence-mysql、业务、AI、设计和 Web 检查,不能用 PR #5 的成绩代替。 +下一阶段 I-01C 是仓储、会话/RBAC、幂等与租户事务;真实模型主线可按 I-ML-01 推进,但不以数据库通过替代模型或生产验收。 + diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..d2cc3b8 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,3 @@ +[alembic] +script_location = %(here)s/packages/persistence/migrations +prepend_sys_path = . diff --git a/contracts/database-design.sql b/contracts/database-design.sql index d88d8bf..0f5c257 100644 --- a/contracts/database-design.sql +++ b/contracts/database-design.sql @@ -83,7 +83,7 @@ CREATE TABLE `user_roles` ( `role_id` CHAR(36) NOT NULL, `laboratory_id` CHAR(36) NULL, `scope_kind` ENUM('tenant','laboratory') NOT NULL DEFAULT 'tenant', - `scope_key` CHAR(36) GENERATED ALWAYS AS (COALESCE(laboratory_id,'00000000-0000-0000-0000-000000000000')) STORED, + `scope_key` CHAR(36) GENERATED ALWAYS AS (COALESCE(laboratory_id,'00000000-0000-0000-0000-000000000000')) STORED NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uq_user_roles_0` (`tenant_id`, `id`), UNIQUE KEY `uq_user_roles_1` (`tenant_id`, `user_id`, `role_id`, `scope_key`), diff --git a/docs/02-architecture/06-data-persistence.md b/docs/02-architecture/06-data-persistence.md index 128c56a..a385bc2 100644 --- a/docs/02-architecture/06-data-persistence.md +++ b/docs/02-architecture/06-data-persistence.md @@ -60,3 +60,7 @@ IRR-04:completeUpload用API内部HEAD取得准确versionId/大小/MIME,HEAD ## 6. 初始化和迁移门禁 先 tenants/roles/users,再组织/模板/词典/模型/规则/activation;循环current pointers初始NULL。角色固定六种;bootstrap管理员由一次性CLI输入密码,不将演示账号写进DDL。数据库管理账户只用于migration;API/worker按表权限分配,AI无账户。DDL脚本不能直接在非空生产schema执行;迁移必须可备份、演练且有前向修复方案。 + +2026-09-29 实现说明:I-01B 已提供 packages/persistence 的 Alembic 0001_initial、只读结构核对和原子租户/角色/管理员初始化。操作与失败恢复见 [开发指南](../../DEVELOPMENT.md),真实 MySQL 证据见 [I-01B 验收](../08-delivery/08-i01b-acceptance.md)。本阶段迁移/初始化凭据只供离线管理 CLI;不代表 API/Worker 已获得业务数据库访问能力。 + +真实库核对发现 DDL 生成器遗漏了 user_roles.scope_key 的 NOT NULL;生成源与未发布的初始迁移快照已补齐,以保持原数据字典定义,不改变角色 scope 规则。迁移发布后不得修改历史快照;后续设计变更必须新增 revision,并更新当前 head 的核对基准。 diff --git a/docs/08-delivery/07-i01a-acceptance.md b/docs/08-delivery/07-i01a-acceptance.md index aebae41..8bcd3b2 100644 --- a/docs/08-delivery/07-i01a-acceptance.md +++ b/docs/08-delivery/07-i01a-acceptance.md @@ -40,6 +40,10 @@ ## 3. 明确未完成的后续阶段 + +以下保留 2026-09-28 的历史边界;2026-09-29 后续实现状态见 [I-01B 验收](08-i01b-acceptance.md),不回写当时的验收结论。 + + - I-01B/C:专用 MySQL、43 表 Alembic 迁移、仓储、会话、RBAC、幂等与租户事务。 - I-02/I-03:实际巡检命令、Outbox/inbox、Worker→AI→DB、租约/fencing/恢复。 - I-03:真实 spawn 计算子进程、硬超时 kill/join 与故障重建;I-01A仅有可取消的合成延迟。 diff --git a/docs/08-delivery/08-i01b-acceptance.md b/docs/08-delivery/08-i01b-acceptance.md new file mode 100644 index 0000000..853dceb --- /dev/null +++ b/docs/08-delivery/08-i01b-acceptance.md @@ -0,0 +1,70 @@ +# 08.8 I-01B 数据库基础验收记录 + +日期:2026-09-29。范围:初始迁移、租户/角色/初始管理员 CLI、真实 MySQL 结构与约束核对。 +本机验证通过不等于协作者批准、远程 CI 通过或生产可用。 + +## 1. 分支与前置依赖 + +本地分支 wsq/i-01b-database-foundation,基于已合并的 I-01A PR #5(main 提交 3a92e09)。 +I-01B PR #7 已推送并以 main 为目标;本页保留分支依赖关系,数据库/安全变更仍需两名协作者共同确认。 +远程 CI 已运行并通过 business-python311、ai-python311、design-contracts、persistence-mysql 和 web 检查;通过 CI 不替代代码评审。 + +## 2. 已交付 + +| 交付项 | 代码入口与约束 | +|---|---| +| 初始 Alembic 迁移 | packages/persistence/migrations/versions/0001_initial.py;43 张业务表、115 个 FK,另有 Alembic 版本表 | +| 不可变迁移输入 | migrations/snapshots/0001_initial.sql/json;执行时不读取可变的 contracts;wheel 包含快照和 revision | +| 连接保护 | database.py;Python 3.11、显式 dev/test、同环境前缀 schema、秘密文件、回环地址、MySQL 8.x 且不低于 8.0.16 | +| 迁移保护 | 非空初始库/视图和 offline 模式拒绝;迁移持有 schema 级命名锁;downgrade 仅允许显式确认的可丢弃 test 库 | +| 初始化 | cli.py / bootstrap.py;租户、固定六角色、租户级 safety_admin、脱敏审计同事务提交;并发初始化串行化,重复租户不重置密码 | +| 密码与账号 | 交互 getpass 或秘密文件;密码 12–128 字符,不 trim/规范化;Argon2id m=65536 KiB、t=3、p=1、salt=16 bytes、hash=32 bytes;用户名 NFKC+casefold | +| 结构核对 | schema.py;表/列顺序、类型、nullability、默认值、生成列、PK/unique/index/FK/check、collation、UTC/strict/FK/unique session 设置 | +| 测试与 CI | tests/persistence、tools/database/run_mysql_tests.py;新增独立 MySQL CI job,不给 AI 安装数据库或密码散列依赖 | + +结构比较只规范化 MySQL 的表示差异,不忽略 NOT NULL、CHECK 优先级或 ENUM 字符串的大小写差异。 +六角色是 safety_admin、lab_manager、inspector、remediator、viewer、rule_expert;只为初始管理员创建 safety_admin 授权,不自动授予其他角色。 +初始化 CLI 不是登录、会话或密码重置实现。 + +## 3. 本机实际验证 + +Windows、Python 3.11.4、MySQL Community Server 8.0.33。数据库测试使用新的临时目录、随机回环端口、随机凭据与专用随机 schema。 +启动器使用 --no-defaults,并在任何写操作前核对该实例的 datadir/port;没有使用、注册、修改或停止已有 MySQL 服务。 +测试结束已删除本次临时 schema/data directory 并关闭本次子进程。 + +| 检查 | 实际结果 | +|---|---| +| I-01B 测试套件 | 47 项通过:26 项真实 MySQL 用例 + 21 项配置/安全/规范化/快照单元用例 | +| 迁移周期 | upgrade → 核对 → downgrade → re-upgrade → 再核对;43 表、115 FK、head=0001_initial、差异为空 | +| 保护反例 | 非空库 sentinel 保留;未确认 downgrade 拒绝且原结构不变;生产/远端连接/offline/错误 schema/缺密码/额外 URL 参数拒绝 | +| 数据约束 | 跨 tenant、错 item current 指针、重复 scope、owner 同空/同非空、重复 crop、错 run/parent/evidence、非法 attempt/state、受引用父项删除均拒绝;合法路径通过 | +| 拒绝错误码 | FK=1452;父记录 RESTRICT=1451;唯一键=1062;CHECK=3819;严格模式非法 ENUM=1265 | +| 结构校验反例 | 实际修改列长度、默认值、唯一索引、FK、CHECK enforcement、额外索引六类漂移均被发现,恢复后再次无差异 | +| 初始化事务 | 注入 audit 写入失败后所有记录回滚;并发同租户只生成一个管理员;重复调用不改变密码;密码/hash 不进入输出或审计 changes | +| I-01A 回归 | 6 项业务 + 4 项协议 + 15 项 AI 测试通过;协议在独立 AI 环境另行复验通过,不重复计数 | +| 环境与静态检查 | 两环境 pip check 通过;AI 无 SQLAlchemy/Alembic/PyMySQL/Argon2/Celery/Redis/模型依赖;Ruff check/format 通过(50 个 Python 文件) | +| 发布包 | 使用声明的隔离构建依赖构建 wheel,安装到新临时目录后可找到两份快照和 Alembic head,不依赖源码目录 | +| 设计检查 | build_specs --check、148 项 IRR 合成检查、validate_specs(含 41 发布/39 D-FINE 合成检查)通过 | +| CI 配置 | YAML 结构校验通过;PR #7 的新 MySQL job 已在远程运行并通过,未复用 PR #5 的成绩 | + +MySQL 8.0.33 只是本次本机/CI 兼容性测试基线,不是生产镜像或安全补丁选型结论。 +设计工具仍声明未运行应用/数据库测试,是该工具自身的证据范围;本页单独记录真实数据库结果,二者不能互相替代。 + +## 4. 本轮发现并关闭的差异 + +真实 introspection 发现设计字典要求 user_roles.scope_key 非空,但 DDL 生成器遗漏 NOT NULL。 +已修复 tools/design/data_model.py、重新生成 contracts/database-design.sql,并同步 0001_initial 快照;数据字典和角色规则没有变更。 +MySQL 8 的默认认证所需 RSA 依赖通过 PyMySQL[rsa] 补齐;Argon2id 和时区依赖仅加入业务侧。 + +## 5. 操作、回滚与后续边界 + +完整环境准备、upgrade/verify/bootstrap-tenant、秘密文件、退出码、失败处理和命令见 [开发指南](../../DEVELOPMENT.md) 第 3.5 节。 +正常代码回滚不授权删除业务库。首迁移 DDL 中途失败可能留下部分表;保留失败证据,仅在操作者确认的可丢弃库重建。 +downgrade 删除 43 张业务表,必须 APP_ENV=test 且 LABSAFE_ALLOW_DESTRUCTIVE_DOWNGRADE 与 DATABASE_SCHEMA 完全一致;不是生产恢复方案。 +历史迁移发布后不得就地修改快照;新增 revision 并维护对应 head 的核对基准。 + +- I-01C:业务仓储、session/RBAC、幂等、租户事务、最后管理员保护及角色/实验室授权守卫。 +- I-02/I-03:跨资源/JSON 语义一致性、状态机、持久任务、旧租约 fencing 回写拒绝、Worker→AI→DB。 +- I-ML/I-05/R:真实 D-FINE-N/CUDA/图片评测、远端 TLS 与生产账户最小权限、备份恢复、部署和人工批准。 + +以上后续事项未在 I-01B 实现,不能用 FK 检查、schema 通过、开发 fixture 或既有 CI 结果代替验收。 diff --git a/docs/README.md b/docs/README.md index 550313d..5b1b9d7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,9 @@ ## 阅读顺序 +实现进度:I-01A 工程骨架与 I-01B 数据库基础已在特性分支实现;审批状态与本机测试不能互相替代。 +运行入口见 [开发指南](../DEVELOPMENT.md),本轮证据见 [I-01B 验收](08-delivery/08-i01b-acceptance.md)。 + 1. [文档控制](00-document-control.md):规范边界和变更要求。 2. [产品](01-requirements/01-product-requirements.md)与[范围](01-requirements/01-scope-and-acceptance.md)。 3. [编码入口](02-architecture/04-coding-baseline.md)→[状态机](02-architecture/02-domain-model.md)→[AI进程](02-architecture/03-ai-inference-process.md)→[持久任务](02-architecture/05-durable-jobs.md)→[数据库](02-architecture/06-data-persistence.md)。 diff --git a/packages/persistence/bootstrap.py b/packages/persistence/bootstrap.py new file mode 100644 index 0000000..d9dd3b2 --- /dev/null +++ b/packages/persistence/bootstrap.py @@ -0,0 +1,167 @@ +"""One-time tenant/admin creation; all records and audit commit atomically.""" + +import hashlib +import json +import re +import unicodedata +from dataclasses import dataclass +from uuid import NAMESPACE_URL, uuid4, uuid5 +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from argon2 import PasswordHasher, Type +from sqlalchemy import text +from sqlalchemy.engine import Engine + +from packages.persistence.database import DatabaseSafetyError +from packages.persistence.schema import verify_schema + +ROLES = { + "safety_admin": "安全管理员", + "lab_manager": "实验室负责人", + "inspector": "巡检员", + "remediator": "整改员", + "viewer": "只读用户", + "rule_expert": "规则专家", +} +PASSWORD_HASHER = PasswordHasher( + time_cost=3, memory_cost=65536, parallelism=1, hash_len=32, salt_len=16, type=Type.ID +) + + +def normalized(value: str, limit: int, *, account: bool = False) -> str: + value = unicodedata.normalize("NFKC" if account else "NFC", value).strip() + if account: + value = value.casefold() + if not 1 <= len(value) <= limit or any(unicodedata.category(c).startswith("C") for c in value): + raise DatabaseSafetyError("Invalid text length or control characters in bootstrap input") + return value + + +@dataclass(frozen=True) +class TenantInput: + code: str + name: str + timezone: str + username: str + display_name: str + reason: str + + def validated(self): + code = normalized(self.code, 64) + if not re.fullmatch(r"[a-z0-9][a-z0-9_-]*", code): + raise DatabaseSafetyError( + "Tenant code must use lowercase ASCII letters, digits, _ or -" + ) + timezone = normalized(self.timezone, 64) + try: + ZoneInfo(timezone) + except (ZoneInfoNotFoundError, ValueError): + raise DatabaseSafetyError("Unknown IANA timezone") from None + return TenantInput( + code, + normalized(self.name, 200), + timezone, + normalized(self.username, 128, account=True), + normalized(self.display_name, 100), + normalized(self.reason, 2000), + ) + + +def bootstrap_tenant(engine: Engine, inputs: TenantInput, password: str) -> dict[str, str]: + inputs = inputs.validated() + # Passwords are never normalized, stripped, truncated, logged or returned. + if not 12 <= len(password) <= 128: + raise DatabaseSafetyError("Password must contain 12 to 128 characters") + password_hash = PASSWORD_HASHER.hash(password) + tenant_id, user_id = str(uuid4()), str(uuid4()) + lock = hashlib.sha256(f"labsafe:bootstrap:{engine.url.database}".encode()).hexdigest() + with engine.connect() as connection: + if connection.scalar(text("SELECT GET_LOCK(:name, 10)"), {"name": lock}) != 1: + raise DatabaseSafetyError("Another bootstrap owns the database lock") + connection.commit() + try: + with connection.begin(): + if verify_schema(connection)["errors"]: + raise DatabaseSafetyError("Bootstrap requires verified migration 0001_initial") + if connection.scalar( + text("SELECT id FROM tenants WHERE code=:code"), {"code": inputs.code} + ): + raise DatabaseSafetyError( + "Tenant already exists; bootstrap never resets accounts" + ) + existing = dict(connection.execute(text("SELECT code,id FROM roles")).all()) + if set(existing) - set(ROLES): + raise DatabaseSafetyError( + "Unknown roles found; resolve schema/seed drift first" + ) + for code, name in ROLES.items(): + if code not in existing: + role_id = str(uuid5(NAMESPACE_URL, f"urn:labsafe:role:{code}")) + connection.execute( + text("INSERT INTO roles (id,code,name) VALUES (:id,:code,:name)"), + {"id": role_id, "code": code, "name": name}, + ) + existing[code] = role_id + connection.execute( + text( + "INSERT INTO tenants (id,code,name,timezone) " + "VALUES (:id,:code,:name,:timezone)" + ), + { + "id": tenant_id, + "code": inputs.code, + "name": inputs.name, + "timezone": inputs.timezone, + }, + ) + connection.execute( + text( + "INSERT INTO users (id,tenant_id,username,display_name,password_hash) " + "VALUES (:id,:tenant,:username,:display_name,:password_hash)" + ), + { + "id": user_id, + "tenant": tenant_id, + "username": inputs.username, + "display_name": inputs.display_name, + "password_hash": password_hash, + }, + ) + connection.execute( + text( + "INSERT INTO user_roles (id,tenant_id,user_id,role_id,scope_kind) " + "VALUES (:id,:tenant,:user,:role,'tenant')" + ), + { + "id": str(uuid4()), + "tenant": tenant_id, + "user": user_id, + "role": existing["safety_admin"], + }, + ) + connection.execute( + text( + "INSERT INTO audit_events " + "(id,tenant_id,actor_id,action,resource_type,resource_id," + "changes,reason,request_id) " + "VALUES (:id,:tenant,:actor,'tenant.bootstrap','tenant',:tenant," + ":changes,:reason,:request)" + ), + { + "id": str(uuid4()), + "tenant": tenant_id, + "actor": user_id, + "changes": json.dumps( + { + "initial_admin_id": user_id, + "role_codes": list(ROLES), + "scope_kind": "tenant", + } + ), + "reason": inputs.reason, + "request": str(uuid4()), + }, + ) + finally: + connection.execute(text("SELECT RELEASE_LOCK(:name)"), {"name": lock}) + return {"tenant_id": tenant_id, "admin_id": user_id, "role": "safety_admin"} diff --git a/packages/persistence/cli.py b/packages/persistence/cli.py new file mode 100644 index 0000000..e98906a --- /dev/null +++ b/packages/persistence/cli.py @@ -0,0 +1,91 @@ +"""Development database CLI; connection/password secrets never appear in arguments.""" + +import argparse +import getpass +import json +import sys +from pathlib import Path + +from alembic import command +from alembic.config import Config +from sqlalchemy.exc import SQLAlchemyError + +from packages.persistence.database import DatabaseSafetyError, database_engine +from packages.persistence.schema import verify_schema + + +def migration_config(): + config = Config() + config.set_main_option("script_location", str(Path(__file__).parent / "migrations")) + return config + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("upgrade", help="Apply head to a dedicated dev/test schema") + commands.add_parser("downgrade", help="Destroy tables only in explicitly confirmed test schema") + commands.add_parser("verify", help="Compare actual schema to frozen baseline; exit 1 on drift") + bootstrap = commands.add_parser("bootstrap-tenant", help="Create tenant, six roles and admin") + for field in ("code", "name", "timezone", "username", "display-name", "reason"): + bootstrap.add_argument("--" + field, required=True) + bootstrap.add_argument( + "--password-file", + type=Path, + help="Read exact UTF-8 password bytes; no stripping or normalization", + ) + arguments = parser.parse_args(argv) + engine = None + try: + if arguments.command in {"upgrade", "downgrade"}: + if arguments.command == "upgrade": + command.upgrade(migration_config(), "head") + else: + command.downgrade(migration_config(), "base") + print(json.dumps({"command": arguments.command, "status": "pass"})) + return 0 + engine = database_engine() + if arguments.command == "verify": + with engine.connect() as connection: + report = verify_schema(connection) + print(json.dumps(report, indent=2, ensure_ascii=False)) + return 1 if report["errors"] else 0 + from packages.persistence.bootstrap import TenantInput, bootstrap_tenant + + if arguments.password_file: + # read_bytes avoids newline translation; passwords must remain unchanged. + password = arguments.password_file.read_bytes().decode("utf-8") + elif sys.stdin.isatty(): + password = getpass.getpass("Initial administrator password: ") + if password != getpass.getpass("Confirm password: "): + raise DatabaseSafetyError("Password confirmation does not match") + else: + raise DatabaseSafetyError("Interactive terminal or --password-file is required") + inputs = TenantInput( + arguments.code, + arguments.name, + arguments.timezone, + arguments.username, + arguments.display_name, + arguments.reason, + ) + print(json.dumps(bootstrap_tenant(engine, inputs, password))) + return 0 + except DatabaseSafetyError as error: + print(f"Database safety check failed: {error}", file=sys.stderr) + return 2 + except (SQLAlchemyError, OSError, ValueError, RuntimeError): + # Driver exceptions may carry connection credentials or bound values. + print( + "Database command failed. Check configuration/schema and private server logs; " + "no partial DDL recovery or automatic account reset was attempted.", + file=sys.stderr, + ) + return 1 + finally: + if engine is not None: + engine.dispose() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/persistence/database.py b/packages/persistence/database.py new file mode 100644 index 0000000..5913929 --- /dev/null +++ b/packages/persistence/database.py @@ -0,0 +1,75 @@ +"""Explicit, secret-file based access to disposable I-01B databases only.""" + +import os +import re +from pathlib import Path + +from sqlalchemy import create_engine, event +from sqlalchemy.engine import Engine, make_url +from sqlalchemy.exc import ArgumentError +from sqlalchemy.pool import NullPool + +from packages.shared.environment import development_environment + +STRICT_SQL_MODE = ( + "STRICT_ALL_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO," + "NO_ENGINE_SUBSTITUTION,ONLY_FULL_GROUP_BY" +) + + +class DatabaseSafetyError(ValueError): + """Safe-to-display configuration error; never include the connection URL.""" + + +def database_engine() -> Engine: + environment = development_environment() + secret = os.environ.get("DATABASE_URL_FILE") + schema = os.environ.get("DATABASE_SCHEMA", "") + if not secret or not re.fullmatch(rf"labsafe_{environment}_[a-z0-9_]+", schema): + raise DatabaseSafetyError( + "Set DATABASE_URL_FILE and DATABASE_SCHEMA=labsafe__" + ) + if len(schema) > 64: + raise DatabaseSafetyError("DATABASE_SCHEMA must not exceed 64 characters") + try: + raw = Path(secret).read_text(encoding="utf-8").strip() + url = make_url(raw) + except (OSError, ValueError, UnicodeError, ArgumentError): + raise DatabaseSafetyError("Cannot read a valid database URL from secret file") from None + if ( + url.drivername != "mysql+pymysql" + or url.database != schema + or not url.username + or not url.password + or url.host not in {"localhost", "127.0.0.1", "::1"} + or url.query + ): + raise DatabaseSafetyError( + "Require authenticated loopback mysql+pymysql URL, confirmed schema, no query options" + ) + engine = create_engine( + url, + poolclass=NullPool, + hide_parameters=True, + connect_args={"charset": "utf8mb4", "connect_timeout": 10}, + ) + + @event.listens_for(engine, "connect") + def configure_session(connection, _record): + with connection.cursor() as cursor: + cursor.execute("SET SESSION time_zone = '+00:00'") + cursor.execute("SET SESSION sql_mode = %s", (STRICT_SQL_MODE,)) + cursor.execute("SET SESSION foreign_key_checks = 1") + cursor.execute("SET SESSION unique_checks = 1") + cursor.execute("SELECT VERSION()") + version = cursor.fetchone()[0] + match = re.match(r"(\d+)\.(\d+)\.(\d+)", version) + if ( + "mariadb" in version.lower() + or not match + or tuple(map(int, match.groups())) < (8, 0, 16) + or int(match.group(1)) != 8 + ): + raise DatabaseSafetyError("I-01B requires MySQL 8.x, version >= 8.0.16") + + return engine diff --git a/packages/persistence/migrations/__init__.py b/packages/persistence/migrations/__init__.py new file mode 100644 index 0000000..de96aed --- /dev/null +++ b/packages/persistence/migrations/__init__.py @@ -0,0 +1 @@ +"""Immutable migration revisions and their packaged schema snapshots.""" diff --git a/packages/persistence/migrations/env.py b/packages/persistence/migrations/env.py new file mode 100644 index 0000000..a0bd5ce --- /dev/null +++ b/packages/persistence/migrations/env.py @@ -0,0 +1,26 @@ +"""Online-only migration environment with a schema-specific advisory lock.""" + +import hashlib + +from alembic import context +from sqlalchemy import text + +from packages.persistence.database import database_engine + +if context.is_offline_mode(): + raise RuntimeError("Offline migration is disabled; use an explicit disposable database") + +engine = database_engine() +with engine.connect() as connection: + lock = hashlib.sha256(f"labsafe:migrate:{engine.url.database}".encode()).hexdigest() + if connection.scalar(text("SELECT GET_LOCK(:name, 10)"), {"name": lock}) != 1: + raise RuntimeError("Another migration owns the database lock") + connection.commit() + try: + context.configure(connection=connection, target_metadata=None) + with context.begin_transaction(): + context.run_migrations() + connection.commit() + finally: + connection.execute(text("SELECT RELEASE_LOCK(:name)"), {"name": lock}) +engine.dispose() diff --git a/packages/persistence/migrations/snapshots/0001_initial.json b/packages/persistence/migrations/snapshots/0001_initial.json new file mode 100644 index 0000000..de6f9d8 --- /dev/null +++ b/packages/persistence/migrations/snapshots/0001_initial.json @@ -0,0 +1,6108 @@ +{ + "version": "1.1.0", + "engine": "MySQL 8.0.16+ / InnoDB", + "timezone": "UTC", + "tables": { + "tenants": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "code": { + "type": "VARCHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "name": { + "type": "VARCHAR(200)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "timezone": { + "type": "VARCHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('active','suspended')", + "nullable": false, + "default_sql": "'active'", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "code" + ] + ], + "indexes": [], + "foreign_keys": [], + "checks": [], + "append_only": false + }, + "roles": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "code": { + "type": "VARCHAR(40)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "name": { + "type": "VARCHAR(100)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "code" + ] + ], + "indexes": [], + "foreign_keys": [], + "checks": [], + "append_only": false + }, + "colleges": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "code": { + "type": "VARCHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "name": { + "type": "VARCHAR(200)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('active','archived')", + "nullable": false, + "default_sql": "'active'", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "code" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "laboratories": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "college_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "code": { + "type": "VARCHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "name": { + "type": "VARCHAR(200)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('active','archived')", + "nullable": false, + "default_sql": "'active'", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "code" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "college_id" + ], + "target": "colleges", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "users": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "username": { + "type": "VARCHAR(128)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "display_name": { + "type": "VARCHAR(100)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "password_hash": { + "type": "VARCHAR(255)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('active','disabled')", + "nullable": false, + "default_sql": "'active'", + "description": "", + "generated_sql": null + }, + "session_epoch": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "0", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "username" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "user_roles": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "user_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "role_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "laboratory_id": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "scope_kind": { + "type": "ENUM('tenant','laboratory')", + "nullable": false, + "default_sql": "'tenant'", + "description": "", + "generated_sql": null + }, + "scope_key": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": "COALESCE(laboratory_id,'00000000-0000-0000-0000-000000000000')" + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "user_id", + "role_id", + "scope_key" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "user_id" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id" + ], + "target": "laboratories", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "role_id" + ], + "target": "roles", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [ + "(scope_kind='tenant' AND laboratory_id IS NULL) OR (scope_kind='laboratory' AND laboratory_id IS NOT NULL)" + ], + "append_only": false + }, + "sessions": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "user_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "token_hash": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "csrf_hash": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "session_epoch": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "0", + "description": "", + "generated_sql": null + }, + "expires_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "idle_expires_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "revoked_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "token_hash" + ] + ], + "indexes": [ + [ + "expires_at" + ] + ], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "user_id" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "locations": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "laboratory_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "parent_id": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "type": { + "type": "ENUM('room','area','shelf','cabinet')", + "nullable": false, + "default_sql": "'room'", + "description": "", + "generated_sql": null + }, + "label": { + "type": "VARCHAR(200)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('active','archived')", + "nullable": false, + "default_sql": "'active'", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "laboratory_id", + "id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id" + ], + "target": "laboratories", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id", + "parent_id" + ], + "target": "locations", + "references": [ + "tenant_id", + "laboratory_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "inspection_templates": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "family_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "revision": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "name": { + "type": "VARCHAR(200)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('draft','published','retired')", + "nullable": false, + "default_sql": "'draft'", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "family_id", + "revision" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "template_items": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "template_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "code": { + "type": "VARCHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "title": { + "type": "VARCHAR(200)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "capture_hint": { + "type": "VARCHAR(1000)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "sort_order": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "0", + "description": "", + "generated_sql": null + }, + "required": { + "type": "BOOLEAN", + "nullable": false, + "default_sql": "TRUE", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "template_id", + "code" + ], + [ + "tenant_id", + "template_id", + "id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "template_id" + ], + "target": "inspection_templates", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "inspections": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "laboratory_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "template_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "inspector_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('draft','in_progress','completed','cancelled')", + "nullable": false, + "default_sql": "'draft'", + "description": "", + "generated_sql": null + }, + "completed_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "laboratory_id", + "id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id" + ], + "target": "laboratories", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "template_id" + ], + "target": "inspection_templates", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "inspector_id" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "inspection_items": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "inspection_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "laboratory_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "template_item_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "location_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('draft','uploaded','queued','quality_checking','needs_retake','processing','needs_review','completed','failed')", + "nullable": false, + "default_sql": "'draft'", + "description": "", + "generated_sql": null + }, + "submission_revision": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "0", + "description": "", + "generated_sql": null + }, + "current_run_id": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "current_fact_revision_id": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "current_evaluation_id": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "review_outcome": { + "type": "ENUM('no_issue','issues_confirmed','cannot_determine')", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "reviewed_by": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "reviewed_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "laboratory_id", + "id" + ], + [ + "tenant_id", + "inspection_id", + "template_item_id", + "location_id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id", + "inspection_id" + ], + "target": "inspections", + "references": [ + "tenant_id", + "laboratory_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "template_item_id" + ], + "target": "template_items", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id", + "location_id" + ], + "target": "locations", + "references": [ + "tenant_id", + "laboratory_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "reviewed_by" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "id", + "current_run_id" + ], + "target": "inference_runs", + "references": [ + "tenant_id", + "item_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "id", + "current_fact_revision_id" + ], + "target": "fact_revisions", + "references": [ + "tenant_id", + "item_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "id", + "current_evaluation_id" + ], + "target": "rule_evaluations", + "references": [ + "tenant_id", + "item_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "dictionary_versions": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "version_label": { + "type": "VARCHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "checksum": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "object_key": { + "type": "VARCHAR(1024)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "source": { + "type": "VARCHAR(2000)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('draft','published','retired')", + "nullable": false, + "default_sql": "'draft'", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "version_label" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "chemical_entities": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "dictionary_version_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "canonical_name": { + "type": "VARCHAR(200)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "cas_number": { + "type": "VARCHAR(32)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "hazard_class": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "storage_class": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "dictionary_version_id", + "id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "dictionary_version_id" + ], + "target": "dictionary_versions", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + }, + "chemical_aliases": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "dictionary_version_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "entity_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "normalized_alias": { + "type": "VARCHAR(200)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "dictionary_version_id", + "entity_id", + "normalized_alias" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "dictionary_version_id", + "entity_id" + ], + "target": "chemical_entities", + "references": [ + "tenant_id", + "dictionary_version_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + }, + "model_versions": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "name": { + "type": "VARCHAR(100)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "bundle_version": { + "type": "VARCHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "dictionary_version_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "checksum": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "manifest": { + "type": "JSON", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('registered','validated','published','retired')", + "nullable": false, + "default_sql": "'registered'", + "description": "", + "generated_sql": null + }, + "evaluation_report_key": { + "type": "VARCHAR(1024)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "bundle_version" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "dictionary_version_id" + ], + "target": "dictionary_versions", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "rule_sets": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "name": { + "type": "VARCHAR(200)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "description": { + "type": "VARCHAR(2000)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "name" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "rule_versions": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "rule_set_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "revision": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "checksum": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "definition": { + "type": "JSON", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('draft','submitted','approved','published','retired')", + "nullable": false, + "default_sql": "'draft'", + "description": "", + "generated_sql": null + }, + "submitted_by": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "approved_by": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "approved_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "rule_set_id", + "revision" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "rule_set_id" + ], + "target": "rule_sets", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "submitted_by" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "approved_by" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "rule_bundles": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "checksum": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "evaluator_version": { + "type": "VARCHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "checksum" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + }, + "rule_bundle_members": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "bundle_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "rule_version_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "bundle_id", + "rule_version_id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "bundle_id" + ], + "target": "rule_bundles", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "rule_version_id" + ], + "target": "rule_versions", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + }, + "activations": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "laboratory_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "model_bundle_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "dictionary_version_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "rule_bundle_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "pipeline_version": { + "type": "VARCHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "device_profile": { + "type": "ENUM('cpu','cuda')", + "nullable": false, + "default_sql": "'cpu'", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "laboratory_id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id" + ], + "target": "laboratories", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "model_bundle_id" + ], + "target": "model_versions", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "dictionary_version_id" + ], + "target": "dictionary_versions", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "rule_bundle_id" + ], + "target": "rule_bundles", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "inference_runs": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "item_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "laboratory_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "submission_revision": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "replay_of": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "input_hash": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "model_bundle_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "dictionary_version_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "rule_bundle_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "pipeline_version": { + "type": "VARCHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "device_profile": { + "type": "ENUM('cpu','cuda')", + "nullable": false, + "default_sql": "'cpu'", + "description": "", + "generated_sql": null + }, + "reference_date": { + "type": "DATE", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('queued','processing','retrying','completed','needs_retake','needs_review','failed','superseded')", + "nullable": false, + "default_sql": "'queued'", + "description": "", + "generated_sql": null + }, + "stage": { + "type": "ENUM('queued','quality','facts','rules','done')", + "nullable": false, + "default_sql": "'queued'", + "description": "", + "generated_sql": null + }, + "result": { + "type": "JSON", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "result_hash": { + "type": "CHAR(64)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "error_code": { + "type": "VARCHAR(80)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "started_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "finished_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "item_id", + "id" + ], + [ + "tenant_id", + "item_id", + "submission_revision" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id", + "item_id" + ], + "target": "inspection_items", + "references": [ + "tenant_id", + "laboratory_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "replay_of" + ], + "target": "inference_runs", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "model_bundle_id" + ], + "target": "model_versions", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "dictionary_version_id" + ], + "target": "dictionary_versions", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "rule_bundle_id" + ], + "target": "rule_bundles", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "fact_revisions": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "item_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "run_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "revision": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "entities": { + "type": "JSON", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "relations": { + "type": "JSON", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "dates": { + "type": "JSON", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "reason": { + "type": "VARCHAR(2000)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_by": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "item_id", + "id" + ], + [ + "tenant_id", + "run_id", + "revision" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "item_id", + "run_id" + ], + "target": "inference_runs", + "references": [ + "tenant_id", + "item_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "created_by" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + }, + "rule_evaluations": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "item_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "run_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "fact_revision_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "rule_bundle_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "reference_date": { + "type": "DATE", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('queued','running','completed','failed','superseded')", + "nullable": false, + "default_sql": "'queued'", + "description": "", + "generated_sql": null + }, + "result": { + "type": "JSON", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "result_hash": { + "type": "CHAR(64)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "finished_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "item_id", + "id" + ], + [ + "tenant_id", + "fact_revision_id", + "rule_bundle_id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "item_id", + "run_id" + ], + "target": "inference_runs", + "references": [ + "tenant_id", + "item_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "item_id", + "fact_revision_id" + ], + "target": "fact_revisions", + "references": [ + "tenant_id", + "item_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "rule_bundle_id" + ], + "target": "rule_bundles", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "findings": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "item_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "run_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "fact_revision_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "rule_evaluation_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "rule_id": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "fingerprint": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "type": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "severity": { + "type": "ENUM('low','medium','high','critical')", + "nullable": false, + "default_sql": "'low'", + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('needs_review','confirmed','rejected','cannot_determine','dispatched','closed')", + "nullable": false, + "default_sql": "'needs_review'", + "description": "", + "generated_sql": null + }, + "explanation": { + "type": "VARCHAR(4000)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "evidence": { + "type": "JSON", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "superseded_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "confirmed_by": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "rule_evaluation_id", + "fingerprint" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "item_id", + "run_id" + ], + "target": "inference_runs", + "references": [ + "tenant_id", + "item_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "item_id", + "fact_revision_id" + ], + "target": "fact_revisions", + "references": [ + "tenant_id", + "item_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "item_id", + "rule_evaluation_id" + ], + "target": "rule_evaluations", + "references": [ + "tenant_id", + "item_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "confirmed_by" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "review_actions": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "actor_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "resource_type": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "resource_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "action": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "reason": { + "type": "VARCHAR(2000)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "before_version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "after_version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "actor_id" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + }, + "remediation_tasks": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "finding_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "laboratory_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "assignee_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('pending_dispatch','in_progress','pending_recheck','rejected','cannot_remediate','closed')", + "nullable": false, + "default_sql": "'pending_dispatch'", + "description": "", + "generated_sql": null + }, + "priority": { + "type": "ENUM('low','medium','high','critical')", + "nullable": false, + "default_sql": "'low'", + "description": "", + "generated_sql": null + }, + "due_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "description": { + "type": "VARCHAR(4000)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "latest_evidence_id": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "closed_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "finding_id" + ], + [ + "tenant_id", + "laboratory_id", + "id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "finding_id" + ], + "target": "findings", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id" + ], + "target": "laboratories", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "assignee_id" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "id", + "latest_evidence_id" + ], + "target": "remediation_evidence", + "references": [ + "tenant_id", + "task_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "remediation_evidence": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "task_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "description": { + "type": "VARCHAR(4000)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "submitted_by": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "task_id", + "id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "task_id" + ], + "target": "remediation_tasks", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "submitted_by" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + }, + "uploads": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "laboratory_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "inspection_item_id": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "remediation_task_id": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "requested_by": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "object_key": { + "type": "VARCHAR(1024)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "expected_sha256": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "mime_type": { + "type": "VARCHAR(40)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "size_bytes": { + "type": "BIGINT UNSIGNED", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "captured_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "expires_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('granted','validating','ready','rejected','expired')", + "nullable": false, + "default_sql": "'granted'", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "requested_by" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id" + ], + "target": "laboratories", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id", + "inspection_item_id" + ], + "target": "inspection_items", + "references": [ + "tenant_id", + "laboratory_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id", + "remediation_task_id" + ], + "target": "remediation_tasks", + "references": [ + "tenant_id", + "laboratory_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [ + "(inspection_item_id IS NULL) <> (remediation_task_id IS NULL)" + ], + "append_only": false + }, + "asset_images": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "laboratory_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "inspection_item_id": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "remediation_task_id": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "upload_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('validating','ready','rejected','deleted')", + "nullable": false, + "default_sql": "'validating'", + "description": "", + "generated_sql": null + }, + "original_key": { + "type": "VARCHAR(1024)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "original_object_version": { + "type": "VARCHAR(200)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "original_sha256": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "analysis_key": { + "type": "VARCHAR(1024)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "analysis_object_version": { + "type": "VARCHAR(200)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "analysis_sha256": { + "type": "CHAR(64)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "width": { + "type": "INT UNSIGNED", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "height": { + "type": "INT UNSIGNED", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "mime_type": { + "type": "VARCHAR(40)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "captured_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "legal_hold": { + "type": "BOOLEAN", + "nullable": false, + "default_sql": "FALSE", + "description": "", + "generated_sql": null + }, + "deleted_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "upload_id" + ], + [ + "tenant_id", + "inspection_item_id", + "id" + ], + [ + "tenant_id", + "remediation_task_id", + "id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "upload_id" + ], + "target": "uploads", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id" + ], + "target": "laboratories", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id", + "inspection_item_id" + ], + "target": "inspection_items", + "references": [ + "tenant_id", + "laboratory_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id", + "remediation_task_id" + ], + "target": "remediation_tasks", + "references": [ + "tenant_id", + "laboratory_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [ + "(inspection_item_id IS NULL) <> (remediation_task_id IS NULL)" + ], + "append_only": false + }, + "evidence_images": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "task_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "evidence_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "image_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "ordinal": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "0", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "evidence_id", + "image_id" + ], + [ + "tenant_id", + "evidence_id", + "ordinal" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "task_id", + "evidence_id" + ], + "target": "remediation_evidence", + "references": [ + "tenant_id", + "task_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "task_id", + "image_id" + ], + "target": "asset_images", + "references": [ + "tenant_id", + "remediation_task_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + }, + "run_images": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "item_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "run_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "image_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "ordinal": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "0", + "description": "", + "generated_sql": null + }, + "role": { + "type": "ENUM('overview','detail')", + "nullable": false, + "default_sql": "'overview'", + "description": "", + "generated_sql": null + }, + "parent_image_id": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "analysis_sha256": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "run_id", + "image_id" + ], + [ + "tenant_id", + "run_id", + "ordinal" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "item_id", + "run_id" + ], + "target": "inference_runs", + "references": [ + "tenant_id", + "item_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "item_id", + "image_id" + ], + "target": "asset_images", + "references": [ + "tenant_id", + "inspection_item_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "run_id", + "parent_image_id" + ], + "target": "run_images", + "references": [ + "tenant_id", + "run_id", + "image_id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + }, + "image_derivatives": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "run_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "image_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "crop_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "detection_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "kind": { + "type": "ENUM('crop')", + "nullable": false, + "default_sql": "'crop'", + "description": "", + "generated_sql": null + }, + "recipe": { + "type": "JSON", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "object_key": { + "type": "VARCHAR(1024)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "object_version": { + "type": "VARCHAR(200)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "sha256": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "run_id", + "crop_id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "run_id", + "image_id" + ], + "target": "run_images", + "references": [ + "tenant_id", + "run_id", + "image_id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + }, + "api_idempotency": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "actor_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "method": { + "type": "VARCHAR(10)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "path_hash": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "key_hash": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "request_hash": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "state": { + "type": "ENUM('pending','completed')", + "nullable": false, + "default_sql": "'pending'", + "description": "", + "generated_sql": null + }, + "response_status": { + "type": "SMALLINT UNSIGNED", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "response": { + "type": "JSON", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "lease_owner": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "lease_until": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "expires_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "actor_id", + "method", + "path_hash", + "key_hash" + ] + ], + "indexes": [ + [ + "expires_at" + ] + ], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "actor_id" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "outbox_events": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "event_type": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "aggregate_type": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "aggregate_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "aggregate_version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "payload": { + "type": "JSON", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "state": { + "type": "ENUM('pending','leased','published')", + "nullable": false, + "default_sql": "'pending'", + "description": "", + "generated_sql": null + }, + "lease_owner": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "lease_until": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "attempts": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "0", + "description": "", + "generated_sql": null + }, + "available_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "published_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ] + ], + "indexes": [ + [ + "state", + "available_at", + "id" + ], + [ + "state", + "lease_until" + ] + ], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "event_inbox": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "event_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "consumer": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "processed_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "event_id", + "consumer" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "event_id" + ], + "target": "outbox_events", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "task_runs": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "task_type": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "resource_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "logical_key": { + "type": "VARCHAR(200)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "payload": { + "type": "JSON", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "state": { + "type": "ENUM('ready','leased','retry_wait','succeeded','failed','dead_letter')", + "nullable": false, + "default_sql": "'ready'", + "description": "", + "generated_sql": null + }, + "attempt": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "0", + "description": "", + "generated_sql": null + }, + "replay_generation": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "0", + "description": "", + "generated_sql": null + }, + "dispatch_sequence": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "lease_owner": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "lease_until": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "fencing_token": { + "type": "BIGINT UNSIGNED", + "nullable": false, + "default_sql": "0", + "description": "", + "generated_sql": null + }, + "heartbeat_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "available_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "last_dispatched_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "last_error_code": { + "type": "VARCHAR(80)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "started_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "finished_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "logical_key" + ] + ], + "indexes": [ + [ + "state", + "available_at", + "id" + ], + [ + "state", + "lease_until" + ] + ], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [ + "attempt <= 4" + ], + "append_only": false + }, + "task_attempts": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "task_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "replay_generation": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "0", + "description": "", + "generated_sql": null + }, + "attempt": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "fencing_token": { + "type": "BIGINT UNSIGNED", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "lease_owner": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "started_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "finished_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('running','succeeded','failed','abandoned')", + "nullable": false, + "default_sql": "'running'", + "description": "", + "generated_sql": null + }, + "error_code": { + "type": "VARCHAR(80)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "task_id", + "replay_generation", + "attempt" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "task_id" + ], + "target": "task_runs", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "notifications": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "event_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "recipient_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "type": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "resource_type": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "resource_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "read_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "event_id", + "recipient_id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "event_id" + ], + "target": "outbox_events", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "recipient_id" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "audit_events": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "actor_id": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "action": { + "type": "VARCHAR(100)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "resource_type": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "resource_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "changes": { + "type": "JSON", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "reason": { + "type": "VARCHAR(2000)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "request_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ] + ], + "indexes": [ + [ + "tenant_id", + "resource_type", + "resource_id", + "created_at" + ] + ], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "actor_id" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + }, + "report_exports": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "requested_by": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "format": { + "type": "ENUM('csv','pdf')", + "nullable": false, + "default_sql": "'csv'", + "description": "", + "generated_sql": null + }, + "filters": { + "type": "JSON", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "snapshot_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "snapshot": { + "type": "JSON", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('queued','running','ready','failed','expired')", + "nullable": false, + "default_sql": "'queued'", + "description": "", + "generated_sql": null + }, + "object_key": { + "type": "VARCHAR(1024)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "checksum": { + "type": "CHAR(64)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "expires_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "last_error_code": { + "type": "VARCHAR(80)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "requested_by" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "dataset_versions": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "name": { + "type": "VARCHAR(100)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "checksum": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "manifest_key": { + "type": "VARCHAR(1024)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "authorization_ref": { + "type": "VARCHAR(1000)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('draft','frozen','retired')", + "nullable": false, + "default_sql": "'draft'", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "name" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + }, + "dataset_images": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "dataset_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "image_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "scene_id": { + "type": "VARCHAR(100)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "split": { + "type": "ENUM('train','validation','test')", + "nullable": false, + "default_sql": "'train'", + "description": "", + "generated_sql": null + }, + "annotation_key": { + "type": "VARCHAR(1024)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "provenance": { + "type": "VARCHAR(2000)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "dataset_id", + "image_id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "dataset_id" + ], + "target": "dataset_versions", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "image_id" + ], + "target": "asset_images", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + } + } +} diff --git a/packages/persistence/migrations/snapshots/0001_initial.sql b/packages/persistence/migrations/snapshots/0001_initial.sql new file mode 100644 index 0000000..0f5c257 --- /dev/null +++ b/packages/persistence/migrations/snapshots/0001_initial.sql @@ -0,0 +1,1022 @@ +-- Design-only MySQL 8.0.16+ / InnoDB DDL. Run in a new empty schema. + +-- Application transactions enforce documented cross-resource and JSON invariants. + +SET time_zone = '+00:00'; + +CREATE TABLE `tenants` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `code` VARCHAR(64) NOT NULL, + `name` VARCHAR(200) NOT NULL, + `timezone` VARCHAR(64) NOT NULL, + `status` ENUM('active','suspended') NOT NULL DEFAULT 'active', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_tenants_0` (`code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `roles` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `code` VARCHAR(40) NOT NULL, + `name` VARCHAR(100) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_roles_0` (`code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `colleges` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `code` VARCHAR(64) NOT NULL, + `name` VARCHAR(200) NOT NULL, + `status` ENUM('active','archived') NOT NULL DEFAULT 'active', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_colleges_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_colleges_1` (`tenant_id`, `code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `laboratories` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `college_id` CHAR(36) NOT NULL, + `code` VARCHAR(64) NOT NULL, + `name` VARCHAR(200) NOT NULL, + `status` ENUM('active','archived') NOT NULL DEFAULT 'active', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_laboratories_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_laboratories_1` (`tenant_id`, `code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `users` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `username` VARCHAR(128) NOT NULL, + `display_name` VARCHAR(100) NOT NULL, + `password_hash` VARCHAR(255) NOT NULL, + `status` ENUM('active','disabled') NOT NULL DEFAULT 'active', + `session_epoch` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_users_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_users_1` (`tenant_id`, `username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `user_roles` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `user_id` CHAR(36) NOT NULL, + `role_id` CHAR(36) NOT NULL, + `laboratory_id` CHAR(36) NULL, + `scope_kind` ENUM('tenant','laboratory') NOT NULL DEFAULT 'tenant', + `scope_key` CHAR(36) GENERATED ALWAYS AS (COALESCE(laboratory_id,'00000000-0000-0000-0000-000000000000')) STORED NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_user_roles_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_user_roles_1` (`tenant_id`, `user_id`, `role_id`, `scope_key`), + CONSTRAINT `ck_user_roles_0` CHECK ((scope_kind='tenant' AND laboratory_id IS NULL) OR (scope_kind='laboratory' AND laboratory_id IS NOT NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `sessions` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `user_id` CHAR(36) NOT NULL, + `token_hash` CHAR(64) NOT NULL, + `csrf_hash` CHAR(64) NOT NULL, + `session_epoch` INT UNSIGNED NOT NULL DEFAULT 0, + `expires_at` DATETIME(3) NOT NULL, + `idle_expires_at` DATETIME(3) NOT NULL, + `revoked_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_sessions_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_sessions_1` (`token_hash`), + KEY `ix_sessions_0` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `locations` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `laboratory_id` CHAR(36) NOT NULL, + `parent_id` CHAR(36) NULL, + `type` ENUM('room','area','shelf','cabinet') NOT NULL DEFAULT 'room', + `label` VARCHAR(200) NOT NULL, + `status` ENUM('active','archived') NOT NULL DEFAULT 'active', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_locations_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_locations_1` (`tenant_id`, `laboratory_id`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `inspection_templates` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `family_id` CHAR(36) NOT NULL, + `revision` INT UNSIGNED NOT NULL DEFAULT 1, + `name` VARCHAR(200) NOT NULL, + `status` ENUM('draft','published','retired') NOT NULL DEFAULT 'draft', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_inspection_templates_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_inspection_templates_1` (`tenant_id`, `family_id`, `revision`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `template_items` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `template_id` CHAR(36) NOT NULL, + `code` VARCHAR(64) NOT NULL, + `title` VARCHAR(200) NOT NULL, + `capture_hint` VARCHAR(1000) NOT NULL, + `sort_order` INT UNSIGNED NOT NULL DEFAULT 0, + `required` BOOLEAN NOT NULL DEFAULT TRUE, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_template_items_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_template_items_1` (`tenant_id`, `template_id`, `code`), + UNIQUE KEY `uq_template_items_2` (`tenant_id`, `template_id`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `inspections` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `laboratory_id` CHAR(36) NOT NULL, + `template_id` CHAR(36) NOT NULL, + `inspector_id` CHAR(36) NOT NULL, + `status` ENUM('draft','in_progress','completed','cancelled') NOT NULL DEFAULT 'draft', + `completed_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_inspections_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_inspections_1` (`tenant_id`, `laboratory_id`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `inspection_items` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `inspection_id` CHAR(36) NOT NULL, + `laboratory_id` CHAR(36) NOT NULL, + `template_item_id` CHAR(36) NOT NULL, + `location_id` CHAR(36) NOT NULL, + `status` ENUM('draft','uploaded','queued','quality_checking','needs_retake','processing','needs_review','completed','failed') NOT NULL DEFAULT 'draft', + `submission_revision` INT UNSIGNED NOT NULL DEFAULT 0, + `current_run_id` CHAR(36) NULL, + `current_fact_revision_id` CHAR(36) NULL, + `current_evaluation_id` CHAR(36) NULL, + `review_outcome` ENUM('no_issue','issues_confirmed','cannot_determine') NULL, + `reviewed_by` CHAR(36) NULL, + `reviewed_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_inspection_items_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_inspection_items_1` (`tenant_id`, `laboratory_id`, `id`), + UNIQUE KEY `uq_inspection_items_2` (`tenant_id`, `inspection_id`, `template_item_id`, `location_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `dictionary_versions` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `version_label` VARCHAR(64) NOT NULL, + `checksum` CHAR(64) NOT NULL, + `object_key` VARCHAR(1024) NOT NULL, + `source` VARCHAR(2000) NOT NULL, + `status` ENUM('draft','published','retired') NOT NULL DEFAULT 'draft', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_dictionary_versions_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_dictionary_versions_1` (`tenant_id`, `version_label`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `chemical_entities` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `dictionary_version_id` CHAR(36) NOT NULL, + `canonical_name` VARCHAR(200) NOT NULL, + `cas_number` VARCHAR(32) NULL, + `hazard_class` VARCHAR(80) NOT NULL, + `storage_class` VARCHAR(80) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_chemical_entities_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_chemical_entities_1` (`tenant_id`, `dictionary_version_id`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `chemical_aliases` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `dictionary_version_id` CHAR(36) NOT NULL, + `entity_id` CHAR(36) NOT NULL, + `normalized_alias` VARCHAR(200) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_chemical_aliases_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_chemical_aliases_1` (`tenant_id`, `dictionary_version_id`, `entity_id`, `normalized_alias`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `model_versions` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `name` VARCHAR(100) NOT NULL, + `bundle_version` VARCHAR(64) NOT NULL, + `dictionary_version_id` CHAR(36) NOT NULL, + `checksum` CHAR(64) NOT NULL, + `manifest` JSON NOT NULL, + `status` ENUM('registered','validated','published','retired') NOT NULL DEFAULT 'registered', + `evaluation_report_key` VARCHAR(1024) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_model_versions_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_model_versions_1` (`tenant_id`, `bundle_version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `rule_sets` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `name` VARCHAR(200) NOT NULL, + `description` VARCHAR(2000) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_rule_sets_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_rule_sets_1` (`tenant_id`, `name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `rule_versions` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `rule_set_id` CHAR(36) NOT NULL, + `revision` INT UNSIGNED NOT NULL DEFAULT 1, + `checksum` CHAR(64) NOT NULL, + `definition` JSON NOT NULL, + `status` ENUM('draft','submitted','approved','published','retired') NOT NULL DEFAULT 'draft', + `submitted_by` CHAR(36) NULL, + `approved_by` CHAR(36) NULL, + `approved_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_rule_versions_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_rule_versions_1` (`tenant_id`, `rule_set_id`, `revision`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `rule_bundles` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `checksum` CHAR(64) NOT NULL, + `evaluator_version` VARCHAR(64) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_rule_bundles_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_rule_bundles_1` (`tenant_id`, `checksum`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `rule_bundle_members` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `bundle_id` CHAR(36) NOT NULL, + `rule_version_id` CHAR(36) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_rule_bundle_members_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_rule_bundle_members_1` (`tenant_id`, `bundle_id`, `rule_version_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `activations` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `laboratory_id` CHAR(36) NOT NULL, + `model_bundle_id` CHAR(36) NOT NULL, + `dictionary_version_id` CHAR(36) NOT NULL, + `rule_bundle_id` CHAR(36) NOT NULL, + `pipeline_version` VARCHAR(64) NOT NULL, + `device_profile` ENUM('cpu','cuda') NOT NULL DEFAULT 'cpu', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_activations_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_activations_1` (`tenant_id`, `laboratory_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `inference_runs` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `item_id` CHAR(36) NOT NULL, + `laboratory_id` CHAR(36) NOT NULL, + `submission_revision` INT UNSIGNED NOT NULL DEFAULT 1, + `replay_of` CHAR(36) NULL, + `input_hash` CHAR(64) NOT NULL, + `model_bundle_id` CHAR(36) NOT NULL, + `dictionary_version_id` CHAR(36) NOT NULL, + `rule_bundle_id` CHAR(36) NOT NULL, + `pipeline_version` VARCHAR(64) NOT NULL, + `device_profile` ENUM('cpu','cuda') NOT NULL DEFAULT 'cpu', + `reference_date` DATE NOT NULL, + `status` ENUM('queued','processing','retrying','completed','needs_retake','needs_review','failed','superseded') NOT NULL DEFAULT 'queued', + `stage` ENUM('queued','quality','facts','rules','done') NOT NULL DEFAULT 'queued', + `result` JSON NULL, + `result_hash` CHAR(64) NULL, + `error_code` VARCHAR(80) NULL, + `started_at` DATETIME(3) NULL, + `finished_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_inference_runs_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_inference_runs_1` (`tenant_id`, `item_id`, `id`), + UNIQUE KEY `uq_inference_runs_2` (`tenant_id`, `item_id`, `submission_revision`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `fact_revisions` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `item_id` CHAR(36) NOT NULL, + `run_id` CHAR(36) NOT NULL, + `revision` INT UNSIGNED NOT NULL DEFAULT 1, + `entities` JSON NOT NULL, + `relations` JSON NOT NULL, + `dates` JSON NOT NULL, + `reason` VARCHAR(2000) NOT NULL, + `created_by` CHAR(36) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_fact_revisions_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_fact_revisions_1` (`tenant_id`, `item_id`, `id`), + UNIQUE KEY `uq_fact_revisions_2` (`tenant_id`, `run_id`, `revision`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `rule_evaluations` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `item_id` CHAR(36) NOT NULL, + `run_id` CHAR(36) NOT NULL, + `fact_revision_id` CHAR(36) NOT NULL, + `rule_bundle_id` CHAR(36) NOT NULL, + `reference_date` DATE NOT NULL, + `status` ENUM('queued','running','completed','failed','superseded') NOT NULL DEFAULT 'queued', + `result` JSON NULL, + `result_hash` CHAR(64) NULL, + `finished_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_rule_evaluations_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_rule_evaluations_1` (`tenant_id`, `item_id`, `id`), + UNIQUE KEY `uq_rule_evaluations_2` (`tenant_id`, `fact_revision_id`, `rule_bundle_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `findings` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `item_id` CHAR(36) NOT NULL, + `run_id` CHAR(36) NOT NULL, + `fact_revision_id` CHAR(36) NOT NULL, + `rule_evaluation_id` CHAR(36) NOT NULL, + `rule_id` VARCHAR(80) NOT NULL, + `fingerprint` CHAR(64) NOT NULL, + `type` VARCHAR(80) NOT NULL, + `severity` ENUM('low','medium','high','critical') NOT NULL DEFAULT 'low', + `status` ENUM('needs_review','confirmed','rejected','cannot_determine','dispatched','closed') NOT NULL DEFAULT 'needs_review', + `explanation` VARCHAR(4000) NOT NULL, + `evidence` JSON NOT NULL, + `superseded_at` DATETIME(3) NULL, + `confirmed_by` CHAR(36) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_findings_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_findings_1` (`tenant_id`, `rule_evaluation_id`, `fingerprint`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `review_actions` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `actor_id` CHAR(36) NOT NULL, + `resource_type` VARCHAR(80) NOT NULL, + `resource_id` CHAR(36) NOT NULL, + `action` VARCHAR(80) NOT NULL, + `reason` VARCHAR(2000) NOT NULL, + `before_version` INT UNSIGNED NOT NULL DEFAULT 1, + `after_version` INT UNSIGNED NOT NULL DEFAULT 1, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_review_actions_0` (`tenant_id`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `remediation_tasks` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `finding_id` CHAR(36) NOT NULL, + `laboratory_id` CHAR(36) NOT NULL, + `assignee_id` CHAR(36) NOT NULL, + `status` ENUM('pending_dispatch','in_progress','pending_recheck','rejected','cannot_remediate','closed') NOT NULL DEFAULT 'pending_dispatch', + `priority` ENUM('low','medium','high','critical') NOT NULL DEFAULT 'low', + `due_at` DATETIME(3) NOT NULL, + `description` VARCHAR(4000) NOT NULL, + `latest_evidence_id` CHAR(36) NULL, + `closed_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_remediation_tasks_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_remediation_tasks_1` (`tenant_id`, `finding_id`), + UNIQUE KEY `uq_remediation_tasks_2` (`tenant_id`, `laboratory_id`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `remediation_evidence` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `task_id` CHAR(36) NOT NULL, + `description` VARCHAR(4000) NOT NULL, + `submitted_by` CHAR(36) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_remediation_evidence_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_remediation_evidence_1` (`tenant_id`, `task_id`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `uploads` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `laboratory_id` CHAR(36) NOT NULL, + `inspection_item_id` CHAR(36) NULL, + `remediation_task_id` CHAR(36) NULL, + `requested_by` CHAR(36) NOT NULL, + `object_key` VARCHAR(1024) NOT NULL, + `expected_sha256` CHAR(64) NOT NULL, + `mime_type` VARCHAR(40) NOT NULL, + `size_bytes` BIGINT UNSIGNED NOT NULL, + `captured_at` DATETIME(3) NOT NULL, + `expires_at` DATETIME(3) NOT NULL, + `status` ENUM('granted','validating','ready','rejected','expired') NOT NULL DEFAULT 'granted', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_uploads_0` (`tenant_id`, `id`), + CONSTRAINT `ck_uploads_0` CHECK ((inspection_item_id IS NULL) <> (remediation_task_id IS NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `asset_images` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `laboratory_id` CHAR(36) NOT NULL, + `inspection_item_id` CHAR(36) NULL, + `remediation_task_id` CHAR(36) NULL, + `upload_id` CHAR(36) NOT NULL, + `status` ENUM('validating','ready','rejected','deleted') NOT NULL DEFAULT 'validating', + `original_key` VARCHAR(1024) NOT NULL, + `original_object_version` VARCHAR(200) NOT NULL, + `original_sha256` CHAR(64) NOT NULL, + `analysis_key` VARCHAR(1024) NULL, + `analysis_object_version` VARCHAR(200) NULL, + `analysis_sha256` CHAR(64) NULL, + `width` INT UNSIGNED NULL, + `height` INT UNSIGNED NULL, + `mime_type` VARCHAR(40) NOT NULL, + `captured_at` DATETIME(3) NOT NULL, + `legal_hold` BOOLEAN NOT NULL DEFAULT FALSE, + `deleted_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_asset_images_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_asset_images_1` (`tenant_id`, `upload_id`), + UNIQUE KEY `uq_asset_images_2` (`tenant_id`, `inspection_item_id`, `id`), + UNIQUE KEY `uq_asset_images_3` (`tenant_id`, `remediation_task_id`, `id`), + CONSTRAINT `ck_asset_images_0` CHECK ((inspection_item_id IS NULL) <> (remediation_task_id IS NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `evidence_images` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `task_id` CHAR(36) NOT NULL, + `evidence_id` CHAR(36) NOT NULL, + `image_id` CHAR(36) NOT NULL, + `ordinal` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_evidence_images_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_evidence_images_1` (`tenant_id`, `evidence_id`, `image_id`), + UNIQUE KEY `uq_evidence_images_2` (`tenant_id`, `evidence_id`, `ordinal`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `run_images` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `item_id` CHAR(36) NOT NULL, + `run_id` CHAR(36) NOT NULL, + `image_id` CHAR(36) NOT NULL, + `ordinal` INT UNSIGNED NOT NULL DEFAULT 0, + `role` ENUM('overview','detail') NOT NULL DEFAULT 'overview', + `parent_image_id` CHAR(36) NULL, + `analysis_sha256` CHAR(64) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_run_images_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_run_images_1` (`tenant_id`, `run_id`, `image_id`), + UNIQUE KEY `uq_run_images_2` (`tenant_id`, `run_id`, `ordinal`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `image_derivatives` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `run_id` CHAR(36) NOT NULL, + `image_id` CHAR(36) NOT NULL, + `crop_id` CHAR(36) NOT NULL, + `detection_id` CHAR(36) NOT NULL, + `kind` ENUM('crop') NOT NULL DEFAULT 'crop', + `recipe` JSON NOT NULL, + `object_key` VARCHAR(1024) NOT NULL, + `object_version` VARCHAR(200) NOT NULL, + `sha256` CHAR(64) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_image_derivatives_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_image_derivatives_1` (`tenant_id`, `run_id`, `crop_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `api_idempotency` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `actor_id` CHAR(36) NOT NULL, + `method` VARCHAR(10) NOT NULL, + `path_hash` CHAR(64) NOT NULL, + `key_hash` CHAR(64) NOT NULL, + `request_hash` CHAR(64) NOT NULL, + `state` ENUM('pending','completed') NOT NULL DEFAULT 'pending', + `response_status` SMALLINT UNSIGNED NULL, + `response` JSON NULL, + `lease_owner` CHAR(36) NULL, + `lease_until` DATETIME(3) NULL, + `expires_at` DATETIME(3) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_api_idempotency_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_api_idempotency_1` (`tenant_id`, `actor_id`, `method`, `path_hash`, `key_hash`), + KEY `ix_api_idempotency_0` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `outbox_events` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `event_type` VARCHAR(80) NOT NULL, + `aggregate_type` VARCHAR(80) NOT NULL, + `aggregate_id` CHAR(36) NOT NULL, + `aggregate_version` INT UNSIGNED NOT NULL DEFAULT 1, + `payload` JSON NOT NULL, + `state` ENUM('pending','leased','published') NOT NULL DEFAULT 'pending', + `lease_owner` CHAR(36) NULL, + `lease_until` DATETIME(3) NULL, + `attempts` INT UNSIGNED NOT NULL DEFAULT 0, + `available_at` DATETIME(3) NOT NULL, + `published_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_outbox_events_0` (`tenant_id`, `id`), + KEY `ix_outbox_events_0` (`state`, `available_at`, `id`), + KEY `ix_outbox_events_1` (`state`, `lease_until`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `event_inbox` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `event_id` CHAR(36) NOT NULL, + `consumer` VARCHAR(80) NOT NULL, + `processed_at` DATETIME(3) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_event_inbox_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_event_inbox_1` (`tenant_id`, `event_id`, `consumer`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `task_runs` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `task_type` VARCHAR(80) NOT NULL, + `resource_id` CHAR(36) NOT NULL, + `logical_key` VARCHAR(200) NOT NULL, + `payload` JSON NOT NULL, + `state` ENUM('ready','leased','retry_wait','succeeded','failed','dead_letter') NOT NULL DEFAULT 'ready', + `attempt` INT UNSIGNED NOT NULL DEFAULT 0, + `replay_generation` INT UNSIGNED NOT NULL DEFAULT 0, + `dispatch_sequence` INT UNSIGNED NOT NULL DEFAULT 1, + `lease_owner` CHAR(36) NULL, + `lease_until` DATETIME(3) NULL, + `fencing_token` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `heartbeat_at` DATETIME(3) NULL, + `available_at` DATETIME(3) NOT NULL, + `last_dispatched_at` DATETIME(3) NULL, + `last_error_code` VARCHAR(80) NULL, + `started_at` DATETIME(3) NULL, + `finished_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_task_runs_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_task_runs_1` (`tenant_id`, `logical_key`), + KEY `ix_task_runs_0` (`state`, `available_at`, `id`), + KEY `ix_task_runs_1` (`state`, `lease_until`), + CONSTRAINT `ck_task_runs_0` CHECK (attempt <= 4) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `task_attempts` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `task_id` CHAR(36) NOT NULL, + `replay_generation` INT UNSIGNED NOT NULL DEFAULT 0, + `attempt` INT UNSIGNED NOT NULL DEFAULT 1, + `fencing_token` BIGINT UNSIGNED NOT NULL, + `lease_owner` CHAR(36) NOT NULL, + `started_at` DATETIME(3) NOT NULL, + `finished_at` DATETIME(3) NULL, + `status` ENUM('running','succeeded','failed','abandoned') NOT NULL DEFAULT 'running', + `error_code` VARCHAR(80) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_task_attempts_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_task_attempts_1` (`tenant_id`, `task_id`, `replay_generation`, `attempt`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `notifications` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `event_id` CHAR(36) NOT NULL, + `recipient_id` CHAR(36) NOT NULL, + `type` VARCHAR(80) NOT NULL, + `resource_type` VARCHAR(80) NOT NULL, + `resource_id` CHAR(36) NOT NULL, + `read_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_notifications_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_notifications_1` (`tenant_id`, `event_id`, `recipient_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `audit_events` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `actor_id` CHAR(36) NULL, + `action` VARCHAR(100) NOT NULL, + `resource_type` VARCHAR(80) NOT NULL, + `resource_id` CHAR(36) NOT NULL, + `changes` JSON NOT NULL, + `reason` VARCHAR(2000) NOT NULL, + `request_id` CHAR(36) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_audit_events_0` (`tenant_id`, `id`), + KEY `ix_audit_events_0` (`tenant_id`, `resource_type`, `resource_id`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `report_exports` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `requested_by` CHAR(36) NOT NULL, + `format` ENUM('csv','pdf') NOT NULL DEFAULT 'csv', + `filters` JSON NOT NULL, + `snapshot_at` DATETIME(3) NOT NULL, + `snapshot` JSON NOT NULL, + `status` ENUM('queued','running','ready','failed','expired') NOT NULL DEFAULT 'queued', + `object_key` VARCHAR(1024) NULL, + `checksum` CHAR(64) NULL, + `expires_at` DATETIME(3) NULL, + `last_error_code` VARCHAR(80) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_report_exports_0` (`tenant_id`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `dataset_versions` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `name` VARCHAR(100) NOT NULL, + `checksum` CHAR(64) NOT NULL, + `manifest_key` VARCHAR(1024) NOT NULL, + `authorization_ref` VARCHAR(1000) NOT NULL, + `status` ENUM('draft','frozen','retired') NOT NULL DEFAULT 'draft', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_dataset_versions_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_dataset_versions_1` (`tenant_id`, `name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `dataset_images` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `dataset_id` CHAR(36) NOT NULL, + `image_id` CHAR(36) NOT NULL, + `scene_id` VARCHAR(100) NOT NULL, + `split` ENUM('train','validation','test') NOT NULL DEFAULT 'train', + `annotation_key` VARCHAR(1024) NOT NULL, + `provenance` VARCHAR(2000) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_dataset_images_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_dataset_images_1` (`tenant_id`, `dataset_id`, `image_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +ALTER TABLE `colleges` ADD CONSTRAINT `fk_colleges_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `laboratories` ADD CONSTRAINT `fk_laboratories_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `laboratories` ADD CONSTRAINT `fk_laboratories_1` FOREIGN KEY (`tenant_id`, `college_id`) REFERENCES `colleges` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `users` ADD CONSTRAINT `fk_users_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `user_roles` ADD CONSTRAINT `fk_user_roles_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `user_roles` ADD CONSTRAINT `fk_user_roles_1` FOREIGN KEY (`tenant_id`, `user_id`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `user_roles` ADD CONSTRAINT `fk_user_roles_2` FOREIGN KEY (`tenant_id`, `laboratory_id`) REFERENCES `laboratories` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `user_roles` ADD CONSTRAINT `fk_user_roles_3` FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `sessions` ADD CONSTRAINT `fk_sessions_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `sessions` ADD CONSTRAINT `fk_sessions_1` FOREIGN KEY (`tenant_id`, `user_id`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `locations` ADD CONSTRAINT `fk_locations_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `locations` ADD CONSTRAINT `fk_locations_1` FOREIGN KEY (`tenant_id`, `laboratory_id`) REFERENCES `laboratories` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `locations` ADD CONSTRAINT `fk_locations_2` FOREIGN KEY (`tenant_id`, `laboratory_id`, `parent_id`) REFERENCES `locations` (`tenant_id`, `laboratory_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspection_templates` ADD CONSTRAINT `fk_inspection_templates_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `template_items` ADD CONSTRAINT `fk_template_items_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `template_items` ADD CONSTRAINT `fk_template_items_1` FOREIGN KEY (`tenant_id`, `template_id`) REFERENCES `inspection_templates` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspections` ADD CONSTRAINT `fk_inspections_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspections` ADD CONSTRAINT `fk_inspections_1` FOREIGN KEY (`tenant_id`, `laboratory_id`) REFERENCES `laboratories` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspections` ADD CONSTRAINT `fk_inspections_2` FOREIGN KEY (`tenant_id`, `template_id`) REFERENCES `inspection_templates` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspections` ADD CONSTRAINT `fk_inspections_3` FOREIGN KEY (`tenant_id`, `inspector_id`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspection_items` ADD CONSTRAINT `fk_inspection_items_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspection_items` ADD CONSTRAINT `fk_inspection_items_1` FOREIGN KEY (`tenant_id`, `laboratory_id`, `inspection_id`) REFERENCES `inspections` (`tenant_id`, `laboratory_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspection_items` ADD CONSTRAINT `fk_inspection_items_2` FOREIGN KEY (`tenant_id`, `template_item_id`) REFERENCES `template_items` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspection_items` ADD CONSTRAINT `fk_inspection_items_3` FOREIGN KEY (`tenant_id`, `laboratory_id`, `location_id`) REFERENCES `locations` (`tenant_id`, `laboratory_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspection_items` ADD CONSTRAINT `fk_inspection_items_4` FOREIGN KEY (`tenant_id`, `reviewed_by`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspection_items` ADD CONSTRAINT `fk_inspection_items_5` FOREIGN KEY (`tenant_id`, `id`, `current_run_id`) REFERENCES `inference_runs` (`tenant_id`, `item_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspection_items` ADD CONSTRAINT `fk_inspection_items_6` FOREIGN KEY (`tenant_id`, `id`, `current_fact_revision_id`) REFERENCES `fact_revisions` (`tenant_id`, `item_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspection_items` ADD CONSTRAINT `fk_inspection_items_7` FOREIGN KEY (`tenant_id`, `id`, `current_evaluation_id`) REFERENCES `rule_evaluations` (`tenant_id`, `item_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `dictionary_versions` ADD CONSTRAINT `fk_dictionary_versions_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `chemical_entities` ADD CONSTRAINT `fk_chemical_entities_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `chemical_entities` ADD CONSTRAINT `fk_chemical_entities_1` FOREIGN KEY (`tenant_id`, `dictionary_version_id`) REFERENCES `dictionary_versions` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `chemical_aliases` ADD CONSTRAINT `fk_chemical_aliases_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `chemical_aliases` ADD CONSTRAINT `fk_chemical_aliases_1` FOREIGN KEY (`tenant_id`, `dictionary_version_id`, `entity_id`) REFERENCES `chemical_entities` (`tenant_id`, `dictionary_version_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `model_versions` ADD CONSTRAINT `fk_model_versions_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `model_versions` ADD CONSTRAINT `fk_model_versions_1` FOREIGN KEY (`tenant_id`, `dictionary_version_id`) REFERENCES `dictionary_versions` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_sets` ADD CONSTRAINT `fk_rule_sets_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_versions` ADD CONSTRAINT `fk_rule_versions_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_versions` ADD CONSTRAINT `fk_rule_versions_1` FOREIGN KEY (`tenant_id`, `rule_set_id`) REFERENCES `rule_sets` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_versions` ADD CONSTRAINT `fk_rule_versions_2` FOREIGN KEY (`tenant_id`, `submitted_by`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_versions` ADD CONSTRAINT `fk_rule_versions_3` FOREIGN KEY (`tenant_id`, `approved_by`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_bundles` ADD CONSTRAINT `fk_rule_bundles_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_bundle_members` ADD CONSTRAINT `fk_rule_bundle_members_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_bundle_members` ADD CONSTRAINT `fk_rule_bundle_members_1` FOREIGN KEY (`tenant_id`, `bundle_id`) REFERENCES `rule_bundles` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_bundle_members` ADD CONSTRAINT `fk_rule_bundle_members_2` FOREIGN KEY (`tenant_id`, `rule_version_id`) REFERENCES `rule_versions` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `activations` ADD CONSTRAINT `fk_activations_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `activations` ADD CONSTRAINT `fk_activations_1` FOREIGN KEY (`tenant_id`, `laboratory_id`) REFERENCES `laboratories` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `activations` ADD CONSTRAINT `fk_activations_2` FOREIGN KEY (`tenant_id`, `model_bundle_id`) REFERENCES `model_versions` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `activations` ADD CONSTRAINT `fk_activations_3` FOREIGN KEY (`tenant_id`, `dictionary_version_id`) REFERENCES `dictionary_versions` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `activations` ADD CONSTRAINT `fk_activations_4` FOREIGN KEY (`tenant_id`, `rule_bundle_id`) REFERENCES `rule_bundles` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inference_runs` ADD CONSTRAINT `fk_inference_runs_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inference_runs` ADD CONSTRAINT `fk_inference_runs_1` FOREIGN KEY (`tenant_id`, `laboratory_id`, `item_id`) REFERENCES `inspection_items` (`tenant_id`, `laboratory_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inference_runs` ADD CONSTRAINT `fk_inference_runs_2` FOREIGN KEY (`tenant_id`, `replay_of`) REFERENCES `inference_runs` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inference_runs` ADD CONSTRAINT `fk_inference_runs_3` FOREIGN KEY (`tenant_id`, `model_bundle_id`) REFERENCES `model_versions` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inference_runs` ADD CONSTRAINT `fk_inference_runs_4` FOREIGN KEY (`tenant_id`, `dictionary_version_id`) REFERENCES `dictionary_versions` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inference_runs` ADD CONSTRAINT `fk_inference_runs_5` FOREIGN KEY (`tenant_id`, `rule_bundle_id`) REFERENCES `rule_bundles` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `fact_revisions` ADD CONSTRAINT `fk_fact_revisions_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `fact_revisions` ADD CONSTRAINT `fk_fact_revisions_1` FOREIGN KEY (`tenant_id`, `item_id`, `run_id`) REFERENCES `inference_runs` (`tenant_id`, `item_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `fact_revisions` ADD CONSTRAINT `fk_fact_revisions_2` FOREIGN KEY (`tenant_id`, `created_by`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_evaluations` ADD CONSTRAINT `fk_rule_evaluations_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_evaluations` ADD CONSTRAINT `fk_rule_evaluations_1` FOREIGN KEY (`tenant_id`, `item_id`, `run_id`) REFERENCES `inference_runs` (`tenant_id`, `item_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_evaluations` ADD CONSTRAINT `fk_rule_evaluations_2` FOREIGN KEY (`tenant_id`, `item_id`, `fact_revision_id`) REFERENCES `fact_revisions` (`tenant_id`, `item_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_evaluations` ADD CONSTRAINT `fk_rule_evaluations_3` FOREIGN KEY (`tenant_id`, `rule_bundle_id`) REFERENCES `rule_bundles` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `findings` ADD CONSTRAINT `fk_findings_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `findings` ADD CONSTRAINT `fk_findings_1` FOREIGN KEY (`tenant_id`, `item_id`, `run_id`) REFERENCES `inference_runs` (`tenant_id`, `item_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `findings` ADD CONSTRAINT `fk_findings_2` FOREIGN KEY (`tenant_id`, `item_id`, `fact_revision_id`) REFERENCES `fact_revisions` (`tenant_id`, `item_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `findings` ADD CONSTRAINT `fk_findings_3` FOREIGN KEY (`tenant_id`, `item_id`, `rule_evaluation_id`) REFERENCES `rule_evaluations` (`tenant_id`, `item_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `findings` ADD CONSTRAINT `fk_findings_4` FOREIGN KEY (`tenant_id`, `confirmed_by`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `review_actions` ADD CONSTRAINT `fk_review_actions_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `review_actions` ADD CONSTRAINT `fk_review_actions_1` FOREIGN KEY (`tenant_id`, `actor_id`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `remediation_tasks` ADD CONSTRAINT `fk_remediation_tasks_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `remediation_tasks` ADD CONSTRAINT `fk_remediation_tasks_1` FOREIGN KEY (`tenant_id`, `finding_id`) REFERENCES `findings` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `remediation_tasks` ADD CONSTRAINT `fk_remediation_tasks_2` FOREIGN KEY (`tenant_id`, `laboratory_id`) REFERENCES `laboratories` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `remediation_tasks` ADD CONSTRAINT `fk_remediation_tasks_3` FOREIGN KEY (`tenant_id`, `assignee_id`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `remediation_tasks` ADD CONSTRAINT `fk_remediation_tasks_4` FOREIGN KEY (`tenant_id`, `id`, `latest_evidence_id`) REFERENCES `remediation_evidence` (`tenant_id`, `task_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `remediation_evidence` ADD CONSTRAINT `fk_remediation_evidence_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `remediation_evidence` ADD CONSTRAINT `fk_remediation_evidence_1` FOREIGN KEY (`tenant_id`, `task_id`) REFERENCES `remediation_tasks` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `remediation_evidence` ADD CONSTRAINT `fk_remediation_evidence_2` FOREIGN KEY (`tenant_id`, `submitted_by`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `uploads` ADD CONSTRAINT `fk_uploads_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `uploads` ADD CONSTRAINT `fk_uploads_1` FOREIGN KEY (`tenant_id`, `requested_by`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `uploads` ADD CONSTRAINT `fk_uploads_2` FOREIGN KEY (`tenant_id`, `laboratory_id`) REFERENCES `laboratories` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `uploads` ADD CONSTRAINT `fk_uploads_3` FOREIGN KEY (`tenant_id`, `laboratory_id`, `inspection_item_id`) REFERENCES `inspection_items` (`tenant_id`, `laboratory_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `uploads` ADD CONSTRAINT `fk_uploads_4` FOREIGN KEY (`tenant_id`, `laboratory_id`, `remediation_task_id`) REFERENCES `remediation_tasks` (`tenant_id`, `laboratory_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `asset_images` ADD CONSTRAINT `fk_asset_images_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `asset_images` ADD CONSTRAINT `fk_asset_images_1` FOREIGN KEY (`tenant_id`, `upload_id`) REFERENCES `uploads` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `asset_images` ADD CONSTRAINT `fk_asset_images_2` FOREIGN KEY (`tenant_id`, `laboratory_id`) REFERENCES `laboratories` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `asset_images` ADD CONSTRAINT `fk_asset_images_3` FOREIGN KEY (`tenant_id`, `laboratory_id`, `inspection_item_id`) REFERENCES `inspection_items` (`tenant_id`, `laboratory_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `asset_images` ADD CONSTRAINT `fk_asset_images_4` FOREIGN KEY (`tenant_id`, `laboratory_id`, `remediation_task_id`) REFERENCES `remediation_tasks` (`tenant_id`, `laboratory_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `evidence_images` ADD CONSTRAINT `fk_evidence_images_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `evidence_images` ADD CONSTRAINT `fk_evidence_images_1` FOREIGN KEY (`tenant_id`, `task_id`, `evidence_id`) REFERENCES `remediation_evidence` (`tenant_id`, `task_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `evidence_images` ADD CONSTRAINT `fk_evidence_images_2` FOREIGN KEY (`tenant_id`, `task_id`, `image_id`) REFERENCES `asset_images` (`tenant_id`, `remediation_task_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `run_images` ADD CONSTRAINT `fk_run_images_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `run_images` ADD CONSTRAINT `fk_run_images_1` FOREIGN KEY (`tenant_id`, `item_id`, `run_id`) REFERENCES `inference_runs` (`tenant_id`, `item_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `run_images` ADD CONSTRAINT `fk_run_images_2` FOREIGN KEY (`tenant_id`, `item_id`, `image_id`) REFERENCES `asset_images` (`tenant_id`, `inspection_item_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `run_images` ADD CONSTRAINT `fk_run_images_3` FOREIGN KEY (`tenant_id`, `run_id`, `parent_image_id`) REFERENCES `run_images` (`tenant_id`, `run_id`, `image_id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `image_derivatives` ADD CONSTRAINT `fk_image_derivatives_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `image_derivatives` ADD CONSTRAINT `fk_image_derivatives_1` FOREIGN KEY (`tenant_id`, `run_id`, `image_id`) REFERENCES `run_images` (`tenant_id`, `run_id`, `image_id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `api_idempotency` ADD CONSTRAINT `fk_api_idempotency_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `api_idempotency` ADD CONSTRAINT `fk_api_idempotency_1` FOREIGN KEY (`tenant_id`, `actor_id`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `outbox_events` ADD CONSTRAINT `fk_outbox_events_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `event_inbox` ADD CONSTRAINT `fk_event_inbox_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `event_inbox` ADD CONSTRAINT `fk_event_inbox_1` FOREIGN KEY (`tenant_id`, `event_id`) REFERENCES `outbox_events` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `task_runs` ADD CONSTRAINT `fk_task_runs_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `task_attempts` ADD CONSTRAINT `fk_task_attempts_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `task_attempts` ADD CONSTRAINT `fk_task_attempts_1` FOREIGN KEY (`tenant_id`, `task_id`) REFERENCES `task_runs` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `notifications` ADD CONSTRAINT `fk_notifications_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `notifications` ADD CONSTRAINT `fk_notifications_1` FOREIGN KEY (`tenant_id`, `event_id`) REFERENCES `outbox_events` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `notifications` ADD CONSTRAINT `fk_notifications_2` FOREIGN KEY (`tenant_id`, `recipient_id`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `audit_events` ADD CONSTRAINT `fk_audit_events_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `audit_events` ADD CONSTRAINT `fk_audit_events_1` FOREIGN KEY (`tenant_id`, `actor_id`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `report_exports` ADD CONSTRAINT `fk_report_exports_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `report_exports` ADD CONSTRAINT `fk_report_exports_1` FOREIGN KEY (`tenant_id`, `requested_by`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `dataset_versions` ADD CONSTRAINT `fk_dataset_versions_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `dataset_images` ADD CONSTRAINT `fk_dataset_images_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `dataset_images` ADD CONSTRAINT `fk_dataset_images_1` FOREIGN KEY (`tenant_id`, `dataset_id`) REFERENCES `dataset_versions` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `dataset_images` ADD CONSTRAINT `fk_dataset_images_2` FOREIGN KEY (`tenant_id`, `image_id`) REFERENCES `asset_images` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; diff --git a/packages/persistence/migrations/versions/0001_initial.py b/packages/persistence/migrations/versions/0001_initial.py new file mode 100644 index 0000000..3e7abc8 --- /dev/null +++ b/packages/persistence/migrations/versions/0001_initial.py @@ -0,0 +1,46 @@ +"""I-01B: frozen 43-table design baseline; no demo accounts or credentials.""" + +import json +import os +from pathlib import Path + +from alembic import op +from sqlalchemy import inspect + +revision = "0001_initial" +down_revision = None +branch_labels = None +depends_on = None +SNAPSHOTS = Path(__file__).resolve().parents[1] / "snapshots" + + +def upgrade(): + connection = op.get_bind() + inspector = inspect(connection) + if set(inspector.get_table_names()) - {"alembic_version"} or inspector.get_view_names(): + raise RuntimeError("Initial migration requires an empty disposable database") + ddl = (SNAPSHOTS / "0001_initial.sql").read_text(encoding="utf-8") + # Immutable generated DDL has no procedures or semicolons in literals. + ddl = "\n".join(line for line in ddl.splitlines() if not line.startswith("--")) + for statement in ddl.split(";"): + if statement.strip(): + connection.exec_driver_sql(statement.strip()) + + +def downgrade(): + connection = op.get_bind() + schema = connection.engine.url.database + if ( + os.environ.get("APP_ENV") != "test" + or os.environ.get("LABSAFE_ALLOW_DESTRUCTIVE_DOWNGRADE") != schema + ): + raise RuntimeError( + "Destructive downgrade requires APP_ENV=test and " + "LABSAFE_ALLOW_DESTRUCTIVE_DOWNGRADE equal to DATABASE_SCHEMA" + ) + model = json.loads((SNAPSHOTS / "0001_initial.json").read_text(encoding="utf-8")) + for table, definition in model["tables"].items(): + for index, _foreign_key in enumerate(definition["foreign_keys"]): + op.drop_constraint(f"fk_{table}_{index}", table, type_="foreignkey") + for table in reversed(model["tables"]): + op.drop_table(table) diff --git a/packages/persistence/migrations/versions/__init__.py b/packages/persistence/migrations/versions/__init__.py new file mode 100644 index 0000000..e9b79b6 --- /dev/null +++ b/packages/persistence/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Alembic revision history. Never edit a released revision.""" diff --git a/packages/persistence/schema.py b/packages/persistence/schema.py new file mode 100644 index 0000000..643139c --- /dev/null +++ b/packages/persistence/schema.py @@ -0,0 +1,250 @@ +"""Read-only comparison of actual MySQL metadata with the frozen design.""" + +import json +import re +from collections import defaultdict +from pathlib import Path + +from sqlalchemy import text + +from packages.persistence.database import STRICT_SQL_MODE + +MODEL_PATH = Path(__file__).parent / "migrations/snapshots/0001_initial.json" + + +def model(): + return json.loads(MODEL_PATH.read_text(encoding="utf-8")) + + +def expression_tokens(value): + # MySQL adds identifier quotes, charset introducers and redundant parentheses. + value = value.replace(chr(96), "").replace("\\'", "'") + value = re.sub(r"_(?:utf8mb[34]|ascii|latin1)(?=')", "", value, flags=re.I) + tokens = re.findall(r"'(?:''|[^'])*'|[a-zA-Z_][a-zA-Z_0-9]*|\d+|<=|>=|<>|[(),=<>]", value) + if re.sub(r"\s+", "", "".join(tokens)) != re.sub(r"\s+", "", value): + raise ValueError(f"Unsupported SQL schema expression token: {value!r}") + return [t if t.startswith("'") else t.lower() for t in tokens] + + +def check_tree(value): + """Parse this baseline's restricted CHECK grammar without discarding precedence.""" + tokens = expression_tokens(value) + position = 0 + + precedence = {"or": 1, "and": 2, "=": 3, "<=": 3, ">=": 3, "<": 3, ">": 3, "<>": 3, "is": 3} + + def parse(minimum=0): + nonlocal position + if tokens[position] == "(": + position += 1 + node = parse() + if tokens[position] != ")": + raise ValueError("Unbalanced CHECK expression") + position += 1 + else: + node = tokens[position] + position += 1 + while position < len(tokens) and precedence.get(tokens[position], -1) >= minimum: + operator = tokens[position] + priority = precedence[operator] + position += 1 + if operator == "is" and tokens[position] == "not": + operator += " not" + position += 1 + node = (operator, node, parse(priority + 1)) + return node + + result = parse() + if position != len(tokens): + raise ValueError("Unexpected CHECK expression suffix") + return result + + +def column_type(value): + # ENUM values are case-sensitive under utf8mb4_bin; normalize SQL keywords only. + parts = re.split(r"('(?:''|[^'])*')", value) + value = "".join(part if i % 2 else part.lower() for i, part in enumerate(parts)) + if value == "boolean": + return "tinyint(1)" + # MySQL 8.0 omits deprecated integer display widths except BOOLEAN/tinyint(1). + return re.sub(r"\b(int|bigint|smallint|mediumint)\(\d+\)", r"\1", value) + + +def default_value(value): + if value is None: + return None + value = str(value) + if value.startswith("'") and value.endswith("'"): + return value[1:-1].replace("''", "'") + if value.upper() in {"TRUE", "FALSE"}: + return "1" if value.upper() == "TRUE" else "0" + if value.lower().startswith("current_timestamp"): + return value.lower() + return value + + +def verify_schema(connection) -> dict: + expected = model()["tables"] + errors = [] + + def compare(path, actual, wanted): + if actual != wanted: + errors.append(f"{path}: actual={actual!r}; expected={wanted!r}") + + def rows(query): + return connection.execute(text(query)).mappings().all() + + tables = rows( + "SELECT TABLE_NAME,ENGINE,TABLE_COLLATION FROM information_schema.TABLES " + "WHERE TABLE_SCHEMA=DATABASE()" + ) + actual_names = {row["TABLE_NAME"] for row in tables} - {"alembic_version"} + compare("tables", sorted(actual_names), sorted(expected)) + for row in tables: + if row["TABLE_NAME"] in expected: + compare(f"{row['TABLE_NAME']}.engine", row["ENGINE"], "InnoDB") + compare(f"{row['TABLE_NAME']}.collation", row["TABLE_COLLATION"], "utf8mb4_bin") + + columns = defaultdict(dict) + for row in rows( + "SELECT * FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() " + "ORDER BY TABLE_NAME,ORDINAL_POSITION" + ): + columns[row["TABLE_NAME"]][row["COLUMN_NAME"]] = row + for table, definition in expected.items(): + compare(f"{table}.columns", list(columns[table]), list(definition["columns"])) + for name, wanted in definition["columns"].items(): + actual = columns[table].get(name) + if actual is None: + continue + path = f"{table}.{name}" + compare(path + ".type", column_type(actual["COLUMN_TYPE"]), column_type(wanted["type"])) + compare(path + ".nullable", actual["IS_NULLABLE"] == "YES", wanted["nullable"]) + compare( + path + ".default", + default_value(actual["COLUMN_DEFAULT"]), + default_value(wanted["default_sql"]), + ) + generated = wanted["generated_sql"] + compare( + path + ".generated", + expression_tokens(actual["GENERATION_EXPRESSION"] or ""), + expression_tokens(generated or ""), + ) + extra = actual["EXTRA"].lower().split() + wanted_extra = ["stored", "generated"] if generated else [] + if (wanted["default_sql"] or "").upper().startswith("CURRENT_TIMESTAMP"): + wanted_extra = ["default_generated"] + compare(path + ".extra", extra, wanted_extra) + if re.match(r"^(char|varchar|enum|text)", actual["DATA_TYPE"]): + compare(path + ".collation", actual["COLLATION_NAME"], "utf8mb4_bin") + + indexes = defaultdict(lambda: defaultdict(list)) + for row in rows( + "SELECT * FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() " + "ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX" + ): + indexes[row["TABLE_NAME"]][row["INDEX_NAME"]].append(row) + for table, definition in expected.items(): + required = {"PRIMARY": (0, definition["primary_key"])} + required.update( + {f"uq_{table}_{i}": (0, fields) for i, fields in enumerate(definition["unique"])} + ) + required.update( + {f"ix_{table}_{i}": (1, fields) for i, fields in enumerate(definition["indexes"])} + ) + implicit = { + f"fk_{table}_{i}": (1, fk["columns"]) for i, fk in enumerate(definition["foreign_keys"]) + } + for name in required.keys() - indexes[table].keys(): + errors.append(f"{table}.index.{name}: missing") + for name, parts in indexes[table].items(): + wanted = required.get(name, implicit.get(name)) + actual = (parts[0]["NON_UNIQUE"], [part["COLUMN_NAME"] for part in parts]) + compare(f"{table}.index.{name}", actual, wanted) + for part in parts: + compare( + f"{table}.index.{name}.kind", + (part["SUB_PART"], part["INDEX_TYPE"], part["IS_VISIBLE"]), + (None, "BTREE", "YES"), + ) + + foreign_keys = defaultdict(lambda: defaultdict(list)) + for row in rows( + "SELECT k.*,r.UPDATE_RULE,r.DELETE_RULE FROM information_schema.KEY_COLUMN_USAGE k " + "JOIN information_schema.REFERENTIAL_CONSTRAINTS r " + "ON k.CONSTRAINT_SCHEMA=r.CONSTRAINT_SCHEMA AND k.TABLE_NAME=r.TABLE_NAME " + "AND k.CONSTRAINT_NAME=r.CONSTRAINT_NAME WHERE k.TABLE_SCHEMA=DATABASE() " + "ORDER BY k.TABLE_NAME,k.CONSTRAINT_NAME,k.ORDINAL_POSITION" + ): + foreign_keys[row["TABLE_NAME"]][row["CONSTRAINT_NAME"]].append(row) + schema = connection.scalar(text("SELECT DATABASE()")) + for table, definition in expected.items(): + wanted_names = [f"fk_{table}_{i}" for i in range(len(definition["foreign_keys"]))] + compare(f"{table}.foreign_keys", sorted(foreign_keys[table]), sorted(wanted_names)) + for i, wanted in enumerate(definition["foreign_keys"]): + name = f"fk_{table}_{i}" + parts = foreign_keys[table].get(name) + if not parts: + continue + compare( + f"{table}.{name}", + ( + [p["COLUMN_NAME"] for p in parts], + parts[0]["REFERENCED_TABLE_NAME"], + [p["REFERENCED_COLUMN_NAME"] for p in parts], + parts[0]["DELETE_RULE"], + parts[0]["UPDATE_RULE"], + parts[0]["REFERENCED_TABLE_SCHEMA"], + ), + ( + wanted["columns"], + wanted["target"], + wanted["references"], + wanted["on_delete"], + wanted["on_update"], + schema, + ), + ) + + checks = defaultdict(dict) + for row in rows( + "SELECT t.TABLE_NAME,t.CONSTRAINT_NAME,c.CHECK_CLAUSE,t.ENFORCED " + "FROM information_schema.TABLE_CONSTRAINTS t JOIN information_schema.CHECK_CONSTRAINTS c " + "ON t.CONSTRAINT_SCHEMA=c.CONSTRAINT_SCHEMA AND t.CONSTRAINT_NAME=c.CONSTRAINT_NAME " + "WHERE t.TABLE_SCHEMA=DATABASE()" + ): + checks[row["TABLE_NAME"]][row["CONSTRAINT_NAME"]] = row + for table, definition in expected.items(): + wanted_names = [f"ck_{table}_{i}" for i in range(len(definition["checks"]))] + compare(f"{table}.checks", sorted(checks[table]), sorted(wanted_names)) + for i, expression in enumerate(definition["checks"]): + name = f"ck_{table}_{i}" + if name in checks[table]: + compare( + f"{table}.{name}", + check_tree(checks[table][name]["CHECK_CLAUSE"]), + check_tree(expression), + ) + compare(f"{table}.{name}.enforced", checks[table][name]["ENFORCED"], "YES") + head = connection.scalar(text("SELECT version_num FROM alembic_version")) + compare("migration_head", head, "0001_initial") + compare("session.time_zone", connection.scalar(text("SELECT @@session.time_zone")), "+00:00") + actual_modes = set(connection.scalar(text("SELECT @@session.sql_mode")).split(",")) + required_modes = set(STRICT_SQL_MODE.split(",")) + compare("session.sql_mode.missing", sorted(required_modes - actual_modes), []) + compare( + "session.foreign_key_checks", + connection.scalar(text("SELECT @@session.foreign_key_checks")), + 1, + ) + compare("session.unique_checks", connection.scalar(text("SELECT @@session.unique_checks")), 1) + return { + "mysql_version": connection.scalar(text("SELECT VERSION()")), + "schema": schema, + "migration_head": head, + "tables": len(actual_names), + "foreign_keys": sum(len(v) for v in foreign_keys.values()), + "errors": errors, + "status": "pass" if not errors else "fail", + } diff --git a/pyproject.toml b/pyproject.toml index 5245c8b..73dc80e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,11 @@ worker = [ persistence = [ "alembic==1.14.1", "sqlalchemy==2.0.37", - "pymysql==1.1.1", + + "pymysql[rsa]==1.1.1", + "argon2-cffi==23.1.0", + "tzdata==2026.4", + ] dev = [ "pytest==8.3.4", @@ -48,6 +52,9 @@ namespaces = false "packages.inference_protocol" = ["contract.json"] "apps.ai_inference" = ["fixtures/*.json"] +"packages.persistence.migrations" = ["snapshots/*.json", "snapshots/*.sql"] + + [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-ra" diff --git a/requirements/py311-business.txt b/requirements/py311-business.txt index 7200597..3b1ac09 100644 --- a/requirements/py311-business.txt +++ b/requirements/py311-business.txt @@ -4,4 +4,8 @@ celery==5.4.0 redis==5.2.1 alembic==1.14.1 sqlalchemy==2.0.37 -pymysql==1.1.1 + +pymysql[rsa]==1.1.1 +argon2-cffi==23.1.0 +tzdata==2026.4 + diff --git a/tests/persistence/__init__.py b/tests/persistence/__init__.py new file mode 100644 index 0000000..908ab9a --- /dev/null +++ b/tests/persistence/__init__.py @@ -0,0 +1 @@ +"""Persistence unit and real MySQL integration tests.""" diff --git a/tests/persistence/conftest.py b/tests/persistence/conftest.py new file mode 100644 index 0000000..19c75d1 --- /dev/null +++ b/tests/persistence/conftest.py @@ -0,0 +1,61 @@ +import json +import os + +import pytest +from alembic import command +from sqlalchemy import inspect, text + +from packages.persistence.cli import migration_config +from packages.persistence.database import database_engine +from packages.persistence.schema import verify_schema + + +@pytest.fixture(scope="session") +def database(): + if os.environ.get("LABSAFE_MYSQL_INTEGRATION") != "1": + pytest.skip("Run tools/database/run_mysql_tests.py for an isolated real MySQL database") + if os.environ.get("LABSAFE_ALLOW_DESTRUCTIVE_DOWNGRADE") != os.environ.get("DATABASE_SCHEMA"): + pytest.fail("Migration-cycle tests require explicit disposable schema confirmation") + engine = database_engine() + with engine.connect() as connection: + assert inspect(connection).get_table_names() == [], "Integration tests require an empty DB" + config = migration_config() + with engine.begin() as connection: + connection.execute(text("CREATE TABLE user_sentinel (id INT PRIMARY KEY)")) + connection.execute(text("INSERT INTO user_sentinel VALUES (7)")) + with pytest.raises(RuntimeError, match="empty disposable"): + command.upgrade(config, "head") + with engine.begin() as connection: + assert connection.scalar(text("SELECT id FROM user_sentinel")) == 7 + connection.execute(text("DROP TABLE user_sentinel")) + command.upgrade(config, "head") + with engine.connect() as connection: + report = verify_schema(connection) + print(json.dumps(report, indent=2)) + assert report["errors"] == [] + confirmation = os.environ.pop("LABSAFE_ALLOW_DESTRUCTIVE_DOWNGRADE") + try: + with pytest.raises(RuntimeError, match="Destructive downgrade"): + command.downgrade(config, "base") + finally: + os.environ["LABSAFE_ALLOW_DESTRUCTIVE_DOWNGRADE"] = confirmation + with engine.connect() as connection: + assert verify_schema(connection)["errors"] == [] + command.downgrade(config, "base") + with engine.connect() as connection: + assert inspect(connection).get_table_names() == ["alembic_version"] + command.upgrade(config, "head") + with engine.connect() as connection: + assert verify_schema(connection)["errors"] == [] + yield engine + engine.dispose() + + +@pytest.fixture +def transaction(database): + with database.connect() as connection: + transaction = connection.begin() + try: + yield connection + finally: + transaction.rollback() diff --git a/tests/persistence/factories.py b/tests/persistence/factories.py new file mode 100644 index 0000000..a52f233 --- /dev/null +++ b/tests/persistence/factories.py @@ -0,0 +1,119 @@ +"""Synthetic DDL-only fixtures; not application repositories or production seed data.""" + +from datetime import datetime +from uuid import uuid4 + +from sqlalchemy import text + +from packages.persistence.schema import model + +TABLES = model()["tables"] + + +def insert(connection, table, **values): + definition = TABLES[table] + for name, column in definition["columns"].items(): + if ( + name in values + or column["nullable"] + or column["default_sql"] is not None + or column["generated_sql"] + ): + continue + kind = column["type"] + if kind == "CHAR(36)": + values[name] = str(uuid4()) + elif kind == "CHAR(64)": + values[name] = uuid4().hex * 2 + elif kind.startswith("VARCHAR"): + values[name] = uuid4().hex + elif kind == "JSON": + values[name] = "{}" + elif kind.startswith("DATETIME"): + values[name] = datetime(2026, 9, 29) + elif kind == "DATE": + values[name] = datetime(2026, 9, 29).date() + elif "INT" in kind: + values[name] = 1 + else: + raise AssertionError(f"No fixture value for {table}.{name}") + assert set(values) <= definition["columns"].keys() + quote = chr(96) + fields = ",".join(quote + name + quote for name in values) + parameters = ",".join(":" + name for name in values) + connection.execute( + text(f"INSERT INTO {quote}{table}{quote} ({fields}) VALUES ({parameters})"), values + ) + return values["id"] + + +class Graph: + def __init__(self, connection): + self.connection = connection + self.tenant = insert(connection, "tenants", timezone="UTC") + self.user = self.add("users") + self.college = self.add("colleges") + self.lab = self.add("laboratories", college_id=self.college) + self.location = self.add("locations", laboratory_id=self.lab) + self.template = self.add("inspection_templates") + self.inspection = self.add( + "inspections", laboratory_id=self.lab, template_id=self.template, inspector_id=self.user + ) + self.dictionary = self.add("dictionary_versions") + self.model = self.add("model_versions", dictionary_version_id=self.dictionary) + self.rules = self.add("rule_bundles") + self.item, self.run, self.fact, self.evaluation = self.item_chain() + self.other_item, self.other_run, self.other_fact, self.other_evaluation = self.item_chain() + self.finding = self.add( + "findings", + item_id=self.item, + run_id=self.run, + fact_revision_id=self.fact, + rule_evaluation_id=self.evaluation, + ) + self.task = self.add( + "remediation_tasks", + finding_id=self.finding, + laboratory_id=self.lab, + assignee_id=self.user, + ) + self.upload = self.add( + "uploads", laboratory_id=self.lab, inspection_item_id=self.item, requested_by=self.user + ) + self.image = self.add( + "asset_images", + laboratory_id=self.lab, + inspection_item_id=self.item, + upload_id=self.upload, + ) + self.add("run_images", item_id=self.item, run_id=self.run, image_id=self.image) + + def add(self, table, **values): + return insert(self.connection, table, tenant_id=self.tenant, **values) + + def item_chain(self): + template_item = self.add("template_items", template_id=self.template) + item = self.add( + "inspection_items", + inspection_id=self.inspection, + laboratory_id=self.lab, + template_item_id=template_item, + location_id=self.location, + ) + run = self.add( + "inference_runs", + item_id=item, + laboratory_id=self.lab, + model_bundle_id=self.model, + dictionary_version_id=self.dictionary, + rule_bundle_id=self.rules, + ) + fact = self.add("fact_revisions", item_id=item, run_id=run) + evaluation = self.add( + "rule_evaluations", + item_id=item, + run_id=run, + fact_revision_id=fact, + rule_bundle_id=self.rules, + ) + return item, run, fact, evaluation diff --git a/tests/persistence/test_bootstrap.py b/tests/persistence/test_bootstrap.py new file mode 100644 index 0000000..5f6e8fa --- /dev/null +++ b/tests/persistence/test_bootstrap.py @@ -0,0 +1,149 @@ +import json +from concurrent.futures import ThreadPoolExecutor +from uuid import uuid4 + +import pytest +from sqlalchemy import event, text + +from packages.persistence.bootstrap import PASSWORD_HASHER, ROLES, TenantInput, bootstrap_tenant +from packages.persistence.cli import main +from packages.persistence.database import DatabaseSafetyError + +PASSWORD = " Synthetic-Only-Password-中文 " + + +def inputs(): + return TenantInput( + "test_" + uuid4().hex, + "合成测试机构", + "Asia/Shanghai", + " ADMIN ", + "初始管理员", + "I-01B synthetic verification", + ) + + +def test_bootstrap_hash_roles_and_audit_atomic(database): + data = inputs() + result = bootstrap_tenant(database, data, PASSWORD) + with database.connect() as connection: + user = ( + connection.execute(text("SELECT * FROM users WHERE id=:id"), {"id": result["admin_id"]}) + .mappings() + .one() + ) + assert user["username"] == "admin" + assert PASSWORD_HASHER.verify(user["password_hash"], PASSWORD) + assert not PASSWORD_HASHER.check_needs_rehash(user["password_hash"]) + assert set(connection.execute(text("SELECT code FROM roles")).scalars()) == set(ROLES) + grants = connection.execute( + text("SELECT scope_kind,laboratory_id FROM user_roles WHERE user_id=:id"), + {"id": result["admin_id"]}, + ).all() + assert grants == [("tenant", None)] + audit = connection.execute( + text("SELECT changes FROM audit_events WHERE tenant_id=:id"), + {"id": result["tenant_id"]}, + ).scalar_one() + assert json.loads(audit)["initial_admin_id"] == result["admin_id"] + assert "password" not in audit.lower() and PASSWORD not in audit + with pytest.raises(DatabaseSafetyError, match="already exists"): + bootstrap_tenant(database, data, "Another-Password-Do-Not-Reset") + with database.connect() as connection: + unchanged = connection.execute( + text("SELECT password_hash FROM users WHERE id=:id"), {"id": result["admin_id"]} + ).scalar_one() + assert unchanged == user["password_hash"] + + +def test_bootstrap_audit_failure_rolls_back_every_write(database): + data = inputs() + tables = ("tenants", "users", "roles", "user_roles", "audit_events") + with database.connect() as connection: + before = { + table: connection.scalar(text(f"SELECT COUNT(*) FROM {table}")) for table in tables + } + + def fail_audit(_connection, _cursor, statement, _parameters, _context, _many): + if statement.startswith("INSERT INTO audit_events"): + raise RuntimeError("synthetic audit failure") + + event.listen(database, "before_cursor_execute", fail_audit) + try: + with pytest.raises(RuntimeError, match="synthetic audit failure"): + bootstrap_tenant(database, data, PASSWORD) + finally: + event.remove(database, "before_cursor_execute", fail_audit) + with database.connect() as connection: + after = { + table: connection.scalar(text(f"SELECT COUNT(*) FROM {table}")) for table in tables + } + assert before == after + assert not connection.scalar( + text("SELECT id FROM tenants WHERE code=:code"), {"code": data.code} + ) + + +def test_concurrent_bootstrap_creates_one_admin(database): + data = inputs() + + def attempt(_): + try: + bootstrap_tenant(database, data, PASSWORD) + return "created" + except DatabaseSafetyError as error: + assert "already exists" in str(error) + return "duplicate" + + with ThreadPoolExecutor(max_workers=2) as executor: + assert sorted(executor.map(attempt, range(2))) == ["created", "duplicate"] + with database.connect() as connection: + assert ( + connection.scalar( + text( + "SELECT COUNT(*) FROM users u JOIN tenants t " + "ON u.tenant_id=t.id WHERE t.code=:code" + ), + {"code": data.code}, + ) + == 1 + ) + + +def test_cli_password_file_and_readonly_verify(database, tmp_path, capsys): + data = inputs() + password_file = tmp_path / "one-time-password" + password_file.write_bytes(PASSWORD.encode("utf-8")) + assert ( + main( + [ + "bootstrap-tenant", + "--code", + data.code, + "--name", + data.name, + "--timezone", + data.timezone, + "--username", + data.username, + "--display-name", + data.display_name, + "--reason", + data.reason, + "--password-file", + str(password_file), + ] + ) + == 0 + ) + output = capsys.readouterr() + result = json.loads(output.out) + assert PASSWORD not in output.out + output.err + assert "password" not in output.out.lower() + with database.connect() as connection: + hashed = connection.scalar( + text("SELECT password_hash FROM users WHERE id=:id"), {"id": result["admin_id"]} + ) + assert PASSWORD_HASHER.verify(hashed, PASSWORD) + assert main(["verify"]) == 0 + assert json.loads(capsys.readouterr().out)["errors"] == [] diff --git a/tests/persistence/test_constraints.py b/tests/persistence/test_constraints.py new file mode 100644 index 0000000..c52314e --- /dev/null +++ b/tests/persistence/test_constraints.py @@ -0,0 +1,163 @@ +from uuid import uuid4 + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError + +from tests.persistence.factories import Graph, insert + + +@pytest.fixture +def graph(transaction): + return Graph(transaction) + + +def rejected(code, operation): + with pytest.raises(DBAPIError) as failure: + operation() + assert failure.value.orig.args[0] == code + + +def test_cross_tenant_foreign_key(transaction, graph): + other_tenant = insert(transaction, "tenants", timezone="UTC") + rejected( + 1452, + lambda: insert( + transaction, "laboratories", tenant_id=other_tenant, college_id=graph.college + ), + ) + + +@pytest.mark.parametrize( + "column,wrong,right", + [ + ("current_run_id", "other_run", "run"), + ("current_fact_revision_id", "other_fact", "fact"), + ("current_evaluation_id", "other_evaluation", "evaluation"), + ], +) +def test_current_pointer_must_belong_to_same_item(transaction, graph, column, wrong, right): + statement = text(f"UPDATE inspection_items SET {column}=:target WHERE id=:item") + rejected( + 1452, + lambda: transaction.execute( + statement, {"target": getattr(graph, wrong), "item": graph.item} + ), + ) + transaction.execute(statement, {"target": getattr(graph, right), "item": graph.item}) + + +def test_nullable_scope_cannot_duplicate_tenant_role(transaction, graph): + role = insert(transaction, "roles") + values = dict(user_id=graph.user, role_id=role, scope_kind="tenant") + graph.add("user_roles", **values) + rejected(1062, lambda: graph.add("user_roles", **values)) + rejected(3819, lambda: graph.add("user_roles", **values, laboratory_id=graph.lab)) + rejected( + 3819, + lambda: graph.add("user_roles", user_id=graph.user, role_id=role, scope_kind="laboratory"), + ) + graph.add( + "user_roles", + user_id=graph.user, + role_id=role, + scope_kind="laboratory", + laboratory_id=graph.lab, + ) + + +@pytest.mark.parametrize("table", ["uploads", "asset_images"]) +@pytest.mark.parametrize("both", [False, True]) +def test_exactly_one_owner_required(graph, table, both): + owners = {"inspection_item_id": graph.item, "remediation_task_id": graph.task} if both else {} + extra = {"requested_by": graph.user} if table == "uploads" else {"upload_id": graph.upload} + rejected(3819, lambda: graph.add(table, laboratory_id=graph.lab, **extra, **owners)) + + +def test_remediation_owner_is_valid(graph): + upload = graph.add( + "uploads", laboratory_id=graph.lab, remediation_task_id=graph.task, requested_by=graph.user + ) + graph.add( + "asset_images", laboratory_id=graph.lab, remediation_task_id=graph.task, upload_id=upload + ) + + +def test_multi_crop_allowed_duplicate_run_crop_rejected(graph): + first = str(uuid4()) + graph.add("image_derivatives", run_id=graph.run, image_id=graph.image, crop_id=first) + graph.add("image_derivatives", run_id=graph.run, image_id=graph.image, crop_id=str(uuid4())) + rejected( + 1062, + lambda: graph.add( + "image_derivatives", run_id=graph.run, image_id=graph.image, crop_id=first + ), + ) + rejected( + 1452, + lambda: graph.add( + "image_derivatives", run_id=graph.other_run, image_id=graph.image, crop_id=str(uuid4()) + ), + ) + + +def test_run_image_and_parent_must_belong_to_same_run_item(graph): + rejected( + 1452, + lambda: graph.add( + "run_images", item_id=graph.other_item, run_id=graph.other_run, image_id=graph.image + ), + ) + upload = graph.add( + "uploads", + laboratory_id=graph.lab, + inspection_item_id=graph.other_item, + requested_by=graph.user, + ) + image = graph.add( + "asset_images", + laboratory_id=graph.lab, + inspection_item_id=graph.other_item, + upload_id=upload, + ) + rejected( + 1452, + lambda: graph.add( + "run_images", + item_id=graph.other_item, + run_id=graph.other_run, + image_id=image, + parent_image_id=graph.image, + ), + ) + graph.add("run_images", item_id=graph.other_item, run_id=graph.other_run, image_id=image) + + +def test_remediation_evidence_pointer_must_belong_to_same_task(transaction, graph): + evidence = graph.add("remediation_evidence", task_id=graph.task, submitted_by=graph.user) + finding = graph.add( + "findings", + item_id=graph.other_item, + run_id=graph.other_run, + fact_revision_id=graph.other_fact, + rule_evaluation_id=graph.other_evaluation, + ) + task = graph.add( + "remediation_tasks", finding_id=finding, laboratory_id=graph.lab, assignee_id=graph.user + ) + statement = text("UPDATE remediation_tasks SET latest_evidence_id=:evidence WHERE id=:task") + rejected(1452, lambda: transaction.execute(statement, {"evidence": evidence, "task": task})) + transaction.execute(statement, {"evidence": evidence, "task": graph.task}) + + +def test_task_attempt_and_enum_guards(graph): + graph.add("task_runs", attempt=4) + rejected(3819, lambda: graph.add("task_runs", attempt=5)) + rejected(1265, lambda: graph.add("task_runs", state="not-a-real-state")) + + +def test_parent_delete_restricted(transaction, graph): + rejected( + 1451, + lambda: transaction.execute(text("DELETE FROM tenants WHERE id=:id"), {"id": graph.tenant}), + ) diff --git a/tests/persistence/test_schema.py b/tests/persistence/test_schema.py new file mode 100644 index 0000000..98bef03 --- /dev/null +++ b/tests/persistence/test_schema.py @@ -0,0 +1,58 @@ +import pytest + +from packages.persistence.schema import verify_schema + + +def test_full_schema(database): + with database.connect() as connection: + report = verify_schema(connection) + assert report["errors"] == [] + assert report["tables"] == 43 + assert report["foreign_keys"] == 115 + + +@pytest.mark.parametrize( + "change,restore,expected_error", + [ + ( + "ALTER TABLE tenants MODIFY name VARCHAR(199) NOT NULL", + "ALTER TABLE tenants MODIFY name VARCHAR(200) NOT NULL", + "tenants.name.type", + ), + ( + "ALTER TABLE tenants ALTER status SET DEFAULT 'suspended'", + "ALTER TABLE tenants ALTER status SET DEFAULT 'active'", + "tenants.status.default", + ), + ( + "ALTER TABLE roles DROP INDEX uq_roles_0", + "ALTER TABLE roles ADD UNIQUE KEY uq_roles_0 (code)", + "roles.index.uq_roles_0", + ), + ( + "ALTER TABLE colleges DROP FOREIGN KEY fk_colleges_0", + "ALTER TABLE colleges ADD CONSTRAINT fk_colleges_0 FOREIGN KEY (tenant_id) " + "REFERENCES tenants (id) ON DELETE RESTRICT ON UPDATE RESTRICT", + "colleges.foreign_keys", + ), + ( + "ALTER TABLE task_runs ALTER CHECK ck_task_runs_0 NOT ENFORCED", + "ALTER TABLE task_runs ALTER CHECK ck_task_runs_0 ENFORCED", + "task_runs.ck_task_runs_0.enforced", + ), + ( + "ALTER TABLE roles ADD INDEX unexpected_index (name)", + "ALTER TABLE roles DROP INDEX unexpected_index", + "roles.index.unexpected_index", + ), + ], +) +def test_checker_detects_real_schema_drift(database, change, restore, expected_error): + with database.connect() as connection: + connection.exec_driver_sql(change) + try: + report = verify_schema(connection) + assert any(expected_error in error for error in report["errors"]) + finally: + connection.exec_driver_sql(restore) + assert verify_schema(connection)["errors"] == [] diff --git a/tests/persistence/test_unit.py b/tests/persistence/test_unit.py new file mode 100644 index 0000000..be355eb --- /dev/null +++ b/tests/persistence/test_unit.py @@ -0,0 +1,117 @@ +import json +from pathlib import Path +from unittest.mock import Mock + +import pytest +from alembic import command +from argon2 import Type, extract_parameters + +from packages.persistence.bootstrap import PASSWORD_HASHER, TenantInput, bootstrap_tenant +from packages.persistence.cli import main, migration_config +from packages.persistence.database import DatabaseSafetyError, database_engine +from packages.persistence.schema import ( + MODEL_PATH, + check_tree, + column_type, + expression_tokens, + model, +) + +ROOT = Path(__file__).resolve().parents[2] + + +def test_frozen_migration_matches_authoritative_design(): + assert model() == json.loads((ROOT / "contracts/data-model.json").read_text(encoding="utf-8")) + snapshot = MODEL_PATH.with_suffix(".sql").read_text(encoding="utf-8").strip() + assert snapshot == (ROOT / "contracts/database-design.sql").read_text(encoding="utf-8").strip() + + +@pytest.mark.parametrize("environment", ["production", "staging", "", "prod"]) +def test_no_production_database_access(monkeypatch, environment): + monkeypatch.setenv("APP_ENV", environment) + with pytest.raises(RuntimeError, match="production"): + database_engine() + + +@pytest.mark.parametrize( + "url,schema", + [ + ("mysql+pymysql://u:p@localhost/mysql", "mysql"), + ("mysql+pymysql://u:p@localhost/labsafe_test_other", "labsafe_test_confirmed"), + ("sqlite:///labsafe_test_confirmed", "labsafe_test_confirmed"), + ("mysql+pymysql://u@localhost/labsafe_test_confirmed", "labsafe_test_confirmed"), + ( + "mysql+pymysql://u:p@localhost/labsafe_test_confirmed?local_infile=1", + "labsafe_test_confirmed", + ), + ("malformed-SECRET", "labsafe_test_confirmed"), + ("mysql+pymysql://u:p@remote.example/labsafe_test_confirmed", "labsafe_test_confirmed"), + ], +) +def test_url_guards_do_not_connect_or_expose_secrets(monkeypatch, tmp_path, url, schema): + secret = tmp_path / "url" + secret.write_text(url, encoding="utf-8") + monkeypatch.setenv("APP_ENV", "test") + monkeypatch.setenv("DATABASE_URL_FILE", str(secret)) + monkeypatch.setenv("DATABASE_SCHEMA", schema) + with pytest.raises(DatabaseSafetyError) as failure: + database_engine() + assert url not in str(failure.value) + + +def test_password_policy_parameters_and_random_salt(): + password = " Synthetic password 中文 " + first = PASSWORD_HASHER.hash(password) + second = PASSWORD_HASHER.hash(password) + assert first != second + parameters = extract_parameters(first) + assert ( + parameters.type, + parameters.time_cost, + parameters.memory_cost, + parameters.parallelism, + parameters.salt_len, + parameters.hash_len, + ) == (Type.ID, 3, 65536, 1, 16, 32) + assert PASSWORD_HASHER.verify(first, password) + + +@pytest.mark.parametrize("password", ["", "x" * 11, "x" * 129]) +def test_bad_password_rejected_before_connection(password): + engine = Mock() + data = TenantInput("synthetic", "Test", "UTC", "admin", "Admin", "Testing") + with pytest.raises(DatabaseSafetyError, match="Password"): + bootstrap_tenant(engine, data, password) + engine.connect.assert_not_called() + + +def test_normalization_and_timezone_validation(): + data = TenantInput("synthetic", "Test", "UTC", " ADMIN ", "Admin", "Testing") + assert data.validated().username == "admin" + with pytest.raises(DatabaseSafetyError, match="timezone"): + TenantInput("synthetic", "Test", "Not/AZone", "admin", "Admin", "Testing").validated() + + +def test_expression_normalization_preserves_logic(): + assert check_tree("(a IS NULL) <> (b IS NULL)") == check_tree("((a is null) <> (b is null))") + assert check_tree("a=1 OR b=2 AND c=3") != check_tree("(a=1 OR b=2) AND c=3") + assert expression_tokens("coalesce(a,_utf8mb4\\'x\\')") == expression_tokens("COALESCE(a,'x')") + + +def test_type_normalization_preserves_case_sensitive_enum_values(): + assert column_type("ENUM('active')") == column_type("enum('active')") + assert column_type("ENUM('ACTIVE')") != column_type("ENUM('active')") + + +def test_offline_migration_cannot_bypass_guards(): + with pytest.raises(RuntimeError, match="Offline"): + command.upgrade(migration_config(), "head", sql=True) + + +def test_cli_sanitizes_driver_failures(monkeypatch, capsys): + def fail(): + raise RuntimeError("mysql://credential-SENTINEL") + + monkeypatch.setattr("packages.persistence.cli.database_engine", fail) + assert main(["verify"]) == 1 + assert "credential-SENTINEL" not in capsys.readouterr().err diff --git a/tools/database/run_mysql_tests.py b/tools/database/run_mysql_tests.py new file mode 100644 index 0000000..4f3c8ae --- /dev/null +++ b/tools/database/run_mysql_tests.py @@ -0,0 +1,165 @@ +"""Run integration tests using a new local mysqld or a dedicated GitHub CI service. + +Never discover, stop, reconfigure or use an existing local MySQL service. +""" + +import argparse +import os +import secrets +import socket +import subprocess +import sys +import tempfile +import time +from pathlib import Path +from uuid import uuid4 + +import pymysql +from sqlalchemy.engine import URL + +ROOT = Path(__file__).resolve().parents[2] +NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0) + + +def run_suite(connection, port, password, folder): + schema = "labsafe_test_" + uuid4().hex + # Identifier is internally generated, never taken from CLI/connection strings. + with connection.cursor() as cursor: + cursor.execute(f"CREATE DATABASE {schema} CHARACTER SET utf8mb4 COLLATE utf8mb4_bin") + url = URL.create( + "mysql+pymysql", + username="root", + password=password, + host="127.0.0.1", + port=port, + database=schema, + ) + secret = folder / "database-url" + secret.write_text(url.render_as_string(hide_password=False), encoding="utf-8") + secret.chmod(0o600) + environment = os.environ.copy() + environment.update( + APP_ENV="test", + DATABASE_SCHEMA=schema, + DATABASE_URL_FILE=str(secret), + LABSAFE_MYSQL_INTEGRATION="1", + PYTHONDONTWRITEBYTECODE="1", + PYTHONUTF8="1", + LABSAFE_ALLOW_DESTRUCTIVE_DOWNGRADE=schema, + ) + try: + return subprocess.run( + [sys.executable, "-m", "pytest", "tests/persistence", "-v", "-s", "--tb=short"], + cwd=ROOT, + env=environment, + check=False, + ).returncode + finally: + with connection.cursor() as cursor: + cursor.execute(f"DROP DATABASE {schema}") + secret.unlink(missing_ok=True) + + +def local_suite(mysqld, folder): + executable = Path(mysqld).resolve(strict=True) + data = folder / "data" + data.mkdir() + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + port = listener.getsockname()[1] + subprocess.run( + [ + str(executable), + "--no-defaults", + "--initialize-insecure", + f"--datadir={data}", + "--console", + ], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=120, + creationflags=NO_WINDOW, + ) + server = subprocess.Popen( + [ + str(executable), + "--no-defaults", + f"--datadir={data}", + f"--port={port}", + "--bind-address=127.0.0.1", + "--mysqlx=0", + "--skip-log-bin", + f"--log-error={folder / 'server.log'}", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=NO_WINDOW, + ) + connection = None + verified_own_server = False + try: + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + if server.poll() is not None: + raise RuntimeError("Disposable mysqld exited during startup") + try: + connection = pymysql.connect( + host="127.0.0.1", port=port, user="root", connect_timeout=2, autocommit=True + ) + break + except pymysql.OperationalError: + time.sleep(0.2) + if connection is None: + raise RuntimeError("Disposable mysqld did not start within 60 seconds") + with connection.cursor() as cursor: + cursor.execute("SELECT @@datadir,@@port,VERSION()") + actual_data, actual_port, version = cursor.fetchone() + if Path(actual_data).resolve() != data.resolve() or actual_port != port: + raise RuntimeError("Refusing connection: not the server created by this process") + verified_own_server = True + print( + f"Isolated MySQL {version}; random loopback port; new temporary data directory", + flush=True, + ) + password = secrets.token_urlsafe(32) + cursor.execute("ALTER USER 'root'@'localhost' IDENTIFIED BY %s", (password,)) + return run_suite(connection, port, password, folder) + finally: + if connection is not None: + if verified_own_server: + try: + with connection.cursor() as cursor: + cursor.execute("SHUTDOWN") + except pymysql.Error: + pass + connection.close() + try: + server.wait(timeout=10) + except subprocess.TimeoutExpired: + server.terminate() # Only the child process created above, never a service/PID search. + server.wait(timeout=10) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--mysqld", help="Path to installed MySQL server executable, not a data dir") + mode.add_argument("--ci", action="store_true", help="Use this job's disposable CI service") + arguments = parser.parse_args() + with tempfile.TemporaryDirectory(prefix="labsafe-i01b-") as directory: + folder = Path(directory) + if arguments.mysqld: + return local_suite(arguments.mysqld, folder) + if os.environ.get("CI") != "true": + raise RuntimeError("--ci is restricted to a disposable CI job service") + port = int(os.environ["LABSAFE_CI_MYSQL_PORT"]) + password = os.environ["LABSAFE_CI_MYSQL_PASSWORD"] + with pymysql.connect( + host="127.0.0.1", port=port, user="root", password=password, autocommit=True + ) as connection: + return run_suite(connection, port, password, folder) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/design/data_model.py b/tools/design/data_model.py index 2530399..9a13e94 100644 --- a/tools/design/data_model.py +++ b/tools/design/data_model.py @@ -75,7 +75,9 @@ def build_database(): lines=[] for col,v in t['columns'].items(): z=q(col)+' '+v['type'] - if v['generated_sql']:z+=' GENERATED ALWAYS AS ('+v['generated_sql']+') STORED' + if v['generated_sql']: + z+=' GENERATED ALWAYS AS ('+v['generated_sql']+') STORED' + z+=' NULL' if v['nullable'] else ' NOT NULL' else: z+=' NULL' if v['nullable'] else ' NOT NULL' if v['default_sql'] is not None:z+=' DEFAULT '+v['default_sql']