diff --git a/models/pbl_runtime_event.json b/models/pbl_runtime_event.json
index bfd9d8b..d4a32f9 100644
--- a/models/pbl_runtime_event.json
+++ b/models/pbl_runtime_event.json
@@ -5,7 +5,7 @@
"title": "运行时事件流(append-only)",
"primary": ["id", "created_at"],
"catelog": "entity",
- "comment": "【本表定位 · world_sync 侧内联副本】运行时事件流(append-only:禁 UPDATE/DELETE,由权威模块 pbl_runtime_ext 的存储过程 pbl_assert_append_only + BEFORE UPDATE/BEFORE DELETE 触发器强制;按 created_at 做按月 RANGE COLUMNS(created_at) 分区,分区保留 14 个月)。物理主键为复合主键 (id, created_at) —— MySQL/MariaDB 要求分区表的所有唯一键(含主键)必须包含分区列,否则建表报错 1503。【结构权威源 · PM 裁决】PM 裁决(任务 u0EO4x3ajntR41acaHj5H 问答):选 B —— 以 pbl_runtime_ext 为唯一真源;结构权威定义位于 modules/pbl_runtime_ext/models/pbl_runtime_event.json(M11b-1 已批准产物,本任务禁止反向修改),DDL 派生源为该模块 scripts/pbl_runtime_event_ddl.py 与 scripts/migrations/*.sql,建表/分区/触发器归 pbl_runtime_ext 所有,本文件不复制那套逻辑。【为什么是内联而不是引用】database-table-definition-spec 只承认 summary/fields/indexes/codes 四段式;scripts/validate_models_json.py(M11b-2a 子任务 A 修复版)对 key=schema 引用式写法直接判 FAIL(QC #5),故本文件按真源逐字段内联权威列集,作为 world_sync 侧的消费方契约视图;两份定义的一致性由 scripts/m11b2b_column_diff.py 与 scripts/m11b2b_selftest.py 机械核对,真源列集变更时必须同步本副本。【id 类型偏离处理 · 本子任务的选择与理由】真源 id 为 type=long + auto_increment(BIGINT AUTO_INCREMENT,真源侧已在其 summary[0].comment 自行登记该偏离);子任务 A 的最终登记表 COMPLIANCE_DEVIATIONS 为空 {}(A 按 QC #5 选 (a):不放行任何历史失效项),且本任务范围明确禁止修改校验器脚本,因此 world_sync 侧不再走「登记放行 id=long」路线,而是内联为 spec 合规写法 type=str / length=32、不带 auto_increment 键,由应用层生成 32 位 uuid 去横线十六进制串作为主键值(见 world_sync/pbl_runtime_tx.py::new_event_uid,正是 uuid4().hex 32 位)。与真源的 id 类型差异由 world_sync 侧消费方代码适配:写路径 INSERT 不显式提交 id 时交由真源 DDL 的 AUTO_INCREMENT 生成(BIGINT),本地/测试库(scripts 渲染的 sqlite DDL)则使用应用层生成的 str(32);对外幂等与跨系统追溯一律使用 event_uid(str 64,uk_event_uid 唯一键),不依赖 id 的物理类型。此差异为有意选择、非疏漏,评审请勿据此判为结构缺陷。【消费方清单】modules/world_sync/world_sync/pbl_runtime_sql.py(EVENT_TABLE/EVENT_COLUMNS)、modules/world_sync/world_sync/pbl_runtime_tx.py::write_event_with_state(单事务事件+状态写入)、modules/world_sync/world_sync/pbl_runtime_authority.py(列契约解析层);上述代码引用的列名必须全部出现在本文件 fields[] 中。【打包镜像说明】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 断言守护。【私有键剥离说明】真源使用的 spec 未定义顶层扩展键 module/engine/charset/collate/tenant_scoped/append_only/write_paths/guards/partition/ddl_source,以及 fields[].ddl_type/comment/auto_increment、indexes[].comment、codes[].comment,在本副本中一律不保留(本文件根键仅 summary/fields/indexes/codes,summary[0] 仅 name/title/primary/catelog/comment,fields[] 仅 name/title/type/length/dec/nullable/default,indexes[] 仅 name/idxtype/idxfields,codes[] 仅 field/table/valuefield/textfield/cond);这些物理实现细节以真源与其 DDL 派生源为准。"
+ "comment": "【本表定位】world_sync 侧消费方契约副本:运行时事件流(append-only,禁 UPDATE/DELETE;生产库由权威模块 pbl_runtime_ext 的存储过程 pbl_assert_append_only + BEFORE UPDATE/BEFORE DELETE 触发器强制,本副本只声明列契约、不复制那套物理实现)。【结构权威源 · PM 裁决】PM 裁决(任务 u0EO4x3ajntR41acaHj5H)选 B:以 modules/pbl_runtime_ext/models/pbl_runtime_event.json 为唯一真源(M11b-1 已批准产物,本任务禁止反向修改);建表 DDL 派生源为该模块 scripts/pbl_runtime_event_ddl.py 与 scripts/migrations/*.sql。本文件是按真源逐字段内联的 world_sync 侧副本(25 列全量抄录),不是引用式 schema —— database-table-definition-spec 只承认 summary/fields/indexes/codes 四段式,scripts/validate_models_json.py 对 key=schema 引用式写法直接判 FAIL(QC #5),故 QC 要求内联。两份定义的一致性由 scripts/m11b2b_column_diff.py(代码列名 vs 权威列集双向 diff)与 scripts/m11b2b_selftest.py(四段式/抽象类型/主键/索引/编码 6 项断言 + 真源镜像 sha256)机械核对,真源列集变更时必须同步本副本。【id 生成方 · 单一口径,QC #4】本副本声明 id 由应用层生成:world_sync/pbl_runtime_tx.py::new_event_uid()(uuid4().hex,32 位小写十六进制),写路径 INSERT 列集(= 本文件 fields[] 全列,见 pbl_runtime_sql.py::EVENT_COLUMNS 由 fields[] 机械导出)显式提交 id,不依赖任何数据库 AUTO_INCREMENT。真源侧 id 为 type=long + auto_increment(BIGINT AUTO_INCREMENT),属其私有物理实现,本副本按 spec 归一为 str(32) 且不保留 auto_increment 键;两者差异已在交付件正文登记为 DEVIATION(WS-2) 并冒泡 agent.pm 裁决生产库列型收敛方案(改 VARCHAR(32) 或统一走 event_uid 定位),本副本不再同时声称两种生成方。对外幂等与跨系统追溯一律使用 event_uid(str 64,uk_event_uid),不依赖 id 的物理类型。【复合主键偏离 · 已登记,QC #4】summary[0].primary=[\"id\",\"created_at\"] 是对 spec「单键表恒为 [\\\"id\\\"]」的有意偏离:本表按 created_at 做按月 RANGE COLUMNS 分区,MySQL/MariaDB 要求分区表所有唯一键(含主键)必须包含分区列,否则建表报错 1503。真源依据行号:modules/pbl_runtime_ext/models/pbl_runtime_event.json:12(primary=[\"id\",\"created_at\"])、同文件:6(comment 中 1503 约束说明与分区保留 14 个月)、同文件:40(fields[0].id type=long + auto_increment)。本副本不写 partition/guards/engine 等私有键,但主键必须与真源一致,否则 uk_event_uid/uk_tenant_idem(含 created_at)无法在本副本声明的 PK 下成立。该偏离已在 M11b-2b-B 交付件正文登记(DEVIATION(WS-1)),未写进校验器 COMPLIANCE_DEVIATIONS(子任务 A 最终登记表为空 {},且本任务禁止修改校验器)。【私有键剥离】真源使用的 spec 未定义顶层扩展键 module/engine/charset/collate/tenant_scoped/append_only/write_paths/guards/partition/ddl_source,以及 fields[].ddl_type/comment/auto_increment、indexes[].comment、codes[].comment,本副本一律不保留:根键仅 summary/fields/indexes/codes,summary[0] 仅 name/title/primary/catelog/comment,fields[] 仅 name/title/type/length/dec/nullable/default,indexes[] 仅 name/idxtype/idxfields(主键不写进 indexes,由 summary[0].primary 表达),codes[] 仅 field/table/valuefield/textfield/cond。物理细节(分区、触发器、字符集、存储过程)以真源与其 DDL 派生源为准。【消费方清单】modules/world_sync/world_sync/pbl_runtime_sql.py(EVENT_TABLE/EVENT_COLUMNS,由本文件 fields[] 机械导出)、modules/world_sync/world_sync/pbl_runtime_tx.py::write_event_with_state(单事务事件+状态写入)、modules/world_sync/world_sync/pbl_runtime_authority.py(列契约解析层,已适配本内联四段式副本)、modules/world_sync/world_sync/init.py(事件写入门面)。上述代码引用的列名必须全部出现在本文件 fields[] 中,核对证据见 M11b-2b-B 交付件「字段交叉核对清单」。【打包镜像】apps/scense/pkgs/world_sync/models/pbl_runtime_event.json 是本文件的打包镜像,只由 modules/world_sync/scripts/sync_models_mirror.py 生成,禁止手工双写;一致性由 scripts/m11b2b_selftest.py 的 sha256 断言守护。"
}
],
"fields": [
diff --git a/world_sync/pbl_runtime_authority.py b/world_sync/pbl_runtime_authority.py
index e4781d4..e86deab 100644
--- a/world_sync/pbl_runtime_authority.py
+++ b/world_sync/pbl_runtime_authority.py
@@ -1,25 +1,29 @@
# -*- coding: utf-8 -*-
-"""pbl_runtime_authority —— M11b-2b:``key=schema`` 引用的解析层(列名唯一权威源)。
+"""pbl_runtime_authority —— M11b-2b:表定义 JSON 的列契约解析层(列名唯一入口)。
-**为什么存在**
-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 结构体。
+**结构现状(M11b-2b-B 起,QC #1/#2/#3 整改)**
+PM 裁决 B:``pbl_runtime_event`` / ``pbl_entity_state`` 的**结构权威源**是
+``modules/pbl_runtime_ext/models/*.json``(M11b-1 已批准产物,禁止反向修改)。
+但 QC 明确要求 world_sync 侧 ``models/*.json`` 必须是 **database-table-definition-spec
+四段式(summary/fields/indexes/codes)内联副本**,而不是 ``key=schema`` 引用式
+——校验器 ``scripts/validate_models_json.py`` 对引用式写法直接判 FAIL。
+因此本模块现在解析的是「按真源逐字段内联、并按 spec 归一(抽象类型、去私有键)的
+world_sync 侧副本」,它同时就是 world_sync 消费方的列契约(列名与真源一致)。
-于是「消费方列契约」必须从权威定义**机械导出**,而不是人工抄一遍——人工双写正是
-QC #1/#3 判定的「多份互相矛盾定义」的根因。本模块就是那个导出层:
+本模块是列契约的**机械导出层**,不是人工抄写层:
-* :func:`load_reference` —— 读本模块 ``models/
.json``(key=schema 引用);
-* :func:`authority_model` —— 顺着引用解析到 pbl_runtime_ext 的真源 JSON;
+* :func:`load_model` —— 读本模块 ``models/.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`` 所有,本模块不复制那套逻辑)。
+ —— 暴露列契约(``insert_columns`` 自动剔除自增列,兼容真源私有 ``auto_increment`` 键);
+* :func:`authority_path` —— 反查结构权威源(pbl_runtime_ext)在磁盘上的真实路径,
+ 供审计与 QC 取证(写入结果的 ``authority`` 字段);
+* :func:`ddl` —— 由列契约渲染本地建表语句(仅供测试/本地起库;生产 DDL、分区与
+ append-only 触发器归 pbl_runtime_ext 的 ``scripts/pbl_runtime_event_ddl.py``
+ 与 ``scripts/migrations/*.sql`` 所有,本模块不复制那套逻辑);
+* :func:`column_diff` —— 消费方用到的列 vs 列契约的双向 diff(fail-closed 的判据)。
-**fail-closed**:引用缺失、真源找不到、真源不是四段式、列集为空 —— 一律抛
-:class:`AuthorityResolutionError`,绝不退化成「猜列名」。
+**fail-closed**:文件缺失、JSON 不可解析、不是四段式、fields 为空 —— 一律抛
+:class:`AuthorityResolutionError`,绝不退化成「猜列名」或回落到旧的手写常量。
"""
import json
@@ -27,10 +31,13 @@ import os
__all__ = [
"AuthorityResolutionError",
- "REFERENCE_ROOT_KEYS",
- "REFERENCE_SCHEMA_KEYS",
"SPEC_ROOT_KEYS",
- "load_reference",
+ "SPEC_SUMMARY_KEYS",
+ "SPEC_FIELD_KEYS",
+ "SPEC_INDEX_KEYS",
+ "SPEC_CODE_KEYS",
+ "AUTHORITY_MODULE",
+ "load_model",
"authority_path",
"authority_model",
"columns",
@@ -44,15 +51,16 @@ __all__ = [
"column_diff",
]
-# models/.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",
-)
-# 真源(表定义)必须是四段式
+# world_sync 侧 models/.json 必须是 spec 四段式(database-table-definition-spec)
SPEC_ROOT_KEYS = ("summary", "fields", "indexes", "codes")
+# 各段允许的键(白名单,自测脚本按此做机械断言;comment 为 spec 允许的可选说明键)
+SPEC_SUMMARY_KEYS = ("name", "title", "primary", "catelog", "comment")
+SPEC_FIELD_KEYS = ("name", "title", "type", "length", "dec", "nullable", "default")
+SPEC_INDEX_KEYS = ("name", "idxtype", "idxfields")
+SPEC_CODE_KEYS = ("field", "table", "valuefield", "textfield", "cond")
+
+# 结构权威源所在模块(PM 裁决 B;本模块只反查路径,不消费其私有键)
+AUTHORITY_MODULE = "pbl_runtime_ext"
MODULE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MODELS_DIR = os.path.join(MODULE_ROOT, "models")
@@ -77,7 +85,7 @@ _MYSQL_TYPES = {
class AuthorityResolutionError(Exception):
- """引用/真源解析失败(fail-closed,调用方不得吞掉)。"""
+ """模型缺失/不可解析/非四段式(fail-closed,调用方不得吞掉)。"""
def __init__(self, message, **details):
Exception.__init__(self, message)
@@ -91,61 +99,83 @@ class AuthorityResolutionError(Exception):
return "%s (%s)" % (self.message, parts)
-def _reference_dir():
+def _models_dir():
return MODELS_DIR
-def load_reference(table):
- """读取本模块 ``models/.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")
+def _read_json(path):
if not os.path.isfile(path):
- raise AuthorityResolutionError("schema reference missing", path=path)
+ raise AuthorityResolutionError("model file missing", path=path)
try:
with open(path, "r", encoding="utf-8") as fh:
- ref = json.load(fh)
+ data = 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("model not valid JSON: %s" % exc, path=path)
+ if not isinstance(data, dict):
+ raise AuthorityResolutionError("model must be a JSON object", path=path)
+ return data
+
+
+_MODEL_CACHE = {}
+
+
+def load_model(table):
+ """读取并校验本模块 ``models/.json``(spec 四段式内联副本)。
+
+ 校验项(fail-closed):根键含 summary/fields/indexes/codes;summary 恰好 1 条;
+ fields 非空且每列有 name。列集与真源的一致性由 scripts/m11b2b_column_diff.py
+ 与 scripts/m11b2b_selftest.py 机械核对,不在运行期重复解析真源私有键。
+ """
+ if not table or not isinstance(table, str):
+ raise AuthorityResolutionError("table name is required", table=table)
+ if table in _MODEL_CACHE:
+ return _MODEL_CACHE[table]
+ path = os.path.join(_models_dir(), table + ".json")
+ model = _read_json(path)
+ missing = [k for k in SPEC_ROOT_KEYS if k not in model]
+ if missing:
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
+ "model is not database-table-definition-spec four-section",
+ path=path, missing=missing,
+ hint="world_sync 侧必须是内联四段式副本(PM 裁决 B + QC #1/#5),不得用 key=schema 引用式")
+ summary = model.get("summary")
+ if not isinstance(summary, list) or len(summary) != 1 or not isinstance(summary[0], dict):
+ raise AuthorityResolutionError("summary must be an array with exactly 1 record",
+ path=path, got=type(summary).__name__)
+ if not str(summary[0].get("name") or "").strip():
+ raise AuthorityResolutionError("summary[0].name is required", path=path)
+ fields = model.get("fields")
+ if not isinstance(fields, list) or not fields:
+ raise AuthorityResolutionError("model has no fields", path=path)
+ for f in fields:
+ if not isinstance(f, dict) or not str(f.get("name") or "").strip():
+ raise AuthorityResolutionError("field without name", path=path, field=f)
+ _MODEL_CACHE[table] = model
+ return model
+
+
+# 兼容旧调用名:本模块过去暴露 authority_model(解析引用指向的真源),现在列契约
+# 就是内联副本本身,语义等价。
+def authority_model(table):
+ """列契约模型(= world_sync 侧内联四段式副本,逐字段抄自 pbl_runtime_ext 真源)。"""
+ return load_model(table)
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")))
+ """反查结构权威源(pbl_runtime_ext)在磁盘上的真实路径,供审计取证。
+
+ 只用于写入结果里的 ``authority`` 字段与 QC 核对,**不参与列集解析**(列集来自
+ 本模块内联副本)。找不到真源不抛错,返回声明式相对路径(真源缺失属部署期问题,
+ 不应让运行时写入失败)。
+ """
+ declared = os.path.join("modules", AUTHORITY_MODULE, "models", table + ".json")
+ candidates = [
+ os.path.normpath(os.path.join(WORKSPACE_ROOT, declared)),
+ os.path.normpath(os.path.join(MODULE_ROOT, os.pardir, AUTHORITY_MODULE,
+ "models", table + ".json")),
+ os.path.join(WORKSPACE_ROOT, "apps", "scense", "pkgs", AUTHORITY_MODULE,
+ "models", table + ".json"),
+ ]
tried = []
for cand in candidates:
if cand in tried:
@@ -153,55 +183,28 @@ def authority_path(table):
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
+ return declared
def field_map(table):
- """``{列名: 列定义 dict}``(保持真源顺序)。"""
+ """``{列名: 列定义 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
+ for f in load_model(table)["fields"]:
+ out[f["name"]] = f
return out
def columns(table):
- """权威全列集(有序元组)。"""
+ """权威全列集(有序元组)—— 消费方 SQL 里允许出现的列名以此为唯一白名单。"""
return tuple(field_map(table).keys())
def auto_increment_columns(table):
- """由数据库自增生成的列(INSERT 时不得出现)。"""
+ """由数据库自增生成的列(INSERT 时不得出现)。
+
+ 内联副本按 spec 剥离了 ``auto_increment`` 私有键,故通常为空集;保留该逻辑是为了
+ 当列契约指向真源(其 id 带 auto_increment)时仍能正确剔除该列。
+ """
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"))
@@ -214,29 +217,38 @@ def insert_columns(table):
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)
+ """summary[0].primary(非空数组)。主键不写进 indexes(spec 约定)。"""
+ summary = load_model(table).get("summary") or [{}]
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 ())
+ if not isinstance(pk, list) or not pk:
+ raise AuthorityResolutionError("model summary[0].primary must be a non-empty array",
+ table=table)
+ cols = set(columns(table))
+ unknown = [p for p in pk if p not in cols]
+ if unknown:
+ raise AuthorityResolutionError("primary references unknown column",
+ table=table, unknown=unknown)
+ return tuple(pk)
def indexes(table):
- """规范化索引:``[{'name','idxtype','idxfields'}]``(兼容 unique/fields 写法)。"""
+ """二级索引/唯一键列表:``[{name, idxtype, idxfields}]``(兼容真源 unique/fields 写法)。"""
out = []
- for idx in authority_model(table).get("indexes") or []:
+ for idx in (load_model(table).get("indexes") or []):
+ if not isinstance(idx, dict):
+ raise AuthorityResolutionError("index must be an object", table=table, index=idx)
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)})
+ idxtype = idx.get("idxtype") or ("unique" if idx.get("unique") else "index")
+ out.append({"name": idx.get("name"), "idxtype": idxtype, "idxfields": list(fields)})
return out
def table_title(table):
- summary = authority_model(table).get("summary") or [{}]
+ summary = load_model(table).get("summary") or [{}]
return summary[0].get("title") or summary[0].get("name") or table
@@ -264,21 +276,19 @@ def _nullable(f):
def ddl(table, dialect="sqlite"):
- """由权威列集渲染 CREATE TABLE(测试/本地起库用)。
+ """由列契约渲染 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)
+ model = load_model(table)
+ gen = set(auto_increment_columns(table))
lines = []
for f in model["fields"]:
- col = "`%s`" % f["name"] if dialect == "mysql" else f["name"]
+ 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")
diff --git a/world_sync/pbl_runtime_sql.py b/world_sync/pbl_runtime_sql.py
index 6bf192e..dafc571 100644
--- a/world_sync/pbl_runtime_sql.py
+++ b/world_sync/pbl_runtime_sql.py
@@ -2,8 +2,15 @@
"""M11b-2 运行时单事务写入 —— SQL 方言层(纯 SQL 文本 + 占位符,不绑定任何 ORM/驱动)。
职责边界:本模块只负责「把 SQL 写对」——方言识别、占位符风格、READ COMMITTED 设置语句、
-悲观锁子句、以及两张表的建表 DDL(与 M11b-1 的 pbl_runtime_event schema 对齐)。
-真正的事务编排在 :mod:`world_sync.pbl_runtime_tx`。
+悲观锁子句、以及两张表的建表 DDL。真正的事务编排在 :mod:`world_sync.pbl_runtime_tx`。
+
+**列集单一真源(M11b-2b-B 起,QC #3 整改)**
+``EVENT_COLUMNS`` / ``STATE_COLUMNS`` 不再手写常量,而是从
+``modules/world_sync/models/pbl_runtime_event.json`` / ``pbl_entity_state.json``
+(spec 四段式内联副本,结构权威源 = pbl_runtime_ext,PM 裁决 B)的 ``fields[]``
+**机械导出**。旧版在本文件里手抄的 ``event_id`` / ``world_id``(state 表) / ``state``
+/ ``updated_by_event`` 等列名在真源中不存在,构成「代码一套、JSON 另一套」的双写分叉,
+是 QC #3 判定的消费方契约断裂根因;现在这类漂移在结构上不可能再发生。
设计要点:
1. 用 DB-API 2.0 规范接口(``paramstyle`` / ``execute`` / ``rowcount`` / ``commit`` /
@@ -12,11 +19,15 @@
``pyformat`` → ``%(name)s``),避免在 MySQL 上写出 sqlite 的 ``?``;
3. ``SELECT ... FOR UPDATE`` 只有 MySQL/PG 支持,sqlite 侧返回空串(sqlite 写锁由事务本身保证),
并发正确性在两条路径上都由「乐观锁 state_version」兜底;
-4. 不引入任何广播 / 轮询 / 时延优化逻辑(明确排除在 M11b-2 范围之外)。
+4. 不引入任何广播 / 轮询 / 时延优化逻辑(明确排除在 M11b-2 范围之外);
+5. 建表 DDL 由 :func:`world_sync.pbl_runtime_authority.ddl` 按同一份模型渲染,
+ 本模块不再自带第二套列定义(生产库的分区/触发器 DDL 归 pbl_runtime_ext 所有)。
"""
import sqlite3
+from . import pbl_runtime_authority as auth
+
__all__ = [
"EVENT_TABLE",
"STATE_TABLE",
@@ -34,6 +45,7 @@ __all__ = [
"ddl_for",
"EVENT_COLUMNS",
"STATE_COLUMNS",
+ "columns_of",
"percentile",
]
@@ -44,30 +56,24 @@ DIALECT_SQLITE = "sqlite"
DIALECT_MYSQL = "mysql"
DIALECT_POSTGRES = "postgres"
-# 与 M11b-1 dev-notes-m11b1-pbl_runtime_event.md 的表定义对齐(append-only,无 updated_at)。
-EVENT_COLUMNS = (
- "event_id", # 事件主键(uuid/ULID,由调用方或本模块生成)
- "tenant_id", # 租户,强制打头,缺失即拒绝写入
- "world_id", # 世界隔离维度,强制
- "session_id", # 运行时会话(可空)
- "entity_id", # 关联实体
- "event_type", # 事件类型(如 state.updated)
- "payload", # 事件体(JSON 文本)
- "causation_id", # 因果链上游事件(可空)
- "source", # 写入来源标识(模块名/引擎版本)
- "state_version", # 本事件落库后实体的状态版本(与 pbl_entity_state 一致,便于回放)
- "created_at", # 服务端时间戳(TEXT ISO8601 UTC)
-)
-STATE_COLUMNS = (
- "tenant_id",
- "world_id",
- "entity_id",
- "state", # 实体状态(JSON 文本)
- "state_version", # 乐观锁版本号,每次成功写入 +1
- "updated_at",
- "updated_by_event",
-)
+def columns_of(table):
+ """取某表在模型 JSON(spec 四段式内联副本)里声明的全列集(有序)。
+
+ fail-closed:模型缺失 / 不是四段式 / fields 为空,一律由
+ :mod:`world_sync.pbl_runtime_authority` 抛
+ :class:`~world_sync.pbl_runtime_authority.AuthorityResolutionError`,不退回旧常量。
+ """
+ return auth.columns(table)
+
+
+# append-only 事件流列集(= models/pbl_runtime_event.json fields[] 顺序,25 列,含应用层
+# 生成的 id;id 生成方见 world_sync/pbl_runtime_tx.py::new_event_uid 与该文件 summary[0].comment)
+EVENT_COLUMNS = columns_of(EVENT_TABLE)
+
+# 实体状态列集(= models/pbl_entity_state.json fields[] 顺序,10 列;定位键是
+# tenant_id + session_id + entity_id,对应唯一键 uk_es,真源无 world_id / state 列)
+STATE_COLUMNS = columns_of(STATE_TABLE)
def detect_dialect(conn_or_module):
@@ -162,80 +168,18 @@ def supports_transaction_control(dialect):
def _ddl_sqlite():
- """返回 sqlite 建表语句列表(单测 / 本地环境用)。"""
- return [
- """
- CREATE TABLE IF NOT EXISTS %s (
- event_id TEXT PRIMARY KEY,
- tenant_id TEXT NOT NULL,
- world_id TEXT NOT NULL,
- session_id TEXT,
- entity_id TEXT NOT NULL,
- event_type TEXT NOT NULL,
- payload TEXT NOT NULL,
- causation_id TEXT,
- source TEXT,
- state_version INTEGER NOT NULL,
- created_at TEXT NOT NULL
- )
- """ % EVENT_TABLE,
- """
- CREATE INDEX IF NOT EXISTS idx_pbl_runtime_event_world_time
- ON %s (tenant_id, world_id, created_at)
- """ % EVENT_TABLE,
- """
- CREATE INDEX IF NOT EXISTS idx_pbl_runtime_event_entity
- ON %s (tenant_id, world_id, entity_id, state_version)
- """ % EVENT_TABLE,
- """
- CREATE TABLE IF NOT EXISTS %s (
- tenant_id TEXT NOT NULL,
- world_id TEXT NOT NULL,
- entity_id TEXT NOT NULL,
- state TEXT NOT NULL,
- state_version INTEGER NOT NULL DEFAULT 0,
- updated_at TEXT NOT NULL,
- updated_by_event TEXT,
- PRIMARY KEY (tenant_id, world_id, entity_id)
- )
- """ % STATE_TABLE,
- ]
+ """sqlite 建表语句列表(单测 / 本地环境用),由模型 JSON 渲染,无第二套列定义。"""
+ return [auth.ddl(EVENT_TABLE, DIALECT_SQLITE), auth.ddl(STATE_TABLE, DIALECT_SQLITE)]
def _ddl_mysql():
- """返回 MySQL 建表语句列表(生产环境用,InnoDB + utf8mb4)。"""
- return [
- """
- CREATE TABLE IF NOT EXISTS `%s` (
- `event_id` VARCHAR(64) NOT NULL COMMENT '事件主键',
- `tenant_id` VARCHAR(64) NOT NULL COMMENT '租户ID(强制)',
- `world_id` VARCHAR(64) NOT NULL COMMENT '世界ID(隔离维度)',
- `session_id` VARCHAR(64) DEFAULT NULL COMMENT '运行时会话ID',
- `entity_id` VARCHAR(64) NOT NULL COMMENT '关联实体ID',
- `event_type` VARCHAR(64) NOT NULL COMMENT '事件类型',
- `payload` JSON NOT NULL COMMENT '事件体',
- `causation_id` VARCHAR(64) DEFAULT NULL COMMENT '因果链上游事件',
- `source` VARCHAR(64) DEFAULT NULL COMMENT '写入来源',
- `state_version` BIGINT NOT NULL COMMENT '落库后实体状态版本',
- `created_at` DATETIME(3) NOT NULL COMMENT '服务端时间戳',
- PRIMARY KEY (`event_id`),
- KEY `idx_pbl_runtime_event_world_time` (`tenant_id`, `world_id`, `created_at`),
- KEY `idx_pbl_runtime_event_entity` (`tenant_id`, `world_id`, `entity_id`, `state_version`)
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL运行时事件(append-only,M11b-1)'
- """ % EVENT_TABLE,
- """
- CREATE TABLE IF NOT EXISTS `%s` (
- `tenant_id` VARCHAR(64) NOT NULL COMMENT '租户ID(强制)',
- `world_id` VARCHAR(64) NOT NULL COMMENT '世界ID(隔离维度)',
- `entity_id` VARCHAR(64) NOT NULL COMMENT '实体ID',
- `state` JSON NOT NULL COMMENT '实体状态',
- `state_version` BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
- `updated_at` DATETIME(3) NOT NULL COMMENT '更新时间',
- `updated_by_event` VARCHAR(64) DEFAULT NULL COMMENT '最近生效事件',
- PRIMARY KEY (`tenant_id`, `world_id`, `entity_id`)
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL实体状态(单事务写入,M11b-2)'
- """ % STATE_TABLE,
- ]
+ """MySQL 建表语句列表(本地/预发用,InnoDB + utf8mb4),同样由模型 JSON 渲染。
+
+ 生产库真正的 ``pbl_runtime_event`` DDL(按月 RANGE COLUMNS 分区、append-only 存储过程
+ 与触发器)归 pbl_runtime_ext 的 ``scripts/pbl_runtime_event_ddl.py`` 与
+ ``scripts/migrations/*.sql`` 所有,本函数不复制那套物理细节。
+ """
+ return [auth.ddl(EVENT_TABLE, DIALECT_MYSQL), auth.ddl(STATE_TABLE, DIALECT_MYSQL)]
#: 各方言建表语句集合,供宿主初始化 / 单测建库使用。