@gscdump/lakehouse 0.1.0 → 0.35.2

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.
@@ -5,37 +5,6 @@ Apache-2.0, BlueOak-1.0.0, MIT
5
5
 
6
6
  # Bundled Dependencies
7
7
 
8
- ## @netlify/blobs
9
-
10
- License: MIT
11
- By: Netlify Inc.
12
- Repository: https://github.com/netlify/primitives
13
-
14
- > Copyright (c) 2022 Netlify <team@netlify.com>
15
- >
16
- > MIT License
17
- >
18
- > Permission is hereby granted, free of charge, to any person obtaining
19
- > a copy of this software and associated documentation files (the
20
- > "Software"), to deal in the Software without restriction, including
21
- > without limitation the rights to use, copy, modify, merge, publish,
22
- > distribute, sublicense, and/or sell copies of the Software, and to
23
- > permit persons to whom the Software is furnished to do so, subject to
24
- > the following conditions:
25
- >
26
- > The above copyright notice and this permission notice shall be
27
- > included in all copies or substantial portions of the Software.
28
- >
29
- > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
30
- > EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
31
- > MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
32
- > NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
33
- > LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
34
- > OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
35
- > WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
36
-
37
- ---------------------------------------
38
-
39
8
  ## chokidar
40
9
 
41
10
  License: MIT
@@ -63,6 +63,45 @@ interface ProvisionedCatalogRef {
63
63
  bucket: string;
64
64
  namespace: string;
65
65
  }
66
+ interface EnableCatalogMaintenanceOptions {
67
+ accountId: string;
68
+ /** R2-Data-Catalog-scoped token (credential-set / maintenance-configs). */
69
+ catalogToken: string;
70
+ bucket: string;
71
+ /** See {@link CloudflareProvisionCreds.compactionCredentialToken}. */
72
+ compactionCredentialToken?: string;
73
+ /** Injectable fetch — defaults to the global. */
74
+ fetch?: typeof fetch;
75
+ /** Injectable warn sink for best-effort maintenance failures. Defaults to `console.warn`. */
76
+ onWarn?: (message: string) => void;
77
+ }
78
+ /**
79
+ * Standalone credential-set + compaction/snapshot-expiry enable — the same two
80
+ * steps {@link selfProvisionCatalog} ends with, WITHOUT its bucket-create /
81
+ * CORS / catalog-enable side effects. For the "backfill maintenance onto an
82
+ * already-ready catalog" case (re-provision / admin-repair jobs), where
83
+ * re-running bucket-create or catalog-enable would be wasted CF calls, not
84
+ * just idempotent no-ops.
85
+ *
86
+ * R2-FIXES C3 note: this export postdates the version both nuxtseo's
87
+ * `provision-team-catalog.ts` and gscdump.com's `team-catalog-provisioner.ts`
88
+ * synced against (published 0.1.0 had no such entry point), so BOTH apps still
89
+ * hand-roll this exact sequence locally for their idempotent-backfill branch.
90
+ * Swap their local duplicates for this export on the next publish + bump.
91
+ */
92
+ declare function enableCatalogMaintenance(opts: EnableCatalogMaintenanceOptions): Promise<void>;
93
+ /**
94
+ * Generic `prefix + key` bucket-name normalization (R2-FIXES C3 TODO): lowercase,
95
+ * collapse non-`[a-z0-9-]` runs to a single hyphen, trim, clamp to the R2
96
+ * 63-char limit. Throws if the result isn't a valid bucket name.
97
+ *
98
+ * Neither app has switched over yet: nuxtseo's `teamCatalogBucketName` and
99
+ * gscdump.com's `teamCatalogBucket` predate this export AND encode slightly
100
+ * different schemes (gscdump.com additionally suffixes the partition-key
101
+ * encoding, e.g. `-int`) — see their own TODO(lakehouse@next) comments for the
102
+ * planned migration once a version carrying this export is published.
103
+ */
104
+ declare function deriveBucketName(prefix: string, key: string): string;
66
105
  interface SelfProvisionOptions {
67
106
  creds: CloudflareProvisionCreds;
68
107
  bucket: string;
@@ -121,4 +160,4 @@ declare function adoptCatalog(remote: RemoteCatalogRef | null, opts?: AdoptCatal
121
160
  * pure validation/passthrough, no CF calls, no persistence.
122
161
  */
123
162
  declare function suppliedCatalog(ref: ProvisionedCatalogRef): ProvisionedCatalogRef;
124
- export { AdoptCatalogOptions, AdoptCatalogResult, type AllocateCatalogSiteIdOptions, type AllocateCatalogSiteIdResult, CloudflareProvisionCreds, ProvisionedCatalogRef, RemoteCatalogRef, SelfProvisionOptions, adoptCatalog, allocateCatalogSiteId, selfProvisionCatalog, suppliedCatalog };
163
+ export { AdoptCatalogOptions, AdoptCatalogResult, type AllocateCatalogSiteIdOptions, type AllocateCatalogSiteIdResult, CloudflareProvisionCreds, EnableCatalogMaintenanceOptions, ProvisionedCatalogRef, RemoteCatalogRef, SelfProvisionOptions, adoptCatalog, allocateCatalogSiteId, deriveBucketName, enableCatalogMaintenance, selfProvisionCatalog, suppliedCatalog };
@@ -95,6 +95,18 @@ async function ensureCredential(fetchImpl, accountId, catalogToken, credentialTo
95
95
  onWarn(`[lakehouse-provisioning] credential-set failed for ${bucket} — compaction stays inert (credential absent): ${err instanceof Error ? err.message : String(err)}`);
96
96
  });
97
97
  }
98
+ async function enableCatalogMaintenance(opts) {
99
+ const fetchImpl = opts.fetch ?? fetch;
100
+ const onWarn = opts.onWarn ?? ((m) => console.warn(m));
101
+ if (opts.compactionCredentialToken) await ensureCredential(fetchImpl, opts.accountId, opts.catalogToken, opts.compactionCredentialToken, opts.bucket, onWarn);
102
+ await enableMaintenance(fetchImpl, opts.accountId, opts.catalogToken, opts.bucket, onWarn);
103
+ }
104
+ const MAX_BUCKET_NAME = 63;
105
+ function deriveBucketName(prefix, key) {
106
+ const bucket = `${prefix}${key}`.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, MAX_BUCKET_NAME).replace(/-+$/g, "");
107
+ if (!/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/.test(bucket)) throw new Error(`Cannot derive a valid R2 bucket name from prefix "${prefix}" + key "${key}" (got "${bucket}")`);
108
+ return bucket;
109
+ }
98
110
  async function selfProvisionCatalog(opts) {
99
111
  const { creds, bucket, namespace, corsOrigins } = opts;
100
112
  const fetchImpl = opts.fetch ?? fetch;
@@ -138,4 +150,4 @@ function suppliedCatalog(ref) {
138
150
  if (!ref.catalogUri || !ref.warehouse || !ref.bucket) throw new Error("suppliedCatalog: catalogUri, warehouse and bucket are all required");
139
151
  return ref;
140
152
  }
141
- export { adoptCatalog, allocateCatalogSiteId, selfProvisionCatalog, suppliedCatalog };
153
+ export { adoptCatalog, allocateCatalogSiteId, deriveBucketName, enableCatalogMaintenance, selfProvisionCatalog, suppliedCatalog };
@@ -1,3 +1,93 @@
1
- import { icebergAppendRetrying, isCommitRateLimited } from "./_chunks/catalog.mjs";
1
+ import { IcebergConnection, icebergAppendRetrying, isCommitRateLimited } from "./_chunks/catalog.mjs";
2
2
  import { icebergAppend, icebergCreateTable, icebergDropTable, icebergManifests, restCatalogConnect, restCatalogCreateNamespace, restCatalogListTables, restCatalogLoadTable, s3SignedResolver } from "./_chunks/libs/icebird.mjs";
3
- export { icebergAppend, icebergAppendRetrying, icebergCreateTable, icebergDropTable, icebergManifests, isCommitRateLimited, restCatalogConnect, restCatalogCreateNamespace, restCatalogListTables, restCatalogLoadTable, s3SignedResolver };
3
+ /** One listed object under a data prefix. */
4
+ interface SweepListedObject {
5
+ key: string;
6
+ uploaded: Date;
7
+ }
8
+ /** One page of a prefix listing. */
9
+ interface SweepListPage {
10
+ objects: readonly SweepListedObject[];
11
+ truncated: boolean;
12
+ cursor?: string;
13
+ }
14
+ /**
15
+ * Minimal storage client `sweepUncommittedOrphans` needs: list-by-prefix +
16
+ * batch delete against the warehouse bucket. Deliberately NOT an S3 SDK
17
+ * client shape — this interface is structurally satisfied by Cloudflare's
18
+ * native `R2Bucket` Workers binding (`env.R2_ANALYTICS.list({ prefix })` /
19
+ * `env.R2_ANALYTICS.delete(keys)`), so a caller running inside a Worker can
20
+ * pass the binding straight through with no adapter. Any other
21
+ * S3-compatible client can be wrapped to this shape.
22
+ */
23
+ interface SweepStorageClient {
24
+ list: (options: {
25
+ prefix: string;
26
+ cursor?: string;
27
+ }) => Promise<SweepListPage>;
28
+ delete: (keys: string[]) => Promise<void>;
29
+ }
30
+ interface SweepUncommittedOrphansOptions {
31
+ conn: IcebergConnection;
32
+ s3: SweepStorageClient;
33
+ /**
34
+ * Bare R2 bucket name backing `conn`'s warehouse. Every scanned table's
35
+ * `location` must resolve into this exact bucket; a table whose location
36
+ * points elsewhere is SKIPPED — never guessed at, never touched.
37
+ */
38
+ bucket: string;
39
+ /**
40
+ * Restrict the sweep to these tables; defaults to every table currently
41
+ * in `conn.namespace` (`conn` already carries the namespace scope, so
42
+ * there's no separate `namespace` field here).
43
+ */
44
+ tables?: readonly string[];
45
+ /** Only delete objects older than this many hours. Default 48. */
46
+ graceHours?: number;
47
+ /** Hard cap on deletions in one run. Default 500. */
48
+ maxDeletes?: number;
49
+ /** Compute the delete set but never call `s3.delete`. Default false. */
50
+ dryRun?: boolean;
51
+ /** Injectable clock (ms epoch). Defaults to `Date.now`. */
52
+ now?: () => number;
53
+ }
54
+ interface SweepUncommittedOrphansResult {
55
+ /** Tables whose retained snapshots were successfully walked. */
56
+ scannedTables: string[];
57
+ /** Tables skipped because their `location` didn't resolve into `bucket` — never scanned, never touched. */
58
+ skippedTables: string[];
59
+ /** Count of live (referenced-by-some-retained-snapshot) files found across `scannedTables`. */
60
+ liveFileCount: number;
61
+ /** Objects under a scanned table's data prefix with no snapshot reference anywhere, past `graceHours`. */
62
+ candidateCount: number;
63
+ /** Keys deleted (or, under `dryRun`, that WOULD be deleted). */
64
+ deleted: string[];
65
+ dryRun: boolean;
66
+ /** True when `candidateCount` exceeded `maxDeletes` — remaining orphans are left for the next run. */
67
+ cappedAtLimit: boolean;
68
+ /** Set when the sweep did nothing because the table scope listed zero tables (fail-closed — see class docs). */
69
+ reason?: 'zero-tables';
70
+ }
71
+ /**
72
+ * Sweep data files written under a table's `data/` prefix that were NEVER
73
+ * referenced by any snapshot the catalog still retains — a crashed/partial
74
+ * writer's leftovers. This is the one orphan class Cloudflare's own R2 Data
75
+ * Catalog snapshot-expiration GC does not cover.
76
+ *
77
+ * HARD SAFETY:
78
+ * - A listing failure (table load / manifest walk throwing) PROPAGATES.
79
+ * It is never caught and treated as "no live files" — that would make
80
+ * every real data file look orphaned. Callers must let this reject and
81
+ * must not proceed to delete on catch.
82
+ * - Zero tables in scope is treated as "couldn't determine what's live",
83
+ * not "nothing to protect" — the sweep no-ops with `reason: 'zero-tables'`
84
+ * rather than listing/deleting anything.
85
+ * - Deletions are capped at `maxDeletes` per run (default 500).
86
+ * - `dryRun` computes the exact candidate set without ever calling `s3.delete`.
87
+ * - Only ever lists/deletes keys under a scanned table's own
88
+ * `<table.location>/data/` prefix, and every candidate is re-checked to
89
+ * actually start with that prefix before being considered for deletion —
90
+ * never a bare bucket-root listing, never a key outside a data prefix.
91
+ */
92
+ declare function sweepUncommittedOrphans(opts: SweepUncommittedOrphansOptions): Promise<SweepUncommittedOrphansResult>;
93
+ export { type SweepListPage, type SweepListedObject, type SweepStorageClient, type SweepUncommittedOrphansOptions, type SweepUncommittedOrphansResult, icebergAppend, icebergAppendRetrying, icebergCreateTable, icebergDropTable, icebergManifests, isCommitRateLimited, restCatalogConnect, restCatalogCreateNamespace, restCatalogListTables, restCatalogLoadTable, s3SignedResolver, sweepUncommittedOrphans };
@@ -1,3 +1,112 @@
1
1
  import { icebergAppend, icebergCreateTable, icebergDropTable, icebergManifests, restCatalogConnect, restCatalogCreateNamespace, restCatalogListTables, restCatalogLoadTable, s3SignedResolver } from "./_chunks/libs/icebird.mjs";
2
2
  import { icebergAppendRetrying, isCommitRateLimited } from "./_chunks/catalog.mjs";
3
- export { icebergAppend, icebergAppendRetrying, icebergCreateTable, icebergDropTable, icebergManifests, isCommitRateLimited, restCatalogConnect, restCatalogCreateNamespace, restCatalogListTables, restCatalogLoadTable, s3SignedResolver };
3
+ const DEFAULT_GRACE_HOURS = 48;
4
+ const DEFAULT_MAX_DELETES = 500;
5
+ const HOUR_MS = 3600 * 1e3;
6
+ function splitBucketKey(filePath) {
7
+ if (!filePath.startsWith("s3://")) return null;
8
+ const rest = filePath.slice(5);
9
+ const slash = rest.indexOf("/");
10
+ if (slash < 0) return null;
11
+ return {
12
+ bucket: rest.slice(0, slash),
13
+ key: rest.slice(slash + 1)
14
+ };
15
+ }
16
+ async function scanTable(conn, table, bucket) {
17
+ const { metadata } = await restCatalogLoadTable(conn.catalog, {
18
+ namespace: conn.namespace,
19
+ table
20
+ });
21
+ const location = splitBucketKey(String(metadata.location ?? ""));
22
+ if (!location || location.bucket !== bucket) return {
23
+ dataPrefix: null,
24
+ liveKeys: /* @__PURE__ */ new Set()
25
+ };
26
+ const dataPrefix = `${location.key.replace(/\/+$/, "")}/data/`;
27
+ const liveKeys = /* @__PURE__ */ new Set();
28
+ const snapshots = metadata.snapshots ?? [];
29
+ for (const snapshot of snapshots) {
30
+ const manifests = await icebergManifests({
31
+ metadata,
32
+ resolver: conn.resolver,
33
+ snapshotId: snapshot["snapshot-id"]
34
+ });
35
+ for (const manifest of manifests) for (const entry of manifest.entries) {
36
+ if (entry.status === 2) continue;
37
+ const filePath = entry.data_file?.file_path;
38
+ if (!filePath) continue;
39
+ const parsed = splitBucketKey(filePath);
40
+ if (parsed && parsed.bucket === bucket) liveKeys.add(parsed.key);
41
+ }
42
+ }
43
+ return {
44
+ dataPrefix,
45
+ liveKeys
46
+ };
47
+ }
48
+ async function listAllUnderPrefix(s3, prefix) {
49
+ const out = [];
50
+ let cursor;
51
+ do {
52
+ const page = await s3.list({
53
+ prefix,
54
+ cursor
55
+ });
56
+ out.push(...page.objects);
57
+ cursor = page.truncated ? page.cursor : void 0;
58
+ } while (cursor);
59
+ return out;
60
+ }
61
+ async function sweepUncommittedOrphans(opts) {
62
+ const { conn, s3, bucket } = opts;
63
+ const graceHours = opts.graceHours ?? DEFAULT_GRACE_HOURS;
64
+ const maxDeletes = opts.maxDeletes ?? DEFAULT_MAX_DELETES;
65
+ const dryRun = opts.dryRun ?? false;
66
+ const graceCutoff = (opts.now ?? Date.now)() - graceHours * HOUR_MS;
67
+ const tables = opts.tables ?? (await restCatalogListTables(conn.catalog, { namespace: conn.namespace })).map((t) => t.name);
68
+ if (tables.length === 0) return {
69
+ scannedTables: [],
70
+ skippedTables: [],
71
+ liveFileCount: 0,
72
+ candidateCount: 0,
73
+ deleted: [],
74
+ dryRun,
75
+ cappedAtLimit: false,
76
+ reason: "zero-tables"
77
+ };
78
+ const scannedTables = [];
79
+ const skippedTables = [];
80
+ let liveFileCount = 0;
81
+ const candidates = [];
82
+ for (const table of tables) {
83
+ const { dataPrefix, liveKeys } = await scanTable(conn, table, bucket);
84
+ if (!dataPrefix) {
85
+ skippedTables.push(table);
86
+ continue;
87
+ }
88
+ scannedTables.push(table);
89
+ liveFileCount += liveKeys.size;
90
+ const listed = await listAllUnderPrefix(s3, dataPrefix);
91
+ for (const obj of listed) {
92
+ if (!obj.key.startsWith(dataPrefix)) continue;
93
+ if (liveKeys.has(obj.key)) continue;
94
+ if (obj.uploaded.getTime() > graceCutoff) continue;
95
+ candidates.push(obj);
96
+ }
97
+ }
98
+ const candidateCount = candidates.length;
99
+ const toDelete = candidates.slice(0, maxDeletes);
100
+ const cappedAtLimit = candidateCount > maxDeletes;
101
+ if (!dryRun && toDelete.length > 0) await s3.delete(toDelete.map((o) => o.key));
102
+ return {
103
+ scannedTables,
104
+ skippedTables,
105
+ liveFileCount,
106
+ candidateCount,
107
+ deleted: toDelete.map((o) => o.key),
108
+ dryRun,
109
+ cappedAtLimit
110
+ };
111
+ }
112
+ export { icebergAppend, icebergAppendRetrying, icebergCreateTable, icebergDropTable, icebergManifests, isCommitRateLimited, restCatalogConnect, restCatalogCreateNamespace, restCatalogListTables, restCatalogLoadTable, s3SignedResolver, sweepUncommittedOrphans };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/lakehouse",
3
3
  "type": "module",
4
- "version": "0.1.0",
4
+ "version": "0.35.2",
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",
@@ -1,6 +0,0 @@
1
- declare global {
2
- var netlifyBlobsContext: unknown;
3
- }
4
- /**
5
- * The context object that we expect in the environment.
6
- */