feat(y-octo): import y-octo monorepo (#11750)

This commit is contained in:
Brooooooklyn
2025-04-21 02:51:15 +00:00
parent e3973538e8
commit 95dbda24fc
127 changed files with 17319 additions and 18 deletions

View File

@@ -0,0 +1,2 @@
*.node
.coverage

View File

@@ -0,0 +1,20 @@
[package]
authors = ["DarkSky <darksky2048@gmail.com>"]
edition = "2021"
license = "MIT"
name = "y-octo-node"
repository = "https://github.com/toeverything/y-octo"
version = "0.0.1"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
crate-type = ["cdylib"]
[dependencies]
anyhow = { workspace = true }
napi = { workspace = true, features = ["anyhow", "napi4"] }
napi-derive = { workspace = true }
y-octo = { workspace = true, features = ["large_refs"] }
[build-dependencies]
napi-build = { workspace = true }

View File

@@ -0,0 +1,3 @@
fn main() {
napi_build::setup();
}

48
packages/common/y-octo/node/index.d.ts vendored Normal file
View File

@@ -0,0 +1,48 @@
/* auto-generated by NAPI-RS */
/* eslint-disable */
export declare class Doc {
constructor(clientId?: number | undefined | null)
get clientId(): number
get guid(): string
get keys(): Array<string>
getOrCreateArray(key: string): YArray
getOrCreateText(key: string): YText
getOrCreateMap(key: string): YMap
createArray(): YArray
createText(): YText
createMap(): YMap
applyUpdate(update: Uint8Array): void
encodeStateAsUpdateV1(state?: Uint8Array | undefined | null): Uint8Array
gc(): void
onUpdate(callback: (result: Uint8Array) => void): void
}
export declare class YArray {
constructor()
get length(): number
get isEmpty(): boolean
get<T = unknown>(index: number): T
insert(index: number, value: YArray | YMap | YText | boolean | number | string | Record<string, any> | null | undefined): void
remove(index: number, len: number): void
toJson(): JsArray
}
export declare class YMap {
constructor()
get length(): number
get isEmpty(): boolean
get<T = unknown>(key: string): T
set(key: string, value: YArray | YMap | YText | boolean | number | string | Record<string, any> | null | undefined): void
remove(key: string): void
toJson(): object
}
export declare class YText {
constructor()
get len(): number
get isEmpty(): boolean
insert(index: number, str: string): void
remove(index: number, len: number): void
get length(): number
toString(): string
}

View File

@@ -0,0 +1,377 @@
// prettier-ignore
/* eslint-disable */
// @ts-nocheck
/* auto-generated by NAPI-RS */
const { createRequire } = require('node:module')
require = createRequire(__filename);
const { readFileSync } = require('node:fs');
let nativeBinding = null;
const loadErrors = [];
const isMusl = () => {
let musl = false;
if (process.platform === 'linux') {
musl = isMuslFromFilesystem();
if (musl === null) {
musl = isMuslFromReport();
}
if (musl === null) {
musl = isMuslFromChildProcess();
}
}
return musl;
};
const isFileMusl = f => f.includes('libc.musl-') || f.includes('ld-musl-');
const isMuslFromFilesystem = () => {
try {
return readFileSync('/usr/bin/ldd', 'utf-8').includes('musl');
} catch {
return null;
}
};
const isMuslFromReport = () => {
let report = null;
if (typeof process.report?.getReport === 'function') {
process.report.excludeNetwork = true;
report = process.report.getReport();
}
if (!report) {
return null;
}
if (report.header && report.header.glibcVersionRuntime) {
return false;
}
if (Array.isArray(report.sharedObjects)) {
if (report.sharedObjects.some(isFileMusl)) {
return true;
}
}
return false;
};
const isMuslFromChildProcess = () => {
try {
return require('child_process')
.execSync('ldd --version', { encoding: 'utf8' })
.includes('musl');
} catch (e) {
// If we reach this case, we don't know if the system is musl or not, so is better to just fallback to false
return false;
}
};
function requireNative() {
if (process.env.NAPI_RS_NATIVE_LIBRARY_PATH) {
try {
nativeBinding = require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH);
} catch (err) {
loadErrors.push(err);
}
} else if (process.platform === 'android') {
if (process.arch === 'arm64') {
try {
return require('./y-octo.android-arm64.node');
} catch (e) {
loadErrors.push(e);
}
try {
return require('@y-octo/node-android-arm64');
} catch (e) {
loadErrors.push(e);
}
} else if (process.arch === 'arm') {
try {
return require('./y-octo.android-arm-eabi.node');
} catch (e) {
loadErrors.push(e);
}
try {
return require('@y-octo/node-android-arm-eabi');
} catch (e) {
loadErrors.push(e);
}
} else {
loadErrors.push(
new Error(`Unsupported architecture on Android ${process.arch}`)
);
}
} else if (process.platform === 'win32') {
if (process.arch === 'x64') {
try {
return require('./y-octo.win32-x64-msvc.node');
} catch (e) {
loadErrors.push(e);
}
try {
return require('@y-octo/node-win32-x64-msvc');
} catch (e) {
loadErrors.push(e);
}
} else if (process.arch === 'ia32') {
try {
return require('./y-octo.win32-ia32-msvc.node');
} catch (e) {
loadErrors.push(e);
}
try {
return require('@y-octo/node-win32-ia32-msvc');
} catch (e) {
loadErrors.push(e);
}
} else if (process.arch === 'arm64') {
try {
return require('./y-octo.win32-arm64-msvc.node');
} catch (e) {
loadErrors.push(e);
}
try {
return require('@y-octo/node-win32-arm64-msvc');
} catch (e) {
loadErrors.push(e);
}
} else {
loadErrors.push(
new Error(`Unsupported architecture on Windows: ${process.arch}`)
);
}
} else if (process.platform === 'darwin') {
try {
return require('./y-octo.darwin-universal.node');
} catch (e) {
loadErrors.push(e);
}
try {
return require('@y-octo/node-darwin-universal');
} catch (e) {
loadErrors.push(e);
}
if (process.arch === 'x64') {
try {
return require('./y-octo.darwin-x64.node');
} catch (e) {
loadErrors.push(e);
}
try {
return require('@y-octo/node-darwin-x64');
} catch (e) {
loadErrors.push(e);
}
} else if (process.arch === 'arm64') {
try {
return require('./y-octo.darwin-arm64.node');
} catch (e) {
loadErrors.push(e);
}
try {
return require('@y-octo/node-darwin-arm64');
} catch (e) {
loadErrors.push(e);
}
} else {
loadErrors.push(
new Error(`Unsupported architecture on macOS: ${process.arch}`)
);
}
} else if (process.platform === 'freebsd') {
if (process.arch === 'x64') {
try {
return require('./y-octo.freebsd-x64.node');
} catch (e) {
loadErrors.push(e);
}
try {
return require('@y-octo/node-freebsd-x64');
} catch (e) {
loadErrors.push(e);
}
} else if (process.arch === 'arm64') {
try {
return require('./y-octo.freebsd-arm64.node');
} catch (e) {
loadErrors.push(e);
}
try {
return require('@y-octo/node-freebsd-arm64');
} catch (e) {
loadErrors.push(e);
}
} else {
loadErrors.push(
new Error(`Unsupported architecture on FreeBSD: ${process.arch}`)
);
}
} else if (process.platform === 'linux') {
if (process.arch === 'x64') {
if (isMusl()) {
try {
return require('./y-octo.linux-x64-musl.node');
} catch (e) {
loadErrors.push(e);
}
try {
return require('@y-octo/node-linux-x64-musl');
} catch (e) {
loadErrors.push(e);
}
} else {
try {
return require('./y-octo.linux-x64-gnu.node');
} catch (e) {
loadErrors.push(e);
}
try {
return require('@y-octo/node-linux-x64-gnu');
} catch (e) {
loadErrors.push(e);
}
}
} else if (process.arch === 'arm64') {
if (isMusl()) {
try {
return require('./y-octo.linux-arm64-musl.node');
} catch (e) {
loadErrors.push(e);
}
try {
return require('@y-octo/node-linux-arm64-musl');
} catch (e) {
loadErrors.push(e);
}
} else {
try {
return require('./y-octo.linux-arm64-gnu.node');
} catch (e) {
loadErrors.push(e);
}
try {
return require('@y-octo/node-linux-arm64-gnu');
} catch (e) {
loadErrors.push(e);
}
}
} else if (process.arch === 'arm') {
if (isMusl()) {
try {
return require('./y-octo.linux-arm-musleabihf.node');
} catch (e) {
loadErrors.push(e);
}
try {
return require('@y-octo/node-linux-arm-musleabihf');
} catch (e) {
loadErrors.push(e);
}
} else {
try {
return require('./y-octo.linux-arm-gnueabihf.node');
} catch (e) {
loadErrors.push(e);
}
try {
return require('@y-octo/node-linux-arm-gnueabihf');
} catch (e) {
loadErrors.push(e);
}
}
} else if (process.arch === 'riscv64') {
if (isMusl()) {
try {
return require('./y-octo.linux-riscv64-musl.node');
} catch (e) {
loadErrors.push(e);
}
try {
return require('@y-octo/node-linux-riscv64-musl');
} catch (e) {
loadErrors.push(e);
}
} else {
try {
return require('./y-octo.linux-riscv64-gnu.node');
} catch (e) {
loadErrors.push(e);
}
try {
return require('@y-octo/node-linux-riscv64-gnu');
} catch (e) {
loadErrors.push(e);
}
}
} else if (process.arch === 'ppc64') {
try {
return require('./y-octo.linux-ppc64-gnu.node');
} catch (e) {
loadErrors.push(e);
}
try {
return require('@y-octo/node-linux-ppc64-gnu');
} catch (e) {
loadErrors.push(e);
}
} else if (process.arch === 's390x') {
try {
return require('./y-octo.linux-s390x-gnu.node');
} catch (e) {
loadErrors.push(e);
}
try {
return require('@y-octo/node-linux-s390x-gnu');
} catch (e) {
loadErrors.push(e);
}
} else {
loadErrors.push(
new Error(`Unsupported architecture on Linux: ${process.arch}`)
);
}
} else {
loadErrors.push(
new Error(
`Unsupported OS: ${process.platform}, architecture: ${process.arch}`
)
);
}
}
nativeBinding = requireNative();
if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) {
try {
nativeBinding = require('./y-octo.wasi.cjs');
} catch (err) {
if (process.env.NAPI_RS_FORCE_WASI) {
loadErrors.push(err);
}
}
if (!nativeBinding) {
try {
nativeBinding = require('@y-octo/node-wasm32-wasi');
} catch (err) {
if (process.env.NAPI_RS_FORCE_WASI) {
loadErrors.push(err);
}
}
}
}
if (!nativeBinding) {
if (loadErrors.length > 0) {
// TODO Link to documentation with potential fixes
// - The package owner could build/publish bindings for this arch
// - The user may need to bundle the correct files
// - The user may need to re-install node_modules to get new packages
throw new Error('Failed to load native binding', { cause: loadErrors });
}
throw new Error(`Failed to load native binding`);
}
module.exports.Doc = nativeBinding.Doc;
module.exports.YArray = nativeBinding.YArray;
module.exports.YMap = nativeBinding.YMap;
module.exports.YText = nativeBinding.YText;

View File

@@ -0,0 +1,72 @@
{
"name": "@y-octo/node",
"private": true,
"main": "index.js",
"types": "index.d.ts",
"napi": {
"binaryName": "y-octo",
"targets": [
"x86_64-unknown-linux-gnu",
"x86_64-apple-darwin",
"x86_64-pc-windows-msvc",
"aarch64-apple-darwin",
"aarch64-pc-windows-msvc",
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-musl"
],
"ts": {
"constEnum": false
}
},
"license": "MIT",
"devDependencies": {
"@napi-rs/cli": "3.0.0-alpha.77",
"@types/node": "^22.14.1",
"@types/prompts": "^2.4.9",
"c8": "^10.1.3",
"prompts": "^2.4.2",
"ts-node": "^10.9.2",
"typescript": "^5.8.3",
"yjs": "^13.6.24"
},
"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": "NODE_NO_WARNINGS=1 node ./scripts/run-test.mts all",
"test:watch": "yarn exec ts-node-esm ./scripts/run-test.ts all --watch",
"test:coverage": "NODE_OPTIONS=\"--loader ts-node/esm\" c8 node ./scripts/run-test.mts all",
"version": "napi version"
},
"version": "0.0.1",
"sharedConfig": {
"nodeArgs": [
"--loader",
"ts-node/esm",
"--es-module-specifier-resolution=node"
],
"env": {
"TS_NODE_TRANSPILE_ONLY": "1",
"TS_NODE_PROJECT": "./tsconfig.json",
"NODE_ENV": "development",
"DEBUG": "napi:*"
}
},
"c8": {
"reporter": [
"text",
"lcov"
],
"report-dir": ".coverage",
"exclude": [
"scripts",
"node_modules",
"**/*.spec.ts"
]
}
}

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env ts-node-esm
import { resolve } from 'node:path';
import { spawn } from 'node:child_process';
import { readdir } from 'node:fs/promises';
import * as process from 'node:process';
import { fileURLToPath } from 'node:url';
import prompts from 'prompts';
import pkg from '../package.json' with { type: 'json' };
const root = fileURLToPath(new URL('..', import.meta.url));
const testDir = resolve(root, 'tests');
const files = await readdir(testDir);
const watchMode = process.argv.includes('--watch');
const sharedArgs = [
...pkg.sharedConfig.nodeArgs,
'--test',
watchMode ? '--watch' : '',
];
const env = {
...pkg.sharedConfig.env,
PATH: process.env.PATH,
NODE_ENV: 'test',
NODE_NO_WARNINGS: '1',
};
if (process.argv[2] === 'all') {
const cp = spawn(
'node',
[...sharedArgs, ...files.map(f => resolve(testDir, f))],
{
cwd: root,
env,
stdio: 'inherit',
shell: true,
}
);
cp.on('exit', code => {
process.exit(code ?? 0);
});
} else {
const result = await prompts([
{
type: 'select',
name: 'file',
message: 'Select a file to run',
choices: files.map(file => ({
title: file,
value: file,
})),
initial: 1,
},
]);
const target = resolve(testDir, result.file);
const cp = spawn(
'node',
[
...sharedArgs,
'--test-reporter=spec',
'--test-reporter-destination=stdout',
target,
],
{
cwd: root,
env,
stdio: 'inherit',
shell: true,
}
);
cp.on('exit', code => {
process.exit(code ?? 0);
});
}

View File

@@ -0,0 +1,160 @@
use napi::{bindgen_prelude::Array as JsArray, Env, JsUnknown, ValueType};
use y_octo::{Any, Array, Value};
use super::*;
#[napi]
pub struct YArray {
pub(crate) array: Array,
}
#[napi]
impl YArray {
#[allow(clippy::new_without_default)]
#[napi(constructor)]
pub fn new() -> Self {
unimplemented!()
}
pub(crate) fn inner_new(array: Array) -> Self {
Self { array }
}
#[napi(getter)]
pub fn length(&self) -> i64 {
self.array.len() as i64
}
#[napi(getter)]
pub fn is_empty(&self) -> bool {
self.array.is_empty()
}
#[napi(ts_generic_types = "T = unknown", ts_return_type = "T")]
pub fn get(&self, env: Env, index: i64) -> Result<MixedYType> {
if let Some(value) = self.array.get(index as u64) {
match value {
Value::Any(any) => get_js_unknown_from_any(env, any).map(MixedYType::D),
Value::Array(array) => Ok(MixedYType::A(YArray::inner_new(array))),
Value::Map(map) => Ok(MixedYType::B(YMap::inner_new(map))),
Value::Text(text) => Ok(MixedYType::C(YText::inner_new(text))),
_ => env.get_null().map(|v| v.into_unknown()).map(MixedYType::D),
}
.map_err(anyhow::Error::from)
} else {
Ok(MixedYType::D(env.get_null()?.into_unknown()))
}
}
#[napi(
ts_args_type = "index: number, value: YArray | YMap | YText | boolean | number | string | \
Record<string, any> | null | undefined"
)]
pub fn insert(&mut self, index: i64, value: MixedRefYType) -> Result<()> {
match value {
MixedRefYType::A(array) => self
.array
.insert(index as u64, array.array.clone())
.map_err(anyhow::Error::from),
MixedRefYType::B(map) => self
.array
.insert(index as u64, map.map.clone())
.map_err(anyhow::Error::from),
MixedRefYType::C(text) => self
.array
.insert(index as u64, text.text.clone())
.map_err(anyhow::Error::from),
MixedRefYType::D(unknown) => match unknown.get_type() {
Ok(value_type) => match value_type {
ValueType::Undefined | ValueType::Null => self
.array
.insert(index as u64, Any::Null)
.map_err(anyhow::Error::from),
ValueType::Boolean => match unknown.coerce_to_bool().and_then(|v| v.get_value()) {
Ok(boolean) => self
.array
.insert(index as u64, boolean)
.map_err(anyhow::Error::from),
Err(e) => Err(anyhow::Error::new(e).context("Failed to coerce value to boolean")),
},
ValueType::Number => match unknown.coerce_to_number().and_then(|v| v.get_double()) {
Ok(number) => self
.array
.insert(index as u64, number)
.map_err(anyhow::Error::from),
Err(e) => Err(anyhow::Error::new(e).context("Failed to coerce value to number")),
},
ValueType::String => {
match unknown
.coerce_to_string()
.and_then(|v| v.into_utf8())
.and_then(|s| s.as_str().map(|s| s.to_string()))
{
Ok(string) => self
.array
.insert(index as u64, string)
.map_err(anyhow::Error::from),
Err(e) => Err(anyhow::Error::new(e).context("Failed to coerce value to string")),
}
}
ValueType::Object => match unknown
.coerce_to_object()
.and_then(|o| o.get_array_length().map(|l| (o, l)))
{
Ok((object, length)) => {
for i in 0..length {
if let Ok(any) = object
.get_element::<JsUnknown>(i)
.and_then(get_any_from_js_unknown)
{
self
.array
.insert(index as u64 + i as u64, Value::Any(any))
.map_err(anyhow::Error::from)?;
}
}
Ok(())
}
Err(e) => Err(anyhow::Error::new(e).context("Failed to coerce value to object")),
},
ValueType::BigInt => Err(anyhow::Error::msg("BigInt values are not supported")),
ValueType::Symbol => Err(anyhow::Error::msg("Symbol values are not supported")),
ValueType::Function => Err(anyhow::Error::msg("Function values are not supported")),
ValueType::External => Err(anyhow::Error::msg("External values are not supported")),
ValueType::Unknown => Err(anyhow::Error::msg("Unknown values are not supported")),
},
Err(e) => Err(anyhow::Error::from(e)),
},
}
}
#[napi]
pub fn remove(&mut self, index: i64, len: i64) -> Result<()> {
self
.array
.remove(index as u64, len as u64)
.map_err(anyhow::Error::from)
}
#[napi]
pub fn to_json(&self, env: Env) -> Result<JsArray> {
let mut js_array = env.create_array(0)?;
for value in self.array.iter() {
js_array.insert(get_js_unknown_from_value(env, value)?)?;
}
Ok(js_array)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_array_init() {
let doc = Doc::new(None);
let array = doc.get_or_create_array("array".into()).unwrap();
assert_eq!(array.length(), 0);
}
}

View File

@@ -0,0 +1,176 @@
use napi::{
bindgen_prelude::{Buffer, Uint8Array},
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
};
use y_octo::{CrdtRead, Doc as YDoc, History, RawDecoder, StateVector};
use super::*;
#[napi]
pub struct Doc {
doc: YDoc,
}
#[napi]
impl Doc {
#[napi(constructor)]
pub fn new(client_id: Option<i64>) -> Self {
Self {
doc: if let Some(client_id) = client_id {
YDoc::with_client(client_id as u64)
} else {
YDoc::default()
},
}
}
#[napi(getter)]
pub fn client_id(&self) -> i64 {
self.doc.client() as i64
}
#[napi(getter)]
pub fn guid(&self) -> &str {
self.doc.guid()
}
#[napi(getter)]
pub fn keys(&self) -> Vec<String> {
self.doc.keys()
}
#[napi]
pub fn get_or_create_array(&self, key: String) -> Result<YArray> {
self
.doc
.get_or_create_array(key)
.map(YArray::inner_new)
.map_err(anyhow::Error::from)
}
#[napi]
pub fn get_or_create_text(&self, key: String) -> Result<YText> {
self
.doc
.get_or_create_text(key)
.map(YText::inner_new)
.map_err(anyhow::Error::from)
}
#[napi]
pub fn get_or_create_map(&self, key: String) -> Result<YMap> {
self
.doc
.get_or_create_map(key)
.map(YMap::inner_new)
.map_err(anyhow::Error::from)
}
#[napi]
pub fn create_array(&self) -> Result<YArray> {
self
.doc
.create_array()
.map(YArray::inner_new)
.map_err(anyhow::Error::from)
}
#[napi]
pub fn create_text(&self) -> Result<YText> {
self
.doc
.create_text()
.map(YText::inner_new)
.map_err(anyhow::Error::from)
}
#[napi]
pub fn create_map(&self) -> Result<YMap> {
self
.doc
.create_map()
.map(YMap::inner_new)
.map_err(anyhow::Error::from)
}
#[napi]
pub fn apply_update(&mut self, update: &[u8]) -> Result<()> {
self.doc.apply_update_from_binary_v1(update)?;
Ok(())
}
#[napi]
pub fn encode_state_as_update_v1(&self, state: Option<&[u8]>) -> Result<Uint8Array> {
let result = match state {
Some(state) => {
let mut decoder = RawDecoder::new(state);
let state = StateVector::read(&mut decoder)?;
self.doc.encode_state_as_update_v1(&state)
}
None => self.doc.encode_update_v1(),
};
result.map(|v| v.into()).map_err(anyhow::Error::from)
}
#[napi]
pub fn gc(&self) -> Result<()> {
self.doc.gc().map_err(anyhow::Error::from)
}
#[napi(ts_args_type = "callback: (result: Uint8Array) => void")]
pub fn on_update(&mut self, callback: ThreadsafeFunction<Buffer>) -> Result<()> {
let callback = move |update: &[u8], _h: &[History]| {
callback.call(
Ok(update.to_vec().into()),
ThreadsafeFunctionCallMode::Blocking,
);
};
self.doc.subscribe(Box::new(callback));
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_doc_client() {
let client_id = 1;
let doc = Doc::new(Some(client_id));
assert_eq!(doc.client_id(), 1);
}
#[test]
fn test_doc_guid() {
let doc = Doc::new(None);
assert_eq!(doc.guid().len(), 21);
}
#[test]
fn test_create_array() {
let doc = Doc::new(None);
let array = doc.get_or_create_array("array".into()).unwrap();
assert_eq!(array.length(), 0);
}
#[test]
fn test_create_text() {
let doc = Doc::new(None);
let text = doc.get_or_create_text("text".into()).unwrap();
assert_eq!(text.len(), 0);
}
#[test]
fn test_keys() {
let doc = Doc::new(None);
doc.get_or_create_array("array".into()).unwrap();
doc.get_or_create_text("text".into()).unwrap();
doc.get_or_create_map("map".into()).unwrap();
let mut keys = doc.keys();
keys.sort();
assert_eq!(keys, vec!["array", "map", "text"]);
}
}

View File

@@ -0,0 +1,17 @@
use anyhow::Result;
use napi_derive::napi;
mod array;
mod doc;
mod map;
mod text;
mod utils;
pub use array::YArray;
pub use doc::Doc;
pub use map::YMap;
pub use text::YText;
use utils::{
get_any_from_js_object, get_any_from_js_unknown, get_js_unknown_from_any,
get_js_unknown_from_value, MixedRefYType, MixedYType,
};

View File

@@ -0,0 +1,134 @@
use napi::{Env, JsObject, ValueType};
use y_octo::{Any, Map, Value};
use super::*;
#[napi]
pub struct YMap {
pub(crate) map: Map,
}
#[napi]
impl YMap {
#[allow(clippy::new_without_default)]
#[napi(constructor)]
pub fn new() -> Self {
unimplemented!()
}
pub(crate) fn inner_new(map: Map) -> Self {
Self { map }
}
#[napi(getter)]
pub fn length(&self) -> i64 {
self.map.len() as i64
}
#[napi(getter)]
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
#[napi(ts_generic_types = "T = unknown", ts_return_type = "T")]
pub fn get(&self, env: Env, key: String) -> Result<MixedYType> {
if let Some(value) = self.map.get(&key) {
match value {
Value::Any(any) => get_js_unknown_from_any(env, any).map(MixedYType::D),
Value::Array(array) => Ok(MixedYType::A(YArray::inner_new(array))),
Value::Map(map) => Ok(MixedYType::B(YMap::inner_new(map))),
Value::Text(text) => Ok(MixedYType::C(YText::inner_new(text))),
_ => env.get_null().map(|v| v.into_unknown()).map(MixedYType::D),
}
.map_err(anyhow::Error::from)
} else {
Ok(MixedYType::D(env.get_null()?.into_unknown()))
}
}
#[napi(
ts_args_type = "key: string, value: YArray | YMap | YText | boolean | number | string | \
Record<string, any> | null | undefined"
)]
pub fn set(&mut self, key: String, value: MixedRefYType) -> Result<()> {
match value {
MixedRefYType::A(array) => self
.map
.insert(key, array.array.clone())
.map_err(anyhow::Error::from),
MixedRefYType::B(map) => self
.map
.insert(key, map.map.clone())
.map_err(anyhow::Error::from),
MixedRefYType::C(text) => self
.map
.insert(key, text.text.clone())
.map_err(anyhow::Error::from),
MixedRefYType::D(unknown) => match unknown.get_type() {
Ok(value_type) => match value_type {
ValueType::Undefined | ValueType::Null => {
self.map.insert(key, Any::Null).map_err(anyhow::Error::from)
}
ValueType::Boolean => match unknown.coerce_to_bool().and_then(|v| v.get_value()) {
Ok(boolean) => self.map.insert(key, boolean).map_err(anyhow::Error::from),
Err(e) => Err(anyhow::Error::from(e).context("Failed to coerce value to boolean")),
},
ValueType::Number => match unknown.coerce_to_number().and_then(|v| v.get_double()) {
Ok(number) => self.map.insert(key, number).map_err(anyhow::Error::from),
Err(e) => Err(anyhow::Error::from(e).context("Failed to coerce value to number")),
},
ValueType::String => {
match unknown
.coerce_to_string()
.and_then(|v| v.into_utf8())
.and_then(|s| s.as_str().map(|s| s.to_string()))
{
Ok(string) => self.map.insert(key, string).map_err(anyhow::Error::from),
Err(e) => Err(anyhow::Error::from(e).context("Failed to coerce value to string")),
}
}
ValueType::Object => match unknown.coerce_to_object().and_then(get_any_from_js_object) {
Ok(any) => self
.map
.insert(key, Value::Any(any))
.map_err(anyhow::Error::from),
Err(e) => Err(anyhow::Error::from(e).context("Failed to coerce value to object")),
},
ValueType::BigInt => Err(anyhow::Error::msg("BigInt values are not supported")),
ValueType::Symbol => Err(anyhow::Error::msg("Symbol values are not supported")),
ValueType::Function => Err(anyhow::Error::msg("Function values are not supported")),
ValueType::External => Err(anyhow::Error::msg("External values are not supported")),
ValueType::Unknown => Err(anyhow::Error::msg("Unknown values are not supported")),
},
Err(e) => Err(anyhow::Error::from(e)),
},
}
}
#[napi]
pub fn remove(&mut self, key: String) {
self.map.remove(&key);
}
#[napi]
pub fn to_json(&self, env: Env) -> Result<JsObject> {
let mut js_object = env.create_object()?;
for (key, value) in self.map.iter() {
js_object.set(key, get_js_unknown_from_value(env, value))?;
}
Ok(js_object)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_map_init() {
let doc = Doc::new(None);
let text = doc.get_or_create_map("map".into()).unwrap();
assert_eq!(text.length(), 0);
}
}

View File

@@ -0,0 +1,82 @@
use y_octo::Text;
use super::*;
#[napi]
pub struct YText {
pub(crate) text: Text,
}
#[napi]
impl YText {
#[allow(clippy::new_without_default)]
#[napi(constructor)]
pub fn new() -> Self {
unimplemented!()
}
pub(crate) fn inner_new(text: Text) -> Self {
Self { text }
}
#[napi(getter)]
pub fn len(&self) -> i64 {
self.text.len() as i64
}
#[napi(getter)]
pub fn is_empty(&self) -> bool {
self.text.is_empty()
}
#[napi]
pub fn insert(&mut self, index: i64, str: String) -> Result<()> {
self
.text
.insert(index as u64, str)
.map_err(anyhow::Error::from)
}
#[napi]
pub fn remove(&mut self, index: i64, len: i64) -> Result<()> {
self
.text
.remove(index as u64, len as u64)
.map_err(anyhow::Error::from)
}
#[napi(getter)]
pub fn length(&self) -> i64 {
self.text.len() as i64
}
#[allow(clippy::inherent_to_string)]
#[napi]
pub fn to_string(&self) -> String {
self.text.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_text_init() {
let doc = Doc::new(None);
let text = doc.get_or_create_text("text".into()).unwrap();
assert_eq!(text.len(), 0);
}
#[test]
fn test_text_edit() {
let doc = Doc::new(None);
let mut text = doc.get_or_create_text("text".into()).unwrap();
text.insert(0, "hello".into()).unwrap();
assert_eq!(text.to_string(), "hello");
text.insert(5, " world".into()).unwrap();
assert_eq!(text.to_string(), "hello world");
text.remove(5, 6).unwrap();
assert_eq!(text.to_string(), "hello");
}
}

View File

@@ -0,0 +1,117 @@
use napi::{bindgen_prelude::Either4, Env, Error, JsObject, JsUnknown, Result, Status, ValueType};
use y_octo::{AHashMap, Any, HashMapExt, Value};
use super::*;
pub type MixedYType = Either4<YArray, YMap, YText, JsUnknown>;
pub type MixedRefYType<'a> = Either4<&'a YArray, &'a YMap, &'a YText, JsUnknown>;
pub fn get_js_unknown_from_any(env: Env, any: Any) -> Result<JsUnknown> {
match any {
Any::Null | Any::Undefined => env.get_null().map(|v| v.into_unknown()),
Any::True => env.get_boolean(true).map(|v| v.into_unknown()),
Any::False => env.get_boolean(false).map(|v| v.into_unknown()),
Any::Integer(number) => env.create_int32(number).map(|v| v.into_unknown()),
Any::BigInt64(number) => env.create_int64(number).map(|v| v.into_unknown()),
Any::Float32(number) => env.create_double(number.0 as f64).map(|v| v.into_unknown()),
Any::Float64(number) => env.create_double(number.0).map(|v| v.into_unknown()),
Any::String(string) => env.create_string(string.as_str()).map(|v| v.into_unknown()),
Any::Array(array) => {
let mut js_array = env.create_array_with_length(array.len())?;
for (i, value) in array.into_iter().enumerate() {
js_array.set_element(i as u32, get_js_unknown_from_any(env, value)?)?;
}
Ok(js_array.into_unknown())
}
_ => env.get_null().map(|v| v.into_unknown()),
}
}
#[allow(deprecated)]
// Wait for NAPI-RS External::into_unknown to be stabilized
pub fn get_js_unknown_from_value(env: Env, value: Value) -> Result<JsUnknown> {
match value {
Value::Any(any) => get_js_unknown_from_any(env, any),
Value::Array(array) => env
.create_external(YArray::inner_new(array), None)
.map(|o| o.into_unknown()),
Value::Map(map) => env
.create_external(YMap::inner_new(map), None)
.map(|o| o.into_unknown()),
Value::Text(text) => env
.create_external(YText::inner_new(text), None)
.map(|o| o.into_unknown()),
_ => env.get_null().map(|v| v.into_unknown()),
}
}
pub fn get_any_from_js_object(object: JsObject) -> Result<Any> {
if let Ok(length) = object.get_array_length() {
let mut array = Vec::with_capacity(length as usize);
for i in 0..length {
if let Ok(value) = object.get_element::<JsUnknown>(i) {
array.push(get_any_from_js_unknown(value)?);
}
}
Ok(Any::Array(array))
} else {
let mut map = AHashMap::new();
let keys = object.get_property_names()?;
if let Ok(length) = keys.get_array_length() {
for i in 0..length {
if let Ok((obj, key)) = keys.get_element::<JsUnknown>(i).and_then(|o| {
o.coerce_to_string().and_then(|obj| {
obj
.into_utf8()
.and_then(|s| s.as_str().map(|s| (obj, s.to_string())))
})
}) {
if let Ok(value) = object.get_property::<_, JsUnknown>(obj) {
println!("key: {}", key);
map.insert(key, get_any_from_js_unknown(value)?);
}
}
}
}
Ok(Any::Object(map))
}
}
pub fn get_any_from_js_unknown(js_unknown: JsUnknown) -> Result<Any> {
match js_unknown.get_type()? {
ValueType::Undefined | ValueType::Null => Ok(Any::Null),
ValueType::Boolean => Ok(
js_unknown
.coerce_to_bool()
.and_then(|v| v.get_value())?
.into(),
),
ValueType::Number => Ok(
js_unknown
.coerce_to_number()
.and_then(|v| v.get_double())
.map(|v| v.into())?,
),
ValueType::String => Ok(
js_unknown
.coerce_to_string()
.and_then(|v| v.into_utf8())
.and_then(|s| s.as_str().map(|s| s.to_string()))?
.into(),
),
ValueType::Object => {
if let Ok(object) = js_unknown.coerce_to_object() {
get_any_from_js_object(object)
} else {
Err(Error::new(
Status::InvalidArg,
"Failed to coerce value to object",
))
}
}
_ => Err(Error::new(
Status::InvalidArg,
"Failed to coerce value to any",
)),
}
}

View File

@@ -0,0 +1,62 @@
import assert, { equal, deepEqual } from 'node:assert';
import { test } from 'node:test';
import { Doc, type YArray } from '../index';
test('array test', { concurrency: false }, async t => {
let client_id: number;
let doc: Doc;
t.beforeEach(async () => {
client_id = (Math.random() * 100000) | 0;
doc = new Doc(client_id);
});
t.afterEach(async () => {
client_id = -1;
// @ts-expect-error - doc must not null in next range
doc = null;
});
await t.test('array should be created', () => {
let arr = doc.getOrCreateArray('arr');
deepEqual(doc.keys, ['arr']);
equal(arr.length, 0);
});
await t.test('array editing', () => {
let arr = doc.getOrCreateArray('arr');
arr.insert(0, true);
arr.insert(1, false);
arr.insert(2, 1);
arr.insert(3, 'hello world');
equal(arr.length, 4);
equal(arr.get(0), true);
equal(arr.get(1), false);
equal(arr.get(2), 1);
equal(arr.get(3), 'hello world');
equal(arr.length, 4);
arr.remove(1, 1);
equal(arr.length, 3);
equal(arr.get(2), 'hello world');
});
await t.test('sub array should can edit', () => {
let map = doc.getOrCreateMap('map');
let sub = doc.createArray();
map.set('sub', sub);
sub.insert(0, true);
sub.insert(1, false);
sub.insert(2, 1);
sub.insert(3, 'hello world');
equal(sub.length, 4);
let sub2 = map.get<YArray>('sub');
assert(sub2);
equal(sub2.get(0), true);
equal(sub2.get(1), false);
equal(sub2.get(2), 1);
equal(sub2.get(3), 'hello world');
equal(sub2.length, 4);
});
});

View File

@@ -0,0 +1,99 @@
import { equal } from 'node:assert';
import { test } from 'node:test';
import { Doc } from '../index';
import * as Y from 'yjs';
test('doc test', { concurrency: false }, async t => {
let client_id: number;
let doc: Doc;
t.beforeEach(async () => {
client_id = (Math.random() * 100000) | 0;
doc = new Doc(client_id);
});
t.afterEach(async () => {
client_id = -1;
// @ts-expect-error - doc must not null in next range
doc = null;
});
await t.test('doc id should be set', () => {
equal(doc.clientId, client_id);
});
await t.test('y-octo doc update should be apply', () => {
let array = doc.getOrCreateArray('array');
let map = doc.getOrCreateMap('map');
let text = doc.getOrCreateText('text');
array.insert(0, true);
array.insert(1, false);
array.insert(2, 1);
array.insert(3, 'hello world');
map.set('a', true);
map.set('b', false);
map.set('c', 1);
map.set('d', 'hello world');
text.insert(0, 'a');
text.insert(1, 'b');
text.insert(2, 'c');
let doc2 = new Doc(client_id);
doc2.applyUpdate(doc.encodeStateAsUpdateV1());
let array2 = doc2.getOrCreateArray('array');
let map2 = doc2.getOrCreateMap('map');
let text2 = doc2.getOrCreateText('text');
equal(doc2.clientId, client_id);
equal(array2.length, 4);
equal(array2.get(0), true);
equal(array2.get(1), false);
equal(array2.get(2), 1);
equal(array2.get(3), 'hello world');
equal(map2.length, 4);
equal(map2.get('a'), true);
equal(map2.get('b'), false);
equal(map2.get('c'), 1);
equal(map2.get('d'), 'hello world');
equal(text2.toString(), 'abc');
});
await t.test('yjs doc update should be apply', () => {
let doc2 = new Y.Doc();
let array2 = doc2.getArray('array');
let map2 = doc2.getMap('map');
let text2 = doc2.getText('text');
array2.insert(0, [true]);
array2.insert(1, [false]);
array2.insert(2, [1]);
array2.insert(3, ['hello world']);
map2.set('a', true);
map2.set('b', false);
map2.set('c', 1);
map2.set('d', 'hello world');
text2.insert(0, 'a');
text2.insert(1, 'b');
text2.insert(2, 'c');
doc.applyUpdate(Buffer.from(Y.encodeStateAsUpdate(doc2)));
let array = doc.getOrCreateArray('array');
let map = doc.getOrCreateMap('map');
let text = doc.getOrCreateText('text');
equal(array.length, 4);
equal(array.get(0), true);
equal(array.get(1), false);
equal(array.get(2), 1);
equal(array.get(3), 'hello world');
equal(map.length, 4);
equal(map.get('a'), true);
equal(map.get('b'), false);
equal(map.get('c'), 1);
equal(map.get('d'), 'hello world');
equal(text.toString(), 'abc');
});
});

View File

@@ -0,0 +1,152 @@
import assert, { equal, deepEqual } from 'node:assert';
import { test } from 'node:test';
import * as Y from 'yjs';
import { Doc, type YArray, type YMap, type YText } from '../index';
test('map test', { concurrency: false }, async t => {
let client_id: number;
let doc: Doc;
t.beforeEach(async () => {
client_id = (Math.random() * 100000) | 0;
doc = new Doc(client_id);
});
t.afterEach(async () => {
client_id = -1;
// @ts-expect-error - doc must not null in next range
doc = null;
});
await t.test('map should be created', () => {
let map = doc.getOrCreateMap('map');
deepEqual(doc.keys, ['map']);
equal(map.length, 0);
});
await t.test('map editing', () => {
let map = doc.getOrCreateMap('map');
map.set('a', true);
map.set('b', false);
map.set('c', 1);
map.set('d', 'hello world');
equal(map.length, 4);
equal(map.get('a'), true);
equal(map.get('b'), false);
equal(map.get('c'), 1);
equal(map.get('d'), 'hello world');
equal(map.length, 4);
map.remove('b');
equal(map.length, 3);
equal(map.get('d'), 'hello world');
});
await t.test('map should can be nested', () => {
let map = doc.getOrCreateMap('map');
let sub = doc.createMap();
map.set('sub', sub);
sub.set('a', true);
sub.set('b', false);
sub.set('c', 1);
sub.set('d', 'hello world');
equal(sub.length, 4);
let sub2 = map.get<YMap>('sub');
assert(sub2);
equal(sub2.get('a'), true);
equal(sub2.get('b'), false);
equal(sub2.get('c'), 1);
equal(sub2.get('d'), 'hello world');
equal(sub2.length, 4);
});
await t.test('y-octo to yjs compatibility test with nested type', () => {
let map = doc.getOrCreateMap('map');
let sub_array = doc.createArray();
let sub_map = doc.createMap();
let sub_text = doc.createText();
map.set('array', sub_array);
map.set('map', sub_map);
map.set('text', sub_text);
sub_array.insert(0, true);
sub_array.insert(1, false);
sub_array.insert(2, 1);
sub_array.insert(3, 'hello world');
sub_map.set('a', true);
sub_map.set('b', false);
sub_map.set('c', 1);
sub_map.set('d', 'hello world');
sub_text.insert(0, 'a');
sub_text.insert(1, 'b');
sub_text.insert(2, 'c');
let doc2 = new Y.Doc();
Y.applyUpdate(doc2, doc.encodeStateAsUpdateV1());
let map2 = doc2.getMap<any>('map');
let sub_array2 = map2.get('array') as Y.Array<any>;
let sub_map2 = map2.get('map') as Y.Map<any>;
let sub_text2 = map2.get('text') as Y.Text;
assert(sub_array2);
equal(sub_array2.length, 4);
equal(sub_array2.get(0), true);
equal(sub_array2.get(1), false);
equal(sub_array2.get(2), 1);
equal(sub_array2.get(3), 'hello world');
assert(sub_map2);
equal(sub_map2.get('a'), true);
equal(sub_map2.get('b'), false);
equal(sub_map2.get('c'), 1);
equal(sub_map2.get('d'), 'hello world');
assert(sub_text2);
equal(sub_text2.toString(), 'abc');
});
await t.test('yjs to y-octo compatibility test with nested type', () => {
let doc2 = new Y.Doc();
let map2 = doc2.getMap<any>('map');
let sub_array2 = new Y.Array<any>();
let sub_map2 = new Y.Map<any>();
let sub_text2 = new Y.Text();
map2.set('array', sub_array2);
map2.set('map', sub_map2);
map2.set('text', sub_text2);
sub_array2.insert(0, [true]);
sub_array2.insert(1, [false]);
sub_array2.insert(2, [1]);
sub_array2.insert(3, ['hello world']);
sub_map2.set('a', true);
sub_map2.set('b', false);
sub_map2.set('c', 1);
sub_map2.set('d', 'hello world');
sub_text2.insert(0, 'a');
sub_text2.insert(1, 'b');
sub_text2.insert(2, 'c');
doc.applyUpdate(Buffer.from(Y.encodeStateAsUpdate(doc2)));
let map = doc.getOrCreateMap('map');
let sub_array = map.get<YArray>('array');
let sub_map = map.get<YMap>('map');
let sub_text = map.get<YText>('text');
assert(sub_array);
equal(sub_array.length, 4);
equal(sub_array.get(0), true);
equal(sub_array.get(1), false);
equal(sub_array.get(2), 1);
equal(sub_array.get(3), 'hello world');
assert(sub_map);
equal(sub_map.get('a'), true);
equal(sub_map.get('b'), false);
equal(sub_map.get('c'), 1);
equal(sub_map.get('d'), 'hello world');
assert(sub_text);
equal(sub_text.toString(), 'abc');
});
});

View File

@@ -0,0 +1,54 @@
import assert, { equal, deepEqual } from 'node:assert';
import { test } from 'node:test';
import { Doc, type YText } from '../index';
test('text test', { concurrency: false }, async t => {
let client_id: number;
let doc: Doc;
t.beforeEach(async () => {
client_id = (Math.random() * 100000) | 0;
doc = new Doc(client_id);
});
t.afterEach(async () => {
client_id = -1;
// @ts-expect-error - doc must not null in next range
doc = null;
});
await t.test('text should be created', () => {
let text = doc.getOrCreateText('text');
deepEqual(doc.keys, ['text']);
equal(text.len, 0);
});
await t.test('text editing', () => {
let text = doc.getOrCreateText('text');
text.insert(0, 'a');
text.insert(1, 'b');
text.insert(2, 'c');
equal(text.toString(), 'abc');
text.remove(0, 1);
equal(text.toString(), 'bc');
text.remove(1, 1);
equal(text.toString(), 'b');
text.remove(0, 1);
equal(text.toString(), '');
});
await t.test('sub text should can edit', () => {
let map = doc.getOrCreateMap('map');
let sub = doc.createText();
map.set('sub', sub);
sub.insert(0, 'a');
sub.insert(1, 'b');
sub.insert(2, 'c');
equal(sub.toString(), 'abc');
let sub2 = map.get<YText>('sub');
assert(sub2);
equal(sub2.toString(), 'abc');
});
});

View File

@@ -0,0 +1,10 @@
{
"extends": "../../../../tsconfig.node.json",
"compilerOptions": {
"noEmit": false,
"outDir": "lib",
"composite": true
},
"include": ["index.d.ts", "tests/**/*.mts"],
"references": []
}