@loams/client 0.1.0 → 0.1.1
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/README.md +78 -2
- package/dist/app.d.ts +149 -0
- package/dist/app.js +317 -0
- package/dist/builders/filter.d.ts +96 -0
- package/dist/builders/filter.js +190 -0
- package/dist/builders/schema.d.ts +50 -0
- package/dist/builders/schema.js +92 -0
- package/dist/builders/search.d.ts +159 -0
- package/dist/builders/search.js +307 -0
- package/dist/gen/facade.d.ts +267 -2
- package/dist/gen/facade.js +194 -0
- package/dist/handle.d.ts +67 -0
- package/dist/handle.js +107 -0
- package/dist/index.d.ts +12 -5
- package/dist/index.js +12 -3
- package/dist/loams.d.ts +77 -2
- package/dist/loams.js +84 -6
- package/dist/runtime/batch.d.ts +29 -0
- package/dist/runtime/batch.js +95 -0
- package/dist/runtime/call.d.ts +63 -9
- package/dist/runtime/call.js +243 -41
- package/dist/runtime/consistency.d.ts +65 -11
- package/dist/runtime/consistency.js +212 -46
- package/dist/runtime/errors.d.ts +29 -2
- package/dist/runtime/errors.js +26 -0
- package/dist/runtime/pagination.js +8 -2
- package/dist/runtime/subscribe.d.ts +37 -0
- package/dist/runtime/subscribe.js +128 -0
- package/dist/vendor/proto/collection-v1.d.ts +3 -0
- package/dist/vendor/proto/collection-v1.js +7 -0
- package/dist/vendor/proto/gen/loams/collection/v1/collection_pb.d.ts +1610 -0
- package/dist/vendor/proto/gen/loams/collection/v1/collection_pb.js +399 -0
- package/dist/vendor/proto/gen/loams/collection/v1/document_pb.d.ts +1054 -0
- package/dist/vendor/proto/gen/loams/collection/v1/document_pb.js +327 -0
- package/dist/vendor/proto/gen/loams/collection/v1/query_pb.d.ts +2101 -0
- package/dist/vendor/proto/gen/loams/collection/v1/query_pb.js +608 -0
- package/dist/vendor/proto/gen/loams/graph/v1/graph_pb.d.ts +2299 -0
- package/dist/vendor/proto/gen/loams/graph/v1/graph_pb.js +596 -0
- package/dist/vendor/proto/gen/loams/operations/v1/operations_pb.d.ts +399 -0
- package/dist/vendor/proto/gen/loams/operations/v1/operations_pb.js +118 -0
- package/dist/vendor/proto/gen/loams/sql/v1/sql_pb.d.ts +182 -0
- package/dist/vendor/proto/gen/loams/sql/v1/sql_pb.js +72 -0
- package/dist/vendor/proto/gen/loams/stream/v1/stream_pb.d.ts +688 -0
- package/dist/vendor/proto/gen/loams/stream/v1/stream_pb.js +180 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# The TypeScript SDK (`@loams/client`)
|
|
4
4
|
|
|
5
|
-
> **Preview.** `0.1.
|
|
5
|
+
> **Preview.** `0.1.1` tracks the Loams preview server and may change between previews; pin an exact version. Installed with `npm install @loams/client@preview`.
|
|
6
6
|
|
|
7
7
|
Design §44 §7 and §10.2: the SDK is a `Loams` object with namespaced modules,
|
|
8
8
|
generated from the `loams.options.v1` annotations on the protos, over the
|
|
@@ -29,7 +29,7 @@ generator inputs stay outside the workspace, next to `proto/`.
|
|
|
29
29
|
npm i @loams/client # and @loams/live for Live's stubs
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
`0.1.
|
|
32
|
+
`0.1.1` tracks Loams 0.1.1; in 0.x the API may change between minor versions.
|
|
33
33
|
|
|
34
34
|
## Using it
|
|
35
35
|
|
|
@@ -67,6 +67,82 @@ The `/node` subpath exists so a bundler never follows `@connectrpc/connect-node`
|
|
|
67
67
|
into a browser build it cannot satisfy; the conformance suite checks that
|
|
68
68
|
statically.
|
|
69
69
|
|
|
70
|
+
## Build an app
|
|
71
|
+
|
|
72
|
+
The app-building layer (SDKP) adds builders, a collection handle and the
|
|
73
|
+
runtime an app needs: a default namespace, deadlines, read-your-writes
|
|
74
|
+
consistency tokens, chunked bulk writes and stream subscriptions.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { Filter, Loams, Schema, Search, Sort, rrf } from '@loams/client';
|
|
78
|
+
|
|
79
|
+
const loams = Loams.fromEnv(); // LOAMS_ENDPOINT, LOAMS_API_KEY, LOAMS_NAMESPACE
|
|
80
|
+
// or: new Loams({ endpoint, auth: key, namespace: 'app', sessionConsistency: true })
|
|
81
|
+
|
|
82
|
+
await loams.collections.create({
|
|
83
|
+
name: 'notes',
|
|
84
|
+
schema: new Schema().text('title').text('body').keyword('tags').i64('n')
|
|
85
|
+
.vector('embedding', 4).toJson(),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const notes = loams.collection('notes'); // calls with `collection` filled in
|
|
89
|
+
await notes.upsert([
|
|
90
|
+
{ id: 1, source: { title: 'Refunds', body: 'refund policy', tags: 'policy', n: 1 },
|
|
91
|
+
vectors: { embedding: [1, 0, 0, 0] } },
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
const hits = await notes.search(
|
|
95
|
+
notes.query()
|
|
96
|
+
.text('refund', { fields: ['body'] })
|
|
97
|
+
.vector('embedding', [1, 0, 0, 0], { k: 10 })
|
|
98
|
+
.fuse(rrf(60))
|
|
99
|
+
.filter(Filter.and(Filter.eq('tags', 'policy'), Filter.range('n', { gte: 1 })))
|
|
100
|
+
.highlight('body')
|
|
101
|
+
.select({ include: ['title'] })
|
|
102
|
+
.limit(5),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
for await (const hit of loams.search.queryAll(
|
|
106
|
+
Search.on('notes').text('refund').sort(Sort.score(), Sort.pk()).limit(100),
|
|
107
|
+
)) { /* every hit, page by page */ }
|
|
108
|
+
|
|
109
|
+
await notes.writeBatch(rows); // 10 000 rows / 4 MiB a chunk, one key per chunk
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- **Default namespace (R11).** `namespace` fills every request that has a
|
|
113
|
+
`namespace` field and leaves it empty; an explicit one wins, and neither is
|
|
114
|
+
`InvalidArgumentError` before any network call.
|
|
115
|
+
- **Deadlines (R12).** Unary calls default to 30 s (`timeoutMs` on the client
|
|
116
|
+
or per call; `0` disables). Running out raises `DeadlineExceededError` with
|
|
117
|
+
reason `client_deadline_exceeded`, which is never retried. Streams have no
|
|
118
|
+
default deadline.
|
|
119
|
+
- **Consistency tokens (R4).** `ConsistencyToken.parse/merge/toString`; with
|
|
120
|
+
`sessionConsistency: true` every token a response carries merges into
|
|
121
|
+
`loams.consistency`, and every read with a `consistency` field is sent
|
|
122
|
+
`at_least` the session.
|
|
123
|
+
- **Bulk writes (R13).** `documents.writeBatch` sends one chunk at a time,
|
|
124
|
+
each with its own idempotency key, returns the merged token, and stops at
|
|
125
|
+
the first failed chunk with a `BatchError` (`chunksApplied`, `rowsApplied`).
|
|
126
|
+
- **Subscriptions (R14).** `streams.subscribe({ stream, partition, fromOffset })`
|
|
127
|
+
is an async iterable over `Fetch` that exposes `offset` for resuming.
|
|
128
|
+
|
|
129
|
+
The modules `loams.namespaces`, `loams.collections`, `loams.documents`,
|
|
130
|
+
`loams.search`, `loams.vector` and `loams.graph` are generated from the protos'
|
|
131
|
+
facade annotations, with the hand-written conveniences above added to them.
|
|
132
|
+
`loams.sql` is generated from `loams.sql.v1` the same way: `sql.query` answers
|
|
133
|
+
`columns` and `rows`, `sql.records(statement, [$1, …])` answers one object per
|
|
134
|
+
row, and `sqlRecords(answer)` reads an answer already in hand. `sql.queryArrow`
|
|
135
|
+
answers the result as Arrow IPC bytes (`arrowIpc`), which `apache-arrow`'s
|
|
136
|
+
`tableFromIPC` reads; the SDK does not depend on it. `loams.streams` is
|
|
137
|
+
generated from `loams.stream.v1`: `create`, `get`, `list` (and `listAll`),
|
|
138
|
+
`produce` (pass `producerId` and `producerSeq` so a retry cannot append
|
|
139
|
+
twice), `fetch`, `produceEvents` and `fetchEvents` (CloudEvents as a JSON
|
|
140
|
+
batch), plus `streams.subscribe(...)` (R14), an async iterable over `fetch`
|
|
141
|
+
whose `offset` is where to resume. `examples/notes/main.ts` is the whole app
|
|
142
|
+
scenario as a program, type-checked in CI. The session's token is used only for
|
|
143
|
+
collection and SQL reads: a graph's token names one graph (SDKP-17). Durable
|
|
144
|
+
workflows use the Resonate SDK pointed at `LOAMS_ENDPOINT/durable/` (SDKP-1).
|
|
145
|
+
|
|
70
146
|
## What is generated and what is hand-written
|
|
71
147
|
|
|
72
148
|
Design §44 §7.3 draws the line, and this SDK follows it exactly.
|
package/dist/app.d.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { type JsonObject, type JsonValue } from '@bufbuild/protobuf';
|
|
2
|
+
import { type ListValue } from '@bufbuild/protobuf/wkt';
|
|
3
|
+
import type { ModuleBinding } from './gen/facade.js';
|
|
4
|
+
import { type DocId, type FilterJson } from './builders/filter.js';
|
|
5
|
+
import { Search, type ConsistencyLevel, type FusionJson } from './builders/search.js';
|
|
6
|
+
import { type BatchOptions, type BatchResult } from './runtime/batch.js';
|
|
7
|
+
import type { CallOptions } from './runtime/call.js';
|
|
8
|
+
import { type OffsetRecord, type Subscription, type SubscribeRequest } from './runtime/subscribe.js';
|
|
9
|
+
/** A generated unary call, as the hand-written layer sees it. */
|
|
10
|
+
export type UnaryCall = (request: any, options?: CallOptions) => Promise<any>;
|
|
11
|
+
/** One document to write. */
|
|
12
|
+
export interface DocumentInput {
|
|
13
|
+
id: DocId;
|
|
14
|
+
/** The document's `_source`. */
|
|
15
|
+
source?: JsonObject;
|
|
16
|
+
/** Dense vectors by name. */
|
|
17
|
+
vectors?: Record<string, readonly number[]>;
|
|
18
|
+
/** Sparse vectors by name. */
|
|
19
|
+
sparseVectors?: Record<string, {
|
|
20
|
+
indices: number[];
|
|
21
|
+
values: number[];
|
|
22
|
+
}>;
|
|
23
|
+
}
|
|
24
|
+
/** Where a document call goes: the collection, and the namespace when it is
|
|
25
|
+
* not the client's default. */
|
|
26
|
+
export interface Target {
|
|
27
|
+
collection: string;
|
|
28
|
+
namespace?: string;
|
|
29
|
+
}
|
|
30
|
+
/** A document id as `DocumentId`'s proto3 JSON: `{"uint": "1"}`,
|
|
31
|
+
* `{"string": "a"}` or `{"uuid": "…"}`. */
|
|
32
|
+
export declare function documentIdJson(id: DocId): JsonObject;
|
|
33
|
+
/** The `WriteOp`s of the document conveniences, as proto3 JSON. */
|
|
34
|
+
export declare const WriteOps: {
|
|
35
|
+
readonly upsert: (doc: DocumentInput) => JsonObject;
|
|
36
|
+
readonly delete: (id: DocId) => JsonObject;
|
|
37
|
+
readonly patch: (id: DocId, change: PatchChange) => JsonObject;
|
|
38
|
+
};
|
|
39
|
+
/** What a `patch` changes. */
|
|
40
|
+
export interface PatchChange {
|
|
41
|
+
/** The keys to merge into `_source`. */
|
|
42
|
+
set?: JsonObject;
|
|
43
|
+
/** The top-level keys to remove. */
|
|
44
|
+
unset?: string[];
|
|
45
|
+
/** `merge_deep` (the default), `merge_top` or `replace`. */
|
|
46
|
+
mode?: 'merge_deep' | 'merge_top' | 'replace';
|
|
47
|
+
/** Vectors to set; `null` removes one. */
|
|
48
|
+
vectors?: Record<string, readonly number[] | null>;
|
|
49
|
+
/** The document to write when there is none to patch. */
|
|
50
|
+
upsert?: DocumentInput;
|
|
51
|
+
}
|
|
52
|
+
/** The scroll options of `scrollAll`. */
|
|
53
|
+
export interface ScrollOptions extends CallOptions {
|
|
54
|
+
/** A typed filter (`ScrollDocumentsRequest.where`, a `Query`'s proto3 JSON). */
|
|
55
|
+
where?: FilterJson;
|
|
56
|
+
/** Documents per page. */
|
|
57
|
+
limit?: number;
|
|
58
|
+
select?: JsonObject;
|
|
59
|
+
/** How fresh each page must be. */
|
|
60
|
+
read?: ConsistencyLevel;
|
|
61
|
+
}
|
|
62
|
+
/** The document conveniences over `documents.write` and `documents.scroll`. */
|
|
63
|
+
export declare function documentCalls(documents: {
|
|
64
|
+
write: UnaryCall;
|
|
65
|
+
scroll?: UnaryCall;
|
|
66
|
+
}): {
|
|
67
|
+
/** Writes (creates or replaces) documents in one call. Answers the write's
|
|
68
|
+
* response, whose `token` is its consistency token. */
|
|
69
|
+
upsert(collection: string | Target, docs: DocumentInput[], options?: CallOptions): Promise<any>;
|
|
70
|
+
/** Removes documents by id in one call. */
|
|
71
|
+
delete(collection: string | Target, ids: DocId[], options?: CallOptions): Promise<any>;
|
|
72
|
+
/** Changes part of one document. */
|
|
73
|
+
patch(collection: string | Target, id: DocId, change: PatchChange, options?: CallOptions): Promise<any>;
|
|
74
|
+
/** A bulk upsert, chunked, one chunk in flight (R13). */
|
|
75
|
+
writeBatch(collection: string | Target, docs: Iterable<DocumentInput> | AsyncIterable<DocumentInput>, options?: BatchOptions & CallOptions): Promise<BatchResult>;
|
|
76
|
+
/** Every document, page by page, following `next` until it is unset. */
|
|
77
|
+
scrollAll(collection: string | Target, options?: ScrollOptions): AsyncGenerator<unknown, void, unknown>;
|
|
78
|
+
};
|
|
79
|
+
/** The options of the search shorthands. */
|
|
80
|
+
export interface SearchShorthandOptions extends CallOptions {
|
|
81
|
+
namespace?: string;
|
|
82
|
+
filter?: FilterJson;
|
|
83
|
+
limit?: number;
|
|
84
|
+
}
|
|
85
|
+
/** The search conveniences over `search.query`. */
|
|
86
|
+
export declare function searchCalls(search: {
|
|
87
|
+
query: UnaryCall;
|
|
88
|
+
}): {
|
|
89
|
+
/** A text search: one field is a `match`, several a `multi_match`, none a
|
|
90
|
+
* `query_string`. */
|
|
91
|
+
text(collection: string, text: string, options?: SearchShorthandOptions & {
|
|
92
|
+
fields?: string[];
|
|
93
|
+
}): Promise<any>;
|
|
94
|
+
/** Text plus vector, fused (RRF by default). */
|
|
95
|
+
hybrid(collection: string, text: string, vector: readonly number[], options: SearchShorthandOptions & {
|
|
96
|
+
field: string;
|
|
97
|
+
fields?: string[];
|
|
98
|
+
fusion?: FusionJson;
|
|
99
|
+
k?: number;
|
|
100
|
+
}): Promise<any>;
|
|
101
|
+
/**
|
|
102
|
+
* Every hit, page after page, following `search_after` from the last hit's
|
|
103
|
+
* `sortValues` until a page comes back short. The sort must end in a
|
|
104
|
+
* primary-key tiebreaker (`Sort.pk()`), or two hits with equal sort values
|
|
105
|
+
* could straddle a page and be skipped or repeated; without one this
|
|
106
|
+
* refuses with `invalid_argument` before any call.
|
|
107
|
+
*/
|
|
108
|
+
queryAll(builder: Search, options?: CallOptions): AsyncGenerator<{
|
|
109
|
+
sortValues?: unknown[];
|
|
110
|
+
}, void, unknown>;
|
|
111
|
+
};
|
|
112
|
+
/** The parts of a `sql.query` answer `sqlRecords` reads. */
|
|
113
|
+
export interface SqlAnswer {
|
|
114
|
+
columns: readonly {
|
|
115
|
+
name: string;
|
|
116
|
+
}[];
|
|
117
|
+
rows: readonly ListValue[];
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* A `sql.query` answer's rows as one object per row, keyed by column name
|
|
121
|
+
* (SDKP §2.5). A value is its JSON: a 64-bit integer past 2^53 is the decimal
|
|
122
|
+
* string the server sent, so it stays exact. A column name that repeats keeps
|
|
123
|
+
* its last value, as a JavaScript object must.
|
|
124
|
+
*/
|
|
125
|
+
export declare function sqlRecords(answer: SqlAnswer): Record<string, JsonValue>[];
|
|
126
|
+
/** The options of `sql.records`. */
|
|
127
|
+
export interface SqlRecordsOptions extends CallOptions {
|
|
128
|
+
namespace?: string;
|
|
129
|
+
maxRows?: number;
|
|
130
|
+
}
|
|
131
|
+
/** The SQL conveniences over `sql.query`. */
|
|
132
|
+
export declare function sqlCalls(sql: {
|
|
133
|
+
query: UnaryCall;
|
|
134
|
+
}): {
|
|
135
|
+
/**
|
|
136
|
+
* One read-only statement with its `$1…$n` values, answered as records
|
|
137
|
+
* (`sqlRecords`). The values are JSON scalars; the server casts each to the
|
|
138
|
+
* type its placeholder was inferred as.
|
|
139
|
+
*/
|
|
140
|
+
records(statement: string, params?: readonly JsonValue[], options?: SqlRecordsOptions): Promise<Record<string, JsonValue>[]>;
|
|
141
|
+
};
|
|
142
|
+
/** The stream conveniences over `streams.fetch`. */
|
|
143
|
+
export declare function streamCalls(streams: {
|
|
144
|
+
fetch: UnaryCall;
|
|
145
|
+
}): {
|
|
146
|
+
/** Every record from `fromOffset` on, as an async iterable (R14). */
|
|
147
|
+
subscribe<R extends OffsetRecord = OffsetRecord>(request: SubscribeRequest, options?: CallOptions): Subscription<R>;
|
|
148
|
+
};
|
|
149
|
+
export declare function attachAppCalls(modules: Record<string, unknown>, bindings: readonly ModuleBinding[]): void;
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// The hand-written half of the app modules (SDKP §2.3–§2.5, §4).
|
|
2
|
+
//
|
|
3
|
+
// The generated facade gives one function per proto method. A developer
|
|
4
|
+
// building an app wants a few more, each a thin loop or shorthand over those:
|
|
5
|
+
//
|
|
6
|
+
// - `documents.upsert`, `delete`, `patch` and `writeBatch` over `write`;
|
|
7
|
+
// - `documents.scrollAll` over `scroll` (its cursor is a `DocumentId`, not an
|
|
8
|
+
// AIP-158 string, so the generic `listAll` does not fit it);
|
|
9
|
+
// - `search.text`, `search.hybrid` and `search.queryAll` over `search.query`;
|
|
10
|
+
// - `sql.records` over `sql.query`, and `sqlRecords` for an answer in hand;
|
|
11
|
+
// - `streams.subscribe` over `streams.fetch`;
|
|
12
|
+
// - `<call>All` for every paged call (`namespaces.listAll`,
|
|
13
|
+
// `collections.listAll`), over the generic paging iterator.
|
|
14
|
+
//
|
|
15
|
+
// `attachAppCalls` adds them to the module objects the facade built, and only
|
|
16
|
+
// to modules that exist: an SDK whose facade has no `documents` module gets no
|
|
17
|
+
// `documents.upsert`. Nothing here names an RPC path or a retry class; those
|
|
18
|
+
// stay the generated bindings'.
|
|
19
|
+
import { fromJson, toJson } from '@bufbuild/protobuf';
|
|
20
|
+
import { ListValueSchema, ValueSchema } from '@bufbuild/protobuf/wkt';
|
|
21
|
+
import { Code } from '@connectrpc/connect';
|
|
22
|
+
import { WriteOpSchema } from './vendor/proto/gen/loams/collection/v1/document_pb.js';
|
|
23
|
+
import { idValue } from './builders/filter.js';
|
|
24
|
+
import { Search, consistencyJson, rrf } from './builders/search.js';
|
|
25
|
+
import { writeBatch } from './runtime/batch.js';
|
|
26
|
+
import { InvalidArgumentError } from './runtime/errors.js';
|
|
27
|
+
import { paginate } from './runtime/pagination.js';
|
|
28
|
+
import { subscribe } from './runtime/subscribe.js';
|
|
29
|
+
/** A document id as `DocumentId`'s proto3 JSON: `{"uint": "1"}`,
|
|
30
|
+
* `{"string": "a"}` or `{"uuid": "…"}`. */
|
|
31
|
+
export function documentIdJson(id) {
|
|
32
|
+
if (typeof id === 'string') {
|
|
33
|
+
return { string: id };
|
|
34
|
+
}
|
|
35
|
+
const value = idValue(id);
|
|
36
|
+
return typeof value === 'string' ? { string: value } : value;
|
|
37
|
+
}
|
|
38
|
+
function documentJson(doc) {
|
|
39
|
+
const out = { id: documentIdJson(doc.id) };
|
|
40
|
+
if (doc.source !== undefined) {
|
|
41
|
+
out.source = doc.source;
|
|
42
|
+
}
|
|
43
|
+
if (doc.vectors !== undefined) {
|
|
44
|
+
out.vectors = Object.fromEntries(Object.entries(doc.vectors).map(([name, values]) => [name, [...values]]));
|
|
45
|
+
}
|
|
46
|
+
if (doc.sparseVectors !== undefined) {
|
|
47
|
+
out.sparseVectors = doc.sparseVectors;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/** The `WriteOp`s of the document conveniences, as proto3 JSON. */
|
|
52
|
+
export const WriteOps = {
|
|
53
|
+
upsert(doc) {
|
|
54
|
+
return { upsert: documentJson(doc) };
|
|
55
|
+
},
|
|
56
|
+
delete(id) {
|
|
57
|
+
return { delete: { id: documentIdJson(id) } };
|
|
58
|
+
},
|
|
59
|
+
patch(id, change) {
|
|
60
|
+
const patch = { id: documentIdJson(id) };
|
|
61
|
+
if (change.mode !== undefined) {
|
|
62
|
+
patch.mode = change.mode;
|
|
63
|
+
}
|
|
64
|
+
if (change.set !== undefined) {
|
|
65
|
+
patch.source = change.set;
|
|
66
|
+
}
|
|
67
|
+
if (change.unset !== undefined && change.unset.length > 0) {
|
|
68
|
+
patch.deleteKeys = [...change.unset];
|
|
69
|
+
}
|
|
70
|
+
if (change.vectors !== undefined) {
|
|
71
|
+
patch.vectors = change.vectors;
|
|
72
|
+
}
|
|
73
|
+
if (change.upsert !== undefined) {
|
|
74
|
+
patch.upsert = documentJson(change.upsert);
|
|
75
|
+
}
|
|
76
|
+
return { patch };
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
function opsRequest(target, ops) {
|
|
80
|
+
return {
|
|
81
|
+
...(target.namespace === undefined ? {} : { namespace: target.namespace }),
|
|
82
|
+
collection: target.collection,
|
|
83
|
+
ops: ops.map((op) => fromJson(WriteOpSchema, op)),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function targetOf(collection) {
|
|
87
|
+
return typeof collection === 'string' ? { collection } : collection;
|
|
88
|
+
}
|
|
89
|
+
/** The document conveniences over `documents.write` and `documents.scroll`. */
|
|
90
|
+
export function documentCalls(documents) {
|
|
91
|
+
return {
|
|
92
|
+
/** Writes (creates or replaces) documents in one call. Answers the write's
|
|
93
|
+
* response, whose `token` is its consistency token. */
|
|
94
|
+
upsert(collection, docs, options) {
|
|
95
|
+
return documents.write(opsRequest(targetOf(collection), docs.map(WriteOps.upsert)), options);
|
|
96
|
+
},
|
|
97
|
+
/** Removes documents by id in one call. */
|
|
98
|
+
delete(collection, ids, options) {
|
|
99
|
+
return documents.write(opsRequest(targetOf(collection), ids.map(WriteOps.delete)), options);
|
|
100
|
+
},
|
|
101
|
+
/** Changes part of one document. */
|
|
102
|
+
patch(collection, id, change, options) {
|
|
103
|
+
return documents.write(opsRequest(targetOf(collection), [WriteOps.patch(id, change)]), options);
|
|
104
|
+
},
|
|
105
|
+
/** A bulk upsert, chunked, one chunk in flight (R13). */
|
|
106
|
+
writeBatch(collection, docs, options = {}) {
|
|
107
|
+
const target = targetOf(collection);
|
|
108
|
+
return writeBatch((ops, callOptions) => documents.write(opsRequest(target, ops), callOptions), mapOps(docs), options);
|
|
109
|
+
},
|
|
110
|
+
/** Every document, page by page, following `next` until it is unset. */
|
|
111
|
+
async *scrollAll(collection, options = {}) {
|
|
112
|
+
if (documents.scroll === undefined) {
|
|
113
|
+
throw new InvalidArgumentError('this SDK build has no documents.scroll', {
|
|
114
|
+
code: Code.Unimplemented,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
const { where, limit, select, read, ...call } = options;
|
|
118
|
+
const target = targetOf(collection);
|
|
119
|
+
let after;
|
|
120
|
+
for (;;) {
|
|
121
|
+
const page = await documents.scroll({
|
|
122
|
+
...(target.namespace === undefined ? {} : { namespace: target.namespace }),
|
|
123
|
+
collection: target.collection,
|
|
124
|
+
// `where` is a `Query`'s proto3 JSON (SDKP-12), which is what a
|
|
125
|
+
// `Filter` already is.
|
|
126
|
+
...(where === undefined ? {} : { where }),
|
|
127
|
+
...(limit === undefined ? {} : { limit }),
|
|
128
|
+
...(select === undefined ? {} : { select }),
|
|
129
|
+
...(read === undefined ? {} : { consistency: consistencyJsonMessage(read) }),
|
|
130
|
+
...(after === undefined ? {} : { after }),
|
|
131
|
+
}, call);
|
|
132
|
+
for (const doc of (page?.documents ?? [])) {
|
|
133
|
+
yield doc;
|
|
134
|
+
}
|
|
135
|
+
if (page?.next === undefined || page.next.id?.case === undefined) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
after = page.next;
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/** A `Consistency` init object for a call that takes one directly. */
|
|
144
|
+
function consistencyJsonMessage(level) {
|
|
145
|
+
const json = consistencyJson(level);
|
|
146
|
+
if ('atLeast' in json) {
|
|
147
|
+
return { atLeast: json.atLeast };
|
|
148
|
+
}
|
|
149
|
+
return { freshness: json.freshness === 'FRESHNESS_EVENTUAL' ? 2 : 1 };
|
|
150
|
+
}
|
|
151
|
+
async function* mapOps(docs) {
|
|
152
|
+
for await (const doc of docs) {
|
|
153
|
+
yield WriteOps.upsert(doc);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** The search conveniences over `search.query`. */
|
|
157
|
+
export function searchCalls(search) {
|
|
158
|
+
const run = (builder, options) => search.query(builder.build(), options);
|
|
159
|
+
return {
|
|
160
|
+
/** A text search: one field is a `match`, several a `multi_match`, none a
|
|
161
|
+
* `query_string`. */
|
|
162
|
+
text(collection, text, options = {}) {
|
|
163
|
+
const { namespace, filter, limit, fields, ...call } = options;
|
|
164
|
+
let builder = Search.on(collection, { namespace }).text(text, { fields });
|
|
165
|
+
if (filter !== undefined)
|
|
166
|
+
builder = builder.filter(filter);
|
|
167
|
+
if (limit !== undefined)
|
|
168
|
+
builder = builder.limit(limit);
|
|
169
|
+
return run(builder, call);
|
|
170
|
+
},
|
|
171
|
+
/** Text plus vector, fused (RRF by default). */
|
|
172
|
+
hybrid(collection, text, vector, options) {
|
|
173
|
+
const { namespace, filter, limit, fields, field, fusion, k, ...call } = options;
|
|
174
|
+
let builder = Search.on(collection, { namespace })
|
|
175
|
+
.text(text, { fields, k })
|
|
176
|
+
.vector(field, vector, { k })
|
|
177
|
+
.fuse(fusion ?? rrf());
|
|
178
|
+
if (filter !== undefined)
|
|
179
|
+
builder = builder.filter(filter);
|
|
180
|
+
if (limit !== undefined)
|
|
181
|
+
builder = builder.limit(limit);
|
|
182
|
+
return run(builder, call);
|
|
183
|
+
},
|
|
184
|
+
/**
|
|
185
|
+
* Every hit, page after page, following `search_after` from the last hit's
|
|
186
|
+
* `sortValues` until a page comes back short. The sort must end in a
|
|
187
|
+
* primary-key tiebreaker (`Sort.pk()`), or two hits with equal sort values
|
|
188
|
+
* could straddle a page and be skipped or repeated; without one this
|
|
189
|
+
* refuses with `invalid_argument` before any call.
|
|
190
|
+
*/
|
|
191
|
+
async *queryAll(builder, options = {}) {
|
|
192
|
+
if (!builder.hasTiebreak) {
|
|
193
|
+
throw new InvalidArgumentError('search.queryAll needs a sort that ends in the primary key, such as .sort(Sort.score(), Sort.pk())', { code: Code.InvalidArgument, reason: 'search_after_needs_tiebreak', metadata: { field: 'sort' } });
|
|
194
|
+
}
|
|
195
|
+
const limit = builder.toJson().limit ?? 10;
|
|
196
|
+
let page = builder;
|
|
197
|
+
for (;;) {
|
|
198
|
+
const answer = await run(page, options);
|
|
199
|
+
const hits = (answer?.hits ?? []);
|
|
200
|
+
for (const hit of hits) {
|
|
201
|
+
yield hit;
|
|
202
|
+
}
|
|
203
|
+
const last = hits[hits.length - 1];
|
|
204
|
+
if (hits.length < limit || last === undefined || last.sortValues === undefined) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
page = builder.after(last.sortValues);
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* A `sql.query` answer's rows as one object per row, keyed by column name
|
|
214
|
+
* (SDKP §2.5). A value is its JSON: a 64-bit integer past 2^53 is the decimal
|
|
215
|
+
* string the server sent, so it stays exact. A column name that repeats keeps
|
|
216
|
+
* its last value, as a JavaScript object must.
|
|
217
|
+
*/
|
|
218
|
+
export function sqlRecords(answer) {
|
|
219
|
+
const names = answer.columns.map((column) => column.name);
|
|
220
|
+
return answer.rows.map((row) => {
|
|
221
|
+
const values = toJson(ListValueSchema, row);
|
|
222
|
+
const record = {};
|
|
223
|
+
names.forEach((name, at) => {
|
|
224
|
+
record[name] = values[at] ?? null;
|
|
225
|
+
});
|
|
226
|
+
return record;
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
/** The SQL conveniences over `sql.query`. */
|
|
230
|
+
export function sqlCalls(sql) {
|
|
231
|
+
return {
|
|
232
|
+
/**
|
|
233
|
+
* One read-only statement with its `$1…$n` values, answered as records
|
|
234
|
+
* (`sqlRecords`). The values are JSON scalars; the server casts each to the
|
|
235
|
+
* type its placeholder was inferred as.
|
|
236
|
+
*/
|
|
237
|
+
async records(statement, params = [], options = {}) {
|
|
238
|
+
const { namespace, maxRows, ...call } = options;
|
|
239
|
+
const answer = (await sql.query({
|
|
240
|
+
sql: statement,
|
|
241
|
+
params: params.map((value) => fromJson(ValueSchema, value)),
|
|
242
|
+
...(namespace !== undefined ? { namespace } : {}),
|
|
243
|
+
...(maxRows !== undefined ? { maxRows } : {}),
|
|
244
|
+
}, call));
|
|
245
|
+
return sqlRecords(answer);
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/** The stream conveniences over `streams.fetch`. */
|
|
250
|
+
export function streamCalls(streams) {
|
|
251
|
+
return {
|
|
252
|
+
/** Every record from `fromOffset` on, as an async iterable (R14). */
|
|
253
|
+
subscribe(request, options = {}) {
|
|
254
|
+
return subscribe((fetchRequest, loopOptions) => streams.fetch(fetchRequest, { ...options, signal: loopOptions?.signal }), request, { signal: options.signal });
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Adds the hand-written calls to the generated module objects, in place.
|
|
260
|
+
*
|
|
261
|
+
* Every paged call gets `<call>All`; `documents`, `search` and `streams` get
|
|
262
|
+
* their conveniences when the facade generated them. A name the facade
|
|
263
|
+
* already uses is never overwritten.
|
|
264
|
+
*/
|
|
265
|
+
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
266
|
+
export function attachAppCalls(modules, bindings) {
|
|
267
|
+
const add = (module, calls) => {
|
|
268
|
+
// Module names come from the generated bindings, but an own-property
|
|
269
|
+
// check keeps a name such as `__proto__` from reaching Object.prototype.
|
|
270
|
+
if (!Object.hasOwn(modules, module) || UNSAFE_KEYS.has(module)) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const target = modules[module];
|
|
274
|
+
if (target === undefined) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
for (const [name, fn] of Object.entries(calls)) {
|
|
278
|
+
if (!UNSAFE_KEYS.has(name) && !(name in target)) {
|
|
279
|
+
target[name] = fn;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
for (const module of bindings) {
|
|
284
|
+
const target = modules[module.name];
|
|
285
|
+
if (target === undefined) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
for (const binding of module.calls) {
|
|
289
|
+
if (binding.pagination === null || binding.streaming !== 'unary') {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const call = target[binding.name];
|
|
293
|
+
if (typeof call !== 'function') {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
add(module.name, {
|
|
297
|
+
[`${binding.name}All`]: (request = {}, options) => paginate(binding, call, request, options),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const documents = modules.documents;
|
|
302
|
+
if (typeof documents?.write === 'function') {
|
|
303
|
+
add('documents', documentCalls(documents));
|
|
304
|
+
}
|
|
305
|
+
const search = modules.search;
|
|
306
|
+
if (typeof search?.query === 'function') {
|
|
307
|
+
add('search', searchCalls(search));
|
|
308
|
+
}
|
|
309
|
+
const sql = modules.sql;
|
|
310
|
+
if (typeof sql?.query === 'function') {
|
|
311
|
+
add('sql', sqlCalls(sql));
|
|
312
|
+
}
|
|
313
|
+
const streams = modules.streams;
|
|
314
|
+
if (typeof streams?.fetch === 'function') {
|
|
315
|
+
add('streams', streamCalls(streams));
|
|
316
|
+
}
|
|
317
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type JsonObject, type JsonValue } from '@bufbuild/protobuf';
|
|
2
|
+
import { type Query } from '../vendor/proto/gen/loams/collection/v1/query_pb.js';
|
|
3
|
+
/** One filter, as the proto3 JSON of a `loams.collection.v1.Query`. */
|
|
4
|
+
export type FilterJson = JsonObject;
|
|
5
|
+
/** A document id, as a filter or a document call takes it. A `number` must be
|
|
6
|
+
* a safe integer; pass a `bigint` above 2^53. */
|
|
7
|
+
export type DocId = number | bigint | string | {
|
|
8
|
+
uuid: string;
|
|
9
|
+
} | {
|
|
10
|
+
uint: string | number | bigint;
|
|
11
|
+
};
|
|
12
|
+
/** `{"auto": {}}` or an exact number of edits, 0 to 2. */
|
|
13
|
+
export type Fuzziness = 'auto' | 0 | 1 | 2;
|
|
14
|
+
/** A document id as the `Query.ids` `Value` spells it: `{"uint": "1"}`,
|
|
15
|
+
* `{"uuid": "…"}` or a bare string. */
|
|
16
|
+
export declare function idValue(id: DocId): JsonValue;
|
|
17
|
+
/** The bounds of a range: any of `gt`, `gte`, `lt`, `lte`. */
|
|
18
|
+
export interface Bounds {
|
|
19
|
+
gt?: JsonValue;
|
|
20
|
+
gte?: JsonValue;
|
|
21
|
+
lt?: JsonValue;
|
|
22
|
+
lte?: JsonValue;
|
|
23
|
+
}
|
|
24
|
+
/** The options of a `match`. */
|
|
25
|
+
export interface MatchOptions {
|
|
26
|
+
/** `'or'` (the default) or `'and'`. */
|
|
27
|
+
operator?: 'or' | 'and';
|
|
28
|
+
minimumShouldMatch?: string | number;
|
|
29
|
+
fuzziness?: Fuzziness;
|
|
30
|
+
analyzer?: string;
|
|
31
|
+
}
|
|
32
|
+
/** The options of a `query_string`. */
|
|
33
|
+
export interface QueryStringOptions {
|
|
34
|
+
fields?: string[];
|
|
35
|
+
operator?: 'or' | 'and';
|
|
36
|
+
}
|
|
37
|
+
/** The options of a `multi_match`. */
|
|
38
|
+
export interface MultiMatchOptions {
|
|
39
|
+
/** `best_fields` (the default), `most_fields`, `cross_fields`, `phrase`,
|
|
40
|
+
* `phrase_prefix`. */
|
|
41
|
+
kind?: 'best_fields' | 'most_fields' | 'cross_fields' | 'phrase' | 'phrase_prefix';
|
|
42
|
+
operator?: 'or' | 'and';
|
|
43
|
+
tieBreaker?: number;
|
|
44
|
+
}
|
|
45
|
+
/** The filter verbs. */
|
|
46
|
+
export declare const Filter: {
|
|
47
|
+
/** Every document. */
|
|
48
|
+
readonly all: () => FilterJson;
|
|
49
|
+
/** No document. */
|
|
50
|
+
readonly none: () => FilterJson;
|
|
51
|
+
/** The field equals `value` (a term query). */
|
|
52
|
+
readonly eq: (field: string, value: JsonValue) => FilterJson;
|
|
53
|
+
/** The field equals any of `values` (a terms query). */
|
|
54
|
+
readonly in: (field: string, values: JsonValue[]) => FilterJson;
|
|
55
|
+
/** The field is within the bounds; an omitted bound is open. */
|
|
56
|
+
readonly range: (field: string, bounds: Bounds) => FilterJson;
|
|
57
|
+
/** The field is present, whatever its value. */
|
|
58
|
+
readonly exists: (field: string) => FilterJson;
|
|
59
|
+
/** The field is `null` or absent. */
|
|
60
|
+
readonly isNull: (field: string) => FilterJson;
|
|
61
|
+
/** The field is an empty string, list or object. */
|
|
62
|
+
readonly isEmpty: (field: string) => FilterJson;
|
|
63
|
+
/** The field starts with `value`. */
|
|
64
|
+
readonly prefix: (field: string, value: string) => FilterJson;
|
|
65
|
+
/** The field matches a `*`/`?` pattern. */
|
|
66
|
+
readonly wildcard: (field: string, pattern: string) => FilterJson;
|
|
67
|
+
/** The field is within `fuzziness` edits of `value`. */
|
|
68
|
+
readonly fuzzy: (field: string, value: string, edits?: Fuzziness) => FilterJson;
|
|
69
|
+
/** A full-text match on one field. */
|
|
70
|
+
readonly match: (field: string, text: string, options?: MatchOptions) => FilterJson;
|
|
71
|
+
/** The words of `text`, in order, at most `slop` positions apart. */
|
|
72
|
+
readonly phrase: (field: string, text: string, slop?: number) => FilterJson;
|
|
73
|
+
/** A full-text match over several fields; `'title^2'` boosts one. */
|
|
74
|
+
readonly multiMatch: (fields: string[], text: string, options?: MultiMatchOptions) => FilterJson;
|
|
75
|
+
/** The query-string syntax, over `fields` or the collection's defaults. */
|
|
76
|
+
readonly queryString: (query: string, options?: QueryStringOptions) => FilterJson;
|
|
77
|
+
/** The documents with these ids, and no others. */
|
|
78
|
+
readonly ids: (...ids: DocId[]) => FilterJson;
|
|
79
|
+
/** Every query matches (a bool `must`). */
|
|
80
|
+
readonly and: (...queries: FilterJson[]) => FilterJson;
|
|
81
|
+
/** At least one of the queries matches (a bool `should`). */
|
|
82
|
+
readonly or: (...queries: FilterJson[]) => FilterJson;
|
|
83
|
+
/** At least `minimumShouldMatch` of the queries match: `or` with an explicit
|
|
84
|
+
* `minimum_should_match`. */
|
|
85
|
+
readonly anyOf: (queries: FilterJson[], minimumShouldMatch: number | string) => FilterJson;
|
|
86
|
+
/** None of the queries match (a bool `must_not`). */
|
|
87
|
+
readonly not: (...queries: FilterJson[]) => FilterJson;
|
|
88
|
+
/** Every query matches, without scoring (a bool `filter`). */
|
|
89
|
+
readonly filter: (...queries: FilterJson[]) => FilterJson;
|
|
90
|
+
/** Multiplies the query's score by `boost`. */
|
|
91
|
+
readonly boost: (query: FilterJson, boost: number) => FilterJson;
|
|
92
|
+
/** Every match of `query` scores `score`. */
|
|
93
|
+
readonly constantScore: (query: FilterJson, score: number) => FilterJson;
|
|
94
|
+
};
|
|
95
|
+
/** Decodes a filter into the generated `Query` message. */
|
|
96
|
+
export declare function toQuery(filter: FilterJson): Query;
|