reduct-js
Version:
ReductStore Client SDK for Javascript/NodeJS/Typescript
481 lines (480 loc) • 17.9 kB
JavaScript
import { BucketSettings } from "./messages/BucketSettings";
import { BucketInfo } from "./messages/BucketInfo";
import { EntryInfo } from "./messages/EntryInfo";
import { ReadableRecord, WritableRecord } from "./Record";
import { APIError } from "./APIError";
import Stream, { Readable } from "stream";
import { Buffer } from "buffer";
import { Batch, BatchType } from "./Batch";
import { isCompatibale } from "./Client";
import { QueryOptions, QueryType } from "./messages/QueryEntry";
/**
* Represents a bucket in ReductStore
*/
export class Bucket {
/**
* Create a bucket. Use Client.creatBucket or Client.getBucket instead it
* @constructor
* @param name
* @param httpClient
* @see {Client}
*/
constructor(name, httpClient) {
this.name = name;
this.httpClient = httpClient;
this.isBrowser = typeof window !== "undefined";
this.readRecord = this.readRecord.bind(this);
}
/**
* Get bucket settings
* @async
* @return {Promise<BucketSettings>}
*/
async getSettings() {
const { data } = await this.httpClient.get(`/b/${this.name}`);
return Promise.resolve(BucketSettings.parse(data.settings));
}
/**
* Set bucket settings
* @async
* @param settings {BucketSettings} new settings (you can set a part of settings)
*/
async setSettings(settings) {
await this.httpClient.put(`/b/${this.name}`, BucketSettings.serialize(settings));
}
/**
* Get information about a bucket
* @async
* @return {Promise<BucketInfo>}
*/
async getInfo() {
const { data } = await this.httpClient.get(`/b/${this.name}`);
return BucketInfo.parse(data.info);
}
/**
* Get entry list
* @async
* @return {Promise<EntryInfo>}
*/
async getEntryList() {
const { data } = await this.httpClient.get(`/b/${this.name}`);
return Promise.resolve(data.entries.map((entry) => EntryInfo.parse(entry)));
}
/**
* Remove bucket
* @async
* @return {Promise<void>}
*/
async remove() {
await this.httpClient.delete(`/b/${this.name}`);
}
/**
* Remove an entry
* @async
* @param entry {string} name of the entry
* @return {Promise<void>}
*/
async removeEntry(entry) {
await this.httpClient.delete(`/b/${this.name}/${entry}`);
}
/**
* Remove a record
* @param entry {string} name of the entry
* @param ts {BigInt} timestamp of record in microseconds
*/
async removeRecord(entry, ts) {
await this.httpClient.delete(`/b/${this.name}/${entry}?ts=${ts}`);
}
/**
* Remove a batch of records
* @param entry {string} name of the entry
* @param tsList {BigInt[]} list of timestamps of records in microseconds
*/
async beginRemoveBatch(entry) {
return new Batch(this.name, entry, this.httpClient, BatchType.REMOVE);
}
/**
* Remove records by query
* @param entry {string} name of the entry
* @param start {BigInt} start point of the time period, if undefined, the query starts from the first record
* @param stop {BigInt} stop point of the time period. If undefined, the query stops at the last record
* @param options {QueryOptions} options for query. You can use only include, exclude, eachS, eachN other options are ignored
*/
async removeQuery(entry, start, stop, options) {
if (options !== undefined && options.when !== undefined) {
const { data } = await this.httpClient.post(`/b/${this.name}/${entry}/q`, QueryOptions.serialize(QueryType.REMOVE, options));
return Promise.resolve(data["removed_records"]);
}
else {
const ret = this.parse_query_params(start, stop, options);
const { data } = await this.httpClient.delete(`/b/${this.name}/${entry}/q?${ret.query}`);
return Promise.resolve(data["removed_records"]);
}
}
/**
* Start writing a record into an entry
* @param entry name of the entry
* @param options {BigInt | WriteOptions} timestamp in microseconds for the record or options. It is current time if undefined.
* @return Promise<WritableRecord>
* @example
* const record = await bucket.beginWrite("entry", {
* ts: 12345667n
* labels: {label1: "value1", label2: "value2"}
* contentType: "text/plain"
* );
* await record.write("Hello!");
*/
async beginWrite(entry, options) {
let localOptions = { ts: undefined };
if (options !== undefined) {
if (typeof options === "bigint") {
localOptions = { ts: options };
}
else {
localOptions = options;
}
}
localOptions.ts = localOptions.ts ?? BigInt(Date.now()) * 1000n;
return Promise.resolve(new WritableRecord(this.name, entry, localOptions, this.httpClient));
}
/**
* Update labels of an existing record
*
* If a label has empty string value, it will be removed.
*
* @param entry {string} name of the entry
* @param ts {BigInt} timestamp of record in microseconds
* @param labels {LabelMap} labels to update
*/
async update(entry, ts, labels) {
const headers = {
"Content-Length": "0",
};
for (const [key, value] of Object.entries(labels)) {
headers[`x-reduct-label-${key}`] = value.toString();
}
await this.httpClient.patch(`/b/${this.name}/${entry}?ts=${ts}`, "", {
headers,
});
}
/**
* Start reading a record from an entry
* @param entry name of the entry
* @param ts {BigInt} timestamp of record in microseconds. Get the latest one, if undefined
* @param head {boolean} return only head of the record
* @return Promise<ReadableRecord>
*/
async beginRead(entry, ts, head) {
return await this.readRecord(entry, head ?? false, ts ? ts.toString() : undefined);
}
/**
* Rename an entry
* @param entry entry name to rename
* @param newEntry new entry name
*/
async renameEntry(entry, newEntry) {
await this.httpClient.put(`/b/${this.name}/${entry}/rename`, {
new_name: newEntry,
}, {
headers: {
"Content-Type": "application/json",
},
});
}
/**
* Rename a bucket
* @param newName new name of the bucket
*/
async rename(newName) {
await this.httpClient.put(`/b/${this.name}/rename`, {
new_name: newName,
}, {
headers: {
"Content-Type": "application/json",
},
});
this.name = newName;
}
/**
* Query records for a time interval as generator
* @param entry entry name
* @param entry {string} name of the entry
* @param start {BigInt} start point of the time period
* @param stop {BigInt} stop point of the time period
* @param options {number | QueryOptions} if number it is TTL of query on the server side, otherwise it is options for query
* @example
* for await (const record in bucket.query("entry-1", start, stop)) {
* console.log(record.ts, record.size);
* console.log(record.labels);
* const content = await record.read();
* // or use pipe
* const fileStream = fs.createWriteStream(`ts_${record.size}.txt`);
* record.pipe(fileStream);
* }
*/
async *query(entry, start, stop, options) {
let id;
let header_api_version;
let continuous = false;
let pollInterval = 1;
let head = false;
if (options !== undefined &&
typeof options === "object" &&
"when" in options) {
const { data, headers } = await this.httpClient.post(`/b/${this.name}/${entry}/q`, QueryOptions.serialize(QueryType.QUERY, options));
({ id } = data);
header_api_version = headers["x-reduct-api"];
continuous = options.continuous ?? false;
pollInterval = options.pollInterval ?? 1;
head = options.head ?? false;
}
else {
// TODO: remove this block after 1.xx
const ret = this.parse_query_params(start, stop, options);
const url = `/b/${this.name}/${entry}/q?` + ret.query;
const { data, headers } = await this.httpClient.get(url);
({ id } = data);
header_api_version = headers["x-reduct-api"];
({ continuous, pollInterval, head } = ret);
}
if (isCompatibale("1.5", header_api_version) && !this.isBrowser) {
yield* this.fetchAndParseBatchedRecords(entry, id, continuous, pollInterval, head);
}
else {
yield* this.fetchAndParseSingleRecord(entry, id, continuous, pollInterval, head);
}
}
getName() {
return this.name;
}
parse_query_params(start, stop, options) {
let continueQuery = false;
let poolInterval = 1;
const params = [];
let head = false;
if (start !== undefined) {
params.push(`start=${start}`);
}
if (stop !== undefined) {
params.push(`stop=${stop}`);
}
if (options !== undefined) {
if (typeof options === "number") {
params.push(`ttl=${options}`);
}
else {
// Build query string from options
if (options.ttl !== undefined) {
params.push(`ttl=${options.ttl}`);
}
for (const [key, value] of Object.entries(options.include ? options.include : {})) {
params.push(`include-${key}=${value}`);
}
for (const [key, value] of Object.entries(options.exclude ? options.exclude : {})) {
params.push(`exclude-${key}=${value}`);
}
if (options.eachS !== undefined) {
params.push(`each_s=${options.eachS}`);
}
if (options.eachN !== undefined) {
params.push(`each_n=${options.eachN}`);
}
if (options.limit !== undefined) {
params.push(`limit=${options.limit}`);
}
if (options.continuous !== undefined) {
params.push(`continuous=${options.continuous ? "true" : "false"}`);
continueQuery = options.continuous;
if (options.pollInterval !== undefined) {
// eslint-disable-next-line prefer-destructuring
poolInterval = options.pollInterval;
}
// Set default TTL for continue query as 2 * poolInterval
if (options.ttl === undefined) {
params.push(`ttl=${poolInterval * 2}`);
}
}
continueQuery = options.continuous ?? false;
poolInterval = options.pollInterval ?? 1;
head = options.head ?? false;
}
}
return {
continuous: continueQuery,
pollInterval: poolInterval,
head: head,
query: params.join("&"),
};
}
async *fetchAndParseSingleRecord(entry, id, continueQuery, pollInterval, head) {
while (true) {
try {
const record = await this.readRecord(entry, head, undefined, id);
yield record;
}
catch (e) {
if (e instanceof APIError && e.status === 204) {
if (continueQuery) {
await new Promise((resolve) => setTimeout(resolve, pollInterval * 1000));
continue;
}
return;
}
throw e;
}
}
}
async readRecord(entry, head, ts, id) {
let param = "";
if (ts !== undefined) {
param = `ts=${ts}`;
}
if (id !== undefined) {
param = `q=${id}`;
}
const request = head ? this.httpClient.head : this.httpClient.get;
const { status, headers, data } = await request(`/b/${this.name}/${entry}?${param}`, head
? undefined
: {
responseType: this.isBrowser ? "arraybuffer" : "stream",
});
if (status === 204) {
throw new APIError(headers["x-reduct-error"] ?? "No content", 204);
}
const labels = {};
for (const [key, value] of Object.entries(headers)) {
if (key.startsWith("x-reduct-label-")) {
labels[key.substring(15)] = value;
}
}
if (this.isBrowser) {
// Pass a dummy Stream object and use ArrayBuffer
const arrayBuffer = data;
return new ReadableRecord(BigInt(headers["x-reduct-time"] ?? 0), BigInt(headers["content-length"] ?? 0), headers["x-reduct-last"] == "1", head, new Stream.Readable(), labels, headers["content-type"] ?? "application/octet-stream", arrayBuffer);
}
else {
// Pass the actual Stream object to ReadableRecord
const stream = data;
return new ReadableRecord(BigInt(headers["x-reduct-time"] ?? 0), BigInt(headers["content-length"] ?? 0), headers["x-reduct-last"] == "1", head, stream, labels, headers["content-type"] ?? "application/octet-stream");
}
}
async *fetchAndParseBatchedRecords(entry, id, continueQuery, poolInterval, head) {
while (true) {
try {
for await (const record of this.readBatchedRecords(entry, head, id)) {
yield record;
if (record.last) {
return;
}
}
}
catch (e) {
if (e instanceof APIError && e.status === 204) {
if (continueQuery) {
await new Promise((resolve) => setTimeout(resolve, poolInterval * 1000));
continue;
}
return;
}
throw e;
}
}
}
async *readBatchedRecords(entry, head, id) {
const request = head ? this.httpClient.head : this.httpClient.get;
const { status, headers, data } = await request(`/b/${this.name}/${entry}/batch?q=${id}`, head ? undefined : { responseType: "stream" });
if (status === 204) {
throw new APIError(headers["x-reduct-error"] ?? "No content", 204);
}
let count = 0;
const total = Object.entries(headers).reduce((acc, [key, _]) => (key.startsWith("x-reduct-time-") ? acc + 1 : acc), 0);
let last = false;
for (const [key, value] of Object.entries(headers)) {
if (!key.startsWith("x-reduct-time-"))
continue;
const ts = key.substring(14);
const { size, contentType, labels } = parseCsvRow(value);
count += 1;
let stream;
if (count === total) {
if (headers["x-reduct-last"] === "true") {
last = true;
}
stream = data;
}
else {
let buffer = Buffer.from([]);
if (!head) {
buffer = await new Promise((resolve, reject) => {
const err_handler = (err) => {
reject(err);
};
data.on("readable", function handler() {
const chunk = data.read(Number(size));
if (chunk !== null) {
resolve(chunk);
data.off("readable", handler);
data.off("error", err_handler);
}
});
data.on("error", err_handler);
});
}
stream = Readable.from(buffer);
}
yield new ReadableRecord(BigInt(ts), size, last, head, stream, labels, contentType);
}
}
/**
* Create a new batch for writing records to the database.
* @param entry
*/
async beginWriteBatch(entry) {
return new Batch(this.name, entry, this.httpClient, BatchType.WRITE);
}
/**
* Create a new batch for updating records in the database.
* @param entry
*/
async beginUpdateBatch(entry) {
return new Batch(this.name, entry, this.httpClient, BatchType.UPDATE);
}
}
function parseCsvRow(row) {
const items = [];
let escaped = "";
for (const item of row.split(",")) {
if (item.startsWith('"') && !escaped) {
escaped = item.substring(1);
}
if (escaped) {
if (item.endsWith('"')) {
escaped = escaped.slice(0, -1);
items.push(escaped);
escaped = "";
}
else {
escaped += item;
}
}
else {
items.push(item);
}
}
const size = BigInt(items[0]);
// eslint-disable-next-line prefer-destructuring
const contentType = items[1];
const labels = {};
for (const item of items.slice(2)) {
if (!item.includes("=")) {
continue;
}
const [key, value] = item.split("=", 2);
labels[key] = value;
}
return {
size,
contentType,
labels,
};
}