@malloy-publisher/server 0.0.203 → 0.0.205
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/build.ts +10 -1
- package/dist/app/api-doc.yaml +146 -0
- package/dist/app/assets/{EnvironmentPage-BVQ7glKP.js → EnvironmentPage-CAge6UHD.js} +1 -1
- package/dist/app/assets/HomePage-DhTe8qpa.js +1 -0
- package/dist/app/assets/{MainPage-bYOWcgDP.js → MainPage-CeTxxGex.js} +2 -2
- package/dist/app/assets/MaterializationsPage-CpDHB70t.js +1 -0
- package/dist/app/assets/ModelPage-D9sSMb75.js +1 -0
- package/dist/app/assets/PackagePage-LRqQWrFY.js +1 -0
- package/dist/app/assets/{RouteError-_J-EBz7W.js → RouteError-xT6kuCNw.js} +1 -1
- package/dist/app/assets/{WorkbookPage-Bjs9Nm-_.js → WorkbookPage-DsIh9svZ.js} +1 -1
- package/dist/app/assets/{core-BPLlx5VM.es-C2ARtwWI.js → core-C2sQrwVu.es-Bjem0hym.js} +1 -1
- package/dist/app/assets/{index-CqUWJELr.js → index-BdOZDcce.js} +2 -2
- package/dist/app/assets/index-DHHAcY5o.js +1812 -0
- package/dist/app/assets/index-RX3QOTde.js +455 -0
- package/dist/app/assets/index.umd-D2WH3D-f.js +2469 -0
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +392 -67
- package/dist/runtime/publisher.js +318 -0
- package/dist/server.mjs +982 -346
- package/package.json +15 -14
- package/scripts/bake-duckdb-extensions.js +104 -0
- package/src/controller/watch-mode.controller.ts +176 -46
- package/src/ducklake_version.spec.ts +43 -0
- package/src/ducklake_version.ts +26 -0
- package/src/errors.spec.ts +21 -0
- package/src/errors.ts +18 -1
- package/src/mcp/error_messages.spec.ts +35 -0
- package/src/mcp/error_messages.ts +14 -1
- package/src/mcp/handler_utils.ts +12 -0
- package/src/package_load/package_load_pool.ts +0 -5
- package/src/package_load/package_load_worker.ts +41 -99
- package/src/package_load/protocol.ts +1 -7
- package/src/runtime/publisher.js +318 -0
- package/src/server.ts +479 -2
- package/src/service/annotations.spec.ts +118 -0
- package/src/service/annotations.ts +91 -0
- package/src/service/authorize.spec.ts +132 -0
- package/src/service/authorize.ts +241 -0
- package/src/service/authorize_integration.spec.ts +932 -0
- package/src/service/compile_authorize.spec.ts +85 -0
- package/src/service/connection.ts +1 -1
- package/src/service/environment.ts +67 -9
- package/src/service/environment_store.ts +142 -11
- package/src/service/filter.spec.ts +14 -3
- package/src/service/filter.ts +5 -1
- package/src/service/filter_bypass.spec.ts +418 -0
- package/src/service/given.ts +37 -12
- package/src/service/givens_integration.spec.ts +34 -7
- package/src/service/materialization_service.ts +25 -20
- package/src/service/materialized_table_gc.spec.ts +6 -5
- package/src/service/materialized_table_gc.ts +2 -50
- package/src/service/model.spec.ts +203 -8
- package/src/service/model.ts +349 -155
- package/src/service/package.ts +17 -6
- package/src/service/package_worker_path.spec.ts +113 -0
- package/src/service/quoting.ts +0 -20
- package/src/service/restricted_mode.spec.ts +299 -0
- package/src/service/source_extraction.ts +226 -0
- package/src/storage/StorageManager.ts +73 -0
- package/src/storage/duckdb/DuckDBConnection.ts +70 -124
- package/tests/fixtures/authorize-compile/model.malloy +9 -0
- package/tests/fixtures/authorize-compile/publisher.json +4 -0
- package/tests/fixtures/html-pages-nopublic/model.malloy +1 -0
- package/tests/fixtures/html-pages-nopublic/publisher.json +5 -0
- package/tests/fixtures/html-pages-test/data.csv +3 -0
- package/tests/fixtures/html-pages-test/public/assets/app.css +3 -0
- package/tests/fixtures/html-pages-test/public/data.json +1 -0
- package/tests/fixtures/html-pages-test/public/index.html +9 -0
- package/tests/fixtures/html-pages-test/public/sub/page2.html +9 -0
- package/tests/fixtures/html-pages-test/publisher.json +5 -0
- package/tests/fixtures/html-pages-test/report.malloy +1 -0
- package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +92 -0
- package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +138 -0
- package/tests/integration/html_pages/html_pages.integration.spec.ts +378 -0
- package/tests/integration/watch-mode/watch_mode.integration.spec.ts +421 -0
- package/tests/unit/duckdb/attached_databases.test.ts +111 -0
- package/tests/unit/duckdb/duckdb_connection.test.ts +181 -0
- package/tests/unit/duckdb/repositories.test.ts +208 -0
- package/dist/app/assets/HomePage-D9drXoZX.js +0 -1
- package/dist/app/assets/ModelPage-DT0gjNy1.js +0 -1
- package/dist/app/assets/PackagePage-N1ZBNJul.js +0 -1
- package/dist/app/assets/index-BeNwIeYQ.js +0 -454
- package/dist/app/assets/index-Dx7qi2LO.js +0 -1803
- package/dist/app/assets/index.umd-BXm2lnUO.js +0 -1145
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared source / query introspection extracted from a compiled `ModelDef`.
|
|
3
|
+
*
|
|
4
|
+
* Both the in-process `Model.create` path (`service/model.ts`) and the
|
|
5
|
+
* package-load worker (`package_load/package_load_worker.ts`, which runs in a
|
|
6
|
+
* separate bundle and serializes the result over the worker protocol) need to
|
|
7
|
+
* walk a `ModelDef` and produce the same `sources` / `queries` shapes plus the
|
|
8
|
+
* `#(filter)` `filterMap`. These two call sites used to carry byte-for-byte
|
|
9
|
+
* copies of this logic; keeping them in lockstep by hand was a standing hazard
|
|
10
|
+
* (a change to one silently diverged from the other). This module is the single
|
|
11
|
+
* source of truth — the two callers differ only in how they type the result
|
|
12
|
+
* (generated API types vs. worker wire types — structurally identical, so each
|
|
13
|
+
* casts at its boundary) and in how they report a filter parse failure (the
|
|
14
|
+
* service logs a warning; the worker has no logger and stays silent), which is
|
|
15
|
+
* threaded through the optional `onParseError` callback.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
isSourceDef,
|
|
20
|
+
ModelDef,
|
|
21
|
+
NamedModelObject,
|
|
22
|
+
NamedQueryDef,
|
|
23
|
+
StructDef,
|
|
24
|
+
TurtleDef,
|
|
25
|
+
} from "@malloydata/malloy";
|
|
26
|
+
import { annotationTexts, modelAnnotations } from "./annotations";
|
|
27
|
+
import { collectAuthorizeExprs, type AuthorizeMap } from "./authorize";
|
|
28
|
+
import { parseFilters, type FilterDefinition } from "./filter";
|
|
29
|
+
|
|
30
|
+
/** A `#(filter)` definition enriched with the dimension's Malloy type. */
|
|
31
|
+
export interface ExtractedFilter {
|
|
32
|
+
name: string;
|
|
33
|
+
dimension: string;
|
|
34
|
+
type: string;
|
|
35
|
+
implicit: boolean;
|
|
36
|
+
required: boolean;
|
|
37
|
+
dimensionType: string | undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ExtractedView {
|
|
41
|
+
name: string;
|
|
42
|
+
annotations: string[] | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Structural source shape both callers cast to their own typed view
|
|
47
|
+
* (`ApiSource` in the service, `ApiSourceWire` in the worker). `givens` is
|
|
48
|
+
* attached verbatim from the caller-supplied list, so it stays `unknown` here.
|
|
49
|
+
*/
|
|
50
|
+
export interface ExtractedSource {
|
|
51
|
+
name: string;
|
|
52
|
+
annotations: string[] | undefined;
|
|
53
|
+
views: ExtractedView[];
|
|
54
|
+
filters: ExtractedFilter[] | undefined;
|
|
55
|
+
givens: unknown;
|
|
56
|
+
/**
|
|
57
|
+
* Effective `#(authorize)` / `##(authorize)` expressions gating this source:
|
|
58
|
+
* file-level expressions first, then the source's own. Undefined when the
|
|
59
|
+
* source carries no authorize annotations. Surfaced for introspection;
|
|
60
|
+
* enforcement happens server-side.
|
|
61
|
+
*/
|
|
62
|
+
authorize: string[] | undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ExtractedQuery {
|
|
66
|
+
name: string;
|
|
67
|
+
sourceName: string | undefined;
|
|
68
|
+
annotations: string[] | undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Extract every source from a compiled model, parsing `#(filter)` annotations
|
|
73
|
+
* along the way.
|
|
74
|
+
*
|
|
75
|
+
* Filters are collected by walking the `annotations.inherits` chain so that
|
|
76
|
+
* filters declared on a base source flow to an extending source. The chain runs
|
|
77
|
+
* child → parent, so we collect child-first then reverse — `parseFilters` uses
|
|
78
|
+
* "last wins" dedup, which lets a child's `#(filter)` override the base's.
|
|
79
|
+
*
|
|
80
|
+
* `givens` is attached unchanged to every source (Malloy exposes givens at the
|
|
81
|
+
* model level, not per-source). `onParseError`, when supplied, is invoked with
|
|
82
|
+
* the source name and error if a source's `#(filter)` annotations fail to parse;
|
|
83
|
+
* filter extraction then continues. Authorize parse errors are NOT routed here —
|
|
84
|
+
* they propagate (a malformed gate fails model load) so a security gate is never
|
|
85
|
+
* silently dropped.
|
|
86
|
+
*
|
|
87
|
+
* Authorize (`#(authorize)` / `##(authorize)`) is collected from the source's
|
|
88
|
+
* own `blockNotes` only — we do NOT walk the `inherits` chain. Note Malloy's
|
|
89
|
+
* behavior for `X is Y extend {...}`: if X declares its own `#(authorize)`,
|
|
90
|
+
* X.blockNotes holds only X's gates (Y's are dropped — the intended "curated
|
|
91
|
+
* re-exposure"); if X declares none, Malloy surfaces Y's blockNotes on X, so
|
|
92
|
+
* the base gate carries to the un-annotated extension (a safe default — a
|
|
93
|
+
* locked base stays locked unless an extension explicitly re-exposes itself).
|
|
94
|
+
* This carry happens through `blockNotes`, not the `inherits` chain, so reading
|
|
95
|
+
* own-blockNotes is sufficient. Joins are a separate concern and are not gated.
|
|
96
|
+
* The effective list per source is the file-level `##(authorize)` expressions
|
|
97
|
+
* (from `modelDef.annotations.notes`) followed by the source's own
|
|
98
|
+
* `#(authorize)` expressions, evaluated as one OR disjunction at request time.
|
|
99
|
+
*/
|
|
100
|
+
export function extractSourcesFromModelDef(
|
|
101
|
+
modelDef: ModelDef,
|
|
102
|
+
givens: unknown,
|
|
103
|
+
onParseError?: (sourceName: string, err: unknown) => void,
|
|
104
|
+
): {
|
|
105
|
+
sources: ExtractedSource[];
|
|
106
|
+
filterMap: Map<string, FilterDefinition[]>;
|
|
107
|
+
authorizeMap: AuthorizeMap;
|
|
108
|
+
} {
|
|
109
|
+
const filterMap = new Map<string, FilterDefinition[]>();
|
|
110
|
+
const authorizeMap: AuthorizeMap = new Map();
|
|
111
|
+
|
|
112
|
+
// File-level ##(authorize) is collected once and prepended to every source.
|
|
113
|
+
// Unlike filters, a malformed authorize annotation is NOT swallowed: the
|
|
114
|
+
// parse error propagates so the model fails to load loudly (caught per-model
|
|
115
|
+
// upstream and turned into a compilationError). Silently dropping a gate —
|
|
116
|
+
// and in the worker path there is no onParseError callback, so it would be
|
|
117
|
+
// truly silent — could leave a source that the author meant to lock looking
|
|
118
|
+
// unrestricted.
|
|
119
|
+
const fileLevelAuthorize = collectAuthorizeExprs(
|
|
120
|
+
(modelAnnotations(modelDef).notes ?? []).map((note) => note.text),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const sources: ExtractedSource[] = Object.values(modelDef.contents)
|
|
124
|
+
.filter((obj) => isSourceDef(obj))
|
|
125
|
+
.map((sourceObj) => {
|
|
126
|
+
const struct = sourceObj as StructDef;
|
|
127
|
+
const sourceName = struct.as || struct.name;
|
|
128
|
+
const annotations = annotationTexts(struct.annotations);
|
|
129
|
+
|
|
130
|
+
const collected: string[][] = [];
|
|
131
|
+
let cur = struct.annotations;
|
|
132
|
+
while (cur) {
|
|
133
|
+
if (cur.blockNotes) {
|
|
134
|
+
collected.push(cur.blockNotes.map((note) => note.text));
|
|
135
|
+
}
|
|
136
|
+
cur = cur.inherits;
|
|
137
|
+
}
|
|
138
|
+
const allAnnotations = collected.reverse().flat();
|
|
139
|
+
|
|
140
|
+
let filters: ExtractedFilter[] | undefined;
|
|
141
|
+
if (allAnnotations.length > 0) {
|
|
142
|
+
try {
|
|
143
|
+
const parsed = parseFilters(allAnnotations);
|
|
144
|
+
if (parsed.length > 0) {
|
|
145
|
+
filterMap.set(sourceName, parsed);
|
|
146
|
+
const fields = struct.fields;
|
|
147
|
+
filters = parsed.map((f) => {
|
|
148
|
+
const field = fields.find(
|
|
149
|
+
(fd) => (fd.as || fd.name) === f.dimension,
|
|
150
|
+
);
|
|
151
|
+
return {
|
|
152
|
+
name: f.name,
|
|
153
|
+
dimension: f.dimension,
|
|
154
|
+
type: f.type,
|
|
155
|
+
implicit: f.implicit,
|
|
156
|
+
required: f.required,
|
|
157
|
+
dimensionType: field?.type as string | undefined,
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
} catch (err) {
|
|
162
|
+
onParseError?.(sourceName, err);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Authorize: the source's OWN #(authorize) annotations only — no
|
|
167
|
+
// inherits walk. File-level ##(authorize) is prepended so file gates
|
|
168
|
+
// and source gates form one OR disjunction. A malformed annotation
|
|
169
|
+
// propagates (model fails to load) rather than silently dropping the
|
|
170
|
+
// gate — see the file-level note above.
|
|
171
|
+
const ownNotes = (struct.annotations?.blockNotes ?? []).map(
|
|
172
|
+
(note) => note.text,
|
|
173
|
+
);
|
|
174
|
+
const effective = [
|
|
175
|
+
...fileLevelAuthorize,
|
|
176
|
+
...collectAuthorizeExprs(ownNotes),
|
|
177
|
+
];
|
|
178
|
+
let authorize: string[] | undefined;
|
|
179
|
+
if (effective.length > 0) {
|
|
180
|
+
authorizeMap.set(sourceName, effective);
|
|
181
|
+
authorize = effective;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const views: ExtractedView[] = struct.fields
|
|
185
|
+
.filter((field) => field.type === "turtle")
|
|
186
|
+
.filter((turtle) =>
|
|
187
|
+
// Filter out non-reduce views (e.g. indexes).
|
|
188
|
+
(turtle as TurtleDef).pipeline
|
|
189
|
+
.map((stage) => stage.type)
|
|
190
|
+
.every((type) => type === "reduce"),
|
|
191
|
+
)
|
|
192
|
+
.map((turtle) => ({
|
|
193
|
+
name: turtle.as || turtle.name,
|
|
194
|
+
annotations: annotationTexts(turtle.annotations),
|
|
195
|
+
}));
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
name: sourceName,
|
|
199
|
+
annotations,
|
|
200
|
+
views,
|
|
201
|
+
filters,
|
|
202
|
+
givens,
|
|
203
|
+
authorize,
|
|
204
|
+
};
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
return { sources, filterMap, authorizeMap };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Extract every named query from a compiled model. */
|
|
211
|
+
export function extractQueriesFromModelDef(
|
|
212
|
+
modelDef: ModelDef,
|
|
213
|
+
): ExtractedQuery[] {
|
|
214
|
+
const isNamedQuery = (obj: NamedModelObject): obj is NamedQueryDef =>
|
|
215
|
+
obj.type === "query";
|
|
216
|
+
return Object.values(modelDef.contents)
|
|
217
|
+
.filter(isNamedQuery)
|
|
218
|
+
.map((queryObj) => ({
|
|
219
|
+
name: queryObj.as || queryObj.name,
|
|
220
|
+
sourceName:
|
|
221
|
+
typeof queryObj.structRef === "string"
|
|
222
|
+
? queryObj.structRef
|
|
223
|
+
: undefined,
|
|
224
|
+
annotations: annotationTexts(queryObj.annotations),
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { Mutex } from "async-mutex";
|
|
2
2
|
import * as crypto from "crypto";
|
|
3
|
+
import {
|
|
4
|
+
isCatalogVersionSupported,
|
|
5
|
+
SUPPORTED_CATALOG_VERSIONS,
|
|
6
|
+
} from "../ducklake_version";
|
|
3
7
|
import { ConnectionAuthError } from "../errors";
|
|
4
8
|
import { logger } from "../logger";
|
|
5
9
|
import {
|
|
@@ -64,6 +68,53 @@ function catalogNameForConfig(c: DuckLakeManifestConfig): string {
|
|
|
64
68
|
return `manifest_lake_${hash}`;
|
|
65
69
|
}
|
|
66
70
|
|
|
71
|
+
// Read the catalog's recorded DuckLake format version from its
|
|
72
|
+
// `ducklake_metadata` table via a plain postgres ATTACH (does NOT invoke
|
|
73
|
+
// the DuckLake extension on the catalog). Returns the version string on
|
|
74
|
+
// success, or `undefined` on any failure (missing table, query timeout,
|
|
75
|
+
// connect failure) so the main ATTACH path stays the source of truth for
|
|
76
|
+
// unrelated errors. Only meaningful for postgres-backed catalogs; the
|
|
77
|
+
// caller must guard with `isPostgres`.
|
|
78
|
+
async function readDuckLakeCatalogVersion(
|
|
79
|
+
connection: DuckDBConnection,
|
|
80
|
+
catalogUrl: string,
|
|
81
|
+
catalogName: string,
|
|
82
|
+
): Promise<string | undefined> {
|
|
83
|
+
if (!catalogUrl.startsWith("postgres:")) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
const pgConnString = catalogUrl.slice("postgres:".length);
|
|
87
|
+
const tempDb = `${catalogName}_preflight`;
|
|
88
|
+
const escaped = escapeSQL(pgConnString);
|
|
89
|
+
try {
|
|
90
|
+
await connection.run(
|
|
91
|
+
`ATTACH '${escaped}' AS ${tempDb} (TYPE postgres, READ_ONLY);`,
|
|
92
|
+
);
|
|
93
|
+
const rows = await connection.all<{ value: string }>(
|
|
94
|
+
`SELECT value FROM ${tempDb}.ducklake_metadata WHERE key = 'version' LIMIT 1;`,
|
|
95
|
+
);
|
|
96
|
+
const value = rows[0]?.value;
|
|
97
|
+
return typeof value === "string" ? value : undefined;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
logger.warn(
|
|
100
|
+
"DuckLake catalog version preflight failed; falling back to ATTACH",
|
|
101
|
+
{
|
|
102
|
+
catalogName,
|
|
103
|
+
error: redactPgSecrets(
|
|
104
|
+
error instanceof Error ? error.message : String(error),
|
|
105
|
+
),
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
return undefined;
|
|
109
|
+
} finally {
|
|
110
|
+
try {
|
|
111
|
+
await connection.run(`DETACH ${tempDb};`);
|
|
112
|
+
} catch {
|
|
113
|
+
// ATTACH may have failed, so DETACH may have nothing to do.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
67
118
|
/**
|
|
68
119
|
* Manages the storage backend (DuckDB, Postgres, etc.) and per-environment
|
|
69
120
|
* manifest stores. Environments without `materializationStorage` config use
|
|
@@ -222,6 +273,28 @@ export class StorageManager {
|
|
|
222
273
|
await connection.run("INSTALL httpfs; LOAD httpfs;");
|
|
223
274
|
}
|
|
224
275
|
|
|
276
|
+
// Preflight: read the catalog's recorded format version via the
|
|
277
|
+
// postgres extension (not DuckLake) and fail fast with a non-retryable
|
|
278
|
+
// 422 if the baked DuckLake extension can't read it. Without this,
|
|
279
|
+
// an unsupported catalog would surface as a generic DuckDB error
|
|
280
|
+
// from the ATTACH below, which retry loops misclassify as transient.
|
|
281
|
+
if (isPostgres) {
|
|
282
|
+
const catalogVersion = await readDuckLakeCatalogVersion(
|
|
283
|
+
connection,
|
|
284
|
+
catalogUrl,
|
|
285
|
+
catalogName,
|
|
286
|
+
);
|
|
287
|
+
if (catalogVersion && !isCatalogVersionSupported(catalogVersion)) {
|
|
288
|
+
const supportedMax =
|
|
289
|
+
SUPPORTED_CATALOG_VERSIONS[
|
|
290
|
+
SUPPORTED_CATALOG_VERSIONS.length - 1
|
|
291
|
+
];
|
|
292
|
+
throw new ConnectionAuthError(
|
|
293
|
+
`DuckLake catalog version ${catalogVersion} is newer than this Publisher's extension supports (max ${supportedMax}). Upgrade the Publisher image or downgrade the catalog.`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
225
298
|
let attachCmd = `ATTACH 'ducklake:${escapedCatalogUrl}' AS ${catalogName}`;
|
|
226
299
|
const attachOpts: string[] = [
|
|
227
300
|
`DATA_PATH '${escapedDataPath}'`,
|
|
@@ -1,11 +1,30 @@
|
|
|
1
1
|
import { Mutex } from "async-mutex";
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
DuckDBConnection as NeoConnection,
|
|
4
|
+
DuckDBInstance,
|
|
5
|
+
type DuckDBValue,
|
|
6
|
+
} from "@duckdb/node-api";
|
|
3
7
|
import * as path from "path";
|
|
4
8
|
import { DatabaseConnection } from "../DatabaseInterface";
|
|
5
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Embedded persistence layer for the publisher's own metadata (environments,
|
|
12
|
+
* packages, connections, materializations, build manifests) in `publisher.db`.
|
|
13
|
+
*
|
|
14
|
+
* This is a plain DAO over a durable, exclusively-owned DuckDB handle -- it is
|
|
15
|
+
* deliberately NOT Malloy's `@malloydata/db-duckdb` connection, which is an
|
|
16
|
+
* analytical query connection (no prepared-statement parameter binding, a
|
|
17
|
+
* `:memory:` primary with ATTACH/DETACH/idle lifecycle, pooled/shared
|
|
18
|
+
* instances, and a poison-pill close). Those semantics are wrong for a
|
|
19
|
+
* source-of-truth store that must hold one handle open for the server's
|
|
20
|
+
* lifetime and run parameterized CRUD.
|
|
21
|
+
*
|
|
22
|
+
* It wraps `@duckdb/node-api` (the same DuckDB engine Malloy pulls in), so the
|
|
23
|
+
* repo carries a single DuckDB engine rather than a second, redundant driver.
|
|
24
|
+
*/
|
|
6
25
|
export class DuckDBConnection implements DatabaseConnection {
|
|
7
|
-
private
|
|
8
|
-
private connection:
|
|
26
|
+
private instance: DuckDBInstance | null = null;
|
|
27
|
+
private connection: NeoConnection | null = null;
|
|
9
28
|
private dbPath: string;
|
|
10
29
|
private mutex: Mutex = new Mutex();
|
|
11
30
|
|
|
@@ -15,94 +34,47 @@ export class DuckDBConnection implements DatabaseConnection {
|
|
|
15
34
|
}
|
|
16
35
|
|
|
17
36
|
async initialize(): Promise<void> {
|
|
18
|
-
|
|
19
|
-
this.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
this.db as duckdb.Database & { connect(): duckdb.Connection }
|
|
29
|
-
).connect();
|
|
30
|
-
|
|
31
|
-
if (!this.connection) {
|
|
32
|
-
reject(new Error("Failed to create connection object"));
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// Verify connection works
|
|
37
|
-
this.connection.all("SELECT 42 as answer", (testErr, _rows) => {
|
|
38
|
-
if (testErr) {
|
|
39
|
-
console.error("Connection test failed:", testErr);
|
|
40
|
-
reject(
|
|
41
|
-
new Error(
|
|
42
|
-
`Failed to verify DuckDB connection: ${testErr.message}`,
|
|
43
|
-
),
|
|
44
|
-
);
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
resolve();
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
});
|
|
37
|
+
try {
|
|
38
|
+
this.instance = await DuckDBInstance.create(this.dbPath);
|
|
39
|
+
this.connection = await this.instance.connect();
|
|
40
|
+
// Verify the connection works
|
|
41
|
+
await this.connection.run("SELECT 42 as answer");
|
|
42
|
+
} catch (err) {
|
|
43
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
44
|
+
console.error("Failed to create DuckDB database:", err);
|
|
45
|
+
throw new Error(`Failed to initialize DuckDB: ${message}`);
|
|
46
|
+
}
|
|
52
47
|
}
|
|
53
48
|
|
|
54
49
|
async close(): Promise<void> {
|
|
55
|
-
|
|
50
|
+
try {
|
|
56
51
|
if (this.connection) {
|
|
57
|
-
this.connection.
|
|
58
|
-
|
|
59
|
-
reject(
|
|
60
|
-
new Error(
|
|
61
|
-
`Failed to close DuckDB connection: ${err.message}`,
|
|
62
|
-
),
|
|
63
|
-
);
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (this.db) {
|
|
68
|
-
this.db.close((dbErr) => {
|
|
69
|
-
if (dbErr) {
|
|
70
|
-
reject(
|
|
71
|
-
new Error(
|
|
72
|
-
`Failed to close DuckDB: ${dbErr.message}`,
|
|
73
|
-
),
|
|
74
|
-
);
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
console.log("DuckDB connection closed");
|
|
78
|
-
resolve();
|
|
79
|
-
});
|
|
80
|
-
} else {
|
|
81
|
-
resolve();
|
|
82
|
-
}
|
|
83
|
-
});
|
|
84
|
-
} else {
|
|
85
|
-
resolve();
|
|
52
|
+
this.connection.closeSync();
|
|
53
|
+
this.connection = null;
|
|
86
54
|
}
|
|
87
|
-
|
|
55
|
+
if (this.instance) {
|
|
56
|
+
this.instance.closeSync();
|
|
57
|
+
this.instance = null;
|
|
58
|
+
}
|
|
59
|
+
console.log("DuckDB connection closed");
|
|
60
|
+
} catch (err) {
|
|
61
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
62
|
+
throw new Error(`Failed to close DuckDB connection: ${message}`);
|
|
63
|
+
}
|
|
88
64
|
}
|
|
89
65
|
|
|
90
66
|
async isInitialized(): Promise<boolean> {
|
|
91
67
|
if (!this.connection) return false;
|
|
92
68
|
|
|
93
69
|
return this.mutex.runExclusive(async () => {
|
|
94
|
-
|
|
95
|
-
this.connection!.
|
|
70
|
+
try {
|
|
71
|
+
const reader = await this.connection!.runAndReadAll(
|
|
96
72
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='environments'",
|
|
97
|
-
(err, rows) => {
|
|
98
|
-
if (err) {
|
|
99
|
-
resolve(false);
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
resolve(rows && rows.length > 0);
|
|
103
|
-
},
|
|
104
73
|
);
|
|
105
|
-
|
|
74
|
+
return reader.getRowObjectsJS().length > 0;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
106
78
|
});
|
|
107
79
|
}
|
|
108
80
|
|
|
@@ -112,26 +84,14 @@ export class DuckDBConnection implements DatabaseConnection {
|
|
|
112
84
|
}
|
|
113
85
|
|
|
114
86
|
return this.mutex.runExclusive(async () => {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
resolve();
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
// Pass params directly without the params argument if empty
|
|
129
|
-
if (params && params.length > 0) {
|
|
130
|
-
this.connection!.run(query, ...params, callback);
|
|
131
|
-
} else {
|
|
132
|
-
this.connection!.run(query, callback);
|
|
133
|
-
}
|
|
134
|
-
});
|
|
87
|
+
try {
|
|
88
|
+
await this.connection!.run(query, params as DuckDBValue[]);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Query execution failed: ${message}\nQuery: ${query}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
135
95
|
});
|
|
136
96
|
}
|
|
137
97
|
|
|
@@ -141,25 +101,18 @@ export class DuckDBConnection implements DatabaseConnection {
|
|
|
141
101
|
}
|
|
142
102
|
|
|
143
103
|
return this.mutex.runExclusive(async () => {
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (params && params.length > 0) {
|
|
158
|
-
this.connection!.all(query, ...params, callback);
|
|
159
|
-
} else {
|
|
160
|
-
this.connection!.all(query, callback);
|
|
161
|
-
}
|
|
162
|
-
});
|
|
104
|
+
try {
|
|
105
|
+
const reader = await this.connection!.runAndReadAll(
|
|
106
|
+
query,
|
|
107
|
+
params as DuckDBValue[],
|
|
108
|
+
);
|
|
109
|
+
return reader.getRowObjectsJS() as T[];
|
|
110
|
+
} catch (err) {
|
|
111
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Query execution failed: ${message}\nQuery: ${query}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
163
116
|
});
|
|
164
117
|
}
|
|
165
118
|
|
|
@@ -167,11 +120,4 @@ export class DuckDBConnection implements DatabaseConnection {
|
|
|
167
120
|
const rows = await this.all<T>(query, params);
|
|
168
121
|
return rows.length > 0 ? rows[0] : null;
|
|
169
122
|
}
|
|
170
|
-
|
|
171
|
-
getConnection(): duckdb.Connection {
|
|
172
|
-
if (!this.connection) {
|
|
173
|
-
throw new Error("Database not initialized");
|
|
174
|
-
}
|
|
175
|
-
return this.connection;
|
|
176
|
-
}
|
|
177
123
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
source: nums is duckdb.sql("SELECT 1 as n")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "ok": true }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
source: report is duckdb.sql("SELECT 1 as n")
|