storepy 3.0.0__tar.gz → 3.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {storepy-3.0.0/src/storepy.egg-info → storepy-3.2.0}/PKG-INFO +2 -2
- {storepy-3.0.0 → storepy-3.2.0}/pyproject.toml +2 -2
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/__init__.py +74 -3
- storepy-3.2.0/src/py_store/feedback.py +102 -0
- storepy-3.2.0/src/py_store/metadef.py +272 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/schema.py +15 -3
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/workflow.py +9 -1
- {storepy-3.0.0 → storepy-3.2.0/src/storepy.egg-info}/PKG-INFO +2 -2
- {storepy-3.0.0 → storepy-3.2.0}/src/storepy.egg-info/SOURCES.txt +2 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/storepy.egg-info/requires.txt +1 -1
- storepy-3.2.0/tests/test_metadef.py +366 -0
- {storepy-3.0.0 → storepy-3.2.0}/tests/test_workflow.py +25 -0
- storepy-3.0.0/src/py_store/feedback.py +0 -41
- {storepy-3.0.0 → storepy-3.2.0}/LICENSE +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/README.md +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/setup.cfg +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/ask.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/ask_knowledge.md +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/core.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/crud/__init__.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/crud/exec.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/crud/id.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/crud/mutation.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/crud/query.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/crud/write.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/datasource.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/ddl.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/executors/__init__.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/executors/_values.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/executors/mongo.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/executors/mysql.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/executors/postgres.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/executors/sqlite.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/introspect/__init__.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/introspect/mysql.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/introspect/postgres.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/introspect/sqlite.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/llm.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/permission.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/py_store/sync.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/storepy.egg-info/dependency_links.txt +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/src/storepy.egg-info/top_level.txt +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/tests/test_ask.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/tests/test_coverage_margin.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/tests/test_federation_e2e.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/tests/test_host_contract.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/tests/test_host_paths.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/tests/test_migration.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/tests/test_mongo_executor.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/tests/test_mongo_session.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/tests/test_multi_datasource.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/tests/test_profile.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/tests/test_py_store.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/tests/test_raw_sql_and_ddl.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/tests/test_rbac_host.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/tests/test_real_backends_e2e.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/tests/test_require_context.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/tests/test_scenario_course_platform.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/tests/test_scenario_manager_transaction.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/tests/test_session.py +0 -0
- {storepy-3.0.0 → storepy-3.2.0}/tests/test_sql_commit_and_registry.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: storepy
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.2.0
|
|
4
4
|
Summary: Multi-backend data layer for Python asyncio (MongoDB, MySQL, SQLite, PostgreSQL): pure JSON schemas, GQL tree queries compiled to a single native query, GROUP BY/HAVING aggregation, computed columns, soft-delete and role-based access control
|
|
5
5
|
Author-email: leo <coen_ddt@qq.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -24,7 +24,7 @@ Requires-Python: >=3.10
|
|
|
24
24
|
Description-Content-Type: text/markdown
|
|
25
25
|
License-File: LICENSE
|
|
26
26
|
Requires-Dist: pymongo>=4.9
|
|
27
|
-
Requires-Dist: rust-store-py<4.0.0,>=3.
|
|
27
|
+
Requires-Dist: rust-store-py<4.0.0,>=3.2.0
|
|
28
28
|
Provides-Extra: mysql
|
|
29
29
|
Requires-Dist: asyncmy>=0.2.9; extra == "mysql"
|
|
30
30
|
Provides-Extra: postgres
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "storepy"
|
|
7
|
-
version = "3.
|
|
7
|
+
version = "3.2.0"
|
|
8
8
|
description = "Multi-backend data layer for Python asyncio (MongoDB, MySQL, SQLite, PostgreSQL): pure JSON schemas, GQL tree queries compiled to a single native query, GROUP BY/HAVING aggregation, computed columns, soft-delete and role-based access control"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -25,7 +25,7 @@ authors = [
|
|
|
25
25
|
]
|
|
26
26
|
dependencies = [
|
|
27
27
|
"pymongo>=4.9",
|
|
28
|
-
"rust-store-py>=3.
|
|
28
|
+
"rust-store-py>=3.2.0,<4.0.0",
|
|
29
29
|
]
|
|
30
30
|
classifiers = [
|
|
31
31
|
"Development Status :: 4 - Beta",
|
|
@@ -30,6 +30,7 @@ from . import (
|
|
|
30
30
|
datasource,
|
|
31
31
|
ddl,
|
|
32
32
|
feedback,
|
|
33
|
+
metadef,
|
|
33
34
|
permission,
|
|
34
35
|
schema,
|
|
35
36
|
workflow,
|
|
@@ -195,10 +196,64 @@ class Store:
|
|
|
195
196
|
"""从已注册 schema def 生成指定后端 DDL 文本(纯函数,不连库、不回写;铁律 6)"""
|
|
196
197
|
return ddl.generate(backend, names)
|
|
197
198
|
|
|
199
|
+
# ── meta-store 定义控制面(定义持久化与版本化;见 metadef.py)──
|
|
200
|
+
async def persistDef(self, defn: dict, opts: dict | None = None) -> dict:
|
|
201
|
+
"""持久化定义(同名同形幂等,异形 version+1;A1/A2)"""
|
|
202
|
+
return await metadef.persist_def(self, defn, opts)
|
|
203
|
+
|
|
204
|
+
async def listDefs(self, opts: dict | None = None) -> list[dict]:
|
|
205
|
+
"""列定义行(按 version desc;name 缺省列全部)"""
|
|
206
|
+
return await metadef.list_defs(self, opts or {})
|
|
207
|
+
|
|
208
|
+
async def loadDefs(self, opts: dict | None = None) -> list[dict]:
|
|
209
|
+
"""各 name 的最新 active 行"""
|
|
210
|
+
return await metadef.load_defs(self, opts or {})
|
|
211
|
+
|
|
212
|
+
async def restoreDefs(self, opts: dict | None = None) -> dict:
|
|
213
|
+
"""从持久化定义重建注册表:schema + workflow 两类(网关 reload 重装配前调用)"""
|
|
214
|
+
base = opts or {}
|
|
215
|
+
s = await metadef.restore_defs(self, {**base, 'kind': 'schema'})
|
|
216
|
+
w = await metadef.restore_defs(self, {**base, 'kind': 'workflow'})
|
|
217
|
+
return {'total': s['total'] + w['total'], 'applied': s['applied'] + w['applied']}
|
|
218
|
+
|
|
219
|
+
async def rollbackTo(self, opts: dict) -> dict:
|
|
220
|
+
"""回滚到历史版本(追加式,重新 register 该版本 defn)"""
|
|
221
|
+
return await metadef.rollback_to(self, opts)
|
|
222
|
+
|
|
223
|
+
# 定义控制面:workflow 定义(kind=workflow;见 metadef.py)
|
|
224
|
+
async def persistWorkflowDef(self, defn: dict, opts: dict | None = None) -> dict:
|
|
225
|
+
"""持久化 workflow 定义(同名同形幂等,异形 version+1)"""
|
|
226
|
+
return await metadef.persist_def(self, defn, {**(opts or {}), 'kind': 'workflow'})
|
|
227
|
+
|
|
228
|
+
async def listWorkflowDefs(self, opts: dict | None = None) -> list[dict]:
|
|
229
|
+
"""列 workflow 定义行(按 version desc;name 缺省列全部)"""
|
|
230
|
+
return await metadef.list_defs(self, {**(opts or {}), 'kind': 'workflow'})
|
|
231
|
+
|
|
232
|
+
async def loadWorkflowDefs(self, opts: dict | None = None) -> list[dict]:
|
|
233
|
+
"""各 name 的最新 active workflow 定义行"""
|
|
234
|
+
return await metadef.load_defs(self, {**(opts or {}), 'kind': 'workflow'})
|
|
235
|
+
|
|
236
|
+
async def rollbackWorkflowTo(self, opts: dict) -> dict:
|
|
237
|
+
"""回滚 workflow 定义到历史版本(追加式)"""
|
|
238
|
+
return await metadef.rollback_to(self, {**(opts or {}), 'kind': 'workflow'})
|
|
239
|
+
|
|
240
|
+
def ensureBuiltins(self) -> None:
|
|
241
|
+
"""自举内建定义表 __schemaDef/__workflowDef(幂等)"""
|
|
242
|
+
return metadef.ensure_builtins()
|
|
243
|
+
|
|
244
|
+
# ── 反馈事件落库(A6;见 feedback.py)──
|
|
245
|
+
def enableFeedbackTable(self):
|
|
246
|
+
"""一键接线:注册内建 __feedback 并把 sink 指向落库;返回 disposer(恢复原 sink)"""
|
|
247
|
+
return feedback.enable_feedback_table(self)
|
|
248
|
+
|
|
249
|
+
def setFeedbackMeta(self, tenant: str = '', env: str = '') -> None:
|
|
250
|
+
"""注入进程级 ns 标签(tenant/env),供落库事件附加(进程级隔离下天然单 ns)"""
|
|
251
|
+
feedback.set_meta({'tenant': tenant, 'env': env})
|
|
252
|
+
|
|
198
253
|
# ── 工作流编排(首批:线性 + when 守卫 + fail-fast;见 workflow.py 与设计文档)──
|
|
199
|
-
def registerWorkflow(self, defn: dict) -> dict:
|
|
200
|
-
"""
|
|
201
|
-
return workflow.register(defn)
|
|
254
|
+
def registerWorkflow(self, defn: dict, ctx: dict | None = None) -> dict:
|
|
255
|
+
"""注册工作流定义(可选 ``ctx`` 过定义层门禁;默认 Open。白名单外显式 Err 含 WORKFLOW_UNSUPPORTED)"""
|
|
256
|
+
return workflow.register(defn, ctx)
|
|
202
257
|
|
|
203
258
|
def workflows(self, ctx: dict | None = None) -> list[str]:
|
|
204
259
|
"""全部可见工作流名(read 白名单过滤)"""
|
|
@@ -239,6 +294,19 @@ class Store:
|
|
|
239
294
|
register_workflow = registerWorkflow
|
|
240
295
|
run_workflow = runWorkflow
|
|
241
296
|
get_workflow = getWorkflow
|
|
297
|
+
# 定义控制面(蛇形别名与上方驼峰同实现)
|
|
298
|
+
persist_def = persistDef
|
|
299
|
+
list_defs = listDefs
|
|
300
|
+
load_defs = loadDefs
|
|
301
|
+
restore_defs = restoreDefs
|
|
302
|
+
rollback_to = rollbackTo
|
|
303
|
+
persist_workflow_def = persistWorkflowDef
|
|
304
|
+
list_workflow_defs = listWorkflowDefs
|
|
305
|
+
load_workflow_defs = loadWorkflowDefs
|
|
306
|
+
rollback_workflow_to = rollbackWorkflowTo
|
|
307
|
+
ensure_builtins = ensureBuiltins
|
|
308
|
+
enable_feedback_table = enableFeedbackTable
|
|
309
|
+
set_feedback_meta = setFeedbackMeta
|
|
242
310
|
|
|
243
311
|
# ── 其余 API 显式绑定(staticmethod:避免实例化后 self 注入)──
|
|
244
312
|
# Schema 管理
|
|
@@ -288,6 +356,9 @@ class Store:
|
|
|
288
356
|
set_deny_write_roles = staticmethod(permission.set_deny_write_roles)
|
|
289
357
|
setUnconfiguredPolicy = staticmethod(permission.set_unconfigured_policy)
|
|
290
358
|
set_unconfigured_policy = staticmethod(permission.set_unconfigured_policy)
|
|
359
|
+
# 定义层门禁策略(判决唯一在 core):closed 时仅 internal/白名单可注册或覆盖
|
|
360
|
+
setMetaPolicy = staticmethod(schema.set_meta_policy)
|
|
361
|
+
set_meta_policy = staticmethod(schema.set_meta_policy)
|
|
291
362
|
# 查询档位(判决唯一在 core):standard 默认放开 / text2query 功能收缩
|
|
292
363
|
# 进入档即等效强制 ctx;未知档由 core 抛 ValueError 上抛(禁静默回落默认档)
|
|
293
364
|
setProfile = staticmethod(schema.set_profile)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""反馈事件通道 —— 兜底/降级/拦截触发的统一出口
|
|
2
|
+
|
|
3
|
+
事件形状(对齐 rust-store 联邦 degraded 契约)::
|
|
4
|
+
|
|
5
|
+
{type, code, layer, message, hint, ...}
|
|
6
|
+
- type : 事件类别(federation_degraded / sql_pushdown_unsupported ...)
|
|
7
|
+
- code : 机器可读代码(crossSourceSort / pushdownUnsupported ...)
|
|
8
|
+
- layer : 命中的防护层(federation / dialect ...)
|
|
9
|
+
- message: 人可读描述
|
|
10
|
+
- hint : 修复指引(供上游排查/加固)
|
|
11
|
+
|
|
12
|
+
默认无 sink 时打 stderr(向后兼容);宿主可 ``set_sink(fn)`` 接管,
|
|
13
|
+
接入自动反馈闭环(允许被拦截,禁止静默失守)。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
_sink = None
|
|
21
|
+
|
|
22
|
+
# 进程级 ns 标签(进程级隔离下天然单 ns;由宿主 set_meta 注入)
|
|
23
|
+
_meta = {'tenant': '', 'env': ''}
|
|
24
|
+
|
|
25
|
+
# 落库失败累计计数(进程级;>0 表示有事件未入表——可观测,不静默)
|
|
26
|
+
_fail_count = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_sink():
|
|
30
|
+
"""当前 sink(None = 默认 stderr 行为);供接管方保存原值、退出时恢复(token-set/reset 同构)"""
|
|
31
|
+
return _sink
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def set_sink(fn):
|
|
35
|
+
"""注册反馈事件回调 ``fn(event: dict)``;传 None 恢复默认 stderr 行为"""
|
|
36
|
+
global _sink
|
|
37
|
+
_sink = fn
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def set_meta(meta):
|
|
41
|
+
"""注入进程级 ns 标签(tenant/env),供落库 sink 附加到事件"""
|
|
42
|
+
global _meta
|
|
43
|
+
_meta = {**_meta, **(meta or {})}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def fail_count():
|
|
47
|
+
"""落库失败累计计数(进程级;>0 表示有事件未入表——可观测,不静默)"""
|
|
48
|
+
return _fail_count
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _fail(msg):
|
|
52
|
+
global _fail_count
|
|
53
|
+
_fail_count += 1
|
|
54
|
+
print(f'[py-store][feedback] {msg}', file=sys.stderr)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def _write_feedback(store, row):
|
|
58
|
+
"""落库(internal 上下文——__feedback write 空名单仅放行 internal/admin);失败不静默、不抛回 emit"""
|
|
59
|
+
from . import metadef as _metadef
|
|
60
|
+
try:
|
|
61
|
+
with _metadef._internal_ctx():
|
|
62
|
+
await store.insert('__feedback', row)
|
|
63
|
+
except Exception as e: # 落库失败:stderr + 计数,绝不抛回 emit(不破坏主链路)
|
|
64
|
+
_fail(f'__feedback 落库失败: {e}')
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def enable_feedback_table(store):
|
|
68
|
+
"""一键接线:注册内建 ``__feedback`` 并把 sink 指向落库;返回 disposer(恢复原 sink)
|
|
69
|
+
|
|
70
|
+
落库为异步 fire-and-forget(有运行中事件循环时 ``create_task``);失败走 stderr + 计数,
|
|
71
|
+
绝不抛回 ``emit``。未调用本函数时 ``emit`` 保持原 stderr 行为(不改变默认语义)。
|
|
72
|
+
"""
|
|
73
|
+
from . import metadef as _metadef
|
|
74
|
+
_metadef.ensure_builtins() # 幂等(含 __feedback)
|
|
75
|
+
prev = get_sink()
|
|
76
|
+
|
|
77
|
+
def _sink(event):
|
|
78
|
+
row = {**(event or {}), 'tenant': _meta.get('tenant', ''),
|
|
79
|
+
'env': _meta.get('env', ''), 'now': int(time.time() * 1000)}
|
|
80
|
+
try:
|
|
81
|
+
loop = asyncio.get_running_loop()
|
|
82
|
+
except RuntimeError:
|
|
83
|
+
loop = None
|
|
84
|
+
if loop is not None:
|
|
85
|
+
loop.create_task(_write_feedback(store, row))
|
|
86
|
+
else:
|
|
87
|
+
_fail('无运行中事件循环,__feedback 事件未落库')
|
|
88
|
+
|
|
89
|
+
set_sink(_sink)
|
|
90
|
+
return lambda: set_sink(prev)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def emit(event):
|
|
94
|
+
"""产出一条反馈事件:有 sink 回调之;否则打印 stderr(允许拦截,禁止静默)"""
|
|
95
|
+
if _sink is not None:
|
|
96
|
+
_sink(event)
|
|
97
|
+
return
|
|
98
|
+
print(
|
|
99
|
+
f"[py-store][{event.get('layer', '?')}/{event.get('code', '?')}] "
|
|
100
|
+
f"{event.get('message', '')}(hint: {event.get('hint', '-')})",
|
|
101
|
+
file=sys.stderr,
|
|
102
|
+
)
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""meta-store 定义持久化与版本化(宿主层;core 无 IO 铁律)
|
|
2
|
+
|
|
3
|
+
设计见 doc/execution/2026/10/meta-store定义控制面-01(A1/A2/A3)。三条契约:
|
|
4
|
+
- 定义落库为内建 schema ``__schemaDef``(tenant/env/name/version/defn 五要素);
|
|
5
|
+
- 同名同形幂等不新增行,异形 version+1;
|
|
6
|
+
- 版本历史可列(append-only),``rollbackTo`` 以历史 defn **追加新版本**(跨进程经 ``restore_defs`` 对协议面可见)。
|
|
7
|
+
|
|
8
|
+
对齐 ``nodejs-store/src/metadef.js``(双宿主流库行内容逐字节一致由对拍脚本守护)。
|
|
9
|
+
存储是 IO,故落宿主层,core 零改动。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
|
|
15
|
+
from . import permission as _permission
|
|
16
|
+
from .schema import _to_core_defn
|
|
17
|
+
from .schema import has as _schema_has
|
|
18
|
+
from .schema import register as _schema_register
|
|
19
|
+
from .workflow import register as _register_workflow
|
|
20
|
+
|
|
21
|
+
# 内建定义表名(``__`` 前缀为内建保留名,对齐 workflow 的 name.startswith('__') 校验)
|
|
22
|
+
_SCHEMA_DEF = '__schemaDef'
|
|
23
|
+
_WORKFLOW_DEF = '__workflowDef'
|
|
24
|
+
_FEEDBACK = '__feedback'
|
|
25
|
+
# 读取投影(双端一致;对拍比较用)
|
|
26
|
+
_DEF_FIELDS = '_id, tenant, env, name, version, defn, status, createdBy'
|
|
27
|
+
|
|
28
|
+
# 定义类型(kind)→ 内建表名 / 注册函数(缺省 schema,保既有调用零变更)
|
|
29
|
+
_TABLES = {'schema': _SCHEMA_DEF, 'workflow': _WORKFLOW_DEF}
|
|
30
|
+
# 注册函数:(defn, internal) → 注册到对应注册表;internal 仅供系统重建(restore)使用
|
|
31
|
+
_REGISTRARS = {
|
|
32
|
+
'schema': lambda defn, internal=False: _schema_register(defn, {'internal': True} if internal else None),
|
|
33
|
+
'workflow': lambda defn, internal=False: _register_workflow(defn, {'internal': True} if internal else None),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _kind_of(opts):
|
|
38
|
+
"""解析 kind → ``(kind, table)``;未知 kind 显式 Err(不兜底)"""
|
|
39
|
+
kind = (opts or {}).get('kind') or 'schema'
|
|
40
|
+
table = _TABLES.get(kind)
|
|
41
|
+
if not table:
|
|
42
|
+
raise MetaDefError(f'metadef: 未知定义类型 {kind!r}')
|
|
43
|
+
return kind, table
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _def_model(name, id_prefix):
|
|
47
|
+
"""内建定义表 schema(write 显式空名单:普通角色禁写,防篡改定义审计)
|
|
48
|
+
|
|
49
|
+
不声明 indexes:内建表随宿主注册表进入 `ddl.generate()`,而场景 harness 会对全部
|
|
50
|
+
注册表执行其中的 CREATE INDEX(建表仅限业务表)——为内建表加索引会令其对未建的
|
|
51
|
+
内建表建索引而报错。版本唯一性改由行自然键 ``_id``(见 ``def_id``,D2)在**存储层**
|
|
52
|
+
保证:并发写同版本必触发唯一键冲突,按 §4.4 显式上抛(不重试、不吞)。
|
|
53
|
+
"""
|
|
54
|
+
return {
|
|
55
|
+
'name': name,
|
|
56
|
+
'system': True,
|
|
57
|
+
'collection': name,
|
|
58
|
+
'idPrefix': id_prefix,
|
|
59
|
+
'write': [],
|
|
60
|
+
'fields': {
|
|
61
|
+
'_id': {'type': 'string'},
|
|
62
|
+
'tenant': {'type': 'string'},
|
|
63
|
+
'env': {'type': 'string'},
|
|
64
|
+
'name': {'type': 'string'},
|
|
65
|
+
'version': {'type': 'int'},
|
|
66
|
+
'defn': {'type': 'object'},
|
|
67
|
+
'status': {'type': 'string'},
|
|
68
|
+
'createdBy': {'type': 'string'},
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_SCHEMA_DEF_MODEL = _def_model(_SCHEMA_DEF, 'sdef')
|
|
74
|
+
_WORKFLOW_DEF_MODEL = _def_model(_WORKFLOW_DEF, 'wdef')
|
|
75
|
+
|
|
76
|
+
# 内建反馈事件表(05):承载结构化降级/拦截事件(write 空名单——普通角色禁写,事件审计)
|
|
77
|
+
_FEEDBACK_MODEL = {
|
|
78
|
+
'name': _FEEDBACK,
|
|
79
|
+
'system': True,
|
|
80
|
+
'collection': _FEEDBACK,
|
|
81
|
+
'idPrefix': 'fdbk',
|
|
82
|
+
'write': [],
|
|
83
|
+
'fields': {
|
|
84
|
+
'_id': {'type': 'string'},
|
|
85
|
+
'type': {'type': 'string'},
|
|
86
|
+
'code': {'type': 'string'},
|
|
87
|
+
'layer': {'type': 'string'},
|
|
88
|
+
'message': {'type': 'string'},
|
|
89
|
+
'hint': {'type': 'string'},
|
|
90
|
+
'tenant': {'type': 'string'},
|
|
91
|
+
'env': {'type': 'string'},
|
|
92
|
+
'now': {'type': 'number'},
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def ensure_builtins():
|
|
98
|
+
"""注册内建 ``__schemaDef``/``__workflowDef``/``__feedback``(幂等;core 对重复三元组显式报错,故先 has 守卫)"""
|
|
99
|
+
if not _schema_has(_SCHEMA_DEF):
|
|
100
|
+
_schema_register(_SCHEMA_DEF_MODEL)
|
|
101
|
+
if not _schema_has(_WORKFLOW_DEF):
|
|
102
|
+
_schema_register(_WORKFLOW_DEF_MODEL)
|
|
103
|
+
if not _schema_has(_FEEDBACK):
|
|
104
|
+
_schema_register(_FEEDBACK_MODEL)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class MetaDefError(ValueError):
|
|
108
|
+
"""metadef 契约失败(defn.name 缺失 / 版本不存在 / 唯一冲突)"""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ─── 纯逻辑(双端逐字节等价;parity 锚) ───────────────────────
|
|
112
|
+
|
|
113
|
+
def stable_stringify(value):
|
|
114
|
+
"""稳定序列化(键序无关):与 node ``_stableStringify`` 逐字节等价"""
|
|
115
|
+
return json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def same_defn(a, b):
|
|
119
|
+
"""两 defn 是否同形(深比较;键序无关)"""
|
|
120
|
+
return stable_stringify(a) == stable_stringify(b)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def next_version(rows):
|
|
124
|
+
"""由既有行推下一个版本号(无行 → 1)"""
|
|
125
|
+
return (max(r['version'] for r in rows) + 1) if rows else 1
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def build_def_row(defn, opts, version):
|
|
129
|
+
"""构造待插入行(纯函数,便于双端对拍)。键序与 node ``buildDefRow`` 一致"""
|
|
130
|
+
opts = opts or {}
|
|
131
|
+
return {
|
|
132
|
+
'tenant': opts.get('tenant'),
|
|
133
|
+
'env': opts.get('env'),
|
|
134
|
+
'name': defn['name'],
|
|
135
|
+
'version': version,
|
|
136
|
+
'defn': defn,
|
|
137
|
+
'status': 'active',
|
|
138
|
+
'createdBy': opts.get('actor') or '',
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def def_id(tenant, env, name, version):
|
|
143
|
+
"""定义行自然键 ``_id`` = ``(tenant, env, name, version)``(D2;对齐 node ``defId``)。
|
|
144
|
+
|
|
145
|
+
作为存储层主键,同 ``(tenant,env,name,version)`` 二次写入必触发唯一键冲突
|
|
146
|
+
(SQLite UNIQUE / Mongo E11000),使「读最新行 +1」的并发窗口在存储层收口 ——
|
|
147
|
+
并发写同版本时后到者显式报错(控制面映射 409 CONFLICT),不产重复 version。
|
|
148
|
+
分隔符用 US(``\\u001f``):schema name 不含该控制字符,拼接无歧义。
|
|
149
|
+
"""
|
|
150
|
+
t = '' if tenant is None else str(tenant)
|
|
151
|
+
e = '' if env is None else str(env)
|
|
152
|
+
return f'{t}\u001f{e}\u001f{name}\u001f{version}'
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@contextmanager
|
|
156
|
+
def _internal_ctx():
|
|
157
|
+
"""干净 internal 上下文({internal: True},丢弃触发者 roles)
|
|
158
|
+
|
|
159
|
+
内建表 write 显式空名单仅放行 internal/admin;写入身份与触发者权限解耦,
|
|
160
|
+
对齐 ``workflow._internal_ctx`` 的 __workflowRun 写入约束。
|
|
161
|
+
"""
|
|
162
|
+
token = _permission._ctx.set({'internal': True})
|
|
163
|
+
try:
|
|
164
|
+
yield
|
|
165
|
+
finally:
|
|
166
|
+
_permission._ctx.reset(token)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ─── IO(经传入的 store 面,避免包内循环依赖) ─────────────────
|
|
170
|
+
|
|
171
|
+
async def list_defs(store, opts):
|
|
172
|
+
"""列定义行(按 version desc;name 缺省列全部)"""
|
|
173
|
+
opts = opts or {}
|
|
174
|
+
_, table = _kind_of(opts)
|
|
175
|
+
condition = {'tenant': opts.get('tenant'), 'env': opts.get('env')}
|
|
176
|
+
if opts.get('name') is not None:
|
|
177
|
+
condition['name'] = opts['name']
|
|
178
|
+
gql = f'{table}($condition:@c0, $sort:@s0){{{_DEF_FIELDS}}}'
|
|
179
|
+
return await store.query(gql, {'c0': condition, 's0': {'version': -1}})
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def load_defs(store, opts):
|
|
183
|
+
"""各 name 的**最新 active** 行(每 name 取 version 最大者;version desc 后首见即最新)"""
|
|
184
|
+
opts = opts or {}
|
|
185
|
+
_, table = _kind_of(opts)
|
|
186
|
+
gql = f'{table}($condition:@c0, $sort:@s0){{{_DEF_FIELDS}}}'
|
|
187
|
+
rows = await store.query(gql, {
|
|
188
|
+
'c0': {'tenant': opts.get('tenant'), 'env': opts.get('env'), 'status': 'active'},
|
|
189
|
+
's0': {'version': -1},
|
|
190
|
+
})
|
|
191
|
+
out = []
|
|
192
|
+
seen = set()
|
|
193
|
+
for r in rows:
|
|
194
|
+
if r.get('name') in seen:
|
|
195
|
+
continue
|
|
196
|
+
seen.add(r['name'])
|
|
197
|
+
out.append(r)
|
|
198
|
+
return out
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def persist_def(store, defn, opts):
|
|
202
|
+
"""持久化定义:同名同形返回原行不插入;异形插入 version+1"""
|
|
203
|
+
if not isinstance(defn, dict) or not defn.get('name'):
|
|
204
|
+
raise MetaDefError('metadef: defn.name 必填')
|
|
205
|
+
opts = opts or {}
|
|
206
|
+
_, table = _kind_of(opts)
|
|
207
|
+
name = defn['name']
|
|
208
|
+
rows = await list_defs(store, {'tenant': opts.get('tenant'), 'env': opts.get('env'),
|
|
209
|
+
'name': name, 'kind': opts.get('kind')})
|
|
210
|
+
latest = rows[0] if rows else None
|
|
211
|
+
core_defn = _to_core_defn(defn) # 函数值剔除(纯 JSON 入库,A3 前提)
|
|
212
|
+
if latest is not None and same_defn(latest.get('defn'), core_defn):
|
|
213
|
+
return latest
|
|
214
|
+
version = next_version(rows)
|
|
215
|
+
row = build_def_row(core_defn, opts, version)
|
|
216
|
+
# 自然键 `_id`(D2):同版本并发写必冲突 → 存储层保证 version 唯一
|
|
217
|
+
row['_id'] = def_id(opts.get('tenant'), opts.get('env'), core_defn['name'], version)
|
|
218
|
+
with _internal_ctx():
|
|
219
|
+
return await store.insert(table, row)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# 已重建进注册表的定义自然键(进程级;同名同版本只注册一次,避免每次 reload 全量覆盖)
|
|
223
|
+
_applied: set = set()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def restore_defs(store, opts):
|
|
227
|
+
"""从持久化定义重建注册表(D1 闭环桥):``load_defs`` → 逐条 ``register(defn, internal)``。
|
|
228
|
+
|
|
229
|
+
网关 reload 在重装配前调用本函数,使「控制面 publish(写库)」与「协议面可见(注册)」
|
|
230
|
+
经 reload 衔接。已注册过的同版本跳过(幂等);版本变化时以新 defn 覆盖注册。
|
|
231
|
+
注册走 internal 上下文:属系统重建动作,不受业务定义层门禁(MetaPolicy)影响。
|
|
232
|
+
返回 ``{'total': 库内最新 active 行数, 'applied': 本次新注册数}``。
|
|
233
|
+
"""
|
|
234
|
+
opts = opts or {}
|
|
235
|
+
kind, _ = _kind_of(opts)
|
|
236
|
+
rows = await load_defs(store, opts)
|
|
237
|
+
applied = 0
|
|
238
|
+
for r in rows:
|
|
239
|
+
key = f"{kind}\u001f{def_id(opts.get('tenant'), opts.get('env'), r['name'], r['version'])}"
|
|
240
|
+
if key in _applied:
|
|
241
|
+
continue
|
|
242
|
+
_REGISTRARS[kind](r['defn'], True)
|
|
243
|
+
_applied.add(key)
|
|
244
|
+
applied += 1
|
|
245
|
+
return {'total': len(rows), 'applied': applied}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
async def rollback_to(store, opts):
|
|
249
|
+
"""回滚到历史版本(追加式):取历史行 defn → 作为新版本再发布 → 本进程 register → 返回落库行。
|
|
250
|
+
|
|
251
|
+
D21 跨进程闭环:回滚不再「原地重注册」,而是复用 ``persist_def`` 把历史 defn 落成一条
|
|
252
|
+
**新版本行**(同名同形幂等 → 返回当前最新行)。``load_defs`` 取「最新 active」故必然返回
|
|
253
|
+
该行,网关 ``restore_defs`` hydrate 即按回滚后的 defn 装配 —— 回滚对协议面可见。
|
|
254
|
+
历史保持 append-only:目标历史行不被改写。
|
|
255
|
+
"""
|
|
256
|
+
opts = opts or {}
|
|
257
|
+
kind, _ = _kind_of(opts)
|
|
258
|
+
rows = await list_defs(store, {'tenant': opts.get('tenant'), 'env': opts.get('env'),
|
|
259
|
+
'name': opts.get('name'), 'kind': opts.get('kind')})
|
|
260
|
+
version = opts.get('version')
|
|
261
|
+
row = next((r for r in rows if r.get('version') == version), None)
|
|
262
|
+
if row is None:
|
|
263
|
+
raise MetaDefError(f"metadef: 版本不存在 {opts.get('name')}@{version}")
|
|
264
|
+
# 追加式回滚:以历史 defn 走 persist 语义(异形 → version+1;同形 → 返回当前最新)
|
|
265
|
+
persisted = await persist_def(store, row['defn'], opts)
|
|
266
|
+
# 系统重建动作:workflow 走 internal;schema 维持既有调用形态(False = 无 ctx,门禁行为零变更)
|
|
267
|
+
_REGISTRARS[kind](row['defn'], kind == 'workflow')
|
|
268
|
+
return persisted
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# 模块导入即自举内建定义表(幂等;零配置)
|
|
272
|
+
ensure_builtins()
|
|
@@ -77,15 +77,19 @@ def _to_core_defn(defn):
|
|
|
77
77
|
return walk(defn)
|
|
78
78
|
|
|
79
79
|
|
|
80
|
-
def register(defn):
|
|
80
|
+
def register(defn, ctx=None):
|
|
81
81
|
"""注册一个 schema(core 注册 + Host 侧元数据镜像)
|
|
82
82
|
|
|
83
|
+
``ctx``:可选定义层门禁上下文(``{'userId','roles',...}`` 或 ``{'internal': True}``)。
|
|
84
|
+
返回 ``None`` 兼容既有调用;门禁策略由 ``set_meta_policy`` 配置,默认 Open(全放行)。
|
|
85
|
+
判决唯一在 core(拒绝抛 ``ERR_PERMISSION:`` 前缀错误,定义不变)。
|
|
86
|
+
|
|
83
87
|
归档表 `<Name>Deleted` 由 core 在 register 内**自动派生并注册**
|
|
84
|
-
(rust-store/core/src/schema/registry.rs
|
|
88
|
+
(rust-store/core/src/schema/registry.rs);Host 只补 Host 侧镜像,
|
|
85
89
|
**不再调用 core.register** —— 否则同名条目二次进入 core.order,使 list()/
|
|
86
90
|
generate_ddl() 出现重复表(基线实测 list=['User','UserDeleted','UserDeleted'])。
|
|
87
91
|
"""
|
|
88
|
-
core.
|
|
92
|
+
core.register_with_ctx(_to_core_defn(defn), ctx)
|
|
89
93
|
|
|
90
94
|
# 计算列回调:fn → core 回调桥;asyncFn → Host 侧映射
|
|
91
95
|
computes = {}
|
|
@@ -228,6 +232,14 @@ def set_unconfigured_policy(policy):
|
|
|
228
232
|
core.set_unconfigured_policy(policy)
|
|
229
233
|
|
|
230
234
|
|
|
235
|
+
def set_meta_policy(closed, roles):
|
|
236
|
+
"""定义层门禁策略:`closed=True` 时仅 internal 或 `roles` 白名单可注册/覆盖。
|
|
237
|
+
|
|
238
|
+
判决唯一在 core;默认 Open(`register` 无 ctx 亦放行,保既有兼容)。
|
|
239
|
+
"""
|
|
240
|
+
core.set_meta_policy(bool(closed), roles)
|
|
241
|
+
|
|
242
|
+
|
|
231
243
|
def require_context():
|
|
232
244
|
"""「上下文强制」开关当前值"""
|
|
233
245
|
return core.require_context()
|
|
@@ -300,12 +300,20 @@ def _validate_when(when, errors, where):
|
|
|
300
300
|
_workflows: dict = {}
|
|
301
301
|
|
|
302
302
|
|
|
303
|
-
def register(defn):
|
|
303
|
+
def register(defn, ctx=None):
|
|
304
304
|
"""注册工作流定义(注册即静态校验,白名单外显式 Err;name 全局唯一)。
|
|
305
305
|
|
|
306
|
+
``ctx``:可选定义层门禁上下文(``{'internal': True}`` / ``{'roles': [...]}``)。
|
|
307
|
+
判决唯一在 core(``_core.can_register``,与 ``schema.register`` 同一 MetaPolicy);
|
|
308
|
+
默认 Open → 全放行。Closed 且 ctx 不过 → 抛 ``ERR_PERMISSION:``(定义不写入)。
|
|
309
|
+
|
|
306
310
|
同名同形重复注册幂等通过(对齐 core schema.register 的复跑语义——场景 harness
|
|
307
311
|
每后端复跑同一批用例时必须可重入);同名异形显式 Err(禁止静默覆盖已注册定义)。
|
|
308
312
|
"""
|
|
313
|
+
# 定义层门禁:判决先于静态校验(拒绝即返回,零副作用;与 schema.register 同序)
|
|
314
|
+
if not _core.can_register(ctx):
|
|
315
|
+
name = defn.get('name') if isinstance(defn, dict) else ''
|
|
316
|
+
raise WorkflowError(f'ERR_PERMISSION: 无权注册或覆盖工作流定义 {name or ""}')
|
|
309
317
|
errors = validate_defn(defn)
|
|
310
318
|
if errors:
|
|
311
319
|
raise WorkflowError('WORKFLOW_UNSUPPORTED: ' + ';'.join(errors))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: storepy
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.2.0
|
|
4
4
|
Summary: Multi-backend data layer for Python asyncio (MongoDB, MySQL, SQLite, PostgreSQL): pure JSON schemas, GQL tree queries compiled to a single native query, GROUP BY/HAVING aggregation, computed columns, soft-delete and role-based access control
|
|
5
5
|
Author-email: leo <coen_ddt@qq.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -24,7 +24,7 @@ Requires-Python: >=3.10
|
|
|
24
24
|
Description-Content-Type: text/markdown
|
|
25
25
|
License-File: LICENSE
|
|
26
26
|
Requires-Dist: pymongo>=4.9
|
|
27
|
-
Requires-Dist: rust-store-py<4.0.0,>=3.
|
|
27
|
+
Requires-Dist: rust-store-py<4.0.0,>=3.2.0
|
|
28
28
|
Provides-Extra: mysql
|
|
29
29
|
Requires-Dist: asyncmy>=0.2.9; extra == "mysql"
|
|
30
30
|
Provides-Extra: postgres
|
|
@@ -9,6 +9,7 @@ src/py_store/datasource.py
|
|
|
9
9
|
src/py_store/ddl.py
|
|
10
10
|
src/py_store/feedback.py
|
|
11
11
|
src/py_store/llm.py
|
|
12
|
+
src/py_store/metadef.py
|
|
12
13
|
src/py_store/permission.py
|
|
13
14
|
src/py_store/schema.py
|
|
14
15
|
src/py_store/sync.py
|
|
@@ -39,6 +40,7 @@ tests/test_coverage_margin.py
|
|
|
39
40
|
tests/test_federation_e2e.py
|
|
40
41
|
tests/test_host_contract.py
|
|
41
42
|
tests/test_host_paths.py
|
|
43
|
+
tests/test_metadef.py
|
|
42
44
|
tests/test_migration.py
|
|
43
45
|
tests/test_mongo_executor.py
|
|
44
46
|
tests/test_mongo_session.py
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""meta-store 定义持久化与版本化单测:A1(落库幂等)/ A2(版本化与回滚)+ 纯函数锚。
|
|
2
|
+
|
|
3
|
+
parity 锚:纯函数输出与 nodejs-store 侧同输入同输出(对拍脚本 + metadef.js 守护)。
|
|
4
|
+
运行:$env:LOCAL_CORE='1'; $env:PYTHONPATH='py-store/src'; python -m pytest py-store/tests/test_metadef.py -q
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'src'))
|
|
14
|
+
|
|
15
|
+
from py_store import metadef as md
|
|
16
|
+
from py_store.metadef import MetaDefError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _register_test_schemas():
|
|
20
|
+
"""conftest 清场后恢复内建定义表(幂等自举)"""
|
|
21
|
+
md.ensure_builtins()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ─── 纯函数(双端逐字节等价锚) ────────────────────────────────
|
|
25
|
+
|
|
26
|
+
def test_same_defn_key_order_insensitive():
|
|
27
|
+
assert md.same_defn({'a': 1, 'b': 2}, {'b': 2, 'a': 1})
|
|
28
|
+
assert not md.same_defn({'a': 1}, {'a': 2})
|
|
29
|
+
assert md.same_defn({'x': [1, {'k': 'v'}]}, {'x': [1, {'k': 'v'}]})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_next_version():
|
|
33
|
+
assert md.next_version([]) == 1
|
|
34
|
+
assert md.next_version([{'version': 1}, {'version': 3}]) == 4
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_build_def_row_shape_and_key_order():
|
|
38
|
+
row = md.build_def_row({'name': 'Item'}, {'tenant': 't1', 'env': 'dev', 'actor': 'u1'}, 2)
|
|
39
|
+
assert row == {'tenant': 't1', 'env': 'dev', 'name': 'Item', 'version': 2,
|
|
40
|
+
'defn': {'name': 'Item'}, 'status': 'active', 'createdBy': 'u1'}
|
|
41
|
+
# 键序与 node buildDefRow 一致(对拍逐字节)
|
|
42
|
+
assert list(row) == ['tenant', 'env', 'name', 'version', 'defn', 'status', 'createdBy']
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_build_def_row_actor_defaults_empty():
|
|
46
|
+
row = md.build_def_row({'name': 'Item'}, {'tenant': 't', 'env': 'e'}, 1)
|
|
47
|
+
assert row['createdBy'] == ''
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ─── A1 / A2:真实 sqlite 落库闭环 ─────────────────────────────
|
|
51
|
+
|
|
52
|
+
def _run_in_sqlite(body, tables=('__schemaDef',)):
|
|
53
|
+
"""建 sqlite 内存库 → 建内建表(默认 __schemaDef)→ init → 执行业务协程
|
|
54
|
+
|
|
55
|
+
进程级连接映射是全局单例:用前快照、用后还原,避免把「已关闭的库」留给后续测试文件
|
|
56
|
+
(对齐 conftest 的注册表隔离思路)。
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
async def _main():
|
|
60
|
+
import aiosqlite
|
|
61
|
+
|
|
62
|
+
from py_store import datasource as _ds
|
|
63
|
+
from py_store import ddl as ddl_mod
|
|
64
|
+
from py_store import executors, init, store
|
|
65
|
+
md.ensure_builtins()
|
|
66
|
+
prev = dict(_ds._connections)
|
|
67
|
+
db = await aiosqlite.connect(':memory:')
|
|
68
|
+
ddl = str(ddl_mod.generate('sqlite', list(tables)))
|
|
69
|
+
for stmt in ddl.split('\n\n'):
|
|
70
|
+
if stmt.strip():
|
|
71
|
+
await db.execute(stmt.strip())
|
|
72
|
+
await db.commit()
|
|
73
|
+
await init({'default': executors.create_connection('sqlite', db)})
|
|
74
|
+
try:
|
|
75
|
+
return await body(store)
|
|
76
|
+
finally:
|
|
77
|
+
await db.close()
|
|
78
|
+
_ds._connections = prev
|
|
79
|
+
|
|
80
|
+
return asyncio.new_event_loop().run_until_complete(_main())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _wf_defn(name, gql='Item(){ _id }'):
|
|
84
|
+
"""最小合法 workflow defn(注册期白名单通过;gql 内容不参与注册期校验)"""
|
|
85
|
+
return {'name': name, 'steps': [{'op': 'query', 'as': 'a', 'gql': gql}]}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_a1_persist_one_row_and_idempotent():
|
|
89
|
+
"""A1:register 后 __schemaDef 出现 1 行(五要素);同名同形重复不新增行"""
|
|
90
|
+
|
|
91
|
+
async def _run(store):
|
|
92
|
+
defn = {'name': 'Item', 'fields': {'_id': {'type': 'string'}, 'title': {'type': 'string'}}}
|
|
93
|
+
r1 = await md.persist_def(store, defn, {'tenant': 't1', 'env': 'dev', 'actor': 'u1'})
|
|
94
|
+
# 五要素 (tenant, env, name, version, defn)
|
|
95
|
+
assert r1['tenant'] == 't1' and r1['env'] == 'dev' and r1['name'] == 'Item'
|
|
96
|
+
assert r1['version'] == 1 and r1['defn'] == defn
|
|
97
|
+
assert r1['status'] == 'active' and r1['createdBy'] == 'u1'
|
|
98
|
+
rows = await md.list_defs(store, {'tenant': 't1', 'env': 'dev', 'name': 'Item'})
|
|
99
|
+
assert len(rows) == 1
|
|
100
|
+
# 同名同形重复注册 → 返回原行、不新增
|
|
101
|
+
r2 = await md.persist_def(store, defn, {'tenant': 't1', 'env': 'dev', 'actor': 'u2'})
|
|
102
|
+
assert r2['_id'] == r1['_id'] and r2['version'] == 1
|
|
103
|
+
rows2 = await md.list_defs(store, {'tenant': 't1', 'env': 'dev', 'name': 'Item'})
|
|
104
|
+
assert len(rows2) == 1
|
|
105
|
+
|
|
106
|
+
_run_in_sqlite(_run)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_a2_versioning_history_and_rollback():
|
|
110
|
+
"""A2:变更 defn → 新增 version+1;可列全部历史;rollbackTo(v) 后生效 defn = 历史 v"""
|
|
111
|
+
|
|
112
|
+
async def _run(store):
|
|
113
|
+
v1 = {'name': 'Item', 'fields': {'_id': {'type': 'string'}, 'title': {'type': 'string'}}}
|
|
114
|
+
v2 = {'name': 'Item', 'fields': {'_id': {'type': 'string'}, 'title': {'type': 'string'},
|
|
115
|
+
'price': {'type': 'number'}}}
|
|
116
|
+
r1 = await md.persist_def(store, v1, {'tenant': 't1', 'env': 'dev'})
|
|
117
|
+
r2 = await md.persist_def(store, v2, {'tenant': 't1', 'env': 'dev'})
|
|
118
|
+
assert r1['version'] == 1 and r2['version'] == 2
|
|
119
|
+
hist = await md.list_defs(store, {'tenant': 't1', 'env': 'dev', 'name': 'Item'})
|
|
120
|
+
assert [r['version'] for r in hist] == [2, 1] # version desc
|
|
121
|
+
# loadDefs:各 name 最新 active
|
|
122
|
+
latest = await md.load_defs(store, {'tenant': 't1', 'env': 'dev'})
|
|
123
|
+
assert len(latest) == 1 and latest[0]['version'] == 2
|
|
124
|
+
# 回滚到 v1(追加式)→ 以 v1 defn 追加新版本行;本进程 register → 生效 defn = v1
|
|
125
|
+
rb = await md.rollback_to(store, {'tenant': 't1', 'env': 'dev', 'name': 'Item', 'version': 1})
|
|
126
|
+
assert rb['version'] == 3 # 追加式:回滚 = 以 v1 defn 追加 v3(非原地改 v1)
|
|
127
|
+
assert rb['defn'] == v1
|
|
128
|
+
assert store.get('Item')['fields'] == v1['fields']
|
|
129
|
+
assert 'price' not in store.get('Item')['fields']
|
|
130
|
+
# 历史 append-only:目标行未被改写
|
|
131
|
+
hist2 = await md.list_defs(store, {'tenant': 't1', 'env': 'dev', 'name': 'Item'})
|
|
132
|
+
assert [r['version'] for r in hist2] == [3, 2, 1]
|
|
133
|
+
# 跨进程闭环锚:网关 hydrate 读 load_defs → 现返回回滚后的最新行(defn=v1)
|
|
134
|
+
latest2 = await md.load_defs(store, {'tenant': 't1', 'env': 'dev'})
|
|
135
|
+
assert len(latest2) == 1 and latest2[0]['version'] == 3
|
|
136
|
+
assert latest2[0]['defn'] == v1
|
|
137
|
+
|
|
138
|
+
_run_in_sqlite(_run)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_rollback_missing_version_raises():
|
|
142
|
+
async def _run(store):
|
|
143
|
+
with pytest.raises(MetaDefError):
|
|
144
|
+
await md.rollback_to(store, {'tenant': 't1', 'env': 'dev', 'name': 'Ghost', 'version': 9})
|
|
145
|
+
|
|
146
|
+
_run_in_sqlite(_run)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_persist_missing_name_raises():
|
|
150
|
+
async def _run(store):
|
|
151
|
+
with pytest.raises(MetaDefError):
|
|
152
|
+
await md.persist_def(store, {'fields': {}}, {'tenant': 't1', 'env': 'dev'})
|
|
153
|
+
|
|
154
|
+
_run_in_sqlite(_run)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ─── D2:版本唯一性(行自然键 _id) ────────────────────────────
|
|
158
|
+
|
|
159
|
+
def test_def_id_natural_key_shape():
|
|
160
|
+
assert md.def_id('t1', 'dev', 'Item', 2) == 't1\u001fdev\u001fItem\u001f2'
|
|
161
|
+
assert md.def_id(None, None, 'Item', 1) == '\u001f\u001fItem\u001f1'
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_d2_persist_writes_natural_id_and_version_unique():
|
|
165
|
+
"""D2:persist 落行带自然键 _id;同版本二次写入 → 存储层唯一键冲突(显式上抛)"""
|
|
166
|
+
|
|
167
|
+
async def _run(store):
|
|
168
|
+
defn = {'name': 'UniqItem', 'fields': {'_id': {'type': 'string'}}}
|
|
169
|
+
r1 = await md.persist_def(store, defn, {'tenant': 't2', 'env': 'dev'})
|
|
170
|
+
assert r1['_id'] == md.def_id('t2', 'dev', 'UniqItem', 1)
|
|
171
|
+
# 并发写同版本的最小复现:绕过「读最新行 +1」直插同版本行
|
|
172
|
+
dup = md.build_def_row(defn, {'tenant': 't2', 'env': 'dev'}, 1)
|
|
173
|
+
dup['_id'] = md.def_id('t2', 'dev', 'UniqItem', 1)
|
|
174
|
+
with pytest.raises(Exception) as ei:
|
|
175
|
+
with md._internal_ctx():
|
|
176
|
+
await store.insert('__schemaDef', dup)
|
|
177
|
+
assert 'unique' in str(ei.value).lower()
|
|
178
|
+
|
|
179
|
+
_run_in_sqlite(_run)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ─── D1:从持久化定义重建注册表(发布闭环桥) ────────────────
|
|
183
|
+
|
|
184
|
+
def test_d1_restore_defs_rebuilds_registry():
|
|
185
|
+
"""D1:persist 只落库不注册;restore_defs 从库重建注册表(同版本幂等)"""
|
|
186
|
+
|
|
187
|
+
async def _run(store):
|
|
188
|
+
md._applied.clear()
|
|
189
|
+
defn = {'name': 'RestoredItem',
|
|
190
|
+
'fields': {'_id': {'type': 'string'}, 'title': {'type': 'string'}}}
|
|
191
|
+
await md.persist_def(store, defn, {'tenant': 't3', 'env': 'dev'})
|
|
192
|
+
assert not store.has('RestoredItem') # 原缺口:落库后协议面不可见
|
|
193
|
+
out = await md.restore_defs(store, {'tenant': 't3', 'env': 'dev'})
|
|
194
|
+
assert out['applied'] == 1
|
|
195
|
+
assert store.has('RestoredItem') # 重建后即可见
|
|
196
|
+
out2 = await md.restore_defs(store, {'tenant': 't3', 'env': 'dev'})
|
|
197
|
+
assert out2['applied'] == 0 # 同版本幂等:不重复注册
|
|
198
|
+
|
|
199
|
+
_run_in_sqlite(_run)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ─── D21:回滚跨进程闭环(rollback → reload hydrate → 按历史 defn 装配) ────
|
|
203
|
+
|
|
204
|
+
def test_d21_rollback_visible_via_restore_defs():
|
|
205
|
+
"""D21:rollback 以历史 defn 追加新版本 → restore_defs(网关 hydrate 路径)按历史 defn 装配
|
|
206
|
+
|
|
207
|
+
复现原缺口:若回滚只原地重注册、不落新行,load_defs 仍返回被回滚掉的旧版本,
|
|
208
|
+
网关 hydrate 后协议面仍是旧版本 —— 跨进程回滚不闭环。追加式回滚后 load_defs 返回
|
|
209
|
+
回滚后的新行(新自然键),restore_defs 必然 applied。
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
async def _run(store):
|
|
213
|
+
md._applied.clear()
|
|
214
|
+
v1 = {'name': 'RbItem', 'fields': {'_id': {'type': 'string'}, 'title': {'type': 'string'}}}
|
|
215
|
+
v2 = {'name': 'RbItem', 'fields': {'_id': {'type': 'string'}, 'title': {'type': 'string'},
|
|
216
|
+
'price': {'type': 'number'}}}
|
|
217
|
+
o = {'tenant': 't5', 'env': 'dev'}
|
|
218
|
+
await md.persist_def(store, v1, o)
|
|
219
|
+
await md.restore_defs(store, o) # 网关首次 hydrate → v1
|
|
220
|
+
await md.persist_def(store, v2, o)
|
|
221
|
+
await md.restore_defs(store, o) # 网关再次 hydrate → v2
|
|
222
|
+
assert 'price' in store.get('RbItem')['fields']
|
|
223
|
+
# 控制面回滚到 v1
|
|
224
|
+
await md.rollback_to(store, {'tenant': 't5', 'env': 'dev', 'name': 'RbItem', 'version': 1})
|
|
225
|
+
# 网关新一次 reload:load_defs 应返回回滚后的最新行(defn=v1),新键 → applied
|
|
226
|
+
latest = await md.load_defs(store, o)
|
|
227
|
+
assert len(latest) == 1 and latest[0]['version'] == 3 and latest[0]['defn'] == v1
|
|
228
|
+
out = await md.restore_defs(store, o)
|
|
229
|
+
assert out['applied'] == 1
|
|
230
|
+
assert 'price' not in store.get('RbItem')['fields'] # 协议面按历史 defn 装配
|
|
231
|
+
|
|
232
|
+
_run_in_sqlite(_run)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ─── workflow 定义持久化(kind=workflow,落 __workflowDef;审计 §8 N1)───────
|
|
236
|
+
|
|
237
|
+
def test_wf_persist_idempotent_versioning_and_list():
|
|
238
|
+
"""workflow:persist 同名同形幂等 + 异形 version+1 + list version desc"""
|
|
239
|
+
|
|
240
|
+
async def _run(store):
|
|
241
|
+
o = {'tenant': 't6', 'env': 'dev', 'kind': 'workflow'}
|
|
242
|
+
w1 = _wf_defn('WfPersist')
|
|
243
|
+
w2 = _wf_defn('WfPersist', 'Item(){ _id title }')
|
|
244
|
+
r1 = await md.persist_def(store, w1, o)
|
|
245
|
+
assert r1['version'] == 1
|
|
246
|
+
assert (await md.persist_def(store, w1, o))['version'] == 1 # 同名同形幂等
|
|
247
|
+
r2 = await md.persist_def(store, w2, o)
|
|
248
|
+
assert r2['version'] == 2 # 异形 version+1
|
|
249
|
+
rows = await md.list_defs(store, {'tenant': 't6', 'env': 'dev',
|
|
250
|
+
'name': 'WfPersist', 'kind': 'workflow'})
|
|
251
|
+
assert [r['version'] for r in rows] == [2, 1] # version desc
|
|
252
|
+
assert rows[0]['_id'] == md.def_id('t6', 'dev', 'WfPersist', 2)
|
|
253
|
+
|
|
254
|
+
_run_in_sqlite(_run, ('__workflowDef',))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def test_wf_load_defs_latest_active():
|
|
258
|
+
"""workflow:load_defs 取各 name 最新 active"""
|
|
259
|
+
|
|
260
|
+
async def _run(store):
|
|
261
|
+
o = {'tenant': 't7', 'env': 'dev', 'kind': 'workflow'}
|
|
262
|
+
await md.persist_def(store, _wf_defn('WfLoad'), o)
|
|
263
|
+
await md.persist_def(store, _wf_defn('WfLoad', 'Item(){ _id title }'), o)
|
|
264
|
+
await md.persist_def(store, {'name': 'WfOther', 'steps': [{'op': 'fail', 'message': 'x'}]}, o)
|
|
265
|
+
rows = await md.load_defs(store, o)
|
|
266
|
+
assert {r['name']: r['version'] for r in rows} == {'WfLoad': 2, 'WfOther': 1}
|
|
267
|
+
|
|
268
|
+
_run_in_sqlite(_run, ('__workflowDef',))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_wf_restore_defs_rebuilds_workflow_registry():
|
|
272
|
+
"""workflow:restore_defs(kind=workflow) 重建 workflow 注册表(幂等)"""
|
|
273
|
+
|
|
274
|
+
async def _run(store):
|
|
275
|
+
md._applied.clear()
|
|
276
|
+
o = {'tenant': 't8', 'env': 'dev'}
|
|
277
|
+
await md.persist_def(store, _wf_defn('WfRestore'), {**o, 'kind': 'workflow'})
|
|
278
|
+
assert 'WfRestore' not in store.workflows() # 落库未注册
|
|
279
|
+
out = await md.restore_defs(store, {**o, 'kind': 'workflow'})
|
|
280
|
+
assert out['applied'] == 1
|
|
281
|
+
assert 'WfRestore' in store.workflows() # 重建后可见
|
|
282
|
+
assert (await md.restore_defs(store, {**o, 'kind': 'workflow'}))['applied'] == 0
|
|
283
|
+
|
|
284
|
+
_run_in_sqlite(_run, ('__workflowDef',))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def test_wf_rollback_append_only_and_reload():
|
|
288
|
+
"""workflow:rollback 追加式 → load_defs 按历史 defn,本进程重注册"""
|
|
289
|
+
|
|
290
|
+
async def _run(store):
|
|
291
|
+
md._applied.clear()
|
|
292
|
+
o = {'tenant': 't10', 'env': 'dev', 'kind': 'workflow'}
|
|
293
|
+
v1 = _wf_defn('WfRb')
|
|
294
|
+
v2 = _wf_defn('WfRb', 'Item(){ _id title }')
|
|
295
|
+
await md.persist_def(store, v1, o)
|
|
296
|
+
await md.persist_def(store, v2, o)
|
|
297
|
+
rb = await md.rollback_to(store, {'tenant': 't10', 'env': 'dev',
|
|
298
|
+
'name': 'WfRb', 'version': 1, 'kind': 'workflow'})
|
|
299
|
+
assert rb['version'] == 3 and rb['defn'] == v1 # 追加式
|
|
300
|
+
latest = await md.load_defs(store, o)
|
|
301
|
+
assert latest[0]['version'] == 3 and latest[0]['defn'] == v1
|
|
302
|
+
assert store.get_workflow('WfRb')['steps'][0]['gql'] == 'Item(){ _id }' # 本进程重注册
|
|
303
|
+
|
|
304
|
+
_run_in_sqlite(_run, ('__workflowDef',))
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_a3_host_restore_defs_both_kinds():
|
|
308
|
+
"""宿主 restore_defs 同时重建 schema 与 workflow 两类(一次调用闭环)"""
|
|
309
|
+
|
|
310
|
+
async def _run(store):
|
|
311
|
+
md._applied.clear()
|
|
312
|
+
o = {'tenant': 't9', 'env': 'dev'}
|
|
313
|
+
await store.persist_def({'name': 'HostItem', 'fields': {'_id': {'type': 'string'}}}, o)
|
|
314
|
+
await store.persist_workflow_def(_wf_defn('HostWf'), o)
|
|
315
|
+
assert not store.has('HostItem')
|
|
316
|
+
assert 'HostWf' not in store.workflows()
|
|
317
|
+
out = await store.restore_defs(o)
|
|
318
|
+
assert out['applied'] == 2 # 一次调用重建两类
|
|
319
|
+
assert store.has('HostItem')
|
|
320
|
+
assert 'HostWf' in store.workflows()
|
|
321
|
+
|
|
322
|
+
_run_in_sqlite(_run, ('__schemaDef', '__workflowDef'))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def test_wf_restore_under_closed_meta_policy():
|
|
326
|
+
"""Closed 定义层门禁下 restore_defs 仍以 internal 重建 workflow 定义"""
|
|
327
|
+
|
|
328
|
+
async def _run(store):
|
|
329
|
+
from py_store import schema as sc
|
|
330
|
+
from py_store import workflow as wf
|
|
331
|
+
md._applied.clear()
|
|
332
|
+
o = {'tenant': 't11', 'env': 'dev', 'kind': 'workflow'}
|
|
333
|
+
await md.persist_def(store, _wf_defn('WfRestoreGate'), o)
|
|
334
|
+
sc.set_meta_policy(True, [])
|
|
335
|
+
try:
|
|
336
|
+
out = await md.restore_defs(store, dict(o))
|
|
337
|
+
assert out['applied'] == 1
|
|
338
|
+
assert 'WfRestoreGate' in store.workflows() # Closed 下仍重建(internal)
|
|
339
|
+
finally:
|
|
340
|
+
sc.set_meta_policy(False, [])
|
|
341
|
+
wf._workflows.pop('WfRestoreGate', None)
|
|
342
|
+
|
|
343
|
+
_run_in_sqlite(_run, ('__workflowDef',))
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def test_wf_rollback_under_closed_meta_policy():
|
|
347
|
+
"""Closed 定义层门禁下 rollback_to 仍以 internal 重建 workflow 定义"""
|
|
348
|
+
|
|
349
|
+
async def _run(store):
|
|
350
|
+
from py_store import schema as sc
|
|
351
|
+
from py_store import workflow as wf
|
|
352
|
+
md._applied.clear()
|
|
353
|
+
o = {'tenant': 't11', 'env': 'dev', 'kind': 'workflow'}
|
|
354
|
+
await md.persist_def(store, _wf_defn('WfRbGate'), o)
|
|
355
|
+
await md.persist_def(store, _wf_defn('WfRbGate', 'Item(){ _id title }'), o)
|
|
356
|
+
sc.set_meta_policy(True, [])
|
|
357
|
+
try:
|
|
358
|
+
rb = await md.rollback_to(store, {'tenant': 't11', 'env': 'dev',
|
|
359
|
+
'name': 'WfRbGate', 'version': 1, 'kind': 'workflow'})
|
|
360
|
+
assert rb['version'] == 3 # 追加式
|
|
361
|
+
assert store.get_workflow('WfRbGate')['steps'][0]['gql'] == 'Item(){ _id }' # Closed 下仍重建
|
|
362
|
+
finally:
|
|
363
|
+
sc.set_meta_policy(False, [])
|
|
364
|
+
wf._workflows.pop('WfRbGate', None)
|
|
365
|
+
|
|
366
|
+
_run_in_sqlite(_run, ('__workflowDef',))
|
|
@@ -134,6 +134,31 @@ def test_register_and_read_filter():
|
|
|
134
134
|
wf._workflows.pop(GOOD['name'], None)
|
|
135
135
|
|
|
136
136
|
|
|
137
|
+
def test_register_meta_gate():
|
|
138
|
+
from py_store import schema as sc
|
|
139
|
+
|
|
140
|
+
gate = {'name': 'gateWf', 'steps': [{'op': 'fail', 'message': 'x'}]}
|
|
141
|
+
wf._workflows.pop('gateWf', None)
|
|
142
|
+
try:
|
|
143
|
+
# Open(缺省):无 ctx 放行
|
|
144
|
+
sc.set_meta_policy(False, [])
|
|
145
|
+
wf.register(gate)
|
|
146
|
+
wf._workflows.pop('gateWf', None)
|
|
147
|
+
|
|
148
|
+
# Closed:无 ctx → 显式 ERR_PERMISSION,且定义不写入
|
|
149
|
+
sc.set_meta_policy(True, [])
|
|
150
|
+
with pytest.raises(WorkflowError, match='ERR_PERMISSION:'):
|
|
151
|
+
wf.register(gate)
|
|
152
|
+
assert 'gateWf' not in wf._workflows
|
|
153
|
+
|
|
154
|
+
# Closed:internal 放行
|
|
155
|
+
wf.register(gate, {'internal': True})
|
|
156
|
+
assert 'gateWf' in wf._workflows
|
|
157
|
+
finally:
|
|
158
|
+
wf._workflows.pop('gateWf', None)
|
|
159
|
+
sc.set_meta_policy(False, []) # 复位,防污染后续用例
|
|
160
|
+
|
|
161
|
+
|
|
137
162
|
# ─── parity 锚:占位符解析 ───────────────────────────────────
|
|
138
163
|
|
|
139
164
|
_RESOLVE_CTX = {'inv': {'_id': 'inv1', 'stock': 100}}
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
"""反馈事件通道 —— 兜底/降级/拦截触发的统一出口
|
|
2
|
-
|
|
3
|
-
事件形状(对齐 rust-store 联邦 degraded 契约)::
|
|
4
|
-
|
|
5
|
-
{type, code, layer, message, hint, ...}
|
|
6
|
-
- type : 事件类别(federation_degraded / sql_pushdown_unsupported ...)
|
|
7
|
-
- code : 机器可读代码(crossSourceSort / pushdownUnsupported ...)
|
|
8
|
-
- layer : 命中的防护层(federation / dialect ...)
|
|
9
|
-
- message: 人可读描述
|
|
10
|
-
- hint : 修复指引(供上游排查/加固)
|
|
11
|
-
|
|
12
|
-
默认无 sink 时打 stderr(向后兼容);宿主可 ``set_sink(fn)`` 接管,
|
|
13
|
-
接入自动反馈闭环(允许被拦截,禁止静默失守)。
|
|
14
|
-
"""
|
|
15
|
-
|
|
16
|
-
import sys
|
|
17
|
-
|
|
18
|
-
_sink = None
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def get_sink():
|
|
22
|
-
"""当前 sink(None = 默认 stderr 行为);供接管方保存原值、退出时恢复(token-set/reset 同构)"""
|
|
23
|
-
return _sink
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def set_sink(fn):
|
|
27
|
-
"""注册反馈事件回调 ``fn(event: dict)``;传 None 恢复默认 stderr 行为"""
|
|
28
|
-
global _sink
|
|
29
|
-
_sink = fn
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def emit(event):
|
|
33
|
-
"""产出一条反馈事件:有 sink 回调之;否则打印 stderr(允许拦截,禁止静默)"""
|
|
34
|
-
if _sink is not None:
|
|
35
|
-
_sink(event)
|
|
36
|
-
return
|
|
37
|
-
print(
|
|
38
|
-
f"[py-store][{event.get('layer', '?')}/{event.get('code', '?')}] "
|
|
39
|
-
f"{event.get('message', '')}(hint: {event.get('hint', '-')})",
|
|
40
|
-
file=sys.stderr,
|
|
41
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|