@dooer/dooer-test-env 1.0.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/bin/index.js +7 -0
- package/discovery-router/Dockerfile +18 -0
- package/discovery-router/README.md +99 -0
- package/discovery-router/package.json +13 -0
- package/discovery-router/registry.example.json +5 -0
- package/discovery-router/server.js +272 -0
- package/lib/account.js +120 -0
- package/lib/auth-dev-keys.js +12 -0
- package/lib/bankid.js +130 -0
- package/lib/cli.js +27 -0
- package/lib/command/bankid.js +45 -0
- package/lib/command/customer.js +108 -0
- package/lib/command/db.js +156 -0
- package/lib/command/env.js +114 -0
- package/lib/command/logs.js +143 -0
- package/lib/command/measure.js +81 -0
- package/lib/command/service.js +166 -0
- package/lib/command/setup.js +92 -0
- package/lib/command/shred.js +60 -0
- package/lib/compose/README.md +98 -0
- package/lib/compose/generate.js +375 -0
- package/lib/compose/manifests.js +108 -0
- package/lib/db/roles.js +118 -0
- package/lib/discovery/client.js +40 -0
- package/lib/engine/GUIDE.md +176 -0
- package/lib/engine/PROCESS.md +571 -0
- package/lib/engine/dbbuild.js +325 -0
- package/lib/engine/gen-schema-map.js +479 -0
- package/lib/engine/purge.js +137 -0
- package/lib/engine/schema-map.json +11016 -0
- package/lib/engine/seed.js +1045 -0
- package/lib/obc.js +72 -0
- package/lib/registry.js +123 -0
- package/lib/runtime.js +101 -0
- package/lib/service-token.js +40 -0
- package/lib/shred/README.md +118 -0
- package/lib/shred/audit.js +128 -0
- package/lib/shred/faker.js +545 -0
- package/lib/shred/index.js +126 -0
- package/lib/shred/scripts/base-partner-emails.sql +9 -0
- package/lib/shred/scripts/dev-accounts.sql +195 -0
- package/lib/shred/scripts/emails.sql +48 -0
- package/lib/shred/scripts/institution-browser.sql +3 -0
- package/lib/shred/scripts/notification-targets.sql +5 -0
- package/lib/shred/scripts/partners.sql +2 -0
- package/lib/shred/scripts/passwords.sql +8 -0
- package/lib/shred/scripts/personal-numbers.sql +177 -0
- package/lib/shred/scripts/phone-numbers.sql +22 -0
- package/lib/shred/scripts/salary-spec-reports.sql +5 -0
- package/lib/shred/scripts/service-activity-tracker-data.sql +4 -0
- package/lib/shred/scripts/service-core-objects.sql +19 -0
- package/lib/shred/scripts/service-event-stream.sql +2 -0
- package/lib/shred/scripts/service-integrations.sql +4 -0
- package/lib/shred/scripts/template.sql +4 -0
- package/lib/shred/scripts/x-service-billing.sql +34 -0
- package/lib/shred/scripts/xxx-history-tables.sql +25 -0
- package/lib/stub.js +8 -0
- package/local-postgres/Dockerfile +11 -0
- package/package.json +46 -0
- package/readme.md +92 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* gen-schema-map.js — build schema-map.json from the read-only measurement TSVs in _measure/.
|
|
4
|
+
*
|
|
5
|
+
* Philosophy (Jimmy, 2026-08-24): START FROM ALL 297 org-scoped tables and COPY by default; a table
|
|
6
|
+
* is only `ignore` when there is a specific, stated reason AND we know it will not affect AI-booking
|
|
7
|
+
* tests. Every table is present in the map in structured form so a decision can be flipped by editing
|
|
8
|
+
* one `action` field — nothing is silently dropped.
|
|
9
|
+
*
|
|
10
|
+
* Run: node gen-schema-map.js (writes ./schema-map.json)
|
|
11
|
+
* Re-measure the inputs with the queries documented in PROCESS.md.
|
|
12
|
+
*/
|
|
13
|
+
const fs = require('fs')
|
|
14
|
+
const path = require('path')
|
|
15
|
+
const M = path.join(__dirname, '..', '..', '_measure')
|
|
16
|
+
|
|
17
|
+
const readTsv = (f) =>
|
|
18
|
+
fs
|
|
19
|
+
.readFileSync(path.join(M, f), 'utf8')
|
|
20
|
+
.split('\n')
|
|
21
|
+
.map((l) => l.trim())
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
|
|
24
|
+
// ── measurement inputs ───────────────────────────────────────────────────────
|
|
25
|
+
// m2: schema|table|orgColumn (one row per org-fk column; a table may appear several times)
|
|
26
|
+
const orgCols = readTsv('m2.org-fk-columns.tsv').map((l) => l.split('|'))
|
|
27
|
+
// m3: schema|table|pkColumn|pkType (PKs for every table in an org schema — over-inclusive)
|
|
28
|
+
const pkRows = readTsv('m3.pk-types.tsv').map((l) => l.split('|'))
|
|
29
|
+
// m5b: schema|table|emailColumn (email columns inside the copy set)
|
|
30
|
+
const emailRows = readTsv('m5b.email-in-copyset.tsv').map((l) => l.split('|'))
|
|
31
|
+
// m11: schema|table|column|typname|sequenceName (every single-column-unique column)
|
|
32
|
+
const uniqueRows = readTsv('m11.unique-columns.tsv').map((l) => l.split('|'))
|
|
33
|
+
// m4: child|childCol|=>|parent|parentCol (FK graph among org schemas)
|
|
34
|
+
const stripQ = (s) => s.replace(/"/g, '')
|
|
35
|
+
const fkEdges = readTsv('m4.fk-graph.tsv').map((l) => {
|
|
36
|
+
const [child, childCol, , parent, parentCol] = l.split('|')
|
|
37
|
+
return { child: stripQ(child), childCol, parent: stripQ(parent), parentCol }
|
|
38
|
+
})
|
|
39
|
+
// m5: ALL email columns in the org schemas (broader than m5b, so orphan tables get scrubbed too)
|
|
40
|
+
const emailAll = readTsv('m5.email-columns.tsv').map((l) => l.split('|'))
|
|
41
|
+
// m13: the COMPLETE FK graph (child|childCol|parent|parentCol) — used to compute the orphan closure
|
|
42
|
+
// TRANSITIVELY (below). Supersedes the old static m14.orphan-fk.json, which only captured ONE level and
|
|
43
|
+
// so missed children-of-orphans like document_files_converted (the page images). (fix 2026-08-27)
|
|
44
|
+
const allFk = readTsv('m13.all-fk.tsv').map((l) => {
|
|
45
|
+
const [child, childCol, parent, parentCol] = l.split('|')
|
|
46
|
+
return { child: stripQ(child), childCol: stripQ(childCol), parent: stripQ(parent), parentCol: stripQ(parentCol) }
|
|
47
|
+
})
|
|
48
|
+
// m7: schema.table present on staging but NOT production (schema drift — skip if absent either side)
|
|
49
|
+
const stagingOnly = new Set(readTsv('m7.copyset-missing-in-prod.txt'))
|
|
50
|
+
// m15: uuid columns that REFERENCE service_accounts.users (soft refs by name pattern + declared FKs).
|
|
51
|
+
// Copied rows point at users that may not exist in the target namespace (a cross-namespace copy), which
|
|
52
|
+
// breaks resolvers like `createdByUser`. We collect the ids in these columns, copy the referenced users
|
|
53
|
+
// that are missing from the target, and remap the refs. (Jimmy 2026-08-27)
|
|
54
|
+
const userRefRows = readTsv('m15.user-ref-columns.tsv').map((l) => l.split('|'))
|
|
55
|
+
|
|
56
|
+
// ── column-role vocabulary ───────────────────────────────────────────────────
|
|
57
|
+
// OWNER = holds the org's OWN id → used to FILTER rows and remapped source→target.
|
|
58
|
+
const OWNER = new Set([
|
|
59
|
+
'organizationId',
|
|
60
|
+
'company_id',
|
|
61
|
+
'organization_id',
|
|
62
|
+
'companyId',
|
|
63
|
+
'fk_companies_pk',
|
|
64
|
+
'companies_pk', // the org's own PK on service_accounts.companies
|
|
65
|
+
'taskOrganizationId', // scheduledTask: the task's owning org
|
|
66
|
+
'customerOrganizationId', // voice.call: the call's owning (customer) org
|
|
67
|
+
])
|
|
68
|
+
// CROSS-ORG = points at ANOTHER org → left as-is (UUID-rewrite only rewrites it if it equals source).
|
|
69
|
+
const CROSS_ORG = new Set([
|
|
70
|
+
'partnerOrganizationId',
|
|
71
|
+
'parent_company_id',
|
|
72
|
+
'child_company_id',
|
|
73
|
+
'organizationInstitutionId',
|
|
74
|
+
])
|
|
75
|
+
// NON-ORG = matched the regex but is a type/role/junction ref, not an org id → not a filter column.
|
|
76
|
+
const roleOf = (c) => (OWNER.has(c) ? 'owner' : CROSS_ORG.has(c) ? 'crossOrg' : 'nonOrg')
|
|
77
|
+
// owner-filter priority when a table carries several owner columns
|
|
78
|
+
const OWNER_PRIORITY = [
|
|
79
|
+
'organizationId',
|
|
80
|
+
'company_id',
|
|
81
|
+
'organization_id',
|
|
82
|
+
'companyId',
|
|
83
|
+
'fk_companies_pk',
|
|
84
|
+
'companies_pk',
|
|
85
|
+
'taskOrganizationId',
|
|
86
|
+
'customerOrganizationId',
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
// ── ignore rules (each with a stated reason; everything else copies) ─────────
|
|
90
|
+
const IGNORE_SCHEMA = {
|
|
91
|
+
service_institution_browser:
|
|
92
|
+
'Excluded by Dennis/Jimmy: bank-scraping/institution data, security-sensitive, irrelevant to AI booking.',
|
|
93
|
+
}
|
|
94
|
+
// identity / user / auth tables — target account + its owners are kept as-is (rule #4: never create or
|
|
95
|
+
// copy users). The source org id is remapped onto the EXISTING target org id instead.
|
|
96
|
+
const IGNORE_TABLE = {
|
|
97
|
+
'service_accounts.companies': 'Org identity row already exists in the target; keep it, remap org id.',
|
|
98
|
+
'service_accounts.companies2users': 'Owner/user links — rule #4: keep the target account owners, do not copy users.',
|
|
99
|
+
'service_accounts.user_roles_at_companies': 'User role assignments — user data, kept as-is on target.',
|
|
100
|
+
'service_accounts.user_roles_at_companies_privileges': 'User role privileges — user data, kept as-is on target.',
|
|
101
|
+
'service_accounts.user_privileges': 'Per-user privileges — user data, kept as-is on target.',
|
|
102
|
+
'service_accounts.invites': 'User invites — user/onboarding data, not part of accounting state.',
|
|
103
|
+
'service_accounts.signup': 'Signup/onboarding user data.',
|
|
104
|
+
'service_accounts.signupConnectAccount': 'Signup/onboarding user data.',
|
|
105
|
+
'service_accounts.tokenInvalidation': 'Auth token state — security, user-specific.',
|
|
106
|
+
'service_accounts.bankIdSession': 'BankID auth sessions — security, user-specific, transient.',
|
|
107
|
+
'service_outgoing_email.email':
|
|
108
|
+
'Outbound email queue — avoid any send from a test org; historical, not accounting state.',
|
|
109
|
+
'service_search.searchIndex':
|
|
110
|
+
'Derived/rebuildable full-text search index; its composite (entityType, entityId) unique would collide in a same-DB copy and it rebuilds itself from the copied source data. (Jimmy 2026-08-24)',
|
|
111
|
+
'service_documents.duplicateInfo':
|
|
112
|
+
'Derived/rebuildable duplicate-detection metadata; a CHECK constraint (documentIds_ordered: documentId1<documentId2) is broken when both uuids are remapped and their order flips. Rebuilt by the dedup pass. (2026-08-26)',
|
|
113
|
+
}
|
|
114
|
+
// Per-table EXTRA filter (SQL fragment ANDed onto the org filter; may use $1 = the source org id).
|
|
115
|
+
// Restricts a big table to rows tied to an ACTIVE task (not fulfilled/rejected) — both these tables
|
|
116
|
+
// carry a `taskId` FK to service_task_engine.task, so no join table is needed. (Jimmy 2026-08-26)
|
|
117
|
+
const ACTIVE_TASK = `"taskId" IN (SELECT id FROM service_task_engine.task WHERE "organizationId"=$1 AND status NOT IN ('fulfilled','rejected'))`
|
|
118
|
+
const EXTRA_FILTER = {
|
|
119
|
+
'service_accounting_checks.check': ACTIVE_TASK,
|
|
120
|
+
'service_task_engine.taskEventAction': ACTIVE_TASK,
|
|
121
|
+
}
|
|
122
|
+
// advisory only (still action=copy): tables you may additionally want to drop later. Flip action to
|
|
123
|
+
// "ignore" to exclude — left in so the default is inclusive per the start-from-all principle.
|
|
124
|
+
const IGNORE_CANDIDATES = [
|
|
125
|
+
'service_notifications.notification',
|
|
126
|
+
'service_activity_log.userActivity',
|
|
127
|
+
'service_activity_log.groupedUserActivity',
|
|
128
|
+
'service_activity_log.groupedUserActivityLogLink',
|
|
129
|
+
'service_activity_tracker_data.trackedEvent',
|
|
130
|
+
'service_activity_tracker_data.salesActivity',
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
// REVERSE-reference parents: tables with NO org column that are REFERENCED BY a copied table and must
|
|
134
|
+
// themselves be copied (fresh rows) rather than shared. `fileData` is the S3-blob metadata row (S3
|
|
135
|
+
// key = its id); we copy it with a new id and physically copy the S3 object to the new key, so the
|
|
136
|
+
// copy owns its own files (no sharing → no cross-account delete risk). (Jimmy 2026-08-26)
|
|
137
|
+
const REVERSE_REF = [
|
|
138
|
+
{
|
|
139
|
+
key: 'service_file_storage.fileData',
|
|
140
|
+
schema: 'service_file_storage',
|
|
141
|
+
table: 'fileData',
|
|
142
|
+
fkFilter: {
|
|
143
|
+
column: 'id',
|
|
144
|
+
parentSchema: 'service_file_storage',
|
|
145
|
+
parentTable: 'fileEntry',
|
|
146
|
+
parentColumn: 'fileDataId',
|
|
147
|
+
parentOrgColumn: 'organizationId',
|
|
148
|
+
},
|
|
149
|
+
s3Blob: true, // its id is an S3 object key → the S3 copy phase moves the object to the new id
|
|
150
|
+
},
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
// ── transitive orphan closure ────────────────────────────────────────────────
|
|
154
|
+
// An "orphan" is a table with no owner (org) column. It is copyable iff a chain of FKs reaches an
|
|
155
|
+
// org-rooted table. A ONE-level orphan (document_files → documents) terminates at `parentOrgColumn`; a
|
|
156
|
+
// MULTI-level orphan (document_files_converted → document_files → documents) nests a `parentFilter` so
|
|
157
|
+
// the whole chain roots at an org-scoped ancestor. We only route through parents that are themselves
|
|
158
|
+
// copyable (rooted-and-not-ignored, or an already-resolved orphan) so no copied row dangles.
|
|
159
|
+
const splitKey = (k) => {
|
|
160
|
+
const d = k.indexOf('.')
|
|
161
|
+
return [k.slice(0, d), k.slice(d + 1)]
|
|
162
|
+
}
|
|
163
|
+
const isIgnoredKey = (key) => {
|
|
164
|
+
const [schema, table] = splitKey(key)
|
|
165
|
+
return !!IGNORE_SCHEMA[schema] || !!IGNORE_TABLE[key] || /__history$/.test(table) || /_history$/.test(schema)
|
|
166
|
+
}
|
|
167
|
+
// owner filter column per table (same rule as the per-table build below), computed up-front
|
|
168
|
+
const ownerColOf = {}
|
|
169
|
+
{
|
|
170
|
+
const ownersByKey = {}
|
|
171
|
+
for (const [schema, table, column] of orgCols) {
|
|
172
|
+
if (roleOf(column) !== 'owner') continue
|
|
173
|
+
const key = `${schema}.${table}`
|
|
174
|
+
;(ownersByKey[key] = ownersByKey[key] || []).push(column)
|
|
175
|
+
}
|
|
176
|
+
for (const key of Object.keys(ownersByKey)) {
|
|
177
|
+
ownerColOf[key] = OWNER_PRIORITY.find((p) => ownersByKey[key].includes(p)) || ownersByKey[key][0]
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const copyable = new Set() // rooted (owner column) and not ignored — the closure seed
|
|
181
|
+
for (const key of Object.keys(ownerColOf)) if (!isIgnoredKey(key)) copyable.add(key)
|
|
182
|
+
const computedOrphanFks = {} // key → fkFilter (possibly nested via parentFilter)
|
|
183
|
+
let grew = true
|
|
184
|
+
while (grew) {
|
|
185
|
+
grew = false
|
|
186
|
+
for (const e of allFk) {
|
|
187
|
+
if (copyable.has(e.child) || isIgnoredKey(e.child)) continue
|
|
188
|
+
if (!copyable.has(e.parent)) continue
|
|
189
|
+
const [ps, pt] = splitKey(e.parent)
|
|
190
|
+
const base = { column: e.childCol, parentSchema: ps, parentTable: pt, parentColumn: e.parentCol }
|
|
191
|
+
if (ownerColOf[e.parent]) computedOrphanFks[e.child] = { ...base, parentOrgColumn: ownerColOf[e.parent] }
|
|
192
|
+
else if (computedOrphanFks[e.parent])
|
|
193
|
+
computedOrphanFks[e.child] = { ...base, parentFilter: computedOrphanFks[e.parent] }
|
|
194
|
+
else continue // parent copyable via a special handler but exposes no filter to nest — try another edge
|
|
195
|
+
copyable.add(e.child)
|
|
196
|
+
grew = true
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── build per-table records ──────────────────────────────────────────────────
|
|
201
|
+
const tables = {}
|
|
202
|
+
for (const [schema, table, column] of orgCols) {
|
|
203
|
+
const key = `${schema}.${table}`
|
|
204
|
+
if (!tables[key]) tables[key] = { schema, table, orgColumns: [], emailColumns: [] }
|
|
205
|
+
tables[key].orgColumns.push({ column, role: roleOf(column) })
|
|
206
|
+
}
|
|
207
|
+
// orphan child tables (no org column): copied by filtering a FK chain into a copied parent (transitive
|
|
208
|
+
// closure above; nested `parentFilter` for children-of-orphans)
|
|
209
|
+
for (const childKey of Object.keys(computedOrphanFks)) {
|
|
210
|
+
const [schema, table] = splitKey(childKey)
|
|
211
|
+
if (!tables[childKey])
|
|
212
|
+
tables[childKey] = { schema, table, orgColumns: [], emailColumns: [], fkFilter: computedOrphanFks[childKey] }
|
|
213
|
+
}
|
|
214
|
+
// reverse-reference parents (fileData): copied via a FK from a copied child
|
|
215
|
+
for (const r of REVERSE_REF) {
|
|
216
|
+
if (!tables[r.key])
|
|
217
|
+
tables[r.key] = {
|
|
218
|
+
schema: r.schema,
|
|
219
|
+
table: r.table,
|
|
220
|
+
orgColumns: [],
|
|
221
|
+
emailColumns: [],
|
|
222
|
+
fkFilter: r.fkFilter,
|
|
223
|
+
s3Blob: r.s3Blob,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// attach PK (single-column only; composite/none → pk:null, copied as a leaf with UUID-rewrite)
|
|
227
|
+
const pkByTable = {}
|
|
228
|
+
for (const [schema, table, col, type] of pkRows) {
|
|
229
|
+
const key = `${schema}.${table}`
|
|
230
|
+
if (!pkByTable[key]) pkByTable[key] = []
|
|
231
|
+
pkByTable[key].push({ column: col, type })
|
|
232
|
+
}
|
|
233
|
+
// only scrub columns that actually hold an email ADDRESS — never a PK or an *Id/*MessageID column
|
|
234
|
+
// (scrubbing a key to a constant would collide/corrupt).
|
|
235
|
+
const looksLikeAddress = (c) => /email/i.test(c) && !/(_pk$|id$|messageid$)/i.test(c)
|
|
236
|
+
for (const [schema, table, col] of emailAll) {
|
|
237
|
+
const key = `${schema}.${table}`
|
|
238
|
+
if (tables[key] && looksLikeAddress(col)) tables[key].emailColumns.push(col)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const out = []
|
|
242
|
+
for (const key of Object.keys(tables).sort()) {
|
|
243
|
+
const t = tables[key]
|
|
244
|
+
const pks = pkByTable[key] || []
|
|
245
|
+
const pk = pks.length === 1 ? pks[0] : null // composite/none → null
|
|
246
|
+
// owner filter column
|
|
247
|
+
const owners = t.orgColumns.filter((c) => c.role === 'owner').map((c) => c.column)
|
|
248
|
+
const filterColumn = OWNER_PRIORITY.find((p) => owners.includes(p)) || owners[0] || null
|
|
249
|
+
// company_relation has only parent/child cross-org cols — special (match either side)
|
|
250
|
+
const special = []
|
|
251
|
+
if (key === 'service_core_objects.transactions')
|
|
252
|
+
special.push('integerPkRemap: transactions_pk (int) needs old→new remap kept consistent with the *_uuid twin')
|
|
253
|
+
if (key === 'service_authorization.company_relation')
|
|
254
|
+
special.push(
|
|
255
|
+
'crossOrgRelation: copy rows where parent_company_id OR child_company_id = source; remap the matching side'
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
// decision
|
|
259
|
+
let action = 'copy'
|
|
260
|
+
let ignoreReason = null
|
|
261
|
+
if (IGNORE_SCHEMA[t.schema]) {
|
|
262
|
+
action = 'ignore'
|
|
263
|
+
ignoreReason = IGNORE_SCHEMA[t.schema]
|
|
264
|
+
} else if (IGNORE_TABLE[key]) {
|
|
265
|
+
action = 'ignore'
|
|
266
|
+
ignoreReason = IGNORE_TABLE[key]
|
|
267
|
+
} else if (/__history$/.test(t.table) || /_history$/.test(t.schema)) {
|
|
268
|
+
// audit/history mirror tables (a `_history` schema, or a `__history` table) — not source-of-truth
|
|
269
|
+
// state, rebuilt by the history triggers, and large/slow to copy. (Jimmy 2026-08-26)
|
|
270
|
+
action = 'ignore'
|
|
271
|
+
ignoreReason =
|
|
272
|
+
'Audit/history mirror — not source-of-truth state, regenerated by history triggers, large/slow to copy.'
|
|
273
|
+
} else if (!filterColumn && !t.fkFilter && special.length === 0) {
|
|
274
|
+
// safe default: no owner-filter column, no FK filter, no special handler → cannot determine which
|
|
275
|
+
// rows belong to the org, so do NOT copy. Review manually to opt in.
|
|
276
|
+
action = 'ignore'
|
|
277
|
+
ignoreReason =
|
|
278
|
+
'No owner-filter column found (only ' +
|
|
279
|
+
t.orgColumns.map((c) => `${c.column}:${c.role}`).join(', ') +
|
|
280
|
+
') — cannot scope rows to the org. Review before enabling.'
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
out.push({
|
|
284
|
+
key,
|
|
285
|
+
schema: t.schema,
|
|
286
|
+
table: t.table,
|
|
287
|
+
action,
|
|
288
|
+
...(ignoreReason ? { ignoreReason } : {}),
|
|
289
|
+
...(IGNORE_CANDIDATES.includes(key) ? { ignoreCandidate: true } : {}),
|
|
290
|
+
...(EXTRA_FILTER[key] ? { extraFilter: EXTRA_FILTER[key] } : {}),
|
|
291
|
+
...(t.fkFilter ? { fkFilter: t.fkFilter } : {}),
|
|
292
|
+
...(t.s3Blob ? { s3Blob: true } : {}),
|
|
293
|
+
presentInProdAndStaging: !stagingOnly.has(key),
|
|
294
|
+
filterColumn,
|
|
295
|
+
orgColumns: t.orgColumns,
|
|
296
|
+
pk: pk ? { column: pk.column, type: pk.type } : null,
|
|
297
|
+
emailColumns: t.emailColumns,
|
|
298
|
+
...(special.length ? { special } : {}),
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── uniqueness strategies (per-column, config-driven; the script hardcodes NOTHING) ──────────────
|
|
303
|
+
// Every single-column-unique column needs a strategy so a copy never collides on it. uuids remap
|
|
304
|
+
// (value carries no meaning); sequence-backed ints draw nextval; app-assigned unique ints get a fresh
|
|
305
|
+
// value above the target's max; unique text keys get a suffix; edit any column's `strategy` to change it.
|
|
306
|
+
const copySet = new Set(out.filter((t) => t.action === 'copy').map((t) => t.key))
|
|
307
|
+
const DEFAULT_BY_TYPE = {
|
|
308
|
+
uuid: 'remap-uuid',
|
|
309
|
+
int4: 'max-offset',
|
|
310
|
+
int8: 'max-offset',
|
|
311
|
+
integer: 'max-offset',
|
|
312
|
+
bigint: 'max-offset',
|
|
313
|
+
int2: 'max-offset',
|
|
314
|
+
text: 'text-suffix',
|
|
315
|
+
varchar: 'text-suffix',
|
|
316
|
+
'character varying': 'text-suffix',
|
|
317
|
+
bpchar: 'text-suffix',
|
|
318
|
+
}
|
|
319
|
+
const refsFor = (schema, table, column) =>
|
|
320
|
+
fkEdges
|
|
321
|
+
.filter((e) => e.parent === `${schema}.${table}` && e.parentCol === column)
|
|
322
|
+
.map((e) => {
|
|
323
|
+
const dot = e.child.indexOf('.')
|
|
324
|
+
return { schema: e.child.slice(0, dot), table: e.child.slice(dot + 1), column: e.childCol }
|
|
325
|
+
})
|
|
326
|
+
const uniqueColumns = []
|
|
327
|
+
for (const [schema, table, column, typ, seq] of uniqueRows) {
|
|
328
|
+
const key = `${schema}.${table}`
|
|
329
|
+
if (!copySet.has(key)) continue
|
|
330
|
+
if (typ === 'uuid') {
|
|
331
|
+
uniqueColumns.push({ key: `${key}.${column}`, schema, table, column, type: 'uuid', strategy: 'remap-uuid' })
|
|
332
|
+
continue
|
|
333
|
+
}
|
|
334
|
+
const strategy = seq ? 'sequence' : DEFAULT_BY_TYPE[typ] || 'text-suffix'
|
|
335
|
+
const references = refsFor(schema, table, column)
|
|
336
|
+
uniqueColumns.push({
|
|
337
|
+
key: `${key}.${column}`,
|
|
338
|
+
schema,
|
|
339
|
+
table,
|
|
340
|
+
column,
|
|
341
|
+
type: typ,
|
|
342
|
+
strategy,
|
|
343
|
+
...(seq ? { sequenceName: seq } : {}),
|
|
344
|
+
references,
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
// Composite-UNIQUE dedup-key members that would collide in a same-DB copy (no org column, no remapped
|
|
348
|
+
// uuid in the constraint). They are dedup/idempotency keys, not FKs. Strategy depends on the column
|
|
349
|
+
// TYPE (measured 2026-08-26): a uuid key must be DERIVED to a fresh uuid (a text suffix would make it
|
|
350
|
+
// an invalid uuid); a text key gets a suffix. Edit `type`/`strategy` to change.
|
|
351
|
+
const COMPOSITE_KEYS = [
|
|
352
|
+
{
|
|
353
|
+
schema: 'service_task_engine',
|
|
354
|
+
table: 'task',
|
|
355
|
+
column: 'uniquenessKey',
|
|
356
|
+
type: 'text',
|
|
357
|
+
note: '(type, uniquenessKey)',
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
schema: 'service_customer_questions',
|
|
361
|
+
table: 'dynamicQuestion',
|
|
362
|
+
column: 'uniquenessKey',
|
|
363
|
+
type: 'uuid',
|
|
364
|
+
note: '(typePrefix, type, uniquenessKey)',
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
schema: 'service_customer_questions',
|
|
368
|
+
table: 'scheduledDynamicQuestion',
|
|
369
|
+
column: 'uniquenessKey',
|
|
370
|
+
type: 'uuid',
|
|
371
|
+
note: '(type, uniquenessKey)',
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
schema: 'service_checks_engine',
|
|
375
|
+
table: 'checkFinding',
|
|
376
|
+
column: 'dedupeKey',
|
|
377
|
+
type: 'text',
|
|
378
|
+
note: '(checkKey, dedupeKey)',
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
schema: 'service_business_entities',
|
|
382
|
+
table: 'entity',
|
|
383
|
+
column: 'externalId',
|
|
384
|
+
type: 'text',
|
|
385
|
+
note: '(externalType, externalId)',
|
|
386
|
+
},
|
|
387
|
+
]
|
|
388
|
+
for (const c of COMPOSITE_KEYS) {
|
|
389
|
+
const key = `${c.schema}.${c.table}`
|
|
390
|
+
if (!copySet.has(key)) continue
|
|
391
|
+
if (c.type === 'uuid') {
|
|
392
|
+
// uuid dedup key → derive a fresh uuid via the normal uuid remap (valid + unique)
|
|
393
|
+
uniqueColumns.push({
|
|
394
|
+
key: `${key}.${c.column}`,
|
|
395
|
+
schema: c.schema,
|
|
396
|
+
table: c.table,
|
|
397
|
+
column: c.column,
|
|
398
|
+
type: 'uuid',
|
|
399
|
+
strategy: 'remap-uuid',
|
|
400
|
+
composite: c.note,
|
|
401
|
+
})
|
|
402
|
+
} else {
|
|
403
|
+
uniqueColumns.push({
|
|
404
|
+
key: `${key}.${c.column}`,
|
|
405
|
+
schema: c.schema,
|
|
406
|
+
table: c.table,
|
|
407
|
+
column: c.column,
|
|
408
|
+
type: 'text',
|
|
409
|
+
strategy: 'text-suffix',
|
|
410
|
+
references: [],
|
|
411
|
+
composite: c.note,
|
|
412
|
+
})
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
uniqueColumns.sort((a, b) => (a.type === b.type ? a.key.localeCompare(b.key) : a.type === 'uuid' ? 1 : -1))
|
|
416
|
+
|
|
417
|
+
const copyCount = out.filter((t) => t.action === 'copy').length
|
|
418
|
+
const doc = {
|
|
419
|
+
generatedAt: '2026-08-24 (measured read-only on dooer-staging)',
|
|
420
|
+
note:
|
|
421
|
+
'START FROM ALL, copy by default; `action:"ignore"` only with a stated reason that does not affect ' +
|
|
422
|
+
'AI-booking tests. Flip one `action` field to change a decision. See FINDINGS.md / PROCESS.md.',
|
|
423
|
+
orgIdentity:
|
|
424
|
+
'Org identity is ONE uuid: `organizationId` == `service_accounts.companies.companies_pk`. The copy ' +
|
|
425
|
+
'remaps the source org uuid to the target org uuid, and rewrites every other copied row PK (uuid) so ' +
|
|
426
|
+
'intra-org references rewire automatically. References to non-copied rows (users, global templates) ' +
|
|
427
|
+
'stay untouched because they are not in the remap map.',
|
|
428
|
+
emailScrub: { value: 'testcustomer@dooer.com', columnCount: emailRows.length },
|
|
429
|
+
options: {
|
|
430
|
+
// a text/varchar value equal to a copied row's uuid is a reference (e.g. a polymorphic entityId
|
|
431
|
+
// stored as text) — remap it to the copy's new uuid so references follow. Safe + improves fidelity.
|
|
432
|
+
remapTextStoredUuids: true,
|
|
433
|
+
},
|
|
434
|
+
// Tables whose PRIMARY KEY is an S3 object key (the S3-copy phase moves each object old.id → new.id).
|
|
435
|
+
s3BlobTables: REVERSE_REF.filter((r) => r.s3Blob).map((r) => r.key),
|
|
436
|
+
// Referenced-user copy: soft/`fk` uuid columns (in the COPY set) that point at service_accounts.users.
|
|
437
|
+
// The tool collects their values, copies the users missing from the target (never touching existing
|
|
438
|
+
// ones), remaps the refs, and anonymizes emails for `customer`-role users. `userTable` is the config
|
|
439
|
+
// for that copy — role lives in `fk_user_roles_at_dooer_pk` (text: admin/customer/hi/hiab); only
|
|
440
|
+
// `customer` is anonymized (Jimmy 2026-08-27), the rest are copied verbatim. Email is UNIQUE per role
|
|
441
|
+
// (`(email, fk_user_roles_at_dooer_pk)`), so anonymized addresses are made unique per user.
|
|
442
|
+
userTable: {
|
|
443
|
+
schema: 'service_accounts',
|
|
444
|
+
table: 'users',
|
|
445
|
+
pk: 'users_pk',
|
|
446
|
+
roleColumn: 'fk_user_roles_at_dooer_pk',
|
|
447
|
+
anonymizeRoles: ['customer'],
|
|
448
|
+
emailColumns: ['email', 'contactEmail', 'conversationEmailAddress'],
|
|
449
|
+
nullColumns: ['id_number', 'password_hash'], // PII / credentials — cleared for anonymized users
|
|
450
|
+
anonymizedEmailPattern: 'testcustomer+u<id>@dooer.com', // <id> = the copied user's new uuid (unique)
|
|
451
|
+
},
|
|
452
|
+
userRefColumns: userRefRows
|
|
453
|
+
.filter(([s, t]) => copySet.has(`${s}.${t}`))
|
|
454
|
+
.map(([schema, table, column]) => ({ schema, table, column })),
|
|
455
|
+
uniquenessStrategies: {
|
|
456
|
+
note:
|
|
457
|
+
'Per-column strategy for every single-column-UNIQUE column so a copy never collides. The script ' +
|
|
458
|
+
"reads this and hardcodes nothing — edit a column's `strategy` to change behaviour.",
|
|
459
|
+
strategies: {
|
|
460
|
+
'remap-uuid': 'generate a fresh uuid, applied consistently everywhere the value appears (value-based, global)',
|
|
461
|
+
sequence: 'draw nextval() from `sequenceName` on the target; also rewrite the columns in `references`',
|
|
462
|
+
'max-offset': 'assign target_max(column)+1,+2,… (app-assigned unique int); also rewrite `references`',
|
|
463
|
+
'text-suffix': 'append "-seedcopy-<rand>" to the source value; also rewrite `references`',
|
|
464
|
+
preserve: 'keep the source value unchanged (only safe when a collision is impossible)',
|
|
465
|
+
},
|
|
466
|
+
defaultByType: DEFAULT_BY_TYPE,
|
|
467
|
+
columns: uniqueColumns,
|
|
468
|
+
},
|
|
469
|
+
skipSchemas: Object.keys(IGNORE_SCHEMA),
|
|
470
|
+
counts: { total: out.length, copy: copyCount, ignore: out.length - copyCount },
|
|
471
|
+
ignoreCandidates: IGNORE_CANDIDATES,
|
|
472
|
+
fkGraph: '_measure/m4.fk-graph.tsv (420 edges among org schemas)',
|
|
473
|
+
tables: out,
|
|
474
|
+
}
|
|
475
|
+
fs.writeFileSync(path.join(__dirname, 'schema-map.json'), JSON.stringify(doc, null, 2) + '\n')
|
|
476
|
+
console.log(
|
|
477
|
+
`schema-map.json: ${out.length} tables (${copyCount} copy, ${out.length - copyCount} ignore), ` +
|
|
478
|
+
`${emailRows.length} email columns, ${stagingOnly.size} staging-only.`
|
|
479
|
+
)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// purge.js — delete ALL data for one organization (the inverse of copy). Removes every org-scoped row
|
|
2
|
+
// across the schema-map (data + identity: companies, memberships, …), FK-safe in one transaction, and
|
|
3
|
+
// deletes the org's S3 file objects. NEVER touches service_accounts.users (users are global; not in the
|
|
4
|
+
// map). Dry-run by default. Writing to dooer-production requires confirmProduction.
|
|
5
|
+
const path = require('path')
|
|
6
|
+
const engine = require('./seed')
|
|
7
|
+
|
|
8
|
+
const { connect, withRetry, filterClause, qTable, qIdent, s3Config, DeleteObjectCommand } = engine
|
|
9
|
+
|
|
10
|
+
// every map table that can be scoped to an org (copy AND ignore — a purge removes identity rows too),
|
|
11
|
+
// that physically exists on the target. Order doesn't matter: we disable FK/triggers for the delete.
|
|
12
|
+
function purgeTables(map) {
|
|
13
|
+
return map.tables.filter((t) => {
|
|
14
|
+
if (t.key === 'service_accounts.users') return false // never delete users
|
|
15
|
+
const f = filterClause(t, '00000000-0000-0000-0000-000000000000')
|
|
16
|
+
return !!f
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function purge(opts) {
|
|
21
|
+
const args = {
|
|
22
|
+
org: opts.org,
|
|
23
|
+
namespace: opts.namespace || 'dooer-staging',
|
|
24
|
+
execute: !!opts.execute,
|
|
25
|
+
confirmProduction: !!opts.confirmProduction,
|
|
26
|
+
skipFiles: !!opts.skipFiles,
|
|
27
|
+
mapPath: opts.mapPath || path.join(__dirname, 'schema-map.json'),
|
|
28
|
+
}
|
|
29
|
+
if (!args.org) throw new Error('missing --org')
|
|
30
|
+
if (args.namespace === 'dooer-production' && args.execute && !args.confirmProduction) {
|
|
31
|
+
throw new Error('refusing to DELETE from dooer-production without --confirm-production')
|
|
32
|
+
}
|
|
33
|
+
const map = require(args.mapPath)
|
|
34
|
+
const tables = purgeTables(map)
|
|
35
|
+
const mode = args.execute ? 'EXECUTE' : 'DRY-RUN (dummy — no writes)'
|
|
36
|
+
console.log(`\n=== customer purge · ${mode} ===`)
|
|
37
|
+
console.log(`org: ${args.org} @ ${args.namespace} · candidate tables: ${tables.length}\n`)
|
|
38
|
+
|
|
39
|
+
const c = await connect(args.namespace)
|
|
40
|
+
try {
|
|
41
|
+
// count what would be deleted (also validates every filter compiles against the live schema)
|
|
42
|
+
const plan = []
|
|
43
|
+
let total = 0
|
|
44
|
+
let idx = 0
|
|
45
|
+
for (const t of tables) {
|
|
46
|
+
idx++
|
|
47
|
+
process.stderr.write(`\r[scan ${idx}/${tables.length}] ${t.key.slice(0, 52).padEnd(52)}`)
|
|
48
|
+
const exists = await c.query(
|
|
49
|
+
`SELECT 1 FROM information_schema.tables WHERE table_schema=$1 AND table_name=$2 AND table_type='BASE TABLE'`,
|
|
50
|
+
[t.schema, t.table]
|
|
51
|
+
)
|
|
52
|
+
if (!exists.rowCount) continue
|
|
53
|
+
const f = filterClause(t, args.org, { includeExtra: false })
|
|
54
|
+
const r = await withRetry(
|
|
55
|
+
() => c.query(`SELECT count(*)::int AS n FROM ${qTable(t.schema, t.table)} WHERE ${f.sql}`, f.params),
|
|
56
|
+
{
|
|
57
|
+
label: `count ${t.key}`,
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
const n = r.rows[0].n
|
|
61
|
+
if (n > 0) plan.push({ t, n })
|
|
62
|
+
total += n
|
|
63
|
+
}
|
|
64
|
+
process.stderr.write('\n')
|
|
65
|
+
for (const p of plan) console.log(` ${String(p.n).padStart(9)} ${p.t.key}`)
|
|
66
|
+
console.log(`\ntotal rows to delete: ${total} across ${plan.length} table(s)`)
|
|
67
|
+
|
|
68
|
+
// collect the org's S3 object keys (s3Blob tables) before we delete the rows
|
|
69
|
+
let fileKeys = []
|
|
70
|
+
if (!args.skipFiles) {
|
|
71
|
+
for (const key of map.s3BlobTables || []) {
|
|
72
|
+
const t = map.tables.find((x) => x.key === key)
|
|
73
|
+
if (!t) continue
|
|
74
|
+
const f = filterClause(t, args.org, { includeExtra: false })
|
|
75
|
+
const r = await withRetry(
|
|
76
|
+
() =>
|
|
77
|
+
c.query(`SELECT ${qIdent(t.pk.column)} AS id FROM ${qTable(t.schema, t.table)} WHERE ${f.sql}`, f.params),
|
|
78
|
+
{
|
|
79
|
+
label: `filekeys ${key}`,
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
fileKeys = fileKeys.concat(r.rows.map((x) => x.id).filter(Boolean))
|
|
83
|
+
}
|
|
84
|
+
console.log(`S3 objects to delete: ${fileKeys.length}`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!args.execute) {
|
|
88
|
+
console.log('\nDRY-RUN complete — nothing was deleted. Re-run with --execute to purge.\n')
|
|
89
|
+
return { total, tables: plan.length, files: fileKeys.length }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// delete rows in ONE transaction with FK/triggers disabled (order-independent)
|
|
93
|
+
await c.client.query('BEGIN')
|
|
94
|
+
await c.client.query("SET session_replication_role = 'replica'")
|
|
95
|
+
let deleted = 0
|
|
96
|
+
for (const p of plan) {
|
|
97
|
+
const f = filterClause(p.t, args.org, { includeExtra: false })
|
|
98
|
+
const r = await c.client.query(`DELETE FROM ${qTable(p.t.schema, p.t.table)} WHERE ${f.sql}`, f.params)
|
|
99
|
+
deleted += r.rowCount
|
|
100
|
+
}
|
|
101
|
+
await c.client.query("SET session_replication_role = 'origin'")
|
|
102
|
+
await c.client.query('COMMIT')
|
|
103
|
+
console.log(`\nEXECUTE complete — ${deleted} rows deleted from ${args.org} @ ${args.namespace}.`)
|
|
104
|
+
|
|
105
|
+
// delete the S3 objects (best-effort, after the DB commit)
|
|
106
|
+
if (!args.skipFiles && fileKeys.length) {
|
|
107
|
+
const cfg = s3Config(args.namespace)
|
|
108
|
+
let ok = 0
|
|
109
|
+
let failed = 0
|
|
110
|
+
for (const key of fileKeys) {
|
|
111
|
+
try {
|
|
112
|
+
await withRetry(
|
|
113
|
+
() => cfg.primary.client.send(new DeleteObjectCommand({ Bucket: cfg.primary.bucket, Key: key })),
|
|
114
|
+
{ label: 'del' }
|
|
115
|
+
)
|
|
116
|
+
if (cfg.fallback) {
|
|
117
|
+
try {
|
|
118
|
+
await cfg.fallback.client.send(new DeleteObjectCommand({ Bucket: cfg.fallback.bucket, Key: key }))
|
|
119
|
+
} catch (_) {
|
|
120
|
+
/* object may only exist in one bucket */
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
ok++
|
|
124
|
+
} catch (e) {
|
|
125
|
+
failed++
|
|
126
|
+
console.error(` S3 delete failed for ${key}: ${e.message}`)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
console.log(`S3: deleted ${ok}, failed ${failed}.`)
|
|
130
|
+
}
|
|
131
|
+
return { total: deleted, tables: plan.length, files: fileKeys.length }
|
|
132
|
+
} finally {
|
|
133
|
+
await c.end()
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = { purge, purgeTables }
|