@firedrill-tools/unstructured 0.1.4
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/LICENSE +201 -0
- package/README.md +275 -0
- package/firedrill/agent.target.json +16 -0
- package/firedrill/baseline.scenario.json +5 -0
- package/firedrill/conformance.suite.json +21 -0
- package/firedrill/overloaded.scenario.json +11 -0
- package/firedrill/rate-limited.scenario.json +11 -0
- package/firedrill/run-response-lost.scenario.json +11 -0
- package/firedrill/small-responses.scenario.json +21 -0
- package/firedrill/tight-limits.scenario.json +21 -0
- package/firedrill/tools/unstructured/behavior.mjs +69 -0
- package/firedrill/tools/unstructured/lib/connectors.mjs +68 -0
- package/firedrill/tools/unstructured/lib/errors.mjs +58 -0
- package/firedrill/tools/unstructured/lib/gzip.mjs +38 -0
- package/firedrill/tools/unstructured/lib/identity.mjs +19 -0
- package/firedrill/tools/unstructured/lib/ids.mjs +36 -0
- package/firedrill/tools/unstructured/lib/jobs-derive.mjs +92 -0
- package/firedrill/tools/unstructured/lib/multipart.mjs +171 -0
- package/firedrill/tools/unstructured/lib/pages.mjs +40 -0
- package/firedrill/tools/unstructured/lib/partition/chunk.mjs +243 -0
- package/firedrill/tools/unstructured/lib/partition/csv.mjs +85 -0
- package/firedrill/tools/unstructured/lib/partition/csvout.mjs +25 -0
- package/firedrill/tools/unstructured/lib/partition/elements.mjs +171 -0
- package/firedrill/tools/unstructured/lib/partition/email.mjs +269 -0
- package/firedrill/tools/unstructured/lib/partition/html-tokens.mjs +134 -0
- package/firedrill/tools/unstructured/lib/partition/html-util.mjs +99 -0
- package/firedrill/tools/unstructured/lib/partition/html.mjs +211 -0
- package/firedrill/tools/unstructured/lib/partition/index.mjs +122 -0
- package/firedrill/tools/unstructured/lib/partition/markdown.mjs +220 -0
- package/firedrill/tools/unstructured/lib/partition/other.mjs +118 -0
- package/firedrill/tools/unstructured/lib/partition/text.mjs +53 -0
- package/firedrill/tools/unstructured/lib/sha256.mjs +161 -0
- package/firedrill/tools/unstructured/lib/store.mjs +33 -0
- package/firedrill/tools/unstructured/lib/util.mjs +149 -0
- package/firedrill/tools/unstructured/lib/validate.mjs +115 -0
- package/firedrill/tools/unstructured/lib/wire-multipart.mjs +78 -0
- package/firedrill/tools/unstructured/lib/wire.mjs +154 -0
- package/firedrill/tools/unstructured/ops/connectors.mjs +129 -0
- package/firedrill/tools/unstructured/ops/jobs.mjs +83 -0
- package/firedrill/tools/unstructured/ops/nodes.mjs +107 -0
- package/firedrill/tools/unstructured/ops/partition.mjs +112 -0
- package/firedrill/tools/unstructured/ops/workflows.mjs +180 -0
- package/firedrill/tools/unstructured/unstructured.tool.json +4892 -0
- package/firedrill/unstructured-archivist.drill.json +68 -0
- package/firedrill/unstructured-chunking.drill.json +67 -0
- package/firedrill/unstructured-connectors.drill.json +121 -0
- package/firedrill/unstructured-denied.drill.json +58 -0
- package/firedrill/unstructured-fresh-actor.drill.json +68 -0
- package/firedrill/unstructured-overloaded.drill.json +51 -0
- package/firedrill/unstructured-partition-errors.drill.json +66 -0
- package/firedrill/unstructured-partition.drill.json +95 -0
- package/firedrill/unstructured-rate-limited.drill.json +66 -0
- package/firedrill/unstructured-revoked-key.drill.json +773 -0
- package/firedrill/unstructured-run-lost.drill.json +51 -0
- package/firedrill/unstructured-small-responses.drill.json +173 -0
- package/firedrill/unstructured-tight-limits.drill.json +203 -0
- package/firedrill/unstructured-workflows-jobs.drill.json +167 -0
- package/firedrill/world.json +1556 -0
- package/firedrill.json +5 -0
- package/package.json +52 -0
- package/starter.json +1114 -0
- package/test/conformance.mjs +37 -0
- package/test/flows/access.mjs +54 -0
- package/test/flows/chunking.mjs +95 -0
- package/test/flows/connectors.mjs +76 -0
- package/test/flows/errors.mjs +115 -0
- package/test/flows/faults.mjs +73 -0
- package/test/flows/partition.mjs +225 -0
- package/test/flows/workflows.mjs +123 -0
- package/test/hostile-gen.mjs +0 -0
- package/test/hostile.mjs +155 -0
- package/test/lib.mjs +113 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Synthetic Unstructured account for Firedrill: the legacy Partition Endpoint (`POST /general/v0/general`) over a
|
|
2
|
+
// deterministic text partitioner, and the Workflow Endpoint subset (sources, destinations, workflows, jobs).
|
|
3
|
+
// Every operation computes from `context.state`; ids come from the seeded random source and `meta/counters`,
|
|
4
|
+
// timestamps from virtual time. No Unstructured service is contacted.
|
|
5
|
+
import { DESTINATION_TYPES, SOURCE_TYPES } from "./lib/connectors.mjs";
|
|
6
|
+
import { Q, decoder, encodeError, encoder } from "./lib/wire.mjs";
|
|
7
|
+
import { decodePartition, decodeRun } from "./lib/wire-multipart.mjs";
|
|
8
|
+
import { connectorOps } from "./ops/connectors.mjs";
|
|
9
|
+
import * as jobs from "./ops/jobs.mjs";
|
|
10
|
+
import { partition } from "./ops/partition.mjs";
|
|
11
|
+
import * as workflows from "./ops/workflows.mjs";
|
|
12
|
+
|
|
13
|
+
const sources = connectorOps({ namespace: "sources", idField: "source_id", types: SOURCE_TYPES, typeFilter: "source_type", label: "Source connector" });
|
|
14
|
+
const destinations = connectorOps({ namespace: "destinations", idField: "destination_id", types: DESTINATION_TYPES, typeFilter: "destination_type", label: "Destination connector" });
|
|
15
|
+
|
|
16
|
+
const CONNECTOR_FIELDS = ["name", "type", "config", "key"];
|
|
17
|
+
const WORKFLOW_FIELDS = ["name", "workflow_type", "source_id", "destination_id", "workflow_nodes", "schedule", "reprocess_all", "key", "template_id", "source_ids", "destination_ids", "skip_preflight"];
|
|
18
|
+
const WORKFLOW_UPDATE_FIELDS = [...WORKFLOW_FIELDS.filter((f) => f !== "key"), "status"];
|
|
19
|
+
const WORKFLOW_QUERY = { source_id: Q.str, destination_id: Q.str, status: Q.str, name: Q.str, page: Q.int, page_size: Q.int, sort_by: Q.str, sort_direction: Q.str, show_only_soft_deleted: Q.str, show_recommender_workflows: Q.str, dag_node_configuration_id: Q.str, created_since: Q.str, created_before: Q.str };
|
|
20
|
+
|
|
21
|
+
const API_VERSION = { "unstructured-api-version": "0.1.0-firedrill" };
|
|
22
|
+
const encodePartition = (result) => {
|
|
23
|
+
if (result.outcome.status !== "ok") return { headers: API_VERSION, ...encodeError(result) };
|
|
24
|
+
const value = result.outcome.value;
|
|
25
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value) && typeof value.csv === "string") return { headers: API_VERSION, body: { kind: "text", value: value.csv, contentType: "text/csv; charset=utf-8" } };
|
|
26
|
+
return { headers: API_VERSION, body: { kind: "json", value } };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** [operation id, handler, [route id, decode, encode?]...] */
|
|
30
|
+
const TABLE = [
|
|
31
|
+
["general.partition", partition, ["partition", decodePartition, encodePartition]],
|
|
32
|
+
|
|
33
|
+
["sources.list", sources.list, ["list-sources", decoder({ query: { source_type: Q.str }, idem: false })]],
|
|
34
|
+
["sources.create", sources.create, ["create-source", decoder({ body: "json", fields: CONNECTOR_FIELDS })]],
|
|
35
|
+
["sources.get", sources.get, ["get-source", decoder({ path: ["source_id"], idem: false })]],
|
|
36
|
+
["sources.update", sources.update, ["update-source", decoder({ path: ["source_id"], body: "json", fields: CONNECTOR_FIELDS })]],
|
|
37
|
+
["sources.delete", sources.remove, ["delete-source", decoder({ path: ["source_id"] })]],
|
|
38
|
+
["sources.check_connection", sources.checkConnection, ["create-source-connection-check", decoder({ path: ["source_id"] })]],
|
|
39
|
+
["sources.get_connection_check", sources.getConnectionCheck, ["get-source-connection-check", decoder({ path: ["source_id"], idem: false })]],
|
|
40
|
+
|
|
41
|
+
["destinations.list", destinations.list, ["list-destinations", decoder({ query: { destination_type: Q.str }, idem: false })]],
|
|
42
|
+
["destinations.create", destinations.create, ["create-destination", decoder({ body: "json", fields: CONNECTOR_FIELDS })]],
|
|
43
|
+
["destinations.get", destinations.get, ["get-destination", decoder({ path: ["destination_id"], idem: false })]],
|
|
44
|
+
["destinations.update", destinations.update, ["update-destination", decoder({ path: ["destination_id"], body: "json", fields: CONNECTOR_FIELDS })]],
|
|
45
|
+
["destinations.delete", destinations.remove, ["delete-destination", decoder({ path: ["destination_id"] })]],
|
|
46
|
+
|
|
47
|
+
["workflows.list", workflows.list, ["list-workflows", decoder({ query: WORKFLOW_QUERY, idem: false })]],
|
|
48
|
+
["workflows.create", workflows.create, ["create-workflow", decoder({ body: "json", fields: WORKFLOW_FIELDS })]],
|
|
49
|
+
["workflows.get", workflows.get, ["get-workflow", decoder({ path: ["workflow_id"], idem: false })]],
|
|
50
|
+
["workflows.update", workflows.update, ["update-workflow", decoder({ path: ["workflow_id"], body: "json", fields: WORKFLOW_UPDATE_FIELDS })]],
|
|
51
|
+
["workflows.delete", workflows.remove, ["delete-workflow", decoder({ path: ["workflow_id"] })]],
|
|
52
|
+
["workflows.run", workflows.run, ["run-workflow", decodeRun]],
|
|
53
|
+
|
|
54
|
+
["jobs.list", jobs.list, ["list-jobs", decoder({ query: { workflow_id: Q.str, status: Q.str, page: Q.int, page_size: Q.int }, idem: false })]],
|
|
55
|
+
["jobs.get", jobs.get, ["get-job", decoder({ path: ["job_id"], idem: false })]],
|
|
56
|
+
["jobs.cancel", jobs.cancel, ["cancel-job", decoder({ path: ["job_id"] })]],
|
|
57
|
+
["jobs.get_details", jobs.details, ["get-job-details", decoder({ path: ["job_id"], idem: false })]],
|
|
58
|
+
["jobs.get_failed_files", jobs.failedFiles, ["get-job-failed-files", decoder({ path: ["job_id"], idem: false })]],
|
|
59
|
+
["jobs.download_output", jobs.downloadOutput, ["download-job-output", decoder({ path: ["job_id"], query: { file_id: Q.str, node_id: Q.str }, idem: false })]],
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const operations = {};
|
|
63
|
+
const http = {};
|
|
64
|
+
for (const [operationId, handler, ...routes] of TABLE) {
|
|
65
|
+
operations[operationId] = handler;
|
|
66
|
+
for (const [routeId, decode, encode] of routes) http[routeId] = { decode, encode: encode ?? encoder };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default { operations, http };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Connector vocabulary: the source and destination connector types of the Workflow Endpoint, the configuration key
|
|
2
|
+
// each type needs for a successful connection check, and the secret keys masked in every response.
|
|
3
|
+
|
|
4
|
+
export const SOURCE_TYPES = [
|
|
5
|
+
"azure", "confluence", "couchbase", "databricks_volumes", "dropbox", "elasticsearch", "gcs", "google_drive", "jira", "kafka_cloud",
|
|
6
|
+
"mongodb", "onedrive", "outlook", "postgres", "s3", "salesforce", "sharepoint", "slack", "snowflake", "zendesk", "box", "notion",
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
export const DESTINATION_TYPES = [
|
|
10
|
+
"astradb", "azure_ai_search", "couchbase", "databricks_volumes", "databricks_volume_delta_tables", "delta_table", "elasticsearch", "gcs",
|
|
11
|
+
"kafka_cloud", "milvus", "mongodb", "motherduck", "neo4j", "onedrive", "pinecone", "postgres", "redis", "qdrant_cloud", "s3", "snowflake",
|
|
12
|
+
"weaviate_cloud", "ibm_watsonx_s3", "duckdb",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
/** First required configuration key per type (a non-empty string makes the connection check succeed). */
|
|
16
|
+
const REQUIRED = new Map([
|
|
17
|
+
["azure", ["remote_url"]], ["confluence", ["url"]], ["couchbase", ["connection_string", "bucket"]], ["databricks_volumes", ["host", "catalog", "volume"]],
|
|
18
|
+
["dropbox", ["remote_url"]], ["elasticsearch", ["hosts", "index_name"]], ["gcs", ["remote_url"]], ["google_drive", ["drive_id"]], ["jira", ["url"]],
|
|
19
|
+
["kafka_cloud", ["bootstrap_servers", "topic"]], ["mongodb", ["database", "collection"]], ["onedrive", ["client_id", "tenant", "user_pname"]],
|
|
20
|
+
["outlook", ["client_id", "tenant", "user_email"]], ["postgres", ["host", "database", "table_name"]], ["s3", ["remote_url"]], ["salesforce", ["username"]],
|
|
21
|
+
["sharepoint", ["site", "client_id", "tenant"]], ["slack", ["channels"]], ["snowflake", ["account", "database", "table_name"]], ["zendesk", ["subdomain"]],
|
|
22
|
+
["box", ["remote_url"]], ["notion", []],
|
|
23
|
+
["astradb", ["collection_name"]], ["azure_ai_search", ["endpoint", "index"]], ["databricks_volume_delta_tables", ["server_hostname", "catalog"]],
|
|
24
|
+
["delta_table", ["table_uri"]], ["milvus", ["uri", "collection_name"]], ["motherduck", ["database"]], ["neo4j", ["uri", "database"]],
|
|
25
|
+
["pinecone", ["index_name"]], ["redis", ["host"]], ["qdrant_cloud", ["url", "collection_name"]], ["weaviate_cloud", ["cluster_url", "collection"]],
|
|
26
|
+
["ibm_watsonx_s3", ["iceberg_endpoint", "catalog"]], ["duckdb", ["database"]],
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export const requiredKeys = (type) => REQUIRED.get(type) ?? [];
|
|
30
|
+
|
|
31
|
+
const SECRET_KEYS = new Set(["secret_access_key", "token", "password", "client_secret", "private_key", "api_key", "access_token", "client_cred", "aws_secret_access_key"]);
|
|
32
|
+
export const MASK = "********";
|
|
33
|
+
export const isSecretKey = (key) => SECRET_KEYS.has(key);
|
|
34
|
+
|
|
35
|
+
/** Response shape of a connector row: `config` with secrets masked, internal fields removed. */
|
|
36
|
+
export function connectorView(row) {
|
|
37
|
+
const config = {};
|
|
38
|
+
for (const key of Object.keys(row.config)) config[key] = isSecretKey(key) ? MASK : row.config[key];
|
|
39
|
+
return { id: row.id, name: row.name, type: row.type, config, created_at: row.created_at, updated_at: row.updated_at, key: row.key };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Validates a caller-supplied config object into a flat map of scalars; returns { config } or { issues: [[loc, msg]] }. */
|
|
43
|
+
export function normaliseConfig(value, loc) {
|
|
44
|
+
const issues = [];
|
|
45
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return { issues: [[loc, "Input should be a valid dictionary"]] };
|
|
46
|
+
const keys = Object.keys(value);
|
|
47
|
+
if (keys.length > 40) return { issues: [[loc, "Configuration has more than 40 keys"]] };
|
|
48
|
+
const config = {};
|
|
49
|
+
for (const key of keys) {
|
|
50
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype") {
|
|
51
|
+
issues.push([`${loc}.${key}`, "Invalid configuration key"]);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (key.length === 0 || key.length > 100 || !/^[A-Za-z0-9_.-]+$/.test(key)) {
|
|
55
|
+
issues.push([`${loc}.${key.slice(0, 60)}`, "Configuration keys must match ^[A-Za-z0-9_.-]{1,100}$"]);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const item = value[key];
|
|
59
|
+
if (item === null || typeof item === "boolean") config[key] = item;
|
|
60
|
+
else if (typeof item === "number" && Number.isFinite(item)) config[key] = item;
|
|
61
|
+
else if (typeof item === "string") {
|
|
62
|
+
if (item.length > 2000) issues.push([`${loc}.${key}`, "String should have at most 2000 characters"]);
|
|
63
|
+
else config[key] = item;
|
|
64
|
+
} else if (Array.isArray(item) && item.length <= 50 && item.every((x) => typeof x === "string" && x.length <= 500)) config[key] = item.join(",");
|
|
65
|
+
else issues.push([`${loc}.${key}`, "Configuration values must be strings, numbers, booleans or null"]);
|
|
66
|
+
}
|
|
67
|
+
return issues.length > 0 ? { issues } : { config };
|
|
68
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Declared error codes, their HTTP statuses and the helpers every handler uses to raise them.
|
|
2
|
+
import { clip } from "./util.mjs";
|
|
3
|
+
|
|
4
|
+
/** code -> HTTP status (FastAPI-style `{ detail }` envelopes are rendered by wire.mjs). */
|
|
5
|
+
export const STATUS = new Map([
|
|
6
|
+
["UNAUTHORIZED", 401],
|
|
7
|
+
["INVALID_REQUEST", 400],
|
|
8
|
+
["UNSUPPORTED_FILE_TYPE", 400],
|
|
9
|
+
["NOT_FOUND", 404],
|
|
10
|
+
["RESPONSE_TOO_LARGE", 413],
|
|
11
|
+
["INVALID_FILE", 422],
|
|
12
|
+
["VALIDATION_ERROR", 422],
|
|
13
|
+
["RATE_LIMITED", 429],
|
|
14
|
+
["INTERNAL_ERROR", 500],
|
|
15
|
+
["SERVICE_OVERLOADED", 503],
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export function fail(context, code, message) {
|
|
19
|
+
context.fail({ code, message: clip(message, 3000) });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Field-level validation failure. `loc` is a dotted location (`body.files`, `query.page_size`); several issues are
|
|
24
|
+
* joined by ` | ` and rendered as the FastAPI `detail` array by the codec.
|
|
25
|
+
*/
|
|
26
|
+
export function validation(context, loc, message) {
|
|
27
|
+
fail(context, "VALIDATION_ERROR", `loc=${loc}: ${clip(message, 200)}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const notFound = (context, message) => fail(context, "NOT_FOUND", message);
|
|
31
|
+
|
|
32
|
+
/** Fails when the decoder attached a request problem; the prefix chooses the code. */
|
|
33
|
+
export function requestProblem(input, context) {
|
|
34
|
+
const problem = input.__request_error;
|
|
35
|
+
if (typeof problem !== "string" || problem.length === 0) return;
|
|
36
|
+
if (problem.startsWith("400:")) fail(context, "INVALID_REQUEST", problem.slice(4));
|
|
37
|
+
fail(context, "VALIDATION_ERROR", problem.startsWith("422:") ? problem.slice(4) : problem);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Collects `loc=…: message` issues and fails once with all of them (at most 20). */
|
|
41
|
+
export class Issues {
|
|
42
|
+
constructor() {
|
|
43
|
+
this.items = [];
|
|
44
|
+
}
|
|
45
|
+
add(loc, message) {
|
|
46
|
+
if (this.items.length < 20) this.items.push(`loc=${loc}: ${clip(message, 200)}`);
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
get empty() {
|
|
50
|
+
return this.items.length === 0;
|
|
51
|
+
}
|
|
52
|
+
raise(context) {
|
|
53
|
+
if (!this.empty) fail(context, "VALIDATION_ERROR", this.items.join(" | "));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const boundExceeded = (context, namespace, bound) =>
|
|
58
|
+
fail(context, "INTERNAL_ERROR", `state exceeds the supported bound of ${bound} ${namespace} rows`);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Real gzip stream with deflate *stored* blocks (RFC 1951 BTYPE=00, RFC 1952 header/trailer) for `orig_elements`.
|
|
2
|
+
// No compression happens, but every gzip/zlib reader decodes it. Pure JS, no Node built-ins.
|
|
3
|
+
import { base64Encode, utf8Bytes } from "./util.mjs";
|
|
4
|
+
|
|
5
|
+
const CRC_TABLE = new Int32Array(256);
|
|
6
|
+
for (let n = 0; n < 256; n += 1) {
|
|
7
|
+
let c = n;
|
|
8
|
+
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
9
|
+
CRC_TABLE[n] = c;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function crc32(bytes) {
|
|
13
|
+
let crc = -1;
|
|
14
|
+
for (let i = 0; i < bytes.length; i += 1) crc = CRC_TABLE[(crc ^ bytes[i]) & 255] ^ (crc >>> 8);
|
|
15
|
+
return (crc ^ -1) >>> 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** gzip bytes (stored blocks of at most 65,535 bytes) for a byte array; mtime is fixed to 0 (deterministic). */
|
|
19
|
+
export function gzipStored(bytes) {
|
|
20
|
+
const out = [0x1f, 0x8b, 0x08, 0x00, 0, 0, 0, 0, 0x00, 0x03];
|
|
21
|
+
const total = bytes.length;
|
|
22
|
+
if (total === 0) out.push(0x01, 0x00, 0x00, 0xff, 0xff);
|
|
23
|
+
for (let offset = 0; offset < total; offset += 65535) {
|
|
24
|
+
const len = Math.min(65535, total - offset);
|
|
25
|
+
const final = offset + len >= total ? 1 : 0;
|
|
26
|
+
out.push(final, len & 255, (len >> 8) & 255, ~len & 255, (~len >> 8) & 255);
|
|
27
|
+
for (let i = 0; i < len; i += 1) out.push(bytes[offset + i]);
|
|
28
|
+
}
|
|
29
|
+
const crc = crc32(bytes);
|
|
30
|
+
out.push(crc & 255, (crc >>> 8) & 255, (crc >>> 16) & 255, (crc >>> 24) & 255);
|
|
31
|
+
out.push(total & 255, (total >>> 8) & 255, (total >>> 16) & 255, (total >>> 24) & 255);
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** base64(gzip(JSON.stringify(value))) — the encoding the Unstructured library uses for `orig_elements`. */
|
|
36
|
+
export function gzipBase64Json(value) {
|
|
37
|
+
return base64Encode(gzipStored(utf8Bytes(JSON.stringify(value))));
|
|
38
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Workspace resolution. An API key belongs to one workspace; the actor attribute `unstructuredWorkspaceId` names it.
|
|
2
|
+
// A fresh `firedrill tool add` actor has no attributes and falls back to the seeded default workspace.
|
|
3
|
+
import { fail } from "./errors.mjs";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_WORKSPACE = "ws_northgate";
|
|
6
|
+
|
|
7
|
+
/** Returns the workspace row id or fails UNAUTHORIZED ("API key is invalid"). */
|
|
8
|
+
export function workspaceOf(context) {
|
|
9
|
+
const claimed = context.actor.attributes.unstructuredWorkspaceId;
|
|
10
|
+
if (claimed === undefined || claimed === null) {
|
|
11
|
+
const fallback = context.state.get("workspaces", DEFAULT_WORKSPACE);
|
|
12
|
+
if (fallback === null) fail(context, "UNAUTHORIZED", "API key is invalid");
|
|
13
|
+
return DEFAULT_WORKSPACE;
|
|
14
|
+
}
|
|
15
|
+
if (typeof claimed !== "string" || claimed.length === 0 || claimed.length > 512 || context.state.get("workspaces", claimed) === null) {
|
|
16
|
+
fail(context, "UNAUTHORIZED", "API key is invalid");
|
|
17
|
+
}
|
|
18
|
+
return claimed;
|
|
19
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Ids and counters: UUID v4 strings drawn from the seeded random source, `seq` from meta/counters.
|
|
2
|
+
|
|
3
|
+
const HEX = "0123456789abcdef";
|
|
4
|
+
|
|
5
|
+
/** Deterministic (per seed) UUID v4 from context.random. */
|
|
6
|
+
export function uuid(context) {
|
|
7
|
+
let out = "";
|
|
8
|
+
for (let i = 0; i < 32; i += 1) {
|
|
9
|
+
if (i === 8 || i === 12 || i === 16 || i === 20) out += "-";
|
|
10
|
+
if (i === 12) out += "4";
|
|
11
|
+
else if (i === 16) out += HEX[8 + context.random.nextInteger(0, 4)];
|
|
12
|
+
else out += HEX[context.random.nextInteger(0, 16)];
|
|
13
|
+
}
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function nextSeq(context) {
|
|
18
|
+
const row = context.state.get("meta", "counters");
|
|
19
|
+
const current = row !== null && Number.isInteger(row.next_seq) ? row.next_seq : 1000;
|
|
20
|
+
context.state.put("meta", "counters", { next_seq: current + 1 });
|
|
21
|
+
return current;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEFAULT_LIMITS = { connectors: 2000, workflows: 5000, jobs: 5000, files_per_source: 200, source_file_bytes: 262144, response_bytes: 900 * 1024 };
|
|
25
|
+
|
|
26
|
+
export function limits(context) {
|
|
27
|
+
const row = context.state.get("meta", "limits");
|
|
28
|
+
const out = { ...DEFAULT_LIMITS };
|
|
29
|
+
if (row !== null) for (const key of Object.keys(DEFAULT_LIMITS)) if (Number.isInteger(row[key]) && row[key] >= 0) out[key] = row[key];
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
34
|
+
|
|
35
|
+
/** A caller-supplied row id is usable when it is a non-empty string short enough for the state store. */
|
|
36
|
+
export const usableId = (value) => typeof value === "string" && value.length > 0 && value.length <= 512;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Job status derived from virtual time: SCHEDULED for 5 s, IN_PROGRESS for 10 s per file plus 1 s per 4 KB, then
|
|
2
|
+
// finished. Nothing time-dependent is stored on the job row, so the same world read at the same virtual time
|
|
3
|
+
// always answers the same way.
|
|
4
|
+
import { isoDuration, isoFromUs, usFromIso } from "./util.mjs";
|
|
5
|
+
|
|
6
|
+
const SCHEDULE_US = 5000000;
|
|
7
|
+
const PER_FILE_US = 10000000;
|
|
8
|
+
const PER_4KB_US = 1000000;
|
|
9
|
+
|
|
10
|
+
export function jobTiming(job) {
|
|
11
|
+
const createdUs = usFromIso(job.created_at) ?? 0;
|
|
12
|
+
const startUs = createdUs + SCHEDULE_US;
|
|
13
|
+
let bytes = 0;
|
|
14
|
+
for (const file of job.files) bytes += Number.isInteger(file.size_bytes) ? file.size_bytes : 0;
|
|
15
|
+
const finishUs = startUs + job.files.length * PER_FILE_US + Math.floor(bytes / 4096) * PER_4KB_US;
|
|
16
|
+
return { createdUs, startUs, finishUs };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** "STOPPED" | "SCHEDULED" | "IN_PROGRESS" | "FINISHED" */
|
|
20
|
+
export function jobPhase(job, nowUs) {
|
|
21
|
+
const { startUs, finishUs } = jobTiming(job);
|
|
22
|
+
if (typeof job.cancelled_at === "string") {
|
|
23
|
+
const cancelledUs = usFromIso(job.cancelled_at) ?? 0;
|
|
24
|
+
if (cancelledUs < finishUs) return "STOPPED";
|
|
25
|
+
}
|
|
26
|
+
if (nowUs < startUs) return "SCHEDULED";
|
|
27
|
+
if (nowUs < finishUs) return "IN_PROGRESS";
|
|
28
|
+
return "FINISHED";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const isRunning = (phase) => phase === "SCHEDULED" || phase === "IN_PROGRESS";
|
|
32
|
+
|
|
33
|
+
/** { status, processing_status, runtime, reason, phase } as the Workflow Endpoint reports them. */
|
|
34
|
+
export function jobStatus(job, nowUs) {
|
|
35
|
+
const phase = jobPhase(job, nowUs);
|
|
36
|
+
if (phase === "STOPPED") return { phase, status: "STOPPED", processing_status: "STOPPED", runtime: null, reason: "Cancelled by user" };
|
|
37
|
+
if (phase === "SCHEDULED") return { phase, status: "SCHEDULED", processing_status: "SCHEDULED", runtime: null, reason: null };
|
|
38
|
+
if (phase === "IN_PROGRESS") return { phase, status: "IN_PROGRESS", processing_status: "IN_PROGRESS", runtime: null, reason: null };
|
|
39
|
+
const { startUs, finishUs } = jobTiming(job);
|
|
40
|
+
const runtime = isoDuration((finishUs - startUs) / 1000000);
|
|
41
|
+
const failed = job.files.filter((file) => file.error !== null).length;
|
|
42
|
+
if (job.files.length === 0) return { phase, status: "FAILED", processing_status: "FAILED", runtime, reason: "Source connector holds no files" };
|
|
43
|
+
if (failed === job.files.length) return { phase, status: "FAILED", processing_status: "FAILED", runtime, reason: "All files failed" };
|
|
44
|
+
if (failed > 0) return { phase, status: "COMPLETED", processing_status: "COMPLETED_WITH_ERRORS", runtime, reason: null };
|
|
45
|
+
return { phase, status: "COMPLETED", processing_status: "SUCCESS", runtime, reason: null };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The node whose output the destination receives: the last node of the snapshot. */
|
|
49
|
+
export const outputNodeOf = (job) => (job.nodes.length > 0 ? job.nodes[job.nodes.length - 1] : null);
|
|
50
|
+
|
|
51
|
+
/** `JobInformation` view of a job row at virtual `nowUs`. */
|
|
52
|
+
export function jobView(job, nowUs) {
|
|
53
|
+
const derived = jobStatus(job, nowUs);
|
|
54
|
+
const node = outputNodeOf(job);
|
|
55
|
+
const outputs = [];
|
|
56
|
+
if (derived.phase === "FINISHED" && node !== null) {
|
|
57
|
+
for (const file of job.files) if (file.error === null) outputs.push({ node_id: node.id, file_id: file.file_id, node_type: node.type, node_subtype: node.subtype });
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
id: job.id,
|
|
61
|
+
workflow_id: job.workflow_id,
|
|
62
|
+
workflow_name: job.workflow_name,
|
|
63
|
+
status: derived.status,
|
|
64
|
+
stop_requested: job.stop_requested,
|
|
65
|
+
created_at: job.created_at,
|
|
66
|
+
runtime: derived.runtime,
|
|
67
|
+
input_file_ids: job.files.map((file) => file.file_id),
|
|
68
|
+
output_node_files: outputs,
|
|
69
|
+
job_type: job.job_type,
|
|
70
|
+
created_by_id: job.created_by,
|
|
71
|
+
reason: derived.reason,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `node_stats` for `GET /jobs/{id}/details`. */
|
|
76
|
+
export function nodeStats(job, phase) {
|
|
77
|
+
const total = job.files.length;
|
|
78
|
+
const failed = job.files.filter((file) => file.error !== null).length;
|
|
79
|
+
return job.nodes.map((node) => {
|
|
80
|
+
const stat = { node_name: node.name, node_type: node.type, node_subtype: node.subtype, ready: 0, in_progress: 0, success: 0, failure: 0 };
|
|
81
|
+
if (phase === "SCHEDULED") stat.ready = total;
|
|
82
|
+
else if (phase === "IN_PROGRESS") stat.in_progress = total;
|
|
83
|
+
else if (phase === "STOPPED") stat.ready = total;
|
|
84
|
+
else {
|
|
85
|
+
stat.success = total - failed;
|
|
86
|
+
stat.failure = failed;
|
|
87
|
+
}
|
|
88
|
+
return stat;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const nowIso = (context) => isoFromUs(context.clock.nowUs());
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Linear multipart/form-data parser over the UTF-8 text body the framework hands to `text` routes.
|
|
2
|
+
// Returns { parts: [{ name, filename, contentType, transferEncoding, lastModified, body }] } or { error }.
|
|
3
|
+
//
|
|
4
|
+
// Cost model: the body is walked forward once. Delimiters are found with one `indexOf` cursor that only moves forward
|
|
5
|
+
// (a candidate `--boundary` counts only at the start of a line and only when the line ends right after it or it is the
|
|
6
|
+
// close delimiter); each part's header block is searched only inside a window of MAX_HEADER_BLOCK characters. No search
|
|
7
|
+
// restarts from an earlier position, so the work is O(body length + parts x MAX_HEADER_BLOCK), whatever the part count.
|
|
8
|
+
|
|
9
|
+
const MAX_PARTS = 64;
|
|
10
|
+
const MAX_HEADER_BLOCK = 16384;
|
|
11
|
+
const MAX_NAME = 200;
|
|
12
|
+
// RFC 2046 bchars, plus ';' (only reachable inside a quoted boundary parameter).
|
|
13
|
+
const BOUNDARY_RE = /^[0-9A-Za-z'()+_,\-./:=?; ]{1,70}$/;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Splits a header value `type; key=value; key="quoted \"value\"; with ;"` in one scan.
|
|
17
|
+
* Returns { type, params: Map(lower-case key -> first value) }. Quoted values are unescaped (`\x` -> `x`).
|
|
18
|
+
*/
|
|
19
|
+
export function headerParams(value) {
|
|
20
|
+
const params = new Map();
|
|
21
|
+
const n = value.length;
|
|
22
|
+
let i = value.indexOf(";");
|
|
23
|
+
if (i < 0) i = n;
|
|
24
|
+
const type = value.slice(0, i).trim().toLowerCase();
|
|
25
|
+
while (i < n) {
|
|
26
|
+
i += 1; // past ';'
|
|
27
|
+
let keyEnd = i;
|
|
28
|
+
while (keyEnd < n && value[keyEnd] !== "=" && value[keyEnd] !== ";") keyEnd += 1;
|
|
29
|
+
const key = value.slice(i, keyEnd).trim().toLowerCase();
|
|
30
|
+
if (keyEnd >= n || value[keyEnd] === ";") {
|
|
31
|
+
i = keyEnd;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
let j = keyEnd + 1;
|
|
35
|
+
while (j < n && (value[j] === " " || value[j] === "\t")) j += 1;
|
|
36
|
+
let parsed;
|
|
37
|
+
if (value[j] === '"') {
|
|
38
|
+
let out = "";
|
|
39
|
+
j += 1;
|
|
40
|
+
while (j < n && value[j] !== '"') {
|
|
41
|
+
if (value[j] === "\\" && j + 1 < n) j += 1;
|
|
42
|
+
out += value[j];
|
|
43
|
+
j += 1;
|
|
44
|
+
}
|
|
45
|
+
parsed = out;
|
|
46
|
+
j += 1; // past closing quote (or end)
|
|
47
|
+
while (j < n && value[j] !== ";") j += 1;
|
|
48
|
+
} else {
|
|
49
|
+
const start = j;
|
|
50
|
+
while (j < n && value[j] !== ";") j += 1;
|
|
51
|
+
parsed = value.slice(start, j).trim();
|
|
52
|
+
}
|
|
53
|
+
if (key.length > 0 && !params.has(key)) params.set(key, parsed);
|
|
54
|
+
i = j;
|
|
55
|
+
}
|
|
56
|
+
return { type, params };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** RFC 5987 `charset'lang'pct-encoded` (UTF-8 or US-ASCII); null when it does not decode. */
|
|
60
|
+
function extValue(value) {
|
|
61
|
+
const first = value.indexOf("'");
|
|
62
|
+
const second = first < 0 ? -1 : value.indexOf("'", first + 1);
|
|
63
|
+
if (second < 0) return null;
|
|
64
|
+
const charset = value.slice(0, first).toLowerCase();
|
|
65
|
+
if (charset !== "utf-8" && charset !== "us-ascii") return null;
|
|
66
|
+
try {
|
|
67
|
+
return decodeURIComponent(value.slice(second + 1));
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The boundary parameter of a multipart/form-data content type, or null. */
|
|
74
|
+
export function boundaryOf(contentType) {
|
|
75
|
+
if (typeof contentType !== "string" || contentType.length > MAX_HEADER_BLOCK) return null;
|
|
76
|
+
const { type, params } = headerParams(contentType);
|
|
77
|
+
if (type !== "multipart/form-data" || !params.has("boundary")) return null;
|
|
78
|
+
const value = params.get("boundary");
|
|
79
|
+
return BOUNDARY_RE.test(value) && !value.endsWith(" ") ? value : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const isMultipart = (contentType) => typeof contentType === "string" && contentType.split(";")[0].trim().toLowerCase() === "multipart/form-data";
|
|
83
|
+
|
|
84
|
+
function dispositionParams(value) {
|
|
85
|
+
const { type, params } = headerParams(value);
|
|
86
|
+
if (type !== "form-data") return null;
|
|
87
|
+
if (params.has("filename*")) {
|
|
88
|
+
// RFC 6266: the extended value wins when it decodes.
|
|
89
|
+
const decoded = extValue(params.get("filename*"));
|
|
90
|
+
if (decoded !== null) params.set("filename", decoded);
|
|
91
|
+
}
|
|
92
|
+
return params;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function parseMultipart(contentType, body) {
|
|
96
|
+
const boundary = boundaryOf(contentType);
|
|
97
|
+
if (boundary === null) return { error: "Content-Type must be multipart/form-data with a boundary" };
|
|
98
|
+
if (typeof body !== "string") return { error: "Request body is empty" };
|
|
99
|
+
const delimiter = `--${boundary}`;
|
|
100
|
+
let searchFrom = 0;
|
|
101
|
+
// Next delimiter at or after `from` whose line break before it starts at or after `floor`. Forward-only.
|
|
102
|
+
const nextDelimiter = (floor) => {
|
|
103
|
+
for (;;) {
|
|
104
|
+
const d = body.indexOf(delimiter, searchFrom);
|
|
105
|
+
if (d < 0) return -1;
|
|
106
|
+
searchFrom = d + 1;
|
|
107
|
+
if (d - 1 < floor || body[d - 1] !== "\n") continue;
|
|
108
|
+
const after = d + delimiter.length;
|
|
109
|
+
if (body.startsWith("--", after) || body.startsWith("\r\n", after) || body.startsWith("\n", after)) return d;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
let cursor = body.indexOf(delimiter);
|
|
113
|
+
if (cursor < 0) return { error: "Multipart body has no boundary delimiter" };
|
|
114
|
+
searchFrom = cursor + 1;
|
|
115
|
+
const parts = [];
|
|
116
|
+
for (;;) {
|
|
117
|
+
cursor += delimiter.length;
|
|
118
|
+
if (body.startsWith("--", cursor)) break;
|
|
119
|
+
if (body.startsWith("\r\n", cursor)) cursor += 2;
|
|
120
|
+
else if (body.startsWith("\n", cursor)) cursor += 1;
|
|
121
|
+
else return { error: "Malformed multipart delimiter line" };
|
|
122
|
+
if (parts.length >= MAX_PARTS) return { error: `Multipart body has more than ${MAX_PARTS} parts` };
|
|
123
|
+
const windowEnd = Math.min(body.length, cursor + MAX_HEADER_BLOCK + 4);
|
|
124
|
+
const window = body.slice(cursor, windowEnd);
|
|
125
|
+
let headerEnd = window.indexOf("\r\n\r\n");
|
|
126
|
+
let sepLength = 4;
|
|
127
|
+
const lfEnd = window.indexOf("\n\n");
|
|
128
|
+
if (headerEnd < 0 || (lfEnd >= 0 && lfEnd < headerEnd)) {
|
|
129
|
+
headerEnd = lfEnd;
|
|
130
|
+
sepLength = 2;
|
|
131
|
+
}
|
|
132
|
+
if (headerEnd < 0) return { error: windowEnd === body.length ? "Multipart part headers are not terminated" : "Multipart part headers are too long" };
|
|
133
|
+
if (headerEnd > MAX_HEADER_BLOCK) return { error: "Multipart part headers are too long" };
|
|
134
|
+
const headerText = window.slice(0, headerEnd);
|
|
135
|
+
headerEnd += cursor;
|
|
136
|
+
let disposition = null;
|
|
137
|
+
let partType = null;
|
|
138
|
+
let transfer = null;
|
|
139
|
+
let lastModified = null;
|
|
140
|
+
for (const rawLine of headerText.split("\n")) {
|
|
141
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
142
|
+
const colon = line.indexOf(":");
|
|
143
|
+
if (colon <= 0) continue;
|
|
144
|
+
const name = line.slice(0, colon).trim().toLowerCase();
|
|
145
|
+
const value = line.slice(colon + 1).trim();
|
|
146
|
+
if (name === "content-disposition") disposition = dispositionParams(value);
|
|
147
|
+
else if (name === "content-type") partType = value.split(";")[0].trim().toLowerCase();
|
|
148
|
+
else if (name === "content-transfer-encoding") transfer = value.toLowerCase();
|
|
149
|
+
else if (name === "last-modified") lastModified = value;
|
|
150
|
+
}
|
|
151
|
+
if (disposition === null || !disposition.has("name")) return { error: "Multipart part is missing a Content-Disposition form-data name" };
|
|
152
|
+
const name = disposition.get("name");
|
|
153
|
+
if (name.length === 0 || name.length > MAX_NAME) return { error: "Multipart part name is empty or too long" };
|
|
154
|
+
const contentStart = headerEnd + sepLength;
|
|
155
|
+
if (searchFrom < contentStart) searchFrom = contentStart;
|
|
156
|
+
// The floor admits an empty part whose close delimiter shares the blank line ending its headers.
|
|
157
|
+
const d = nextDelimiter(contentStart - 1);
|
|
158
|
+
if (d < 0) return { error: "Multipart body is not terminated by the closing boundary" };
|
|
159
|
+
const contentEnd = d - 2 >= contentStart && body[d - 2] === "\r" ? d - 2 : Math.max(contentStart, d - 1);
|
|
160
|
+
parts.push({
|
|
161
|
+
name,
|
|
162
|
+
filename: disposition.has("filename") ? disposition.get("filename") : null,
|
|
163
|
+
contentType: partType,
|
|
164
|
+
transferEncoding: transfer,
|
|
165
|
+
lastModified,
|
|
166
|
+
body: body.slice(contentStart, contentEnd),
|
|
167
|
+
});
|
|
168
|
+
cursor = d;
|
|
169
|
+
}
|
|
170
|
+
return { parts };
|
|
171
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Response size budgeting: every route response must stay under the framework's 1 MiB cap, measured in UTF-8 bytes.
|
|
2
|
+
// The budget is `meta/limits.response_bytes` (default 900 KB). A response that would pass it fails RESPONSE_TOO_LARGE
|
|
3
|
+
// (413) — a page is never shortened silently.
|
|
4
|
+
import { fail } from "./errors.mjs";
|
|
5
|
+
import { limits } from "./ids.mjs";
|
|
6
|
+
import { utf8Length } from "./util.mjs";
|
|
7
|
+
|
|
8
|
+
/** UTF-8 size of the JSON encoding of a value. */
|
|
9
|
+
export const jsonBytes = (value) => utf8Length(JSON.stringify(value));
|
|
10
|
+
|
|
11
|
+
/** The response byte budget of this world (`meta/limits.response_bytes`). */
|
|
12
|
+
export const responseBudget = (context) => limits(context).response_bytes;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Fails RESPONSE_TOO_LARGE when `bytes` passes the budget. `what` names the payload ("Partition output"); `hint` tells the
|
|
16
|
+
* caller how to shrink it.
|
|
17
|
+
*/
|
|
18
|
+
export function assertWithinBudget(context, bytes, what, hint) {
|
|
19
|
+
const budget = responseBudget(context);
|
|
20
|
+
if (bytes > budget) fail(context, "RESPONSE_TOO_LARGE", `${what} of ${bytes} bytes exceeds the ${budget} byte response limit${hint ? `; ${hint}` : ""}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Fails RESPONSE_TOO_LARGE when the JSON encoding of `value` passes the budget; otherwise returns `value`. */
|
|
24
|
+
export function withinBudget(context, value, what, hint) {
|
|
25
|
+
assertWithinBudget(context, jsonBytes(value), what, hint);
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The page-numbered slice of `rows` (already sorted): rows [start, start + count). Every requested row is returned or
|
|
31
|
+
* the call fails RESPONSE_TOO_LARGE telling the caller to lower `page_size` — a page never stops early at the budget,
|
|
32
|
+
* because the next page would start at (page - 1) * page_size and the rows in between would be unreachable.
|
|
33
|
+
*/
|
|
34
|
+
export function pageOf(context, rows, start, count) {
|
|
35
|
+
const items = rows.slice(start, start + count);
|
|
36
|
+
let bytes = 2;
|
|
37
|
+
for (const item of items) bytes += jsonBytes(item) + 1;
|
|
38
|
+
assertWithinBudget(context, bytes, `A page of ${items.length} rows`, items.length > 1 ? "lower page_size" : "the row itself exceeds the budget");
|
|
39
|
+
return items;
|
|
40
|
+
}
|