toolframe-sql-postgres 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,348 @@
1
+ """Explicit, administrator-run provisioning; never invoked by the runtime plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import secrets
8
+
9
+ import psycopg
10
+ from psycopg import sql
11
+ from psycopg.conninfo import conninfo_to_dict, make_conninfo
12
+ from toolframe_sdk import PluginError
13
+
14
+ from .config import names
15
+
16
+
17
+ def identifier(value):
18
+ if not isinstance(value, str) or not re.fullmatch(r"[a-z][a-z0-9_]{0,62}", value):
19
+ raise PluginError(
20
+ "database_name_invalid", "Use a lowercase PostgreSQL database identifier"
21
+ )
22
+ return value
23
+
24
+
25
+ def role(
26
+ connection, name, *, password=None, existing=False, memberships=(), created=None
27
+ ):
28
+ found = connection.execute(
29
+ "SELECT rolsuper,rolcreatedb,rolcreaterole,rolreplication,rolbypassrls,rolcanlogin,rolinherit FROM pg_roles WHERE rolname=%s",
30
+ (name,),
31
+ ).fetchone()
32
+ if found:
33
+ if not existing or any(found[:5]) or found[5] != bool(password) or found[6]:
34
+ raise PluginError(
35
+ "role_conflict",
36
+ "Existing role cannot be adopted; use matching setup credentials",
37
+ )
38
+ parents = connection.execute(
39
+ "SELECT parent.rolname FROM pg_auth_members m JOIN pg_roles member ON member.oid=m.member JOIN pg_roles parent ON parent.oid=m.roleid WHERE member.rolname=%s",
40
+ (name,),
41
+ ).fetchall()
42
+ if {row[0] for row in parents} != set(memberships):
43
+ raise PluginError(
44
+ "role_conflict",
45
+ "Existing role membership differs from the provisioning contract",
46
+ )
47
+ return
48
+ attributes = (
49
+ sql.SQL("LOGIN PASSWORD {} NOINHERIT").format(sql.Literal(password))
50
+ if password
51
+ else sql.SQL("NOLOGIN NOINHERIT")
52
+ )
53
+ connection.execute(
54
+ sql.SQL(
55
+ "CREATE ROLE {} {} NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS"
56
+ ).format(sql.Identifier(name), attributes)
57
+ )
58
+
59
+ if created is not None:
60
+ created.append(("role", name))
61
+
62
+
63
+ def provision(admin_dsn, database, application_id, owners, *, previous=None):
64
+ created = []
65
+ try:
66
+ return _provision(
67
+ admin_dsn,
68
+ database,
69
+ application_id,
70
+ owners,
71
+ previous=previous,
72
+ created=created,
73
+ )
74
+ except BaseException:
75
+ if not created:
76
+ raise
77
+ # Roll back only resources created by this invocation. Existing data is
78
+ # never adopted or dropped as a response to a failed setup attempt.
79
+ try:
80
+ with psycopg.connect(admin_dsn, autocommit=True) as admin:
81
+ for kind, name in reversed(created):
82
+ if kind == "database":
83
+ admin.execute(
84
+ sql.SQL("DROP DATABASE {} WITH (FORCE)").format(
85
+ sql.Identifier(name)
86
+ )
87
+ )
88
+ for kind, name in reversed(created):
89
+ if kind == "role":
90
+ if previous:
91
+ admin.execute(
92
+ sql.SQL("REVOKE ALL ON DATABASE {} FROM {}").format(
93
+ sql.Identifier(database), sql.Identifier(name)
94
+ )
95
+ )
96
+ admin.execute(
97
+ sql.SQL("DROP ROLE {}").format(sql.Identifier(name))
98
+ )
99
+ except psycopg.Error:
100
+ raise PluginError(
101
+ "setup_cleanup_failed",
102
+ "Setup failed and its new resources need administrator inspection before retrying",
103
+ ) from None
104
+ raise
105
+
106
+
107
+ def _provision(admin_dsn, database, application_id, owners, *, previous=None, created):
108
+ """Provision fresh resources or add owners using a previous setup result.
109
+
110
+ Returns environment values. Callers must save them privately, never print them.
111
+ Existing databases/roles are only reused when the stored application identity
112
+ and the previous credentials match. Other application resources are untouched.
113
+ """
114
+ identifier(database)
115
+ if not application_id or len(application_id) > 256:
116
+ raise PluginError("application_id_invalid", "Application identity is required")
117
+ owners = sorted(set(owners))
118
+ if not owners or any(
119
+ not re.fullmatch(r"[a-z][a-z0-9-]{0,63}", owner) for owner in owners
120
+ ):
121
+ raise PluginError("owner_invalid", "Supply stable plugin IDs to provision")
122
+ manager = names(application_id, database=database)["manager"]
123
+ stored = json.loads(previous["TOOLFRAME_POSTGRES_STORES"]) if previous else {}
124
+ manager_password = secrets.token_hex(32)
125
+ manager_dsn = make_conninfo(
126
+ admin_dsn, dbname=database, user=manager, password=manager_password
127
+ )
128
+ if previous:
129
+ if previous["TOOLFRAME_POSTGRES_APPLICATION_ID"] != application_id:
130
+ raise PluginError(
131
+ "identity_mismatch", "Setup output belongs to a different application"
132
+ )
133
+ manager_dsn = previous["TOOLFRAME_POSTGRES_DSN"]
134
+ parsed = conninfo_to_dict(manager_dsn)
135
+ if parsed.get("dbname") != database or parsed.get("user") != manager:
136
+ raise PluginError(
137
+ "identity_mismatch", "Setup output belongs to a different database"
138
+ )
139
+ manager_dsn = make_conninfo(
140
+ admin_dsn, dbname=database, user=manager, password=parsed["password"]
141
+ )
142
+ # Prove that the retained credentials work before touching any existing resource.
143
+ with psycopg.connect(manager_dsn) as check:
144
+ identity = check.execute(
145
+ "SELECT application_id FROM _toolframe_platform.identity"
146
+ ).fetchall()
147
+ if identity != [(application_id,)]:
148
+ raise PluginError(
149
+ "identity_mismatch", "Database application identity differs"
150
+ )
151
+ for owner, credentials in stored.items():
152
+ ns = names(application_id, owner, database=database)
153
+ if credentials.get("user") != ns["controller"]:
154
+ raise PluginError(
155
+ "identity_mismatch",
156
+ "Retained store credentials belong to another owner",
157
+ )
158
+ with psycopg.connect(make_conninfo(manager_dsn, **credentials)) as check:
159
+ identity = check.execute(
160
+ sql.SQL("SELECT application_id,owner FROM {}.identity").format(
161
+ sql.Identifier(ns["metadata"])
162
+ )
163
+ ).fetchall()
164
+ if identity != [(application_id, owner)]:
165
+ raise PluginError("identity_mismatch", "Store identity differs")
166
+ with psycopg.connect(admin_dsn, autocommit=True) as admin:
167
+ if admin.info.server_version < 160000:
168
+ raise PluginError(
169
+ "postgres_version_unsupported",
170
+ "Provisioning requires PostgreSQL 16 or newer",
171
+ )
172
+ found = admin.execute(
173
+ "SELECT pg_get_userbyid(datdba) FROM pg_database WHERE datname=%s",
174
+ (database,),
175
+ ).fetchone()
176
+ if found and (not previous or found[0] != manager):
177
+ raise PluginError(
178
+ "database_conflict", "Refusing to adopt an existing database"
179
+ )
180
+ role(
181
+ admin,
182
+ manager,
183
+ password=manager_password,
184
+ existing=bool(previous),
185
+ created=created,
186
+ )
187
+ if not found:
188
+ admin.execute(
189
+ sql.SQL("CREATE DATABASE {} OWNER {}").format(
190
+ sql.Identifier(database), sql.Identifier(manager)
191
+ )
192
+ )
193
+ created.append(("database", database))
194
+ admin.execute(
195
+ sql.SQL("REVOKE ALL ON DATABASE {} FROM PUBLIC").format(
196
+ sql.Identifier(database)
197
+ )
198
+ )
199
+ for owner in owners:
200
+ ns = names(application_id, owner, database=database)
201
+ creds = stored.get(owner) or {
202
+ "user": ns["controller"],
203
+ "password": secrets.token_hex(32),
204
+ }
205
+ role(
206
+ admin,
207
+ ns["controller"],
208
+ password=creds["password"],
209
+ existing=owner in stored,
210
+ memberships=(ns["runtime"],),
211
+ created=created,
212
+ )
213
+ role(admin, ns["runtime"], existing=owner in stored, created=created)
214
+ admin.execute(
215
+ sql.SQL("GRANT {} TO {} WITH INHERIT FALSE, SET TRUE").format(
216
+ sql.Identifier(ns["runtime"]), sql.Identifier(ns["controller"])
217
+ )
218
+ )
219
+ admin.execute(
220
+ sql.SQL("GRANT CONNECT ON DATABASE {} TO {}").format(
221
+ sql.Identifier(database), sql.Identifier(ns["controller"])
222
+ )
223
+ )
224
+ stored[owner] = creds
225
+ with psycopg.connect(make_conninfo(admin_dsn, dbname=database)) as db:
226
+ db.execute("REVOKE ALL ON SCHEMA public FROM PUBLIC")
227
+ db.execute(
228
+ sql.SQL(
229
+ "CREATE SCHEMA IF NOT EXISTS _toolframe_platform AUTHORIZATION {}"
230
+ ).format(sql.Identifier(manager))
231
+ )
232
+ db.execute(sql.SQL("SET LOCAL ROLE {}").format(sql.Identifier(manager)))
233
+ db.execute(
234
+ "CREATE TABLE IF NOT EXISTS _toolframe_platform.identity(application_id TEXT PRIMARY KEY)"
235
+ )
236
+ current = db.execute(
237
+ "SELECT application_id FROM _toolframe_platform.identity"
238
+ ).fetchall()
239
+ if current and current != [(application_id,)]:
240
+ raise PluginError(
241
+ "identity_mismatch", "Database application identity differs"
242
+ )
243
+ db.execute(
244
+ "INSERT INTO _toolframe_platform.identity VALUES (%s) ON CONFLICT DO NOTHING",
245
+ (application_id,),
246
+ )
247
+ db.execute("RESET ROLE")
248
+ for owner in owners:
249
+ ns = names(application_id, owner, database=database)
250
+ for key in ("schema", "metadata"):
251
+ db.execute(
252
+ sql.SQL("CREATE SCHEMA IF NOT EXISTS {} AUTHORIZATION {}").format(
253
+ sql.Identifier(ns[key]), sql.Identifier(ns["controller"])
254
+ )
255
+ )
256
+ db.execute(
257
+ sql.SQL("REVOKE ALL ON SCHEMA {} FROM PUBLIC").format(
258
+ sql.Identifier(ns[key])
259
+ )
260
+ )
261
+ db.execute(
262
+ sql.SQL("GRANT USAGE ON SCHEMA {} TO {}").format(
263
+ sql.Identifier(ns[key]), sql.Identifier(manager)
264
+ )
265
+ )
266
+ db.execute(
267
+ sql.SQL("GRANT USAGE ON SCHEMA {} TO {}").format(
268
+ sql.Identifier(ns["schema"]), sql.Identifier(ns["runtime"])
269
+ )
270
+ )
271
+ db.execute(
272
+ sql.SQL("SET LOCAL ROLE {}").format(sql.Identifier(ns["controller"]))
273
+ )
274
+ for who, permissions in (
275
+ (ns["runtime"], "SELECT, INSERT, UPDATE, DELETE"),
276
+ (manager, "SELECT"),
277
+ ):
278
+ db.execute(
279
+ sql.SQL(
280
+ "ALTER DEFAULT PRIVILEGES IN SCHEMA {} GRANT {} ON TABLES TO {}"
281
+ ).format(
282
+ sql.Identifier(ns["schema"]),
283
+ sql.SQL(permissions),
284
+ sql.Identifier(who),
285
+ )
286
+ )
287
+ db.execute(
288
+ sql.SQL("GRANT {} ON ALL TABLES IN SCHEMA {} TO {}").format(
289
+ sql.SQL(permissions),
290
+ sql.Identifier(ns["schema"]),
291
+ sql.Identifier(who),
292
+ )
293
+ )
294
+ db.execute(
295
+ sql.SQL(
296
+ "ALTER DEFAULT PRIVILEGES IN SCHEMA {} GRANT USAGE, SELECT ON SEQUENCES TO {}"
297
+ ).format(sql.Identifier(ns["schema"]), sql.Identifier(who))
298
+ )
299
+ db.execute(
300
+ sql.SQL(
301
+ "GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA {} TO {}"
302
+ ).format(sql.Identifier(ns["schema"]), sql.Identifier(who))
303
+ )
304
+ db.execute(
305
+ sql.SQL(
306
+ "ALTER DEFAULT PRIVILEGES IN SCHEMA {} GRANT SELECT ON TABLES TO {}"
307
+ ).format(sql.Identifier(ns["metadata"]), sql.Identifier(manager))
308
+ )
309
+ db.execute(
310
+ sql.SQL(
311
+ "CREATE TABLE IF NOT EXISTS {}.identity(application_id TEXT NOT NULL, owner TEXT NOT NULL)"
312
+ ).format(sql.Identifier(ns["metadata"]))
313
+ )
314
+ identity = db.execute(
315
+ sql.SQL("SELECT application_id,owner FROM {}.identity").format(
316
+ sql.Identifier(ns["metadata"])
317
+ )
318
+ ).fetchall()
319
+ if identity and identity != [(application_id, owner)]:
320
+ raise PluginError("identity_mismatch", "Store identity differs")
321
+ if not identity:
322
+ db.execute(
323
+ sql.SQL("INSERT INTO {}.identity VALUES(%s,%s)").format(
324
+ sql.Identifier(ns["metadata"])
325
+ ),
326
+ (application_id, owner),
327
+ )
328
+ db.execute(
329
+ sql.SQL(
330
+ "CREATE TABLE IF NOT EXISTS {}.migrations(position INTEGER PRIMARY KEY, revision TEXT UNIQUE NOT NULL, checksum TEXT NOT NULL)"
331
+ ).format(sql.Identifier(ns["metadata"]))
332
+ )
333
+ db.execute(
334
+ sql.SQL(
335
+ "CREATE TABLE IF NOT EXISTS {}.receipts(id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, result TEXT NOT NULL)"
336
+ ).format(sql.Identifier(ns["metadata"]))
337
+ )
338
+ db.execute(
339
+ sql.SQL("GRANT SELECT ON ALL TABLES IN SCHEMA {} TO {}").format(
340
+ sql.Identifier(ns["metadata"]), sql.Identifier(manager)
341
+ )
342
+ )
343
+ db.execute("RESET ROLE")
344
+ return {
345
+ "TOOLFRAME_POSTGRES_APPLICATION_ID": application_id,
346
+ "TOOLFRAME_POSTGRES_DSN": manager_dsn,
347
+ "TOOLFRAME_POSTGRES_STORES": json.dumps(stored, separators=(",", ":")),
348
+ }
@@ -0,0 +1,203 @@
1
+ """A bounded SQL surface for managed calls; PostgreSQL roles enforce ownership."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pglast import ast, parse_sql
6
+ from pglast.stream import RawStream
7
+ from pglast.visitors import Visitor
8
+ from toolframe_sdk import PluginError
9
+
10
+ # These are builtins in pg_catalog, which is first in the fixed search_path.
11
+ FUNCTIONS = frozenset(
12
+ [
13
+ "abs",
14
+ "avg",
15
+ "count",
16
+ "length",
17
+ "char_length",
18
+ "lower",
19
+ "upper",
20
+ "min",
21
+ "max",
22
+ "sum",
23
+ "round",
24
+ "trim",
25
+ "btrim",
26
+ "ltrim",
27
+ "rtrim",
28
+ "substr",
29
+ "substring",
30
+ "replace",
31
+ "concat",
32
+ "concat_ws",
33
+ "greatest",
34
+ "least",
35
+ "nullif",
36
+ ]
37
+ )
38
+ MUTATIONS = (ast.InsertStmt, ast.UpdateStmt, ast.DeleteStmt)
39
+ DDL = (ast.CreateStmt, ast.IndexStmt, ast.AlterTableStmt, ast.DropStmt)
40
+
41
+
42
+ def parameters(source: str, count: int) -> str:
43
+ """Convert SDK qmarks to PostgreSQL AST parameters, respecting SQL quoting."""
44
+ result = []
45
+ i = 0
46
+ position = 0
47
+ while i < len(source):
48
+ char = source[i]
49
+ if source.startswith("--", i):
50
+ end = source.find("\n", i)
51
+ end = len(source) if end < 0 else end
52
+ result.append(source[i:end])
53
+ i = end
54
+ elif source.startswith("/*", i):
55
+ start = i
56
+ depth = 1
57
+ i += 2
58
+ while i < len(source) and depth:
59
+ if source.startswith("/*", i):
60
+ depth += 1
61
+ i += 2
62
+ elif source.startswith("*/", i):
63
+ depth -= 1
64
+ i += 2
65
+ else:
66
+ i += 1
67
+ if depth:
68
+ raise ValueError("Unclosed SQL comment")
69
+ result.append(source[start:i])
70
+ elif char in "\"'":
71
+ start = i
72
+ quote = char
73
+ i += 1
74
+ # Escape-string syntax is outside the SDK parameter contract.
75
+ if quote == "'" and start and source[start - 1] in "eE":
76
+ raise ValueError("Use parameters instead of escape-string literals")
77
+ while i < len(source):
78
+ if source[i] == quote:
79
+ i += 1
80
+ if i < len(source) and source[i] == quote:
81
+ i += 1
82
+ continue
83
+ break
84
+ i += 1
85
+ else:
86
+ raise ValueError("Unclosed SQL literal")
87
+ result.append(source[start:i])
88
+ elif char == "$":
89
+ raise ValueError("Use SDK qmark parameters; dollar quoting is unsupported")
90
+ elif char == "?":
91
+ position += 1
92
+ result.append(f"${position}")
93
+ i += 1
94
+ else:
95
+ result.append(char)
96
+ i += 1
97
+ if position != count:
98
+ raise ValueError("SQL parameter count does not match")
99
+ return "".join(result)
100
+
101
+
102
+ class Guard(Visitor):
103
+ def __init__(self, *, read_only=False, migration=False):
104
+ self.read_only, self.migration = read_only, migration
105
+
106
+ def visit(self, ancestors, node):
107
+ if self.read_only and isinstance(node, MUTATIONS):
108
+ raise ValueError("Read-only query cannot mutate")
109
+ if isinstance(node, ast.RangeVar) and (
110
+ node.schemaname
111
+ or node.catalogname
112
+ or node.relname.startswith(("_toolframe", "pg_"))
113
+ or node.relpersistence != "p"
114
+ ):
115
+ raise ValueError("Use unqualified permanent store-owned tables")
116
+ if isinstance(node, ast.IndexStmt) and node.concurrent:
117
+ raise ValueError("Concurrent indexes cannot run in a migration transaction")
118
+ if isinstance(node, ast.A_Expr) and node.name and len(node.name) != 1:
119
+ raise ValueError("Qualified operators are outside the SQL contract")
120
+ if isinstance(node, ast.FuncCall):
121
+ parts = [part.sval for part in node.funcname]
122
+ # PostgreSQL lowers standard LIKE ... ESCAPE into this qualified
123
+ # builtin during parsing. It only transforms the search pattern;
124
+ # children still pass through the guard (including bound functions).
125
+ like_escape = (
126
+ parts == ["pg_catalog", "like_escape"] and len(node.args or ()) == 2
127
+ )
128
+ if not like_escape and (
129
+ len(parts) != 1 or parts[0].lower() not in FUNCTIONS
130
+ ):
131
+ raise ValueError("Function is outside the managed SQL contract")
132
+ if isinstance(node, ast.TypeName):
133
+ parts = [part.sval for part in node.names]
134
+ if any(
135
+ part
136
+ in {"regclass", "regproc", "regprocedure", "regnamespace", "regrole"}
137
+ for part in parts
138
+ ):
139
+ raise ValueError("Catalog identity casts are unsupported")
140
+ if isinstance(node, ast.SelectStmt) and (node.intoClause or node.lockingClause):
141
+ raise ValueError("SELECT INTO and explicit row locking are unsupported")
142
+ if isinstance(node, ast.DropStmt):
143
+ for name in node.objects:
144
+ if len(name) != 1 or name[0].sval.startswith(("_toolframe", "pg_")):
145
+ raise ValueError("Only unqualified store objects may be dropped")
146
+ if isinstance(node, ast.AlterTableCmd) and node.subtype.name not in {
147
+ "AT_AddColumn",
148
+ "AT_DropColumn",
149
+ "AT_AlterColumnType",
150
+ "AT_SetNotNull",
151
+ "AT_DropNotNull",
152
+ "AT_ColumnDefault",
153
+ "AT_AddConstraint",
154
+ "AT_DropConstraint",
155
+ "AT_ValidateConstraint",
156
+ }:
157
+ raise ValueError("Unsupported table alteration")
158
+
159
+
160
+ def prepare(source: str, values, method: str):
161
+ try:
162
+ if not isinstance(source, str) or len(source) > 900_000:
163
+ raise ValueError
164
+ tree = parse_sql(parameters(source, len(values)))
165
+ if len(tree) != 1:
166
+ raise ValueError("Exactly one SQL statement is required")
167
+ statement = tree[0].stmt
168
+ allowed = (ast.SelectStmt,) if method == "query" else MUTATIONS
169
+ if not isinstance(statement, allowed):
170
+ raise TypeError("Unsupported SQL statement")
171
+ Guard(read_only=method == "query")(statement)
172
+ returning = getattr(statement, "returningClause", None)
173
+ if method == "execute_returning" and not returning:
174
+ raise ValueError("Mutation must include RETURNING")
175
+ if method == "execute" and returning:
176
+ raise ValueError("Use execute_returning for returned rows")
177
+ # AsyncRawCursor accepts native $n placeholders and does not reinterpret '%'.
178
+ return RawStream()(statement), list(values)
179
+ except Exception: # noqa: BLE001 - parser diagnostics can contain bound/literal secrets
180
+ raise PluginError(
181
+ "sql_not_supported",
182
+ "SQL is outside the managed PostgreSQL contract or has invalid parameters",
183
+ ) from None
184
+
185
+
186
+ def migration_statements(source):
187
+ try:
188
+ statements = parse_sql(source)
189
+ for raw in statements:
190
+ if not isinstance(raw.stmt, (*DDL, *MUTATIONS)):
191
+ raise TypeError
192
+ if isinstance(raw.stmt, ast.DropStmt) and raw.stmt.removeType.name not in {
193
+ "OBJECT_TABLE",
194
+ "OBJECT_INDEX",
195
+ }:
196
+ raise ValueError
197
+ Guard(migration=True)(raw.stmt)
198
+ yield RawStream()(raw.stmt)
199
+ except Exception: # noqa: BLE001 - redact SQL before crossing the RPC boundary
200
+ raise PluginError(
201
+ "migration_sql_invalid",
202
+ "Migration must contain transactional store-local table/index DDL or DML",
203
+ ) from None
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.5
2
+ Name: toolframe-sql-postgres
3
+ Version: 0.2.0
4
+ Summary: Managed PostgreSQL stores for Toolframe plugins
5
+ Project-URL: Homepage, https://github.com/toolframe/toolframe-sql-postgres
6
+ Project-URL: Repository, https://github.com/toolframe/toolframe-sql-postgres
7
+ Project-URL: Documentation, https://github.com/toolframe/toolframe-sql-postgres#readme
8
+ Project-URL: Issues, https://github.com/toolframe/toolframe-sql-postgres/issues
9
+ Author-email: byfb <62798989+byfb@users.noreply.github.com>
10
+ License-Expression: Apache-2.0
11
+ License-File: LICENSE
12
+ License-File: NOTICE
13
+ Requires-Python: >=3.14
14
+ Requires-Dist: pglast==8.4
15
+ Requires-Dist: psycopg[binary]<4,>=3.3
16
+ Requires-Dist: toolframe-sdk==0.4.0
17
+ Description-Content-Type: text/markdown
18
+
19
+ # Toolframe PostgreSQL storage
20
+
21
+ `sql-postgres` stores Toolframe plugin data in PostgreSQL. Entry Register,
22
+ Auth, MCP Auth and OIDC Auth use a separate schema per plugin inside one database
23
+ per application environment. Database access is loaded **only from the runtime
24
+ process environment**; credentials do not belong in `toolframe.toml`.
25
+
26
+ This release requires Toolframe SDK 0.4 and Core 0.7. Run one Core per
27
+ application. Core 0.7 can separately store its management journal in a dedicated
28
+ PostgreSQL database; SQLite remains the default. Configuration, private keys and
29
+ artifacts still need a persistent application home. PostgreSQL storage does not
30
+ make Core stateless or enable multiple replicas.
31
+ Existing SQLite data is not imported. Plugins are trusted Python code.
32
+
33
+ ## Quick start
34
+
35
+ Use Python 3.14+, PostgreSQL 16+, and `pg_dump` / `pg_restore` with the **same major
36
+ version** as the server. PostgreSQL 16 is the integration-test baseline.
37
+
38
+ Install the host and provider CLI from PyPI:
39
+
40
+ ```sh
41
+ uv venv --python 3.14.7 .venv
42
+ uv pip install --python .venv/bin/python \
43
+ toolframe-core==0.7.0 toolframe-sql-postgres==0.2.0
44
+ .venv/bin/toolframe-core --home ./app init
45
+ ```
46
+
47
+ Read `[application].id` from `app/toolframe.toml`. Supply an administrator DSN from
48
+ your secret manager as `TOOLFRAME_POSTGRES_SETUP_DSN`. It is used only by setup.
49
+ The CLI output file contains generated runtime credentials and must remain private.
50
+
51
+ ```sh
52
+ .venv/bin/toolframe-postgres setup \
53
+ --application-id '<id from app/toolframe.toml>' \
54
+ --database toolframe_development \
55
+ --owner entry-register --owner auth --owner mcp-auth --owner oidc-auth \
56
+ --output ./app/.toolframe/postgres-environment.json
57
+ unset TOOLFRAME_POSTGRES_SETUP_DSN
58
+ ```
59
+
60
+ Add the provider to `app/toolframe.toml`:
61
+
62
+ ```toml
63
+ [plugins.sql-postgres]
64
+ source = "pypi:toolframe-sql-postgres==0.2.0"
65
+ enabled = true
66
+
67
+ [bindings]
68
+ "toolframe.sql" = "sql-postgres"
69
+ ```
70
+
71
+ This configuration sends every SQL consumer to PostgreSQL. Core builds with
72
+ multiple SQL provider support can also run SQLite, using an explicit per-plugin
73
+ override such as `"entry-register/sql" = "sql-sqlite"` in `[bindings]`.
74
+ Choose bindings before initializing stores: changing a retained store's provider
75
+ is rejected and does not copy data. Earlier Core builds require one enabled
76
+ storage provider. The package version alone does not identify multiple-provider
77
+ support; build the matching Core revision when using mixed providers.
78
+
79
+ Install the matching business plugins
80
+ before enabling them: Entry Register 0.4, Auth 0.3, MCP Auth 0.3 and OIDC Auth 0.3.
81
+ Each store owner must be provisioned before Core starts its plugin.
82
+
83
+ ```sh
84
+ .venv/bin/toolframe-postgres run \
85
+ --env-file ./app/.toolframe/postgres-environment.json -- \
86
+ .venv/bin/toolframe-core --home ./app serve
87
+ ```
88
+
89
+ The `run` helper exports the private document as environment variables and replaces
90
+ itself with Core. It removes setup/test administrator variables. Containers can
91
+ inject the same runtime values directly; the provider does not read that file.
92
+
93
+ - [Setup, environment and recovery](docs/operations.md)
94
+ - [SQL and isolation contract](docs/storage-contract.md)
95
+ - [Development and verification](docs/development.md)
96
+ - [Domain glossary](docs/glossary.md) and [architecture decisions](docs/adr/0001-plugin-storage-boundary.md)
97
+
98
+ For release validation and Trusted Publisher setup, see [releasing](docs/releasing.md).
99
+
100
+ ## License
101
+
102
+ Apache-2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
103
+
104
+ For development hooks and contribution rules, see [Contributing](CONTRIBUTING.md).
@@ -0,0 +1,13 @@
1
+ toolframe_sql_postgres/__init__.py,sha256=yxb8kdJcYr5KWyUPPSiS5ESBye1Hqq1tID5GSux87jU,81
2
+ toolframe_sql_postgres/backup.py,sha256=37i1N8YJQNraBHr1J6rAjDLn6ZUe7ytSz3Z1E65nP6o,5538
3
+ toolframe_sql_postgres/cli.py,sha256=0UoyIDX564pNVFZlSi5TS5GbvSu8P1NwkhZP14lCLo0,8079
4
+ toolframe_sql_postgres/config.py,sha256=PZLvDVsMHTMwFxknTQ1KLsG8dD8_UF1R6m_017sD030,3699
5
+ toolframe_sql_postgres/plugin.py,sha256=089XX0DJXcHOp50Ot886pdrmt41SGVzDyHEen0zggCo,23748
6
+ toolframe_sql_postgres/provision.py,sha256=rxrang9Tvgq6dsBJ636lG0GZLrgY8da2G2sDumHZR4Q,14568
7
+ toolframe_sql_postgres/sql.py,sha256=_EsmE8mUCnsgoUJLg-xk377zBCb66t6587vnecYlf20,7805
8
+ toolframe_sql_postgres-0.2.0.dist-info/METADATA,sha256=f0IPQ4hSITRYtEhCByARpdIotfO-Ok-uFMp1T5TJ6EQ,4297
9
+ toolframe_sql_postgres-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
10
+ toolframe_sql_postgres-0.2.0.dist-info/entry_points.txt,sha256=tE0jNFs0Go3xlOk0fCPxWM8mlnVmMIqTiCSdwOwAgsI,144
11
+ toolframe_sql_postgres-0.2.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
12
+ toolframe_sql_postgres-0.2.0.dist-info/licenses/NOTICE,sha256=NJT1YiIiAYUGjix9VOzO18OJRNb8NeCSd5XkbU7VJhw,43
13
+ toolframe_sql_postgres-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ toolframe-postgres = toolframe_sql_postgres.cli:main
3
+
4
+ [toolframe.plugins]
5
+ sql-postgres = toolframe_sql_postgres.plugin:plugin