@plitzi/sdk-server 0.38.10 → 0.38.12

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 (71) hide show
  1. package/CHANGELOG.md +287 -0
  2. package/README.md +3 -3
  3. package/dist/auth.js +1 -1
  4. package/dist/core/auth/createAuth.js +1 -1
  5. package/dist/core/http/stages/signInRoute.js +1 -1
  6. package/dist/core/server/baseServer.js +11 -6
  7. package/dist/core/server/fleet/stores.d.ts +7 -0
  8. package/dist/core/server/fleet/stores.js +16 -2
  9. package/dist/core/server/fleet/supervisor.d.ts +2 -0
  10. package/dist/core/server/fleet/supervisor.js +14 -2
  11. package/dist/core/server/pageServer.js +1 -1
  12. package/dist/functions-api.d.ts +47 -2
  13. package/dist/helpers/cache/cacheManager.d.ts +9 -2
  14. package/dist/helpers/cache/cacheManager.js +46 -22
  15. package/dist/helpers/cache/keys.d.ts +12 -5
  16. package/dist/helpers/cache/keys.js +56 -11
  17. package/dist/modules/actions/index.js +6 -2
  18. package/dist/modules/actions/jobs/later.d.ts +43 -0
  19. package/dist/modules/actions/jobs/later.js +66 -0
  20. package/dist/modules/actions/jobs/later.test.d.ts +1 -0
  21. package/dist/modules/actions/jobs/memoryQueue.js +14 -1
  22. package/dist/modules/actions/jobs/worker.js +2 -2
  23. package/dist/modules/actions/runtime/credentials.d.ts +10 -0
  24. package/dist/modules/actions/runtime/credentials.js +28 -0
  25. package/dist/modules/actions/runtime/credentials.test.d.ts +1 -0
  26. package/dist/modules/actions/runtime/fileKv.d.ts +8 -3
  27. package/dist/modules/actions/runtime/fileKv.js +79 -7
  28. package/dist/modules/actions/runtime/precheck.js +1 -1
  29. package/dist/modules/actions/runtime/rateLimit.d.ts +7 -0
  30. package/dist/modules/actions/runtime/rateLimit.js +7 -1
  31. package/dist/modules/actions/runtime/runAction.d.ts +4 -1
  32. package/dist/modules/actions/runtime/runAction.js +46 -11
  33. package/dist/modules/actions/runtime/taskContext.d.ts +4 -1
  34. package/dist/modules/actions/runtime/taskContext.js +7 -1
  35. package/dist/modules/actions/tasks/flow.js +113 -35
  36. package/dist/modules/actions/types.d.ts +8 -0
  37. package/dist/modules/functions/build.js +1 -1
  38. package/dist/modules/functions/capabilities.js +21 -0
  39. package/dist/modules/functions/context.js +9 -3
  40. package/dist/modules/functions/contract.d.ts +10 -0
  41. package/dist/modules/functions/driver.js +16 -4
  42. package/dist/modules/functions/local.d.ts +1 -1
  43. package/dist/modules/functions/local.js +2 -1
  44. package/dist/modules/functions/manifest.js +8 -3
  45. package/dist/modules/functions/protocol.d.ts +7 -0
  46. package/dist/modules/mongo/jobQueue.js +26 -0
  47. package/dist/modules/mysql/jobs/jobQueue.js +21 -2
  48. package/dist/modules/mysql/jobs/schema.d.ts +3 -2
  49. package/dist/modules/mysql/jobs/schema.js +24 -11
  50. package/dist/modules/project/plugins.d.ts +1 -1
  51. package/dist/modules/project/serveProject.js +29 -4
  52. package/dist/modules/realtime/handlers.d.ts +4 -1
  53. package/dist/modules/realtime/handlers.js +8 -4
  54. package/dist/modules/realtime/hub.d.ts +3 -1
  55. package/dist/modules/realtime/hub.js +8 -4
  56. package/dist/modules/ssr/prepareRender.js +1 -1
  57. package/package.json +3 -3
  58. package/skills/plitzi-authoring/CHEATSHEET.md +4 -2
  59. package/skills/plitzi-authoring/recipes/plugin.ts +9 -7
  60. package/skills/plitzi-authoring/recipes/style-a-page.ts +1 -1
  61. package/skills/plitzi-authoring/reference/accessibility.md +1 -0
  62. package/skills/plitzi-authoring/reference/authoring-errors.md +8 -4
  63. package/skills/plitzi-authoring/reference/data-and-visibility.md +11 -10
  64. package/skills/plitzi-authoring/reference/elements-and-styles.md +1 -1
  65. package/skills/plitzi-authoring/reference/flows.md +6 -5
  66. package/skills/plitzi-authoring/reference/layouts.md +1 -0
  67. package/skills/plitzi-authoring/reference/lists.md +1 -0
  68. package/skills/plitzi-authoring/reference/plugins.md +43 -37
  69. package/skills/plitzi-authoring/reference/realtime.md +4 -1
  70. package/skills/plitzi-authoring/reference/rules.md +1 -1
  71. package/skills/plitzi-authoring/reference/typed-sources.md +6 -4
@@ -1,5 +1,6 @@
1
1
  import { serverLog } from "../../../helpers/serverLog.js";
2
- import { ActionRunError, isActionRefusal } from "./errors.js";
2
+ import "../jobs/schedules.js";
3
+ import { ActionRefusal, ActionRunError, isActionRefusal } from "./errors.js";
3
4
  import { runCancelKey } from "./guards.js";
4
5
  import { createKvStore } from "./kvStore.js";
5
6
  import { resolveLimits } from "./limits.js";
@@ -150,10 +151,12 @@ var MAX_LOG_LINES = 100;
150
151
  var MAX_LOG_LINE = 1e3;
151
152
  /**
152
153
  * A value written as text, read as the type its task declares: a `number` param written `5` — or bound to text that says
153
- * it — arrives as 5, a `boolean` one written `'true'` as `true`, an empty number as nothing (so the default applies). The
154
- * browser reads a callback's params the same way (`coerceDeclaredParams`): a task's author never writes `Number(…)`.
154
+ * it — arrives as 5, a `boolean` one written `'true'` as `true`, an empty number as nothing (so the default applies), and
155
+ * a `text` one bound to a number as its digits. The browser reads a callback's params the same way
156
+ * (`coerceDeclaredParams`): a task's author never writes `Number(…)` or `String(…)`.
155
157
  */
156
158
  var asDeclared = (type, value) => {
159
+ if (typeof value === "number" && (type === "text" || type === "textarea")) return String(value);
157
160
  if (typeof value !== "string") return value;
158
161
  if (type === "number") {
159
162
  const parsed = Number(value.trim());
@@ -161,11 +164,36 @@ var asDeclared = (type, value) => {
161
164
  }
162
165
  return type === "boolean" && (value === "true" || value === "false") ? value === "true" : value;
163
166
  };
164
- var withDefaults = (task, params) => Object.entries(task.params).reduce((acum, [key, param]) => {
165
- const value = asDeclared(typeof param.type === "function" ? param.type(acum) : param.type, acum[key]);
166
- acum[key] = value === void 0 || value === "" ? param.defaultValue ?? "" : value;
167
- return acum;
168
- }, { ...params });
167
+ /**
168
+ * Why a value does not fit the rules its param declares — `required`, a text's `maxLength`, a number's `min` and `max` —
169
+ * in the words the page shows: what a task's code otherwise checked by hand before doing anything. A param that
170
+ * declares none is never judged.
171
+ */
172
+ var paramProblem = (key, param, value) => {
173
+ const name = param.label ?? key;
174
+ if (value === void 0 || value === null || typeof value === "string" && value.trim() === "") {
175
+ const defaulted = param.defaultValue !== void 0 && param.defaultValue !== "";
176
+ return param.required && !defaulted ? `${name} is required` : void 0;
177
+ }
178
+ if ((param.type === "text" || param.type === "textarea") && param.maxLength !== void 0) return typeof value === "string" && value.length > param.maxLength ? `${name} is longer than ${String(param.maxLength)} characters` : void 0;
179
+ if (param.type === "number" && (param.min !== void 0 || param.max !== void 0)) {
180
+ if (typeof value !== "number") return `${name} is not a number`;
181
+ if (param.min !== void 0 && value < param.min) return `${name} is at least ${String(param.min)}`;
182
+ if (param.max !== void 0 && value > param.max) return `${name} is at most ${String(param.max)}`;
183
+ }
184
+ };
185
+ var withDefaults = (task, params) => {
186
+ const problems = [];
187
+ const values = Object.entries(task.params).reduce((acum, [key, param]) => {
188
+ const value = asDeclared(typeof param.type === "function" ? param.type(acum) : param.type, acum[key]);
189
+ const problem = paramProblem(key, param, value);
190
+ if (problem) problems.push(problem);
191
+ acum[key] = value === void 0 || value === "" ? param.defaultValue ?? "" : value;
192
+ return acum;
193
+ }, { ...params });
194
+ if (problems.length > 0) throw new ActionRefusal(problems.join("; "));
195
+ return values;
196
+ };
169
197
  var runNode = async (node, scope, registry, buildContext) => {
170
198
  if (!node.action || !node.enabled) return {
171
199
  status: "disabled",
@@ -194,13 +222,18 @@ var runNode = async (node, scope, registry, buildContext) => {
194
222
  * input contract — lives HERE rather than in a transport, because a deployment mounting its own trigger calls this
195
223
  * directly. A check in the endpoint would be a check a custom trigger silently skips.
196
224
  */
197
- var createActionRunner = (config, registryFor, baseFetch = fetch) => {
225
+ var createActionRunner = (config, registryFor, baseFetch = fetch, jobsQueue = () => void 0) => {
198
226
  const kv = createKvStore(config.kv ?? createMemoryKv());
199
227
  const emailSender = createEmailSender({
200
228
  ...config.email,
201
229
  kv
202
230
  });
203
231
  const signing = config.signingSecret ? createSigning(config.signingSecret) : void 0;
232
+ const later = {
233
+ queue: jobsQueue,
234
+ lookups: config.lookups,
235
+ maxAttempts: (config.jobs === false ? void 0 : config.jobs?.maxAttempts) ?? 3
236
+ };
204
237
  /** Never allowed to fail a run: a logging outage must not take an action down, the same rule metering follows. */
205
238
  const record = async (entry) => {
206
239
  try {
@@ -236,7 +269,8 @@ var createActionRunner = (config, registryFor, baseFetch = fetch) => {
236
269
  kv,
237
270
  email: emailSender,
238
271
  redactor,
239
- signing
272
+ signing,
273
+ later
240
274
  }, request, signal, runFetch);
241
275
  /** The request budget ran out: said on the server's log at once, and in the step that hit it. */
242
276
  let budgetSpent;
@@ -548,7 +582,8 @@ var createActionRunner = (config, registryFor, baseFetch = fetch) => {
548
582
  kv,
549
583
  email: emailSender,
550
584
  redactor: createRedactor(),
551
- signing
585
+ signing,
586
+ later
552
587
  }, contextRequest, signal, createRunFetch(baseFetch, signal, resolveLimits(config.limits, void 0), lineage))({});
553
588
  return {
554
589
  runAction,
@@ -1,6 +1,7 @@
1
1
  import { createEmailSender } from './email';
2
2
  import { createRedactor } from './scope';
3
3
  import { createSigning } from './signing';
4
+ import { LaterDeps } from '../jobs/later';
4
5
  import { ActionKvStore, ActionRunRequest, ActionsConfig, ActionTaskContext, ResolvedActionLimits } from '../types';
5
6
  /**
6
7
  * Counts outbound calls, refuses past the budget, caps what one answer may carry back, and stamps the run's
@@ -28,6 +29,8 @@ export type TaskContextDeps = {
28
29
  redactor: ReturnType<typeof createRedactor>;
29
30
  /** Absent when the deployment gave no `signingSecret`: its spaces then sign nothing. */
30
31
  signing?: ReturnType<typeof createSigning>;
32
+ /** What runs set for later go through: the module's queue, read when asked. */
33
+ later: LaterDeps;
31
34
  };
32
35
  /** Who and what the context is for — a run's request, or anything shaped like one (a function's route). */
33
36
  export type TaskContextRequest = Pick<ActionRunRequest, 'runId' | 'spaceId' | 'environment' | 'trigger' | 'user' | 'callerId' | 'at' | 'emit'>;
@@ -36,4 +39,4 @@ export type TaskContextRequest = Pick<ActionRunRequest, 'runId' | 'spaceId' | 'e
36
39
  * every secret it resolves registered with the redactor, the space's `kv` namespace, the run's outbound budget, the
37
40
  * space's channels. Everything but `log`, which is the STEP's: each step keeps what it logged.
38
41
  */
39
- export declare const taskContextFor: (config: ActionsConfig, { kv, email, redactor, signing }: TaskContextDeps, request: TaskContextRequest, signal: AbortSignal, runFetch: typeof fetch) => ((scope: Record<string, unknown>) => Omit<ActionTaskContext, "log">);
42
+ export declare const taskContextFor: (config: ActionsConfig, { kv, email, redactor, signing, later }: TaskContextDeps, request: TaskContextRequest, signal: AbortSignal, runFetch: typeof fetch) => ((scope: Record<string, unknown>) => Omit<ActionTaskContext, "log">);
@@ -1,6 +1,7 @@
1
1
  import { ActionRunError } from "./errors.js";
2
2
  import { namespaceKv } from "./namespaceKv.js";
3
3
  import { projectDataReader } from "./projectData.js";
4
+ import { laterFor } from "../jobs/later.js";
4
5
  //#region src/modules/actions/runtime/taskContext.ts
5
6
  /**
6
7
  * Holds an answer to what the run is allowed to carry.
@@ -66,7 +67,7 @@ var createRunFetch = (base, signal, limits, lineage, onBudgetSpent) => {
66
67
  * every secret it resolves registered with the redactor, the space's `kv` namespace, the run's outbound budget, the
67
68
  * space's channels. Everything but `log`, which is the STEP's: each step keeps what it logged.
68
69
  */
69
- var taskContextFor = (config, { kv, email, redactor, signing }, request, signal, runFetch) => {
70
+ var taskContextFor = (config, { kv, email, redactor, signing, later }, request, signal, runFetch) => {
70
71
  const { realtime } = config;
71
72
  const readData = projectDataReader(config.lookups.getData, request.spaceId, request.at);
72
73
  return (scope) => ({
@@ -111,6 +112,11 @@ var taskContextFor = (config, { kv, email, redactor, signing }, request, signal,
111
112
  spaceId: request.spaceId,
112
113
  environment: request.environment
113
114
  }),
115
+ ...later.queue() ? laterFor(later, {
116
+ spaceId: request.spaceId,
117
+ environment: request.environment,
118
+ ...request.at ? { at: request.at } : {}
119
+ }) : {},
114
120
  ...realtime ? {
115
121
  publish: (topic, type, data) => realtime.publish({
116
122
  spaceId: request.spaceId,
@@ -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: "rateLimit",
142
- title: "Rate Limit",
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
- bucket: {
219
+ action: {
145
220
  type: "text",
146
221
  canBind: true,
147
222
  defaultValue: "",
148
- label: "Name (what is counted — \"comments\")"
223
+ label: "Action (its identifier)"
149
224
  },
150
- limit: {
225
+ in: {
151
226
  type: "text",
152
227
  canBind: true,
153
- defaultValue: "10",
154
- label: "At most"
228
+ defaultValue: "30",
229
+ label: "In (seconds)"
155
230
  },
156
- windowSeconds: {
157
- type: "text",
231
+ input: {
232
+ type: "codemirror-json",
158
233
  canBind: true,
159
- defaultValue: "60",
160
- label: "Every (seconds)"
234
+ defaultValue: "{}",
235
+ label: "Its input"
161
236
  },
162
- per: {
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: "Refusal (what the caller is told)"
241
+ label: "Known as (replaces what waits under it)"
180
242
  }
181
243
  },
182
- run: async ({ bucket, limit, windowSeconds, per, message }, ctx) => {
183
- const perSeconds = Number(windowSeconds);
184
- const { allowed, count, remaining } = await countRate(ctx.kv, ctx.callerId, bucket, {
185
- most: Number(limit),
186
- perSeconds,
187
- per: per === "everyone" ? "everyone" : "caller"
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
- if (!allowed) throw new ActionRefusal(message || `Too many at once — try again in ${String(perSeconds)} seconds`);
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
- count,
192
- remaining
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);
@@ -1,6 +1,6 @@
1
1
  import { prefixKv } from "../actions/runtime/namespaceKv.js";
2
2
  import { fetchOutbound } from "../../helpers/outboundGuard.js";
3
- import { countRate } from "../actions/runtime/rateLimit.js";
3
+ import { countRate, refusedOver } from "../actions/runtime/rateLimit.js";
4
4
  import { pluginKvPrefix, pluginSigned } from "./scope.js";
5
5
  import { processTwigParam } from "@plitzi/sdk-shared/helpers/twigWrapper";
6
6
  //#region src/modules/functions/context.ts
@@ -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
@@ -91,26 +92,31 @@ var functionContextFor = (ctx, hosts, scope) => {
91
92
  if (!scope) return {
92
93
  ...shared,
93
94
  kv: ctx.kv,
94
- rateLimit: (bucket, limit) => countRate(ctx.kv, ctx.callerId, bucket, limit),
95
+ rateLimit: async (bucket, limit) => refusedOver(await countRate(ctx.kv, ctx.callerId, bucket, limit), limit),
95
96
  sign: ctx.sign ?? unsigned,
96
97
  verify: ctx.verify ?? unsigned,
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,
108
- rateLimit: (bucket, limit) => countRate(kv, ctx.callerId, bucket, limit),
112
+ rateLimit: async (bucket, limit) => refusedOver(await countRate(kv, ctx.callerId, bucket, limit), limit),
109
113
  sign: sign ? (value) => sign(pluginSigned(scope.plugin, value)) : unsigned,
110
114
  verify: verify ? (value, signature) => verify(pluginSigned(scope.plugin, value), signature) : unsigned,
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,11 +99,23 @@ var createFunctionsDriver = (environment) => {
99
99
  grant
100
100
  });
101
101
  },
102
- rateLimit: (bucket, limit) => call({
103
- op: "rateLimit",
104
- bucket,
105
- limit
102
+ later: (request) => call({
103
+ op: "later",
104
+ request
106
105
  }),
106
+ cancelLater: (key) => call({
107
+ op: "cancelLater",
108
+ key
109
+ }),
110
+ rateLimit: async (bucket, limit) => {
111
+ const count = await call({
112
+ op: "rateLimit",
113
+ bucket,
114
+ limit
115
+ });
116
+ if (isRecord(limit) && typeof limit.refuse === "string" && isRecord(count) && count.allowed === false) throw refusal(limit.refuse);
117
+ return count;
118
+ },
107
119
  sign: (value) => call({
108
120
  op: "sign",
109
121
  value
@@ -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 — a project's own, from its environment; none by default. */
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 = {}, limits, dataDir, fetchImpl } = {}) => {
24
+ var createLocalFunctions = ({ credentials = credentialsFromEnv(), limits, dataDir, fetchImpl } = {}) => {
24
25
  const runner = createIsolateRunner({ concurrency: 1 });
25
26
  let loaded;
26
27
  const module = createActionsModule({
@@ -47,20 +47,25 @@ var paramOf = (value) => {
47
47
  const base = {
48
48
  ...typeof value.canBind === "boolean" ? { canBind: value.canBind } : {},
49
49
  ...typeof value.label === "string" ? { label: value.label } : {},
50
- ...typeof value.when === "boolean" ? { when: value.when } : {}
50
+ ...typeof value.when === "boolean" ? { when: value.when } : {},
51
+ ...typeof value.required === "boolean" ? { required: value.required } : {}
51
52
  };
53
+ const bound = (key) => typeof value[key] === "number" && Number.isFinite(value[key]) ? { [key]: value[key] } : {};
52
54
  const defaultValue = value.defaultValue;
53
55
  switch (value.type) {
54
56
  case "text":
55
57
  case "textarea": return {
56
58
  ...base,
57
59
  type: value.type,
58
- ...typeof defaultValue === "string" || typeof defaultValue === "number" ? { defaultValue } : {}
60
+ ...typeof defaultValue === "string" || typeof defaultValue === "number" ? { defaultValue } : {},
61
+ ...bound("maxLength")
59
62
  };
60
63
  case "number": return {
61
64
  ...base,
62
65
  type: "number",
63
- ...typeof defaultValue === "number" ? { defaultValue } : {}
66
+ ...typeof defaultValue === "number" ? { defaultValue } : {},
67
+ ...bound("min"),
68
+ ...bound("max")
64
69
  };
65
70
  case "codemirror-text":
66
71
  case "codemirror-json": return {
@@ -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
  };