mirror: add public method `extract` (#894)
Summary: The `extract` method lets you get data out of a mirror in a structured format. The mirror module now contains all the plumbing needed to provide meaningful value. Remaining to be implemented are some internal porcelain and a public method to perform an update step. This makes progress toward #622. Test Plan: Comprehensive unit tests included, with full coverage; run `yarn unit`. wchargin-branch: mirror-extract
This commit is contained in:
parent
90ace93f91
commit
b74f1f3714
|
@ -4,6 +4,7 @@ import type Database, {BindingDictionary, Statement} from "better-sqlite3";
|
||||||
import stringify from "json-stable-stringify";
|
import stringify from "json-stable-stringify";
|
||||||
|
|
||||||
import dedent from "../util/dedent";
|
import dedent from "../util/dedent";
|
||||||
|
import * as NullUtil from "../util/null";
|
||||||
import * as Schema from "./schema";
|
import * as Schema from "./schema";
|
||||||
import * as Queries from "./queries";
|
import * as Queries from "./queries";
|
||||||
|
|
||||||
|
@ -945,6 +946,269 @@ export class Mirror {
|
||||||
|
|
||||||
// Last-updates, primitives, and links all updated: we're done.
|
// Last-updates, primitives, and links all updated: we're done.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a structured object and all of its transitive dependencies
|
||||||
|
* from the database.
|
||||||
|
*
|
||||||
|
* The result is an object whose keys are fieldnames, and whose values
|
||||||
|
* are:
|
||||||
|
*
|
||||||
|
* - for the ID field: the object ID;
|
||||||
|
* - for primitive fields: the corresponding primitive value;
|
||||||
|
* - for node reference fields: a reference to the corresponding
|
||||||
|
* extracted object, which may be `null`;
|
||||||
|
* - for connection fields: an in-order array of the corresponding
|
||||||
|
* extracted objects, each of which may be `null`.
|
||||||
|
*
|
||||||
|
* For instance, the result of `extract("issue:1")` might be:
|
||||||
|
*
|
||||||
|
* {
|
||||||
|
* id: "issue:1172",
|
||||||
|
* title: "bug: holding <Space> causes CPU to overheat",
|
||||||
|
* body: "We should fix this immediately.",
|
||||||
|
* author: {
|
||||||
|
* id: "user:admin",
|
||||||
|
* login: "admin",
|
||||||
|
* },
|
||||||
|
* comments: [
|
||||||
|
* {
|
||||||
|
* body: "I depend on this behavior; please do not change it.",
|
||||||
|
* author: {
|
||||||
|
* id: "user:longtimeuser4",
|
||||||
|
* login: "longtimeuser4",
|
||||||
|
* },
|
||||||
|
* },
|
||||||
|
* {
|
||||||
|
* body: "That's horrifying.",
|
||||||
|
* author: {
|
||||||
|
* id: "user:admin",
|
||||||
|
* login: "admin",
|
||||||
|
* },
|
||||||
|
* },
|
||||||
|
* ],
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* The returned structure may be circular.
|
||||||
|
*
|
||||||
|
* If a node appears more than one time in the result---for instance,
|
||||||
|
* the "user:admin" node above---all instances will refer to the same
|
||||||
|
* object. However, objects are distinct across calls to `extract`, so
|
||||||
|
* it is safe to deeply mutate the result of this function.
|
||||||
|
*
|
||||||
|
* The provided object ID must correspond to a known object, or an
|
||||||
|
* error will be thrown. Furthermore, all transitive dependencies of
|
||||||
|
* the object must have been at least partially loaded at some point,
|
||||||
|
* or an error will be thrown.
|
||||||
|
*/
|
||||||
|
extract(rootId: Schema.ObjectId): mixed {
|
||||||
|
const db = this._db;
|
||||||
|
return _inTransaction(db, () => {
|
||||||
|
// We'll compute the transitive dependencies and store them into a
|
||||||
|
// temporary table. To do so, we first find a free table name.
|
||||||
|
const temporaryTableName: string = _nontransactionallyFindUnusedTableName(
|
||||||
|
db,
|
||||||
|
"tmp_transitive_dependencies_"
|
||||||
|
);
|
||||||
|
db.prepare(
|
||||||
|
`CREATE TEMPORARY TABLE ${temporaryTableName} ` +
|
||||||
|
"(id TEXT NOT NULL PRIMARY KEY, typename TEXT NOT NULL)"
|
||||||
|
).run();
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.prepare(
|
||||||
|
dedent`\
|
||||||
|
WITH RECURSIVE
|
||||||
|
direct_dependencies (parent_id, child_id) AS (
|
||||||
|
SELECT parent_id, child_id FROM links
|
||||||
|
WHERE child_id IS NOT NULL
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT
|
||||||
|
connections.object_id AS parent_id,
|
||||||
|
connection_entries.child_id AS child_id
|
||||||
|
FROM connections JOIN connection_entries
|
||||||
|
ON connections.rowid = connection_entries.connection_id
|
||||||
|
WHERE child_id IS NOT NULL
|
||||||
|
),
|
||||||
|
transitive_dependencies (id) AS (
|
||||||
|
VALUES (:rootId) UNION
|
||||||
|
SELECT direct_dependencies.child_id
|
||||||
|
FROM transitive_dependencies JOIN direct_dependencies
|
||||||
|
ON transitive_dependencies.id = direct_dependencies.parent_id
|
||||||
|
)
|
||||||
|
INSERT INTO ${temporaryTableName} (id, typename)
|
||||||
|
SELECT objects.id AS id, objects.typename AS typename
|
||||||
|
FROM objects JOIN transitive_dependencies
|
||||||
|
ON objects.id = transitive_dependencies.id
|
||||||
|
`
|
||||||
|
).run({rootId});
|
||||||
|
const typenames: $ReadOnlyArray<Schema.Typename> = db
|
||||||
|
.prepare(`SELECT DISTINCT typename FROM ${temporaryTableName}`)
|
||||||
|
.pluck()
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Check to make sure all required objects and connections have
|
||||||
|
// been updated at least once.
|
||||||
|
{
|
||||||
|
const neverUpdatedEntry: void | {|
|
||||||
|
+id: Schema.ObjectId,
|
||||||
|
+fieldname: null | Schema.Fieldname,
|
||||||
|
|} = db
|
||||||
|
.prepare(
|
||||||
|
dedent`\
|
||||||
|
SELECT objects.id AS id, NULL as fieldname
|
||||||
|
FROM ${temporaryTableName}
|
||||||
|
JOIN objects USING (id)
|
||||||
|
WHERE objects.last_update IS NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT objects.id AS id, connections.fieldname AS fieldname
|
||||||
|
FROM ${temporaryTableName}
|
||||||
|
JOIN objects
|
||||||
|
USING (id)
|
||||||
|
LEFT OUTER JOIN connections
|
||||||
|
ON objects.id = connections.object_id
|
||||||
|
WHERE
|
||||||
|
connections.rowid IS NOT NULL
|
||||||
|
AND connections.last_update IS NULL
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (neverUpdatedEntry !== undefined) {
|
||||||
|
const entry = neverUpdatedEntry;
|
||||||
|
const s = JSON.stringify;
|
||||||
|
const missingData: string =
|
||||||
|
entry.fieldname == null
|
||||||
|
? "own data"
|
||||||
|
: `${s(entry.fieldname)} connection`;
|
||||||
|
throw new Error(
|
||||||
|
`${s(rootId)} transitively depends on ${s(entry.id)}, ` +
|
||||||
|
`but that object's ${missingData} has never been fetched`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constructing the result set inherently requires mutation,
|
||||||
|
// because the object graph can have cycles. We start by
|
||||||
|
// creating a record for each object, with just that object's
|
||||||
|
// primitive data. Then, we link in node references and
|
||||||
|
// connection entries.
|
||||||
|
const allObjects: Map<Schema.ObjectId, Object> = new Map();
|
||||||
|
for (const typename of typenames) {
|
||||||
|
const objectType = this._schemaInfo.objectTypes[typename];
|
||||||
|
// istanbul ignore if: should not be possible using the
|
||||||
|
// publicly accessible APIs
|
||||||
|
if (objectType == null) {
|
||||||
|
throw new Error(
|
||||||
|
`Corruption: unknown object type ${JSON.stringify(typename)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const primitivesTableName = _primitivesTableName(typename);
|
||||||
|
const selections = [
|
||||||
|
`${primitivesTableName}.id AS id`,
|
||||||
|
...objectType.primitiveFieldNames.map(
|
||||||
|
(fieldname) =>
|
||||||
|
`${primitivesTableName}."${fieldname}" AS "${fieldname}"`
|
||||||
|
),
|
||||||
|
].join(", ");
|
||||||
|
const rows: $ReadOnlyArray<{|
|
||||||
|
+id: Schema.ObjectId,
|
||||||
|
+[Schema.Fieldname]: string,
|
||||||
|
|}> = db
|
||||||
|
.prepare(
|
||||||
|
dedent`\
|
||||||
|
SELECT ${selections}
|
||||||
|
FROM ${temporaryTableName} JOIN ${primitivesTableName}
|
||||||
|
USING (id)
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
for (const row of rows) {
|
||||||
|
const object = {};
|
||||||
|
object.id = row.id;
|
||||||
|
for (const key of Object.keys(row)) {
|
||||||
|
if (key === "id") continue;
|
||||||
|
object[key] = JSON.parse(row[key]);
|
||||||
|
}
|
||||||
|
allObjects.set(object.id, object);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add links.
|
||||||
|
{
|
||||||
|
const getLinks = db.prepare(
|
||||||
|
dedent`\
|
||||||
|
SELECT
|
||||||
|
parent_id AS parentId,
|
||||||
|
fieldname AS fieldname,
|
||||||
|
child_id AS childId
|
||||||
|
FROM ${temporaryTableName} JOIN links
|
||||||
|
ON ${temporaryTableName}.id = links.parent_id
|
||||||
|
`
|
||||||
|
);
|
||||||
|
for (const link: {|
|
||||||
|
+parentId: Schema.ObjectId,
|
||||||
|
+fieldname: Schema.Fieldname,
|
||||||
|
+childId: Schema.ObjectId | null,
|
||||||
|
|} of getLinks.iterate()) {
|
||||||
|
const parent = NullUtil.get(allObjects.get(link.parentId));
|
||||||
|
const child =
|
||||||
|
link.childId == null
|
||||||
|
? null
|
||||||
|
: NullUtil.get(allObjects.get(link.childId));
|
||||||
|
parent[link.fieldname] = child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add connections.
|
||||||
|
{
|
||||||
|
const getConnectionData = db.prepare(
|
||||||
|
dedent`\
|
||||||
|
SELECT
|
||||||
|
objects.id AS parentId,
|
||||||
|
connections.fieldname AS fieldname,
|
||||||
|
connection_entries.connection_id IS NOT NULL AS hasContents,
|
||||||
|
connection_entries.child_id AS childId
|
||||||
|
FROM ${temporaryTableName}
|
||||||
|
JOIN objects
|
||||||
|
USING (id)
|
||||||
|
JOIN connections
|
||||||
|
ON objects.id = connections.object_id
|
||||||
|
LEFT OUTER JOIN connection_entries
|
||||||
|
ON connections.rowid = connection_entries.connection_id
|
||||||
|
ORDER BY
|
||||||
|
objects.id, connections.fieldname, connection_entries.idx ASC
|
||||||
|
`
|
||||||
|
);
|
||||||
|
for (const datum: {|
|
||||||
|
+parentId: Schema.ObjectId,
|
||||||
|
+fieldname: Schema.Fieldname,
|
||||||
|
+hasContents: 0 | 1,
|
||||||
|
+childId: Schema.ObjectId | null,
|
||||||
|
|} of getConnectionData.iterate()) {
|
||||||
|
const parent = NullUtil.get(allObjects.get(datum.parentId));
|
||||||
|
if (parent[datum.fieldname] === undefined) {
|
||||||
|
parent[datum.fieldname] = [];
|
||||||
|
}
|
||||||
|
if (datum.hasContents) {
|
||||||
|
const child =
|
||||||
|
datum.childId == null
|
||||||
|
? null
|
||||||
|
: NullUtil.get(allObjects.get(datum.childId));
|
||||||
|
parent[datum.fieldname].push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = allObjects.get(rootId);
|
||||||
|
if (result === undefined) {
|
||||||
|
throw new Error("No such object: " + JSON.stringify(rootId));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
this._db.prepare(`DROP TABLE ${temporaryTableName}`).run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1378,6 +1378,757 @@ describe("graphql/mirror", () => {
|
||||||
expect(format([query])).toMatchSnapshot();
|
expect(format([query])).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("extract", () => {
|
||||||
|
// A schema with some useful edge cases.
|
||||||
|
function buildTestSchema(): Schema.Schema {
|
||||||
|
const s = Schema;
|
||||||
|
return s.schema({
|
||||||
|
Caveman: s.object({
|
||||||
|
id: s.id(),
|
||||||
|
only: s.primitive(),
|
||||||
|
primitives: s.primitive(),
|
||||||
|
}),
|
||||||
|
Feline: s.object({
|
||||||
|
id: s.id(),
|
||||||
|
only: s.node("Feline"),
|
||||||
|
lynx: s.node("Feline"),
|
||||||
|
}),
|
||||||
|
Socket: s.object({
|
||||||
|
id: s.id(),
|
||||||
|
only: s.connection("Socket"),
|
||||||
|
connections: s.connection("Socket"),
|
||||||
|
}),
|
||||||
|
Empty: s.object({
|
||||||
|
id: s.id(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
type Caveman = {|
|
||||||
|
+id: string,
|
||||||
|
+only: mixed,
|
||||||
|
+primitives: mixed,
|
||||||
|
|};
|
||||||
|
type Feline = {|
|
||||||
|
+id: string,
|
||||||
|
+only: null | Feline,
|
||||||
|
+lynx: null | Feline,
|
||||||
|
|};
|
||||||
|
type Socket = {|
|
||||||
|
+id: string,
|
||||||
|
+only: $ReadOnlyArray<null | Socket>,
|
||||||
|
+connections: $ReadOnlyArray<null | Socket>,
|
||||||
|
|};
|
||||||
|
|
||||||
|
it("fails if the provided object does not exist", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const mirror = new Mirror(db, buildTestSchema());
|
||||||
|
expect(() => {
|
||||||
|
mirror.extract("wat");
|
||||||
|
}).toThrow('No such object: "wat"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails if the provided object is missing own-data", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const mirror = new Mirror(db, buildTestSchema());
|
||||||
|
mirror.registerObject({typename: "Caveman", id: "brog"});
|
||||||
|
expect(() => {
|
||||||
|
mirror.extract("brog");
|
||||||
|
}).toThrow(
|
||||||
|
'"brog" transitively depends on "brog", ' +
|
||||||
|
"but that object's own data has never been fetched"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails if the provided object is missing connection data", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const mirror = new Mirror(db, buildTestSchema());
|
||||||
|
mirror.registerObject({typename: "Socket", id: "localhost"});
|
||||||
|
mirror.registerObject({typename: "Socket", id: "loopback"});
|
||||||
|
const updateId = mirror._createUpdate(new Date(123));
|
||||||
|
mirror._updateOwnData(updateId, [
|
||||||
|
{__typename: "Socket", id: "localhost"},
|
||||||
|
{__typename: "Socket", id: "loopback"},
|
||||||
|
]);
|
||||||
|
|
||||||
|
mirror._updateConnection(updateId, "localhost", "connections", {
|
||||||
|
totalCount: 0,
|
||||||
|
pageInfo: {hasNextPage: false, endCursor: null},
|
||||||
|
nodes: [],
|
||||||
|
});
|
||||||
|
expect(() => {
|
||||||
|
mirror.extract("localhost");
|
||||||
|
}).toThrow(
|
||||||
|
'"localhost" transitively depends on "localhost", ' +
|
||||||
|
'but that object\'s "only" connection has never been fetched'
|
||||||
|
);
|
||||||
|
|
||||||
|
mirror._updateConnection(updateId, "loopback", "only", {
|
||||||
|
totalCount: 0,
|
||||||
|
pageInfo: {hasNextPage: false, endCursor: null},
|
||||||
|
nodes: [],
|
||||||
|
});
|
||||||
|
expect(() => {
|
||||||
|
mirror.extract("loopback");
|
||||||
|
}).toThrow(
|
||||||
|
'"loopback" transitively depends on "loopback", ' +
|
||||||
|
'but that object\'s "connections" connection has never been fetched'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails if a transitive dependency is missing own-data", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const mirror = new Mirror(db, buildTestSchema());
|
||||||
|
mirror.registerObject({typename: "Feline", id: "alpha"});
|
||||||
|
mirror.registerObject({typename: "Feline", id: "beta"});
|
||||||
|
mirror.registerObject({typename: "Feline", id: "gamma"});
|
||||||
|
const updateId = mirror._createUpdate(new Date(123));
|
||||||
|
mirror._updateOwnData(updateId, [
|
||||||
|
{
|
||||||
|
__typename: "Feline",
|
||||||
|
id: "alpha",
|
||||||
|
only: null,
|
||||||
|
lynx: {__typename: "Feline", id: "beta"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
__typename: "Feline",
|
||||||
|
id: "beta",
|
||||||
|
only: null,
|
||||||
|
lynx: {__typename: "Feline", id: "gamma"},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(() => {
|
||||||
|
mirror.extract("alpha");
|
||||||
|
}).toThrow(
|
||||||
|
'"alpha" transitively depends on "gamma", ' +
|
||||||
|
"but that object's own data has never been fetched"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails if a transitive dependency is missing connection data", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const mirror = new Mirror(db, buildTestSchema());
|
||||||
|
mirror.registerObject({typename: "Socket", id: "localhost:8080"});
|
||||||
|
mirror.registerObject({typename: "Socket", id: "localhost:7070"});
|
||||||
|
mirror.registerObject({typename: "Socket", id: "localhost:6060"});
|
||||||
|
const updateId = mirror._createUpdate(new Date(123));
|
||||||
|
mirror._updateOwnData(updateId, [
|
||||||
|
{__typename: "Socket", id: "localhost:8080"},
|
||||||
|
{__typename: "Socket", id: "localhost:7070"},
|
||||||
|
{__typename: "Socket", id: "localhost:6060"},
|
||||||
|
]);
|
||||||
|
const updateConnection = (
|
||||||
|
objectId: Schema.ObjectId,
|
||||||
|
fieldname: Schema.Fieldname,
|
||||||
|
ids: $ReadOnlyArray<Schema.ObjectId>
|
||||||
|
) => {
|
||||||
|
mirror._updateConnection(updateId, objectId, fieldname, {
|
||||||
|
totalCount: ids.length,
|
||||||
|
pageInfo: {hasNextPage: false, endCursor: String(ids.length)},
|
||||||
|
nodes: ids.map((id) => ({__typename: "Socket", id})),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
updateConnection("localhost:8080", "only", []);
|
||||||
|
updateConnection("localhost:7070", "only", []);
|
||||||
|
updateConnection("localhost:6060", "only", []);
|
||||||
|
updateConnection("localhost:8080", "connections", ["localhost:7070"]);
|
||||||
|
updateConnection("localhost:7070", "connections", ["localhost:6060"]);
|
||||||
|
expect(() => {
|
||||||
|
mirror.extract("localhost:8080");
|
||||||
|
}).toThrow(
|
||||||
|
'"localhost:8080" transitively depends on "localhost:6060", ' +
|
||||||
|
'but that object\'s "connections" connection has never been fetched'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles objects that only have primitive fields", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const mirror = new Mirror(db, buildTestSchema());
|
||||||
|
mirror.registerObject({typename: "Caveman", id: "brog"});
|
||||||
|
const updateId = mirror._createUpdate(new Date(123));
|
||||||
|
mirror._updateOwnData(updateId, [
|
||||||
|
{__typename: "Caveman", id: "brog", only: "ugg", primitives: "ook"},
|
||||||
|
]);
|
||||||
|
const result: Caveman = (mirror.extract("brog"): any);
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: "brog",
|
||||||
|
only: "ugg",
|
||||||
|
primitives: "ook",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles objects that only have link fields", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const mirror = new Mirror(db, buildTestSchema());
|
||||||
|
mirror.registerObject({typename: "Feline", id: "alpha"});
|
||||||
|
mirror.registerObject({typename: "Feline", id: "beta"});
|
||||||
|
mirror.registerObject({typename: "Feline", id: "gamma"});
|
||||||
|
const updateId = mirror._createUpdate(new Date(123));
|
||||||
|
mirror._updateOwnData(updateId, [
|
||||||
|
{
|
||||||
|
__typename: "Feline",
|
||||||
|
id: "alpha",
|
||||||
|
only: null,
|
||||||
|
lynx: {__typename: "Feline", id: "beta"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
__typename: "Feline",
|
||||||
|
id: "beta",
|
||||||
|
only: null,
|
||||||
|
lynx: {__typename: "Feline", id: "gamma"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
__typename: "Feline",
|
||||||
|
id: "gamma",
|
||||||
|
only: null,
|
||||||
|
lynx: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const result = mirror.extract("alpha");
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: "alpha",
|
||||||
|
only: null,
|
||||||
|
lynx: {
|
||||||
|
id: "beta",
|
||||||
|
only: null,
|
||||||
|
lynx: {
|
||||||
|
id: "gamma",
|
||||||
|
only: null,
|
||||||
|
lynx: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles objects that only have connection fields", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const mirror = new Mirror(db, buildTestSchema());
|
||||||
|
mirror.registerObject({typename: "Socket", id: "localhost:8080"});
|
||||||
|
mirror.registerObject({typename: "Socket", id: "localhost:7070"});
|
||||||
|
mirror.registerObject({typename: "Socket", id: "localhost:6060"});
|
||||||
|
const updateId = mirror._createUpdate(new Date(123));
|
||||||
|
mirror._updateOwnData(updateId, [
|
||||||
|
{__typename: "Socket", id: "localhost:8080"},
|
||||||
|
{__typename: "Socket", id: "localhost:7070"},
|
||||||
|
{__typename: "Socket", id: "localhost:6060"},
|
||||||
|
]);
|
||||||
|
const updateConnection = (
|
||||||
|
objectId: Schema.ObjectId,
|
||||||
|
fieldname: Schema.Fieldname,
|
||||||
|
ids: $ReadOnlyArray<Schema.ObjectId>
|
||||||
|
) => {
|
||||||
|
mirror._updateConnection(updateId, objectId, fieldname, {
|
||||||
|
totalCount: ids.length,
|
||||||
|
pageInfo: {hasNextPage: false, endCursor: String(ids.length)},
|
||||||
|
nodes: ids.map((id) => ({__typename: "Socket", id})),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
updateConnection("localhost:8080", "only", []);
|
||||||
|
updateConnection("localhost:7070", "only", []);
|
||||||
|
updateConnection("localhost:6060", "only", []);
|
||||||
|
updateConnection("localhost:8080", "connections", ["localhost:7070"]);
|
||||||
|
updateConnection("localhost:7070", "connections", [
|
||||||
|
"localhost:6060",
|
||||||
|
"localhost:6060",
|
||||||
|
]);
|
||||||
|
updateConnection("localhost:6060", "connections", []);
|
||||||
|
const result = mirror.extract("localhost:8080");
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: "localhost:8080",
|
||||||
|
only: [],
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: "localhost:7070",
|
||||||
|
only: [],
|
||||||
|
connections: [
|
||||||
|
{id: "localhost:6060", only: [], connections: []},
|
||||||
|
{id: "localhost:6060", only: [], connections: []},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles objects with no fields", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const mirror = new Mirror(db, buildTestSchema());
|
||||||
|
mirror.registerObject({typename: "Empty", id: "mt"});
|
||||||
|
const updateId = mirror._createUpdate(new Date(123));
|
||||||
|
mirror._updateOwnData(updateId, [{__typename: "Empty", id: "mt"}]);
|
||||||
|
const result = mirror.extract("mt");
|
||||||
|
expect(result).toEqual({id: "mt"});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles boolean primitives", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const mirror = new Mirror(db, buildTestSchema());
|
||||||
|
mirror.registerObject({typename: "Caveman", id: "brog"});
|
||||||
|
const updateId = mirror._createUpdate(new Date(123));
|
||||||
|
mirror._updateOwnData(updateId, [
|
||||||
|
{__typename: "Caveman", id: "brog", only: false, primitives: true},
|
||||||
|
]);
|
||||||
|
expect(mirror.extract("brog")).toEqual({
|
||||||
|
id: "brog",
|
||||||
|
only: false,
|
||||||
|
primitives: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles null primitives", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const mirror = new Mirror(db, buildTestSchema());
|
||||||
|
mirror.registerObject({typename: "Caveman", id: "brog"});
|
||||||
|
const updateId = mirror._createUpdate(new Date(123));
|
||||||
|
mirror._updateOwnData(updateId, [
|
||||||
|
{__typename: "Caveman", id: "brog", only: null, primitives: null},
|
||||||
|
]);
|
||||||
|
expect(mirror.extract("brog")).toEqual({
|
||||||
|
id: "brog",
|
||||||
|
only: null,
|
||||||
|
primitives: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles numeric primitives", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const mirror = new Mirror(db, buildTestSchema());
|
||||||
|
mirror.registerObject({typename: "Caveman", id: "brog"});
|
||||||
|
const updateId = mirror._createUpdate(new Date(123));
|
||||||
|
mirror._updateOwnData(updateId, [
|
||||||
|
{__typename: "Caveman", id: "brog", only: 123, primitives: 987},
|
||||||
|
]);
|
||||||
|
expect(mirror.extract("brog")).toEqual({
|
||||||
|
id: "brog",
|
||||||
|
only: 123,
|
||||||
|
primitives: 987,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles cyclic link structures", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const mirror = new Mirror(db, buildTestSchema());
|
||||||
|
mirror.registerObject({typename: "Feline", id: "alpha"});
|
||||||
|
mirror.registerObject({typename: "Feline", id: "beta"});
|
||||||
|
mirror.registerObject({typename: "Feline", id: "gamma"});
|
||||||
|
const updateId = mirror._createUpdate(new Date(123));
|
||||||
|
mirror._updateOwnData(updateId, [
|
||||||
|
{
|
||||||
|
__typename: "Feline",
|
||||||
|
id: "alpha",
|
||||||
|
only: null,
|
||||||
|
lynx: {__typename: "Feline", id: "beta"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
__typename: "Feline",
|
||||||
|
id: "beta",
|
||||||
|
only: {__typename: "Feline", id: "beta"},
|
||||||
|
lynx: {__typename: "Feline", id: "gamma"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
__typename: "Feline",
|
||||||
|
id: "gamma",
|
||||||
|
only: {__typename: "Feline", id: "beta"},
|
||||||
|
lynx: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const result: Feline = (mirror.extract("alpha"): any);
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: "alpha",
|
||||||
|
only: null,
|
||||||
|
lynx: {
|
||||||
|
id: "beta",
|
||||||
|
only: result.lynx,
|
||||||
|
lynx: {
|
||||||
|
id: "gamma",
|
||||||
|
only: result.lynx,
|
||||||
|
lynx: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect((result: any).lynx.only).toBe(result.lynx);
|
||||||
|
expect((result: any).lynx.lynx.only).toBe(result.lynx);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles cyclic connection structures", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const mirror = new Mirror(db, buildTestSchema());
|
||||||
|
mirror.registerObject({typename: "Socket", id: "localhost:8080"});
|
||||||
|
mirror.registerObject({typename: "Socket", id: "localhost:7070"});
|
||||||
|
mirror.registerObject({typename: "Socket", id: "localhost:6060"});
|
||||||
|
const updateId = mirror._createUpdate(new Date(123));
|
||||||
|
mirror._updateOwnData(updateId, [
|
||||||
|
{__typename: "Socket", id: "localhost:8080"},
|
||||||
|
{__typename: "Socket", id: "localhost:7070"},
|
||||||
|
{__typename: "Socket", id: "localhost:6060"},
|
||||||
|
]);
|
||||||
|
const updateConnection = (
|
||||||
|
objectId: Schema.ObjectId,
|
||||||
|
fieldname: Schema.Fieldname,
|
||||||
|
ids: $ReadOnlyArray<Schema.ObjectId>
|
||||||
|
) => {
|
||||||
|
mirror._updateConnection(updateId, objectId, fieldname, {
|
||||||
|
totalCount: ids.length,
|
||||||
|
pageInfo: {hasNextPage: false, endCursor: String(ids.length)},
|
||||||
|
nodes: ids.map((id) => ({__typename: "Socket", id})),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
updateConnection("localhost:8080", "only", []);
|
||||||
|
updateConnection("localhost:7070", "only", []);
|
||||||
|
updateConnection("localhost:6060", "only", []);
|
||||||
|
updateConnection("localhost:8080", "connections", ["localhost:7070"]);
|
||||||
|
updateConnection("localhost:7070", "connections", [
|
||||||
|
"localhost:8080",
|
||||||
|
"localhost:7070",
|
||||||
|
"localhost:6060",
|
||||||
|
]);
|
||||||
|
updateConnection("localhost:6060", "connections", ["localhost:7070"]);
|
||||||
|
const result: Socket = (mirror.extract("localhost:8080"): any);
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: "localhost:8080",
|
||||||
|
only: [],
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: "localhost:7070",
|
||||||
|
only: [],
|
||||||
|
connections: [
|
||||||
|
result,
|
||||||
|
result.connections[0],
|
||||||
|
{
|
||||||
|
id: "localhost:6060",
|
||||||
|
only: [],
|
||||||
|
connections: [result.connections[0]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const s8080: Socket = result;
|
||||||
|
const s7070: Socket = ((s8080.connections[0]: Socket | null): any);
|
||||||
|
const s6060: Socket = ((s7070.connections[2]: Socket | null): any);
|
||||||
|
expect(s7070.connections[0]).toBe(s8080);
|
||||||
|
expect(s7070.connections[1]).toBe(s7070);
|
||||||
|
expect(s7070.connections[2]).toBe(s6060);
|
||||||
|
expect(s6060.connections[0]).toBe(s7070);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles connections with null and repeated values", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const mirror = new Mirror(db, buildTestSchema());
|
||||||
|
mirror.registerObject({typename: "Socket", id: "localhost"});
|
||||||
|
const updateId = mirror._createUpdate(new Date(123));
|
||||||
|
mirror._updateOwnData(updateId, [
|
||||||
|
{__typename: "Socket", id: "localhost"},
|
||||||
|
]);
|
||||||
|
mirror._updateConnection(updateId, "localhost", "only", {
|
||||||
|
totalCount: 0,
|
||||||
|
pageInfo: {hasNextPage: false, endCursor: null},
|
||||||
|
nodes: [],
|
||||||
|
});
|
||||||
|
mirror._updateConnection(updateId, "localhost", "connections", {
|
||||||
|
totalCount: 6,
|
||||||
|
pageInfo: {hasNextPage: false, endCursor: "#6"},
|
||||||
|
nodes: [
|
||||||
|
null,
|
||||||
|
{__typename: "Socket", id: "localhost"},
|
||||||
|
null,
|
||||||
|
{__typename: "Socket", id: "localhost"},
|
||||||
|
{__typename: "Socket", id: "localhost"},
|
||||||
|
null,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result: Socket = (mirror.extract("localhost"): any);
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: "localhost",
|
||||||
|
only: [],
|
||||||
|
connections: [null, result, null, result, result, null],
|
||||||
|
});
|
||||||
|
expect(result.connections[1]).toBe(result);
|
||||||
|
expect(result.connections[3]).toBe(result);
|
||||||
|
expect(result.connections[4]).toBe(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a representative normal case", () => {
|
||||||
|
// In this test case, we have:
|
||||||
|
//
|
||||||
|
// - objects that are not relevant
|
||||||
|
// - object types with no relevant instances
|
||||||
|
// - object types with no instances at all
|
||||||
|
// - relevant objects that are not direct dependencies
|
||||||
|
// - relevant objects with cyclic links and connections
|
||||||
|
// - relevant objects with only primitive fields
|
||||||
|
// - relevant objects with empty connections
|
||||||
|
// - relevant objects with links pointing to `null`
|
||||||
|
// - relevant objects with links of union type
|
||||||
|
//
|
||||||
|
// (An object is "relevant" if it is a transitive dependency of
|
||||||
|
// the root.)
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const mirror = new Mirror(db, buildGithubSchema());
|
||||||
|
|
||||||
|
const objects = {
|
||||||
|
repo: () => ({typename: "Repository", id: "repo:foo/bar"}),
|
||||||
|
issue1: () => ({typename: "Issue", id: "issue:#1"}),
|
||||||
|
issue2: () => ({typename: "Issue", id: "issue:#2"}),
|
||||||
|
issue3: () => ({typename: "Issue", id: "issue:#3"}),
|
||||||
|
alice: () => ({typename: "User", id: "user:alice"}),
|
||||||
|
bob: () => ({typename: "User", id: "user:bob"}),
|
||||||
|
ethereal: () => ({typename: "User", id: "user:ethereal"}),
|
||||||
|
nobody: () => ({typename: "User", id: "user:nobody"}),
|
||||||
|
noboty: () => ({typename: "Bot", id: "bot:noboty"}),
|
||||||
|
comment1: () => ({typename: "IssueComment", id: "comment:#2.1"}),
|
||||||
|
comment2: () => ({typename: "IssueComment", id: "comment:#2.2"}),
|
||||||
|
closedEvent: () => ({
|
||||||
|
typename: "ClosedEvent",
|
||||||
|
id: "issue:#2!closed#0",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const asNode = ({typename, id}) => ({__typename: typename, id});
|
||||||
|
|
||||||
|
const update1 = mirror._createUpdate(new Date(123));
|
||||||
|
const update2 = mirror._createUpdate(new Date(234));
|
||||||
|
const update3 = mirror._createUpdate(new Date(345));
|
||||||
|
|
||||||
|
const emptyConnection = () => ({
|
||||||
|
totalCount: 0,
|
||||||
|
pageInfo: {
|
||||||
|
endCursor: null,
|
||||||
|
hasNextPage: false,
|
||||||
|
},
|
||||||
|
nodes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update #1: Own data for the repository and issues #1 and #2
|
||||||
|
// and their authors. Connection data for issue #1 as a child of
|
||||||
|
// the repository, but not issue #2. No comments on any issue.
|
||||||
|
mirror.registerObject(objects.repo());
|
||||||
|
mirror.registerObject(objects.issue1());
|
||||||
|
mirror.registerObject(objects.issue2());
|
||||||
|
mirror.registerObject(objects.alice());
|
||||||
|
mirror.registerObject(objects.ethereal());
|
||||||
|
mirror._updateOwnData(update1, [
|
||||||
|
{
|
||||||
|
...asNode(objects.repo()),
|
||||||
|
url: "url://foo/bar",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mirror._updateOwnData(update1, [
|
||||||
|
{
|
||||||
|
...asNode(objects.issue1()),
|
||||||
|
url: "url://issue/1",
|
||||||
|
author: asNode(objects.alice()),
|
||||||
|
repository: asNode(objects.repo()),
|
||||||
|
title: "this project looks dead; let's make some issues",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...asNode(objects.issue2()),
|
||||||
|
url: "url://issue/2",
|
||||||
|
author: asNode(objects.ethereal()),
|
||||||
|
repository: asNode(objects.repo()),
|
||||||
|
title: "by the time you read this, I will have deleted my account",
|
||||||
|
},
|
||||||
|
// issue:#3 remains unloaded
|
||||||
|
]);
|
||||||
|
mirror._updateOwnData(update1, [
|
||||||
|
{
|
||||||
|
...asNode(objects.alice()),
|
||||||
|
url: "url://alice",
|
||||||
|
login: "alice",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...asNode(objects.ethereal()),
|
||||||
|
login: "ethereal",
|
||||||
|
url: "url://ethereal",
|
||||||
|
},
|
||||||
|
// "nobody" and "noboty" remain unloaded
|
||||||
|
]);
|
||||||
|
mirror._updateConnection(update1, objects.repo().id, "issues", {
|
||||||
|
totalCount: 2,
|
||||||
|
pageInfo: {
|
||||||
|
endCursor: "cursor:repo:foo/bar.issues@update1",
|
||||||
|
hasNextPage: true,
|
||||||
|
},
|
||||||
|
nodes: [asNode(objects.issue1())],
|
||||||
|
});
|
||||||
|
mirror._updateConnection(
|
||||||
|
update1,
|
||||||
|
objects.issue1().id,
|
||||||
|
"comments",
|
||||||
|
emptyConnection()
|
||||||
|
);
|
||||||
|
mirror._updateConnection(
|
||||||
|
update1,
|
||||||
|
objects.issue1().id,
|
||||||
|
"timeline",
|
||||||
|
emptyConnection()
|
||||||
|
);
|
||||||
|
mirror._updateConnection(
|
||||||
|
update1,
|
||||||
|
objects.issue2().id,
|
||||||
|
"comments",
|
||||||
|
emptyConnection()
|
||||||
|
);
|
||||||
|
mirror._updateConnection(
|
||||||
|
update1,
|
||||||
|
objects.issue2().id,
|
||||||
|
"timeline",
|
||||||
|
emptyConnection()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update #2: Issue #2 author changes to `null`. Alice posts a
|
||||||
|
// comment on issue #2 and closes it. Issue #2 is loaded as a
|
||||||
|
// child of the repository.
|
||||||
|
mirror.registerObject(objects.comment1());
|
||||||
|
mirror._updateOwnData(update2, [
|
||||||
|
{
|
||||||
|
...asNode(objects.issue2()),
|
||||||
|
url: "url://issue/2",
|
||||||
|
author: null,
|
||||||
|
repository: asNode(objects.repo()),
|
||||||
|
title: "by the time you read this, I will have deleted my account",
|
||||||
|
},
|
||||||
|
// issue:#3 remains unloaded
|
||||||
|
]);
|
||||||
|
mirror._updateOwnData(update2, [
|
||||||
|
{
|
||||||
|
...asNode(objects.comment1()),
|
||||||
|
body: "cya",
|
||||||
|
author: asNode(objects.alice()),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mirror._updateConnection(update2, objects.repo().id, "issues", {
|
||||||
|
totalCount: 2,
|
||||||
|
pageInfo: {
|
||||||
|
endCursor: "cursor:repo:foo/bar.issues@update2",
|
||||||
|
hasNextPage: true,
|
||||||
|
},
|
||||||
|
nodes: [asNode(objects.issue2())],
|
||||||
|
});
|
||||||
|
mirror._updateConnection(update2, objects.issue2().id, "comments", {
|
||||||
|
totalCount: 1,
|
||||||
|
pageInfo: {
|
||||||
|
endCursor: "cursor:issue:#2.comments@update2",
|
||||||
|
hasNextPage: false,
|
||||||
|
},
|
||||||
|
nodes: [asNode(objects.comment1())],
|
||||||
|
});
|
||||||
|
mirror._updateConnection(update2, objects.issue2().id, "timeline", {
|
||||||
|
totalCount: 1,
|
||||||
|
pageInfo: {
|
||||||
|
endCursor: "cursor:issue:#2.timeline@update2",
|
||||||
|
hasNextPage: false,
|
||||||
|
},
|
||||||
|
nodes: [asNode(objects.closedEvent())],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update #3: Bob comments on issue #2. An issue #3 is created
|
||||||
|
// but not yet added to the repository connection. The details
|
||||||
|
// for the closed event are fetched.
|
||||||
|
mirror.registerObject(objects.bob());
|
||||||
|
mirror.registerObject(objects.comment2());
|
||||||
|
mirror.registerObject(objects.issue3());
|
||||||
|
mirror._updateOwnData(update3, [
|
||||||
|
{
|
||||||
|
...asNode(objects.bob()),
|
||||||
|
url: "url://bob",
|
||||||
|
login: "bob",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mirror._updateOwnData(update3, [
|
||||||
|
{
|
||||||
|
...asNode(objects.comment2()),
|
||||||
|
body: "alas, I did not know them well",
|
||||||
|
author: asNode(objects.bob()),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mirror._updateOwnData(update3, [
|
||||||
|
{
|
||||||
|
...asNode(objects.issue3()),
|
||||||
|
url: "url://issue/3",
|
||||||
|
author: asNode(objects.bob()),
|
||||||
|
repository: asNode(objects.repo()),
|
||||||
|
title: "duly responding to the call for spurious issues",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mirror._updateOwnData(update3, [
|
||||||
|
{
|
||||||
|
...asNode(objects.closedEvent()),
|
||||||
|
actor: asNode(objects.alice()),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mirror._updateConnection(update3, objects.issue2().id, "comments", {
|
||||||
|
totalCount: 2,
|
||||||
|
pageInfo: {
|
||||||
|
endCursor: "cursor:issue:#2.comments@update3",
|
||||||
|
hasNextPage: false,
|
||||||
|
},
|
||||||
|
nodes: [asNode(objects.comment2())],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The following entities are never referenced...
|
||||||
|
mirror.registerObject(objects.nobody());
|
||||||
|
mirror.registerObject(objects.noboty());
|
||||||
|
mirror.registerObject(objects.issue3());
|
||||||
|
|
||||||
|
const result = mirror.extract("repo:foo/bar");
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: "repo:foo/bar",
|
||||||
|
url: "url://foo/bar",
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
id: "issue:#1",
|
||||||
|
url: "url://issue/1",
|
||||||
|
author: {
|
||||||
|
id: "user:alice",
|
||||||
|
url: "url://alice",
|
||||||
|
login: "alice",
|
||||||
|
},
|
||||||
|
repository: result, // circular
|
||||||
|
title: "this project looks dead; let's make some issues",
|
||||||
|
comments: [],
|
||||||
|
timeline: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "issue:#2",
|
||||||
|
url: "url://issue/2",
|
||||||
|
author: null,
|
||||||
|
repository: result, // circular
|
||||||
|
title:
|
||||||
|
"by the time you read this, I will have deleted my account",
|
||||||
|
comments: [
|
||||||
|
{
|
||||||
|
id: "comment:#2.1",
|
||||||
|
body: "cya",
|
||||||
|
author: {
|
||||||
|
id: "user:alice",
|
||||||
|
url: "url://alice",
|
||||||
|
login: "alice",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "comment:#2.2",
|
||||||
|
body: "alas, I did not know them well",
|
||||||
|
author: {
|
||||||
|
id: "user:bob",
|
||||||
|
url: "url://bob",
|
||||||
|
login: "bob",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
timeline: [
|
||||||
|
{
|
||||||
|
id: "issue:#2!closed#0",
|
||||||
|
actor: {
|
||||||
|
id: "user:alice",
|
||||||
|
url: "url://alice",
|
||||||
|
login: "alice",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("_buildSchemaInfo", () => {
|
describe("_buildSchemaInfo", () => {
|
||||||
|
|
Loading…
Reference in New Issue