@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,115 @@
|
|
|
1
|
+
// VENDORED from apps/cms/scripts/content-bundle/types.ts.
|
|
2
|
+
// The CLI is published standalone and cannot import from apps/cms/.
|
|
3
|
+
// Keep this file in sync when the canonical version changes.
|
|
4
|
+
//
|
|
5
|
+
// This is a PARTIAL vendor by design — the canonical defines additional
|
|
6
|
+
// types (`OnConflict`, `EntryImportPlan`, `EntryImportSummary`,
|
|
7
|
+
// `EntryImportPlanResult`) used by the importer's planner that the CLI
|
|
8
|
+
// does not consume. Do not add them here unless the CLI starts needing
|
|
9
|
+
// them. The drift test (`tests/unit/vendorDrift.test.ts`) intentionally
|
|
10
|
+
// omits this pair because a byte-identical check would fail on the
|
|
11
|
+
// import-source rewrites and the missing types.
|
|
12
|
+
//
|
|
13
|
+
// Types that MUST stay structurally in sync with the canonical:
|
|
14
|
+
// BundleFieldOptions, BundleField, BundleContentType,
|
|
15
|
+
// BundleEntryVersion, BundleEntry, Bundle, BundleMode,
|
|
16
|
+
// ValidationError, ConflictErrorKind, ConflictError,
|
|
17
|
+
// ValidationResult, ImportResult, and the BUNDLE_VERSION constant.
|
|
18
|
+
|
|
19
|
+
import type { FieldTypeName } from './fieldTypes.js';
|
|
20
|
+
import type { ContentStatusName } from './contentStatus.js';
|
|
21
|
+
|
|
22
|
+
// Coupling rule: bumping BUNDLE_VERSION is a breaking change to the bundle
|
|
23
|
+
// format and MUST coincide with a CLI semver-major release. The reverse
|
|
24
|
+
// does not hold — a CLI major may ship without a bundle version bump.
|
|
25
|
+
// See docs/superpowers/specs/2026-05-28-bundle-format-versioning-and-migrations-design.md
|
|
26
|
+
// (internal repo) for the full versioning policy.
|
|
27
|
+
//
|
|
28
|
+
// History: V2 was redefined in #205 to require `entryKey` on every entry.
|
|
29
|
+
// The version number was not bumped at that time (clean cutover — no real
|
|
30
|
+
// v1/v2 entry-bearing bundles existed outside this repo). The defensive
|
|
31
|
+
// v1 entry-shape fallback was removed in #32.
|
|
32
|
+
export const BUNDLE_VERSION = 2;
|
|
33
|
+
|
|
34
|
+
export type BundleFieldOptions = {
|
|
35
|
+
choices?: string[];
|
|
36
|
+
targetContentTypeIds?: string[] | null[];
|
|
37
|
+
targetContentTypeIdentifiers?: string[];
|
|
38
|
+
[key: string]: unknown;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export interface BundleField {
|
|
42
|
+
id: string | null;
|
|
43
|
+
identifier: string;
|
|
44
|
+
name: string;
|
|
45
|
+
type: FieldTypeName;
|
|
46
|
+
required: boolean;
|
|
47
|
+
unique?: boolean;
|
|
48
|
+
order: number;
|
|
49
|
+
options: BundleFieldOptions | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface BundleContentType {
|
|
53
|
+
id: string | null;
|
|
54
|
+
identifier: string;
|
|
55
|
+
name: string;
|
|
56
|
+
description: string | null;
|
|
57
|
+
fields: BundleField[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface BundleEntryVersion {
|
|
61
|
+
status: ContentStatusName;
|
|
62
|
+
data: Record<string, unknown>;
|
|
63
|
+
publishedAt: string | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface BundleEntry {
|
|
67
|
+
id: string | null;
|
|
68
|
+
contentTypeId: string | null;
|
|
69
|
+
contentTypeIdentifier: string;
|
|
70
|
+
entryTitle: string;
|
|
71
|
+
entryKey: string;
|
|
72
|
+
slug: string | null;
|
|
73
|
+
versions: BundleEntryVersion[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface Bundle {
|
|
77
|
+
version: number;
|
|
78
|
+
exportedAt: string;
|
|
79
|
+
portable: boolean;
|
|
80
|
+
contentTypes?: BundleContentType[];
|
|
81
|
+
entries?: BundleEntry[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type BundleMode = 'schema' | 'entries' | 'all';
|
|
85
|
+
|
|
86
|
+
export interface ValidationError {
|
|
87
|
+
path: string;
|
|
88
|
+
message: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type ConflictErrorKind =
|
|
92
|
+
| 'contentType.identifier'
|
|
93
|
+
| 'contentType.id'
|
|
94
|
+
| 'field.id'
|
|
95
|
+
| 'entry.id'
|
|
96
|
+
| 'entry.slug'
|
|
97
|
+
| 'entry.entryTitle';
|
|
98
|
+
|
|
99
|
+
export interface ConflictError {
|
|
100
|
+
kind: ConflictErrorKind;
|
|
101
|
+
identifier: string;
|
|
102
|
+
existingId?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ValidationResult {
|
|
106
|
+
ok: boolean;
|
|
107
|
+
errors: ValidationError[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface ImportResult {
|
|
111
|
+
contentTypesCreated: number;
|
|
112
|
+
entriesCreated: number;
|
|
113
|
+
entriesUpdated: number;
|
|
114
|
+
entriesSkipped: number;
|
|
115
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Canonical registry of content entry statuses. Mirrors the `ContentStatus`
|
|
2
|
+
// enum in apps/cms/prisma/schema/base.prisma so that resolvers, validators,
|
|
3
|
+
// API handlers, GraphQL types, and tests all read from one source. Pure module
|
|
4
|
+
// (zero Nuxt / Prisma deps) so it ships unchanged into packages/boject-cli
|
|
5
|
+
// via vendor copy — keep packages/boject-cli/src/vendor/contentStatus.ts in sync.
|
|
6
|
+
|
|
7
|
+
export const CONTENT_STATUSES = {
|
|
8
|
+
DRAFT: 'DRAFT',
|
|
9
|
+
PUBLISHED: 'PUBLISHED',
|
|
10
|
+
CHANGED: 'CHANGED',
|
|
11
|
+
ARCHIVED: 'ARCHIVED',
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
export const CONTENT_STATUS_NAMES = Object.values(CONTENT_STATUSES);
|
|
15
|
+
|
|
16
|
+
export type ContentStatusName =
|
|
17
|
+
(typeof CONTENT_STATUSES)[keyof typeof CONTENT_STATUSES];
|
|
18
|
+
|
|
19
|
+
export const CONTENT_STATUSES_SET: ReadonlySet<ContentStatusName> = new Set(
|
|
20
|
+
CONTENT_STATUS_NAMES
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
export function isContentStatusName(
|
|
24
|
+
value: unknown
|
|
25
|
+
): value is ContentStatusName {
|
|
26
|
+
return (
|
|
27
|
+
typeof value === 'string' &&
|
|
28
|
+
CONTENT_STATUSES_SET.has(value as ContentStatusName)
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Canonical registry of dynamic content type field types. Mirrors the
|
|
2
|
+
// `FieldType` enum in apps/cms/prisma/schema/contentType.prisma so that
|
|
3
|
+
// validators, UI dropdowns, and tests all read from one source. Pure module
|
|
4
|
+
// (zero Nuxt / Prisma deps) so it ships unchanged into packages/boject-cli
|
|
5
|
+
// via vendor copy — keep packages/boject-cli/src/vendor/fieldTypes.ts in sync.
|
|
6
|
+
|
|
7
|
+
export const FIELD_TYPES = {
|
|
8
|
+
ENTRY_TITLE: 'ENTRY_TITLE',
|
|
9
|
+
SLUG: 'SLUG',
|
|
10
|
+
TEXT: 'TEXT',
|
|
11
|
+
TEXTAREA: 'TEXTAREA',
|
|
12
|
+
NUMBER: 'NUMBER',
|
|
13
|
+
BOOLEAN: 'BOOLEAN',
|
|
14
|
+
DATETIME: 'DATETIME',
|
|
15
|
+
SELECT: 'SELECT',
|
|
16
|
+
RICHTEXT: 'RICHTEXT',
|
|
17
|
+
RELATION: 'RELATION',
|
|
18
|
+
MULTIRELATION: 'MULTIRELATION',
|
|
19
|
+
IMAGE: 'IMAGE',
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
export const FIELD_TYPE_NAMES = Object.values(FIELD_TYPES);
|
|
23
|
+
|
|
24
|
+
export type FieldTypeName = (typeof FIELD_TYPES)[keyof typeof FIELD_TYPES];
|
|
25
|
+
|
|
26
|
+
export const FIELD_TYPES_SET: ReadonlySet<FieldTypeName> = new Set(
|
|
27
|
+
FIELD_TYPE_NAMES
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export function isFieldTypeName(value: unknown): value is FieldTypeName {
|
|
31
|
+
return (
|
|
32
|
+
typeof value === 'string' && FIELD_TYPES_SET.has(value as FieldTypeName)
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const FIELD_TYPE_LABELS: Record<FieldTypeName, string> = {
|
|
37
|
+
ENTRY_TITLE: 'Entry Title',
|
|
38
|
+
SLUG: 'Slug',
|
|
39
|
+
TEXT: 'Text',
|
|
40
|
+
TEXTAREA: 'Textarea',
|
|
41
|
+
NUMBER: 'Number',
|
|
42
|
+
BOOLEAN: 'Boolean',
|
|
43
|
+
DATETIME: 'Date/Time',
|
|
44
|
+
SELECT: 'Select',
|
|
45
|
+
RICHTEXT: 'Rich Text',
|
|
46
|
+
RELATION: 'Relation',
|
|
47
|
+
MULTIRELATION: 'Multi Relation',
|
|
48
|
+
IMAGE: 'Image',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const FIELD_TYPE_OPTIONS: Array<{
|
|
52
|
+
label: string;
|
|
53
|
+
value: FieldTypeName;
|
|
54
|
+
}> = FIELD_TYPE_NAMES.map((value) => ({
|
|
55
|
+
label: FIELD_TYPE_LABELS[value],
|
|
56
|
+
value,
|
|
57
|
+
}));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { loadK6Config } from './config-k6.ts';
|
|
2
|
+
|
|
3
|
+
export function apiKeyHeaders(): Record<string, string> {
|
|
4
|
+
const cfg = loadK6Config();
|
|
5
|
+
if (!cfg.apiKey) {
|
|
6
|
+
throw new Error(
|
|
7
|
+
'PERF_API_KEY not set. Run: pnpm prisma:seed:perf and export PERF_API_KEY=boject_perf_key_for_load_tests_only'
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
Authorization: `Bearer ${cfg.apiKey}`,
|
|
12
|
+
'Content-Type': 'application/json',
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// k6 runtime (goja VM). Reads `__ENV` — no process.env, no node_modules.
|
|
2
|
+
|
|
3
|
+
export interface PerfK6Config {
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
apiKey: string | undefined;
|
|
6
|
+
adminEmail: string;
|
|
7
|
+
adminPassword: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function loadK6Config(): PerfK6Config {
|
|
11
|
+
const baseUrl = (__ENV.PERF_BASE_URL || 'http://localhost:4000').replace(
|
|
12
|
+
/\/$/,
|
|
13
|
+
''
|
|
14
|
+
);
|
|
15
|
+
return {
|
|
16
|
+
baseUrl,
|
|
17
|
+
apiKey: __ENV.PERF_API_KEY || undefined,
|
|
18
|
+
adminEmail: __ENV.PERF_ADMIN_EMAIL || 'admin@example.com',
|
|
19
|
+
adminPassword: __ENV.PERF_ADMIN_PASSWORD || 'password',
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Trend, Counter } from 'k6/metrics';
|
|
2
|
+
|
|
3
|
+
export const drainLatency = new Trend('perf_drain_page_ms', true);
|
|
4
|
+
export const drainWallClock = new Trend('perf_drain_total_ms', true);
|
|
5
|
+
export const crudCreateLatency = new Trend('perf_crud_create_ms', true);
|
|
6
|
+
export const crudReadLatency = new Trend('perf_crud_read_ms', true);
|
|
7
|
+
export const crudDeleteLatency = new Trend('perf_crud_delete_ms', true);
|
|
8
|
+
export const intentional429s = new Counter('perf_intentional_429');
|
|
9
|
+
export const unexpectedErrors = new Counter('perf_unexpected_errors');
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { Client } from 'pg';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
export interface Sample {
|
|
8
|
+
timestamp: Date;
|
|
9
|
+
total: number;
|
|
10
|
+
active: number;
|
|
11
|
+
idle: number;
|
|
12
|
+
cpuPercent: number;
|
|
13
|
+
memMb: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SampleOpts {
|
|
17
|
+
query: (sql: string) => Promise<{
|
|
18
|
+
rows: Array<{ total: string; active: string; idle: string }>;
|
|
19
|
+
}>;
|
|
20
|
+
dockerStats: () => Promise<{ cpu_percent: number; mem_mb: number }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function sampleOnce(opts: SampleOpts): Promise<Sample> {
|
|
24
|
+
const [pgResult, docker] = await Promise.all([
|
|
25
|
+
opts.query(
|
|
26
|
+
`SELECT COUNT(*) AS total,
|
|
27
|
+
COUNT(*) FILTER (WHERE state = 'active') AS active,
|
|
28
|
+
COUNT(*) FILTER (WHERE state = 'idle') AS idle
|
|
29
|
+
FROM pg_stat_activity
|
|
30
|
+
WHERE datname = current_database()`
|
|
31
|
+
),
|
|
32
|
+
opts.dockerStats(),
|
|
33
|
+
]);
|
|
34
|
+
const row = pgResult.rows[0]!;
|
|
35
|
+
return {
|
|
36
|
+
timestamp: new Date(),
|
|
37
|
+
total: Number(row.total),
|
|
38
|
+
active: Number(row.active),
|
|
39
|
+
idle: Number(row.idle),
|
|
40
|
+
cpuPercent: docker.cpu_percent,
|
|
41
|
+
memMb: docker.mem_mb,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function formatCsvRow(s: Sample): string {
|
|
46
|
+
return [
|
|
47
|
+
s.timestamp.toISOString(),
|
|
48
|
+
s.total,
|
|
49
|
+
s.active,
|
|
50
|
+
s.idle,
|
|
51
|
+
s.cpuPercent,
|
|
52
|
+
s.memMb,
|
|
53
|
+
].join(',');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const CSV_HEADER = 'timestamp,total,active,idle,cpu_percent,mem_mb';
|
|
57
|
+
|
|
58
|
+
// Container name varies with the docker compose project name (defaults to the
|
|
59
|
+
// repo directory) and the compose service name (`db` in docker-compose.yml).
|
|
60
|
+
// The default below matches `pnpm db:up` in this repo; override via env when
|
|
61
|
+
// running against a different deployment.
|
|
62
|
+
const DEFAULT_CONTAINER = 'boject-cms-db-1';
|
|
63
|
+
|
|
64
|
+
// Pure parser extracted so the regex/JSON behaviour is unit-testable
|
|
65
|
+
// without spawning docker. Currently only matches `MiB` — non-MiB units
|
|
66
|
+
// (KiB / GiB) yield mem_mb=0; the test suite documents this.
|
|
67
|
+
export function parseDockerStatsJson(line: string): {
|
|
68
|
+
cpu_percent: number;
|
|
69
|
+
mem_mb: number;
|
|
70
|
+
} {
|
|
71
|
+
const j = JSON.parse(line.trim()) as {
|
|
72
|
+
CPUPerc: string;
|
|
73
|
+
MemUsage: string;
|
|
74
|
+
};
|
|
75
|
+
const cpu = Number(j.CPUPerc.replace('%', '').trim());
|
|
76
|
+
const memMatch = j.MemUsage.match(/([\d.]+)\s*MiB/);
|
|
77
|
+
const mem = memMatch ? Number(memMatch[1]) : 0;
|
|
78
|
+
return { cpu_percent: cpu, mem_mb: mem };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function dockerStatsDefault(): Promise<{
|
|
82
|
+
cpu_percent: number;
|
|
83
|
+
mem_mb: number;
|
|
84
|
+
}> {
|
|
85
|
+
const container = process.env.PERF_SAMPLER_CONTAINER ?? DEFAULT_CONTAINER;
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
const child = spawn('docker', [
|
|
88
|
+
'stats',
|
|
89
|
+
'--no-stream',
|
|
90
|
+
'--format',
|
|
91
|
+
'{{json .}}',
|
|
92
|
+
container,
|
|
93
|
+
]);
|
|
94
|
+
let buf = '';
|
|
95
|
+
let errBuf = '';
|
|
96
|
+
child.stdout.on('data', (d) => {
|
|
97
|
+
buf += d.toString();
|
|
98
|
+
});
|
|
99
|
+
child.stderr.on('data', (d) => {
|
|
100
|
+
errBuf += d.toString();
|
|
101
|
+
});
|
|
102
|
+
child.on('close', (code) => {
|
|
103
|
+
if (code !== 0) {
|
|
104
|
+
console.error(
|
|
105
|
+
`[pg-sampler] docker stats ${container} exited ${code}: ${errBuf.trim()}`
|
|
106
|
+
);
|
|
107
|
+
return resolve({ cpu_percent: 0, mem_mb: 0 });
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
resolve(parseDockerStatsJson(buf));
|
|
111
|
+
} catch (err) {
|
|
112
|
+
reject(err);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Fixed-cadence scheduler: the Nth sample is anchored at
|
|
119
|
+
// `startedAt + N * intervalMs`. Returns the ms to sleep before that tick.
|
|
120
|
+
// Returns 0 when work overran the slot — the caller should sample again
|
|
121
|
+
// immediately rather than padding `intervalMs` on top of every overrun
|
|
122
|
+
// (which would let drift accumulate).
|
|
123
|
+
export function nextTickDelay(opts: {
|
|
124
|
+
startedAt: number;
|
|
125
|
+
tickIndex: number;
|
|
126
|
+
intervalMs: number;
|
|
127
|
+
now: number;
|
|
128
|
+
}): number {
|
|
129
|
+
return Math.max(
|
|
130
|
+
0,
|
|
131
|
+
opts.startedAt + opts.tickIndex * opts.intervalMs - opts.now
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function parseIntervalMs(raw: string | undefined): number {
|
|
136
|
+
const n = Number(raw ?? '5000');
|
|
137
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`Invalid PERF_SAMPLER_INTERVAL_MS=${JSON.stringify(raw)} — expected a positive number of milliseconds`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return n;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function main(): Promise<void> {
|
|
146
|
+
const outPath = process.env.PERF_SAMPLER_OUT ?? 'pg-samples.csv';
|
|
147
|
+
const intervalMs = parseIntervalMs(process.env.PERF_SAMPLER_INTERVAL_MS);
|
|
148
|
+
// `||` not `??`: docker-compose's `${PERF_DATABASE_URL:-}` passthrough sets
|
|
149
|
+
// the var to '' inside the container when the host has it unset; `??`
|
|
150
|
+
// would treat '' as a valid value and pg would reject the empty connection
|
|
151
|
+
// string. `||` falls back on unset AND empty.
|
|
152
|
+
const databaseUrl =
|
|
153
|
+
process.env.PERF_DATABASE_URL ||
|
|
154
|
+
'postgresql://boject:boject@localhost:5432/boject_perf';
|
|
155
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
156
|
+
appendFileSync(outPath, CSV_HEADER + '\n');
|
|
157
|
+
|
|
158
|
+
const client = new Client({ connectionString: databaseUrl });
|
|
159
|
+
await client.connect();
|
|
160
|
+
|
|
161
|
+
// Cooperative shutdown: SIGINT/SIGTERM flips `running` and aborts the
|
|
162
|
+
// current sleep so Ctrl+C is responsive even between samples. The loop
|
|
163
|
+
// exits naturally; client.end() runs in the finally block exactly once.
|
|
164
|
+
let running = true;
|
|
165
|
+
let sleepAbort: AbortController | null = null;
|
|
166
|
+
const stop = (): void => {
|
|
167
|
+
running = false;
|
|
168
|
+
sleepAbort?.abort();
|
|
169
|
+
};
|
|
170
|
+
process.on('SIGINT', stop);
|
|
171
|
+
process.on('SIGTERM', stop);
|
|
172
|
+
|
|
173
|
+
const startedAt = Date.now();
|
|
174
|
+
let tickIndex = 0;
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
while (running) {
|
|
178
|
+
try {
|
|
179
|
+
const sample = await sampleOnce({
|
|
180
|
+
query: (sql) => client.query(sql),
|
|
181
|
+
dockerStats: dockerStatsDefault,
|
|
182
|
+
});
|
|
183
|
+
appendFileSync(outPath, formatCsvRow(sample) + '\n');
|
|
184
|
+
} catch (err) {
|
|
185
|
+
// Suppress fallout from the in-flight query racing the signal —
|
|
186
|
+
// those errors are expected during graceful shutdown.
|
|
187
|
+
if (running) {
|
|
188
|
+
console.error('[pg-sampler]', err);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (!running) break;
|
|
192
|
+
tickIndex++;
|
|
193
|
+
const sleepFor = nextTickDelay({
|
|
194
|
+
startedAt,
|
|
195
|
+
tickIndex,
|
|
196
|
+
intervalMs,
|
|
197
|
+
now: Date.now(),
|
|
198
|
+
});
|
|
199
|
+
sleepAbort = new AbortController();
|
|
200
|
+
const ac = sleepAbort;
|
|
201
|
+
await new Promise<void>((resolve) => {
|
|
202
|
+
const t = setTimeout(resolve, sleepFor);
|
|
203
|
+
ac.signal.addEventListener('abort', () => {
|
|
204
|
+
clearTimeout(t);
|
|
205
|
+
resolve();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
sleepAbort = null;
|
|
209
|
+
}
|
|
210
|
+
} finally {
|
|
211
|
+
await client.end();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// CLI entry — pathToFileURL handles symlinks, spaces in paths, and
|
|
216
|
+
// platform path separators. The naked `file://${argv[1]}` form silently
|
|
217
|
+
// no-ops in those cases.
|
|
218
|
+
//
|
|
219
|
+
// Also gate on the entry's basename: when this module is bundled into
|
|
220
|
+
// the @boject/cli `dist/index.js`, `import.meta.url` is rewritten to the
|
|
221
|
+
// bundle's URL, which equals argv[1] for every CLI invocation — making
|
|
222
|
+
// the original conditional fire on `boject upgrade`, `boject schema *`,
|
|
223
|
+
// etc. The basename check pins auto-run to literal pg-sampler entry.
|
|
224
|
+
function isPgSamplerEntry(): boolean {
|
|
225
|
+
if (!process.argv[1]) return false;
|
|
226
|
+
if (import.meta.url !== pathToFileURL(process.argv[1]).href) return false;
|
|
227
|
+
const entryName = process.argv[1].split(/[\\/]/).pop() ?? '';
|
|
228
|
+
return /^pg-sampler\.(?:ts|js|mjs|cjs)$/i.test(entryName);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (isPgSamplerEntry()) {
|
|
232
|
+
main().catch((err: unknown) => {
|
|
233
|
+
console.error('[pg-sampler] fatal', err);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Scenario 1B — GraphQL flat-RPS ramp.
|
|
2
|
+
// Drives the cms's GraphQL endpoint at increasing arrival rates to find
|
|
3
|
+
// the failure point. PERF_QUERY_SHAPE selects a query template:
|
|
4
|
+
// - bare: small projection, no joins
|
|
5
|
+
// - filtered: with a DATETIME filter (exercises JSONB index path)
|
|
6
|
+
// - relation: includes a RELATION (exercises sub-resolver)
|
|
7
|
+
// Requires the perf DB seeded and PERF_API_KEY exported.
|
|
8
|
+
//
|
|
9
|
+
// Content-type identifiers default to the internal PerfArticle/Author
|
|
10
|
+
// fixture so `pnpm perf:sweep` keeps working unchanged. The CLI
|
|
11
|
+
// (#126) overrides them via PERF_LIST_FIELD / PERF_FILTER_FIELD /
|
|
12
|
+
// PERF_RELATION_FIELD when running against an operator's schema.
|
|
13
|
+
import http from 'k6/http';
|
|
14
|
+
import { check } from 'k6';
|
|
15
|
+
import { loadK6Config } from '../lib/config-k6.ts';
|
|
16
|
+
import { apiKeyHeaders } from '../lib/auth-k6.ts';
|
|
17
|
+
|
|
18
|
+
const QUERY_SHAPE = __ENV.PERF_QUERY_SHAPE ?? 'bare';
|
|
19
|
+
const LIST_FIELD = __ENV.PERF_LIST_FIELD ?? 'perfArticleList';
|
|
20
|
+
const FILTER_FIELD = __ENV.PERF_FILTER_FIELD ?? 'publishDate';
|
|
21
|
+
const RELATION_FIELD = __ENV.PERF_RELATION_FIELD ?? 'author';
|
|
22
|
+
|
|
23
|
+
// Power-user intensity overrides. PERF_TARGET_RPS sets the peak; the
|
|
24
|
+
// default 6-stage ramp scales proportionally from it. PERF_STAGES (a
|
|
25
|
+
// CSV of integer RPS targets) overrides the ramp wholesale. With no
|
|
26
|
+
// overrides the produced ramp is [50, 100, 250, 500, 1000, 2000] —
|
|
27
|
+
// byte-equivalent to the previous hardcoded values.
|
|
28
|
+
const TARGET_RPS = Number(__ENV.PERF_TARGET_RPS ?? '2000');
|
|
29
|
+
const STAGE_DURATION = '30s';
|
|
30
|
+
|
|
31
|
+
function parseStages(): Array<{ target: number; duration: string }> {
|
|
32
|
+
const raw = __ENV.PERF_STAGES;
|
|
33
|
+
if (raw) {
|
|
34
|
+
return raw.split(',').map((s) => ({
|
|
35
|
+
target: Number(s.trim()),
|
|
36
|
+
duration: STAGE_DURATION,
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
const ramp = [
|
|
40
|
+
Math.round(TARGET_RPS * 0.025),
|
|
41
|
+
Math.round(TARGET_RPS * 0.05),
|
|
42
|
+
Math.round(TARGET_RPS * 0.125),
|
|
43
|
+
Math.round(TARGET_RPS * 0.25),
|
|
44
|
+
Math.round(TARGET_RPS * 0.5),
|
|
45
|
+
TARGET_RPS,
|
|
46
|
+
];
|
|
47
|
+
return ramp.map((target) => ({ target, duration: STAGE_DURATION }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const STAGES = parseStages();
|
|
51
|
+
|
|
52
|
+
const QUERIES: Record<string, string> = {
|
|
53
|
+
bare: `query Items { ${LIST_FIELD}(first: 100) { edges { node { id } } } }`,
|
|
54
|
+
filtered: `query Items { ${LIST_FIELD}(first: 100, where: { ${FILTER_FIELD}: { gt: "2020-01-01T00:00:00Z" } }) { edges { node { id } } } }`,
|
|
55
|
+
relation: `query Items { ${LIST_FIELD}(first: 100) { edges { node { id ${RELATION_FIELD} { id } } } } }`,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const options = {
|
|
59
|
+
scenarios: {
|
|
60
|
+
flat: {
|
|
61
|
+
executor: 'ramping-arrival-rate',
|
|
62
|
+
startRate: STAGES[0]?.target ?? 50,
|
|
63
|
+
timeUnit: '1s',
|
|
64
|
+
preAllocatedVUs: 50,
|
|
65
|
+
maxVUs: 500,
|
|
66
|
+
stages: STAGES,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
thresholds: {
|
|
70
|
+
http_req_failed: ['rate<0.05'],
|
|
71
|
+
http_req_duration: ['p(99)<5000'],
|
|
72
|
+
dropped_iterations: ['count<100'],
|
|
73
|
+
},
|
|
74
|
+
tags: { shape: QUERY_SHAPE },
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export default function flat() {
|
|
78
|
+
const cfg = loadK6Config();
|
|
79
|
+
const headers = apiKeyHeaders();
|
|
80
|
+
const query = QUERIES[QUERY_SHAPE];
|
|
81
|
+
if (!query) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Unknown PERF_QUERY_SHAPE: ${QUERY_SHAPE}. Valid: ${Object.keys(
|
|
84
|
+
QUERIES
|
|
85
|
+
).join(', ')}`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const res = http.post(
|
|
90
|
+
`${cfg.baseUrl}/api/graphql`,
|
|
91
|
+
JSON.stringify({ query }),
|
|
92
|
+
{ headers, tags: { shape: QUERY_SHAPE } }
|
|
93
|
+
);
|
|
94
|
+
check(res, {
|
|
95
|
+
'2xx': (r) => r.status >= 200 && r.status < 300,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Scenario 1A — GraphQL cursor-paginated sitemap drain.
|
|
2
|
+
// Each VU drains every entry of the chosen content type via {camelName}List.
|
|
3
|
+
// PERF_VUS scales concurrent read load, NOT parallel partitioning.
|
|
4
|
+
// Requires the perf DB pre-seeded (`pnpm perf:seed --size=N`) and the
|
|
5
|
+
// perf API key present (`pnpm prisma:seed:perf` once per fresh DB).
|
|
6
|
+
//
|
|
7
|
+
// PERF_LIST_FIELD overrides the list field name (default
|
|
8
|
+
// `perfArticleList` keeps the internal harness unchanged). The CLI
|
|
9
|
+
// (#126) sets this to the operator's content-type list field.
|
|
10
|
+
import http, { type RefinedResponse, type ResponseType } from 'k6/http';
|
|
11
|
+
import { check, fail } from 'k6';
|
|
12
|
+
import { loadK6Config } from '../lib/config-k6.ts';
|
|
13
|
+
import { apiKeyHeaders } from '../lib/auth-k6.ts';
|
|
14
|
+
import { drainLatency, drainWallClock } from '../lib/metrics-k6.ts';
|
|
15
|
+
|
|
16
|
+
const PAGE_SIZE = Number(__ENV.PERF_PAGE_SIZE ?? '100');
|
|
17
|
+
const VUS = Number(__ENV.PERF_VUS ?? '1');
|
|
18
|
+
const LIST_FIELD = __ENV.PERF_LIST_FIELD ?? 'perfArticleList';
|
|
19
|
+
|
|
20
|
+
export const options = {
|
|
21
|
+
scenarios: {
|
|
22
|
+
sitemap: {
|
|
23
|
+
executor: 'per-vu-iterations',
|
|
24
|
+
vus: VUS,
|
|
25
|
+
iterations: 1,
|
|
26
|
+
maxDuration: '10m',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
thresholds: {
|
|
30
|
+
http_req_failed: ['rate<0.01'],
|
|
31
|
+
perf_drain_page_ms: ['p(99)<2000'],
|
|
32
|
+
},
|
|
33
|
+
tags: {
|
|
34
|
+
scenario: 'sitemap',
|
|
35
|
+
page_size: String(PAGE_SIZE),
|
|
36
|
+
vus: String(VUS),
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const QUERY = `
|
|
41
|
+
query Items($first: Int!, $after: String) {
|
|
42
|
+
${LIST_FIELD}(first: $first, after: $after) {
|
|
43
|
+
edges { node { id } cursor }
|
|
44
|
+
pageInfo { endCursor hasNextPage }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
export default function sitemap() {
|
|
50
|
+
const cfg = loadK6Config();
|
|
51
|
+
const headers = apiKeyHeaders();
|
|
52
|
+
|
|
53
|
+
let cursor: string | null = null;
|
|
54
|
+
let pages = 0;
|
|
55
|
+
const start = Date.now();
|
|
56
|
+
|
|
57
|
+
while (true) {
|
|
58
|
+
const res: RefinedResponse<ResponseType | undefined> = http.post(
|
|
59
|
+
`${cfg.baseUrl}/api/graphql`,
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
query: QUERY,
|
|
62
|
+
variables: { first: PAGE_SIZE, after: cursor },
|
|
63
|
+
}),
|
|
64
|
+
{ headers, tags: { phase: 'drain' } }
|
|
65
|
+
);
|
|
66
|
+
const ok = check(res, {
|
|
67
|
+
'page 200': (r) => r.status === 200,
|
|
68
|
+
'has data': (r) => {
|
|
69
|
+
try {
|
|
70
|
+
const j = r.json() as {
|
|
71
|
+
data?: Record<string, { pageInfo?: unknown }>;
|
|
72
|
+
};
|
|
73
|
+
return Boolean(j.data?.[LIST_FIELD]?.pageInfo);
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
if (!ok) fail(`GraphQL page request failed: ${res.status}`);
|
|
80
|
+
drainLatency.add(res.timings.duration);
|
|
81
|
+
const body = res.json() as {
|
|
82
|
+
data?: Record<
|
|
83
|
+
string,
|
|
84
|
+
{ pageInfo?: { endCursor: string | null; hasNextPage: boolean } }
|
|
85
|
+
>;
|
|
86
|
+
};
|
|
87
|
+
const list = body.data?.[LIST_FIELD];
|
|
88
|
+
if (!list?.pageInfo) {
|
|
89
|
+
fail(`GraphQL response missing pageInfo for ${LIST_FIELD}`);
|
|
90
|
+
}
|
|
91
|
+
const page = list.pageInfo;
|
|
92
|
+
pages++;
|
|
93
|
+
if (!page.hasNextPage) break;
|
|
94
|
+
cursor = page.endCursor;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
drainWallClock.add(Date.now() - start);
|
|
98
|
+
console.log(`sitemap VU drained ${pages} pages in ${Date.now() - start}ms`);
|
|
99
|
+
}
|