@sixb/ducklake 0.1.8 → 0.2.0

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.
Files changed (69) hide show
  1. package/README.md +59 -4
  2. package/dist/ducklake-storage.d.ts +1 -0
  3. package/dist/ducklake-storage.d.ts.map +1 -1
  4. package/dist/index.js +1817 -511
  5. package/dist/index.js.map +24 -14
  6. package/dist/internal/dataset-row-commit.d.ts +10 -0
  7. package/dist/internal/dataset-row-commit.d.ts.map +1 -1
  8. package/dist/internal/dataset-row-merge.d.ts.map +1 -1
  9. package/dist/internal/ducklake-dataset-catalog.d.ts +20 -3
  10. package/dist/internal/ducklake-dataset-catalog.d.ts.map +1 -1
  11. package/dist/internal/ducklake-dataset-table-ref.d.ts +13 -0
  12. package/dist/internal/ducklake-dataset-table-ref.d.ts.map +1 -1
  13. package/dist/internal/ducklake-maintenance.d.ts +21 -1
  14. package/dist/internal/ducklake-maintenance.d.ts.map +1 -1
  15. package/dist/internal/ducklake-snapshot-reader.d.ts +63 -16
  16. package/dist/internal/ducklake-snapshot-reader.d.ts.map +1 -1
  17. package/dist/internal/ducklake-sql-executor.d.ts +9 -1
  18. package/dist/internal/ducklake-sql-executor.d.ts.map +1 -1
  19. package/dist/internal/ducklake-version-transaction.d.ts +54 -0
  20. package/dist/internal/ducklake-version-transaction.d.ts.map +1 -0
  21. package/dist/internal/ducklake-write-coordinator.d.ts +30 -3
  22. package/dist/internal/ducklake-write-coordinator.d.ts.map +1 -1
  23. package/dist/internal/lineage-encoding.d.ts +8 -0
  24. package/dist/internal/lineage-encoding.d.ts.map +1 -0
  25. package/dist/internal/sql-lineage/analysis.d.ts +32 -0
  26. package/dist/internal/sql-lineage/analysis.d.ts.map +1 -0
  27. package/dist/internal/sql-lineage/expressions.d.ts +9 -0
  28. package/dist/internal/sql-lineage/expressions.d.ts.map +1 -0
  29. package/dist/internal/sql-lineage/plan-json.d.ts +34 -0
  30. package/dist/internal/sql-lineage/plan-json.d.ts.map +1 -0
  31. package/dist/internal/sql-lineage/plan-relation.d.ts +28 -0
  32. package/dist/internal/sql-lineage/plan-relation.d.ts.map +1 -0
  33. package/dist/internal/sql-lineage/plan-walker.d.ts +18 -0
  34. package/dist/internal/sql-lineage/plan-walker.d.ts.map +1 -0
  35. package/dist/internal/sql-lineage/rejection.d.ts +11 -0
  36. package/dist/internal/sql-lineage/rejection.d.ts.map +1 -0
  37. package/dist/internal/sql-lineage/statement-gate.d.ts +21 -0
  38. package/dist/internal/sql-lineage/statement-gate.d.ts.map +1 -0
  39. package/dist/internal/sql-lineage/table-functions.d.ts +6 -0
  40. package/dist/internal/sql-lineage/table-functions.d.ts.map +1 -0
  41. package/dist/internal/sql-transform-relations.d.ts +20 -5
  42. package/dist/internal/sql-transform-relations.d.ts.map +1 -1
  43. package/dist/internal/versions.d.ts +10 -1
  44. package/dist/internal/versions.d.ts.map +1 -1
  45. package/dist/types.d.ts +10 -1
  46. package/dist/types.d.ts.map +1 -1
  47. package/package.json +6 -6
  48. package/src/ducklake-storage.ts +9 -2
  49. package/src/internal/dataset-row-commit.ts +28 -2
  50. package/src/internal/dataset-row-merge.ts +4 -0
  51. package/src/internal/ducklake-dataset-catalog.ts +162 -49
  52. package/src/internal/ducklake-dataset-table-ref.ts +75 -17
  53. package/src/internal/ducklake-maintenance.ts +110 -48
  54. package/src/internal/ducklake-snapshot-reader.ts +352 -304
  55. package/src/internal/ducklake-sql-executor.ts +214 -14
  56. package/src/internal/ducklake-version-transaction.ts +224 -0
  57. package/src/internal/ducklake-write-coordinator.ts +226 -148
  58. package/src/internal/lineage-encoding.ts +136 -0
  59. package/src/internal/sql-lineage/analysis.ts +257 -0
  60. package/src/internal/sql-lineage/expressions.ts +208 -0
  61. package/src/internal/sql-lineage/plan-json.ts +130 -0
  62. package/src/internal/sql-lineage/plan-relation.ts +59 -0
  63. package/src/internal/sql-lineage/plan-walker.ts +727 -0
  64. package/src/internal/sql-lineage/rejection.ts +16 -0
  65. package/src/internal/sql-lineage/statement-gate.ts +164 -0
  66. package/src/internal/sql-lineage/table-functions.ts +11 -0
  67. package/src/internal/sql-transform-relations.ts +90 -40
  68. package/src/internal/versions.ts +37 -14
  69. 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
- export interface RenderDuckLakeSqlTransformSqlInput {
21
- readonly options: Pick<DuckLakeStorageOptions, "alias">
22
- readonly sources: Readonly<Record<string, DuckLakeSqlTransformSourceRelation>>
23
- readonly sql: SqlTransformBody<"duckdb">
24
- }
25
-
26
- interface DuckLakeSqlTransformRelationContext {
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 renderDuckLakeSqlTransformSql(input: RenderDuckLakeSqlTransformSqlInput): string {
40
- const context = createDuckLakeSqlTransformRelationContext(input)
41
- const sql = input.sql(context.relations)
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 replacements = new Map<string, string>()
46
+ const sourceNamesByPlaceholder = new Map<string, string>()
56
47
 
57
- let index = 0
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
- replacements.set(placeholder, renderDuckLakeSourceRelation(input.options, source))
62
- index += 1
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
- relations: Object.freeze(relations),
67
- render(sql) {
60
+ render(relationSql) {
68
61
  const rendered = sql.replace(RELATION_PLACEHOLDER_PATTERN, (placeholder) => {
69
- const replacement = replacements.get(placeholder)
70
- if (replacement === undefined) {
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 replacement
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
- function renderDuckLakeSourceRelation(
80
+ /** Each source as DuckLake time travel to its resolved version. */
81
+ export function renderDuckLakeSourceRelations(
87
82
  options: Pick<DuckLakeStorageOptions, "alias">,
88
- source: DuckLakeSqlTransformSourceRelation
89
- ): string {
90
- const snapshotId = parseVersionId(source.versionId)
91
- const tableName = encodeDatasetTableName(source.datasetId)
92
- return `(SELECT * FROM ${qualifiedTableName(options, tableName)} AT (VERSION => ${snapshotId}))`
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 {
@@ -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
- ...(isDatasetProducer(parsed.sixb.producer) ? { producer: parsed.sixb.producer } : {}),
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
- export function parseInlineDataChange(
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
- function isDatasetProducer(value: unknown): value is NonNullable<DatasetVersion["producer"]> {
114
- return (
115
- isRecord(value) &&
116
- (value.kind === "sync" || value.kind === "pipeline" || value.kind === "ingest") &&
117
- optionalString(value.id) &&
118
- optionalString(value.runId)
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
- /** How long to keep snapshots before they can expire. Default: "7 days". */
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
  }