@malloy-publisher/server 0.0.204 → 0.0.206

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 (100) hide show
  1. package/build.ts +10 -1
  2. package/dist/app/api-doc.yaml +494 -397
  3. package/dist/app/assets/{EnvironmentPage-CX06cjOF.js → EnvironmentPage-BYwBeC2F.js} +1 -1
  4. package/dist/app/assets/HomePage-ivu4vdpj.js +1 -0
  5. package/dist/app/assets/{MainPage-nUJ9YatG.js → MainPage-B2DnHEDU.js} +2 -2
  6. package/dist/app/assets/MaterializationsPage-BZEuwF9P.js +1 -0
  7. package/dist/app/assets/ModelPage-Dpu3bfPg.js +1 -0
  8. package/dist/app/assets/{PackagePage-BaEVdEAG.js → PackagePage-B8PwcRHt.js} +1 -1
  9. package/dist/app/assets/{RouteError-BShQjZio.js → RouteError-BhbywAeC.js} +1 -1
  10. package/dist/app/assets/{WorkbookPage-CBn6ZjJW.js → WorkbookPage-C-JXsJG0.js} +1 -1
  11. package/dist/app/assets/{core-DECXYL4E.es-OaRfXwuQ.js → core-pPlPr7jK.es-CNEOlxKB.js} +1 -1
  12. package/dist/app/assets/{index-BLfPC1gy.js → index-BHEm8Egc.js} +1 -1
  13. package/dist/app/assets/{index-Dy3YhAZQ.js → index-BsvDrV14.js} +1 -1
  14. package/dist/app/assets/{index-DqiJ0bWp.js → index-ChR1fKR2.js} +134 -134
  15. package/dist/app/assets/{index.umd-DAN9K8yC.js → index.umd-BVLPYNuj.js} +1 -1
  16. package/dist/app/index.html +1 -1
  17. package/dist/instrumentation.mjs +18 -8
  18. package/dist/package_load_worker.mjs +19 -2
  19. package/dist/runtime/publisher.js +318 -0
  20. package/dist/server.mjs +1703 -1443
  21. package/package.json +5 -4
  22. package/scripts/bake-duckdb-extensions.js +104 -0
  23. package/src/constants.ts +12 -0
  24. package/src/controller/materialization.controller.ts +79 -35
  25. package/src/controller/package.controller.spec.ts +179 -0
  26. package/src/controller/package.controller.ts +60 -73
  27. package/src/controller/watch-mode.controller.ts +176 -46
  28. package/src/dto/package.dto.ts +16 -1
  29. package/src/errors.spec.ts +33 -0
  30. package/src/errors.ts +18 -0
  31. package/src/health.spec.ts +34 -1
  32. package/src/instrumentation.ts +33 -17
  33. package/src/materialization_metrics.ts +66 -0
  34. package/src/mcp/error_messages.spec.ts +35 -0
  35. package/src/mcp/error_messages.ts +14 -1
  36. package/src/mcp/handler_utils.ts +12 -0
  37. package/src/package_load/package_load_pool.ts +7 -1
  38. package/src/package_load/package_load_worker.ts +44 -4
  39. package/src/package_load/protocol.ts +7 -1
  40. package/src/runtime/publisher.js +318 -0
  41. package/src/server-old.ts +7 -149
  42. package/src/server.ts +488 -190
  43. package/src/service/authorize_integration.spec.ts +163 -2
  44. package/src/service/compile_authorize.spec.ts +85 -0
  45. package/src/service/environment.ts +270 -12
  46. package/src/service/environment_store.spec.ts +0 -81
  47. package/src/service/environment_store.ts +142 -34
  48. package/src/service/explore_visibility.spec.ts +434 -0
  49. package/src/service/exports_probe.spec.ts +107 -0
  50. package/src/service/manifest_loader.spec.ts +99 -0
  51. package/src/service/manifest_loader.ts +95 -0
  52. package/src/service/materialization_service.spec.ts +324 -512
  53. package/src/service/materialization_service.ts +816 -656
  54. package/src/service/model.ts +444 -13
  55. package/src/service/package.spec.ts +14 -2
  56. package/src/service/package.ts +271 -20
  57. package/src/service/package_rollback.spec.ts +190 -0
  58. package/src/service/package_worker_path.spec.ts +223 -0
  59. package/src/service/query_boundary.spec.ts +470 -0
  60. package/src/storage/DatabaseInterface.ts +35 -57
  61. package/src/storage/StorageManager.mock.ts +0 -9
  62. package/src/storage/StorageManager.ts +7 -290
  63. package/src/storage/duckdb/DuckDBConnection.ts +70 -124
  64. package/src/storage/duckdb/DuckDBRepository.ts +2 -35
  65. package/src/storage/duckdb/MaterializationRepository.ts +52 -27
  66. package/src/storage/duckdb/schema.ts +4 -20
  67. package/tests/fixtures/authorize-compile/model.malloy +9 -0
  68. package/tests/fixtures/authorize-compile/publisher.json +4 -0
  69. package/tests/fixtures/html-pages-nopublic/model.malloy +1 -0
  70. package/tests/fixtures/html-pages-nopublic/publisher.json +5 -0
  71. package/tests/fixtures/html-pages-test/data.csv +3 -0
  72. package/tests/fixtures/html-pages-test/public/assets/app.css +3 -0
  73. package/tests/fixtures/html-pages-test/public/data.json +1 -0
  74. package/tests/fixtures/html-pages-test/public/index.html +9 -0
  75. package/tests/fixtures/html-pages-test/public/sub/page2.html +9 -0
  76. package/tests/fixtures/html-pages-test/publisher.json +5 -0
  77. package/tests/fixtures/html-pages-test/report.malloy +1 -0
  78. package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +92 -0
  79. package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +138 -0
  80. package/tests/integration/html_pages/html_pages.integration.spec.ts +378 -0
  81. package/tests/integration/materialization/manifest_binding.integration.spec.ts +175 -0
  82. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +277 -266
  83. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +421 -0
  84. package/tests/unit/duckdb/attached_databases.test.ts +111 -0
  85. package/tests/unit/duckdb/duckdb_connection.test.ts +181 -0
  86. package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
  87. package/tests/unit/duckdb/repositories.test.ts +208 -0
  88. package/dist/app/assets/HomePage-CNFt_eUU.js +0 -1
  89. package/dist/app/assets/MaterializationsPage-B5goxVXW.js +0 -1
  90. package/dist/app/assets/ModelPage-Ba7Xh4lL.js +0 -1
  91. package/src/controller/manifest.controller.ts +0 -38
  92. package/src/service/manifest_service.spec.ts +0 -206
  93. package/src/service/manifest_service.ts +0 -117
  94. package/src/service/materialized_table_gc.spec.ts +0 -384
  95. package/src/service/materialized_table_gc.ts +0 -231
  96. package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
  97. package/src/storage/duckdb/ManifestRepository.ts +0 -120
  98. package/src/storage/duckdb/manifest_store.spec.ts +0 -133
  99. package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -155
  100. package/tests/unit/storage/StorageManager.test.ts +0 -166
@@ -11,12 +11,6 @@ import { EnvironmentStore } from "./environment_store";
11
11
 
12
12
  type MockData = Record<string, unknown>;
13
13
 
14
- const initializeDuckLakeCalls: Array<{
15
- environmentId: string;
16
- environmentName: string;
17
- config: { catalogUrl: string; dataPath: string };
18
- }> = [];
19
-
20
14
  mock.module("../storage/StorageManager", () => {
21
15
  return {
22
16
  StorageManager: class MockStorageManager {
@@ -24,18 +18,6 @@ mock.module("../storage/StorageManager", () => {
24
18
  return;
25
19
  }
26
20
 
27
- async initializeDuckLakeForEnvironment(
28
- environmentId: string,
29
- environmentName: string,
30
- config: { catalogUrl: string; dataPath: string },
31
- ): Promise<void> {
32
- initializeDuckLakeCalls.push({
33
- environmentId,
34
- environmentName,
35
- config,
36
- });
37
- }
38
-
39
21
  getRepository() {
40
22
  return {
41
23
  // ===== PROJECT METHODS =====
@@ -488,69 +470,6 @@ describe("EnvironmentStore Service", () => {
488
470
  expect(readmeContent).toBe("Updated README content");
489
471
  });
490
472
 
491
- it("should propagate materializationStorage on addEnvironment for new environment", async () => {
492
- writeFileSync(
493
- path.join(serverRootPath, "publisher.config.json"),
494
- JSON.stringify({ frozenConfig: false, environments: [] }),
495
- );
496
-
497
- await environmentStore.finishedInitialization;
498
-
499
- const materializationStorage = {
500
- catalogUrl:
501
- "postgres:host=localhost port=5432 dbname=ducklake user=u password=p",
502
- dataPath: "gs://test-bucket",
503
- };
504
-
505
- initializeDuckLakeCalls.length = 0;
506
- const project = await environmentStore.addEnvironment({
507
- name: projectName,
508
- materializationStorage,
509
- });
510
-
511
- expect(project.metadata.materializationStorage).toEqual(
512
- materializationStorage,
513
- );
514
- expect(initializeDuckLakeCalls).toHaveLength(1);
515
- expect(initializeDuckLakeCalls[0].config).toEqual(materializationStorage);
516
- });
517
-
518
- it("should propagate materializationStorage on update", async () => {
519
- const projectPath = path.join(serverRootPath, projectName);
520
- mkdirSync(projectPath, { recursive: true });
521
- writeFileSync(
522
- path.join(projectPath, "publisher.json"),
523
- JSON.stringify({ name: projectName, description: "Test package" }),
524
- );
525
- writeFileSync(
526
- path.join(serverRootPath, "publisher.config.json"),
527
- JSON.stringify({
528
- frozenConfig: false,
529
- environments: [
530
- {
531
- name: projectName,
532
- packages: [{ name: projectName, location: projectPath }],
533
- },
534
- ],
535
- }),
536
- );
537
-
538
- await environmentStore.finishedInitialization;
539
- const project = await environmentStore.getEnvironment(projectName);
540
-
541
- const materializationStorage = {
542
- catalogUrl:
543
- "postgres:host=localhost port=5432 dbname=ducklake user=u password=p",
544
- dataPath: "gs://test-bucket",
545
- };
546
-
547
- await project.update({ name: projectName, materializationStorage });
548
-
549
- expect(project.metadata.materializationStorage).toEqual(
550
- materializationStorage,
551
- );
552
- });
553
-
554
473
  it(
555
474
  "should handle project reload",
556
475
  async () => {
@@ -95,6 +95,26 @@ function validateEnvironmentAzureUrls(environment: ApiEnvironment): void {
95
95
  }
96
96
  }
97
97
 
98
+ // Safely clear a prior mount target before (re)mounting at `targetPath`.
99
+ // Distinguishes symlinks from real dirs: fs.rm's recursive mode could otherwise
100
+ // traverse into a symlinked source and damage it, so unlink symlinks and rm real
101
+ // dirs. Both the in-place (symlink) and copy mount paths call this, so toggling
102
+ // watch mode across runs against the same publisher_data can't leave a stale
103
+ // entry that breaks the next mount (mkdir is a no-op on a symlink and fs.cp then
104
+ // throws ERR_FS_CP_DIR_TO_NON_DIR).
105
+ async function clearMountTarget(targetPath: string): Promise<void> {
106
+ try {
107
+ const stats = await fs.promises.lstat(targetPath);
108
+ if (stats.isDirectory() && !stats.isSymbolicLink()) {
109
+ await fs.promises.rm(targetPath, { recursive: true, force: true });
110
+ } else {
111
+ await fs.promises.unlink(targetPath);
112
+ }
113
+ } catch {
114
+ // Nothing there, fine.
115
+ }
116
+ }
117
+
98
118
  export class EnvironmentStore {
99
119
  public serverRootPath: string;
100
120
  private environments: Map<string, Environment> = new Map();
@@ -112,10 +132,36 @@ export class EnvironmentStore {
112
132
  // new Environments pick it up at construction.
113
133
  private memoryGovernor: PackageMemoryGovernor | null = null;
114
134
 
135
+ /**
136
+ * Set of environment names that should be loaded "in place" — i.e. the
137
+ * package directories under `publisher_data/<envName>/` are symlinks to
138
+ * the original local source directories instead of recursive copies.
139
+ *
140
+ * In-place mode powers the `npm run dev`-style live-reload story: edits
141
+ * to your source repo are visible to Publisher and trigger watch events
142
+ * that fan out via SSE to embedded HTML pages. Production mode (the
143
+ * default) keeps the safe copy semantics so the source repo and the
144
+ * served package are decoupled.
145
+ *
146
+ * Populated from the `PUBLISHER_WATCH` env var (comma-separated env
147
+ * names) at construction; can also be augmented via `markInPlace()` if
148
+ * a future feature wants to flip an environment to dev mode at runtime.
149
+ *
150
+ * Only LOCAL-DIR package locations are eligible for symlinking; remote
151
+ * sources (GitHub, GCS, S3) always copy regardless.
152
+ */
153
+ private inPlaceEnvs = new Set<string>();
154
+
115
155
  constructor(serverRootPath: string) {
116
156
  this.serverRootPath = serverRootPath;
117
157
  this.gcsClient = new Storage();
118
158
 
159
+ const watchEnvList = (process.env.PUBLISHER_WATCH || "")
160
+ .split(",")
161
+ .map((s) => s.trim())
162
+ .filter((s) => s.length > 0);
163
+ for (const envName of watchEnvList) this.inPlaceEnvs.add(envName);
164
+
119
165
  const storageConfig: StorageConfig = {
120
166
  type: "duckdb",
121
167
  duckdb: {
@@ -127,6 +173,17 @@ export class EnvironmentStore {
127
173
  this.finishedInitialization = this.initialize();
128
174
  }
129
175
 
176
+ /** True if this env should mount package dirs in place (symlinks). */
177
+ public isInPlace(environmentName: string): boolean {
178
+ return this.inPlaceEnvs.has(environmentName);
179
+ }
180
+
181
+ /** Add an env to in-place mode at runtime. Caller is responsible for
182
+ * triggering a re-mount if the env was already loaded with copies. */
183
+ public markInPlace(environmentName: string): void {
184
+ this.inPlaceEnvs.add(environmentName);
185
+ }
186
+
130
187
  /**
131
188
  * Attach (or detach with `null`) the shared {@link PackageMemoryGovernor}.
132
189
  * Propagated to every Environment so the back-pressure decision is
@@ -395,25 +452,6 @@ export class EnvironmentStore {
395
452
  dbEnvironment = await repository.createEnvironment(environmentData);
396
453
  }
397
454
 
398
- // Initialize DuckLake manifest storage if configured on the environment.
399
- const materializationStorage = environment.metadata
400
- ?.materializationStorage as
401
- | { catalogUrl?: string; dataPath?: string }
402
- | undefined;
403
- if (
404
- materializationStorage?.catalogUrl &&
405
- materializationStorage?.dataPath
406
- ) {
407
- await this.storageManager.initializeDuckLakeForEnvironment(
408
- dbEnvironment.id,
409
- dbEnvironment.name,
410
- {
411
- catalogUrl: materializationStorage.catalogUrl,
412
- dataPath: materializationStorage.dataPath,
413
- },
414
- );
415
- }
416
-
417
455
  return dbEnvironment;
418
456
  }
419
457
 
@@ -881,10 +919,6 @@ export class EnvironmentStore {
881
919
 
882
920
  if (!newEnvironment.metadata) newEnvironment.metadata = {};
883
921
  newEnvironment.metadata.location = absoluteEnvironmentPath;
884
- if (environment.materializationStorage !== undefined) {
885
- newEnvironment.metadata.materializationStorage =
886
- environment.materializationStorage;
887
- }
888
922
 
889
923
  this.environments.set(environmentName, newEnvironment);
890
924
 
@@ -1202,7 +1236,15 @@ export class EnvironmentStore {
1202
1236
  } else {
1203
1237
  // For non-GitHub locations, use package name
1204
1238
  if (this.isLocalPath(_package.location)) {
1205
- sourcePath = _package.location;
1239
+ // Match the resolution rule used by
1240
+ // `downloadOrMountLocation` (line ~1352): relative
1241
+ // paths are anchored at `serverRootPath`. Without this
1242
+ // step the existing-source check below falls through
1243
+ // for any relative location, and the in-place mount
1244
+ // branch is unreachable.
1245
+ sourcePath = path.isAbsolute(_package.location)
1246
+ ? _package.location
1247
+ : path.join(this.serverRootPath, _package.location);
1206
1248
  } else {
1207
1249
  sourcePath = safeJoinUnderRoot(
1208
1250
  tempDownloadPath,
@@ -1217,18 +1259,84 @@ export class EnvironmentStore {
1217
1259
  .catch(() => false);
1218
1260
 
1219
1261
  if (sourceExists) {
1220
- // Copy the specific directory
1221
- await fs.promises.mkdir(absolutePackagePath, {
1222
- recursive: true,
1223
- });
1224
- await fs.promises.cp(sourcePath, absolutePackagePath, {
1225
- recursive: true,
1226
- });
1227
- logger.info(
1228
- `Extracted package "${packageDir}" from ${groupedLocation.startsWith("https://github.com/") && _package.location.includes("/tree/") ? "GitHub subdirectory" : "shared download"}`,
1229
- );
1262
+ // In-place mount path (watch-mode-only, local-dir packages
1263
+ // only). Replace the recursive copy with a symlink so that
1264
+ // edits to the source repo are visible to Publisher and
1265
+ // can trigger live-reload events.
1266
+ //
1267
+ // Carefully: we MUST distinguish symlinks from real dirs
1268
+ // when removing the prior content, because `fs.rm`'s
1269
+ // recursive mode could in principle traverse into a
1270
+ // symlinked source dir and damage it. We lstat first and
1271
+ // call `unlink` for symlinks, `rm` for real directories.
1272
+ const isInPlace =
1273
+ this.inPlaceEnvs.has(environmentName) &&
1274
+ this.isLocalPath(_package.location);
1275
+ if (isInPlace) {
1276
+ await clearMountTarget(absolutePackagePath);
1277
+ const absoluteSourcePath = path.resolve(sourcePath);
1278
+ // On Windows a "dir" symlink needs elevation
1279
+ // (SeCreateSymbolicLinkPrivilege — admin or Developer
1280
+ // Mode); a junction does not and works for absolute
1281
+ // directory targets, so prefer it there. chokidar watches
1282
+ // through both.
1283
+ const linkType =
1284
+ process.platform === "win32" ? "junction" : "dir";
1285
+ try {
1286
+ await fs.promises.symlink(
1287
+ absoluteSourcePath,
1288
+ absolutePackagePath,
1289
+ linkType,
1290
+ );
1291
+ logger.info(
1292
+ `In-place mount (watch mode): linked package "${packageDir}" -> "${absoluteSourcePath}"`,
1293
+ );
1294
+ } catch (linkError) {
1295
+ // Degrade gracefully instead of failing the env load:
1296
+ // copy the package so it still serves. Live reload
1297
+ // won't work for it (source edits aren't seen), but the
1298
+ // environment comes up. Hit e.g. on locked-down Windows
1299
+ // where even junction creation is denied.
1300
+ const code =
1301
+ (linkError as NodeJS.ErrnoException)?.code ??
1302
+ String(linkError);
1303
+ logger.warn(
1304
+ `In-place mount failed for package "${packageDir}" (${code}); falling back to a copy. Source-edit live reload is disabled for this package.`,
1305
+ );
1306
+ await clearMountTarget(absolutePackagePath);
1307
+ await fs.promises.mkdir(absolutePackagePath, {
1308
+ recursive: true,
1309
+ });
1310
+ await fs.promises.cp(sourcePath, absolutePackagePath, {
1311
+ recursive: true,
1312
+ });
1313
+ }
1314
+ } else {
1315
+ if (
1316
+ this.inPlaceEnvs.has(environmentName) &&
1317
+ !this.isLocalPath(_package.location)
1318
+ ) {
1319
+ logger.warn(
1320
+ `Watch mode: package "${packageDir}" has remote location "${_package.location}" — falling back to copy. Source-edit live reload won't work for this package; clone the source locally and use a local-dir location to enable it.`,
1321
+ );
1322
+ }
1323
+ // Copy the specific directory. Clear any stale mount target
1324
+ // first (e.g. a symlink left by a prior --watch-env run), or
1325
+ // fs.cp would throw ERR_FS_CP_DIR_TO_NON_DIR onto it.
1326
+ await clearMountTarget(absolutePackagePath);
1327
+ await fs.promises.mkdir(absolutePackagePath, {
1328
+ recursive: true,
1329
+ });
1330
+ await fs.promises.cp(sourcePath, absolutePackagePath, {
1331
+ recursive: true,
1332
+ });
1333
+ logger.info(
1334
+ `Extracted package "${packageDir}" from ${groupedLocation.startsWith("https://github.com/") && _package.location.includes("/tree/") ? "GitHub subdirectory" : "shared download"}`,
1335
+ );
1336
+ }
1230
1337
  } else {
1231
1338
  // If source doesn't exist, copy the entire download as the package
1339
+ await clearMountTarget(absolutePackagePath);
1232
1340
  await fs.promises.mkdir(absolutePackagePath, {
1233
1341
  recursive: true,
1234
1342
  });