mirror of
https://github.com/block/buzz.git
synced 2026-11-12 20:31:12 +00:00
## What This is phase 1 of 2 for retiring the `events_created_at_floor` deferred constraint trigger and its `events_created_at_floor_guard()` function (migration 0021). The invariant now holds in the app writers. **The trigger, function and `buzz.created_at_floor` GUC arming all stay** as the backstop while pods run mixed versions. A later PR drops them. One helper, `replica_fence::guard_event_created_at_floor(&mut AdmittedTx, created_at, channel_id)`, runs on the admitted write connection. If `created_at < clock_timestamp() - CREATED_AT_FLOOR_SECS`, it rejects the write with the typed, non-retryable `DbError::CreatedAtFloorRejected`. It uses the same 960 s constant as the GUC and the fence wall. Channel-less rows skip it and cost no query. **Why checking at insert time is enough:** the fence wall is `min(oldest_xact_start, S) - floor - 5s`. A writer transaction that is open during the probe's activity scan is bounded by its own `xact_start`. A transaction that starts after the scan samples a clock later than `S`. So any `clock_timestamp()` sample taken inside the writing transaction is as strong as the trigger's commit-time sample. This needs no commit hook, no candidate-row tracking and no ordering against the TTL refresh, and `AdmittedTx::commit` is unchanged. | Site | Check placement | |---|---| | `store/event.rs` `insert_event_on` | after the `ON CONFLICT DO NOTHING` INSERT, only when a row was inserted, before follow-ups | | `store/event.rs` `insert_event_with_thread_metadata_tx` | after the INSERT, only when a row was inserted, before follow-ups and thread metadata | | `store/replaceable.rs` parameterized replacement | after the `ON CONFLICT DO NOTHING` INSERT, only when a row was inserted, inside the supersede savepoint (a rejection rolls back to it and restores the old head) | | `store/replaceable.rs` addressable replacement | after the `ON CONFLICT DO NOTHING` INSERT, only when a row was inserted, before follow-ups (the caller's rollback restores the old head) | | `store/channel_members.rs` `LockedMemberSnapshot::replace_member_event` | before the old head is superseded | **Writer inventory:** there are 7 production `INSERT INTO events` functions. The other two are structurally `channel_id = NULL`: `push.rs` `accept_lease_event` (kind 30350) and `relay_members.rs` `publish_nip43_membership_locked` (kind 13534). Both write a literal `NULL` for `channel_id` in their INSERT SQL, and the policy exempts them only while that NULL sits at the `channel_id` position. No production UPDATE writes `events.created_at` or `events.channel_id`, and the events `ON CONFLICT` clauses are `DO NOTHING`. **Client-visible outcome is unchanged.** The trigger's SQLSTATE 23514 surfaced as a generic DB error, which the relay maps to `IngestError::Internal` (WS `OK false` / HTTP 500). The new variant follows the same path. Relay mappings are untouched. **`scripts/seed-admin-dashboard.sh`** writes a channel event with `created_at = 1790000000` through raw psql, and it accepted any `PGHOST`. It now refuses non-local hosts (`localhost`, `127.0.0.1`, `::1` and Unix socket paths are still allowed), a non-empty `PGHOSTADDR` or `PGSERVICE`, a multi-host `PGHOST`, and any `PGDATABASE` that is not a plain name (psql treats URI and `key=value` `-d` values as connection strings that override `-h`). Both psql paths pass `-X`, so a psqlrc can't `\connect` elsewhere after the guard. ## Tests There are eleven new PG tests in `store::event_floor_postgres_tests`. Each runs in its own per-test database with `ALTER TABLE events DISABLE TRIGGER events_created_at_floor`, and a `pg_partition_tree` check asserts that every partition clone is disabled, so the trigger can't hide a missing app check. - `insert_event_floor_rejects_old_and_accepts_fresh`, `thread_metadata_event_floor_rejects_old_and_accepts_fresh`, `parameterized_event_floor_rejects_old_and_accepts_fresh`, `addressable_event_floor_rejects_old_and_accepts_fresh`, `member_snapshot_event_floor_rejects_old_and_accepts_fresh`: each rejects a too-old event with the typed error, confirms no row was written, then accepts a fresh event. - `insert_event_floor_accepts_expired_duplicate`, `thread_metadata_event_floor_accepts_expired_duplicate`: replaying an already-stored event whose `created_at` is now below the floor returns `Duplicate` (inserted = false), leaves one row and runs no thread follow-ups. - `parameterized_replay_of_soft_deleted_aged_event_is_a_duplicate`, `addressable_replay_of_soft_deleted_aged_event_is_a_duplicate`: replaying a replacement whose stored row is soft-deleted and has aged below the floor returns a duplicate, not a floor rejection, as the trigger did (it never fired on `ON CONFLICT`). - `cluster_global_event_floor_held_transaction_cannot_write_below_probe_wall`: runs a real heartbeat probe while a writer transaction is open. It asserts that the wall is bounded by that writer's `xact_start`, that a below-wall event is rejected, and that a near-floor event sampled after the probe lands above the wall. - `event_floor_samples_statement_clock_after_transaction_start`: waits on the database until a boundary timestamp that was valid at BEGIN expires (normally ≤1.1 s), so `now()` would pass where `clock_timestamp()` fails. Source policy (`observability_source.rs`): every channel-bearing production events INSERT in any crate's `src` must call the helper, no production UPDATE may assign `created_at` or `channel_id` (including `ONLY`, schema-qualified, aliased and row-constructor forms; quoted identifiers are normalized), the helper must use `clock_timestamp()` and the shared constant, and the seed script must refuse a remote host. Negative fixtures cover each rule. Each check was confirmed to fail when its fix is removed. All of these mutants failed on assertions: - removing the helper from all five writers, which failed all seven PG tests - switching `clock_timestamp()` to `now()`, which failed both the clock PG test and the clock policy - removing one call - adding a `created_at` UPDATE - disabling the policy self-test - removing the seed guard - (review round 2) restoring the pre-INSERT check placement, which failed both duplicate-replay tests; the round-1 UPDATE matcher, quoted-identifier normalization, buzz-db-only scan, NIP-43 bind exemption, process-wide canvas base and each seed override were each restored and failed their tests - (review round 3) restoring the round-2 seed script, which failed the extended seed test on its URI and key/value `PGDATABASE` cases; restoring the script without `-X`, which failed the control's `-X` assertion **Fixture changes:** about 40 existing buzz-db PG tests seeded historical rows (fixed old timestamps) through the serving writers. The trigger never fired on them because the test pool is unarmed. They now seed through a `#[cfg(test)]` raw-SQL helper, `test_support::insert_historical_event` (and `insert_historical_event_with_thread_metadata` for thread fixtures), which keeps admission, follow-ups and mentions. The canvas CAS fixtures keep their logical seconds and ties but each test passes its own fresh base. The existing trigger backstop tests still exercise raw armed INSERT/UPDATE plus COMMIT returning 23514. The new PG tests are selected by the `postgres-ci` filter `(test(/postgres_tests::/) or binary(/^postgres_/)) and not test(/(^|::)external_infra[^:]*::/)`. The `cluster_global_` test also joins the existing serial group. ## Behavior notes - Prepared transactions (2PC) are covered only by the retained trigger, which runs at PREPARE. The insert-time app guard runs earlier, and no app writer issues `PREPARE TRANSACTION`. The comment in `replica_fence.rs` now says so, so the trigger drop has to keep this case covered. - One extra round trip for each newly inserted channel event. Duplicate replays add none. - Ordinary and thread event writes check the floor only for newly inserted rows, before follow-ups, mentions or thread metadata. Replaying an already-stored id stays `Duplicate` even when its timestamp has aged below the floor, as with the trigger. (Internal writers such as the admin outbox worker do replay relay-signed events with their original `created_at`.) A too-old new row returns the non-retryable `CreatedAtFloorRejected`, and the owning transaction rolls back; every current caller propagates the error and drops the transaction. The transaction-composition seam documents that contract. - Replacement writers check after their INSERT, only when it inserted a row, so a replay of any stored id (live or soft-deleted) stays a duplicate. The locked roster writer checks before the old head is superseded. - The trigger is stricter for a row that ages below the floor between the app check and COMMIT. The proof above doesn't need that strictness, and phase 2 gives it up. ## What got simpler The NIP-43 insert drops a bind for a literal `NULL`, the floor and follow-up policies share one workspace walk, and the canvas fixtures lose their process-wide `OnceLock`. No commit-time machinery was added: no candidate tracking, commit hook, cleanup protocol or new lock. Test-only historical writes now go through one helper instead of being scattered through the serving APIs. Nothing could be deleted in this phase because mixed-version pods still rely on the trigger. **Phase 2** drops the trigger and function, the GUC arming, `verify_floor_guard_catalog` / `verify_floor_guard_behavior` and the schema reconciliation entries. Its deploy gate: the whole fleet on this binary, no rollback expected, and the replica breaker held closed through the drop. **Identified debt (pre-existing, outside this diff):** three `buzz-relay` `handlers::group_state_postgres_tests::*` tests fail with `ensure community: Sqlx(PoolTimedOut)` on the Blox host. They fail the same way on a clean checkout of the base (`state.rs` test pool, 100 ms acquire timeout). `writer_pool_rejects_non_read_committed_database_default` and `writer_pool_metrics_stop_after_session_timeout_setup_failure` hang on that host at base too. ## Verification Head `b046df76d` (after the placement fix and main's newer test fixtures): on Blox, fmt, clippy and buzz-db unit (241/241) pass, and a filtered buzz-db PG lane over the floor tests and every converted fixture passes 88/88. CI is running at this head. Earlier full gate, at tree `67c4fe5e1` + this diff, before the placement fix: | Gate | Result | |---|---| | `cargo fmt --all -- --check` | pass | | `cargo clippy --workspace --all-targets -- -D warnings` | pass | | `cargo nextest run -p buzz-db --lib` | 167/167 | | `cargo nextest run -p buzz-db --test observability_source` | 22/22 | | `scripts/test-postgres-test-discovery.sh` | pass | | `scripts/postgres-test-run.sh -p buzz-db` (excluding the two host hangs) | 517/517 | | `scripts/postgres-test-run.sh -p` buzz-admin / audit / deletion / push-gateway / search / workflow | all rc 0 (4, 7, 11, 11, 20, 2) | | `scripts/postgres-test-run.sh -p buzz-relay` | 402 pass, 3 fail (the pre-existing `PoolTimedOut` failures above) | Local gates used a reverted `rustls` lock bump, because the build mirror lacks 0.23.46. The committed `Cargo.lock` is unchanged, and CI uses the real lock. --------- Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
408 lines
26 KiB
Bash
Executable File
408 lines
26 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Seed deterministic moderation reports and product feedback for local dashboard review.
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|
cd "${REPO_ROOT}"
|
|
|
|
if [[ -f ".env" ]]; then
|
|
set -o allexport
|
|
# shellcheck disable=SC1091
|
|
source .env
|
|
set +o allexport
|
|
fi
|
|
|
|
export PGHOST="${PGHOST:-localhost}"
|
|
export PGPORT="${PGPORT:-5432}"
|
|
export PGUSER="${PGUSER:-buzz}"
|
|
export PGPASSWORD="${PGPASSWORD:-buzz_dev}"
|
|
export PGDATABASE="${PGDATABASE:-buzz}"
|
|
|
|
# This raw fixture writer is unarmed and must never target a live replica fence.
|
|
if [[ -n "${PGHOSTADDR:-}" || -n "${PGSERVICE:-}" || "${PGHOST}" == *,* ]]; then
|
|
echo "error: admin seed requires a local PostgreSQL target without PGHOSTADDR, PGSERVICE or multiple hosts" >&2
|
|
exit 1
|
|
fi
|
|
case "${PGHOST}" in
|
|
localhost|127.0.0.1|::1|/*) ;;
|
|
*)
|
|
echo "error: admin seed requires a local PostgreSQL target (PGHOST=${PGHOST})" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
# psql -d treats URI and key=value values as full connection strings that
|
|
# override -h, so accept only a plain database name.
|
|
if [[ ! "${PGDATABASE}" =~ ^[A-Za-z0-9_-]+$ ]]; then
|
|
echo "error: admin seed requires a local PostgreSQL target with a plain PGDATABASE name" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# -X skips psqlrc, which runs after this guard and could \connect elsewhere.
|
|
if command -v psql >/dev/null 2>&1; then
|
|
run_psql() {
|
|
PGPASSWORD="${PGPASSWORD}" psql -X -h "${PGHOST}" -p "${PGPORT}" \
|
|
-U "${PGUSER}" -d "${PGDATABASE}" "$@"
|
|
}
|
|
elif docker exec buzz-postgres psql --version >/dev/null 2>&1; then
|
|
run_psql() {
|
|
docker exec -i -e PGPASSWORD="${PGPASSWORD}" buzz-postgres \
|
|
psql -X -U "${PGUSER}" -d "${PGDATABASE}" "$@"
|
|
}
|
|
else
|
|
echo "error: neither psql nor buzz-postgres docker psql is available" >&2
|
|
exit 1
|
|
fi
|
|
|
|
community_id="$(run_psql -At -v ON_ERROR_STOP=1 -c "
|
|
SELECT id
|
|
FROM communities
|
|
WHERE lower(host) IN ('localhost:3000', 'localhost', '127.0.0.1:3000', '127.0.0.1')
|
|
ORDER BY CASE lower(host)
|
|
WHEN 'localhost:3000' THEN 1
|
|
WHEN 'localhost' THEN 2
|
|
WHEN '127.0.0.1:3000' THEN 3
|
|
ELSE 4
|
|
END
|
|
LIMIT 1
|
|
")"
|
|
if [[ -z "${community_id}" ]]; then
|
|
echo "error: local community is missing; run just setup first" >&2
|
|
exit 1
|
|
fi
|
|
|
|
fixture_hash() {
|
|
if command -v sha256sum >/dev/null 2>&1; then
|
|
sha256sum "$1" | awk '{print $1}'
|
|
else
|
|
shasum -a 256 "$1" | awk '{print $1}'
|
|
fi
|
|
}
|
|
|
|
fixture_size() {
|
|
wc -c < "$1" | awk '{print $1}'
|
|
}
|
|
|
|
upload_fixture() {
|
|
local path="$1" hash="$2" extension="$3" mime="$4" dimensions="$5"
|
|
local size sidecar
|
|
size="$(fixture_size "${path}")"
|
|
sidecar="$(printf '{"dim":"%s","blurhash":"","thumb_url":"","ext":"%s","mime_type":"%s","size":%s,"uploaded_at":0}' \
|
|
"${dimensions}" "${extension}" "${mime}" "${size}")"
|
|
docker exec -i buzz-minio mc pipe --quiet --attr "Content-Type=${mime}" \
|
|
"local/${BUZZ_S3_BUCKET:-buzz-media}/${hash}.${extension}" < "${path}"
|
|
printf '%s' "${sidecar}" | docker exec -i buzz-minio mc pipe --quiet \
|
|
--attr "Content-Type=application/json" \
|
|
"local/${BUZZ_S3_BUCKET:-buzz-media}/_meta/${community_id}/${hash}.json"
|
|
}
|
|
|
|
fixture_dir="$(mktemp -d "${TMPDIR:-/tmp}/buzz-admin-feedback.XXXXXX")"
|
|
search_image="${REPO_ROOT}/docs/assets/screenshots/media-comments.png"
|
|
workspace_image="${REPO_ROOT}/docs/assets/screenshots/channel-thread.png"
|
|
quality_image="${REPO_ROOT}/docs/assets/screenshots/channel-agents.png"
|
|
composer_diagnostics="${fixture_dir}/composer-diagnostics.txt"
|
|
workspace_diagnostics="${fixture_dir}/workspace-diagnostics.txt"
|
|
trap 'rm -f "${composer_diagnostics}" "${workspace_diagnostics}"; rmdir "${fixture_dir}"' EXIT
|
|
|
|
printf '%s\n' "buzz feedback diagnostics" "area: composer" \
|
|
"event: resumed_from_sleep" "result: composer_unresponsive" > "${composer_diagnostics}"
|
|
printf '%s\n' "buzz feedback diagnostics" "area: workspace-switching" \
|
|
"from: design" "to: engineering" \
|
|
"result: previous_sidebar_visible_for_one_frame" > "${workspace_diagnostics}"
|
|
|
|
search_image_hash="$(fixture_hash "${search_image}")"
|
|
workspace_image_hash="$(fixture_hash "${workspace_image}")"
|
|
quality_image_hash="$(fixture_hash "${quality_image}")"
|
|
composer_diagnostics_hash="$(fixture_hash "${composer_diagnostics}")"
|
|
workspace_diagnostics_hash="$(fixture_hash "${workspace_diagnostics}")"
|
|
|
|
if ! docker exec buzz-minio mc alias set local http://localhost:9000 \
|
|
"${BUZZ_S3_ACCESS_KEY:-buzz_dev}" "${BUZZ_S3_SECRET_KEY:-buzz_dev_secret}" >/dev/null; then
|
|
echo "error: local MinIO is unavailable; run just setup first" >&2
|
|
exit 1
|
|
fi
|
|
|
|
read -r -d '' sql <<'SQL' || true
|
|
DO $$
|
|
DECLARE
|
|
local_community_id UUID;
|
|
BEGIN
|
|
SELECT id INTO local_community_id
|
|
FROM communities
|
|
WHERE lower(host) IN ('localhost:3000', 'localhost', '127.0.0.1:3000', '127.0.0.1')
|
|
ORDER BY CASE lower(host)
|
|
WHEN 'localhost:3000' THEN 1
|
|
WHEN 'localhost' THEN 2
|
|
WHEN '127.0.0.1:3000' THEN 3
|
|
ELSE 4
|
|
END
|
|
LIMIT 1;
|
|
|
|
IF local_community_id IS NULL THEN
|
|
RAISE EXCEPTION 'local community is missing; run just setup first';
|
|
END IF;
|
|
|
|
-- A real channel for the failed-enforcement report below. Kick is only valid
|
|
-- on `event` reports, and the relay rejects it pre-mutation unless the report
|
|
-- carries a channel_id (FK into channels). Seeding this channel makes the
|
|
-- Kick action reachable in the UI and lets the enforcement genuinely run and
|
|
-- fail, so the Cancel & reopen recovery path is exercisable locally.
|
|
INSERT INTO channels (community_id, id, name, created_by)
|
|
VALUES (
|
|
local_community_id,
|
|
'c4a11e10-0000-4000-8000-000000000001',
|
|
'seed-enforcement-channel',
|
|
decode(repeat('3b', 32), 'hex')
|
|
)
|
|
ON CONFLICT (community_id, id) DO UPDATE SET name = EXCLUDED.name;
|
|
|
|
INSERT INTO moderation_reports (
|
|
community_id, id, report_event_id, reporter_pubkey, target_kind,
|
|
target_event_id, target_pubkey, target_blob_sha256, channel_id, report_type, note,
|
|
status, resolved_by, resolved_at, created_at
|
|
) VALUES
|
|
(local_community_id, 'a11d0000-0000-4000-8000-000000000001', decode(repeat('01', 32), 'hex'), decode(repeat('11', 32), 'hex'), 'event', decode(repeat('21', 32), 'hex'), NULL, NULL, NULL, 'spam', 'Repeated unsolicited promotion across several channels.', 'open', NULL, NULL, now() - interval '8 minutes'),
|
|
(local_community_id, 'a11d0000-0000-4000-8000-000000000002', decode(repeat('02', 32), 'hex'), decode(repeat('12', 32), 'hex'), 'pubkey', NULL, decode(repeat('22', 32), 'hex'), NULL, NULL, 'impersonation', 'Profile appears to impersonate a community organizer.', 'open', NULL, NULL, now() - interval '25 minutes'),
|
|
(local_community_id, 'a11d0000-0000-4000-8000-000000000003', decode(repeat('03', 32), 'hex'), decode(repeat('13', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('23', 32), 'hex'), NULL, 'malware', 'Attachment was flagged after download.', 'open', NULL, NULL, now() - interval '50 minutes'),
|
|
(local_community_id, 'a11d0000-0000-4000-8000-000000000004', decode(repeat('04', 32), 'hex'), decode(repeat('14', 32), 'hex'), 'event', decode(repeat('24', 32), 'hex'), NULL, NULL, NULL, 'illegal', 'Contains material that may require legal review.', 'open', NULL, NULL, now() - interval '2 hours'),
|
|
(local_community_id, 'a11d0000-0000-4000-8000-000000000005', decode(repeat('05', 32), 'hex'), decode(repeat('15', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('25', 32), 'hex'), NULL, 'nudity', NULL, 'open', NULL, NULL, now() - interval '5 hours'),
|
|
(local_community_id, 'a11d0000-0000-4000-8000-000000000006', decode(repeat('06', 32), 'hex'), decode(repeat('16', 32), 'hex'), 'pubkey', NULL, decode(repeat('26', 32), 'hex'), NULL, NULL, 'profanity', 'Repeated abusive replies from this account.', 'open', NULL, NULL, now() - interval '12 hours'),
|
|
(local_community_id, 'a11d0000-0000-4000-8000-000000000007', decode(repeat('07', 32), 'hex'), decode(repeat('17', 32), 'hex'), 'event', decode(repeat('27', 32), 'hex'), NULL, NULL, NULL, 'other', 'Does not fit a standard report category.', 'open', NULL, NULL, now() - interval '1 day'),
|
|
(local_community_id, 'a11d0000-0000-4000-8000-000000000008', decode(repeat('08', 32), 'hex'), decode(repeat('18', 32), 'hex'), 'pubkey', NULL, decode(repeat('28', 32), 'hex'), NULL, NULL, 'impersonation', 'Escalated while ownership is verified.', 'escalated', decode(repeat('38', 32), 'hex'), now() - interval '1 hour', now() - interval '2 days'),
|
|
(local_community_id, 'a11d0000-0000-4000-8000-000000000009', decode(repeat('09', 32), 'hex'), decode(repeat('19', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('29', 32), 'hex'), NULL, 'malware', 'Resolved after the attachment was removed.', 'resolved', decode(repeat('39', 32), 'hex'), now() - interval '1 day', now() - interval '3 days'),
|
|
(local_community_id, 'a11d0000-0000-4000-8000-000000000010', decode(repeat('0a', 32), 'hex'), decode(repeat('1a', 32), 'hex'), 'event', decode(repeat('2a', 32), 'hex'), NULL, NULL, NULL, 'other', 'Dismissed after reviewing the surrounding thread.', 'dismissed', decode(repeat('3a', 32), 'hex'), now() - interval '3 days', now() - interval '4 days'),
|
|
(local_community_id, 'a11d0000-0000-4000-8000-000000000011', decode(repeat('0b', 32), 'hex'), decode(repeat('1b', 32), 'hex'), 'event', decode(repeat('2b', 32), 'hex'), NULL, NULL, 'c4a11e10-0000-4000-8000-000000000001', 'spam', 'Event report in a real channel — Kick is offered and the enforcement genuinely runs and fails, exercising the Cancel & reopen recovery path.', 'open', NULL, NULL, now() - interval '3 minutes')
|
|
ON CONFLICT (community_id, report_event_id) DO UPDATE SET
|
|
reporter_pubkey = EXCLUDED.reporter_pubkey,
|
|
target_kind = EXCLUDED.target_kind,
|
|
target_event_id = EXCLUDED.target_event_id,
|
|
target_pubkey = EXCLUDED.target_pubkey,
|
|
target_blob_sha256 = EXCLUDED.target_blob_sha256,
|
|
channel_id = EXCLUDED.channel_id,
|
|
report_type = EXCLUDED.report_type,
|
|
note = EXCLUDED.note,
|
|
status = EXCLUDED.status,
|
|
resolved_by = EXCLUDED.resolved_by,
|
|
resolved_at = EXCLUDED.resolved_at,
|
|
created_at = EXCLUDED.created_at;
|
|
|
|
INSERT INTO product_feedback (
|
|
id, community_id, event_id, submitter_pubkey, category, body, tags,
|
|
event_created_at, received_at
|
|
) VALUES
|
|
('feed0000-0000-4000-8000-000000000001', local_community_id, decode(repeat('41', 32), 'hex'), decode(repeat('51', 32), 'hex'), 'bug', 'Unread counts return after reopening the desktop app.', '[["category", "bug"]]', now() - interval '20 minutes', now() - interval '19 minutes'),
|
|
('feed0000-0000-4000-8000-000000000002', local_community_id, decode(repeat('42', 32), 'hex'), decode(repeat('52', 32), 'hex'), 'needs-work', E'Search needs clearer empty-state guidance.\n', '[["category", "needs-work"], ["imeta", "url http://localhost:3000/media/__SEARCH_IMAGE_HASH__.png", "m image/png", "x __SEARCH_IMAGE_HASH__", "size __SEARCH_IMAGE_SIZE__", "dim 2000x1172", "filename search-empty-state.png"]]', now() - interval '5 hours', now() - interval '5 hours'),
|
|
('feed0000-0000-4000-8000-000000000003', local_community_id, decode(repeat('43', 32), 'hex'), decode(repeat('53', 32), 'hex'), 'praise', 'The new channel switcher feels immediate.', '[["category", "praise"]]', now() - interval '1 day', now() - interval '1 day'),
|
|
('feed0000-0000-4000-8000-000000000004', local_community_id, decode(repeat('44', 32), 'hex'), decode(repeat('54', 32), 'hex'), 'bug', E'The composer froze after waking my laptop. Diagnostics attached.\n[feedback-diagnostics.txt](http://localhost:3000/media/__COMPOSER_DIAGNOSTICS_HASH__.txt)', '[["category", "bug"], ["imeta", "url http://localhost:3000/media/__COMPOSER_DIAGNOSTICS_HASH__.txt", "m text/plain", "x __COMPOSER_DIAGNOSTICS_HASH__", "size __COMPOSER_DIAGNOSTICS_SIZE__", "filename feedback-diagnostics.txt"]]', now() - interval '2 days', now() - interval '2 days'),
|
|
('feed0000-0000-4000-8000-000000000005', local_community_id, decode(repeat('45', 32), 'hex'), decode(repeat('55', 32), 'hex'), NULL, 'General feedback without a selected category or any attachments.', '[]', now() - interval '3 days', now() - interval '3 days'),
|
|
('feed0000-0000-4000-8000-000000000006', local_community_id, decode(repeat('46', 32), 'hex'), decode(repeat('56', 32), 'hex'), 'needs-work', E'The sidebar briefly renders the previous workspace after switching. Screenshot and diagnostics attached.\n\n[feedback-diagnostics.txt](http://localhost:3000/media/__WORKSPACE_DIAGNOSTICS_HASH__.txt)', '[["category", "needs-work"], ["imeta", "url http://localhost:3000/media/__WORKSPACE_IMAGE_HASH__.png", "m image/png", "x __WORKSPACE_IMAGE_HASH__", "size __WORKSPACE_IMAGE_SIZE__", "dim 2000x1172", "filename workspace-flash.png"], ["imeta", "url http://localhost:3000/media/__WORKSPACE_DIAGNOSTICS_HASH__.txt", "m text/plain", "x __WORKSPACE_DIAGNOSTICS_HASH__", "size __WORKSPACE_DIAGNOSTICS_SIZE__", "filename feedback-diagnostics.txt"]]', now() - interval '5 days', now() - interval '5 days'),
|
|
('feed0000-0000-4000-8000-000000000007', local_community_id, decode(repeat('47', 32), 'hex'), decode(repeat('57', 32), 'hex'), 'praise', E'Calls have been much more reliable this week. Attaching the quality graph that made the improvement obvious.\n', '[["category", "praise"], ["imeta", "url http://localhost:3000/media/__QUALITY_IMAGE_HASH__.png", "m image/png", "x __QUALITY_IMAGE_HASH__", "size __QUALITY_IMAGE_SIZE__", "dim 2000x1172", "filename huddle-quality.png"]]', now() - interval '8 days', now() - interval '8 days')
|
|
ON CONFLICT (event_id) DO UPDATE SET
|
|
community_id = EXCLUDED.community_id,
|
|
submitter_pubkey = EXCLUDED.submitter_pubkey,
|
|
category = EXCLUDED.category,
|
|
body = EXCLUDED.body,
|
|
tags = EXCLUDED.tags,
|
|
event_created_at = EXCLUDED.event_created_at,
|
|
received_at = EXCLUDED.received_at,
|
|
status = 'new';
|
|
END $$;
|
|
SQL
|
|
|
|
sql="${sql//__SEARCH_IMAGE_HASH__/${search_image_hash}}"
|
|
sql="${sql//__WORKSPACE_IMAGE_HASH__/${workspace_image_hash}}"
|
|
sql="${sql//__QUALITY_IMAGE_HASH__/${quality_image_hash}}"
|
|
sql="${sql//__COMPOSER_DIAGNOSTICS_HASH__/${composer_diagnostics_hash}}"
|
|
sql="${sql//__WORKSPACE_DIAGNOSTICS_HASH__/${workspace_diagnostics_hash}}"
|
|
sql="${sql//__SEARCH_IMAGE_SIZE__/$(fixture_size "${search_image}")}"
|
|
sql="${sql//__WORKSPACE_IMAGE_SIZE__/$(fixture_size "${workspace_image}")}"
|
|
sql="${sql//__QUALITY_IMAGE_SIZE__/$(fixture_size "${quality_image}")}"
|
|
sql="${sql//__COMPOSER_DIAGNOSTICS_SIZE__/$(fixture_size "${composer_diagnostics}")}"
|
|
sql="${sql//__WORKSPACE_DIAGNOSTICS_SIZE__/$(fixture_size "${workspace_diagnostics}")}"
|
|
|
|
# Multi-community data: two named communities plus filler so the directory
|
|
# pages past 50, named members, one ban and one timeout per named community,
|
|
# extra reports and feedback, and one pre-signed kind-9 message each. The
|
|
# messages were signed once with fixed test keys (secret = 5eed + the member
|
|
# number, zero-padded to 32 bytes), so their signatures stay valid forever.
|
|
read -r -d '' multi_sql <<'SQL' || true
|
|
CREATE TEMP TABLE seed_community (host TEXT, channel_id UUID, author INT, event_id TEXT, sig TEXT, members INT[]);
|
|
INSERT INTO seed_community VALUES
|
|
('localhost:3000', 'c4a11e10-0000-4000-8000-000000000001', 1,
|
|
'0fe4de81609e02e263b268f7069882f2db68dd74c3f62074be31eaab24b0b5fc',
|
|
'63d4a64362bf1f91e56ea1f0001a196d4bc9a0e26712d8083516ffe054665a04fc7c3a13a554d327d0a37e0439bc67d9c0bf2252f595d9b8ce5a09587192e06d',
|
|
'{1,2,5,6}'),
|
|
('beta.localhost:3000', 'c4a11e10-0000-4000-8000-000000000002', 3,
|
|
'50b89df773d8009a88d43ad707ebe0054c7e619a6c13727e0984aca0f930d03c',
|
|
'b7d6c122ff4589498c05b3cabd26c7759d0acc497c747f9331130fd5f65e645203313b7c32ddfd97ac2fde0d2341aa4f189135e2b188d8bd7689a54e7c5787b4',
|
|
'{1,3,5,6}'),
|
|
('gamma.localhost:3000', 'c4a11e10-0000-4000-8000-000000000003', 4,
|
|
'108f54732c522a97e2481b41b8b5a588a99c536ae25d5c0c2e9a1a0c28189f00',
|
|
'839d536f36f998020596c080e2356f881a43af7d046b19b6e8cd214b8c097720cdeea5cc3173a60da8b671846580c4612c5e5fa1e34002ce6c3754bba683fb71',
|
|
'{2,4,5,6}');
|
|
|
|
CREATE TEMP TABLE seed_member (n INT, pubkey BYTEA, display_name TEXT);
|
|
INSERT INTO seed_member VALUES
|
|
(1, decode('7a863b2e9a491321daf769357e398269d074730493cd1ca14812fbfd07cece70', 'hex'), 'Ada Lovelace'),
|
|
(2, decode('ef1e7fbda3e8776e2cadd5ee1fc3cd3728014b18814ea21d15748aca6a1c5c27', 'hex'), 'Grace Hopper'),
|
|
(3, decode('5929d0d248167876c88ded48bfd901389f644f5c47b967cab2c112ef9bf14f1a', 'hex'), 'Linus Pauling'),
|
|
(4, decode('b75653278afb3d4522fdf8bd8d418c2085c2995c273d0357540c21374be953d5', 'hex'), 'Rosalind Franklin'),
|
|
(5, decode('2ad6553f16e40b22045a68ffe6ba31ae5f400470d0e6276f377d50fc23f936a3', 'hex'), 'Spam Account'),
|
|
(6, decode('9eb3e44e539549ff1436b6f98b3b583b8f89326d1cbee78a4edaea042cd34057', 'hex'), 'Heated Debater');
|
|
|
|
CREATE TEMP VIEW seeded AS
|
|
SELECT s.*, c.id AS community_id
|
|
FROM seed_community s
|
|
JOIN communities c ON lower(c.host) = lower(s.host)
|
|
OR (s.host = 'localhost:3000' AND c.id = :'local_community_id');
|
|
|
|
-- Refuse to reset while the admin console has unfinished work on a fixture:
|
|
-- forcing a processing report back to open strands its action, and a pending
|
|
-- action could re-apply after the reset. Locking admin actions blocks new
|
|
-- claims and direct actions until this transaction commits.
|
|
-- A claim or reopen locks its report row before writing an action, so the
|
|
-- fixture rows are taken with NOWAIT: waiting on one would deadlock.
|
|
LOCK TABLE relay_admin_actions IN SHARE ROW EXCLUSIVE MODE;
|
|
CREATE TEMP VIEW seed_report AS
|
|
SELECT r.* FROM moderation_reports r
|
|
JOIN seeded s ON s.community_id = r.community_id
|
|
JOIN (SELECT 'localhost:3000', to_hex(n) FROM generate_series(1, 11) n
|
|
UNION ALL VALUES ('beta.localhost:3000', '61'), ('beta.localhost:3000', '62'),
|
|
('gamma.localhost:3000', '63')) k(host, byte)
|
|
ON k.host = s.host AND r.report_event_id = decode(repeat(lpad(k.byte, 2, '0'), 32), 'hex');
|
|
DO $$
|
|
BEGIN
|
|
PERFORM 1 FROM moderation_reports r JOIN seed_report f USING (community_id, id) FOR UPDATE OF r NOWAIT;
|
|
EXCEPTION WHEN lock_not_available THEN
|
|
RAISE EXCEPTION 'admin-seed refused, nothing was written: moderation is active on a seeded report. Finish it in the admin console, then rerun just admin-seed.';
|
|
END $$;
|
|
CREATE TEMP TABLE seed_busy AS
|
|
SELECT format('report %s in %s is %s', r.id, c.host, r.status) AS what
|
|
FROM seed_report r JOIN communities c ON c.id = r.community_id
|
|
WHERE r.status = 'processing' OR r.active_action_id IS NOT NULL
|
|
UNION ALL
|
|
SELECT format('%s action %s in %s is %s', a.action, a.id, c.host, a.state)
|
|
FROM relay_admin_actions a JOIN communities c ON c.id = a.report_community_id
|
|
WHERE a.state IN ('pending', 'enforcing')
|
|
AND a.report_community_id IN (SELECT community_id FROM seeded)
|
|
AND (a.enforcement_target_pubkey IN (SELECT pubkey FROM seed_member)
|
|
OR a.enforcement_target_event_id IN (SELECT decode(event_id, 'hex') FROM seed_community));
|
|
DO $$
|
|
BEGIN
|
|
IF EXISTS (SELECT 1 FROM seed_busy) THEN
|
|
RAISE EXCEPTION E'admin-seed refused, nothing was written: %\nFinish or cancel these in the admin console (Cancel & reopen for a failed report action), wait for pending actions to complete, then rerun just admin-seed.',
|
|
(SELECT string_agg(what, '; ') FROM seed_busy);
|
|
END IF;
|
|
END $$;
|
|
|
|
INSERT INTO communities (host)
|
|
SELECT host FROM seed_community WHERE host <> 'localhost:3000'
|
|
UNION ALL
|
|
SELECT format('filler-%s.localhost:3000', lpad(n::text, 2, '0')) FROM generate_series(1, 50) n
|
|
ON CONFLICT ((lower(host))) DO NOTHING;
|
|
|
|
INSERT INTO channels (community_id, id, name, created_by)
|
|
SELECT community_id, channel_id, 'seed-enforcement-channel', decode(repeat('3b', 32), 'hex') FROM seeded
|
|
ON CONFLICT (community_id, id) DO UPDATE SET name = EXCLUDED.name;
|
|
|
|
INSERT INTO users (community_id, pubkey, display_name)
|
|
SELECT s.community_id, m.pubkey, m.display_name
|
|
FROM seeded s JOIN seed_member m ON m.n = ANY (s.members)
|
|
ON CONFLICT (community_id, pubkey) DO UPDATE SET display_name = EXCLUDED.display_name;
|
|
|
|
-- Raw rows bypass the NIP-43 member list; run `buzz-admin republish-members`
|
|
-- per community to rebuild it.
|
|
INSERT INTO relay_members (community_id, pubkey, role, added_by)
|
|
SELECT s.community_id, encode(m.pubkey, 'hex'), 'member', NULL
|
|
FROM seeded s JOIN seed_member m ON m.n = ANY (s.members)
|
|
ON CONFLICT (community_id, pubkey) DO NOTHING;
|
|
|
|
INSERT INTO channel_members (community_id, channel_id, pubkey)
|
|
SELECT community_id, channel_id, (SELECT pubkey FROM seed_member WHERE n = author) FROM seeded
|
|
ON CONFLICT (community_id, channel_id, pubkey) DO UPDATE SET removed_at = NULL, removed_by = NULL;
|
|
|
|
INSERT INTO community_bans (community_id, pubkey, banned, ban_reason, muted_until, mute_reason, actor_pubkey)
|
|
SELECT s.community_id, m.pubkey, m.n = 5,
|
|
CASE WHEN m.n = 5 THEN 'Seeded ban: repeated spam.' END,
|
|
CASE WHEN m.n = 6 THEN now() + interval '1 day' END,
|
|
CASE WHEN m.n = 6 THEN 'Seeded timeout: heated thread.' END,
|
|
decode(repeat('3b', 32), 'hex')
|
|
FROM seeded s JOIN seed_member m ON m.n = ANY (s.members)
|
|
ON CONFLICT (community_id, pubkey) DO UPDATE SET
|
|
banned = EXCLUDED.banned, ban_expires_at = NULL, ban_reason = EXCLUDED.ban_reason,
|
|
muted_until = EXCLUDED.muted_until, mute_reason = EXCLUDED.mute_reason, updated_at = now();
|
|
|
|
INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, channel_id)
|
|
SELECT s.community_id, decode(s.event_id, 'hex'), m.pubkey, to_timestamp(1790000000), 9,
|
|
jsonb_build_array(jsonb_build_array('h', s.channel_id::text)),
|
|
format('Seeded message in %s for the admin delete preview.', s.host),
|
|
decode(s.sig, 'hex'), s.channel_id
|
|
FROM seeded s JOIN seed_member m ON m.n = s.author
|
|
ON CONFLICT (community_id, created_at, id) DO UPDATE SET deleted_at = NULL;
|
|
|
|
INSERT INTO moderation_reports (
|
|
community_id, id, report_event_id, reporter_pubkey, target_kind,
|
|
target_event_id, target_pubkey, channel_id, report_type, note, status, created_at
|
|
)
|
|
SELECT s.community_id, ('a11d0000-0000-4000-8000-0000000001' || lpad(r.n::text, 2, '0'))::uuid,
|
|
decode(repeat(to_hex(96 + r.n), 32), 'hex'), decode(repeat('1c', 32), 'hex'),
|
|
r.kind, CASE WHEN r.kind = 'event' THEN decode(s.event_id, 'hex') END,
|
|
CASE WHEN r.kind = 'pubkey' THEN (SELECT pubkey FROM seed_member WHERE n = 5) END,
|
|
CASE WHEN r.kind = 'event' THEN s.channel_id END,
|
|
r.report_type, format('%s report in %s.', initcap(r.report_type), s.host), 'open',
|
|
now() - r.n * interval '7 minutes'
|
|
FROM seeded s
|
|
JOIN (VALUES (1, 'beta.localhost:3000', 'event', 'spam'),
|
|
(2, 'beta.localhost:3000', 'pubkey', 'impersonation'),
|
|
(3, 'gamma.localhost:3000', 'event', 'profanity')) r(n, host, kind, report_type)
|
|
ON r.host = s.host
|
|
ON CONFLICT (community_id, report_event_id) DO UPDATE SET
|
|
channel_id = EXCLUDED.channel_id, status = EXCLUDED.status, resolved_by = NULL,
|
|
resolved_at = NULL, created_at = EXCLUDED.created_at;
|
|
|
|
INSERT INTO product_feedback (id, community_id, event_id, submitter_pubkey, category, body, tags, event_created_at, received_at)
|
|
SELECT ('feed0000-0000-4000-8000-0000000001' || lpad(f.n::text, 2, '0'))::uuid, s.community_id,
|
|
decode(repeat(to_hex(112 + f.n), 32), 'hex'), (SELECT pubkey FROM seed_member WHERE n = f.member),
|
|
f.category, format('%s (from %s)', f.body, s.host), jsonb_build_array(jsonb_build_array('category', f.category)),
|
|
now() - f.n * interval '3 hours', now() - f.n * interval '3 hours'
|
|
FROM seeded s
|
|
JOIN (VALUES (1, 'beta.localhost:3000', 3, 'bug', 'Notifications arrive twice on mobile.'),
|
|
(2, 'gamma.localhost:3000', 4, 'praise', 'Thread replies load instantly now.')) f(n, host, member, category, body)
|
|
ON f.host = s.host
|
|
ON CONFLICT (event_id) DO UPDATE SET
|
|
community_id = EXCLUDED.community_id, body = EXCLUDED.body, event_created_at = EXCLUDED.event_created_at,
|
|
received_at = EXCLUDED.received_at, status = 'new';
|
|
SQL
|
|
|
|
# One transaction: the guard in multi_sql locks admin actions and refuses before
|
|
# any write, so the reset cannot race a claim. Uploads only follow a commit.
|
|
run_psql -q -1 -v ON_ERROR_STOP=1 -v local_community_id="${community_id}" \
|
|
<<< "${multi_sql}"$'\n'"${sql}"
|
|
|
|
upload_fixture "${search_image}" "${search_image_hash}" png image/png 2000x1172
|
|
upload_fixture "${workspace_image}" "${workspace_image_hash}" png image/png 2000x1172
|
|
upload_fixture "${quality_image}" "${quality_image_hash}" png image/png 2000x1172
|
|
upload_fixture "${composer_diagnostics}" "${composer_diagnostics_hash}" txt text/plain ""
|
|
upload_fixture "${workspace_diagnostics}" "${workspace_diagnostics_hash}" txt text/plain ""
|
|
|
|
cat <<'EOF'
|
|
Seeded 14 moderation reports, 9 feedback entries, and 5 attachments, plus
|
|
beta.localhost:3000, gamma.localhost:3000 and 50 filler-NN.localhost:3000 communities.
|
|
|
|
Communities (with localhost:3000): beta.localhost:3000, gamma.localhost:3000
|
|
Members (display name, npub, communities):
|
|
Ada Lovelace npub102rrkt56fyfjrkhhdy6huwvzd8g8gucyj0x3eg2gztal6p7weecqlj45d5 localhost, beta
|
|
Grace Hopper npub1au08l0drapmkut9d6hhpls7dxu5qzjccs982y8g4wj9v56sutsnshfethq localhost, gamma
|
|
Linus Pauling npub1ty5ap5jgzeu8djyda4ytlkgp8z0kgn6ug7uk0j4jcyfwlxl3fudq59tr5r beta
|
|
Rosalind Franklin npub1kat9xfu2lv752ghalz7c6svvyzzu9x2uyu7sx465pssnwjlf202s04ej99 gamma
|
|
Spam Account npub19tt920ckus9jypz6drl7dw334e05qprs6rnzwmeh04g0cglex63s34trhf all three, banned
|
|
Heated Debater npub1n6e7gnjnj4yl79pkkmuckw6c8w8cjvndrjlw0zjwmt4qgtxngptse52a73 all three, timed out 1 day
|
|
Messages (rerunning the seed restores deleted ones):
|
|
localhost:3000 note1pljdaqtqncpwycajdrmsdxyz7tdk3ht5c0mzqa97x842kf9skh7q9whkgc
|
|
nevent1qqsqlex7s9sfuqhzvwex3acxnzp09kmgm46v8a3qwjlrr64tyjcttlqrmlq9s
|
|
beta.localhost:3000 note12zufmamnmqqf4zx58tts06lqq4x8ucv6dsfhylsfsjk2p7fs6q7q2svrv4
|
|
nevent1qqs9pwya7aeasqy63r2r44c8a0sq2nr7vxdxcymj0cycft9qlycdq0q2n8jdv
|
|
gamma.localhost:3000 note1zz84guev2g4f0cjgrdqm3dd93z5ec5m2ufw4crpwngdqc2qcnuqqd7x0uy
|
|
nevent1qqsppr65wvk9y25hufypksdckkjc32vu2d4wyh2upshf5xsv9qvf7qqft8acc
|
|
EOF
|