cvx-kit 0.1.15 → 0.1.17
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/README.md +67 -40
- package/dist/querying/filters.d.mts +10 -1
- package/dist/querying/filters.mjs +1 -1
- package/dist/querying/filters.mjs.map +1 -1
- package/dist/querying/planner.d.mts +1 -0
- package/dist/querying/planner.mjs +1 -1
- package/dist/querying/planner.mjs.map +1 -1
- package/dist/querying/relationships.d.mts +1 -1
- package/dist/querying/relationships.mjs +1 -1
- package/dist/querying/relationships.mjs.map +1 -1
- package/dist/querying.d.mts +9 -8
- package/dist/querying.mjs.map +1 -1
- package/dist/relations-public.d.mts +2 -2
- package/dist/relations.d.mts +74 -25
- package/dist/relations.mjs +1 -1
- package/dist/relations.mjs.map +1 -1
- package/dist/zod-table.d.mts +59 -2
- package/dist/zod-table.mjs +1 -1
- package/dist/zod-table.mjs.map +1 -1
- package/package.json +12 -3
- package/AGENTS.md +0 -47
- package/CHANGELOG.md +0 -337
- package/ROADMAP.md +0 -126
- package/docs/agent-tools.md +0 -41
- package/docs/approvals.md +0 -201
- package/docs/architecture.md +0 -124
- package/docs/auth.md +0 -443
- package/docs/command-context.md +0 -150
- package/docs/commands-legacy.md +0 -513
- package/docs/commands.md +0 -173
- package/docs/conventions.md +0 -345
- package/docs/crud.md +0 -66
- package/docs/domain-contracts.md +0 -148
- package/docs/domain-operations.md +0 -615
- package/docs/effect.md +0 -403
- package/docs/llms.md +0 -324
- package/docs/maintainability.md +0 -124
- package/docs/migration.md +0 -351
- package/docs/oxlint.md +0 -260
- package/docs/querying.md +0 -329
- package/docs/tenancy.md +0 -176
- package/docs/triggers.md +0 -77
- package/docs/upgrading.md +0 -287
- package/docs/webhooks.md +0 -58
- package/docs/zod-table.md +0 -278
- package/scripts/convex-config-bridge.mjs +0 -44
- package/scripts/convex-smoke-env.mjs +0 -33
- package/scripts/extract-changelog.mjs +0 -29
- package/scripts/owned-process.mjs +0 -140
- package/scripts/owned-process.test.mjs +0 -107
- package/scripts/smoke-packed-components.mjs +0 -341
- package/scripts/smoke-packed-effect.mjs +0 -439
- package/scripts/smoke-packed-querying.mjs +0 -298
- package/scripts/test-packed-auth.mjs +0 -72
- package/scripts/test-packed-effect.mjs +0 -482
- package/scripts/test-packed-ids.mjs +0 -150
- package/scripts/test-packed-oxlint.mjs +0 -167
- package/scripts/test-packed-querying.mjs +0 -185
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs'
|
|
2
|
-
import { homedir } from 'node:os'
|
|
3
|
-
import { join } from 'node:path'
|
|
4
|
-
|
|
5
|
-
/** A smoke fixture must never inherit a cloud or self-hosted deployment selection. */
|
|
6
|
-
export function localConvexEnvironment(source = process.env) {
|
|
7
|
-
const env = Object.fromEntries(
|
|
8
|
-
Object.entries(source).filter(
|
|
9
|
-
([key]) =>
|
|
10
|
-
!key.startsWith('CONVEX_') && key !== 'NEXT_PUBLIC_CONVEX_URL' && key !== 'VITE_CONVEX_URL',
|
|
11
|
-
),
|
|
12
|
-
)
|
|
13
|
-
return { ...env, CONVEX_AGENT_MODE: 'anonymous' }
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** Convex 1.45 can fall back to a legacy home deployment with the same agent name. */
|
|
17
|
-
export function assertIsolatedConvexFixture(
|
|
18
|
-
fixture,
|
|
19
|
-
legacyState = join(homedir(), '.convex', 'anonymous-convex-backend-state', 'anonymous-agent'),
|
|
20
|
-
) {
|
|
21
|
-
const localConfig = join(fixture, '.convex', 'local', 'default', 'config.json')
|
|
22
|
-
if (!existsSync(localConfig) && existsSync(legacyState)) {
|
|
23
|
-
throw new Error(
|
|
24
|
-
'Local smoke cannot reuse legacy anonymous-agent state; an isolated fixture is required',
|
|
25
|
-
)
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function assertLocalConvexState(fixture) {
|
|
30
|
-
if (!existsSync(join(fixture, '.convex', 'local', 'default', 'config.json'))) {
|
|
31
|
-
throw new Error('Local smoke did not create project-local deployment state')
|
|
32
|
-
}
|
|
33
|
-
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
// Extracts one version's section from CHANGELOG.md (Keep a Changelog form).
|
|
2
|
-
// Usage: node scripts/extract-changelog.mjs 0.1.0 [outfile]
|
|
3
|
-
// Used by the release workflow for GitHub Release notes, and runnable
|
|
4
|
-
// locally BEFORE tagging to verify extraction (see the release plan).
|
|
5
|
-
import { readFileSync, writeFileSync } from 'node:fs'
|
|
6
|
-
|
|
7
|
-
const version = process.argv[2]
|
|
8
|
-
if (!version) {
|
|
9
|
-
console.error('usage: extract-changelog.mjs <version> [outfile]')
|
|
10
|
-
process.exit(1)
|
|
11
|
-
}
|
|
12
|
-
const changelog = readFileSync(new URL('../CHANGELOG.md', import.meta.url), 'utf8')
|
|
13
|
-
const pattern = new RegExp(
|
|
14
|
-
String.raw`^## \[${version.replace(/\./g, String.raw`\.`)}\][^\n]*\n([\s\S]*?)(?=^## \[|(?![\s\S]))`,
|
|
15
|
-
'm',
|
|
16
|
-
)
|
|
17
|
-
const match = changelog.match(pattern)
|
|
18
|
-
if (!match) {
|
|
19
|
-
console.error(`CHANGELOG.md has no section for version ${version}`)
|
|
20
|
-
process.exit(1)
|
|
21
|
-
}
|
|
22
|
-
const notes = match[1].trim()
|
|
23
|
-
if (!notes) {
|
|
24
|
-
console.error(`CHANGELOG.md section for ${version} is empty`)
|
|
25
|
-
process.exit(1)
|
|
26
|
-
}
|
|
27
|
-
const outfile = process.argv[3]
|
|
28
|
-
if (outfile) writeFileSync(outfile, `${notes}\n`)
|
|
29
|
-
else process.stdout.write(`${notes}\n`)
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process'
|
|
2
|
-
import { setTimeout as delay } from 'node:timers/promises'
|
|
3
|
-
|
|
4
|
-
export function processGroupAlive(pid) {
|
|
5
|
-
try {
|
|
6
|
-
process.kill(-pid, 0)
|
|
7
|
-
return true
|
|
8
|
-
} catch (error) {
|
|
9
|
-
if (error.code === 'ESRCH') return false
|
|
10
|
-
throw error
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
const owned = new Set()
|
|
14
|
-
export async function cleanupOwnedProcess(pid, graceMs = 1000) {
|
|
15
|
-
if (!owned.has(pid)) throw new Error('Process group is not owned by this runner')
|
|
16
|
-
const signals = [],
|
|
17
|
-
errors = []
|
|
18
|
-
for (const signal of ['SIGINT', 'SIGTERM', 'SIGKILL']) {
|
|
19
|
-
if (!processGroupAlive(pid)) break
|
|
20
|
-
try {
|
|
21
|
-
process.kill(-pid, signal)
|
|
22
|
-
signals.push(signal)
|
|
23
|
-
} catch (error) {
|
|
24
|
-
if (error.code !== 'ESRCH') errors.push({ signal, code: error.code })
|
|
25
|
-
}
|
|
26
|
-
const deadline = performance.now() + graceMs
|
|
27
|
-
while (processGroupAlive(pid) && performance.now() < deadline) await delay(20)
|
|
28
|
-
}
|
|
29
|
-
return { pid, groupId: pid, signals, errors, alive: processGroupAlive(pid) }
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export async function runOwnedProcess({
|
|
33
|
-
command,
|
|
34
|
-
args,
|
|
35
|
-
cwd,
|
|
36
|
-
env,
|
|
37
|
-
timeoutMs = 180000,
|
|
38
|
-
graceMs = 1000,
|
|
39
|
-
onSpawn = () => {},
|
|
40
|
-
}) {
|
|
41
|
-
const started = performance.now()
|
|
42
|
-
const child = spawn(command, args, { cwd, env, detached: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
43
|
-
let stdout = '',
|
|
44
|
-
stderr = '',
|
|
45
|
-
timedOut = false,
|
|
46
|
-
spawnError,
|
|
47
|
-
timeoutCleanup
|
|
48
|
-
child.stdout.on('data', (bytes) => {
|
|
49
|
-
stdout += bytes
|
|
50
|
-
})
|
|
51
|
-
child.stderr.on('data', (bytes) => {
|
|
52
|
-
stderr += bytes
|
|
53
|
-
})
|
|
54
|
-
const closed = new Promise((resolve) => {
|
|
55
|
-
child.once('error', (error) => {
|
|
56
|
-
spawnError = { code: error.code, message: error.message }
|
|
57
|
-
resolve({ code: null, signal: null })
|
|
58
|
-
})
|
|
59
|
-
child.once('close', (code, signal) => resolve({ code, signal }))
|
|
60
|
-
})
|
|
61
|
-
if (child.pid) {
|
|
62
|
-
owned.add(child.pid)
|
|
63
|
-
onSpawn(child.pid)
|
|
64
|
-
}
|
|
65
|
-
child.stdin.end()
|
|
66
|
-
let finishTimeout
|
|
67
|
-
const timeoutFinished = new Promise((resolve) => {
|
|
68
|
-
finishTimeout = resolve
|
|
69
|
-
})
|
|
70
|
-
const timer = setTimeout(() => {
|
|
71
|
-
timedOut = true
|
|
72
|
-
timeoutCleanup = child.pid
|
|
73
|
-
? cleanupOwnedProcess(child.pid, graceMs).catch((error) => ({
|
|
74
|
-
pid: child.pid,
|
|
75
|
-
alive: true,
|
|
76
|
-
error: { code: error.code, message: error.message },
|
|
77
|
-
}))
|
|
78
|
-
: Promise.resolve(null)
|
|
79
|
-
timeoutCleanup.then(() => finishTimeout({ code: child.exitCode, signal: child.signalCode }))
|
|
80
|
-
}, timeoutMs)
|
|
81
|
-
let exit, cleanup
|
|
82
|
-
try {
|
|
83
|
-
exit = await Promise.race([closed, timeoutFinished])
|
|
84
|
-
if (timeoutCleanup) await timeoutCleanup
|
|
85
|
-
} finally {
|
|
86
|
-
clearTimeout(timer)
|
|
87
|
-
if (child.pid) {
|
|
88
|
-
try {
|
|
89
|
-
cleanup = await cleanupOwnedProcess(child.pid, graceMs)
|
|
90
|
-
} catch (error) {
|
|
91
|
-
cleanup = {
|
|
92
|
-
pid: child.pid,
|
|
93
|
-
alive: true,
|
|
94
|
-
error: { code: error.code, message: error.message },
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
if (timedOut) {
|
|
100
|
-
child.stdout.destroy()
|
|
101
|
-
child.stderr.destroy()
|
|
102
|
-
child.unref()
|
|
103
|
-
}
|
|
104
|
-
return {
|
|
105
|
-
...exit,
|
|
106
|
-
spawnError,
|
|
107
|
-
stdout,
|
|
108
|
-
stderr,
|
|
109
|
-
timedOut,
|
|
110
|
-
pid: child.pid,
|
|
111
|
-
milliseconds: performance.now() - started,
|
|
112
|
-
timeoutCleanup: await timeoutCleanup,
|
|
113
|
-
cleanup,
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export async function collectOwnedCleanup({ pids, ports, checkPort, failure, graceMs = 1000 }) {
|
|
118
|
-
const groups = []
|
|
119
|
-
for (const pid of pids) {
|
|
120
|
-
try {
|
|
121
|
-
groups.push(await cleanupOwnedProcess(pid, graceMs))
|
|
122
|
-
} catch (error) {
|
|
123
|
-
groups.push({ pid, alive: true, error: { code: error.code, message: error.message } })
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
const portResults = []
|
|
127
|
-
for (const port of ports) {
|
|
128
|
-
try {
|
|
129
|
-
portResults.push({ port, closed: (await checkPort(port)) === port })
|
|
130
|
-
} catch (error) {
|
|
131
|
-
portResults.push({ port, closed: false, error: { code: error.code, message: error.message } })
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
return {
|
|
135
|
-
pids: groups.map(({ pid, alive }) => ({ pid, alive })),
|
|
136
|
-
failure: failure ? { name: failure.name, message: failure.message } : null,
|
|
137
|
-
groups,
|
|
138
|
-
ports: portResults,
|
|
139
|
-
}
|
|
140
|
-
}
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
// @vitest-environment node
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import { test } from 'vite-plus/test'
|
|
4
|
-
import { createServer } from 'node:net'
|
|
5
|
-
import { mkdtempSync, writeFileSync } from 'node:fs'
|
|
6
|
-
import { join } from 'node:path'
|
|
7
|
-
import {
|
|
8
|
-
runOwnedProcess,
|
|
9
|
-
cleanupOwnedProcess,
|
|
10
|
-
processGroupAlive,
|
|
11
|
-
collectOwnedCleanup,
|
|
12
|
-
} from './owned-process.mjs'
|
|
13
|
-
|
|
14
|
-
const proof = mkdtempSync('/tmp/cvx-owned-process-')
|
|
15
|
-
console.log('Owned cleanup evidence: ' + proof)
|
|
16
|
-
|
|
17
|
-
const descendant = `process.on('SIGINT',()=>{}); process.on('SIGTERM',()=>{}); console.log('ready'); setInterval(()=>{},1000)`
|
|
18
|
-
const parent = `
|
|
19
|
-
const {spawn}=require('node:child_process');
|
|
20
|
-
const child=spawn(process.execPath,['-e',${JSON.stringify(descendant)}],{stdio:['ignore','pipe','ignore']});
|
|
21
|
-
process.on('SIGINT',()=>{}); process.on('SIGTERM',()=>{});
|
|
22
|
-
child.stdout.once('data',()=>{console.log('descendant:'+child.pid); MODE});
|
|
23
|
-
setInterval(()=>{},1000);
|
|
24
|
-
`
|
|
25
|
-
|
|
26
|
-
test('timeout terminates the entire owned group after ignored graceful signals', async () => {
|
|
27
|
-
let pid
|
|
28
|
-
try {
|
|
29
|
-
const result = await runOwnedProcess({
|
|
30
|
-
command: process.execPath,
|
|
31
|
-
args: ['-e', parent.replace('MODE', '')],
|
|
32
|
-
timeoutMs: 1500,
|
|
33
|
-
graceMs: 150,
|
|
34
|
-
onSpawn: (value) => {
|
|
35
|
-
pid = value
|
|
36
|
-
},
|
|
37
|
-
})
|
|
38
|
-
writeFileSync(join(proof, 'timeout.json'), JSON.stringify(result, null, 2))
|
|
39
|
-
assert.match(result.stdout, /descendant:\d+/)
|
|
40
|
-
assert.equal(result.timedOut, true)
|
|
41
|
-
assert.ok(result.timeoutCleanup.signals.includes('SIGKILL'))
|
|
42
|
-
assert.equal(result.cleanup.alive, false)
|
|
43
|
-
assert.equal(processGroupAlive(pid), false)
|
|
44
|
-
} finally {
|
|
45
|
-
if (pid) await cleanupOwnedProcess(pid, 150)
|
|
46
|
-
}
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
test('failed leader preserves failure output and finally cleans its surviving descendant', async () => {
|
|
50
|
-
let pid
|
|
51
|
-
try {
|
|
52
|
-
const result = await runOwnedProcess({
|
|
53
|
-
command: process.execPath,
|
|
54
|
-
args: ['-e', parent.replace('MODE', "console.error('original failure'); process.exit(7)")],
|
|
55
|
-
timeoutMs: 1500,
|
|
56
|
-
graceMs: 150,
|
|
57
|
-
onSpawn: (value) => {
|
|
58
|
-
pid = value
|
|
59
|
-
},
|
|
60
|
-
})
|
|
61
|
-
writeFileSync(join(proof, 'failed-leader.json'), JSON.stringify(result, null, 2))
|
|
62
|
-
assert.equal(result.code, 7)
|
|
63
|
-
assert.equal(result.timedOut, false)
|
|
64
|
-
assert.match(result.stderr, /original failure/)
|
|
65
|
-
assert.equal(result.cleanup.alive, false)
|
|
66
|
-
assert.equal(processGroupAlive(pid), false)
|
|
67
|
-
} finally {
|
|
68
|
-
if (pid) await cleanupOwnedProcess(pid, 150)
|
|
69
|
-
}
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
test('cleanup rejects a group that was not created by the runner', async () => {
|
|
73
|
-
await assert.rejects(cleanupOwnedProcess(process.pid, 20), /not owned/)
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
test('port-check failure is recorded without replacing original proof failure', async () => {
|
|
77
|
-
const server = createServer()
|
|
78
|
-
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
79
|
-
const port = server.address().port
|
|
80
|
-
try {
|
|
81
|
-
const result = await runOwnedProcess({
|
|
82
|
-
command: process.execPath,
|
|
83
|
-
args: ['-e', 'process.exit(7)'],
|
|
84
|
-
timeoutMs: 1000,
|
|
85
|
-
})
|
|
86
|
-
const failure = new Error('original proof failure: exit ' + result.code)
|
|
87
|
-
const evidence = await collectOwnedCleanup({
|
|
88
|
-
pids: [result.pid],
|
|
89
|
-
ports: [port],
|
|
90
|
-
failure,
|
|
91
|
-
checkPort: (requested) =>
|
|
92
|
-
new Promise((resolve, reject) => {
|
|
93
|
-
const probe = createServer()
|
|
94
|
-
probe.once('error', reject)
|
|
95
|
-
probe.listen(requested, '127.0.0.1', () => probe.close(() => resolve(requested)))
|
|
96
|
-
}),
|
|
97
|
-
})
|
|
98
|
-
writeFileSync(join(proof, 'port-failure.json'), JSON.stringify(evidence, null, 2))
|
|
99
|
-
assert.equal(evidence.failure.message, 'original proof failure: exit 7')
|
|
100
|
-
assert.equal(evidence.ports[0].closed, false)
|
|
101
|
-
assert.equal(evidence.ports[0].error.code, 'EADDRINUSE')
|
|
102
|
-
assert.equal(evidence.groups[0].alive, false)
|
|
103
|
-
assert.equal(server.listening, true, 'unrelated listener must never be killed')
|
|
104
|
-
} finally {
|
|
105
|
-
await new Promise((resolve) => server.close(resolve))
|
|
106
|
-
}
|
|
107
|
-
})
|
|
@@ -1,341 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process'
|
|
2
|
-
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
-
import { tmpdir } from 'node:os'
|
|
4
|
-
import { join } from 'node:path'
|
|
5
|
-
import {
|
|
6
|
-
assertIsolatedConvexFixture,
|
|
7
|
-
assertLocalConvexState,
|
|
8
|
-
localConvexEnvironment,
|
|
9
|
-
} from './convex-smoke-env.mjs'
|
|
10
|
-
|
|
11
|
-
const installer = process.argv[2] ?? 'bun'
|
|
12
|
-
if (installer !== 'bun' && installer !== 'npm') {
|
|
13
|
-
throw new Error('Usage: smoke-packed-components.mjs [bun|npm]')
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const root = join(import.meta.dirname, '..')
|
|
17
|
-
const temporaryRoot = mkdtempSync(join(tmpdir(), 'cvx-kit-smoke-'))
|
|
18
|
-
const fixture = join(temporaryRoot, 'fixture')
|
|
19
|
-
const packageManifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
20
|
-
const packageVersion = packageManifest.version
|
|
21
|
-
const tarball = join(temporaryRoot, `cvx-kit-${packageVersion}.tgz`)
|
|
22
|
-
|
|
23
|
-
function run(command, args, cwd = fixture) {
|
|
24
|
-
return execFileSync(command, args, {
|
|
25
|
-
cwd,
|
|
26
|
-
encoding: 'utf8',
|
|
27
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
28
|
-
env: localConvexEnvironment(),
|
|
29
|
-
})
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function runStreaming(command, args, cwd = fixture) {
|
|
33
|
-
execFileSync(command, args, {
|
|
34
|
-
cwd,
|
|
35
|
-
stdio: 'inherit',
|
|
36
|
-
env: localConvexEnvironment(),
|
|
37
|
-
})
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function runConvex(...args) {
|
|
41
|
-
return run('node', [join(fixture, 'node_modules', 'convex', 'bin', 'main.js'), ...args])
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function write(relativePath, contents) {
|
|
45
|
-
const path = join(fixture, relativePath)
|
|
46
|
-
writeFileSync(path, contents)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function step(label, callback) {
|
|
50
|
-
console.log(`smoke step: ${label}`)
|
|
51
|
-
return callback()
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
try {
|
|
55
|
-
run('bun', ['pm', 'pack', '--destination', temporaryRoot, '--ignore-scripts'], root)
|
|
56
|
-
mkdirSync(join(fixture, 'convex'), { recursive: true })
|
|
57
|
-
write(
|
|
58
|
-
'package.json',
|
|
59
|
-
JSON.stringify(
|
|
60
|
-
{
|
|
61
|
-
private: true,
|
|
62
|
-
type: 'module',
|
|
63
|
-
dependencies: {
|
|
64
|
-
'cvx-kit': `file:${tarball}`,
|
|
65
|
-
convex: packageManifest.devDependencies.convex,
|
|
66
|
-
zod: packageManifest.devDependencies.zod,
|
|
67
|
-
'convex-helpers': packageManifest.devDependencies['convex-helpers'],
|
|
68
|
-
},
|
|
69
|
-
devDependencies: {
|
|
70
|
-
'vite-plus': packageManifest.devDependencies['vite-plus'],
|
|
71
|
-
},
|
|
72
|
-
},
|
|
73
|
-
null,
|
|
74
|
-
2,
|
|
75
|
-
),
|
|
76
|
-
)
|
|
77
|
-
write(
|
|
78
|
-
'packed-test-helper.test.ts',
|
|
79
|
-
`import { describe, expect, it } from 'vite-plus/test'
|
|
80
|
-
import { registerApprovals, registerFoundation } from 'cvx-kit/test'
|
|
81
|
-
|
|
82
|
-
describe('packed cvx-kit test helpers', () => {
|
|
83
|
-
it('registers both compiled component bundles and their discoverable schemas', () => {
|
|
84
|
-
const registrations = []
|
|
85
|
-
const testRuntime = {
|
|
86
|
-
registerComponent(name, schema, modules) {
|
|
87
|
-
registrations.push({ name, schema, modules })
|
|
88
|
-
},
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
registerApprovals(testRuntime)
|
|
92
|
-
registerFoundation(testRuntime)
|
|
93
|
-
|
|
94
|
-
expect(registrations.map(({ name }) => name)).toEqual(['approvals', 'foundation'])
|
|
95
|
-
expect(Object.keys(registrations[0].schema.tables).sort()).toEqual([
|
|
96
|
-
'approvalDecisions',
|
|
97
|
-
'approvalRuns',
|
|
98
|
-
])
|
|
99
|
-
expect(Object.keys(registrations[0].modules)).toContain(
|
|
100
|
-
'../dist/components/approvals/health.mjs',
|
|
101
|
-
)
|
|
102
|
-
})
|
|
103
|
-
})
|
|
104
|
-
`,
|
|
105
|
-
)
|
|
106
|
-
write(
|
|
107
|
-
'convex/convex.config.ts',
|
|
108
|
-
`import { defineApp } from 'convex/server'
|
|
109
|
-
import approvals from 'cvx-kit/components/approvals/convex.config'
|
|
110
|
-
import foundation from 'cvx-kit/components/foundation/convex.config'
|
|
111
|
-
|
|
112
|
-
const app = defineApp()
|
|
113
|
-
app.use(approvals)
|
|
114
|
-
app.use(foundation)
|
|
115
|
-
export default app
|
|
116
|
-
`,
|
|
117
|
-
)
|
|
118
|
-
write(
|
|
119
|
-
'convex/smoke.ts',
|
|
120
|
-
`import { approvalCallbackArgs } from 'cvx-kit/components/approvals'
|
|
121
|
-
import { createFunctionHandle, paginationOptsValidator } from 'convex/server'
|
|
122
|
-
import { v } from 'convex/values'
|
|
123
|
-
import { components, internal } from './_generated/api'
|
|
124
|
-
import { internalMutation, mutation, query } from './_generated/server'
|
|
125
|
-
|
|
126
|
-
export const applyDecision = internalMutation({
|
|
127
|
-
args: approvalCallbackArgs,
|
|
128
|
-
returns: v.null(),
|
|
129
|
-
handler: (_ctx, input) => {
|
|
130
|
-
if (input.decision?.evidence[0]?.stepKey !== 'releaseDecision') {
|
|
131
|
-
throw new Error('Missing callback decision step key')
|
|
132
|
-
}
|
|
133
|
-
return null
|
|
134
|
-
},
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
export const rejectDecision = internalMutation({
|
|
138
|
-
args: approvalCallbackArgs,
|
|
139
|
-
returns: v.null(),
|
|
140
|
-
handler: () => null,
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
export const health = query({
|
|
144
|
-
args: {},
|
|
145
|
-
returns: v.any(),
|
|
146
|
-
handler: (ctx) => ctx.runQuery(components.approvals.health.check, {}),
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
export const foundationHealth = query({
|
|
150
|
-
args: {},
|
|
151
|
-
returns: v.literal('ready'),
|
|
152
|
-
handler: (ctx) => ctx.runQuery(components.foundation.functions.status, {}),
|
|
153
|
-
})
|
|
154
|
-
|
|
155
|
-
export const start = mutation({
|
|
156
|
-
args: { resourceRef: v.optional(v.string()) },
|
|
157
|
-
returns: v.object({ runId: v.string() }),
|
|
158
|
-
handler: async (ctx, args) => {
|
|
159
|
-
const approvedHandle = await createFunctionHandle(internal.smoke.applyDecision)
|
|
160
|
-
const rejectedHandle = await createFunctionHandle(internal.smoke.rejectDecision)
|
|
161
|
-
return ctx.runMutation(components.approvals.requests.start, {
|
|
162
|
-
scopeRef: 'smoke',
|
|
163
|
-
resourceType: 'release',
|
|
164
|
-
resourceRef: args.resourceRef ?? 'packed-component',
|
|
165
|
-
requester: { actorRef: 'requester', capabilities: [] },
|
|
166
|
-
workflow: {
|
|
167
|
-
schemaVersion: 1,
|
|
168
|
-
compatibilityKey: 'packedSmoke',
|
|
169
|
-
name: 'packedSmoke',
|
|
170
|
-
steps: [
|
|
171
|
-
{
|
|
172
|
-
kind: 'decision',
|
|
173
|
-
key: 'releaseDecision',
|
|
174
|
-
decisions: ['approved', 'rejected'],
|
|
175
|
-
quorum: { kind: 'count', approvals: 1 },
|
|
176
|
-
makerChecker: true,
|
|
177
|
-
},
|
|
178
|
-
{
|
|
179
|
-
kind: 'branch',
|
|
180
|
-
key: 'routeDecision',
|
|
181
|
-
approvedStepKey: 'applyDecision',
|
|
182
|
-
rejectedStepKey: 'rejectDecision',
|
|
183
|
-
},
|
|
184
|
-
{
|
|
185
|
-
kind: 'mutation',
|
|
186
|
-
key: 'applyDecision',
|
|
187
|
-
callback: { kind: 'mutation', handle: approvedHandle, retry: false },
|
|
188
|
-
},
|
|
189
|
-
{
|
|
190
|
-
kind: 'mutation',
|
|
191
|
-
key: 'rejectDecision',
|
|
192
|
-
callback: { kind: 'mutation', handle: rejectedHandle, retry: false },
|
|
193
|
-
},
|
|
194
|
-
],
|
|
195
|
-
},
|
|
196
|
-
})
|
|
197
|
-
},
|
|
198
|
-
})
|
|
199
|
-
|
|
200
|
-
export const list = query({
|
|
201
|
-
args: {
|
|
202
|
-
paginationOpts: paginationOptsValidator,
|
|
203
|
-
state: v.optional(v.union(v.literal('pending'), v.literal('approved'))),
|
|
204
|
-
},
|
|
205
|
-
returns: v.any(),
|
|
206
|
-
handler: (ctx, args) => ctx.runQuery(components.approvals.requests.list, {
|
|
207
|
-
scopeRef: 'smoke',
|
|
208
|
-
...args,
|
|
209
|
-
}),
|
|
210
|
-
})
|
|
211
|
-
|
|
212
|
-
export const decide = mutation({
|
|
213
|
-
args: { runId: v.string() },
|
|
214
|
-
returns: v.any(),
|
|
215
|
-
handler: (ctx, args) => ctx.runMutation(components.approvals.decisions.decide, {
|
|
216
|
-
runId: args.runId,
|
|
217
|
-
decision: 'approved',
|
|
218
|
-
actor: { actorRef: 'approver', capabilities: [] },
|
|
219
|
-
compatibilityKey: 'packedSmoke',
|
|
220
|
-
}),
|
|
221
|
-
})
|
|
222
|
-
|
|
223
|
-
export const history = query({
|
|
224
|
-
args: { runId: v.string() },
|
|
225
|
-
returns: v.any(),
|
|
226
|
-
handler: (ctx, args) => ctx.runQuery(components.approvals.decisions.list, args),
|
|
227
|
-
})
|
|
228
|
-
|
|
229
|
-
export const status = query({
|
|
230
|
-
args: { runId: v.string() },
|
|
231
|
-
returns: v.any(),
|
|
232
|
-
handler: (ctx, args) => ctx.runQuery(components.approvals.requests.status, {
|
|
233
|
-
...args,
|
|
234
|
-
compatibilityKey: 'packedSmoke',
|
|
235
|
-
}),
|
|
236
|
-
})
|
|
237
|
-
`,
|
|
238
|
-
)
|
|
239
|
-
|
|
240
|
-
if (installer === 'bun') {
|
|
241
|
-
copyFileSync(join(root, 'bun.lock'), join(fixture, 'bun.lock'))
|
|
242
|
-
}
|
|
243
|
-
if (installer === 'bun')
|
|
244
|
-
step('install with bun', () => runStreaming('bun', ['install', '--ignore-scripts']))
|
|
245
|
-
else step('install with npm', () => runStreaming('npm', ['install', '--ignore-scripts']))
|
|
246
|
-
if (installer === 'bun')
|
|
247
|
-
step('test helper with bun', () =>
|
|
248
|
-
runStreaming('bunx', ['vp', 'test', 'run', 'packed-test-helper.test.ts']),
|
|
249
|
-
)
|
|
250
|
-
else
|
|
251
|
-
step('test helper with npm', () =>
|
|
252
|
-
runStreaming('npx', ['vp', 'test', 'run', 'packed-test-helper.test.ts']),
|
|
253
|
-
)
|
|
254
|
-
|
|
255
|
-
assertIsolatedConvexFixture(fixture)
|
|
256
|
-
step('deploy Convex fixture', () => runConvex('dev', '--once', '--typecheck=disable'))
|
|
257
|
-
assertLocalConvexState(fixture)
|
|
258
|
-
const health = JSON.parse(runConvex('run', 'smoke:health'))
|
|
259
|
-
if (health.status !== 'ready' || health.schemaVersion !== 1) {
|
|
260
|
-
throw new Error(`Unexpected approvals health response: ${JSON.stringify(health)}`)
|
|
261
|
-
}
|
|
262
|
-
const foundationHealth = JSON.parse(runConvex('run', 'smoke:foundationHealth'))
|
|
263
|
-
if (foundationHealth !== 'ready') {
|
|
264
|
-
throw new Error(`Unexpected foundation health response: ${JSON.stringify(foundationHealth)}`)
|
|
265
|
-
}
|
|
266
|
-
const { runId } = JSON.parse(runConvex('run', 'smoke:start'))
|
|
267
|
-
const { runId: secondRunId } = JSON.parse(
|
|
268
|
-
runConvex('run', 'smoke:start', JSON.stringify({ resourceRef: 'packed-component-2' })),
|
|
269
|
-
)
|
|
270
|
-
const firstPage = JSON.parse(
|
|
271
|
-
runConvex(
|
|
272
|
-
'run',
|
|
273
|
-
'smoke:list',
|
|
274
|
-
JSON.stringify({
|
|
275
|
-
state: 'pending',
|
|
276
|
-
paginationOpts: { cursor: null, numItems: 1 },
|
|
277
|
-
}),
|
|
278
|
-
),
|
|
279
|
-
)
|
|
280
|
-
if (firstPage.page.length !== 1 || firstPage.page[0]._id !== secondRunId) {
|
|
281
|
-
throw new Error(`Unexpected first approval page: ${JSON.stringify(firstPage)}`)
|
|
282
|
-
}
|
|
283
|
-
const secondPage = JSON.parse(
|
|
284
|
-
runConvex(
|
|
285
|
-
'run',
|
|
286
|
-
'smoke:list',
|
|
287
|
-
JSON.stringify({
|
|
288
|
-
state: 'pending',
|
|
289
|
-
paginationOpts: {
|
|
290
|
-
cursor: firstPage.continueCursor,
|
|
291
|
-
numItems: 10,
|
|
292
|
-
},
|
|
293
|
-
}),
|
|
294
|
-
),
|
|
295
|
-
)
|
|
296
|
-
if (
|
|
297
|
-
secondPage.page.length !== 1 ||
|
|
298
|
-
secondPage.page[0]._id !== runId ||
|
|
299
|
-
secondPage.page[0]._id === firstPage.page[0]._id
|
|
300
|
-
) {
|
|
301
|
-
throw new Error(`Unexpected continuation approval page: ${JSON.stringify(secondPage)}`)
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// start schedules the workflow; a successful start is not decision readiness.
|
|
305
|
-
let readyStatus
|
|
306
|
-
const readyDeadline = Date.now() + 60_000
|
|
307
|
-
while (Date.now() < readyDeadline) {
|
|
308
|
-
readyStatus = JSON.parse(runConvex('run', 'smoke:status', JSON.stringify({ runId })))
|
|
309
|
-
if (
|
|
310
|
-
readyStatus?.run.state !== 'pending' ||
|
|
311
|
-
['failed', 'canceled', 'completed'].includes(readyStatus?.execution?.type)
|
|
312
|
-
) {
|
|
313
|
-
throw new Error(`Approval ended before decision readiness: ${JSON.stringify(readyStatus)}`)
|
|
314
|
-
}
|
|
315
|
-
if (readyStatus.run.currentStepKey === 'releaseDecision') break
|
|
316
|
-
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
317
|
-
}
|
|
318
|
-
if (readyStatus?.run.currentStepKey !== 'releaseDecision') {
|
|
319
|
-
throw new Error(`Approval did not become decision-ready: ${JSON.stringify(readyStatus)}`)
|
|
320
|
-
}
|
|
321
|
-
const decision = JSON.parse(runConvex('run', 'smoke:decide', JSON.stringify({ runId })))
|
|
322
|
-
if (decision.state !== 'approved') {
|
|
323
|
-
throw new Error(`Unexpected decision response: ${JSON.stringify(decision)}`)
|
|
324
|
-
}
|
|
325
|
-
const history = JSON.parse(runConvex('run', 'smoke:history', JSON.stringify({ runId })))
|
|
326
|
-
if (history.length !== 1 || history[0].decision !== 'approved') {
|
|
327
|
-
throw new Error(`Unexpected approval history: ${JSON.stringify(history)}`)
|
|
328
|
-
}
|
|
329
|
-
let status
|
|
330
|
-
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
331
|
-
status = JSON.parse(runConvex('run', 'smoke:status', JSON.stringify({ runId })))
|
|
332
|
-
if (status.execution?.type === 'completed') break
|
|
333
|
-
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
334
|
-
}
|
|
335
|
-
if (status?.execution?.type !== 'completed') {
|
|
336
|
-
throw new Error(`Approval callback did not complete: ${JSON.stringify(status)}`)
|
|
337
|
-
}
|
|
338
|
-
console.log(`packed component smoke passed with ${installer}`)
|
|
339
|
-
} finally {
|
|
340
|
-
rmSync(temporaryRoot, { recursive: true, force: true })
|
|
341
|
-
}
|