@malloy-publisher/server 0.0.195 → 0.0.196

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 (104) hide show
  1. package/dist/app/api-doc.yaml +213 -214
  2. package/dist/app/assets/EnvironmentPage-1j6QDWAy.js +1 -0
  3. package/dist/app/assets/HomePage-DMop21VG.js +1 -0
  4. package/dist/app/assets/MainPage-BbE8ETz1.js +2 -0
  5. package/dist/app/assets/ModelPage-D2jvfe3t.js +1 -0
  6. package/dist/app/assets/PackagePage-BbnhGoD3.js +1 -0
  7. package/dist/app/assets/{RouteError-DefbDO7F.js → RouteError-D3LGEZ3i.js} +1 -1
  8. package/dist/app/assets/WorkbookPage-DttVIj4u.js +1 -0
  9. package/dist/app/assets/{core-BrfQApxh.es-DnvCX4oH.js → core-w79IMXAG.es-Bd0UlzOL.js} +1 -1
  10. package/dist/app/assets/{index-Bu0ub036.js → index-5K9YjIxF.js} +117 -117
  11. package/dist/app/assets/{index-CkzK3JIl.js → index-C513UodQ.js} +1 -1
  12. package/dist/app/assets/{index-CoA6HIGS.js → index-DIgzgp69.js} +1 -1
  13. package/dist/app/assets/{index.umd-B6Ms2PpL.js → index.umd-BMeMPq_9.js} +1 -1
  14. package/dist/app/index.html +1 -1
  15. package/dist/server.mjs +1976 -1322
  16. package/package.json +2 -2
  17. package/publisher.config.json +2 -2
  18. package/src/config.spec.ts +181 -66
  19. package/src/config.ts +68 -47
  20. package/src/controller/compile.controller.ts +10 -7
  21. package/src/controller/connection.controller.ts +79 -58
  22. package/src/controller/database.controller.ts +10 -7
  23. package/src/controller/manifest.controller.ts +23 -14
  24. package/src/controller/materialization.controller.ts +14 -14
  25. package/src/controller/model.controller.ts +35 -20
  26. package/src/controller/package.controller.ts +83 -49
  27. package/src/controller/query.controller.ts +11 -8
  28. package/src/controller/watch-mode.controller.ts +35 -29
  29. package/src/errors.ts +2 -2
  30. package/src/mcp/error_messages.ts +2 -2
  31. package/src/mcp/handler_utils.ts +23 -20
  32. package/src/mcp/mcp_constants.ts +1 -1
  33. package/src/mcp/prompts/handlers.ts +3 -3
  34. package/src/mcp/prompts/prompt_service.ts +5 -5
  35. package/src/mcp/prompts/utils.ts +12 -12
  36. package/src/mcp/resource_metadata.ts +3 -3
  37. package/src/mcp/resources/environment_resource.ts +187 -0
  38. package/src/mcp/resources/model_resource.ts +19 -17
  39. package/src/mcp/resources/notebook_resource.ts +13 -13
  40. package/src/mcp/resources/package_resource.ts +30 -27
  41. package/src/mcp/resources/query_resource.ts +15 -10
  42. package/src/mcp/resources/source_resource.ts +10 -10
  43. package/src/mcp/resources/view_resource.ts +11 -11
  44. package/src/mcp/server.ts +16 -14
  45. package/src/mcp/tools/discovery_tools.ts +67 -49
  46. package/src/mcp/tools/execute_query_tool.ts +14 -14
  47. package/src/server-old.ts +1119 -0
  48. package/src/server.ts +191 -159
  49. package/src/service/connection.spec.ts +158 -133
  50. package/src/service/connection.ts +42 -39
  51. package/src/service/connection_config.spec.ts +13 -11
  52. package/src/service/connection_config.ts +28 -19
  53. package/src/service/connection_service.spec.ts +63 -43
  54. package/src/service/connection_service.ts +106 -89
  55. package/src/service/{project.ts → environment.ts} +92 -77
  56. package/src/service/{project_compile.spec.ts → environment_compile.spec.ts} +1 -1
  57. package/src/service/{project_store.spec.ts → environment_store.spec.ts} +99 -83
  58. package/src/service/{project_store.ts → environment_store.ts} +373 -327
  59. package/src/service/manifest_service.spec.ts +15 -15
  60. package/src/service/manifest_service.ts +26 -21
  61. package/src/service/materialization_service.spec.ts +93 -59
  62. package/src/service/materialization_service.ts +71 -62
  63. package/src/service/materialized_table_gc.spec.ts +15 -15
  64. package/src/service/materialized_table_gc.ts +3 -3
  65. package/src/service/model.ts +4 -4
  66. package/src/service/package.spec.ts +2 -2
  67. package/src/service/package.ts +23 -21
  68. package/src/service/resolve_environment.ts +15 -0
  69. package/src/storage/DatabaseInterface.ts +34 -25
  70. package/src/storage/StorageManager.mock.ts +3 -3
  71. package/src/storage/StorageManager.ts +64 -28
  72. package/src/storage/duckdb/ConnectionRepository.ts +13 -11
  73. package/src/storage/duckdb/DuckDBConnection.ts +1 -1
  74. package/src/storage/duckdb/DuckDBManifestStore.ts +6 -6
  75. package/src/storage/duckdb/DuckDBRepository.ts +47 -47
  76. package/src/storage/duckdb/{ProjectRepository.ts → EnvironmentRepository.ts} +35 -35
  77. package/src/storage/duckdb/ManifestRepository.ts +21 -20
  78. package/src/storage/duckdb/MaterializationRepository.ts +31 -28
  79. package/src/storage/duckdb/PackageRepository.ts +11 -11
  80. package/src/storage/duckdb/manifest_store.spec.ts +2 -2
  81. package/src/storage/duckdb/schema.ts +61 -20
  82. package/src/storage/ducklake/DuckLakeManifestStore.ts +20 -11
  83. package/tests/fixtures/publisher.config.json +1 -1
  84. package/tests/harness/e2e.ts +1 -1
  85. package/tests/harness/mcp_test_setup.ts +12 -24
  86. package/tests/harness/mocks.ts +10 -8
  87. package/tests/harness/rest_e2e.ts +2 -2
  88. package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +259 -0
  89. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +4 -4
  90. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +28 -49
  91. package/tests/integration/mcp/mcp_resource.integration.spec.ts +39 -47
  92. package/tests/integration/mcp/mcp_transport.integration.spec.ts +1 -1
  93. package/tests/unit/duckdb/attached_databases.test.ts +51 -33
  94. package/tests/unit/duckdb/legacy_schema_migration.test.ts +194 -0
  95. package/tests/unit/ducklake/ducklake.test.ts +24 -22
  96. package/tests/unit/mcp/prompt_happy.test.ts +8 -8
  97. package/dist/app/assets/HomePage-DbZS0N7G.js +0 -1
  98. package/dist/app/assets/MainPage-CBuWkbmr.js +0 -2
  99. package/dist/app/assets/ModelPage-Bt37smot.js +0 -1
  100. package/dist/app/assets/PackagePage-DLZe50WG.js +0 -1
  101. package/dist/app/assets/ProjectPage-FQTEPXP4.js +0 -1
  102. package/dist/app/assets/WorkbookPage-CkAo16ar.js +0 -1
  103. package/src/mcp/resources/project_resource.ts +0 -184
  104. package/src/service/resolve_project.ts +0 -13
@@ -12,10 +12,10 @@ export class PackageRepository {
12
12
  return new Date();
13
13
  }
14
14
 
15
- async listPackages(projectId: string): Promise<Package[]> {
15
+ async listPackages(environmentId: string): Promise<Package[]> {
16
16
  const rows = await this.db.all<Record<string, unknown>>(
17
- "SELECT * FROM packages WHERE project_id = ? ORDER BY name",
18
- [projectId],
17
+ "SELECT * FROM packages WHERE environment_id = ? ORDER BY name",
18
+ [environmentId],
19
19
  );
20
20
  return rows.map(this.mapToPackage);
21
21
  }
@@ -29,12 +29,12 @@ export class PackageRepository {
29
29
  }
30
30
 
31
31
  async getPackageByName(
32
- projectId: string,
32
+ environmentId: string,
33
33
  name: string,
34
34
  ): Promise<Package | null> {
35
35
  const row = await this.db.get<Record<string, unknown>>(
36
- "SELECT * FROM packages WHERE project_id = ? AND name = ?",
37
- [projectId, name],
36
+ "SELECT * FROM packages WHERE environment_id = ? AND name = ?",
37
+ [environmentId, name],
38
38
  );
39
39
  return row ? this.mapToPackage(row) : null;
40
40
  }
@@ -46,11 +46,11 @@ export class PackageRepository {
46
46
  const now = this.now();
47
47
 
48
48
  await this.db.run(
49
- `INSERT INTO packages (id, project_id, name, description, manifest_path, metadata, created_at, updated_at)
49
+ `INSERT INTO packages (id, environment_id, name, description, manifest_path, metadata, created_at, updated_at)
50
50
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
51
51
  [
52
52
  id,
53
- pkg.projectId,
53
+ pkg.environmentId,
54
54
  pkg.name,
55
55
  pkg.description || null,
56
56
  pkg.manifestPath,
@@ -114,14 +114,14 @@ export class PackageRepository {
114
114
  await this.db.run("DELETE FROM packages WHERE id = ?", [id]);
115
115
  }
116
116
 
117
- async deletePackagesByProjectId(id: string): Promise<void> {
118
- await this.db.run("DELETE FROM packages WHERE project_id = ?", [id]);
117
+ async deletePackagesByEnvironmentId(id: string): Promise<void> {
118
+ await this.db.run("DELETE FROM packages WHERE environment_id = ?", [id]);
119
119
  }
120
120
 
121
121
  private mapToPackage(row: Record<string, unknown>): Package {
122
122
  return {
123
123
  id: row.id as string,
124
- projectId: row.project_id as string,
124
+ environmentId: row.environment_id as string,
125
125
  name: row.name as string,
126
126
  description: row.description as string | undefined,
127
127
  manifestPath: row.manifest_path as string,
@@ -6,7 +6,7 @@ import { DuckDBManifestStore } from "./DuckDBManifestStore";
6
6
  function makeEntry(overrides: Partial<ManifestEntry> = {}): ManifestEntry {
7
7
  return {
8
8
  id: "entry-1",
9
- projectId: "proj-1",
9
+ environmentId: "proj-1",
10
10
  packageName: "pkg",
11
11
  buildId: "build-abc",
12
12
  tableName: "my_table",
@@ -83,7 +83,7 @@ describe("DuckDBManifestStore", () => {
83
83
  expect(ctx.repository.upsertManifestEntry.calledOnce).toBe(true);
84
84
  const arg = ctx.repository.upsertManifestEntry.firstCall.args[0];
85
85
  expect(arg).toEqual({
86
- projectId: "proj-1",
86
+ environmentId: "proj-1",
87
87
  packageName: "pkg",
88
88
  buildId: "build-abc",
89
89
  tableName: "tbl",
@@ -17,12 +17,21 @@ export async function initializeSchema(
17
17
  );
18
18
  await dropAllTables(db);
19
19
  } else {
20
+ // TODO: Remove this during projects cleanup
21
+ // If a pre-rename `projects` schema is on disk, the new
22
+ // CREATE TABLE IF NOT EXISTS pass below would silently leave child
23
+ // tables on the old `project_id` column and the first query against
24
+ // `environment_id` would crash. Drop the legacy tables (with a loud
25
+ // warning) so the fresh schema can be created cleanly. This is
26
+ // destructive — operators upgrading should re-create their environments
27
+ // and packages via the API after the upgrade.
28
+ await dropLegacyProjectSchema(db);
20
29
  logger.info("Creating database schema for the first time...");
21
30
  }
22
31
 
23
- // Projects table
32
+ // Environments table
24
33
  await db.run(`
25
- CREATE TABLE IF NOT EXISTS projects (
34
+ CREATE TABLE IF NOT EXISTS environments (
26
35
  id VARCHAR PRIMARY KEY,
27
36
  name VARCHAR NOT NULL UNIQUE,
28
37
  path VARCHAR NOT NULL,
@@ -37,15 +46,15 @@ export async function initializeSchema(
37
46
  await db.run(`
38
47
  CREATE TABLE IF NOT EXISTS packages (
39
48
  id VARCHAR PRIMARY KEY,
40
- project_id VARCHAR NOT NULL,
49
+ environment_id VARCHAR NOT NULL,
41
50
  name VARCHAR NOT NULL,
42
51
  description VARCHAR,
43
52
  manifest_path VARCHAR NOT NULL,
44
53
  metadata JSON,
45
54
  created_at TIMESTAMP NOT NULL,
46
55
  updated_at TIMESTAMP NOT NULL,
47
- FOREIGN KEY (project_id) REFERENCES projects(id),
48
- UNIQUE (project_id, name)
56
+ FOREIGN KEY (environment_id) REFERENCES environments(id),
57
+ UNIQUE (environment_id, name)
49
58
  )
50
59
  `);
51
60
 
@@ -53,22 +62,22 @@ export async function initializeSchema(
53
62
  await db.run(`
54
63
  CREATE TABLE IF NOT EXISTS connections (
55
64
  id VARCHAR PRIMARY KEY,
56
- project_id VARCHAR NOT NULL,
65
+ environment_id VARCHAR NOT NULL,
57
66
  name VARCHAR NOT NULL,
58
67
  type VARCHAR NOT NULL,
59
68
  config JSON NOT NULL,
60
69
  created_at TIMESTAMP NOT NULL,
61
70
  updated_at TIMESTAMP NOT NULL,
62
- FOREIGN KEY (project_id) REFERENCES projects(id),
63
- UNIQUE (project_id, name)
71
+ FOREIGN KEY (environment_id) REFERENCES environments(id),
72
+ UNIQUE (environment_id, name)
64
73
  )
65
74
  `);
66
75
 
67
76
  // Materializations table.
68
77
  //
69
78
  // `active_key` enforces at-most-one active (PENDING or RUNNING)
70
- // materialization per (project, package) at the DB layer. It is set to
71
- // `{project_id}|{package_name}` while the row is active and cleared
79
+ // materialization per (environment, package) at the DB layer. It is set to
80
+ // `{environment_id}|{package_name}` while the row is active and cleared
72
81
  // to NULL on transition to any terminal state. A unique index on
73
82
  // `active_key` (see below) makes the insert-then-check race impossible —
74
83
  // a second concurrent create fails with a constraint violation, which the
@@ -76,7 +85,7 @@ export async function initializeSchema(
76
85
  await db.run(`
77
86
  CREATE TABLE IF NOT EXISTS materializations (
78
87
  id VARCHAR PRIMARY KEY,
79
- project_id VARCHAR NOT NULL,
88
+ environment_id VARCHAR NOT NULL,
80
89
  package_name VARCHAR NOT NULL,
81
90
  status VARCHAR NOT NULL,
82
91
  active_key VARCHAR,
@@ -86,7 +95,7 @@ export async function initializeSchema(
86
95
  metadata JSON,
87
96
  created_at TIMESTAMP NOT NULL,
88
97
  updated_at TIMESTAMP NOT NULL,
89
- FOREIGN KEY (project_id) REFERENCES projects(id)
98
+ FOREIGN KEY (environment_id) REFERENCES environments(id)
90
99
  )
91
100
  `);
92
101
 
@@ -94,7 +103,7 @@ export async function initializeSchema(
94
103
  await db.run(`
95
104
  CREATE TABLE IF NOT EXISTS build_manifests (
96
105
  id VARCHAR PRIMARY KEY,
97
- project_id VARCHAR NOT NULL,
106
+ environment_id VARCHAR NOT NULL,
98
107
  package_name VARCHAR NOT NULL,
99
108
  build_id VARCHAR NOT NULL,
100
109
  table_name VARCHAR NOT NULL,
@@ -102,36 +111,68 @@ export async function initializeSchema(
102
111
  connection_name VARCHAR NOT NULL,
103
112
  created_at TIMESTAMP NOT NULL,
104
113
  updated_at TIMESTAMP NOT NULL,
105
- FOREIGN KEY (project_id) REFERENCES projects(id),
106
- UNIQUE (project_id, package_name, build_id)
114
+ FOREIGN KEY (environment_id) REFERENCES environments(id),
115
+ UNIQUE (environment_id, package_name, build_id)
107
116
  )
108
117
  `);
109
118
 
110
119
  // Create indexes for better query performance
111
120
  await db.run(
112
- "CREATE INDEX IF NOT EXISTS idx_packages_project_id ON packages(project_id)",
121
+ "CREATE INDEX IF NOT EXISTS idx_packages_environment_id ON packages(environment_id)",
113
122
  );
114
123
  await db.run(
115
- "CREATE INDEX IF NOT EXISTS idx_connections_project_id ON connections(project_id)",
124
+ "CREATE INDEX IF NOT EXISTS idx_connections_environment_id ON connections(environment_id)",
116
125
  );
117
126
  await db.run(
118
- "CREATE INDEX IF NOT EXISTS idx_materializations_project_package ON materializations(project_id, package_name)",
127
+ "CREATE INDEX IF NOT EXISTS idx_materializations_environment_package ON materializations(environment_id, package_name)",
119
128
  );
120
129
  await db.run(
121
130
  "CREATE UNIQUE INDEX IF NOT EXISTS idx_materializations_active_key ON materializations(active_key)",
122
131
  );
123
132
  await db.run(
124
- "CREATE INDEX IF NOT EXISTS idx_build_manifests_project_package ON build_manifests(project_id, package_name)",
133
+ "CREATE INDEX IF NOT EXISTS idx_build_manifests_environment_package ON build_manifests(environment_id, package_name)",
125
134
  );
126
135
  }
127
136
 
137
+ // TODO: Remove this during projects cleanup
138
+ // Tables in the pre-rename schema, listed children-first so DROP order
139
+ // satisfies foreign-key dependencies on the legacy `projects` table.
140
+ const LEGACY_TABLES_DROP_ORDER = [
141
+ "build_manifests",
142
+ "materializations",
143
+ "packages",
144
+ "connections",
145
+ "projects",
146
+ ] as const;
147
+
148
+ async function dropLegacyProjectSchema(db: DuckDBConnection): Promise<void> {
149
+ const legacy = await db.all<{ name: string }>(
150
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='projects'",
151
+ );
152
+ if (!legacy || legacy.length === 0) {
153
+ return;
154
+ }
155
+
156
+ logger.warn(
157
+ "Detected legacy 'projects' schema. Dropping legacy tables; existing environments/packages/connections/materializations data will be lost. Re-create them via the API after upgrade.",
158
+ );
159
+
160
+ for (const table of LEGACY_TABLES_DROP_ORDER) {
161
+ try {
162
+ await db.run(`DROP TABLE IF EXISTS ${table}`);
163
+ } catch (err) {
164
+ logger.warn(`Failed to drop legacy table ${table}:`, err);
165
+ }
166
+ }
167
+ }
168
+
128
169
  async function dropAllTables(db: DuckDBConnection): Promise<void> {
129
170
  const tables = [
130
171
  "build_manifests",
131
172
  "materializations",
132
173
  "packages",
133
174
  "connections",
134
- "projects",
175
+ "environments",
135
176
  ];
136
177
 
137
178
  logger.info("Dropping tables:", tables.join(", "));
@@ -31,23 +31,32 @@ import { DuckDBConnection } from "../duckdb/DuckDBConnection";
31
31
  */
32
32
  export class DuckLakeManifestStore implements ManifestStore {
33
33
  private readonly table: string;
34
+ private readonly environmentName: string;
34
35
 
35
36
  constructor(
36
37
  private db: DuckDBConnection,
37
38
  catalogName: string,
39
+ environmentName: string,
38
40
  ) {
39
41
  this.table = `${catalogName}.build_manifests`;
42
+ this.environmentName = environmentName;
40
43
  }
41
44
 
42
45
  /**
43
46
  * Idempotently creates the `build_manifests` table and indices in the
44
47
  * DuckLake catalog. Safe to call from every worker on startup.
48
+ *
49
+ * Note: this table uses `project_name` for the partition column, unlike
50
+ * the local DuckDB `build_manifests` table (in `storage/duckdb/schema.ts`)
51
+ * which uses `project_id` and FK-references `projects.id`. The two stores
52
+ * partition by different identifiers — local random id vs cross-pod-stable
53
+ * project name — so they intentionally diverge here.
45
54
  */
46
55
  async bootstrapSchema(): Promise<void> {
47
56
  await this.db.run(`
48
57
  CREATE TABLE IF NOT EXISTS ${this.table} (
49
58
  id VARCHAR,
50
- project_id VARCHAR NOT NULL,
59
+ environment_name VARCHAR NOT NULL,
51
60
  package_name VARCHAR NOT NULL,
52
61
  build_id VARCHAR NOT NULL,
53
62
  table_name VARCHAR NOT NULL,
@@ -61,12 +70,12 @@ export class DuckLakeManifestStore implements ManifestStore {
61
70
  }
62
71
 
63
72
  async getManifest(
64
- projectId: string,
73
+ _environmentId: string,
65
74
  packageName: string,
66
75
  ): Promise<BuildManifest> {
67
76
  const rows = await this.db.all<Record<string, unknown>>(
68
- `SELECT * FROM ${this.table} WHERE project_id = ? AND package_name = ? ORDER BY created_at DESC`,
69
- [projectId, packageName],
77
+ `SELECT * FROM ${this.table} WHERE environment_name = ? AND package_name = ? ORDER BY created_at DESC`,
78
+ [this.environmentName, packageName],
70
79
  );
71
80
  const manifest: BuildManifest = { entries: {}, strict: false };
72
81
  for (const row of rows) {
@@ -88,7 +97,7 @@ export class DuckLakeManifestStore implements ManifestStore {
88
97
  * {@link getManifest} deduplicates by build_id keeping the newest row.
89
98
  */
90
99
  async writeEntry(
91
- projectId: string,
100
+ _environmentId: string,
92
101
  packageName: string,
93
102
  buildId: string,
94
103
  tableName: string,
@@ -99,11 +108,11 @@ export class DuckLakeManifestStore implements ManifestStore {
99
108
  const id = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
100
109
 
101
110
  await this.db.run(
102
- `INSERT INTO ${this.table} (id, project_id, package_name, build_id, table_name, source_name, connection_name, created_at, updated_at)
111
+ `INSERT INTO ${this.table} (id, environment_name, package_name, build_id, table_name, source_name, connection_name, created_at, updated_at)
103
112
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
104
113
  [
105
114
  id,
106
- projectId,
115
+ this.environmentName,
107
116
  packageName,
108
117
  buildId,
109
118
  tableName,
@@ -120,12 +129,12 @@ export class DuckLakeManifestStore implements ManifestStore {
120
129
  }
121
130
 
122
131
  async listEntries(
123
- projectId: string,
132
+ _environmentId: string,
124
133
  packageName: string,
125
134
  ): Promise<ManifestEntry[]> {
126
135
  const rows = await this.db.all<Record<string, unknown>>(
127
- `SELECT * FROM ${this.table} WHERE project_id = ? AND package_name = ? ORDER BY created_at DESC`,
128
- [projectId, packageName],
136
+ `SELECT * FROM ${this.table} WHERE environment_name = ? AND package_name = ? ORDER BY created_at DESC`,
137
+ [this.environmentName, packageName],
129
138
  );
130
139
  return rows.map(this.mapToEntry);
131
140
  }
@@ -133,7 +142,7 @@ export class DuckLakeManifestStore implements ManifestStore {
133
142
  private mapToEntry(row: Record<string, unknown>): ManifestEntry {
134
143
  return {
135
144
  id: row.id as string,
136
- projectId: row.project_id as string,
145
+ environmentId: row.environment_name as string,
137
146
  packageName: row.package_name as string,
138
147
  buildId: row.build_id as string,
139
148
  tableName: row.table_name as string,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "frozenConfig": false,
3
- "projects": [
3
+ "environments": [
4
4
  {
5
5
  "name": "test-project",
6
6
  "packages": [
@@ -28,7 +28,7 @@ let originalServerRoot: string | undefined;
28
28
  */
29
29
  export async function startE2E(): Promise<E2EEnv & { stop(): Promise<void> }> {
30
30
  //--------------------------------------------------------------------------
31
- // 1. Set SERVER_ROOT so ProjectStore loader finds publisher.config.json
31
+ // 1. Set SERVER_ROOT so EnvironmentStore loader finds publisher.config.json
32
32
  //--------------------------------------------------------------------------
33
33
  originalServerRoot = process.env.SERVER_ROOT;
34
34
  // Use import.meta.url for cross-platform compatibility (works on Windows)
@@ -5,6 +5,7 @@ import {
5
5
  Request,
6
6
  Result,
7
7
  } from "@modelcontextprotocol/sdk/types.js";
8
+ import { Mutex } from "async-mutex";
8
9
  import http from "http";
9
10
  import { AddressInfo } from "net";
10
11
  import path from "path";
@@ -24,42 +25,29 @@ export interface McpE2ETestEnvironment {
24
25
  originalInitializeStorage: string | undefined;
25
26
  }
26
27
 
27
- // Counter for unique port assignment per test suite
28
+ // Counter for unique port assignment per test suite (incremented only under e2eSetupMutex)
28
29
  let portCounter = 0;
29
30
 
30
- // Mutex to prevent concurrent initialization (since all tests share the same database)
31
- let initializationLock: Promise<void> | null = null;
31
+ // True mutex: the previous Promise-based lock had a TOCTOU race where two beforeAll hooks
32
+ // could both pass the `if (initializationLock)` check and run setup concurrently, sharing
33
+ // one publisher.db / server singleton and causing flaky listResources and port collisions.
34
+ const e2eSetupMutex = new Mutex();
32
35
 
33
36
  /**
34
37
  * Starts the real application server and connects a real MCP client.
35
38
  */
36
39
  export async function setupE2ETestEnvironment(): Promise<McpE2ETestEnvironment> {
37
- // Wait for any ongoing initialization to complete (prevents concurrent DB initialization)
38
- if (initializationLock) {
40
+ return e2eSetupMutex.runExclusive(async () => {
39
41
  console.log(
40
- "[E2E Test Setup] Waiting for previous initialization to complete...",
42
+ "[E2E Test Setup] Acquired setup mutex; starting environment...",
41
43
  );
42
- await initializationLock;
43
- }
44
-
45
- // Create a new lock for this initialization
46
- let resolveLock: () => void;
47
- initializationLock = new Promise((resolve) => {
48
- resolveLock = resolve;
44
+ return setupE2ETestEnvironmentInternal();
49
45
  });
50
-
51
- try {
52
- return await setupE2ETestEnvironmentInternal();
53
- } finally {
54
- // Release the lock
55
- resolveLock!();
56
- initializationLock = null;
57
- }
58
46
  }
59
47
 
60
48
  async function setupE2ETestEnvironmentInternal(): Promise<McpE2ETestEnvironment> {
61
49
  // --- Store and Set SERVER_ROOT Env Var ---
62
- // The ProjectStore relies on SERVER_ROOT to find publisher.config.json.
50
+ // The EnvironmentStore relies on SERVER_ROOT to find publisher.config.json.
63
51
  const originalServerRoot = process.env.SERVER_ROOT; // Store original value
64
52
  // Resolve the path to 'packages/server' based on the location of this file
65
53
  // Use import.meta.url for cross-platform compatibility (works on Windows)
@@ -129,9 +117,9 @@ async function setupE2ETestEnvironmentInternal(): Promise<McpE2ETestEnvironment>
129
117
 
130
118
  // --- Wait for server to be ready (packages downloaded) ---
131
119
  // Poll the readiness endpoint to ensure initialization completes before tests run
132
- // Note: Reduced timeout to 90s to be under the 100s test timeout
120
+ // Allow >90s: cold CI can spend most of the budget on clone + package load before markReady().
133
121
  console.log("[E2E Test Setup] Waiting for server to be ready...");
134
- const maxWaitTime = 90000; // 90 seconds max wait (under 100s test timeout)
122
+ const maxWaitTime = 150000; // 150 seconds (INITIALIZE_STORAGE + large samples can be slow)
135
123
  const pollInterval = 1000; // Check every second
136
124
  const startTime = Date.now();
137
125
  let isReady = false;
@@ -1,12 +1,14 @@
1
1
  import sinon from "sinon";
2
- import { ProjectStore } from "../../src/service/project_store";
2
+ import { EnvironmentStore } from "../../src/service/environment_store";
3
3
 
4
- /** Return a stubbed ProjectStore where every lookup throws or returns minimal objects. */
5
- export function fakeProjectStore(): sinon.SinonStubbedInstance<ProjectStore> {
6
- const ps = sinon.createStubInstance(ProjectStore);
7
- // For now just have getProject reject; suites can stub more.
8
- ps.getProject.rejects(new Error("fakeProjectStore: getProject not stubbed"));
9
- return ps;
4
+ /** Return a stubbed EnvironmentStore where every lookup throws or returns minimal objects. */
5
+ export function fakeEnvironmentStore(): sinon.SinonStubbedInstance<EnvironmentStore> {
6
+ const es = sinon.createStubInstance(EnvironmentStore);
7
+ // For now just have getEnvironment reject; suites can stub more.
8
+ es.getEnvironment.rejects(
9
+ new Error("fakeEnvironmentStore: getEnvironment not stubbed"),
10
+ );
11
+ return es;
10
12
  }
11
13
 
12
14
  // The runtime implementation of `McpServer` lives in the MCP SDK package, which
@@ -27,7 +29,7 @@ export type McpServer = DummyMcpServer;
27
29
  /** Convenience helper mimicking the old mocks used in integration specs. */
28
30
  export function createMalloyServiceMocks() {
29
31
  return {
30
- projectStore: fakeProjectStore(),
32
+ environmentStore: fakeEnvironmentStore(),
31
33
  } as const;
32
34
  }
33
35
 
@@ -12,8 +12,8 @@ export interface RestE2EEnv {
12
12
  * reuses the cached Express app and binds on an OS-assigned port
13
13
  * to avoid collisions.
14
14
  *
15
- * Callers are responsible for creating any test-specific projects
16
- * via the REST API (POST /api/v0/projects) and cleaning them up.
15
+ * Callers are responsible for creating any test-specific environments
16
+ * via the REST API (POST /api/v0/environments) and cleaning them up.
17
17
  */
18
18
  export async function startRestE2E(): Promise<
19
19
  RestE2EEnv & { stop(): Promise<void> }