@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 CHANGED
@@ -1,3 +1,9 @@
1
- # Temporary Holding Version
1
+ # @tangen/porter
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
@@ -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>;
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }