@plitzi/sdk-server 0.38.10 → 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 (64) hide show
  1. package/CHANGELOG.md +179 -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 +11 -3
  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/protocol.d.ts +7 -0
  43. package/dist/modules/mongo/jobQueue.js +26 -0
  44. package/dist/modules/mysql/jobs/jobQueue.js +21 -2
  45. package/dist/modules/mysql/jobs/schema.d.ts +3 -2
  46. package/dist/modules/mysql/jobs/schema.js +24 -11
  47. package/dist/modules/project/serveProject.js +29 -4
  48. package/dist/modules/realtime/handlers.d.ts +4 -1
  49. package/dist/modules/realtime/handlers.js +8 -4
  50. package/dist/modules/realtime/hub.d.ts +3 -1
  51. package/dist/modules/realtime/hub.js +8 -4
  52. package/dist/modules/ssr/prepareRender.js +1 -1
  53. package/package.json +3 -3
  54. package/skills/plitzi-authoring/CHEATSHEET.md +3 -1
  55. package/skills/plitzi-authoring/recipes/style-a-page.ts +1 -1
  56. package/skills/plitzi-authoring/reference/accessibility.md +1 -0
  57. package/skills/plitzi-authoring/reference/authoring-errors.md +5 -3
  58. package/skills/plitzi-authoring/reference/data-and-visibility.md +11 -10
  59. package/skills/plitzi-authoring/reference/flows.md +2 -2
  60. package/skills/plitzi-authoring/reference/layouts.md +1 -0
  61. package/skills/plitzi-authoring/reference/lists.md +1 -0
  62. package/skills/plitzi-authoring/reference/plugins.md +3 -2
  63. package/skills/plitzi-authoring/reference/realtime.md +4 -1
  64. package/skills/plitzi-authoring/reference/typed-sources.md +6 -4
@@ -63,22 +63,67 @@ var buildHtmlCacheKey = (accessToken = "anonymous", spaceId, environment, revisi
63
63
  };
64
64
  return HTML_KEY_FIELDS.map((field) => fields[field]).join("\0");
65
65
  };
66
+ /** One field of a key joined from `fields`, read back by the same list: never by a position written down twice. */
67
+ var fieldOf = (fields, key, field) => key.split("\0")[fields.indexOf(field)] ?? "";
66
68
  /**
67
69
  * What an HTML key says about the page it holds, read by the one list that also writes it. `server.cache.invalidate`
68
70
  * filters on these; it used to split the key by positions of its own, and when the key grew a field in front the
69
71
  * filter went on reading the wrong ones — every invalidation by space matched nothing.
70
72
  */
71
- var readHtmlCacheKey = (key) => {
72
- const parts = key.split("\0");
73
- const at = (field) => parts[HTML_KEY_FIELDS.indexOf(field)] ?? "";
74
- return {
75
- spaceId: at("spaceId"),
76
- environment: at("environment"),
77
- hostname: at("hostname")
73
+ var readHtmlCacheKey = (key) => ({
74
+ spaceId: fieldOf(HTML_KEY_FIELDS, key, "spaceId"),
75
+ environment: fieldOf(HTML_KEY_FIELDS, key, "environment"),
76
+ hostname: fieldOf(HTML_KEY_FIELDS, key, "hostname")
77
+ });
78
+ var OFFLINE_DATA_KEY_FIELDS = [
79
+ "spaceId",
80
+ "environment",
81
+ "revision",
82
+ "flags"
83
+ ];
84
+ /** The space as read for a render: its revision, and what its flags are at when they change apart from it. */
85
+ var buildOfflineDataCacheKey = (spaceId, environment, revision, flagsVersion) => {
86
+ const fields = {
87
+ spaceId: String(spaceId),
88
+ environment,
89
+ revision: String(revision),
90
+ flags: flagsVersion ?? ""
78
91
  };
92
+ return OFFLINE_DATA_KEY_FIELDS.map((field) => fields[field]).join("\0");
79
93
  };
80
- /** The space as read for a render: its revision, and what its flags are at when they change apart from it. */
81
- var buildOfflineDataCacheKey = (spaceId, environment, revision, flagsVersion) => `${spaceId}|${environment}|${revision}|${flagsVersion ?? ""}`;
82
- var buildRscCacheKey = (spaceId, environment, revision, userId, idsParam, req) => `${spaceId}\0${environment}\0${revision}\0${flagsVersionOf(req)}\0${userId ?? "anon"}\0${idsParam ?? ""}\0${req.hostname}\0${req.path}\0${req.search}`;
94
+ var readOfflineDataCacheKey = (key) => ({
95
+ spaceId: fieldOf(OFFLINE_DATA_KEY_FIELDS, key, "spaceId"),
96
+ environment: fieldOf(OFFLINE_DATA_KEY_FIELDS, key, "environment")
97
+ });
98
+ var RSC_KEY_FIELDS = [
99
+ "spaceId",
100
+ "environment",
101
+ "revision",
102
+ "flags",
103
+ "user",
104
+ "ids",
105
+ "hostname",
106
+ "path",
107
+ "search"
108
+ ];
109
+ var buildRscCacheKey = (spaceId, environment, revision, userId, idsParam, req) => {
110
+ const fields = {
111
+ spaceId: String(spaceId),
112
+ environment,
113
+ revision: String(revision),
114
+ flags: flagsVersionOf(req),
115
+ user: userId === void 0 ? "anon" : String(userId),
116
+ ids: idsParam ?? "",
117
+ hostname: req.hostname,
118
+ path: req.path,
119
+ search: req.search
120
+ };
121
+ return RSC_KEY_FIELDS.map((field) => fields[field]).join("\0");
122
+ };
123
+ var readRscCacheKey = (key) => ({
124
+ spaceId: fieldOf(RSC_KEY_FIELDS, key, "spaceId"),
125
+ environment: fieldOf(RSC_KEY_FIELDS, key, "environment"),
126
+ hostname: fieldOf(RSC_KEY_FIELDS, key, "hostname")
127
+ });
83
128
  //#endregion
84
- export { buildHtmlCacheKey, buildOfflineDataCacheKey, buildRscCacheKey, readHtmlCacheKey };
129
+ export { buildHtmlCacheKey, buildOfflineDataCacheKey, buildRscCacheKey, readHtmlCacheKey, readOfflineDataCacheKey, readRscCacheKey };
@@ -64,7 +64,8 @@ var createActionsModule = (given) => {
64
64
  return spaceRegistries.registryFor(functions, plugins);
65
65
  };
66
66
  const registryForRun = (request) => Object.values(request.entry.document.nodes).every((node) => node.type !== "task" || !node.action || registry.get(node.action)) ? registry : registryFor(request.spaceId, request.at);
67
- const { runAction, taskContext } = createActionRunner(config, registryForRun, config.fetchImpl);
67
+ let jobsQueue;
68
+ const { runAction, taskContext } = createActionRunner(config, registryForRun, config.fetchImpl, () => jobsQueue);
68
69
  const routes = createRoutes({
69
70
  config: config.functions ?? {},
70
71
  plugins: () => plugins,
@@ -105,7 +106,10 @@ var createActionsModule = (given) => {
105
106
  next.forEach((value, key) => plugins.set(key, value));
106
107
  }
107
108
  };
108
- if (config.jobs !== false && config.lookups.listActions) module.jobs = createActionJobs(config.jobs ?? {}, module, config.lookups);
109
+ if (config.jobs !== false && config.lookups.listActions) {
110
+ module.jobs = createActionJobs(config.jobs ?? {}, module, config.lookups);
111
+ jobsQueue = module.jobs.queue;
112
+ }
109
113
  return module;
110
114
  };
111
115
  //#endregion
@@ -0,0 +1,43 @@
1
+ import { ActionLookups } from '../types';
2
+ import { ActionJobQueue, Environment, SpaceRevision } from '@plitzi/sdk-shared';
3
+ /** A month: past it, a run set for later is a schedule, and a cron says it better. */
4
+ export declare const MAX_LATER_SECONDS: number;
5
+ /** What a run asks to start later: one of the space's actions, in so many seconds, with its input — and a name. */
6
+ export type LaterRequest = {
7
+ /** The action, by its identifier. It says it may be started this way with a `later` trigger. */
8
+ action: string;
9
+ /** Seconds from now, by the queue's clock — whole or not, from 0 to a month. */
10
+ in: number;
11
+ input?: Record<string, unknown>;
12
+ /**
13
+ * What it is known by in this space: setting another under the same key replaces the one still waiting — a turn's
14
+ * timer moved on by a move — and `cancelLater(key)` drops it. The one already running is left be.
15
+ */
16
+ key?: string;
17
+ };
18
+ /** The job it became, and when it is due — epoch ms, by the queue's clock. */
19
+ export type LaterAnswer = {
20
+ id: string;
21
+ at: number;
22
+ };
23
+ export type LaterDeps = {
24
+ /** Read when asked: the queue is made after the runner, and a server may run none. */
25
+ queue: () => ActionJobQueue | undefined;
26
+ lookups: Pick<ActionLookups, 'getAction'>;
27
+ maxAttempts: number;
28
+ };
29
+ export type LaterScope = {
30
+ spaceId: number;
31
+ environment: Environment;
32
+ at?: SpaceRevision;
33
+ };
34
+ /**
35
+ * Runs of a space's actions that start in a while — a turn that runs out, a bot's move, a hold that lapses — held by the
36
+ * same queue as its schedules: they outlive a restart where the queue does, run once across replicas, retry, and show
37
+ * in the queue an operator reads. Every refusal says why, before anything is queued.
38
+ */
39
+ export declare const laterFor: ({ queue, lookups, maxAttempts }: LaterDeps, { spaceId, environment, at }: LaterScope) => {
40
+ later: ({ action, in: seconds, input, key }: LaterRequest) => Promise<LaterAnswer>;
41
+ /** Drops what is waiting under `key`, and answers how many. */
42
+ cancelLater: (key: string) => Promise<number>;
43
+ };
@@ -0,0 +1,66 @@
1
+ import { findTriggerNode } from "../runtime/triggers.js";
2
+ import { randomUUID } from "node:crypto";
3
+ //#region src/modules/actions/jobs/later.ts
4
+ /** A month: past it, a run set for later is a schedule, and a cron says it better. */
5
+ var MAX_LATER_SECONDS = 2592e3;
6
+ /** A name a run set for later is known by, as a topic is written: what it is replaced and cancelled by. */
7
+ var KEY = /^[A-Za-z0-9:_.-]{1,128}$/;
8
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
9
+ /**
10
+ * Runs of a space's actions that start in a while — a turn that runs out, a bot's move, a hold that lapses — held by the
11
+ * same queue as its schedules: they outlive a restart where the queue does, run once across replicas, retry, and show
12
+ * in the queue an operator reads. Every refusal says why, before anything is queued.
13
+ */
14
+ var laterFor = ({ queue, lookups, maxAttempts }, { spaceId, environment, at }) => {
15
+ const queueOrThrow = () => {
16
+ const found = queue();
17
+ if (!found) throw new Error("This server runs no jobs — its actions have no queue (`jobs: false`, or no way to list actions)");
18
+ return found;
19
+ };
20
+ const keyOrThrow = (key) => {
21
+ if (typeof key !== "string" || !KEY.test(key)) throw new Error("A key is 1 to 128 letters, digits and `:_.-` — `turn:ABCD`");
22
+ return key;
23
+ };
24
+ return {
25
+ later: async ({ action, in: seconds, input = {}, key }) => {
26
+ const jobs = queueOrThrow();
27
+ if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds < 0 || seconds > 2592e3) throw new Error(`"in" is a number of seconds from 0 to ${String(MAX_LATER_SECONDS)}: ${JSON.stringify(seconds)}`);
28
+ if (!isRecord(input)) throw new Error("A run set for later is handed an object as its input");
29
+ const named = key === void 0 ? void 0 : keyOrThrow(key);
30
+ const entry = typeof action === "string" && action !== "" ? await lookups.getAction(spaceId, action, at) : void 0;
31
+ if (!entry) throw new Error(`Action "${action}" is not one of this space's`);
32
+ const trigger = findTriggerNode(entry.document.nodes, "later");
33
+ if (!trigger) throw new Error(`Action "${action}" cannot be set to run later: give it a "later" trigger, which says it may be started this way`);
34
+ if (!trigger.enabled) throw new Error(`Action "${action}" has its "later" trigger switched off: nothing set for later would run`);
35
+ const dueAt = (await jobs.now()).getTime() + Math.round(seconds * 1e3);
36
+ const id = `later:${String(spaceId)}:${randomUUID()}`;
37
+ await jobs.enqueue({
38
+ id,
39
+ spaceId,
40
+ actionId: entry.id,
41
+ environment,
42
+ trigger: "later",
43
+ input,
44
+ dueAt,
45
+ maxAttempts,
46
+ ...named === void 0 ? {} : { key: named }
47
+ });
48
+ if (named !== void 0) await jobs.cancelPending({
49
+ spaceId,
50
+ key: named,
51
+ olderThan: id
52
+ });
53
+ return {
54
+ id,
55
+ at: dueAt
56
+ };
57
+ },
58
+ /** Drops what is waiting under `key`, and answers how many. */
59
+ cancelLater: (key) => queueOrThrow().cancelPending({
60
+ spaceId,
61
+ key: keyOrThrow(key)
62
+ })
63
+ };
64
+ };
65
+ //#endregion
66
+ export { MAX_LATER_SECONDS, laterFor };
@@ -0,0 +1 @@
1
+ export {};
@@ -19,7 +19,8 @@ var JOB_QUEUE_METHODS = {
19
19
  getJob: true,
20
20
  listSchedules: true,
21
21
  requeue: true,
22
- cancel: true
22
+ cancel: true,
23
+ cancelPending: true
23
24
  };
24
25
  /**
25
26
  * The default queue: one process's Maps.
@@ -187,6 +188,18 @@ var createMemoryJobQueue = ({ retainFinished = 500, clock = Date.now } = {}) =>
187
188
  } else job.cancelRequested = true;
188
189
  job.updatedAt = now();
189
190
  return Promise.resolve(true);
191
+ },
192
+ cancelPending: ({ spaceId, key, olderThan }) => {
193
+ const newest = olderThan === void 0 ? void 0 : jobs.get(olderThan);
194
+ const isOlder = (job) => newest === void 0 || job.createdAt < newest.createdAt || job.createdAt === newest.createdAt && job.id < newest.id;
195
+ const at = now();
196
+ let dropped = 0;
197
+ for (const job of jobs.values()) if (job.spaceId === spaceId && job.key === key && job.status === "pending" && job.id !== olderThan && isOlder(job)) {
198
+ job.status = "cancelled";
199
+ job.updatedAt = at;
200
+ dropped += 1;
201
+ }
202
+ return Promise.resolve(dropped);
190
203
  }
191
204
  };
192
205
  };
@@ -104,7 +104,7 @@ var createJobWorker = ({ queue, lookups, module, workers = 4, pollMs = 1e3, leas
104
104
  run = await module.guards.begin({
105
105
  spaceId: job.spaceId,
106
106
  actionId: job.actionId,
107
- callerId: "schedule",
107
+ callerId: job.trigger,
108
108
  input: job.input,
109
109
  idempotencyKey: job.id,
110
110
  sharedKey: true,
@@ -139,7 +139,7 @@ var createJobWorker = ({ queue, lookups, module, workers = 4, pollMs = 1e3, leas
139
139
  spaceId: job.spaceId,
140
140
  environment: job.environment,
141
141
  trigger: job.trigger,
142
- callerId: "schedule",
142
+ callerId: job.trigger,
143
143
  runId: run.runId,
144
144
  signal: run.controller.signal
145
145
  });
@@ -0,0 +1,10 @@
1
+ import { ActionCredential } from '../types';
2
+ /** Where a project's server — and `plitzi functions dev` — reads the credentials its space's code names. */
3
+ export declare const CREDENTIALS_ENV = "PLITZI_CREDENTIALS";
4
+ /**
5
+ * The credentials a deployment with no credential store keeps in its environment: one JSON object, credential id → its
6
+ * keys, each a string — what `ctx.fetch({ credential })`, a connector and an `http.request` step name. Read whole or
7
+ * refused with why: a value that does not read would otherwise leave every credential missing, and the integration
8
+ * failing far from the cause.
9
+ */
10
+ export declare const credentialsFromEnv: (env?: NodeJS.ProcessEnv) => Record<string, ActionCredential>;
@@ -0,0 +1,28 @@
1
+ //#region src/modules/actions/runtime/credentials.ts
2
+ /** Where a project's server — and `plitzi functions dev` — reads the credentials its space's code names. */
3
+ var CREDENTIALS_ENV = "PLITZI_CREDENTIALS";
4
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5
+ var EXAMPLE = "{\"google\":{\"clientId\":\"…\",\"clientSecret\":\"…\"}}";
6
+ /**
7
+ * The credentials a deployment with no credential store keeps in its environment: one JSON object, credential id → its
8
+ * keys, each a string — what `ctx.fetch({ credential })`, a connector and an `http.request` step name. Read whole or
9
+ * refused with why: a value that does not read would otherwise leave every credential missing, and the integration
10
+ * failing far from the cause.
11
+ */
12
+ var credentialsFromEnv = (env = process.env) => {
13
+ const raw = env[CREDENTIALS_ENV];
14
+ if (raw === void 0 || raw.trim() === "") return {};
15
+ let value;
16
+ try {
17
+ value = JSON.parse(raw);
18
+ } catch (error) {
19
+ throw new Error(`${CREDENTIALS_ENV} is not JSON: write one object, credential id → its keys — ${EXAMPLE}`, { cause: error });
20
+ }
21
+ if (!isRecord(value)) throw new Error(`${CREDENTIALS_ENV} is one object, credential id → its keys — ${EXAMPLE}`);
22
+ return Object.fromEntries(Object.entries(value).map(([id, keys]) => {
23
+ if (!isRecord(keys) || !Object.values(keys).every((key) => typeof key === "string")) throw new Error(`${CREDENTIALS_ENV}: "${id}" is an object of strings, its keys by name — ${EXAMPLE}`);
24
+ return [id, Object.fromEntries(Object.entries(keys).map(([name, key]) => [name, String(key)]))];
25
+ }));
26
+ };
27
+ //#endregion
28
+ export { CREDENTIALS_ENV, credentialsFromEnv };
@@ -0,0 +1 @@
1
+ export {};
@@ -2,10 +2,14 @@ import { ActionKvAdapter } from '../types';
2
2
  export type FileKvOptions = {
3
3
  /** The JSON file the store lives in — created, with its folder, on the first write. */
4
4
  file: string;
5
+ /** Who holds it, said to a second process that tries: `this project's server, on port 8080`. */
6
+ owner?: string;
5
7
  };
6
8
  export type FileKv = ActionKvAdapter & {
7
9
  /** Resolves once everything written so far is on disk: for a shutdown, or a test that reads the file. */
8
10
  flush: () => Promise<void>;
11
+ /** Writes what is pending and lets go of the file, for the next process to take. */
12
+ close: () => Promise<void>;
9
13
  };
10
14
  /**
11
15
  * A key/value store in one JSON file: the `kv` server actions keep — saved layouts, counters, a source's cached answer —
@@ -21,8 +25,9 @@ export type FileKv = ActionKvAdapter & {
21
25
  * that arrive while a write is under way are written together right after it, so a crash loses at most the writes
22
26
  * of that instant and never leaves half a file. Entries past their lifetime are not written.
23
27
  *
24
- * ONE process: it reads the file when it starts and owns it after. Two processes on one file overwrite each other —
25
- * replicas, or a server started with `workers`, use `createSqliteKv` (`@plitzi/sdk-server/sqlite`) or a database.
28
+ * ONE process: it reads the file when it starts and holds it after (`<file>.lock`, with its pid) — a second process is
29
+ * refused, naming the first, because two would write over each other's changes. The workers of one server reach the
30
+ * primary's through the fleet (`fleetKv`); replicas use `createSqliteKv` (`@plitzi/sdk-server/sqlite`) or a database.
26
31
  * And a whole file per change: right for what a site keeps, not for a counter bumped thousands of times a second.
27
32
  */
28
- export declare const createFileKv: ({ file }: FileKvOptions) => FileKv;
33
+ export declare const createFileKv: ({ file, owner }: FileKvOptions) => FileKv;
@@ -4,6 +4,62 @@ import { writeFileAtomic } from "../../../helpers/atomicFile.js";
4
4
  import path from "node:path";
5
5
  import fs from "node:fs";
6
6
  //#region src/modules/actions/runtime/fileKv.ts
7
+ var holderIn = (lock) => {
8
+ try {
9
+ const parsed = JSON.parse(fs.readFileSync(lock, "utf8"));
10
+ return typeof parsed === "object" && parsed !== null && "pid" in parsed && typeof parsed.pid === "number" ? {
11
+ pid: parsed.pid,
12
+ since: "since" in parsed && typeof parsed.since === "string" ? parsed.since : "?",
13
+ ..."owner" in parsed && typeof parsed.owner === "string" ? { owner: parsed.owner } : {}
14
+ } : void 0;
15
+ } catch {
16
+ return;
17
+ }
18
+ };
19
+ /** Whether a process is still running: signal 0 asks without sending anything, and `EPERM` is one that is not ours. */
20
+ var running = (pid) => {
21
+ try {
22
+ process.kill(pid, 0);
23
+ return true;
24
+ } catch (error) {
25
+ return error.code === "EPERM";
26
+ }
27
+ };
28
+ /** The files this process holds, let go of together when it exits — however it does. */
29
+ var held = /* @__PURE__ */ new Set();
30
+ var letGo = (lock) => {
31
+ held.delete(lock);
32
+ if (holderIn(lock)?.pid === process.pid) fs.rmSync(lock, { force: true });
33
+ };
34
+ process.once("exit", () => held.forEach(letGo));
35
+ /**
36
+ * Takes the file for this process, or says who has it.
37
+ *
38
+ * Two processes on one file each load it and then write their whole map over it: every change one makes, the other
39
+ * erases with its next write, and nothing anywhere says so — a second server started beside the first, a `--watch`
40
+ * left running after a restart. So a file has one holder: a second is refused, naming the first. A lock whose process is
41
+ * gone — killed, crashed — is taken over.
42
+ */
43
+ var take = (file, owner) => {
44
+ const lock = `${file}.lock`;
45
+ fs.mkdirSync(path.dirname(file), { recursive: true });
46
+ const mine = {
47
+ pid: process.pid,
48
+ since: (/* @__PURE__ */ new Date()).toISOString(),
49
+ ...owner ? { owner } : {}
50
+ };
51
+ for (let attempt = 0; attempt < 2; attempt += 1) try {
52
+ fs.writeFileSync(lock, JSON.stringify(mine), { flag: "wx" });
53
+ held.add(lock);
54
+ return lock;
55
+ } catch (error) {
56
+ if (error.code !== "EEXIST") throw error;
57
+ const holder = holderIn(lock);
58
+ if (holder && running(holder.pid)) throw new Error(`createFileKv: ${file} is in use by process ${String(holder.pid)}${holder.owner ? ` — ${holder.owner}` : ""}, since ${holder.since}. Two processes on one file write over each other's changes: stop that one first, or point \`file\` elsewhere.`, { cause: error });
59
+ fs.rmSync(lock, { force: true });
60
+ }
61
+ throw new Error(`createFileKv: ${lock} could not be taken`);
62
+ };
7
63
  var isEntry = (value) => typeof value === "object" && value !== null && "value" in value && typeof value.value === "string" && (!("expiresAt" in value) || typeof value.expiresAt === "number");
8
64
  var load = (file) => {
9
65
  if (!fs.existsSync(file)) return /* @__PURE__ */ new Map();
@@ -35,12 +91,20 @@ var load = (file) => {
35
91
  * that arrive while a write is under way are written together right after it, so a crash loses at most the writes
36
92
  * of that instant and never leaves half a file. Entries past their lifetime are not written.
37
93
  *
38
- * ONE process: it reads the file when it starts and owns it after. Two processes on one file overwrite each other —
39
- * replicas, or a server started with `workers`, use `createSqliteKv` (`@plitzi/sdk-server/sqlite`) or a database.
94
+ * ONE process: it reads the file when it starts and holds it after (`<file>.lock`, with its pid) — a second process is
95
+ * refused, naming the first, because two would write over each other's changes. The workers of one server reach the
96
+ * primary's through the fleet (`fleetKv`); replicas use `createSqliteKv` (`@plitzi/sdk-server/sqlite`) or a database.
40
97
  * And a whole file per change: right for what a site keeps, not for a counter bumped thousands of times a second.
41
98
  */
42
- var createFileKv = ({ file }) => {
43
- const entries = load(file);
99
+ var createFileKv = ({ file, owner }) => {
100
+ const lock = take(file, owner);
101
+ let entries;
102
+ try {
103
+ entries = load(file);
104
+ } catch (error) {
105
+ letGo(lock);
106
+ throw error;
107
+ }
44
108
  let dirty = false;
45
109
  let failed = false;
46
110
  /** The write under way, resolving to whether everything it took on reached the disk. */
@@ -83,11 +147,19 @@ var createFileKv = ({ file }) => {
83
147
  failed = false;
84
148
  write();
85
149
  };
150
+ const flush = async () => {
151
+ failed = false;
152
+ while (writing !== void 0 || dirty) if (!await (writing ?? write())) throw new Error(`createFileKv: ${file} could not be written; the reason is in the log.`);
153
+ };
86
154
  return {
87
155
  ...createMapKv(entries, changed),
88
- flush: async () => {
89
- failed = false;
90
- while (writing !== void 0 || dirty) if (!await (writing ?? write())) throw new Error(`createFileKv: ${file} could not be written; the reason is in the log.`);
156
+ flush,
157
+ close: async () => {
158
+ try {
159
+ await flush();
160
+ } finally {
161
+ letGo(lock);
162
+ }
91
163
  }
92
164
  };
93
165
  };
@@ -4,7 +4,7 @@ import { applyFields } from "./scope.js";
4
4
  import { accessRefusal, triggerAccess, triggerInput } from "@plitzi/sdk-shared/actions";
5
5
  //#region src/modules/actions/runtime/precheck.ts
6
6
  var authorize = (access, kind, user) => {
7
- if (kind === "schedule") return;
7
+ if (kind === "schedule" || kind === "later") return;
8
8
  const refusal = accessRefusal(access, user);
9
9
  if (refusal === "unauthenticated") throw new ActionRunError("unauthenticated", "This action requires a signed-in visitor");
10
10
  if (refusal) throw new ActionRunError("forbidden", access ? "This action requires permissions the caller does not hold" : "This trigger declares no access rule");
@@ -1,5 +1,6 @@
1
1
  import { TaskContextRequest } from './taskContext';
2
2
  import { ActionRunRequest, ActionRunResult, ActionsConfig, ActionTaskContext, ActionTaskRegistry } from '../types';
3
+ import { ActionJobQueue } from '@plitzi/sdk-shared';
3
4
  export type ActionRunner = {
4
5
  runAction: (request: ActionRunRequest) => Promise<ActionRunResult>;
5
6
  };
@@ -15,6 +16,8 @@ export type TaskContextSource = (request: TaskContextRequest, signal: AbortSigna
15
16
  * input contract — lives HERE rather than in a transport, because a deployment mounting its own trigger calls this
16
17
  * directly. A check in the endpoint would be a check a custom trigger silently skips.
17
18
  */
18
- export declare const createActionRunner: (config: ActionsConfig, registryFor: (request: ActionRunRequest) => ActionTaskRegistry | Promise<ActionTaskRegistry>, baseFetch?: typeof fetch) => ActionRunner & {
19
+ export declare const createActionRunner: (config: ActionsConfig, registryFor: (request: ActionRunRequest) => ActionTaskRegistry | Promise<ActionTaskRegistry>, baseFetch?: typeof fetch,
20
+ /** The queue runs set for later go into, read when asked: it is made after the runner, and a server may run none. */
21
+ jobsQueue?: () => ActionJobQueue | undefined) => ActionRunner & {
19
22
  taskContext: TaskContextSource;
20
23
  };
@@ -1,4 +1,5 @@
1
1
  import { serverLog } from "../../../helpers/serverLog.js";
2
+ import "../jobs/schedules.js";
2
3
  import { ActionRunError, isActionRefusal } from "./errors.js";
3
4
  import { runCancelKey } from "./guards.js";
4
5
  import { createKvStore } from "./kvStore.js";
@@ -194,13 +195,18 @@ var runNode = async (node, scope, registry, buildContext) => {
194
195
  * input contract — lives HERE rather than in a transport, because a deployment mounting its own trigger calls this
195
196
  * directly. A check in the endpoint would be a check a custom trigger silently skips.
196
197
  */
197
- var createActionRunner = (config, registryFor, baseFetch = fetch) => {
198
+ var createActionRunner = (config, registryFor, baseFetch = fetch, jobsQueue = () => void 0) => {
198
199
  const kv = createKvStore(config.kv ?? createMemoryKv());
199
200
  const emailSender = createEmailSender({
200
201
  ...config.email,
201
202
  kv
202
203
  });
203
204
  const signing = config.signingSecret ? createSigning(config.signingSecret) : void 0;
205
+ const later = {
206
+ queue: jobsQueue,
207
+ lookups: config.lookups,
208
+ maxAttempts: (config.jobs === false ? void 0 : config.jobs?.maxAttempts) ?? 3
209
+ };
204
210
  /** Never allowed to fail a run: a logging outage must not take an action down, the same rule metering follows. */
205
211
  const record = async (entry) => {
206
212
  try {
@@ -236,7 +242,8 @@ var createActionRunner = (config, registryFor, baseFetch = fetch) => {
236
242
  kv,
237
243
  email: emailSender,
238
244
  redactor,
239
- signing
245
+ signing,
246
+ later
240
247
  }, request, signal, runFetch);
241
248
  /** The request budget ran out: said on the server's log at once, and in the step that hit it. */
242
249
  let budgetSpent;
@@ -548,7 +555,8 @@ var createActionRunner = (config, registryFor, baseFetch = fetch) => {
548
555
  kv,
549
556
  email: emailSender,
550
557
  redactor: createRedactor(),
551
- signing
558
+ signing,
559
+ later
552
560
  }, contextRequest, signal, createRunFetch(baseFetch, signal, resolveLimits(config.limits, void 0), lineage))({});
553
561
  return {
554
562
  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,