@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.
- package/index.d.ts +262 -14
- package/package.json +1 -1
- package/src/batchRef.js +128 -0
- package/src/constraints.js +84 -0
- package/src/documentLock.js +67 -29
- package/src/events.js +21 -11
- package/src/http.js +249 -41
- package/src/ids.js +54 -0
- package/src/index.js +540 -135
- package/src/replayed.js +27 -0
- package/src/roles.js +17 -0
- package/src/services.js +49 -1
- package/src/textEdits.js +191 -0
- package/src/transforms.js +4 -0
package/src/replayed.js
ADDED
|
@@ -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
|
|
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,:;]+$/, '');
|
package/src/textEdits.js
ADDED
|
@@ -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
|
|