@sixb/ducklake 0.1.8 → 0.1.9
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 +59 -4
- package/dist/ducklake-storage.d.ts +1 -0
- package/dist/ducklake-storage.d.ts.map +1 -1
- package/dist/index.js +1817 -511
- package/dist/index.js.map +24 -14
- package/dist/internal/dataset-row-commit.d.ts +10 -0
- package/dist/internal/dataset-row-commit.d.ts.map +1 -1
- package/dist/internal/dataset-row-merge.d.ts.map +1 -1
- package/dist/internal/ducklake-dataset-catalog.d.ts +20 -3
- package/dist/internal/ducklake-dataset-catalog.d.ts.map +1 -1
- package/dist/internal/ducklake-dataset-table-ref.d.ts +13 -0
- package/dist/internal/ducklake-dataset-table-ref.d.ts.map +1 -1
- package/dist/internal/ducklake-maintenance.d.ts +21 -1
- package/dist/internal/ducklake-maintenance.d.ts.map +1 -1
- package/dist/internal/ducklake-snapshot-reader.d.ts +63 -16
- package/dist/internal/ducklake-snapshot-reader.d.ts.map +1 -1
- package/dist/internal/ducklake-sql-executor.d.ts +9 -1
- package/dist/internal/ducklake-sql-executor.d.ts.map +1 -1
- package/dist/internal/ducklake-version-transaction.d.ts +54 -0
- package/dist/internal/ducklake-version-transaction.d.ts.map +1 -0
- package/dist/internal/ducklake-write-coordinator.d.ts +30 -3
- package/dist/internal/ducklake-write-coordinator.d.ts.map +1 -1
- package/dist/internal/lineage-encoding.d.ts +8 -0
- package/dist/internal/lineage-encoding.d.ts.map +1 -0
- package/dist/internal/sql-lineage/analysis.d.ts +32 -0
- package/dist/internal/sql-lineage/analysis.d.ts.map +1 -0
- package/dist/internal/sql-lineage/expressions.d.ts +9 -0
- package/dist/internal/sql-lineage/expressions.d.ts.map +1 -0
- package/dist/internal/sql-lineage/plan-json.d.ts +34 -0
- package/dist/internal/sql-lineage/plan-json.d.ts.map +1 -0
- package/dist/internal/sql-lineage/plan-relation.d.ts +28 -0
- package/dist/internal/sql-lineage/plan-relation.d.ts.map +1 -0
- package/dist/internal/sql-lineage/plan-walker.d.ts +18 -0
- package/dist/internal/sql-lineage/plan-walker.d.ts.map +1 -0
- package/dist/internal/sql-lineage/rejection.d.ts +11 -0
- package/dist/internal/sql-lineage/rejection.d.ts.map +1 -0
- package/dist/internal/sql-lineage/statement-gate.d.ts +21 -0
- package/dist/internal/sql-lineage/statement-gate.d.ts.map +1 -0
- package/dist/internal/sql-lineage/table-functions.d.ts +6 -0
- package/dist/internal/sql-lineage/table-functions.d.ts.map +1 -0
- package/dist/internal/sql-transform-relations.d.ts +20 -5
- package/dist/internal/sql-transform-relations.d.ts.map +1 -1
- package/dist/internal/versions.d.ts +10 -1
- package/dist/internal/versions.d.ts.map +1 -1
- package/dist/types.d.ts +10 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/ducklake-storage.ts +9 -2
- package/src/internal/dataset-row-commit.ts +28 -2
- package/src/internal/dataset-row-merge.ts +4 -0
- package/src/internal/ducklake-dataset-catalog.ts +162 -49
- package/src/internal/ducklake-dataset-table-ref.ts +75 -17
- package/src/internal/ducklake-maintenance.ts +110 -48
- package/src/internal/ducklake-snapshot-reader.ts +352 -304
- package/src/internal/ducklake-sql-executor.ts +214 -14
- package/src/internal/ducklake-version-transaction.ts +224 -0
- package/src/internal/ducklake-write-coordinator.ts +226 -148
- package/src/internal/lineage-encoding.ts +136 -0
- package/src/internal/sql-lineage/analysis.ts +257 -0
- package/src/internal/sql-lineage/expressions.ts +208 -0
- package/src/internal/sql-lineage/plan-json.ts +130 -0
- package/src/internal/sql-lineage/plan-relation.ts +59 -0
- package/src/internal/sql-lineage/plan-walker.ts +727 -0
- package/src/internal/sql-lineage/rejection.ts +16 -0
- package/src/internal/sql-lineage/statement-gate.ts +164 -0
- package/src/internal/sql-lineage/table-functions.ts +11 -0
- package/src/internal/sql-transform-relations.ts +90 -40
- package/src/internal/versions.ts +37 -14
- package/src/types.ts +11 -1
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A transform whose lineage cannot be derived exactly. The message names the construct and becomes
|
|
3
|
+
* the `reason` of an unsupported analysis; it never escapes the analysis as an error.
|
|
4
|
+
*/
|
|
5
|
+
export class SqlLineageRejected extends Error {
|
|
6
|
+
override readonly name = "SqlLineageRejected"
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function reject(reason: string): never {
|
|
10
|
+
throw new SqlLineageRejected(reason)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** DuckDB serialized something the analysis does not recognize, most likely after an upgrade. */
|
|
14
|
+
export function rejectPlanFormat(detail: string): never {
|
|
15
|
+
reject(`unexpected DuckDB plan format: ${detail}`)
|
|
16
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { isJsonObject, type JsonObject, listField, objectField, stringField } from "./plan-json"
|
|
2
|
+
import { reject } from "./rejection"
|
|
3
|
+
import { PURE_TABLE_FUNCTIONS } from "./table-functions"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Checks the parse tree (`json_serialize_sql`) before DuckDB binds the query. Binding already runs
|
|
7
|
+
* table functions' bind code, and table macros such as `query()` only expand during binding, so
|
|
8
|
+
* they must be refused from the parse tree. PIVOT is refused here because its output columns depend
|
|
9
|
+
* on data the empty analysis tables do not have.
|
|
10
|
+
*/
|
|
11
|
+
export function assertSingleSelect(statements: readonly unknown[]): void {
|
|
12
|
+
const [statement] = statements
|
|
13
|
+
if (statements.length !== 1 || statement === undefined) {
|
|
14
|
+
reject("the transform must be exactly one SELECT statement")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
visit(statement, (node) => {
|
|
18
|
+
if (node.type === "PIVOT") {
|
|
19
|
+
const unpivot = listField(node, "unpivot_names").length > 0
|
|
20
|
+
reject(`${unpivot ? "UNPIVOT" : "PIVOT"} is not supported`)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const unread = readsBeyondSources(node)
|
|
24
|
+
if (unread) reject(unread)
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether the statement reads nothing but its sources, for a transform the analysis rejected: then
|
|
30
|
+
* every column it outputs depends on at most every column of its sources. PIVOT reads only its
|
|
31
|
+
* input.
|
|
32
|
+
*
|
|
33
|
+
* Data reaches a query through its table references. Each must be one of `sourceTables`, or a
|
|
34
|
+
* common table expression an enclosing query defines; a table function must compute from its
|
|
35
|
+
* arguments alone. A name a query does not define resolves to a table of the lake.
|
|
36
|
+
*
|
|
37
|
+
* A function must carry a name DuckDB itself defines, one of `builtinFunctions`: a macro the
|
|
38
|
+
* lake's setup defines can read any table, and its call shows none.
|
|
39
|
+
*/
|
|
40
|
+
export function readsOnlySources(
|
|
41
|
+
statements: readonly unknown[],
|
|
42
|
+
sourceTables: ReadonlySet<string>,
|
|
43
|
+
builtinFunctions: ReadonlySet<string>
|
|
44
|
+
): boolean {
|
|
45
|
+
const [statement] = statements
|
|
46
|
+
if (statements.length !== 1 || statement === undefined) return false
|
|
47
|
+
|
|
48
|
+
let only = true
|
|
49
|
+
visit(statement, (node, ctes) => {
|
|
50
|
+
if (readsBeyondSources(node)) only = false
|
|
51
|
+
else if (node.type === "BASE_TABLE" && !readsSourceOrCte(node, sourceTables, ctes)) only = false
|
|
52
|
+
else if (callsUnknownFunction(node, builtinFunctions)) only = false
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
return only
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** What makes a node read outside the transform's sources, if anything. */
|
|
59
|
+
function readsBeyondSources(node: JsonObject): string | undefined {
|
|
60
|
+
if (node.type === "SHOW_REF") {
|
|
61
|
+
return "DESCRIBE, SHOW and SUMMARIZE are not supported"
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (node.type === "TABLE_FUNCTION") {
|
|
65
|
+
const name = stringField(objectField(node, "function"), "function_name")
|
|
66
|
+
if (!PURE_TABLE_FUNCTIONS.has(name)) return `table function '${name}' is not supported`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (node.class === "PARAMETER") {
|
|
70
|
+
return "query parameters are not supported"
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readsSourceOrCte(
|
|
77
|
+
table: JsonObject,
|
|
78
|
+
sourceTables: ReadonlySet<string>,
|
|
79
|
+
ctes: ReadonlySet<string>
|
|
80
|
+
): boolean {
|
|
81
|
+
if (qualified(table, "schema_name") || qualified(table, "catalog_name")) return false
|
|
82
|
+
|
|
83
|
+
const name = stringField(table, "table_name")
|
|
84
|
+
return sourceTables.has(name) || ctes.has(name.toLowerCase())
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* DuckDB writes syntax such as `EXTRACT`, `[1, 2]` or `{a: 1}` as calls to functions of schema
|
|
89
|
+
* `main`, so only another schema or a catalog qualifies a call.
|
|
90
|
+
*/
|
|
91
|
+
function callsUnknownFunction(node: JsonObject, builtinFunctions: ReadonlySet<string>): boolean {
|
|
92
|
+
if (node.class !== "FUNCTION" && node.class !== "WINDOW") return false
|
|
93
|
+
if (qualified(node, "catalog") || (qualified(node, "schema") && node.schema !== "main")) {
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const name = node.function_name
|
|
98
|
+
return typeof name !== "string" || !builtinFunctions.has(name.toLowerCase())
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function qualified(node: JsonObject, key: string): boolean {
|
|
102
|
+
const value = node[key]
|
|
103
|
+
return typeof value === "string" && value !== ""
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Visit every node of a parse tree, with the names of the common table expressions in scope, which
|
|
108
|
+
* DuckDB matches without regard to case.
|
|
109
|
+
*
|
|
110
|
+
* A query's body sees all of its expressions. An expression's own query sees only those defined
|
|
111
|
+
* before it, and a recursive one sees itself in its recursive part alone: any other name resolves
|
|
112
|
+
* to a table of the lake, so `WITH raw AS (SELECT * FROM raw)` reads the lake's `raw`.
|
|
113
|
+
*/
|
|
114
|
+
function visit(
|
|
115
|
+
value: unknown,
|
|
116
|
+
check: (node: JsonObject, ctes: ReadonlySet<string>) => void,
|
|
117
|
+
ctes: ReadonlySet<string> = new Set()
|
|
118
|
+
): void {
|
|
119
|
+
if (Array.isArray(value)) {
|
|
120
|
+
for (const item of value) visit(item, check, ctes)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!isJsonObject(value)) return
|
|
125
|
+
|
|
126
|
+
const entries = cteEntries(value)
|
|
127
|
+
const defined = new Set(ctes)
|
|
128
|
+
for (const { name, query } of entries) {
|
|
129
|
+
visit(query, check, new Set(defined))
|
|
130
|
+
defined.add(name)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
check(value, defined)
|
|
134
|
+
|
|
135
|
+
const recursive =
|
|
136
|
+
value.type === "RECURSIVE_CTE_NODE" && typeof value.cte_name === "string"
|
|
137
|
+
? new Set([...defined, value.cte_name.toLowerCase()])
|
|
138
|
+
: undefined
|
|
139
|
+
|
|
140
|
+
for (const [key, child] of Object.entries(value)) {
|
|
141
|
+
if (key === "cte_map" && entries.length > 0) continue
|
|
142
|
+
visit(child, check, key === "right" && recursive ? recursive : defined)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A query's common table expressions, in the order it defines them. A map in a shape this does not
|
|
148
|
+
* know gives none: its nodes are then visited as any others, with no name in scope.
|
|
149
|
+
*/
|
|
150
|
+
function cteEntries(
|
|
151
|
+
node: JsonObject
|
|
152
|
+
): readonly { readonly name: string; readonly query: unknown }[] {
|
|
153
|
+
const map = node.cte_map
|
|
154
|
+
if (!isJsonObject(map) || !Array.isArray(map.map)) return []
|
|
155
|
+
|
|
156
|
+
const entries: { name: string; query: unknown }[] = []
|
|
157
|
+
for (const entry of map.map) {
|
|
158
|
+
if (!isJsonObject(entry) || typeof entry.key !== "string" || !isJsonObject(entry.value)) {
|
|
159
|
+
return []
|
|
160
|
+
}
|
|
161
|
+
entries.push({ name: entry.key.toLowerCase(), query: entry.value.query })
|
|
162
|
+
}
|
|
163
|
+
return entries
|
|
164
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table functions that compute only from their arguments. Every other table function reads outside
|
|
3
|
+
* the transform's sources (files, catalogs, settings) or runs SQL of its own, like `query()`.
|
|
4
|
+
*/
|
|
5
|
+
export const PURE_TABLE_FUNCTIONS: ReadonlySet<string> = new Set([
|
|
6
|
+
"range",
|
|
7
|
+
"generate_series",
|
|
8
|
+
"unnest",
|
|
9
|
+
"json_each",
|
|
10
|
+
"json_tree",
|
|
11
|
+
])
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto"
|
|
2
|
+
import type { DatasetColumnDefinition, DatasetDefinition } from "@sixb/core"
|
|
2
3
|
import {
|
|
3
4
|
LakeStorageError,
|
|
4
5
|
type SqlTransformBody,
|
|
@@ -6,7 +7,7 @@ import {
|
|
|
6
7
|
} from "@sixb/core/lake-storage"
|
|
7
8
|
import type { DuckLakeStorageOptions } from "../types"
|
|
8
9
|
import { encodeDatasetTableName } from "./names"
|
|
9
|
-
import { qualifiedTableName } from "./sql"
|
|
10
|
+
import { qualifiedTableName, quoteIdentifier } from "./sql"
|
|
10
11
|
import { parseVersionId } from "./versions"
|
|
11
12
|
|
|
12
13
|
const RELATION_PLACEHOLDER_PREFIX = "__sixb_sql_transform_relation_"
|
|
@@ -15,17 +16,17 @@ const RELATION_PLACEHOLDER_PATTERN = /__sixb_sql_transform_relation_[0-9a-f]{32}
|
|
|
15
16
|
export interface DuckLakeSqlTransformSourceRelation {
|
|
16
17
|
readonly datasetId: string
|
|
17
18
|
readonly versionId: string
|
|
19
|
+
/** The only columns the transform may read, in order. Every column when omitted. */
|
|
20
|
+
readonly columns?: readonly string[]
|
|
18
21
|
}
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
readonly relations: Readonly<Record<string, SqlTransformRelation>>
|
|
28
|
-
render(sql: string): string
|
|
23
|
+
/**
|
|
24
|
+
* The SQL a transform body returned, with a placeholder where each source relation belongs. The
|
|
25
|
+
* body runs once; the same SQL then renders against the lake and against the lineage analysis.
|
|
26
|
+
*/
|
|
27
|
+
export interface DuckLakeSqlTransformTemplate {
|
|
28
|
+
/** Replaces each placeholder with its source's relation SQL, keyed by source name. */
|
|
29
|
+
render(relations: ReadonlyMap<string, string>): string
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
class DuckLakeSqlTransformRelation implements SqlTransformRelation {
|
|
@@ -36,42 +37,35 @@ class DuckLakeSqlTransformRelation implements SqlTransformRelation {
|
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
export function
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (typeof sql !== "string") {
|
|
44
|
-
throw new LakeStorageError("[SixbDuckLake] SQL transform body must return a SQL string.")
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return context.render(sql)
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function createDuckLakeSqlTransformRelationContext(
|
|
51
|
-
input: Omit<RenderDuckLakeSqlTransformSqlInput, "sql">
|
|
52
|
-
): DuckLakeSqlTransformRelationContext {
|
|
40
|
+
export function createDuckLakeSqlTransformTemplate(
|
|
41
|
+
sourceNames: readonly string[],
|
|
42
|
+
body: SqlTransformBody<"duckdb">
|
|
43
|
+
): DuckLakeSqlTransformTemplate {
|
|
53
44
|
const token = randomUUID().replaceAll("-", "")
|
|
54
45
|
const relations: Record<string, SqlTransformRelation> = Object.create(null)
|
|
55
|
-
const
|
|
46
|
+
const sourceNamesByPlaceholder = new Map<string, string>()
|
|
56
47
|
|
|
57
|
-
|
|
58
|
-
for (const [sourceName, source] of Object.entries(input.sources)) {
|
|
48
|
+
sourceNames.forEach((sourceName, index) => {
|
|
59
49
|
const placeholder = `${RELATION_PLACEHOLDER_PREFIX}${token}_${index}__`
|
|
60
50
|
relations[sourceName] = Object.freeze(new DuckLakeSqlTransformRelation(placeholder))
|
|
61
|
-
|
|
62
|
-
|
|
51
|
+
sourceNamesByPlaceholder.set(placeholder, sourceName)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const sql = body(Object.freeze(relations))
|
|
55
|
+
if (typeof sql !== "string") {
|
|
56
|
+
throw new LakeStorageError("[SixbDuckLake] SQL transform body must return a SQL string.")
|
|
63
57
|
}
|
|
64
58
|
|
|
65
59
|
return {
|
|
66
|
-
|
|
67
|
-
render(sql) {
|
|
60
|
+
render(relationSql) {
|
|
68
61
|
const rendered = sql.replace(RELATION_PLACEHOLDER_PATTERN, (placeholder) => {
|
|
69
|
-
const
|
|
70
|
-
|
|
62
|
+
const sourceName = sourceNamesByPlaceholder.get(placeholder)
|
|
63
|
+
const relation = sourceName === undefined ? undefined : relationSql.get(sourceName)
|
|
64
|
+
if (relation === undefined) {
|
|
71
65
|
throwUnresolvedPlaceholder()
|
|
72
66
|
}
|
|
73
67
|
|
|
74
|
-
return
|
|
68
|
+
return relation
|
|
75
69
|
})
|
|
76
70
|
|
|
77
71
|
if (rendered.includes(RELATION_PLACEHOLDER_PREFIX)) {
|
|
@@ -83,13 +77,69 @@ function createDuckLakeSqlTransformRelationContext(
|
|
|
83
77
|
}
|
|
84
78
|
}
|
|
85
79
|
|
|
86
|
-
|
|
80
|
+
/** Each source as DuckLake time travel to its resolved version. */
|
|
81
|
+
export function renderDuckLakeSourceRelations(
|
|
87
82
|
options: Pick<DuckLakeStorageOptions, "alias">,
|
|
88
|
-
|
|
89
|
-
): string {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
83
|
+
sources: Readonly<Record<string, DuckLakeSqlTransformSourceRelation>>
|
|
84
|
+
): ReadonlyMap<string, string> {
|
|
85
|
+
return new Map(
|
|
86
|
+
Object.entries(sources).map(([sourceName, source]) => {
|
|
87
|
+
const snapshotId = parseVersionId(source.versionId)
|
|
88
|
+
const table = qualifiedTableName(options, encodeDatasetTableName(source.datasetId))
|
|
89
|
+
return [
|
|
90
|
+
sourceName,
|
|
91
|
+
renderSourceRelation(`${table} AT (VERSION => ${snapshotId})`, source.columns),
|
|
92
|
+
]
|
|
93
|
+
})
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The shape of every source relation. A pick lists its columns, so the transform cannot read any
|
|
99
|
+
* other column of the source.
|
|
100
|
+
*/
|
|
101
|
+
export function renderSourceRelation(tableSql: string, columns?: readonly string[]): string {
|
|
102
|
+
const columnsSql =
|
|
103
|
+
columns === undefined ? "*" : columns.map((column) => quoteIdentifier(column)).join(", ")
|
|
104
|
+
return `(SELECT ${columnsSql} FROM ${tableSql})`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The columns a source exposes to a transform: its pick, in order, or every column. */
|
|
108
|
+
export function pickSourceColumns(
|
|
109
|
+
sourceName: string,
|
|
110
|
+
dataset: DatasetDefinition,
|
|
111
|
+
columns?: readonly string[]
|
|
112
|
+
): readonly DatasetColumnDefinition[] {
|
|
113
|
+
if (columns === undefined) {
|
|
114
|
+
return dataset.schema.columns
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (columns.length === 0) {
|
|
118
|
+
throw new LakeStorageError(
|
|
119
|
+
`[SixbDuckLake] SQL transform source '${sourceName}' must pick at least one column.`
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const columnsByName = new Map(dataset.schema.columns.map((column) => [column.name, column]))
|
|
124
|
+
const picked = new Set<string>()
|
|
125
|
+
|
|
126
|
+
return columns.map((columnName) => {
|
|
127
|
+
const column = columnsByName.get(columnName)
|
|
128
|
+
if (column === undefined) {
|
|
129
|
+
throw new LakeStorageError(
|
|
130
|
+
`[SixbDuckLake] SQL transform source '${sourceName}' picks column '${columnName}', which dataset '${dataset.id}' does not have.`
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (picked.has(columnName)) {
|
|
135
|
+
throw new LakeStorageError(
|
|
136
|
+
`[SixbDuckLake] SQL transform source '${sourceName}' picks column '${columnName}' more than once.`
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
picked.add(columnName)
|
|
141
|
+
return column
|
|
142
|
+
})
|
|
93
143
|
}
|
|
94
144
|
|
|
95
145
|
function throwUnresolvedPlaceholder(): never {
|
package/src/internal/versions.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DatasetVersion } from "@sixb/core/lake-storage"
|
|
1
|
+
import type { DatasetProducer, DatasetVersion } from "@sixb/core/lake-storage"
|
|
2
2
|
import { LakeStorageError } from "@sixb/core/lake-storage"
|
|
3
3
|
|
|
4
4
|
export interface SixbSchemaChangeMetadata {
|
|
@@ -15,6 +15,8 @@ export interface SixbCommitMetadata {
|
|
|
15
15
|
readonly rowCount?: number
|
|
16
16
|
readonly validatedPrimaryKeyColumns?: readonly string[]
|
|
17
17
|
readonly schemaChange?: SixbSchemaChangeMetadata
|
|
18
|
+
/** As `encodeLineage` stored it: decoded only for the versions a read returns. */
|
|
19
|
+
readonly lineage?: unknown
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export function parseVersionId(versionId: string): string {
|
|
@@ -57,6 +59,7 @@ export function parseCommitMetadata(value: unknown): SixbCommitMetadata | undefi
|
|
|
57
59
|
|
|
58
60
|
const mode = parsed.sixb.mode
|
|
59
61
|
const commitId = parsed.sixb.commitId
|
|
62
|
+
const producer = parseDatasetProducer(parsed.sixb.producer)
|
|
60
63
|
const rowCount = parsed.sixb.rowCount
|
|
61
64
|
const validatedPrimaryKeyColumns = parsed.sixb.validatedPrimaryKeyColumns
|
|
62
65
|
|
|
@@ -67,7 +70,7 @@ export function parseCommitMetadata(value: unknown): SixbCommitMetadata | undefi
|
|
|
67
70
|
...(mode === "snapshot" || mode === "append" || mode === "merge" || mode === "schema"
|
|
68
71
|
? { mode }
|
|
69
72
|
: {}),
|
|
70
|
-
...(
|
|
73
|
+
...(producer === undefined ? {} : { producer }),
|
|
71
74
|
...(isDatasetVersionRefs(parsed.sixb.inputs) ? { inputs: parsed.sixb.inputs } : {}),
|
|
72
75
|
...(typeof commitId === "string" && isRowCount(rowCount) ? { rowCount } : {}),
|
|
73
76
|
...(typeof commitId === "string" && isValidatedPrimaryKeyColumns(validatedPrimaryKeyColumns)
|
|
@@ -76,10 +79,22 @@ export function parseCommitMetadata(value: unknown): SixbCommitMetadata | undefi
|
|
|
76
79
|
...(isSchemaChangeMetadata(parsed.sixb.schemaChange)
|
|
77
80
|
? { schemaChange: parsed.sixb.schemaChange }
|
|
78
81
|
: {}),
|
|
82
|
+
...(parsed.sixb.lineage === undefined ? {} : { lineage: parsed.sixb.lineage }),
|
|
79
83
|
}
|
|
80
84
|
}
|
|
81
85
|
|
|
82
|
-
|
|
86
|
+
/** Whether a DuckLake snapshot's `changes_made` names one of the tables, for any change. */
|
|
87
|
+
export function changesTouchTables(changesMade: string, tableIds: readonly bigint[]): boolean {
|
|
88
|
+
const ids = new Set(tableIds.map((tableId) => tableId.toString()))
|
|
89
|
+
return changesMade.split(",").some((change) => ids.has(change.split(":")[1] ?? ""))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Whether a DuckLake snapshot changed a table's rows, and whether it removed any. DuckLake records
|
|
94
|
+
* every row change in `changes_made`, for inlined rows (`inlined_insert:<id>`) as for files
|
|
95
|
+
* (`inserted_into_table:<id>`, `deleted_from_table:<id>`); altering a table changes none.
|
|
96
|
+
*/
|
|
97
|
+
export function parseTableDataChange(
|
|
83
98
|
changesMade: string,
|
|
84
99
|
tableId: bigint
|
|
85
100
|
): { readonly hasDataChange: boolean; readonly hasDeleteChange: boolean } {
|
|
@@ -110,13 +125,25 @@ function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
|
110
125
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
111
126
|
}
|
|
112
127
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Malformed fields are dropped rather than failing the read, and never the producer's kind: a
|
|
130
|
+
* pipeline version must keep reading as one, since a pipeline version without lineage is
|
|
131
|
+
* classified with every marking.
|
|
132
|
+
*/
|
|
133
|
+
function parseDatasetProducer(value: unknown): DatasetProducer | undefined {
|
|
134
|
+
if (
|
|
135
|
+
!isRecord(value) ||
|
|
136
|
+
(value.kind !== "sync" && value.kind !== "pipeline" && value.kind !== "ingest")
|
|
137
|
+
) {
|
|
138
|
+
return undefined
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
kind: value.kind,
|
|
143
|
+
...(typeof value.id === "string" ? { id: value.id } : {}),
|
|
144
|
+
...(typeof value.runId === "string" ? { runId: value.runId } : {}),
|
|
145
|
+
...(typeof value.stepId === "string" ? { stepId: value.stepId } : {}),
|
|
146
|
+
}
|
|
120
147
|
}
|
|
121
148
|
|
|
122
149
|
function isDatasetVersionRefs(value: unknown): value is NonNullable<DatasetVersion["inputs"]> {
|
|
@@ -138,10 +165,6 @@ function isSchemaChangeMetadata(value: unknown): value is SixbSchemaChangeMetada
|
|
|
138
165
|
)
|
|
139
166
|
}
|
|
140
167
|
|
|
141
|
-
function optionalString(value: unknown): boolean {
|
|
142
|
-
return value === undefined || typeof value === "string"
|
|
143
|
-
}
|
|
144
|
-
|
|
145
168
|
function isRowCount(value: unknown): value is number {
|
|
146
169
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
|
|
147
170
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { DatasetVersionRef } from "@sixb/core/lake-storage"
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* DuckLake catalog connection settings.
|
|
3
5
|
*
|
|
@@ -119,19 +121,27 @@ export type DuckDbSecretOptions =
|
|
|
119
121
|
| AzureSecretOptions
|
|
120
122
|
|
|
121
123
|
export interface DuckLakeMaintenanceOptions {
|
|
122
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* How long to keep snapshots before they can expire. Default: "7 days". Each dataset's latest
|
|
126
|
+
* version and the `protect` versions are kept whatever their age.
|
|
127
|
+
*/
|
|
123
128
|
readonly expireOlderThan?: string
|
|
124
129
|
/** How long to keep deleted/orphaned files before reclaim. Default: expireOlderThan. */
|
|
125
130
|
readonly deleteOlderThan?: string
|
|
126
131
|
/** Report what would be expired/deleted without changing the lake. Default: false. */
|
|
127
132
|
readonly dryRun?: boolean
|
|
133
|
+
/** Versions to keep whatever their age, such as those projections applied. */
|
|
134
|
+
readonly protect?: readonly DatasetVersionRef[]
|
|
128
135
|
}
|
|
129
136
|
|
|
130
137
|
export interface DuckLakeMaintenanceReport {
|
|
131
138
|
readonly dryRun: boolean
|
|
132
139
|
readonly expireOlderThan: string
|
|
133
140
|
readonly deleteOlderThan: string
|
|
141
|
+
/** Snapshots expired, or that would expire on a dry run. */
|
|
134
142
|
readonly snapshots: number
|
|
143
|
+
/** Snapshots older than `expireOlderThan` kept because a version they hold is still needed. */
|
|
144
|
+
readonly keptSnapshots: number
|
|
135
145
|
readonly oldFiles: number
|
|
136
146
|
readonly orphanedFiles: number
|
|
137
147
|
}
|