openvibers/openvibe.tips Commits

Tips keeps no Live-import or SQLite code (the import is long done; no compatibility kept): server/importer/live.js, scripts/import-live.js and migrate-to-postgres.js, the import and SQLite-move tests and fixture, the better-sqlite3 dependency, sqlitePath/TIPS_DB_PATH, and the legacy_source columns and migration_maps table (dropped by the new migration 0002); goal:<id> takes Tips goal ids only. The earlier await fixes (13 files: async store calls inside transactions and routes) ship with it. Reviewed independently; its findings (untracked migration, lockfile, two stale README lines) are resolved here. npm test 16/16 test files passed.

OP OpenVibers committed 29 Sept 2026 · 036effdc2d · 31 files, +187 −1453


Changes

ddiff --git a/.env.example b/.env.example
iindex a3b1b3c..8a5b221 100644
---- a/.env.example
++++ b/.env.example
@@@ -17,16 +17,12 @@ TRUST_PROXY=1
# an embedded PGlite database in data/pglite (one process). Tests need neither (PGlite, or the containers
# of node_modules/openvibe-sdk/scripts/test-services.sh).
DATABASE_URL=
--# DATABASE_DIRECT_URL: the owner role on a direct connection, for the migrations at boot and
--# scripts/migrate-to-postgres.js.
++# DATABASE_DIRECT_URL: the owner role on a direct connection, for the migrations at boot.
DATABASE_DIRECT_URL=
# VALKEY_URL: shared, never authoritative state (per-actor limits, overlay fan-out and stream slots).
# Unset: kept in each process, right for one process only.
VALKEY_URL=
VALKEY_PREFIX=ov:tips:
--# The SQLite file of the release before PostgreSQL: read (never written) only by
--# scripts/migrate-to-postgres.js. The systemd unit sets /var/lib/openvibe-tips/tips.db.
--#TIPS_DB_PATH=./data/tips.db
# ── Identity: OpenVibe.Network ──────────────────────────────
OV_NETWORK_URL=https://openvibe.network
ddiff --git a/README.md b/README.md
iindex 5967312..294b740 100644
---- a/README.md
++++ b/README.md
@@@ -3,10 +3,9 @@
> Creator support: tips, goals, paid messages, TTS and media requests, overlays.
**Status:** alpha — runtime built and tested against stubs (roadmap Wave 9). Deployed internally, not
--launched: running on the host since 2026-09-23 on `127.0.0.1:4610` only, holding the Live import and no
++launched: running on the host since 2026-09-23 on `127.0.0.1:4610` only, with no
creator profile yet, with no public route. OpenVibe.Live remains where tips happen until the Billing cutover (see *What waits*).
--Data: PostgreSQL through PgBouncer, and Valkey for shared state ([ADR-035](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-035-postgresql-and-valkey.md));
--production runs the SQLite release until the switch in *Deploy*.
++Data: PostgreSQL through PgBouncer, and Valkey for shared state ([ADR-035](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-035-postgresql-and-valkey.md));
**Domain:** `openvibe.tips` (keeps its OpenVibe.Sites placeholder until the launch rule below holds)
**Port / service id:** 4610 / `tips`
**Decision:** [ADR-012](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-012-economic-classification.md) — Tips never moves money; Billing does.
@@@ -22,7 +21,7 @@ from *interaction delivered*.
## Owns
- `tip_interactions`, `creator_tip_profiles`, `tip_goals`, `tip_goal_contributions`, `paid_messages`,
-- `paid_media_requests`, `overlay_configs`, `overlay_deliveries`, `interaction_effects`, `migration_maps`
++ `paid_media_requests`, `overlay_configs`, `overlay_deliveries`, `interaction_effects`
(its own PostgreSQL database `ov_tips`, schema in [migrations/](migrations/), access in `server/db.js`) —
interaction state and **references** to Billing transaction ids, never a mutable cash balance
- revocable scoped overlay tokens (`overlay_tokens`, hashed at rest; never creator cookies)
@@@ -45,7 +44,7 @@ from *interaction delivered*.
PowerChat, once Billing receives the PowerChat webhook)
- **OpenVibe.Events** — Billing's events in (signed webhook + openvibe-sdk inbox), Tips' events out
(openvibe-sdk transactional outbox)
--- **OpenVibe.Network** — SSO for people, service tokens, JWKS, identity resolve (importer)
++- **OpenVibe.Network** — SSO for people, service tokens, JWKS, identity resolve
- **OpenVibe.Live** — chat delivery through `/internal/tips/deliveries` (deployed in Live since `f11f809`; [docs/live-patch.diff](docs/live-patch.diff) is the original patch) until OpenVibe.Chat exposes paid messages; overlay consumer
- **OpenVibe.Shared** v1.25.0 (app icon, SSR footer, noscript nav, release manifest, legal pages) and
the Network's `navbar.js`; **openvibe-contracts** v0.76.0 and **openvibe-sdk** v0.18.0 (service tokens,
@@@ -65,7 +64,6 @@ Called elsewhere, as the service principal `tips`:
|---|---|---|
| OpenVibe.Billing | `billing.intent.create`, `billing.transfer.create` | checkout, and a tip from credit |
| OpenVibe.Events | `events.event.publish`; `events.subscription.manage` once (`npm run subscribe`) | the outbox relay; the `billing.transaction.*` and `billing.receipt.*` subscriptions |
--| OpenVibe.Network | `identity.subject.resolve` | the Live importer's creator mapping |
| OpenVibe.Live | `live.tips_delivery.write` (only with `TIPS_CHAT_ADAPTER=live-chat`) | chat lines and alerts through `/internal/tips/deliveries` |
## Run it
@@@ -79,8 +77,6 @@ eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" # PostgreSQL 1
npm test # … now also test/integration.test.js on the containers
npm run test:pg # every test file through PgBouncer and Valkey
npm run subscribe # create the billing.transaction.* and billing.receipt.* subscriptions in OpenVibe.Events
--node scripts/import-live.js --live-db <live snapshot> --billing-db <billing snapshot> [--dry-run] [--json]
--npm run migrate-to-postgres -- [--sqlite <copy>] [--pglite] [--json] # the one-time SQLite import (Deploy)
```
Production (deployed, loopback only): `/opt/openvibe.tips`, env `/etc/openvibe/tips.env`, unit
@@@ -140,8 +136,8 @@ minus what Billing took back) — the number that must equal Billing's books —
(settings changes and per-creator limits), the interaction row, its goals by id, the creator's overlay
stream (an advisory lock, so a creator's deliveries commit in seq order), then the rest. Serialization
failures and deadlocks are retried by the data layer.
--- Text is made storable on the way in: PostgreSQL keeps no NUL character, and jsonb no unpaired surrogate
-- (SQLite kept both), so they are dropped or replaced (U+FFFD) in what people and Billing's events send.
++- Text is made storable on the way in: PostgreSQL keeps no NUL character, and jsonb no unpaired surrogate,
++ so they are dropped or replaced (U+FFFD) in what people and Billing's events send.
- **Background work runs in every process** and claims its rows: due effects and due transfers with a
lease (`FOR UPDATE SKIP LOCKED`, the lease in `next_attempt_at` / `next_transfer_at`), the overlay
window sweep with `SKIP LOCKED`, the outbox relay by the SDK's lease; prunes are idempotent deletes.
@@@ -269,8 +265,8 @@ Consumed:
`donor_name` — null when `anonymous` — `message`, `provider`, `provider_event_id`, `app_purpose`/`app_ref`,
`test`) is sent by Billing only once it is the money authority (`BILLING_AUTHORITY=billing` in
billing.env); before that Live's own PowerChat webhook announces those tips, so nothing is announced
--twice. A `goal:<id>` in `app_purpose`/`app_ref` picks the goal: a Tips goal id, or a Live `donation_goals`
--id mapped by the Live import; otherwise Live's rule (the only active goal).
++twice. A `goal:<id>` in `app_purpose`/`app_ref` picks the goal (a Tips goal id); otherwise the creator's
++only active goal.
## Overlays
@@@ -358,22 +354,6 @@ Never limited: `/api/health`, `/api/ready`, `/release.json`, `/metrics`, the ove
`TIPS_OVERLAY_MAX_STREAMS`) and the signed Events deliveries at `/internal/events`. `test/actor-limits.test.js`.
With `VALKEY_URL` every process counts one caller together (`test/integration.test.js`).
--## Import and reconciliation
--
--`scripts/import-live.js` reads a **copy** of Live's database (read-only) after Billing's own import, and
--a copy of Billing's database for the links and totals:
--
--- `transactions` donations → interactions (`settlement imported`), linked to Billing by its importer key
-- `import:live:txn:<live id>`; non-completed rows excluded with the reason; rows before
-- `stats_vibes_reset_at` flagged test (as Billing flags them); unmapped creators **held**, released by a
-- later run; Live refunds applied to the donation they undo
--- PowerChat donations recorded only in `chat_messages` → EXTERNAL interactions; the celebration line of a
-- site-routed `pcdon` tip is matched to its transaction and not counted twice
--- `donation_goals` → goals, Live's `current_amount` carried over as `opening_amount`
--- reconciliation: per creator, Tips' `settled_via_billing` must equal Billing's (donations to them minus
-- refunds from them, non-test); mismatches are listed and the exit code is 1. Every source row ends up
-- imported, excluded with a reason, or held (`migration_maps`).
--
## Acceptance (must be true before "done")
| Criterion | Evidence |
@@@ -381,12 +361,11 @@ a copy of Billing's database for the links and totals:
| a duplicate provider webhook yields one Billing transaction and one logical interaction | `test/settlement.test.js` (same event redelivered, same transaction under a new event id, foreign donation delivered three times) |
| overlay replay never charges again | `test/overlays.test.js` (three `Last-Event-ID` replays: transfers, contributions, goal total, payable, events unchanged) |
| the creator can use openvibe.tips with Live offline | pages, API, overlays and settlement need no Live call (`test/pages.test.js`, `test/overlays.test.js` run with no Live); only the `live-chat` adapter talks to Live and its failure never touches payment (`test/delivery.test.js`) — **not yet demonstrated on the real host** |
--| creator totals reconcile exactly to Billing | `test/import.test.js` (fixtures in Live's schema and Billing's importer keys; a Billing donation Tips never saw is reported, then reconciles once its event arrives); `test/api.test.js` (totals = the stub Billing payable) |
++| creator totals reconcile exactly to Billing | `test/api.test.js` (totals = the stub Billing payable) |
| simulation never counted; token revocation immediate; goals from settled only; reversal keeps the delivery record | `test/overlays.test.js`, `test/api.test.js`, `test/settlement.test.js` |
--| an EXTERNAL PowerChat tip Billing announced is celebrated once: Live chat line, overlay, goal; never Billing money | `test/external.test.js` (redelivery and republish, same key as `POST /interactions/external`, Tips and Live goal ids, anonymous, test receipts, malformed payloads) |
++| an EXTERNAL PowerChat tip Billing announced is celebrated once: Live chat line, overlay, goal; never Billing money | `test/external.test.js` (redelivery and republish, same key as `POST /interactions/external`, Tips goal ids, anonymous, test receipts, malformed payloads) |
| paid messages are filtered before they are shown or read; the creator and their moderators hide and show them without touching the money | `test/moderation.test.js` (mask, hold and release, invisible/full-width evasion, TTS text, invitation links, hide cancels queued chat/TTS and retracts the overlay live/replay/state, restore, pending payments, who may moderate, event payloads) |
| the suite passes on PostgreSQL behind PgBouncer in transaction mode; one settlement or reversal raced across two processes counts once; the unpaid-checkout limit holds under concurrency; leased work runs once; flushing Valkey changes no money answer (ADR-007 2026-09-24, ADR-035) | `npm run test:pg`; `test/integration.test.js` (two app instances on one database and one Valkey) |
--| the SQLite data moves whole: every table and column, verified by counts and checksums | `test/sqlite-import.test.js`, `test/migrations.test.js` (no column left behind) |
| a supporter's privacy holds everywhere; the creator chooses what public pages show; export and erasure keep the books reconciled | `test/privacy.test.js` (anonymous / hidden amount / private message across API, overlays, chat jobs, pages and events; goal and supporters page settings; export; erasure scrubs rows, overlay payloads, stored answers and unsent events, emits `tips.interaction.erased`, totals still equal Billing) |
## What waits
@@@ -418,23 +397,6 @@ Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not
restart; afterwards `sudo ovhost rollback tips --to <sha>`. Migrations are expand-only until a
`contract` migration, which waits out the 7-day N-1 window.
--**The switch from SQLite to PostgreSQL** (once; the procedure is the SDK's
--[migrating-to-postgresql.md](https://github.com/OpenVibers/OpenVibe.SDK/blob/main/docs/migrating-to-postgresql.md), section 6):
--1. `sudo /opt/openvibe.host/roles/data/add-service.sh tips` — database `ov_tips`, its roles and Valkey
-- user; it writes `DATABASE_URL`, `DATABASE_DIRECT_URL`, `VALKEY_URL`, `VALKEY_PREFIX` into
-- `/etc/openvibe/tips.env`.
--2. Rehearse on a copy of `/var/lib/openvibe-tips/tips.db` (`sqlite3 … ".backup …"` as the service user):
-- `npm run migrate-to-postgres -- --sqlite <copy> --pglite` must end OK.
--3. Merge the PostgreSQL release; do not deploy it yet.
--4. `sudo systemctl stop openvibe-tips` (the write freeze), then back up `tips.db`.
--5. From the new release's directory, as the service user with the service's environment:
-- `node scripts/migrate-to-postgres.js` — it migrates as the owner, imports every table with
-- `truncate`, verifies counts and checksums, changes nothing in the SQLite file, and exits 1 unless OK.
--6. `sudo ovhost deploy tips`; check `/api/ready` shows `db` `store: postgresql` and `valkey` ok, the
-- dashboard, a receipt and an overlay, and a flat error rate.
--7. Keep `tips.db` read-only for 7 days (the rollback: the previous release reads it, losing the writes
-- made since the switch unless they are replayed), then `ovhost archive push` it and delete it.
--
## Launch rule
This repository does not make the product real, and the domain keeps its placeholder page on
@@@ -449,8 +411,7 @@ exist here (plan §12.12):
principal is provisioned on the host;
3. server-rendered public routes useful without JavaScript — ✔;
4. real persistence and end-to-end workflows — ✔ against stubs; not yet against the real Billing and
-- Events (Billing is in shadow and has sent no events; production holds the Live import and no creator
-- profile);
++ Events (Billing is in shadow and has sent no events; production has no creator profile yet);
5. capability and event registration against OpenVibe.Contracts — ✔ v0.15.0, payload schemas in v0.30.2 and v0.32.0 (v0.76.0 pinned);
6. a migration/seed strategy ✔, a written threat review ✔ ([docs/threat-review.md](docs/threat-review.md),
internal; an independent review is still due), and sitemap/robots ✔ (no feed);
ddiff --git a/STATUS.json b/STATUS.json
iindex 927712d..d7a6e56 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -8,9 +8,9 @@
"authoritative": false,
"deployed": true,
"deployment": "Deployed internally, not launched: deployed with `sudo ovhost deploy tips`, /opt/openvibe.tips, unit openvibe-tips, 127.0.0.1:4610 loopback only; https://openvibe.tips still serves the OpenVibe.Sites placeholder. Principal tips; Events subscriptions billing.transaction.* and billing.receipt.* -> /internal/events (secret rotated 2026-09-24); TIPS_CHAT_ADAPTER is empty (no chat side effects).",
-- "database": "PostgreSQL 18 through PgBouncer (transaction mode) as the system of record, Valkey 9 for shared state (per-actor limits, overlay fan-out, overlay stream slots), ADR-035: database ov_tips from OpenVibe.Host's data role, schema in migrations/ (0001_initial, expand), async on openvibe-sdk/db v0.18.0, money paths serializable with row locks, PostgreSQL outbox and inbox. Switched in production on 2026-09-28: scripts/migrate-to-postgres.js imported /var/lib/openvibe-tips/tips.db verified by counts and checksums (rehearsed on a copy of the production file first: report.ok, 139 interactions, 143 migration maps, 4 goals); the SQLite file stays read-only for the 7-day N-1 window (until 2026-10-05), then is archived to B2.",
++ "database": "PostgreSQL 18 through PgBouncer (transaction mode) as the system of record, Valkey 9 for shared state (per-actor limits, overlay fan-out, overlay stream slots), ADR-035: database ov_tips from OpenVibe.Host's data role, schema in migrations/ (0001_initial expand, 0002_drop_legacy_import contract), async on openvibe-sdk/db v0.18.0, money paths serializable with row locks, PostgreSQL outbox and inbox.",
"contracts": "openvibe-contracts v0.76.0",
-- "notes": "Runtime, API, SSR pages, overlays, the Billing settlement consumer, events outbox, Live import and reconciliation are built, tested and deployed, together with /metrics (loopback only), truthful /api/ready (Billing and Events checks), supporter privacy (anonymous, hidden amount, private message), goal and supporter-page settings, export and erasure (tips.interaction.erased), moderation (word filter, hide/show, invited moderators, tips.interaction.moderated) and per-actor limits. Capabilities (18 tips.*) and payload schemas are in openvibe-contracts. On 2026-09-24 production held the Live import (3 import runs, the last 2026-09-23 18:31 UTC: 139 interactions, 4 goals, 143 migration maps); 0 creator profiles and 0 overlay tokens (nobody had set up the product). No billing.* event had arrived: Billing runs in shadow. A written internal threat review (docs/threat-review.md) with its fixes and test/security.test.js. Not done: an independent security review; the openvibe.tips launch. Live stays the place tips happen until the Billing cutover (blocked on the owner re-pointing the PowerChat webhook) and the launch rule in README.md holds.",
++ "notes": "Runtime, API, SSR pages, overlays, the Billing settlement consumer and the events outbox are built, tested and deployed, together with /metrics (loopback only), truthful /api/ready (Billing and Events checks), supporter privacy (anonymous, hidden amount, private message), goal and supporter-page settings, export and erasure (tips.interaction.erased), moderation (word filter, hide/show, invited moderators, tips.interaction.moderated) and per-actor limits. Capabilities (18 tips.*) and payload schemas are in openvibe-contracts. No billing.* event had arrived: Billing runs in shadow. A written internal threat review (docs/threat-review.md) with its fixes and test/security.test.js. Not done: an independent security review; the openvibe.tips launch. Live stays the place tips happen until the Billing cutover (blocked on the owner re-pointing the PowerChat webhook) and the launch rule in README.md holds.",
"updated": "2026-09-28",
"features": [
"tips, paid messages, TTS and media requests settled by OpenVibe.Billing (never Tips' own money)",
@@@ -20,7 +20,6 @@
"supporter privacy, export and erasure",
"moderation: word filter, hide and show, invited moderators",
"Live chat delivery adapter",
-- "Live import and reconciliation against Billing",
"server-rendered creator pages useful without JavaScript",
"/api/v1 by capability",
"per-actor limits (openvibe-sdk/limits)",
ddiff --git a/deploy/systemd/openvibe-tips.service b/deploy/systemd/openvibe-tips.service
iindex 06cb639..ee379ed 100644
---- a/deploy/systemd/openvibe-tips.service
++++ b/deploy/systemd/openvibe-tips.service
@@@ -23,11 +23,9 @@ ExecStart=/usr/bin/env node /opt/openvibe.tips/server/index.js
Restart=always
RestartSec=5
# DATABASE_URL, DATABASE_DIRECT_URL, VALKEY_URL and VALKEY_PREFIX come from the env file
--# (roles/data/add-service.sh tips). The state directory keeps the SQLite file of the release before
--# PostgreSQL: read-only rollback for the 7-day N-1 window, read by scripts/migrate-to-postgres.js.
++# (roles/data/add-service.sh tips).
StateDirectory=openvibe-tips
StateDirectoryMode=0700
--Environment=TIPS_DB_PATH=/var/lib/openvibe-tips/tips.db
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
ddiff --git a/migrations/0002_drop_legacy_import.sql b/migrations/0002_drop_legacy_import.sql
nnew file mode 100644
iindex 0000000..fe02e08
---- /dev/null
++++ b/migrations/0002_drop_legacy_import.sql
@@@ -0,0 +1,10 @@
++-- phase: contract
++-- after: 0001
++--
++-- The Live import and the SQLite migration era are gone. `legacy_source` and the `migration_maps`
++-- table (0001_initial) existed only to key imported Live rows to the Tips entities they became;
++-- nothing reads or writes them now (server/importer/live.js and the import scripts are deleted), so
++-- the one-time import's columns go and its map table is dropped.
++ALTER TABLE tip_interactions DROP COLUMN legacy_source;
++ALTER TABLE tip_goals DROP COLUMN legacy_source;
++DROP TABLE migration_maps;
ddiff --git a/package-lock.json b/package-lock.json
iindex 298730f..d79b2c1 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -9,7 +9,6 @@
"version": "0.1.0",
"license": "AGPL-3.0-only",
"dependencies": {
-- "better-sqlite3": "^11.10.0",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
@@@ -110,7 +109,9 @@
"url": "https://feross.org/support"
}
],
-- "license": "MIT"
++ "license": "MIT",
++ "optional": true,
++ "peer": true
},
"node_modules/better-sqlite3": {
"version": "11.10.0",
@@@ -118,6 +119,8 @@
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
"hasInstallScript": true,
"license": "MIT",
++ "optional": true,
++ "peer": true,
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
@@@ -128,6 +131,8 @@
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
++ "optional": true,
++ "peer": true,
"dependencies": {
"file-uri-to-path": "1.0.0"
}
@@@ -137,6 +142,8 @@
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
++ "optional": true,
++ "peer": true,
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
@@@ -186,6 +193,8 @@
}
],
"license": "MIT",
++ "optional": true,
++ "peer": true,
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
@@@ -239,7 +248,9 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
-- "license": "ISC"
++ "license": "ISC",
++ "optional": true,
++ "peer": true
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
@@@ -313,6 +324,8 @@
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
++ "optional": true,
++ "peer": true,
"dependencies": {
"mimic-response": "^3.1.0"
},
@@@ -328,6 +341,8 @@
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
++ "optional": true,
++ "peer": true,
"engines": {
"node": ">=4.0.0"
}
@@@ -365,6 +380,8 @@
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
++ "optional": true,
++ "peer": true,
"engines": {
"node": ">=8"
}
@@@ -424,6 +441,8 @@
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
++ "optional": true,
++ "peer": true,
"dependencies": {
"once": "^1.4.0"
}
@@@ -478,6 +497,8 @@
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
++ "optional": true,
++ "peer": true,
"engines": {
"node": ">=6"
}
@@@ -554,7 +575,9 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
-- "license": "MIT"
++ "license": "MIT",
++ "optional": true,
++ "peer": true
},
"node_modules/finalhandler": {
"version": "1.3.2",
@@@ -596,7 +619,9 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
-- "license": "MIT"
++ "license": "MIT",
++ "optional": true,
++ "peer": true
},
"node_modules/function-bind": {
"version": "1.1.2",
@@@ -648,7 +673,9 @@
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
-- "license": "MIT"
++ "license": "MIT",
++ "optional": true,
++ "peer": true
},
"node_modules/gopd": {
"version": "1.2.0",
@@@ -736,7 +763,9 @@
"url": "https://feross.org/support"
}
],
-- "license": "BSD-3-Clause"
++ "license": "BSD-3-Clause",
++ "optional": true,
++ "peer": true
},
"node_modules/inherits": {
"version": "2.0.4",
@@@ -748,7 +777,9 @@
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
-- "license": "ISC"
++ "license": "ISC",
++ "optional": true,
++ "peer": true
},
"node_modules/iovalkey": {
"version": "0.4.0",
@@@ -985,6 +1016,8 @@
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
++ "optional": true,
++ "peer": true,
"engines": {
"node": ">=10"
},
@@@ -997,6 +1030,8 @@
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
++ "optional": true,
++ "peer": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@@@ -1005,7 +1040,9 @@
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
-- "license": "MIT"
++ "license": "MIT",
++ "optional": true,
++ "peer": true
},
"node_modules/ms": {
"version": "2.0.0",
@@@ -1017,7 +1054,9 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
-- "license": "MIT"
++ "license": "MIT",
++ "optional": true,
++ "peer": true
},
"node_modules/negotiator": {
"version": "0.6.3",
@@@ -1033,6 +1072,8 @@
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
"integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
"license": "MIT",
++ "optional": true,
++ "peer": true,
"dependencies": {
"semver": "^7.3.5"
},
@@@ -1069,6 +1110,8 @@
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
++ "optional": true,
++ "peer": true,
"dependencies": {
"wrappy": "1"
}
@@@ -1276,6 +1319,8 @@
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
++ "optional": true,
++ "peer": true,
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
@@@ -1319,6 +1364,8 @@
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
++ "optional": true,
++ "peer": true,
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
@@@ -1369,6 +1416,8 @@
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
++ "optional": true,
++ "peer": true,
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
@@@ -1384,6 +1433,8 @@
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
++ "optional": true,
++ "peer": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
@@@ -1602,7 +1653,9 @@
"url": "https://feross.org/support"
}
],
-- "license": "MIT"
++ "license": "MIT",
++ "optional": true,
++ "peer": true
},
"node_modules/simple-get": {
"version": "4.0.1",
@@@ -1623,6 +1676,8 @@
}
],
"license": "MIT",
++ "optional": true,
++ "peer": true,
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
@@@ -1658,6 +1713,8 @@
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
++ "optional": true,
++ "peer": true,
"dependencies": {
"safe-buffer": "~5.2.0"
}
@@@ -1667,6 +1724,8 @@
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
++ "optional": true,
++ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@@ -1676,6 +1735,8 @@
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
"license": "MIT",
++ "optional": true,
++ "peer": true,
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
@@@ -1688,6 +1749,8 @@
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
++ "optional": true,
++ "peer": true,
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
@@@ -1713,6 +1776,8 @@
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
++ "optional": true,
++ "peer": true,
"dependencies": {
"safe-buffer": "^5.0.1"
},
@@@ -1746,7 +1811,9 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
-- "license": "MIT"
++ "license": "MIT",
++ "optional": true,
++ "peer": true
},
"node_modules/utils-merge": {
"version": "1.0.1",
@@@ -1770,7 +1837,9 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
-- "license": "ISC"
++ "license": "ISC",
++ "optional": true,
++ "peer": true
},
"node_modules/xtend": {
"version": "4.0.2",
ddiff --git a/package.json b/package.json
iindex 1a29e30..a517fef 100644
---- a/package.json
++++ b/package.json
@@@ -9,9 +9,7 @@
"dev": "NODE_ENV=development node server/index.js",
"test": "node test/run.js",
"test:pg": "TIPS_TEST_STORE=pg node test/run.js",
-- "subscribe": "node scripts/subscribe.js",
-- "import-live": "node scripts/import-live.js",
-- "migrate-to-postgres": "node scripts/migrate-to-postgres.js"
++ "subscribe": "node scripts/subscribe.js"
},
"keywords": [
"openvibe",
@@@ -23,7 +21,6 @@
"license": "AGPL-3.0-only",
"repository": "github:OpenVibers/OpenVibe.Tips",
"dependencies": {
-- "better-sqlite3": "^11.10.0",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
ddiff --git a/scripts/import-live.js b/scripts/import-live.js
ddeleted file mode 100644
iindex 62d96a3..0000000
---- a/scripts/import-live.js
++++ /dev/null
@@@ -1,67 +0,0 @@
--#!/usr/bin/env node
--'use strict';
--/**
-- * Import OpenVibe.Live's tip history into Tips, linked to OpenVibe.Billing's imported journal.
-- *
-- * node scripts/import-live.js --live-db <live snapshot> [--billing-db <billing snapshot>] [--dry-run] [--json]
-- *
-- * Both databases must be COPIES (e.g. `sqlite3 live.db ".backup live-snapshot.db"`); they are opened
-- * read-only. Run it AFTER Billing's own import (scripts/import-live.js in OpenVibe.Billing) so every
-- * Live donation links to its Billing transaction. Live user ids are resolved to Network subjects
-- * through resolve-batch with Tips' client credentials (capability identity.subject.resolve).
-- *
-- * With --billing-db the report ends with the reconciliation: per creator, Tips' settled total must
-- * equal Billing's; the exit code is 1 when it does not. Safe to re-run.
-- *
-- * Tips' side is its PostgreSQL database (DATABASE_URL; migrations applied with DATABASE_DIRECT_URL
-- * first). The two snapshots are other services' SQLite files, which is why this operator tool, and
-- * scripts/migrate-to-postgres.js, are the only code here that loads better-sqlite3.
-- */
--const path = require('path');
--const Database = require('better-sqlite3');
--const { loadConfig } = require('../server/config');
--const { openDb, migrate } = require('../server/db');
--const { createIdentity } = require('../server/network');
--const { createTipsOutbox } = require('../server/events/outbox');
--const { createDomain } = require('../server/domain');
--const { importLive, billingSnapshotSource } = require('../server/importer/live');
--
--const args = process.argv.slice(2);
--const opt = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };
--const flag = (name) => args.includes(`--${name}`);
--
--async function main() {
-- const livePath = opt('live-db');
-- if (!livePath) { console.error('usage: import-live.js --live-db <snapshot> [--billing-db <snapshot>] [--dry-run] [--json]'); process.exit(2); }
-- const config = loadConfig();
-- for (const p of [livePath, opt('billing-db')].filter(Boolean)) if (path.resolve(p) === path.resolve(config.sqlitePath)) throw new Error(`${p} is Tips' own (pre-PostgreSQL) database`);
-- const live = new Database(livePath, { readonly: true, fileMustExist: true });
-- const bdb = opt('billing-db') ? new Database(opt('billing-db'), { readonly: true, fileMustExist: true }) : null;
-- const db = openDb(config);
-- await migrate(config, { serving: db });
-- const outbox = createTipsOutbox({ db, config });
-- const domain = createDomain({ db, config, outbox, billing: null, adapters: {}, log: console });
-- const identity = createIdentity(config);
-- const report = await importLive(domain, { live, billingSource: bdb ? billingSnapshotSource(bdb) : null, resolveLiveUsers: identity.resolveLiveUsers, dryRun: flag('dry-run') });
-- if (flag('json')) console.log(JSON.stringify(report, null, 2));
-- else {
-- const c = report.counts;
-- console.log(`import ${report.run_id}${report.dry_run ? ' (DRY RUN — nothing kept)' : ''}`);
-- console.log(` live snapshot: ${JSON.stringify(c.live)}; identities ${JSON.stringify(c.identities)}; test before ${report.test_before || '(none)'}`);
-- console.log(` imported ${c.imported} tips (${c.linked_to_billing} linked to Billing, ${c.unlinked} not), ${c.refunds_applied} refunds applied, ${c.external} external PowerChat tips, ${c.goals} goals`);
-- console.log(` held ${c.held}, excluded ${c.excluded}, unchanged ${c.unchanged}, released from hold ${c.released}`);
-- for (const h of report.holds) console.log(` held: ${JSON.stringify(h)}`);
-- const r = report.reconciliation;
-- if (!r) console.log(' reconciliation: skipped (no --billing-db)');
-- else {
-- console.log(` reconciliation: ${r.ok ? 'OK' : 'MISMATCH'} over ${r.creators} creators`);
-- for (const m of r.mismatches) console.log(` ${m.creator}: tips ${m.tips} vs billing ${m.billing} (difference ${m.difference})`);
-- }
-- }
-- live.close();
-- if (bdb) bdb.close();
-- await db.close();
-- if (report.reconciliation && !report.reconciliation.ok) process.exit(1);
--}
--
--main().catch((e) => { console.error(e.message); process.exit(1); });
ddiff --git a/scripts/migrate-to-postgres.js b/scripts/migrate-to-postgres.js
ddeleted file mode 100644
iindex 0a15448..0000000
---- a/scripts/migrate-to-postgres.js
++++ /dev/null
@@@ -1,142 +0,0 @@
--#!/usr/bin/env node
--'use strict';
--/**
-- * The one-time move of Tips' SQLite database into its PostgreSQL schema (ADR-035; the procedure is
-- * openvibe-sdk docs/migrating-to-postgresql.md, section 6).
-- *
-- * node scripts/migrate-to-postgres.js [--sqlite <file>] [--pglite] [--json]
-- *
-- * 1. applies migrations/ as the owner (DATABASE_DIRECT_URL, a direct connection);
-- * 2. copies every table with openvibe-sdk's importSqlite (batched, parents first, identities kept and
-- * their sequences advanced) into emptied tables (truncate: a rehearsal can be repeated);
-- * 3. verifies each table's row count and a checksum of every row on both sides, prints the report,
-- * and exits 1 unless report.ok.
-- *
-- * The SQLite file (TIPS_DB_PATH, config.sqlitePath; --sqlite for a copy) is opened read-only: nothing in
-- * it changes. --pglite imports into an in-memory PostgreSQL instead (a rehearsal with nothing to set up;
-- * the report is all it leaves). --json prints the whole report.
-- *
-- * In production it runs once, from the new release's directory, as the service user with the service's
-- * environment, while the service is stopped (the write freeze), before the PostgreSQL release starts.
-- *
-- * What does not map one to one:
-- * settings not carried: the SQLite bookkeeping row (schema_version 3); ov_migrations
-- * records the schema now (migrations/0001_initial.sql)
-- * event_outbox → tips_event_outbox (the SDK outbox, outboxSchema('tips_event_outbox'))
-- * idempotency_receipts → tips_event_inbox (the SDK inbox, inboxSchema('tips_event_inbox'))
-- * JSON text 'null' → SQL NULL in the reference columns (interaction_effects.result,
-- * paid_messages.chat_ref, paid_media_requests.queue_ref): the SQLite code
-- * wrote JSON.stringify(null) when an adapter returned no reference, the
-- * PostgreSQL code writes NULL, and both mean "no reference"
-- * NUL, unpaired surrogate PostgreSQL stores no NUL character in text or jsonb and no unpaired surrogate
-- * in jsonb (SQLite took both; the code strips them now): a NUL is dropped and an
-- * unpaired surrogate becomes U+FFFD, in text and inside JSON values alike. The
-- * report lists every column where that happened and how many values changed
-- * No column is dropped.
-- */
--const path = require('path');
--const { createDb, importSqlite } = require('openvibe-sdk/db');
--const { loadConfig } = require('../server/config');
--const { migrate, MIGRATIONS } = require('../server/db');
--
--const args = process.argv.slice(2);
--const opt = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };
--const flag = (name) => args.includes(`--${name}`);
--
--/** A row map turning the JSON text 'null' into SQL NULL for these columns. */
--const jsonNullToSql = (...cols) => (row) => {
-- for (const c of cols) if (row[c] === 'null') row[c] = null;
-- return row;
--};
--
--const TABLES = {
-- tips_event_outbox: { from: 'event_outbox' },
-- tips_event_inbox: { from: 'idempotency_receipts' },
-- interaction_effects: { map: jsonNullToSql('result') },
-- paid_messages: { map: jsonNullToSql('chat_ref') },
-- paid_media_requests: { map: jsonNullToSql('queue_ref') },
--};
--
--const cleanText = (v) => v.toWellFormed().replace(/\u0000/g, '');
--function cleanJson(v) {
-- if (typeof v === 'string') return cleanText(v);
-- if (Array.isArray(v)) return v.map(cleanJson);
-- if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [cleanText(k), cleanJson(x)]));
-- return v;
--}
--
--/**
-- * importSqlite's `tables` for this schema: TABLES, and for every table a map that makes its text and
-- * JSON values storable (see above). `cleaned` counts the distinct values changed per column (the
-- * verification sees the same mapped values on both sides, so this is the record of what changed).
-- */
--async function importOptions(db) {
-- const cols = await db.many(`SELECT table_name, column_name, udt_name FROM information_schema.columns
-- WHERE table_schema = current_schema() AND table_name <> 'ov_migrations' AND udt_name IN ('text', 'varchar', 'json', 'jsonb')`);
-- const byTable = new Map();
-- for (const c of cols) { if (!byTable.has(c.table_name)) byTable.set(c.table_name, []); byTable.get(c.table_name).push(c); }
-- const cleaned = new Map(); // "table.column" → Set of the original values changed
-- const note = (t, c, v) => { const k = `${t}.${c}`; if (!cleaned.has(k)) cleaned.set(k, new Set()); cleaned.get(k).add(v); };
-- const tables = {};
-- for (const [table, list] of byTable) {
-- const base = TABLES[table] || {};
-- tables[table] = {
-- ...base,
-- map(row) {
-- const r = base.map ? base.map(row) : row;
-- if (!r) return r;
-- for (const { column_name: c, udt_name: udt } of list) {
-- const v = r[c];
-- if (typeof v !== 'string') continue;
-- if (udt === 'json' || udt === 'jsonb') {
-- let parsed;
-- try { parsed = JSON.parse(v); } catch { continue; } // left for the importer to report
-- const fixed = JSON.stringify(cleanJson(parsed));
-- if (fixed !== JSON.stringify(parsed)) { note(table, c, v); r[c] = fixed; }
-- } else {
-- const fixed = cleanText(v);
-- if (fixed !== v) { note(table, c, v); r[c] = fixed; }
-- }
-- }
-- return r;
-- },
-- };
-- }
-- return { tables, cleaned: () => [...cleaned].map(([column, values]) => ({ column, values: values.size })) };
--}
--
--async function main() {
-- const config = loadConfig();
-- const sqlite = path.resolve(opt('sqlite') || config.sqlitePath);
-- const quiet = { log() {}, warn: console.warn, error: console.error };
-- let owner;
-- if (flag('pglite')) {
-- owner = createDb({ pglite: true, service: 'tips-import' });
-- await owner.migrate({ dir: MIGRATIONS, log: quiet });
-- } else {
-- await migrate(config, { log: quiet });
-- owner = createDb({ url: config.db.directUrl, service: 'tips-import', max: 2 });
-- }
-- try {
-- const t0 = Date.now();
-- const options = await importOptions(owner);
-- const report = await importSqlite({ sqlite, db: owner, truncate: true, tables: options.tables, log: quiet });
-- const cleaned = options.cleaned();
-- if (flag('json')) console.log(JSON.stringify({ sqlite, into: owner.store, ...report, cleaned }, null, 2));
-- else {
-- console.log(`import ${sqlite} → ${owner.store} (${Date.now() - t0} ms): ${report.ok ? 'OK' : 'PROBLEMS'}`);
-- for (const t of report.tables) console.log(` ${t.table.padEnd(24)} ${String(t.rows).padStart(8)} rows ${t.checksum || '-'}${t.source !== t.table ? ` (from ${t.source})` : ''}`);
-- for (const c of cleaned) console.log(` cleaned: ${c.column}: ${c.values} value(s) with a NUL or an unpaired surrogate`);
-- for (const p of report.problems) console.log(` problem: ${p.table}: ${p.problem}`);
-- }
-- return report.ok ? 0 : 1;
-- } finally {
-- await owner.close();
-- }
--}
--
--if (require.main === module) {
-- main().then((code) => process.exit(code), (e) => { console.error(`migrate-to-postgres failed: ${e.message}`); process.exit(1); });
--}
--
--module.exports = { TABLES, importOptions };
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 9812354..6e713a4 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -403,9 +403,9 @@ function v1Router({ domain, apiAuth, limits }) {
/** Async-safe handler: TipsError → problem+json; anything else → 500. */
function wrap(fn) {
-- return (req, res, next) => {
++ return async (req, res, next) => {
try {
-- const p = fn(req, res, next);
++ const p = await fn(req, res, next);
if (p && typeof p.catch === 'function') p.catch((e) => sendError(req, res, e));
} catch (e) { sendError(req, res, e); }
};
ddiff --git a/server/billing-client.js b/server/billing-client.js
iindex 3c4717a..d875b9f 100644
---- a/server/billing-client.js
++++ b/server/billing-client.js
@@@ -58,7 +58,7 @@ function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClient
} catch (e) {
throw new BillingCallError(`Billing unreachable: ${e.message}`);
}
-- if (res.status === 401 && !retried) { tokenClientFor().invalidate(); return call(method, path, { body, key, traceparent, retried: true }); }
++ if (res.status === 401 && !retried) { tokenClientFor().invalidate(); return await call(method, path, { body, key, traceparent, retried: true }); }
const data = await res.json().catch(() => null);
if (!res.ok) {
const code = (data && (data.code || data.error)) || `http_${res.status}`;
@@@ -69,17 +69,17 @@ function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClient
return {
/** { intent, checkout_url } — kind purchase: the supporter buys `bits` of credit. */
-- createIntent: ({ provider, subject, bits, successUrl, cancelUrl, key, traceparent }) => call('POST', '/api/v1/intents', {
++ createIntent: async ({ provider, subject, bits, successUrl, cancelUrl, key, traceparent }) => await call('POST', '/api/v1/intents', {
body: { provider, kind: 'purchase', subject: { type: 'user', id: subject }, bits, success_url: successUrl, cancel_url: cancelUrl },
key, traceparent,
}),
/** { transaction } — credit of `from` → payable of `to`, tagged with the interaction. */
-- createTransfer: ({ from, to, amount, kind, target, message, key, traceparent }) => call('POST', '/api/v1/transfers', {
++ createTransfer: async ({ from, to, amount, kind, target, message, key, traceparent }) => await call('POST', '/api/v1/transfers', {
body: { from: { type: 'user', id: from }, to: { type: 'user', id: to }, amount, kind, target, message: message || undefined },
key, traceparent,
}),
-- refundTransfer: ({ txnId, amount, reason, key }) => call('POST', `/api/v1/transfers/${encodeURIComponent(txnId)}/refund`, { body: { amount, reason }, key }),
-- rates: () => call('GET', '/api/v1/rates'),
++ refundTransfer: async ({ txnId, amount, reason, key }) => await call('POST', `/api/v1/transfers/${encodeURIComponent(txnId)}/refund`, { body: { amount, reason }, key }),
++ rates: async () => await call('GET', '/api/v1/rates'),
};
}
ddiff --git a/server/config.js b/server/config.js
iindex 7058f64..1c8b561 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -32,7 +32,7 @@ function loadConfig(env = process.env) {
// PostgreSQL (ADR-035; OpenVibe.Host's data role writes all four into /etc/openvibe/tips.env):
// DATABASE_URL is the pooled runtime role through PgBouncer (transaction mode), DATABASE_DIRECT_URL
-- // the owner role on a direct connection, for migrations at boot and the one-time import.
++ // the owner role on a direct connection, for migrations at boot.
db: {
url: env.DATABASE_URL || '',
directUrl: env.DATABASE_DIRECT_URL || '',
@@@ -43,10 +43,6 @@ function loadConfig(env = process.env) {
url: env.VALKEY_URL || '',
prefix: env.VALKEY_PREFIX || 'ov:tips:',
},
-- // The SQLite file of the release before PostgreSQL: read (never written) only by
-- // scripts/migrate-to-postgres.js. The systemd unit sets /var/lib/openvibe-tips/tips.db.
-- sqlitePath: env.TIPS_DB_PATH || './data/tips.db',
--
// Identity: service tokens and user tokens are RS256 JWTs signed by OpenVibe.Network.
network: {
url: networkUrl,
@@@ -56,8 +52,7 @@ function loadConfig(env = process.env) {
},
audience: env.TIPS_AUDIENCE || 'openvibe.tips',
// Tips' own client credentials (client `tips` in the Network): service tokens for Billing,
-- // Events, Live (chat delivery) and Network (identity resolve for the importer); and the
-- // OAuth code flow for browser sign-in (redirect ${baseUrl}/auth/callback).
++ // Events and Live (chat delivery); and the OAuth code flow for browser sign-in.
oauth: {
clientId: env.OV_OAUTH_CLIENT_ID || 'tips',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
ddiff --git a/server/db.js b/server/db.js
iindex 4264da9..a721ea4 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -22,7 +22,6 @@
* overlay_configs alert / goal overlay settings
* overlay_deliveries what overlays were sent (monotonic seq per creator, replayable)
* interaction_effects the retryable delivery work of an interaction (chat line, TTS, media, overlay)
-- * migration_maps legacy (Live) row → Tips entity, with held/excluded reasons
*
* Supporting: overlay_tokens (hashed, scoped, revocable), tip_moderators / _invites / _moderation_log,
* api_idempotency, import_runs, tips_event_outbox + tips_event_inbox (openvibe-sdk outbox and inbox).
ddiff --git a/server/delivery/live-chat.js b/server/delivery/live-chat.js
iindex dc0d642..9c7506d 100644
---- a/server/delivery/live-chat.js
++++ b/server/delivery/live-chat.js
@@@ -43,7 +43,7 @@ function createLiveChatAdapter(config, { fetchImpl = globalThis.fetch, tokenClie
} catch (e) {
throw new Error(`Live unreachable: ${e.message}`);
}
-- if (res.status === 401 && !retried) { client().invalidate(); return deliver(job, true); }
++ if (res.status === 401 && !retried) { client().invalidate(); return await deliver(job, true); }
const body = await res.json().catch(() => null);
if (!res.ok) {
const e = new Error(`Live ${res.status}: ${(body && (body.detail || body.error || body.code)) || 'refused'}`);
ddiff --git a/server/domain/goals.js b/server/domain/goals.js
iindex 7c6a332..86a84d2 100644
---- a/server/domain/goals.js
++++ b/server/domain/goals.js
@@@ -141,7 +141,7 @@ function createGoals(ctx) {
const id = prefixedId('tgoal', ctx.now());
await t.exec(sql`INSERT INTO tip_goals (id, creator_subject, title, description, target_amount, image_url, status, sort_order, created_at, updated_at)
VALUES (${id}, ${creator}, ${f.title}, ${f.description || null}, ${f.target_amount}, ${f.image_url || null}, 'active', ${f.sort_order || 0}, ${at}, ${at})`);
-- return changed(t, id, 'created');
++ return await changed(t, id, 'created');
});
}
@@@ -150,23 +150,23 @@ function createGoals(ctx) {
if (goal.status !== 'active') fail(409, 'tips.goal_closed', 'a closed goal cannot be edited');
if (input.revision != null && Number(input.revision) !== goal.revision) fail(409, 'tips.revision_conflict', `the goal is at revision ${goal.revision}`);
const f = fields(input, true);
-- if (!Object.keys(f).length) return present(db, goal);
++ if (!Object.keys(f).length) return await present(db, goal);
return ctx.tx(async (t) => {
const cur = await t.one(sql`SELECT * FROM tip_goals WHERE id = ${goal.id} FOR UPDATE`);
if (cur.status !== 'active') fail(409, 'tips.goal_closed', 'a closed goal cannot be edited');
if (input.revision != null && Number(input.revision) !== cur.revision) fail(409, 'tips.revision_conflict', `the goal is at revision ${cur.revision}`);
await t.exec(sql`UPDATE tip_goals SET ${sql.set(f)} WHERE id = ${goal.id}`);
await markReached(t, goal.id);
-- return changed(t, goal.id, 'updated');
++ return await changed(t, goal.id, 'updated');
});
}
async function close(goal) {
-- if (goal.status === 'closed') return present(db, goal);
++ if (goal.status === 'closed') return await present(db, goal);
return ctx.tx(async (t) => {
const n = await t.exec(sql`UPDATE tip_goals SET status = 'closed', closed_at = ${iso(ctx.now())} WHERE id = ${goal.id} AND status = 'active'`);
-- if (!n) return present(t, await get(t, goal.id));
-- return changed(t, goal.id, 'closed');
++ if (!n) return await present(t, await get(t, goal.id));
++ return await changed(t, goal.id, 'closed');
});
}
@@@ -208,7 +208,7 @@ function createGoals(ctx) {
// The overlay's goal update names the supporter as the alert does; not when the amount is hidden
// (the bar's jump would otherwise pin the amount on them), nor while moderation holds it.
const by = interaction.hide_amount || (interaction.moderation && interaction.moderation !== 'visible') ? null : publicView(interaction, { filter }).supporter_name;
-- return changed(t, g.id, reachedNow ? 'reached' : 'contribution', { interactionId: interaction.id, by });
++ return await changed(t, g.id, reachedNow ? 'reached' : 'contribution', { interactionId: interaction.id, by });
}
/** Inside the reversal transaction: take `bits` back off every goal the interaction counted toward. */
ddiff --git a/server/domain/interactions.js b/server/domain/interactions.js
iindex 0f40022..68e95d5 100644
---- a/server/domain/interactions.js
++++ b/server/domain/interactions.js
@@@ -65,7 +65,7 @@ const TRANSFER_BATCH = 10;
const TRANSFER_LEASE_MS = 5 * 60_000;
const COLUMNS = ['id', 'creator_subject', 'supporter_subject', 'supporter_name', 'kind', 'amount', 'amount_cents', 'message', 'request',
'funding', 'settlement', 'payment_state', 'delivery_state', 'test', 'billing_txn_id', 'billing_intent_id', 'funding_txn_id', 'provider', 'provider_ref',
-- 'legacy_source', 'idempotency_key', 'origin', 'target', 'anonymous', 'hide_amount', 'private_message', 'created_at', 'updated_at'];
++ 'idempotency_key', 'origin', 'target', 'anonymous', 'hide_amount', 'private_message', 'created_at', 'updated_at'];
function createInteractions(ctx) {
const { db, config, MONEY } = ctx;
@@@ -148,7 +148,7 @@ function createInteractions(ctx) {
const at = iso(ctx.now());
const r = {
supporter_subject: null, supporter_name: null, amount_cents: null, message: null, request: {}, test: false, billing_txn_id: null,
-- billing_intent_id: null, funding_txn_id: null, provider: null, provider_ref: null, legacy_source: null, idempotency_key: null,
++ billing_intent_id: null, funding_txn_id: null, provider: null, provider_ref: null, idempotency_key: null,
target: null, origin: 'tips', anonymous: false, hide_amount: false, private_message: false, created_at: at, ...row,
};
const values = {
@@@ -208,11 +208,11 @@ function createInteractions(ctx) {
key: `tips:transfer:${i.id}`, traceparent,
});
const txn = out.transaction;
-- await ctx.tx(async (t) => settleByTransaction(t, await lock(t, i.id), { id: txn.id, test: !!txn.test }), MONEY);
++ await ctx.tx(async (t) => await settleByTransaction(t, await lock(t, i.id), { id: txn.id, test: !!txn.test }), MONEY);
return { settled: true };
} catch (e) {
if (e instanceof BillingCallError && !e.retryable) {
-- await ctx.tx(async (t) => failPayment(t, await lock(t, i.id), e.code || 'billing.refused', e.message), MONEY);
++ await ctx.tx(async (t) => await failPayment(t, await lock(t, i.id), e.code || 'billing.refused', e.message), MONEY);
return { settled: false, refused: e.code, detail: e.body && e.body.detail };
}
// Due again after the attempt's backoff (TRANSFER_BACKOFF[attempts - 1], the last one repeating).
@@@ -237,7 +237,7 @@ function createInteractions(ctx) {
});
} catch (e) {
if (e instanceof BillingCallError && !e.retryable) {
-- await ctx.tx(async (t) => failPayment(t, await lock(t, i.id), e.code || 'billing.refused', e.message), MONEY);
++ await ctx.tx(async (t) => await failPayment(t, await lock(t, i.id), e.code || 'billing.refused', e.message), MONEY);
fail(e.status === 409 ? 409 : 422, e.code || 'billing.refused', (e.body && e.body.detail) || 'Billing refused the checkout');
}
fail(502, 'tips.billing_unavailable', 'checkout is unavailable right now; nothing was charged — try again shortly');
@@@ -280,7 +280,7 @@ function createInteractions(ctx) {
if (other && other.id !== i.id) return other;
const row = await t.one(sql`UPDATE tip_interactions SET billing_txn_id = ${txn.id}, test = test OR ${!!txn.test}, transfer_due = false, failure = NULL
WHERE id = ${i.id} RETURNING *`);
-- return settle(t, row);
++ return await settle(t, row);
}
/** Inside a MONEY transaction, `i` locked (or created by it): pending/failed → settled, and the whole effect path. */
@@@ -465,19 +465,15 @@ function createInteractions(ctx) {
}
/**
-- * The goal a PowerChat tip asked for: its app_purpose/app_ref carries `goal:<id>` — a Tips goal id,
-- * or a Live donation_goals id (links minted by Live), which the Live import mapped to a Tips goal.
-- * goals.contribute() still checks it is the creator's and active, else falls back to Live's rule.
++ * The goal a PowerChat tip asked for: its app_purpose/app_ref carries `goal:<id>` — a Tips goal id.
++ * goals.contribute() still checks it is the creator's and active, else falls back to the
++ * sole-active-goal rule.
*/
-- async function goalFromPurpose(q, ...refs) {
++ function goalFromPurpose(...refs) {
for (const v of refs) {
const m = storable(v || '').match(/(?:^|[:_-])goal[:_-]?([A-Za-z0-9_]+)/i);
if (!m) continue;
-- if (/^\d+$/.test(m[1])) {
-- const map = await q.maybe(sql`SELECT target_id FROM migration_maps WHERE source = 'live' AND source_table = 'donation_goals' AND source_id = ${m[1]} AND status = 'imported'`);
-- if (map && map.target_id) return map.target_id;
-- continue;
-- }
++ if (/^\d+$/.test(m[1])) continue; // a numeric id names no Tips goal (goal ids carry a prefix)
return m[1];
}
return null;
@@@ -502,7 +498,7 @@ function createInteractions(ctx) {
const bits = Number(p.value_bits);
const amount = Math.min(Number.isSafeInteger(bits) && bits > 0 ? bits : cents, config.limits.maxBits);
const message = p.message ? storable(String(p.message).replace(/\r\n?/g, '\n').trim().slice(0, config.limits.messageChars)) || null : null;
-- const goalId = await goalFromPurpose(t, p.app_purpose, p.app_ref);
++ const goalId = goalFromPurpose(p.app_purpose, p.app_ref);
const i = await insert(t, {
id: prefixedId('tint', ctx.now()), creator_subject: creator, supporter_subject: null,
supporter_name: p.anonymous ? 'Anonymous' : (displayName(p.donor_name) || 'Someone'), anonymous: !!p.anonymous, kind: 'tip', amount, amount_cents: cents,
@@@ -596,7 +592,6 @@ function createInteractions(ctx) {
out.effects = effects;
out.provider = i.provider || null;
out.provider_ref = i.provider_ref || null;
-- out.legacy_source = i.legacy_source || null;
}
return out;
}
ddiff --git a/server/domain/moderation.js b/server/domain/moderation.js
iindex 96e50d3..f52fa17 100644
---- a/server/domain/moderation.js
++++ b/server/domain/moderation.js
@@@ -149,8 +149,8 @@ function createModeration(ctx) {
? t.one(sql`UPDATE tip_interactions SET filtered = true, moderation = 'held', moderated_at = ${at}, moderated_by = 'filter', updated_at = ${at} WHERE id = ${i.id} RETURNING *`)
: t.one(sql`UPDATE tip_interactions SET filtered = true, updated_at = ${at} WHERE id = ${i.id} RETURNING *`);
}
-- function recordScreened(t, i, verdict) {
-- return record(t, i, verdict.hold ? 'held' : 'filtered', { role: 'filter', reason: 'blocked_words' });
++ async function recordScreened(t, i, verdict) {
++ return await record(t, i, verdict.hold ? 'held' : 'filtered', { role: 'filter', reason: 'blocked_words' });
}
/** Who is acting: { role, actor } for the creator, a moderator or a granted service; else null. */
ddiff --git a/server/domain/overlay-hub.js b/server/domain/overlay-hub.js
iindex 79abfb8..8e37094 100644
---- a/server/domain/overlay-hub.js
++++ b/server/domain/overlay-hub.js
@@@ -109,7 +109,7 @@ function createOverlayHub({ valkey = null, pushNew, heartbeatMs, now = () => Dat
/** Run fn for a client after whatever it is already doing (its pushes never interleave). */
function queue(client, fn) {
-- client.chain = client.chain.then(() => (client.closed ? null : fn())).catch((e) => log.warn('[Tips] overlay push:', e.message));
++ client.chain = client.chain.then(async () => (client.closed ? null : await fn())).catch((e) => log.warn('[Tips] overlay push:', e.message));
return client.chain;
}
/** New deliveries for this client: coalesced, so a burst of notifications reads once. */
@@@ -214,7 +214,7 @@ function createOverlayHub({ valkey = null, pushNew, heartbeatMs, now = () => Dat
config: (creator, config) => publish(creator, { t: 'config', config }),
revoke: (creator, tokenId) => publish(creator, { t: 'revoke', token_id: tokenId }),
/** Open streams of this creator's overlays, in every process. */
-- connected: (creator) => slots('count', creator),
++ connected: async (creator) => await slots('count', creator),
closeAll() { for (const entry of [...creators.values()]) for (const c of [...entry.clients]) detach(c); },
async close() { this.closeAll(); await ps.close(); },
};
ddiff --git a/server/domain/overlays.js b/server/domain/overlays.js
iindex 4a7b892..5a9c1f5 100644
---- a/server/domain/overlays.js
++++ b/server/domain/overlays.js
@@@ -142,7 +142,7 @@ function createOverlays(ctx) {
return ctx.tx(async (t) => {
const c = await t.one(sql`SELECT * FROM overlay_configs WHERE id = ${config.id} FOR UPDATE`);
if (input.revision != null && Number(input.revision) !== c.revision) fail(409, 'tips.revision_conflict', `the config is at revision ${c.revision}`);
-- return changeConfig(t, c, input);
++ return await changeConfig(t, c, input);
});
}
async function changeConfig(t, c, input) {
@@@ -189,9 +189,9 @@ function createOverlays(ctx) {
}
/** Inside the settlement transaction (or a simulation). `filter`: the creator's word filter. */
-- function addAlert(t, interaction, filter) {
++ async function addAlert(t, interaction, filter) {
const payload = publicView(interaction, { at: iso(ctx.now()), filter });
-- return insertDelivery(t, interaction.creator_subject, 'alert', { interactionId: interaction.id, payload, test: interaction.test, dedupe: `alert:${interaction.id}` });
++ return await insertDelivery(t, interaction.creator_subject, 'alert', { interactionId: interaction.id, payload, test: interaction.test, dedupe: `alert:${interaction.id}` });
}
/**
@@@ -215,7 +215,7 @@ function createOverlays(ctx) {
await t.exec(sql`UPDATE overlay_deliveries d SET payload = v.payload
FROM jsonb_to_recordset(${sql.json(updates)}) AS v(seq bigint, payload jsonb) WHERE d.seq = v.seq`);
}
-- const refreshInteraction = (t, interaction, filter) => refreshInteractions(t, [interaction], new Map([[interaction.creator_subject, filter]]));
++ const refreshInteraction = async (t, interaction, filter) => await refreshInteractions(t, [interaction], new Map([[interaction.creator_subject, filter]]));
/** Inside a transaction: a moderator hid the interaction. Its alert is never sent or replayed again. */
async function retract(t, interaction, filter) {
@@@ -230,8 +230,8 @@ function createOverlays(ctx) {
await refreshInteraction(t, interaction, filter);
}
-- function addGoalDelivery(t, creator, goalView, { reason, interactionId, by, dedupe, test = false }) {
-- return insertDelivery(t, creator, 'goal', { interactionId, goalId: goalView.id, payload: { goal: goalView, reason, by: by || null, test }, test, dedupe });
++ async function addGoalDelivery(t, creator, goalView, { reason, interactionId, by, dedupe, test = false }) {
++ return await insertDelivery(t, creator, 'goal', { interactionId, goalId: goalView.id, payload: { goal: goalView, reason, by: by || null, test }, test, dedupe });
}
/**
ddiff --git a/server/domain/profiles.js b/server/domain/profiles.js
iindex fbc0caa..0959513 100644
---- a/server/domain/profiles.js
++++ b/server/domain/profiles.js
@@@ -65,7 +65,7 @@ function createProfiles(ctx) {
const bySubject = async (q, s) => parse(await q.maybe(sql`SELECT * FROM creator_tip_profiles WHERE creator_subject = ${String(s || '')}`));
const byHandle = async (q, h) => { const n = normalizeHandle(h); return n ? parse(await q.maybe(sql`SELECT * FROM creator_tip_profiles WHERE handle = ${n}`)) : null; };
/** usr_… or a handle (with or without @). */
-- const resolve = (q, ref) => (/^usr_/.test(String(ref || '')) ? bySubject(q, String(ref)) : byHandle(q, ref));
++ const resolve = async (q, ref) => (/^usr_/.test(String(ref || '')) ? await bySubject(q, String(ref)) : await byHandle(q, ref));
/** A handle not held by another creator: the username, else username-<suffix of the subject>. */
async function freeHandle(q, subject, username) {
@@@ -81,7 +81,7 @@ function createProfiles(ctx) {
*/
async function ensure(subject, claims = {}, attempt = 0) {
try { return await ensureOnce(subject, claims); } catch (e) {
-- if (e.code === '23505' && e.constraint === HANDLE_TAKEN && attempt < 2) return ensure(subject, claims, attempt + 1);
++ if (e.code === '23505' && e.constraint === HANDLE_TAKEN && attempt < 2) return await ensure(subject, claims, attempt + 1);
throw e;
}
}
@@@ -93,7 +93,7 @@ function createProfiles(ctx) {
const row = await db.maybe(sql`INSERT INTO creator_tip_profiles (creator_subject, handle, display_name, avatar_url, created_at, updated_at)
VALUES (${subject}, ${await freeHandle(db, subject, username)}, ${name}, ${safeUrl(avatarUrl)}, ${at}, ${at})
ON CONFLICT (creator_subject) DO NOTHING RETURNING *`);
-- return row ? parse(row) : bySubject(db, subject);
++ return row ? parse(row) : await bySubject(db, subject);
}
const handle = normalizeHandle(username) && normalizeHandle(username) !== existing.handle ? await freeHandle(db, subject, username) : existing.handle;
if (handle !== existing.handle || name !== existing.display_name || (safeUrl(avatarUrl) || null) !== (existing.avatar_url || null)) {
@@@ -119,7 +119,7 @@ function createProfiles(ctx) {
const p = parse(await t.maybe(sql`SELECT * FROM creator_tip_profiles WHERE creator_subject = ${String(subject || '')} FOR UPDATE`));
if (!p) fail(404, 'tips.profile_not_found', 'no tip profile for this creator');
if (expectedRevision != null && Number(expectedRevision) !== p.revision) fail(409, 'tips.revision_conflict', `the profile is at revision ${p.revision}`);
-- return change(t, p, patch);
++ return await change(t, p, patch);
});
}
ddiff --git a/server/importer/live.js b/server/importer/live.js
ddeleted file mode 100644
iindex 5b2d81b..0000000
---- a/server/importer/live.js
++++ /dev/null
@@@ -1,313 +0,0 @@
--'use strict';
--
--/**
-- * Import tip history from a SNAPSHOT COPY of OpenVibe.Live's database (opened read-only), linked to
-- * OpenVibe.Billing's imported journal, with a reconciliation report.
-- *
-- * 1. identities every Live user id in a tip row → Network subject (resolve-batch). A tip whose
-- * CREATOR has no subject is held (migration_maps status 'held'), never dropped; a
-- * later run imports it once the Network knows the creator. An unmapped supporter is
-- * imported with no subject and their Live username as the display name.
-- * 2. donations transactions (type 'donation') → tip_interactions, settlement 'imported':
-- * completed → payment settled; anything else → excluded with the reason (no balance
-- * moved on Live). Rows before site_settings.stats_vibes_reset_at are imported flagged
-- * test, exactly as Billing flags them. Media-request charges ("Media request: …")
-- * become kind media_request. Each row is linked to Billing's transaction for it
-- * (Billing's importer key `import:live:txn:<live id>`).
-- * 3. refunds transactions (type 'refund', streamer → donor) are applied to the latest earlier
-- * donation from that donor to that streamer that still has room (reversed_bits), so a
-- * creator's total nets them out the way Billing's journal does; none left → held.
-- * 4. external chat_messages of kind donation with source powerchat — the only record Live kept of
-- * tips on a streamer's OWN PowerChat (EXTERNAL, no Billing liability). A message that
-- * matches a site-routed "PowerChat tip via site account" donation row (same streamer,
-- * same amount, within 10 minutes) is that row's celebration and is excluded, not
-- * counted twice.
-- * 5. goals donation_goals → tip_goals (Live's current_amount carried over as opening_amount:
-- * Live never recorded which donations counted toward which goal).
-- *
-- * Reconciliation: per creator subject, Tips' settled-via-Billing total (non-test, minus refunds) must
-- * equal Billing's (donations to the creator minus refunds from them, non-test). Any difference is
-- * listed; nothing is absorbed.
-- *
-- * Idempotent: every row is keyed by legacy_source / migration_maps (live, table, id); a re-run changes
-- * nothing, and a later snapshot only adds what is new or releases what was held. --dry-run does it all
-- * inside a transaction that is rolled back.
-- *
-- * The snapshots are other services' SQLite files, read synchronously (better-sqlite3 handles passed in
-- * by scripts/import-live.js); everything written goes to Tips' PostgreSQL in one SERIALIZABLE
-- * transaction (it records settled money and applies refunds), whose report starts over on a retry.
-- */
--const { sql } = require('openvibe-sdk/db');
--const { iso, prefixedId, json, storable } = require('../util');
--
--const liveTs = (s) => {
-- if (!s) return null;
-- const str = String(s);
-- const t = Date.parse(str.includes('T') ? str : `${str.replace(' ', 'T')}Z`);
-- return Number.isFinite(t) ? new Date(t).toISOString() : null;
--};
--// The snapshot (SQLite, read-only) side.
--const tableExists = (snap, name) => !!snap.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
--const columns = (snap, t) => (tableExists(snap, t) ? snap.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name) : []);
--
--class DryRun extends Error {}
--
--/**
-- * Billing's side, from a read-only SNAPSHOT of billing.db (operator tool, like Billing reading Live's
-- * snapshot): the transaction Billing's importer made for each Live row, and per-creator totals.
-- */
--function billingSnapshotSource(bdb) {
-- const byKey = bdb.prepare("SELECT id, test FROM transactions WHERE idempotency_key = ?");
-- return {
-- name: 'billing snapshot',
-- linkLiveTxn(liveId) { const r = byKey.get(`import:live:txn:${liveId}`); return r ? r.id : null; },
-- creatorTotals() {
-- const totals = new Map();
-- const add = (s, n) => { if (s && n) totals.set(s, (totals.get(s) || 0) + n); };
-- const rows = bdb.prepare('SELECT id, type, status, from_subject, to_subject, reverses_txn, metadata FROM transactions WHERE test = 0').all();
-- const donations = new Set(rows.filter((t) => t.type === 'donation').map((t) => t.id));
-- for (const t of rows) {
-- const m = json(t.metadata, {});
-- if (t.type === 'import' && m.live && m.live.status === 'completed' && !m.note) {
-- // Billing's replay of Live's history: donation → the creator's payable; refund → out of it.
-- if (m.live.type === 'donation') add(t.to_subject, Number(m.live.amount) || 0);
-- if (m.live.type === 'refund') add(t.from_subject, -(Number(m.live.amount) || 0));
-- } else if (t.type === 'donation') {
-- add(t.to_subject, Number(m.amount_bits) || 0);
-- } else if (t.type === 'refund' && donations.has(t.reverses_txn) && m.amount_bits != null) {
-- // A credit-funded transfer given back (the creator's payable → the giver's credit).
-- add(t.from_subject, -(Number(m.amount_bits) || 0));
-- }
-- }
-- return totals;
-- },
-- };
--}
--
--async function importLive(domain, { live, billingSource = null, resolveLiveUsers, dryRun = false, log = console }) {
-- const started = domain.now();
-- const runId = prefixedId('timp', started);
-- const at = iso(started);
-- const report = {
-- run_id: runId, dry_run: !!dryRun, started_at: at, source: 'openvibe-live snapshot', billing_source: billingSource ? billingSource.name : null,
-- counts: {}, holds: [], excluded: [], unlinked: [], reconciliation: null,
-- };
-- const fresh = () => ({ imported: 0, held: 0, excluded: 0, refunds_applied: 0, external: 0, goals: 0, unchanged: 0, released: 0, linked_to_billing: 0, unlinked: 0 });
-- Object.assign(report.counts, fresh());
--
-- // ── Read the snapshot ────────────────────────────────────
-- const txns = tableExists(live, 'transactions') ? live.prepare("SELECT * FROM transactions WHERE type IN ('donation', 'refund') ORDER BY id").all() : [];
-- const chatCols = columns(live, 'chat_messages');
-- // Live's chat_messages carries its time in `timestamp` (older snapshots: created_at).
-- const chatTs = chatCols.includes('created_at') ? 'created_at' : (chatCols.includes('timestamp') ? 'timestamp' : 'NULL');
-- const chatDonations = chatCols.includes('metadata')
-- ? live.prepare(`SELECT id, channel_user_id, username, message, metadata, ${chatTs} AS created_at FROM chat_messages WHERE message_type = 'donation' AND metadata LIKE '%powerchat%' ORDER BY id`).all()
-- : [];
-- const goalRows = tableExists(live, 'donation_goals') ? live.prepare('SELECT * FROM donation_goals ORDER BY id').all() : [];
-- const users = new Map((tableExists(live, 'users') ? live.prepare('SELECT id, username, display_name FROM users').all() : []).map((u) => [String(u.id), u]));
-- let resetAt = null;
-- if (tableExists(live, 'site_settings')) {
-- const r = live.prepare("SELECT value FROM site_settings WHERE key = 'stats_vibes_reset_at'").get();
-- if (r && r.value && !Number.isNaN(Date.parse(r.value))) resetAt = new Date(r.value).toISOString();
-- }
-- report.test_before = resetAt;
-- report.counts.live = { tip_transactions: txns.length, powerchat_chat_donations: chatDonations.length, goals: goalRows.length };
--
-- // ── 1. identities ────────────────────────────────────────
-- const ids = new Set();
-- for (const t of txns) { if (t.from_user_id) ids.add(String(t.from_user_id)); if (t.to_user_id) ids.add(String(t.to_user_id)); }
-- for (const c of chatDonations) if (c.channel_user_id) ids.add(String(c.channel_user_id));
-- for (const g of goalRows) if (g.user_id) ids.add(String(g.user_id));
-- const map = ids.size ? await resolveLiveUsers([...ids]) : new Map();
-- const subjectOf = (liveId) => { const v = liveId == null ? null : map.get(String(liveId)); return v ? (typeof v === 'string' ? v : v.subject) : null; };
-- const nameOf = (liveId) => { const u = users.get(String(liveId)); return u && (u.display_name || u.username) ? storable(u.display_name || u.username) : null; };
-- report.counts.identities = { resolved: [...ids].filter((i) => subjectOf(i)).length, unmapped: [...ids].filter((i) => !subjectOf(i)).length };
--
-- let q = null; // the import's transaction
-- const mapRow = (_source, table, id) => q.maybe(sql`SELECT * FROM migration_maps WHERE source = 'live' AND source_table = ${table} AND source_id = ${id}`);
-- const record = (table, id, fields) => {
-- const f = { target_type: null, target_id: null, reason: null, billing: null, ...fields };
-- return q.exec(sql`INSERT INTO migration_maps (source, source_table, source_id, target_type, target_id, status, reason, billing_txn_id, run_id, created_at, updated_at)
-- VALUES ('live', ${table}, ${String(id)}, ${f.target_type}, ${f.target_id}, ${f.status}, ${f.reason}, ${f.billing}, ${runId}, ${at}, ${at})
-- ON CONFLICT (source, source_table, source_id) DO UPDATE SET target_type = excluded.target_type, target_id = excluded.target_id, status = excluded.status,
-- reason = excluded.reason, billing_txn_id = COALESCE(excluded.billing_txn_id, migration_maps.billing_txn_id), run_id = excluded.run_id, updated_at = excluded.updated_at`);
-- };
--
-- const run = async (t0) => {
-- q = t0;
-- // A retried attempt (serialization failure) reports from scratch.
-- Object.assign(report.counts, fresh(), { live: report.counts.live, identities: report.counts.identities });
-- report.holds = []; report.excluded = []; report.unlinked = [];
-- // ── 2. donations ─────────────────────────────────────
-- for (const t of txns.filter((x) => x.type === 'donation')) {
-- const prev = await mapRow('live', 'transactions', String(t.id));
-- if (prev && prev.status !== 'held') {
-- // Re-link to Billing when an earlier run had no Billing transaction for it yet.
-- if (prev.status === 'imported' && !prev.billing_txn_id && billingSource) {
-- const b = billingSource.linkLiveTxn(t.id);
-- if (b) {
-- await q.exec(sql`UPDATE tip_interactions SET billing_txn_id = ${b} WHERE id = ${prev.target_id} AND billing_txn_id IS NULL`);
-- await record('transactions', t.id, { ...pick(prev), billing: b });
-- report.counts.linked_to_billing++;
-- }
-- }
-- report.counts.unchanged++;
-- continue;
-- }
-- const amount = Math.round(Number(t.amount) || 0);
-- const creator = subjectOf(t.to_user_id);
-- if (t.status !== 'completed' || amount <= 0) {
-- const reason = amount <= 0 ? 'non-positive amount' : `status ${t.status}: no balance moved on Live`;
-- await record('transactions', t.id, { status: 'excluded', reason });
-- report.counts.excluded++; report.excluded.push({ live_txn: t.id, reason });
-- continue;
-- }
-- if (!creator) {
-- const reason = `creator (Live user ${t.to_user_id}) has no Network subject yet`;
-- await record('transactions', t.id, { status: 'held', reason });
-- report.counts.held++; report.holds.push({ live_txn: t.id, live_creator: t.to_user_id, amount });
-- continue;
-- }
-- const created = liveTs(t.created_at) || at;
-- const test = !!(resetAt && created < resetAt);
-- const billingTxn = billingSource ? billingSource.linkLiveTxn(t.id) : null;
-- const message = t.message ? storable(String(t.message).slice(0, 500)) : null;
-- const media = /^Media request:/i.test(message || '');
-- const site = /^PowerChat tip via site account/i.test(message || '');
-- const i = await domain.interactions.insert(q, {
-- id: prefixedId('tint', Date.parse(created)), creator_subject: creator, supporter_subject: subjectOf(t.from_user_id),
-- supporter_name: nameOf(t.from_user_id) || (site ? 'PowerChat supporter' : null), kind: media ? 'media_request' : 'tip', amount,
-- message: media || site ? null : message, request: { legacy_message: message, live_stream_id: t.stream_id || null },
-- funding: site ? 'provider' : 'credit', settlement: 'imported', payment_state: 'settled', delivery_state: 'delivered',
-- test, billing_txn_id: billingTxn, provider: site ? 'powerchat' : null, legacy_source: `live:transactions:${t.id}`,
-- origin: 'import', created_at: created,
-- target: t.stream_id ? { service: 'live', type: 'stream', id: String(t.stream_id) } : null,
-- });
-- await q.exec(sql`UPDATE tip_interactions SET settled_at = ${created}, delivered_at = ${created} WHERE id = ${i.id}`);
-- await record('transactions', t.id, { status: 'imported', target_type: 'interaction', target_id: i.id, billing: billingTxn, reason: prev ? 'released from hold' : null });
-- if (prev) report.counts.released++;
-- report.counts.imported++;
-- if (billingTxn) report.counts.linked_to_billing++;
-- else { report.counts.unlinked++; report.unlinked.push({ live_txn: t.id, interaction: i.id }); }
-- }
--
-- // ── 3. refunds (streamer → donor) ────────────────────
-- for (const t of txns.filter((x) => x.type === 'refund')) {
-- const prev = await mapRow('live', 'transactions', String(t.id));
-- if (prev && prev.status !== 'held') { report.counts.unchanged++; continue; }
-- const amount = Math.round(Number(t.amount) || 0);
-- if (t.status !== 'completed' || amount <= 0) { await record('transactions', t.id, { status: 'excluded', reason: `refund with status ${t.status}` }); report.counts.excluded++; continue; }
-- const creator = subjectOf(t.from_user_id);
-- const donor = subjectOf(t.to_user_id);
-- const created = liveTs(t.created_at) || at;
-- // The donations a refund may apply to, locked in id order (the domain's lock order).
-- const candidates = creator ? (await q.many(sql`SELECT * FROM tip_interactions WHERE creator_subject = ${creator} AND settlement = 'imported'
-- AND starts_with(legacy_source, 'live:transactions:') AND supporter_subject IS NOT DISTINCT FROM ${donor}::text AND created_at <= ${created}
-- AND amount - reversed_bits > 0 ORDER BY id FOR UPDATE`)).sort((a, b) => (a.created_at < b.created_at ? 1 : a.created_at > b.created_at ? -1 : 0)) : [];
-- // Plan first; apply only when the whole refund can be placed.
-- let left = amount;
-- const plan = [];
-- for (const c of candidates) {
-- if (!left) break;
-- const take = Math.min(left, c.amount - c.reversed_bits);
-- plan.push({ c, take });
-- left -= take;
-- }
-- if (left > 0) {
-- await record('transactions', t.id, { status: 'held', reason: `refund of ${amount} from Live user ${t.from_user_id} to ${t.to_user_id}: no earlier imported donation left to apply it to` });
-- report.counts.held++; report.holds.push({ live_txn: t.id, refund: true, amount });
-- continue;
-- }
-- const applied = [];
-- for (const { c, take } of plan) {
-- const ids = [...(c.reversal_txn_ids || []), `live:transactions:${t.id}`];
-- await q.exec(sql`UPDATE tip_interactions SET reversed_bits = reversed_bits + ${take}, payment_state = 'reversed', reversed_at = ${created},
-- reversal_txn_ids = ${sql.json(ids)} WHERE id = ${c.id}`);
-- applied.push({ interaction: c.id, bits: take });
-- }
-- await record('transactions', t.id, { status: 'imported', target_type: 'refund', target_id: applied.map((a) => a.interaction).join(','), billing: billingSource ? billingSource.linkLiveTxn(t.id) : null });
-- report.counts.refunds_applied++;
-- }
--
-- // ── 4. external PowerChat tips (chat record only) ────
-- const siteRows = txns.filter((x) => x.type === 'donation' && /^PowerChat tip via site account/i.test(x.message || ''));
-- const usedSite = new Set();
-- for (const c of chatDonations) {
-- const m = json(c.metadata, {});
-- if (m.source !== 'powerchat' || m.kind !== 'donation') continue;
-- const prev = await mapRow('live', 'chat_messages', String(c.id));
-- if (prev && prev.status !== 'held') { report.counts.unchanged++; continue; }
-- const amount = Math.round(Number(m.amount) || 0);
-- const created = liveTs(c.created_at) || at;
-- const twin = siteRows.find((t) => !usedSite.has(t.id) && String(t.to_user_id) === String(c.channel_user_id) && Math.round(Number(t.amount)) === amount
-- && Math.abs(Date.parse(liveTs(t.created_at) || at) - Date.parse(created)) <= 10 * 60 * 1000);
-- if (twin) {
-- usedSite.add(twin.id);
-- await record('chat_messages', c.id, { status: 'excluded', reason: `celebration of site-routed tip (Live transaction ${twin.id})` });
-- report.counts.excluded++;
-- continue;
-- }
-- if (amount <= 0) { await record('chat_messages', c.id, { status: 'excluded', reason: 'no amount' }); report.counts.excluded++; continue; }
-- const creator = subjectOf(c.channel_user_id);
-- if (!creator) {
-- await record('chat_messages', c.id, { status: 'held', reason: `creator (Live user ${c.channel_user_id}) has no Network subject yet` });
-- report.counts.held++; report.holds.push({ live_chat_message: c.id, live_creator: c.channel_user_id, amount });
-- continue;
-- }
-- const i = await domain.interactions.insert(q, {
-- id: prefixedId('tint', Date.parse(created)), creator_subject: creator, supporter_name: m.username ? storable(String(m.username).slice(0, 80)) : 'PowerChat supporter',
-- kind: 'tip', amount, amount_cents: amount, message: m.message ? storable(String(m.message).slice(0, 500)) : null, funding: 'external', settlement: 'external',
-- payment_state: 'settled', delivery_state: 'delivered', test: !!(resetAt && created < resetAt), provider: 'powerchat',
-- provider_ref: `live-chat:${c.id}`, legacy_source: `live:chat_messages:${c.id}`, origin: 'import', created_at: created,
-- });
-- await q.exec(sql`UPDATE tip_interactions SET settled_at = ${created}, delivered_at = ${created} WHERE id = ${i.id}`);
-- await record('chat_messages', c.id, { status: 'imported', target_type: 'interaction', target_id: i.id });
-- report.counts.external++;
-- }
--
-- // ── 5. goals ─────────────────────────────────────────
-- for (const g of goalRows) {
-- const prev = await mapRow('live', 'donation_goals', String(g.id));
-- if (prev && prev.status !== 'held') { report.counts.unchanged++; continue; }
-- const creator = subjectOf(g.user_id);
-- if (!creator) { await record('donation_goals', g.id, { status: 'held', reason: `creator (Live user ${g.user_id}) has no Network subject yet` }); report.counts.held++; continue; }
-- const target = Math.round(Number(g.target_amount) || 0);
-- if (target <= 0 || !g.title) { await record('donation_goals', g.id, { status: 'excluded', reason: 'no title or target' }); report.counts.excluded++; continue; }
-- const created = liveTs(g.created_at) || at;
-- const id = prefixedId('tgoal', Date.parse(created));
-- await q.exec(sql`INSERT INTO tip_goals (id, creator_subject, title, target_amount, image_url, status, sort_order, reached_at, closed_at, opening_amount, legacy_source, created_at, updated_at)
-- VALUES (${id}, ${creator}, ${storable(String(g.title).slice(0, 120))}, ${target}, ${/^https:\/\//.test(String(g.image_url || '')) ? g.image_url : null},
-- ${g.is_active ? 'active' : 'closed'}, ${Number(g.sort_order) || 0}, ${liveTs(g.reached_at)}, ${g.is_active ? null : at},
-- ${Math.max(0, Math.round(Number(g.current_amount) || 0))}, ${`live:donation_goals:${g.id}`}, ${created}, ${at})`);
-- await record('donation_goals', g.id, { status: 'imported', target_type: 'goal', target_id: id, reason: `opening_amount ${Math.round(Number(g.current_amount) || 0)} carried over from Live's current_amount` });
-- report.counts.goals++;
-- }
--
-- if (dryRun) { report.reconciliation = await reconcile(domain, billingSource, q); throw new DryRun(); }
-- };
--
-- // Serializable while the service writes too: a conflict retries the whole run (idempotent), up to 10 times.
-- try { await domain.tx(run, { ...domain.MONEY, retries: 10 }); } catch (e) { if (!(e instanceof DryRun)) throw e; }
-- if (!dryRun) report.reconciliation = await reconcile(domain, billingSource);
-- report.finished_at = iso(domain.now());
-- await domain.db.exec(sql`INSERT INTO import_runs (id, source, dry_run, started_at, finished_at, report)
-- VALUES (${runId}, 'live', ${!!dryRun}, ${report.started_at}, ${report.finished_at}, ${sql.json(report)})`);
-- if (log && log.log) log.log(`[Tips] import ${runId}${dryRun ? ' (dry run, rolled back)' : ''}: ${report.counts.imported} tips, ${report.counts.external} external, ${report.counts.goals} goals, ${report.counts.held} held, ${report.counts.excluded} excluded; reconciliation ${report.reconciliation ? (report.reconciliation.ok ? 'OK' : 'MISMATCH') : 'skipped (no Billing source)'}`);
-- return report;
--}
--
--function pick(row) { return { status: row.status, target_type: row.target_type, target_id: row.target_id, reason: row.reason }; }
--
--/** Per-creator totals: Tips (settled via Billing or imported, non-test, minus refunds) vs Billing. */
--async function reconcile(domain, billingSource, q = domain.db) {
-- if (!billingSource) return null;
-- const billing = billingSource.creatorTotals();
-- const tips = new Map((await q.many(sql`SELECT creator_subject AS s, SUM(amount - reversed_bits)::bigint AS n FROM tip_interactions
-- WHERE NOT test AND settlement IN ('billing', 'imported') AND payment_state IN ('settled', 'reversed') GROUP BY creator_subject`)).map((r) => [r.s, r.n]));
-- const creators = [...new Set([...billing.keys(), ...tips.keys()])].sort();
-- const rows = creators.map((s) => ({ creator: s, tips: tips.get(s) || 0, billing: billing.get(s) || 0, difference: (tips.get(s) || 0) - (billing.get(s) || 0) }));
-- const mismatches = rows.filter((r) => r.difference !== 0);
-- return { ok: mismatches.length === 0, creators: rows.length, mismatches, rows };
--}
--
--module.exports = { importLive, reconcile, billingSnapshotSource, liveTs };
ddiff --git a/server/index.js b/server/index.js
iindex f02d369..7791385 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -66,8 +66,8 @@ async function main() {
if (valkey) await valkey.close().catch(() => {});
process.exit(0);
}
-- process.on('SIGTERM', () => { shutdown('SIGTERM'); });
-- process.on('SIGINT', () => { shutdown('SIGINT'); });
++ process.on('SIGTERM', async () => { await shutdown('SIGTERM'); });
++ process.on('SIGINT', async () => { await shutdown('SIGINT'); });
}
main().catch((e) => {
ddiff --git a/server/network.js b/server/network.js
iindex 6ce4cc2..1de9a03 100644
---- a/server/network.js
++++ b/server/network.js
@@@ -6,16 +6,11 @@
* keys the Network's RS256 public key (OV_NETWORK_PUBLIC_KEY, else GET /api/.well-known/jwks,
* refreshed every 6 h and retried every 30 s until it loads). It verifies service tokens
* (audience openvibe.tips) and the browser's Network user JWT, both offline.
-- * identity Live user ids → canonical subjects via POST /internal/identity/resolve-batch
-- * (capability identity.subject.resolve, audience openvibe.network) — the importer only.
*/
const crypto = require('crypto');
--const { serviceAuth } = require('openvibe-contracts');
const jwt = require('jsonwebtoken');
const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
--const BATCH = 500;
--
function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console } = {}) {
let pem = config.network.publicKey ? crypto.createPublicKey(config.network.publicKey).export({ type: 'spki', format: 'pem' }) : null;
let timer = null;
@@@ -39,7 +34,7 @@ function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console
if (config.network.publicKey || timer) return;
const retry = () => load().then((k) => { if (!k) setTimeout(retry, 30_000).unref(); });
retry();
-- timer = setInterval(() => { load(); }, 6 * 60 * 60 * 1000);
++ timer = setInterval(async () => { await load(); }, 6 * 60 * 60 * 1000);
timer.unref();
}
function stop() { if (timer) clearInterval(timer); timer = null; }
@@@ -74,47 +69,4 @@ function createUserAuth(config, keys) {
return { client, verify };
}
--function createIdentity(config, { fetchImpl = globalThis.fetch } = {}) {
-- const base = config.network.internalUrl;
-- let tokens = null;
-- const tokenClient = () => {
-- if (!tokens) {
-- tokens = serviceAuth.createTokenClient({
-- tokenUrl: `${base}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
-- audience: 'openvibe.network', scope: 'identity.subject.resolve', fetchImpl,
-- });
-- }
-- return tokens;
-- };
--
-- async function post(body, retried = false) {
-- const res = await fetchImpl(`${base}/internal/identity/resolve-batch`, {
-- method: 'POST',
-- headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokenClient().authHeaders()) },
-- body: JSON.stringify(body),
-- signal: AbortSignal.timeout(10000),
-- });
-- if (res.status === 401 && !retried) { tokenClient().invalidate(); return post(body, true); }
-- const data = await res.json().catch(() => null);
-- if (!res.ok || !data || typeof data.results !== 'object') throw new Error(`resolve-batch ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);
-- return data.results;
-- }
--
-- /** Live user ids → Map(String(id) → { subject, username } | null). */
-- async function resolveLiveUsers(liveIds) {
-- const out = new Map();
-- const all = [...new Set(liveIds.map(String))];
-- for (let i = 0; i < all.length; i += BATCH) {
-- const chunk = all.slice(i, i + BATCH);
-- const results = await post({ system: 'live', type: 'user', ids: chunk });
-- for (const k of chunk) {
-- const p = results[k];
-- out.set(k, p && p.subject && p.subject.type === 'user' ? { subject: p.subject.id, username: p.username || null } : null);
-- }
-- }
-- return out;
-- }
-- return { resolveLiveUsers };
--}
--
--module.exports = { createKeyProvider, createUserAuth, createIdentity };
++module.exports = { createKeyProvider, createUserAuth };
ddiff --git a/server/util.js b/server/util.js
iindex c120385..c19155f 100644
---- a/server/util.js
++++ b/server/util.js
@@@ -19,7 +19,7 @@ function fail(status, code, detail, extra) { throw new TipsError(status, code, d
const prefixedId = (prefix, ms = Date.now()) => `${prefix}_${ids.ulid(ms)}`;
const iso = (ms) => new Date(ms).toISOString();
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
--/** JSON text (a SQLite snapshot's column) → value, or `d`. PostgreSQL jsonb columns come back parsed. */
++/** JSON text → value, or `d`. PostgreSQL jsonb columns come back parsed. */
const json = (v, d) => { if (v == null || v === '') return d; try { return JSON.parse(v); } catch { return d; } };
function positiveInt(v, field, max) {
@@@ -33,8 +33,8 @@ function positiveInt(v, field, max) {
// from the creator's filter and turn text around on stream (docs/threat-review.md).
const INVISIBLE = /[\u00AD\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/g;
--// PostgreSQL stores no NUL character in text or jsonb, and jsonb refuses an unpaired surrogate (SQLite
--// took both): every text from outside goes through storable() before it reaches the database.
++// PostgreSQL stores no NUL character in text or jsonb, and jsonb refuses an unpaired surrogate:
++// every text from outside goes through storable() before it reaches the database.
const NUL = /\u0000/g;
/** A string PostgreSQL can store: NUL characters dropped, unpaired surrogates replaced by U+FFFD. */
const storable = (v) => String(v).toWellFormed().replace(NUL, '');
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex d27a6ac..69f4335 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -112,11 +112,11 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
}
r.get('/receipts/erase', withViewer, async (req, res) => {
if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent('/receipts/erase')}`);
-- return renderErase(req, res);
++ return await renderErase(req, res);
});
r.post('/receipts/erase', withViewer, form, async (req, res) => {
if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/receipts/erase')}`);
-- if (!csrfOk(req) || !bool(req.body.confirm)) return renderErase(req, res, 403);
++ if (!csrfOk(req) || !bool(req.body.confirm)) return await renderErase(req, res, 403);
await interactions.erase(req.viewer.subject);
return res.redirect(303, `/receipts?done=${encodeURIComponent('Your data was erased from your tips')}`);
});
@@@ -163,21 +163,21 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
r.get('/dashboard', withViewer, async (req, res) => {
if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent('/dashboard')}`);
// Only our own confirmations are shown: a crafted ?done= link cannot put words on the page.
-- return renderDashboard(req, res, { flash: FLASH.has(String(req.query.done || '')) ? String(req.query.done) : null });
++ return await renderDashboard(req, res, { flash: FLASH.has(String(req.query.done || '')) ? String(req.query.done) : null });
});
/** A dashboard form action: sign-in + anti-forgery, errors re-render the dashboard. */
function action(path, fn) {
r.post(path, withViewer, form, async (req, res) => {
if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent('/dashboard')}`);
-- if (!csrfOk(req)) return renderDashboard(req, res, { error: 'That form expired. Please try again.', status: 403 });
++ if (!csrfOk(req)) return await renderDashboard(req, res, { error: 'That form expired. Please try again.', status: 403 });
try {
const p = await ensureProfile(req);
const out = await fn(req, p);
if (out && out.render) return out.render(res);
return res.redirect(303, `/dashboard?done=${encodeURIComponent((out && out.done) || 'Saved')}`);
} catch (e) {
-- if (e instanceof TipsError) return renderDashboard(req, res, { error: e.detail || e.message, status: e.status >= 500 ? 500 : 400 });
++ if (e instanceof TipsError) return await renderDashboard(req, res, { error: e.detail || e.message, status: e.status >= 500 ? 500 : 400 });
throw e;
}
});
@@@ -335,19 +335,19 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
const { p, actor } = await modAccess(req);
if (!p || !actor) return notFound(req, res);
-- return renderModerate(req, res, p);
++ return await renderModerate(req, res, p);
});
r.post('/moderate/:handle/:id/:act(hide|restore)', withViewer, form, async (req, res) => {
if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/moderate/${req.params.handle}`)}`);
const { p, actor } = await modAccess(req);
if (!p || !actor) return notFound(req, res);
-- if (!csrfOk(req)) return renderModerate(req, res, p, { error: 'That form expired. Please try again.', status: 403 });
++ if (!csrfOk(req)) return await renderModerate(req, res, p, { error: 'That form expired. Please try again.', status: 403 });
const i = await interactions.get(db, req.params.id);
if (!i || i.creator_subject !== p.creator_subject) return notFound(req, res);
try {
await moderation[req.params.act](i, { ...actor, reason: req.body.reason });
} catch (e) {
-- if (e instanceof TipsError) return renderModerate(req, res, p, { error: e.detail || e.message, status: 400 });
++ if (e instanceof TipsError) return await renderModerate(req, res, p, { error: e.detail || e.message, status: 400 });
throw e;
}
return res.redirect(303, `/moderate/${encodeURIComponent(p.handle)}?done=${req.params.act === 'hide' ? 'Hidden' : 'Shown'}`);
@@@ -371,7 +371,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
body: pages.creatorPage({ profile: p, goals: list, viewer: req.viewer, csrf, idem: idem(), values, providers, error, ownerPreview: !p.page_enabled }),
}), status, p.page_enabled ? {} : { 'X-Robots-Tag': 'noindex, nofollow' });
}
-- r.get('/:handle', withViewer, (req, res, next) => (profiles.normalizeHandle(req.params.handle) ? creatorView(req, res) : next()));
++ r.get('/:handle', withViewer, async (req, res, next) => (profiles.normalizeHandle(req.params.handle) ? await creatorView(req, res) : next()));
r.get('/:handle/goals', withViewer, async (req, res, next) => {
const p = await profiles.byHandle(db, req.params.handle);
if (!p) return next();
@@@ -407,7 +407,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
kind: b.kind, amount: b.amount, message: b.message, tts_text: b.tts_text, tts_voice: b.tts_voice, media_url: b.media_url, goal_id: b.goal_id, pay_with: b.pay_with,
supporter_name: b.supporter_name, anonymous: bool(b.anonymous), hide_amount: bool(b.hide_amount), private_message: bool(b.private_message),
};
-- if (!csrfOk(req)) return creatorView(req, res, { values, error: 'That form expired. Please send it again.', status: 403 });
++ if (!csrfOk(req)) return await creatorView(req, res, { values, error: 'That form expired. Please send it again.', status: 403 });
const owner = req.viewer.subject === p.creator_subject;
if (!p.page_enabled && !owner) return notFound(req, res);
const key = /^form-[A-Za-z0-9_-]{8,40}$/.test(String(b.idem || '')) ? `form:${req.viewer.subject}:${b.idem}` : null;
@@@ -426,7 +426,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
const out = await interactions.transfer(i);
if (out.refused) {
const msg = out.refused === 'billing.insufficient_funds' ? 'Your Vibes balance is too low for this. Pay by checkout instead, or top up on OpenVibe.Live.' : `Billing refused this tip (${out.refused}).`;
-- return creatorView(req, res, { values, error: msg, status: 409 });
++ return await creatorView(req, res, { values, error: msg, status: 409 });
}
} else {
i = (await interactions.startCheckout(i, { provider: b.provider })).interaction;
@@@ -435,7 +435,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
}
return res.redirect(303, `/receipts/${i.id}`);
} catch (e) {
-- if (e instanceof TipsError) return creatorView(req, res, { values, error: e.detail || e.message, status: e.status >= 500 ? 502 : 400 });
++ if (e instanceof TipsError) return await creatorView(req, res, { values, error: e.detail || e.message, status: e.status >= 500 ? 502 : 400 });
return next(e);
}
});
ddiff --git a/test/external.test.js b/test/external.test.js
iindex 58a2c0d..7ddbc23 100644
---- a/test/external.test.js
++++ b/test/external.test.js
@@@ -72,17 +72,15 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(totals.settled_via_billing, 0);
});
-- await check('the goal the tip asked for: a Tips goal id, or a Live goal id through the import map', async () => {
++ await check('the goal the tip asked for: a Tips goal id', async () => {
const a = (await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'Desk', target_amount: 900 } })).json.goal;
const b = (await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'Chair', target_amount: 900 } })).json.goal;
await t.deliver(receipt({ provider_event_id: 'ext-goal-tips', amount_cents: 100, value_bits: 100, app_purpose: `goal:${a.id}` }));
-- await t.db.exec(`INSERT INTO migration_maps (source, source_table, source_id, target_type, target_id, status, run_id, created_at, updated_at)
-- VALUES ('live', 'donation_goals', '12', 'goal', $1, 'imported', 'imp_test', $2, $2)`, [b.id, new Date().toISOString()]);
-- await t.deliver(receipt({ provider_event_id: 'ext-goal-live', amount_cents: 200, value_bits: 200, app_purpose: 'goal:12' }));
++ await t.deliver(receipt({ provider_event_id: 'ext-goal-other', amount_cents: 200, value_bits: 200, app_purpose: `goal:${b.id}` }));
await t.deliver(receipt({ provider_event_id: 'ext-goal-none', amount_cents: 300, value_bits: 300 }));
assert.strictEqual((await domain.goals.present(t.db, await domain.goals.get(t.db, a.id))).current_amount, 100);
assert.strictEqual((await domain.goals.present(t.db, await domain.goals.get(t.db, b.id))).current_amount, 200);
-- assert.strictEqual(await t.db.value('SELECT count(*) FROM tip_goal_contributions WHERE interaction_id = $1', [(await byRef('ext-goal-none')).id]), 0, 'several active goals and no pick: none (Live\'s rule)');
++ assert.strictEqual(await t.db.value('SELECT count(*) FROM tip_goal_contributions WHERE interaction_id = $1', [(await byRef('ext-goal-none')).id]), 0, 'several active goals and no pick: none');
});
await check('anonymous: no donor name; a creator with no tip page is still announced', async () => {
ddiff --git a/test/fixtures/sqlite-v3-schema.sql b/test/fixtures/sqlite-v3-schema.sql
ddeleted file mode 100644
iindex ee02962..0000000
---- a/test/fixtures/sqlite-v3-schema.sql
++++ /dev/null
@@@ -1,301 +0,0 @@
---- The SQLite schema of the last SQLite release of OpenVibe.Tips (schema v3: server/db.js with its added
---- columns, plus the openvibe-sdk outbox and inbox), as sqlite_master holds it in a database that release made.
---- test/sqlite-import.test.js builds its fixture from it. Never edit: it is the source side of the one-time import.
--
--CREATE TABLE settings (
-- id INTEGER PRIMARY KEY CHECK (id = 1),
-- schema_version INTEGER NOT NULL,
-- created_at TEXT NOT NULL
--);
--
--CREATE TABLE creator_tip_profiles (
-- creator_subject TEXT PRIMARY KEY, -- usr_…
-- handle TEXT NOT NULL UNIQUE, -- lower-case Network username (projection)
-- display_name TEXT NOT NULL,
-- avatar_url TEXT,
-- headline TEXT,
-- page_enabled INTEGER NOT NULL DEFAULT 0, -- public page + indexing
-- accepting INTEGER NOT NULL DEFAULT 1, -- new tips accepted
-- min_amount INTEGER NOT NULL DEFAULT 1,
-- paid_message_min INTEGER NOT NULL DEFAULT 100,
-- tts_enabled INTEGER NOT NULL DEFAULT 0,
-- tts_min_amount INTEGER NOT NULL DEFAULT 100,
-- tts_max_chars INTEGER NOT NULL DEFAULT 200,
-- tts_voice TEXT NOT NULL DEFAULT 'gary',
-- media_requests_enabled INTEGER NOT NULL DEFAULT 0,
-- media_request_min INTEGER NOT NULL DEFAULT 25,
-- media_max_seconds INTEGER NOT NULL DEFAULT 600,
-- revision INTEGER NOT NULL DEFAULT 1,
-- created_at TEXT NOT NULL,
-- updated_at TEXT NOT NULL
--, page_settings TEXT NOT NULL DEFAULT '{}', filter_words TEXT NOT NULL DEFAULT '[]', filter_action TEXT NOT NULL DEFAULT 'mask', filter_links INTEGER NOT NULL DEFAULT 1);
--
--CREATE TABLE tip_interactions (
-- id TEXT PRIMARY KEY, -- tint_<ULID>
-- creator_subject TEXT NOT NULL,
-- supporter_subject TEXT, -- null: external/anonymous/unmapped
-- supporter_name TEXT, -- display projection at the time
-- kind TEXT NOT NULL CHECK (kind IN ('tip', 'paid_message', 'tts', 'media_request')),
-- amount INTEGER NOT NULL CHECK (amount > 0),
-- currency TEXT NOT NULL DEFAULT 'vibes-bits',
-- amount_cents INTEGER, -- money that arrived (provider/external)
-- message TEXT,
-- request TEXT NOT NULL DEFAULT '{}', -- JSON: tts text/voice, media url, goal_id, target
-- funding TEXT NOT NULL CHECK (funding IN ('credit', 'checkout', 'provider', 'external', 'none')),
-- settlement TEXT NOT NULL CHECK (settlement IN ('billing', 'external', 'simulated', 'imported')),
-- payment_state TEXT NOT NULL CHECK (payment_state IN ('pending', 'settled', 'reversed', 'failed')),
-- delivery_state TEXT NOT NULL CHECK (delivery_state IN ('awaiting_payment', 'queued', 'delivered', 'failed', 'cancelled')),
-- test INTEGER NOT NULL DEFAULT 0, -- simulation or Billing test money: never counted
-- billing_txn_id TEXT UNIQUE, -- the Billing transaction that settled it
-- billing_intent_id TEXT UNIQUE, -- checkout: the Billing payment intent
-- funding_txn_id TEXT UNIQUE, -- checkout: the purchase that funded it
-- reversal_txn_ids TEXT NOT NULL DEFAULT '[]',
-- reversed_bits INTEGER NOT NULL DEFAULT 0, -- bits Billing took back from the creator
-- checkout_url TEXT,
-- checkout_ref TEXT,
-- provider TEXT,
-- provider_ref TEXT,
-- legacy_source TEXT, -- 'live:transactions:<id>' etc.
-- idempotency_key TEXT UNIQUE,
-- failure TEXT,
-- origin TEXT NOT NULL DEFAULT 'tips', -- tips | billing (settled elsewhere) | external | billing-external (announced by Billing, delivered here) | import
-- transfer_due INTEGER NOT NULL DEFAULT 0, -- a Billing transfer still has to be (re)tried
-- transfer_attempts INTEGER NOT NULL DEFAULT 0,
-- next_transfer_at INTEGER NOT NULL DEFAULT 0,
-- target TEXT, -- JSON EntityRef (e.g. a Live stream)
-- created_at TEXT NOT NULL,
-- settled_at TEXT,
-- reversed_at TEXT,
-- delivered_at TEXT,
-- updated_at TEXT NOT NULL, anonymous INTEGER NOT NULL DEFAULT 0, hide_amount INTEGER NOT NULL DEFAULT 0, private_message INTEGER NOT NULL DEFAULT 0, erased_at TEXT, moderation TEXT NOT NULL DEFAULT 'visible', moderated_at TEXT, moderated_by TEXT, filtered INTEGER NOT NULL DEFAULT 0,
-- UNIQUE (provider, provider_ref),
-- UNIQUE (legacy_source)
--);
--
--CREATE TABLE tip_goals (
-- id TEXT PRIMARY KEY, -- tgoal_<ULID>
-- creator_subject TEXT NOT NULL,
-- title TEXT NOT NULL,
-- description TEXT,
-- target_amount INTEGER NOT NULL CHECK (target_amount > 0),
-- currency TEXT NOT NULL DEFAULT 'vibes-bits',
-- image_url TEXT,
-- status TEXT NOT NULL CHECK (status IN ('active', 'closed')),
-- sort_order INTEGER NOT NULL DEFAULT 0,
-- reached_at TEXT,
-- closed_at TEXT,
-- opening_amount INTEGER NOT NULL DEFAULT 0, -- carried over from Live's goal column (not tied to tips)
-- legacy_source TEXT UNIQUE,
-- revision INTEGER NOT NULL DEFAULT 1,
-- created_at TEXT NOT NULL,
-- updated_at TEXT NOT NULL
--);
--
--CREATE TABLE tip_goal_contributions (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- goal_id TEXT NOT NULL REFERENCES tip_goals(id),
-- interaction_id TEXT NOT NULL REFERENCES tip_interactions(id),
-- amount INTEGER NOT NULL CHECK (amount > 0),
-- reversed_amount INTEGER NOT NULL DEFAULT 0,
-- created_at TEXT NOT NULL,
-- updated_at TEXT NOT NULL,
-- UNIQUE (goal_id, interaction_id)
--);
--
--CREATE TABLE paid_messages (
-- id TEXT PRIMARY KEY, -- tpm_<ULID>
-- interaction_id TEXT NOT NULL UNIQUE REFERENCES tip_interactions(id),
-- creator_subject TEXT NOT NULL,
-- kind TEXT NOT NULL CHECK (kind IN ('paid_message', 'tts')),
-- text TEXT NOT NULL,
-- voice TEXT,
-- highlight_seconds INTEGER NOT NULL DEFAULT 0,
-- status TEXT NOT NULL CHECK (status IN ('queued', 'delivered', 'failed', 'cancelled')),
-- chat_ref TEXT, -- JSON reference the chat adapter returned
-- test INTEGER NOT NULL DEFAULT 0,
-- created_at TEXT NOT NULL,
-- updated_at TEXT NOT NULL
--);
--
--CREATE TABLE paid_media_requests (
-- id TEXT PRIMARY KEY, -- tmr_<ULID>
-- interaction_id TEXT NOT NULL UNIQUE REFERENCES tip_interactions(id),
-- creator_subject TEXT NOT NULL,
-- url TEXT NOT NULL,
-- provider TEXT,
-- status TEXT NOT NULL CHECK (status IN ('queued', 'accepted', 'played', 'skipped', 'failed', 'cancelled', 'refunded')),
-- queue_ref TEXT, -- JSON reference returned by the queue owner
-- test INTEGER NOT NULL DEFAULT 0,
-- created_at TEXT NOT NULL,
-- updated_at TEXT NOT NULL
--);
--
--CREATE TABLE overlay_configs (
-- id TEXT PRIMARY KEY, -- tovc_<ULID>
-- creator_subject TEXT NOT NULL,
-- kind TEXT NOT NULL CHECK (kind IN ('alerts', 'goal')),
-- name TEXT NOT NULL,
-- settings TEXT NOT NULL DEFAULT '{}',
-- goal_id TEXT,
-- revision INTEGER NOT NULL DEFAULT 1,
-- created_at TEXT NOT NULL,
-- updated_at TEXT NOT NULL
--);
--
--CREATE TABLE overlay_tokens (
-- id TEXT PRIMARY KEY, -- tovt_<ULID>
-- creator_subject TEXT NOT NULL,
-- token_hash TEXT NOT NULL UNIQUE, -- sha256 of the secret; the secret is shown once
-- scopes TEXT NOT NULL, -- JSON ["alerts","goals"]
-- config_id TEXT,
-- label TEXT,
-- created_by TEXT NOT NULL,
-- created_at TEXT NOT NULL,
-- last_used_at TEXT,
-- revoked_at TEXT
--);
--
--CREATE TABLE overlay_deliveries (
-- seq INTEGER PRIMARY KEY AUTOINCREMENT, -- the SSE event id (monotonic)
-- id TEXT NOT NULL UNIQUE, -- tovd_<ULID>
-- creator_subject TEXT NOT NULL,
-- kind TEXT NOT NULL CHECK (kind IN ('alert', 'goal')),
-- interaction_id TEXT,
-- goal_id TEXT,
-- payload TEXT NOT NULL,
-- test INTEGER NOT NULL DEFAULT 0,
-- status TEXT NOT NULL CHECK (status IN ('pending', 'delivered', 'failed')),
-- delivered_at TEXT,
-- failed_at TEXT,
-- sends INTEGER NOT NULL DEFAULT 0, -- times written to an overlay (replays included)
-- dedupe_key TEXT NOT NULL UNIQUE, -- alert:<interaction> | goal:<goal>:<interaction|revision>
-- created_at TEXT NOT NULL
--, hidden INTEGER NOT NULL DEFAULT 0);
--
--CREATE TABLE interaction_effects (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- interaction_id TEXT NOT NULL REFERENCES tip_interactions(id),
-- effect TEXT NOT NULL CHECK (effect IN ('chat_line', 'paid_message', 'tts', 'media_request', 'overlay_alert')),
-- adapter TEXT NOT NULL,
-- state TEXT NOT NULL CHECK (state IN ('queued', 'delivered', 'failed', 'cancelled')),
-- attempts INTEGER NOT NULL DEFAULT 0,
-- next_attempt_at INTEGER NOT NULL DEFAULT 0,
-- last_error TEXT,
-- result TEXT,
-- created_at TEXT NOT NULL,
-- updated_at TEXT NOT NULL,
-- UNIQUE (interaction_id, effect)
--);
--
--CREATE TABLE migration_maps (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- source TEXT NOT NULL, -- 'live'
-- source_table TEXT NOT NULL,
-- source_id TEXT NOT NULL,
-- target_type TEXT, -- 'interaction' | 'goal'
-- target_id TEXT,
-- status TEXT NOT NULL CHECK (status IN ('imported', 'held', 'excluded')),
-- reason TEXT,
-- billing_txn_id TEXT,
-- run_id TEXT NOT NULL,
-- created_at TEXT NOT NULL,
-- updated_at TEXT NOT NULL,
-- UNIQUE (source, source_table, source_id)
--);
--
--CREATE TABLE import_runs (
-- id TEXT PRIMARY KEY,
-- source TEXT NOT NULL,
-- dry_run INTEGER NOT NULL,
-- started_at TEXT NOT NULL,
-- finished_at TEXT,
-- report TEXT
--);
--
--CREATE TABLE tip_moderators (
-- creator_subject TEXT NOT NULL,
-- moderator_subject TEXT NOT NULL,
-- name TEXT, -- display name when they joined (for the creator's list)
-- added_by TEXT NOT NULL, -- the creator, a service, or 'invite:<id>'
-- created_at TEXT NOT NULL,
-- removed_at TEXT,
-- PRIMARY KEY (creator_subject, moderator_subject)
--);
--
--CREATE TABLE tip_moderator_invites (
-- id TEXT PRIMARY KEY, -- tmin_<ULID>
-- creator_subject TEXT NOT NULL,
-- token_hash TEXT NOT NULL UNIQUE, -- sha256 of the link's secret (shown once)
-- created_by TEXT NOT NULL,
-- created_at TEXT NOT NULL,
-- expires_at TEXT NOT NULL,
-- used_at TEXT,
-- used_by TEXT,
-- revoked_at TEXT
--);
--
--CREATE TABLE tip_moderation_log (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- creator_subject TEXT NOT NULL,
-- interaction_id TEXT NOT NULL,
-- action TEXT NOT NULL CHECK (action IN ('held', 'filtered', 'hidden', 'restored')),
-- by_role TEXT NOT NULL CHECK (by_role IN ('filter', 'creator', 'moderator', 'service')),
-- actor TEXT, -- usr_… / svc:… ; null for the filter
-- reason TEXT,
-- created_at TEXT NOT NULL
--);
--
--CREATE TABLE api_idempotency (
-- key TEXT PRIMARY KEY, -- <principal>:<Idempotency-Key>
-- request_hash TEXT NOT NULL,
-- method TEXT NOT NULL,
-- path TEXT NOT NULL,
-- status INTEGER NOT NULL,
-- response TEXT NOT NULL,
-- created_at TEXT NOT NULL
--);
--
--CREATE TABLE event_outbox (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- event_id TEXT NOT NULL UNIQUE,
-- envelope TEXT NOT NULL,
-- traceparent TEXT,
-- created_at INTEGER NOT NULL,
-- attempts INTEGER NOT NULL DEFAULT 0,
-- next_attempt_at INTEGER NOT NULL DEFAULT 0,
-- sent_at INTEGER,
-- seq INTEGER,
-- rejected_at INTEGER,
-- last_error TEXT
-- );
--
--CREATE TABLE idempotency_receipts (
-- consumer TEXT NOT NULL,
-- event_id TEXT NOT NULL,
-- processed_at INTEGER NOT NULL,
-- PRIMARY KEY (consumer, event_id)
-- );
--
--CREATE INDEX idx_tint_creator ON tip_interactions (creator_subject, created_at);
--
--CREATE INDEX idx_tint_supporter ON tip_interactions (supporter_subject, created_at);
--
--CREATE INDEX idx_tint_pending ON tip_interactions (payment_state, funding);
--
--CREATE INDEX idx_goal_creator ON tip_goals (creator_subject, status);
--
--CREATE INDEX idx_ovc_creator ON overlay_configs (creator_subject);
--
--CREATE INDEX idx_ovt_creator ON overlay_tokens (creator_subject);
--
--CREATE INDEX idx_ovd_creator ON overlay_deliveries (creator_subject, seq);
--
--CREATE INDEX idx_ovd_pending ON overlay_deliveries (seq) WHERE status = 'pending';
--
--CREATE INDEX idx_effects_due ON interaction_effects (state, next_attempt_at);
--
--CREATE INDEX idx_tmod_moderator ON tip_moderators (moderator_subject);
--
--CREATE INDEX idx_tmodlog_creator ON tip_moderation_log (creator_subject, id);
--
--CREATE INDEX idx_event_outbox_due ON event_outbox(sent_at, rejected_at, next_attempt_at);
ddiff --git a/test/helpers/db.js b/test/helpers/db.js
iindex 7dfdaa5..ce1f289 100644
---- a/test/helpers/db.js
++++ b/test/helpers/db.js
@@@ -27,7 +27,8 @@ const valkeyAvailable = () => !!process.env.OV_TEST_VALKEY_URL;
async function testDb({ store = process.env.TIPS_TEST_STORE || 'pglite', max = 4 } = {}) {
if (store !== 'pg') {
const db = createDb({ pglite: true, service: 'tips-test', log: quiet });
-- await db.migrate({ dir: MIGRATIONS, log: quiet });
++ // windowDays 0: a test wants the final schema, not a contract held for its N-1 window.
++ await db.migrate({ dir: MIGRATIONS, log: quiet, windowDays: 0 });
return { db, store: 'pglite', close: () => db.close().catch(() => {}) };
}
if (!pgAvailable()) throw new Error('store pg needs OV_TEST_PG_URL and OV_TEST_PG_DIRECT_URL (openvibe-sdk scripts/test-services.sh up)');
@@@ -62,7 +63,7 @@ async function testDb({ store = process.env.TIPS_TEST_STORE || 'pglite', max = 4
let db;
try {
const ownerDb = createDb({ url: as(process.env.OV_TEST_PG_DIRECT_URL, owner), service: 'tips-test-migrate', max: 1, log: quiet });
-- try { await ownerDb.migrate({ dir: MIGRATIONS, log: quiet }); } finally { await ownerDb.close(); }
++ try { await ownerDb.migrate({ dir: MIGRATIONS, log: quiet, windowDays: 0 }); } finally { await ownerDb.close(); }
db = createDb({ url: as(process.env.OV_TEST_PG_URL, name), service: 'tips-test', max, log: quiet });
} catch (e) { await drop().catch(() => {}); throw e; } // a failed setup leaves nothing behind
return {
ddiff --git a/test/import.test.js b/test/import.test.js
ddeleted file mode 100644
iindex 4e80699..0000000
---- a/test/import.test.js
++++ /dev/null
@@@ -1,191 +0,0 @@
--'use strict';
--// Import from a Live snapshot, linked to Billing's imported journal, with the reconciliation:
--// creator totals must equal Billing's. Fixtures follow Live's schema and Billing's importer keys.
--const assert = require('assert');
--const path = require('path');
--const Database = require('better-sqlite3');
--const { boot, check, done } = require('./helpers/app');
--
--function liveFixture(file) {
-- const db = new Database(file);
-- db.exec(`
-- CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, display_name TEXT, openvibe_bucks_balance INTEGER DEFAULT 0);
-- CREATE TABLE transactions (id INTEGER PRIMARY KEY, from_user_id INTEGER, to_user_id INTEGER, stream_id INTEGER, amount INTEGER NOT NULL,
-- type TEXT NOT NULL, status TEXT DEFAULT 'completed', message TEXT, paypal_transaction_id TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);
-- CREATE TABLE chat_messages (id INTEGER PRIMARY KEY, stream_id INTEGER, channel_user_id INTEGER, user_id INTEGER, username TEXT, message TEXT,
-- message_type TEXT DEFAULT 'chat', metadata TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP); -- Live's column name
-- CREATE TABLE donation_goals (id INTEGER PRIMARY KEY, user_id INTEGER, title TEXT, target_amount INTEGER, current_amount INTEGER DEFAULT 0,
-- is_active INTEGER DEFAULT 1, image_url TEXT, media_type TEXT, reached_at DATETIME, sort_order INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);
-- CREATE TABLE site_settings (key TEXT PRIMARY KEY, value TEXT);
-- `);
-- const u = db.prepare('INSERT INTO users (id, username, display_name) VALUES (?, ?, ?)');
-- [[1, 'alex', 'Alex'], [2, 'viewer', 'Viewer'], [3, 'ghost', 'Ghost'], [4, 'held', 'Held'], [5, 'bob', 'Bob']].forEach((r) => u.run(...r));
-- db.prepare("INSERT INTO site_settings (key, value) VALUES ('stats_vibes_reset_at', '2026-01-01T00:00:00.000Z')").run();
-- const t = db.prepare('INSERT INTO transactions (id, from_user_id, to_user_id, stream_id, amount, type, status, message, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
-- t.run(1, 2, 1, 7, 100, 'donation', 'completed', 'test era', '2025-12-01 10:00:00');
-- t.run(2, 2, 1, 7, 500, 'donation', 'completed', 'nice', '2026-02-01 10:00:00');
-- t.run(3, 3, 1, null, 200, 'donation', 'completed', null, '2026-02-02 10:00:00');
-- t.run(4, 2, 4, null, 300, 'donation', 'completed', null, '2026-02-02 11:00:00');
-- t.run(5, 2, 1, null, 50, 'donation', 'failed', null, '2026-02-02 12:00:00');
-- t.run(6, 2, 1, 7, 250, 'donation', 'completed', 'Media request: Song', '2026-02-03 10:00:00');
-- t.run(7, 1, 2, null, 250, 'refund', 'completed', 'Refund: Song', '2026-02-04 10:00:00');
-- t.run(8, null, 5, null, 1000, 'donation', 'completed', 'PowerChat tip via site account ($10.00)', '2026-02-05 12:00:00');
-- t.run(9, null, 2, null, 1000, 'purchase', 'completed', null, '2026-01-20 12:00:00');
-- const c = db.prepare("INSERT INTO chat_messages (id, channel_user_id, username, message, message_type, metadata, timestamp) VALUES (?, ?, ?, ?, 'donation', ?, ?)");
-- c.run(1, 5, 'Fan', 'Fan tipped 1000 Vibes (PowerChat)', JSON.stringify({ kind: 'donation', amount: 1000, message: '', username: 'Fan', source: 'powerchat' }), '2026-02-05 12:00:30');
-- c.run(2, 1, 'Direct', 'Direct tipped 700 Vibes (PowerChat)', JSON.stringify({ kind: 'donation', amount: 700, message: 'love it', username: 'Direct', source: 'powerchat' }), '2026-02-06 09:00:00');
-- c.run(3, 1, 'Viewer', 'Viewer donated 500 Vibes: nice', JSON.stringify({ kind: 'donation', amount: 500, username: 'Viewer' }), '2026-02-01 10:00:01');
-- const g = db.prepare('INSERT INTO donation_goals (id, user_id, title, target_amount, current_amount, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)');
-- g.run(1, 1, 'Synth', 5000, 1200, 1, '2026-01-10 10:00:00');
-- g.run(2, 4, 'Held goal', 100, 0, 1, '2026-01-10 10:00:00');
-- return db;
--}
--
--/** The rows OpenVibe.Billing's importer writes for that snapshot (server/importer/live.js keys). */
--function billingFixture(file, subjects, { withNative = false } = {}) {
-- const db = new Database(file);
-- db.exec(`CREATE TABLE transactions (id TEXT PRIMARY KEY, type TEXT NOT NULL, status TEXT NOT NULL, idempotency_key TEXT UNIQUE, reverses_txn TEXT,
-- test INTEGER NOT NULL DEFAULT 0, from_subject TEXT, to_subject TEXT, provider TEXT, receipt_ref TEXT, metadata TEXT, created_at TEXT)`);
-- const ins = db.prepare('INSERT INTO transactions (id, type, status, idempotency_key, reverses_txn, test, from_subject, to_subject, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
-- const live = [
-- [1, 2, 1, 100, 'donation', 'completed', true], [2, 2, 1, 500, 'donation', 'completed'], [3, 3, 1, 200, 'donation', 'completed'],
-- [4, 2, 4, 300, 'donation', 'completed'], [5, 2, 1, 50, 'donation', 'failed'], [6, 2, 1, 250, 'donation', 'completed'],
-- [7, 1, 2, 250, 'refund', 'completed'], [8, null, 5, 1000, 'donation', 'completed'], [9, null, 2, 1000, 'purchase', 'completed'],
-- ];
-- for (const [id, from, to, amount, type, status, test] of live) {
-- const note = status === 'completed' ? null : `status ${status}: no balance moved`;
-- ins.run(`txn_import_${id}`, 'import', 'imported', `import:live:txn:${id}`, null, test ? 1 : 0, from ? subjects[from] || null : null, to ? subjects[to] || null : null,
-- JSON.stringify({ live: { id, type, status, amount, from_user_id: from, to_user_id: to }, note }), '2026-09-22T00:00:00Z');
-- }
-- if (withNative) {
-- ins.run('txn_native_1', 'donation', 'settled', 'api:svc:live:k1', null, 0, subjects[2], subjects[1], JSON.stringify({ kind: 'donation', amount_bits: 40 }), '2026-09-22T01:00:00Z');
-- ins.run('txn_native_test', 'donation', 'settled', 'api:svc:live:k2', null, 1, subjects[2], subjects[1], JSON.stringify({ kind: 'donation', amount_bits: 999 }), '2026-09-22T01:00:00Z');
-- }
-- return db;
--}
--
--(async () => {
-- const t = await boot();
-- const { domain } = t;
-- const { importLive, billingSnapshotSource } = require('../server/importer/live');
-- const { createIdentity } = require('../server/network');
-- const identity = createIdentity(t.config);
-- const users = { 1: t.network.newUser('alex', 1), 2: t.network.newUser('viewer', 2), 5: t.network.newUser('bob', 5) };
-- const subjects = { 1: users[1].subject, 2: users[2].subject, 5: users[5].subject };
-- const live = liveFixture(path.join(t.dir, 'live-snapshot.db'));
-- const bdb = billingFixture(path.join(t.dir, 'billing-snapshot.db'), subjects, { withNative: true });
-- const quiet = { log() {} };
-- const run = (opts = {}) => importLive(domain, { live, billingSource: billingSnapshotSource(bdb), resolveLiveUsers: identity.resolveLiveUsers, log: quiet, ...opts });
--
-- await check('a dry run reports and keeps nothing', async () => {
-- const r = await run({ dryRun: true });
-- assert.strictEqual(r.dry_run, true);
-- assert.ok(r.counts.imported > 0);
-- assert.strictEqual(await t.db.value('SELECT count(*) FROM tip_interactions'), 0);
-- assert.strictEqual(await t.db.value('SELECT count(*) FROM migration_maps'), 0);
-- });
--
-- let report;
-- await check('donations import settled and linked to Billing; failed excluded; unmapped creators held; test era flagged', async () => {
-- report = await run();
-- const byLegacy = (id) => t.db.maybe('SELECT * FROM tip_interactions WHERE legacy_source = $1', [`live:transactions:${id}`]);
-- const two = await byLegacy(2);
-- assert.strictEqual(two.payment_state, 'settled');
-- assert.strictEqual(two.settlement, 'imported');
-- assert.strictEqual(two.billing_txn_id, 'txn_import_2');
-- assert.strictEqual(two.supporter_subject, subjects[2]);
-- assert.strictEqual(two.message, 'nice');
-- assert.strictEqual(two.created_at, '2026-02-01T10:00:00.000Z');
-- assert.strictEqual((await byLegacy(1)).test, true, 'before stats_vibes_reset_at');
-- const ghost = await byLegacy(3);
-- assert.strictEqual(ghost.supporter_subject, null);
-- assert.strictEqual(ghost.supporter_name, 'Ghost');
-- assert.strictEqual((await byLegacy(6)).kind, 'media_request');
-- assert.strictEqual((await byLegacy(8)).funding, 'provider');
-- assert.strictEqual(await byLegacy(4), null, 'held, not dropped');
-- const held = await t.db.maybe("SELECT * FROM migration_maps WHERE source_table = 'transactions' AND source_id = '4'");
-- assert.strictEqual(held.status, 'held');
-- const excluded = await t.db.maybe("SELECT * FROM migration_maps WHERE source_table = 'transactions' AND source_id = '5'");
-- assert.strictEqual(excluded.status, 'excluded');
-- assert.match(excluded.reason, /status failed/);
-- assert.strictEqual(report.counts.linked_to_billing, report.counts.imported);
-- // Imports are history: no effects, no overlay alerts, no events.
-- assert.strictEqual(await t.db.value('SELECT count(*) FROM interaction_effects'), 0);
-- assert.strictEqual(await t.db.value('SELECT count(*) FROM overlay_deliveries'), 0);
-- assert.strictEqual((await t.outboxRows('tips.interaction.ready')).length, 0);
-- });
--
-- await check('the refund nets out against the donation it undoes', async () => {
-- const six = await t.db.maybe("SELECT * FROM tip_interactions WHERE legacy_source = 'live:transactions:6'");
-- assert.strictEqual(six.reversed_bits, 250);
-- assert.strictEqual(six.payment_state, 'reversed');
-- assert.strictEqual(report.counts.refunds_applied, 1);
-- });
--
-- await check('external PowerChat tips: the direct one is imported as EXTERNAL; the site-routed celebration is not counted twice', async () => {
-- const ext = await t.db.maybe("SELECT * FROM tip_interactions WHERE legacy_source = 'live:chat_messages:2'");
-- assert.strictEqual(ext.settlement, 'external');
-- assert.strictEqual(ext.amount, 700);
-- assert.strictEqual(ext.supporter_name, 'Direct');
-- const twin = await t.db.maybe("SELECT * FROM migration_maps WHERE source_table = 'chat_messages' AND source_id = '1'");
-- assert.strictEqual(twin.status, 'excluded');
-- assert.match(twin.reason, /site-routed tip \(Live transaction 8\)/);
-- assert.strictEqual(await t.db.value("SELECT count(*) FROM migration_maps WHERE source_table = 'chat_messages' AND source_id = '3'"), 0, 'on-site donation lines are not PowerChat');
-- });
--
-- await check('goals import with Live\'s current amount carried over (not invented contributions)', async () => {
-- const g = await t.db.maybe("SELECT * FROM tip_goals WHERE legacy_source = 'live:donation_goals:1'");
-- const view = await domain.goals.present(t.db, g);
-- assert.strictEqual(view.current_amount, 1200);
-- assert.strictEqual(view.carried_over_amount, 1200);
-- assert.strictEqual(view.supporters_count, 0);
-- });
--
-- await check('reconciliation: a Billing donation Tips never saw is reported, not absorbed', async () => {
-- const r = report.reconciliation;
-- assert.strictEqual(r.ok, false);
-- assert.deepStrictEqual(r.mismatches, [{ creator: subjects[1], tips: 700, billing: 740, difference: -40 }]);
-- assert.ok(r.rows.find((x) => x.creator === subjects[5] && x.tips === 1000 && x.billing === 1000));
-- });
--
-- await check('once the Billing event arrives, creator totals equal Billing exactly (test money excluded on both sides)', async () => {
-- const ev = t.billing.envelope('billing.transaction.settled', { transaction_id: 'txn_native_1', type: 'donation', test: false, from_subject: subjects[2], to_subject: subjects[1], provider: null, metadata: { kind: 'donation', amount_bits: 40 } }, 'txn_native_1');
-- assert.strictEqual((await t.deliver(ev)).json.outcome, 'recorded');
-- const evTest = t.billing.envelope('billing.transaction.settled', { transaction_id: 'txn_native_test', type: 'donation', test: true, from_subject: subjects[2], to_subject: subjects[1], provider: null, metadata: { kind: 'donation', amount_bits: 999 } }, 'txn_native_test');
-- await t.deliver(evTest);
-- const { reconcile } = require('../server/importer/live');
-- const r = await reconcile(domain, billingSnapshotSource(bdb));
-- assert.strictEqual(r.ok, true, JSON.stringify(r.mismatches));
-- assert.strictEqual((await domain.interactions.totals(t.db, subjects[1])).settled_via_billing, 740);
-- assert.strictEqual((await domain.interactions.totals(t.db, subjects[1])).external, 700);
-- });
--
-- await check('a re-run changes nothing; a newly mapped creator is released from hold', async () => {
-- const before = await t.db.value('SELECT count(*) FROM tip_interactions');
-- const again = await run();
-- assert.strictEqual(again.counts.imported, 0);
-- assert.strictEqual(again.counts.goals, 0);
-- assert.ok(again.counts.unchanged > 0);
-- assert.strictEqual(await t.db.value('SELECT count(*) FROM tip_interactions'), before);
-- const heldUser = t.network.newUser('held', 4);
-- const r = await run();
-- assert.strictEqual(r.counts.released, 1);
-- assert.strictEqual(r.counts.goals, 1);
-- const four = await t.db.maybe("SELECT * FROM tip_interactions WHERE legacy_source = 'live:transactions:4'");
-- assert.strictEqual(four.creator_subject, heldUser.subject);
-- assert.strictEqual(four.billing_txn_id, 'txn_import_4');
-- assert.strictEqual(await t.db.value("SELECT status FROM migration_maps WHERE source_table = 'transactions' AND source_id = '4'"), 'imported');
-- });
--
-- await check('every source row is imported, excluded with a reason, or held — none silently dropped', async () => {
-- const tipRows = live.prepare("SELECT id FROM transactions WHERE type IN ('donation', 'refund')").all().map((x) => String(x.id));
-- const mapped = new Set((await t.db.many("SELECT source_id FROM migration_maps WHERE source_table = 'transactions'")).map((x) => x.source_id));
-- assert.deepStrictEqual(tipRows.filter((id) => !mapped.has(id)), []);
-- const excluded = await t.db.many("SELECT * FROM migration_maps WHERE status = 'excluded'");
-- assert.ok(excluded.every((x) => x.reason));
-- });
--
-- live.close(); bdb.close();
-- await t.close();
-- done();
--})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/migrations.test.js b/test/migrations.test.js
iindex bb5f228..5203d4e 100644
---- a/test/migrations.test.js
++++ b/test/migrations.test.js
@@@ -1,18 +1,15 @@
'use strict';
// migrations/ (ADR-035, ADR-028): the files parse with their phase, apply in order, and apply again
--// without change; the schema carries every column the SQLite release had (nothing lost in the import);
--// every query shape the code runs can be served by an index; and no runtime file loads better-sqlite3.
++// without change; a contract migration drops what an earlier expand left behind; and every query shape
++// the code runs can be served by an index.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
--const Database = require('better-sqlite3');
const { createDb, sql } = require('openvibe-sdk/db');
const { MIGRATIONS } = require('../server/db');
--const { TABLES } = require('../scripts/migrate-to-postgres');
const { check, done } = require('./helpers/app');
const quiet = { log() {} };
--const ROOT = path.join(__dirname, '..');
/** The migration files as the SDK reads them: NNNN_name.sql with a phase header (and after: for a contract). */
function parse(dir) {
@@@ -38,17 +35,24 @@ function parse(dir) {
});
await check('they apply once, in order, and a second run changes nothing', async () => {
-- const first = await db.migrate({ dir: MIGRATIONS, log: quiet });
++ const first = await db.migrate({ dir: MIGRATIONS, log: quiet, windowDays: 0 });
assert.deepStrictEqual(first.applied.map((m) => m.id), parse(MIGRATIONS).map((m) => m.id));
const schema = async () => db.many(sql`SELECT table_name, column_name, data_type, is_nullable, column_default FROM information_schema.columns
WHERE table_schema = 'public' ORDER BY table_name, ordinal_position`);
const before = await schema();
-- const second = await db.migrate({ dir: MIGRATIONS, log: quiet });
++ const second = await db.migrate({ dir: MIGRATIONS, log: quiet, windowDays: 0 });
assert.deepStrictEqual([second.applied, second.held], [[], []]);
assert.deepStrictEqual(await schema(), before, 'the schema is the same after a second run');
assert.strictEqual(await db.value(sql`SELECT count(*) FROM ov_migrations`), parse(MIGRATIONS).length);
});
++ await check('the contract migration drops the legacy import columns and the map table', async () => {
++ assert.deepStrictEqual(await db.many(sql`SELECT table_name FROM information_schema.columns
++ WHERE table_schema = 'public' AND column_name = 'legacy_source'`), []);
++ assert.deepStrictEqual(await db.many(sql`SELECT table_name FROM information_schema.tables
++ WHERE table_schema = 'public' AND table_name = 'migration_maps'`), []);
++ });
++
await check('every table has a primary key; money and time columns have their types', async () => {
const noKey = await db.many(sql`SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'r' AND NOT EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid AND i.indisprimary)`);
@@@ -69,27 +73,6 @@ function parse(dir) {
assert.strictEqual(await type('api_idempotency', 'response'), 'text', 'replayed byte for byte');
});
-- await check('nothing the SQLite release stored is left behind: every source table and column has a target', async () => {
-- const s = new Database(':memory:');
-- s.exec(fs.readFileSync(path.join(__dirname, 'fixtures', 'sqlite-v3-schema.sql'), 'utf8'));
-- const from = (target) => (TABLES[target] && TABLES[target].from) || target;
-- const targets = new Map();
-- for (const r of await db.many(sql`SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name <> 'ov_migrations'`)) {
-- const src = from(r.table_name);
-- if (!targets.has(src)) targets.set(src, new Set());
-- targets.get(src).add(r.column_name);
-- }
-- const missing = [];
-- for (const { name } of s.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all()) {
-- if (name === 'settings') continue; // not carried on purpose: ov_migrations records the schema (0001_initial.sql)
-- if (!targets.has(name)) { missing.push(name); continue; }
-- for (const c of s.prepare(`PRAGMA table_info(${name})`).all()) if (!targets.get(name).has(c.name)) missing.push(`${name}.${c.name}`);
-- }
-- s.close();
-- assert.deepStrictEqual(missing, [], 'no table or column dropped (dropColumns stays empty)');
-- assert.ok(Object.values(TABLES).every((o) => !o.dropColumns), 'no dropColumns');
-- });
--
await check('every query shape the code runs can be served by an index (no sequential scan of its table)', async () => {
// With sequential scans switched off for the plan, a shape that has no usable index still plans a
// Seq Scan (at a prohibitive cost): that is what this looks for.
@@@ -125,7 +108,6 @@ function parse(dir) {
'the weekly prune of stored answers': ['api_idempotency', sql`DELETE FROM api_idempotency WHERE created_at < now()`],
'an erasure scrubbing outbox copies': ['tips_event_outbox', sql`SELECT id FROM tips_event_outbox WHERE envelope->'subject'->>'type' = 'interaction' AND envelope->'subject'->>'id' = ANY('{a}'::text[])`],
'the rejected-events count': ['tips_event_outbox', sql`SELECT count(*) FROM tips_event_outbox WHERE rejected_at IS NOT NULL`],
-- 'a Live goal through the import map': ['migration_maps', sql`SELECT target_id FROM migration_maps WHERE source = 'live' AND source_table = 'donation_goals' AND source_id = '12'`],
};
const scans = [];
for (const [name, [table, q]] of Object.entries(shapes)) {
@@@ -141,21 +123,6 @@ function parse(dir) {
assert.deepStrictEqual(scans, []);
});
-- await check('no runtime file loads better-sqlite3 (only the import tools may)', async () => {
-- const offenders = [];
-- const walk = (dir) => {
-- for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
-- const p = path.join(dir, e.name);
-- if (e.isDirectory()) walk(p);
-- else if (e.name.endsWith('.js') && /require\(['"]better-sqlite3['"]\)|\.prepare\(|db\.transaction\(/.test(fs.readFileSync(p, 'utf8').replace(/(live|bdb|snap)\.prepare\(/g, ''))) offenders.push(path.relative(ROOT, p));
-- }
-- };
-- walk(path.join(ROOT, 'server'));
-- assert.deepStrictEqual(offenders, []);
-- const loaders = fs.readdirSync(path.join(ROOT, 'scripts')).filter((f) => /require\(['"]better-sqlite3['"]\)/.test(fs.readFileSync(path.join(ROOT, 'scripts', f), 'utf8')));
-- assert.deepStrictEqual(loaders, ['import-live.js'], "the Live import reads Live's and Billing's snapshot files; migrate-to-postgres.js reads Tips' through importSqlite");
-- });
--
await db.close();
done();
})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/sqlite-import.test.js b/test/sqlite-import.test.js
ddeleted file mode 100644
iindex 005ba27..0000000
---- a/test/sqlite-import.test.js
++++ /dev/null
@@@ -1,189 +0,0 @@
--'use strict';
--// The one-time import (ADR-035; scripts/migrate-to-postgres.js): a SQLite file with the last SQLite
--// release's schema (test/fixtures/sqlite-v3-schema.sql) and rows of every table goes through
--// openvibe-sdk's importSqlite into the migrated schema: report.ok, every row counted and checksummed,
--// values converted by column type (0/1 → boolean, ISO text → timestamptz, JSON text → jsonb), identities
--// kept and continued, the SQLite file untouched; and the service then serves the imported data.
--const assert = require('assert');
--const crypto = require('crypto');
--const fs = require('fs');
--const os = require('os');
--const path = require('path');
--const { execFileSync } = require('child_process');
--const Database = require('better-sqlite3');
--const { ids } = require('openvibe-contracts');
--const { importSqlite } = require('openvibe-sdk/db');
--const { testDb } = require('./helpers/db');
--const { boot, check, done } = require('./helpers/app');
--const { importOptions } = require('../scripts/migrate-to-postgres');
--
--const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tips-import-'));
--const quiet = { log() {} };
--const creator = ids.newId('user');
--const supporter = ids.newId('user');
--const T = (s) => `2026-09-${s}Z`; // an ISO time, as the SQLite release wrote them
--
--/** A SQLite file as the SQLite release left it: its schema and a few rows in every table. */
--function fixture(file) {
-- const s = new Database(file);
-- s.exec(fs.readFileSync(path.join(__dirname, 'fixtures', 'sqlite-v3-schema.sql'), 'utf8'));
-- const ins = (table, row) => s.prepare(`INSERT INTO ${table} (${Object.keys(row).join(', ')}) VALUES (${Object.keys(row).map(() => '?').join(', ')})`).run(...Object.values(row));
-- ins('settings', { id: 1, schema_version: 3, created_at: T('01T00:00:00.000') });
-- ins('creator_tip_profiles', {
-- creator_subject: creator, handle: 'alex', display_name: 'Alex', page_enabled: 1, accepting: 1, tts_enabled: 0, media_requests_enabled: 1,
-- revision: 4, created_at: T('01T10:00:00.000'), updated_at: T('02T11:30:00.250'),
-- page_settings: '{"goal_amounts":false,"supporters_page":true}', filter_words: '["badword","two words"]', filter_action: 'hold', filter_links: 0,
-- });
-- const tip = (id, fields) => ins('tip_interactions', {
-- id, creator_subject: creator, supporter_subject: supporter, supporter_name: 'Viewer', kind: 'tip', amount: 250, funding: 'credit', settlement: 'billing',
-- payment_state: 'settled', delivery_state: 'delivered', created_at: T('03T12:00:00.000'), updated_at: T('03T12:00:01.000'), settled_at: T('03T12:00:00.500'), ...fields,
-- });
-- tip('tint_01', { billing_txn_id: 'txn_1', request: '{"goal_id":"tgoal_01"}', target: '{"service":"live","type":"stream","id":"42"}', message: 'great stream' });
-- // Two things SQLite took and PostgreSQL does not: a NUL in text, an unpaired surrogate inside JSON.
-- tip('tint_02', { message: 'nul\u0000here', request: '{"legacy_message":"cut \\ud83d","n":1}', billing_txn_id: 'txn_2', payment_state: 'reversed', reversal_txn_ids: '["txn_9"]', reversed_bits: 100, reversed_at: T('04T09:00:00.000'), anonymous: 1, hide_amount: 1, test: 0 });
-- tip('tint_03', { kind: 'paid_message', amount: 500, message: 'hello chat', funding: 'checkout', payment_state: 'pending', delivery_state: 'awaiting_payment',
-- settled_at: null, billing_intent_id: 'pi_1', checkout_url: 'https://checkout.example/pi_1', idempotency_key: 'api:usr:k1', transfer_due: 1, transfer_attempts: 2, next_transfer_at: 1790600000123 });
-- tip('tint_04', { supporter_subject: null, supporter_name: null, erased_at: T('05T08:00:00.000'), moderation: 'hidden', moderated_by: 'filter', filtered: 1, test: 1, origin: 'import',
-- settlement: 'imported', legacy_source: 'live:transactions:7', provider: 'powerchat', provider_ref: 'live-chat:3', request: '{}' });
-- ins('tip_goals', { id: 'tgoal_01', creator_subject: creator, title: 'Desk', target_amount: 1000, status: 'active', opening_amount: 50, created_at: T('02T00:00:00.000'), updated_at: T('02T00:00:00.000'), reached_at: null });
-- ins('tip_goal_contributions', { id: 7, goal_id: 'tgoal_01', interaction_id: 'tint_01', amount: 250, reversed_amount: 0, created_at: T('03T12:00:00.600'), updated_at: T('03T12:00:00.600') });
-- ins('tip_goal_contributions', { id: 12, goal_id: 'tgoal_01', interaction_id: 'tint_02', amount: 250, reversed_amount: 100, created_at: T('03T12:00:00.700'), updated_at: T('04T09:00:00.000') });
-- ins('paid_messages', { id: 'tpm_01', interaction_id: 'tint_01', creator_subject: creator, kind: 'paid_message', text: 'hello', highlight_seconds: 30, status: 'delivered', chat_ref: 'null', test: 0, created_at: T('03T12:00:01.000'), updated_at: T('03T12:00:02.000') });
-- ins('paid_media_requests', { id: 'tmr_01', interaction_id: 'tint_02', creator_subject: creator, url: 'https://youtu.be/x', provider: 'youtube', status: 'accepted', queue_ref: '{"queue_id":5}', created_at: T('03T12:00:01.000'), updated_at: T('03T12:00:02.000') });
-- ins('overlay_configs', { id: 'tovc_01', creator_subject: creator, kind: 'alerts', name: 'Alerts', settings: '{"min_amount":1,"sound_url":null}', revision: 2, created_at: T('02T00:00:00.000'), updated_at: T('02T00:00:00.000') });
-- ins('overlay_tokens', { id: 'tovt_01', creator_subject: creator, token_hash: 'a'.repeat(64), scopes: '["alerts","goals"]', config_id: 'tovc_01', created_by: creator, created_at: T('02T00:00:00.000'), revoked_at: T('06T00:00:00.000') });
-- ins('overlay_deliveries', { seq: 3, id: 'tovd_03', creator_subject: creator, kind: 'alert', interaction_id: 'tint_01', payload: '{"interaction_id":"tint_01","amount":250}', status: 'delivered', sends: 4, dedupe_key: 'alert:tint_01', created_at: T('03T12:00:01.000'), delivered_at: T('03T12:00:01.500'), hidden: 0 });
-- ins('overlay_deliveries', { seq: 9, id: 'tovd_09', creator_subject: creator, kind: 'goal', goal_id: 'tgoal_01', payload: '{"goal":{"id":"tgoal_01"},"by":null}', status: 'pending', dedupe_key: 'goal:tgoal_01:r2', created_at: T('03T12:00:01.000'), hidden: 1 });
-- ins('interaction_effects', { id: 4, interaction_id: 'tint_01', effect: 'chat_line', adapter: 'live-chat', state: 'delivered', attempts: 1, result: '{"ref":{"chat_message_id":1}}', created_at: T('03T12:00:01.000'), updated_at: T('03T12:00:02.000') });
-- ins('interaction_effects', { id: 5, interaction_id: 'tint_01', effect: 'overlay_alert', adapter: 'overlay', state: 'delivered', result: 'null', created_at: T('03T12:00:01.000'), updated_at: T('03T12:00:01.000') });
-- ins('interaction_effects', { id: 6, interaction_id: 'tint_03', effect: 'paid_message', adapter: 'live-chat', state: 'queued', attempts: 2, next_attempt_at: 1790600000999, last_error: 'Live 503', created_at: T('03T12:00:01.000'), updated_at: T('03T12:00:01.000') });
-- ins('migration_maps', { id: 2, source: 'live', source_table: 'transactions', source_id: '7', target_type: 'interaction', target_id: 'tint_04', status: 'imported', run_id: 'timp_1', created_at: T('01T00:00:00.000'), updated_at: T('01T00:00:00.000') });
-- ins('import_runs', { id: 'timp_1', source: 'live', dry_run: 0, started_at: T('01T00:00:00.000'), finished_at: T('01T00:00:05.000'), report: '{"counts":{"imported":1},"holds":[]}' });
-- ins('tip_moderators', { creator_subject: creator, moderator_subject: supporter, name: 'Mod', added_by: 'invite:tmin_1', created_at: T('02T00:00:00.000') });
-- ins('tip_moderator_invites', { id: 'tmin_1', creator_subject: creator, token_hash: 'b'.repeat(64), created_by: creator, created_at: T('02T00:00:00.000'), expires_at: T('09T00:00:00.000'), used_at: T('02T01:00:00.000'), used_by: supporter });
-- ins('tip_moderation_log', { id: 3, creator_subject: creator, interaction_id: 'tint_04', action: 'hidden', by_role: 'moderator', actor: supporter, reason: 'abuse', created_at: T('05T08:00:00.000') });
-- // A stored answer is replayed byte for byte: its text keeps its key order and spacing.
-- ins('api_idempotency', { key: `${supporter}:tip-key-0001`, request_hash: 'h1', method: 'POST', path: '/api/v1/checkout', status: 201, response: '{"z":1,"a":{"b":2}}', created_at: T('03T12:00:00.000') });
-- ins('event_outbox', { id: 1, event_id: 'evt_01', envelope: '{"event_type":"tips.interaction.ready","subject":{"type":"interaction","id":"tint_01"},"payload":{"supporter":null}}', created_at: 1790500000000, attempts: 1, sent_at: 1790500001000, seq: 17 });
-- ins('event_outbox', { id: 2, event_id: 'evt_02', envelope: '{"event_type":"tips.goal.updated","subject":{"type":"goal","id":"tgoal_01"},"payload":{}}', created_at: 1790500002000, attempts: 3, next_attempt_at: 1790500999000, last_error: 'timeout' });
-- ins('idempotency_receipts', { consumer: 'tips-billing', event_id: 'evt_billing_1', processed_at: 1790500000500 });
-- s.close();
-- return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
--}
--
--(async () => {
-- const file = path.join(tmp, 'tips.db');
-- const hash = fixture(file);
--
-- const imported = await testDb({ store: 'pglite' });
-- const db = imported.db;
-- let report;
-- const options = await importOptions(db);
-- const TABLES = options.tables;
-- await check('the importer copies every table, verified by counts and checksums (report.ok)', async () => {
-- report = await importSqlite({ sqlite: file, db, truncate: true, tables: TABLES, log: quiet });
-- assert.strictEqual(report.ok, true, JSON.stringify(report.problems));
-- const rows = Object.fromEntries(report.tables.map((t) => [t.table, t.rows]));
-- assert.deepStrictEqual(rows, {
-- creator_tip_profiles: 1, tip_interactions: 4, tip_goals: 1, tip_goal_contributions: 2, paid_messages: 1, paid_media_requests: 1,
-- overlay_configs: 1, overlay_tokens: 1, overlay_deliveries: 2, interaction_effects: 3, migration_maps: 1, import_runs: 1,
-- tip_moderators: 1, tip_moderator_invites: 1, tip_moderation_log: 1, api_idempotency: 1, tips_event_outbox: 2, tips_event_inbox: 1,
-- });
-- assert.ok(report.tables.every((t) => /^[0-9a-f]{16}$/.test(t.checksum)), 'a checksum per table');
-- const renamed = report.tables.filter((t) => t.source !== t.table).map((t) => `${t.source} → ${t.table}`).sort();
-- assert.deepStrictEqual(renamed, ['event_outbox → tips_event_outbox', 'idempotency_receipts → tips_event_inbox']);
-- assert.strictEqual(crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'), hash, 'the SQLite file is unchanged');
-- });
--
-- await check('values are converted by column type', async () => {
-- const p = await db.one('SELECT * FROM creator_tip_profiles');
-- assert.deepStrictEqual([p.page_enabled, p.tts_enabled, p.filter_links, p.revision], [true, false, false, 4]);
-- assert.deepStrictEqual(p.page_settings, { goal_amounts: false, supporters_page: true });
-- assert.deepStrictEqual(p.filter_words, ['badword', 'two words']);
-- assert.strictEqual(p.updated_at, T('02T11:30:00.250'), 'the same ISO time the API sent before');
-- const i = await db.one("SELECT * FROM tip_interactions WHERE id = 'tint_01'");
-- assert.deepStrictEqual(i.request, { goal_id: 'tgoal_01' });
-- assert.deepStrictEqual(i.target, { service: 'live', type: 'stream', id: '42' });
-- assert.deepStrictEqual([i.test, i.anonymous, i.transfer_due, i.amount], [false, false, false, 250]);
-- assert.deepStrictEqual(i.reversal_txn_ids, []);
-- const r = await db.one("SELECT * FROM tip_interactions WHERE id = 'tint_02'");
-- assert.deepStrictEqual([r.anonymous, r.hide_amount, r.reversed_bits, r.reversal_txn_ids], [true, true, 100, ['txn_9']]);
-- assert.strictEqual(r.message, 'nulhere', 'a NUL is dropped (PostgreSQL text cannot hold it)');
-- assert.deepStrictEqual(r.request, { legacy_message: 'cut \ufffd', n: 1 }, 'an unpaired surrogate becomes U+FFFD (jsonb refuses it)');
-- assert.deepStrictEqual(options.cleaned().sort((a, b) => a.column.localeCompare(b.column)),
-- [{ column: 'tip_interactions.message', values: 1 }, { column: 'tip_interactions.request', values: 1 }], 'and the report says where');
-- const pending = await db.one("SELECT * FROM tip_interactions WHERE id = 'tint_03'");
-- assert.deepStrictEqual([pending.transfer_due, pending.transfer_attempts, pending.next_transfer_at, pending.settled_at], [true, 2, 1790600000123, null]);
-- const erased = await db.one("SELECT * FROM tip_interactions WHERE id = 'tint_04'");
-- assert.deepStrictEqual([erased.test, erased.filtered, erased.erased_at, erased.target], [true, true, T('05T08:00:00.000'), null]);
-- assert.strictEqual(await db.value("SELECT chat_ref FROM paid_messages WHERE id = 'tpm_01'"), null, "JSON text 'null' → NULL (no reference)");
-- assert.deepStrictEqual(await db.value("SELECT queue_ref FROM paid_media_requests WHERE id = 'tmr_01'"), { queue_id: 5 });
-- assert.deepStrictEqual((await db.many('SELECT id, result FROM interaction_effects ORDER BY id')).map((e) => [e.id, e.result]),
-- [[4, { ref: { chat_message_id: 1 } }], [5, null], [6, null]]);
-- assert.deepStrictEqual(await db.value("SELECT scopes FROM overlay_tokens"), ['alerts', 'goals']);
-- assert.deepStrictEqual((await db.many('SELECT seq, hidden, test FROM overlay_deliveries ORDER BY seq')).map((d) => [d.seq, d.hidden, d.test]), [[3, false, false], [9, true, false]]);
-- assert.strictEqual(await db.value('SELECT response FROM api_idempotency'), '{"z":1,"a":{"b":2}}', 'a stored answer stays byte for byte');
-- assert.strictEqual(await db.value('SELECT dry_run FROM import_runs'), false);
-- const out = await db.many('SELECT id, envelope, sent_at, next_attempt_at FROM tips_event_outbox ORDER BY id');
-- assert.deepStrictEqual(out.map((o) => [o.id, o.envelope.event_type, o.sent_at, o.next_attempt_at]), [[1, 'tips.interaction.ready', 1790500001000, 0], [2, 'tips.goal.updated', null, 1790500999000]]);
-- assert.deepStrictEqual(await db.one('SELECT * FROM tips_event_inbox'), { consumer: 'tips-billing', event_id: 'evt_billing_1', processed_at: 1790500000500 });
-- });
--
-- await check('identities keep their values and continue after the imported maximum', async () => {
-- const at = new Date().toISOString();
-- assert.strictEqual(await db.value(`INSERT INTO overlay_deliveries (id, creator_subject, kind, payload, status, dedupe_key, created_at)
-- VALUES ('tovd_new', $1, 'alert', '{}', 'pending', 'alert:new', $2) RETURNING seq`, [creator, at]), 10, 'the SSE ids go on from 9');
-- assert.strictEqual(await db.value(`INSERT INTO interaction_effects (interaction_id, effect, adapter, state, created_at, updated_at)
-- VALUES ('tint_02', 'chat_line', 'test', 'queued', $1, $1) RETURNING id`, [at]), 7);
-- assert.strictEqual(await db.value(`INSERT INTO tip_goal_contributions (goal_id, interaction_id, amount, created_at, updated_at)
-- VALUES ('tgoal_01', 'tint_03', 5, $1, $1) RETURNING id`, [at]), 13);
-- assert.strictEqual(await db.value("INSERT INTO tips_event_outbox (event_id, envelope, created_at) VALUES ('evt_03', '{}', 1) RETURNING id"), 3);
-- });
--
-- await check('a rehearsal can be repeated (truncate), and JSON null verifies without a mapping', async () => {
-- const again = await importSqlite({ sqlite: file, db, truncate: true, tables: TABLES, log: quiet });
-- assert.strictEqual(again.ok, true, JSON.stringify(again.problems));
-- // openvibe-sdk ≥ 0.18 counts JSON null and SQL NULL as equal in verification, so the import is verified
-- // even without Tips' own 'null' mapping (which stays: it stores SQL NULL, not a JSON null).
-- const plain = await importSqlite({ sqlite: file, db, truncate: true, tables: { ...TABLES, paid_messages: {} }, only: ['paid_messages', 'tip_interactions', 'creator_tip_profiles'], log: quiet });
-- assert.strictEqual(plain.ok, true, JSON.stringify(plain.problems));
-- await importSqlite({ sqlite: file, db, truncate: true, tables: TABLES, log: quiet });
-- });
--
-- await check('the service serves the imported data as it did from SQLite', async () => {
-- const t = await boot({ db });
-- const who = { subject: supporter, username: 'viewer' };
-- const r = await t.call('GET', '/api/v1/interactions', { user: who });
-- assert.strictEqual(r.status, 200, r.text);
-- const byId = Object.fromEntries(r.json.interactions.map((i) => [i.id, i]));
-- assert.deepStrictEqual(Object.keys(byId).sort(), ['tint_01', 'tint_02', 'tint_03']);
-- assert.strictEqual(byId.tint_01.created_at, T('03T12:00:00.000'));
-- assert.strictEqual(byId.tint_01.payment.settled_at, T('03T12:00:00.500'));
-- assert.deepStrictEqual(byId.tint_02.payment.reversal_txn_ids, ['txn_9']);
-- assert.strictEqual(byId.tint_02.public.supporter_name, 'Anonymous');
-- const goal = (await t.call('GET', '/api/v1/goals/tgoal_01', { user: { subject: creator, username: 'alex' } })).json.goal;
-- assert.strictEqual(goal.current_amount, 50 + 250 + 150, 'opening amount plus what the contributions keep');
-- const replay = await t.call('POST', '/api/v1/checkout', { user: who, key: 'tip-key-0001', body: {} });
-- assert.strictEqual(replay.status, 422, 'a different request under a stored key is refused');
-- assert.strictEqual(await t.db.value('SELECT count(*) FROM tips_event_inbox'), 1);
-- await t.close();
-- });
--
-- await check('scripts/migrate-to-postgres.js: OK exits 0; a column the schema lacks is a problem and exits 1', async () => {
-- const out = execFileSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'migrate-to-postgres.js'), '--pglite', '--sqlite', file], { encoding: 'utf8' });
-- assert.match(out, /→ pglite \(\d+ ms\): OK/);
-- assert.match(out, /tips_event_outbox\s+2 rows\s+[0-9a-f]{16}\s+\(from event_outbox\)/);
-- assert.match(out, /cleaned: tip_interactions\.message: 1 value\(s\) with a NUL or an unpaired surrogate/);
-- const extra = path.join(tmp, 'extra.db');
-- fs.copyFileSync(file, extra);
-- const s = new Database(extra); s.exec('ALTER TABLE tip_goals ADD COLUMN mystery TEXT'); s.close();
-- let code = 0; let text = '';
-- try { execFileSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'migrate-to-postgres.js'), '--pglite', '--sqlite', extra], { encoding: 'utf8' }); } catch (e) { code = e.status; text = e.stdout; }
-- assert.strictEqual(code, 1);
-- assert.match(text, /problem: tip_goals: source columns with no target column .*mystery/);
-- });
--
-- await imported.close();
-- fs.rmSync(tmp, { recursive: true, force: true });
-- done();
--})().catch((e) => { console.error(e); process.exit(1); });