better-auth-elasticsearch 0.2.0 → 0.3.0

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.
@@ -0,0 +1,662 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isNotFound, isVersionConflict, isWriteBlocked, statusCodeOf, } from "./elasticsearch-client.js";
3
+ /**
4
+ * How long a reservation whose row does not hold its value counts as a write
5
+ * still in flight. Older, it is left over from a crash and may be taken over,
6
+ * so a crash blocks a value for at most this long.
7
+ */
8
+ export const GRACE_MS = 120_000;
9
+ /** How often a claim of one value goes again after losing a race for it. */
10
+ const MAX_CLAIM_ATTEMPTS = 10;
11
+ /** How many rows a backfill reads and reserves per request. */
12
+ const BACKFILL_PAGE_SIZE = 1000;
13
+ // Re-stamps a reservation the claiming row already owns. One another row owns
14
+ // is left untouched, and the claimant decides what to do from its contents.
15
+ const CLAIM_SCRIPT = `
16
+ if (ctx._source.owner == params.owner) {
17
+ ctx._source.claimedAt = params.now;
18
+ } else {
19
+ ctx.op = 'noop';
20
+ }`;
21
+ // Deletes a reservation only while the deleted row still owns it.
22
+ const RELEASE_SCRIPT = `
23
+ if (ctx._source.owner == params.owner) {
24
+ ctx.op = 'delete';
25
+ } else {
26
+ ctx.op = 'noop';
27
+ }`;
28
+ const isValue = (value) => value !== null && value !== undefined;
29
+ const dbFieldsOf = (constraint) => constraint.fields.map((field) => field.dbName);
30
+ /**
31
+ * The values a stored row holds in a constraint's fields, or undefined if
32
+ * any is null or missing: such a row holds no reservation.
33
+ */
34
+ const valuesIn = (constraint, source) => {
35
+ const values = dbFieldsOf(constraint).map((dbName) => source?.[dbName]);
36
+ return values.every(isValue) ? values : undefined;
37
+ };
38
+ /** Whether a stored row holds `held`, compared as stored. */
39
+ const holds = (source, held) => JSON.stringify(valuesIn(held.constraint, source)) ===
40
+ JSON.stringify(held.values);
41
+ /**
42
+ * A reservation's `_id`: a hash, so the value itself is never stored. One
43
+ * field hashes as `[field, value]` however its uniqueness is declared;
44
+ * several hash as `[fields, values]`, which no single field can collide with.
45
+ */
46
+ export const reservationId = (dbFields, values) => createHash("sha256")
47
+ .update(JSON.stringify(dbFields.length === 1 ? [dbFields[0], values[0]] : [dbFields, values]))
48
+ .digest("base64url");
49
+ const idOf = (held) => reservationId(dbFieldsOf(held.constraint), held.values);
50
+ /** The reservation document `held` claimed for `owner`. */
51
+ const reservationFor = (held, owner, claimedAt) => ({
52
+ field: dbFieldsOf(held.constraint).join("+"),
53
+ owner,
54
+ claimedAt,
55
+ });
56
+ const versionOf = (item) => item._seq_no === undefined || item._primary_term === undefined
57
+ ? undefined
58
+ : { if_seq_no: item._seq_no, if_primary_term: item._primary_term };
59
+ /** A write that failed in a way that leaves open whether it landed. */
60
+ const mayHaveLanded = (error) => {
61
+ const status = statusCodeOf(error);
62
+ return (status === undefined || status === 408 || status === 429 || status >= 500);
63
+ };
64
+ const isAlreadyExists = (error) => error.meta?.body
65
+ ?.error?.type === "resource_already_exists_exception";
66
+ export const duplicateError = (model, field) => new Error(`duplicate key: a "${model}" row with this ${field} already exists`);
67
+ /**
68
+ * Enforces the fields the better-auth schema declares unique and its tables'
69
+ * unique indexes. Every value of such a field, or combination of values of
70
+ * such an index, is backed by a reservation document in `<alias>_unique`
71
+ * whose `_id` derives from the fields and the values, so Elasticsearch refuses a
72
+ * second one the way it refuses a second row with the same id. Reservations
73
+ * are only read and written by `_id`, which is real-time, so none of their
74
+ * writes refresh.
75
+ */
76
+ export const createReservations = ({ client, schema, getDefaultModelName, skipUniqueFor, settings, withRetry, }) => {
77
+ const skipped = new Set(skipUniqueFor);
78
+ const constraintsByModel = new Map();
79
+ /**
80
+ * The enforced constraints of a model, by its schema name. A field
81
+ * declared unique both ways is one constraint, with one reservation.
82
+ */
83
+ const constraintsOfDefault = (defaultModel) => {
84
+ let constraints = constraintsByModel.get(defaultModel);
85
+ if (!constraints) {
86
+ const table = schema[defaultModel];
87
+ const fields = table?.fields ?? {};
88
+ const declared = [
89
+ ...Object.entries(fields)
90
+ .filter(([, field]) => field.unique === true)
91
+ .map(([name]) => [name]),
92
+ ...(table?.indexes ?? [])
93
+ .filter((index) => index.unique === true)
94
+ .map((index) => [...index.fields]),
95
+ ];
96
+ const byName = new Map();
97
+ for (const names of declared) {
98
+ const name = names.join("+");
99
+ if (byName.has(name) || skipped.has(`${defaultModel}.${name}`)) {
100
+ continue;
101
+ }
102
+ byName.set(name, {
103
+ name,
104
+ fields: names.map((field) => ({
105
+ name: field,
106
+ dbName: fields[field]?.fieldName ?? field,
107
+ })),
108
+ });
109
+ }
110
+ constraints = [...byName.values()];
111
+ constraintsByModel.set(defaultModel, constraints);
112
+ }
113
+ return constraints;
114
+ };
115
+ /** The enforced constraints of a model, by its database name. */
116
+ const constraintsOf = (model) => constraintsOfDefault(getDefaultModelName(model));
117
+ /**
118
+ * The database names of the fields of a model's enforced constraints
119
+ * that `data` sets any field of, or of all of them without `data`. A
120
+ * write needs the row's current values of all of these.
121
+ */
122
+ const fieldsOf = (model, data) => [
123
+ ...new Set(constraintsOf(model)
124
+ .filter((constraint) => !data || dbFieldsOf(constraint).some((dbName) => dbName in data))
125
+ .flatMap(dbFieldsOf)),
126
+ ];
127
+ /** The schema name of a field `data` sets that a constraint covers. */
128
+ const constrainedFieldIn = (model, data) => constraintsOf(model)
129
+ .flatMap((constraint) => constraint.fields)
130
+ .find((field) => field.dbName in data)?.name;
131
+ const indexFor = (alias) => `${alias}_unique`;
132
+ // One creation check per reservation index and process. A failed check is
133
+ // forgotten so the next call tries again.
134
+ const ready = new Map();
135
+ /**
136
+ * Creates the reservation index on first use. Without it, the first
137
+ * reservation write would have Elasticsearch create it with a dynamic
138
+ * mapping.
139
+ */
140
+ const ensureIndex = async (alias) => {
141
+ const index = indexFor(alias);
142
+ let check = ready.get(index);
143
+ if (!check) {
144
+ check = withRetry(async () => {
145
+ try {
146
+ await client.indices.create({
147
+ index,
148
+ settings: {
149
+ number_of_shards: settings.numberOfShards,
150
+ number_of_replicas: settings.numberOfReplicas,
151
+ },
152
+ mappings: {
153
+ dynamic: "strict",
154
+ properties: {
155
+ field: { type: "keyword" },
156
+ owner: { type: "keyword" },
157
+ claimedAt: { type: "date", format: "epoch_millis" },
158
+ },
159
+ },
160
+ });
161
+ }
162
+ catch (error) {
163
+ if (!isAlreadyExists(error))
164
+ throw error;
165
+ }
166
+ });
167
+ ready.set(index, check);
168
+ check.catch(() => ready.delete(index));
169
+ }
170
+ await check;
171
+ return index;
172
+ };
173
+ /** Rows of `alias` by id, real-time, with only `fields` of their source. */
174
+ const rowsById = async (alias, ids, fields) => {
175
+ const rows = new Map();
176
+ if (ids.length === 0)
177
+ return rows;
178
+ const response = await withRetry(() => client.mget({ index: alias, ids, _source: fields }));
179
+ for (const doc of response.docs) {
180
+ if ("found" in doc && doc.found)
181
+ rows.set(doc._id, doc._source ?? {});
182
+ }
183
+ return rows;
184
+ };
185
+ /** A row of `alias` by id, real-time, with only `fields` of its source. */
186
+ const rowById = async (alias, id, ...fields) => {
187
+ try {
188
+ const doc = await withRetry(() => client.get({ index: alias, id, _source_includes: fields }));
189
+ return doc.found ? (doc._source ?? {}) : undefined;
190
+ }
191
+ catch (error) {
192
+ if (isNotFound(error))
193
+ return undefined;
194
+ throw error;
195
+ }
196
+ };
197
+ /**
198
+ * Deletes reservations only if each is still as `version` says. Conditional
199
+ * deletes are safe to repeat. A failure only leaves orphans behind, which
200
+ * are taken over once the grace period has passed, so it is not reported.
201
+ */
202
+ const deleteUnchanged = async (index, entries) => {
203
+ const operations = entries.flatMap((entry) => entry.version ? [{ delete: { _id: entry.id, ...entry.version } }] : []);
204
+ if (operations.length === 0)
205
+ return;
206
+ await withRetry(() => client.bulk({ index, operations })).catch(() => { });
207
+ };
208
+ /** The enforced values a stored row holds. */
209
+ const valuesOf = (model, source) => constraintsOf(model).flatMap((constraint) => {
210
+ const values = valuesIn(constraint, source);
211
+ return values ? [{ constraint, values }] : [];
212
+ });
213
+ /**
214
+ * What a write of `changes` to a row stored as `before` takes and gives up.
215
+ * Undefined when it changes no enforced value. `before` must hold every
216
+ * field of each constraint `changes` touches (see `fieldsOf`).
217
+ */
218
+ const changeOf = (model, id, before, changes) => {
219
+ const after = { ...before, ...changes };
220
+ const claim = [];
221
+ const drop = [];
222
+ for (const constraint of constraintsOf(model)) {
223
+ if (!dbFieldsOf(constraint).some((dbName) => dbName in changes)) {
224
+ continue;
225
+ }
226
+ const next = valuesIn(constraint, after);
227
+ const previous = valuesIn(constraint, before);
228
+ if (JSON.stringify(next) === JSON.stringify(previous))
229
+ continue;
230
+ if (next)
231
+ claim.push({ constraint, values: next });
232
+ if (previous)
233
+ drop.push({ constraint, values: previous });
234
+ }
235
+ if (claim.length === 0 && drop.length === 0)
236
+ return undefined;
237
+ return { id, claim, drop };
238
+ };
239
+ /**
240
+ * The claims of one write, of one row or a page of rows: taken before the
241
+ * rows are written, then either kept, with the dropped values released, or
242
+ * given back.
243
+ */
244
+ const claims = (model, alias) => {
245
+ let claimed = [];
246
+ let dropped = [];
247
+ let stampedAt = 0;
248
+ const entriesOf = (row, held = []) => held.map((values) => ({
249
+ ...values,
250
+ row,
251
+ id: idOf(values),
252
+ created: false,
253
+ }));
254
+ /** The reservation document as stored now, or null. */
255
+ const current = async (index, id) => {
256
+ try {
257
+ const doc = await withRetry(() => client.get({ index, id }));
258
+ return doc._source
259
+ ? { source: doc._source, version: versionOf(doc) }
260
+ : null;
261
+ }
262
+ catch (error) {
263
+ if (isNotFound(error))
264
+ return null;
265
+ throw error;
266
+ }
267
+ };
268
+ /**
269
+ * Claims one value the bulk claim could not: creates it if it is gone,
270
+ * re-stamps it if the row owns it, and takes it over if it is an orphan,
271
+ * each conditional on what was read. Returns false for a duplicate.
272
+ *
273
+ * These writes are not retried, like the adapter's other conditional
274
+ * writes: one that timed out may have landed and would then lose to
275
+ * itself.
276
+ */
277
+ const claimContested = async (index, entry, seen) => {
278
+ let reservation = seen;
279
+ for (let attempt = 0; attempt < MAX_CLAIM_ATTEMPTS; attempt++) {
280
+ reservation ??= await current(index, entry.id);
281
+ const now = Date.now();
282
+ const document = reservationFor(entry, entry.row, now);
283
+ try {
284
+ if (!reservation) {
285
+ const result = await client.index({
286
+ index,
287
+ id: entry.id,
288
+ op_type: "create",
289
+ document,
290
+ });
291
+ entry.created = true;
292
+ entry.version = versionOf(result);
293
+ return true;
294
+ }
295
+ const owned = reservation.source.owner === entry.row;
296
+ if (!owned) {
297
+ // A reservation whose row does not hold the value is a write in
298
+ // flight until the grace period has passed, an orphan after.
299
+ if (now - Number(reservation.source.claimedAt) < GRACE_MS) {
300
+ return false;
301
+ }
302
+ const owner = await rowById(alias, reservation.source.owner, ...dbFieldsOf(entry.constraint));
303
+ if (holds(owner, entry))
304
+ return false;
305
+ }
306
+ if (!reservation.version) {
307
+ reservation = null;
308
+ continue;
309
+ }
310
+ const result = await client.index({
311
+ index,
312
+ id: entry.id,
313
+ document,
314
+ ...reservation.version,
315
+ });
316
+ // Taken over from an orphan, it is not this write's to give back.
317
+ if (!owned)
318
+ entry.created = false;
319
+ entry.version = versionOf(result);
320
+ return true;
321
+ }
322
+ catch (error) {
323
+ if (!isVersionConflict(error))
324
+ throw error;
325
+ reservation = null;
326
+ }
327
+ }
328
+ throw new Error(`Claiming a unique value for a "${model}" row did not complete after ${MAX_CLAIM_ATTEMPTS} attempts because other requests kept changing it`);
329
+ };
330
+ return {
331
+ /**
332
+ * Claims the values `changes` take, in one bulk request, and re-stamps
333
+ * the values they give up so their release can tell whether a later
334
+ * write re-claimed them. Throws a duplicate key error, having given back
335
+ * what it created, if another row holds or is writing a value. Called
336
+ * again when a write reruns, it re-stamps its own claims.
337
+ */
338
+ async claim(changes) {
339
+ const createdBefore = new Set(claimed
340
+ .filter((entry) => entry.created)
341
+ .map((entry) => `${entry.row}\n${entry.id}`));
342
+ claimed = changes.flatMap((change) => entriesOf(change.id, change.claim));
343
+ dropped = changes.flatMap((change) => entriesOf(change.id, change.drop));
344
+ if (claimed.length === 0 && dropped.length === 0)
345
+ return;
346
+ const index = await ensureIndex(alias);
347
+ stampedAt = Date.now();
348
+ const params = (entry) => ({
349
+ lang: "painless",
350
+ source: CLAIM_SCRIPT,
351
+ params: { owner: entry.row, now: stampedAt },
352
+ });
353
+ const response = await withRetry(() => client.bulk({
354
+ index,
355
+ operations: [
356
+ ...claimed.flatMap((entry) => [
357
+ { update: { _id: entry.id, retry_on_conflict: 3 } },
358
+ {
359
+ script: params(entry),
360
+ upsert: reservationFor(entry, entry.row, stampedAt),
361
+ _source: true,
362
+ },
363
+ ]),
364
+ ...dropped.flatMap((entry) => [
365
+ { update: { _id: entry.id } },
366
+ { script: params(entry) },
367
+ ]),
368
+ ],
369
+ }));
370
+ const contested = [];
371
+ const duplicates = [];
372
+ let failure;
373
+ response.items.forEach((item, position) => {
374
+ const outcome = item.update;
375
+ const entry = position < claimed.length
376
+ ? claimed[position]
377
+ : dropped[position - claimed.length];
378
+ if (!outcome || !entry)
379
+ return;
380
+ if (position >= claimed.length) {
381
+ // Only a value this write re-stamped is its own to release; one
382
+ // another row owns, or none at all, is left alone.
383
+ if (outcome.result === "updated")
384
+ entry.version = versionOf(outcome);
385
+ return;
386
+ }
387
+ if (outcome.result === "created") {
388
+ entry.created = true;
389
+ entry.version = versionOf(outcome);
390
+ }
391
+ else if (outcome.result === "updated") {
392
+ // Created by an earlier run of this write, it still is its own.
393
+ entry.created = createdBefore.has(`${entry.row}\n${entry.id}`);
394
+ entry.version = versionOf(outcome);
395
+ }
396
+ else if (outcome.result === "noop") {
397
+ const seen = outcome.get?._source
398
+ ? {
399
+ source: outcome.get._source,
400
+ version: versionOf(outcome.get),
401
+ }
402
+ : null;
403
+ contested.push(claimContested(index, entry, seen).then((claimedIt) => {
404
+ if (!claimedIt)
405
+ duplicates.push(entry);
406
+ return claimedIt;
407
+ }));
408
+ }
409
+ else if (outcome.status === 409 || outcome.status === 429) {
410
+ contested.push(claimContested(index, entry).then((claimedIt) => {
411
+ if (!claimedIt)
412
+ duplicates.push(entry);
413
+ return claimedIt;
414
+ }));
415
+ }
416
+ else {
417
+ failure ??= outcome.error ?? { type: "unexpected bulk response" };
418
+ }
419
+ });
420
+ const settled = await Promise.allSettled(contested);
421
+ const rejected = settled.find((result) => result.status === "rejected");
422
+ if (failure || rejected || duplicates.length > 0) {
423
+ await deleteUnchanged(index, claimed.filter((entry) => entry.created));
424
+ if (rejected)
425
+ throw rejected.reason;
426
+ if (failure) {
427
+ throw new Error(`Claiming the unique values of a "${model}" row failed: ${failure.reason ?? failure.type}`);
428
+ }
429
+ const [first] = duplicates;
430
+ throw duplicateError(model, first?.constraint.name ?? "value");
431
+ }
432
+ },
433
+ /**
434
+ * After a write of the rows `ids`: releases the values they gave up,
435
+ * unless a later write re-claimed them since this one re-stamped them.
436
+ */
437
+ async written(ids) {
438
+ const rows = new Set(ids);
439
+ await deleteUnchanged(indexFor(alias), dropped.filter((entry) => rows.has(entry.row)));
440
+ },
441
+ /**
442
+ * After the rows `ids` were certainly not written: gives back the
443
+ * reservations this write created for them, unless re-claimed since.
444
+ */
445
+ async unwritten(ids) {
446
+ const rows = new Set(ids);
447
+ await deleteUnchanged(indexFor(alias), claimed.filter((entry) => entry.created && rows.has(entry.row)));
448
+ },
449
+ /**
450
+ * After the single row `id` failed to be written with `error`: gives
451
+ * back the reservations this write created, unless the write may have
452
+ * landed after all or the row holds the value. A write refused by a
453
+ * migration's write block keeps them for its rerun.
454
+ */
455
+ async failed(id, error) {
456
+ if (isWriteBlocked(error) || mayHaveLanded(error))
457
+ return;
458
+ const created = claimed.filter((entry) => entry.created);
459
+ if (created.length === 0)
460
+ return;
461
+ let row;
462
+ try {
463
+ row = await rowById(alias, id, ...created.flatMap((entry) => dbFieldsOf(entry.constraint)));
464
+ }
465
+ catch {
466
+ return;
467
+ }
468
+ await deleteUnchanged(indexFor(alias), created.filter((entry) => !holds(row, entry)));
469
+ },
470
+ /**
471
+ * The schema name of a value the row no longer owns, if any. A write
472
+ * landing later than half the grace period after its claim may have
473
+ * had one taken over meanwhile; one landing sooner cannot, so it is
474
+ * not asked.
475
+ */
476
+ async lost() {
477
+ if (claimed.length === 0 || Date.now() - stampedAt <= GRACE_MS / 2) {
478
+ return undefined;
479
+ }
480
+ const response = await withRetry(() => client.mget({
481
+ index: indexFor(alias),
482
+ ids: claimed.map((entry) => entry.id),
483
+ }));
484
+ const entry = claimed.find((claim, position) => {
485
+ const doc = response.docs[position];
486
+ return !(doc &&
487
+ "found" in doc &&
488
+ doc.found &&
489
+ doc._source?.owner === claim.row);
490
+ });
491
+ return entry?.constraint.name;
492
+ },
493
+ };
494
+ };
495
+ /**
496
+ * After the rows were deleted: releases the values they held, unless
497
+ * another row has taken one over since.
498
+ */
499
+ const release = async (model, alias, rows) => {
500
+ const operations = rows.flatMap(({ id, source }) => valuesOf(model, source).flatMap((held) => [
501
+ { update: { _id: idOf(held) } },
502
+ {
503
+ script: {
504
+ lang: "painless",
505
+ source: RELEASE_SCRIPT,
506
+ params: { owner: id },
507
+ },
508
+ },
509
+ ]));
510
+ if (operations.length === 0)
511
+ return;
512
+ const index = await ensureIndex(alias);
513
+ // Repeating it is safe, and a failure only leaves orphans behind.
514
+ await withRetry(() => client.bulk({ index, operations })).catch(() => { });
515
+ };
516
+ /**
517
+ * Reserves the values of rows that hold none, as rows written before
518
+ * reservations existed do, and reports values more than one row holds.
519
+ * Changes nothing for rows already reserved, so it can run every time.
520
+ */
521
+ const backfill = async (defaultModel, alias) => {
522
+ const constraints = constraintsOfDefault(defaultModel);
523
+ if (constraints.length === 0)
524
+ return [];
525
+ if (!(await withRetry(() => client.indices.exists({ index: alias })))) {
526
+ return [];
527
+ }
528
+ const index = await ensureIndex(alias);
529
+ const dbNames = [...new Set(constraints.flatMap(dbFieldsOf))];
530
+ const duplicates = new Map();
531
+ let searchAfter;
532
+ while (true) {
533
+ const page = await withRetry(() => client.search({
534
+ index: alias,
535
+ size: BACKFILL_PAGE_SIZE,
536
+ sort: [{ id: "asc" }],
537
+ search_after: searchAfter,
538
+ _source: dbNames,
539
+ track_total_hits: false,
540
+ }));
541
+ const hits = page.hits.hits;
542
+ if (hits.length === 0)
543
+ break;
544
+ searchAfter = hits[hits.length - 1]?.sort;
545
+ const entries = hits.flatMap((hit) => constraints.flatMap((constraint) => {
546
+ const values = valuesIn(constraint, hit._source);
547
+ if (!hit._id || !values)
548
+ return [];
549
+ const held = { constraint, values };
550
+ return [
551
+ { ...held, row: hit._id, source: hit._source, id: idOf(held) },
552
+ ];
553
+ }));
554
+ if (entries.length > 0) {
555
+ const claimedAt = Date.now();
556
+ const response = await withRetry(() => client.bulk({
557
+ index,
558
+ operations: entries.flatMap((entry) => [
559
+ { create: { _id: entry.id } },
560
+ reservationFor(entry, entry.row, claimedAt),
561
+ ]),
562
+ }));
563
+ const taken = entries.filter((entry, position) => {
564
+ const outcome = response.items[position]?.create;
565
+ if (outcome?.status === 409)
566
+ return true;
567
+ if (!outcome || outcome.error) {
568
+ throw new Error(`Reserving the unique values of "${alias}" failed: ${outcome?.error?.reason ?? outcome?.error?.type ?? "unexpected bulk response"}`);
569
+ }
570
+ return false;
571
+ });
572
+ if (taken.length > 0) {
573
+ await resolveTaken(index, alias, taken, dbNames, (entry, owner) => {
574
+ const group = duplicates.get(entry.id) ?? {
575
+ field: entry.constraint.name,
576
+ ids: new Set(),
577
+ };
578
+ group.ids.add(owner).add(entry.row);
579
+ duplicates.set(entry.id, group);
580
+ });
581
+ }
582
+ }
583
+ if (hits.length < BACKFILL_PAGE_SIZE)
584
+ break;
585
+ }
586
+ return [...duplicates.values()].map(({ field, ids }) => ({
587
+ model: defaultModel,
588
+ field,
589
+ ids: [...ids].sort(),
590
+ }));
591
+ };
592
+ /**
593
+ * For backfilled values that already had a reservation: reports those whose
594
+ * owner holds the value too, and takes over orphans past the grace period.
595
+ * A reservation still in flight is left to its write.
596
+ */
597
+ const resolveTaken = async (index, alias, taken, dbNames, onDuplicate) => {
598
+ const ids = [...new Set(taken.map((entry) => entry.id))];
599
+ const response = await withRetry(() => client.mget({ index, ids }));
600
+ const reservations = new Map();
601
+ for (const doc of response.docs) {
602
+ if ("found" in doc && doc.found && doc._source) {
603
+ reservations.set(doc._id, {
604
+ source: doc._source,
605
+ version: versionOf(doc),
606
+ });
607
+ }
608
+ }
609
+ // Only rows other than the backfilled one can be duplicates.
610
+ const owners = await rowsById(alias, [
611
+ ...new Set(taken.flatMap((entry) => {
612
+ const owner = reservations.get(entry.id)?.source.owner;
613
+ return owner && owner !== entry.row ? [owner] : [];
614
+ })),
615
+ ], dbNames);
616
+ for (const entry of taken) {
617
+ const reservation = reservations.get(entry.id);
618
+ // Released since the bulk ran; the next run reserves it.
619
+ if (!reservation || reservation.source.owner === entry.row)
620
+ continue;
621
+ const owner = reservation.source.owner;
622
+ if (holds(owners.get(owner), entry)) {
623
+ onDuplicate(entry, owner);
624
+ continue;
625
+ }
626
+ const now = Date.now();
627
+ if (!reservation.version ||
628
+ now - Number(reservation.source.claimedAt) < GRACE_MS) {
629
+ continue;
630
+ }
631
+ try {
632
+ const document = reservationFor(entry, entry.row, now);
633
+ const result = await client.index({
634
+ index,
635
+ id: entry.id,
636
+ document,
637
+ ...reservation.version,
638
+ });
639
+ reservation.source = document;
640
+ reservation.version = versionOf(result);
641
+ // A later row of this page with the same value is then a duplicate
642
+ // of this one.
643
+ owners.set(entry.row, entry.source ?? {});
644
+ }
645
+ catch (error) {
646
+ if (!isVersionConflict(error))
647
+ throw error;
648
+ }
649
+ }
650
+ };
651
+ return {
652
+ constraintsOf,
653
+ fieldsOf,
654
+ constrainedFieldIn,
655
+ valuesOf,
656
+ changeOf,
657
+ claims,
658
+ release,
659
+ backfill,
660
+ };
661
+ };
662
+ //# sourceMappingURL=unique-reservations.js.map