@graffiti-garden/implementation-local 0.4.1 → 0.4.2

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/src/database.ts CHANGED
@@ -10,7 +10,6 @@ import {
10
10
  GraffitiErrorForbidden,
11
11
  GraffitiErrorPatchError,
12
12
  } from "@graffiti-garden/api";
13
- import PouchDB from "pouchdb";
14
13
  import {
15
14
  locationToUri,
16
15
  unpackLocationOrUri,
@@ -22,8 +21,8 @@ import {
22
21
  compileGraffitiObjectSchema,
23
22
  } from "./utilities.js";
24
23
  import { Repeater } from "@repeaterjs/repeater";
25
- import Ajv from "ajv";
26
- import { applyPatch } from "fast-json-patch";
24
+ import type Ajv from "ajv";
25
+ import type { applyPatch } from "fast-json-patch";
27
26
 
28
27
  /**
29
28
  * Constructor options for the GraffitiPoubchDB class.
@@ -43,6 +42,22 @@ export interface GraffitiLocalOptions {
43
42
  * Defaults to `"local"`.
44
43
  */
45
44
  sourceName?: string;
45
+ /**
46
+ * Whether to allow putting objects with a different than the
47
+ * default source name. Defaults to `false`.
48
+ *
49
+ * Allows this implementation to be used as a client-side cache
50
+ * for remote sources.
51
+ */
52
+ allowOtherSources?: boolean;
53
+ /**
54
+ * Whether to allow the user to set the lastModified field
55
+ * when putting objects. Defaults to `false`.
56
+ *
57
+ * Allows this implementation to be used as a client-side cache
58
+ * for remote sources.
59
+ */
60
+ allowSettinngLastModified?: boolean;
46
61
  /**
47
62
  * The time in milliseconds to keep tombstones before deleting them.
48
63
  * See the {@link https://api.graffiti.garden/classes/Graffiti.html#discover | `discover` }
@@ -51,10 +66,14 @@ export interface GraffitiLocalOptions {
51
66
  tombstoneRetention?: number;
52
67
  /**
53
68
  * An optional Ajv instance to use for schema validation.
69
+ * If not provided, an internal instance will be created.
54
70
  */
55
71
  ajv?: Ajv;
56
72
  }
57
73
 
74
+ const DEFAULT_TOMBSTONE_RETENTION = 86400000; // 1 day in milliseconds
75
+ const DEFAULT_SOURCE_NAME = "local";
76
+
58
77
  /**
59
78
  * An implementation of only the database operations of the
60
79
  * GraffitiAPI without synchronization or session management.
@@ -72,91 +91,121 @@ export class GraffitiLocalDatabase
72
91
  | "channelStats"
73
92
  >
74
93
  {
75
- protected readonly db: PouchDB.Database<GraffitiObjectBase>;
76
- protected readonly source: string = "local";
77
- protected readonly tombstoneRetention: number = 86400000; // 1 day in ms
78
- protected readonly ajv: Ajv;
94
+ protected db_: Promise<PouchDB.Database<GraffitiObjectBase>> | undefined;
95
+ protected applyPatch_: Promise<typeof applyPatch> | undefined;
96
+ protected ajv_: Promise<Ajv> | undefined;
97
+ protected readonly options: GraffitiLocalOptions;
98
+
99
+ get db() {
100
+ if (!this.db_) {
101
+ this.db_ = (async () => {
102
+ const { default: PouchDB } = await import("pouchdb");
103
+ const pouchDbOptions = {
104
+ name: "graffitiDb",
105
+ ...this.options.pouchDBOptions,
106
+ };
107
+ const db = new PouchDB<GraffitiObjectBase>(
108
+ pouchDbOptions.name,
109
+ pouchDbOptions,
110
+ );
111
+ await db
112
+ //@ts-ignore
113
+ .put({
114
+ _id: "_design/indexes",
115
+ views: {
116
+ objectsPerChannelAndLastModified: {
117
+ map: function (object: GraffitiObjectBase) {
118
+ const paddedLastModified = object.lastModified
119
+ .toString()
120
+ .padStart(15, "0");
121
+ object.channels.forEach(function (channel) {
122
+ const id =
123
+ encodeURIComponent(channel) + "/" + paddedLastModified;
124
+ //@ts-ignore
125
+ emit(id);
126
+ });
127
+ }.toString(),
128
+ },
129
+ orphansPerActorAndLastModified: {
130
+ map: function (object: GraffitiObjectBase) {
131
+ if (object.channels.length === 0) {
132
+ const paddedLastModified = object.lastModified
133
+ .toString()
134
+ .padStart(15, "0");
135
+ const id =
136
+ encodeURIComponent(object.actor) +
137
+ "/" +
138
+ paddedLastModified;
139
+ //@ts-ignore
140
+ emit(id);
141
+ }
142
+ }.toString(),
143
+ },
144
+ channelStatsPerActor: {
145
+ map: function (object: GraffitiObjectBase) {
146
+ if (object.tombstone) return;
147
+ object.channels.forEach(function (channel) {
148
+ const id =
149
+ encodeURIComponent(object.actor) +
150
+ "/" +
151
+ encodeURIComponent(channel);
152
+ //@ts-ignore
153
+ emit(id, object.lastModified);
154
+ });
155
+ }.toString(),
156
+ reduce: "_stats",
157
+ },
158
+ },
159
+ })
160
+ //@ts-ignore
161
+ .catch((error) => {
162
+ if (
163
+ error &&
164
+ typeof error === "object" &&
165
+ "name" in error &&
166
+ error.name === "conflict"
167
+ ) {
168
+ // Design document already exists
169
+ return;
170
+ } else {
171
+ throw error;
172
+ }
173
+ });
174
+ return db;
175
+ })();
176
+ }
177
+ return this.db_;
178
+ }
79
179
 
80
- constructor(options?: GraffitiLocalOptions) {
81
- this.ajv = options?.ajv ?? new Ajv({ strict: false });
82
- this.source = options?.sourceName ?? this.source;
83
- this.tombstoneRetention =
84
- options?.tombstoneRetention ?? this.tombstoneRetention;
85
- const pouchDbOptions = {
86
- name: "graffitiDb",
87
- ...options?.pouchDBOptions,
88
- };
89
- this.db = new PouchDB<GraffitiObjectBase>(
90
- pouchDbOptions.name,
91
- pouchDbOptions,
92
- );
180
+ get applyPatch() {
181
+ if (!this.applyPatch_) {
182
+ this.applyPatch_ = (async () => {
183
+ const { applyPatch } = await import("fast-json-patch");
184
+ return applyPatch;
185
+ })();
186
+ }
187
+ return this.applyPatch_;
188
+ }
93
189
 
94
- this.db
95
- //@ts-ignore
96
- .put({
97
- _id: "_design/indexes",
98
- views: {
99
- objectsPerChannelAndLastModified: {
100
- map: function (object: GraffitiObjectBase) {
101
- const paddedLastModified = object.lastModified
102
- .toString()
103
- .padStart(15, "0");
104
- object.channels.forEach(function (channel) {
105
- const id =
106
- encodeURIComponent(channel) + "/" + paddedLastModified;
107
- //@ts-ignore
108
- emit(id);
109
- });
110
- }.toString(),
111
- },
112
- orphansPerActorAndLastModified: {
113
- map: function (object: GraffitiObjectBase) {
114
- if (object.channels.length === 0) {
115
- const paddedLastModified = object.lastModified
116
- .toString()
117
- .padStart(15, "0");
118
- const id =
119
- encodeURIComponent(object.actor) + "/" + paddedLastModified;
120
- //@ts-ignore
121
- emit(id);
122
- }
123
- }.toString(),
124
- },
125
- channelStatsPerActor: {
126
- map: function (object: GraffitiObjectBase) {
127
- if (object.tombstone) return;
128
- object.channels.forEach(function (channel) {
129
- const id =
130
- encodeURIComponent(object.actor) +
131
- "/" +
132
- encodeURIComponent(channel);
133
- //@ts-ignore
134
- emit(id, object.lastModified);
135
- });
136
- }.toString(),
137
- reduce: "_stats",
138
- },
139
- },
140
- })
141
- //@ts-ignore
142
- .catch((error) => {
143
- if (
144
- error &&
145
- typeof error === "object" &&
146
- "name" in error &&
147
- error.name === "conflict"
148
- ) {
149
- // Design document already exists
150
- return;
151
- } else {
152
- throw error;
153
- }
154
- });
190
+ get ajv() {
191
+ if (!this.ajv_) {
192
+ this.ajv_ = (async () => {
193
+ const { default: Ajv } = await import("ajv");
194
+ return new Ajv({ strict: false });
195
+ })();
196
+ }
197
+ return this.ajv_;
198
+ }
199
+
200
+ constructor(options?: GraffitiLocalOptions) {
201
+ this.options = options ?? {};
155
202
  }
156
203
 
157
204
  protected async queryByLocation(location: GraffitiLocation) {
158
205
  const uri = locationToUri(location) + "/";
159
- const results = await this.db.allDocs({
206
+ const results = await (
207
+ await this.db
208
+ ).allDocs({
160
209
  startkey: uri,
161
210
  endkey: uri + "\uffff", // \uffff is the last unicode character
162
211
  include_docs: true,
@@ -201,7 +250,7 @@ export class GraffitiLocalDatabase
201
250
  // if the user is not the owner
202
251
  maskGraffitiObject(object, [], session);
203
252
 
204
- const validate = compileGraffitiObjectSchema(this.ajv, schema);
253
+ const validate = compileGraffitiObjectSchema(await this.ajv, schema);
205
254
  if (!validate(object)) {
206
255
  throw new GraffitiErrorSchemaMismatch();
207
256
  }
@@ -252,7 +301,9 @@ export class GraffitiLocalDatabase
252
301
 
253
302
  const lastModified = keepLatest ? latestModified : new Date().getTime();
254
303
 
255
- const deleteResults = await this.db.bulkDocs<GraffitiObjectBase>(
304
+ const deleteResults = await (
305
+ await this.db
306
+ ).bulkDocs<GraffitiObjectBase>(
256
307
  docsToDelete.map((doc) => ({
257
308
  ...doc,
258
309
  tombstone: true,
@@ -300,24 +351,37 @@ export class GraffitiLocalDatabase
300
351
  if (objectPartial.actor && objectPartial.actor !== session.actor) {
301
352
  throw new GraffitiErrorForbidden();
302
353
  }
303
- if (objectPartial.source && objectPartial.source !== this.source) {
354
+ if (
355
+ objectPartial.source &&
356
+ objectPartial.source !==
357
+ (this.options.sourceName ?? DEFAULT_SOURCE_NAME) &&
358
+ !(this.options.allowOtherSources ?? false)
359
+ ) {
304
360
  throw new GraffitiErrorForbidden(
305
361
  "Putting an object that does not match this source",
306
362
  );
307
363
  }
308
364
 
365
+ const lastModified =
366
+ ((this.options.allowSettinngLastModified ?? false) &&
367
+ objectPartial.lastModified) ||
368
+ new Date().getTime();
369
+
309
370
  const object: GraffitiObjectBase = {
310
371
  value: objectPartial.value,
311
372
  channels: objectPartial.channels,
312
373
  allowed: objectPartial.allowed,
313
374
  name: objectPartial.name ?? randomBase64(),
314
- source: this.source,
375
+ source:
376
+ objectPartial.source ?? this.options.sourceName ?? DEFAULT_SOURCE_NAME,
315
377
  actor: session.actor,
316
378
  tombstone: false,
317
- lastModified: new Date().getTime(),
379
+ lastModified,
318
380
  };
319
381
 
320
- await this.db.put({
382
+ await (
383
+ await this.db
384
+ ).put({
321
385
  _id: this.docId(object),
322
386
  ...object,
323
387
  });
@@ -353,7 +417,7 @@ export class GraffitiLocalDatabase
353
417
  // Patch it outside of the database
354
418
  const patchObject: GraffitiObjectBase = { ...originalObject };
355
419
  for (const prop of ["value", "channels", "allowed"] as const) {
356
- applyGraffitiPatch(applyPatch, prop, patch, patchObject);
420
+ applyGraffitiPatch(await this.applyPatch, prop, patch, patchObject);
357
421
  }
358
422
 
359
423
  // Make sure the value is an object
@@ -387,7 +451,9 @@ export class GraffitiLocalDatabase
387
451
  }
388
452
 
389
453
  patchObject.lastModified = new Date().getTime();
390
- await this.db.put({
454
+ await (
455
+ await this.db
456
+ ).put({
391
457
  ...patchObject,
392
458
  _id: this.docId(patchObject),
393
459
  });
@@ -451,7 +517,6 @@ export class GraffitiLocalDatabase
451
517
 
452
518
  discover: Graffiti["discover"] = (...args) => {
453
519
  const [channels, schema, session] = args;
454
- const validate = compileGraffitiObjectSchema(this.ajv, schema);
455
520
 
456
521
  const { startKeySuffix, endKeySuffix } =
457
522
  this.queryLastModifiedSuffixes(schema);
@@ -459,6 +524,8 @@ export class GraffitiLocalDatabase
459
524
  const repeater: ReturnType<
460
525
  typeof Graffiti.prototype.discover<typeof schema>
461
526
  > = new Repeater(async (push, stop) => {
527
+ const validate = compileGraffitiObjectSchema(await this.ajv, schema);
528
+
462
529
  const processedIds = new Set<string>();
463
530
 
464
531
  for (const channel of channels) {
@@ -466,7 +533,9 @@ export class GraffitiLocalDatabase
466
533
  const startkey = keyPrefix + startKeySuffix;
467
534
  const endkey = keyPrefix + endKeySuffix;
468
535
 
469
- const result = await this.db.query<GraffitiObjectBase>(
536
+ const result = await (
537
+ await this.db
538
+ ).query<GraffitiObjectBase>(
470
539
  "indexes/objectsPerChannelAndLastModified",
471
540
  { startkey, endkey, include_docs: true },
472
541
  );
@@ -497,7 +566,8 @@ export class GraffitiLocalDatabase
497
566
  }
498
567
  stop();
499
568
  return {
500
- tombstoneRetention: this.tombstoneRetention,
569
+ tombstoneRetention:
570
+ this.options.tombstoneRetention ?? DEFAULT_TOMBSTONE_RETENTION,
501
571
  };
502
572
  });
503
573
 
@@ -505,8 +575,6 @@ export class GraffitiLocalDatabase
505
575
  };
506
576
 
507
577
  recoverOrphans: Graffiti["recoverOrphans"] = (schema, session) => {
508
- const validate = compileGraffitiObjectSchema(this.ajv, schema);
509
-
510
578
  const { startKeySuffix, endKeySuffix } =
511
579
  this.queryLastModifiedSuffixes(schema);
512
580
  const keyPrefix = encodeURIComponent(session.actor) + "/";
@@ -516,10 +584,15 @@ export class GraffitiLocalDatabase
516
584
  const repeater: ReturnType<
517
585
  typeof Graffiti.prototype.recoverOrphans<typeof schema>
518
586
  > = new Repeater(async (push, stop) => {
519
- const result = await this.db.query<GraffitiObjectBase>(
520
- "indexes/orphansPerActorAndLastModified",
521
- { startkey, endkey, include_docs: true },
522
- );
587
+ const validate = compileGraffitiObjectSchema(await this.ajv, schema);
588
+
589
+ const result = await (
590
+ await this.db
591
+ ).query<GraffitiObjectBase>("indexes/orphansPerActorAndLastModified", {
592
+ startkey,
593
+ endkey,
594
+ include_docs: true,
595
+ });
523
596
 
524
597
  for (const row of result.rows) {
525
598
  const doc = row.doc;
@@ -535,7 +608,8 @@ export class GraffitiLocalDatabase
535
608
  }
536
609
  stop();
537
610
  return {
538
- tombstoneRetention: this.tombstoneRetention,
611
+ tombstoneRetention:
612
+ this.options.tombstoneRetention ?? DEFAULT_TOMBSTONE_RETENTION,
539
613
  };
540
614
  });
541
615
 
@@ -546,7 +620,9 @@ export class GraffitiLocalDatabase
546
620
  const repeater: ReturnType<typeof Graffiti.prototype.channelStats> =
547
621
  new Repeater(async (push, stop) => {
548
622
  const keyPrefix = encodeURIComponent(session.actor) + "/";
549
- const result = await this.db.query("indexes/channelStatsPerActor", {
623
+ const result = await (
624
+ await this.db
625
+ ).query("indexes/channelStatsPerActor", {
550
626
  startkey: keyPrefix,
551
627
  endkey: keyPrefix + "\uffff",
552
628
  reduce: true,