cvx-kit 0.0.2 → 0.0.4

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.
Files changed (65) hide show
  1. package/README.md +1 -0
  2. package/dist/auth.d.mts +52 -5
  3. package/dist/auth.mjs +1 -1
  4. package/dist/auth.mjs.map +1 -1
  5. package/dist/components/foundation/client.d.mts +40 -3
  6. package/dist/components/foundation/client.mjs +1 -1
  7. package/dist/components/foundation/client.mjs.map +1 -1
  8. package/dist/index.d.mts +6 -5
  9. package/dist/index.mjs +1 -1
  10. package/dist/tenancy.d.mts +34 -0
  11. package/dist/tenancy.mjs +2 -0
  12. package/dist/tenancy.mjs.map +1 -0
  13. package/dist/triggers.d.mts +9 -1
  14. package/dist/triggers.mjs +1 -1
  15. package/dist/triggers.mjs.map +1 -1
  16. package/dist/zod-table.d.mts +21 -1
  17. package/dist/zod-table.mjs +1 -1
  18. package/dist/zod-table.mjs.map +1 -1
  19. package/docs/auth.md +10 -0
  20. package/docs/commands.md +56 -4
  21. package/docs/conventions.md +21 -0
  22. package/docs/explain/modules-and-multitenancy.html +469 -0
  23. package/docs/llms.md +16 -4
  24. package/docs/tenancy.md +179 -0
  25. package/docs/triggers.md +5 -0
  26. package/docs/zod-table.md +23 -0
  27. package/package.json +6 -8
  28. package/src/test.ts +7 -5
  29. package/src/auth.ts +0 -360
  30. package/src/components/approvals/_generated/api.ts +0 -69
  31. package/src/components/approvals/_generated/component.ts +0 -335
  32. package/src/components/approvals/_generated/dataModel.ts +0 -60
  33. package/src/components/approvals/_generated/server.ts +0 -156
  34. package/src/components/approvals/audit.ts +0 -148
  35. package/src/components/approvals/client.ts +0 -321
  36. package/src/components/approvals/constants.ts +0 -51
  37. package/src/components/approvals/convex.config.ts +0 -10
  38. package/src/components/approvals/decisions.ts +0 -376
  39. package/src/components/approvals/functions.ts +0 -27
  40. package/src/components/approvals/requests.ts +0 -240
  41. package/src/components/approvals/schema.ts +0 -23
  42. package/src/components/approvals/validators.ts +0 -272
  43. package/src/components/approvals/workflow.ts +0 -204
  44. package/src/components/approvals/workflow_steps.ts +0 -189
  45. package/src/components/foundation/_generated/api.ts +0 -62
  46. package/src/components/foundation/_generated/component.ts +0 -29
  47. package/src/components/foundation/_generated/dataModel.ts +0 -60
  48. package/src/components/foundation/_generated/server.ts +0 -156
  49. package/src/components/foundation/client.ts +0 -190
  50. package/src/components/foundation/convex.config.ts +0 -3
  51. package/src/components/foundation/functions.ts +0 -10
  52. package/src/components/foundation/modules/command/__tests__/command.test.ts +0 -106
  53. package/src/components/foundation/modules/command/command.ts +0 -154
  54. package/src/components/foundation/modules/observability/__tests__/observability.test.ts +0 -79
  55. package/src/components/foundation/modules/observability/observability.ts +0 -95
  56. package/src/components/foundation/modules/query/__tests__/query.test.ts +0 -25
  57. package/src/components/foundation/modules/query/query.ts +0 -47
  58. package/src/components/foundation/result.ts +0 -77
  59. package/src/components/foundation/schema.ts +0 -4
  60. package/src/components/foundation/telemetry.ts +0 -23
  61. package/src/errors.ts +0 -25
  62. package/src/index.ts +0 -41
  63. package/src/triggers.ts +0 -90
  64. package/src/zod-table.ts +0 -197
  65. /package/docs/{convex-architecture-explainer.html → explain/convex-architecture-explainer.html} +0 -0
@@ -1,154 +0,0 @@
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
- }
@@ -1,79 +0,0 @@
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
- }
@@ -1,95 +0,0 @@
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
- }
@@ -1,25 +0,0 @@
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
- })
@@ -1,47 +0,0 @@
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
- }
@@ -1,77 +0,0 @@
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
- }
@@ -1,4 +0,0 @@
1
- import { defineSchema } from 'convex/server'
2
-
3
- /** Foundation is a capability-free in-process kernel and owns no business data. */
4
- export default defineSchema({})
@@ -1,23 +0,0 @@
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 DELETED
@@ -1,25 +0,0 @@
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 DELETED
@@ -1,41 +0,0 @@
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/triggers.ts DELETED
@@ -1,90 +0,0 @@
1
- import {
2
- Triggers,
3
- type Change,
4
- type Trigger,
5
- } from 'convex-helpers/server/triggers'
6
- import type {
7
- GenericDataModel,
8
- TableNamesInDataModel,
9
- } from 'convex/server'
10
-
11
- export { Triggers }
12
- export type { Change, Trigger }
13
-
14
- /**
15
- * One application-wide mutation interceptor registry. Pass it to
16
- * createAuthFunctions({ triggers }) so every authMutation/roleMutation/
17
- * systemMutation runs writes through it — trigger enforcement becomes
18
- * structural, not a convention.
19
- */
20
- export function createTriggers<
21
- DataModel extends GenericDataModel,
22
- >(): Triggers<DataModel> {
23
- return new Triggers<DataModel>()
24
- }
25
-
26
- /**
27
- * Marks tables as append-only evidence tables: inserts pass, every update or
28
- * delete throws. The ontology pattern for votes/history/audit-style tables.
29
- */
30
- export function appendOnly<DataModel extends GenericDataModel>(
31
- triggers: Triggers<DataModel>,
32
- ...tables: readonly TableNamesInDataModel<DataModel>[]
33
- ) {
34
- for (const table of tables) {
35
- triggers.register(table, async (_ctx, change) => {
36
- if (change.operation !== 'insert') {
37
- throw new Error(`${table} is append-only`)
38
- }
39
- })
40
- }
41
- }
42
-
43
- /** Forbids hard deletes on the given tables (soft-delete discipline). */
44
- export function noDelete<DataModel extends GenericDataModel>(
45
- triggers: Triggers<DataModel>,
46
- ...tables: readonly TableNamesInDataModel<DataModel>[]
47
- ) {
48
- for (const table of tables) {
49
- triggers.register(table, async (_ctx, change) => {
50
- if (change.operation === 'delete') {
51
- throw new Error(`${table} rows cannot be deleted`)
52
- }
53
- })
54
- }
55
- }
56
-
57
- /**
58
- * Maintains zod-table's opinionated lifecycle timestamps: createdAt and
59
- * updatedAt on insert, updatedAt on every update. archivedAt stays under
60
- * application control. Register once per table; writes made through the
61
- * auth constructors keep these fields correct automatically.
62
- */
63
- export function timestamps<DataModel extends GenericDataModel>(
64
- triggers: Triggers<DataModel>,
65
- ...tables: readonly TableNamesInDataModel<DataModel>[]
66
- ) {
67
- for (const table of tables) {
68
- triggers.register(table, async (ctx, change) => {
69
- if (change.operation === 'delete') return
70
- const now = Date.now()
71
- if (change.operation === 'insert') {
72
- const document = change.newDoc as { createdAt?: number }
73
- if (document.createdAt === undefined) {
74
- await ctx.innerDb.patch(change.id, {
75
- createdAt: now,
76
- updatedAt: now,
77
- } as never)
78
- }
79
- return
80
- }
81
- const previous = change.oldDoc as { updatedAt?: number }
82
- const current = change.newDoc as { updatedAt?: number }
83
- // A write that already moved updatedAt (including our own patch)
84
- // is left alone — this is the recursion guard.
85
- if (current.updatedAt === previous.updatedAt) {
86
- await ctx.innerDb.patch(change.id, { updatedAt: now } as never)
87
- }
88
- })
89
- }
90
- }