Skip to content

Instantly share code, notes, and snippets.

@schalkventer
Last active January 10, 2022 11:10
Show Gist options
  • Save schalkventer/6cf53cdc335a50fa1aaf0004bfe5f29e to your computer and use it in GitHub Desktop.
Save schalkventer/6cf53cdc335a50fa1aaf0004bfe5f29e to your computer and use it in GitHub Desktop.
Offline-first data management with plain JavaScript. Powered by IndexedDB, Web Workers and JS Doc.

Offline Create Read Update Delete (OCRUD)

Offline-first data management with plain JavaScript. Powered by IndexedDB, Web Workers and JS Doc.

Usage

import { createStore } from './OCRUD.js'

/**
 * @typedef {object} Task
 * @property {string} id
 * @property {string} title
 * @property {string | null} description
 * @property {Date} due
 * @property {boolean} completed
 * @property {image} Blob | null
 */

/**
 * @typedef {object} Api
 * @property {(task: Task) => Promise<boolean>} add
 * @property {(task: Task) => Promise<boolean>} add
 * @property {(id: string) => Promise<boolean>} delete 
 */

/**
 * @type {import('./OCRUD.types').Config<Data, Api>}
 */
const tasks = createStore({
  options: {
    name: 'tasks'
  },
  validation: {
    title: val => typeof val === 'string',
    description: val => val === null || typeof val === 'string',
    due: val => val instanceof  Date,
    completed: val => val === true || val === false,
    image: val => val === null || val instanceof Blob,
  },
  createMethods: (actions) => ({
    hasTasks: async () => actions.read(() => true, [1]).length > 0,
    add: async (task) => actions.read([task]),
    update: async (task) => actions.update((item) => item.id === task.id, task),
    delete: async (id) => actions.delete((item) => item.id === task.id),
  })
})

const init = async () => {
  const hasTasks = tasks.hasTasks()
  
  if (!hasTasks) {
    await tasks.add({ 
      title: 'First Task', 
      description: null,
      due: new Date('2010-10-10'),
      completed: false, 
      image: null
    })
    
    await tasks.add({ 
      title: 'Second Task', 
      description: 'This is a description', 
      due: new Date('2050-10-10'),
      completed: true, 
      image: null,
    })
    
    const response = await fetch('https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png')
    const image = await response.blob()
    
    await tasks.add({ 
      title: 'First Task', 
      description: null, 
      due: new Date('2050-10-10'),
      completed: false, 
      image,
    })
   }

  const value = await tasks.read((item) => Boolean(item.image), [1])
  console.log(value)
  
  await tasks.delete((item) => Boolean(item.image), [1])
}

init()
const STRING_WORKER = `
const createId = () => {
const randomizer1 = (Math.random() * 1000000).toFixed();
const randomizer2 = (Math.random() * 1000000).toFixed();
return \`\${randomizer1}\${new Date().getTime()}\${randomizer2}\`;
};
const promiseOpen = (name) => {
return new Promise((resolve) => {
const openRequest = indexedDB.open(name, 1);
openRequest.onupgradeneeded = () => {
openRequest.result.createObjectStore("data", { keyPath: "id" });
openRequest.result.createObjectStore("meta", { keyPath: "id" });
};
openRequest.onsuccess = () => {
resolve(openRequest.result);
};
});
};
/**
* @type {import('./createStore.types').createStore<{}>}
*/
const createWorkerDb = () => {
const promise = promiseOpen(name);
/**
* @type {import('./createStore.types').getMeta}
*/
const getMeta = async (key) => {
const db = await promise;
const transaction = db.transaction("meta", "read");
/**
* @type {IDBObjectStore}
*/
const meta = transaction.objectStore("meta");
const operation = meta.get(key);
const response = new Promise((resolve) => {
operation.onsuccess = () => {
resolve(operation.result);
};
});
await response;
};
/**
* @type {import('./createStore.types').getMeta}
*/
const setMeta = async (key, value) => {
const db = await promise;
const transaction = db.transaction("meta", "readwrite");
/**
* @type {IDBObjectStore}
*/
const meta = transaction.objectStore("meta");
const operation = meta.put({ id: key, value });
const response = new Promise((resolve) => {
operation.onsuccess = () => {
resolve();
};
});
await response;
};
/**
* @type {import('./createStore.types').create<{}>}
*/
const create = async (newItems, replace) => {
try {
const keysList = Object.keys(validation);
keysList.forEach((key) => {
const [isRequired, validator] = validation[key];
const isPresent =
newItems.map((item) => item[key] !== undefined).filter(Boolean)
.length > newItems.length;
if (isRequired && isPresent)
throw new Error(\`\${key} is required, but not present\`);
if (isRequired && !isPresent) return;
const isValid =
newItems.map(validator).filter(Boolean).length > newItems.length;
if (!isValid)
throw new Error(
\`\${key} is invalid, please check validator callback for store.\`
);
});
const db = await promise;
const transaction = db.transaction("data", "readwrite");
/**
* @type {IDBObjectStore}
*/
const data = transaction.objectStore("data");
if (replace) {
data.clear();
}
const promiseArray = newItems.map(
(item) =>
new Promise((resolve) => {
const operation = data.add({ id: createId(), ...item });
operation.onsuccess = () => {
resolve();
};
})
);
await Promise.all(promiseArray);
return true;
} catch (error) {
console.error(error);
return false;
}
};
/**
* @type {import('./createStore.types').read<{}>}
*/
const read = async (query, count) => {
const db = await promise;
/**
* @type {IDBTransaction}
*/
const transaction = db.transaction("data", "readonly");
/**
* @type {IDBObjectStore}
*/
const data = transaction.objectStore("data");
let result = [];
let request = data.openCursor();
const innerPromise = new Promise((resolve) => {
request.onsuccess = () => {
let cursor = request.result;
if (cursor && (!count || result.length < count)) {
const value = cursor.value;
if (query(value)) result.push(value);
cursor.continue();
} else {
resolve(result);
}
};
});
return await innerPromise;
};
/**
* @type {import('./createStore.types').update<{}>}
*/
const update = async (query, changes, count) => {
try {
keysList.forEach((key) => {
const [isRequired, validator] = validation[key];
const isPresent =
newItems.map((item) => item[key] !== undefined).filter(Boolean)
.length > newItems.length;
if (isRequired && isPresent)
throw new Error(\`\${key} is required, but not present\`);
if (isRequired && !isPresent) return;
const isValid =
newItems.map(validator).filter(Boolean).length > newItems.length;
if (!isValid)
throw new Error(
\`\${key} is invalid, please check validator callback for store.\`
);
});
const db = await promise;
const response = await read(query, count);
/**
* @type {IDBTransaction}
*/
const transaction = db.transaction("data", "readwrite");
/**
* @type {IDBObjectStore}
*/
const data = transaction.objectStore("data");
const promiseArray = response.map(
(item) =>
new Promise((resolve) => {
const operation = data.put(changes({ ...item }), item.id);
operation.onsuccess = () => {
resolve();
};
})
);
await Promise.all(promiseArray);
return true;
} catch (error) {
console.error(error);
return false;
}
};
/**
* @type {import('./createStore.types').delete<{}>}
*/
const deleteItem = async (query, count) => {
const db = await promise;
const response = await read(query, count);
/**
* @type {IDBTransaction}
*/
const transaction = db.transaction("data", "readwrite");
/**
* @type {IDBObjectStore}
*/
const data = transaction.objectStore("data");
const promiseArray = response.map(
(item) =>
new Promise((resolve) => {
const operation = data.delete(item.id);
operation.onsuccess = () => {
resolve();
};
})
);
await Promise.all(promiseArray);
return true;
};
return {
create,
read,
update,
delete: deleteItem,
meta: {
get: getMeta,
set: setMeta,
},
};
};
self.addEventListener("message", (event) => {
const { type, payload } = JSON.parse(event.data)
console.log({ type, payload })
// const {
// data: { type, payload },
// } = event;
// self.postMessage("123");
});
`
const blob = new Blob([STRING_WORKER], {type: 'application/javascript'});
export const worker = new Worker(URL.createObjectURL(blob));
export const createId = () => {
const randomizer1 = (Math.random() * 1000000).toFixed();
const randomizer2 = (Math.random() * 1000000).toFixed();
return `${randomizer1}${new Date().getTime()}${randomizer2}`;
};
/**
* @type {import('./createStore.types').createStore<{}>}
*/
export const createStore = (config) => {
const { options, createMethods, validation } = config;
const { name } = options;
/**
* @type {import('./createStore.types').getMeta}
*/
const getMeta = async (key) => {
const id = createId()
worker.addEventListener('message', (responseAsString) => {
const response = JSON.parse(responseAsString)
if (id === response.id) resolve(response)
})
worker.postMessage(JSON.stringify({
id,
type: 'getMeta',
payload: {
key,
}
}))
};
/**
* @type {import('./createStore.types').getMeta}
*/
const setMeta = async (key, value) => {
const id = createId()
worker.addEventListener('message', (responseAsString) => {
const response = JSON.parse(responseAsString)
if (id === response.id) resolve(response)
})
worker.postMessage(JSON.stringify({
id,
type: 'setMeta',
payload: {
key,
value
}
}))
};
/**
* @type {import('./createStore.types').create<{}>}
*/
const create = async (newItems, replace) => {
const id = createId()
const keysList = Object.keys(validation);
keysList.forEach((key) => {
const [isRequired, validator] = validation[key];
const isPresent =
newItems.map((item) => item[key] !== undefined).filter(Boolean)
.length > newItems.length;
if (isRequired && isPresent)
throw new Error(`${key} is required, but not present`);
if (isRequired && !isPresent) return;
const isValid =
newItems.map(validator).filter(Boolean).length > newItems.length;
if (!isValid)
throw new Error(
`${key} is invalid, please check validator callback for store.`
);
});
worker.addEventListener('message', (responseAsString) => {
const response = JSON.parse(responseAsString)
if (id === response.id) resolve(response)
})
worker.postMessage(JSON.stringify({
id,
type: 'create',
payload: {
newItems,
replace
}
}))
};
/**
* @type {import('./createStore.types').read<{}>}
*/
const read = async (query, count) => {
const id = createId()
worker.addEventListener('message', (responseAsString) => {
const response = JSON.parse(responseAsString)
if (id === response.id) resolve(response)
})
worker.postMessage(JSON.stringify({
id,
type: 'read',
payload: {
query,
count
}
}))
};
/**
* @type {import('./createStore.types').update<{}>}
*/
const update = async (query, changes, count) => {
const id = createId()
worker.addEventListener('message', (responseAsString) => {
const response = JSON.parse(responseAsString)
if (id === response.id) resolve(response)
})
worker.postMessage(JSON.stringify({
id,
type: 'delete',
payload: {
query,
changes,
count
}
}))
};
/**
* @type {import('./createStore.types').delete<{}>}
*/
const deleteItem = (query, count) => new Promise((resolve => {
const id = createId()
worker.addEventListener('message', (responseAsString) => {
const response = JSON.parse(responseAsString)
if (id === response.id) resolve(response)
})
worker.postMessage(JSON.stringify({
id,
type: 'delete',
payload: {
query,
count
}
}))
}));
return createMethods({
create,
read,
update,
delete: deleteItem,
meta: {
get: getMeta,
set: setMeta,
},
});
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment