@malloy-publisher/server 0.0.198-dev → 0.0.198-dev2

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 (87) hide show
  1. package/README.docker.md +135 -20
  2. package/README.md +15 -0
  3. package/build.ts +32 -1
  4. package/dist/app/api-doc.yaml +51 -0
  5. package/dist/app/assets/EnvironmentPage-Dpee_Kn6.js +1 -0
  6. package/dist/app/assets/HomePage-DLRWTNoL.js +1 -0
  7. package/dist/app/assets/MainPage-DsVt5QGM.js +2 -0
  8. package/dist/app/assets/ModelPage-AwAugZ37.js +1 -0
  9. package/dist/app/assets/PackagePage-XQ-EWGTC.js +1 -0
  10. package/dist/app/assets/RouteError-3Mv8JQw7.js +1 -0
  11. package/dist/app/assets/WorkbookPage-DHYYpcYc.js +1 -0
  12. package/dist/app/assets/{core-w79IMXAG.es-Bd0UlzOL.js → core-DfcpQGVP.es-DQggNOdX.js} +14 -14
  13. package/dist/app/assets/{index-C513UodQ.js → index-BUp81Qdm.js} +15 -15
  14. package/dist/app/assets/index-D1pdwrUW.js +1803 -0
  15. package/dist/app/assets/index-Dv5bF4Ii.js +451 -0
  16. package/dist/app/assets/{index.umd-BMeMPq_9.js → index.umd-CQH4LZU8.js} +1 -1
  17. package/dist/app/index.html +2 -3
  18. package/dist/default-publisher.config.json +23 -0
  19. package/dist/instrumentation.mjs +22 -3
  20. package/dist/server.mjs +1522 -651
  21. package/dist/service/schema_worker.mjs +61 -0
  22. package/package.json +11 -12
  23. package/publisher.config.example.bigquery.json +33 -0
  24. package/publisher.config.example.duckdb.json +23 -0
  25. package/publisher.config.json +1 -11
  26. package/src/config.spec.ts +306 -0
  27. package/src/config.ts +222 -2
  28. package/src/controller/compile.controller.ts +3 -1
  29. package/src/controller/connection.controller.ts +1 -1
  30. package/src/controller/model.controller.ts +8 -1
  31. package/src/controller/package.controller.ts +70 -29
  32. package/src/controller/query.controller.ts +3 -0
  33. package/src/default-publisher.config.json +23 -0
  34. package/src/errors.spec.ts +42 -0
  35. package/src/errors.ts +21 -0
  36. package/src/health.spec.ts +90 -0
  37. package/src/health.ts +73 -45
  38. package/src/instrumentation.ts +50 -0
  39. package/src/logger.ts +1 -3
  40. package/src/mcp/tools/discovery_tools.ts +6 -2
  41. package/src/mcp/tools/execute_query_tool.ts +12 -0
  42. package/src/path_safety.spec.ts +158 -0
  43. package/src/path_safety.ts +140 -0
  44. package/src/pg_helpers.spec.ts +226 -0
  45. package/src/pg_helpers.ts +129 -0
  46. package/src/server-old.ts +3 -23
  47. package/src/server.ts +54 -0
  48. package/src/service/connection.spec.ts +6 -4
  49. package/src/service/connection.ts +8 -3
  50. package/src/service/connection_config.ts +2 -2
  51. package/src/service/environment.ts +621 -176
  52. package/src/service/environment_admission.spec.ts +180 -0
  53. package/src/service/environment_store.ts +31 -0
  54. package/src/service/filter_integration.spec.ts +110 -0
  55. package/src/service/givens_integration.spec.ts +192 -0
  56. package/src/service/manifest_service.spec.ts +7 -2
  57. package/src/service/manifest_service.ts +8 -2
  58. package/src/service/materialization_service.ts +14 -3
  59. package/src/service/model.spec.ts +105 -0
  60. package/src/service/model.ts +91 -7
  61. package/src/service/package.spec.ts +11 -7
  62. package/src/service/package.ts +53 -56
  63. package/src/service/package_memory_governor.spec.ts +173 -0
  64. package/src/service/package_memory_governor.ts +233 -0
  65. package/src/service/package_race.spec.ts +208 -0
  66. package/src/service/process_stats_reporter.ts +169 -0
  67. package/src/service/schema_worker.ts +123 -0
  68. package/src/service/schema_worker_pool.ts +278 -0
  69. package/src/storage/StorageManager.ts +71 -11
  70. package/src/storage/duckdb/schema.ts +41 -0
  71. package/src/utils.ts +11 -0
  72. package/tests/harness/rest_e2e.ts +2 -2
  73. package/tests/integration/concurrent_environment/concurrent_environment.integration.spec.ts +235 -0
  74. package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +280 -0
  75. package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +259 -0
  76. package/tests/unit/duckdb/attached_databases.test.ts +5 -5
  77. package/tests/unit/duckdb/legacy_schema_migration.test.ts +194 -0
  78. package/tests/unit/storage/StorageManager.test.ts +166 -0
  79. package/dist/app/assets/EnvironmentPage-1j6QDWAy.js +0 -1
  80. package/dist/app/assets/HomePage-DMop21VG.js +0 -1
  81. package/dist/app/assets/MainPage-BbE8ETz1.js +0 -2
  82. package/dist/app/assets/ModelPage-D2jvfe3t.js +0 -1
  83. package/dist/app/assets/PackagePage-BbnhGoD3.js +0 -1
  84. package/dist/app/assets/RouteError-D3LGEZ3i.js +0 -1
  85. package/dist/app/assets/WorkbookPage-DttVIj4u.js +0 -1
  86. package/dist/app/assets/index-5K9YjIxF.js +0 -456
  87. package/dist/app/assets/index-DIgzgp69.js +0 -1742
@@ -17,6 +17,15 @@ 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
 
@@ -125,6 +134,38 @@ export async function initializeSchema(
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",
package/src/utils.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { URLReader } from "@malloydata/malloy";
2
2
  import * as fs from "fs";
3
+ import * as path from "path";
3
4
  import { fileURLToPath } from "url";
4
5
 
5
6
  export const URL_READER: URLReader = {
@@ -11,3 +12,13 @@ export const URL_READER: URLReader = {
11
12
  return fs.promises.readFile(path, "utf8");
12
13
  },
13
14
  };
15
+
16
+ /**
17
+ * Skip dotfiles/dotdirs (.vscode, .git, .DS_Store, etc.) when walking a
18
+ * package tree. These come from editors/VCS, never contain Malloy models
19
+ * or databases, and have been a source of spurious ENOENTs when their
20
+ * contents disappear mid-scan.
21
+ */
22
+ export function ignoreDotfiles(file: string): boolean {
23
+ return path.basename(file).startsWith(".");
24
+ }
@@ -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> }
@@ -0,0 +1,235 @@
1
+ /// <reference types="bun-types" />
2
+
3
+ /**
4
+ * Regression test for per-environment load/scaffold races.
5
+ *
6
+ * Before fix: concurrent GET (especially ?reload=true), POST, and PATCH against
7
+ * the same environment name could enter `getEnvironment` / `addEnvironment` in
8
+ * parallel. Multiple callers would scaffold or re-load the same directory
9
+ * concurrently, leaving publisher.db and on-disk state inconsistent. Lazy loads
10
+ * then failed with `Environment "…" could not be resolved to a path.`
11
+ *
12
+ * After fix: environment operations serialize on a per-environment mutex in
13
+ * `EnvironmentStore.getEnvironment`, so concurrent callers share one load path.
14
+ * All N requests must succeed and the environment must remain usable afterwards.
15
+ */
16
+
17
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
18
+ import { RestE2EEnv, startRestE2E } from "../../harness/rest_e2e";
19
+
20
+ const ENV_NAME = `concurrent-environment-test-env-${Date.now()}`;
21
+ const PACKAGE_NAME = "gcs_faa";
22
+ const FIXTURE_LOCATION = "gs://publisher_test_packages/gcs_faa.zip";
23
+ const CONCURRENCY = 12;
24
+
25
+ const FORBIDDEN_ERROR_FRAGMENTS = [
26
+ "could not be resolved to a path",
27
+ "Package manifest for",
28
+ "does not exist",
29
+ "compiling model path not found",
30
+ "model path not found",
31
+ ];
32
+
33
+ function findForbiddenError(body: unknown): string | undefined {
34
+ const text = typeof body === "string" ? body : JSON.stringify(body ?? "");
35
+ return FORBIDDEN_ERROR_FRAGMENTS.find((frag) => text.includes(frag));
36
+ }
37
+
38
+ function environmentPayload(description?: string) {
39
+ return {
40
+ name: ENV_NAME,
41
+ packages: [{ name: PACKAGE_NAME, location: FIXTURE_LOCATION }],
42
+ connections: [],
43
+ ...(description !== undefined ? { description } : {}),
44
+ };
45
+ }
46
+
47
+ async function waitForPackageReady(
48
+ baseUrl: string,
49
+ deadlineMs = 30_000,
50
+ ): Promise<void> {
51
+ const deadline = Date.now() + deadlineMs;
52
+ while (Date.now() < deadline) {
53
+ try {
54
+ const res = await fetch(
55
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}`,
56
+ );
57
+ if (res.ok) {
58
+ return;
59
+ }
60
+ } catch {
61
+ // not ready yet
62
+ }
63
+ await new Promise((r) => setTimeout(r, 250));
64
+ }
65
+ throw new Error("Seeded package did not become available in time");
66
+ }
67
+
68
+ describe("Concurrent environment operations (E2E)", () => {
69
+ let env: (RestE2EEnv & { stop(): Promise<void> }) | null = null;
70
+ let baseUrl: string;
71
+
72
+ beforeAll(async () => {
73
+ env = await startRestE2E();
74
+ baseUrl = env.baseUrl;
75
+
76
+ const createRes = await fetch(`${baseUrl}/api/v0/environments`, {
77
+ method: "POST",
78
+ headers: { "Content-Type": "application/json" },
79
+ body: JSON.stringify(environmentPayload()),
80
+ });
81
+ if (!createRes.ok) {
82
+ const body = await createRes.text();
83
+ throw new Error(
84
+ `Failed to seed test environment (${createRes.status}): ${body}`,
85
+ );
86
+ }
87
+ await waitForPackageReady(baseUrl);
88
+ });
89
+
90
+ afterAll(async () => {
91
+ if (baseUrl) {
92
+ try {
93
+ await fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}`, {
94
+ method: "DELETE",
95
+ });
96
+ } catch {
97
+ // best-effort cleanup
98
+ }
99
+ }
100
+ await env?.stop();
101
+ env = null;
102
+ });
103
+
104
+ it("concurrent POST /environments for the same name all succeed", async () => {
105
+ const requests = Array.from({ length: CONCURRENCY }, () =>
106
+ fetch(`${baseUrl}/api/v0/environments`, {
107
+ method: "POST",
108
+ headers: { "Content-Type": "application/json" },
109
+ body: JSON.stringify(environmentPayload()),
110
+ }),
111
+ );
112
+
113
+ const responses = await Promise.all(requests);
114
+ const bodies = await Promise.all(
115
+ responses.map(async (r) => ({
116
+ status: r.status,
117
+ body: await r.json().catch(() => null),
118
+ })),
119
+ );
120
+
121
+ for (const { status, body } of bodies) {
122
+ expect(status).toBe(200);
123
+ const forbidden = findForbiddenError(body);
124
+ expect(forbidden).toBeUndefined();
125
+ const meta = body as { name?: string };
126
+ expect(meta.name).toBe(ENV_NAME);
127
+ }
128
+
129
+ await waitForPackageReady(baseUrl);
130
+
131
+ const getRes = await fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}`);
132
+ expect(getRes.status).toBe(200);
133
+ const forbidden = findForbiddenError(
134
+ await getRes.json().catch(() => null),
135
+ );
136
+ expect(forbidden).toBeUndefined();
137
+ });
138
+
139
+ it("concurrent GET /environments/:name?reload=true all succeed", async () => {
140
+ const requests = Array.from({ length: CONCURRENCY }, () =>
141
+ fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}?reload=true`),
142
+ );
143
+ const responses = await Promise.all(requests);
144
+ const bodies = await Promise.all(
145
+ responses.map(async (r) => ({
146
+ status: r.status,
147
+ body: await r.json().catch(() => null),
148
+ })),
149
+ );
150
+
151
+ for (const { status, body } of bodies) {
152
+ expect(status).toBe(200);
153
+ const forbidden = findForbiddenError(body);
154
+ expect(forbidden).toBeUndefined();
155
+ const meta = body as { name?: string };
156
+ expect(meta.name).toBe(ENV_NAME);
157
+ }
158
+
159
+ await waitForPackageReady(baseUrl);
160
+ });
161
+
162
+ it("simultaneous POST + PATCH for the same environment serialize cleanly", async () => {
163
+ const newReadme = `concurrent-env-update-${Date.now()}`;
164
+ const [postRes, patchRes] = await Promise.all([
165
+ fetch(`${baseUrl}/api/v0/environments`, {
166
+ method: "POST",
167
+ headers: { "Content-Type": "application/json" },
168
+ body: JSON.stringify(environmentPayload()),
169
+ }),
170
+ fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}`, {
171
+ method: "PATCH",
172
+ headers: { "Content-Type": "application/json" },
173
+ body: JSON.stringify({
174
+ name: ENV_NAME,
175
+ readme: newReadme,
176
+ }),
177
+ }),
178
+ ]);
179
+
180
+ expect(postRes.status).toBe(200);
181
+ expect(
182
+ findForbiddenError(await postRes.json().catch(() => null)),
183
+ ).toBeUndefined();
184
+
185
+ const patchBody = await patchRes.json().catch(() => null);
186
+ expect(findForbiddenError(patchBody)).toBeUndefined();
187
+ expect([200, 404]).toContain(patchRes.status);
188
+
189
+ const getRes = await fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}`);
190
+ expect(getRes.status).toBe(200);
191
+ const meta = (await getRes.json()) as { name?: string; readme?: string };
192
+ expect(meta.name).toBe(ENV_NAME);
193
+ });
194
+
195
+ it("interleaved POST + GET-reload + package list never surface path errors", async () => {
196
+ const work: Array<Promise<{ status: number; body: unknown }>> = [];
197
+ for (let i = 0; i < CONCURRENCY; i++) {
198
+ work.push(
199
+ fetch(`${baseUrl}/api/v0/environments`, {
200
+ method: "POST",
201
+ headers: { "Content-Type": "application/json" },
202
+ body: JSON.stringify(environmentPayload()),
203
+ }).then(async (r) => ({
204
+ status: r.status,
205
+ body: await r.json().catch(() => null),
206
+ })),
207
+ );
208
+ work.push(
209
+ fetch(
210
+ `${baseUrl}/api/v0/environments/${ENV_NAME}?reload=true`,
211
+ ).then(async (r) => ({
212
+ status: r.status,
213
+ body: await r.json().catch(() => null),
214
+ })),
215
+ );
216
+ work.push(
217
+ fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}/packages`).then(
218
+ async (r) => ({
219
+ status: r.status,
220
+ body: await r.json().catch(() => null),
221
+ }),
222
+ ),
223
+ );
224
+ }
225
+
226
+ const results = await Promise.all(work);
227
+ for (const { status, body } of results) {
228
+ const forbidden = findForbiddenError(body);
229
+ expect(forbidden).toBeUndefined();
230
+ expect(status).toBeLessThan(500);
231
+ }
232
+
233
+ await waitForPackageReady(baseUrl);
234
+ });
235
+ });
@@ -0,0 +1,280 @@
1
+ /// <reference types="bun-types" />
2
+
3
+ /**
4
+ * Regression test for the per-package download/unzip race.
5
+ *
6
+ * Before fix: concurrent POST/PATCH/GET-with-reload against the same package
7
+ * name landed in `downloadPackage` *before* acquiring the per-package mutex.
8
+ * Multiple callers would `rm -rf <targetPath>` and then `cp -r` / `extractAllTo`
9
+ * in parallel, leaving the directory in an inconsistent state. Subsequent
10
+ * reads failed with "Package manifest for ... does not exist" and any
11
+ * in-flight model compilation would 500 with "compiling model path not found".
12
+ *
13
+ * After fix: download is run inside the per-package mutex, so concurrent
14
+ * callers serialize on the same target path. All N requests must succeed
15
+ * and the package must be usable afterwards.
16
+ */
17
+
18
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
19
+ import path from "path";
20
+ import { fileURLToPath } from "url";
21
+ import { RestE2EEnv, startRestE2E } from "../../harness/rest_e2e";
22
+
23
+ const __filename = fileURLToPath(import.meta.url);
24
+ const __dirname = path.dirname(__filename);
25
+
26
+ // Use a unique env name per run so stale state from a previous run (env
27
+ // directory, persisted env metadata in publisher.db) can't poison startup.
28
+ const ENV_NAME = `concurrent-package-test-env-${Date.now()}`;
29
+ const PACKAGE_NAME = "gcs_faa";
30
+ const CONCURRENCY = 12;
31
+
32
+ const FORBIDDEN_ERROR_FRAGMENTS = [
33
+ "Package manifest for",
34
+ "does not exist",
35
+ "compiling model path not found",
36
+ "model path not found",
37
+ ];
38
+
39
+ function findForbiddenError(body: unknown): string | undefined {
40
+ const text = typeof body === "string" ? body : JSON.stringify(body ?? "");
41
+ return FORBIDDEN_ERROR_FRAGMENTS.find((frag) => text.includes(frag));
42
+ }
43
+
44
+ describe("Concurrent package operations (E2E)", () => {
45
+ let env: (RestE2EEnv & { stop(): Promise<void> }) | null = null;
46
+ let baseUrl: string;
47
+ let fixtureDir: string;
48
+
49
+ beforeAll(async () => {
50
+ env = await startRestE2E();
51
+ baseUrl = env.baseUrl;
52
+ fixtureDir = "gs://publisher_test_packages/gcs_faa.zip";
53
+
54
+ // Seed the environment with the package once so the env exists on disk.
55
+ // addEnvironment requires at least one package.
56
+ const createRes = await fetch(`${baseUrl}/api/v0/environments`, {
57
+ method: "POST",
58
+ headers: { "Content-Type": "application/json" },
59
+ body: JSON.stringify({
60
+ name: ENV_NAME,
61
+ packages: [{ name: PACKAGE_NAME, location: fixtureDir }],
62
+ connections: [],
63
+ }),
64
+ });
65
+ if (!createRes.ok) {
66
+ const body = await createRes.text();
67
+ throw new Error(
68
+ `Failed to seed test environment (${createRes.status}): ${body}`,
69
+ );
70
+ }
71
+
72
+ // Wait for the seeded package to be loadable.
73
+ const deadline = Date.now() + 30_000;
74
+ let ready = false;
75
+ while (!ready && Date.now() < deadline) {
76
+ try {
77
+ const res = await fetch(
78
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}`,
79
+ );
80
+ if (res.ok) {
81
+ ready = true;
82
+ break;
83
+ }
84
+ } catch {
85
+ // not ready yet
86
+ }
87
+ await new Promise((r) => setTimeout(r, 250));
88
+ }
89
+ if (!ready) {
90
+ throw new Error("Seeded package did not become available in time");
91
+ }
92
+ });
93
+
94
+ afterAll(async () => {
95
+ if (baseUrl) {
96
+ try {
97
+ await fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}`, {
98
+ method: "DELETE",
99
+ });
100
+ } catch {
101
+ // best-effort cleanup
102
+ }
103
+ }
104
+ await env?.stop();
105
+ env = null;
106
+ });
107
+
108
+ it("concurrent POST /packages for the same name all succeed", async () => {
109
+ const requests = Array.from({ length: CONCURRENCY }, () =>
110
+ fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}/packages`, {
111
+ method: "POST",
112
+ headers: { "Content-Type": "application/json" },
113
+ body: JSON.stringify({
114
+ name: PACKAGE_NAME,
115
+ location: fixtureDir,
116
+ }),
117
+ }),
118
+ );
119
+
120
+ const responses = await Promise.all(requests);
121
+ const bodies = await Promise.all(
122
+ responses.map(async (r) => ({
123
+ status: r.status,
124
+ body: await r.json().catch(() => null),
125
+ })),
126
+ );
127
+
128
+ for (const { status, body } of bodies) {
129
+ expect(status).toBe(200);
130
+ const forbidden = findForbiddenError(body);
131
+ expect(forbidden).toBeUndefined();
132
+ }
133
+
134
+ // Package must be loadable after the storm.
135
+ const res = await fetch(
136
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}`,
137
+ );
138
+ expect(res.status).toBe(200);
139
+ const meta = (await res.json()) as { name?: string };
140
+ expect(meta.name).toBe(PACKAGE_NAME);
141
+
142
+ // Models must be listable — proves the model files survived the unzip
143
+ // race and Package.create read a consistent directory.
144
+ const modelsRes = await fetch(
145
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}/models`,
146
+ );
147
+ expect(modelsRes.status).toBe(200);
148
+ const models = (await modelsRes.json()) as Array<{ path?: string }>;
149
+ expect(Array.isArray(models)).toBe(true);
150
+ expect(models.length).toBeGreaterThan(0);
151
+ });
152
+
153
+ it("concurrent GET /packages/:name?reload=true all succeed", async () => {
154
+ const requests = Array.from({ length: CONCURRENCY }, () =>
155
+ fetch(
156
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}?reload=true`,
157
+ ),
158
+ );
159
+ const responses = await Promise.all(requests);
160
+ const bodies = await Promise.all(
161
+ responses.map(async (r) => ({
162
+ status: r.status,
163
+ body: await r.json().catch(() => null),
164
+ })),
165
+ );
166
+ for (const { status, body } of bodies) {
167
+ expect(status).toBe(200);
168
+ const forbidden = findForbiddenError(body);
169
+ expect(forbidden).toBeUndefined();
170
+ }
171
+
172
+ // Models must still list cleanly.
173
+ const modelsRes = await fetch(
174
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}/models`,
175
+ );
176
+ expect(modelsRes.status).toBe(200);
177
+ });
178
+
179
+ it("simultaneous POST + PATCH for the same package serialize cleanly", async () => {
180
+ // Fire create and update at the same time. Both should land under the
181
+ // same per-package mutex. Whichever wins the mutex first runs first;
182
+ // the loser sees a coherent post-condition. After both settle the
183
+ // package must be loadable and the description must reflect the PATCH
184
+ // when the PATCH ran *after* a successful POST.
185
+ const newDescription = `concurrent-update-${Date.now()}`;
186
+ const [postRes, patchRes] = await Promise.all([
187
+ fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}/packages`, {
188
+ method: "POST",
189
+ headers: { "Content-Type": "application/json" },
190
+ body: JSON.stringify({
191
+ name: PACKAGE_NAME,
192
+ location: fixtureDir,
193
+ }),
194
+ }),
195
+ fetch(
196
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}`,
197
+ {
198
+ method: "PATCH",
199
+ headers: { "Content-Type": "application/json" },
200
+ body: JSON.stringify({
201
+ name: PACKAGE_NAME,
202
+ description: newDescription,
203
+ }),
204
+ },
205
+ ),
206
+ ]);
207
+
208
+ // POST is idempotent here — always succeeds.
209
+ expect(postRes.status).toBe(200);
210
+ const postBody = await postRes.json().catch(() => null);
211
+ expect(findForbiddenError(postBody)).toBeUndefined();
212
+
213
+ // PATCH either succeeds (ran after POST loaded the package) or 404s
214
+ // (ran before POST populated `this.packages`). It must NOT silently
215
+ // rewrite disk and then 404, and must never surface the forbidden
216
+ // error fragments.
217
+ const patchBody = await patchRes.json().catch(() => null);
218
+ expect(findForbiddenError(patchBody)).toBeUndefined();
219
+ expect([200, 404]).toContain(patchRes.status);
220
+
221
+ // Whatever happened, the package must remain loadable.
222
+ const getRes = await fetch(
223
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}`,
224
+ );
225
+ expect(getRes.status).toBe(200);
226
+ const meta = (await getRes.json()) as {
227
+ name?: string;
228
+ description?: string;
229
+ };
230
+ expect(meta.name).toBe(PACKAGE_NAME);
231
+ });
232
+
233
+ it("interleaved POST + GET-reload + model list never surface stale-dir errors", async () => {
234
+ // Worst-case interleave: writers and readers hammering the same
235
+ // package. None of them should observe a missing manifest or a
236
+ // half-extracted directory.
237
+ const work: Array<Promise<{ status: number; body: unknown }>> = [];
238
+ for (let i = 0; i < CONCURRENCY; i++) {
239
+ work.push(
240
+ fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}/packages`, {
241
+ method: "POST",
242
+ headers: { "Content-Type": "application/json" },
243
+ body: JSON.stringify({
244
+ name: PACKAGE_NAME,
245
+ location: fixtureDir,
246
+ }),
247
+ }).then(async (r) => ({
248
+ status: r.status,
249
+ body: await r.json().catch(() => null),
250
+ })),
251
+ );
252
+ work.push(
253
+ fetch(
254
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}?reload=true`,
255
+ ).then(async (r) => ({
256
+ status: r.status,
257
+ body: await r.json().catch(() => null),
258
+ })),
259
+ );
260
+ work.push(
261
+ fetch(
262
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}/models`,
263
+ ).then(async (r) => ({
264
+ status: r.status,
265
+ body: await r.json().catch(() => null),
266
+ })),
267
+ );
268
+ }
269
+
270
+ const results = await Promise.all(work);
271
+ for (const { status, body } of results) {
272
+ const forbidden = findForbiddenError(body);
273
+ expect(forbidden).toBeUndefined();
274
+ // Model list while the package is being rewritten can transiently
275
+ // return 404 (the package is unloaded mid-rewrite), but never 5xx,
276
+ // and never with the forbidden error fragments.
277
+ expect(status).toBeLessThan(500);
278
+ }
279
+ });
280
+ });