@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.
Files changed (73) hide show
  1. package/README.md +39 -0
  2. package/package.json +13 -6
  3. package/src/apiAnnotations.d.ts +508 -0
  4. package/src/apiAnnotations.js +543 -0
  5. package/src/apiDocuments.d.ts +569 -0
  6. package/src/apiDocuments.js +551 -0
  7. package/src/apiLayers.d.ts +369 -0
  8. package/src/apiLayers.js +222 -0
  9. package/src/apiMessages.d.ts +293 -0
  10. package/src/apiMessages.js +265 -0
  11. package/src/apiPeople.d.ts +626 -0
  12. package/src/apiPeople.js +698 -0
  13. package/src/apiProject.d.ts +761 -0
  14. package/src/apiProject.js +774 -0
  15. package/src/apiServer.d.ts +455 -0
  16. package/src/apiServer.js +422 -0
  17. package/src/apiVocab.d.ts +456 -0
  18. package/src/apiVocab.js +425 -0
  19. package/src/batch.d.ts +54 -0
  20. package/src/batch.js +323 -0
  21. package/src/batchRef.d.ts +30 -0
  22. package/src/batchRef.js +128 -0
  23. package/src/client.d.ts +1321 -0
  24. package/src/client.js +743 -0
  25. package/src/codepoint.d.ts +94 -0
  26. package/src/codepoint.js +131 -1
  27. package/src/constraints.d.ts +85 -0
  28. package/src/constraints.js +112 -0
  29. package/src/created.d.ts +2 -0
  30. package/src/created.js +29 -0
  31. package/src/documentLock.d.ts +172 -0
  32. package/src/documentLock.js +384 -0
  33. package/src/entryForms.d.ts +63 -0
  34. package/src/entryForms.js +152 -0
  35. package/src/events.d.ts +60 -0
  36. package/src/events.js +271 -0
  37. package/src/families.d.ts +349 -0
  38. package/src/families.js +396 -0
  39. package/src/http.d.ts +233 -0
  40. package/src/http.js +809 -93
  41. package/src/ids.d.ts +26 -0
  42. package/src/ids.js +96 -0
  43. package/src/index.d.ts +101 -0
  44. package/src/index.js +183 -2105
  45. package/src/metadataOps.d.ts +15 -0
  46. package/src/metadataOps.js +124 -0
  47. package/src/operations.d.ts +36 -0
  48. package/src/operations.js +164 -0
  49. package/src/pagination.d.ts +49 -0
  50. package/src/pagination.js +20 -53
  51. package/src/provenance.d.ts +243 -0
  52. package/src/provenance.js +458 -36
  53. package/src/replayed.d.ts +3 -0
  54. package/src/replayed.js +31 -0
  55. package/src/requester.d.ts +60 -0
  56. package/src/requester.js +74 -0
  57. package/src/roles.d.ts +104 -0
  58. package/src/roles.js +64 -2
  59. package/src/serviceSchema.d.ts +337 -0
  60. package/src/serviceSchema.js +177 -60
  61. package/src/services.d.ts +319 -0
  62. package/src/services.js +742 -58
  63. package/src/sse.d.ts +30 -0
  64. package/src/sse.js +80 -3
  65. package/src/testing.d.ts +128 -0
  66. package/src/testing.js +379 -0
  67. package/src/textEdits.d.ts +59 -0
  68. package/src/textEdits.js +234 -0
  69. package/src/transforms.d.ts +25 -0
  70. package/src/transforms.js +11 -1
  71. package/src/types.d.ts +867 -0
  72. package/src/types.js +663 -0
  73. 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
+ }
@@ -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
+ }