@malloy-publisher/server 0.0.198-dev → 0.0.198-dev1
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.
- package/README.docker.md +135 -20
- package/README.md +15 -0
- package/build.ts +42 -1
- package/dist/app/api-doc.yaml +51 -0
- package/dist/app/assets/EnvironmentPage-Dpee_Kn6.js +1 -0
- package/dist/app/assets/HomePage-DLRWTNoL.js +1 -0
- package/dist/app/assets/MainPage-DsVt5QGM.js +2 -0
- package/dist/app/assets/ModelPage-AwAugZ37.js +1 -0
- package/dist/app/assets/PackagePage-XQ-EWGTC.js +1 -0
- package/dist/app/assets/RouteError-3Mv8JQw7.js +1 -0
- package/dist/app/assets/WorkbookPage-DHYYpcYc.js +1 -0
- package/dist/app/assets/{core-w79IMXAG.es-Bd0UlzOL.js → core-DfcpQGVP.es-DQggNOdX.js} +14 -14
- package/dist/app/assets/{index-C513UodQ.js → index-BUp81Qdm.js} +15 -15
- package/dist/app/assets/index-D1pdwrUW.js +1803 -0
- package/dist/app/assets/index-Dv5bF4Ii.js +451 -0
- package/dist/app/assets/{index.umd-BMeMPq_9.js → index.umd-CQH4LZU8.js} +1 -1
- package/dist/app/index.html +2 -3
- package/dist/compile_worker.mjs +628 -0
- package/dist/default-publisher.config.json +23 -0
- package/dist/instrumentation.mjs +36 -38
- package/dist/server.mjs +2060 -913
- package/package.json +11 -12
- package/publisher.config.example.bigquery.json +33 -0
- package/publisher.config.example.duckdb.json +23 -0
- package/publisher.config.json +1 -11
- package/src/compile/compile_pool.spec.ts +227 -0
- package/src/compile/compile_pool.ts +729 -0
- package/src/compile/compile_worker.ts +683 -0
- package/src/compile/protocol.ts +251 -0
- package/src/config.spec.ts +306 -0
- package/src/config.ts +222 -2
- package/src/controller/compile.controller.ts +3 -1
- package/src/controller/connection.controller.ts +1 -1
- package/src/controller/model.controller.ts +8 -1
- package/src/controller/package.controller.ts +70 -29
- package/src/controller/query.controller.ts +3 -0
- package/src/default-publisher.config.json +23 -0
- package/src/errors.spec.ts +42 -0
- package/src/errors.ts +21 -0
- package/src/health.spec.ts +90 -0
- package/src/health.ts +86 -45
- package/src/logger.ts +1 -3
- package/src/mcp/tools/discovery_tools.ts +6 -2
- package/src/mcp/tools/execute_query_tool.ts +12 -0
- package/src/path_safety.spec.ts +158 -0
- package/src/path_safety.ts +140 -0
- package/src/pg_helpers.spec.ts +226 -0
- package/src/pg_helpers.ts +129 -0
- package/src/server-old.ts +3 -23
- package/src/server.ts +49 -0
- package/src/service/connection.spec.ts +6 -4
- package/src/service/connection.ts +8 -3
- package/src/service/connection_config.ts +2 -2
- package/src/service/environment.ts +621 -176
- package/src/service/environment_admission.spec.ts +180 -0
- package/src/service/environment_store.ts +22 -0
- package/src/service/filter_integration.spec.ts +110 -0
- package/src/service/givens_integration.spec.ts +192 -0
- package/src/service/manifest_service.spec.ts +7 -2
- package/src/service/manifest_service.ts +8 -2
- package/src/service/materialization_service.ts +14 -3
- package/src/service/model.spec.ts +105 -0
- package/src/service/model.ts +317 -10
- package/src/service/model_worker_path.spec.ts +125 -0
- package/src/service/package.ts +4 -3
- package/src/service/package_memory_governor.spec.ts +173 -0
- package/src/service/package_memory_governor.ts +233 -0
- package/src/service/package_race.spec.ts +208 -0
- package/src/storage/StorageManager.ts +71 -11
- package/src/storage/duckdb/schema.ts +41 -0
- package/src/utils.ts +11 -0
- package/tests/harness/rest_e2e.ts +2 -2
- package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +280 -0
- package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +259 -0
- package/tests/unit/duckdb/attached_databases.test.ts +5 -5
- package/tests/unit/duckdb/legacy_schema_migration.test.ts +194 -0
- package/tests/unit/storage/StorageManager.test.ts +166 -0
- package/dist/app/assets/EnvironmentPage-1j6QDWAy.js +0 -1
- package/dist/app/assets/HomePage-DMop21VG.js +0 -1
- package/dist/app/assets/MainPage-BbE8ETz1.js +0 -2
- package/dist/app/assets/ModelPage-D2jvfe3t.js +0 -1
- package/dist/app/assets/PackagePage-BbnhGoD3.js +0 -1
- package/dist/app/assets/RouteError-D3LGEZ3i.js +0 -1
- package/dist/app/assets/WorkbookPage-DttVIj4u.js +0 -1
- package/dist/app/assets/index-5K9YjIxF.js +0 -456
- package/dist/app/assets/index-DIgzgp69.js +0 -1742
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/// <reference types="bun-types" />
|
|
2
|
+
|
|
3
|
+
// TODO: Remove this during projects cleanup
|
|
4
|
+
/**
|
|
5
|
+
* Smoke tests for the legacy `/api/v0/projects/...` REST surface registered
|
|
6
|
+
* by `server-old.ts`. These routes exist purely to keep pre-rename SDK
|
|
7
|
+
* clients (e.g. `@malloydata/db-publisher`) working after the
|
|
8
|
+
* projects→environments rename.
|
|
9
|
+
*
|
|
10
|
+
* One test per route group: projects CRUD, packages, connections, models,
|
|
11
|
+
* notebooks, databases, queries, materializations, manifest. The
|
|
12
|
+
* materialization test additionally asserts the response field rename
|
|
13
|
+
* (`projectId` not `environmentId`). `/status` is no longer in the legacy
|
|
14
|
+
* surface — both old and new clients hit the single `/api/v0/status`
|
|
15
|
+
* handler in server.ts, which returns `environments`.
|
|
16
|
+
*
|
|
17
|
+
* This file is intentionally separate from the regular integration suite so
|
|
18
|
+
* it can be deleted in one motion when legacy support is dropped.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
22
|
+
import path from "path";
|
|
23
|
+
import { fileURLToPath } from "url";
|
|
24
|
+
import { RestE2EEnv, startRestE2E } from "../../harness/rest_e2e";
|
|
25
|
+
|
|
26
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
27
|
+
const __dirname = path.dirname(__filename);
|
|
28
|
+
|
|
29
|
+
// Use a distinct project name so this suite doesn't collide with the
|
|
30
|
+
// materialization integration suite (which also uses "test-project") if
|
|
31
|
+
// they ever run in the same DB instance.
|
|
32
|
+
const PROJECT_NAME = "legacy-routes-test-project";
|
|
33
|
+
const PACKAGE_NAME = "persist-test";
|
|
34
|
+
|
|
35
|
+
describe("Legacy /api/v0/projects/* REST routes (E2E)", () => {
|
|
36
|
+
let env: (RestE2EEnv & { stop(): Promise<void> }) | null = null;
|
|
37
|
+
let baseUrl: string;
|
|
38
|
+
|
|
39
|
+
beforeAll(async () => {
|
|
40
|
+
env = await startRestE2E();
|
|
41
|
+
baseUrl = env.baseUrl;
|
|
42
|
+
|
|
43
|
+
// Create the test environment via the LEGACY route — proves POST
|
|
44
|
+
// /projects works end-to-end.
|
|
45
|
+
const fixtureDir = path.resolve(__dirname, "../../fixtures/persist-test");
|
|
46
|
+
const createRes = await fetch(`${baseUrl}/api/v0/projects`, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { "Content-Type": "application/json" },
|
|
49
|
+
body: JSON.stringify({
|
|
50
|
+
name: PROJECT_NAME,
|
|
51
|
+
packages: [{ name: PACKAGE_NAME, location: fixtureDir }],
|
|
52
|
+
connections: [],
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
if (!createRes.ok) {
|
|
56
|
+
const body = await createRes.text();
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Failed to create test project via legacy route (${createRes.status}): ${body}`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Wait for the package to finish loading via the legacy GET path.
|
|
63
|
+
const deadline = Date.now() + 30_000;
|
|
64
|
+
let pkgReady = false;
|
|
65
|
+
while (!pkgReady && Date.now() < deadline) {
|
|
66
|
+
try {
|
|
67
|
+
const res = await fetch(
|
|
68
|
+
`${baseUrl}/api/v0/projects/${PROJECT_NAME}/packages/${PACKAGE_NAME}`,
|
|
69
|
+
);
|
|
70
|
+
if (res.ok) {
|
|
71
|
+
pkgReady = true;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// not ready yet
|
|
76
|
+
}
|
|
77
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
78
|
+
}
|
|
79
|
+
if (!pkgReady) {
|
|
80
|
+
throw new Error("Test package did not become available in time");
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterAll(async () => {
|
|
85
|
+
if (baseUrl) {
|
|
86
|
+
try {
|
|
87
|
+
// Clean up via the legacy DELETE route.
|
|
88
|
+
await fetch(`${baseUrl}/api/v0/projects/${PROJECT_NAME}`, {
|
|
89
|
+
method: "DELETE",
|
|
90
|
+
});
|
|
91
|
+
} catch {
|
|
92
|
+
// best-effort cleanup
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
await env?.stop();
|
|
96
|
+
env = null;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("projects CRUD", () => {
|
|
100
|
+
it("GET /projects lists environments under the legacy URL", async () => {
|
|
101
|
+
const res = await fetch(`${baseUrl}/api/v0/projects`);
|
|
102
|
+
expect(res.status).toBe(200);
|
|
103
|
+
const body = (await res.json()) as Array<{ name?: string }>;
|
|
104
|
+
expect(Array.isArray(body)).toBe(true);
|
|
105
|
+
expect(body.some((e) => e.name === PROJECT_NAME)).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("GET /projects/:projectName returns the project", async () => {
|
|
109
|
+
const res = await fetch(`${baseUrl}/api/v0/projects/${PROJECT_NAME}`);
|
|
110
|
+
expect(res.status).toBe(200);
|
|
111
|
+
const body = (await res.json()) as { name?: string };
|
|
112
|
+
expect(body.name).toBe(PROJECT_NAME);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("packages", () => {
|
|
117
|
+
it("GET /projects/:projectName/packages returns the package list", async () => {
|
|
118
|
+
const res = await fetch(
|
|
119
|
+
`${baseUrl}/api/v0/projects/${PROJECT_NAME}/packages`,
|
|
120
|
+
);
|
|
121
|
+
expect(res.status).toBe(200);
|
|
122
|
+
const body = (await res.json()) as Array<{ name?: string }>;
|
|
123
|
+
expect(Array.isArray(body)).toBe(true);
|
|
124
|
+
expect(body.some((p) => p.name === PACKAGE_NAME)).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("GET /projects/:projectName/packages/:packageName returns the package", async () => {
|
|
128
|
+
const res = await fetch(
|
|
129
|
+
`${baseUrl}/api/v0/projects/${PROJECT_NAME}/packages/${PACKAGE_NAME}`,
|
|
130
|
+
);
|
|
131
|
+
expect(res.status).toBe(200);
|
|
132
|
+
const body = (await res.json()) as { name?: string };
|
|
133
|
+
expect(body.name).toBe(PACKAGE_NAME);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("connections", () => {
|
|
138
|
+
it("GET /projects/:projectName/connections returns 200 (may be empty)", async () => {
|
|
139
|
+
const res = await fetch(
|
|
140
|
+
`${baseUrl}/api/v0/projects/${PROJECT_NAME}/connections`,
|
|
141
|
+
);
|
|
142
|
+
expect(res.status).toBe(200);
|
|
143
|
+
const body = await res.json();
|
|
144
|
+
expect(Array.isArray(body)).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("models", () => {
|
|
149
|
+
it("GET /projects/:projectName/packages/:packageName/models returns the model list", async () => {
|
|
150
|
+
const res = await fetch(
|
|
151
|
+
`${baseUrl}/api/v0/projects/${PROJECT_NAME}/packages/${PACKAGE_NAME}/models`,
|
|
152
|
+
);
|
|
153
|
+
expect(res.status).toBe(200);
|
|
154
|
+
const body = await res.json();
|
|
155
|
+
expect(Array.isArray(body)).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("notebooks", () => {
|
|
160
|
+
it("GET /projects/:projectName/packages/:packageName/notebooks returns 200", async () => {
|
|
161
|
+
const res = await fetch(
|
|
162
|
+
`${baseUrl}/api/v0/projects/${PROJECT_NAME}/packages/${PACKAGE_NAME}/notebooks`,
|
|
163
|
+
);
|
|
164
|
+
expect(res.status).toBe(200);
|
|
165
|
+
const body = await res.json();
|
|
166
|
+
expect(Array.isArray(body)).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("databases", () => {
|
|
171
|
+
it("GET /projects/:projectName/packages/:packageName/databases returns 200", async () => {
|
|
172
|
+
const res = await fetch(
|
|
173
|
+
`${baseUrl}/api/v0/projects/${PROJECT_NAME}/packages/${PACKAGE_NAME}/databases`,
|
|
174
|
+
);
|
|
175
|
+
expect(res.status).toBe(200);
|
|
176
|
+
const body = await res.json();
|
|
177
|
+
expect(Array.isArray(body)).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("queries", () => {
|
|
182
|
+
it("POST /projects/:projectName/packages/:packageName/models/.../query reaches the handler", async () => {
|
|
183
|
+
// Hit the route with a bogus model name. We only need to prove the
|
|
184
|
+
// legacy URL is wired up to the controller — a structured JSON
|
|
185
|
+
// error (not Express's HTML fall-through 404) is sufficient signal.
|
|
186
|
+
const res = await fetch(
|
|
187
|
+
`${baseUrl}/api/v0/projects/${PROJECT_NAME}/packages/${PACKAGE_NAME}/models/does-not-exist.malloy/query`,
|
|
188
|
+
{
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: { "Content-Type": "application/json" },
|
|
191
|
+
body: JSON.stringify({ query: "run: nothing" }),
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
195
|
+
expect(res.status).toBeLessThan(600);
|
|
196
|
+
// Controller errors come back as JSON with a `message` field.
|
|
197
|
+
// An unhandled Express 404 returns HTML — that would fail here.
|
|
198
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
199
|
+
expect(typeof body.message).toBe("string");
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("materializations", () => {
|
|
204
|
+
it("GET list and POST create return 'projectId' (not 'environmentId') under the legacy URL", async () => {
|
|
205
|
+
const listRes = await fetch(
|
|
206
|
+
`${baseUrl}/api/v0/projects/${PROJECT_NAME}/packages/${PACKAGE_NAME}/materializations`,
|
|
207
|
+
);
|
|
208
|
+
expect(listRes.status).toBe(200);
|
|
209
|
+
const list = (await listRes.json()) as unknown;
|
|
210
|
+
expect(Array.isArray(list)).toBe(true);
|
|
211
|
+
|
|
212
|
+
// Create one so we can assert the field rename on a populated payload.
|
|
213
|
+
const createRes = await fetch(
|
|
214
|
+
`${baseUrl}/api/v0/projects/${PROJECT_NAME}/packages/${PACKAGE_NAME}/materializations`,
|
|
215
|
+
{
|
|
216
|
+
method: "POST",
|
|
217
|
+
headers: { "Content-Type": "application/json" },
|
|
218
|
+
body: JSON.stringify({ autoLoadManifest: true }),
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
expect(createRes.status).toBe(201);
|
|
222
|
+
const created = (await createRes.json()) as Record<string, unknown>;
|
|
223
|
+
|
|
224
|
+
// Legacy contract: materialization payloads expose `projectId`, not
|
|
225
|
+
// `environmentId`. This is the response remapper in server-old.ts.
|
|
226
|
+
expect(created).toHaveProperty("projectId");
|
|
227
|
+
expect(created).not.toHaveProperty("environmentId");
|
|
228
|
+
|
|
229
|
+
const id = created.id as string;
|
|
230
|
+
// Best-effort cleanup so we don't leak a PENDING materialization
|
|
231
|
+
// into other tests. We don't poll-to-terminal; the suite teardown
|
|
232
|
+
// of the project will mop up.
|
|
233
|
+
try {
|
|
234
|
+
await fetch(
|
|
235
|
+
`${baseUrl}/api/v0/projects/${PROJECT_NAME}/packages/${PACKAGE_NAME}/materializations/${id}?action=stop`,
|
|
236
|
+
{ method: "POST" },
|
|
237
|
+
);
|
|
238
|
+
await fetch(
|
|
239
|
+
`${baseUrl}/api/v0/projects/${PROJECT_NAME}/packages/${PACKAGE_NAME}/materializations/${id}`,
|
|
240
|
+
{ method: "DELETE" },
|
|
241
|
+
);
|
|
242
|
+
} catch {
|
|
243
|
+
// ignore
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("manifest", () => {
|
|
249
|
+
it("GET /projects/:projectName/packages/:packageName/manifest returns 200 or a structured 4xx", async () => {
|
|
250
|
+
const res = await fetch(
|
|
251
|
+
`${baseUrl}/api/v0/projects/${PROJECT_NAME}/packages/${PACKAGE_NAME}/manifest`,
|
|
252
|
+
);
|
|
253
|
+
// Without a built materialization the manifest may be empty/404 —
|
|
254
|
+
// we only assert the legacy URL reaches the handler, not 404 from
|
|
255
|
+
// Express's catch-all.
|
|
256
|
+
expect([200, 400, 404]).toContain(res.status);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -855,7 +855,7 @@ describe("createEnvironmentConnections - DuckDB", () => {
|
|
|
855
855
|
|
|
856
856
|
await expect(
|
|
857
857
|
createEnvironmentConnections(connections, PROJECT_TEST_DIR),
|
|
858
|
-
).rejects.toThrow(
|
|
858
|
+
).rejects.toThrow(/'duckdb' is reserved/);
|
|
859
859
|
});
|
|
860
860
|
|
|
861
861
|
it("should throw when DuckDB connection name is 'duckdb' with attached databases", async () => {
|
|
@@ -885,10 +885,12 @@ describe("createEnvironmentConnections - DuckDB", () => {
|
|
|
885
885
|
|
|
886
886
|
await expect(
|
|
887
887
|
createEnvironmentConnections(connections, PROJECT_TEST_DIR),
|
|
888
|
-
).rejects.toThrow(
|
|
888
|
+
).rejects.toThrow(/'duckdb' is reserved/);
|
|
889
889
|
});
|
|
890
890
|
|
|
891
891
|
it("should throw when DuckDB connection has no attached databases", async () => {
|
|
892
|
+
// Env-level DuckDB requires at least one attached foreign db;
|
|
893
|
+
// the per-package "duckdb" sandbox covers the plain-in-memory case.
|
|
892
894
|
const connections: ApiConnection[] = [
|
|
893
895
|
{
|
|
894
896
|
name: "no_attached_db",
|
|
@@ -901,9 +903,7 @@ describe("createEnvironmentConnections - DuckDB", () => {
|
|
|
901
903
|
|
|
902
904
|
await expect(
|
|
903
905
|
createEnvironmentConnections(connections, PROJECT_TEST_DIR),
|
|
904
|
-
).rejects.toThrow(
|
|
905
|
-
"DuckDB connection must have at least one attached database",
|
|
906
|
-
);
|
|
906
|
+
).rejects.toThrow(/has no attached databases/);
|
|
907
907
|
});
|
|
908
908
|
|
|
909
909
|
it("should throw on unsupported connection type", async () => {
|