@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.
- package/CHANGELOG.md +179 -0
- package/README.md +3 -3
- package/dist/auth.js +1 -1
- package/dist/core/auth/createAuth.js +1 -1
- package/dist/core/http/stages/signInRoute.js +1 -1
- package/dist/core/server/baseServer.js +11 -6
- package/dist/core/server/fleet/stores.d.ts +7 -0
- package/dist/core/server/fleet/stores.js +16 -2
- package/dist/core/server/fleet/supervisor.d.ts +2 -0
- package/dist/core/server/fleet/supervisor.js +14 -2
- package/dist/core/server/pageServer.js +1 -1
- package/dist/functions-api.d.ts +29 -0
- package/dist/helpers/cache/cacheManager.d.ts +9 -2
- package/dist/helpers/cache/cacheManager.js +46 -22
- package/dist/helpers/cache/keys.d.ts +12 -5
- package/dist/helpers/cache/keys.js +56 -11
- package/dist/modules/actions/index.js +6 -2
- package/dist/modules/actions/jobs/later.d.ts +43 -0
- package/dist/modules/actions/jobs/later.js +66 -0
- package/dist/modules/actions/jobs/later.test.d.ts +1 -0
- package/dist/modules/actions/jobs/memoryQueue.js +14 -1
- package/dist/modules/actions/jobs/worker.js +2 -2
- package/dist/modules/actions/runtime/credentials.d.ts +10 -0
- package/dist/modules/actions/runtime/credentials.js +28 -0
- package/dist/modules/actions/runtime/credentials.test.d.ts +1 -0
- package/dist/modules/actions/runtime/fileKv.d.ts +8 -3
- package/dist/modules/actions/runtime/fileKv.js +79 -7
- package/dist/modules/actions/runtime/precheck.js +1 -1
- package/dist/modules/actions/runtime/runAction.d.ts +4 -1
- package/dist/modules/actions/runtime/runAction.js +11 -3
- package/dist/modules/actions/runtime/taskContext.d.ts +4 -1
- package/dist/modules/actions/runtime/taskContext.js +7 -1
- package/dist/modules/actions/tasks/flow.js +113 -35
- package/dist/modules/actions/types.d.ts +8 -0
- package/dist/modules/functions/build.js +1 -1
- package/dist/modules/functions/capabilities.js +21 -0
- package/dist/modules/functions/context.js +6 -0
- package/dist/modules/functions/contract.d.ts +10 -0
- package/dist/modules/functions/driver.js +8 -0
- package/dist/modules/functions/local.d.ts +1 -1
- package/dist/modules/functions/local.js +2 -1
- package/dist/modules/functions/protocol.d.ts +7 -0
- package/dist/modules/mongo/jobQueue.js +26 -0
- package/dist/modules/mysql/jobs/jobQueue.js +21 -2
- package/dist/modules/mysql/jobs/schema.d.ts +3 -2
- package/dist/modules/mysql/jobs/schema.js +24 -11
- package/dist/modules/project/serveProject.js +29 -4
- package/dist/modules/realtime/handlers.d.ts +4 -1
- package/dist/modules/realtime/handlers.js +8 -4
- package/dist/modules/realtime/hub.d.ts +3 -1
- package/dist/modules/realtime/hub.js +8 -4
- package/dist/modules/ssr/prepareRender.js +1 -1
- package/package.json +3 -3
- package/skills/plitzi-authoring/CHEATSHEET.md +3 -1
- package/skills/plitzi-authoring/recipes/style-a-page.ts +1 -1
- package/skills/plitzi-authoring/reference/accessibility.md +1 -0
- package/skills/plitzi-authoring/reference/authoring-errors.md +5 -3
- package/skills/plitzi-authoring/reference/data-and-visibility.md +11 -10
- package/skills/plitzi-authoring/reference/flows.md +2 -2
- package/skills/plitzi-authoring/reference/layouts.md +1 -0
- package/skills/plitzi-authoring/reference/lists.md +1 -0
- package/skills/plitzi-authoring/reference/plugins.md +3 -2
- package/skills/plitzi-authoring/reference/realtime.md +4 -1
- package/skills/plitzi-authoring/reference/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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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)
|
|
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:
|
|
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:
|
|
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
|
|
25
|
-
*
|
|
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
|
|
39
|
-
*
|
|
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
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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,
|