storepy 3.2.0__tar.gz → 3.4.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.
Files changed (64) hide show
  1. {storepy-3.2.0 → storepy-3.4.0}/PKG-INFO +1 -1
  2. {storepy-3.2.0 → storepy-3.4.0}/pyproject.toml +1 -1
  3. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/__init__.py +32 -0
  4. storepy-3.4.0/src/py_store/bootstrap.py +19 -0
  5. storepy-3.4.0/src/py_store/cache.py +36 -0
  6. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/feedback.py +15 -1
  7. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/metadef.py +23 -0
  8. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/workflow.py +62 -0
  9. {storepy-3.2.0 → storepy-3.4.0}/src/storepy.egg-info/PKG-INFO +1 -1
  10. {storepy-3.2.0 → storepy-3.4.0}/src/storepy.egg-info/SOURCES.txt +4 -0
  11. storepy-3.4.0/tests/test_cache.py +64 -0
  12. storepy-3.4.0/tests/test_feedback_flush.py +40 -0
  13. {storepy-3.2.0 → storepy-3.4.0}/tests/test_metadef.py +31 -0
  14. {storepy-3.2.0 → storepy-3.4.0}/tests/test_workflow.py +37 -0
  15. {storepy-3.2.0 → storepy-3.4.0}/LICENSE +0 -0
  16. {storepy-3.2.0 → storepy-3.4.0}/README.md +0 -0
  17. {storepy-3.2.0 → storepy-3.4.0}/setup.cfg +0 -0
  18. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/ask.py +0 -0
  19. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/ask_knowledge.md +0 -0
  20. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/core.py +0 -0
  21. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/crud/__init__.py +0 -0
  22. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/crud/exec.py +0 -0
  23. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/crud/id.py +0 -0
  24. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/crud/mutation.py +0 -0
  25. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/crud/query.py +0 -0
  26. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/crud/write.py +0 -0
  27. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/datasource.py +0 -0
  28. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/ddl.py +0 -0
  29. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/executors/__init__.py +0 -0
  30. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/executors/_values.py +0 -0
  31. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/executors/mongo.py +0 -0
  32. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/executors/mysql.py +0 -0
  33. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/executors/postgres.py +0 -0
  34. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/executors/sqlite.py +0 -0
  35. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/introspect/__init__.py +0 -0
  36. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/introspect/mysql.py +0 -0
  37. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/introspect/postgres.py +0 -0
  38. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/introspect/sqlite.py +0 -0
  39. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/llm.py +0 -0
  40. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/permission.py +0 -0
  41. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/schema.py +0 -0
  42. {storepy-3.2.0 → storepy-3.4.0}/src/py_store/sync.py +0 -0
  43. {storepy-3.2.0 → storepy-3.4.0}/src/storepy.egg-info/dependency_links.txt +0 -0
  44. {storepy-3.2.0 → storepy-3.4.0}/src/storepy.egg-info/requires.txt +0 -0
  45. {storepy-3.2.0 → storepy-3.4.0}/src/storepy.egg-info/top_level.txt +0 -0
  46. {storepy-3.2.0 → storepy-3.4.0}/tests/test_ask.py +0 -0
  47. {storepy-3.2.0 → storepy-3.4.0}/tests/test_coverage_margin.py +0 -0
  48. {storepy-3.2.0 → storepy-3.4.0}/tests/test_federation_e2e.py +0 -0
  49. {storepy-3.2.0 → storepy-3.4.0}/tests/test_host_contract.py +0 -0
  50. {storepy-3.2.0 → storepy-3.4.0}/tests/test_host_paths.py +0 -0
  51. {storepy-3.2.0 → storepy-3.4.0}/tests/test_migration.py +0 -0
  52. {storepy-3.2.0 → storepy-3.4.0}/tests/test_mongo_executor.py +0 -0
  53. {storepy-3.2.0 → storepy-3.4.0}/tests/test_mongo_session.py +0 -0
  54. {storepy-3.2.0 → storepy-3.4.0}/tests/test_multi_datasource.py +0 -0
  55. {storepy-3.2.0 → storepy-3.4.0}/tests/test_profile.py +0 -0
  56. {storepy-3.2.0 → storepy-3.4.0}/tests/test_py_store.py +0 -0
  57. {storepy-3.2.0 → storepy-3.4.0}/tests/test_raw_sql_and_ddl.py +0 -0
  58. {storepy-3.2.0 → storepy-3.4.0}/tests/test_rbac_host.py +0 -0
  59. {storepy-3.2.0 → storepy-3.4.0}/tests/test_real_backends_e2e.py +0 -0
  60. {storepy-3.2.0 → storepy-3.4.0}/tests/test_require_context.py +0 -0
  61. {storepy-3.2.0 → storepy-3.4.0}/tests/test_scenario_course_platform.py +0 -0
  62. {storepy-3.2.0 → storepy-3.4.0}/tests/test_scenario_manager_transaction.py +0 -0
  63. {storepy-3.2.0 → storepy-3.4.0}/tests/test_session.py +0 -0
  64. {storepy-3.2.0 → storepy-3.4.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.2.0
3
+ Version: 3.4.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "storepy"
7
- version = "3.2.0"
7
+ version = "3.4.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"
@@ -26,6 +26,7 @@ from pymongo.errors import PyMongoError
26
26
 
27
27
  from . import (
28
28
  ask,
29
+ cache,
29
30
  crud,
30
31
  datasource,
31
32
  ddl,
@@ -250,6 +251,19 @@ class Store:
250
251
  """注入进程级 ns 标签(tenant/env),供落库事件附加(进程级隔离下天然单 ns)"""
251
252
  feedback.set_meta({'tenant': tenant, 'env': env})
252
253
 
254
+ async def flushFeedback(self):
255
+ """等待全部在途 __feedback 落库完成(graceful shutdown 前调用;对齐 node store.flushFeedback)"""
256
+ await feedback.flush()
257
+
258
+ # ── 缓存状态注记(B6;见 cache.py)──
259
+ def setCacheStatus(self, fn):
260
+ """注册缓存状态 provider:`x-cache` 注记位唯一取值来源;未注册恒 BYPASS"""
261
+ return cache.set_cache_status(fn)
262
+
263
+ def cacheStatus(self, ctx: dict | None = None) -> str:
264
+ """当前响应的缓存状态注记(恒为 HIT|MISS|BYPASS 之一)"""
265
+ return cache.cache_status(permission.get_context() if ctx is None else ctx)
266
+
253
267
  # ── 工作流编排(首批:线性 + when 守卫 + fail-fast;见 workflow.py 与设计文档)──
254
268
  def registerWorkflow(self, defn: dict, ctx: dict | None = None) -> dict:
255
269
  """注册工作流定义(可选 ``ctx`` 过定义层门禁;默认 Open。白名单外显式 Err 含 WORKFLOW_UNSUPPORTED)"""
@@ -307,6 +321,10 @@ class Store:
307
321
  ensure_builtins = ensureBuiltins
308
322
  enable_feedback_table = enableFeedbackTable
309
323
  set_feedback_meta = setFeedbackMeta
324
+ flush_feedback = flushFeedback
325
+ # 缓存状态注记(蛇形别名与上方驼峰同实现)
326
+ set_cache_status = setCacheStatus
327
+ cache_status = cacheStatus
310
328
 
311
329
  # ── 其余 API 显式绑定(staticmethod:避免实例化后 self 注入)──
312
330
  # Schema 管理
@@ -450,3 +468,17 @@ async def init(connections):
450
468
  await _create_indexes_if_needed()
451
469
 
452
470
  return store
471
+
472
+
473
+ def __getattr__(name: str):
474
+ """惰性暴露框架统一入口 ``create_app``。
475
+
476
+ 放在模块级 ``__getattr__``(PEP 562)而非顶部 ``from .bootstrap import create_app``,
477
+ 以规避包内循环导入;因此无模块级导入,兼容 ruff E402 / I001。
478
+ """
479
+ if name == "create_app":
480
+ from .bootstrap import create_app
481
+
482
+ return create_app
483
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
484
+
@@ -0,0 +1,19 @@
1
+ """框架统一入口(Python):init → 逐条 register;协议面由接入方另行挂载。
2
+
3
+ 注意:``init`` / ``store`` 用函数内惰性导入——``__init__.py`` 末尾导入本模块,
4
+ 若此处顶层 ``from . import init, store`` 会在包尚未完成初始化时取到未定义名,
5
+ 故延迟到调用期解析。
6
+ """
7
+
8
+ from typing import Any, Iterable
9
+
10
+
11
+ async def create_app(*, datasource: Any, schemas: Iterable[dict] = (), ctx: Any = None):
12
+ from . import init, store # 延迟导入,规避包内循环导入
13
+
14
+ if datasource is None:
15
+ raise ValueError("ERR_BOOTSTRAP:缺 datasource")
16
+ await init(datasource)
17
+ for defn in schemas:
18
+ store.register(defn, ctx)
19
+ return {"store": store}
@@ -0,0 +1,36 @@
1
+ """缓存状态注记(B6)—— 协议层 `x-cache` 响应头的唯一取值来源。
2
+
3
+ 本轮只落**注记位**:不实现任何实际缓存。无 provider(默认)恒返回 'BYPASS'
4
+ (响应未经过缓存)——注记必须为真,禁把无缓存谎报为 HIT/MISS。
5
+ provider 由接入方在实现缓存时经 set_cache_status 注入,签名 (ctx) -> 'HIT'|'MISS'|'BYPASS'。
6
+ """
7
+
8
+ from .feedback import emit
9
+
10
+ _VALUES = ('HIT', 'MISS', 'BYPASS')
11
+ _provider = None
12
+
13
+
14
+ def set_cache_status(fn):
15
+ """注册缓存状态 provider;传 None / 非可调用恢复默认(恒 BYPASS)"""
16
+ global _provider
17
+ _provider = fn if callable(fn) else None
18
+
19
+
20
+ def cache_status(ctx=None):
21
+ """取当前响应的缓存状态注记(恒为 HIT|MISS|BYPASS 之一;非法/异常 → BYPASS + 反馈留痕)"""
22
+ if _provider is None:
23
+ return 'BYPASS'
24
+ try:
25
+ v = _provider(ctx)
26
+ except Exception as e: # 异常回落 BYPASS 且必须留痕(禁静默)
27
+ emit({'type': 'cache_status_failed', 'code': 'cacheStatusFailed', 'layer': 'host',
28
+ 'message': f'缓存状态 provider 抛错: {e}',
29
+ 'hint': '检查 set_cache_status 注入的 provider;本轮注记位不实现缓存,异常即回落 BYPASS'})
30
+ return 'BYPASS'
31
+ if v not in _VALUES:
32
+ emit({'type': 'cache_status_invalid', 'code': 'cacheStatusInvalid', 'layer': 'host',
33
+ 'message': f'缓存状态取值非法: {v!r}(值域 HIT|MISS|BYPASS)',
34
+ 'hint': 'provider 返回值必须为 HIT/MISS/BYPASS 之一;非法值回落 BYPASS'})
35
+ return 'BYPASS'
36
+ return v
@@ -25,6 +25,9 @@ _meta = {'tenant': '', 'env': ''}
25
25
  # 落库失败累计计数(进程级;>0 表示有事件未入表——可观测,不静默)
26
26
  _fail_count = 0
27
27
 
28
+ # 在途落库任务(进程级;graceful shutdown 前经 flush() 收口,消除 fire-and-forget 丢事件窗口 D8)
29
+ _pending: set = set()
30
+
28
31
 
29
32
  def get_sink():
30
33
  """当前 sink(None = 默认 stderr 行为);供接管方保存原值、退出时恢复(token-set/reset 同构)"""
@@ -82,7 +85,9 @@ def enable_feedback_table(store):
82
85
  except RuntimeError:
83
86
  loop = None
84
87
  if loop is not None:
85
- loop.create_task(_write_feedback(store, row))
88
+ task = loop.create_task(_write_feedback(store, row))
89
+ _pending.add(task)
90
+ task.add_done_callback(_pending.discard) # 完成即出集合
86
91
  else:
87
92
  _fail('无运行中事件循环,__feedback 事件未落库')
88
93
 
@@ -90,6 +95,15 @@ def enable_feedback_table(store):
90
95
  return lambda: set_sink(prev)
91
96
 
92
97
 
98
+ async def flush():
99
+ """等待全部在途 `__feedback` 落库任务完成(graceful shutdown 前调用)。
100
+
101
+ 落库失败已由 ``_write_feedback`` 计为 ``fail_count``(不抛),故此处 ``gather`` 不冒泡。
102
+ """
103
+ while _pending:
104
+ await asyncio.gather(*list(_pending), return_exceptions=True)
105
+
106
+
93
107
  def emit(event):
94
108
  """产出一条反馈事件:有 sink 回调之;否则打印 stderr(允许拦截,禁止静默)"""
95
109
  if _sink is not None:
@@ -110,6 +110,28 @@ class MetaDefError(ValueError):
110
110
 
111
111
  # ─── 纯逻辑(双端逐字节等价;parity 锚) ───────────────────────
112
112
 
113
+ def _assert_serializable(value, path=''):
114
+ """深度检查 defn 是否含函数值 —— 持久化定义 = 纯 JSON 契约(N3)。
115
+
116
+ 必须先于 ``_to_core_defn`` 调用:后者会把 ``fn/asyncFn`` 归一为 ``True`` 占位、把其余函数剔除,
117
+ 使「含回调定义」静默降级入库,回滚/restore 即丢失回调(缺陷台账 D17)。
118
+ 本函数在归一化**之前**显式拒绝(fail-fast),文案对齐 node ``_assertSerializable``(逐字节一致)。
119
+ """
120
+ if callable(value):
121
+ raise MetaDefError(
122
+ f'metadef: 定义含函数回调({path}),不可持久化;'
123
+ '回调类定义禁止经控制面发布(定义内改用 fnRef 字符串,实现由宿主 register 时注入)'
124
+ )
125
+ if value is None or not isinstance(value, (dict, list, tuple)):
126
+ return
127
+ if isinstance(value, dict):
128
+ for k, v in value.items():
129
+ _assert_serializable(v, f'{path}.{k}' if path else str(k))
130
+ else:
131
+ for i, v in enumerate(value):
132
+ _assert_serializable(v, f'{path}.{i}' if path else str(i))
133
+
134
+
113
135
  def stable_stringify(value):
114
136
  """稳定序列化(键序无关):与 node ``_stableStringify`` 逐字节等价"""
115
137
  return json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
@@ -204,6 +226,7 @@ async def persist_def(store, defn, opts):
204
226
  raise MetaDefError('metadef: defn.name 必填')
205
227
  opts = opts or {}
206
228
  _, table = _kind_of(opts)
229
+ _assert_serializable(defn, '') # ← 新增:先于 IO 与归一化
207
230
  name = defn['name']
208
231
  rows = await list_defs(store, {'tenant': opts.get('tenant'), 'env': opts.get('env'),
209
232
  'name': name, 'kind': opts.get('kind')})
@@ -16,6 +16,7 @@ import json
16
16
  import re
17
17
  import time
18
18
  from contextlib import contextmanager
19
+ from typing import List
19
20
 
20
21
  from . import permission as _permission
21
22
  from .crud.exec import _call, _sources_of, run_atomic
@@ -268,6 +269,63 @@ def validate_defn(defn):
268
269
  return errors
269
270
 
270
271
 
272
+ def _collect_param_keys(ast_value, out=None):
273
+ """递归收集 core 返回 ast 中所有 ``params`` 对象的字符串值(@key 名)。"""
274
+ if out is None:
275
+ out = set()
276
+ if isinstance(ast_value, dict):
277
+ params = ast_value.get('params')
278
+ if isinstance(params, dict):
279
+ # core `ast.params` 的值为 `@key` 形态(含前导 @,core parse.rs::parse_params
280
+ # 保留 ref 原值);归一化去掉前导 @,使错误文案为 `@c0` 而非 `@@c0`。
281
+ for v in params.values():
282
+ if isinstance(v, str) and v:
283
+ out.add(v[1:] if v.startswith('@') else v)
284
+ for v in ast_value.values():
285
+ _collect_param_keys(v, out)
286
+ elif isinstance(ast_value, _LIST_TYPES):
287
+ for v in ast_value:
288
+ _collect_param_keys(v, out)
289
+ return out
290
+
291
+
292
+ def validate_planable(defn):
293
+ """注册期「只校验不绑参」可规划性校验 → 错误列表(空 = 通过)。纯内存、无 IO、无参数值绑定。
294
+
295
+ ① 结构性可规划:对每个 query 步骤的 gql 调 core 门面
296
+ ``_core.build_pipeline(gql, {}, None)``(params 传 {} = 不绑参;ctx 传 None);
297
+ 不可规划 → core 抛异常,此处汇聚为注册期错误。
298
+ ② 参数键完整:返回体 ``ast`` 暴露 ``params``(槽位→@key,含嵌套关系);
299
+ gql 引用的每个 ``@key`` 须在 ``step['params']`` 顶层存在。
300
+
301
+ 与 ``validate_defn``(纯函数)分职:本函数需要 core 注册表,故独立、不入 validate_defn。
302
+ """
303
+ errors: List[str] = []
304
+ if not isinstance(defn, dict):
305
+ return errors
306
+ steps = defn.get('steps')
307
+ if not isinstance(steps, _LIST_TYPES):
308
+ return errors
309
+ for i, step in enumerate(steps):
310
+ if not isinstance(step, dict) or step.get('op') != 'query':
311
+ continue
312
+ gql = step.get('gql')
313
+ if not isinstance(gql, str):
314
+ continue
315
+ where = f'steps[{i}]'
316
+ try:
317
+ built = _core.build_pipeline(gql, {}, None)
318
+ except Exception as e: # 透传 core 文案,与 node 逐字节对齐
319
+ errors.append(f'{where}: gql 不可规划: {e}')
320
+ continue
321
+ params = step.get('params')
322
+ provided = set(params) if isinstance(params, dict) else set()
323
+ for key in sorted(_collect_param_keys(built.get('ast') if isinstance(built, dict) else None)):
324
+ if key not in provided:
325
+ errors.append(f'{where}: gql 引用了未提供的参数 @{key}(params 须提供该键)')
326
+ return errors
327
+
328
+
271
329
  def _split_when(when):
272
330
  """when → (op, param_key);无歧义文法:含 exists 键即 op=exists(is 作参数键),
273
331
  其余算子的右值键统一 than(is 键已被 exists 参数占用;is/eq 算子同用 than)"""
@@ -317,6 +375,10 @@ def register(defn, ctx=None):
317
375
  errors = validate_defn(defn)
318
376
  if errors:
319
377
  raise WorkflowError('WORKFLOW_UNSUPPORTED: ' + ';'.join(errors))
378
+ # B1:注册期「只校验不绑参」可规划性(结构 + 参数键完整);独立于纯函数 validate_defn
379
+ plan_errors = validate_planable(defn)
380
+ if plan_errors:
381
+ raise WorkflowError('WORKFLOW_UNSUPPORTED: ' + ';'.join(plan_errors))
320
382
  name = defn['name']
321
383
  if name in _workflows:
322
384
  if _workflows[name] == defn:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: storepy
3
- Version: 3.2.0
3
+ Version: 3.4.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
@@ -4,6 +4,8 @@ pyproject.toml
4
4
  src/py_store/__init__.py
5
5
  src/py_store/ask.py
6
6
  src/py_store/ask_knowledge.md
7
+ src/py_store/bootstrap.py
8
+ src/py_store/cache.py
7
9
  src/py_store/core.py
8
10
  src/py_store/datasource.py
9
11
  src/py_store/ddl.py
@@ -36,8 +38,10 @@ src/storepy.egg-info/dependency_links.txt
36
38
  src/storepy.egg-info/requires.txt
37
39
  src/storepy.egg-info/top_level.txt
38
40
  tests/test_ask.py
41
+ tests/test_cache.py
39
42
  tests/test_coverage_margin.py
40
43
  tests/test_federation_e2e.py
44
+ tests/test_feedback_flush.py
41
45
  tests/test_host_contract.py
42
46
  tests/test_host_paths.py
43
47
  tests/test_metadef.py
@@ -0,0 +1,64 @@
1
+ """缓存状态注记(B6)单测:注记必须为真——无 provider 恒 BYPASS;
2
+ 非法值/抛错回落 BYPASS 且走反馈通道留痕(不打断响应,禁静默)。
3
+
4
+ 运行:$env:LOCAL_CORE='1'; $env:PYTHONPATH='py-store/src'; python -m pytest py-store/tests/test_cache.py -q
5
+ """
6
+
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'src'))
11
+
12
+ from py_store import cache, feedback
13
+
14
+
15
+ def _collect(body):
16
+ """收集期间 emit 的事件(用后恢复原 sink,并复位 provider 防跨用例污染)"""
17
+ prev = feedback.get_sink()
18
+ events = []
19
+ feedback.set_sink(events.append)
20
+ try:
21
+ body()
22
+ finally:
23
+ feedback.set_sink(prev)
24
+ cache.set_cache_status(None)
25
+ return events
26
+
27
+
28
+ def test_no_provider_bypass():
29
+ cache.set_cache_status(None)
30
+ assert cache.cache_status() == 'BYPASS'
31
+
32
+
33
+ def test_invalid_value_bypass_with_feedback():
34
+ def _body():
35
+ cache.set_cache_status(lambda ctx: 'NOPE')
36
+ assert cache.cache_status() == 'BYPASS'
37
+
38
+ events = _collect(_body)
39
+ assert len(events) == 1
40
+ assert events[0]['code'] == 'cacheStatusInvalid'
41
+ assert events[0]['layer'] == 'host'
42
+
43
+
44
+ def test_provider_raises_bypass_with_feedback():
45
+ def _body():
46
+ def _boom(ctx):
47
+ raise RuntimeError('boom')
48
+
49
+ cache.set_cache_status(_boom)
50
+ assert cache.cache_status() == 'BYPASS'
51
+
52
+ events = _collect(_body)
53
+ assert len(events) == 1
54
+ assert events[0]['code'] == 'cacheStatusFailed'
55
+ assert 'boom' in events[0]['message']
56
+
57
+
58
+ def test_valid_value_passthrough():
59
+ def _body():
60
+ cache.set_cache_status(lambda ctx: 'HIT')
61
+ assert cache.cache_status() == 'HIT'
62
+
63
+ events = _collect(_body)
64
+ assert events == []
@@ -0,0 +1,40 @@
1
+ """A3 残留(D8):__feedback 落库 flush 与失败不静默 —— mock store,不连库。"""
2
+ import asyncio
3
+
4
+ from py_store import feedback
5
+
6
+
7
+ class _MockStore:
8
+ def __init__(self):
9
+ self.rows = []
10
+
11
+ async def insert(self, name, row):
12
+ self.rows.append((name, row))
13
+ return row
14
+
15
+
16
+ class _BoomStore:
17
+ async def insert(self, name, row):
18
+ raise RuntimeError('boom')
19
+
20
+
21
+ def test_flush_persists_inflight():
22
+ async def run():
23
+ s = _MockStore()
24
+ dispose = feedback.enable_feedback_table(s)
25
+ feedback.emit({'type': 't', 'code': 'c', 'layer': 'host', 'message': 'm', 'hint': 'h'})
26
+ await feedback.flush()
27
+ assert len(s.rows) == 1 and s.rows[0][0] == '__feedback' and s.rows[0][1]['code'] == 'c'
28
+ dispose()
29
+ asyncio.run(run())
30
+
31
+
32
+ def test_flush_failure_not_silent():
33
+ async def run():
34
+ before = feedback.fail_count()
35
+ dispose = feedback.enable_feedback_table(_BoomStore())
36
+ feedback.emit({'type': 't', 'code': 'c', 'layer': 'host'})
37
+ await feedback.flush()
38
+ assert feedback.fail_count() == before + 1
39
+ dispose()
40
+ asyncio.run(run())
@@ -232,6 +232,37 @@ def test_d21_rollback_visible_via_restore_defs():
232
232
  _run_in_sqlite(_run)
233
233
 
234
234
 
235
+ # ─── N3:回调类定义不可持久化(持久化定义 = 纯 JSON)─────────────
236
+
237
+ def test_n3_callback_defn_rejected_no_row():
238
+ """N3:含函数回调的 defn 经 persist_def 显式拒绝,库内零新增行"""
239
+
240
+ async def _run(store):
241
+ o = {'tenant': 't12', 'env': 'dev'}
242
+ defn = {'name': 'CbItem', 'fields': {'_id': {'type': 'string'}},
243
+ 'computes': {'total': {'fn': lambda item: 1}}}
244
+ with pytest.raises(MetaDefError, match='不可持久化'):
245
+ await md.persist_def(store, defn, o)
246
+ rows = await md.list_defs(store, {**o, 'name': 'CbItem'})
247
+ assert len(rows) == 0 # 拒绝先于 IO:库内零新增行
248
+
249
+ _run_in_sqlite(_run)
250
+
251
+
252
+ def test_n3_pure_json_defn_persists_same_shape():
253
+ """N3:纯 JSON defn(fnRef 字符串)正常落库且与入参同形"""
254
+
255
+ async def _run(store):
256
+ o = {'tenant': 't12', 'env': 'dev'}
257
+ defn = {'name': 'CbPure', 'fields': {'_id': {'type': 'string'}},
258
+ 'computes': {'total': {'fnRef': 'sum', 'type': 'int'}}}
259
+ row = await md.persist_def(store, defn, o)
260
+ assert row['version'] == 1
261
+ assert md.same_defn(row['defn'], defn)
262
+
263
+ _run_in_sqlite(_run)
264
+
265
+
235
266
  # ─── workflow 定义持久化(kind=workflow,落 __workflowDef;审计 §8 N1)───────
236
267
 
237
268
  def test_wf_persist_idempotent_versioning_and_list():
@@ -12,9 +12,23 @@ import pytest
12
12
 
13
13
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'src'))
14
14
 
15
+ from py_store import schema as sc
15
16
  from py_store import workflow as wf
16
17
  from py_store.workflow import WorkflowError
17
18
 
19
+
20
+ # B1 前置:GOOD 的 gql/mutation 引用 Inventory,注册期可规划性校验要求其已注册。
21
+ # 本仓 conftest 的模块级夹具会在每模块清空注册表(clear_schemas),故按约定入口
22
+ # `_register_test_schemas` 提供(只定义不执行,由夹具在清场后重放)。
23
+ def _register_test_schemas():
24
+ sc.register({
25
+ 'name': 'Inventory', 'collection': 'inventory', 'idPrefix': 'inv',
26
+ 'fields': {
27
+ '_id': {'type': 'string'}, 'stock': {'type': 'int'},
28
+ 'productId': {'type': 'string'}, 'warehouse': {'type': 'string'},
29
+ },
30
+ })
31
+
18
32
  GOOD = {
19
33
  'name': 'placeOrder',
20
34
  'read': ['admin', 'ops'],
@@ -322,3 +336,26 @@ def test_parity_run_doc_shape():
322
336
  assert sorted(run_doc['steps'][1]) == ['as', 'op', 'state']
323
337
  finally:
324
338
  wf._workflows.pop('shapeWf', None)
339
+
340
+
341
+ # ─── B1:注册期 GQL 可规划性校验(结构 + 参数键完整) ─────────
342
+
343
+ def test_b1_planable_unplanned_rejected():
344
+ bad = {'name': 'noModelWf', 'steps': [
345
+ {'op': 'query', 'as': 'a', 'gql': 'NoSuchModel{_id}', 'params': {}}]}
346
+ assert len(wf.validate_planable(bad)) == 1
347
+ with pytest.raises(wf.WorkflowError, match=r'WORKFLOW_UNSUPPORTED: steps\[0\]: gql 不可规划'):
348
+ wf.register(bad)
349
+
350
+
351
+ def test_b1_planable_missing_param_key():
352
+ bad = {'name': 'missParamWf', 'steps': [
353
+ {'op': 'query', 'as': 'a', 'gql': 'Inventory($condition:@c0){_id}', 'params': {}}]}
354
+ assert wf.validate_planable(bad) == [
355
+ 'steps[0]: gql 引用了未提供的参数 @c0(params 须提供该键)']
356
+
357
+
358
+ def test_b1_planable_ok():
359
+ assert wf.validate_planable({'name': 'okWf', 'steps': [
360
+ {'op': 'query', 'as': 'a', 'gql': 'Inventory($condition:@c0){_id, stock}',
361
+ 'params': {'c0': {'stock': 1}}}]}) == []
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