@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,253 @@
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 } from 'node:url';
8
+
9
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
+ const templatesRoot = path.join(packageRoot, 'templates', 'backend', 'jobs');
11
+
12
+ async function createWorkspace(prefix = 'webstir-backend-jobs-') {
13
+ const workspace = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
14
+ await fs.mkdir(path.join(workspace, 'src', 'backend', 'jobs'), { recursive: true });
15
+ await fs.copyFile(
16
+ path.join(packageRoot, 'templates', 'backend', 'env.ts'),
17
+ path.join(workspace, 'src', 'backend', 'env.ts'),
18
+ );
19
+ await fs.copyFile(
20
+ path.join(templatesRoot, 'runtime.ts'),
21
+ path.join(workspace, 'src', 'backend', 'jobs', 'runtime.ts'),
22
+ );
23
+ await fs.copyFile(
24
+ path.join(templatesRoot, 'scheduler.ts'),
25
+ path.join(workspace, 'src', 'backend', 'jobs', 'scheduler.ts'),
26
+ );
27
+ return workspace;
28
+ }
29
+
30
+ async function writePackage(workspace, jobs) {
31
+ await fs.writeFile(
32
+ path.join(workspace, 'package.json'),
33
+ JSON.stringify({ type: 'module', webstir: { moduleManifest: { jobs } } }, null, 2),
34
+ 'utf8',
35
+ );
36
+ }
37
+
38
+ async function writeJob(workspace, name, source) {
39
+ const dir = path.join(workspace, 'src', 'backend', 'jobs', name);
40
+ await fs.mkdir(dir, { recursive: true });
41
+ await fs.writeFile(path.join(dir, 'index.ts'), source, 'utf8');
42
+ }
43
+
44
+ function runScheduler(workspace, args, options = {}) {
45
+ return collectProcess(startScheduler(workspace, args, options), options);
46
+ }
47
+
48
+ function startScheduler(workspace, args, options = {}) {
49
+ const child = spawn('bun', [path.join('src', 'backend', 'jobs', 'scheduler.ts'), ...args], {
50
+ cwd: workspace,
51
+ env: { ...process.env, ...options.env },
52
+ stdio: ['ignore', 'pipe', 'pipe'],
53
+ });
54
+ return child;
55
+ }
56
+
57
+ function collectProcess(child, options = {}) {
58
+ let stdout = '';
59
+ let stderr = '';
60
+ child.stdout.setEncoding('utf8');
61
+ child.stderr.setEncoding('utf8');
62
+ child.stdout.on('data', (chunk) => {
63
+ stdout += chunk;
64
+ });
65
+ child.stderr.on('data', (chunk) => {
66
+ stderr += chunk;
67
+ });
68
+
69
+ let timeout;
70
+ return new Promise((resolve, reject) => {
71
+ if (options.timeoutMs) {
72
+ timeout = setTimeout(() => {
73
+ child.kill('SIGTERM');
74
+ reject(new Error(`scheduler timed out\nstdout:\n${stdout}\nstderr:\n${stderr}`));
75
+ }, options.timeoutMs);
76
+ }
77
+
78
+ child.once('error', reject);
79
+ child.once('exit', (code, signal) => {
80
+ clearTimeout(timeout);
81
+ resolve({ code, signal, stdout, stderr });
82
+ });
83
+ });
84
+ }
85
+
86
+ test('jobs scheduler lists stable text and JSON metadata', async () => {
87
+ const workspace = await createWorkspace();
88
+ try {
89
+ await writePackage(workspace, [
90
+ {
91
+ name: 'nightly',
92
+ schedule: 'rate(5 minutes)',
93
+ description: 'Refresh snapshots',
94
+ priority: 10,
95
+ },
96
+ ]);
97
+ await writeJob(workspace, 'nightly', 'export async function run() {}\n');
98
+
99
+ const listed = await runScheduler(workspace, ['--list']);
100
+ assert.equal(listed.code, 0);
101
+ assert.match(listed.stdout, /- nightly \(rate\(5 minutes\)\).*Refresh snapshots/);
102
+
103
+ const json = await runScheduler(workspace, ['--json']);
104
+ assert.equal(json.code, 0);
105
+ assert.deepEqual(JSON.parse(json.stdout), [
106
+ {
107
+ name: 'nightly',
108
+ schedule: 'rate(5 minutes)',
109
+ description: 'Refresh snapshots',
110
+ priority: 10,
111
+ },
112
+ ]);
113
+ } finally {
114
+ await fs.rm(workspace, { recursive: true, force: true });
115
+ }
116
+ });
117
+
118
+ test('jobs scheduler runs named jobs and reports missing jobs clearly', async () => {
119
+ const workspace = await createWorkspace();
120
+ try {
121
+ await writePackage(workspace, [{ name: 'existing' }]);
122
+ await writeJob(
123
+ workspace,
124
+ 'existing',
125
+ 'export async function run() { console.log("ran job"); }\n',
126
+ );
127
+
128
+ const existing = await runScheduler(workspace, ['--job', 'existing']);
129
+ assert.equal(existing.code, 0);
130
+ assert.match(existing.stdout, /ran job/);
131
+ assert.match(existing.stdout, /\[jobs\] existing completed/);
132
+
133
+ const missing = await runScheduler(workspace, ['--job', 'missing']);
134
+ assert.equal(missing.code, 1);
135
+ assert.match(missing.stderr, /\[jobs\] job 'missing' not found/);
136
+ } finally {
137
+ await fs.rm(workspace, { recursive: true, force: true });
138
+ }
139
+ });
140
+
141
+ test('jobs scheduler reports missing modules, missing run exports, and thrown errors', async () => {
142
+ const workspace = await createWorkspace();
143
+ try {
144
+ await writePackage(workspace, [
145
+ { name: 'missing-module' },
146
+ { name: 'missing-run' },
147
+ { name: 'throws' },
148
+ ]);
149
+ await writeJob(workspace, 'missing-run', 'export const value = 1;\n');
150
+ await writeJob(
151
+ workspace,
152
+ 'throws',
153
+ 'export async function run() { throw new Error("database unavailable"); }\n',
154
+ );
155
+
156
+ const missingModule = await runScheduler(workspace, ['--job', 'missing-module']);
157
+ assert.equal(missingModule.code, 1);
158
+ assert.match(missingModule.stderr, /missing-module failed/);
159
+ assert.match(missingModule.stderr, /unable to load job 'missing-module'/);
160
+
161
+ const missingRun = await runScheduler(workspace, ['--job', 'missing-run']);
162
+ assert.equal(missingRun.code, 1);
163
+ assert.match(missingRun.stderr, /job module must export a run\(\) or default function/);
164
+
165
+ const throws = await runScheduler(workspace, ['--job', 'throws']);
166
+ assert.equal(throws.code, 1);
167
+ assert.match(throws.stderr, /throws failed: database unavailable/);
168
+ } finally {
169
+ await fs.rm(workspace, { recursive: true, force: true });
170
+ }
171
+ });
172
+
173
+ test('jobs scheduler supports @reboot, rate schedules, cron schedules, and unsupported diagnostics', {
174
+ timeout: 10_000,
175
+ }, async () => {
176
+ const workspace = await createWorkspace();
177
+ try {
178
+ await writePackage(workspace, [
179
+ { name: 'boot', schedule: '@reboot' },
180
+ { name: 'fast', schedule: 'rate(1 second)' },
181
+ { name: 'cron', schedule: '* * * * *' },
182
+ { name: 'bad', schedule: 'every Tuesdayish' },
183
+ ]);
184
+ await writeJob(workspace, 'boot', 'export async function run() { console.log("booted"); }\n');
185
+ await writeJob(workspace, 'fast', 'export async function run() { console.log("fast"); }\n');
186
+ await writeJob(workspace, 'cron', 'export async function run() { console.log("cron"); }\n');
187
+ await writeJob(workspace, 'bad', 'export async function run() { console.log("bad"); }\n');
188
+
189
+ const reboot = await runScheduler(workspace, ['--watch', '--job', 'boot']);
190
+ assert.equal(reboot.code, 0);
191
+ assert.match(reboot.stdout, /booted/);
192
+ assert.match(reboot.stdout, /completed @reboot jobs and exiting/);
193
+
194
+ const unsupported = await runScheduler(workspace, ['--watch', '--job', 'bad']);
195
+ assert.equal(unsupported.code, 0);
196
+ assert.match(unsupported.stdout, /unsupported schedule 'every Tuesdayish'/);
197
+
198
+ const rate = startScheduler(workspace, ['--watch', '--job', 'fast']);
199
+ const rateResultPromise = collectProcess(rate, { timeoutMs: 4000 });
200
+ await wait(1500);
201
+ rate.kill('SIGTERM');
202
+ const rateResult = await rateResultPromise;
203
+ assert.equal(rateResult.code, 0);
204
+ assert.match(rateResult.stdout, /watching jobs: fast/);
205
+ assert.match(rateResult.stdout, /fast/);
206
+ assert.match(rateResult.stdout, /received SIGTERM; stopped 1 scheduled job timer/);
207
+
208
+ const cron = startScheduler(workspace, ['--watch', '--job', 'cron']);
209
+ const cronResultPromise = collectProcess(cron, { timeoutMs: 4000 });
210
+ await wait(300);
211
+ cron.kill('SIGTERM');
212
+ const cronResult = await cronResultPromise;
213
+ assert.equal(cronResult.code, 0);
214
+ assert.match(cronResult.stdout, /watching jobs: cron/);
215
+ assert.match(cronResult.stdout, /received SIGTERM; stopped 1 scheduled job timer/);
216
+ } finally {
217
+ await fs.rm(workspace, { recursive: true, force: true });
218
+ }
219
+ });
220
+
221
+ test('jobs scheduler skips overlapping rate runs', { timeout: 10_000 }, async () => {
222
+ const workspace = await createWorkspace();
223
+ try {
224
+ await writePackage(workspace, [{ name: 'slow', schedule: 'rate(1 second)' }]);
225
+ await writeJob(
226
+ workspace,
227
+ 'slow',
228
+ [
229
+ 'const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));',
230
+ 'export async function run() {',
231
+ ' console.log("slow-start");',
232
+ ' await sleep(1400);',
233
+ ' console.log("slow-end");',
234
+ '}',
235
+ ].join('\n'),
236
+ );
237
+
238
+ const child = startScheduler(workspace, ['--watch', '--job', 'slow']);
239
+ const resultPromise = collectProcess(child, { timeoutMs: 6000 });
240
+ await wait(2300);
241
+ child.kill('SIGTERM');
242
+ const result = await resultPromise;
243
+ assert.equal(result.code, 0);
244
+ assert.match(result.stdout, /slow-start/);
245
+ assert.match(result.stderr, /skipping slow; previous run is still active/);
246
+ } finally {
247
+ await fs.rm(workspace, { recursive: true, force: true });
248
+ }
249
+ });
250
+
251
+ async function wait(delayMs) {
252
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
253
+ }
@@ -50,15 +50,19 @@ test('manifest loader honors package overrides', async () => {
50
50
  name: '@demo/custom',
51
51
  version: '2.0.0',
52
52
  kind: 'backend',
53
- capabilities: ['db']
54
- }
55
- }
53
+ capabilities: ['db'],
54
+ },
55
+ },
56
56
  };
57
- await fs.writeFile(path.join(workspace, 'package.json'), JSON.stringify(pkgJson, null, 2), 'utf8');
57
+ await fs.writeFile(
58
+ path.join(workspace, 'package.json'),
59
+ JSON.stringify(pkgJson, null, 2),
60
+ 'utf8',
61
+ );
58
62
 
59
63
  const env = {
60
64
  WEBSTIR_MODULE_MODE: 'build',
61
- PATH: `${getLocalBinPath()}${path.delimiter}${process.env.PATH ?? ''}`
65
+ PATH: `${getLocalBinPath()}${path.delimiter}${process.env.PATH ?? ''}`,
62
66
  };
63
67
 
64
68
  const result = await backendProvider.build({ workspaceRoot: workspace, env, incremental: false });
@@ -76,13 +80,17 @@ test('manifest loader falls back to package name/version when no overrides prese
76
80
  const pkgJson = {
77
81
  name: '@demo/fallback',
78
82
  version: '4.5.6',
79
- type: 'module'
83
+ type: 'module',
80
84
  };
81
- await fs.writeFile(path.join(workspace, 'package.json'), JSON.stringify(pkgJson, null, 2), 'utf8');
85
+ await fs.writeFile(
86
+ path.join(workspace, 'package.json'),
87
+ JSON.stringify(pkgJson, null, 2),
88
+ 'utf8',
89
+ );
82
90
 
83
91
  const env = {
84
92
  WEBSTIR_MODULE_MODE: 'build',
85
- PATH: `${getLocalBinPath()}${path.delimiter}${process.env.PATH ?? ''}`
93
+ PATH: `${getLocalBinPath()}${path.delimiter}${process.env.PATH ?? ''}`,
86
94
  };
87
95
 
88
96
  const result = await backendProvider.build({ workspaceRoot: workspace, env, incremental: false });
@@ -113,12 +121,12 @@ test('manifest loader merges compiled module definition metadata', async () => {
113
121
  await fs.writeFile(
114
122
  path.join(workspace, 'package.json'),
115
123
  JSON.stringify({ name: '@demo/fallback-package', version: '0.0.1', type: 'module' }, null, 2),
116
- 'utf8'
124
+ 'utf8',
117
125
  );
118
126
 
119
127
  const env = {
120
128
  WEBSTIR_MODULE_MODE: 'build',
121
- PATH: `${getLocalBinPath()}${path.delimiter}${process.env.PATH ?? ''}`
129
+ PATH: `${getLocalBinPath()}${path.delimiter}${process.env.PATH ?? ''}`,
122
130
  };
123
131
 
124
132
  const result = await backendProvider.build({ workspaceRoot: workspace, env, incremental: false });
@@ -130,6 +138,214 @@ test('manifest loader merges compiled module definition metadata', async () => {
130
138
  assert.deepEqual(moduleManifest?.capabilities, ['search']);
131
139
  });
132
140
 
141
+ test('manifest loader merges package routes with compiled module routes without duplicating overlaps', async () => {
142
+ const workspace = await createTempWorkspace();
143
+ await seedBackendEntry(workspace);
144
+
145
+ const moduleSource = `export const module = {
146
+ manifest: {
147
+ contractVersion: '1.0.0',
148
+ name: '@demo/with-routes',
149
+ version: '1.2.3',
150
+ kind: 'backend',
151
+ capabilities: ['http'],
152
+ routes: [
153
+ {
154
+ name: 'builtInRoute',
155
+ method: 'GET',
156
+ path: '/demo/built-in',
157
+ summary: 'Built-in route'
158
+ },
159
+ {
160
+ name: 'overlapRoute',
161
+ method: 'GET',
162
+ path: '/demo/overlap',
163
+ summary: 'Built-in overlap route'
164
+ }
165
+ ],
166
+ views: []
167
+ }
168
+ };
169
+ `;
170
+
171
+ await fs.writeFile(path.join(workspace, 'src', 'backend', 'module.ts'), moduleSource, 'utf8');
172
+ await fs.writeFile(
173
+ path.join(workspace, 'package.json'),
174
+ JSON.stringify(
175
+ {
176
+ name: '@demo/routes-package',
177
+ version: '0.0.1',
178
+ type: 'module',
179
+ webstir: {
180
+ moduleManifest: {
181
+ routes: [
182
+ {
183
+ name: 'overlapRouteFromPackage',
184
+ method: 'GET',
185
+ path: '/demo/overlap',
186
+ summary: 'Package overlap route',
187
+ },
188
+ {
189
+ name: 'packageOnlyRoute',
190
+ method: 'POST',
191
+ path: '/demo/package-only',
192
+ summary: 'Package-only route',
193
+ },
194
+ ],
195
+ },
196
+ },
197
+ },
198
+ null,
199
+ 2,
200
+ ),
201
+ 'utf8',
202
+ );
203
+
204
+ const env = {
205
+ WEBSTIR_MODULE_MODE: 'build',
206
+ PATH: `${getLocalBinPath()}${path.delimiter}${process.env.PATH ?? ''}`,
207
+ };
208
+
209
+ const result = await backendProvider.build({ workspaceRoot: workspace, env, incremental: false });
210
+ const moduleManifest = result.manifest.module;
211
+
212
+ assert.deepEqual(
213
+ moduleManifest?.routes?.map((route) => ({
214
+ name: route.name,
215
+ method: route.method,
216
+ path: route.path,
217
+ })),
218
+ [
219
+ { name: 'builtInRoute', method: 'GET', path: '/demo/built-in' },
220
+ { name: 'overlapRoute', method: 'GET', path: '/demo/overlap' },
221
+ { name: 'packageOnlyRoute', method: 'POST', path: '/demo/package-only' },
222
+ ],
223
+ );
224
+ });
225
+
226
+ test('manifest loader merges package jobs with compiled module jobs without duplicating names', async () => {
227
+ const workspace = await createTempWorkspace();
228
+ await seedBackendEntry(workspace);
229
+
230
+ const moduleSource = `export const module = {
231
+ manifest: {
232
+ contractVersion: '1.0.0',
233
+ name: '@demo/with-jobs',
234
+ version: '1.2.3',
235
+ kind: 'backend',
236
+ jobs: [
237
+ {
238
+ name: 'nightly',
239
+ schedule: '0 0 * * *'
240
+ }
241
+ ],
242
+ routes: [],
243
+ views: []
244
+ }
245
+ };
246
+ `;
247
+
248
+ await fs.writeFile(path.join(workspace, 'src', 'backend', 'module.ts'), moduleSource, 'utf8');
249
+ await fs.writeFile(
250
+ path.join(workspace, 'package.json'),
251
+ JSON.stringify(
252
+ {
253
+ name: '@demo/jobs-package',
254
+ version: '0.0.1',
255
+ type: 'module',
256
+ webstir: {
257
+ moduleManifest: {
258
+ jobs: [
259
+ {
260
+ name: 'nightly',
261
+ schedule: 'rate(1 hour)',
262
+ },
263
+ {
264
+ name: 'session-cleanup',
265
+ schedule: 'rate(5 minutes)',
266
+ },
267
+ ],
268
+ },
269
+ },
270
+ },
271
+ null,
272
+ 2,
273
+ ),
274
+ 'utf8',
275
+ );
276
+
277
+ const env = {
278
+ WEBSTIR_MODULE_MODE: 'build',
279
+ PATH: `${getLocalBinPath()}${path.delimiter}${process.env.PATH ?? ''}`,
280
+ };
281
+
282
+ const result = await backendProvider.build({ workspaceRoot: workspace, env, incremental: false });
283
+ const moduleManifest = result.manifest.module;
284
+
285
+ assert.deepEqual(
286
+ moduleManifest?.jobs?.map((job) => ({
287
+ name: job.name,
288
+ schedule: job.schedule,
289
+ })),
290
+ [
291
+ { name: 'nightly', schedule: '0 0 * * *' },
292
+ { name: 'session-cleanup', schedule: 'rate(5 minutes)' },
293
+ ],
294
+ );
295
+ });
296
+
297
+ test('manifest loader falls back to module exports from the compiled index entry', async () => {
298
+ const workspace = await createTempWorkspace();
299
+ await ensureDir(path.join(workspace, 'src', 'backend'));
300
+
301
+ const indexSource = `export const module = {
302
+ manifest: {
303
+ contractVersion: '1.0.0',
304
+ name: '@demo/index-module',
305
+ version: '3.2.1',
306
+ kind: 'backend',
307
+ capabilities: ['http'],
308
+ routes: [
309
+ {
310
+ name: 'indexEntryRoute',
311
+ method: 'GET',
312
+ path: '/demo/index-entry',
313
+ summary: 'Route surfaced from index.ts'
314
+ }
315
+ ],
316
+ views: []
317
+ }
318
+ };
319
+ `;
320
+
321
+ await fs.writeFile(path.join(workspace, 'src', 'backend', 'index.ts'), indexSource, 'utf8');
322
+ await fs.writeFile(
323
+ path.join(workspace, 'package.json'),
324
+ JSON.stringify(
325
+ { name: '@demo/index-fallback-package', version: '0.0.1', type: 'module' },
326
+ null,
327
+ 2,
328
+ ),
329
+ 'utf8',
330
+ );
331
+
332
+ const env = {
333
+ WEBSTIR_MODULE_MODE: 'build',
334
+ PATH: `${getLocalBinPath()}${path.delimiter}${process.env.PATH ?? ''}`,
335
+ };
336
+
337
+ const result = await backendProvider.build({ workspaceRoot: workspace, env, incremental: false });
338
+ const moduleManifest = result.manifest.module;
339
+
340
+ assert.equal(moduleManifest?.name, '@demo/index-module');
341
+ assert.equal(moduleManifest?.version, '3.2.1');
342
+ assert.deepEqual(moduleManifest?.capabilities, ['http']);
343
+ assert.deepEqual(
344
+ moduleManifest?.routes?.map((route) => route.path),
345
+ ['/demo/index-entry'],
346
+ );
347
+ });
348
+
133
349
  test('scaffold assets expose core backend templates', async () => {
134
350
  const assets = await backendProvider.getScaffoldAssets();
135
351
  const targetSet = new Set(assets.map((asset) => asset.targetPath));
@@ -138,10 +354,11 @@ test('scaffold assets expose core backend templates', async () => {
138
354
  path.join('src', 'backend', 'tsconfig.json'),
139
355
  path.join('src', 'backend', 'index.ts'),
140
356
  path.join('src', 'backend', 'module.ts'),
141
- path.join('src', 'backend', 'server', 'fastify.ts'),
142
357
  path.join('src', 'backend', 'auth', 'adapter.ts'),
143
358
  path.join('src', 'backend', 'observability', 'logger.ts'),
144
359
  path.join('src', 'backend', 'observability', 'metrics.ts'),
360
+ path.join('src', 'backend', 'session', 'store.ts'),
361
+ path.join('src', 'backend', 'session', 'sqlite.ts'),
145
362
  path.join('src', 'backend', 'functions', 'hello', 'index.ts'),
146
363
  path.join('src', 'backend', 'jobs', 'nightly', 'index.ts'),
147
364
  path.join('src', 'backend', 'jobs', 'runtime.ts'),
@@ -150,10 +367,68 @@ test('scaffold assets expose core backend templates', async () => {
150
367
  path.join('src', 'backend', 'db', 'migrate.ts'),
151
368
  path.join('src', 'backend', 'db', 'migrations', '0001-example.ts'),
152
369
  path.join('src', 'backend', 'db', 'types.d.ts'),
153
- path.join('.env.example')
370
+ path.join('.env.example'),
154
371
  ];
155
372
 
156
373
  for (const target of requiredTargets) {
157
374
  assert.ok(targetSet.has(target), `expected scaffold assets to include ${target}`);
158
375
  }
376
+
377
+ const removedTargets = [
378
+ path.join('src', 'backend', 'server', 'bun.ts'),
379
+ path.join('src', 'backend', 'runtime', 'request-hooks.ts'),
380
+ path.join('src', 'backend', 'runtime', 'session.ts'),
381
+ path.join('src', 'backend', 'runtime', 'forms.ts'),
382
+ path.join('src', 'backend', 'runtime', 'views.ts'),
383
+ path.join('src', 'backend', 'runtime', 'core.ts'),
384
+ path.join('src', 'backend', 'runtime', 'fastify.ts'),
385
+ path.join('src', 'backend', 'server', 'fastify.ts'),
386
+ ];
387
+
388
+ for (const target of removedTargets) {
389
+ assert.ok(!targetSet.has(target), `expected scaffold assets to omit ${target}`);
390
+ }
391
+ const sessionStoreAsset = assets.find(
392
+ (asset) => asset.targetPath === path.join('src', 'backend', 'session', 'store.ts'),
393
+ );
394
+ assert.ok(sessionStoreAsset, 'expected scaffold assets to include the session store helper');
395
+
396
+ const sessionStoreSource = await fs.readFile(sessionStoreAsset.sourcePath, 'utf8');
397
+ assert.match(sessionStoreSource, /createSessionStoreFromEnv/);
398
+ assert.match(sessionStoreSource, /@webstir-io\/webstir-backend\/runtime\/session/);
399
+ assert.match(sessionStoreSource, /SESSION_STORE_DRIVER/);
400
+
401
+ const sqliteSessionStoreAsset = assets.find(
402
+ (asset) => asset.targetPath === path.join('src', 'backend', 'session', 'sqlite.ts'),
403
+ );
404
+ assert.ok(
405
+ sqliteSessionStoreAsset,
406
+ 'expected scaffold assets to include the durable sqlite session store helper',
407
+ );
408
+
409
+ const sqliteSessionStoreSource = await fs.readFile(sqliteSessionStoreAsset.sourcePath, 'utf8');
410
+ assert.match(sqliteSessionStoreSource, /createSqliteSessionStore/);
411
+
412
+ const schedulerAsset = assets.find(
413
+ (asset) => asset.targetPath === path.join('src', 'backend', 'jobs', 'scheduler.ts'),
414
+ );
415
+ assert.ok(schedulerAsset, 'expected scaffold assets to include the job scheduler');
416
+
417
+ const schedulerSource = await fs.readFile(schedulerAsset.sourcePath, 'utf8');
418
+ assert.match(schedulerSource, /^#!\/usr\/bin\/env bun/m);
419
+ assert.match(schedulerSource, /bun build\/backend\/jobs\/scheduler\.js --job <name>/);
420
+ assert.match(schedulerSource, /Bun\.cron\.parse/);
421
+ assert.match(
422
+ schedulerSource,
423
+ /--json\s+Print registered job metadata as JSON for external schedulers/,
424
+ );
425
+
426
+ const migrateAsset = assets.find(
427
+ (asset) => asset.targetPath === path.join('src', 'backend', 'db', 'migrate.ts'),
428
+ );
429
+ assert.ok(migrateAsset, 'expected scaffold assets to include the database migration runner');
430
+
431
+ const migrateSource = await fs.readFile(migrateAsset.sourcePath, 'utf8');
432
+ assert.match(migrateSource, /^#!\/usr\/bin\/env bun/m);
433
+ assert.match(migrateSource, /bun src\/backend\/db\/migrate\.ts \[--list\]/);
159
434
  });