@larc-iu/plaid-client 0.2.0-alpha.18 → 0.2.0-alpha.20

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.
@@ -0,0 +1,27 @@
1
+ // A write sent under an Idempotency-Key whose request the server had already
2
+ // run (its first answer was lost, and the client sent it again) is answered
3
+ // from what that first send stored, with `Idempotent-Replayed: true`, and
4
+ // writes nothing. The client resends on its own (retryUnknown), so the header
5
+ // never reaches the caller. It is marked on the answer instead: `replayed`,
6
+ // true and not enumerable, so the answer reads, compares and serializes as it
7
+ // would unmarked. Only an answer with a JSON object or list body can carry it.
8
+ // Imports nothing.
9
+
10
+ /** `value` marked as replayed, when it is an object or a list. */
11
+ export function markReplayed(value) {
12
+ if (value !== null && typeof value === "object") {
13
+ Object.defineProperty(value, "replayed", {
14
+ value: true,
15
+ enumerable: false,
16
+ configurable: true,
17
+ });
18
+ }
19
+ return value;
20
+ }
21
+
22
+ /** Whether a client answer was replayed from its key's first send. */
23
+ export const wasReplayed = (answer) =>
24
+ answer !== null &&
25
+ typeof answer === "object" &&
26
+ Object.getOwnPropertyDescriptor(answer, "replayed")?.enumerable === false &&
27
+ answer.replayed === true;
package/src/roles.js CHANGED
@@ -34,6 +34,23 @@ export const ROLE_KEY = 'role';
34
34
  */
35
35
  export const PRESERVE_ON_SPLIT_KEY = 'preserveOnSplit';
36
36
 
37
+ /**
38
+ * Layer config key (true) asking Plaid to take a text edit plainly on the
39
+ * layer: an edit inside a token, or touching its edge with no whitespace
40
+ * between, grows or shrinks it, a typed space never splits it, a deleted one
41
+ * never joins two, and only a token whose whole text is deleted goes. See the
42
+ * manual's `Changing a text's body`.
43
+ */
44
+ export const PLAIN_EDITS_KEY = 'plainEdits';
45
+
46
+ /**
47
+ * Layer config key (true), beside PLAIN_EDITS_KEY: a space typed inside a
48
+ * token of the text's words splits it, the token going on the half sharing
49
+ * more letters with it. Declared on a word layer another app shares, it holds
50
+ * for the whole text.
51
+ */
52
+ export const SPLIT_ON_SPACE_KEY = 'splitOnSpace';
53
+
37
54
  export const ROLES = Object.freeze({
38
55
  BASELINE: 'baseline',
39
56
  SENTENCE: 'sentence',
package/src/services.js CHANGED
@@ -30,6 +30,50 @@ const redact = (text, secrets) => {
30
30
  return out.trim();
31
31
  };
32
32
 
33
+ /** A write sent to the server whose answer never came back. */
34
+ export const UNKNOWN_OUTCOME =
35
+ 'The Plaid server did not answer. This change may or may not have been saved.';
36
+
37
+ /** The server was never reached, so nothing was sent. */
38
+ export const UNREACHABLE = 'The Plaid server could not be reached.';
39
+
40
+ // A few reads travel as a POST, and saved nothing either way (the Python twin
41
+ // and plaid-ui's errors.js keep the same list).
42
+ const READ_POSTS = /\/api\/v1\/(?:query|login|invites\/lookup)(?:[?#]|$)/;
43
+
44
+ // Node's fetch (undici) names a connection that never opened by its code: a
45
+ // refused port, a name that did not resolve, a connect timeout. Nothing was
46
+ // sent then. A browser's fetch says only "Failed to fetch", which cannot be
47
+ // told apart from a reset, so it counts as sent.
48
+ const NEVER_CONNECTED = new Set([
49
+ 'ECONNREFUSED',
50
+ 'ENOTFOUND',
51
+ 'EAI_AGAIN',
52
+ 'EHOSTUNREACH',
53
+ 'ENETUNREACH',
54
+ 'UND_ERR_CONNECT_TIMEOUT',
55
+ ]);
56
+
57
+ const neverConnected = (error) => {
58
+ const seen = new Set();
59
+ let cause = error && error.originalError;
60
+ while (cause && typeof cause === 'object' && !seen.has(cause)) {
61
+ seen.add(cause);
62
+ if (NEVER_CONNECTED.has(cause.code)) return true;
63
+ cause = cause.cause;
64
+ }
65
+ return false;
66
+ };
67
+
68
+ const networkFailure = (error) => {
69
+ const method = String(error.method || '').toUpperCase();
70
+ if (neverConnected(error)) return UNREACHABLE;
71
+ if (method && method !== 'GET' && method !== 'HEAD' && !READ_POSTS.test(String(error.url || ''))) {
72
+ return UNKNOWN_OUTCOME;
73
+ }
74
+ return 'The Plaid server did not answer.';
75
+ };
76
+
33
77
  /**
34
78
  * One line about a failure that is safe to show the person who asked.
35
79
  *
@@ -38,13 +82,17 @@ const redact = (text, secrets) => {
38
82
  * own error text). The whole error still goes to the operator's console: this
39
83
  * is the requester's half only. The Python twin is `requester_message`.
40
84
  *
85
+ * Only a connection that never opened is "could not be reached". A write whose
86
+ * answer never came (a reset, a timeout) may have been saved, and says so
87
+ * (`UNKNOWN_OUTCOME`).
88
+ *
41
89
  * @param {any} error
42
90
  * @param {string[]} [secrets]
43
91
  * @returns {string}
44
92
  */
45
93
  export function requesterMessage(error, secrets = []) {
46
94
  if (error && typeof error.status === 'number') {
47
- if (!error.status) return 'The Plaid server could not be reached.';
95
+ if (!error.status) return networkFailure(error);
48
96
  let text = String(error.message || '');
49
97
  if (error.url) text = text.split(` at ${error.url}`).join('').split(error.url).join('');
50
98
  text = text.trim().replace(/[\s,:;]+$/, '');
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Text edit operations: composing a stream of ops into the net change.
3
+ *
4
+ * An op is one of the shapes `PATCH /texts/:id` takes:
5
+ *
6
+ * { type: 'insert', index, value: string }
7
+ * { type: 'delete', index, value: count }
8
+ * { type: 'replace', index, length, value: string }
9
+ *
10
+ * Every index and count is in Unicode CODE POINTS (see ./codepoint.js), and
11
+ * the ops are in running coordinates: each op's `index` is in the body as the
12
+ * ops before it left it.
13
+ *
14
+ * A GAP is `{ start, end, value }` in the OLD body's coordinates: old
15
+ * [start, end) gives way to `value`. `composeTextEdits` turns any op stream
16
+ * into gaps that depend only on the net change, never on how the keystrokes
17
+ * came, which is also how the server reads an edit. The Python client's
18
+ * `plaid_client.text_edits` is the twin of this module.
19
+ */
20
+
21
+ const codePoints = (s) => [...s];
22
+
23
+ function opName(op) {
24
+ return JSON.stringify(op);
25
+ }
26
+
27
+ /**
28
+ * The op's type after checking its shape and bounds against a body of `len`
29
+ * code points, as the server checks it. Throws an Error otherwise.
30
+ */
31
+ function checkOp(op, len) {
32
+ const type = op && op.type;
33
+ const { index, value, length } = op || {};
34
+ const int = Number.isInteger;
35
+ const wellFormed =
36
+ (type === 'insert' && int(index) && typeof value === 'string') ||
37
+ (type === 'delete' && int(index) && int(value)) ||
38
+ (type === 'replace' && int(index) && int(length) && typeof value === 'string');
39
+ if (!wellFormed) {
40
+ throw new Error(
41
+ `Malformed text edit operation: ${opName(op)}. Expected {type: "insert", index: int, ` +
42
+ `value: string}, {type: "delete", index: int, value: int} or {type: "replace", ` +
43
+ `index: int, length: int, value: string}`,
44
+ );
45
+ }
46
+ const inBounds =
47
+ type === 'insert'
48
+ ? index >= 0 && index <= len
49
+ : type === 'delete'
50
+ ? index >= 0 && value >= 0 && index + value <= len
51
+ : index >= 0 && length >= 0 && index + length <= len;
52
+ if (!inBounds) {
53
+ throw new Error(
54
+ `Text edit operation out of bounds: ${opName(op)} (text length is ${len} code points)`,
55
+ );
56
+ }
57
+ return type;
58
+ }
59
+
60
+ // How many code points an op removes and what it types.
61
+ function opParts(type, op) {
62
+ if (type === 'insert') return [0, op.value];
63
+ if (type === 'delete') return [op.value, ''];
64
+ return [op.length, op.value];
65
+ }
66
+
67
+ const segLength = (seg) => (seg.old ? seg.end - seg.start : seg.chars.length);
68
+
69
+ // The segment list of the new text, cut so that a segment boundary falls at
70
+ // code point `at`. Returns the index of the first segment at or after `at`.
71
+ function cutAt(segs, at) {
72
+ let pos = 0;
73
+ for (let i = 0; i < segs.length; i += 1) {
74
+ const seg = segs[i];
75
+ const n = segLength(seg);
76
+ if (at === pos) return i;
77
+ if (at < pos + n) {
78
+ const k = at - pos;
79
+ const left = seg.old
80
+ ? { old: true, start: seg.start, end: seg.start + k }
81
+ : { old: false, chars: seg.chars.slice(0, k) };
82
+ const right = seg.old
83
+ ? { old: true, start: seg.start + k, end: seg.end }
84
+ : { old: false, chars: seg.chars.slice(k) };
85
+ segs.splice(i, 1, left, right);
86
+ return i + 1;
87
+ }
88
+ pos += n;
89
+ }
90
+ return segs.length;
91
+ }
92
+
93
+ /**
94
+ * The net change of `ops` applied in turn to `body`, as gaps in the old
95
+ * body's code points: sorted, and never touching (at least one old code point
96
+ * is kept between two gaps). Old text deleted and typed back is typed text,
97
+ * and a gap whose value is the old text it removes is dropped. Throws on a
98
+ * malformed or out-of-bounds op.
99
+ */
100
+ export function composeTextEdits(body, ops) {
101
+ const old = codePoints(body);
102
+ let segs = old.length ? [{ old: true, start: 0, end: old.length }] : [];
103
+ let len = old.length;
104
+ for (const op of ops) {
105
+ const type = checkOp(op, len);
106
+ const [del, typed] = opParts(type, op);
107
+ const chars = codePoints(typed);
108
+ const from = cutAt(segs, op.index);
109
+ const to = cutAt(segs, op.index + del);
110
+ const inserted = chars.length ? [{ old: false, chars }] : [];
111
+ segs.splice(from, to - from, ...inserted);
112
+ len += chars.length - del;
113
+ segs = mergeTyped(segs);
114
+ }
115
+
116
+ const gaps = [];
117
+ let kept = 0; // the old position the last kept run ended at
118
+ let typed = [];
119
+ const close = (next) => {
120
+ if (next > kept || typed.length) {
121
+ const value = typed.join('');
122
+ if (value !== old.slice(kept, next).join('')) gaps.push({ start: kept, end: next, value });
123
+ }
124
+ typed = [];
125
+ };
126
+ for (const seg of segs) {
127
+ if (seg.old) {
128
+ close(seg.start);
129
+ kept = seg.end;
130
+ } else {
131
+ typed.push(...seg.chars);
132
+ }
133
+ }
134
+ close(old.length);
135
+ return gaps;
136
+ }
137
+
138
+ // Neighbouring typed segments as one, and neighbouring old ranges as one.
139
+ function mergeTyped(segs) {
140
+ const out = [];
141
+ for (const seg of segs) {
142
+ const last = out[out.length - 1];
143
+ if (last && !last.old && !seg.old) {
144
+ out[out.length - 1] = { old: false, chars: last.chars.concat(seg.chars) };
145
+ } else if (last && last.old && seg.old && last.end === seg.start) {
146
+ out[out.length - 1] = { old: true, start: last.start, end: seg.end };
147
+ } else if (segLength(seg) > 0) {
148
+ out.push(seg);
149
+ }
150
+ }
151
+ return out;
152
+ }
153
+
154
+ /**
155
+ * Gaps (old-body coordinates, sorted, not overlapping) as running ops: an
156
+ * insert where a gap removes nothing, a delete where it types nothing, and a
157
+ * replace otherwise.
158
+ */
159
+ export function gapsToOps(gaps) {
160
+ const ops = [];
161
+ let shift = 0;
162
+ for (const { start, end, value } of gaps) {
163
+ const index = start + shift;
164
+ const typed = codePoints(value).length;
165
+ if (start === end) {
166
+ if (typed) ops.push({ type: 'insert', index, value });
167
+ } else if (!typed) {
168
+ ops.push({ type: 'delete', index, value: end - start });
169
+ } else {
170
+ ops.push({ type: 'replace', index, length: end - start, value });
171
+ }
172
+ shift += typed - (end - start);
173
+ }
174
+ return ops;
175
+ }
176
+
177
+ /**
178
+ * `body` with `ops` applied in turn (running coordinates, code points).
179
+ * Throws on a malformed or out-of-bounds op.
180
+ */
181
+ export function applyTextOps(body, ops) {
182
+ let chars = codePoints(body);
183
+ for (const op of ops) {
184
+ const type = checkOp(op, chars.length);
185
+ const [del, typed] = opParts(type, op);
186
+ chars = chars
187
+ .slice(0, op.index)
188
+ .concat(codePoints(typed), chars.slice(op.index + del));
189
+ }
190
+ return chars.join('');
191
+ }
package/src/transforms.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { BatchRef } from "./batchRef.js";
2
+
1
3
  /**
2
4
  * Convert kebab-case/namespaced key to camelCase.
3
5
  * 'layer-id' -> 'layerId'
@@ -39,6 +41,8 @@ const isOpaque = (key, value) =>
39
41
  */
40
42
  export function transformRequest(obj) {
41
43
  if (obj === null || obj === undefined) return obj;
44
+ // A batch ref is taken out whole when its write is queued.
45
+ if (obj instanceof BatchRef) return obj;
42
46
  if (Array.isArray(obj)) return obj.map(item => transformRequest(item));
43
47
  if (typeof obj !== 'object') return obj;
44
48