@salesforce/graphiti 10.10.3 → 10.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT_GUIDE.md +23 -0
- package/CHANGELOG.md +6 -0
- package/README.md +17 -6
- package/dist/commands/connect.d.ts +2 -1
- package/dist/commands/connect.js +28 -7
- package/dist/commands/connect.js.map +1 -1
- package/dist/intent/build-connect.d.ts +24 -0
- package/dist/intent/build-connect.js +47 -0
- package/dist/intent/build-connect.js.map +1 -0
- package/dist/intent/types.d.ts +23 -0
- package/dist/lib/fs-utils.d.ts +3 -1
- package/dist/lib/fs-utils.js +7 -3
- package/dist/lib/fs-utils.js.map +1 -1
- package/dist/lib/introspect.js +20 -1
- package/dist/lib/introspect.js.map +1 -1
- package/dist/lib/prime-schema.d.ts +70 -16
- package/dist/lib/prime-schema.js +166 -33
- package/dist/lib/prime-schema.js.map +1 -1
- package/dist/lib/walker.d.ts +10 -0
- package/dist/lib/walker.js +26 -2
- package/dist/lib/walker.js.map +1 -1
- package/dist/mcp/server.js +2 -0
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools/sf-gql-connect.d.ts +9 -0
- package/dist/mcp/tools/sf-gql-connect.js +27 -0
- package/dist/mcp/tools/sf-gql-connect.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/helpers/object-info.ts +37 -0
- package/src/commands/__tests__/connect.spec.ts +92 -0
- package/src/commands/connect.ts +28 -6
- package/src/intent/__tests__/build-connect.spec.ts +103 -0
- package/src/intent/build-connect.ts +55 -0
- package/src/intent/types.ts +25 -0
- package/src/lib/__tests__/introspect.spec.ts +34 -0
- package/src/lib/__tests__/prime-schema.spec.ts +341 -0
- package/src/lib/__tests__/walker.spec.ts +13 -0
- package/src/lib/fs-utils.ts +8 -3
- package/src/lib/introspect.ts +29 -6
- package/src/lib/prime-schema.ts +184 -32
- package/src/lib/walker.ts +26 -2
- package/src/mcp/__tests__/server.spec.ts +1 -0
- package/src/mcp/server.ts +2 -0
- package/src/mcp/tools/__tests__/sf-gql-connect.spec.ts +202 -0
- package/src/mcp/tools/sf-gql-connect.ts +42 -0
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import os from "node:os";
|
|
9
9
|
import path from "node:path";
|
|
10
|
+
import { buildSchema } from "graphql";
|
|
10
11
|
import { describe, expect, it } from "vitest";
|
|
12
|
+
import { makeObjectInfo } from "../../__tests__/helpers/object-info.js";
|
|
11
13
|
|
|
12
14
|
describe("lib/prime-schema", () => {
|
|
13
15
|
it("atomic-write: temp file is renamed to final path, no .tmp left behind", async () => {
|
|
@@ -27,6 +29,23 @@ describe("lib/prime-schema", () => {
|
|
|
27
29
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
28
30
|
});
|
|
29
31
|
|
|
32
|
+
it("atomic-write: atomicWriteText renames into place with no .tmp left behind", async () => {
|
|
33
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-prime-text-"));
|
|
34
|
+
const finalPath = path.join(tmpRoot, "schema.graphql");
|
|
35
|
+
|
|
36
|
+
const { atomicWriteText } = await import("../fs-utils.js");
|
|
37
|
+
|
|
38
|
+
atomicWriteText(finalPath, "type Query { a: Int }");
|
|
39
|
+
|
|
40
|
+
expect(fs.existsSync(finalPath)).toBe(true);
|
|
41
|
+
expect(fs.readFileSync(finalPath, "utf-8")).toBe("type Query { a: Int }");
|
|
42
|
+
|
|
43
|
+
const leftovers = fs.readdirSync(tmpRoot).filter((f) => f.includes(".tmp"));
|
|
44
|
+
expect(leftovers).toEqual([]);
|
|
45
|
+
|
|
46
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
30
49
|
it("atomic-write: parallel calls in the same process do not collide", async () => {
|
|
31
50
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-prime-parallel-"));
|
|
32
51
|
const finalPath = path.join(tmpRoot, "p.json");
|
|
@@ -231,6 +250,328 @@ describe("lib/prime-schema", () => {
|
|
|
231
250
|
}
|
|
232
251
|
});
|
|
233
252
|
|
|
253
|
+
// ── forceRefresh (W-22845606) ──────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
const INSTANCE_URL = "https://example.my.salesforce.com";
|
|
256
|
+
|
|
257
|
+
async function schemaFilePath(tmpRoot: string): Promise<string> {
|
|
258
|
+
const { schemaCacheKeyForInstanceUrl } = await import("../introspect.js");
|
|
259
|
+
return path.join(tmpRoot, "schemas", `${schemaCacheKeyForInstanceUrl(INSTANCE_URL)}.json`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function writeSchemaFile(tmpRoot: string): Promise<string> {
|
|
263
|
+
const { atomicWriteJson } = await import("../fs-utils.js");
|
|
264
|
+
const fp = await schemaFilePath(tmpRoot);
|
|
265
|
+
atomicWriteJson(fp, {
|
|
266
|
+
data: { __schema: { types: [] } },
|
|
267
|
+
__graphiti: {
|
|
268
|
+
instanceUrl: INSTANCE_URL,
|
|
269
|
+
alias: "test-org",
|
|
270
|
+
cachedAt: new Date().toISOString(),
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
return fp;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Build stub PrimeDeps whose downloadSchema increments a counter, optionally
|
|
277
|
+
// throws (per the `throwOn` map), and otherwise writes the cache file.
|
|
278
|
+
function makeStubDeps(
|
|
279
|
+
tmpRoot: string,
|
|
280
|
+
opts: { throwOn?: Record<number, unknown>; delayMs?: number } = {},
|
|
281
|
+
) {
|
|
282
|
+
let calls = 0;
|
|
283
|
+
const deps = {
|
|
284
|
+
getOrgAuth: async () => ({
|
|
285
|
+
alias: "test-org",
|
|
286
|
+
instanceUrl: INSTANCE_URL,
|
|
287
|
+
accessToken: "fake-token",
|
|
288
|
+
username: "user@example.com",
|
|
289
|
+
orgId: "00Dxx0000000000",
|
|
290
|
+
}),
|
|
291
|
+
downloadSchema: async () => {
|
|
292
|
+
calls++;
|
|
293
|
+
const toThrow = opts.throwOn?.[calls];
|
|
294
|
+
if (toThrow) throw toThrow;
|
|
295
|
+
if (opts.delayMs) await new Promise((r) => setTimeout(r, opts.delayMs));
|
|
296
|
+
await writeSchemaFile(tmpRoot);
|
|
297
|
+
const { schemaCacheKeyForInstanceUrl } = await import("../introspect.js");
|
|
298
|
+
return {
|
|
299
|
+
cacheKey: schemaCacheKeyForInstanceUrl(INSTANCE_URL),
|
|
300
|
+
instanceUrl: INSTANCE_URL,
|
|
301
|
+
typeCount: 0,
|
|
302
|
+
downloadedAt: new Date().toISOString(),
|
|
303
|
+
filePath: await schemaFilePath(tmpRoot),
|
|
304
|
+
};
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
return { deps, calls: () => calls };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
it("withSchemaLock: honors a custom skipIf predicate", async () => {
|
|
311
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-skipif-"));
|
|
312
|
+
const finalPath = path.join(tmpRoot, "s.json");
|
|
313
|
+
try {
|
|
314
|
+
const { withSchemaLock } = await import("../prime-schema.js");
|
|
315
|
+
|
|
316
|
+
let ran = false;
|
|
317
|
+
const skipResult = await withSchemaLock(finalPath, async () => (ran = true), {
|
|
318
|
+
skipIf: () => true,
|
|
319
|
+
});
|
|
320
|
+
expect(ran).toBe(false);
|
|
321
|
+
expect(skipResult).toBeUndefined();
|
|
322
|
+
|
|
323
|
+
await withSchemaLock(finalPath, async () => (ran = true), { skipIf: () => false });
|
|
324
|
+
expect(ran).toBe(true);
|
|
325
|
+
} finally {
|
|
326
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("primeSchemaWithLock: forceRefresh re-downloads even though the file exists", async () => {
|
|
331
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-refresh-"));
|
|
332
|
+
process.env.GRAPHITI_HOME = tmpRoot;
|
|
333
|
+
try {
|
|
334
|
+
const { primeSchemaWithLock } = await import("../prime-schema.js");
|
|
335
|
+
const { deps, calls } = makeStubDeps(tmpRoot);
|
|
336
|
+
|
|
337
|
+
const first = await primeSchemaWithLock("test-org", deps);
|
|
338
|
+
expect(first.cached).toBe(false);
|
|
339
|
+
expect(first.refreshed).toBe(false);
|
|
340
|
+
expect(calls()).toBe(1);
|
|
341
|
+
|
|
342
|
+
// Without forceRefresh, a second call is a pure cache hit.
|
|
343
|
+
const hit = await primeSchemaWithLock("test-org", deps);
|
|
344
|
+
expect(hit.cached).toBe(true);
|
|
345
|
+
expect(hit.refreshed).toBe(false);
|
|
346
|
+
expect(calls()).toBe(1);
|
|
347
|
+
|
|
348
|
+
// With forceRefresh, it re-downloads despite the existing file.
|
|
349
|
+
const refreshed = await primeSchemaWithLock("test-org", deps, { forceRefresh: true });
|
|
350
|
+
expect(refreshed.refreshed).toBe(true);
|
|
351
|
+
expect(refreshed.cached).toBe(false);
|
|
352
|
+
expect(calls()).toBe(2);
|
|
353
|
+
} finally {
|
|
354
|
+
delete process.env.GRAPHITI_HOME;
|
|
355
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("primeSchemaWithLock: forceRefresh evicts the in-memory parsed-schema cache (cache #2)", async () => {
|
|
360
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-refresh-schemacache-"));
|
|
361
|
+
process.env.GRAPHITI_HOME = tmpRoot;
|
|
362
|
+
try {
|
|
363
|
+
const { primeSchemaWithLock } = await import("../prime-schema.js");
|
|
364
|
+
const { primeSchemaCache, getSchema } = await import("../walker.js");
|
|
365
|
+
|
|
366
|
+
// Seed the in-memory parsed-schema cache under the instance URL key.
|
|
367
|
+
const sentinel = buildSchema("type Query { sentinel: Int }");
|
|
368
|
+
primeSchemaCache(INSTANCE_URL, sentinel);
|
|
369
|
+
expect(getSchema(INSTANCE_URL)).toBe(sentinel);
|
|
370
|
+
|
|
371
|
+
// A forced refresh must route clearSchemaCacheByUrl with the resolved
|
|
372
|
+
// instance URL, evicting the sentinel. After eviction, getSchema misses
|
|
373
|
+
// the cache and rebuilds from the freshly-written introspection on disk
|
|
374
|
+
// — so it returns a NEW schema object, never the seeded sentinel.
|
|
375
|
+
await primeSchemaWithLock("test-org", makeStubDeps(tmpRoot).deps, { forceRefresh: true });
|
|
376
|
+
expect(getSchema(INSTANCE_URL)).not.toBe(sentinel);
|
|
377
|
+
} finally {
|
|
378
|
+
delete process.env.GRAPHITI_HOME;
|
|
379
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("primeSchemaWithLock: forceRefresh clears the ObjectInfo cache on success", async () => {
|
|
384
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-refresh-oi-"));
|
|
385
|
+
process.env.GRAPHITI_HOME = tmpRoot;
|
|
386
|
+
try {
|
|
387
|
+
const { primeSchemaWithLock } = await import("../prime-schema.js");
|
|
388
|
+
const { setCachedObjectInfo, getCachedObjectInfo } = await import("../object-info.js");
|
|
389
|
+
const { deps } = makeStubDeps(tmpRoot);
|
|
390
|
+
|
|
391
|
+
await primeSchemaWithLock("test-org", deps); // prime first
|
|
392
|
+
setCachedObjectInfo("test-org", "Account", makeObjectInfo());
|
|
393
|
+
expect(getCachedObjectInfo("test-org", "Account")).not.toBeNull();
|
|
394
|
+
|
|
395
|
+
await primeSchemaWithLock("test-org", deps, { forceRefresh: true });
|
|
396
|
+
expect(getCachedObjectInfo("test-org", "Account")).toBeNull();
|
|
397
|
+
} finally {
|
|
398
|
+
delete process.env.GRAPHITI_HOME;
|
|
399
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("primeSchemaWithLock: concurrent forceRefresh coalesces into a single download", async () => {
|
|
404
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-refresh-coalesce-"));
|
|
405
|
+
process.env.GRAPHITI_HOME = tmpRoot;
|
|
406
|
+
try {
|
|
407
|
+
const { primeSchemaWithLock } = await import("../prime-schema.js");
|
|
408
|
+
|
|
409
|
+
// Pre-existing cache, backdated so the new download's mtime is strictly newer.
|
|
410
|
+
const fp = await writeSchemaFile(tmpRoot);
|
|
411
|
+
const past = new Date(Date.now() - 5_000);
|
|
412
|
+
fs.utimesSync(fp, past, past);
|
|
413
|
+
|
|
414
|
+
const { deps, calls } = makeStubDeps(tmpRoot, { delayMs: 200 });
|
|
415
|
+
|
|
416
|
+
const results = await Promise.all([
|
|
417
|
+
primeSchemaWithLock("test-org", deps, { forceRefresh: true }),
|
|
418
|
+
primeSchemaWithLock("test-org", deps, { forceRefresh: true }),
|
|
419
|
+
]);
|
|
420
|
+
|
|
421
|
+
// This test owns the *single-introspection* guarantee. That the coalesced
|
|
422
|
+
// (loser) caller still clears ITS OWN stale caches is proven separately by
|
|
423
|
+
// the R4 test below — here both callers share one alias, so a cache
|
|
424
|
+
// assertion couldn't distinguish the loser's clear from the winner's.
|
|
425
|
+
expect(calls()).toBe(1); // single introspection
|
|
426
|
+
const refreshedCount = results.filter((r) => r.refreshed).length;
|
|
427
|
+
expect(refreshedCount).toBe(1); // exactly one performed the refresh
|
|
428
|
+
} finally {
|
|
429
|
+
delete process.env.GRAPHITI_HOME;
|
|
430
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("primeSchemaWithLock: sequential forceRefresh each re-download", async () => {
|
|
435
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-refresh-seq-"));
|
|
436
|
+
process.env.GRAPHITI_HOME = tmpRoot;
|
|
437
|
+
try {
|
|
438
|
+
const { primeSchemaWithLock } = await import("../prime-schema.js");
|
|
439
|
+
const { deps, calls } = makeStubDeps(tmpRoot);
|
|
440
|
+
|
|
441
|
+
await primeSchemaWithLock("test-org", deps); // 1
|
|
442
|
+
await primeSchemaWithLock("test-org", deps, { forceRefresh: true }); // 2
|
|
443
|
+
await primeSchemaWithLock("test-org", deps, { forceRefresh: true }); // 3
|
|
444
|
+
expect(calls()).toBe(3);
|
|
445
|
+
} finally {
|
|
446
|
+
delete process.env.GRAPHITI_HOME;
|
|
447
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("primeSchemaWithLock: a failed forceRefresh keeps the old caches and throws SchemaRefreshError", async () => {
|
|
452
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-refresh-fail-"));
|
|
453
|
+
process.env.GRAPHITI_HOME = tmpRoot;
|
|
454
|
+
try {
|
|
455
|
+
const { primeSchemaWithLock, SchemaRefreshError } = await import("../prime-schema.js");
|
|
456
|
+
|
|
457
|
+
const fp = await writeSchemaFile(tmpRoot);
|
|
458
|
+
const originalContent = fs.readFileSync(fp, "utf-8");
|
|
459
|
+
const past = new Date(Date.now() - 5_000);
|
|
460
|
+
fs.utimesSync(fp, past, past);
|
|
461
|
+
|
|
462
|
+
// downloadSchema throws after the connection layer exhausts its
|
|
463
|
+
// timeout/retry. A forced refresh must keep every cache intact and
|
|
464
|
+
// surface a staleness-aware SchemaRefreshError.
|
|
465
|
+
const { deps, calls } = makeStubDeps(tmpRoot, {
|
|
466
|
+
throwOn: { 1: new Error("introspection failed") },
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const err = await primeSchemaWithLock("test-org", deps, { forceRefresh: true }).catch(
|
|
470
|
+
(e) => e,
|
|
471
|
+
);
|
|
472
|
+
expect(err).toBeInstanceOf(SchemaRefreshError);
|
|
473
|
+
expect(err.staleSince).toBeTruthy(); // surviving cache age anchor
|
|
474
|
+
expect(err.instanceUrl).toBe(INSTANCE_URL);
|
|
475
|
+
expect(calls()).toBe(1); // primeSchemaWithLock calls downloadSchema once
|
|
476
|
+
expect(fs.readFileSync(fp, "utf-8")).toBe(originalContent); // old cache intact
|
|
477
|
+
} finally {
|
|
478
|
+
delete process.env.GRAPHITI_HOME;
|
|
479
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("primeSchemaWithLock: a failed refresh with NO prior cache has no staleSince", async () => {
|
|
484
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-refresh-nocache-"));
|
|
485
|
+
process.env.GRAPHITI_HOME = tmpRoot;
|
|
486
|
+
try {
|
|
487
|
+
const { primeSchemaWithLock, SchemaRefreshError } = await import("../prime-schema.js");
|
|
488
|
+
|
|
489
|
+
const { deps } = makeStubDeps(tmpRoot, {
|
|
490
|
+
throwOn: { 1: new Error("introspection failed") },
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const err = await primeSchemaWithLock("test-org", deps, { forceRefresh: true }).catch(
|
|
494
|
+
(e) => e,
|
|
495
|
+
);
|
|
496
|
+
expect(err).toBeInstanceOf(SchemaRefreshError);
|
|
497
|
+
expect(err.staleSince).toBeUndefined();
|
|
498
|
+
} finally {
|
|
499
|
+
delete process.env.GRAPHITI_HOME;
|
|
500
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("primeSchemaWithLock: a coalesced refresh clears its OWN stale caches (R4, isolated from the winner)", async () => {
|
|
505
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-refresh-r4-"));
|
|
506
|
+
process.env.GRAPHITI_HOME = tmpRoot;
|
|
507
|
+
try {
|
|
508
|
+
const { primeSchemaWithLock } = await import("../prime-schema.js");
|
|
509
|
+
const { setCachedObjectInfo, getCachedObjectInfo } = await import("../object-info.js");
|
|
510
|
+
|
|
511
|
+
// Pre-existing cache, backdated so the winner's download is strictly newer.
|
|
512
|
+
const fp = await writeSchemaFile(tmpRoot);
|
|
513
|
+
const past = new Date(Date.now() - 5_000);
|
|
514
|
+
fs.utimesSync(fp, past, past);
|
|
515
|
+
|
|
516
|
+
// Two distinct aliases for the SAME org (instanceUrl). Seed ObjectInfo
|
|
517
|
+
// ONLY under the loser's alias: the winner's clearObjectInfoCache(winner)
|
|
518
|
+
// can never touch "loser:Account", so if it ends up null it was the
|
|
519
|
+
// loser's post-lock R4 block that cleared it — not the winner's success path.
|
|
520
|
+
setCachedObjectInfo("loser", "Account", makeObjectInfo());
|
|
521
|
+
|
|
522
|
+
let winnerDownloading = false;
|
|
523
|
+
const winnerDeps = {
|
|
524
|
+
getOrgAuth: async () => ({
|
|
525
|
+
alias: "winner",
|
|
526
|
+
instanceUrl: INSTANCE_URL,
|
|
527
|
+
accessToken: "t",
|
|
528
|
+
username: "u@e.com",
|
|
529
|
+
orgId: "00D",
|
|
530
|
+
}),
|
|
531
|
+
downloadSchema: async () => {
|
|
532
|
+
winnerDownloading = true;
|
|
533
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
534
|
+
await writeSchemaFile(tmpRoot);
|
|
535
|
+
const { schemaCacheKeyForInstanceUrl } = await import("../introspect.js");
|
|
536
|
+
return {
|
|
537
|
+
cacheKey: schemaCacheKeyForInstanceUrl(INSTANCE_URL),
|
|
538
|
+
instanceUrl: INSTANCE_URL,
|
|
539
|
+
typeCount: 0,
|
|
540
|
+
downloadedAt: new Date().toISOString(),
|
|
541
|
+
filePath: fp,
|
|
542
|
+
};
|
|
543
|
+
},
|
|
544
|
+
};
|
|
545
|
+
let loserDownloads = 0;
|
|
546
|
+
const loserDeps = {
|
|
547
|
+
getOrgAuth: async () => ({
|
|
548
|
+
alias: "loser",
|
|
549
|
+
instanceUrl: INSTANCE_URL,
|
|
550
|
+
accessToken: "t",
|
|
551
|
+
username: "u@e.com",
|
|
552
|
+
orgId: "00D",
|
|
553
|
+
}),
|
|
554
|
+
downloadSchema: async () => {
|
|
555
|
+
loserDownloads++;
|
|
556
|
+
throw new Error("loser must coalesce, not download");
|
|
557
|
+
},
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
const winner = primeSchemaWithLock("winner", winnerDeps, { forceRefresh: true });
|
|
561
|
+
while (!winnerDownloading) await new Promise((r) => setTimeout(r, 5));
|
|
562
|
+
const loser = primeSchemaWithLock("loser", loserDeps, { forceRefresh: true });
|
|
563
|
+
|
|
564
|
+
const [, loserResult] = await Promise.all([winner, loser]);
|
|
565
|
+
expect(loserResult.refreshed).toBe(false); // coalesced
|
|
566
|
+
expect(loserResult.cached).toBe(true);
|
|
567
|
+
expect(loserDownloads).toBe(0); // never downloaded
|
|
568
|
+
expect(getCachedObjectInfo("loser", "Account")).toBeNull(); // cleared by R4, not the winner
|
|
569
|
+
} finally {
|
|
570
|
+
delete process.env.GRAPHITI_HOME;
|
|
571
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
|
|
234
575
|
it("primeSchemaWithLock: awaits an async getOrgAuth and returns its instanceUrl", async () => {
|
|
235
576
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-prime-async-"));
|
|
236
577
|
process.env.GRAPHITI_HOME = tmpRoot;
|
|
@@ -22,6 +22,7 @@ import { createSession } from "../session.js";
|
|
|
22
22
|
import { validateQuery } from "../validator.js";
|
|
23
23
|
import {
|
|
24
24
|
clearSchemaCache,
|
|
25
|
+
clearSchemaCacheByUrl,
|
|
25
26
|
filterDataCloudFields,
|
|
26
27
|
type FieldInfo,
|
|
27
28
|
getSchema,
|
|
@@ -70,6 +71,18 @@ describe("walker", () => {
|
|
|
70
71
|
expect(() => selectLeafInSession(session2, "id")).not.toThrow();
|
|
71
72
|
});
|
|
72
73
|
|
|
74
|
+
it("clearSchemaCacheByUrl drops the URL-keyed entry", () => {
|
|
75
|
+
const instanceUrl = "https://refresh-test.my.salesforce.com";
|
|
76
|
+
primeSchemaCache(instanceUrl, TEST_SCHEMA);
|
|
77
|
+
// Cached: getSchema by URL returns the primed schema without touching disk.
|
|
78
|
+
expect(getSchema(instanceUrl)).toBe(TEST_SCHEMA);
|
|
79
|
+
|
|
80
|
+
clearSchemaCacheByUrl(instanceUrl);
|
|
81
|
+
// Evicted: the next read misses the in-memory cache and falls back to
|
|
82
|
+
// disk, which fails because no introspection file exists for this URL.
|
|
83
|
+
expect(() => getSchema(instanceUrl)).toThrow();
|
|
84
|
+
});
|
|
85
|
+
|
|
73
86
|
it("getSchema returns a primed schema by instance URL", () => {
|
|
74
87
|
const url = "https://example.my.salesforce.com";
|
|
75
88
|
clearSchemaCache();
|
package/src/lib/fs-utils.ts
CHANGED
|
@@ -23,16 +23,16 @@ export function graphitiHome(): string {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
* Write
|
|
26
|
+
* Write `text` to `finalPath` atomically. Writes to a sibling
|
|
27
27
|
* `<base>.tmp.<pid>.<timestamp>.<rand>` file, then renames over the final
|
|
28
28
|
* path. Concurrent readers see either the old contents or the fully-written
|
|
29
29
|
* new contents — never a partial. The temp file is unlinked if the rename
|
|
30
30
|
* fails, so failure paths do not leak partial files.
|
|
31
31
|
*/
|
|
32
|
-
export function
|
|
32
|
+
export function atomicWriteText(finalPath: string, text: string): void {
|
|
33
33
|
fs.mkdirSync(path.dirname(finalPath), { recursive: true });
|
|
34
34
|
const tmp = `${finalPath}.tmp.${process.pid}.${Date.now()}.${crypto.randomBytes(4).toString("hex")}`;
|
|
35
|
-
fs.writeFileSync(tmp,
|
|
35
|
+
fs.writeFileSync(tmp, text, "utf-8");
|
|
36
36
|
try {
|
|
37
37
|
fs.renameSync(tmp, finalPath);
|
|
38
38
|
} catch (e) {
|
|
@@ -44,3 +44,8 @@ export function atomicWriteJson(finalPath: string, payload: unknown): void {
|
|
|
44
44
|
throw e;
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
+
|
|
48
|
+
/** Write JSON to `finalPath` atomically (pretty-printed). See {@link atomicWriteText}. */
|
|
49
|
+
export function atomicWriteJson(finalPath: string, payload: unknown): void {
|
|
50
|
+
atomicWriteText(finalPath, JSON.stringify(payload, null, 2));
|
|
51
|
+
}
|
package/src/lib/introspect.ts
CHANGED
|
@@ -24,6 +24,26 @@ async function getConnection(auth: OrgAuth): Promise<Connection> {
|
|
|
24
24
|
return org.getConnection();
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// Per-request options for the introspection POST (W-22845606). `@salesforce/core`
|
|
28
|
+
// passes these straight through to jsforce:
|
|
29
|
+
// - `timeout` bounds EACH attempt (jsforce defaults to 30 min) so a wedged gateway
|
|
30
|
+
// can't hang the schema-lock holder indefinitely. A single attempt (5 min) aborts
|
|
31
|
+
// before prime-schema's 7-min STALE_LOCK_MS, so the holder normally releases the
|
|
32
|
+
// lock before any waiter reclaims it. A timeout *plus* the retry below can run
|
|
33
|
+
// ~2x the timeout and exceed STALE_LOCK_MS — a waiter may then reclaim mid-retry
|
|
34
|
+
// and run a second introspection: benign, it degrades to the redundant-download
|
|
35
|
+
// case coalescing already tolerates (atomic writes keep the cache correct). A
|
|
36
|
+
// strict bound would require timeout × (maxRetries + 1) < STALE_LOCK_MS.
|
|
37
|
+
// - jsforce does NOT retry POST by default, so we opt this idempotent introspection
|
|
38
|
+
// read into one retry on transient HTTP/network failures (with jsforce's built-in
|
|
39
|
+
// exponential backoff). Network errnos (ECONNRESET/ETIMEDOUT/…) are covered by
|
|
40
|
+
// jsforce's default `errorCodes`.
|
|
41
|
+
const INTROSPECTION_TIMEOUT_MS = 5 * 60_000;
|
|
42
|
+
const INTROSPECTION_REQUEST_OPTIONS = {
|
|
43
|
+
timeout: INTROSPECTION_TIMEOUT_MS,
|
|
44
|
+
retry: { methods: ["POST"], maxRetries: 1, statusCodes: [420, 429, 500, 502, 503, 504] },
|
|
45
|
+
};
|
|
46
|
+
|
|
27
47
|
/** Resolves the directory holding cached introspection JSON files. */
|
|
28
48
|
export function schemaDir(): string {
|
|
29
49
|
return path.join(graphitiHome(), "schemas");
|
|
@@ -243,12 +263,15 @@ export async function downloadSchema(auth: OrgAuth): Promise<SchemaMetadata> {
|
|
|
243
263
|
const connection = await getConnection(auth);
|
|
244
264
|
const url = `${auth.instanceUrl}/services/data/v${connection.getApiVersion()}/graphql`;
|
|
245
265
|
|
|
246
|
-
const rawResult = (await connection.request(
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
266
|
+
const rawResult = (await connection.request(
|
|
267
|
+
{
|
|
268
|
+
method: "POST",
|
|
269
|
+
url,
|
|
270
|
+
body: JSON.stringify({ query: INTROSPECTION_QUERY }),
|
|
271
|
+
headers: { "Content-Type": "application/json", "X-Chatter-Entity-Encoding": "false" },
|
|
272
|
+
},
|
|
273
|
+
INTROSPECTION_REQUEST_OPTIONS,
|
|
274
|
+
)) as any;
|
|
252
275
|
|
|
253
276
|
if (rawResult?.errors?.length) {
|
|
254
277
|
const messages = (rawResult.errors as any[])
|