@dwk/solid-pod 0.1.0-beta.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.
Files changed (68) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +108 -0
  3. package/dist/auth.d.ts +33 -0
  4. package/dist/auth.d.ts.map +1 -0
  5. package/dist/auth.js +160 -0
  6. package/dist/auth.js.map +1 -0
  7. package/dist/config.d.ts +181 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +74 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/encoding.d.ts +13 -0
  12. package/dist/encoding.d.ts.map +1 -0
  13. package/dist/encoding.js +31 -0
  14. package/dist/encoding.js.map +1 -0
  15. package/dist/gc.d.ts +22 -0
  16. package/dist/gc.d.ts.map +1 -0
  17. package/dist/gc.js +33 -0
  18. package/dist/gc.js.map +1 -0
  19. package/dist/handler.d.ts +20 -0
  20. package/dist/handler.d.ts.map +1 -0
  21. package/dist/handler.js +155 -0
  22. package/dist/handler.js.map +1 -0
  23. package/dist/index.d.ts +24 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +23 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/jwt.d.ts +36 -0
  28. package/dist/jwt.d.ts.map +1 -0
  29. package/dist/jwt.js +120 -0
  30. package/dist/jwt.js.map +1 -0
  31. package/dist/ldp.d.ts +37 -0
  32. package/dist/ldp.d.ts.map +1 -0
  33. package/dist/ldp.js +85 -0
  34. package/dist/ldp.js.map +1 -0
  35. package/dist/log.d.ts +55 -0
  36. package/dist/log.d.ts.map +1 -0
  37. package/dist/log.js +51 -0
  38. package/dist/log.js.map +1 -0
  39. package/dist/negotiation.d.ts +23 -0
  40. package/dist/negotiation.d.ts.map +1 -0
  41. package/dist/negotiation.js +80 -0
  42. package/dist/negotiation.js.map +1 -0
  43. package/dist/patch.d.ts +80 -0
  44. package/dist/patch.d.ts.map +1 -0
  45. package/dist/patch.js +425 -0
  46. package/dist/patch.js.map +1 -0
  47. package/dist/pod.d.ts +20 -0
  48. package/dist/pod.d.ts.map +1 -0
  49. package/dist/pod.js +860 -0
  50. package/dist/pod.js.map +1 -0
  51. package/dist/wac.d.ts +33 -0
  52. package/dist/wac.d.ts.map +1 -0
  53. package/dist/wac.js +84 -0
  54. package/dist/wac.js.map +1 -0
  55. package/package.json +55 -0
  56. package/src/auth.ts +203 -0
  57. package/src/config.ts +254 -0
  58. package/src/encoding.ts +32 -0
  59. package/src/gc.ts +47 -0
  60. package/src/handler.ts +199 -0
  61. package/src/index.ts +32 -0
  62. package/src/jwt.ts +166 -0
  63. package/src/ldp.ts +99 -0
  64. package/src/log.ts +59 -0
  65. package/src/negotiation.ts +97 -0
  66. package/src/patch.ts +539 -0
  67. package/src/pod.ts +1195 -0
  68. package/src/wac.ts +119 -0
package/src/patch.ts ADDED
@@ -0,0 +1,539 @@
1
+ /**
2
+ * N3 Patch (`text/n3`) and a minimal `application/sparql-update` parser, plus
3
+ * the deliberately-small `solid:where` matcher.
4
+ *
5
+ * This is **not** a SPARQL engine. `solid:where` is a conjunctive basic graph
6
+ * pattern matched against the resource's current triples; the patch applies
7
+ * only when the pattern binds to **exactly one** solution (per the Solid
8
+ * Protocol's N3 Patch rules). Variables in `solid:deletes` / `solid:inserts`
9
+ * are then instantiated from that single binding. Anything richer (`OPTIONAL`,
10
+ * `FILTER`, property paths, multiple solutions) is out of scope and surfaces as
11
+ * a `409`.
12
+ */
13
+
14
+ import {
15
+ parseTurtle,
16
+ type Quad,
17
+ type StoredQuad,
18
+ type StoredTerm,
19
+ } from "@dwk/rdf";
20
+
21
+ const SOLID = "http://www.w3.org/ns/solid/terms#";
22
+ const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
23
+ const INSERT_DELETE_PATCH = `${SOLID}InsertDeletePatch`;
24
+ const XSD_STRING = "http://www.w3.org/2001/XMLSchema#string";
25
+ const DEFAULT_GRAPH: StoredTerm = { termType: "DefaultGraph", value: "" };
26
+
27
+ /** A term in a patch template/pattern: a variable, or a concrete RDF term. */
28
+ type PatchTerm =
29
+ | { readonly kind: "var"; readonly name: string }
30
+ | { readonly kind: "term"; readonly term: StoredTerm };
31
+
32
+ /** A triple pattern (subject/predicate/object) from a patch graph. */
33
+ interface PatchTriple {
34
+ readonly subject: PatchTerm;
35
+ readonly predicate: PatchTerm;
36
+ readonly object: PatchTerm;
37
+ }
38
+
39
+ /** A parsed patch: the `where` pattern and the `deletes`/`inserts` templates. */
40
+ export interface Patch {
41
+ readonly where: readonly PatchTriple[];
42
+ readonly deletes: readonly PatchTriple[];
43
+ readonly inserts: readonly PatchTriple[];
44
+ }
45
+
46
+ /** A concrete delete/insert pair ready to hand to `@dwk/store`. */
47
+ export interface ResolvedPatch {
48
+ readonly deletes: readonly StoredQuad[];
49
+ readonly inserts: readonly StoredQuad[];
50
+ /** True when the patch only inserts (authorizable with `acl:Append`). */
51
+ readonly insertOnly: boolean;
52
+ }
53
+
54
+ /** A stable failure code from parsing or applying a patch. */
55
+ export type PatchError =
56
+ | "parse_error"
57
+ | "unsupported_media_type"
58
+ | "no_match"
59
+ | "ambiguous_match"
60
+ | "delete_not_found"
61
+ | "where_too_complex";
62
+
63
+ export class PatchProblem extends Error {
64
+ constructor(readonly code: PatchError) {
65
+ super(`@dwk/solid-pod: patch ${code}`);
66
+ this.name = "PatchProblem";
67
+ }
68
+ }
69
+
70
+ /**
71
+ * A stable failure code for a violation of the N3 Patch *document constraints*
72
+ * (Solid Protocol §5.3.1) — as distinct from a binding/state outcome. These all
73
+ * mean the patch document is itself malformed or ill-formed per the spec, and
74
+ * MUST be answered with `422 Unprocessable Entity` (`#server-patch-n3-invalid`).
75
+ */
76
+ export type PatchConstraint =
77
+ | "parse_error"
78
+ | "missing_type"
79
+ | "duplicate_predicate"
80
+ | "blank_node_in_template"
81
+ | "unbound_template_variable";
82
+
83
+ /**
84
+ * Thrown when a patch document does not satisfy the N3 Patch document
85
+ * constraints. Kept distinct from {@link PatchProblem} so the handler can map it
86
+ * to `422` while binding/state failures stay `409`.
87
+ */
88
+ export class PatchConstraintError extends Error {
89
+ constructor(readonly code: PatchConstraint) {
90
+ super(`@dwk/solid-pod: patch constraint ${code}`);
91
+ this.name = "PatchConstraintError";
92
+ }
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Term conversion
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /** Normalize an N3 term into a {@link PatchTerm}. */
100
+ function fromN3Term(term: Quad["object"] | Quad["subject"]): PatchTerm {
101
+ switch (term.termType) {
102
+ case "Variable":
103
+ return { kind: "var", name: term.value };
104
+ case "NamedNode":
105
+ return {
106
+ kind: "term",
107
+ term: { termType: "NamedNode", value: term.value },
108
+ };
109
+ case "BlankNode":
110
+ return {
111
+ kind: "term",
112
+ term: { termType: "BlankNode", value: term.value },
113
+ };
114
+ case "Literal": {
115
+ const literal = term as {
116
+ value: string;
117
+ language: string;
118
+ datatype: { value: string };
119
+ };
120
+ if (literal.language) {
121
+ return {
122
+ kind: "term",
123
+ term: {
124
+ termType: "Literal",
125
+ value: literal.value,
126
+ language: literal.language,
127
+ },
128
+ };
129
+ }
130
+ return {
131
+ kind: "term",
132
+ term: {
133
+ termType: "Literal",
134
+ value: literal.value,
135
+ datatype: literal.datatype.value,
136
+ },
137
+ };
138
+ }
139
+ default:
140
+ throw new PatchProblem("parse_error");
141
+ }
142
+ }
143
+
144
+ function tripleFromQuad(quad: Quad): PatchTriple {
145
+ return {
146
+ subject: fromN3Term(quad.subject),
147
+ predicate: fromN3Term(quad.predicate),
148
+ object: fromN3Term(quad.object),
149
+ };
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // N3 Patch parsing
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /**
157
+ * Collect the inner triples of the formula referenced by `(subject, predicate)`.
158
+ *
159
+ * Each of `solid:where` / `solid:inserts` / `solid:deletes` MUST appear at most
160
+ * once (Solid Protocol §5.3.1); more than one such statement is a document
161
+ * constraint violation (`#server-patch-n3-invalid`), reported as
162
+ * {@link PatchConstraintError} `duplicate_predicate`.
163
+ */
164
+ function formulaTriples(quads: Quad[], predicateIri: string): PatchTriple[] {
165
+ // The statement `_:patch solid:<predicate> { … }` references the formula as a
166
+ // blank-node graph; N3.js emits the formula's triples with that graph term.
167
+ const graphValues = new Set<string>();
168
+ let statementCount = 0;
169
+ for (const q of quads) {
170
+ if (
171
+ q.predicate.value === predicateIri &&
172
+ q.graph.termType === "DefaultGraph"
173
+ ) {
174
+ statementCount++;
175
+ if (q.object.termType === "BlankNode") graphValues.add(q.object.value);
176
+ }
177
+ }
178
+ if (statementCount > 1) {
179
+ throw new PatchConstraintError("duplicate_predicate");
180
+ }
181
+ const triples: PatchTriple[] = [];
182
+ for (const q of quads) {
183
+ if (q.graph.termType !== "DefaultGraph" && graphValues.has(q.graph.value)) {
184
+ triples.push(tripleFromQuad(q));
185
+ }
186
+ }
187
+ return triples;
188
+ }
189
+
190
+ /** Whether any term of a triple is a blank node. */
191
+ function tripleHasBlankNode(triple: PatchTriple): boolean {
192
+ return [triple.subject, triple.predicate, triple.object].some(
193
+ (t) => t.kind === "term" && t.term.termType === "BlankNode",
194
+ );
195
+ }
196
+
197
+ /** Collect the variable names a triple references. */
198
+ function collectVars(triple: PatchTriple, into: Set<string>): void {
199
+ for (const term of [triple.subject, triple.predicate, triple.object]) {
200
+ if (term.kind === "var") into.add(term.name);
201
+ }
202
+ }
203
+
204
+ /** Parse an N3 Patch document (`text/n3`) and enforce its document constraints. */
205
+ function parseN3Patch(body: string, baseIRI: string): Patch {
206
+ let quads: Quad[];
207
+ try {
208
+ quads = parseTurtle(body, { format: "text/n3", baseIRI });
209
+ } catch {
210
+ throw new PatchConstraintError("parse_error");
211
+ }
212
+
213
+ // `#server-patch-n3-simple-type`: the patch document MUST contain exactly one
214
+ // `?patch rdf:type solid:InsertDeletePatch` statement in the default graph.
215
+ const hasType = quads.some(
216
+ (q) =>
217
+ q.graph.termType === "DefaultGraph" &&
218
+ q.predicate.value === RDF_TYPE &&
219
+ q.object.termType === "NamedNode" &&
220
+ q.object.value === INSERT_DELETE_PATCH,
221
+ );
222
+ if (!hasType) {
223
+ throw new PatchConstraintError("missing_type");
224
+ }
225
+
226
+ const patch: Patch = {
227
+ where: formulaTriples(quads, `${SOLID}where`),
228
+ deletes: formulaTriples(quads, `${SOLID}deletes`),
229
+ inserts: formulaTriples(quads, `${SOLID}inserts`),
230
+ };
231
+
232
+ // `#server-patch-n3-blank-nodes`: the inserts/deletes formulae MUST NOT
233
+ // contain blank nodes.
234
+ if (
235
+ patch.inserts.some(tripleHasBlankNode) ||
236
+ patch.deletes.some(tripleHasBlankNode)
237
+ ) {
238
+ throw new PatchConstraintError("blank_node_in_template");
239
+ }
240
+
241
+ // `#server-patch-n3-variables`: every variable used in inserts/deletes MUST
242
+ // occur in `where`. Detect this statically so a template using an unbound
243
+ // variable is a document constraint violation (422), not a runtime miss.
244
+ const whereVars = new Set<string>();
245
+ for (const t of patch.where) collectVars(t, whereVars);
246
+ const templateVars = new Set<string>();
247
+ for (const t of patch.inserts) collectVars(t, templateVars);
248
+ for (const t of patch.deletes) collectVars(t, templateVars);
249
+ for (const name of templateVars) {
250
+ if (!whereVars.has(name)) {
251
+ throw new PatchConstraintError("unbound_template_variable");
252
+ }
253
+ }
254
+
255
+ return patch;
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Minimal SPARQL Update parsing
260
+ // ---------------------------------------------------------------------------
261
+
262
+ /**
263
+ * Extract `PREFIX`/`BASE` (and `@prefix`/`@base`) declaration lines verbatim.
264
+ *
265
+ * Turtle 1.1 (which N3.js parses) accepts SPARQL-style `PREFIX`/`BASE` without
266
+ * the leading `@` or a trailing `.`, so the lines are passed through as-is and
267
+ * prepended to each pattern block before parsing.
268
+ */
269
+ function prologue(body: string): string {
270
+ const matches = body.match(
271
+ /^[ \t]*(?:PREFIX|BASE|@prefix|@base)\b[^\n]*$/gim,
272
+ );
273
+ return matches ? matches.join("\n") : "";
274
+ }
275
+
276
+ /** Pull the contents of the first `KEYWORD { … }` block (brace-matched). */
277
+ function block(body: string, keyword: RegExp): string | null {
278
+ const m = keyword.exec(body);
279
+ if (!m) return null;
280
+ const open = body.indexOf("{", m.index + m[0].length - 1);
281
+ if (open < 0) return null;
282
+ let depth = 0;
283
+ for (let i = open; i < body.length; i++) {
284
+ const ch = body[i];
285
+ if (ch === "{") depth++;
286
+ else if (ch === "}" && --depth === 0) return body.slice(open + 1, i);
287
+ }
288
+ return null;
289
+ }
290
+
291
+ function parsePatternBlock(
292
+ content: string,
293
+ prefixes: string,
294
+ baseIRI: string,
295
+ ): PatchTriple[] {
296
+ try {
297
+ const quads = parseTurtle(`${prefixes}\n${content}`, {
298
+ format: "text/n3",
299
+ baseIRI,
300
+ });
301
+ return quads
302
+ .filter((q) => q.graph.termType === "DefaultGraph")
303
+ .map(tripleFromQuad);
304
+ } catch {
305
+ throw new PatchProblem("parse_error");
306
+ }
307
+ }
308
+
309
+ /** Parse the supported subset of `application/sparql-update`. */
310
+ function parseSparqlUpdate(body: string, baseIRI: string): Patch {
311
+ const prefixes = prologue(body);
312
+ const insertData = block(body, /INSERT\s+DATA\s*/i);
313
+ const deleteData = block(body, /DELETE\s+DATA\s*/i);
314
+ if (insertData !== null || deleteData !== null) {
315
+ return {
316
+ where: [],
317
+ inserts: insertData
318
+ ? parsePatternBlock(insertData, prefixes, baseIRI)
319
+ : [],
320
+ deletes: deleteData
321
+ ? parsePatternBlock(deleteData, prefixes, baseIRI)
322
+ : [],
323
+ };
324
+ }
325
+
326
+ const where = block(body, /WHERE\s*/i);
327
+ const deletes = block(body, /DELETE\s*/i);
328
+ const inserts = block(body, /INSERT\s*/i);
329
+ if (where === null && deletes === null && inserts === null) {
330
+ throw new PatchProblem("parse_error");
331
+ }
332
+ return {
333
+ where: where ? parsePatternBlock(where, prefixes, baseIRI) : [],
334
+ deletes: deletes ? parsePatternBlock(deletes, prefixes, baseIRI) : [],
335
+ inserts: inserts ? parsePatternBlock(inserts, prefixes, baseIRI) : [],
336
+ };
337
+ }
338
+
339
+ /** Parse a patch body by its `Content-Type`. */
340
+ export function parsePatch(
341
+ body: string,
342
+ contentType: string,
343
+ baseIRI: string,
344
+ ): Patch {
345
+ const essence = contentType.split(";")[0]?.trim().toLowerCase();
346
+ if (essence === "text/n3" || essence === "application/n3") {
347
+ return parseN3Patch(body, baseIRI);
348
+ }
349
+ if (
350
+ essence === "application/sparql-update" ||
351
+ essence === "application/sparql-update; charset=utf-8"
352
+ ) {
353
+ return parseSparqlUpdate(body, baseIRI);
354
+ }
355
+ throw new PatchProblem("unsupported_media_type");
356
+ }
357
+
358
+ // ---------------------------------------------------------------------------
359
+ // where-matching
360
+ // ---------------------------------------------------------------------------
361
+
362
+ type Bindings = ReadonlyMap<string, StoredTerm>;
363
+
364
+ /**
365
+ * DoS guards for the `where` solver. This is a minimal conjunctive matcher, not
366
+ * a SPARQL engine, and it runs inside the single-threaded per-pod Durable
367
+ * Object — so a crafted patch with several all-variable `where` triples against
368
+ * a large resource could otherwise build an N^k cartesian product and exhaust
369
+ * the CPU budget, stalling every request that serializes through that pod.
370
+ *
371
+ * `MAX_WHERE_TRIPLES` caps the pattern size, and `MAX_SOLVE_WORK` caps the
372
+ * total candidate-match attempts across all triples — together they bound the
373
+ * solver's cost regardless of resource size. Exceeding either is reported as
374
+ * {@link PatchProblem} `where_too_complex`.
375
+ */
376
+ const MAX_WHERE_TRIPLES = 25;
377
+ const MAX_SOLVE_WORK = 1_000_000;
378
+
379
+ /** Normalize a literal's datatype: an untyped, non-language literal is xsd:string. */
380
+ function effectiveDatatype(term: StoredTerm): string | undefined {
381
+ if (term.termType !== "Literal") return undefined;
382
+ if (term.language) return undefined;
383
+ return term.datatype ?? XSD_STRING;
384
+ }
385
+
386
+ /** Strict term equality, normalizing the implicit xsd:string datatype. */
387
+ function termsEqual(a: StoredTerm, b: StoredTerm): boolean {
388
+ if (a.termType !== b.termType || a.value !== b.value) return false;
389
+ if (a.termType === "Literal") {
390
+ return (
391
+ (a.language ?? "") === (b.language ?? "") &&
392
+ effectiveDatatype(a) === effectiveDatatype(b)
393
+ );
394
+ }
395
+ return true;
396
+ }
397
+
398
+ /** Match one pattern term against a concrete term under `bindings`. */
399
+ function matchTerm(
400
+ pattern: PatchTerm,
401
+ value: StoredTerm,
402
+ bindings: Map<string, StoredTerm>,
403
+ ): boolean {
404
+ if (pattern.kind === "var") {
405
+ const bound = bindings.get(pattern.name);
406
+ if (bound) return termsEqual(bound, value);
407
+ bindings.set(pattern.name, value);
408
+ return true;
409
+ }
410
+ return termsEqual(pattern.term, value);
411
+ }
412
+
413
+ /**
414
+ * Find solutions of the conjunctive `where` pattern against `current`, via
415
+ * straightforward backtracking. Each solution is a complete variable binding.
416
+ *
417
+ * Bounded against pattern-driven CPU exhaustion (see {@link MAX_WHERE_TRIPLES}
418
+ * and {@link MAX_SOLVE_WORK}): an over-large pattern, or one whose intermediate
419
+ * cartesian product blows the work budget, throws `where_too_complex` rather
420
+ * than enumerating it. Resolution only needs to distinguish "no bind", "exactly
421
+ * one bind", and "more than one bind", so the result is capped at two solutions
422
+ * — that is enough to detect ambiguity without materializing every binding.
423
+ */
424
+ function solve(
425
+ where: readonly PatchTriple[],
426
+ current: readonly StoredQuad[],
427
+ ): Bindings[] {
428
+ if (where.length === 0) return [new Map()];
429
+ if (where.length > MAX_WHERE_TRIPLES) {
430
+ throw new PatchProblem("where_too_complex");
431
+ }
432
+
433
+ let work = 0;
434
+ let solutions: Map<string, StoredTerm>[] = [new Map()];
435
+ for (let i = 0; i < where.length; i++) {
436
+ const triple = where[i] as PatchTriple;
437
+ const last = i === where.length - 1;
438
+ const next: Map<string, StoredTerm>[] = [];
439
+ for (const partial of solutions) {
440
+ // The variables this triple would newly bind in `partial`. `matchTerm`
441
+ // binds into the map it is given, so we match against `partial` directly
442
+ // and roll these back after each quad — cloning a fresh candidate per
443
+ // quad would, on the abort path, allocate up to MAX_SOLVE_WORK maps and
444
+ // thrash GC inside the single-threaded DO. Only successful matches clone.
445
+ const newVars: string[] = [];
446
+ for (const term of [triple.subject, triple.predicate, triple.object]) {
447
+ if (term.kind === "var" && !partial.has(term.name)) {
448
+ newVars.push(term.name);
449
+ }
450
+ }
451
+ for (const quad of current) {
452
+ if (++work > MAX_SOLVE_WORK) {
453
+ throw new PatchProblem("where_too_complex");
454
+ }
455
+ if (
456
+ matchTerm(triple.subject, quad.subject, partial) &&
457
+ matchTerm(triple.predicate, quad.predicate, partial) &&
458
+ matchTerm(triple.object, quad.object, partial)
459
+ ) {
460
+ next.push(new Map(partial));
461
+ // On the final triple, two complete solutions already prove the
462
+ // match is ambiguous; stop before enumerating the rest.
463
+ if (last && next.length > 1) return next;
464
+ }
465
+ for (const name of newVars) partial.delete(name);
466
+ }
467
+ }
468
+ if (next.length === 0) return [];
469
+ solutions = next;
470
+ }
471
+ return solutions;
472
+ }
473
+
474
+ /** Instantiate a template term under a binding into a concrete {@link StoredTerm}. */
475
+ function instantiate(term: PatchTerm, bindings: Bindings): StoredTerm {
476
+ if (term.kind === "var") {
477
+ const bound = bindings.get(term.name);
478
+ // N3 patches are checked statically in `parseN3Patch`; this backstops the
479
+ // SPARQL path, where an unbound template variable is also a document
480
+ // constraint violation (422).
481
+ if (!bound) throw new PatchConstraintError("unbound_template_variable");
482
+ return bound;
483
+ }
484
+ return term.term;
485
+ }
486
+
487
+ function instantiateTriple(
488
+ triple: PatchTriple,
489
+ bindings: Bindings,
490
+ ): StoredQuad {
491
+ return {
492
+ subject: instantiate(triple.subject, bindings),
493
+ predicate: instantiate(triple.predicate, bindings),
494
+ object: instantiate(triple.object, bindings),
495
+ graph: DEFAULT_GRAPH,
496
+ };
497
+ }
498
+
499
+ /** Whether `current` contains a quad equal to `target` (default graph). */
500
+ function contains(current: readonly StoredQuad[], target: StoredQuad): boolean {
501
+ return current.some(
502
+ (q) =>
503
+ termsEqual(q.subject, target.subject) &&
504
+ termsEqual(q.predicate, target.predicate) &&
505
+ termsEqual(q.object, target.object),
506
+ );
507
+ }
508
+
509
+ /**
510
+ * Resolve a parsed {@link Patch} against the resource's `current` quads into a
511
+ * concrete delete/insert pair.
512
+ *
513
+ * @throws {PatchProblem}
514
+ * - `no_match` when `where` binds to no solution;
515
+ * - `ambiguous_match` when it binds to more than one;
516
+ * - `delete_not_found` when a resolved delete triple is absent;
517
+ * - `where_too_complex` when the `where` pattern exceeds the solver's bounds.
518
+ * @throws {PatchConstraintError}
519
+ * - `unbound_template_variable` when a template uses an unbound variable
520
+ * (backstop for the SPARQL path; N3 patches are checked at parse time).
521
+ */
522
+ export function resolvePatch(
523
+ patch: Patch,
524
+ current: readonly StoredQuad[],
525
+ ): ResolvedPatch {
526
+ const solutions = solve(patch.where, current);
527
+ if (solutions.length === 0) throw new PatchProblem("no_match");
528
+ if (solutions.length > 1) throw new PatchProblem("ambiguous_match");
529
+ const bindings = solutions[0] as Bindings;
530
+
531
+ const deletes = patch.deletes.map((t) => instantiateTriple(t, bindings));
532
+ const inserts = patch.inserts.map((t) => instantiateTriple(t, bindings));
533
+
534
+ for (const d of deletes) {
535
+ if (!contains(current, d)) throw new PatchProblem("delete_not_found");
536
+ }
537
+
538
+ return { deletes, inserts, insertOnly: deletes.length === 0 };
539
+ }