@linkforty/core 1.33.0 → 1.35.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/README.md +32 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +10 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/database.d.ts.map +1 -1
- package/dist/lib/database.js +7 -0
- package/dist/lib/database.js.map +1 -1
- package/dist/lib/database.test.js +28 -1
- package/dist/lib/database.test.js.map +1 -1
- package/dist/lib/event-emitter.d.ts +4 -0
- package/dist/lib/event-emitter.d.ts.map +1 -1
- package/dist/lib/event-emitter.js.map +1 -1
- package/dist/lib/portable/bundle.d.ts +57 -0
- package/dist/lib/portable/bundle.d.ts.map +1 -0
- package/dist/lib/portable/bundle.js +139 -0
- package/dist/lib/portable/bundle.js.map +1 -0
- package/dist/lib/portable/cli.d.ts +14 -0
- package/dist/lib/portable/cli.d.ts.map +1 -0
- package/dist/lib/portable/cli.js +102 -0
- package/dist/lib/portable/cli.js.map +1 -0
- package/dist/lib/portable/cli.test.d.ts +2 -0
- package/dist/lib/portable/cli.test.d.ts.map +1 -0
- package/dist/lib/portable/cli.test.js +98 -0
- package/dist/lib/portable/cli.test.js.map +1 -0
- package/dist/lib/portable/files.d.ts +17 -0
- package/dist/lib/portable/files.d.ts.map +1 -0
- package/dist/lib/portable/files.js +37 -0
- package/dist/lib/portable/files.js.map +1 -0
- package/dist/lib/portable/import.d.ts +52 -0
- package/dist/lib/portable/import.d.ts.map +1 -0
- package/dist/lib/portable/import.js +211 -0
- package/dist/lib/portable/import.js.map +1 -0
- package/dist/lib/portable/index.d.ts +5 -0
- package/dist/lib/portable/index.d.ts.map +1 -0
- package/dist/lib/portable/index.js +5 -0
- package/dist/lib/portable/index.js.map +1 -0
- package/dist/lib/portable/portable.integration.test.d.ts +2 -0
- package/dist/lib/portable/portable.integration.test.d.ts.map +1 -0
- package/dist/lib/portable/portable.integration.test.js +139 -0
- package/dist/lib/portable/portable.integration.test.js.map +1 -0
- package/dist/lib/portable/registry.d.ts +47 -0
- package/dist/lib/portable/registry.d.ts.map +1 -0
- package/dist/lib/portable/registry.js +211 -0
- package/dist/lib/portable/registry.js.map +1 -0
- package/dist/lib/portable/registry.schema.test.d.ts +2 -0
- package/dist/lib/portable/registry.schema.test.d.ts.map +1 -0
- package/dist/lib/portable/registry.schema.test.js +59 -0
- package/dist/lib/portable/registry.schema.test.js.map +1 -0
- package/dist/routes/redirect.click-event.test.d.ts +2 -0
- package/dist/routes/redirect.click-event.test.d.ts.map +1 -0
- package/dist/routes/redirect.click-event.test.js +85 -0
- package/dist/routes/redirect.click-event.test.js.map +1 -0
- package/dist/routes/redirect.d.ts.map +1 -1
- package/dist/routes/redirect.js +3 -0
- package/dist/routes/redirect.js.map +1 -1
- package/dist/routes/sdk.d.ts.map +1 -1
- package/dist/routes/sdk.js +1 -0
- package/dist/routes/sdk.js.map +1 -1
- package/package.json +4 -1
|
@@ -0,0 +1,98 @@
|
|
|
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 { runCli } from './cli.js';
|
|
8
|
+
/**
|
|
9
|
+
* The `linkforty` command: arguments, exit codes and what it prints. The
|
|
10
|
+
* export and import runs need a real database and skip without DATABASE_URL.
|
|
11
|
+
*/
|
|
12
|
+
function capture() {
|
|
13
|
+
const lines = [];
|
|
14
|
+
const errors = [];
|
|
15
|
+
return { lines, errors, log: (l) => lines.push(l), logError: (l) => errors.push(l) };
|
|
16
|
+
}
|
|
17
|
+
describe('linkforty usage', () => {
|
|
18
|
+
it('prints usage and exits 2 for an unknown command or a missing file', async () => {
|
|
19
|
+
for (const argv of [[], ['backup'], ['export']]) {
|
|
20
|
+
const out = capture();
|
|
21
|
+
expect(await runCli(argv, {}, out.log, out.logError)).toBe(2);
|
|
22
|
+
expect(out.errors.join('\n')).toContain('Usage:');
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
it('exits 2 for a malformed flag', async () => {
|
|
26
|
+
const out = capture();
|
|
27
|
+
expect(await runCli(['import', 'x.lfbundle', '--organization'], {}, out.log, out.logError)).toBe(2);
|
|
28
|
+
expect(out.errors[0]).toContain('--organization needs a value');
|
|
29
|
+
});
|
|
30
|
+
it('exits 2 without DATABASE_URL', async () => {
|
|
31
|
+
const out = capture();
|
|
32
|
+
expect(await runCli(['export', 'x.lfbundle'], {}, out.log, out.logError)).toBe(2);
|
|
33
|
+
expect(out.errors[0]).toContain('DATABASE_URL');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
const ADMIN_URL = process.env.DATABASE_URL;
|
|
37
|
+
describe.skipIf(!ADMIN_URL)('linkforty export and import (integration)', () => {
|
|
38
|
+
const stamp = Date.now();
|
|
39
|
+
const names = { source: `lf_core_cli_src_${stamp}`, target: `lf_core_cli_dst_${stamp}` };
|
|
40
|
+
let admin;
|
|
41
|
+
let dir;
|
|
42
|
+
let file;
|
|
43
|
+
const urlFor = (name) => {
|
|
44
|
+
const url = new URL(ADMIN_URL);
|
|
45
|
+
url.pathname = `/${name}`;
|
|
46
|
+
return url.toString();
|
|
47
|
+
};
|
|
48
|
+
const run = async (argv, database) => {
|
|
49
|
+
const out = capture();
|
|
50
|
+
const code = await runCli(argv, { DATABASE_URL: urlFor(names[database]) }, out.log, out.logError);
|
|
51
|
+
return { code, text: out.lines.join('\n'), errors: out.errors.join('\n') };
|
|
52
|
+
};
|
|
53
|
+
beforeAll(async () => {
|
|
54
|
+
admin = new pg.Client({ connectionString: ADMIN_URL });
|
|
55
|
+
await admin.connect();
|
|
56
|
+
for (const name of Object.values(names))
|
|
57
|
+
await admin.query(`CREATE DATABASE ${name}`);
|
|
58
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lf-cli-'));
|
|
59
|
+
file = path.join(dir, 'server.lfbundle');
|
|
60
|
+
// An export creates the schema; then seed one link and one webhook.
|
|
61
|
+
await run(['export', path.join(dir, 'empty.lfbundle')], 'source');
|
|
62
|
+
const source = new pg.Client({ connectionString: urlFor(names.source) });
|
|
63
|
+
await source.connect();
|
|
64
|
+
await source.query(`INSERT INTO links (short_code, original_url) VALUES ('hello', 'https://example.com')`);
|
|
65
|
+
await source.query(`INSERT INTO webhooks (name, url, secret, events) VALUES ('w', 'https://hooks.example', 's', '{click_event}')`);
|
|
66
|
+
await source.end();
|
|
67
|
+
}, 60000);
|
|
68
|
+
afterAll(async () => {
|
|
69
|
+
for (const name of Object.values(names))
|
|
70
|
+
await admin.query(`DROP DATABASE IF EXISTS ${name} WITH (FORCE)`);
|
|
71
|
+
await admin.end();
|
|
72
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
73
|
+
}, 30000);
|
|
74
|
+
it('exports, dry-runs, imports and then reports conflicts', async () => {
|
|
75
|
+
const exported = await run(['export', file], 'source');
|
|
76
|
+
expect(exported.code).toBe(0);
|
|
77
|
+
expect(exported.text).toContain('Exported 2 rows');
|
|
78
|
+
const dry = await run(['import', file, '--dry-run'], 'target');
|
|
79
|
+
expect(dry).toMatchObject({ code: 0 });
|
|
80
|
+
expect(dry.text).toContain('Dry run: no conflicts');
|
|
81
|
+
const imported = await run(['import', file], 'target');
|
|
82
|
+
expect(imported.code).toBe(0);
|
|
83
|
+
expect(imported.text).toContain('Imported.');
|
|
84
|
+
expect(imported.text).toMatch(/https:\/\/hooks\.example {2}[0-9a-f]{64}/);
|
|
85
|
+
const again = await run(['import', file], 'target');
|
|
86
|
+
expect(again.code).toBe(1);
|
|
87
|
+
expect(again.text).toContain('conflict(s); nothing was imported');
|
|
88
|
+
expect(again.text).toContain('short_code_taken: hello');
|
|
89
|
+
}, 60000);
|
|
90
|
+
it('exits 1 with the reason for a file that is not a bundle', async () => {
|
|
91
|
+
const bogus = path.join(dir, 'bogus.lfbundle');
|
|
92
|
+
fs.writeFileSync(bogus, zlib.gzipSync('{"hello":"world"}\n'));
|
|
93
|
+
const result = await run(['import', bogus], 'target');
|
|
94
|
+
expect(result.code).toBe(1);
|
|
95
|
+
expect(result.errors).toContain('Not a LinkForty bundle');
|
|
96
|
+
}, 60000);
|
|
97
|
+
});
|
|
98
|
+
//# sourceMappingURL=cli.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.test.js","sourceRoot":"","sources":["../../../src/lib/portable/cli.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundles as files: gzipped NDJSON, conventionally named `*.lfbundle`.
|
|
3
|
+
*/
|
|
4
|
+
import type { Pool } from 'pg';
|
|
5
|
+
import { type ExportOptions } from './bundle.js';
|
|
6
|
+
import { type ImportOptions, type ImportReport } from './import.js';
|
|
7
|
+
/** Export to a gzipped bundle file at `path`. */
|
|
8
|
+
export declare function exportBundleToFile(pool: Pool, path: string, options?: ExportOptions): Promise<{
|
|
9
|
+
counts: Record<string, number>;
|
|
10
|
+
rows: number;
|
|
11
|
+
sha256: string;
|
|
12
|
+
}>;
|
|
13
|
+
/** The lines of a gzipped bundle file. */
|
|
14
|
+
export declare function bundleFileLines(path: string): AsyncIterable<string>;
|
|
15
|
+
/** Check, and unless it's a dry run, import a gzipped bundle file. */
|
|
16
|
+
export declare function importBundleFile(pool: Pool, path: string, options?: ImportOptions): Promise<ImportReport>;
|
|
17
|
+
//# sourceMappingURL=files.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"files.d.ts","sourceRoot":"","sources":["../../../src/lib/portable/files.ts"],"names":[],"mappings":"AAAA;;GAEG;AAMH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,IAAI,CAAC;AAC/B,OAAO,EAAgB,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AAC/D,OAAO,EAAgB,KAAK,aAAa,EAAE,KAAK,YAAY,EAAE,MAAM,aAAa,CAAC;AAElF,iDAAiD;AACjD,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,aAAkB;;;;GAe7F;AAED,0CAA0C;AAC1C,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,CAAC,MAAM,CAAC,CAGnE;AAED,sEAAsE;AACtE,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,aAAkB,GAAG,OAAO,CAAC,YAAY,CAAC,CAE7G"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundles as files: gzipped NDJSON, conventionally named `*.lfbundle`.
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import readline from 'readline';
|
|
6
|
+
import zlib from 'zlib';
|
|
7
|
+
import { finished } from 'stream/promises';
|
|
8
|
+
import { exportBundle } from './bundle.js';
|
|
9
|
+
import { importBundle } from './import.js';
|
|
10
|
+
/** Export to a gzipped bundle file at `path`. */
|
|
11
|
+
export async function exportBundleToFile(pool, path, options = {}) {
|
|
12
|
+
const gzip = zlib.createGzip();
|
|
13
|
+
const file = fs.createWriteStream(path);
|
|
14
|
+
gzip.pipe(file);
|
|
15
|
+
try {
|
|
16
|
+
const result = await exportBundle(pool, gzip, options);
|
|
17
|
+
gzip.end();
|
|
18
|
+
await finished(file);
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
gzip.destroy();
|
|
23
|
+
file.destroy();
|
|
24
|
+
await fs.promises.rm(path, { force: true });
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** The lines of a gzipped bundle file. */
|
|
29
|
+
export function bundleFileLines(path) {
|
|
30
|
+
const input = fs.createReadStream(path).pipe(zlib.createGunzip());
|
|
31
|
+
return readline.createInterface({ input, crlfDelay: Infinity });
|
|
32
|
+
}
|
|
33
|
+
/** Check, and unless it's a dry run, import a gzipped bundle file. */
|
|
34
|
+
export function importBundleFile(pool, path, options = {}) {
|
|
35
|
+
return importBundle(pool, () => bundleFileLines(path), options);
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=files.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"files.js","sourceRoot":"","sources":["../../../src/lib/portable/files.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,MAAM,IAAI,CAAC;AACpB,OAAO,QAAQ,MAAM,UAAU,CAAC;AAChC,OAAO,IAAI,MAAM,MAAM,CAAC;AACxB,OAAO,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAE3C,OAAO,EAAE,YAAY,EAAsB,MAAM,aAAa,CAAC;AAC/D,OAAO,EAAE,YAAY,EAAyC,MAAM,aAAa,CAAC;AAElF,iDAAiD;AACjD,MAAM,CAAC,KAAK,UAAU,kBAAkB,CAAC,IAAU,EAAE,IAAY,EAAE,UAAyB,EAAE;IAC5F,MAAM,IAAI,GAAG,IAAI,CAAC,UAAU,EAAE,CAAC;IAC/B,MAAM,IAAI,GAAG,EAAE,CAAC,iBAAiB,CAAC,IAAI,CAAC,CAAC;IACxC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAChB,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,MAAM,YAAY,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,CAAC,CAAC;QACvD,IAAI,CAAC,GAAG,EAAE,CAAC;QACX,MAAM,QAAQ,CAAC,IAAI,CAAC,CAAC;QACrB,OAAO,MAAM,CAAC;IAChB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,CAAC,OAAO,EAAE,CAAC;QACf,IAAI,CAAC,OAAO,EAAE,CAAC;QACf,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;QAC5C,MAAM,KAAK,CAAC;IACd,CAAC;AACH,CAAC;AAED,0CAA0C;AAC1C,MAAM,UAAU,eAAe,CAAC,IAAY;IAC1C,MAAM,KAAK,GAAG,EAAE,CAAC,gBAAgB,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,YAAY,EAAE,CAAC,CAAC;IAClE,OAAO,QAAQ,CAAC,eAAe,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,CAAC;AAClE,CAAC;AAED,sEAAsE;AACtE,MAAM,UAAU,gBAAgB,CAAC,IAAU,EAAE,IAAY,EAAE,UAAyB,EAAE;IACpF,OAAO,YAAY,CAAC,IAAI,EAAE,GAAG,EAAE,CAAC,eAAe,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC,CAAC;AAClE,CAAC"}
|
|
@@ -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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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
|