storepy 3.1.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.1.0/src/storepy.egg-info → storepy-3.2.0}/PKG-INFO +2 -2
- {storepy-3.1.0 → storepy-3.2.0}/pyproject.toml +2 -2
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/__init__.py +30 -6
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/metadef.py +43 -11
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/workflow.py +9 -1
- {storepy-3.1.0 → storepy-3.2.0/src/storepy.egg-info}/PKG-INFO +2 -2
- {storepy-3.1.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.1.0 → storepy-3.2.0}/tests/test_workflow.py +25 -0
- storepy-3.1.0/tests/test_metadef.py +0 -186
- {storepy-3.1.0 → storepy-3.2.0}/LICENSE +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/README.md +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/setup.cfg +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/ask.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/ask_knowledge.md +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/core.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/crud/__init__.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/crud/exec.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/crud/id.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/crud/mutation.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/crud/query.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/crud/write.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/datasource.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/ddl.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/executors/__init__.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/executors/_values.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/executors/mongo.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/executors/mysql.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/executors/postgres.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/executors/sqlite.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/feedback.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/introspect/__init__.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/introspect/mysql.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/introspect/postgres.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/introspect/sqlite.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/llm.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/permission.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/schema.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/py_store/sync.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/storepy.egg-info/SOURCES.txt +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/storepy.egg-info/dependency_links.txt +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/src/storepy.egg-info/top_level.txt +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/tests/test_ask.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/tests/test_coverage_margin.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/tests/test_federation_e2e.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/tests/test_host_contract.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/tests/test_host_paths.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/tests/test_migration.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/tests/test_mongo_executor.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/tests/test_mongo_session.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/tests/test_multi_datasource.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/tests/test_profile.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/tests/test_py_store.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/tests/test_raw_sql_and_ddl.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/tests/test_rbac_host.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/tests/test_real_backends_e2e.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/tests/test_require_context.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/tests/test_scenario_course_platform.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/tests/test_scenario_manager_transaction.py +0 -0
- {storepy-3.1.0 → storepy-3.2.0}/tests/test_session.py +0 -0
- {storepy-3.1.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",
|
|
@@ -210,13 +210,33 @@ class Store:
|
|
|
210
210
|
return await metadef.load_defs(self, opts or {})
|
|
211
211
|
|
|
212
212
|
async def restoreDefs(self, opts: dict | None = None) -> dict:
|
|
213
|
-
"""
|
|
214
|
-
|
|
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']}
|
|
215
218
|
|
|
216
219
|
async def rollbackTo(self, opts: dict) -> dict:
|
|
217
|
-
"""
|
|
220
|
+
"""回滚到历史版本(追加式,重新 register 该版本 defn)"""
|
|
218
221
|
return await metadef.rollback_to(self, opts)
|
|
219
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
|
+
|
|
220
240
|
def ensureBuiltins(self) -> None:
|
|
221
241
|
"""自举内建定义表 __schemaDef/__workflowDef(幂等)"""
|
|
222
242
|
return metadef.ensure_builtins()
|
|
@@ -231,9 +251,9 @@ class Store:
|
|
|
231
251
|
feedback.set_meta({'tenant': tenant, 'env': env})
|
|
232
252
|
|
|
233
253
|
# ── 工作流编排(首批:线性 + when 守卫 + fail-fast;见 workflow.py 与设计文档)──
|
|
234
|
-
def registerWorkflow(self, defn: dict) -> dict:
|
|
235
|
-
"""
|
|
236
|
-
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)
|
|
237
257
|
|
|
238
258
|
def workflows(self, ctx: dict | None = None) -> list[str]:
|
|
239
259
|
"""全部可见工作流名(read 白名单过滤)"""
|
|
@@ -280,6 +300,10 @@ class Store:
|
|
|
280
300
|
load_defs = loadDefs
|
|
281
301
|
restore_defs = restoreDefs
|
|
282
302
|
rollback_to = rollbackTo
|
|
303
|
+
persist_workflow_def = persistWorkflowDef
|
|
304
|
+
list_workflow_defs = listWorkflowDefs
|
|
305
|
+
load_workflow_defs = loadWorkflowDefs
|
|
306
|
+
rollback_workflow_to = rollbackWorkflowTo
|
|
283
307
|
ensure_builtins = ensureBuiltins
|
|
284
308
|
enable_feedback_table = enableFeedbackTable
|
|
285
309
|
set_feedback_meta = setFeedbackMeta
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
设计见 doc/execution/2026/10/meta-store定义控制面-01(A1/A2/A3)。三条契约:
|
|
4
4
|
- 定义落库为内建 schema ``__schemaDef``(tenant/env/name/version/defn 五要素);
|
|
5
5
|
- 同名同形幂等不新增行,异形 version+1;
|
|
6
|
-
-
|
|
6
|
+
- 版本历史可列(append-only),``rollbackTo`` 以历史 defn **追加新版本**(跨进程经 ``restore_defs`` 对协议面可见)。
|
|
7
7
|
|
|
8
8
|
对齐 ``nodejs-store/src/metadef.js``(双宿主流库行内容逐字节一致由对拍脚本守护)。
|
|
9
9
|
存储是 IO,故落宿主层,core 零改动。
|
|
@@ -16,6 +16,7 @@ from . import permission as _permission
|
|
|
16
16
|
from .schema import _to_core_defn
|
|
17
17
|
from .schema import has as _schema_has
|
|
18
18
|
from .schema import register as _schema_register
|
|
19
|
+
from .workflow import register as _register_workflow
|
|
19
20
|
|
|
20
21
|
# 内建定义表名(``__`` 前缀为内建保留名,对齐 workflow 的 name.startswith('__') 校验)
|
|
21
22
|
_SCHEMA_DEF = '__schemaDef'
|
|
@@ -24,6 +25,23 @@ _FEEDBACK = '__feedback'
|
|
|
24
25
|
# 读取投影(双端一致;对拍比较用)
|
|
25
26
|
_DEF_FIELDS = '_id, tenant, env, name, version, defn, status, createdBy'
|
|
26
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
|
+
|
|
27
45
|
|
|
28
46
|
def _def_model(name, id_prefix):
|
|
29
47
|
"""内建定义表 schema(write 显式空名单:普通角色禁写,防篡改定义审计)
|
|
@@ -153,17 +171,19 @@ def _internal_ctx():
|
|
|
153
171
|
async def list_defs(store, opts):
|
|
154
172
|
"""列定义行(按 version desc;name 缺省列全部)"""
|
|
155
173
|
opts = opts or {}
|
|
174
|
+
_, table = _kind_of(opts)
|
|
156
175
|
condition = {'tenant': opts.get('tenant'), 'env': opts.get('env')}
|
|
157
176
|
if opts.get('name') is not None:
|
|
158
177
|
condition['name'] = opts['name']
|
|
159
|
-
gql = f'
|
|
178
|
+
gql = f'{table}($condition:@c0, $sort:@s0){{{_DEF_FIELDS}}}'
|
|
160
179
|
return await store.query(gql, {'c0': condition, 's0': {'version': -1}})
|
|
161
180
|
|
|
162
181
|
|
|
163
182
|
async def load_defs(store, opts):
|
|
164
183
|
"""各 name 的**最新 active** 行(每 name 取 version 最大者;version desc 后首见即最新)"""
|
|
165
184
|
opts = opts or {}
|
|
166
|
-
|
|
185
|
+
_, table = _kind_of(opts)
|
|
186
|
+
gql = f'{table}($condition:@c0, $sort:@s0){{{_DEF_FIELDS}}}'
|
|
167
187
|
rows = await store.query(gql, {
|
|
168
188
|
'c0': {'tenant': opts.get('tenant'), 'env': opts.get('env'), 'status': 'active'},
|
|
169
189
|
's0': {'version': -1},
|
|
@@ -183,9 +203,10 @@ async def persist_def(store, defn, opts):
|
|
|
183
203
|
if not isinstance(defn, dict) or not defn.get('name'):
|
|
184
204
|
raise MetaDefError('metadef: defn.name 必填')
|
|
185
205
|
opts = opts or {}
|
|
206
|
+
_, table = _kind_of(opts)
|
|
186
207
|
name = defn['name']
|
|
187
208
|
rows = await list_defs(store, {'tenant': opts.get('tenant'), 'env': opts.get('env'),
|
|
188
|
-
'name': name})
|
|
209
|
+
'name': name, 'kind': opts.get('kind')})
|
|
189
210
|
latest = rows[0] if rows else None
|
|
190
211
|
core_defn = _to_core_defn(defn) # 函数值剔除(纯 JSON 入库,A3 前提)
|
|
191
212
|
if latest is not None and same_defn(latest.get('defn'), core_defn):
|
|
@@ -195,7 +216,7 @@ async def persist_def(store, defn, opts):
|
|
|
195
216
|
# 自然键 `_id`(D2):同版本并发写必冲突 → 存储层保证 version 唯一
|
|
196
217
|
row['_id'] = def_id(opts.get('tenant'), opts.get('env'), core_defn['name'], version)
|
|
197
218
|
with _internal_ctx():
|
|
198
|
-
return await store.insert(
|
|
219
|
+
return await store.insert(table, row)
|
|
199
220
|
|
|
200
221
|
|
|
201
222
|
# 已重建进注册表的定义自然键(进程级;同名同版本只注册一次,避免每次 reload 全量覆盖)
|
|
@@ -211,29 +232,40 @@ async def restore_defs(store, opts):
|
|
|
211
232
|
返回 ``{'total': 库内最新 active 行数, 'applied': 本次新注册数}``。
|
|
212
233
|
"""
|
|
213
234
|
opts = opts or {}
|
|
235
|
+
kind, _ = _kind_of(opts)
|
|
214
236
|
rows = await load_defs(store, opts)
|
|
215
237
|
applied = 0
|
|
216
238
|
for r in rows:
|
|
217
|
-
key = def_id(opts.get('tenant'), opts.get('env'), r['name'], r['version'])
|
|
239
|
+
key = f"{kind}\u001f{def_id(opts.get('tenant'), opts.get('env'), r['name'], r['version'])}"
|
|
218
240
|
if key in _applied:
|
|
219
241
|
continue
|
|
220
|
-
|
|
242
|
+
_REGISTRARS[kind](r['defn'], True)
|
|
221
243
|
_applied.add(key)
|
|
222
244
|
applied += 1
|
|
223
245
|
return {'total': len(rows), 'applied': applied}
|
|
224
246
|
|
|
225
247
|
|
|
226
248
|
async def rollback_to(store, opts):
|
|
227
|
-
"""
|
|
249
|
+
"""回滚到历史版本(追加式):取历史行 defn → 作为新版本再发布 → 本进程 register → 返回落库行。
|
|
250
|
+
|
|
251
|
+
D21 跨进程闭环:回滚不再「原地重注册」,而是复用 ``persist_def`` 把历史 defn 落成一条
|
|
252
|
+
**新版本行**(同名同形幂等 → 返回当前最新行)。``load_defs`` 取「最新 active」故必然返回
|
|
253
|
+
该行,网关 ``restore_defs`` hydrate 即按回滚后的 defn 装配 —— 回滚对协议面可见。
|
|
254
|
+
历史保持 append-only:目标历史行不被改写。
|
|
255
|
+
"""
|
|
228
256
|
opts = opts or {}
|
|
257
|
+
kind, _ = _kind_of(opts)
|
|
229
258
|
rows = await list_defs(store, {'tenant': opts.get('tenant'), 'env': opts.get('env'),
|
|
230
|
-
'name': opts.get('name')})
|
|
259
|
+
'name': opts.get('name'), 'kind': opts.get('kind')})
|
|
231
260
|
version = opts.get('version')
|
|
232
261
|
row = next((r for r in rows if r.get('version') == version), None)
|
|
233
262
|
if row is None:
|
|
234
263
|
raise MetaDefError(f"metadef: 版本不存在 {opts.get('name')}@{version}")
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
237
269
|
|
|
238
270
|
|
|
239
271
|
# 模块导入即自举内建定义表(幂等;零配置)
|
|
@@ -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
|
|
@@ -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,186 +0,0 @@
|
|
|
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):
|
|
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', ['__schemaDef']))
|
|
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 test_a1_persist_one_row_and_idempotent():
|
|
84
|
-
"""A1:register 后 __schemaDef 出现 1 行(五要素);同名同形重复不新增行"""
|
|
85
|
-
|
|
86
|
-
async def _run(store):
|
|
87
|
-
defn = {'name': 'Item', 'fields': {'_id': {'type': 'string'}, 'title': {'type': 'string'}}}
|
|
88
|
-
r1 = await md.persist_def(store, defn, {'tenant': 't1', 'env': 'dev', 'actor': 'u1'})
|
|
89
|
-
# 五要素 (tenant, env, name, version, defn)
|
|
90
|
-
assert r1['tenant'] == 't1' and r1['env'] == 'dev' and r1['name'] == 'Item'
|
|
91
|
-
assert r1['version'] == 1 and r1['defn'] == defn
|
|
92
|
-
assert r1['status'] == 'active' and r1['createdBy'] == 'u1'
|
|
93
|
-
rows = await md.list_defs(store, {'tenant': 't1', 'env': 'dev', 'name': 'Item'})
|
|
94
|
-
assert len(rows) == 1
|
|
95
|
-
# 同名同形重复注册 → 返回原行、不新增
|
|
96
|
-
r2 = await md.persist_def(store, defn, {'tenant': 't1', 'env': 'dev', 'actor': 'u2'})
|
|
97
|
-
assert r2['_id'] == r1['_id'] and r2['version'] == 1
|
|
98
|
-
rows2 = await md.list_defs(store, {'tenant': 't1', 'env': 'dev', 'name': 'Item'})
|
|
99
|
-
assert len(rows2) == 1
|
|
100
|
-
|
|
101
|
-
_run_in_sqlite(_run)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def test_a2_versioning_history_and_rollback():
|
|
105
|
-
"""A2:变更 defn → 新增 version+1;可列全部历史;rollbackTo(v) 后生效 defn = 历史 v"""
|
|
106
|
-
|
|
107
|
-
async def _run(store):
|
|
108
|
-
v1 = {'name': 'Item', 'fields': {'_id': {'type': 'string'}, 'title': {'type': 'string'}}}
|
|
109
|
-
v2 = {'name': 'Item', 'fields': {'_id': {'type': 'string'}, 'title': {'type': 'string'},
|
|
110
|
-
'price': {'type': 'number'}}}
|
|
111
|
-
r1 = await md.persist_def(store, v1, {'tenant': 't1', 'env': 'dev'})
|
|
112
|
-
r2 = await md.persist_def(store, v2, {'tenant': 't1', 'env': 'dev'})
|
|
113
|
-
assert r1['version'] == 1 and r2['version'] == 2
|
|
114
|
-
hist = await md.list_defs(store, {'tenant': 't1', 'env': 'dev', 'name': 'Item'})
|
|
115
|
-
assert [r['version'] for r in hist] == [2, 1] # version desc
|
|
116
|
-
# loadDefs:各 name 最新 active
|
|
117
|
-
latest = await md.load_defs(store, {'tenant': 't1', 'env': 'dev'})
|
|
118
|
-
assert len(latest) == 1 and latest[0]['version'] == 2
|
|
119
|
-
# 回滚到 v1 → 重新 register → 生效 defn = v1
|
|
120
|
-
rb = await md.rollback_to(store, {'tenant': 't1', 'env': 'dev', 'name': 'Item', 'version': 1})
|
|
121
|
-
assert rb['version'] == 1
|
|
122
|
-
assert store.get('Item')['fields'] == v1['fields']
|
|
123
|
-
assert 'price' not in store.get('Item')['fields']
|
|
124
|
-
|
|
125
|
-
_run_in_sqlite(_run)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def test_rollback_missing_version_raises():
|
|
129
|
-
async def _run(store):
|
|
130
|
-
with pytest.raises(MetaDefError):
|
|
131
|
-
await md.rollback_to(store, {'tenant': 't1', 'env': 'dev', 'name': 'Ghost', 'version': 9})
|
|
132
|
-
|
|
133
|
-
_run_in_sqlite(_run)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def test_persist_missing_name_raises():
|
|
137
|
-
async def _run(store):
|
|
138
|
-
with pytest.raises(MetaDefError):
|
|
139
|
-
await md.persist_def(store, {'fields': {}}, {'tenant': 't1', 'env': 'dev'})
|
|
140
|
-
|
|
141
|
-
_run_in_sqlite(_run)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
# ─── D2:版本唯一性(行自然键 _id) ────────────────────────────
|
|
145
|
-
|
|
146
|
-
def test_def_id_natural_key_shape():
|
|
147
|
-
assert md.def_id('t1', 'dev', 'Item', 2) == 't1\u001fdev\u001fItem\u001f2'
|
|
148
|
-
assert md.def_id(None, None, 'Item', 1) == '\u001f\u001fItem\u001f1'
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def test_d2_persist_writes_natural_id_and_version_unique():
|
|
152
|
-
"""D2:persist 落行带自然键 _id;同版本二次写入 → 存储层唯一键冲突(显式上抛)"""
|
|
153
|
-
|
|
154
|
-
async def _run(store):
|
|
155
|
-
defn = {'name': 'UniqItem', 'fields': {'_id': {'type': 'string'}}}
|
|
156
|
-
r1 = await md.persist_def(store, defn, {'tenant': 't2', 'env': 'dev'})
|
|
157
|
-
assert r1['_id'] == md.def_id('t2', 'dev', 'UniqItem', 1)
|
|
158
|
-
# 并发写同版本的最小复现:绕过「读最新行 +1」直插同版本行
|
|
159
|
-
dup = md.build_def_row(defn, {'tenant': 't2', 'env': 'dev'}, 1)
|
|
160
|
-
dup['_id'] = md.def_id('t2', 'dev', 'UniqItem', 1)
|
|
161
|
-
with pytest.raises(Exception) as ei:
|
|
162
|
-
with md._internal_ctx():
|
|
163
|
-
await store.insert('__schemaDef', dup)
|
|
164
|
-
assert 'unique' in str(ei.value).lower()
|
|
165
|
-
|
|
166
|
-
_run_in_sqlite(_run)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
# ─── D1:从持久化定义重建注册表(发布闭环桥) ────────────────
|
|
170
|
-
|
|
171
|
-
def test_d1_restore_defs_rebuilds_registry():
|
|
172
|
-
"""D1:persist 只落库不注册;restore_defs 从库重建注册表(同版本幂等)"""
|
|
173
|
-
|
|
174
|
-
async def _run(store):
|
|
175
|
-
md._applied.clear()
|
|
176
|
-
defn = {'name': 'RestoredItem',
|
|
177
|
-
'fields': {'_id': {'type': 'string'}, 'title': {'type': 'string'}}}
|
|
178
|
-
await md.persist_def(store, defn, {'tenant': 't3', 'env': 'dev'})
|
|
179
|
-
assert not store.has('RestoredItem') # 原缺口:落库后协议面不可见
|
|
180
|
-
out = await md.restore_defs(store, {'tenant': 't3', 'env': 'dev'})
|
|
181
|
-
assert out['applied'] == 1
|
|
182
|
-
assert store.has('RestoredItem') # 重建后即可见
|
|
183
|
-
out2 = await md.restore_defs(store, {'tenant': 't3', 'env': 'dev'})
|
|
184
|
-
assert out2['applied'] == 0 # 同版本幂等:不重复注册
|
|
185
|
-
|
|
186
|
-
_run_in_sqlite(_run)
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|