@larc-iu/plaid-client 0.2.0-alpha.2 → 0.2.0-alpha.21
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 +1636 -164
- package/package.json +1 -1
- package/src/batchRef.js +128 -0
- package/src/constraints.js +73 -0
- package/src/created.js +29 -0
- package/src/documentLock.js +325 -0
- package/src/events.js +266 -0
- package/src/http.js +736 -90
- package/src/ids.js +54 -0
- package/src/index.js +2832 -650
- package/src/metadataOps.js +119 -0
- package/src/pagination.js +19 -35
- package/src/provenance.js +265 -27
- package/src/replayed.js +27 -0
- package/src/requester.js +56 -0
- package/src/roles.js +17 -0
- package/src/serviceSchema.js +83 -47
- package/src/services.js +619 -54
- package/src/sse.js +60 -3
- package/src/textEdits.js +191 -0
- package/src/transforms.js +11 -1
package/src/sse.js
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
import { transformResponse } from './transforms.js';
|
|
2
2
|
|
|
3
|
+
// A live SSE stream is never silent for long: the server sends a keepalive
|
|
4
|
+
// comment on service channels (every 25s) and a heartbeat event on the /listen
|
|
5
|
+
// bus (every 30s). So if NOTHING arrives for longer than this, the connection is
|
|
6
|
+
// dead: the server was killed without closing the socket, the network dropped
|
|
7
|
+
// it, or a reconnect caught the server mid-restart (port accepting, app not yet
|
|
8
|
+
// streaming). We must surface that as a drop so the registration supervisor in
|
|
9
|
+
// serve() can reopen it. Without the watchdog such a silent drop
|
|
10
|
+
// leaves `reader.read()` pending forever: readyState never reaches CLOSED, so
|
|
11
|
+
// nothing reconnects and the service never heals. Comfortably above both server
|
|
12
|
+
// cadences, so a healthy idle stream never trips it.
|
|
13
|
+
export const SSE_IDLE_TIMEOUT_MS = 60000;
|
|
14
|
+
|
|
15
|
+
// The last failure warned about per stream URL, so a stream retried every few
|
|
16
|
+
// seconds through an outage warns once per distinct failure rather than once
|
|
17
|
+
// per attempt. Cleared when the stream opens. Mirrors the Python client.
|
|
18
|
+
const lastWarning = new Map();
|
|
19
|
+
|
|
3
20
|
/**
|
|
4
21
|
* Project a parsed SSE `data:` object onto what a listener callback sees.
|
|
5
22
|
*
|
|
@@ -30,7 +47,10 @@ export function eventPayload(eventType, parsed) {
|
|
|
30
47
|
* /listen bus; service request channels pass their own. Only /listen emits
|
|
31
48
|
* `heartbeat` events needing a POST confirmation — other streams keep
|
|
32
49
|
* themselves alive with ignored SSE comments.
|
|
33
|
-
* @returns {Object} SSE connection with .close(), .getStats(), .readyState
|
|
50
|
+
* @returns {Object} SSE connection with .close(), .getStats(), .readyState,
|
|
51
|
+
* and .error: once CLOSED, why (null when this side closed it). A refused
|
|
52
|
+
* stream's error carries the HTTP `status`, and a stream the server ended
|
|
53
|
+
* carries an error named `StreamClosed`.
|
|
34
54
|
*/
|
|
35
55
|
export function createSSEConnection(client, projectId, onEvent, path) {
|
|
36
56
|
const streamPath = path || `/api/v1/projects/${projectId}/listen`;
|
|
@@ -59,13 +79,30 @@ export function createSSEConnection(client, projectId, onEvent, path) {
|
|
|
59
79
|
}
|
|
60
80
|
};
|
|
61
81
|
|
|
82
|
+
// Watchdog: abort the fetch if the stream goes silent for longer than the
|
|
83
|
+
// server's keepalive cadence, so a silently-dropped connection lands in the
|
|
84
|
+
// catch below (readyState CLOSED) instead of blocking on a read that will
|
|
85
|
+
// never resolve.
|
|
86
|
+
let idleTimer = null;
|
|
87
|
+
const clearIdleTimer = () => {
|
|
88
|
+
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
|
|
89
|
+
};
|
|
90
|
+
const armIdleTimer = () => {
|
|
91
|
+
clearIdleTimer();
|
|
92
|
+
idleTimer = setTimeout(() => {
|
|
93
|
+
if (!isClosed) abortController.abort(new Error('SSE stream went silent'));
|
|
94
|
+
}, SSE_IDLE_TIMEOUT_MS);
|
|
95
|
+
};
|
|
96
|
+
|
|
62
97
|
const sseConnection = {
|
|
63
98
|
readyState: 0, // CONNECTING
|
|
99
|
+
error: null,
|
|
64
100
|
close: () => {
|
|
65
101
|
if (!isClosed) {
|
|
66
102
|
isClosed = true;
|
|
67
103
|
isConnected = false;
|
|
68
104
|
sseConnection.readyState = 2; // CLOSED
|
|
105
|
+
clearIdleTimer();
|
|
69
106
|
abortController.abort();
|
|
70
107
|
}
|
|
71
108
|
},
|
|
@@ -83,6 +120,7 @@ export function createSSEConnection(client, projectId, onEvent, path) {
|
|
|
83
120
|
(async () => {
|
|
84
121
|
try {
|
|
85
122
|
const url = `${client.baseUrl}${streamPath}`;
|
|
123
|
+
armIdleTimer();
|
|
86
124
|
const response = await fetch(url, {
|
|
87
125
|
method: 'GET',
|
|
88
126
|
headers: {
|
|
@@ -101,11 +139,14 @@ export function createSSEConnection(client, projectId, onEvent, path) {
|
|
|
101
139
|
// takes over.
|
|
102
140
|
console.warn('Service registration rejected (409): another instance of this service is already connected; will retry');
|
|
103
141
|
}
|
|
104
|
-
|
|
142
|
+
const refused = new Error(`HTTP ${response.status} ${response.statusText}`);
|
|
143
|
+
refused.status = response.status;
|
|
144
|
+
throw refused;
|
|
105
145
|
}
|
|
106
146
|
|
|
107
147
|
isConnected = true;
|
|
108
148
|
sseConnection.readyState = 1; // OPEN
|
|
149
|
+
lastWarning.delete(url);
|
|
109
150
|
|
|
110
151
|
const reader = response.body.getReader();
|
|
111
152
|
const decoder = new TextDecoder();
|
|
@@ -120,6 +161,7 @@ export function createSSEConnection(client, projectId, onEvent, path) {
|
|
|
120
161
|
while (true) {
|
|
121
162
|
const { done, value } = await reader.read();
|
|
122
163
|
if (done || isClosed) break;
|
|
164
|
+
armIdleTimer();
|
|
123
165
|
|
|
124
166
|
buffer += decoder.decode(value, { stream: true });
|
|
125
167
|
const lines = buffer.split('\n');
|
|
@@ -158,11 +200,26 @@ export function createSSEConnection(client, projectId, onEvent, path) {
|
|
|
158
200
|
}
|
|
159
201
|
}
|
|
160
202
|
}
|
|
203
|
+
if (!isClosed) {
|
|
204
|
+
// The server ended the stream, for instance because the opener lost
|
|
205
|
+
// the right to it. Say so, so a caller can tell it from a close of
|
|
206
|
+
// its own.
|
|
207
|
+
sseConnection.error = Object.assign(new Error('The server closed the stream'), {
|
|
208
|
+
name: 'StreamClosed',
|
|
209
|
+
});
|
|
210
|
+
}
|
|
161
211
|
} catch (error) {
|
|
212
|
+
if (!isClosed) sseConnection.error = error;
|
|
162
213
|
if (error.name !== 'AbortError') {
|
|
163
|
-
|
|
214
|
+
const url = `${client.baseUrl}${streamPath}`;
|
|
215
|
+
const message = String(error?.message || error);
|
|
216
|
+
if (lastWarning.get(url) !== message) {
|
|
217
|
+
lastWarning.set(url, message);
|
|
218
|
+
console.warn('SSE connection error:', error);
|
|
219
|
+
}
|
|
164
220
|
}
|
|
165
221
|
} finally {
|
|
222
|
+
clearIdleTimer();
|
|
166
223
|
isConnected = false;
|
|
167
224
|
isClosed = true;
|
|
168
225
|
sseConnection.readyState = 2; // CLOSED
|
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'
|
|
@@ -27,19 +29,27 @@ export function transformKeyFromCamel(key) {
|
|
|
27
29
|
// usual case conversion.
|
|
28
30
|
const OPAQUE_KEYS = new Set(['metadata', 'config', 'bindings']);
|
|
29
31
|
|
|
32
|
+
// A request's `metadata` may also be a list of metadata ops (a bulk update
|
|
33
|
+
// entry), whose values are user data too, so it passes through verbatim.
|
|
34
|
+
const isOpaque = (key, value) =>
|
|
35
|
+
typeof value === 'object' && value !== null &&
|
|
36
|
+
(Array.isArray(value) ? key === 'metadata' : OPAQUE_KEYS.has(key));
|
|
37
|
+
|
|
30
38
|
/**
|
|
31
39
|
* Recursively transform request object keys from camelCase to kebab-case.
|
|
32
40
|
* Preserves `metadata` and `config` contents without transformation.
|
|
33
41
|
*/
|
|
34
42
|
export function transformRequest(obj) {
|
|
35
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;
|
|
36
46
|
if (Array.isArray(obj)) return obj.map(item => transformRequest(item));
|
|
37
47
|
if (typeof obj !== 'object') return obj;
|
|
38
48
|
|
|
39
49
|
const transformed = {};
|
|
40
50
|
for (const [key, value] of Object.entries(obj)) {
|
|
41
51
|
const newKey = transformKeyFromCamel(key);
|
|
42
|
-
if (
|
|
52
|
+
if (isOpaque(key, value)) {
|
|
43
53
|
transformed[newKey] = value;
|
|
44
54
|
} else {
|
|
45
55
|
transformed[newKey] = transformRequest(value);
|