@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.
- package/LICENSE +28 -0
- package/README.md +66 -2
- package/dist/cjs/classify.js +157 -0
- package/dist/cjs/classify.js.map +1 -0
- package/dist/cjs/identifiers.js +38 -0
- package/dist/cjs/identifiers.js.map +1 -0
- package/dist/cjs/index.js +39 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/records.js +105 -0
- package/dist/cjs/records.js.map +1 -0
- package/dist/cjs/rest.js +293 -0
- package/dist/cjs/rest.js.map +1 -0
- package/dist/cjs/sink.js +463 -0
- package/dist/cjs/sink.js.map +1 -0
- package/dist/dts-cjs/classify.d.ts +101 -0
- package/dist/dts-cjs/classify.d.ts.map +1 -0
- package/dist/dts-cjs/identifiers.d.ts +34 -0
- package/dist/dts-cjs/identifiers.d.ts.map +1 -0
- package/dist/dts-cjs/index.d.ts +39 -0
- package/dist/dts-cjs/index.d.ts.map +1 -0
- package/dist/dts-cjs/package.json +3 -0
- package/dist/dts-cjs/records.d.ts +56 -0
- package/dist/dts-cjs/records.d.ts.map +1 -0
- package/dist/dts-cjs/rest.d.ts +215 -0
- package/dist/dts-cjs/rest.d.ts.map +1 -0
- package/dist/dts-cjs/sink.d.ts +220 -0
- package/dist/dts-cjs/sink.d.ts.map +1 -0
- package/dist/dts-esm/classify.d.ts +101 -0
- package/dist/dts-esm/classify.d.ts.map +1 -0
- package/dist/dts-esm/identifiers.d.ts +34 -0
- package/dist/dts-esm/identifiers.d.ts.map +1 -0
- package/dist/dts-esm/index.d.ts +39 -0
- package/dist/dts-esm/index.d.ts.map +1 -0
- package/dist/dts-esm/package.json +3 -0
- package/dist/dts-esm/records.d.ts +56 -0
- package/dist/dts-esm/records.d.ts.map +1 -0
- package/dist/dts-esm/rest.d.ts +215 -0
- package/dist/dts-esm/rest.d.ts.map +1 -0
- package/dist/dts-esm/sink.d.ts +220 -0
- package/dist/dts-esm/sink.d.ts.map +1 -0
- package/dist/esm/classify.js +145 -0
- package/dist/esm/classify.js.map +1 -0
- package/dist/esm/identifiers.js +31 -0
- package/dist/esm/identifiers.js.map +1 -0
- package/dist/esm/index.js +38 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/package.json +4 -0
- package/dist/esm/records.js +96 -0
- package/dist/esm/records.js.map +1 -0
- package/dist/esm/rest.js +285 -0
- package/dist/esm/rest.js.map +1 -0
- package/dist/esm/sink.js +454 -0
- package/dist/esm/sink.js.map +1 -0
- package/package.json +33 -3
- package/src/classify.ts +201 -0
- package/src/identifiers.ts +45 -0
- package/src/index.ts +48 -0
- package/src/records.ts +109 -0
- package/src/rest.ts +773 -0
- package/src/sink.ts +733 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `makeAlgoliaSink` — a `FeedSink` onto one served Algolia index, with both optional
|
|
3
|
+
* parts: a `FeedResync` built on Algolia's index copy and move, and a
|
|
4
|
+
* `FeedReconcile` built on `browse`.
|
|
5
|
+
*
|
|
6
|
+
* ## The steady state
|
|
7
|
+
*
|
|
8
|
+
* `apply` is ONE `batch` to the served index (`records.ts` owns what goes in it and
|
|
9
|
+
* which records are refused before it is sent), and its receipt is the index and
|
|
10
|
+
* the task Algolia returned. `confirm` polls `getTask` on that index until the task
|
|
11
|
+
* is `published`, which is the moment Algolia serves the batch — the moment the
|
|
12
|
+
* port's `confirm` promises. The poll runs on an Effect `Schedule`: it backs off
|
|
13
|
+
* from `taskPolling.initialDelay`, doubling to `taskPolling.maxDelay`, and no poll
|
|
14
|
+
* STARTS after `taskPolling.timeout` has passed since the wait began — the last
|
|
15
|
+
* wait is cut short to land on that deadline rather than overrun it by up to
|
|
16
|
+
* `maxDelay`. A task still unpublished then is a `SinkRetryable`: the driver
|
|
17
|
+
* resends the batch, which is safe, rather than waiting on a task without bound.
|
|
18
|
+
* A poll that has started can still outlast the deadline by its own request
|
|
19
|
+
* timeout (`rest.ts`). A `Schedule` rather than a hand-written loop because the
|
|
20
|
+
* curve is then one declared value, run under the `Clock`, so a test's
|
|
21
|
+
* `TestClock` drives it like any other wait.
|
|
22
|
+
*
|
|
23
|
+
* ## Resync: a fresh index, swapped in whole
|
|
24
|
+
*
|
|
25
|
+
* Algolia's `operationIndex` move replaces the destination index atomically, which
|
|
26
|
+
* is exactly the swap `FeedResync` asks for. So:
|
|
27
|
+
*
|
|
28
|
+
* - `begin(generation)` names the target `<served>__kairos_g<generation>_<attempt>`,
|
|
29
|
+
* where `<attempt>` is drawn from Effect's `Random` afresh on every call, and
|
|
30
|
+
* then COPIES the served index into it with `scope` limited to settings, synonyms
|
|
31
|
+
* and rules. The fresh index then ranks and matches exactly as the served one does,
|
|
32
|
+
* and holds none of its records. A served index that has never been written has no
|
|
33
|
+
* scope to copy, and that is not an error: `begin` reads the served index's
|
|
34
|
+
* settings first and makes no copy when it does not exist. Algolia would copy a
|
|
35
|
+
* missing source as an empty index, but the copy's task is polled on the served
|
|
36
|
+
* index, and `getTask` on an index that does not exist answers 404, so skipping
|
|
37
|
+
* the copy is what keeps that wait off a missing index. The name is the ATTEMPT's, not the
|
|
38
|
+
* generation's, because two workers can be resyncing one generation at once —
|
|
39
|
+
* one of them having lost its lease without knowing it yet — and a target they
|
|
40
|
+
* shared is one either could publish half-built; `runFeedSync.ts` owns the
|
|
41
|
+
* argument. Random rather than the lease holder's id, because the port hands a
|
|
42
|
+
* sink no lease, and a draw needs none.
|
|
43
|
+
* - `begin` then MARKS the target as its attempt's own: it writes the target's
|
|
44
|
+
* `userData` setting as whatever the copy brought from the served index, plus one
|
|
45
|
+
* key, `kairosResyncAttempt`, holding the attempt. Algolia creates an index on a
|
|
46
|
+
* settings write, so this is also what makes the target EXIST whatever the copy
|
|
47
|
+
* did — over a served index never written, and for a rebuild that loads nothing.
|
|
48
|
+
* The mark is what `publish` checks, below. It goes in `userData` because that
|
|
49
|
+
* is the one setting Algolia keeps for the application's own data and reads
|
|
50
|
+
* nothing from, so the mark changes nothing about how the target ranks or
|
|
51
|
+
* matches.
|
|
52
|
+
* - `begin` first drops every OTHER resync index of this served index it can list,
|
|
53
|
+
* whatever its generation, settings snapshots included. Each was left by an attempt that will never publish
|
|
54
|
+
* it now — one that crashed, one abandoned because the generation moved, or one
|
|
55
|
+
* a worker that lost its lease is still loading — and every such index holds
|
|
56
|
+
* records against the application's quota; an ephemeral feed, whose generation
|
|
57
|
+
* is new on every boot, would otherwise leave one behind per interrupted boot.
|
|
58
|
+
* Dropping the one a stale worker is loading is also what makes that worker's
|
|
59
|
+
* `publish` fail rather than land. Listing needs the key to carry Algolia's
|
|
60
|
+
* `listIndexes` ACL, and the sink REQUIRES it rather than skipping the drop when
|
|
61
|
+
* it is missing: without the drop, a stale worker's `publish` could land and
|
|
62
|
+
* abandoned targets would pile up. A key without it is refused like any other
|
|
63
|
+
* 4xx, a `SinkHalted` at the start of the resync, before the copy, so nothing is
|
|
64
|
+
* created.
|
|
65
|
+
* - `load` is `apply`, against the target. It does not check the mark: that would
|
|
66
|
+
* cost a settings read per page and still race the drop, and `publish` is where
|
|
67
|
+
* a target is served, so `publish` is where the check has to hold.
|
|
68
|
+
* - `publish` reads the target's settings and refuses, `SinkRetryable`, a target
|
|
69
|
+
* that is missing or does not carry this attempt's mark; only then does it move
|
|
70
|
+
* the target over the served index and wait on the move's task. Then it reads the
|
|
71
|
+
* SERVED index's settings and reports success only if the served index now
|
|
72
|
+
* carries this attempt's mark, which only this move could have put there;
|
|
73
|
+
* otherwise it fails `SinkRetryable`. That read is the authority, and the
|
|
74
|
+
* move's own answer is not: Algolia ignores a move of a missing index "without
|
|
75
|
+
* returning an error", so a target dropped after the check gets a task back, the
|
|
76
|
+
* task publishes, and the served index still holds the old copy. Taking that for
|
|
77
|
+
* success would let the driver save its cursor at the new generation over an
|
|
78
|
+
* index that never got it. For the same
|
|
79
|
+
* reason a 404 on the move's task poll — `getTask` on an index that no longer
|
|
80
|
+
* exists — ends the wait rather than halting, and the read decides. An empty rebuild
|
|
81
|
+
* is a move like any other, of a target that holds no records, and it leaves the
|
|
82
|
+
* served index empty. A target can go missing before its publish in two ways: a
|
|
83
|
+
* newer `begin` dropped it, or something moved it over the served index first.
|
|
84
|
+
* Only the second could ever land a half-built target, and the per-attempt name
|
|
85
|
+
* is what rules it out: the one `publish` that names this target is this
|
|
86
|
+
* attempt's own, so no other worker's can move it. What is left is the first,
|
|
87
|
+
* and it has a second form, which is why the mark exists: a worker that lost its
|
|
88
|
+
* lease without knowing it goes on LOADING into its dropped target, and Algolia
|
|
89
|
+
* creates an index on a batch write, so the target comes back holding the stale
|
|
90
|
+
* worker's later pages alone. A name alone cannot tell that index from the one
|
|
91
|
+
* `begin` made; the missing mark can, since only `begin` writes it. Either way
|
|
92
|
+
* `publish` fails `SinkRetryable` and the resync starts again. It never touches
|
|
93
|
+
* the served index on that account, since the worker that finds its target gone
|
|
94
|
+
* is the one most likely to have lost the lease. An index a stale load brought
|
|
95
|
+
* back is left where it is, and the next `begin` drops it with the rest.
|
|
96
|
+
* - Before it marks the target, `begin` takes a SNAPSHOT of the served index's
|
|
97
|
+
* settings into `<target>__settings`, a settings-scoped copy, or an empty
|
|
98
|
+
* settings write when the served index does not exist. After the move has landed,
|
|
99
|
+
* `publish` takes the mark back off the served index by copying the snapshot's
|
|
100
|
+
* settings over it, scoped to settings, then drops the snapshot. The drop is a
|
|
101
|
+
* best effort of its own, and runs whether or not the restore landed. A copy is
|
|
102
|
+
* the one write that REMOVES a setting: Algolia stores a `null` written to `userData` as
|
|
103
|
+
* the value `null`, and `{}` or `resetToDefault` leave an empty object, so a
|
|
104
|
+
* rebuild of an index that had no `userData` would otherwise leave one behind.
|
|
105
|
+
* The snapshot is a second copy of the same served index's settings, made just
|
|
106
|
+
* after the target's, so the restore changes nothing else unless those settings
|
|
107
|
+
* changed between the two copies, when it applies the later ones. Its scope is
|
|
108
|
+
* SETTINGS ONLY, and must stay so: the snapshot holds no synonyms or rules, so a
|
|
109
|
+
* wider restore would wipe the served index's. Records are untouched, and so are the
|
|
110
|
+
* served index's replicas (see below). A mark an earlier failed restore left on the
|
|
111
|
+
* served index is taken off the snapshot as it is made, so it cannot come
|
|
112
|
+
* back; when it was all the `userData` there was, a settings write is the only
|
|
113
|
+
* way to take it off, and the `null` Algolia stores for it is what remains.
|
|
114
|
+
* The move and the restore are two steps, so a search index briefly carries the
|
|
115
|
+
* key, which nothing reads. The restore is BEST-EFFORT: the swap has
|
|
116
|
+
* already landed, and failing `publish` now would tell the driver it had not. A
|
|
117
|
+
* snapshot that is gone (a newer `begin` dropped it) is not copied, since a copy
|
|
118
|
+
* from a missing index changes nothing and its task never publishes; the mark
|
|
119
|
+
* then stays until the next rebuild's restore, and a warning says so. One that
|
|
120
|
+
* goes between that check and the copy is reported the same way, once the wait
|
|
121
|
+
* on the copy's task ends: at the first poll, if `getTask` on the missing index
|
|
122
|
+
* answers 404 as above, and at `pollTimeout` if it does not. The swap has landed
|
|
123
|
+
* by then, so the worst cost is a `publish` that returns late, not a wrong one.
|
|
124
|
+
*
|
|
125
|
+
* Check-then-move leaves no gap a stale load can use: the only worker that loads
|
|
126
|
+
* into this target is the one publishing it, and its loads have finished. A drop
|
|
127
|
+
* between the check and the move makes the move a no-op, which the read of the
|
|
128
|
+
* served index after it turns into a `SinkRetryable`, as above. Taking the mark
|
|
129
|
+
* off the TARGET before the move would open one: a drop between that write and
|
|
130
|
+
* the move, and the write itself would bring back an empty target for the move to
|
|
131
|
+
* land.
|
|
132
|
+
*
|
|
133
|
+
* Replicas are the one thing this does not settle by construction: a copy with
|
|
134
|
+
* `scope` is not documented to carry a primary's replicas to the target, nor a
|
|
135
|
+
* move over a primary, or the restore's settings-scoped copy over it, to keep
|
|
136
|
+
* them. Measured against a real application, all three leave them where they
|
|
137
|
+
* were, and the package's live test checks it after `publish` when credentials
|
|
138
|
+
* are present.
|
|
139
|
+
*
|
|
140
|
+
* ## Reconcile
|
|
141
|
+
*
|
|
142
|
+
* `listKeys` browses the served index page by page, retrieving `objectID` and nothing
|
|
143
|
+
* else, so listing a large index costs the ids and not the records.
|
|
144
|
+
*
|
|
145
|
+
* ## The key's ACLs
|
|
146
|
+
*
|
|
147
|
+
* A scoped key needs `addObject` (record writes, and the copy and the move),
|
|
148
|
+
* `deleteObject`, `deleteIndex` (the drop), `settings` (the mark check),
|
|
149
|
+
* `editSettings` (writing and removing the mark), `browse` (reconcile) and
|
|
150
|
+
* `listIndexes` (the drop's listing), on the served index and its resync indices.
|
|
151
|
+
* The README states the same set, ACL by ACL, for a consumer.
|
|
152
|
+
*
|
|
153
|
+
* ## What the sink holds
|
|
154
|
+
*
|
|
155
|
+
* Nothing between calls but its options and the captured `HttpClient`: no queue, no
|
|
156
|
+
* cursor, no buffered batch. Every piece of progress is the driver's, which is what
|
|
157
|
+
* lets a resent batch and a restarted resync be safe.
|
|
158
|
+
*/
|
|
159
|
+
import { type FeedSink, type Generation, type SinkName } from '@kairos-es/feed';
|
|
160
|
+
import { Duration, Effect, type Schema } from 'effect';
|
|
161
|
+
import type { HttpClient } from 'effect/http';
|
|
162
|
+
import { AlgoliaIndexName, type AlgoliaTaskId } from './identifiers.js';
|
|
163
|
+
import { type AlgoliaConnection } from './rest.js';
|
|
164
|
+
/** What `apply` and `load` return: the index the batch went to, and its task. */
|
|
165
|
+
export interface AlgoliaReceipt {
|
|
166
|
+
readonly index: AlgoliaIndexName;
|
|
167
|
+
readonly taskID: AlgoliaTaskId;
|
|
168
|
+
}
|
|
169
|
+
/** How `confirm`, and every wait on an index operation, polls `getTask`. */
|
|
170
|
+
export interface AlgoliaTaskPolling {
|
|
171
|
+
/** The first wait between polls. Default 100 ms. */
|
|
172
|
+
readonly initialDelay?: Duration.Input;
|
|
173
|
+
/** The cap the wait doubles up to. Default 2 seconds. */
|
|
174
|
+
readonly maxDelay?: Duration.Input;
|
|
175
|
+
/** How long to poll before giving up, retryably. Default 60 seconds. */
|
|
176
|
+
readonly timeout?: Duration.Input;
|
|
177
|
+
}
|
|
178
|
+
/** Options for {@link makeAlgoliaSink}. */
|
|
179
|
+
export interface AlgoliaSinkOptions<D, I> extends AlgoliaConnection {
|
|
180
|
+
/** The sink's name: one cursor per feed and sink. */
|
|
181
|
+
readonly name: SinkName;
|
|
182
|
+
/** The SERVED index: the one search reads. */
|
|
183
|
+
readonly index: AlgoliaIndexName;
|
|
184
|
+
/** The document's schema; a record is its encoded form plus `objectID`. */
|
|
185
|
+
readonly doc: Schema.Codec<D, I>;
|
|
186
|
+
/**
|
|
187
|
+
* Records larger than this, in bytes of their JSON with `objectID`, are refused
|
|
188
|
+
* (parked) before sending. Default 10 000 bytes — {@link DEFAULT_MAX_RECORD_BYTES}
|
|
189
|
+
* — which is the record size limit of Algolia's smallest plan, so the default
|
|
190
|
+
* refuses nothing Algolia would take on any plan.
|
|
191
|
+
*
|
|
192
|
+
* On a plan with a larger limit, raise this to that limit (100 000 for a plan
|
|
193
|
+
* whose records may be 100 KB, say); records between the two are then sent
|
|
194
|
+
* rather than parked. Set it no higher than the plan's own limit: a record over
|
|
195
|
+
* the plan's limit that passes this check costs a round trip, which Algolia
|
|
196
|
+
* refuses whole, before it is parked by the answer naming it.
|
|
197
|
+
*/
|
|
198
|
+
readonly maxRecordBytes?: number;
|
|
199
|
+
readonly taskPolling?: AlgoliaTaskPolling;
|
|
200
|
+
/** Ids per `browse` page when listing keys. Default 1 000, Algolia's maximum. */
|
|
201
|
+
readonly browsePageSize?: number;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The resync target's name for one attempt at `generation`. PACKAGE-INTERNAL, and
|
|
205
|
+
* kept off the entry point deliberately: `attempt` is what `begin` draws for each
|
|
206
|
+
* call, so a real target's name is the one `begin` returns, and a consumer has no
|
|
207
|
+
* use for composing one.
|
|
208
|
+
*
|
|
209
|
+
* It does not check `attempt`. Every caller here passes lower-case base-36
|
|
210
|
+
* digits, which `begin` draws and the tests write by hand, and that shape is all
|
|
211
|
+
* `resyncGenerationOf` below recognises: a suffix of any other shape would name an
|
|
212
|
+
* index that `begin` never drops and `publish` never accepts.
|
|
213
|
+
*/
|
|
214
|
+
export declare const resyncIndexName: (served: AlgoliaIndexName, generation: Generation, attempt: string) => AlgoliaIndexName;
|
|
215
|
+
/**
|
|
216
|
+
* Build an Algolia sink over the `HttpClient` in context; see the module doc.
|
|
217
|
+
* Dies at once on options out of range.
|
|
218
|
+
*/
|
|
219
|
+
export declare const makeAlgoliaSink: <D, I>(options: AlgoliaSinkOptions<D, I>) => Effect.Effect<FeedSink<D, AlgoliaReceipt>, never, HttpClient.HttpClient>;
|
|
220
|
+
//# sourceMappingURL=sink.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sink.d.ts","sourceRoot":"","sources":["../../src/sink.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6JG;AACH,OAAO,EAIL,KAAK,QAAQ,EACb,KAAK,UAAU,EAGf,KAAK,QAAQ,EAEd,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAEL,QAAQ,EACR,MAAM,EAIN,KAAK,MAAM,EAEZ,MAAM,QAAQ,CAAA;AACf,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAE7C,OAAO,EAAE,gBAAgB,EAAE,KAAK,aAAa,EAAE,MAAM,eAAe,CAAA;AAEpE,OAAO,EAAE,KAAK,iBAAiB,EAAmB,MAAM,QAAQ,CAAA;AAEhE,iFAAiF;AACjF,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,KAAK,EAAE,gBAAgB,CAAA;IAChC,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAA;CAC/B;AAED,4EAA4E;AAC5E,MAAM,WAAW,kBAAkB;IACjC,oDAAoD;IACpD,QAAQ,CAAC,YAAY,CAAC,EAAE,QAAQ,CAAC,KAAK,CAAA;IACtC,yDAAyD;IACzD,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,KAAK,CAAA;IAClC,wEAAwE;IACxE,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,KAAK,CAAA;CAClC;AAED,2CAA2C;AAC3C,MAAM,WAAW,kBAAkB,CAAC,CAAC,EAAE,CAAC,CAAE,SAAQ,iBAAiB;IACjE,qDAAqD;IACrD,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;IACvB,8CAA8C;IAC9C,QAAQ,CAAC,KAAK,EAAE,gBAAgB,CAAA;IAChC,2EAA2E;IAC3E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAA;IAChC;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAA;IAChC,QAAQ,CAAC,WAAW,CAAC,EAAE,kBAAkB,CAAA;IACzC,iFAAiF;IACjF,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAA;CACjC;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,eAAe,GAC1B,QAAQ,gBAAgB,EACxB,YAAY,UAAU,EACtB,SAAS,MAAM,KACd,gBACmE,CAAA;AA2JtE;;;GAGG;AACH,eAAO,MAAM,eAAe,GAAI,CAAC,EAAE,CAAC,EAClC,SAAS,kBAAkB,CAAC,CAAC,EAAE,CAAC,CAAC,KAChC,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,EAAE,cAAc,CAAC,EAAE,KAAK,EAAE,UAAU,CAAC,UAAU,CAwUtE,CAAA"}
|
|
@@ -0,0 +1,145 @@
|
|
|
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 { SinkHalted, SinkRejected, SinkRetryable } from '@kairos-es/feed';
|
|
41
|
+
import { DateTime, Duration, Option } from 'effect';
|
|
42
|
+
const OBJECT_ID_MARKER = 'objectID=';
|
|
43
|
+
/** What may follow an id in Algolia's message: the end, a space or punctuation. */
|
|
44
|
+
const ID_ENDS = /^(?:$|[\s,;.:)])/;
|
|
45
|
+
/**
|
|
46
|
+
* The key of `batchKeys` an Algolia error message names, when it names one.
|
|
47
|
+
*
|
|
48
|
+
* Algolia's record-level refusals all carry `objectID=<id>` (the record-too-big
|
|
49
|
+
* message is the common one), with nothing marking where the id ends. So each
|
|
50
|
+
* batch key is tried against the text after each `objectID=`, and it matches when
|
|
51
|
+
* that text starts with the key and the key is followed by the end of the message,
|
|
52
|
+
* a space or punctuation; the longest key that matches wins, so of `big` and
|
|
53
|
+
* `big one` the message `objectID=big one is too big` names the second.
|
|
54
|
+
*/
|
|
55
|
+
export const objectIdNamedIn = (message, batchKeys) => {
|
|
56
|
+
let named;
|
|
57
|
+
let from = message.indexOf(OBJECT_ID_MARKER);
|
|
58
|
+
while (from >= 0) {
|
|
59
|
+
const after = message.slice(from + OBJECT_ID_MARKER.length);
|
|
60
|
+
for (const key of batchKeys) {
|
|
61
|
+
if (after.startsWith(key) && ID_ENDS.test(after.slice(key.length)) && (named === undefined || key.length > named.length)) {
|
|
62
|
+
named = key;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
from = message.indexOf(OBJECT_ID_MARKER, from + 1);
|
|
66
|
+
}
|
|
67
|
+
return Option.fromUndefinedOr(named);
|
|
68
|
+
};
|
|
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 const QUOTA_429 = [/quota/i, /file size/i];
|
|
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 const parseRetryAfter = (header, nowMillis) => {
|
|
84
|
+
const trimmed = header.trim();
|
|
85
|
+
if (/^\d+$/.test(trimmed)) {
|
|
86
|
+
return Option.some(Duration.seconds(Number(trimmed)));
|
|
87
|
+
}
|
|
88
|
+
// A bare number with a fraction or a sign is neither form; do not let the date
|
|
89
|
+
// parser read it as a year.
|
|
90
|
+
if (/^[-+\d.]+$/.test(trimmed)) {
|
|
91
|
+
return Option.none();
|
|
92
|
+
}
|
|
93
|
+
return Option.map(DateTime.make(trimmed), at => Duration.millis(Math.max(0, DateTime.toEpochMillis(at) - nowMillis)));
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Classify an Algolia error answer; see the module doc for the rules.
|
|
97
|
+
*
|
|
98
|
+
* `batchKeys` is the set of keys the request carried, empty for a call that sends
|
|
99
|
+
* no records, so only a write can ever produce a `SinkRejected`.
|
|
100
|
+
*/
|
|
101
|
+
export const classifyAnswer = (call, answer, batchKeys, nowMillis) => {
|
|
102
|
+
const reason = `Algolia answered ${answer.status} to ${call.method} ${call.path}: ${answer.message}`;
|
|
103
|
+
if (answer.status === 429 && QUOTA_429.some(wording => wording.test(answer.message))) {
|
|
104
|
+
return new SinkHalted({
|
|
105
|
+
reason
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (answer.status === 429 || answer.status >= 500 || answer.status < 200 || answer.status >= 300 && answer.status < 400) {
|
|
109
|
+
const retryAfter = Option.flatMap(answer.retryAfter, header => parseRetryAfter(header, nowMillis));
|
|
110
|
+
return new SinkRetryable({
|
|
111
|
+
reason,
|
|
112
|
+
...(Option.isSome(retryAfter) ? {
|
|
113
|
+
retryAfter: retryAfter.value
|
|
114
|
+
} : {})
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
if (answer.status === 400) {
|
|
118
|
+
const named = objectIdNamedIn(answer.message, batchKeys);
|
|
119
|
+
if (Option.isSome(named)) {
|
|
120
|
+
const key = named.value;
|
|
121
|
+
return new SinkRejected({
|
|
122
|
+
keys: [key],
|
|
123
|
+
reasons: new Map([[key, answer.message]])
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return new SinkHalted({
|
|
128
|
+
reason
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* A request that got no answer at all. Retryable: see the module doc for why a
|
|
133
|
+
* resend is safe.
|
|
134
|
+
*/
|
|
135
|
+
export const transportFailure = (call, detail) => new SinkRetryable({
|
|
136
|
+
reason: `no answer from Algolia to ${call.method} ${call.path}: ${detail}`
|
|
137
|
+
});
|
|
138
|
+
/**
|
|
139
|
+
* An answer this package cannot read: a success whose body is not the shape
|
|
140
|
+
* Algolia documents. Halted, because sending again will get the same body back.
|
|
141
|
+
*/
|
|
142
|
+
export const unreadableAnswer = (call, detail) => new SinkHalted({
|
|
143
|
+
reason: `unreadable answer from Algolia to ${call.method} ${call.path}: ${detail}`
|
|
144
|
+
});
|
|
145
|
+
//# sourceMappingURL=classify.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"classify.js","names":["SinkHalted","SinkRejected","SinkRetryable","DateTime","Duration","Option","OBJECT_ID_MARKER","ID_ENDS","objectIdNamedIn","message","batchKeys","named","from","indexOf","after","slice","length","key","startsWith","test","undefined","fromUndefinedOr","QUOTA_429","parseRetryAfter","header","nowMillis","trimmed","trim","some","seconds","Number","none","map","make","at","millis","Math","max","toEpochMillis","classifyAnswer","call","answer","reason","status","method","path","wording","retryAfter","flatMap","isSome","value","keys","reasons","Map","transportFailure","detail","unreadableAnswer"],"sources":["../../src/classify.ts"],"sourcesContent":[null],"mappings":"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","ignoreList":[]}
|
|
@@ -0,0 +1,31 @@
|
|
|
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 const AlgoliaAppId = /*#__PURE__*/Schema.String.pipe(/*#__PURE__*/Schema.check(/*#__PURE__*/Schema.isNonEmpty()), /*#__PURE__*/Schema.brand('AlgoliaAppId'));
|
|
19
|
+
/**
|
|
20
|
+
* The name of an Algolia index. The sink's SERVED index is one; a resync builds into
|
|
21
|
+
* another, named after the served one, the generation it rebuilds and the attempt.
|
|
22
|
+
*/
|
|
23
|
+
export const AlgoliaIndexName = /*#__PURE__*/Schema.String.pipe(/*#__PURE__*/Schema.check(/*#__PURE__*/Schema.isNonEmpty()), /*#__PURE__*/Schema.brand('AlgoliaIndexName'));
|
|
24
|
+
/**
|
|
25
|
+
* An Algolia task id: what every write returns, and what `getTask` is polled with
|
|
26
|
+
* until the write is published. A task belongs to the index it was issued on, and
|
|
27
|
+
* is polled there and nowhere else — which is why a receipt carries the index
|
|
28
|
+
* beside it.
|
|
29
|
+
*/
|
|
30
|
+
export const AlgoliaTaskId = /*#__PURE__*/Schema.Number.pipe(/*#__PURE__*/Schema.check(/*#__PURE__*/Schema.isInt(), /*#__PURE__*/Schema.isGreaterThanOrEqualTo(0)), /*#__PURE__*/Schema.brand('AlgoliaTaskId'));
|
|
31
|
+
//# sourceMappingURL=identifiers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"identifiers.js","names":["Schema","AlgoliaAppId","String","pipe","check","isNonEmpty","brand","AlgoliaIndexName","AlgoliaTaskId","Number","isInt","isGreaterThanOrEqualTo"],"sources":["../../src/identifiers.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;AAWA,SAASA,MAAM,QAAQ,QAAQ;AAE/B;;;;;AAKA,OAAO,MAAMC,YAAY,gBAAGD,MAAM,CAACE,MAAM,CAACC,IAAI,cAC5CH,MAAM,CAACI,KAAK,cAACJ,MAAM,CAACK,UAAU,EAAE,CAAC,eACjCL,MAAM,CAACM,KAAK,CAAC,cAAc,CAAC,CAC7B;AAGD;;;;AAIA,OAAO,MAAMC,gBAAgB,gBAAGP,MAAM,CAACE,MAAM,CAACC,IAAI,cAChDH,MAAM,CAACI,KAAK,cAACJ,MAAM,CAACK,UAAU,EAAE,CAAC,eACjCL,MAAM,CAACM,KAAK,CAAC,kBAAkB,CAAC,CACjC;AAGD;;;;;;AAMA,OAAO,MAAME,aAAa,gBAAGR,MAAM,CAACS,MAAM,CAACN,IAAI,cAC7CH,MAAM,CAACI,KAAK,cAACJ,MAAM,CAACU,KAAK,EAAE,eAAEV,MAAM,CAACW,sBAAsB,CAAC,CAAC,CAAC,CAAC,eAC9DX,MAAM,CAACM,KAAK,CAAC,eAAe,CAAC,CAC9B","ignoreList":[]}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kairos-es/feed-algolia — a `FeedSink` onto an Algolia index, with both of the
|
|
3
|
+
* port's optional parts: a `FeedResync` that rebuilds into a fresh index and moves
|
|
4
|
+
* it over the served one, and a `FeedReconcile` that lists the served index's ids.
|
|
5
|
+
*
|
|
6
|
+
* It talks to Algolia's REST API through `effect`'s own `HttpClient`, so it peers
|
|
7
|
+
* `effect` and `@kairos-es/feed` and nothing else, and the application chooses the
|
|
8
|
+
* HTTP transport by providing the client — `FetchHttpClient.layer` from
|
|
9
|
+
* `effect/http`, say.
|
|
10
|
+
*
|
|
11
|
+
* Where each part's argument lives:
|
|
12
|
+
*
|
|
13
|
+
* - `makeAlgoliaSink` (`sink.ts`): the steady state, resync and reconcile, and how
|
|
14
|
+
* `confirm` waits.
|
|
15
|
+
* - Encoding and the size pre-check (`records.ts`): why only full-record writes,
|
|
16
|
+
* and why oversized records are refused before they are sent.
|
|
17
|
+
* - Error classification (`classify.ts`): which Algolia answers are retryable,
|
|
18
|
+
* which refuse one record, and which stop the worker.
|
|
19
|
+
* - The REST calls (`rest.ts`): why no SDK, the host failover kept from it, and
|
|
20
|
+
* how the admin key is kept out of anything printed.
|
|
21
|
+
*
|
|
22
|
+
* Wiring:
|
|
23
|
+
*
|
|
24
|
+
* ```ts
|
|
25
|
+
* const sink = yield* makeAlgoliaSink({
|
|
26
|
+
* name: SinkName.make('search'),
|
|
27
|
+
* appId: AlgoliaAppId.make(config.appId),
|
|
28
|
+
* adminKey: config.adminKey, // a Redacted<string>, from the application's config
|
|
29
|
+
* index: AlgoliaIndexName.make('courses'),
|
|
30
|
+
* doc: CourseDoc,
|
|
31
|
+
* }).pipe(Effect.provide(FetchHttpClient.layer))
|
|
32
|
+
* yield* Effect.forkScoped(runFeedSync({ source, sink, store }))
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export { AlgoliaAppId, AlgoliaIndexName, AlgoliaTaskId } from "./identifiers.js";
|
|
36
|
+
export { DEFAULT_MAX_RECORD_BYTES } from "./records.js";
|
|
37
|
+
export { makeAlgoliaSink } from "./sink.js";
|
|
38
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":["AlgoliaAppId","AlgoliaIndexName","AlgoliaTaskId","DEFAULT_MAX_RECORD_BYTES","makeAlgoliaSink"],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmCA,SACEA,YAAY,EACZC,gBAAgB,EAChBC,aAAa,QACR,kBAAe;AACtB,SAASC,wBAAwB,QAAQ,cAAW;AAEpD,SAIEC,eAAe,QACV,WAAQ","ignoreList":[]}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a batch of feed changes into Algolia batch operations, and refusing the
|
|
3
|
+
* records Algolia would refuse BEFORE they are sent.
|
|
4
|
+
*
|
|
5
|
+
* ## Full-record replace, and nothing partial
|
|
6
|
+
*
|
|
7
|
+
* An upsert is `addObject` with the record's `objectID` set to the feed key, which
|
|
8
|
+
* replaces whatever Algolia held under that id; a delete is `deleteObject`. Both
|
|
9
|
+
* leave the index the same however many times they are sent, which is the
|
|
10
|
+
* idempotence the driver relies on when it resends a batch it could not confirm.
|
|
11
|
+
* Algolia's partial operations (`partialUpdateObject`, the built-in `Increment`
|
|
12
|
+
* and its kin) do not: applied twice they apply twice. They are never used.
|
|
13
|
+
*
|
|
14
|
+
* The key is written LAST, over the encoded document, so a document that carries an
|
|
15
|
+
* `objectID` field of its own cannot point a record at another key.
|
|
16
|
+
*
|
|
17
|
+
* ## Why oversized records are refused here
|
|
18
|
+
*
|
|
19
|
+
* Algolia refuses a whole `batch` when one record in it is over the plan's record
|
|
20
|
+
* size limit. Its answer does name the record, and `classify.ts` turns that into a
|
|
21
|
+
* rejection of that one key — but only after a round trip that wrote nothing, and
|
|
22
|
+
* only for the first oversized record the batch held. Checking every record's size
|
|
23
|
+
* up front rejects all of them at once, so the driver's resend of the rest goes
|
|
24
|
+
* through first time. The 400 path stays as the backstop for a limit configured
|
|
25
|
+
* higher than the plan's.
|
|
26
|
+
*
|
|
27
|
+
* The size is the record's JSON in UTF-8 bytes, `objectID` included, which is how
|
|
28
|
+
* Algolia measures it. The default limit is the smallest Algolia plan's (10 KB);
|
|
29
|
+
* an application on a larger plan raises it.
|
|
30
|
+
*
|
|
31
|
+
* ## A document that does not encode
|
|
32
|
+
*
|
|
33
|
+
* The document schema is the read model's, and a document it cannot encode — or one
|
|
34
|
+
* that encodes to something other than a JSON object, which is all an Algolia
|
|
35
|
+
* record can be — is a defect in the read model, not a record the sink refuses. It
|
|
36
|
+
* dies, loudly, rather than being parked where nobody looks.
|
|
37
|
+
*/
|
|
38
|
+
import { SinkRejected } from '@kairos-es/feed';
|
|
39
|
+
import { Array as Arr, Effect, Schema } from 'effect';
|
|
40
|
+
/** The smallest Algolia plan's record size limit, in bytes. */
|
|
41
|
+
export const DEFAULT_MAX_RECORD_BYTES = 10_000;
|
|
42
|
+
const utf8 = /*#__PURE__*/new TextEncoder();
|
|
43
|
+
/** A record's size as Algolia measures it: its JSON, in UTF-8 bytes. */
|
|
44
|
+
export const recordBytes = record => utf8.encode(JSON.stringify(record)).length;
|
|
45
|
+
const isJsonObject = u => typeof u === 'object' && u !== null && !Array.isArray(u);
|
|
46
|
+
/**
|
|
47
|
+
* Encode a batch, failing `SinkRejected` with EVERY oversized key when there is
|
|
48
|
+
* one; see the module doc.
|
|
49
|
+
*/
|
|
50
|
+
export const encodeBatch = (doc, maxRecordBytes, batch) => Effect.gen(function* () {
|
|
51
|
+
const encode = Schema.encodeEffect(doc);
|
|
52
|
+
const operations = [];
|
|
53
|
+
const keys = new Set();
|
|
54
|
+
const reasons = new Map();
|
|
55
|
+
for (const change of batch) {
|
|
56
|
+
keys.add(change.key);
|
|
57
|
+
if (change._tag === 'Delete') {
|
|
58
|
+
operations.push({
|
|
59
|
+
action: 'deleteObject',
|
|
60
|
+
body: {
|
|
61
|
+
objectID: change.key
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const encoded = yield* Effect.orDie(encode(change.doc));
|
|
67
|
+
if (!isJsonObject(encoded)) {
|
|
68
|
+
return yield* Effect.die(new Error(`feed-algolia: the document for key '${change.key}' encodes to ${encoded === null ? 'null' : typeof encoded}, and an Algolia record must be a JSON object`));
|
|
69
|
+
}
|
|
70
|
+
const record = {
|
|
71
|
+
...encoded,
|
|
72
|
+
objectID: change.key
|
|
73
|
+
};
|
|
74
|
+
const size = recordBytes(record);
|
|
75
|
+
if (size > maxRecordBytes) {
|
|
76
|
+
reasons.set(change.key, `the record is ${size} bytes, over the configured maxRecordBytes of ${maxRecordBytes}`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
operations.push({
|
|
80
|
+
action: 'addObject',
|
|
81
|
+
body: record
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const rejected = Array.from(reasons.keys());
|
|
85
|
+
if (Arr.isReadonlyArrayNonEmpty(rejected)) {
|
|
86
|
+
return yield* Effect.fail(new SinkRejected({
|
|
87
|
+
keys: rejected,
|
|
88
|
+
reasons
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
operations,
|
|
93
|
+
keys
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
//# sourceMappingURL=records.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"records.js","names":["SinkRejected","Array","Arr","Effect","Schema","DEFAULT_MAX_RECORD_BYTES","utf8","TextEncoder","recordBytes","record","encode","JSON","stringify","length","isJsonObject","u","isArray","encodeBatch","doc","maxRecordBytes","batch","gen","encodeEffect","operations","keys","Set","reasons","Map","change","add","key","_tag","push","action","body","objectID","encoded","orDie","die","Error","size","set","rejected","from","isReadonlyArrayNonEmpty","fail"],"sources":["../../src/records.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,SAAwCA,YAAY,QAAQ,iBAAiB;AAC7E,SAASC,KAAK,IAAIC,GAAG,EAAEC,MAAM,EAAEC,MAAM,QAAQ,QAAQ;AAIrD;AACA,OAAO,MAAMC,wBAAwB,GAAG,MAAM;AAE9C,MAAMC,IAAI,gBAAG,IAAIC,WAAW,EAAE;AAE9B;AACA,OAAO,MAAMC,WAAW,GACtBC,MAAyC,IAC9BH,IAAI,CAACI,MAAM,CAACC,IAAI,CAACC,SAAS,CAACH,MAAM,CAAC,CAAC,CAACI,MAAM;AAQvD,MAAMC,YAAY,GAAIC,CAAU,IAC9B,OAAOA,CAAC,KAAK,QAAQ,IAAIA,CAAC,KAAK,IAAI,IAAI,CAACd,KAAK,CAACe,OAAO,CAACD,CAAC,CAAC;AAE1D;;;;AAIA,OAAO,MAAME,WAAW,GAAGA,CACzBC,GAAuB,EACvBC,cAAsB,EACtBC,KAA2C,KAE3CjB,MAAM,CAACkB,GAAG,CAAC,aAAS;EAClB,MAAMX,MAAM,GAAGN,MAAM,CAACkB,YAAY,CAACJ,GAAG,CAAC;EACvC,MAAMK,UAAU,GAA0B,EAAE;EAC5C,MAAMC,IAAI,GAAG,IAAIC,GAAG,EAAW;EAC/B,MAAMC,OAAO,GAAG,IAAIC,GAAG,EAAmB;EAC1C,KAAK,MAAMC,MAAM,IAAIR,KAAK,EAAE;IAC1BI,IAAI,CAACK,GAAG,CAACD,MAAM,CAACE,GAAG,CAAC;IACpB,IAAIF,MAAM,CAACG,IAAI,KAAK,QAAQ,EAAE;MAC5BR,UAAU,CAACS,IAAI,CAAC;QACdC,MAAM,EAAE,cAAc;QACtBC,IAAI,EAAE;UAAEC,QAAQ,EAAEP,MAAM,CAACE;QAAG;OAC7B,CAAC;MACF;IACF;IACA,MAAMM,OAAO,GAAY,OAAOjC,MAAM,CAACkC,KAAK,CAAC3B,MAAM,CAACkB,MAAM,CAACV,GAAG,CAAC,CAAC;IAChE,IAAI,CAACJ,YAAY,CAACsB,OAAO,CAAC,EAAE;MAC1B,OAAO,OAAOjC,MAAM,CAACmC,GAAG,CACtB,IAAIC,KAAK,CACP,uCAAuCX,MAAM,CAACE,GAAG,gBAAgBM,OAAO,KAAK,IAAI,GAAG,MAAM,GAAG,OAAOA,OAAO,+CAA+C,CAC3J,CACF;IACH;IACA,MAAM3B,MAAM,GAAG;MAAE,GAAG2B,OAAO;MAAED,QAAQ,EAAEP,MAAM,CAACE;IAAG,CAAE;IACnD,MAAMU,IAAI,GAAGhC,WAAW,CAACC,MAAM,CAAC;IAChC,IAAI+B,IAAI,GAAGrB,cAAc,EAAE;MACzBO,OAAO,CAACe,GAAG,CACTb,MAAM,CAACE,GAAG,EACV,iBAAiBU,IAAI,iDAAiDrB,cAAc,EAAE,CACvF;MACD;IACF;IACAI,UAAU,CAACS,IAAI,CAAC;MAAEC,MAAM,EAAE,WAAW;MAAEC,IAAI,EAAEzB;IAAM,CAAE,CAAC;EACxD;EACA,MAAMiC,QAAQ,GAAGzC,KAAK,CAAC0C,IAAI,CAACjB,OAAO,CAACF,IAAI,EAAE,CAAC;EAC3C,IAAItB,GAAG,CAAC0C,uBAAuB,CAACF,QAAQ,CAAC,EAAE;IACzC,OAAO,OAAOvC,MAAM,CAAC0C,IAAI,CAAC,IAAI7C,YAAY,CAAC;MAAEwB,IAAI,EAAEkB,QAAQ;MAAEhB;IAAO,CAAE,CAAC,CAAC;EAC1E;EACA,OAAO;IAAEH,UAAU;IAAEC;EAAI,CAAE;AAC7B,CAAC,CAAC","ignoreList":[]}
|