wenmar-open 0.1.0 → 0.2.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/README.md +136 -0
- package/dist/batch.d.ts +5 -0
- package/dist/batch.js +7 -0
- package/dist/client.d.ts +1 -5
- package/dist/client.js +1 -6
- package/dist/errors.d.ts +12 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/offline/client.d.ts +70 -0
- package/dist/offline/client.js +245 -0
- package/dist/offline/engine.d.ts +53 -0
- package/dist/offline/engine.js +129 -0
- package/dist/offline/index.d.ts +10 -0
- package/dist/offline/index.js +4 -0
- package/dist/offline/node.d.ts +20 -0
- package/dist/offline/node.js +78 -0
- package/dist/offline/store.d.ts +72 -0
- package/dist/offline/store.js +156 -0
- package/dist/offline/wasm-inline.js +2 -0
- package/dist/offline/wenmar_open.wasm +0 -0
- package/dist/schema.d.ts +1 -1
- package/package.json +23 -4
package/README.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
A typed client for the [Wenmar Open](https://open.wenmarpro.com) API: VIN decoding and a year, make, model, trim and engine catalog for auto repair shops. The API needs no key and no account.
|
|
4
4
|
|
|
5
5
|
- No dependencies. It uses the platform's `fetch`.
|
|
6
|
+
- An [offline mode](#offline) that decodes from a data file with no network.
|
|
6
7
|
- Runs in Node 20 and later, browsers, Cloudflare Workers, Deno and Bun.
|
|
7
8
|
- Types are generated from the API's OpenAPI description.
|
|
8
9
|
|
|
@@ -175,6 +176,141 @@ Answers change only when the data does, about once a month. `open.meta()` return
|
|
|
175
176
|
|
|
176
177
|
One address may make 600 requests a minute. A server that calls the API for many users shares one address, so cache catalog answers there. To decode a list of VINs, use `decodeVins`: a batch of 50 counts as one request.
|
|
177
178
|
|
|
179
|
+
## Offline
|
|
180
|
+
|
|
181
|
+
`wenmar-open/offline` answers the same questions with no network. It runs the same decoder the API runs, compiled to WebAssembly and shipped inside this package, over a Wenmar Open data file: a plain SQLite database of about 167 MB. The methods, their answers and the error codes are those of `WenmarOpen`, with three more codes for the data ([below](#switching-between-the-api-and-the-data-file)), so code written for one works with the other.
|
|
182
|
+
|
|
183
|
+
The hosted client does not load any of this. An application that imports only `wenmar-open` gets the small client and nothing else.
|
|
184
|
+
|
|
185
|
+
### In Node
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
npm install wenmar-open wenmar-open-data
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`wenmar-open-data` is the data file as an npm package. It is 49 MB to download and 167 MB on disk, and a new version is published each month.
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
import { WenmarOpenError } from "wenmar-open/offline";
|
|
195
|
+
import { openOffline } from "wenmar-open/offline/node";
|
|
196
|
+
|
|
197
|
+
const open = await openOffline();
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const vehicle = await open.decodeVin("KM8K2CAB4PU001140");
|
|
201
|
+
console.log(vehicle.year, vehicle.make, vehicle.model);
|
|
202
|
+
console.log(await open.models({ make: "honda", year: 2019 }));
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (error instanceof WenmarOpenError) console.error(error.code, error.message);
|
|
205
|
+
else throw error;
|
|
206
|
+
} finally {
|
|
207
|
+
open.close();
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`openOffline()` uses Node's own SQLite, so it needs Node 22.16 or later. It opens the file read-only and checks it before it returns. To read a file from somewhere else, such as one downloaded with `wenmar-open data pull`, pass its path:
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
import { openOffline } from "wenmar-open/offline/node";
|
|
215
|
+
|
|
216
|
+
const open = await openOffline({ path: "/var/lib/wenmar-open/wenmar-open-2026.09.sqlite3" });
|
|
217
|
+
console.log((await open.meta()).data_version);
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
On Node 20, or to use a database you opened yourself, give the client a store. `syncStore` takes a `DatabaseSync` of `node:sqlite` or a database of `better-sqlite3`:
|
|
221
|
+
|
|
222
|
+
```js
|
|
223
|
+
import Database from "better-sqlite3";
|
|
224
|
+
import { path } from "wenmar-open-data";
|
|
225
|
+
import { WenmarOpenOffline, syncStore } from "wenmar-open/offline";
|
|
226
|
+
|
|
227
|
+
const database = new Database(path, { readonly: true });
|
|
228
|
+
const open = new WenmarOpenOffline({ store: syncStore(database) });
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Switching between the API and the data file
|
|
232
|
+
|
|
233
|
+
Both clients have the same methods, so the choice can be one line:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
import { WenmarOpen } from "wenmar-open";
|
|
237
|
+
import type { VinDecode } from "wenmar-open";
|
|
238
|
+
import type { WenmarOpenOffline } from "wenmar-open/offline";
|
|
239
|
+
import { openOffline } from "wenmar-open/offline/node";
|
|
240
|
+
|
|
241
|
+
async function client(offline: boolean): Promise<WenmarOpen | WenmarOpenOffline> {
|
|
242
|
+
return offline ? openOffline() : new WenmarOpen();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const open = await client(true);
|
|
246
|
+
const vehicle: VinDecode = await open.decodeVin("KM8K2CAB4PU001140");
|
|
247
|
+
console.log(vehicle.catalog?.vehicle_id);
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
What differs offline:
|
|
251
|
+
|
|
252
|
+
- `error.status` is always `undefined`: there is no HTTP answer. Branch on `error.code`.
|
|
253
|
+
- Three more codes: `no_data` (no data file where one was looked for), `data_invalid` (the database is not a data file this version reads) and `store_error` (the database failed while it was read, including while it was first opened, so a retry may succeed; `data_invalid` is for rows that were read and are not a data file, and for a schema version this client does not read).
|
|
254
|
+
- `search` reads the text as a year, a make, a model and a submodel, in that order. The API also finds a model from its words in any order, with an index the data file does not have. `search({ q: "civic type r" })` finds the Civic Type R in both; `search({ q: "type r" })` finds it only through the API.
|
|
255
|
+
- `meta().server_version` is the version of this package.
|
|
256
|
+
- `timeoutMs` and `signal` are checked each time the client goes back to the database, not while the database is working. There is no time limit unless you set one.
|
|
257
|
+
- The current year, which bounds the model years a VIN can have, comes from the device's clock. Pass `currentYear` to set it.
|
|
258
|
+
|
|
259
|
+
### In Cloudflare Workers
|
|
260
|
+
|
|
261
|
+
A Worker cannot carry a 167 MB file. Import the data into a D1 database once (see the [`wenmar-open-data` README](https://www.npmjs.com/package/wenmar-open-data)), or use the hosted API.
|
|
262
|
+
|
|
263
|
+
```js
|
|
264
|
+
import { WenmarOpenOffline, d1Store } from "wenmar-open/offline";
|
|
265
|
+
import wasm from "wenmar-open/offline.wasm";
|
|
266
|
+
|
|
267
|
+
let open;
|
|
268
|
+
|
|
269
|
+
export default {
|
|
270
|
+
async fetch(request, env) {
|
|
271
|
+
open ??= new WenmarOpenOffline({ store: d1Store(env.DB), wasm });
|
|
272
|
+
const vin = new URL(request.url).pathname.slice(1);
|
|
273
|
+
return Response.json(await open.decodeVin(vin));
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Workers do not compile WebAssembly from bytes, so the module is imported as a file and passed as `wasm`. Everywhere else the option is left out.
|
|
279
|
+
|
|
280
|
+
A decode reads D1 6 or 7 times and runs 10 to 15 statements, each of which counts as one D1 query. The first question an isolate answers also reads about 12,000 rows of makes, once. `years()` reads every model-year row of the catalog, about 490,000, on every call: keep its answer.
|
|
281
|
+
|
|
282
|
+
### A store of your own
|
|
283
|
+
|
|
284
|
+
Anything that can run a `SELECT` on the data file can be a store: an object with one method, which is given a list of statements and returns the rows of each, in order, as arrays of values. It may return them at once or as a promise.
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
import { WenmarOpenOffline } from "wenmar-open/offline";
|
|
288
|
+
import type { Statement, Store } from "wenmar-open/offline";
|
|
289
|
+
|
|
290
|
+
declare function run(sql: string, params: readonly (string | number | null)[]): Promise<unknown[][]>;
|
|
291
|
+
|
|
292
|
+
const store: Store = {
|
|
293
|
+
query: (statements: readonly Statement[]) => Promise.all(statements.map(({ sql, params }) => run(sql, params))),
|
|
294
|
+
};
|
|
295
|
+
export const open = new WenmarOpenOffline({ store });
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Rows must be arrays, not objects keyed by column name: two columns of one statement may share a name. Integers may be numbers or BigInts.
|
|
299
|
+
|
|
300
|
+
### Data versions
|
|
301
|
+
|
|
302
|
+
A version of `wenmar-open` reads data files of one schema version, and `wenmar-open-data`'s major version is that schema version: `wenmar-open-data@3.202609.0` is the data of September 2026 in schema version 3. A file of another schema version is refused with `data_invalid` and a message naming both versions.
|
|
303
|
+
|
|
304
|
+
| `wenmar-open` | reads `wenmar-open-data` |
|
|
305
|
+
|---|---|
|
|
306
|
+
| 0.2 | 3.x |
|
|
307
|
+
|
|
308
|
+
### Size
|
|
309
|
+
|
|
310
|
+
The decoder is 450 kB of WebAssembly (163 kB compressed). The package holds it twice, as a file and as text inside a module, so the package is 1.1 MB unpacked. None of it is loaded by `import "wenmar-open"`.
|
|
311
|
+
|
|
312
|
+
In a browser the page's content security policy must allow WebAssembly (`'wasm-unsafe-eval'`). The data file is too large to send to a browser; use the hosted API there.
|
|
313
|
+
|
|
178
314
|
## CommonJS
|
|
179
315
|
|
|
180
316
|
The package is an ES module. `require("wenmar-open")` works in Node 20.19 and later and in Node 22.12 and later. In older Node 20 releases use `await import("wenmar-open")`.
|
package/dist/batch.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { BatchItem, ErrorBody } from "./types.js";
|
|
2
|
+
/** The most VINs decoded in one batch. */
|
|
3
|
+
export declare const MAX_BATCH = 50;
|
|
4
|
+
/** Whether an item of a batch is the error for its VIN and not a decode. */
|
|
5
|
+
export declare function isBatchError(item: BatchItem): item is ErrorBody;
|
package/dist/batch.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** The most VINs decoded in one batch. */
|
|
2
|
+
export const MAX_BATCH = 50;
|
|
3
|
+
/** Whether an item of a batch is the error for its VIN and not a decode. */
|
|
4
|
+
export function isBatchError(item) {
|
|
5
|
+
const error = item.error;
|
|
6
|
+
return typeof error === "object" && error !== null && !Array.isArray(error);
|
|
7
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import type { BatchItem, EngineOption, EnginesQuery, Entry,
|
|
1
|
+
import type { BatchItem, EngineOption, EnginesQuery, Entry, Make, MakesQuery, Meta, Model, ModelsQuery, SearchQuery, Submodel, SubmodelsQuery, VinDecode, YearsQuery } from "./types.js";
|
|
2
2
|
/** The hosted API. */
|
|
3
3
|
export declare const DEFAULT_BASE_URL = "https://open.wenmarpro.com";
|
|
4
4
|
/** How long a request may take before it fails with `timeout`. */
|
|
5
5
|
export declare const DEFAULT_TIMEOUT_MS = 10000;
|
|
6
|
-
/** The most VINs the API decodes in one batch. */
|
|
7
|
-
export declare const MAX_BATCH = 50;
|
|
8
6
|
/** What the client asks of a `fetch`: the part of the standard it uses. */
|
|
9
7
|
export interface FetchInit {
|
|
10
8
|
method: string;
|
|
@@ -47,8 +45,6 @@ export interface DecodeOptions extends RequestOptions {
|
|
|
47
45
|
/** Use this model year instead of working it out from the VIN. */
|
|
48
46
|
year?: number;
|
|
49
47
|
}
|
|
50
|
-
/** Whether an item of a batch is the error for its VIN and not a decode. */
|
|
51
|
-
export declare function isBatchError(item: BatchItem): item is ErrorBody;
|
|
52
48
|
/** A client for the Wenmar Open API. It keeps no state between requests. */
|
|
53
49
|
export declare class WenmarOpen {
|
|
54
50
|
#private;
|
package/dist/client.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
+
import { MAX_BATCH } from "./batch.js";
|
|
1
2
|
import { WenmarOpenError, parseRetryAfter } from "./errors.js";
|
|
2
3
|
/** The hosted API. */
|
|
3
4
|
export const DEFAULT_BASE_URL = "https://open.wenmarpro.com";
|
|
4
5
|
/** How long a request may take before it fails with `timeout`. */
|
|
5
6
|
export const DEFAULT_TIMEOUT_MS = 10_000;
|
|
6
|
-
/** The most VINs the API decodes in one batch. */
|
|
7
|
-
export const MAX_BATCH = 50;
|
|
8
7
|
function queryString(query) {
|
|
9
8
|
const params = new URLSearchParams();
|
|
10
9
|
for (const [name, value] of Object.entries((query ?? {}))) {
|
|
@@ -34,10 +33,6 @@ function segment(field, value) {
|
|
|
34
33
|
function isRecord(value) {
|
|
35
34
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
36
35
|
}
|
|
37
|
-
/** Whether an item of a batch is the error for its VIN and not a decode. */
|
|
38
|
-
export function isBatchError(item) {
|
|
39
|
-
return isRecord(item.error);
|
|
40
|
-
}
|
|
41
36
|
/** A client for the Wenmar Open API. It keeps no state between requests. */
|
|
42
37
|
export class WenmarOpen {
|
|
43
38
|
baseUrl;
|
package/dist/errors.d.ts
CHANGED
|
@@ -11,11 +11,22 @@ export type ApiErrorCode = "invalid_vin" | "validation_failed" | "not_found" | "
|
|
|
11
11
|
* error page and a sign-in page from a captive portal both end up here.
|
|
12
12
|
*/
|
|
13
13
|
export type ClientErrorCode = "network" | "timeout" | "aborted" | "bad_response";
|
|
14
|
+
/**
|
|
15
|
+
* The codes of `wenmar-open/offline`, for failures of the data and not of
|
|
16
|
+
* the question. The command-line tool uses `no_data` and `data_invalid` for
|
|
17
|
+
* the same things.
|
|
18
|
+
*
|
|
19
|
+
* - `no_data`: there is no data file where one was looked for.
|
|
20
|
+
* - `data_invalid`: the database is not a data file this version reads: it
|
|
21
|
+
* has another schema version, or a row is not what the layout says.
|
|
22
|
+
* - `store_error`: the database failed while it was being read.
|
|
23
|
+
*/
|
|
24
|
+
export type OfflineErrorCode = "no_data" | "data_invalid" | "store_error";
|
|
14
25
|
/**
|
|
15
26
|
* Every code known when this version was published. The API may add codes,
|
|
16
27
|
* so `code` is typed to accept any string while still completing these.
|
|
17
28
|
*/
|
|
18
|
-
export type ErrorCode = ApiErrorCode | ClientErrorCode | (string & {});
|
|
29
|
+
export type ErrorCode = ApiErrorCode | ClientErrorCode | OfflineErrorCode | (string & {});
|
|
19
30
|
export interface WenmarOpenErrorInit {
|
|
20
31
|
code: ErrorCode;
|
|
21
32
|
message: string;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { MAX_BATCH, isBatchError } from "./batch.js";
|
|
2
|
+
export { DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS, WenmarOpen } from "./client.js";
|
|
2
3
|
export type { ClientOptions, DecodeOptions, FetchInit, FetchLike, FetchResponse, RequestOptions, } from "./client.js";
|
|
3
4
|
export { WenmarOpenError } from "./errors.js";
|
|
4
|
-
export type { ApiErrorCode, ClientErrorCode, ErrorCode, WenmarOpenErrorInit } from "./errors.js";
|
|
5
|
+
export type { ApiErrorCode, ClientErrorCode, ErrorCode, OfflineErrorCode, WenmarOpenErrorInit, } from "./errors.js";
|
|
5
6
|
export type { BatchItem, CheckDigit, Engine, EngineOption, EnginesQuery, Entry, ErrorBody, Make, MakesQuery, Manufacturer, Meta, Model, ModelsQuery, Plant, Safety, SearchQuery, Selection, Submodel, SubmodelsQuery, VinDecode, Warning, YearsQuery, } from "./types.js";
|
|
6
7
|
export type { components, operations, paths } from "./schema.js";
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { MAX_BATCH, isBatchError } from "./batch.js";
|
|
2
|
+
export { DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS, WenmarOpen } from "./client.js";
|
|
2
3
|
export { WenmarOpenError } from "./errors.js";
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { DecodeOptions, RequestOptions } from "../client.js";
|
|
2
|
+
import type { BatchItem, EngineOption, EnginesQuery, Entry, Make, MakesQuery, Meta, Model, ModelsQuery, SearchQuery, Submodel, SubmodelsQuery, VinDecode, YearsQuery } from "../types.js";
|
|
3
|
+
import type { WasmSource } from "./engine.js";
|
|
4
|
+
import type { Store } from "./store.js";
|
|
5
|
+
/**
|
|
6
|
+
* The most times one question, or the opening of the data, may go back to
|
|
7
|
+
* the store. Opening takes 2; over the fixture of the tests a decode takes
|
|
8
|
+
* at most 7 and a search at most 4. Reaching this means something is wrong.
|
|
9
|
+
*/
|
|
10
|
+
export declare const MAX_STEPS = 32;
|
|
11
|
+
export interface OfflineOptions {
|
|
12
|
+
/** Where the data is: `syncStore(...)`, `d1Store(...)`, or your own. */
|
|
13
|
+
store: Store;
|
|
14
|
+
/**
|
|
15
|
+
* The decoder. Leave it out everywhere but Cloudflare Workers, where it
|
|
16
|
+
* is the module `import wasm from "wenmar-open/offline.wasm"` gives.
|
|
17
|
+
*/
|
|
18
|
+
wasm?: WasmSource;
|
|
19
|
+
/**
|
|
20
|
+
* The current calendar year, which bounds the model years a VIN can
|
|
21
|
+
* have. Default: the year of the device's clock, in UTC.
|
|
22
|
+
*/
|
|
23
|
+
currentYear?: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* VIN decoding and the vehicle catalog with no network: the methods of
|
|
27
|
+
* `WenmarOpen`, answered from a data file by the decoder inside the package.
|
|
28
|
+
* Every method gives what the hosted API gives for the same question and
|
|
29
|
+
* fails with the same `WenmarOpenError` codes.
|
|
30
|
+
*/
|
|
31
|
+
export declare class WenmarOpenOffline {
|
|
32
|
+
#private;
|
|
33
|
+
constructor(options: OfflineOptions);
|
|
34
|
+
/** Decode one VIN. Spaces, dashes and lowercase are accepted. */
|
|
35
|
+
decodeVin(vin: string, options?: DecodeOptions): Promise<VinDecode>;
|
|
36
|
+
/**
|
|
37
|
+
* Decode up to 50 VINs. The answer has one item per VIN, in order: a
|
|
38
|
+
* decode, or the error a single call for that VIN would have thrown.
|
|
39
|
+
* Tell them apart with `isBatchError`.
|
|
40
|
+
*/
|
|
41
|
+
decodeVins(vins: readonly string[], options?: RequestOptions): Promise<BatchItem[]>;
|
|
42
|
+
/** Model years, newest first. */
|
|
43
|
+
years(query?: YearsQuery, options?: RequestOptions): Promise<number[]>;
|
|
44
|
+
/** Makes, popular ones first and the rest by name. */
|
|
45
|
+
makes(query?: MakesQuery, options?: RequestOptions): Promise<Make[]>;
|
|
46
|
+
/** Models of a make, by name. An unknown make has no models. */
|
|
47
|
+
models(query: ModelsQuery, options?: RequestOptions): Promise<Model[]>;
|
|
48
|
+
/** Submodels of a model year: trims, or series where there are no trims. */
|
|
49
|
+
submodels(query: SubmodelsQuery, options?: RequestOptions): Promise<Submodel[]>;
|
|
50
|
+
/** The same as `submodels`, under the name most people use. */
|
|
51
|
+
trims(query: SubmodelsQuery, options?: RequestOptions): Promise<Submodel[]>;
|
|
52
|
+
/** Engines of a model year, in short form. */
|
|
53
|
+
engines(query: EnginesQuery, options?: RequestOptions): Promise<EngineOption[]>;
|
|
54
|
+
/**
|
|
55
|
+
* Catalog entries for free text such as `2019 civic si`, best first. The
|
|
56
|
+
* hosted API also finds words in any order with an index of its own;
|
|
57
|
+
* here, text the catalog cannot read as year, make, model and submodel
|
|
58
|
+
* finds nothing.
|
|
59
|
+
*/
|
|
60
|
+
search(query: SearchQuery, options?: RequestOptions): Promise<Entry[]>;
|
|
61
|
+
/** One catalog entry by its id, such as `2019_honda_civic_si`. */
|
|
62
|
+
vehicle(id: string, options?: RequestOptions): Promise<Entry>;
|
|
63
|
+
/**
|
|
64
|
+
* The data version, the vPIC release it came from, and the build time.
|
|
65
|
+
* `server_version` is the version of this package.
|
|
66
|
+
*/
|
|
67
|
+
meta(options?: RequestOptions): Promise<Meta>;
|
|
68
|
+
/** Closes the store, if the store was opened for this client. */
|
|
69
|
+
close(): void;
|
|
70
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { MAX_BATCH } from "../batch.js";
|
|
2
|
+
import { WenmarOpenError } from "../errors.js";
|
|
3
|
+
import { Engine, compile } from "./engine.js";
|
|
4
|
+
import { cleanRows } from "./store.js";
|
|
5
|
+
/**
|
|
6
|
+
* The most times one question, or the opening of the data, may go back to
|
|
7
|
+
* the store. Opening takes 2; over the fixture of the tests a decode takes
|
|
8
|
+
* at most 7 and a search at most 4. Reaching this means something is wrong.
|
|
9
|
+
*/
|
|
10
|
+
export const MAX_STEPS = 32;
|
|
11
|
+
const keyOf = (statement) => `${statement.sql}\u0000${JSON.stringify(statement.params)}`;
|
|
12
|
+
function thrown(failure) {
|
|
13
|
+
return new WenmarOpenError({ code: failure.code, message: failure.message, details: failure.details });
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The rows of every statement, from the store. A store that throws, and one
|
|
17
|
+
* that does not return one list for each statement, are `store_error`: the
|
|
18
|
+
* same whether the data is being opened or a question answered.
|
|
19
|
+
*/
|
|
20
|
+
async function read(store, statements) {
|
|
21
|
+
let rows;
|
|
22
|
+
try {
|
|
23
|
+
rows = await store.query(statements);
|
|
24
|
+
}
|
|
25
|
+
catch (cause) {
|
|
26
|
+
if (cause instanceof WenmarOpenError)
|
|
27
|
+
throw cause;
|
|
28
|
+
throw new WenmarOpenError({
|
|
29
|
+
code: "store_error",
|
|
30
|
+
message: "The database failed while it was being read.",
|
|
31
|
+
cause,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
if (!Array.isArray(rows) || rows.length !== statements.length) {
|
|
35
|
+
throw new WenmarOpenError({
|
|
36
|
+
code: "store_error",
|
|
37
|
+
message: "The store must return one list of rows for each statement.",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return rows;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* VIN decoding and the vehicle catalog with no network: the methods of
|
|
44
|
+
* `WenmarOpen`, answered from a data file by the decoder inside the package.
|
|
45
|
+
* Every method gives what the hosted API gives for the same question and
|
|
46
|
+
* fails with the same `WenmarOpenError` codes.
|
|
47
|
+
*/
|
|
48
|
+
export class WenmarOpenOffline {
|
|
49
|
+
#store;
|
|
50
|
+
#wasm;
|
|
51
|
+
#currentYear;
|
|
52
|
+
#module;
|
|
53
|
+
#engine;
|
|
54
|
+
constructor(options) {
|
|
55
|
+
this.#store = options.store;
|
|
56
|
+
this.#wasm = options.wasm;
|
|
57
|
+
this.#currentYear = options.currentYear;
|
|
58
|
+
}
|
|
59
|
+
/** Decode one VIN. Spaces, dashes and lowercase are accepted. */
|
|
60
|
+
async decodeVin(vin, options = {}) {
|
|
61
|
+
return this.#one({ op: "decode", args: { vin: String(vin), year: options.year } }, options);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Decode up to 50 VINs. The answer has one item per VIN, in order: a
|
|
65
|
+
* decode, or the error a single call for that VIN would have thrown.
|
|
66
|
+
* Tell them apart with `isBatchError`.
|
|
67
|
+
*/
|
|
68
|
+
async decodeVins(vins, options = {}) {
|
|
69
|
+
if (vins.length > MAX_BATCH) {
|
|
70
|
+
throw new WenmarOpenError({
|
|
71
|
+
code: "validation_failed",
|
|
72
|
+
message: `a batch holds at most ${MAX_BATCH} VINs`,
|
|
73
|
+
details: { field: "vins", max: MAX_BATCH, received: vins.length },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
const questions = vins.map((vin) => ({ op: "decode", args: { vin: String(vin) } }));
|
|
77
|
+
const outcomes = await this.#ask(questions, options);
|
|
78
|
+
return outcomes.map((outcome) => ("ok" in outcome ? outcome.ok : { error: outcome.error }));
|
|
79
|
+
}
|
|
80
|
+
/** Model years, newest first. */
|
|
81
|
+
async years(query = {}, options = {}) {
|
|
82
|
+
return this.#one({ op: "years", args: query }, options);
|
|
83
|
+
}
|
|
84
|
+
/** Makes, popular ones first and the rest by name. */
|
|
85
|
+
async makes(query = {}, options = {}) {
|
|
86
|
+
return this.#one({ op: "makes", args: query }, options);
|
|
87
|
+
}
|
|
88
|
+
/** Models of a make, by name. An unknown make has no models. */
|
|
89
|
+
async models(query, options = {}) {
|
|
90
|
+
return this.#one({ op: "models", args: query }, options);
|
|
91
|
+
}
|
|
92
|
+
/** Submodels of a model year: trims, or series where there are no trims. */
|
|
93
|
+
async submodels(query, options = {}) {
|
|
94
|
+
return this.#one({ op: "submodels", args: query }, options);
|
|
95
|
+
}
|
|
96
|
+
/** The same as `submodels`, under the name most people use. */
|
|
97
|
+
async trims(query, options = {}) {
|
|
98
|
+
return this.#one({ op: "submodels", args: query }, options);
|
|
99
|
+
}
|
|
100
|
+
/** Engines of a model year, in short form. */
|
|
101
|
+
async engines(query, options = {}) {
|
|
102
|
+
return this.#one({ op: "engines", args: query }, options);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Catalog entries for free text such as `2019 civic si`, best first. The
|
|
106
|
+
* hosted API also finds words in any order with an index of its own;
|
|
107
|
+
* here, text the catalog cannot read as year, make, model and submodel
|
|
108
|
+
* finds nothing.
|
|
109
|
+
*/
|
|
110
|
+
async search(query, options = {}) {
|
|
111
|
+
return this.#one({ op: "search", args: query }, options);
|
|
112
|
+
}
|
|
113
|
+
/** One catalog entry by its id, such as `2019_honda_civic_si`. */
|
|
114
|
+
async vehicle(id, options = {}) {
|
|
115
|
+
return this.#one({ op: "vehicle", args: { id: String(id) } }, options);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The data version, the vPIC release it came from, and the build time.
|
|
119
|
+
* `server_version` is the version of this package.
|
|
120
|
+
*/
|
|
121
|
+
async meta(options = {}) {
|
|
122
|
+
return this.#one({ op: "meta", args: {} }, options);
|
|
123
|
+
}
|
|
124
|
+
/** Closes the store, if the store was opened for this client. */
|
|
125
|
+
close() {
|
|
126
|
+
this.#store.close?.();
|
|
127
|
+
}
|
|
128
|
+
async #one(question, options) {
|
|
129
|
+
const [outcome] = await this.#ask([question], options);
|
|
130
|
+
if (outcome === undefined) {
|
|
131
|
+
throw new WenmarOpenError({ code: "internal_error", message: "The decoder gave no answer." });
|
|
132
|
+
}
|
|
133
|
+
if ("error" in outcome)
|
|
134
|
+
throw thrown(outcome.error);
|
|
135
|
+
return outcome.ok;
|
|
136
|
+
}
|
|
137
|
+
/** The decoder, started and with the data opened. Started again after a stop. */
|
|
138
|
+
async #ready() {
|
|
139
|
+
const current = this.#engine;
|
|
140
|
+
if (current !== undefined) {
|
|
141
|
+
const engine = await current;
|
|
142
|
+
if (!engine.stopped)
|
|
143
|
+
return engine;
|
|
144
|
+
if (this.#engine === current)
|
|
145
|
+
this.#engine = undefined;
|
|
146
|
+
}
|
|
147
|
+
this.#engine ??= this.#start();
|
|
148
|
+
try {
|
|
149
|
+
return await this.#engine;
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
this.#engine = undefined;
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async #start() {
|
|
157
|
+
this.#module ??= compile(this.#wasm);
|
|
158
|
+
let module;
|
|
159
|
+
try {
|
|
160
|
+
module = await this.#module;
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
this.#module = undefined;
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
const engine = await Engine.start(module);
|
|
167
|
+
const answers = [];
|
|
168
|
+
for (let step = 0; step < MAX_STEPS; step += 1) {
|
|
169
|
+
const answer = engine.call({ op: "open", answers });
|
|
170
|
+
if ("ok" in answer)
|
|
171
|
+
return engine;
|
|
172
|
+
if ("error" in answer)
|
|
173
|
+
throw thrown(answer.error);
|
|
174
|
+
const rows = await read(this.#store, answer.need);
|
|
175
|
+
answer.need.forEach((statement, index) => {
|
|
176
|
+
answers.push({ ...statement, rows: cleanRows(rows[index], statement.sql) });
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
throw new WenmarOpenError({ code: "internal_error", message: "Opening the data did not finish." });
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Answers every question, going back to the store as often as the
|
|
183
|
+
* decoder asks. What several questions need in the same step is read
|
|
184
|
+
* together, each statement once.
|
|
185
|
+
*/
|
|
186
|
+
async #ask(questions, options) {
|
|
187
|
+
const signal = options.signal;
|
|
188
|
+
const timeoutMs = options.timeoutMs ?? 0;
|
|
189
|
+
const started = Date.now();
|
|
190
|
+
const currentYear = this.#currentYear ?? new Date().getUTCFullYear();
|
|
191
|
+
const answers = questions.map(() => []);
|
|
192
|
+
const outcomes = questions.map(() => undefined);
|
|
193
|
+
for (let step = 0;; step += 1) {
|
|
194
|
+
if (signal?.aborted) {
|
|
195
|
+
throw new WenmarOpenError({ code: "aborted", message: "The request was aborted.", cause: signal.reason });
|
|
196
|
+
}
|
|
197
|
+
if (timeoutMs > 0 && Date.now() - started > timeoutMs) {
|
|
198
|
+
throw new WenmarOpenError({
|
|
199
|
+
code: "timeout",
|
|
200
|
+
message: `No answer within ${timeoutMs} ms.`,
|
|
201
|
+
details: { timeout_ms: timeoutMs },
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
const engine = await this.#ready();
|
|
205
|
+
const wanted = new Map();
|
|
206
|
+
const waiting = [];
|
|
207
|
+
questions.forEach((question, index) => {
|
|
208
|
+
if (outcomes[index] !== undefined)
|
|
209
|
+
return;
|
|
210
|
+
const answer = engine.call({
|
|
211
|
+
op: question.op,
|
|
212
|
+
args: question.args,
|
|
213
|
+
current_year: currentYear,
|
|
214
|
+
answers: answers[index] ?? [],
|
|
215
|
+
});
|
|
216
|
+
if ("need" in answer) {
|
|
217
|
+
waiting.push({ index, need: answer.need });
|
|
218
|
+
for (const statement of answer.need)
|
|
219
|
+
wanted.set(keyOf(statement), statement);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
outcomes[index] = answer;
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
if (waiting.length === 0)
|
|
226
|
+
return outcomes;
|
|
227
|
+
if (step >= MAX_STEPS) {
|
|
228
|
+
throw new WenmarOpenError({ code: "internal_error", message: "The decoder kept asking for more data." });
|
|
229
|
+
}
|
|
230
|
+
const statements = [...wanted.values()];
|
|
231
|
+
const rows = await read(this.#store, statements);
|
|
232
|
+
const found = new Map();
|
|
233
|
+
statements.forEach((statement, index) => {
|
|
234
|
+
found.set(keyOf(statement), { ...statement, rows: cleanRows(rows[index], statement.sql) });
|
|
235
|
+
});
|
|
236
|
+
for (const { index, need } of waiting) {
|
|
237
|
+
for (const statement of need) {
|
|
238
|
+
const answered = found.get(keyOf(statement));
|
|
239
|
+
if (answered !== undefined)
|
|
240
|
+
answers[index]?.push(answered);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Statement } from "./store.js";
|
|
2
|
+
/**
|
|
3
|
+
* A compiled `WebAssembly.Module`. It is written as `object` so that these
|
|
4
|
+
* declarations name nothing a project without the DOM library lacks.
|
|
5
|
+
*/
|
|
6
|
+
export type CompiledWasm = object;
|
|
7
|
+
/**
|
|
8
|
+
* The decoder: a compiled module, or its bytes. Cloudflare Workers do not
|
|
9
|
+
* compile bytes, so there it is the module `import wasm from
|
|
10
|
+
* "wenmar-open/offline.wasm"` gives.
|
|
11
|
+
*/
|
|
12
|
+
export type WasmSource = CompiledWasm | ArrayBuffer | ArrayBufferView;
|
|
13
|
+
/** A statement with the rows a store gave for it. */
|
|
14
|
+
export interface Answered extends Statement {
|
|
15
|
+
readonly rows: readonly (readonly (string | number | null)[])[];
|
|
16
|
+
}
|
|
17
|
+
export interface EngineRequest {
|
|
18
|
+
op: string;
|
|
19
|
+
args?: object;
|
|
20
|
+
current_year?: number;
|
|
21
|
+
answers?: readonly Answered[];
|
|
22
|
+
}
|
|
23
|
+
export type EngineAnswer = {
|
|
24
|
+
need: Statement[];
|
|
25
|
+
} | {
|
|
26
|
+
ok: unknown;
|
|
27
|
+
} | {
|
|
28
|
+
error: {
|
|
29
|
+
code: string;
|
|
30
|
+
message: string;
|
|
31
|
+
details: Record<string, unknown>;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Compiles the decoder. With no source it is the copy inside the package,
|
|
36
|
+
* which is read as text and never fetched, so nothing here needs a file
|
|
37
|
+
* system or a network.
|
|
38
|
+
*/
|
|
39
|
+
export declare function compile(source?: WasmSource): Promise<CompiledWasm>;
|
|
40
|
+
/** One running copy of the decoder. */
|
|
41
|
+
export declare class Engine {
|
|
42
|
+
#private;
|
|
43
|
+
private constructor();
|
|
44
|
+
static start(module: CompiledWasm): Promise<Engine>;
|
|
45
|
+
/** Whether a call stopped this copy. A stopped copy is never used again. */
|
|
46
|
+
get stopped(): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* One request, one answer. A request that cannot be written as JSON is
|
|
49
|
+
* refused before the decoder sees it. A stop inside the decoder is thrown
|
|
50
|
+
* as an error, and this copy is not used again.
|
|
51
|
+
*/
|
|
52
|
+
call(request: EngineRequest): EngineAnswer;
|
|
53
|
+
}
|