@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,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
+ })