@plitzi/sdk-server 0.38.9 → 0.38.11
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/CHANGELOG.md +263 -0
- package/README.md +3 -3
- package/dist/auth.js +1 -1
- package/dist/core/auth/createAuth.js +1 -1
- package/dist/core/http/stages/signInRoute.js +1 -1
- package/dist/core/server/baseServer.js +11 -6
- package/dist/core/server/fleet/stores.d.ts +7 -0
- package/dist/core/server/fleet/stores.js +16 -2
- package/dist/core/server/fleet/supervisor.d.ts +2 -0
- package/dist/core/server/fleet/supervisor.js +14 -2
- package/dist/core/server/pageServer.js +1 -1
- package/dist/functions-api.d.ts +29 -0
- package/dist/helpers/cache/cacheManager.d.ts +9 -2
- package/dist/helpers/cache/cacheManager.js +46 -22
- package/dist/helpers/cache/keys.d.ts +12 -5
- package/dist/helpers/cache/keys.js +56 -11
- package/dist/modules/actions/index.js +6 -2
- package/dist/modules/actions/jobs/later.d.ts +43 -0
- package/dist/modules/actions/jobs/later.js +66 -0
- package/dist/modules/actions/jobs/later.test.d.ts +1 -0
- package/dist/modules/actions/jobs/memoryQueue.js +14 -1
- package/dist/modules/actions/jobs/worker.js +2 -2
- package/dist/modules/actions/runtime/credentials.d.ts +10 -0
- package/dist/modules/actions/runtime/credentials.js +28 -0
- package/dist/modules/actions/runtime/credentials.test.d.ts +1 -0
- package/dist/modules/actions/runtime/fileKv.d.ts +8 -3
- package/dist/modules/actions/runtime/fileKv.js +79 -7
- package/dist/modules/actions/runtime/precheck.js +1 -1
- package/dist/modules/actions/runtime/runAction.d.ts +4 -1
- package/dist/modules/actions/runtime/runAction.js +26 -4
- package/dist/modules/actions/runtime/taskContext.d.ts +4 -1
- package/dist/modules/actions/runtime/taskContext.js +7 -1
- package/dist/modules/actions/tasks/flow.js +113 -35
- package/dist/modules/actions/types.d.ts +8 -0
- package/dist/modules/functions/build.js +1 -1
- package/dist/modules/functions/capabilities.js +21 -0
- package/dist/modules/functions/context.js +6 -0
- package/dist/modules/functions/contract.d.ts +10 -0
- package/dist/modules/functions/driver.js +8 -0
- package/dist/modules/functions/local.d.ts +1 -1
- package/dist/modules/functions/local.js +2 -1
- package/dist/modules/functions/manifest.js +6 -1
- package/dist/modules/functions/protocol.d.ts +7 -0
- package/dist/modules/mongo/jobQueue.js +26 -0
- package/dist/modules/mysql/jobs/jobQueue.js +21 -2
- package/dist/modules/mysql/jobs/schema.d.ts +3 -2
- package/dist/modules/mysql/jobs/schema.js +24 -11
- package/dist/modules/project/serveProject.d.ts +5 -2
- package/dist/modules/project/serveProject.js +31 -6
- package/dist/modules/realtime/handlers.d.ts +4 -1
- package/dist/modules/realtime/handlers.js +8 -4
- package/dist/modules/realtime/hub.d.ts +3 -1
- package/dist/modules/realtime/hub.js +8 -4
- package/dist/modules/ssr/prepareRender.js +8 -6
- package/dist/modules/ssr/resolvePageSeo.d.ts +4 -1
- package/dist/modules/ssr/resolvePageSeo.js +7 -3
- package/package.json +3 -3
- package/skills/plitzi-authoring/CHEATSHEET.md +4 -2
- package/skills/plitzi-authoring/recipes/filter-a-list.ts +0 -2
- package/skills/plitzi-authoring/recipes/forms-and-modals.ts +2 -1
- package/skills/plitzi-authoring/recipes/style-a-page.ts +1 -1
- package/skills/plitzi-authoring/reference/accessibility.md +1 -0
- package/skills/plitzi-authoring/reference/authoring-errors.md +9 -4
- package/skills/plitzi-authoring/reference/data-and-visibility.md +11 -10
- package/skills/plitzi-authoring/reference/flows.md +2 -2
- package/skills/plitzi-authoring/reference/layouts.md +5 -2
- package/skills/plitzi-authoring/reference/lists.md +1 -0
- package/skills/plitzi-authoring/reference/plugins.md +3 -2
- package/skills/plitzi-authoring/reference/realtime.md +4 -1
- package/skills/plitzi-authoring/reference/structure.md +3 -1
- package/skills/plitzi-authoring/reference/typed-sources.md +6 -4
|
@@ -131,65 +131,143 @@ var emit = {
|
|
|
131
131
|
return { emitted: true };
|
|
132
132
|
}
|
|
133
133
|
};
|
|
134
|
+
/**
|
|
135
|
+
* Refuses the run once a caller has asked too often — a contact form, a vote, a comment box, anything public.
|
|
136
|
+
*
|
|
137
|
+
* A fixed window per bucket: at most `limit` runs every `windowSeconds`, counted in the space's `kv` (shared by every
|
|
138
|
+
* replica that shares it). `per: caller` gives each person their own count — the signed-in account, or the address of
|
|
139
|
+
* whoever is not signed in — which is what a public action wants; `per: everyone` is one count for all of them, a
|
|
140
|
+
* ceiling on the action itself. Refused with the message, which the caller sees as the run's `error`.
|
|
141
|
+
*/
|
|
142
|
+
var rateLimit = {
|
|
143
|
+
namespace: "flow",
|
|
144
|
+
action: "rateLimit",
|
|
145
|
+
title: "Rate Limit",
|
|
146
|
+
params: {
|
|
147
|
+
bucket: {
|
|
148
|
+
type: "text",
|
|
149
|
+
canBind: true,
|
|
150
|
+
defaultValue: "",
|
|
151
|
+
label: "Name (what is counted — \"comments\")"
|
|
152
|
+
},
|
|
153
|
+
limit: {
|
|
154
|
+
type: "text",
|
|
155
|
+
canBind: true,
|
|
156
|
+
defaultValue: "10",
|
|
157
|
+
label: "At most"
|
|
158
|
+
},
|
|
159
|
+
windowSeconds: {
|
|
160
|
+
type: "text",
|
|
161
|
+
canBind: true,
|
|
162
|
+
defaultValue: "60",
|
|
163
|
+
label: "Every (seconds)"
|
|
164
|
+
},
|
|
165
|
+
per: {
|
|
166
|
+
type: "select",
|
|
167
|
+
canBind: true,
|
|
168
|
+
defaultValue: "caller",
|
|
169
|
+
label: "Counted",
|
|
170
|
+
options: [{
|
|
171
|
+
label: "For each person",
|
|
172
|
+
value: "caller"
|
|
173
|
+
}, {
|
|
174
|
+
label: "For everyone together",
|
|
175
|
+
value: "everyone"
|
|
176
|
+
}]
|
|
177
|
+
},
|
|
178
|
+
message: {
|
|
179
|
+
type: "text",
|
|
180
|
+
canBind: true,
|
|
181
|
+
defaultValue: "",
|
|
182
|
+
label: "Refusal (what the caller is told)"
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
run: async ({ bucket, limit, windowSeconds, per, message }, ctx) => {
|
|
186
|
+
const perSeconds = Number(windowSeconds);
|
|
187
|
+
const { allowed, count, remaining } = await countRate(ctx.kv, ctx.callerId, bucket, {
|
|
188
|
+
most: Number(limit),
|
|
189
|
+
perSeconds,
|
|
190
|
+
per: per === "everyone" ? "everyone" : "caller"
|
|
191
|
+
});
|
|
192
|
+
if (!allowed) throw new ActionRefusal(message || `Too many at once — try again in ${String(perSeconds)} seconds`);
|
|
193
|
+
return {
|
|
194
|
+
count,
|
|
195
|
+
remaining
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
var jsonInput = (value) => {
|
|
200
|
+
if (typeof value !== "string") return value;
|
|
201
|
+
try {
|
|
202
|
+
return JSON.parse(value || "{}");
|
|
203
|
+
} catch {
|
|
204
|
+
throw new Error("The input of a run set for later is JSON: `{ \"room\": \"{{ input.room }}\" }`");
|
|
205
|
+
}
|
|
206
|
+
};
|
|
134
207
|
var flowTasks = [
|
|
135
208
|
delay,
|
|
136
209
|
fail,
|
|
137
210
|
output,
|
|
138
211
|
onFailure,
|
|
212
|
+
rateLimit,
|
|
139
213
|
{
|
|
140
214
|
namespace: "flow",
|
|
141
|
-
action: "
|
|
142
|
-
title: "
|
|
215
|
+
action: "later",
|
|
216
|
+
title: "Run Later",
|
|
217
|
+
description: "Starts one of the space’s actions — one with a “later” trigger — in so many seconds, on the server.",
|
|
143
218
|
params: {
|
|
144
|
-
|
|
219
|
+
action: {
|
|
145
220
|
type: "text",
|
|
146
221
|
canBind: true,
|
|
147
222
|
defaultValue: "",
|
|
148
|
-
label: "
|
|
223
|
+
label: "Action (its identifier)"
|
|
149
224
|
},
|
|
150
|
-
|
|
225
|
+
in: {
|
|
151
226
|
type: "text",
|
|
152
227
|
canBind: true,
|
|
153
|
-
defaultValue: "
|
|
154
|
-
label: "
|
|
228
|
+
defaultValue: "30",
|
|
229
|
+
label: "In (seconds)"
|
|
155
230
|
},
|
|
156
|
-
|
|
157
|
-
type: "
|
|
231
|
+
input: {
|
|
232
|
+
type: "codemirror-json",
|
|
158
233
|
canBind: true,
|
|
159
|
-
defaultValue: "
|
|
160
|
-
label: "
|
|
234
|
+
defaultValue: "{}",
|
|
235
|
+
label: "Its input"
|
|
161
236
|
},
|
|
162
|
-
|
|
163
|
-
type: "select",
|
|
164
|
-
canBind: true,
|
|
165
|
-
defaultValue: "caller",
|
|
166
|
-
label: "Counted",
|
|
167
|
-
options: [{
|
|
168
|
-
label: "For each person",
|
|
169
|
-
value: "caller"
|
|
170
|
-
}, {
|
|
171
|
-
label: "For everyone together",
|
|
172
|
-
value: "everyone"
|
|
173
|
-
}]
|
|
174
|
-
},
|
|
175
|
-
message: {
|
|
237
|
+
key: {
|
|
176
238
|
type: "text",
|
|
177
239
|
canBind: true,
|
|
178
240
|
defaultValue: "",
|
|
179
|
-
label: "
|
|
241
|
+
label: "Known as (replaces what waits under it)"
|
|
180
242
|
}
|
|
181
243
|
},
|
|
182
|
-
run: async ({
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
244
|
+
run: async ({ action, in: seconds, input, key }, ctx) => {
|
|
245
|
+
if (!ctx.later) throw new Error("This server runs no jobs: nothing can be set to run later");
|
|
246
|
+
const given = jsonInput(input);
|
|
247
|
+
return ctx.later({
|
|
248
|
+
action,
|
|
249
|
+
in: Number(seconds),
|
|
250
|
+
input: typeof given === "object" && given !== null && !Array.isArray(given) ? { ...given } : {},
|
|
251
|
+
...key === "" ? {} : { key }
|
|
188
252
|
});
|
|
189
|
-
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
namespace: "flow",
|
|
257
|
+
action: "cancelLater",
|
|
258
|
+
title: "Cancel Later",
|
|
259
|
+
description: "Drops the runs set for later under a key that are still waiting.",
|
|
260
|
+
params: { key: {
|
|
261
|
+
type: "text",
|
|
262
|
+
canBind: true,
|
|
263
|
+
defaultValue: "",
|
|
264
|
+
label: "Known as"
|
|
265
|
+
} },
|
|
266
|
+
run: async ({ key }, ctx) => {
|
|
267
|
+
if (!ctx.cancelLater) throw new Error("This server runs no jobs: nothing was set to run later");
|
|
190
268
|
return {
|
|
191
|
-
|
|
192
|
-
|
|
269
|
+
key,
|
|
270
|
+
cancelled: await ctx.cancelLater(key)
|
|
193
271
|
};
|
|
194
272
|
}
|
|
195
273
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { LaterAnswer, LaterRequest } from './jobs/later';
|
|
1
2
|
import { ActionEmailSender } from './runtime/email';
|
|
2
3
|
import { FunctionsConfig } from '../functions/config';
|
|
3
4
|
import { SpaceFunctions } from '../functions/protocol';
|
|
@@ -196,6 +197,13 @@ export type ActionTaskContext = {
|
|
|
196
197
|
grant?: (topic: string, ttlSeconds?: number) => Promise<string>;
|
|
197
198
|
/** Revokes `grant` for `topic` — or every grant for it, naming none — and lets go whoever is on it with one. */
|
|
198
199
|
revoke?: (topic: string, grant?: string) => Promise<void>;
|
|
200
|
+
/**
|
|
201
|
+
* Starts one of this space's actions in so many seconds, by its `later` trigger (`jobs/later.ts`) — `undefined` when
|
|
202
|
+
* the server runs no jobs. What `flow.later` and a function's `ctx.later` go through.
|
|
203
|
+
*/
|
|
204
|
+
later?: (request: LaterRequest) => Promise<LaterAnswer>;
|
|
205
|
+
/** Drops the runs waiting under `key`, and answers how many. */
|
|
206
|
+
cancelLater?: (key: string) => Promise<number>;
|
|
199
207
|
/** Signs with the space's own key (`ActionsConfig.signingSecret`) — `undefined` when the deployment gave none. */
|
|
200
208
|
sign?: SpaceSigning['sign'];
|
|
201
209
|
verify?: SpaceSigning['verify'];
|
|
@@ -55,7 +55,7 @@ var sourcePlugin = (files) => ({
|
|
|
55
55
|
path: CONTRACT_SPECIFIER,
|
|
56
56
|
namespace: "contract"
|
|
57
57
|
};
|
|
58
|
-
if (args.kind !== "entry-point" && !args.path.startsWith(".")) return { errors: [{ text: `"${args.path}" cannot be imported: functions import their own files and ${CONTRACT_SPECIFIER}, nothing else
|
|
58
|
+
if (args.kind !== "entry-point" && !args.path.startsWith(".")) return { errors: [{ text: `"${args.path}" cannot be imported: functions import their own files and ${CONTRACT_SPECIFIER}, nothing else — a package of web APIs is bundled in as one of their files with \`plitzi functions add ${args.path.startsWith("node:") ? "<package>" : args.path}\`` }] };
|
|
59
59
|
const base = args.kind === "entry-point" ? "." : path.posix.dirname(args.importer);
|
|
60
60
|
const wanted = normalize(path.posix.join(base, args.path));
|
|
61
61
|
if (wanted === ".." || wanted.startsWith("../")) return { errors: [{ text: outsideFunctions(args.path, wanted) }] };
|
|
@@ -129,6 +129,17 @@ var fetchInitOf = (value) => {
|
|
|
129
129
|
};
|
|
130
130
|
};
|
|
131
131
|
var optionalString = (value, what) => value === void 0 || value === null ? void 0 : stringArg(value, what);
|
|
132
|
+
/** What a run set for later is checked against where it is queued (`laterFor`); here only that it is what it claims. */
|
|
133
|
+
var laterOf = (value) => {
|
|
134
|
+
const request = recordArg(value, "A run set for later");
|
|
135
|
+
const key = optionalString(request.key, "A key");
|
|
136
|
+
return {
|
|
137
|
+
action: stringArg(request.action, "An action"),
|
|
138
|
+
in: numberArg(request.in, "in"),
|
|
139
|
+
...request.input === void 0 ? {} : { input: recordArg(request.input, "An input") },
|
|
140
|
+
...key === void 0 ? {} : { key }
|
|
141
|
+
};
|
|
142
|
+
};
|
|
132
143
|
/** A limit's numbers are checked where it is counted (`countRate`); here only that they are what they claim. */
|
|
133
144
|
var rateLimitOf = (value) => {
|
|
134
145
|
const limit = recordArg(value, "A rate limit");
|
|
@@ -177,6 +188,14 @@ var readCall = (value) => {
|
|
|
177
188
|
...grant === void 0 ? {} : { grant }
|
|
178
189
|
};
|
|
179
190
|
}
|
|
191
|
+
case "later": return {
|
|
192
|
+
op: "later",
|
|
193
|
+
request: laterOf(call.request)
|
|
194
|
+
};
|
|
195
|
+
case "cancelLater": return {
|
|
196
|
+
op: "cancelLater",
|
|
197
|
+
key: stringArg(call.key, "A key")
|
|
198
|
+
};
|
|
180
199
|
case "rateLimit": return {
|
|
181
200
|
op: "rateLimit",
|
|
182
201
|
bucket: stringArg(call.bucket, "A bucket"),
|
|
@@ -217,6 +236,8 @@ var answerCall = async (ctx, call) => {
|
|
|
217
236
|
case "publish": return ctx.publish(call.topic, call.type, call.data);
|
|
218
237
|
case "grant": return ctx.grant(call.topic, call.ttlSeconds);
|
|
219
238
|
case "revoke": return ctx.revoke(call.topic, call.grant);
|
|
239
|
+
case "later": return ctx.later(call.request);
|
|
240
|
+
case "cancelLater": return ctx.cancelLater(call.key);
|
|
220
241
|
case "rateLimit": return ctx.rateLimit(call.bucket, call.limit);
|
|
221
242
|
case "sign": return ctx.sign(call.value);
|
|
222
243
|
case "verify": return ctx.verify(call.value, call.signature);
|
|
@@ -64,6 +64,7 @@ var lineOf = (values) => values.map((value) => {
|
|
|
64
64
|
}).join(" ");
|
|
65
65
|
var unavailable = (what) => () => Promise.reject(/* @__PURE__ */ new Error(`This server has no realtime channels (${what})`));
|
|
66
66
|
var unsigned = () => Promise.reject(/* @__PURE__ */ new Error("This server signs nothing: its actions were given no signingSecret"));
|
|
67
|
+
var noJobs = () => Promise.reject(/* @__PURE__ */ new Error("This server runs no jobs: nothing can be set to run later"));
|
|
67
68
|
/**
|
|
68
69
|
* A plugin reads none of its space's data. The space keeps there what its pages must not carry, and a plugin was trusted
|
|
69
70
|
* to draw, not with what the space keeps — the same line as its credentials: handed the data, its `fetch` could send
|
|
@@ -97,11 +98,14 @@ var functionContextFor = (ctx, hosts, scope) => {
|
|
|
97
98
|
publish: ctx.publish ?? unavailable("publish"),
|
|
98
99
|
grant: ctx.grant ?? unavailable("grant"),
|
|
99
100
|
revoke: ctx.revoke ?? unavailable("revoke"),
|
|
101
|
+
later: ctx.later ?? noJobs,
|
|
102
|
+
cancelLater: ctx.cancelLater ?? noJobs,
|
|
100
103
|
data: ctx.data
|
|
101
104
|
};
|
|
102
105
|
const kv = prefixKv(ctx.kv, pluginKvPrefix(scope.plugin));
|
|
103
106
|
const { sign, verify } = ctx;
|
|
104
107
|
const noChannel = () => Promise.reject(/* @__PURE__ */ new Error("The functions of a plugin reach none of the realtime channels of its space"));
|
|
108
|
+
const noLater = () => Promise.reject(/* @__PURE__ */ new Error("The functions of a plugin set none of the actions of its space to run"));
|
|
105
109
|
return {
|
|
106
110
|
...shared,
|
|
107
111
|
kv,
|
|
@@ -111,6 +115,8 @@ var functionContextFor = (ctx, hosts, scope) => {
|
|
|
111
115
|
publish: noChannel,
|
|
112
116
|
grant: noChannel,
|
|
113
117
|
revoke: noChannel,
|
|
118
|
+
later: noLater,
|
|
119
|
+
cancelLater: noLater,
|
|
114
120
|
data: noData
|
|
115
121
|
};
|
|
116
122
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { LaterAnswer, LaterRequest } from '../actions/jobs/later';
|
|
1
2
|
import { RateCount, RateLimit } from '../actions/runtime/rateLimit';
|
|
2
3
|
import { ActionKvStore } from '../actions/types';
|
|
3
4
|
import { Environment, FunctionTimeLimits, InteractionCallbackParam } from '@plitzi/sdk-shared';
|
|
@@ -67,6 +68,15 @@ export type FunctionContext = {
|
|
|
67
68
|
grant: (topic: string, ttlSeconds?: number) => Promise<string>;
|
|
68
69
|
/** Takes a grant back — or every grant for the topic, naming none. */
|
|
69
70
|
revoke: (topic: string, grant?: string) => Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* Starts one of the space's actions — one with a `later` trigger — in so many seconds, on the server, whether or not a
|
|
73
|
+
* page is still open: a turn that runs out, a bot's move, a hold that lapses. A `key` names it: set again under the
|
|
74
|
+
* same key, the one still waiting is replaced. Answers the job and when it is due (epoch ms, by the queue's clock).
|
|
75
|
+
* `ctx.later({ action: 'game.timeout', in: 45, input: { room }, key: 'turn:' + room })`.
|
|
76
|
+
*/
|
|
77
|
+
later: (request: LaterRequest) => Promise<LaterAnswer>;
|
|
78
|
+
/** Drops what waits under `key` — the timer of a game that ended — and answers how many. */
|
|
79
|
+
cancelLater: (key: string) => Promise<number>;
|
|
70
80
|
/**
|
|
71
81
|
* One file of the space's data — a project's `src/data/<file>` — parsed, as of the version the run belongs to: what
|
|
72
82
|
* a page must not carry, read here and answered only as far as it is shown. Read-only. `ctx.data('products.json')`.
|
|
@@ -99,6 +99,14 @@ var createFunctionsDriver = (environment) => {
|
|
|
99
99
|
grant
|
|
100
100
|
});
|
|
101
101
|
},
|
|
102
|
+
later: (request) => call({
|
|
103
|
+
op: "later",
|
|
104
|
+
request
|
|
105
|
+
}),
|
|
106
|
+
cancelLater: (key) => call({
|
|
107
|
+
op: "cancelLater",
|
|
108
|
+
key
|
|
109
|
+
}),
|
|
102
110
|
rateLimit: (bucket, limit) => call({
|
|
103
111
|
op: "rateLimit",
|
|
104
112
|
bucket,
|
|
@@ -3,7 +3,7 @@ import { FunctionLimits } from './protocol';
|
|
|
3
3
|
import { ActionRunResult } from '../actions';
|
|
4
4
|
import { FunctionsProblem } from '@plitzi/sdk-shared';
|
|
5
5
|
export type LocalFunctionsOptions = {
|
|
6
|
-
/** Credentials `ctx.fetch` may name, by id —
|
|
6
|
+
/** Credentials `ctx.fetch` may name, by id — by default the project's own, from its environment (`PLITZI_CREDENTIALS`). */
|
|
7
7
|
credentials?: Record<string, Record<string, string>>;
|
|
8
8
|
/** As the platform's: a plan's limits, for code that must fit them there. */
|
|
9
9
|
limits?: Partial<FunctionLimits>;
|
|
@@ -3,6 +3,7 @@ import { functionsInHand } from "./space.js";
|
|
|
3
3
|
import { createActionsModule } from "../actions/index.js";
|
|
4
4
|
import { functionTryEntry } from "./tryEntry.js";
|
|
5
5
|
import { createIsolateRunner } from "./sandbox/isolate.js";
|
|
6
|
+
import { credentialsFromEnv } from "../actions/runtime/credentials.js";
|
|
6
7
|
import { randomUUID } from "node:crypto";
|
|
7
8
|
//#region src/modules/functions/local.ts
|
|
8
9
|
/** Who a local run is for: somebody signed in, with nothing to forward. */
|
|
@@ -20,7 +21,7 @@ var LOCAL_USER = {
|
|
|
20
21
|
* isolates and limits, `ctx` over the same actions module — so what works here works there. `kv` lives as long as this
|
|
21
22
|
* does. What `plitzi functions dev` is made of; needs `isolated-vm` and `core-js` installed, as any runner does.
|
|
22
23
|
*/
|
|
23
|
-
var createLocalFunctions = ({ credentials =
|
|
24
|
+
var createLocalFunctions = ({ credentials = credentialsFromEnv(), limits, dataDir, fetchImpl } = {}) => {
|
|
24
25
|
const runner = createIsolateRunner({ concurrency: 1 });
|
|
25
26
|
let loaded;
|
|
26
27
|
const module = createActionsModule({
|
|
@@ -57,6 +57,11 @@ var paramOf = (value) => {
|
|
|
57
57
|
type: value.type,
|
|
58
58
|
...typeof defaultValue === "string" || typeof defaultValue === "number" ? { defaultValue } : {}
|
|
59
59
|
};
|
|
60
|
+
case "number": return {
|
|
61
|
+
...base,
|
|
62
|
+
type: "number",
|
|
63
|
+
...typeof defaultValue === "number" ? { defaultValue } : {}
|
|
64
|
+
};
|
|
60
65
|
case "codemirror-text":
|
|
61
66
|
case "codemirror-json": return {
|
|
62
67
|
...base,
|
|
@@ -84,7 +89,7 @@ var paramOf = (value) => {
|
|
|
84
89
|
...typeof defaultValue === "string" ? { defaultValue } : {}
|
|
85
90
|
};
|
|
86
91
|
}
|
|
87
|
-
default: return `has a type the builder cannot draw ("${String(value.type)}"): text, textarea, codemirror-text, codemirror-json, boolean, elements or select`;
|
|
92
|
+
default: return `has a type the builder cannot draw ("${String(value.type)}"): text, number, textarea, codemirror-text, codemirror-json, boolean, elements or select`;
|
|
88
93
|
}
|
|
89
94
|
};
|
|
90
95
|
/** What each asked limit is, in words a problem is written in. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FunctionContext, FunctionFetchInit } from './contract';
|
|
2
|
+
import { LaterRequest } from '../actions/jobs/later';
|
|
2
3
|
import { RateLimit } from '../actions/runtime/rateLimit';
|
|
3
4
|
import { FunctionsManifest } from '@plitzi/sdk-shared';
|
|
4
5
|
/**
|
|
@@ -93,6 +94,12 @@ export type FunctionCall = {
|
|
|
93
94
|
op: 'revoke';
|
|
94
95
|
topic: string;
|
|
95
96
|
grant?: string;
|
|
97
|
+
} | {
|
|
98
|
+
op: 'later';
|
|
99
|
+
request: LaterRequest;
|
|
100
|
+
} | {
|
|
101
|
+
op: 'cancelLater';
|
|
102
|
+
key: string;
|
|
96
103
|
} | {
|
|
97
104
|
op: 'rateLimit';
|
|
98
105
|
bucket: string;
|
|
@@ -86,6 +86,11 @@ var createMongoJobQueue = ({ db, collections: names = {}, retentionDays = 90 })
|
|
|
86
86
|
actionId: 1,
|
|
87
87
|
updatedAt: -1
|
|
88
88
|
}),
|
|
89
|
+
jobsOf().createIndex({
|
|
90
|
+
spaceId: 1,
|
|
91
|
+
key: 1,
|
|
92
|
+
status: 1
|
|
93
|
+
}, { partialFilterExpression: { key: { $exists: true } } }),
|
|
89
94
|
jobsOf().createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }),
|
|
90
95
|
schedulesOf().createIndex({
|
|
91
96
|
enabled: 1,
|
|
@@ -328,6 +333,27 @@ var createMongoJobQueue = ({ db, collections: names = {}, retentionDays = 90 })
|
|
|
328
333
|
cancelRequested: true,
|
|
329
334
|
updatedAt: at
|
|
330
335
|
} })).matchedCount === 1;
|
|
336
|
+
},
|
|
337
|
+
cancelPending: async ({ spaceId, key, olderThan }) => {
|
|
338
|
+
const { jobs } = await collections();
|
|
339
|
+
const newest = olderThan === void 0 ? void 0 : await jobs.findOne({ _id: olderThan });
|
|
340
|
+
const at = await clock.now();
|
|
341
|
+
return (await jobs.updateMany({
|
|
342
|
+
spaceId,
|
|
343
|
+
key,
|
|
344
|
+
status: "pending",
|
|
345
|
+
...newest ? { $or: [{ createdAt: { $lt: newest.createdAt } }, {
|
|
346
|
+
createdAt: newest.createdAt,
|
|
347
|
+
_id: { $lt: newest._id }
|
|
348
|
+
}] } : {}
|
|
349
|
+
}, {
|
|
350
|
+
$set: {
|
|
351
|
+
status: "cancelled",
|
|
352
|
+
updatedAt: at,
|
|
353
|
+
expiresAt: expiryOf(at)
|
|
354
|
+
},
|
|
355
|
+
$unset: { workerId: "" }
|
|
356
|
+
})).modifiedCount;
|
|
331
357
|
}
|
|
332
358
|
};
|
|
333
359
|
};
|
|
@@ -28,6 +28,7 @@ var toJob = (row) => ({
|
|
|
28
28
|
dueAt: Number(row.due_at),
|
|
29
29
|
maxAttempts: Number(row.max_attempts),
|
|
30
30
|
...row.missed === null ? {} : { missed: Number(row.missed) },
|
|
31
|
+
...row.job_key === null ? {} : { key: row.job_key },
|
|
31
32
|
status: row.status,
|
|
32
33
|
runAt: Number(row.run_at),
|
|
33
34
|
attempts: Number(row.attempts),
|
|
@@ -128,8 +129,8 @@ var createMysqlJobQueue = ({ pool, tablePrefix = "", createTables = true, retent
|
|
|
128
129
|
const at = await clock.now();
|
|
129
130
|
try {
|
|
130
131
|
await execute(conn, `INSERT INTO ${t.jobs} (id, space_id, action_id, environment, trigger_type, input, due_at, max_attempts,
|
|
131
|
-
missed, status, run_at, attempts, created_at, updated_at, history)
|
|
132
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, 0, ?, ?, '[]')`, [
|
|
132
|
+
missed, job_key, status, run_at, attempts, created_at, updated_at, history)
|
|
133
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, 0, ?, ?, '[]')`, [
|
|
133
134
|
job.id,
|
|
134
135
|
job.spaceId,
|
|
135
136
|
job.actionId,
|
|
@@ -139,6 +140,7 @@ var createMysqlJobQueue = ({ pool, tablePrefix = "", createTables = true, retent
|
|
|
139
140
|
job.dueAt,
|
|
140
141
|
job.maxAttempts,
|
|
141
142
|
job.missed ?? null,
|
|
143
|
+
job.key ?? null,
|
|
142
144
|
job.dueAt,
|
|
143
145
|
at,
|
|
144
146
|
at
|
|
@@ -368,6 +370,23 @@ var createMysqlJobQueue = ({ pool, tablePrefix = "", createTables = true, retent
|
|
|
368
370
|
jobId,
|
|
369
371
|
spaceIds
|
|
370
372
|
])).affectedRows === 1;
|
|
373
|
+
},
|
|
374
|
+
cancelPending: async ({ spaceId, key, olderThan }) => {
|
|
375
|
+
const conn = await db();
|
|
376
|
+
const newest = olderThan === void 0 ? void 0 : await selectOne(conn, `SELECT id, created_at FROM ${t.jobs} WHERE id = ?`, [olderThan]);
|
|
377
|
+
const at = await clock.now();
|
|
378
|
+
return (await execute(conn, `UPDATE ${t.jobs} SET status = 'cancelled', updated_at = ?, expires_at = ?, worker_id = NULL
|
|
379
|
+
WHERE space_id = ? AND job_key = ? AND status = 'pending'${newest ? " AND (created_at < ? OR (created_at = ? AND id < ?))" : ""}`, [
|
|
380
|
+
at,
|
|
381
|
+
expiryOf(at),
|
|
382
|
+
spaceId,
|
|
383
|
+
key,
|
|
384
|
+
...newest ? [
|
|
385
|
+
newest.created_at,
|
|
386
|
+
newest.created_at,
|
|
387
|
+
newest.id
|
|
388
|
+
] : []
|
|
389
|
+
])).affectedRows;
|
|
371
390
|
}
|
|
372
391
|
};
|
|
373
392
|
};
|
|
@@ -23,8 +23,9 @@ export declare const jobTables: (prefix?: string) => JobTables;
|
|
|
23
23
|
*/
|
|
24
24
|
export declare const mysqlJobSchemaStatements: (prefix?: string) => string[];
|
|
25
25
|
/**
|
|
26
|
-
* What brings
|
|
27
|
-
* once. `ensureJobTables` does it by itself, and only
|
|
26
|
+
* What brings tables made before the columns above up to them — for a deployment that runs its own migrations: each
|
|
27
|
+
* statement once, the ones its tables still lack. `ensureJobTables` does it by itself, and only where a table needs
|
|
28
|
+
* it: an `ALTER` copies the table.
|
|
28
29
|
*/
|
|
29
30
|
export declare const mysqlJobSchemaUpgrades: (prefix?: string) => string[];
|
|
30
31
|
/** The server's now, in epoch ms, as an SQL expression — what every comparison reads instead of a caller's clock. */
|
|
@@ -38,6 +38,7 @@ var mysqlJobSchemaStatements = (prefix = "") => {
|
|
|
38
38
|
due_at BIGINT NOT NULL,
|
|
39
39
|
max_attempts INT NOT NULL,
|
|
40
40
|
missed INT NULL,
|
|
41
|
+
job_key VARCHAR(191) NULL,
|
|
41
42
|
status VARCHAR(16) NOT NULL,
|
|
42
43
|
run_at BIGINT NOT NULL,
|
|
43
44
|
attempts INT NOT NULL DEFAULT 0,
|
|
@@ -54,6 +55,7 @@ var mysqlJobSchemaStatements = (prefix = "") => {
|
|
|
54
55
|
KEY action_jobs_pending (status, run_at),
|
|
55
56
|
KEY action_jobs_lease (status, lease_until),
|
|
56
57
|
KEY action_jobs_space (space_id, updated_at),
|
|
58
|
+
KEY action_jobs_key (space_id, job_key, status),
|
|
57
59
|
KEY action_jobs_expires (expires_at)
|
|
58
60
|
) ${CHARSET}`,
|
|
59
61
|
`CREATE TABLE IF NOT EXISTS ${t.schedules} (
|
|
@@ -80,18 +82,29 @@ var mysqlJobSchemaStatements = (prefix = "") => {
|
|
|
80
82
|
) ${CHARSET}`
|
|
81
83
|
];
|
|
82
84
|
};
|
|
83
|
-
/**
|
|
84
|
-
|
|
85
|
-
* once. `ensureJobTables` does it by itself, and only when the table needs it: the `ALTER` copies the table.
|
|
86
|
-
*/
|
|
87
|
-
var mysqlJobSchemaUpgrades = (prefix = "") => [`ALTER TABLE ${jobTables(prefix).kv} MODIFY k VARBINARY(764) NOT NULL, MODIFY v MEDIUMTEXT NOT NULL`];
|
|
88
|
-
/** Whether the `kv` table still has the columns it was first made with. */
|
|
89
|
-
var kvNeedsUpgrade = async (db, prefix) => {
|
|
85
|
+
/** The columns a table of this database has, by name, with their types. */
|
|
86
|
+
var columnsOf = async (db, table) => {
|
|
90
87
|
const columns = await selectRows(db, `SELECT COLUMN_NAME AS name, DATA_TYPE AS type FROM INFORMATION_SCHEMA.COLUMNS
|
|
91
|
-
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME =
|
|
92
|
-
|
|
93
|
-
return typeOf("k") !== "varbinary" || typeOf("v") !== "mediumtext";
|
|
88
|
+
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`, [table]);
|
|
89
|
+
return new Map(columns.map((column) => [column.name, column.type.toLowerCase()]));
|
|
94
90
|
};
|
|
91
|
+
var UPGRADES = [{
|
|
92
|
+
needed: async (db, prefix) => {
|
|
93
|
+
const columns = await columnsOf(db, `${prefix}action_kv`);
|
|
94
|
+
return columns.get("k") !== "varbinary" || columns.get("v") !== "mediumtext";
|
|
95
|
+
},
|
|
96
|
+
statements: (prefix) => [`ALTER TABLE ${jobTables(prefix).kv} MODIFY k VARBINARY(764) NOT NULL, MODIFY v MEDIUMTEXT NOT NULL`]
|
|
97
|
+
}, {
|
|
98
|
+
needed: async (db, prefix) => !(await columnsOf(db, `${prefix}action_jobs`)).has("job_key"),
|
|
99
|
+
statements: (prefix) => [`ALTER TABLE ${jobTables(prefix).jobs} ADD COLUMN job_key VARCHAR(191) NULL AFTER missed,
|
|
100
|
+
ADD KEY action_jobs_key (space_id, job_key, status)`]
|
|
101
|
+
}];
|
|
102
|
+
/**
|
|
103
|
+
* What brings tables made before the columns above up to them — for a deployment that runs its own migrations: each
|
|
104
|
+
* statement once, the ones its tables still lack. `ensureJobTables` does it by itself, and only where a table needs
|
|
105
|
+
* it: an `ALTER` copies the table.
|
|
106
|
+
*/
|
|
107
|
+
var mysqlJobSchemaUpgrades = (prefix = "") => UPGRADES.flatMap((upgrade) => upgrade.statements(prefix));
|
|
95
108
|
/** The server's now, in epoch ms, as an SQL expression — what every comparison reads instead of a caller's clock. */
|
|
96
109
|
var NOW_MS = "CAST(ROUND(UNIX_TIMESTAMP(NOW(3)) * 1000) AS SIGNED)";
|
|
97
110
|
/** Creates the tables once per process, on first use; a failure is retried by the next call rather than remembered. */
|
|
@@ -101,7 +114,7 @@ var ensureJobTables = (db, prefix, create) => {
|
|
|
101
114
|
if (!create) return;
|
|
102
115
|
ready ??= (async () => {
|
|
103
116
|
for (const statement of mysqlJobSchemaStatements(prefix)) await execute(db, statement);
|
|
104
|
-
if (await
|
|
117
|
+
for (const upgrade of UPGRADES) if (await upgrade.needed(db, prefix)) for (const statement of upgrade.statements(prefix)) await execute(db, statement);
|
|
105
118
|
})().catch((error) => {
|
|
106
119
|
ready = void 0;
|
|
107
120
|
throw error;
|
|
@@ -3,11 +3,14 @@ import { ServerConfig } from '../../core/createServer';
|
|
|
3
3
|
import { ConnectorManifest } from '../connectors';
|
|
4
4
|
import { ActionEntry, SSRServer } from '@plitzi/sdk-shared';
|
|
5
5
|
/** What `serveProject` sets itself, from the project's layout — so never the project's options'. */
|
|
6
|
-
type SetByProject = 'port' | '
|
|
6
|
+
type SetByProject = 'port' | 'health' | 'adapters' | 'plugins' | 'publicDir' | 'dataDir' | 'functions' | 'action';
|
|
7
7
|
/**
|
|
8
8
|
* What a project's server does besides serving its space — its `src/config/serverOptions.ts`: what `createServer`
|
|
9
9
|
* takes, but for what `serveProject` wires from the project's layout, and an action's `lookups`, which are the
|
|
10
|
-
* project's `src/actions/`. `logLevel` and `logger` are the project's to change; `action.kv` too.
|
|
10
|
+
* project's `src/actions/`. `logLevel` and `logger` are the project's to change; `action.kv` too. So are `devMode` and
|
|
11
|
+
* `devReload`, which follow `NODE_ENV` when left out — on unless it is `production`. A deployment started without it (a
|
|
12
|
+
* container's `CMD`, a process manager with no environment) says `devMode: false` here, and a public action answers
|
|
13
|
+
* with its output alone, never the trace of every step.
|
|
11
14
|
*/
|
|
12
15
|
export type ProjectServerOptions = Partial<Omit<ServerConfig, SetByProject>> & {
|
|
13
16
|
action?: Omit<NonNullable<ServerConfig['action']>, 'lookups'>;
|