@larc-iu/plaid-client 0.2.0-alpha.3 → 0.2.0-alpha.30
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/README.md +39 -0
- package/package.json +13 -6
- package/src/apiAnnotations.d.ts +508 -0
- package/src/apiAnnotations.js +543 -0
- package/src/apiDocuments.d.ts +569 -0
- package/src/apiDocuments.js +551 -0
- package/src/apiLayers.d.ts +369 -0
- package/src/apiLayers.js +222 -0
- package/src/apiMessages.d.ts +293 -0
- package/src/apiMessages.js +265 -0
- package/src/apiPeople.d.ts +626 -0
- package/src/apiPeople.js +698 -0
- package/src/apiProject.d.ts +761 -0
- package/src/apiProject.js +774 -0
- package/src/apiServer.d.ts +455 -0
- package/src/apiServer.js +422 -0
- package/src/apiVocab.d.ts +456 -0
- package/src/apiVocab.js +425 -0
- package/src/batch.d.ts +54 -0
- package/src/batch.js +323 -0
- package/src/batchRef.d.ts +30 -0
- package/src/batchRef.js +128 -0
- package/src/client.d.ts +1321 -0
- package/src/client.js +743 -0
- package/src/codepoint.d.ts +94 -0
- package/src/codepoint.js +131 -1
- package/src/constraints.d.ts +85 -0
- package/src/constraints.js +112 -0
- package/src/created.d.ts +2 -0
- package/src/created.js +29 -0
- package/src/documentLock.d.ts +172 -0
- package/src/documentLock.js +384 -0
- package/src/entryForms.d.ts +63 -0
- package/src/entryForms.js +152 -0
- package/src/events.d.ts +60 -0
- package/src/events.js +271 -0
- package/src/families.d.ts +349 -0
- package/src/families.js +396 -0
- package/src/http.d.ts +233 -0
- package/src/http.js +809 -93
- package/src/ids.d.ts +26 -0
- package/src/ids.js +96 -0
- package/src/index.d.ts +101 -0
- package/src/index.js +183 -2105
- package/src/metadataOps.d.ts +15 -0
- package/src/metadataOps.js +124 -0
- package/src/operations.d.ts +36 -0
- package/src/operations.js +164 -0
- package/src/pagination.d.ts +49 -0
- package/src/pagination.js +20 -53
- package/src/provenance.d.ts +243 -0
- package/src/provenance.js +458 -36
- package/src/replayed.d.ts +3 -0
- package/src/replayed.js +31 -0
- package/src/requester.d.ts +60 -0
- package/src/requester.js +74 -0
- package/src/roles.d.ts +104 -0
- package/src/roles.js +64 -2
- package/src/serviceSchema.d.ts +337 -0
- package/src/serviceSchema.js +177 -60
- package/src/services.d.ts +319 -0
- package/src/services.js +742 -58
- package/src/sse.d.ts +30 -0
- package/src/sse.js +80 -3
- package/src/testing.d.ts +128 -0
- package/src/testing.js +379 -0
- package/src/textEdits.d.ts +59 -0
- package/src/textEdits.js +234 -0
- package/src/transforms.d.ts +25 -0
- package/src/transforms.js +11 -1
- package/src/types.d.ts +867 -0
- package/src/types.js +663 -0
- package/index.d.ts +0 -532
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { isUnknownOutcome, retryUnknown } from "./http.js";
|
|
2
|
+
import { uuidv4 } from "./ids.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Keeping a document lock alive for as long as a `locked()` block runs.
|
|
6
|
+
*
|
|
7
|
+
* plaid-core expires a document lock about a minute after it was taken, and the
|
|
8
|
+
* holder's own writes are what renew it (`plaid.sql.operation` calls
|
|
9
|
+
* `refresh-locks!` on every write it accepts). That is the right rule for an
|
|
10
|
+
* editor, which writes as the person types. It is the wrong one for a service:
|
|
11
|
+
* a parser loads a model, reads the document, spends minutes in inference and
|
|
12
|
+
* only then writes, so for most of its run it holds nothing. The lock lapses in
|
|
13
|
+
* silence, a person's edit lands between the read the work was planned from and
|
|
14
|
+
* the write about to go out, and the write clobbers it.
|
|
15
|
+
*
|
|
16
|
+
* A `locked()` block therefore renews on a timer of its own. The renewal is an
|
|
17
|
+
* acquire that names the holder's `lockId`: the server refreshes the lock that
|
|
18
|
+
* id holds, and refuses one somebody else holds (the same user included).
|
|
19
|
+
*
|
|
20
|
+
* The beat is unconditional rather than "only when no write has gone out
|
|
21
|
+
* recently". Two extra requests a minute is the whole cost. A write the server
|
|
22
|
+
* took that moved the locked document's version renewed the lock as well, and
|
|
23
|
+
* the request layer tells the keeper (`writesStarted`, `writesLanded` in
|
|
24
|
+
* http.js), so renewals that fail with something other than a 423 while the
|
|
25
|
+
* run's writes land do not end the run as a lost lock.
|
|
26
|
+
*
|
|
27
|
+
* A renewal that fails means the block is no longer holding what it asked for,
|
|
28
|
+
* so it stops the run: the loss is recorded on the client and every later write
|
|
29
|
+
* throws `DocumentLockLost`. The guard sits in the request layer because the
|
|
30
|
+
* callers are services, and a check they have to remember to call is a check
|
|
31
|
+
* that is missing somewhere.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The lock a `documents.locked()` block was holding is no longer held.
|
|
36
|
+
*
|
|
37
|
+
* Thrown by the keep-alive when it cannot renew the lock, and then by the
|
|
38
|
+
* request layer on every write this client attempts, so work that has been
|
|
39
|
+
* running for minutes stops rather than writing over an edit that may have
|
|
40
|
+
* landed while the lock was gone.
|
|
41
|
+
*/
|
|
42
|
+
export class DocumentLockLost extends Error {
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} message
|
|
45
|
+
* @param {string} documentId
|
|
46
|
+
* @param {unknown} [cause]
|
|
47
|
+
*/
|
|
48
|
+
constructor(message, documentId, cause) {
|
|
49
|
+
super(message);
|
|
50
|
+
/** @type {"DocumentLockLost"} */
|
|
51
|
+
this.name = "DocumentLockLost";
|
|
52
|
+
/** @type {string} */
|
|
53
|
+
this.documentId = documentId;
|
|
54
|
+
/** @type {unknown} */
|
|
55
|
+
this.cause = cause;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* How long a freshly taken lock lasts, from the server's own answer.
|
|
61
|
+
*
|
|
62
|
+
* `expiresAt` is the epoch-millisecond moment the lock endpoints return, on
|
|
63
|
+
* the server's clock, so it is read against the server's clock too
|
|
64
|
+
* (`client.serverNow()`): this machine's clock being off cannot stretch or
|
|
65
|
+
* shrink the window. It is how a window an operator has retuned
|
|
66
|
+
* (`:plaid.server.locks/config :expiration-ms`) reaches the keeper.
|
|
67
|
+
*
|
|
68
|
+
* An answer that cannot be a window (none, already over, past an hour) is
|
|
69
|
+
* core's default, 60 s.
|
|
70
|
+
*
|
|
71
|
+
* @param {number} expiresAt
|
|
72
|
+
* @param {number} serverNowMs
|
|
73
|
+
* @returns {number}
|
|
74
|
+
*/
|
|
75
|
+
export function lockTtlMs(expiresAt, serverNowMs) {
|
|
76
|
+
const ttl = expiresAt - serverNowMs;
|
|
77
|
+
// No answer at all, or one past an hour or already over: this machine's
|
|
78
|
+
// clock stood in for the server's (a page that cannot read its Date
|
|
79
|
+
// header) and is far off. Core's default window is the better guess.
|
|
80
|
+
return Number.isFinite(ttl) && ttl > 0 && ttl <= MAX_TTL_MS ? ttl : DEFAULT_TTL_MS;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Core's default lock window, and the longest one believed.
|
|
84
|
+
const DEFAULT_TTL_MS = 60000;
|
|
85
|
+
const MAX_TTL_MS = 3600000;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Renews one document's lock until the block holding it exits.
|
|
89
|
+
*
|
|
90
|
+
* `refresh` is called with the document id and must reject on failure.
|
|
91
|
+
* `onLost` is called once, with the `DocumentLockLost`, when the lock can no
|
|
92
|
+
* longer be assumed.
|
|
93
|
+
*
|
|
94
|
+
* `clock` and `sleep` exist so the whole policy can be run on a fake clock:
|
|
95
|
+
* `sleep(delayMs)` resolves to true once the block has exited.
|
|
96
|
+
*/
|
|
97
|
+
export class LockKeeper {
|
|
98
|
+
constructor(
|
|
99
|
+
refresh,
|
|
100
|
+
documentId,
|
|
101
|
+
ttlMs,
|
|
102
|
+
{ onLost = null, clock = Date.now, sleep = null } = {},
|
|
103
|
+
) {
|
|
104
|
+
this._refresh = refresh;
|
|
105
|
+
this._documentId = documentId;
|
|
106
|
+
this._ttlMs = ttlMs;
|
|
107
|
+
this._onLost = onLost;
|
|
108
|
+
this._clock = clock;
|
|
109
|
+
this._sleep = sleep || ((ms) => this._defaultSleep(ms));
|
|
110
|
+
this._stopped = false;
|
|
111
|
+
this._wake = null;
|
|
112
|
+
this._running = null;
|
|
113
|
+
// When the window this keeper last saw renewed runs out, on its clock.
|
|
114
|
+
// Moved by a renewal (run) and by an accepted write (noteWrite).
|
|
115
|
+
this._deadline = clock() + ttlMs;
|
|
116
|
+
/**
|
|
117
|
+
* The `DocumentLockLost` this keeper raised, or null.
|
|
118
|
+
* @type {DocumentLockLost | null}
|
|
119
|
+
*/
|
|
120
|
+
this.lost = null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
get documentId() {
|
|
124
|
+
return this._documentId;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** This keeper's clock, which a write's send time is read on. */
|
|
128
|
+
now() {
|
|
129
|
+
return this._clock();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A write of the locked document, sent at `sentAt`, was taken. The server
|
|
134
|
+
* renews a live lock on every write its holder makes to the document, from
|
|
135
|
+
* when it took the write, which is no earlier than `sentAt`. It counts only
|
|
136
|
+
* when its answer came back before the window this keeper counts ran out:
|
|
137
|
+
* the server then still held the lock when it took the write.
|
|
138
|
+
* @param {number} sentAt
|
|
139
|
+
*/
|
|
140
|
+
noteWrite(sentAt) {
|
|
141
|
+
if (this._clock() < this._deadline) {
|
|
142
|
+
this._deadline = Math.max(this._deadline, sentAt + this._ttlMs);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Time between renewals: half the window, so a renewal that fails has a
|
|
148
|
+
* second chance before the lock it is renewing expires.
|
|
149
|
+
*/
|
|
150
|
+
get intervalMs() {
|
|
151
|
+
return Math.max(1000, this._ttlMs / 2);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Time before retrying a renewal that failed. A blip should not end a run
|
|
156
|
+
* that the server still considers the holder of.
|
|
157
|
+
*/
|
|
158
|
+
get retryMs() {
|
|
159
|
+
return Math.max(500, this._ttlMs / 10);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
_defaultSleep(ms) {
|
|
163
|
+
if (this._stopped) return Promise.resolve(true);
|
|
164
|
+
return new Promise((resolve) => {
|
|
165
|
+
const timer = setTimeout(() => {
|
|
166
|
+
this._wake = null;
|
|
167
|
+
resolve(this._stopped);
|
|
168
|
+
}, ms);
|
|
169
|
+
// Never hold a Node process open on the beat alone.
|
|
170
|
+
if (timer && typeof timer.unref === "function") timer.unref();
|
|
171
|
+
this._wake = () => {
|
|
172
|
+
clearTimeout(timer);
|
|
173
|
+
this._wake = null;
|
|
174
|
+
resolve(true);
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The beat. Resolves when the block exits or the lock is lost. */
|
|
180
|
+
async run() {
|
|
181
|
+
let delay = this.intervalMs;
|
|
182
|
+
while (!(await this._sleep(delay))) {
|
|
183
|
+
// The window is counted from when the renewal was sent, since the
|
|
184
|
+
// server starts it on receipt, not when its answer gets back.
|
|
185
|
+
const sent = this._clock();
|
|
186
|
+
try {
|
|
187
|
+
await this._refresh(this._documentId);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
// 423 is definitive: somebody else holds the document now, or ours
|
|
190
|
+
// expired or was dropped, and the server never takes it back.
|
|
191
|
+
// Anything else may be a blip, and is only fatal once the lock we are
|
|
192
|
+
// renewing has actually run out.
|
|
193
|
+
if (error?.status === 423 || this._clock() >= this._deadline) {
|
|
194
|
+
this._fail(error);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
delay = this.retryMs;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
this._deadline = Math.max(this._deadline, sent + this._ttlMs);
|
|
201
|
+
delay = this.intervalMs;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
start() {
|
|
206
|
+
this._running = this.run();
|
|
207
|
+
// Nothing awaits the beat: a failure inside it is reported through onLost,
|
|
208
|
+
// not through this promise.
|
|
209
|
+
this._running.catch(() => {});
|
|
210
|
+
return this._running;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
stop() {
|
|
214
|
+
this._stopped = true;
|
|
215
|
+
if (this._wake) this._wake();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
_fail(error) {
|
|
219
|
+
this.lost = new DocumentLockLost(
|
|
220
|
+
`The lock on document ${this._documentId} lapsed: it could not be renewed.`,
|
|
221
|
+
this._documentId,
|
|
222
|
+
error,
|
|
223
|
+
);
|
|
224
|
+
if (this._onLost) this._onLost(this.lost);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* What a `client.documents.locked(id, (lock) => ...)` block gets.
|
|
230
|
+
*
|
|
231
|
+
* The block does not have to consult it: a lost lock stops the next write on
|
|
232
|
+
* its own. Read `lock.lost` (or call `lock.raiseIfLost()`) to give up earlier,
|
|
233
|
+
* between steps of work that has not written anything yet.
|
|
234
|
+
*/
|
|
235
|
+
export class DocumentLock {
|
|
236
|
+
/**
|
|
237
|
+
* @param {string} documentId
|
|
238
|
+
* @param {LockKeeper | null} keeper
|
|
239
|
+
* @param {string | null} [lockId]
|
|
240
|
+
*/
|
|
241
|
+
constructor(documentId, keeper, lockId = null) {
|
|
242
|
+
/** @type {string} */
|
|
243
|
+
this.documentId = documentId;
|
|
244
|
+
/**
|
|
245
|
+
* The holder id the acquire answered with.
|
|
246
|
+
* @type {string | null}
|
|
247
|
+
*/
|
|
248
|
+
this.lockId = lockId;
|
|
249
|
+
this._keeper = keeper;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The `DocumentLockLost` if the lock lapsed, else null.
|
|
254
|
+
* @returns {DocumentLockLost | null}
|
|
255
|
+
*/
|
|
256
|
+
get lost() {
|
|
257
|
+
return this._keeper ? this._keeper.lost : null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Throw if the lock lapsed. Call it between steps that have not written. */
|
|
261
|
+
raiseIfLost() {
|
|
262
|
+
if (this.lost) throw this.lost;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The pause before sending again an acquire whose outcome is unknown, which
|
|
268
|
+
* grows by this much each time (three sends in all).
|
|
269
|
+
*/
|
|
270
|
+
export const LOCK_ACQUIRE_RETRY_MS = 500;
|
|
271
|
+
|
|
272
|
+
function mintLockId() {
|
|
273
|
+
return uuidv4();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The acquire of a `locked()` block, under the holder id it minted.
|
|
278
|
+
*
|
|
279
|
+
* An acquire whose outcome is unknown (no answer, a timeout, a 502 or a 504)
|
|
280
|
+
* may have taken the lock. It is sent again under the same id as any write is
|
|
281
|
+
* (`retryUnknown`), which the server answers 200 while that holder has it.
|
|
282
|
+
* When every send is unknown (or the network went), the lock it may hold is
|
|
283
|
+
* released on the way out, so it does not stand in everyone's way until it
|
|
284
|
+
* expires.
|
|
285
|
+
*/
|
|
286
|
+
async function takeLock(client, documentId, lockId, retryMs) {
|
|
287
|
+
try {
|
|
288
|
+
return await retryUnknown(
|
|
289
|
+
() => client.documents.acquireLock(documentId, undefined, lockId),
|
|
290
|
+
{ delaysMs: [retryMs, 2 * retryMs] },
|
|
291
|
+
);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (error?.status === 423) {
|
|
294
|
+
// The error body is raw JSON off the wire, so it is still kebab-cased.
|
|
295
|
+
const body = error.responseData || {};
|
|
296
|
+
const holder = body["user-id"] || "another user";
|
|
297
|
+
const readable = new Error(
|
|
298
|
+
`This document is being edited by ${holder}. Try again once they're done.`,
|
|
299
|
+
);
|
|
300
|
+
readable.status = 423;
|
|
301
|
+
readable.statusText = error.statusText;
|
|
302
|
+
readable.url = error.url;
|
|
303
|
+
readable.method = error.method;
|
|
304
|
+
readable.responseData = error.responseData;
|
|
305
|
+
readable.cause = error;
|
|
306
|
+
throw readable;
|
|
307
|
+
}
|
|
308
|
+
if (isUnknownOutcome(error)) {
|
|
309
|
+
try {
|
|
310
|
+
await client.documents.releaseLock(documentId, lockId);
|
|
311
|
+
} catch {
|
|
312
|
+
/* the lock expires on its own */
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Hold `documentId`'s server-enforced lock for the length of `fn`.
|
|
321
|
+
* `client.documents.locked` is the entry point; see its doc comment.
|
|
322
|
+
*
|
|
323
|
+
* @param {object} client
|
|
324
|
+
* @param {string} documentId
|
|
325
|
+
* @param {(lock: DocumentLock) => any} fn
|
|
326
|
+
* @param {{ keepAlive?: boolean, acquireRetryMs?: number }} [options]
|
|
327
|
+
*/
|
|
328
|
+
export async function withDocumentLock(
|
|
329
|
+
client,
|
|
330
|
+
documentId,
|
|
331
|
+
fn,
|
|
332
|
+
{ keepAlive = true, acquireRetryMs = LOCK_ACQUIRE_RETRY_MS } = {},
|
|
333
|
+
) {
|
|
334
|
+
const minted = mintLockId();
|
|
335
|
+
const info = await takeLock(client, documentId, minted, acquireRetryMs);
|
|
336
|
+
|
|
337
|
+
const lockId = info?.lockId ?? minted;
|
|
338
|
+
let keeper = null;
|
|
339
|
+
if (keepAlive) {
|
|
340
|
+
const ttlMs = lockTtlMs(info.expiresAt, client.serverNow().getTime());
|
|
341
|
+
client.documentLockLost = null;
|
|
342
|
+
keeper = new LockKeeper(
|
|
343
|
+
(id) => client.documents.renewLock(id, lockId),
|
|
344
|
+
documentId,
|
|
345
|
+
ttlMs,
|
|
346
|
+
{
|
|
347
|
+
onLost: (lost) => {
|
|
348
|
+
client.documentLockLost = lost;
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
);
|
|
352
|
+
client.lockKeepers?.set(documentId, keeper);
|
|
353
|
+
keeper.start();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
let result;
|
|
357
|
+
let threw = false;
|
|
358
|
+
try {
|
|
359
|
+
result = await fn(new DocumentLock(documentId, keeper, lockId));
|
|
360
|
+
} catch (error) {
|
|
361
|
+
threw = true;
|
|
362
|
+
throw error;
|
|
363
|
+
} finally {
|
|
364
|
+
if (keeper) {
|
|
365
|
+
keeper.stop();
|
|
366
|
+
if (client.lockKeepers?.get(documentId) === keeper)
|
|
367
|
+
client.lockKeepers.delete(documentId);
|
|
368
|
+
}
|
|
369
|
+
const lost = keeper ? keeper.lost : null;
|
|
370
|
+
client.documentLockLost = null;
|
|
371
|
+
// Best-effort release: the server TTL reclaims a stranded lock, and we must
|
|
372
|
+
// not let a release failure mask the real error from the body.
|
|
373
|
+
try {
|
|
374
|
+
await client.documents.releaseLock(documentId, lockId);
|
|
375
|
+
} catch {
|
|
376
|
+
/* the lock expires on its own */
|
|
377
|
+
}
|
|
378
|
+
// A block that ran to the end without the lock it asked for did not do what
|
|
379
|
+
// it says it did. Only throw when nothing else is already propagating, so
|
|
380
|
+
// the real failure is never masked.
|
|
381
|
+
if (lost && !threw) throw lost;
|
|
382
|
+
}
|
|
383
|
+
return result;
|
|
384
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A form written the way FieldWorks writes a morph standing alone: "-ar" for
|
|
3
|
+
* a suffix, "ka-" for a prefix, "=ni" for an enclitic, unchanged for a stem or
|
|
4
|
+
* for a type outside the table or none.
|
|
5
|
+
*
|
|
6
|
+
* @template {string | null | undefined} F
|
|
7
|
+
* @param {string | null | undefined} morphType
|
|
8
|
+
* @param {F} form
|
|
9
|
+
* @returns {F | string}
|
|
10
|
+
*/
|
|
11
|
+
export function decorateWithAffixMarkers<F extends string | null | undefined>(morphType: string | null | undefined, form: F): F | string;
|
|
12
|
+
/**
|
|
13
|
+
* A morph form as FieldWorks writes it, read back: the inverse of
|
|
14
|
+
* decorateWithAffixMarkers. A morph of a known type loses exactly that type's
|
|
15
|
+
* markers ("-ar" of a suffix is "ar"), and a form without them is kept as
|
|
16
|
+
* written. A morph with no type, or a type outside MORPH_TYPES, takes the type
|
|
17
|
+
* its markers name ("ni=" is a proclitic), and keeps its form whole when they
|
|
18
|
+
* name none. The type comes back lower-cased, or null.
|
|
19
|
+
*
|
|
20
|
+
* @param {string | null | undefined} morphType
|
|
21
|
+
* @param {string | null | undefined} form
|
|
22
|
+
* @returns {{ form: string | null | undefined, morphType: string | null }}
|
|
23
|
+
*/
|
|
24
|
+
export function readAffixMarkers(morphType: string | null | undefined, form: string | null | undefined): {
|
|
25
|
+
form: string | null | undefined;
|
|
26
|
+
morphType: string | null;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* The form and morph type an entry stores, from the form and type a person,
|
|
30
|
+
* a file or a model gave. A form carrying its type's markers loses them
|
|
31
|
+
* ("-s" of a suffix is "s"). A form carrying "-" or "=" markers that name a
|
|
32
|
+
* type loses them. When a type in MORPH_TYPES came with the form, that type
|
|
33
|
+
* is kept ("theŋjən-" given as a bound stem is the bound stem "theŋjən"), and
|
|
34
|
+
* only when none came do the markers set it ("-s" with no type is the suffix
|
|
35
|
+
* "s"). Any other form, a free-text one with whitespace inside its markers
|
|
36
|
+
* among them, is kept as written with the type it came with, which is
|
|
37
|
+
* returned as given ("*kwa" with no type stays "*kwa", a reconstructed form).
|
|
38
|
+
*
|
|
39
|
+
* @template {string | null | undefined} T
|
|
40
|
+
* @param {T} morphType
|
|
41
|
+
* @param {string | null | undefined} form
|
|
42
|
+
* @returns {{ form: string | null | undefined, morphType: T | string }}
|
|
43
|
+
*/
|
|
44
|
+
export function entryForm<T extends string | null | undefined>(morphType: T, form: string | null | undefined): {
|
|
45
|
+
form: string | null | undefined;
|
|
46
|
+
morphType: T | string;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* How a lexicon entry's form is stored: without affix or clitic markers. An
|
|
50
|
+
* entry's marker is drawn from its morph type ("-s" is a suffix "s", "ni=" a
|
|
51
|
+
* proclitic "ni"), so every writer of an entry form, in every app and in the
|
|
52
|
+
* Python client (plaid_client.workflows.igt.entry_forms), passes the form and
|
|
53
|
+
* type it was given through `entryForm` and stores what comes back.
|
|
54
|
+
*
|
|
55
|
+
* The morph types are FieldWorks' MoMorphType inventory, which LIFT and
|
|
56
|
+
* .flextext need verbatim. The markers are each type's Prefix and Postfix as
|
|
57
|
+
* FieldWorks spells a morph standing alone, read off the factory objects of
|
|
58
|
+
* real .fwbackup files (identical in Lezgi, Sena, Arabic, Onim and Lamkang).
|
|
59
|
+
* The seven types left out of the table (stem, root, circumfix, clitic,
|
|
60
|
+
* particle, phrase, discontiguous phrase) have no marker in that data.
|
|
61
|
+
*/
|
|
62
|
+
/** FieldWorks' morph types, the values a stored morph type may take. */
|
|
63
|
+
export const MORPH_TYPES: readonly string[];
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a lexicon entry's form is stored: without affix or clitic markers. An
|
|
3
|
+
* entry's marker is drawn from its morph type ("-s" is a suffix "s", "ni=" a
|
|
4
|
+
* proclitic "ni"), so every writer of an entry form, in every app and in the
|
|
5
|
+
* Python client (plaid_client.workflows.igt.entry_forms), passes the form and
|
|
6
|
+
* type it was given through `entryForm` and stores what comes back.
|
|
7
|
+
*
|
|
8
|
+
* The morph types are FieldWorks' MoMorphType inventory, which LIFT and
|
|
9
|
+
* .flextext need verbatim. The markers are each type's Prefix and Postfix as
|
|
10
|
+
* FieldWorks spells a morph standing alone, read off the factory objects of
|
|
11
|
+
* real .fwbackup files (identical in Lezgi, Sena, Arabic, Onim and Lamkang).
|
|
12
|
+
* The seven types left out of the table (stem, root, circumfix, clitic,
|
|
13
|
+
* particle, phrase, discontiguous phrase) have no marker in that data.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** FieldWorks' morph types, the values a stored morph type may take. */
|
|
17
|
+
export const MORPH_TYPES = Object.freeze([
|
|
18
|
+
"stem",
|
|
19
|
+
"bound stem",
|
|
20
|
+
"root",
|
|
21
|
+
"bound root",
|
|
22
|
+
"prefix",
|
|
23
|
+
"suffix",
|
|
24
|
+
"infix",
|
|
25
|
+
"circumfix",
|
|
26
|
+
"simulfix",
|
|
27
|
+
"suprafix",
|
|
28
|
+
"infixing interfix",
|
|
29
|
+
"prefixing interfix",
|
|
30
|
+
"suffixing interfix",
|
|
31
|
+
"clitic",
|
|
32
|
+
"enclitic",
|
|
33
|
+
"proclitic",
|
|
34
|
+
"particle",
|
|
35
|
+
"phrase",
|
|
36
|
+
"discontiguous phrase",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
/** @type {Readonly<Record<string, readonly [string, string]>>} */
|
|
40
|
+
const MORPH_TYPE_MARKERS = Object.freeze({
|
|
41
|
+
"bound root": ["*", ""],
|
|
42
|
+
"bound stem": ["*", ""],
|
|
43
|
+
enclitic: ["=", ""],
|
|
44
|
+
infix: ["-", "-"],
|
|
45
|
+
"infixing interfix": ["-", "-"],
|
|
46
|
+
prefix: ["", "-"],
|
|
47
|
+
"prefixing interfix": ["", "-"],
|
|
48
|
+
proclitic: ["", "="],
|
|
49
|
+
simulfix: ["=", "="],
|
|
50
|
+
suffix: ["-", ""],
|
|
51
|
+
"suffixing interfix": ["-", ""],
|
|
52
|
+
suprafix: ["~", "~"],
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// The one plain type each pair of "-" and "=" markers names, keyed
|
|
56
|
+
// "<before>,<after>". The interfixes share their markers with a plainer type,
|
|
57
|
+
// so markers alone never name one, and "*" and "~" are left out because both
|
|
58
|
+
// also occur in forms as themselves (a reconstructed "*kwa").
|
|
59
|
+
/** @type {Readonly<Record<string, string>>} */
|
|
60
|
+
const TYPE_BY_MARKERS = Object.freeze({
|
|
61
|
+
"-,": "suffix",
|
|
62
|
+
",-": "prefix",
|
|
63
|
+
"-,-": "infix",
|
|
64
|
+
"=,": "enclitic",
|
|
65
|
+
",=": "proclitic",
|
|
66
|
+
"=,=": "simulfix",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A form written the way FieldWorks writes a morph standing alone: "-ar" for
|
|
71
|
+
* a suffix, "ka-" for a prefix, "=ni" for an enclitic, unchanged for a stem or
|
|
72
|
+
* for a type outside the table or none.
|
|
73
|
+
*
|
|
74
|
+
* @template {string | null | undefined} F
|
|
75
|
+
* @param {string | null | undefined} morphType
|
|
76
|
+
* @param {F} form
|
|
77
|
+
* @returns {F | string}
|
|
78
|
+
*/
|
|
79
|
+
export function decorateWithAffixMarkers(morphType, form) {
|
|
80
|
+
if (form == null || form === "") return form;
|
|
81
|
+
const marks = MORPH_TYPE_MARKERS[String(morphType ?? "").toLowerCase()];
|
|
82
|
+
return marks ? `${marks[0]}${form}${marks[1]}` : form;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @param {string} form @param {string} type */
|
|
86
|
+
const strips = (form, type) => {
|
|
87
|
+
const [before, after] = MORPH_TYPE_MARKERS[type] ?? ["", ""];
|
|
88
|
+
return before + after !== "" &&
|
|
89
|
+
form.length > before.length + after.length &&
|
|
90
|
+
form.startsWith(before) &&
|
|
91
|
+
form.endsWith(after)
|
|
92
|
+
? form.slice(before.length, form.length - after.length)
|
|
93
|
+
: null;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// A form whose bare middle holds whitespace is free text ("-du, du-"), so its
|
|
97
|
+
// markers name no type and stay. WHITESPACE spells out JS's \s, so the
|
|
98
|
+
// Python client's copy matches it character for character.
|
|
99
|
+
const WHITESPACE = /[\t\n\v\f\r \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/u;
|
|
100
|
+
/** @param {string} form */
|
|
101
|
+
const markedType = (form) => {
|
|
102
|
+
const m = /^([-=]?)(.+?)([-=]?)$/su.exec(form);
|
|
103
|
+
const type = m && !WHITESPACE.test(m[2]) && TYPE_BY_MARKERS[`${m[1]},${m[3]}`];
|
|
104
|
+
return type && m ? { form: m[2], morphType: type } : null;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A morph form as FieldWorks writes it, read back: the inverse of
|
|
109
|
+
* decorateWithAffixMarkers. A morph of a known type loses exactly that type's
|
|
110
|
+
* markers ("-ar" of a suffix is "ar"), and a form without them is kept as
|
|
111
|
+
* written. A morph with no type, or a type outside MORPH_TYPES, takes the type
|
|
112
|
+
* its markers name ("ni=" is a proclitic), and keeps its form whole when they
|
|
113
|
+
* name none. The type comes back lower-cased, or null.
|
|
114
|
+
*
|
|
115
|
+
* @param {string | null | undefined} morphType
|
|
116
|
+
* @param {string | null | undefined} form
|
|
117
|
+
* @returns {{ form: string | null | undefined, morphType: string | null }}
|
|
118
|
+
*/
|
|
119
|
+
export function readAffixMarkers(morphType, form) {
|
|
120
|
+
const named = String(morphType ?? "").toLowerCase();
|
|
121
|
+
const known = MORPH_TYPES.includes(named) ? named : null;
|
|
122
|
+
if (form == null || form === "") return { form, morphType: known };
|
|
123
|
+
if (known) return { form: strips(form, known) ?? form, morphType: known };
|
|
124
|
+
return markedType(form) ?? { form, morphType: null };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The form and morph type an entry stores, from the form and type a person,
|
|
129
|
+
* a file or a model gave. A form carrying its type's markers loses them
|
|
130
|
+
* ("-s" of a suffix is "s"). A form carrying "-" or "=" markers that name a
|
|
131
|
+
* type loses them. When a type in MORPH_TYPES came with the form, that type
|
|
132
|
+
* is kept ("theŋjən-" given as a bound stem is the bound stem "theŋjən"), and
|
|
133
|
+
* only when none came do the markers set it ("-s" with no type is the suffix
|
|
134
|
+
* "s"). Any other form, a free-text one with whitespace inside its markers
|
|
135
|
+
* among them, is kept as written with the type it came with, which is
|
|
136
|
+
* returned as given ("*kwa" with no type stays "*kwa", a reconstructed form).
|
|
137
|
+
*
|
|
138
|
+
* @template {string | null | undefined} T
|
|
139
|
+
* @param {T} morphType
|
|
140
|
+
* @param {string | null | undefined} form
|
|
141
|
+
* @returns {{ form: string | null | undefined, morphType: T | string }}
|
|
142
|
+
*/
|
|
143
|
+
export function entryForm(morphType, form) {
|
|
144
|
+
if (typeof form !== "string" || form === "") return { form, morphType };
|
|
145
|
+
const own = typeof morphType === "string" ? morphType.toLowerCase() : "";
|
|
146
|
+
const given = MORPH_TYPES.includes(own);
|
|
147
|
+
const bare = given ? strips(form, own) : null;
|
|
148
|
+
if (bare != null) return { form: bare, morphType };
|
|
149
|
+
const marked = markedType(form);
|
|
150
|
+
if (!marked) return { form, morphType };
|
|
151
|
+
return given ? { form: marked.form, morphType } : marked;
|
|
152
|
+
}
|
package/src/events.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** The page's recorder for `client`'s server. */
|
|
2
|
+
export function recorderFor(client: any): any;
|
|
3
|
+
/** Forget every recorder (tests). */
|
|
4
|
+
export function resetEventRecorders(): void;
|
|
5
|
+
/** Every event type the server accepts. */
|
|
6
|
+
export const EVENT_TYPES: readonly string[];
|
|
7
|
+
export const FLUSH_INTERVAL_MS: 10000;
|
|
8
|
+
export const FLUSH_AT: 50;
|
|
9
|
+
export const FLAG_TTL_MS: 10000;
|
|
10
|
+
export const FLAG_OFF_TTL_MS: 60000;
|
|
11
|
+
export class EventRecorder {
|
|
12
|
+
/**
|
|
13
|
+
* @param {object} [opts]
|
|
14
|
+
* @param {function} [opts.now] - Clock, for tests
|
|
15
|
+
* @param {object} [opts.window] - Where `pagehide` fires (default globalThis)
|
|
16
|
+
*/
|
|
17
|
+
constructor({ now, window }?: {
|
|
18
|
+
now?: Function;
|
|
19
|
+
window?: object;
|
|
20
|
+
});
|
|
21
|
+
client: any;
|
|
22
|
+
buffer: any[];
|
|
23
|
+
timer: number;
|
|
24
|
+
now: Function;
|
|
25
|
+
flags: Map<any, any>;
|
|
26
|
+
seen: Set<any>;
|
|
27
|
+
_onPageHide: () => void;
|
|
28
|
+
window: any;
|
|
29
|
+
/** Tell the recorder a project's switch, e.g. right after changing it. */
|
|
30
|
+
setEnabled(projectId: any, on: any): void;
|
|
31
|
+
/**
|
|
32
|
+
* Record one event. Returns true when it was buffered, false when it was
|
|
33
|
+
* not (switch off, a repeat "shown", an unknown type, no project).
|
|
34
|
+
* @param {any} client
|
|
35
|
+
* @param {string} type
|
|
36
|
+
* @param {{projectId?: string, documentId?: string, targetId?: string, data?: Record<string, any>}} [fields]
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
record(client: any, type: string, { projectId, documentId, targetId, data }?: {
|
|
40
|
+
projectId?: string;
|
|
41
|
+
documentId?: string;
|
|
42
|
+
targetId?: string;
|
|
43
|
+
data?: Record<string, any>;
|
|
44
|
+
}): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Send what is buffered for every project whose switch is known to be on,
|
|
47
|
+
* drop what is buffered for one known to be off, and keep the rest until
|
|
48
|
+
* the switch is known. With `keepalive` (the page is going away) nothing is
|
|
49
|
+
* kept.
|
|
50
|
+
*/
|
|
51
|
+
flush({ keepalive }?: {
|
|
52
|
+
keepalive?: boolean;
|
|
53
|
+
}): void;
|
|
54
|
+
/** Stop listening for `pagehide` and forget everything (tests). */
|
|
55
|
+
close(): void;
|
|
56
|
+
_schedule(): void;
|
|
57
|
+
_drop(projectId: any): void;
|
|
58
|
+
_flag(projectId: any): any;
|
|
59
|
+
_send(projectId: any, events: any, keepalive: any): void;
|
|
60
|
+
}
|