@samebase/alchemy-convex 0.13.0 → 0.14.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/README.md +13 -3
- package/lib/index.d.ts +15 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +33 -6
- package/lib/index.js.map +1 -1
- package/package.json +13 -1
- package/src/State.ts +71 -18
- package/src/index.ts +1 -1
- package/src/store/authConfig.ts +17 -0
- package/src/store/component/reader.ts +1 -1
- package/src/store/component/schema.ts +1 -1
- package/src/store/component/state.ts +1 -1
- package/src/store/convex/auth.config.ts +2 -12
- package/src/store/convex/convex.config.ts +1 -1
- package/src/store/convex/reader.ts +3 -49
- package/src/store/convex/state.ts +21 -121
- package/src/store/convex/version.ts +10 -7
- package/src/store/functions.ts +214 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// The functions of the state store, for each app that serves the store. Two
|
|
2
|
+
// apps do: the store app of this package (src/store/convex), which
|
|
3
|
+
// Convex.state() pushes to the samebase project of a team, and the base app of
|
|
4
|
+
// a team, which mounts the alchemyState component next to its own components
|
|
5
|
+
// in the same project. A push replaces every function of a deployment, so both
|
|
6
|
+
// apps serve these functions with the same names. Exported as
|
|
7
|
+
// `@samebase/alchemy-convex/state/functions`.
|
|
8
|
+
//
|
|
9
|
+
// Each `state:*` function forwards to the component function of the same
|
|
10
|
+
// name, with the same results; an absent `run` or `stage` goes to the
|
|
11
|
+
// component as null. These names and arguments are the wire contract of
|
|
12
|
+
// STORE_VERSION. Samebase reads `stacks`, `getOutput`, `readStack`, and
|
|
13
|
+
// `reader:repository` by name: a renamed function is a version bump of the
|
|
14
|
+
// store and of Samebase.
|
|
15
|
+
import { internalMutationGeneric, internalQueryGeneric, queryGeneric } from "convex/server";
|
|
16
|
+
import { type Infer, v } from "convex/values";
|
|
17
|
+
import type { ComponentApi } from "./component/_generated/component.js";
|
|
18
|
+
import { env } from "./component/_generated/server.js";
|
|
19
|
+
import { repositoryReply } from "./component/reader.js";
|
|
20
|
+
import { runRecord } from "./component/runRecord.js";
|
|
21
|
+
import { stackReply } from "./component/state.js";
|
|
22
|
+
import { STORE_VERSION } from "./convex/version.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The app that serves the store. `seed`: the store app of this package, which
|
|
26
|
+
* Convex.state() pushes. `base`: the base app of the team, which
|
|
27
|
+
* Convex.state() never pushes over.
|
|
28
|
+
*/
|
|
29
|
+
const storeHost = v.union(v.literal("seed"), v.literal("base"));
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The internal `state:*` functions over the mounted component `alchemyState`.
|
|
33
|
+
* `state:host` answers `host`, the app that serves them. Export each one
|
|
34
|
+
* by name from the app's `convex/state.ts`.
|
|
35
|
+
*/
|
|
36
|
+
export const stateFunctions = (alchemyState: ComponentApi, host: Infer<typeof storeHost>) => {
|
|
37
|
+
const state = alchemyState.state;
|
|
38
|
+
return {
|
|
39
|
+
/** The wire version of this store, compared with the client's STORE_VERSION. */
|
|
40
|
+
version: internalQueryGeneric({
|
|
41
|
+
args: {},
|
|
42
|
+
returns: v.number(),
|
|
43
|
+
handler: async () => STORE_VERSION,
|
|
44
|
+
}),
|
|
45
|
+
|
|
46
|
+
/** The app that serves this store. Convex.state() reads it before it pushes over an older store. */
|
|
47
|
+
host: internalQueryGeneric({
|
|
48
|
+
args: {},
|
|
49
|
+
returns: storeHost,
|
|
50
|
+
handler: async () => host,
|
|
51
|
+
}),
|
|
52
|
+
|
|
53
|
+
/** Stacks with at least one resource or one output, sorted. */
|
|
54
|
+
stacks: internalQueryGeneric({
|
|
55
|
+
args: {},
|
|
56
|
+
returns: v.array(v.string()),
|
|
57
|
+
handler: (ctx) => ctx.runQuery(state.stacks, {}),
|
|
58
|
+
}),
|
|
59
|
+
|
|
60
|
+
/** Stages of a stack with at least one resource or one output, sorted. */
|
|
61
|
+
stages: internalQueryGeneric({
|
|
62
|
+
args: { stack: v.string() },
|
|
63
|
+
returns: v.array(v.string()),
|
|
64
|
+
handler: (ctx, args) => ctx.runQuery(state.stages, { stack: args.stack }),
|
|
65
|
+
}),
|
|
66
|
+
|
|
67
|
+
list: internalQueryGeneric({
|
|
68
|
+
args: { stack: v.string(), stage: v.string() },
|
|
69
|
+
returns: v.array(v.string()),
|
|
70
|
+
handler: (ctx, args) => ctx.runQuery(state.list, { stack: args.stack, stage: args.stage }),
|
|
71
|
+
}),
|
|
72
|
+
|
|
73
|
+
get: internalQueryGeneric({
|
|
74
|
+
args: { stack: v.string(), stage: v.string(), fqn: v.string() },
|
|
75
|
+
returns: v.union(v.string(), v.null()),
|
|
76
|
+
handler: (ctx, args) =>
|
|
77
|
+
ctx.runQuery(state.get, { stack: args.stack, stage: args.stage, fqn: args.fqn }),
|
|
78
|
+
}),
|
|
79
|
+
|
|
80
|
+
/** Resources whose state has `status: "replaced"`. */
|
|
81
|
+
replaced: internalQueryGeneric({
|
|
82
|
+
args: { stack: v.string(), stage: v.string() },
|
|
83
|
+
returns: v.array(v.string()),
|
|
84
|
+
handler: (ctx, args) =>
|
|
85
|
+
ctx.runQuery(state.replaced, { stack: args.stack, stage: args.stage }),
|
|
86
|
+
}),
|
|
87
|
+
|
|
88
|
+
set: internalMutationGeneric({
|
|
89
|
+
args: {
|
|
90
|
+
stack: v.string(),
|
|
91
|
+
stage: v.string(),
|
|
92
|
+
fqn: v.string(),
|
|
93
|
+
value: v.string(),
|
|
94
|
+
run: v.optional(runRecord),
|
|
95
|
+
},
|
|
96
|
+
returns: v.null(),
|
|
97
|
+
handler: (ctx, args) =>
|
|
98
|
+
ctx.runMutation(state.set, {
|
|
99
|
+
stack: args.stack,
|
|
100
|
+
stage: args.stage,
|
|
101
|
+
fqn: args.fqn,
|
|
102
|
+
value: args.value,
|
|
103
|
+
run: args.run ?? null,
|
|
104
|
+
}),
|
|
105
|
+
}),
|
|
106
|
+
|
|
107
|
+
remove: internalMutationGeneric({
|
|
108
|
+
args: { stack: v.string(), stage: v.string(), fqn: v.string(), run: v.optional(runRecord) },
|
|
109
|
+
returns: v.null(),
|
|
110
|
+
handler: (ctx, args) =>
|
|
111
|
+
ctx.runMutation(state.remove, {
|
|
112
|
+
stack: args.stack,
|
|
113
|
+
stage: args.stage,
|
|
114
|
+
fqn: args.fqn,
|
|
115
|
+
run: args.run ?? null,
|
|
116
|
+
}),
|
|
117
|
+
}),
|
|
118
|
+
|
|
119
|
+
/** Deletes the resources and outputs of one stage, or of every stage of the stack. */
|
|
120
|
+
removeStack: internalMutationGeneric({
|
|
121
|
+
args: { stack: v.string(), stage: v.optional(v.string()) },
|
|
122
|
+
returns: v.null(),
|
|
123
|
+
handler: (ctx, args) =>
|
|
124
|
+
ctx.runMutation(state.removeStack, { stack: args.stack, stage: args.stage ?? null }),
|
|
125
|
+
}),
|
|
126
|
+
|
|
127
|
+
getOutput: internalQueryGeneric({
|
|
128
|
+
args: { stack: v.string(), stage: v.string() },
|
|
129
|
+
returns: v.union(v.string(), v.null()),
|
|
130
|
+
handler: (ctx, args) =>
|
|
131
|
+
ctx.runQuery(state.getOutput, { stack: args.stack, stage: args.stage }),
|
|
132
|
+
}),
|
|
133
|
+
|
|
134
|
+
setOutput: internalMutationGeneric({
|
|
135
|
+
args: { stack: v.string(), stage: v.string(), value: v.string(), run: v.optional(runRecord) },
|
|
136
|
+
returns: v.null(),
|
|
137
|
+
handler: (ctx, args) =>
|
|
138
|
+
ctx.runMutation(state.setOutput, {
|
|
139
|
+
stack: args.stack,
|
|
140
|
+
stage: args.stage,
|
|
141
|
+
value: args.value,
|
|
142
|
+
run: args.run ?? null,
|
|
143
|
+
}),
|
|
144
|
+
}),
|
|
145
|
+
|
|
146
|
+
/** Every stage of a stack with its resources, output, and current record, in one call. Since store version 2. */
|
|
147
|
+
readStack: internalQueryGeneric({
|
|
148
|
+
args: { stack: v.string() },
|
|
149
|
+
returns: stackReply,
|
|
150
|
+
handler: (ctx, args) => ctx.runQuery(state.readStack, { stack: args.stack }),
|
|
151
|
+
}),
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const repositoryName = /^[^/]+\/[^/]+$/;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The `reader:*` functions over the mounted component `alchemyState`. Export
|
|
159
|
+
* each one by name from the app's `convex/reader.ts`. They stay in the app:
|
|
160
|
+
* `authorize` signs and `repository` verifies with `ctx.auth`, which a
|
|
161
|
+
* component does not have. The rows and the redaction are in the component.
|
|
162
|
+
* The token audience is `${CONVEX_CLOUD_URL}/alchemy-reader`, the application
|
|
163
|
+
* id of authConfig.ts. CONVEX_CLOUD_URL is a system variable of the
|
|
164
|
+
* deployment, the same for the app and the component, so the typed `env` of
|
|
165
|
+
* the component's codegen gives it.
|
|
166
|
+
*/
|
|
167
|
+
export const readerFunctions = (alchemyState: ComponentApi) => ({
|
|
168
|
+
/** Only a deployment credential can exchange an approved repository for a read token. */
|
|
169
|
+
authorize: internalMutationGeneric({
|
|
170
|
+
args: { repository: v.string(), subject: v.string() },
|
|
171
|
+
returns: v.object({ token: v.string() }),
|
|
172
|
+
handler: async (ctx, args) => {
|
|
173
|
+
if (!repositoryName.test(args.repository) || args.subject === "") {
|
|
174
|
+
throw new Error("A repository and subject are required.");
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
token: await ctx.auth.signJwt({
|
|
178
|
+
expiresInSeconds: 300,
|
|
179
|
+
claims: {
|
|
180
|
+
sub: args.subject,
|
|
181
|
+
aud: `${env.CONVEX_CLOUD_URL}/alchemy-reader`,
|
|
182
|
+
repository: args.repository,
|
|
183
|
+
scope: "alchemy:read",
|
|
184
|
+
},
|
|
185
|
+
}),
|
|
186
|
+
};
|
|
187
|
+
},
|
|
188
|
+
}),
|
|
189
|
+
|
|
190
|
+
/** A native subscription reads only the repository named in its verified capability. */
|
|
191
|
+
repository: queryGeneric({
|
|
192
|
+
args: {},
|
|
193
|
+
returns: repositoryReply,
|
|
194
|
+
handler: async (ctx) => {
|
|
195
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
196
|
+
// Custom claims are bracket reads: a base app compiles this file under its own tsconfig,
|
|
197
|
+
// which can set noPropertyAccessFromIndexSignature.
|
|
198
|
+
const scope = identity?.["scope"];
|
|
199
|
+
const repository = identity?.["repository"];
|
|
200
|
+
// Only a token of the deployment's own issuer reads, the one that `authorize` signs: a base
|
|
201
|
+
// can accept other providers too, and a token of one of those with the same claims does not.
|
|
202
|
+
if (
|
|
203
|
+
identity === null ||
|
|
204
|
+
identity.issuer !== env.CONVEX_CLOUD_URL ||
|
|
205
|
+
scope !== "alchemy:read" ||
|
|
206
|
+
typeof repository !== "string" ||
|
|
207
|
+
!repositoryName.test(repository)
|
|
208
|
+
) {
|
|
209
|
+
throw new Error("Alchemy repository read access is required.");
|
|
210
|
+
}
|
|
211
|
+
return await ctx.runQuery(alchemyState.reader.repository, { repository });
|
|
212
|
+
},
|
|
213
|
+
}),
|
|
214
|
+
});
|