@le-space/orbitdb-storage-bridge 0.14.1 → 0.16.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.
@@ -17,15 +17,84 @@
17
17
  * all three. Returns an unsubscribe function.
18
18
  *
19
19
  * Wire messages (dag-cbor encoded, one per courier payload):
20
- * { v, tag, p, t: "announce", heads: [hash] }
20
+ * { v, tag, p, t: "announce", heads: [hash], r? } r: please reconcile
21
21
  * { v, tag, p, t: "want", cids: [hash], have: [hash] }
22
22
  * { v, tag, p, t: "blocks", heads: [hash], blocks: [{ hash, bytes }] }
23
+ * { v, tag, p, t: "op", id: bytes8, o: { op, key, value } } one change
23
24
  * { v, tag, p, t: "hello" } is anybody keeping this database out there?
24
25
  * { v, tag, p, t: "here" } the answer
25
26
  * `tag` is a short hash of the database address, so couriers can be shared
26
27
  * between databases without cross-talk while the address itself stays off
27
28
  * the air (the mesh reads everything).
28
29
  *
30
+ * Two ways to carry a change, and the cheap one is not replication.
31
+ *
32
+ * `announce`/`want`/`blocks` move the log itself: signed entries, their
33
+ * ancestry, the identity that vouches for them. That is real OrbitDB
34
+ * replication — every peer ends up with the same log and the same hashes — and
35
+ * it is what a cold join needs, because a peer that has never seen the database
36
+ * has to be given the manifest and the whole history.
37
+ *
38
+ * It is also expensive out of all proportion to a todo. The operation inside
39
+ * that entry — `{op: "PUT", key, value}`, which OrbitDB hands to every
40
+ * `update` listener already — is the whole of what anyone meant to send.
41
+ *
42
+ * Measured over funkpost's Meshtastic courier, framing, ARQ and duty-cycle
43
+ * pacing included, for one further change to a list both sides already hold:
44
+ *
45
+ * | plane | bytes on the air |
46
+ * | ------------ | ---------------- |
47
+ * | `delta` | 1774 B |
48
+ * | `operations` | 86 B |
49
+ *
50
+ * Twenty times. On a carrier moving half a kilobyte a minute that is three and
51
+ * a half minutes against ten seconds, for one ticked box.
52
+ *
53
+ * So `op` ships the operation and lets the far side perform it locally, as its
54
+ * own write, with its own identity. What that buys and what it costs:
55
+ *
56
+ * - The two logs **diverge**: each device holds its own entry for the same
57
+ * change, with a different hash. For a keyvalue database the materialised
58
+ * view still agrees, and once an IP path returns OrbitDB's own sync merges
59
+ * both logs and the divergence becomes history rather than disagreement.
60
+ * - Two devices editing the **same key** while both are offline can show
61
+ * different values until that merge, because each applies the other's
62
+ * operation after its own. A CRDT would not have this; an oplog with
63
+ * last-write-wins does. For a shared todo list it is an acceptable trade
64
+ * and it must be a deliberate one.
65
+ * - **The receiver must be allowed to write.** This is the requirement that
66
+ * surprises: on the delta plane the far side only *stores* an entry someone
67
+ * else signed, so a database whose access controller names one writer
68
+ * replicates fine. Here the far side performs the change as its own write,
69
+ * so an access controller that excludes it refuses the operation outright —
70
+ * *"Key … is not allowed to write to the log"*. `write: ["*"]` makes it
71
+ * work, which is what funkpost's mesh-todo already does.
72
+ *
73
+ * That is also where a pairing between two devices belongs, rather than in
74
+ * a new protocol of its own: two devices that have met put each other's
75
+ * OrbitDB identity in the write set, and "anyone holding the channel key"
76
+ * becomes "these two". The access controller is the authorisation
77
+ * mechanism this plane needs, and it already exists.
78
+ * - Nothing is signed end to end. The receiver vouches for the change with
79
+ * its own identity, so trust rests entirely on the carrier — which on a
80
+ * Meshtastic channel means everyone holding the channel key. The sender id
81
+ * in `p` is not authentication and must never be read as such.
82
+ * - Reconciliation is asked for, not inferred: `announce()` sets `r` on the
83
+ * wire and the far side answers with a delta. An announce without it is
84
+ * noted and left alone, because divergent logs are this plane's ordinary
85
+ * state rather than a gap.
86
+ * - A lost `op` is simply lost; there is no ancestry to notice the hole.
87
+ * Note that `send`'s own comment — that a dropped message is re-derivable
88
+ * because a peer still wanting it asks again — holds for `announce`, `want`
89
+ * and `blocks` and **not** for `op`: an operation the outbox sheds under
90
+ * `maxOutbox` takes its change with it.
91
+ * `announce()` is the repair: it runs the delta plane, which is complete by
92
+ * construction. Cheap path for the ordinary change, expensive path when
93
+ * something has to be made right.
94
+ *
95
+ * `liveUpdates: "operations"` opts in. The default stays `"delta"`, so nothing
96
+ * changes for a consumer that does not ask.
97
+ *
29
98
  * `p` is a four-byte sender id, and it is what makes *presence* possible: a
30
99
  * carrier can tell you a radio is in range, which is not the question. The
31
100
  * question is whether another program is keeping the same database, and only
@@ -52,6 +121,20 @@ export const COURIER_SYNC_VERSION = 1;
52
121
 
53
122
  const TAG_LENGTH = 8;
54
123
 
124
+ // Eight bytes naming a change, derived from the entry hash so that every
125
+ // retransmission of the same change carries the same id and applies once.
126
+ const CHANGE_ID_LENGTH = 8;
127
+ // How many change ids to remember. A duplicate arriving after this many others
128
+ // is applied twice: one extra entry in the log, not a wrong value.
129
+ const SEEN_OPS = 512;
130
+ // How many operations may wait for a deliberate send before the batch goes as
131
+ // a delta instead. Not a memory limit: the outbox holds `maxOutbox` (32) and
132
+ // sheds the oldest when it overflows, and an operation is the one message here
133
+ // that is *not* re-derivable — so a long batch of them would lose writes
134
+ // quietly. A delta is one message and complete by construction, which is the
135
+ // right shape once a batch is large enough to threaten that.
136
+ const MAX_PENDING_OPS = 16;
137
+
55
138
  // Four bytes of sender id: enough that two peers in one conversation collide
56
139
  // with probability ~1 in 4 billion, small enough to ride on every message.
57
140
  const PEER_ID_LENGTH = 4;
@@ -185,6 +268,19 @@ function isOplogEntry(value) {
185
268
  * Reading the whole ancestry locally to avoid transmitting it is a good trade
186
269
  * on any carrier: the reads are a blockstore away, the bytes are airtime.
187
270
  */
271
+ /**
272
+ * Eight bytes naming one change, the same on every retransmission of it.
273
+ *
274
+ * Derived from the entry hash rather than from the operation, so that two
275
+ * genuinely separate writes of the same value — ticking a box off and on and
276
+ * off again — stay separate changes, while a duplicate delivery of one write
277
+ * is recognised and applied once.
278
+ */
279
+ async function changeId(entryHash) {
280
+ const digest = await sha256.digest(new TextEncoder().encode(entryHash));
281
+ return digest.digest.slice(0, CHANGE_ID_LENGTH);
282
+ }
283
+
188
284
  async function reachableFrom(db, roots) {
189
285
  const held = new Set();
190
286
  const queue = [...roots];
@@ -478,6 +574,11 @@ async function applyDeltaToStores({ blockstore, log, events, delta }) {
478
574
  * @param {string} [params.address] Database address, required when `db` is not given
479
575
  * @param {Object} params.courier The byte courier (see module docs)
480
576
  * @param {Object} [params.dbOptions] Extra options for the lazy `orbitdb.open`
577
+ * @param {"delta"|"operations"} [params.liveUpdates="delta"] How a local write
578
+ * travels. `"delta"` announces and lets the peer ask, which moves signed
579
+ * entries and keeps both logs identical. `"operations"` ships the operation
580
+ * itself — thirteen times smaller, at the cost of divergent logs and no
581
+ * end-to-end signature. See the module docs before choosing it.
481
582
  * @param {boolean} [params.announceOnLocalUpdate=true] Announce as soon as a
482
583
  * local write lands. Default keeps the eager behaviour. Set false where the
483
584
  * courier is expensive — a duty-cycled radio, say — and the application would
@@ -512,6 +613,7 @@ export async function createCourierSync({
512
613
  dbOptions = {},
513
614
  rejoinIntervalMs = 15000,
514
615
  announceOnLocalUpdate = true,
616
+ liveUpdates = "delta",
515
617
  peerId = randomPeerId(),
516
618
  peerTimeoutMs = PEER_TIMEOUT_MS,
517
619
  sendTimeoutMs = SEND_TIMEOUT_MS,
@@ -527,6 +629,9 @@ export async function createCourierSync({
527
629
  if (!(peerId instanceof Uint8Array) || peerId.length !== PEER_ID_LENGTH) {
528
630
  throw new Error(`peerId must be ${PEER_ID_LENGTH} bytes`);
529
631
  }
632
+ if (liveUpdates !== "delta" && liveUpdates !== "operations") {
633
+ throw new Error('liveUpdates must be "delta" or "operations"');
634
+ }
530
635
  const databaseAddress = address || (db && db.address);
531
636
  if (!databaseAddress) {
532
637
  throw new Error("Either an open db or a database address is required");
@@ -542,6 +647,13 @@ export async function createCourierSync({
542
647
  const peers = new Map(); // sender id (hex) -> when we last heard it
543
648
  let lastHeardAt = null; // any traffic for this database, identified or not
544
649
  const listeners = { synced: [], applied: [], message: [], error: [] };
650
+ // Written locally and not yet sent, oldest first. Only fills when the
651
+ // operation plane is on and the application has opted out of sending on
652
+ // every write — the batching case, where a button decides.
653
+ const pendingOps = [];
654
+ // Change ids already applied, oldest first. Insertion-ordered, so the oldest
655
+ // key is the first one Map iteration yields.
656
+ const seenOps = new Map();
545
657
  let database = db;
546
658
  // Opened on first contact but not handed out: the bootstrap is not in it yet.
547
659
  // The protocol works on it all the same, so repair stays incremental.
@@ -726,7 +838,13 @@ export async function createCourierSync({
726
838
  const ourHeadHashes = async (target = local()) =>
727
839
  target ? (await target.log.heads()).map((entry) => entry.hash) : [];
728
840
 
729
- const announce = async () => {
841
+ /**
842
+ * @param {boolean} [reconcile] Ask the peer to close the gap, not merely to
843
+ * note where we stand. Only the operation plane distinguishes the two:
844
+ * there, divergent logs are the ordinary state rather than a gap, so a
845
+ * reconciliation has to be asked for or it would run after every change.
846
+ */
847
+ const announce = async (reconcile = false) => {
730
848
  if (!local()) {
731
849
  // Nothing local yet — not even the manifest. An announce of empty heads
732
850
  // cannot get one from a peer whose log is also empty, so first contact
@@ -735,7 +853,34 @@ export async function createCourierSync({
735
853
  await send({ t: "want", cids: [], have: [] });
736
854
  return;
737
855
  }
738
- await send({ t: "announce", heads: await ourHeadHashes() });
856
+ const message = { t: "announce", heads: await ourHeadHashes() };
857
+ if (reconcile) message.r = true;
858
+ await send(message);
859
+ };
860
+
861
+ /**
862
+ * Everything written since the last deliberate send, as operations.
863
+ *
864
+ * Answers how many went, so the caller can tell "I sent your changes" from
865
+ * "there was nothing to send, so I asked the peer to reconcile" — which is
866
+ * what an announce means when the queue is empty.
867
+ *
868
+ * Past `MAX_PENDING_OPS` the batch goes as a delta instead. Not arbitrary:
869
+ * the outbox holds `maxOutbox` messages and sheds the oldest, and an
870
+ * operation is the one message here that cannot be re-derived, so a long
871
+ * batch of them would lose writes without saying so. One delta is one
872
+ * message and carries all of it.
873
+ */
874
+ const flushOperations = async () => {
875
+ if (pendingOps.length === 0) return 0;
876
+ const going = pendingOps.splice(0, pendingOps.length);
877
+ if (going.length > MAX_PENDING_OPS) {
878
+ emit("message", { direction: "out", type: "batch-too-long", bytes: 0 });
879
+ await announce(true);
880
+ return going.length;
881
+ }
882
+ for (const entry of going) await shipOperation(entry);
883
+ return going.length;
739
884
  };
740
885
 
741
886
  /**
@@ -769,15 +914,26 @@ export async function createCourierSync({
769
914
  };
770
915
 
771
916
  const watchLocalUpdates = () => {
772
- // Opted out: the application announces when it decides to, not when a write
773
- // happens. Incoming announces are still answered, so a peer asking for
774
- // blocks is served — going quiet must not mean going deaf.
775
- if (!announceOnLocalUpdate) return;
917
+ // On the delta plane, opting out means not watching at all: the application
918
+ // announces when it decides to. On the operation plane it means something
919
+ // else — the write still has to be *remembered*, or the deliberate send has
920
+ // nothing to send. So the hook goes on either way there, and only what it
921
+ // does with the entry changes.
922
+ //
923
+ // Incoming announces are still answered in both cases: going quiet must not
924
+ // mean going deaf.
925
+ if (!announceOnLocalUpdate && liveUpdates !== "operations") return;
776
926
  if (!database || offUpdate) return;
777
- const onUpdate = () => {
927
+ const onUpdate = (entry) => {
778
928
  if (applying) return; // courier-applied entries already end in an announce
929
+ if (liveUpdates === "operations" && !announceOnLocalUpdate) {
930
+ pendingOps.push(entry);
931
+ return; // waits for the application to ask
932
+ }
779
933
  queue = queue
780
- .then(() => announceSoon())
934
+ .then(() =>
935
+ liveUpdates === "operations" ? shipOperation(entry) : announceSoon(),
936
+ )
781
937
  .catch((error) => emit("error", error));
782
938
  };
783
939
  database.events.on("update", onUpdate);
@@ -796,6 +952,16 @@ export async function createCourierSync({
796
952
  );
797
953
  return;
798
954
  }
955
+ // On the operation plane both sides write their own entry for the same
956
+ // change, so the peer's heads being strangers to us is the ordinary state
957
+ // and not a gap to close. Without this, the acknowledgement that ends a
958
+ // blocks delivery reads as a reconcile request, and on logs that are
959
+ // permanently divergent each answer earns another: measured over the memory
960
+ // courier at nine messages for one reconcile against six with it. Both
961
+ // terminate — this is chatter, not a loop — and it does not change what an
962
+ // operation costs, which is one message either way.
963
+ if (liveUpdates === "operations" && !message.r) return;
964
+
799
965
  const ours = await ourHeadHashes();
800
966
  const theirSet = new Set(theirHeads);
801
967
  const theyLack = ours.filter((hash) => !theirSet.has(hash));
@@ -887,8 +1053,114 @@ export async function createCourierSync({
887
1053
  emit("synced", { joined: result.joined, entries: result.entries });
888
1054
  }
889
1055
  // Tells the peer where we now stand — their diff turns empty and the
890
- // exchange goes quiet; doubles as an end-to-end acknowledgement.
891
- await announce();
1056
+ // exchange goes quiet; doubles as an end-to-end acknowledgement. Not a
1057
+ // reconcile request: two peers answering each other's would never stop.
1058
+ await announce(false);
1059
+ };
1060
+
1061
+ /**
1062
+ * Remember a change id. Answers whether it was new.
1063
+ *
1064
+ * Our own changes are remembered as they go out, so a mesh repeating us —
1065
+ * or a peer echoing the operation onward — cannot make us perform our own
1066
+ * write a second time.
1067
+ */
1068
+ const rememberOp = (id) => {
1069
+ const key = hex(id);
1070
+ if (seenOps.has(key)) return false;
1071
+ seenOps.set(key, Date.now());
1072
+ while (seenOps.size > SEEN_OPS) seenOps.delete(seenOps.keys().next().value);
1073
+ return true;
1074
+ };
1075
+
1076
+ /**
1077
+ * Perform someone else's change as our own write.
1078
+ *
1079
+ * Only the operations a keyvalue, documents or events database emits. A type
1080
+ * whose operations are named differently is not carried this way, and saying
1081
+ * so out loud is better than writing something that silently does nothing.
1082
+ */
1083
+ const performOperation = async (target, op) => {
1084
+ const run = () => {
1085
+ if (op.op === "PUT") return target.put(op.key, op.value);
1086
+ if (op.op === "DEL") return target.del(op.key);
1087
+ if (op.op === "ADD") return target.add(op.value);
1088
+ throw new Error(`cannot perform operation ${op.op} over the courier`);
1089
+ };
1090
+ try {
1091
+ return await run();
1092
+ } catch (error) {
1093
+ // The refusal every consumer of this plane meets first, and OrbitDB's own
1094
+ // message does not say why it is happening here rather than on the delta
1095
+ // plane. Name the cause where it is read.
1096
+ if (/not allowed to write/i.test(error?.message || "")) {
1097
+ throw new Error(
1098
+ "the operation plane performs the change as a local write, so this " +
1099
+ "device must be in the database's write set — open it with an " +
1100
+ 'access controller that admits it (mesh-todo uses write: ["*"]), ' +
1101
+ 'or stay on liveUpdates: "delta"',
1102
+ { cause: error },
1103
+ );
1104
+ }
1105
+ throw error;
1106
+ }
1107
+ };
1108
+
1109
+ /** One local change, on its way as an operation rather than as a log entry. */
1110
+ const shipOperation = async (entry) => {
1111
+ const op = entry && entry.payload;
1112
+ if (!op || typeof op.op !== "string" || !entry.hash) return;
1113
+ const id = await changeId(entry.hash);
1114
+ rememberOp(id); // ours: never perform it on ourselves
1115
+ post({ t: "op", id, o: op });
1116
+ };
1117
+
1118
+ const handleOperation = async (message) => {
1119
+ const id = message.id;
1120
+ const op = message.o;
1121
+ if (!(id instanceof Uint8Array) || id.length !== CHANGE_ID_LENGTH) return;
1122
+ if (!op || typeof op.op !== "string") return;
1123
+
1124
+ const target = local();
1125
+ // An operation carries no manifest, so it cannot bootstrap a database. Ask
1126
+ // for the whole thing instead and let the delta plane answer.
1127
+ if (!target) {
1128
+ post({ t: "want", cids: [], have: [] }, { to: senderOf(message) });
1129
+ return;
1130
+ }
1131
+
1132
+ const report = (applied) =>
1133
+ emit("applied", {
1134
+ complete: true,
1135
+ heads: 1,
1136
+ missing: 0,
1137
+ joined: applied ? 1 : 0,
1138
+ held: applied ? 0 : 1,
1139
+ absent: 0,
1140
+ malformed: 0,
1141
+ refused: 0,
1142
+ via: "operation",
1143
+ });
1144
+
1145
+ // Duplicates are ordinary on a broadcast carrier with retransmission, and
1146
+ // performing one twice would put a second entry in the log for a change
1147
+ // that already happened.
1148
+ if (!rememberOp(id)) return report(false);
1149
+
1150
+ // The write we are about to make fires the database's own "update", which
1151
+ // is what an application listens to — so the far side's list moves without
1152
+ // anything else being wired. The guard is what stops that same event from
1153
+ // sending the change straight back out.
1154
+ applying = true;
1155
+ try {
1156
+ await performOperation(target, op);
1157
+ } finally {
1158
+ applying = false;
1159
+ }
1160
+ // No "synced": that event carries the entries that were joined, and nothing
1161
+ // was joined here — the database performed a write of its own. "applied"
1162
+ // is where a courier delivery reports what it came to.
1163
+ report(true);
892
1164
  };
893
1165
 
894
1166
  const handlePayload = (bytes) => {
@@ -917,6 +1189,7 @@ export async function createCourierSync({
917
1189
  if (message.t === "announce") return handleAnnounce(message);
918
1190
  if (message.t === "want") return handleWant(message);
919
1191
  if (message.t === "blocks") return handleBlocks(message);
1192
+ if (message.t === "op") return handleOperation(message);
920
1193
  })
921
1194
  .catch((error) => emit("error", error));
922
1195
  };
@@ -935,7 +1208,9 @@ export async function createCourierSync({
935
1208
  started = true;
936
1209
  unsubscribe = courier.onPayload(handlePayload);
937
1210
  watchLocalUpdates();
938
- await announce();
1211
+ // A start reconciles: whatever happened while this program was not
1212
+ // running is exactly what the delta plane is for. Once per session.
1213
+ await announce(true);
939
1214
  // A joiner that has not bootstrapped keeps re-asking on its own until
940
1215
  // the database opens — so a bootstrap the lossy channel dropped heals
941
1216
  // without the user pressing "join" again. Cleared the moment the
@@ -953,7 +1228,18 @@ export async function createCourierSync({
953
1228
  }
954
1229
  },
955
1230
  /** Re-announce — recovery poke after suspected loss. */
956
- announce: () => announce(),
1231
+ /**
1232
+ * What an application's "send" button means.
1233
+ *
1234
+ * With changes waiting, it sends those — that is what the press was about.
1235
+ * With none, it is a request to reconcile, which is what it always was.
1236
+ * Internal announces never flush: the acknowledgement that ends a blocks
1237
+ * delivery must not spend somebody's queued writes on its own.
1238
+ */
1239
+ announce: async () => {
1240
+ if ((await flushOperations()) > 0) return;
1241
+ await announce(true);
1242
+ },
957
1243
  /** This instance's sender id, as it appears on the wire. */
958
1244
  peerId: hex(peerId),
959
1245
  /**
@@ -256,3 +256,76 @@ export async function extractDatabaseBlocks(database, options = {}) {
256
256
  logger.info(` 📊 Extracted ${blocks.size} total blocks`);
257
257
  return { blocks, blockSources, manifestCID };
258
258
  }
259
+
260
+ /**
261
+ * Several databases in one backup: every block of each, in one Map, and the
262
+ * metadata that names them all.
263
+ *
264
+ * The metadata is the shape `backupDatabaseCAR` writes and `isValidMetadata`
265
+ * accepts — `databases` was always a list, and here it finally holds more than
266
+ * one. Each entry also names the database's **heads**: `restoreFromBlocks` can
267
+ * find them from the blocks alone, but a stated head says which entries the
268
+ * writer considered current when the backup ran, and costs a few bytes.
269
+ *
270
+ * Blocks two databases share — a writer's identity — are kept once: the Map is
271
+ * keyed by the CID string OrbitDB uses, and the same block has the same name.
272
+ *
273
+ * @param {Object[]|Object<string, Object>} databases opened OrbitDB databases,
274
+ * as a list or by name
275
+ * @param {Object} [options]
276
+ * @param {(progress: { stage: "database", index: number, total: number,
277
+ * name: string, address: string, entries: number, blocks: number }) => void}
278
+ * [options.onProgress] called after each database, with what it added
279
+ * @param {number} [options.timestamp] ms since the epoch; injected for tests
280
+ * @returns {Promise<{ blocks: Map<string, { cid: CID, bytes: Uint8Array }>,
281
+ * metadata: Object }>}
282
+ */
283
+ export async function bundleDatabases(databases, options = {}) {
284
+ const list = Array.isArray(databases) ? databases : Object.values(databases ?? {});
285
+ if (list.length === 0) throw new Error("bundleDatabases needs at least one database");
286
+
287
+ const blocks = new Map();
288
+ const described = [];
289
+ let totalEntries = 0;
290
+
291
+ for (const [index, database] of list.entries()) {
292
+ const before = blocks.size;
293
+ const { blocks: own, manifestCID } = await extractDatabaseBlocks(database);
294
+ for (const [name, block] of own) if (!blocks.has(name)) blocks.set(name, block);
295
+
296
+ const entries = (await database.log.values()).length;
297
+ const heads = (await database.log.heads()).map((head) => head.hash);
298
+ totalEntries += entries;
299
+ described.push({
300
+ address: database.address,
301
+ name: database.name,
302
+ type: database.type,
303
+ manifestCID,
304
+ entryCount: entries,
305
+ heads,
306
+ });
307
+ options.onProgress?.({
308
+ stage: "database",
309
+ index,
310
+ total: list.length,
311
+ name: database.name,
312
+ address: database.address,
313
+ entries,
314
+ blocks: blocks.size - before,
315
+ });
316
+ }
317
+
318
+ return {
319
+ blocks,
320
+ metadata: {
321
+ version: "1.0",
322
+ timestamp: options.timestamp ?? Date.now(),
323
+ databaseCount: described.length,
324
+ totalBlocks: blocks.size,
325
+ totalEntries,
326
+ // The first database's, so a reader of the one-database shape still finds one.
327
+ manifestCID: described[0].manifestCID,
328
+ databases: described,
329
+ },
330
+ };
331
+ }