@firedrill-tools/salesforce 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 +156 -0
- package/firedrill/agent.target.json +17 -0
- package/firedrill/api-limit-exceeded.scenario.json +11 -0
- package/firedrill/baseline.scenario.json +2213 -0
- package/firedrill/conformance.suite.json +23 -0
- package/firedrill/row-locked.scenario.json +11 -0
- package/firedrill/salesforce-api-limit-exceeded.drill.json +336 -0
- package/firedrill/salesforce-collections-composite.drill.json +277 -0
- package/firedrill/salesforce-denied.drill.json +74 -0
- package/firedrill/salesforce-fresh-install.drill.json +86 -0
- package/firedrill/salesforce-inactive-user.drill.json +43 -0
- package/firedrill/salesforce-invalid-session.drill.json +336 -0
- package/firedrill/salesforce-large-responses.drill.json +138 -0
- package/firedrill/salesforce-mcp-aliases.drill.json +156 -0
- package/firedrill/salesforce-profile-permissions.drill.json +241 -0
- package/firedrill/salesforce-read-only.drill.json +154 -0
- package/firedrill/salesforce-rest-flow.drill.json +714 -0
- package/firedrill/salesforce-row-locked.drill.json +227 -0
- package/firedrill/salesforce-sharing.drill.json +201 -0
- package/firedrill/salesforce-soql.drill.json +139 -0
- package/firedrill/salesforce-tight-limits.drill.json +336 -0
- package/firedrill/salesforce-write-committed-lost.drill.json +176 -0
- package/firedrill/tight-limits.scenario.json +41 -0
- package/firedrill/tools/salesforce/behavior.mjs +637 -0
- package/firedrill/tools/salesforce/lib/bytes.mjs +56 -0
- package/firedrill/tools/salesforce/lib/ids.mjs +68 -0
- package/firedrill/tools/salesforce/lib/match.mjs +352 -0
- package/firedrill/tools/salesforce/lib/records.mjs +506 -0
- package/firedrill/tools/salesforce/lib/schema.mjs +429 -0
- package/firedrill/tools/salesforce/lib/search.mjs +92 -0
- package/firedrill/tools/salesforce/lib/soql.mjs +1119 -0
- package/firedrill/tools/salesforce/lib/state.mjs +378 -0
- package/firedrill/tools/salesforce/lib/wire.mjs +109 -0
- package/firedrill/tools/salesforce/salesforce.tool.json +3939 -0
- package/firedrill/world.json +2705 -0
- package/firedrill/write-committed-lost.scenario.json +11 -0
- package/firedrill.json +5 -0
- package/package.json +64 -0
- package/starter.json +2212 -0
- package/test/conformance.mjs +1122 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// Session plumbing over context.state: the org row, the calling user (identity resolution with the
|
|
2
|
+
// documented fresh-install fallback), the profile's object permissions, org-wide-default sharing,
|
|
3
|
+
// bounded scans, timestamps, the id counter, the write journal and the declared-error helpers.
|
|
4
|
+
// Every decision is derived from rows; naming conventions never grant anything.
|
|
5
|
+
|
|
6
|
+
import { mintId } from "./ids.mjs";
|
|
7
|
+
import { CHILD_RELATIONSHIPS, NAMESPACES, catalogue, isRecordType } from "./schema.mjs";
|
|
8
|
+
|
|
9
|
+
const SCAN_STEP = 1000;
|
|
10
|
+
export const DEFAULT_ROW_BOUND = 10000;
|
|
11
|
+
const SESSION_MESSAGE = "Session expired or invalid";
|
|
12
|
+
|
|
13
|
+
/** Longest caller-derived name or value quoted in an error message or `fields` entry (declared maxLength 255). */
|
|
14
|
+
export const MAX_QUOTED = 255;
|
|
15
|
+
/** Longest error message (the framework refuses operation error messages over 4,000 characters). */
|
|
16
|
+
export const MAX_MESSAGE = 3000;
|
|
17
|
+
|
|
18
|
+
/** Clip text to at most `max` code points, ending with "…" when clipped; never splits a surrogate pair. */
|
|
19
|
+
export function clip(text, max = MAX_QUOTED) {
|
|
20
|
+
const value = String(text);
|
|
21
|
+
if (value.length <= max) return value;
|
|
22
|
+
let out = "";
|
|
23
|
+
let count = 0;
|
|
24
|
+
for (const char of value) {
|
|
25
|
+
if (count === max - 1) return `${out}…`;
|
|
26
|
+
out += char;
|
|
27
|
+
count += 1;
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function clipFields(fields) {
|
|
33
|
+
if (!Array.isArray(fields)) return [];
|
|
34
|
+
return fields.map((field) => clip(field)).filter((field) => field.length > 0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A declared domain error raised by record processing; single-record operations turn it into context.fail. */
|
|
38
|
+
export class RecordError extends Error {
|
|
39
|
+
constructor(statusCode, message, fields = []) {
|
|
40
|
+
super(clip(message, MAX_MESSAGE));
|
|
41
|
+
this.name = "RecordError";
|
|
42
|
+
this.statusCode = statusCode;
|
|
43
|
+
this.fields = clipFields(fields);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isRecordError(value) {
|
|
48
|
+
return typeof value === "object" && value !== null && value.name === "RecordError" && typeof value.statusCode === "string";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function recordError(statusCode, message, fields) {
|
|
52
|
+
return new RecordError(statusCode, message, fields ?? []);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function limitInfo(org) {
|
|
56
|
+
const limits = org.dailyApiRequests ?? { max: 0, used: 0 };
|
|
57
|
+
return `api-usage=${limits.used}/${limits.max}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Stop the operation with a declared Tool error carrying Salesforce's `fields` and the limit header value. */
|
|
61
|
+
export function fail(session, statusCode, message, fields) {
|
|
62
|
+
const details = { limitInfo: session.limitInfo };
|
|
63
|
+
const clipped = clipFields(fields);
|
|
64
|
+
if (clipped.length > 0) details.fields = clipped;
|
|
65
|
+
return session.context.fail({ code: statusCode, message: clip(message, MAX_MESSAGE), details });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Convert a RecordError into the operation's declared failure; rethrow anything else. */
|
|
69
|
+
export function raise(session, error) {
|
|
70
|
+
if (isRecordError(error)) return fail(session, error.statusCode, error.message, error.fields);
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function boundExceeded(session, namespace, bound) {
|
|
75
|
+
return fail(session, "LIMIT_EXCEEDED", `state exceeds the supported bound of ${bound} rows for ${namespace}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Every row of a namespace in row-id order, failing (never truncating) beyond `bound` rows. */
|
|
79
|
+
export function allRows(session, namespace) {
|
|
80
|
+
const bound = session.rowBound;
|
|
81
|
+
const rows = [];
|
|
82
|
+
let after;
|
|
83
|
+
for (;;) {
|
|
84
|
+
const batch = session.context.state.scan(namespace, { ...(after === undefined ? {} : { afterRowId: after }), limit: SCAN_STEP });
|
|
85
|
+
if (batch.length === 0) return rows;
|
|
86
|
+
for (const record of batch) {
|
|
87
|
+
after = record.rowId;
|
|
88
|
+
rows.push(record.value);
|
|
89
|
+
if (rows.length > bound) return boundExceeded(session, namespace, bound);
|
|
90
|
+
}
|
|
91
|
+
if (batch.length < SCAN_STEP) return rows;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------------------------
|
|
96
|
+
// Timestamps
|
|
97
|
+
// ---------------------------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
/** Virtual time → Salesforce's `2026-09-14T09:00:00.000+0000` rendering. */
|
|
100
|
+
export function salesforceTimestamp(ms) {
|
|
101
|
+
const iso = new Date(ms).toISOString();
|
|
102
|
+
return `${iso.slice(0, -1)}+0000`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function nowMs(session) {
|
|
106
|
+
return Math.floor(session.context.clock.nowUs() / 1000);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function timestampNow(session) {
|
|
110
|
+
return salesforceTimestamp(nowMs(session));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function dateNow(session) {
|
|
114
|
+
return new Date(nowMs(session)).toISOString().slice(0, 10);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** ISO-8601 (`Z`, `+0000`, `+00:00`, date-only) → epoch ms, or null when unparseable. */
|
|
118
|
+
export function parseInstant(value) {
|
|
119
|
+
if (typeof value !== "string") return null;
|
|
120
|
+
let text = value.trim();
|
|
121
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) text = `${text}T00:00:00Z`;
|
|
122
|
+
const normalized = text.replace(/([+-]\d{2})(\d{2})$/, "$1:$2");
|
|
123
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})$/.test(normalized)) return null;
|
|
124
|
+
const ms = Date.parse(normalized);
|
|
125
|
+
return Number.isFinite(ms) ? ms : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------------------------
|
|
129
|
+
// Session: org, identity, permissions
|
|
130
|
+
// ---------------------------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
function usable(user) {
|
|
133
|
+
return user !== null && user !== undefined && user.IsActive === true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The calling Salesforce user: `actor.attributes.username` (case-insensitive, must be active),
|
|
138
|
+
* otherwise the org's `defaultUserId` row (the documented fresh-install identity). An explicit
|
|
139
|
+
* username that matches no active user is an expired/invalid session.
|
|
140
|
+
*/
|
|
141
|
+
function resolveUser(session) {
|
|
142
|
+
const attributes = session.context.actor.attributes ?? {};
|
|
143
|
+
const username = typeof attributes.username === "string" ? attributes.username.trim().toLowerCase() : "";
|
|
144
|
+
if (username.length > 0) {
|
|
145
|
+
const user = allRows(session, "users").find((row) => typeof row.Username === "string" && row.Username.toLowerCase() === username);
|
|
146
|
+
return usable(user) ? user : fail(session, "INVALID_SESSION_ID", SESSION_MESSAGE);
|
|
147
|
+
}
|
|
148
|
+
const seeded = session.context.state.get("users", session.org.defaultUserId);
|
|
149
|
+
if (usable(seeded)) return seeded;
|
|
150
|
+
const first = allRows(session, "users").find((row) => usable(row));
|
|
151
|
+
return first === undefined ? fail(session, "INVALID_SESSION_ID", SESSION_MESSAGE) : first;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const NO_PERMISSIONS = Object.freeze({ create: false, read: false, edit: false, delete: false, viewAll: false, modifyAll: false });
|
|
155
|
+
const READ_ONLY_PERMISSIONS = Object.freeze({ create: false, read: true, edit: false, delete: false, viewAll: true, modifyAll: false });
|
|
156
|
+
|
|
157
|
+
/** Open a session: org row, identity, profile. Authentication happens before anything else. */
|
|
158
|
+
export function openSession(context) {
|
|
159
|
+
const org = context.state.get("org", "org");
|
|
160
|
+
const session = {
|
|
161
|
+
context,
|
|
162
|
+
org: org ?? { organizationId: "", name: "", instanceUrl: "", myDomain: "", orgWideDefaults: {}, dailyApiRequests: { max: 0, used: 0 }, dataStorageMB: { max: 0, used: 0 }, defaultUserId: "", limits: { maxRowsPerNamespace: DEFAULT_ROW_BOUND } },
|
|
163
|
+
cache: new Map(),
|
|
164
|
+
journal: [],
|
|
165
|
+
pendingEvents: [],
|
|
166
|
+
};
|
|
167
|
+
session.rowBound = Math.min(DEFAULT_ROW_BOUND, Math.max(1, Number(session.org.limits?.maxRowsPerNamespace ?? DEFAULT_ROW_BOUND)));
|
|
168
|
+
session.limitInfo = limitInfo(session.org);
|
|
169
|
+
if (org === null) return fail(session, "INVALID_SESSION_ID", SESSION_MESSAGE);
|
|
170
|
+
session.user = resolveUser(session);
|
|
171
|
+
session.profile = context.state.get("profiles", session.user.ProfileId);
|
|
172
|
+
return session;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function cached(session, key, load) {
|
|
176
|
+
if (!session.cache.has(key)) session.cache.set(key, load());
|
|
177
|
+
return session.cache.get(key);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Object permissions of the caller's profile for one sObject type. */
|
|
181
|
+
export function permissions(session, type) {
|
|
182
|
+
if (!isRecordType(type)) return READ_ONLY_PERMISSIONS;
|
|
183
|
+
const table = session.profile?.objectPermissions ?? {};
|
|
184
|
+
const entry = table[type];
|
|
185
|
+
return typeof entry === "object" && entry !== null ? { ...NO_PERMISSIONS, ...entry } : NO_PERMISSIONS;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function orgWideDefault(session, type) {
|
|
189
|
+
const value = session.org.orgWideDefaults?.[type];
|
|
190
|
+
return typeof value === "string" ? value : "Private";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Custom field rows for a type (one `custom-fields` row per sObject, direct lookup — no scan). */
|
|
194
|
+
export function customFieldRows(session, type) {
|
|
195
|
+
return cached(session, `custom/${type}`, () => {
|
|
196
|
+
const row = session.context.state.get("custom-fields", type);
|
|
197
|
+
return row === null || !Array.isArray(row.fields) ? [] : row.fields;
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function fieldsOf(session, type) {
|
|
202
|
+
return cached(session, `fields/${type}`, () => catalogue(type, customFieldRows(session, type)));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Parent-reference fields that child relationships group by, per child sObject (e.g. Task → WhoId, WhatId). */
|
|
206
|
+
const PARENT_FIELDS = new Map();
|
|
207
|
+
for (const relationships of Object.values(CHILD_RELATIONSHIPS)) {
|
|
208
|
+
for (const relationship of relationships) {
|
|
209
|
+
const fields = PARENT_FIELDS.get(relationship.childSObject) ?? [];
|
|
210
|
+
if (!fields.includes(relationship.field)) fields.push(relationship.field);
|
|
211
|
+
PARENT_FIELDS.set(relationship.childSObject, fields);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const TYPE_BY_NAMESPACE = new Map(Object.entries(NAMESPACES).map(([type, namespace]) => [namespace, type]));
|
|
215
|
+
|
|
216
|
+
/** Every row of a type (bounded) plus an Id → position index, cached per operation and kept in sync by putRow. */
|
|
217
|
+
function rowTable(session, type) {
|
|
218
|
+
return cached(session, `rows/${type}`, () => {
|
|
219
|
+
const rows = allRows(session, NAMESPACES[type]);
|
|
220
|
+
const byId = new Map();
|
|
221
|
+
rows.forEach((row, index) => byId.set(row.Id, index));
|
|
222
|
+
return { rows, byId };
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** All rows of a record namespace, cached per session (bounded). */
|
|
227
|
+
export function rowsOf(session, type) {
|
|
228
|
+
return rowTable(session, type).rows;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** One row by id: an O(1) index lookup once the type's rows are cached, a direct state read otherwise. */
|
|
232
|
+
export function getRow(session, type, id) {
|
|
233
|
+
const table = session.cache.get(`rows/${type}`);
|
|
234
|
+
if (table !== undefined) {
|
|
235
|
+
const index = table.byId.get(id);
|
|
236
|
+
return index === undefined ? null : table.rows[index];
|
|
237
|
+
}
|
|
238
|
+
return session.context.state.get(NAMESPACES[type], id);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function addChild(index, parentId, id) {
|
|
242
|
+
if (typeof parentId !== "string") return;
|
|
243
|
+
const ids = index.get(parentId);
|
|
244
|
+
if (ids === undefined) index.set(parentId, new Set([id]));
|
|
245
|
+
else ids.add(id);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Rows of `type` whose `field` references `parentId` (deleted ones included), in row order; indexed once per operation. */
|
|
249
|
+
export function childRowsOf(session, type, field, parentId) {
|
|
250
|
+
const table = rowTable(session, type);
|
|
251
|
+
const index = cached(session, `children/${type}/${field}`, () => {
|
|
252
|
+
const built = new Map();
|
|
253
|
+
for (const row of table.rows) addChild(built, row.fields?.[field], row.Id);
|
|
254
|
+
return built;
|
|
255
|
+
});
|
|
256
|
+
const ids = index.get(parentId);
|
|
257
|
+
if (ids === undefined) return [];
|
|
258
|
+
return [...ids].map((id) => table.byId.get(id)).sort((left, right) => left - right).map((position) => table.rows[position]);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Stored field value (`fields` map for record types; flat rows for User/Profile). */
|
|
262
|
+
export function valueOf(type, row, fieldName) {
|
|
263
|
+
if (row === null || row === undefined) return null;
|
|
264
|
+
if (!isRecordType(type)) return row[fieldName] === undefined ? null : row[fieldName];
|
|
265
|
+
if (fieldName === "Id" || fieldName === "IsDeleted" || fieldName === "CreatedDate" || fieldName === "CreatedById" || fieldName === "LastModifiedDate" || fieldName === "LastModifiedById" || fieldName === "SystemModstamp") {
|
|
266
|
+
return row[fieldName] === undefined ? null : row[fieldName];
|
|
267
|
+
}
|
|
268
|
+
const value = row.fields?.[fieldName];
|
|
269
|
+
return value === undefined ? null : value;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function isDeleted(row) {
|
|
273
|
+
return row?.IsDeleted === true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------------------------------------
|
|
277
|
+
// Sharing (org-wide defaults + ownership + viewAll/modifyAll)
|
|
278
|
+
// ---------------------------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
function parentAccount(session, row) {
|
|
281
|
+
const accountId = valueOf("Contact", row, "AccountId");
|
|
282
|
+
if (typeof accountId !== "string") return null;
|
|
283
|
+
const account = getRow(session, "Account", accountId);
|
|
284
|
+
return account === null || isDeleted(account) ? null : account;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** May the caller see this (existing) record? Unreadable types are handled by the caller. */
|
|
288
|
+
export function canRead(session, type, row) {
|
|
289
|
+
if (!isRecordType(type)) return true;
|
|
290
|
+
const perms = permissions(session, type);
|
|
291
|
+
if (!perms.read) return false;
|
|
292
|
+
if (perms.viewAll || perms.modifyAll) return true;
|
|
293
|
+
const owd = orgWideDefault(session, type);
|
|
294
|
+
if (owd === "ReadOnly" || owd === "ReadWrite") return true;
|
|
295
|
+
if (owd === "ControlledByParent") {
|
|
296
|
+
const parent = parentAccount(session, row);
|
|
297
|
+
return parent === null ? valueOf(type, row, "OwnerId") === session.user.Id : canRead(session, "Account", parent);
|
|
298
|
+
}
|
|
299
|
+
return valueOf(type, row, "OwnerId") === session.user.Id;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** May the caller edit (`edit`) or delete (`delete`) this readable record? */
|
|
303
|
+
export function canModify(session, type, row, action) {
|
|
304
|
+
if (!isRecordType(type)) return false;
|
|
305
|
+
const perms = permissions(session, type);
|
|
306
|
+
if (!perms[action]) return false;
|
|
307
|
+
if (perms.modifyAll) return true;
|
|
308
|
+
const owd = orgWideDefault(session, type);
|
|
309
|
+
if (owd === "ReadWrite") return true;
|
|
310
|
+
if (owd === "ControlledByParent") {
|
|
311
|
+
const parent = parentAccount(session, row);
|
|
312
|
+
return parent === null ? valueOf(type, row, "OwnerId") === session.user.Id : canModify(session, "Account", parent, action);
|
|
313
|
+
}
|
|
314
|
+
return valueOf(type, row, "OwnerId") === session.user.Id;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------------------------------------
|
|
318
|
+
// Writes: journal (for allOrNone rollback), id counter, events
|
|
319
|
+
// ---------------------------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
/** Put a row, remembering the previous value so an envelope operation can roll it back. */
|
|
322
|
+
export function putRow(session, namespace, rowId, value) {
|
|
323
|
+
const previous = session.context.state.get(namespace, rowId);
|
|
324
|
+
session.journal.push({ namespace, rowId, previous });
|
|
325
|
+
session.context.state.put(namespace, rowId, value);
|
|
326
|
+
const type = TYPE_BY_NAMESPACE.get(namespace);
|
|
327
|
+
if (type === undefined) return;
|
|
328
|
+
const table = session.cache.get(`rows/${type}`);
|
|
329
|
+
if (table === undefined) return;
|
|
330
|
+
const position = table.byId.get(rowId);
|
|
331
|
+
const before = position === undefined ? null : table.rows[position];
|
|
332
|
+
if (position === undefined) {
|
|
333
|
+
table.byId.set(rowId, table.rows.length);
|
|
334
|
+
table.rows.push(value);
|
|
335
|
+
} else table.rows[position] = value;
|
|
336
|
+
for (const field of PARENT_FIELDS.get(type) ?? []) {
|
|
337
|
+
const index = session.cache.get(`children/${type}/${field}`);
|
|
338
|
+
if (index === undefined) continue;
|
|
339
|
+
const oldParent = before?.fields?.[field];
|
|
340
|
+
const newParent = value?.fields?.[field];
|
|
341
|
+
if (oldParent === newParent) continue;
|
|
342
|
+
if (typeof oldParent === "string") index.get(oldParent)?.delete(rowId);
|
|
343
|
+
addChild(index, newParent, rowId);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Undo every journaled write since `mark` (in reverse order) and drop the events queued since. */
|
|
348
|
+
export function rollback(session, mark) {
|
|
349
|
+
while (session.journal.length > mark.journal) {
|
|
350
|
+
const entry = session.journal.pop();
|
|
351
|
+
if (entry.previous === null) session.context.state.delete(entry.namespace, entry.rowId);
|
|
352
|
+
else session.context.state.put(entry.namespace, entry.rowId, entry.previous);
|
|
353
|
+
}
|
|
354
|
+
session.pendingEvents.length = mark.events;
|
|
355
|
+
session.cache.clear();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function mark(session) {
|
|
359
|
+
return { journal: session.journal.length, events: session.pendingEvents.length };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function queueEvent(session, eventId, payload) {
|
|
363
|
+
session.pendingEvents.push({ eventId, payload });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Emit every queued event (called once the response envelope is final). */
|
|
367
|
+
export function flushEvents(session) {
|
|
368
|
+
for (const entry of session.pendingEvents) session.context.events.emit(entry.eventId, entry.payload);
|
|
369
|
+
session.pendingEvents.length = 0;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Next id for `type` from the single org-wide record-number sequence. */
|
|
373
|
+
export function nextId(session, type) {
|
|
374
|
+
const counters = session.context.state.get("meta", "counters") ?? { nextRecordNumber: 1 };
|
|
375
|
+
const number = Number(counters.nextRecordNumber ?? 1);
|
|
376
|
+
putRow(session, "meta", "counters", { ...counters, nextRecordNumber: number + 1 });
|
|
377
|
+
return mintId(type, number);
|
|
378
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Salesforce REST wire helpers: query-string parsing, the error-array envelope, response headers.
|
|
2
|
+
// Pure functions only — the HTTP codecs have neither state nor a clock.
|
|
3
|
+
|
|
4
|
+
function last(values) {
|
|
5
|
+
return values === undefined || values.length === 0 ? undefined : values[values.length - 1];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function str(query, name) {
|
|
9
|
+
return last(query[name]);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Comma-separated (and repeatable) query parameter → string array, or undefined when absent. */
|
|
13
|
+
export function list(query, name) {
|
|
14
|
+
const values = query[name];
|
|
15
|
+
if (values === undefined || values.length === 0) return undefined;
|
|
16
|
+
const items = [];
|
|
17
|
+
for (const value of values) for (const part of value.split(",")) if (part.trim().length > 0) items.push(part.trim());
|
|
18
|
+
return items;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `true`/`false` query parameter; anything else is passed through so the schema rejects it. */
|
|
22
|
+
export function boolOrRaw(query, name) {
|
|
23
|
+
const value = last(query[name]);
|
|
24
|
+
if (value === undefined) return undefined;
|
|
25
|
+
if (value === "true") return true;
|
|
26
|
+
if (value === "false") return false;
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function defined(object) {
|
|
31
|
+
return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** JSON bodies nesting deeper than this are refused at decode time (400), before schema validation recurses. */
|
|
35
|
+
export const MAX_JSON_DEPTH = 512;
|
|
36
|
+
|
|
37
|
+
/** True when `value` nests arrays/objects deeper than `max` levels; iterative (explicit stack), never recursive. */
|
|
38
|
+
export function nestsDeeperThan(value, max) {
|
|
39
|
+
const stack = [[value, 1]];
|
|
40
|
+
while (stack.length > 0) {
|
|
41
|
+
const [current, depth] = stack.pop();
|
|
42
|
+
if (typeof current !== "object" || current === null) continue;
|
|
43
|
+
if (depth > max) return true;
|
|
44
|
+
for (const child of Array.isArray(current) ? current : Object.values(current)) {
|
|
45
|
+
if (typeof child === "object" && child !== null) stack.push([child, depth + 1]);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function jsonBody(request) {
|
|
52
|
+
const value = request.body.kind === "json" ? request.body.value : undefined;
|
|
53
|
+
if (nestsDeeperThan(value, MAX_JSON_DEPTH)) throw new Error(`JSON body nests deeper than ${MAX_JSON_DEPTH} levels`);
|
|
54
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `Sforce-Query-Options: batchSize=N` → N (undefined when absent or unparsable). */
|
|
58
|
+
export function batchSizeHeader(request) {
|
|
59
|
+
const header = last(request.headers["sforce-query-options"]);
|
|
60
|
+
if (header === undefined) return undefined;
|
|
61
|
+
const match = /batchSize\s*=\s*(\d{1,6})/i.exec(header);
|
|
62
|
+
return match === null ? undefined : Number(match[1]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Mutations accept an optional X-Firedrill-Idempotency-Key header (Salesforce clients never send one). */
|
|
66
|
+
export function operationInput(request, args) {
|
|
67
|
+
const key = last(request.headers["x-firedrill-idempotency-key"]);
|
|
68
|
+
return key === undefined || key.length === 0 ? { arguments: args } : { arguments: args, idempotencyKey: key };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function errorCode(outcome) {
|
|
72
|
+
return String(outcome.error?.code ?? "").replace(/^tool\./, "");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The `Sforce-Limit-Info` value an outcome carries (object outputs and handler-raised errors only). */
|
|
76
|
+
export function limitInfoOf(outcome) {
|
|
77
|
+
if (outcome.status === "ok") {
|
|
78
|
+
const value = outcome.value;
|
|
79
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value._limitInfo === "string" ? value._limitInfo : undefined;
|
|
80
|
+
}
|
|
81
|
+
const details = outcome.error?.details;
|
|
82
|
+
return typeof details === "object" && details !== null && typeof details.limitInfo === "string" ? details.limitInfo : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function responseHeaders(outcome, extra = {}) {
|
|
86
|
+
const info = limitInfoOf(outcome);
|
|
87
|
+
return { ...(info === undefined ? {} : { "sforce-limit-info": info }), ...extra };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Strip the `_limitInfo` carrier from an object body (arrays pass through). */
|
|
91
|
+
export function stripLimitInfo(value) {
|
|
92
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return value;
|
|
93
|
+
const { _limitInfo, ...rest } = value;
|
|
94
|
+
return rest;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Salesforce's error array `[{ message, errorCode, fields? }]` for a non-ok outcome. */
|
|
98
|
+
export function salesforceError(outcome) {
|
|
99
|
+
const error = outcome.error ?? {};
|
|
100
|
+
const message = typeof error.message === "string" && error.message.length > 0 ? error.message : "Request failed.";
|
|
101
|
+
if (outcome.status === "denied") return [{ message: "The REST API is not enabled for this User", errorCode: "API_DISABLED_FOR_ORG" }];
|
|
102
|
+
if (outcome.status === "unsupported") return [{ message: "The requested resource does not exist", errorCode: "NOT_FOUND" }];
|
|
103
|
+
if (outcome.status === "invalid") return [{ message, errorCode: "JSON_PARSER_ERROR" }];
|
|
104
|
+
const code = errorCode(outcome) || "UNKNOWN_EXCEPTION";
|
|
105
|
+
const details = typeof error.details === "object" && error.details !== null ? error.details : {};
|
|
106
|
+
const entry = { message, errorCode: code };
|
|
107
|
+
if (Array.isArray(details.fields) && details.fields.length > 0) entry.fields = details.fields;
|
|
108
|
+
return [entry];
|
|
109
|
+
}
|