@linkforty/core 1.34.0 → 1.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +32 -0
  2. package/dist/cli.d.ts +6 -0
  3. package/dist/cli.d.ts.map +1 -0
  4. package/dist/cli.js +10 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/lib/database.d.ts.map +1 -1
  11. package/dist/lib/database.js +7 -0
  12. package/dist/lib/database.js.map +1 -1
  13. package/dist/lib/database.test.js +28 -1
  14. package/dist/lib/database.test.js.map +1 -1
  15. package/dist/lib/fingerprint.d.ts +27 -2
  16. package/dist/lib/fingerprint.d.ts.map +1 -1
  17. package/dist/lib/fingerprint.js +94 -5
  18. package/dist/lib/fingerprint.js.map +1 -1
  19. package/dist/lib/fingerprint.test.js +106 -0
  20. package/dist/lib/fingerprint.test.js.map +1 -1
  21. package/dist/lib/portable/bundle.d.ts +57 -0
  22. package/dist/lib/portable/bundle.d.ts.map +1 -0
  23. package/dist/lib/portable/bundle.js +139 -0
  24. package/dist/lib/portable/bundle.js.map +1 -0
  25. package/dist/lib/portable/cli.d.ts +14 -0
  26. package/dist/lib/portable/cli.d.ts.map +1 -0
  27. package/dist/lib/portable/cli.js +102 -0
  28. package/dist/lib/portable/cli.js.map +1 -0
  29. package/dist/lib/portable/cli.test.d.ts +2 -0
  30. package/dist/lib/portable/cli.test.d.ts.map +1 -0
  31. package/dist/lib/portable/cli.test.js +98 -0
  32. package/dist/lib/portable/cli.test.js.map +1 -0
  33. package/dist/lib/portable/files.d.ts +17 -0
  34. package/dist/lib/portable/files.d.ts.map +1 -0
  35. package/dist/lib/portable/files.js +37 -0
  36. package/dist/lib/portable/files.js.map +1 -0
  37. package/dist/lib/portable/import.d.ts +52 -0
  38. package/dist/lib/portable/import.d.ts.map +1 -0
  39. package/dist/lib/portable/import.js +211 -0
  40. package/dist/lib/portable/import.js.map +1 -0
  41. package/dist/lib/portable/index.d.ts +5 -0
  42. package/dist/lib/portable/index.d.ts.map +1 -0
  43. package/dist/lib/portable/index.js +5 -0
  44. package/dist/lib/portable/index.js.map +1 -0
  45. package/dist/lib/portable/portable.integration.test.d.ts +2 -0
  46. package/dist/lib/portable/portable.integration.test.d.ts.map +1 -0
  47. package/dist/lib/portable/portable.integration.test.js +139 -0
  48. package/dist/lib/portable/portable.integration.test.js.map +1 -0
  49. package/dist/lib/portable/registry.d.ts +47 -0
  50. package/dist/lib/portable/registry.d.ts.map +1 -0
  51. package/dist/lib/portable/registry.js +211 -0
  52. package/dist/lib/portable/registry.js.map +1 -0
  53. package/dist/lib/portable/registry.schema.test.d.ts +2 -0
  54. package/dist/lib/portable/registry.schema.test.d.ts.map +1 -0
  55. package/dist/lib/portable/registry.schema.test.js +59 -0
  56. package/dist/lib/portable/registry.schema.test.js.map +1 -0
  57. package/dist/routes/sdk.d.ts.map +1 -1
  58. package/dist/routes/sdk.install.test.d.ts +2 -0
  59. package/dist/routes/sdk.install.test.d.ts.map +1 -0
  60. package/dist/routes/sdk.install.test.js +56 -0
  61. package/dist/routes/sdk.install.test.js.map +1 -0
  62. package/dist/routes/sdk.js +10 -5
  63. package/dist/routes/sdk.js.map +1 -1
  64. package/package.json +4 -1
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Restoring a LinkForty bundle (bundle.ts).
3
+ *
4
+ * Import reads the bundle twice. The first pass checks it: the format, the
5
+ * checksum, and every row against the target database — ids already present,
6
+ * short codes already used in the target organization, template slugs already
7
+ * taken. If anything conflicts, or on a dry run, it stops there and reports.
8
+ * The second pass writes every row in one transaction, so an import either
9
+ * lands whole or not at all. Ids are kept, so links, clicks and installs stay
10
+ * connected, and apps keep the install ids they already hold.
11
+ */
12
+ import type { Pool } from 'pg';
13
+ import { type BundleManifest } from './bundle.js';
14
+ export interface ImportOptions {
15
+ /** Check the bundle against the database and report, without writing. */
16
+ dryRun?: boolean;
17
+ /**
18
+ * Import into this organization: links and webhooks are assigned to it, and
19
+ * the bundle's own organization record is not created.
20
+ */
21
+ organizationId?: string;
22
+ /** Assign imported links, templates and webhooks to this user (null clears it). */
23
+ userId?: string | null;
24
+ }
25
+ export interface ImportConflict {
26
+ record: string;
27
+ kind: 'id_exists' | 'short_code_taken' | 'template_slug_taken';
28
+ value: string;
29
+ }
30
+ export interface ImportReport {
31
+ manifest: BundleManifest;
32
+ /** Rows per record type found in the bundle. */
33
+ counts: Record<string, number>;
34
+ /** Conflicts with the target database, at most 100 per kind and record. */
35
+ conflicts: ImportConflict[];
36
+ imported: boolean;
37
+ /** Imported webhooks with the signing secrets issued to them. */
38
+ webhooks: Array<{
39
+ id: string;
40
+ url: string;
41
+ secret: string;
42
+ }>;
43
+ }
44
+ export declare class BundleError extends Error {
45
+ }
46
+ /**
47
+ * Check a bundle against the database and, unless it's a dry run or anything
48
+ * conflicts, import it in one transaction. `openLines` is called once per pass
49
+ * and must yield the bundle's lines from the start each time.
50
+ */
51
+ export declare function importBundle(pool: Pool, openLines: () => AsyncIterable<string>, options?: ImportOptions): Promise<ImportReport>;
52
+ //# sourceMappingURL=import.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"import.d.ts","sourceRoot":"","sources":["../../../src/lib/portable/import.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,KAAK,EAAE,IAAI,EAAc,MAAM,IAAI,CAAC;AAC3C,OAAO,EAAmD,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AAInG,MAAM,WAAW,aAAa;IAC5B,yEAAyE;IACzE,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,mFAAmF;IACnF,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,WAAW,GAAG,kBAAkB,GAAG,qBAAqB,CAAC;IAC/D,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,cAAc,CAAC;IACzB,gDAAgD;IAChD,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,2EAA2E;IAC3E,SAAS,EAAE,cAAc,EAAE,CAAC;IAC5B,QAAQ,EAAE,OAAO,CAAC;IAClB,iEAAiE;IACjE,QAAQ,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC9D;AAED,qBAAa,WAAY,SAAQ,KAAK;CAAG;AA0KzC;;;;GAIG;AACH,wBAAsB,YAAY,CAChC,IAAI,EAAE,IAAI,EACV,SAAS,EAAE,MAAM,aAAa,CAAC,MAAM,CAAC,EACtC,OAAO,GAAE,aAAkB,GAC1B,OAAO,CAAC,YAAY,CAAC,CA8CvB"}
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Restoring a LinkForty bundle (bundle.ts).
3
+ *
4
+ * Import reads the bundle twice. The first pass checks it: the format, the
5
+ * checksum, and every row against the target database — ids already present,
6
+ * short codes already used in the target organization, template slugs already
7
+ * taken. If anything conflicts, or on a dry run, it stops there and reports.
8
+ * The second pass writes every row in one transaction, so an import either
9
+ * lands whole or not at all. Ids are kept, so links, clicks and installs stay
10
+ * connected, and apps keep the install ids they already hold.
11
+ */
12
+ import crypto from 'crypto';
13
+ import { BUNDLE_FORMAT, BUNDLE_FORMAT_VERSION, ZERO_UUID } from './bundle.js';
14
+ import { BUNDLE_TABLES, exportedColumns } from './registry.js';
15
+ import { generateWebhookSecret } from '../webhook.js';
16
+ export class BundleError extends Error {
17
+ }
18
+ /** Rows written or checked per statement. */
19
+ const BATCH_SIZE = 1000;
20
+ const CONFLICT_CAP = 100;
21
+ const TABLE_BY_RECORD = new Map(Object.entries(BUNDLE_TABLES).map(([table, rule]) => [rule.record, table]));
22
+ /**
23
+ * Parse and verify a bundle's lines: manifest first, rows, then a trailer
24
+ * whose checksum and count match. Yields rows; throws BundleError on anything
25
+ * malformed.
26
+ */
27
+ async function* readBundle(lines, onManifest) {
28
+ const hash = crypto.createHash('sha256');
29
+ let manifest = null;
30
+ let rows = 0;
31
+ let ended = false;
32
+ for await (const raw of lines) {
33
+ if (raw === '')
34
+ continue;
35
+ if (ended)
36
+ throw new BundleError('Data after the end of the bundle');
37
+ let line;
38
+ try {
39
+ line = JSON.parse(raw);
40
+ }
41
+ catch {
42
+ throw new BundleError('Bundle contains a line that is not JSON');
43
+ }
44
+ if (!manifest) {
45
+ if (line?.type !== 'manifest' || line.format !== BUNDLE_FORMAT) {
46
+ throw new BundleError('Not a LinkForty bundle');
47
+ }
48
+ if (typeof line.formatVersion !== 'number' || line.formatVersion > BUNDLE_FORMAT_VERSION) {
49
+ throw new BundleError(`Bundle format version ${line.formatVersion} is newer than this server reads (${BUNDLE_FORMAT_VERSION}); upgrade first`);
50
+ }
51
+ manifest = line;
52
+ onManifest(manifest);
53
+ continue;
54
+ }
55
+ if (line?.type === 'end') {
56
+ if (line.rows !== rows || line.sha256 !== hash.digest('hex')) {
57
+ throw new BundleError('Bundle checksum does not match; the file is incomplete or was changed');
58
+ }
59
+ ended = true;
60
+ continue;
61
+ }
62
+ const table = TABLE_BY_RECORD.get(line?.type);
63
+ if (!table || !line.data || typeof line.data !== 'object') {
64
+ throw new BundleError(`Unknown record type in bundle: ${String(line?.type)}`);
65
+ }
66
+ hash.update(raw + '\n');
67
+ rows++;
68
+ yield { record: line.type, table, data: line.data };
69
+ }
70
+ if (!manifest)
71
+ throw new BundleError('Bundle is empty');
72
+ if (!ended)
73
+ throw new BundleError('Bundle has no end record; the file is incomplete');
74
+ }
75
+ /** A bundle row as a row of its table, with the import's assignments applied. */
76
+ function toColumns(row, options) {
77
+ const out = {};
78
+ for (const [column, rule] of exportedColumns(row.table)) {
79
+ let value = row.data[rule.field];
80
+ if (rule.class === 'portable' && rule.keys && value && typeof value === 'object' && !Array.isArray(value)) {
81
+ value = Object.fromEntries(rule.keys.filter((k) => k in value).map((k) => [k, value[k]]));
82
+ }
83
+ if (rule.class === 'remap') {
84
+ if (rule.owner === 'organization' && options.organizationId)
85
+ value = options.organizationId;
86
+ if (rule.owner === 'user' && options.userId !== undefined)
87
+ value = options.userId;
88
+ }
89
+ out[column] = value ?? null;
90
+ }
91
+ return out;
92
+ }
93
+ /** Rows grouped into batches, each batch from a single table. */
94
+ async function* batches(rows) {
95
+ let batch = [];
96
+ for await (const row of rows) {
97
+ if (batch.length > 0 && (batch[0].table !== row.table || batch.length >= BATCH_SIZE)) {
98
+ yield batch;
99
+ batch = [];
100
+ }
101
+ batch.push(row);
102
+ }
103
+ if (batch.length > 0)
104
+ yield batch;
105
+ }
106
+ function skipped(row, options) {
107
+ // Importing into an existing organization: the bundle's organization record
108
+ // describes the source, not the target.
109
+ return row.table === 'organizations' && Boolean(options.organizationId);
110
+ }
111
+ async function findConflicts(client, batch, options) {
112
+ const { table, record } = batch[0];
113
+ const key = BUNDLE_TABLES[table].key;
114
+ const keyField = BUNDLE_TABLES[table].columns[key].field;
115
+ const conflicts = [];
116
+ const ids = batch.map((r) => r.data[keyField]).filter((v) => typeof v === 'string');
117
+ const existing = await client.query(`SELECT ${key}::text AS id FROM ${table} WHERE ${key} = ANY($1::uuid[])`, [ids]);
118
+ for (const { id } of existing.rows)
119
+ conflicts.push({ record, kind: 'id_exists', value: id });
120
+ if (table === 'links') {
121
+ const codes = batch.map((r) => ({
122
+ code: String(r.data.shortCode),
123
+ org: options.organizationId ?? r.data.organizationId ?? ZERO_UUID,
124
+ }));
125
+ const taken = await client.query(`SELECT l.short_code
126
+ FROM links l
127
+ INNER JOIN unnest($1::text[], $2::uuid[]) AS c(code, org)
128
+ ON l.short_code = c.code AND COALESCE(l.organization_id, '${ZERO_UUID}'::uuid) = c.org`, [codes.map((c) => c.code), codes.map((c) => c.org)]);
129
+ for (const { short_code } of taken.rows)
130
+ conflicts.push({ record, kind: 'short_code_taken', value: short_code });
131
+ }
132
+ if (table === 'link_templates') {
133
+ const slugs = batch.map((r) => String(r.data.slug));
134
+ const taken = await client.query('SELECT slug FROM link_templates WHERE slug = ANY($1::text[])', [slugs]);
135
+ for (const { slug } of taken.rows)
136
+ conflicts.push({ record, kind: 'template_slug_taken', value: slug });
137
+ }
138
+ return conflicts;
139
+ }
140
+ async function insertBatch(client, batch, options, webhooks) {
141
+ const { table } = batch[0];
142
+ const rows = batch.map((row) => toColumns(row, options));
143
+ const columns = exportedColumns(table).map(([column]) => column);
144
+ if (table === 'webhooks') {
145
+ columns.push('secret');
146
+ for (const row of rows) {
147
+ row.secret = generateWebhookSecret();
148
+ webhooks.push({ id: String(row.id), url: String(row.url), secret: String(row.secret) });
149
+ }
150
+ }
151
+ // json_populate_recordset converts every value back to its column type
152
+ // exactly as row_to_json rendered it on export.
153
+ const list = columns.join(', ');
154
+ await client.query(`INSERT INTO ${table} (${list}) SELECT ${list} FROM json_populate_recordset(null::${table}, $1::json)`, [JSON.stringify(rows)]);
155
+ }
156
+ /**
157
+ * Check a bundle against the database and, unless it's a dry run or anything
158
+ * conflicts, import it in one transaction. `openLines` is called once per pass
159
+ * and must yield the bundle's lines from the start each time.
160
+ */
161
+ export async function importBundle(pool, openLines, options = {}) {
162
+ let manifest = null;
163
+ const counts = {};
164
+ const conflicts = [];
165
+ const webhooks = [];
166
+ const client = await pool.connect();
167
+ try {
168
+ // Pass 1: verify and check.
169
+ for await (const batch of batches(readBundle(openLines(), (m) => (manifest = m)))) {
170
+ const { record } = batch[0];
171
+ counts[record] = (counts[record] ?? 0) + batch.length;
172
+ const kept = batch.filter((row) => !skipped(row, options));
173
+ if (kept.length === 0)
174
+ continue;
175
+ for (const conflict of await findConflicts(client, kept, options)) {
176
+ const sameKind = conflicts.filter((c) => c.record === conflict.record && c.kind === conflict.kind).length;
177
+ if (sameKind < CONFLICT_CAP)
178
+ conflicts.push(conflict);
179
+ }
180
+ }
181
+ const report = (imported) => ({
182
+ manifest: manifest,
183
+ counts,
184
+ conflicts,
185
+ imported,
186
+ webhooks,
187
+ });
188
+ if (options.dryRun || conflicts.length > 0)
189
+ return report(false);
190
+ // Pass 2: write everything or nothing.
191
+ await client.query('BEGIN');
192
+ try {
193
+ for await (const batch of batches(readBundle(openLines(), () => undefined))) {
194
+ const kept = batch.filter((row) => !skipped(row, options));
195
+ if (kept.length > 0)
196
+ await insertBatch(client, kept, options, webhooks);
197
+ }
198
+ await client.query('COMMIT');
199
+ }
200
+ catch (error) {
201
+ await client.query('ROLLBACK').catch(() => undefined);
202
+ webhooks.length = 0;
203
+ throw error;
204
+ }
205
+ return report(true);
206
+ }
207
+ finally {
208
+ client.release();
209
+ }
210
+ }
211
+ //# sourceMappingURL=import.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"import.js","sourceRoot":"","sources":["../../../src/lib/portable/import.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,5 @@
1
+ export * from './registry.js';
2
+ export * from './bundle.js';
3
+ export * from './import.js';
4
+ export * from './files.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/portable/index.ts"],"names":[],"mappings":"AAAA,cAAc,eAAe,CAAC;AAC9B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC"}
@@ -0,0 +1,5 @@
1
+ export * from './registry.js';
2
+ export * from './bundle.js';
3
+ export * from './import.js';
4
+ export * from './files.js';
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/lib/portable/index.ts"],"names":[],"mappings":"AAAA,cAAc,eAAe,CAAC;AAC9B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=portable.integration.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"portable.integration.test.d.ts","sourceRoot":"","sources":["../../../src/lib/portable/portable.integration.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,139 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import zlib from 'zlib';
5
+ import pg from 'pg';
6
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
7
+ import { db, initializeDatabase } from '../database.js';
8
+ import { BUNDLE_TABLES, exportedColumns } from './registry.js';
9
+ import { exportBundleToFile, importBundleFile } from './files.js';
10
+ import { BundleError } from './import.js';
11
+ /**
12
+ * A bundle restores exactly what it exported: every carried column, value for
13
+ * value, with excluded columns left behind. Import refuses conflicts and
14
+ * tampered files, and can target a different organization.
15
+ *
16
+ * Needs a real database; skipped without DATABASE_URL, like database.test.ts.
17
+ */
18
+ const ADMIN_URL = process.env.DATABASE_URL;
19
+ const ORG_A = '0a0a0a0a-0000-4000-8000-00000000000a';
20
+ const ORG_B = '0b0b0b0b-0000-4000-8000-00000000000b';
21
+ const ORG_X = '0c0c0c0c-0000-4000-8000-00000000000c';
22
+ const TEMPLATE = '1a1a1a1a-0000-4000-8000-00000000001a';
23
+ const LINK_A = '2a2a2a2a-0000-4000-8000-00000000002a';
24
+ const LINK_B = '2b2b2b2b-0000-4000-8000-00000000002b';
25
+ const CLICK = '3a3a3a3a-0000-4000-8000-00000000003a';
26
+ const INSTALL = '4a4a4a4a-0000-4000-8000-00000000004a';
27
+ const WEBHOOK = '5a5a5a5a-0000-4000-8000-00000000005a';
28
+ describe.skipIf(!ADMIN_URL)('LinkForty bundles (integration)', () => {
29
+ const stamp = Date.now();
30
+ const names = { source: `lf_core_bundle_src_${stamp}`, target: `lf_core_bundle_dst_${stamp}`, other: `lf_core_bundle_org_${stamp}` };
31
+ const pools = {};
32
+ let admin;
33
+ let dir;
34
+ const urlFor = (name) => {
35
+ const url = new URL(ADMIN_URL);
36
+ url.pathname = `/${name}`;
37
+ return url.toString();
38
+ };
39
+ /** Every carried column of every row, as Postgres renders it. */
40
+ async function snapshot(pool) {
41
+ const out = {};
42
+ for (const [table, rule] of Object.entries(BUNDLE_TABLES)) {
43
+ const cols = exportedColumns(table).map(([c]) => c).join(', ');
44
+ const result = await pool.query(`SELECT row_to_json(r) AS row FROM (SELECT ${cols} FROM ${table} ORDER BY ${rule.key}) r`);
45
+ out[table] = result.rows.map((r) => r.row);
46
+ }
47
+ return out;
48
+ }
49
+ beforeAll(async () => {
50
+ admin = new pg.Client({ connectionString: ADMIN_URL });
51
+ await admin.connect();
52
+ for (const key of ['other', 'target', 'source']) {
53
+ await admin.query(`CREATE DATABASE ${names[key]}`);
54
+ await initializeDatabase({ url: urlFor(names[key]), pool: { min: 1, max: 3 } });
55
+ pools[key] = db;
56
+ }
57
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lf-bundle-'));
58
+ const q = (sql, params = []) => pools.source.query(sql, params);
59
+ await q(`INSERT INTO organizations (id, name, settings, suspended_at) VALUES
60
+ ($1, 'A', '{"appConfig":{"webFallbackUrl":"https://a.example"},"launchpad":{"mobile":"page"},"brandKit":{"accent":"#000"}}', NOW()),
61
+ ($2, 'B', '{}', NULL)`, [ORG_A, ORG_B]);
62
+ await q(`INSERT INTO link_templates (id, name, slug, settings, is_default) VALUES ($1, 'T', 'tpl-a', '{"defaultWebFallbackUrl":"https://t.example"}', true)`, [TEMPLATE]);
63
+ await q(`INSERT INTO links (id, organization_id, template_id, short_code, original_url, web_fallback_url, deep_link_parameters,
64
+ utm_parameters, expires_at, warn_at, disabled_reason, created_at)
65
+ VALUES ($1, $2, $3, 'promo', 'https://a.example/p', 'https://a.example/p', '{"token":"x"}', '{"source":"ig"}',
66
+ '2030-01-02 03:04:05.678', '2026-10-01 12:00:00+00', 'flagged', '2026-01-01 00:00:00.123456')`, [LINK_A, ORG_A, TEMPLATE]);
67
+ await q(`INSERT INTO links (id, organization_id, short_code, original_url) VALUES ($1, $2, 'promo', 'https://b.example')`, [LINK_B, ORG_B]);
68
+ await q(`INSERT INTO click_events (id, link_id, clicked_at, ip_address, latitude, longitude, link_params, is_bot)
69
+ VALUES ($1, $2, '2026-10-15 10:00:00.5', '203.0.113.7', 37.7749, -122.4194, '{"fbclid":"abc"}', false)`, [CLICK, LINK_A]);
70
+ await q(`INSERT INTO device_fingerprints (click_id, fingerprint_hash, ip_address, user_agent, screen_width) VALUES ($1, 'h', '2001:db8::1', 'UA', 1170)`, [CLICK]);
71
+ await q(`INSERT INTO install_events (id, link_id, click_id, fingerprint_hash, confidence_score, matched_factors, deep_link_data, ip_address)
72
+ VALUES ($1, $2, $3, 'h', 90.5, ARRAY['ip','platform'], '{"shortCode":"promo"}', '203.0.113.7')`, [INSTALL, LINK_A, CLICK]);
73
+ await q(`INSERT INTO in_app_events (install_id, event_name, event_data, event_timestamp) VALUES ($1, 'purchase', '{"revenue":29.99}', NOW())`, [INSTALL]);
74
+ await q(`INSERT INTO webhooks (id, organization_id, name, url, secret, events, headers) VALUES ($1, $2, 'W', 'https://hooks.example', 'old-secret', '{click_event,install_event}', '{"X-Key":"1"}')`, [WEBHOOK, ORG_A]);
75
+ }, 120000);
76
+ afterAll(async () => {
77
+ for (const pool of Object.values(pools))
78
+ await pool?.end().catch(() => undefined);
79
+ for (const name of Object.values(names))
80
+ await admin.query(`DROP DATABASE IF EXISTS ${name} WITH (FORCE)`);
81
+ await admin.end();
82
+ fs.rmSync(dir, { recursive: true, force: true });
83
+ }, 60000);
84
+ it('restores every carried column exactly, and nothing it excludes', async () => {
85
+ const file = path.join(dir, 'all.lfbundle');
86
+ const exported = await exportBundleToFile(pools.source, file);
87
+ expect(exported.counts).toMatchObject({ organization: 2, link: 2, click: 1, fingerprint: 1, install: 1, event: 1, webhook: 1 });
88
+ const report = await importBundleFile(pools.target, file);
89
+ expect(report.conflicts).toEqual([]);
90
+ expect(report.imported).toBe(true);
91
+ const [source, target] = [await snapshot(pools.source), await snapshot(pools.target)];
92
+ // Settings carry only the listed keys.
93
+ const orgA = source.organizations.find((o) => o.id === ORG_A);
94
+ delete orgA.settings.brandKit;
95
+ expect(target).toEqual(source);
96
+ const link = await pools.target.query('SELECT warn_at, disabled_reason FROM links WHERE id = $1', [LINK_A]);
97
+ expect(link.rows[0]).toEqual({ warn_at: null, disabled_reason: null });
98
+ const org = await pools.target.query('SELECT suspended_at FROM organizations WHERE id = $1', [ORG_A]);
99
+ expect(org.rows[0].suspended_at).toBeNull();
100
+ const webhook = await pools.target.query('SELECT secret FROM webhooks WHERE id = $1', [WEBHOOK]);
101
+ expect(webhook.rows[0].secret).not.toBe('old-secret');
102
+ expect(report.webhooks).toEqual([{ id: WEBHOOK, url: 'https://hooks.example', secret: webhook.rows[0].secret }]);
103
+ }, 60000);
104
+ it('reports conflicts and writes nothing when the data is already there', async () => {
105
+ const file = path.join(dir, 'all.lfbundle');
106
+ const before = await snapshot(pools.target);
107
+ const report = await importBundleFile(pools.target, file);
108
+ expect(report.imported).toBe(false);
109
+ expect(report.conflicts).toEqual(expect.arrayContaining([
110
+ { record: 'link', kind: 'id_exists', value: LINK_A },
111
+ { record: 'link', kind: 'short_code_taken', value: 'promo' },
112
+ { record: 'template', kind: 'template_slug_taken', value: 'tpl-a' },
113
+ ]));
114
+ expect(await snapshot(pools.target)).toEqual(before);
115
+ }, 60000);
116
+ it('refuses a bundle that was changed after export', async () => {
117
+ const file = path.join(dir, 'all.lfbundle');
118
+ const text = zlib.gunzipSync(fs.readFileSync(file)).toString().replace('https://a.example/p', 'https://evil.example/p');
119
+ const tampered = path.join(dir, 'tampered.lfbundle');
120
+ fs.writeFileSync(tampered, zlib.gzipSync(text));
121
+ await expect(importBundleFile(pools.other, tampered)).rejects.toThrow(BundleError);
122
+ });
123
+ it("exports one organization's data and imports it into another organization", async () => {
124
+ const file = path.join(dir, 'org-a.lfbundle');
125
+ const exported = await exportBundleToFile(pools.source, file, { organizationId: ORG_A });
126
+ expect(exported.counts).toMatchObject({ organization: 1, link: 1, click: 1, install: 1, event: 1, webhook: 1, template: 1 });
127
+ await pools.other.query(`INSERT INTO organizations (id, name) VALUES ($1, 'X')`, [ORG_X]);
128
+ const dry = await importBundleFile(pools.other, file, { organizationId: ORG_X, dryRun: true });
129
+ expect(dry).toMatchObject({ imported: false, conflicts: [] });
130
+ expect((await pools.other.query('SELECT COUNT(*)::int AS n FROM links')).rows[0].n).toBe(0);
131
+ const report = await importBundleFile(pools.other, file, { organizationId: ORG_X });
132
+ expect(report.imported).toBe(true);
133
+ const links = await pools.other.query('SELECT id, organization_id FROM links');
134
+ expect(links.rows).toEqual([{ id: LINK_A, organization_id: ORG_X }]);
135
+ const orgs = await pools.other.query('SELECT id FROM organizations');
136
+ expect(orgs.rows).toEqual([{ id: ORG_X }]);
137
+ }, 60000);
138
+ });
139
+ //# sourceMappingURL=portable.integration.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"portable.integration.test.js","sourceRoot":"","sources":["../../../src/lib/portable/portable.integration.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,47 @@
1
+ /**
2
+ * What a LinkForty bundle carries, column by column.
3
+ *
4
+ * Every column of every table Core creates is classified here, and the schema
5
+ * test (registry.schema.test.ts) fails when a column exists that isn't, or a
6
+ * classified column no longer exists. So adding a column means deciding,
7
+ * in the same change, whether a bundle carries it.
8
+ *
9
+ * - `portable`: exported under `field` and restored on import.
10
+ * - `remap`: an owner reference (organization or user). Exported as is, and
11
+ * replaceable on import, so a bundle can be imported into a different
12
+ * organization or for a different user.
13
+ * - `excluded`: never exported; `reason` says why.
14
+ *
15
+ * JSONB columns whose keys belong to different features list the keys a bundle
16
+ * carries in `keys`; any other key is left out, so a key added later is not
17
+ * exported until it is listed here.
18
+ */
19
+ export type ColumnRule = {
20
+ class: 'portable';
21
+ field: string;
22
+ keys?: readonly string[];
23
+ } | {
24
+ class: 'remap';
25
+ field: string;
26
+ owner: 'organization' | 'user';
27
+ } | {
28
+ class: 'excluded';
29
+ reason: string;
30
+ };
31
+ export interface TableRule {
32
+ /** Record type in the bundle. */
33
+ record: string;
34
+ /** Primary key column; rows are read in its order. */
35
+ key: string;
36
+ columns: Readonly<Record<string, ColumnRule>>;
37
+ }
38
+ /**
39
+ * Tables in the order rows are written and restored: every table after the
40
+ * ones it references.
41
+ */
42
+ export declare const BUNDLE_TABLES: Readonly<Record<string, TableRule>>;
43
+ /** Columns a bundle carries for a table, in registry order. */
44
+ export declare function exportedColumns(table: string): Array<[column: string, rule: Exclude<ColumnRule, {
45
+ class: 'excluded';
46
+ }>]>;
47
+ //# sourceMappingURL=registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../../src/lib/portable/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,MAAM,UAAU,GAClB;IAAE,KAAK,EAAE,UAAU,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,GAC9D;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAAA;CAAE,GACjE;IAAE,KAAK,EAAE,UAAU,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE1C,MAAM,WAAW,SAAS;IACxB,iCAAiC;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,sDAAsD;IACtD,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC,CAAC;CAC/C;AAUD;;;GAGG;AACH,eAAO,MAAM,aAAa,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAkL7D,CAAC;AAEF,+DAA+D;AAC/D,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,KAAK,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,CAAC,UAAU,EAAE;IAAE,KAAK,EAAE,UAAU,CAAA;CAAE,CAAC,CAAC,CAAC,CAIxH"}