@gscdump/lakehouse 7.10.0 → 7.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -197,6 +197,35 @@ An uncertain response may follow a successful commit, so deleting staged files c
197
197
  The operation never expires historical snapshots or deletes historical manifests.
198
198
  Keep those objects while authenticated receipts or append retries still need them.
199
199
 
200
+ ## Table restoration
201
+
202
+ Use `restoreIcebergTable(connection, table, options)` from `@gscdump/lakehouse/maintenance` for an empty Catalog target.
203
+ Restore every referenced metadata, manifest, and data object before registering the table.
204
+ Preserve their S3 locations, table UUID, snapshot IDs, and immutable bytes.
205
+ The caller owns the target and excludes concurrent writers through settlement.
206
+
207
+ Supply the saved metadata location, SHA-256, table UUID, head snapshot ID, and complete snapshot ID inventory.
208
+ The operation checks the target, saved metadata bytes, and identity before registration.
209
+ Metadata can use plain JSON or gzip. The byte limit applies to stored and expanded bytes.
210
+ The SHA-256 always authenticates the stored object bytes before decompression.
211
+ It registers with `overwrite: false` and verifies the returned identity.
212
+ Existing tables produce `target-exists`, including tables with matching saved identities.
213
+ Malformed bytes or mismatched identities produce `backup-invalid` before registration.
214
+
215
+ Supply `confirmAuthority` to recheck ownership before access, immediately before registration, and after acknowledgement.
216
+ This callback does not create an atomic transaction with external state.
217
+ `Restored` confirms the acknowledged table identity. It does not verify every referenced data object.
218
+ After restoration, run the application's receipt, object hash, and query checks through fresh read sessions.
219
+ Supply `cache` to invalidate its saved snapshot pointer after success.
220
+
221
+ `Unknown` means registration may have committed.
222
+ Keep the restored objects and inspect fresh Catalog metadata before deciding the next action.
223
+ The operation never retries, overwrites, or removes objects after an uncertain response.
224
+ Other infrastructure failures propagate.
225
+
226
+ To detach a registration while retaining its objects, use `dropIcebergTables(connection, tables, { purge: false })`.
227
+ The default still requests purging. Use explicit table names for a restore drill.
228
+
200
229
  ## License
201
230
 
202
231
  [MIT](../../LICENSE)
@@ -64,6 +64,25 @@ async function restCatalogCreateTable(ctx, { namespace, table, schema, location,
64
64
  config: responseBody.config ?? {}
65
65
  };
66
66
  }
67
+ async function restCatalogRegisterTable(ctx, { namespace, table, metadataLocation, overwrite }) {
68
+ const ns = encodeNamespace(namespace);
69
+ const body = {
70
+ name: table,
71
+ "metadata-location": metadataLocation
72
+ };
73
+ if (overwrite !== void 0) body.overwrite = overwrite;
74
+ const res = await restFetch(ctx, `namespaces/${ns}/register`, {
75
+ method: "POST",
76
+ headers: { "content-type": "application/json" },
77
+ body: stringifyIcebergJson(body)
78
+ });
79
+ const responseBody = parseIcebergJson(await res.text());
80
+ return {
81
+ metadataLocation: responseBody["metadata-location"],
82
+ metadata: responseBody.metadata,
83
+ config: responseBody.config ?? {}
84
+ };
85
+ }
67
86
  async function restCatalogUpdateTable(ctx, { namespace, table, requirements, updates }) {
68
87
  const res = await restFetch(ctx, `namespaces/${encodeNamespace(namespace)}/tables/${encodeURIComponent(table)}`, {
69
88
  method: "POST",
@@ -161,4 +180,4 @@ async function paginate(baseParams, fetchPage) {
161
180
  pageToken = encodeURIComponent(nextPageToken);
162
181
  }
163
182
  }
164
- export { restCatalogConnect, restCatalogCreateNamespace, restCatalogCreateTable, restCatalogDropTable, restCatalogListTables, restCatalogLoadTable, restCatalogUpdateTable };
183
+ export { restCatalogConnect, restCatalogCreateNamespace, restCatalogCreateTable, restCatalogDropTable, restCatalogListTables, restCatalogLoadTable, restCatalogRegisterTable, restCatalogUpdateTable };
@@ -135,10 +135,12 @@ export interface IcebergTableOpResult {
135
135
  error?: string;
136
136
  }
137
137
  /**
138
- * Drop tables from the catalog namespace, purging their data objects.
138
+ * Drop tables from the catalog namespace. Set purge to false to preserve their objects.
139
139
  * Defaults to every table currently in the namespace.
140
140
  */
141
- export declare function dropIcebergTables(conn: IcebergConnection, tables?: readonly string[]): Promise<IcebergTableOpResult[]>;
141
+ export declare function dropIcebergTables(conn: IcebergConnection, tables?: readonly string[], options?: {
142
+ purge: boolean;
143
+ }): Promise<IcebergTableOpResult[]>;
142
144
  /** Tunable retry policy for {@link icebergAppendRetrying}. */
143
145
  export interface CommitRetryOptions {
144
146
  /** Total attempts, including the first. Default 6. */
package/dist/catalog.mjs CHANGED
@@ -190,7 +190,7 @@ async function ensureIcebergNamespace(conn) {
190
190
  async function listIcebergTables(conn) {
191
191
  return (await restCatalogListTables(conn.catalog, { namespace: conn.namespace })).map((t) => t.name).sort();
192
192
  }
193
- async function dropIcebergTables(conn, tables) {
193
+ async function dropIcebergTables(conn, tables, options = { purge: true }) {
194
194
  const targets = tables ?? (await restCatalogListTables(conn.catalog, { namespace: conn.namespace })).map((t) => t.name);
195
195
  const results = [];
196
196
  const { icebergDropTable } = await useIcebirdWriteModule();
@@ -198,7 +198,7 @@ async function dropIcebergTables(conn, tables) {
198
198
  catalog: conn.catalog,
199
199
  namespace: conn.namespace,
200
200
  table,
201
- purgeRequested: true
201
+ purgeRequested: options.purge
202
202
  }).then(() => results.push({
203
203
  table,
204
204
  ok: true
@@ -2,4 +2,5 @@ import { isCommitRateLimited, isCommitServerError, isCommitTransient } from "./c
2
2
  import { MergeIcebergManifestsOptions, MergeIcebergManifestsResult, mergeIcebergManifests } from "./manifest-merge.mjs";
3
3
  import { SweepListPage, SweepListedObject, SweepStorageClient, SweepUncommittedOrphansOptions, SweepUncommittedOrphansResult, sweepUncommittedOrphans } from "./orphan-sweep.mjs";
4
4
  import { ExpireIcebergSnapshotsOptions, ExpireIcebergSnapshotsResult, PlanIcebergSnapshotRetentionOptions, PlanIcebergSnapshotRetentionResult, expireIcebergSnapshots, planIcebergSnapshotRetention } from "./snapshot-retention.mjs";
5
- export { type ExpireIcebergSnapshotsOptions, type ExpireIcebergSnapshotsResult, type MergeIcebergManifestsOptions, type MergeIcebergManifestsResult, type PlanIcebergSnapshotRetentionOptions, type PlanIcebergSnapshotRetentionResult, type SweepListPage, type SweepListedObject, type SweepStorageClient, type SweepUncommittedOrphansOptions, type SweepUncommittedOrphansResult, expireIcebergSnapshots, isCommitRateLimited, isCommitServerError, isCommitTransient, mergeIcebergManifests, planIcebergSnapshotRetention, sweepUncommittedOrphans };
5
+ import { RestoreIcebergTableOptions, RestoreIcebergTableResult, restoreIcebergTable } from "./table-restore.mjs";
6
+ export { type ExpireIcebergSnapshotsOptions, type ExpireIcebergSnapshotsResult, type MergeIcebergManifestsOptions, type MergeIcebergManifestsResult, type PlanIcebergSnapshotRetentionOptions, type PlanIcebergSnapshotRetentionResult, type RestoreIcebergTableOptions, type RestoreIcebergTableResult, type SweepListPage, type SweepListedObject, type SweepStorageClient, type SweepUncommittedOrphansOptions, type SweepUncommittedOrphansResult, expireIcebergSnapshots, isCommitRateLimited, isCommitServerError, isCommitTransient, mergeIcebergManifests, planIcebergSnapshotRetention, restoreIcebergTable, sweepUncommittedOrphans };
@@ -2,4 +2,5 @@ import { isCommitRateLimited, isCommitServerError, isCommitTransient } from "./c
2
2
  import { mergeIcebergManifests } from "./manifest-merge.mjs";
3
3
  import { sweepUncommittedOrphans } from "./orphan-sweep.mjs";
4
4
  import { expireIcebergSnapshots, planIcebergSnapshotRetention } from "./snapshot-retention.mjs";
5
- export { expireIcebergSnapshots, isCommitRateLimited, isCommitServerError, isCommitTransient, mergeIcebergManifests, planIcebergSnapshotRetention, sweepUncommittedOrphans };
5
+ import { restoreIcebergTable } from "./table-restore.mjs";
6
+ export { expireIcebergSnapshots, isCommitRateLimited, isCommitServerError, isCommitTransient, mergeIcebergManifests, planIcebergSnapshotRetention, restoreIcebergTable, sweepUncommittedOrphans };
@@ -0,0 +1,33 @@
1
+ import { CatalogCache } from "./catalog-cache.mjs";
2
+ import { IcebergConnection } from "./catalog.mjs";
3
+ export interface RestoreIcebergTableOptions {
4
+ /** Immutable metadata object restored before its Catalog registration. */
5
+ metadataLocation: string;
6
+ metadataSha256: string;
7
+ tableUuid: string;
8
+ headSnapshotId: string | null;
9
+ /** Complete snapshot identity inventory from the saved metadata. */
10
+ snapshotIds: readonly string[];
11
+ /** The caller excludes writers and owns the target through settlement. */
12
+ confirmAuthority: () => Promise<boolean>;
13
+ /** Bounds stored and expanded metadata. Defaults to 16 MiB. The maximum is 64 MiB. */
14
+ maxMetadataBytes?: number;
15
+ cache?: CatalogCache;
16
+ }
17
+ export type RestoreIcebergTableResult = {
18
+ _tag: 'Restored';
19
+ tableUuid: string;
20
+ snapshotIds: string[];
21
+ } | {
22
+ _tag: 'Err';
23
+ reason: 'invalid-options' | 'target-exists' | 'backup-invalid' | 'authority-changed';
24
+ } | {
25
+ _tag: 'Unknown';
26
+ reason: 'request-failed';
27
+ cause: unknown;
28
+ } | {
29
+ _tag: 'Unknown';
30
+ reason: 'acknowledgement-invalid' | 'authority-changed';
31
+ };
32
+ /** Restore a verified metadata pointer into an empty target. Never replace an existing table. */
33
+ export declare function restoreIcebergTable(conn: IcebergConnection, table: string, options: RestoreIcebergTableOptions): Promise<RestoreIcebergTableResult>;
@@ -0,0 +1,140 @@
1
+ import { parseIcebergJson } from "./_virtual/node_modules/.pnpm/icebird@0.8.30_patch_hash=3323fb6fb33622328d6d10f450feec7039468e8cad2e8667b7d8e59d48ae701b/node_modules/icebird/src/json.mjs";
2
+ import { restCatalogLoadTable, restCatalogRegisterTable } from "./_virtual/node_modules/.pnpm/icebird@0.8.30_patch_hash=3323fb6fb33622328d6d10f450feec7039468e8cad2e8667b7d8e59d48ae701b/node_modules/icebird/src/catalog/rest.mjs";
3
+ import { invalidateSnapshotRef } from "./catalog.mjs";
4
+ function validOptions(table, options) {
5
+ const maxBytes = options.maxMetadataBytes ?? 16777216;
6
+ return table.length > 0 && table.length <= 255 && /^s3:\/\/[a-z0-9][a-z0-9.-]+\/[^\s?#]+$/.test(options.metadataLocation) && /^[a-f0-9]{64}$/.test(options.metadataSha256) && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(options.tableUuid) && Number.isSafeInteger(maxBytes) && maxBytes > 0 && maxBytes <= 67108864 && options.snapshotIds.length <= 1e5 && options.snapshotIds.every((id) => /^(?:0|[1-9]\d{0,18})$/.test(id)) && new Set(options.snapshotIds).size === options.snapshotIds.length && (options.headSnapshotId === null ? options.snapshotIds.length === 0 : options.snapshotIds.includes(options.headSnapshotId));
7
+ }
8
+ function matchesBackup(loaded, options) {
9
+ if (!loaded.metadata || typeof loaded.metadata !== "object" || Array.isArray(loaded.metadata)) return false;
10
+ const metadata = loaded.metadata;
11
+ const snapshots = metadata.snapshots ?? [];
12
+ if (!Array.isArray(snapshots) || snapshots.some((snapshot) => !snapshot || typeof snapshot !== "object")) return false;
13
+ const ids = snapshots.map((snapshot) => String(snapshot["snapshot-id"]));
14
+ const expected = new Set(options.snapshotIds);
15
+ const rawHead = metadata["current-snapshot-id"];
16
+ const head = rawHead == null || String(rawHead) === "-1" ? null : String(rawHead);
17
+ return loaded.metadataLocation === options.metadataLocation && metadata["table-uuid"] === options.tableUuid && head === options.headSnapshotId && ids.length === expected.size && new Set(ids).size === expected.size && ids.every((id) => expected.has(id));
18
+ }
19
+ async function expandMetadata(bytes, maxBytes) {
20
+ if (bytes[0] !== 31 || bytes[1] !== 139) return bytes;
21
+ const reader = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")).getReader();
22
+ try {
23
+ const chunks = [];
24
+ let total = 0;
25
+ for (;;) {
26
+ const chunk = await reader.read();
27
+ if (chunk.done) break;
28
+ total += chunk.value.byteLength;
29
+ if (total > maxBytes) {
30
+ await reader.cancel();
31
+ return null;
32
+ }
33
+ chunks.push(chunk.value);
34
+ }
35
+ const expanded = new Uint8Array(total);
36
+ let offset = 0;
37
+ for (const chunk of chunks) {
38
+ expanded.set(chunk, offset);
39
+ offset += chunk.byteLength;
40
+ }
41
+ return expanded;
42
+ } catch {
43
+ return null;
44
+ } finally {
45
+ reader.releaseLock();
46
+ }
47
+ }
48
+ async function restoreIcebergTable(conn, table, options) {
49
+ if (!validOptions(table, options)) return {
50
+ _tag: "Err",
51
+ reason: "invalid-options"
52
+ };
53
+ if (!await options.confirmAuthority()) return {
54
+ _tag: "Err",
55
+ reason: "authority-changed"
56
+ };
57
+ if (await restCatalogLoadTable(conn.catalog, {
58
+ namespace: conn.namespace,
59
+ table
60
+ }).then(() => true, (error) => {
61
+ if (error && typeof error === "object" && "status" in error && error.status === 404) return false;
62
+ throw error;
63
+ })) return {
64
+ _tag: "Err",
65
+ reason: "target-exists"
66
+ };
67
+ const reader = await conn.resolver.reader(options.metadataLocation);
68
+ const maxBytes = options.maxMetadataBytes ?? 16777216;
69
+ if (!Number.isSafeInteger(reader.byteLength) || reader.byteLength <= 0 || reader.byteLength > maxBytes) return {
70
+ _tag: "Err",
71
+ reason: "backup-invalid"
72
+ };
73
+ const bytes = new Uint8Array(await reader.slice(0, reader.byteLength));
74
+ if (bytes.byteLength !== reader.byteLength) return {
75
+ _tag: "Err",
76
+ reason: "backup-invalid"
77
+ };
78
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
79
+ if ([...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("") !== options.metadataSha256) return {
80
+ _tag: "Err",
81
+ reason: "backup-invalid"
82
+ };
83
+ const expanded = await expandMetadata(bytes, maxBytes);
84
+ if (!expanded) return {
85
+ _tag: "Err",
86
+ reason: "backup-invalid"
87
+ };
88
+ let metadata;
89
+ try {
90
+ metadata = parseIcebergJson(new TextDecoder("utf-8", { fatal: true }).decode(expanded));
91
+ } catch {
92
+ return {
93
+ _tag: "Err",
94
+ reason: "backup-invalid"
95
+ };
96
+ }
97
+ if (!matchesBackup({
98
+ metadataLocation: options.metadataLocation,
99
+ metadata
100
+ }, options)) return {
101
+ _tag: "Err",
102
+ reason: "backup-invalid"
103
+ };
104
+ if (!await options.confirmAuthority()) return {
105
+ _tag: "Err",
106
+ reason: "authority-changed"
107
+ };
108
+ const registered = await restCatalogRegisterTable(conn.catalog, {
109
+ namespace: conn.namespace,
110
+ table,
111
+ metadataLocation: options.metadataLocation,
112
+ overwrite: false
113
+ }).then((value) => ({
114
+ _tag: "Acknowledged",
115
+ value
116
+ }), (cause) => ({
117
+ _tag: "Unknown",
118
+ reason: "request-failed",
119
+ cause
120
+ }));
121
+ if (registered._tag === "Unknown") return registered;
122
+ if (!matchesBackup({
123
+ metadataLocation: registered.value.metadataLocation,
124
+ metadata: registered.value.metadata
125
+ }, options)) return {
126
+ _tag: "Unknown",
127
+ reason: "acknowledgement-invalid"
128
+ };
129
+ if (!await options.confirmAuthority()) return {
130
+ _tag: "Unknown",
131
+ reason: "authority-changed"
132
+ };
133
+ if (options.cache) await invalidateSnapshotRef(options.cache, conn.namespace, table, conn.cacheScope);
134
+ return {
135
+ _tag: "Restored",
136
+ tableUuid: options.tableUuid,
137
+ snapshotIds: [...options.snapshotIds]
138
+ };
139
+ }
140
+ export { restoreIcebergTable };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/lakehouse",
3
3
  "type": "module",
4
- "version": "7.10.0",
4
+ "version": "7.11.1",
5
5
  "description": "Dataset-agnostic Iceberg lakehouse layer + dataset registry for R2 Data Catalog producers (ADR-0021).",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",