@graffiti-garden/implementation-local 0.4.3 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graffiti-garden/implementation-local",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "A local implementation of the Graffiti API using PouchDB",
5
5
  "types": "./dist/index.d.ts",
6
6
  "module": "./dist/esm/index.js",
@@ -73,6 +73,7 @@
73
73
  "url": "https://github.com/graffiti-garden/implementation-local/issues"
74
74
  },
75
75
  "devDependencies": {
76
+ "@vitest/coverage-v8": "^3.0.6",
76
77
  "esbuild": "^0.25.0",
77
78
  "esbuild-plugin-polyfill-node": "^0.3.0",
78
79
  "tsx": "^4.19.2",
@@ -80,7 +81,7 @@
80
81
  "vitest": "^3.0.5"
81
82
  },
82
83
  "dependencies": {
83
- "@graffiti-garden/api": "^0.4.1",
84
+ "@graffiti-garden/api": "^0.5.0",
84
85
  "@repeaterjs/repeater": "^3.0.6",
85
86
  "@types/pouchdb": "^6.4.2",
86
87
  "ajv": "^8.17.1",
package/src/database.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  GraffitiObjectBase,
4
4
  GraffitiLocation,
5
5
  JSONSchema,
6
+ GraffitiSession,
6
7
  } from "@graffiti-garden/api";
7
8
  import {
8
9
  GraffitiErrorNotFound,
@@ -11,14 +12,13 @@ import {
11
12
  GraffitiErrorPatchError,
12
13
  } from "@graffiti-garden/api";
13
14
  import {
14
- locationToUri,
15
- unpackLocationOrUri,
16
15
  randomBase64,
17
16
  applyGraffitiPatch,
18
17
  maskGraffitiObject,
19
18
  isActorAllowedGraffitiObject,
20
19
  isObjectNewer,
21
20
  compileGraffitiObjectSchema,
21
+ unpackLocationOrUri,
22
22
  } from "./utilities.js";
23
23
  import { Repeater } from "@repeaterjs/repeater";
24
24
  import type Ajv from "ajv";
@@ -37,19 +37,19 @@ export interface GraffitiLocalOptions {
37
37
  */
38
38
  pouchDBOptions?: PouchDB.Configuration.DatabaseConfiguration;
39
39
  /**
40
- * Defines the name of the {@link https://api.graffiti.garden/interfaces/GraffitiObjectBase.html#source | `source` }
41
- * under which to store objects.
42
- * Defaults to `"local"`.
40
+ * Includes the scheme and other information (possibly domain name)
41
+ * to prefix prefixes all URIs put in the system. Defaults to `graffiti:local`.
43
42
  */
44
- sourceName?: string;
43
+ origin?: string;
45
44
  /**
46
- * Whether to allow putting objects with a different than the
47
- * default source name. Defaults to `false`.
45
+ * Whether to allow putting objects at arbtirary URIs, i.e.
46
+ * URIs that are *not* prefixed with the origin or not generated
47
+ * by the system. Defaults to `false`.
48
48
  *
49
49
  * Allows this implementation to be used as a client-side cache
50
50
  * for remote sources.
51
51
  */
52
- allowOtherSources?: boolean;
52
+ allowSettingArbitraryUris?: boolean;
53
53
  /**
54
54
  * Whether to allow the user to set the lastModified field
55
55
  * when putting objects. Defaults to `false`.
@@ -72,7 +72,7 @@ export interface GraffitiLocalOptions {
72
72
  }
73
73
 
74
74
  const DEFAULT_TOMBSTONE_RETENTION = 86400000; // 1 day in milliseconds
75
- const DEFAULT_SOURCE_NAME = "local";
75
+ const DEFAULT_ORIGIN = "graffiti:local:";
76
76
 
77
77
  /**
78
78
  * An implementation of only the database operations of the
@@ -95,6 +95,7 @@ export class GraffitiLocalDatabase
95
95
  protected applyPatch_: Promise<typeof applyPatch> | undefined;
96
96
  protected ajv_: Promise<Ajv> | undefined;
97
97
  protected readonly options: GraffitiLocalOptions;
98
+ protected readonly origin: string;
98
99
 
99
100
  get db() {
100
101
  if (!this.db_) {
@@ -201,10 +202,14 @@ export class GraffitiLocalDatabase
201
202
 
202
203
  constructor(options?: GraffitiLocalOptions) {
203
204
  this.options = options ?? {};
205
+ this.origin = this.options.origin ?? DEFAULT_ORIGIN;
206
+ if (!this.origin.endsWith(":") && !this.origin.endsWith("/")) {
207
+ this.origin += "/";
208
+ }
204
209
  }
205
210
 
206
- protected async queryByLocation(location: GraffitiLocation) {
207
- const uri = locationToUri(location) + "/";
211
+ protected async allDocsAtLocation(locationOrUri: GraffitiLocation | string) {
212
+ const uri = unpackLocationOrUri(locationOrUri) + "/";
208
213
  const results = await (
209
214
  await this.db
210
215
  ).allDocs({
@@ -227,20 +232,22 @@ export class GraffitiLocalDatabase
227
232
  }
228
233
 
229
234
  protected docId(location: GraffitiLocation) {
230
- return locationToUri(location) + "/" + randomBase64();
235
+ return location.uri + "/" + randomBase64();
231
236
  }
232
237
 
233
238
  get: Graffiti["get"] = async (...args) => {
234
239
  const [locationOrUri, schema, session] = args;
235
- const { location } = unpackLocationOrUri(locationOrUri);
236
240
 
237
- const docsAll = await this.queryByLocation(location);
241
+ const docsAll = await this.allDocsAtLocation(locationOrUri);
238
242
 
239
243
  // Filter out ones not allowed
240
244
  const docs = docsAll.filter((doc) =>
241
245
  isActorAllowedGraffitiObject(doc, session),
242
246
  );
243
- if (!docs.length) throw new GraffitiErrorNotFound();
247
+ if (!docs.length)
248
+ throw new GraffitiErrorNotFound(
249
+ "The object you are trying to get either does not exist or you are not allowed to see it",
250
+ );
244
251
 
245
252
  // Get the most recent document
246
253
  const doc = docs.reduce((a, b) => (isObjectNewer(a, b) ? a : b));
@@ -268,11 +275,35 @@ export class GraffitiLocalDatabase
268
275
  * spared.
269
276
  */
270
277
  protected async deleteAtLocation(
271
- location: GraffitiLocation,
272
- keepLatest: boolean = false,
278
+ locationOrUri: GraffitiLocation | string,
279
+ options: {
280
+ keepLatest?: boolean;
281
+ session?: GraffitiSession;
282
+ } = {
283
+ keepLatest: false,
284
+ },
273
285
  ) {
274
- const docsAtLocationAll = await this.queryByLocation(location);
275
- const docsAtLocation = docsAtLocationAll.filter((doc) => !doc.tombstone);
286
+ const docsAtLocationAll = await this.allDocsAtLocation(locationOrUri);
287
+ const docsAtLocationAllowed = options.session
288
+ ? docsAtLocationAll.filter((doc) =>
289
+ isActorAllowedGraffitiObject(doc, options.session),
290
+ )
291
+ : docsAtLocationAll;
292
+ if (!docsAtLocationAllowed.length) {
293
+ throw new GraffitiErrorNotFound(
294
+ "The object you are trying to delete either does not exist or you are not allowed to see it",
295
+ );
296
+ } else if (
297
+ options.session &&
298
+ docsAtLocationAllowed.some((doc) => doc.actor !== options.session?.actor)
299
+ ) {
300
+ throw new GraffitiErrorForbidden(
301
+ "You cannot delete an object owned by another actor",
302
+ );
303
+ }
304
+ const docsAtLocation = docsAtLocationAllowed.filter(
305
+ (doc) => !doc.tombstone,
306
+ );
276
307
  if (!docsAtLocation.length) return undefined;
277
308
 
278
309
  // Get the most recent lastModified timestamp.
@@ -282,14 +313,14 @@ export class GraffitiLocalDatabase
282
313
 
283
314
  // Delete all old docs
284
315
  const docsToDelete = docsAtLocation.filter(
285
- (doc) => !keepLatest || doc.lastModified < latestModified,
316
+ (doc) => !options.keepLatest || doc.lastModified < latestModified,
286
317
  );
287
318
 
288
319
  // For docs with the same timestamp,
289
320
  // keep the one with the highest _id
290
321
  // to break concurrency ties
291
322
  const concurrentDocsAll = docsAtLocation.filter(
292
- (doc) => keepLatest && doc.lastModified === latestModified,
323
+ (doc) => options.keepLatest && doc.lastModified === latestModified,
293
324
  );
294
325
  if (concurrentDocsAll.length) {
295
326
  const keepDocId = concurrentDocsAll
@@ -301,7 +332,9 @@ export class GraffitiLocalDatabase
301
332
  docsToDelete.push(...concurrentDocsToDelete);
302
333
  }
303
334
 
304
- const lastModified = keepLatest ? latestModified : new Date().getTime();
335
+ const lastModified = options.keepLatest
336
+ ? latestModified
337
+ : new Date().getTime();
305
338
 
306
339
  const deleteResults = await (
307
340
  await this.db
@@ -336,14 +369,11 @@ export class GraffitiLocalDatabase
336
369
 
337
370
  delete: Graffiti["delete"] = async (...args) => {
338
371
  const [locationOrUri, session] = args;
339
- const { location } = unpackLocationOrUri(locationOrUri);
340
- if (location.actor !== session.actor) {
341
- throw new GraffitiErrorForbidden();
342
- }
343
-
344
- const deletedObject = await this.deleteAtLocation(location);
372
+ const deletedObject = await this.deleteAtLocation(locationOrUri, {
373
+ session,
374
+ });
345
375
  if (!deletedObject) {
346
- throw new GraffitiErrorNotFound();
376
+ throw new GraffitiErrorNotFound("The object has already been deleted");
347
377
  }
348
378
  return deletedObject;
349
379
  };
@@ -351,19 +381,33 @@ export class GraffitiLocalDatabase
351
381
  put: Graffiti["put"] = async (...args) => {
352
382
  const [objectPartial, session] = args;
353
383
  if (objectPartial.actor && objectPartial.actor !== session.actor) {
354
- throw new GraffitiErrorForbidden();
355
- }
356
- if (
357
- objectPartial.source &&
358
- objectPartial.source !==
359
- (this.options.sourceName ?? DEFAULT_SOURCE_NAME) &&
360
- !(this.options.allowOtherSources ?? false)
361
- ) {
362
384
  throw new GraffitiErrorForbidden(
363
- "Putting an object that does not match this source",
385
+ "Cannot put an object with a different actor than the session actor",
364
386
  );
365
387
  }
366
388
 
389
+ if (objectPartial.uri) {
390
+ let oldObject: GraffitiObjectBase | undefined;
391
+ try {
392
+ oldObject = await this.get(objectPartial.uri, {}, session);
393
+ } catch (e) {
394
+ if (e instanceof GraffitiErrorNotFound) {
395
+ if (!this.options.allowSettingArbitraryUris) {
396
+ throw new GraffitiErrorNotFound(
397
+ "The object you are trying to replace does not exist or you are not allowed to see it",
398
+ );
399
+ }
400
+ } else {
401
+ throw e;
402
+ }
403
+ }
404
+ if (oldObject?.actor !== session.actor) {
405
+ throw new GraffitiErrorForbidden(
406
+ "The object you are trying to replace is owned by another actor",
407
+ );
408
+ }
409
+ }
410
+
367
411
  const lastModified =
368
412
  ((this.options.allowSettinngLastModified ?? false) &&
369
413
  objectPartial.lastModified) ||
@@ -373,9 +417,7 @@ export class GraffitiLocalDatabase
373
417
  value: objectPartial.value,
374
418
  channels: objectPartial.channels,
375
419
  allowed: objectPartial.allowed,
376
- name: objectPartial.name ?? randomBase64(),
377
- source:
378
- objectPartial.source ?? this.options.sourceName ?? DEFAULT_SOURCE_NAME,
420
+ uri: objectPartial.uri ?? this.origin + randomBase64(),
379
421
  actor: session.actor,
380
422
  tombstone: false,
381
423
  lastModified,
@@ -389,7 +431,9 @@ export class GraffitiLocalDatabase
389
431
  });
390
432
 
391
433
  // Delete the old object
392
- const previousObject = await this.deleteAtLocation(object, true);
434
+ const previousObject = await this.deleteAtLocation(object, {
435
+ keepLatest: true,
436
+ });
393
437
  if (previousObject) {
394
438
  return previousObject;
395
439
  } else {
@@ -397,7 +441,7 @@ export class GraffitiLocalDatabase
397
441
  ...object,
398
442
  value: {},
399
443
  channels: [],
400
- allowed: undefined,
444
+ allowed: [],
401
445
  tombstone: true,
402
446
  };
403
447
  }
@@ -405,12 +449,23 @@ export class GraffitiLocalDatabase
405
449
 
406
450
  patch: Graffiti["patch"] = async (...args) => {
407
451
  const [patch, locationOrUri, session] = args;
408
- const { location } = unpackLocationOrUri(locationOrUri);
409
- if (location.actor !== session.actor) {
410
- throw new GraffitiErrorForbidden();
452
+ let originalObject: GraffitiObjectBase;
453
+ try {
454
+ originalObject = await this.get(locationOrUri, {}, session);
455
+ } catch (e) {
456
+ if (e instanceof GraffitiErrorNotFound) {
457
+ throw new GraffitiErrorNotFound(
458
+ "The object you are trying to patch does not exist or you are not allowed to see it",
459
+ );
460
+ } else {
461
+ throw e;
462
+ }
411
463
  }
412
- const originalObject = await this.get(locationOrUri, {}, session);
413
- if (originalObject.tombstone) {
464
+ if (originalObject.actor !== session.actor) {
465
+ throw new GraffitiErrorForbidden(
466
+ "The object you are trying to patch is owned by another actor",
467
+ );
468
+ } else if (originalObject.tombstone) {
414
469
  throw new GraffitiErrorNotFound(
415
470
  "The object you are trying to patch has been deleted",
416
471
  );
@@ -461,7 +516,9 @@ export class GraffitiLocalDatabase
461
516
  });
462
517
 
463
518
  // Delete the old object
464
- await this.deleteAtLocation(patchObject, true);
519
+ await this.deleteAtLocation(patchObject, {
520
+ keepLatest: true,
521
+ });
465
522
 
466
523
  return {
467
524
  ...originalObject,
package/src/index.ts CHANGED
@@ -4,7 +4,6 @@ import {
4
4
  GraffitiLocalDatabase,
5
5
  type GraffitiLocalOptions,
6
6
  } from "./database.js";
7
- import { locationToUri, uriToLocation } from "./utilities.js";
8
7
 
9
8
  export type { GraffitiLocalOptions };
10
9
 
@@ -16,9 +15,6 @@ export type { GraffitiLocalOptions };
16
15
  * although using it with a remote server will not be secure.
17
16
  */
18
17
  export class GraffitiLocal extends Graffiti {
19
- locationToUri = locationToUri;
20
- uriToLocation = uriToLocation;
21
-
22
18
  protected sessionManagerLocal = new GraffitiLocalSessionManager();
23
19
  login = this.sessionManagerLocal.login.bind(this.sessionManagerLocal);
24
20
  logout = this.sessionManagerLocal.logout.bind(this.sessionManagerLocal);
package/src/tests.spec.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import {
2
- graffitiLocationTests,
3
2
  graffitiCRUDTests,
4
3
  graffitiDiscoverTests,
5
4
  graffitiOrphanTests,
@@ -11,7 +10,6 @@ const useGraffiti = () => new GraffitiLocal();
11
10
  const useSession1 = () => ({ actor: "someone" });
12
11
  const useSession2 = () => ({ actor: "someoneelse" });
13
12
 
14
- graffitiLocationTests(useGraffiti);
15
13
  graffitiCRUDTests(useGraffiti, useSession1, useSession2);
16
14
  graffitiDiscoverTests(useGraffiti, useSession1, useSession2);
17
15
  graffitiOrphanTests(useGraffiti, useSession1, useSession2);
package/src/utilities.ts CHANGED
@@ -5,36 +5,21 @@ import {
5
5
  GraffitiErrorPatchTestFailed,
6
6
  } from "@graffiti-garden/api";
7
7
  import type {
8
- Graffiti,
9
8
  GraffitiObject,
10
9
  GraffitiObjectBase,
11
- GraffitiLocation,
12
10
  GraffitiPatch,
13
11
  JSONSchema,
14
12
  GraffitiSession,
13
+ GraffitiLocation,
15
14
  } from "@graffiti-garden/api";
16
15
  import type { Ajv } from "ajv";
17
16
  import type { applyPatch } from "fast-json-patch";
18
17
 
19
- export const locationToUri: Graffiti["locationToUri"] = (location) => {
20
- return `${location.source}/${encodeURIComponent(location.actor)}/${encodeURIComponent(location.name)}`;
21
- };
22
-
23
- export const uriToLocation: Graffiti["uriToLocation"] = (uri) => {
24
- const parts = uri.split("/");
25
- const nameEncoded = parts.pop();
26
- const webIdEncoded = parts.pop();
27
- if (!nameEncoded || !webIdEncoded || !parts.length) {
28
- throw new GraffitiErrorInvalidUri();
29
- }
30
- return {
31
- name: decodeURIComponent(nameEncoded),
32
- actor: decodeURIComponent(webIdEncoded),
33
- source: parts.join("/"),
34
- };
35
- };
18
+ export function unpackLocationOrUri(locationOrUri: GraffitiLocation | string) {
19
+ return typeof locationOrUri === "string" ? locationOrUri : locationOrUri.uri;
20
+ }
36
21
 
37
- export function randomBase64(numBytes: number = 16) {
22
+ export function randomBase64(numBytes: number = 24) {
38
23
  const bytes = new Uint8Array(numBytes);
39
24
  crypto.getRandomValues(bytes);
40
25
  // Convert it to base64
@@ -43,24 +28,6 @@ export function randomBase64(numBytes: number = 16) {
43
28
  return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/\=+$/, "");
44
29
  }
45
30
 
46
- export function unpackLocationOrUri(locationOrUri: GraffitiLocation | string) {
47
- if (typeof locationOrUri === "string") {
48
- return {
49
- location: uriToLocation(locationOrUri),
50
- uri: locationOrUri,
51
- };
52
- } else {
53
- return {
54
- location: {
55
- name: locationOrUri.name,
56
- actor: locationOrUri.actor,
57
- source: locationOrUri.source,
58
- },
59
- uri: locationToUri(locationOrUri),
60
- };
61
- }
62
- }
63
-
64
31
  export function isObjectNewer(
65
32
  left: GraffitiObjectBase,
66
33
  right: GraffitiObjectBase,