@malloy-publisher/server 0.0.203 → 0.0.204

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 (50) hide show
  1. package/dist/app/api-doc.yaml +17 -0
  2. package/dist/app/assets/{EnvironmentPage-BVQ7glKP.js → EnvironmentPage-CX06cjOF.js} +1 -1
  3. package/dist/app/assets/HomePage-CNFt_eUU.js +1 -0
  4. package/dist/app/assets/{MainPage-bYOWcgDP.js → MainPage-nUJ9YatG.js} +1 -1
  5. package/dist/app/assets/{PackagePage-N1ZBNJul.js → MaterializationsPage-B5goxVXW.js} +1 -1
  6. package/dist/app/assets/{ModelPage-DT0gjNy1.js → ModelPage-Ba7Xh4lL.js} +1 -1
  7. package/dist/app/assets/PackagePage-BaEVdEAG.js +1 -0
  8. package/dist/app/assets/{RouteError-_J-EBz7W.js → RouteError-BShQjZio.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-Bjs9Nm-_.js → WorkbookPage-CBn6ZjJW.js} +1 -1
  10. package/dist/app/assets/{core-BPLlx5VM.es-C2ARtwWI.js → core-DECXYL4E.es-OaRfXwuQ.js} +1 -1
  11. package/dist/app/assets/{index-CqUWJELr.js → index-BLfPC1gy.js} +2 -2
  12. package/dist/app/assets/index-DqiJ0bWp.js +455 -0
  13. package/dist/app/assets/index-Dy3YhAZQ.js +1812 -0
  14. package/dist/app/assets/index.umd-DAN9K8yC.js +2469 -0
  15. package/dist/app/index.html +1 -1
  16. package/dist/package_load_worker.mjs +392 -67
  17. package/dist/server.mjs +415 -152
  18. package/package.json +11 -11
  19. package/src/ducklake_version.spec.ts +43 -0
  20. package/src/ducklake_version.ts +26 -0
  21. package/src/errors.ts +18 -1
  22. package/src/package_load/package_load_pool.ts +0 -5
  23. package/src/package_load/package_load_worker.ts +41 -99
  24. package/src/package_load/protocol.ts +1 -7
  25. package/src/service/annotations.spec.ts +118 -0
  26. package/src/service/annotations.ts +91 -0
  27. package/src/service/authorize.spec.ts +132 -0
  28. package/src/service/authorize.ts +241 -0
  29. package/src/service/authorize_integration.spec.ts +838 -0
  30. package/src/service/connection.ts +1 -1
  31. package/src/service/environment.ts +4 -4
  32. package/src/service/filter.spec.ts +14 -3
  33. package/src/service/filter.ts +5 -1
  34. package/src/service/filter_bypass.spec.ts +418 -0
  35. package/src/service/given.ts +37 -12
  36. package/src/service/givens_integration.spec.ts +34 -7
  37. package/src/service/materialization_service.ts +25 -20
  38. package/src/service/materialized_table_gc.spec.ts +6 -5
  39. package/src/service/materialized_table_gc.ts +2 -50
  40. package/src/service/model.spec.ts +203 -8
  41. package/src/service/model.ts +305 -155
  42. package/src/service/package_worker_path.spec.ts +113 -0
  43. package/src/service/quoting.ts +0 -20
  44. package/src/service/restricted_mode.spec.ts +299 -0
  45. package/src/service/source_extraction.ts +226 -0
  46. package/src/storage/StorageManager.ts +73 -0
  47. package/dist/app/assets/HomePage-D9drXoZX.js +0 -1
  48. package/dist/app/assets/index-BeNwIeYQ.js +0 -454
  49. package/dist/app/assets/index-Dx7qi2LO.js +0 -1803
  50. package/dist/app/assets/index.umd-BXm2lnUO.js +0 -1145
@@ -27,7 +27,7 @@ import {
27
27
  liveTableKey,
28
28
  } from "./materialized_table_gc";
29
29
  import { Model } from "./model";
30
- import { quoteTablePath, splitTablePath } from "./quoting";
30
+ import { splitTablePath } from "./quoting";
31
31
  import { resolveEnvironmentId } from "./resolve_environment";
32
32
 
33
33
  /**
@@ -91,12 +91,17 @@ export function manifestTableKey(
91
91
  * running a zero-row SELECT. Returns `true` if the table resolves,
92
92
  * `false` if the query fails (assumed "table not found").
93
93
  */
94
+ /**
95
+ * `tableName` is interpolated verbatim into the probe SQL — the caller
96
+ * supplies it already quoted for the dialect (the `#@ persist name=…`
97
+ * contract), matching how Malloy substitutes the name on the read side.
98
+ */
94
99
  export async function tablePhysicallyExists(
95
100
  connection: MalloyConnection,
96
- quotedTableName: string,
101
+ tableName: string,
97
102
  ): Promise<boolean> {
98
103
  try {
99
- await connection.runSQL(`SELECT 1 FROM ${quotedTableName} WHERE 1=0`);
104
+ await connection.runSQL(`SELECT 1 FROM ${tableName} WHERE 1=0`);
100
105
  return true;
101
106
  } catch {
102
107
  return false;
@@ -731,7 +736,7 @@ export class MaterializationService {
731
736
 
732
737
  // getBuildPlan() throws if the tag is missing, so check first to
733
738
  // keep plain models in the same package buildable.
734
- const modelTag = malloyModel.tagParse({ prefix: /^##! / }).tag;
739
+ const modelTag = malloyModel.annotations.parseAsTag("!").tag;
735
740
  if (!modelTag.has("experimental", "persistence")) {
736
741
  logger.debug(
737
742
  "Model has no ##! experimental.persistence tag, skipping",
@@ -775,7 +780,7 @@ export class MaterializationService {
775
780
  const tableOwners = new Map<string, string>();
776
781
  for (const [sourceID, source] of Object.entries(allSources)) {
777
782
  const tableName =
778
- source.tagParse({ prefix: /^#@ / }).tag.text("name") || source.name;
783
+ source.annotations.parseAsTag("@").tag.text("name") || source.name;
779
784
  const key = `${source.connectionName}::${tableName}`;
780
785
  const existing = tableOwners.get(key);
781
786
  if (existing) {
@@ -844,12 +849,16 @@ export class MaterializationService {
844
849
 
845
850
  const connectionName = persistSource.connectionName;
846
851
  const tableName =
847
- persistSource.tagParse({ prefix: /^#@ / }).tag.text("name") ||
852
+ persistSource.annotations.parseAsTag("@").tag.text("name") ||
848
853
  persistSource.name;
849
- const { schemaPrefix, bareName } = splitTablePath(tableName);
850
- const stagingTableName = `${schemaPrefix}${bareName}${stagingSuffix(buildId)}`;
851
- const dialect = persistSource.dialect;
852
- const quoted = (p: string) => quoteTablePath(p, dialect);
854
+ const { bareName } = splitTablePath(tableName);
855
+ const stagingTableName = `${tableName}${stagingSuffix(buildId)}`;
856
+
857
+ // Table names go into DDL verbatim. Malloy assumes a table name handed
858
+ // to it (here, via the build manifest) is already quoted for the
859
+ // dialect and substitutes it into generated SQL as-is; our DDL has to
860
+ // match that exact identifier or the CREATE and the read diverge. The
861
+ // model author owns quoting the `#@ persist name=...` value.
853
862
 
854
863
  // Guard: refuse to overwrite a pre-existing table that was not
855
864
  // created by a previous materialization build. Without this check a
@@ -858,7 +867,7 @@ export class MaterializationService {
858
867
  // DROP TABLE below would silently destroy it.
859
868
  const tableKey = manifestTableKey(connectionName, tableName);
860
869
  if (!knownMaterializedTables.has(tableKey)) {
861
- if (await tablePhysicallyExists(connection, quoted(tableName))) {
870
+ if (await tablePhysicallyExists(connection, tableName)) {
862
871
  throw new BadRequestError(
863
872
  `Refusing to materialize source '${persistSource.name}': ` +
864
873
  `target table '${tableName}' already exists on connection ` +
@@ -877,26 +886,22 @@ export class MaterializationService {
877
886
 
878
887
  const startTime = performance.now();
879
888
 
880
- await connection.runSQL(
881
- `DROP TABLE IF EXISTS ${quoted(stagingTableName)}`,
882
- );
889
+ await connection.runSQL(`DROP TABLE IF EXISTS ${stagingTableName}`);
883
890
 
884
891
  // If any step after CREATE throws we must best-effort drop the
885
892
  // staging table, else it orphans under a name that GC will never
886
893
  // find (no manifest row is written for a failed build).
887
894
  try {
888
895
  await connection.runSQL(
889
- `CREATE TABLE ${quoted(stagingTableName)} AS (${buildSQL})`,
896
+ `CREATE TABLE ${stagingTableName} AS (${buildSQL})`,
890
897
  );
891
- await connection.runSQL(`DROP TABLE IF EXISTS ${quoted(tableName)}`);
898
+ await connection.runSQL(`DROP TABLE IF EXISTS ${tableName}`);
892
899
  await connection.runSQL(
893
- `ALTER TABLE ${quoted(stagingTableName)} RENAME TO ${dialect.quoteTablePath(bareName)}`,
900
+ `ALTER TABLE ${stagingTableName} RENAME TO ${bareName}`,
894
901
  );
895
902
  } catch (err) {
896
903
  try {
897
- await connection.runSQL(
898
- `DROP TABLE IF EXISTS ${quoted(stagingTableName)}`,
899
- );
904
+ await connection.runSQL(`DROP TABLE IF EXISTS ${stagingTableName}`);
900
905
  } catch (cleanupErr) {
901
906
  logger.warn(
902
907
  "Build: failed to clean up staging table after a failed rebuild; physical leak",
@@ -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