@rex0220/ksql-flownet 1.0.0

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 (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.ja.md +81 -0
  3. package/README.md +117 -0
  4. package/dist/bundle/bundle-builder.d.ts +49 -0
  5. package/dist/bundle/bundle-builder.js +208 -0
  6. package/dist/bundle/index.d.ts +4 -0
  7. package/dist/bundle/index.js +4 -0
  8. package/dist/bundle/upload.d.ts +28 -0
  9. package/dist/bundle/upload.js +72 -0
  10. package/dist/bundle/zip-store.d.ts +17 -0
  11. package/dist/bundle/zip-store.js +175 -0
  12. package/dist/cli/archive-run-command.d.ts +12 -0
  13. package/dist/cli/archive-run-command.js +131 -0
  14. package/dist/cli/cancel-run-command.d.ts +9 -0
  15. package/dist/cli/cancel-run-command.js +77 -0
  16. package/dist/cli/force-unlock-network-command.d.ts +8 -0
  17. package/dist/cli/force-unlock-network-command.js +129 -0
  18. package/dist/cli/index.d.ts +3 -0
  19. package/dist/cli/index.js +132 -0
  20. package/dist/cli/plan-command.d.ts +2 -0
  21. package/dist/cli/plan-command.js +100 -0
  22. package/dist/cli/poll-requests-command.d.ts +3 -0
  23. package/dist/cli/poll-requests-command.js +74 -0
  24. package/dist/cli/record-job-unlock-command.d.ts +11 -0
  25. package/dist/cli/record-job-unlock-command.js +78 -0
  26. package/dist/cli/resolve-node-command.d.ts +13 -0
  27. package/dist/cli/resolve-node-command.js +174 -0
  28. package/dist/cli/run-network-command.d.ts +14 -0
  29. package/dist/cli/run-network-command.js +418 -0
  30. package/dist/cli/status-command.d.ts +5 -0
  31. package/dist/cli/status-command.js +137 -0
  32. package/dist/cli/validate-command.d.ts +6 -0
  33. package/dist/cli/validate-command.js +17 -0
  34. package/dist/dag/descendants.d.ts +9 -0
  35. package/dist/dag/descendants.js +67 -0
  36. package/dist/dag/topological-sort.d.ts +7 -0
  37. package/dist/dag/topological-sort.js +32 -0
  38. package/dist/domain/business-key.d.ts +15 -0
  39. package/dist/domain/business-key.js +249 -0
  40. package/dist/domain/canonical-lock-key.d.ts +17 -0
  41. package/dist/domain/canonical-lock-key.js +60 -0
  42. package/dist/domain/canonical-record-key.d.ts +18 -0
  43. package/dist/domain/canonical-record-key.js +70 -0
  44. package/dist/domain/load-network.d.ts +4 -0
  45. package/dist/domain/load-network.js +47 -0
  46. package/dist/domain/network-definition.d.ts +47 -0
  47. package/dist/domain/network-definition.js +7 -0
  48. package/dist/domain/persistence-model.d.ts +198 -0
  49. package/dist/domain/persistence-model.js +2 -0
  50. package/dist/domain/run-aggregate.d.ts +3 -0
  51. package/dist/domain/run-aggregate.js +15 -0
  52. package/dist/domain/validate-network-path.d.ts +11 -0
  53. package/dist/domain/validate-network-path.js +33 -0
  54. package/dist/domain/validate-network.d.ts +3 -0
  55. package/dist/domain/validate-network.js +180 -0
  56. package/dist/executor/attempt-executor.d.ts +43 -0
  57. package/dist/executor/attempt-executor.js +236 -0
  58. package/dist/executor/job-log-reader.d.ts +37 -0
  59. package/dist/executor/job-log-reader.js +91 -0
  60. package/dist/executor/ksql-flow-cli.d.ts +86 -0
  61. package/dist/executor/ksql-flow-cli.js +161 -0
  62. package/dist/executor/preflight.d.ts +41 -0
  63. package/dist/executor/preflight.js +150 -0
  64. package/dist/executor/result-classifier.d.ts +69 -0
  65. package/dist/executor/result-classifier.js +237 -0
  66. package/dist/executor/run-subprocess.d.ts +64 -0
  67. package/dist/executor/run-subprocess.js +175 -0
  68. package/dist/io/input-baseline.d.ts +19 -0
  69. package/dist/io/input-baseline.js +214 -0
  70. package/dist/io/io-config.d.ts +6 -0
  71. package/dist/io/io-config.js +33 -0
  72. package/dist/io/io-path.d.ts +37 -0
  73. package/dist/io/io-path.js +279 -0
  74. package/dist/orchestration/archive-run.d.ts +53 -0
  75. package/dist/orchestration/archive-run.js +323 -0
  76. package/dist/orchestration/cancel-request.d.ts +18 -0
  77. package/dist/orchestration/cancel-request.js +67 -0
  78. package/dist/orchestration/ensure-run.d.ts +83 -0
  79. package/dist/orchestration/ensure-run.js +632 -0
  80. package/dist/orchestration/job-lock-audit.d.ts +24 -0
  81. package/dist/orchestration/job-lock-audit.js +123 -0
  82. package/dist/orchestration/orphan-attempt-adjudication.d.ts +39 -0
  83. package/dist/orchestration/orphan-attempt-adjudication.js +167 -0
  84. package/dist/orchestration/reconciliation.d.ts +41 -0
  85. package/dist/orchestration/reconciliation.js +526 -0
  86. package/dist/orchestration/resolve-node.d.ts +29 -0
  87. package/dist/orchestration/resolve-node.js +105 -0
  88. package/dist/orchestration/run-activity.d.ts +13 -0
  89. package/dist/orchestration/run-activity.js +14 -0
  90. package/dist/orchestration/sequential-scheduler.d.ts +79 -0
  91. package/dist/orchestration/sequential-scheduler.js +706 -0
  92. package/dist/orchestration/status.d.ts +91 -0
  93. package/dist/orchestration/status.js +171 -0
  94. package/dist/persistence/in-memory-repository.d.ts +41 -0
  95. package/dist/persistence/in-memory-repository.js +333 -0
  96. package/dist/persistence/kintone/client.d.ts +36 -0
  97. package/dist/persistence/kintone/client.js +93 -0
  98. package/dist/persistence/kintone/design-notes.d.ts +38 -0
  99. package/dist/persistence/kintone/design-notes.js +38 -0
  100. package/dist/persistence/kintone/repository.d.ts +44 -0
  101. package/dist/persistence/kintone/repository.js +836 -0
  102. package/dist/persistence/kintone/schema.d.ts +116 -0
  103. package/dist/persistence/kintone/schema.js +115 -0
  104. package/dist/persistence/network-lock-reader.d.ts +24 -0
  105. package/dist/persistence/network-lock-reader.js +43 -0
  106. package/dist/persistence/network-lock-recovery.d.ts +66 -0
  107. package/dist/persistence/network-lock-recovery.js +392 -0
  108. package/dist/persistence/network-lock.d.ts +97 -0
  109. package/dist/persistence/network-lock.js +408 -0
  110. package/dist/persistence/repository.d.ts +101 -0
  111. package/dist/persistence/repository.js +11 -0
  112. package/dist/persistence/state-transition.d.ts +4 -0
  113. package/dist/persistence/state-transition.js +25 -0
  114. package/dist/requests/flownet-child-client.d.ts +78 -0
  115. package/dist/requests/flownet-child-client.js +243 -0
  116. package/dist/requests/kintone-request-store.d.ts +44 -0
  117. package/dist/requests/kintone-request-store.js +202 -0
  118. package/dist/requests/poll-requests-config.d.ts +26 -0
  119. package/dist/requests/poll-requests-config.js +105 -0
  120. package/dist/requests/request-model.d.ts +57 -0
  121. package/dist/requests/request-model.js +305 -0
  122. package/dist/requests/request-poller.d.ts +33 -0
  123. package/dist/requests/request-poller.js +353 -0
  124. package/dist/requests/request-result.d.ts +18 -0
  125. package/dist/requests/request-result.js +215 -0
  126. package/dist/requests/start-request.d.ts +26 -0
  127. package/dist/requests/start-request.js +83 -0
  128. package/package.json +63 -0
  129. package/schemas/.gitkeep +1 -0
  130. package/schemas/network-definition.schema.json +121 -0
@@ -0,0 +1,243 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ export const DEFAULT_CHILD_OUTPUT_LIMIT = 64 * 1024;
7
+ export const REQUESTED_BY_MAX_LENGTH = 1024;
8
+ export class FlownetChildClient {
9
+ profile;
10
+ environment;
11
+ outputLimit;
12
+ cliPath;
13
+ executeProcess;
14
+ makeTempDirectory;
15
+ removeTempDirectory;
16
+ constructor(options) {
17
+ this.profile = options.profile;
18
+ this.environment = options.environment ?? process.env;
19
+ this.outputLimit = options.outputLimit ?? DEFAULT_CHILD_OUTPUT_LIMIT;
20
+ if (!Number.isSafeInteger(this.outputLimit) || this.outputLimit < 1) {
21
+ throw new RangeError("outputLimit must be a positive integer");
22
+ }
23
+ this.cliPath =
24
+ options.cliPath ??
25
+ fileURLToPath(new URL("../cli/index.js", import.meta.url));
26
+ this.executeProcess =
27
+ options.execute ??
28
+ ((command, args, childOptions) => executeChild(command, args, childOptions, this.outputLimit));
29
+ this.makeTempDirectory =
30
+ options.makeTempDirectory ??
31
+ (() => mkdtemp(join(tmpdir(), "ksql-flownet-request-")));
32
+ this.removeTempDirectory =
33
+ options.removeTempDirectory ??
34
+ ((path) => rm(path, { recursive: true, force: true }));
35
+ }
36
+ async status(network, selector) {
37
+ const result = await this.run([
38
+ "status",
39
+ network.networkId,
40
+ "--profile",
41
+ this.profile,
42
+ ...(typeof selector === "string"
43
+ ? ["--run-id", selector]
44
+ : ["--business-key", selector.businessKey]),
45
+ "--json",
46
+ ], undefined);
47
+ if (result.exitCode !== 0 || result.spawnError !== undefined) {
48
+ if (result.spawnError === undefined &&
49
+ /^Error \[RECORD_NOT_FOUND\]:/u.test(result.stderr)) {
50
+ return null;
51
+ }
52
+ throw new ChildClientError("STATUS_CHILD_FAILED", "status child failed", result);
53
+ }
54
+ try {
55
+ return JSON.parse(result.stdout);
56
+ }
57
+ catch (error) {
58
+ throw new ChildClientError("STATUS_JSON_INVALID", "status child returned invalid JSON", result, error);
59
+ }
60
+ }
61
+ async runNetwork(network, request) {
62
+ const args = [
63
+ "run-network",
64
+ network.definitionPath,
65
+ "--resume-run",
66
+ request.runId,
67
+ ...(request.rerunFromNode === null
68
+ ? []
69
+ : ["--rerun-from", request.rerunFromNode]),
70
+ "--json",
71
+ ];
72
+ const processResult = await this.run(args, requestedBy(request));
73
+ let output = null;
74
+ try {
75
+ output = JSON.parse(processResult.stdout);
76
+ }
77
+ catch {
78
+ // The classifier treats an absent machine-readable result as a rejection.
79
+ }
80
+ return { output, process: processResult };
81
+ }
82
+ async startNetwork(network, request, input) {
83
+ const args = [
84
+ "run-network",
85
+ network.definitionPath,
86
+ ...(input.businessKey === undefined
87
+ ? []
88
+ : ["--business-key", input.businessKey]),
89
+ ...(input.scheduledFor === undefined
90
+ ? []
91
+ : ["--scheduled-for", input.scheduledFor]),
92
+ "--json",
93
+ ];
94
+ const processResult = await this.run(args, requestedBy(request));
95
+ let output = null;
96
+ try {
97
+ output = JSON.parse(processResult.stdout);
98
+ }
99
+ catch {
100
+ // The classifier treats an absent machine-readable result as a rejection.
101
+ }
102
+ return { output, process: processResult };
103
+ }
104
+ async cancelRun(request, release) {
105
+ const directory = await this.makeTempDirectory();
106
+ const reasonPath = join(directory, "reason.txt");
107
+ try {
108
+ await writeFile(reasonPath, request.reason, {
109
+ encoding: "utf8",
110
+ mode: 0o600,
111
+ });
112
+ return await this.run([
113
+ "cancel-run",
114
+ "--run-id",
115
+ request.runId,
116
+ ...(release ? ["--release"] : []),
117
+ "--reason-file",
118
+ reasonPath,
119
+ ], requestedBy(request));
120
+ }
121
+ finally {
122
+ await this.removeTempDirectory(directory);
123
+ }
124
+ }
125
+ async archiveRun(network, request) {
126
+ const directory = await this.makeTempDirectory();
127
+ const reasonPath = join(directory, "reason.txt");
128
+ try {
129
+ await writeFile(reasonPath, request.reason, {
130
+ encoding: "utf8",
131
+ mode: 0o600,
132
+ });
133
+ const processResult = await this.run([
134
+ "archive-run",
135
+ network.definitionPath,
136
+ "--run-id",
137
+ request.runId,
138
+ "--reason-file",
139
+ reasonPath,
140
+ ], requestedBy(request));
141
+ let output = null;
142
+ try {
143
+ output = JSON.parse(processResult.stdout);
144
+ }
145
+ catch {
146
+ // The classifier treats an absent machine-readable result as invalid.
147
+ }
148
+ return { output, process: processResult };
149
+ }
150
+ finally {
151
+ await this.removeTempDirectory(directory);
152
+ }
153
+ }
154
+ run(args, correlation) {
155
+ const env = {
156
+ ...this.environment,
157
+ ...(correlation === undefined
158
+ ? {}
159
+ : { KSQL_FLOWNET_REQUESTED_BY: correlation }),
160
+ };
161
+ return this.executeProcess(process.execPath, [this.cliPath, ...args], {
162
+ env,
163
+ shell: false,
164
+ });
165
+ }
166
+ }
167
+ export class ChildClientError extends Error {
168
+ code;
169
+ result;
170
+ constructor(code, message, result, cause) {
171
+ super(message, cause === undefined ? undefined : { cause });
172
+ this.code = code;
173
+ this.result = result;
174
+ this.name = "ChildClientError";
175
+ }
176
+ }
177
+ export function requestedBy(request) {
178
+ let encoded;
179
+ try {
180
+ encoded = encodeURIComponent(request.creatorCode);
181
+ }
182
+ catch (error) {
183
+ throw new RequestCorrelationError("request creator cannot be percent-encoded", error);
184
+ }
185
+ const value = `app-request:${request.id}:${encoded}`;
186
+ if (value.length > REQUESTED_BY_MAX_LENGTH) {
187
+ throw new RequestCorrelationError("request correlation value is too long");
188
+ }
189
+ return value;
190
+ }
191
+ export class RequestCorrelationError extends Error {
192
+ code = "REQUESTED_BY_INVALID";
193
+ constructor(message, cause) {
194
+ super(message, cause === undefined ? undefined : { cause });
195
+ this.name = "RequestCorrelationError";
196
+ }
197
+ }
198
+ export function executeChild(command, args, options, limit) {
199
+ return new Promise((resolve) => {
200
+ let stdout = Buffer.alloc(0);
201
+ let stderr = Buffer.alloc(0);
202
+ let stdoutTruncated = false;
203
+ let stderrTruncated = false;
204
+ let spawnError;
205
+ const child = spawn(command, [...args], options);
206
+ child.stdout.on("data", (chunk) => {
207
+ const appended = appendBounded(stdout, chunk, limit);
208
+ stdout = appended.value;
209
+ stdoutTruncated ||= appended.truncated;
210
+ });
211
+ child.stderr.on("data", (chunk) => {
212
+ const appended = appendBounded(stderr, chunk, limit);
213
+ stderr = appended.value;
214
+ stderrTruncated ||= appended.truncated;
215
+ });
216
+ child.once("error", (error) => {
217
+ spawnError =
218
+ "code" in error && typeof error.code === "string"
219
+ ? error.code
220
+ : "SPAWN_FAILED";
221
+ });
222
+ child.once("close", (exitCode) => {
223
+ resolve({
224
+ exitCode,
225
+ stdout: stdout.toString("utf8"),
226
+ stderr: stderr.toString("utf8"),
227
+ stdoutTruncated,
228
+ stderrTruncated,
229
+ ...(spawnError === undefined ? {} : { spawnError }),
230
+ });
231
+ });
232
+ });
233
+ }
234
+ function appendBounded(current, chunk, limit) {
235
+ if (current.length >= limit)
236
+ return { value: current, truncated: true };
237
+ const remaining = limit - current.length;
238
+ return {
239
+ value: Buffer.concat([current, chunk.subarray(0, remaining)]),
240
+ truncated: chunk.length > remaining,
241
+ };
242
+ }
243
+ //# sourceMappingURL=flownet-child-client.js.map
@@ -0,0 +1,44 @@
1
+ import { type KintoneClientConfig } from "../persistence/kintone/client.js";
2
+ import { type RequestRecord, type TerminalRequestState, type RequestEnvelope } from "./request-model.js";
3
+ export declare const DEFAULT_REQUEST_FETCH_LIMIT = 100;
4
+ export declare const MAX_REQUEST_FETCH_LIMIT = 500;
5
+ export interface KintoneRequestStoreConfig extends KintoneClientConfig {
6
+ readonly fetchLimit?: number;
7
+ }
8
+ export interface RequestResult {
9
+ readonly state: TerminalRequestState;
10
+ readonly code: string;
11
+ readonly message: string;
12
+ }
13
+ export interface InvalidRequestRecord {
14
+ readonly id: string;
15
+ readonly revision: number;
16
+ readonly requestState: RequestEnvelope["requestState"];
17
+ readonly cancelRequested: RequestEnvelope["cancelRequested"];
18
+ readonly issues: readonly {
19
+ readonly code: string;
20
+ readonly field: string;
21
+ readonly message: string;
22
+ }[];
23
+ }
24
+ export interface RequestedRecords {
25
+ readonly valid: readonly RequestRecord[];
26
+ readonly invalid: readonly InvalidRequestRecord[];
27
+ readonly skipped: number;
28
+ }
29
+ export declare class KintoneRequestStore {
30
+ private readonly client;
31
+ private readonly fetchLimit;
32
+ constructor(config: KintoneRequestStoreConfig);
33
+ listRequested(): Promise<RequestedRecords>;
34
+ cancelBeforeClaim(request: RequestEnvelope, result: RequestResult): Promise<boolean>;
35
+ listAccepted(): Promise<readonly RequestRecord[]>;
36
+ rejectInvalid(request: Pick<InvalidRequestRecord, "id" | "revision">, result: RequestResult): Promise<void>;
37
+ claim(request: RequestRecord, claimedHost: string, now: string): Promise<RequestRecord | null>;
38
+ heartbeat(request: RequestRecord, now: string): Promise<RequestRecord>;
39
+ writeResult(request: RequestRecord, result: RequestResult): Promise<RequestRecord>;
40
+ getById(id: string): Promise<RequestRecord | null>;
41
+ private resultRecord;
42
+ private readIdentity;
43
+ }
44
+ //# sourceMappingURL=kintone-request-store.d.ts.map
@@ -0,0 +1,202 @@
1
+ import { KintoneApiError, KintoneClient, } from "../persistence/kintone/client.js";
2
+ import { parseRequestRecord, parseRequestEnvelope, RequestValidationError, validateRequestRecord, } from "./request-model.js";
3
+ export const DEFAULT_REQUEST_FETCH_LIMIT = 100;
4
+ export const MAX_REQUEST_FETCH_LIMIT = 500;
5
+ function field(value) {
6
+ return { value };
7
+ }
8
+ function conflict(error) {
9
+ return (error instanceof KintoneApiError &&
10
+ (error.status === 409 ||
11
+ error.apiCode === "GAIA_CO02" ||
12
+ error.apiCode === "GAIA_DA02"));
13
+ }
14
+ function terminalMatches(record, result) {
15
+ return (record.requestState === result.state &&
16
+ record.resultCode === result.code &&
17
+ record.resultMessage === result.message);
18
+ }
19
+ function withRevision(record, revision) {
20
+ return { ...record, revision };
21
+ }
22
+ function assertValid(record) {
23
+ const issues = validateRequestRecord(record);
24
+ if (issues.length > 0)
25
+ throw new RequestValidationError(issues);
26
+ }
27
+ export class KintoneRequestStore {
28
+ client;
29
+ fetchLimit;
30
+ constructor(config) {
31
+ const fetchLimit = config.fetchLimit ?? DEFAULT_REQUEST_FETCH_LIMIT;
32
+ if (!Number.isSafeInteger(fetchLimit) ||
33
+ fetchLimit < 1 ||
34
+ fetchLimit > MAX_REQUEST_FETCH_LIMIT) {
35
+ throw new RangeError(`fetchLimit must be an integer from 1 to ${MAX_REQUEST_FETCH_LIMIT}`);
36
+ }
37
+ this.fetchLimit = fetchLimit;
38
+ this.client = new KintoneClient(config);
39
+ }
40
+ async listRequested() {
41
+ const records = await this.client.getRecords(`request_state in ("REQUESTED") order by 作成日時 asc, $id asc limit ${this.fetchLimit}`);
42
+ const valid = [];
43
+ const invalid = [];
44
+ let skipped = 0;
45
+ for (const record of records) {
46
+ let envelope;
47
+ try {
48
+ envelope = parseRequestEnvelope(record);
49
+ }
50
+ catch {
51
+ skipped += 1;
52
+ continue;
53
+ }
54
+ try {
55
+ valid.push(parseRequestRecord(record));
56
+ }
57
+ catch (error) {
58
+ if (!(error instanceof RequestValidationError))
59
+ throw error;
60
+ invalid.push({
61
+ id: envelope.id,
62
+ revision: envelope.revision,
63
+ requestState: envelope.requestState,
64
+ cancelRequested: envelope.cancelRequested,
65
+ issues: error.issues,
66
+ });
67
+ }
68
+ }
69
+ return { valid, invalid, skipped };
70
+ }
71
+ async cancelBeforeClaim(request, result) {
72
+ if (request.requestState !== "REQUESTED" ||
73
+ request.cancelRequested !== true ||
74
+ result.state !== "CANCELLED")
75
+ throw new Error("cancellation requires REQUESTED -> CANCELLED");
76
+ try {
77
+ await this.client.putRecordById(request.id, request.revision, {
78
+ request_state: field("CANCELLED"),
79
+ result_code: field(result.code),
80
+ result_message: field(result.message),
81
+ });
82
+ return true;
83
+ }
84
+ catch (error) {
85
+ if (conflict(error))
86
+ return false;
87
+ throw error;
88
+ }
89
+ }
90
+ async listAccepted() {
91
+ const records = await this.client.getRecords(`request_state in ("ACCEPTED") order by claim_heartbeat_at asc, $id asc limit ${this.fetchLimit}`);
92
+ return records.map(parseRequestRecord);
93
+ }
94
+ async rejectInvalid(request, result) {
95
+ if (result.state !== "REJECTED") {
96
+ throw new Error("invalid requests can only be REJECTED");
97
+ }
98
+ try {
99
+ await this.client.putRecordById(request.id, request.revision, {
100
+ request_state: field("REJECTED"),
101
+ result_code: field(result.code),
102
+ result_message: field(result.message),
103
+ });
104
+ }
105
+ catch (error) {
106
+ if (!conflict(error))
107
+ throw error;
108
+ }
109
+ }
110
+ async claim(request, claimedHost, now) {
111
+ if (request.requestState !== "REQUESTED") {
112
+ throw new Error("only REQUESTED records can be claimed");
113
+ }
114
+ const claimed = {
115
+ ...request,
116
+ requestState: "ACCEPTED",
117
+ claimedAt: now,
118
+ claimedHost,
119
+ claimHeartbeatAt: now,
120
+ };
121
+ assertValid(claimed);
122
+ try {
123
+ const revision = await this.client.putRecordById(request.id, request.revision, {
124
+ request_state: field("ACCEPTED"),
125
+ claimed_at: field(now),
126
+ claimed_host: field(claimedHost),
127
+ claim_heartbeat_at: field(now),
128
+ });
129
+ return withRevision(claimed, revision);
130
+ }
131
+ catch (error) {
132
+ if (conflict(error))
133
+ return null;
134
+ throw error;
135
+ }
136
+ }
137
+ async heartbeat(request, now) {
138
+ if (request.requestState !== "ACCEPTED") {
139
+ throw new Error("only ACCEPTED records can receive a heartbeat");
140
+ }
141
+ const updated = { ...request, claimHeartbeatAt: now };
142
+ assertValid(updated);
143
+ const revision = await this.client.putRecordById(request.id, request.revision, { claim_heartbeat_at: field(now) });
144
+ return withRevision(updated, revision);
145
+ }
146
+ async writeResult(request, result) {
147
+ if (request.requestState !== "ACCEPTED" &&
148
+ !(request.requestState === "REQUESTED" && result.state === "REJECTED")) {
149
+ throw new Error("results require ACCEPTED, except direct validation rejection from REQUESTED");
150
+ }
151
+ const projected = this.resultRecord(request, result, request.revision);
152
+ assertValid(projected);
153
+ const update = {
154
+ request_state: field(result.state),
155
+ result_code: field(result.code),
156
+ result_message: field(result.message),
157
+ };
158
+ try {
159
+ const revision = await this.client.putRecordById(request.id, request.revision, update);
160
+ return this.resultRecord(request, result, revision);
161
+ }
162
+ catch (error) {
163
+ if (!conflict(error))
164
+ throw error;
165
+ }
166
+ const current = await this.getById(request.id);
167
+ if (current === null)
168
+ throw new Error("request record disappeared after conflict");
169
+ if (terminalMatches(current, result))
170
+ return current;
171
+ const revision = await this.client.putRecordById(current.id, current.revision, update);
172
+ return this.resultRecord(current, result, revision);
173
+ }
174
+ async getById(id) {
175
+ const records = await this.client.getRecords(`$id in ("${id}") limit 1`);
176
+ const record = records[0];
177
+ return record === undefined ? null : parseRequestRecord(record);
178
+ }
179
+ resultRecord(request, result, revision) {
180
+ return withRevision({
181
+ ...request,
182
+ requestState: result.state,
183
+ resultCode: result.code,
184
+ resultMessage: result.message,
185
+ }, revision);
186
+ }
187
+ readIdentity(record) {
188
+ const id = record.$id?.value;
189
+ const revision = record.$revision?.value;
190
+ if (typeof id !== "string" ||
191
+ !/^\d+$/.test(id) ||
192
+ typeof revision !== "string" ||
193
+ !/^\d+$/.test(revision)) {
194
+ return null;
195
+ }
196
+ const parsedRevision = Number(revision);
197
+ if (!Number.isSafeInteger(parsedRevision) || parsedRevision < 1)
198
+ return null;
199
+ return { id, revision: parsedRevision };
200
+ }
201
+ }
202
+ //# sourceMappingURL=kintone-request-store.js.map
@@ -0,0 +1,26 @@
1
+ export declare const DEFAULT_REQUEST_HEARTBEAT_INTERVAL_MS = 60000;
2
+ export declare const DEFAULT_REQUEST_STALE_AFTER_MS: number;
3
+ export declare const KINTONE_DATETIME_PRECISION_ALLOWANCE_MS = 60000;
4
+ export interface PollRequestsNetwork {
5
+ readonly networkId: string;
6
+ readonly definitionPath: string;
7
+ readonly appStart: boolean;
8
+ }
9
+ export interface PollRequestsConfig {
10
+ readonly networks: readonly PollRequestsNetwork[];
11
+ readonly heartbeatIntervalMs: number;
12
+ readonly staleAfterMs: number;
13
+ readonly stalePrecisionAllowanceMs: number;
14
+ }
15
+ export interface LoadPollRequestsConfigOptions {
16
+ readonly heartbeatIntervalMs?: number;
17
+ readonly staleAfterMs?: number;
18
+ }
19
+ export declare class PollRequestsConfigError extends Error {
20
+ readonly code: string;
21
+ constructor(code: string, message: string, cause?: unknown);
22
+ }
23
+ export declare function loadPollRequestsConfig(allowlistPath: string, options?: LoadPollRequestsConfigOptions): PollRequestsConfig;
24
+ export declare function definitionPathForNetwork(config: PollRequestsConfig, networkId: string): string;
25
+ export declare function networkForNetworkId(config: PollRequestsConfig, networkId: string): PollRequestsNetwork;
26
+ //# sourceMappingURL=poll-requests-config.d.ts.map
@@ -0,0 +1,105 @@
1
+ import { isAbsolute, resolve } from "node:path";
2
+ import { readFileSync } from "node:fs";
3
+ import { parseDocument } from "yaml";
4
+ import { validateNetworkPath } from "../domain/validate-network-path.js";
5
+ export const DEFAULT_REQUEST_HEARTBEAT_INTERVAL_MS = 60_000;
6
+ export const DEFAULT_REQUEST_STALE_AFTER_MS = 15 * 60_000;
7
+ export const KINTONE_DATETIME_PRECISION_ALLOWANCE_MS = 60_000;
8
+ export class PollRequestsConfigError extends Error {
9
+ code;
10
+ constructor(code, message, cause) {
11
+ super(message, cause === undefined ? undefined : { cause });
12
+ this.name = "PollRequestsConfigError";
13
+ this.code = code;
14
+ }
15
+ }
16
+ function positiveInteger(value, name) {
17
+ if (!Number.isSafeInteger(value) || value <= 0) {
18
+ throw new PollRequestsConfigError("THRESHOLD_INVALID", `${name} must be a positive integer`);
19
+ }
20
+ return value;
21
+ }
22
+ function objectValue(value) {
23
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
24
+ throw new PollRequestsConfigError("CONFIG_SHAPE_INVALID", "allowlist root must be an object");
25
+ }
26
+ return value;
27
+ }
28
+ export function loadPollRequestsConfig(allowlistPath, options = {}) {
29
+ if (!isAbsolute(allowlistPath)) {
30
+ throw new PollRequestsConfigError("ALLOWLIST_PATH_NOT_ABSOLUTE", "allowlist path must be absolute");
31
+ }
32
+ let source;
33
+ try {
34
+ source = readFileSync(allowlistPath, "utf8");
35
+ }
36
+ catch (error) {
37
+ throw new PollRequestsConfigError("ALLOWLIST_UNREADABLE", "allowlist file cannot be read", error);
38
+ }
39
+ const document = parseDocument(source, { uniqueKeys: true });
40
+ if (document.errors.length > 0) {
41
+ throw new PollRequestsConfigError("ALLOWLIST_PARSE_ERROR", `invalid allowlist: ${document.errors[0]?.message ?? "parse error"}`);
42
+ }
43
+ const root = objectValue(document.toJS());
44
+ if (!Array.isArray(root.networks) ||
45
+ root.networks.length === 0 ||
46
+ Object.keys(root).some((key) => key !== "networks")) {
47
+ throw new PollRequestsConfigError("CONFIG_SHAPE_INVALID", "allowlist must contain only a non-empty networks array");
48
+ }
49
+ const seen = new Set();
50
+ const networks = root.networks.map((entry, index) => {
51
+ const item = objectValue(entry);
52
+ if (Object.keys(item).some((key) => key !== "network_id" &&
53
+ key !== "definition_path" &&
54
+ key !== "app_start") ||
55
+ typeof item.network_id !== "string" ||
56
+ item.network_id.trim() === "" ||
57
+ typeof item.definition_path !== "string" ||
58
+ item.definition_path.trim() === "" ||
59
+ (item.app_start !== undefined && typeof item.app_start !== "boolean")) {
60
+ throw new PollRequestsConfigError("NETWORK_ENTRY_INVALID", `networks[${index}] must contain network_id and definition_path`);
61
+ }
62
+ if (seen.has(item.network_id)) {
63
+ throw new PollRequestsConfigError("DUPLICATE_NETWORK", `duplicate network_id: ${item.network_id}`);
64
+ }
65
+ seen.add(item.network_id);
66
+ if (!isAbsolute(item.definition_path)) {
67
+ throw new PollRequestsConfigError("DEFINITION_PATH_NOT_ABSOLUTE", `definition path for ${item.network_id} must be absolute`);
68
+ }
69
+ const definitionPath = resolve(item.definition_path);
70
+ const loaded = validateNetworkPath(definitionPath);
71
+ if (loaded.definition === undefined || loaded.errors.length > 0) {
72
+ throw new PollRequestsConfigError("NETWORK_DEFINITION_INVALID", `network definition for ${item.network_id} is unavailable or invalid`);
73
+ }
74
+ if (loaded.definition.network_id !== item.network_id) {
75
+ throw new PollRequestsConfigError("NETWORK_ID_MISMATCH", `allowlist network_id does not match definition: ${item.network_id}`);
76
+ }
77
+ return {
78
+ networkId: item.network_id,
79
+ definitionPath,
80
+ appStart: item.app_start === true,
81
+ };
82
+ });
83
+ const heartbeatIntervalMs = positiveInteger(options.heartbeatIntervalMs ?? DEFAULT_REQUEST_HEARTBEAT_INTERVAL_MS, "heartbeatIntervalMs");
84
+ const staleAfterMs = positiveInteger(options.staleAfterMs ?? DEFAULT_REQUEST_STALE_AFTER_MS, "staleAfterMs");
85
+ if (staleAfterMs <= heartbeatIntervalMs) {
86
+ throw new PollRequestsConfigError("THRESHOLD_INVALID", "staleAfterMs must be greater than heartbeatIntervalMs");
87
+ }
88
+ return {
89
+ networks,
90
+ heartbeatIntervalMs,
91
+ staleAfterMs,
92
+ stalePrecisionAllowanceMs: KINTONE_DATETIME_PRECISION_ALLOWANCE_MS,
93
+ };
94
+ }
95
+ export function definitionPathForNetwork(config, networkId) {
96
+ return networkForNetworkId(config, networkId).definitionPath;
97
+ }
98
+ export function networkForNetworkId(config, networkId) {
99
+ const match = config.networks.find((network) => network.networkId === networkId);
100
+ if (match === undefined) {
101
+ throw new PollRequestsConfigError("NETWORK_NOT_ALLOWED", `network is not in the allowlist: ${networkId}`);
102
+ }
103
+ return match;
104
+ }
105
+ //# sourceMappingURL=poll-requests-config.js.map
@@ -0,0 +1,57 @@
1
+ import type { KintoneRecord } from "../persistence/kintone/client.js";
2
+ export declare const REQUEST_TYPES: readonly ["RERUN", "STOP", "RELEASE", "START", "CLOSE"];
3
+ export type RequestType = (typeof REQUEST_TYPES)[number];
4
+ export declare const REQUEST_STATES: readonly ["REQUESTED", "ACCEPTED", "DONE", "REJECTED", "CANCELLED"];
5
+ export type RequestState = (typeof REQUEST_STATES)[number];
6
+ export type TerminalRequestState = Extract<RequestState, "DONE" | "REJECTED" | "CANCELLED">;
7
+ export type CancelRequestedEnvelope = boolean | "INVALID";
8
+ export interface RequestEnvelope {
9
+ readonly id: string;
10
+ readonly revision: number;
11
+ readonly requestState: RequestState;
12
+ readonly cancelRequested: CancelRequestedEnvelope;
13
+ }
14
+ export declare const REQUEST_VALUE_LIMITS: {
15
+ readonly runId: 128;
16
+ readonly networkId: 128;
17
+ readonly businessKey: 128;
18
+ readonly rerunFromNode: 128;
19
+ readonly reason: 65535;
20
+ readonly creatorCode: 256;
21
+ readonly claimedHost: 256;
22
+ readonly resultCode: 128;
23
+ readonly resultMessage: 65535;
24
+ };
25
+ export interface RequestRecord {
26
+ readonly id: string;
27
+ readonly revision: number;
28
+ readonly creatorCode: string;
29
+ readonly createdAt: string;
30
+ readonly requestType: RequestType;
31
+ readonly runId: string;
32
+ readonly networkId: string | null;
33
+ readonly businessKey: string | null;
34
+ readonly scheduledFor: string | null;
35
+ readonly rerunFromNode: string | null;
36
+ readonly reason: string;
37
+ readonly requestState: RequestState;
38
+ readonly cancelRequested: boolean;
39
+ readonly claimedAt: string | null;
40
+ readonly claimedHost: string | null;
41
+ readonly claimHeartbeatAt: string | null;
42
+ readonly resultCode: string | null;
43
+ readonly resultMessage: string | null;
44
+ }
45
+ export interface RequestValidationIssue {
46
+ readonly code: string;
47
+ readonly field: string;
48
+ readonly message: string;
49
+ }
50
+ export declare class RequestValidationError extends Error {
51
+ readonly issues: readonly RequestValidationIssue[];
52
+ constructor(issues: readonly RequestValidationIssue[]);
53
+ }
54
+ export declare function validateRequestRecord(record: RequestRecord): readonly RequestValidationIssue[];
55
+ export declare function parseRequestEnvelope(record: KintoneRecord): RequestEnvelope;
56
+ export declare function parseRequestRecord(record: KintoneRecord): RequestRecord;
57
+ //# sourceMappingURL=request-model.d.ts.map