@contextableai/openclaw-memory-graphiti 0.1.2 → 0.2.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/README.md +54 -0
- package/authorization.ts +27 -38
- package/bin/graphiti-mem.ts +142 -0
- package/cli.ts +525 -0
- package/config.ts +13 -1
- package/docker/docker-compose.yml +3 -2
- package/graphiti.ts +80 -7
- package/index.ts +154 -361
- package/openclaw.plugin.json +15 -1
- package/package.json +11 -6
- package/scripts/dev-start.sh +4 -0
- package/search.ts +34 -18
- package/spicedb.ts +190 -9
package/openclaw.plugin.json
CHANGED
|
@@ -27,6 +27,18 @@
|
|
|
27
27
|
"placeholder": "main",
|
|
28
28
|
"help": "Default Graphiti group_id for memory isolation"
|
|
29
29
|
},
|
|
30
|
+
"graphiti.uuidPollIntervalMs": {
|
|
31
|
+
"label": "UUID Poll Interval (ms)",
|
|
32
|
+
"placeholder": "3000",
|
|
33
|
+
"help": "Milliseconds between polling attempts when resolving episode UUIDs after memory_store (default: 3000)",
|
|
34
|
+
"advanced": true
|
|
35
|
+
},
|
|
36
|
+
"graphiti.uuidPollMaxAttempts": {
|
|
37
|
+
"label": "UUID Poll Max Attempts",
|
|
38
|
+
"placeholder": "30",
|
|
39
|
+
"help": "Maximum polling attempts for episode UUID resolution; total timeout = interval × attempts (default: 30 = 90s)",
|
|
40
|
+
"advanced": true
|
|
41
|
+
},
|
|
30
42
|
"subjectType": {
|
|
31
43
|
"label": "Subject Type",
|
|
32
44
|
"placeholder": "agent",
|
|
@@ -76,7 +88,9 @@
|
|
|
76
88
|
"additionalProperties": false,
|
|
77
89
|
"properties": {
|
|
78
90
|
"endpoint": { "type": "string" },
|
|
79
|
-
"defaultGroupId": { "type": "string" }
|
|
91
|
+
"defaultGroupId": { "type": "string" },
|
|
92
|
+
"uuidPollIntervalMs": { "type": "integer", "minimum": 500, "maximum": 30000 },
|
|
93
|
+
"uuidPollMaxAttempts": { "type": "integer", "minimum": 1, "maximum": 200 }
|
|
80
94
|
}
|
|
81
95
|
},
|
|
82
96
|
"subjectType": { "type": "string", "enum": ["agent", "person"] },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contextableai/openclaw-memory-graphiti",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "OpenClaw two-layer memory plugin: SpiceDB authorization + Graphiti knowledge graph",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
],
|
|
19
19
|
"files": [
|
|
20
20
|
"index.ts",
|
|
21
|
+
"cli.ts",
|
|
21
22
|
"config.ts",
|
|
22
23
|
"authorization.ts",
|
|
23
24
|
"graphiti.ts",
|
|
@@ -26,27 +27,31 @@
|
|
|
26
27
|
"openclaw.plugin.json",
|
|
27
28
|
"schema.zed",
|
|
28
29
|
"docker/",
|
|
29
|
-
"scripts/"
|
|
30
|
+
"scripts/",
|
|
31
|
+
"bin/"
|
|
30
32
|
],
|
|
31
33
|
"scripts": {
|
|
34
|
+
"cli": "tsx bin/graphiti-mem.ts",
|
|
35
|
+
"typecheck": "tsc --noEmit 2>&1 | grep -v '../openclaw/' | grep -c 'error TS' | xargs -I{} test {} -eq 0",
|
|
32
36
|
"test": "vitest run",
|
|
33
37
|
"test:watch": "vitest",
|
|
34
38
|
"test:e2e": "OPENCLAW_LIVE_TEST=1 vitest run --config vitest.e2e.config.ts"
|
|
35
39
|
},
|
|
36
40
|
"dependencies": {
|
|
37
41
|
"@authzed/authzed-node": "^1.2.0",
|
|
38
|
-
"@sinclair/typebox": "0.34.48"
|
|
42
|
+
"@sinclair/typebox": "0.34.48",
|
|
43
|
+
"commander": "^13.1.0"
|
|
39
44
|
},
|
|
40
45
|
"devDependencies": {
|
|
46
|
+
"typescript": "^5.9.3",
|
|
41
47
|
"vitest": "^4.0.18"
|
|
42
48
|
},
|
|
43
49
|
"publishConfig": {
|
|
44
50
|
"access": "public"
|
|
45
51
|
},
|
|
46
52
|
"openclaw": {
|
|
47
|
-
"
|
|
48
|
-
|
|
49
|
-
],
|
|
53
|
+
"_extensions_note": "Intentionally empty — explicit entries cause idHint to derive from the npm package name rather than the directory name, producing a mismatch with the manifest id. Previously: [\"./index.ts\"]",
|
|
54
|
+
"extensions": [],
|
|
50
55
|
"install": {
|
|
51
56
|
"npmSpec": "@contextableai/openclaw-memory-graphiti",
|
|
52
57
|
"localPath": "extensions/memory-graphiti",
|
package/scripts/dev-start.sh
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
# SPICEDB_DB_URI Postgres connection URI (when SPICEDB_DATASTORE=postgres)
|
|
14
14
|
# FALKORDB_PORT Redis port (default: 6379)
|
|
15
15
|
# GRAPHITI_PORT HTTP port (default: 8000)
|
|
16
|
+
# EPISODE_ID_PREFIX Prefix for Graphiti episode UUIDs (default: epi-)
|
|
16
17
|
# -------------------------------------------------------------------
|
|
17
18
|
set -euo pipefail
|
|
18
19
|
|
|
@@ -63,6 +64,8 @@ if is_running "$DEV_DIR/pids/falkordb.pid"; then
|
|
|
63
64
|
echo "==> FalkorDB already running (pid $(cat "$DEV_DIR/pids/falkordb.pid"))"
|
|
64
65
|
else
|
|
65
66
|
echo "==> Starting FalkorDB on port $FALKORDB_PORT..."
|
|
67
|
+
# Redis 8.x requires a valid locale; default to C.utf8 if LANG is unset
|
|
68
|
+
export LC_ALL="${LC_ALL:-C.utf8}"
|
|
66
69
|
redis-server \
|
|
67
70
|
--loadmodule "$DEV_DIR/lib/falkordb.so" \
|
|
68
71
|
--port "$FALKORDB_PORT" \
|
|
@@ -190,6 +193,7 @@ else
|
|
|
190
193
|
# Set environment for Graphiti
|
|
191
194
|
export OPENAI_API_KEY="${OPENAI_API_KEY:-}"
|
|
192
195
|
export FALKORDB_URI="redis://localhost:$FALKORDB_PORT"
|
|
196
|
+
export EPISODE_ID_PREFIX="${EPISODE_ID_PREFIX:-epi-}"
|
|
193
197
|
|
|
194
198
|
cd "$GRAPHITI_DIR"
|
|
195
199
|
uv run main.py \
|
package/search.ts
CHANGED
|
@@ -27,6 +27,8 @@ export type SearchOptions = {
|
|
|
27
27
|
limit?: number;
|
|
28
28
|
searchNodes?: boolean;
|
|
29
29
|
searchFacts?: boolean;
|
|
30
|
+
entityTypes?: string[];
|
|
31
|
+
centerNodeUuid?: string;
|
|
30
32
|
};
|
|
31
33
|
|
|
32
34
|
// ============================================================================
|
|
@@ -41,7 +43,15 @@ export async function searchAuthorizedMemories(
|
|
|
41
43
|
graphiti: GraphitiClient,
|
|
42
44
|
options: SearchOptions,
|
|
43
45
|
): Promise<SearchResult[]> {
|
|
44
|
-
const {
|
|
46
|
+
const {
|
|
47
|
+
query,
|
|
48
|
+
groupIds,
|
|
49
|
+
limit = 10,
|
|
50
|
+
searchNodes = true,
|
|
51
|
+
searchFacts = true,
|
|
52
|
+
entityTypes,
|
|
53
|
+
centerNodeUuid,
|
|
54
|
+
} = options;
|
|
45
55
|
|
|
46
56
|
if (groupIds.length === 0) {
|
|
47
57
|
return [];
|
|
@@ -52,10 +62,10 @@ export async function searchAuthorizedMemories(
|
|
|
52
62
|
|
|
53
63
|
for (const groupId of groupIds) {
|
|
54
64
|
if (searchNodes) {
|
|
55
|
-
promises.push(searchNodesForGroup(graphiti, query, groupId, limit));
|
|
65
|
+
promises.push(searchNodesForGroup(graphiti, query, groupId, limit, entityTypes));
|
|
56
66
|
}
|
|
57
67
|
if (searchFacts) {
|
|
58
|
-
promises.push(searchFactsForGroup(graphiti, query, groupId, limit));
|
|
68
|
+
promises.push(searchFactsForGroup(graphiti, query, groupId, limit, centerNodeUuid));
|
|
59
69
|
}
|
|
60
70
|
}
|
|
61
71
|
|
|
@@ -99,8 +109,9 @@ async function searchNodesForGroup(
|
|
|
99
109
|
query: string,
|
|
100
110
|
groupId: string,
|
|
101
111
|
limit: number,
|
|
112
|
+
entityTypes?: string[],
|
|
102
113
|
): Promise<SearchResult[]> {
|
|
103
|
-
const nodes = await graphiti.searchNodes({ query, group_id: groupId, limit });
|
|
114
|
+
const nodes = await graphiti.searchNodes({ query, group_id: groupId, limit, entity_types: entityTypes });
|
|
104
115
|
return nodes.map(nodeToResult);
|
|
105
116
|
}
|
|
106
117
|
|
|
@@ -109,8 +120,9 @@ async function searchFactsForGroup(
|
|
|
109
120
|
query: string,
|
|
110
121
|
groupId: string,
|
|
111
122
|
limit: number,
|
|
123
|
+
centerNodeUuid?: string,
|
|
112
124
|
): Promise<SearchResult[]> {
|
|
113
|
-
const facts = await graphiti.searchFacts({ query, group_id: groupId, limit });
|
|
125
|
+
const facts = await graphiti.searchFacts({ query, group_id: groupId, limit, center_node_uuid: centerNodeUuid });
|
|
114
126
|
return facts.map(factToResult);
|
|
115
127
|
}
|
|
116
128
|
|
|
@@ -126,9 +138,10 @@ function nodeToResult(node: GraphitiNode): SearchResult {
|
|
|
126
138
|
}
|
|
127
139
|
|
|
128
140
|
function factToResult(fact: GraphitiFact): SearchResult {
|
|
129
|
-
// Use node names
|
|
130
|
-
|
|
131
|
-
const
|
|
141
|
+
// Use node names only — never expose raw node UUIDs in the formatted output.
|
|
142
|
+
// Bare UUIDs in context strings cause LLMs to confuse node IDs with fact/episode IDs.
|
|
143
|
+
const source = fact.source_node_name ?? "?";
|
|
144
|
+
const target = fact.target_node_name ?? "?";
|
|
132
145
|
const context = fact.name ? `${source} -[${fact.name}]→ ${target}` : `${source} → ${target}`;
|
|
133
146
|
return {
|
|
134
147
|
type: "fact",
|
|
@@ -152,12 +165,7 @@ export function formatResultsForContext(results: SearchResult[]): string {
|
|
|
152
165
|
return "";
|
|
153
166
|
}
|
|
154
167
|
|
|
155
|
-
return results
|
|
156
|
-
.map((r, i) => {
|
|
157
|
-
const typeLabel = r.type === "node" ? "entity" : "fact";
|
|
158
|
-
return `${i + 1}. [${typeLabel}] ${r.summary} (${r.context})`;
|
|
159
|
-
})
|
|
160
|
-
.join("\n");
|
|
168
|
+
return results.map((r, i) => formatResultLine(r, i + 1)).join("\n");
|
|
161
169
|
}
|
|
162
170
|
|
|
163
171
|
/**
|
|
@@ -173,22 +181,30 @@ export function formatDualResults(
|
|
|
173
181
|
|
|
174
182
|
if (longTermResults.length > 0) {
|
|
175
183
|
for (const r of longTermResults) {
|
|
176
|
-
|
|
177
|
-
parts.push(`${idx++}. [${typeLabel}] ${r.summary} (${r.context})`);
|
|
184
|
+
parts.push(formatResultLine(r, idx++));
|
|
178
185
|
}
|
|
179
186
|
}
|
|
180
187
|
|
|
181
188
|
if (sessionResults.length > 0) {
|
|
182
189
|
parts.push("Session memories:");
|
|
183
190
|
for (const r of sessionResults) {
|
|
184
|
-
|
|
185
|
-
parts.push(`${idx++}. [${typeLabel}] ${r.summary} (${r.context})`);
|
|
191
|
+
parts.push(formatResultLine(r, idx++));
|
|
186
192
|
}
|
|
187
193
|
}
|
|
188
194
|
|
|
189
195
|
return parts.join("\n");
|
|
190
196
|
}
|
|
191
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Format a single search result line with type-prefixed UUID.
|
|
200
|
+
* e.g. "[fact:da8650cb-...] Eric's birthday is Dec 17th (Eric -[HAS_BIRTHDAY]→ Dec 17th)"
|
|
201
|
+
* The type prefix tells the LLM which deletion method to use.
|
|
202
|
+
*/
|
|
203
|
+
function formatResultLine(r: SearchResult, idx: number): string {
|
|
204
|
+
const typeLabel = r.type === "node" ? "entity" : "fact";
|
|
205
|
+
return `${idx}. [${typeLabel}:${r.uuid}] ${r.summary} (${r.context})`;
|
|
206
|
+
}
|
|
207
|
+
|
|
192
208
|
/**
|
|
193
209
|
* Deduplicate session results against long-term results (by UUID).
|
|
194
210
|
*/
|
package/spicedb.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* SpiceDB Client Wrapper
|
|
3
3
|
*
|
|
4
4
|
* Wraps @authzed/authzed-node for authorization operations:
|
|
5
|
-
* WriteSchema, WriteRelationships,
|
|
5
|
+
* WriteSchema, WriteRelationships, DeleteRelationships, BulkImportRelationships,
|
|
6
|
+
* LookupResources, CheckPermission.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { v1 } from "@authzed/authzed-node";
|
|
@@ -25,6 +26,11 @@ export type RelationshipTuple = {
|
|
|
25
26
|
subjectId: string;
|
|
26
27
|
};
|
|
27
28
|
|
|
29
|
+
export type ConsistencyMode =
|
|
30
|
+
| { mode: "full" }
|
|
31
|
+
| { mode: "at_least_as_fresh"; token: string }
|
|
32
|
+
| { mode: "minimize_latency" };
|
|
33
|
+
|
|
28
34
|
// ============================================================================
|
|
29
35
|
// Client
|
|
30
36
|
// ============================================================================
|
|
@@ -65,7 +71,7 @@ export class SpiceDbClient {
|
|
|
65
71
|
// Relationships
|
|
66
72
|
// --------------------------------------------------------------------------
|
|
67
73
|
|
|
68
|
-
async writeRelationships(tuples: RelationshipTuple[]): Promise<
|
|
74
|
+
async writeRelationships(tuples: RelationshipTuple[]): Promise<string | undefined> {
|
|
69
75
|
const updates = tuples.map((t) =>
|
|
70
76
|
v1.RelationshipUpdate.create({
|
|
71
77
|
operation: v1.RelationshipUpdate_Operation.TOUCH,
|
|
@@ -86,7 +92,8 @@ export class SpiceDbClient {
|
|
|
86
92
|
);
|
|
87
93
|
|
|
88
94
|
const request = v1.WriteRelationshipsRequest.create({ updates });
|
|
89
|
-
await this.promises.writeRelationships(request);
|
|
95
|
+
const response = await this.promises.writeRelationships(request);
|
|
96
|
+
return response.writtenAt?.token;
|
|
90
97
|
}
|
|
91
98
|
|
|
92
99
|
async deleteRelationships(tuples: RelationshipTuple[]): Promise<void> {
|
|
@@ -113,16 +120,193 @@ export class SpiceDbClient {
|
|
|
113
120
|
await this.promises.writeRelationships(request);
|
|
114
121
|
}
|
|
115
122
|
|
|
123
|
+
async deleteRelationshipsByFilter(params: {
|
|
124
|
+
resourceType: string;
|
|
125
|
+
resourceId: string;
|
|
126
|
+
relation?: string;
|
|
127
|
+
}): Promise<string | undefined> {
|
|
128
|
+
const request = v1.DeleteRelationshipsRequest.create({
|
|
129
|
+
relationshipFilter: v1.RelationshipFilter.create({
|
|
130
|
+
resourceType: params.resourceType,
|
|
131
|
+
optionalResourceId: params.resourceId,
|
|
132
|
+
...(params.relation ? { optionalRelation: params.relation } : {}),
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const response = await this.promises.deleteRelationships(request);
|
|
137
|
+
return response.deletedAt?.token;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --------------------------------------------------------------------------
|
|
141
|
+
// Bulk Import
|
|
142
|
+
// --------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
private toRelationship(t: RelationshipTuple) {
|
|
145
|
+
return v1.Relationship.create({
|
|
146
|
+
resource: v1.ObjectReference.create({
|
|
147
|
+
objectType: t.resourceType,
|
|
148
|
+
objectId: t.resourceId,
|
|
149
|
+
}),
|
|
150
|
+
relation: t.relation,
|
|
151
|
+
subject: v1.SubjectReference.create({
|
|
152
|
+
object: v1.ObjectReference.create({
|
|
153
|
+
objectType: t.subjectType,
|
|
154
|
+
objectId: t.subjectId,
|
|
155
|
+
}),
|
|
156
|
+
}),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Bulk import relationships using the streaming ImportBulkRelationships RPC.
|
|
162
|
+
* More efficient than individual writeRelationships calls for large batches.
|
|
163
|
+
* Falls back to batched writeRelationships if the streaming RPC is unavailable.
|
|
164
|
+
*/
|
|
165
|
+
async bulkImportRelationships(
|
|
166
|
+
tuples: RelationshipTuple[],
|
|
167
|
+
batchSize = 1000,
|
|
168
|
+
): Promise<number> {
|
|
169
|
+
if (tuples.length === 0) return 0;
|
|
170
|
+
|
|
171
|
+
// Try streaming bulk import first
|
|
172
|
+
if (typeof this.promises.bulkImportRelationships === "function") {
|
|
173
|
+
return this.bulkImportViaStream(tuples, batchSize);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Fallback: batched writeRelationships
|
|
177
|
+
return this.bulkImportViaWrite(tuples, batchSize);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private bulkImportViaStream(
|
|
181
|
+
tuples: RelationshipTuple[],
|
|
182
|
+
batchSize: number,
|
|
183
|
+
): Promise<number> {
|
|
184
|
+
return new Promise((resolve, reject) => {
|
|
185
|
+
const stream = this.promises.bulkImportRelationships(
|
|
186
|
+
(err: Error | null, response?: { numLoaded?: string }) => {
|
|
187
|
+
if (err) reject(err);
|
|
188
|
+
else resolve(Number(response?.numLoaded ?? tuples.length));
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
stream.on("error", (err: Error) => {
|
|
193
|
+
reject(err);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
for (let i = 0; i < tuples.length; i += batchSize) {
|
|
197
|
+
const chunk = tuples.slice(i, i + batchSize);
|
|
198
|
+
stream.write(
|
|
199
|
+
v1.BulkImportRelationshipsRequest.create({
|
|
200
|
+
relationships: chunk.map((t) => this.toRelationship(t)),
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
stream.end();
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async bulkImportViaWrite(
|
|
210
|
+
tuples: RelationshipTuple[],
|
|
211
|
+
batchSize: number,
|
|
212
|
+
): Promise<number> {
|
|
213
|
+
let total = 0;
|
|
214
|
+
for (let i = 0; i < tuples.length; i += batchSize) {
|
|
215
|
+
const chunk = tuples.slice(i, i + batchSize);
|
|
216
|
+
await this.writeRelationships(chunk);
|
|
217
|
+
total += chunk.length;
|
|
218
|
+
}
|
|
219
|
+
return total;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// --------------------------------------------------------------------------
|
|
223
|
+
// Read Relationships
|
|
224
|
+
// --------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Read relationships matching a filter. Returns all tuples that match the
|
|
228
|
+
* specified resource type, optional resource ID, optional relation, and
|
|
229
|
+
* optional subject filter. Used by the cleanup command to find which
|
|
230
|
+
* Graphiti episodes have SpiceDB authorization relationships.
|
|
231
|
+
*/
|
|
232
|
+
async readRelationships(params: {
|
|
233
|
+
resourceType: string;
|
|
234
|
+
resourceId?: string;
|
|
235
|
+
relation?: string;
|
|
236
|
+
subjectType?: string;
|
|
237
|
+
subjectId?: string;
|
|
238
|
+
consistency?: ConsistencyMode;
|
|
239
|
+
}): Promise<RelationshipTuple[]> {
|
|
240
|
+
const filterFields: Record<string, unknown> = {
|
|
241
|
+
resourceType: params.resourceType,
|
|
242
|
+
};
|
|
243
|
+
if (params.resourceId) {
|
|
244
|
+
filterFields.optionalResourceId = params.resourceId;
|
|
245
|
+
}
|
|
246
|
+
if (params.relation) {
|
|
247
|
+
filterFields.optionalRelation = params.relation;
|
|
248
|
+
}
|
|
249
|
+
if (params.subjectType) {
|
|
250
|
+
const subjectFilter: Record<string, unknown> = {
|
|
251
|
+
subjectType: params.subjectType,
|
|
252
|
+
};
|
|
253
|
+
if (params.subjectId) {
|
|
254
|
+
subjectFilter.optionalSubjectId = params.subjectId;
|
|
255
|
+
}
|
|
256
|
+
filterFields.optionalSubjectFilter = v1.SubjectFilter.create(subjectFilter);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const request = v1.ReadRelationshipsRequest.create({
|
|
260
|
+
relationshipFilter: v1.RelationshipFilter.create(filterFields),
|
|
261
|
+
consistency: this.buildConsistency(params.consistency),
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const results = await this.promises.readRelationships(request);
|
|
265
|
+
const tuples: RelationshipTuple[] = [];
|
|
266
|
+
for (const r of results) {
|
|
267
|
+
const rel = r.relationship;
|
|
268
|
+
if (!rel?.resource || !rel.subject?.object) continue;
|
|
269
|
+
tuples.push({
|
|
270
|
+
resourceType: rel.resource.objectType,
|
|
271
|
+
resourceId: rel.resource.objectId,
|
|
272
|
+
relation: rel.relation,
|
|
273
|
+
subjectType: rel.subject.object.objectType,
|
|
274
|
+
subjectId: rel.subject.object.objectId,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return tuples;
|
|
278
|
+
}
|
|
279
|
+
|
|
116
280
|
// --------------------------------------------------------------------------
|
|
117
281
|
// Permissions
|
|
118
282
|
// --------------------------------------------------------------------------
|
|
119
283
|
|
|
284
|
+
private buildConsistency(mode?: ConsistencyMode) {
|
|
285
|
+
if (!mode || mode.mode === "minimize_latency") {
|
|
286
|
+
return v1.Consistency.create({
|
|
287
|
+
requirement: { oneofKind: "minimizeLatency", minimizeLatency: true },
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
if (mode.mode === "at_least_as_fresh") {
|
|
291
|
+
return v1.Consistency.create({
|
|
292
|
+
requirement: {
|
|
293
|
+
oneofKind: "atLeastAsFresh",
|
|
294
|
+
atLeastAsFresh: v1.ZedToken.create({ token: mode.token }),
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
return v1.Consistency.create({
|
|
299
|
+
requirement: { oneofKind: "fullyConsistent", fullyConsistent: true },
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
120
303
|
async checkPermission(params: {
|
|
121
304
|
resourceType: string;
|
|
122
305
|
resourceId: string;
|
|
123
306
|
permission: string;
|
|
124
307
|
subjectType: string;
|
|
125
308
|
subjectId: string;
|
|
309
|
+
consistency?: ConsistencyMode;
|
|
126
310
|
}): Promise<boolean> {
|
|
127
311
|
const request = v1.CheckPermissionRequest.create({
|
|
128
312
|
resource: v1.ObjectReference.create({
|
|
@@ -136,9 +320,7 @@ export class SpiceDbClient {
|
|
|
136
320
|
objectId: params.subjectId,
|
|
137
321
|
}),
|
|
138
322
|
}),
|
|
139
|
-
consistency:
|
|
140
|
-
requirement: { oneofKind: "fullyConsistent", fullyConsistent: true },
|
|
141
|
-
}),
|
|
323
|
+
consistency: this.buildConsistency(params.consistency),
|
|
142
324
|
});
|
|
143
325
|
|
|
144
326
|
const response = await this.promises.checkPermission(request);
|
|
@@ -153,6 +335,7 @@ export class SpiceDbClient {
|
|
|
153
335
|
permission: string;
|
|
154
336
|
subjectType: string;
|
|
155
337
|
subjectId: string;
|
|
338
|
+
consistency?: ConsistencyMode;
|
|
156
339
|
}): Promise<string[]> {
|
|
157
340
|
const request = v1.LookupResourcesRequest.create({
|
|
158
341
|
resourceObjectType: params.resourceType,
|
|
@@ -163,9 +346,7 @@ export class SpiceDbClient {
|
|
|
163
346
|
objectId: params.subjectId,
|
|
164
347
|
}),
|
|
165
348
|
}),
|
|
166
|
-
consistency:
|
|
167
|
-
requirement: { oneofKind: "fullyConsistent", fullyConsistent: true },
|
|
168
|
-
}),
|
|
349
|
+
consistency: this.buildConsistency(params.consistency),
|
|
169
350
|
});
|
|
170
351
|
|
|
171
352
|
const results = await this.promises.lookupResources(request);
|