@hearthlog/tooling 0.0.0-stage → 0.1.1
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.
- package/LICENSE +235 -0
- package/bin/hearthlog.mjs +61 -0
- package/package.json +64 -3
- package/src/apply.ts +73 -0
- package/src/check/check-layers.ts +118 -0
- package/src/check/check-modules.ts +198 -0
- package/src/check/check-sdk-exports.ts +79 -0
- package/src/check/check-tenant-config.ts +208 -0
- package/src/cli/hearthlog.ts +212 -0
- package/src/cli/list-sites.ts +15 -0
- package/src/compose/compose-modules.ts +490 -0
- package/src/compose/module-scaffold.ts +195 -0
- package/src/deploy/artifact-compare.ts +93 -0
- package/src/deploy/artifact.ts +127 -0
- package/src/deploy/async-utils.ts +71 -0
- package/src/deploy/backup-tenants.ts +139 -0
- package/src/deploy/cloudflare-access.ts +789 -0
- package/src/deploy/copy-tenant.ts +395 -0
- package/src/deploy/dedicated-ledger.ts +77 -0
- package/src/deploy/deploy-ledger.ts +162 -0
- package/src/deploy/deploy-runner.ts +240 -0
- package/src/deploy/deploy-tenant.ts +170 -0
- package/src/deploy/deployment-security.ts +141 -0
- package/src/deploy/ensure-tenant.ts +988 -0
- package/src/deploy/event-policy.ts +23 -0
- package/src/deploy/frontend-dry-run.ts +18 -0
- package/src/deploy/ledger-credential.ts +48 -0
- package/src/deploy/ledger-scripts.ts +13 -0
- package/src/deploy/split-tenant.ts +295 -0
- package/src/dev/local-development.ts +288 -0
- package/src/dev/provision-local.ts +13 -0
- package/src/generate/build-generated.ts +166 -0
- package/src/generate/generate-brand-icons.ts +128 -0
- package/src/generate/generate-entities.ts +627 -0
- package/src/generate/generate-tenant.ts +161 -0
- package/src/host/core-routes.ts +39 -0
- package/src/host/write-host.ts +119 -0
- package/src/index.ts +8 -0
- package/src/init/check-module.ts +143 -0
- package/src/init/export-tenant.ts +124 -0
- package/src/init/init.ts +168 -0
- package/src/move.ts +189 -0
- package/src/plan.ts +160 -0
- package/src/public-workers/build-gallery-public.ts +114 -0
- package/src/public-workers/calendar-public-config.ts +27 -0
- package/src/public-workers/check-calendar-public-route.ts +41 -0
- package/src/public-workers/check-gallery-public-route.ts +43 -0
- package/src/public-workers/gallery-public-config.ts +22 -0
- package/src/receipt.ts +74 -0
- package/src/root.ts +16 -0
- package/src/security/audit-push.ts +141 -0
- package/src/security/forbidden-identities.ts +107 -0
- package/src/security/forbidden-init.ts +88 -0
- package/src/tenants/host-configs.ts +96 -0
- package/src/tenants/ledger-config.ts +57 -0
- package/src/tenants/lock-file.ts +45 -0
- package/src/tenants/module-lock.ts +58 -0
- package/src/tenants/tenant-config.ts +887 -0
- package/src/tenants/tenant-copy-plan.ts +119 -0
- package/src/tenants/tenant-schema.ts +171 -0
- package/src/tenants/tenant-wrangler.ts +37 -0
- package/src/tenants/wrangler-base.ts +50 -0
- package/src/tenants/wrangler-config.ts +266 -0
- package/src/vite/config.ts +310 -0
- package/src/vite/dev-plugins.ts +338 -0
- package/src/vite/vite-plugin-entity-gen.ts +70 -0
- package/src/vite/vite-plugin-tenant-manifest.ts +61 -0
- package/src/vite/vite-plugin-worker-assets.ts +42 -0
- package/templates/host/app.d.ts.txt +31 -0
- package/templates/host/microlighter.d.ts.txt +11 -0
- package/templates/module/ci.yml.tmpl +44 -0
- package/templates/module/gitignore.tmpl +7 -0
- package/templates/module/gitleaks.toml.tmpl +11 -0
- package/templates/module/release.yml.tmpl +53 -0
- package/templates/tenant/README.md.tmpl +13 -0
- package/templates/tenant/generated-package.json.tmpl +16 -0
- package/templates/tenant/github-workflows/deploy.yml.tmpl +111 -0
- package/templates/tenant/gitignore.tmpl +11 -0
- package/templates/tenant/package.json.tmpl +11 -0
- package/templates/tenant/patches/sqlocal@0.18.0.patch +18 -0
- package/templates/tenant/pnpm-workspace.yaml.tmpl +8 -0
- package/templates/tenant/tenants/__id__/tenant.json.tmpl +8 -0
- package/templates/tenant/tenants.json.tmpl +17 -0
- package/templates/tenant/tsconfig.json.tmpl +21 -0
- package/templates/tenant/vite.config.ts.tmpl +5 -0
- package/README.md +0 -3
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* Entity Code Generator
|
|
4
|
+
*
|
|
5
|
+
* Generates TypeScript types and database schema from entity-definitions.ts
|
|
6
|
+
*
|
|
7
|
+
* Usage: pnpm build:generated
|
|
8
|
+
*
|
|
9
|
+
* Generated files:
|
|
10
|
+
* - .generated/src/types.generated.ts - Event payloads and types
|
|
11
|
+
* - .generated/src/schema.generated.ts - Database types and DDL
|
|
12
|
+
* - .generated/src/standard-commands.generated.ts, standard-db.generated.ts - Data APIs
|
|
13
|
+
* - .generated/src/event-schemas.generated.ts - Payload shapes Ledger validates
|
|
14
|
+
*
|
|
15
|
+
* `pnpm build:generated` runs this after writing the module catalog, in a
|
|
16
|
+
* process of its own so the entity definitions include the site's modules.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as fs from 'fs';
|
|
20
|
+
import * as path from 'path';
|
|
21
|
+
import { fileURLToPath } from 'url';
|
|
22
|
+
|
|
23
|
+
const ROOT = hostRoot();
|
|
24
|
+
import { hostRoot } from '../root.js';
|
|
25
|
+
|
|
26
|
+
import { ENTITY_DEFINITIONS, type EntityDefinition as EntityDef, type EntityFieldDefinition as FieldDef } from '@hearthlog/contracts/event/entity-definitions';
|
|
27
|
+
import { MODULE_FEATURES } from '#generated/modules';
|
|
28
|
+
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// Helper Functions
|
|
31
|
+
// ============================================================================
|
|
32
|
+
|
|
33
|
+
function toCamelCase(str: string): string {
|
|
34
|
+
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toPascalCase(str: string): string {
|
|
38
|
+
const camel = toCamelCase(str);
|
|
39
|
+
return camel.charAt(0).toUpperCase() + camel.slice(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getEventPrefix(def: EntityDef): string {
|
|
43
|
+
return def.eventPrefix || def.name.toUpperCase() + '_';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getPayloadKey(field: FieldDef): string {
|
|
47
|
+
return field.payloadKey || toCamelCase(field.name);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function fieldTypeToTS(type: string): string {
|
|
51
|
+
switch (type) {
|
|
52
|
+
case 'number': return 'number';
|
|
53
|
+
case 'boolean': return 'boolean';
|
|
54
|
+
case 'string':
|
|
55
|
+
default: return 'string';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function fieldTypeToSQL(type: string): string {
|
|
60
|
+
switch (type) {
|
|
61
|
+
case 'number': return 'REAL';
|
|
62
|
+
case 'boolean': return 'INTEGER';
|
|
63
|
+
case 'string':
|
|
64
|
+
default: return 'TEXT';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function quoteSqlIdentifier(identifier: string): string {
|
|
69
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function validateFileReferences(entities: EntityDef[]): void {
|
|
73
|
+
for (const entity of entities) {
|
|
74
|
+
if (!entity.fileReference) continue;
|
|
75
|
+
const field = entity.fields.find((candidate) => candidate.name === entity.fileReference!.versionField);
|
|
76
|
+
if (!field || field.type !== 'string') {
|
|
77
|
+
throw new Error(`Entity ${entity.name}: fileReference.versionField must name a string field`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function validateIndexes(entities: EntityDef[]): void {
|
|
83
|
+
const indexNames = new Set<string>();
|
|
84
|
+
const automaticColumns = ['tenant_id', 'log_id', 'id', 'created_at', 'updated_at'];
|
|
85
|
+
|
|
86
|
+
for (const entity of entities) {
|
|
87
|
+
const availableColumns = new Set([
|
|
88
|
+
...automaticColumns,
|
|
89
|
+
...entity.fields.map((field) => field.name),
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
for (const index of entity.indexes ?? []) {
|
|
93
|
+
if (!index.name) {
|
|
94
|
+
throw new Error(`Entity ${entity.name} has an index without a name`);
|
|
95
|
+
}
|
|
96
|
+
if (indexNames.has(index.name)) {
|
|
97
|
+
throw new Error(`Duplicate entity index name: ${index.name}`);
|
|
98
|
+
}
|
|
99
|
+
if (index.columns.length === 0) {
|
|
100
|
+
throw new Error(`Entity index ${index.name} must include at least one column`);
|
|
101
|
+
}
|
|
102
|
+
for (const column of index.columns) {
|
|
103
|
+
if (!availableColumns.has(column)) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Entity index ${index.name} references unknown column ${entity.table}.${column}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
indexNames.add(index.name);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ============================================================================
|
|
115
|
+
// Event Types Generator
|
|
116
|
+
// ============================================================================
|
|
117
|
+
|
|
118
|
+
function generateEventTypes(entities: EntityDef[]): string {
|
|
119
|
+
const lines: string[] = [
|
|
120
|
+
'// ============================================================================',
|
|
121
|
+
'// AUTO-GENERATED FILE - DO NOT EDIT MANUALLY',
|
|
122
|
+
'// Generated from entity-definitions.ts by packages/tooling/src/generate/generate-entities.ts',
|
|
123
|
+
'// ============================================================================',
|
|
124
|
+
'',
|
|
125
|
+
'// ============================================================================',
|
|
126
|
+
'// Base Event Type',
|
|
127
|
+
'// ============================================================================',
|
|
128
|
+
'',
|
|
129
|
+
'export interface BaseEvent<T extends string, P> {',
|
|
130
|
+
' id: string;',
|
|
131
|
+
' type: T;',
|
|
132
|
+
' timestamp: number;',
|
|
133
|
+
' payload: P;',
|
|
134
|
+
'}',
|
|
135
|
+
'',
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
// Generate types for each entity
|
|
139
|
+
for (const entity of entities) {
|
|
140
|
+
const pascalName = toPascalCase(entity.name);
|
|
141
|
+
const prefix = getEventPrefix(entity);
|
|
142
|
+
|
|
143
|
+
lines.push('// ============================================================================');
|
|
144
|
+
lines.push(`// ${pascalName} Events`);
|
|
145
|
+
lines.push('// ============================================================================');
|
|
146
|
+
lines.push('');
|
|
147
|
+
|
|
148
|
+
// Add Payload - all fields
|
|
149
|
+
lines.push(`export interface Add${pascalName}Payload {`);
|
|
150
|
+
lines.push(' id: string;');
|
|
151
|
+
for (const field of entity.fields) {
|
|
152
|
+
const key = getPayloadKey(field);
|
|
153
|
+
const tsType = fieldTypeToTS(field.type);
|
|
154
|
+
lines.push(` ${key}${field.optionalInPayload ? "?" : ""}: ${tsType}${field.optionalInPayload && !field.required && field.default === undefined ? " | null" : ""};`);
|
|
155
|
+
}
|
|
156
|
+
lines.push('}');
|
|
157
|
+
lines.push('');
|
|
158
|
+
|
|
159
|
+
// Update Payload - all fields optional except id
|
|
160
|
+
lines.push(`export interface Update${pascalName}Payload {`);
|
|
161
|
+
lines.push(' id: string;');
|
|
162
|
+
for (const field of entity.fields) {
|
|
163
|
+
const key = getPayloadKey(field);
|
|
164
|
+
const tsType = fieldTypeToTS(field.type);
|
|
165
|
+
lines.push(` ${key}?: ${tsType}${field.optionalInPayload && !field.required && field.default === undefined ? " | null" : ""};`);
|
|
166
|
+
}
|
|
167
|
+
lines.push('}');
|
|
168
|
+
lines.push('');
|
|
169
|
+
|
|
170
|
+
// Remove Payload - just id
|
|
171
|
+
lines.push(`export interface Remove${pascalName}Payload {`);
|
|
172
|
+
lines.push(' id: string;');
|
|
173
|
+
lines.push('}');
|
|
174
|
+
lines.push('');
|
|
175
|
+
|
|
176
|
+
// Event types
|
|
177
|
+
lines.push(`export type Add${pascalName}Event = BaseEvent<'${prefix}ADDED', Add${pascalName}Payload>;`);
|
|
178
|
+
lines.push(`export type Update${pascalName}Event = BaseEvent<'${prefix}UPDATED', Update${pascalName}Payload>;`);
|
|
179
|
+
lines.push(`export type Remove${pascalName}Event = BaseEvent<'${prefix}REMOVED', Remove${pascalName}Payload>;`);
|
|
180
|
+
lines.push('');
|
|
181
|
+
|
|
182
|
+
// Entity event union
|
|
183
|
+
lines.push(`export type ${pascalName}Event = Add${pascalName}Event | Update${pascalName}Event | Remove${pascalName}Event;`);
|
|
184
|
+
lines.push('');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Tombstone event (special, not entity-driven)
|
|
188
|
+
lines.push('// ============================================================================');
|
|
189
|
+
lines.push('// Tombstone Event (Special - not entity-driven)');
|
|
190
|
+
lines.push('// ============================================================================');
|
|
191
|
+
lines.push('');
|
|
192
|
+
lines.push('export interface TombstonePayload {');
|
|
193
|
+
lines.push(' eventId: string;');
|
|
194
|
+
lines.push('}');
|
|
195
|
+
lines.push('');
|
|
196
|
+
lines.push("export type TombstoneEvent = BaseEvent<'EVENT_TOMBSTONED', TombstonePayload>;");
|
|
197
|
+
lines.push('');
|
|
198
|
+
|
|
199
|
+
// Excision event (special, not entity-driven): hides accepted events from
|
|
200
|
+
// replay. The directive is itself an event, so it syncs, audits, and can
|
|
201
|
+
// be tombstoned to revive the removed events.
|
|
202
|
+
lines.push('// ============================================================================');
|
|
203
|
+
lines.push('// Excision Event (Special - not entity-driven)');
|
|
204
|
+
lines.push('// ============================================================================');
|
|
205
|
+
lines.push('');
|
|
206
|
+
lines.push('export interface EventsExcisedPayload {');
|
|
207
|
+
lines.push(' eventIds: string[];');
|
|
208
|
+
lines.push(' note?: string;');
|
|
209
|
+
lines.push('}');
|
|
210
|
+
lines.push('');
|
|
211
|
+
lines.push("export type EventsExcisedEvent = BaseEvent<'EVENTS_EXCISED', EventsExcisedPayload>;");
|
|
212
|
+
lines.push('');
|
|
213
|
+
|
|
214
|
+
// AppEvent union
|
|
215
|
+
lines.push('// ============================================================================');
|
|
216
|
+
lines.push('// Combined Event Types');
|
|
217
|
+
lines.push('// ============================================================================');
|
|
218
|
+
lines.push('');
|
|
219
|
+
const eventUnions = entities.map(e => `${toPascalCase(e.name)}Event`).join('\n | ');
|
|
220
|
+
lines.push('export type AppEvent =');
|
|
221
|
+
lines.push(` | ${eventUnions}`);
|
|
222
|
+
lines.push(' | TombstoneEvent');
|
|
223
|
+
lines.push(' | EventsExcisedEvent;');
|
|
224
|
+
lines.push('');
|
|
225
|
+
|
|
226
|
+
return lines.join('\n');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ============================================================================
|
|
230
|
+
// Database Schema Generator
|
|
231
|
+
// ============================================================================
|
|
232
|
+
|
|
233
|
+
function generateDbSchema(entities: EntityDef[]): string {
|
|
234
|
+
validateIndexes(entities);
|
|
235
|
+
|
|
236
|
+
for (const entity of entities) {
|
|
237
|
+
if (!entity.feature) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`Entity ${entity.name} is missing its owning "feature" ` +
|
|
240
|
+
"(see packages/contracts/src/features/registry.ts FeatureId)",
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const lines: string[] = [
|
|
246
|
+
'// ============================================================================',
|
|
247
|
+
'// AUTO-GENERATED FILE - DO NOT EDIT MANUALLY',
|
|
248
|
+
'// Generated from entity-definitions.ts by packages/tooling/src/generate/generate-entities.ts',
|
|
249
|
+
'// ============================================================================',
|
|
250
|
+
'',
|
|
251
|
+
"import type { ColumnType } from 'kysely';",
|
|
252
|
+
'',
|
|
253
|
+
];
|
|
254
|
+
|
|
255
|
+
const tableNames: string[] = [];
|
|
256
|
+
const ddlNames: string[] = [];
|
|
257
|
+
const indexDdlNames: string[] = [];
|
|
258
|
+
|
|
259
|
+
// Generate types for each entity
|
|
260
|
+
for (const entity of entities) {
|
|
261
|
+
const pascalName = toPascalCase(entity.name);
|
|
262
|
+
const tableName = `${pascalName}Table`;
|
|
263
|
+
tableNames.push(tableName);
|
|
264
|
+
|
|
265
|
+
const ddlConstName = `${entity.name.toUpperCase()}_DDL`;
|
|
266
|
+
ddlNames.push(ddlConstName);
|
|
267
|
+
|
|
268
|
+
lines.push('// ============================================================================');
|
|
269
|
+
lines.push(`// ${pascalName}`);
|
|
270
|
+
lines.push('// ============================================================================');
|
|
271
|
+
lines.push('');
|
|
272
|
+
|
|
273
|
+
// Plain read type (for query results)
|
|
274
|
+
lines.push(`export type ${pascalName} = {`);
|
|
275
|
+
lines.push(' tenant_id: string;');
|
|
276
|
+
lines.push(' log_id: string;');
|
|
277
|
+
lines.push(' id: string;');
|
|
278
|
+
for (const field of entity.fields) {
|
|
279
|
+
const tsType = fieldTypeToTS(field.type);
|
|
280
|
+
const nullable = !field.required && field.default === undefined ? ' | null' : '';
|
|
281
|
+
lines.push(` ${field.name}: ${tsType}${nullable};`);
|
|
282
|
+
}
|
|
283
|
+
lines.push(' created_at: string;');
|
|
284
|
+
lines.push(' updated_at: string;');
|
|
285
|
+
lines.push('};');
|
|
286
|
+
lines.push('');
|
|
287
|
+
|
|
288
|
+
// Kysely table type
|
|
289
|
+
lines.push(`export type ${tableName} = {`);
|
|
290
|
+
lines.push(' tenant_id: string;');
|
|
291
|
+
lines.push(' log_id: string;');
|
|
292
|
+
lines.push(' id: string;');
|
|
293
|
+
for (const field of entity.fields) {
|
|
294
|
+
const tsType = fieldTypeToTS(field.type);
|
|
295
|
+
const nullable = !field.required && field.default === undefined ? ' | null' : '';
|
|
296
|
+
if (field.default !== undefined || !field.required) {
|
|
297
|
+
// Fields with defaults or optional fields use ColumnType
|
|
298
|
+
lines.push(` ${field.name}: ColumnType<${tsType}${nullable}, ${tsType} | undefined, ${tsType}>;`);
|
|
299
|
+
} else {
|
|
300
|
+
lines.push(` ${field.name}: ${tsType}${nullable};`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
lines.push(' created_at: ColumnType<string, string | undefined, never>;');
|
|
304
|
+
lines.push(' updated_at: ColumnType<string, string | undefined, string>;');
|
|
305
|
+
lines.push('};');
|
|
306
|
+
lines.push('');
|
|
307
|
+
|
|
308
|
+
// DDL string — composite PK scopes projection rows per tenant/log
|
|
309
|
+
const ddlFields: string[] = [
|
|
310
|
+
'"tenant_id" TEXT NOT NULL',
|
|
311
|
+
'"log_id" TEXT NOT NULL',
|
|
312
|
+
'"id" TEXT NOT NULL',
|
|
313
|
+
];
|
|
314
|
+
for (const field of entity.fields) {
|
|
315
|
+
const sqlType = fieldTypeToSQL(field.type);
|
|
316
|
+
let ddlLine = `"${field.name}" ${sqlType}`;
|
|
317
|
+
|
|
318
|
+
if (field.default !== undefined) {
|
|
319
|
+
const isSqlExpression = typeof field.default === 'string' && field.default.includes('(');
|
|
320
|
+
let defaultVal: string | number | null;
|
|
321
|
+
if (typeof field.default === 'string') {
|
|
322
|
+
// SQL expressions need to be wrapped in parentheses for SQLite
|
|
323
|
+
defaultVal = isSqlExpression ? `(${field.default})` : `'${field.default}'`;
|
|
324
|
+
} else {
|
|
325
|
+
defaultVal = field.default;
|
|
326
|
+
}
|
|
327
|
+
ddlLine += ` DEFAULT ${defaultVal}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (field.required) {
|
|
331
|
+
ddlLine += ' NOT NULL';
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
ddlFields.push(ddlLine);
|
|
335
|
+
}
|
|
336
|
+
ddlFields.push('"created_at" TEXT DEFAULT (datetime(\'now\'))');
|
|
337
|
+
ddlFields.push('"updated_at" TEXT DEFAULT (datetime(\'now\'))');
|
|
338
|
+
ddlFields.push('PRIMARY KEY ("tenant_id", "log_id", "id")');
|
|
339
|
+
|
|
340
|
+
lines.push(`export const ${ddlConstName} = \``);
|
|
341
|
+
lines.push(` CREATE TABLE IF NOT EXISTS "${entity.table}" (`);
|
|
342
|
+
lines.push(` ${ddlFields.join(',\\n ')}`);
|
|
343
|
+
lines.push(' );');
|
|
344
|
+
lines.push('`;');
|
|
345
|
+
lines.push('');
|
|
346
|
+
|
|
347
|
+
if (entity.indexes?.length) {
|
|
348
|
+
const indexDdlConstName = `${entity.name.toUpperCase()}_INDEX_DDL`;
|
|
349
|
+
indexDdlNames.push(indexDdlConstName);
|
|
350
|
+
lines.push(`export const ${indexDdlConstName} = [`);
|
|
351
|
+
for (const index of entity.indexes) {
|
|
352
|
+
const unique = index.unique ? 'UNIQUE ' : '';
|
|
353
|
+
const columns = index.columns.map(quoteSqlIdentifier).join(', ');
|
|
354
|
+
lines.push(' `');
|
|
355
|
+
lines.push(` CREATE ${unique}INDEX IF NOT EXISTS ${quoteSqlIdentifier(index.name)}`);
|
|
356
|
+
lines.push(` ON ${quoteSqlIdentifier(entity.table)} (${columns});`);
|
|
357
|
+
lines.push(' `,');
|
|
358
|
+
}
|
|
359
|
+
lines.push('];');
|
|
360
|
+
lines.push('');
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// EntityDatabaseTables interface
|
|
365
|
+
lines.push('// ============================================================================');
|
|
366
|
+
lines.push('// Generated Database Interface');
|
|
367
|
+
lines.push('// ============================================================================');
|
|
368
|
+
lines.push('');
|
|
369
|
+
lines.push('export interface EntityDatabaseTables {');
|
|
370
|
+
for (const entity of entities) {
|
|
371
|
+
const pascalName = toPascalCase(entity.name);
|
|
372
|
+
lines.push(` ${entity.table}: ${pascalName}Table;`);
|
|
373
|
+
}
|
|
374
|
+
lines.push('}');
|
|
375
|
+
lines.push('');
|
|
376
|
+
|
|
377
|
+
// getAllEntityDDL function
|
|
378
|
+
lines.push('/**');
|
|
379
|
+
lines.push(' * Get all entity table DDL statements');
|
|
380
|
+
lines.push(' */');
|
|
381
|
+
lines.push('export function getAllEntityDDL(schema?: string): string[] {');
|
|
382
|
+
lines.push(` const entries: Array<[string, string]> = [${ddlNames.map((name, index) => `[${name}, '${entities[index].table}']`).join(', ')}];`);
|
|
383
|
+
lines.push(' if (!schema) return entries.map(([ddl]) => ddl);');
|
|
384
|
+
lines.push(' const qualifiedSchema = `"${schema.replace(/"/g, \'""\')}"`;');
|
|
385
|
+
lines.push(' return entries.map(([ddl, table]) => ddl.replace(');
|
|
386
|
+
lines.push(' `CREATE TABLE IF NOT EXISTS "${table}"`,');
|
|
387
|
+
lines.push(' `CREATE TABLE IF NOT EXISTS ${qualifiedSchema}."${table}"`,');
|
|
388
|
+
lines.push(' ));');
|
|
389
|
+
lines.push('}');
|
|
390
|
+
lines.push('');
|
|
391
|
+
|
|
392
|
+
lines.push('/**');
|
|
393
|
+
lines.push(' * Get all entity index DDL statements');
|
|
394
|
+
lines.push(' */');
|
|
395
|
+
lines.push('export function getAllEntityIndexDDL(schema?: string): string[] {');
|
|
396
|
+
const indexDdlSpreads = indexDdlNames.map((name) => `...${name}`).join(', ');
|
|
397
|
+
lines.push(` const ddls = [${indexDdlSpreads}];`);
|
|
398
|
+
lines.push(' if (!schema) return ddls;');
|
|
399
|
+
lines.push(' const qualifiedSchema = `"${schema.replace(/"/g, \'""\')}"`;');
|
|
400
|
+
lines.push(' return ddls.map((ddl) => ddl.replace(');
|
|
401
|
+
lines.push(' /CREATE (UNIQUE )?INDEX IF NOT EXISTS "([^"]+)"/,' );
|
|
402
|
+
lines.push(' (_match, unique = \'\', indexName) => `CREATE ${unique}INDEX IF NOT EXISTS ${qualifiedSchema}."${indexName}"`,' );
|
|
403
|
+
lines.push(' ));');
|
|
404
|
+
lines.push('}');
|
|
405
|
+
lines.push('');
|
|
406
|
+
|
|
407
|
+
// Export list of table names
|
|
408
|
+
lines.push('/**');
|
|
409
|
+
lines.push(' * List of all entity table names');
|
|
410
|
+
lines.push(' */');
|
|
411
|
+
const tableNameStrings = entities.map(e => `'${e.table}'`).join(', ');
|
|
412
|
+
lines.push(`export const ENTITY_TABLE_NAMES = [${tableNameStrings}] as const;`);
|
|
413
|
+
lines.push('');
|
|
414
|
+
|
|
415
|
+
// Feature ownership: table -> owning feature (see features/registry.ts).
|
|
416
|
+
// Drives per-tenant local SQLite scoping and the ledger append allow-list.
|
|
417
|
+
lines.push('/**');
|
|
418
|
+
lines.push(' * Owning feature for each entity table (tenants.json enables features).');
|
|
419
|
+
lines.push(' */');
|
|
420
|
+
lines.push('export const ENTITY_FEATURES: Record<string, string> = {');
|
|
421
|
+
for (const entity of entities) {
|
|
422
|
+
lines.push(` '${entity.table}': '${entity.feature}',`);
|
|
423
|
+
}
|
|
424
|
+
lines.push('};');
|
|
425
|
+
lines.push('');
|
|
426
|
+
|
|
427
|
+
// Per-table DDL lookup (for tenant-scoped database bootstrap).
|
|
428
|
+
lines.push('/**');
|
|
429
|
+
lines.push(' * Table DDL statements keyed by table name.');
|
|
430
|
+
lines.push(' */');
|
|
431
|
+
lines.push('export const ENTITY_DDL_BY_TABLE: Record<string, string> = {');
|
|
432
|
+
for (let index = 0; index < entities.length; index += 1) {
|
|
433
|
+
lines.push(` '${entities[index].table}': ${ddlNames[index]},`);
|
|
434
|
+
}
|
|
435
|
+
lines.push('};');
|
|
436
|
+
lines.push('');
|
|
437
|
+
|
|
438
|
+
// Per-table index DDL lookup (entities without indexes are omitted).
|
|
439
|
+
lines.push('/**');
|
|
440
|
+
lines.push(' * Index DDL statements keyed by table name.');
|
|
441
|
+
lines.push(' */');
|
|
442
|
+
lines.push('export const ENTITY_INDEX_DDL_BY_TABLE: Record<string, string[]> = {');
|
|
443
|
+
for (const entity of entities) {
|
|
444
|
+
if (!entity.indexes?.length) continue;
|
|
445
|
+
const constName = `${entity.name.toUpperCase()}_INDEX_DDL`;
|
|
446
|
+
lines.push(` '${entity.table}': [...${constName}],`);
|
|
447
|
+
}
|
|
448
|
+
lines.push('};');
|
|
449
|
+
lines.push('');
|
|
450
|
+
|
|
451
|
+
lines.push('/**');
|
|
452
|
+
lines.push(' * Entity DDL statements restricted to the approved tables.');
|
|
453
|
+
lines.push(' */');
|
|
454
|
+
lines.push('export function getEntityDDLForTables(approvedTables: ReadonlySet<string>): string[] {');
|
|
455
|
+
lines.push(' const ddls: string[] = [];');
|
|
456
|
+
lines.push(' for (const [table, ddl] of Object.entries(ENTITY_DDL_BY_TABLE)) {');
|
|
457
|
+
lines.push(' if (approvedTables.has(table)) ddls.push(ddl);');
|
|
458
|
+
lines.push(' }');
|
|
459
|
+
lines.push(' return ddls;');
|
|
460
|
+
lines.push('}');
|
|
461
|
+
lines.push('');
|
|
462
|
+
|
|
463
|
+
lines.push('/**');
|
|
464
|
+
lines.push(' * Entity index DDL statements restricted to the approved tables.');
|
|
465
|
+
lines.push(' */');
|
|
466
|
+
lines.push('export function getEntityIndexDDLForTables(approvedTables: ReadonlySet<string>): string[] {');
|
|
467
|
+
lines.push(' const ddls: string[] = [];');
|
|
468
|
+
lines.push(' for (const [table, tableDdls] of Object.entries(ENTITY_INDEX_DDL_BY_TABLE)) {');
|
|
469
|
+
lines.push(' if (!approvedTables.has(table)) continue;');
|
|
470
|
+
lines.push(' ddls.push(...tableDdls);');
|
|
471
|
+
lines.push(' }');
|
|
472
|
+
lines.push(' return ddls;');
|
|
473
|
+
lines.push('}');
|
|
474
|
+
lines.push('');
|
|
475
|
+
|
|
476
|
+
return lines.join('\n');
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ============================================================================
|
|
480
|
+
// Main
|
|
481
|
+
// ============================================================================
|
|
482
|
+
|
|
483
|
+
function generateCommands(entities: EntityDef[]): string {
|
|
484
|
+
return [
|
|
485
|
+
'// AUTO-GENERATED by packages/tooling/src/generate/generate-entities.ts — do not edit.',
|
|
486
|
+
"import { standardCommand } from '@hearthlog/runtime/commands/standard.js';",
|
|
487
|
+
...entities.map(entity => `export const ${toCamelCase(entity.name)}Commands = {
|
|
488
|
+
create: standardCommand('${getEventPrefix(entity)}ADDED'),
|
|
489
|
+
update: standardCommand('${getEventPrefix(entity)}UPDATED'),
|
|
490
|
+
remove: standardCommand('${getEventPrefix(entity)}REMOVED'),
|
|
491
|
+
};`),
|
|
492
|
+
'',
|
|
493
|
+
].join('\n');
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function generateQueries(entities: EntityDef[]): string {
|
|
497
|
+
return [
|
|
498
|
+
'// AUTO-GENERATED by packages/tooling/src/generate/generate-entities.ts — do not edit.',
|
|
499
|
+
"import type { ReadModelSession } from '@hearthlog/runtime/db/read-session.js';",
|
|
500
|
+
"import { tenant } from '@hearthlog/contracts/tenant/config';",
|
|
501
|
+
'export function createStandardRepositories(session: ReadModelSession) {',
|
|
502
|
+
' function assertEnabled(table: string) {',
|
|
503
|
+
" if (!tenant.approvedEntities.tables.has(table)) throw new Error(`Entity ${table} is not enabled for this tenant`);",
|
|
504
|
+
' }',
|
|
505
|
+
' return {',
|
|
506
|
+
...entities.map(entity => ` ${toCamelCase(entity.name)}: {
|
|
507
|
+
list: async () => {
|
|
508
|
+
assertEnabled('${entity.table}');
|
|
509
|
+
return session.selectFrom('${entity.table}').selectAll().orderBy('id').execute();
|
|
510
|
+
},
|
|
511
|
+
get: async (id: string) => {
|
|
512
|
+
assertEnabled('${entity.table}');
|
|
513
|
+
return session.selectFrom('${entity.table}').selectAll().where('id', '=', id).executeTakeFirst();
|
|
514
|
+
},
|
|
515
|
+
},`),
|
|
516
|
+
' };', '}', '',
|
|
517
|
+
].join('\n');
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* What Ledger checks about each known entity event: which payload keys exist,
|
|
522
|
+
* their JSON kinds, and which keys an add must carry. Extra keys are allowed so
|
|
523
|
+
* newer clients are never blocked, and the table only ever grows with the
|
|
524
|
+
* definitions, so a Ledger deployed ahead of the frontend accepts everything the
|
|
525
|
+
* frontend sends.
|
|
526
|
+
*/
|
|
527
|
+
function generateLedgerEventSchemas(entities: EntityDef[]): string {
|
|
528
|
+
const lines: string[] = [
|
|
529
|
+
'// ============================================================================',
|
|
530
|
+
'// AUTO-GENERATED FILE - DO NOT EDIT MANUALLY',
|
|
531
|
+
'// Generated by packages/tooling/src/generate/generate-entities.ts from the core entity definitions',
|
|
532
|
+
'// (packages/contracts/src/event/entity-definitions.ts) and those of every module in modules/*.',
|
|
533
|
+
'// ============================================================================',
|
|
534
|
+
'',
|
|
535
|
+
"export type PayloadKind = 'string' | 'number' | 'boolean';",
|
|
536
|
+
'',
|
|
537
|
+
'export interface EventSchema {',
|
|
538
|
+
" kind: 'added' | 'updated' | 'removed';",
|
|
539
|
+
' entity: string;',
|
|
540
|
+
' /** Payload keys an add must carry with a non-null value. */',
|
|
541
|
+
' required: readonly string[];',
|
|
542
|
+
' /** JSON kind of every declared payload key. */',
|
|
543
|
+
' fields: Readonly<Record<string, PayloadKind>>;',
|
|
544
|
+
'}',
|
|
545
|
+
'',
|
|
546
|
+
'export const EVENT_SCHEMAS: Readonly<Record<string, EventSchema>> = {',
|
|
547
|
+
];
|
|
548
|
+
const references: Array<[string, { entity: string; versionKey: string; requiredOnAdd: boolean }]> = [];
|
|
549
|
+
const owners: Array<[string, string]> = [];
|
|
550
|
+
for (const entity of entities) {
|
|
551
|
+
const prefix = getEventPrefix(entity);
|
|
552
|
+
const owner = MODULE_FEATURES.find((feature) => feature.id === entity.feature)?.module ?? 'core';
|
|
553
|
+
const fields = Object.fromEntries(entity.fields.map((field) => [
|
|
554
|
+
getPayloadKey(field),
|
|
555
|
+
field.type === 'number' ? 'number' : field.type === 'boolean' ? 'boolean' : 'string',
|
|
556
|
+
]));
|
|
557
|
+
const required = entity.fields.filter((field) => field.required).map(getPayloadKey);
|
|
558
|
+
const schemas = {
|
|
559
|
+
ADDED: { kind: 'added', entity: entity.name, required, fields },
|
|
560
|
+
UPDATED: { kind: 'updated', entity: entity.name, required: [], fields },
|
|
561
|
+
REMOVED: { kind: 'removed', entity: entity.name, required: [], fields: {} },
|
|
562
|
+
};
|
|
563
|
+
for (const [suffix, schema] of Object.entries(schemas)) {
|
|
564
|
+
lines.push(` ${prefix}${suffix}: ${JSON.stringify(schema)},`);
|
|
565
|
+
owners.push([`${prefix}${suffix}`, owner]);
|
|
566
|
+
}
|
|
567
|
+
if (entity.fileReference) {
|
|
568
|
+
const versionKey = getPayloadKey(entity.fields.find((field) => field.name === entity.fileReference!.versionField)!);
|
|
569
|
+
references.push([`${prefix}ADDED`, { entity: entity.name, versionKey, requiredOnAdd: true }]);
|
|
570
|
+
references.push([`${prefix}UPDATED`, { entity: entity.name, versionKey, requiredOnAdd: false }]);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
lines.push(
|
|
574
|
+
'};',
|
|
575
|
+
'',
|
|
576
|
+
'/**',
|
|
577
|
+
' * Which module (or the core) defines each event type. Events of features that',
|
|
578
|
+
' * no longer exist have no entry here and pass as dynamic types (shape checks only).',
|
|
579
|
+
' */',
|
|
580
|
+
'export const EVENT_OWNERS: Readonly<Record<string, string>> = {',
|
|
581
|
+
...owners.map(([type, owner]) => ` ${type}: ${JSON.stringify(owner)},`),
|
|
582
|
+
'};',
|
|
583
|
+
'',
|
|
584
|
+
'/** An event that can name an immutable file version the Ledger records per log (see `fileReference` in entity definitions). */',
|
|
585
|
+
'export interface FileReference {',
|
|
586
|
+
' entity: string;',
|
|
587
|
+
' /** Payload key holding the file version id. */',
|
|
588
|
+
' versionKey: string;',
|
|
589
|
+
' /** An add must name a version; an update names one only when it replaces the file. */',
|
|
590
|
+
' requiredOnAdd: boolean;',
|
|
591
|
+
'}',
|
|
592
|
+
'',
|
|
593
|
+
'export const FILE_REFERENCES: Readonly<Record<string, FileReference>> = {',
|
|
594
|
+
...references.map(([type, reference]) => ` ${type}: ${JSON.stringify(reference)},`),
|
|
595
|
+
'};',
|
|
596
|
+
'',
|
|
597
|
+
);
|
|
598
|
+
return lines.join('\n');
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function main() {
|
|
602
|
+
const entities = ENTITY_DEFINITIONS;
|
|
603
|
+
validateIndexes(entities);
|
|
604
|
+
validateFileReferences(entities);
|
|
605
|
+
const outputs = new Map([
|
|
606
|
+
['.generated/src/types.generated.ts', generateEventTypes(entities)],
|
|
607
|
+
['.generated/src/schema.generated.ts', generateDbSchema(entities)],
|
|
608
|
+
['.generated/src/standard-commands.generated.ts', generateCommands(entities)],
|
|
609
|
+
['.generated/src/standard-db.generated.ts', generateQueries(entities)],
|
|
610
|
+
['.generated/src/event-schemas.generated.ts', generateLedgerEventSchemas(entities)],
|
|
611
|
+
]);
|
|
612
|
+
const check = process.argv.includes('--check');
|
|
613
|
+
const stale: string[] = [];
|
|
614
|
+
for (const [name, output] of outputs) {
|
|
615
|
+
const destination = path.join(ROOT, name);
|
|
616
|
+
if (fs.existsSync(destination) && fs.readFileSync(destination, 'utf8') === output) continue;
|
|
617
|
+
if (check) stale.push(name);
|
|
618
|
+
else {
|
|
619
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
620
|
+
fs.writeFileSync(destination, output);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
if (stale.length) throw new Error(`Generated entity files are stale: ${stale.join(', ')}`);
|
|
624
|
+
console.log(`${check ? 'Checked' : 'Generated'} ${entities.length} entity data APIs.`);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (process.env.STOR_SKIP_ENTITY_GENERATION !== '1') main();
|