msgctl 0.1.0a1__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.
- msg/__init__.py +2 -0
- msg/admin/__init__.py +0 -0
- msg/admin/backup_retirement.py +56 -0
- msg/admin/backups.py +341 -0
- msg/admin/custodial_check.py +77 -0
- msg/admin/diagnostics.py +750 -0
- msg/admin/market.py +91 -0
- msg/admin/market_check.py +341 -0
- msg/admin/money.py +322 -0
- msg/admin/preflight.py +142 -0
- msg/admin/recovery_replay.py +281 -0
- msg/admin/restore_database.py +37 -0
- msg/admin/root.py +347 -0
- msg/admin/rotation.py +197 -0
- msg/admin/token_delivery_check.py +54 -0
- msg/admin/upgrade_check.py +58 -0
- msg/application.py +163 -0
- msg/bootstrap.py +305 -0
- msg/cli.py +537 -0
- msg/client.py +725 -0
- msg/client_certificates.py +98 -0
- msg/client_content.py +43 -0
- msg/client_custodial.py +206 -0
- msg/client_market.py +104 -0
- msg/client_recovery.py +186 -0
- msg/client_secrets.py +58 -0
- msg/client_tokens.py +99 -0
- msg/client_upgrade.py +188 -0
- msg/config.py +318 -0
- msg/constants.py +11 -0
- msg/core/__init__.py +0 -0
- msg/core/batching.py +23 -0
- msg/core/codec.py +253 -0
- msg/core/contracts.py +128 -0
- msg/core/cursors.py +68 -0
- msg/core/email_address.py +33 -0
- msg/core/errors.py +22 -0
- msg/core/events.py +6 -0
- msg/core/execution_ports.py +20 -0
- msg/core/executor.py +193 -0
- msg/core/models.py +517 -0
- msg/core/packet.py +71 -0
- msg/core/permissions.py +14 -0
- msg/core/query.py +44 -0
- msg/core/read_query.py +60 -0
- msg/core/registry.py +173 -0
- msg/core/requests.py +58 -0
- msg/core/schema_policy.py +28 -0
- msg/core/schemas.py +30 -0
- msg/core/tags.py +21 -0
- msg/core/template_dsl.py +94 -0
- msg/core/text_patch.py +283 -0
- msg/core/tool_execution.py +28 -0
- msg/core/transfer.py +183 -0
- msg/daemon.py +255 -0
- msg/data/__init__.py +0 -0
- msg/data/bootstrap.json +72 -0
- msg/data/favicon.png +0 -0
- msg/data/logo-dark.svg +5 -0
- msg/data/logo.svg +5 -0
- msg/data/recovery-checkpoint.example.json +1 -0
- msg/data/recovery-checkpoint.schema.json +118 -0
- msg/data/shortcodes.json +1 -0
- msg/data/system/AGENTS.md +6 -0
- msg/data/system/rules/_index.md +17 -0
- msg/data/system/rules/auth.md +6 -0
- msg/data/system/rules/files.md +6 -0
- msg/data/system/rules/identity.md +6 -0
- msg/data/system/rules/protocol.md +6 -0
- msg/data/system/rules/read-write.md +6 -0
- msg/data/system/rules/recovery.md +6 -0
- msg/data/system/rules/security.md +10 -0
- msg/data/system/rules/topics.md +6 -0
- msg/extensions/__init__.py +1 -0
- msg/extensions/hosting.py +259 -0
- msg/extensions/keystore.py +96 -0
- msg/extensions/repositories.py +769 -0
- msg/extensions/rss.py +67 -0
- msg/extensions/ssh.py +264 -0
- msg/extensions/ssh_git.py +300 -0
- msg/extensions/tools.py +150 -0
- msg/hosting_runtime.py +138 -0
- msg/market/__init__.py +1 -0
- msg/market/arbitration.py +481 -0
- msg/market/delivery.py +338 -0
- msg/market/delivery_notifications.py +110 -0
- msg/market/delivery_targets.py +76 -0
- msg/market/email.py +100 -0
- msg/market/escrow.py +345 -0
- msg/market/orders.py +218 -0
- msg/market/policy.py +147 -0
- msg/market/rationale.py +91 -0
- msg/market/references.py +19 -0
- msg/market/targets.py +157 -0
- msg/plugins/__init__.py +28 -0
- msg/plugins/achievements.py +316 -0
- msg/plugins/batch.py +32 -0
- msg/plugins/bounty.py +366 -0
- msg/plugins/collaboration.py +261 -0
- msg/plugins/common.py +217 -0
- msg/plugins/communication.py +853 -0
- msg/plugins/content.py +855 -0
- msg/plugins/custodial_lifecycle.py +224 -0
- msg/plugins/delivery.py +230 -0
- msg/plugins/discovery.py +1266 -0
- msg/plugins/discussion.py +162 -0
- msg/plugins/extensions.py +10 -0
- msg/plugins/following.py +78 -0
- msg/plugins/hosting_capacity.py +52 -0
- msg/plugins/identity.py +1654 -0
- msg/plugins/money.py +221 -0
- msg/plugins/offers.py +183 -0
- msg/plugins/orders.py +273 -0
- msg/plugins/recovery.py +376 -0
- msg/plugins/schemas.py +5 -0
- msg/plugins/sharing.py +300 -0
- msg/plugins/store.py +249 -0
- msg/plugins/system.py +75 -0
- msg/plugins/transfer.py +249 -0
- msg/py.typed +0 -0
- msg/security/__init__.py +0 -0
- msg/security/age_keys.py +112 -0
- msg/security/authentication.py +141 -0
- msg/security/authorization.py +300 -0
- msg/security/backup_retirement.py +135 -0
- msg/security/capabilities.py +157 -0
- msg/security/certificates.py +183 -0
- msg/security/crypto.py +102 -0
- msg/security/custodial_migration.py +292 -0
- msg/security/custody_history.py +146 -0
- msg/security/network.py +81 -0
- msg/security/policy.py +67 -0
- msg/security/quarantine.py +19 -0
- msg/security/root_files.py +67 -0
- msg/security/rotation_journal.py +60 -0
- msg/security/sealed_box.py +70 -0
- msg/security/sharing_policy.py +29 -0
- msg/security/token_delivery.py +93 -0
- msg/security/vault.py +186 -0
- msg/storage/__init__.py +0 -0
- msg/storage/capacity.py +100 -0
- msg/storage/custodial_migration.py +22 -0
- msg/storage/git.py +435 -0
- msg/storage/ledger_migration.py +307 -0
- msg/storage/market_migration.py +49 -0
- msg/storage/postgres.py +663 -0
- msg/storage/query.py +39 -0
- msg/storage/read_only.py +126 -0
- msg/storage/session.py +403 -0
- msg/storage/sqlite.py +360 -0
- msg/storage/topic_event_migration.py +26 -0
- msg/storage/valkey_bus.py +45 -0
- msg/transports/__init__.py +0 -0
- msg/transports/client.py +183 -0
- msg/transports/dictionary.py +387 -0
- msg/transports/graphql.py +75 -0
- msg/transports/http.py +72 -0
- msg/transports/http_routes.py +1577 -0
- msg/transports/mcp.py +127 -0
- msg/transports/packet.py +42 -0
- msg/transports/read_tree_path.py +65 -0
- msg/transports/stdio.py +36 -0
- msg/transports/url_safety.py +123 -0
- msg/tui.py +368 -0
- msg/workers/__init__.py +1 -0
- msg/workers/effects.py +406 -0
- msg/workers/leases.py +19 -0
- msg/workers/mail.py +63 -0
- msg/workers/maintenance.py +310 -0
- msg/workers/sandbox.py +74 -0
- msg/workers/sandbox_child.py +189 -0
- msg/workers/webhook.py +160 -0
- msgctl-0.1.0a1.dist-info/METADATA +96 -0
- msgctl-0.1.0a1.dist-info/RECORD +176 -0
- msgctl-0.1.0a1.dist-info/WHEEL +4 -0
- msgctl-0.1.0a1.dist-info/entry_points.txt +4 -0
msg/storage/postgres.py
ADDED
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
"""PostgreSQL metadata store with the same transactional session contract as SQLite.
|
|
2
|
+
|
|
3
|
+
All writes hold one connection-scoped advisory lock through rollback compensation.
|
|
4
|
+
The executor relies on serialized writers for replay, audit chaining and job
|
|
5
|
+
deduplication; this lock preserves that ordering across processes and hosts.
|
|
6
|
+
Connections must be direct or session-pooled, not transaction-pooled.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextvars
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
import uuid
|
|
18
|
+
from contextlib import asynccontextmanager
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import psycopg
|
|
22
|
+
from psycopg import sql
|
|
23
|
+
from psycopg.conninfo import conninfo_to_dict, make_conninfo
|
|
24
|
+
|
|
25
|
+
from msg.core.errors import Failure
|
|
26
|
+
from msg.core.query import QueryResult, SqlParameters
|
|
27
|
+
from msg.storage.query import SessionQueryResult
|
|
28
|
+
from msg.storage.session import RelationalSession
|
|
29
|
+
|
|
30
|
+
_SCHEMA = """
|
|
31
|
+
CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);
|
|
32
|
+
INSERT INTO schema_version (version) VALUES (1) ON CONFLICT DO NOTHING;
|
|
33
|
+
CREATE TABLE IF NOT EXISTS resources (
|
|
34
|
+
id TEXT PRIMARY KEY, type TEXT NOT NULL, name TEXT NOT NULL,
|
|
35
|
+
parent TEXT REFERENCES resources(id) DEFERRABLE INITIALLY DEFERRED,
|
|
36
|
+
owner TEXT NOT NULL, grp TEXT NOT NULL, mode INTEGER NOT NULL,
|
|
37
|
+
generation INTEGER NOT NULL, revision TEXT, state TEXT NOT NULL,
|
|
38
|
+
created_at TEXT NOT NULL, modified_at TEXT NOT NULL, body TEXT NOT NULL,
|
|
39
|
+
UNIQUE(parent,name));
|
|
40
|
+
CREATE UNIQUE INDEX IF NOT EXISTS one_root ON resources((1)) WHERE parent IS NULL;
|
|
41
|
+
CREATE INDEX IF NOT EXISTS resources_parent ON resources(parent,id);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS resources_time ON resources(created_at,id);
|
|
43
|
+
CREATE INDEX IF NOT EXISTS resources_owner ON resources(owner,id);
|
|
44
|
+
CREATE INDEX IF NOT EXISTS resources_type_state ON resources(type,state,id);
|
|
45
|
+
CREATE TABLE IF NOT EXISTS resource_tags (
|
|
46
|
+
resource_id TEXT NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
|
|
47
|
+
tag TEXT NOT NULL, PRIMARY KEY(tag,resource_id));
|
|
48
|
+
CREATE INDEX IF NOT EXISTS resource_tags_resource ON resource_tags(resource_id);
|
|
49
|
+
CREATE TABLE IF NOT EXISTS revisions (
|
|
50
|
+
id TEXT PRIMARY KEY, resource_id TEXT NOT NULL REFERENCES resources(id),
|
|
51
|
+
created_at TEXT NOT NULL, body TEXT NOT NULL);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS revisions_resource ON revisions(resource_id,created_at,id);
|
|
53
|
+
CREATE TABLE IF NOT EXISTS relations (
|
|
54
|
+
revision_id TEXT NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
|
|
55
|
+
source_id TEXT NOT NULL, type TEXT NOT NULL, target_id TEXT NOT NULL,
|
|
56
|
+
target_revision TEXT, body TEXT NOT NULL);
|
|
57
|
+
CREATE INDEX IF NOT EXISTS relations_target ON relations(target_id,type);
|
|
58
|
+
CREATE TABLE IF NOT EXISTS identities (id TEXT PRIMARY KEY, kind TEXT NOT NULL, generation INTEGER NOT NULL, body TEXT NOT NULL);
|
|
59
|
+
CREATE TABLE IF NOT EXISTS memberships (org TEXT, subject TEXT, generation INTEGER NOT NULL, body TEXT NOT NULL, PRIMARY KEY(org,subject));
|
|
60
|
+
CREATE TABLE IF NOT EXISTS emails (subject TEXT PRIMARY KEY, generation INTEGER NOT NULL, body TEXT NOT NULL);
|
|
61
|
+
CREATE TABLE IF NOT EXISTS credentials (id TEXT PRIMARY KEY, subject TEXT NOT NULL, body TEXT NOT NULL);
|
|
62
|
+
CREATE INDEX IF NOT EXISTS credentials_subject ON credentials(subject);
|
|
63
|
+
CREATE TABLE IF NOT EXISTS token_deliveries (
|
|
64
|
+
credential_id TEXT PRIMARY KEY, subject TEXT NOT NULL, request_id TEXT NOT NULL,
|
|
65
|
+
request_digest TEXT NOT NULL, recovery_verifier TEXT NOT NULL,
|
|
66
|
+
recovery_expires_at TEXT NOT NULL, claimed_at TEXT, consumed_at TEXT);
|
|
67
|
+
CREATE INDEX IF NOT EXISTS token_deliveries_subject ON token_deliveries(subject,request_id);
|
|
68
|
+
CREATE TABLE IF NOT EXISTS identity_keys (
|
|
69
|
+
key_id TEXT PRIMARY KEY, subject TEXT NOT NULL, public_key TEXT NOT NULL,
|
|
70
|
+
created_at TEXT NOT NULL, retired_at TEXT, is_primary INTEGER NOT NULL DEFAULT 0);
|
|
71
|
+
CREATE UNIQUE INDEX IF NOT EXISTS identity_keys_primary ON identity_keys(subject) WHERE is_primary=1;
|
|
72
|
+
CREATE INDEX IF NOT EXISTS identity_keys_subject ON identity_keys(subject,created_at,key_id);
|
|
73
|
+
CREATE TABLE IF NOT EXISTS encryption_subkeys (
|
|
74
|
+
key_id TEXT PRIMARY KEY, subject TEXT NOT NULL, recipient TEXT NOT NULL UNIQUE,
|
|
75
|
+
public_key TEXT NOT NULL, created_at TEXT NOT NULL, retired_at TEXT,
|
|
76
|
+
is_primary INTEGER NOT NULL DEFAULT 0);
|
|
77
|
+
CREATE UNIQUE INDEX IF NOT EXISTS encryption_subkeys_primary ON encryption_subkeys(subject) WHERE is_primary=1;
|
|
78
|
+
CREATE INDEX IF NOT EXISTS encryption_subkeys_subject ON encryption_subkeys(subject,created_at,key_id);
|
|
79
|
+
CREATE TABLE IF NOT EXISTS certificates (id TEXT PRIMARY KEY, subject TEXT NOT NULL, parent TEXT, revoked INTEGER NOT NULL DEFAULT 0, body TEXT NOT NULL);
|
|
80
|
+
CREATE TABLE IF NOT EXISTS csrs (id TEXT PRIMARY KEY, generation INTEGER NOT NULL, state TEXT NOT NULL, body TEXT NOT NULL, state_body TEXT NOT NULL);
|
|
81
|
+
CREATE TABLE IF NOT EXISTS results (subject TEXT, request_id TEXT, digest TEXT NOT NULL, body TEXT NOT NULL, PRIMARY KEY(subject,request_id));
|
|
82
|
+
CREATE TABLE IF NOT EXISTS batches (subject TEXT, request_id TEXT, digest TEXT NOT NULL, PRIMARY KEY(subject,request_id));
|
|
83
|
+
CREATE TABLE IF NOT EXISTS transfers (id TEXT PRIMARY KEY, subject TEXT NOT NULL, generation INTEGER NOT NULL, body TEXT NOT NULL, limits TEXT NOT NULL DEFAULT '{}');
|
|
84
|
+
CREATE TABLE IF NOT EXISTS chunks (transfer_id TEXT NOT NULL, "offset" BIGINT NOT NULL, length BIGINT NOT NULL, body TEXT NOT NULL, PRIMARY KEY(transfer_id,"offset"));
|
|
85
|
+
CREATE TABLE IF NOT EXISTS events (seq BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id TEXT UNIQUE NOT NULL, body TEXT NOT NULL);
|
|
86
|
+
CREATE TABLE IF NOT EXISTS topic_event_projection (
|
|
87
|
+
seq BIGINT PRIMARY KEY REFERENCES events(seq) ON DELETE CASCADE, topic TEXT NOT NULL);
|
|
88
|
+
CREATE INDEX IF NOT EXISTS topic_event_projection_topic ON topic_event_projection(topic,seq DESC);
|
|
89
|
+
CREATE TABLE IF NOT EXISTS audit (seq BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, digest TEXT UNIQUE NOT NULL, previous TEXT, body TEXT NOT NULL);
|
|
90
|
+
CREATE TABLE IF NOT EXISTS ledger_accounts (
|
|
91
|
+
id TEXT PRIMARY KEY,
|
|
92
|
+
kind TEXT NOT NULL CHECK(kind IN ('subject','order_escrow','bounty_escrow')),
|
|
93
|
+
subject_id TEXT REFERENCES identities(id), source_id TEXT,
|
|
94
|
+
CHECK((kind='subject' AND subject_id=id AND source_id IS NULL) OR
|
|
95
|
+
(kind IN ('order_escrow','bounty_escrow') AND subject_id IS NULL AND source_id IS NOT NULL)),
|
|
96
|
+
UNIQUE(kind,source_id));
|
|
97
|
+
CREATE TABLE IF NOT EXISTS money_accounts (
|
|
98
|
+
-- Historical column name; values are LedgerAccount IDs, not always subjects.
|
|
99
|
+
subject_id TEXT NOT NULL CONSTRAINT money_accounts_ledger_account_fkey REFERENCES ledger_accounts(id),
|
|
100
|
+
currency_id TEXT NOT NULL CHECK(currency_id='primary'),
|
|
101
|
+
PRIMARY KEY(subject_id,currency_id));
|
|
102
|
+
CREATE TABLE IF NOT EXISTS money_bank_roles (
|
|
103
|
+
subject_id TEXT PRIMARY KEY REFERENCES identities(id),
|
|
104
|
+
status TEXT NOT NULL CHECK(status IN ('active','revoked')),
|
|
105
|
+
granted_at TEXT NOT NULL, granted_by TEXT NOT NULL CHECK(granted_by='u_root'),
|
|
106
|
+
revoked_at TEXT);
|
|
107
|
+
CREATE TABLE IF NOT EXISTS money_ledger (
|
|
108
|
+
seq BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
|
109
|
+
id TEXT NOT NULL UNIQUE,
|
|
110
|
+
kind TEXT NOT NULL CHECK(kind IN ('mint','burn','transfer','redeem','refund')),
|
|
111
|
+
currency_id TEXT NOT NULL CHECK(currency_id='primary'),
|
|
112
|
+
amount_minor BIGINT NOT NULL CHECK(amount_minor>0),
|
|
113
|
+
debit_account TEXT CONSTRAINT money_ledger_debit_ledger_account_fkey REFERENCES ledger_accounts(id),
|
|
114
|
+
credit_account TEXT CONSTRAINT money_ledger_credit_ledger_account_fkey REFERENCES ledger_accounts(id),
|
|
115
|
+
actor TEXT NOT NULL,
|
|
116
|
+
request_id TEXT NOT NULL,
|
|
117
|
+
reference TEXT,
|
|
118
|
+
committed_at TEXT NOT NULL,
|
|
119
|
+
policy_version INTEGER NOT NULL CHECK(policy_version>0),
|
|
120
|
+
policy_digest TEXT NOT NULL,
|
|
121
|
+
receipt TEXT NOT NULL,
|
|
122
|
+
CHECK((kind='mint' AND debit_account IS NULL AND credit_account IS NOT NULL) OR
|
|
123
|
+
(kind='burn' AND debit_account IS NOT NULL AND credit_account IS NULL) OR
|
|
124
|
+
(kind IN ('transfer','redeem','refund') AND debit_account IS NOT NULL AND
|
|
125
|
+
credit_account IS NOT NULL AND debit_account<>credit_account)),
|
|
126
|
+
entry_key TEXT NOT NULL DEFAULT 'primary',
|
|
127
|
+
UNIQUE(actor,request_id,entry_key));
|
|
128
|
+
CREATE INDEX IF NOT EXISTS money_ledger_debit ON money_ledger(debit_account,seq);
|
|
129
|
+
CREATE INDEX IF NOT EXISTS money_ledger_credit ON money_ledger(credit_account,seq);
|
|
130
|
+
CREATE OR REPLACE FUNCTION msg_money_ledger_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
131
|
+
BEGIN RAISE EXCEPTION 'append_only_money_ledger' USING ERRCODE = '23514'; END;
|
|
132
|
+
$$;
|
|
133
|
+
DO $$ BEGIN
|
|
134
|
+
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'money_ledger_no_update'
|
|
135
|
+
AND tgrelid = 'money_ledger'::regclass) THEN
|
|
136
|
+
CREATE TRIGGER money_ledger_no_update BEFORE UPDATE OR DELETE ON money_ledger
|
|
137
|
+
FOR EACH ROW EXECUTE FUNCTION msg_money_ledger_append_only();
|
|
138
|
+
END IF;
|
|
139
|
+
END $$;
|
|
140
|
+
CREATE TABLE IF NOT EXISTS store_packages (
|
|
141
|
+
id TEXT PRIMARY KEY, listing_id TEXT NOT NULL, listing_revision TEXT NOT NULL,
|
|
142
|
+
seller TEXT NOT NULL, revision TEXT NOT NULL UNIQUE, kind TEXT NOT NULL,
|
|
143
|
+
manifest TEXT NOT NULL, payload_refs TEXT NOT NULL, digest TEXT NOT NULL,
|
|
144
|
+
total_size BIGINT NOT NULL, delivery_mode TEXT NOT NULL, deposited_at TEXT NOT NULL);
|
|
145
|
+
CREATE TABLE IF NOT EXISTS bounty_listings (
|
|
146
|
+
listing_id TEXT PRIMARY KEY REFERENCES resources(id),
|
|
147
|
+
publisher TEXT NOT NULL REFERENCES identities(id),
|
|
148
|
+
escrow_subject TEXT NOT NULL UNIQUE CONSTRAINT bounty_listings_escrow_account_fkey REFERENCES ledger_accounts(id),
|
|
149
|
+
reward_minor BIGINT NOT NULL CHECK(reward_minor>0),
|
|
150
|
+
budget_minor BIGINT NOT NULL CHECK(budget_minor>0),
|
|
151
|
+
max_claims INTEGER NOT NULL CHECK(max_claims>0),
|
|
152
|
+
claim_limit_per_subject INTEGER NOT NULL CHECK(claim_limit_per_subject>0),
|
|
153
|
+
verifier_id TEXT NOT NULL, verifier_version INTEGER NOT NULL CHECK(verifier_version>0),
|
|
154
|
+
eligibility TEXT NOT NULL,
|
|
155
|
+
state TEXT NOT NULL CHECK(state IN ('draft','active','paused','closed')),
|
|
156
|
+
pause_reason TEXT, expires_at TEXT, created_at TEXT NOT NULL);
|
|
157
|
+
CREATE INDEX IF NOT EXISTS bounty_listings_publisher ON bounty_listings(publisher,listing_id);
|
|
158
|
+
CREATE TABLE IF NOT EXISTS bounty_challenges (
|
|
159
|
+
id TEXT PRIMARY KEY,
|
|
160
|
+
listing_id TEXT NOT NULL REFERENCES bounty_listings(listing_id),
|
|
161
|
+
claimant TEXT NOT NULL REFERENCES identities(id),
|
|
162
|
+
key_id TEXT NOT NULL, nonce TEXT NOT NULL,
|
|
163
|
+
issued_at TEXT NOT NULL, expires_at TEXT NOT NULL,
|
|
164
|
+
verifier_version INTEGER NOT NULL CHECK(verifier_version>0),
|
|
165
|
+
payload TEXT NOT NULL, consumed_at TEXT);
|
|
166
|
+
CREATE INDEX IF NOT EXISTS bounty_challenges_claimant ON bounty_challenges(listing_id,claimant);
|
|
167
|
+
CREATE TABLE IF NOT EXISTS bounty_claims (
|
|
168
|
+
id TEXT PRIMARY KEY,
|
|
169
|
+
listing_id TEXT NOT NULL REFERENCES bounty_listings(listing_id),
|
|
170
|
+
claimant TEXT NOT NULL REFERENCES identities(id),
|
|
171
|
+
challenge_id TEXT NOT NULL UNIQUE REFERENCES bounty_challenges(id),
|
|
172
|
+
proof_digest TEXT NOT NULL, status TEXT NOT NULL CHECK(status='paid'),
|
|
173
|
+
reward_minor BIGINT NOT NULL CHECK(reward_minor>0),
|
|
174
|
+
transaction_id TEXT NOT NULL UNIQUE REFERENCES money_ledger(id),
|
|
175
|
+
claimed_at TEXT NOT NULL);
|
|
176
|
+
CREATE INDEX IF NOT EXISTS bounty_claims_claimant ON bounty_claims(listing_id,claimant);
|
|
177
|
+
CREATE TABLE IF NOT EXISTS store_orders (
|
|
178
|
+
id TEXT PRIMARY KEY,
|
|
179
|
+
buyer TEXT NOT NULL REFERENCES identities(id),
|
|
180
|
+
seller TEXT NOT NULL REFERENCES identities(id),
|
|
181
|
+
listing_id TEXT NOT NULL REFERENCES resources(id),
|
|
182
|
+
listing_revision TEXT NOT NULL,
|
|
183
|
+
package_id TEXT, package_revision TEXT, package_digest TEXT,
|
|
184
|
+
quantity INTEGER NOT NULL CHECK(quantity>0),
|
|
185
|
+
unit_price_minor BIGINT NOT NULL CHECK(unit_price_minor>0),
|
|
186
|
+
total_price_minor BIGINT NOT NULL CHECK(total_price_minor>0),
|
|
187
|
+
currency_id TEXT NOT NULL CHECK(currency_id='primary'),
|
|
188
|
+
escrow_subject TEXT NOT NULL UNIQUE CONSTRAINT store_orders_escrow_account_fkey REFERENCES ledger_accounts(id),
|
|
189
|
+
escrow_policy TEXT NOT NULL, dispute_policy TEXT NOT NULL,
|
|
190
|
+
terms_digest TEXT NOT NULL, delivery_target TEXT NOT NULL,
|
|
191
|
+
payment_intent_digest TEXT NOT NULL,
|
|
192
|
+
payment_transaction_id TEXT NOT NULL UNIQUE REFERENCES money_ledger(id),
|
|
193
|
+
state TEXT NOT NULL CHECK(state IN
|
|
194
|
+
('funded','delivered','accepted','settled','cancelled','refunded','disputed')),
|
|
195
|
+
created_at TEXT NOT NULL, funded_at TEXT NOT NULL,
|
|
196
|
+
delivered_at TEXT, settled_at TEXT, receipt_refs TEXT NOT NULL);
|
|
197
|
+
CREATE INDEX IF NOT EXISTS store_orders_buyer ON store_orders(buyer,id);
|
|
198
|
+
CREATE INDEX IF NOT EXISTS store_orders_seller ON store_orders(seller,id);
|
|
199
|
+
CREATE INDEX IF NOT EXISTS store_orders_listing ON store_orders(listing_id,state);
|
|
200
|
+
CREATE TABLE IF NOT EXISTS store_deliveries (
|
|
201
|
+
id TEXT PRIMARY KEY,
|
|
202
|
+
order_id TEXT NOT NULL UNIQUE REFERENCES store_orders(id),
|
|
203
|
+
recipient_subject TEXT NOT NULL REFERENCES identities(id),
|
|
204
|
+
kind TEXT NOT NULL,
|
|
205
|
+
payload_refs TEXT NOT NULL, manifest TEXT NOT NULL,
|
|
206
|
+
package_digest TEXT NOT NULL, delivery_digest TEXT NOT NULL,
|
|
207
|
+
channel TEXT NOT NULL CHECK(channel='site'),
|
|
208
|
+
state TEXT NOT NULL CHECK(state IN ('prepared','claimed')),
|
|
209
|
+
prepared_at TEXT NOT NULL, claimed_at TEXT, receipt TEXT);
|
|
210
|
+
CREATE TABLE IF NOT EXISTS order_deadlines (
|
|
211
|
+
order_id TEXT PRIMARY KEY REFERENCES store_orders(id), expires_at TEXT NOT NULL);
|
|
212
|
+
CREATE INDEX IF NOT EXISTS order_deadlines_due ON order_deadlines(expires_at,order_id);
|
|
213
|
+
-- Market v3 keeps published v1/v2 signed contracts intact. Creation reserves stock without money.
|
|
214
|
+
ALTER TABLE store_orders ALTER COLUMN payment_transaction_id DROP NOT NULL;
|
|
215
|
+
ALTER TABLE store_orders ALTER COLUMN funded_at DROP NOT NULL;
|
|
216
|
+
ALTER TABLE store_orders DROP CONSTRAINT IF EXISTS store_orders_state_check;
|
|
217
|
+
ALTER TABLE store_orders ADD CONSTRAINT store_orders_state_check CHECK(state IN
|
|
218
|
+
('created','funded','delivered','accepted','settled','cancelled','refunded','disputed'));
|
|
219
|
+
CREATE TABLE IF NOT EXISTS delivery_email_endpoints (
|
|
220
|
+
id TEXT PRIMARY KEY, subject TEXT NOT NULL REFERENCES identities(id),
|
|
221
|
+
address TEXT NOT NULL, verified_at TEXT NOT NULL, active BOOLEAN NOT NULL DEFAULT TRUE);
|
|
222
|
+
CREATE TABLE IF NOT EXISTS delivery_email_challenges (
|
|
223
|
+
id TEXT PRIMARY KEY, order_id TEXT NOT NULL REFERENCES store_orders(id),
|
|
224
|
+
subject TEXT NOT NULL REFERENCES identities(id), address TEXT NOT NULL,
|
|
225
|
+
verifier TEXT NOT NULL, expires_at TEXT NOT NULL, consumed BOOLEAN NOT NULL DEFAULT FALSE);
|
|
226
|
+
CREATE TABLE IF NOT EXISTS order_contracts (
|
|
227
|
+
order_id TEXT PRIMARY KEY REFERENCES store_orders(id), body TEXT NOT NULL, digest TEXT NOT NULL);
|
|
228
|
+
CREATE TABLE IF NOT EXISTS order_settlements (
|
|
229
|
+
order_id TEXT PRIMARY KEY REFERENCES store_orders(id), decision_id TEXT UNIQUE, body TEXT NOT NULL);
|
|
230
|
+
CREATE TABLE IF NOT EXISTS order_transitions (
|
|
231
|
+
id TEXT PRIMARY KEY, order_id TEXT NOT NULL REFERENCES store_orders(id), body TEXT NOT NULL);
|
|
232
|
+
CREATE TABLE IF NOT EXISTS delivery_envelopes (
|
|
233
|
+
order_id TEXT PRIMARY KEY REFERENCES store_orders(id), body TEXT NOT NULL);
|
|
234
|
+
CREATE TABLE IF NOT EXISTS arbitration_policies (
|
|
235
|
+
id TEXT PRIMARY KEY, body TEXT NOT NULL, digest TEXT NOT NULL);
|
|
236
|
+
CREATE TABLE IF NOT EXISTS arbitrator_roles (
|
|
237
|
+
subject TEXT PRIMARY KEY REFERENCES identities(id), epoch TEXT NOT NULL, active BOOLEAN NOT NULL);
|
|
238
|
+
CREATE TABLE IF NOT EXISTS arbitrator_conflicts (
|
|
239
|
+
arbitrator TEXT NOT NULL REFERENCES identities(id), party TEXT NOT NULL REFERENCES identities(id),
|
|
240
|
+
PRIMARY KEY(arbitrator,party));
|
|
241
|
+
CREATE TABLE IF NOT EXISTS arbitration_cases (
|
|
242
|
+
id TEXT PRIMARY KEY, order_id TEXT NOT NULL UNIQUE REFERENCES store_orders(id),
|
|
243
|
+
round INTEGER NOT NULL DEFAULT 0, state TEXT NOT NULL, deadline TEXT NOT NULL, body TEXT NOT NULL);
|
|
244
|
+
CREATE TABLE IF NOT EXISTS arbitration_votes (
|
|
245
|
+
case_id TEXT NOT NULL REFERENCES arbitration_cases(id), round INTEGER NOT NULL,
|
|
246
|
+
arbitrator TEXT NOT NULL REFERENCES identities(id), body TEXT NOT NULL,
|
|
247
|
+
PRIMARY KEY(case_id,round,arbitrator));
|
|
248
|
+
CREATE TABLE IF NOT EXISTS arbitration_decisions (
|
|
249
|
+
id TEXT PRIMARY KEY, case_id TEXT NOT NULL REFERENCES arbitration_cases(id),
|
|
250
|
+
round INTEGER NOT NULL, body TEXT NOT NULL, revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
|
251
|
+
UNIQUE(case_id,round));
|
|
252
|
+
CREATE TABLE IF NOT EXISTS arbitration_evidence (
|
|
253
|
+
id TEXT PRIMARY KEY, case_id TEXT NOT NULL REFERENCES arbitration_cases(id),
|
|
254
|
+
author TEXT NOT NULL REFERENCES identities(id), visibility TEXT NOT NULL, body TEXT NOT NULL);
|
|
255
|
+
DO $$ DECLARE tbl TEXT; BEGIN
|
|
256
|
+
FOREACH tbl IN ARRAY ARRAY['order_contracts','order_settlements','order_transitions',
|
|
257
|
+
'arbitration_policies','arbitration_votes'] LOOP
|
|
258
|
+
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname=tbl||'_append_only'
|
|
259
|
+
AND tgrelid=tbl::regclass) THEN
|
|
260
|
+
EXECUTE format('CREATE TRIGGER %I BEFORE UPDATE OR DELETE ON %I FOR EACH ROW
|
|
261
|
+
EXECUTE FUNCTION msg_money_ledger_append_only()', tbl||'_append_only',tbl);
|
|
262
|
+
END IF;
|
|
263
|
+
END LOOP;
|
|
264
|
+
END $$;
|
|
265
|
+
CREATE TABLE IF NOT EXISTS order_escrow_decisions (
|
|
266
|
+
order_id TEXT PRIMARY KEY REFERENCES store_orders(id),
|
|
267
|
+
id TEXT NOT NULL UNIQUE, body TEXT NOT NULL, signature TEXT NOT NULL,
|
|
268
|
+
source_proof TEXT NOT NULL,
|
|
269
|
+
transaction_id TEXT NOT NULL UNIQUE REFERENCES money_ledger(id));
|
|
270
|
+
CREATE OR REPLACE FUNCTION msg_escrow_decision_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
271
|
+
BEGIN RAISE EXCEPTION 'append_only_escrow_decision' USING ERRCODE = '23514'; END;
|
|
272
|
+
$$;
|
|
273
|
+
DO $$ BEGIN
|
|
274
|
+
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'escrow_decision_no_update'
|
|
275
|
+
AND tgrelid = 'order_escrow_decisions'::regclass) THEN
|
|
276
|
+
CREATE TRIGGER escrow_decision_no_update BEFORE UPDATE OR DELETE ON order_escrow_decisions
|
|
277
|
+
FOR EACH ROW EXECUTE FUNCTION msg_escrow_decision_append_only();
|
|
278
|
+
END IF;
|
|
279
|
+
END $$;
|
|
280
|
+
CREATE TABLE IF NOT EXISTS server_offers (
|
|
281
|
+
offer_id TEXT PRIMARY KEY,
|
|
282
|
+
resource_kind TEXT NOT NULL, unit TEXT NOT NULL,
|
|
283
|
+
price_minor BIGINT NOT NULL CHECK(price_minor>0),
|
|
284
|
+
min_quantity BIGINT NOT NULL CHECK(min_quantity>0),
|
|
285
|
+
max_quantity BIGINT NOT NULL CHECK(max_quantity>=min_quantity),
|
|
286
|
+
entitlement_kind TEXT NOT NULL,
|
|
287
|
+
duration_seconds BIGINT CHECK(duration_seconds IS NULL OR duration_seconds>0),
|
|
288
|
+
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
|
289
|
+
price_revision TEXT NOT NULL);
|
|
290
|
+
CREATE TABLE IF NOT EXISTS resource_entitlements (
|
|
291
|
+
id TEXT PRIMARY KEY,
|
|
292
|
+
subject_id TEXT NOT NULL REFERENCES identities(id),
|
|
293
|
+
offer_id TEXT NOT NULL REFERENCES server_offers(offer_id),
|
|
294
|
+
purchase_request_id TEXT NOT NULL,
|
|
295
|
+
quantity BIGINT NOT NULL CHECK(quantity>0),
|
|
296
|
+
entitlement_kind TEXT NOT NULL,
|
|
297
|
+
granted_at TEXT NOT NULL, expires_at TEXT,
|
|
298
|
+
redeem_transaction_id TEXT UNIQUE REFERENCES money_ledger(id),
|
|
299
|
+
UNIQUE(subject_id,purchase_request_id));
|
|
300
|
+
CREATE OR REPLACE FUNCTION msg_audit_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
301
|
+
BEGIN RAISE EXCEPTION 'append_only_audit' USING ERRCODE = '23514'; END;
|
|
302
|
+
$$;
|
|
303
|
+
DO $$ BEGIN
|
|
304
|
+
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'audit_no_update'
|
|
305
|
+
AND tgrelid = 'audit'::regclass) THEN
|
|
306
|
+
CREATE TRIGGER audit_no_update BEFORE UPDATE OR DELETE ON audit
|
|
307
|
+
FOR EACH ROW EXECUTE FUNCTION msg_audit_append_only();
|
|
308
|
+
END IF;
|
|
309
|
+
END $$;
|
|
310
|
+
CREATE TABLE IF NOT EXISTS jobs (id TEXT PRIMARY KEY, dedupe TEXT UNIQUE NOT NULL, kind TEXT NOT NULL, state TEXT NOT NULL, next_at TEXT NOT NULL, body TEXT NOT NULL);
|
|
311
|
+
CREATE TABLE IF NOT EXISTS watches (subject TEXT, resource TEXT, PRIMARY KEY(subject,resource));
|
|
312
|
+
CREATE TABLE IF NOT EXISTS messages (id TEXT PRIMARY KEY, sender TEXT, recipient TEXT, resource TEXT, event_id TEXT, body TEXT NOT NULL);
|
|
313
|
+
CREATE TABLE IF NOT EXISTS webhook_endpoints (
|
|
314
|
+
subject TEXT PRIMARY KEY, url TEXT NOT NULL, nonce TEXT NOT NULL, ciphertext TEXT NOT NULL,
|
|
315
|
+
enabled INTEGER NOT NULL CHECK(enabled IN (0,1)), generation INTEGER NOT NULL);
|
|
316
|
+
CREATE TABLE IF NOT EXISTS reactions (subject TEXT, resource TEXT, kind TEXT, revision TEXT NOT NULL DEFAULT '', body TEXT NOT NULL, PRIMARY KEY(subject,resource,kind,revision));
|
|
317
|
+
CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
318
|
+
CREATE TABLE IF NOT EXISTS projections (resource_id TEXT PRIMARY KEY, text TEXT NOT NULL);
|
|
319
|
+
CREATE TABLE IF NOT EXISTS email_challenges (subject TEXT PRIMARY KEY, digest TEXT NOT NULL, expires TEXT NOT NULL);
|
|
320
|
+
CREATE TABLE IF NOT EXISTS achievement_ceremonies (
|
|
321
|
+
id TEXT PRIMARY KEY, subject TEXT NOT NULL, state TEXT NOT NULL, body TEXT NOT NULL);
|
|
322
|
+
CREATE INDEX IF NOT EXISTS achievement_ceremonies_subject ON achievement_ceremonies(subject);
|
|
323
|
+
CREATE TABLE IF NOT EXISTS achievement_grants (
|
|
324
|
+
id TEXT PRIMARY KEY, subject TEXT NOT NULL, achievement_id TEXT NOT NULL,
|
|
325
|
+
spec_version INTEGER NOT NULL, body TEXT NOT NULL,
|
|
326
|
+
UNIQUE(subject,achievement_id,spec_version));
|
|
327
|
+
CREATE INDEX IF NOT EXISTS achievement_grants_lookup ON achievement_grants(achievement_id,subject);
|
|
328
|
+
CREATE TABLE IF NOT EXISTS achievement_pins (
|
|
329
|
+
subject TEXT NOT NULL REFERENCES identities(id),
|
|
330
|
+
grant_id TEXT NOT NULL REFERENCES achievement_grants(id),
|
|
331
|
+
position INTEGER NOT NULL CHECK(position>=0),
|
|
332
|
+
PRIMARY KEY(subject,grant_id), UNIQUE(subject,position));
|
|
333
|
+
CREATE TABLE IF NOT EXISTS share_grants (
|
|
334
|
+
id TEXT PRIMARY KEY, resource_id TEXT NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
|
|
335
|
+
grantor TEXT NOT NULL, grantee TEXT NOT NULL, created_at TEXT NOT NULL,
|
|
336
|
+
expires_at TEXT NOT NULL, revoked_at TEXT);
|
|
337
|
+
CREATE UNIQUE INDEX IF NOT EXISTS share_grants_active ON share_grants(resource_id,grantee)
|
|
338
|
+
WHERE revoked_at IS NULL;
|
|
339
|
+
CREATE INDEX IF NOT EXISTS share_grants_grantee ON share_grants(grantee,resource_id);
|
|
340
|
+
CREATE TABLE IF NOT EXISTS share_grants_v2 (
|
|
341
|
+
id TEXT PRIMARY KEY, resource_id TEXT NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
|
|
342
|
+
grantor TEXT NOT NULL, grantee TEXT NOT NULL, grantee_kind TEXT NOT NULL
|
|
343
|
+
CHECK(grantee_kind IN ('user','group')), parent_id TEXT REFERENCES share_grants_v2(id),
|
|
344
|
+
operations TEXT NOT NULL, constraints TEXT NOT NULL, allow_reshare INTEGER NOT NULL
|
|
345
|
+
CHECK(allow_reshare IN (0,1)), created_at TEXT NOT NULL, expires_at TEXT NOT NULL,
|
|
346
|
+
revoked_at TEXT);
|
|
347
|
+
CREATE INDEX IF NOT EXISTS share_grants_v2_resource ON share_grants_v2(resource_id,grantee,grantee_kind);
|
|
348
|
+
CREATE INDEX IF NOT EXISTS share_grants_v2_parent ON share_grants_v2(parent_id);
|
|
349
|
+
CREATE TABLE IF NOT EXISTS share_links (
|
|
350
|
+
id TEXT PRIMARY KEY, resource_id TEXT NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
|
|
351
|
+
grantor TEXT NOT NULL, credential_id TEXT NOT NULL,
|
|
352
|
+
verifier TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL,
|
|
353
|
+
expires_at TEXT NOT NULL, revoked_at TEXT);
|
|
354
|
+
CREATE INDEX IF NOT EXISTS share_links_resource ON share_links(resource_id,grantor);
|
|
355
|
+
CREATE TABLE IF NOT EXISTS dm_conversations (
|
|
356
|
+
pair TEXT PRIMARY KEY, resource_id TEXT NOT NULL UNIQUE REFERENCES resources(id),
|
|
357
|
+
participant_a TEXT NOT NULL, participant_b TEXT NOT NULL, initiator TEXT NOT NULL,
|
|
358
|
+
conversation_kind TEXT NOT NULL DEFAULT 'direct' CHECK(conversation_kind='direct'),
|
|
359
|
+
state TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
360
|
+
CHECK(participant_a < participant_b), CHECK(state IN ('pending','active','rejected')));
|
|
361
|
+
CREATE INDEX IF NOT EXISTS dm_conversations_participants ON dm_conversations(participant_a,participant_b);
|
|
362
|
+
CREATE TABLE IF NOT EXISTS dm_blocks (
|
|
363
|
+
blocker TEXT NOT NULL, blocked TEXT NOT NULL, PRIMARY KEY(blocker,blocked));
|
|
364
|
+
CREATE TABLE IF NOT EXISTS dm_archives (
|
|
365
|
+
subject TEXT NOT NULL, pair TEXT NOT NULL REFERENCES dm_conversations(pair),
|
|
366
|
+
PRIMARY KEY(subject,pair));
|
|
367
|
+
CREATE TABLE IF NOT EXISTS handoffs (
|
|
368
|
+
id TEXT PRIMARY KEY, from_subject TEXT NOT NULL, to_subject TEXT NOT NULL,
|
|
369
|
+
status TEXT NOT NULL CHECK(status IN ('pending','accepted','rejected','cancelled')),
|
|
370
|
+
generation INTEGER NOT NULL, body TEXT NOT NULL);
|
|
371
|
+
CREATE INDEX IF NOT EXISTS handoffs_from ON handoffs(from_subject,id);
|
|
372
|
+
CREATE INDEX IF NOT EXISTS handoffs_to ON handoffs(to_subject,id);
|
|
373
|
+
CREATE TABLE IF NOT EXISTS collaboration_leases (
|
|
374
|
+
id TEXT PRIMARY KEY, holder TEXT NOT NULL, target TEXT NOT NULL REFERENCES resources(id),
|
|
375
|
+
status TEXT NOT NULL CHECK(status IN ('active','released')),
|
|
376
|
+
generation INTEGER NOT NULL, expires_at TEXT NOT NULL, body TEXT NOT NULL);
|
|
377
|
+
CREATE INDEX IF NOT EXISTS collaboration_leases_holder ON collaboration_leases(holder,id);
|
|
378
|
+
CREATE TABLE IF NOT EXISTS presence (
|
|
379
|
+
subject TEXT PRIMARY KEY, expires_at TEXT NOT NULL, body TEXT NOT NULL);
|
|
380
|
+
CREATE TABLE IF NOT EXISTS claims (
|
|
381
|
+
id TEXT PRIMARY KEY REFERENCES resources(id), subject TEXT NOT NULL,
|
|
382
|
+
issued_at TEXT NOT NULL, expires_at TEXT, body TEXT NOT NULL);
|
|
383
|
+
CREATE INDEX IF NOT EXISTS claims_subject ON claims(subject,issued_at,id);
|
|
384
|
+
CREATE TABLE IF NOT EXISTS topic_settings (
|
|
385
|
+
topic TEXT PRIMARY KEY REFERENCES resources(id), membership_policy TEXT NOT NULL,
|
|
386
|
+
CHECK(membership_policy IN ('open','approval','invite','closed')));
|
|
387
|
+
CREATE TABLE IF NOT EXISTS topic_memberships (
|
|
388
|
+
topic TEXT NOT NULL REFERENCES resources(id), subject TEXT NOT NULL,
|
|
389
|
+
role TEXT NOT NULL CHECK(role IN ('admin','member')),
|
|
390
|
+
status TEXT NOT NULL CHECK(status IN ('active','pending','invited','left','removed')),
|
|
391
|
+
joined_at TEXT, invited_by TEXT, PRIMARY KEY(topic,subject));
|
|
392
|
+
CREATE INDEX IF NOT EXISTS topic_memberships_subject ON topic_memberships(subject,topic);
|
|
393
|
+
CREATE TABLE IF NOT EXISTS topic_bans (
|
|
394
|
+
topic TEXT NOT NULL REFERENCES resources(id), subject TEXT NOT NULL,
|
|
395
|
+
actor TEXT NOT NULL, created_at TEXT NOT NULL, expires_at TEXT, reason TEXT,
|
|
396
|
+
status TEXT NOT NULL CHECK(status IN ('active','lifted')),
|
|
397
|
+
PRIMARY KEY(topic,subject));
|
|
398
|
+
CREATE TABLE IF NOT EXISTS system_sources (
|
|
399
|
+
resource_id TEXT PRIMARY KEY REFERENCES resources(id), source_path TEXT NOT NULL UNIQUE,
|
|
400
|
+
rule_id TEXT NOT NULL UNIQUE, source_kind TEXT NOT NULL,
|
|
401
|
+
source_version INTEGER NOT NULL, source_digest TEXT NOT NULL,
|
|
402
|
+
revision_id TEXT NOT NULL REFERENCES revisions(id));
|
|
403
|
+
CREATE TABLE IF NOT EXISTS personal_revision_proofs (
|
|
404
|
+
revision_id TEXT PRIMARY KEY REFERENCES revisions(id), subject TEXT NOT NULL,
|
|
405
|
+
kind TEXT NOT NULL, signature TEXT NOT NULL, signed_envelope TEXT NOT NULL,
|
|
406
|
+
created_at TEXT NOT NULL);
|
|
407
|
+
CREATE TABLE IF NOT EXISTS recovery_policies (
|
|
408
|
+
subject TEXT NOT NULL, version INTEGER NOT NULL, encryption_key_id TEXT NOT NULL,
|
|
409
|
+
created_at TEXT NOT NULL, body TEXT NOT NULL, PRIMARY KEY(subject,version));
|
|
410
|
+
CREATE TABLE IF NOT EXISTS recovery_envelopes (
|
|
411
|
+
id TEXT PRIMARY KEY, owner TEXT NOT NULL, ciphertext_resource TEXT NOT NULL,
|
|
412
|
+
ciphertext_revision TEXT NOT NULL, policy_version INTEGER NOT NULL,
|
|
413
|
+
encryption_key_id TEXT NOT NULL, purpose TEXT NOT NULL, created_at TEXT NOT NULL,
|
|
414
|
+
body TEXT NOT NULL,
|
|
415
|
+
UNIQUE(owner,ciphertext_resource,ciphertext_revision,purpose));
|
|
416
|
+
CREATE INDEX IF NOT EXISTS recovery_envelopes_owner ON recovery_envelopes(owner,created_at,id);
|
|
417
|
+
CREATE TABLE IF NOT EXISTS custodial_vault (
|
|
418
|
+
subject TEXT PRIMARY KEY, signing_key_id TEXT NOT NULL, encryption_key_id TEXT NOT NULL,
|
|
419
|
+
signing_nonce TEXT, signing_ciphertext TEXT, age_nonce TEXT, age_ciphertext TEXT,
|
|
420
|
+
status TEXT NOT NULL,
|
|
421
|
+
created_at TEXT NOT NULL, destroyed_at TEXT,
|
|
422
|
+
CONSTRAINT custodial_vault_lifecycle_check CHECK (
|
|
423
|
+
status IN ('active','decrypt_only','destroyed') AND
|
|
424
|
+
(status <> 'decrypt_only' OR (signing_nonce IS NULL AND signing_ciphertext IS NULL
|
|
425
|
+
AND age_nonce IS NOT NULL AND age_ciphertext IS NOT NULL))));
|
|
426
|
+
CREATE TABLE IF NOT EXISTS custodial_upgrades (
|
|
427
|
+
id TEXT PRIMARY KEY, subject TEXT NOT NULL, credential_id TEXT NOT NULL,
|
|
428
|
+
status TEXT NOT NULL CHECK(status IN ('pending','pending_rewrap','failed','completed')),
|
|
429
|
+
expires_at TEXT NOT NULL, challenge TEXT NOT NULL,
|
|
430
|
+
ephemeral_nonce TEXT, ephemeral_ciphertext TEXT, body TEXT NOT NULL);
|
|
431
|
+
CREATE INDEX IF NOT EXISTS custodial_upgrades_subject ON custodial_upgrades(subject,status,expires_at);
|
|
432
|
+
CREATE TABLE IF NOT EXISTS legacy_directives (
|
|
433
|
+
subject TEXT PRIMARY KEY, resource_id TEXT NOT NULL UNIQUE REFERENCES resources(id),
|
|
434
|
+
created_at TEXT NOT NULL);
|
|
435
|
+
CREATE TABLE IF NOT EXISTS legacy_directive_versions (
|
|
436
|
+
revision_id TEXT PRIMARY KEY REFERENCES revisions(id), resource_id TEXT NOT NULL,
|
|
437
|
+
subject TEXT NOT NULL, body TEXT NOT NULL);
|
|
438
|
+
CREATE TABLE IF NOT EXISTS legacy_states (
|
|
439
|
+
subject TEXT PRIMARY KEY, state TEXT NOT NULL
|
|
440
|
+
CHECK(state IN ('active','unreachable','recovery_requested','legacy')));
|
|
441
|
+
CREATE TABLE IF NOT EXISTS sync_checkpoints (
|
|
442
|
+
id TEXT PRIMARY KEY, subject TEXT NOT NULL, credential_id TEXT NOT NULL,
|
|
443
|
+
version INTEGER NOT NULL, expires_at TEXT NOT NULL, body TEXT NOT NULL);
|
|
444
|
+
CREATE INDEX IF NOT EXISTS sync_checkpoints_subject ON sync_checkpoints(subject,expires_at);
|
|
445
|
+
"""
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
# Kept as an import alias for the existing storage constructor and callers.
|
|
449
|
+
from msg.storage.ledger_migration import migrate_ledger_accounts as _migrate_ledger_accounts
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _postgres_sql(sql: str, *, has_parameters: bool = False) -> str:
|
|
453
|
+
"""Translate shared SQLite SQL without altering quoted text or comments."""
|
|
454
|
+
ignore = re.match(r'(?is)^(\s*)INSERT\s+OR\s+IGNORE\s+INTO\b', sql)
|
|
455
|
+
if ignore:
|
|
456
|
+
sql = sql[:ignore.start()] + ignore.group(1) + 'INSERT INTO' + sql[ignore.end():]
|
|
457
|
+
ending = re.search(r'\s*;\s*$', sql)
|
|
458
|
+
if ending:
|
|
459
|
+
sql = sql[:ending.start()] + ' ON CONFLICT DO NOTHING' + sql[ending.start():]
|
|
460
|
+
else:
|
|
461
|
+
sql += ' ON CONFLICT DO NOTHING'
|
|
462
|
+
|
|
463
|
+
chunks_query = bool(re.search(r'\bchunks\b', sql, flags=re.IGNORECASE))
|
|
464
|
+
output: list[str] = []
|
|
465
|
+
position = 0
|
|
466
|
+
state = 'sql'
|
|
467
|
+
while position < len(sql):
|
|
468
|
+
char = sql[position]
|
|
469
|
+
next_char = sql[position + 1] if position + 1 < len(sql) else ''
|
|
470
|
+
if state == 'sql':
|
|
471
|
+
if char == "'":
|
|
472
|
+
state = 'single'
|
|
473
|
+
elif char == '"':
|
|
474
|
+
state = 'double'
|
|
475
|
+
elif char == '-' and next_char == '-':
|
|
476
|
+
state = 'line_comment'
|
|
477
|
+
elif char == '/' and next_char == '*':
|
|
478
|
+
state = 'block_comment'
|
|
479
|
+
elif char == '?':
|
|
480
|
+
output.append('%s')
|
|
481
|
+
position += 1
|
|
482
|
+
continue
|
|
483
|
+
elif (chunks_query and sql[position:position + 6].lower() == 'offset'
|
|
484
|
+
and (position == 0 or not (sql[position - 1].isalnum() or sql[position - 1] == '_'))
|
|
485
|
+
and (position + 6 == len(sql) or not (
|
|
486
|
+
sql[position + 6].isalnum() or sql[position + 6] == '_'))):
|
|
487
|
+
output.append('"offset"')
|
|
488
|
+
position += 6
|
|
489
|
+
continue
|
|
490
|
+
elif state == 'single' and char == "'":
|
|
491
|
+
if next_char == "'":
|
|
492
|
+
output.extend((char, next_char))
|
|
493
|
+
position += 2
|
|
494
|
+
continue
|
|
495
|
+
state = 'sql'
|
|
496
|
+
elif state == 'double' and char == '"':
|
|
497
|
+
if next_char == '"':
|
|
498
|
+
output.extend((char, next_char))
|
|
499
|
+
position += 2
|
|
500
|
+
continue
|
|
501
|
+
state = 'sql'
|
|
502
|
+
elif state == 'line_comment' and char == '\n':
|
|
503
|
+
state = 'sql'
|
|
504
|
+
elif state == 'block_comment' and char == '*' and next_char == '/':
|
|
505
|
+
output.extend((char, next_char))
|
|
506
|
+
position += 2
|
|
507
|
+
state = 'sql'
|
|
508
|
+
continue
|
|
509
|
+
output.append('%%' if has_parameters and char == '%' else char)
|
|
510
|
+
position += 1
|
|
511
|
+
return ''.join(output)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
class PostgresSession(RelationalSession):
|
|
515
|
+
def __init__(self, connection, *, write: bool):
|
|
516
|
+
super().__init__(write=write)
|
|
517
|
+
self._connection = connection
|
|
518
|
+
self.pending_effect_ids: list[str] = []
|
|
519
|
+
|
|
520
|
+
def execute(self, sql: str, parameters: SqlParameters = (), *,
|
|
521
|
+
write: bool = False) -> QueryResult:
|
|
522
|
+
self.check(write)
|
|
523
|
+
try:
|
|
524
|
+
# With an empty parameter tuple psycopg still parses literal '%' as a
|
|
525
|
+
# placeholder; SQL such as LIKE 'policy:%' must be sent unchanged.
|
|
526
|
+
cursor = self._connection.execute(
|
|
527
|
+
_postgres_sql(sql, has_parameters=bool(parameters)), parameters or None)
|
|
528
|
+
return SessionQueryResult(cursor, self.check)
|
|
529
|
+
except psycopg.errors.IntegrityError as exc:
|
|
530
|
+
raise Failure("constraint_conflict") from exc
|
|
531
|
+
except (psycopg.errors.DeadlockDetected, psycopg.errors.LockNotAvailable,
|
|
532
|
+
psycopg.errors.QueryCanceled) as exc:
|
|
533
|
+
raise Failure("server_busy", retryable=True) from exc
|
|
534
|
+
|
|
535
|
+
async def enqueue(self, job):
|
|
536
|
+
await super().enqueue(job)
|
|
537
|
+
self.pending_effect_ids.append(job.id)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
class PostgresMetadataStore:
|
|
541
|
+
"""One connection per transaction; no mutable process-local authority state."""
|
|
542
|
+
|
|
543
|
+
def __init__(self, dsn: str, *, signal=None, initialize: bool = True):
|
|
544
|
+
self.dsn = dsn
|
|
545
|
+
self.signal = signal
|
|
546
|
+
self._current = contextvars.ContextVar('msg_pg_transaction_' + uuid.uuid4().hex, default=None)
|
|
547
|
+
if initialize:
|
|
548
|
+
with self._connect() as conn:
|
|
549
|
+
# Multiple daemon workers may initialize the same fresh database.
|
|
550
|
+
conn.execute('SELECT pg_advisory_xact_lock(725274758, 1886265951)')
|
|
551
|
+
conn.execute(_SCHEMA)
|
|
552
|
+
from msg.storage.market_migration import migrate_market
|
|
553
|
+
migrate_market(conn)
|
|
554
|
+
_migrate_ledger_accounts(conn)
|
|
555
|
+
from msg.storage.custodial_migration import migrate_custodial_vault
|
|
556
|
+
migrate_custodial_vault(conn)
|
|
557
|
+
from msg.storage.topic_event_migration import migrate_topic_events
|
|
558
|
+
migrate_topic_events(conn, postgres=True)
|
|
559
|
+
|
|
560
|
+
def _connect(self):
|
|
561
|
+
return psycopg.connect(self.dsn, autocommit=False)
|
|
562
|
+
|
|
563
|
+
async def _connect_async(self):
|
|
564
|
+
pending = asyncio.create_task(asyncio.to_thread(self._connect))
|
|
565
|
+
try:
|
|
566
|
+
return await asyncio.shield(pending)
|
|
567
|
+
except asyncio.CancelledError:
|
|
568
|
+
# Cancelling a coroutine does not stop a connection attempt in a thread.
|
|
569
|
+
# Consume its result and close it instead of leaking a server connection.
|
|
570
|
+
try:
|
|
571
|
+
connection = await pending
|
|
572
|
+
await asyncio.to_thread(connection.close)
|
|
573
|
+
finally:
|
|
574
|
+
raise
|
|
575
|
+
|
|
576
|
+
@asynccontextmanager
|
|
577
|
+
async def transaction(self, *, write):
|
|
578
|
+
existing = self._current.get()
|
|
579
|
+
if existing is not None:
|
|
580
|
+
existing.check(write)
|
|
581
|
+
name = 'nested_' + uuid.uuid4().hex
|
|
582
|
+
existing.execute('SAVEPOINT ' + name)
|
|
583
|
+
pending_at_entry = len(existing.pending_effect_ids)
|
|
584
|
+
rollback_at = len(existing.rollback_effects)
|
|
585
|
+
try:
|
|
586
|
+
yield existing
|
|
587
|
+
existing.execute('RELEASE SAVEPOINT ' + name)
|
|
588
|
+
except BaseException as exc:
|
|
589
|
+
existing.execute('ROLLBACK TO SAVEPOINT ' + name)
|
|
590
|
+
existing.execute('RELEASE SAVEPOINT ' + name)
|
|
591
|
+
del existing.pending_effect_ids[pending_at_entry:]
|
|
592
|
+
await existing.run_rollback_effects(exc, rollback_at)
|
|
593
|
+
raise
|
|
594
|
+
return
|
|
595
|
+
|
|
596
|
+
conn = await self._connect_async()
|
|
597
|
+
tx = PostgresSession(conn, write=write)
|
|
598
|
+
token = None
|
|
599
|
+
committed = False
|
|
600
|
+
try:
|
|
601
|
+
if write:
|
|
602
|
+
# The same application-wide key still conflicts with schema/recovery
|
|
603
|
+
# transaction locks. A session lock survives SQL abort/ROLLBACK until
|
|
604
|
+
# compensation finishes and this dedicated connection is closed.
|
|
605
|
+
# Waiting stays off the event loop so the holder can finish.
|
|
606
|
+
async def acquire():
|
|
607
|
+
def blocking():
|
|
608
|
+
conn.execute("SET lock_timeout = '10s'")
|
|
609
|
+
conn.execute('SELECT pg_advisory_lock(725274758, 1886265951)')
|
|
610
|
+
task = asyncio.create_task(asyncio.to_thread(blocking))
|
|
611
|
+
try:
|
|
612
|
+
await asyncio.shield(task)
|
|
613
|
+
except asyncio.CancelledError:
|
|
614
|
+
# The worker thread must finish before this connection is closed.
|
|
615
|
+
try:
|
|
616
|
+
await task
|
|
617
|
+
finally:
|
|
618
|
+
raise
|
|
619
|
+
try:
|
|
620
|
+
await acquire()
|
|
621
|
+
except (psycopg.errors.LockNotAvailable, psycopg.errors.QueryCanceled) as exc:
|
|
622
|
+
raise Failure('server_busy', retryable=True) from exc
|
|
623
|
+
else:
|
|
624
|
+
conn.execute('SET TRANSACTION READ ONLY')
|
|
625
|
+
token = self._current.set(tx)
|
|
626
|
+
yield tx
|
|
627
|
+
conn.commit()
|
|
628
|
+
committed = True
|
|
629
|
+
except BaseException as exc:
|
|
630
|
+
conn.rollback()
|
|
631
|
+
await tx.run_rollback_effects(exc)
|
|
632
|
+
raise
|
|
633
|
+
finally:
|
|
634
|
+
tx.closed = True
|
|
635
|
+
if token is not None:
|
|
636
|
+
self._current.reset(token)
|
|
637
|
+
conn.close()
|
|
638
|
+
if committed and self.signal is not None and tx.pending_effect_ids:
|
|
639
|
+
# The durable jobs table is authoritative. A signal only reduces worker latency.
|
|
640
|
+
try:
|
|
641
|
+
await self.signal.publish_pending(tuple(tx.pending_effect_ids))
|
|
642
|
+
except Exception:
|
|
643
|
+
logging.getLogger(__name__).exception('Valkey job signal failed; jobs remain durable')
|
|
644
|
+
|
|
645
|
+
async def close(self):
|
|
646
|
+
return None
|
|
647
|
+
|
|
648
|
+
def backup(self, destination: Path):
|
|
649
|
+
"""Write a pg_dump custom-format archive at *destination*.
|
|
650
|
+
|
|
651
|
+
The caller holds the store's write transaction while copying content trees.
|
|
652
|
+
Credentials travel via the child environment, never command arguments.
|
|
653
|
+
"""
|
|
654
|
+
destination = Path(destination)
|
|
655
|
+
fields = conninfo_to_dict(self.dsn)
|
|
656
|
+
password = fields.pop('password', None)
|
|
657
|
+
env = os.environ.copy()
|
|
658
|
+
if password is not None:
|
|
659
|
+
env['PGPASSWORD'] = password
|
|
660
|
+
safe_dsn = make_conninfo(**fields)
|
|
661
|
+
subprocess.run(['pg_dump', '--format=custom', '--no-owner', '--no-acl',
|
|
662
|
+
'--file', str(destination), '--dbname', safe_dsn],
|
|
663
|
+
env=env, check=True, capture_output=True)
|