@boject/cli 0.0.1-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +95 -0
- package/README.md +352 -0
- package/dist/index.js +6122 -0
- package/dist/perf/index.js +1023 -0
- package/dist/vendor/contentBundleTypes.ts +115 -0
- package/dist/vendor/contentStatus.ts +30 -0
- package/dist/vendor/fieldTypes.ts +57 -0
- package/dist/vendor/perf/lib/auth-k6.ts +14 -0
- package/dist/vendor/perf/lib/config-k6.ts +21 -0
- package/dist/vendor/perf/lib/metrics-k6.ts +9 -0
- package/dist/vendor/perf/lib/pg-sampler.ts +236 -0
- package/dist/vendor/perf/scenarios/graphql-flat.ts +97 -0
- package/dist/vendor/perf/scenarios/graphql-sitemap.ts +99 -0
- package/dist/vendor/perf/scenarios/rest-crud-cycle.ts +148 -0
- package/package.json +77 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Scenario 2 — public-write CRUD churn for the rate-limit + write path.
|
|
2
|
+
// 10 VUs run interleaved create / read / delete iterations against the public
|
|
3
|
+
// namespace (/api/public/entries). PERF_CRUD_N controls the number of items per
|
|
4
|
+
// phase (default 10000). Uses Bearer API key auth — the key must carry
|
|
5
|
+
// content:write (create/delete), content:read (the public list read), and
|
|
6
|
+
// schema:read (content-type discovery). CSRF middleware bypasses Bearer-authed
|
|
7
|
+
// requests so no Origin threading is needed.
|
|
8
|
+
// Requires the perf DB seeded and a valid PERF_API_KEY in scope.
|
|
9
|
+
import http from 'k6/http';
|
|
10
|
+
import { group } from 'k6';
|
|
11
|
+
import { loadK6Config } from '../lib/config-k6.ts';
|
|
12
|
+
import { apiKeyHeaders } from '../lib/auth-k6.ts';
|
|
13
|
+
import {
|
|
14
|
+
crudCreateLatency,
|
|
15
|
+
crudReadLatency,
|
|
16
|
+
crudDeleteLatency,
|
|
17
|
+
intentional429s,
|
|
18
|
+
unexpectedErrors,
|
|
19
|
+
} from '../lib/metrics-k6.ts';
|
|
20
|
+
|
|
21
|
+
const N = Number(__ENV.PERF_CRUD_N ?? '10000');
|
|
22
|
+
const IDENTIFIER = __ENV.PERF_CONTENT_TYPE ?? 'PerfArticle';
|
|
23
|
+
|
|
24
|
+
export const options = {
|
|
25
|
+
scenarios: {
|
|
26
|
+
crud: {
|
|
27
|
+
executor: 'shared-iterations',
|
|
28
|
+
vus: 10,
|
|
29
|
+
iterations: N * 3, // create + read + delete per item
|
|
30
|
+
maxDuration: '30m',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
tags: { scenario: 'crud' },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
interface SetupData {
|
|
37
|
+
contentTypeId: string;
|
|
38
|
+
identifier: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function setup(): SetupData {
|
|
42
|
+
const cfg = loadK6Config();
|
|
43
|
+
const headers = apiKeyHeaders();
|
|
44
|
+
|
|
45
|
+
// Discover the content-type UUID via the schema export (portable=false
|
|
46
|
+
// preserves real UUIDs) — a schema:read management endpoint, NOT the admin
|
|
47
|
+
// content surface, so perf needs no admin-content token (#395/#257).
|
|
48
|
+
const res = http.get(`${cfg.baseUrl}/api/schema/export?portable=false`, {
|
|
49
|
+
headers,
|
|
50
|
+
});
|
|
51
|
+
const bundle = res.json() as {
|
|
52
|
+
contentTypes?: Array<{ id: string | null; identifier: string }>;
|
|
53
|
+
};
|
|
54
|
+
const ct = (bundle.contentTypes ?? []).find(
|
|
55
|
+
(t) => t.identifier === IDENTIFIER
|
|
56
|
+
);
|
|
57
|
+
if (!ct || !ct.id) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`${IDENTIFIER} content type not found in schema export — run \`pnpm perf:seed --size=1\` first`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return { contentTypeId: ct.id, identifier: IDENTIFIER };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export default function crud(data: SetupData) {
|
|
66
|
+
const cfg = loadK6Config();
|
|
67
|
+
const headers = apiKeyHeaders();
|
|
68
|
+
const iter = __ITER;
|
|
69
|
+
|
|
70
|
+
// Use a simple phase calculation from __VU + __ITER — imperfect ordering,
|
|
71
|
+
// but across 10 VUs × 3N iterations we hit each phase roughly equally.
|
|
72
|
+
// See README for why strict phase ordering is a follow-up refinement.
|
|
73
|
+
const phase = iter % 3;
|
|
74
|
+
|
|
75
|
+
if (phase === 0) {
|
|
76
|
+
group('create', () => {
|
|
77
|
+
const body = {
|
|
78
|
+
contentTypeId: data.contentTypeId,
|
|
79
|
+
// Publish so the entry is PUBLISHED → visible to the public read list
|
|
80
|
+
// (the public namespace is PUBLISHED-only). Exercises the publish path
|
|
81
|
+
// + cache invalidation — the production-representative workload.
|
|
82
|
+
publish: true,
|
|
83
|
+
data: {
|
|
84
|
+
title: `CRUD ${__VU}-${iter}-${Date.now()}`,
|
|
85
|
+
slug: `crud-${__VU}-${iter}-${Date.now()}`,
|
|
86
|
+
excerpt: 'CRUD cycle',
|
|
87
|
+
body: {
|
|
88
|
+
type: 'doc',
|
|
89
|
+
content: [
|
|
90
|
+
{
|
|
91
|
+
type: 'paragraph',
|
|
92
|
+
content: [{ type: 'text', text: 'body' }],
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
publishDate: new Date().toISOString(),
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
const res = http.post(
|
|
100
|
+
`${cfg.baseUrl}/api/public/entries`,
|
|
101
|
+
JSON.stringify(body),
|
|
102
|
+
{ headers, tags: { phase: 'create' } }
|
|
103
|
+
);
|
|
104
|
+
crudCreateLatency.add(res.timings.duration);
|
|
105
|
+
if (res.status === 429) intentional429s.add(1);
|
|
106
|
+
else if (res.status < 200 || res.status >= 300) unexpectedErrors.add(1);
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (phase === 1) {
|
|
112
|
+
group('read', () => {
|
|
113
|
+
// No single-entry public read exists; the cached public LIST is the read.
|
|
114
|
+
// crudReadLatency now measures the list GET (a list read, not by-id).
|
|
115
|
+
const res = http.get(
|
|
116
|
+
`${cfg.baseUrl}/api/public/entries?contentType=${data.identifier}&perPage=1`,
|
|
117
|
+
{ headers, tags: { phase: 'read' } }
|
|
118
|
+
);
|
|
119
|
+
crudReadLatency.add(res.timings.duration);
|
|
120
|
+
if (res.status < 200 || res.status >= 300) unexpectedErrors.add(1);
|
|
121
|
+
});
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
group('delete', () => {
|
|
126
|
+
const list = http.get(
|
|
127
|
+
`${cfg.baseUrl}/api/public/entries?contentType=${data.identifier}&perPage=1`,
|
|
128
|
+
{ headers, tags: { phase: 'list' } }
|
|
129
|
+
);
|
|
130
|
+
if (list.status !== 200) {
|
|
131
|
+
unexpectedErrors.add(1);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const items = (list.json() as { items: Array<{ id: string }> }).items;
|
|
135
|
+
if (items.length === 0) return;
|
|
136
|
+
const res = http.del(
|
|
137
|
+
`${cfg.baseUrl}/api/public/entries/${items[0]!.id}`,
|
|
138
|
+
null,
|
|
139
|
+
{ headers, tags: { phase: 'delete' } }
|
|
140
|
+
);
|
|
141
|
+
crudDeleteLatency.add(res.timings.duration);
|
|
142
|
+
if (res.status === 429) intentional429s.add(1);
|
|
143
|
+
// 404 here means another VU already deleted the same head item.
|
|
144
|
+
// That's a normal race under load, not an unexpected error.
|
|
145
|
+
else if (res.status === 404) return;
|
|
146
|
+
else if (res.status < 200 || res.status >= 300) unexpectedErrors.add(1);
|
|
147
|
+
});
|
|
148
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@boject/cli",
|
|
3
|
+
"version": "0.0.1-rc.1",
|
|
4
|
+
"license": "BUSL-1.1",
|
|
5
|
+
"private": false,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"description": "Boject CMS maintenance CLI.",
|
|
8
|
+
"author": "Boject Ltd",
|
|
9
|
+
"homepage": "https://github.com/bojectify/boject-cms/tree/main/packages/boject-cli#readme",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/bojectify/boject-cms.git",
|
|
13
|
+
"directory": "packages/boject-cli"
|
|
14
|
+
},
|
|
15
|
+
"bugs": "https://github.com/bojectify/boject-cms/issues",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"boject",
|
|
18
|
+
"cms",
|
|
19
|
+
"headless-cms",
|
|
20
|
+
"cli",
|
|
21
|
+
"content-management",
|
|
22
|
+
"graphql"
|
|
23
|
+
],
|
|
24
|
+
"bin": {
|
|
25
|
+
"boject": "./dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./src/index.ts",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./perf": {
|
|
34
|
+
"types": "./src/perf/index.ts",
|
|
35
|
+
"default": "./dist/perf/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./vendor/contentBundleTypes": "./dist/vendor/contentBundleTypes.ts",
|
|
38
|
+
"./vendor/fieldTypes": "./dist/vendor/fieldTypes.ts",
|
|
39
|
+
"./vendor/contentStatus": "./dist/vendor/contentStatus.ts"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"dist"
|
|
43
|
+
],
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=24"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsup",
|
|
52
|
+
"lint": "eslint .",
|
|
53
|
+
"lint:fix": "eslint . --fix",
|
|
54
|
+
"typecheck": "tsc --noEmit",
|
|
55
|
+
"test": "vitest run",
|
|
56
|
+
"test:unit": "vitest run --project unit",
|
|
57
|
+
"test:e2e": "vitest run tests/e2e",
|
|
58
|
+
"test:integration": "vitest run --project integration"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"pg": "^8.13.1",
|
|
62
|
+
"semver": "^7.6.3",
|
|
63
|
+
"yaml": "^2.6.1"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@prisma/adapter-pg": "^7.4.1",
|
|
67
|
+
"@prisma/client": "^7.4.1",
|
|
68
|
+
"@types/node": "^24.12.2",
|
|
69
|
+
"@types/pg": "^8.11.10",
|
|
70
|
+
"@types/semver": "^7.5.8",
|
|
71
|
+
"msw": "^2.13.4",
|
|
72
|
+
"tsup": "^8.3.0",
|
|
73
|
+
"tsx": "^4.19.0",
|
|
74
|
+
"typescript": "^5.7.0",
|
|
75
|
+
"vitest": "^4.1.5"
|
|
76
|
+
}
|
|
77
|
+
}
|