@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.
Files changed (84) hide show
  1. package/build.ts +10 -1
  2. package/dist/app/api-doc.yaml +146 -0
  3. package/dist/app/assets/{EnvironmentPage-BVQ7glKP.js → EnvironmentPage-CAge6UHD.js} +1 -1
  4. package/dist/app/assets/HomePage-DhTe8qpa.js +1 -0
  5. package/dist/app/assets/{MainPage-bYOWcgDP.js → MainPage-CeTxxGex.js} +2 -2
  6. package/dist/app/assets/MaterializationsPage-CpDHB70t.js +1 -0
  7. package/dist/app/assets/ModelPage-D9sSMb75.js +1 -0
  8. package/dist/app/assets/PackagePage-LRqQWrFY.js +1 -0
  9. package/dist/app/assets/{RouteError-_J-EBz7W.js → RouteError-xT6kuCNw.js} +1 -1
  10. package/dist/app/assets/{WorkbookPage-Bjs9Nm-_.js → WorkbookPage-DsIh9svZ.js} +1 -1
  11. package/dist/app/assets/{core-BPLlx5VM.es-C2ARtwWI.js → core-C2sQrwVu.es-Bjem0hym.js} +1 -1
  12. package/dist/app/assets/{index-CqUWJELr.js → index-BdOZDcce.js} +2 -2
  13. package/dist/app/assets/index-DHHAcY5o.js +1812 -0
  14. package/dist/app/assets/index-RX3QOTde.js +455 -0
  15. package/dist/app/assets/index.umd-D2WH3D-f.js +2469 -0
  16. package/dist/app/index.html +1 -1
  17. package/dist/package_load_worker.mjs +392 -67
  18. package/dist/runtime/publisher.js +318 -0
  19. package/dist/server.mjs +982 -346
  20. package/package.json +15 -14
  21. package/scripts/bake-duckdb-extensions.js +104 -0
  22. package/src/controller/watch-mode.controller.ts +176 -46
  23. package/src/ducklake_version.spec.ts +43 -0
  24. package/src/ducklake_version.ts +26 -0
  25. package/src/errors.spec.ts +21 -0
  26. package/src/errors.ts +18 -1
  27. package/src/mcp/error_messages.spec.ts +35 -0
  28. package/src/mcp/error_messages.ts +14 -1
  29. package/src/mcp/handler_utils.ts +12 -0
  30. package/src/package_load/package_load_pool.ts +0 -5
  31. package/src/package_load/package_load_worker.ts +41 -99
  32. package/src/package_load/protocol.ts +1 -7
  33. package/src/runtime/publisher.js +318 -0
  34. package/src/server.ts +479 -2
  35. package/src/service/annotations.spec.ts +118 -0
  36. package/src/service/annotations.ts +91 -0
  37. package/src/service/authorize.spec.ts +132 -0
  38. package/src/service/authorize.ts +241 -0
  39. package/src/service/authorize_integration.spec.ts +932 -0
  40. package/src/service/compile_authorize.spec.ts +85 -0
  41. package/src/service/connection.ts +1 -1
  42. package/src/service/environment.ts +67 -9
  43. package/src/service/environment_store.ts +142 -11
  44. package/src/service/filter.spec.ts +14 -3
  45. package/src/service/filter.ts +5 -1
  46. package/src/service/filter_bypass.spec.ts +418 -0
  47. package/src/service/given.ts +37 -12
  48. package/src/service/givens_integration.spec.ts +34 -7
  49. package/src/service/materialization_service.ts +25 -20
  50. package/src/service/materialized_table_gc.spec.ts +6 -5
  51. package/src/service/materialized_table_gc.ts +2 -50
  52. package/src/service/model.spec.ts +203 -8
  53. package/src/service/model.ts +349 -155
  54. package/src/service/package.ts +17 -6
  55. package/src/service/package_worker_path.spec.ts +113 -0
  56. package/src/service/quoting.ts +0 -20
  57. package/src/service/restricted_mode.spec.ts +299 -0
  58. package/src/service/source_extraction.ts +226 -0
  59. package/src/storage/StorageManager.ts +73 -0
  60. package/src/storage/duckdb/DuckDBConnection.ts +70 -124
  61. package/tests/fixtures/authorize-compile/model.malloy +9 -0
  62. package/tests/fixtures/authorize-compile/publisher.json +4 -0
  63. package/tests/fixtures/html-pages-nopublic/model.malloy +1 -0
  64. package/tests/fixtures/html-pages-nopublic/publisher.json +5 -0
  65. package/tests/fixtures/html-pages-test/data.csv +3 -0
  66. package/tests/fixtures/html-pages-test/public/assets/app.css +3 -0
  67. package/tests/fixtures/html-pages-test/public/data.json +1 -0
  68. package/tests/fixtures/html-pages-test/public/index.html +9 -0
  69. package/tests/fixtures/html-pages-test/public/sub/page2.html +9 -0
  70. package/tests/fixtures/html-pages-test/publisher.json +5 -0
  71. package/tests/fixtures/html-pages-test/report.malloy +1 -0
  72. package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +92 -0
  73. package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +138 -0
  74. package/tests/integration/html_pages/html_pages.integration.spec.ts +378 -0
  75. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +421 -0
  76. package/tests/unit/duckdb/attached_databases.test.ts +111 -0
  77. package/tests/unit/duckdb/duckdb_connection.test.ts +181 -0
  78. package/tests/unit/duckdb/repositories.test.ts +208 -0
  79. package/dist/app/assets/HomePage-D9drXoZX.js +0 -1
  80. package/dist/app/assets/ModelPage-DT0gjNy1.js +0 -1
  81. package/dist/app/assets/PackagePage-N1ZBNJul.js +0 -1
  82. package/dist/app/assets/index-BeNwIeYQ.js +0 -454
  83. package/dist/app/assets/index-Dx7qi2LO.js +0 -1803
  84. package/dist/app/assets/index.umd-BXm2lnUO.js +0 -1145
@@ -174,7 +174,7 @@ describe("dropManifestEntries", () => {
174
174
  );
175
175
  });
176
176
 
177
- it("skips DROP and records an error when the dialect is unknown", async () => {
177
+ it("drops regardless of connection dialect DDL uses table names verbatim", async () => {
178
178
  const ctx2 = makeCtx("martian-sql");
179
179
  const entry = makeEntry();
180
180
 
@@ -184,10 +184,11 @@ describe("dropManifestEntries", () => {
184
184
  environmentId: "proj-1",
185
185
  });
186
186
 
187
- expect(result.dropped).toHaveLength(0);
188
- expect(result.errors).toHaveLength(1);
189
- expect(result.errors[0].error).toMatch(/martian-sql/);
190
- expect(ctx2.conn.runSQL.called).toBe(false);
187
+ // GC no longer needs a per-dialect quoter: table names are used
188
+ // verbatim, so an unrecognized dialect is no longer a special case.
189
+ expect(result.dropped).toHaveLength(1);
190
+ expect(result.errors).toHaveLength(0);
191
+ expect(ctx2.conn.runSQL.callCount).toBe(2);
191
192
  });
192
193
 
193
194
  it("dryRun lists what would drop without issuing SQL or deleting rows", async () => {
@@ -1,37 +1,8 @@
1
1
  import type { Connection } from "@malloydata/malloy";
2
- import {
3
- DatabricksDialect,
4
- DuckDBDialect,
5
- MySQLDialect,
6
- PostgresDialect,
7
- SnowflakeDialect,
8
- StandardSQLDialect,
9
- TrinoDialect,
10
- } from "@malloydata/malloy";
11
2
  import { logger } from "../logger";
12
3
  import { ManifestEntry } from "../storage/DatabaseInterface";
13
4
  import { ManifestService } from "./manifest_service";
14
5
  import { stagingSuffix } from "./materialization_service";
15
- import { type Quoter, quoteTablePath } from "./quoting";
16
-
17
- /**
18
- * Registry of built-in dialects keyed by `Connection.dialectName`. Malloy's
19
- * internal `getDialect` helper isn't part of the package's public exports,
20
- * so we assemble our own registry from the exported dialect classes.
21
- *
22
- * Note: `presto` (extends `TrinoDialect`) is not re-exported publicly and
23
- * is niche enough to omit; if/when it ships as a publisher connection type,
24
- * add it here.
25
- */
26
- const DIALECTS: Readonly<Record<string, Quoter>> = Object.freeze({
27
- duckdb: new DuckDBDialect(),
28
- standardsql: new StandardSQLDialect(),
29
- trino: new TrinoDialect(),
30
- postgres: new PostgresDialect(),
31
- snowflake: new SnowflakeDialect(),
32
- mysql: new MySQLDialect(),
33
- databricks: new DatabricksDialect(),
34
- });
35
6
 
36
7
  /** Build a stable key for a `(connectionName, tableName)` tuple. */
37
8
  export function liveTableKey(
@@ -154,19 +125,6 @@ async function processOneEntry(
154
125
  };
155
126
  }
156
127
 
157
- // ── Unknown dialect ───────────────────────────────────────────
158
- const dialect = DIALECTS[connection.dialectName];
159
- if (!dialect) {
160
- return {
161
- error: {
162
- buildId: entry.buildId,
163
- tableName: entry.tableName,
164
- connectionName: entry.connectionName,
165
- error: `No dialect registered for '${connection.dialectName}'`,
166
- },
167
- };
168
- }
169
-
170
128
  // ── Dry run ───────────────────────────────────────────────────
171
129
  if (ctx.dryRun) {
172
130
  return {
@@ -181,8 +139,6 @@ async function processOneEntry(
181
139
  }
182
140
 
183
141
  // ── Live run: delete manifest row first ───────────────────────
184
- const quoted = (p: string) => quoteTablePath(p, dialect);
185
-
186
142
  try {
187
143
  await ctx.manifestService.deleteEntry(ctx.environmentId, entry.id);
188
144
  } catch (err) {
@@ -214,9 +170,7 @@ async function processOneEntry(
214
170
  );
215
171
  } else {
216
172
  try {
217
- await connection.runSQL(
218
- `DROP TABLE IF EXISTS ${quoted(entry.tableName)}`,
219
- );
173
+ await connection.runSQL(`DROP TABLE IF EXISTS ${entry.tableName}`);
220
174
  } catch (err) {
221
175
  logger.warn(
222
176
  "GC: deleted manifest row but failed to drop materialized table (orphaned)",
@@ -231,9 +185,7 @@ async function processOneEntry(
231
185
 
232
186
  // ── Best-effort drop staging companion ────────────────────────
233
187
  try {
234
- await connection.runSQL(
235
- `DROP TABLE IF EXISTS ${quoted(stagingTableName)}`,
236
- );
188
+ await connection.runSQL(`DROP TABLE IF EXISTS ${stagingTableName}`);
237
189
  } catch (err) {
238
190
  logger.warn("GC: failed to drop staging table (best-effort)", {
239
191
  stagingTableName,
@@ -191,6 +191,84 @@ describe("service/model", () => {
191
191
 
192
192
  sinon.restore();
193
193
  });
194
+
195
+ it("embeds model-level givens in each newSources SourceInfo", async () => {
196
+ const sourceInfo = {
197
+ name: "carriers",
198
+ schema: { fields: [] },
199
+ };
200
+ const givens = [
201
+ {
202
+ name: "region",
203
+ type: "string",
204
+ annotations: ["#(doc) Region"],
205
+ },
206
+ ];
207
+ const model = new Model(
208
+ packageName,
209
+ "test.malloynb",
210
+ {},
211
+ "notebook",
212
+ undefined, // modelMaterializer
213
+ undefined, // modelDef
214
+ undefined, // sources
215
+ undefined, // queries
216
+ undefined, // sourceInfos
217
+ [
218
+ {
219
+ type: "code",
220
+ text: "import 'carriers.malloy'",
221
+ newSources: [sourceInfo],
222
+ },
223
+ ], // runnableNotebookCells
224
+ undefined, // compilationError
225
+ undefined, // filterMap
226
+ givens, // givens
227
+ );
228
+
229
+ const notebook = await model.getNotebook();
230
+ expect(notebook.notebookCells).toHaveLength(1);
231
+ const parsed = JSON.parse(
232
+ notebook.notebookCells![0].newSources![0],
233
+ );
234
+ expect(parsed.name).toBe("carriers");
235
+ // SourceInfo fields are preserved untouched.
236
+ expect(parsed.schema).toEqual({ fields: [] });
237
+ // Givens ride along verbatim — no second getModel round-trip needed.
238
+ expect(parsed.givens).toEqual(givens);
239
+ });
240
+
241
+ it("omits givens from newSources when the model declares none", async () => {
242
+ const sourceInfo = { name: "carriers", schema: { fields: [] } };
243
+ const model = new Model(
244
+ packageName,
245
+ "test.malloynb",
246
+ {},
247
+ "notebook",
248
+ undefined,
249
+ undefined,
250
+ undefined,
251
+ undefined,
252
+ undefined,
253
+ [
254
+ {
255
+ type: "code",
256
+ text: "import 'carriers.malloy'",
257
+ newSources: [sourceInfo],
258
+ },
259
+ ],
260
+ undefined,
261
+ undefined,
262
+ undefined, // no givens
263
+ );
264
+
265
+ const notebook = await model.getNotebook();
266
+ const parsed = JSON.parse(
267
+ notebook.notebookCells![0].newSources![0],
268
+ );
269
+ expect(parsed.name).toBe("carriers");
270
+ expect(parsed).not.toHaveProperty("givens");
271
+ });
194
272
  });
195
273
 
196
274
  describe("getQueryResults", () => {
@@ -239,6 +317,78 @@ describe("service/model", () => {
239
317
  sinon.restore();
240
318
  });
241
319
 
320
+ // Both caller-driven compile paths — the free-form `query` text and the
321
+ // `run: source->view` string built from `sourceName`/`queryName` — must
322
+ // go through restricted mode. The trusted `loadQuery` is reserved for
323
+ // author-curated content (notebook cells) and must never be reached from
324
+ // `getQueryResults`. These tests pin the dispatch so a regression that
325
+ // re-routes either path back to `loadQuery` is caught.
326
+ describe("compile dispatch", () => {
327
+ function buildDispatchModel(): {
328
+ model: Model;
329
+ loadQuery: sinon.SinonStub;
330
+ loadRestrictedQuery: sinon.SinonStub;
331
+ } {
332
+ // getPreparedResult rejects so execution stops right after the
333
+ // loader call; we only assert which loader was invoked.
334
+ const runnableStub = {
335
+ getPreparedResult: sinon
336
+ .stub()
337
+ .rejects(new MalloyError("stub-stop", [])),
338
+ run: sinon.stub().rejects(new MalloyError("stub-stop", [])),
339
+ };
340
+ const loadQuery = sinon.stub().returns(runnableStub);
341
+ const loadRestrictedQuery = sinon.stub().returns(runnableStub);
342
+ const modelMaterializer = { loadQuery, loadRestrictedQuery };
343
+ const model = new Model(
344
+ packageName,
345
+ mockModelPath,
346
+ {},
347
+ "model",
348
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
349
+ modelMaterializer as any,
350
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
351
+ { contents: {}, exports: [], queryList: [] } as any,
352
+ undefined,
353
+ undefined,
354
+ undefined,
355
+ undefined,
356
+ undefined,
357
+ );
358
+ return { model, loadQuery, loadRestrictedQuery };
359
+ }
360
+
361
+ afterEach(() => sinon.restore());
362
+
363
+ it("compiles ad-hoc query text in restricted mode, never trusted loadQuery", async () => {
364
+ const { model, loadQuery, loadRestrictedQuery } =
365
+ buildDispatchModel();
366
+
367
+ await expect(
368
+ model.getQueryResults(
369
+ undefined,
370
+ undefined,
371
+ "run: orders -> { aggregate: c is count() }",
372
+ ),
373
+ ).rejects.toThrow(MalloyError);
374
+
375
+ expect(loadRestrictedQuery.calledOnce).toBe(true);
376
+ expect(loadQuery.called).toBe(false);
377
+ });
378
+
379
+ it("compiles the named source/view path in restricted mode, never trusted loadQuery", async () => {
380
+ const { model, loadQuery, loadRestrictedQuery } =
381
+ buildDispatchModel();
382
+
383
+ await expect(
384
+ model.getQueryResults("orders", "summary"),
385
+ ).rejects.toThrow(MalloyError);
386
+
387
+ expect(loadRestrictedQuery.calledOnce).toBe(true);
388
+ expect(loadQuery.called).toBe(false);
389
+ });
390
+ });
391
+
242
392
  it("forwards givens to runnable.getPreparedResult and .run", async () => {
243
393
  const givensArg = { region: "EU" };
244
394
  const preparedResultStub = sinon
@@ -247,11 +397,13 @@ describe("service/model", () => {
247
397
  const runStub = sinon
248
398
  .stub()
249
399
  .rejects(new MalloyError("stub-stop", []));
400
+ const runnableStub = {
401
+ getPreparedResult: preparedResultStub,
402
+ run: runStub,
403
+ };
250
404
  const modelMaterializer = {
251
- loadQuery: sinon.stub().returns({
252
- getPreparedResult: preparedResultStub,
253
- run: runStub,
254
- }),
405
+ loadQuery: sinon.stub().returns(runnableStub),
406
+ loadRestrictedQuery: sinon.stub().returns(runnableStub),
255
407
  };
256
408
 
257
409
  const model = new Model(
@@ -347,11 +499,13 @@ describe("service/model", () => {
347
499
  typeof API.util.wrapResult
348
500
  >,
349
501
  );
502
+ const runnableStub = {
503
+ getPreparedResult: preparedResultStub,
504
+ run: runStub,
505
+ };
350
506
  const modelMaterializer = {
351
- loadQuery: sinon.stub().returns({
352
- getPreparedResult: preparedResultStub,
353
- run: runStub,
354
- }),
507
+ loadQuery: sinon.stub().returns(runnableStub),
508
+ loadRestrictedQuery: sinon.stub().returns(runnableStub),
355
509
  };
356
510
  const model = new Model(
357
511
  packageName,
@@ -529,6 +683,47 @@ describe("service/model", () => {
529
683
 
530
684
  sinon.restore();
531
685
  });
686
+
687
+ it("embeds model-level givens in executed cell newSources", async () => {
688
+ const sourceInfo = { name: "carriers", schema: { fields: [] } };
689
+ const givens = [
690
+ {
691
+ name: "region",
692
+ type: "string",
693
+ annotations: ["#(doc) Region"],
694
+ },
695
+ ];
696
+ // A source-only code cell (no runnable) still emits newSources.
697
+ const runnableCells = [
698
+ {
699
+ type: "code" as const,
700
+ text: "import 'carriers.malloy'",
701
+ newSources: [sourceInfo],
702
+ },
703
+ ];
704
+
705
+ const model = new Model(
706
+ packageName,
707
+ "test.malloynb",
708
+ {},
709
+ "notebook",
710
+ undefined, // modelMaterializer
711
+ undefined, // modelDef
712
+ undefined, // sources
713
+ undefined, // queries
714
+ undefined, // sourceInfos
715
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
716
+ runnableCells as any, // runnableNotebookCells
717
+ undefined, // compilationError
718
+ undefined, // filterMap
719
+ givens, // givens
720
+ );
721
+
722
+ const result = await model.executeNotebookCell(0);
723
+ const parsed = JSON.parse(result.newSources![0]);
724
+ expect(parsed.name).toBe("carriers");
725
+ expect(parsed.givens).toEqual(givens);
726
+ });
532
727
  });
533
728
  });
534
729