@velastack/cms 0.6.1 → 0.6.3

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.
@@ -214,9 +214,9 @@ export const createCmsBackend = (options = {}) => {
214
214
  if (!testReset) {
215
215
  throw new Error('createCmsBackend({ testReset: true }) is required to call reset()');
216
216
  }
217
- store.__resetProjectForTests(projectId);
217
+ const filenames = store.__resetProjectForTests(projectId);
218
218
  cache.clear();
219
- await clearUploads(uploadDir);
219
+ await clearUploads(uploadDir, filenames);
220
220
  const { layouts, pages } = normalizeSeed(seed);
221
221
  if (Object.keys(layouts).length > 0 || Object.keys(pages).length > 0) {
222
222
  store.seedPublishedDocs(projectId, layouts, pages);
@@ -10,6 +10,9 @@ export declare const normalizeSeed: (seed: SeedInput) => {
10
10
  layouts: Record<string, Record<string, Tree>>;
11
11
  pages: Record<string, Record<string, PageEntry[]>>;
12
12
  };
13
- /** Remove only files this backend could have written. */
14
- export declare const clearUploads: (uploadDir: string) => Promise<void>;
13
+ /**
14
+ * Unlink the given uploads (the reset project's media files), skipping any name
15
+ * this backend could not have generated. A file already gone is not an error.
16
+ */
17
+ export declare const clearUploads: (uploadDir: string, filenames: string[]) => Promise<void>;
15
18
  export declare const postTestReset: (ctx: RouteCtx) => Promise<Response>;
@@ -8,11 +8,14 @@
8
8
  *
9
9
  * One deliberate change from the original: that version did
10
10
  * `rm(uploadDir(), { recursive: true, force: true })` — a recursive delete of a
11
- * host-configured directory, on an unauthenticated POST. Here only files whose
12
- * names this backend could have generated are unlinked, and never the directory
13
- * itself, so a misconfigured `uploadDir` cannot be destroyed.
11
+ * host-configured directory, on an unauthenticated POST. Here only the files of
12
+ * the project's own media rows are unlinked, and never the directory itself, so
13
+ * a misconfigured `uploadDir` cannot be destroyed and a reset of one project
14
+ * cannot delete another's uploads (several projects, or several test files
15
+ * running in parallel, share one `uploadDir`).
14
16
  */
15
- import { readdir, unlink } from 'node:fs/promises';
17
+ import { unlink } from 'node:fs/promises';
18
+ import { join } from 'node:path';
16
19
  import { DEFAULT_LOCALE } from '../store/queries.js';
17
20
  import { isSafeUploadFilename } from '../storage.js';
18
21
  import { badRequest, isObjectRecord, readJson } from './context.js';
@@ -86,24 +89,20 @@ export const normalizeSeed = (seed) => {
86
89
  }
87
90
  return { layouts, pages };
88
91
  };
89
- /** Remove only files this backend could have written. */
90
- export const clearUploads = async (uploadDir) => {
91
- let names;
92
- try {
93
- names = await readdir(uploadDir);
94
- }
95
- catch {
96
- return;
97
- }
98
- await Promise.all(names.filter(isSafeUploadFilename).map((n) => unlink(`${uploadDir}/${n}`).catch(() => { })));
92
+ /**
93
+ * Unlink the given uploads (the reset project's media files), skipping any name
94
+ * this backend could not have generated. A file already gone is not an error.
95
+ */
96
+ export const clearUploads = async (uploadDir, filenames) => {
97
+ await Promise.all(filenames.filter(isSafeUploadFilename).map((n) => unlink(join(uploadDir, n)).catch(() => { })));
99
98
  };
100
99
  export const postTestReset = async (ctx) => {
101
100
  const body = await readJson(ctx.event);
102
101
  if (body !== undefined && !isObjectRecord(body))
103
102
  return badRequest('invalid json');
104
- ctx.store.__resetProjectForTests(ctx.projectId);
103
+ const filenames = ctx.store.__resetProjectForTests(ctx.projectId);
105
104
  ctx.cache.clear();
106
- await clearUploads(ctx.storage.dir());
105
+ await clearUploads(ctx.storage.dir(), filenames);
107
106
  const { layouts, pages } = normalizeSeed((body ?? {}));
108
107
  if (Object.keys(layouts).length > 0 || Object.keys(pages).length > 0) {
109
108
  ctx.store.seedPublishedDocs(ctx.projectId, layouts, pages);
@@ -1,6 +1,6 @@
1
1
  import { dirname } from 'node:path';
2
2
  import { mkdirSync } from 'node:fs';
3
- import { loadDriver } from './sqlite.js';
3
+ import { loadDriver, withoutReportNetwork } from './sqlite.js';
4
4
  import { runMigrations } from './migrate.js';
5
5
  import { MIGRATIONS } from './migrations/index.js';
6
6
  /**
@@ -16,7 +16,7 @@ export const openDatabase = (dbPath) => {
16
16
  if (dbPath !== ':memory:') {
17
17
  mkdirSync(dirname(dbPath), { recursive: true });
18
18
  }
19
- const db = new Driver(dbPath);
19
+ const db = withoutReportNetwork(() => new Driver(dbPath));
20
20
  db.pragma('journal_mode = WAL');
21
21
  db.pragma('synchronous = NORMAL');
22
22
  db.pragma('cache_size = -65536');
@@ -21,7 +21,7 @@ export declare const generatePreviewKey: () => string;
21
21
  * The body is otherwise a verbatim move.
22
22
  */
23
23
  export declare const createStore: (db: SqliteDb) => {
24
- __resetProjectForTests: (projectId: string) => void;
24
+ __resetProjectForTests: (projectId: string) => string[];
25
25
  addReleaseItems: (projectId: string, userId: string, inputs: AddReleaseItemInput[]) => OpenRelease;
26
26
  allPageRouteIds: (projectId: string) => string[];
27
27
  buildStateAtRelease: (projectId: string, releaseId: string) => {
@@ -1138,18 +1138,26 @@ export const createStore = (db) => {
1138
1138
  });
1139
1139
  };
1140
1140
  let _truncators = null;
1141
+ const mediaFilenamesStmt = () => db.prepare('SELECT filename FROM media_items WHERE project_id = ?');
1141
1142
  /** Wipe every CMS table for one project. Used by `__test_reset__`.
1142
1143
  * Also clears the in-process LRU — without this, the next test's first publish
1143
1144
  * would re-mint `cms_version=1` and collide with cached entries from the
1144
- * previous test run (production never resets, so version is monotonic). */
1145
+ * previous test run (production never resets, so version is monotonic).
1146
+ *
1147
+ * Returns the filenames of the project's media rows it deleted, so the caller
1148
+ * can unlink exactly those files and leave other projects' uploads alone. */
1145
1149
  const __resetProjectForTests = (projectId) => {
1146
1150
  if (!_truncators)
1147
1151
  _truncators = truncateProjectStmts();
1148
1152
  const tx = db.transaction(() => {
1153
+ const filenames = mediaFilenamesStmt()
1154
+ .all(projectId)
1155
+ .map((r) => r.filename);
1149
1156
  for (const fn of _truncators)
1150
1157
  fn(projectId);
1158
+ return filenames;
1151
1159
  });
1152
- tx();
1160
+ return tx();
1153
1161
  };
1154
1162
  /**
1155
1163
  * Seed published page/layout content for a project. Bypasses the release
@@ -20,3 +20,15 @@ export type SqliteDriver = new (path: string) => SqliteDb;
20
20
  * it — and never compiles a native module.
21
21
  */
22
22
  export declare const loadDriver: () => SqliteDriver;
23
+ /**
24
+ * Run `fn` with Node's diagnostic report leaving out networking details.
25
+ *
26
+ * better-sqlite3 loads its native addon on the first `new Database`, and on
27
+ * Linux picks the prebuilt binary by calling `process.report.getReport()` to
28
+ * tell glibc from musl. The report's network section resolves the address of
29
+ * every open socket, synchronously, so in a server with live connections the
30
+ * first open could block the event loop for seconds (10s on a CI runner).
31
+ * Nothing in that check needs the network section. The setting is restored
32
+ * before returning; `fn` is synchronous, so nothing else sees it changed.
33
+ */
34
+ export declare const withoutReportNetwork: <T>(fn: () => T) => T;
@@ -27,3 +27,27 @@ export const loadDriver = () => {
27
27
  'dependency. Install it with `npm i better-sqlite3`.', { cause });
28
28
  }
29
29
  };
30
+ /**
31
+ * Run `fn` with Node's diagnostic report leaving out networking details.
32
+ *
33
+ * better-sqlite3 loads its native addon on the first `new Database`, and on
34
+ * Linux picks the prebuilt binary by calling `process.report.getReport()` to
35
+ * tell glibc from musl. The report's network section resolves the address of
36
+ * every open socket, synchronously, so in a server with live connections the
37
+ * first open could block the event loop for seconds (10s on a CI runner).
38
+ * Nothing in that check needs the network section. The setting is restored
39
+ * before returning; `fn` is synchronous, so nothing else sees it changed.
40
+ */
41
+ export const withoutReportNetwork = (fn) => {
42
+ // `excludeNetwork` exists from Node 20.13 / 22.0; older versions skip this.
43
+ const report = process.report;
44
+ if (!report || typeof report.excludeNetwork !== 'boolean' || report.excludeNetwork)
45
+ return fn();
46
+ report.excludeNetwork = true;
47
+ try {
48
+ return fn();
49
+ }
50
+ finally {
51
+ report.excludeNetwork = false;
52
+ }
53
+ };
@@ -168,6 +168,24 @@ export const resolveCmsPayload = async (args) => {
168
168
  redirectTo: null
169
169
  };
170
170
  };
171
+ /**
172
+ * A query parameter of the request, or null where the page cannot have one:
173
+ * while building, and on a prerendered page, where SvelteKit (3.0 in dev too,
174
+ * via `prerender_default`) replaces `url.search`/`url.searchParams` with a
175
+ * getter that throws `url_search_unavailable_prerender`. A prerendered page
176
+ * cannot vary by preview or version key, so null is the right answer, not an
177
+ * error that takes the whole page down in `vite dev`.
178
+ */
179
+ const searchParam = (event, key) => {
180
+ if (building)
181
+ return null;
182
+ try {
183
+ return event.url.searchParams.get(key);
184
+ }
185
+ catch {
186
+ return null;
187
+ }
188
+ };
171
189
  /**
172
190
  * Resolve the CMS payload for the current SvelteKit request.
173
191
  *
@@ -217,8 +235,8 @@ export const loadCms = (event, options) => {
217
235
  manifest: builtManifest,
218
236
  routeId: event.route.id,
219
237
  params: event.params,
220
- previewKey: building ? null : event.url.searchParams.get('preview'),
221
- versionKey: building ? null : event.url.searchParams.get('version'),
238
+ previewKey: searchParam(event, 'preview'),
239
+ versionKey: searchParam(event, 'version'),
222
240
  locale: options.locale,
223
241
  locales: options.locales,
224
242
  adapter: options.adapter,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velastack/cms",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "A scope-aware CMS for SvelteKit, for static or dynamic sites.",
5
5
  "keywords": [
6
6
  "svelte",