sqlmirror 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. sqlmirror/__init__.py +1 -0
  2. sqlmirror/apply.py +136 -0
  3. sqlmirror/backfill.py +65 -0
  4. sqlmirror/cli.py +335 -0
  5. sqlmirror/cron.py +103 -0
  6. sqlmirror/db.py +469 -0
  7. sqlmirror/db_mssql.py +530 -0
  8. sqlmirror/dev/__init__.py +1 -0
  9. sqlmirror/dev/cli.py +166 -0
  10. sqlmirror/dev/compare.py +87 -0
  11. sqlmirror/dev/e2e.py +261 -0
  12. sqlmirror/dev/golden.py +182 -0
  13. sqlmirror/dev/mock_jira.py +347 -0
  14. sqlmirror/dev/site.py +383 -0
  15. sqlmirror/dev/synth.py +769 -0
  16. sqlmirror/golden/01_issues_by_status.sql +8 -0
  17. sqlmirror/golden/02_issue_keys.sql +7 -0
  18. sqlmirror/golden/03_created_vs_resolved.sql +8 -0
  19. sqlmirror/golden/04_open_by_priority.sql +10 -0
  20. sqlmirror/golden/05_time_in_status.sql +16 -0
  21. sqlmirror/golden/06_cycle_time.sql +16 -0
  22. sqlmirror/golden/07_worklog_by_user.sql +11 -0
  23. sqlmirror/golden/08_fix_versions.sql +11 -0
  24. sqlmirror/golden/09_bugs_by_affected_version.sql +11 -0
  25. sqlmirror/golden/10_components.sql +10 -0
  26. sqlmirror/golden/11_top_labels.sql +9 -0
  27. sqlmirror/golden/12_select_field_distribution.sql +11 -0
  28. sqlmirror/golden/13_story_points_by_sprint.sql +11 -0
  29. sqlmirror/golden/14_sprint_completion.sql +14 -0
  30. sqlmirror/golden/15_epics.sql +12 -0
  31. sqlmirror/golden/16_subtasks.sql +10 -0
  32. sqlmirror/golden/17_blocked_issues.sql +11 -0
  33. sqlmirror/golden/18_most_discussed.sql +10 -0
  34. sqlmirror/golden/19_assignee_workload.sql +11 -0
  35. sqlmirror/golden/20_resolutions.sql +7 -0
  36. sqlmirror/golden/21_issue_history.sql +10 -0
  37. sqlmirror/golden/22_reopened.sql +11 -0
  38. sqlmirror/golden/23_overdue.sql +8 -0
  39. sqlmirror/golden/24_cascading_select.sql +13 -0
  40. sqlmirror/golden/25_user_picker.sql +12 -0
  41. sqlmirror/golden/26_attachments.sql +9 -0
  42. sqlmirror/golden/27_project_categories.sql +8 -0
  43. sqlmirror/golden/28_issue_types.sql +7 -0
  44. sqlmirror/golden/29_estimate_accuracy.sql +11 -0
  45. sqlmirror/golden/30_project_leads.sql +7 -0
  46. sqlmirror/golden/tsql/01_issues_by_status.sql +8 -0
  47. sqlmirror/golden/tsql/02_issue_keys.sql +6 -0
  48. sqlmirror/golden/tsql/03_created_vs_resolved.sql +8 -0
  49. sqlmirror/golden/tsql/04_open_by_priority.sql +10 -0
  50. sqlmirror/golden/tsql/05_time_in_status.sql +16 -0
  51. sqlmirror/golden/tsql/06_cycle_time.sql +16 -0
  52. sqlmirror/golden/tsql/07_worklog_by_user.sql +11 -0
  53. sqlmirror/golden/tsql/08_fix_versions.sql +11 -0
  54. sqlmirror/golden/tsql/09_bugs_by_affected_version.sql +11 -0
  55. sqlmirror/golden/tsql/10_components.sql +10 -0
  56. sqlmirror/golden/tsql/11_top_labels.sql +8 -0
  57. sqlmirror/golden/tsql/12_select_field_distribution.sql +11 -0
  58. sqlmirror/golden/tsql/13_story_points_by_sprint.sql +11 -0
  59. sqlmirror/golden/tsql/14_sprint_completion.sql +14 -0
  60. sqlmirror/golden/tsql/15_epics.sql +11 -0
  61. sqlmirror/golden/tsql/16_subtasks.sql +9 -0
  62. sqlmirror/golden/tsql/17_blocked_issues.sql +11 -0
  63. sqlmirror/golden/tsql/18_most_discussed.sql +9 -0
  64. sqlmirror/golden/tsql/19_assignee_workload.sql +11 -0
  65. sqlmirror/golden/tsql/20_resolutions.sql +7 -0
  66. sqlmirror/golden/tsql/21_issue_history.sql +10 -0
  67. sqlmirror/golden/tsql/22_reopened.sql +11 -0
  68. sqlmirror/golden/tsql/23_overdue.sql +8 -0
  69. sqlmirror/golden/tsql/24_cascading_select.sql +13 -0
  70. sqlmirror/golden/tsql/25_user_picker.sql +12 -0
  71. sqlmirror/golden/tsql/26_attachments.sql +9 -0
  72. sqlmirror/golden/tsql/27_project_categories.sql +8 -0
  73. sqlmirror/golden/tsql/28_issue_types.sql +7 -0
  74. sqlmirror/golden/tsql/29_estimate_accuracy.sql +11 -0
  75. sqlmirror/golden/tsql/30_project_leads.sql +7 -0
  76. sqlmirror/idmap.py +161 -0
  77. sqlmirror/jira.py +223 -0
  78. sqlmirror/mapper.py +774 -0
  79. sqlmirror/reconcile.py +73 -0
  80. sqlmirror/samples/dc_export_example.csv +19 -0
  81. sqlmirror/samples/dc_reports/mysql_sprint_report.sql +10 -0
  82. sqlmirror/samples/dc_reports/postgres_assigned_to_user.sql +6 -0
  83. sqlmirror/samples/dc_reports/postgres_customer_impact.sql +7 -0
  84. sqlmirror/samples/dc_reports/postgres_time_logged.sql +7 -0
  85. sqlmirror/samples/dc_reports/postgres_workflow_steps.sql +4 -0
  86. sqlmirror/samples/dc_reports/sqlserver_open_by_status.sql +9 -0
  87. sqlmirror/schema.py +518 -0
  88. sqlmirror/source.py +117 -0
  89. sqlmirror/sqlcheck.py +647 -0
  90. sqlmirror/util.py +80 -0
  91. sqlmirror-0.2.0.dist-info/METADATA +19 -0
  92. sqlmirror-0.2.0.dist-info/RECORD +95 -0
  93. sqlmirror-0.2.0.dist-info/WHEEL +5 -0
  94. sqlmirror-0.2.0.dist-info/entry_points.txt +3 -0
  95. sqlmirror-0.2.0.dist-info/top_level.txt +1 -0
sqlmirror/dev/cli.py ADDED
@@ -0,0 +1,166 @@
1
+ """sqlmirror-dev: test tooling (synthetic tenants, mock Jira, live-site loaders, golden queries)."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+ from dotenv import load_dotenv
10
+
11
+ from ..cli import _env, _jira
12
+
13
+
14
+ @click.group()
15
+ @click.option("--env-file", type=click.Path(dir_okay=False))
16
+ def main(env_file: str | None) -> None:
17
+ """Development and test tools for SQL Mirror."""
18
+ load_dotenv(env_file or Path.cwd() / ".env", override=False)
19
+
20
+
21
+ @main.command()
22
+ @click.option("--issues", default=2000, show_default=True)
23
+ @click.option("--projects", default=3, show_default=True)
24
+ @click.option("--seed", default=7, show_default=True)
25
+ @click.option("--out", default="data/tenant.json.gz", show_default=True)
26
+ def synth(issues: int, projects: int, seed: int, out: str) -> None:
27
+ """Generate a synthetic Jira Cloud tenant (for the mock server)."""
28
+ from .synth import build_tenant
29
+
30
+ t = build_tenant(issues=issues, projects=projects, seed=seed)
31
+ t.save(out)
32
+ click.echo(f"Wrote {len(t.issues)} issues to {out}")
33
+
34
+
35
+ @main.command("mock-jira")
36
+ @click.option("--data", help="Tenant file from 'synth'. Default: generate --issues on the fly.")
37
+ @click.option("--issues", default=2000, show_default=True)
38
+ @click.option("--port", default=8899, show_default=True)
39
+ @click.option("--flaky", default=0.0, help="Fraction of requests answered with 429.")
40
+ def mock_jira(data: str | None, issues: int, port: int, flaky: float) -> None:
41
+ """Serve a synthetic tenant over the Jira REST API (for backfill and app tests)."""
42
+ from .mock_jira import serve
43
+ from .synth import Tenant, build_tenant
44
+
45
+ tenant = Tenant.load(data) if data else build_tenant(issues=issues)
46
+ click.echo(f"Mock Jira with {len(tenant.issues)} issues on http://127.0.0.1:{port} (Ctrl+C to stop)")
47
+ serve(tenant, port=port, flaky=flaky)
48
+
49
+
50
+ @main.command()
51
+ @click.option("--server-dsn", default=lambda: _env("SQLMIRROR_TEST_SERVER_DSN") or "postgresql://postgres@localhost:5433/postgres",
52
+ show_default="$SQLMIRROR_TEST_SERVER_DSN or postgresql://postgres@localhost:5433/postgres",
53
+ help="Postgres server to create the sqlm_e2e_* test databases on.")
54
+ @click.option("--forge-dir", default=None, help="Path to the Forge app (default: ../sql-mirror next to cli/).")
55
+ @click.option("--issues", default=600, show_default=True)
56
+ @click.option("--mssql-dsn", default=lambda: _env("SQLMIRROR_TEST_MSSQL_DSN"),
57
+ help="Also test a SQL Server mirror: server to create sqlm_e2e_* databases on, e.g. "
58
+ "sqlserver://sa:password@localhost:1433/master (default $SQLMIRROR_TEST_MSSQL_DSN).")
59
+ def e2e(server_dsn: str, forge_dir: str | None, issues: int, mssql_dsn: str | None) -> None:
60
+ """Offline end-to-end test: mock Jira + Forge app code + local S3 + Postgres (+ SQL Server)."""
61
+ import logging
62
+
63
+ from .e2e import run_e2e
64
+
65
+ logging.getLogger("werkzeug").setLevel(logging.ERROR)
66
+ forge_dir = forge_dir or str(Path(__file__).resolve().parents[3] / "sql-mirror")
67
+ ok = run_e2e(server_dsn, forge_dir, issues=issues, mssql_server_dsn=mssql_dsn)
68
+ sys.exit(0 if ok else 1)
69
+
70
+
71
+ @main.command()
72
+ @click.option("--dsn", default=lambda: _env("DATABASE_URL"), help="Mirror database (default $DATABASE_URL).")
73
+ @click.option("--strict", is_flag=True, help="Treat 'no data of this kind' as a failure (for synthetic data).")
74
+ @click.option("--compare-with", help="Another mirror of the same data (e.g. Postgres for a SQL Server mirror): "
75
+ "check both give the same answers.")
76
+ def golden(dsn: str | None, strict: bool, compare_with: str | None) -> None:
77
+ """Run the 30 golden Data Center report queries against a mirror (Postgres or T-SQL set to match)."""
78
+ from .golden import compare_golden, run_golden
79
+
80
+ if not dsn:
81
+ raise click.UsageError("Set DATABASE_URL or pass --dsn")
82
+ results = run_golden(dsn, strict=strict)
83
+ for r in results:
84
+ click.echo(f"{r['status'].upper():<5} {r['name']:<32} {r['rows']:>5} rows {r['ms']:>5} ms {r['error'] or ''}")
85
+ counts = {s: sum(1 for r in results if r["status"] == s) for s in ("pass", "empty", "fail")}
86
+ click.echo(f"{counts['pass']} passed, {counts['empty']} empty (site has no such data yet), {counts['fail']} failed")
87
+ differing = []
88
+ if compare_with:
89
+ verdicts = compare_golden(results, run_golden(compare_with, strict=strict))
90
+ differing = [v for v in verdicts if v["verdict"] != "same"]
91
+ click.echo(f"\nSame answers as {compare_with.split('@')[-1]}: {len(verdicts) - len(differing)}/{len(verdicts)}")
92
+ for v in differing:
93
+ click.echo(f" {v['name']}: {v['verdict']}: {v['detail']}")
94
+ sys.exit(1 if counts["fail"] or differing else 0)
95
+
96
+
97
+ @main.command()
98
+ @click.argument("dsn_a")
99
+ @click.argument("dsn_b")
100
+ def compare(dsn_a: str, dsn_b: str) -> None:
101
+ """Compare two mirror databases table by table (Postgres and/or SQL Server)."""
102
+ from .compare import compare as run, format_report, identical
103
+
104
+ report = run(dsn_a, dsn_b)
105
+ click.echo(format_report(report))
106
+ sys.exit(0 if identical(report) else 1)
107
+
108
+
109
+ def _demo_guard(yes: bool) -> None:
110
+ site = _env("JIRA_SITE") or ""
111
+ if not yes:
112
+ click.confirm(f"This creates and edits issues on {site}. Only use a demo/test site. Continue?", abort=True)
113
+
114
+
115
+ @main.command("load-site")
116
+ @click.option("--project", default="SQLM", show_default=True, help="Project key to create or fill.")
117
+ @click.option("--issues", default=500, show_default=True)
118
+ @click.option("--seed", default=1, show_default=True)
119
+ @click.option("--yes", is_flag=True, help="Don't ask for confirmation.")
120
+ def load_site_cmd(project: str, issues: int, seed: int, yes: bool) -> None:
121
+ """Create realistic test data on a demo site (uses JIRA_* settings)."""
122
+ from .site import load_site
123
+
124
+ _demo_guard(yes)
125
+ load_site(_jira(), project=project, issues=issues, seed=seed)
126
+
127
+
128
+ @main.command("enrich-site")
129
+ @click.option("--project", default="SQLM", show_default=True)
130
+ @click.option("--yes", is_flag=True)
131
+ def enrich_site_cmd(project: str, yes: bool) -> None:
132
+ """Add sprints, attachments, estimates, reopened issues etc. to existing demo issues."""
133
+ from .site import enrich_site
134
+
135
+ _demo_guard(yes)
136
+ enrich_site(_jira(), project=project)
137
+
138
+
139
+ @main.command("simulate-edits")
140
+ @click.option("--project", default="SQLM", show_default=True)
141
+ @click.option("--per-hour", default=300, show_default=True, help="Edits per hour (300 is a busy 250-user team).")
142
+ @click.option("--minutes", default=60, show_default=True)
143
+ @click.option("--yes", is_flag=True)
144
+ def simulate_edits_cmd(project: str, per_hour: int, minutes: int, yes: bool) -> None:
145
+ """Make a steady stream of edits on a demo site (drives the app's events)."""
146
+ from .site import simulate_edits
147
+
148
+ _demo_guard(yes)
149
+ simulate_edits(_jira(), project=project, per_hour=per_hour, minutes=minutes)
150
+
151
+
152
+ @main.command("bulk-edit")
153
+ @click.option("--project", default="SQLM", show_default=True)
154
+ @click.option("--count", default=5000, show_default=True)
155
+ @click.option("--workers", default=8, show_default=True)
156
+ @click.option("--yes", is_flag=True)
157
+ def bulk_edit_cmd(project: str, count: int, workers: int, yes: bool) -> None:
158
+ """Edit many issues at once (the bulk-edit rate-limit test)."""
159
+ from .site import bulk_edit
160
+
161
+ _demo_guard(yes)
162
+ bulk_edit(_jira(), project=project, count=count, workers=workers)
163
+
164
+
165
+ if __name__ == "__main__":
166
+ main()
@@ -0,0 +1,87 @@
1
+ """Compare two mirror databases table by table (used to prove two sync paths agree).
2
+
3
+ Works across databases too: a SQL Server mirror against a Postgres mirror of
4
+ the same data. Then timestamps are compared at SQL Server's datetime
5
+ resolution (1/300 s), since that is all datetime keeps.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime, timezone
10
+ from typing import Any
11
+
12
+ from ..schema import TABLES
13
+
14
+
15
+ def _open(target: Any) -> tuple[Any, bool]:
16
+ if isinstance(target, str):
17
+ from ..db import open_mirror
18
+
19
+ return open_mirror(target), True
20
+ return target, False
21
+
22
+
23
+ def _norm_cross(db: Any, v: Any) -> Any:
24
+ if isinstance(v, datetime):
25
+ if v.tzinfo is None: # SQL Server: naive, in the mirror's zone
26
+ v = db.local_to_aware(v) if hasattr(db, "local_to_aware") else v.replace(tzinfo=timezone.utc)
27
+ v = v.astimezone(timezone.utc).replace(tzinfo=None)
28
+ return ("ts", round((v - datetime(1900, 1, 1)).total_seconds() * 300))
29
+ return v
30
+
31
+
32
+ def _rows(db: Any, table, cross: bool) -> dict[tuple, tuple]:
33
+ cols = table.column_names
34
+ pk_idx = [cols.index(c) for c in table.pk]
35
+ out = {}
36
+ for r in db.read_table(table):
37
+ r = tuple(_norm_cross(db, v) for v in r) if cross else tuple(r)
38
+ out[tuple(r[i] for i in pk_idx)] = r
39
+ return out
40
+
41
+
42
+ def compare(a: Any, b: Any, sample: int = 3) -> list[dict[str, Any]]:
43
+ """Return one entry per table with counts of rows only in A, only in B, and different.
44
+
45
+ ``a`` and ``b`` are connection strings or open mirrors.
46
+ """
47
+ report = []
48
+ ma, close_a = _open(a)
49
+ mb, close_b = _open(b)
50
+ cross = ma.dialect != mb.dialect
51
+ try:
52
+ for t in TABLES:
53
+ if t.mode == "state":
54
+ continue
55
+ ra, rb = _rows(ma, t, cross), _rows(mb, t, cross)
56
+ ma.rollback()
57
+ mb.rollback()
58
+ only_a = sorted(set(ra) - set(rb), key=str)
59
+ only_b = sorted(set(rb) - set(ra), key=str)
60
+ diff = sorted((k for k in set(ra) & set(rb) if ra[k] != rb[k]), key=str)
61
+ entry = {"table": t.name, "a": len(ra), "b": len(rb), "only_a": len(only_a), "only_b": len(only_b),
62
+ "different": len(diff)}
63
+ if only_a or only_b or diff:
64
+ entry["samples"] = {
65
+ "only_a": [ra[k] for k in only_a[:sample]],
66
+ "only_b": [rb[k] for k in only_b[:sample]],
67
+ "different": [(ra[k], rb[k]) for k in diff[:sample]],
68
+ }
69
+ report.append(entry)
70
+ finally:
71
+ if close_a:
72
+ ma.close()
73
+ if close_b:
74
+ mb.close()
75
+ return report
76
+
77
+
78
+ def format_report(report: list[dict[str, Any]]) -> str:
79
+ lines = [f"{'table':<22}{'A':>8}{'B':>8}{'only A':>8}{'only B':>8}{'diff':>7}"]
80
+ for e in report:
81
+ flag = "" if not (e["only_a"] or e["only_b"] or e["different"]) else " <-- differs"
82
+ lines.append(f"{e['table']:<22}{e['a']:>8}{e['b']:>8}{e['only_a']:>8}{e['only_b']:>8}{e['different']:>7}{flag}")
83
+ return "\n".join(lines)
84
+
85
+
86
+ def identical(report: list[dict[str, Any]]) -> bool:
87
+ return all(not (e["only_a"] or e["only_b"] or e["different"]) for e in report)
sqlmirror/dev/e2e.py ADDED
@@ -0,0 +1,261 @@
1
+ """Offline end-to-end test of the whole pipeline.
2
+
3
+ Runs everything locally, with no Atlassian site and no cloud account:
4
+
5
+ synthetic tenant -> mock Jira -> Forge app code (Node, mocked Forge services)
6
+ -> local S3 (moto) -> sqlmirror apply -> Postgres
7
+ synthetic tenant -> mock Jira -> sqlmirror backfill -> Postgres
8
+
9
+ and checks that the different paths produce identical databases:
10
+
11
+ 1. Forge full resync + apply == CLI backfill
12
+ 2. after edits, deletes, new issues and links (events delivered):
13
+ Forge incremental + apply == fresh backfill
14
+ 3. backfill + Forge incremental == fresh backfill (the real-world setup)
15
+ 4. after edits whose events were lost: hourly catch-up recovers them
16
+ 5. the golden Data Center report queries all run on the mirror
17
+ 6. reconcile --deep finds nothing to repair
18
+
19
+ With a SQL Server (mssql_server_dsn), the same change files and the same mock
20
+ Jira also load a SQL Server mirror, which must equal the Postgres one:
21
+
22
+ 7. Forge files applied to SQL Server == Postgres mirror
23
+ 8. CLI backfill into SQL Server == Postgres mirror
24
+ 9. reconcile --deep on SQL Server finds nothing to repair
25
+ 10. the T-SQL golden reports run on SQL Server and give the same answers as
26
+ the Postgres golden reports on Postgres
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ import socket
33
+ import subprocess
34
+ import tempfile
35
+ import time
36
+ from pathlib import Path
37
+ from typing import Callable
38
+
39
+ import httpx
40
+ import psycopg
41
+
42
+ from ..apply import apply_once
43
+ from ..backfill import backfill
44
+ from ..reconcile import reconcile
45
+ from ..db import PgMirror
46
+ from ..jira import JiraClient
47
+ from ..mapper import Mapper
48
+ from ..source import S3Source
49
+ from .compare import compare, format_report, identical
50
+ from .golden import compare_golden, run_golden
51
+ from .mock_jira import serve
52
+ from .synth import build_tenant
53
+
54
+ BUCKET = "sqlmirror-e2e"
55
+ PREFIX = "site1/"
56
+
57
+
58
+ def _free_port() -> int:
59
+ with socket.socket() as s:
60
+ s.bind(("127.0.0.1", 0))
61
+ return s.getsockname()[1]
62
+
63
+
64
+ def _dsn_for(server_dsn: str, dbname: str) -> str:
65
+ from psycopg.conninfo import conninfo_to_dict, make_conninfo
66
+
67
+ params = conninfo_to_dict(server_dsn)
68
+ params["dbname"] = dbname
69
+ return make_conninfo(**params)
70
+
71
+
72
+ def _recreate_db(server_dsn: str, name: str) -> str:
73
+ with psycopg.connect(server_dsn, autocommit=True) as conn:
74
+ conn.execute(f'DROP DATABASE IF EXISTS "{name}"')
75
+ conn.execute(f'CREATE DATABASE "{name}"')
76
+ return _dsn_for(server_dsn, name)
77
+
78
+
79
+ def run_e2e(server_dsn: str, forge_dir: str | Path, *, issues: int = 600, seed: int = 11,
80
+ log: Callable[[str], None] = print, mssql_server_dsn: str | None = None) -> bool:
81
+ import logging
82
+
83
+ logging.getLogger("werkzeug").setLevel(logging.ERROR)
84
+ forge_dir = Path(forge_dir).resolve()
85
+ if not (forge_dir / "node_modules" / "@forge" / "api").exists():
86
+ raise SystemExit(f"Run 'npm install' in {forge_dir} first.")
87
+ ok = True
88
+
89
+ def check(title: str, report) -> None:
90
+ nonlocal ok
91
+ same = identical(report)
92
+ ok = ok and same
93
+ log(("PASS " if same else "FAIL ") + title)
94
+ if not same:
95
+ log(format_report(report))
96
+ for e in report:
97
+ if e.get("samples"):
98
+ log(f" {e['table']}: {json.dumps(e['samples'], default=str)[:1500]}")
99
+
100
+ # ---------------------------------------------------------------- services
101
+ tenant = build_tenant(issues=issues, seed=seed)
102
+ jira_port, s3_port = _free_port(), _free_port()
103
+ server, _mock = serve(tenant, port=jira_port, background=True, flaky=0.01)
104
+ jira_url = f"http://127.0.0.1:{jira_port}"
105
+
106
+ os.environ.setdefault("AWS_ACCESS_KEY_ID", "testing")
107
+ os.environ.setdefault("AWS_SECRET_ACCESS_KEY", "testing")
108
+ os.environ.setdefault("AWS_DEFAULT_REGION", "us-west-1")
109
+ from moto.server import ThreadedMotoServer
110
+
111
+ moto = ThreadedMotoServer(ip_address="127.0.0.1", port=s3_port, verbose=False)
112
+ moto.start()
113
+ s3_url = f"http://127.0.0.1:{s3_port}"
114
+ import uuid
115
+
116
+ import boto3
117
+
118
+ # moto keeps its buckets for the life of the process, so each run gets its own.
119
+ BUCKET = f"sqlmirror-e2e-{uuid.uuid4().hex[:8]}"
120
+ # Outside us-east-1, S3 needs the region spelled out when creating a bucket.
121
+ boto3.client("s3", endpoint_url=s3_url, region_name="us-west-1").create_bucket(
122
+ Bucket=BUCKET, CreateBucketConfiguration={"LocationConstraint": "us-west-1"})
123
+ log(f"Mock Jira on {jira_url} ({len(tenant.issues)} issues), local S3 on {s3_url}")
124
+
125
+ state_file = tempfile.NamedTemporaryFile(prefix="sqlm-forge-sim-", suffix=".json", delete=False).name
126
+ os.unlink(state_file)
127
+ env = {**os.environ, "SQLM_SIM_STATE": state_file, "SQLM_MOCK_JIRA": jira_url}
128
+
129
+ def forge(*args: str) -> str:
130
+ res = subprocess.run(["node", "--import", "./test/register.mjs", "test/sim.mjs", *args], cwd=forge_dir,
131
+ env=env, capture_output=True, text=True, timeout=900)
132
+ if res.returncode != 0:
133
+ raise RuntimeError(f"forge sim {args[0]} failed:\n{res.stdout}\n{res.stderr}")
134
+ return res.stdout.strip()
135
+
136
+ def forge_state() -> dict:
137
+ return json.loads(forge("state"))
138
+
139
+ source = S3Source(f"s3://{BUCKET}/{PREFIX}", endpoint_url=s3_url, region="us-west-1")
140
+ db_forge = PgMirror(_recreate_db(server_dsn, "sqlm_e2e_forge"))
141
+ db_forge.init_schema()
142
+ db_backfill = PgMirror(_recreate_db(server_dsn, "sqlm_e2e_backfill"))
143
+ db_backfill.init_schema()
144
+ quiet = lambda _msg: None # noqa: E731
145
+ mssql_dbs: list = []
146
+
147
+ try:
148
+ # 1. Forge full resync vs CLI backfill -------------------------------
149
+ forge("configure", json.dumps({"bucket": BUCKET, "region": "us-west-1", "endpoint": s3_url, "prefix": PREFIX,
150
+ "accessKeyId": "testing", "secretAccessKey": "testing"}))
151
+ forge("enqueue", "resync")
152
+ t0 = time.time()
153
+ log(forge("run-jobs"))
154
+ st = forge_state()
155
+ log(f"Forge resync: {st['lastJobSummary']} in {time.time() - t0:.1f}s; error: {st['lastError']}")
156
+ res = apply_once(source, db_forge, Mapper(), log=quiet)
157
+ log(f"apply: {res.files} files, {res.stats.summary()}")
158
+
159
+ client = JiraClient(jira_url, "e2e@example.com", "token")
160
+ backfill(client, db_backfill, Mapper(), log=quiet)
161
+ check("1. Forge resync + apply == CLI backfill", compare(db_forge.dsn, db_backfill.dsn))
162
+
163
+ # 2. Incremental changes with events ---------------------------------
164
+ events = httpx.post(f"{jira_url}/__mock/mutate", json={"edits": 40, "deletes": 4, "creates": 6, "links": 5,
165
+ "seed": 5}).json()["events"]
166
+ ev_file = state_file + ".events.json"
167
+ Path(ev_file).write_text(json.dumps(events))
168
+ log(forge("events", ev_file))
169
+ log(forge("scheduled-sync"))
170
+ log(forge("run-jobs"))
171
+ res = apply_once(source, db_forge, Mapper(), log=quiet)
172
+ log(f"apply: {res.files} files, {res.stats.summary()}")
173
+ db_truth = PgMirror(_recreate_db(server_dsn, "sqlm_e2e_truth"))
174
+ db_truth.init_schema()
175
+ backfill(client, db_truth, Mapper(), log=quiet)
176
+ check("2. Forge incremental (edits, deletes, creates, links) == fresh backfill",
177
+ compare(db_forge.dsn, db_truth.dsn))
178
+
179
+ # 3. Backfilled mirror kept current by the Forge app -------------------
180
+ res = apply_once(source, db_backfill, Mapper(), log=quiet)
181
+ log(f"apply onto backfill: {res.files} files, {res.stats.summary()}")
182
+ check("3. backfill + Forge incremental == fresh backfill", compare(db_backfill.dsn, db_truth.dsn))
183
+
184
+ # 4. Lost events: the hourly catch-up must recover the edits ------------
185
+ httpx.post(f"{jira_url}/__mock/mutate", json={"edits": 15, "deletes": 0, "creates": 2, "links": 0, "seed": 6})
186
+ log(forge("scheduled-catchup"))
187
+ log(forge("enqueue", "sync")) # the catch-up only queues; the next scheduled sync writes
188
+ log(forge("run-jobs"))
189
+ res = apply_once(source, db_forge, Mapper(), log=quiet)
190
+ db_truth2 = PgMirror(_recreate_db(server_dsn, "sqlm_e2e_truth2"))
191
+ db_truth2.init_schema()
192
+ backfill(client, db_truth2, Mapper(), log=quiet)
193
+ check("4. hourly catch-up recovers changes whose events were lost", compare(db_forge.dsn, db_truth2.dsn))
194
+
195
+ # 5. Golden Data Center queries ---------------------------------------
196
+ results = run_golden(db_forge.dsn)
197
+ failed = [r for r in results if not r["ok"]]
198
+ ok = ok and not failed
199
+ log(("PASS " if not failed else "FAIL ") + f"5. {len(results) - len(failed)}/{len(results)} golden DC report queries ran")
200
+ for r in failed:
201
+ log(f" {r['name']}: {r['error']}")
202
+
203
+ # 6. Reconcile against Jira -------------------------------------------
204
+ def reconciled(db, title: str) -> None:
205
+ nonlocal ok
206
+ reports = reconcile(client, db, Mapper(), deep=True, log=quiet)
207
+ bad = [r for r in reports if not r.ok]
208
+ ok = ok and not bad and bool(reports)
209
+ log(("PASS " if reports and not bad else "FAIL ") + title)
210
+ for r in bad:
211
+ log(f" {r.key}: jira {r.jira} mirror {r.mirror} missing {r.missing[:5]} extra {r.extra[:5]} "
212
+ f"stale {r.stale[:5]}")
213
+
214
+ reconciled(db_forge, "6. reconcile --deep: Postgres mirror matches Jira")
215
+
216
+ # 7-10. SQL Server ------------------------------------------------------
217
+ if mssql_server_dsn:
218
+ from ..db_mssql import MssqlMirror, recreate_database
219
+
220
+ ms_forge = MssqlMirror(recreate_database(mssql_server_dsn, "sqlm_e2e_forge"))
221
+ ms_backfill = MssqlMirror(recreate_database(mssql_server_dsn, "sqlm_e2e_backfill"))
222
+ mssql_dbs.extend([ms_forge, ms_backfill])
223
+ ms_forge.init_schema()
224
+ ms_backfill.init_schema()
225
+ t0 = time.time()
226
+ res = apply_once(source, ms_forge, Mapper(), log=quiet)
227
+ log(f"SQL Server apply: {res.files} files, {res.stats.summary()} in {time.time() - t0:.1f}s")
228
+ again = apply_once(source, ms_forge, Mapper(), log=quiet)
229
+ if again.files:
230
+ ok = False
231
+ log(f"FAIL re-running apply on SQL Server applied {again.files} files again")
232
+ check("7. Forge files applied to SQL Server == Postgres mirror", compare(ms_forge, db_forge))
233
+ t0 = time.time()
234
+ backfill(client, ms_backfill, Mapper(), log=quiet)
235
+ log(f"SQL Server backfill in {time.time() - t0:.1f}s")
236
+ check("8. CLI backfill into SQL Server == Postgres mirror", compare(ms_backfill, db_truth2))
237
+ reconciled(ms_forge, "9. reconcile --deep: SQL Server mirror matches Jira")
238
+
239
+ ms_results = run_golden(ms_forge)
240
+ ms_failed = [r for r in ms_results if not r["ok"]]
241
+ verdicts = compare_golden(results, ms_results)
242
+ differing = [v for v in verdicts if v["verdict"] != "same"]
243
+ ok = ok and not ms_failed and not differing
244
+ log(("PASS " if not ms_failed and not differing else "FAIL ")
245
+ + f"10. {len(ms_results) - len(ms_failed)}/{len(ms_results)} T-SQL golden reports ran on SQL Server; "
246
+ + f"{len(verdicts) - len(differing)}/{len(verdicts)} give the same answers as on Postgres")
247
+ for v in differing:
248
+ log(f" {v['name']}: {v['verdict']}: {v['detail']}")
249
+
250
+ st = forge_state()
251
+ log(f"Forge totals: {json.dumps(st['totals'])}")
252
+ finally:
253
+ for db in (db_forge, db_backfill, *mssql_dbs):
254
+ db.close()
255
+ server.shutdown()
256
+ moto.stop()
257
+ for f in (state_file, state_file + ".events.json"):
258
+ if os.path.exists(f):
259
+ os.unlink(f)
260
+ log("ALL PASSED" if ok else "SOME CHECKS FAILED")
261
+ return ok