@sixb/ducklake 0.0.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.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +490 -0
  3. package/dist/ducklake-storage.d.ts +42 -0
  4. package/dist/ducklake-storage.d.ts.map +1 -0
  5. package/dist/index.d.ts +3 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +3112 -0
  8. package/dist/index.js.map +34 -0
  9. package/dist/internal/catalog-key.d.ts +11 -0
  10. package/dist/internal/catalog-key.d.ts.map +1 -0
  11. package/dist/internal/dataset-row-commit.d.ts +26 -0
  12. package/dist/internal/dataset-row-commit.d.ts.map +1 -0
  13. package/dist/internal/duckdb-column-types.d.ts +45 -0
  14. package/dist/internal/duckdb-column-types.d.ts.map +1 -0
  15. package/dist/internal/duckdb-decimal.d.ts +6 -0
  16. package/dist/internal/duckdb-decimal.d.ts.map +1 -0
  17. package/dist/internal/duckdb-row.d.ts +8 -0
  18. package/dist/internal/duckdb-row.d.ts.map +1 -0
  19. package/dist/internal/duckdb-runtime.d.ts +63 -0
  20. package/dist/internal/duckdb-runtime.d.ts.map +1 -0
  21. package/dist/internal/duckdb-value-converter.d.ts +7 -0
  22. package/dist/internal/duckdb-value-converter.d.ts.map +1 -0
  23. package/dist/internal/ducklake-catalog-schema.d.ts +12 -0
  24. package/dist/internal/ducklake-catalog-schema.d.ts.map +1 -0
  25. package/dist/internal/ducklake-connection-manager.d.ts +67 -0
  26. package/dist/internal/ducklake-connection-manager.d.ts.map +1 -0
  27. package/dist/internal/ducklake-current-dataset-definitions.d.ts +10 -0
  28. package/dist/internal/ducklake-current-dataset-definitions.d.ts.map +1 -0
  29. package/dist/internal/ducklake-dataset-catalog.d.ts +33 -0
  30. package/dist/internal/ducklake-dataset-catalog.d.ts.map +1 -0
  31. package/dist/internal/ducklake-dataset-table-ref.d.ts +9 -0
  32. package/dist/internal/ducklake-dataset-table-ref.d.ts.map +1 -0
  33. package/dist/internal/ducklake-maintenance.d.ts +9 -0
  34. package/dist/internal/ducklake-maintenance.d.ts.map +1 -0
  35. package/dist/internal/ducklake-row-reader.d.ts +24 -0
  36. package/dist/internal/ducklake-row-reader.d.ts.map +1 -0
  37. package/dist/internal/ducklake-snapshot-reader.d.ts +73 -0
  38. package/dist/internal/ducklake-snapshot-reader.d.ts.map +1 -0
  39. package/dist/internal/ducklake-sql-executor.d.ts +33 -0
  40. package/dist/internal/ducklake-sql-executor.d.ts.map +1 -0
  41. package/dist/internal/ducklake-write-coordinator.d.ts +53 -0
  42. package/dist/internal/ducklake-write-coordinator.d.ts.map +1 -0
  43. package/dist/internal/ducklake-write-session.d.ts +17 -0
  44. package/dist/internal/ducklake-write-session.d.ts.map +1 -0
  45. package/dist/internal/names.d.ts +13 -0
  46. package/dist/internal/names.d.ts.map +1 -0
  47. package/dist/internal/row-appender.d.ts +7 -0
  48. package/dist/internal/row-appender.d.ts.map +1 -0
  49. package/dist/internal/schema.d.ts +35 -0
  50. package/dist/internal/schema.d.ts.map +1 -0
  51. package/dist/internal/sql-transform-relations.d.ts +13 -0
  52. package/dist/internal/sql-transform-relations.d.ts.map +1 -0
  53. package/dist/internal/sql.d.ts +62 -0
  54. package/dist/internal/sql.d.ts.map +1 -0
  55. package/dist/internal/versions.d.ts +22 -0
  56. package/dist/internal/versions.d.ts.map +1 -0
  57. package/dist/tsconfig.json +5 -0
  58. package/dist/types.d.ts +147 -0
  59. package/dist/types.d.ts.map +1 -0
  60. package/package.json +64 -0
  61. package/src/ducklake-storage.ts +130 -0
  62. package/src/index.ts +14 -0
  63. package/src/internal/catalog-key.ts +28 -0
  64. package/src/internal/dataset-row-commit.ts +128 -0
  65. package/src/internal/duckdb-column-types.ts +68 -0
  66. package/src/internal/duckdb-decimal.ts +36 -0
  67. package/src/internal/duckdb-row.ts +83 -0
  68. package/src/internal/duckdb-runtime.ts +347 -0
  69. package/src/internal/duckdb-value-converter.ts +27 -0
  70. package/src/internal/ducklake-catalog-schema.ts +116 -0
  71. package/src/internal/ducklake-connection-manager.ts +510 -0
  72. package/src/internal/ducklake-current-dataset-definitions.ts +228 -0
  73. package/src/internal/ducklake-dataset-catalog.ts +363 -0
  74. package/src/internal/ducklake-dataset-table-ref.ts +35 -0
  75. package/src/internal/ducklake-maintenance.ts +116 -0
  76. package/src/internal/ducklake-row-reader.ts +143 -0
  77. package/src/internal/ducklake-snapshot-reader.ts +826 -0
  78. package/src/internal/ducklake-sql-executor.ts +334 -0
  79. package/src/internal/ducklake-write-coordinator.ts +532 -0
  80. package/src/internal/ducklake-write-session.ts +170 -0
  81. package/src/internal/names.ts +111 -0
  82. package/src/internal/row-appender.ts +73 -0
  83. package/src/internal/schema.ts +149 -0
  84. package/src/internal/sql-transform-relations.ts +99 -0
  85. package/src/internal/sql.ts +440 -0
  86. package/src/internal/versions.ts +140 -0
  87. package/src/types.ts +159 -0
@@ -0,0 +1,440 @@
1
+ import { LakeStorageError } from "@sixb/core/lake-storage"
2
+ import type {
3
+ AzureSecretOptions,
4
+ DuckDbSecretOptions,
5
+ DuckLakeCatalogOptions,
6
+ DuckLakeStorageOptions,
7
+ GcsSecretOptions,
8
+ PostgresDuckLakeCatalogOptions,
9
+ R2SecretOptions,
10
+ S3SecretOptions,
11
+ } from "../types"
12
+
13
+ const DEFAULT_ALIAS = "sixb_lake"
14
+
15
+ /**
16
+ * Resolve the DuckLake attachment alias used by all provider SQL.
17
+ */
18
+ export function duckLakeAlias(options: Pick<DuckLakeStorageOptions, "alias">): string {
19
+ return options.alias ?? DEFAULT_ALIAS
20
+ }
21
+
22
+ /**
23
+ * DuckLake exposes catalog metadata through an attached DuckDB database whose
24
+ * name is derived from the user-facing DuckLake alias.
25
+ */
26
+ function duckLakeMetadataCatalog(options: Pick<DuckLakeStorageOptions, "alias">): string {
27
+ return `__ducklake_metadata_${duckLakeAlias(options)}`
28
+ }
29
+
30
+ /**
31
+ * Configure the PostgreSQL extension pool used by DuckLake's attached metadata
32
+ * catalog. DuckDB worker threads can otherwise pin pooled Postgres connections
33
+ * in the thread-local cache until the pool is exhausted during repeated
34
+ * DuckLake metadata reads.
35
+ */
36
+ export function buildConfigurePostgresMetadataPoolSql(
37
+ options: Pick<DuckLakeStorageOptions, "alias" | "postgresPool">
38
+ ): string {
39
+ const parameters = [
40
+ `catalog_name=${quoteSqlString(duckLakeMetadataCatalog(options))}`,
41
+ ...postgresPoolParameters(options.postgresPool).map(({ name, value }) => `${name}=${value}`),
42
+ ]
43
+
44
+ return `FROM postgres_configure_pool(${parameters.join(", ")})`
45
+ }
46
+
47
+ /**
48
+ * Configure DuckDB's PostgreSQL extension before ATTACH. These settings only
49
+ * affect databases attached after they are set.
50
+ */
51
+ export function buildSetPostgresPoolSql(
52
+ options: Pick<DuckLakeStorageOptions, "postgresPool">
53
+ ): readonly string[] {
54
+ return postgresPoolParameters(options.postgresPool).map(
55
+ ({ name, value }) => `SET pg_pool_${name} = ${value}`
56
+ )
57
+ }
58
+
59
+ /**
60
+ * Render a fully-qualified DuckLake metadata table path.
61
+ *
62
+ * PostgreSQL catalogs expose DuckLake metadata tables under the configured
63
+ * metadata schema inside the attached metadata catalog. Local catalogs use
64
+ * DuckDB's default `main` schema.
65
+ */
66
+ export function duckLakeMetadataTableName(
67
+ options: Pick<DuckLakeStorageOptions, "alias" | "catalog">,
68
+ tableName: string
69
+ ): string {
70
+ const schemaName =
71
+ options.catalog.type === "postgres" ? (options.catalog.metadataSchema ?? "main") : "main"
72
+
73
+ return `${quoteIdentifier(duckLakeMetadataCatalog(options))}.${quoteIdentifier(
74
+ schemaName
75
+ )}.${quoteIdentifier(tableName)}`
76
+ }
77
+
78
+ /**
79
+ * Quote a SQL identifier after validating it is already identifier-shaped.
80
+ *
81
+ * Dataset table names are produced by `names.ts`; user text should never be
82
+ * passed here without first going through a deterministic encoder.
83
+ */
84
+ export function quoteIdentifier(identifier: string): string {
85
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
86
+ throw new LakeStorageError(`[SixbDuckLake] Invalid SQL identifier '${identifier}'.`)
87
+ }
88
+
89
+ return `"${identifier}"`
90
+ }
91
+
92
+ /**
93
+ * Quote a SQL string literal for DDL statements that DuckDB does not parameterize.
94
+ */
95
+ export function quoteSqlString(value: string): string {
96
+ return `'${value.replaceAll("'", "''")}'`
97
+ }
98
+
99
+ export function quoteSqlStringList(values: readonly string[]): string {
100
+ return values.map((value) => quoteSqlString(value)).join(", ")
101
+ }
102
+
103
+ /**
104
+ * Render a fully-qualified table path.
105
+ */
106
+ export function qualifiedTableName(
107
+ options: Pick<DuckLakeStorageOptions, "alias">,
108
+ tableName: string
109
+ ): string {
110
+ return `${quoteIdentifier(duckLakeAlias(options))}.main.${quoteIdentifier(tableName)}`
111
+ }
112
+
113
+ /**
114
+ * Build the catalog URI expected by DuckLake's ATTACH statement.
115
+ */
116
+ export function catalogUri(catalog: DuckLakeCatalogOptions): string {
117
+ switch (catalog.type) {
118
+ case "duckdb":
119
+ return `ducklake:${catalog.path}`
120
+ case "sqlite":
121
+ return `ducklake:sqlite:${catalog.path}`
122
+ case "postgres":
123
+ return `ducklake:postgres:${postgresConnectionString(catalog)}`
124
+ case "custom":
125
+ return `ducklake:${catalog.uri}`
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Render typed PostgreSQL options to the libpq connection string DuckLake expects.
131
+ */
132
+ export function postgresConnectionString(catalog: PostgresDuckLakeCatalogOptions): string {
133
+ const parameters: string[] = []
134
+ const names = new Set<string>()
135
+
136
+ appendPostgresConnectionParameter(parameters, names, "dbname", catalog.database)
137
+ appendPostgresConnectionParameter(parameters, names, "host", catalog.host)
138
+ appendPostgresConnectionParameter(parameters, names, "port", catalog.port)
139
+ appendPostgresConnectionParameter(parameters, names, "user", catalog.user)
140
+ appendPostgresConnectionParameter(parameters, names, "password", catalog.password)
141
+ appendPostgresConnectionParameter(parameters, names, "sslmode", catalog.sslMode)
142
+ appendPostgresConnectionParameter(parameters, names, "application_name", catalog.applicationName)
143
+ appendPostgresConnectionParameter(
144
+ parameters,
145
+ names,
146
+ "connect_timeout",
147
+ catalog.connectTimeoutSeconds
148
+ )
149
+
150
+ for (const [name, value] of Object.entries(catalog.parameters ?? {})) {
151
+ appendPostgresConnectionParameter(parameters, names, name, value)
152
+ }
153
+
154
+ return parameters.join(" ")
155
+ }
156
+
157
+ /**
158
+ * Catalog extensions that must be available before ATTACH.
159
+ */
160
+ function catalogExtensions(catalog: DuckLakeCatalogOptions): readonly string[] {
161
+ switch (catalog.type) {
162
+ case "duckdb":
163
+ return []
164
+ case "sqlite":
165
+ return ["sqlite"]
166
+ case "postgres":
167
+ return ["postgres"]
168
+ case "custom":
169
+ return catalog.extensions ?? []
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Extensions needed by the catalog, data path, and typed secrets.
175
+ */
176
+ export function requiredExtensions(options: DuckLakeStorageOptions): readonly string[] {
177
+ const extensions = new Set<string>(catalogExtensions(options.catalog))
178
+
179
+ for (const extension of storageExtensions(options.dataPath)) {
180
+ extensions.add(extension)
181
+ }
182
+
183
+ for (const secret of options.secrets ?? []) {
184
+ for (const extension of secretExtensions(secret)) {
185
+ extensions.add(extension)
186
+ }
187
+ }
188
+
189
+ return [...extensions]
190
+ }
191
+
192
+ /**
193
+ * Build a typed DuckDB CREATE SECRET statement.
194
+ */
195
+ export function buildCreateSecretSql(secret: DuckDbSecretOptions): string {
196
+ const persistence = secret.persistent ? "PERSISTENT " : "TEMPORARY "
197
+ const nameSql = secret.name === undefined ? "" : `${quoteIdentifier(secret.name)} `
198
+ return `CREATE OR REPLACE ${persistence}SECRET ${nameSql}(${secretParameters(secret).join(", ")})`
199
+ }
200
+
201
+ /**
202
+ * Build the DuckLake ATTACH statement from public provider options.
203
+ */
204
+ export function buildAttachSql(options: DuckLakeStorageOptions): string {
205
+ const parameters: string[] = []
206
+
207
+ if (options.dataPath !== undefined) {
208
+ parameters.push(`DATA_PATH ${quoteSqlString(options.dataPath)}`)
209
+ }
210
+
211
+ parameters.push(`CREATE_IF_NOT_EXISTS ${options.createIfNotExists ?? true}`)
212
+
213
+ if (options.readOnly) {
214
+ parameters.push("READ_ONLY")
215
+ }
216
+
217
+ if (options.catalog.type === "postgres" && options.catalog.metadataSchema !== undefined) {
218
+ parameters.push(`METADATA_SCHEMA ${quoteSqlString(options.catalog.metadataSchema)}`)
219
+ }
220
+
221
+ if (
222
+ options.catalog.type === "custom" &&
223
+ options.catalog.metadataParameters !== undefined &&
224
+ Object.keys(options.catalog.metadataParameters).length > 0
225
+ ) {
226
+ const entries = Object.entries(options.catalog.metadataParameters).map(
227
+ ([key, value]) => `${quoteSqlString(key)}: ${quoteSqlString(value)}`
228
+ )
229
+ parameters.push(`METADATA_PARAMETERS MAP {${entries.join(", ")}}`)
230
+ }
231
+
232
+ return `ATTACH ${quoteSqlString(catalogUri(options.catalog))} AS ${quoteIdentifier(
233
+ duckLakeAlias(options)
234
+ )} (${parameters.join(", ")})`
235
+ }
236
+
237
+ function storageExtensions(path: string | undefined): readonly string[] {
238
+ if (path === undefined) {
239
+ return []
240
+ }
241
+
242
+ switch (pathScheme(path)) {
243
+ case "s3":
244
+ case "r2":
245
+ case "gcs":
246
+ case "gs":
247
+ return ["httpfs"]
248
+ case "az":
249
+ case "azure":
250
+ case "abfss":
251
+ return ["azure"]
252
+ default:
253
+ return []
254
+ }
255
+ }
256
+
257
+ /**
258
+ * The single source of truth for PostgreSQL pool options. Both the pre-attach
259
+ * `SET pg_pool_*` form and the post-attach `postgres_configure_pool(...)` form
260
+ * render from this list so the two stay in sync. `name` is the unprefixed
261
+ * option key; `value` is already rendered (numbers/booleans bare, strings
262
+ * quoted as SQL literals).
263
+ */
264
+ function postgresPoolParameters(
265
+ pool: DuckLakeStorageOptions["postgresPool"]
266
+ ): readonly { readonly name: string; readonly value: string }[] {
267
+ const parameters: { name: string; value: string }[] = [
268
+ { name: "enable_thread_local_cache", value: String(pool?.enableThreadLocalCache ?? false) },
269
+ ]
270
+
271
+ appendPoolNumber(parameters, "max_connections", pool?.maxConnections)
272
+ appendPoolNumber(parameters, "wait_timeout_millis", pool?.waitTimeoutMillis)
273
+ appendPoolNumber(parameters, "max_lifetime_millis", pool?.maxLifetimeMillis)
274
+ appendPoolNumber(parameters, "idle_timeout_millis", pool?.idleTimeoutMillis)
275
+
276
+ if (pool?.enableReaperThread !== undefined) {
277
+ parameters.push({ name: "enable_reaper_thread", value: String(pool.enableReaperThread) })
278
+ }
279
+
280
+ if (pool?.healthCheckQuery !== undefined) {
281
+ parameters.push({ name: "health_check_query", value: quoteSqlString(pool.healthCheckQuery) })
282
+ }
283
+
284
+ return parameters
285
+ }
286
+
287
+ function appendPoolNumber(
288
+ parameters: { name: string; value: string }[],
289
+ name: string,
290
+ value: number | undefined
291
+ ): void {
292
+ if (value === undefined) {
293
+ return
294
+ }
295
+
296
+ parameters.push({ name, value: String(value) })
297
+ }
298
+
299
+ function secretExtensions(secret: DuckDbSecretOptions): readonly string[] {
300
+ switch (secret.type) {
301
+ case "s3":
302
+ case "r2":
303
+ case "gcs":
304
+ return ["httpfs"]
305
+ case "azure":
306
+ return ["azure"]
307
+ }
308
+ }
309
+
310
+ function secretParameters(secret: DuckDbSecretOptions): readonly string[] {
311
+ switch (secret.type) {
312
+ case "s3":
313
+ return withCommonSecretParameters(secret, [
314
+ "TYPE s3",
315
+ ...s3ApiSecretParameters(secret),
316
+ ...optionalStringParameter("KMS_KEY_ID", secret.kmsKeyId),
317
+ ...optionalBooleanParameter("REQUESTER_PAYS", secret.requesterPays),
318
+ ])
319
+ case "r2":
320
+ return withCommonSecretParameters(secret, [
321
+ "TYPE r2",
322
+ ...s3ApiSecretParameters(secret),
323
+ ...optionalStringParameter("ACCOUNT_ID", secret.accountId),
324
+ ])
325
+ case "gcs":
326
+ return withCommonSecretParameters(secret, ["TYPE gcs", ...s3ApiSecretParameters(secret)])
327
+ case "azure":
328
+ return withCommonSecretParameters(secret, azureSecretParameters(secret))
329
+ }
330
+ }
331
+
332
+ function s3ApiSecretParameters(
333
+ secret: S3SecretOptions | R2SecretOptions | GcsSecretOptions
334
+ ): readonly string[] {
335
+ return [
336
+ ...optionalIdentifierParameter("PROVIDER", secret.provider),
337
+ ...optionalStringParameter("KEY_ID", secret.keyId),
338
+ ...optionalStringParameter("SECRET", secret.secret),
339
+ ...optionalStringParameter("SESSION_TOKEN", secret.sessionToken),
340
+ ...optionalStringParameter("REGION", secret.region),
341
+ ...optionalStringParameter("ENDPOINT", secret.endpoint),
342
+ ...optionalStringParameter("URL_STYLE", secret.urlStyle),
343
+ ...optionalBooleanParameter("USE_SSL", secret.useSsl),
344
+ ...optionalBooleanParameter("VERIFY_SSL", secret.verifySsl),
345
+ ...optionalStringParameter("CHAIN", secret.chain),
346
+ ...optionalStringParameter("PROFILE", secret.profile),
347
+ ]
348
+ }
349
+
350
+ function azureSecretParameters(secret: AzureSecretOptions): readonly string[] {
351
+ if (
352
+ secret.tenantId !== undefined ||
353
+ secret.clientId !== undefined ||
354
+ secret.clientSecret !== undefined
355
+ ) {
356
+ return [
357
+ "TYPE azure",
358
+ "PROVIDER service_principal",
359
+ ...optionalStringParameter("TENANT_ID", secret.tenantId),
360
+ ...optionalStringParameter("CLIENT_ID", secret.clientId),
361
+ ...optionalStringParameter("CLIENT_SECRET", secret.clientSecret),
362
+ ...optionalStringParameter("ACCOUNT_NAME", secret.accountName),
363
+ ]
364
+ }
365
+
366
+ const connectionString =
367
+ secret.connectionString ??
368
+ (secret.accountName !== undefined && secret.accountKey !== undefined
369
+ ? `DefaultEndpointsProtocol=https;AccountName=${secret.accountName};AccountKey=${secret.accountKey};EndpointSuffix=core.windows.net`
370
+ : undefined)
371
+
372
+ if (connectionString !== undefined) {
373
+ return ["TYPE azure", ...optionalStringParameter("CONNECTION_STRING", connectionString)]
374
+ }
375
+
376
+ return [
377
+ "TYPE azure",
378
+ "PROVIDER credential_chain",
379
+ ...optionalStringParameter("ACCOUNT_NAME", secret.accountName),
380
+ ]
381
+ }
382
+
383
+ function withCommonSecretParameters(
384
+ secret: DuckDbSecretOptions,
385
+ parameters: readonly string[]
386
+ ): readonly string[] {
387
+ return [...parameters, ...optionalStringParameter("SCOPE", secret.scope)]
388
+ }
389
+
390
+ function optionalStringParameter(name: string, value: string | undefined): readonly string[] {
391
+ return value === undefined ? [] : [`${name} ${quoteSqlString(value)}`]
392
+ }
393
+
394
+ function optionalBooleanParameter(name: string, value: boolean | undefined): readonly string[] {
395
+ return value === undefined ? [] : [`${name} ${value}`]
396
+ }
397
+
398
+ function optionalIdentifierParameter(name: string, value: string | undefined): readonly string[] {
399
+ if (value === undefined) {
400
+ return []
401
+ }
402
+
403
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
404
+ throw new LakeStorageError(`[SixbDuckLake] Invalid SQL identifier '${value}'.`)
405
+ }
406
+
407
+ return [`${name} ${value}`]
408
+ }
409
+
410
+ function appendPostgresConnectionParameter(
411
+ parameters: string[],
412
+ names: Set<string>,
413
+ name: string,
414
+ value: string | number | boolean | undefined
415
+ ): void {
416
+ if (value === undefined) {
417
+ return
418
+ }
419
+
420
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
421
+ throw new LakeStorageError(`[SixbDuckLake] Invalid PostgreSQL catalog parameter '${name}'.`)
422
+ }
423
+
424
+ const normalizedName = name.toLowerCase()
425
+ if (names.has(normalizedName)) {
426
+ throw new LakeStorageError(`[SixbDuckLake] Duplicate PostgreSQL catalog parameter '${name}'.`)
427
+ }
428
+
429
+ names.add(normalizedName)
430
+ parameters.push(`${name}=${quotePostgresConnectionValue(String(value))}`)
431
+ }
432
+
433
+ function quotePostgresConnectionValue(value: string): string {
434
+ return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`
435
+ }
436
+
437
+ function pathScheme(path: string): string | null {
438
+ const match = /^([a-z][a-z0-9+.-]*):\/\//i.exec(path)
439
+ return match ? match[1].toLowerCase() : null
440
+ }
@@ -0,0 +1,140 @@
1
+ import type { DatasetVersion } from "@sixb/core/lake-storage"
2
+ import { LakeStorageError } from "@sixb/core/lake-storage"
3
+
4
+ export interface SixbSchemaChangeMetadata {
5
+ readonly addColumns?: readonly string[]
6
+ }
7
+
8
+ export interface SixbCommitMetadata {
9
+ readonly kind: "datasetVersion"
10
+ readonly datasetId: string
11
+ readonly commitId?: string
12
+ readonly mode?: DatasetVersion["mode"]
13
+ readonly producer?: DatasetVersion["producer"]
14
+ readonly inputs?: DatasetVersion["inputs"]
15
+ readonly rowCount?: number
16
+ readonly schemaChange?: SixbSchemaChangeMetadata
17
+ }
18
+
19
+ export function parseVersionId(versionId: string): string {
20
+ if (!versionId.startsWith("ducklake:")) {
21
+ throw new LakeStorageError(`[SixbDuckLake] Invalid DuckLake version id '${versionId}'.`)
22
+ }
23
+
24
+ const snapshotId = versionId.slice("ducklake:".length)
25
+ if (!/^\d+$/.test(snapshotId)) {
26
+ throw new LakeStorageError(`[SixbDuckLake] Invalid DuckLake version id '${versionId}'.`)
27
+ }
28
+
29
+ return snapshotId
30
+ }
31
+
32
+ export function toVersionId(snapshotId: string): string {
33
+ return `ducklake:${snapshotId}`
34
+ }
35
+
36
+ export function parseCommitMetadata(value: unknown): SixbCommitMetadata | undefined {
37
+ if (typeof value !== "string") {
38
+ return undefined
39
+ }
40
+
41
+ let parsed: unknown
42
+ try {
43
+ parsed = JSON.parse(value)
44
+ } catch {
45
+ return undefined
46
+ }
47
+
48
+ if (
49
+ !isRecord(parsed) ||
50
+ !isRecord(parsed.sixb) ||
51
+ parsed.sixb.kind !== "datasetVersion" ||
52
+ typeof parsed.sixb.datasetId !== "string"
53
+ ) {
54
+ return undefined
55
+ }
56
+
57
+ const mode = parsed.sixb.mode
58
+ const commitId = parsed.sixb.commitId
59
+ const rowCount = parsed.sixb.rowCount
60
+
61
+ return {
62
+ kind: "datasetVersion",
63
+ datasetId: parsed.sixb.datasetId,
64
+ ...(typeof commitId === "string" ? { commitId } : {}),
65
+ ...(mode === "snapshot" || mode === "append" || mode === "schema" ? { mode } : {}),
66
+ ...(isDatasetProducer(parsed.sixb.producer) ? { producer: parsed.sixb.producer } : {}),
67
+ ...(isDatasetVersionRefs(parsed.sixb.inputs) ? { inputs: parsed.sixb.inputs } : {}),
68
+ ...(typeof commitId === "string" && isRowCount(rowCount) ? { rowCount } : {}),
69
+ ...(isSchemaChangeMetadata(parsed.sixb.schemaChange)
70
+ ? { schemaChange: parsed.sixb.schemaChange }
71
+ : {}),
72
+ }
73
+ }
74
+
75
+ export function parseInlineDataChange(
76
+ changesMade: string,
77
+ tableId: bigint
78
+ ): { readonly hasDataChange: boolean; readonly hasDeleteChange: boolean } {
79
+ const tableIdText = tableId.toString()
80
+ let hasDataChange = false
81
+ let hasDeleteChange = false
82
+
83
+ for (const change of changesMade.split(",")) {
84
+ const [kind, changedTableId] = change.split(":")
85
+ if (changedTableId !== tableIdText) {
86
+ continue
87
+ }
88
+
89
+ const normalizedKind = kind.toLowerCase()
90
+ if (normalizedKind.includes("insert") || normalizedKind.includes("delete")) {
91
+ hasDataChange = true
92
+ }
93
+
94
+ if (normalizedKind.includes("delete")) {
95
+ hasDeleteChange = true
96
+ }
97
+ }
98
+
99
+ return { hasDataChange, hasDeleteChange }
100
+ }
101
+
102
+ function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
103
+ return typeof value === "object" && value !== null && !Array.isArray(value)
104
+ }
105
+
106
+ function isDatasetProducer(value: unknown): value is NonNullable<DatasetVersion["producer"]> {
107
+ return (
108
+ isRecord(value) &&
109
+ (value.kind === "sync" || value.kind === "pipeline") &&
110
+ optionalString(value.id) &&
111
+ optionalString(value.runId)
112
+ )
113
+ }
114
+
115
+ function isDatasetVersionRefs(value: unknown): value is NonNullable<DatasetVersion["inputs"]> {
116
+ return (
117
+ Array.isArray(value) &&
118
+ value.every(
119
+ (item) =>
120
+ isRecord(item) && typeof item.datasetId === "string" && typeof item.versionId === "string"
121
+ )
122
+ )
123
+ }
124
+
125
+ function isSchemaChangeMetadata(value: unknown): value is SixbSchemaChangeMetadata {
126
+ return (
127
+ isRecord(value) &&
128
+ (value.addColumns === undefined ||
129
+ (Array.isArray(value.addColumns) &&
130
+ value.addColumns.every((columnName) => typeof columnName === "string")))
131
+ )
132
+ }
133
+
134
+ function optionalString(value: unknown): boolean {
135
+ return value === undefined || typeof value === "string"
136
+ }
137
+
138
+ function isRowCount(value: unknown): value is number {
139
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
140
+ }