@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
package/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Neverbland
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md CHANGED
@@ -1,3 +1,67 @@
1
- # Temporary Holding Version
1
+ # @kairos-es/feed-algolia
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An Algolia sink for the kairos-es change feed (`@kairos-es/feed`): it keeps one Algolia index in step with a read model, sending only the records that changed, each at its newest state, and rebuilding the index into a fresh one that is swapped in whole when the read model is rebuilt.
4
+
5
+ It talks to Algolia's REST API through `effect`'s own `HttpClient`, so it adds no SDK dependency. You choose the HTTP transport by providing the client.
6
+
7
+ ## Installation
8
+
9
+ `@kairos-es/feed` and `effect` are peer dependencies:
10
+
11
+ ```sh
12
+ pnpm add @kairos-es/feed-algolia@alpha @kairos-es/feed@alpha effect@^4.0.5
13
+ ```
14
+
15
+ `@kairos-es/feed` peers `@kairos-es/core` and `@kairos-es/read` in turn; its README says what to install alongside it.
16
+
17
+ ## Wiring
18
+
19
+ ```ts
20
+ import { runFeedSync, SinkName } from '@kairos-es/feed'
21
+ import {
22
+ AlgoliaAppId,
23
+ AlgoliaIndexName,
24
+ makeAlgoliaSink,
25
+ } from '@kairos-es/feed-algolia'
26
+ import { Config, Effect } from 'effect'
27
+ import { FetchHttpClient } from 'effect/http'
28
+
29
+ const program = Effect.gen(function* () {
30
+ const sink = yield* makeAlgoliaSink({
31
+ name: SinkName.make('search'),
32
+ appId: AlgoliaAppId.make(yield* Config.String('ALGOLIA_APP_ID')),
33
+ adminKey: yield* Config.Redacted('ALGOLIA_ADMIN_KEY'),
34
+ index: AlgoliaIndexName.make('courses'),
35
+ doc: CourseDoc, // the Schema your read model writes into the feed
36
+ }).pipe(Effect.provide(FetchHttpClient.layer))
37
+ yield* Effect.forkScoped(runFeedSync({ source, sink, store }))
38
+ })
39
+ ```
40
+
41
+ The admin key is held as `Redacted` and never logged. If you use a scoped key instead, give it exactly these ACLs, on the index and on the resync indices named after it (`<index>__kairos_g…`):
42
+
43
+ | ACL | What the sink uses it for |
44
+ | :-- | :-- |
45
+ | `addObject` | writing records, copying the served index's settings into a resync index, and moving that index over the served one |
46
+ | `deleteObject` | deleting records |
47
+ | `deleteIndex` | deleting the resync indices an abandoned rebuild left behind |
48
+ | `settings` | reading an index's settings, to check a resync index before it is moved |
49
+ | `editSettings` | marking a resync index, and taking the mark off the served index after the move |
50
+ | `browse` | listing the served index's object ids, for reconcile |
51
+ | `listIndexes` | listing indices, to find the resync indices to delete |
52
+
53
+ `listIndexes` is required. A key without it stops the worker with a `SinkHalted` (a 403 on `GET /1/indexes`) at the start of every rebuild, before anything is created. The sink does not skip the clean-up when the key cannot list: the clean-up is also what stops a worker that has lost its lease from moving its own half-built index over the served one.
54
+
55
+ ## What it does
56
+
57
+ - **Writes.** Each batch is one Algolia `batch` call of full-record writes: `addObject` with `objectID` set to the feed key, or `deleteObject`. Nothing partial is ever sent, so resending a batch after a crash is harmless.
58
+ - **Confirmation.** The feed's cursor moves only once Algolia has published the batch's task. `taskPolling` sets how the task is polled (default: from 100 ms, doubling to 2 s, giving up after 60 s, after which the batch is resent).
59
+ - **Oversized records.** Algolia refuses a whole batch for one record over the plan's size limit. The sink measures every record first (its JSON in UTF-8 bytes, `objectID` included) and refuses the oversized ones itself, so they are parked and the rest of the batch goes through. The default limit, `maxRecordBytes: 10_000`, is the smallest plan's, so out of the box nothing is parked that Algolia would have taken. On a plan with a larger record limit, raise `maxRecordBytes` to that limit (for example `100_000` for 100 KB records). Do not set it above your plan's limit: such a record then costs a round trip that Algolia refuses before it is parked.
60
+ - **Hosts.** Unless you set `host`, the sink talks to `<appId>.algolia.net` and, when that host does not answer in time, answers with a 5xx, or answers with a 1xx or 3xx, tries the same request on Algolia's fallback hosts `<appId>-1.algolianet.com` to `<appId>-3.algolianet.com`, in random order. A host that failed that way is skipped by the sink for two minutes, so a host that has gone quiet costs one timeout rather than one per call; when every host is down, all of them are tried again. `timeouts` sets how long one request to one host may take, as `connect`, `read` and `write` (defaults 2 s, 5 s and 30 s, the official JavaScript client's); a read (a `GET`, or a `browse`) may take `connect` plus `read`, and every other call `connect` plus `write`. Setting `host` sends every call to that one base URL and turns this failover off.
61
+ - **Failures.** A 429, or a 5xx or no answer from every host tried, is retried after a back-off that honours `Retry-After`. A 429 is not tried on another host, since it is your application's rate limit; a 429 whose message names a quota (your record quota, or your allowance of indexing by file size) stops the worker instead, with Algolia's message, since waiting does not lift it. A 400 that names a record parks that record. Any other 4xx (bad credentials, a missing ACL) stops the worker, with Algolia's message.
62
+ - **Rebuilds.** When the read model is rebuilt, the sink builds a fresh index named `<index>__kairos_g<generation>_<attempt>`, where `<attempt>` is random and new on every rebuild attempt, copying only the served index's settings, synonyms and rules into it. It loads every record there and then moves it over the served index. Search keeps serving the old index until the move. The rebuild marks its fresh index with one key, `kairosResyncAttempt`, in the index's `userData` setting. Just after the move it puts the served index's settings back exactly as they were before the rebuild began, by copying them from a snapshot it took then (`<index>__kairos_g<generation>_<attempt>__settings`, deleted afterwards). So your own `userData` is kept as it was, and an index that had none still has none. Algolia keeps a `userData: null` as a stored value, so a settings write could not do that. The one exception: if a restore fails, the sink logs a warning and leaves the mark on the served index, and the next rebuild takes it off. If the mark was all the `userData` there was, that index then ends with `userData: null`. Your `userData` must therefore be a JSON object, or absent: a rebuild of an index whose `userData` is anything else stops the worker. A rebuild whose fresh index was deleted under it (by a newer rebuild) is refused rather than moved over the served index, whether it was written to again or deleted just before the move; the sink checks that the served index carries the move's mark before it reports the rebuild published. Starting a rebuild also deletes every other `<index>__kairos_g…` index an earlier, abandoned rebuild left behind, so an interrupted rebuild does not keep holding records against your quota.
63
+ - **Reconcile.** The sink can list the served index's object ids (by `browse`, retrieving the ids alone), so the feed can delete records the index holds and the feed no longer has.
64
+
65
+ ## Replicas
66
+
67
+ Algolia does not document how replicas behave across a scoped copy and a move. The expectation this package is built on is that the resync index is not given the served index's replicas, and that the served index keeps its replicas across the move and across the settings restore that follows it. The package's own tests check both against a real application when credentials are available. Until you have seen that hold for your application, check your replicas after a first rebuild.
@@ -0,0 +1,157 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.unreadableAnswer = exports.transportFailure = exports.parseRetryAfter = exports.objectIdNamedIn = exports.classifyAnswer = exports.QUOTA_429 = void 0;
7
+ var _feed = require("@kairos-es/feed");
8
+ var _effect = require("effect");
9
+ /**
10
+ * How an Algolia answer becomes one of the feed's three sink failures.
11
+ *
12
+ * The driver's whole behaviour on failure follows from which of the three a sink
13
+ * names (`@kairos-es/feed`'s `errors.ts` owns that), so this mapping is the most
14
+ * consequential thing in the package and it is kept pure, in one place, and tested
15
+ * on its own. The rules:
16
+ *
17
+ * - **A 429 is `SinkRetryable`**, carrying `Retry-After` when Algolia sends one,
18
+ * so the driver backs off no less than Algolia asked — UNLESS its message names
19
+ * a QUOTA rather than a rate: Algolia answers 429 for an application over its
20
+ * record quota or its indexing file-size allowance too, and neither lifts by
21
+ * waiting, so retrying would only hold the feed until supervision reported it
22
+ * stalled, with Algolia's reason lost. Such a 429 is `SinkHalted`, carrying
23
+ * Algolia's message. The wordings recognised are {@link QUOTA_429}; a 429 whose
24
+ * wording is not among them stays retryable, which is the safe side to err on.
25
+ * - **Every 5xx is `SinkRetryable`, and so is a 1xx or 3xx**, a status no call
26
+ * here expects and one the transport has already tried on every host, as the
27
+ * official client does. A transport failure — no answer at all — is retryable
28
+ * too: the request may never have arrived, and resending is safe because every
29
+ * write this sink makes is a full-record replace or a delete.
30
+ * - **A 400 that names an `objectID` in the batch is `SinkRejected` for that key.**
31
+ * Algolia refuses a whole batch for one bad record, and names the record in its
32
+ * message (`… objectID=<id> …`). Parking just that key lets the driver send the
33
+ * rest again without it, which is the difference between one record missing from
34
+ * search and the whole feed stopping. The key has to be one the batch carried: an
35
+ * id the batch did not send is not a record the driver can park, so such an
36
+ * answer falls through to the next rule. The message does not delimit the id, and
37
+ * a feed key may hold a space, a comma or anything else, so the id is not parsed
38
+ * out of the message: the batch's own keys are matched against what follows
39
+ * `objectID=` (`objectIdNamedIn`).
40
+ * - **Every other 4xx is `SinkHalted`.** Bad credentials, a key without the right
41
+ * ACL, an index name Algolia will not accept, a request this package built wrong:
42
+ * none of them is mended by sending again, so the worker stops and supervision
43
+ * reports it.
44
+ *
45
+ * Nothing here reads the request, and nothing here sees a header but `Retry-After`,
46
+ * so no classification can carry the admin key into a reason string.
47
+ */
48
+
49
+ const OBJECT_ID_MARKER = 'objectID=';
50
+ /** What may follow an id in Algolia's message: the end, a space or punctuation. */
51
+ const ID_ENDS = /^(?:$|[\s,;.:)])/;
52
+ /**
53
+ * The key of `batchKeys` an Algolia error message names, when it names one.
54
+ *
55
+ * Algolia's record-level refusals all carry `objectID=<id>` (the record-too-big
56
+ * message is the common one), with nothing marking where the id ends. So each
57
+ * batch key is tried against the text after each `objectID=`, and it matches when
58
+ * that text starts with the key and the key is followed by the end of the message,
59
+ * a space or punctuation; the longest key that matches wins, so of `big` and
60
+ * `big one` the message `objectID=big one is too big` names the second.
61
+ */
62
+ const objectIdNamedIn = (message, batchKeys) => {
63
+ let named;
64
+ let from = message.indexOf(OBJECT_ID_MARKER);
65
+ while (from >= 0) {
66
+ const after = message.slice(from + OBJECT_ID_MARKER.length);
67
+ for (const key of batchKeys) {
68
+ if (after.startsWith(key) && ID_ENDS.test(after.slice(key.length)) && (named === undefined || key.length > named.length)) {
69
+ named = key;
70
+ }
71
+ }
72
+ from = message.indexOf(OBJECT_ID_MARKER, from + 1);
73
+ }
74
+ return _effect.Option.fromUndefinedOr(named);
75
+ };
76
+ /**
77
+ * The 429 wordings that name a quota, not a rate, matched case-insensitively
78
+ * anywhere in Algolia's message: an application over its record quota, or over
79
+ * its allowance of indexing by file size. See the module doc for why each halts.
80
+ */
81
+ exports.objectIdNamedIn = objectIdNamedIn;
82
+ const QUOTA_429 = exports.QUOTA_429 = [/quota/i, /file size/i];
83
+ /**
84
+ * `Retry-After` as a duration, given the current time.
85
+ *
86
+ * The header is either a whole number of seconds or an HTTP date. A date is turned
87
+ * into a wait against `now`, which the caller reads from `Clock` so a test's
88
+ * `TestClock` governs it; a date already past is a wait of zero. Anything else is
89
+ * ignored rather than guessed at, and the driver's own back-off applies.
90
+ */
91
+ const parseRetryAfter = (header, nowMillis) => {
92
+ const trimmed = header.trim();
93
+ if (/^\d+$/.test(trimmed)) {
94
+ return _effect.Option.some(_effect.Duration.seconds(Number(trimmed)));
95
+ }
96
+ // A bare number with a fraction or a sign is neither form; do not let the date
97
+ // parser read it as a year.
98
+ if (/^[-+\d.]+$/.test(trimmed)) {
99
+ return _effect.Option.none();
100
+ }
101
+ return _effect.Option.map(_effect.DateTime.make(trimmed), at => _effect.Duration.millis(Math.max(0, _effect.DateTime.toEpochMillis(at) - nowMillis)));
102
+ };
103
+ /**
104
+ * Classify an Algolia error answer; see the module doc for the rules.
105
+ *
106
+ * `batchKeys` is the set of keys the request carried, empty for a call that sends
107
+ * no records, so only a write can ever produce a `SinkRejected`.
108
+ */
109
+ exports.parseRetryAfter = parseRetryAfter;
110
+ const classifyAnswer = (call, answer, batchKeys, nowMillis) => {
111
+ const reason = `Algolia answered ${answer.status} to ${call.method} ${call.path}: ${answer.message}`;
112
+ if (answer.status === 429 && QUOTA_429.some(wording => wording.test(answer.message))) {
113
+ return new _feed.SinkHalted({
114
+ reason
115
+ });
116
+ }
117
+ if (answer.status === 429 || answer.status >= 500 || answer.status < 200 || answer.status >= 300 && answer.status < 400) {
118
+ const retryAfter = _effect.Option.flatMap(answer.retryAfter, header => parseRetryAfter(header, nowMillis));
119
+ return new _feed.SinkRetryable({
120
+ reason,
121
+ ...(_effect.Option.isSome(retryAfter) ? {
122
+ retryAfter: retryAfter.value
123
+ } : {})
124
+ });
125
+ }
126
+ if (answer.status === 400) {
127
+ const named = objectIdNamedIn(answer.message, batchKeys);
128
+ if (_effect.Option.isSome(named)) {
129
+ const key = named.value;
130
+ return new _feed.SinkRejected({
131
+ keys: [key],
132
+ reasons: new Map([[key, answer.message]])
133
+ });
134
+ }
135
+ }
136
+ return new _feed.SinkHalted({
137
+ reason
138
+ });
139
+ };
140
+ /**
141
+ * A request that got no answer at all. Retryable: see the module doc for why a
142
+ * resend is safe.
143
+ */
144
+ exports.classifyAnswer = classifyAnswer;
145
+ const transportFailure = (call, detail) => new _feed.SinkRetryable({
146
+ reason: `no answer from Algolia to ${call.method} ${call.path}: ${detail}`
147
+ });
148
+ /**
149
+ * An answer this package cannot read: a success whose body is not the shape
150
+ * Algolia documents. Halted, because sending again will get the same body back.
151
+ */
152
+ exports.transportFailure = transportFailure;
153
+ const unreadableAnswer = (call, detail) => new _feed.SinkHalted({
154
+ reason: `unreadable answer from Algolia to ${call.method} ${call.path}: ${detail}`
155
+ });
156
+ exports.unreadableAnswer = unreadableAnswer;
157
+ //# sourceMappingURL=classify.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"classify.js","names":["_feed","require","_effect","OBJECT_ID_MARKER","ID_ENDS","objectIdNamedIn","message","batchKeys","named","from","indexOf","after","slice","length","key","startsWith","test","undefined","Option","fromUndefinedOr","exports","QUOTA_429","parseRetryAfter","header","nowMillis","trimmed","trim","some","Duration","seconds","Number","none","map","DateTime","make","at","millis","Math","max","toEpochMillis","classifyAnswer","call","answer","reason","status","method","path","wording","SinkHalted","retryAfter","flatMap","SinkRetryable","isSome","value","SinkRejected","keys","reasons","Map","transportFailure","detail","unreadableAnswer"],"sources":["../../src/classify.ts"],"sourcesContent":[null],"mappings":"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","ignoreList":[]}
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.AlgoliaTaskId = exports.AlgoliaIndexName = exports.AlgoliaAppId = void 0;
7
+ var _effect = require("effect");
8
+ /**
9
+ * The Algolia sink's branded identifiers.
10
+ *
11
+ * Three values cross this package's boundary that would otherwise be bare
12
+ * strings and numbers, and each is easy to pass in the place of another: an
13
+ * application id where an index name belongs, a feed key where an index name
14
+ * belongs, a task id from one index polled against another. Branding them is the
15
+ * repository's standing rule for a domain identifier, and here it is also what
16
+ * keeps a `Receipt` — an index and a task id — from being assembled by hand out of
17
+ * values that never belonged together.
18
+ */
19
+
20
+ /**
21
+ * The Algolia application id: the first half of the credentials, and the name of
22
+ * the application's hosts. Not a secret — it is sent beside every search a browser
23
+ * makes — so it is a plain branded string, unlike the admin key.
24
+ */
25
+ const AlgoliaAppId = exports.AlgoliaAppId = /*#__PURE__*/_effect.Schema.String.pipe(/*#__PURE__*/_effect.Schema.check(/*#__PURE__*/_effect.Schema.isNonEmpty()), /*#__PURE__*/_effect.Schema.brand('AlgoliaAppId'));
26
+ /**
27
+ * The name of an Algolia index. The sink's SERVED index is one; a resync builds into
28
+ * another, named after the served one, the generation it rebuilds and the attempt.
29
+ */
30
+ const AlgoliaIndexName = exports.AlgoliaIndexName = /*#__PURE__*/_effect.Schema.String.pipe(/*#__PURE__*/_effect.Schema.check(/*#__PURE__*/_effect.Schema.isNonEmpty()), /*#__PURE__*/_effect.Schema.brand('AlgoliaIndexName'));
31
+ /**
32
+ * An Algolia task id: what every write returns, and what `getTask` is polled with
33
+ * until the write is published. A task belongs to the index it was issued on, and
34
+ * is polled there and nowhere else — which is why a receipt carries the index
35
+ * beside it.
36
+ */
37
+ const AlgoliaTaskId = exports.AlgoliaTaskId = /*#__PURE__*/_effect.Schema.Number.pipe(/*#__PURE__*/_effect.Schema.check(/*#__PURE__*/_effect.Schema.isInt(), /*#__PURE__*/_effect.Schema.isGreaterThanOrEqualTo(0)), /*#__PURE__*/_effect.Schema.brand('AlgoliaTaskId'));
38
+ //# sourceMappingURL=identifiers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"identifiers.js","names":["_effect","require","AlgoliaAppId","exports","Schema","String","pipe","check","isNonEmpty","brand","AlgoliaIndexName","AlgoliaTaskId","Number","isInt","isGreaterThanOrEqualTo"],"sources":["../../src/identifiers.ts"],"sourcesContent":[null],"mappings":";;;;;;AAWA,IAAAA,OAAA,GAAAC,OAAA;AAXA;;;;;;;;;;;;AAaA;;;;;AAKO,MAAMC,YAAY,GAAAC,OAAA,CAAAD,YAAA,gBAAGE,cAAM,CAACC,MAAM,CAACC,IAAI,cAC5CF,cAAM,CAACG,KAAK,cAACH,cAAM,CAACI,UAAU,EAAE,CAAC,eACjCJ,cAAM,CAACK,KAAK,CAAC,cAAc,CAAC,CAC7B;AAGD;;;;AAIO,MAAMC,gBAAgB,GAAAP,OAAA,CAAAO,gBAAA,gBAAGN,cAAM,CAACC,MAAM,CAACC,IAAI,cAChDF,cAAM,CAACG,KAAK,cAACH,cAAM,CAACI,UAAU,EAAE,CAAC,eACjCJ,cAAM,CAACK,KAAK,CAAC,kBAAkB,CAAC,CACjC;AAGD;;;;;;AAMO,MAAME,aAAa,GAAAR,OAAA,CAAAQ,aAAA,gBAAGP,cAAM,CAACQ,MAAM,CAACN,IAAI,cAC7CF,cAAM,CAACG,KAAK,cAACH,cAAM,CAACS,KAAK,EAAE,eAAET,cAAM,CAACU,sBAAsB,CAAC,CAAC,CAAC,CAAC,eAC9DV,cAAM,CAACK,KAAK,CAAC,eAAe,CAAC,CAC9B","ignoreList":[]}
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ Object.defineProperty(exports, "AlgoliaAppId", {
7
+ enumerable: true,
8
+ get: function () {
9
+ return _identifiers.AlgoliaAppId;
10
+ }
11
+ });
12
+ Object.defineProperty(exports, "AlgoliaIndexName", {
13
+ enumerable: true,
14
+ get: function () {
15
+ return _identifiers.AlgoliaIndexName;
16
+ }
17
+ });
18
+ Object.defineProperty(exports, "AlgoliaTaskId", {
19
+ enumerable: true,
20
+ get: function () {
21
+ return _identifiers.AlgoliaTaskId;
22
+ }
23
+ });
24
+ Object.defineProperty(exports, "DEFAULT_MAX_RECORD_BYTES", {
25
+ enumerable: true,
26
+ get: function () {
27
+ return _records.DEFAULT_MAX_RECORD_BYTES;
28
+ }
29
+ });
30
+ Object.defineProperty(exports, "makeAlgoliaSink", {
31
+ enumerable: true,
32
+ get: function () {
33
+ return _sink.makeAlgoliaSink;
34
+ }
35
+ });
36
+ var _identifiers = require("./identifiers.js");
37
+ var _records = require("./records.js");
38
+ var _sink = require("./sink.js");
39
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":["_identifiers","require","_records","_sink"],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmCA,IAAAA,YAAA,GAAAC,OAAA;AAKA,IAAAC,QAAA,GAAAD,OAAA;AAEA,IAAAE,KAAA,GAAAF,OAAA","ignoreList":[]}
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.recordBytes = exports.encodeBatch = exports.DEFAULT_MAX_RECORD_BYTES = void 0;
7
+ var _feed = require("@kairos-es/feed");
8
+ var _effect = require("effect");
9
+ /**
10
+ * Turning a batch of feed changes into Algolia batch operations, and refusing the
11
+ * records Algolia would refuse BEFORE they are sent.
12
+ *
13
+ * ## Full-record replace, and nothing partial
14
+ *
15
+ * An upsert is `addObject` with the record's `objectID` set to the feed key, which
16
+ * replaces whatever Algolia held under that id; a delete is `deleteObject`. Both
17
+ * leave the index the same however many times they are sent, which is the
18
+ * idempotence the driver relies on when it resends a batch it could not confirm.
19
+ * Algolia's partial operations (`partialUpdateObject`, the built-in `Increment`
20
+ * and its kin) do not: applied twice they apply twice. They are never used.
21
+ *
22
+ * The key is written LAST, over the encoded document, so a document that carries an
23
+ * `objectID` field of its own cannot point a record at another key.
24
+ *
25
+ * ## Why oversized records are refused here
26
+ *
27
+ * Algolia refuses a whole `batch` when one record in it is over the plan's record
28
+ * size limit. Its answer does name the record, and `classify.ts` turns that into a
29
+ * rejection of that one key — but only after a round trip that wrote nothing, and
30
+ * only for the first oversized record the batch held. Checking every record's size
31
+ * up front rejects all of them at once, so the driver's resend of the rest goes
32
+ * through first time. The 400 path stays as the backstop for a limit configured
33
+ * higher than the plan's.
34
+ *
35
+ * The size is the record's JSON in UTF-8 bytes, `objectID` included, which is how
36
+ * Algolia measures it. The default limit is the smallest Algolia plan's (10 KB);
37
+ * an application on a larger plan raises it.
38
+ *
39
+ * ## A document that does not encode
40
+ *
41
+ * The document schema is the read model's, and a document it cannot encode — or one
42
+ * that encodes to something other than a JSON object, which is all an Algolia
43
+ * record can be — is a defect in the read model, not a record the sink refuses. It
44
+ * dies, loudly, rather than being parked where nobody looks.
45
+ */
46
+
47
+ /** The smallest Algolia plan's record size limit, in bytes. */
48
+ const DEFAULT_MAX_RECORD_BYTES = exports.DEFAULT_MAX_RECORD_BYTES = 10_000;
49
+ const utf8 = /*#__PURE__*/new TextEncoder();
50
+ /** A record's size as Algolia measures it: its JSON, in UTF-8 bytes. */
51
+ const recordBytes = record => utf8.encode(JSON.stringify(record)).length;
52
+ exports.recordBytes = recordBytes;
53
+ const isJsonObject = u => typeof u === 'object' && u !== null && !Array.isArray(u);
54
+ /**
55
+ * Encode a batch, failing `SinkRejected` with EVERY oversized key when there is
56
+ * one; see the module doc.
57
+ */
58
+ const encodeBatch = (doc, maxRecordBytes, batch) => _effect.Effect.gen(function* () {
59
+ const encode = _effect.Schema.encodeEffect(doc);
60
+ const operations = [];
61
+ const keys = new Set();
62
+ const reasons = new Map();
63
+ for (const change of batch) {
64
+ keys.add(change.key);
65
+ if (change._tag === 'Delete') {
66
+ operations.push({
67
+ action: 'deleteObject',
68
+ body: {
69
+ objectID: change.key
70
+ }
71
+ });
72
+ continue;
73
+ }
74
+ const encoded = yield* _effect.Effect.orDie(encode(change.doc));
75
+ if (!isJsonObject(encoded)) {
76
+ return yield* _effect.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`));
77
+ }
78
+ const record = {
79
+ ...encoded,
80
+ objectID: change.key
81
+ };
82
+ const size = recordBytes(record);
83
+ if (size > maxRecordBytes) {
84
+ reasons.set(change.key, `the record is ${size} bytes, over the configured maxRecordBytes of ${maxRecordBytes}`);
85
+ continue;
86
+ }
87
+ operations.push({
88
+ action: 'addObject',
89
+ body: record
90
+ });
91
+ }
92
+ const rejected = Array.from(reasons.keys());
93
+ if (_effect.Array.isReadonlyArrayNonEmpty(rejected)) {
94
+ return yield* _effect.Effect.fail(new _feed.SinkRejected({
95
+ keys: rejected,
96
+ reasons
97
+ }));
98
+ }
99
+ return {
100
+ operations,
101
+ keys
102
+ };
103
+ });
104
+ exports.encodeBatch = encodeBatch;
105
+ //# sourceMappingURL=records.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"records.js","names":["_feed","require","_effect","DEFAULT_MAX_RECORD_BYTES","exports","utf8","TextEncoder","recordBytes","record","encode","JSON","stringify","length","isJsonObject","u","Array","isArray","encodeBatch","doc","maxRecordBytes","batch","Effect","gen","Schema","encodeEffect","operations","keys","Set","reasons","Map","change","add","key","_tag","push","action","body","objectID","encoded","orDie","die","Error","size","set","rejected","from","Arr","isReadonlyArrayNonEmpty","fail","SinkRejected"],"sources":["../../src/records.ts"],"sourcesContent":[null],"mappings":";;;;;;AAqCA,IAAAA,KAAA,GAAAC,OAAA;AACA,IAAAC,OAAA,GAAAD,OAAA;AAtCA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0CA;AACO,MAAME,wBAAwB,GAAAC,OAAA,CAAAD,wBAAA,GAAG,MAAM;AAE9C,MAAME,IAAI,gBAAG,IAAIC,WAAW,EAAE;AAE9B;AACO,MAAMC,WAAW,GACtBC,MAAyC,IAC9BH,IAAI,CAACI,MAAM,CAACC,IAAI,CAACC,SAAS,CAACH,MAAM,CAAC,CAAC,CAACI,MAAM;AAAAR,OAAA,CAAAG,WAAA,GAAAA,WAAA;AAQvD,MAAMM,YAAY,GAAIC,CAAU,IAC9B,OAAOA,CAAC,KAAK,QAAQ,IAAIA,CAAC,KAAK,IAAI,IAAI,CAACC,KAAK,CAACC,OAAO,CAACF,CAAC,CAAC;AAE1D;;;;AAIO,MAAMG,WAAW,GAAGA,CACzBC,GAAuB,EACvBC,cAAsB,EACtBC,KAA2C,KAE3CC,cAAM,CAACC,GAAG,CAAC,aAAS;EAClB,MAAMb,MAAM,GAAGc,cAAM,CAACC,YAAY,CAACN,GAAG,CAAC;EACvC,MAAMO,UAAU,GAA0B,EAAE;EAC5C,MAAMC,IAAI,GAAG,IAAIC,GAAG,EAAW;EAC/B,MAAMC,OAAO,GAAG,IAAIC,GAAG,EAAmB;EAC1C,KAAK,MAAMC,MAAM,IAAIV,KAAK,EAAE;IAC1BM,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,OAAOjB,cAAM,CAACkB,KAAK,CAAC9B,MAAM,CAACqB,MAAM,CAACZ,GAAG,CAAC,CAAC;IAChE,IAAI,CAACL,YAAY,CAACyB,OAAO,CAAC,EAAE;MAC1B,OAAO,OAAOjB,cAAM,CAACmB,GAAG,CACtB,IAAIC,KAAK,CACP,uCAAuCX,MAAM,CAACE,GAAG,gBAAgBM,OAAO,KAAK,IAAI,GAAG,MAAM,GAAG,OAAOA,OAAO,+CAA+C,CAC3J,CACF;IACH;IACA,MAAM9B,MAAM,GAAG;MAAE,GAAG8B,OAAO;MAAED,QAAQ,EAAEP,MAAM,CAACE;IAAG,CAAE;IACnD,MAAMU,IAAI,GAAGnC,WAAW,CAACC,MAAM,CAAC;IAChC,IAAIkC,IAAI,GAAGvB,cAAc,EAAE;MACzBS,OAAO,CAACe,GAAG,CACTb,MAAM,CAACE,GAAG,EACV,iBAAiBU,IAAI,iDAAiDvB,cAAc,EAAE,CACvF;MACD;IACF;IACAM,UAAU,CAACS,IAAI,CAAC;MAAEC,MAAM,EAAE,WAAW;MAAEC,IAAI,EAAE5B;IAAM,CAAE,CAAC;EACxD;EACA,MAAMoC,QAAQ,GAAG7B,KAAK,CAAC8B,IAAI,CAACjB,OAAO,CAACF,IAAI,EAAE,CAAC;EAC3C,IAAIoB,aAAG,CAACC,uBAAuB,CAACH,QAAQ,CAAC,EAAE;IACzC,OAAO,OAAOvB,cAAM,CAAC2B,IAAI,CAAC,IAAIC,kBAAY,CAAC;MAAEvB,IAAI,EAAEkB,QAAQ;MAAEhB;IAAO,CAAE,CAAC,CAAC;EAC1E;EACA,OAAO;IAAEH,UAAU;IAAEC;EAAI,CAAE;AAC7B,CAAC,CAAC;AAAAtB,OAAA,CAAAa,WAAA,GAAAA,WAAA","ignoreList":[]}