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.
Files changed (91) hide show
  1. package/.cabloy-version +1 -1
  2. package/.github/workflows/agent-governance.yml +1 -0
  3. package/CHANGELOG.md +22 -0
  4. package/README.md +13 -24
  5. package/package.json +2 -1
  6. package/repo-agent-governance/managed-assets.json +138 -33
  7. package/repo-agent-governance/scripts/pack-check.mjs +17 -0
  8. package/repo-agent-governance/skills/cabloy-backend-scaffold/references/follow-up-checklist.md +1 -0
  9. package/repo-agent-governance/skills/cabloy-domain-planning/SKILL.md +5 -3
  10. package/repo-agent-governance/skills/cabloy-spec-execution/SKILL.md +13 -8
  11. package/repo-agent-governance/skills/cabloy-spec-execution/evals/evals.json +108 -12
  12. package/repo-agent-governance/skills/cabloy-spec-execution/evals/files/scenarios.json +84 -0
  13. package/repo-agent-governance/skills/cabloy-spec-execution/evals/protocol.md +9 -0
  14. package/repo-agent-governance/skills/cabloy-spec-execution/references/execution-protocol.md +21 -5
  15. package/repo-agent-governance/skills/cabloy-spec-execution/references/status-and-evidence.md +5 -3
  16. package/repo-agent-governance/skills/cabloy-spec-generation/SKILL.md +78 -168
  17. package/repo-agent-governance/skills/cabloy-spec-generation/evals/evals.json +165 -17
  18. package/repo-agent-governance/skills/cabloy-spec-generation/evals/files/scenarios.json +114 -0
  19. package/repo-agent-governance/skills/cabloy-spec-generation/evals/protocol.md +44 -0
  20. package/repo-agent-governance/skills/cabloy-spec-generation/references/canonical-spec-input.md +145 -0
  21. package/repo-agent-governance/skills/cabloy-spec-generation/references/repo-aware-discovery.md +55 -57
  22. package/repo-agent-governance/skills/cabloy-spec-generation/references/repo-specs-document-set.md +6 -4
  23. package/repo-agent-governance/skills/cabloy-spec-generation/references/traceability-and-status-rules.md +12 -8
  24. package/repo-agent-governance/tests/governance.test.mjs +10 -1
  25. package/repo-agent-governance/tests/spec-audit.test.mjs +255 -0
  26. package/repo-agent-governance/tools/spec-audit/audit.mjs +427 -0
  27. package/repo-agent-governance/tools/spec-charts/generate-implementation-charts.mjs +97 -408
  28. package/repo-agent-governance/tools/spec-charts/generate-implementation-charts.test.mjs +462 -1
  29. package/repo-agent-governance/tools/spec-charts/spec-parser.mjs +561 -0
  30. package/repo-docs/.vitepress/config.mjs +54 -29
  31. package/repo-docs/ai/playbook-spec-execution.md +10 -4
  32. package/repo-docs/ai/playbook-spec-generation.md +40 -3
  33. package/repo-docs/ai/skills.md +3 -3
  34. package/repo-docs/backend/controller-aop-guide.md +10 -0
  35. package/repo-docs/backend/field-indexes.md +25 -0
  36. package/repo-docs/blogs/cabloy-fullstack-resource-addressing/index.md +1 -1
  37. package/repo-docs/fullstack/contract-loop-playbook.md +1 -1
  38. package/repo-docs/fullstack/development-history.md +24 -0
  39. package/repo-docs/fullstack/introduction.md +25 -156
  40. package/repo-docs/fullstack/quickstart.md +1 -1
  41. package/repo-docs/fullstack/ssr-entry-modes.md +52 -0
  42. package/repo-docs/fullstack/ssr-site-and-flavor-setup.md +3 -1
  43. package/repo-docs/fullstack/tutorial-5-backend-contract-sharing.md +13 -7
  44. package/repo-docs/fullstack/tutorial-6-one-contract-four-uses.md +11 -15
  45. package/repo-docs/fullstack/tutorials-overview.md +2 -2
  46. package/repo-docs/index.md +23 -45
  47. package/repo-docs/public/cabloy.png +0 -0
  48. package/repo-docs/public/cabloy.svg +3 -0
  49. package/repo-docs/public/favicon.svg +3 -0
  50. package/repo-docs/reference/repo-scripts.md +6 -1
  51. package/repo-e2e/specs/home-user-account.spec.ts +311 -207
  52. package/scripts/bootstrapAgentGovernance.mjs +1 -0
  53. package/scripts/upgrade.ts +1 -0
  54. package/vona/packages-cli/cli/package.json +1 -1
  55. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/snippets/2-meta.index.ts +4 -10
  56. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/snippets/2-meta.index.ts +4 -10
  57. package/vona/packages-cli/cli-set-api/package.json +8 -2
  58. package/vona/packages-cli/cli-set-api/src/index.ts +1 -0
  59. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.tools.masterDetail.ts +9 -12
  60. package/vona/packages-cli/cli-set-api/src/lib/mergeMetaIndex.ts +121 -0
  61. package/vona/packages-cli/cli-set-api/test/indexSnippets.test.ts +41 -0
  62. package/vona/packages-cli/cli-set-api/test/mergeMetaIndex.test.ts +78 -0
  63. package/vona/packages-vona/vona/package.json +1 -1
  64. package/vona/pnpm-lock.yaml +6 -6
  65. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/service/sku.ts +25 -4
  66. package/vona/src/suite/a-commerce/modules/commerce-catalog/test/skuUniqueness.test.ts +161 -0
  67. package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/meta.index.ts +21 -20
  68. package/vona/src/suite/a-commerce/modules/commerce-payment/test/paymentIndexes.test.ts +165 -0
  69. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/bean/meta.index.ts +16 -13
  70. package/vona/src/suite/a-commerce/modules/commerce-promotion/test/promotionIndexes.test.ts +82 -0
  71. package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/meta.index.ts +23 -21
  72. package/vona/src/suite/a-commerce/modules/commerce-trade/test/tradeIndexes.test.ts +86 -0
  73. package/vona/src/suite/a-home/modules/home-user/src/.metadata/index.ts +379 -376
  74. package/vona/src/suite/a-home/modules/home-user/src/controller/passportTest.ts +60 -0
  75. package/vona/src/suite/a-home/modules/home-user/test/passportTest.test.ts +164 -1
  76. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  77. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  78. package/vona/src/suite-vendor/a-pay/modules/a-pay/package.json +1 -1
  79. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/meta.index.ts +35 -26
  80. package/vona/src/suite-vendor/a-pay/modules/pay-mock/package.json +1 -1
  81. package/vona/src/suite-vendor/a-pay/modules/pay-paypal/package.json +1 -1
  82. package/vona/src/suite-vendor/a-pay/modules/pay-stripe/package.json +1 -1
  83. package/vona/src/suite-vendor/a-pay/package.json +5 -5
  84. package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
  85. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/transactionFiber_.ts +6 -2
  86. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/transaction_.ts +4 -1
  87. package/vona/src/suite-vendor/a-vona/modules/a-ormutils/package.json +1 -1
  88. package/vona/src/suite-vendor/a-vona/modules/a-ormutils/src/lib/columns.ts +3 -1
  89. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  90. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  91. /package/repo-docs/{.vitepress/public → public}/CNAME +0 -0
@@ -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 __snippet_update = "...$tableColumns('<%=argv.moduleResourceName%>', 'name'),";
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
- // update
39
- const code = await cli.template.renderContent({ content: __snippet_update });
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,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 __snippet_update = "...$tableColumns('<%=argv.moduleResourceName%>', 'name'),";
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
- // update
39
- const code = await cli.template.renderContent({ content: __snippet_update });
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.154",
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
  }
@@ -1,3 +1,4 @@
1
1
  export * from './lib/beans.ts';
2
2
  export * from './lib/commands.ts';
3
3
  export * from './lib/utils.ts';
4
+ export { mergeMetaIndex } from './lib/mergeMetaIndex.ts';
@@ -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
- await this._patchFileByMarker({
183
- fileName: this._detailPaths().metaIndex,
184
- existsNeedle: `'${argv.fk}'`,
185
- marker: ' indexes: {\n',
186
- line: this._detailMetaIndexLine(),
187
- shapeName: 'detail meta.index',
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.102",
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": [
@@ -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.7
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.8
1959
+ specifier: ^5.0.10
1960
1960
  version: link:modules/a-pay
1961
1961
  vona-module-pay-mock:
1962
- specifier: ^5.0.8
1962
+ specifier: ^5.0.10
1963
1963
  version: link:modules/pay-mock
1964
1964
  vona-module-pay-paypal:
1965
- specifier: ^5.0.8
1965
+ specifier: ^5.0.10
1966
1966
  version: link:modules/pay-paypal
1967
1967
  vona-module-pay-stripe:
1968
- specifier: ^5.0.8
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.7
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({ code });
100
- if (sku && String(sku.id) !== String(excludedId)) {
101
- this.app.throw(409, `SKU code already exists: ${code}`);
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
+ });