const IS_NODE = typeof process === "object";
try {
// ambient axios context in browser
if (IS_NODE) var axios = require("axios").default;
} catch {}
/**
* @typedef {Object} SearchOption
* @property {string} field - The field to be searched for
* @property {"!=" | "==" | ">=" | "<=" | "<" | ">" | "in" | "includes" | "startsWith" | "endsWith" | "array-contains" | "array-contains-none" | "array-contains-any" | "array-length-(eq|df|gt|lt|ge|le)"} criteria - Search criteria to filter results
* @property {string | number | boolean | Array} value - The value to be searched for
* @property {boolean} [ignoreCase] - Is it case sensitive? (default true)
*/
/**
* @typedef {Object} EditFieldOption
* @property {string | number} id - The affected element
* @property {string} field - The field to edit
* @property {"set" | "remove" | "append" | "increment" | "decrement" | "array-push" | "array-delete" | "array-splice"} operation - Operation for the field
* @property {string | number | boolean | Array} [value] - The value to write
*/
/**
* @typedef {Object} ValueOption
* @property {string} field - Field to search
* @property {boolean} [flatten] - Flatten array fields? (default false)
*/
/**
* @typedef {Object} SelectOption
* @property {Array<string>} fields - Selected fields to be returned
*/
/**
* @typedef {Object} WriteConfirmation
* @property {string} message - Write status
*/
/** @ignore */
let _address = undefined;
/** @ignore */
let _token = undefined;
const ID_FIELD_NAME = "id";
const readAddress = () => {
if (!_address) throw new Error("Firestorm address was not configured");
return _address + "get.php";
};
const writeAddress = () => {
if (!_address) throw new Error("Firestorm address was not configured");
return _address + "post.php";
};
const fileAddress = () => {
if (!_address) throw new Error("Firestorm address was not configured");
return _address + "files.php";
};
const writeToken = () => {
if (!_token) throw new Error("Firestorm token was not configured");
return _token;
};
/**
* Axios Promise typedef to avoid documentation generation problems
* @ignore
* @typedef {require("axios").AxiosPromise} AxiosPromise
*/
/**
* Auto-extracts data from Axios request
* @ignore
* @param {AxiosPromise} request - Axios request promise
*/
const __extract_data = (request) => {
if (!(request instanceof Promise)) request = Promise.resolve(request);
return request.then((res) => {
if ("data" in res) return res.data;
return res;
});
};
/**
* Represents a Firestorm Collection
* @template T
*/
class Collection {
/**
* Create a new Firestorm collection instance
* @param {string} name - The name of the collection
* @param {Function} [addMethods] - Additional methods and data to add to the objects
*/
constructor(name, addMethods = (el) => el) {
if (!name) throw new SyntaxError("Collection must have a name");
if (typeof addMethods !== "function")
throw new TypeError("Collection add methods must be a function");
this.addMethods = addMethods;
this.collectionName = name;
}
/**
* Add user methods to returned data
* @private
* @ignore
* @param {any} el - Value to add methods to
* @param {boolean} [nested] - Nest the methods inside an object
* @returns {any}
*/
__add_methods(el, nested = true) {
// can't add properties on falsy values
if (!el) return el;
if (Array.isArray(el)) return el.map((el) => this.addMethods(el));
// nested objects
if (nested && typeof el === "object") {
Object.keys(el).forEach((k) => {
el[k] = this.addMethods(el[k]);
});
return el;
}
// add directly to single object
return this.addMethods(el);
}
/**
* Auto-extracts data from Axios request
* @private
* @ignore
* @param {AxiosPromise} request - Axios request promise
*/
__extract_data(request) {
return __extract_data(request);
}
/**
* Send GET request with provided data and return extracted response
* @private
* @ignore
* @param {string} command - The read command name
* @param {Object} [data] - Body data
* @param {boolean} [objectLike] - Reject if an object or array isn't being returned
* @returns {Promise<any>} Extracted response
*/
__get_request(command, data = {}, objectLike = true) {
const obj = {
collection: this.collectionName,
command: command,
...data,
};
const request = IS_NODE
? axios.get(readAddress(), { data: obj })
: axios.post(readAddress(), obj);
return this.__extract_data(request).then((res) => {
// reject php error strings if enforcing return type
if (objectLike && typeof res !== "object") return Promise.reject(res);
return res;
});
}
/**
* Generate POST data with provided data
* @private
* @ignore
* @param {string} command - The write command name
* @param {Object} [value] - The value for the command
* @param {boolean} [multiple] - Used to delete multiple
* @returns {Object} Write data object
*/
__write_data(command, value = undefined, multiple = false) {
const obj = {
token: writeToken(),
collection: this.collectionName,
command: command,
};
// clone/serialize data if possible (prevents mutating data)
if (value) value = JSON.parse(JSON.stringify(value));
if (multiple && Array.isArray(value)) {
value.forEach((v) => {
if (typeof v === "object" && !Array.isArray(v) && v != null) delete v[ID_FIELD_NAME];
});
} else if (
multiple === false &&
value !== null &&
value !== undefined &&
typeof value !== "number" &&
typeof value !== "string" &&
!Array.isArray(value)
) {
if (typeof value === "object") value = { ...value };
delete value[ID_FIELD_NAME];
}
if (value) {
if (multiple) obj.values = value;
else obj.value = value;
}
return obj;
}
/**
* Get the sha1 hash of the JSON
* - Can be used to compare file content without downloading the file
* @returns {string} The sha1 hash of the file
*/
sha1() {
// string value is correct so we don't need validation
return this.__get_request("sha1", {}, false);
}
/**
* Get an element from the collection by its key
* @param {string | number} key - Key to search
* @returns {Promise<T>} The found element
*/
get(key) {
return this.__get_request("get", {
id: key,
}).then((res) => {
const firstKey = Object.keys(res)[0];
res[firstKey][ID_FIELD_NAME] = firstKey;
res = res[firstKey];
return this.__add_methods(res, false);
});
}
/**
* Get multiple elements from the collection by their keys
* @param {string[] | number[]} keys - Array of keys to search
* @returns {Promise<T[]>} The found elements
*/
searchKeys(keys) {
if (!Array.isArray(keys)) return Promise.reject(new TypeError("Incorrect keys"));
return this.__get_request("searchKeys", {
search: keys,
}).then((res) => {
const arr = Object.entries(res).map(([id, value]) => {
value[ID_FIELD_NAME] = id;
return value;
});
return this.__add_methods(arr);
});
}
/**
* Search through the collection
* @param {SearchOption[]} options - Array of search options
* @param {boolean | number} [random] - Random result seed, disabled by default, but can activated with true or a given seed
* @returns {Promise<T[]>} The found elements
*/
search(options, random = false) {
if (!Array.isArray(options))
return Promise.reject(new TypeError("searchOptions shall be an array"));
options.forEach((option) => {
if (option.field === undefined || option.criteria === undefined || option.value === undefined)
return Promise.reject(new TypeError("Missing fields in searchOptions array"));
if (typeof option.field !== "string")
return Promise.reject(
new TypeError(`${JSON.stringify(option)} search option field is not a string`),
);
if (option.criteria == "in" && !Array.isArray(option.value))
return Promise.reject(new TypeError("in takes an array of values"));
// TODO: add more strict value field warnings in JS and PHP
});
const params = {
search: options,
};
if (random !== false) {
if (random === true) {
params.random = {};
} else {
const seed = parseInt(random);
if (isNaN(seed))
return Promise.reject(
new TypeError("random takes as parameter true, false or an integer value"),
);
params.random = { seed };
}
}
return this.__get_request("search", params).then((res) => {
const arr = Object.entries(res).map(([id, value]) => {
value[ID_FIELD_NAME] = id;
return value;
});
return this.__add_methods(arr);
});
}
/**
* Read the entire collection
* @param {boolean} [original] - Disable ID field injection for easier iteration (default false)
* @returns {Promise<Record<string, T>>} The entire collection
*/
readRaw(original = false) {
return this.__get_request("read_raw").then((data) => {
if (original) return this.__add_methods(data);
// preserve as object
Object.keys(data).forEach((key) => {
data[key][ID_FIELD_NAME] = key;
});
return this.__add_methods(data);
});
}
/**
* Read the entire collection
* - ID values are injected for easier iteration, so this may be different from {@link sha1}
* @deprecated Use {@link readRaw} instead
* @returns {Promise<Record<string, T>>} The entire collection
*/
read_raw() {
return this.readRaw();
}
/**
* Get only selected fields from the collection
* - Essentially an upgraded version of {@link readRaw}
* @param {SelectOption} option - The fields you want to select
* @returns {Promise<Record<string, Partial<T>>>} Selected fields
*/
select(option) {
if (!option) option = {};
return this.__get_request("select", {
select: option,
}).then((data) => {
Object.keys(data).forEach((key) => {
data[key][ID_FIELD_NAME] = key;
});
return this.__add_methods(data);
});
}
/**
* Get all distinct non-null values for a given key across a collection
* @param {ValueOption} option - Value options
* @returns {Promise<T[]>} Array of unique values
*/
values(option) {
if (!option) return Promise.reject(new TypeError("Value option must be provided"));
if (typeof option.field !== "string")
return Promise.reject(new TypeError("Field must be a string"));
if (option.flatten !== undefined && typeof option.flatten !== "boolean")
return Promise.reject(new TypeError("Flatten must be a boolean"));
return this.__get_request("values", {
values: option,
}).then((data) =>
// no ID_FIELD or method injection since no ids are returned
Object.values(data).filter((d) => d !== null),
);
}
/**
* Read random elements of the collection
* @param {number} max - The maximum number of entries
* @param {number} seed - The seed to use
* @param {number} offset - The offset to use
* @returns {Promise<T[]>} The found elements
*/
random(max, seed, offset) {
const params = {};
if (max !== undefined) {
if (typeof max !== "number" || !Number.isInteger(max) || max < -1)
return Promise.reject(new TypeError("Expected integer >= -1 for the max"));
params.max = max;
}
const hasSeed = seed !== undefined;
const hasOffset = offset !== undefined;
if (hasOffset && !hasSeed)
return Promise.reject(new TypeError("You can't put an offset without a seed"));
if (hasOffset && (typeof offset !== "number" || !Number.isInteger(offset) || offset < 0))
return Promise.reject(new TypeError("Expected integer >= -1 for the max"));
if (hasSeed) {
if (typeof seed !== "number" || !Number.isInteger(seed))
return Promise.reject(new TypeError("Expected integer for the seed"));
if (!hasOffset) offset = 0;
params.seed = seed;
params.offset = offset;
}
return this.__get_request("random", {
random: params,
}).then((data) => {
Object.keys(data).forEach((key) => {
data[key][ID_FIELD_NAME] = key;
});
return this.__add_methods(data);
});
}
/**
* Set the entire content of the collection.
* - Only use this method if you know what you are doing!
* @param {Record<string, T>} value - The value to write
* @returns {Promise<WriteConfirmation>} Write confirmation
*/
writeRaw(value) {
if (value === undefined || value === null)
return Promise.reject(new TypeError("writeRaw value must not be undefined or null"));
return this.__extract_data(axios.post(writeAddress(), this.__write_data("write_raw", value)));
}
/**
* Set the entire content of the collection.
* - Only use this method if you know what you are doing!
* @deprecated Use {@link writeRaw} instead
* @param {Record<string, T>} value - The value to write
* @returns {Promise<WriteConfirmation>} Write confirmation
*/
write_raw(value) {
return this.writeRaw(value);
}
/**
* Append a value to the collection
* - Only works if autoKey is enabled server-side
* @param {T} value - The value (without methods) to add
* @returns {Promise<string>} The generated key of the added element
*/
add(value) {
return axios
.post(writeAddress(), this.__write_data("add", value))
.then((res) => this.__extract_data(res))
.then((res) => {
if (typeof res != "object" || !("id" in res) || typeof res.id != "string")
return Promise.reject(res);
return res.id;
});
}
/**
* Append multiple values to the collection
* - Only works if autoKey is enabled server-side
* @param {T[]} values - The values (without methods) to add
* @returns {Promise<string[]>} The generated keys of the added elements
*/
addBulk(values) {
return this.__extract_data(
axios.post(writeAddress(), this.__write_data("addBulk", values, true)),
).then((res) => res.ids);
}
/**
* Remove an element from the collection by its key
* @param {string | number} key The key from the entry to remove
* @returns {Promise<WriteConfirmation>} Write confirmation
*/
remove(key) {
return this.__extract_data(axios.post(writeAddress(), this.__write_data("remove", key)));
}
/**
* Remove multiple elements from the collection by their keys
* @param {string[] | number[]} keys The key from the entries to remove
* @returns {Promise<WriteConfirmation>} Write confirmation
*/
removeBulk(keys) {
return this.__extract_data(axios.post(writeAddress(), this.__write_data("removeBulk", keys)));
}
/**
* Set a value in the collection by key
* @param {string} key - The key of the element you want to edit
* @param {T} value - The value (without methods) you want to edit
* @returns {Promise<WriteConfirmation>} Write confirmation
*/
set(key, value) {
const data = this.__write_data("set", value);
data["key"] = key;
return this.__extract_data(axios.post(writeAddress(), data));
}
/**
* Set multiple values in the collection by their keys
* @param {string[]} keys - The keys of the elements you want to edit
* @param {T[]} values - The values (without methods) you want to edit
* @returns {Promise<WriteConfirmation>} Write confirmation
*/
setBulk(keys, values) {
const data = this.__write_data("setBulk", values, true);
data["keys"] = keys;
return this.__extract_data(axios.post(writeAddress(), data));
}
/**
* Edit an element's field in the collection
* @param {EditFieldOption} option - The edit object
* @returns {Promise<WriteConfirmation>} Edit confirmation
*/
editField(option) {
const data = this.__write_data("editField", option, null);
return this.__extract_data(axios.post(writeAddress(), data));
}
/**
* Edit multiple elements' fields in the collection
* @param {EditFieldOption[]} options - The edit objects
* @returns {Promise<WriteConfirmation>} Edit confirmation
*/
editFieldBulk(options) {
const data = this.__write_data("editFieldBulk", options, undefined);
return this.__extract_data(axios.post(writeAddress(), data));
}
}
/**
* @namespace firestorm
*/
const firestorm = {
/**
* Change or get the current Firestorm address
* @param {string} [newValue] - The new Firestorm address
* @returns {string} The stored Firestorm address
*/
address(newValue = undefined) {
if (newValue === undefined) return readAddress();
if (!newValue.endsWith("/")) newValue += "/";
if (newValue) _address = newValue;
return _address;
},
/**
* Change or get the current Firestorm token
* @param {string} [newValue] - The new Firestorm write token
* @returns {string} The stored Firestorm write token
*/
token(newValue = undefined) {
if (newValue === undefined) return writeToken();
if (newValue) _token = newValue;
return _token;
},
/**
* Create a new Firestorm collection instance
* @template T
* @param {string} name - The name of the collection
* @param {Function} [addMethods] - Additional methods and data to add to the objects
* @returns {Collection<T>} The collection instance
*/
collection(name, addMethods = (el) => el) {
return new Collection(name, addMethods);
},
/**
* Create a temporary Firestorm collection with no methods
* @deprecated Use {@link collection} with no second argument instead
* @template T
* @param {string} name - The table name to get
* @returns {Collection<T>} The table instance
*/
table(name) {
return this.collection(name);
},
/** Value for the ID field when searching content */
ID_FIELD: ID_FIELD_NAME,
/**
* Firestorm file handler
* @memberof firestorm
* @type {Object}
* @namespace firestorm.files
*/
files: {
/**
* Get a file by its path
* @memberof firestorm.files
* @param {string} path - The wanted file path
* @returns {Promise<any>} File contents
*/
get(path) {
return __extract_data(
axios.get(fileAddress(), {
params: {
path,
},
}),
);
},
/**
* Upload a file
* @memberof firestorm.files
* @param {FormData} form - Form data with path, filename, and file
* @returns {Promise<WriteConfirmation>} Write confirmation
*/
upload(form) {
form.append("token", firestorm.token());
return __extract_data(
axios.post(fileAddress(), form, {
headers: {
...form.getHeaders(),
},
}),
);
},
/**
* Delete a file by its path
* @memberof firestorm.files
* @param {string} path - The file path to delete
* @returns {Promise<WriteConfirmation>} Write confirmation
*/
delete(path) {
return __extract_data(
axios.delete(fileAddress(), {
data: {
path,
token: firestorm.token(),
},
}),
);
},
},
};
// browser check
try {
if (IS_NODE) module.exports = firestorm;
} catch {}