Compare commits
4 Commits
main
...
v1.0.1-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e7b2ec0ba | |||
| cfc82231b3 | |||
| 3ff729cbc6 | |||
| 1206b5fd29 |
8
.cool-saas-candidate.json
Normal file
8
.cool-saas-candidate.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"moduleCode": "order",
|
||||||
|
"candidateVersion": "1.0.1-rc.2",
|
||||||
|
"buildRevision": 2,
|
||||||
|
"buildChecksum": "e71d92cc440437054c77fef0dbee098c6ceb8700d9a7d828dcad86420f230f22",
|
||||||
|
"schemaInputChecksum": "34ed49d330f45bd9933b582bc9265cf0f4e2e2683833d0f39e38a357d138ab65",
|
||||||
|
"branchName": "release/1.0.1-rc.2"
|
||||||
|
}
|
||||||
5
bootstrap.js
vendored
5
bootstrap.js
vendored
@@ -11,10 +11,11 @@ function loadGeneratedSaasModule() {
|
|||||||
return require('./dist/saas/generated').GeneratedSaasModule;
|
return require('./dist/saas/generated').GeneratedSaasModule;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function start() {
|
async function start(options = {}) {
|
||||||
await prepareSaasServiceHost({
|
const session = await prepareSaasServiceHost({
|
||||||
generated: loadGeneratedSaasModule(),
|
generated: loadGeneratedSaasModule(),
|
||||||
});
|
});
|
||||||
|
if (options.verifySession) await options.verifySession(session);
|
||||||
requireExactReleaseEnvironment();
|
requireExactReleaseEnvironment();
|
||||||
return Bootstrap.configure({
|
return Bootstrap.configure({
|
||||||
baseDir: path.join(__dirname, 'dist'),
|
baseDir: path.join(__dirname, 'dist'),
|
||||||
|
|||||||
@@ -10,13 +10,16 @@
|
|||||||
"build": "rimraf dist && mwtsc --cleanOutDir",
|
"build": "rimraf dist && mwtsc --cleanOutDir",
|
||||||
"publish": "cool saas-model finalize",
|
"publish": "cool saas-model finalize",
|
||||||
"start": "node bootstrap.js",
|
"start": "node bootstrap.js",
|
||||||
|
"start:stable": "node start-stable.js",
|
||||||
|
"test:stable": "node --test test/start-stable.test.cjs",
|
||||||
"start:local": "cross-env NODE_ENV=local SAAS_RUNTIME_BOOTSTRAP_LOCAL_FALLBACK_ENABLED=true node bootstrap.js"
|
"start:local": "cross-env NODE_ENV=local SAAS_RUNTIME_BOOTSTRAP_LOCAL_FALLBACK_ENABLED=true node bootstrap.js"
|
||||||
},
|
},
|
||||||
"coolSaas": {
|
"coolSaas": {
|
||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"dev": "pnpm run build && pnpm run start:local",
|
"dev": "pnpm run build && pnpm run start:local",
|
||||||
"test": "pnpm run typecheck",
|
"test": "pnpm run typecheck && pnpm run test:stable",
|
||||||
"build": "pnpm run build"
|
"build": "pnpm run build",
|
||||||
|
"artifactDir": "dist"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -25,6 +28,7 @@
|
|||||||
"@midwayjs/bootstrap": "^3.20.3",
|
"@midwayjs/bootstrap": "^3.20.3",
|
||||||
"@midwayjs/core": "^3.20.3",
|
"@midwayjs/core": "^3.20.3",
|
||||||
"@midwayjs/koa": "^3.20.3",
|
"@midwayjs/koa": "^3.20.3",
|
||||||
|
"@midwayjs/logger": "^3.4.2",
|
||||||
"@midwayjs/validate": "^3.20.3",
|
"@midwayjs/validate": "^3.20.3",
|
||||||
"mysql2": "^3.12.0",
|
"mysql2": "^3.12.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
|||||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@@ -23,6 +23,9 @@ importers:
|
|||||||
'@midwayjs/koa':
|
'@midwayjs/koa':
|
||||||
specifier: ^3.20.3
|
specifier: ^3.20.3
|
||||||
version: 3.20.24
|
version: 3.20.24
|
||||||
|
'@midwayjs/logger':
|
||||||
|
specifier: ^3.4.2
|
||||||
|
version: 3.4.2
|
||||||
'@midwayjs/validate':
|
'@midwayjs/validate':
|
||||||
specifier: ^3.20.3
|
specifier: ^3.20.3
|
||||||
version: 3.20.24
|
version: 3.20.24
|
||||||
@@ -221,6 +224,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-vmfsVCRPxnVrG9p3I/ZKSWpucKRfbnBoGVzelk3UQuL3Fh7FbfwP6lsbkecGT6FSfGuZmD4hPU84gIACyKX9jw==}
|
resolution: {integrity: sha512-vmfsVCRPxnVrG9p3I/ZKSWpucKRfbnBoGVzelk3UQuL3Fh7FbfwP6lsbkecGT6FSfGuZmD4hPU84gIACyKX9jw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
'@midwayjs/logger@3.4.2':
|
||||||
|
resolution: {integrity: sha512-BxSdSMog4jxeqpHwgauuKZT6iAiu/Vr1HDjO9l81iqU+l8c9veAJVeLT08nTqKdjUiQXnxTaW6nYlvnzF2HdMA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
'@midwayjs/session@3.20.24':
|
'@midwayjs/session@3.20.24':
|
||||||
resolution: {integrity: sha512-1zGRWhaa1N+73o/nQ17/MGNFS+CxgcgkdFHcT7LokSWsgpUAKrLrQoTc/dzLP9yB3sGAMP9cLsgn/SR60s3dYw==}
|
resolution: {integrity: sha512-1zGRWhaa1N+73o/nQ17/MGNFS+CxgcgkdFHcT7LokSWsgpUAKrLrQoTc/dzLP9yB3sGAMP9cLsgn/SR60s3dYw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2020,6 +2027,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
|
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
safe-stable-stringify@2.5.0:
|
||||||
|
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
safer-buffer@2.1.2:
|
safer-buffer@2.1.2:
|
||||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||||
|
|
||||||
@@ -2648,6 +2659,11 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@midwayjs/logger@3.4.2':
|
||||||
|
dependencies:
|
||||||
|
dayjs: 1.11.23
|
||||||
|
safe-stable-stringify: 2.5.0
|
||||||
|
|
||||||
'@midwayjs/session@3.20.24':
|
'@midwayjs/session@3.20.24':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@midwayjs/cookies': 1.3.0
|
'@midwayjs/cookies': 1.3.0
|
||||||
@@ -4538,6 +4554,8 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
is-regex: 1.2.1
|
is-regex: 1.2.1
|
||||||
|
|
||||||
|
safe-stable-stringify@2.5.0: {}
|
||||||
|
|
||||||
safer-buffer@2.1.2: {}
|
safer-buffer@2.1.2: {}
|
||||||
|
|
||||||
sax@1.6.1: {}
|
sax@1.6.1: {}
|
||||||
|
|||||||
1
src/saas/generated/index.ts
Normal file
1
src/saas/generated/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './order';
|
||||||
1176
src/saas/generated/order/contracts.ts
Normal file
1176
src/saas/generated/order/contracts.ts
Normal file
File diff suppressed because it is too large
Load Diff
5
src/saas/generated/order/index.ts
Normal file
5
src/saas/generated/order/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './models';
|
||||||
|
export * from './contracts';
|
||||||
|
export * from './saas-actions';
|
||||||
|
export * from './saas-events';
|
||||||
|
export * from './module';
|
||||||
100
src/saas/generated/order/models.ts
Normal file
100
src/saas/generated/order/models.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
// Generated by cool saas-model generate. DO NOT EDIT.
|
||||||
|
import {
|
||||||
|
SaasReadModel,
|
||||||
|
SaasTableModel,
|
||||||
|
defineSaasReadModel,
|
||||||
|
defineSaasTableModel
|
||||||
|
} from '@cool-midway/module-runtime';
|
||||||
|
|
||||||
|
export interface OrdersRow extends Record<string, unknown> {
|
||||||
|
id: string;
|
||||||
|
order_no: string;
|
||||||
|
goods_id: string;
|
||||||
|
quantity: number;
|
||||||
|
unit_price: string;
|
||||||
|
amount: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Orders extends SaasTableModel<OrdersRow> {
|
||||||
|
static readonly definition = defineSaasTableModel({
|
||||||
|
"kind": "table",
|
||||||
|
"moduleCode": "order",
|
||||||
|
"revision": 2,
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"schemaInputChecksum": "34ed49d330f45bd9933b582bc9265cf0f4e2e2683833d0f39e38a357d138ab65",
|
||||||
|
"tableCode": "orders",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"code": "id",
|
||||||
|
"type": "bigint",
|
||||||
|
"nullable": false,
|
||||||
|
"primary": true,
|
||||||
|
"readable": true,
|
||||||
|
"writable": false,
|
||||||
|
"filterable": true,
|
||||||
|
"sortable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "order_no",
|
||||||
|
"type": "varchar",
|
||||||
|
"nullable": false,
|
||||||
|
"primary": false,
|
||||||
|
"readable": true,
|
||||||
|
"writable": true,
|
||||||
|
"filterable": true,
|
||||||
|
"sortable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "goods_id",
|
||||||
|
"type": "bigint",
|
||||||
|
"nullable": false,
|
||||||
|
"primary": false,
|
||||||
|
"readable": true,
|
||||||
|
"writable": true,
|
||||||
|
"filterable": true,
|
||||||
|
"sortable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "quantity",
|
||||||
|
"type": "int",
|
||||||
|
"nullable": false,
|
||||||
|
"primary": false,
|
||||||
|
"readable": true,
|
||||||
|
"writable": true,
|
||||||
|
"filterable": true,
|
||||||
|
"sortable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unit_price",
|
||||||
|
"type": "decimal",
|
||||||
|
"nullable": false,
|
||||||
|
"primary": false,
|
||||||
|
"readable": true,
|
||||||
|
"writable": true,
|
||||||
|
"filterable": true,
|
||||||
|
"sortable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "amount",
|
||||||
|
"type": "decimal",
|
||||||
|
"nullable": false,
|
||||||
|
"primary": false,
|
||||||
|
"readable": true,
|
||||||
|
"writable": true,
|
||||||
|
"filterable": true,
|
||||||
|
"sortable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "status",
|
||||||
|
"type": "varchar",
|
||||||
|
"nullable": false,
|
||||||
|
"primary": false,
|
||||||
|
"readable": true,
|
||||||
|
"writable": true,
|
||||||
|
"filterable": true,
|
||||||
|
"sortable": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
12
src/saas/generated/order/module.ts
Normal file
12
src/saas/generated/order/module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// Generated by cool saas-model generate. DO NOT EDIT.
|
||||||
|
import { defineGeneratedSaasModule } from '@cool-midway/module-runtime';
|
||||||
|
|
||||||
|
export const GeneratedSaasModule = defineGeneratedSaasModule({
|
||||||
|
"formatVersion": 2,
|
||||||
|
"moduleCode": "order",
|
||||||
|
"revision": 2,
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"schemaInputChecksum": "34ed49d330f45bd9933b582bc9265cf0f4e2e2683833d0f39e38a357d138ab65",
|
||||||
|
"runtimeContractChecksum": "ba026a37cab981d8bdc1ed59db977b7c10df5f0528eaed02202e1303dc3d1194",
|
||||||
|
"implementationManifestChecksum": "f25647d2dd90e14d6226683d2ff67ba32132b2dfdeb861015805240a96d4635b"
|
||||||
|
});
|
||||||
218
src/saas/generated/order/saas-actions.ts
Normal file
218
src/saas/generated/order/saas-actions.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
// Generated by cool saas-model generate. DO NOT EDIT.
|
||||||
|
import { defineInjectedSaasAction } from '@cool-midway/module-runtime';
|
||||||
|
|
||||||
|
export type DemoGoodsBatchInfoInput = {
|
||||||
|
ids: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DemoGoodsBatchInfoOutput = {
|
||||||
|
list: Record<string, unknown>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DemoGoodsReleaseStockInput = {
|
||||||
|
goodsId: number;
|
||||||
|
orderNo: string;
|
||||||
|
quantity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DemoGoodsReleaseStockOutput = {
|
||||||
|
currentStock: number;
|
||||||
|
goodsId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DemoGoodsReserveStockInput = {
|
||||||
|
goodsId: number;
|
||||||
|
quantity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DemoGoodsReserveStockOutput = {
|
||||||
|
goodsId: number;
|
||||||
|
remainingStock: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SaasActions = {
|
||||||
|
Demo: {
|
||||||
|
GoodsBatchInfo: defineInjectedSaasAction<DemoGoodsBatchInfoInput, DemoGoodsBatchInfoOutput>({
|
||||||
|
"kind": "action",
|
||||||
|
"name": "goods.batchInfo",
|
||||||
|
"access": "read",
|
||||||
|
"actionName": "goods.batchInfo",
|
||||||
|
"idempotent": false,
|
||||||
|
"moduleCode": "demo",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"ids"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"minItems": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"callerPolicy": {
|
||||||
|
"kind": "module",
|
||||||
|
"allowedModules": [
|
||||||
|
"order"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"contractKind": "saas.action.import",
|
||||||
|
"outputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"list"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"list": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"inputChecksum": "b2e0c5fd68adc75e2687c6a1ce01b8270240b42df6511efd6d922ea3242bc264",
|
||||||
|
"actionChecksum": "3978a7ba9a3e0f91842031fd323ec63b3816eef68cb8dc82ffad6d187de8b7ea",
|
||||||
|
"outputChecksum": "fe644424ef6a9e462b56edc9f70f48bb218783c69469469d4310de48589a0df5",
|
||||||
|
"contractVersion": 1,
|
||||||
|
"providerReleaseId": 41,
|
||||||
|
"consumerModuleCode": "order",
|
||||||
|
"providerModuleCode": "demo",
|
||||||
|
"providerReleaseVersion": "1.0.1"
|
||||||
|
}),
|
||||||
|
GoodsReleaseStock: defineInjectedSaasAction<DemoGoodsReleaseStockInput, DemoGoodsReleaseStockOutput>({
|
||||||
|
"kind": "action",
|
||||||
|
"name": "goods.releaseStock",
|
||||||
|
"access": "write",
|
||||||
|
"actionName": "goods.releaseStock",
|
||||||
|
"idempotent": true,
|
||||||
|
"moduleCode": "demo",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"goodsId",
|
||||||
|
"quantity",
|
||||||
|
"orderNo"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"goodsId": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"orderNo": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 64,
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"quantity": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"callerPolicy": {
|
||||||
|
"kind": "module",
|
||||||
|
"allowedModules": [
|
||||||
|
"order"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"contractKind": "saas.action.import",
|
||||||
|
"outputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"goodsId",
|
||||||
|
"currentStock"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"goodsId": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"currentStock": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"inputChecksum": "c363d259b5108d069650a2554309eb3eb165d98723596595958e00acf8410abe",
|
||||||
|
"actionChecksum": "63423abe8557075caf668a91795f95a45195759a6b82e755d8af51ddfc71996e",
|
||||||
|
"outputChecksum": "a632b9f89518c3c17861454b74173a4880d5e8482639d3ff7f761aee4d061bd9",
|
||||||
|
"contractVersion": 1,
|
||||||
|
"commandEventType": "saas.workflow.demo.goods.releaseStock",
|
||||||
|
"providerReleaseId": 41,
|
||||||
|
"consumerModuleCode": "order",
|
||||||
|
"providerModuleCode": "demo",
|
||||||
|
"providerReleaseVersion": "1.0.1",
|
||||||
|
"commandEventContractChecksum": "18a727b91cf9410c3b7ac14e3a8799dbbfa8937f84667bfe7e31e21e662396be"
|
||||||
|
}),
|
||||||
|
GoodsReserveStock: defineInjectedSaasAction<DemoGoodsReserveStockInput, DemoGoodsReserveStockOutput>({
|
||||||
|
"kind": "action",
|
||||||
|
"name": "goods.reserveStock",
|
||||||
|
"access": "write",
|
||||||
|
"actionName": "goods.reserveStock",
|
||||||
|
"idempotent": true,
|
||||||
|
"moduleCode": "demo",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"goodsId",
|
||||||
|
"quantity"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"goodsId": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"quantity": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"callerPolicy": {
|
||||||
|
"kind": "module",
|
||||||
|
"allowedModules": [
|
||||||
|
"order"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"contractKind": "saas.action.import",
|
||||||
|
"outputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"goodsId",
|
||||||
|
"remainingStock"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"goodsId": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"remainingStock": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"inputChecksum": "6fbfa4d2324c4c0bdc621cbfd256353284e27495f59f47231b7d897b82a4e7b6",
|
||||||
|
"actionChecksum": "773465c93790e8df9119149e1efb4c4d8d2510298a24af7aa825662540ff37d5",
|
||||||
|
"outputChecksum": "545bd8b0bf6eb8670b21497236038187ee0f1df1544a318b16a399458fcb796e",
|
||||||
|
"contractVersion": 1,
|
||||||
|
"commandEventType": "saas.workflow.demo.goods.reserveStock",
|
||||||
|
"providerReleaseId": 41,
|
||||||
|
"consumerModuleCode": "order",
|
||||||
|
"providerModuleCode": "demo",
|
||||||
|
"providerReleaseVersion": "1.0.1",
|
||||||
|
"commandEventContractChecksum": "ffbecad3a3d63b8bf92fde701dfaa11c8502a2c5f9f6bbb89b755e214024c3ae"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
25
src/saas/generated/order/saas-events.ts
Normal file
25
src/saas/generated/order/saas-events.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// Generated by cool saas-model generate. DO NOT EDIT.
|
||||||
|
import { defineSaasEventHandler } from '@cool-midway/module-runtime';
|
||||||
|
import type { SaasEventEnvelope } from '@cool-midway/module-runtime';
|
||||||
|
|
||||||
|
export type DemoGoodsStockReservedV1Payload = {
|
||||||
|
goodsId: number;
|
||||||
|
quantity: number;
|
||||||
|
remainingStock: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DemoGoodsStockReservedV1Event = SaasEventEnvelope<DemoGoodsStockReservedV1Payload>;
|
||||||
|
|
||||||
|
export const SaasEvents = {
|
||||||
|
Demo: {
|
||||||
|
GoodsStockReservedV1: defineSaasEventHandler({
|
||||||
|
"kind": "event_handler",
|
||||||
|
"eventType": "demo.goods.stockReserved",
|
||||||
|
"eventVersion": 1,
|
||||||
|
"eventContractChecksum": "4d8a47d64f7d9fa43e0253228d05a2bb28db7efe3181e96d58dbff2163b9b752",
|
||||||
|
"sourceModule": "demo",
|
||||||
|
"tables": [],
|
||||||
|
"maxAttempts": 10
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
1022
src/saas/generated/order/saas-implementation.expected.json
Normal file
1022
src/saas/generated/order/saas-implementation.expected.json
Normal file
File diff suppressed because it is too large
Load Diff
2390
src/saas/generated/order/saas-schema.lock.json
Normal file
2390
src/saas/generated/order/saas-schema.lock.json
Normal file
File diff suppressed because it is too large
Load Diff
195
src/service/orders.ts
Normal file
195
src/service/orders.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { Provide } from '@midwayjs/core';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
InjectSaasAction,
|
||||||
|
InjectSaasCrud,
|
||||||
|
ModuleRuntimeError,
|
||||||
|
SaasAction,
|
||||||
|
type SaasActionCaller,
|
||||||
|
type SaasCrudCaller,
|
||||||
|
SaasCrudService,
|
||||||
|
SaasCrudServiceBase,
|
||||||
|
} from '@cool-midway/module-runtime';
|
||||||
|
import {
|
||||||
|
Actions,
|
||||||
|
Crud,
|
||||||
|
SaasActions,
|
||||||
|
type OrdersCancelOrderInput,
|
||||||
|
type OrdersCancelOrderOutput,
|
||||||
|
type OrdersCreateOrderInput,
|
||||||
|
type OrdersCreateOrderOutput,
|
||||||
|
type OrdersDetailWithGoodsInput,
|
||||||
|
type OrdersDetailWithGoodsOutput,
|
||||||
|
} from '../saas/generated/order';
|
||||||
|
|
||||||
|
type GoodsRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
@Provide()
|
||||||
|
@SaasCrudService(Crud.Orders)
|
||||||
|
export class OrdersService extends SaasCrudServiceBase<typeof Crud.Orders> {
|
||||||
|
@InjectSaasCrud(Crud.Orders)
|
||||||
|
private readonly orders!: SaasCrudCaller<typeof Crud.Orders>;
|
||||||
|
|
||||||
|
@InjectSaasAction(SaasActions.Demo.GoodsBatchInfo)
|
||||||
|
private readonly batchInfo!: SaasActionCaller<
|
||||||
|
typeof SaasActions.Demo.GoodsBatchInfo
|
||||||
|
>;
|
||||||
|
|
||||||
|
@InjectSaasAction(SaasActions.Demo.GoodsReserveStock)
|
||||||
|
private readonly reserveStock!: SaasActionCaller<
|
||||||
|
typeof SaasActions.Demo.GoodsReserveStock
|
||||||
|
>;
|
||||||
|
|
||||||
|
@InjectSaasAction(SaasActions.Demo.GoodsReleaseStock)
|
||||||
|
private readonly releaseStock!: SaasActionCaller<
|
||||||
|
typeof SaasActions.Demo.GoodsReleaseStock
|
||||||
|
>;
|
||||||
|
|
||||||
|
@SaasAction(Actions.OrdersCreateOrder)
|
||||||
|
async createOrder(
|
||||||
|
input: OrdersCreateOrderInput
|
||||||
|
): Promise<OrdersCreateOrderOutput> {
|
||||||
|
const goods = await this.requireGoods(input.goodsId);
|
||||||
|
if (integer(goods.status, '商品状态') !== 1) {
|
||||||
|
throw conflict('商品未上架,不能下单');
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitPrice = decimal(goods.price, '商品价格');
|
||||||
|
const amount = multiplyDecimal(unitPrice, input.quantity);
|
||||||
|
|
||||||
|
// Complete the remote write before the local eventized CRUD acquires the
|
||||||
|
// tenant-wide outbox sequence lock. Holding that lock across a synchronous
|
||||||
|
// module call would make the provider wait for this transaction itself.
|
||||||
|
await this.reserveStock(
|
||||||
|
{
|
||||||
|
goodsId: input.goodsId,
|
||||||
|
quantity: input.quantity,
|
||||||
|
},
|
||||||
|
{ idempotencyKey: businessKey('reserve', input.orderNo) }
|
||||||
|
);
|
||||||
|
|
||||||
|
const order = await this.orders.add({
|
||||||
|
order_no: input.orderNo,
|
||||||
|
goods_id: String(input.goodsId),
|
||||||
|
quantity: input.quantity,
|
||||||
|
unit_price: unitPrice,
|
||||||
|
amount,
|
||||||
|
status: 'created',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: safeInteger(order.id, '订单 ID'),
|
||||||
|
orderNo: order.order_no,
|
||||||
|
amount: finiteNumber(order.amount, '订单金额'),
|
||||||
|
status: order.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@SaasAction(Actions.OrdersDetailWithGoods)
|
||||||
|
async detailWithGoods(
|
||||||
|
input: OrdersDetailWithGoodsInput
|
||||||
|
): Promise<OrdersDetailWithGoodsOutput> {
|
||||||
|
const order = await this.requireOrder(input.id);
|
||||||
|
const goods = await this.requireGoods(safeInteger(order.goods_id, '商品 ID'));
|
||||||
|
return { order, goods };
|
||||||
|
}
|
||||||
|
|
||||||
|
@SaasAction(Actions.OrdersCancelOrder)
|
||||||
|
async cancelOrder(
|
||||||
|
input: OrdersCancelOrderInput
|
||||||
|
): Promise<OrdersCancelOrderOutput> {
|
||||||
|
const order = await this.requireOrder(input.id);
|
||||||
|
if (order.status === 'cancelled') {
|
||||||
|
return { id: input.id, status: 'cancelled', restoredStock: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const goodsId = safeInteger(order.goods_id, '商品 ID');
|
||||||
|
await this.releaseStock(
|
||||||
|
{
|
||||||
|
goodsId,
|
||||||
|
quantity: order.quantity,
|
||||||
|
orderNo: order.order_no,
|
||||||
|
},
|
||||||
|
{ idempotencyKey: businessKey('cancel', String(order.id)) }
|
||||||
|
);
|
||||||
|
|
||||||
|
const update = await this.orders.update({
|
||||||
|
id: order.id,
|
||||||
|
status: 'cancelled',
|
||||||
|
});
|
||||||
|
if (update.affected === 0) {
|
||||||
|
return { id: input.id, status: 'cancelled', restoredStock: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: input.id,
|
||||||
|
status: 'cancelled',
|
||||||
|
restoredStock: order.quantity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireOrder(id: number) {
|
||||||
|
const order = await this.orders.info({ id: String(id) });
|
||||||
|
if (!order) throw conflict(`订单不存在: ${id}`);
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireGoods(goodsId: number): Promise<GoodsRecord> {
|
||||||
|
const result = await this.batchInfo({ ids: [goodsId] });
|
||||||
|
const goods = result.list.find(item => String(item.id) === String(goodsId));
|
||||||
|
if (!goods) throw conflict(`商品不存在: ${goodsId}`);
|
||||||
|
return goods;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function businessKey(operation: string, identity: string): string {
|
||||||
|
return createHash('sha256')
|
||||||
|
.update(`${operation}:${identity}`, 'utf8')
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function decimal(value: unknown, label: string): string {
|
||||||
|
const normalized = String(value ?? '').trim();
|
||||||
|
if (!/^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/.test(normalized)) {
|
||||||
|
throw conflict(`${label}非法`);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function multiplyDecimal(value: string, multiplier: number): string {
|
||||||
|
const negative = value.startsWith('-');
|
||||||
|
const unsigned = negative ? value.slice(1) : value;
|
||||||
|
const [integerPart, fractionPart = ''] = unsigned.split('.');
|
||||||
|
const scale = fractionPart.length;
|
||||||
|
const digits = BigInt(`${integerPart}${fractionPart}`);
|
||||||
|
const product = digits * BigInt(multiplier);
|
||||||
|
const padded = product.toString().padStart(scale + 1, '0');
|
||||||
|
const absolute = scale
|
||||||
|
? `${padded.slice(0, -scale)}.${padded.slice(-scale)}`
|
||||||
|
: padded;
|
||||||
|
return negative && product !== BigInt(0) ? `-${absolute}` : absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
function integer(value: unknown, label: string): number {
|
||||||
|
const normalized = Number(value);
|
||||||
|
if (!Number.isInteger(normalized)) throw conflict(`${label}非法`);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeInteger(value: unknown, label: string): number {
|
||||||
|
const normalized = integer(value, label);
|
||||||
|
if (!Number.isSafeInteger(normalized) || normalized < 1) {
|
||||||
|
throw conflict(`${label}超出安全范围`);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteNumber(value: unknown, label: string): number {
|
||||||
|
const normalized = Number(value);
|
||||||
|
if (!Number.isFinite(normalized)) throw conflict(`${label}非法`);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function conflict(message: string) {
|
||||||
|
return new ModuleRuntimeError('MODULE_ACTION_INPUT_INVALID', message, 409);
|
||||||
|
}
|
||||||
186
start-stable.js
Normal file
186
start-stable.js
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Starts an already-built, frozen directory. There is intentionally no build,
|
||||||
|
// codegen, Git checkout, publish, or fallback-to-RC operation in this entrypoint.
|
||||||
|
const { createHash } = require('node:crypto');
|
||||||
|
const { readdirSync, lstatSync, readFileSync } = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { spawnSync } = require('node:child_process');
|
||||||
|
|
||||||
|
const SHA256 = /^[a-f0-9]{64}$/;
|
||||||
|
const COMMIT = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
||||||
|
const STABLE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
||||||
|
|
||||||
|
function ensure(condition, message) {
|
||||||
|
if (!condition) throw new Error(`Stable startup refused: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalJson(value) {
|
||||||
|
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
||||||
|
if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidateLockChecksum(lock) {
|
||||||
|
ensure(Object.keys(lock || {}).sort().join(',') === 'branchName,buildChecksum,buildRevision,candidateVersion,moduleCode,schemaInputChecksum', 'invalid Candidate lock fields');
|
||||||
|
return createHash('sha256').update(canonicalJson(lock), 'utf8').digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateCompiledArtifactDigest(projectDir) {
|
||||||
|
const root = path.resolve(projectDir, 'dist');
|
||||||
|
ensure(lstatSync(root, { throwIfNoEntry: false })?.isDirectory(), 'dist does not exist; deploy the frozen build, do not rebuild during startup');
|
||||||
|
const files = [];
|
||||||
|
function visit(directory) {
|
||||||
|
for (const name of readdirSync(directory)) {
|
||||||
|
const file = path.join(directory, name);
|
||||||
|
const stat = lstatSync(file);
|
||||||
|
ensure(!stat.isSymbolicLink(), 'compiled artifact must not contain symbolic links');
|
||||||
|
if (stat.isDirectory()) visit(file);
|
||||||
|
else {
|
||||||
|
ensure(stat.isFile(), 'compiled artifact contains a non-file entry');
|
||||||
|
files.push([path.relative(root, file).replace(/\\/g, '/'), file]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(root);
|
||||||
|
ensure(files.length > 0, 'compiled artifact is empty');
|
||||||
|
// Exact algorithm used by cool saas-model finalize with artifactDir: dist.
|
||||||
|
const hash = createHash('sha256').update('COOL_SAAS_ARTIFACT_DIRECTORY_V1\0', 'utf8');
|
||||||
|
for (const [name, file] of files.sort(([left], [right]) => left.localeCompare(right))) {
|
||||||
|
const content = readFileSync(file);
|
||||||
|
hash.update(`${Buffer.byteLength(name, 'utf8')}:`, 'utf8');
|
||||||
|
hash.update(name, 'utf8');
|
||||||
|
hash.update(`:${content.length}:`, 'utf8');
|
||||||
|
hash.update(content);
|
||||||
|
}
|
||||||
|
return `sha256:${hash.digest('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceCommit(projectDir, environment) {
|
||||||
|
const gitOptions = { cwd: projectDir, encoding: 'utf8', windowsHide: true, timeout: 30_000 };
|
||||||
|
const root = spawnSync('git', ['rev-parse', '--show-toplevel'], gitOptions);
|
||||||
|
if (root.status === 0) {
|
||||||
|
ensure(path.resolve(root.stdout.trim()) === path.resolve(projectDir), 'project must be its own exact Git checkout');
|
||||||
|
const status = spawnSync('git', ['status', '--porcelain=v1', '--untracked-files=all'], gitOptions);
|
||||||
|
ensure(status.status === 0 && !status.stdout.trim(), 'Git checkout is dirty');
|
||||||
|
const commit = spawnSync('git', ['rev-parse', 'HEAD'], gitOptions);
|
||||||
|
ensure(commit.status === 0 && COMMIT.test(commit.stdout.trim()), 'Git HEAD is unavailable');
|
||||||
|
ensure(!environment.BUILD_SHA || environment.BUILD_SHA === commit.stdout.trim(), 'BUILD_SHA differs from Git HEAD');
|
||||||
|
return commit.stdout.trim();
|
||||||
|
}
|
||||||
|
ensure(!lstatSync(path.join(projectDir, '.git'), { throwIfNoEntry: false }), 'Git metadata exists but could not be verified');
|
||||||
|
// Git-free deployment archives must carry the trusted orchestrator's commit.
|
||||||
|
// This is cross-checked against both the authoritative Frozen Artifact and
|
||||||
|
// its actual compiled bytes, never accepted as a digest substitute.
|
||||||
|
ensure(COMMIT.test(environment.BUILD_SHA || ''), 'Git-free deployments require an exact BUILD_SHA');
|
||||||
|
return environment.BUILD_SHA;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchReleaseEvidence(environment, fetcher = fetch) {
|
||||||
|
const releaseId = Number(environment.RPC_RELEASE_ID);
|
||||||
|
ensure(Number.isSafeInteger(releaseId) && releaseId > 0, 'RPC_RELEASE_ID must select a Stable release');
|
||||||
|
const base = new URL(environment.SAAS_CONTROL_PLANE_URL || environment.COOL_SAAS_CONTROL_PLANE_URL || 'http://127.0.0.1:8080');
|
||||||
|
const local = ['local', 'test'].includes(environment.NODE_ENV);
|
||||||
|
ensure(base.protocol === 'https:' || (local && base.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(base.hostname)), 'use HTTPS (loopback HTTP is local/test only)');
|
||||||
|
ensure(!base.username && !base.password, 'control-plane URL must not contain credentials');
|
||||||
|
ensure(String(environment.COOL_SAAS_TOKEN || '').trim(), 'a short-lived CLI/CI Bearer credential is required to verify immutable release evidence');
|
||||||
|
const url = new URL('/admin/saas/module_release/info', base);
|
||||||
|
url.searchParams.set('id', String(releaseId));
|
||||||
|
const response = await fetcher(url, {
|
||||||
|
method: 'GET', headers: { Authorization: `Bearer ${environment.COOL_SAAS_TOKEN}` },
|
||||||
|
redirect: 'error', signal: AbortSignal.timeout(10_000)
|
||||||
|
});
|
||||||
|
ensure(response.ok, `control plane rejected release evidence (${response.status})`);
|
||||||
|
const body = await response.json();
|
||||||
|
ensure(body?.code === 1000 && body.data, 'control plane did not return release evidence');
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyStableEvidence({ release, lock, generated, digest, commit, releaseId }) {
|
||||||
|
const build = release?.build;
|
||||||
|
const artifact = release?.frozenArtifact;
|
||||||
|
ensure(release?.id === Number(releaseId) && release.releaseType === 'stable' && release.status === 'published' && release.lifecycleVersion === 1, 'target is not a published lifecycle Stable release');
|
||||||
|
ensure(STABLE.test(release.version || '') && release.serviceVersion === release.version, 'Stable version identity is invalid');
|
||||||
|
ensure(build && artifact && Number(release.promotedFromReleaseId) === Number(artifact.rcReleaseId) && Number(release.frozenArtifactId) === Number(artifact.id), 'Stable does not reference its source RC frozen artifact');
|
||||||
|
ensure(Number(build.id) === Number(release.buildSnapshotId) && Number(build.moduleId) === Number(release.moduleId) && Number(artifact.moduleId) === Number(release.moduleId), 'Build/module/artifact association differs');
|
||||||
|
ensure(Number(build.revision) === Number(lock.buildRevision) && Number(release.buildRevision) === Number(lock.buildRevision) && Number(artifact.buildRevision) === Number(lock.buildRevision), 'Build Revision differs from the Candidate lock');
|
||||||
|
ensure(SHA256.test(build.buildChecksum || '') && build.buildChecksum === lock.buildChecksum && build.schemaInputChecksum === lock.schemaInputChecksum, 'Build checksums differ from the Candidate lock');
|
||||||
|
ensure(lock.candidateVersion === `${release.version}-rc.${lock.buildRevision}` && lock.branchName === `release/${lock.candidateVersion}` && artifact.sourceBranch === lock.branchName && artifact.tagName === `v${lock.candidateVersion}`, 'source Candidate branch/version/tag differs');
|
||||||
|
ensure(candidateLockChecksum(lock) === artifact.generatedLockChecksum, 'generated Candidate lock checksum differs');
|
||||||
|
ensure(COMMIT.test(artifact.sourceCommit || '') && artifact.sourceCommit === commit, 'source commit differs from the Frozen Artifact');
|
||||||
|
ensure(/^sha256:[a-f0-9]{64}$/.test(artifact.artifactDigest || ''), 'Frozen Artifact has no compiled digest; old Git-only evidence cannot be upgraded or forged here');
|
||||||
|
ensure(artifact.artifactDigest === digest, 'compiled artifact digest differs from the Frozen Artifact');
|
||||||
|
ensure(SHA256.test(artifact.artifactIdentityChecksum || '') && SHA256.test(artifact.implementationManifestChecksum || ''), 'frozen implementation identity is incomplete');
|
||||||
|
ensure(generated.moduleCode === lock.moduleCode && generated.revision === lock.buildRevision && generated.schemaInputChecksum === lock.schemaInputChecksum, 'compiled generated identity differs from the immutable Build');
|
||||||
|
ensure(!generated.releaseVersion || generated.releaseVersion === release.version, 'generated runtime embeds a different Release version');
|
||||||
|
ensure(!generated.implementationManifestChecksum || generated.implementationManifestChecksum === artifact.implementationManifestChecksum, 'compiled implementation checksum differs');
|
||||||
|
const manifest = release.schemaSnapshot?.actionManifest;
|
||||||
|
const contracts = release.schemaSnapshot?.runtimeContracts;
|
||||||
|
ensure(manifest && contracts?.implementationManifestChecksum === artifact.implementationManifestChecksum && manifest.artifactDigest === artifact.artifactDigest, 'Stable release envelope differs from the frozen implementation/artifact');
|
||||||
|
ensure(generated.runtimeContractChecksum === contracts.runtimeContractChecksum && SHA256.test(contracts.runtimeContractChecksum || ''), 'compiled Runtime contract checksum differs');
|
||||||
|
ensure(generated.schemaVersion === release.schemaVersion && build.schemaVersion === release.schemaVersion, 'compiled Schema version differs');
|
||||||
|
return {
|
||||||
|
SAAS_RUNTIME_MODE: 'production', RPC_REQUIRE_EXACT_RELEASE: 'true',
|
||||||
|
RPC_MODULE_CODE: lock.moduleCode, RPC_RELEASE_ID: String(release.id),
|
||||||
|
RPC_RELEASE_VERSION: release.version, RPC_SERVICE_VERSION: release.serviceVersion,
|
||||||
|
SAAS_BUILD_REVISION: String(build.revision), SAAS_BUILD_CHECKSUM: build.buildChecksum,
|
||||||
|
SAAS_SCHEMA_INPUT_CHECKSUM: build.schemaInputChecksum,
|
||||||
|
RPC_MIN_SCHEMA_VERSION: String(release.schemaVersion), RPC_MAX_SCHEMA_VERSION: String(release.schemaVersion),
|
||||||
|
RPC_SCHEMA_CHECKSUM: release.schemaChecksum,
|
||||||
|
RPC_ACTION_MANIFEST_CHECKSUM: release.schemaSnapshot.actionManifestChecksum,
|
||||||
|
RPC_RUNTIME_CONTRACT_CHECKSUM: contracts.runtimeContractChecksum,
|
||||||
|
RPC_IMPLEMENTATION_MANIFEST_CHECKSUM: artifact.implementationManifestChecksum,
|
||||||
|
RPC_ARTIFACT_DIGEST: artifact.artifactDigest,
|
||||||
|
RPC_ARTIFACT_IDENTITY_CHECKSUM: artifact.artifactIdentityChecksum,
|
||||||
|
BUILD_SHA: artifact.sourceCommit
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyExactEnvironment(environment, exact) {
|
||||||
|
for (const [key, value] of Object.entries(exact)) {
|
||||||
|
ensure(value != null && String(value).length > 0, `authoritative field ${key} is missing`);
|
||||||
|
ensure(!environment[key] || environment[key] === String(value), `${key} conflicts with authoritative Stable evidence`);
|
||||||
|
environment[key] = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyBootstrapSession(session, exact) {
|
||||||
|
ensure(session?.moduleCode === exact.RPC_MODULE_CODE && session.revision === Number(exact.SAAS_BUILD_REVISION), 'bootstrap module/Revision differs');
|
||||||
|
const mapping = {
|
||||||
|
releaseId: 'RPC_RELEASE_ID', releaseVersion: 'RPC_RELEASE_VERSION', serviceVersion: 'RPC_SERVICE_VERSION',
|
||||||
|
runtimeMode: 'SAAS_RUNTIME_MODE', buildRevision: 'SAAS_BUILD_REVISION', buildChecksum: 'SAAS_BUILD_CHECKSUM',
|
||||||
|
schemaInputChecksum: 'SAAS_SCHEMA_INPUT_CHECKSUM', implementationManifestChecksum: 'RPC_IMPLEMENTATION_MANIFEST_CHECKSUM',
|
||||||
|
artifactDigest: 'RPC_ARTIFACT_DIGEST', artifactIdentityChecksum: 'RPC_ARTIFACT_IDENTITY_CHECKSUM'
|
||||||
|
};
|
||||||
|
for (const [field, key] of Object.entries(mapping)) ensure(String(session.release?.[field]) === exact[key], `authenticated bootstrap ${field} differs from Stable evidence`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startStable(options = {}) {
|
||||||
|
const projectDir = path.resolve(options.projectDir || __dirname);
|
||||||
|
const environment = options.environment || process.env;
|
||||||
|
const release = await fetchReleaseEvidence(environment, options.fetch);
|
||||||
|
const lock = JSON.parse(readFileSync(path.join(projectDir, '.cool-saas-candidate.json'), 'utf8'));
|
||||||
|
const digest = calculateCompiledArtifactDigest(projectDir);
|
||||||
|
const commit = sourceCommit(projectDir, environment);
|
||||||
|
// Do not execute generated JavaScript before its compiled digest is verified.
|
||||||
|
ensure(digest === release.frozenArtifact?.artifactDigest, 'compiled artifact digest differs from the Frozen Artifact');
|
||||||
|
const generated = (options.loadGenerated || (() => require(path.join(projectDir, 'dist/saas/generated')).GeneratedSaasModule))();
|
||||||
|
const exact = verifyStableEvidence({ release, lock, generated, digest, commit, releaseId: environment.RPC_RELEASE_ID });
|
||||||
|
applyExactEnvironment(environment, exact);
|
||||||
|
environment.SAAS_CONTROL_PLANE_URL ||= environment.COOL_SAAS_CONTROL_PLANE_URL || 'http://127.0.0.1:8080';
|
||||||
|
delete environment.COOL_SAAS_TOKEN;
|
||||||
|
delete environment.NODE_AUTH_TOKEN;
|
||||||
|
delete environment.npm_config__authToken;
|
||||||
|
const start = options.start || require(path.join(projectDir, 'bootstrap.js')).start;
|
||||||
|
return start({ verifySession: session => {
|
||||||
|
verifyBootstrapSession(session, exact);
|
||||||
|
ensure(calculateCompiledArtifactDigest(projectDir) === digest, 'compiled bytes changed during bootstrap');
|
||||||
|
} });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) startStable().catch(error => {
|
||||||
|
process.stderr.write(`${error?.message || error}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = { startStable, verifyStableEvidence, verifyBootstrapSession, applyExactEnvironment, calculateCompiledArtifactDigest, candidateLockChecksum, fetchReleaseEvidence, sourceCommit };
|
||||||
195
test/start-stable.test.cjs
Normal file
195
test/start-stable.test.cjs
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const test = require('node:test');
|
||||||
|
const { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, symlinkSync } = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
const { spawnSync } = require('node:child_process');
|
||||||
|
const { createHash } = require('node:crypto');
|
||||||
|
const {
|
||||||
|
startStable, verifyStableEvidence, verifyBootstrapSession, applyExactEnvironment,
|
||||||
|
calculateCompiledArtifactDigest, candidateLockChecksum, fetchReleaseEvidence, sourceCommit
|
||||||
|
} = require('../start-stable');
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const lock = { moduleCode: 'demo', candidateVersion: '1.0.1-rc.2', buildRevision: 2, buildChecksum: 'b'.repeat(64), schemaInputChecksum: 'c'.repeat(64), branchName: 'release/1.0.1-rc.2' };
|
||||||
|
const generated = { moduleCode: 'demo', revision: 2, schemaVersion: 3, schemaInputChecksum: lock.schemaInputChecksum, implementationManifestChecksum: 'd'.repeat(64), runtimeContractChecksum: 'e'.repeat(64) };
|
||||||
|
const commit = 'a'.repeat(40);
|
||||||
|
const digest = `sha256:${'f'.repeat(64)}`;
|
||||||
|
const release = {
|
||||||
|
id: 42, moduleId: 2, releaseType: 'stable', status: 'published', lifecycleVersion: 1,
|
||||||
|
version: '1.0.1', serviceVersion: '1.0.1', promotedFromReleaseId: 41, buildSnapshotId: 52,
|
||||||
|
buildRevision: 2, frozenArtifactId: 62, schemaVersion: 3, schemaChecksum: '3'.repeat(64),
|
||||||
|
build: { id: 52, moduleId: 2, revision: 2, schemaVersion: 3, buildChecksum: lock.buildChecksum, schemaInputChecksum: lock.schemaInputChecksum },
|
||||||
|
frozenArtifact: { id: 62, rcReleaseId: 41, moduleId: 2, buildRevision: 2, sourceBranch: lock.branchName, tagName: `v${lock.candidateVersion}`, sourceCommit: commit, generatedLockChecksum: candidateLockChecksum(lock), artifactDigest: digest, artifactIdentityChecksum: '1'.repeat(64), implementationManifestChecksum: generated.implementationManifestChecksum },
|
||||||
|
schemaSnapshot: { actionManifest: { artifactDigest: digest }, actionManifestChecksum: '2'.repeat(64), runtimeContracts: { implementationManifestChecksum: generated.implementationManifestChecksum, runtimeContractChecksum: generated.runtimeContractChecksum } }
|
||||||
|
};
|
||||||
|
return { release, lock, generated, digest, commit, releaseId: 42 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionFor(exact) {
|
||||||
|
return { moduleCode: exact.RPC_MODULE_CODE, revision: Number(exact.SAAS_BUILD_REVISION), release: {
|
||||||
|
releaseId: Number(exact.RPC_RELEASE_ID), releaseVersion: exact.RPC_RELEASE_VERSION, serviceVersion: exact.RPC_SERVICE_VERSION,
|
||||||
|
runtimeMode: exact.SAAS_RUNTIME_MODE, buildRevision: Number(exact.SAAS_BUILD_REVISION), buildChecksum: exact.SAAS_BUILD_CHECKSUM,
|
||||||
|
schemaInputChecksum: exact.SAAS_SCHEMA_INPUT_CHECKSUM, implementationManifestChecksum: exact.RPC_IMPLEMENTATION_MANIFEST_CHECKSUM,
|
||||||
|
artifactDigest: exact.RPC_ARTIFACT_DIGEST, artifactIdentityChecksum: exact.RPC_ARTIFACT_IDENTITY_CHECKSUM
|
||||||
|
} };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Stable derives its own deployment envelope from the same frozen Candidate and Build', () => {
|
||||||
|
const input = fixture();
|
||||||
|
const before = JSON.stringify(input);
|
||||||
|
const exact = verifyStableEvidence(input);
|
||||||
|
assert.equal(exact.RPC_RELEASE_ID, '42');
|
||||||
|
assert.equal(exact.RPC_RELEASE_VERSION, '1.0.1');
|
||||||
|
assert.equal(exact.SAAS_RUNTIME_MODE, 'production');
|
||||||
|
assert.equal(exact.SAAS_BUILD_REVISION, '2');
|
||||||
|
assert.equal(exact.BUILD_SHA, input.commit);
|
||||||
|
assert.equal(exact.RPC_ARTIFACT_DIGEST, input.digest);
|
||||||
|
assert.equal(JSON.stringify(input), before, 'must never rewrite the Candidate or frozen evidence');
|
||||||
|
verifyBootstrapSession(sessionFor(exact), exact);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fail closed on RC targets, history-only artifacts, or any frozen identity mismatch', () => {
|
||||||
|
const edits = [
|
||||||
|
data => { data.release.releaseType = 'rc'; },
|
||||||
|
data => { data.release.status = 'retired'; },
|
||||||
|
data => { data.release.version = '1.0.1-rc.2'; },
|
||||||
|
data => { data.release.frozenArtifact.artifactDigest = null; },
|
||||||
|
data => { data.release.frozenArtifact.rcReleaseId++; },
|
||||||
|
data => { data.release.buildSnapshotId++; },
|
||||||
|
data => { data.release.build.buildChecksum = '9'.repeat(64); },
|
||||||
|
data => { data.release.buildRevision++; },
|
||||||
|
data => { data.release.frozenArtifact.generatedLockChecksum = '9'.repeat(64); },
|
||||||
|
data => { data.release.frozenArtifact.tagName = 'v9.0.0'; },
|
||||||
|
data => { data.commit = '9'.repeat(40); },
|
||||||
|
data => { data.digest = `sha256:${'9'.repeat(64)}`; },
|
||||||
|
data => { data.generated.revision++; },
|
||||||
|
data => { data.generated.releaseVersion = '1.0.1-rc.2'; },
|
||||||
|
data => { data.generated.implementationManifestChecksum = '9'.repeat(64); },
|
||||||
|
data => { data.generated.runtimeContractChecksum = '9'.repeat(64); },
|
||||||
|
data => { data.generated.schemaVersion++; },
|
||||||
|
data => { data.release.schemaSnapshot.actionManifest.artifactDigest = `sha256:${'9'.repeat(64)}`; }
|
||||||
|
];
|
||||||
|
for (const edit of edits) {
|
||||||
|
const input = fixture();
|
||||||
|
edit(input);
|
||||||
|
assert.throws(() => verifyStableEvidence(input), /Stable startup refused/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('environment and authenticated bootstrap cannot silently replace Stable with RC or a different Build', () => {
|
||||||
|
const exact = verifyStableEvidence(fixture());
|
||||||
|
assert.throws(() => applyExactEnvironment({ RPC_RELEASE_ID: '41' }, exact), /conflicts/);
|
||||||
|
const environment = {};
|
||||||
|
applyExactEnvironment(environment, exact);
|
||||||
|
assert.equal(environment.RPC_ARTIFACT_DIGEST, exact.RPC_ARTIFACT_DIGEST);
|
||||||
|
for (const key of ['releaseId', 'releaseVersion', 'buildChecksum', 'runtimeMode', 'artifactDigest', 'artifactIdentityChecksum']) {
|
||||||
|
const session = sessionFor(exact);
|
||||||
|
session.release[key] = 'incorrect';
|
||||||
|
assert.throws(() => verifyBootstrapSession(session, exact), /differs/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compiled digest uses the exact finalize byte format and detects changes', t => {
|
||||||
|
const root = mkdtempSync(path.join(os.tmpdir(), 'stable-digest-test-'));
|
||||||
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||||
|
mkdirSync(path.join(root, 'dist/nested'), { recursive: true });
|
||||||
|
writeFileSync(path.join(root, 'dist/a.js'), 'first');
|
||||||
|
writeFileSync(path.join(root, 'dist/nested/b.js'), 'second');
|
||||||
|
const exactHash = createHash('sha256').update('COOL_SAAS_ARTIFACT_DIRECTORY_V1\0', 'utf8');
|
||||||
|
for (const [name, content] of [['a.js', 'first'], ['nested/b.js', 'second']]) {
|
||||||
|
exactHash.update(`${Buffer.byteLength(name)}:`).update(name).update(`:${Buffer.byteLength(content)}:`).update(content);
|
||||||
|
}
|
||||||
|
assert.equal(calculateCompiledArtifactDigest(root), `sha256:${exactHash.digest('hex')}`);
|
||||||
|
const oldDigest = calculateCompiledArtifactDigest(root);
|
||||||
|
writeFileSync(path.join(root, 'dist/a.js'), 'changed');
|
||||||
|
assert.notEqual(calculateCompiledArtifactDigest(root), oldDigest);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing, empty, and linked compiled directories are refused', t => {
|
||||||
|
const root = mkdtempSync(path.join(os.tmpdir(), 'stable-linked-test-'));
|
||||||
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||||
|
assert.throws(() => calculateCompiledArtifactDigest(root), /does not exist/);
|
||||||
|
mkdirSync(path.join(root, 'dist'));
|
||||||
|
assert.throws(() => calculateCompiledArtifactDigest(root), /empty/);
|
||||||
|
mkdirSync(path.join(root, 'outside'));
|
||||||
|
writeFileSync(path.join(root, 'outside/escaped.js'), 'outside the frozen directory');
|
||||||
|
symlinkSync(path.join(root, 'outside'), path.join(root, 'dist/linked'), 'junction');
|
||||||
|
assert.throws(() => calculateCompiledArtifactDigest(root), /symbolic links/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('control-plane evidence requires a Bearer credential and local HTTP cannot target a remote host', async () => {
|
||||||
|
const environment = { NODE_ENV: 'local', RPC_RELEASE_ID: '42', SAAS_CONTROL_PLANE_URL: 'http://127.0.0.1:8080', COOL_SAAS_TOKEN: 'test-short-lived-credential' };
|
||||||
|
const release = fixture().release;
|
||||||
|
assert.equal(await fetchReleaseEvidence(environment, async (url, options) => {
|
||||||
|
assert.equal(url.searchParams.get('id'), '42');
|
||||||
|
assert.equal(options.headers.Authorization, `Bearer ${environment.COOL_SAAS_TOKEN}`);
|
||||||
|
assert.equal(options.redirect, 'error');
|
||||||
|
return { ok: true, json: async () => ({ code: 1000, data: release }) };
|
||||||
|
}), release);
|
||||||
|
await assert.rejects(fetchReleaseEvidence({ ...environment, COOL_SAAS_TOKEN: '' }), /credential/);
|
||||||
|
await assert.rejects(fetchReleaseEvidence({ ...environment, SAAS_CONTROL_PLANE_URL: 'http://example.com' }), /HTTPS/);
|
||||||
|
await assert.rejects(fetchReleaseEvidence(environment, async () => ({ ok: false, status: 401 })), /rejected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('actual startup verifies existing bytes, removes CLI secrets, and never invokes a build', async t => {
|
||||||
|
const root = mkdtempSync(path.join(os.tmpdir(), 'stable-start-test-'));
|
||||||
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||||
|
mkdirSync(path.join(root, 'dist'), { recursive: true });
|
||||||
|
writeFileSync(path.join(root, 'dist/configuration.js'), 'frozen compiled bytes');
|
||||||
|
const data = fixture();
|
||||||
|
data.digest = calculateCompiledArtifactDigest(root);
|
||||||
|
data.release.frozenArtifact.artifactDigest = data.digest;
|
||||||
|
data.release.schemaSnapshot.actionManifest.artifactDigest = data.digest;
|
||||||
|
writeFileSync(path.join(root, '.cool-saas-candidate.json'), JSON.stringify(data.lock));
|
||||||
|
const environment = { NODE_ENV: 'local', RPC_RELEASE_ID: '42', BUILD_SHA: data.commit, COOL_SAAS_TOKEN: 'short-lived-test', NODE_AUTH_TOKEN: 'must-not-inherit' };
|
||||||
|
let starts = 0;
|
||||||
|
const options = {
|
||||||
|
projectDir: root, environment,
|
||||||
|
fetch: async () => ({ ok: true, json: async () => ({ code: 1000, data: data.release }) }),
|
||||||
|
loadGenerated: () => data.generated,
|
||||||
|
start: ({ verifySession }) => {
|
||||||
|
starts++;
|
||||||
|
assert.equal(environment.COOL_SAAS_TOKEN, undefined);
|
||||||
|
assert.equal(environment.NODE_AUTH_TOKEN, undefined);
|
||||||
|
verifySession(sessionFor(environment));
|
||||||
|
return 'started';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert.equal(await startStable(options), 'started');
|
||||||
|
assert.equal(starts, 1);
|
||||||
|
assert.equal(readFileSync(path.join(root, 'dist/configuration.js'), 'utf8'), 'frozen compiled bytes');
|
||||||
|
writeFileSync(path.join(root, 'dist/configuration.js'), 'tampered');
|
||||||
|
environment.COOL_SAAS_TOKEN = 'short-lived-test';
|
||||||
|
options.loadGenerated = () => { throw new Error('must not execute unverified compiled code'); };
|
||||||
|
await assert.rejects(startStable(options), /compiled artifact digest differs/);
|
||||||
|
assert.equal(starts, 1);
|
||||||
|
writeFileSync(path.join(root, 'dist/configuration.js'), 'frozen compiled bytes');
|
||||||
|
environment.COOL_SAAS_TOKEN = 'short-lived-test';
|
||||||
|
await assert.rejects(startStable({
|
||||||
|
...options,
|
||||||
|
loadGenerated: () => data.generated,
|
||||||
|
start: ({ verifySession }) => {
|
||||||
|
writeFileSync(path.join(root, 'dist/configuration.js'), 'changed during bootstrap');
|
||||||
|
verifySession(sessionFor(environment));
|
||||||
|
}
|
||||||
|
}), /changed during bootstrap/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dirty Git checkouts cannot be presented as the frozen source commit', t => {
|
||||||
|
const root = mkdtempSync(path.join(os.tmpdir(), 'stable-git-test-'));
|
||||||
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||||
|
const git = (...args) => {
|
||||||
|
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8', windowsHide: true });
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
return result.stdout.trim();
|
||||||
|
};
|
||||||
|
git('init'); git('config', 'user.email', 'test@example.invalid'); git('config', 'user.name', 'Stable Test');
|
||||||
|
writeFileSync(path.join(root, 'business.js'), 'original');
|
||||||
|
git('add', '.'); git('commit', '-m', 'frozen');
|
||||||
|
assert.equal(sourceCommit(root, {}), git('rev-parse', 'HEAD'));
|
||||||
|
writeFileSync(path.join(root, 'business.js'), 'modified');
|
||||||
|
assert.throws(() => sourceCommit(root, {}), /dirty/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user