@kuratchi/js 0.0.20 → 0.0.22

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.
@@ -0,0 +1,175 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ export function buildDesktopManifest(opts) {
4
+ const { projectDir, workerFile, desktopConfig, ormDatabases } = opts;
5
+ if (!desktopConfig)
6
+ return null;
7
+ const wranglerConfig = readDesktopWranglerConfig(projectDir);
8
+ const projectName = path.basename(projectDir);
9
+ const appName = desktopConfig.appName ?? projectName;
10
+ const appId = desktopConfig.appId ?? `dev.kuratchi.${projectName.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}`;
11
+ const assetsRoot = resolveAssetsRoot(projectDir);
12
+ const requestedRemoteBindings = desktopConfig.remoteBindings.length > 0
13
+ ? desktopConfig.remoteBindings
14
+ : ormDatabases
15
+ .filter((entry) => entry.type === 'd1' && entry.remote)
16
+ .map((entry) => ({ binding: entry.binding, type: 'd1', remote: true }));
17
+ const remoteBindings = requestedRemoteBindings.map((binding) => {
18
+ if (binding.type === 'd1') {
19
+ const wranglerBinding = wranglerConfig.d1Databases.find((entry) => entry.binding === binding.binding && entry.remote);
20
+ if (!wranglerBinding) {
21
+ throw new Error(`Desktop manifest could not resolve remote D1 binding "${binding.binding}" from wrangler.jsonc.`);
22
+ }
23
+ return {
24
+ binding: binding.binding,
25
+ type: 'd1',
26
+ remote: true,
27
+ databaseId: wranglerBinding.databaseId,
28
+ databaseName: wranglerBinding.databaseName,
29
+ };
30
+ }
31
+ const wranglerBinding = wranglerConfig.r2Buckets.find((entry) => entry.binding === binding.binding && entry.remote);
32
+ if (!wranglerBinding) {
33
+ throw new Error(`Desktop manifest could not resolve remote R2 binding "${binding.binding}" from wrangler.jsonc.`);
34
+ }
35
+ return {
36
+ binding: binding.binding,
37
+ type: 'r2',
38
+ remote: true,
39
+ bucketName: wranglerBinding.bucketName,
40
+ };
41
+ });
42
+ return {
43
+ formatVersion: 1,
44
+ generatedAt: new Date().toISOString(),
45
+ projectDir,
46
+ app: {
47
+ name: appName,
48
+ id: appId,
49
+ initialPath: desktopConfig.initialPath || '/',
50
+ window: {
51
+ title: desktopConfig.windowTitle ?? appName,
52
+ width: desktopConfig.windowWidth,
53
+ height: desktopConfig.windowHeight,
54
+ },
55
+ },
56
+ runtime: {
57
+ workerEntrypoint: workerFile,
58
+ assetsRoot,
59
+ compatibilityDate: wranglerConfig.compatibilityDate,
60
+ compatibilityFlags: wranglerConfig.compatibilityFlags,
61
+ cloudflareAccountId: wranglerConfig.cloudflareAccountId,
62
+ },
63
+ bindings: {
64
+ desktop: {
65
+ notifications: desktopConfig.bindings.notifications,
66
+ files: desktopConfig.bindings.files,
67
+ },
68
+ remote: remoteBindings,
69
+ },
70
+ };
71
+ }
72
+ export function writeDesktopManifest(projectDir, manifest) {
73
+ const outFile = path.join(projectDir, '.kuratchi', 'desktop.manifest.json');
74
+ const next = `${JSON.stringify(manifest, null, 2)}\n`;
75
+ if (fs.existsSync(outFile)) {
76
+ const current = fs.readFileSync(outFile, 'utf-8');
77
+ if (current === next)
78
+ return outFile;
79
+ }
80
+ fs.writeFileSync(outFile, next, 'utf-8');
81
+ return outFile;
82
+ }
83
+ function resolveAssetsRoot(projectDir) {
84
+ const publicDir = path.join(projectDir, '.kuratchi', 'public');
85
+ if (fs.existsSync(publicDir))
86
+ return publicDir;
87
+ const srcAssets = path.join(projectDir, 'src', 'assets');
88
+ if (fs.existsSync(srcAssets))
89
+ return srcAssets;
90
+ return null;
91
+ }
92
+ function readDesktopWranglerConfig(projectDir) {
93
+ const configPath = ['wrangler.jsonc', 'wrangler.json']
94
+ .map((name) => path.join(projectDir, name))
95
+ .find((candidate) => fs.existsSync(candidate));
96
+ if (!configPath) {
97
+ throw new Error('Desktop runtime requires wrangler.jsonc or wrangler.json for compatibility settings and remote bindings.');
98
+ }
99
+ const rawConfig = fs.readFileSync(configPath, 'utf-8');
100
+ const parsed = JSON.parse(stripJsonComments(rawConfig));
101
+ if (!parsed.compatibility_date) {
102
+ throw new Error(`Missing compatibility_date in ${path.basename(configPath)}.`);
103
+ }
104
+ return {
105
+ compatibilityDate: parsed.compatibility_date,
106
+ compatibilityFlags: Array.isArray(parsed.compatibility_flags) ? parsed.compatibility_flags : [],
107
+ cloudflareAccountId: typeof parsed.account_id === 'string' && parsed.account_id.trim().length > 0
108
+ ? parsed.account_id.trim()
109
+ : null,
110
+ d1Databases: Array.isArray(parsed.d1_databases)
111
+ ? parsed.d1_databases
112
+ .filter((entry) => entry.binding && entry.database_id)
113
+ .map((entry) => ({
114
+ binding: entry.binding,
115
+ databaseId: entry.database_id,
116
+ databaseName: entry.database_name ?? null,
117
+ remote: entry.remote !== false,
118
+ }))
119
+ : [],
120
+ r2Buckets: Array.isArray(parsed.r2_buckets)
121
+ ? parsed.r2_buckets
122
+ .filter((entry) => entry.binding && entry.bucket_name)
123
+ .map((entry) => ({
124
+ binding: entry.binding,
125
+ bucketName: entry.bucket_name,
126
+ remote: entry.remote !== false,
127
+ }))
128
+ : [],
129
+ };
130
+ }
131
+ function stripJsonComments(content) {
132
+ let result = '';
133
+ let i = 0;
134
+ let inString = false;
135
+ let stringChar = '';
136
+ while (i < content.length) {
137
+ const ch = content[i];
138
+ const next = content[i + 1];
139
+ if (inString) {
140
+ result += ch;
141
+ if (ch === '\\' && i + 1 < content.length) {
142
+ result += next;
143
+ i += 2;
144
+ continue;
145
+ }
146
+ if (ch === stringChar) {
147
+ inString = false;
148
+ }
149
+ i++;
150
+ continue;
151
+ }
152
+ if (ch === '"' || ch === '\'') {
153
+ inString = true;
154
+ stringChar = ch;
155
+ result += ch;
156
+ i++;
157
+ continue;
158
+ }
159
+ if (ch === '/' && next === '/') {
160
+ while (i < content.length && content[i] !== '\n')
161
+ i++;
162
+ continue;
163
+ }
164
+ if (ch === '/' && next === '*') {
165
+ i += 2;
166
+ while (i < content.length - 1 && !(content[i] === '*' && content[i + 1] === '/'))
167
+ i++;
168
+ i += 2;
169
+ continue;
170
+ }
171
+ result += ch;
172
+ i++;
173
+ }
174
+ return result;
175
+ }
@@ -1,6 +1,10 @@
1
- import { type DoConfigEntry, type DoHandlerEntry, type OrmDatabaseEntry } from './compiler-shared.js';
2
- export declare function discoverDurableObjects(srcDir: string, configDoEntries: DoConfigEntry[], ormDatabases: OrmDatabaseEntry[]): {
3
- config: DoConfigEntry[];
1
+ import { type DoHandlerEntry } from './compiler-shared.js';
2
+ export declare function discoverDurableObjects(srcDir: string): {
3
+ config: {
4
+ binding: string;
5
+ className: string;
6
+ files?: string[];
7
+ }[];
4
8
  handlers: DoHandlerEntry[];
5
9
  };
6
10
  export declare function generateHandlerProxy(handler: DoHandlerEntry, opts: {
@@ -3,27 +3,14 @@ import * as path from 'node:path';
3
3
  import * as ts from 'typescript';
4
4
  import { toSafeIdentifier, } from './compiler-shared.js';
5
5
  import { discoverFilesWithExtensions, discoverFilesWithSuffix } from './convention-discovery.js';
6
- export function discoverDurableObjects(srcDir, configDoEntries, ormDatabases) {
6
+ export function discoverDurableObjects(srcDir) {
7
7
  const serverDir = path.join(srcDir, 'server');
8
8
  const legacyDir = path.join(srcDir, 'durable-objects');
9
9
  const serverDoFiles = discoverFilesWithSuffix(serverDir, '.do.ts');
10
10
  const legacyDoFiles = discoverFilesWithSuffix(legacyDir, '.ts');
11
11
  const discoveredFiles = Array.from(new Set([...serverDoFiles, ...legacyDoFiles]));
12
12
  if (discoveredFiles.length === 0) {
13
- return { config: configDoEntries, handlers: [] };
14
- }
15
- const bindings = new Set(configDoEntries.map((d) => d.binding));
16
- const fileToBinding = new Map();
17
- for (const entry of configDoEntries) {
18
- for (const rawFile of entry.files ?? []) {
19
- const normalized = rawFile.trim().replace(/^\.?[\\/]/, '').replace(/\\/g, '/').toLowerCase();
20
- if (!normalized)
21
- continue;
22
- fileToBinding.set(normalized, entry.binding);
23
- const base = path.basename(normalized);
24
- if (!fileToBinding.has(base))
25
- fileToBinding.set(base, entry.binding);
26
- }
13
+ return { config: [], handlers: [] };
27
14
  }
28
15
  const handlers = [];
29
16
  const handlerIdToAbsPath = new Map();
@@ -48,28 +35,17 @@ export function discoverDurableObjects(srcDir, configDoEntries, ormDatabases) {
48
35
  continue;
49
36
  // Binding resolution:
50
37
  // 1) explicit static binding declared in the class
51
- // 2) config-mapped file name
52
- // 3) if exactly one binding exists, infer it
38
+ // 2) derive from the handler file name
53
39
  let binding = null;
54
40
  const bindingMatch = source.match(/static\s+binding\s*=\s*['"](\w+)['"]/);
55
41
  if (bindingMatch) {
56
42
  binding = bindingMatch[1];
57
43
  }
58
44
  else {
59
- const normalizedFile = file.replace(/\\/g, '/').toLowerCase();
60
- const normalizedRelFromSrc = path.relative(srcDir, absPath).replace(/\\/g, '/').toLowerCase();
61
- binding = className ? (configDoEntries.find((entry) => entry.className === className)?.binding ?? null) : null;
62
- if (!binding) {
63
- binding = fileToBinding.get(normalizedRelFromSrc) ?? fileToBinding.get(normalizedFile) ?? null;
64
- }
65
- if (!binding && configDoEntries.length === 1) {
66
- binding = configDoEntries[0].binding;
67
- }
45
+ binding = deriveBindingFromFile(file);
68
46
  }
69
47
  if (!binding)
70
48
  continue;
71
- if (!bindings.has(binding))
72
- continue;
73
49
  const classMethods = className ? extractClassMethods(absPath, source, className) : [];
74
50
  const fileName = path
75
51
  .relative(absPath.startsWith(serverDir) ? serverDir : legacyDir, absPath)
@@ -103,17 +79,14 @@ export function discoverDurableObjects(srcDir, configDoEntries, ormDatabases) {
103
79
  }
104
80
  }
105
81
  // Build config entries from discovered handlers (de-duped by binding).
106
- // Prefer class name from the original config entry (e.g. from wrangler.jsonc).
82
+ // The handler source is authoritative; wrangler.jsonc is then synced from this.
107
83
  const discoveredConfigByBinding = new Map();
108
84
  for (const handler of handlers) {
109
- const configEntry = configDoEntries.find((e) => e.binding === handler.binding);
110
85
  const existing = discoveredConfigByBinding.get(handler.binding);
111
86
  if (!existing) {
112
87
  discoveredConfigByBinding.set(handler.binding, {
113
88
  binding: handler.binding,
114
- // Use config class name when available (authoritative, e.g. from wrangler.jsonc).
115
- className: configEntry?.className ?? handler.className ?? handler.binding,
116
- stubId: configEntry?.stubId,
89
+ className: handler.className ?? deriveClassNameFromFile(path.basename(handler.absPath)),
117
90
  files: [path.basename(handler.absPath)],
118
91
  });
119
92
  }
@@ -121,9 +94,32 @@ export function discoverDurableObjects(srcDir, configDoEntries, ormDatabases) {
121
94
  existing.files?.push(path.basename(handler.absPath));
122
95
  }
123
96
  }
124
- void ormDatabases;
125
97
  return { config: [...discoveredConfigByBinding.values()], handlers };
126
98
  }
99
+ function deriveBindingFromFile(fileName) {
100
+ const normalized = fileName
101
+ .replace(/\.(ts|tsx|js|mjs|cjs)$/i, '')
102
+ .replace(/\.do$/i, '')
103
+ .replace(/[^A-Za-z0-9]+/g, '_')
104
+ .replace(/^_+|_+$/g, '')
105
+ .toUpperCase();
106
+ if (!normalized)
107
+ return 'DURABLE_OBJECT';
108
+ return normalized.endsWith('_DO') ? normalized : `${normalized}_DO`;
109
+ }
110
+ function deriveClassNameFromFile(fileName) {
111
+ const normalized = fileName
112
+ .replace(/\.(ts|tsx|js|mjs|cjs)$/i, '')
113
+ .replace(/\.do$/i, '');
114
+ const base = normalized
115
+ .split(/[^A-Za-z0-9]+/)
116
+ .filter(Boolean)
117
+ .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
118
+ .join('');
119
+ if (!base)
120
+ return 'DurableObjectHandler';
121
+ return base.endsWith('DO') ? base : `${base}DO`;
122
+ }
127
123
  // ---------------------------------------------------------------------------
128
124
  // TypeScript AST helpers
129
125
  // ---------------------------------------------------------------------------
@@ -7,7 +7,7 @@ import { compileAssets } from './asset-pipeline.js';
7
7
  import { compileApiRoute } from './api-route-pipeline.js';
8
8
  import { createClientModuleCompiler } from './client-module-pipeline.js';
9
9
  import { createComponentCompiler } from './component-pipeline.js';
10
- import { readAssetsPrefix, readAuthConfig, readDoConfig, readOrmConfig, readSecurityConfig, readUiConfigValues, readUiTheme, } from './config-reading.js';
10
+ import { readAssetsPrefix, readAuthConfig, readOrmConfig, readSecurityConfig, readUiConfigValues, readUiTheme, } from './config-reading.js';
11
11
  import { discoverContainerFiles, discoverConventionClassFiles, discoverWorkflowFiles, } from './convention-discovery.js';
12
12
  import { discoverDurableObjects, generateHandlerProxy } from './durable-object-pipeline.js';
13
13
  import { compileErrorPages } from './error-page-pipeline.js';
@@ -132,8 +132,7 @@ export async function compile(options) {
132
132
  // Read security config from kuratchi.config.ts
133
133
  const securityConfig = readSecurityConfig(projectDir);
134
134
  // Auto-discover Durable Objects from .do.ts files (config optional, only needed for stubId)
135
- const configDoEntries = readDoConfig(projectDir);
136
- const { config: doConfig, handlers: doHandlers } = discoverDurableObjects(srcDir, configDoEntries, ormDatabases);
135
+ const { config: doConfig, handlers: doHandlers } = discoverDurableObjects(srcDir);
137
136
  // Auto-discover convention-based worker class files (no config needed)
138
137
  const containerConfig = discoverContainerFiles(projectDir);
139
138
  const workflowConfig = discoverWorkflowFiles(projectDir);
@@ -1135,6 +1135,7 @@ export function parseFile(source, options = {}) {
1135
1135
  const actionFunctions = [];
1136
1136
  const pollFunctions = [];
1137
1137
  const dataGetQueries = [];
1138
+ let warnedLegacyActionAttrs = false;
1138
1139
  for (const tag of templateTags) {
1139
1140
  if (tag.closing)
1140
1141
  continue;
@@ -1143,6 +1144,11 @@ export function parseFile(source, options = {}) {
1143
1144
  actionFunctions.push(actionExpr);
1144
1145
  }
1145
1146
  for (const [attrName, attrValue] of tag.attrs.entries()) {
1147
+ if (!warnedLegacyActionAttrs && (attrName === 'data-action' || attrName === 'data-args')) {
1148
+ warnedLegacyActionAttrs = true;
1149
+ console.warn(`[kuratchi] ${options.filePath || kind}: authored data-action/data-args are deprecated. ` +
1150
+ `Use data-post={fn(...)} or action={fn} instead.`);
1151
+ }
1146
1152
  if (/^on[A-Za-z]+$/i.test(attrName)) {
1147
1153
  const actionCall = extractCallExpression(attrValue);
1148
1154
  if (actionCall && !actionFunctions.includes(actionCall.fnName))
@@ -383,7 +383,11 @@ function buildDurableObjectBlock(opts) {
383
383
  doResolverLines.push(` });`);
384
384
  }
385
385
  else {
386
- doResolverLines.push(` // No 'stubId' config for ${doEntry.binding} - stub must be obtained manually`);
386
+ doResolverLines.push(` __registerDoResolver('${doEntry.binding}', async () => {`);
387
+ doResolverLines.push(` const __ns = __env['${doEntry.binding}'];`);
388
+ doResolverLines.push(` if (!__ns?.idFromName || !__ns?.get) return null;`);
389
+ doResolverLines.push(` return __ns.get(__ns.idFromName('global'));`);
390
+ doResolverLines.push(` });`);
387
391
  }
388
392
  }
389
393
  return {
@@ -37,6 +37,12 @@ export function createServerModuleCompiler(options) {
37
37
  return resolveExistingModuleFile(proxyNoExt) ?? (fs.existsSync(proxyNoExt + '.ts') ? proxyNoExt + '.ts' : null);
38
38
  }
39
39
  function resolveImportTarget(importerAbs, spec) {
40
+ // Handle kuratchi:* virtual modules
41
+ if (spec.startsWith('kuratchi:')) {
42
+ // These are resolved at bundle time to @kuratchi/js runtime modules
43
+ // Return null to keep the specifier as-is for later rewriting
44
+ return null;
45
+ }
40
46
  if (spec.startsWith('$')) {
41
47
  const slashIdx = spec.indexOf('/');
42
48
  const folder = slashIdx === -1 ? spec.slice(1) : spec.slice(1, slashIdx);
@@ -70,6 +76,15 @@ export function createServerModuleCompiler(options) {
70
76
  }
71
77
  const source = fs.readFileSync(resolved, 'utf-8');
72
78
  const rewriteSpecifier = (spec) => {
79
+ // Rewrite kuratchi:* virtual modules to @kuratchi/js runtime paths
80
+ if (spec.startsWith('kuratchi:')) {
81
+ const moduleName = spec.slice('kuratchi:'.length);
82
+ const moduleMap = {
83
+ 'request': '@kuratchi/js/request',
84
+ 'navigation': '@kuratchi/js/navigation',
85
+ };
86
+ return moduleMap[moduleName] ?? spec;
87
+ }
73
88
  const target = resolveImportTarget(resolved, spec);
74
89
  if (!target)
75
90
  return spec;
@@ -96,6 +111,15 @@ export function createServerModuleCompiler(options) {
96
111
  return outPath;
97
112
  }
98
113
  function resolveCompiledImportPath(origPath, importerDir, outFileDir) {
114
+ // Rewrite kuratchi:* virtual modules to @kuratchi/js runtime paths
115
+ if (origPath.startsWith('kuratchi:')) {
116
+ const moduleName = origPath.slice('kuratchi:'.length);
117
+ const moduleMap = {
118
+ 'request': '@kuratchi/js/request',
119
+ 'navigation': '@kuratchi/js/navigation',
120
+ };
121
+ return moduleMap[moduleName] ?? origPath;
122
+ }
99
123
  const isBareModule = !origPath.startsWith('.') && !origPath.startsWith('/') && !origPath.startsWith('$');
100
124
  if (isBareModule)
101
125
  return origPath;
@@ -37,6 +37,36 @@ const JS_CONTROL_PATTERNS = [
37
37
  function isJsControlLine(line) {
38
38
  return JS_CONTROL_PATTERNS.some(p => p.test(line));
39
39
  }
40
+ /** HTML boolean attributes that should be present or absent, never have a value */
41
+ const BOOLEAN_ATTRIBUTES = new Set([
42
+ 'disabled',
43
+ 'checked',
44
+ 'selected',
45
+ 'readonly',
46
+ 'required',
47
+ 'hidden',
48
+ 'open',
49
+ 'autofocus',
50
+ 'autoplay',
51
+ 'controls',
52
+ 'default',
53
+ 'defer',
54
+ 'formnovalidate',
55
+ 'inert',
56
+ 'ismap',
57
+ 'itemscope',
58
+ 'loop',
59
+ 'multiple',
60
+ 'muted',
61
+ 'nomodule',
62
+ 'novalidate',
63
+ 'playsinline',
64
+ 'reversed',
65
+ 'async',
66
+ ]);
67
+ function isBooleanAttribute(name) {
68
+ return BOOLEAN_ATTRIBUTES.has(name.toLowerCase());
69
+ }
40
70
  const FRAGMENT_OPEN_MARKER = '<!--__KURATCHI_FRAGMENT_OPEN:';
41
71
  const FRAGMENT_CLOSE_MARKER = '<!--__KURATCHI_FRAGMENT_CLOSE-->';
42
72
  export function splitTemplateRenderSections(template) {
@@ -1130,9 +1160,13 @@ function compileHtmlSegment(line, actionNames, rpcNameMap, options = {}) {
1130
1160
  pos = closeIdx + 1;
1131
1161
  continue;
1132
1162
  }
1133
- else if (attrName === 'disabled' || attrName === 'checked' || attrName === 'hidden' || attrName === 'readonly') {
1134
- // Boolean attributes: disabled={expr} → conditionally include
1135
- result += `"\${${inner} ? '' : undefined}"`;
1163
+ else if (isBooleanAttribute(attrName)) {
1164
+ // Boolean attributes: disabled={expr} conditionally include the attribute or omit entirely
1165
+ // Remove the trailing "attrName=" we already appended, we'll handle it with a ternary
1166
+ result = result.replace(new RegExp(`\\s*${attrName}=$`), '');
1167
+ result += `\${${inner} ? ' ${attrName}' : ''}`;
1168
+ pos = closeIdx + 1;
1169
+ continue;
1136
1170
  }
1137
1171
  else {
1138
1172
  // Regular attribute: value={expr} → value="escaped"
@@ -0,0 +1,8 @@
1
+ export interface GenerateTypesOptions {
2
+ projectDir: string;
3
+ schemaPath?: string;
4
+ outputPath?: string;
5
+ localsInterface?: string;
6
+ }
7
+ export declare function generateAppTypes(options: GenerateTypesOptions): string;
8
+ export declare function writeAppTypes(options: GenerateTypesOptions): void;
@@ -0,0 +1,124 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ function sqliteTypeToTs(sqlType) {
4
+ const lower = sqlType.toLowerCase();
5
+ if (lower.includes('integer') || lower.includes('int') || lower.includes('real') || lower.includes('numeric')) {
6
+ return 'number';
7
+ }
8
+ if (lower.includes('text') || lower.includes('varchar') || lower.includes('char')) {
9
+ return 'string';
10
+ }
11
+ if (lower.includes('blob')) {
12
+ return 'Uint8Array';
13
+ }
14
+ if (lower.includes('json')) {
15
+ return 'Record<string, unknown>';
16
+ }
17
+ if (lower.includes('boolean') || lower.includes('bool')) {
18
+ return 'boolean';
19
+ }
20
+ return 'unknown';
21
+ }
22
+ function parseSchemaColumn(name, definition) {
23
+ const lower = definition.toLowerCase();
24
+ const nullable = !lower.includes('not null');
25
+ const hasDefault = lower.includes('default');
26
+ const type = sqliteTypeToTs(definition);
27
+ return { name, type, nullable, hasDefault };
28
+ }
29
+ function parseSchemaFromSource(source) {
30
+ const tables = [];
31
+ // Match tables: { tableName: { col: 'def', ... }, ... }
32
+ const tablesMatch = source.match(/tables\s*:\s*\{([\s\S]*?)\n\t?\}/);
33
+ if (!tablesMatch)
34
+ return tables;
35
+ const tablesBlock = tablesMatch[1];
36
+ // Match each table definition
37
+ const tableRegex = /(\w+)\s*:\s*\{([^}]+)\}/g;
38
+ let match;
39
+ while ((match = tableRegex.exec(tablesBlock)) !== null) {
40
+ const tableName = match[1];
41
+ const columnsBlock = match[2];
42
+ const columns = [];
43
+ // Match each column: name: 'definition'
44
+ const colRegex = /(\w+)\s*:\s*['"]([^'"]+)['"]/g;
45
+ let colMatch;
46
+ while ((colMatch = colRegex.exec(columnsBlock)) !== null) {
47
+ columns.push(parseSchemaColumn(colMatch[1], colMatch[2]));
48
+ }
49
+ tables.push({ name: tableName, columns });
50
+ }
51
+ return tables;
52
+ }
53
+ function generateTableTypes(tables) {
54
+ const lines = [];
55
+ for (const table of tables) {
56
+ const pascalName = table.name
57
+ .split('_')
58
+ .map(s => s.charAt(0).toUpperCase() + s.slice(1))
59
+ .join('');
60
+ lines.push(` /** Row type for ${table.name} table */`);
61
+ lines.push(` interface ${pascalName}Row {`);
62
+ for (const col of table.columns) {
63
+ const optional = col.nullable || col.hasDefault ? '?' : '';
64
+ lines.push(` ${col.name}${optional}: ${col.type};`);
65
+ }
66
+ lines.push(` }`);
67
+ lines.push('');
68
+ }
69
+ return lines.join('\n');
70
+ }
71
+ export function generateAppTypes(options) {
72
+ const { projectDir, schemaPath = 'src/server/schema.ts', outputPath = 'src/app.d.ts', localsInterface, } = options;
73
+ const schemaFullPath = path.join(projectDir, schemaPath);
74
+ let tables = [];
75
+ if (fs.existsSync(schemaFullPath)) {
76
+ const schemaSource = fs.readFileSync(schemaFullPath, 'utf-8');
77
+ tables = parseSchemaFromSource(schemaSource);
78
+ }
79
+ const tableTypes = tables.length > 0 ? generateTableTypes(tables) : '';
80
+ // Check if user has existing Locals definition to preserve
81
+ const outputFullPath = path.join(projectDir, outputPath);
82
+ let existingLocals = null;
83
+ if (fs.existsSync(outputFullPath)) {
84
+ const existing = fs.readFileSync(outputFullPath, 'utf-8');
85
+ // Extract user-defined Locals interface (between USER LOCALS START/END markers)
86
+ const localsMatch = existing.match(/\/\/ USER LOCALS START\n([\s\S]*?)\/\/ USER LOCALS END/);
87
+ if (localsMatch) {
88
+ existingLocals = localsMatch[1];
89
+ }
90
+ }
91
+ const localsBlock = existingLocals || localsInterface || ` interface Locals {
92
+ userId: number;
93
+ userEmail: string;
94
+ }`;
95
+ const output = `/**
96
+ * Type declarations for kuratchi app.
97
+ *
98
+ * DB types are auto-generated from schema.ts - regenerate with: kuratchi types
99
+ * Edit the Locals interface below to match your runtime.hook.ts
100
+ */
101
+ declare global {
102
+ namespace App {
103
+ /** Request-scoped locals set by runtime hooks */
104
+ // USER LOCALS START
105
+ ${localsBlock}
106
+ // USER LOCALS END
107
+
108
+ ${tableTypes ? ` // Database table row types (auto-generated from schema.ts)\n${tableTypes}` : ''} }
109
+ }
110
+
111
+ export {};
112
+ `;
113
+ return output;
114
+ }
115
+ export function writeAppTypes(options) {
116
+ const output = generateAppTypes(options);
117
+ const outputPath = path.join(options.projectDir, options.outputPath || 'src/app.d.ts');
118
+ const dir = path.dirname(outputPath);
119
+ if (!fs.existsSync(dir)) {
120
+ fs.mkdirSync(dir, { recursive: true });
121
+ }
122
+ fs.writeFileSync(outputPath, output, 'utf-8');
123
+ console.log(`[kuratchi] Generated types → ${path.relative(options.projectDir, outputPath)}`);
124
+ }
@@ -1,5 +1,26 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
+ function nextMigrationTag(existing) {
4
+ const used = new Set(existing
5
+ .map((entry) => (entry && typeof entry.tag === 'string' ? entry.tag : null))
6
+ .filter(Boolean));
7
+ let maxNumeric = 0;
8
+ for (const tag of used) {
9
+ const match = /^v(\d+)$/i.exec(String(tag));
10
+ if (match) {
11
+ maxNumeric = Math.max(maxNumeric, Number(match[1]));
12
+ }
13
+ }
14
+ let candidate = `v${maxNumeric > 0 ? maxNumeric + 1 : 1}`;
15
+ if (!used.has(candidate))
16
+ return candidate;
17
+ let index = maxNumeric + 1;
18
+ while (used.has(candidate)) {
19
+ index += 1;
20
+ candidate = `v${index}`;
21
+ }
22
+ return candidate;
23
+ }
3
24
  function stripJsonComments(content) {
4
25
  let result = '';
5
26
  let i = 0;
@@ -169,8 +190,34 @@ export function syncWranglerConfig(opts) {
169
190
  changed = true;
170
191
  console.log(`[kuratchi] Added durable_object "${durableObject.binding}" to wrangler config`);
171
192
  }
193
+ else if (existing.class_name !== durableObject.className) {
194
+ existing.class_name = durableObject.className;
195
+ changed = true;
196
+ console.log(`[kuratchi] Updated durable_object "${durableObject.binding}" class_name to "${durableObject.className}"`);
197
+ }
172
198
  }
173
199
  wranglerConfig.durable_objects.bindings = existingBindings;
200
+ const existingMigrations = Array.isArray(wranglerConfig.migrations) ? wranglerConfig.migrations : [];
201
+ const knownClasses = new Set();
202
+ for (const migration of existingMigrations) {
203
+ const newClasses = Array.isArray(migration?.new_sqlite_classes) ? migration.new_sqlite_classes : [];
204
+ for (const className of newClasses) {
205
+ if (typeof className === 'string' && className)
206
+ knownClasses.add(className);
207
+ }
208
+ }
209
+ const missingClasses = opts.config.durableObjects
210
+ .map((entry) => entry.className)
211
+ .filter((className) => !knownClasses.has(className));
212
+ if (missingClasses.length > 0) {
213
+ existingMigrations.push({
214
+ tag: nextMigrationTag(existingMigrations),
215
+ new_sqlite_classes: missingClasses,
216
+ });
217
+ wranglerConfig.migrations = existingMigrations;
218
+ changed = true;
219
+ console.log(`[kuratchi] Added durable object migration for ${missingClasses.join(', ')}`);
220
+ }
174
221
  }
175
222
  if (opts.config.assetsDirectory !== undefined) {
176
223
  const existing = wranglerConfig.assets;