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 +21 -0
- package/README.md +324 -2
- package/SKILL.md +66 -0
- package/dist/adapters/csv.d.ts +13 -0
- package/dist/adapters/csv.js +73 -0
- package/dist/adapters/json.d.ts +4 -0
- package/dist/adapters/json.js +25 -0
- package/dist/adapters/mysql.d.ts +21 -0
- package/dist/adapters/mysql.js +72 -0
- package/dist/adapters/postgres.d.ts +28 -0
- package/dist/adapters/postgres.js +72 -0
- package/dist/adapters/sqlite.d.ts +18 -0
- package/dist/adapters/sqlite.js +49 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +145 -0
- package/dist/format.d.ts +3 -0
- package/dist/format.js +63 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +5 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +229 -0
- package/dist/normalize.d.ts +19 -0
- package/dist/normalize.js +102 -0
- package/dist/scan.d.ts +25 -0
- package/dist/scan.js +260 -0
- package/dist/similarity.d.ts +34 -0
- package/dist/similarity.js +81 -0
- package/dist/types.d.ts +107 -0
- package/dist/types.js +1 -0
- package/dist/ui.d.ts +19 -0
- package/dist/ui.js +80 -0
- package/package.json +81 -4
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
|
-
#
|
|
1
|
+
# shadowjoin
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
[](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,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[]>;
|