commit 45c56db5ce7b80378d4bd8b9768c609ba601ef23
parent 9ebce20c5565dd1c1c657a14226dff460541abd1
Author: triesap <triesap@radroots.dev>
Date: Sun, 28 Dec 2025 17:02:15 +0000
tangle: add signed DB export and rename backup JSON types
- Rename backup export types to TangleDatabaseJsonExport across PWA and client
- Add WebTangleDatabase.export_database with manifest, sha256, and optional signer
- Implement ZIP export pipeline with save picker/share/download fallbacks
- Expose SQL WASM export bridge and add crypto_unavailable error key
Diffstat:
5 files changed, 482 insertions(+), 31 deletions(-)
diff --git a/apps-lib-pwa/src/lib/types/app.ts b/apps-lib-pwa/src/lib/types/app.ts
@@ -1,4 +1,4 @@
-import type { TangleDatabaseBackup } from "@radroots/client/tangle";
+import type { TangleDatabaseJsonExport } from "@radroots/client/tangle";
import type { IdbClientConfig } from "@radroots/utils";
export type AppConfigRole = `farmer` | `individual`
@@ -53,7 +53,7 @@ export type ExportedAppState = {
};
database: {
store_key: string;
- backup: TangleDatabaseBackup;
+ backup: TangleDatabaseJsonExport;
};
};
diff --git a/client/src/tangle/bridge.ts b/client/src/tangle/bridge.ts
@@ -41,6 +41,7 @@ export function radroots_sql_install_bridges(engine: WebSqlEngine): void {
const params = parse_sql_params(params_json);
const res = engine.query(sql, params);
return res;
- }
+ },
+ __radroots_sql_wasm_export_bytes: () => engine.export_bytes()
});
}
diff --git a/client/src/tangle/error.ts b/client/src/tangle/error.ts
@@ -2,7 +2,8 @@ export const cl_tangle_error = {
init_failure: "error.client.tangle.init_failure",
parse_failure: "error.client.tangle.parse_failure",
invalid_response: "error.client.tangle.invalid_response",
- runtime_unavailable: "error.client.tangle.runtime_unavailable"
+ runtime_unavailable: "error.client.tangle.runtime_unavailable",
+ crypto_unavailable: "error.client.tangle.crypto_unavailable"
} as const;
export type ClientTangleError = keyof typeof cl_tangle_error;
diff --git a/client/src/tangle/types.ts b/client/src/tangle/types.ts
@@ -158,7 +158,10 @@ import type {
} from "@radroots/tangle-db-schema-bindings";
import { type SqlJsMigrationState } from "../sql/types.js";
import type { IError } from "@radroots/types-bindings";
-import type { TangleDatabaseBackup } from "./web.js";
+import type {
+ TangleDatabaseExportOptions,
+ TangleDatabaseJsonExport
+} from "./web.js";
export interface IClientTangleDatabase {
init(): Promise<void>;
@@ -167,8 +170,9 @@ export interface IClientTangleDatabase {
reset(): Promise<SqlJsMigrationState | IError<string>>;
reinit(): Promise<SqlJsMigrationState | IError<string>>;
get_store_key(): string;
- export_backup(): Promise<TangleDatabaseBackup | IError<string>>;
- import_backup(backup: TangleDatabaseBackup): Promise<void | IError<string>>;
+ export_json(): Promise<TangleDatabaseJsonExport | IError<string>>;
+ import_json(backup: TangleDatabaseJsonExport): Promise<void | IError<string>>;
+ export_database(opts: TangleDatabaseExportOptions): Promise<void | IError<string>>;
farm_create(opts: IFarmCreate): Promise<IFarmCreateResolve | IError<string>>;
farm_find_one(opts: IFarmFindOne): Promise<IFarmFindOneResolve | IError<string>>;
farm_find_many(opts?: IFarmFindMany): Promise<IFarmFindManyResolve | IError<string>>;
diff --git a/client/src/tangle/web.ts b/client/src/tangle/web.ts
@@ -241,8 +241,10 @@ import init_wasm, {
tangle_db_trade_product_media_unset,
tangle_db_reset_database,
tangle_db_run_migrations,
- tangle_db_export_backup,
- tangle_db_import_backup
+ tangle_db_export_begin,
+ tangle_db_export_finish,
+ tangle_db_export_json,
+ tangle_db_import_json
} from "@radroots/tangle-db-wasm";
import type { IError } from "@radroots/types-bindings";
import { err_msg, handle_err, type IdbClientConfig } from "@radroots/utils";
@@ -253,26 +255,87 @@ import { radroots_sql_install_bridges } from "./bridge.js";
import { cl_tangle_error } from "./error.js";
import type { IWebTangleDatabase } from "./types.js";
-export type TangleDatabaseBackup = {
+export type TangleDatabaseSchemaEntry = {
+ object_type: string;
+ name: string;
+ table_name?: string;
+ sql?: string;
+};
+
+export type TangleDatabaseMigrationEntry = {
+ name: string;
+ up_sql: string;
+ down_sql: string;
+};
+
+export type TangleDatabaseJsonExport = {
format_version: string;
tangle_db_version: string;
- schema: {
- object_type: string;
- name: string;
- table_name?: string;
- sql?: string;
- }[];
+ schema: TangleDatabaseSchemaEntry[];
data: {
name: string;
rows: Record<string, unknown>[];
}[];
- migrations: {
+ migrations: TangleDatabaseMigrationEntry[];
+};
+
+export type TangleDatabaseExportManifestRs = {
+ export_version: string;
+ tangle_db_version: string;
+ backup_format_version: string;
+ schema_hash: string;
+ schema: TangleDatabaseSchemaEntry[];
+ migrations: TangleDatabaseMigrationEntry[];
+ table_counts: {
name: string;
- up_sql: string;
- down_sql: string;
+ row_count: number;
}[];
};
+export type NostrEventEnvelope = {
+ id: string;
+ pubkey: string;
+ created_at: number;
+ kind: number;
+ tags: string[][];
+ content: string;
+ sig: string;
+};
+
+export type TangleDatabaseExportManifestTs = {
+ app_name: string;
+ app_version: string;
+ exported_at: string;
+ db_sha256: string;
+ db_size_bytes: number;
+ store_key: string;
+ nostr_event?: NostrEventEnvelope;
+};
+
+export type TangleDatabaseExportManifest = {
+ rs: TangleDatabaseExportManifestRs;
+ ts: TangleDatabaseExportManifestTs;
+};
+
+export type TangleDatabaseExportSnapshot = {
+ manifest_rs: TangleDatabaseExportManifestRs;
+ db_bytes: Uint8Array;
+};
+
+export type TangleDatabaseExportSignRequest = {
+ db_sha256: string;
+ manifest: TangleDatabaseExportManifest;
+};
+
+export type TangleDatabaseExportSigner = (opts: TangleDatabaseExportSignRequest) => Promise<NostrEventEnvelope | null>;
+
+export type TangleDatabaseExportOptions = {
+ app_name: string;
+ app_version: string;
+ store_key?: string;
+ signer?: TangleDatabaseExportSigner;
+};
+
export type WebTangleDatabaseConfig = {
store_key?: string;
idb_config?: IdbClientConfig;
@@ -294,7 +357,7 @@ const is_sql_migration_row = (value: unknown): value is SqlJsMigrationRow => {
const is_sql_migration_row_list = (value: unknown): value is SqlJsMigrationRow[] =>
Array.isArray(value) && value.every(is_sql_migration_row);
-const is_backup_schema_entry = (value: unknown): value is TangleDatabaseBackup["schema"][number] => {
+const is_schema_entry = (value: unknown): value is TangleDatabaseSchemaEntry => {
if (!is_record(value)) return false;
if (typeof value.object_type !== "string") return false;
if (typeof value.name !== "string") return false;
@@ -303,7 +366,7 @@ const is_backup_schema_entry = (value: unknown): value is TangleDatabaseBackup["
return true;
};
-const is_backup_data_entry = (value: unknown): value is TangleDatabaseBackup["data"][number] => {
+const is_json_export_data_entry = (value: unknown): value is TangleDatabaseJsonExport["data"][number] => {
if (!is_record(value)) return false;
if (typeof value.name !== "string") return false;
if (!Array.isArray(value.rows)) return false;
@@ -311,23 +374,330 @@ const is_backup_data_entry = (value: unknown): value is TangleDatabaseBackup["da
return true;
};
-const is_backup_migration_entry = (value: unknown): value is TangleDatabaseBackup["migrations"][number] => {
+const is_migration_entry = (value: unknown): value is TangleDatabaseMigrationEntry => {
if (!is_record(value)) return false;
return typeof value.name === "string"
&& typeof value.up_sql === "string"
&& typeof value.down_sql === "string";
};
-const is_tangle_database_backup = (value: unknown): value is TangleDatabaseBackup => {
+const is_table_count_entry = (value: unknown): value is TangleDatabaseExportManifestRs["table_counts"][number] => {
+ if (!is_record(value)) return false;
+ if (typeof value.name !== "string") return false;
+ if (typeof value.row_count !== "number" || !Number.isFinite(value.row_count)) return false;
+ return true;
+};
+
+const is_tangle_database_json_export = (value: unknown): value is TangleDatabaseJsonExport => {
if (!is_record(value)) return false;
if (typeof value.format_version !== "string") return false;
if (typeof value.tangle_db_version !== "string") return false;
- if (!Array.isArray(value.schema) || !value.schema.every(is_backup_schema_entry)) return false;
- if (!Array.isArray(value.data) || !value.data.every(is_backup_data_entry)) return false;
- if (!Array.isArray(value.migrations) || !value.migrations.every(is_backup_migration_entry)) return false;
+ if (!Array.isArray(value.schema) || !value.schema.every(is_schema_entry)) return false;
+ if (!Array.isArray(value.data) || !value.data.every(is_json_export_data_entry)) return false;
+ if (!Array.isArray(value.migrations) || !value.migrations.every(is_migration_entry)) return false;
+ return true;
+};
+
+const is_export_manifest_rs = (value: unknown): value is TangleDatabaseExportManifestRs => {
+ if (!is_record(value)) return false;
+ if (typeof value.export_version !== "string") return false;
+ if (typeof value.tangle_db_version !== "string") return false;
+ if (typeof value.backup_format_version !== "string") return false;
+ if (typeof value.schema_hash !== "string") return false;
+ if (!Array.isArray(value.schema) || !value.schema.every(is_schema_entry)) return false;
+ if (!Array.isArray(value.migrations) || !value.migrations.every(is_migration_entry)) return false;
+ if (!Array.isArray(value.table_counts) || !value.table_counts.every(is_table_count_entry)) return false;
+ return true;
+};
+
+const is_export_snapshot = (value: unknown): value is TangleDatabaseExportSnapshot => {
+ if (!is_record(value)) return false;
+ if (!("manifest_rs" in value) || !is_export_manifest_rs(value.manifest_rs)) return false;
+ if (!("db_bytes" in value) || !(value.db_bytes instanceof Uint8Array)) return false;
return true;
};
+type ZipEntry = {
+ name: string;
+ data: Uint8Array;
+};
+
+type ZipEntryPrepared = {
+ name_bytes: Uint8Array;
+ data: Uint8Array;
+ crc32: number;
+ size: number;
+};
+
+type ZipFilePickerOptions = {
+ suggestedName?: string;
+ types?: {
+ description?: string;
+ accept: Record<string, string[]>;
+ }[];
+};
+
+type ZipFileHandle = {
+ createWritable(): Promise<ZipFileWritable>;
+};
+
+type ZipFileWritable = {
+ write(data: Uint8Array): Promise<void>;
+ close(): Promise<void>;
+};
+
+type ZipFilePicker = (options?: ZipFilePickerOptions) => Promise<ZipFileHandle>;
+
+const ZIP_CRC_TABLE = (() => {
+ const table = new Uint32Array(256);
+ for (let i = 0; i < 256; i++) {
+ let c = i;
+ for (let k = 0; k < 8; k++) {
+ if (c & 1) c = 0xedb88320 ^ (c >>> 1);
+ else c >>>= 1;
+ }
+ table[i] = c >>> 0;
+ }
+ return table;
+})();
+
+const crc32 = (data: Uint8Array): number => {
+ let crc = 0xffffffff;
+ for (let i = 0; i < data.length; i++) {
+ const idx = (crc ^ data[i]) & 0xff;
+ crc = ZIP_CRC_TABLE[idx] ^ (crc >>> 8);
+ }
+ return (crc ^ 0xffffffff) >>> 0;
+};
+
+const zip_prepare_entries = (entries: ZipEntry[]): ZipEntryPrepared[] => {
+ const enc = new TextEncoder();
+ return entries.map((entry) => ({
+ name_bytes: enc.encode(entry.name),
+ data: entry.data,
+ crc32: crc32(entry.data),
+ size: entry.data.length
+ }));
+};
+
+const zip_dos_time = (date: Date): { time: number; date: number } => {
+ const year = Math.max(1980, date.getFullYear());
+ const month = date.getMonth() + 1;
+ const day = date.getDate();
+ const hours = date.getHours();
+ const minutes = date.getMinutes();
+ const seconds = Math.floor(date.getSeconds() / 2);
+ const time = (hours << 11) | (minutes << 5) | seconds;
+ const date_val = ((year - 1980) << 9) | (month << 5) | day;
+ return { time, date: date_val };
+};
+
+const zip_local_header = (entry: ZipEntryPrepared, time: number, date: number): Uint8Array => {
+ const name_len = entry.name_bytes.length;
+ const buffer = new ArrayBuffer(30 + name_len);
+ const view = new DataView(buffer);
+ view.setUint32(0, 0x04034b50, true);
+ view.setUint16(4, 20, true);
+ view.setUint16(6, 0, true);
+ view.setUint16(8, 0, true);
+ view.setUint16(10, time, true);
+ view.setUint16(12, date, true);
+ view.setUint32(14, entry.crc32, true);
+ view.setUint32(18, entry.size, true);
+ view.setUint32(22, entry.size, true);
+ view.setUint16(26, name_len, true);
+ view.setUint16(28, 0, true);
+ const out = new Uint8Array(buffer);
+ out.set(entry.name_bytes, 30);
+ return out;
+};
+
+const zip_central_header = (
+ entry: ZipEntryPrepared,
+ time: number,
+ date: number,
+ offset: number
+): Uint8Array => {
+ const name_len = entry.name_bytes.length;
+ const buffer = new ArrayBuffer(46 + name_len);
+ const view = new DataView(buffer);
+ view.setUint32(0, 0x02014b50, true);
+ view.setUint16(4, 20, true);
+ view.setUint16(6, 20, true);
+ view.setUint16(8, 0, true);
+ view.setUint16(10, 0, true);
+ view.setUint16(12, time, true);
+ view.setUint16(14, date, true);
+ view.setUint32(16, entry.crc32, true);
+ view.setUint32(20, entry.size, true);
+ view.setUint32(24, entry.size, true);
+ view.setUint16(28, name_len, true);
+ view.setUint16(30, 0, true);
+ view.setUint16(32, 0, true);
+ view.setUint16(34, 0, true);
+ view.setUint16(36, 0, true);
+ view.setUint32(38, 0, true);
+ view.setUint32(42, offset, true);
+ const out = new Uint8Array(buffer);
+ out.set(entry.name_bytes, 46);
+ return out;
+};
+
+const zip_end_record = (entry_count: number, central_size: number, central_offset: number): Uint8Array => {
+ const buffer = new ArrayBuffer(22);
+ const view = new DataView(buffer);
+ view.setUint32(0, 0x06054b50, true);
+ view.setUint16(4, 0, true);
+ view.setUint16(6, 0, true);
+ view.setUint16(8, entry_count, true);
+ view.setUint16(10, entry_count, true);
+ view.setUint32(12, central_size, true);
+ view.setUint32(16, central_offset, true);
+ view.setUint16(20, 0, true);
+ return new Uint8Array(buffer);
+};
+
+const zip_build_bytes = (entries: ZipEntry[]): Uint8Array => {
+ const prepared = zip_prepare_entries(entries);
+ const { time, date } = zip_dos_time(new Date());
+ const local_parts: Uint8Array[] = [];
+ const central_parts: Uint8Array[] = [];
+ let offset = 0;
+
+ for (const entry of prepared) {
+ const local = zip_local_header(entry, time, date);
+ local_parts.push(local, entry.data);
+ const central = zip_central_header(entry, time, date, offset);
+ central_parts.push(central);
+ offset += local.length + entry.data.length;
+ }
+
+ let central_size = 0;
+ for (const part of central_parts) central_size += part.length;
+ const end = zip_end_record(prepared.length, central_size, offset);
+ const total = offset + central_size + end.length;
+ const out = new Uint8Array(total);
+ let cursor = 0;
+ for (const part of local_parts) {
+ out.set(part, cursor);
+ cursor += part.length;
+ }
+ for (const part of central_parts) {
+ out.set(part, cursor);
+ cursor += part.length;
+ }
+ out.set(end, cursor);
+ return out;
+};
+
+const zip_write_stream = async (stream: ZipFileWritable, entries: ZipEntry[]): Promise<void> => {
+ const prepared = zip_prepare_entries(entries);
+ const { time, date } = zip_dos_time(new Date());
+ const central_parts: Uint8Array[] = [];
+ let offset = 0;
+
+ for (const entry of prepared) {
+ const local = zip_local_header(entry, time, date);
+ await stream.write(local);
+ await stream.write(entry.data);
+ central_parts.push(zip_central_header(entry, time, date, offset));
+ offset += local.length + entry.data.length;
+ }
+
+ let central_size = 0;
+ for (const part of central_parts) central_size += part.length;
+ for (const part of central_parts) await stream.write(part);
+ const end = zip_end_record(prepared.length, central_size, offset);
+ await stream.write(end);
+ await stream.close();
+};
+
+const bytes_to_hex = (bytes: Uint8Array): string => {
+ const hex: string[] = [];
+ for (let i = 0; i < bytes.length; i++) {
+ hex.push(bytes[i].toString(16).padStart(2, "0"));
+ }
+ return hex.join("");
+};
+
+const sha256_hex = async (bytes: Uint8Array): Promise<string> => {
+ if (!globalThis.crypto || !globalThis.crypto.subtle) throw new Error(cl_tangle_error.crypto_unavailable);
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
+ return bytes_to_hex(new Uint8Array(digest));
+};
+
+const filename_slug = (value: string): string => {
+ const slug = value
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+ if (slug.startsWith("radroots-")) return slug;
+ return `radroots-${slug}`;
+};
+
+const export_filename = (app_name: string, app_version: string, exported_at: string): string => {
+ const ts = exported_at.replace(/[:.]/g, "-");
+ const base = filename_slug(app_name);
+ return `${base}-${app_version}-backup-${ts}.zip`;
+};
+
+const get_zip_file_picker = (): ZipFilePicker | undefined => {
+ if (typeof window === "undefined") return undefined;
+ const picker = (window as Window & { showSaveFilePicker?: ZipFilePicker }).showSaveFilePicker;
+ return picker;
+};
+
+const can_share_file = (file: File): boolean => {
+ if (typeof navigator === "undefined") return false;
+ const nav = navigator as Navigator & { canShare?: (data: { files?: File[] }) => boolean };
+ if (!nav.canShare) return false;
+ return nav.canShare({ files: [file] });
+};
+
+const share_file = async (file: File): Promise<boolean> => {
+ if (typeof navigator === "undefined") return false;
+ const nav = navigator as Navigator & { share?: (data: { files?: File[]; title?: string }) => Promise<void> };
+ if (!nav.share) return false;
+ await nav.share({ files: [file], title: file.name });
+ return true;
+};
+
+const download_blob = (blob: Blob, filename: string): void => {
+ if (typeof document === "undefined") return;
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = filename;
+ anchor.click();
+ URL.revokeObjectURL(url);
+};
+
+const export_zip = async (filename: string, entries: ZipEntry[]): Promise<void> => {
+ const picker = get_zip_file_picker();
+ if (picker) {
+ const handle = await picker({
+ suggestedName: filename,
+ types: [
+ {
+ description: "Radroots tangle export",
+ accept: { "application/zip": [".zip"] }
+ }
+ ]
+ });
+ const stream = await handle.createWritable();
+ await zip_write_stream(stream, entries);
+ return;
+ }
+ const zip_bytes = zip_build_bytes(entries);
+ const blob = new Blob([zip_bytes], { type: "application/zip" });
+ const file = new File([blob], filename, { type: "application/zip" });
+ if (can_share_file(file)) {
+ const shared = await share_file(file);
+ if (shared) return;
+ }
+ download_blob(blob, filename);
+};
+
const DEFAULT_TANGLE_STORE_KEY = "radroots-pwa-v1-tangle-db";
const DEFAULT_TANGLE_IDB_CONFIG: IdbClientConfig = IDB_CONFIG_TANGLE;
let wasm_init_promise: Promise<void> | null = null;
@@ -468,10 +838,10 @@ export class WebTangleDatabase implements IWebTangleDatabase {
}
}
- async export_backup(): Promise<TangleDatabaseBackup | IError<string>> {
+ async export_json(): Promise<TangleDatabaseJsonExport | IError<string>> {
try {
await this.ensure_ready();
- const res = await tangle_db_export_backup();
+ const res = await tangle_db_export_json();
let parsed: unknown = res;
if (typeof res === "string") {
try {
@@ -480,23 +850,98 @@ export class WebTangleDatabase implements IWebTangleDatabase {
return err_msg(cl_tangle_error.parse_failure);
}
}
- if (!is_tangle_database_backup(parsed)) return err_msg(cl_tangle_error.invalid_response);
+ if (!is_tangle_database_json_export(parsed)) return err_msg(cl_tangle_error.invalid_response);
return parsed;
} catch (e) {
return handle_err(e);
}
}
- async import_backup(backup: TangleDatabaseBackup): Promise<void | IError<string>> {
+ async import_json(backup: TangleDatabaseJsonExport): Promise<void | IError<string>> {
try {
await this.ensure_ready();
- tangle_db_import_backup(this.serialize(backup));
+ tangle_db_import_json(this.serialize(backup));
+ return;
+ } catch (e) {
+ return handle_err(e);
+ }
+ }
+
+ async export_database(opts: TangleDatabaseExportOptions): Promise<void | IError<string>> {
+ try {
+ if (opts.store_key && opts.store_key !== this.store_key) {
+ const alt_db = new WebTangleDatabase({
+ store_key: opts.store_key,
+ idb_config: this.idb_config,
+ cipher_config: this.cipher_config,
+ sql_wasm_path: this.sql_wasm_path
+ });
+ const res = await alt_db.export_database(opts);
+ await alt_db.close();
+ return res;
+ }
+ await this.export_database_inner(opts);
return;
} catch (e) {
return handle_err(e);
}
}
+ private async export_database_inner(opts: TangleDatabaseExportOptions): Promise<void> {
+ await this.ensure_ready();
+ const app_name = opts.app_name;
+ const app_version = opts.app_version;
+ const store_key = this.store_key;
+ let export_active = false;
+
+ try {
+ const snapshot_raw = await tangle_db_export_begin();
+ export_active = true;
+ if (!is_export_snapshot(snapshot_raw)) throw new Error(cl_tangle_error.invalid_response);
+ const manifest_rs = snapshot_raw.manifest_rs;
+ const db_bytes = snapshot_raw.db_bytes;
+ const db_sha256 = await sha256_hex(db_bytes);
+ const exported_at = new Date().toISOString();
+
+ const manifest_ts_base: TangleDatabaseExportManifestTs = {
+ app_name,
+ app_version,
+ exported_at,
+ db_sha256,
+ db_size_bytes: db_bytes.byteLength,
+ store_key
+ };
+
+ let manifest: TangleDatabaseExportManifest = {
+ rs: manifest_rs,
+ ts: manifest_ts_base
+ };
+
+ if (opts.signer) {
+ const nostr_event = await opts.signer({ db_sha256, manifest });
+ if (nostr_event) {
+ manifest = {
+ rs: manifest_rs,
+ ts: {
+ ...manifest_ts_base,
+ nostr_event
+ }
+ };
+ }
+ }
+
+ const manifest_json = JSON.stringify(manifest, null, 2);
+ const manifest_bytes = new TextEncoder().encode(manifest_json);
+ const filename = export_filename(app_name, app_version, exported_at);
+ await export_zip(filename, [
+ { name: "manifest.json", data: manifest_bytes },
+ { name: "tangle.db", data: db_bytes }
+ ]);
+ } finally {
+ if (export_active) tangle_db_export_finish();
+ }
+ }
+
async farm_create(opts: IFarmCreate): Promise<IFarmCreateResolve | IError<string>> {
await this.ensure_ready();
const res = await tangle_db_farm_create(this.serialize(opts));