shadowjoin 0.0.0-stage → 0.3.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Harsh N. Chandak
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,325 @@
1
- # Temporary Holding Version
1
+ # shadowjoin
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Measure the matches your exact join is silently dropping.**
4
+
5
+ Your pipeline joins two sources on a name, an email, a SKU, a company. It matches
6
+ what matches. Everything else just... isn't there. No error, no exception, no
7
+ alert — the query returns, the dashboard renders, and a quarter of the rows that
8
+ should have matched quietly don't.
9
+
10
+ shadowjoin runs a shadow matcher alongside your real join key and tells you how
11
+ many matches you are losing, which normalization rule would recover each one, and
12
+ — if you have any ground truth at all — proves the recoveries are real.
13
+
14
+ ```
15
+ npx shadowjoin scan \
16
+ --left customers.csv --left-key company_name \
17
+ --right crm_export.csv --right-key account_name
18
+ ```
19
+
20
+ [![shadowjoin measuring a country-name join: 205 of 249 matched exactly, which looks fine, while five real matches were being dropped including Vietnam, spelled Viet Nam by the UN](assets/brag.gif)](https://github.com/harsh-chandak/shadowjoin/blob/main/assets/brag.mp4)
21
+
22
+ <sub>Fourteen seconds, no sound. Every figure in it is verbatim CLI output from
23
+ the committed example below — [higher-resolution
24
+ version](https://github.com/harsh-chandak/shadowjoin/blob/main/assets/brag.mp4).</sub>
25
+
26
+ The progress output above is real: shadowjoin shows what it is doing while it
27
+ works. All of it is suppressed automatically when stdout is not a terminal, so
28
+ piping to a file, to `jq`, or running in CI produces byte-identical output to
29
+ before — and there is **no postinstall script**, because a tool you point at a
30
+ production database has no business running code at install time.
31
+
32
+ ## What it found on public data
33
+
34
+ Joining the **S&P 500 constituent list** against **NYSE + Nasdaq listings** on
35
+ company name. Both files carry ticker symbols, so every recovered match can be
36
+ checked rather than believed:
37
+
38
+ ```
39
+ left rows 503 right rows 6,709
40
+
41
+ exact join matches 1 0.2%
42
+ recovered by shadow join 428 85.09%
43
+ still unexplained 74
44
+
45
+ LOWER BOUND ON LOST MATCHES: 85.09% of left rows
46
+ verified against ground truth: 419 correct, 1 wrong, 8 ambiguous (precision 99.76%)
47
+
48
+ rules worth adopting, in order (greedy by marginal recovery):
49
+ 1. strip security/listing boilerplate (common stock, ADS, ...) +68 68
50
+ 2. strip corporate suffixes (inc, corp, ltd, plc, gmbh ...) +319 387
51
+ 3. strip punctuation +33 420
52
+ 4. sort tokens (order-insensitive match) +4 424
53
+ 5. remove all whitespace +1 425
54
+ ```
55
+
56
+ The exact join found **one** match. There were **four hundred and twenty-eight**,
57
+ and 419 of them are confirmed correct by ticker.
58
+
59
+ Reproduce it yourself — the data ships with the repo:
60
+
61
+ ```bash
62
+ git clone https://github.com/harsh-chandak/shadowjoin && cd shadowjoin
63
+ npm install && npm run build
64
+ node dist/cli.js scan \
65
+ --left examples/sp500.csv --left-key Security --left-id Symbol --left-truth Symbol \
66
+ --right examples/listings.json --right-key name --right-id symbol --right-truth symbol
67
+ ```
68
+
69
+ ### A join you would actually ship
70
+
71
+ The example above is deliberately unflattering — a short index name against a
72
+ full security description. Here is the realistic case.
73
+
74
+ **Unicode CLDR country names** (what your UI displays) joined to **UN official
75
+ country names** (what your reference data uses), on the country name, with ISO
76
+ 3166 alpha-3 as ground truth. That is a join people write every week.
77
+
78
+ ```
79
+ exact join matches 205 / 249 82.33%
80
+ recovered by shadow join 5 2.01% precision 100%
81
+ still unexplained 39
82
+
83
+ rules worth adopting:
84
+ 1. & -> and +4
85
+ 2. remove all whitespace +1
86
+ ```
87
+
88
+ It looks healthy. 82% matched, no errors, nobody investigating. And it is
89
+ silently dropping `Antigua & Barbuda`, `Trinidad & Tobago`,
90
+ `São Tomé & Príncipe`, `Turks & Caicos Islands` and `Vietnam` — the last one
91
+ because the UN spells it `Viet Nam`.
92
+
93
+ Two things this example shows that the first one does not:
94
+
95
+ **Applying every rule finds nothing.** The full rule set recovers zero here; the
96
+ two-rule subset recovers five. Over-normalization collapses keys that should stay
97
+ distinct, and shadowjoin names the better subset instead of letting you assume
98
+ more rules is better.
99
+
100
+ **It separates mechanical misses from semantic ones.** The 39 it cannot explain
101
+ are not normalization problems at all:
102
+
103
+ ```
104
+ Vatican City vs Holy See
105
+ North Korea vs Democratic People's Republic of Korea
106
+ Cape Verde vs Cabo Verde
107
+ Bosnia vs Bosnia and Herzegovina
108
+ Hong Kong vs China, Hong Kong Special Administrative Region
109
+ ```
110
+
111
+ No string transform recovers those. They need an alias table or a real entity
112
+ resolution library. Knowing *which* of your misses are mechanical and which are
113
+ semantic is the difference between a one-line fix and a project.
114
+
115
+ ```bash
116
+ node dist/cli.js scan \
117
+ --left examples/countries_ui.csv --left-key country --left-truth iso3 \
118
+ --right examples/countries_reference.csv --right-key country --right-truth iso3
119
+ ```
120
+
121
+ > That particular join is deliberately unflattering — a short index name against a
122
+ > full security description. It is a worked example of the *measurement*, not a
123
+ > claim that anyone ships that join. Point it at your own data to get a figure
124
+ > that means something about you.
125
+
126
+ ## The number is a floor, not an estimate
127
+
128
+ You cannot compute recall without labels. The entity-resolution literature calls
129
+ the metric **pair completeness**, and it assumes an oracle you do not have in
130
+ production.
131
+
132
+ So shadowjoin reports a **lower bound**: matches the shadow matcher found that
133
+ exact matching missed. The shadow matcher has unknown recall of its own, so the
134
+ true loss is *at least* this large and may be larger. Every figure ships with
135
+ sample pairs so a human can verify them by hand, and when ground truth is
136
+ available on both sides, the report carries a precision figure too.
137
+
138
+ This is deliberately the weaker claim. It is also the one that survives being
139
+ questioned.
140
+
141
+ ## Why not just ask an LLM to check?
142
+
143
+ For a one-off look at a small table, do that — it is faster and this tool is
144
+ redundant. It stops working when:
145
+
146
+ - **You need the same number twice.** An agent writes different SQL every run, so
147
+ you cannot tell whether the pipeline changed or the query did.
148
+ - **You need rules nobody thought of.** Asked "is this join lossy," an agent tries
149
+ `lower()` and `trim()`. It does not systematically try NFKD folding, diacritics,
150
+ leading zeros, corporate suffixes, token reordering or listing boilerplate. The
151
+ exhaustive rule set *is* the product.
152
+ - **You need it in CI.** `--max-loss` exits non-zero. "Ask Claude" has no exit code.
153
+ - **Exploratory SQL on production is expensive.** This does hash joins on
154
+ precomputed keys, under a statement timeout, in a read-only transaction.
155
+
156
+ ## Install
157
+
158
+ ```bash
159
+ npm install shadowjoin # or run it with npx, no install
160
+ ```
161
+
162
+ Zero runtime dependencies. `pg` and `@modelcontextprotocol/sdk` are optional
163
+ peers, pulled in only if you use the Postgres adapter or the MCP server.
164
+
165
+ ## Use it
166
+
167
+ ### CLI
168
+
169
+ ```bash
170
+ # CSV or JSON, no database involved
171
+ npx shadowjoin scan --left a.csv --left-key name --right b.json --right-key title
172
+
173
+ # with ground truth on both sides, so recoveries are verified
174
+ npx shadowjoin scan \
175
+ --left a.csv --left-key name --left-truth customer_id \
176
+ --right b.csv --right-key title --right-truth customer_id
177
+
178
+ # straight at Postgres
179
+ npx shadowjoin scan \
180
+ --left 'postgres://ro@host/db' --left-table orders --left-key customer_name \
181
+ --right 'postgres://ro@host/db' --right-table customers --right-key name
182
+
183
+ # in CI: fail the build if the floor climbs above 2%
184
+ npx shadowjoin scan --left a.csv --left-key name --right b.csv --right-key name --max-loss 2
185
+
186
+ # MySQL and SQLite work the same way
187
+ npx shadowjoin scan \
188
+ --left 'mysql://ro@host/db' --left-table accounts --left-key company_name \
189
+ --right ./reference.sqlite --right-table vendors --right-key name
190
+
191
+ npx shadowjoin rules # list the normalization rules
192
+ ```
193
+
194
+ **Sources:** CSV, JSON, Postgres, MySQL and SQLite. SQLite uses Node's built-in
195
+ `node:sqlite` (Node 22.5+), so it needs nothing installed; Postgres and MySQL
196
+ need `pg` or `mysql2`, which are optional peers.
197
+
198
+ **Anything else** works through the library in three lines, because the core
199
+ takes two arrays rather than a connection:
200
+
201
+ ```js
202
+ import { scan } from "shadowjoin";
203
+ const [rows] = await anyDriver.query("SELECT id, company_name FROM accounts");
204
+ const left = rows.map((r) => ({ id: String(r.id), key: r.company_name }));
205
+ scan(left, right);
206
+ ```
207
+
208
+ ### Library
209
+
210
+ ```js
211
+ import { scan, normalize, rulesByIds, toSql } from "shadowjoin";
212
+
213
+ const report = scan(leftRows, rightRows); // rows are { id, key, truth? }
214
+ console.log(report.lowerBoundLossPct, report.stack);
215
+
216
+ // adopt the recommended rules in your own pipeline
217
+ const rules = rulesByIds(report.stack.map((s) => s.rule));
218
+ const key = (value) => normalize(value, rules);
219
+ toSql("company_name", rules); // the same thing as Postgres SQL
220
+ ```
221
+
222
+ ### MCP server
223
+
224
+ Lets an agent measure a join, look at real examples, propose a fix, and then
225
+ **prove the fix worked** — a loop it cannot fake, because the measurement is
226
+ not something it writes.
227
+
228
+ ```json
229
+ {
230
+ "mcpServers": {
231
+ "shadowjoin": {
232
+ "command": "npx",
233
+ "args": ["-y", "-p", "shadowjoin", "shadowjoin-mcp"],
234
+ "env": { "SHADOWJOIN_CONFIG": "/abs/path/to/shadowjoin.config.json" }
235
+ }
236
+ }
237
+ }
238
+ ```
239
+
240
+ ```json
241
+ {
242
+ "sources": {
243
+ "prod": { "type": "postgres", "connectionString": "postgres://shadowjoin_ro@host/db" },
244
+ "crm": { "type": "csv", "path": "./crm_export.csv" }
245
+ }
246
+ }
247
+ ```
248
+
249
+ Tools: `scan_join`, `explain_misses`, `suggest_normalization`, `compare_runs`.
250
+
251
+ **Two things this MCP server deliberately does not have:**
252
+
253
+ 1. **No tool that executes arbitrary SQL.** An agent can name a configured source,
254
+ a table and a column. That is the entire surface.
255
+ 2. **No tool that accepts a connection string.** Credentials live in your config
256
+ file and never enter a tool call, because tool calls end up in transcripts.
257
+
258
+ ## Pointing it at a real database
259
+
260
+ shadowjoin opens a `READ ONLY` transaction with `statement_timeout`,
261
+ `lock_timeout` and `idle_in_transaction_session_timeout` set, and it **refuses to
262
+ run** if the role it connected with can write to the table it is about to read.
263
+
264
+ Give it a role that makes trust unnecessary:
265
+
266
+ ```sql
267
+ CREATE ROLE shadowjoin_ro LOGIN PASSWORD 'xxx';
268
+ GRANT CONNECT ON DATABASE yourdb TO shadowjoin_ro;
269
+ GRANT USAGE ON SCHEMA public TO shadowjoin_ro;
270
+ GRANT SELECT ON public.orders, public.customers TO shadowjoin_ro;
271
+ ```
272
+
273
+ Point it at a replica where you can.
274
+
275
+ **It makes no outbound network connection except to the source you give it.** No
276
+ telemetry, not even opt-out. Check the imports — there are three dependencies and
277
+ none of them is an HTTP client.
278
+
279
+ ## Limits
280
+
281
+ Read these before quoting a number from it.
282
+
283
+ - **The figure is a floor.** The shadow matcher's own recall is unknown. True loss
284
+ is at least this, possibly more.
285
+ - **Recoveries can be wrong.** In the bundled example, 1 of 428 was contradicted
286
+ by ground truth and 8 were ambiguous — mostly share classes, where stripping
287
+ "Class A" merges genuinely different securities. Ambiguous matches need a
288
+ tie-breaker, not a normalization rule. Always read `samplePairs`.
289
+ - **Per-rule counts overlap and will not sum to the total.** Each is measured
290
+ independently against the exact baseline, deliberately, so the numbers are not
291
+ an artifact of the order rules happen to run in. Use `stack` — the greedy
292
+ ordering — to decide what to actually adopt.
293
+ - **More rules is not better.** Applying every rule can recover *fewer* matches
294
+ than the best subset, because over-normalization merges keys that should stay
295
+ apart. shadowjoin warns when this happens and names the better subset.
296
+ - **It reads your join keys**, so those values are in memory while it runs.
297
+ `--redact` hashes them in sample output and server-side normalization keeps
298
+ whole rows in the database, but neither makes the keys invisible. For regulated
299
+ data, run it on a replica inside your own environment.
300
+ - **It does not write, fix or migrate anything.** It measures.
301
+ - **`unaccent` in emitted SQL** needs the Postgres extension of the same name.
302
+
303
+ ## Prior art
304
+
305
+ Blocking recall — *pair completeness* — is well established in the
306
+ entity-resolution literature, along with its companions pairs quality and
307
+ reduction ratio:
308
+
309
+ - [A Survey of Blocking and Filtering Techniques for Entity Resolution](https://arxiv.org/pdf/1905.06167)
310
+ - [Comparative Analysis of Approximate Blocking Techniques for Entity Resolution](http://www.vldb.org/pvldb/vol9/p684-papadakis.pdf)
311
+ - [Supervised Meta-blocking](http://www.vldb.org/pvldb/vol7/p1929-papadakis.pdf)
312
+
313
+ Those papers assume a labelled gold standard. shadowjoin's contribution is not the
314
+ metric — it is computing a defensible version of it against a live pipeline with
315
+ no labels at all, and reporting it as a bound rather than a point estimate.
316
+
317
+ For *doing* the matching rather than measuring what yours misses, use
318
+ [Splink](https://github.com/moj-analytical-services/splink),
319
+ [Dedupe](https://github.com/dedupeio/dedupe) or
320
+ [recordlinkage](https://github.com/J535D165/recordlinkage). shadowjoin is
321
+ complementary: it tells you whether you need them.
322
+
323
+ ## License
324
+
325
+ MIT © Harsh N. Chandak
package/SKILL.md ADDED
@@ -0,0 +1,66 @@
1
+ ---
2
+ name: shadowjoin
3
+ description: Measure how many matches a join or matching pipeline is silently dropping, and fix it. Use when records "aren't matching", duplicates slipped through, a join returns fewer rows than expected, a dedupe step looks wrong, or before and after editing any matching, joining, dedupe or entity-resolution logic. Requires the shadowjoin MCP server.
4
+ ---
5
+
6
+ # shadowjoin
7
+
8
+ A join that drops rows raises no error. The query returns, the dashboard renders,
9
+ and the missing matches are invisible. This skill measures that loss and proves
10
+ whether a fix worked.
11
+
12
+ ## When to use it
13
+
14
+ - The user says records aren't matching, rows are missing from a join, duplicates
15
+ got through, or a dedupe/merge produced the wrong count.
16
+ - **Before** you change any matching, joining, normalization or dedupe logic —
17
+ take a baseline first.
18
+ - **After** you change it — you cannot otherwise tell whether your edit helped.
19
+
20
+ ## The loop
21
+
22
+ 1. **`scan_join`** — baseline. Note the `runId`, the `exactMatchRate`, and the
23
+ `lowerBoundLossPct`.
24
+ 2. **`explain_misses`** — pull real pairs. Show the user actual values, not a
25
+ percentage. A reviewer believes `"Altria" -> "Altria Group Inc."`; nobody
26
+ believes "85%".
27
+ 3. **`suggest_normalization`** — get the ranked rules with pasteable SQL and JS.
28
+ Apply them to the user's pipeline.
29
+ 4. **`scan_join`** again, then **`compare_runs`** — prove the change landed.
30
+
31
+ ## Rules for reporting this
32
+
33
+ **The number is a floor, never an estimate.** Say "at least 23% of matches are
34
+ being lost", never "23% of matches are lost". The shadow matcher's own recall is
35
+ unknown, so the true figure can only be larger.
36
+
37
+ **Never claim an improvement without `compare_runs`.** If you changed matching
38
+ logic and `exactMatchRate` did not move, your change did not take effect — say
39
+ that plainly rather than reporting success.
40
+
41
+ **Always surface contradicted and ambiguous pairs.** `contradicted` means the
42
+ matcher merged two records that ground truth says are different. `ambiguous`
43
+ means several candidates collapsed onto one key — typically share classes,
44
+ product variants or franchise locations. Those need a tie-breaker, not another
45
+ normalization rule. Never recommend adopting a rule without showing these counts.
46
+
47
+ **More rules is not better.** If the report warns that the full rule set recovers
48
+ fewer matches than a subset, recommend the subset it names. Over-normalization
49
+ merges records that should stay apart.
50
+
51
+ **Adopting a rule widens matching.** Say so. Recovering 400 matches at the cost
52
+ of 12 wrong merges may be a bad trade in a billing system and a fine one in
53
+ search. That judgement belongs to the user — give them both numbers.
54
+
55
+ ## Privacy
56
+
57
+ `explain_misses` returns real key values. If the data looks sensitive — people's
58
+ names, emails, account numbers — tell the user before dumping examples into the
59
+ conversation, and prefer summarising the *rule* and the count over pasting rows.
60
+
61
+ ## What it will not do
62
+
63
+ It does not write, fix, migrate or dedupe anything. It measures, and it hands you
64
+ expressions to apply yourself. If the user asks it to "fix the matching", the fix
65
+ is you editing their pipeline with `suggest_normalization`'s output and then
66
+ proving it with `compare_runs`.
@@ -0,0 +1,13 @@
1
+ import type { Row } from "../types.js";
2
+ /** Minimal RFC-4180 parser: quoted fields, embedded commas, doubled quotes. */
3
+ export declare function parseCsv(text: string): string[][];
4
+ export interface CsvOptions {
5
+ /** Column holding the join key. */
6
+ keyColumn: string;
7
+ /** Column holding a stable id. Defaults to the row number. */
8
+ idColumn?: string;
9
+ /** Column holding ground truth, if you have one. */
10
+ truthColumn?: string;
11
+ }
12
+ export declare function rowsFromCsvText(text: string, options: CsvOptions): Row[];
13
+ export declare function rowsFromCsv(path: string, options: CsvOptions): Promise<Row[]>;
@@ -0,0 +1,73 @@
1
+ import { readFile } from "node:fs/promises";
2
+ /** Minimal RFC-4180 parser: quoted fields, embedded commas, doubled quotes. */
3
+ export function parseCsv(text) {
4
+ const rows = [];
5
+ let field = "", row = [], quoted = false;
6
+ const src = text.replace(/^/, "");
7
+ for (let i = 0; i < src.length; i++) {
8
+ const c = src[i];
9
+ if (quoted) {
10
+ if (c === '"') {
11
+ if (src[i + 1] === '"') {
12
+ field += '"';
13
+ i++;
14
+ }
15
+ else
16
+ quoted = false;
17
+ }
18
+ else
19
+ field += c;
20
+ continue;
21
+ }
22
+ if (c === '"')
23
+ quoted = true;
24
+ else if (c === ",") {
25
+ row.push(field);
26
+ field = "";
27
+ }
28
+ else if (c === "\n") {
29
+ row.push(field);
30
+ rows.push(row);
31
+ row = [];
32
+ field = "";
33
+ }
34
+ else if (c !== "\r")
35
+ field += c;
36
+ }
37
+ if (field.length > 0 || row.length > 0) {
38
+ row.push(field);
39
+ rows.push(row);
40
+ }
41
+ return rows.filter((r) => r.length > 1 || (r[0] ?? "").trim() !== "");
42
+ }
43
+ export function rowsFromCsvText(text, options) {
44
+ const table = parseCsv(text);
45
+ if (table.length === 0)
46
+ return [];
47
+ const header = table[0].map((h) => h.trim());
48
+ const at = (name) => {
49
+ const i = header.findIndex((h) => h.toLowerCase() === name.toLowerCase());
50
+ if (i === -1)
51
+ throw new Error(`Column "${name}" not found. Columns: ${header.join(", ")}`);
52
+ return i;
53
+ };
54
+ const k = at(options.keyColumn);
55
+ const idx = options.idColumn ? at(options.idColumn) : -1;
56
+ const t = options.truthColumn ? at(options.truthColumn) : -1;
57
+ const rows = [];
58
+ for (let i = 1; i < table.length; i++) {
59
+ const rec = table[i];
60
+ const key = (rec[k] ?? "").trim();
61
+ if (!key)
62
+ continue;
63
+ rows.push({
64
+ id: idx === -1 ? String(i) : (rec[idx] ?? String(i)).trim(),
65
+ key,
66
+ ...(t === -1 ? {} : { truth: (rec[t] ?? "").trim() || undefined }),
67
+ });
68
+ }
69
+ return rows;
70
+ }
71
+ export async function rowsFromCsv(path, options) {
72
+ return rowsFromCsvText(await readFile(path, "utf8"), options);
73
+ }
@@ -0,0 +1,4 @@
1
+ import type { Row } from "../types.js";
2
+ import type { CsvOptions } from "./csv.js";
3
+ export declare function rowsFromJsonText(text: string, options: CsvOptions): Row[];
4
+ export declare function rowsFromJson(path: string, options: CsvOptions): Promise<Row[]>;
@@ -0,0 +1,25 @@
1
+ import { readFile } from "node:fs/promises";
2
+ export function rowsFromJsonText(text, options) {
3
+ const parsed = JSON.parse(text);
4
+ if (!Array.isArray(parsed))
5
+ throw new Error("Expected the JSON file to contain an array of objects.");
6
+ const rows = [];
7
+ parsed.forEach((rec, i) => {
8
+ if (typeof rec !== "object" || rec === null)
9
+ return;
10
+ const obj = rec;
11
+ const key = String(obj[options.keyColumn] ?? "").trim();
12
+ if (!key)
13
+ return;
14
+ const truth = options.truthColumn ? String(obj[options.truthColumn] ?? "").trim() : "";
15
+ rows.push({
16
+ id: options.idColumn ? String(obj[options.idColumn] ?? i) : String(i),
17
+ key,
18
+ ...(truth ? { truth } : {}),
19
+ });
20
+ });
21
+ return rows;
22
+ }
23
+ export async function rowsFromJson(path, options) {
24
+ return rowsFromJsonText(await readFile(path, "utf8"), options);
25
+ }
@@ -0,0 +1,21 @@
1
+ import type { Row } from "../types.js";
2
+ export interface MysqlSource {
3
+ /** mysql:// connection string. Prefer a replica and a read-only user. */
4
+ connectionString: string;
5
+ table: string;
6
+ keyColumn: string;
7
+ idColumn?: string;
8
+ truthColumn?: string;
9
+ limit?: number;
10
+ /** Per-statement cap in ms. Default 30000. */
11
+ statementTimeoutMs?: number;
12
+ /** shadowjoin refuses a user that can write to the table it is about to read. */
13
+ allowWritableRole?: boolean;
14
+ }
15
+ /**
16
+ * Read join keys out of MySQL inside a READ ONLY transaction with a statement
17
+ * timeout, and refuse a user that holds write privileges on the target.
18
+ *
19
+ * Requires the optional peer dependency `mysql2`.
20
+ */
21
+ export declare function rowsFromMysql(source: MysqlSource): Promise<Row[]>;
@@ -0,0 +1,72 @@
1
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_$]*$/;
2
+ function quoteIdent(name) {
3
+ const parts = name.split(".");
4
+ for (const part of parts) {
5
+ if (!IDENT.test(part)) {
6
+ throw new Error(`Refusing identifier "${name}". Use a plain table or column name; shadowjoin does not accept arbitrary SQL.`);
7
+ }
8
+ }
9
+ return parts.map((p) => `\`${p}\``).join(".");
10
+ }
11
+ /**
12
+ * Read join keys out of MySQL inside a READ ONLY transaction with a statement
13
+ * timeout, and refuse a user that holds write privileges on the target.
14
+ *
15
+ * Requires the optional peer dependency `mysql2`.
16
+ */
17
+ export async function rowsFromMysql(source) {
18
+ let mysql;
19
+ try {
20
+ mysql = await import("mysql2/promise");
21
+ }
22
+ catch {
23
+ throw new Error("The MySQL adapter needs the optional peer dependency `mysql2`. Install it with: npm i mysql2");
24
+ }
25
+ const table = quoteIdent(source.table);
26
+ const key = quoteIdent(source.keyColumn);
27
+ const id = source.idColumn ? quoteIdent(source.idColumn) : null;
28
+ const truth = source.truthColumn ? quoteIdent(source.truthColumn) : null;
29
+ const limit = Math.max(1, Math.min(source.limit ?? 100_000, 5_000_000));
30
+ const timeout = source.statementTimeoutMs ?? 30_000;
31
+ const conn = await mysql.createConnection(source.connectionString);
32
+ try {
33
+ if (!source.allowWritableRole) {
34
+ const [grants] = await conn.query("SHOW GRANTS FOR CURRENT_USER()");
35
+ const writable = grants.some((g) => {
36
+ const text = String(Object.values(g)[0] ?? "").toUpperCase();
37
+ if (!/\bON\b/.test(text))
38
+ return false;
39
+ return /\b(ALL PRIVILEGES|INSERT|UPDATE|DELETE)\b/.test(text);
40
+ });
41
+ if (writable) {
42
+ throw new Error(`The connected MySQL user holds write privileges. shadowjoin only reads — use a SELECT-only user (see the README) or pass allowWritableRole: true to override.`);
43
+ }
44
+ }
45
+ await conn.query(`SET SESSION max_execution_time = ${Number(timeout)}`);
46
+ await conn.query("START TRANSACTION READ ONLY");
47
+ const select = [
48
+ `${key} AS \`key\``,
49
+ id ? `CAST(${id} AS CHAR) AS \`id\`` : `CAST(ROW_NUMBER() OVER () AS CHAR) AS \`id\``,
50
+ truth ? `CAST(${truth} AS CHAR) AS \`truth\`` : `NULL AS \`truth\``,
51
+ ].join(", ");
52
+ const [rows] = await conn.query(`SELECT ${select} FROM ${table} WHERE ${key} IS NOT NULL LIMIT ${limit}`);
53
+ await conn.query("COMMIT");
54
+ return rows
55
+ .filter((r) => String(r.key ?? "").trim() !== "")
56
+ .map((r) => ({
57
+ id: String(r.id),
58
+ key: String(r.key).trim(),
59
+ ...(r.truth ? { truth: String(r.truth).trim() } : {}),
60
+ }));
61
+ }
62
+ catch (err) {
63
+ try {
64
+ await conn.query("ROLLBACK");
65
+ }
66
+ catch { /* connection may already be gone */ }
67
+ throw err;
68
+ }
69
+ finally {
70
+ await conn.end();
71
+ }
72
+ }
@@ -0,0 +1,28 @@
1
+ import type { Row } from "../types.js";
2
+ export interface PostgresSource {
3
+ /** Postgres connection string. Prefer a replica and a read-only role. */
4
+ connectionString: string;
5
+ /** Table, optionally schema-qualified. */
6
+ table: string;
7
+ /** Column holding the join key. */
8
+ keyColumn: string;
9
+ idColumn?: string;
10
+ truthColumn?: string;
11
+ /** Row cap. Default 100000. */
12
+ limit?: number;
13
+ /** Per-statement timeout in ms. Default 30000. */
14
+ statementTimeoutMs?: number;
15
+ /**
16
+ * shadowjoin refuses a role that can write to the table it is about to read.
17
+ * A measurement tool has no business holding write capability.
18
+ */
19
+ allowWritableRole?: boolean;
20
+ }
21
+ /**
22
+ * Read join keys out of Postgres. Opens a READ ONLY transaction with a
23
+ * statement and lock timeout, so it cannot write, cannot block a writer, and
24
+ * cannot run away with your CPU.
25
+ *
26
+ * Requires the optional peer dependency `pg`.
27
+ */
28
+ export declare function rowsFromPostgres(source: PostgresSource): Promise<Row[]>;