@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,152 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+
5
+ import type {
6
+ SessionFlashMessage,
7
+ SessionStore,
8
+ SessionStoreRecord,
9
+ } from '@webstir-io/webstir-backend/runtime/session';
10
+
11
+ import { resolveWorkspaceRoot } from '../env.js';
12
+
13
+ interface SqliteSessionStoreOptions {
14
+ url?: string;
15
+ }
16
+
17
+ interface SqliteSessionRow {
18
+ id: string;
19
+ value: string;
20
+ flash: string;
21
+ runtime: string;
22
+ createdAt: string;
23
+ expiresAt: string;
24
+ }
25
+
26
+ type SqliteDatabase = {
27
+ prepare(sql: string): {
28
+ run(...params: unknown[]): void;
29
+ get(...params: unknown[]): SqliteSessionRow | undefined;
30
+ all(...params: unknown[]): { name: string }[];
31
+ };
32
+ };
33
+
34
+ const DEFAULT_SQLITE_SESSION_STORE_URL = 'file:./data/sessions.sqlite';
35
+ const SESSION_TABLE_NAME = 'webstir_sessions';
36
+ const require = createRequire(import.meta.url);
37
+
38
+ export function createSqliteSessionStore<
39
+ TSession extends Record<string, unknown> = Record<string, unknown>,
40
+ >(options: SqliteSessionStoreOptions = {}): SessionStore<TSession> {
41
+ const Database = loadBunSqlite();
42
+ const target = normalizeSqlitePath(options.url ?? DEFAULT_SQLITE_SESSION_STORE_URL);
43
+ mkdirSync(path.dirname(target), { recursive: true });
44
+
45
+ const db = new Database(target) as SqliteDatabase;
46
+ db.prepare(`
47
+ CREATE TABLE IF NOT EXISTS ${SESSION_TABLE_NAME} (
48
+ id TEXT PRIMARY KEY,
49
+ value TEXT NOT NULL,
50
+ flash TEXT NOT NULL,
51
+ runtime TEXT NOT NULL DEFAULT '{}',
52
+ created_at TEXT NOT NULL,
53
+ expires_at TEXT NOT NULL
54
+ )
55
+ `).run();
56
+ ensureRuntimeColumn(db);
57
+ db.prepare(`
58
+ CREATE INDEX IF NOT EXISTS ${SESSION_TABLE_NAME}_expires_at_idx
59
+ ON ${SESSION_TABLE_NAME} (expires_at)
60
+ `).run();
61
+
62
+ const deleteExpiredStatement = db.prepare(
63
+ `DELETE FROM ${SESSION_TABLE_NAME} WHERE expires_at <= ?`,
64
+ );
65
+ const getStatement = db.prepare(`
66
+ SELECT id, value, flash, runtime, created_at AS createdAt, expires_at AS expiresAt
67
+ FROM ${SESSION_TABLE_NAME}
68
+ WHERE id = ?
69
+ `);
70
+ const setStatement = db.prepare(`
71
+ INSERT INTO ${SESSION_TABLE_NAME} (id, value, flash, runtime, created_at, expires_at)
72
+ VALUES (?, ?, ?, ?, ?, ?)
73
+ ON CONFLICT(id) DO UPDATE SET
74
+ value = excluded.value,
75
+ flash = excluded.flash,
76
+ runtime = excluded.runtime,
77
+ created_at = excluded.created_at,
78
+ expires_at = excluded.expires_at
79
+ `);
80
+ const deleteStatement = db.prepare(`DELETE FROM ${SESSION_TABLE_NAME} WHERE id = ?`);
81
+
82
+ return {
83
+ get(sessionId) {
84
+ deleteExpiredStatement.run(new Date().toISOString());
85
+ const row = getStatement.get(sessionId);
86
+ return row ? deserializeSessionRecord<TSession>(row) : undefined;
87
+ },
88
+ set(record) {
89
+ setStatement.run(
90
+ record.id,
91
+ JSON.stringify(record.value),
92
+ JSON.stringify(record.flash ?? []),
93
+ JSON.stringify(record.runtime ?? {}),
94
+ record.createdAt,
95
+ record.expiresAt,
96
+ );
97
+ },
98
+ delete(sessionId) {
99
+ deleteStatement.run(sessionId);
100
+ },
101
+ };
102
+ }
103
+
104
+ function ensureRuntimeColumn(db: SqliteDatabase): void {
105
+ const columns = db.prepare(`PRAGMA table_info(${SESSION_TABLE_NAME})`).all();
106
+ if (columns.some((column) => column.name === 'runtime')) {
107
+ return;
108
+ }
109
+
110
+ db.prepare(
111
+ `ALTER TABLE ${SESSION_TABLE_NAME} ADD COLUMN runtime TEXT NOT NULL DEFAULT '{}'`,
112
+ ).run();
113
+ }
114
+
115
+ function loadBunSqlite(): new (filename: string) => SqliteDatabase {
116
+ try {
117
+ const sqliteModule = require('bun:sqlite');
118
+ return sqliteModule.Database ?? sqliteModule.default ?? sqliteModule;
119
+ } catch (error) {
120
+ throw new Error(
121
+ `[session] Failed to load bun:sqlite. Run the SQLite session store with Bun or switch SESSION_STORE_DRIVER to "memory". (${(error as Error).message})`,
122
+ );
123
+ }
124
+ }
125
+
126
+ function normalizeSqlitePath(url: string): string {
127
+ const workspaceRoot = resolveWorkspaceRoot();
128
+ const target = url.startsWith('file:') ? url.slice('file:'.length) : url;
129
+ return path.isAbsolute(target) ? path.resolve(target) : path.resolve(workspaceRoot, target);
130
+ }
131
+
132
+ function deserializeSessionRecord<TSession extends Record<string, unknown>>(
133
+ row: SqliteSessionRow,
134
+ ): SessionStoreRecord<TSession> {
135
+ try {
136
+ const flash = JSON.parse(row.flash) as SessionFlashMessage[];
137
+ const runtime = JSON.parse(row.runtime) as SessionStoreRecord<TSession>['runtime'];
138
+ const legacyFlash = Array.isArray(runtime?.flash) || flash.length === 0 ? undefined : flash;
139
+ return {
140
+ id: row.id,
141
+ value: JSON.parse(row.value) as TSession,
142
+ ...(legacyFlash ? { flash: legacyFlash } : {}),
143
+ ...(runtime ? { runtime } : {}),
144
+ createdAt: row.createdAt,
145
+ expiresAt: row.expiresAt,
146
+ };
147
+ } catch (error) {
148
+ throw new Error(
149
+ `[session] Failed to deserialize SQLite session row '${row.id}': ${(error as Error).message}`,
150
+ );
151
+ }
152
+ }
@@ -0,0 +1,45 @@
1
+ import {
2
+ createInMemorySessionStore,
3
+ type SessionStore,
4
+ } from '@webstir-io/webstir-backend/runtime/session';
5
+ import { createSqliteSessionStore } from './sqlite.js';
6
+
7
+ const DEFAULT_SQLITE_SESSION_STORE_URL = 'file:./data/sessions.sqlite';
8
+
9
+ export function createSessionStoreFromEnv<
10
+ TSession extends Record<string, unknown> = Record<string, unknown>,
11
+ >(env: NodeJS.ProcessEnv = process.env): SessionStore<TSession> {
12
+ const driver = normalizeSessionStoreDriver(env);
13
+
14
+ if (driver === 'memory') {
15
+ return createInMemorySessionStore<TSession>();
16
+ }
17
+
18
+ if (driver === 'sqlite') {
19
+ return createSqliteSessionStore<TSession>({
20
+ url: normalizeSessionStoreUrl(env.SESSION_STORE_URL),
21
+ });
22
+ }
23
+
24
+ throw new Error(
25
+ `[session] Unsupported SESSION_STORE_DRIVER '${driver}'. Use "memory" or "sqlite".`,
26
+ );
27
+ }
28
+
29
+ export const sessionStore = createSessionStoreFromEnv<Record<string, unknown>>();
30
+
31
+ function normalizeSessionStoreDriver(env: NodeJS.ProcessEnv): 'memory' | 'sqlite' | string {
32
+ const normalized = env.SESSION_STORE_DRIVER?.trim().toLowerCase();
33
+ if (!normalized) {
34
+ if (env.SESSION_STORE_URL?.trim()) {
35
+ return 'sqlite';
36
+ }
37
+ return env.NODE_ENV?.trim().toLowerCase() === 'production' ? 'sqlite' : 'memory';
38
+ }
39
+ return normalized;
40
+ }
41
+
42
+ function normalizeSessionStoreUrl(value: string | undefined): string {
43
+ const normalized = value?.trim();
44
+ return normalized || DEFAULT_SQLITE_SESSION_STORE_URL;
45
+ }
@@ -11,7 +11,7 @@
11
11
  "rootDir": ".",
12
12
  "declaration": false,
13
13
  "sourceMap": true,
14
- "types": ["node"],
14
+ "types": ["node", "bun"],
15
15
  "resolveJsonModule": true
16
16
  },
17
17
  "include": ["**/*"],
@@ -0,0 +1,327 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ import { backendProvider, runAddJob, runAddRoute, runUpdateRouteContract } from '../dist/index.js';
8
+
9
+ async function createTempWorkspace(prefix = 'webstir-backend-add-') {
10
+ return await fs.mkdtemp(path.join(os.tmpdir(), prefix));
11
+ }
12
+
13
+ async function ensureDir(dir) {
14
+ await fs.mkdir(dir, { recursive: true });
15
+ }
16
+
17
+ async function copyAssetByTarget(workspace, targetPath) {
18
+ const assets = await backendProvider.getScaffoldAssets();
19
+ const asset = assets.find((entry) => entry.targetPath === targetPath);
20
+ assert.ok(asset, `expected scaffold asset ${targetPath}`);
21
+ const destination = path.join(workspace, targetPath);
22
+ await ensureDir(path.dirname(destination));
23
+ await fs.copyFile(asset.sourcePath, destination);
24
+ }
25
+
26
+ async function readJson(filePath) {
27
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
28
+ }
29
+
30
+ test('runAddRoute writes manifest metadata without scaffolding server-specific files', async () => {
31
+ const workspace = await createTempWorkspace();
32
+
33
+ await fs.writeFile(
34
+ path.join(workspace, 'package.json'),
35
+ JSON.stringify(
36
+ {
37
+ name: '@demo/api',
38
+ version: '1.0.0',
39
+ type: 'module',
40
+ webstir: { mode: 'api', moduleManifest: {} },
41
+ },
42
+ null,
43
+ 2,
44
+ ),
45
+ 'utf8',
46
+ );
47
+
48
+ const result = await runAddRoute({
49
+ workspaceRoot: workspace,
50
+ name: 'accounts',
51
+ method: 'POST',
52
+ path: '/api/accounts',
53
+ summary: 'List accounts',
54
+ description: 'Returns the current account list',
55
+ tags: ['accounts', 'api', 'accounts'],
56
+ interaction: 'mutation',
57
+ sessionMode: 'required',
58
+ sessionWrite: true,
59
+ formUrlEncoded: true,
60
+ formCsrf: true,
61
+ fragmentTarget: 'accounts-panel',
62
+ fragmentMode: 'replace',
63
+ paramsSchema: 'zod:AccountParams@src/shared/contracts/accounts.ts',
64
+ responseSchema: 'AccountList@src/shared/contracts/accounts.ts',
65
+ responseStatus: '201',
66
+ });
67
+
68
+ assert.equal(result.subject, 'route');
69
+ assert.ok(result.changes.includes('package.json'));
70
+ assert.deepEqual(result.changes, ['package.json']);
71
+
72
+ const pkg = await readJson(path.join(workspace, 'package.json'));
73
+ assert.deepEqual(pkg.webstir.moduleManifest.routes, [
74
+ {
75
+ name: 'accounts',
76
+ method: 'POST',
77
+ path: '/api/accounts',
78
+ summary: 'List accounts',
79
+ description: 'Returns the current account list',
80
+ tags: ['accounts', 'api'],
81
+ interaction: 'mutation',
82
+ session: {
83
+ mode: 'required',
84
+ write: true,
85
+ },
86
+ form: {
87
+ contentType: 'application/x-www-form-urlencoded',
88
+ csrf: true,
89
+ },
90
+ fragment: {
91
+ target: 'accounts-panel',
92
+ mode: 'replace',
93
+ },
94
+ input: {
95
+ params: {
96
+ kind: 'zod',
97
+ name: 'AccountParams',
98
+ source: 'src/shared/contracts/accounts.ts',
99
+ },
100
+ },
101
+ output: {
102
+ body: {
103
+ kind: 'zod',
104
+ name: 'AccountList',
105
+ source: 'src/shared/contracts/accounts.ts',
106
+ },
107
+ status: 201,
108
+ },
109
+ },
110
+ ]);
111
+ });
112
+
113
+ test('runAddRoute preserves interaction, session, form, and fragment metadata', async () => {
114
+ const workspace = await createTempWorkspace('webstir-backend-add-route-metadata-');
115
+
116
+ await fs.writeFile(
117
+ path.join(workspace, 'package.json'),
118
+ JSON.stringify(
119
+ {
120
+ name: '@demo/api',
121
+ version: '1.0.0',
122
+ type: 'module',
123
+ webstir: { mode: 'api', moduleManifest: {} },
124
+ },
125
+ null,
126
+ 2,
127
+ ),
128
+ 'utf8',
129
+ );
130
+
131
+ const result = await runAddRoute({
132
+ workspaceRoot: workspace,
133
+ name: 'session-sign-in',
134
+ method: 'POST',
135
+ path: '/session/sign-in',
136
+ interaction: 'mutation',
137
+ sessionMode: 'required',
138
+ sessionWrite: true,
139
+ formUrlEncoded: true,
140
+ formCsrf: true,
141
+ fragmentTarget: 'session-panel',
142
+ fragmentSelector: '#session-panel',
143
+ fragmentMode: 'replace',
144
+ });
145
+
146
+ assert.equal(result.subject, 'route');
147
+ assert.deepEqual(result.changes, ['package.json']);
148
+
149
+ const pkg = await readJson(path.join(workspace, 'package.json'));
150
+ assert.deepEqual(pkg.webstir.moduleManifest.routes, [
151
+ {
152
+ name: 'session-sign-in',
153
+ method: 'POST',
154
+ path: '/session/sign-in',
155
+ interaction: 'mutation',
156
+ session: {
157
+ mode: 'required',
158
+ write: true,
159
+ },
160
+ form: {
161
+ contentType: 'application/x-www-form-urlencoded',
162
+ csrf: true,
163
+ },
164
+ fragment: {
165
+ target: 'session-panel',
166
+ selector: '#session-panel',
167
+ mode: 'replace',
168
+ },
169
+ },
170
+ ]);
171
+ });
172
+
173
+ test('runAddJob writes a job scaffold and preserves description in a valid manifest', async () => {
174
+ const workspace = await createTempWorkspace('webstir-backend-add-job-');
175
+
176
+ await copyAssetByTarget(workspace, path.join('src', 'backend', 'index.ts'));
177
+ await fs.writeFile(
178
+ path.join(workspace, 'package.json'),
179
+ JSON.stringify(
180
+ {
181
+ name: '@demo/api',
182
+ version: '1.0.0',
183
+ type: 'module',
184
+ webstir: { mode: 'api', moduleManifest: {} },
185
+ },
186
+ null,
187
+ 2,
188
+ ),
189
+ 'utf8',
190
+ );
191
+
192
+ const result = await runAddJob({
193
+ workspaceRoot: workspace,
194
+ name: 'nightly',
195
+ schedule: 'rate(5 minutes)',
196
+ description: 'Nightly maintenance run',
197
+ priority: '5',
198
+ });
199
+
200
+ assert.equal(result.subject, 'job');
201
+ assert.ok(result.changes.includes('package.json'));
202
+ assert.ok(result.changes.includes('src/backend/jobs/nightly/index.ts'));
203
+
204
+ const pkg = await readJson(path.join(workspace, 'package.json'));
205
+ assert.deepEqual(pkg.webstir.moduleManifest.jobs, [
206
+ {
207
+ name: 'nightly',
208
+ schedule: 'rate(5 minutes)',
209
+ description: 'Nightly maintenance run',
210
+ priority: 5,
211
+ },
212
+ ]);
213
+
214
+ const buildResult = await backendProvider.build({
215
+ workspaceRoot: workspace,
216
+ env: {
217
+ WEBSTIR_MODULE_MODE: 'build',
218
+ WEBSTIR_BACKEND_TYPECHECK: 'skip',
219
+ PATH: process.env.PATH ?? '',
220
+ },
221
+ incremental: false,
222
+ });
223
+
224
+ assert.deepEqual(buildResult.manifest.module?.jobs, [
225
+ {
226
+ name: 'nightly',
227
+ schedule: 'rate(5 minutes)',
228
+ description: 'Nightly maintenance run',
229
+ priority: 5,
230
+ },
231
+ ]);
232
+ });
233
+
234
+ test('runAddJob rejects malformed rate schedules before writing files', async () => {
235
+ const workspace = await createTempWorkspace('webstir-backend-add-job-rate-');
236
+
237
+ await assert.rejects(
238
+ runAddJob({
239
+ workspaceRoot: workspace,
240
+ name: 'nightly',
241
+ schedule: 'rate(0 seconds)',
242
+ }),
243
+ /Expected rate\(<positive integer> second\(s\)\|minute\(s\)\|hour\(s\)\)/,
244
+ );
245
+ });
246
+
247
+ test('runUpdateRouteContract merges advanced metadata onto an existing route', async () => {
248
+ const workspace = await createTempWorkspace('webstir-backend-update-route-contract-');
249
+
250
+ await fs.writeFile(
251
+ path.join(workspace, 'package.json'),
252
+ JSON.stringify(
253
+ {
254
+ name: '@demo/api',
255
+ version: '1.0.0',
256
+ type: 'module',
257
+ webstir: {
258
+ mode: 'api',
259
+ moduleManifest: {
260
+ routes: [
261
+ {
262
+ name: 'session-sign-in',
263
+ method: 'POST',
264
+ path: '/session/sign-in',
265
+ summary: 'Sign in',
266
+ interaction: 'mutation',
267
+ },
268
+ ],
269
+ },
270
+ },
271
+ },
272
+ null,
273
+ 2,
274
+ ),
275
+ 'utf8',
276
+ );
277
+
278
+ const result = await runUpdateRouteContract({
279
+ workspaceRoot: workspace,
280
+ method: 'POST',
281
+ path: '/session/sign-in',
282
+ sessionMode: 'required',
283
+ sessionWrite: true,
284
+ formUrlEncoded: true,
285
+ formCsrf: true,
286
+ fragmentTarget: 'session-panel',
287
+ fragmentSelector: '#session-panel',
288
+ fragmentMode: 'replace',
289
+ responseSchema: 'SessionSignInResponse@src/shared/contracts/session.ts',
290
+ responseStatus: 200,
291
+ });
292
+
293
+ assert.equal(result.subject, 'route');
294
+ assert.deepEqual(result.changes, ['package.json']);
295
+
296
+ const pkg = await readJson(path.join(workspace, 'package.json'));
297
+ assert.deepEqual(pkg.webstir.moduleManifest.routes, [
298
+ {
299
+ name: 'session-sign-in',
300
+ method: 'POST',
301
+ path: '/session/sign-in',
302
+ summary: 'Sign in',
303
+ interaction: 'mutation',
304
+ session: {
305
+ mode: 'required',
306
+ write: true,
307
+ },
308
+ form: {
309
+ contentType: 'application/x-www-form-urlencoded',
310
+ csrf: true,
311
+ },
312
+ fragment: {
313
+ target: 'session-panel',
314
+ selector: '#session-panel',
315
+ mode: 'replace',
316
+ },
317
+ output: {
318
+ body: {
319
+ kind: 'zod',
320
+ name: 'SessionSignInResponse',
321
+ source: 'src/shared/contracts/session.ts',
322
+ },
323
+ status: 200,
324
+ },
325
+ },
326
+ ]);
327
+ });