functualize-state-sqlite 0.2.3__tar.gz → 0.3.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 (29) hide show
  1. {functualize_state_sqlite-0.2.3 → functualize_state_sqlite-0.3.0}/.gitignore +27 -0
  2. functualize_state_sqlite-0.3.0/LICENSE +202 -0
  3. functualize_state_sqlite-0.3.0/NOTICE +5 -0
  4. {functualize_state_sqlite-0.2.3 → functualize_state_sqlite-0.3.0}/PKG-INFO +5 -4
  5. {functualize_state_sqlite-0.2.3 → functualize_state_sqlite-0.3.0}/README.md +1 -1
  6. {functualize_state_sqlite-0.2.3 → functualize_state_sqlite-0.3.0}/pyproject.toml +2 -4
  7. functualize_state_sqlite-0.3.0/src/functualize_state_sqlite/__init__.py +14 -0
  8. functualize_state_sqlite-0.3.0/src/functualize_state_sqlite/_plugin.py +113 -0
  9. functualize_state_sqlite-0.3.0/src/functualize_state_sqlite/substrate.py +225 -0
  10. functualize_state_sqlite-0.3.0/tests/test_sqlite_substrate.py +355 -0
  11. functualize_state_sqlite-0.2.3/src/functualize_state_sqlite/__init__.py +0 -13
  12. functualize_state_sqlite-0.2.3/src/functualize_state_sqlite/_backend.py +0 -160
  13. functualize_state_sqlite-0.2.3/src/functualize_state_sqlite/_execution_store.py +0 -379
  14. functualize_state_sqlite-0.2.3/src/functualize_state_sqlite/_migrations.py +0 -157
  15. functualize_state_sqlite-0.2.3/src/functualize_state_sqlite/_plugin.py +0 -205
  16. functualize_state_sqlite-0.2.3/src/functualize_state_sqlite/plugin.py +0 -345
  17. functualize_state_sqlite-0.2.3/src/functualize_state_sqlite/sqlite_backend.py +0 -721
  18. functualize_state_sqlite-0.2.3/src/functualize_state_sqlite/state_store.py +0 -178
  19. functualize_state_sqlite-0.2.3/src/functualize_state_sqlite/tracker.py +0 -318
  20. functualize_state_sqlite-0.2.3/tests/test_execution_state_integration.py +0 -638
  21. functualize_state_sqlite-0.2.3/tests/test_plugin.py +0 -86
  22. functualize_state_sqlite-0.2.3/tests/test_sqlite_backend.py +0 -437
  23. functualize_state_sqlite-0.2.3/tests/test_sqlite_state_store_properties.py +0 -365
  24. {functualize_state_sqlite-0.2.3 → functualize_state_sqlite-0.3.0}/examples/README.md +0 -0
  25. {functualize_state_sqlite-0.2.3 → functualize_state_sqlite-0.3.0}/examples/persistent_counter/persistent_counter.py +0 -0
  26. {functualize_state_sqlite-0.2.3 → functualize_state_sqlite-0.3.0}/examples/persistent_counter/test_persistent_counter.py +0 -0
  27. {functualize_state_sqlite-0.2.3 → functualize_state_sqlite-0.3.0}/src/functualize_state_sqlite/py.typed +0 -0
  28. {functualize_state_sqlite-0.2.3 → functualize_state_sqlite-0.3.0}/tests/__init__.py +0 -0
  29. {functualize_state_sqlite-0.2.3 → functualize_state_sqlite-0.3.0}/tests/conftest.py +0 -0
@@ -113,3 +113,30 @@ examples/plugins/file_based_plugin/.functualize/plugins/*
113
113
  .release/
114
114
  .worktrees/
115
115
  .claude/settings.local.json
116
+
117
+ # Agent index directories. Which parts are tracked is decided by ONE property:
118
+ # whether the stored paths are portable.
119
+ #
120
+ # graphify graph.json holds only repo-relative paths, so it travels into every
121
+ # worktree and every ephemeral cloud checkout for free. Tracked.
122
+ # Its cache/, manifest.json and .graphify_root are machine-local
123
+ # (mtimes, absolute paths), so the rule below is a whitelist: ignore
124
+ # everything, then un-ignore the two portable artifacts.
125
+ # zvec-grep index entries are keyed by ABSOLUTE path — copying one and rewriting
126
+ # its manifest yields 0% coverage and a full re-embed. Never tracked;
127
+ # each workspace builds its own (~21s scoped).
128
+ # serena cache/ pickles hold absolute file:// URIs, so those and
129
+ # project.local.yml are machine-local; serena's own
130
+ # .serena/.gitignore already excludes cache/ and project.local.yml.
131
+ # memories/ is plain text and portable, but not tracked anyway: it
132
+ # duplicated contract docs that already have a committed home
133
+ # (`.claude/rules/spec-workflow.md`, `.spec/ARCHITECTURE.md`,
134
+ # AGENTS.md) and went stale the moment those changed underneath
135
+ # it. project.yml stays tracked and portable.
136
+ graphify-out/*
137
+ !graphify-out/graph.json
138
+ !graphify-out/GRAPH_REPORT.md
139
+ .zvec-grep/
140
+ .serena/cache/
141
+ .serena/project.local.yml
142
+ .serena/memories/
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
@@ -0,0 +1,5 @@
1
+ functualize
2
+ Copyright 2025-2026 Mohammad Hakim Adiprasetya
3
+
4
+ This product includes software developed by
5
+ Mohammad Hakim Adiprasetya (https://github.com/raicing-ai/functualize).
@@ -1,9 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: functualize-state-sqlite
3
- Version: 0.2.3
3
+ Version: 0.3.0
4
4
  Summary: SQLite-backed state persistence and execution tracking plugin for functualize
5
5
  Author-email: Mohammad Hakim Adiprasetya <viltohmyst@gmail.com>
6
- License-Expression: MIT
6
+ License-Expression: Apache-2.0
7
+ License-File: LICENSE
8
+ License-File: NOTICE
7
9
  Classifier: Development Status :: 3 - Alpha
8
10
  Classifier: Programming Language :: Python :: 3
9
11
  Classifier: Programming Language :: Python :: 3.11
@@ -11,7 +13,6 @@ Classifier: Programming Language :: Python :: 3.12
11
13
  Classifier: Programming Language :: Python :: 3.13
12
14
  Classifier: Typing :: Typed
13
15
  Requires-Python: >=3.11
14
- Requires-Dist: functualize-state<1.0.0,>=0.1.0
15
16
  Requires-Dist: functualize<1.0.0,>=0.1.0
16
17
  Provides-Extra: dev
17
18
  Requires-Dist: hypothesis>=6.82.0; extra == 'dev'
@@ -70,7 +71,7 @@ Public classes exported by this plugin:
70
71
  Internal classes (available via direct import but not part of the public protocol surface):
71
72
 
72
73
  - `SQLiteBackend` — Low-level connection manager with WAL mode, schema initialization, and query helpers for sessions, executions, steps, and namespaced state.
73
- - `SQLiteStateStore` — `StateStoreProtocol` implementation scoped to a `(scope_id, job_namespace)` pair, with `get()`, `set()`, `delete()`, `keys()`, `to_dict()`, `clear()`, and cross-job access via `get_job_state()`.
74
+ - `SQLiteStateStore` — `StateStoreProtocol` implementation scoped to a `(scope_id, job_namespace)` pair, with `get()`, `set()`, `delete()`, `keys()`, `to_dict()` and `clear()`.
74
75
  - `ExecutionTracker` — High-level session management and execution recording with automatic session resume based on TTL, and AI context summary generation via `to_ai_context()`.
75
76
 
76
77
  ## Development
@@ -49,7 +49,7 @@ Public classes exported by this plugin:
49
49
  Internal classes (available via direct import but not part of the public protocol surface):
50
50
 
51
51
  - `SQLiteBackend` — Low-level connection manager with WAL mode, schema initialization, and query helpers for sessions, executions, steps, and namespaced state.
52
- - `SQLiteStateStore` — `StateStoreProtocol` implementation scoped to a `(scope_id, job_namespace)` pair, with `get()`, `set()`, `delete()`, `keys()`, `to_dict()`, `clear()`, and cross-job access via `get_job_state()`.
52
+ - `SQLiteStateStore` — `StateStoreProtocol` implementation scoped to a `(scope_id, job_namespace)` pair, with `get()`, `set()`, `delete()`, `keys()`, `to_dict()` and `clear()`.
53
53
  - `ExecutionTracker` — High-level session management and execution recording with automatic session resume based on TTL, and AI context summary generation via `to_ai_context()`.
54
54
 
55
55
  ## Development
@@ -1,9 +1,9 @@
1
1
  [project]
2
2
  name = "functualize-state-sqlite"
3
- version = "0.2.3"
3
+ version = "0.3.0"
4
4
  description = "SQLite-backed state persistence and execution tracking plugin for functualize"
5
5
  readme = "README.md"
6
- license = "MIT"
6
+ license = "Apache-2.0"
7
7
  authors = [
8
8
  { name = "Mohammad Hakim Adiprasetya", email = "viltohmyst@gmail.com" }
9
9
  ]
@@ -18,7 +18,6 @@ classifiers = [
18
18
  ]
19
19
  dependencies = [
20
20
  "functualize>=0.1.0,<1.0.0",
21
- "functualize-state>=0.1.0,<1.0.0",
22
21
  ]
23
22
 
24
23
  [project.entry-points."functualize.state_providers"]
@@ -37,7 +36,6 @@ build-backend = "hatchling.build"
37
36
 
38
37
  [tool.uv.sources]
39
38
  functualize = { workspace = true }
40
- functualize-state = { workspace = true }
41
39
 
42
40
  [tool.hatch.build.targets.wheel]
43
41
  packages = ["src/functualize_state_sqlite"]
@@ -0,0 +1,14 @@
1
+ """SQLite-backed runtime state for functualize.
2
+
3
+ One substrate, installed at boot. See `substrate.py` for why this replaced a
4
+ key-value `StateBackend`, and `contributor/adr/022` for why that idea is
5
+ retired rather than deferred.
6
+ """
7
+
8
+ from functualize_state_sqlite._plugin import SQLiteStatePlugin
9
+ from functualize_state_sqlite.substrate import SQLiteSubstrate
10
+
11
+ __all__ = [
12
+ "SQLiteStatePlugin",
13
+ "SQLiteSubstrate",
14
+ ]
@@ -0,0 +1,113 @@
1
+ """The plugin: install a SQLite substrate, and nothing else.
2
+
3
+ `store-substrate`/T5, T6.
4
+
5
+ This used to be three things at once — a `StateBackend`, an `ExecutionStore`,
6
+ and a per-scope key-value store swapped in at `ON_SCOPE_CREATED`. All three are
7
+ gone, and the reason is the same one in all three cases: they were a *second*
8
+ storage vocabulary sitting beside the framework's own.
9
+
10
+ - The scope store swap happened one level below the real seam, so a scope could
11
+ keep its job state in SQLite while the records describing it stayed on the
12
+ filesystem. A resumed run then found its steps and not its variables.
13
+ - `StateBackend` and `ExecutionStore` were a backend-agnostic key-value
14
+ protocol, which can only offer the intersection of every backend — worth
15
+ least exactly where having a database is worth most. `contributor/adr/022`
16
+ records that argument so it is not re-proposed.
17
+
18
+ What is left is one line of work: choose the substrate. Every store follows,
19
+ because there is one place that decides and one object handed to all of them.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from functualize_state_sqlite.substrate import SQLiteSubstrate
29
+
30
+ __all__ = ["SQLiteStatePlugin"]
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ #: Where the database goes when nothing configures it. Beside the project's
35
+ #: other runtime state, so `func builtin data clear` and a `.gitignore` that
36
+ #: already covers `.functualize/` keep working.
37
+ DEFAULT_DB_NAME = "state.db"
38
+
39
+
40
+ class SQLiteStatePlugin:
41
+ """Installs a :class:`SQLiteSubstrate` as the app's substrate at boot."""
42
+
43
+ name: str = "sqlite-state"
44
+ version: str = "0.2.0"
45
+ description: str = "Keeps this project's runtime state in SQLite"
46
+
47
+ def __init__(self) -> None:
48
+ self._substrate: SQLiteSubstrate | None = None
49
+
50
+ @property
51
+ def substrate(self) -> SQLiteSubstrate | None:
52
+ """The substrate this plugin installed, or None before APP_READY."""
53
+ return self._substrate
54
+
55
+ def __call__(self, app: Any) -> None:
56
+ from functualize._events.hooks import HookEvent
57
+
58
+ app.hook_registry.register_global(HookEvent.APP_READY, self._on_app_ready)
59
+
60
+ def _on_app_ready(self, app: Any) -> None:
61
+ """Choose the substrate, once, before anything has resolved one.
62
+
63
+ `APP_READY` is the right moment and not an arbitrary one: the engine
64
+ resolves its substrate lazily, on the first store access, which happens
65
+ during a run. Installing later is **refused** by the app rather than
66
+ allowed to half-apply — some of a run's documents in one backend and
67
+ some in the other is exactly the state this feature exists to make
68
+ unreachable.
69
+
70
+ A failure to install is logged and left alone. The app then uses the
71
+ filesystem default, which is a working program with a note in the log
72
+ rather than a boot that dies over a storage preference.
73
+ """
74
+ try:
75
+ self._substrate = SQLiteSubstrate(self._db_path(app))
76
+ app.substrate = self._substrate
77
+ except Exception:
78
+ logger.exception(
79
+ "sqlite-state could not install its substrate; this project "
80
+ "will use the filesystem default"
81
+ )
82
+ return
83
+ logger.debug("sqlite-state installed a substrate at %s", self._substrate.path)
84
+
85
+ def _db_path(self, app: Any) -> Path:
86
+ """``plugin.sqlite-state.db_path``, or beside the project's other state.
87
+
88
+ Resolved from :attr:`fresh_root` rather than the cwd, so a later
89
+ ``chdir`` cannot move a run's database out from under it — the same
90
+ rule the filesystem substrate follows.
91
+ """
92
+ configured = self._configured_path(app)
93
+ if configured:
94
+ return Path(configured)
95
+ return Path(app.fresh_root) / ".functualize" / DEFAULT_DB_NAME
96
+
97
+ @staticmethod
98
+ def _configured_path(app: Any) -> str | None:
99
+ try:
100
+ from pydantic import BaseModel, Field
101
+
102
+ class _SqliteConfig(BaseModel):
103
+ db_path: str | None = Field(
104
+ default=None,
105
+ description="Where this project's SQLite state lives.",
106
+ )
107
+
108
+ resolved = app.configuration.resolve_model(
109
+ "plugin.sqlite-state", _SqliteConfig
110
+ )
111
+ return resolved.db_path
112
+ except Exception:
113
+ return None
@@ -0,0 +1,225 @@
1
+ """A `StoreSubstrate` over SQLite. One table, six methods.
2
+
3
+ `store-substrate`/T5.
4
+
5
+ This plugin used to supply a **key-value store for one scope's job state**,
6
+ swapped in through `WorkflowScope.replace_state_store`. That was a seam one
7
+ level below the real one, and it is what made the split brain reachable: a
8
+ scope could keep its job state in SQLite while the *records* describing that
9
+ scope — which steps ran, where the walk stopped, what a human approved — stayed
10
+ on the filesystem. A resumed run then found its steps and not its variables.
11
+
12
+ So the plugin now supplies a **substrate**, and every store moves together or
13
+ none does.
14
+
15
+ ## Why this is the interesting implementation
16
+
17
+ `JsonFileSubstrate` is the baseline; it exists to change nothing. This one is
18
+ the reason the port exists at all:
19
+
20
+ - **It has no shared disk.** Two processes on different machines can reach the
21
+ same database, which is what makes a gate blocked on one runner resumable on
22
+ another (spec AC-3).
23
+ - **`lock` is one lock.** A transaction covers every key it is given, so the
24
+ lock-order inversion an external review found between the scope lock and the
25
+ state lock is removed *by construction* rather than by asking callers to
26
+ acquire in a careful order. `JsonFileSubstrate` can only sort; this cannot
27
+ invert.
28
+ - **`write(expect=)` is a real compare-and-swap**, done in SQL, so it does not
29
+ depend on the caller holding anything.
30
+
31
+ ## The revision
32
+
33
+ A monotonically increasing integer per document, assigned by the write. Unlike
34
+ `JsonFileSubstrate`'s content hash it does not survive rewriting the same
35
+ content, and that is fine: a revision is an opaque token to compare, and the
36
+ port says so.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import json
42
+ import sqlite3
43
+ import threading
44
+ from collections.abc import Iterator
45
+ from contextlib import contextmanager
46
+ from pathlib import Path
47
+ from typing import Any
48
+
49
+ from functualize._types.errors import SubstrateUnreadableError
50
+ from functualize._types.protocols import Stored
51
+
52
+ __all__ = ["SQLiteSubstrate"]
53
+
54
+ _SCHEMA = """
55
+ CREATE TABLE IF NOT EXISTS documents (
56
+ key TEXT PRIMARY KEY,
57
+ payload TEXT NOT NULL,
58
+ revision INTEGER NOT NULL
59
+ );
60
+ """
61
+
62
+
63
+ class SQLiteSubstrate:
64
+ """Documents in one SQLite table, reachable from any process."""
65
+
66
+ __slots__ = ("_local", "_path")
67
+
68
+ def __init__(self, path: Path | str) -> None:
69
+ self._path = Path(path)
70
+ self._path.parent.mkdir(parents=True, exist_ok=True)
71
+ #: One connection per thread. SQLite connections are not safe to share
72
+ #: across threads, and `invoke_parallel` runs workers on threads that
73
+ #: all reach the same substrate.
74
+ self._local = threading.local()
75
+ self._conn().executescript(_SCHEMA)
76
+
77
+ @property
78
+ def path(self) -> Path:
79
+ """The database file."""
80
+ return self._path
81
+
82
+ def _conn(self) -> sqlite3.Connection:
83
+ conn = getattr(self._local, "conn", None)
84
+ if conn is None:
85
+ conn = sqlite3.connect(str(self._path), isolation_level=None)
86
+ conn.execute("PRAGMA journal_mode=WAL")
87
+ conn.execute("PRAGMA busy_timeout=10000")
88
+ self._local.conn = conn
89
+ self._local.depth = 0
90
+ return conn
91
+
92
+ # ------------------------------------------------------------------
93
+ # The port
94
+ # ------------------------------------------------------------------
95
+
96
+ def read(self, key: str) -> Stored | None:
97
+ row = (
98
+ self._conn()
99
+ .execute("SELECT payload, revision FROM documents WHERE key = ?", (key,))
100
+ .fetchone()
101
+ )
102
+ if row is None:
103
+ return None
104
+ try:
105
+ data = json.loads(row[0])
106
+ except ValueError as exc:
107
+ raise SubstrateUnreadableError(key, str(exc)) from exc
108
+ if not isinstance(data, dict):
109
+ raise SubstrateUnreadableError(
110
+ key, f"expected an object, found {type(data).__name__}"
111
+ )
112
+ return Stored(data=data, revision=int(row[1]))
113
+
114
+ def write(
115
+ self, key: str, payload: dict[str, Any], *, expect: int | None = None
116
+ ) -> bool:
117
+ """Replace the document, refusing when ``expect`` no longer matches.
118
+
119
+ The compare and the write are **one statement**, so unlike the
120
+ filesystem implementation this does not need the caller to hold a lock
121
+ to be exact. That is the property a backend with no `flock` has to
122
+ provide instead.
123
+ """
124
+ blob = json.dumps(payload, indent=2, sort_keys=True)
125
+ conn = self._conn()
126
+ if expect is None:
127
+ conn.execute(
128
+ "INSERT INTO documents (key, payload, revision) VALUES (?, ?, 1) "
129
+ "ON CONFLICT(key) DO UPDATE SET payload = excluded.payload, "
130
+ "revision = documents.revision + 1",
131
+ (key, blob),
132
+ )
133
+ return True
134
+ cursor = conn.execute(
135
+ "UPDATE documents SET payload = ?, revision = revision + 1 "
136
+ "WHERE key = ? AND revision = ?",
137
+ (blob, key, expect),
138
+ )
139
+ return cursor.rowcount == 1
140
+
141
+ @contextmanager
142
+ def lock(self, *keys: str) -> Iterator[None]:
143
+ """**One** lock, covering every key — the whole point of the port.
144
+
145
+ A write transaction on the database excludes every other writer
146
+ regardless of which keys they named, so a caller that takes state then
147
+ scopes and a caller that takes scopes then state cannot deadlock
148
+ against each other. `JsonFileSubstrate` sorts its per-file locks, which
149
+ prevents a caller from inverting *within one call* and nothing more.
150
+
151
+ Re-entrant per thread, because a store takes the lock around a
152
+ read-modify-write that a wider batch may already hold.
153
+ """
154
+ conn = self._conn()
155
+ depth = getattr(self._local, "depth", 0)
156
+ self._local.depth = depth + 1
157
+ try:
158
+ if depth == 0:
159
+ conn.execute("BEGIN IMMEDIATE")
160
+ try:
161
+ yield
162
+ except BaseException:
163
+ conn.execute("ROLLBACK")
164
+ raise
165
+ else:
166
+ conn.execute("COMMIT")
167
+ else:
168
+ yield
169
+ finally:
170
+ self._local.depth = depth
171
+
172
+ def clear(self, key: str) -> str | None:
173
+ """Copy the document to a backup key and remove the original.
174
+
175
+ Never decodes it — the escape hatch has to work on exactly the content
176
+ :meth:`read` refuses.
177
+ """
178
+ conn = self._conn()
179
+ row = conn.execute(
180
+ "SELECT payload FROM documents WHERE key = ?", (key,)
181
+ ).fetchone()
182
+ if row is None:
183
+ return None
184
+ backup = f"{key}.bak"
185
+ index = 1
186
+ while conn.execute(
187
+ "SELECT 1 FROM documents WHERE key = ?", (backup,)
188
+ ).fetchone():
189
+ backup = f"{key}.bak.{index}"
190
+ index += 1
191
+ conn.execute(
192
+ "INSERT INTO documents (key, payload, revision) VALUES (?, ?, 1)",
193
+ (backup, row[0]),
194
+ )
195
+ conn.execute("DELETE FROM documents WHERE key = ?", (key,))
196
+ return f"{self._path}#{backup}"
197
+
198
+ def delete(self, key: str) -> bool:
199
+ cursor = self._conn().execute("DELETE FROM documents WHERE key = ?", (key,))
200
+ return cursor.rowcount > 0
201
+
202
+ def describe(self, key: str) -> str:
203
+ """The database, the key, and the payload size.
204
+
205
+ A namespace key (``"scope-state/"``) is described in aggregate, as the
206
+ port requires, because scope state is one document per run.
207
+ """
208
+ conn = self._conn()
209
+ if key.endswith("/"):
210
+ row = conn.execute(
211
+ "SELECT COUNT(*), COALESCE(SUM(LENGTH(payload)), 0) "
212
+ "FROM documents WHERE key LIKE ?",
213
+ (f"{key}%",),
214
+ ).fetchone()
215
+ count, total = int(row[0]), int(row[1])
216
+ if not count:
217
+ return "empty"
218
+ plural = "" if count == 1 else "s"
219
+ return f"{count} document{plural}, {total} B in {self._path}"
220
+ row = conn.execute(
221
+ "SELECT LENGTH(payload) FROM documents WHERE key = ?", (key,)
222
+ ).fetchone()
223
+ if row is None:
224
+ return f"{self._path}#{key} (absent)"
225
+ return f"{self._path}#{key} ({int(row[0])} B)"