@firedrill-tools/unified 0.1.1
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 +242 -0
- package/firedrill/agent.target.json +17 -0
- package/firedrill/baseline.scenario.json +5 -0
- package/firedrill/bounds.scenario.json +23 -0
- package/firedrill/conformance.suite.json +23 -0
- package/firedrill/large-channels.scenario.json +4251 -0
- package/firedrill/many-workspaces.scenario.json +1623 -0
- package/firedrill/rate-limited.scenario.json +11 -0
- package/firedrill/tools/unified/behavior.mjs +69 -0
- package/firedrill/tools/unified/lib/assoc.mjs +61 -0
- package/firedrill/tools/unified/lib/enums.mjs +14 -0
- package/firedrill/tools/unified/lib/errors.mjs +50 -0
- package/firedrill/tools/unified/lib/events.mjs +27 -0
- package/firedrill/tools/unified/lib/identity.mjs +59 -0
- package/firedrill/tools/unified/lib/query.mjs +154 -0
- package/firedrill/tools/unified/lib/shapes.mjs +176 -0
- package/firedrill/tools/unified/lib/store.mjs +75 -0
- package/firedrill/tools/unified/lib/time.mjs +59 -0
- package/firedrill/tools/unified/lib/util.mjs +92 -0
- package/firedrill/tools/unified/lib/validate.mjs +114 -0
- package/firedrill/tools/unified/lib/wire.mjs +120 -0
- package/firedrill/tools/unified/ops/channels.mjs +45 -0
- package/firedrill/tools/unified/ops/connections.mjs +151 -0
- package/firedrill/tools/unified/ops/crm.mjs +126 -0
- package/firedrill/tools/unified/ops/deals.mjs +123 -0
- package/firedrill/tools/unified/ops/messages.mjs +133 -0
- package/firedrill/tools/unified/ops/pipelines.mjs +20 -0
- package/firedrill/tools/unified/ops/resource.mjs +114 -0
- package/firedrill/tools/unified/unified.tool.json +14068 -0
- package/firedrill/unified-bounds.drill.json +148 -0
- package/firedrill/unified-connections-flow.drill.json +226 -0
- package/firedrill/unified-crm-flow.drill.json +576 -0
- package/firedrill/unified-denied.drill.json +83 -0
- package/firedrill/unified-error-coverage.drill.json +528 -0
- package/firedrill/unified-fresh-actor.drill.json +83 -0
- package/firedrill/unified-invalid-workspace.drill.json +933 -0
- package/firedrill/unified-large-channels.drill.json +68 -0
- package/firedrill/unified-many-workspaces.drill.json +943 -0
- package/firedrill/unified-mcp-core-aliases.drill.json +113 -0
- package/firedrill/unified-messaging-flow.drill.json +326 -0
- package/firedrill/unified-rate-limited.drill.json +933 -0
- package/firedrill/unified-scoped-token.drill.json +113 -0
- package/firedrill/unified-size-bounds.drill.json +263 -0
- package/firedrill/unified-write-committed-lost.drill.json +107 -0
- package/firedrill/unified-write-unavailable.drill.json +528 -0
- package/firedrill/world.json +2958 -0
- package/firedrill/write-committed-lost.scenario.json +11 -0
- package/firedrill/write-unavailable.scenario.json +11 -0
- package/firedrill.json +5 -0
- package/package.json +52 -0
- package/starter.json +2446 -0
- package/test/conformance.mjs +39 -0
- package/test/flows/connections.mjs +87 -0
- package/test/flows/coverage.mjs +64 -0
- package/test/flows/crm.mjs +124 -0
- package/test/flows/faults.mjs +82 -0
- package/test/flows/identity.mjs +84 -0
- package/test/flows/messaging.mjs +82 -0
- package/test/flows/size.mjs +71 -0
- package/test/lib.mjs +133 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// State access: row ids, the shared id counter, bounded prefix scans and per-request indexes.
|
|
2
|
+
import { MAX_SCAN_BOUND, fail, scanBoundExceeded } from "./errors.mjs";
|
|
3
|
+
import { pad } from "./util.mjs";
|
|
4
|
+
|
|
5
|
+
const CHUNK = 512;
|
|
6
|
+
|
|
7
|
+
/** Data rows are keyed `{connection_id}/{id}` so one connection's rows form one contiguous row-id range. */
|
|
8
|
+
export const rowId = (connectionId, id) => `${connectionId}/${id}`;
|
|
9
|
+
|
|
10
|
+
/** The scan bound for the caller's workspace: `limits.max_rows_per_namespace`, never above the hard ceiling. */
|
|
11
|
+
export function scanBound(workspace) {
|
|
12
|
+
const configured = workspace?.limits?.max_rows_per_namespace;
|
|
13
|
+
return Number.isInteger(configured) && configured >= 1 ? Math.min(configured, MAX_SCAN_BOUND) : MAX_SCAN_BOUND;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Reads every row of `namespace` under one connection, in row-id (creation) order. A connection holding more than
|
|
18
|
+
* `bound` rows fails FAILED_PRECONDITION instead of answering from an incomplete scan.
|
|
19
|
+
*/
|
|
20
|
+
export function scanConnection(context, namespace, connectionId, bound) {
|
|
21
|
+
const prefix = `${connectionId}/`;
|
|
22
|
+
const out = [];
|
|
23
|
+
let after = prefix;
|
|
24
|
+
for (;;) {
|
|
25
|
+
const rows = context.state.scan(namespace, { afterRowId: after, limit: CHUNK });
|
|
26
|
+
for (const row of rows) {
|
|
27
|
+
if (!row.rowId.startsWith(prefix)) return out;
|
|
28
|
+
out.push(row.value);
|
|
29
|
+
if (out.length > bound) scanBoundExceeded(context, namespace, bound);
|
|
30
|
+
}
|
|
31
|
+
if (rows.length < CHUNK) return out;
|
|
32
|
+
after = rows[rows.length - 1].rowId;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Reads a whole namespace (used for the small `workspaces` and `connections` namespaces). */
|
|
37
|
+
export function scanAll(context, namespace, bound = MAX_SCAN_BOUND) {
|
|
38
|
+
const out = [];
|
|
39
|
+
let after;
|
|
40
|
+
for (;;) {
|
|
41
|
+
const rows = context.state.scan(namespace, after === undefined ? { limit: CHUNK } : { afterRowId: after, limit: CHUNK });
|
|
42
|
+
for (const row of rows) {
|
|
43
|
+
out.push(row.value);
|
|
44
|
+
if (out.length > bound) scanBoundExceeded(context, namespace, bound);
|
|
45
|
+
}
|
|
46
|
+
if (rows.length < CHUNK) return out;
|
|
47
|
+
after = rows[rows.length - 1].rowId;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Indexes rows by id for the duration of one request (no per-row rescans). */
|
|
52
|
+
export function indexById(rows) {
|
|
53
|
+
const map = new Map();
|
|
54
|
+
for (const row of rows) map.set(row.id, row);
|
|
55
|
+
return map;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const getRow = (context, namespace, connectionId, id) => context.state.get(namespace, rowId(connectionId, id));
|
|
59
|
+
|
|
60
|
+
export const putRow = (context, namespace, row) => context.state.put(namespace, rowId(row.connection_id, row.id), row);
|
|
61
|
+
|
|
62
|
+
export const deleteRow = (context, namespace, connectionId, id) => context.state.delete(namespace, rowId(connectionId, id));
|
|
63
|
+
|
|
64
|
+
/** Ids issued at runtime: `68c8` + 14 zeros + a 6-hex-digit counter, so they sort in creation order and never collide. */
|
|
65
|
+
export const idFor = (n) => `68c8${"0".repeat(14)}${pad(n.toString(16), 6)}`;
|
|
66
|
+
|
|
67
|
+
/** Draws the next object id from the counter row (created lazily), writing the counter back. */
|
|
68
|
+
export function nextId(context) {
|
|
69
|
+
const row = context.state.get("meta", "counters");
|
|
70
|
+
const next = Number.isInteger(row?.next_id) && row.next_id >= 1 ? row.next_id : 1;
|
|
71
|
+
// Six hex digits hold 16,777,215 ids; past that the id would no longer be 24 characters.
|
|
72
|
+
if (next > 0xffffff) fail(context, "FAILED_PRECONDITION", "The simulated workspace has exhausted its id space");
|
|
73
|
+
context.state.put("meta", "counters", { next_id: next + 1 });
|
|
74
|
+
return idFor(next);
|
|
75
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Host-independent time handling: ISO-8601 UTC strings from virtual microseconds and strict parsing of caller dates.
|
|
2
|
+
// Zone-less input is UTC (the provider's documented default); nothing here consults the host clock or time zone.
|
|
3
|
+
import { pad } from "./util.mjs";
|
|
4
|
+
|
|
5
|
+
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
6
|
+
|
|
7
|
+
const isLeap = (year) => (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
8
|
+
|
|
9
|
+
/** Formats virtual microseconds as `YYYY-MM-DDTHH:MM:SS.sssZ`. */
|
|
10
|
+
export function isoFromUs(us) {
|
|
11
|
+
const ms = Math.floor(Number(us) / 1000);
|
|
12
|
+
const date = new Date(ms);
|
|
13
|
+
return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1, 2)}-${pad(date.getUTCDate(), 2)}T${pad(
|
|
14
|
+
date.getUTCHours(),
|
|
15
|
+
2,
|
|
16
|
+
)}:${pad(date.getUTCMinutes(), 2)}:${pad(date.getUTCSeconds(), 2)}.${pad(date.getUTCMilliseconds(), 3)}Z`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const nowIso = (context) => isoFromUs(context.clock.nowUs());
|
|
20
|
+
|
|
21
|
+
const ISO =
|
|
22
|
+
/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,6}))?)?)?(Z|[+-]\d{2}:?\d{2})?$/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parses an ISO-8601 date or date-time into microseconds since the epoch, or returns null when the text is not a
|
|
26
|
+
* real instant. Time and zone are optional; a missing zone means UTC. A U+FFFD (mangled percent-encoding) is invalid.
|
|
27
|
+
*/
|
|
28
|
+
export function parseIsoUs(text) {
|
|
29
|
+
if (typeof text !== "string" || text.length > 40 || text.includes("�")) return null;
|
|
30
|
+
const match = ISO.exec(text.trim());
|
|
31
|
+
if (match === null) return null;
|
|
32
|
+
const year = Number(match[1]);
|
|
33
|
+
const month = Number(match[2]);
|
|
34
|
+
const day = Number(match[3]);
|
|
35
|
+
const hour = match[4] === undefined ? 0 : Number(match[4]);
|
|
36
|
+
const minute = match[5] === undefined ? 0 : Number(match[5]);
|
|
37
|
+
const second = match[6] === undefined ? 0 : Number(match[6]);
|
|
38
|
+
const fraction = match[7] === undefined ? 0 : Number(match[7].padEnd(6, "0"));
|
|
39
|
+
if (year < 1970 || year > 9999 || month < 1 || month > 12 || day < 1) return null;
|
|
40
|
+
const maxDay = DAYS_IN_MONTH[month - 1] + (month === 2 && isLeap(year) ? 1 : 0);
|
|
41
|
+
if (day > maxDay || hour > 23 || minute > 59 || second > 60) return null;
|
|
42
|
+
let offsetMinutes = 0;
|
|
43
|
+
if (match[8] !== undefined && match[8] !== "Z") {
|
|
44
|
+
const sign = match[8][0] === "-" ? -1 : 1;
|
|
45
|
+
const digits = match[8].slice(1).replace(":", "");
|
|
46
|
+
const offsetHours = Number(digits.slice(0, 2));
|
|
47
|
+
const offsetMins = Number(digits.slice(2, 4));
|
|
48
|
+
if (offsetHours > 14 || offsetMins > 59) return null;
|
|
49
|
+
offsetMinutes = sign * (offsetHours * 60 + offsetMins);
|
|
50
|
+
}
|
|
51
|
+
const ms = Date.UTC(year, month - 1, day, hour, minute, Math.min(second, 59)) - offsetMinutes * 60000;
|
|
52
|
+
return ms * 1000 + fraction;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Normalises caller date text to the canonical ISO string, or null when invalid. */
|
|
56
|
+
export function normaliseIso(text) {
|
|
57
|
+
const us = parseIsoUs(text);
|
|
58
|
+
return us === null ? null : isoFromUs(us);
|
|
59
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Small deterministic helpers shared by every module. No clock, no randomness, no Node built-ins.
|
|
2
|
+
|
|
3
|
+
export const HEX24 = /^[0-9a-f]{24}$/;
|
|
4
|
+
|
|
5
|
+
export const isHex24 = (value) => typeof value === "string" && HEX24.test(value);
|
|
6
|
+
|
|
7
|
+
export const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
8
|
+
|
|
9
|
+
/** Clips caller text quoted into messages so no error message ever approaches the framework's 4,000-character cap. */
|
|
10
|
+
export function clip(value, max = 200) {
|
|
11
|
+
const text = String(value);
|
|
12
|
+
return text.length <= max ? text : `${text.slice(0, max)}…`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const pad = (value, width, fill = "0") => String(value).padStart(width, fill);
|
|
16
|
+
|
|
17
|
+
/** UTF-8 byte length of a string, computed from code points (a behavior module has no Buffer). */
|
|
18
|
+
export function utf8Bytes(text) {
|
|
19
|
+
let bytes = 0;
|
|
20
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
21
|
+
const code = text.charCodeAt(i);
|
|
22
|
+
if (code < 0x80) bytes += 1;
|
|
23
|
+
else if (code < 0x800) bytes += 2;
|
|
24
|
+
else if (code >= 0xd800 && code <= 0xdbff) {
|
|
25
|
+
// High surrogate: a well-formed pair is one 4-byte code point; a lone surrogate serialises as 3 bytes.
|
|
26
|
+
const next = i + 1 < text.length ? text.charCodeAt(i + 1) : 0;
|
|
27
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
28
|
+
bytes += 4;
|
|
29
|
+
i += 1;
|
|
30
|
+
} else bytes += 3;
|
|
31
|
+
} else bytes += 3;
|
|
32
|
+
}
|
|
33
|
+
return bytes;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** UTF-8 byte size of the JSON encoding of a value. */
|
|
37
|
+
export const jsonBytes = (value) => utf8Bytes(JSON.stringify(value));
|
|
38
|
+
|
|
39
|
+
/** Case fold for search: lower-case plus a full-width/upper-case normalisation that never depends on the host locale. */
|
|
40
|
+
export const fold = (text) => text.toLowerCase();
|
|
41
|
+
|
|
42
|
+
/** Linear-time substring test on folded text (String.prototype.includes is linear in V8; the needle is literal). */
|
|
43
|
+
export const containsFolded = (haystack, needle) => typeof haystack === "string" && fold(haystack).includes(needle);
|
|
44
|
+
|
|
45
|
+
export function titleCase(slug) {
|
|
46
|
+
return slug
|
|
47
|
+
.split(/[-_]+/)
|
|
48
|
+
.filter((part) => part.length > 0)
|
|
49
|
+
.map((part) => part[0].toUpperCase() + part.slice(1))
|
|
50
|
+
.join(" ");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Deterministic string comparison by UTF-16 code units (no locale). */
|
|
54
|
+
export function compareText(a, b) {
|
|
55
|
+
if (a === b) return 0;
|
|
56
|
+
return a < b ? -1 : 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function hasOwn(object, key) {
|
|
60
|
+
return isPlainObject(object) && Object.hasOwn(object, key);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Returns a copy of `value` without the listed keys. */
|
|
64
|
+
export function omit(value, keys) {
|
|
65
|
+
const out = {};
|
|
66
|
+
for (const key of Object.keys(value)) {
|
|
67
|
+
if (!keys.includes(key)) out[key] = value[key];
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Structural equality for JSON values (used to compute changed fields on update). */
|
|
73
|
+
export function jsonEqual(a, b) {
|
|
74
|
+
if (a === b) return true;
|
|
75
|
+
if (typeof a !== typeof b || a === null || b === null) return false;
|
|
76
|
+
if (Array.isArray(a)) {
|
|
77
|
+
if (!Array.isArray(b) || a.length !== b.length) return false;
|
|
78
|
+
for (let i = 0; i < a.length; i += 1) if (!jsonEqual(a[i], b[i])) return false;
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
if (typeof a === "object") {
|
|
82
|
+
if (Array.isArray(b)) return false;
|
|
83
|
+
const keysA = Object.keys(a);
|
|
84
|
+
const keysB = Object.keys(b);
|
|
85
|
+
if (keysA.length !== keysB.length) return false;
|
|
86
|
+
for (const key of keysA) {
|
|
87
|
+
if (!Object.hasOwn(b, key) || !jsonEqual(a[key], b[key])) return false;
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Body validation against the state schema bounds. Every problem is BAD_REQUEST naming the field and the bound, with
|
|
2
|
+
// caller text clipped. Read-only fields are ignored (as the provider ignores them); unknown fields are refused.
|
|
3
|
+
import { badRequest, invalidField } from "./errors.mjs";
|
|
4
|
+
import { normaliseIso } from "./time.mjs";
|
|
5
|
+
import { clip, isHex24, isPlainObject } from "./util.mjs";
|
|
6
|
+
import { SHAPES } from "./shapes.mjs";
|
|
7
|
+
|
|
8
|
+
export const READ_ONLY = new Set(["id", "created_at", "updated_at", "connection_id", "workspace_id"]);
|
|
9
|
+
|
|
10
|
+
// Field spec constructors -----------------------------------------------------------------------
|
|
11
|
+
export const str = (max, opts = {}) => ({ kind: "str", max, min: opts.min ?? 0, nullable: opts.nullable !== false, pattern: opts.pattern ?? null });
|
|
12
|
+
export const bool = () => ({ kind: "bool" });
|
|
13
|
+
export const num = (opts = {}) => ({ kind: "num", min: opts.min ?? null, max: opts.max ?? null, integer: opts.integer === true, nullable: opts.nullable !== false });
|
|
14
|
+
export const strArray = (maxItems, maxLen) => ({ kind: "strArray", maxItems, maxLen });
|
|
15
|
+
export const idArray = (maxItems) => ({ kind: "idArray", maxItems });
|
|
16
|
+
export const enumOf = (values, nullable = true) => ({ kind: "enum", values, nullable });
|
|
17
|
+
export const iso = () => ({ kind: "iso" });
|
|
18
|
+
export const shape = (name) => ({ kind: "shape", name });
|
|
19
|
+
|
|
20
|
+
const isNullish = (value) => value === undefined || value === null;
|
|
21
|
+
|
|
22
|
+
/** Validates one value against a spec, returning the normalised value. */
|
|
23
|
+
export function checkValue(context, field, spec, value) {
|
|
24
|
+
switch (spec.kind) {
|
|
25
|
+
case "str": {
|
|
26
|
+
if (isNullish(value)) {
|
|
27
|
+
if (!spec.nullable) invalidField(context, field, "a string is required");
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
if (typeof value !== "string") invalidField(context, field, "must be a string");
|
|
31
|
+
if (value.length < spec.min) invalidField(context, field, `must be at least ${spec.min} characters`);
|
|
32
|
+
if (value.length > spec.max) invalidField(context, field, `must be at most ${spec.max} characters`);
|
|
33
|
+
if (spec.pattern !== null && !spec.pattern.test(value)) invalidField(context, field, "has an invalid format");
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
case "bool":
|
|
37
|
+
if (isNullish(value)) return null;
|
|
38
|
+
if (typeof value !== "boolean") invalidField(context, field, "must be a boolean");
|
|
39
|
+
return value;
|
|
40
|
+
case "num": {
|
|
41
|
+
if (isNullish(value)) {
|
|
42
|
+
if (!spec.nullable) invalidField(context, field, "a number is required");
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
if (typeof value !== "number" || !Number.isFinite(value)) invalidField(context, field, "must be a finite number");
|
|
46
|
+
if (spec.integer && !Number.isInteger(value)) invalidField(context, field, "must be an integer");
|
|
47
|
+
if (spec.min !== null && value < spec.min) invalidField(context, field, `must be at least ${spec.min}`);
|
|
48
|
+
if (spec.max !== null && value > spec.max) invalidField(context, field, `must be at most ${spec.max}`);
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
case "strArray": {
|
|
52
|
+
if (isNullish(value)) return [];
|
|
53
|
+
if (!Array.isArray(value)) invalidField(context, field, "must be an array of strings");
|
|
54
|
+
if (value.length > spec.maxItems) invalidField(context, field, `must have at most ${spec.maxItems} entries`);
|
|
55
|
+
for (const entry of value) {
|
|
56
|
+
if (typeof entry !== "string" || entry.length > spec.maxLen) invalidField(context, field, `entries must be strings of at most ${spec.maxLen} characters`);
|
|
57
|
+
}
|
|
58
|
+
return value.slice();
|
|
59
|
+
}
|
|
60
|
+
case "idArray": {
|
|
61
|
+
if (isNullish(value)) return [];
|
|
62
|
+
if (!Array.isArray(value)) invalidField(context, field, "must be an array of ids");
|
|
63
|
+
if (value.length > spec.maxItems) invalidField(context, field, `must have at most ${spec.maxItems} entries`);
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const entry of value) {
|
|
66
|
+
if (!isHex24(entry)) invalidField(context, field, `contains an invalid id ${clip(String(entry), 40)}`);
|
|
67
|
+
if (!out.includes(entry)) out.push(entry);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
case "enum":
|
|
72
|
+
if (isNullish(value)) {
|
|
73
|
+
if (!spec.nullable) invalidField(context, field, `must be one of ${spec.values.join(", ")}`);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
if (typeof value !== "string" || !spec.values.includes(value)) invalidField(context, field, `must be one of ${spec.values.join(", ")}`);
|
|
77
|
+
return value;
|
|
78
|
+
case "iso": {
|
|
79
|
+
if (isNullish(value)) return null;
|
|
80
|
+
const normalised = typeof value === "string" ? normaliseIso(value) : null;
|
|
81
|
+
if (normalised === null) invalidField(context, field, "must be an ISO-8601 date-time");
|
|
82
|
+
return normalised;
|
|
83
|
+
}
|
|
84
|
+
case "shape": {
|
|
85
|
+
const validator = SHAPES.get(spec.name);
|
|
86
|
+
return validator(context, field, value);
|
|
87
|
+
}
|
|
88
|
+
default:
|
|
89
|
+
invalidField(context, field, "unsupported");
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Validates a request body against `specs` (field -> spec). Returns the normalised present fields only, so the
|
|
96
|
+
* caller can merge them (update) or fill defaults (create). Read-only names are dropped; unknown names fail.
|
|
97
|
+
*/
|
|
98
|
+
export function validateBody(context, body, specs) {
|
|
99
|
+
if (!isPlainObject(body)) badRequest(context, "Request body must be a JSON object");
|
|
100
|
+
const out = {};
|
|
101
|
+
for (const key of Object.keys(body)) {
|
|
102
|
+
if (READ_ONLY.has(key) || key === "__request_error") continue;
|
|
103
|
+
if (!Object.hasOwn(specs, key)) badRequest(context, `Unknown field "${clip(key, 80)}"`);
|
|
104
|
+
out[key] = checkValue(context, key, specs[key], body[key]);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Splits an operation input into the addressing part and the resource body (everything that is not addressing). */
|
|
110
|
+
export function bodyOf(input, addressing) {
|
|
111
|
+
const body = {};
|
|
112
|
+
for (const key of Object.keys(input)) if (!addressing.includes(key)) body[key] = input[key];
|
|
113
|
+
return body;
|
|
114
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Pure HTTP codecs for the provider-shaped routes. `decode` maps path, query and JSON body into the canonical
|
|
2
|
+
// snake_case arguments; a request that cannot be mapped travels as `__request_error`, which every handler checks
|
|
3
|
+
// first so the caller receives the provider envelope and status instead of a framework message. `encode` passes
|
|
4
|
+
// success bodies through and renders `{ statusCode, message, error }` for every failure.
|
|
5
|
+
import { ERRORS } from "./errors.mjs";
|
|
6
|
+
import { clip } from "./util.mjs";
|
|
7
|
+
|
|
8
|
+
const MAX_DEPTH = 512;
|
|
9
|
+
const MAX_REPEATS = 16;
|
|
10
|
+
const MAX_MEMBERS = 20000;
|
|
11
|
+
const BANNED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
12
|
+
const RESERVED = "__request_error";
|
|
13
|
+
|
|
14
|
+
/** Iterative shape check (explicit stack): depth, member count and prototype-poisoning keys. */
|
|
15
|
+
function shapeProblem(value) {
|
|
16
|
+
const stack = [[value, 1]];
|
|
17
|
+
let visited = 0;
|
|
18
|
+
while (stack.length > 0) {
|
|
19
|
+
const [node, depth] = stack.pop();
|
|
20
|
+
if (depth > MAX_DEPTH) return "Request body is nested too deeply";
|
|
21
|
+
if (node === null || typeof node !== "object") continue;
|
|
22
|
+
visited += 1;
|
|
23
|
+
if (visited > MAX_MEMBERS) return "Request body has too many members";
|
|
24
|
+
if (Array.isArray(node)) {
|
|
25
|
+
if (node.length > MAX_MEMBERS) return "Request body has too many array entries";
|
|
26
|
+
for (const child of node) if (child !== null && typeof child === "object") stack.push([child, depth + 1]);
|
|
27
|
+
} else {
|
|
28
|
+
for (const key of Object.keys(node)) {
|
|
29
|
+
if (BANNED_KEYS.has(key)) return `Invalid key "${clip(key, 60)}"`;
|
|
30
|
+
const child = node[key];
|
|
31
|
+
if (child !== null && typeof child === "object") stack.push([child, depth + 1]);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const intOrRaw = (value) => (typeof value === "string" && /^-?[0-9]{1,15}$/.test(value) ? Number(value) : value);
|
|
39
|
+
|
|
40
|
+
export const Q = {
|
|
41
|
+
str: (value) => value,
|
|
42
|
+
int: intOrRaw,
|
|
43
|
+
list: null, // marker: collect every repetition into an array
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Builds a decoder. `path` lists path parameter names copied verbatim; `query` maps query names to converters
|
|
48
|
+
* (Q.list collects repetitions); `body` is "json" or "none"; `idem` reads the Idempotency-Key header.
|
|
49
|
+
*/
|
|
50
|
+
export function decoder({ path = [], query = {}, body = "none", idem = false }) {
|
|
51
|
+
return (request) => {
|
|
52
|
+
const args = {};
|
|
53
|
+
for (const name of path) args[name] = typeof request.path[name] === "string" ? request.path[name] : "";
|
|
54
|
+
const refuse = (message) => ({ arguments: { ...args, [RESERVED]: clip(message, 300) } });
|
|
55
|
+
|
|
56
|
+
for (const [name, convert] of Object.entries(query)) {
|
|
57
|
+
const values = request.query[name];
|
|
58
|
+
if (!Array.isArray(values) || values.length === 0) continue;
|
|
59
|
+
if (values.length > MAX_REPEATS) return refuse(`Query parameter ${name} is repeated more than ${MAX_REPEATS} times`);
|
|
60
|
+
if (convert === null) args[name] = values.slice();
|
|
61
|
+
else args[name] = convert(values[values.length - 1]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (body === "json") {
|
|
65
|
+
if (request.body.kind === "json") {
|
|
66
|
+
const value = request.body.value;
|
|
67
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return refuse("Request body must be a JSON object");
|
|
68
|
+
const problem = shapeProblem(value);
|
|
69
|
+
if (problem !== null) return refuse(problem);
|
|
70
|
+
for (const key of Object.keys(value)) {
|
|
71
|
+
if (key === RESERVED || Object.hasOwn(args, key)) continue;
|
|
72
|
+
args[key] = value[key];
|
|
73
|
+
}
|
|
74
|
+
} else if (request.body.kind === "text" && request.body.value.trim().length > 0) {
|
|
75
|
+
return refuse("Request body must be JSON");
|
|
76
|
+
} else if (request.body.kind === "form") {
|
|
77
|
+
return refuse("Request body must be JSON");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const out = { arguments: args };
|
|
82
|
+
if (idem) {
|
|
83
|
+
const keys = request.headers["idempotency-key"];
|
|
84
|
+
const key = Array.isArray(keys) && keys.length > 0 ? keys[keys.length - 1] : undefined;
|
|
85
|
+
if (typeof key === "string" && key.length > 0 && key.length <= 255) out.idempotencyKey = key;
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function envelope(status, message, reason) {
|
|
92
|
+
return { statusCode: status, message: clip(message, 2000), error: reason };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Renders the failure envelope; framework outcomes keep the framework's fixed statuses (400/403/404). */
|
|
96
|
+
export function encodeError({ invocation, outcome }) {
|
|
97
|
+
const error = outcome.error ?? {};
|
|
98
|
+
const refusal = invocation?.arguments?.[RESERVED];
|
|
99
|
+
if (outcome.status === "invalid") {
|
|
100
|
+
let message = "Required parameters are missing or in the wrong format";
|
|
101
|
+
if (typeof refusal === "string") message = refusal;
|
|
102
|
+
else if (Array.isArray(error.issues) && error.issues.length > 0) {
|
|
103
|
+
const issue = error.issues[0];
|
|
104
|
+
const where = Array.isArray(issue.path) && issue.path.length > 0 ? clip(issue.path.join("."), 80) : "request";
|
|
105
|
+
message = `${message}: ${where} ${clip(String(issue.message ?? "is invalid"), 200)}`;
|
|
106
|
+
}
|
|
107
|
+
return { body: { kind: "json", value: envelope(400, message, "Bad Request") } };
|
|
108
|
+
}
|
|
109
|
+
if (outcome.status === "denied") return { body: { kind: "json", value: envelope(403, "Forbidden", "Forbidden") } };
|
|
110
|
+
if (outcome.status === "unsupported") return { body: { kind: "json", value: envelope(404, "Not Found", "Not Found") } };
|
|
111
|
+
const code = String(error.code ?? "").replace(/^tool\./, "");
|
|
112
|
+
const [status, reason] = ERRORS.get(code) ?? [500, "Internal Server Error"];
|
|
113
|
+
const message = typeof error.message === "string" && error.message.length > 0 ? error.message : reason;
|
|
114
|
+
const response = { body: { kind: "json", value: envelope(status, message, reason) } };
|
|
115
|
+
if (code === "RATE_LIMITED") response.headers = { "retry-after": "1" };
|
|
116
|
+
return response;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Success -> the canonical value verbatim; failure -> the envelope. */
|
|
120
|
+
export const encoder = (result) => (result.outcome.status === "ok" ? { body: { kind: "json", value: result.outcome.value } } : encodeError(result));
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Messaging channels: read-only list and get. `has_subchannels` is computed from the connection's `parent_id` rows
|
|
2
|
+
// at read time; the internal `alias` field (INBOX/SENT/DRAFT resolution for messages) is never returned.
|
|
3
|
+
import { enumFilter, finishPage, idFilter, matchesQuery, parseFields, parseListParams, project, sortRows, updatedSince } from "../lib/query.mjs";
|
|
4
|
+
import { open, requireId, rowsOf } from "./resource.mjs";
|
|
5
|
+
|
|
6
|
+
const SPEC = { namespace: "channels", label: "Channel", category: "messaging", permission: "messaging_channel" };
|
|
7
|
+
const HIDDEN = ["alias"];
|
|
8
|
+
|
|
9
|
+
const parentsOf = (rows) => {
|
|
10
|
+
const parents = new Set();
|
|
11
|
+
for (const row of rows) if (typeof row.parent_id === "string") parents.add(row.parent_id);
|
|
12
|
+
return parents;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const withSubchannels = (row, parents) => ({ ...row, has_subchannels: parents.has(row.id) });
|
|
16
|
+
|
|
17
|
+
export const channels = {
|
|
18
|
+
list(input, context) {
|
|
19
|
+
const { workspace, connection } = open(input, context, SPEC, "read");
|
|
20
|
+
const params = parseListParams(input, context);
|
|
21
|
+
const fields = parseFields(input, context);
|
|
22
|
+
const parentId = idFilter(input, context, "parent_id");
|
|
23
|
+
const type = enumFilter(input, context, "type", ["PUBLIC", "PRIVATE"]);
|
|
24
|
+
const all = rowsOf(context, workspace, SPEC, connection.id);
|
|
25
|
+
const parents = parentsOf(all);
|
|
26
|
+
const rows = all
|
|
27
|
+
.filter(
|
|
28
|
+
(row) =>
|
|
29
|
+
updatedSince(row, params.updatedGteUs) &&
|
|
30
|
+
matchesQuery(row, params.query, ["name", "description"]) &&
|
|
31
|
+
(parentId === null || row.parent_id === parentId) &&
|
|
32
|
+
(type === null || row.is_private === (type === "PRIVATE")),
|
|
33
|
+
)
|
|
34
|
+
.map((row) => withSubchannels(row, parents));
|
|
35
|
+
sortRows(rows, params.sort, params.order, "name");
|
|
36
|
+
return finishPage(context, rows, params, fields, HIDDEN);
|
|
37
|
+
},
|
|
38
|
+
get(input, context) {
|
|
39
|
+
const { workspace, connection } = open(input, context, SPEC, "read");
|
|
40
|
+
const fields = parseFields(input, context);
|
|
41
|
+
const row = requireId(context, SPEC, connection.id, input.id);
|
|
42
|
+
const parents = parentsOf(rowsOf(context, workspace, SPEC, connection.id));
|
|
43
|
+
return project(withSubchannels(row, parents), fields, HIDDEN);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Workspace connections: list with env/categories/external_xref filters, get, create (a synthetic, already-authorised
|
|
2
|
+
// connection), merge update and remove with a cascade over every data namespace of the connection.
|
|
3
|
+
import { badRequest, forbidden, notFound, scanBoundExceeded } from "../lib/errors.mjs";
|
|
4
|
+
import { emitDeleted } from "../lib/events.mjs";
|
|
5
|
+
import { connectionScope, inScope, loadConnection, resolveWorkspace } from "../lib/identity.mjs";
|
|
6
|
+
import { finishPage, parseListParams, project, sortRows, textFilter, updatedSince } from "../lib/query.mjs";
|
|
7
|
+
import { deleteRow, nextId, scanAll, scanBound, scanConnection } from "../lib/store.mjs";
|
|
8
|
+
import { nowIso } from "../lib/time.mjs";
|
|
9
|
+
import { bodyOf, checkValue, enumOf, str, strArray, validateBody } from "../lib/validate.mjs";
|
|
10
|
+
import { CATEGORIES, PERMISSIONS } from "../lib/enums.mjs";
|
|
11
|
+
import { isPlainObject, titleCase } from "../lib/util.mjs";
|
|
12
|
+
|
|
13
|
+
const ENVIRONMENTS = ["Production", "Sandbox"];
|
|
14
|
+
const DATA_NAMESPACES = { contacts: "crm_contact", companies: "crm_company", deals: "crm_deal", pipelines: null, channels: null, messages: "messaging_message" };
|
|
15
|
+
const AUTH_SPECS = { name: str(256), emails: strArray(8, 320), user_id: str(256) };
|
|
16
|
+
|
|
17
|
+
const CREATE_SPECS = {
|
|
18
|
+
integration_type: str(64, { nullable: false, min: 1, pattern: /^[a-z][a-z0-9_-]*$/ }),
|
|
19
|
+
permissions: strArray(64, 64),
|
|
20
|
+
categories: strArray(35, 32),
|
|
21
|
+
external_xref: str(256),
|
|
22
|
+
environment: enumOf(ENVIRONMENTS),
|
|
23
|
+
auth: { kind: "auth" },
|
|
24
|
+
// Read-only members of the Connection schema are accepted and ignored, as the provider ignores them.
|
|
25
|
+
integration_name: str(256),
|
|
26
|
+
is_paused: { kind: "ignored" },
|
|
27
|
+
last_healthy_at: { kind: "ignored" },
|
|
28
|
+
last_unhealthy_at: { kind: "ignored" },
|
|
29
|
+
last_unhealthy_code: { kind: "ignored" },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const IGNORED = new Set(["integration_name", "is_paused", "last_healthy_at", "last_unhealthy_at", "last_unhealthy_code"]);
|
|
33
|
+
|
|
34
|
+
function checkAuth(context, value) {
|
|
35
|
+
if (value === undefined || value === null) return { name: null, emails: [], user_id: null };
|
|
36
|
+
if (!isPlainObject(value)) badRequest(context, "Invalid value for auth: must be an object");
|
|
37
|
+
const out = validateBody(context, value, AUTH_SPECS);
|
|
38
|
+
for (const email of out.emails ?? []) if (!email.includes("@")) badRequest(context, "Invalid value for auth.emails: entries must be e-mail addresses");
|
|
39
|
+
return { name: out.name ?? null, emails: out.emails ?? [], user_id: out.user_id ?? null };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function checkEnums(context, fields) {
|
|
43
|
+
for (const category of fields.categories ?? []) if (!CATEGORIES.includes(category)) badRequest(context, `Invalid value for categories: ${category.slice(0, 60)} is not a supported category`);
|
|
44
|
+
for (const permission of fields.permissions ?? []) if (!PERMISSIONS.includes(permission)) badRequest(context, `Invalid value for permissions: ${permission.slice(0, 60)} is not a supported permission`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Every permission's category prefix (crm_ -> crm, messaging_ -> messaging) must be one of the connection's categories. */
|
|
48
|
+
function checkPermissionCategories(context, permissions, categories) {
|
|
49
|
+
for (const permission of permissions) {
|
|
50
|
+
const category = permission.split("_")[0];
|
|
51
|
+
if (!categories.includes(category)) badRequest(context, `Permission ${permission} requires the ${category} category`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function validateConnectionBody(context, body) {
|
|
56
|
+
const fields = {};
|
|
57
|
+
for (const key of Object.keys(body)) {
|
|
58
|
+
if (["id", "workspace_id", "created_at", "updated_at", "__request_error"].includes(key)) continue;
|
|
59
|
+
if (!Object.hasOwn(CREATE_SPECS, key)) badRequest(context, `Unknown field "${key.slice(0, 80)}"`);
|
|
60
|
+
if (IGNORED.has(key)) continue;
|
|
61
|
+
fields[key] = key === "auth" ? checkAuth(context, body[key]) : checkValue(context, key, CREATE_SPECS[key], body[key]);
|
|
62
|
+
}
|
|
63
|
+
checkEnums(context, fields);
|
|
64
|
+
return fields;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const listFilter = (input, context) => {
|
|
68
|
+
const env = input.env === undefined || input.env === null ? "Production" : input.env;
|
|
69
|
+
if (!ENVIRONMENTS.includes(env)) badRequest(context, "env must be Production or Sandbox");
|
|
70
|
+
const external = textFilter(input, context, "external_xref");
|
|
71
|
+
let categories = [];
|
|
72
|
+
if (input.categories !== undefined && input.categories !== null) {
|
|
73
|
+
const parts = Array.isArray(input.categories) ? input.categories : typeof input.categories === "string" ? [input.categories] : null;
|
|
74
|
+
if (parts === null) badRequest(context, "categories must be a comma-separated list");
|
|
75
|
+
for (const part of parts) {
|
|
76
|
+
if (typeof part !== "string") badRequest(context, "categories must be a comma-separated list");
|
|
77
|
+
for (const name of part.split(",")) if (name.trim().length > 0) categories.push(name.trim());
|
|
78
|
+
}
|
|
79
|
+
if (categories.length > 35) badRequest(context, "categories lists too many values");
|
|
80
|
+
for (const category of categories) if (!CATEGORIES.includes(category)) badRequest(context, `categories: ${category.slice(0, 60)} is not a supported category`);
|
|
81
|
+
}
|
|
82
|
+
return (row) => row.environment === env && (external === null || row.external_xref === external) && categories.every((category) => row.categories.includes(category));
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const connections = {
|
|
86
|
+
list(input, context) {
|
|
87
|
+
const workspace = resolveWorkspace(input, context);
|
|
88
|
+
const params = parseListParams(input, context);
|
|
89
|
+
const filter = listFilter(input, context);
|
|
90
|
+
const scope = connectionScope(context);
|
|
91
|
+
const rows = scanAll(context, "connections", scanBound(workspace)).filter(
|
|
92
|
+
(row) => row.workspace_id === workspace.id && inScope(scope, row.id) && updatedSince(row, params.updatedGteUs) && filter(row),
|
|
93
|
+
);
|
|
94
|
+
sortRows(rows, params.sort, params.order, "integration_name");
|
|
95
|
+
return finishPage(context, rows, params, null);
|
|
96
|
+
},
|
|
97
|
+
get(input, context) {
|
|
98
|
+
const workspace = resolveWorkspace(input, context);
|
|
99
|
+
return project(loadConnection(context, workspace, input.id), null);
|
|
100
|
+
},
|
|
101
|
+
create(input, context) {
|
|
102
|
+
const workspace = resolveWorkspace(input, context);
|
|
103
|
+
if (connectionScope(context) !== null) forbidden(context, "Connection-scoped tokens cannot create connections");
|
|
104
|
+
const fields = validateConnectionBody(context, bodyOf(input, ["__request_error"]));
|
|
105
|
+
if (typeof fields.integration_type !== "string") badRequest(context, "integration_type is required");
|
|
106
|
+
const categories = fields.categories ?? [];
|
|
107
|
+
if (categories.length === 0) badRequest(context, "categories must list at least one category");
|
|
108
|
+
const permissions = fields.permissions ?? [];
|
|
109
|
+
checkPermissionCategories(context, permissions, categories);
|
|
110
|
+
const existing = scanAll(context, "connections", scanBound(workspace)).filter((row) => row.workspace_id === workspace.id);
|
|
111
|
+
const bound = scanBound(workspace);
|
|
112
|
+
if (existing.length >= bound) scanBoundExceeded(context, "connections", bound);
|
|
113
|
+
const now = nowIso(context);
|
|
114
|
+
const row = {
|
|
115
|
+
id: nextId(context), workspace_id: workspace.id, integration_type: fields.integration_type, integration_name: titleCase(fields.integration_type),
|
|
116
|
+
external_xref: fields.external_xref ?? null, permissions, categories, auth: fields.auth ?? { name: null, emails: [], user_id: null },
|
|
117
|
+
is_paused: false, environment: fields.environment ?? "Production", last_healthy_at: now, last_unhealthy_at: null, last_unhealthy_code: null,
|
|
118
|
+
created_at: now, updated_at: now,
|
|
119
|
+
};
|
|
120
|
+
context.state.put("connections", row.id, row);
|
|
121
|
+
return row;
|
|
122
|
+
},
|
|
123
|
+
update(input, context) {
|
|
124
|
+
const workspace = resolveWorkspace(input, context);
|
|
125
|
+
const existing = loadConnection(context, workspace, input.id);
|
|
126
|
+
const fields = validateConnectionBody(context, bodyOf(input, ["id", "__request_error"]));
|
|
127
|
+
const merged = { ...existing };
|
|
128
|
+
for (const key of ["permissions", "external_xref", "environment", "auth"]) if (Object.hasOwn(fields, key) && fields[key] !== null) merged[key] = fields[key];
|
|
129
|
+
if (Object.hasOwn(fields, "external_xref") && fields.external_xref === null) merged.external_xref = null;
|
|
130
|
+
if (Object.hasOwn(fields, "integration_type") || Object.hasOwn(fields, "categories")) badRequest(context, "integration_type and categories cannot be changed after creation");
|
|
131
|
+
checkPermissionCategories(context, merged.permissions, merged.categories);
|
|
132
|
+
merged.updated_at = nowIso(context);
|
|
133
|
+
context.state.put("connections", merged.id, merged);
|
|
134
|
+
return merged;
|
|
135
|
+
},
|
|
136
|
+
remove(input, context) {
|
|
137
|
+
const workspace = resolveWorkspace(input, context);
|
|
138
|
+
const existing = loadConnection(context, workspace, input.id);
|
|
139
|
+
const bound = scanBound(workspace);
|
|
140
|
+
// Read every namespace first so the cascade either completes or refuses without half-deleting.
|
|
141
|
+
const cascade = Object.keys(DATA_NAMESPACES).map((namespace) => [namespace, scanConnection(context, namespace, existing.id, bound)]);
|
|
142
|
+
for (const [namespace, rows] of cascade) {
|
|
143
|
+
for (const row of rows) {
|
|
144
|
+
deleteRow(context, namespace, existing.id, row.id);
|
|
145
|
+
if (DATA_NAMESPACES[namespace] !== null) emitDeleted(context, existing, DATA_NAMESPACES[namespace], row.id);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
context.state.delete("connections", existing.id);
|
|
149
|
+
return {};
|
|
150
|
+
},
|
|
151
|
+
};
|