@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
package/src/classify.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
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
|
+
|
|
41
|
+
import type { FeedKey } from '@kairos-es/feed'
|
|
42
|
+
import { SinkHalted, SinkRejected, SinkRetryable } from '@kairos-es/feed'
|
|
43
|
+
import { DateTime, Duration, Option } from 'effect'
|
|
44
|
+
|
|
45
|
+
/** Every failure an Algolia call can end in, already in the feed's vocabulary. */
|
|
46
|
+
export type AlgoliaFailure = SinkRetryable | SinkRejected | SinkHalted
|
|
47
|
+
|
|
48
|
+
/** An Algolia answer that was not a success, reduced to what classifying needs. */
|
|
49
|
+
export interface AlgoliaErrorAnswer {
|
|
50
|
+
readonly status: number
|
|
51
|
+
/** Algolia's `message`, or the raw body when it sent none. */
|
|
52
|
+
readonly message: string
|
|
53
|
+
/** The `Retry-After` header, verbatim, when there was one. */
|
|
54
|
+
readonly retryAfter: Option.Option<string>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What the call was, for the reason string: never its headers. */
|
|
58
|
+
export interface AlgoliaCallLabel {
|
|
59
|
+
readonly method: string
|
|
60
|
+
readonly path: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const OBJECT_ID_MARKER = 'objectID='
|
|
64
|
+
|
|
65
|
+
/** What may follow an id in Algolia's message: the end, a space or punctuation. */
|
|
66
|
+
const ID_ENDS = /^(?:$|[\s,;.:)])/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The key of `batchKeys` an Algolia error message names, when it names one.
|
|
70
|
+
*
|
|
71
|
+
* Algolia's record-level refusals all carry `objectID=<id>` (the record-too-big
|
|
72
|
+
* message is the common one), with nothing marking where the id ends. So each
|
|
73
|
+
* batch key is tried against the text after each `objectID=`, and it matches when
|
|
74
|
+
* that text starts with the key and the key is followed by the end of the message,
|
|
75
|
+
* a space or punctuation; the longest key that matches wins, so of `big` and
|
|
76
|
+
* `big one` the message `objectID=big one is too big` names the second.
|
|
77
|
+
*/
|
|
78
|
+
export const objectIdNamedIn = (
|
|
79
|
+
message: string,
|
|
80
|
+
batchKeys: ReadonlySet<FeedKey>,
|
|
81
|
+
): Option.Option<FeedKey> => {
|
|
82
|
+
let named: FeedKey | undefined
|
|
83
|
+
let from = message.indexOf(OBJECT_ID_MARKER)
|
|
84
|
+
while (from >= 0) {
|
|
85
|
+
const after = message.slice(from + OBJECT_ID_MARKER.length)
|
|
86
|
+
for (const key of batchKeys) {
|
|
87
|
+
if (
|
|
88
|
+
after.startsWith(key) &&
|
|
89
|
+
ID_ENDS.test(after.slice(key.length)) &&
|
|
90
|
+
(named === undefined || key.length > named.length)
|
|
91
|
+
) {
|
|
92
|
+
named = key
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
from = message.indexOf(OBJECT_ID_MARKER, from + 1)
|
|
96
|
+
}
|
|
97
|
+
return Option.fromUndefinedOr(named)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The 429 wordings that name a quota, not a rate, matched case-insensitively
|
|
102
|
+
* anywhere in Algolia's message: an application over its record quota, or over
|
|
103
|
+
* its allowance of indexing by file size. See the module doc for why each halts.
|
|
104
|
+
*/
|
|
105
|
+
export const QUOTA_429: ReadonlyArray<RegExp> = [/quota/i, /file size/i]
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `Retry-After` as a duration, given the current time.
|
|
109
|
+
*
|
|
110
|
+
* The header is either a whole number of seconds or an HTTP date. A date is turned
|
|
111
|
+
* into a wait against `now`, which the caller reads from `Clock` so a test's
|
|
112
|
+
* `TestClock` governs it; a date already past is a wait of zero. Anything else is
|
|
113
|
+
* ignored rather than guessed at, and the driver's own back-off applies.
|
|
114
|
+
*/
|
|
115
|
+
export const parseRetryAfter = (
|
|
116
|
+
header: string,
|
|
117
|
+
nowMillis: number,
|
|
118
|
+
): Option.Option<Duration.Duration> => {
|
|
119
|
+
const trimmed = header.trim()
|
|
120
|
+
if (/^\d+$/.test(trimmed)) {
|
|
121
|
+
return Option.some(Duration.seconds(Number(trimmed)))
|
|
122
|
+
}
|
|
123
|
+
// A bare number with a fraction or a sign is neither form; do not let the date
|
|
124
|
+
// parser read it as a year.
|
|
125
|
+
if (/^[-+\d.]+$/.test(trimmed)) {
|
|
126
|
+
return Option.none()
|
|
127
|
+
}
|
|
128
|
+
return Option.map(DateTime.make(trimmed), (at) =>
|
|
129
|
+
Duration.millis(Math.max(0, DateTime.toEpochMillis(at) - nowMillis)),
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Classify an Algolia error answer; see the module doc for the rules.
|
|
135
|
+
*
|
|
136
|
+
* `batchKeys` is the set of keys the request carried, empty for a call that sends
|
|
137
|
+
* no records, so only a write can ever produce a `SinkRejected`.
|
|
138
|
+
*/
|
|
139
|
+
export const classifyAnswer = (
|
|
140
|
+
call: AlgoliaCallLabel,
|
|
141
|
+
answer: AlgoliaErrorAnswer,
|
|
142
|
+
batchKeys: ReadonlySet<FeedKey>,
|
|
143
|
+
nowMillis: number,
|
|
144
|
+
): AlgoliaFailure => {
|
|
145
|
+
const reason = `Algolia answered ${answer.status} to ${call.method} ${call.path}: ${answer.message}`
|
|
146
|
+
if (
|
|
147
|
+
answer.status === 429 &&
|
|
148
|
+
QUOTA_429.some((wording) => wording.test(answer.message))
|
|
149
|
+
) {
|
|
150
|
+
return new SinkHalted({ reason })
|
|
151
|
+
}
|
|
152
|
+
if (
|
|
153
|
+
answer.status === 429 ||
|
|
154
|
+
answer.status >= 500 ||
|
|
155
|
+
answer.status < 200 ||
|
|
156
|
+
(answer.status >= 300 && answer.status < 400)
|
|
157
|
+
) {
|
|
158
|
+
const retryAfter = Option.flatMap(answer.retryAfter, (header) =>
|
|
159
|
+
parseRetryAfter(header, nowMillis),
|
|
160
|
+
)
|
|
161
|
+
return new SinkRetryable({
|
|
162
|
+
reason,
|
|
163
|
+
...(Option.isSome(retryAfter) ? { retryAfter: retryAfter.value } : {}),
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
if (answer.status === 400) {
|
|
167
|
+
const named = objectIdNamedIn(answer.message, batchKeys)
|
|
168
|
+
if (Option.isSome(named)) {
|
|
169
|
+
const key = named.value
|
|
170
|
+
return new SinkRejected({
|
|
171
|
+
keys: [key],
|
|
172
|
+
reasons: new Map([[key, answer.message]]),
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return new SinkHalted({ reason })
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A request that got no answer at all. Retryable: see the module doc for why a
|
|
181
|
+
* resend is safe.
|
|
182
|
+
*/
|
|
183
|
+
export const transportFailure = (
|
|
184
|
+
call: AlgoliaCallLabel,
|
|
185
|
+
detail: string,
|
|
186
|
+
): AlgoliaFailure =>
|
|
187
|
+
new SinkRetryable({
|
|
188
|
+
reason: `no answer from Algolia to ${call.method} ${call.path}: ${detail}`,
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* An answer this package cannot read: a success whose body is not the shape
|
|
193
|
+
* Algolia documents. Halted, because sending again will get the same body back.
|
|
194
|
+
*/
|
|
195
|
+
export const unreadableAnswer = (
|
|
196
|
+
call: AlgoliaCallLabel,
|
|
197
|
+
detail: string,
|
|
198
|
+
): AlgoliaFailure =>
|
|
199
|
+
new SinkHalted({
|
|
200
|
+
reason: `unreadable answer from Algolia to ${call.method} ${call.path}: ${detail}`,
|
|
201
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
/**
|
|
15
|
+
* The Algolia application id: the first half of the credentials, and the name of
|
|
16
|
+
* the application's hosts. Not a secret — it is sent beside every search a browser
|
|
17
|
+
* makes — so it is a plain branded string, unlike the admin key.
|
|
18
|
+
*/
|
|
19
|
+
export const AlgoliaAppId = Schema.String.pipe(
|
|
20
|
+
Schema.check(Schema.isNonEmpty()),
|
|
21
|
+
Schema.brand('AlgoliaAppId'),
|
|
22
|
+
)
|
|
23
|
+
export type AlgoliaAppId = typeof AlgoliaAppId.Type
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The name of an Algolia index. The sink's SERVED index is one; a resync builds into
|
|
27
|
+
* another, named after the served one, the generation it rebuilds and the attempt.
|
|
28
|
+
*/
|
|
29
|
+
export const AlgoliaIndexName = Schema.String.pipe(
|
|
30
|
+
Schema.check(Schema.isNonEmpty()),
|
|
31
|
+
Schema.brand('AlgoliaIndexName'),
|
|
32
|
+
)
|
|
33
|
+
export type AlgoliaIndexName = typeof AlgoliaIndexName.Type
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* An Algolia task id: what every write returns, and what `getTask` is polled with
|
|
37
|
+
* until the write is published. A task belongs to the index it was issued on, and
|
|
38
|
+
* is polled there and nowhere else — which is why a receipt carries the index
|
|
39
|
+
* beside it.
|
|
40
|
+
*/
|
|
41
|
+
export const AlgoliaTaskId = Schema.Number.pipe(
|
|
42
|
+
Schema.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)),
|
|
43
|
+
Schema.brand('AlgoliaTaskId'),
|
|
44
|
+
)
|
|
45
|
+
export type AlgoliaTaskId = typeof AlgoliaTaskId.Type
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
|
|
36
|
+
export {
|
|
37
|
+
AlgoliaAppId,
|
|
38
|
+
AlgoliaIndexName,
|
|
39
|
+
AlgoliaTaskId,
|
|
40
|
+
} from './identifiers'
|
|
41
|
+
export { DEFAULT_MAX_RECORD_BYTES } from './records'
|
|
42
|
+
export type { AlgoliaConnection, AlgoliaTimeouts } from './rest'
|
|
43
|
+
export {
|
|
44
|
+
type AlgoliaReceipt,
|
|
45
|
+
type AlgoliaSinkOptions,
|
|
46
|
+
type AlgoliaTaskPolling,
|
|
47
|
+
makeAlgoliaSink,
|
|
48
|
+
} from './sink'
|
package/src/records.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
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 { type FeedChange, type FeedKey, SinkRejected } from '@kairos-es/feed'
|
|
39
|
+
import { Array as Arr, Effect, Schema } from 'effect'
|
|
40
|
+
import type { NonEmptyReadonlyArray } from 'effect/Array'
|
|
41
|
+
import type { BatchOperation } from './rest'
|
|
42
|
+
|
|
43
|
+
/** The smallest Algolia plan's record size limit, in bytes. */
|
|
44
|
+
export const DEFAULT_MAX_RECORD_BYTES = 10_000
|
|
45
|
+
|
|
46
|
+
const utf8 = new TextEncoder()
|
|
47
|
+
|
|
48
|
+
/** A record's size as Algolia measures it: its JSON, in UTF-8 bytes. */
|
|
49
|
+
export const recordBytes = (
|
|
50
|
+
record: Readonly<Record<string, unknown>>,
|
|
51
|
+
): number => utf8.encode(JSON.stringify(record)).length
|
|
52
|
+
|
|
53
|
+
/** A batch ready to send: its operations, and the keys it carries. */
|
|
54
|
+
export interface EncodedBatch {
|
|
55
|
+
readonly operations: ReadonlyArray<BatchOperation>
|
|
56
|
+
readonly keys: ReadonlySet<FeedKey>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const isJsonObject = (u: unknown): u is Readonly<Record<string, unknown>> =>
|
|
60
|
+
typeof u === 'object' && u !== null && !Array.isArray(u)
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Encode a batch, failing `SinkRejected` with EVERY oversized key when there is
|
|
64
|
+
* one; see the module doc.
|
|
65
|
+
*/
|
|
66
|
+
export const encodeBatch = <D, I>(
|
|
67
|
+
doc: Schema.Codec<D, I>,
|
|
68
|
+
maxRecordBytes: number,
|
|
69
|
+
batch: NonEmptyReadonlyArray<FeedChange<D>>,
|
|
70
|
+
): Effect.Effect<EncodedBatch, SinkRejected> =>
|
|
71
|
+
Effect.gen(function* () {
|
|
72
|
+
const encode = Schema.encodeEffect(doc)
|
|
73
|
+
const operations: Array<BatchOperation> = []
|
|
74
|
+
const keys = new Set<FeedKey>()
|
|
75
|
+
const reasons = new Map<FeedKey, string>()
|
|
76
|
+
for (const change of batch) {
|
|
77
|
+
keys.add(change.key)
|
|
78
|
+
if (change._tag === 'Delete') {
|
|
79
|
+
operations.push({
|
|
80
|
+
action: 'deleteObject',
|
|
81
|
+
body: { objectID: change.key },
|
|
82
|
+
})
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
const encoded: unknown = yield* Effect.orDie(encode(change.doc))
|
|
86
|
+
if (!isJsonObject(encoded)) {
|
|
87
|
+
return yield* Effect.die(
|
|
88
|
+
new Error(
|
|
89
|
+
`feed-algolia: the document for key '${change.key}' encodes to ${encoded === null ? 'null' : typeof encoded}, and an Algolia record must be a JSON object`,
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
const record = { ...encoded, objectID: change.key }
|
|
94
|
+
const size = recordBytes(record)
|
|
95
|
+
if (size > maxRecordBytes) {
|
|
96
|
+
reasons.set(
|
|
97
|
+
change.key,
|
|
98
|
+
`the record is ${size} bytes, over the configured maxRecordBytes of ${maxRecordBytes}`,
|
|
99
|
+
)
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
operations.push({ action: 'addObject', body: record })
|
|
103
|
+
}
|
|
104
|
+
const rejected = Array.from(reasons.keys())
|
|
105
|
+
if (Arr.isReadonlyArrayNonEmpty(rejected)) {
|
|
106
|
+
return yield* Effect.fail(new SinkRejected({ keys: rejected, reasons }))
|
|
107
|
+
}
|
|
108
|
+
return { operations, keys }
|
|
109
|
+
})
|