@kairos-es/feed-algolia 0.0.0-stage → 0.0.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.
Files changed (60) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +66 -2
  3. package/dist/cjs/classify.js +157 -0
  4. package/dist/cjs/classify.js.map +1 -0
  5. package/dist/cjs/identifiers.js +38 -0
  6. package/dist/cjs/identifiers.js.map +1 -0
  7. package/dist/cjs/index.js +39 -0
  8. package/dist/cjs/index.js.map +1 -0
  9. package/dist/cjs/records.js +105 -0
  10. package/dist/cjs/records.js.map +1 -0
  11. package/dist/cjs/rest.js +293 -0
  12. package/dist/cjs/rest.js.map +1 -0
  13. package/dist/cjs/sink.js +463 -0
  14. package/dist/cjs/sink.js.map +1 -0
  15. package/dist/dts-cjs/classify.d.ts +101 -0
  16. package/dist/dts-cjs/classify.d.ts.map +1 -0
  17. package/dist/dts-cjs/identifiers.d.ts +34 -0
  18. package/dist/dts-cjs/identifiers.d.ts.map +1 -0
  19. package/dist/dts-cjs/index.d.ts +39 -0
  20. package/dist/dts-cjs/index.d.ts.map +1 -0
  21. package/dist/dts-cjs/package.json +3 -0
  22. package/dist/dts-cjs/records.d.ts +56 -0
  23. package/dist/dts-cjs/records.d.ts.map +1 -0
  24. package/dist/dts-cjs/rest.d.ts +215 -0
  25. package/dist/dts-cjs/rest.d.ts.map +1 -0
  26. package/dist/dts-cjs/sink.d.ts +220 -0
  27. package/dist/dts-cjs/sink.d.ts.map +1 -0
  28. package/dist/dts-esm/classify.d.ts +101 -0
  29. package/dist/dts-esm/classify.d.ts.map +1 -0
  30. package/dist/dts-esm/identifiers.d.ts +34 -0
  31. package/dist/dts-esm/identifiers.d.ts.map +1 -0
  32. package/dist/dts-esm/index.d.ts +39 -0
  33. package/dist/dts-esm/index.d.ts.map +1 -0
  34. package/dist/dts-esm/package.json +3 -0
  35. package/dist/dts-esm/records.d.ts +56 -0
  36. package/dist/dts-esm/records.d.ts.map +1 -0
  37. package/dist/dts-esm/rest.d.ts +215 -0
  38. package/dist/dts-esm/rest.d.ts.map +1 -0
  39. package/dist/dts-esm/sink.d.ts +220 -0
  40. package/dist/dts-esm/sink.d.ts.map +1 -0
  41. package/dist/esm/classify.js +145 -0
  42. package/dist/esm/classify.js.map +1 -0
  43. package/dist/esm/identifiers.js +31 -0
  44. package/dist/esm/identifiers.js.map +1 -0
  45. package/dist/esm/index.js +38 -0
  46. package/dist/esm/index.js.map +1 -0
  47. package/dist/esm/package.json +4 -0
  48. package/dist/esm/records.js +96 -0
  49. package/dist/esm/records.js.map +1 -0
  50. package/dist/esm/rest.js +285 -0
  51. package/dist/esm/rest.js.map +1 -0
  52. package/dist/esm/sink.js +454 -0
  53. package/dist/esm/sink.js.map +1 -0
  54. package/package.json +33 -3
  55. package/src/classify.ts +201 -0
  56. package/src/identifiers.ts +45 -0
  57. package/src/index.ts +48 -0
  58. package/src/records.ts +109 -0
  59. package/src/rest.ts +773 -0
  60. package/src/sink.ts +733 -0
@@ -0,0 +1,463 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.resyncIndexName = exports.makeAlgoliaSink = void 0;
7
+ var _feed = require("@kairos-es/feed");
8
+ var _effect = require("effect");
9
+ var _identifiers = require("./identifiers.js");
10
+ var _records = require("./records.js");
11
+ var _rest = require("./rest.js");
12
+ /**
13
+ * `makeAlgoliaSink` — a `FeedSink` onto one served Algolia index, with both optional
14
+ * parts: a `FeedResync` built on Algolia's index copy and move, and a
15
+ * `FeedReconcile` built on `browse`.
16
+ *
17
+ * ## The steady state
18
+ *
19
+ * `apply` is ONE `batch` to the served index (`records.ts` owns what goes in it and
20
+ * which records are refused before it is sent), and its receipt is the index and
21
+ * the task Algolia returned. `confirm` polls `getTask` on that index until the task
22
+ * is `published`, which is the moment Algolia serves the batch — the moment the
23
+ * port's `confirm` promises. The poll runs on an Effect `Schedule`: it backs off
24
+ * from `taskPolling.initialDelay`, doubling to `taskPolling.maxDelay`, and no poll
25
+ * STARTS after `taskPolling.timeout` has passed since the wait began — the last
26
+ * wait is cut short to land on that deadline rather than overrun it by up to
27
+ * `maxDelay`. A task still unpublished then is a `SinkRetryable`: the driver
28
+ * resends the batch, which is safe, rather than waiting on a task without bound.
29
+ * A poll that has started can still outlast the deadline by its own request
30
+ * timeout (`rest.ts`). A `Schedule` rather than a hand-written loop because the
31
+ * curve is then one declared value, run under the `Clock`, so a test's
32
+ * `TestClock` drives it like any other wait.
33
+ *
34
+ * ## Resync: a fresh index, swapped in whole
35
+ *
36
+ * Algolia's `operationIndex` move replaces the destination index atomically, which
37
+ * is exactly the swap `FeedResync` asks for. So:
38
+ *
39
+ * - `begin(generation)` names the target `<served>__kairos_g<generation>_<attempt>`,
40
+ * where `<attempt>` is drawn from Effect's `Random` afresh on every call, and
41
+ * then COPIES the served index into it with `scope` limited to settings, synonyms
42
+ * and rules. The fresh index then ranks and matches exactly as the served one does,
43
+ * and holds none of its records. A served index that has never been written has no
44
+ * scope to copy, and that is not an error: `begin` reads the served index's
45
+ * settings first and makes no copy when it does not exist. Algolia would copy a
46
+ * missing source as an empty index, but the copy's task is polled on the served
47
+ * index, and `getTask` on an index that does not exist answers 404, so skipping
48
+ * the copy is what keeps that wait off a missing index. The name is the ATTEMPT's, not the
49
+ * generation's, because two workers can be resyncing one generation at once —
50
+ * one of them having lost its lease without knowing it yet — and a target they
51
+ * shared is one either could publish half-built; `runFeedSync.ts` owns the
52
+ * argument. Random rather than the lease holder's id, because the port hands a
53
+ * sink no lease, and a draw needs none.
54
+ * - `begin` then MARKS the target as its attempt's own: it writes the target's
55
+ * `userData` setting as whatever the copy brought from the served index, plus one
56
+ * key, `kairosResyncAttempt`, holding the attempt. Algolia creates an index on a
57
+ * settings write, so this is also what makes the target EXIST whatever the copy
58
+ * did — over a served index never written, and for a rebuild that loads nothing.
59
+ * The mark is what `publish` checks, below. It goes in `userData` because that
60
+ * is the one setting Algolia keeps for the application's own data and reads
61
+ * nothing from, so the mark changes nothing about how the target ranks or
62
+ * matches.
63
+ * - `begin` first drops every OTHER resync index of this served index it can list,
64
+ * whatever its generation, settings snapshots included. Each was left by an attempt that will never publish
65
+ * it now — one that crashed, one abandoned because the generation moved, or one
66
+ * a worker that lost its lease is still loading — and every such index holds
67
+ * records against the application's quota; an ephemeral feed, whose generation
68
+ * is new on every boot, would otherwise leave one behind per interrupted boot.
69
+ * Dropping the one a stale worker is loading is also what makes that worker's
70
+ * `publish` fail rather than land. Listing needs the key to carry Algolia's
71
+ * `listIndexes` ACL, and the sink REQUIRES it rather than skipping the drop when
72
+ * it is missing: without the drop, a stale worker's `publish` could land and
73
+ * abandoned targets would pile up. A key without it is refused like any other
74
+ * 4xx, a `SinkHalted` at the start of the resync, before the copy, so nothing is
75
+ * created.
76
+ * - `load` is `apply`, against the target. It does not check the mark: that would
77
+ * cost a settings read per page and still race the drop, and `publish` is where
78
+ * a target is served, so `publish` is where the check has to hold.
79
+ * - `publish` reads the target's settings and refuses, `SinkRetryable`, a target
80
+ * that is missing or does not carry this attempt's mark; only then does it move
81
+ * the target over the served index and wait on the move's task. Then it reads the
82
+ * SERVED index's settings and reports success only if the served index now
83
+ * carries this attempt's mark, which only this move could have put there;
84
+ * otherwise it fails `SinkRetryable`. That read is the authority, and the
85
+ * move's own answer is not: Algolia ignores a move of a missing index "without
86
+ * returning an error", so a target dropped after the check gets a task back, the
87
+ * task publishes, and the served index still holds the old copy. Taking that for
88
+ * success would let the driver save its cursor at the new generation over an
89
+ * index that never got it. For the same
90
+ * reason a 404 on the move's task poll — `getTask` on an index that no longer
91
+ * exists — ends the wait rather than halting, and the read decides. An empty rebuild
92
+ * is a move like any other, of a target that holds no records, and it leaves the
93
+ * served index empty. A target can go missing before its publish in two ways: a
94
+ * newer `begin` dropped it, or something moved it over the served index first.
95
+ * Only the second could ever land a half-built target, and the per-attempt name
96
+ * is what rules it out: the one `publish` that names this target is this
97
+ * attempt's own, so no other worker's can move it. What is left is the first,
98
+ * and it has a second form, which is why the mark exists: a worker that lost its
99
+ * lease without knowing it goes on LOADING into its dropped target, and Algolia
100
+ * creates an index on a batch write, so the target comes back holding the stale
101
+ * worker's later pages alone. A name alone cannot tell that index from the one
102
+ * `begin` made; the missing mark can, since only `begin` writes it. Either way
103
+ * `publish` fails `SinkRetryable` and the resync starts again. It never touches
104
+ * the served index on that account, since the worker that finds its target gone
105
+ * is the one most likely to have lost the lease. An index a stale load brought
106
+ * back is left where it is, and the next `begin` drops it with the rest.
107
+ * - Before it marks the target, `begin` takes a SNAPSHOT of the served index's
108
+ * settings into `<target>__settings`, a settings-scoped copy, or an empty
109
+ * settings write when the served index does not exist. After the move has landed,
110
+ * `publish` takes the mark back off the served index by copying the snapshot's
111
+ * settings over it, scoped to settings, then drops the snapshot. The drop is a
112
+ * best effort of its own, and runs whether or not the restore landed. A copy is
113
+ * the one write that REMOVES a setting: Algolia stores a `null` written to `userData` as
114
+ * the value `null`, and `{}` or `resetToDefault` leave an empty object, so a
115
+ * rebuild of an index that had no `userData` would otherwise leave one behind.
116
+ * The snapshot is a second copy of the same served index's settings, made just
117
+ * after the target's, so the restore changes nothing else unless those settings
118
+ * changed between the two copies, when it applies the later ones. Its scope is
119
+ * SETTINGS ONLY, and must stay so: the snapshot holds no synonyms or rules, so a
120
+ * wider restore would wipe the served index's. Records are untouched, and so are the
121
+ * served index's replicas (see below). A mark an earlier failed restore left on the
122
+ * served index is taken off the snapshot as it is made, so it cannot come
123
+ * back; when it was all the `userData` there was, a settings write is the only
124
+ * way to take it off, and the `null` Algolia stores for it is what remains.
125
+ * The move and the restore are two steps, so a search index briefly carries the
126
+ * key, which nothing reads. The restore is BEST-EFFORT: the swap has
127
+ * already landed, and failing `publish` now would tell the driver it had not. A
128
+ * snapshot that is gone (a newer `begin` dropped it) is not copied, since a copy
129
+ * from a missing index changes nothing and its task never publishes; the mark
130
+ * then stays until the next rebuild's restore, and a warning says so. One that
131
+ * goes between that check and the copy is reported the same way, once the wait
132
+ * on the copy's task ends: at the first poll, if `getTask` on the missing index
133
+ * answers 404 as above, and at `pollTimeout` if it does not. The swap has landed
134
+ * by then, so the worst cost is a `publish` that returns late, not a wrong one.
135
+ *
136
+ * Check-then-move leaves no gap a stale load can use: the only worker that loads
137
+ * into this target is the one publishing it, and its loads have finished. A drop
138
+ * between the check and the move makes the move a no-op, which the read of the
139
+ * served index after it turns into a `SinkRetryable`, as above. Taking the mark
140
+ * off the TARGET before the move would open one: a drop between that write and
141
+ * the move, and the write itself would bring back an empty target for the move to
142
+ * land.
143
+ *
144
+ * Replicas are the one thing this does not settle by construction: a copy with
145
+ * `scope` is not documented to carry a primary's replicas to the target, nor a
146
+ * move over a primary, or the restore's settings-scoped copy over it, to keep
147
+ * them. Measured against a real application, all three leave them where they
148
+ * were, and the package's live test checks it after `publish` when credentials
149
+ * are present.
150
+ *
151
+ * ## Reconcile
152
+ *
153
+ * `listKeys` browses the served index page by page, retrieving `objectID` and nothing
154
+ * else, so listing a large index costs the ids and not the records.
155
+ *
156
+ * ## The key's ACLs
157
+ *
158
+ * A scoped key needs `addObject` (record writes, and the copy and the move),
159
+ * `deleteObject`, `deleteIndex` (the drop), `settings` (the mark check),
160
+ * `editSettings` (writing and removing the mark), `browse` (reconcile) and
161
+ * `listIndexes` (the drop's listing), on the served index and its resync indices.
162
+ * The README states the same set, ACL by ACL, for a consumer.
163
+ *
164
+ * ## What the sink holds
165
+ *
166
+ * Nothing between calls but its options and the captured `HttpClient`: no queue, no
167
+ * cursor, no buffered batch. Every piece of progress is the driver's, which is what
168
+ * lets a resent batch and a restarted resync be safe.
169
+ */
170
+
171
+ /**
172
+ * The resync target's name for one attempt at `generation`. PACKAGE-INTERNAL, and
173
+ * kept off the entry point deliberately: `attempt` is what `begin` draws for each
174
+ * call, so a real target's name is the one `begin` returns, and a consumer has no
175
+ * use for composing one.
176
+ *
177
+ * It does not check `attempt`. Every caller here passes lower-case base-36
178
+ * digits, which `begin` draws and the tests write by hand, and that shape is all
179
+ * `resyncGenerationOf` below recognises: a suffix of any other shape would name an
180
+ * index that `begin` never drops and `publish` never accepts.
181
+ */
182
+ const resyncIndexName = (served, generation, attempt) => _identifiers.AlgoliaIndexName.make(`${served}__kairos_g${generation}_${attempt}`);
183
+ /**
184
+ * The generation `name` rebuilds, when it is a resync target of `served` for SOME
185
+ * generation and attempt, and `undefined` otherwise: the prefix, the decimal digits
186
+ * a `Generation` prints as, an underscore, then base-36 digits and nothing else,
187
+ * so a served index that merely starts with this one's name plus `__kairos_g` is not
188
+ * mistaken for one of its targets.
189
+ */
190
+ exports.resyncIndexName = resyncIndexName;
191
+ const resyncGenerationOf = (served, name) => {
192
+ const prefix = `${served}__kairos_g`;
193
+ if (!name.startsWith(prefix)) {
194
+ return undefined;
195
+ }
196
+ return /^(\d+)_[0-9a-z]+$/.exec(name.slice(prefix.length))?.[1];
197
+ };
198
+ /**
199
+ * The settings snapshot `begin` takes for `target`: the served index's settings as
200
+ * the target got them, before the mark. Named after its target, so it is a resync
201
+ * index of the same served index and `begin`'s drop of leftovers takes a snapshot an
202
+ * interrupted attempt left, as it takes the target.
203
+ */
204
+ const settingsSnapshotName = target => _identifiers.AlgoliaIndexName.make(`${target}__settings`);
205
+ /**
206
+ * Whether `name` is any resync index of `served`: a target, or a target's settings
207
+ * snapshot. What `begin` drops; `resyncGenerationOf` alone is what a target must be.
208
+ */
209
+ const isResyncIndexOf = (served, name) => resyncGenerationOf(served, name.replace(/__settings$/, '')) !== undefined;
210
+ /**
211
+ * The attempt a resync target's name ends in: the base-36 digits after its last
212
+ * underscore. Called only on a name `targetIndex` has already checked.
213
+ */
214
+ const attemptOf = name => /_([0-9a-z]+)$/.exec(name)?.[1] ?? '';
215
+ /** The `userData` key `begin` marks its target with; see the module doc. */
216
+ const ATTEMPT_MARK = 'kairosResyncAttempt';
217
+ /** Digits in a resync target's attempt suffix: 12 base-36 digits, about 62 bits. */
218
+ const ATTEMPT_DIGITS = 12;
219
+ /** One base-36 digit's worth of `Random`, `ATTEMPT_DIGITS` times. */
220
+ const drawAttempt = /*#__PURE__*/_effect.Effect.map(/*#__PURE__*/_effect.Effect.all(/*#__PURE__*/Array.from({
221
+ length: ATTEMPT_DIGITS
222
+ }, () => _effect.Random.nextIntBetween(0, 35))), digits => digits.map(d => d.toString(36)).join(''));
223
+ const RESYNC_SCOPE = ['settings', 'synonyms', 'rules'];
224
+ /** Index names per `listIndices` page; Algolia's own default. */
225
+ const LIST_PAGE_SIZE = 100;
226
+ const DEFAULT_POLL_INITIAL = /*#__PURE__*/_effect.Duration.millis(100);
227
+ const DEFAULT_POLL_MAX = /*#__PURE__*/_effect.Duration.seconds(2);
228
+ const DEFAULT_POLL_TIMEOUT = /*#__PURE__*/_effect.Duration.seconds(60);
229
+ const DEFAULT_BROWSE_PAGE = 1_000;
230
+ /** A positive duration from an option, or the default; dies on nonsense. */
231
+ const durationOption = (label, input, fallback) => {
232
+ if (input === undefined) {
233
+ return fallback;
234
+ }
235
+ const decoded = _effect.Duration.fromInputUnsafe(input);
236
+ if (!_effect.Duration.isFinite(decoded) || _effect.Duration.toMillis(decoded) <= 0) {
237
+ throw new Error(`feed-algolia: ${label} must be a positive, finite duration`);
238
+ }
239
+ return decoded;
240
+ };
241
+ const positiveInteger = (label, value, fallback) => {
242
+ if (value === undefined) {
243
+ return fallback;
244
+ }
245
+ if (!Number.isInteger(value) || value <= 0) {
246
+ throw new Error(`feed-algolia: ${label} must be a positive integer (got ${value})`);
247
+ }
248
+ return value;
249
+ };
250
+ /**
251
+ * An index's `userData` setting as an object to add a key to or take one from:
252
+ * empty when the index has none. A `userData` that is not a JSON object leaves no
253
+ * room for the mark beside it, and is a halt rather than something to overwrite.
254
+ */
255
+ const userDataOf = (index, settings) => {
256
+ const userData = _effect.Option.isSome(settings) ? settings.value.userData : undefined;
257
+ if (userData === undefined || userData === null) {
258
+ return _effect.Effect.succeed({});
259
+ }
260
+ return typeof userData === 'object' && !Array.isArray(userData) ? _effect.Effect.succeed(userData) : _effect.Effect.fail(new _feed.SinkHalted({
261
+ reason: `feed-algolia: the userData setting of '${index}' is not a JSON object, so a resync cannot mark its target beside it`
262
+ }));
263
+ };
264
+ /**
265
+ * The resync mark an index's settings carry, if any. Unlike {@link userDataOf} it
266
+ * never halts: after a move, a `userData` that is not an object only means the
267
+ * move did not land, which is the retryable case.
268
+ */
269
+ const attemptMarkOf = settings => {
270
+ const userData = _effect.Option.isSome(settings) ? settings.value.userData : undefined;
271
+ return typeof userData === 'object' && userData !== null && !Array.isArray(userData) ? userData[ATTEMPT_MARK] : undefined;
272
+ };
273
+ /**
274
+ * Narrow a call that sends no records. Only a write can be refused per record, so
275
+ * a `SinkRejected` from anything else is a bug in this package's classification;
276
+ * it is reported as a halt rather than silently dropped.
277
+ */
278
+ const noRejection = effect => _effect.Effect.mapError(effect, failure => failure._tag === 'SinkRejected' ? new _feed.SinkHalted({
279
+ reason: `feed-algolia: a call that sends no records was refused per record: ${failure.message}`
280
+ }) : failure);
281
+ /**
282
+ * Build an Algolia sink over the `HttpClient` in context; see the module doc.
283
+ * Dies at once on options out of range.
284
+ */
285
+ const makeAlgoliaSink = options => _effect.Effect.gen(function* () {
286
+ const settings = yield* _effect.Effect.try({
287
+ try: () => ({
288
+ maxRecordBytes: positiveInteger('maxRecordBytes', options.maxRecordBytes, _records.DEFAULT_MAX_RECORD_BYTES),
289
+ browsePageSize: positiveInteger('browsePageSize', options.browsePageSize, DEFAULT_BROWSE_PAGE),
290
+ pollInitial: durationOption('taskPolling.initialDelay', options.taskPolling?.initialDelay, DEFAULT_POLL_INITIAL),
291
+ pollMax: durationOption('taskPolling.maxDelay', options.taskPolling?.maxDelay, DEFAULT_POLL_MAX),
292
+ pollTimeout: durationOption('taskPolling.timeout', options.taskPolling?.timeout, DEFAULT_POLL_TIMEOUT)
293
+ }),
294
+ catch: error => error
295
+ }).pipe(_effect.Effect.orDie);
296
+ const rest = yield* (0, _rest.makeAlgoliaRest)(options);
297
+ const served = options.index;
298
+ const pollTimeoutMillis = _effect.Duration.toMillis(settings.pollTimeout);
299
+ /**
300
+ * The curve a task poll waits on between polls: doubling from the first delay,
301
+ * capped, and never past `deadline` — the last delay is cut to land on it, and
302
+ * nothing recurs once it is reached. Built per wait, since the deadline is
303
+ * the wait's own; the curve is the same for every one.
304
+ */
305
+ const pollSchedule = deadline => _effect.Schedule.min([_effect.Schedule.exponential(settings.pollInitial, 2), _effect.Schedule.spaced(settings.pollMax)]).pipe(_effect.Schedule.while(({
306
+ now
307
+ }) => now < deadline), _effect.Schedule.modifyDelay(({
308
+ now,
309
+ duration
310
+ }) => _effect.Effect.succeed(_effect.Duration.millis(Math.min(_effect.Duration.toMillis(duration), deadline - now)))));
311
+ /**
312
+ * Poll until `poll` is done; see the module doc for the curve. `poll` answers
313
+ * whether to stop, which for every wait but one is "the task is published".
314
+ */
315
+ const pollUntil = (index, taskID, poll) => _effect.Effect.gen(function* () {
316
+ const deadline = (yield* _effect.Clock.currentTimeMillis) + pollTimeoutMillis;
317
+ const done = yield* poll.pipe(_effect.Effect.repeat({
318
+ schedule: pollSchedule(deadline),
319
+ until: d => d
320
+ }));
321
+ if (!done) {
322
+ return yield* _effect.Effect.fail(new _feed.SinkRetryable({
323
+ reason: `Algolia task ${taskID} on index '${index}' was not published within ${_effect.Duration.format(settings.pollTimeout)}`
324
+ }));
325
+ }
326
+ });
327
+ /** Poll a task until it is published; see the module doc for the curve. */
328
+ const awaitTask = (index, taskID) => pollUntil(index, taskID, noRejection(rest.taskPublished(index, taskID)));
329
+ /** Wait on a task an index operation may or may not have returned. */
330
+ const awaitIfAny = (index, task) => _effect.Option.match(task, {
331
+ onNone: () => _effect.Effect.void,
332
+ onSome: taskID => awaitTask(index, taskID)
333
+ });
334
+ const send = (index, batch) => _effect.Effect.gen(function* () {
335
+ const encoded = yield* (0, _records.encodeBatch)(options.doc, settings.maxRecordBytes, batch);
336
+ const taskID = yield* rest.batch(index, encoded.operations, encoded.keys);
337
+ return {
338
+ index,
339
+ taskID
340
+ };
341
+ });
342
+ /**
343
+ * The target's index name, refusing a target this sink did not begin: a
344
+ * target is named after the served index, its generation and its attempt, and a
345
+ * load into any other index would write somewhere no publish of this sink will
346
+ * move.
347
+ */
348
+ const targetIndex = target => resyncGenerationOf(served, target.name) === `${target.generation}` ? _effect.Effect.succeed(_identifiers.AlgoliaIndexName.make(target.name)) : _effect.Effect.die(new Error(`feed-algolia: the resync target '${target.name}' is not one of this sink's targets for generation ${target.generation} ('${served}__kairos_g${target.generation}_<attempt>')`));
349
+ /** Every name `listIndices` returns, page by page. */
350
+ const allIndexNames = _effect.Stream.paginate(0, page => _effect.Effect.map(noRejection(rest.listIndexNames(page, LIST_PAGE_SIZE)), answer => [answer.names, answer.more ? _effect.Option.some(page + 1) : _effect.Option.none()]));
351
+ /** Drop every resync target of `served` but `keep`; see the module doc. */
352
+ const dropAbandonedTargets = keep => _effect.Effect.gen(function* () {
353
+ const names = yield* _effect.Stream.runCollect(allIndexNames);
354
+ for (const name of names) {
355
+ if (name === keep || !isResyncIndexOf(served, name)) {
356
+ continue;
357
+ }
358
+ const index = _identifiers.AlgoliaIndexName.make(name);
359
+ yield* awaitIfAny(index, yield* noRejection(rest.deleteIndex(index)));
360
+ }
361
+ });
362
+ const resync = {
363
+ begin: generation => _effect.Effect.gen(function* () {
364
+ const attempt = yield* drawAttempt;
365
+ const target = resyncIndexName(served, generation, attempt);
366
+ yield* dropAbandonedTargets(target);
367
+ const snapshot = settingsSnapshotName(target);
368
+ // No copy of a served index that does not exist; see the module doc.
369
+ if (_effect.Option.isSome(yield* noRejection(rest.readSettings(served)))) {
370
+ yield* awaitTask(served, yield* noRejection(rest.operation(served, {
371
+ operation: 'copy',
372
+ destination: target,
373
+ scope: RESYNC_SCOPE
374
+ })));
375
+ yield* awaitTask(served, yield* noRejection(rest.operation(served, {
376
+ operation: 'copy',
377
+ destination: snapshot,
378
+ scope: ['settings']
379
+ })));
380
+ // A mark a failed restore left on the served index must not ride into
381
+ // the snapshot, or every restore would put it back.
382
+ const {
383
+ [ATTEMPT_MARK]: stale,
384
+ ...kept
385
+ } = yield* userDataOf(snapshot, yield* noRejection(rest.readSettings(snapshot)));
386
+ if (stale !== undefined) {
387
+ yield* awaitTask(snapshot, yield* noRejection(rest.writeSettings(snapshot, {
388
+ userData: Object.keys(kept).length === 0 ? null : kept
389
+ })));
390
+ }
391
+ } else {
392
+ // An index with every setting at its default, which is what the
393
+ // served index has; an empty settings write creates it.
394
+ yield* awaitTask(snapshot, yield* noRejection(rest.writeSettings(snapshot, {})));
395
+ }
396
+ const userData = yield* userDataOf(target, yield* noRejection(rest.readSettings(target)));
397
+ yield* awaitTask(target, yield* noRejection(rest.writeSettings(target, {
398
+ userData: {
399
+ ...userData,
400
+ [ATTEMPT_MARK]: attempt
401
+ }
402
+ })));
403
+ return {
404
+ generation,
405
+ name: target
406
+ };
407
+ }),
408
+ load: (target, batch) => _effect.Effect.flatMap(targetIndex(target), index => send(index, batch)),
409
+ publish: target => _effect.Effect.gen(function* () {
410
+ const index = yield* targetIndex(target);
411
+ const found = yield* noRejection(rest.readSettings(index));
412
+ const userData = yield* userDataOf(index, found);
413
+ if (userData[ATTEMPT_MARK] !== attemptOf(index)) {
414
+ return yield* _effect.Effect.fail(new _feed.SinkRetryable({
415
+ reason: _effect.Option.isNone(found) ? `the resync index '${index}' vanished before it could be moved over '${served}'` : `the resync index '${index}' is not the one its begin made (a load after a newer begin dropped it brought it back), so it is not moved over '${served}'`
416
+ }));
417
+ }
418
+ const moved = yield* noRejection(rest.operation(index, {
419
+ operation: 'move',
420
+ destination: served
421
+ }));
422
+ // A 404 on the poll ends the wait; the read below decides.
423
+ yield* pollUntil(index, moved, _effect.Effect.map(noRejection(rest.taskStateUnlessIndexMissing(index, moved)), state => state !== 'pending'));
424
+ const landed = yield* noRejection(rest.readSettings(served));
425
+ if (attemptMarkOf(landed) !== attemptOf(index)) {
426
+ return yield* _effect.Effect.fail(new _feed.SinkRetryable({
427
+ reason: `the move of the resync index '${index}' over '${served}' did not land: '${served}' does not carry its mark, so the index was dropped after publish checked it, or another attempt's move landed over it`
428
+ }));
429
+ }
430
+ // Best-effort, after the swap has landed; see the module doc.
431
+ const snapshot = settingsSnapshotName(index);
432
+ yield* _effect.Effect.gen(function* () {
433
+ // A copy from a missing snapshot changes nothing, and its task never
434
+ // publishes, so a snapshot a newer `begin` dropped is not waited on,
435
+ // unless it goes between this read and the copy; see the module doc.
436
+ if (_effect.Option.isNone(yield* noRejection(rest.readSettings(snapshot)))) {
437
+ return yield* _effect.Effect.fail(new _feed.SinkRetryable({
438
+ reason: `its settings snapshot '${snapshot}' is gone`
439
+ }));
440
+ }
441
+ yield* awaitTask(snapshot, yield* noRejection(rest.operation(snapshot, {
442
+ operation: 'copy',
443
+ destination: served,
444
+ scope: ['settings']
445
+ })));
446
+ }).pipe(_effect.Effect.catch(failure => _effect.Effect.logWarning(`feed-algolia: the resync mark was left in the userData of '${served}', and the next rebuild will take it off: ${failure.reason}`)));
447
+ // Separately, so a failed drop is not reported as a failed restore.
448
+ yield* _effect.Effect.flatMap(noRejection(rest.deleteIndex(snapshot)), task => awaitIfAny(snapshot, task)).pipe(_effect.Effect.catch(failure => _effect.Effect.logWarning(`feed-algolia: the settings snapshot '${snapshot}' was left behind, and the next rebuild will drop it: ${failure.reason}`)));
449
+ })
450
+ };
451
+ const reconcile = {
452
+ listKeys: _effect.Stream.paginate(_effect.Option.none(), cursor => _effect.Effect.map(noRejection(rest.browseObjectIds(served, cursor, settings.browsePageSize)), page => [page.objectIDs.filter(id => id.length > 0).map(id => _feed.FeedKey.make(id)), _effect.Option.map(page.cursor, _effect.Option.some)]))
453
+ };
454
+ return {
455
+ name: options.name,
456
+ apply: batch => send(served, batch),
457
+ confirm: receipt => awaitTask(receipt.index, receipt.taskID),
458
+ resync,
459
+ reconcile
460
+ };
461
+ });
462
+ exports.makeAlgoliaSink = makeAlgoliaSink;
463
+ //# sourceMappingURL=sink.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sink.js","names":["_feed","require","_effect","_identifiers","_records","_rest","resyncIndexName","served","generation","attempt","AlgoliaIndexName","make","exports","resyncGenerationOf","name","prefix","startsWith","undefined","exec","slice","length","settingsSnapshotName","target","isResyncIndexOf","replace","attemptOf","ATTEMPT_MARK","ATTEMPT_DIGITS","drawAttempt","Effect","map","all","Array","from","Random","nextIntBetween","digits","d","toString","join","RESYNC_SCOPE","LIST_PAGE_SIZE","DEFAULT_POLL_INITIAL","Duration","millis","DEFAULT_POLL_MAX","seconds","DEFAULT_POLL_TIMEOUT","DEFAULT_BROWSE_PAGE","durationOption","label","input","fallback","decoded","fromInputUnsafe","isFinite","toMillis","Error","positiveInteger","value","Number","isInteger","userDataOf","index","settings","userData","Option","isSome","succeed","isArray","fail","SinkHalted","reason","attemptMarkOf","noRejection","effect","mapError","failure","_tag","message","makeAlgoliaSink","options","gen","try","maxRecordBytes","DEFAULT_MAX_RECORD_BYTES","browsePageSize","pollInitial","taskPolling","initialDelay","pollMax","maxDelay","pollTimeout","timeout","catch","error","pipe","orDie","rest","makeAlgoliaRest","pollTimeoutMillis","pollSchedule","deadline","Schedule","min","exponential","spaced","while","now","modifyDelay","duration","Math","pollUntil","taskID","poll","Clock","currentTimeMillis","done","repeat","schedule","until","SinkRetryable","format","awaitTask","taskPublished","awaitIfAny","task","match","onNone","void","onSome","send","batch","encoded","encodeBatch","doc","operations","keys","targetIndex","die","allIndexNames","Stream","paginate","page","listIndexNames","answer","names","more","some","none","dropAbandonedTargets","keep","runCollect","deleteIndex","resync","begin","snapshot","readSettings","operation","destination","scope","stale","kept","writeSettings","Object","load","flatMap","publish","found","isNone","moved","taskStateUnlessIndexMissing","state","landed","logWarning","reconcile","listKeys","cursor","browseObjectIds","objectIDs","filter","id","FeedKey","apply","confirm","receipt"],"sources":["../../src/sink.ts"],"sourcesContent":[null],"mappings":"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","ignoreList":[]}
@@ -0,0 +1,101 @@
1
+ /**
2
+ * How an Algolia answer becomes one of the feed's three sink failures.
3
+ *
4
+ * The driver's whole behaviour on failure follows from which of the three a sink
5
+ * names (`@kairos-es/feed`'s `errors.ts` owns that), so this mapping is the most
6
+ * consequential thing in the package and it is kept pure, in one place, and tested
7
+ * on its own. The rules:
8
+ *
9
+ * - **A 429 is `SinkRetryable`**, carrying `Retry-After` when Algolia sends one,
10
+ * so the driver backs off no less than Algolia asked — UNLESS its message names
11
+ * a QUOTA rather than a rate: Algolia answers 429 for an application over its
12
+ * record quota or its indexing file-size allowance too, and neither lifts by
13
+ * waiting, so retrying would only hold the feed until supervision reported it
14
+ * stalled, with Algolia's reason lost. Such a 429 is `SinkHalted`, carrying
15
+ * Algolia's message. The wordings recognised are {@link QUOTA_429}; a 429 whose
16
+ * wording is not among them stays retryable, which is the safe side to err on.
17
+ * - **Every 5xx is `SinkRetryable`, and so is a 1xx or 3xx**, a status no call
18
+ * here expects and one the transport has already tried on every host, as the
19
+ * official client does. A transport failure — no answer at all — is retryable
20
+ * too: the request may never have arrived, and resending is safe because every
21
+ * write this sink makes is a full-record replace or a delete.
22
+ * - **A 400 that names an `objectID` in the batch is `SinkRejected` for that key.**
23
+ * Algolia refuses a whole batch for one bad record, and names the record in its
24
+ * message (`… objectID=<id> …`). Parking just that key lets the driver send the
25
+ * rest again without it, which is the difference between one record missing from
26
+ * search and the whole feed stopping. The key has to be one the batch carried: an
27
+ * id the batch did not send is not a record the driver can park, so such an
28
+ * answer falls through to the next rule. The message does not delimit the id, and
29
+ * a feed key may hold a space, a comma or anything else, so the id is not parsed
30
+ * out of the message: the batch's own keys are matched against what follows
31
+ * `objectID=` (`objectIdNamedIn`).
32
+ * - **Every other 4xx is `SinkHalted`.** Bad credentials, a key without the right
33
+ * ACL, an index name Algolia will not accept, a request this package built wrong:
34
+ * none of them is mended by sending again, so the worker stops and supervision
35
+ * reports it.
36
+ *
37
+ * Nothing here reads the request, and nothing here sees a header but `Retry-After`,
38
+ * so no classification can carry the admin key into a reason string.
39
+ */
40
+ import type { FeedKey } from '@kairos-es/feed';
41
+ import { SinkHalted, SinkRejected, SinkRetryable } from '@kairos-es/feed';
42
+ import { Duration, Option } from 'effect';
43
+ /** Every failure an Algolia call can end in, already in the feed's vocabulary. */
44
+ export type AlgoliaFailure = SinkRetryable | SinkRejected | SinkHalted;
45
+ /** An Algolia answer that was not a success, reduced to what classifying needs. */
46
+ export interface AlgoliaErrorAnswer {
47
+ readonly status: number;
48
+ /** Algolia's `message`, or the raw body when it sent none. */
49
+ readonly message: string;
50
+ /** The `Retry-After` header, verbatim, when there was one. */
51
+ readonly retryAfter: Option.Option<string>;
52
+ }
53
+ /** What the call was, for the reason string: never its headers. */
54
+ export interface AlgoliaCallLabel {
55
+ readonly method: string;
56
+ readonly path: string;
57
+ }
58
+ /**
59
+ * The key of `batchKeys` an Algolia error message names, when it names one.
60
+ *
61
+ * Algolia's record-level refusals all carry `objectID=<id>` (the record-too-big
62
+ * message is the common one), with nothing marking where the id ends. So each
63
+ * batch key is tried against the text after each `objectID=`, and it matches when
64
+ * that text starts with the key and the key is followed by the end of the message,
65
+ * a space or punctuation; the longest key that matches wins, so of `big` and
66
+ * `big one` the message `objectID=big one is too big` names the second.
67
+ */
68
+ export declare const objectIdNamedIn: (message: string, batchKeys: ReadonlySet<FeedKey>) => Option.Option<FeedKey>;
69
+ /**
70
+ * The 429 wordings that name a quota, not a rate, matched case-insensitively
71
+ * anywhere in Algolia's message: an application over its record quota, or over
72
+ * its allowance of indexing by file size. See the module doc for why each halts.
73
+ */
74
+ export declare const QUOTA_429: ReadonlyArray<RegExp>;
75
+ /**
76
+ * `Retry-After` as a duration, given the current time.
77
+ *
78
+ * The header is either a whole number of seconds or an HTTP date. A date is turned
79
+ * into a wait against `now`, which the caller reads from `Clock` so a test's
80
+ * `TestClock` governs it; a date already past is a wait of zero. Anything else is
81
+ * ignored rather than guessed at, and the driver's own back-off applies.
82
+ */
83
+ export declare const parseRetryAfter: (header: string, nowMillis: number) => Option.Option<Duration.Duration>;
84
+ /**
85
+ * Classify an Algolia error answer; see the module doc for the rules.
86
+ *
87
+ * `batchKeys` is the set of keys the request carried, empty for a call that sends
88
+ * no records, so only a write can ever produce a `SinkRejected`.
89
+ */
90
+ export declare const classifyAnswer: (call: AlgoliaCallLabel, answer: AlgoliaErrorAnswer, batchKeys: ReadonlySet<FeedKey>, nowMillis: number) => AlgoliaFailure;
91
+ /**
92
+ * A request that got no answer at all. Retryable: see the module doc for why a
93
+ * resend is safe.
94
+ */
95
+ export declare const transportFailure: (call: AlgoliaCallLabel, detail: string) => AlgoliaFailure;
96
+ /**
97
+ * An answer this package cannot read: a success whose body is not the shape
98
+ * Algolia documents. Halted, because sending again will get the same body back.
99
+ */
100
+ export declare const unreadableAnswer: (call: AlgoliaCallLabel, detail: string) => AlgoliaFailure;
101
+ //# sourceMappingURL=classify.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"classify.d.ts","sourceRoot":"","sources":["../../src/classify.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAA;AAC9C,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAA;AACzE,OAAO,EAAY,QAAQ,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAEnD,kFAAkF;AAClF,MAAM,MAAM,cAAc,GAAG,aAAa,GAAG,YAAY,GAAG,UAAU,CAAA;AAEtE,mFAAmF;AACnF,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,8DAA8D;IAC9D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,8DAA8D;IAC9D,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;CAC3C;AAED,mEAAmE;AACnE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAOD;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,GAC1B,SAAS,MAAM,EACf,WAAW,WAAW,CAAC,OAAO,CAAC,KAC9B,MAAM,CAAC,MAAM,CAAC,OAAO,CAiBvB,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,SAAS,EAAE,aAAa,CAAC,MAAM,CAA4B,CAAA;AAExE;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,GAC1B,QAAQ,MAAM,EACd,WAAW,MAAM,KAChB,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAajC,CAAA;AAED;;;;;GAKG;AACH,eAAO,MAAM,cAAc,GACzB,MAAM,gBAAgB,EACtB,QAAQ,kBAAkB,EAC1B,WAAW,WAAW,CAAC,OAAO,CAAC,EAC/B,WAAW,MAAM,KAChB,cAiCF,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,gBAAgB,GAC3B,MAAM,gBAAgB,EACtB,QAAQ,MAAM,KACb,cAGC,CAAA;AAEJ;;;GAGG;AACH,eAAO,MAAM,gBAAgB,GAC3B,MAAM,gBAAgB,EACtB,QAAQ,MAAM,KACb,cAGC,CAAA"}
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The Algolia sink's branded identifiers.
3
+ *
4
+ * Three values cross this package's boundary that would otherwise be bare
5
+ * strings and numbers, and each is easy to pass in the place of another: an
6
+ * application id where an index name belongs, a feed key where an index name
7
+ * belongs, a task id from one index polled against another. Branding them is the
8
+ * repository's standing rule for a domain identifier, and here it is also what
9
+ * keeps a `Receipt` — an index and a task id — from being assembled by hand out of
10
+ * values that never belonged together.
11
+ */
12
+ import { Schema } from 'effect';
13
+ /**
14
+ * The Algolia application id: the first half of the credentials, and the name of
15
+ * the application's hosts. Not a secret — it is sent beside every search a browser
16
+ * makes — so it is a plain branded string, unlike the admin key.
17
+ */
18
+ export declare const AlgoliaAppId: Schema.brand<Schema.String, "AlgoliaAppId">;
19
+ export type AlgoliaAppId = typeof AlgoliaAppId.Type;
20
+ /**
21
+ * The name of an Algolia index. The sink's SERVED index is one; a resync builds into
22
+ * another, named after the served one, the generation it rebuilds and the attempt.
23
+ */
24
+ export declare const AlgoliaIndexName: Schema.brand<Schema.String, "AlgoliaIndexName">;
25
+ export type AlgoliaIndexName = typeof AlgoliaIndexName.Type;
26
+ /**
27
+ * An Algolia task id: what every write returns, and what `getTask` is polled with
28
+ * until the write is published. A task belongs to the index it was issued on, and
29
+ * is polled there and nowhere else — which is why a receipt carries the index
30
+ * beside it.
31
+ */
32
+ export declare const AlgoliaTaskId: Schema.brand<Schema.Number, "AlgoliaTaskId">;
33
+ export type AlgoliaTaskId = typeof AlgoliaTaskId.Type;
34
+ //# sourceMappingURL=identifiers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"identifiers.d.ts","sourceRoot":"","sources":["../../src/identifiers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAE/B;;;;GAIG;AACH,eAAO,MAAM,YAAY,6CAGxB,CAAA;AACD,MAAM,MAAM,YAAY,GAAG,OAAO,YAAY,CAAC,IAAI,CAAA;AAEnD;;;GAGG;AACH,eAAO,MAAM,gBAAgB,iDAG5B,CAAA;AACD,MAAM,MAAM,gBAAgB,GAAG,OAAO,gBAAgB,CAAC,IAAI,CAAA;AAE3D;;;;;GAKG;AACH,eAAO,MAAM,aAAa,8CAGzB,CAAA;AACD,MAAM,MAAM,aAAa,GAAG,OAAO,aAAa,CAAC,IAAI,CAAA"}