deliver: 交付收口(引擎代为提交)
This commit is contained in:
parent
882a1b5cd3
commit
40672e1115
@ -1,90 +1,23 @@
|
||||
{
|
||||
"summary": [
|
||||
{
|
||||
"name": "pbl_entity_state",
|
||||
"title": "实体当前状态(乐观锁版本表)",
|
||||
"primary": [
|
||||
"id"
|
||||
],
|
||||
"catelog": "relation",
|
||||
"comment": "PBL 实体当前状态表(world_sync 侧,M11b-2 新增)。归属模块 module=world_sync、owner_module=world_sync、里程碑 milestone=M11b-2b;表属性 tenant_scoped=true(租户隔离)。保存世界内实体的最新完整状态快照,state_version 为乐观锁版本号(每次成功写入 +1),用于防止并发覆盖(丢失更新)。【唯一真源】本文件是 pbl_entity_state 表定义的唯一权威文本,apps/scense/pkgs/world_sync/models/pbl_entity_state.json 只是由 scripts/sync_models_to_app.py(或 cp)从本文件同步出的打包镜像,禁止手工双写,二者 sha256 必须相同(由 scripts/m11b2_selftest.py 机械断言)。【主键与唯一性】规范主键 id 为 str(32)(应用层生成 uuid4().hex);业务唯一性由 (tenant_id, world_id, entity_id) 复合唯一索引保证,天然实现租户/世界隔离——同一实体在跨租户下互不干扰。【事务约束】本表的 UPDATE/INSERT 必须与 pbl_runtime_event 的 INSERT 处于同一事务内(world_sync.pbl_runtime_tx.write_event_with_state),任一失败整事务回滚;禁止脱离事务单独更新本表。【并发控制】UPDATE 必须带 state_version = <读到的当前版本> 条件(乐观锁),rowcount != 1 视为并发冲突并回滚;MySQL/PG 读取当前版本时追加 FOR UPDATE 悲观行锁,sqlite 由 BEGIN IMMEDIATE 保证单写者。【列真源】fields 与 world_sync/pbl_runtime_sql.py 的 STATE_COLUMNS 及 pbl_runtime_tx.py 中 SELECT/UPDATE/INSERT 实际使用的列名逐一对应(交叉核对:tenant_id/world_id/entity_id/state/state_version/updated_at/updated_by_event 全部在册),另按规范补 id 主键。【索引说明】uk_es_tenant_world_entity=业务唯一键,一个租户一个世界内一个实体只有一行当前状态,并发首次插入靠它触发冲突回滚;ix_es_tenant_world_updated=按世界扫描最近变更实体(对账/清理用)。【与 pbl_runtime_ext 的差异】modules/pbl_runtime_ext/models/pbl_entity_state.json(M11a 侧)使用 state_json/checksum/updated_by 等不同列集与 int 自增 id,属另一张同名表的另一套定义,两者不可混用;本文件为 world_sync(M11b-2)写入路径的权威定义,双真源合并方案已按 team-communication 规范向 agent.pm 冒泡请求裁决(见交付摘要冒泡单据)。【方言说明】物理类型由 sqlor DDL 模板按抽象类型派生(str→VARCHAR、int→INT、text→TEXT、datetime→DATETIME),本文件不出现 VARCHAR/BIGINT/DATETIME(3)/JSON 等方言具体类型。【键白名单】summary 仅含 name/title/primary/catelog/comment;fields 仅含 name/title/type/length/dec/nullable/default(说明文字一律写进 title,DDL 模板按 title 渲染 COMMENT);indexes 仅含 name/idxtype/idxfields;根键仅 summary/fields/indexes/codes 四段式。"
|
||||
"key": "schema",
|
||||
"schema": {
|
||||
"table": "pbl_entity_state",
|
||||
"authority_module": "pbl_runtime_ext",
|
||||
"authority_path": "modules/pbl_runtime_ext/models/pbl_entity_state.json",
|
||||
"owner": "pbls",
|
||||
"milestone": "M11b-2b",
|
||||
"decision_ref": "PM 裁决(任务 u0EO4x3ajntR41acaHj5H 问答):选 B —— 以 pbl_runtime_ext 为唯一真源",
|
||||
"decision_basis": [
|
||||
"设计文档 docs/01-design/modules/entity.md 明确 pbl_entity_state 为 pbl_runtime_ext 侧、pbls 所有",
|
||||
"world_sync 仅作为单事务写入的消费方,不拥有该表结构定义权",
|
||||
"本任务不得反向修改 M11b-1 已批准产物"
|
||||
],
|
||||
"structure_note": "本文件不再自带 summary/fields/indexes/codes 结构体;列集、主键、索引一律以 authority_path 指向的 pbl_runtime_ext 定义为准。world_sync 侧消费方代码(pbl_runtime_sql.py / pbl_runtime_tx.py::write_event_with_state / pbl_runtime_schema.py)以该权威结构为准,不再依赖旧的 8 列本地结构。",
|
||||
"consumers": [
|
||||
"modules/world_sync/world_sync/pbl_runtime_sql.py",
|
||||
"modules/world_sync/world_sync/pbl_runtime_tx.py::write_event_with_state",
|
||||
"modules/world_sync/world_sync/pbl_runtime_schema.py"
|
||||
],
|
||||
"mirror_note": "apps/scense/pkgs/world_sync/models/pbl_entity_state.json 是本文件的打包镜像,只由 modules/world_sync/scripts/sync_models_mirror.py 生成,禁止手工双写;一致性由 modules/world_sync/scripts/m11b2b_selftest.py 的 sha256 断言守护。"
|
||||
}
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"name": "id",
|
||||
"title": "主键ID(str32,应用层生成uuid4().hex,首次INSERT状态行时创建)",
|
||||
"type": "str",
|
||||
"length": 32,
|
||||
"nullable": "no"
|
||||
},
|
||||
{
|
||||
"name": "tenant_id",
|
||||
"title": "租户ID(隔离维度,所有读写WHERE打头列)",
|
||||
"type": "str",
|
||||
"length": 64,
|
||||
"nullable": "no"
|
||||
},
|
||||
{
|
||||
"name": "world_id",
|
||||
"title": "世界ID(隔离维度,与tenant_id共同限定作用域)",
|
||||
"type": "str",
|
||||
"length": 64,
|
||||
"nullable": "no"
|
||||
},
|
||||
{
|
||||
"name": "entity_id",
|
||||
"title": "实体ID(tenant_id+world_id+entity_id定位唯一状态行)",
|
||||
"type": "str",
|
||||
"length": 64,
|
||||
"nullable": "no"
|
||||
},
|
||||
{
|
||||
"name": "state",
|
||||
"title": "实体当前完整状态(JSON文本,规范抽象类型text,不用方言JSON类型)",
|
||||
"type": "text",
|
||||
"nullable": "no"
|
||||
},
|
||||
{
|
||||
"name": "state_version",
|
||||
"title": "状态版本号(乐观锁,每次成功写入+1,与pbl_runtime_event.state_version一致)",
|
||||
"type": "int",
|
||||
"nullable": "no",
|
||||
"default": "0"
|
||||
},
|
||||
{
|
||||
"name": "updated_at",
|
||||
"title": "最近生效时间(UTC时间戳,应用层写入ISO8601文本)",
|
||||
"type": "datetime",
|
||||
"nullable": "no"
|
||||
},
|
||||
{
|
||||
"name": "updated_by_event",
|
||||
"title": "最近生效事件ID(回指pbl_runtime_event.event_id业务键)",
|
||||
"type": "str",
|
||||
"length": 64,
|
||||
"nullable": "yes"
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"name": "uk_es_tenant_world_entity",
|
||||
"idxtype": "unique",
|
||||
"idxfields": [
|
||||
"tenant_id",
|
||||
"world_id",
|
||||
"entity_id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ix_es_tenant_world_updated",
|
||||
"idxtype": "index",
|
||||
"idxfields": [
|
||||
"tenant_id",
|
||||
"world_id",
|
||||
"updated_at"
|
||||
]
|
||||
}
|
||||
],
|
||||
"codes": []
|
||||
}
|
||||
|
||||
@ -1,154 +1,24 @@
|
||||
{
|
||||
"summary": [
|
||||
{
|
||||
"name": "pbl_runtime_event",
|
||||
"title": "运行时事件流(append-only)",
|
||||
"primary": [
|
||||
"id"
|
||||
],
|
||||
"catelog": "relation",
|
||||
"comment": "PBL 运行时事件表(world_sync 侧写入视图)。归属模块 module=world_sync、owner_module=world_sync、里程碑 milestone=M11b-2b;表属性 append_only=true(只允许 INSERT,禁止 UPDATE/DELETE,每条事件代表一次运行时状态变更,供回放与审计)、tenant_scoped=true(租户隔离)。【唯一真源】本文件是 pbl_runtime_event 表定义的唯一权威文本,apps/scense/pkgs/world_sync/models/pbl_runtime_event.json 只是由 scripts/sync_models_to_app.py(或 cp)从本文件同步出的打包镜像,禁止手工双写,二者 sha256 必须相同(由 scripts/m11b2_selftest.py 机械断言)。【写入约束】append-only:只允许 INSERT,禁止 UPDATE/DELETE。【事务约束】本表的 INSERT 必须与 pbl_entity_state 的更新处于同一数据库事务内(见 world_sync.pbl_runtime_tx.write_event_with_state),任一失败整事务回滚,不允许脱离事务单独写事件。【隔离约束】所有读写 WHERE / 唯一键必须以 tenant_id 打头,world_id 为第二隔离维度;跨租户同名 entity_id 不得互相影响。【ID 约定】主键 id 为 str(32),由应用层生成(uuid4().hex,即 uuid 去横线 32 位十六进制),不使用数据库自增,便于幂等重试与因果链引用;event_id 保留为业务事件键(同样为 uuid hex 文本),由 write_event_with_state 写入并作为 (tenant_id, event_id) 唯一键,用于幂等去重与 pbl_entity_state.updated_by_event 回指。【列真源】fields 与 world_sync/pbl_runtime_sql.py 的 EVENT_COLUMNS 及 pbl_runtime_tx.py 实际 INSERT 列名逐一对应(交叉核对:event_id/tenant_id/world_id/session_id/entity_id/event_type/payload/causation_id/source/state_version/created_at 全部在册),另按规范补 id 主键。【索引说明】uk_re_tenant_event_id=业务事件键租户内唯一,幂等去重(重复写入直接报错并回滚整事务);uk_re_tenant_world_entity_created=同一实体在同一租户/世界下状态版本号唯一,防止并发推进出版本丢失(与乐观锁 state_version 配合);ix_re_tenant_world_entity=按实体取事件序列(回放/审计);ix_re_tenant_world_type_created=按租户+世界+事件类型增量拉取;ix_re_created_at=时间轴扫描与归档清理。【与 M11b-1 的关系】modules/pbl_runtime_ext/models/pbl_runtime_event.json 是 M11b-1(pbl_runtime_ext)侧的同一表名定义,其真源为 scripts/pbl_runtime_event_ddl.py 的 COLUMNS/PRIMARY_KEY/UNIQUE_KEYS(25 列、复合主键 (id, created_at)、id 为 long 自增、按 created_at 做按月 RANGE COLUMNS 分区、保留 14 个月)。两者同名不同构,属双真源风险,已按 team-communication 规范向 agent.pm 冒泡请求裁决(见交付摘要冒泡单据),本任务不擅自改动 M11b-1 已批准的 json 与其 DDL 生成器。【方言说明】物理类型由 sqlor DDL 模板按抽象类型派生(str→VARCHAR、int→INT、text→TEXT/LONGTEXT、datetime→DATETIME),本文件不出现任何数据库方言具体类型。【键白名单】summary 仅含 name/title/primary/catelog/comment;fields 仅含 name/title/type/length/dec/nullable/default(说明文字一律写进 title,DDL 模板按 title 渲染 COMMENT);indexes 仅含 name/idxtype/idxfields;根键仅 summary/fields/indexes/codes 四段式。"
|
||||
"key": "schema",
|
||||
"schema": {
|
||||
"table": "pbl_runtime_event",
|
||||
"authority_module": "pbl_runtime_ext",
|
||||
"authority_path": "modules/pbl_runtime_ext/models/pbl_runtime_event.json",
|
||||
"owner": "pbls",
|
||||
"milestone": "M11b-2b",
|
||||
"decision_ref": "PM 裁决(任务 u0EO4x3ajntR41acaHj5H 问答):选 B —— 以 pbl_runtime_ext 为唯一真源",
|
||||
"decision_basis": [
|
||||
"任务书原文:在 M11b-1 产出的 pbl_runtime_event 表基础上(依赖 key=schema),引用语义自任务定义起即为 B",
|
||||
"设计文档 docs/01-design/modules/entity.md 明确该表为 pbl_runtime_ext 侧、pbls 所有",
|
||||
"pbl_runtime_ext 侧 pbl_runtime_event 的 DDL + 3 个迁移文件 + dev-notes 均已 approved,为既定权威结构",
|
||||
"本任务不得反向修改 M11b-1 已批准产物"
|
||||
],
|
||||
"structure_note": "本文件不再自带 summary/fields/indexes/codes 结构体;列集、主键、索引、分区、append-only 约束一律以 authority_path 指向的 pbl_runtime_ext 定义为准(25 列 / id long 自增 + (id, created_at) 复合主键 / 按月 RANGE COLUMNS 分区 / append-only 触发器)。world_sync 侧消费方代码(pbl_runtime_sql.py / pbl_runtime_tx.py::write_event_with_state / pbl_runtime_schema.py)以该 25 列权威结构为准,不再依赖旧的 12 列本地结构。",
|
||||
"consumers": [
|
||||
"modules/world_sync/world_sync/pbl_runtime_sql.py",
|
||||
"modules/world_sync/world_sync/pbl_runtime_tx.py::write_event_with_state",
|
||||
"modules/world_sync/world_sync/pbl_runtime_schema.py"
|
||||
],
|
||||
"mirror_note": "apps/scense/pkgs/world_sync/models/pbl_runtime_event.json 是本文件的打包镜像,只由 modules/world_sync/scripts/sync_models_mirror.py 生成,禁止手工双写;一致性由 modules/world_sync/scripts/m11b2b_selftest.py 的 sha256 断言守护。"
|
||||
}
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"name": "id",
|
||||
"title": "主键ID(str32,应用层生成uuid4().hex,非数据库自增)",
|
||||
"type": "str",
|
||||
"length": 32,
|
||||
"nullable": "no"
|
||||
},
|
||||
{
|
||||
"name": "event_id",
|
||||
"title": "业务事件ID(uuid hex文本,幂等去重键,被pbl_entity_state.updated_by_event回指)",
|
||||
"type": "str",
|
||||
"length": 64,
|
||||
"nullable": "no"
|
||||
},
|
||||
{
|
||||
"name": "tenant_id",
|
||||
"title": "租户ID(隔离维度,强制打头,缺失即拒绝写入)",
|
||||
"type": "str",
|
||||
"length": 64,
|
||||
"nullable": "no"
|
||||
},
|
||||
{
|
||||
"name": "world_id",
|
||||
"title": "世界ID(隔离维度,强制)",
|
||||
"type": "str",
|
||||
"length": 64,
|
||||
"nullable": "no"
|
||||
},
|
||||
{
|
||||
"name": "session_id",
|
||||
"title": "运行时会话ID(可空,无会话上下文时为空)",
|
||||
"type": "str",
|
||||
"length": 64,
|
||||
"nullable": "yes"
|
||||
},
|
||||
{
|
||||
"name": "entity_id",
|
||||
"title": "实体ID(事件关联的实体)",
|
||||
"type": "str",
|
||||
"length": 64,
|
||||
"nullable": "no"
|
||||
},
|
||||
{
|
||||
"name": "event_type",
|
||||
"title": "事件类型(如state.updated,默认值同pbl_runtime_tx.DEFAULT_EVENT_TYPE)",
|
||||
"type": "str",
|
||||
"length": 64,
|
||||
"nullable": "no",
|
||||
"default": "state.updated"
|
||||
},
|
||||
{
|
||||
"name": "payload",
|
||||
"title": "事件体(JSON文本,规范抽象类型text,不用方言JSON类型)",
|
||||
"type": "text",
|
||||
"nullable": "yes"
|
||||
},
|
||||
{
|
||||
"name": "causation_id",
|
||||
"title": "因果上游事件ID(可空)",
|
||||
"type": "str",
|
||||
"length": 64,
|
||||
"nullable": "yes"
|
||||
},
|
||||
{
|
||||
"name": "source",
|
||||
"title": "写入来源(模块名/引擎版本,默认值同pbl_runtime_tx.DEFAULT_SOURCE)",
|
||||
"type": "str",
|
||||
"length": 64,
|
||||
"nullable": "no",
|
||||
"default": "world_sync.m11b2"
|
||||
},
|
||||
{
|
||||
"name": "state_version",
|
||||
"title": "状态版本(本事件落库后实体状态版本,与pbl_entity_state.state_version一致,便于回放对账)",
|
||||
"type": "int",
|
||||
"nullable": "no",
|
||||
"default": "0"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"title": "创建时间(服务端UTC时间戳,应用层写入ISO8601文本;append-only表不设updated_at)",
|
||||
"type": "datetime",
|
||||
"nullable": "no"
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"name": "uk_re_tenant_event_id",
|
||||
"idxtype": "unique",
|
||||
"idxfields": [
|
||||
"tenant_id",
|
||||
"event_id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "uk_re_tenant_world_entity_created",
|
||||
"idxtype": "unique",
|
||||
"idxfields": [
|
||||
"tenant_id",
|
||||
"world_id",
|
||||
"entity_id",
|
||||
"state_version"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ix_re_tenant_world_entity",
|
||||
"idxtype": "index",
|
||||
"idxfields": [
|
||||
"tenant_id",
|
||||
"world_id",
|
||||
"entity_id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ix_re_tenant_world_type_created",
|
||||
"idxtype": "index",
|
||||
"idxfields": [
|
||||
"tenant_id",
|
||||
"world_id",
|
||||
"event_type",
|
||||
"created_at"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ix_re_created_at",
|
||||
"idxtype": "index",
|
||||
"idxfields": [
|
||||
"created_at"
|
||||
]
|
||||
}
|
||||
],
|
||||
"codes": [
|
||||
{
|
||||
"field": "event_type",
|
||||
"table": "appcodes_kv",
|
||||
"valuefield": "k",
|
||||
"textfield": "v",
|
||||
"cond": "parentid='pbl_event_type'"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
181
scripts/m11b2b_column_diff.py
Normal file
181
scripts/m11b2b_column_diff.py
Normal file
@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""M11b-2b 双向列集 diff:world_sync 消费方代码引用的列 vs pbl_runtime_ext 权威列集。
|
||||
|
||||
PM 裁决 B 的执行要求:「引用后跑双向列集 diff 留证(world_sync 消费方代码若依赖旧结构,
|
||||
适配到权威结构)」。
|
||||
|
||||
为避免把 Python 标识符误当列名,只从代码中的 SQL 字符串字面量里提取列名:
|
||||
方向 1(代码 -> 权威):SQL 字面量中出现的列名必须全部存在于权威列集(缺则 FAIL);
|
||||
方向 2(权威 -> 代码):权威列集中未被任何 SQL 引用的列,打印为 informational
|
||||
(由自增/默认值/写入方填充,不判失败)。
|
||||
|
||||
用法:python3 modules/world_sync/scripts/m11b2b_column_diff.py
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tokenize
|
||||
|
||||
CONSUMERS = [
|
||||
"modules/world_sync/world_sync/pbl_runtime_sql.py",
|
||||
"modules/world_sync/world_sync/pbl_runtime_tx.py",
|
||||
"modules/world_sync/world_sync/pbl_runtime_schema.py",
|
||||
]
|
||||
|
||||
AUTHORITY = {
|
||||
"pbl_runtime_event": "modules/pbl_runtime_ext/models/pbl_runtime_event.json",
|
||||
"pbl_entity_state": "modules/pbl_runtime_ext/models/pbl_entity_state.json",
|
||||
}
|
||||
|
||||
SQL_HINT = re.compile(r"\b(select|insert\s+into|update|delete\s+from|on\s+duplicate)\b", re.IGNORECASE)
|
||||
|
||||
# SQL 语法词/函数词,避免误判为列名
|
||||
STOPWORDS = {
|
||||
"select", "from", "where", "insert", "into", "values", "update", "set", "delete",
|
||||
"and", "or", "not", "null", "is", "in", "on", "as", "by", "order", "group", "limit",
|
||||
"join", "left", "right", "inner", "outer", "distinct", "count", "max", "min", "sum",
|
||||
"now", "current_timestamp", "true", "false", "begin", "commit", "rollback", "with",
|
||||
"returning", "if", "exists", "duplicate", "key", "auto_increment", "using",
|
||||
}
|
||||
|
||||
TABLE_WORDS = {"pbl_runtime_event", "pbl_entity_state"}
|
||||
|
||||
|
||||
def find_root():
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
cur = here
|
||||
for _ in range(10):
|
||||
if os.path.isdir(os.path.join(cur, "modules")) and os.path.isdir(os.path.join(cur, "apps")):
|
||||
return cur
|
||||
parent = os.path.dirname(cur)
|
||||
if parent == cur:
|
||||
break
|
||||
cur = parent
|
||||
return os.path.abspath(os.path.join(here, os.pardir, os.pardir, os.pardir))
|
||||
|
||||
|
||||
def authority_columns(root, table):
|
||||
with open(os.path.join(root, AUTHORITY[table]), "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
return [f.get("name") for f in (data.get("fields") or []) if isinstance(f, dict) and f.get("name")]
|
||||
|
||||
|
||||
def sql_literals(path):
|
||||
"""用 tokenize 取出文件中的字符串字面量,仅保留含 SQL 关键字者。"""
|
||||
with open(path, "rb") as fh:
|
||||
raw = fh.read()
|
||||
out = []
|
||||
try:
|
||||
for tok in tokenize.tokenize(io.BytesIO(raw).readline):
|
||||
if tok.type != tokenize.STRING:
|
||||
continue
|
||||
try:
|
||||
val = ast_literal_eval(tok.string)
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if isinstance(val, str) and SQL_HINT.search(val):
|
||||
out.append(val)
|
||||
except (tokenize.TokenError, IndentationError, SyntaxError):
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def ast_literal_eval(src):
|
||||
"""轻量解析字符串字面量(含 f-string 前缀),失败返回 None。"""
|
||||
s = src
|
||||
# 去掉 f/r/b 等前缀
|
||||
m = re.match(r"^([a-zA-Z]*)", s)
|
||||
prefix = m.group(1).lower() if m else ""
|
||||
body = s[len(prefix):]
|
||||
if "f" in prefix: # f-string:把 {expr} 替换为占位,仅取字面部分
|
||||
body = re.sub(r"\{[^{}]*\}", " ", body)
|
||||
if "b" in prefix:
|
||||
body = body.encode("latin-1", "ignore").decode("unicode_escape")
|
||||
try:
|
||||
import ast as _ast
|
||||
return _ast.literal_eval(body) if body[:1] in "\"'" else body
|
||||
except Exception: # noqa: BLE001
|
||||
return body
|
||||
try:
|
||||
import ast as _ast
|
||||
return _ast.literal_eval(body)
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def columns_from_sql(sql):
|
||||
cols = set()
|
||||
cols.update(re.findall(r"`([a-zA-Z_][a-zA-Z_0-9]*)`", sql))
|
||||
for grp in re.findall(r"INSERT\s+INTO\s+[\w`.]+\s*\(([^)]*)\)", sql, re.IGNORECASE):
|
||||
for tok in grp.split(","):
|
||||
tok = tok.strip().strip("`")
|
||||
if re.fullmatch(r"[a-zA-Z_][a-zA-Z_0-9]*", tok or ""):
|
||||
cols.add(tok)
|
||||
for grp in re.findall(r"\bSET\s+(.+?)(?:\bWHERE\b|$)", sql, re.IGNORECASE | re.DOTALL):
|
||||
cols.update(re.findall(r"([a-zA-Z_][a-zA-Z_0-9]*)\s*=", grp))
|
||||
for grp in re.findall(r"\bWHERE\s+(.+?)$", sql, re.IGNORECASE | re.DOTALL):
|
||||
for tok in re.findall(r"([a-zA-Z_][a-zA-Z_0-9]*)\s*(?:=|<|>|!|in\b|is\b)", grp, re.IGNORECASE):
|
||||
cols.add(tok)
|
||||
for grp in re.findall(r"\b(?:ORDER\s+BY|GROUP\s+BY)\s+([\w`,\s.]+)", sql, re.IGNORECASE):
|
||||
for tok in grp.split(","):
|
||||
tok = tok.strip().strip("`").split()[-1] if tok.strip() else ""
|
||||
if re.fullmatch(r"[a-zA-Z_][a-zA-Z_0-9]*", tok or ""):
|
||||
cols.add(tok)
|
||||
for tok in re.findall(r"\b(?:pbl_runtime_event|pbl_entity_state)\.([a-zA-Z_][a-zA-Z_0-9]*)", sql):
|
||||
cols.add(tok)
|
||||
return {c for c in cols if c.lower() not in STOPWORDS and c not in TABLE_WORDS}
|
||||
|
||||
|
||||
def main():
|
||||
root = find_root()
|
||||
print("workspace root: %s" % root)
|
||||
|
||||
sql_total = []
|
||||
ident = set()
|
||||
scanned = []
|
||||
for rel in CONSUMERS:
|
||||
path = os.path.join(root, rel)
|
||||
if not os.path.isfile(path):
|
||||
print(" SKIP (absent): %s" % rel)
|
||||
continue
|
||||
scanned.append(rel)
|
||||
lits = sql_literals(path)
|
||||
sql_total.extend(lits)
|
||||
for s in lits:
|
||||
ident |= columns_from_sql(s)
|
||||
print("consumer files scanned: %d -> %s" % (len(scanned), scanned))
|
||||
print("SQL literals found: %d, distinct column identifiers: %d" % (len(sql_total), len(ident)))
|
||||
|
||||
auth_cols = {}
|
||||
for table in AUTHORITY:
|
||||
auth_cols[table] = authority_columns(root, table)
|
||||
print("\nauthority columns [%s] (%d cols): %s" % (table, len(auth_cols[table]), auth_cols[table]))
|
||||
|
||||
print("\n--- 方向 1: 代码 SQL 引用列 ⊆ 权威列集(必须全部成立)---")
|
||||
bad = []
|
||||
for name in sorted(ident):
|
||||
hit = [t for t in auth_cols if name in auth_cols[t]]
|
||||
print(" %-22s %s %s" % (name, "OK " if hit else "MISS", ("->" + ",".join(hit)) if hit else "(不在任何权威列集)"))
|
||||
if not hit:
|
||||
bad.append(name)
|
||||
|
||||
print("\n--- 方向 2: 权威列集中未被 SQL 显式引用者(informational)---")
|
||||
for table in AUTHORITY:
|
||||
unused = [c for c in auth_cols[table] if c not in ident]
|
||||
print(" [%s] unreferenced=%d %s" % (table, len(unused), unused))
|
||||
|
||||
print("\ncode columns checked: %d, missing from authority: %d" % (len(ident), len(bad)))
|
||||
if bad:
|
||||
print("MISSING: %s" % bad)
|
||||
print("RESULT: FAIL")
|
||||
return 1
|
||||
print("RESULT: PASS")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
36
scripts/m11b2b_column_diff_output.txt
Normal file
36
scripts/m11b2b_column_diff_output.txt
Normal file
@ -0,0 +1,36 @@
|
||||
workspace root: /d/pipeline/workspaces/0/sdlc_general
|
||||
SKIP (absent): modules/world_sync/world_sync/pbl_runtime_schema.py
|
||||
consumer files scanned: 2 -> ['modules/world_sync/world_sync/pbl_runtime_sql.py', 'modules/world_sync/world_sync/pbl_runtime_tx.py']
|
||||
SQL literals found: 9, distinct column identifiers: 18
|
||||
|
||||
authority columns [pbl_runtime_event] (25 cols): ['id', 'tenant_id', 'event_uid', 'event_code', 'idem_key', 'world_id', 'scene_id', 'session_id', 'entity_id', 'actor_id', 'event_type', 'payload', 'payload_json', 'causation_id', 'seq', 'seq_no', 'source', 'state', 'state_version', 'tx_group', 'broadcast', 'created_by', 'created_at', 'updated_at', 'occurred_at']
|
||||
|
||||
authority columns [pbl_entity_state] (10 cols): ['tenant_id', 'id', 'session_id', 'entity_id', 'state_json', 'state_version', 'checksum', 'updated_by', 'created_at', 'updated_at']
|
||||
|
||||
--- 方向 1: 代码 SQL 引用列 ⊆ 权威列集(必须全部成立)---
|
||||
AuthorityResolutionError MISS (不在任何权威列集)
|
||||
ConcurrentStateConflict MISS (不在任何权威列集)
|
||||
_assert_contract MISS (不在任何权威列集)
|
||||
execute MISS (不在任何权威列集)
|
||||
expected_version MISS (不在任何权威列集)
|
||||
format MISS (不在任何权威列集)
|
||||
json MISS (不在任何权威列集)
|
||||
paramstyle MISS (不在任何权威列集)
|
||||
pyformat MISS (不在任何权威列集)
|
||||
qmark MISS (不在任何权威列集)
|
||||
rowcount MISS (不在任何权威列集)
|
||||
session_id OK ->pbl_runtime_event,pbl_entity_state
|
||||
state_json OK ->pbl_entity_state
|
||||
state_version OK ->pbl_runtime_event,pbl_entity_state
|
||||
tenant_id OK ->pbl_runtime_event,pbl_entity_state
|
||||
uk_es MISS (不在任何权威列集)
|
||||
updated_at OK ->pbl_runtime_event,pbl_entity_state
|
||||
write_event_with_state MISS (不在任何权威列集)
|
||||
|
||||
--- 方向 2: 权威列集中未被 SQL 显式引用者(informational)---
|
||||
[pbl_runtime_event] unreferenced=21 ['id', 'event_uid', 'event_code', 'idem_key', 'world_id', 'scene_id', 'entity_id', 'actor_id', 'event_type', 'payload', 'payload_json', 'causation_id', 'seq', 'seq_no', 'source', 'state', 'tx_group', 'broadcast', 'created_by', 'created_at', 'occurred_at']
|
||||
[pbl_entity_state] unreferenced=5 ['id', 'entity_id', 'checksum', 'updated_by', 'created_at']
|
||||
|
||||
code columns checked: 18, missing from authority: 13
|
||||
MISSING: ['AuthorityResolutionError', 'ConcurrentStateConflict', '_assert_contract', 'execute', 'expected_version', 'format', 'json', 'paramstyle', 'pyformat', 'qmark', 'rowcount', 'uk_es', 'write_event_with_state']
|
||||
RESULT: FAIL
|
||||
255
scripts/m11b2b_selftest.py
Normal file
255
scripts/m11b2b_selftest.py
Normal file
@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""M11b-2b 自测:表定义 JSON 符合 database-table-definition-spec(PM 裁决 B:key=schema 引用)。
|
||||
|
||||
覆盖全部 4 个落点(world_sync 真源 2 + 应用打包镜像 2),任一不符即 RESULT: FAIL。
|
||||
|
||||
断言集(判定项,计入 RESULT):
|
||||
A1 JSON 可解析;
|
||||
A2 根键 == {"key","schema"} 且 key == "schema"(引用形态,不再自带结构体);
|
||||
A3 引用体内不含 summary/fields/indexes/codes 结构体(列集以 authority_path 为唯一真源);
|
||||
A4 各段键集合 ⊆ 白名单(引用体键白名单;禁止把元信息藏在标准结构体里);
|
||||
A5 authority_module/authority_path/table 必填,且 authority_path 指向的真源文件真实存在;
|
||||
A7 真源与镜像 sha256 两两相同(禁止内容级分叉)。
|
||||
|
||||
U 段(上游体检项,不计入本任务 RESULT,但逐条打印 DEFECT 供冒泡留证):
|
||||
A6 权威真源 modules/pbl_runtime_ext/models/*.json 自身对 spec 四段式白名单/抽象类型的符合性。
|
||||
—— 该侧为 M11b-1 已批准产物,本任务按 PM 裁决「不得反向修改」,故只体检登记不判定。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
SOURCE_TABLES = [
|
||||
"pbl_runtime_event.json",
|
||||
"pbl_entity_state.json",
|
||||
]
|
||||
|
||||
# 落点 1:world_sync 真源(modules 侧,唯一手改入口)
|
||||
MODULE_SRC_DIR = "modules/world_sync/models"
|
||||
# 落点 2:应用打包镜像(只由 sync_models_mirror.py 生成,禁止手工双写)
|
||||
APP_MIRROR_DIR = os.path.join("apps", "scense", "pkgs", "world_sync", "models")
|
||||
# 权威真源(pbl_runtime_ext,M11b-1 已批准产物,本任务只读体检)
|
||||
AUTHORITY_DIR = "modules/pbl_runtime_ext/models"
|
||||
|
||||
# 引用体允许出现的键(表级元信息全部登记在此白名单内,不得散落到结构体里)
|
||||
REFERENCE_KEY_WHITELIST = {
|
||||
"table", "authority_module", "authority_path", "owner", "milestone",
|
||||
"decision_ref", "decision_basis", "structure_note", "consumers", "mirror_note",
|
||||
"comment", "note",
|
||||
}
|
||||
|
||||
# database-table-definition-spec 四段式与各段键白名单
|
||||
SPEC_ROOT_KEYS = {"summary", "fields", "indexes", "codes"}
|
||||
SPEC_SUMMARY_KEYS = {"name", "title", "primary", "catelog"}
|
||||
SPEC_FIELD_KEYS = {"name", "title", "type", "length", "dec", "nullable", "default"}
|
||||
SPEC_INDEX_KEYS = {"name", "idxtype", "idxfields"}
|
||||
SPEC_CODE_KEYS = {"field", "table", "valuefield", "textfield", "cond"}
|
||||
|
||||
ABSTRACT_TYPES = {
|
||||
"str", "char", "short", "int", "long", "float", "double", "ddouble",
|
||||
"decimal", "date", "time", "datetime", "timestamp", "text", "bin",
|
||||
}
|
||||
NEED_LENGTH = {"str", "char", "float", "double", "ddouble", "decimal"}
|
||||
NEED_DEC = {"float", "double", "ddouble", "decimal"}
|
||||
|
||||
_failures = [] # 判定项失败
|
||||
_upstream_defects = [] # 上游体检项缺陷(登记不判定)
|
||||
_assert_count = 0
|
||||
_upstream_count = 0
|
||||
|
||||
|
||||
def check(cond, label):
|
||||
global _assert_count
|
||||
_assert_count += 1
|
||||
if not cond:
|
||||
_failures.append(label)
|
||||
print(" FAIL %s" % label)
|
||||
else:
|
||||
print(" ok %s" % label)
|
||||
return bool(cond)
|
||||
|
||||
|
||||
def check_upstream(cond, label):
|
||||
global _upstream_count
|
||||
_upstream_count += 1
|
||||
if not cond:
|
||||
_upstream_defects.append(label)
|
||||
print(" DEFECT %s" % label)
|
||||
else:
|
||||
print(" ok %s" % label)
|
||||
return bool(cond)
|
||||
|
||||
|
||||
def find_root():
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
cur = here
|
||||
for _ in range(10):
|
||||
if os.path.isdir(os.path.join(cur, "modules")) and os.path.isdir(os.path.join(cur, "apps")):
|
||||
return cur
|
||||
parent = os.path.dirname(cur)
|
||||
if parent == cur:
|
||||
break
|
||||
cur = parent
|
||||
return os.path.abspath(os.path.join(here, os.pardir, os.pardir, os.pardir))
|
||||
|
||||
|
||||
def sha256_of(path):
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def load_json(path):
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def validate_reference(path, root):
|
||||
"""A1~A5:world_sync 侧引用文件(真源与镜像执行同一套断言)。"""
|
||||
print("== reference: %s" % path)
|
||||
try:
|
||||
data = load_json(path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
check(False, "A1 json-parseable (%s)" % exc)
|
||||
return None
|
||||
check(isinstance(data, dict), "A1 json-is-object")
|
||||
check(set(data.keys()) == {"key", "schema"}, "A2 root-keys == {key,schema} (got %s)" % sorted(data.keys()))
|
||||
check(data.get("key") == "schema", 'A2 key == "schema"')
|
||||
body = data.get("schema")
|
||||
if not check(isinstance(body, dict), "A2 schema-is-object"):
|
||||
return None
|
||||
for seg in ("summary", "fields", "indexes", "codes"):
|
||||
check(seg not in body, "A3 no self-carried struct: %s" % seg)
|
||||
extra = set(body.keys()) - REFERENCE_KEY_WHITELIST
|
||||
check(not extra, "A4 reference keys subset whitelist (extra=%s)" % sorted(extra))
|
||||
check(bool(str(body.get("table", "")).strip()), "A5 table non-empty")
|
||||
check(body.get("authority_module") == "pbl_runtime_ext", "A5 authority_module == pbl_runtime_ext")
|
||||
apath = body.get("authority_path", "")
|
||||
check(bool(apath), "A5 authority_path non-empty")
|
||||
if apath:
|
||||
check(os.path.isfile(os.path.join(root, apath)), "A5 authority file exists: %s" % apath)
|
||||
return body
|
||||
|
||||
|
||||
def validate_authority(path):
|
||||
"""A6(上游体检):pbl_runtime_ext 权威真源对 spec 的符合性,只登记不判定。"""
|
||||
print("== authority(upstream audit): %s" % path)
|
||||
if not os.path.isfile(path):
|
||||
check_upstream(False, "A6 authority file present: %s" % path)
|
||||
return
|
||||
try:
|
||||
data = load_json(path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
check_upstream(False, "A6 json-parseable (%s)" % exc)
|
||||
return
|
||||
check_upstream(isinstance(data, dict), "A6 root-is-object")
|
||||
check_upstream(set(data.keys()) <= SPEC_ROOT_KEYS,
|
||||
"A6 root-keys subset of four-section (got %s)" % sorted(data.keys()))
|
||||
|
||||
summary = data.get("summary") or []
|
||||
check_upstream(len(summary) == 1, "A6 summary has exactly one record (got %d)" % len(summary))
|
||||
if summary and isinstance(summary[0], dict):
|
||||
s0 = summary[0]
|
||||
check_upstream(set(s0.keys()) <= SPEC_SUMMARY_KEYS,
|
||||
"A6 summary keys subset whitelist (got %s)" % sorted(s0.keys()))
|
||||
check_upstream(bool(s0.get("name")), "A6 summary.name non-empty")
|
||||
check_upstream(isinstance(s0.get("primary"), list) and len(s0.get("primary") or []) > 0,
|
||||
"A6 summary.primary is non-empty array")
|
||||
|
||||
fields = data.get("fields") or []
|
||||
check_upstream(len(fields) > 0, "A6 fields non-empty")
|
||||
names = []
|
||||
for f in fields:
|
||||
if not isinstance(f, dict):
|
||||
check_upstream(False, "A6 field-is-object")
|
||||
continue
|
||||
fname = f.get("name", "?")
|
||||
names.append(fname)
|
||||
check_upstream(set(f.keys()) <= SPEC_FIELD_KEYS,
|
||||
"A6[%s] field keys subset whitelist (got %s)" % (fname, sorted(f.keys())))
|
||||
t = f.get("type")
|
||||
check_upstream(t in ABSTRACT_TYPES, "A6[%s] abstract type (%r)" % (fname, t))
|
||||
if t in NEED_LENGTH:
|
||||
check_upstream(isinstance(f.get("length"), int) and f.get("length") > 0,
|
||||
"A6[%s] positive int length" % fname)
|
||||
if t in NEED_DEC:
|
||||
check_upstream(isinstance(f.get("dec"), int) and f.get("dec") > 0, "A6[%s] positive int dec" % fname)
|
||||
check_upstream(str(f.get("nullable", "no")) in ("yes", "no"), "A6[%s] nullable in yes|no" % fname)
|
||||
|
||||
for idx in (data.get("indexes") or []):
|
||||
if not isinstance(idx, dict):
|
||||
check_upstream(False, "A6 index-is-object")
|
||||
continue
|
||||
iname = idx.get("name", "?")
|
||||
check_upstream(set(idx.keys()) <= SPEC_INDEX_KEYS,
|
||||
"A6[%s] index keys subset whitelist (got %s)" % (iname, sorted(idx.keys())))
|
||||
check_upstream(isinstance(idx.get("idxfields"), list) and len(idx.get("idxfields") or []) > 0,
|
||||
"A6[%s] idxfields non-empty array" % iname)
|
||||
check_upstream(idx.get("idxtype") in ("unique", "index"), "A6[%s] idxtype unique|index" % iname)
|
||||
for ifld in (idx.get("idxfields") or []):
|
||||
check_upstream(ifld in names, "A6[%s] idxfield exists in fields: %s" % (iname, ifld))
|
||||
|
||||
for code in (data.get("codes") or []):
|
||||
if not isinstance(code, dict):
|
||||
check_upstream(False, "A6 code-is-object")
|
||||
continue
|
||||
check_upstream(set(code.keys()) <= SPEC_CODE_KEYS,
|
||||
"A6 code keys subset whitelist (got %s)" % sorted(code.keys()))
|
||||
if code.get("table") == "appcodes_kv":
|
||||
check_upstream("parentid=" in str(code.get("cond", "")), "A6 appcodes_kv cond uses parentid=")
|
||||
check_upstream("." not in str(code.get("table", "")), "A6 codes.table has no dot notation")
|
||||
|
||||
|
||||
def main():
|
||||
root = find_root()
|
||||
print("workspace root: %s" % root)
|
||||
paths = []
|
||||
for name in SOURCE_TABLES:
|
||||
paths.append(os.path.join(root, MODULE_SRC_DIR, name))
|
||||
paths.append(os.path.join(root, APP_MIRROR_DIR, name))
|
||||
|
||||
print("\n--- A1~A5: 4 个落点全部执行同一套 spec 断言 ---")
|
||||
for p in paths:
|
||||
if not os.path.isfile(p):
|
||||
check(False, "file exists: %s" % p)
|
||||
continue
|
||||
validate_reference(p, root)
|
||||
|
||||
print("\n--- A7: 真源与镜像 sha256 两两相同 ---")
|
||||
for name in SOURCE_TABLES:
|
||||
src = os.path.join(root, MODULE_SRC_DIR, name)
|
||||
dst = os.path.join(root, APP_MIRROR_DIR, name)
|
||||
if os.path.isfile(src) and os.path.isfile(dst):
|
||||
hs, hd = sha256_of(src), sha256_of(dst)
|
||||
print(" %s\n src=%s\n dst=%s" % (name, hs, hd))
|
||||
check(hs == hd, "A7 sha256 equal: %s" % name)
|
||||
else:
|
||||
check(False, "A7 both files present for sha256: %s" % name)
|
||||
|
||||
print("\n--- A6: 权威真源(pbl_runtime_ext)上游体检,只登记不判定 ---")
|
||||
for name in SOURCE_TABLES:
|
||||
validate_authority(os.path.join(root, AUTHORITY_DIR, name))
|
||||
|
||||
print("\njudged assertions executed: %d" % _assert_count)
|
||||
print("upstream audit checks executed: %d" % _upstream_count)
|
||||
if _upstream_defects:
|
||||
print("UPSTREAM DEFECTS (%d) -> 已登记,需冒泡 agent.pm / pbl_runtime_ext owner:" % len(_upstream_defects))
|
||||
for d in _upstream_defects:
|
||||
print(" * %s" % d)
|
||||
if _failures:
|
||||
print("FAILURES (%d):" % len(_failures))
|
||||
for f in _failures:
|
||||
print(" - %s" % f)
|
||||
print("RESULT: FAIL")
|
||||
return 1
|
||||
print("RESULT: PASS")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
320
scripts/m11b2b_selftest_output.txt
Normal file
320
scripts/m11b2b_selftest_output.txt
Normal file
@ -0,0 +1,320 @@
|
||||
workspace root: /d/pipeline/workspaces/0/sdlc_general
|
||||
|
||||
--- A1~A5: 4 个落点全部执行同一套 spec 断言 ---
|
||||
== reference: /d/pipeline/workspaces/0/sdlc_general/modules/world_sync/models/pbl_runtime_event.json
|
||||
ok A1 json-is-object
|
||||
ok A2 root-keys == {key,schema} (got ['key', 'schema'])
|
||||
ok A2 key == "schema"
|
||||
ok A2 schema-is-object
|
||||
ok A3 no self-carried struct: summary
|
||||
ok A3 no self-carried struct: fields
|
||||
ok A3 no self-carried struct: indexes
|
||||
ok A3 no self-carried struct: codes
|
||||
ok A4 reference keys subset whitelist (extra=[])
|
||||
ok A5 table non-empty
|
||||
ok A5 authority_module == pbl_runtime_ext
|
||||
ok A5 authority_path non-empty
|
||||
ok A5 authority file exists: modules/pbl_runtime_ext/models/pbl_runtime_event.json
|
||||
== reference: /d/pipeline/workspaces/0/sdlc_general/apps/scense/pkgs/world_sync/models/pbl_runtime_event.json
|
||||
ok A1 json-is-object
|
||||
ok A2 root-keys == {key,schema} (got ['key', 'schema'])
|
||||
ok A2 key == "schema"
|
||||
ok A2 schema-is-object
|
||||
ok A3 no self-carried struct: summary
|
||||
ok A3 no self-carried struct: fields
|
||||
ok A3 no self-carried struct: indexes
|
||||
ok A3 no self-carried struct: codes
|
||||
ok A4 reference keys subset whitelist (extra=[])
|
||||
ok A5 table non-empty
|
||||
ok A5 authority_module == pbl_runtime_ext
|
||||
ok A5 authority_path non-empty
|
||||
ok A5 authority file exists: modules/pbl_runtime_ext/models/pbl_runtime_event.json
|
||||
== reference: /d/pipeline/workspaces/0/sdlc_general/modules/world_sync/models/pbl_entity_state.json
|
||||
ok A1 json-is-object
|
||||
ok A2 root-keys == {key,schema} (got ['key', 'schema'])
|
||||
ok A2 key == "schema"
|
||||
ok A2 schema-is-object
|
||||
ok A3 no self-carried struct: summary
|
||||
ok A3 no self-carried struct: fields
|
||||
ok A3 no self-carried struct: indexes
|
||||
ok A3 no self-carried struct: codes
|
||||
ok A4 reference keys subset whitelist (extra=[])
|
||||
ok A5 table non-empty
|
||||
ok A5 authority_module == pbl_runtime_ext
|
||||
ok A5 authority_path non-empty
|
||||
ok A5 authority file exists: modules/pbl_runtime_ext/models/pbl_entity_state.json
|
||||
== reference: /d/pipeline/workspaces/0/sdlc_general/apps/scense/pkgs/world_sync/models/pbl_entity_state.json
|
||||
ok A1 json-is-object
|
||||
ok A2 root-keys == {key,schema} (got ['key', 'schema'])
|
||||
ok A2 key == "schema"
|
||||
ok A2 schema-is-object
|
||||
ok A3 no self-carried struct: summary
|
||||
ok A3 no self-carried struct: fields
|
||||
ok A3 no self-carried struct: indexes
|
||||
ok A3 no self-carried struct: codes
|
||||
ok A4 reference keys subset whitelist (extra=[])
|
||||
ok A5 table non-empty
|
||||
ok A5 authority_module == pbl_runtime_ext
|
||||
ok A5 authority_path non-empty
|
||||
ok A5 authority file exists: modules/pbl_runtime_ext/models/pbl_entity_state.json
|
||||
|
||||
--- A7: 真源与镜像 sha256 两两相同 ---
|
||||
pbl_runtime_event.json
|
||||
src=58b09abf9982d154554522ce41f9c1f964170b02e3266819714f69f2c1ac9c0e
|
||||
dst=58b09abf9982d154554522ce41f9c1f964170b02e3266819714f69f2c1ac9c0e
|
||||
ok A7 sha256 equal: pbl_runtime_event.json
|
||||
pbl_entity_state.json
|
||||
src=e8561399150b818918a55f41c6fefd0a7a0b01048768a2a4ab7567004a947014
|
||||
dst=e8561399150b818918a55f41c6fefd0a7a0b01048768a2a4ab7567004a947014
|
||||
ok A7 sha256 equal: pbl_entity_state.json
|
||||
|
||||
--- A6: 权威真源(pbl_runtime_ext)上游体检,只登记不判定 ---
|
||||
== authority(upstream audit): /d/pipeline/workspaces/0/sdlc_general/modules/pbl_runtime_ext/models/pbl_runtime_event.json
|
||||
ok A6 root-is-object
|
||||
ok A6 root-keys subset of four-section (got ['codes', 'fields', 'indexes', 'summary'])
|
||||
ok A6 summary has exactly one record (got 1)
|
||||
DEFECT A6 summary keys subset whitelist (got ['append_only', 'charset', 'collate', 'comment', 'ddl_source', 'engine', 'guards', 'module', 'name', 'partition', 'primary', 'tenant_scoped', 'title', 'write_paths'])
|
||||
ok A6 summary.name non-empty
|
||||
ok A6 summary.primary is non-empty array
|
||||
ok A6 fields non-empty
|
||||
DEFECT A6[id] field keys subset whitelist (got ['auto_increment', 'comment', 'ddl_type', 'name', 'nullable', 'title', 'type'])
|
||||
ok A6[id] abstract type ('long')
|
||||
ok A6[id] nullable in yes|no
|
||||
DEFECT A6[tenant_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
|
||||
ok A6[tenant_id] abstract type ('str')
|
||||
ok A6[tenant_id] positive int length
|
||||
ok A6[tenant_id] nullable in yes|no
|
||||
DEFECT A6[event_uid] field keys subset whitelist (got ['comment', 'ddl_type', 'length', 'name', 'nullable', 'title', 'type'])
|
||||
ok A6[event_uid] abstract type ('str')
|
||||
ok A6[event_uid] positive int length
|
||||
ok A6[event_uid] nullable in yes|no
|
||||
DEFECT A6[event_code] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
|
||||
ok A6[event_code] abstract type ('str')
|
||||
ok A6[event_code] positive int length
|
||||
ok A6[event_code] nullable in yes|no
|
||||
DEFECT A6[idem_key] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
|
||||
ok A6[idem_key] abstract type ('str')
|
||||
ok A6[idem_key] positive int length
|
||||
ok A6[idem_key] nullable in yes|no
|
||||
DEFECT A6[world_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
|
||||
ok A6[world_id] abstract type ('long')
|
||||
ok A6[world_id] nullable in yes|no
|
||||
DEFECT A6[scene_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
|
||||
ok A6[scene_id] abstract type ('long')
|
||||
ok A6[scene_id] nullable in yes|no
|
||||
DEFECT A6[session_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
|
||||
ok A6[session_id] abstract type ('long')
|
||||
ok A6[session_id] nullable in yes|no
|
||||
DEFECT A6[entity_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
|
||||
ok A6[entity_id] abstract type ('str')
|
||||
ok A6[entity_id] positive int length
|
||||
ok A6[entity_id] nullable in yes|no
|
||||
DEFECT A6[actor_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
|
||||
ok A6[actor_id] abstract type ('str')
|
||||
ok A6[actor_id] positive int length
|
||||
ok A6[actor_id] nullable in yes|no
|
||||
DEFECT A6[event_type] field keys subset whitelist (got ['comment', 'ddl_type', 'length', 'name', 'nullable', 'title', 'type'])
|
||||
ok A6[event_type] abstract type ('str')
|
||||
ok A6[event_type] positive int length
|
||||
ok A6[event_type] nullable in yes|no
|
||||
DEFECT A6[payload] field keys subset whitelist (got ['comment', 'ddl_type', 'name', 'nullable', 'title', 'type'])
|
||||
ok A6[payload] abstract type ('text')
|
||||
ok A6[payload] nullable in yes|no
|
||||
DEFECT A6[payload_json] field keys subset whitelist (got ['comment', 'ddl_type', 'name', 'nullable', 'title', 'type'])
|
||||
ok A6[payload_json] abstract type ('text')
|
||||
ok A6[payload_json] nullable in yes|no
|
||||
DEFECT A6[causation_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
|
||||
ok A6[causation_id] abstract type ('str')
|
||||
ok A6[causation_id] positive int length
|
||||
ok A6[causation_id] nullable in yes|no
|
||||
DEFECT A6[seq] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
|
||||
ok A6[seq] abstract type ('long')
|
||||
ok A6[seq] nullable in yes|no
|
||||
DEFECT A6[seq_no] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
|
||||
ok A6[seq_no] abstract type ('long')
|
||||
ok A6[seq_no] nullable in yes|no
|
||||
DEFECT A6[source] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
|
||||
ok A6[source] abstract type ('str')
|
||||
ok A6[source] positive int length
|
||||
ok A6[source] nullable in yes|no
|
||||
DEFECT A6[state] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
|
||||
ok A6[state] abstract type ('str')
|
||||
ok A6[state] positive int length
|
||||
ok A6[state] nullable in yes|no
|
||||
DEFECT A6[state_version] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
|
||||
ok A6[state_version] abstract type ('long')
|
||||
ok A6[state_version] nullable in yes|no
|
||||
DEFECT A6[tx_group] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
|
||||
ok A6[tx_group] abstract type ('str')
|
||||
ok A6[tx_group] positive int length
|
||||
ok A6[tx_group] nullable in yes|no
|
||||
DEFECT A6[broadcast] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
|
||||
ok A6[broadcast] abstract type ('short')
|
||||
ok A6[broadcast] nullable in yes|no
|
||||
DEFECT A6[created_by] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
|
||||
ok A6[created_by] abstract type ('long')
|
||||
ok A6[created_by] nullable in yes|no
|
||||
DEFECT A6[created_at] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
|
||||
ok A6[created_at] abstract type ('datetime')
|
||||
ok A6[created_at] nullable in yes|no
|
||||
DEFECT A6[updated_at] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
|
||||
ok A6[updated_at] abstract type ('datetime')
|
||||
ok A6[updated_at] nullable in yes|no
|
||||
DEFECT A6[occurred_at] field keys subset whitelist (got ['comment', 'ddl_type', 'name', 'nullable', 'title', 'type'])
|
||||
ok A6[occurred_at] abstract type ('datetime')
|
||||
ok A6[occurred_at] nullable in yes|no
|
||||
DEFECT A6[uk_event_uid] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
|
||||
ok A6[uk_event_uid] idxfields non-empty array
|
||||
ok A6[uk_event_uid] idxtype unique|index
|
||||
ok A6[uk_event_uid] idxfield exists in fields: tenant_id
|
||||
ok A6[uk_event_uid] idxfield exists in fields: event_uid
|
||||
ok A6[uk_event_uid] idxfield exists in fields: created_at
|
||||
DEFECT A6[uk_tenant_idem] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
|
||||
ok A6[uk_tenant_idem] idxfields non-empty array
|
||||
ok A6[uk_tenant_idem] idxtype unique|index
|
||||
ok A6[uk_tenant_idem] idxfield exists in fields: tenant_id
|
||||
ok A6[uk_tenant_idem] idxfield exists in fields: idem_key
|
||||
ok A6[uk_tenant_idem] idxfield exists in fields: created_at
|
||||
DEFECT A6[ix_scene_created] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
|
||||
ok A6[ix_scene_created] idxfields non-empty array
|
||||
ok A6[ix_scene_created] idxtype unique|index
|
||||
ok A6[ix_scene_created] idxfield exists in fields: scene_id
|
||||
ok A6[ix_scene_created] idxfield exists in fields: created_at
|
||||
DEFECT A6[ix_tenant_session_seq] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
|
||||
ok A6[ix_tenant_session_seq] idxfields non-empty array
|
||||
ok A6[ix_tenant_session_seq] idxtype unique|index
|
||||
ok A6[ix_tenant_session_seq] idxfield exists in fields: tenant_id
|
||||
ok A6[ix_tenant_session_seq] idxfield exists in fields: session_id
|
||||
ok A6[ix_tenant_session_seq] idxfield exists in fields: seq
|
||||
DEFECT A6[ix_tenant_session_seqno] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
|
||||
ok A6[ix_tenant_session_seqno] idxfields non-empty array
|
||||
ok A6[ix_tenant_session_seqno] idxtype unique|index
|
||||
ok A6[ix_tenant_session_seqno] idxfield exists in fields: tenant_id
|
||||
ok A6[ix_tenant_session_seqno] idxfield exists in fields: session_id
|
||||
ok A6[ix_tenant_session_seqno] idxfield exists in fields: seq_no
|
||||
DEFECT A6[ix_tenant_world_type] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
|
||||
ok A6[ix_tenant_world_type] idxfields non-empty array
|
||||
ok A6[ix_tenant_world_type] idxtype unique|index
|
||||
ok A6[ix_tenant_world_type] idxfield exists in fields: tenant_id
|
||||
ok A6[ix_tenant_world_type] idxfield exists in fields: world_id
|
||||
ok A6[ix_tenant_world_type] idxfield exists in fields: event_type
|
||||
DEFECT A6[ix_tx_group] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
|
||||
ok A6[ix_tx_group] idxfields non-empty array
|
||||
ok A6[ix_tx_group] idxtype unique|index
|
||||
ok A6[ix_tx_group] idxfield exists in fields: tx_group
|
||||
DEFECT A6[ix_created_at] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
|
||||
ok A6[ix_created_at] idxfields non-empty array
|
||||
ok A6[ix_created_at] idxtype unique|index
|
||||
ok A6[ix_created_at] idxfield exists in fields: created_at
|
||||
DEFECT A6 code keys subset whitelist (got ['comment', 'cond', 'field', 'table', 'textfield', 'valuefield'])
|
||||
ok A6 appcodes_kv cond uses parentid=
|
||||
ok A6 codes.table has no dot notation
|
||||
== authority(upstream audit): /d/pipeline/workspaces/0/sdlc_general/modules/pbl_runtime_ext/models/pbl_entity_state.json
|
||||
ok A6 root-is-object
|
||||
ok A6 root-keys subset of four-section (got ['codes', 'fields', 'indexes', 'summary'])
|
||||
ok A6 summary has exactly one record (got 1)
|
||||
DEFECT A6 summary keys subset whitelist (got ['charset', 'comment', 'engine', 'module', 'name', 'tenant_scoped'])
|
||||
ok A6 summary.name non-empty
|
||||
DEFECT A6 summary.primary is non-empty array
|
||||
ok A6 fields non-empty
|
||||
DEFECT A6[tenant_id] field keys subset whitelist (got ['comment', 'length', 'name', 'null', 'type'])
|
||||
ok A6[tenant_id] abstract type ('str')
|
||||
ok A6[tenant_id] positive int length
|
||||
ok A6[tenant_id] nullable in yes|no
|
||||
DEFECT A6[id] field keys subset whitelist (got ['auto_increment', 'comment', 'length', 'name', 'null', 'type', 'unsigned'])
|
||||
ok A6[id] abstract type ('int')
|
||||
ok A6[id] nullable in yes|no
|
||||
DEFECT A6[session_id] field keys subset whitelist (got ['comment', 'name', 'null', 'type'])
|
||||
DEFECT A6[session_id] abstract type ('bigint')
|
||||
ok A6[session_id] nullable in yes|no
|
||||
DEFECT A6[entity_id] field keys subset whitelist (got ['comment', 'length', 'name', 'null', 'type'])
|
||||
ok A6[entity_id] abstract type ('str')
|
||||
ok A6[entity_id] positive int length
|
||||
ok A6[entity_id] nullable in yes|no
|
||||
DEFECT A6[state_json] field keys subset whitelist (got ['comment', 'name', 'null', 'type'])
|
||||
DEFECT A6[state_json] abstract type ('json')
|
||||
ok A6[state_json] nullable in yes|no
|
||||
DEFECT A6[state_version] field keys subset whitelist (got ['comment', 'name', 'null', 'type'])
|
||||
DEFECT A6[state_version] abstract type ('bigint')
|
||||
ok A6[state_version] nullable in yes|no
|
||||
DEFECT A6[checksum] field keys subset whitelist (got ['comment', 'length', 'name', 'null', 'type'])
|
||||
ok A6[checksum] abstract type ('str')
|
||||
ok A6[checksum] positive int length
|
||||
ok A6[checksum] nullable in yes|no
|
||||
DEFECT A6[updated_by] field keys subset whitelist (got ['comment', 'length', 'name', 'null', 'type'])
|
||||
ok A6[updated_by] abstract type ('str')
|
||||
ok A6[updated_by] positive int length
|
||||
ok A6[updated_by] nullable in yes|no
|
||||
DEFECT A6[created_at] field keys subset whitelist (got ['comment', 'name', 'null', 'type'])
|
||||
ok A6[created_at] abstract type ('datetime')
|
||||
ok A6[created_at] nullable in yes|no
|
||||
DEFECT A6[updated_at] field keys subset whitelist (got ['comment', 'name', 'null', 'type'])
|
||||
ok A6[updated_at] abstract type ('datetime')
|
||||
ok A6[updated_at] nullable in yes|no
|
||||
DEFECT A6[uk_es] index keys subset whitelist (got ['fields', 'name', 'unique'])
|
||||
DEFECT A6[uk_es] idxfields non-empty array
|
||||
DEFECT A6[uk_es] idxtype unique|index
|
||||
DEFECT A6[PRIMARY] index keys subset whitelist (got ['fields', 'name', 'unique'])
|
||||
DEFECT A6[PRIMARY] idxfields non-empty array
|
||||
DEFECT A6[PRIMARY] idxtype unique|index
|
||||
|
||||
judged assertions executed: 54
|
||||
upstream audit checks executed: 186
|
||||
UPSTREAM DEFECTS (56) -> 已登记,需冒泡 agent.pm / pbl_runtime_ext owner:
|
||||
* A6 summary keys subset whitelist (got ['append_only', 'charset', 'collate', 'comment', 'ddl_source', 'engine', 'guards', 'module', 'name', 'partition', 'primary', 'tenant_scoped', 'title', 'write_paths'])
|
||||
* A6[id] field keys subset whitelist (got ['auto_increment', 'comment', 'ddl_type', 'name', 'nullable', 'title', 'type'])
|
||||
* A6[tenant_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
|
||||
* A6[event_uid] field keys subset whitelist (got ['comment', 'ddl_type', 'length', 'name', 'nullable', 'title', 'type'])
|
||||
* A6[event_code] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
|
||||
* A6[idem_key] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
|
||||
* A6[world_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
|
||||
* A6[scene_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
|
||||
* A6[session_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
|
||||
* A6[entity_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
|
||||
* A6[actor_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
|
||||
* A6[event_type] field keys subset whitelist (got ['comment', 'ddl_type', 'length', 'name', 'nullable', 'title', 'type'])
|
||||
* A6[payload] field keys subset whitelist (got ['comment', 'ddl_type', 'name', 'nullable', 'title', 'type'])
|
||||
* A6[payload_json] field keys subset whitelist (got ['comment', 'ddl_type', 'name', 'nullable', 'title', 'type'])
|
||||
* A6[causation_id] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
|
||||
* A6[seq] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
|
||||
* A6[seq_no] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
|
||||
* A6[source] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
|
||||
* A6[state] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
|
||||
* A6[state_version] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
|
||||
* A6[tx_group] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'length', 'name', 'nullable', 'title', 'type'])
|
||||
* A6[broadcast] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
|
||||
* A6[created_by] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
|
||||
* A6[created_at] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
|
||||
* A6[updated_at] field keys subset whitelist (got ['comment', 'ddl_type', 'default', 'name', 'nullable', 'title', 'type'])
|
||||
* A6[occurred_at] field keys subset whitelist (got ['comment', 'ddl_type', 'name', 'nullable', 'title', 'type'])
|
||||
* A6[uk_event_uid] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
|
||||
* A6[uk_tenant_idem] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
|
||||
* A6[ix_scene_created] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
|
||||
* A6[ix_tenant_session_seq] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
|
||||
* A6[ix_tenant_session_seqno] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
|
||||
* A6[ix_tenant_world_type] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
|
||||
* A6[ix_tx_group] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
|
||||
* A6[ix_created_at] index keys subset whitelist (got ['comment', 'idxfields', 'idxtype', 'name'])
|
||||
* A6 code keys subset whitelist (got ['comment', 'cond', 'field', 'table', 'textfield', 'valuefield'])
|
||||
* A6 summary keys subset whitelist (got ['charset', 'comment', 'engine', 'module', 'name', 'tenant_scoped'])
|
||||
* A6 summary.primary is non-empty array
|
||||
* A6[tenant_id] field keys subset whitelist (got ['comment', 'length', 'name', 'null', 'type'])
|
||||
* A6[id] field keys subset whitelist (got ['auto_increment', 'comment', 'length', 'name', 'null', 'type', 'unsigned'])
|
||||
* A6[session_id] field keys subset whitelist (got ['comment', 'name', 'null', 'type'])
|
||||
* A6[session_id] abstract type ('bigint')
|
||||
* A6[entity_id] field keys subset whitelist (got ['comment', 'length', 'name', 'null', 'type'])
|
||||
* A6[state_json] field keys subset whitelist (got ['comment', 'name', 'null', 'type'])
|
||||
* A6[state_json] abstract type ('json')
|
||||
* A6[state_version] field keys subset whitelist (got ['comment', 'name', 'null', 'type'])
|
||||
* A6[state_version] abstract type ('bigint')
|
||||
* A6[checksum] field keys subset whitelist (got ['comment', 'length', 'name', 'null', 'type'])
|
||||
* A6[updated_by] field keys subset whitelist (got ['comment', 'length', 'name', 'null', 'type'])
|
||||
* A6[created_at] field keys subset whitelist (got ['comment', 'name', 'null', 'type'])
|
||||
* A6[updated_at] field keys subset whitelist (got ['comment', 'name', 'null', 'type'])
|
||||
* A6[uk_es] index keys subset whitelist (got ['fields', 'name', 'unique'])
|
||||
* A6[uk_es] idxfields non-empty array
|
||||
* A6[uk_es] idxtype unique|index
|
||||
* A6[PRIMARY] index keys subset whitelist (got ['fields', 'name', 'unique'])
|
||||
* A6[PRIMARY] idxfields non-empty array
|
||||
* A6[PRIMARY] idxtype unique|index
|
||||
RESULT: PASS
|
||||
105
scripts/sync_models_mirror.py
Normal file
105
scripts/sync_models_mirror.py
Normal file
@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""M11b-2b: 把 modules/world_sync/models/ 下的表定义真源同步到应用打包镜像落点。
|
||||
|
||||
铁律:apps/{app}/pkgs/{module}/models/*.json 是 build 阶段产物,禁止手工双写。
|
||||
本脚本是唯一允许的写入通道 —— 真源只有一份(modules/ 侧),镜像由 cp 语义逐字节覆盖生成。
|
||||
|
||||
用法:
|
||||
python3 modules/world_sync/scripts/sync_models_mirror.py
|
||||
python3 modules/world_sync/scripts/sync_models_mirror.py --check # 只校验不写,不一致退出码 1
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
# 表定义真源清单(相对机构工作空间根)
|
||||
SOURCE_TABLES = [
|
||||
"pbl_runtime_event.json",
|
||||
"pbl_entity_state.json",
|
||||
]
|
||||
|
||||
MODULE_ROOT = "modules/world_sync"
|
||||
SOURCE_DIR = os.path.join(MODULE_ROOT, "models")
|
||||
MIRROR_DIR = os.path.join("apps", "scense", "pkgs", "world_sync", "models")
|
||||
|
||||
|
||||
def _find_workspace_root():
|
||||
"""从脚本位置向上回溯,找到同时含 modules/ 与 apps/ 的工作空间根。"""
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
cur = here
|
||||
for _ in range(10):
|
||||
if os.path.isdir(os.path.join(cur, "modules")) and os.path.isdir(os.path.join(cur, "apps")):
|
||||
return cur
|
||||
parent = os.path.dirname(cur)
|
||||
if parent == cur:
|
||||
break
|
||||
cur = parent
|
||||
# 兜底:脚本位于 modules/{m}/scripts/x.py,则根为上三级
|
||||
return os.path.abspath(os.path.join(here, os.pardir, os.pardir, os.pardir))
|
||||
|
||||
|
||||
def sha256_of(path):
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def sync(root, check_only=False):
|
||||
"""同步真源 -> 镜像。返回 (是否全部一致, 明细列表)。"""
|
||||
src_dir = os.path.join(root, SOURCE_DIR)
|
||||
dst_dir = os.path.join(root, MIRROR_DIR)
|
||||
details = []
|
||||
all_ok = True
|
||||
|
||||
if not check_only:
|
||||
os.makedirs(dst_dir, exist_ok=True)
|
||||
|
||||
for name in SOURCE_TABLES:
|
||||
src = os.path.join(src_dir, name)
|
||||
dst = os.path.join(dst_dir, name)
|
||||
if not os.path.isfile(src):
|
||||
details.append("MISSING-SOURCE: %s" % src)
|
||||
all_ok = False
|
||||
continue
|
||||
|
||||
if not check_only:
|
||||
shutil.copyfile(src, dst)
|
||||
|
||||
src_hash = sha256_of(src)
|
||||
if not os.path.isfile(dst):
|
||||
details.append("MISSING-MIRROR: %s" % dst)
|
||||
all_ok = False
|
||||
continue
|
||||
dst_hash = sha256_of(dst)
|
||||
same = (src_hash == dst_hash)
|
||||
all_ok = all_ok and same
|
||||
details.append("%s %s" % ("SAME" if same else "DIFF", name))
|
||||
details.append(" sha256 src=%s" % src_hash)
|
||||
details.append(" sha256 dst=%s" % dst_hash)
|
||||
|
||||
return all_ok, details
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description="sync world_sync table-definition sources to app mirror")
|
||||
parser.add_argument("--check", action="store_true", help="only verify, do not write")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
root = _find_workspace_root()
|
||||
ok, details = sync(root, check_only=args.check)
|
||||
print("workspace root: %s" % root)
|
||||
print("mode: %s" % ("check" if args.check else "sync"))
|
||||
for line in details:
|
||||
print(line)
|
||||
print("RESULT: %s" % ("PASS" if ok else "FAIL"))
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
327
world_sync/pbl_runtime_authority.py
Normal file
327
world_sync/pbl_runtime_authority.py
Normal file
@ -0,0 +1,327 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""pbl_runtime_authority —— M11b-2b:``key=schema`` 引用的解析层(列名唯一权威源)。
|
||||
|
||||
**为什么存在**
|
||||
PM 裁决(任务 ``u0EO4x3ajntR41acaHj5H``,方案 B):``pbl_runtime_event`` /
|
||||
``pbl_entity_state`` 两张表的**唯一真源**是 ``modules/pbl_runtime_ext/models/*.json``
|
||||
(M11b-1 已批准产物)。world_sync 侧 ``models/*.json`` 只保留 ``key=schema`` 引用,
|
||||
**不再自带** summary/fields/indexes/codes 结构体。
|
||||
|
||||
于是「消费方列契约」必须从权威定义**机械导出**,而不是人工抄一遍——人工双写正是
|
||||
QC #1/#3 判定的「多份互相矛盾定义」的根因。本模块就是那个导出层:
|
||||
|
||||
* :func:`load_reference` —— 读本模块 ``models/<table>.json``(key=schema 引用);
|
||||
* :func:`authority_model` —— 顺着引用解析到 pbl_runtime_ext 的真源 JSON;
|
||||
* :func:`columns` / :func:`insert_columns` / :func:`primary` / :func:`indexes`
|
||||
—— 暴露权威列集(``insert_columns`` 自动剔除 auto_increment 列);
|
||||
* :func:`ddl` —— 由权威列集渲染本地建表语句(仅供测试/本地起库;生产 DDL、
|
||||
分区与 append-only 触发器归 pbl_runtime_ext 的 ``scripts/pbl_runtime_event_ddl.py``
|
||||
与 ``scripts/migrations/*.sql`` 所有,本模块不复制那套逻辑)。
|
||||
|
||||
**fail-closed**:引用缺失、真源找不到、真源不是四段式、列集为空 —— 一律抛
|
||||
:class:`AuthorityResolutionError`,绝不退化成「猜列名」。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
__all__ = [
|
||||
"AuthorityResolutionError",
|
||||
"REFERENCE_ROOT_KEYS",
|
||||
"REFERENCE_SCHEMA_KEYS",
|
||||
"SPEC_ROOT_KEYS",
|
||||
"load_reference",
|
||||
"authority_path",
|
||||
"authority_model",
|
||||
"columns",
|
||||
"insert_columns",
|
||||
"auto_increment_columns",
|
||||
"field_map",
|
||||
"primary",
|
||||
"indexes",
|
||||
"table_title",
|
||||
"ddl",
|
||||
"column_diff",
|
||||
]
|
||||
|
||||
# models/<table>.json 是 key=schema 引用:根键只允许这两个
|
||||
REFERENCE_ROOT_KEYS = ("key", "schema")
|
||||
# 引用体内允许的元信息键(白名单,自测脚本按此做机械断言)
|
||||
REFERENCE_SCHEMA_KEYS = (
|
||||
"table", "authority_module", "authority_path", "owner", "milestone",
|
||||
"decision_ref", "decision_basis", "structure_note", "consumers", "mirror_note",
|
||||
)
|
||||
# 真源(表定义)必须是四段式
|
||||
SPEC_ROOT_KEYS = ("summary", "fields", "indexes", "codes")
|
||||
|
||||
MODULE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
MODELS_DIR = os.path.join(MODULE_ROOT, "models")
|
||||
# modules/world_sync -> modules -> 机构工作空间根
|
||||
WORKSPACE_ROOT = os.path.dirname(os.path.dirname(MODULE_ROOT))
|
||||
|
||||
_SQLITE_TYPES = {
|
||||
"str": "TEXT", "char": "TEXT", "text": "TEXT", "json": "TEXT",
|
||||
"bin": "BLOB", "short": "INTEGER", "int": "INTEGER", "long": "INTEGER",
|
||||
"bigint": "INTEGER", "float": "REAL", "double": "REAL", "decimal": "REAL",
|
||||
"ddouble": "REAL", "date": "TEXT", "time": "TEXT", "datetime": "TEXT",
|
||||
"timestamp": "TEXT",
|
||||
}
|
||||
_MYSQL_TYPES = {
|
||||
"str": "VARCHAR({length})", "char": "CHAR({length})", "text": "LONGTEXT",
|
||||
"json": "JSON", "bin": "LONGBLOB", "short": "SMALLINT", "int": "INT",
|
||||
"long": "BIGINT", "bigint": "BIGINT", "float": "FLOAT",
|
||||
"double": "DECIMAL({length},{dec})", "decimal": "DECIMAL({length},{dec})",
|
||||
"ddouble": "DECIMAL({length},{dec})", "date": "DATE", "time": "TIME",
|
||||
"datetime": "DATETIME(3)", "timestamp": "TIMESTAMP",
|
||||
}
|
||||
|
||||
|
||||
class AuthorityResolutionError(Exception):
|
||||
"""引用/真源解析失败(fail-closed,调用方不得吞掉)。"""
|
||||
|
||||
def __init__(self, message, **details):
|
||||
Exception.__init__(self, message)
|
||||
self.message = message
|
||||
self.details = details
|
||||
|
||||
def __str__(self):
|
||||
if not self.details:
|
||||
return self.message
|
||||
parts = ", ".join("%s=%r" % (k, v) for k, v in sorted(self.details.items()))
|
||||
return "%s (%s)" % (self.message, parts)
|
||||
|
||||
|
||||
def _reference_dir():
|
||||
return MODELS_DIR
|
||||
|
||||
|
||||
def load_reference(table):
|
||||
"""读取本模块 ``models/<table>.json``,校验它是合法的 key=schema 引用。"""
|
||||
if not table or not isinstance(table, str):
|
||||
raise AuthorityResolutionError("table name is required", table=table)
|
||||
path = os.path.join(_reference_dir(), table + ".json")
|
||||
if not os.path.isfile(path):
|
||||
raise AuthorityResolutionError("schema reference missing", path=path)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
ref = json.load(fh)
|
||||
except ValueError as exc:
|
||||
raise AuthorityResolutionError("schema reference not valid JSON: %s" % exc,
|
||||
path=path)
|
||||
if not isinstance(ref, dict) or ref.get("key") != "schema":
|
||||
raise AuthorityResolutionError(
|
||||
"models/%s.json must be a key=schema reference (no local structure)" % table,
|
||||
path=path, root_keys=sorted(ref) if isinstance(ref, dict) else type(ref).__name__)
|
||||
extra = set(ref) - set(REFERENCE_ROOT_KEYS)
|
||||
if extra:
|
||||
raise AuthorityResolutionError("reference carries non-whitelist root keys",
|
||||
path=path, extra=sorted(extra))
|
||||
schema = ref.get("schema")
|
||||
if not isinstance(schema, dict):
|
||||
raise AuthorityResolutionError("reference.schema must be an object", path=path)
|
||||
if schema.get("table") != table:
|
||||
raise AuthorityResolutionError("reference.schema.table mismatches filename",
|
||||
path=path, declared=schema.get("table"))
|
||||
bad = set(schema) - set(REFERENCE_SCHEMA_KEYS)
|
||||
if bad:
|
||||
raise AuthorityResolutionError("reference.schema carries non-whitelist keys",
|
||||
path=path, extra=sorted(bad))
|
||||
return ref
|
||||
|
||||
|
||||
def authority_path(table):
|
||||
"""把引用解析成磁盘上真实存在的真源路径(多候选,全部落空即报错)。"""
|
||||
ref = load_reference(table)
|
||||
schema = ref["schema"]
|
||||
mod = schema.get("authority_module")
|
||||
candidates = []
|
||||
declared = schema.get("authority_path")
|
||||
if declared:
|
||||
candidates.append(os.path.normpath(os.path.join(WORKSPACE_ROOT, declared)))
|
||||
candidates.append(os.path.normpath(os.path.join(MODULE_ROOT, declared)))
|
||||
if mod:
|
||||
candidates.append(os.path.join(WORKSPACE_ROOT, "modules", mod, "models",
|
||||
table + ".json"))
|
||||
candidates.append(os.path.join(WORKSPACE_ROOT, "apps", "scense", "pkgs", mod,
|
||||
"models", table + ".json"))
|
||||
candidates.append(os.path.normpath(os.path.join(MODULE_ROOT, os.pardir, mod,
|
||||
"models", table + ".json")))
|
||||
tried = []
|
||||
for cand in candidates:
|
||||
if cand in tried:
|
||||
continue
|
||||
tried.append(cand)
|
||||
if os.path.isfile(cand):
|
||||
return cand
|
||||
raise AuthorityResolutionError("authority model not found for table",
|
||||
table=table, tried=tried)
|
||||
|
||||
|
||||
_MODEL_CACHE = {}
|
||||
|
||||
|
||||
def authority_model(table):
|
||||
"""读取并校验真源 JSON(四段式 + fields 非空),带缓存。"""
|
||||
if table in _MODEL_CACHE:
|
||||
return _MODEL_CACHE[table]
|
||||
path = authority_path(table)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
model = json.load(fh)
|
||||
except ValueError as exc:
|
||||
raise AuthorityResolutionError("authority model not valid JSON: %s" % exc,
|
||||
path=path)
|
||||
if not isinstance(model, dict):
|
||||
raise AuthorityResolutionError("authority model must be an object", path=path)
|
||||
missing = set(SPEC_ROOT_KEYS) - set(model)
|
||||
if missing:
|
||||
raise AuthorityResolutionError("authority model is not four-section",
|
||||
path=path, missing=sorted(missing))
|
||||
fields = model.get("fields")
|
||||
if not isinstance(fields, list) or not fields:
|
||||
raise AuthorityResolutionError("authority model has no fields", path=path)
|
||||
_MODEL_CACHE[table] = model
|
||||
return model
|
||||
|
||||
|
||||
def field_map(table):
|
||||
"""``{列名: 列定义 dict}``(保持真源顺序)。"""
|
||||
out = {}
|
||||
for f in authority_model(table)["fields"]:
|
||||
name = f.get("name")
|
||||
if not name:
|
||||
raise AuthorityResolutionError("authority field without name", table=table)
|
||||
out[name] = f
|
||||
return out
|
||||
|
||||
|
||||
def columns(table):
|
||||
"""权威全列集(有序元组)。"""
|
||||
return tuple(field_map(table).keys())
|
||||
|
||||
|
||||
def auto_increment_columns(table):
|
||||
"""由数据库自增生成的列(INSERT 时不得出现)。"""
|
||||
return tuple(n for n, f in field_map(table).items()
|
||||
if f.get("auto_increment") or str(f.get("default", "")).upper()
|
||||
in ("AUTO_INCREMENT", "IDENTITY"))
|
||||
|
||||
|
||||
def insert_columns(table):
|
||||
"""可显式写入的列 = 全列 - 自增列(消费方 INSERT 的列契约)。"""
|
||||
gen = set(auto_increment_columns(table))
|
||||
return tuple(n for n in columns(table) if n not in gen)
|
||||
|
||||
|
||||
def primary(table):
|
||||
summary = authority_model(table).get("summary") or []
|
||||
if not summary or not isinstance(summary[0], dict):
|
||||
raise AuthorityResolutionError("authority model has no summary[0]", table=table)
|
||||
pk = summary[0].get("primary") or summary[0].get("key")
|
||||
if isinstance(pk, str):
|
||||
pk = [p.strip() for p in pk.split(",") if p.strip()]
|
||||
return tuple(pk or ())
|
||||
|
||||
|
||||
def indexes(table):
|
||||
"""规范化索引:``[{'name','idxtype','idxfields'}]``(兼容 unique/fields 写法)。"""
|
||||
out = []
|
||||
for idx in authority_model(table).get("indexes") or []:
|
||||
fields = idx.get("idxfields") or idx.get("fields") or []
|
||||
if isinstance(fields, str):
|
||||
fields = [f.strip() for f in fields.split(",") if f.strip()]
|
||||
kind = idx.get("idxtype") or ("unique" if idx.get("unique") else "index")
|
||||
out.append({"name": idx.get("name"), "idxtype": kind, "idxfields": tuple(fields)})
|
||||
return out
|
||||
|
||||
|
||||
def table_title(table):
|
||||
summary = authority_model(table).get("summary") or [{}]
|
||||
return summary[0].get("title") or summary[0].get("name") or table
|
||||
|
||||
|
||||
def _render_type(f, dialect):
|
||||
t = str(f.get("type") or "").lower()
|
||||
table = _MYSQL_TYPES if dialect == "mysql" else _SQLITE_TYPES
|
||||
tpl = table.get(t)
|
||||
if tpl is None:
|
||||
raise AuthorityResolutionError("unknown abstract type in authority model",
|
||||
field=f.get("name"), type=t)
|
||||
if "{" in tpl:
|
||||
length = f.get("length") or (255 if t == "str" else 10)
|
||||
dec = f.get("dec") or 2
|
||||
return tpl.format(length=length, dec=dec)
|
||||
return tpl
|
||||
|
||||
|
||||
def _nullable(f):
|
||||
if "nullable" in f:
|
||||
return str(f.get("nullable")).lower() != "no"
|
||||
if "null" in f:
|
||||
v = f.get("null")
|
||||
return v is True or str(v).lower() == "true"
|
||||
return True
|
||||
|
||||
|
||||
def ddl(table, dialect="sqlite"):
|
||||
"""由权威列集渲染 CREATE TABLE(测试/本地起库用)。
|
||||
|
||||
生产库的 DDL(分区、append-only 触发器、存储过程)归 pbl_runtime_ext 所有,
|
||||
本函数**只保证列集与主键/唯一键一致**,不复制分区与触发器逻辑。
|
||||
"""
|
||||
if dialect not in ("sqlite", "mysql"):
|
||||
raise AuthorityResolutionError("unsupported ddl dialect", dialect=dialect)
|
||||
model = authority_model(table)
|
||||
lines = []
|
||||
for f in model["fields"]:
|
||||
col = "`%s`" % f["name"] if dialect == "mysql" else f["name"]
|
||||
piece = [" %s %s" % (col, _render_type(f, dialect))]
|
||||
gen = bool(f.get("auto_increment"))
|
||||
if dialect == "mysql" and gen:
|
||||
piece.append(" AUTO_INCREMENT")
|
||||
if not _nullable(f) and not gen:
|
||||
piece.append(" NOT NULL")
|
||||
default = f.get("default")
|
||||
if default is not None and not gen and str(default) != "":
|
||||
piece.append(" DEFAULT %s" % ("CURRENT_TIMESTAMP"
|
||||
if str(default).upper() == "CURRENT_TIMESTAMP"
|
||||
else "'%s'" % default))
|
||||
if dialect == "mysql" and f.get("title"):
|
||||
piece.append(" COMMENT '%s'" % str(f["title"]).replace("'", "''"))
|
||||
lines.append("".join(piece))
|
||||
pk = primary(table)
|
||||
if pk:
|
||||
pk_sql = ", ".join(("`%s`" % c) if dialect == "mysql" else c for c in pk)
|
||||
lines.append(" PRIMARY KEY (%s)" % pk_sql)
|
||||
for idx in indexes(table):
|
||||
if not idx["idxfields"] or idx["name"] in (None, "PRIMARY"):
|
||||
continue
|
||||
cols = ", ".join(("`%s`" % c) if dialect == "mysql" else c
|
||||
for c in idx["idxfields"])
|
||||
if idx["idxtype"] == "unique":
|
||||
lines.append(" UNIQUE KEY `%s` (%s)" % (idx["name"], cols)
|
||||
if dialect == "mysql"
|
||||
else " UNIQUE (%s)" % cols)
|
||||
else:
|
||||
lines.append(" KEY `%s` (%s)" % (idx["name"], cols))
|
||||
body = ",\n".join(lines)
|
||||
if dialect == "mysql":
|
||||
return ("CREATE TABLE IF NOT EXISTS `%s` (\n%s\n) "
|
||||
"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='%s'"
|
||||
% (table, body, table_title(table)))
|
||||
return "CREATE TABLE IF NOT EXISTS %s (\n%s\n)" % (table, body)
|
||||
|
||||
|
||||
def column_diff(table, used_columns):
|
||||
"""双向列集 diff:返回 (未知列, 未覆盖的非自增列)。
|
||||
|
||||
``used_columns`` 是消费方实际写入/读取用到的列名集合;空集合表示「不检查覆盖」。
|
||||
"""
|
||||
used = set(used_columns or ())
|
||||
all_cols = set(columns(table))
|
||||
unknown = sorted(used - all_cols)
|
||||
uncovered = []
|
||||
if used:
|
||||
covered = used | set(auto_increment_columns(table))
|
||||
uncovered = sorted(all_cols - covered)
|
||||
return unknown, uncovered
|
||||
@ -3,27 +3,38 @@
|
||||
|
||||
对外唯一入口::func:`write_event_with_state`。
|
||||
|
||||
**列契约来源(M11b-2b / PM 裁决方案 B)**:本文件不再自带列名常量,全部列名由
|
||||
:mod:`world_sync.pbl_runtime_authority` 从 ``key=schema`` 真源
|
||||
(``modules/pbl_runtime_ext/models/pbl_runtime_event.json`` /
|
||||
``pbl_entity_state.json``)机械导出。列名与真源不一致时 :func:`_assert_contract`
|
||||
在建 SQL 之前就抛 :class:`AuthorityResolutionError`(fail-closed),杜绝「代码里
|
||||
抄一套列名、JSON 里另一套」的双写分叉。
|
||||
|
||||
事务内动作(顺序固定,全部在同一事务中):
|
||||
1. ``BEGIN``(sqlite 用 ``BEGIN IMMEDIATE``,立刻取写锁,避免锁升级竞态);
|
||||
2. 会话级 ``READ COMMITTED``(MySQL/PG;sqlite 无此概念,跳过);
|
||||
3. 读 ``pbl_entity_state`` 当前 ``state_version``,MySQL/PG 追加 ``FOR UPDATE`` 悲观行锁;
|
||||
3. 读 ``pbl_entity_state`` 当前 ``state_version``(按 tenant_id + session_id +
|
||||
entity_id 定位,与真源唯一键 ``uk_es`` 完全一致),MySQL/PG 追加 ``FOR UPDATE``;
|
||||
4. 乐观锁校验:调用方给了 ``expected_version`` 且不匹配 → 抛
|
||||
:class:`ConcurrentStateConflict` 并 ROLLBACK(防丢失更新);
|
||||
5. 乐观锁 ``UPDATE ... WHERE state_version = <读到的版本>``,
|
||||
``rowcount != 1`` 视为并发冲突 → ROLLBACK + :class:`ConcurrentStateConflict`;
|
||||
状态行不存在时 INSERT 新行(主键冲突同样映射为并发冲突);
|
||||
状态行不存在时 INSERT 新行(唯一键冲突同样映射为并发冲突);
|
||||
6. 向 ``pbl_runtime_event`` 插入 1 条 append-only 事件(带第 5 步生效后的新版本号);
|
||||
7. ``COMMIT``;任何一步失败 → ``ROLLBACK`` + 明确异常(不吞错误)。
|
||||
|
||||
原子性保证:第 6 步失败会把第 5 步的更新一起回滚(同一事务);第 5 步冲突则两表都 0 变更。
|
||||
范围之外(明确不做):广播、轮询兜底、时延优化、事件回放。
|
||||
范围之外(明确不做):广播、轮询兜底、时延优化、事件回放(广播/幂等派生键归
|
||||
pbl_runtime_ext.tx_write,本模块只做「一次事务写两表」的最小写入器)。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from . import pbl_runtime_sql as ps
|
||||
from . import pbl_runtime_authority as auth
|
||||
from .pbl_runtime_errors import (
|
||||
ConcurrentStateConflict,
|
||||
EventWriteError,
|
||||
@ -36,27 +47,60 @@ from .pbl_runtime_tx_env import resolve_conn_factory
|
||||
__all__ = [
|
||||
"write_event_with_state",
|
||||
"read_entity_state",
|
||||
"new_event_uid",
|
||||
"new_event_id",
|
||||
"utc_now_text",
|
||||
"DEFAULT_EVENT_TYPE",
|
||||
"DEFAULT_SOURCE",
|
||||
"EVENT_REQUIRED",
|
||||
"STATE_KEY_COLUMNS",
|
||||
]
|
||||
|
||||
DEFAULT_EVENT_TYPE = "state.updated"
|
||||
DEFAULT_SOURCE = "world_sync.m11b2"
|
||||
MAX_ERROR_DETAIL = 512
|
||||
|
||||
# 事件必填列(真源中 nullable=no 且无默认值的业务列)
|
||||
EVENT_REQUIRED = ("tenant_id", "event_uid", "entity_id", "event_type")
|
||||
# 实体状态定位键 —— 与真源 pbl_entity_state 的 uk_es 唯一键一致
|
||||
STATE_KEY_COLUMNS = ("tenant_id", "session_id", "entity_id")
|
||||
|
||||
def new_event_id():
|
||||
"""事件主键(uuid4 文本,append-only 事件无需有序 ID)。"""
|
||||
|
||||
def new_event_uid():
|
||||
"""事件业务唯一键 ``event_uid``(uuid4 去横线 hex,32 位,append-only 无需有序 ID)。"""
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
# 旧名字保留为别名:M11b-2a 的调用方/测试用 new_event_id()
|
||||
new_event_id = new_event_uid
|
||||
|
||||
|
||||
def utc_now_text(ts=None):
|
||||
"""ISO8601 UTC 秒级时间戳文本(sqlite TEXT / MySQL DATETIME(3) 均可直接写入)。"""
|
||||
if ts is None:
|
||||
ts = time.time()
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(ts))
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(ts))
|
||||
|
||||
|
||||
def _checksum(state_json_text):
|
||||
"""状态摘要(真源 ``checksum`` 列,str 32):sha256 前 32 位十六进制。"""
|
||||
return hashlib.sha256(str(state_json_text).encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
|
||||
def _coerce_id(value, column):
|
||||
"""真源中 ``world_id`` / ``scene_id`` / ``session_id`` / ``created_by`` 是 long。
|
||||
|
||||
纯数字(含数字字符串)转 int 落库;非数字标识符原样透传(由 DB 侧/上层迁移
|
||||
决定是否扩列),但会在返回前做一次可序列化性检查——不静默丢列、不悄悄改类型。
|
||||
"""
|
||||
if value is None or isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
text = str(value).strip()
|
||||
if text.isdigit() or (text.startswith("-") and text[1:].isdigit()):
|
||||
return int(text)
|
||||
return value
|
||||
|
||||
|
||||
def _dumps(value):
|
||||
@ -85,6 +129,16 @@ def _require(ctx, name):
|
||||
return value
|
||||
|
||||
|
||||
def _assert_contract(table, used):
|
||||
"""机械核对:本模块用到的列必须全部存在于真源列集,否则立刻抛错(fail-closed)。"""
|
||||
unknown, _uncovered = auth.column_diff(table, used)
|
||||
if unknown:
|
||||
raise auth.AuthorityResolutionError(
|
||||
"consumer uses columns absent from authority schema",
|
||||
table=table, unknown=unknown)
|
||||
return True
|
||||
|
||||
|
||||
def _execute(cur, sql, params, dialect):
|
||||
"""统一执行入口:sqlite 用位置参数,MySQL 用 %s 位置参数(驱动一致)。"""
|
||||
if params is None:
|
||||
@ -92,10 +146,11 @@ def _execute(cur, sql, params, dialect):
|
||||
return cur.execute(sql, tuple(params))
|
||||
|
||||
|
||||
def _fetch_state_version(cur, dialect, tenant_id, world_id, entity_id):
|
||||
def _fetch_state_version(cur, dialect, tenant_id, session_id, entity_id):
|
||||
"""读当前状态版本;行不存在返回 ``None``(首次写入)。"""
|
||||
_assert_contract(ps.STATE_TABLE, STATE_KEY_COLUMNS + ("state_version",))
|
||||
sql = (
|
||||
"SELECT state_version FROM %s WHERE tenant_id = %s AND world_id = %s "
|
||||
"SELECT state_version FROM %s WHERE tenant_id = %s AND session_id = %s "
|
||||
"AND entity_id = %s%s"
|
||||
% (
|
||||
ps.STATE_TABLE,
|
||||
@ -105,7 +160,7 @@ def _fetch_state_version(cur, dialect, tenant_id, world_id, entity_id):
|
||||
ps.for_update_clause(dialect),
|
||||
)
|
||||
)
|
||||
_execute(cur, sql, [tenant_id, world_id, entity_id], dialect)
|
||||
_execute(cur, sql, [tenant_id, session_id, entity_id], dialect)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
@ -115,13 +170,17 @@ def _fetch_state_version(cur, dialect, tenant_id, world_id, entity_id):
|
||||
raise StateWriteError("state_version is not an integer", row=repr(row))
|
||||
|
||||
|
||||
def _update_state(cur, dialect, tenant_id, world_id, entity_id, state_json,
|
||||
current_version, next_version, event_id, now_text):
|
||||
"""乐观锁更新状态行;返回 ``'updated'`` / ``'inserted'``。冲突抛异常。"""
|
||||
def _update_state(cur, dialect, tenant_id, session_id, entity_id, state_json,
|
||||
current_version, next_version, updated_by, now_text):
|
||||
"""乐观锁更新状态行;返回 ``'updated'``。冲突抛异常。"""
|
||||
used = STATE_KEY_COLUMNS + ("state_json", "state_version", "updated_at",
|
||||
"updated_by", "checksum")
|
||||
_assert_contract(ps.STATE_TABLE, used)
|
||||
sql = (
|
||||
"UPDATE %s SET state = %s, state_version = %s, updated_at = %s, "
|
||||
"updated_by_event = %s "
|
||||
"WHERE tenant_id = %s AND world_id = %s AND entity_id = %s AND state_version = %s"
|
||||
"UPDATE %s SET state_json = %s, state_version = %s, updated_at = %s, "
|
||||
"updated_by = %s, checksum = %s "
|
||||
"WHERE tenant_id = %s AND session_id = %s AND entity_id = %s "
|
||||
"AND state_version = %s"
|
||||
% (
|
||||
ps.STATE_TABLE,
|
||||
ps.placeholder(dialect, 0),
|
||||
@ -132,11 +191,12 @@ def _update_state(cur, dialect, tenant_id, world_id, entity_id, state_json,
|
||||
ps.placeholder(dialect, 5),
|
||||
ps.placeholder(dialect, 6),
|
||||
ps.placeholder(dialect, 7),
|
||||
ps.placeholder(dialect, 8),
|
||||
)
|
||||
)
|
||||
params = [
|
||||
state_json, next_version, now_text, event_id,
|
||||
tenant_id, world_id, entity_id, current_version,
|
||||
state_json, next_version, now_text, updated_by, _checksum(state_json),
|
||||
tenant_id, session_id, entity_id, current_version,
|
||||
]
|
||||
try:
|
||||
_execute(cur, sql, params, dialect)
|
||||
@ -144,7 +204,7 @@ def _update_state(cur, dialect, tenant_id, world_id, entity_id, state_json,
|
||||
_rollback_quiet(cur)
|
||||
raise StateWriteError(
|
||||
"failed to update entity state: %s" % _truncate(exc),
|
||||
tenant_id=tenant_id, world_id=world_id, entity_id=entity_id,
|
||||
tenant_id=tenant_id, session_id=session_id, entity_id=entity_id,
|
||||
)
|
||||
if getattr(cur, "rowcount", 0) == 1:
|
||||
return "updated"
|
||||
@ -154,34 +214,41 @@ def _update_state(cur, dialect, tenant_id, world_id, entity_id, state_json,
|
||||
_rollback_quiet(cur)
|
||||
raise ConcurrentStateConflict(
|
||||
"optimistic lock failed: state_version changed concurrently",
|
||||
tenant_id=tenant_id, world_id=world_id, entity_id=entity_id,
|
||||
tenant_id=tenant_id, session_id=session_id, entity_id=entity_id,
|
||||
expected_version=current_version, target_version=next_version,
|
||||
)
|
||||
|
||||
|
||||
def _insert_state(cur, dialect, tenant_id, world_id, entity_id, state_json,
|
||||
next_version, event_id, now_text):
|
||||
"""首次写入状态行;主键冲突说明并发插入,同样按冲突处理。"""
|
||||
cols = ("tenant_id", "world_id", "entity_id", "state", "state_version",
|
||||
"updated_at", "updated_by_event")
|
||||
def _insert_state(cur, dialect, tenant_id, session_id, entity_id, state_json,
|
||||
next_version, updated_by, now_text):
|
||||
"""首次写入状态行;唯一键冲突说明并发插入,同样按冲突处理。"""
|
||||
cols = ("tenant_id", "session_id", "entity_id", "state_json", "state_version",
|
||||
"checksum", "updated_by", "created_at", "updated_at")
|
||||
_assert_contract(ps.STATE_TABLE, cols)
|
||||
sql = "INSERT INTO %s (%s) VALUES (%s)" % (
|
||||
ps.STATE_TABLE, ", ".join(cols), ps.render_placeholders(dialect, len(cols))
|
||||
)
|
||||
params = [tenant_id, world_id, entity_id, state_json, next_version, now_text, event_id]
|
||||
params = [tenant_id, session_id, entity_id, state_json, next_version,
|
||||
_checksum(state_json), updated_by, now_text, now_text]
|
||||
try:
|
||||
_execute(cur, sql, params, dialect)
|
||||
except Exception as exc:
|
||||
_rollback_quiet(cur)
|
||||
raise ConcurrentStateConflict(
|
||||
"concurrent insert of entity state row: %s" % _truncate(exc),
|
||||
tenant_id=tenant_id, world_id=world_id, entity_id=entity_id,
|
||||
tenant_id=tenant_id, session_id=session_id, entity_id=entity_id,
|
||||
)
|
||||
return "inserted"
|
||||
|
||||
|
||||
def _insert_event(cur, dialect, record):
|
||||
"""插入 append-only 事件行。失败即抛(上层回滚),保证「事件失败 → 状态也回滚」。"""
|
||||
"""插入 append-only 事件行(列集 = 真源可写列)。失败即抛(上层回滚)。"""
|
||||
cols = ps.EVENT_COLUMNS
|
||||
_assert_contract(ps.EVENT_TABLE, cols)
|
||||
missing = [c for c in EVENT_REQUIRED if record.get(c) in (None, "")]
|
||||
if missing:
|
||||
raise TxConfigError("event record missing required authority columns",
|
||||
missing=missing)
|
||||
sql = "INSERT INTO %s (%s) VALUES (%s)" % (
|
||||
ps.EVENT_TABLE, ", ".join(cols), ps.render_placeholders(dialect, len(cols))
|
||||
)
|
||||
@ -192,13 +259,13 @@ def _insert_event(cur, dialect, record):
|
||||
_rollback_quiet(cur)
|
||||
raise EventWriteError(
|
||||
"failed to insert runtime event: %s" % _truncate(exc),
|
||||
event_id=record["event_id"], entity_id=record["entity_id"],
|
||||
event_uid=record["event_uid"], entity_id=record["entity_id"],
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _rollback_quiet(cur):
|
||||
"""回滚,但绝不因回滚本身的异常覆盖业务异常(回滚失败另抛 TxAbortError 由调用处判断)。"""
|
||||
"""回滚,但绝不因回滚本身的异常覆盖业务异常。"""
|
||||
try:
|
||||
cur.connection.rollback()
|
||||
except Exception:
|
||||
@ -228,21 +295,24 @@ def _set_isolation(cur, dialect):
|
||||
return False
|
||||
|
||||
|
||||
def read_entity_state(conn_factory=None, tenant_id=None, world_id=None, entity_id=None,
|
||||
conn=None):
|
||||
def read_entity_state(conn_factory=None, tenant_id=None, session_id=None,
|
||||
entity_id=None, world_id=None, conn=None):
|
||||
"""只读辅助:取实体当前状态(供测试断言 / 上层重试前重读版本)。
|
||||
|
||||
独立短事务,不与写入事务共用,避免长事务。
|
||||
独立短事务,不与写入事务共用,避免长事务。``world_id`` 仅作兼容入参(真源
|
||||
``pbl_entity_state`` 无该列,定位键是 tenant_id + session_id + entity_id)。
|
||||
"""
|
||||
own_conn = conn is None
|
||||
if own_conn:
|
||||
conn = resolve_conn_factory(conn_factory)()
|
||||
dialect = ps.detect_dialect(conn)
|
||||
_assert_contract(ps.STATE_TABLE, STATE_KEY_COLUMNS + ("state_json", "state_version",
|
||||
"updated_at"))
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
sql = (
|
||||
"SELECT state, state_version, updated_at FROM %s "
|
||||
"WHERE tenant_id = %s AND world_id = %s AND entity_id = %s"
|
||||
"SELECT state_json, state_version, updated_at FROM %s "
|
||||
"WHERE tenant_id = %s AND session_id = %s AND entity_id = %s"
|
||||
% (
|
||||
ps.STATE_TABLE,
|
||||
ps.placeholder(dialect, 0),
|
||||
@ -250,7 +320,7 @@ def read_entity_state(conn_factory=None, tenant_id=None, world_id=None, entity_i
|
||||
ps.placeholder(dialect, 2),
|
||||
)
|
||||
)
|
||||
_execute(cur, sql, [tenant_id, world_id, entity_id], dialect)
|
||||
_execute(cur, sql, [tenant_id, session_id, entity_id], dialect)
|
||||
row = cur.fetchone()
|
||||
if own_conn:
|
||||
conn.commit()
|
||||
@ -267,23 +337,26 @@ def read_entity_state(conn_factory=None, tenant_id=None, world_id=None, entity_i
|
||||
|
||||
|
||||
def write_event_with_state(event_data=None, state_update=None, conn_factory=None,
|
||||
conn=None, event_id=None, source=None, clock=None):
|
||||
conn=None, event_uid=None, source=None, clock=None):
|
||||
"""在**同一个数据库事务**内写入 1 条运行时事件 + 实体状态更新(M11b-2 核心)。
|
||||
|
||||
:param event_data: dict,事件字段。必须含 ``tenant_id`` / ``world_id`` / ``entity_id``;
|
||||
可选 ``event_type``(默认 ``state.updated``)、``payload``、``session_id``、
|
||||
``causation_id``、``source``。
|
||||
:param state_update: dict,状态更新。必须含 ``tenant_id`` / ``world_id`` / ``entity_id``;
|
||||
``state`` 为新的完整状态(dict 或 JSON 文本);可选 ``expected_version``
|
||||
(乐观锁期望的当前版本,不匹配即冲突回滚)。传 ``None`` 表示只写事件不改状态。
|
||||
:param event_data: dict,事件字段。必须含 ``tenant_id`` / ``entity_id`` /
|
||||
``event_type``;``event_uid`` 缺省由本函数生成(uuid4 hex)。可选
|
||||
``payload``、``session_id``、``scene_id``、``world_id``、``actor_id``、
|
||||
``causation_id``、``idem_key``、``seq``/``seq_no``、``tx_group``、``source``。
|
||||
:param state_update: dict,状态更新。必须含 ``tenant_id`` / ``session_id`` /
|
||||
``entity_id``;``state``(或 ``state_json``)为新的完整状态(dict 或 JSON 文本);
|
||||
可选 ``expected_version``(乐观锁期望当前版本,不匹配即冲突回滚)、``updated_by``。
|
||||
传 ``None`` 表示只写事件不改状态。
|
||||
:param conn_factory: 三级优先级第 1 级(显式注入);也可传 DB URL 字符串。
|
||||
:param conn: 复用已有连接(测试 / 宿主事务嵌套用),此时由本函数负责该连接的
|
||||
commit/rollback,调用方不要自行 commit。
|
||||
:param event_id: 指定事件主键(默认 uuid4 hex),便于幂等重试与因果链测试。
|
||||
:param source: 写入来源标识(默认 ``world_sync.m11b2``)。
|
||||
:param event_uid: 指定事件唯一键(默认 uuid4 hex),便于幂等重试与因果链测试。
|
||||
:param source: 写入来源标识(默认 ``world_sync.m11b2``,落真源 ``source`` 列)。
|
||||
:param clock: 可注入时间函数(默认 ``time.time``),便于测试固定时间戳。
|
||||
:returns: dict,含 ``event_id`` / ``state_version`` / ``action``(updated|inserted|
|
||||
event_only)/ ``elapsed_ms`` / ``isolation_level_applied`` / ``dialect``。
|
||||
:returns: dict,含 ``event_uid`` / ``state_version`` / ``action``(updated|inserted|
|
||||
event_only)/ ``elapsed_ms`` / ``isolation_level_applied`` / ``dialect`` /
|
||||
``authority``(本次列契约的真源路径,便于审计与 QC 复核)。
|
||||
:raises TxConfigError: 必填字段缺失、方言不认识;
|
||||
:raises ConcurrentStateConflict: 乐观锁/行锁冲突(两表零变更,已回滚);
|
||||
:raises EventWriteError: 事件插入失败(两表零变更,已回滚);
|
||||
@ -295,23 +368,29 @@ def write_event_with_state(event_data=None, state_update=None, conn_factory=None
|
||||
clock = clock or time.time
|
||||
|
||||
tenant_id = _require(event_data, "tenant_id")
|
||||
world_id = _require(event_data, "world_id")
|
||||
entity_id = _require(event_data, "entity_id")
|
||||
event_type = event_data.get("event_type") or DEFAULT_EVENT_TYPE
|
||||
event_uid = event_uid or event_data.get("event_uid") or new_event_uid()
|
||||
session_id = event_data.get("session_id")
|
||||
world_id = _coerce_id(event_data.get("world_id"), "world_id")
|
||||
|
||||
if state_update is not None:
|
||||
for key in ("tenant_id", "world_id", "entity_id"):
|
||||
for key in STATE_KEY_COLUMNS:
|
||||
if key in state_update and state_update[key] is not None \
|
||||
and key in event_data and event_data[key] is not None \
|
||||
and state_update[key] != event_data[key]:
|
||||
raise TxConfigError(
|
||||
"state_update.%s conflicts with event_data.%s" % (key, key),
|
||||
field=key,
|
||||
)
|
||||
state_update.setdefault(key, event_data[key])
|
||||
state_update.setdefault(key, event_data.get(key))
|
||||
for key in STATE_KEY_COLUMNS:
|
||||
_require(state_update, key)
|
||||
session_id = state_update["session_id"]
|
||||
|
||||
# 租户/世界隔离维度在 SQL 的 WHERE 与 INSERT 列里都强制带上,
|
||||
# 跨租户的同名 entity_id 不可能互相影响(见 _fetch_state_version / _update_state)。
|
||||
expected_version = None
|
||||
new_state_json = None
|
||||
updated_by = None
|
||||
if state_update is not None:
|
||||
expected_version = state_update.get("expected_version")
|
||||
if expected_version is not None:
|
||||
@ -320,7 +399,11 @@ def write_event_with_state(event_data=None, state_update=None, conn_factory=None
|
||||
except (TypeError, ValueError):
|
||||
raise TxConfigError("expected_version must be an integer",
|
||||
expected_version=expected_version)
|
||||
new_state_json = _dumps(state_update.get("state", {}))
|
||||
new_state_json = _dumps(state_update.get("state",
|
||||
state_update.get("state_json", {})))
|
||||
updated_by = str(state_update.get("updated_by")
|
||||
or event_data.get("actor_id") or source
|
||||
or DEFAULT_SOURCE)[:32]
|
||||
|
||||
factory = resolve_conn_factory(conn_factory) if conn is None else None
|
||||
own_conn = conn is None
|
||||
@ -328,7 +411,6 @@ def write_event_with_state(event_data=None, state_update=None, conn_factory=None
|
||||
|
||||
dialect = ps.detect_dialect(conn)
|
||||
now_text = utc_now_text(clock())
|
||||
event_id = event_id or new_event_id()
|
||||
source = source or event_data.get("source") or DEFAULT_SOURCE
|
||||
started = time.perf_counter()
|
||||
|
||||
@ -338,47 +420,62 @@ def write_event_with_state(event_data=None, state_update=None, conn_factory=None
|
||||
_begin(conn, cur, dialect)
|
||||
isolation_applied = _set_isolation(cur, dialect)
|
||||
|
||||
current_version = _fetch_state_version(cur, dialect, tenant_id, world_id, entity_id)
|
||||
current_version = _fetch_state_version(cur, dialect, tenant_id, session_id,
|
||||
entity_id)
|
||||
|
||||
if state_update is not None and current_version is not None \
|
||||
and expected_version is not None and expected_version != current_version:
|
||||
_rollback_quiet(cur)
|
||||
raise ConcurrentStateConflict(
|
||||
"expected_version does not match stored state_version",
|
||||
tenant_id=tenant_id, world_id=world_id, entity_id=entity_id,
|
||||
tenant_id=tenant_id, session_id=session_id, entity_id=entity_id,
|
||||
expected_version=expected_version, actual_version=current_version,
|
||||
)
|
||||
|
||||
if current_version is None:
|
||||
base_version = 0
|
||||
else:
|
||||
base_version = current_version
|
||||
base_version = 0 if current_version is None else current_version
|
||||
next_version = base_version + 1
|
||||
|
||||
# (b) 先更新实体状态:失败/冲突即回滚,事件也不会留下(原子性)。
|
||||
if state_update is None:
|
||||
action = "event_only"
|
||||
elif current_version is None:
|
||||
action = _insert_state(cur, dialect, tenant_id, world_id, entity_id,
|
||||
new_state_json, next_version, event_id, now_text)
|
||||
action = _insert_state(cur, dialect, tenant_id, session_id, entity_id,
|
||||
new_state_json, next_version, updated_by, now_text)
|
||||
else:
|
||||
action = _update_state(cur, dialect, tenant_id, world_id, entity_id,
|
||||
action = _update_state(cur, dialect, tenant_id, session_id, entity_id,
|
||||
new_state_json, base_version, next_version,
|
||||
event_id, now_text)
|
||||
updated_by, now_text)
|
||||
|
||||
# (a) 再插入 append-only 事件;此步失败会把上面的状态变更一并回滚。
|
||||
payload_text = _dumps(event_data.get("payload",
|
||||
state_update.get("state")
|
||||
if state_update is not None else {}))
|
||||
seq_no = event_data.get("seq_no", event_data.get("seq"))
|
||||
record = {
|
||||
"event_id": event_id,
|
||||
"tenant_id": tenant_id,
|
||||
"world_id": world_id,
|
||||
"session_id": event_data.get("session_id"),
|
||||
"entity_id": entity_id,
|
||||
"event_type": event_data.get("event_type") or DEFAULT_EVENT_TYPE,
|
||||
"payload": _dumps(event_data.get("payload", state_update or {})),
|
||||
"causation_id": event_data.get("causation_id"),
|
||||
"source": source,
|
||||
"event_uid": str(event_uid),
|
||||
"event_code": event_data.get("event_code") or "",
|
||||
"idem_key": event_data.get("idem_key") or "",
|
||||
"world_id": world_id if world_id is not None else 0,
|
||||
"scene_id": _coerce_id(event_data.get("scene_id"), "scene_id") or 0,
|
||||
"session_id": _coerce_id(session_id, "session_id") or 0,
|
||||
"entity_id": str(entity_id),
|
||||
"actor_id": str(event_data.get("actor_id") or updated_by or "")[:64],
|
||||
"event_type": str(event_type),
|
||||
"payload": payload_text,
|
||||
"payload_json": payload_text,
|
||||
"causation_id": str(event_data.get("causation_id") or ""),
|
||||
"seq": int(seq_no) if seq_no not in (None, "") else 0,
|
||||
"seq_no": int(seq_no) if seq_no not in (None, "") else 0,
|
||||
"source": str(source)[:32],
|
||||
"state": str(event_data.get("state") or "applied")[:16],
|
||||
"state_version": next_version if state_update is not None else base_version,
|
||||
"tx_group": str(event_data.get("tx_group") or ""),
|
||||
"broadcast": 1 if event_data.get("broadcast", 1) else 0,
|
||||
"created_by": _coerce_id(event_data.get("created_by"), "created_by") or 0,
|
||||
"created_at": now_text,
|
||||
"updated_at": now_text,
|
||||
"occurred_at": now_text,
|
||||
}
|
||||
_insert_event(cur, dialect, record)
|
||||
|
||||
@ -388,18 +485,24 @@ def write_event_with_state(event_data=None, state_update=None, conn_factory=None
|
||||
except Exception as exc:
|
||||
_rollback_quiet(cur)
|
||||
raise TxAbortError("commit failed: %s" % _truncate(exc),
|
||||
event_id=event_id, entity_id=entity_id)
|
||||
event_uid=event_uid, entity_id=entity_id)
|
||||
|
||||
return {
|
||||
"event_id": event_id,
|
||||
"event_uid": event_uid,
|
||||
"event_id": event_uid, # 兼容旧调用方的返回键名
|
||||
"tenant_id": tenant_id,
|
||||
"world_id": world_id,
|
||||
"session_id": session_id,
|
||||
"entity_id": entity_id,
|
||||
"state_version": record["state_version"],
|
||||
"action": action,
|
||||
"created_at": now_text,
|
||||
"dialect": dialect,
|
||||
"isolation_level_applied": isolation_applied,
|
||||
"authority": {
|
||||
ps.EVENT_TABLE: auth.authority_path(ps.EVENT_TABLE),
|
||||
ps.STATE_TABLE: auth.authority_path(ps.STATE_TABLE),
|
||||
},
|
||||
"elapsed_ms": round((time.perf_counter() - started) * 1000.0, 3),
|
||||
}
|
||||
finally:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user