@webstir-io/webstir-backend 0.1.15 → 0.1.16

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 (123) hide show
  1. package/README.md +106 -79
  2. package/dist/add.d.ts +59 -0
  3. package/dist/add.js +626 -0
  4. package/dist/build/artifacts.d.ts +115 -1
  5. package/dist/build/artifacts.js +4 -4
  6. package/dist/build/entries.js +1 -1
  7. package/dist/build/pipeline.d.ts +33 -1
  8. package/dist/build/pipeline.js +307 -65
  9. package/dist/cache/diff.js +9 -8
  10. package/dist/cache/reporters.js +1 -1
  11. package/dist/deploy-cli.d.ts +2 -0
  12. package/dist/deploy-cli.js +86 -0
  13. package/dist/diagnostics/summary.js +2 -2
  14. package/dist/index.d.ts +6 -0
  15. package/dist/index.js +4 -0
  16. package/dist/manifest/pipeline.js +103 -32
  17. package/dist/provider.js +35 -17
  18. package/dist/runtime/bun.d.ts +51 -0
  19. package/dist/runtime/bun.js +499 -0
  20. package/dist/runtime/core.d.ts +141 -0
  21. package/dist/runtime/core.js +316 -0
  22. package/dist/runtime/deploy-backend.d.ts +20 -0
  23. package/dist/runtime/deploy-backend.js +175 -0
  24. package/dist/runtime/deploy-shared.d.ts +43 -0
  25. package/dist/runtime/deploy-shared.js +75 -0
  26. package/dist/runtime/deploy-static.d.ts +2 -0
  27. package/dist/runtime/deploy-static.js +161 -0
  28. package/dist/runtime/deploy.d.ts +3 -0
  29. package/dist/runtime/deploy.js +91 -0
  30. package/dist/runtime/forms.d.ts +73 -0
  31. package/dist/runtime/forms.js +236 -0
  32. package/dist/runtime/request-hooks.d.ts +47 -0
  33. package/dist/runtime/request-hooks.js +102 -0
  34. package/dist/runtime/session-metadata.d.ts +13 -0
  35. package/dist/runtime/session-metadata.js +98 -0
  36. package/dist/runtime/session-runtime.d.ts +28 -0
  37. package/dist/runtime/session-runtime.js +180 -0
  38. package/dist/runtime/session.d.ts +83 -0
  39. package/dist/runtime/session.js +396 -0
  40. package/dist/runtime/views.d.ts +74 -0
  41. package/dist/runtime/views.js +221 -0
  42. package/dist/scaffold/assets.js +25 -21
  43. package/dist/testing/context.js +1 -1
  44. package/dist/testing/index.d.ts +1 -1
  45. package/dist/testing/index.js +100 -56
  46. package/dist/utils/bun.d.ts +2 -0
  47. package/dist/utils/bun.js +13 -0
  48. package/dist/watch.d.ts +13 -1
  49. package/dist/watch.js +345 -97
  50. package/dist/workspace.d.ts +8 -0
  51. package/dist/workspace.js +44 -3
  52. package/package.json +49 -14
  53. package/scripts/publish.sh +2 -92
  54. package/scripts/smoke.mjs +282 -107
  55. package/scripts/update-contract.sh +12 -10
  56. package/src/add.ts +964 -0
  57. package/src/build/artifacts.ts +49 -46
  58. package/src/build/entries.ts +12 -12
  59. package/src/build/pipeline.ts +779 -403
  60. package/src/cache/diff.ts +111 -105
  61. package/src/cache/reporters.ts +26 -26
  62. package/src/deploy-cli.ts +111 -0
  63. package/src/diagnostics/summary.ts +28 -22
  64. package/src/index.ts +11 -0
  65. package/src/manifest/pipeline.ts +328 -215
  66. package/src/provider.ts +115 -98
  67. package/src/runtime/bun.ts +793 -0
  68. package/src/runtime/core.ts +598 -0
  69. package/src/runtime/deploy-backend.ts +239 -0
  70. package/src/runtime/deploy-shared.ts +136 -0
  71. package/src/runtime/deploy-static.ts +191 -0
  72. package/src/runtime/deploy.ts +143 -0
  73. package/src/runtime/forms.ts +364 -0
  74. package/src/runtime/request-hooks.ts +165 -0
  75. package/src/runtime/session-metadata.ts +135 -0
  76. package/src/runtime/session-runtime.ts +267 -0
  77. package/src/runtime/session.ts +642 -0
  78. package/src/runtime/views.ts +385 -0
  79. package/src/scaffold/assets.ts +77 -73
  80. package/src/testing/context.js +8 -9
  81. package/src/testing/context.ts +9 -9
  82. package/src/testing/index.d.ts +14 -3
  83. package/src/testing/index.js +254 -175
  84. package/src/testing/index.ts +298 -195
  85. package/src/testing/types.d.ts +18 -19
  86. package/src/testing/types.ts +18 -18
  87. package/src/utils/bun.ts +26 -0
  88. package/src/watch.ts +503 -99
  89. package/src/workspace.ts +59 -3
  90. package/templates/backend/.env.example +15 -0
  91. package/templates/backend/auth/adapter.ts +335 -36
  92. package/templates/backend/db/connection.ts +190 -65
  93. package/templates/backend/db/migrate.ts +149 -43
  94. package/templates/backend/db/types.d.ts +1 -1
  95. package/templates/backend/env.ts +132 -20
  96. package/templates/backend/functions/hello/index.ts +1 -2
  97. package/templates/backend/index.ts +15 -508
  98. package/templates/backend/jobs/nightly/index.ts +1 -1
  99. package/templates/backend/jobs/runtime.ts +24 -11
  100. package/templates/backend/jobs/scheduler.ts +208 -46
  101. package/templates/backend/module.ts +227 -13
  102. package/templates/backend/observability/logger.ts +2 -12
  103. package/templates/backend/observability/metrics.ts +8 -5
  104. package/templates/backend/session/sqlite.ts +152 -0
  105. package/templates/backend/session/store.ts +45 -0
  106. package/templates/backend/tsconfig.json +1 -1
  107. package/tests/add.test.js +327 -0
  108. package/tests/authAdapter.test.js +315 -0
  109. package/tests/bundlerParity.test.js +217 -0
  110. package/tests/cacheReporter.test.js +10 -10
  111. package/tests/dbConnection.test.js +209 -0
  112. package/tests/deploy.test.js +357 -0
  113. package/tests/envLoader.test.js +271 -17
  114. package/tests/integration.test.js +2432 -3
  115. package/tests/jobsScheduler.test.js +253 -0
  116. package/tests/manifest.test.js +287 -12
  117. package/tests/migrationRunner.test.js +249 -0
  118. package/tests/sessionScaffoldStore.test.js +752 -0
  119. package/tests/sessionStore.test.js +490 -0
  120. package/tests/testing.test.js +252 -0
  121. package/tests/watch.test.js +192 -32
  122. package/tsconfig.json +3 -10
  123. package/templates/backend/server/fastify.ts +0 -288
@@ -0,0 +1,752 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawn } from 'node:child_process';
4
+ import fs from 'node:fs/promises';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
+ import { build as esbuild } from 'esbuild';
9
+
10
+ import { backendProvider } from '../dist/index.js';
11
+ import { prepareSessionState } from '../dist/runtime/session.js';
12
+ import { prepareFormState, processFormSubmission } from '../dist/runtime/forms.js';
13
+
14
+ const config = {
15
+ secret: 'test-session-secret',
16
+ cookieName: 'webstir_session',
17
+ secure: false,
18
+ maxAgeSeconds: 60,
19
+ };
20
+
21
+ const loginRoute = {
22
+ form: {
23
+ session: { write: true },
24
+ flash: {
25
+ publish: [{ key: 'signed-in', level: 'success', when: 'success' }],
26
+ },
27
+ },
28
+ };
29
+
30
+ const accountRoute = {
31
+ session: { mode: 'optional' },
32
+ flash: { consume: ['signed-in'] },
33
+ };
34
+
35
+ async function createTempWorkspace(prefix = 'webstir-backend-session-store-') {
36
+ return await fs.mkdtemp(path.join(os.tmpdir(), prefix));
37
+ }
38
+
39
+ async function copyFile(src, dest) {
40
+ await fs.mkdir(path.dirname(dest), { recursive: true });
41
+ await fs.copyFile(src, dest);
42
+ }
43
+
44
+ async function seedBackendWorkspace(workspace, name) {
45
+ const assets = await backendProvider.getScaffoldAssets();
46
+ for (const asset of assets) {
47
+ await copyFile(asset.sourcePath, path.join(workspace, asset.targetPath));
48
+ }
49
+
50
+ await fs.writeFile(
51
+ path.join(workspace, 'package.json'),
52
+ JSON.stringify(
53
+ {
54
+ name,
55
+ version: '0.0.0',
56
+ type: 'module',
57
+ },
58
+ null,
59
+ 2,
60
+ ),
61
+ 'utf8',
62
+ );
63
+ }
64
+
65
+ async function linkWorkspacePackage(workspace) {
66
+ const packageRoot = getPackageRoot();
67
+ const scopeRoot = path.join(workspace, 'node_modules', '@webstir-io');
68
+ await fs.mkdir(scopeRoot, { recursive: true });
69
+ await fs.symlink(packageRoot, path.join(scopeRoot, 'webstir-backend'), 'dir');
70
+ }
71
+
72
+ function getPackageRoot() {
73
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
74
+ }
75
+
76
+ async function compileTemplateSessionFiles(workspace) {
77
+ await esbuild({
78
+ entryPoints: [
79
+ path.join(workspace, 'src', 'backend', 'env.ts'),
80
+ path.join(workspace, 'src', 'backend', 'session', 'sqlite.ts'),
81
+ path.join(workspace, 'src', 'backend', 'session', 'store.ts'),
82
+ ],
83
+ bundle: false,
84
+ format: 'esm',
85
+ platform: 'node',
86
+ target: 'node20',
87
+ outdir: path.join(workspace, 'build', 'backend'),
88
+ outbase: path.join(workspace, 'src', 'backend'),
89
+ logLevel: 'silent',
90
+ });
91
+ }
92
+
93
+ function snapshotEnv(keys) {
94
+ return Object.fromEntries(keys.map((key) => [key, process.env[key]]));
95
+ }
96
+
97
+ function restoreEnv(snapshot) {
98
+ for (const [key, value] of Object.entries(snapshot)) {
99
+ if (value === undefined) {
100
+ delete process.env[key];
101
+ } else {
102
+ process.env[key] = value;
103
+ }
104
+ }
105
+ }
106
+
107
+ function extractCookieHeader(setCookie) {
108
+ assert.ok(setCookie, 'expected a session cookie');
109
+ return String(setCookie).split(';')[0];
110
+ }
111
+
112
+ function extractSessionId(cookieHeader, cookieName) {
113
+ const [nameValue] = String(cookieHeader).split(';');
114
+ const prefix = `${cookieName}=`;
115
+ assert.ok(nameValue.startsWith(prefix), `expected ${cookieName} cookie`);
116
+ const encodedValue = nameValue.slice(prefix.length);
117
+ const separatorIndex = encodedValue.indexOf('.');
118
+ assert.notEqual(separatorIndex, -1, 'expected signed session cookie');
119
+ return decodeURIComponent(encodedValue.slice(0, separatorIndex));
120
+ }
121
+
122
+ async function importCompiledModule(filePath) {
123
+ return await import(`${pathToFileURL(filePath).href}?t=${Date.now()}-${Math.random()}`);
124
+ }
125
+
126
+ async function runSqliteSessionProbe(workspace, { cwd = workspace, env = {} } = {}) {
127
+ const sessionStoreUrl = pathToFileURL(
128
+ path.join(workspace, 'build', 'backend', 'session', 'store.js'),
129
+ ).href;
130
+ const runtimeSessionUrl = pathToFileURL(
131
+ path.join(getPackageRoot(), 'dist', 'runtime', 'session.js'),
132
+ ).href;
133
+ const script = `
134
+ const [{ sessionStore }, { prepareSessionState }] = await Promise.all([
135
+ import(${JSON.stringify(sessionStoreUrl)}),
136
+ import(${JSON.stringify(runtimeSessionUrl)})
137
+ ]);
138
+
139
+ const config = {
140
+ secret: 'test-session-secret',
141
+ cookieName: 'webstir_session',
142
+ secure: false,
143
+ maxAgeSeconds: 60
144
+ };
145
+ const loginRoute = {
146
+ form: {
147
+ session: { write: true },
148
+ flash: {
149
+ publish: [{ key: 'signed-in', level: 'success', when: 'success' }]
150
+ }
151
+ }
152
+ };
153
+ const accountRoute = {
154
+ session: { mode: 'optional' },
155
+ flash: { consume: ['signed-in'] }
156
+ };
157
+ const created = prepareSessionState({
158
+ cookies: '',
159
+ route: loginRoute,
160
+ config,
161
+ store: sessionStore
162
+ });
163
+ const createdCommit = created.commit({
164
+ session: {
165
+ userId: 'ada@example.com',
166
+ data: { email: 'ada@example.com' }
167
+ },
168
+ route: loginRoute,
169
+ result: {
170
+ status: 303,
171
+ redirect: { location: '/session/account' }
172
+ }
173
+ });
174
+ const cookie = String(createdCommit.setCookie).split(';')[0];
175
+ const read = prepareSessionState({
176
+ cookies: cookie,
177
+ route: accountRoute,
178
+ config,
179
+ store: sessionStore
180
+ });
181
+ console.log(JSON.stringify({
182
+ userId: read.session?.userId ?? null,
183
+ flash: read.flash.map((message) => ({ key: message.key, level: message.level }))
184
+ }));
185
+ `;
186
+ const child = spawn('bun', ['--eval', script], {
187
+ cwd,
188
+ env: {
189
+ ...process.env,
190
+ ...env,
191
+ },
192
+ stdio: ['ignore', 'pipe', 'pipe'],
193
+ });
194
+
195
+ let stdout = '';
196
+ let stderr = '';
197
+ child.stdout.on('data', (chunk) => {
198
+ stdout += chunk.toString();
199
+ });
200
+ child.stderr.on('data', (chunk) => {
201
+ stderr += chunk.toString();
202
+ });
203
+
204
+ const exitCode = await new Promise((resolve) => {
205
+ child.once('close', resolve);
206
+ });
207
+
208
+ if (exitCode !== 0) {
209
+ throw new Error(
210
+ `Session probe failed (exit ${exitCode}).\nstdout:\n${stdout}\nstderr:\n${stderr}`,
211
+ );
212
+ }
213
+
214
+ const line = stdout
215
+ .split(/\r?\n/)
216
+ .map((value) => value.trim())
217
+ .find((value) => value.startsWith('{'));
218
+ if (!line) {
219
+ throw new Error(`Session probe did not emit JSON.\nstdout:\n${stdout}\nstderr:\n${stderr}`);
220
+ }
221
+
222
+ return JSON.parse(line);
223
+ }
224
+
225
+ test('scaffold session store defaults to in-memory storage outside production', async () => {
226
+ const workspace = await createTempWorkspace('webstir-backend-session-memory-');
227
+ await seedBackendWorkspace(workspace, '@demo/session-memory');
228
+ await linkWorkspacePackage(workspace);
229
+ await compileTemplateSessionFiles(workspace);
230
+
231
+ const previousEnv = snapshotEnv([
232
+ 'NODE_ENV',
233
+ 'WORKSPACE_ROOT',
234
+ 'WEBSTIR_WORKSPACE_ROOT',
235
+ 'SESSION_STORE_DRIVER',
236
+ 'SESSION_STORE_URL',
237
+ ]);
238
+ const previousSqliteTarget = globalThis.__webstirSqliteTarget;
239
+
240
+ try {
241
+ delete process.env.NODE_ENV;
242
+ delete process.env.WORKSPACE_ROOT;
243
+ delete process.env.WEBSTIR_WORKSPACE_ROOT;
244
+ delete process.env.SESSION_STORE_DRIVER;
245
+ delete process.env.SESSION_STORE_URL;
246
+ delete globalThis.__webstirSqliteTarget;
247
+
248
+ const { sessionStore } = await importCompiledModule(
249
+ path.join(workspace, 'build', 'backend', 'session', 'store.js'),
250
+ );
251
+ const created = prepareSessionState({
252
+ cookies: '',
253
+ route: loginRoute,
254
+ config,
255
+ store: sessionStore,
256
+ });
257
+ const createdCommit = created.commit({
258
+ session: {
259
+ userId: 'ada@example.com',
260
+ },
261
+ route: loginRoute,
262
+ result: {
263
+ status: 303,
264
+ redirect: { location: '/session/account' },
265
+ },
266
+ });
267
+
268
+ const read = prepareSessionState({
269
+ cookies: extractCookieHeader(createdCommit.setCookie),
270
+ route: accountRoute,
271
+ config,
272
+ store: sessionStore,
273
+ });
274
+
275
+ assert.equal(read.session?.userId, 'ada@example.com');
276
+ assert.equal(globalThis.__webstirSqliteTarget, undefined);
277
+ } finally {
278
+ restoreEnv(previousEnv);
279
+ if (previousSqliteTarget === undefined) {
280
+ delete globalThis.__webstirSqliteTarget;
281
+ } else {
282
+ globalThis.__webstirSqliteTarget = previousSqliteTarget;
283
+ }
284
+ }
285
+ });
286
+
287
+ test('scaffold session store defaults to sqlite storage in production', async () => {
288
+ const workspace = await createTempWorkspace('webstir-backend-session-prod-sqlite-');
289
+ const alternateCwd = await createTempWorkspace('webstir-backend-session-prod-sqlite-cwd-');
290
+ await seedBackendWorkspace(workspace, '@demo/session-prod-sqlite');
291
+ await linkWorkspacePackage(workspace);
292
+ await compileTemplateSessionFiles(workspace);
293
+
294
+ const result = await runSqliteSessionProbe(workspace, {
295
+ cwd: alternateCwd,
296
+ env: {
297
+ NODE_ENV: 'production',
298
+ WORKSPACE_ROOT: ' ',
299
+ WEBSTIR_WORKSPACE_ROOT: workspace,
300
+ },
301
+ });
302
+
303
+ assert.equal(result.userId, 'ada@example.com');
304
+ assert.deepEqual(result.flash, [{ key: 'signed-in', level: 'success' }]);
305
+ assert.equal(
306
+ await fs
307
+ .access(path.join(workspace, 'data', 'sessions.sqlite'))
308
+ .then(() => true)
309
+ .catch(() => false),
310
+ true,
311
+ );
312
+ assert.equal(
313
+ await fs
314
+ .access(path.join(alternateCwd, 'data', 'sessions.sqlite'))
315
+ .then(() => true)
316
+ .catch(() => false),
317
+ false,
318
+ );
319
+ });
320
+
321
+ test('scaffold session store resolves sqlite paths from the workspace root outside the workspace cwd', async () => {
322
+ const workspace = await createTempWorkspace('webstir-backend-session-sqlite-');
323
+ const alternateCwd = await createTempWorkspace('webstir-backend-session-sqlite-cwd-');
324
+ await seedBackendWorkspace(workspace, '@demo/session-sqlite');
325
+ await linkWorkspacePackage(workspace);
326
+ await compileTemplateSessionFiles(workspace);
327
+
328
+ const result = await runSqliteSessionProbe(workspace, {
329
+ cwd: alternateCwd,
330
+ env: {
331
+ WORKSPACE_ROOT: ' ',
332
+ WEBSTIR_WORKSPACE_ROOT: workspace,
333
+ SESSION_STORE_DRIVER: 'sqlite',
334
+ SESSION_STORE_URL: 'file:./data/session-store.sqlite',
335
+ },
336
+ });
337
+
338
+ assert.equal(result.userId, 'ada@example.com');
339
+ assert.deepEqual(result.flash, [{ key: 'signed-in', level: 'success' }]);
340
+ assert.equal(
341
+ await fs
342
+ .access(path.join(workspace, 'data', 'session-store.sqlite'))
343
+ .then(() => true)
344
+ .catch(() => false),
345
+ true,
346
+ );
347
+ assert.equal(
348
+ await fs
349
+ .access(path.join(alternateCwd, 'data', 'session-store.sqlite'))
350
+ .then(() => true)
351
+ .catch(() => false),
352
+ false,
353
+ );
354
+ });
355
+
356
+ test('scaffold session store infers sqlite when SESSION_STORE_URL is configured', async () => {
357
+ const workspace = await createTempWorkspace('webstir-backend-session-sqlite-url-');
358
+ const alternateCwd = await createTempWorkspace('webstir-backend-session-sqlite-url-cwd-');
359
+ await seedBackendWorkspace(workspace, '@demo/session-sqlite-url');
360
+ await linkWorkspacePackage(workspace);
361
+ await compileTemplateSessionFiles(workspace);
362
+
363
+ const result = await runSqliteSessionProbe(workspace, {
364
+ cwd: alternateCwd,
365
+ env: {
366
+ WORKSPACE_ROOT: ' ',
367
+ WEBSTIR_WORKSPACE_ROOT: workspace,
368
+ SESSION_STORE_URL: 'file:./data/session-store-from-url.sqlite',
369
+ },
370
+ });
371
+
372
+ assert.equal(result.userId, 'ada@example.com');
373
+ assert.deepEqual(result.flash, [{ key: 'signed-in', level: 'success' }]);
374
+ assert.equal(
375
+ await fs
376
+ .access(path.join(workspace, 'data', 'session-store-from-url.sqlite'))
377
+ .then(() => true)
378
+ .catch(() => false),
379
+ true,
380
+ );
381
+ assert.equal(
382
+ await fs
383
+ .access(path.join(alternateCwd, 'data', 'session-store-from-url.sqlite'))
384
+ .then(() => true)
385
+ .catch(() => false),
386
+ false,
387
+ );
388
+ });
389
+
390
+ test('scaffold session store allows an explicit memory override in production', async () => {
391
+ const workspace = await createTempWorkspace('webstir-backend-session-prod-memory-');
392
+ await seedBackendWorkspace(workspace, '@demo/session-prod-memory');
393
+ await linkWorkspacePackage(workspace);
394
+ await compileTemplateSessionFiles(workspace);
395
+
396
+ const previousEnv = snapshotEnv([
397
+ 'NODE_ENV',
398
+ 'WORKSPACE_ROOT',
399
+ 'WEBSTIR_WORKSPACE_ROOT',
400
+ 'SESSION_STORE_DRIVER',
401
+ 'SESSION_STORE_URL',
402
+ ]);
403
+ const previousSqliteTarget = globalThis.__webstirSqliteTarget;
404
+
405
+ try {
406
+ process.env.NODE_ENV = 'production';
407
+ delete process.env.WORKSPACE_ROOT;
408
+ delete process.env.WEBSTIR_WORKSPACE_ROOT;
409
+ process.env.SESSION_STORE_DRIVER = 'memory';
410
+ delete process.env.SESSION_STORE_URL;
411
+ delete globalThis.__webstirSqliteTarget;
412
+
413
+ const { sessionStore } = await importCompiledModule(
414
+ path.join(workspace, 'build', 'backend', 'session', 'store.js'),
415
+ );
416
+ const created = prepareSessionState({
417
+ cookies: '',
418
+ route: loginRoute,
419
+ config,
420
+ store: sessionStore,
421
+ });
422
+ const createdCommit = created.commit({
423
+ session: {
424
+ userId: 'ada@example.com',
425
+ },
426
+ route: loginRoute,
427
+ result: {
428
+ status: 303,
429
+ redirect: { location: '/session/account' },
430
+ },
431
+ });
432
+
433
+ const read = prepareSessionState({
434
+ cookies: extractCookieHeader(createdCommit.setCookie),
435
+ route: accountRoute,
436
+ config,
437
+ store: sessionStore,
438
+ });
439
+
440
+ assert.equal(read.session?.userId, 'ada@example.com');
441
+ assert.equal(globalThis.__webstirSqliteTarget, undefined);
442
+ } finally {
443
+ restoreEnv(previousEnv);
444
+ if (previousSqliteTarget === undefined) {
445
+ delete globalThis.__webstirSqliteTarget;
446
+ } else {
447
+ globalThis.__webstirSqliteTarget = previousSqliteTarget;
448
+ }
449
+ }
450
+ });
451
+
452
+ test('scaffold sqlite session store preserves form and csrf transport without embedding legacy runtime keys', async () => {
453
+ const workspace = await createTempWorkspace('webstir-backend-session-form-sqlite-');
454
+ await seedBackendWorkspace(workspace, '@demo/session-form-sqlite');
455
+ await linkWorkspacePackage(workspace);
456
+ await compileTemplateSessionFiles(workspace);
457
+
458
+ const previousEnv = snapshotEnv([
459
+ 'WORKSPACE_ROOT',
460
+ 'WEBSTIR_WORKSPACE_ROOT',
461
+ 'SESSION_STORE_DRIVER',
462
+ 'SESSION_STORE_URL',
463
+ ]);
464
+
465
+ try {
466
+ process.env.WORKSPACE_ROOT = ' ';
467
+ process.env.WEBSTIR_WORKSPACE_ROOT = workspace;
468
+ process.env.SESSION_STORE_DRIVER = 'sqlite';
469
+ process.env.SESSION_STORE_URL = 'file:./data/form-runtime.sqlite';
470
+
471
+ const { sessionStore } = await importCompiledModule(
472
+ path.join(workspace, 'build', 'backend', 'session', 'store.js'),
473
+ );
474
+ const route = {
475
+ path: '/account/settings',
476
+ form: {
477
+ csrf: true,
478
+ },
479
+ };
480
+
481
+ const initial = prepareSessionState({
482
+ cookies: '',
483
+ route,
484
+ config,
485
+ store: sessionStore,
486
+ });
487
+ const page = prepareFormState({
488
+ session: initial.session,
489
+ formId: 'account-settings',
490
+ route,
491
+ });
492
+ const initialCommit = initial.commit({
493
+ session: page.session,
494
+ route,
495
+ result: {
496
+ status: 200,
497
+ },
498
+ });
499
+ const postState = prepareSessionState({
500
+ cookies: extractCookieHeader(initialCommit.setCookie),
501
+ route,
502
+ config,
503
+ store: sessionStore,
504
+ });
505
+ const failure = processFormSubmission({
506
+ session: postState.session,
507
+ body: {
508
+ _csrf: page.csrfToken,
509
+ email: 'invalid-email',
510
+ },
511
+ auth: { source: 'service-token' },
512
+ formId: 'account-settings',
513
+ route,
514
+ redirectTo: route.path,
515
+ validate(values) {
516
+ return typeof values.email === 'string' && values.email.includes('@')
517
+ ? []
518
+ : [{ field: 'email', message: 'Enter a valid email address.' }];
519
+ },
520
+ });
521
+
522
+ assert.equal(failure.ok, false);
523
+
524
+ const failureCommit = postState.commit({
525
+ session: failure.session,
526
+ route,
527
+ result: failure.result,
528
+ });
529
+ const cookieHeader = failureCommit.setCookie
530
+ ? extractCookieHeader(failureCommit.setCookie)
531
+ : extractCookieHeader(initialCommit.setCookie);
532
+ assert.equal(Object.hasOwn(failureCommit.session ?? {}, '__webstir_form_runtime'), false);
533
+
534
+ const reread = prepareSessionState({
535
+ cookies: cookieHeader,
536
+ route,
537
+ config,
538
+ store: sessionStore,
539
+ });
540
+ assert.equal(Object.hasOwn(reread.session ?? {}, '__webstir_form_runtime'), false);
541
+
542
+ const rereadPage = prepareFormState({
543
+ session: reread.session,
544
+ formId: 'account-settings',
545
+ route,
546
+ });
547
+ assert.equal(rereadPage.values.email, 'invalid-email');
548
+ assert.deepEqual(rereadPage.issues, [
549
+ {
550
+ code: 'validation',
551
+ field: 'email',
552
+ message: 'Enter a valid email address.',
553
+ },
554
+ ]);
555
+ assert.match(String(rereadPage.csrfToken), /^[a-f0-9-]+$/i);
556
+ assert.equal(
557
+ await fs
558
+ .access(path.join(workspace, 'data', 'form-runtime.sqlite'))
559
+ .then(() => true)
560
+ .catch(() => false),
561
+ true,
562
+ );
563
+ } finally {
564
+ restoreEnv(previousEnv);
565
+ }
566
+ });
567
+
568
+ test('scaffold sqlite session store keeps session metadata outside the persisted app payload', async () => {
569
+ const workspace = await createTempWorkspace('webstir-backend-session-metadata-sqlite-');
570
+ await seedBackendWorkspace(workspace, '@demo/session-metadata-sqlite');
571
+ await linkWorkspacePackage(workspace);
572
+ await compileTemplateSessionFiles(workspace);
573
+
574
+ const previousEnv = snapshotEnv([
575
+ 'WORKSPACE_ROOT',
576
+ 'WEBSTIR_WORKSPACE_ROOT',
577
+ 'SESSION_STORE_DRIVER',
578
+ 'SESSION_STORE_URL',
579
+ ]);
580
+
581
+ try {
582
+ process.env.WORKSPACE_ROOT = ' ';
583
+ process.env.WEBSTIR_WORKSPACE_ROOT = workspace;
584
+ process.env.SESSION_STORE_DRIVER = 'sqlite';
585
+ process.env.SESSION_STORE_URL = 'file:./data/session-metadata.sqlite';
586
+
587
+ const { sessionStore } = await importCompiledModule(
588
+ path.join(workspace, 'build', 'backend', 'session', 'store.js'),
589
+ );
590
+ const created = prepareSessionState({
591
+ cookies: '',
592
+ route: loginRoute,
593
+ config,
594
+ store: sessionStore,
595
+ });
596
+ const createdCommit = created.commit({
597
+ session: {
598
+ userId: 'ada@example.com',
599
+ },
600
+ route: loginRoute,
601
+ result: {
602
+ status: 303,
603
+ redirect: { location: '/session/account' },
604
+ },
605
+ });
606
+ const cookieHeader = extractCookieHeader(createdCommit.setCookie);
607
+ const sessionId = extractSessionId(cookieHeader, config.cookieName);
608
+ const stored = sessionStore.get(sessionId);
609
+
610
+ assert.ok(stored, 'expected stored session record');
611
+ assert.equal(Object.hasOwn(stored.value, 'id'), false);
612
+ assert.equal(Object.hasOwn(stored.value, 'createdAt'), false);
613
+ assert.equal(Object.hasOwn(stored.value, 'expiresAt'), false);
614
+ assert.equal(stored.id, sessionId);
615
+
616
+ const read = prepareSessionState({
617
+ cookies: cookieHeader,
618
+ route: accountRoute,
619
+ config,
620
+ store: sessionStore,
621
+ });
622
+ assert.equal(read.session?.userId, 'ada@example.com');
623
+ assert.equal(read.session?.id, sessionId);
624
+ assert.match(String(read.session?.createdAt), /^\d{4}-\d{2}-\d{2}T/);
625
+ assert.match(String(read.session?.expiresAt), /^\d{4}-\d{2}-\d{2}T/);
626
+ assert.equal(Object.keys(read.session ?? {}).includes('id'), false);
627
+ assert.equal(Object.keys(read.session ?? {}).includes('createdAt'), false);
628
+ assert.equal(Object.keys(read.session ?? {}).includes('expiresAt'), false);
629
+ } finally {
630
+ restoreEnv(previousEnv);
631
+ }
632
+ });
633
+
634
+ test('scaffold sqlite session store keeps flash in runtime metadata while reading legacy top-level flash rows', async () => {
635
+ const workspace = await createTempWorkspace('webstir-backend-session-flash-sqlite-');
636
+ await seedBackendWorkspace(workspace, '@demo/session-flash-sqlite');
637
+ await linkWorkspacePackage(workspace);
638
+ await compileTemplateSessionFiles(workspace);
639
+
640
+ const previousEnv = snapshotEnv([
641
+ 'WORKSPACE_ROOT',
642
+ 'WEBSTIR_WORKSPACE_ROOT',
643
+ 'SESSION_STORE_DRIVER',
644
+ 'SESSION_STORE_URL',
645
+ ]);
646
+
647
+ try {
648
+ process.env.WORKSPACE_ROOT = ' ';
649
+ process.env.WEBSTIR_WORKSPACE_ROOT = workspace;
650
+ process.env.SESSION_STORE_DRIVER = 'sqlite';
651
+ process.env.SESSION_STORE_URL = 'file:./data/session-flash.sqlite';
652
+
653
+ const { sessionStore } = await importCompiledModule(
654
+ path.join(workspace, 'build', 'backend', 'session', 'store.js'),
655
+ );
656
+ const created = prepareSessionState({
657
+ cookies: '',
658
+ route: loginRoute,
659
+ config,
660
+ store: sessionStore,
661
+ });
662
+ const createdCommit = created.commit({
663
+ session: {
664
+ userId: 'ada@example.com',
665
+ },
666
+ route: loginRoute,
667
+ result: {
668
+ status: 303,
669
+ redirect: { location: '/session/account' },
670
+ },
671
+ });
672
+ const cookieHeader = extractCookieHeader(createdCommit.setCookie);
673
+ const sessionId = extractSessionId(cookieHeader, config.cookieName);
674
+ const stored = sessionStore.get(sessionId);
675
+
676
+ assert.ok(stored, 'expected stored session record');
677
+ assert.equal(Object.hasOwn(stored, 'flash'), false);
678
+ assert.deepEqual(
679
+ (stored.runtime?.flash ?? []).map((message) => ({ key: message.key, level: message.level })),
680
+ [{ key: 'signed-in', level: 'success' }],
681
+ );
682
+
683
+ sessionStore.set({
684
+ ...stored,
685
+ flash: stored.runtime?.flash ?? [],
686
+ runtime: undefined,
687
+ });
688
+
689
+ const read = prepareSessionState({
690
+ cookies: cookieHeader,
691
+ route: accountRoute,
692
+ config,
693
+ store: sessionStore,
694
+ });
695
+ assert.equal(read.session?.userId, 'ada@example.com');
696
+ assert.deepEqual(
697
+ read.flash.map((message) => ({ key: message.key, level: message.level })),
698
+ [{ key: 'signed-in', level: 'success' }],
699
+ );
700
+ } finally {
701
+ restoreEnv(previousEnv);
702
+ }
703
+ });
704
+
705
+ test('scaffold sqlite session store fails clearly for malformed persisted rows', async () => {
706
+ const workspace = await createTempWorkspace('webstir-backend-session-malformed-sqlite-');
707
+ await seedBackendWorkspace(workspace, '@demo/session-malformed-sqlite');
708
+ await linkWorkspacePackage(workspace);
709
+ await compileTemplateSessionFiles(workspace);
710
+
711
+ const previousEnv = snapshotEnv([
712
+ 'WORKSPACE_ROOT',
713
+ 'WEBSTIR_WORKSPACE_ROOT',
714
+ 'SESSION_STORE_DRIVER',
715
+ 'SESSION_STORE_URL',
716
+ ]);
717
+
718
+ try {
719
+ process.env.WORKSPACE_ROOT = ' ';
720
+ process.env.WEBSTIR_WORKSPACE_ROOT = workspace;
721
+ process.env.SESSION_STORE_DRIVER = 'sqlite';
722
+ process.env.SESSION_STORE_URL = 'file:./data/malformed-session.sqlite';
723
+
724
+ const { sessionStore } = await importCompiledModule(
725
+ path.join(workspace, 'build', 'backend', 'session', 'store.js'),
726
+ );
727
+ const dbPath = path.join(workspace, 'data', 'malformed-session.sqlite');
728
+ const sqliteModule = await import('bun:sqlite');
729
+ const Database = sqliteModule.Database;
730
+ const db = new Database(dbPath);
731
+
732
+ db.prepare(
733
+ `INSERT INTO webstir_sessions (id, value, flash, runtime, created_at, expires_at)
734
+ VALUES (?, ?, ?, ?, ?, ?)`,
735
+ ).run(
736
+ 'malformed-session-id',
737
+ '{not-json',
738
+ '[]',
739
+ '{}',
740
+ '2026-01-01T00:00:00.000Z',
741
+ '2099-01-02T00:00:00.000Z',
742
+ );
743
+ db.close();
744
+
745
+ assert.throws(
746
+ () => sessionStore.get('malformed-session-id'),
747
+ /Failed to deserialize SQLite session row 'malformed-session-id'/,
748
+ );
749
+ } finally {
750
+ restoreEnv(previousEnv);
751
+ }
752
+ });