@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +263 -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 +29 -0
  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/runAction.d.ts +4 -1
  30. package/dist/modules/actions/runtime/runAction.js +26 -4
  31. package/dist/modules/actions/runtime/taskContext.d.ts +4 -1
  32. package/dist/modules/actions/runtime/taskContext.js +7 -1
  33. package/dist/modules/actions/tasks/flow.js +113 -35
  34. package/dist/modules/actions/types.d.ts +8 -0
  35. package/dist/modules/functions/build.js +1 -1
  36. package/dist/modules/functions/capabilities.js +21 -0
  37. package/dist/modules/functions/context.js +6 -0
  38. package/dist/modules/functions/contract.d.ts +10 -0
  39. package/dist/modules/functions/driver.js +8 -0
  40. package/dist/modules/functions/local.d.ts +1 -1
  41. package/dist/modules/functions/local.js +2 -1
  42. package/dist/modules/functions/manifest.js +6 -1
  43. package/dist/modules/functions/protocol.d.ts +7 -0
  44. package/dist/modules/mongo/jobQueue.js +26 -0
  45. package/dist/modules/mysql/jobs/jobQueue.js +21 -2
  46. package/dist/modules/mysql/jobs/schema.d.ts +3 -2
  47. package/dist/modules/mysql/jobs/schema.js +24 -11
  48. package/dist/modules/project/serveProject.d.ts +5 -2
  49. package/dist/modules/project/serveProject.js +31 -6
  50. package/dist/modules/realtime/handlers.d.ts +4 -1
  51. package/dist/modules/realtime/handlers.js +8 -4
  52. package/dist/modules/realtime/hub.d.ts +3 -1
  53. package/dist/modules/realtime/hub.js +8 -4
  54. package/dist/modules/ssr/prepareRender.js +8 -6
  55. package/dist/modules/ssr/resolvePageSeo.d.ts +4 -1
  56. package/dist/modules/ssr/resolvePageSeo.js +7 -3
  57. package/package.json +3 -3
  58. package/skills/plitzi-authoring/CHEATSHEET.md +4 -2
  59. package/skills/plitzi-authoring/recipes/filter-a-list.ts +0 -2
  60. package/skills/plitzi-authoring/recipes/forms-and-modals.ts +2 -1
  61. package/skills/plitzi-authoring/recipes/style-a-page.ts +1 -1
  62. package/skills/plitzi-authoring/reference/accessibility.md +1 -0
  63. package/skills/plitzi-authoring/reference/authoring-errors.md +9 -4
  64. package/skills/plitzi-authoring/reference/data-and-visibility.md +11 -10
  65. package/skills/plitzi-authoring/reference/flows.md +2 -2
  66. package/skills/plitzi-authoring/reference/layouts.md +5 -2
  67. package/skills/plitzi-authoring/reference/lists.md +1 -0
  68. package/skills/plitzi-authoring/reference/plugins.md +3 -2
  69. package/skills/plitzi-authoring/reference/realtime.md +4 -1
  70. package/skills/plitzi-authoring/reference/structure.md +3 -1
  71. 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: "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);
@@ -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 — 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({
@@ -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 a `kv` table made before the columns above up to them — for a deployment that runs its own migrations,
27
- * once. `ensureJobTables` does it by itself, and only when the table needs it: the `ALTER` copies the table.
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
- * What brings a `kv` table made before the columns above up to them — for a deployment that runs its own migrations,
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 = ? AND COLUMN_NAME IN ('k', 'v')`, [`${prefix}action_kv`]);
92
- const typeOf = (name) => columns.find((column) => column.name === name)?.type.toLowerCase();
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 kvNeedsUpgrade(db, prefix)) for (const statement of mysqlJobSchemaUpgrades(prefix)) await execute(db, statement);
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' | 'devMode' | 'devReload' | 'health' | 'adapters' | 'plugins' | 'publicDir' | 'dataDir' | 'functions' | 'action';
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'>;