webspresso 0.0.74 → 0.0.75
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/README.md +41 -3
- package/bin/commands/orm-map.js +139 -0
- package/bin/commands/skill.js +22 -8
- package/bin/utils/orm-map-html.js +689 -0
- package/bin/utils/orm-map-load.js +85 -0
- package/bin/utils/orm-map-snapshot.js +179 -0
- package/bin/utils/resolve-webspresso-orm.js +23 -0
- package/bin/webspresso.js +2 -0
- package/core/auth/manager.js +14 -1
- package/core/kernel/app.js +96 -0
- package/core/kernel/base-repository.js +143 -0
- package/core/kernel/events.js +101 -0
- package/core/kernel/flow.js +22 -0
- package/core/kernel/index.js +17 -0
- package/core/kernel/plugin.js +23 -0
- package/core/kernel/plugins/sample-seo.js +26 -0
- package/core/kernel/run-demo.js +58 -0
- package/core/kernel/view.js +167 -0
- package/core/openapi/build-from-api-routes.js +8 -2
- package/core/orm/model.js +3 -1
- package/core/url-path-normalize.js +30 -0
- package/index.d.ts +168 -1
- package/index.js +20 -2
- package/package.json +11 -1
- package/plugins/admin-panel/api.js +43 -15
- package/plugins/admin-panel/client/README.md +39 -0
- package/plugins/admin-panel/client/load-parts.js +74 -0
- package/plugins/admin-panel/client/manifest.parts.json +12 -0
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +150 -0
- package/plugins/admin-panel/client/parts/02-filter-components.js +554 -0
- package/plugins/admin-panel/client/parts/03-pagination-intro.js +70 -0
- package/plugins/admin-panel/client/parts/04-field-renderers.js +287 -0
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +335 -0
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +125 -0
- package/plugins/admin-panel/client/parts/07-model-list.js +596 -0
- package/plugins/admin-panel/client/parts/08-record-list.js +536 -0
- package/plugins/admin-panel/client/parts/09-record-form.js +170 -0
- package/plugins/admin-panel/client/parts/10-export-registry.js +11 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +32 -0
- package/plugins/admin-panel/client/vite.config.example.mjs +22 -0
- package/plugins/admin-panel/components.js +4 -2640
- package/plugins/admin-panel/core/api-extensions.js +100 -10
- package/plugins/admin-panel/index.js +3 -0
- package/plugins/admin-panel/lib/is-rich-text-empty.js +23 -0
- package/plugins/admin-panel/lib/sanitize-rich-html.js +106 -0
- package/plugins/admin-panel/modules/dashboard.js +3 -2
- package/plugins/admin-panel/modules/user-management.js +90 -20
- package/plugins/index.js +4 -0
- package/plugins/rate-limit/index.js +178 -0
- package/plugins/redirect/index.js +204 -0
- package/plugins/rest-resources/index.js +2 -1
- package/plugins/swagger.js +2 -1
- package/plugins/upload/local-file-provider.js +6 -2
- package/src/file-router.js +191 -50
- package/src/njk-frontmatter.js +156 -0
- package/src/plugin-manager.js +4 -2
- package/src/server.js +26 -9
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +276 -0
- package/templates/skills/webspresso-usage/REFERENCE-kernel.md +93 -0
- package/templates/skills/webspresso-usage/SKILL.md +29 -278
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load ORM model files into the global registry without opening a DB connection.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { resolveDbConfigIfExists } = require('./db');
|
|
8
|
+
const { getWebspressoOrmForProject } = require('./resolve-webspresso-orm');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve absolute models directory.
|
|
12
|
+
* @param {string} cwd
|
|
13
|
+
* @param {{ modelsOverride?: string, configPath?: string, env?: string }} options
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
function resolveModelsDir(cwd, options = {}) {
|
|
17
|
+
const { modelsOverride, configPath, env } = options;
|
|
18
|
+
if (modelsOverride) {
|
|
19
|
+
return path.resolve(cwd, modelsOverride);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const resolved = resolveDbConfigIfExists(configPath);
|
|
23
|
+
if (resolved) {
|
|
24
|
+
const environment = env || process.env.NODE_ENV || 'development';
|
|
25
|
+
const cfg = resolved.config[environment] ?? resolved.config;
|
|
26
|
+
if (cfg && typeof cfg.models === 'string') {
|
|
27
|
+
return path.resolve(cwd, cfg.models);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return path.resolve(cwd, 'models');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Require each model file (same filter as createDatabase).
|
|
36
|
+
* @param {string} modelsDir
|
|
37
|
+
* @returns {{ loaded: string[], errors: Array<{ file: string, message: string }> }}
|
|
38
|
+
*/
|
|
39
|
+
function loadModelFiles(modelsDir) {
|
|
40
|
+
const errors = [];
|
|
41
|
+
if (!fs.existsSync(modelsDir)) {
|
|
42
|
+
return {
|
|
43
|
+
loaded: [],
|
|
44
|
+
errors: [{ file: '', message: `Models directory not found: ${modelsDir}` }],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const files = fs
|
|
49
|
+
.readdirSync(modelsDir)
|
|
50
|
+
.filter((f) => f.endsWith('.js') && !f.startsWith('_'))
|
|
51
|
+
.sort();
|
|
52
|
+
|
|
53
|
+
const loaded = [];
|
|
54
|
+
for (const file of files) {
|
|
55
|
+
const full = path.join(modelsDir, file);
|
|
56
|
+
try {
|
|
57
|
+
require(full);
|
|
58
|
+
loaded.push(file);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
errors.push({ file, message: e.message || String(e) });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { loaded, errors };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Clear registry and load all models from the resolved directory.
|
|
69
|
+
* @param {string} cwd
|
|
70
|
+
* @param {{ modelsOverride?: string, configPath?: string, env?: string }} options
|
|
71
|
+
* @returns {{ modelsDir: string, loaded: string[], errors: Array<{ file: string, message: string }> }}
|
|
72
|
+
*/
|
|
73
|
+
function loadProjectModels(cwd, options = {}) {
|
|
74
|
+
const orm = getWebspressoOrmForProject(cwd);
|
|
75
|
+
orm.clearRegistry();
|
|
76
|
+
const modelsDir = resolveModelsDir(cwd, options);
|
|
77
|
+
const { loaded, errors } = loadModelFiles(modelsDir);
|
|
78
|
+
return { modelsDir, loaded, errors };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = {
|
|
82
|
+
resolveModelsDir,
|
|
83
|
+
loadModelFiles,
|
|
84
|
+
loadProjectModels,
|
|
85
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialize ORM registry to JSON-safe snapshot + Mermaid erDiagram source.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {string} name
|
|
7
|
+
* @returns {string}
|
|
8
|
+
*/
|
|
9
|
+
function mermaidEntityId(name) {
|
|
10
|
+
return String(name).replace(/[^a-zA-Z0-9_]/g, '_');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {import('../../../core/orm/types').ColumnMeta} meta
|
|
15
|
+
* @returns {Record<string, unknown>}
|
|
16
|
+
*/
|
|
17
|
+
function sanitizeColumnMeta(meta) {
|
|
18
|
+
if (!meta || typeof meta !== 'object') return {};
|
|
19
|
+
const out = {};
|
|
20
|
+
const keys = [
|
|
21
|
+
'type',
|
|
22
|
+
'nullable',
|
|
23
|
+
'primary',
|
|
24
|
+
'autoIncrement',
|
|
25
|
+
'unique',
|
|
26
|
+
'index',
|
|
27
|
+
'default',
|
|
28
|
+
'maxLength',
|
|
29
|
+
'precision',
|
|
30
|
+
'scale',
|
|
31
|
+
'enumValues',
|
|
32
|
+
'references',
|
|
33
|
+
'referenceColumn',
|
|
34
|
+
'auto',
|
|
35
|
+
];
|
|
36
|
+
for (const k of keys) {
|
|
37
|
+
if (meta[k] !== undefined) out[k] = meta[k];
|
|
38
|
+
}
|
|
39
|
+
if (meta.validations && typeof meta.validations === 'object') {
|
|
40
|
+
out.validations = { ...meta.validations };
|
|
41
|
+
}
|
|
42
|
+
if (meta.ui && typeof meta.ui === 'object') {
|
|
43
|
+
out.ui = { ...meta.ui };
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {import('../../../core/orm/types').ModelDefinition} def
|
|
50
|
+
* @returns {object}
|
|
51
|
+
*/
|
|
52
|
+
function serializeModel(def) {
|
|
53
|
+
const columns = [];
|
|
54
|
+
for (const [colName, meta] of def.columns) {
|
|
55
|
+
columns.push({
|
|
56
|
+
name: colName,
|
|
57
|
+
...sanitizeColumnMeta(meta),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
columns.sort((a, b) => a.name.localeCompare(b.name));
|
|
61
|
+
|
|
62
|
+
const relations = [];
|
|
63
|
+
for (const [relName, rel] of Object.entries(def.relations || {})) {
|
|
64
|
+
let targetModel = null;
|
|
65
|
+
try {
|
|
66
|
+
if (typeof rel.model === 'function') {
|
|
67
|
+
const tgt = rel.model();
|
|
68
|
+
targetModel = tgt && tgt.name ? tgt.name : null;
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
targetModel = null;
|
|
72
|
+
}
|
|
73
|
+
relations.push({
|
|
74
|
+
name: relName,
|
|
75
|
+
type: rel.type,
|
|
76
|
+
foreignKey: rel.foreignKey,
|
|
77
|
+
localKey: rel.localKey || 'id',
|
|
78
|
+
targetModel,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
relations.sort((a, b) => a.name.localeCompare(b.name));
|
|
82
|
+
|
|
83
|
+
let cache = def.cache;
|
|
84
|
+
if (cache !== undefined && typeof cache === 'object' && cache !== null) {
|
|
85
|
+
try {
|
|
86
|
+
JSON.stringify(cache);
|
|
87
|
+
} catch {
|
|
88
|
+
cache = '[object]';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
name: def.name,
|
|
94
|
+
table: def.table,
|
|
95
|
+
primaryKey: def.primaryKey,
|
|
96
|
+
scopes: { ...def.scopes },
|
|
97
|
+
hidden: [...def.hidden],
|
|
98
|
+
admin: {
|
|
99
|
+
enabled: def.admin.enabled,
|
|
100
|
+
label: def.admin.label,
|
|
101
|
+
icon: def.admin.icon,
|
|
102
|
+
},
|
|
103
|
+
rest: {
|
|
104
|
+
enabled: def.rest.enabled,
|
|
105
|
+
path: def.rest.path,
|
|
106
|
+
allowInclude: def.rest.allowInclude,
|
|
107
|
+
},
|
|
108
|
+
columns,
|
|
109
|
+
relations,
|
|
110
|
+
cache,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {Map<string, import('../../../core/orm/types').ModelDefinition>} modelsMap
|
|
116
|
+
* @param {{ generatedAt?: string }} [opts]
|
|
117
|
+
* @returns {{ generatedAt: string, models: object[] }}
|
|
118
|
+
*/
|
|
119
|
+
function buildSnapshot(modelsMap, opts = {}) {
|
|
120
|
+
const models = [];
|
|
121
|
+
for (const [, def] of modelsMap) {
|
|
122
|
+
models.push(serializeModel(def));
|
|
123
|
+
}
|
|
124
|
+
models.sort((a, b) => a.name.localeCompare(b.name));
|
|
125
|
+
return {
|
|
126
|
+
generatedAt: opts.generatedAt || new Date().toISOString(),
|
|
127
|
+
models,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Mermaid erDiagram: entity blocks + relationship lines (deduped).
|
|
133
|
+
* @param {{ models: object[] }} snapshot
|
|
134
|
+
* @returns {string}
|
|
135
|
+
*/
|
|
136
|
+
function buildMermaidErDiagram(snapshot) {
|
|
137
|
+
const lines = ['erDiagram'];
|
|
138
|
+
|
|
139
|
+
for (const m of snapshot.models) {
|
|
140
|
+
const id = mermaidEntityId(m.name);
|
|
141
|
+
lines.push(` ${id} {`);
|
|
142
|
+
for (const col of m.columns) {
|
|
143
|
+
const t = col.type != null ? String(col.type) : 'unknown';
|
|
144
|
+
const nm = col.name.replace(/[^\w]/g, '_');
|
|
145
|
+
lines.push(` ${t} ${nm}`);
|
|
146
|
+
}
|
|
147
|
+
lines.push(' }');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const seen = new Set();
|
|
151
|
+
for (const m of snapshot.models) {
|
|
152
|
+
const srcId = mermaidEntityId(m.name);
|
|
153
|
+
for (const r of m.relations) {
|
|
154
|
+
if (!r.targetModel) continue;
|
|
155
|
+
const tgtId = mermaidEntityId(r.targetModel);
|
|
156
|
+
let mid;
|
|
157
|
+
if (r.type === 'hasMany') mid = `${srcId} ||--o{ ${tgtId}`;
|
|
158
|
+
else if (r.type === 'hasOne') mid = `${srcId} ||--|| ${tgtId}`;
|
|
159
|
+
else if (r.type === 'belongsTo') mid = `${srcId} }o--|| ${tgtId}`;
|
|
160
|
+
else continue;
|
|
161
|
+
|
|
162
|
+
const label = String(r.name).replace(/"/g, "'");
|
|
163
|
+
const full = `${mid} : "${label}"`;
|
|
164
|
+
const key = `${srcId}|${tgtId}|${mid}`;
|
|
165
|
+
if (seen.has(key)) continue;
|
|
166
|
+
seen.add(key);
|
|
167
|
+
lines.push(` ${full}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return lines.join('\n');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = {
|
|
175
|
+
mermaidEntityId,
|
|
176
|
+
buildSnapshot,
|
|
177
|
+
buildMermaidErDiagram,
|
|
178
|
+
serializeModel,
|
|
179
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the same webspresso/core/orm instance that app model files use
|
|
3
|
+
* when they `require('webspresso')`. Global CLI would otherwise load a
|
|
4
|
+
* different module copy than the project's node_modules.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} cwd - Project root (typically `process.cwd()`).
|
|
11
|
+
* @returns {typeof import('../../core/orm')}
|
|
12
|
+
*/
|
|
13
|
+
function getWebspressoOrmForProject(cwd) {
|
|
14
|
+
try {
|
|
15
|
+
const pkgJson = require.resolve('webspresso/package.json', { paths: [cwd] });
|
|
16
|
+
const root = path.dirname(pkgJson);
|
|
17
|
+
return require(path.join(root, 'core', 'orm'));
|
|
18
|
+
} catch {
|
|
19
|
+
return require(path.join(__dirname, '../../core/orm'));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { getWebspressoOrmForProject };
|
package/bin/webspresso.js
CHANGED
|
@@ -31,6 +31,7 @@ const { registerCommand: registerAuditPrune } = require('./commands/audit-prune'
|
|
|
31
31
|
const { registerCommand: registerDoctor } = require('./commands/doctor');
|
|
32
32
|
const { registerCommand: registerSkill } = require('./commands/skill');
|
|
33
33
|
const { registerCommand: registerUpgrade } = require('./commands/upgrade');
|
|
34
|
+
const { registerCommand: registerOrmMap } = require('./commands/orm-map');
|
|
34
35
|
|
|
35
36
|
registerNew(program);
|
|
36
37
|
registerPage(program);
|
|
@@ -50,6 +51,7 @@ registerAuditPrune(program);
|
|
|
50
51
|
registerDoctor(program);
|
|
51
52
|
registerSkill(program);
|
|
52
53
|
registerUpgrade(program);
|
|
54
|
+
registerOrmMap(program);
|
|
53
55
|
|
|
54
56
|
// Parse arguments
|
|
55
57
|
program.parse();
|
package/core/auth/manager.js
CHANGED
|
@@ -116,7 +116,20 @@ class AuthManager {
|
|
|
116
116
|
if (!config.secret) {
|
|
117
117
|
throw new Error('Session secret is required. Set AUTH_SESSION_SECRET environment variable or pass session.secret in config.');
|
|
118
118
|
}
|
|
119
|
-
|
|
119
|
+
|
|
120
|
+
const mergedCookie = {
|
|
121
|
+
...DEFAULT_CONFIG.session.cookie,
|
|
122
|
+
...(config.cookie || {}),
|
|
123
|
+
};
|
|
124
|
+
const userSetSecure =
|
|
125
|
+
typeof (this.config.session.cookie && this.config.session.cookie.secure) === 'boolean';
|
|
126
|
+
const secureComputed =
|
|
127
|
+
process.env.NODE_ENV === 'production' ||
|
|
128
|
+
process.env.COOKIE_SECURE === 'true' ||
|
|
129
|
+
/^https:/i.test(String(process.env.BASE_URL || '').trim());
|
|
130
|
+
mergedCookie.secure = userSetSecure ? mergedCookie.secure : secureComputed;
|
|
131
|
+
config.cookie = mergedCookie;
|
|
132
|
+
|
|
120
133
|
return config;
|
|
121
134
|
}
|
|
122
135
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal extensible application kernel (event bus, views, flows, plugins).
|
|
3
|
+
* @module core/kernel/app
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { createEventBus } = require('./events');
|
|
7
|
+
const { createViewEngine } = require('./view');
|
|
8
|
+
const { definePlugin } = require('./plugin');
|
|
9
|
+
const { defineFlow } = require('./flow');
|
|
10
|
+
const { BaseRepository } = require('./base-repository');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {{
|
|
14
|
+
* events: ReturnType<typeof createEventBus>,
|
|
15
|
+
* view: ReturnType<typeof createViewEngine>,
|
|
16
|
+
* flows: Array<{ id?: string, trigger: string }>,
|
|
17
|
+
* registerPlugin: (plugin: Parameters<typeof definePlugin>[0]) => void,
|
|
18
|
+
* registerFlow: (flow: ReturnType<typeof defineFlow>) => () => void,
|
|
19
|
+
* paths: Record<string, string | undefined>,
|
|
20
|
+
* }} KernelApp
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {{
|
|
25
|
+
* paths?: { appViews?: string, themeViews?: string },
|
|
26
|
+
* }} [options]
|
|
27
|
+
*/
|
|
28
|
+
function createApp(options = {}) {
|
|
29
|
+
const paths = options.paths || {};
|
|
30
|
+
const events = createEventBus();
|
|
31
|
+
const view = createViewEngine({
|
|
32
|
+
appViews: paths.appViews,
|
|
33
|
+
themeViews: paths.themeViews,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** @type {Array<{ id?: string, trigger: string, unregister: () => void }>} */
|
|
37
|
+
const flowHandles = [];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {ReturnType<typeof defineFlow>} flowDef
|
|
41
|
+
*/
|
|
42
|
+
function registerFlow(flowDef) {
|
|
43
|
+
const id = flowDef.id || flowDef.trigger;
|
|
44
|
+
/** @param {import('./events').KernelEventContext} ctx */
|
|
45
|
+
const handler = async (ctx) => {
|
|
46
|
+
if (flowDef.when && !flowDef.when(ctx)) return;
|
|
47
|
+
const actions = flowDef.actions || [];
|
|
48
|
+
for (const action of actions) {
|
|
49
|
+
await action(ctx, app);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
events.on(flowDef.trigger, handler);
|
|
53
|
+
const unregister = () => events.off(flowDef.trigger, handler);
|
|
54
|
+
flowHandles.push({
|
|
55
|
+
id,
|
|
56
|
+
trigger: flowDef.trigger,
|
|
57
|
+
unregister,
|
|
58
|
+
});
|
|
59
|
+
return unregister;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {Parameters<typeof definePlugin>[0]} plugin
|
|
64
|
+
*/
|
|
65
|
+
function registerPlugin(plugin) {
|
|
66
|
+
if (typeof plugin.views === 'function') {
|
|
67
|
+
const bundle = plugin.views();
|
|
68
|
+
view.registerPluginViews(plugin.name, bundle);
|
|
69
|
+
}
|
|
70
|
+
if (typeof plugin.events === 'function') {
|
|
71
|
+
plugin.events(app);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @type {KernelApp} */
|
|
76
|
+
const app = {
|
|
77
|
+
events,
|
|
78
|
+
view,
|
|
79
|
+
options,
|
|
80
|
+
paths,
|
|
81
|
+
get flows() {
|
|
82
|
+
return flowHandles.map(({ id, trigger }) => ({ id, trigger }));
|
|
83
|
+
},
|
|
84
|
+
registerPlugin,
|
|
85
|
+
registerFlow,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return app;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = {
|
|
92
|
+
createApp,
|
|
93
|
+
definePlugin,
|
|
94
|
+
defineFlow,
|
|
95
|
+
BaseRepository,
|
|
96
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
const { randomUUID } = require('./events');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {ReturnType<import('./events').createEventBus>} EventBus
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Simulated in-memory row store keyed by id (UUID).
|
|
9
|
+
*/
|
|
10
|
+
class MemoryStore {
|
|
11
|
+
constructor() {
|
|
12
|
+
/** @type {Array<{ id: string } & Record<string, any>>} */
|
|
13
|
+
this._rows = [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
insert(record) {
|
|
17
|
+
this._rows.push(record);
|
|
18
|
+
return record;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
findIndex(id) {
|
|
22
|
+
return this._rows.findIndex((r) => r.id === id);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
getAt(index) {
|
|
26
|
+
return this._rows[index];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {number} index
|
|
31
|
+
* @param {{ id: string } & Record<string, any>} row
|
|
32
|
+
*/
|
|
33
|
+
setAt(index, row) {
|
|
34
|
+
this._rows[index] = row;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
deleteAt(index) {
|
|
38
|
+
const [removed] = this._rows.splice(index, 1);
|
|
39
|
+
return removed;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @template T
|
|
45
|
+
*/
|
|
46
|
+
class BaseRepository {
|
|
47
|
+
/**
|
|
48
|
+
* @param {EventBus} events
|
|
49
|
+
* @param {{ resource: string, source?: import('./events').EventSource }} options
|
|
50
|
+
*/
|
|
51
|
+
constructor(events, options) {
|
|
52
|
+
this.events = events;
|
|
53
|
+
this.resource = options.resource;
|
|
54
|
+
this.source = options.source || 'orm';
|
|
55
|
+
this._store = new MemoryStore();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @param {string} phase */
|
|
59
|
+
_eventName(phase) {
|
|
60
|
+
return `orm.${this.resource}.${phase}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {T & { id?: string }} data
|
|
65
|
+
*/
|
|
66
|
+
async create(data) {
|
|
67
|
+
const id = data.id || randomUUID();
|
|
68
|
+
const record = { ...data, id };
|
|
69
|
+
|
|
70
|
+
const beforeCtx = this.events.buildContext(
|
|
71
|
+
{ data: { ...record } },
|
|
72
|
+
{ source: this.source },
|
|
73
|
+
);
|
|
74
|
+
await this.events.dispatch(this._eventName('beforeCreate'), beforeCtx);
|
|
75
|
+
|
|
76
|
+
const toInsert = beforeCtx.payload.data;
|
|
77
|
+
const saved = this._store.insert({ ...toInsert });
|
|
78
|
+
|
|
79
|
+
const afterCtx = this.events.buildContext(
|
|
80
|
+
{ record: saved },
|
|
81
|
+
{ source: this.source },
|
|
82
|
+
);
|
|
83
|
+
await this.events.publish(this._eventName('afterCreate'), afterCtx);
|
|
84
|
+
|
|
85
|
+
return saved;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {string} id
|
|
90
|
+
* @param {Partial<T>} data
|
|
91
|
+
*/
|
|
92
|
+
async update(id, data) {
|
|
93
|
+
const idx = this._store.findIndex(id);
|
|
94
|
+
if (idx === -1) {
|
|
95
|
+
throw new Error(`Record not found: ${id}`);
|
|
96
|
+
}
|
|
97
|
+
const existing = this._store.getAt(idx);
|
|
98
|
+
|
|
99
|
+
const beforeCtx = this.events.buildContext(
|
|
100
|
+
{ id, data, record: { ...existing } },
|
|
101
|
+
{ source: this.source },
|
|
102
|
+
);
|
|
103
|
+
await this.events.dispatch(this._eventName('beforeUpdate'), beforeCtx);
|
|
104
|
+
|
|
105
|
+
const merged = { ...existing, ...beforeCtx.payload.data, id };
|
|
106
|
+
this._store.setAt(idx, merged);
|
|
107
|
+
|
|
108
|
+
const afterCtx = this.events.buildContext(
|
|
109
|
+
{ record: merged, previous: existing },
|
|
110
|
+
{ source: this.source },
|
|
111
|
+
);
|
|
112
|
+
await this.events.publish(this._eventName('afterUpdate'), afterCtx);
|
|
113
|
+
|
|
114
|
+
return merged;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {string} id
|
|
119
|
+
*/
|
|
120
|
+
async delete(id) {
|
|
121
|
+
const idx = this._store.findIndex(id);
|
|
122
|
+
if (idx === -1) {
|
|
123
|
+
throw new Error(`Record not found: ${id}`);
|
|
124
|
+
}
|
|
125
|
+
const existing = this._store.getAt(idx);
|
|
126
|
+
|
|
127
|
+
const beforeCtx = this.events.buildContext(
|
|
128
|
+
{ id, record: { ...existing } },
|
|
129
|
+
{ source: this.source },
|
|
130
|
+
);
|
|
131
|
+
await this.events.dispatch(this._eventName('beforeDelete'), beforeCtx);
|
|
132
|
+
|
|
133
|
+
const removed = this._store.deleteAt(idx);
|
|
134
|
+
|
|
135
|
+
const afterCtx = this.events.buildContext(
|
|
136
|
+
{ id, record: removed },
|
|
137
|
+
{ source: this.source },
|
|
138
|
+
);
|
|
139
|
+
await this.events.publish(this._eventName('afterDelete'), afterCtx);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = { BaseRepository, MemoryStore };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application event bus: sync dispatch vs async publish.
|
|
3
|
+
* @module core/kernel/events
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {'orm' | 'auth' | 'route' | 'plugin' | 'system'} EventSource
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {Object} EventMeta
|
|
12
|
+
* @property {string} [requestId]
|
|
13
|
+
* @property {string} [userId]
|
|
14
|
+
* @property {EventSource} source
|
|
15
|
+
* @property {Date} createdAt
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} KernelEventContext
|
|
20
|
+
* @property {any} payload
|
|
21
|
+
* @property {EventMeta} meta
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const { randomUUID } = require('crypto');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {any} payload
|
|
28
|
+
* @param {{ source: EventSource, requestId?: string, userId?: string }} meta
|
|
29
|
+
* @returns {KernelEventContext}
|
|
30
|
+
*/
|
|
31
|
+
function buildContext(payload, meta) {
|
|
32
|
+
return {
|
|
33
|
+
payload,
|
|
34
|
+
meta: {
|
|
35
|
+
source: meta.source,
|
|
36
|
+
requestId: meta.requestId,
|
|
37
|
+
userId: meta.userId,
|
|
38
|
+
createdAt: new Date(),
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @returns {{ dispatch: Function, publish: Function, on: Function, off: Function, buildContext: typeof buildContext }}
|
|
45
|
+
*/
|
|
46
|
+
function createEventBus() {
|
|
47
|
+
/** @type {Map<string, Array<(ctx: KernelEventContext) => any>>} */
|
|
48
|
+
const listeners = new Map();
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} eventName
|
|
52
|
+
* @param {(ctx: KernelEventContext) => any} handler
|
|
53
|
+
*/
|
|
54
|
+
function on(eventName, handler) {
|
|
55
|
+
if (!listeners.has(eventName)) {
|
|
56
|
+
listeners.set(eventName, []);
|
|
57
|
+
}
|
|
58
|
+
listeners.get(eventName).push(handler);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} eventName
|
|
63
|
+
* @param {(ctx: KernelEventContext) => any} handler
|
|
64
|
+
*/
|
|
65
|
+
function off(eventName, handler) {
|
|
66
|
+
const list = listeners.get(eventName);
|
|
67
|
+
if (!list) return;
|
|
68
|
+
const i = list.indexOf(handler);
|
|
69
|
+
if (i !== -1) list.splice(i, 1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {string} eventName
|
|
74
|
+
* @param {KernelEventContext} ctx
|
|
75
|
+
*/
|
|
76
|
+
async function dispatch(eventName, ctx) {
|
|
77
|
+
const list = listeners.get(eventName) || [];
|
|
78
|
+
let last;
|
|
79
|
+
for (const fn of list) {
|
|
80
|
+
last = await fn(ctx);
|
|
81
|
+
}
|
|
82
|
+
return last;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string} eventName
|
|
87
|
+
* @param {KernelEventContext} ctx
|
|
88
|
+
*/
|
|
89
|
+
async function publish(eventName, ctx) {
|
|
90
|
+
const list = listeners.get(eventName) || [];
|
|
91
|
+
await Promise.all(list.map((fn) => Promise.resolve(fn(ctx))));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { dispatch, publish, on, off, buildContext };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = {
|
|
98
|
+
createEventBus,
|
|
99
|
+
buildContext,
|
|
100
|
+
randomUUID,
|
|
101
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module core/kernel/flow
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {{
|
|
7
|
+
* id?: string,
|
|
8
|
+
* trigger: string,
|
|
9
|
+
* when?: (ctx: import('./events').KernelEventContext) => boolean,
|
|
10
|
+
* actions: Array<
|
|
11
|
+
* (
|
|
12
|
+
* ctx: import('./events').KernelEventContext,
|
|
13
|
+
* app: Record<string, unknown>,
|
|
14
|
+
* ) => Promise<void> | void
|
|
15
|
+
* >,
|
|
16
|
+
* }} definition
|
|
17
|
+
*/
|
|
18
|
+
function defineFlow(definition) {
|
|
19
|
+
return definition;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = { defineFlow };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application kernel: event bus, simulated repository hooks, plugins, views, flows.
|
|
3
|
+
* @module core/kernel
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
createApp: require('./app').createApp,
|
|
8
|
+
definePlugin: require('./plugin').definePlugin,
|
|
9
|
+
defineFlow: require('./flow').defineFlow,
|
|
10
|
+
BaseRepository: require('./base-repository').BaseRepository,
|
|
11
|
+
createEventBus: require('./events').createEventBus,
|
|
12
|
+
buildContext: require('./events').buildContext,
|
|
13
|
+
randomUUID: require('./events').randomUUID,
|
|
14
|
+
createViewEngine: require('./view').createViewEngine,
|
|
15
|
+
renderTemplate: require('./view').renderTemplate,
|
|
16
|
+
parseQualified: require('./view').parseQualified,
|
|
17
|
+
};
|