refactor(infra): directory structure (#4615)

This commit is contained in:
Joooye_34
2023-10-18 23:30:08 +08:00
committed by GitHub
parent 814d552be8
commit bed9310519
1150 changed files with 539 additions and 584 deletions
+1
View File
@@ -0,0 +1 @@
DATABASE_URL="sqlite:affine.db"
+2
View File
@@ -0,0 +1,2 @@
*.fixture
lib
+56
View File
@@ -0,0 +1,56 @@
[package]
edition = "2021"
name = "affine_native"
version = "0.0.0"
[lib]
crate-type = ["cdylib"]
[dependencies]
affine_schema = { path = "./schema" }
anyhow = "1"
chrono = "0.4"
napi = { version = "2", default-features = false, features = [
"napi5",
"tokio_rt",
"serde-json",
"error_anyhow",
"chrono_date",
] }
napi-derive = "2"
notify = { version = "6", features = ["serde"] }
once_cell = "1"
parking_lot = "0.12"
rand = "0.8"
serde = "1"
serde_json = "1"
sha3 = "0.10"
sqlx = { version = "0.7.1", default-features = false, features = [
"sqlite",
"migrate",
"runtime-tokio",
"tls-rustls",
"chrono",
"macros",
] }
tokio = { version = "1", features = ["full"] }
uuid = { version = "1", default-features = false, features = [
"serde",
"v4",
"fast-rng",
] }
[build-dependencies]
affine_schema = { path = "./schema" }
dotenv = "0.15"
napi-build = "2"
sqlx = { version = "0.7.1", default-features = false, features = [
"sqlite",
"runtime-tokio",
"tls-rustls",
"chrono",
"macros",
"migrate",
"json",
] }
tokio = { version = "1", features = ["full"] }
@@ -0,0 +1,12 @@
import test from 'ava';
import { fileURLToPath } from 'node:url';
import { SqliteConnection, ValidationResult } from '../index';
test('db validate', async t => {
const path = fileURLToPath(
new URL('./fixtures/test01.affine', import.meta.url)
);
const result = await SqliteConnection.validate(path);
t.is(result, ValidationResult.MissingVersionColumn);
});
+33
View File
@@ -0,0 +1,33 @@
use sqlx::sqlite::SqliteConnectOptions;
use std::fs;
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
dotenv::dotenv().ok();
// always start with a fresh database to have
// latest db schema
let db_path = "../../../affine.db";
// check if db exists and then remove file
if fs::metadata(db_path).is_ok() {
fs::remove_file(db_path)?;
}
napi_build::setup();
let options = SqliteConnectOptions::new()
.filename(db_path)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Off)
.locking_mode(sqlx::sqlite::SqliteLockingMode::Exclusive)
.create_if_missing(true);
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await
.unwrap();
sqlx::query(affine_schema::SCHEMA)
.execute(&pool)
.await
.unwrap();
Ok(())
}
+43
View File
@@ -0,0 +1,43 @@
export interface NotifyEvent {
type: EventKind;
paths: string[];
}
export type EventKind =
| 'any'
| 'other'
| {
remove: {
kind: 'any' | 'file' | 'folder' | 'other';
};
}
| {
create: {
kind: 'any' | 'file' | 'folder' | 'other';
};
}
| {
modify:
| {
kind: 'any' | 'other';
}
| {
kind: 'data';
mode: 'any' | 'size' | 'content' | 'other';
}
| {
kind: 'metadata';
mode:
| 'any'
| 'access-time'
| 'write-time'
| 'permissions'
| 'ownership'
| 'extended'
| 'other';
}
| {
kind: 'rename';
mode: 'any' | 'to' | 'from' | 'both' | 'other';
};
};
+59
View File
@@ -0,0 +1,59 @@
/* tslint:disable */
/* eslint-disable */
/* auto-generated by NAPI-RS */
export interface BlobRow {
key: string;
data: Buffer;
timestamp: Date;
}
export interface UpdateRow {
id: number;
timestamp: Date;
data: Buffer;
docId?: string;
}
export interface InsertRow {
docId?: string;
data: Uint8Array;
}
export enum ValidationResult {
MissingTables = 0,
MissingDocIdColumn = 1,
MissingVersionColumn = 2,
GeneralError = 3,
Valid = 4,
}
export function verifyChallengeResponse(
response: string,
bits: number,
resource: string
): Promise<boolean>;
export function mintChallengeResponse(
resource: string,
bits?: number | undefined | null
): Promise<string>;
export class SqliteConnection {
constructor(path: string);
connect(): Promise<void>;
addBlob(key: string, blob: Uint8Array): Promise<void>;
getBlob(key: string): Promise<BlobRow | null>;
deleteBlob(key: string): Promise<void>;
getBlobKeys(): Promise<Array<string>>;
getUpdates(docId?: string | undefined | null): Promise<Array<UpdateRow>>;
getUpdatesCount(docId?: string | undefined | null): Promise<number>;
getAllUpdates(): Promise<Array<UpdateRow>>;
insertUpdates(updates: Array<InsertRow>): Promise<void>;
replaceUpdates(
docId: string | undefined | null,
updates: Array<InsertRow>
): Promise<void>;
initVersion(): Promise<void>;
setVersion(version: number): Promise<void>;
getMaxVersion(): Promise<number>;
close(): Promise<void>;
get isClose(): boolean;
static validate(path: string): Promise<ValidationResult>;
migrateAddDocId(): Promise<void>;
}
+276
View File
@@ -0,0 +1,276 @@
/* tslint:disable */
/* eslint-disable */
/* prettier-ignore */
/* auto-generated by NAPI-RS */
const { existsSync, readFileSync } = require('fs')
const { join } = require('path');
const { platform, arch } = process;
let nativeBinding = null;
let localFileExisted = false;
let loadError = null;
function isMusl() {
// For Node 10
if (!process.report || typeof process.report.getReport !== 'function') {
try {
const lddPath = require('child_process')
.execSync('which ldd')
.toString()
.trim();
return readFileSync(lddPath, 'utf8').includes('musl');
} catch (e) {
return true;
}
} else {
const { glibcVersionRuntime } = process.report.getReport().header;
return !glibcVersionRuntime;
}
}
switch (platform) {
case 'android':
switch (arch) {
case 'arm64':
localFileExisted = existsSync(
join(__dirname, 'affine.android-arm64.node')
);
try {
if (localFileExisted) {
nativeBinding = require('./affine.android-arm64.node');
} else {
nativeBinding = require('@affine/native-android-arm64');
}
} catch (e) {
loadError = e;
}
break;
case 'arm':
localFileExisted = existsSync(
join(__dirname, 'affine.android-arm-eabi.node')
);
try {
if (localFileExisted) {
nativeBinding = require('./affine.android-arm-eabi.node');
} else {
nativeBinding = require('@affine/native-android-arm-eabi');
}
} catch (e) {
loadError = e;
}
break;
default:
throw new Error(`Unsupported architecture on Android ${arch}`);
}
break;
case 'win32':
switch (arch) {
case 'x64':
localFileExisted = existsSync(
join(__dirname, 'affine.win32-x64-msvc.node')
);
try {
if (localFileExisted) {
nativeBinding = require('./affine.win32-x64-msvc.node');
} else {
nativeBinding = require('@affine/native-win32-x64-msvc');
}
} catch (e) {
loadError = e;
}
break;
case 'ia32':
localFileExisted = existsSync(
join(__dirname, 'affine.win32-ia32-msvc.node')
);
try {
if (localFileExisted) {
nativeBinding = require('./affine.win32-ia32-msvc.node');
} else {
nativeBinding = require('@affine/native-win32-ia32-msvc');
}
} catch (e) {
loadError = e;
}
break;
case 'arm64':
localFileExisted = existsSync(
join(__dirname, 'affine.win32-arm64-msvc.node')
);
try {
if (localFileExisted) {
nativeBinding = require('./affine.win32-arm64-msvc.node');
} else {
nativeBinding = require('@affine/native-win32-arm64-msvc');
}
} catch (e) {
loadError = e;
}
break;
default:
throw new Error(`Unsupported architecture on Windows: ${arch}`);
}
break;
case 'darwin':
localFileExisted = existsSync(
join(__dirname, 'affine.darwin-universal.node')
);
try {
if (localFileExisted) {
nativeBinding = require('./affine.darwin-universal.node');
} else {
nativeBinding = require('@affine/native-darwin-universal');
}
break;
} catch {}
switch (arch) {
case 'x64':
localFileExisted = existsSync(
join(__dirname, 'affine.darwin-x64.node')
);
try {
if (localFileExisted) {
nativeBinding = require('./affine.darwin-x64.node');
} else {
nativeBinding = require('@affine/native-darwin-x64');
}
} catch (e) {
loadError = e;
}
break;
case 'arm64':
localFileExisted = existsSync(
join(__dirname, 'affine.darwin-arm64.node')
);
try {
if (localFileExisted) {
nativeBinding = require('./affine.darwin-arm64.node');
} else {
nativeBinding = require('@affine/native-darwin-arm64');
}
} catch (e) {
loadError = e;
}
break;
default:
throw new Error(`Unsupported architecture on macOS: ${arch}`);
}
break;
case 'freebsd':
if (arch !== 'x64') {
throw new Error(`Unsupported architecture on FreeBSD: ${arch}`);
}
localFileExisted = existsSync(join(__dirname, 'affine.freebsd-x64.node'));
try {
if (localFileExisted) {
nativeBinding = require('./affine.freebsd-x64.node');
} else {
nativeBinding = require('@affine/native-freebsd-x64');
}
} catch (e) {
loadError = e;
}
break;
case 'linux':
switch (arch) {
case 'x64':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'affine.linux-x64-musl.node')
);
try {
if (localFileExisted) {
nativeBinding = require('./affine.linux-x64-musl.node');
} else {
nativeBinding = require('@affine/native-linux-x64-musl');
}
} catch (e) {
loadError = e;
}
} else {
localFileExisted = existsSync(
join(__dirname, 'affine.linux-x64-gnu.node')
);
try {
if (localFileExisted) {
nativeBinding = require('./affine.linux-x64-gnu.node');
} else {
nativeBinding = require('@affine/native-linux-x64-gnu');
}
} catch (e) {
loadError = e;
}
}
break;
case 'arm64':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'affine.linux-arm64-musl.node')
);
try {
if (localFileExisted) {
nativeBinding = require('./affine.linux-arm64-musl.node');
} else {
nativeBinding = require('@affine/native-linux-arm64-musl');
}
} catch (e) {
loadError = e;
}
} else {
localFileExisted = existsSync(
join(__dirname, 'affine.linux-arm64-gnu.node')
);
try {
if (localFileExisted) {
nativeBinding = require('./affine.linux-arm64-gnu.node');
} else {
nativeBinding = require('@affine/native-linux-arm64-gnu');
}
} catch (e) {
loadError = e;
}
}
break;
case 'arm':
localFileExisted = existsSync(
join(__dirname, 'affine.linux-arm-gnueabihf.node')
);
try {
if (localFileExisted) {
nativeBinding = require('./affine.linux-arm-gnueabihf.node');
} else {
nativeBinding = require('@affine/native-linux-arm-gnueabihf');
}
} catch (e) {
loadError = e;
}
break;
default:
throw new Error(`Unsupported architecture on Linux: ${arch}`);
}
break;
default:
throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`);
}
if (!nativeBinding) {
if (loadError) {
throw loadError;
}
throw new Error(`Failed to load native binding`);
}
const {
SqliteConnection,
ValidationResult,
verifyChallengeResponse,
mintChallengeResponse,
} = nativeBinding;
module.exports.SqliteConnection = SqliteConnection;
module.exports.ValidationResult = ValidationResult;
module.exports.verifyChallengeResponse = verifyChallengeResponse;
module.exports.mintChallengeResponse = mintChallengeResponse;
+62
View File
@@ -0,0 +1,62 @@
{
"name": "@affine/native",
"private": true,
"main": "index.js",
"types": "index.d.ts",
"napi": {
"name": "affine",
"triples": {
"additional": [
"aarch64-apple-darwin",
"aarch64-unknown-linux-gnu",
"aarch64-pc-windows-msvc"
]
},
"ts": {
"constEnum": false
}
},
"license": "MIT",
"ava": {
"extensions": {
"mts": "module"
},
"nodeArguments": [
"--loader",
"ts-node/esm.mjs",
"--es-module-specifier-resolution",
"node"
],
"files": [
"__tests__/*.spec.mts"
],
"environmentVariables": {
"TS_NODE_PROJECT": "./tsconfig.json"
}
},
"devDependencies": {
"@napi-rs/cli": "^2.16.3",
"@types/node": "^18.18.5",
"@types/uuid": "^9.0.5",
"ava": "^5.3.1",
"cross-env": "^7.0.3",
"nx": "^16.10.0",
"nx-cloud": "^16.5.2",
"rxjs": "^7.8.1",
"ts-node": "^10.9.1",
"typescript": "^5.2.2",
"uuid": "^9.0.1"
},
"engines": {
"node": ">= 10"
},
"scripts": {
"artifacts": "napi artifacts",
"build": "napi build --platform --release --no-const-enum",
"build:debug": "napi build --platform --no-const-enum",
"universal": "napi universal",
"test": "ava",
"version": "napi version"
},
"version": "0.10.0-canary.1"
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@affine/native",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"root": "packages/frontend/native",
"sourceRoot": "packages/frontend/native/src",
"targets": {
"build": {
"executor": "nx:run-script",
"dependsOn": ["^build"],
"options": {
"script": "build"
},
"inputs": [
{
"runtime": "rustc --version"
},
{
"runtime": "node -v"
}
],
"outputs": ["{projectRoot}/*.node", "{workspaceRoot}/affine.db"]
}
}
}
@@ -0,0 +1,4 @@
[package]
edition = "2021"
name = "affine_schema"
version = "0.0.0"
@@ -0,0 +1 @@
A temporary crate to share the schema between AFFiNE native and `build.rs` in the AFFiNE native.
@@ -0,0 +1,19 @@
// TODO
// dynamic create it from JavaScript side
// and remove this crate then.
pub const SCHEMA: &str = r#"CREATE TABLE IF NOT EXISTS "updates" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
data BLOB NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
doc_id TEXT
);
CREATE TABLE IF NOT EXISTS "blobs" (
key TEXT PRIMARY KEY NOT NULL,
data BLOB NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS "version_info" (
version NUMBER NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
"#;
+225
View File
@@ -0,0 +1,225 @@
use std::convert::TryFrom;
use chrono::{DateTime, Duration, NaiveDateTime, Utc};
use napi::{bindgen_prelude::AsyncTask, Env, JsBoolean, JsString, Result as NapiResult, Task};
use napi_derive::napi;
use rand::{
distributions::{Alphanumeric, Distribution},
thread_rng,
};
use sha3::{Digest, Sha3_256};
const SALT_LENGTH: usize = 16;
#[derive(Debug)]
struct Stamp {
version: String,
claim: u32,
ts: String,
resource: String,
ext: String,
rand: String,
counter: String,
}
impl Stamp {
fn check_expiration(&self) -> bool {
NaiveDateTime::parse_from_str(&self.ts, "%Y%m%d%H%M%S")
.ok()
.map(|ts| DateTime::<Utc>::from_naive_utc_and_offset(ts, Utc))
.and_then(|utc| {
utc
.checked_add_signed(Duration::minutes(5))
.map(|utc| Utc::now() <= utc)
})
.unwrap_or(false)
}
pub fn check<S: AsRef<str>>(&self, bits: u32, resource: S) -> bool {
if self.version == "1"
&& bits <= self.claim
&& self.check_expiration()
&& self.resource == resource.as_ref()
{
let hex_digits = ((self.claim as f32) / 4.).floor() as usize;
// check challenge
let mut hasher = Sha3_256::new();
hasher.update(&self.format().as_bytes());
let result = format!("{:x}", hasher.finalize());
result[..hex_digits] == String::from_utf8(vec![b'0'; hex_digits]).unwrap()
} else {
false
}
}
fn format(&self) -> String {
format!(
"{}:{}:{}:{}:{}:{}:{}",
self.version, self.claim, self.ts, self.resource, self.ext, self.rand, self.counter
)
}
/// Mint a new hashcash stamp.
pub fn mint(resource: String, bits: Option<u32>) -> Self {
let version = "1";
let now = Utc::now();
let ts = now.format("%Y%m%d%H%M%S");
let bits = bits.unwrap_or(20);
let rand = String::from_iter(
Alphanumeric
.sample_iter(thread_rng())
.take(SALT_LENGTH)
.map(char::from),
);
let challenge = format!("{}:{}:{}:{}:{}:{}", version, bits, ts, &resource, "", rand);
Stamp {
version: version.to_string(),
claim: bits,
ts: ts.to_string(),
resource,
ext: "".to_string(),
rand,
counter: {
let mut hasher = Sha3_256::new();
let mut counter = 0;
let hex_digits = ((bits as f32) / 4.).ceil() as usize;
let zeros = String::from_utf8(vec![b'0'; hex_digits]).unwrap();
loop {
hasher.update(&format!("{}:{:x}", challenge, counter).as_bytes());
let result = format!("{:x}", hasher.finalize_reset());
if result[..hex_digits] == zeros {
break format!("{:x}", counter);
};
counter += 1
}
},
}
}
}
impl TryFrom<&str> for Stamp {
type Error = String;
fn try_from(value: &str) -> Result<Self, Self::Error> {
let stamp_vec = value.split(':').collect::<Vec<&str>>();
if stamp_vec.len() != 7 {
return Err(format!(
"Malformed stamp, expected 6 parts, got {}",
stamp_vec.len()
));
}
Ok(Stamp {
version: stamp_vec[0].to_string(),
claim: stamp_vec[1]
.parse()
.map_err(|_| "Malformed stamp".to_string())?,
ts: stamp_vec[2].to_string(),
resource: stamp_vec[3].to_string(),
ext: stamp_vec[4].to_string(),
rand: stamp_vec[5].to_string(),
counter: stamp_vec[6].to_string(),
})
}
}
pub struct AsyncVerifyChallengeResponse {
response: String,
bits: u32,
resource: String,
}
#[napi]
impl Task for AsyncVerifyChallengeResponse {
type Output = bool;
type JsValue = JsBoolean;
fn compute(&mut self) -> NapiResult<Self::Output> {
Ok(if let Ok(stamp) = Stamp::try_from(self.response.as_str()) {
stamp.check(self.bits, &self.resource)
} else {
false
})
}
fn resolve(&mut self, env: Env, output: bool) -> NapiResult<Self::JsValue> {
env.get_boolean(output)
}
}
#[napi]
pub fn verify_challenge_response(
response: String,
bits: u32,
resource: String,
) -> AsyncTask<AsyncVerifyChallengeResponse> {
AsyncTask::new(AsyncVerifyChallengeResponse {
response,
bits,
resource,
})
}
pub struct AsyncMintChallengeResponse {
bits: Option<u32>,
resource: String,
}
#[napi]
impl Task for AsyncMintChallengeResponse {
type Output = String;
type JsValue = JsString;
fn compute(&mut self) -> NapiResult<Self::Output> {
Ok(Stamp::mint(self.resource.clone(), self.bits).format())
}
fn resolve(&mut self, env: Env, output: String) -> NapiResult<Self::JsValue> {
env.create_string(&output)
}
}
#[napi]
pub fn mint_challenge_response(
resource: String,
bits: Option<u32>,
) -> AsyncTask<AsyncMintChallengeResponse> {
AsyncTask::new(AsyncMintChallengeResponse { bits, resource })
}
#[cfg(test)]
mod tests {
use super::Stamp;
#[test]
fn test_mint() {
let response = Stamp::mint("test".into(), Some(22)).format();
assert!(Stamp::try_from(response.as_str())
.unwrap()
.check(22, "test"));
}
#[test]
fn test_check() {
assert!(Stamp::try_from("1:20:20202116:test::Z4p8WaiO:31c14")
.unwrap()
.check(20, "test"));
assert!(!Stamp::try_from("1:20:20202116:test1::Z4p8WaiO:31c14")
.unwrap()
.check(20, "test"));
assert!(!Stamp::try_from("1:20:20202116:test::z4p8WaiO:31c14")
.unwrap()
.check(20, "test"));
assert!(!Stamp::try_from("1:20:20202116:test::Z4p8WaiO:31C14")
.unwrap()
.check(20, "test"));
assert!(Stamp::try_from("0:20:20202116:test::Z4p8WaiO:31c14").is_err());
assert!(!Stamp::try_from("1:19:20202116:test::Z4p8WaiO:31c14")
.unwrap()
.check(20, "test"));
assert!(!Stamp::try_from("1:20:20202115:test::Z4p8WaiO:31c14")
.unwrap()
.check(20, "test"));
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod sqlite;
pub mod hashcash;
+364
View File
@@ -0,0 +1,364 @@
use chrono::NaiveDateTime;
use napi::bindgen_prelude::{Buffer, FromNapiValue, ToNapiValue, Uint8Array};
use napi_derive::napi;
use sqlx::{
migrate::MigrateDatabase,
sqlite::{Sqlite, SqliteConnectOptions, SqlitePoolOptions},
Pool, Row,
};
// latest version
const LATEST_VERSION: i32 = 4;
#[napi(object)]
pub struct BlobRow {
pub key: String,
pub data: Buffer,
pub timestamp: NaiveDateTime,
}
#[napi(object)]
pub struct UpdateRow {
pub id: i64,
pub timestamp: NaiveDateTime,
pub data: Buffer,
pub doc_id: Option<String>,
}
#[napi(object)]
pub struct InsertRow {
pub doc_id: Option<String>,
pub data: Uint8Array,
}
#[napi]
pub struct SqliteConnection {
pool: Pool<Sqlite>,
path: String,
}
#[napi]
pub enum ValidationResult {
MissingTables,
MissingDocIdColumn,
MissingVersionColumn,
GeneralError,
Valid,
}
#[napi]
impl SqliteConnection {
#[napi(constructor)]
pub fn new(path: String) -> napi::Result<Self> {
let sqlite_options = SqliteConnectOptions::new()
.filename(&path)
.foreign_keys(false)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Off);
let pool = SqlitePoolOptions::new()
.max_connections(4)
.connect_lazy_with(sqlite_options);
Ok(Self { pool, path })
}
#[napi]
pub async fn connect(&self) -> napi::Result<()> {
if !Sqlite::database_exists(&self.path).await.unwrap_or(false) {
Sqlite::create_database(&self.path)
.await
.map_err(anyhow::Error::from)?;
};
let mut connection = self.pool.acquire().await.map_err(anyhow::Error::from)?;
sqlx::query(affine_schema::SCHEMA)
.execute(connection.as_mut())
.await
.map_err(anyhow::Error::from)?;
self.migrate_add_doc_id().await?;
connection.detach();
Ok(())
}
#[napi]
pub async fn add_blob(&self, key: String, blob: Uint8Array) -> napi::Result<()> {
let blob = blob.as_ref();
sqlx::query_as!(
BlobRow,
"INSERT INTO blobs (key, data) VALUES ($1, $2) ON CONFLICT(key) DO UPDATE SET data = excluded.data",
key,
blob,
)
.execute(&self.pool)
.await
.map_err(anyhow::Error::from)?;
Ok(())
}
#[napi]
pub async fn get_blob(&self, key: String) -> Option<BlobRow> {
sqlx::query_as!(
BlobRow,
"SELECT key, data, timestamp FROM blobs WHERE key = ?",
key
)
.fetch_one(&self.pool)
.await
.ok()
}
#[napi]
pub async fn delete_blob(&self, key: String) -> napi::Result<()> {
sqlx::query!("DELETE FROM blobs WHERE key = ?", key)
.execute(&self.pool)
.await
.map_err(anyhow::Error::from)?;
Ok(())
}
#[napi]
pub async fn get_blob_keys(&self) -> napi::Result<Vec<String>> {
let keys = sqlx::query!("SELECT key FROM blobs")
.fetch_all(&self.pool)
.await
.map(|rows| rows.into_iter().map(|row| row.key).collect())
.map_err(anyhow::Error::from)?;
Ok(keys)
}
#[napi]
pub async fn get_updates(&self, doc_id: Option<String>) -> napi::Result<Vec<UpdateRow>> {
let updates = match doc_id {
Some(doc_id) => sqlx::query_as!(
UpdateRow,
"SELECT id, timestamp, data, doc_id FROM updates WHERE doc_id = ?",
doc_id
)
.fetch_all(&self.pool)
.await
.map_err(anyhow::Error::from)?,
None => sqlx::query_as!(
UpdateRow,
"SELECT id, timestamp, data, doc_id FROM updates WHERE doc_id is NULL",
)
.fetch_all(&self.pool)
.await
.map_err(anyhow::Error::from)?,
};
Ok(updates)
}
#[napi]
pub async fn get_updates_count(&self, doc_id: Option<String>) -> napi::Result<i32> {
let count = match doc_id {
Some(doc_id) => {
sqlx::query!(
"SELECT COUNT(*) as count FROM updates WHERE doc_id = ?",
doc_id
)
.fetch_one(&self.pool)
.await
.map_err(anyhow::Error::from)?
.count
}
None => {
sqlx::query!("SELECT COUNT(*) as count FROM updates WHERE doc_id is NULL")
.fetch_one(&self.pool)
.await
.map_err(anyhow::Error::from)?
.count
}
};
Ok(count)
}
#[napi]
pub async fn get_all_updates(&self) -> napi::Result<Vec<UpdateRow>> {
let updates = sqlx::query_as!(UpdateRow, "SELECT id, timestamp, data, doc_id FROM updates")
.fetch_all(&self.pool)
.await
.map_err(anyhow::Error::from)?;
Ok(updates)
}
#[napi]
pub async fn insert_updates(&self, updates: Vec<InsertRow>) -> napi::Result<()> {
let mut transaction = self.pool.begin().await.map_err(anyhow::Error::from)?;
for InsertRow { data, doc_id } in updates {
let update = data.as_ref();
sqlx::query_as!(
UpdateRow,
"INSERT INTO updates (data, doc_id) VALUES ($1, $2)",
update,
doc_id
)
.execute(&mut *transaction)
.await
.map_err(anyhow::Error::from)?;
}
transaction.commit().await.map_err(anyhow::Error::from)?;
Ok(())
}
#[napi]
pub async fn replace_updates(
&self,
doc_id: Option<String>,
updates: Vec<InsertRow>,
) -> napi::Result<()> {
let mut transaction = self.pool.begin().await.map_err(anyhow::Error::from)?;
match doc_id {
Some(doc_id) => sqlx::query!("DELETE FROM updates where doc_id = ?", doc_id)
.execute(&mut *transaction)
.await
.map_err(anyhow::Error::from)?,
None => sqlx::query!("DELETE FROM updates where doc_id is NULL",)
.execute(&mut *transaction)
.await
.map_err(anyhow::Error::from)?,
};
for InsertRow { data, doc_id } in updates {
let update = data.as_ref();
sqlx::query_as!(
UpdateRow,
"INSERT INTO updates (data, doc_id) VALUES ($1, $2)",
update,
doc_id
)
.execute(&mut *transaction)
.await
.map_err(anyhow::Error::from)?;
}
transaction.commit().await.map_err(anyhow::Error::from)?;
Ok(())
}
#[napi]
pub async fn init_version(&self) -> napi::Result<()> {
// create version_info table
sqlx::query!(
"CREATE TABLE IF NOT EXISTS version_info (
version NUMBER NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)"
)
.execute(&self.pool)
.await
.map_err(anyhow::Error::from)?;
// `3` is the first version that has version_info table,
// do not modify the version number.
sqlx::query!("INSERT INTO version_info (version) VALUES (3)")
.execute(&self.pool)
.await
.map_err(anyhow::Error::from)?;
Ok(())
}
#[napi]
pub async fn set_version(&self, version: i32) -> napi::Result<()> {
if version > LATEST_VERSION {
return Err(anyhow::Error::msg("Version is too new").into());
}
sqlx::query!("UPDATE version_info SET version = ?", version)
.execute(&self.pool)
.await
.map_err(anyhow::Error::from)?;
Ok(())
}
#[napi]
pub async fn get_max_version(&self) -> napi::Result<i32> {
// 4 is the current version
let version = sqlx::query!("SELECT COALESCE(MAX(version), 4) AS max_version FROM version_info")
.fetch_one(&self.pool)
.await
.map_err(anyhow::Error::from)?
.max_version;
Ok(version)
}
#[napi]
pub async fn close(&self) {
self.pool.close().await;
}
#[napi(getter)]
pub fn is_close(&self) -> bool {
self.pool.is_closed()
}
#[napi]
pub async fn validate(path: String) -> ValidationResult {
let pool = match SqlitePoolOptions::new()
.max_connections(1)
.connect(&path)
.await
{
Ok(pool) => pool,
Err(_) => return ValidationResult::GeneralError,
};
let tables_res = sqlx::query("SELECT name FROM sqlite_master WHERE type='table'")
.fetch_all(&pool)
.await;
let tables_exist = match tables_res {
Ok(res) => {
let names: Vec<String> = res.iter().map(|row| row.get(0)).collect();
names.contains(&"updates".to_string()) && names.contains(&"blobs".to_string())
}
Err(_) => return ValidationResult::GeneralError,
};
let tables_res = sqlx::query("SELECT name FROM sqlite_master WHERE type='table'")
.fetch_all(&pool)
.await;
let version_exist = match tables_res {
Ok(res) => {
let names: Vec<String> = res.iter().map(|row| row.get(0)).collect();
names.contains(&"version_info".to_string())
}
Err(_) => return ValidationResult::GeneralError,
};
let columns_res = sqlx::query("PRAGMA table_info(updates)")
.fetch_all(&pool)
.await;
let doc_id_exist = match columns_res {
Ok(res) => {
let names: Vec<String> = res.iter().map(|row| row.get(1)).collect();
names.contains(&"doc_id".to_string())
}
Err(_) => return ValidationResult::GeneralError,
};
if !tables_exist {
ValidationResult::MissingTables
} else if !doc_id_exist {
ValidationResult::MissingDocIdColumn
} else if !version_exist {
ValidationResult::MissingVersionColumn
} else {
ValidationResult::Valid
}
}
#[napi]
pub async fn migrate_add_doc_id(&self) -> napi::Result<()> {
// ignore errors
match sqlx::query("ALTER TABLE updates ADD COLUMN doc_id TEXT")
.execute(&self.pool)
.await
{
Ok(_) => Ok(()),
Err(err) => {
if err.to_string().contains("duplicate column name") {
Ok(()) // Ignore error if it's due to duplicate column
} else {
Err(anyhow::Error::from(err).into()) // Propagate other errors
}
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "lib",
"composite": true,
"types": ["node"]
},
"include": ["index.d.ts", "__tests__/**/*.mts"],
"ts-node": {
"esm": true,
"experimentalSpecifierResolution": "node"
}
}