Split attachmentProxy into its own package
This commit is contained in:
commit
a2cd9284f7
79
packages/pouchdb-attachmentproxy/README.md
Normal file
79
packages/pouchdb-attachmentproxy/README.md
Normal file
@ -0,0 +1,79 @@
|
||||
# PouchDB Attachment Proxy
|
||||
|
||||
PouchDB Attachment Proxy is a PouchDB plugin that intercepts and allows you to redirect document
|
||||
attachments in a PouchDB database. The intention is to use the PouchDB API to manage all attachment
|
||||
metadata without having the raw data bogging down your PouchDB database.
|
||||
|
||||
## Overview
|
||||
|
||||
Attachment Proxy works by intercepting attachments bound for the pouchdb backend and passing them to
|
||||
a custom `save` function that will return a sentry blob to be saved in the pouchdb document.
|
||||
|
||||
When an attachment is requested, the sentry blob will be provided to a custom `getFn` function to be
|
||||
converted into desired document.
|
||||
|
||||
Similar to `getFn`, the sentry blob is provided to `remove` when an attachment or its containing
|
||||
document is deleted.
|
||||
|
||||
Each function is expected to be asynchronous and the database will not continue its operation until
|
||||
the proxy function completes to ensure data integrity. It may be desirable to for the `remove`
|
||||
function to delay attachment cleanup. The proxy blob will be removed from document (effectively
|
||||
orphaning the proxied attachment until it is cleaned).
|
||||
|
||||
## Example
|
||||
|
||||
To create an attachment proxy, supply an object to the proxy wrapper with the three handler
|
||||
functions.
|
||||
|
||||
```js
|
||||
import {
|
||||
PouchDBAttachmentProxy,
|
||||
SENTRY_MIMETYPE,
|
||||
blobToString,
|
||||
stringToBlob
|
||||
} from 'pouchdb-attachmentproxy';
|
||||
|
||||
const dataUrlToBlob = data => {
|
||||
const [header, b64str] = data.split(';base64,');
|
||||
const byteCharacters = atob(b64str);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (var i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
return new Blob([byteArray], { type: header.substr(5) });
|
||||
};
|
||||
|
||||
const LocalStorageExampleAdapter = PouchDBAttachmentProxy({
|
||||
getFn: async function getAttachment(blob) {
|
||||
const id = await blobToString(blob);
|
||||
return dataUrlToBlob(localStorage[id]);
|
||||
},
|
||||
remove: async function removeAttachment(blob) {
|
||||
const id = await blobToString(blob);
|
||||
delete localStorage[id];
|
||||
},
|
||||
save: async function saveAttachment(blob) {
|
||||
let id;
|
||||
while (!id || localStorage[id] !== undefined) {
|
||||
id = '' + Math.ceil(Math.random() * 100000);
|
||||
}
|
||||
// placeholder to prevent a race condition
|
||||
localStorage[id] = true;
|
||||
|
||||
return new Promise(resolve => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onloadend = function() {
|
||||
localStorage[id] = reader.result;
|
||||
// The returned blob must have the SENTRY_MIMETYPE mime_type in
|
||||
// order to be proxied to `getFn` and `remove`.
|
||||
resolve(stringToBlob(id, SENTRY_MIMETYPE));
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const ProxiedPouch = PouchDB.plugin(LocalStorageExampleAdapter);
|
||||
```
|
||||
27
packages/pouchdb-attachmentproxy/package.json
Normal file
27
packages/pouchdb-attachmentproxy/package.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "pouchdb-attachmentproxy",
|
||||
"version": "1.0.0",
|
||||
"description": "A pouchdb plugin to intercept and redirect document attachments.",
|
||||
"main": "src/index.js",
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "npm run build:test && mv dist/index.html ./.spec_runner.html && node ../../bin/runTests.js ./",
|
||||
"pre-commit": "npm run test",
|
||||
"build:test": "webpack --config webpack.test-config.js"
|
||||
},
|
||||
"author": "Timothy Farrell <tim@thecookiejar.me> (https://github.com/explorigin)",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"pouchdb-core": "~7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"html-webpack-plugin": "~3.2.0",
|
||||
"pouchdb": "~7.0.0",
|
||||
"pouchdb-adapter-memory": "^7.0.0",
|
||||
"script-ext-html-webpack-plugin": "^2.0.1",
|
||||
"webpack": "~4.10.2",
|
||||
"webpack-cli": "~2.1.5"
|
||||
}
|
||||
}
|
||||
15
packages/pouchdb-attachmentproxy/spec/index.html
Normal file
15
packages/pouchdb-attachmentproxy/spec/index.html
Normal file
@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Jasmine Spec Runner v3.1.0</title>
|
||||
|
||||
<link rel="shortcut icon" type="image/png" href="/vendor/jasmine/jasmine_favicon.png">
|
||||
<link rel="stylesheet" href="/vendor/jasmine/jasmine.css">
|
||||
|
||||
<script src="/vendor/jasmine/jasmine.js"></script>
|
||||
<script src="/vendor/jasmine/jasmine-html.js"></script>
|
||||
<script defer src="/vendor/jasmine/boot.js"></script>
|
||||
</head><body></body>
|
||||
</html>
|
||||
|
||||
230
packages/pouchdb-attachmentproxy/spec/proxy.spec.js
Normal file
230
packages/pouchdb-attachmentproxy/spec/proxy.spec.js
Normal file
@ -0,0 +1,230 @@
|
||||
import PouchDB from 'pouchdb';
|
||||
import memory from 'pouchdb-adapter-memory';
|
||||
import {
|
||||
PouchDBAttachmentProxy,
|
||||
SENTRY_MIMETYPE,
|
||||
blobToString,
|
||||
stringToBlob
|
||||
} from '../src/index.js';
|
||||
|
||||
const notDesignDocs = d => !d.id.startsWith('_design');
|
||||
|
||||
const dataUrlToBlob = data => {
|
||||
const [header, b64str, ...nothing] = data.split(';base64,');
|
||||
const byteCharacters = atob(b64str);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (var i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
return new Blob([byteArray], { type: header.substr(5) });
|
||||
};
|
||||
|
||||
describe('PouchDB Attachment Proxy', () => {
|
||||
let db;
|
||||
let wrap = true;
|
||||
|
||||
const LocalStorageExampleAdapter = PouchDBAttachmentProxy({
|
||||
getFn: async function getAttachment(blob) {
|
||||
if (!wrap) {
|
||||
return blob;
|
||||
}
|
||||
const id = await blobToString(blob);
|
||||
return dataUrlToBlob(localStorage[id]);
|
||||
},
|
||||
remove: async function removeAttachment(blob) {
|
||||
if (!wrap) {
|
||||
return blob;
|
||||
}
|
||||
const id = await blobToString(blob);
|
||||
delete localStorage[id];
|
||||
},
|
||||
save: async function saveAttachment(blob) {
|
||||
if (!wrap) {
|
||||
return blob;
|
||||
}
|
||||
let id;
|
||||
while (!id || localStorage[id] !== undefined) {
|
||||
id = '' + Math.ceil(Math.random() * 100000);
|
||||
}
|
||||
localStorage[id] = true; // placeholder to prevent a race condition while the FileReader reads the blob.
|
||||
|
||||
return new Promise(resolve => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onloadend = function() {
|
||||
localStorage[id] = reader.result;
|
||||
resolve(stringToBlob(id));
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
});
|
||||
const PDB = PouchDB.plugin(memory).plugin(LocalStorageExampleAdapter);
|
||||
|
||||
beforeEach(() => {
|
||||
db = new PDB('pouchdb-test', { adapter: 'memory' });
|
||||
wrap = true;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const res = await db.allDocs();
|
||||
await Promise.all(res.rows.filter(notDesignDocs).map(d => db.remove(d.id, d.value.rev))).catch(
|
||||
() => {}
|
||||
);
|
||||
await db.compact();
|
||||
});
|
||||
|
||||
async function checkBlob(blob, data) {
|
||||
const blobData = await blobToString(blob);
|
||||
expect(blobData).toEqual(data);
|
||||
}
|
||||
|
||||
async function checkDocument(id, attName, mime_type, data) {
|
||||
wrap = false;
|
||||
const placeholderBlob = await db.getAttachment(id, attName);
|
||||
wrap = true;
|
||||
|
||||
const placeholder = await blobToString(placeholderBlob);
|
||||
|
||||
expect(placeholderBlob.type).toEqual(SENTRY_MIMETYPE);
|
||||
|
||||
const resultDataStr = localStorage[placeholder];
|
||||
const [header, b64str, ...nothing] = resultDataStr.split(';base64,');
|
||||
expect(header.substr(5)).toEqual(mime_type);
|
||||
expect(b64str).toEqual(btoa(data));
|
||||
return resultDataStr;
|
||||
}
|
||||
|
||||
describe('getFn()', () => {
|
||||
it('returns attachments when PouchDB.getAttachment() is called', async () => {
|
||||
const id = 'abc123';
|
||||
localStorage['abc123'] = `data:text/plain;base64,${btoa('Test3 document')}`;
|
||||
|
||||
wrap = false;
|
||||
await db.bulkDocs([
|
||||
{
|
||||
_id: 'test3',
|
||||
_attachments: {
|
||||
test3: {
|
||||
content_type: SENTRY_MIMETYPE,
|
||||
data: new Blob(['abc123'], { type: SENTRY_MIMETYPE })
|
||||
}
|
||||
}
|
||||
}
|
||||
]);
|
||||
wrap = true;
|
||||
|
||||
const blob = await db.getAttachment('test3', 'test3');
|
||||
checkBlob(blob, 'Test3 document');
|
||||
expect(blob.type).toEqual('text/plain');
|
||||
});
|
||||
});
|
||||
|
||||
describe('save()', () => {
|
||||
it('writes attachments in documents saved with PouchDB.put()', async () => {
|
||||
await db.put({
|
||||
_id: 'test1',
|
||||
_attachments: {
|
||||
test: {
|
||||
content_type: 'text/plain',
|
||||
data: new Blob(['Test document'], { type: 'text/plain' })
|
||||
}
|
||||
}
|
||||
});
|
||||
await checkDocument('test1', 'test', 'text/plain', 'Test document');
|
||||
});
|
||||
|
||||
it('writes attachments in documents saved with PouchDB.bulkDocs()', async () => {
|
||||
await db.bulkDocs([
|
||||
{
|
||||
_id: 'test2',
|
||||
_attachments: {
|
||||
test2: {
|
||||
content_type: 'text/plain',
|
||||
data: new Blob(['Test2 document'], { type: 'text/plain' })
|
||||
}
|
||||
}
|
||||
}
|
||||
]);
|
||||
await checkDocument('test2', 'test2', 'text/plain', 'Test2 document');
|
||||
});
|
||||
|
||||
it('writes attachments saved with PouchDB.putAttachment() ', async () => {
|
||||
const res = await db.put({
|
||||
_id: 'test5',
|
||||
hi: 'there'
|
||||
});
|
||||
|
||||
const res2 = await db.putAttachment(
|
||||
res.id,
|
||||
'test5',
|
||||
res.rev,
|
||||
new Blob(['Test5 document'], { type: 'text/plain' }),
|
||||
'text/plain'
|
||||
);
|
||||
await checkDocument('test5', 'test5', 'text/plain', 'Test5 document');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove()', () => {
|
||||
it('is called after PouchDB.removeAttachment()', async () => {
|
||||
await db.bulkDocs([
|
||||
{
|
||||
_id: 'test4',
|
||||
_attachments: {
|
||||
test4: {
|
||||
content_type: 'text/plain',
|
||||
data: new Blob(['Test4 document'], { type: 'text/plain' })
|
||||
}
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const doc = await db.get('test4', { attachments: true });
|
||||
expect(doc && doc._attachments && doc._attachments.test4).toBeTruthy();
|
||||
|
||||
wrap = false;
|
||||
const placeholderBlob = await db.getAttachment('test4', 'test4');
|
||||
wrap = true;
|
||||
|
||||
const placeholder = await blobToString(placeholderBlob);
|
||||
expect(placeholderBlob.type).toEqual(SENTRY_MIMETYPE);
|
||||
expect(localStorage[placeholder]).toBeTruthy();
|
||||
|
||||
await db.removeAttachment(doc._id, 'test4', doc._rev);
|
||||
|
||||
const doc2 = await db.get('test4', { attachments: true });
|
||||
expect(doc2._attachments).toBeFalsy();
|
||||
|
||||
expect(localStorage[placeholder]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is called for attachments of deleted documents', async () => {
|
||||
await db.bulkDocs([
|
||||
{
|
||||
_id: 'test6',
|
||||
_attachments: {
|
||||
test6: {
|
||||
content_type: 'text/plain',
|
||||
data: new Blob(['Test6 document'], { type: 'text/plain' })
|
||||
}
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
await checkDocument('test6', 'test6', 'text/plain', 'Test6 document');
|
||||
|
||||
wrap = false;
|
||||
const placeholderBlob = await db.getAttachment('test6', 'test6');
|
||||
wrap = true;
|
||||
const placeholder = await blobToString(placeholderBlob);
|
||||
|
||||
const doc = await db.get('test6');
|
||||
|
||||
await db.remove(doc._id, doc._rev);
|
||||
|
||||
expect(localStorage[placeholder]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
2
packages/pouchdb-attachmentproxy/src/index.js
Normal file
2
packages/pouchdb-attachmentproxy/src/index.js
Normal file
@ -0,0 +1,2 @@
|
||||
export { PouchDBAttachmentProxy } from './proxy.js';
|
||||
export { SENTRY_MIMETYPE, stringToBlob, blobToString } from './utils.js';
|
||||
97
packages/pouchdb-attachmentproxy/src/proxy.js
Normal file
97
packages/pouchdb-attachmentproxy/src/proxy.js
Normal file
@ -0,0 +1,97 @@
|
||||
import core from 'pouchdb-core';
|
||||
import { SENTRY_MIMETYPE } from './utils.js';
|
||||
|
||||
const pouchBulkDocs = core.prototype.bulkDocs;
|
||||
const pouchGetAttachment = core.prototype.getAttachment;
|
||||
const pouchRemoveAttachment = core.prototype.removeAttachment;
|
||||
|
||||
export function PouchDBAttachmentProxy({ save, getFn, remove }) {
|
||||
const override = {
|
||||
$$attachmentsProxied: true
|
||||
};
|
||||
|
||||
if (getFn) {
|
||||
override.getAttachment = async function getAttachment(...args) {
|
||||
const att = await pouchGetAttachment.apply(this, args);
|
||||
if (att.type !== SENTRY_MIMETYPE) {
|
||||
return att;
|
||||
}
|
||||
return await getFn(att);
|
||||
};
|
||||
}
|
||||
|
||||
if (remove && getFn) {
|
||||
override.removeAttachment = async function removeAttachment(docId, attName, rev) {
|
||||
const doc = await pouchGetAttachment.call(this, docId, attName);
|
||||
await cleanupFiles(doc);
|
||||
return await pouchRemoveAttachment.call(this, docId, attName, rev);
|
||||
};
|
||||
}
|
||||
|
||||
if (save || remove) {
|
||||
override.bulkDocs = function bulkDocs(...args) {
|
||||
const self = this;
|
||||
let docs;
|
||||
if (Array.isArray(args[0])) {
|
||||
docs = args[0];
|
||||
} else {
|
||||
docs = args[0].docs;
|
||||
}
|
||||
|
||||
// All documents must have a .name field.
|
||||
const doomedDocs = [];
|
||||
const attachments = [];
|
||||
docs.map(f => {
|
||||
if (f._deleted) {
|
||||
doomedDocs.push(f._attachments ? f : this.get(f._id, { attachments: true, binary: true }));
|
||||
return;
|
||||
}
|
||||
if (f._attachments) {
|
||||
Object.entries(f._attachments).forEach(([attName, att]) => {
|
||||
attachments.push([f, attName, att.data]);
|
||||
delete f._attachments[attName];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return handleBulkAttachments(attachments, doomedDocs).then(() =>
|
||||
pouchBulkDocs.call(self, ...args)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
async function handleBulkAttachments(saveableAttachments, doomedDocsAndPromises) {
|
||||
const self = this;
|
||||
const doomedDocs = await Promise.all(doomedDocsAndPromises);
|
||||
const doomedAttachments = doomedDocs
|
||||
.map(doc => Object.values(doc._attachments || {})) // extract attachments
|
||||
.reduce((acc, val) => acc.concat(val), []); // flatten
|
||||
await Promise.all(doomedAttachments.map(a => cleanupFiles.call(self, a.data)));
|
||||
|
||||
await Promise.all(
|
||||
saveableAttachments.map(async ([doc, attName, blob]) => {
|
||||
try {
|
||||
const proxyBlob = await save.call(self, blob);
|
||||
doc._attachments[attName] = {
|
||||
content_type: proxyBlob.type,
|
||||
data: proxyBlob
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(`Failed to save attachment ${doc._id}[${attName}]`, e);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function cleanupFiles(att) {
|
||||
if (att.type === SENTRY_MIMETYPE) {
|
||||
try {
|
||||
await remove.call(this, att);
|
||||
} catch (e) {
|
||||
console.error('Failed to remove attachment ', att, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return override;
|
||||
}
|
||||
13
packages/pouchdb-attachmentproxy/src/utils.js
Normal file
13
packages/pouchdb-attachmentproxy/src/utils.js
Normal file
@ -0,0 +1,13 @@
|
||||
export const SENTRY_MIMETYPE = 'application/pouchdb-attachmentmetadata';
|
||||
|
||||
export function blobToString(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const f = new FileReader();
|
||||
f.onload = _ => resolve(f.result);
|
||||
f.readAsText(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export function stringToBlob(str, mime_type = SENTRY_MIMETYPE) {
|
||||
return new Blob([str], { type: mime_type });
|
||||
}
|
||||
31
packages/pouchdb-attachmentproxy/webpack.test-config.js
Normal file
31
packages/pouchdb-attachmentproxy/webpack.test-config.js
Normal file
@ -0,0 +1,31 @@
|
||||
const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const ScriptExtHtmlWebpackPlugin = require('script-ext-html-webpack-plugin');
|
||||
|
||||
module.exports = {
|
||||
context: path.resolve(__dirname, './spec'),
|
||||
mode: 'development',
|
||||
entry: {
|
||||
proxy: './proxy.spec.js'
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(__dirname, './dist'),
|
||||
filename: '[name].bundle.js',
|
||||
publicPath: '/packages/pouchdb-attachmentproxy/dist/'
|
||||
},
|
||||
devServer: {
|
||||
contentBase: path.join(__dirname, 'dist')
|
||||
},
|
||||
module: {},
|
||||
plugins: [
|
||||
new webpack.DefinePlugin({ __DEV__: true }),
|
||||
new HtmlWebpackPlugin({
|
||||
template: 'index.html',
|
||||
inject: 'body'
|
||||
}),
|
||||
new ScriptExtHtmlWebpackPlugin({
|
||||
module: /.*/
|
||||
})
|
||||
]
|
||||
};
|
||||
Reference in New Issue
Block a user