cabloy 5.1.194 → 5.1.196
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/.cabloy-version +1 -1
- package/.github/workflows/agent-governance.yml +1 -0
- package/CHANGELOG.md +22 -0
- package/README.md +13 -24
- package/package.json +2 -1
- package/repo-agent-governance/managed-assets.json +138 -33
- package/repo-agent-governance/scripts/pack-check.mjs +17 -0
- package/repo-agent-governance/skills/cabloy-backend-scaffold/references/follow-up-checklist.md +1 -0
- package/repo-agent-governance/skills/cabloy-domain-planning/SKILL.md +5 -3
- package/repo-agent-governance/skills/cabloy-spec-execution/SKILL.md +13 -8
- package/repo-agent-governance/skills/cabloy-spec-execution/evals/evals.json +108 -12
- package/repo-agent-governance/skills/cabloy-spec-execution/evals/files/scenarios.json +84 -0
- package/repo-agent-governance/skills/cabloy-spec-execution/evals/protocol.md +9 -0
- package/repo-agent-governance/skills/cabloy-spec-execution/references/execution-protocol.md +21 -5
- package/repo-agent-governance/skills/cabloy-spec-execution/references/status-and-evidence.md +5 -3
- package/repo-agent-governance/skills/cabloy-spec-generation/SKILL.md +78 -168
- package/repo-agent-governance/skills/cabloy-spec-generation/evals/evals.json +165 -17
- package/repo-agent-governance/skills/cabloy-spec-generation/evals/files/scenarios.json +114 -0
- package/repo-agent-governance/skills/cabloy-spec-generation/evals/protocol.md +44 -0
- package/repo-agent-governance/skills/cabloy-spec-generation/references/canonical-spec-input.md +145 -0
- package/repo-agent-governance/skills/cabloy-spec-generation/references/repo-aware-discovery.md +55 -57
- package/repo-agent-governance/skills/cabloy-spec-generation/references/repo-specs-document-set.md +6 -4
- package/repo-agent-governance/skills/cabloy-spec-generation/references/traceability-and-status-rules.md +12 -8
- package/repo-agent-governance/tests/governance.test.mjs +10 -1
- package/repo-agent-governance/tests/spec-audit.test.mjs +255 -0
- package/repo-agent-governance/tools/spec-audit/audit.mjs +427 -0
- package/repo-agent-governance/tools/spec-charts/generate-implementation-charts.mjs +97 -408
- package/repo-agent-governance/tools/spec-charts/generate-implementation-charts.test.mjs +462 -1
- package/repo-agent-governance/tools/spec-charts/spec-parser.mjs +561 -0
- package/repo-docs/.vitepress/config.mjs +54 -29
- package/repo-docs/ai/playbook-spec-execution.md +10 -4
- package/repo-docs/ai/playbook-spec-generation.md +40 -3
- package/repo-docs/ai/skills.md +3 -3
- package/repo-docs/backend/controller-aop-guide.md +10 -0
- package/repo-docs/backend/field-indexes.md +25 -0
- package/repo-docs/blogs/cabloy-fullstack-resource-addressing/index.md +1 -1
- package/repo-docs/fullstack/contract-loop-playbook.md +1 -1
- package/repo-docs/fullstack/development-history.md +24 -0
- package/repo-docs/fullstack/introduction.md +25 -156
- package/repo-docs/fullstack/quickstart.md +1 -1
- package/repo-docs/fullstack/ssr-entry-modes.md +52 -0
- package/repo-docs/fullstack/ssr-site-and-flavor-setup.md +3 -1
- package/repo-docs/fullstack/tutorial-5-backend-contract-sharing.md +13 -7
- package/repo-docs/fullstack/tutorial-6-one-contract-four-uses.md +11 -15
- package/repo-docs/fullstack/tutorials-overview.md +2 -2
- package/repo-docs/index.md +23 -45
- package/repo-docs/public/cabloy.png +0 -0
- package/repo-docs/public/cabloy.svg +3 -0
- package/repo-docs/public/favicon.svg +3 -0
- package/repo-docs/reference/repo-scripts.md +6 -1
- package/repo-e2e/specs/home-user-account.spec.ts +311 -207
- package/scripts/bootstrapAgentGovernance.mjs +1 -0
- package/scripts/upgrade.ts +1 -0
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/snippets/2-meta.index.ts +4 -10
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/snippets/2-meta.index.ts +4 -10
- package/vona/packages-cli/cli-set-api/package.json +8 -2
- package/vona/packages-cli/cli-set-api/src/index.ts +1 -0
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.tools.masterDetail.ts +9 -12
- package/vona/packages-cli/cli-set-api/src/lib/mergeMetaIndex.ts +121 -0
- package/vona/packages-cli/cli-set-api/test/indexSnippets.test.ts +41 -0
- package/vona/packages-cli/cli-set-api/test/mergeMetaIndex.test.ts +78 -0
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/pnpm-lock.yaml +6 -6
- package/vona/src/suite/a-commerce/modules/commerce-catalog/src/service/sku.ts +25 -4
- package/vona/src/suite/a-commerce/modules/commerce-catalog/test/skuUniqueness.test.ts +161 -0
- package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/meta.index.ts +21 -20
- package/vona/src/suite/a-commerce/modules/commerce-payment/test/paymentIndexes.test.ts +165 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/bean/meta.index.ts +16 -13
- package/vona/src/suite/a-commerce/modules/commerce-promotion/test/promotionIndexes.test.ts +82 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/meta.index.ts +23 -21
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/tradeIndexes.test.ts +86 -0
- package/vona/src/suite/a-home/modules/home-user/src/.metadata/index.ts +379 -376
- package/vona/src/suite/a-home/modules/home-user/src/controller/passportTest.ts +60 -0
- package/vona/src/suite/a-home/modules/home-user/test/passportTest.test.ts +164 -1
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
- package/vona/src/suite-vendor/a-pay/modules/a-pay/package.json +1 -1
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/meta.index.ts +35 -26
- package/vona/src/suite-vendor/a-pay/modules/pay-mock/package.json +1 -1
- package/vona/src/suite-vendor/a-pay/modules/pay-paypal/package.json +1 -1
- package/vona/src/suite-vendor/a-pay/modules/pay-stripe/package.json +1 -1
- package/vona/src/suite-vendor/a-pay/package.json +5 -5
- package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/transactionFiber_.ts +6 -2
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/transaction_.ts +4 -1
- package/vona/src/suite-vendor/a-vona/modules/a-ormutils/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-ormutils/src/lib/columns.ts +3 -1
- package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- /package/repo-docs/{.vitepress/public → public}/CNAME +0 -0
package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/snippets/2-meta.index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { metadataCustomSnippet } from '@cabloy/cli';
|
|
2
|
+
import { mergeMetaIndex } from 'vona-cli-set-api/mergeMetaIndex';
|
|
2
3
|
import { catchError } from '@cabloy/utils';
|
|
3
4
|
import fs from 'node:fs';
|
|
4
5
|
|
|
@@ -9,7 +10,7 @@ declare module '@cabloy/cli' {
|
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
const __snippet_import1 = "import { $tableColumns } from 'vona-module-a-ormutils';";
|
|
12
|
-
const
|
|
13
|
+
const __snippet_table = '<%=argv.moduleResourceName%>';
|
|
13
14
|
|
|
14
15
|
export default metadataCustomSnippet({
|
|
15
16
|
file: 'src/bean/meta.index.ts',
|
|
@@ -35,14 +36,7 @@ export default metadataCustomSnippet({
|
|
|
35
36
|
`import { Meta } from 'vona-module-a-meta';\n${code}`,
|
|
36
37
|
);
|
|
37
38
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (ast.includes('indexes: {}')) {
|
|
41
|
-
ast = ast.replace('indexes: {', `indexes: {\n ${code}\n `);
|
|
42
|
-
} else {
|
|
43
|
-
ast = ast.replace('indexes: {', `indexes: {\n ${code}`);
|
|
44
|
-
}
|
|
45
|
-
// ok
|
|
46
|
-
return ast;
|
|
39
|
+
const table = await cli.template.renderContent({ content: __snippet_table });
|
|
40
|
+
return mergeMetaIndex(ast, table, 'name');
|
|
47
41
|
},
|
|
48
42
|
});
|
package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/snippets/2-meta.index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { metadataCustomSnippet } from '@cabloy/cli';
|
|
2
|
+
import { mergeMetaIndex } from 'vona-cli-set-api/mergeMetaIndex';
|
|
2
3
|
import { catchError } from '@cabloy/utils';
|
|
3
4
|
import fs from 'node:fs';
|
|
4
5
|
|
|
@@ -9,7 +10,7 @@ declare module '@cabloy/cli' {
|
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
const __snippet_import1 = "import { $tableColumns } from 'vona-module-a-ormutils';";
|
|
12
|
-
const
|
|
13
|
+
const __snippet_table = '<%=argv.moduleResourceName%>';
|
|
13
14
|
|
|
14
15
|
export default metadataCustomSnippet({
|
|
15
16
|
file: 'src/bean/meta.index.ts',
|
|
@@ -35,14 +36,7 @@ export default metadataCustomSnippet({
|
|
|
35
36
|
`import { Meta } from 'vona-module-a-meta';\n${code}`,
|
|
36
37
|
);
|
|
37
38
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (ast.includes('indexes: {}')) {
|
|
41
|
-
ast = ast.replace('indexes: {', `indexes: {\n ${code}\n `);
|
|
42
|
-
} else {
|
|
43
|
-
ast = ast.replace('indexes: {', `indexes: {\n ${code}`);
|
|
44
|
-
}
|
|
45
|
-
// ok
|
|
46
|
-
return ast;
|
|
39
|
+
const table = await cli.template.renderContent({ content: __snippet_table });
|
|
40
|
+
return mergeMetaIndex(ast, table, 'name');
|
|
47
41
|
},
|
|
48
42
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vona-cli-set-api",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.155",
|
|
4
4
|
"gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
|
|
5
5
|
"description": "vona cli-set-api",
|
|
6
6
|
"keywords": [
|
|
@@ -26,6 +26,11 @@
|
|
|
26
26
|
"import": "./src/index.ts",
|
|
27
27
|
"default": "./dist/index.js"
|
|
28
28
|
},
|
|
29
|
+
"./mergeMetaIndex": {
|
|
30
|
+
"types": "./dist/lib/mergeMetaIndex.d.ts",
|
|
31
|
+
"import": "./src/lib/mergeMetaIndex.ts",
|
|
32
|
+
"default": "./dist/index.js"
|
|
33
|
+
},
|
|
29
34
|
"./package.json": "./package.json"
|
|
30
35
|
},
|
|
31
36
|
"publishConfig": {
|
|
@@ -86,7 +91,8 @@
|
|
|
86
91
|
"remove": [
|
|
87
92
|
"clean-package",
|
|
88
93
|
"devDependencies",
|
|
89
|
-
"exports.\\..import"
|
|
94
|
+
"exports.\\..import",
|
|
95
|
+
"exports.\\./mergeMetaIndex.import"
|
|
90
96
|
]
|
|
91
97
|
}
|
|
92
98
|
}
|
|
@@ -4,6 +4,7 @@ import { BeanCliBase } from '@cabloy/cli';
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
|
+
import { mergeMetaIndex } from '../mergeMetaIndex.ts';
|
|
7
8
|
import { __ThisSetName__ } from '../this.ts';
|
|
8
9
|
|
|
9
10
|
type DetailMode = 'aggregate' | 'standalone';
|
|
@@ -179,13 +180,14 @@ export class CliToolsMasterDetail extends BeanCliBase {
|
|
|
179
180
|
|
|
180
181
|
private async _patchDetailMetaIndex() {
|
|
181
182
|
const { argv } = this.context;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
183
|
+
const fileName = this._detailPaths().metaIndex;
|
|
184
|
+
const content = this._readFile(fileName);
|
|
185
|
+
const table = this.helper.combineModuleNameAndResource(
|
|
186
|
+
argv.detailModuleInfo.relativeName,
|
|
187
|
+
argv.detailResourceName,
|
|
188
|
+
);
|
|
189
|
+
const updated = mergeMetaIndex(content, table, argv.fk);
|
|
190
|
+
if (updated !== content) await this._saveFile(fileName, updated);
|
|
189
191
|
}
|
|
190
192
|
|
|
191
193
|
private _detailMetaVersionLine() {
|
|
@@ -193,11 +195,6 @@ export class CliToolsMasterDetail extends BeanCliBase {
|
|
|
193
195
|
return ` table.tableIdentity(entity${argv.detailResourceNameCapitalize}.${argv.fk}).comment(entity${argv.detailResourceNameCapitalize}.$comment.${argv.fk});\n`;
|
|
194
196
|
}
|
|
195
197
|
|
|
196
|
-
private _detailMetaIndexLine() {
|
|
197
|
-
const { argv } = this.context;
|
|
198
|
-
return ` ...$tableColumns('${this.helper.combineModuleNameAndResource(argv.detailModuleInfo.relativeName, argv.detailResourceName)}', '${argv.fk}'),\n`;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
198
|
private async _patchFileByMarker({
|
|
202
199
|
fileName,
|
|
203
200
|
existsNeedle,
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import gogocode from 'gogocode';
|
|
2
|
+
|
|
3
|
+
interface AstNode {
|
|
4
|
+
type: string;
|
|
5
|
+
start: number;
|
|
6
|
+
end: number;
|
|
7
|
+
[key: string]: any;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function propertyName(node: AstNode): string | undefined {
|
|
11
|
+
if (node.computed) return;
|
|
12
|
+
if (node.key?.type === 'Identifier') return node.key.name;
|
|
13
|
+
if (node.key?.type === 'StringLiteral') return node.key.value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function literalValue(node: AstNode): string | undefined {
|
|
17
|
+
return node?.type === 'StringLiteral' ? node.value : undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function indexObject(source: string): AstNode {
|
|
21
|
+
const file = gogocode(source, {
|
|
22
|
+
parseOptions: { sourceType: 'module', plugins: ['typescript', 'decorators-legacy'] },
|
|
23
|
+
}).node as AstNode;
|
|
24
|
+
const objects: AstNode[] = [];
|
|
25
|
+
for (const statement of file.program.body as AstNode[]) {
|
|
26
|
+
const declaration = statement.declaration ?? statement;
|
|
27
|
+
for (const decorator of declaration.decorators ?? []) {
|
|
28
|
+
const call = decorator.expression;
|
|
29
|
+
if (call.type !== 'CallExpression' || call.callee.name !== 'Meta') continue;
|
|
30
|
+
const options = call.arguments[0];
|
|
31
|
+
if (options?.type !== 'ObjectExpression') continue;
|
|
32
|
+
for (const property of options.properties as AstNode[]) {
|
|
33
|
+
if (property.type === 'ObjectProperty' && propertyName(property) === 'indexes') {
|
|
34
|
+
if (property.value.type !== 'ObjectExpression') {
|
|
35
|
+
throw new Error('meta.index indexes must be an object literal');
|
|
36
|
+
}
|
|
37
|
+
objects.push(property.value);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (objects.length !== 1) throw new Error('expected exactly one @Meta indexes object');
|
|
43
|
+
return objects[0];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function quote(value: string): string {
|
|
47
|
+
return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function mergeMetaIndex(source: string, tableName: string, field: string): string {
|
|
51
|
+
const indexes = indexObject(source);
|
|
52
|
+
const declarations: AstNode[] = [];
|
|
53
|
+
const fields: string[] = [];
|
|
54
|
+
for (const property of indexes.properties as AstNode[]) {
|
|
55
|
+
if (property.type === 'ObjectProperty') {
|
|
56
|
+
const key = propertyName(property);
|
|
57
|
+
if (!key || key === tableName) {
|
|
58
|
+
throw new Error(`unsupported index property for ${tableName}`);
|
|
59
|
+
}
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (property.type !== 'SpreadElement') {
|
|
63
|
+
throw new Error(`unsupported index declaration for ${tableName}`);
|
|
64
|
+
}
|
|
65
|
+
const call = property.argument;
|
|
66
|
+
if (call.type !== 'CallExpression' || call.callee.name !== '$tableColumns') {
|
|
67
|
+
throw new Error(`unsupported index spread for ${tableName}`);
|
|
68
|
+
}
|
|
69
|
+
const name = literalValue(call.arguments[0]);
|
|
70
|
+
if (!name) throw new Error(`index table name must be a string literal: ${tableName}`);
|
|
71
|
+
if (name !== tableName) continue;
|
|
72
|
+
const value = call.arguments[1];
|
|
73
|
+
let values: AstNode[];
|
|
74
|
+
if (value?.type === 'StringLiteral') values = [value];
|
|
75
|
+
else if (value?.type === 'ArrayExpression') values = value.elements;
|
|
76
|
+
else throw new Error(`unsupported index fields for ${tableName}`);
|
|
77
|
+
for (const item of values) {
|
|
78
|
+
const text = literalValue(item);
|
|
79
|
+
if (text === undefined) throw new Error(`unsupported index fields for ${tableName}`);
|
|
80
|
+
for (const spec of text.split(',')) {
|
|
81
|
+
if (!fields.includes(spec)) fields.push(spec);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
declarations.push(property);
|
|
85
|
+
}
|
|
86
|
+
if (!fields.includes(field)) fields.push(field);
|
|
87
|
+
if (declarations.length === 1) {
|
|
88
|
+
const value = declarations[0].argument.arguments[1];
|
|
89
|
+
if (value.type === 'StringLiteral' && fields.length === 1 && value.value === field)
|
|
90
|
+
return source;
|
|
91
|
+
if (value.type === 'ArrayExpression') {
|
|
92
|
+
const values = value.elements.map(literalValue);
|
|
93
|
+
if (
|
|
94
|
+
values.length === fields.length &&
|
|
95
|
+
values.every((item, index) => item === fields[index])
|
|
96
|
+
) {
|
|
97
|
+
return source;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const columns = fields.length === 1 ? quote(fields[0]) : `[${fields.map(quote).join(', ')}]`;
|
|
102
|
+
const declaration = `...$tableColumns(${quote(tableName)}, ${columns})`;
|
|
103
|
+
if (!declarations.length) {
|
|
104
|
+
const content = source.slice(indexes.start + 1, indexes.end - 1);
|
|
105
|
+
if (content.trim() && !content.trimEnd().endsWith(',')) {
|
|
106
|
+
throw new Error(`indexes object must use trailing commas before adding ${tableName}`);
|
|
107
|
+
}
|
|
108
|
+
return `${source.slice(0, indexes.start + 1)}\n ${declaration},${source.slice(indexes.start + 1)}`;
|
|
109
|
+
}
|
|
110
|
+
const patches = declarations.map((item, index) => {
|
|
111
|
+
if (!index) return { start: item.start, end: item.end, text: declaration };
|
|
112
|
+
let end = item.end;
|
|
113
|
+
while (/\s/.test(source[end]) && end < indexes.end) end++;
|
|
114
|
+
if (source[end] === ',') end++;
|
|
115
|
+
return { start: item.start, end, text: '' };
|
|
116
|
+
});
|
|
117
|
+
for (const patch of patches.reverse()) {
|
|
118
|
+
source = `${source.slice(0, patch.start)}${patch.text}${source.slice(patch.end)}`;
|
|
119
|
+
}
|
|
120
|
+
return source;
|
|
121
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
|
|
4
|
+
import basic from '../cli/templates/tools/crudBasic/snippets/2-meta.index.ts';
|
|
5
|
+
import start from '../cli/templates/tools/crudStart/snippets/2-meta.index.ts';
|
|
6
|
+
import { mergeMetaIndex } from '../src/lib/mergeMetaIndex.ts';
|
|
7
|
+
|
|
8
|
+
const initial = `import { Meta } from 'vona-module-a-meta';
|
|
9
|
+
@Meta({ indexes: { ...$tableColumns('trainingRecord', 'studentId'), } }) class MetaIndex {}
|
|
10
|
+
`;
|
|
11
|
+
|
|
12
|
+
const cli = {
|
|
13
|
+
template: {
|
|
14
|
+
renderContent: async ({ content }: { content: string }) =>
|
|
15
|
+
content.replace('<%=argv.moduleResourceName%>', 'trainingRecord'),
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe('CRUD index snippets', () => {
|
|
20
|
+
it('resolves the shared index helper from source', async () => {
|
|
21
|
+
assert.equal(
|
|
22
|
+
import.meta.resolve('vona-cli-set-api/mergeMetaIndex'),
|
|
23
|
+
new URL('../src/lib/mergeMetaIndex.ts', import.meta.url).href,
|
|
24
|
+
);
|
|
25
|
+
const { mergeMetaIndex: exported } = await import('vona-cli-set-api/mergeMetaIndex');
|
|
26
|
+
assert.equal(exported, mergeMetaIndex);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
for (const [edition, snippet] of [
|
|
30
|
+
['basic', basic],
|
|
31
|
+
['start', start],
|
|
32
|
+
] as const) {
|
|
33
|
+
it(`${edition} merges name into an existing detail index`, async () => {
|
|
34
|
+
const result = await snippet.transform({ cli, ast: initial } as never);
|
|
35
|
+
assert.equal(result.match(/\.\.\.\$tableColumns\('trainingRecord'/g)?.length, 1);
|
|
36
|
+
assert.match(result, /\['studentId', 'name'\]/);
|
|
37
|
+
assert.equal(await snippet.transform({ cli, ast: result } as never), result);
|
|
38
|
+
assert.equal(mergeMetaIndex(result, 'trainingRecord', 'studentId'), result);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { mergeMetaIndex } from '../src/lib/mergeMetaIndex.ts';
|
|
5
|
+
|
|
6
|
+
const source = (indexes: string) => `import { Meta } from 'vona-module-a-meta';
|
|
7
|
+
@Meta({
|
|
8
|
+
indexes: {
|
|
9
|
+
${indexes}
|
|
10
|
+
},
|
|
11
|
+
})
|
|
12
|
+
class MetaIndex {}
|
|
13
|
+
`;
|
|
14
|
+
|
|
15
|
+
function declarations(code: string, table: string) {
|
|
16
|
+
const matcher = /\.\.\.\$tableColumns\('([^']+)', (\[[^\]]+\]|'[^']+')\)/g;
|
|
17
|
+
const found = [...code.matchAll(matcher)].filter(match => {
|
|
18
|
+
const line = code.slice(code.lastIndexOf('\n', match.index) + 1, match.index);
|
|
19
|
+
return match[1] === table && !line.includes('//');
|
|
20
|
+
});
|
|
21
|
+
assert.equal(found.length, 1, code);
|
|
22
|
+
const fields = found[0][2];
|
|
23
|
+
return fields.startsWith('[')
|
|
24
|
+
? Array.from(fields.matchAll(/'([^']+)'/g), match => match[1])
|
|
25
|
+
: [fields.slice(1, -1)];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('mergeMetaIndex', () => {
|
|
29
|
+
it('adds name and foreign-key indexes independently to a new table', () => {
|
|
30
|
+
const input = source('');
|
|
31
|
+
const withName = mergeMetaIndex(input, 'trainingRecord', 'name');
|
|
32
|
+
const withForeignKey = mergeMetaIndex(withName, 'trainingRecord', 'studentId');
|
|
33
|
+
assert.deepEqual(declarations(withForeignKey, 'trainingRecord'), ['name', 'studentId']);
|
|
34
|
+
assert.equal(mergeMetaIndex(withForeignKey, 'trainingRecord', 'studentId'), withForeignKey);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('merges existing arrays, composites, and repeated table spreads without changing other tables', () => {
|
|
38
|
+
const input = source(` ...$tableColumns('other', 'studentId'),
|
|
39
|
+
...$tableColumns('trainingRecord', ['name', 'state+expiresAt']),
|
|
40
|
+
...$tableColumns('trainingRecord', 'studentId'),`);
|
|
41
|
+
const output = mergeMetaIndex(input, 'trainingRecord', 'studentId');
|
|
42
|
+
assert.deepEqual(declarations(output, 'trainingRecord'), [
|
|
43
|
+
'name',
|
|
44
|
+
'state+expiresAt',
|
|
45
|
+
'studentId',
|
|
46
|
+
]);
|
|
47
|
+
assert.deepEqual(declarations(output, 'other'), ['studentId']);
|
|
48
|
+
assert.equal(mergeMetaIndex(output, 'trainingRecord', 'studentId'), output);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('repairs previously generated declarations even if the requested field appears first', () => {
|
|
52
|
+
const input = source(` ...$tableColumns('trainingRecord', 'studentId'),
|
|
53
|
+
...$tableColumns('trainingRecord', 'name'),`);
|
|
54
|
+
const output = mergeMetaIndex(input, 'trainingRecord', 'studentId');
|
|
55
|
+
assert.deepEqual(declarations(output, 'trainingRecord'), ['studentId', 'name']);
|
|
56
|
+
assert.equal(mergeMetaIndex(output, 'trainingRecord', 'studentId'), output);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('does not confuse another table or comment with an existing target index', () => {
|
|
60
|
+
const input = source(` // ...$tableColumns('trainingRecord', 'studentId'),
|
|
61
|
+
...$tableColumns('other', 'studentId'),`);
|
|
62
|
+
const output = mergeMetaIndex(input, 'trainingRecord', 'studentId');
|
|
63
|
+
assert.deepEqual(declarations(output, 'trainingRecord'), ['studentId']);
|
|
64
|
+
assert.match(output, /\.\.\.\$tableColumns\('other', 'studentId'\)/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('rejects ambiguous same-table expressions', () => {
|
|
68
|
+
const input = source(" ...$tableColumns('trainingRecord', fields),");
|
|
69
|
+
assert.throws(
|
|
70
|
+
() => mergeMetaIndex(input, 'trainingRecord', 'studentId'),
|
|
71
|
+
/unsupported index fields/,
|
|
72
|
+
);
|
|
73
|
+
assert.throws(
|
|
74
|
+
() => mergeMetaIndex(source(" trainingRecord: ['name'],"), 'trainingRecord', 'studentId'),
|
|
75
|
+
/unsupported index property/,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vona",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.103",
|
|
4
4
|
"gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
|
|
5
5
|
"description": "Vona is an intuitive, elegant and powerful Node.js framework for rapidly developing enterprise applications of any size",
|
|
6
6
|
"keywords": [
|
package/vona/pnpm-lock.yaml
CHANGED
|
@@ -1652,7 +1652,7 @@ importers:
|
|
|
1652
1652
|
specifier: ^5.0.3
|
|
1653
1653
|
version: link:modules/a-markdown
|
|
1654
1654
|
vona-module-a-rbac:
|
|
1655
|
-
specifier: ^5.0.
|
|
1655
|
+
specifier: ^5.0.8
|
|
1656
1656
|
version: link:modules/a-rbac
|
|
1657
1657
|
vona-module-a-socket:
|
|
1658
1658
|
specifier: workspace:^
|
|
@@ -1956,16 +1956,16 @@ importers:
|
|
|
1956
1956
|
src/suite-vendor/a-pay:
|
|
1957
1957
|
dependencies:
|
|
1958
1958
|
vona-module-a-pay:
|
|
1959
|
-
specifier: ^5.0.
|
|
1959
|
+
specifier: ^5.0.10
|
|
1960
1960
|
version: link:modules/a-pay
|
|
1961
1961
|
vona-module-pay-mock:
|
|
1962
|
-
specifier: ^5.0.
|
|
1962
|
+
specifier: ^5.0.10
|
|
1963
1963
|
version: link:modules/pay-mock
|
|
1964
1964
|
vona-module-pay-paypal:
|
|
1965
|
-
specifier: ^5.0.
|
|
1965
|
+
specifier: ^5.0.10
|
|
1966
1966
|
version: link:modules/pay-paypal
|
|
1967
1967
|
vona-module-pay-stripe:
|
|
1968
|
-
specifier: ^5.0.
|
|
1968
|
+
specifier: ^5.0.10
|
|
1969
1969
|
version: link:modules/pay-stripe
|
|
1970
1970
|
|
|
1971
1971
|
src/suite-vendor/a-pay/modules/a-pay:
|
|
@@ -2031,7 +2031,7 @@ importers:
|
|
|
2031
2031
|
specifier: ^5.0.3
|
|
2032
2032
|
version: link:modules/test-markdown
|
|
2033
2033
|
vona-module-test-pay:
|
|
2034
|
-
specifier: ^5.0.
|
|
2034
|
+
specifier: ^5.0.9
|
|
2035
2035
|
version: link:modules/test-pay
|
|
2036
2036
|
|
|
2037
2037
|
src/suite-vendor/a-test/modules/test-auth:
|
|
@@ -19,8 +19,27 @@ const allowedLifecycleTransitions: Record<EntitySku['lifecycle'], EntitySku['lif
|
|
|
19
19
|
archived: [],
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
+
const serializationRetryOptions = {
|
|
23
|
+
retries: 6,
|
|
24
|
+
factor: 2,
|
|
25
|
+
minTimeout: 10,
|
|
26
|
+
maxTimeout: 160,
|
|
27
|
+
randomize: true,
|
|
28
|
+
errorCodes: [
|
|
29
|
+
'40001',
|
|
30
|
+
'40P01',
|
|
31
|
+
'ER_LOCK_DEADLOCK',
|
|
32
|
+
'ER_LOCK_WAIT_TIMEOUT',
|
|
33
|
+
'SQLITE_BUSY',
|
|
34
|
+
'SQLITE_BUSY_SNAPSHOT',
|
|
35
|
+
],
|
|
36
|
+
ownerOnly: true,
|
|
37
|
+
};
|
|
38
|
+
|
|
22
39
|
@Service()
|
|
23
40
|
export class ServiceSku extends BeanBase {
|
|
41
|
+
@Core.transaction({ isolationLevel: 'SERIALIZABLE' })
|
|
42
|
+
@Core.retryable(serializationRetryOptions)
|
|
24
43
|
async create(sku: DtoSkuCreate): Promise<EntitySku> {
|
|
25
44
|
await this._ensureProductExists(sku.productId);
|
|
26
45
|
await this._ensureCodeAvailable(sku.code);
|
|
@@ -64,6 +83,7 @@ export class ServiceSku extends BeanBase {
|
|
|
64
83
|
}
|
|
65
84
|
|
|
66
85
|
@Core.transaction({ isolationLevel: 'SERIALIZABLE' })
|
|
86
|
+
@Core.retryable(serializationRetryOptions)
|
|
67
87
|
async update(id: TableIdentity, sku: DtoSkuUpdate) {
|
|
68
88
|
if (sku.lifecycle !== undefined) {
|
|
69
89
|
const currentSku = await this.scope.model.sku.getByIdForUpdate(id);
|
|
@@ -96,9 +116,10 @@ export class ServiceSku extends BeanBase {
|
|
|
96
116
|
}
|
|
97
117
|
|
|
98
118
|
private async _ensureCodeAvailable(code: string, excludedId?: TableIdentity) {
|
|
99
|
-
const sku = await this.scope.model.sku.get(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
119
|
+
const sku = await this.scope.model.sku.get(
|
|
120
|
+
{ code, ...(excludedId === undefined ? {} : { id: { _notEq_: excludedId } }) },
|
|
121
|
+
{ disableCacheEntity: true, disableCacheQuery: true },
|
|
122
|
+
);
|
|
123
|
+
if (sku) this.app.throw(409, `SKU code already exists: ${code}`);
|
|
103
124
|
}
|
|
104
125
|
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { TableIdentity } from 'table-identity';
|
|
2
|
+
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { after, before, describe, it } from 'node:test';
|
|
6
|
+
import { acquireTestLock, app } from 'vona-mock';
|
|
7
|
+
|
|
8
|
+
interface ISkuFixture {
|
|
9
|
+
categoryId?: TableIdentity;
|
|
10
|
+
productIds: TableIdentity[];
|
|
11
|
+
codes: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function runInContext<T>(operation: () => Promise<T>): Promise<T> {
|
|
15
|
+
return await app.bean.executor.mockCtx(operation);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function createFixture(fixture: ISkuFixture, suffix: string, products = 1) {
|
|
19
|
+
await runInContext(async () => {
|
|
20
|
+
const catalog = app.scope('commerce-catalog');
|
|
21
|
+
const category = await catalog.model.category.insert({
|
|
22
|
+
name: `sku-unique-category-${suffix}`,
|
|
23
|
+
published: false,
|
|
24
|
+
});
|
|
25
|
+
fixture.categoryId = category.id;
|
|
26
|
+
for (let index = 0; index < products; index++) {
|
|
27
|
+
const product = await catalog.model.product.insert({
|
|
28
|
+
categoryId: category.id,
|
|
29
|
+
title: `sku-unique-product-${suffix}-${index}`,
|
|
30
|
+
published: false,
|
|
31
|
+
});
|
|
32
|
+
fixture.productIds.push(product.id);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function cleanupFixture(fixture: ISkuFixture) {
|
|
38
|
+
await runInContext(async () => {
|
|
39
|
+
const catalog = app.scope('commerce-catalog');
|
|
40
|
+
for (const productId of fixture.productIds) {
|
|
41
|
+
for (const code of fixture.codes) {
|
|
42
|
+
const skus = await catalog.model.sku.select(
|
|
43
|
+
{ where: { productId, code } },
|
|
44
|
+
{ disableDeleted: true },
|
|
45
|
+
);
|
|
46
|
+
for (const sku of skus) {
|
|
47
|
+
await catalog.model.sku.delete({ id: sku.id }, { disableDeleted: true });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
await catalog.model.product.delete({ id: productId }, { disableDeleted: true });
|
|
51
|
+
}
|
|
52
|
+
if (fixture.categoryId !== undefined) {
|
|
53
|
+
await catalog.model.category.delete({ id: fixture.categoryId }, { disableDeleted: true });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function assertOneBusinessConflict(results: PromiseSettledResult<unknown>[]) {
|
|
59
|
+
assert.equal(
|
|
60
|
+
results.filter(result => result.status === 'fulfilled').length,
|
|
61
|
+
1,
|
|
62
|
+
JSON.stringify(results),
|
|
63
|
+
);
|
|
64
|
+
const rejected = results.filter(result => result.status === 'rejected');
|
|
65
|
+
assert.equal(rejected.length, 1, JSON.stringify(results));
|
|
66
|
+
assert.equal((rejected[0] as PromiseRejectedResult).reason?.code, 409);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('skuUniqueness.test.ts', { concurrency: false }, () => {
|
|
70
|
+
let releaseTestLock: (() => void) | undefined;
|
|
71
|
+
|
|
72
|
+
before(async () => {
|
|
73
|
+
releaseTestLock = await acquireTestLock('a-commerce');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
after(() => {
|
|
77
|
+
releaseTestLock?.();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('keeps exactly one SKU after competing same-tenant creates across products', async () => {
|
|
81
|
+
const fixture: ISkuFixture = { productIds: [], codes: [] };
|
|
82
|
+
const suffix = randomUUID().slice(0, 12);
|
|
83
|
+
const code = `sku-unique-create-${suffix}`;
|
|
84
|
+
fixture.codes.push(code);
|
|
85
|
+
try {
|
|
86
|
+
await createFixture(fixture, suffix, 2);
|
|
87
|
+
const results = await Promise.allSettled(
|
|
88
|
+
fixture.productIds.map(productId =>
|
|
89
|
+
runInContext(() =>
|
|
90
|
+
app.scope('commerce-catalog').service.sku.create({
|
|
91
|
+
productId,
|
|
92
|
+
code,
|
|
93
|
+
priceCents: 100,
|
|
94
|
+
lifecycle: 'draft',
|
|
95
|
+
}),
|
|
96
|
+
),
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
assertOneBusinessConflict(results);
|
|
100
|
+
await runInContext(async () => {
|
|
101
|
+
const skus = await app.scope('commerce-catalog').model.sku.select({ where: { code } });
|
|
102
|
+
assert.equal(skus.length, 1);
|
|
103
|
+
assert.ok(
|
|
104
|
+
fixture.productIds.some(productId => String(productId) === String(skus[0]!.productId)),
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
} finally {
|
|
108
|
+
await cleanupFixture(fixture);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('keeps exactly one target code after competing updates', async () => {
|
|
113
|
+
const fixture: ISkuFixture = { productIds: [], codes: [] };
|
|
114
|
+
const suffix = randomUUID().slice(0, 12);
|
|
115
|
+
const targetCode = `sku-unique-update-${suffix}`;
|
|
116
|
+
const sourceCodes = [0, 1].map(index => `sku-unique-source-${suffix}-${index}`);
|
|
117
|
+
fixture.codes.push(targetCode, ...sourceCodes);
|
|
118
|
+
try {
|
|
119
|
+
await createFixture(fixture, suffix, 2);
|
|
120
|
+
const sourceIds = await runInContext(async () => {
|
|
121
|
+
const catalog = app.scope('commerce-catalog');
|
|
122
|
+
const skus = [];
|
|
123
|
+
for (let index = 0; index < 2; index++) {
|
|
124
|
+
skus.push(
|
|
125
|
+
await catalog.service.sku.create({
|
|
126
|
+
productId: fixture.productIds[index]!,
|
|
127
|
+
code: sourceCodes[index]!,
|
|
128
|
+
priceCents: 100,
|
|
129
|
+
lifecycle: 'draft',
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return skus.map(sku => sku.id);
|
|
134
|
+
});
|
|
135
|
+
const results = await Promise.allSettled(
|
|
136
|
+
sourceIds.map((skuId, index) =>
|
|
137
|
+
runInContext(() =>
|
|
138
|
+
app.scope('commerce-catalog').service.sku.update(skuId, {
|
|
139
|
+
productId: fixture.productIds[index]!,
|
|
140
|
+
code: targetCode,
|
|
141
|
+
priceCents: 200,
|
|
142
|
+
lifecycle: 'draft',
|
|
143
|
+
}),
|
|
144
|
+
),
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
assertOneBusinessConflict(results);
|
|
148
|
+
await runInContext(async () => {
|
|
149
|
+
const catalog = app.scope('commerce-catalog');
|
|
150
|
+
const targetSkus = await catalog.model.sku.select({ where: { code: targetCode } });
|
|
151
|
+
assert.equal(targetSkus.length, 1);
|
|
152
|
+
const allSkus = await catalog.model.sku.select({ where: { id: sourceIds } });
|
|
153
|
+
assert.equal(allSkus.length, 2);
|
|
154
|
+
assert.equal(allSkus.filter(sku => sourceCodes.includes(sku.code!)).length, 1);
|
|
155
|
+
assert.equal(allSkus.find(sku => sku.code === targetCode)?.priceCents, 200);
|
|
156
|
+
});
|
|
157
|
+
} finally {
|
|
158
|
+
await cleanupFixture(fixture);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
});
|