wenmar-open 0.1.0 → 0.3.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 +292 -2
- package/dist/batch.d.ts +5 -0
- package/dist/batch.js +7 -0
- package/dist/client.d.ts +153 -6
- package/dist/client.js +234 -7
- package/dist/errors.d.ts +21 -3
- package/dist/index.d.ts +5 -4
- package/dist/index.js +2 -1
- package/dist/offline/client.d.ts +190 -0
- package/dist/offline/client.js +482 -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 +26 -0
- package/dist/offline/node.js +105 -0
- package/dist/offline/store.d.ts +78 -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 +5152 -471
- package/dist/types.d.ts +124 -0
- package/package.json +30 -4
|
@@ -0,0 +1,482 @@
|
|
|
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.db ?? ""}\u0000${statement.sql}\u0000${JSON.stringify(statement.params)}`;
|
|
12
|
+
/**
|
|
13
|
+
* The operations that are not about recalls alone and still read the shop
|
|
14
|
+
* file when there is one. Without a shop store they answer all the same.
|
|
15
|
+
*/
|
|
16
|
+
const READS_SHOP = new Set(["meta", "overview_vehicle", "overview_vin", "model_years"]);
|
|
17
|
+
/** The operations about trouble codes. Each needs the shop file. */
|
|
18
|
+
const CODE_OPS = new Set(["code", "codes", "vehicle_code", "jobs", "job", "labor_vehicle", "labor_vin"]);
|
|
19
|
+
/** Whether a question reads the shop file, when there is one. */
|
|
20
|
+
const needsShop = (question) => READS_SHOP.has(question.op) || question.op.startsWith("recall") || question.op === "safety" || question.op === "complaints" || FACTS.test(question.op) || CODE_OPS.has(question.op);
|
|
21
|
+
/** The operations for fuel, specifications, fluids and tire fitments, such as `fuel_vin`. */
|
|
22
|
+
const FACTS = /^(fuel|specs|fluids|tires)_(vehicle|vin)$/;
|
|
23
|
+
function thrown(failure) {
|
|
24
|
+
return new WenmarOpenError({ code: failure.code, message: failure.message, details: failure.details });
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The rows of every statement, from the store. A store that throws, and one
|
|
28
|
+
* that does not return one list for each statement, are `store_error`: the
|
|
29
|
+
* same whether the data is being opened or a question answered.
|
|
30
|
+
*/
|
|
31
|
+
async function read(store, statements) {
|
|
32
|
+
let rows;
|
|
33
|
+
try {
|
|
34
|
+
rows = await store.query(statements);
|
|
35
|
+
}
|
|
36
|
+
catch (cause) {
|
|
37
|
+
if (cause instanceof WenmarOpenError)
|
|
38
|
+
throw cause;
|
|
39
|
+
throw new WenmarOpenError({
|
|
40
|
+
code: "store_error",
|
|
41
|
+
message: "The database failed while it was being read.",
|
|
42
|
+
cause,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (!Array.isArray(rows) || rows.length !== statements.length) {
|
|
46
|
+
throw new WenmarOpenError({
|
|
47
|
+
code: "store_error",
|
|
48
|
+
message: "The store must return one list of rows for each statement.",
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return rows;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* VIN decoding and the vehicle catalog with no network: the methods of
|
|
55
|
+
* `WenmarOpen`, answered from a data file by the decoder inside the package.
|
|
56
|
+
* Every method gives what the hosted API gives for the same question and
|
|
57
|
+
* fails with the same `WenmarOpenError` codes.
|
|
58
|
+
*/
|
|
59
|
+
export class WenmarOpenOffline {
|
|
60
|
+
#store;
|
|
61
|
+
#shop;
|
|
62
|
+
/** The copy of the decoder the shop file was opened on, and that opening. */
|
|
63
|
+
#shopOpened;
|
|
64
|
+
#wasm;
|
|
65
|
+
#currentYear;
|
|
66
|
+
#module;
|
|
67
|
+
#engine;
|
|
68
|
+
constructor(options) {
|
|
69
|
+
this.#store = options.store;
|
|
70
|
+
this.#shop = options.shop;
|
|
71
|
+
this.#wasm = options.wasm;
|
|
72
|
+
this.#currentYear = options.currentYear;
|
|
73
|
+
}
|
|
74
|
+
/** Decode one VIN. Spaces, dashes and lowercase are accepted. */
|
|
75
|
+
async decodeVin(vin, options = {}) {
|
|
76
|
+
return this.#one({ op: "decode", args: { vin: String(vin), year: options.year } }, options);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Decode up to 50 VINs. The answer has one item per VIN, in order: a
|
|
80
|
+
* decode, or the error a single call for that VIN would have thrown.
|
|
81
|
+
* Tell them apart with `isBatchError`.
|
|
82
|
+
*/
|
|
83
|
+
async decodeVins(vins, options = {}) {
|
|
84
|
+
if (vins.length > MAX_BATCH) {
|
|
85
|
+
throw new WenmarOpenError({
|
|
86
|
+
code: "validation_failed",
|
|
87
|
+
message: `a batch holds at most ${MAX_BATCH} VINs`,
|
|
88
|
+
details: { field: "vins", max: MAX_BATCH, received: vins.length },
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const questions = vins.map((vin) => ({ op: "decode", args: { vin: String(vin) } }));
|
|
92
|
+
const outcomes = await this.#ask(questions, options);
|
|
93
|
+
return outcomes.map((outcome) => ("ok" in outcome ? outcome.ok : { error: outcome.error }));
|
|
94
|
+
}
|
|
95
|
+
/** Model years, newest first. */
|
|
96
|
+
async years(query = {}, options = {}) {
|
|
97
|
+
return this.#one({ op: "years", args: query }, options);
|
|
98
|
+
}
|
|
99
|
+
/** Makes, popular ones first and the rest by name. */
|
|
100
|
+
async makes(query = {}, options = {}) {
|
|
101
|
+
return this.#one({ op: "makes", args: query }, options);
|
|
102
|
+
}
|
|
103
|
+
/** Models of a make, by name. An unknown make has no models. */
|
|
104
|
+
async models(query, options = {}) {
|
|
105
|
+
return this.#one({ op: "models", args: query }, options);
|
|
106
|
+
}
|
|
107
|
+
/** Submodels of a model year: trims, or series where there are no trims. */
|
|
108
|
+
async submodels(query, options = {}) {
|
|
109
|
+
return this.#one({ op: "submodels", args: query }, options);
|
|
110
|
+
}
|
|
111
|
+
/** The same as `submodels`, under the name most people use. */
|
|
112
|
+
async trims(query, options = {}) {
|
|
113
|
+
return this.#one({ op: "submodels", args: query }, options);
|
|
114
|
+
}
|
|
115
|
+
/** Engines of a model year, in short form. */
|
|
116
|
+
async engines(query, options = {}) {
|
|
117
|
+
return this.#one({ op: "engines", args: query }, options);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Catalog entries for free text such as `2019 civic si`, best first. The
|
|
121
|
+
* hosted API also finds words in any order with an index of its own;
|
|
122
|
+
* here, text the catalog cannot read as year, make, model and submodel
|
|
123
|
+
* finds nothing.
|
|
124
|
+
*/
|
|
125
|
+
async search(query, options = {}) {
|
|
126
|
+
return this.#one({ op: "search", args: query }, options);
|
|
127
|
+
}
|
|
128
|
+
/** One catalog entry by its id, such as `2019_honda_civic_si`. */
|
|
129
|
+
async vehicle(id, options = {}) {
|
|
130
|
+
return this.#one({ op: "vehicle", args: { id: String(id) } }, options);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* NHTSA's crash test ratings for a model year, by its id such as
|
|
134
|
+
* `2018_ford_f-150`. NHTSA rates by body style and drive, so `ratings`
|
|
135
|
+
* often has several entries: each names the vehicle it is for. Needs a
|
|
136
|
+
* shop file that holds the ratings: without one it fails with
|
|
137
|
+
* `dataset_unavailable`.
|
|
138
|
+
*/
|
|
139
|
+
async safety(id, options = {}) {
|
|
140
|
+
return this.#one({ op: "safety", args: { id: String(id) } }, options);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* What owners reported to NHTSA about a model year, as counts by component
|
|
144
|
+
* with the median mileage they gave. Without a shop file that holds the
|
|
145
|
+
* complaints it fails with `dataset_unavailable`.
|
|
146
|
+
*/
|
|
147
|
+
async complaints(id, options = {}) {
|
|
148
|
+
return this.#one({ op: "complaints", args: { id: String(id) } }, options);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Fuel consumption ratings of a model year, from EPA and Natural
|
|
152
|
+
* Resources Canada. Each says in `confidence` how far it was matched:
|
|
153
|
+
* `model`, `engine` or `submodel`. Needs a shop file that holds them:
|
|
154
|
+
* without one it fails with `dataset_unavailable`.
|
|
155
|
+
*/
|
|
156
|
+
async fuel(id, query = {}, options = {}) {
|
|
157
|
+
return this.#one({ op: "fuel_vehicle", args: { ...query, id: String(id) } }, options);
|
|
158
|
+
}
|
|
159
|
+
/** Fuel ratings for the vehicle a VIN decodes to: one engine's when the decode settles it. */
|
|
160
|
+
async vinFuel(vin, query = {}, options = {}) {
|
|
161
|
+
return this.#one({ op: "fuel_vin", args: { ...query, vin: String(vin) } }, options);
|
|
162
|
+
}
|
|
163
|
+
/** Specifications of a model year from EPA's files, one entry per EPA rating. */
|
|
164
|
+
async specs(id, options = {}) {
|
|
165
|
+
return this.#one({ op: "specs_vehicle", args: { id: String(id) } }, options);
|
|
166
|
+
}
|
|
167
|
+
/** Specifications for the vehicle a VIN decodes to: one engine's when the decode settles it. */
|
|
168
|
+
async vinSpecs(vin, query = {}, options = {}) {
|
|
169
|
+
return this.#one({ op: "specs_vin", args: { ...query, vin: String(vin) } }, options);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Fluids of a model year: the engine oil viscosity grade the maker
|
|
173
|
+
* certified each engine with, per EPA, and the records a person read from
|
|
174
|
+
* the maker's own documents, each with its source and the day it was checked.
|
|
175
|
+
*/
|
|
176
|
+
async fluids(id, options = {}) {
|
|
177
|
+
return this.#one({ op: "fluids_vehicle", args: { id: String(id) } }, options);
|
|
178
|
+
}
|
|
179
|
+
/** Fluids for the vehicle a VIN decodes to: one engine's when the decode settles it. */
|
|
180
|
+
async vinFluids(vin, query = {}, options = {}) {
|
|
181
|
+
return this.#one({ op: "fluids_vin", args: { ...query, vin: String(vin) } }, options);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Original-equipment tire fitments of a model year. Every size on record
|
|
185
|
+
* is listed with the trims it covers; none is picked.
|
|
186
|
+
*/
|
|
187
|
+
async tires(id, options = {}) {
|
|
188
|
+
return this.#one({ op: "tires_vehicle", args: { id: String(id) } }, options);
|
|
189
|
+
}
|
|
190
|
+
/** Tire fitments of the model year a VIN decodes to. Never narrowed: a VIN does not name the wheels. */
|
|
191
|
+
async vinTires(vin, query = {}, options = {}) {
|
|
192
|
+
return this.#one({ op: "tires_vin", args: { ...query, vin: String(vin) } }, options);
|
|
193
|
+
}
|
|
194
|
+
/** The repair jobs a labour estimate can be about. Needs a shop file that holds the job list. */
|
|
195
|
+
async jobs(options = {}) {
|
|
196
|
+
return this.#one({ op: "jobs", args: {} }, options);
|
|
197
|
+
}
|
|
198
|
+
/** One repair job: what it covers, and what it typically takes by class of vehicle. */
|
|
199
|
+
async job(job, options = {}) {
|
|
200
|
+
return this.#one({ op: "job", args: { job: String(job) } }, options);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Wenmar's labour estimates for a model year, or for one `job` of it.
|
|
204
|
+
* An estimate, not a quote; credit it as `attribution` says. Needs a
|
|
205
|
+
* shop file that holds the estimates.
|
|
206
|
+
*/
|
|
207
|
+
async labor(id, job, options = {}) {
|
|
208
|
+
return this.#one({ op: "labor_vehicle", args: { id: String(id), job } }, options);
|
|
209
|
+
}
|
|
210
|
+
/** Labour estimates for the vehicle a VIN decodes to, or for one `job` of it. */
|
|
211
|
+
async vinLabor(vin, job, options = {}) {
|
|
212
|
+
return this.#one({ op: "labor_vin", args: { vin: String(vin), job, year: options.year } }, options);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* One generic trouble code, such as `P0420`, described in this project's
|
|
216
|
+
* own words. Needs a shop file that holds the codes: without one it
|
|
217
|
+
* fails with `dataset_unavailable`.
|
|
218
|
+
*/
|
|
219
|
+
async code(code, options = {}) {
|
|
220
|
+
return this.#one({ op: "code", args: { code: String(code) } }, options);
|
|
221
|
+
}
|
|
222
|
+
/** The trouble codes that are described, by code, narrowed by text or system. */
|
|
223
|
+
async codes(query = {}, options = {}) {
|
|
224
|
+
return this.#one({ op: "codes", args: { ...query } }, options);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* A trouble code on one model year: its description and the recalls
|
|
228
|
+
* about the same components. `bulletins` and `related.bulletins` are
|
|
229
|
+
* always `null` offline: bulletins are on the hosted service only.
|
|
230
|
+
*/
|
|
231
|
+
async vehicleCode(id, code, options = {}) {
|
|
232
|
+
return this.#one({ op: "vehicle_code", args: { id: String(id), code: String(code) } }, options);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Recalls that may apply to a model year, by its id such as
|
|
236
|
+
* `2018_ford_f-150`. The list is for the year, make and model: it is not
|
|
237
|
+
* a lookup by VIN, and `lookup` says where one vehicle can be checked.
|
|
238
|
+
* Needs a shop file: without one it fails with `dataset_unavailable`.
|
|
239
|
+
*/
|
|
240
|
+
async recalls(id, query = {}, options = {}) {
|
|
241
|
+
return this.#one({ op: "recalls_vehicle", args: { ...query, id: String(id) } }, options);
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Recalls that may apply to the model year a VIN decodes to. The VIN only
|
|
245
|
+
* names the model year: no public data says a recall applies to one VIN.
|
|
246
|
+
*/
|
|
247
|
+
async vinRecalls(vin, query = {}, options = {}) {
|
|
248
|
+
return this.#one({ op: "recalls_vin", args: { ...query, vin: String(vin) } }, options);
|
|
249
|
+
}
|
|
250
|
+
/** One recall campaign: `source` is `nhtsa` or `tc`. */
|
|
251
|
+
async recall(source, campaign, options = {}) {
|
|
252
|
+
return this.#one({ op: "recall", args: { source: String(source), campaign: String(campaign) } }, options);
|
|
253
|
+
}
|
|
254
|
+
/** Recall campaigns, newest first, narrowed by what is given. */
|
|
255
|
+
async searchRecalls(query = {}, options = {}) {
|
|
256
|
+
return this.#one({ op: "recalls_search", args: query }, options);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Everything held for a model year in one answer, by its id such as
|
|
260
|
+
* `2018_ford_f-150`. A block the files do not hold is `null`. `recalls`
|
|
261
|
+
* counts the campaigns that may apply to the year, make and model: it is
|
|
262
|
+
* not a lookup by VIN. With no shop file `recalls` is `null`, and the
|
|
263
|
+
* method still answers.
|
|
264
|
+
*/
|
|
265
|
+
async overview(id, options = {}) {
|
|
266
|
+
return this.#one({ op: "overview_vehicle", args: { id: String(id) } }, options);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* The overview of the model year a VIN decodes to, with `decode`. The VIN
|
|
270
|
+
* only names the model year: no public data says a recall applies to one
|
|
271
|
+
* VIN.
|
|
272
|
+
*/
|
|
273
|
+
async vinOverview(vin, options = {}) {
|
|
274
|
+
return this.#one({ op: "overview_vin", args: { vin: String(vin), year: options.year } }, options);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Every model year of one model, newest first, each with its vehicle id
|
|
278
|
+
* and the number of recall campaigns that may apply to it: `null` with no
|
|
279
|
+
* shop file.
|
|
280
|
+
*/
|
|
281
|
+
async modelYears(make, model, options = {}) {
|
|
282
|
+
return this.#one({ op: "model_years", args: { make: String(make), model: String(model) } }, options);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Recall campaigns reported on or after `since` (a day such as
|
|
286
|
+
* `2026-09-01`), newest first, each with the vehicles it may apply to.
|
|
287
|
+
* Needs a shop file: without one it fails with `dataset_unavailable`.
|
|
288
|
+
*/
|
|
289
|
+
async recentRecalls(query, options = {}) {
|
|
290
|
+
return this.#one({ op: "recalls_recent", args: query }, options);
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* The data version, the vPIC release it came from, and the build time.
|
|
294
|
+
* `server_version` is the version of this package. `datasets` lists what
|
|
295
|
+
* the shop file holds, and is empty when no shop store was given.
|
|
296
|
+
*/
|
|
297
|
+
async meta(options = {}) {
|
|
298
|
+
return this.#one({ op: "meta", args: {} }, options);
|
|
299
|
+
}
|
|
300
|
+
/** Closes the stores, if they were opened for this client. */
|
|
301
|
+
close() {
|
|
302
|
+
this.#store.close?.();
|
|
303
|
+
this.#shop?.close?.();
|
|
304
|
+
}
|
|
305
|
+
async #one(question, options) {
|
|
306
|
+
const [outcome] = await this.#ask([question], options);
|
|
307
|
+
if (outcome === undefined) {
|
|
308
|
+
throw new WenmarOpenError({ code: "internal_error", message: "The decoder gave no answer." });
|
|
309
|
+
}
|
|
310
|
+
if ("error" in outcome)
|
|
311
|
+
throw thrown(outcome.error);
|
|
312
|
+
return outcome.ok;
|
|
313
|
+
}
|
|
314
|
+
/** The decoder, started and with the data opened. Started again after a stop. */
|
|
315
|
+
async #ready() {
|
|
316
|
+
const current = this.#engine;
|
|
317
|
+
if (current !== undefined) {
|
|
318
|
+
const engine = await current;
|
|
319
|
+
if (!engine.stopped)
|
|
320
|
+
return engine;
|
|
321
|
+
if (this.#engine === current)
|
|
322
|
+
this.#engine = undefined;
|
|
323
|
+
}
|
|
324
|
+
this.#engine ??= this.#start();
|
|
325
|
+
try {
|
|
326
|
+
return await this.#engine;
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
this.#engine = undefined;
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
async #start() {
|
|
334
|
+
this.#module ??= compile(this.#wasm);
|
|
335
|
+
let module;
|
|
336
|
+
try {
|
|
337
|
+
module = await this.#module;
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
this.#module = undefined;
|
|
341
|
+
throw error;
|
|
342
|
+
}
|
|
343
|
+
const engine = await Engine.start(module);
|
|
344
|
+
const answers = [];
|
|
345
|
+
for (let step = 0; step < MAX_STEPS; step += 1) {
|
|
346
|
+
const answer = engine.call({ op: "open", answers });
|
|
347
|
+
if ("ok" in answer)
|
|
348
|
+
return engine;
|
|
349
|
+
if ("error" in answer)
|
|
350
|
+
throw thrown(answer.error);
|
|
351
|
+
const rows = await read(this.#store, answer.need);
|
|
352
|
+
answer.need.forEach((statement, index) => {
|
|
353
|
+
answers.push({ ...statement, rows: cleanRows(rows[index], statement.sql) });
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
throw new WenmarOpenError({ code: "internal_error", message: "Opening the data did not finish." });
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* The rows of every statement, each from the store its file is in: a
|
|
360
|
+
* statement marked `db: "shop"` is for the shop file.
|
|
361
|
+
*/
|
|
362
|
+
async #read(statements) {
|
|
363
|
+
const ofShop = statements.map((statement) => statement.db === "shop");
|
|
364
|
+
if (!ofShop.includes(true))
|
|
365
|
+
return read(this.#store, statements);
|
|
366
|
+
const shop = this.#shop;
|
|
367
|
+
if (shop === undefined) {
|
|
368
|
+
throw new WenmarOpenError({
|
|
369
|
+
code: "internal_error",
|
|
370
|
+
message: "The decoder asked for the shop file, and no shop store was given.",
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
const forData = statements.filter((_, index) => !ofShop[index]);
|
|
374
|
+
const forShop = statements.filter((_, index) => ofShop[index]);
|
|
375
|
+
const [fromData, fromShop] = await Promise.all([
|
|
376
|
+
forData.length === 0 ? [] : read(this.#store, forData),
|
|
377
|
+
read(shop, forShop),
|
|
378
|
+
]);
|
|
379
|
+
let [data, other] = [0, 0];
|
|
380
|
+
return statements.map((_, index) => (ofShop[index] ? fromShop[other++] : fromData[data++]) ?? []);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Opens the shop file on `engine`, once for each copy of the decoder. It
|
|
384
|
+
* is opened when a question first needs it, so a shop store that fails
|
|
385
|
+
* does not stop a VIN from being decoded.
|
|
386
|
+
*/
|
|
387
|
+
async #withShop(engine) {
|
|
388
|
+
if (this.#shopOpened?.engine !== engine) {
|
|
389
|
+
this.#shopOpened = { engine, done: this.#openShop(engine) };
|
|
390
|
+
}
|
|
391
|
+
const opening = this.#shopOpened;
|
|
392
|
+
try {
|
|
393
|
+
await opening.done;
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
if (this.#shopOpened === opening)
|
|
397
|
+
this.#shopOpened = undefined;
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async #openShop(engine) {
|
|
402
|
+
const answers = [];
|
|
403
|
+
for (let step = 0; step < MAX_STEPS; step += 1) {
|
|
404
|
+
const answer = engine.call({ op: "open_shop", answers });
|
|
405
|
+
if ("ok" in answer)
|
|
406
|
+
return;
|
|
407
|
+
if ("error" in answer)
|
|
408
|
+
throw thrown(answer.error);
|
|
409
|
+
const rows = await this.#read(answer.need);
|
|
410
|
+
answer.need.forEach((statement, index) => {
|
|
411
|
+
answers.push({ ...statement, rows: cleanRows(rows[index], statement.sql) });
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
throw new WenmarOpenError({ code: "internal_error", message: "Opening the shop file did not finish." });
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Answers every question, going back to the store as often as the
|
|
418
|
+
* decoder asks. What several questions need in the same step is read
|
|
419
|
+
* together, each statement once.
|
|
420
|
+
*/
|
|
421
|
+
async #ask(questions, options) {
|
|
422
|
+
const signal = options.signal;
|
|
423
|
+
const timeoutMs = options.timeoutMs ?? 0;
|
|
424
|
+
const started = Date.now();
|
|
425
|
+
const currentYear = this.#currentYear ?? new Date().getUTCFullYear();
|
|
426
|
+
const answers = questions.map(() => []);
|
|
427
|
+
const outcomes = questions.map(() => undefined);
|
|
428
|
+
for (let step = 0;; step += 1) {
|
|
429
|
+
if (signal?.aborted) {
|
|
430
|
+
throw new WenmarOpenError({ code: "aborted", message: "The request was aborted.", cause: signal.reason });
|
|
431
|
+
}
|
|
432
|
+
if (timeoutMs > 0 && Date.now() - started > timeoutMs) {
|
|
433
|
+
throw new WenmarOpenError({
|
|
434
|
+
code: "timeout",
|
|
435
|
+
message: `No answer within ${timeoutMs} ms.`,
|
|
436
|
+
details: { timeout_ms: timeoutMs },
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
const engine = await this.#ready();
|
|
440
|
+
if (this.#shop !== undefined && questions.some(needsShop))
|
|
441
|
+
await this.#withShop(engine);
|
|
442
|
+
const wanted = new Map();
|
|
443
|
+
const waiting = [];
|
|
444
|
+
questions.forEach((question, index) => {
|
|
445
|
+
if (outcomes[index] !== undefined)
|
|
446
|
+
return;
|
|
447
|
+
const answer = engine.call({
|
|
448
|
+
op: question.op,
|
|
449
|
+
args: question.args,
|
|
450
|
+
current_year: currentYear,
|
|
451
|
+
answers: answers[index] ?? [],
|
|
452
|
+
});
|
|
453
|
+
if ("need" in answer) {
|
|
454
|
+
waiting.push({ index, need: answer.need });
|
|
455
|
+
for (const statement of answer.need)
|
|
456
|
+
wanted.set(keyOf(statement), statement);
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
outcomes[index] = answer;
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
if (waiting.length === 0)
|
|
463
|
+
return outcomes;
|
|
464
|
+
if (step >= MAX_STEPS) {
|
|
465
|
+
throw new WenmarOpenError({ code: "internal_error", message: "The decoder kept asking for more data." });
|
|
466
|
+
}
|
|
467
|
+
const statements = [...wanted.values()];
|
|
468
|
+
const rows = await this.#read(statements);
|
|
469
|
+
const found = new Map();
|
|
470
|
+
statements.forEach((statement, index) => {
|
|
471
|
+
found.set(keyOf(statement), { ...statement, rows: cleanRows(rows[index], statement.sql) });
|
|
472
|
+
});
|
|
473
|
+
for (const { index, need } of waiting) {
|
|
474
|
+
for (const statement of need) {
|
|
475
|
+
const answered = found.get(keyOf(statement));
|
|
476
|
+
if (answered !== undefined)
|
|
477
|
+
answers[index]?.push(answered);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { WenmarOpenError } from "../errors.js";
|
|
2
|
+
const EXPORTS = ["memory", "wo_alloc", "wo_call", "wo_result", "wo_result_len"];
|
|
3
|
+
function bytesOf(base64) {
|
|
4
|
+
const binary = atob(base64);
|
|
5
|
+
const bytes = new Uint8Array(binary.length);
|
|
6
|
+
for (let index = 0; index < binary.length; index += 1)
|
|
7
|
+
bytes[index] = binary.charCodeAt(index);
|
|
8
|
+
return bytes;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Compiles the decoder. With no source it is the copy inside the package,
|
|
12
|
+
* which is read as text and never fetched, so nothing here needs a file
|
|
13
|
+
* system or a network.
|
|
14
|
+
*/
|
|
15
|
+
export async function compile(source) {
|
|
16
|
+
if (source instanceof WebAssembly.Module)
|
|
17
|
+
return source;
|
|
18
|
+
try {
|
|
19
|
+
const bytes = source ?? bytesOf((await import("./wasm-inline.js")).WASM_BASE64);
|
|
20
|
+
return await WebAssembly.compile(bytes);
|
|
21
|
+
}
|
|
22
|
+
catch (cause) {
|
|
23
|
+
throw new WenmarOpenError({
|
|
24
|
+
code: "internal_error",
|
|
25
|
+
message: "The decoder could not be loaded. Where bytes may not be compiled, as in Cloudflare Workers, pass { wasm } from: import wasm from \"wenmar-open/offline.wasm\".",
|
|
26
|
+
cause,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** One running copy of the decoder. */
|
|
31
|
+
export class Engine {
|
|
32
|
+
#exports;
|
|
33
|
+
#stopped = false;
|
|
34
|
+
constructor(exports) {
|
|
35
|
+
this.#exports = exports;
|
|
36
|
+
}
|
|
37
|
+
static async start(module) {
|
|
38
|
+
let instance;
|
|
39
|
+
try {
|
|
40
|
+
instance = await WebAssembly.instantiate(module, {});
|
|
41
|
+
}
|
|
42
|
+
catch (cause) {
|
|
43
|
+
throw new WenmarOpenError({
|
|
44
|
+
code: "internal_error",
|
|
45
|
+
message: "The decoder could not be started: the wasm given is not the one of this package.",
|
|
46
|
+
cause,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const exports = instance.exports;
|
|
50
|
+
for (const name of EXPORTS) {
|
|
51
|
+
if (exports[name] === undefined) {
|
|
52
|
+
throw new WenmarOpenError({
|
|
53
|
+
code: "internal_error",
|
|
54
|
+
message: "The decoder could not be started: the wasm given is not the one of this package.",
|
|
55
|
+
details: { missing: name },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return new Engine(exports);
|
|
60
|
+
}
|
|
61
|
+
/** Whether a call stopped this copy. A stopped copy is never used again. */
|
|
62
|
+
get stopped() {
|
|
63
|
+
return this.#stopped;
|
|
64
|
+
}
|
|
65
|
+
#read() {
|
|
66
|
+
const { memory, wo_result, wo_result_len } = this.#exports;
|
|
67
|
+
// The memory may have grown during the call, so it is looked at afresh.
|
|
68
|
+
return new TextDecoder().decode(new Uint8Array(memory.buffer, wo_result(), wo_result_len()));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* One request, one answer. A request that cannot be written as JSON is
|
|
72
|
+
* refused before the decoder sees it. A stop inside the decoder is thrown
|
|
73
|
+
* as an error, and this copy is not used again.
|
|
74
|
+
*/
|
|
75
|
+
call(request) {
|
|
76
|
+
if (this.#stopped) {
|
|
77
|
+
throw new WenmarOpenError({ code: "internal_error", message: "The decoder has stopped." });
|
|
78
|
+
}
|
|
79
|
+
let input;
|
|
80
|
+
try {
|
|
81
|
+
input = new TextEncoder().encode(JSON.stringify(request));
|
|
82
|
+
}
|
|
83
|
+
catch (cause) {
|
|
84
|
+
throw new WenmarOpenError({
|
|
85
|
+
code: "validation_failed",
|
|
86
|
+
message: "The question could not be written as JSON.",
|
|
87
|
+
cause,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const { memory, wo_alloc, wo_call } = this.#exports;
|
|
91
|
+
try {
|
|
92
|
+
const pointer = wo_alloc(input.length);
|
|
93
|
+
new Uint8Array(memory.buffer, pointer, input.length).set(input);
|
|
94
|
+
wo_call(pointer, input.length);
|
|
95
|
+
}
|
|
96
|
+
catch (cause) {
|
|
97
|
+
// WebAssembly cannot unwind, so a failure inside it ends the call
|
|
98
|
+
// here. What it was is left where the answer would have been.
|
|
99
|
+
this.#stopped = true;
|
|
100
|
+
let panic;
|
|
101
|
+
try {
|
|
102
|
+
panic = this.#read();
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
panic = undefined;
|
|
106
|
+
}
|
|
107
|
+
// An answer is always a JSON object and a panic's message never is:
|
|
108
|
+
// text that starts as an object is an answer left from before.
|
|
109
|
+
if (panic === "" || panic?.startsWith("{"))
|
|
110
|
+
panic = undefined;
|
|
111
|
+
throw new WenmarOpenError({
|
|
112
|
+
code: "internal_error",
|
|
113
|
+
message: "The decoder stopped unexpectedly. Please report the VIN or the question that caused it.",
|
|
114
|
+
details: panic === undefined ? {} : { panic },
|
|
115
|
+
cause,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
return JSON.parse(this.#read());
|
|
120
|
+
}
|
|
121
|
+
catch (cause) {
|
|
122
|
+
throw new WenmarOpenError({
|
|
123
|
+
code: "internal_error",
|
|
124
|
+
message: "The decoder gave an answer that could not be read.",
|
|
125
|
+
cause,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { MAX_BATCH, isBatchError } from "../batch.js";
|
|
2
|
+
export { WenmarOpenError } from "../errors.js";
|
|
3
|
+
export type { ApiErrorCode, ClientErrorCode, ErrorCode, OfflineErrorCode, WenmarOpenErrorInit, } from "../errors.js";
|
|
4
|
+
export type { DecodeOptions, RequestOptions } from "../client.js";
|
|
5
|
+
export type { BatchItem, CheckDigit, Engine, EngineOil, EngineOption, EnginesQuery, Entry, FluidRecord, FuelRating, ErrorBody, Make, MakesQuery, Manufacturer, Meta, Model, ModelsQuery, ModelYears, Overview, OverviewRecalls, Plant, Recall, RecallCampaign, RecallCount, RecallList, RecallLookup, RecallSearchQuery, RecentRecalls, RecentRecallsQuery, Safety, SafetyRating, SearchQuery, Selection, Spec, Submodel, SubmodelsQuery, TireFitment, VehicleFluids, VehicleFuel, VehicleComplaints, VehicleFuelQuery, VehicleRecalls, VehicleRecallsQuery, Code, CodeLink, CodeList, CodeSearchQuery, VehicleCode, JobDetail, JobList, LaborFigure, VehicleLabor, VinLabor, VehicleSafety, VehicleSpecs, VehicleTires, VinDecode, VinFluids, VinFuel, VinFuelQuery, VinRecalls, VinRecallsQuery, VinSpecs, VinTires, VinYearQuery, Warning, YearsQuery, } from "../types.js";
|
|
6
|
+
export { MAX_STEPS, WenmarOpenOffline } from "./client.js";
|
|
7
|
+
export type { OfflineOptions } from "./client.js";
|
|
8
|
+
export type { CompiledWasm, WasmSource } from "./engine.js";
|
|
9
|
+
export { d1Store, syncStore } from "./store.js";
|
|
10
|
+
export type { D1Like, D1Statement, Param, Statement, Store, SyncDatabase, SyncStatement } from "./store.js";
|