@tangen/porter 0.0.0-stage → 0.1.4
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 +8 -2
- package/dist/bundle.d.ts +63 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +225 -0
- package/dist/tar.d.ts +24 -0
- package/package.json +50 -4
- package/src/bundle.ts +178 -0
- package/src/index.ts +14 -0
- package/src/tar.ts +160 -0
package/README.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tangen/porter
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Porter is Tangen's data movement product: promote content between
|
|
4
|
+
environments, export/import portable bundles, and transfer directly between
|
|
5
|
+
instances. A porter carries your goods safely between places - and data
|
|
6
|
+
portability is the promise.
|
|
7
|
+
|
|
8
|
+
Optional extension package. Install only when you want the Export/Import
|
|
9
|
+
surfaces; environment promotion plumbing lives in the platform core.
|
package/dist/bundle.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Name: bundle.ts
|
|
3
|
+
* Purpose: The Porter bundle format (E-T1) - the portable shape of a Tangen
|
|
4
|
+
* export. A bundle is a storage prefix holding streaming JSONL SECTIONS
|
|
5
|
+
* (objects per type, media index, custom tables, ...) in immutable parts,
|
|
6
|
+
* plus raw media files, sealed by a manifest written LAST: the manifest's
|
|
7
|
+
* presence is the completeness marker, and it records the schema
|
|
8
|
+
* fingerprint the import side diffs before writing anything.
|
|
9
|
+
*
|
|
10
|
+
* Author: Raghunath J
|
|
11
|
+
* License: MIT
|
|
12
|
+
*
|
|
13
|
+
* Dependencies/Notes: Only @tangen/types (StorageAdapter). Parts mirror the
|
|
14
|
+
* run-ledger design: storage backends have no append, so each flush is one
|
|
15
|
+
* immutable file - nothing is ever buffered beyond flushEvery entries.
|
|
16
|
+
* Layout under porter/bundles/<bundleId>/:
|
|
17
|
+
* manifest.json
|
|
18
|
+
* <section>-<part>.jsonl e.g. objects/page-0.jsonl
|
|
19
|
+
* media/files/<assetId>/<file> raw bytes
|
|
20
|
+
*/
|
|
21
|
+
import type { StorageAdapter } from '@tangen/types';
|
|
22
|
+
export declare const BUNDLE_FORMAT_VERSION = "1.0.0";
|
|
23
|
+
export interface BundleManifest {
|
|
24
|
+
formatVersion: string;
|
|
25
|
+
workspaceId: string;
|
|
26
|
+
sourceEnvId: string | null;
|
|
27
|
+
createdAt: string;
|
|
28
|
+
createdBy: string | null;
|
|
29
|
+
/** Field-level schema fingerprint of the exported types (schema-diff shape). */
|
|
30
|
+
schema: unknown[];
|
|
31
|
+
/** section name -> number of JSONL parts. */
|
|
32
|
+
sections: Record<string, number>;
|
|
33
|
+
/** section name -> number of entries. */
|
|
34
|
+
counts: Record<string, number>;
|
|
35
|
+
[key: string]: unknown;
|
|
36
|
+
}
|
|
37
|
+
export interface BundleSectionWriter {
|
|
38
|
+
append(entry: Record<string, unknown>): void;
|
|
39
|
+
/** Writes buffered entries as one immutable part. */
|
|
40
|
+
flush(): Promise<void>;
|
|
41
|
+
close(): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
export interface BundleWriter {
|
|
44
|
+
section(name: string): BundleSectionWriter;
|
|
45
|
+
writeMediaFile(assetId: string, filename: string, bytes: Uint8Array, contentType?: string): Promise<void>;
|
|
46
|
+
/** Writes the manifest LAST - sealing marks the bundle complete. */
|
|
47
|
+
seal(manifest: Omit<BundleManifest, 'formatVersion' | 'sections' | 'counts'>): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
export declare function createBundleWriter(storage: StorageAdapter, bundleId: string, opts?: {
|
|
50
|
+
flushEvery?: number;
|
|
51
|
+
}): BundleWriter;
|
|
52
|
+
/**
|
|
53
|
+
* Refuses bundles this build cannot understand: absent formatVersion, or a
|
|
54
|
+
* MAJOR newer than ours. Older majors pass through to documented shims.
|
|
55
|
+
*/
|
|
56
|
+
export declare function assertSupportedFormat(manifest: Pick<BundleManifest, 'formatVersion'>): void;
|
|
57
|
+
export interface BundleReader {
|
|
58
|
+
manifest: BundleManifest;
|
|
59
|
+
readSection(name: string): AsyncGenerator<Record<string, unknown>>;
|
|
60
|
+
readMediaFile(assetId: string, filename: string): Promise<Uint8Array | null>;
|
|
61
|
+
}
|
|
62
|
+
/** Opens a SEALED bundle; an absent manifest means incomplete -> refuse. */
|
|
63
|
+
export declare function openBundleReader(storage: StorageAdapter, bundleId: string): Promise<BundleReader>;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Name: index.ts
|
|
3
|
+
* Purpose: @tangen/porter public surface - the data movement product.
|
|
4
|
+
* E-T1 ships the bundle format; exporters, importers and the remote
|
|
5
|
+
* transport land in their arcs.
|
|
6
|
+
*
|
|
7
|
+
* Author: Raghunath J
|
|
8
|
+
* License: MIT
|
|
9
|
+
*/
|
|
10
|
+
export { createBundleWriter, openBundleReader, assertSupportedFormat, BUNDLE_FORMAT_VERSION, type BundleManifest, type BundleWriter, type BundleReader, type BundleSectionWriter, } from './bundle';
|
|
11
|
+
export { bundleKeys, tarGzBundle, untarGz, importBundleFromTarGz, type TarEntry } from './tar';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// src/bundle.ts
|
|
2
|
+
var BUNDLE_FORMAT_VERSION = "1.0.0";
|
|
3
|
+
var prefix = (bundleId) => `porter/bundles/${bundleId}`;
|
|
4
|
+
var partKey = (bundleId, section, part) => `${prefix(bundleId)}/${section}-${part}.jsonl`;
|
|
5
|
+
var manifestKey = (bundleId) => `${prefix(bundleId)}/manifest.json`;
|
|
6
|
+
var mediaFileKey = (bundleId, assetId, filename) => `${prefix(bundleId)}/media/files/${assetId}/${filename}`;
|
|
7
|
+
function createBundleWriter(storage, bundleId, opts = {}) {
|
|
8
|
+
const flushEvery = opts.flushEvery ?? 500;
|
|
9
|
+
const sections = {};
|
|
10
|
+
const counts = {};
|
|
11
|
+
let sealed = false;
|
|
12
|
+
function section(name) {
|
|
13
|
+
sections[name] ??= 0;
|
|
14
|
+
counts[name] ??= 0;
|
|
15
|
+
let buffer = [];
|
|
16
|
+
let pending = Promise.resolve();
|
|
17
|
+
function flush() {
|
|
18
|
+
if (buffer.length > 0) {
|
|
19
|
+
const entries = buffer;
|
|
20
|
+
buffer = [];
|
|
21
|
+
const part = sections[name];
|
|
22
|
+
sections[name] += 1;
|
|
23
|
+
counts[name] += entries.length;
|
|
24
|
+
const lines = entries.map((e) => JSON.stringify(e)).join(`
|
|
25
|
+
`) + `
|
|
26
|
+
`;
|
|
27
|
+
pending = pending.then(() => storage.write(partKey(bundleId, name, part), new TextEncoder().encode(lines), {
|
|
28
|
+
contentType: "application/x-ndjson"
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
return pending;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
append(entry) {
|
|
35
|
+
buffer.push(entry);
|
|
36
|
+
if (buffer.length >= flushEvery)
|
|
37
|
+
flush();
|
|
38
|
+
},
|
|
39
|
+
flush,
|
|
40
|
+
close: flush
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
section,
|
|
45
|
+
async writeMediaFile(assetId, filename, bytes, contentType) {
|
|
46
|
+
await storage.write(mediaFileKey(bundleId, assetId, filename), bytes, { contentType });
|
|
47
|
+
},
|
|
48
|
+
async seal(manifest) {
|
|
49
|
+
if (sealed)
|
|
50
|
+
throw new Error(`bundle ${bundleId} is already sealed`);
|
|
51
|
+
sealed = true;
|
|
52
|
+
const full = {
|
|
53
|
+
...manifest,
|
|
54
|
+
formatVersion: BUNDLE_FORMAT_VERSION,
|
|
55
|
+
sections: { ...sections },
|
|
56
|
+
counts: { ...counts }
|
|
57
|
+
};
|
|
58
|
+
await storage.write(manifestKey(bundleId), new TextEncoder().encode(JSON.stringify(full, null, 2)), {
|
|
59
|
+
contentType: "application/json"
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function assertSupportedFormat(manifest) {
|
|
65
|
+
const version = manifest.formatVersion;
|
|
66
|
+
if (!version || typeof version !== "string") {
|
|
67
|
+
throw new Error("bundle manifest has no formatVersion - not a Tangen export");
|
|
68
|
+
}
|
|
69
|
+
const major = Number(version.split(".")[0]);
|
|
70
|
+
const ourMajor = Number(BUNDLE_FORMAT_VERSION.split(".")[0]);
|
|
71
|
+
if (!Number.isFinite(major) || major > ourMajor) {
|
|
72
|
+
throw new Error(`bundle format ${version} is newer than this platform understands (${BUNDLE_FORMAT_VERSION}) - update the platform before importing`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function openBundleReader(storage, bundleId) {
|
|
76
|
+
const raw = await storage.read(manifestKey(bundleId));
|
|
77
|
+
if (!raw) {
|
|
78
|
+
throw new Error(`bundle ${bundleId} has no manifest - it is incomplete or was never sealed`);
|
|
79
|
+
}
|
|
80
|
+
const manifest = JSON.parse(new TextDecoder().decode(raw));
|
|
81
|
+
return {
|
|
82
|
+
manifest,
|
|
83
|
+
async* readSection(name) {
|
|
84
|
+
const parts = manifest.sections[name] ?? 0;
|
|
85
|
+
const decoder = new TextDecoder;
|
|
86
|
+
for (let part = 0;part < parts; part++) {
|
|
87
|
+
const bytes = await storage.read(partKey(bundleId, name, part));
|
|
88
|
+
if (!bytes)
|
|
89
|
+
continue;
|
|
90
|
+
for (const line of decoder.decode(bytes).split(`
|
|
91
|
+
`)) {
|
|
92
|
+
if (line.trim())
|
|
93
|
+
yield JSON.parse(line);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
readMediaFile(assetId, filename) {
|
|
98
|
+
return storage.read(mediaFileKey(bundleId, assetId, filename));
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
// src/tar.ts
|
|
103
|
+
import { gzipSync, gunzipSync } from "node:zlib";
|
|
104
|
+
var prefix2 = (bundleId) => `porter/bundles/${bundleId}`;
|
|
105
|
+
async function bundleKeys(storage, bundleId) {
|
|
106
|
+
const reader = await openBundleReader(storage, bundleId);
|
|
107
|
+
const keys = ["manifest.json"];
|
|
108
|
+
for (const [section, parts] of Object.entries(reader.manifest.sections)) {
|
|
109
|
+
for (let part = 0;part < parts; part++)
|
|
110
|
+
keys.push(`${section}-${part}.jsonl`);
|
|
111
|
+
}
|
|
112
|
+
for await (const entry of reader.readSection("media/index")) {
|
|
113
|
+
const asset = entry.asset;
|
|
114
|
+
const filename = entry.filename;
|
|
115
|
+
if (asset?.id && filename)
|
|
116
|
+
keys.push(`media/files/${asset.id}/${filename}`);
|
|
117
|
+
}
|
|
118
|
+
return keys;
|
|
119
|
+
}
|
|
120
|
+
function octal(value, length) {
|
|
121
|
+
const s = value.toString(8).padStart(length - 1, "0") + "\x00";
|
|
122
|
+
return new TextEncoder().encode(s);
|
|
123
|
+
}
|
|
124
|
+
function tarHeader(name, size, mtime) {
|
|
125
|
+
const header = new Uint8Array(512);
|
|
126
|
+
const enc = new TextEncoder;
|
|
127
|
+
let fileName = name;
|
|
128
|
+
let filePrefix = "";
|
|
129
|
+
if (fileName.length > 100) {
|
|
130
|
+
const cut = fileName.lastIndexOf("/", 155);
|
|
131
|
+
if (cut <= 0)
|
|
132
|
+
throw new Error(`tar: path too long and unsplittable: ${name}`);
|
|
133
|
+
filePrefix = fileName.slice(0, cut);
|
|
134
|
+
fileName = fileName.slice(cut + 1);
|
|
135
|
+
if (fileName.length > 100 || filePrefix.length > 155) {
|
|
136
|
+
throw new Error(`tar: path too long: ${name}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
header.set(enc.encode(fileName), 0);
|
|
140
|
+
header.set(octal(420, 8), 100);
|
|
141
|
+
header.set(octal(0, 8), 108);
|
|
142
|
+
header.set(octal(0, 8), 116);
|
|
143
|
+
header.set(octal(size, 12), 124);
|
|
144
|
+
header.set(octal(mtime, 12), 136);
|
|
145
|
+
header.set(enc.encode(" "), 148);
|
|
146
|
+
header[156] = 48;
|
|
147
|
+
header.set(enc.encode("ustar\x00"), 257);
|
|
148
|
+
header.set(enc.encode("00"), 263);
|
|
149
|
+
header.set(enc.encode(filePrefix), 345);
|
|
150
|
+
let checksum = 0;
|
|
151
|
+
for (const byte of header)
|
|
152
|
+
checksum += byte;
|
|
153
|
+
const cs = checksum.toString(8).padStart(6, "0") + "\x00 ";
|
|
154
|
+
header.set(enc.encode(cs), 148);
|
|
155
|
+
return header;
|
|
156
|
+
}
|
|
157
|
+
async function tarGzBundle(storage, bundleId, opts = {}) {
|
|
158
|
+
const mtime = opts.mtime ?? 0;
|
|
159
|
+
const keys = (await bundleKeys(storage, bundleId)).filter((k) => !opts.excludePrefix || !k.startsWith(opts.excludePrefix));
|
|
160
|
+
const chunks = [];
|
|
161
|
+
for (const key of keys) {
|
|
162
|
+
const bytes = await storage.read(`${prefix2(bundleId)}/${key}`);
|
|
163
|
+
if (!bytes)
|
|
164
|
+
throw new Error(`bundle ${bundleId} is missing "${key}" - refusing to package an incomplete archive`);
|
|
165
|
+
chunks.push(tarHeader(key, bytes.byteLength, mtime));
|
|
166
|
+
chunks.push(bytes);
|
|
167
|
+
const pad = (512 - bytes.byteLength % 512) % 512;
|
|
168
|
+
if (pad > 0)
|
|
169
|
+
chunks.push(new Uint8Array(pad));
|
|
170
|
+
}
|
|
171
|
+
chunks.push(new Uint8Array(1024));
|
|
172
|
+
const total = chunks.reduce((n, c) => n + c.byteLength, 0);
|
|
173
|
+
const tar = new Uint8Array(total);
|
|
174
|
+
let offset = 0;
|
|
175
|
+
for (const c of chunks) {
|
|
176
|
+
tar.set(c, offset);
|
|
177
|
+
offset += c.byteLength;
|
|
178
|
+
}
|
|
179
|
+
return new Uint8Array(gzipSync(tar));
|
|
180
|
+
}
|
|
181
|
+
function untarGz(archive) {
|
|
182
|
+
const tar = new Uint8Array(gunzipSync(archive));
|
|
183
|
+
const dec = new TextDecoder;
|
|
184
|
+
const entries = [];
|
|
185
|
+
let offset = 0;
|
|
186
|
+
while (offset + 512 <= tar.byteLength) {
|
|
187
|
+
const block = tar.subarray(offset, offset + 512);
|
|
188
|
+
if (block.every((b) => b === 0))
|
|
189
|
+
break;
|
|
190
|
+
const name = dec.decode(block.subarray(0, 100)).replace(/\0.*$/, "");
|
|
191
|
+
const prefixField = dec.decode(block.subarray(345, 500)).replace(/\0.*$/, "");
|
|
192
|
+
const size = parseInt(dec.decode(block.subarray(124, 136)).replace(/\0.*$/, "").trim() || "0", 8);
|
|
193
|
+
const typeflag = block[156];
|
|
194
|
+
offset += 512;
|
|
195
|
+
const path = prefixField ? `${prefixField}/${name}` : name;
|
|
196
|
+
if (typeflag === 48 || typeflag === 0) {
|
|
197
|
+
entries.push({ path, bytes: tar.slice(offset, offset + size) });
|
|
198
|
+
}
|
|
199
|
+
offset += Math.ceil(size / 512) * 512;
|
|
200
|
+
}
|
|
201
|
+
return entries;
|
|
202
|
+
}
|
|
203
|
+
async function importBundleFromTarGz(storage, bundleId, archive) {
|
|
204
|
+
const entries = untarGz(archive);
|
|
205
|
+
if (!entries.some((e) => e.path === "manifest.json")) {
|
|
206
|
+
throw new Error("archive has no manifest.json - not a sealed Tangen export");
|
|
207
|
+
}
|
|
208
|
+
for (const entry of entries) {
|
|
209
|
+
if (entry.path.includes("..") || entry.path.startsWith("/")) {
|
|
210
|
+
throw new Error(`archive entry escapes the bundle: ${entry.path}`);
|
|
211
|
+
}
|
|
212
|
+
await storage.write(`${prefix2(bundleId)}/${entry.path}`, entry.bytes);
|
|
213
|
+
}
|
|
214
|
+
return bundleId;
|
|
215
|
+
}
|
|
216
|
+
export {
|
|
217
|
+
untarGz,
|
|
218
|
+
tarGzBundle,
|
|
219
|
+
openBundleReader,
|
|
220
|
+
importBundleFromTarGz,
|
|
221
|
+
createBundleWriter,
|
|
222
|
+
bundleKeys,
|
|
223
|
+
assertSupportedFormat,
|
|
224
|
+
BUNDLE_FORMAT_VERSION
|
|
225
|
+
};
|
package/dist/tar.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { StorageAdapter } from '@tangen/types';
|
|
2
|
+
/**
|
|
3
|
+
* Every storage key of a sealed bundle, RELATIVE to the bundle root:
|
|
4
|
+
* manifest.json, each section part, each media file from media/index.
|
|
5
|
+
*/
|
|
6
|
+
export declare function bundleKeys(storage: StorageAdapter, bundleId: string): Promise<string[]>;
|
|
7
|
+
/** One .tar.gz of the whole sealed bundle. `excludePrefix` leaves keys out
|
|
8
|
+
* (R-T5: media bytes skip the wire when they travel by presigned URL). */
|
|
9
|
+
export declare function tarGzBundle(storage: StorageAdapter, bundleId: string, opts?: {
|
|
10
|
+
mtime?: number;
|
|
11
|
+
excludePrefix?: string;
|
|
12
|
+
}): Promise<Uint8Array>;
|
|
13
|
+
export interface TarEntry {
|
|
14
|
+
path: string;
|
|
15
|
+
bytes: Uint8Array;
|
|
16
|
+
}
|
|
17
|
+
/** Extracts regular files from a .tar.gz (ustar incl. prefix field). */
|
|
18
|
+
export declare function untarGz(archive: Uint8Array): TarEntry[];
|
|
19
|
+
/**
|
|
20
|
+
* Writes an uploaded .tar.gz into storage as a bundle under `bundleId`.
|
|
21
|
+
* Refuses archives without a manifest (they were never sealed) and entries
|
|
22
|
+
* that escape the bundle prefix.
|
|
23
|
+
*/
|
|
24
|
+
export declare function importBundleFromTarGz(storage: StorageAdapter, bundleId: string, archive: Uint8Array): Promise<string>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangen/porter",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "Porter - data movement for Tangen CMS: promote, export/import bundles, remote transfer. A porter carries your goods safely between places.",
|
|
5
|
+
"homepage": "https://github.com/raghunathj/tangen#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/raghunathj/tangen/issues"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/raghunathj/tangen.git",
|
|
13
|
+
"directory": "packages/porter"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src",
|
|
21
|
+
"!**/__tests__/**",
|
|
22
|
+
"!**/*.test.ts",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public",
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "bun build ./src/index.ts --outdir dist --target node --format esm --packages external && tsc --emitDeclarationOnly -p tsconfig.build.json",
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"test": "bun test"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@tangen/types": "^0.3.0"
|
|
44
|
+
},
|
|
45
|
+
"exports": {
|
|
46
|
+
".": {
|
|
47
|
+
"types": "./dist/index.d.ts",
|
|
48
|
+
"import": "./dist/index.js",
|
|
49
|
+
"default": "./dist/index.js"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/bundle.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Name: bundle.ts
|
|
3
|
+
* Purpose: The Porter bundle format (E-T1) - the portable shape of a Tangen
|
|
4
|
+
* export. A bundle is a storage prefix holding streaming JSONL SECTIONS
|
|
5
|
+
* (objects per type, media index, custom tables, ...) in immutable parts,
|
|
6
|
+
* plus raw media files, sealed by a manifest written LAST: the manifest's
|
|
7
|
+
* presence is the completeness marker, and it records the schema
|
|
8
|
+
* fingerprint the import side diffs before writing anything.
|
|
9
|
+
*
|
|
10
|
+
* Author: Raghunath J
|
|
11
|
+
* License: MIT
|
|
12
|
+
*
|
|
13
|
+
* Dependencies/Notes: Only @tangen/types (StorageAdapter). Parts mirror the
|
|
14
|
+
* run-ledger design: storage backends have no append, so each flush is one
|
|
15
|
+
* immutable file - nothing is ever buffered beyond flushEvery entries.
|
|
16
|
+
* Layout under porter/bundles/<bundleId>/:
|
|
17
|
+
* manifest.json
|
|
18
|
+
* <section>-<part>.jsonl e.g. objects/page-0.jsonl
|
|
19
|
+
* media/files/<assetId>/<file> raw bytes
|
|
20
|
+
*/
|
|
21
|
+
import type { StorageAdapter } from '@tangen/types'
|
|
22
|
+
|
|
23
|
+
export const BUNDLE_FORMAT_VERSION = '1.0.0'
|
|
24
|
+
|
|
25
|
+
export interface BundleManifest {
|
|
26
|
+
formatVersion: string
|
|
27
|
+
workspaceId: string
|
|
28
|
+
sourceEnvId: string | null
|
|
29
|
+
createdAt: string
|
|
30
|
+
createdBy: string | null
|
|
31
|
+
/** Field-level schema fingerprint of the exported types (schema-diff shape). */
|
|
32
|
+
schema: unknown[]
|
|
33
|
+
/** section name -> number of JSONL parts. */
|
|
34
|
+
sections: Record<string, number>
|
|
35
|
+
/** section name -> number of entries. */
|
|
36
|
+
counts: Record<string, number>
|
|
37
|
+
[key: string]: unknown
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface BundleSectionWriter {
|
|
41
|
+
append(entry: Record<string, unknown>): void
|
|
42
|
+
/** Writes buffered entries as one immutable part. */
|
|
43
|
+
flush(): Promise<void>
|
|
44
|
+
close(): Promise<void>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface BundleWriter {
|
|
48
|
+
section(name: string): BundleSectionWriter
|
|
49
|
+
writeMediaFile(assetId: string, filename: string, bytes: Uint8Array, contentType?: string): Promise<void>
|
|
50
|
+
/** Writes the manifest LAST - sealing marks the bundle complete. */
|
|
51
|
+
seal(manifest: Omit<BundleManifest, 'formatVersion' | 'sections' | 'counts'>): Promise<void>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const prefix = (bundleId: string) => `porter/bundles/${bundleId}`
|
|
55
|
+
const partKey = (bundleId: string, section: string, part: number) =>
|
|
56
|
+
`${prefix(bundleId)}/${section}-${part}.jsonl`
|
|
57
|
+
const manifestKey = (bundleId: string) => `${prefix(bundleId)}/manifest.json`
|
|
58
|
+
const mediaFileKey = (bundleId: string, assetId: string, filename: string) =>
|
|
59
|
+
`${prefix(bundleId)}/media/files/${assetId}/${filename}`
|
|
60
|
+
|
|
61
|
+
export function createBundleWriter(
|
|
62
|
+
storage: StorageAdapter,
|
|
63
|
+
bundleId: string,
|
|
64
|
+
opts: { flushEvery?: number } = {},
|
|
65
|
+
): BundleWriter {
|
|
66
|
+
const flushEvery = opts.flushEvery ?? 500
|
|
67
|
+
const sections: Record<string, number> = {}
|
|
68
|
+
const counts: Record<string, number> = {}
|
|
69
|
+
let sealed = false
|
|
70
|
+
|
|
71
|
+
function section(name: string): BundleSectionWriter {
|
|
72
|
+
sections[name] ??= 0
|
|
73
|
+
counts[name] ??= 0
|
|
74
|
+
let buffer: Record<string, unknown>[] = []
|
|
75
|
+
// Part numbers are assigned SYNCHRONOUSLY and writes are serialized on a
|
|
76
|
+
// chain - an auto-flush racing a close() must never share a part index.
|
|
77
|
+
let pending: Promise<void> = Promise.resolve()
|
|
78
|
+
|
|
79
|
+
function flush(): Promise<void> {
|
|
80
|
+
if (buffer.length > 0) {
|
|
81
|
+
const entries = buffer
|
|
82
|
+
buffer = []
|
|
83
|
+
const part = sections[name]
|
|
84
|
+
sections[name] += 1
|
|
85
|
+
counts[name] += entries.length
|
|
86
|
+
const lines = entries.map((e) => JSON.stringify(e)).join('\n') + '\n'
|
|
87
|
+
pending = pending.then(() =>
|
|
88
|
+
storage.write(partKey(bundleId, name, part), new TextEncoder().encode(lines), {
|
|
89
|
+
contentType: 'application/x-ndjson',
|
|
90
|
+
}),
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
return pending
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
append(entry) {
|
|
98
|
+
buffer.push(entry)
|
|
99
|
+
if (buffer.length >= flushEvery) void flush()
|
|
100
|
+
},
|
|
101
|
+
flush,
|
|
102
|
+
close: flush,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
section,
|
|
108
|
+
async writeMediaFile(assetId, filename, bytes, contentType) {
|
|
109
|
+
await storage.write(mediaFileKey(bundleId, assetId, filename), bytes, { contentType })
|
|
110
|
+
},
|
|
111
|
+
async seal(manifest) {
|
|
112
|
+
if (sealed) throw new Error(`bundle ${bundleId} is already sealed`)
|
|
113
|
+
sealed = true
|
|
114
|
+
// Omit over an index-signature type erases the known keys, so the
|
|
115
|
+
// spread needs an assertion to reassemble the full manifest shape.
|
|
116
|
+
const full = {
|
|
117
|
+
...manifest,
|
|
118
|
+
formatVersion: BUNDLE_FORMAT_VERSION,
|
|
119
|
+
sections: { ...sections },
|
|
120
|
+
counts: { ...counts },
|
|
121
|
+
} as BundleManifest
|
|
122
|
+
await storage.write(manifestKey(bundleId), new TextEncoder().encode(JSON.stringify(full, null, 2)), {
|
|
123
|
+
contentType: 'application/json',
|
|
124
|
+
})
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Refuses bundles this build cannot understand: absent formatVersion, or a
|
|
131
|
+
* MAJOR newer than ours. Older majors pass through to documented shims.
|
|
132
|
+
*/
|
|
133
|
+
export function assertSupportedFormat(manifest: Pick<BundleManifest, 'formatVersion'>): void {
|
|
134
|
+
const version = manifest.formatVersion
|
|
135
|
+
if (!version || typeof version !== 'string') {
|
|
136
|
+
throw new Error('bundle manifest has no formatVersion - not a Tangen export')
|
|
137
|
+
}
|
|
138
|
+
const major = Number(version.split('.')[0])
|
|
139
|
+
const ourMajor = Number(BUNDLE_FORMAT_VERSION.split('.')[0])
|
|
140
|
+
if (!Number.isFinite(major) || major > ourMajor) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`bundle format ${version} is newer than this platform understands (${BUNDLE_FORMAT_VERSION}) - update the platform before importing`,
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface BundleReader {
|
|
148
|
+
manifest: BundleManifest
|
|
149
|
+
readSection(name: string): AsyncGenerator<Record<string, unknown>>
|
|
150
|
+
readMediaFile(assetId: string, filename: string): Promise<Uint8Array | null>
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Opens a SEALED bundle; an absent manifest means incomplete -> refuse. */
|
|
154
|
+
export async function openBundleReader(storage: StorageAdapter, bundleId: string): Promise<BundleReader> {
|
|
155
|
+
const raw = await storage.read(manifestKey(bundleId))
|
|
156
|
+
if (!raw) {
|
|
157
|
+
throw new Error(`bundle ${bundleId} has no manifest - it is incomplete or was never sealed`)
|
|
158
|
+
}
|
|
159
|
+
const manifest = JSON.parse(new TextDecoder().decode(raw)) as BundleManifest
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
manifest,
|
|
163
|
+
async *readSection(name) {
|
|
164
|
+
const parts = manifest.sections[name] ?? 0
|
|
165
|
+
const decoder = new TextDecoder()
|
|
166
|
+
for (let part = 0; part < parts; part++) {
|
|
167
|
+
const bytes = await storage.read(partKey(bundleId, name, part))
|
|
168
|
+
if (!bytes) continue
|
|
169
|
+
for (const line of decoder.decode(bytes).split('\n')) {
|
|
170
|
+
if (line.trim()) yield JSON.parse(line)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
readMediaFile(assetId, filename) {
|
|
175
|
+
return storage.read(mediaFileKey(bundleId, assetId, filename))
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Name: index.ts
|
|
3
|
+
* Purpose: @tangen/porter public surface - the data movement product.
|
|
4
|
+
* E-T1 ships the bundle format; exporters, importers and the remote
|
|
5
|
+
* transport land in their arcs.
|
|
6
|
+
*
|
|
7
|
+
* Author: Raghunath J
|
|
8
|
+
* License: MIT
|
|
9
|
+
*/
|
|
10
|
+
export {
|
|
11
|
+
createBundleWriter, openBundleReader, assertSupportedFormat, BUNDLE_FORMAT_VERSION,
|
|
12
|
+
type BundleManifest, type BundleWriter, type BundleReader, type BundleSectionWriter,
|
|
13
|
+
} from './bundle'
|
|
14
|
+
export { bundleKeys, tarGzBundle, untarGz, importBundleFromTarGz, type TarEntry } from './tar'
|
package/src/tar.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Name: tar.ts
|
|
3
|
+
* Purpose: Packages a sealed bundle as one downloadable .tar.gz - the
|
|
4
|
+
* portable file people move between machines and instances. Zero
|
|
5
|
+
* dependencies: hand-rolled ustar headers + node:zlib gzip. Storage
|
|
6
|
+
* adapters cannot enumerate keys, so the file list derives entirely from
|
|
7
|
+
* the manifest (sections x parts + media/index entries).
|
|
8
|
+
*
|
|
9
|
+
* Author: Raghunath J
|
|
10
|
+
* License: MIT
|
|
11
|
+
*
|
|
12
|
+
* Dependencies/Notes: ustar handles paths <= 100 chars directly; longer
|
|
13
|
+
* paths use the prefix field (<= 155 + 100), which covers every key shape
|
|
14
|
+
* Porter generates. Entries are regular files, mode 0644, deterministic
|
|
15
|
+
* given an mtime.
|
|
16
|
+
*/
|
|
17
|
+
import { gzipSync, gunzipSync } from 'node:zlib'
|
|
18
|
+
import type { StorageAdapter } from '@tangen/types'
|
|
19
|
+
import { openBundleReader } from './bundle'
|
|
20
|
+
|
|
21
|
+
const prefix = (bundleId: string) => `porter/bundles/${bundleId}`
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Every storage key of a sealed bundle, RELATIVE to the bundle root:
|
|
25
|
+
* manifest.json, each section part, each media file from media/index.
|
|
26
|
+
*/
|
|
27
|
+
export async function bundleKeys(storage: StorageAdapter, bundleId: string): Promise<string[]> {
|
|
28
|
+
const reader = await openBundleReader(storage, bundleId)
|
|
29
|
+
const keys: string[] = ['manifest.json']
|
|
30
|
+
for (const [section, parts] of Object.entries(reader.manifest.sections)) {
|
|
31
|
+
for (let part = 0; part < parts; part++) keys.push(`${section}-${part}.jsonl`)
|
|
32
|
+
}
|
|
33
|
+
for await (const entry of reader.readSection('media/index')) {
|
|
34
|
+
const asset = (entry as any).asset
|
|
35
|
+
const filename = (entry as any).filename
|
|
36
|
+
if (asset?.id && filename) keys.push(`media/files/${asset.id}/${filename}`)
|
|
37
|
+
}
|
|
38
|
+
return keys
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function octal(value: number, length: number): Uint8Array {
|
|
42
|
+
const s = value.toString(8).padStart(length - 1, '0') + '\0'
|
|
43
|
+
return new TextEncoder().encode(s)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function tarHeader(name: string, size: number, mtime: number): Uint8Array {
|
|
47
|
+
const header = new Uint8Array(512)
|
|
48
|
+
const enc = new TextEncoder()
|
|
49
|
+
|
|
50
|
+
// Long paths: split into ustar prefix (155) + name (100) on a '/'.
|
|
51
|
+
let fileName = name
|
|
52
|
+
let filePrefix = ''
|
|
53
|
+
if (fileName.length > 100) {
|
|
54
|
+
const cut = fileName.lastIndexOf('/', 155)
|
|
55
|
+
if (cut <= 0) throw new Error(`tar: path too long and unsplittable: ${name}`)
|
|
56
|
+
filePrefix = fileName.slice(0, cut)
|
|
57
|
+
fileName = fileName.slice(cut + 1)
|
|
58
|
+
if (fileName.length > 100 || filePrefix.length > 155) {
|
|
59
|
+
throw new Error(`tar: path too long: ${name}`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
header.set(enc.encode(fileName), 0) // name
|
|
64
|
+
header.set(octal(0o644, 8), 100) // mode
|
|
65
|
+
header.set(octal(0, 8), 108) // uid
|
|
66
|
+
header.set(octal(0, 8), 116) // gid
|
|
67
|
+
header.set(octal(size, 12), 124) // size
|
|
68
|
+
header.set(octal(mtime, 12), 136) // mtime
|
|
69
|
+
header.set(enc.encode(' '), 148) // checksum placeholder (spaces)
|
|
70
|
+
header[156] = 0x30 // typeflag '0' regular file
|
|
71
|
+
header.set(enc.encode('ustar\0'), 257) // magic
|
|
72
|
+
header.set(enc.encode('00'), 263) // version
|
|
73
|
+
header.set(enc.encode(filePrefix), 345) // prefix
|
|
74
|
+
|
|
75
|
+
let checksum = 0
|
|
76
|
+
for (const byte of header) checksum += byte
|
|
77
|
+
const cs = checksum.toString(8).padStart(6, '0') + '\0 '
|
|
78
|
+
header.set(enc.encode(cs), 148)
|
|
79
|
+
return header
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One .tar.gz of the whole sealed bundle. `excludePrefix` leaves keys out
|
|
83
|
+
* (R-T5: media bytes skip the wire when they travel by presigned URL). */
|
|
84
|
+
export async function tarGzBundle(
|
|
85
|
+
storage: StorageAdapter,
|
|
86
|
+
bundleId: string,
|
|
87
|
+
opts: { mtime?: number; excludePrefix?: string } = {},
|
|
88
|
+
): Promise<Uint8Array> {
|
|
89
|
+
const mtime = opts.mtime ?? 0
|
|
90
|
+
const keys = (await bundleKeys(storage, bundleId))
|
|
91
|
+
.filter((k) => !opts.excludePrefix || !k.startsWith(opts.excludePrefix))
|
|
92
|
+
const chunks: Uint8Array[] = []
|
|
93
|
+
|
|
94
|
+
for (const key of keys) {
|
|
95
|
+
const bytes = await storage.read(`${prefix(bundleId)}/${key}`)
|
|
96
|
+
if (!bytes) throw new Error(`bundle ${bundleId} is missing "${key}" - refusing to package an incomplete archive`)
|
|
97
|
+
chunks.push(tarHeader(key, bytes.byteLength, mtime))
|
|
98
|
+
chunks.push(bytes)
|
|
99
|
+
const pad = (512 - (bytes.byteLength % 512)) % 512
|
|
100
|
+
if (pad > 0) chunks.push(new Uint8Array(pad))
|
|
101
|
+
}
|
|
102
|
+
chunks.push(new Uint8Array(1024)) // end-of-archive: two zero blocks
|
|
103
|
+
|
|
104
|
+
const total = chunks.reduce((n, c) => n + c.byteLength, 0)
|
|
105
|
+
const tar = new Uint8Array(total)
|
|
106
|
+
let offset = 0
|
|
107
|
+
for (const c of chunks) { tar.set(c, offset); offset += c.byteLength }
|
|
108
|
+
|
|
109
|
+
return new Uint8Array(gzipSync(tar))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── reading (I-T5: uploads) ──────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
export interface TarEntry { path: string; bytes: Uint8Array }
|
|
115
|
+
|
|
116
|
+
/** Extracts regular files from a .tar.gz (ustar incl. prefix field). */
|
|
117
|
+
export function untarGz(archive: Uint8Array): TarEntry[] {
|
|
118
|
+
const tar = new Uint8Array(gunzipSync(archive))
|
|
119
|
+
const dec = new TextDecoder()
|
|
120
|
+
const entries: TarEntry[] = []
|
|
121
|
+
let offset = 0
|
|
122
|
+
while (offset + 512 <= tar.byteLength) {
|
|
123
|
+
const block = tar.subarray(offset, offset + 512)
|
|
124
|
+
if (block.every((b) => b === 0)) break // end-of-archive
|
|
125
|
+
const name = dec.decode(block.subarray(0, 100)).replace(/\0.*$/, '')
|
|
126
|
+
const prefixField = dec.decode(block.subarray(345, 500)).replace(/\0.*$/, '')
|
|
127
|
+
const size = parseInt(dec.decode(block.subarray(124, 136)).replace(/\0.*$/, '').trim() || '0', 8)
|
|
128
|
+
const typeflag = block[156]
|
|
129
|
+
offset += 512
|
|
130
|
+
const path = prefixField ? `${prefixField}/${name}` : name
|
|
131
|
+
if (typeflag === 0x30 || typeflag === 0) {
|
|
132
|
+
entries.push({ path, bytes: tar.slice(offset, offset + size) })
|
|
133
|
+
}
|
|
134
|
+
offset += Math.ceil(size / 512) * 512
|
|
135
|
+
}
|
|
136
|
+
return entries
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Writes an uploaded .tar.gz into storage as a bundle under `bundleId`.
|
|
141
|
+
* Refuses archives without a manifest (they were never sealed) and entries
|
|
142
|
+
* that escape the bundle prefix.
|
|
143
|
+
*/
|
|
144
|
+
export async function importBundleFromTarGz(
|
|
145
|
+
storage: StorageAdapter,
|
|
146
|
+
bundleId: string,
|
|
147
|
+
archive: Uint8Array,
|
|
148
|
+
): Promise<string> {
|
|
149
|
+
const entries = untarGz(archive)
|
|
150
|
+
if (!entries.some((e) => e.path === 'manifest.json')) {
|
|
151
|
+
throw new Error('archive has no manifest.json - not a sealed Tangen export')
|
|
152
|
+
}
|
|
153
|
+
for (const entry of entries) {
|
|
154
|
+
if (entry.path.includes('..') || entry.path.startsWith('/')) {
|
|
155
|
+
throw new Error(`archive entry escapes the bundle: ${entry.path}`)
|
|
156
|
+
}
|
|
157
|
+
await storage.write(`${prefix(bundleId)}/${entry.path}`, entry.bytes)
|
|
158
|
+
}
|
|
159
|
+
return bundleId
|
|
160
|
+
}
|