durable-objects-nosql
Version:
MongoDB-style NoSQL interface for Cloudflare Worker Durable Objects
393 lines (392 loc) • 11.3 kB
JavaScript
// src/index.ts
var CursorImpl = class {
documents = [];
_limit;
_skip;
_sort;
constructor(documents) {
this.documents = documents;
}
async toArray() {
let result = [...this.documents];
if (this._sort) {
result.sort((a, b) => {
for (const [key, direction] of Object.entries(this._sort)) {
const valueA = getNestedProperty(a, key);
const valueB = getNestedProperty(b, key);
if (valueA < valueB) return -1 * direction;
if (valueA > valueB) return 1 * direction;
}
return 0;
});
}
if (this._skip) {
result = result.slice(this._skip);
}
if (this._limit) {
result = result.slice(0, this._limit);
}
return result;
}
async first() {
const results = await this.toArray();
return results.length > 0 ? results[0] : null;
}
async count() {
return this.documents.length;
}
limit(n) {
this._limit = n;
return this;
}
skip(n) {
this._skip = n;
return this;
}
sort(sortSpec) {
this._sort = sortSpec;
return this;
}
};
var CollectionImpl = class {
storage;
collectionName;
constructor(storage, collectionName) {
this.storage = storage;
this.collectionName = collectionName;
}
/**
* Find documents matching a query
*/
find(query = {}, options = {}) {
const cursor = new CursorImpl([]);
cursor._execute = async () => {
const documents = await this.getAllDocuments();
return documents.filter((doc) => this.matchesQuery(doc, query));
};
const originalToArray = cursor.toArray;
cursor.toArray = async () => {
if (!cursor._documents || cursor._documents.length === 0) {
;
cursor.documents = await cursor._execute();
}
return originalToArray.call(cursor);
};
return cursor;
}
/**
* Find a single document matching a query
*/
async findOne(query = {}) {
const cursor = await this.find(query);
return cursor.first();
}
/**
* Insert a single document
*/
async insertOne(document) {
const id = crypto.randomUUID();
const docWithId = { ...document, _id: id };
await this.storage.sql.exec(`INSERT INTO documents (id, collection, data) VALUES (?, ?, ?)`, id, this.collectionName, JSON.stringify(docWithId));
return { id };
}
/**
* Insert multiple documents
*/
async insertMany(documents) {
const ids = [];
await this.storage.transaction(async (txn) => {
for (const document of documents) {
const id = crypto.randomUUID();
const docWithId = { ...document, _id: id };
await this.storage.sql.exec(
`INSERT INTO documents (id, collection, data) VALUES (?, ?, ?)`,
id,
this.collectionName,
JSON.stringify(docWithId)
);
ids.push(id);
}
});
return { ids };
}
/**
* Update a single document matching a query
*/
async updateOne(query, update) {
const document = await this.findOne(query);
if (!document) {
return { matchedCount: 0, modifiedCount: 0 };
}
const id = document._id;
const updatedDocument = this.applyUpdate(document, update);
await this.storage.sql.exec(`UPDATE documents SET data = ? WHERE id = ? AND collection = ?`, JSON.stringify(updatedDocument), id, this.collectionName);
return { matchedCount: 1, modifiedCount: 1 };
}
/**
* Update multiple documents matching a query
*/
async updateMany(query, update) {
const cursor = this.find(query);
const documents = await cursor.toArray();
if (documents.length === 0) {
return { matchedCount: 0, modifiedCount: 0 };
}
let modifiedCount = 0;
await this.storage.transaction(async (txn) => {
for (const document of documents) {
const id = document._id;
const updatedDocument = this.applyUpdate(document, update);
await this.storage.sql.exec(
`UPDATE documents SET data = ? WHERE id = ? AND collection = ?`,
JSON.stringify(updatedDocument),
id,
this.collectionName
);
modifiedCount++;
}
});
return { matchedCount: documents.length, modifiedCount };
}
/**
* Delete a single document matching a query
*/
async deleteOne(query) {
const document = await this.findOne(query);
if (!document) {
return { deletedCount: 0 };
}
const id = document._id;
await this.storage.sql.exec(`DELETE FROM documents WHERE id = ? AND collection = ?`, id, this.collectionName);
return { deletedCount: 1 };
}
/**
* Delete multiple documents matching a query
*/
async deleteMany(query) {
const cursor = this.find(query);
const documents = await cursor.toArray();
if (documents.length === 0) {
return { deletedCount: 0 };
}
let deletedCount = 0;
await this.storage.transaction(async (txn) => {
for (const document of documents) {
const id = document._id;
await this.storage.sql.exec(
`DELETE FROM documents WHERE id = ? AND collection = ?`,
id,
this.collectionName
);
deletedCount++;
}
});
return { deletedCount };
}
/**
* Get all documents in the collection
*/
async getAllDocuments() {
const cursor = this.storage.sql.exec(`SELECT * FROM documents WHERE collection = ?`, this.collectionName);
const documents = [];
let result = cursor.next();
while (!result.done && result.value) {
documents.push(JSON.parse(result.value.data));
result = cursor.next();
}
return documents;
}
/**
* Check if a document matches a query
*/
matchesQuery(document, query) {
for (const [key, value] of Object.entries(query)) {
if (key.startsWith("$")) {
switch (key) {
case "$and":
if (!Array.isArray(value)) return false;
if (!value.every((subQuery) => this.matchesQuery(document, subQuery))) return false;
break;
case "$or":
if (!Array.isArray(value)) return false;
if (!value.some((subQuery) => this.matchesQuery(document, subQuery))) return false;
break;
default:
return false;
}
} else {
const docValue = getNestedProperty(document, key);
if (value !== null && typeof value === "object") {
for (const [op, opValue] of Object.entries(value)) {
if (!this.matchesOperator(docValue, op, opValue)) return false;
}
} else if (!this.matchesOperator(docValue, "$eq", value)) {
return false;
}
}
}
return true;
}
/**
* Check if a value matches an operator condition
*/
matchesOperator(value, operator, operatorValue) {
switch (operator) {
case "$eq":
return value === operatorValue;
case "$gt":
return value > operatorValue;
case "$gte":
return value >= operatorValue;
case "$lt":
return value < operatorValue;
case "$lte":
return value <= operatorValue;
case "$ne":
return value !== operatorValue;
case "$in":
return Array.isArray(operatorValue) && operatorValue.includes(value);
case "$nin":
return Array.isArray(operatorValue) && !operatorValue.includes(value);
case "$not":
return !this.matchesQuery({ value }, { value: operatorValue });
default:
return false;
}
}
/**
* Apply an update to a document
*/
applyUpdate(document, update) {
const result = { ...document };
for (const [operator, fields] of Object.entries(update)) {
switch (operator) {
case "$set":
for (const [field, value] of Object.entries(fields)) {
setNestedProperty(result, field, value);
}
break;
case "$unset":
for (const field of Object.keys(fields)) {
deleteNestedProperty(result, field);
}
break;
case "$inc":
for (const [field, value] of Object.entries(fields)) {
const currentValue = getNestedProperty(result, field) || 0;
setNestedProperty(result, field, currentValue + value);
}
break;
case "$push":
for (const [field, value] of Object.entries(fields)) {
const currentValue = getNestedProperty(result, field) || [];
if (!Array.isArray(currentValue)) {
throw new Error(`Cannot apply $push to non-array field: ${field}`);
}
setNestedProperty(result, field, [...currentValue, value]);
}
break;
case "$pull":
for (const [field, value] of Object.entries(fields)) {
const currentValue = getNestedProperty(result, field);
if (!Array.isArray(currentValue)) {
throw new Error(`Cannot apply $pull to non-array field: ${field}`);
}
setNestedProperty(
result,
field,
currentValue.filter((item) => !this.matchesQuery({ item }, { item: value }))
);
}
break;
}
}
return result;
}
};
var DurableObjectsNoSQL = class {
storage;
collections = /* @__PURE__ */ new Map();
constructor(storage) {
this.storage = storage;
this.initializeDatabase();
}
/**
* Get a collection by name
*/
collection(name) {
if (!this.collections.has(name)) {
this.collections.set(name, new CollectionImpl(this.storage, name));
}
return this.collections.get(name);
}
/**
* Initialize the database schema
*/
async initializeDatabase() {
this.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
collection TEXT NOT NULL,
data TEXT NOT NULL
)
`);
this.storage.sql.exec(`
CREATE INDEX IF NOT EXISTS idx_collection ON documents (collection)
`);
}
};
var handler = {
get(target, prop) {
if (prop in target) {
return target[prop];
}
return target.collection(prop);
}
};
function createNoSQLClient(storage) {
const client = new DurableObjectsNoSQL(storage);
return new Proxy(client, handler);
}
function getNestedProperty(obj, path) {
const parts = path.split(".");
let current = obj;
for (const part of parts) {
if (current === null || current === void 0) {
return void 0;
}
current = current[part];
}
return current;
}
function setNestedProperty(obj, path, value) {
const parts = path.split(".");
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!(part in current)) {
current[part] = {};
}
current = current[part];
}
current[parts[parts.length - 1]] = value;
}
function deleteNestedProperty(obj, path) {
const parts = path.split(".");
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!(part in current)) {
return;
}
current = current[part];
}
delete current[parts[parts.length - 1]];
}
var index_default = createNoSQLClient;
export {
DurableObjectsNoSQL,
createNoSQLClient,
index_default as default,
handler
};