@larc-iu/plaid-client 0.2.0-alpha.29 → 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 (49) hide show
  1. package/package.json +3 -2
  2. package/src/apiAnnotations.d.ts +508 -0
  3. package/src/apiAnnotations.js +543 -0
  4. package/src/apiDocuments.d.ts +569 -0
  5. package/src/apiDocuments.js +551 -0
  6. package/src/apiLayers.d.ts +369 -0
  7. package/src/apiLayers.js +222 -0
  8. package/src/apiMessages.d.ts +293 -0
  9. package/src/apiMessages.js +265 -0
  10. package/src/apiPeople.d.ts +626 -0
  11. package/src/apiPeople.js +698 -0
  12. package/src/apiProject.d.ts +761 -0
  13. package/src/apiProject.js +774 -0
  14. package/src/apiServer.d.ts +455 -0
  15. package/src/apiServer.js +422 -0
  16. package/src/apiVocab.d.ts +456 -0
  17. package/src/apiVocab.js +425 -0
  18. package/src/batch.d.ts +54 -0
  19. package/src/batch.js +323 -0
  20. package/src/client.d.ts +1321 -0
  21. package/src/client.js +743 -0
  22. package/src/constraints.d.ts +8 -0
  23. package/src/constraints.js +14 -0
  24. package/src/documentLock.d.ts +18 -4
  25. package/src/documentLock.js +43 -9
  26. package/src/entryForms.d.ts +63 -0
  27. package/src/entryForms.js +152 -0
  28. package/src/events.js +2 -1
  29. package/src/families.d.ts +349 -0
  30. package/src/families.js +396 -0
  31. package/src/http.d.ts +39 -18
  32. package/src/http.js +127 -87
  33. package/src/index.d.ts +90 -4557
  34. package/src/index.js +113 -5172
  35. package/src/operations.d.ts +36 -0
  36. package/src/operations.js +164 -0
  37. package/src/provenance.d.ts +47 -4
  38. package/src/provenance.js +168 -12
  39. package/src/roles.d.ts +39 -0
  40. package/src/roles.js +44 -0
  41. package/src/serviceSchema.d.ts +19 -13
  42. package/src/serviceSchema.js +30 -15
  43. package/src/services.d.ts +20 -3
  44. package/src/services.js +45 -8
  45. package/src/sse.js +10 -1
  46. package/src/testing.d.ts +128 -0
  47. package/src/testing.js +379 -0
  48. package/src/types.d.ts +867 -0
  49. package/src/types.js +663 -0
@@ -29,6 +29,14 @@
29
29
  * @returns {ConstraintViolation[] | null}
30
30
  */
31
31
  export function violationsOf(err: any): ConstraintViolation[] | null;
32
+ /**
33
+ * How many violations a write refused by a layer constraint has in all, or
34
+ * null for any other error. Read from the raw refusal's "violation-count",
35
+ * since the answer lists at most 100 of them.
36
+ * @param {any} err
37
+ * @returns {number | null}
38
+ */
39
+ export function violationCountOf(err: any): number | null;
32
40
  /** The request body of a constraint write: the list, and `expected` when given. */
33
41
  export function constraintsBody(constraints: any, options: any): {
34
42
  constraints?: undefined;
@@ -45,6 +45,20 @@ export function violationsOf(err) {
45
45
  return Array.isArray(vs) ? transformResponse(vs) : null;
46
46
  }
47
47
 
48
+ /**
49
+ * How many violations a write refused by a layer constraint has in all, or
50
+ * null for any other error. Read from the raw refusal's "violation-count",
51
+ * since the answer lists at most 100 of them.
52
+ * @param {any} err
53
+ * @returns {number | null}
54
+ */
55
+ export function violationCountOf(err) {
56
+ const vs = violationsOf(err);
57
+ if (!vs) return null;
58
+ const n = err.responseData["violation-count"];
59
+ return typeof n === "number" ? n : vs.length;
60
+ }
61
+
48
62
  /** The request body of a constraint write: the list, and `expected` when given. */
49
63
  export function constraintsBody(constraints, options) {
50
64
  const body = constraints === undefined ? {} : { constraints };
@@ -45,10 +45,11 @@ export function withDocumentLock(client: object, documentId: string, fn: (lock:
45
45
  * id holds, and refuses one somebody else holds (the same user included).
46
46
  *
47
47
  * The beat is unconditional rather than "only when no write has gone out
48
- * recently". The request layer knows the method of a call but not which
49
- * document it touched, so a write to some other document would count as a
50
- * renewal here and would not be one. Two extra requests a minute is the whole
51
- * cost.
48
+ * recently". Two extra requests a minute is the whole cost. A write the server
49
+ * took that moved the locked document's version renewed the lock as well, and
50
+ * the request layer tells the keeper (`writesStarted`, `writesLanded` in
51
+ * http.js), so renewals that fail with something other than a 423 while the
52
+ * run's writes land do not end the run as a lost lock.
52
53
  *
53
54
  * A renewal that fails means the block is no longer holding what it asked for,
54
55
  * so it stops the run: the loss is recorded on the client and every later write
@@ -99,11 +100,24 @@ export class LockKeeper {
99
100
  _stopped: boolean;
100
101
  _wake: any;
101
102
  _running: Promise<void>;
103
+ _deadline: any;
102
104
  /**
103
105
  * The `DocumentLockLost` this keeper raised, or null.
104
106
  * @type {DocumentLockLost | null}
105
107
  */
106
108
  lost: DocumentLockLost | null;
109
+ get documentId(): any;
110
+ /** This keeper's clock, which a write's send time is read on. */
111
+ now(): number;
112
+ /**
113
+ * A write of the locked document, sent at `sentAt`, was taken. The server
114
+ * renews a live lock on every write its holder makes to the document, from
115
+ * when it took the write, which is no earlier than `sentAt`. It counts only
116
+ * when its answer came back before the window this keeper counts ran out:
117
+ * the server then still held the lock when it took the write.
118
+ * @param {number} sentAt
119
+ */
120
+ noteWrite(sentAt: number): void;
107
121
  /**
108
122
  * Time between renewals: half the window, so a renewal that fails has a
109
123
  * second chance before the lock it is renewing expires.
@@ -18,10 +18,11 @@ import { uuidv4 } from "./ids.js";
18
18
  * id holds, and refuses one somebody else holds (the same user included).
19
19
  *
20
20
  * The beat is unconditional rather than "only when no write has gone out
21
- * recently". The request layer knows the method of a call but not which
22
- * document it touched, so a write to some other document would count as a
23
- * renewal here and would not be one. Two extra requests a minute is the whole
24
- * cost.
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.
25
26
  *
26
27
  * A renewal that fails means the block is no longer holding what it asked for,
27
28
  * so it stops the run: the loss is recorded on the client and every later write
@@ -109,6 +110,9 @@ export class LockKeeper {
109
110
  this._stopped = false;
110
111
  this._wake = null;
111
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;
112
116
  /**
113
117
  * The `DocumentLockLost` this keeper raised, or null.
114
118
  * @type {DocumentLockLost | null}
@@ -116,6 +120,29 @@ export class LockKeeper {
116
120
  this.lost = null;
117
121
  }
118
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
+
119
146
  /**
120
147
  * Time between renewals: half the window, so a renewal that fails has a
121
148
  * second chance before the lock it is renewing expires.
@@ -151,9 +178,11 @@ export class LockKeeper {
151
178
 
152
179
  /** The beat. Resolves when the block exits or the lock is lost. */
153
180
  async run() {
154
- let deadline = this._clock() + this._ttlMs;
155
181
  let delay = this.intervalMs;
156
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();
157
186
  try {
158
187
  await this._refresh(this._documentId);
159
188
  } catch (error) {
@@ -161,14 +190,14 @@ export class LockKeeper {
161
190
  // expired or was dropped, and the server never takes it back.
162
191
  // Anything else may be a blip, and is only fatal once the lock we are
163
192
  // renewing has actually run out.
164
- if (error?.status === 423 || this._clock() >= deadline) {
193
+ if (error?.status === 423 || this._clock() >= this._deadline) {
165
194
  this._fail(error);
166
195
  return;
167
196
  }
168
197
  delay = this.retryMs;
169
198
  continue;
170
199
  }
171
- deadline = this._clock() + this._ttlMs;
200
+ this._deadline = Math.max(this._deadline, sent + this._ttlMs);
172
201
  delay = this.intervalMs;
173
202
  }
174
203
  }
@@ -264,7 +293,7 @@ async function takeLock(client, documentId, lockId, retryMs) {
264
293
  if (error?.status === 423) {
265
294
  // The error body is raw JSON off the wire, so it is still kebab-cased.
266
295
  const body = error.responseData || {};
267
- const holder = body.userId || body["user-id"] || "another user";
296
+ const holder = body["user-id"] || "another user";
268
297
  const readable = new Error(
269
298
  `This document is being edited by ${holder}. Try again once they're done.`,
270
299
  );
@@ -320,6 +349,7 @@ export async function withDocumentLock(
320
349
  },
321
350
  },
322
351
  );
352
+ client.lockKeepers?.set(documentId, keeper);
323
353
  keeper.start();
324
354
  }
325
355
 
@@ -331,7 +361,11 @@ export async function withDocumentLock(
331
361
  threw = true;
332
362
  throw error;
333
363
  } finally {
334
- if (keeper) keeper.stop();
364
+ if (keeper) {
365
+ keeper.stop();
366
+ if (client.lockKeepers?.get(documentId) === keeper)
367
+ client.lockKeepers.delete(documentId);
368
+ }
335
369
  const lost = keeper ? keeper.lost : null;
336
370
  client.documentLockLost = null;
337
371
  // Best-effort release: the server TTL reclaims a stranded lock, and we must
@@ -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.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { transformRequest } from "./transforms.js";
2
+ import { readResearchTelemetry } from "./roles.js";
2
3
 
3
4
  // ---------------------------------------------------------------------------
4
5
  // Client events: the opt-in research telemetry of a project (defined in
@@ -214,7 +215,7 @@ export class EventRecorder {
214
215
  Promise.resolve()
215
216
  .then(() => client.projects.get(projectId))
216
217
  .then(
217
- (project) => project?.config?.plaid?.research?.telemetry === true,
218
+ (project) => readResearchTelemetry(project?.config),
218
219
  () => false,
219
220
  )
220
221
  .then((on) => {