cvx-kit 0.0.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 +21 -0
- package/README.md +200 -0
- package/dist/_virtual/_rolldown/runtime.mjs +1 -0
- package/dist/auth.d.mts +353 -0
- package/dist/auth.mjs +2 -0
- package/dist/auth.mjs.map +1 -0
- package/dist/components/approvals/_generated/api.d.mts +47 -0
- package/dist/components/approvals/_generated/api.mjs +2 -0
- package/dist/components/approvals/_generated/api.mjs.map +1 -0
- package/dist/components/approvals/_generated/component.d.mts +297 -0
- package/dist/components/approvals/_generated/component.mjs +1 -0
- package/dist/components/approvals/_generated/dataModel.d.mts +41 -0
- package/dist/components/approvals/_generated/dataModel.mjs +1 -0
- package/dist/components/approvals/_generated/server.d.mts +116 -0
- package/dist/components/approvals/_generated/server.mjs +2 -0
- package/dist/components/approvals/_generated/server.mjs.map +1 -0
- package/dist/components/approvals/audit.d.mts +40 -0
- package/dist/components/approvals/audit.mjs +2 -0
- package/dist/components/approvals/audit.mjs.map +1 -0
- package/dist/components/approvals/client.d.mts +98 -0
- package/dist/components/approvals/client.mjs +2 -0
- package/dist/components/approvals/client.mjs.map +1 -0
- package/dist/components/approvals/constants.d.mts +31 -0
- package/dist/components/approvals/constants.mjs +2 -0
- package/dist/components/approvals/constants.mjs.map +1 -0
- package/dist/components/approvals/convex.config.d.mts +5 -0
- package/dist/components/approvals/convex.config.mjs +2 -0
- package/dist/components/approvals/convex.config.mjs.map +1 -0
- package/dist/components/approvals/decisions.d.mts +61 -0
- package/dist/components/approvals/decisions.mjs +2 -0
- package/dist/components/approvals/decisions.mjs.map +1 -0
- package/dist/components/approvals/functions.d.mts +9 -0
- package/dist/components/approvals/functions.mjs +2 -0
- package/dist/components/approvals/functions.mjs.map +1 -0
- package/dist/components/approvals/requests.d.mts +323 -0
- package/dist/components/approvals/requests.mjs +2 -0
- package/dist/components/approvals/requests.mjs.map +1 -0
- package/dist/components/approvals/schema.d.mts +376 -0
- package/dist/components/approvals/schema.mjs +2 -0
- package/dist/components/approvals/schema.mjs.map +1 -0
- package/dist/components/approvals/validators.d.mts +302 -0
- package/dist/components/approvals/validators.mjs +2 -0
- package/dist/components/approvals/validators.mjs.map +1 -0
- package/dist/components/approvals/workflow.d.mts +11 -0
- package/dist/components/approvals/workflow.mjs +2 -0
- package/dist/components/approvals/workflow.mjs.map +1 -0
- package/dist/components/approvals/workflow_steps.d.mts +83 -0
- package/dist/components/approvals/workflow_steps.mjs +2 -0
- package/dist/components/approvals/workflow_steps.mjs.map +1 -0
- package/dist/components/foundation/_generated/api.d.mts +40 -0
- package/dist/components/foundation/_generated/api.mjs +2 -0
- package/dist/components/foundation/_generated/api.mjs.map +1 -0
- package/dist/components/foundation/_generated/component.d.mts +21 -0
- package/dist/components/foundation/_generated/component.mjs +1 -0
- package/dist/components/foundation/_generated/dataModel.d.mts +41 -0
- package/dist/components/foundation/_generated/dataModel.mjs +1 -0
- package/dist/components/foundation/_generated/server.d.mts +116 -0
- package/dist/components/foundation/_generated/server.mjs +2 -0
- package/dist/components/foundation/_generated/server.mjs.map +1 -0
- package/dist/components/foundation/client.d.mts +93 -0
- package/dist/components/foundation/client.mjs +2 -0
- package/dist/components/foundation/client.mjs.map +1 -0
- package/dist/components/foundation/convex.config.d.mts +5 -0
- package/dist/components/foundation/convex.config.mjs +2 -0
- package/dist/components/foundation/convex.config.mjs.map +1 -0
- package/dist/components/foundation/functions.d.mts +8 -0
- package/dist/components/foundation/functions.mjs +2 -0
- package/dist/components/foundation/functions.mjs.map +1 -0
- package/dist/components/foundation/modules/command/command.d.mts +47 -0
- package/dist/components/foundation/modules/command/command.mjs +2 -0
- package/dist/components/foundation/modules/command/command.mjs.map +1 -0
- package/dist/components/foundation/modules/observability/observability.d.mts +31 -0
- package/dist/components/foundation/modules/observability/observability.mjs +2 -0
- package/dist/components/foundation/modules/observability/observability.mjs.map +1 -0
- package/dist/components/foundation/modules/query/query.d.mts +24 -0
- package/dist/components/foundation/modules/query/query.mjs +2 -0
- package/dist/components/foundation/modules/query/query.mjs.map +1 -0
- package/dist/components/foundation/result.d.mts +27 -0
- package/dist/components/foundation/result.mjs +2 -0
- package/dist/components/foundation/result.mjs.map +1 -0
- package/dist/components/foundation/schema.d.mts +6 -0
- package/dist/components/foundation/schema.mjs +2 -0
- package/dist/components/foundation/schema.mjs.map +1 -0
- package/dist/components/foundation/telemetry.d.mts +9 -0
- package/dist/components/foundation/telemetry.mjs +2 -0
- package/dist/components/foundation/telemetry.mjs.map +1 -0
- package/dist/errors.d.mts +24 -0
- package/dist/errors.mjs +2 -0
- package/dist/errors.mjs.map +1 -0
- package/dist/index.d.mts +6 -0
- package/dist/index.mjs +1 -0
- package/dist/triggers.d.mts +27 -0
- package/dist/triggers.mjs +2 -0
- package/dist/triggers.mjs.map +1 -0
- package/dist/zod-table.d.mts +124 -0
- package/dist/zod-table.mjs +2 -0
- package/dist/zod-table.mjs.map +1 -0
- package/package.json +108 -0
- package/src/auth.ts +360 -0
- package/src/components/approvals/_generated/api.ts +69 -0
- package/src/components/approvals/_generated/component.ts +335 -0
- package/src/components/approvals/_generated/dataModel.ts +60 -0
- package/src/components/approvals/_generated/server.ts +156 -0
- package/src/components/approvals/audit.ts +148 -0
- package/src/components/approvals/client.ts +321 -0
- package/src/components/approvals/constants.ts +51 -0
- package/src/components/approvals/convex.config.ts +10 -0
- package/src/components/approvals/decisions.ts +376 -0
- package/src/components/approvals/functions.ts +27 -0
- package/src/components/approvals/requests.ts +240 -0
- package/src/components/approvals/schema.ts +23 -0
- package/src/components/approvals/validators.ts +272 -0
- package/src/components/approvals/workflow.ts +204 -0
- package/src/components/approvals/workflow_steps.ts +189 -0
- package/src/components/foundation/_generated/api.ts +62 -0
- package/src/components/foundation/_generated/component.ts +29 -0
- package/src/components/foundation/_generated/dataModel.ts +60 -0
- package/src/components/foundation/_generated/server.ts +156 -0
- package/src/components/foundation/client.ts +190 -0
- package/src/components/foundation/convex.config.ts +3 -0
- package/src/components/foundation/functions.ts +10 -0
- package/src/components/foundation/modules/command/__tests__/command.test.ts +106 -0
- package/src/components/foundation/modules/command/command.ts +154 -0
- package/src/components/foundation/modules/observability/__tests__/observability.test.ts +79 -0
- package/src/components/foundation/modules/observability/observability.ts +95 -0
- package/src/components/foundation/modules/query/__tests__/query.test.ts +25 -0
- package/src/components/foundation/modules/query/query.ts +47 -0
- package/src/components/foundation/result.ts +77 -0
- package/src/components/foundation/schema.ts +4 -0
- package/src/components/foundation/telemetry.ts +23 -0
- package/src/errors.ts +25 -0
- package/src/index.ts +41 -0
- package/src/test.ts +36 -0
- package/src/triggers.ts +90 -0
- package/src/zod-table.ts +197 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
export type Parseable<Output> = Readonly<{ parse: (value: unknown) => Output }>
|
|
2
|
+
|
|
3
|
+
export type CommandRegistry = Readonly<Record<string, CommandDefinition>>
|
|
4
|
+
|
|
5
|
+
export type CommandInput<
|
|
6
|
+
Registry extends CommandRegistry,
|
|
7
|
+
Key extends Operation<Registry>,
|
|
8
|
+
> = Parsed<Registry[Key]['command']>
|
|
9
|
+
|
|
10
|
+
export type CommandResult<
|
|
11
|
+
Registry extends CommandRegistry,
|
|
12
|
+
Key extends Operation<Registry>,
|
|
13
|
+
> = Parsed<Registry[Key]['result']>
|
|
14
|
+
|
|
15
|
+
export type CommandExecution<
|
|
16
|
+
Context,
|
|
17
|
+
Registry extends CommandRegistry,
|
|
18
|
+
Key extends Operation<Registry>,
|
|
19
|
+
> = Readonly<{
|
|
20
|
+
context: Context
|
|
21
|
+
operation: Key
|
|
22
|
+
definition: Registry[Key]
|
|
23
|
+
command: CommandInput<Registry, Key>
|
|
24
|
+
parseResult: (value: unknown) => CommandResult<Registry, Key>
|
|
25
|
+
run: () => Promise<CommandResult<Registry, Key>>
|
|
26
|
+
}>
|
|
27
|
+
|
|
28
|
+
type CommandDefinition = Readonly<{
|
|
29
|
+
command: Parseable<unknown>
|
|
30
|
+
result: Parseable<unknown>
|
|
31
|
+
}>
|
|
32
|
+
|
|
33
|
+
type Operation<Registry extends CommandRegistry> = Extract<
|
|
34
|
+
keyof Registry,
|
|
35
|
+
string
|
|
36
|
+
>
|
|
37
|
+
type Parsed<Schema> = Schema extends Parseable<infer Output> ? Output : never
|
|
38
|
+
type MaybePromise<Value> = Value | Promise<Value>
|
|
39
|
+
type Execute<Context, Registry extends CommandRegistry> = <
|
|
40
|
+
Key extends Operation<Registry>,
|
|
41
|
+
>(
|
|
42
|
+
execution: CommandExecution<Context, Registry, Key>,
|
|
43
|
+
) => Promise<CommandResult<Registry, Key>>
|
|
44
|
+
|
|
45
|
+
type FixedExecutor<
|
|
46
|
+
Context,
|
|
47
|
+
Registry extends CommandRegistry,
|
|
48
|
+
Key extends Operation<Registry>,
|
|
49
|
+
> = Readonly<{
|
|
50
|
+
operation: Key
|
|
51
|
+
handler: (
|
|
52
|
+
context: Context,
|
|
53
|
+
command: CommandInput<Registry, Key>,
|
|
54
|
+
) => MaybePromise<CommandResult<Registry, Key>>
|
|
55
|
+
}>
|
|
56
|
+
|
|
57
|
+
type DynamicExecutor<
|
|
58
|
+
Context,
|
|
59
|
+
Registry extends CommandRegistry,
|
|
60
|
+
Dispatcher,
|
|
61
|
+
Key extends Operation<Registry>,
|
|
62
|
+
> = Readonly<{
|
|
63
|
+
dispatcher: Parseable<Dispatcher>
|
|
64
|
+
select: (dispatcher: Dispatcher) => Key
|
|
65
|
+
handler: (
|
|
66
|
+
context: Context,
|
|
67
|
+
command: CommandInput<Registry, Key>,
|
|
68
|
+
) => MaybePromise<CommandResult<Registry, Key>>
|
|
69
|
+
}>
|
|
70
|
+
|
|
71
|
+
class CommandConfigurationError extends Error {
|
|
72
|
+
readonly code = 'COMMAND_OPERATION_NOT_CONFIGURED'
|
|
73
|
+
readonly name = 'CommandConfigurationError'
|
|
74
|
+
|
|
75
|
+
constructor() {
|
|
76
|
+
super('The selected command operation is not configured')
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Generic command kernel. Host policy is injected by the owning domain. */
|
|
81
|
+
export class Command<Context, const Registry extends CommandRegistry> {
|
|
82
|
+
readonly #operations: Registry
|
|
83
|
+
readonly #execute: Execute<Context, Registry>
|
|
84
|
+
|
|
85
|
+
constructor(configuration: {
|
|
86
|
+
readonly operations: Registry
|
|
87
|
+
readonly execute: Execute<Context, Registry>
|
|
88
|
+
}) {
|
|
89
|
+
this.#operations = configuration.operations
|
|
90
|
+
this.#execute = configuration.execute
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
exec<const Key extends Operation<Registry>>(
|
|
94
|
+
executor: FixedExecutor<Context, Registry, Key>,
|
|
95
|
+
): (
|
|
96
|
+
context: Context,
|
|
97
|
+
command: CommandInput<Registry, Key>,
|
|
98
|
+
) => Promise<CommandResult<Registry, Key>>
|
|
99
|
+
exec<Dispatcher, const Key extends Operation<Registry>>(
|
|
100
|
+
executor: DynamicExecutor<Context, Registry, Dispatcher, Key>,
|
|
101
|
+
): (
|
|
102
|
+
context: Context,
|
|
103
|
+
command: Dispatcher,
|
|
104
|
+
) => Promise<CommandResult<Registry, Key>>
|
|
105
|
+
exec(
|
|
106
|
+
executor:
|
|
107
|
+
| FixedExecutor<Context, Registry, Operation<Registry>>
|
|
108
|
+
| DynamicExecutor<Context, Registry, unknown, Operation<Registry>>,
|
|
109
|
+
) {
|
|
110
|
+
return async (context: Context, value: unknown) => {
|
|
111
|
+
const selected =
|
|
112
|
+
'operation' in executor
|
|
113
|
+
? { operation: executor.operation, value }
|
|
114
|
+
: (() => {
|
|
115
|
+
const dispatcher = executor.dispatcher.parse(value)
|
|
116
|
+
return {
|
|
117
|
+
operation: executor.select(dispatcher),
|
|
118
|
+
value: dispatcher,
|
|
119
|
+
}
|
|
120
|
+
})()
|
|
121
|
+
if (
|
|
122
|
+
!Object.prototype.hasOwnProperty.call(
|
|
123
|
+
this.#operations,
|
|
124
|
+
selected.operation,
|
|
125
|
+
)
|
|
126
|
+
) {
|
|
127
|
+
throw new CommandConfigurationError()
|
|
128
|
+
}
|
|
129
|
+
const operation = selected.operation as Operation<Registry>
|
|
130
|
+
const definition = this.#operations[operation]
|
|
131
|
+
const command = definition.command.parse(selected.value) as CommandInput<
|
|
132
|
+
Registry,
|
|
133
|
+
Operation<Registry>
|
|
134
|
+
>
|
|
135
|
+
const parseResult = (result: unknown) =>
|
|
136
|
+
definition.result.parse(result) as CommandResult<
|
|
137
|
+
Registry,
|
|
138
|
+
Operation<Registry>
|
|
139
|
+
>
|
|
140
|
+
const handler = executor.handler as (
|
|
141
|
+
context: Context,
|
|
142
|
+
command: CommandInput<Registry, Operation<Registry>>,
|
|
143
|
+
) => MaybePromise<CommandResult<Registry, Operation<Registry>>>
|
|
144
|
+
return this.#execute({
|
|
145
|
+
context,
|
|
146
|
+
operation,
|
|
147
|
+
definition,
|
|
148
|
+
command,
|
|
149
|
+
parseResult,
|
|
150
|
+
run: async () => parseResult(await handler(context, command)),
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vite-plus/test'
|
|
2
|
+
|
|
3
|
+
import { Observability } from '../observability'
|
|
4
|
+
|
|
5
|
+
describe('Foundation Observability', () => {
|
|
6
|
+
it('emits bounded semantic metadata without payloads', async () => {
|
|
7
|
+
const emit = vi.fn()
|
|
8
|
+
const observability = new Observability({
|
|
9
|
+
enabled: true,
|
|
10
|
+
clock: tickingClock(),
|
|
11
|
+
classifyError: () => ({
|
|
12
|
+
outcome: 'failed',
|
|
13
|
+
errorCode: 'UNEXPECTED_ERROR',
|
|
14
|
+
}),
|
|
15
|
+
emit,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
await expect(
|
|
19
|
+
observability.observe(
|
|
20
|
+
{ operation: 'assignments.claim', classification: 'writer' },
|
|
21
|
+
() => 'claimed',
|
|
22
|
+
),
|
|
23
|
+
).resolves.toBe('claimed')
|
|
24
|
+
expect(emit).toHaveBeenCalledWith({
|
|
25
|
+
operation: 'assignments.claim',
|
|
26
|
+
classification: 'writer',
|
|
27
|
+
outcome: 'completed',
|
|
28
|
+
durationMs: 5,
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('classifies denial and preserves the original failure', async () => {
|
|
33
|
+
const emit = vi.fn()
|
|
34
|
+
const original = new Error('sensitive reason')
|
|
35
|
+
const observability = new Observability({
|
|
36
|
+
enabled: true,
|
|
37
|
+
clock: tickingClock(),
|
|
38
|
+
classifyError: () => ({ outcome: 'denied', errorCode: 'FORBIDDEN' }),
|
|
39
|
+
emit,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
await expect(
|
|
43
|
+
observability.observe(
|
|
44
|
+
{ operation: 'assignments.assign', classification: 'admin' },
|
|
45
|
+
() => {
|
|
46
|
+
throw original
|
|
47
|
+
},
|
|
48
|
+
),
|
|
49
|
+
).rejects.toBe(original)
|
|
50
|
+
expect(JSON.stringify(emit.mock.calls)).not.toContain('sensitive reason')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('drops invalid semantic fields and contains emitter failures', async () => {
|
|
54
|
+
const emit = vi.fn(() => {
|
|
55
|
+
throw new Error('telemetry unavailable')
|
|
56
|
+
})
|
|
57
|
+
const observability = new Observability({
|
|
58
|
+
enabled: true,
|
|
59
|
+
classifyError: () => ({
|
|
60
|
+
outcome: 'failed',
|
|
61
|
+
errorCode: 'UNEXPECTED_ERROR',
|
|
62
|
+
}),
|
|
63
|
+
emit,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
await expect(
|
|
67
|
+
observability.observe(
|
|
68
|
+
{ operation: 'contains secret whitespace', classification: 'writer' },
|
|
69
|
+
() => 'unchanged',
|
|
70
|
+
),
|
|
71
|
+
).resolves.toBe('unchanged')
|
|
72
|
+
expect(emit).not.toHaveBeenCalled()
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
function tickingClock(): () => number {
|
|
77
|
+
let now = 10
|
|
78
|
+
return () => (now += 5)
|
|
79
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export type CommandObservation = Readonly<{
|
|
2
|
+
operation: string
|
|
3
|
+
classification: string
|
|
4
|
+
outcome: 'completed' | 'denied' | 'failed'
|
|
5
|
+
errorCode?: string
|
|
6
|
+
durationMs: number
|
|
7
|
+
}>
|
|
8
|
+
|
|
9
|
+
export type ObservabilityOptions = Readonly<{
|
|
10
|
+
enabled?: boolean | (() => boolean)
|
|
11
|
+
classifyError: (error: unknown) => Readonly<{
|
|
12
|
+
outcome: 'denied' | 'failed'
|
|
13
|
+
errorCode: string
|
|
14
|
+
}>
|
|
15
|
+
emit?: (observation: CommandObservation) => void
|
|
16
|
+
clock?: () => number
|
|
17
|
+
}>
|
|
18
|
+
|
|
19
|
+
const identifier = /^[A-Za-z][A-Za-z0-9_.-]{0,159}$/
|
|
20
|
+
const errorCode = /^[A-Z][A-Z0-9_]{0,95}$/
|
|
21
|
+
|
|
22
|
+
/** Payload-free command telemetry that cannot change command behavior. */
|
|
23
|
+
export class Observability {
|
|
24
|
+
readonly #options: ObservabilityOptions
|
|
25
|
+
|
|
26
|
+
constructor(options: ObservabilityOptions) {
|
|
27
|
+
this.#options = options
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async observe<Result>(
|
|
31
|
+
activation: Readonly<{ operation: string; classification: string }>,
|
|
32
|
+
execute: () => Result | Promise<Result>,
|
|
33
|
+
): Promise<Result> {
|
|
34
|
+
const started = this.#now()
|
|
35
|
+
try {
|
|
36
|
+
const result = await execute()
|
|
37
|
+
this.#emit({
|
|
38
|
+
...activation,
|
|
39
|
+
outcome: 'completed',
|
|
40
|
+
durationMs: this.#duration(started),
|
|
41
|
+
})
|
|
42
|
+
return result
|
|
43
|
+
} catch (error) {
|
|
44
|
+
try {
|
|
45
|
+
this.#emit({
|
|
46
|
+
...activation,
|
|
47
|
+
...this.#options.classifyError(error),
|
|
48
|
+
durationMs: this.#duration(started),
|
|
49
|
+
})
|
|
50
|
+
} catch {
|
|
51
|
+
// Preserve the original command failure when telemetry fails.
|
|
52
|
+
}
|
|
53
|
+
throw error
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#emit(observation: CommandObservation): void {
|
|
58
|
+
try {
|
|
59
|
+
const enabled =
|
|
60
|
+
typeof this.#options.enabled === 'function'
|
|
61
|
+
? this.#options.enabled()
|
|
62
|
+
: this.#options.enabled
|
|
63
|
+
if (
|
|
64
|
+
enabled !== true ||
|
|
65
|
+
!identifier.test(observation.operation) ||
|
|
66
|
+
!identifier.test(observation.classification) ||
|
|
67
|
+
(observation.errorCode !== undefined &&
|
|
68
|
+
!errorCode.test(observation.errorCode))
|
|
69
|
+
) {
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
if (this.#options.emit) {
|
|
73
|
+
this.#options.emit(Object.freeze(observation))
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
console.info(
|
|
77
|
+
JSON.stringify({ event: 'command.execution', ...observation }),
|
|
78
|
+
)
|
|
79
|
+
} catch {
|
|
80
|
+
// Observability is intentionally behaviorally inert.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#now(): number {
|
|
85
|
+
try {
|
|
86
|
+
return this.#options.clock?.() ?? Date.now()
|
|
87
|
+
} catch {
|
|
88
|
+
return Date.now()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#duration(started: number): number {
|
|
93
|
+
return Math.max(0, Math.min(600_000, this.#now() - started))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vite-plus/test'
|
|
2
|
+
|
|
3
|
+
import { Query } from '../query'
|
|
4
|
+
|
|
5
|
+
describe('Foundation Query', () => {
|
|
6
|
+
it('runs injected policy before its handler', async () => {
|
|
7
|
+
const order: string[] = []
|
|
8
|
+
const query = new Query({
|
|
9
|
+
defaults: { classification: 'authenticated' as const },
|
|
10
|
+
execute: async (execution) => {
|
|
11
|
+
order.push(`policy:${execution.metadata.classification}`)
|
|
12
|
+
return execution.run()
|
|
13
|
+
},
|
|
14
|
+
})
|
|
15
|
+
const execute = query.exec({
|
|
16
|
+
handler: (_context: unknown, id: string) => {
|
|
17
|
+
order.push('handler')
|
|
18
|
+
return id
|
|
19
|
+
},
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
await expect(execute({}, 'assignment-1')).resolves.toBe('assignment-1')
|
|
23
|
+
expect(order).toEqual(['policy:authenticated', 'handler'])
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type QueryExecution<Context, Metadata, Result> = Readonly<{
|
|
2
|
+
context: Context
|
|
3
|
+
metadata: Metadata
|
|
4
|
+
run: () => Promise<Result>
|
|
5
|
+
}>
|
|
6
|
+
|
|
7
|
+
type MaybePromise<Value> = Value | Promise<Value>
|
|
8
|
+
|
|
9
|
+
/** Generic query kernel. Authorization remains an injected host policy. */
|
|
10
|
+
export class Query<Context, Defaults extends object> {
|
|
11
|
+
readonly #defaults: Defaults
|
|
12
|
+
readonly #execute: <Metadata extends object, Result>(
|
|
13
|
+
execution: QueryExecution<Context, Defaults & Metadata, Result>,
|
|
14
|
+
) => Promise<Result>
|
|
15
|
+
|
|
16
|
+
constructor(configuration: {
|
|
17
|
+
readonly defaults: Defaults
|
|
18
|
+
readonly execute: <Metadata extends object, Result>(
|
|
19
|
+
execution: QueryExecution<Context, Defaults & Metadata, Result>,
|
|
20
|
+
) => Promise<Result>
|
|
21
|
+
}) {
|
|
22
|
+
this.#defaults = configuration.defaults
|
|
23
|
+
this.#execute = configuration.execute
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
exec<
|
|
27
|
+
Arguments extends readonly unknown[],
|
|
28
|
+
Result,
|
|
29
|
+
Metadata extends object = object,
|
|
30
|
+
>(executor: {
|
|
31
|
+
readonly metadata?: Metadata
|
|
32
|
+
readonly handler: (
|
|
33
|
+
context: Context,
|
|
34
|
+
...args: Arguments
|
|
35
|
+
) => MaybePromise<Result>
|
|
36
|
+
}): (context: Context, ...args: Arguments) => Promise<Result> {
|
|
37
|
+
return (context, ...args) =>
|
|
38
|
+
this.#execute({
|
|
39
|
+
context,
|
|
40
|
+
metadata: {
|
|
41
|
+
...this.#defaults,
|
|
42
|
+
...(executor.metadata ?? ({} as Metadata)),
|
|
43
|
+
},
|
|
44
|
+
run: async () => executor.handler(context, ...args),
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { TransactionMetrics } from 'convex/server'
|
|
2
|
+
|
|
3
|
+
export type TransactionMetricsContext = Readonly<{
|
|
4
|
+
meta: Readonly<{
|
|
5
|
+
getTransactionMetrics: () => Promise<TransactionMetrics>
|
|
6
|
+
}>
|
|
7
|
+
}>
|
|
8
|
+
|
|
9
|
+
export type Result<Value, Failure> =
|
|
10
|
+
| Readonly<{ ok: true; value: Value }>
|
|
11
|
+
| Readonly<{ ok: false; error: Failure }>
|
|
12
|
+
|
|
13
|
+
export type ResultBoundary<Failure> = Readonly<{
|
|
14
|
+
ok: <Value>(value: Value) => Result<Value, Failure>
|
|
15
|
+
handledError: <Value = never>(failure: Failure) => Result<Value, Failure>
|
|
16
|
+
dataOf: (error: unknown) => Failure | undefined
|
|
17
|
+
}>
|
|
18
|
+
|
|
19
|
+
const metricNames = [
|
|
20
|
+
'bytesRead',
|
|
21
|
+
'bytesWritten',
|
|
22
|
+
'databaseQueries',
|
|
23
|
+
'documentsRead',
|
|
24
|
+
'documentsWritten',
|
|
25
|
+
'functionsScheduled',
|
|
26
|
+
'scheduledFunctionArgsBytes',
|
|
27
|
+
] as const satisfies readonly (keyof TransactionMetrics)[]
|
|
28
|
+
|
|
29
|
+
const effectMetricNames = [
|
|
30
|
+
'bytesWritten',
|
|
31
|
+
'documentsWritten',
|
|
32
|
+
'functionsScheduled',
|
|
33
|
+
'scheduledFunctionArgsBytes',
|
|
34
|
+
] as const satisfies readonly (keyof TransactionMetrics)[]
|
|
35
|
+
|
|
36
|
+
/** Return handled failures only before effects; otherwise rethrow for rollback. */
|
|
37
|
+
export async function executeResultBoundary<Value, Failure>(
|
|
38
|
+
ctx: TransactionMetricsContext,
|
|
39
|
+
operation: () => Promise<Value>,
|
|
40
|
+
boundary: ResultBoundary<Failure>,
|
|
41
|
+
): Promise<Result<Value, Failure>> {
|
|
42
|
+
try {
|
|
43
|
+
return boundary.ok(await operation())
|
|
44
|
+
} catch (error) {
|
|
45
|
+
const handled = boundary.dataOf(error)
|
|
46
|
+
if (handled === undefined) throw error
|
|
47
|
+
try {
|
|
48
|
+
if (hasEffects(await ctx.meta.getTransactionMetrics())) throw error
|
|
49
|
+
} catch {
|
|
50
|
+
throw error
|
|
51
|
+
}
|
|
52
|
+
return boundary.handledError(handled)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function projectResult<Value, Failure>(
|
|
57
|
+
result: Result<Value, Failure>,
|
|
58
|
+
projectFailure: (failure: Failure) => never,
|
|
59
|
+
): Value {
|
|
60
|
+
return result.ok ? result.value : projectFailure(result.error)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function hasEffects(metrics: TransactionMetrics): boolean {
|
|
64
|
+
for (const name of metricNames) {
|
|
65
|
+
const metric = metrics[name]
|
|
66
|
+
if (
|
|
67
|
+
!metric ||
|
|
68
|
+
!Number.isFinite(metric.used) ||
|
|
69
|
+
metric.used < 0 ||
|
|
70
|
+
!Number.isFinite(metric.remaining) ||
|
|
71
|
+
metric.remaining < 0
|
|
72
|
+
) {
|
|
73
|
+
throw new Error('Unreadable transaction metrics')
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return effectMetricNames.some((name) => metrics[name].used > 0)
|
|
77
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
type SemanticValue = string | number | boolean
|
|
2
|
+
|
|
3
|
+
const semanticToken = /^[A-Za-z][A-Za-z0-9_.-]{0,159}$/
|
|
4
|
+
|
|
5
|
+
/** Emit bounded semantic fields only; free-form payloads and identifiers drop. */
|
|
6
|
+
export function emitSemanticEvent(
|
|
7
|
+
event: string,
|
|
8
|
+
fields: Readonly<Record<string, SemanticValue | undefined>> = {},
|
|
9
|
+
): void {
|
|
10
|
+
try {
|
|
11
|
+
if (!semanticToken.test(event)) return
|
|
12
|
+
const safe: Record<string, SemanticValue> = { event }
|
|
13
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
14
|
+
if (!semanticToken.test(key) || value === undefined) continue
|
|
15
|
+
if (typeof value === 'string' && !semanticToken.test(value)) continue
|
|
16
|
+
if (typeof value === 'number' && !Number.isFinite(value)) continue
|
|
17
|
+
safe[key] = value
|
|
18
|
+
}
|
|
19
|
+
console.info(JSON.stringify(safe))
|
|
20
|
+
} catch {
|
|
21
|
+
// Telemetry must never change command behavior.
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Structured application error carried across Convex function boundaries. */
|
|
2
|
+
export class KitError extends Error {
|
|
3
|
+
readonly code: string
|
|
4
|
+
|
|
5
|
+
constructor(input: { code: string; message?: string }) {
|
|
6
|
+
super(input.message ?? input.code)
|
|
7
|
+
this.name = 'KitError'
|
|
8
|
+
this.code = input.code
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Injection seam for host error policy. Hosts with their own error taxonomy
|
|
14
|
+
* (e.g. an App.errors singleton) adapt it to this shape; everyone else uses
|
|
15
|
+
* the default factory below.
|
|
16
|
+
*/
|
|
17
|
+
export type ErrorFactory = {
|
|
18
|
+
throw(input: { code: string; message?: string }): never
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const defaultErrors: ErrorFactory = {
|
|
22
|
+
throw(input) {
|
|
23
|
+
throw new KitError(input)
|
|
24
|
+
},
|
|
25
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export {
|
|
2
|
+
defaultErrors,
|
|
3
|
+
KitError,
|
|
4
|
+
type ErrorFactory,
|
|
5
|
+
} from './errors'
|
|
6
|
+
export {
|
|
7
|
+
jsonSafeZid,
|
|
8
|
+
TIMESTAMP_FIELDS,
|
|
9
|
+
zodTable,
|
|
10
|
+
zodVariantTable,
|
|
11
|
+
type TableBoundaryOptions,
|
|
12
|
+
} from './zod-table'
|
|
13
|
+
export {
|
|
14
|
+
createAuthFunctions,
|
|
15
|
+
createInclude,
|
|
16
|
+
defaultRoleMap,
|
|
17
|
+
type Actor,
|
|
18
|
+
type AuthBundle,
|
|
19
|
+
type AuthFunctionsConfig,
|
|
20
|
+
type DefaultRole,
|
|
21
|
+
type Include,
|
|
22
|
+
type IncludedQuery,
|
|
23
|
+
} from './auth'
|
|
24
|
+
export {
|
|
25
|
+
appendOnly,
|
|
26
|
+
createTriggers,
|
|
27
|
+
noDelete,
|
|
28
|
+
timestamps,
|
|
29
|
+
Triggers,
|
|
30
|
+
type Change,
|
|
31
|
+
type Trigger,
|
|
32
|
+
} from './triggers'
|
|
33
|
+
export {
|
|
34
|
+
Foundation,
|
|
35
|
+
type ApplicationCommand,
|
|
36
|
+
type AuditedOperation,
|
|
37
|
+
type AuditedRegistry,
|
|
38
|
+
type AuditEntryInput,
|
|
39
|
+
type AuditWriter,
|
|
40
|
+
type FoundationOptions,
|
|
41
|
+
} from './components/foundation/client'
|
package/src/test.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test helpers for consumers, following the official component template:
|
|
3
|
+
* register the kit's components on a convex-test instance so host-app tests
|
|
4
|
+
* run against them in-memory. Requires a vitest-compatible runner
|
|
5
|
+
* (import.meta.glob), which is why this export ships as source.
|
|
6
|
+
*/
|
|
7
|
+
import approvalsSchema from './components/approvals/schema'
|
|
8
|
+
import foundationSchema from './components/foundation/schema'
|
|
9
|
+
|
|
10
|
+
const foundationModules = import.meta.glob(
|
|
11
|
+
'./components/foundation/**/!(*.test).ts',
|
|
12
|
+
)
|
|
13
|
+
const approvalsModules = import.meta.glob(
|
|
14
|
+
'./components/approvals/**/!(*.test).ts',
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
type RegistersComponents = {
|
|
18
|
+
registerComponent: (
|
|
19
|
+
name: string,
|
|
20
|
+
schema: unknown,
|
|
21
|
+
modules: Record<string, () => Promise<unknown>>,
|
|
22
|
+
) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Registers the foundation component under its default install name. */
|
|
26
|
+
export function registerFoundation(
|
|
27
|
+
t: RegistersComponents,
|
|
28
|
+
name = 'foundation',
|
|
29
|
+
) {
|
|
30
|
+
t.registerComponent(name, foundationSchema, foundationModules)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Registers the approvals component under its default install name. */
|
|
34
|
+
export function registerApprovals(t: RegistersComponents, name = 'approvals') {
|
|
35
|
+
t.registerComponent(name, approvalsSchema, approvalsModules)
|
|
36
|
+
}
|