chore: sync current workspace changes
Desktop CI Build / Build macOS ARM64 DMG (push) Has been cancelled
Desktop CI Build / Build Windows x64 (push) Has been cancelled

This commit is contained in:
boos
2026-07-14 03:21:10 +08:00
parent 1512fa23ce
commit 557914fea8
745 changed files with 124917 additions and 11222 deletions
@@ -0,0 +1,8 @@
CREATE INDEX IF NOT EXISTS background_tasks_task_type_created_at_desc_idx
ON background_tasks (task_type, created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS background_tasks_created_at_desc_idx
ON background_tasks (created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS background_tasks_task_type_status_created_at_idx
ON background_tasks (task_type, status, created_at);
@@ -0,0 +1,28 @@
-- Speed up workspace asset gallery queries.
-- The gallery only lists completed, non-deleted creative outputs; partial
-- indexes keep the hot path small and allow ORDER BY created_at DESC, id DESC
-- to avoid an explicit sort.
CREATE INDEX IF NOT EXISTS workspace_assets_completed_user_created_idx
ON workspace_assets (user_id, created_at DESC, id DESC)
WHERE deleted_at IS NULL AND status = 'completed';
CREATE INDEX IF NOT EXISTS workspace_assets_completed_created_idx
ON workspace_assets (created_at DESC, id DESC)
WHERE deleted_at IS NULL AND status = 'completed';
CREATE INDEX IF NOT EXISTS workspace_assets_completed_source_created_idx
ON workspace_assets (source, created_at DESC, id DESC)
WHERE deleted_at IS NULL AND status = 'completed';
CREATE INDEX IF NOT EXISTS workspace_assets_completed_kind_created_idx
ON workspace_assets (kind, created_at DESC, id DESC)
WHERE deleted_at IS NULL AND status = 'completed';
CREATE INDEX IF NOT EXISTS workspace_assets_completed_user_source_created_idx
ON workspace_assets (user_id, source, created_at DESC, id DESC)
WHERE deleted_at IS NULL AND status = 'completed';
CREATE INDEX IF NOT EXISTS workspace_assets_completed_user_kind_created_idx
ON workspace_assets (user_id, kind, created_at DESC, id DESC)
WHERE deleted_at IS NULL AND status = 'completed';
@@ -0,0 +1,55 @@
-- Payment orders own fulfillment. Keep the charged amount separate from the
-- API quota granted so non-USD providers cannot accidentally grant a nominally
-- equal USD balance.
ALTER TABLE payment_orders ADD COLUMN credit_amount REAL NOT NULL DEFAULT 0;
ALTER TABLE payment_orders ADD COLUMN credit_unit TEXT NOT NULL DEFAULT 'USD API quota';
ALTER TABLE user_recharge_records ADD COLUMN currency TEXT;
ALTER TABLE user_recharge_records ADD COLUMN credit_amount REAL;
ALTER TABLE user_recharge_records ADD COLUMN credit_unit TEXT;
-- One row is inserted in the same transaction as the balance mutation. The
-- primary key is the durable idempotency guard for duplicate payment callbacks.
CREATE TABLE IF NOT EXISTS payment_order_fulfillments (
payment_order_id INTEGER PRIMARY KEY NOT NULL,
credited_key_id INTEGER,
credit_amount REAL NOT NULL,
credit_unit TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (payment_order_id) REFERENCES payment_orders(id) ON DELETE CASCADE,
FOREIGN KEY (credited_key_id) REFERENCES downstream_api_keys(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS payment_order_fulfillments_key_idx
ON payment_order_fulfillments (credited_key_id);
-- These tables were originally created lazily. Keep the runtime fallback for
-- old installs, but ship their canonical schema through migrations.
CREATE TABLE IF NOT EXISTS credit_ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
user_id INTEGER NOT NULL,
track TEXT NOT NULL,
entry_type TEXT NOT NULL,
amount REAL NOT NULL,
balance_after REAL NOT NULL,
ref_type TEXT,
ref_id TEXT,
model TEXT,
operator TEXT,
note TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS credit_ledger_user_created_idx
ON credit_ledger (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS credit_ledger_track_type_idx
ON credit_ledger (track, entry_type);
CREATE TABLE IF NOT EXISTS user_credits (
user_id INTEGER PRIMARY KEY NOT NULL,
points_balance REAL NOT NULL DEFAULT 0,
points_total_recharged REAL NOT NULL DEFAULT 0,
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
@@ -0,0 +1,93 @@
-- The workspace gallery predicate includes several JSON branches. Index the
-- full predicate so SQLite can satisfy both filtering and ordering without
-- reparsing large metadata documents for every completed asset.
CREATE INDEX IF NOT EXISTS workspace_assets_gallery_created_idx
ON workspace_assets (created_at DESC, id DESC)
WHERE deleted_at IS NULL
AND status = 'completed'
AND (
json_extract(metadata, '$.workspaceAsset.role') = 'output'
OR json_extract(metadata, '$.workflowUi.type') = 'output'
OR (
source IN ('picture-agent', 'workflow-studio')
AND json_type(metadata, '$.responseItem') IS NOT NULL
)
OR json_type(metadata, '$.moneyPrinter') IS NOT NULL
OR json_type(metadata, '$.digitalHuman') IS NOT NULL
);
CREATE INDEX IF NOT EXISTS workspace_assets_gallery_user_created_idx
ON workspace_assets (user_id, created_at DESC, id DESC)
WHERE deleted_at IS NULL
AND status = 'completed'
AND (
json_extract(metadata, '$.workspaceAsset.role') = 'output'
OR json_extract(metadata, '$.workflowUi.type') = 'output'
OR (
source IN ('picture-agent', 'workflow-studio')
AND json_type(metadata, '$.responseItem') IS NOT NULL
)
OR json_type(metadata, '$.moneyPrinter') IS NOT NULL
OR json_type(metadata, '$.digitalHuman') IS NOT NULL
);
CREATE INDEX IF NOT EXISTS workspace_assets_gallery_source_created_idx
ON workspace_assets (source, created_at DESC, id DESC)
WHERE deleted_at IS NULL
AND status = 'completed'
AND (
json_extract(metadata, '$.workspaceAsset.role') = 'output'
OR json_extract(metadata, '$.workflowUi.type') = 'output'
OR (
source IN ('picture-agent', 'workflow-studio')
AND json_type(metadata, '$.responseItem') IS NOT NULL
)
OR json_type(metadata, '$.moneyPrinter') IS NOT NULL
OR json_type(metadata, '$.digitalHuman') IS NOT NULL
);
CREATE INDEX IF NOT EXISTS workspace_assets_gallery_kind_created_idx
ON workspace_assets (kind, created_at DESC, id DESC)
WHERE deleted_at IS NULL
AND status = 'completed'
AND (
json_extract(metadata, '$.workspaceAsset.role') = 'output'
OR json_extract(metadata, '$.workflowUi.type') = 'output'
OR (
source IN ('picture-agent', 'workflow-studio')
AND json_type(metadata, '$.responseItem') IS NOT NULL
)
OR json_type(metadata, '$.moneyPrinter') IS NOT NULL
OR json_type(metadata, '$.digitalHuman') IS NOT NULL
);
CREATE INDEX IF NOT EXISTS workspace_assets_gallery_user_source_created_idx
ON workspace_assets (user_id, source, created_at DESC, id DESC)
WHERE deleted_at IS NULL
AND status = 'completed'
AND (
json_extract(metadata, '$.workspaceAsset.role') = 'output'
OR json_extract(metadata, '$.workflowUi.type') = 'output'
OR (
source IN ('picture-agent', 'workflow-studio')
AND json_type(metadata, '$.responseItem') IS NOT NULL
)
OR json_type(metadata, '$.moneyPrinter') IS NOT NULL
OR json_type(metadata, '$.digitalHuman') IS NOT NULL
);
CREATE INDEX IF NOT EXISTS workspace_assets_gallery_user_kind_created_idx
ON workspace_assets (user_id, kind, created_at DESC, id DESC)
WHERE deleted_at IS NULL
AND status = 'completed'
AND (
json_extract(metadata, '$.workspaceAsset.role') = 'output'
OR json_extract(metadata, '$.workflowUi.type') = 'output'
OR (
source IN ('picture-agent', 'workflow-studio')
AND json_type(metadata, '$.responseItem') IS NOT NULL
)
OR json_type(metadata, '$.moneyPrinter') IS NOT NULL
OR json_type(metadata, '$.digitalHuman') IS NOT NULL
);
@@ -0,0 +1,125 @@
-- Account-level API billing. Workflow points remain in user_credits and are
-- intentionally not migrated into this account.
CREATE TABLE IF NOT EXISTS api_billing_accounts (
user_id INTEGER PRIMARY KEY NOT NULL,
available_micros INTEGER NOT NULL DEFAULT 0,
reserved_micros INTEGER NOT NULL DEFAULT 0,
debt_micros INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
version INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CHECK (available_micros >= 0),
CHECK (reserved_micros >= 0),
CHECK (debt_micros >= 0),
CHECK (status IN ('active', 'frozen', 'closed'))
);
CREATE TABLE IF NOT EXISTS api_credit_grants (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
source_type TEXT NOT NULL,
source_id TEXT,
payment_order_id INTEGER,
total_micros INTEGER NOT NULL,
remaining_micros INTEGER NOT NULL,
reserved_micros INTEGER NOT NULL DEFAULT 0,
starts_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT,
status TEXT NOT NULL DEFAULT 'active',
scope_json TEXT,
idempotency_key TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (payment_order_id) REFERENCES payment_orders(id) ON DELETE SET NULL,
CHECK (total_micros > 0),
CHECK (remaining_micros >= 0),
CHECK (reserved_micros >= 0),
CHECK (remaining_micros + reserved_micros <= total_micros),
CHECK (status IN ('scheduled', 'active', 'refund_pending', 'exhausted', 'expired', 'revoked'))
);
CREATE UNIQUE INDEX IF NOT EXISTS api_credit_grants_public_id_unique
ON api_credit_grants (public_id);
CREATE UNIQUE INDEX IF NOT EXISTS api_credit_grants_idempotency_unique
ON api_credit_grants (idempotency_key);
CREATE INDEX IF NOT EXISTS api_credit_grants_spend_idx
ON api_credit_grants (user_id, status, starts_at, expires_at, id);
CREATE INDEX IF NOT EXISTS api_credit_grants_order_idx
ON api_credit_grants (payment_order_id);
CREATE TABLE IF NOT EXISTS api_entitlement_ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
user_id INTEGER NOT NULL,
entry_type TEXT NOT NULL,
amount_micros INTEGER NOT NULL,
balance_after_micros INTEGER NOT NULL,
grant_id INTEGER,
ref_type TEXT,
ref_id TEXT,
model TEXT,
operator TEXT,
note TEXT,
idempotency_key TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (grant_id) REFERENCES api_credit_grants(id) ON DELETE SET NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS api_entitlement_ledger_idempotency_unique
ON api_entitlement_ledger (idempotency_key);
CREATE INDEX IF NOT EXISTS api_entitlement_ledger_user_created_idx
ON api_entitlement_ledger (user_id, created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS api_entitlement_ledger_ref_idx
ON api_entitlement_ledger (ref_type, ref_id);
-- Preserve the finite remaining API credit users had before account billing.
-- NULL max_cost was an accidental unlimited bypass and is never converted to
-- an unlimited account entitlement.
INSERT OR IGNORE INTO api_billing_accounts (user_id, available_micros)
SELECT
u.id,
CAST(ROUND(COALESCE(SUM(
CASE
WHEN k.max_cost IS NOT NULL
THEN MAX(k.max_cost - COALESCE(k.used_cost, 0), 0) * 1000000.0
ELSE 0
END
), 0)) AS INTEGER)
FROM users u
LEFT JOIN downstream_api_keys k ON k.user_id = u.id
GROUP BY u.id;
INSERT OR IGNORE INTO api_credit_grants (
public_id, user_id, source_type, source_id,
total_micros, remaining_micros, idempotency_key
)
SELECT
'grant_legacy_' || user_id,
user_id,
'legacy_opening_balance',
CAST(user_id AS TEXT),
available_micros,
available_micros,
'migration:20260710000003:user:' || user_id
FROM api_billing_accounts
WHERE available_micros > 0;
INSERT OR IGNORE INTO api_entitlement_ledger (
user_id, entry_type, amount_micros, balance_after_micros,
ref_type, ref_id, operator, idempotency_key
)
SELECT
user_id,
'opening_balance',
available_micros,
available_micros,
'migration',
'20260710000003',
'system',
'migration:20260710000003:ledger:user:' || user_id
FROM api_billing_accounts
WHERE available_micros > 0;
@@ -0,0 +1,365 @@
-- Commerce catalog and immutable purchase snapshots. Money is always stored in
-- integer minor units; API credit is always stored in integer micros. Workflow
-- points deliberately have no representation in this schema.
CREATE TABLE IF NOT EXISTS commerce_products (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL,
code TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
product_type TEXT NOT NULL,
api_credit_micros INTEGER NOT NULL,
subscription_days INTEGER,
enabled INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK (product_type IN ('credit_pack', 'subscription')),
CHECK (api_credit_micros > 0),
CHECK (
(product_type = 'credit_pack' AND subscription_days IS NULL) OR
(product_type = 'subscription' AND subscription_days > 0)
),
CHECK (enabled IN (0, 1))
);
CREATE UNIQUE INDEX IF NOT EXISTS commerce_products_public_id_unique
ON commerce_products (public_id);
CREATE UNIQUE INDEX IF NOT EXISTS commerce_products_code_unique
ON commerce_products (code);
CREATE INDEX IF NOT EXISTS commerce_products_enabled_sort_idx
ON commerce_products (enabled, sort_order, id);
CREATE TABLE IF NOT EXISTS commerce_product_prices (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
product_id INTEGER NOT NULL,
currency TEXT NOT NULL,
amount_minor INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (product_id) REFERENCES commerce_products(id) ON DELETE CASCADE,
UNIQUE (product_id, currency),
CHECK (currency IN ('CNY', 'USD')),
CHECK (amount_minor > 0)
);
-- Credit scopes decide which models may consume the API credit grant. An empty
-- set means all models. Prefixes are converted to billing's `model*` pattern at
-- fulfillment time.
CREATE TABLE IF NOT EXISTS commerce_product_credit_scopes (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
product_id INTEGER NOT NULL,
scope_type TEXT NOT NULL,
scope_value TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (product_id) REFERENCES commerce_products(id) ON DELETE CASCADE,
UNIQUE (product_id, scope_type, scope_value),
CHECK (scope_type IN ('model', 'model_prefix')),
CHECK (length(trim(scope_value)) > 0)
);
-- Unlock scopes are membership entitlements and never flow into an API credit
-- grant. They are intentionally separate from credit scopes.
CREATE TABLE IF NOT EXISTS commerce_product_unlock_scopes (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
product_id INTEGER NOT NULL,
scope_type TEXT NOT NULL,
scope_value TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (product_id) REFERENCES commerce_products(id) ON DELETE CASCADE,
UNIQUE (product_id, scope_type, scope_value),
CHECK (scope_type IN ('model', 'model_prefix')),
CHECK (length(trim(scope_value)) > 0)
);
-- A model gate is a durable policy fact, not a property of the currently
-- visible catalog. Once an unlock scope has been advertised by a product or
-- sealed into an order snapshot, archiving/editing that product must not make
-- the protected model public. Deliberate ungating requires an explicit future
-- policy migration instead of happening as a side effect of catalog CRUD.
CREATE TABLE IF NOT EXISTS commerce_model_gate_registry (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
scope_type TEXT NOT NULL,
scope_value TEXT NOT NULL,
first_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (scope_type, scope_value),
CHECK (scope_type IN ('model', 'model_prefix')),
CHECK (length(trim(scope_value)) > 0)
);
INSERT OR IGNORE INTO commerce_model_gate_registry (scope_type, scope_value)
SELECT scope_type, scope_value FROM commerce_product_unlock_scopes;
CREATE TRIGGER IF NOT EXISTS commerce_product_unlock_scope_register_gate
AFTER INSERT ON commerce_product_unlock_scopes
FOR EACH ROW
BEGIN
INSERT OR IGNORE INTO commerce_model_gate_registry (scope_type, scope_value)
VALUES (NEW.scope_type, NEW.scope_value);
END;
CREATE TRIGGER IF NOT EXISTS commerce_product_unlock_scope_register_gate_update
AFTER UPDATE OF scope_type, scope_value ON commerce_product_unlock_scopes
FOR EACH ROW
BEGIN
INSERT OR IGNORE INTO commerce_model_gate_registry (scope_type, scope_value)
VALUES (NEW.scope_type, NEW.scope_value);
END;
CREATE TRIGGER IF NOT EXISTS commerce_model_gate_registry_no_update
BEFORE UPDATE ON commerce_model_gate_registry
FOR EACH ROW
BEGIN
SELECT RAISE(ABORT, 'commerce model gate registry is append-only');
END;
CREATE TRIGGER IF NOT EXISTS commerce_model_gate_registry_no_delete
BEFORE DELETE ON commerce_model_gate_registry
FOR EACH ROW
BEGIN
SELECT RAISE(ABORT, 'commerce model gate registry is append-only');
END;
CREATE TRIGGER IF NOT EXISTS commerce_unlock_scope_subscription_only_insert
BEFORE INSERT ON commerce_product_unlock_scopes
FOR EACH ROW
WHEN (SELECT product_type FROM commerce_products WHERE id = NEW.product_id) <> 'subscription'
BEGIN
SELECT RAISE(ABORT, 'unlock scopes require a subscription product');
END;
CREATE TRIGGER IF NOT EXISTS commerce_unlock_scope_subscription_only_update
BEFORE UPDATE ON commerce_product_unlock_scopes
FOR EACH ROW
WHEN (SELECT product_type FROM commerce_products WHERE id = NEW.product_id) <> 'subscription'
BEGIN
SELECT RAISE(ABORT, 'unlock scopes require a subscription product');
END;
CREATE TABLE IF NOT EXISTS commerce_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
payment_order_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
product_public_id_snapshot TEXT NOT NULL,
product_code_snapshot TEXT NOT NULL,
product_name_snapshot TEXT NOT NULL,
product_type_snapshot TEXT NOT NULL,
order_kind TEXT NOT NULL,
currency TEXT NOT NULL,
amount_minor INTEGER NOT NULL,
api_credit_micros_snapshot INTEGER NOT NULL,
subscription_days_snapshot INTEGER,
status TEXT NOT NULL DEFAULT 'pending',
idempotency_key TEXT NOT NULL,
snapshot_sealed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
paid_at TEXT,
fulfilled_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT,
FOREIGN KEY (payment_order_id) REFERENCES payment_orders(id) ON DELETE RESTRICT,
FOREIGN KEY (product_id) REFERENCES commerce_products(id) ON DELETE RESTRICT,
UNIQUE (payment_order_id),
UNIQUE (user_id, idempotency_key),
CHECK (product_type_snapshot IN ('credit_pack', 'subscription')),
CHECK (order_kind IN ('credit_pack', 'subscription')),
CHECK (order_kind = product_type_snapshot),
CHECK (currency IN ('CNY', 'USD')),
CHECK (amount_minor > 0),
CHECK (api_credit_micros_snapshot > 0),
CHECK (
datetime(expires_at) IS NOT NULL
AND datetime(expires_at) > datetime(created_at)
),
CHECK (
(product_type_snapshot = 'credit_pack' AND subscription_days_snapshot IS NULL) OR
(product_type_snapshot = 'subscription' AND subscription_days_snapshot > 0)
),
CHECK (status IN ('pending', 'paid', 'fulfilled', 'partially_refunded', 'cancelled', 'expired', 'refunded')),
CHECK (snapshot_sealed IN (0, 1))
);
CREATE UNIQUE INDEX IF NOT EXISTS commerce_orders_public_id_unique
ON commerce_orders (public_id);
CREATE INDEX IF NOT EXISTS commerce_orders_user_created_idx
ON commerce_orders (user_id, created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS commerce_orders_status_created_idx
ON commerce_orders (status, created_at, id);
CREATE INDEX IF NOT EXISTS commerce_orders_pending_expiry_idx
ON commerce_orders (expires_at, id) WHERE status = 'pending';
CREATE TABLE IF NOT EXISTS commerce_order_credit_scopes (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
order_id INTEGER NOT NULL,
scope_type TEXT NOT NULL,
scope_value TEXT NOT NULL,
FOREIGN KEY (order_id) REFERENCES commerce_orders(id) ON DELETE CASCADE,
UNIQUE (order_id, scope_type, scope_value),
CHECK (scope_type IN ('model', 'model_prefix')),
CHECK (length(trim(scope_value)) > 0)
);
CREATE TABLE IF NOT EXISTS commerce_order_unlock_scopes (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
order_id INTEGER NOT NULL,
scope_type TEXT NOT NULL,
scope_value TEXT NOT NULL,
FOREIGN KEY (order_id) REFERENCES commerce_orders(id) ON DELETE CASCADE,
UNIQUE (order_id, scope_type, scope_value),
CHECK (scope_type IN ('model', 'model_prefix')),
CHECK (length(trim(scope_value)) > 0)
);
INSERT OR IGNORE INTO commerce_model_gate_registry (scope_type, scope_value)
SELECT scope_type, scope_value FROM commerce_order_unlock_scopes;
CREATE TRIGGER IF NOT EXISTS commerce_order_unlock_scope_register_gate
AFTER INSERT ON commerce_order_unlock_scopes
FOR EACH ROW
BEGIN
INSERT OR IGNORE INTO commerce_model_gate_registry (scope_type, scope_value)
VALUES (NEW.scope_type, NEW.scope_value);
END;
CREATE TRIGGER IF NOT EXISTS commerce_order_unlock_scope_register_gate_update
AFTER UPDATE OF scope_type, scope_value ON commerce_order_unlock_scopes
FOR EACH ROW
BEGIN
INSERT OR IGNORE INTO commerce_model_gate_registry (scope_type, scope_value)
VALUES (NEW.scope_type, NEW.scope_value);
END;
CREATE TRIGGER IF NOT EXISTS commerce_order_snapshot_fields_immutable
BEFORE UPDATE OF
user_id, payment_order_id, product_id, product_public_id_snapshot,
product_code_snapshot, product_name_snapshot, product_type_snapshot,
order_kind, currency, amount_minor, api_credit_micros_snapshot,
subscription_days_snapshot, idempotency_key, expires_at
ON commerce_orders
FOR EACH ROW
BEGIN
SELECT RAISE(ABORT, 'commerce order snapshot is immutable');
END;
CREATE TRIGGER IF NOT EXISTS commerce_product_unlock_scope_type_guard
BEFORE UPDATE OF product_type ON commerce_products
FOR EACH ROW
WHEN NEW.product_type <> 'subscription'
AND EXISTS (
SELECT 1 FROM commerce_product_unlock_scopes WHERE product_id = OLD.id
)
BEGIN
SELECT RAISE(ABORT, 'unlock scopes require a subscription product');
END;
CREATE TRIGGER IF NOT EXISTS commerce_order_snapshot_cannot_unseal
BEFORE UPDATE OF snapshot_sealed ON commerce_orders
FOR EACH ROW
WHEN OLD.snapshot_sealed = 1 AND NEW.snapshot_sealed <> 1
BEGIN
SELECT RAISE(ABORT, 'commerce order snapshot cannot be unsealed');
END;
CREATE TRIGGER IF NOT EXISTS commerce_order_credit_scope_sealed_insert
BEFORE INSERT ON commerce_order_credit_scopes
FOR EACH ROW
WHEN (SELECT snapshot_sealed FROM commerce_orders WHERE id = NEW.order_id) = 1
BEGIN
SELECT RAISE(ABORT, 'commerce order credit scope snapshot is sealed');
END;
CREATE TRIGGER IF NOT EXISTS commerce_order_credit_scope_sealed_update
BEFORE UPDATE ON commerce_order_credit_scopes
FOR EACH ROW
WHEN (SELECT snapshot_sealed FROM commerce_orders WHERE id = OLD.order_id) = 1
BEGIN
SELECT RAISE(ABORT, 'commerce order credit scope snapshot is sealed');
END;
CREATE TRIGGER IF NOT EXISTS commerce_order_credit_scope_sealed_delete
BEFORE DELETE ON commerce_order_credit_scopes
FOR EACH ROW
WHEN (SELECT snapshot_sealed FROM commerce_orders WHERE id = OLD.order_id) = 1
BEGIN
SELECT RAISE(ABORT, 'commerce order credit scope snapshot is sealed');
END;
CREATE TRIGGER IF NOT EXISTS commerce_order_unlock_scope_sealed_insert
BEFORE INSERT ON commerce_order_unlock_scopes
FOR EACH ROW
WHEN (SELECT snapshot_sealed FROM commerce_orders WHERE id = NEW.order_id) = 1
BEGIN
SELECT RAISE(ABORT, 'commerce order unlock scope snapshot is sealed');
END;
CREATE TRIGGER IF NOT EXISTS commerce_order_unlock_scope_sealed_update
BEFORE UPDATE ON commerce_order_unlock_scopes
FOR EACH ROW
WHEN (SELECT snapshot_sealed FROM commerce_orders WHERE id = OLD.order_id) = 1
BEGIN
SELECT RAISE(ABORT, 'commerce order unlock scope snapshot is sealed');
END;
CREATE TRIGGER IF NOT EXISTS commerce_order_unlock_scope_sealed_delete
BEFORE DELETE ON commerce_order_unlock_scopes
FOR EACH ROW
WHEN (SELECT snapshot_sealed FROM commerce_orders WHERE id = OLD.order_id) = 1
BEGIN
SELECT RAISE(ABORT, 'commerce order unlock scope snapshot is sealed');
END;
CREATE TABLE IF NOT EXISTS commerce_subscription_periods (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
source_order_id INTEGER NOT NULL,
starts_at TEXT NOT NULL,
ends_at TEXT NOT NULL,
access_ends_at TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT,
FOREIGN KEY (product_id) REFERENCES commerce_products(id) ON DELETE RESTRICT,
FOREIGN KEY (source_order_id) REFERENCES commerce_orders(id) ON DELETE RESTRICT,
UNIQUE (source_order_id),
CHECK (datetime(ends_at) > datetime(starts_at)),
CHECK (
access_ends_at IS NULL OR (
datetime(access_ends_at) >= datetime(starts_at)
AND datetime(access_ends_at) <= datetime(ends_at)
)
),
CHECK (status IN ('active', 'revoked'))
);
CREATE UNIQUE INDEX IF NOT EXISTS commerce_subscription_periods_public_id_unique
ON commerce_subscription_periods (public_id);
CREATE INDEX IF NOT EXISTS commerce_subscription_periods_user_product_end_idx
ON commerce_subscription_periods (user_id, product_id, ends_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS commerce_subscription_periods_active_window_idx
ON commerce_subscription_periods (user_id, status, starts_at, ends_at);
CREATE TRIGGER IF NOT EXISTS commerce_subscription_period_window_immutable
BEFORE UPDATE OF user_id, product_id, source_order_id, starts_at, ends_at
ON commerce_subscription_periods
FOR EACH ROW
BEGIN
SELECT RAISE(ABORT, 'subscription period window is immutable');
END;
CREATE TABLE IF NOT EXISTS commerce_order_fulfillments (
order_id INTEGER PRIMARY KEY NOT NULL,
idempotency_key TEXT NOT NULL,
api_credit_grant_id INTEGER,
subscription_period_id INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (order_id) REFERENCES commerce_orders(id) ON DELETE RESTRICT,
FOREIGN KEY (api_credit_grant_id) REFERENCES api_credit_grants(id) ON DELETE RESTRICT,
FOREIGN KEY (subscription_period_id) REFERENCES commerce_subscription_periods(id) ON DELETE RESTRICT,
UNIQUE (idempotency_key)
);
@@ -0,0 +1,409 @@
-- Multi-provider payment routing. Credentials are encrypted by the application
-- with an independent master key; only the versioned ciphertext is persisted.
CREATE TABLE IF NOT EXISTS payment_provider_instances (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL,
provider TEXT NOT NULL,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
credentials_ciphertext BLOB NOT NULL,
credentials_version INTEGER NOT NULL DEFAULT 1,
callback_token_hash TEXT NOT NULL,
callback_token_ciphertext BLOB NOT NULL,
config_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK (provider IN ('easypay', 'alipay', 'wechatpay', 'stripe', 'comfy_cloud')),
CHECK (enabled IN (0, 1)),
CHECK (credentials_version > 0),
CHECK (length(credentials_ciphertext) > 29),
CHECK (length(callback_token_hash) = 64),
CHECK (length(callback_token_ciphertext) > 29),
CHECK (json_valid(config_json))
);
CREATE UNIQUE INDEX IF NOT EXISTS payment_provider_instances_public_id_unique
ON payment_provider_instances (public_id);
CREATE UNIQUE INDEX IF NOT EXISTS payment_provider_instances_callback_token_unique
ON payment_provider_instances (callback_token_hash)
WHERE callback_token_hash IS NOT NULL;
CREATE INDEX IF NOT EXISTS payment_provider_instances_provider_enabled_idx
ON payment_provider_instances (provider, enabled, id);
-- These values are part of the AEAD associated data. Changing either would
-- make the stored credential envelope undecryptable.
CREATE TRIGGER IF NOT EXISTS payment_provider_instance_identity_immutable
BEFORE UPDATE OF public_id, provider ON payment_provider_instances
FOR EACH ROW
BEGIN
SELECT RAISE(ABORT, 'payment provider instance identity is immutable');
END;
CREATE TABLE IF NOT EXISTS payment_method_routes (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL,
name TEXT NOT NULL,
payment_method TEXT NOT NULL,
currency TEXT NOT NULL,
selection_strategy TEXT NOT NULL DEFAULT 'round_robin',
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK (payment_method IN ('alipay', 'wechatpay', 'card', 'comfy_cloud')),
CHECK (currency IN ('CNY', 'USD')),
CHECK (selection_strategy IN ('round_robin', 'least_amount')),
CHECK (enabled IN (0, 1))
);
CREATE UNIQUE INDEX IF NOT EXISTS payment_method_routes_public_id_unique
ON payment_method_routes (public_id);
CREATE INDEX IF NOT EXISTS payment_method_routes_method_enabled_idx
ON payment_method_routes (payment_method, currency, enabled, id);
CREATE TRIGGER IF NOT EXISTS payment_method_route_identity_immutable
BEFORE UPDATE OF public_id, payment_method, currency ON payment_method_routes
FOR EACH ROW
BEGIN
SELECT RAISE(ABORT, 'payment method route identity is immutable');
END;
CREATE TABLE IF NOT EXISTS payment_method_route_members (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
route_id INTEGER NOT NULL,
provider_instance_id INTEGER NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
daily_capacity_minor INTEGER,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (route_id) REFERENCES payment_method_routes(id) ON DELETE CASCADE,
FOREIGN KEY (provider_instance_id) REFERENCES payment_provider_instances(id) ON DELETE RESTRICT,
UNIQUE (route_id, provider_instance_id),
CHECK (daily_capacity_minor IS NULL OR daily_capacity_minor > 0),
CHECK (enabled IN (0, 1))
);
CREATE INDEX IF NOT EXISTS payment_method_route_members_selection_idx
ON payment_method_route_members (route_id, enabled, position, id);
CREATE TRIGGER IF NOT EXISTS payment_route_member_provider_guard_insert
BEFORE INSERT ON payment_method_route_members
FOR EACH ROW
WHEN NOT EXISTS (
SELECT 1
FROM payment_method_routes r
JOIN payment_provider_instances i ON i.id = NEW.provider_instance_id
WHERE r.id = NEW.route_id
AND (
(r.payment_method = 'alipay' AND i.provider IN ('easypay', 'alipay')) OR
(r.payment_method = 'wechatpay' AND i.provider IN ('easypay', 'wechatpay')) OR
(r.payment_method = 'card' AND i.provider = 'stripe') OR
(r.payment_method = 'comfy_cloud' AND i.provider = 'comfy_cloud')
)
)
BEGIN
SELECT RAISE(ABORT, 'provider instance does not support route payment method');
END;
CREATE TRIGGER IF NOT EXISTS payment_route_member_provider_guard_update
BEFORE UPDATE OF route_id, provider_instance_id ON payment_method_route_members
FOR EACH ROW
WHEN NOT EXISTS (
SELECT 1
FROM payment_method_routes r
JOIN payment_provider_instances i ON i.id = NEW.provider_instance_id
WHERE r.id = NEW.route_id
AND (
(r.payment_method = 'alipay' AND i.provider IN ('easypay', 'alipay')) OR
(r.payment_method = 'wechatpay' AND i.provider IN ('easypay', 'wechatpay')) OR
(r.payment_method = 'card' AND i.provider = 'stripe') OR
(r.payment_method = 'comfy_cloud' AND i.provider = 'comfy_cloud')
)
)
BEGIN
SELECT RAISE(ABORT, 'provider instance does not support route payment method');
END;
-- One lock/cursor row per route. `version` is bumped before selection to turn
-- SQLite's deferred transaction into a serialized route allocation.
CREATE TABLE IF NOT EXISTS payment_method_route_cursors (
route_id INTEGER PRIMARY KEY NOT NULL,
last_member_id INTEGER,
version INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (route_id) REFERENCES payment_method_routes(id) ON DELETE CASCADE,
FOREIGN KEY (last_member_id) REFERENCES payment_method_route_members(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS payment_route_member_daily_usage (
member_id INTEGER NOT NULL,
usage_date TEXT NOT NULL,
currency TEXT NOT NULL,
reserved_minor INTEGER NOT NULL DEFAULT 0,
captured_minor INTEGER NOT NULL DEFAULT 0,
refunded_minor INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (member_id, usage_date, currency),
FOREIGN KEY (member_id) REFERENCES payment_method_route_members(id) ON DELETE CASCADE,
CHECK (currency IN ('CNY', 'USD')),
CHECK (reserved_minor >= 0),
CHECK (captured_minor >= 0),
CHECK (refunded_minor >= 0),
CHECK (refunded_minor <= captured_minor)
);
CREATE TABLE IF NOT EXISTS payment_order_provider_bindings (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL,
payment_order_id INTEGER NOT NULL,
route_id INTEGER NOT NULL,
member_id INTEGER NOT NULL,
provider_instance_id INTEGER NOT NULL,
provider TEXT NOT NULL,
payment_method TEXT NOT NULL,
amount_minor INTEGER NOT NULL,
currency TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'bound',
capacity_state TEXT NOT NULL DEFAULT 'reserved',
provider_session_id TEXT,
provider_payment_id TEXT,
bound_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (payment_order_id) REFERENCES payment_orders(id) ON DELETE RESTRICT,
FOREIGN KEY (route_id) REFERENCES payment_method_routes(id) ON DELETE RESTRICT,
FOREIGN KEY (member_id) REFERENCES payment_method_route_members(id) ON DELETE RESTRICT,
FOREIGN KEY (provider_instance_id) REFERENCES payment_provider_instances(id) ON DELETE RESTRICT,
UNIQUE (payment_order_id),
CHECK (provider IN ('easypay', 'alipay', 'wechatpay', 'stripe', 'comfy_cloud')),
CHECK (payment_method IN ('alipay', 'wechatpay', 'card', 'comfy_cloud')),
CHECK (amount_minor > 0),
CHECK (currency IN ('CNY', 'USD')),
CHECK (status IN ('bound', 'processing', 'paid', 'failed', 'cancelled', 'refunded')),
CHECK (capacity_state IN ('reserved', 'released', 'captured', 'refunded'))
);
CREATE UNIQUE INDEX IF NOT EXISTS payment_order_provider_bindings_public_id_unique
ON payment_order_provider_bindings (public_id);
CREATE UNIQUE INDEX IF NOT EXISTS payment_order_provider_session_unique
ON payment_order_provider_bindings (provider_instance_id, provider_session_id)
WHERE provider_session_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS payment_order_provider_payment_unique
ON payment_order_provider_bindings (provider_instance_id, provider_payment_id)
WHERE provider_payment_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS payment_order_provider_bindings_instance_status_idx
ON payment_order_provider_bindings (provider_instance_id, status, id);
CREATE INDEX IF NOT EXISTS payment_order_provider_bindings_capacity_status_idx
ON payment_order_provider_bindings (capacity_state, status, payment_order_id, id);
CREATE TRIGGER IF NOT EXISTS payment_order_provider_binding_identity_immutable
BEFORE UPDATE OF
payment_order_id, route_id, member_id, provider_instance_id, provider,
payment_method, amount_minor, currency
ON payment_order_provider_bindings
FOR EACH ROW
BEGIN
SELECT RAISE(ABORT, 'payment order provider binding is immutable');
END;
CREATE TRIGGER IF NOT EXISTS payment_order_provider_payment_id_immutable
BEFORE UPDATE OF provider_payment_id
ON payment_order_provider_bindings
FOR EACH ROW
WHEN OLD.provider_payment_id IS NOT NULL
AND NEW.provider_payment_id IS NOT OLD.provider_payment_id
BEGIN
SELECT RAISE(ABORT, 'provider payment ID is immutable after first assignment');
END;
CREATE TABLE IF NOT EXISTS payment_provider_event_inbox (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL,
provider_instance_id INTEGER NOT NULL,
provider_event_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload_json TEXT NOT NULL,
payload_sha256 TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'received',
attempt_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
received_at TEXT NOT NULL DEFAULT (datetime('now')),
processed_at TEXT,
FOREIGN KEY (provider_instance_id) REFERENCES payment_provider_instances(id) ON DELETE RESTRICT,
UNIQUE (provider_instance_id, provider_event_id),
CHECK (length(trim(provider_event_id)) > 0),
CHECK (length(trim(event_type)) > 0),
CHECK (json_valid(payload_json)),
CHECK (length(payload_sha256) = 64),
CHECK (status IN ('received', 'processing', 'processed', 'failed')),
CHECK (attempt_count >= 0)
);
CREATE UNIQUE INDEX IF NOT EXISTS payment_provider_event_inbox_public_id_unique
ON payment_provider_event_inbox (public_id);
CREATE INDEX IF NOT EXISTS payment_provider_event_inbox_work_idx
ON payment_provider_event_inbox (status, received_at, id);
CREATE TRIGGER IF NOT EXISTS payment_provider_event_content_immutable
BEFORE UPDATE OF
public_id, provider_instance_id, provider_event_id, event_type,
payload_json, payload_sha256, received_at
ON payment_provider_event_inbox
FOR EACH ROW
BEGIN
SELECT RAISE(ABORT, 'payment provider event content is immutable');
END;
CREATE TABLE IF NOT EXISTS payment_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL,
job_type TEXT NOT NULL,
payment_order_id INTEGER,
binding_id INTEGER,
provider_instance_id INTEGER NOT NULL,
event_id INTEGER,
payload_json TEXT NOT NULL DEFAULT '{}',
result_json TEXT,
status TEXT NOT NULL DEFAULT 'pending',
attempt_count INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 8,
available_at TEXT NOT NULL DEFAULT (datetime('now')),
lease_token TEXT,
leased_until TEXT,
last_error TEXT,
idempotency_key TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (payment_order_id) REFERENCES payment_orders(id) ON DELETE RESTRICT,
FOREIGN KEY (binding_id) REFERENCES payment_order_provider_bindings(id) ON DELETE RESTRICT,
FOREIGN KEY (provider_instance_id) REFERENCES payment_provider_instances(id) ON DELETE RESTRICT,
FOREIGN KEY (event_id) REFERENCES payment_provider_event_inbox(id) ON DELETE RESTRICT,
CHECK (job_type IN ('create_checkout', 'query_payment', 'capture', 'process_event', 'refund')),
CHECK (json_valid(payload_json)),
CHECK (result_json IS NULL OR json_valid(result_json)),
CHECK (status IN ('pending', 'running', 'succeeded', 'failed', 'dead')),
CHECK (attempt_count >= 0),
CHECK (max_attempts > 0)
);
CREATE UNIQUE INDEX IF NOT EXISTS payment_jobs_public_id_unique
ON payment_jobs (public_id);
CREATE UNIQUE INDEX IF NOT EXISTS payment_jobs_idempotency_unique
ON payment_jobs (idempotency_key);
CREATE INDEX IF NOT EXISTS payment_jobs_work_idx
ON payment_jobs (status, available_at, id);
CREATE INDEX IF NOT EXISTS payment_jobs_lease_idx
ON payment_jobs (status, leased_until, id);
CREATE TABLE IF NOT EXISTS payment_refund_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL,
user_id INTEGER,
payment_order_id INTEGER NOT NULL,
binding_id INTEGER NOT NULL,
amount_minor INTEGER NOT NULL,
currency TEXT NOT NULL,
reason TEXT,
requested_by TEXT NOT NULL,
force_reversal INTEGER NOT NULL DEFAULT 0,
entitlement_target_micros INTEGER NOT NULL DEFAULT 0,
entitlement_shortfall_micros INTEGER NOT NULL DEFAULT 0,
subscription_target_seconds INTEGER NOT NULL DEFAULT 0,
subscription_shortfall_seconds INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
idempotency_key TEXT NOT NULL,
reviewed_by TEXT,
review_note TEXT,
reviewed_at TEXT,
provider_attempt_token TEXT,
provider_attempt_started_at TEXT,
provider_attempt_expires_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT,
FOREIGN KEY (payment_order_id) REFERENCES payment_orders(id) ON DELETE RESTRICT,
FOREIGN KEY (binding_id) REFERENCES payment_order_provider_bindings(id) ON DELETE RESTRICT,
CHECK (amount_minor > 0),
CHECK (currency IN ('CNY', 'USD')),
CHECK (force_reversal IN (0, 1)),
CHECK (entitlement_target_micros >= 0),
CHECK (entitlement_shortfall_micros >= 0),
CHECK (subscription_target_seconds >= 0),
CHECK (subscription_shortfall_seconds >= 0),
CHECK (status IN ('pending', 'approved', 'rejected', 'processing', 'completed', 'failed'))
);
CREATE UNIQUE INDEX IF NOT EXISTS payment_refund_requests_public_id_unique
ON payment_refund_requests (public_id);
CREATE UNIQUE INDEX IF NOT EXISTS payment_refund_requests_idempotency_unique
ON payment_refund_requests (requested_by, idempotency_key);
CREATE INDEX IF NOT EXISTS payment_refund_requests_status_created_idx
ON payment_refund_requests (status, created_at, id);
CREATE INDEX IF NOT EXISTS payment_refund_requests_user_created_idx
ON payment_refund_requests (user_id, created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS payment_refund_requests_attempt_idx
ON payment_refund_requests (status, provider_attempt_expires_at)
WHERE provider_attempt_token IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS payment_refund_requests_one_active_per_order
ON payment_refund_requests (payment_order_id)
WHERE status IN ('pending', 'approved', 'processing');
CREATE TABLE IF NOT EXISTS payment_refund_entitlement_reservations (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
refund_request_id INTEGER NOT NULL,
grant_id INTEGER NOT NULL,
reserved_micros INTEGER NOT NULL,
grant_status_before TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'reserved',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (refund_request_id) REFERENCES payment_refund_requests(id) ON DELETE RESTRICT,
FOREIGN KEY (grant_id) REFERENCES api_credit_grants(id) ON DELETE RESTRICT,
UNIQUE (refund_request_id, grant_id),
CHECK (reserved_micros > 0),
CHECK (status IN ('reserved', 'finalized', 'released'))
);
CREATE TABLE IF NOT EXISTS payment_refund_subscription_reservations (
refund_request_id INTEGER PRIMARY KEY NOT NULL,
subscription_period_id INTEGER NOT NULL,
previous_access_ends_at TEXT NOT NULL,
previous_status TEXT NOT NULL,
reserved_access_ends_at TEXT NOT NULL,
reversal_seconds INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'reserved',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (refund_request_id) REFERENCES payment_refund_requests(id) ON DELETE RESTRICT,
FOREIGN KEY (subscription_period_id) REFERENCES commerce_subscription_periods(id) ON DELETE RESTRICT,
CHECK (reversal_seconds >= 0),
CHECK (previous_status IN ('active', 'revoked')),
CHECK (status IN ('reserved', 'finalized', 'released'))
);
CREATE TABLE IF NOT EXISTS payment_refunds (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL,
refund_request_id INTEGER NOT NULL,
provider_instance_id INTEGER NOT NULL,
provider_refund_id TEXT,
amount_minor INTEGER NOT NULL,
currency TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'processing',
response_json TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT,
FOREIGN KEY (refund_request_id) REFERENCES payment_refund_requests(id) ON DELETE RESTRICT,
FOREIGN KEY (provider_instance_id) REFERENCES payment_provider_instances(id) ON DELETE RESTRICT,
UNIQUE (refund_request_id),
CHECK (amount_minor > 0),
CHECK (currency IN ('CNY', 'USD')),
CHECK (status IN ('processing', 'succeeded', 'failed')),
CHECK (response_json IS NULL OR json_valid(response_json))
);
CREATE UNIQUE INDEX IF NOT EXISTS payment_refunds_public_id_unique
ON payment_refunds (public_id);
CREATE UNIQUE INDEX IF NOT EXISTS payment_refunds_provider_reference_unique
ON payment_refunds (provider_instance_id, provider_refund_id)
WHERE provider_refund_id IS NOT NULL;
@@ -0,0 +1,23 @@
-- End-user social-login identities. Provider tokens are intentionally not
-- persisted; this table only links a stable provider subject to a local user.
CREATE TABLE IF NOT EXISTS user_oauth_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
user_id INTEGER NOT NULL,
provider TEXT NOT NULL,
provider_subject TEXT NOT NULL,
email_snapshot TEXT,
display_name TEXT,
avatar_url TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
last_login_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CHECK (provider IN ('google', 'github', 'wechat')),
CHECK (length(provider_subject) > 0)
);
CREATE UNIQUE INDEX IF NOT EXISTS user_oauth_identities_provider_subject_unique
ON user_oauth_identities (provider, provider_subject);
CREATE INDEX IF NOT EXISTS user_oauth_identities_user_id_idx
ON user_oauth_identities (user_id);
@@ -0,0 +1,27 @@
-- Durable pre-authorization leases bound ordinary API spend to one in-flight
-- request per user. These are deliberately separate from credit/refund
-- reservations: no balance is moved into reserved_micros for a request lease.
CREATE TABLE IF NOT EXISTS api_request_leases (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
downstream_key_id INTEGER,
model TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
acquired_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
released_at TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (downstream_key_id) REFERENCES downstream_api_keys(id) ON DELETE SET NULL,
CHECK (status IN ('active', 'released', 'expired'))
);
CREATE UNIQUE INDEX IF NOT EXISTS api_request_leases_public_id_unique
ON api_request_leases (public_id);
CREATE UNIQUE INDEX IF NOT EXISTS api_request_leases_one_active_per_user
ON api_request_leases (user_id) WHERE status = 'active';
CREATE INDEX IF NOT EXISTS api_request_leases_expiry_idx
ON api_request_leases (status, expires_at);
CREATE INDEX IF NOT EXISTS api_request_leases_user_created_idx
ON api_request_leases (user_id, acquired_at DESC, id DESC);
@@ -0,0 +1,72 @@
-- Durable Agent Workbench run lifecycle and per-session workspace identity.
--
-- Active statuses deliberately include `cancelling`: requesting cancellation
-- does not release the session lease until the worker has actually stopped.
ALTER TABLE agent_sessions ADD COLUMN workspace_id TEXT;
ALTER TABLE agent_sessions ADD COLUMN revision INTEGER NOT NULL DEFAULT 0;
ALTER TABLE agent_sessions ADD COLUMN runtime_status TEXT NOT NULL DEFAULT 'idle';
ALTER TABLE agent_sessions ADD COLUMN context_version INTEGER NOT NULL DEFAULT 1;
UPDATE agent_sessions
SET workspace_id = lower(hex(randomblob(4))) || '-' ||
lower(hex(randomblob(2))) || '-' ||
'4' || substr(lower(hex(randomblob(2))), 2) || '-' ||
substr('89ab', abs(random()) % 4 + 1, 1) ||
substr(lower(hex(randomblob(2))), 2) || '-' ||
lower(hex(randomblob(6)))
WHERE workspace_id IS NULL OR trim(workspace_id) = '';
CREATE UNIQUE INDEX IF NOT EXISTS agent_sessions_workspace_id_unique
ON agent_sessions(workspace_id);
CREATE TABLE IF NOT EXISTS agent_runs (
id TEXT PRIMARY KEY NOT NULL,
session_id TEXT NOT NULL,
user_id INTEGER,
actor_key TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'starting',
model TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 0,
error_message TEXT,
cancel_requested_at TEXT,
started_at TEXT NOT NULL DEFAULT (datetime('now')),
finished_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY(session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL,
CHECK(status IN (
'starting', 'running', 'waiting_approval', 'waiting_input', 'cancelling',
'succeeded', 'failed', 'cancelled', 'interrupted'
))
);
CREATE UNIQUE INDEX IF NOT EXISTS agent_runs_one_active_per_session
ON agent_runs(session_id)
WHERE status IN ('starting', 'running', 'waiting_approval', 'waiting_input', 'cancelling');
CREATE INDEX IF NOT EXISTS agent_runs_actor_status_created
ON agent_runs(actor_key, status, created_at DESC);
CREATE INDEX IF NOT EXISTS agent_runs_session_created
ON agent_runs(session_id, created_at DESC);
CREATE TABLE IF NOT EXISTS agent_tool_audits (
id TEXT PRIMARY KEY NOT NULL,
run_id TEXT NOT NULL,
session_id TEXT NOT NULL,
actor_key TEXT NOT NULL,
tool_name TEXT NOT NULL,
risk TEXT NOT NULL,
decision TEXT NOT NULL,
input_summary TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY(run_id) REFERENCES agent_runs(id) ON DELETE CASCADE,
FOREIGN KEY(session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS agent_tool_audits_run_created
ON agent_tool_audits(run_id, created_at DESC);
@@ -0,0 +1,8 @@
-- Link proxy usage records to durable Agent Workbench runs.
ALTER TABLE proxy_logs ADD COLUMN agent_run_id TEXT;
CREATE INDEX IF NOT EXISTS proxy_logs_agent_run_id_idx
ON proxy_logs(agent_run_id)
WHERE agent_run_id IS NOT NULL;
@@ -0,0 +1,37 @@
-- Ownership index for media stored under the shared Comfy Cloud API key.
-- Cloud remains the blob store; this table is the local authorization source.
CREATE TABLE IF NOT EXISTS comfy_cloud_jobs (
job_id TEXT PRIMARY KEY NOT NULL,
user_id INTEGER NOT NULL,
last_synced_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS comfy_cloud_jobs_user_updated_idx
ON comfy_cloud_jobs(user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS comfy_cloud_assets (
asset_id TEXT PRIMARY KEY NOT NULL,
user_id INTEGER NOT NULL,
job_id TEXT,
asset_hash TEXT,
name TEXT,
tags_json TEXT NOT NULL DEFAULT '[]',
snapshot_json TEXT,
is_preview INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS comfy_cloud_assets_user_created_idx
ON comfy_cloud_assets(user_id, created_at DESC, asset_id DESC);
CREATE INDEX IF NOT EXISTS comfy_cloud_assets_user_job_idx
ON comfy_cloud_assets(user_id, job_id);
CREATE INDEX IF NOT EXISTS comfy_cloud_assets_user_name_idx
ON comfy_cloud_assets(user_id, name);
CREATE INDEX IF NOT EXISTS comfy_cloud_assets_user_hash_idx
ON comfy_cloud_assets(user_id, asset_hash);
@@ -0,0 +1 @@
-- MySQL/MariaDB constraint parity is implemented in the matching MySQL migration.
@@ -0,0 +1,144 @@
ALTER TABLE api_credit_grants ADD COLUMN grant_kind TEXT NOT NULL DEFAULT 'balance';
UPDATE api_credit_grants
SET grant_kind = CASE
WHEN source_type = 'subscription' THEN 'package'
WHEN source_type = 'package' AND payment_order_id IS NULL THEN 'package'
ELSE 'balance'
END;
CREATE INDEX IF NOT EXISTS api_credit_grants_kind_spend_idx
ON api_credit_grants (user_id, grant_kind, status, expires_at, id);
CREATE TABLE comfy_billing_accounts (
user_id INTEGER PRIMARY KEY NOT NULL,
available_micros INTEGER NOT NULL DEFAULT 0,
reserved_micros INTEGER NOT NULL DEFAULT 0,
debt_micros INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
version INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CHECK (available_micros >= 0), CHECK (reserved_micros >= 0), CHECK (debt_micros >= 0),
CHECK (status IN ('active', 'frozen', 'closed'))
);
CREATE TABLE comfy_credit_grants (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL,
grant_kind TEXT NOT NULL,
source_type TEXT NOT NULL,
source_id TEXT,
payment_order_id INTEGER,
total_micros INTEGER NOT NULL,
remaining_micros INTEGER NOT NULL,
reserved_micros INTEGER NOT NULL DEFAULT 0,
starts_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT,
status TEXT NOT NULL DEFAULT 'active',
idempotency_key TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (payment_order_id) REFERENCES payment_orders(id) ON DELETE SET NULL,
CHECK (grant_kind IN ('package', 'balance')),
CHECK (total_micros > 0), CHECK (remaining_micros >= 0), CHECK (reserved_micros >= 0),
CHECK (remaining_micros + reserved_micros <= total_micros),
CHECK (status IN ('scheduled', 'active', 'refund_pending', 'exhausted', 'expired', 'revoked'))
);
CREATE INDEX comfy_credit_grants_spend_idx
ON comfy_credit_grants (user_id, grant_kind, status, starts_at, expires_at, id);
CREATE TABLE comfy_charge_holds (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL,
task_type TEXT NOT NULL,
ref_id TEXT NOT NULL,
amount_micros INTEGER NOT NULL,
package_micros INTEGER NOT NULL DEFAULT 0,
balance_micros INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
idempotency_key TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
settled_at TEXT,
released_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CHECK (amount_micros > 0), CHECK (status IN ('active', 'settled', 'released'))
);
CREATE INDEX comfy_charge_holds_ref_idx ON comfy_charge_holds (task_type, ref_id, status);
CREATE TABLE comfy_charge_hold_allocations (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
hold_id INTEGER NOT NULL,
grant_id INTEGER NOT NULL,
amount_micros INTEGER NOT NULL,
grant_kind TEXT NOT NULL,
FOREIGN KEY (hold_id) REFERENCES comfy_charge_holds(id) ON DELETE CASCADE,
FOREIGN KEY (grant_id) REFERENCES comfy_credit_grants(id) ON DELETE RESTRICT,
UNIQUE (hold_id, grant_id), CHECK (amount_micros > 0),
CHECK (grant_kind IN ('package', 'balance'))
);
CREATE TABLE comfy_entitlement_ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
user_id INTEGER NOT NULL,
entry_type TEXT NOT NULL,
amount_micros INTEGER NOT NULL,
balance_after_micros INTEGER NOT NULL,
package_amount_micros INTEGER NOT NULL DEFAULT 0,
balance_amount_micros INTEGER NOT NULL DEFAULT 0,
grant_id INTEGER,
task_type TEXT,
ref_type TEXT,
ref_id TEXT,
operator TEXT,
note TEXT,
idempotency_key TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (grant_id) REFERENCES comfy_credit_grants(id) ON DELETE SET NULL
);
CREATE INDEX comfy_entitlement_ledger_user_idx
ON comfy_entitlement_ledger (user_id, created_at DESC, id DESC);
CREATE TABLE comfy_task_prices (
task_type TEXT PRIMARY KEY NOT NULL,
display_name TEXT NOT NULL,
points_micros INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK (points_micros >= 0), CHECK (enabled IN (0, 1))
);
INSERT INTO comfy_task_prices (task_type, display_name) VALUES
('comfy-native-workflow', 'Comfy 工作流'),
('money-printer-video', 'Money Printer 视频'),
('digital-human-generation', '数字人生成'),
('jimeng-image-generation', '即梦图片'),
('jimeng-video-generation', '即梦视频'),
('seedance-video-generation', 'Seedance 视频');
INSERT INTO comfy_billing_accounts (user_id, available_micros)
SELECT user_id, ROUND(MAX(points_balance, 0) * 1000000.0)
FROM user_credits WHERE points_balance > 0;
INSERT INTO comfy_credit_grants
(public_id, user_id, grant_kind, source_type, source_id, total_micros,
remaining_micros, idempotency_key)
SELECT 'cgrant_legacy_' || user_id, user_id, 'balance', 'legacy_opening_balance',
'user_credits', ROUND(MAX(points_balance, 0) * 1000000.0),
ROUND(MAX(points_balance, 0) * 1000000.0),
'migration:20260711000004:comfy:user:' || user_id
FROM user_credits WHERE points_balance > 0;
INSERT INTO comfy_entitlement_ledger
(user_id, entry_type, amount_micros, balance_after_micros, ref_type,
ref_id, operator, idempotency_key)
SELECT user_id, 'opening_balance', available_micros, available_micros,
'migration', '20260711000004', 'system',
'migration:20260711000004:comfy:ledger:user:' || user_id
FROM comfy_billing_accounts WHERE available_micros > 0;
@@ -0,0 +1,15 @@
ALTER TABLE commerce_products ADD COLUMN billing_track TEXT NOT NULL DEFAULT 'api';
ALTER TABLE commerce_products ADD COLUMN grant_kind TEXT NOT NULL DEFAULT 'package';
ALTER TABLE commerce_products ADD COLUMN benefit_type TEXT NOT NULL DEFAULT 'api_credit';
ALTER TABLE commerce_products ADD COLUMN validity_days INTEGER;
UPDATE commerce_products SET grant_kind = CASE WHEN product_type = 'subscription' THEN 'package' ELSE 'balance' END;
ALTER TABLE commerce_orders ADD COLUMN billing_track_snapshot TEXT NOT NULL DEFAULT 'api';
ALTER TABLE commerce_orders ADD COLUMN grant_kind_snapshot TEXT NOT NULL DEFAULT 'package';
ALTER TABLE commerce_orders ADD COLUMN benefit_type_snapshot TEXT NOT NULL DEFAULT 'api_credit';
ALTER TABLE commerce_orders ADD COLUMN validity_days_snapshot INTEGER;
UPDATE commerce_orders SET grant_kind_snapshot = CASE WHEN product_type_snapshot = 'subscription' THEN 'package' ELSE 'balance' END;
ALTER TABLE commerce_order_fulfillments ADD COLUMN comfy_credit_grant_id INTEGER;
ALTER TABLE payment_orders ADD COLUMN billing_target TEXT NOT NULL DEFAULT 'api';
ALTER TABLE user_recharge_records ADD COLUMN billing_target TEXT NOT NULL DEFAULT 'api';
@@ -0,0 +1,25 @@
ALTER TABLE api_entitlement_ledger ADD COLUMN package_amount_micros INTEGER NOT NULL DEFAULT 0;
ALTER TABLE api_entitlement_ledger ADD COLUMN balance_amount_micros INTEGER NOT NULL DEFAULT 0;
CREATE TABLE comfy_refund_entitlement_reservations (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
refund_request_id INTEGER NOT NULL,
grant_id INTEGER NOT NULL,
reserved_micros INTEGER NOT NULL,
grant_status_before TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'reserved',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (refund_request_id) REFERENCES payment_refund_requests(id) ON DELETE RESTRICT,
FOREIGN KEY (grant_id) REFERENCES comfy_credit_grants(id) ON DELETE RESTRICT,
UNIQUE (refund_request_id, grant_id),
CHECK (reserved_micros > 0),
CHECK (status IN ('reserved', 'finalized', 'released'))
);
CREATE TRIGGER commerce_order_billing_snapshot_immutable
BEFORE UPDATE OF billing_track_snapshot, grant_kind_snapshot, benefit_type_snapshot, validity_days_snapshot
ON commerce_orders FOR EACH ROW
BEGIN
SELECT RAISE(ABORT, 'commerce order billing snapshot is immutable');
END;
@@ -0,0 +1,11 @@
CREATE TABLE user_profile_avatars (
user_id INTEGER PRIMARY KEY NOT NULL,
public_id TEXT NOT NULL UNIQUE,
image_bytes BLOB NOT NULL,
mime_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CHECK (size_bytes > 0)
);