Apply editorconfig
This commit is contained in:
parent
ad3191957a
commit
b05e415acf
131 changed files with 17155 additions and 14368 deletions
|
|
@ -87,7 +87,7 @@ Rust structs generate TypeScript types via ts-rs crate, stored in `sync-server/b
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
### Running Tests
|
### Running Tests
|
||||||
- Server: `cargo test --verbose`
|
- Server: `cargo test --verbose`
|
||||||
- Frontend: `npm run test` (runs Jest across all workspaces)
|
- Frontend: `npm run test` (runs Jest across all workspaces)
|
||||||
- E2E: `scripts/e2e.sh`
|
- E2E: `scripts/e2e.sh`
|
||||||
|
|
||||||
|
|
@ -107,4 +107,4 @@ Rust structs generate TypeScript types via ts-rs crate, stored in `sync-server/b
|
||||||
### TypeScript
|
### TypeScript
|
||||||
- Prettier configuration: 4-space tabs, trailing commas removed, LF line endings
|
- Prettier configuration: 4-space tabs, trailing commas removed, LF line endings
|
||||||
- ESLint with unused imports plugin
|
- ESLint with unused imports plugin
|
||||||
- Consistent across all three frontend packages
|
- Consistent across all three frontend packages
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,60 @@
|
||||||
import { defineConfig } from "vitepress"
|
import { defineConfig } from "vitepress"
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
title: "VaultLink",
|
title: "VaultLink",
|
||||||
description: "Self-hosted real-time synchronisation for Obsidian",
|
description: "Self-hosted real-time synchronisation for Obsidian",
|
||||||
base: "/vault-link/",
|
base: "/vault-link/",
|
||||||
themeConfig: {
|
themeConfig: {
|
||||||
logo: "/logo.svg",
|
logo: "/logo.svg",
|
||||||
nav: [
|
nav: [
|
||||||
{ text: "Home", link: "/" },
|
{ text: "Home", link: "/" },
|
||||||
{ text: "Guide", link: "/guide/getting-started" },
|
{ text: "Guide", link: "/guide/getting-started" },
|
||||||
{ text: "Architecture", link: "/architecture/" },
|
{ text: "Architecture", link: "/architecture/" },
|
||||||
{ text: "GitHub", link: "https://github.com/schmelczer/vault-link" }
|
{ text: "GitHub", link: "https://github.com/schmelczer/vault-link" }
|
||||||
],
|
],
|
||||||
sidebar: [
|
sidebar: [
|
||||||
{
|
{
|
||||||
text: "Introduction",
|
text: "Introduction",
|
||||||
items: [
|
items: [
|
||||||
{ text: "What is VaultLink?", link: "/guide/what-is-vaultlink" },
|
{ text: "What is VaultLink?", link: "/guide/what-is-vaultlink" },
|
||||||
{ text: "Getting Started", link: "/guide/getting-started" },
|
{ text: "Getting Started", link: "/guide/getting-started" },
|
||||||
{ text: "Limitations", link: "/guide/limitations" },
|
{ text: "Limitations", link: "/guide/limitations" },
|
||||||
{ text: "Comparison with Alternatives", link: "/guide/alternatives" }
|
{ text: "Comparison with Alternatives", link: "/guide/alternatives" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Setup",
|
text: "Setup",
|
||||||
items: [
|
items: [
|
||||||
{ text: "Server Setup", link: "/guide/server-setup" },
|
{ text: "Server Setup", link: "/guide/server-setup" },
|
||||||
{ text: "Obsidian Plugin", link: "/guide/obsidian-plugin" },
|
{ text: "Obsidian Plugin", link: "/guide/obsidian-plugin" },
|
||||||
{ text: "CLI Client", link: "/guide/cli-client" }
|
{ text: "CLI Client", link: "/guide/cli-client" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Configuration",
|
text: "Configuration",
|
||||||
items: [
|
items: [
|
||||||
{ text: "Server Configuration", link: "/config/server" },
|
{ text: "Server Configuration", link: "/config/server" },
|
||||||
{ text: "Authentication", link: "/config/authentication" },
|
{ text: "Authentication", link: "/config/authentication" },
|
||||||
{ text: "Advanced Options", link: "/config/advanced" }
|
{ text: "Advanced Options", link: "/config/advanced" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Architecture",
|
text: "Architecture",
|
||||||
items: [
|
items: [
|
||||||
{ text: "Overview", link: "/architecture/" },
|
{ text: "Overview", link: "/architecture/" },
|
||||||
{ text: "Sync Algorithm", link: "/architecture/sync-algorithm" },
|
{ text: "Sync Algorithm", link: "/architecture/sync-algorithm" },
|
||||||
{ text: "Data Flow", link: "/architecture/data-flow" }
|
{ text: "Data Flow", link: "/architecture/data-flow" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
socialLinks: [{ icon: "github", link: "https://github.com/schmelczer/vault-link" }],
|
socialLinks: [{ icon: "github", link: "https://github.com/schmelczer/vault-link" }],
|
||||||
footer: {
|
footer: {
|
||||||
message: "Released under the MIT License.",
|
message: "Released under the MIT License.",
|
||||||
copyright: "Copyright © 2024-present Andras Schmelczer"
|
copyright: "Copyright © 2024-present Andras Schmelczer"
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
provider: "local"
|
provider: "local"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
head: [["link", { rel: "icon", type: "image/svg+xml", href: "/vault-link/logo.svg" }]]
|
head: [["link", { rel: "icon", type: "image/svg+xml", href: "/vault-link/logo.svg" }]]
|
||||||
})
|
})
|
||||||
|
|
|
||||||
3590
docs/package-lock.json
generated
3590
docs/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -22,4 +22,4 @@
|
||||||
"vitepress": "^1.6.4",
|
"vitepress": "^1.6.4",
|
||||||
"vue": "^3.5.24"
|
"vue": "^3.5.24"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
<stop offset="0%" style="stop-color:#4A90E2;stop-opacity:1" />
|
<stop offset="0%" style="stop-color:#4A90E2;stop-opacity:1" />
|
||||||
<stop offset="100%" style="stop-color:#357ABD;stop-opacity:1" />
|
<stop offset="100%" style="stop-color:#357ABD;stop-opacity:1" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
|
|
@ -25,23 +25,23 @@
|
||||||
|
|
||||||
<!-- Link chain -->
|
<!-- Link chain -->
|
||||||
<g opacity="0.9">
|
<g opacity="0.9">
|
||||||
<!-- Left link -->
|
<!-- Left link -->
|
||||||
<ellipse cx="-30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
<ellipse cx="-30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
||||||
<!-- Right link -->
|
<!-- Right link -->
|
||||||
<ellipse cx="30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
<ellipse cx="30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
||||||
<!-- Center link connecting them -->
|
<!-- Center link connecting them -->
|
||||||
<ellipse cx="0" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
<ellipse cx="0" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- Sync arrows (subtle) -->
|
<!-- Sync arrows (subtle) -->
|
||||||
<g opacity="0.5">
|
<g opacity="0.5">
|
||||||
<!-- Clockwise arrow top-right -->
|
<!-- Clockwise arrow top-right -->
|
||||||
<path d="M 35 -35 Q 50 -35 50 -20 L 50 -15" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
|
<path d="M 35 -35 Q 50 -35 50 -20 L 50 -15" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
<polygon points="50,-15 47,-22 53,-22" fill="url(#grad1)"/>
|
<polygon points="50,-15 47,-22 53,-22" fill="url(#grad1)"/>
|
||||||
|
|
||||||
<!-- Counter-clockwise arrow bottom-left -->
|
<!-- Counter-clockwise arrow bottom-left -->
|
||||||
<path d="M -35 25 Q -50 25 -50 10 L -50 5" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
|
<path d="M -35 25 Q -50 25 -50 10 L -50 5" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
<polygon points="-50,5 -47,12 -53,12" fill="url(#grad1)"/>
|
<polygon points="-50,5 -47,12 -53,12" fill="url(#grad1)"/>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 2 KiB |
|
|
@ -3,92 +3,92 @@ import tseslint from "typescript-eslint";
|
||||||
import unusedImports from "eslint-plugin-unused-imports";
|
import unusedImports from "eslint-plugin-unused-imports";
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
"sync-client/src/services/types.ts",
|
"sync-client/src/services/types.ts",
|
||||||
"**/dist/",
|
"**/dist/",
|
||||||
"**/*.mjs",
|
"**/*.mjs",
|
||||||
"**/*.js"
|
"**/*.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
...tseslint.config({
|
...tseslint.config({
|
||||||
plugins: {
|
plugins: {
|
||||||
"unused-imports": unusedImports
|
"unused-imports": unusedImports
|
||||||
},
|
},
|
||||||
extends: [eslint.configs.recommended, tseslint.configs.all],
|
extends: [eslint.configs.recommended, tseslint.configs.all],
|
||||||
rules: {
|
rules: {
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "off",
|
||||||
"@typescript-eslint/restrict-template-expressions": "off",
|
"@typescript-eslint/restrict-template-expressions": "off",
|
||||||
"@typescript-eslint/no-unused-vars": "off",
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
"@typescript-eslint/no-floating-promises": [
|
"@typescript-eslint/no-floating-promises": [
|
||||||
"error",
|
"error",
|
||||||
{
|
|
||||||
allowForKnownSafeCalls: [
|
|
||||||
{ from: "package", name: ["suite", "test"], package: "node:test" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"@typescript-eslint/parameter-properties": "off",
|
|
||||||
"@typescript-eslint/require-await": "off",
|
|
||||||
"@typescript-eslint/class-methods-use-this": "off",
|
|
||||||
"@typescript-eslint/consistent-return": "off",
|
|
||||||
"@typescript-eslint/no-unsafe-argument": "off",
|
|
||||||
"@typescript-eslint/max-params": "off",
|
|
||||||
"@typescript-eslint/no-magic-numbers": "off",
|
|
||||||
"@typescript-eslint/prefer-readonly-parameter-types": "off",
|
|
||||||
"@typescript-eslint/naming-convention": "off",
|
|
||||||
"no-restricted-properties": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
object: "Promise",
|
|
||||||
property: "all",
|
|
||||||
message: "Use `awaitAll` instead of Promise.all to always await all promises."
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
object: "Promise",
|
allowForKnownSafeCalls: [
|
||||||
property: "allSettled",
|
{ from: "package", name: ["suite", "test"], package: "node:test" },
|
||||||
message: "Use `awaitAll` instead of Promise.allSettled to always await all promises and throw on errors."
|
],
|
||||||
},
|
},
|
||||||
{
|
],
|
||||||
object: "String",
|
"@typescript-eslint/parameter-properties": "off",
|
||||||
property: "replace",
|
"@typescript-eslint/require-await": "off",
|
||||||
message: "Use replaceAll instead of replace to replace all occurrences of a substring."
|
"@typescript-eslint/class-methods-use-this": "off",
|
||||||
}
|
"@typescript-eslint/consistent-return": "off",
|
||||||
],
|
"@typescript-eslint/no-unsafe-argument": "off",
|
||||||
"no-restricted-syntax": [
|
"@typescript-eslint/max-params": "off",
|
||||||
"error",
|
"@typescript-eslint/no-magic-numbers": "off",
|
||||||
{
|
"@typescript-eslint/prefer-readonly-parameter-types": "off",
|
||||||
selector: "CallExpression[callee.property.name='splice'][arguments.length=2][arguments.1.type='Literal'][arguments.1.value=1]",
|
"@typescript-eslint/naming-convention": "off",
|
||||||
message: "Use `removeFromArray(array, item)` instead of manually using indexOf + splice(index, 1). Import from 'sync-client/src/utils/remove-from-array'."
|
"no-restricted-properties": [
|
||||||
},
|
"error",
|
||||||
{
|
{
|
||||||
selector: "CallExpression[callee.property.name='filter'] > ArrowFunctionExpression[body.type='BinaryExpression'][body.operator='!==']",
|
object: "Promise",
|
||||||
message: "Use `removeFromArray(array, item)` instead of filter(x => x !== item) for better performance. Import from 'sync-client/src/utils/remove-from-array'."
|
property: "all",
|
||||||
},
|
message: "Use `awaitAll` instead of Promise.all to always await all promises."
|
||||||
{
|
},
|
||||||
selector: "CallExpression[callee.property.name='filter'] > ArrowFunctionExpression > BlockStatement > ReturnStatement > BinaryExpression[operator='!==']",
|
{
|
||||||
message: "Use `removeFromArray(array, item)` instead of filter(x => { return x !== item }) for better performance. Import from 'sync-client/src/utils/remove-from-array'."
|
object: "Promise",
|
||||||
},
|
property: "allSettled",
|
||||||
{
|
message: "Use `awaitAll` instead of Promise.allSettled to always await all promises and throw on errors."
|
||||||
selector: "CallExpression[callee.property.name='filter'] > FunctionExpression[body.type='BlockStatement'] > BlockStatement > ReturnStatement > BinaryExpression[operator='!==']",
|
},
|
||||||
message: "Use `removeFromArray(array, item)` instead of filter(function(x) { return x !== item }) for better performance. Import from 'sync-client/src/utils/remove-from-array'."
|
{
|
||||||
}
|
object: "String",
|
||||||
],
|
property: "replace",
|
||||||
"unused-imports/no-unused-vars": [
|
message: "Use replaceAll instead of replace to replace all occurrences of a substring."
|
||||||
"warn",
|
}
|
||||||
{
|
],
|
||||||
vars: "all",
|
"no-restricted-syntax": [
|
||||||
varsIgnorePattern: "^_",
|
"error",
|
||||||
args: "after-used",
|
{
|
||||||
argsIgnorePattern: "^_"
|
selector: "CallExpression[callee.property.name='splice'][arguments.length=2][arguments.1.type='Literal'][arguments.1.value=1]",
|
||||||
}
|
message: "Use `removeFromArray(array, item)` instead of manually using indexOf + splice(index, 1). Import from 'sync-client/src/utils/remove-from-array'."
|
||||||
]
|
},
|
||||||
},
|
{
|
||||||
languageOptions: {
|
selector: "CallExpression[callee.property.name='filter'] > ArrowFunctionExpression[body.type='BinaryExpression'][body.operator='!==']",
|
||||||
parserOptions: {
|
message: "Use `removeFromArray(array, item)` instead of filter(x => x !== item) for better performance. Import from 'sync-client/src/utils/remove-from-array'."
|
||||||
projectService: true,
|
},
|
||||||
tsconfigRootDir: import.meta.dirname
|
{
|
||||||
}
|
selector: "CallExpression[callee.property.name='filter'] > ArrowFunctionExpression > BlockStatement > ReturnStatement > BinaryExpression[operator='!==']",
|
||||||
}
|
message: "Use `removeFromArray(array, item)` instead of filter(x => { return x !== item }) for better performance. Import from 'sync-client/src/utils/remove-from-array'."
|
||||||
})
|
},
|
||||||
|
{
|
||||||
|
selector: "CallExpression[callee.property.name='filter'] > FunctionExpression[body.type='BlockStatement'] > BlockStatement > ReturnStatement > BinaryExpression[operator='!==']",
|
||||||
|
message: "Use `removeFromArray(array, item)` instead of filter(function(x) { return x !== item }) for better performance. Import from 'sync-client/src/utils/remove-from-array'."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"unused-imports/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
vars: "all",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
args: "after-used",
|
||||||
|
argsIgnorePattern: "^_"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -4,227 +4,227 @@ import { parseArgs } from "./args";
|
||||||
import { LogLevel } from "sync-client";
|
import { LogLevel } from "sync-client";
|
||||||
|
|
||||||
test("parseArgs - parse basic arguments", () => {
|
test("parseArgs - parse basic arguments", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default"
|
"default"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.equal(args.localPath, "/path/to/vault");
|
assert.equal(args.localPath, "/path/to/vault");
|
||||||
assert.equal(args.remoteUri, "https://sync.example.com");
|
assert.equal(args.remoteUri, "https://sync.example.com");
|
||||||
assert.equal(args.token, "mytoken");
|
assert.equal(args.token, "mytoken");
|
||||||
assert.equal(args.vaultName, "default");
|
assert.equal(args.vaultName, "default");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - parse long form arguments", () => {
|
test("parseArgs - parse long form arguments", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"--local-path",
|
"--local-path",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"--remote-uri",
|
"--remote-uri",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"--token",
|
"--token",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"--vault-name",
|
"--vault-name",
|
||||||
"default"
|
"default"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.equal(args.localPath, "/path/to/vault");
|
assert.equal(args.localPath, "/path/to/vault");
|
||||||
assert.equal(args.remoteUri, "https://sync.example.com");
|
assert.equal(args.remoteUri, "https://sync.example.com");
|
||||||
assert.equal(args.token, "mytoken");
|
assert.equal(args.token, "mytoken");
|
||||||
assert.equal(args.vaultName, "default");
|
assert.equal(args.vaultName, "default");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - parse with optional arguments", () => {
|
test("parseArgs - parse with optional arguments", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default",
|
"default",
|
||||||
"--sync-concurrency",
|
"--sync-concurrency",
|
||||||
"5",
|
"5",
|
||||||
"--max-file-size-mb",
|
"--max-file-size-mb",
|
||||||
"20"
|
"20"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.equal(args.syncConcurrency, 5);
|
assert.equal(args.syncConcurrency, 5);
|
||||||
assert.equal(args.maxFileSizeMB, 20);
|
assert.equal(args.maxFileSizeMB, 20);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - parse with multiple ignore patterns", () => {
|
test("parseArgs - parse with multiple ignore patterns", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default",
|
"default",
|
||||||
"--ignore-pattern",
|
"--ignore-pattern",
|
||||||
".git/**",
|
".git/**",
|
||||||
"*.tmp"
|
"*.tmp"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.deepEqual(args.ignorePatterns, [".git/**", "*.tmp"]);
|
assert.deepEqual(args.ignorePatterns, [".git/**", "*.tmp"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - throws on missing required arguments", () => {
|
test("parseArgs - throws on missing required arguments", () => {
|
||||||
assert.throws(() => {
|
assert.throws(() => {
|
||||||
parseArgs(["node", "cli.js", "-r", "https://sync.example.com"]);
|
parseArgs(["node", "cli.js", "-r", "https://sync.example.com"]);
|
||||||
}, /required option/);
|
}, /required option/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - throws on missing remote uri", () => {
|
test("parseArgs - throws on missing remote uri", () => {
|
||||||
assert.throws(() => {
|
assert.throws(() => {
|
||||||
parseArgs([
|
parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default"
|
"default"
|
||||||
]);
|
]);
|
||||||
}, /--remote-uri/);
|
}, /--remote-uri/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - throws on missing token", () => {
|
test("parseArgs - throws on missing token", () => {
|
||||||
assert.throws(() => {
|
assert.throws(() => {
|
||||||
parseArgs([
|
parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-v",
|
"-v",
|
||||||
"default"
|
"default"
|
||||||
]);
|
]);
|
||||||
}, /--token/);
|
}, /--token/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - throws on missing vault name", () => {
|
test("parseArgs - throws on missing vault name", () => {
|
||||||
assert.throws(() => {
|
assert.throws(() => {
|
||||||
parseArgs([
|
parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken"
|
"mytoken"
|
||||||
]);
|
]);
|
||||||
}, /--vault-name/);
|
}, /--vault-name/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - default log level is INFO", () => {
|
test("parseArgs - default log level is INFO", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default"
|
"default"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.equal(args.logLevel, LogLevel.INFO);
|
assert.equal(args.logLevel, LogLevel.INFO);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - parse DEBUG log level", () => {
|
test("parseArgs - parse DEBUG log level", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default",
|
"default",
|
||||||
"--log-level",
|
"--log-level",
|
||||||
"DEBUG"
|
"DEBUG"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.equal(args.logLevel, LogLevel.DEBUG);
|
assert.equal(args.logLevel, LogLevel.DEBUG);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - parse ERROR log level", () => {
|
test("parseArgs - parse ERROR log level", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default",
|
"default",
|
||||||
"--log-level",
|
"--log-level",
|
||||||
"ERROR"
|
"ERROR"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.equal(args.logLevel, LogLevel.ERROR);
|
assert.equal(args.logLevel, LogLevel.ERROR);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - log level is case insensitive", () => {
|
test("parseArgs - log level is case insensitive", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default",
|
"default",
|
||||||
"--log-level",
|
"--log-level",
|
||||||
"debug"
|
"debug"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.equal(args.logLevel, LogLevel.DEBUG);
|
assert.equal(args.logLevel, LogLevel.DEBUG);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - throws on invalid log level", () => {
|
test("parseArgs - throws on invalid log level", () => {
|
||||||
assert.throws(() => {
|
assert.throws(() => {
|
||||||
parseArgs([
|
parseArgs([
|
||||||
"node",
|
"node",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"-l",
|
"-l",
|
||||||
"/path/to/vault",
|
"/path/to/vault",
|
||||||
"-r",
|
"-r",
|
||||||
"https://sync.example.com",
|
"https://sync.example.com",
|
||||||
"-t",
|
"-t",
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default",
|
"default",
|
||||||
"--log-level",
|
"--log-level",
|
||||||
"INVALID"
|
"INVALID"
|
||||||
]);
|
]);
|
||||||
}, /Invalid log level/);
|
}, /Invalid log level/);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,134 +3,134 @@ import packageJson from "../package.json";
|
||||||
import { LogLevel } from "sync-client";
|
import { LogLevel } from "sync-client";
|
||||||
|
|
||||||
export interface CliArgs {
|
export interface CliArgs {
|
||||||
remoteUri: string;
|
remoteUri: string;
|
||||||
token: string;
|
token: string;
|
||||||
vaultName: string;
|
vaultName: string;
|
||||||
localPath: string;
|
localPath: string;
|
||||||
syncConcurrency?: number;
|
syncConcurrency?: number;
|
||||||
maxFileSizeMB?: number;
|
maxFileSizeMB?: number;
|
||||||
ignorePatterns?: string[];
|
ignorePatterns?: string[];
|
||||||
webSocketRetryIntervalMs?: number;
|
webSocketRetryIntervalMs?: number;
|
||||||
logLevel: LogLevel;
|
logLevel: LogLevel;
|
||||||
health?: string;
|
health?: string;
|
||||||
enableTelemetry?: boolean;
|
enableTelemetry?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseArgs(argv: string[]): CliArgs {
|
export function parseArgs(argv: string[]): CliArgs {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
program
|
program
|
||||||
.name("vaultlink")
|
.name("vaultlink")
|
||||||
.description(
|
.description(
|
||||||
"VaultLink Local CLI - Sync your vault to the local filesystem"
|
"VaultLink Local CLI - Sync your vault to the local filesystem"
|
||||||
)
|
)
|
||||||
.version(packageJson.version)
|
.version(packageJson.version)
|
||||||
.option("-l, --local-path <path>", "Local directory path to sync")
|
.option("-l, --local-path <path>", "Local directory path to sync")
|
||||||
.option("-r, --remote-uri <uri>", "Remote server URI")
|
.option("-r, --remote-uri <uri>", "Remote server URI")
|
||||||
.option("-t, --token <token>", "Authentication token")
|
.option("-t, --token <token>", "Authentication token")
|
||||||
.option("-v, --vault-name <name>", "Vault name")
|
.option("-v, --vault-name <name>", "Vault name")
|
||||||
.option(
|
.option(
|
||||||
"--sync-concurrency <number>",
|
"--sync-concurrency <number>",
|
||||||
"[OPTIONAL] Number of concurrent sync operations",
|
"[OPTIONAL] Number of concurrent sync operations",
|
||||||
parseInt
|
parseInt
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--max-file-size-mb <number>",
|
"--max-file-size-mb <number>",
|
||||||
"[OPTIONAL] Maximum file size in MB",
|
"[OPTIONAL] Maximum file size in MB",
|
||||||
parseInt
|
parseInt
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--ignore-pattern <pattern...>",
|
"--ignore-pattern <pattern...>",
|
||||||
"[OPTIONAL] Patterns to ignore (can be specified multiple times)"
|
"[OPTIONAL] Patterns to ignore (can be specified multiple times)"
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--websocket-retry-interval-ms <number>",
|
"--websocket-retry-interval-ms <number>",
|
||||||
"[OPTIONAL] WebSocket retry interval in milliseconds",
|
"[OPTIONAL] WebSocket retry interval in milliseconds",
|
||||||
parseInt
|
parseInt
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--log-level <level>",
|
"--log-level <level>",
|
||||||
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)",
|
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)",
|
||||||
"INFO"
|
"INFO"
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--health <path>",
|
"--health <path>",
|
||||||
"[OPTIONAL] Path to health status file for Docker healthcheck"
|
"[OPTIONAL] Path to health status file for Docker healthcheck"
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--enable-telemetry",
|
"--enable-telemetry",
|
||||||
"[OPTIONAL] Enable telemetry (disabled by default)"
|
"[OPTIONAL] Enable telemetry (disabled by default)"
|
||||||
)
|
)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
"after",
|
"after",
|
||||||
`
|
`
|
||||||
Examples:
|
Examples:
|
||||||
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default
|
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default
|
||||||
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
|
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
|
||||||
--ignore-pattern ".git/**" --ignore-pattern "*.tmp"
|
--ignore-pattern ".git/**" --ignore-pattern "*.tmp"
|
||||||
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
|
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
|
||||||
--log-level DEBUG
|
--log-level DEBUG
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
|
|
||||||
program.parse(argv);
|
program.parse(argv);
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
const localPath = opts.localPath as string | undefined;
|
const localPath = opts.localPath as string | undefined;
|
||||||
const remoteUri = opts.remoteUri as string | undefined;
|
const remoteUri = opts.remoteUri as string | undefined;
|
||||||
const token = opts.token as string | undefined;
|
const token = opts.token as string | undefined;
|
||||||
const vaultName = opts.vaultName as string | undefined;
|
const vaultName = opts.vaultName as string | undefined;
|
||||||
const syncConcurrency = opts.syncConcurrency as number | undefined;
|
const syncConcurrency = opts.syncConcurrency as number | undefined;
|
||||||
const maxFileSizeMb = opts.maxFileSizeMb as number | undefined;
|
const maxFileSizeMb = opts.maxFileSizeMb as number | undefined;
|
||||||
const ignorePattern = opts.ignorePattern as string[] | undefined;
|
const ignorePattern = opts.ignorePattern as string[] | undefined;
|
||||||
const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as
|
const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as
|
||||||
| number
|
| number
|
||||||
| undefined;
|
| undefined;
|
||||||
const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO";
|
const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO";
|
||||||
const health = opts.health as string | undefined;
|
const health = opts.health as string | undefined;
|
||||||
const enableTelemetry = opts.enableTelemetry as boolean | undefined;
|
const enableTelemetry = opts.enableTelemetry as boolean | undefined;
|
||||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
||||||
|
|
||||||
if (localPath === undefined) {
|
if (localPath === undefined) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"required option '-l, --local-path <path>' not specified"
|
"required option '-l, --local-path <path>' not specified"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (remoteUri === undefined) {
|
if (remoteUri === undefined) {
|
||||||
throw new Error("required option '--remote-uri <uri>' not specified");
|
throw new Error("required option '--remote-uri <uri>' not specified");
|
||||||
}
|
}
|
||||||
if (token === undefined) {
|
if (token === undefined) {
|
||||||
throw new Error("required option '--token <token>' not specified");
|
throw new Error("required option '--token <token>' not specified");
|
||||||
}
|
}
|
||||||
if (vaultName === undefined) {
|
if (vaultName === undefined) {
|
||||||
throw new Error("required option '--vault-name <name>' not specified");
|
throw new Error("required option '--vault-name <name>' not specified");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate and parse log level
|
// Validate and parse log level
|
||||||
const logLevelUpper = logLevelStr.toUpperCase();
|
const logLevelUpper = logLevelStr.toUpperCase();
|
||||||
const validLogLevels = Object.values(LogLevel);
|
const validLogLevels = Object.values(LogLevel);
|
||||||
const isLogLevel = (value: string): value is LogLevel => {
|
const isLogLevel = (value: string): value is LogLevel => {
|
||||||
return (validLogLevels as readonly string[]).includes(value);
|
return (validLogLevels as readonly string[]).includes(value);
|
||||||
};
|
};
|
||||||
if (!isLogLevel(logLevelUpper)) {
|
if (!isLogLevel(logLevelUpper)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid log level '${logLevelStr}'. Valid values are: ${validLogLevels.join(", ")}`
|
`Invalid log level '${logLevelStr}'. Valid values are: ${validLogLevels.join(", ")}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const logLevel = logLevelUpper;
|
const logLevel = logLevelUpper;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
localPath,
|
localPath,
|
||||||
remoteUri,
|
remoteUri,
|
||||||
token,
|
token,
|
||||||
vaultName,
|
vaultName,
|
||||||
syncConcurrency,
|
syncConcurrency,
|
||||||
maxFileSizeMB: maxFileSizeMb,
|
maxFileSizeMB: maxFileSizeMb,
|
||||||
ignorePatterns: ignorePattern,
|
ignorePatterns: ignorePattern,
|
||||||
webSocketRetryIntervalMs: websocketRetryIntervalMs,
|
webSocketRetryIntervalMs: websocketRetryIntervalMs,
|
||||||
logLevel,
|
logLevel,
|
||||||
health,
|
health,
|
||||||
enableTelemetry
|
enableTelemetry
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@ import * as fs from "fs/promises";
|
||||||
import * as fsSync from "fs";
|
import * as fsSync from "fs";
|
||||||
import type { NetworkConnectionStatus } from "sync-client";
|
import type { NetworkConnectionStatus } from "sync-client";
|
||||||
import {
|
import {
|
||||||
SyncClient,
|
SyncClient,
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
LogLevel,
|
LogLevel,
|
||||||
type SyncSettings,
|
type SyncSettings,
|
||||||
type StoredDatabase
|
type StoredDatabase
|
||||||
} from "sync-client";
|
} from "sync-client";
|
||||||
import { parseArgs } from "./args";
|
import { parseArgs } from "./args";
|
||||||
import { NodeFileSystemOperations } from "./node-filesystem";
|
import { NodeFileSystemOperations } from "./node-filesystem";
|
||||||
|
|
@ -16,229 +16,229 @@ import { formatLogLine, colorize, styleText } from "./logger-formatter";
|
||||||
import packageJson from "../package.json";
|
import packageJson from "../package.json";
|
||||||
|
|
||||||
function writeHealthStatus(
|
function writeHealthStatus(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
connectionStatus: NetworkConnectionStatus
|
connectionStatus: NetworkConnectionStatus
|
||||||
): void {
|
): void {
|
||||||
try {
|
try {
|
||||||
fsSync.writeFileSync(filePath, JSON.stringify(connectionStatus));
|
fsSync.writeFileSync(filePath, JSON.stringify(connectionStatus));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`Failed to write health status to ${filePath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to write health status to ${filePath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOG_LEVEL_ORDER = {
|
const LOG_LEVEL_ORDER = {
|
||||||
[LogLevel.DEBUG]: 0,
|
[LogLevel.DEBUG]: 0,
|
||||||
[LogLevel.INFO]: 1,
|
[LogLevel.INFO]: 1,
|
||||||
[LogLevel.WARNING]: 2,
|
[LogLevel.WARNING]: 2,
|
||||||
[LogLevel.ERROR]: 3
|
[LogLevel.ERROR]: 3
|
||||||
};
|
};
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const args = parseArgs(process.argv);
|
const args = parseArgs(process.argv);
|
||||||
const absolutePath = path.resolve(args.localPath);
|
const absolutePath = path.resolve(args.localPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stats = await fs.stat(absolutePath);
|
const stats = await fs.stat(absolutePath);
|
||||||
if (!stats.isDirectory()) {
|
if (!stats.isDirectory()) {
|
||||||
console.error(
|
console.error(
|
||||||
colorize(`Error: ${absolutePath} is not a directory`, "red")
|
colorize(`Error: ${absolutePath} is not a directory`, "red")
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
colorize(
|
colorize(
|
||||||
`Error: Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`,
|
`Error: Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
"red"
|
"red"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
styleText("VaultLink Local CLI", "bold", "cyan") +
|
styleText("VaultLink Local CLI", "bold", "cyan") +
|
||||||
colorize(` v${packageJson.version}`, "dim")
|
colorize(` v${packageJson.version}`, "dim")
|
||||||
);
|
);
|
||||||
console.log(colorize("=".repeat(50), "dim"));
|
console.log(colorize("=".repeat(50), "dim"));
|
||||||
console.log(
|
console.log(
|
||||||
`${colorize("Local path:", "dim")} ${colorize(absolutePath, "green")}`
|
`${colorize("Local path:", "dim")} ${colorize(absolutePath, "green")}`
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
`${colorize("Remote URI:", "dim")} ${colorize(args.remoteUri, "cyan")}`
|
`${colorize("Remote URI:", "dim")} ${colorize(args.remoteUri, "cyan")}`
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
`${colorize("Vault name:", "dim")} ${colorize(args.vaultName, "green")}`
|
`${colorize("Vault name:", "dim")} ${colorize(args.vaultName, "green")}`
|
||||||
);
|
);
|
||||||
console.log("");
|
console.log("");
|
||||||
|
|
||||||
const dataDir = path.join(absolutePath, ".vaultlink");
|
const dataDir = path.join(absolutePath, ".vaultlink");
|
||||||
const dataFile = path.join(dataDir, "sync-data.json");
|
const dataFile = path.join(dataDir, "sync-data.json");
|
||||||
|
|
||||||
await fs.mkdir(dataDir, { recursive: true });
|
await fs.mkdir(dataDir, { recursive: true });
|
||||||
|
|
||||||
const fileSystem = new NodeFileSystemOperations(absolutePath);
|
const fileSystem = new NodeFileSystemOperations(absolutePath);
|
||||||
|
|
||||||
const ignorePatterns = [
|
const ignorePatterns = [
|
||||||
...(args.ignorePatterns ?? []),
|
...(args.ignorePatterns ?? []),
|
||||||
".vaultlink/**",
|
".vaultlink/**",
|
||||||
".git/**"
|
".git/**"
|
||||||
];
|
];
|
||||||
|
|
||||||
const settings: SyncSettings = {
|
const settings: SyncSettings = {
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
remoteUri: args.remoteUri,
|
remoteUri: args.remoteUri,
|
||||||
token: args.token,
|
token: args.token,
|
||||||
vaultName: args.vaultName,
|
vaultName: args.vaultName,
|
||||||
syncConcurrency:
|
syncConcurrency:
|
||||||
args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency,
|
args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency,
|
||||||
maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB,
|
maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB,
|
||||||
ignorePatterns,
|
ignorePatterns,
|
||||||
webSocketRetryIntervalMs:
|
webSocketRetryIntervalMs:
|
||||||
args.webSocketRetryIntervalMs ??
|
args.webSocketRetryIntervalMs ??
|
||||||
DEFAULT_SETTINGS.webSocketRetryIntervalMs,
|
DEFAULT_SETTINGS.webSocketRetryIntervalMs,
|
||||||
isSyncEnabled: true,
|
isSyncEnabled: true,
|
||||||
enableTelemetry:
|
enableTelemetry:
|
||||||
args.enableTelemetry ?? DEFAULT_SETTINGS.enableTelemetry
|
args.enableTelemetry ?? DEFAULT_SETTINGS.enableTelemetry
|
||||||
};
|
};
|
||||||
|
|
||||||
const client = await SyncClient.create({
|
const client = await SyncClient.create({
|
||||||
fs: fileSystem,
|
fs: fileSystem,
|
||||||
persistence: {
|
persistence: {
|
||||||
load: async () => {
|
load: async () => {
|
||||||
let database: Partial<StoredDatabase> | undefined = undefined;
|
let database: Partial<StoredDatabase> | undefined = undefined;
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(dataFile, "utf-8");
|
const content = await fs.readFile(dataFile, "utf-8");
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
database = JSON.parse(content) as Partial<StoredDatabase>;
|
database = JSON.parse(content) as Partial<StoredDatabase>;
|
||||||
} catch {
|
} catch {
|
||||||
console.error(
|
console.error(
|
||||||
colorize(
|
colorize(
|
||||||
`Cannot read data file at ${dataFile}`,
|
`Cannot read data file at ${dataFile}`,
|
||||||
"yellow"
|
"yellow"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
settings,
|
settings,
|
||||||
database
|
database
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
save: async ({ database: persistedDatabase }) => {
|
save: async ({ database: persistedDatabase }) => {
|
||||||
// settings can't be updated when running with this CLI
|
// settings can't be updated when running with this CLI
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
dataFile,
|
dataFile,
|
||||||
JSON.stringify(persistedDatabase, null, 2)
|
JSON.stringify(persistedDatabase, null, 2)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n"
|
nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n"
|
||||||
});
|
});
|
||||||
|
|
||||||
if (args.health !== undefined) {
|
if (args.health !== undefined) {
|
||||||
const healthFile = args.health;
|
const healthFile = args.health;
|
||||||
const healthInterval = setInterval(() => {
|
const healthInterval = setInterval(() => {
|
||||||
void client.checkConnection().then((status) => {
|
void client.checkConnection().then((status) => {
|
||||||
writeHealthStatus(healthFile, status);
|
writeHealthStatus(healthFile, status);
|
||||||
});
|
});
|
||||||
}, 30 * 1000); // every 30 seconds
|
}, 30 * 1000); // every 30 seconds
|
||||||
const clearHealthInterval = (): void => {
|
const clearHealthInterval = (): void => {
|
||||||
clearInterval(healthInterval);
|
clearInterval(healthInterval);
|
||||||
};
|
};
|
||||||
process.on("SIGINT", clearHealthInterval);
|
process.on("SIGINT", clearHealthInterval);
|
||||||
process.on("SIGTERM", clearHealthInterval);
|
process.on("SIGTERM", clearHealthInterval);
|
||||||
process.on("exit", clearHealthInterval);
|
process.on("exit", clearHealthInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add colored log formatter with level filtering
|
// Add colored log formatter with level filtering
|
||||||
client.logger.addOnMessageListener((logLine) => {
|
client.logger.addOnMessageListener((logLine) => {
|
||||||
// Only show messages at or above the configured log level
|
// Only show messages at or above the configured log level
|
||||||
if (LOG_LEVEL_ORDER[logLine.level] >= LOG_LEVEL_ORDER[args.logLevel]) {
|
if (LOG_LEVEL_ORDER[logLine.level] >= LOG_LEVEL_ORDER[args.logLevel]) {
|
||||||
console.log(formatLogLine(logLine));
|
console.log(formatLogLine(logLine));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
client.logger.info("Starting sync client");
|
client.logger.info("Starting sync client");
|
||||||
|
|
||||||
const fileWatcher = new FileWatcher(absolutePath, client);
|
const fileWatcher = new FileWatcher(absolutePath, client);
|
||||||
|
|
||||||
client.addWebSocketStatusChangeListener(() => {
|
client.addWebSocketStatusChangeListener(() => {
|
||||||
const isConnected = client.isWebSocketConnected;
|
const isConnected = client.isWebSocketConnected;
|
||||||
client.logger.info(
|
client.logger.info(
|
||||||
`WebSocket status changed: ${isConnected ? "connected" : "disconnected"}`
|
`WebSocket status changed: ${isConnected ? "connected" : "disconnected"}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
client.addRemainingSyncOperationsListener((remaining) => {
|
client.addRemainingSyncOperationsListener((remaining) => {
|
||||||
if (remaining === 0) {
|
if (remaining === 0) {
|
||||||
client.logger.info("All sync operations completed");
|
client.logger.info("All sync operations completed");
|
||||||
} else {
|
} else {
|
||||||
client.logger.info(`${remaining} sync operations remaining`);
|
client.logger.info(`${remaining} sync operations remaining`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const gracefulShutdown = async (signal: string): Promise<void> => {
|
const gracefulShutdown = async (signal: string): Promise<void> => {
|
||||||
console.log(
|
console.log(
|
||||||
colorize(
|
colorize(
|
||||||
`\n${signal} received. Shutting down gracefully...`,
|
`\n${signal} received. Shutting down gracefully...`,
|
||||||
"yellow"
|
"yellow"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
fileWatcher.stop();
|
fileWatcher.stop();
|
||||||
await client.waitUntilFinished();
|
await client.waitUntilFinished();
|
||||||
await client.destroy();
|
await client.destroy();
|
||||||
console.log(colorize("Shutdown complete", "green"));
|
console.log(colorize("Shutdown complete", "green"));
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
void gracefulShutdown("SIGINT");
|
void gracefulShutdown("SIGINT");
|
||||||
});
|
});
|
||||||
process.on("SIGTERM", () => {
|
process.on("SIGTERM", () => {
|
||||||
void gracefulShutdown("SIGTERM");
|
void gracefulShutdown("SIGTERM");
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const connectionStatus = await client.checkConnection();
|
const connectionStatus = await client.checkConnection();
|
||||||
if (!connectionStatus.isSuccessful) {
|
if (!connectionStatus.isSuccessful) {
|
||||||
console.error(
|
console.error(
|
||||||
colorize(
|
colorize(
|
||||||
`Error: Cannot connect to server: ${connectionStatus.serverMessage}`,
|
`Error: Cannot connect to server: ${connectionStatus.serverMessage}`,
|
||||||
"red"
|
"red"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`${colorize("✓", "green")} Server connection successful`);
|
console.log(`${colorize("✓", "green")} Server connection successful`);
|
||||||
console.log(colorize("Press Ctrl+C to stop", "dim"));
|
console.log(colorize("Press Ctrl+C to stop", "dim"));
|
||||||
console.log("");
|
console.log("");
|
||||||
|
|
||||||
await client.start();
|
await client.start();
|
||||||
fileWatcher.start();
|
fileWatcher.start();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
colorize(
|
colorize(
|
||||||
`Fatal error: ${error instanceof Error ? error.message : String(error)}`,
|
`Fatal error: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
"red"
|
"red"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
fileWatcher.stop();
|
fileWatcher.stop();
|
||||||
await client.destroy();
|
await client.destroy();
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error: unknown) => {
|
main().catch((error: unknown) => {
|
||||||
console.error(
|
console.error(
|
||||||
colorize(
|
colorize(
|
||||||
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
|
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
"red"
|
"red"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,58 +9,58 @@ import * as fs from "fs";
|
||||||
import type { NetworkConnectionStatus } from "sync-client";
|
import type { NetworkConnectionStatus } from "sync-client";
|
||||||
|
|
||||||
function isHealthStatus(value: unknown): value is NetworkConnectionStatus {
|
function isHealthStatus(value: unknown): value is NetworkConnectionStatus {
|
||||||
if (typeof value !== "object" || value === null) {
|
if (typeof value !== "object" || value === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
"isSuccessful" in value &&
|
"isSuccessful" in value &&
|
||||||
typeof value.isSuccessful === "boolean" &&
|
typeof value.isSuccessful === "boolean" &&
|
||||||
"isWebSocketConnected" in value &&
|
"isWebSocketConnected" in value &&
|
||||||
typeof value.isWebSocketConnected === "boolean" &&
|
typeof value.isWebSocketConnected === "boolean" &&
|
||||||
"serverMessage" in value &&
|
"serverMessage" in value &&
|
||||||
typeof value.serverMessage === "string"
|
typeof value.serverMessage === "string"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function main(): void {
|
function main(): void {
|
||||||
if (process.argv.length < 3) {
|
if (process.argv.length < 3) {
|
||||||
console.error("Usage: healthcheck <path-to-health-file>");
|
console.error("Usage: healthcheck <path-to-health-file>");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const [, , healthFile] = process.argv;
|
const [, , healthFile] = process.argv;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if health file exists
|
// Check if health file exists
|
||||||
if (!fs.existsSync(healthFile)) {
|
if (!fs.existsSync(healthFile)) {
|
||||||
console.error(`Health file does not exist: ${healthFile}`);
|
console.error(`Health file does not exist: ${healthFile}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read and parse health status
|
// Read and parse health status
|
||||||
const content = fs.readFileSync(healthFile, "utf-8");
|
const content = fs.readFileSync(healthFile, "utf-8");
|
||||||
const parsed: unknown = JSON.parse(content);
|
const parsed: unknown = JSON.parse(content);
|
||||||
|
|
||||||
// Validate the parsed object using type guard
|
// Validate the parsed object using type guard
|
||||||
if (!isHealthStatus(parsed)) {
|
if (!isHealthStatus(parsed)) {
|
||||||
throw new Error("Invalid health status format");
|
throw new Error("Invalid health status format");
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = parsed;
|
const status = parsed;
|
||||||
|
|
||||||
if (!status.isSuccessful || !status.isWebSocketConnected) {
|
if (!status.isSuccessful || !status.isWebSocketConnected) {
|
||||||
console.error("Not connected to server: " + status.serverMessage);
|
console.error("Not connected to server: " + status.serverMessage);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Healthy: Connected to server");
|
console.log("Healthy: Connected to server");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`Health check failed: ${error instanceof Error ? error.message : String(error)}`
|
`Health check failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
|
|
|
||||||
|
|
@ -2,85 +2,85 @@ import { LogLevel, type LogLine } from "sync-client";
|
||||||
|
|
||||||
// ANSI color codes
|
// ANSI color codes
|
||||||
export const colors = {
|
export const colors = {
|
||||||
reset: "\x1b[0m",
|
reset: "\x1b[0m",
|
||||||
bold: "\x1b[1m",
|
bold: "\x1b[1m",
|
||||||
dim: "\x1b[2m",
|
dim: "\x1b[2m",
|
||||||
|
|
||||||
// Foreground colors
|
// Foreground colors
|
||||||
red: "\x1b[31m",
|
red: "\x1b[31m",
|
||||||
green: "\x1b[32m",
|
green: "\x1b[32m",
|
||||||
yellow: "\x1b[33m",
|
yellow: "\x1b[33m",
|
||||||
blue: "\x1b[34m",
|
blue: "\x1b[34m",
|
||||||
magenta: "\x1b[35m",
|
magenta: "\x1b[35m",
|
||||||
cyan: "\x1b[36m",
|
cyan: "\x1b[36m",
|
||||||
gray: "\x1b[90m"
|
gray: "\x1b[90m"
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function colorize(text: string, color: keyof typeof colors): string {
|
export function colorize(text: string, color: keyof typeof colors): string {
|
||||||
return `${colors[color]}${text}${colors.reset}`;
|
return `${colors[color]}${text}${colors.reset}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function to apply multiple color modifiers to text
|
* Helper function to apply multiple color modifiers to text
|
||||||
*/
|
*/
|
||||||
export function styleText(
|
export function styleText(
|
||||||
text: string,
|
text: string,
|
||||||
...modifiers: (keyof typeof colors)[]
|
...modifiers: (keyof typeof colors)[]
|
||||||
): string {
|
): string {
|
||||||
const prefix = modifiers.map((m) => colors[m]).join("");
|
const prefix = modifiers.map((m) => colors[m]).join("");
|
||||||
return `${prefix}${text}${colors.reset}`;
|
return `${prefix}${text}${colors.reset}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(date: Date): string {
|
function formatTimestamp(date: Date): string {
|
||||||
const [time] = date.toTimeString().split(" ");
|
const [time] = date.toTimeString().split(" ");
|
||||||
const ms = date.getMilliseconds().toString().padStart(3, "0");
|
const ms = date.getMilliseconds().toString().padStart(3, "0");
|
||||||
return colorize(`${time}.${ms}`, "gray");
|
return colorize(`${time}.${ms}`, "gray");
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLevel(level: LogLevel): string {
|
function formatLevel(level: LogLevel): string {
|
||||||
const levelStr = level.padEnd(7);
|
const levelStr = level.padEnd(7);
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case LogLevel.DEBUG:
|
case LogLevel.DEBUG:
|
||||||
return colorize(levelStr, "cyan");
|
return colorize(levelStr, "cyan");
|
||||||
case LogLevel.INFO:
|
case LogLevel.INFO:
|
||||||
return colorize(levelStr, "green");
|
return colorize(levelStr, "green");
|
||||||
case LogLevel.WARNING:
|
case LogLevel.WARNING:
|
||||||
return colorize(levelStr, "yellow");
|
return colorize(levelStr, "yellow");
|
||||||
case LogLevel.ERROR:
|
case LogLevel.ERROR:
|
||||||
return colorize(levelStr, "red");
|
return colorize(levelStr, "red");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMessage(message: string, level: LogLevel): string {
|
function formatMessage(message: string, level: LogLevel): string {
|
||||||
// Highlight important parts of the message
|
// Highlight important parts of the message
|
||||||
let formatted = message;
|
let formatted = message;
|
||||||
|
|
||||||
// Highlight file paths
|
// Highlight file paths
|
||||||
formatted = formatted.replace(
|
formatted = formatted.replace(
|
||||||
/(['"])([^'"]*?\.(json|txt|md|js|ts))(['"])/g,
|
/(['"])([^'"]*?\.(json|txt|md|js|ts))(['"])/g,
|
||||||
(_, q1, path, _ext, q2) => q1 + colorize(path, "magenta") + q2
|
(_, q1, path, _ext, q2) => q1 + colorize(path, "magenta") + q2
|
||||||
);
|
);
|
||||||
|
|
||||||
// Highlight numbers
|
// Highlight numbers
|
||||||
formatted = formatted.replace(/\b(\d+)\b/g, (num) => colorize(num, "cyan"));
|
formatted = formatted.replace(/\b(\d+)\b/g, (num) => colorize(num, "cyan"));
|
||||||
|
|
||||||
// Highlight patterns like /regex/
|
// Highlight patterns like /regex/
|
||||||
formatted = formatted.replace(/(\/\^[^$]*\$\/)/g, (pattern) =>
|
formatted = formatted.replace(/(\/\^[^$]*\$\/)/g, (pattern) =>
|
||||||
colorize(pattern, "yellow")
|
colorize(pattern, "yellow")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Make error messages bold
|
// Make error messages bold
|
||||||
if (level === LogLevel.ERROR) {
|
if (level === LogLevel.ERROR) {
|
||||||
formatted = colorize(formatted, "bold");
|
formatted = colorize(formatted, "bold");
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatted;
|
return formatted;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatLogLine(logLine: LogLine): string {
|
export function formatLogLine(logLine: LogLine): string {
|
||||||
const timestamp = formatTimestamp(logLine.timestamp);
|
const timestamp = formatTimestamp(logLine.timestamp);
|
||||||
const level = formatLevel(logLine.level);
|
const level = formatLevel(logLine.level);
|
||||||
const message = formatMessage(logLine.message, logLine.level);
|
const message = formatMessage(logLine.message, logLine.level);
|
||||||
|
|
||||||
return `${timestamp} ${level} ${message}`;
|
return `${timestamp} ${level} ${message}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,157 +6,157 @@ import * as os from "os";
|
||||||
import { NodeFileSystemOperations } from "./node-filesystem";
|
import { NodeFileSystemOperations } from "./node-filesystem";
|
||||||
|
|
||||||
test("NodeFileSystemOperations - read and write files", async () => {
|
test("NodeFileSystemOperations - read and write files", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = new TextEncoder().encode("Hello, world!");
|
const content = new TextEncoder().encode("Hello, world!");
|
||||||
await fsOps.write("test.txt", content);
|
await fsOps.write("test.txt", content);
|
||||||
|
|
||||||
const readContent = await fsOps.read("test.txt");
|
const readContent = await fsOps.read("test.txt");
|
||||||
assert.equal(new TextDecoder().decode(readContent), "Hello, world!");
|
assert.equal(new TextDecoder().decode(readContent), "Hello, world!");
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NodeFileSystemOperations - create nested directories with forward slashes", async () => {
|
test("NodeFileSystemOperations - create nested directories with forward slashes", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = new TextEncoder().encode("Nested file");
|
const content = new TextEncoder().encode("Nested file");
|
||||||
// Always use forward slashes in API
|
// Always use forward slashes in API
|
||||||
await fsOps.write("dir1/dir2/test.txt", content);
|
await fsOps.write("dir1/dir2/test.txt", content);
|
||||||
|
|
||||||
const readContent = await fsOps.read("dir1/dir2/test.txt");
|
const readContent = await fsOps.read("dir1/dir2/test.txt");
|
||||||
assert.equal(new TextDecoder().decode(readContent), "Nested file");
|
assert.equal(new TextDecoder().decode(readContent), "Nested file");
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NodeFileSystemOperations - exists with forward slashes", async () => {
|
test("NodeFileSystemOperations - exists with forward slashes", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
assert.equal(await fsOps.exists("test.txt"), false);
|
assert.equal(await fsOps.exists("test.txt"), false);
|
||||||
|
|
||||||
await fsOps.write("test.txt", new TextEncoder().encode("test"));
|
await fsOps.write("test.txt", new TextEncoder().encode("test"));
|
||||||
|
|
||||||
assert.equal(await fsOps.exists("test.txt"), true);
|
assert.equal(await fsOps.exists("test.txt"), true);
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NodeFileSystemOperations - delete with forward slashes", async () => {
|
test("NodeFileSystemOperations - delete with forward slashes", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fsOps.write("test.txt", new TextEncoder().encode("test"));
|
await fsOps.write("test.txt", new TextEncoder().encode("test"));
|
||||||
assert.equal(await fsOps.exists("test.txt"), true);
|
assert.equal(await fsOps.exists("test.txt"), true);
|
||||||
|
|
||||||
await fsOps.delete("test.txt");
|
await fsOps.delete("test.txt");
|
||||||
assert.equal(await fsOps.exists("test.txt"), false);
|
assert.equal(await fsOps.exists("test.txt"), false);
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NodeFileSystemOperations - rename with forward slashes", async () => {
|
test("NodeFileSystemOperations - rename with forward slashes", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = new TextEncoder().encode("test content");
|
const content = new TextEncoder().encode("test content");
|
||||||
await fsOps.write("old.txt", content);
|
await fsOps.write("old.txt", content);
|
||||||
|
|
||||||
await fsOps.rename("old.txt", "new.txt");
|
await fsOps.rename("old.txt", "new.txt");
|
||||||
|
|
||||||
assert.equal(await fsOps.exists("old.txt"), false);
|
assert.equal(await fsOps.exists("old.txt"), false);
|
||||||
assert.equal(await fsOps.exists("new.txt"), true);
|
assert.equal(await fsOps.exists("new.txt"), true);
|
||||||
|
|
||||||
const readContent = await fsOps.read("new.txt");
|
const readContent = await fsOps.read("new.txt");
|
||||||
assert.equal(new TextDecoder().decode(readContent), "test content");
|
assert.equal(new TextDecoder().decode(readContent), "test content");
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NodeFileSystemOperations - rename to nested path with forward slashes", async () => {
|
test("NodeFileSystemOperations - rename to nested path with forward slashes", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = new TextEncoder().encode("test content");
|
const content = new TextEncoder().encode("test content");
|
||||||
await fsOps.write("old.txt", content);
|
await fsOps.write("old.txt", content);
|
||||||
|
|
||||||
await fsOps.rename("old.txt", "dir1/dir2/new.txt");
|
await fsOps.rename("old.txt", "dir1/dir2/new.txt");
|
||||||
|
|
||||||
assert.equal(await fsOps.exists("old.txt"), false);
|
assert.equal(await fsOps.exists("old.txt"), false);
|
||||||
assert.equal(await fsOps.exists("dir1/dir2/new.txt"), true);
|
assert.equal(await fsOps.exists("dir1/dir2/new.txt"), true);
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NodeFileSystemOperations - getFileSize", async () => {
|
test("NodeFileSystemOperations - getFileSize", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = new TextEncoder().encode("Hello!");
|
const content = new TextEncoder().encode("Hello!");
|
||||||
await fsOps.write("test.txt", content);
|
await fsOps.write("test.txt", content);
|
||||||
|
|
||||||
const size = await fsOps.getFileSize("test.txt");
|
const size = await fsOps.getFileSize("test.txt");
|
||||||
assert.equal(size, content.length);
|
assert.equal(size, content.length);
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NodeFileSystemOperations - atomicUpdateText", async () => {
|
test("NodeFileSystemOperations - atomicUpdateText", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fsOps.write("test.txt", new TextEncoder().encode("Hello"));
|
await fsOps.write("test.txt", new TextEncoder().encode("Hello"));
|
||||||
|
|
||||||
const result = await fsOps.atomicUpdateText("test.txt", (current) => ({
|
const result = await fsOps.atomicUpdateText("test.txt", (current) => ({
|
||||||
text: current.text + " World",
|
text: current.text + " World",
|
||||||
cursors: []
|
cursors: []
|
||||||
}));
|
}));
|
||||||
|
|
||||||
assert.equal(result, "Hello World");
|
assert.equal(result, "Hello World");
|
||||||
|
|
||||||
const content = await fsOps.read("test.txt");
|
const content = await fsOps.read("test.txt");
|
||||||
assert.equal(new TextDecoder().decode(content), "Hello World");
|
assert.equal(new TextDecoder().decode(content), "Hello World");
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NodeFileSystemOperations - handles paths with forward slashes on all platforms", async () => {
|
test("NodeFileSystemOperations - handles paths with forward slashes on all platforms", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// API should always accept forward slashes
|
// API should always accept forward slashes
|
||||||
const testPath = "deep/nested/directory/file.txt";
|
const testPath = "deep/nested/directory/file.txt";
|
||||||
const content = new TextEncoder().encode("test");
|
const content = new TextEncoder().encode("test");
|
||||||
|
|
||||||
await fsOps.write(testPath, content);
|
await fsOps.write(testPath, content);
|
||||||
assert.equal(await fsOps.exists(testPath), true);
|
assert.equal(await fsOps.exists(testPath), true);
|
||||||
|
|
||||||
const readContent = await fsOps.read(testPath);
|
const readContent = await fsOps.read(testPath);
|
||||||
assert.equal(new TextDecoder().decode(readContent), "test");
|
assert.equal(new TextDecoder().decode(readContent), "test");
|
||||||
|
|
||||||
await fsOps.delete(testPath);
|
await fsOps.delete(testPath);
|
||||||
assert.equal(await fsOps.exists(testPath), false);
|
assert.equal(await fsOps.exists(testPath), false);
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,205 +2,205 @@ import * as fs from "fs/promises";
|
||||||
import type { Dirent } from "fs";
|
import type { Dirent } from "fs";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import type {
|
import type {
|
||||||
FileSystemOperations,
|
FileSystemOperations,
|
||||||
RelativePath,
|
RelativePath,
|
||||||
TextWithCursors
|
TextWithCursors
|
||||||
} from "sync-client";
|
} from "sync-client";
|
||||||
|
|
||||||
export class NodeFileSystemOperations implements FileSystemOperations {
|
export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
public constructor(private readonly basePath: string) {}
|
public constructor(private readonly basePath: string) {}
|
||||||
|
|
||||||
public async listFilesRecursively(
|
public async listFilesRecursively(
|
||||||
directory: RelativePath | undefined
|
directory: RelativePath | undefined
|
||||||
): Promise<RelativePath[]> {
|
): Promise<RelativePath[]> {
|
||||||
const files: RelativePath[] = [];
|
const files: RelativePath[] = [];
|
||||||
await this.walkDirectory(
|
await this.walkDirectory(
|
||||||
directory !== undefined ? this.toNativePath(directory) : "",
|
directory !== undefined ? this.toNativePath(directory) : "",
|
||||||
files
|
files
|
||||||
);
|
);
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async read(relativePath: RelativePath): Promise<Uint8Array> {
|
public async read(relativePath: RelativePath): Promise<Uint8Array> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
this.toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
return await fs.readFile(fullPath);
|
return await fs.readFile(fullPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to read file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to read file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async write(
|
public async write(
|
||||||
relativePath: RelativePath,
|
relativePath: RelativePath,
|
||||||
content: Uint8Array
|
content: Uint8Array
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
this.toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
const dir = path.dirname(fullPath);
|
const dir = path.dirname(fullPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(dir, { recursive: true });
|
await fs.mkdir(dir, { recursive: true });
|
||||||
await fs.writeFile(fullPath, content);
|
await fs.writeFile(fullPath, content);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async atomicUpdateText(
|
public async atomicUpdateText(
|
||||||
relativePath: RelativePath,
|
relativePath: RelativePath,
|
||||||
updater: (current: TextWithCursors) => TextWithCursors
|
updater: (current: TextWithCursors) => TextWithCursors
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
this.toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentContent = await fs.readFile(fullPath, "utf-8");
|
const currentContent = await fs.readFile(fullPath, "utf-8");
|
||||||
const result = updater({ text: currentContent, cursors: [] });
|
const result = updater({ text: currentContent, cursors: [] });
|
||||||
await fs.writeFile(fullPath, result.text, "utf-8");
|
await fs.writeFile(fullPath, result.text, "utf-8");
|
||||||
return result.text;
|
return result.text;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to atomically update file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to atomically update file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getFileSize(relativePath: RelativePath): Promise<number> {
|
public async getFileSize(relativePath: RelativePath): Promise<number> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
this.toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const stats = await fs.stat(fullPath);
|
const stats = await fs.stat(fullPath);
|
||||||
return stats.size;
|
return stats.size;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to get file size for ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to get file size for ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async exists(relativePath: RelativePath): Promise<boolean> {
|
public async exists(relativePath: RelativePath): Promise<boolean> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
this.toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await fs.access(fullPath);
|
await fs.access(fullPath);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createDirectory(relativePath: RelativePath): Promise<void> {
|
public async createDirectory(relativePath: RelativePath): Promise<void> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
this.toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(fullPath, { recursive: false });
|
await fs.mkdir(fullPath, { recursive: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to create directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to create directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(relativePath: RelativePath): Promise<void> {
|
public async delete(relativePath: RelativePath): Promise<void> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
this.toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await fs.unlink(fullPath);
|
await fs.unlink(fullPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to delete file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to delete file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async rename(
|
public async rename(
|
||||||
oldPath: RelativePath,
|
oldPath: RelativePath,
|
||||||
newPath: RelativePath
|
newPath: RelativePath
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const oldFullPath = path.join(
|
const oldFullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(oldPath)
|
this.toNativePath(oldPath)
|
||||||
);
|
);
|
||||||
const newFullPath = path.join(
|
const newFullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(newPath)
|
this.toNativePath(newPath)
|
||||||
);
|
);
|
||||||
const newDir = path.dirname(newFullPath);
|
const newDir = path.dirname(newFullPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(newDir, { recursive: true });
|
await fs.mkdir(newDir, { recursive: true });
|
||||||
await fs.rename(oldFullPath, newFullPath);
|
await fs.rename(oldFullPath, newFullPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to rename file from ${oldFullPath} to ${newFullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to rename file from ${oldFullPath} to ${newFullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async walkDirectory(
|
private async walkDirectory(
|
||||||
relativePath: string,
|
relativePath: string,
|
||||||
files: RelativePath[]
|
files: RelativePath[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const fullPath = path.join(this.basePath, relativePath);
|
const fullPath = path.join(this.basePath, relativePath);
|
||||||
let entries: Dirent[] = [];
|
let entries: Dirent[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
entries = await fs.readdir(fullPath, { withFileTypes: true });
|
entries = await fs.readdir(fullPath, { withFileTypes: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to read directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to read directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const entryName = entry.name;
|
const entryName = entry.name;
|
||||||
const entryRelativePath = path.join(relativePath, entryName);
|
const entryRelativePath = path.join(relativePath, entryName);
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
await this.walkDirectory(entryRelativePath, files);
|
await this.walkDirectory(entryRelativePath, files);
|
||||||
} else if (entry.isFile()) {
|
} else if (entry.isFile()) {
|
||||||
// Always return forward slashes
|
// Always return forward slashes
|
||||||
files.push(this.toUnixPath(entryRelativePath));
|
files.push(this.toUnixPath(entryRelativePath));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a forward-slash path to native platform path separators
|
* Convert a forward-slash path to native platform path separators
|
||||||
*/
|
*/
|
||||||
private toNativePath(relativePath: string): string {
|
private toNativePath(relativePath: string): string {
|
||||||
if (path.sep === "\\") {
|
if (path.sep === "\\") {
|
||||||
return relativePath.replace(/\//g, "\\");
|
return relativePath.replace(/\//g, "\\");
|
||||||
}
|
}
|
||||||
return relativePath;
|
return relativePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a native platform path to forward slashes
|
* Convert a native platform path to forward slashes
|
||||||
*/
|
*/
|
||||||
private toUnixPath(nativePath: string): string {
|
private toUnixPath(nativePath: string): string {
|
||||||
if (path.sep === "\\") {
|
if (path.sep === "\\") {
|
||||||
return nativePath.replace(/\\/g, "/");
|
return nativePath.replace(/\\/g, "/");
|
||||||
}
|
}
|
||||||
return nativePath;
|
return nativePath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -85,8 +85,3 @@ If you have multiple URLs, you can also do:
|
||||||
## API Documentation
|
## API Documentation
|
||||||
|
|
||||||
See https://github.com/obsidianmd/obsidian-api
|
See https://github.com/obsidianmd/obsidian-api
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
{
|
{
|
||||||
"id": "vault-link",
|
"id": "vault-link",
|
||||||
"name": "VaultLink",
|
"name": "VaultLink",
|
||||||
"version": "0.12.0",
|
"version": "0.12.0",
|
||||||
"minAppVersion": "0.0.0",
|
"minAppVersion": "0.0.0",
|
||||||
"description": "Self-hosted synchronization and collaboration for your Vault.",
|
"description": "Self-hosted synchronization and collaboration for your Vault.",
|
||||||
"author": "Andras Schmelczer",
|
"author": "Andras Schmelczer",
|
||||||
"authorUrl": "https://schmelczer.dev",
|
"authorUrl": "https://schmelczer.dev",
|
||||||
"isDesktopOnly": false
|
"isDesktopOnly": false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,175 +2,175 @@ import type { Stat, Vault, Workspace } from "obsidian";
|
||||||
import { MarkdownView, normalizePath } from "obsidian";
|
import { MarkdownView, normalizePath } from "obsidian";
|
||||||
import type { CursorPosition, TextWithCursors } from "sync-client";
|
import type { CursorPosition, TextWithCursors } from "sync-client";
|
||||||
import {
|
import {
|
||||||
utils,
|
utils,
|
||||||
type FileSystemOperations,
|
type FileSystemOperations,
|
||||||
type RelativePath
|
type RelativePath
|
||||||
} from "sync-client";
|
} from "sync-client";
|
||||||
import { getSelectionsFromEditor } from "./views/cursors/get-selections-from-editor";
|
import { getSelectionsFromEditor } from "./views/cursors/get-selections-from-editor";
|
||||||
|
|
||||||
export class ObsidianFileSystemOperations implements FileSystemOperations {
|
export class ObsidianFileSystemOperations implements FileSystemOperations {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly vault: Vault,
|
private readonly vault: Vault,
|
||||||
private readonly workspace: Workspace
|
private readonly workspace: Workspace
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async listFilesRecursively(
|
public async listFilesRecursively(
|
||||||
root: RelativePath | undefined
|
root: RelativePath | undefined
|
||||||
): Promise<RelativePath[]> {
|
): Promise<RelativePath[]> {
|
||||||
// Let's implement this by hand because vault.adapter.listAllFiles doesn't always return all files.
|
// Let's implement this by hand because vault.adapter.listAllFiles doesn't always return all files.
|
||||||
const allFiles = [];
|
const allFiles = [];
|
||||||
const remainingFolders = [root ?? this.vault.getRoot().path];
|
const remainingFolders = [root ?? this.vault.getRoot().path];
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
while (true) {
|
while (true) {
|
||||||
const folder = remainingFolders.pop();
|
const folder = remainingFolders.pop();
|
||||||
if (folder == undefined) {
|
if (folder == undefined) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This would be a very bad idea to sync as it would mess with
|
// This would be a very bad idea to sync as it would mess with
|
||||||
// the integrity of the sync database.
|
// the integrity of the sync database.
|
||||||
if (folder.endsWith(".obsidian/plugins/vault-link/data.json")) {
|
if (folder.endsWith(".obsidian/plugins/vault-link/data.json")) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = await this.vault.adapter.list(normalizePath(folder));
|
const files = await this.vault.adapter.list(normalizePath(folder));
|
||||||
allFiles.push(...files.files);
|
allFiles.push(...files.files);
|
||||||
remainingFolders.push(...files.folders);
|
remainingFolders.push(...files.folders);
|
||||||
}
|
}
|
||||||
|
|
||||||
return allFiles;
|
return allFiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async read(path: RelativePath): Promise<Uint8Array> {
|
public async read(path: RelativePath): Promise<Uint8Array> {
|
||||||
path = normalizePath(path);
|
path = normalizePath(path);
|
||||||
const view = this.workspace.getActiveViewOfType(MarkdownView);
|
const view = this.workspace.getActiveViewOfType(MarkdownView);
|
||||||
if (view?.file?.path === path) {
|
if (view?.file?.path === path) {
|
||||||
return new TextEncoder().encode(view.editor.getValue());
|
return new TextEncoder().encode(view.editor.getValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Uint8Array(await this.vault.adapter.readBinary(path));
|
return new Uint8Array(await this.vault.adapter.readBinary(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
|
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
|
||||||
path = normalizePath(path);
|
path = normalizePath(path);
|
||||||
|
|
||||||
const view = this.workspace.getActiveViewOfType(MarkdownView);
|
const view = this.workspace.getActiveViewOfType(MarkdownView);
|
||||||
if (view?.file?.path === path) {
|
if (view?.file?.path === path) {
|
||||||
const position = view.editor.getCursor();
|
const position = view.editor.getCursor();
|
||||||
view.editor.setValue(new TextDecoder().decode(content));
|
view.editor.setValue(new TextDecoder().decode(content));
|
||||||
view.editor.setCursor(position);
|
view.editor.setCursor(position);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.vault.adapter.writeBinary(
|
return this.vault.adapter.writeBinary(
|
||||||
path,
|
path,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
content.buffer as ArrayBuffer
|
content.buffer as ArrayBuffer
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async atomicUpdateText(
|
public async atomicUpdateText(
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
updater: (current: TextWithCursors) => TextWithCursors
|
updater: (current: TextWithCursors) => TextWithCursors
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
path = normalizePath(path);
|
path = normalizePath(path);
|
||||||
|
|
||||||
const view = this.workspace.getActiveViewOfType(MarkdownView);
|
const view = this.workspace.getActiveViewOfType(MarkdownView);
|
||||||
|
|
||||||
if (view?.file?.path === path) {
|
if (view?.file?.path === path) {
|
||||||
const text = view.editor.getValue();
|
const text = view.editor.getValue();
|
||||||
|
|
||||||
const cursors: CursorPosition[] = getSelectionsFromEditor(
|
const cursors: CursorPosition[] = getSelectionsFromEditor(
|
||||||
view.editor
|
view.editor
|
||||||
).flatMap(({ id, start: anchor, end: head }) => [
|
).flatMap(({ id, start: anchor, end: head }) => [
|
||||||
{
|
{
|
||||||
id: 2 * id,
|
id: 2 * id,
|
||||||
position: anchor
|
position: anchor
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2 * id + 1,
|
id: 2 * id + 1,
|
||||||
position: head
|
position: head
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = updater({
|
const result = updater({
|
||||||
text,
|
text,
|
||||||
cursors
|
cursors
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.text === text) {
|
if (result.text === text) {
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
view.editor.setValue(result.text);
|
view.editor.setValue(result.text);
|
||||||
|
|
||||||
const selections = [];
|
const selections = [];
|
||||||
for (let i = 0; i < result.cursors.length / 2; i++) {
|
for (let i = 0; i < result.cursors.length / 2; i++) {
|
||||||
const from = result.cursors[2 * i];
|
const from = result.cursors[2 * i];
|
||||||
const to = result.cursors[2 * i + 1];
|
const to = result.cursors[2 * i + 1];
|
||||||
const { line: fromLine, column: fromColumn } =
|
const { line: fromLine, column: fromColumn } =
|
||||||
utils.positionToLineAndColumn(result.text, from.position);
|
utils.positionToLineAndColumn(result.text, from.position);
|
||||||
|
|
||||||
const { line: toLine, column: toColumn } =
|
const { line: toLine, column: toColumn } =
|
||||||
utils.positionToLineAndColumn(result.text, to.position);
|
utils.positionToLineAndColumn(result.text, to.position);
|
||||||
|
|
||||||
selections.push({
|
selections.push({
|
||||||
anchor: { line: fromLine, ch: fromColumn },
|
anchor: { line: fromLine, ch: fromColumn },
|
||||||
head: { line: toLine, ch: toColumn }
|
head: { line: toLine, ch: toColumn }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
view.editor.setSelections(selections);
|
view.editor.setSelections(selections);
|
||||||
|
|
||||||
return result.text;
|
return result.text;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.vault.adapter.process(
|
return this.vault.adapter.process(
|
||||||
path,
|
path,
|
||||||
(text) =>
|
(text) =>
|
||||||
updater({
|
updater({
|
||||||
text,
|
text,
|
||||||
cursors: []
|
cursors: []
|
||||||
}).text
|
}).text
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getFileSize(path: RelativePath): Promise<number> {
|
public async getFileSize(path: RelativePath): Promise<number> {
|
||||||
return (await this.statFile(path)).size;
|
return (await this.statFile(path)).size;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getModificationTime(path: RelativePath): Promise<Date> {
|
public async getModificationTime(path: RelativePath): Promise<Date> {
|
||||||
return new Date((await this.statFile(path)).mtime);
|
return new Date((await this.statFile(path)).mtime);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async exists(path: RelativePath): Promise<boolean> {
|
public async exists(path: RelativePath): Promise<boolean> {
|
||||||
return this.vault.adapter.exists(normalizePath(path));
|
return this.vault.adapter.exists(normalizePath(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createDirectory(path: RelativePath): Promise<void> {
|
public async createDirectory(path: RelativePath): Promise<void> {
|
||||||
return this.vault.adapter.mkdir(normalizePath(path));
|
return this.vault.adapter.mkdir(normalizePath(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(path: RelativePath): Promise<void> {
|
public async delete(path: RelativePath): Promise<void> {
|
||||||
if (!(await this.vault.adapter.trashSystem(normalizePath(path)))) {
|
if (!(await this.vault.adapter.trashSystem(normalizePath(path)))) {
|
||||||
return this.vault.adapter.remove(normalizePath(path));
|
return this.vault.adapter.remove(normalizePath(path));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async rename(
|
public async rename(
|
||||||
oldPath: RelativePath,
|
oldPath: RelativePath,
|
||||||
newPath: RelativePath
|
newPath: RelativePath
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return this.vault.adapter.rename(oldPath, newPath);
|
return this.vault.adapter.rename(oldPath, newPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async statFile(path: string): Promise<Stat> {
|
private async statFile(path: string): Promise<Stat> {
|
||||||
const file = await this.vault.adapter.stat(normalizePath(path));
|
const file = await this.vault.adapter.stat(normalizePath(path));
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
throw new Error(`File not found: ${path}`);
|
throw new Error(`File not found: ${path}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,4 +12,4 @@
|
||||||
font-size: var(--font-smallest);
|
font-size: var(--font-smallest);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,16 @@ import type { Editor } from "obsidian";
|
||||||
import { utils } from "sync-client";
|
import { utils } from "sync-client";
|
||||||
|
|
||||||
export interface Selection {
|
export interface Selection {
|
||||||
id: number;
|
id: number;
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSelectionsFromEditor(editor: Editor): Selection[] {
|
export function getSelectionsFromEditor(editor: Editor): Selection[] {
|
||||||
const text = editor.getValue();
|
const text = editor.getValue();
|
||||||
return editor.listSelections().map(({ anchor, head }, i) => ({
|
return editor.listSelections().map(({ anchor, head }, i) => ({
|
||||||
id: i,
|
id: i,
|
||||||
start: utils.lineAndColumnToPosition(text, anchor.line, anchor.ch),
|
start: utils.lineAndColumnToPosition(text, anchor.line, anchor.ch),
|
||||||
end: utils.lineAndColumnToPosition(text, head.line, head.ch)
|
end: utils.lineAndColumnToPosition(text, head.line, head.ch)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,46 +5,46 @@ import type { Selection } from "./get-selections-from-editor";
|
||||||
import { getSelectionsFromEditor } from "./get-selections-from-editor";
|
import { getSelectionsFromEditor } from "./get-selections-from-editor";
|
||||||
|
|
||||||
export class LocalCursorUpdateListener {
|
export class LocalCursorUpdateListener {
|
||||||
private static readonly UPDATE_INTERVAL_MS = 50;
|
private static readonly UPDATE_INTERVAL_MS = 50;
|
||||||
private readonly eventHandle: NodeJS.Timeout;
|
private readonly eventHandle: NodeJS.Timeout;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly client: SyncClient,
|
private readonly client: SyncClient,
|
||||||
private readonly workspace: Workspace
|
private readonly workspace: Workspace
|
||||||
) {
|
) {
|
||||||
this.eventHandle = setInterval(() => {
|
this.eventHandle = setInterval(() => {
|
||||||
this.updateAllSelections();
|
this.updateAllSelections();
|
||||||
}, LocalCursorUpdateListener.UPDATE_INTERVAL_MS);
|
}, LocalCursorUpdateListener.UPDATE_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
clearInterval(this.eventHandle);
|
clearInterval(this.eventHandle);
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateAllSelections(): void {
|
private updateAllSelections(): void {
|
||||||
const currentCursors = this.getAllSelections();
|
const currentCursors = this.getAllSelections();
|
||||||
this.client
|
this.client
|
||||||
.updateLocalCursors(currentCursors)
|
.updateLocalCursors(currentCursors)
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
this.client.logger.error(
|
this.client.logger.error(
|
||||||
`Failed to update local cursors: ${error}`
|
`Failed to update local cursors: ${error}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getAllSelections(): Record<string, Selection[]> {
|
private getAllSelections(): Record<string, Selection[]> {
|
||||||
const cursors: Record<string, Selection[]> = {};
|
const cursors: Record<string, Selection[]> = {};
|
||||||
this.workspace
|
this.workspace
|
||||||
.getLeavesOfType("markdown")
|
.getLeavesOfType("markdown")
|
||||||
.map((leaf) => leaf.view)
|
.map((leaf) => leaf.view)
|
||||||
.filter((view) => view instanceof MarkdownView)
|
.filter((view) => view instanceof MarkdownView)
|
||||||
.forEach((view) => {
|
.forEach((view) => {
|
||||||
const { file } = view;
|
const { file } = view;
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cursors[file.path] = getSelectionsFromEditor(view.editor);
|
cursors[file.path] = getSelectionsFromEditor(view.editor);
|
||||||
});
|
});
|
||||||
return cursors;
|
return cursors;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,60 +4,60 @@ const CARET_WIDTH = 2;
|
||||||
const DOT_RADIUS = 4;
|
const DOT_RADIUS = 4;
|
||||||
|
|
||||||
export const remoteCursorsTheme = EditorView.baseTheme({
|
export const remoteCursorsTheme = EditorView.baseTheme({
|
||||||
".selection-caret": {
|
".selection-caret": {
|
||||||
position: "relative"
|
position: "relative"
|
||||||
},
|
},
|
||||||
|
|
||||||
".selection-caret > *": {
|
".selection-caret > *": {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
backgroundColor: "inherit"
|
backgroundColor: "inherit"
|
||||||
},
|
},
|
||||||
|
|
||||||
".selection-caret > .stick": {
|
".selection-caret > .stick": {
|
||||||
left: 0,
|
left: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
transform: "translateX(-50%)",
|
transform: "translateX(-50%)",
|
||||||
width: `${CARET_WIDTH}px`,
|
width: `${CARET_WIDTH}px`,
|
||||||
height: "100%",
|
height: "100%",
|
||||||
display: "block",
|
display: "block",
|
||||||
borderRadius: `${CARET_WIDTH / 2}px`,
|
borderRadius: `${CARET_WIDTH / 2}px`,
|
||||||
animation: "blink-stick 1s steps(1) infinite"
|
animation: "blink-stick 1s steps(1) infinite"
|
||||||
},
|
},
|
||||||
|
|
||||||
"@keyframes blink-stick": {
|
"@keyframes blink-stick": {
|
||||||
"0%, 100%": { opacity: 1 },
|
"0%, 100%": { opacity: 1 },
|
||||||
"50%": { opacity: 0 }
|
"50%": { opacity: 0 }
|
||||||
},
|
},
|
||||||
|
|
||||||
".selection-caret > .dot": {
|
".selection-caret > .dot": {
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
width: `${DOT_RADIUS * 2}px`,
|
width: `${DOT_RADIUS * 2}px`,
|
||||||
height: `${DOT_RADIUS * 2}px`,
|
height: `${DOT_RADIUS * 2}px`,
|
||||||
top: `-${DOT_RADIUS}px`,
|
top: `-${DOT_RADIUS}px`,
|
||||||
left: `-${DOT_RADIUS}px`,
|
left: `-${DOT_RADIUS}px`,
|
||||||
transition: "transform .3s ease-in-out",
|
transition: "transform .3s ease-in-out",
|
||||||
transformOrigin: "bottom center",
|
transformOrigin: "bottom center",
|
||||||
boxSizing: "border-box"
|
boxSizing: "border-box"
|
||||||
},
|
},
|
||||||
|
|
||||||
".selection-caret:hover > .dot": {
|
".selection-caret:hover > .dot": {
|
||||||
transform: "scale(0)"
|
transform: "scale(0)"
|
||||||
},
|
},
|
||||||
|
|
||||||
".selection-caret > .info": {
|
".selection-caret > .info": {
|
||||||
top: "-1.3em",
|
top: "-1.3em",
|
||||||
left: `-${CARET_WIDTH / 2}px`,
|
left: `-${CARET_WIDTH / 2}px`,
|
||||||
fontSize: "0.9em",
|
fontSize: "0.9em",
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
color: "white",
|
color: "white",
|
||||||
padding: "0 2px",
|
padding: "0 2px",
|
||||||
transition: "opacity .3s ease-in-out",
|
transition: "opacity .3s ease-in-out",
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
borderRadius: "3px 3px 3px 0"
|
borderRadius: "3px 3px 3px 0"
|
||||||
},
|
},
|
||||||
|
|
||||||
".selection-caret:hover > .info": {
|
".selection-caret:hover > .info": {
|
||||||
opacity: 1
|
opacity: 1
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,46 @@
|
||||||
import { AnnotationType, Annotation, RangeSet, Range } from "@codemirror/state";
|
import { AnnotationType, Annotation, RangeSet, Range } from "@codemirror/state";
|
||||||
import {
|
import {
|
||||||
ViewUpdate,
|
ViewUpdate,
|
||||||
ViewPlugin,
|
ViewPlugin,
|
||||||
Decoration,
|
Decoration,
|
||||||
WidgetType
|
WidgetType
|
||||||
} from "@codemirror/view";
|
} from "@codemirror/view";
|
||||||
|
|
||||||
import type { PluginValue, DecorationSet, EditorView } from "@codemirror/view";
|
import type { PluginValue, DecorationSet, EditorView } from "@codemirror/view";
|
||||||
|
|
||||||
export class RemoteCursorWidget extends WidgetType {
|
export class RemoteCursorWidget extends WidgetType {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly color: string,
|
private readonly color: string,
|
||||||
private readonly name: string
|
private readonly name: string
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
public toDOM(editor: EditorView): HTMLElement {
|
public toDOM(editor: EditorView): HTMLElement {
|
||||||
return editor.contentDOM.createEl(
|
return editor.contentDOM.createEl(
|
||||||
"span",
|
"span",
|
||||||
{
|
{
|
||||||
cls: "selection-caret",
|
cls: "selection-caret",
|
||||||
attr: {
|
attr: {
|
||||||
style: `background-color: ${this.color}; border-color: ${this.color}`
|
style: `background-color: ${this.color}; border-color: ${this.color}`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
(span) => {
|
(span) => {
|
||||||
span.createEl("div", {
|
span.createEl("div", {
|
||||||
cls: "stick"
|
cls: "stick"
|
||||||
});
|
});
|
||||||
span.createEl("div", {
|
span.createEl("div", {
|
||||||
cls: "dot"
|
cls: "dot"
|
||||||
});
|
});
|
||||||
span.createEl("div", {
|
span.createEl("div", {
|
||||||
cls: "info",
|
cls: "info",
|
||||||
text: this.name
|
text: this.name
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public eq(other: RemoteCursorWidget): boolean {
|
public eq(other: RemoteCursorWidget): boolean {
|
||||||
return other.color === this.color && other.name === this.name;
|
return other.color === this.color && other.name === this.name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,17 @@ import { RangeSet } from "@codemirror/state";
|
||||||
import { ViewPlugin, Decoration } from "@codemirror/view";
|
import { ViewPlugin, Decoration } from "@codemirror/view";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
PluginValue,
|
PluginValue,
|
||||||
DecorationSet,
|
DecorationSet,
|
||||||
EditorView,
|
EditorView,
|
||||||
ViewUpdate
|
ViewUpdate
|
||||||
} from "@codemirror/view";
|
} from "@codemirror/view";
|
||||||
import { RemoteCursorWidget } from "./remote-cursor-widget";
|
import { RemoteCursorWidget } from "./remote-cursor-widget";
|
||||||
import type { RelativePath } from "sync-client";
|
import type { RelativePath } from "sync-client";
|
||||||
import {
|
import {
|
||||||
utils,
|
utils,
|
||||||
type CursorSpan,
|
type CursorSpan,
|
||||||
type MaybeOutdatedClientCursors
|
type MaybeOutdatedClientCursors
|
||||||
} from "sync-client";
|
} from "sync-client";
|
||||||
import type { App } from "obsidian";
|
import type { App } from "obsidian";
|
||||||
import { MarkdownView } from "obsidian";
|
import { MarkdownView } from "obsidian";
|
||||||
|
|
@ -25,241 +25,241 @@ import { reconcileWithHistory } from "reconcile-text";
|
||||||
const forceUpdate = StateEffect.define();
|
const forceUpdate = StateEffect.define();
|
||||||
|
|
||||||
export class RemoteCursorsPluginValue implements PluginValue {
|
export class RemoteCursorsPluginValue implements PluginValue {
|
||||||
private static cursors: {
|
private static cursors: {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
span: CursorSpan;
|
span: CursorSpan;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
isOutdated: boolean;
|
isOutdated: boolean;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
||||||
private static app?: App;
|
private static app?: App;
|
||||||
public decorations: DecorationSet = RangeSet.of([]);
|
public decorations: DecorationSet = RangeSet.of([]);
|
||||||
|
|
||||||
public static setCursors(
|
public static setCursors(
|
||||||
clients: MaybeOutdatedClientCursors[],
|
clients: MaybeOutdatedClientCursors[],
|
||||||
app: App
|
app: App
|
||||||
): void {
|
): void {
|
||||||
RemoteCursorsPluginValue.app = app;
|
RemoteCursorsPluginValue.app = app;
|
||||||
RemoteCursorsPluginValue.cursors = [
|
RemoteCursorsPluginValue.cursors = [
|
||||||
...RemoteCursorsPluginValue.cursors.filter(({ deviceId }) =>
|
...RemoteCursorsPluginValue.cursors.filter(({ deviceId }) =>
|
||||||
clients.some(
|
clients.some(
|
||||||
(client) =>
|
(client) =>
|
||||||
client.deviceId === deviceId && client.isOutdated
|
client.deviceId === deviceId && client.isOutdated
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
...clients
|
...clients
|
||||||
.filter(
|
.filter(
|
||||||
({ isOutdated, deviceId }) =>
|
({ isOutdated, deviceId }) =>
|
||||||
!isOutdated ||
|
!isOutdated ||
|
||||||
RemoteCursorsPluginValue.cursors.every(
|
RemoteCursorsPluginValue.cursors.every(
|
||||||
(c) => deviceId !== c.deviceId
|
(c) => deviceId !== c.deviceId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.flatMap((client) => {
|
.flatMap((client) => {
|
||||||
const clientCursors = client.documentsWithCursors;
|
const clientCursors = client.documentsWithCursors;
|
||||||
return clientCursors.flatMap((cursor) =>
|
return clientCursors.flatMap((cursor) =>
|
||||||
cursor.cursors.map((span) => ({
|
cursor.cursors.map((span) => ({
|
||||||
name: client.userName,
|
name: client.userName,
|
||||||
path: cursor.relative_path,
|
path: cursor.relative_path,
|
||||||
deviceId: client.deviceId,
|
deviceId: client.deviceId,
|
||||||
isOutdated: client.isOutdated,
|
isOutdated: client.isOutdated,
|
||||||
span: { ...span }
|
span: { ...span }
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
app.workspace
|
app.workspace
|
||||||
.getLeavesOfType("markdown")
|
.getLeavesOfType("markdown")
|
||||||
.map((leaf) => leaf.view)
|
.map((leaf) => leaf.view)
|
||||||
.filter((view) => view instanceof MarkdownView)
|
.filter((view) => view instanceof MarkdownView)
|
||||||
.forEach((view) => {
|
.forEach((view) => {
|
||||||
// @ts-expect-error, not typed
|
// @ts-expect-error, not typed
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
const editor = view.editor.cm as EditorView;
|
const editor = view.editor.cm as EditorView;
|
||||||
|
|
||||||
editor.dispatch({
|
editor.dispatch({
|
||||||
effects: [forceUpdate.of(null)]
|
effects: [forceUpdate.of(null)]
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static findFileForEditor(
|
private static findFileForEditor(
|
||||||
editor: EditorView
|
editor: EditorView
|
||||||
): RelativePath | undefined {
|
): RelativePath | undefined {
|
||||||
return RemoteCursorsPluginValue.app?.workspace
|
return RemoteCursorsPluginValue.app?.workspace
|
||||||
.getLeavesOfType("markdown")
|
.getLeavesOfType("markdown")
|
||||||
.map((leaf) => leaf.view)
|
.map((leaf) => leaf.view)
|
||||||
.filter((view) => view instanceof MarkdownView)
|
.filter((view) => view instanceof MarkdownView)
|
||||||
.flatMap((view) => {
|
.flatMap((view) => {
|
||||||
// @ts-expect-error, not typed
|
// @ts-expect-error, not typed
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
if ((view.editor.cm as EditorView) !== editor) {
|
if ((view.editor.cm as EditorView) !== editor) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { file } = view;
|
const { file } = view;
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [file.path];
|
return [file.path];
|
||||||
})
|
})
|
||||||
.first();
|
.first();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static interpolateRemoteCursorPositions(
|
private static interpolateRemoteCursorPositions(
|
||||||
original: string,
|
original: string,
|
||||||
edited: string
|
edited: string
|
||||||
): void {
|
): void {
|
||||||
if (
|
if (
|
||||||
original === edited ||
|
original === edited ||
|
||||||
RemoteCursorsPluginValue.cursors.length === 0
|
RemoteCursorsPluginValue.cursors.length === 0
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedPositions: number[] = [];
|
const updatedPositions: number[] = [];
|
||||||
const reconciled = reconcileWithHistory(
|
const reconciled = reconcileWithHistory(
|
||||||
original,
|
original,
|
||||||
{
|
{
|
||||||
text: original,
|
text: original,
|
||||||
cursors: RemoteCursorsPluginValue.cursors.flatMap(
|
cursors: RemoteCursorsPluginValue.cursors.flatMap(
|
||||||
({ span }, i) => [
|
({ span }, i) => [
|
||||||
{ id: i * 2, position: span.start },
|
{ id: i * 2, position: span.start },
|
||||||
{ id: i * 2 + 1, position: span.end }
|
{ id: i * 2 + 1, position: span.end }
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
edited
|
edited
|
||||||
);
|
);
|
||||||
|
|
||||||
reconciled.cursors.forEach(({ id, position }) => {
|
reconciled.cursors.forEach(({ id, position }) => {
|
||||||
const whereToJump = RemoteCursorsPluginValue.findWhereToMoveCursor(
|
const whereToJump = RemoteCursorsPluginValue.findWhereToMoveCursor(
|
||||||
position,
|
position,
|
||||||
reconciled.history
|
reconciled.history
|
||||||
);
|
);
|
||||||
if (whereToJump !== null) {
|
if (whereToJump !== null) {
|
||||||
updatedPositions[id] = whereToJump;
|
updatedPositions[id] = whereToJump;
|
||||||
} else {
|
} else {
|
||||||
updatedPositions[id] = position;
|
updatedPositions[id] = position;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
RemoteCursorsPluginValue.cursors.forEach(({ span }, i) => {
|
RemoteCursorsPluginValue.cursors.forEach(({ span }, i) => {
|
||||||
span.start = updatedPositions[i * 2];
|
span.start = updatedPositions[i * 2];
|
||||||
span.end = updatedPositions[i * 2 + 1];
|
span.end = updatedPositions[i * 2 + 1];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static findWhereToMoveCursor(
|
private static findWhereToMoveCursor(
|
||||||
cursor: number,
|
cursor: number,
|
||||||
spans: SpanWithHistory[]
|
spans: SpanWithHistory[]
|
||||||
): number | null {
|
): number | null {
|
||||||
let position = 0;
|
let position = 0;
|
||||||
for (const span of spans) {
|
for (const span of spans) {
|
||||||
// left and origin are the same
|
// left and origin are the same
|
||||||
if (position === cursor && span.history === "AddedFromRight") {
|
if (position === cursor && span.history === "AddedFromRight") {
|
||||||
return position + span.text.length;
|
return position + span.text.length;
|
||||||
}
|
}
|
||||||
position += span.text.length;
|
position += span.text.length;
|
||||||
if (position === cursor && span.history === "RemovedFromRight") {
|
if (position === cursor && span.history === "RemovedFromRight") {
|
||||||
return position - span.text.length;
|
return position - span.text.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public update(update: ViewUpdate): void {
|
public update(update: ViewUpdate): void {
|
||||||
const original = update.startState.doc.toString();
|
const original = update.startState.doc.toString();
|
||||||
const edited = update.state.doc.toString();
|
const edited = update.state.doc.toString();
|
||||||
|
|
||||||
RemoteCursorsPluginValue.interpolateRemoteCursorPositions(
|
RemoteCursorsPluginValue.interpolateRemoteCursorPositions(
|
||||||
original,
|
original,
|
||||||
edited
|
edited
|
||||||
);
|
);
|
||||||
|
|
||||||
const decorations: Range<Decoration>[] = [];
|
const decorations: Range<Decoration>[] = [];
|
||||||
const relative_path = RemoteCursorsPluginValue.findFileForEditor(
|
const relative_path = RemoteCursorsPluginValue.findFileForEditor(
|
||||||
update.view
|
update.view
|
||||||
);
|
);
|
||||||
RemoteCursorsPluginValue.cursors
|
RemoteCursorsPluginValue.cursors
|
||||||
.filter(({ path }) => path == relative_path)
|
.filter(({ path }) => path == relative_path)
|
||||||
.forEach(({ name, span: { start, end } }) => {
|
.forEach(({ name, span: { start, end } }) => {
|
||||||
const color = utils.getRandomColor(name);
|
const color = utils.getRandomColor(name);
|
||||||
const startLine = update.view.state.doc.lineAt(start);
|
const startLine = update.view.state.doc.lineAt(start);
|
||||||
const endLine = update.view.state.doc.lineAt(end);
|
const endLine = update.view.state.doc.lineAt(end);
|
||||||
|
|
||||||
const attributes = {
|
const attributes = {
|
||||||
style: `background-color: ${color};`
|
style: `background-color: ${color};`
|
||||||
};
|
};
|
||||||
|
|
||||||
if (startLine.number === endLine.number) {
|
if (startLine.number === endLine.number) {
|
||||||
// selected content in a single line.
|
// selected content in a single line.
|
||||||
decorations.push({
|
decorations.push({
|
||||||
from: start,
|
from: start,
|
||||||
to: end,
|
to: end,
|
||||||
value: Decoration.mark({
|
value: Decoration.mark({
|
||||||
attributes
|
attributes
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// selected content in multiple lines
|
// selected content in multiple lines
|
||||||
// first, render text-selection in the first line
|
// first, render text-selection in the first line
|
||||||
decorations.push({
|
decorations.push({
|
||||||
from: start,
|
from: start,
|
||||||
to: startLine.from + startLine.length,
|
to: startLine.from + startLine.length,
|
||||||
value: Decoration.mark({
|
value: Decoration.mark({
|
||||||
attributes
|
attributes
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
// render text-selection in the lines between the first and last line
|
// render text-selection in the lines between the first and last line
|
||||||
for (
|
for (
|
||||||
let i = startLine.number + 1;
|
let i = startLine.number + 1;
|
||||||
i < endLine.number;
|
i < endLine.number;
|
||||||
i++
|
i++
|
||||||
) {
|
) {
|
||||||
const currentLine = update.view.state.doc.line(i);
|
const currentLine = update.view.state.doc.line(i);
|
||||||
decorations.push({
|
decorations.push({
|
||||||
from: currentLine.from,
|
from: currentLine.from,
|
||||||
to: currentLine.to,
|
to: currentLine.to,
|
||||||
value: Decoration.mark({
|
value: Decoration.mark({
|
||||||
attributes
|
attributes
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// render text-selection in the last line
|
// render text-selection in the last line
|
||||||
decorations.push({
|
decorations.push({
|
||||||
from: endLine.from,
|
from: endLine.from,
|
||||||
to: end,
|
to: end,
|
||||||
value: Decoration.mark({
|
value: Decoration.mark({
|
||||||
attributes
|
attributes
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
decorations.push({
|
decorations.push({
|
||||||
from: end,
|
from: end,
|
||||||
to: end,
|
to: end,
|
||||||
value: Decoration.widget({
|
value: Decoration.widget({
|
||||||
side: end - start > 0 ? -1 : 1, // the local cursor should be rendered outside the remote selection
|
side: end - start > 0 ? -1 : 1, // the local cursor should be rendered outside the remote selection
|
||||||
block: false,
|
block: false,
|
||||||
widget: new RemoteCursorWidget(color, name)
|
widget: new RemoteCursorWidget(color, name)
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.decorations = Decoration.set(decorations, true);
|
this.decorations = Decoration.set(decorations, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const remoteCursorsPlugin = ViewPlugin.fromClass(
|
export const remoteCursorsPlugin = ViewPlugin.fromClass(
|
||||||
RemoteCursorsPluginValue,
|
RemoteCursorsPluginValue,
|
||||||
{
|
{
|
||||||
decorations: (v) => v.decorations
|
decorations: (v) => v.decorations
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,43 @@
|
||||||
.vault-link-sync-status {
|
.vault-link-sync-status {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: var(--size-4-4);
|
right: var(--size-4-4);
|
||||||
top: var(--size-4-2);
|
top: var(--size-4-2);
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
> span {
|
> span {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
padding-right: var(--size-2-2);
|
padding-right: var(--size-2-2);
|
||||||
|
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 0;
|
left: 0;
|
||||||
transform: translateY(-50%) translateX(-100%) translateY(-2px);
|
transform: translateY(-50%) translateX(-100%) translateY(-2px);
|
||||||
transition: opacity 200ms;
|
transition: opacity 200ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
> span {
|
> span {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
> .icon {
|
> .icon {
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.loading > .icon {
|
&.loading > .icon {
|
||||||
animation: spin 2s linear infinite;
|
animation: spin 2s linear infinite;
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
0% {
|
0% {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,91 +7,91 @@ import type VaultLinkPlugin from "src/vault-link-plugin";
|
||||||
import { HistoryView } from "../history/history-view";
|
import { HistoryView } from "../history/history-view";
|
||||||
|
|
||||||
export class EditorStatusDisplayManager {
|
export class EditorStatusDisplayManager {
|
||||||
private static readonly UPDATE_INTERVAL_IN_MS = 100;
|
private static readonly UPDATE_INTERVAL_IN_MS = 100;
|
||||||
|
|
||||||
private readonly intervalId: NodeJS.Timeout;
|
private readonly intervalId: NodeJS.Timeout;
|
||||||
private readonly lastStatuses = new Map<string, DocumentSyncStatus>();
|
private readonly lastStatuses = new Map<string, DocumentSyncStatus>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly plugin: VaultLinkPlugin,
|
private readonly plugin: VaultLinkPlugin,
|
||||||
private readonly workspace: Workspace,
|
private readonly workspace: Workspace,
|
||||||
private readonly client: SyncClient
|
private readonly client: SyncClient
|
||||||
) {
|
) {
|
||||||
this.intervalId = setInterval(() => {
|
this.intervalId = setInterval(() => {
|
||||||
this.updateEditorStatusDisplay();
|
this.updateEditorStatusDisplay();
|
||||||
}, EditorStatusDisplayManager.UPDATE_INTERVAL_IN_MS);
|
}, EditorStatusDisplayManager.UPDATE_INTERVAL_IN_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
clearInterval(this.intervalId);
|
clearInterval(this.intervalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateEditorStatusDisplay(): void {
|
private updateEditorStatusDisplay(): void {
|
||||||
this.workspace.iterateAllLeaves((leaf) => {
|
this.workspace.iterateAllLeaves((leaf) => {
|
||||||
if (leaf.view instanceof FileView) {
|
if (leaf.view instanceof FileView) {
|
||||||
const filePath = leaf.view.file?.path;
|
const filePath = leaf.view.file?.path;
|
||||||
if (filePath == null) {
|
if (filePath == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const element = this.getElementFromLeaf(leaf.view);
|
const element = this.getElementFromLeaf(leaf.view);
|
||||||
if (element == null) {
|
if (element == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousStatus = this.lastStatuses.get(filePath);
|
const previousStatus = this.lastStatuses.get(filePath);
|
||||||
const currentStatus =
|
const currentStatus =
|
||||||
this.client.getDocumentSyncingStatus(filePath);
|
this.client.getDocumentSyncingStatus(filePath);
|
||||||
if (previousStatus === currentStatus) {
|
if (previousStatus === currentStatus) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.lastStatuses.set(filePath, currentStatus);
|
this.lastStatuses.set(filePath, currentStatus);
|
||||||
|
|
||||||
if (currentStatus == DocumentSyncStatus.SYNCING_IS_DISABLED) {
|
if (currentStatus == DocumentSyncStatus.SYNCING_IS_DISABLED) {
|
||||||
element.remove();
|
element.remove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentStatus == DocumentSyncStatus.SYNCING) {
|
if (currentStatus == DocumentSyncStatus.SYNCING) {
|
||||||
element.classList.add("loading");
|
element.classList.add("loading");
|
||||||
} else {
|
} else {
|
||||||
element.classList.remove("loading");
|
element.classList.remove("loading");
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconContainer = element.querySelector(".icon");
|
const iconContainer = element.querySelector(".icon");
|
||||||
if (iconContainer != null) {
|
if (iconContainer != null) {
|
||||||
setIcon(
|
setIcon(
|
||||||
iconContainer as HTMLElement, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
iconContainer as HTMLElement, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
currentStatus == DocumentSyncStatus.SYNCING
|
currentStatus == DocumentSyncStatus.SYNCING
|
||||||
? "loader"
|
? "loader"
|
||||||
: "circle-check"
|
: "circle-check"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getElementFromLeaf(fileView: FileView): Element | undefined {
|
private getElementFromLeaf(fileView: FileView): Element | undefined {
|
||||||
const parent = fileView.contentEl.querySelector(".cm-editor");
|
const parent = fileView.contentEl.querySelector(".cm-editor");
|
||||||
if (parent == null) {
|
if (parent == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
parent.querySelector(".vault-link-sync-status") ??
|
parent.querySelector(".vault-link-sync-status") ??
|
||||||
parent.createDiv(
|
parent.createDiv(
|
||||||
{
|
{
|
||||||
cls: "vault-link-sync-status"
|
cls: "vault-link-sync-status"
|
||||||
},
|
},
|
||||||
(el) => {
|
(el) => {
|
||||||
el.createSpan({ text: "VaultLink sync state" });
|
el.createSpan({ text: "VaultLink sync state" });
|
||||||
el.createDiv({
|
el.createDiv({
|
||||||
cls: "icon"
|
cls: "icon"
|
||||||
});
|
});
|
||||||
el.onclick = async (): Promise<void> =>
|
el.onclick = async (): Promise<void> =>
|
||||||
this.plugin.activateView(HistoryView.TYPE);
|
this.plugin.activateView(HistoryView.TYPE);
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,61 @@
|
||||||
.history-card {
|
.history-card {
|
||||||
padding: var(--size-4-4);
|
padding: var(--size-4-4);
|
||||||
margin: var(--size-4-2);
|
margin: var(--size-4-2);
|
||||||
background-color: var(--color-base-00);
|
background-color: var(--color-base-00);
|
||||||
border-radius: var(--radius-l);
|
border-radius: var(--radius-l);
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
|
||||||
&.clickable {
|
&.clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.success {
|
&.success {
|
||||||
background-color: rgba(var(--color-green-rgb), 0.2);
|
background-color: rgba(var(--color-green-rgb), 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.error {
|
&.error {
|
||||||
background-color: rgba(var(--color-red-rgb), 0.2);
|
background-color: rgba(var(--color-red-rgb), 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.skipped {
|
&.skipped {
|
||||||
background-color: rgba(var(--color-green-rgb), 0.08);
|
background-color: rgba(var(--color-green-rgb), 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-card-header {
|
.history-card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: var(--size-4-2);
|
margin-bottom: var(--size-4-2);
|
||||||
gap: var(--size-4-2);
|
gap: var(--size-4-2);
|
||||||
|
|
||||||
@container (max-width: 300px) {
|
@container (max-width: 300px) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-card-title {
|
.history-card-title {
|
||||||
font: var(--font-monospace);
|
font: var(--font-monospace);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--size-4-2);
|
gap: var(--size-4-2);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
> span {
|
> span {
|
||||||
margin-bottom: var(--size-4-1);
|
margin-bottom: var(--size-4-1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-card-timestamp {
|
.history-card-timestamp {
|
||||||
font-size: var(--font-ui-small);
|
font-size: var(--font-ui-small);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--italic-color);
|
color: var(--italic-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-card-message {
|
.history-card-message {
|
||||||
font-size: var(--font-ui-medium);
|
font-size: var(--font-ui-medium);
|
||||||
color: var(--color-base-70);
|
color: var(--color-base-70);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,234 +7,234 @@ import type { HistoryEntry, SyncClient } from "sync-client";
|
||||||
import { SyncType } from "sync-client";
|
import { SyncType } from "sync-client";
|
||||||
|
|
||||||
export class HistoryView extends ItemView {
|
export class HistoryView extends ItemView {
|
||||||
public static readonly TYPE = "history-view";
|
public static readonly TYPE = "history-view";
|
||||||
public static readonly ICON = "square-stack";
|
public static readonly ICON = "square-stack";
|
||||||
private timer: NodeJS.Timeout | null = null;
|
private timer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
private historyContainer: HTMLElement | undefined;
|
private historyContainer: HTMLElement | undefined;
|
||||||
private readonly historyEntryToElement = new Map<
|
private readonly historyEntryToElement = new Map<
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
HTMLElement
|
HTMLElement
|
||||||
>();
|
>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly client: SyncClient,
|
private readonly client: SyncClient,
|
||||||
leaf: WorkspaceLeaf
|
leaf: WorkspaceLeaf
|
||||||
) {
|
) {
|
||||||
super(leaf);
|
super(leaf);
|
||||||
this.icon = HistoryView.ICON;
|
this.icon = HistoryView.ICON;
|
||||||
|
|
||||||
this.client.addSyncHistoryUpdateListener(async () =>
|
this.client.addSyncHistoryUpdateListener(async () =>
|
||||||
this.updateView().catch((error: unknown) => {
|
this.updateView().catch((error: unknown) => {
|
||||||
this.client.logger.error(
|
this.client.logger.error(
|
||||||
`Failed to update history view: ${error}`
|
`Failed to update history view: ${error}`
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getSyncTypeIcon(type: SyncType | undefined): IconName {
|
private static getSyncTypeIcon(type: SyncType | undefined): IconName {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case SyncType.CREATE:
|
case SyncType.CREATE:
|
||||||
return "file-plus";
|
return "file-plus";
|
||||||
case SyncType.DELETE:
|
case SyncType.DELETE:
|
||||||
return "trash-2";
|
return "trash-2";
|
||||||
case SyncType.UPDATE:
|
case SyncType.UPDATE:
|
||||||
return "file-pen-line";
|
return "file-pen-line";
|
||||||
case SyncType.MOVE:
|
case SyncType.MOVE:
|
||||||
return "move-right";
|
return "move-right";
|
||||||
case SyncType.SKIPPED:
|
case SyncType.SKIPPED:
|
||||||
return "circle-slash";
|
return "circle-slash";
|
||||||
case undefined:
|
case undefined:
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static renderSyncItemTitle(
|
private static renderSyncItemTitle(
|
||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
entry: HistoryEntry
|
entry: HistoryEntry
|
||||||
): void {
|
): void {
|
||||||
const syncTypeIcon = HistoryView.getSyncTypeIcon(entry.details.type);
|
const syncTypeIcon = HistoryView.getSyncTypeIcon(entry.details.type);
|
||||||
if (syncTypeIcon) {
|
if (syncTypeIcon) {
|
||||||
setIcon(element.createDiv(), syncTypeIcon);
|
setIcon(element.createDiv(), syncTypeIcon);
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileName = entry.details.relativePath.split("/").pop() ?? "";
|
let fileName = entry.details.relativePath.split("/").pop() ?? "";
|
||||||
fileName = fileName.replace(/\.md$/, "");
|
fileName = fileName.replace(/\.md$/, "");
|
||||||
|
|
||||||
element.createEl("span", {
|
element.createEl("span", {
|
||||||
text:
|
text:
|
||||||
entry.details.type === SyncType.SKIPPED
|
entry.details.type === SyncType.SKIPPED
|
||||||
? `Skipped: ${fileName}`
|
? `Skipped: ${fileName}`
|
||||||
: fileName
|
: fileName
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static updateTimeSince(
|
private static updateTimeSince(
|
||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
entry: HistoryEntry
|
entry: HistoryEntry
|
||||||
): void {
|
): void {
|
||||||
const timestampElement = element.querySelector(
|
const timestampElement = element.querySelector(
|
||||||
".history-card-timestamp"
|
".history-card-timestamp"
|
||||||
);
|
);
|
||||||
|
|
||||||
if (timestampElement != null) {
|
if (timestampElement != null) {
|
||||||
timestampElement.textContent =
|
timestampElement.textContent =
|
||||||
HistoryView.getTimestampAndAuthor(entry);
|
HistoryView.getTimestampAndAuthor(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getTimestampAndAuthor(entry: HistoryEntry): string {
|
private static getTimestampAndAuthor(entry: HistoryEntry): string {
|
||||||
let content = intlFormatDistance(entry.timestamp, new Date());
|
let content = intlFormatDistance(entry.timestamp, new Date());
|
||||||
if ("author" in entry && entry.author !== undefined) {
|
if ("author" in entry && entry.author !== undefined) {
|
||||||
content += ` by ${entry.author}`;
|
content += ` by ${entry.author}`;
|
||||||
}
|
}
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getViewType(): string {
|
public getViewType(): string {
|
||||||
return HistoryView.TYPE;
|
return HistoryView.TYPE;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getDisplayText(): string {
|
public getDisplayText(): string {
|
||||||
return "VaultLink history";
|
return "VaultLink history";
|
||||||
}
|
}
|
||||||
|
|
||||||
public async onOpen(): Promise<void> {
|
public async onOpen(): Promise<void> {
|
||||||
const container = this.containerEl.children[1];
|
const container = this.containerEl.children[1];
|
||||||
container.createEl("h4", { text: "VaultLink history" });
|
container.createEl("h4", { text: "VaultLink history" });
|
||||||
|
|
||||||
this.historyContainer = container.createDiv({ cls: "logs-container" });
|
this.historyContainer = container.createDiv({ cls: "logs-container" });
|
||||||
|
|
||||||
await this.updateView();
|
await this.updateView();
|
||||||
this.clearTimer();
|
this.clearTimer();
|
||||||
this.timer = setInterval(
|
this.timer = setInterval(
|
||||||
() =>
|
() =>
|
||||||
void this.updateView().catch((error: unknown) => {
|
void this.updateView().catch((error: unknown) => {
|
||||||
this.client.logger.error(
|
this.client.logger.error(
|
||||||
`Failed to update history view: ${error}`
|
`Failed to update history view: ${error}`
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
1000
|
1000
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async onClose(): Promise<void> {
|
public async onClose(): Promise<void> {
|
||||||
this.clearTimer();
|
this.clearTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearTimer(): void {
|
private clearTimer(): void {
|
||||||
if (this.timer) {
|
if (this.timer) {
|
||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateView(): Promise<void> {
|
private async updateView(): Promise<void> {
|
||||||
const container = this.historyContainer;
|
const container = this.historyContainer;
|
||||||
if (container === undefined) {
|
if (container === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// entries are newest first, but we prepend new ones
|
// entries are newest first, but we prepend new ones
|
||||||
const entries = this.client.getHistoryEntries().toReversed();
|
const entries = this.client.getHistoryEntries().toReversed();
|
||||||
|
|
||||||
if (this.historyEntryToElement.size === 0 && entries.length > 0) {
|
if (this.historyEntryToElement.size === 0 && entries.length > 0) {
|
||||||
// Clear the "No update has happened yet" message
|
// Clear the "No update has happened yet" message
|
||||||
container.empty();
|
container.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
const element = this.historyEntryToElement.get(entry);
|
const element = this.historyEntryToElement.get(entry);
|
||||||
if (element !== undefined) {
|
if (element !== undefined) {
|
||||||
HistoryView.updateTimeSince(element, entry);
|
HistoryView.updateTimeSince(element, entry);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newElement = this.createHistoryCard(container, entry);
|
const newElement = this.createHistoryCard(container, entry);
|
||||||
container.prepend(newElement);
|
container.prepend(newElement);
|
||||||
this.historyEntryToElement.set(entry, newElement);
|
this.historyEntryToElement.set(entry, newElement);
|
||||||
});
|
});
|
||||||
|
|
||||||
const newEntries = new Set(entries);
|
const newEntries = new Set(entries);
|
||||||
for (const [entry, element] of this.historyEntryToElement) {
|
for (const [entry, element] of this.historyEntryToElement) {
|
||||||
if (!newEntries.has(entry)) {
|
if (!newEntries.has(entry)) {
|
||||||
element.remove();
|
element.remove();
|
||||||
this.historyEntryToElement.delete(entry);
|
this.historyEntryToElement.delete(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
container.empty();
|
container.empty();
|
||||||
container.createEl("p", {
|
container.createEl("p", {
|
||||||
text: "No update has happened yet."
|
text: "No update has happened yet."
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private createHistoryCard(
|
private createHistoryCard(
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
entry: HistoryEntry
|
entry: HistoryEntry
|
||||||
): HTMLElement {
|
): HTMLElement {
|
||||||
return container.createDiv(
|
return container.createDiv(
|
||||||
{
|
{
|
||||||
cls: ["history-card", entry.status.toLocaleLowerCase()]
|
cls: ["history-card", entry.status.toLocaleLowerCase()]
|
||||||
},
|
},
|
||||||
(card) => {
|
(card) => {
|
||||||
if (
|
if (
|
||||||
this.app.vault.getFileByPath(entry.details.relativePath) !=
|
this.app.vault.getFileByPath(entry.details.relativePath) !=
|
||||||
null
|
null
|
||||||
) {
|
) {
|
||||||
card.addEventListener("click", () => {
|
card.addEventListener("click", () => {
|
||||||
this.app.workspace
|
this.app.workspace
|
||||||
.openLinkText(
|
.openLinkText(
|
||||||
entry.details.relativePath,
|
entry.details.relativePath,
|
||||||
entry.details.relativePath,
|
entry.details.relativePath,
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
this.client.logger.error(
|
this.client.logger.error(
|
||||||
`Failed to open link for ${entry.details.relativePath}: ${error}`
|
`Failed to open link for ${entry.details.relativePath}: ${error}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
card.addClass("clickable");
|
card.addClass("clickable");
|
||||||
}
|
}
|
||||||
|
|
||||||
card.createDiv(
|
card.createDiv(
|
||||||
{
|
{
|
||||||
cls: "history-card-header"
|
cls: "history-card-header"
|
||||||
},
|
},
|
||||||
(header) => {
|
(header) => {
|
||||||
header.createEl(
|
header.createEl(
|
||||||
"h5",
|
"h5",
|
||||||
{
|
{
|
||||||
cls: "history-card-title"
|
cls: "history-card-title"
|
||||||
},
|
},
|
||||||
(title) => {
|
(title) => {
|
||||||
HistoryView.renderSyncItemTitle(title, entry);
|
HistoryView.renderSyncItemTitle(title, entry);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
header.createSpan({
|
header.createSpan({
|
||||||
text: HistoryView.getTimestampAndAuthor(entry),
|
text: HistoryView.getTimestampAndAuthor(entry),
|
||||||
cls: "history-card-timestamp"
|
cls: "history-card-timestamp"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const body =
|
const body =
|
||||||
entry.details.type === SyncType.MOVE
|
entry.details.type === SyncType.MOVE
|
||||||
? `${entry.message}. Moved from '${entry.details.movedFrom}' to '${entry.details.relativePath}'`
|
? `${entry.message}. Moved from '${entry.details.movedFrom}' to '${entry.details.relativePath}'`
|
||||||
: `${entry.message}.`;
|
: `${entry.message}.`;
|
||||||
|
|
||||||
card.createEl("p", {
|
card.createEl("p", {
|
||||||
text: body,
|
text: body,
|
||||||
cls: "history-card-message"
|
cls: "history-card-message"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,74 +1,74 @@
|
||||||
.logs-view {
|
.logs-view {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
.verbosity-selector {
|
.verbosity-selector {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
gap: var(--size-4-2);
|
gap: var(--size-4-2);
|
||||||
margin: var(--size-4-4) var(--size-4-2);
|
margin: var(--size-4-4) var(--size-4-2);
|
||||||
|
|
||||||
h4 {
|
h4 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logs-controls {
|
.logs-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--size-4-2);
|
gap: var(--size-4-2);
|
||||||
|
|
||||||
button {
|
button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--size-2-1);
|
gap: var(--size-2-1);
|
||||||
padding: var(--size-2-2) var(--size-4-2);
|
padding: var(--size-2-2) var(--size-4-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.logs-container {
|
.logs-container {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
.log-message {
|
.log-message {
|
||||||
font: var(--font-monospace);
|
font: var(--font-monospace);
|
||||||
margin-bottom: var(--size-2-1);
|
margin-bottom: var(--size-2-1);
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
user-select: all;
|
user-select: all;
|
||||||
|
|
||||||
.timestamp {
|
.timestamp {
|
||||||
padding: var(--size-2-1) var(--size-4-1);
|
padding: var(--size-2-1) var(--size-4-1);
|
||||||
border-radius: var(--radius-s);
|
border-radius: var(--radius-s);
|
||||||
background-color: var(--color-base-30);
|
background-color: var(--color-base-30);
|
||||||
font-size: var(--font-ui-small);
|
font-size: var(--font-ui-small);
|
||||||
font-family: var(--font-monospace);
|
font-family: var(--font-monospace);
|
||||||
font-weight: var(--bold-weight);
|
font-weight: var(--bold-weight);
|
||||||
margin-right: var(--size-4-1);
|
margin-right: var(--size-4-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.DEBUG {
|
&.DEBUG {
|
||||||
color: var(--color-base-50);
|
color: var(--color-base-50);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.INFO {
|
&.INFO {
|
||||||
color: var(--color-base-100);
|
color: var(--color-base-100);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.WARNING {
|
&.WARNING {
|
||||||
color: rgb(var(--color-yellow-rgb));
|
color: rgb(var(--color-yellow-rgb));
|
||||||
}
|
}
|
||||||
|
|
||||||
&.ERROR {
|
&.ERROR {
|
||||||
color: rgb(var(--color-red-rgb));
|
color: rgb(var(--color-red-rgb));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,189 +6,189 @@ import type { LogLine } from "sync-client";
|
||||||
import { LogLevel, type SyncClient } from "sync-client";
|
import { LogLevel, type SyncClient } from "sync-client";
|
||||||
|
|
||||||
export class LogsView extends ItemView {
|
export class LogsView extends ItemView {
|
||||||
public static readonly TYPE = "logs-view";
|
public static readonly TYPE = "logs-view";
|
||||||
public static readonly ICON = "logs";
|
public static readonly ICON = "logs";
|
||||||
|
|
||||||
private static readonly MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX = 300;
|
private static readonly MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX = 300;
|
||||||
|
|
||||||
private logsContainer: HTMLElement | undefined;
|
private logsContainer: HTMLElement | undefined;
|
||||||
private readonly logLineToElement = new Map<LogLine, HTMLElement>();
|
private readonly logLineToElement = new Map<LogLine, HTMLElement>();
|
||||||
private minLogLevel: LogLevel = LogLevel.INFO;
|
private minLogLevel: LogLevel = LogLevel.INFO;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly client: SyncClient,
|
private readonly client: SyncClient,
|
||||||
leaf: WorkspaceLeaf
|
leaf: WorkspaceLeaf
|
||||||
) {
|
) {
|
||||||
super(leaf);
|
super(leaf);
|
||||||
this.icon = LogsView.ICON;
|
this.icon = LogsView.ICON;
|
||||||
this.client.logger.addOnMessageListener(() => {
|
this.client.logger.addOnMessageListener(() => {
|
||||||
this.updateView();
|
this.updateView();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static createLogLineElement(
|
private static createLogLineElement(
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
logLine: LogLine
|
logLine: LogLine
|
||||||
): HTMLElement {
|
): HTMLElement {
|
||||||
return container.createDiv(
|
return container.createDiv(
|
||||||
{
|
{
|
||||||
cls: ["log-message", logLine.level]
|
cls: ["log-message", logLine.level]
|
||||||
},
|
},
|
||||||
(messageContainer) => {
|
(messageContainer) => {
|
||||||
messageContainer.createEl("span", {
|
messageContainer.createEl("span", {
|
||||||
text: LogsView.formatTimestamp(logLine.timestamp),
|
text: LogsView.formatTimestamp(logLine.timestamp),
|
||||||
cls: "timestamp"
|
cls: "timestamp"
|
||||||
});
|
});
|
||||||
messageContainer.createEl("span", {
|
messageContainer.createEl("span", {
|
||||||
text: logLine.message
|
text: logLine.message
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static formatTimestamp(timestamp: Date): string {
|
private static formatTimestamp(timestamp: Date): string {
|
||||||
return timestamp.toTimeString().split(" ")[0];
|
return timestamp.toTimeString().split(" ")[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
public getViewType(): string {
|
public getViewType(): string {
|
||||||
return LogsView.TYPE;
|
return LogsView.TYPE;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getDisplayText(): string {
|
public getDisplayText(): string {
|
||||||
return "VaultLink logs";
|
return "VaultLink logs";
|
||||||
}
|
}
|
||||||
|
|
||||||
public async onOpen(): Promise<void> {
|
public async onOpen(): Promise<void> {
|
||||||
const container = this.containerEl.children[1];
|
const container = this.containerEl.children[1];
|
||||||
container.addClass("logs-view");
|
container.addClass("logs-view");
|
||||||
|
|
||||||
const logLevels = [
|
const logLevels = [
|
||||||
{ label: "Debug", value: LogLevel.DEBUG },
|
{ label: "Debug", value: LogLevel.DEBUG },
|
||||||
{ label: "Info", value: LogLevel.INFO },
|
{ label: "Info", value: LogLevel.INFO },
|
||||||
{ label: "Warn", value: LogLevel.WARNING },
|
{ label: "Warn", value: LogLevel.WARNING },
|
||||||
{ label: "Error", value: LogLevel.ERROR }
|
{ label: "Error", value: LogLevel.ERROR }
|
||||||
];
|
];
|
||||||
|
|
||||||
container.createDiv(
|
container.createDiv(
|
||||||
{
|
{
|
||||||
cls: "verbosity-selector"
|
cls: "verbosity-selector"
|
||||||
},
|
},
|
||||||
(verbositySection) => {
|
(verbositySection) => {
|
||||||
verbositySection.createEl("h4", {
|
verbositySection.createEl("h4", {
|
||||||
text: "VaultLink logs"
|
text: "VaultLink logs"
|
||||||
});
|
});
|
||||||
|
|
||||||
const controls = verbositySection.createDiv({
|
const controls = verbositySection.createDiv({
|
||||||
cls: "logs-controls"
|
cls: "logs-controls"
|
||||||
});
|
});
|
||||||
|
|
||||||
const copyButton = controls.createEl("button", {
|
const copyButton = controls.createEl("button", {
|
||||||
text: "Copy logs",
|
text: "Copy logs",
|
||||||
cls: "clickable-icon"
|
cls: "clickable-icon"
|
||||||
});
|
});
|
||||||
setIcon(copyButton, "clipboard-copy");
|
setIcon(copyButton, "clipboard-copy");
|
||||||
copyButton.addEventListener("click", () => {
|
copyButton.addEventListener("click", () => {
|
||||||
this.copyLogsToClipboard();
|
this.copyLogsToClipboard();
|
||||||
});
|
});
|
||||||
|
|
||||||
controls.createEl("select", {}, (dropdown) => {
|
controls.createEl("select", {}, (dropdown) => {
|
||||||
logLevels.forEach(({ label, value }) =>
|
logLevels.forEach(({ label, value }) =>
|
||||||
dropdown.createEl("option", { text: label, value })
|
dropdown.createEl("option", { text: label, value })
|
||||||
);
|
);
|
||||||
|
|
||||||
dropdown.value = this.minLogLevel;
|
dropdown.value = this.minLogLevel;
|
||||||
|
|
||||||
dropdown.addEventListener("change", () => {
|
dropdown.addEventListener("change", () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
this.minLogLevel = dropdown.value as LogLevel;
|
this.minLogLevel = dropdown.value as LogLevel;
|
||||||
|
|
||||||
this.logsContainer?.empty();
|
this.logsContainer?.empty();
|
||||||
this.logLineToElement.clear();
|
this.logLineToElement.clear();
|
||||||
this.updateView();
|
this.updateView();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logsContainer = container.createDiv({ cls: "logs-container" });
|
this.logsContainer = container.createDiv({ cls: "logs-container" });
|
||||||
|
|
||||||
this.updateView();
|
this.updateView();
|
||||||
}
|
}
|
||||||
|
|
||||||
private copyLogsToClipboard(): void {
|
private copyLogsToClipboard(): void {
|
||||||
const logs = this.client.logger.getMessages(this.minLogLevel);
|
const logs = this.client.logger.getMessages(this.minLogLevel);
|
||||||
|
|
||||||
if (logs.length === 0) {
|
if (logs.length === 0) {
|
||||||
new Notice("No logs to copy");
|
new Notice("No logs to copy");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formattedLogs = logs
|
const formattedLogs = logs
|
||||||
.map((logLine) => {
|
.map((logLine) => {
|
||||||
const timestamp = logLine.timestamp.toLocaleString();
|
const timestamp = logLine.timestamp.toLocaleString();
|
||||||
const level = logLine.level.toUpperCase();
|
const level = logLine.level.toUpperCase();
|
||||||
return `[${timestamp}] ${level}: ${logLine.message}`;
|
return `[${timestamp}] ${level}: ${logLine.message}`;
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
navigator.clipboard
|
navigator.clipboard
|
||||||
.writeText(formattedLogs)
|
.writeText(formattedLogs)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
new Notice(`Copied ${logs.length} log entries to clipboard`);
|
new Notice(`Copied ${logs.length} log entries to clipboard`);
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
this.client.logger.error(
|
this.client.logger.error(
|
||||||
`Failed to copy logs to clipboard: ${error}`
|
`Failed to copy logs to clipboard: ${error}`
|
||||||
);
|
);
|
||||||
new Notice("Failed to copy logs to clipboard");
|
new Notice("Failed to copy logs to clipboard");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateView(): void {
|
private updateView(): void {
|
||||||
const container = this.logsContainer;
|
const container = this.logsContainer;
|
||||||
if (container === undefined) {
|
if (container === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const logs = this.client.logger.getMessages(this.minLogLevel);
|
const logs = this.client.logger.getMessages(this.minLogLevel);
|
||||||
|
|
||||||
if (this.logLineToElement.size === 0 && logs.length > 0) {
|
if (this.logLineToElement.size === 0 && logs.length > 0) {
|
||||||
// Clear the "No logs available yet" message
|
// Clear the "No logs available yet" message
|
||||||
container.empty();
|
container.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldScroll =
|
const shouldScroll =
|
||||||
container.scrollTop == 0 ||
|
container.scrollTop == 0 ||
|
||||||
container.scrollHeight -
|
container.scrollHeight -
|
||||||
container.clientHeight -
|
container.clientHeight -
|
||||||
container.scrollTop <
|
container.scrollTop <
|
||||||
LogsView.MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX;
|
LogsView.MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX;
|
||||||
|
|
||||||
logs.forEach((message) => {
|
logs.forEach((message) => {
|
||||||
if (this.logLineToElement.has(message)) {
|
if (this.logLineToElement.has(message)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const element = LogsView.createLogLineElement(container, message);
|
const element = LogsView.createLogLineElement(container, message);
|
||||||
|
|
||||||
this.logLineToElement.set(message, element);
|
this.logLineToElement.set(message, element);
|
||||||
});
|
});
|
||||||
|
|
||||||
const newLines = new Set(logs);
|
const newLines = new Set(logs);
|
||||||
for (const [logLine, element] of this.logLineToElement) {
|
for (const [logLine, element] of this.logLineToElement) {
|
||||||
if (!newLines.has(logLine)) {
|
if (!newLines.has(logLine)) {
|
||||||
element.remove();
|
element.remove();
|
||||||
this.logLineToElement.delete(logLine);
|
this.logLineToElement.delete(logLine);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (logs.length === 0) {
|
if (logs.length === 0) {
|
||||||
container.empty();
|
container.empty();
|
||||||
container.createEl("p", {
|
container.createEl("p", {
|
||||||
text: "No logs available yet."
|
text: "No logs available yet."
|
||||||
});
|
});
|
||||||
} else if (shouldScroll) {
|
} else if (shouldScroll) {
|
||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,134 +1,134 @@
|
||||||
@mixin number-card {
|
@mixin number-card {
|
||||||
padding: var(--size-2-1) var(--size-4-1);
|
padding: var(--size-2-1) var(--size-4-1);
|
||||||
border-radius: var(--radius-s);
|
border-radius: var(--radius-s);
|
||||||
background-color: var(--color-base-30);
|
background-color: var(--color-base-30);
|
||||||
font-size: var(--font-ui-small);
|
font-size: var(--font-ui-small);
|
||||||
|
|
||||||
&.good {
|
&.good {
|
||||||
background-color: rgba(var(--color-green-rgb), 0.35);
|
background-color: rgba(var(--color-green-rgb), 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.bad {
|
&.bad {
|
||||||
background-color: rgba(var(--color-red-rgb), 0.35);
|
background-color: rgba(var(--color-red-rgb), 0.35);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.vault-link-settings-container {
|
.vault-link-settings-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
.vault-link-settings {
|
.vault-link-settings {
|
||||||
h2 {
|
h2 {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: var(--h2-size);
|
font-size: var(--h2-size);
|
||||||
|
|
||||||
.version {
|
.version {
|
||||||
@include number-card;
|
@include number-card;
|
||||||
margin: var(--size-2-2) 0 0 var(--size-4-2);
|
margin: var(--size-2-2) 0 0 var(--size-4-2);
|
||||||
background-color: var(--color-base-30);
|
background-color: var(--color-base-30);
|
||||||
color: var(--color-base-70);
|
color: var(--color-base-70);
|
||||||
font-size: var(--font-ui-smaller);
|
font-size: var(--font-ui-smaller);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-container {
|
.button-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--size-4-2);
|
gap: var(--size-4-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
font-size: var(--font-ui-large);
|
font-size: var(--font-ui-large);
|
||||||
margin-top: var(--heading-spacing);
|
margin-top: var(--heading-spacing);
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
input[type="range"],
|
input[type="range"],
|
||||||
.checkbox-container,
|
.checkbox-container,
|
||||||
.slider::-webkit-slider-thumb {
|
.slider::-webkit-slider-thumb {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
textarea {
|
textarea {
|
||||||
width: 250px;
|
width: 250px;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
resize: none;
|
resize: none;
|
||||||
height: 75px;
|
height: 75px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.applying-changes-overlay {
|
.applying-changes-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateY(-50%) translateX(-50%);
|
transform: translateY(-50%) translateX(-50%);
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
|
|
||||||
.spinner-container {
|
.spinner-container {
|
||||||
background-color: rgba(var(--background-primary), 0.5);
|
background-color: rgba(var(--background-primary), 0.5);
|
||||||
border: 1px solid var(--background-modifier-border);
|
border: 1px solid var(--background-modifier-border);
|
||||||
border-radius: var(--radius-m);
|
border-radius: var(--radius-m);
|
||||||
padding: var(--size-4-8);
|
padding: var(--size-4-8);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--size-4-3);
|
gap: var(--size-4-3);
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border: 4px solid var(--background-modifier-border);
|
border: 4px solid var(--background-modifier-border);
|
||||||
border-top-color: var(--interactive-accent);
|
border-top-color: var(--interactive-accent);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner-text {
|
.spinner-text {
|
||||||
color: var(--text-normal);
|
color: var(--text-normal);
|
||||||
font-size: var(--font-ui-medium);
|
font-size: var(--font-ui-medium);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner-warning {
|
.spinner-warning {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: var(--font-ui-small);
|
font-size: var(--font-ui-small);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: var(--size-2-2);
|
margin-top: var(--size-2-2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
from {
|
from {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.applying-changes {
|
&.applying-changes {
|
||||||
.setting-item-control {
|
.setting-item-control {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:not(.applying-changes-overlay button) {
|
button:not(.applying-changes-overlay button) {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
textarea,
|
textarea,
|
||||||
select {
|
select {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,14 +1,14 @@
|
||||||
.sync-status {
|
.sync-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--size-4-2);
|
gap: var(--size-4-2);
|
||||||
|
|
||||||
* {
|
* {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.initialize-button {
|
.initialize-button {
|
||||||
padding: 0 var(--size-4-2);
|
padding: 0 var(--size-4-2);
|
||||||
background: rgba(var(--color-red-rgb), 0.4);
|
background: rgba(var(--color-red-rgb), 0.4);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,72 +4,72 @@ import type { HistoryStats, SyncClient } from "sync-client";
|
||||||
import type VaultLinkPlugin from "../../vault-link-plugin";
|
import type VaultLinkPlugin from "../../vault-link-plugin";
|
||||||
|
|
||||||
export class StatusBar {
|
export class StatusBar {
|
||||||
private readonly statusBarItem: HTMLElement;
|
private readonly statusBarItem: HTMLElement;
|
||||||
|
|
||||||
private lastHistoryStats: HistoryStats | undefined;
|
private lastHistoryStats: HistoryStats | undefined;
|
||||||
private lastRemaining: number | undefined;
|
private lastRemaining: number | undefined;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly plugin: VaultLinkPlugin,
|
private readonly plugin: VaultLinkPlugin,
|
||||||
private readonly syncClient: SyncClient
|
private readonly syncClient: SyncClient
|
||||||
) {
|
) {
|
||||||
this.statusBarItem = plugin.addStatusBarItem();
|
this.statusBarItem = plugin.addStatusBarItem();
|
||||||
this.syncClient.addSyncHistoryUpdateListener((status) => {
|
this.syncClient.addSyncHistoryUpdateListener((status) => {
|
||||||
this.lastHistoryStats = status;
|
this.lastHistoryStats = status;
|
||||||
this.updateStatus();
|
this.updateStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.syncClient.addRemainingSyncOperationsListener(
|
this.syncClient.addRemainingSyncOperationsListener(
|
||||||
(remainingOperations) => {
|
(remainingOperations) => {
|
||||||
this.lastRemaining = remainingOperations;
|
this.lastRemaining = remainingOperations;
|
||||||
this.updateStatus();
|
this.updateStatus();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
this.syncClient.addOnSettingsChangeListener(() => {
|
this.syncClient.addOnSettingsChangeListener(() => {
|
||||||
this.updateStatus();
|
this.updateStatus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateStatus(): void {
|
private updateStatus(): void {
|
||||||
this.statusBarItem.empty();
|
this.statusBarItem.empty();
|
||||||
const container = this.statusBarItem.createDiv({
|
const container = this.statusBarItem.createDiv({
|
||||||
cls: ["sync-status"]
|
cls: ["sync-status"]
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!this.syncClient.getSettings().isSyncEnabled) {
|
if (!this.syncClient.getSettings().isSyncEnabled) {
|
||||||
const button = container.createEl("button", {
|
const button = container.createEl("button", {
|
||||||
text: "VaultLink is disabled, click to configure",
|
text: "VaultLink is disabled, click to configure",
|
||||||
cls: "initialize-button"
|
cls: "initialize-button"
|
||||||
});
|
});
|
||||||
button.onclick = this.plugin.openSettings.bind(this.plugin);
|
button.onclick = this.plugin.openSettings.bind(this.plugin);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasShownMessage = false;
|
let hasShownMessage = false;
|
||||||
|
|
||||||
if ((this.lastRemaining ?? 0) > 0) {
|
if ((this.lastRemaining ?? 0) > 0) {
|
||||||
hasShownMessage = true;
|
hasShownMessage = true;
|
||||||
container.createSpan({ text: `${this.lastRemaining} ⏳` });
|
container.createSpan({ text: `${this.lastRemaining} ⏳` });
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((this.lastHistoryStats?.success ?? 0) > 0) {
|
if ((this.lastHistoryStats?.success ?? 0) > 0) {
|
||||||
hasShownMessage = true;
|
hasShownMessage = true;
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: `${this.lastHistoryStats?.success ?? 0} ✅`
|
text: `${this.lastHistoryStats?.success ?? 0} ✅`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((this.lastHistoryStats?.error ?? 0) > 0) {
|
if ((this.lastHistoryStats?.error ?? 0) > 0) {
|
||||||
hasShownMessage = true;
|
hasShownMessage = true;
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: `${this.lastHistoryStats?.error ?? 0} ❌`
|
text: `${this.lastHistoryStats?.error ?? 0} ❌`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasShownMessage) {
|
if (!hasShownMessage) {
|
||||||
container.createSpan({ text: "VaultLink is idle" });
|
container.createSpan({ text: "VaultLink is idle" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,32 @@
|
||||||
@mixin number-card {
|
@mixin number-card {
|
||||||
padding: var(--size-2-1) var(--size-4-1);
|
padding: var(--size-2-1) var(--size-4-1);
|
||||||
border-radius: var(--radius-s);
|
border-radius: var(--radius-s);
|
||||||
background-color: var(--color-base-30);
|
background-color: var(--color-base-30);
|
||||||
font-size: var(--font-ui-small);
|
font-size: var(--font-ui-small);
|
||||||
|
|
||||||
&.good {
|
&.good {
|
||||||
background-color: rgba(var(--color-green-rgb), 0.35);
|
background-color: rgba(var(--color-green-rgb), 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.bad {
|
&.bad {
|
||||||
background-color: rgba(var(--color-red-rgb), 0.35);
|
background-color: rgba(var(--color-red-rgb), 0.35);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-description {
|
.status-description {
|
||||||
margin: var(--p-spacing) 0;
|
margin: var(--p-spacing) 0;
|
||||||
|
|
||||||
.number {
|
.number {
|
||||||
@include number-card;
|
@include number-card;
|
||||||
font-family: var(--font-monospace);
|
font-family: var(--font-monospace);
|
||||||
font-weight: var(--bold-weight);
|
font-weight: var(--bold-weight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: rgb(var(--color-red-rgb));
|
color: rgb(var(--color-red-rgb));
|
||||||
}
|
}
|
||||||
|
|
||||||
.warning {
|
.warning {
|
||||||
color: rgb(var(--color-yellow-rgb));
|
color: rgb(var(--color-yellow-rgb));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,147 +1,147 @@
|
||||||
import "./status-description.scss";
|
import "./status-description.scss";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
HistoryStats,
|
HistoryStats,
|
||||||
NetworkConnectionStatus,
|
NetworkConnectionStatus,
|
||||||
SyncClient
|
SyncClient
|
||||||
} from "sync-client";
|
} from "sync-client";
|
||||||
import { utils } from "sync-client";
|
import { utils } from "sync-client";
|
||||||
|
|
||||||
export class StatusDescription {
|
export class StatusDescription {
|
||||||
private lastHistoryStats: HistoryStats | undefined;
|
private lastHistoryStats: HistoryStats | undefined;
|
||||||
private lastRemaining: number | undefined;
|
private lastRemaining: number | undefined;
|
||||||
private lastConnectionState: NetworkConnectionStatus | undefined;
|
private lastConnectionState: NetworkConnectionStatus | undefined;
|
||||||
|
|
||||||
private readonly statusChangeListeners: (() => unknown)[] = [];
|
private readonly statusChangeListeners: (() => unknown)[] = [];
|
||||||
|
|
||||||
public constructor(private readonly syncClient: SyncClient) {
|
public constructor(private readonly syncClient: SyncClient) {
|
||||||
void this.updateConnectionState();
|
void this.updateConnectionState();
|
||||||
|
|
||||||
syncClient.addSyncHistoryUpdateListener((status) => {
|
syncClient.addSyncHistoryUpdateListener((status) => {
|
||||||
this.lastHistoryStats = status;
|
this.lastHistoryStats = status;
|
||||||
this.updateDescription();
|
this.updateDescription();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.syncClient.addRemainingSyncOperationsListener(
|
this.syncClient.addRemainingSyncOperationsListener(
|
||||||
(remainingOperations) => {
|
(remainingOperations) => {
|
||||||
this.lastRemaining = remainingOperations;
|
this.lastRemaining = remainingOperations;
|
||||||
this.updateDescription();
|
this.updateDescription();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
this.syncClient.addWebSocketStatusChangeListener(async () =>
|
this.syncClient.addWebSocketStatusChangeListener(async () =>
|
||||||
this.updateConnectionState()
|
this.updateConnectionState()
|
||||||
);
|
);
|
||||||
|
|
||||||
this.syncClient.addOnSettingsChangeListener(async () =>
|
this.syncClient.addOnSettingsChangeListener(async () =>
|
||||||
this.updateConnectionState()
|
this.updateConnectionState()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateConnectionState(): Promise<void> {
|
public async updateConnectionState(): Promise<void> {
|
||||||
this.lastConnectionState = await this.syncClient.checkConnection();
|
this.lastConnectionState = await this.syncClient.checkConnection();
|
||||||
this.updateDescription();
|
this.updateDescription();
|
||||||
}
|
}
|
||||||
|
|
||||||
public addStatusChangeListener(listener: () => unknown): void {
|
public addStatusChangeListener(listener: () => unknown): void {
|
||||||
this.statusChangeListeners.push(listener);
|
this.statusChangeListeners.push(listener);
|
||||||
}
|
}
|
||||||
public removeStatusChangeListener(listener: () => unknown): void {
|
public removeStatusChangeListener(listener: () => unknown): void {
|
||||||
utils.removeFromArray(this.statusChangeListeners, listener);
|
utils.removeFromArray(this.statusChangeListeners, listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
public renderStatusDescription(container: HTMLElement): void {
|
public renderStatusDescription(container: HTMLElement): void {
|
||||||
container.empty();
|
container.empty();
|
||||||
container.addClass("status-description");
|
container.addClass("status-description");
|
||||||
|
|
||||||
if (this.lastConnectionState == undefined) {
|
if (this.lastConnectionState == undefined) {
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: "VaultLink is starting up…",
|
text: "VaultLink is starting up…",
|
||||||
cls: "warning"
|
cls: "warning"
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.lastConnectionState.isSuccessful) {
|
if (!this.lastConnectionState.isSuccessful) {
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: `VaultLink failed to connect to the remote server with error '${this.lastConnectionState.serverMessage}'`,
|
text: `VaultLink failed to connect to the remote server with error '${this.lastConnectionState.serverMessage}'`,
|
||||||
cls: "error"
|
cls: "error"
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.lastConnectionState.isWebSocketConnected) {
|
if (!this.lastConnectionState.isWebSocketConnected) {
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: `${this.lastConnectionState.serverMessage} but the WebSocket connection could not be established.`,
|
text: `${this.lastConnectionState.serverMessage} but the WebSocket connection could not be established.`,
|
||||||
cls: "error"
|
cls: "error"
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.createSpan({ text: "VaultLink is connected to the server " });
|
container.createSpan({ text: "VaultLink is connected to the server " });
|
||||||
container.createEl("a", {
|
container.createEl("a", {
|
||||||
text: this.syncClient.getSettings().remoteUri,
|
text: this.syncClient.getSettings().remoteUri,
|
||||||
href: this.syncClient.getSettings().remoteUri
|
href: this.syncClient.getSettings().remoteUri
|
||||||
});
|
});
|
||||||
|
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: ` and has indexed approximately `
|
text: ` and has indexed approximately `
|
||||||
});
|
});
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: `${this.syncClient.documentCount}`,
|
text: `${this.syncClient.documentCount}`,
|
||||||
cls: "number"
|
cls: "number"
|
||||||
});
|
});
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: ` documents. `
|
text: ` documents. `
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(this.lastRemaining ?? 0) === 0 &&
|
(this.lastRemaining ?? 0) === 0 &&
|
||||||
(this.lastHistoryStats?.success ?? 0) === 0 &&
|
(this.lastHistoryStats?.success ?? 0) === 0 &&
|
||||||
(this.lastHistoryStats?.error ?? 0) === 0
|
(this.lastHistoryStats?.error ?? 0) === 0
|
||||||
) {
|
) {
|
||||||
if (this.syncClient.getSettings().isSyncEnabled) {
|
if (this.syncClient.getSettings().isSyncEnabled) {
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: "Syncing is enabled but VaultLink hasn't found anything to sync yet."
|
text: "Syncing is enabled but VaultLink hasn't found anything to sync yet."
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: "However, syncing is disabled right now.",
|
text: "However, syncing is disabled right now.",
|
||||||
cls: "warning"
|
cls: "warning"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: "The plugin has "
|
text: "The plugin has "
|
||||||
});
|
});
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: `${this.lastRemaining ?? 0}`,
|
text: `${this.lastRemaining ?? 0}`,
|
||||||
cls: "number"
|
cls: "number"
|
||||||
});
|
});
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: " outstanding operations while having succeeded "
|
text: " outstanding operations while having succeeded "
|
||||||
});
|
});
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: `${this.lastHistoryStats?.success ?? 0}`,
|
text: `${this.lastHistoryStats?.success ?? 0}`,
|
||||||
cls: ["number", "good"]
|
cls: ["number", "good"]
|
||||||
});
|
});
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: " times and failed "
|
text: " times and failed "
|
||||||
});
|
});
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: `${this.lastHistoryStats?.error ?? 0}`,
|
text: `${this.lastHistoryStats?.error ?? 0}`,
|
||||||
cls: ["number", "bad"]
|
cls: ["number", "bad"]
|
||||||
});
|
});
|
||||||
container.createSpan({
|
container.createSpan({
|
||||||
text: " times."
|
text: " times."
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateDescription(): void {
|
private updateDescription(): void {
|
||||||
this.statusChangeListeners.forEach((listener) => {
|
this.statusChangeListeners.forEach((listener) => {
|
||||||
listener();
|
listener();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,114 +4,114 @@ const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||||
const fs = require("fs-extra");
|
const fs = require("fs-extra");
|
||||||
|
|
||||||
module.exports = (env, argv) => ({
|
module.exports = (env, argv) => ({
|
||||||
devtool: argv.mode === "development" ? "inline-source-map" : false,
|
devtool: argv.mode === "development" ? "inline-source-map" : false,
|
||||||
entry: {
|
entry: {
|
||||||
index: "./src/vault-link-plugin.ts"
|
index: "./src/vault-link-plugin.ts"
|
||||||
},
|
},
|
||||||
watchOptions: {
|
watchOptions: {
|
||||||
ignored: "**/node_modules"
|
ignored: "**/node_modules"
|
||||||
},
|
},
|
||||||
externals: {
|
externals: {
|
||||||
obsidian: "commonjs obsidian",
|
obsidian: "commonjs obsidian",
|
||||||
electron: "commonjs electron",
|
electron: "commonjs electron",
|
||||||
"@codemirror/autocomplete": "commonjs @codemirror/autocomplete",
|
"@codemirror/autocomplete": "commonjs @codemirror/autocomplete",
|
||||||
"@codemirror/collab": "commonjs @codemirror/collab",
|
"@codemirror/collab": "commonjs @codemirror/collab",
|
||||||
"@codemirror/commands": "commonjs @codemirror/commands",
|
"@codemirror/commands": "commonjs @codemirror/commands",
|
||||||
"@codemirror/language": "commonjs @codemirror/language",
|
"@codemirror/language": "commonjs @codemirror/language",
|
||||||
"@codemirror/lint": "commonjs @codemirror/lint",
|
"@codemirror/lint": "commonjs @codemirror/lint",
|
||||||
"@codemirror/search": "commonjs @codemirror/search",
|
"@codemirror/search": "commonjs @codemirror/search",
|
||||||
"@codemirror/state": "commonjs @codemirror/state",
|
"@codemirror/state": "commonjs @codemirror/state",
|
||||||
"@codemirror/view": "commonjs @codemirror/view"
|
"@codemirror/view": "commonjs @codemirror/view"
|
||||||
},
|
},
|
||||||
optimization: {
|
optimization: {
|
||||||
minimizer: [
|
minimizer: [
|
||||||
new TerserPlugin({
|
new TerserPlugin({
|
||||||
terserOptions: {
|
terserOptions: {
|
||||||
module: true
|
module: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
new MiniCssExtractPlugin({
|
new MiniCssExtractPlugin({
|
||||||
filename: "styles.css"
|
filename: "styles.css"
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
apply: (compiler) => {
|
apply: (compiler) => {
|
||||||
if (argv.mode !== "development") {
|
if (argv.mode !== "development") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
compiler.hooks.done.tap("Copy Files Plugin", (stats) => {
|
compiler.hooks.done.tap("Copy Files Plugin", (stats) => {
|
||||||
const source = path.resolve(__dirname, "dist");
|
const source = path.resolve(__dirname, "dist");
|
||||||
const destinations = [
|
const destinations = [
|
||||||
"/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link",
|
"/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link",
|
||||||
"/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link",
|
"/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link",
|
||||||
// "/home/andras/obsidian-test/.obsidian/plugins/vault-link"
|
// "/home/andras/obsidian-test/.obsidian/plugins/vault-link"
|
||||||
];
|
];
|
||||||
destinations.forEach((destination) => {
|
destinations.forEach((destination) => {
|
||||||
fs.copy(source, destination)
|
fs.copy(source, destination)
|
||||||
.then(() =>
|
.then(() =>
|
||||||
console.log(
|
console.log(
|
||||||
"Files copied successfully after build!"
|
"Files copied successfully after build!"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.catch((err) =>
|
.catch((err) =>
|
||||||
console.error("Error copying files:", err)
|
console.error("Error copying files:", err)
|
||||||
);
|
);
|
||||||
|
|
||||||
fs.createFile(path.join(destination, ".hotreload"));
|
fs.createFile(path.join(destination, ".hotreload"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
test: /\.json$/i,
|
test: /\.json$/i,
|
||||||
type: "asset/resource",
|
type: "asset/resource",
|
||||||
generator: {
|
generator: {
|
||||||
filename: "[name][ext]"
|
filename: "[name][ext]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
test: /\.scss$/i,
|
test: /\.scss$/i,
|
||||||
use: [
|
use: [
|
||||||
MiniCssExtractPlugin.loader,
|
MiniCssExtractPlugin.loader,
|
||||||
"css-loader",
|
"css-loader",
|
||||||
"resolve-url-loader",
|
"resolve-url-loader",
|
||||||
{
|
{
|
||||||
loader: "sass-loader",
|
loader: "sass-loader",
|
||||||
options: {
|
options: {
|
||||||
sourceMap: true // required by resolve-url-loader
|
sourceMap: true // required by resolve-url-loader
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
test: /\.ts$/,
|
test: /\.ts$/,
|
||||||
use: ["ts-loader"]
|
use: ["ts-loader"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
extensions: [
|
extensions: [
|
||||||
".ts",
|
".ts",
|
||||||
".js" // required for development
|
".js" // required for development
|
||||||
],
|
],
|
||||||
alias: {
|
alias: {
|
||||||
root: __dirname,
|
root: __dirname,
|
||||||
src: path.resolve(__dirname, "src")
|
src: path.resolve(__dirname, "src")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
clean: true,
|
clean: true,
|
||||||
filename: "main.js",
|
filename: "main.js",
|
||||||
library: {
|
library: {
|
||||||
type: "commonjs" // required for Obsidian
|
type: "commonjs" // required for Obsidian
|
||||||
},
|
},
|
||||||
path: path.resolve(__dirname, "dist"),
|
path: path.resolve(__dirname, "dist"),
|
||||||
publicPath: ""
|
publicPath: ""
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
12259
frontend/package-lock.json
generated
12259
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,32 +1,32 @@
|
||||||
{
|
{
|
||||||
"name": "my-workspace",
|
"name": "my-workspace",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"sync-client",
|
"sync-client",
|
||||||
"obsidian-plugin",
|
"obsidian-plugin",
|
||||||
"test-client",
|
"test-client",
|
||||||
"local-client-cli"
|
"local-client-cli"
|
||||||
],
|
],
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"tabWidth": 4,
|
"tabWidth": 4,
|
||||||
"useTabs": true,
|
"useTabs": true,
|
||||||
"endOfLine": "lf"
|
"endOfLine": "lf"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build --workspaces",
|
"build": "npm run build --workspaces",
|
||||||
"dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"",
|
"dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"",
|
||||||
"test": "npm run test --workspaces",
|
"test": "npm run test --workspaces",
|
||||||
"lint": "eslint --fix sync-client obsidian-plugin test-client local-client-cli && prettier --write \"**/*.ts\"",
|
"lint": "eslint --fix sync-client obsidian-plugin test-client local-client-cli && prettier --write \"**/*.ts\"",
|
||||||
"update": "ncu -u -ws"
|
"update": "ncu -u -ws"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"eclint": "^2.8.1",
|
"eclint": "^2.8.1",
|
||||||
"eslint": "9.38.0",
|
"eslint": "9.38.0",
|
||||||
"eslint-plugin-unused-imports": "^4.1.4",
|
"eslint-plugin-unused-imports": "^4.1.4",
|
||||||
"npm-check-updates": "^19.1.1",
|
"npm-check-updates": "^19.1.1",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"typescript-eslint": "8.41.0"
|
"typescript-eslint": "8.41.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
export class FileNotFoundError extends Error {
|
export class FileNotFoundError extends Error {
|
||||||
public constructor(
|
public constructor(
|
||||||
message: string,
|
message: string,
|
||||||
public readonly filePath: string
|
public readonly filePath: string
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "FileNotFoundError";
|
this.name = "FileNotFoundError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import type {
|
import type {
|
||||||
Database,
|
Database,
|
||||||
DocumentRecord,
|
DocumentRecord,
|
||||||
RelativePath
|
RelativePath
|
||||||
} from "../persistence/database";
|
} from "../persistence/database";
|
||||||
import { FileOperations } from "./file-operations";
|
import { FileOperations } from "./file-operations";
|
||||||
import { Logger } from "../tracing/logger";
|
import { Logger } from "../tracing/logger";
|
||||||
|
|
@ -12,224 +12,224 @@ import type { TextWithCursors } from "reconcile-text";
|
||||||
import type { ServerConfig, ServerConfigData } from "../services/server-config";
|
import type { ServerConfig, ServerConfigData } from "../services/server-config";
|
||||||
|
|
||||||
class MockServerConfig implements Pick<ServerConfig, "getConfig"> {
|
class MockServerConfig implements Pick<ServerConfig, "getConfig"> {
|
||||||
public getConfig(): ServerConfigData {
|
public getConfig(): ServerConfigData {
|
||||||
return {
|
return {
|
||||||
mergeableFileExtensions: ["md", "txt"],
|
mergeableFileExtensions: ["md", "txt"],
|
||||||
supportedApiVersion: 1,
|
supportedApiVersion: 1,
|
||||||
isAuthenticated: true
|
isAuthenticated: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MockDatabase implements Partial<Database> {
|
class MockDatabase implements Partial<Database> {
|
||||||
public getLatestDocumentByRelativePath(
|
public getLatestDocumentByRelativePath(
|
||||||
_find: RelativePath
|
_find: RelativePath
|
||||||
): DocumentRecord | undefined {
|
): DocumentRecord | undefined {
|
||||||
// no-op
|
// no-op
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
public move(
|
public move(
|
||||||
_oldRelativePath: RelativePath,
|
_oldRelativePath: RelativePath,
|
||||||
_newRelativePath: RelativePath
|
_newRelativePath: RelativePath
|
||||||
): void {
|
): void {
|
||||||
// no-op
|
// no-op
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeFileSystemOperations implements FileSystemOperations {
|
class FakeFileSystemOperations implements FileSystemOperations {
|
||||||
public readonly names = new Set<string>();
|
public readonly names = new Set<string>();
|
||||||
|
|
||||||
public async listFilesRecursively(
|
public async listFilesRecursively(
|
||||||
_root: RelativePath | undefined
|
_root: RelativePath | undefined
|
||||||
): Promise<RelativePath[]> {
|
): Promise<RelativePath[]> {
|
||||||
return ["file.md"];
|
return ["file.md"];
|
||||||
}
|
}
|
||||||
public async read(_path: RelativePath): Promise<Uint8Array> {
|
public async read(_path: RelativePath): Promise<Uint8Array> {
|
||||||
throw new Error("Method not implemented.");
|
throw new Error("Method not implemented.");
|
||||||
}
|
}
|
||||||
public async write(
|
public async write(
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
_content: Uint8Array
|
_content: Uint8Array
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.names.add(path);
|
this.names.add(path);
|
||||||
}
|
}
|
||||||
public async atomicUpdateText(
|
public async atomicUpdateText(
|
||||||
_path: RelativePath,
|
_path: RelativePath,
|
||||||
_updater: (current: TextWithCursors) => TextWithCursors
|
_updater: (current: TextWithCursors) => TextWithCursors
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
throw new Error("Method not implemented.");
|
throw new Error("Method not implemented.");
|
||||||
}
|
}
|
||||||
public async getFileSize(_path: RelativePath): Promise<number> {
|
public async getFileSize(_path: RelativePath): Promise<number> {
|
||||||
throw new Error("Method not implemented.");
|
throw new Error("Method not implemented.");
|
||||||
}
|
}
|
||||||
public async getModificationTime(_path: RelativePath): Promise<Date> {
|
public async getModificationTime(_path: RelativePath): Promise<Date> {
|
||||||
throw new Error("Method not implemented.");
|
throw new Error("Method not implemented.");
|
||||||
}
|
}
|
||||||
public async exists(path: RelativePath): Promise<boolean> {
|
public async exists(path: RelativePath): Promise<boolean> {
|
||||||
return this.names.has(path);
|
return this.names.has(path);
|
||||||
}
|
}
|
||||||
public async createDirectory(_path: RelativePath): Promise<void> {
|
public async createDirectory(_path: RelativePath): Promise<void> {
|
||||||
// this is called but irrelevant for this mock
|
// this is called but irrelevant for this mock
|
||||||
}
|
}
|
||||||
public async delete(_path: RelativePath): Promise<void> {
|
public async delete(_path: RelativePath): Promise<void> {
|
||||||
throw new Error("Method not implemented.");
|
throw new Error("Method not implemented.");
|
||||||
}
|
}
|
||||||
public async rename(
|
public async rename(
|
||||||
oldPath: RelativePath,
|
oldPath: RelativePath,
|
||||||
newPath: RelativePath
|
newPath: RelativePath
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.names.delete(oldPath);
|
this.names.delete(oldPath);
|
||||||
this.names.add(newPath);
|
this.names.add(newPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("File operations", () => {
|
describe("File operations", () => {
|
||||||
it("should deconflict renames", async () => {
|
it("should deconflict renames", async () => {
|
||||||
const fileSystemOperations = new FakeFileSystemOperations();
|
const fileSystemOperations = new FakeFileSystemOperations();
|
||||||
const fileOperations = new FileOperations(
|
const fileOperations = new FileOperations(
|
||||||
new Logger(),
|
new Logger(),
|
||||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
fileSystemOperations,
|
fileSystemOperations,
|
||||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
);
|
);
|
||||||
|
|
||||||
await fileOperations.create("a", new Uint8Array());
|
await fileOperations.create("a", new Uint8Array());
|
||||||
assertSetContainsExactly(fileSystemOperations.names, "a");
|
assertSetContainsExactly(fileSystemOperations.names, "a");
|
||||||
await fileOperations.move("a", "b");
|
await fileOperations.move("a", "b");
|
||||||
assertSetContainsExactly(fileSystemOperations.names, "b");
|
assertSetContainsExactly(fileSystemOperations.names, "b");
|
||||||
|
|
||||||
await fileOperations.create("c", new Uint8Array());
|
await fileOperations.create("c", new Uint8Array());
|
||||||
assertSetContainsExactly(fileSystemOperations.names, "b", "c");
|
assertSetContainsExactly(fileSystemOperations.names, "b", "c");
|
||||||
|
|
||||||
await fileOperations.move("c", "b");
|
await fileOperations.move("c", "b");
|
||||||
assertSetContainsExactly(fileSystemOperations.names, "b", "b (1)");
|
assertSetContainsExactly(fileSystemOperations.names, "b", "b (1)");
|
||||||
|
|
||||||
await fileOperations.create("c", new Uint8Array());
|
await fileOperations.create("c", new Uint8Array());
|
||||||
await fileOperations.move("c", "b");
|
await fileOperations.move("c", "b");
|
||||||
assertSetContainsExactly(
|
assertSetContainsExactly(
|
||||||
fileSystemOperations.names,
|
fileSystemOperations.names,
|
||||||
"b",
|
"b",
|
||||||
"b (1)",
|
"b (1)",
|
||||||
"b (2)"
|
"b (2)"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should deconflict renames with file extension", async () => {
|
it("should deconflict renames with file extension", async () => {
|
||||||
const fileSystemOperations = new FakeFileSystemOperations();
|
const fileSystemOperations = new FakeFileSystemOperations();
|
||||||
const fileOperations = new FileOperations(
|
const fileOperations = new FileOperations(
|
||||||
new Logger(),
|
new Logger(),
|
||||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
fileSystemOperations,
|
fileSystemOperations,
|
||||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
);
|
);
|
||||||
|
|
||||||
await fileOperations.create("b.md", new Uint8Array());
|
await fileOperations.create("b.md", new Uint8Array());
|
||||||
await fileOperations.create("c.md", new Uint8Array());
|
await fileOperations.create("c.md", new Uint8Array());
|
||||||
await fileOperations.move("c.md", "b.md");
|
await fileOperations.move("c.md", "b.md");
|
||||||
assertSetContainsExactly(
|
assertSetContainsExactly(
|
||||||
fileSystemOperations.names,
|
fileSystemOperations.names,
|
||||||
"b.md",
|
"b.md",
|
||||||
"b (1).md"
|
"b (1).md"
|
||||||
);
|
);
|
||||||
|
|
||||||
await fileOperations.create("d.md", new Uint8Array());
|
await fileOperations.create("d.md", new Uint8Array());
|
||||||
await fileOperations.move("d.md", "b.md");
|
await fileOperations.move("d.md", "b.md");
|
||||||
assertSetContainsExactly(
|
assertSetContainsExactly(
|
||||||
fileSystemOperations.names,
|
fileSystemOperations.names,
|
||||||
"b.md",
|
"b.md",
|
||||||
"b (1).md",
|
"b (1).md",
|
||||||
"b (2).md"
|
"b (2).md"
|
||||||
);
|
);
|
||||||
|
|
||||||
await fileOperations.create("file-23.md", new Uint8Array());
|
await fileOperations.create("file-23.md", new Uint8Array());
|
||||||
await fileOperations.create("file-23 (1).md", new Uint8Array());
|
await fileOperations.create("file-23 (1).md", new Uint8Array());
|
||||||
await fileOperations.move("file-23.md", "file-23 (1).md");
|
await fileOperations.move("file-23.md", "file-23 (1).md");
|
||||||
assertSetContainsExactly(
|
assertSetContainsExactly(
|
||||||
fileSystemOperations.names,
|
fileSystemOperations.names,
|
||||||
"b.md",
|
"b.md",
|
||||||
"b (1).md",
|
"b (1).md",
|
||||||
"b (2).md",
|
"b (2).md",
|
||||||
"file-23 (1).md",
|
"file-23 (1).md",
|
||||||
"file-23 (2).md"
|
"file-23 (2).md"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should deconflict renames with paths", async () => {
|
it("should deconflict renames with paths", async () => {
|
||||||
const fileSystemOperations = new FakeFileSystemOperations();
|
const fileSystemOperations = new FakeFileSystemOperations();
|
||||||
const fileOperations = new FileOperations(
|
const fileOperations = new FileOperations(
|
||||||
new Logger(),
|
new Logger(),
|
||||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
fileSystemOperations,
|
fileSystemOperations,
|
||||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
);
|
);
|
||||||
|
|
||||||
await fileOperations.create("a/b.c/d", new Uint8Array());
|
await fileOperations.create("a/b.c/d", new Uint8Array());
|
||||||
await fileOperations.create("a/b.c/e", new Uint8Array());
|
await fileOperations.create("a/b.c/e", new Uint8Array());
|
||||||
await fileOperations.move("a/b.c/d", "a/b.c/e");
|
await fileOperations.move("a/b.c/d", "a/b.c/e");
|
||||||
assertSetContainsExactly(
|
assertSetContainsExactly(
|
||||||
fileSystemOperations.names,
|
fileSystemOperations.names,
|
||||||
"a/b.c/e",
|
"a/b.c/e",
|
||||||
"a/b.c/e (1)"
|
"a/b.c/e (1)"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should continue deconfliction from existing number in filename", async () => {
|
it("should continue deconfliction from existing number in filename", async () => {
|
||||||
const fileSystemOperations = new FakeFileSystemOperations();
|
const fileSystemOperations = new FakeFileSystemOperations();
|
||||||
const fileOperations = new FileOperations(
|
const fileOperations = new FileOperations(
|
||||||
new Logger(),
|
new Logger(),
|
||||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
fileSystemOperations,
|
fileSystemOperations,
|
||||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
);
|
);
|
||||||
|
|
||||||
await fileOperations.create("document (5).md", new Uint8Array());
|
await fileOperations.create("document (5).md", new Uint8Array());
|
||||||
await fileOperations.create("other.md", new Uint8Array());
|
await fileOperations.create("other.md", new Uint8Array());
|
||||||
|
|
||||||
await fileOperations.move("other.md", "document (5).md");
|
await fileOperations.move("other.md", "document (5).md");
|
||||||
assertSetContainsExactly(
|
assertSetContainsExactly(
|
||||||
fileSystemOperations.names,
|
fileSystemOperations.names,
|
||||||
"document (5).md",
|
"document (5).md",
|
||||||
"document (6).md"
|
"document (6).md"
|
||||||
);
|
);
|
||||||
|
|
||||||
await fileOperations.create("another.md", new Uint8Array());
|
await fileOperations.create("another.md", new Uint8Array());
|
||||||
await fileOperations.move("another.md", "document (5).md");
|
await fileOperations.move("another.md", "document (5).md");
|
||||||
assertSetContainsExactly(
|
assertSetContainsExactly(
|
||||||
fileSystemOperations.names,
|
fileSystemOperations.names,
|
||||||
"document (5).md",
|
"document (5).md",
|
||||||
"document (6).md",
|
"document (6).md",
|
||||||
"document (7).md"
|
"document (7).md"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle dotfiles correctly", async () => {
|
it("should handle dotfiles correctly", async () => {
|
||||||
const fileSystemOperations = new FakeFileSystemOperations();
|
const fileSystemOperations = new FakeFileSystemOperations();
|
||||||
const fileOperations = new FileOperations(
|
const fileOperations = new FileOperations(
|
||||||
new Logger(),
|
new Logger(),
|
||||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
fileSystemOperations,
|
fileSystemOperations,
|
||||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
);
|
);
|
||||||
|
|
||||||
await fileOperations.create(".gitignore", new Uint8Array());
|
await fileOperations.create(".gitignore", new Uint8Array());
|
||||||
await fileOperations.create("temp", new Uint8Array());
|
await fileOperations.create("temp", new Uint8Array());
|
||||||
await fileOperations.move("temp", ".gitignore");
|
await fileOperations.move("temp", ".gitignore");
|
||||||
assertSetContainsExactly(
|
assertSetContainsExactly(
|
||||||
fileSystemOperations.names,
|
fileSystemOperations.names,
|
||||||
".gitignore",
|
".gitignore",
|
||||||
".gitignore (1)"
|
".gitignore (1)"
|
||||||
);
|
);
|
||||||
|
|
||||||
await fileOperations.create(".config.json", new Uint8Array());
|
await fileOperations.create(".config.json", new Uint8Array());
|
||||||
await fileOperations.create("temp2", new Uint8Array());
|
await fileOperations.create("temp2", new Uint8Array());
|
||||||
await fileOperations.move("temp2", ".config.json");
|
await fileOperations.move("temp2", ".config.json");
|
||||||
assertSetContainsExactly(
|
assertSetContainsExactly(
|
||||||
fileSystemOperations.names,
|
fileSystemOperations.names,
|
||||||
".gitignore",
|
".gitignore",
|
||||||
".gitignore (1)",
|
".gitignore (1)",
|
||||||
".config.json",
|
".config.json",
|
||||||
".config (1).json"
|
".config (1).json"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,283 +9,283 @@ import { isBinary } from "../utils/is-binary";
|
||||||
import type { ServerConfig } from "../services/server-config";
|
import type { ServerConfig } from "../services/server-config";
|
||||||
|
|
||||||
export class FileOperations {
|
export class FileOperations {
|
||||||
private static readonly PARENTHESES_REGEX = / \((?<count>\d+)\)$/;
|
private static readonly PARENTHESES_REGEX = / \((?<count>\d+)\)$/;
|
||||||
private readonly fs: SafeFileSystemOperations;
|
private readonly fs: SafeFileSystemOperations;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
private readonly database: Database,
|
private readonly database: Database,
|
||||||
fs: FileSystemOperations,
|
fs: FileSystemOperations,
|
||||||
private readonly serverConfig: ServerConfig,
|
private readonly serverConfig: ServerConfig,
|
||||||
private readonly nativeLineEndings = "\n"
|
private readonly nativeLineEndings = "\n"
|
||||||
) {
|
) {
|
||||||
this.fs = new SafeFileSystemOperations(fs, logger);
|
this.fs = new SafeFileSystemOperations(fs, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getParentDirAndFile(
|
private static getParentDirAndFile(
|
||||||
path: RelativePath
|
path: RelativePath
|
||||||
): [RelativePath, RelativePath] {
|
): [RelativePath, RelativePath] {
|
||||||
const pathParts = path.split("/");
|
const pathParts = path.split("/");
|
||||||
const fileName = pathParts.pop();
|
const fileName = pathParts.pop();
|
||||||
if (fileName == null || fileName === "") {
|
if (fileName == null || fileName === "") {
|
||||||
throw new Error(`Path '${path}' cannot be empty`);
|
throw new Error(`Path '${path}' cannot be empty`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [pathParts.join("/"), fileName];
|
return [pathParts.join("/"), fileName];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async listFilesRecursively(
|
public async listFilesRecursively(
|
||||||
root: RelativePath | undefined = undefined
|
root: RelativePath | undefined = undefined
|
||||||
): Promise<RelativePath[]> {
|
): Promise<RelativePath[]> {
|
||||||
return this.fs.listFilesRecursively(root);
|
return this.fs.listFilesRecursively(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async read(path: RelativePath): Promise<Uint8Array> {
|
public async read(path: RelativePath): Promise<Uint8Array> {
|
||||||
return this.fromNativeLineEndings(await this.fs.read(path));
|
return this.fromNativeLineEndings(await this.fs.read(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a file at the specified path.
|
* Create a file at the specified path.
|
||||||
*
|
*
|
||||||
* If a file with the same name already exists, it is moved before creating the new one.
|
* If a file with the same name already exists, it is moved before creating the new one.
|
||||||
* Parent directories are created if necessary.
|
* Parent directories are created if necessary.
|
||||||
*/
|
*/
|
||||||
public async create(
|
public async create(
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
newContent: Uint8Array
|
newContent: Uint8Array
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.ensureClearPath(path);
|
await this.ensureClearPath(path);
|
||||||
return this.fs.write(path, this.toNativeLineEndings(newContent));
|
return this.fs.write(path, this.toNativeLineEndings(newContent));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ensureClearPath(path: RelativePath): Promise<void> {
|
public async ensureClearPath(path: RelativePath): Promise<void> {
|
||||||
if (await this.fs.exists(path)) {
|
if (await this.fs.exists(path)) {
|
||||||
const deconflictedPath = await this.deconflictPath(path);
|
const deconflictedPath = await this.deconflictPath(path);
|
||||||
try {
|
try {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Didn't expect ${path} to exist, deconflicting by moving it to '${deconflictedPath}'`
|
`Didn't expect ${path} to exist, deconflicting by moving it to '${deconflictedPath}'`
|
||||||
);
|
);
|
||||||
|
|
||||||
this.database.move(path, deconflictedPath);
|
this.database.move(path, deconflictedPath);
|
||||||
await this.fs.rename(path, deconflictedPath, true);
|
await this.fs.rename(path, deconflictedPath, true);
|
||||||
} finally {
|
} finally {
|
||||||
this.fs.unlock(deconflictedPath);
|
this.fs.unlock(deconflictedPath);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await this.createParentDirectories(path);
|
await this.createParentDirectories(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the file at the given path.
|
* Update the file at the given path.
|
||||||
*
|
*
|
||||||
* Performs a 3-way merge before writing if the file's content differs from `expectedContent`.
|
* Performs a 3-way merge before writing if the file's content differs from `expectedContent`.
|
||||||
* Does not recreate the file if it no longer exists, returning an empty array instead.
|
* Does not recreate the file if it no longer exists, returning an empty array instead.
|
||||||
*/
|
*/
|
||||||
public async write(
|
public async write(
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
expectedContent: Uint8Array,
|
expectedContent: Uint8Array,
|
||||||
newContent: Uint8Array
|
newContent: Uint8Array
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!(await this.fs.exists(path))) {
|
if (!(await this.fs.exists(path))) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`The caller assumed ${path} exists, but it no longer, so we wont recreate it`
|
`The caller assumed ${path} exists, but it no longer, so we wont recreate it`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!isFileTypeMergable(
|
!isFileTypeMergable(
|
||||||
path,
|
path,
|
||||||
this.serverConfig.getConfig().mergeableFileExtensions
|
this.serverConfig.getConfig().mergeableFileExtensions
|
||||||
) ||
|
) ||
|
||||||
isBinary(expectedContent) ||
|
isBinary(expectedContent) ||
|
||||||
isBinary(newContent)
|
isBinary(newContent)
|
||||||
) {
|
) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it`
|
`The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it`
|
||||||
);
|
);
|
||||||
await this.fs.write(
|
await this.fs.write(
|
||||||
path,
|
path,
|
||||||
// `newContent` might not be binary so we still have to ensure the line endings are correct
|
// `newContent` might not be binary so we still have to ensure the line endings are correct
|
||||||
this.toNativeLineEndings(newContent)
|
this.toNativeLineEndings(newContent)
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectedText = new TextDecoder().decode(expectedContent); // this comes from a previous read which must only have \n line endings
|
const expectedText = new TextDecoder().decode(expectedContent); // this comes from a previous read which must only have \n line endings
|
||||||
const newText = new TextDecoder().decode(newContent); // this comes from the server which stores text with \n line endings
|
const newText = new TextDecoder().decode(newContent); // this comes from the server which stores text with \n line endings
|
||||||
|
|
||||||
await this.fs.atomicUpdateText(
|
await this.fs.atomicUpdateText(
|
||||||
path,
|
path,
|
||||||
({ text, cursors }: TextWithCursors): TextWithCursors => {
|
({ text, cursors }: TextWithCursors): TextWithCursors => {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Performing a 3-way merge for ${path} with the expected content`
|
`Performing a 3-way merge for ${path} with the expected content`
|
||||||
);
|
);
|
||||||
|
|
||||||
text = text.replaceAll(this.nativeLineEndings, "\n");
|
text = text.replaceAll(this.nativeLineEndings, "\n");
|
||||||
const merged = reconcile(
|
const merged = reconcile(
|
||||||
expectedText,
|
expectedText,
|
||||||
{ text, cursors },
|
{ text, cursors },
|
||||||
newText
|
newText
|
||||||
);
|
);
|
||||||
|
|
||||||
const resultText = merged.text.replaceAll(
|
const resultText = merged.text.replaceAll(
|
||||||
"\n",
|
"\n",
|
||||||
this.nativeLineEndings
|
this.nativeLineEndings
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: resultText,
|
text: resultText,
|
||||||
cursors: merged.cursors
|
cursors: merged.cursors
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(path: RelativePath): Promise<void> {
|
public async delete(path: RelativePath): Promise<void> {
|
||||||
if (await this.exists(path)) {
|
if (await this.exists(path)) {
|
||||||
await this.fs.delete(path);
|
await this.fs.delete(path);
|
||||||
await this.deletingEmptyParentDirectoriesOfDeletedFile(path);
|
await this.deletingEmptyParentDirectoriesOfDeletedFile(path);
|
||||||
} else {
|
} else {
|
||||||
this.logger.debug(`No need to delete '${path}', it doesn't exist`);
|
this.logger.debug(`No need to delete '${path}', it doesn't exist`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getFileSize(path: RelativePath): Promise<number> {
|
public async getFileSize(path: RelativePath): Promise<number> {
|
||||||
return this.fs.getFileSize(path);
|
return this.fs.getFileSize(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async exists(path: RelativePath): Promise<boolean> {
|
public async exists(path: RelativePath): Promise<boolean> {
|
||||||
return this.fs.exists(path);
|
return this.fs.exists(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async move(
|
public async move(
|
||||||
oldPath: RelativePath,
|
oldPath: RelativePath,
|
||||||
newPath: RelativePath
|
newPath: RelativePath
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (oldPath === newPath) {
|
if (oldPath === newPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.ensureClearPath(newPath);
|
await this.ensureClearPath(newPath);
|
||||||
|
|
||||||
this.database.move(oldPath, newPath);
|
this.database.move(oldPath, newPath);
|
||||||
await this.fs.rename(oldPath, newPath);
|
await this.fs.rename(oldPath, newPath);
|
||||||
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
|
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this.fs.reset();
|
this.fs.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deletingEmptyParentDirectoriesOfDeletedFile(
|
private async deletingEmptyParentDirectoriesOfDeletedFile(
|
||||||
path: RelativePath
|
path: RelativePath
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let directory = path;
|
let directory = path;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
while (true) {
|
while (true) {
|
||||||
[directory] = FileOperations.getParentDirAndFile(directory);
|
[directory] = FileOperations.getParentDirAndFile(directory);
|
||||||
if (directory.length === 0) {
|
if (directory.length === 0) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const remainingContent =
|
const remainingContent =
|
||||||
await this.fs.listFilesRecursively(directory);
|
await this.fs.listFilesRecursively(directory);
|
||||||
if (remainingContent.length === 0) {
|
if (remainingContent.length === 0) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Folder (${directory}) is now empty, deleting`
|
`Folder (${directory}) is now empty, deleting`
|
||||||
);
|
);
|
||||||
await this.fs.delete(directory);
|
await this.fs.delete(directory);
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fromNativeLineEndings(content: Uint8Array): Uint8Array {
|
private fromNativeLineEndings(content: Uint8Array): Uint8Array {
|
||||||
if (isBinary(content)) {
|
if (isBinary(content)) {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
const decoder = new TextDecoder("utf-8");
|
const decoder = new TextDecoder("utf-8");
|
||||||
let text = decoder.decode(content);
|
let text = decoder.decode(content);
|
||||||
text = text.replaceAll(this.nativeLineEndings, "\n");
|
text = text.replaceAll(this.nativeLineEndings, "\n");
|
||||||
return new TextEncoder().encode(text);
|
return new TextEncoder().encode(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
private toNativeLineEndings(content: Uint8Array): Uint8Array {
|
private toNativeLineEndings(content: Uint8Array): Uint8Array {
|
||||||
if (isBinary(content)) {
|
if (isBinary(content)) {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
const decoder = new TextDecoder("utf-8");
|
const decoder = new TextDecoder("utf-8");
|
||||||
let text = decoder.decode(content);
|
let text = decoder.decode(content);
|
||||||
text = text.replaceAll("\n", this.nativeLineEndings);
|
text = text.replaceAll("\n", this.nativeLineEndings);
|
||||||
return new TextEncoder().encode(text);
|
return new TextEncoder().encode(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createParentDirectories(path: string): Promise<void> {
|
private async createParentDirectories(path: string): Promise<void> {
|
||||||
const components = path.split("/");
|
const components = path.split("/");
|
||||||
if (components.length === 1) {
|
if (components.length === 1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (let i = 1; i < components.length; i++) {
|
for (let i = 1; i < components.length; i++) {
|
||||||
const parentDir = components.slice(0, i).join("/");
|
const parentDir = components.slice(0, i).join("/");
|
||||||
if (!(await this.fs.exists(parentDir))) {
|
if (!(await this.fs.exists(parentDir))) {
|
||||||
await this.fs.createDirectory(parentDir);
|
await this.fs.createDirectory(parentDir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deconflicts the given path by appending (1), (2), etc. before the file extension until a non-existent path is found.
|
* Deconflicts the given path by appending (1), (2), etc. before the file extension until a non-existent path is found.
|
||||||
* The returned path has a lock acquired on it; it must be released by the caller when no longer needed.
|
* The returned path has a lock acquired on it; it must be released by the caller when no longer needed.
|
||||||
*
|
*
|
||||||
* @param path The starting path to deconflict
|
* @param path The starting path to deconflict
|
||||||
* @returns a non-existent path with a lock acquired on it
|
* @returns a non-existent path with a lock acquired on it
|
||||||
*/
|
*/
|
||||||
private async deconflictPath(path: RelativePath): Promise<RelativePath> {
|
private async deconflictPath(path: RelativePath): Promise<RelativePath> {
|
||||||
// eslint-disable-next-line prefer-const
|
// eslint-disable-next-line prefer-const
|
||||||
let [directory, fileName] = FileOperations.getParentDirAndFile(path);
|
let [directory, fileName] = FileOperations.getParentDirAndFile(path);
|
||||||
|
|
||||||
if (directory) {
|
if (directory) {
|
||||||
directory += "/";
|
directory += "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
const nameParts = fileName.split(".");
|
const nameParts = fileName.split(".");
|
||||||
// Handle dotfiles: ".gitignore" should have no extension, ".config.json" should have ".json"
|
// Handle dotfiles: ".gitignore" should have no extension, ".config.json" should have ".json"
|
||||||
const isDotfile = fileName.startsWith(".") && nameParts[0] === "";
|
const isDotfile = fileName.startsWith(".") && nameParts[0] === "";
|
||||||
const extension =
|
const extension =
|
||||||
nameParts.length > 1 && !(isDotfile && nameParts.length === 2)
|
nameParts.length > 1 && !(isDotfile && nameParts.length === 2)
|
||||||
? "." + nameParts[nameParts.length - 1]
|
? "." + nameParts[nameParts.length - 1]
|
||||||
: "";
|
: "";
|
||||||
let stem = extension ? nameParts.slice(0, -1).join(".") : fileName;
|
let stem = extension ? nameParts.slice(0, -1).join(".") : fileName;
|
||||||
let currentCount = Number.parseInt(
|
let currentCount = Number.parseInt(
|
||||||
FileOperations.PARENTHESES_REGEX.exec(stem)?.groups?.count ?? "0"
|
FileOperations.PARENTHESES_REGEX.exec(stem)?.groups?.count ?? "0"
|
||||||
);
|
);
|
||||||
stem = stem.replace(FileOperations.PARENTHESES_REGEX, "");
|
stem = stem.replace(FileOperations.PARENTHESES_REGEX, "");
|
||||||
|
|
||||||
let newName = path;
|
let newName = path;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
while (true) {
|
while (true) {
|
||||||
currentCount++;
|
currentCount++;
|
||||||
newName = `${directory}${stem} (${currentCount})${extension}`;
|
newName = `${directory}${stem} (${currentCount})${extension}`;
|
||||||
|
|
||||||
// Avoid multiple deconflictPath calls returning the same path
|
// Avoid multiple deconflictPath calls returning the same path
|
||||||
if (this.fs.tryLock(newName)) {
|
if (this.fs.tryLock(newName)) {
|
||||||
const newDocument =
|
const newDocument =
|
||||||
this.database.getLatestDocumentByRelativePath(newName);
|
this.database.getLatestDocumentByRelativePath(newName);
|
||||||
if (
|
if (
|
||||||
newDocument?.isDeleted === false || // the document might have been confirmed by the server at a new path but haven't yet moved there locally
|
newDocument?.isDeleted === false || // the document might have been confirmed by the server at a new path but haven't yet moved there locally
|
||||||
(await this.fs.exists(newName, true))
|
(await this.fs.exists(newName, true))
|
||||||
) {
|
) {
|
||||||
this.fs.unlock(newName);
|
this.fs.unlock(newName);
|
||||||
} else {
|
} else {
|
||||||
return newName;
|
return newName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,35 +3,35 @@ import type { RelativePath } from "../persistence/database";
|
||||||
import type { TextWithCursors } from "reconcile-text";
|
import type { TextWithCursors } from "reconcile-text";
|
||||||
|
|
||||||
export interface FileSystemOperations {
|
export interface FileSystemOperations {
|
||||||
// List all files under root that should be synced. If root is undefined, return every file.
|
// List all files under root that should be synced. If root is undefined, return every file.
|
||||||
listFilesRecursively: (
|
listFilesRecursively: (
|
||||||
root: RelativePath | undefined
|
root: RelativePath | undefined
|
||||||
) => Promise<RelativePath[]>;
|
) => Promise<RelativePath[]>;
|
||||||
|
|
||||||
// Read the content of a file.
|
// Read the content of a file.
|
||||||
read: (path: RelativePath) => Promise<Uint8Array>;
|
read: (path: RelativePath) => Promise<Uint8Array>;
|
||||||
|
|
||||||
// Create or overwrite a file with the given content.
|
// Create or overwrite a file with the given content.
|
||||||
write: (path: RelativePath, content: Uint8Array) => Promise<void>;
|
write: (path: RelativePath, content: Uint8Array) => Promise<void>;
|
||||||
|
|
||||||
// Atomically update the content of a text file.
|
// Atomically update the content of a text file.
|
||||||
atomicUpdateText: (
|
atomicUpdateText: (
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
updater: (current: TextWithCursors) => TextWithCursors
|
updater: (current: TextWithCursors) => TextWithCursors
|
||||||
) => Promise<string>;
|
) => Promise<string>;
|
||||||
|
|
||||||
// Get the size of a file in bytes.
|
// Get the size of a file in bytes.
|
||||||
getFileSize: (path: RelativePath) => Promise<number>;
|
getFileSize: (path: RelativePath) => Promise<number>;
|
||||||
|
|
||||||
// Check if a file exists.
|
// Check if a file exists.
|
||||||
exists: (path: RelativePath) => Promise<boolean>;
|
exists: (path: RelativePath) => Promise<boolean>;
|
||||||
|
|
||||||
// Create a directory at the specified path. All parent directories must already exist.
|
// Create a directory at the specified path. All parent directories must already exist.
|
||||||
createDirectory: (path: RelativePath) => Promise<void>;
|
createDirectory: (path: RelativePath) => Promise<void>;
|
||||||
|
|
||||||
// Delete a file. It is expected that the path points to an existing file.
|
// Delete a file. It is expected that the path points to an existing file.
|
||||||
delete: (path: RelativePath) => Promise<void>;
|
delete: (path: RelativePath) => Promise<void>;
|
||||||
|
|
||||||
// Rename a file. It is expected that the oldPath points to an existing file and the newPath does not exist.
|
// Rename a file. It is expected that the oldPath points to an existing file and the newPath does not exist.
|
||||||
rename: (oldPath: RelativePath, newPath: RelativePath) => Promise<void>;
|
rename: (oldPath: RelativePath, newPath: RelativePath) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,160 +11,160 @@ import type { TextWithCursors } from "reconcile-text";
|
||||||
* single request in-flight for any one file through the use of locks.
|
* single request in-flight for any one file through the use of locks.
|
||||||
*/
|
*/
|
||||||
export class SafeFileSystemOperations implements FileSystemOperations {
|
export class SafeFileSystemOperations implements FileSystemOperations {
|
||||||
private readonly locks: Locks<RelativePath>;
|
private readonly locks: Locks<RelativePath>;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly fs: FileSystemOperations,
|
private readonly fs: FileSystemOperations,
|
||||||
private readonly logger: Logger
|
private readonly logger: Logger
|
||||||
) {
|
) {
|
||||||
this.locks = new Locks(logger);
|
this.locks = new Locks(logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async listFilesRecursively(
|
public async listFilesRecursively(
|
||||||
root: RelativePath | undefined
|
root: RelativePath | undefined
|
||||||
): Promise<RelativePath[]> {
|
): Promise<RelativePath[]> {
|
||||||
this.logger.debug("Listing all files");
|
this.logger.debug("Listing all files");
|
||||||
const result = await this.fs.listFilesRecursively(root);
|
const result = await this.fs.listFilesRecursively(root);
|
||||||
this.logger.debug(`Listed ${result.length} files`);
|
this.logger.debug(`Listed ${result.length} files`);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async read(path: RelativePath): Promise<Uint8Array> {
|
public async read(path: RelativePath): Promise<Uint8Array> {
|
||||||
this.logger.debug(`Reading file '${path}'`);
|
this.logger.debug(`Reading file '${path}'`);
|
||||||
return this.safeOperation(
|
return this.safeOperation(
|
||||||
path,
|
path,
|
||||||
async () =>
|
async () =>
|
||||||
this.locks.withLock(path, async () => this.fs.read(path)),
|
this.locks.withLock(path, async () => this.fs.read(path)),
|
||||||
"read"
|
"read"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
|
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
|
||||||
this.logger.debug(`Writing to file '${path}'`);
|
this.logger.debug(`Writing to file '${path}'`);
|
||||||
return this.locks.withLock(path, async () =>
|
return this.locks.withLock(path, async () =>
|
||||||
this.fs.write(path, content)
|
this.fs.write(path, content)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async atomicUpdateText(
|
public async atomicUpdateText(
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
updater: (current: TextWithCursors) => TextWithCursors
|
updater: (current: TextWithCursors) => TextWithCursors
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
this.logger.debug(`Atomically updating file '${path}'`);
|
this.logger.debug(`Atomically updating file '${path}'`);
|
||||||
return this.safeOperation(
|
return this.safeOperation(
|
||||||
path,
|
path,
|
||||||
async () =>
|
async () =>
|
||||||
this.locks.withLock(path, async () =>
|
this.locks.withLock(path, async () =>
|
||||||
this.fs.atomicUpdateText(path, updater)
|
this.fs.atomicUpdateText(path, updater)
|
||||||
),
|
),
|
||||||
"atomicUpdateText"
|
"atomicUpdateText"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getFileSize(path: RelativePath): Promise<number> {
|
public async getFileSize(path: RelativePath): Promise<number> {
|
||||||
// Logging this would be too noisy
|
// Logging this would be too noisy
|
||||||
return this.safeOperation(
|
return this.safeOperation(
|
||||||
path,
|
path,
|
||||||
async () =>
|
async () =>
|
||||||
this.locks.withLock(path, async () =>
|
this.locks.withLock(path, async () =>
|
||||||
this.fs.getFileSize(path)
|
this.fs.getFileSize(path)
|
||||||
),
|
),
|
||||||
"getFileSize"
|
"getFileSize"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async exists(
|
public async exists(
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
skipLock = false
|
skipLock = false
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
this.logger.debug(`Checking if file '${path}' exists`);
|
this.logger.debug(`Checking if file '${path}' exists`);
|
||||||
if (skipLock) {
|
if (skipLock) {
|
||||||
return this.fs.exists(path);
|
return this.fs.exists(path);
|
||||||
} else {
|
} else {
|
||||||
return this.locks.withLock(path, async () => this.fs.exists(path));
|
return this.locks.withLock(path, async () => this.fs.exists(path));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createDirectory(path: RelativePath): Promise<void> {
|
public async createDirectory(path: RelativePath): Promise<void> {
|
||||||
this.logger.debug(`Creating directory '${path}'`);
|
this.logger.debug(`Creating directory '${path}'`);
|
||||||
return this.locks.withLock(path, async () =>
|
return this.locks.withLock(path, async () =>
|
||||||
this.fs.createDirectory(path)
|
this.fs.createDirectory(path)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(path: RelativePath): Promise<void> {
|
public async delete(path: RelativePath): Promise<void> {
|
||||||
this.logger.debug(`Deleting file '${path}'`);
|
this.logger.debug(`Deleting file '${path}'`);
|
||||||
return this.locks.withLock(path, async () => this.fs.delete(path));
|
return this.locks.withLock(path, async () => this.fs.delete(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async rename(
|
public async rename(
|
||||||
oldPath: RelativePath,
|
oldPath: RelativePath,
|
||||||
newPath: RelativePath,
|
newPath: RelativePath,
|
||||||
skipLock = false
|
skipLock = false
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.debug(`Renaming file '${oldPath}' to '${newPath}'`);
|
this.logger.debug(`Renaming file '${oldPath}' to '${newPath}'`);
|
||||||
return this.safeOperation(
|
return this.safeOperation(
|
||||||
oldPath,
|
oldPath,
|
||||||
async () => {
|
async () => {
|
||||||
if (skipLock) {
|
if (skipLock) {
|
||||||
return this.fs.rename(oldPath, newPath);
|
return this.fs.rename(oldPath, newPath);
|
||||||
} else {
|
} else {
|
||||||
return this.locks.withLock([oldPath, newPath], async () =>
|
return this.locks.withLock([oldPath, newPath], async () =>
|
||||||
this.fs.rename(oldPath, newPath)
|
this.fs.rename(oldPath, newPath)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rename"
|
"rename"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public tryLock(path: RelativePath): boolean {
|
public tryLock(path: RelativePath): boolean {
|
||||||
return this.locks.tryLock(path);
|
return this.locks.tryLock(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async waitForLock(path: RelativePath): Promise<void> {
|
public async waitForLock(path: RelativePath): Promise<void> {
|
||||||
return this.locks.waitForLock(path);
|
return this.locks.waitForLock(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
public unlock(path: RelativePath): void {
|
public unlock(path: RelativePath): void {
|
||||||
this.locks.unlock(path);
|
this.locks.unlock(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this.locks.reset();
|
this.locks.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decorate an operation to ensure that the file exists before running it.
|
* Decorate an operation to ensure that the file exists before running it.
|
||||||
* If the operation fails, it will check if the file still exists and throw
|
* If the operation fails, it will check if the file still exists and throw
|
||||||
* a FileNotFoundError if it doesn't.
|
* a FileNotFoundError if it doesn't.
|
||||||
*/
|
*/
|
||||||
private async safeOperation<T>(
|
private async safeOperation<T>(
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
operation: () => Promise<T>,
|
operation: () => Promise<T>,
|
||||||
operationName: string
|
operationName: string
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
if (!(await this.fs.exists(path))) {
|
if (!(await this.fs.exists(path))) {
|
||||||
throw new FileNotFoundError(
|
throw new FileNotFoundError(
|
||||||
`File not found before trying to ${operationName}`,
|
`File not found before trying to ${operationName}`,
|
||||||
path
|
path
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await operation();
|
return await operation();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Without locking the file, this isn't atomic, however, it's good enough in practice.
|
// Without locking the file, this isn't atomic, however, it's good enough in practice.
|
||||||
// This will only break if the file exists, gets deleted and then immediately
|
// This will only break if the file exists, gets deleted and then immediately
|
||||||
// recreated while `operation` is running.
|
// recreated while `operation` is running.
|
||||||
if (await this.fs.exists(path)) {
|
if (await this.fs.exists(path)) {
|
||||||
throw error;
|
throw error;
|
||||||
} else {
|
} else {
|
||||||
throw new FileNotFoundError(
|
throw new FileNotFoundError(
|
||||||
`File not found when trying to ${operationName}`,
|
`File not found when trying to ${operationName}`,
|
||||||
path
|
path
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,15 @@ import { positionToLineAndColumn } from "./utils/position-to-line-and-column";
|
||||||
import { removeFromArray } from "./utils/remove-from-array";
|
import { removeFromArray } from "./utils/remove-from-array";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
SyncType,
|
SyncType,
|
||||||
SyncStatus,
|
SyncStatus,
|
||||||
type HistoryStats,
|
type HistoryStats,
|
||||||
type HistoryEntry,
|
type HistoryEntry,
|
||||||
type SyncDetails,
|
type SyncDetails,
|
||||||
type SyncCreateDetails,
|
type SyncCreateDetails,
|
||||||
type SyncUpdateDetails,
|
type SyncUpdateDetails,
|
||||||
type SyncMovedDetails,
|
type SyncMovedDetails,
|
||||||
type SyncDeleteDetails
|
type SyncDeleteDetails
|
||||||
} from "./tracing/sync-history";
|
} from "./tracing/sync-history";
|
||||||
export { Logger, LogLevel, LogLine } from "./tracing/logger";
|
export { Logger, LogLevel, LogLine } from "./tracing/logger";
|
||||||
export { type SyncSettings, DEFAULT_SETTINGS } from "./persistence/settings";
|
export { type SyncSettings, DEFAULT_SETTINGS } from "./persistence/settings";
|
||||||
|
|
@ -35,15 +35,15 @@ export { SyncClient } from "./sync-client";
|
||||||
export type { TextWithCursors, CursorPosition } from "reconcile-text";
|
export type { TextWithCursors, CursorPosition } from "reconcile-text";
|
||||||
|
|
||||||
export const debugging = {
|
export const debugging = {
|
||||||
slowFetchFactory,
|
slowFetchFactory,
|
||||||
slowWebSocketFactory,
|
slowWebSocketFactory,
|
||||||
logToConsole
|
logToConsole
|
||||||
};
|
};
|
||||||
|
|
||||||
export const utils = {
|
export const utils = {
|
||||||
getRandomColor,
|
getRandomColor,
|
||||||
positionToLineAndColumn,
|
positionToLineAndColumn,
|
||||||
lineAndColumnToPosition,
|
lineAndColumnToPosition,
|
||||||
awaitAll,
|
awaitAll,
|
||||||
removeFromArray
|
removeFromArray
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,23 +9,23 @@ export type DocumentId = string;
|
||||||
export type RelativePath = string;
|
export type RelativePath = string;
|
||||||
|
|
||||||
export interface DocumentMetadata {
|
export interface DocumentMetadata {
|
||||||
parentVersionId: VaultUpdateId;
|
parentVersionId: VaultUpdateId;
|
||||||
hash: string;
|
hash: string;
|
||||||
remoteRelativePath?: RelativePath;
|
remoteRelativePath?: RelativePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoredDocumentMetadata {
|
export interface StoredDocumentMetadata {
|
||||||
relativePath: RelativePath;
|
relativePath: RelativePath;
|
||||||
documentId: DocumentId;
|
documentId: DocumentId;
|
||||||
parentVersionId: VaultUpdateId;
|
parentVersionId: VaultUpdateId;
|
||||||
remoteRelativePath?: RelativePath;
|
remoteRelativePath?: RelativePath;
|
||||||
hash: string;
|
hash: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoredDatabase {
|
export interface StoredDatabase {
|
||||||
documents: StoredDocumentMetadata[];
|
documents: StoredDocumentMetadata[];
|
||||||
lastSeenUpdateId: VaultUpdateId | undefined;
|
lastSeenUpdateId: VaultUpdateId | undefined;
|
||||||
hasInitialSyncCompleted: boolean;
|
hasInitialSyncCompleted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -35,340 +35,340 @@ export interface StoredDatabase {
|
||||||
* state of the document on disk based on the update events we have seen.
|
* state of the document on disk based on the update events we have seen.
|
||||||
*/
|
*/
|
||||||
export interface DocumentRecord {
|
export interface DocumentRecord {
|
||||||
relativePath: RelativePath;
|
relativePath: RelativePath;
|
||||||
documentId: DocumentId;
|
documentId: DocumentId;
|
||||||
metadata: DocumentMetadata | undefined;
|
metadata: DocumentMetadata | undefined;
|
||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
updates: Promise<unknown>[];
|
updates: Promise<unknown>[];
|
||||||
parallelVersion: number;
|
parallelVersion: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Database {
|
export class Database {
|
||||||
private documents: DocumentRecord[];
|
private documents: DocumentRecord[];
|
||||||
private lastSeenUpdateIds: CoveredValues;
|
private lastSeenUpdateIds: CoveredValues;
|
||||||
private hasInitialSyncCompleted: boolean;
|
private hasInitialSyncCompleted: boolean;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
initialState: Partial<StoredDatabase> | undefined,
|
initialState: Partial<StoredDatabase> | undefined,
|
||||||
private readonly saveData: (data: StoredDatabase) => Promise<void>
|
private readonly saveData: (data: StoredDatabase) => Promise<void>
|
||||||
) {
|
) {
|
||||||
initialState ??= {};
|
initialState ??= {};
|
||||||
|
|
||||||
this.documents =
|
this.documents =
|
||||||
initialState.documents?.map(
|
initialState.documents?.map(
|
||||||
({ relativePath, documentId, ...metadata }) => ({
|
({ relativePath, documentId, ...metadata }) => ({
|
||||||
relativePath,
|
relativePath,
|
||||||
documentId,
|
documentId,
|
||||||
metadata,
|
metadata,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
updates: [],
|
updates: [],
|
||||||
parallelVersion: 0
|
parallelVersion: 0
|
||||||
})
|
})
|
||||||
) ?? [];
|
) ?? [];
|
||||||
|
|
||||||
this.ensureConsistency();
|
this.ensureConsistency();
|
||||||
this.logger.debug(`Loaded ${this.documents.length} documents`);
|
this.logger.debug(`Loaded ${this.documents.length} documents`);
|
||||||
|
|
||||||
const { lastSeenUpdateId } = initialState;
|
const { lastSeenUpdateId } = initialState;
|
||||||
this.logger.debug(`Loaded last seen update id: ${lastSeenUpdateId}`);
|
this.logger.debug(`Loaded last seen update id: ${lastSeenUpdateId}`);
|
||||||
this.lastSeenUpdateIds = new CoveredValues(
|
this.lastSeenUpdateIds = new CoveredValues(
|
||||||
Math.max(0, lastSeenUpdateId ?? 0) // the first updateId will be 1 which is the first integer after -1
|
Math.max(0, lastSeenUpdateId ?? 0) // the first updateId will be 1 which is the first integer after -1
|
||||||
);
|
);
|
||||||
|
|
||||||
this.documents.forEach((doc) => {
|
this.documents.forEach((doc) => {
|
||||||
this.lastSeenUpdateIds.add(doc.metadata?.parentVersionId);
|
this.lastSeenUpdateIds.add(doc.metadata?.parentVersionId);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.hasInitialSyncCompleted =
|
this.hasInitialSyncCompleted =
|
||||||
initialState.hasInitialSyncCompleted ?? false;
|
initialState.hasInitialSyncCompleted ?? false;
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Loaded hasInitialSyncCompleted: ${this.hasInitialSyncCompleted}`
|
`Loaded hasInitialSyncCompleted: ${this.hasInitialSyncCompleted}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get length(): number {
|
public get length(): number {
|
||||||
return this.documents.length;
|
return this.documents.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get resolvedDocuments(): DocumentRecord[] {
|
public get resolvedDocuments(): DocumentRecord[] {
|
||||||
const paths = new Map<string, DocumentRecord[]>();
|
const paths = new Map<string, DocumentRecord[]>();
|
||||||
this.documents
|
this.documents
|
||||||
// eslint-disable-next-line no-restricted-syntax -- Type narrowing, not removing a specific item
|
// eslint-disable-next-line no-restricted-syntax -- Type narrowing, not removing a specific item
|
||||||
.filter(({ metadata }) => metadata !== undefined)
|
.filter(({ metadata }) => metadata !== undefined)
|
||||||
.forEach((record) =>
|
.forEach((record) =>
|
||||||
paths.set(record.relativePath, [
|
paths.set(record.relativePath, [
|
||||||
record,
|
record,
|
||||||
...(paths.get(record.relativePath) ?? [])
|
...(paths.get(record.relativePath) ?? [])
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
|
||||||
return Array.from(paths.values()).map((records) => {
|
return Array.from(paths.values()).map((records) => {
|
||||||
records.sort(
|
records.sort(
|
||||||
(a, b) => b.parallelVersion - a.parallelVersion // descending
|
(a, b) => b.parallelVersion - a.parallelVersion // descending
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
records.length > 1 &&
|
records.length > 1 &&
|
||||||
records.some((current, i) =>
|
records.some((current, i) =>
|
||||||
i === 0
|
i === 0
|
||||||
? false
|
? false
|
||||||
: records[i - 1].parallelVersion ===
|
: records[i - 1].parallelVersion ===
|
||||||
current.parallelVersion
|
current.parallelVersion
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Multiple documents with the same parallel version and path at ${records[0].relativePath}`
|
`Multiple documents with the same parallel version and path at ${records[0].relativePath}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return records[0];
|
return records[0];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public updateDocumentMetadata(
|
public updateDocumentMetadata(
|
||||||
metadata: {
|
metadata: {
|
||||||
parentVersionId: VaultUpdateId;
|
parentVersionId: VaultUpdateId;
|
||||||
hash: string;
|
hash: string;
|
||||||
remoteRelativePath: RelativePath;
|
remoteRelativePath: RelativePath;
|
||||||
},
|
},
|
||||||
toUpdate: DocumentRecord
|
toUpdate: DocumentRecord
|
||||||
): void {
|
): void {
|
||||||
if (!this.documents.includes(toUpdate)) {
|
if (!this.documents.includes(toUpdate)) {
|
||||||
throw new Error("Document not found in database");
|
throw new Error("Document not found in database");
|
||||||
}
|
}
|
||||||
|
|
||||||
toUpdate.metadata = metadata;
|
toUpdate.metadata = metadata;
|
||||||
|
|
||||||
this.saveInTheBackground();
|
this.saveInTheBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeDocumentPromise(promise: Promise<unknown>): void {
|
public removeDocumentPromise(promise: Promise<unknown>): void {
|
||||||
const entry = this.documents.find(({ updates }) =>
|
const entry = this.documents.find(({ updates }) =>
|
||||||
updates.includes(promise)
|
updates.includes(promise)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (entry === undefined) {
|
if (entry === undefined) {
|
||||||
// This method should be idempotent and tolerant of
|
// This method should be idempotent and tolerant of
|
||||||
// stragglers calling it after the databse has been reset.
|
// stragglers calling it after the databse has been reset.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
removeFromArray(entry.updates, promise);
|
removeFromArray(entry.updates, promise);
|
||||||
// No need to save as Promises don't get serialized
|
// No need to save as Promises don't get serialized
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeDocument(find: DocumentRecord): void {
|
public removeDocument(find: DocumentRecord): void {
|
||||||
removeFromArray(this.documents, find);
|
removeFromArray(this.documents, find);
|
||||||
this.saveInTheBackground();
|
this.saveInTheBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
public getLatestDocumentByRelativePath(
|
public getLatestDocumentByRelativePath(
|
||||||
find: RelativePath
|
find: RelativePath
|
||||||
): DocumentRecord | undefined {
|
): DocumentRecord | undefined {
|
||||||
const candidates = this.documents.filter(
|
const candidates = this.documents.filter(
|
||||||
({ relativePath }) => relativePath === find
|
({ relativePath }) => relativePath === find
|
||||||
);
|
);
|
||||||
candidates.sort((a, b) => b.parallelVersion - a.parallelVersion); // descending
|
candidates.sort((a, b) => b.parallelVersion - a.parallelVersion); // descending
|
||||||
return candidates[0];
|
return candidates[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getResolvedDocumentByRelativePath(
|
public async getResolvedDocumentByRelativePath(
|
||||||
relativePath: RelativePath,
|
relativePath: RelativePath,
|
||||||
promise: Promise<unknown>
|
promise: Promise<unknown>
|
||||||
): Promise<DocumentRecord> {
|
): Promise<DocumentRecord> {
|
||||||
const entry = this.getLatestDocumentByRelativePath(relativePath);
|
const entry = this.getLatestDocumentByRelativePath(relativePath);
|
||||||
|
|
||||||
if (entry === undefined) {
|
if (entry === undefined) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Document not found by relative path: ${relativePath}, ${JSON.stringify(
|
`Document not found by relative path: ${relativePath}, ${JSON.stringify(
|
||||||
this.documents,
|
this.documents,
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentPromises = entry.updates;
|
const currentPromises = entry.updates;
|
||||||
entry.updates = [...currentPromises, promise];
|
entry.updates = [...currentPromises, promise];
|
||||||
await awaitAll(currentPromises);
|
await awaitAll(currentPromises);
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
public createNewPendingDocument(
|
public createNewPendingDocument(
|
||||||
documentId: DocumentId,
|
documentId: DocumentId,
|
||||||
relativePath: RelativePath,
|
relativePath: RelativePath,
|
||||||
promise: Promise<unknown>
|
promise: Promise<unknown>
|
||||||
): DocumentRecord {
|
): DocumentRecord {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Creating new pending document: ${relativePath} (${documentId})`
|
`Creating new pending document: ${relativePath} (${documentId})`
|
||||||
);
|
);
|
||||||
const previousEntry =
|
const previousEntry =
|
||||||
this.getLatestDocumentByRelativePath(relativePath);
|
this.getLatestDocumentByRelativePath(relativePath);
|
||||||
|
|
||||||
const entry = {
|
const entry = {
|
||||||
relativePath,
|
relativePath,
|
||||||
documentId,
|
documentId,
|
||||||
metadata: undefined,
|
metadata: undefined,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
updates: [promise],
|
updates: [promise],
|
||||||
parallelVersion:
|
parallelVersion:
|
||||||
previousEntry?.parallelVersion === undefined
|
previousEntry?.parallelVersion === undefined
|
||||||
? 0
|
? 0
|
||||||
: previousEntry.parallelVersion + 1
|
: previousEntry.parallelVersion + 1
|
||||||
};
|
};
|
||||||
|
|
||||||
this.documents.push(entry);
|
this.documents.push(entry);
|
||||||
this.saveInTheBackground();
|
this.saveInTheBackground();
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
public createNewEmptyDocument(
|
public createNewEmptyDocument(
|
||||||
documentId: DocumentId,
|
documentId: DocumentId,
|
||||||
parentVersionId: VaultUpdateId,
|
parentVersionId: VaultUpdateId,
|
||||||
relativePath: RelativePath
|
relativePath: RelativePath
|
||||||
): DocumentRecord {
|
): DocumentRecord {
|
||||||
const entry = {
|
const entry = {
|
||||||
relativePath,
|
relativePath,
|
||||||
documentId,
|
documentId,
|
||||||
metadata: {
|
metadata: {
|
||||||
parentVersionId,
|
parentVersionId,
|
||||||
hash: EMPTY_HASH,
|
hash: EMPTY_HASH,
|
||||||
remoteRelativePath: relativePath
|
remoteRelativePath: relativePath
|
||||||
},
|
},
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
updates: [],
|
updates: [],
|
||||||
parallelVersion: 0
|
parallelVersion: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
this.documents.push(entry);
|
this.documents.push(entry);
|
||||||
this.saveInTheBackground();
|
this.saveInTheBackground();
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getDocumentByDocumentId(
|
public getDocumentByDocumentId(
|
||||||
find: DocumentId
|
find: DocumentId
|
||||||
): DocumentRecord | undefined {
|
): DocumentRecord | undefined {
|
||||||
return this.documents.find(({ documentId }) => documentId === find);
|
return this.documents.find(({ documentId }) => documentId === find);
|
||||||
}
|
}
|
||||||
|
|
||||||
public move(
|
public move(
|
||||||
oldRelativePath: RelativePath,
|
oldRelativePath: RelativePath,
|
||||||
newRelativePath: RelativePath
|
newRelativePath: RelativePath
|
||||||
): void {
|
): void {
|
||||||
const oldDocument =
|
const oldDocument =
|
||||||
this.getLatestDocumentByRelativePath(oldRelativePath);
|
this.getLatestDocumentByRelativePath(oldRelativePath);
|
||||||
|
|
||||||
if (oldDocument === undefined) {
|
if (oldDocument === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newDocument =
|
const newDocument =
|
||||||
this.getLatestDocumentByRelativePath(newRelativePath);
|
this.getLatestDocumentByRelativePath(newRelativePath);
|
||||||
if (newDocument?.isDeleted === false) {
|
if (newDocument?.isDeleted === false) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Document already exists at new location: ${newRelativePath}`
|
`Document already exists at new location: ${newRelativePath}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
oldDocument.relativePath = newRelativePath;
|
oldDocument.relativePath = newRelativePath;
|
||||||
// We're in a strange state where the target of the move has just got deleted,
|
// We're in a strange state where the target of the move has just got deleted,
|
||||||
// however, its metadata might already have a bunch of updates queued up for
|
// however, its metadata might already have a bunch of updates queued up for
|
||||||
// the document at the new location. We need to keep these updates.
|
// the document at the new location. We need to keep these updates.
|
||||||
oldDocument.parallelVersion =
|
oldDocument.parallelVersion =
|
||||||
newDocument !== undefined ? newDocument.parallelVersion + 1 : 0;
|
newDocument !== undefined ? newDocument.parallelVersion + 1 : 0;
|
||||||
|
|
||||||
this.saveInTheBackground();
|
this.saveInTheBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
public delete(relativePath: RelativePath): void {
|
public delete(relativePath: RelativePath): void {
|
||||||
const candidate = this.getLatestDocumentByRelativePath(relativePath);
|
const candidate = this.getLatestDocumentByRelativePath(relativePath);
|
||||||
if (candidate === undefined) {
|
if (candidate === undefined) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Document not found by relative path: ${relativePath}`
|
`Document not found by relative path: ${relativePath}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
candidate.isDeleted = true;
|
candidate.isDeleted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getHasInitialSyncCompleted(): boolean {
|
public getHasInitialSyncCompleted(): boolean {
|
||||||
return this.hasInitialSyncCompleted;
|
return this.hasInitialSyncCompleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
public setHasInitialSyncCompleted(value: boolean): void {
|
public setHasInitialSyncCompleted(value: boolean): void {
|
||||||
this.hasInitialSyncCompleted = value;
|
this.hasInitialSyncCompleted = value;
|
||||||
this.saveInTheBackground();
|
this.saveInTheBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
public getLastSeenUpdateId(): VaultUpdateId {
|
public getLastSeenUpdateId(): VaultUpdateId {
|
||||||
return this.lastSeenUpdateIds.min;
|
return this.lastSeenUpdateIds.min;
|
||||||
}
|
}
|
||||||
|
|
||||||
public addSeenUpdateId(value: number): void {
|
public addSeenUpdateId(value: number): void {
|
||||||
const previousMin = this.lastSeenUpdateIds.min;
|
const previousMin = this.lastSeenUpdateIds.min;
|
||||||
this.lastSeenUpdateIds.add(value);
|
this.lastSeenUpdateIds.add(value);
|
||||||
if (previousMin !== this.lastSeenUpdateIds.min) {
|
if (previousMin !== this.lastSeenUpdateIds.min) {
|
||||||
this.saveInTheBackground();
|
this.saveInTheBackground();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public setLastSeenUpdateId(value: number): void {
|
public setLastSeenUpdateId(value: number): void {
|
||||||
this.lastSeenUpdateIds.min = value;
|
this.lastSeenUpdateIds.min = value;
|
||||||
this.saveInTheBackground();
|
this.saveInTheBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this.documents = [];
|
this.documents = [];
|
||||||
this.lastSeenUpdateIds = new CoveredValues(
|
this.lastSeenUpdateIds = new CoveredValues(
|
||||||
0 // the first updateId will be 1 which is the first integer after -1
|
0 // the first updateId will be 1 which is the first integer after -1
|
||||||
);
|
);
|
||||||
this.hasInitialSyncCompleted = false;
|
this.hasInitialSyncCompleted = false;
|
||||||
this.saveInTheBackground();
|
this.saveInTheBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async save(): Promise<void> {
|
public async save(): Promise<void> {
|
||||||
return this.saveData({
|
return this.saveData({
|
||||||
documents: this.resolvedDocuments.map(
|
documents: this.resolvedDocuments.map(
|
||||||
({ relativePath, documentId, metadata }) => ({
|
({ relativePath, documentId, metadata }) => ({
|
||||||
documentId,
|
documentId,
|
||||||
relativePath,
|
relativePath,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
...metadata! // `resolvedDocuments` only returns docs with metadata set
|
...metadata! // `resolvedDocuments` only returns docs with metadata set
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
lastSeenUpdateId: this.lastSeenUpdateIds.min,
|
lastSeenUpdateId: this.lastSeenUpdateIds.min,
|
||||||
hasInitialSyncCompleted: this.hasInitialSyncCompleted
|
hasInitialSyncCompleted: this.hasInitialSyncCompleted
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureConsistency(): void {
|
private ensureConsistency(): void {
|
||||||
const idToPath = new Map<string, string[]>();
|
const idToPath = new Map<string, string[]>();
|
||||||
|
|
||||||
this.resolvedDocuments.forEach(({ relativePath, documentId }) => {
|
this.resolvedDocuments.forEach(({ relativePath, documentId }) => {
|
||||||
idToPath.set(documentId, [
|
idToPath.set(documentId, [
|
||||||
...(idToPath.get(documentId) ?? []),
|
...(idToPath.get(documentId) ?? []),
|
||||||
relativePath
|
relativePath
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const duplicates = Array.from(idToPath.entries())
|
const duplicates = Array.from(idToPath.entries())
|
||||||
.filter(([_, paths]) => paths.length > 1)
|
.filter(([_, paths]) => paths.length > 1)
|
||||||
.map(([id, paths]) => `${id} (${paths.join(", ")})`);
|
.map(([id, paths]) => `${id} (${paths.join(", ")})`);
|
||||||
|
|
||||||
if (duplicates.length > 0) {
|
if (duplicates.length > 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Document IDs are not unique, found duplicates: " +
|
"Document IDs are not unique, found duplicates: " +
|
||||||
duplicates.join("; ")
|
duplicates.join("; ")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private saveInTheBackground(): void {
|
private saveInTheBackground(): void {
|
||||||
this.ensureConsistency();
|
this.ensureConsistency();
|
||||||
void this.save().catch((error: unknown) => {
|
void this.save().catch((error: unknown) => {
|
||||||
this.logger.error(`Error saving data: ${error}`);
|
this.logger.error(`Error saving data: ${error}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export interface PersistenceProvider<T> {
|
export interface PersistenceProvider<T> {
|
||||||
load: () => Promise<T | undefined>;
|
load: () => Promise<T | undefined>;
|
||||||
save: (data: T) => Promise<void>;
|
save: (data: T) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export class AuthenticationError extends Error {
|
export class AuthenticationError extends Error {
|
||||||
public constructor(message: string) {
|
public constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "AuthenticationError";
|
this.name = "AuthenticationError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,171 +7,171 @@ import { SyncResetError } from "./sync-reset-error";
|
||||||
import { sleep } from "../utils/sleep";
|
import { sleep } from "../utils/sleep";
|
||||||
|
|
||||||
describe("FetchController", () => {
|
describe("FetchController", () => {
|
||||||
const createMockFetch = (
|
const createMockFetch = (
|
||||||
shouldSleep: boolean
|
shouldSleep: boolean
|
||||||
): Mock<() => Promise<Response>> =>
|
): Mock<() => Promise<Response>> =>
|
||||||
mock.fn(async () => {
|
mock.fn(async () => {
|
||||||
if (shouldSleep) {
|
if (shouldSleep) {
|
||||||
await sleep(30);
|
await sleep(30);
|
||||||
}
|
}
|
||||||
return Promise.resolve(new Response("OK", { status: 200 }));
|
return Promise.resolve(new Response("OK", { status: 200 }));
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mock.timers.enable({ apis: ["setTimeout"] });
|
mock.timers.enable({ apis: ["setTimeout"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mock.timers.reset();
|
mock.timers.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should allow fetch when canFetch is true", async () => {
|
it("should allow fetch when canFetch is true", async () => {
|
||||||
const logger = new Logger();
|
const logger = new Logger();
|
||||||
const controller = new FetchController(true, logger);
|
const controller = new FetchController(true, logger);
|
||||||
const mockFetch = createMockFetch(false);
|
const mockFetch = createMockFetch(false);
|
||||||
const controlledFetch = controller.getControlledFetchImplementation(
|
const controlledFetch = controller.getControlledFetchImplementation(
|
||||||
logger,
|
logger,
|
||||||
mockFetch
|
mockFetch
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await controlledFetch("http://example.com");
|
const response = await controlledFetch("http://example.com");
|
||||||
|
|
||||||
assert.strictEqual(await response.text(), "OK");
|
assert.strictEqual(await response.text(), "OK");
|
||||||
assert.strictEqual(mockFetch.mock.calls.length, 1);
|
assert.strictEqual(mockFetch.mock.calls.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should block fetch until canFetch becomes true", async () => {
|
it("should block fetch until canFetch becomes true", async () => {
|
||||||
const logger = new Logger();
|
const logger = new Logger();
|
||||||
const controller = new FetchController(false, logger);
|
const controller = new FetchController(false, logger);
|
||||||
const mockFetch = createMockFetch(true);
|
const mockFetch = createMockFetch(true);
|
||||||
const controlledFetch = controller.getControlledFetchImplementation(
|
const controlledFetch = controller.getControlledFetchImplementation(
|
||||||
logger,
|
logger,
|
||||||
mockFetch
|
mockFetch
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchPromise = controlledFetch("http://example.com");
|
const fetchPromise = controlledFetch("http://example.com");
|
||||||
assert.strictEqual(mockFetch.mock.calls.length, 0);
|
assert.strictEqual(mockFetch.mock.calls.length, 0);
|
||||||
|
|
||||||
controller.canFetch = true;
|
controller.canFetch = true;
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
mock.timers.tick(30);
|
mock.timers.tick(30);
|
||||||
|
|
||||||
const response = await fetchPromise;
|
const response = await fetchPromise;
|
||||||
assert.strictEqual(await response.text(), "OK");
|
assert.strictEqual(await response.text(), "OK");
|
||||||
assert.strictEqual(mockFetch.mock.calls.length, 1);
|
assert.strictEqual(mockFetch.mock.calls.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should reject during reset", async () => {
|
it("should reject during reset", async () => {
|
||||||
const logger = new Logger();
|
const logger = new Logger();
|
||||||
const controller = new FetchController(true, logger);
|
const controller = new FetchController(true, logger);
|
||||||
const mockFetch = createMockFetch(true);
|
const mockFetch = createMockFetch(true);
|
||||||
const controlledFetch = controller.getControlledFetchImplementation(
|
const controlledFetch = controller.getControlledFetchImplementation(
|
||||||
logger,
|
logger,
|
||||||
mockFetch
|
mockFetch
|
||||||
);
|
);
|
||||||
|
|
||||||
const firstRequest = controlledFetch("http://example.com");
|
const firstRequest = controlledFetch("http://example.com");
|
||||||
assert.strictEqual(mockFetch.mock.calls.length, 1);
|
assert.strictEqual(mockFetch.mock.calls.length, 1);
|
||||||
|
|
||||||
controller.startReset();
|
controller.startReset();
|
||||||
|
|
||||||
const secondRequest = controlledFetch("http://example.com");
|
const secondRequest = controlledFetch("http://example.com");
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
firstRequest,
|
firstRequest,
|
||||||
(error: unknown) => error instanceof SyncResetError
|
(error: unknown) => error instanceof SyncResetError
|
||||||
);
|
);
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
secondRequest,
|
secondRequest,
|
||||||
(error: unknown) => error instanceof SyncResetError
|
(error: unknown) => error instanceof SyncResetError
|
||||||
);
|
);
|
||||||
assert.strictEqual(mockFetch.mock.calls.length, 1);
|
assert.strictEqual(mockFetch.mock.calls.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should allow fetch after reset finishes", async () => {
|
it("should allow fetch after reset finishes", async () => {
|
||||||
const logger = new Logger();
|
const logger = new Logger();
|
||||||
const controller = new FetchController(true, logger);
|
const controller = new FetchController(true, logger);
|
||||||
const mockFetch = createMockFetch(false);
|
const mockFetch = createMockFetch(false);
|
||||||
const controlledFetch = controller.getControlledFetchImplementation(
|
const controlledFetch = controller.getControlledFetchImplementation(
|
||||||
logger,
|
logger,
|
||||||
mockFetch
|
mockFetch
|
||||||
);
|
);
|
||||||
|
|
||||||
controller.startReset();
|
controller.startReset();
|
||||||
controller.finishReset();
|
controller.finishReset();
|
||||||
|
|
||||||
const response = await controlledFetch("http://example.com");
|
const response = await controlledFetch("http://example.com");
|
||||||
assert.strictEqual(await response.text(), "OK");
|
assert.strictEqual(await response.text(), "OK");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should defer canFetch changes during reset", async () => {
|
it("should defer canFetch changes during reset", async () => {
|
||||||
const logger = new Logger();
|
const logger = new Logger();
|
||||||
const controller = new FetchController(false, logger);
|
const controller = new FetchController(false, logger);
|
||||||
const mockFetch = createMockFetch(true);
|
const mockFetch = createMockFetch(true);
|
||||||
const controlledFetch = controller.getControlledFetchImplementation(
|
const controlledFetch = controller.getControlledFetchImplementation(
|
||||||
logger,
|
logger,
|
||||||
mockFetch
|
mockFetch
|
||||||
);
|
);
|
||||||
|
|
||||||
controller.startReset();
|
controller.startReset();
|
||||||
controller.canFetch = true;
|
controller.canFetch = true;
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
async () => controlledFetch("http://example.com"),
|
async () => controlledFetch("http://example.com"),
|
||||||
(error: unknown) => error instanceof SyncResetError
|
(error: unknown) => error instanceof SyncResetError
|
||||||
);
|
);
|
||||||
|
|
||||||
controller.finishReset();
|
controller.finishReset();
|
||||||
|
|
||||||
const fetchPromise = controlledFetch("http://example.com");
|
const fetchPromise = controlledFetch("http://example.com");
|
||||||
mock.timers.tick(30);
|
mock.timers.tick(30);
|
||||||
|
|
||||||
const response = await fetchPromise;
|
const response = await fetchPromise;
|
||||||
assert.strictEqual(await response.text(), "OK");
|
assert.strictEqual(await response.text(), "OK");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle different input types", async () => {
|
it("should handle different input types", async () => {
|
||||||
const logger = new Logger();
|
const logger = new Logger();
|
||||||
const controller = new FetchController(true, logger);
|
const controller = new FetchController(true, logger);
|
||||||
const mockFetch = createMockFetch(false);
|
const mockFetch = createMockFetch(false);
|
||||||
const controlledFetch = controller.getControlledFetchImplementation(
|
const controlledFetch = controller.getControlledFetchImplementation(
|
||||||
logger,
|
logger,
|
||||||
mockFetch
|
mockFetch
|
||||||
);
|
);
|
||||||
|
|
||||||
await controlledFetch("http://example.com");
|
await controlledFetch("http://example.com");
|
||||||
await controlledFetch(new URL("http://example.com"));
|
await controlledFetch(new URL("http://example.com"));
|
||||||
await controlledFetch(
|
await controlledFetch(
|
||||||
new Request("http://example.com", { method: "POST" })
|
new Request("http://example.com", { method: "POST" })
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.strictEqual(mockFetch.mock.calls.length, 3);
|
assert.strictEqual(mockFetch.mock.calls.length, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle fetch errors", async () => {
|
it("should handle fetch errors", async () => {
|
||||||
const logger = new Logger();
|
const logger = new Logger();
|
||||||
const controller = new FetchController(true, logger);
|
const controller = new FetchController(true, logger);
|
||||||
const mockFetch = mock.fn(async () => {
|
const mockFetch = mock.fn(async () => {
|
||||||
throw new Error("Network error");
|
throw new Error("Network error");
|
||||||
});
|
});
|
||||||
const controlledFetch = controller.getControlledFetchImplementation(
|
const controlledFetch = controller.getControlledFetchImplementation(
|
||||||
logger,
|
logger,
|
||||||
mockFetch
|
mockFetch
|
||||||
);
|
);
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
async () => controlledFetch("http://example.com"),
|
async () => controlledFetch("http://example.com"),
|
||||||
(error: unknown) =>
|
(error: unknown) =>
|
||||||
error instanceof Error && error.message === "Network error"
|
error instanceof Error && error.message === "Network error"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not create unhandled rejection on reset with no waiting fetches", async () => {
|
it("should not create unhandled rejection on reset with no waiting fetches", async () => {
|
||||||
const logger = new Logger();
|
const logger = new Logger();
|
||||||
const controller = new FetchController(true, logger);
|
const controller = new FetchController(true, logger);
|
||||||
|
|
||||||
controller.startReset();
|
controller.startReset();
|
||||||
mock.timers.tick(10);
|
mock.timers.tick(10);
|
||||||
controller.finishReset();
|
controller.finishReset();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,143 +7,143 @@ import { SyncResetError } from "./sync-reset-error";
|
||||||
* and aborts outstanding requests when a reset is started.
|
* and aborts outstanding requests when a reset is started.
|
||||||
*/
|
*/
|
||||||
export class FetchController {
|
export class FetchController {
|
||||||
private static readonly UNTIL_RESOLUTION = Symbol();
|
private static readonly UNTIL_RESOLUTION = Symbol();
|
||||||
|
|
||||||
private isResetting = false;
|
private isResetting = false;
|
||||||
|
|
||||||
// Promise resolves on the next state change: sync enabled/disabled or reset started/ended
|
// Promise resolves on the next state change: sync enabled/disabled or reset started/ended
|
||||||
private until: Promise<symbol>;
|
private until: Promise<symbol>;
|
||||||
private resolveUntil: (result: symbol) => unknown;
|
private resolveUntil: (result: symbol) => unknown;
|
||||||
private rejectUntil: (reason: unknown) => unknown;
|
private rejectUntil: (reason: unknown) => unknown;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private _canFetch: boolean,
|
private _canFetch: boolean,
|
||||||
private readonly logger: Logger
|
private readonly logger: Logger
|
||||||
) {
|
) {
|
||||||
[this.until, this.resolveUntil, this.rejectUntil] =
|
[this.until, this.resolveUntil, this.rejectUntil] =
|
||||||
createPromise<symbol>();
|
createPromise<symbol>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the fetch implementation can immediately send requests once outside of a reset.
|
* Whether the fetch implementation can immediately send requests once outside of a reset.
|
||||||
*/
|
*/
|
||||||
public get canFetch(): boolean {
|
public get canFetch(): boolean {
|
||||||
return this._canFetch;
|
return this._canFetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allow or disallow fetching. The changes only take effect if not resetting.
|
* Allow or disallow fetching. The changes only take effect if not resetting.
|
||||||
* When called during a reset, its effect is deferred until the reset is finished.
|
* When called during a reset, its effect is deferred until the reset is finished.
|
||||||
*
|
*
|
||||||
* @param canFetch Whether fetching is enabled
|
* @param canFetch Whether fetching is enabled
|
||||||
*/
|
*/
|
||||||
public set canFetch(canFetch: boolean) {
|
public set canFetch(canFetch: boolean) {
|
||||||
this._canFetch = canFetch;
|
this._canFetch = canFetch;
|
||||||
|
|
||||||
if (!this.isResetting) {
|
if (!this.isResetting) {
|
||||||
const previousResolve = this.resolveUntil;
|
const previousResolve = this.resolveUntil;
|
||||||
[this.until, this.resolveUntil, this.rejectUntil] =
|
[this.until, this.resolveUntil, this.rejectUntil] =
|
||||||
createPromise<symbol>();
|
createPromise<symbol>();
|
||||||
previousResolve(FetchController.UNTIL_RESOLUTION);
|
previousResolve(FetchController.UNTIL_RESOLUTION);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getUrlFromInput(input: RequestInfo | URL): string {
|
private static getUrlFromInput(input: RequestInfo | URL): string {
|
||||||
if (input instanceof URL) {
|
if (input instanceof URL) {
|
||||||
return input.href;
|
return input.href;
|
||||||
}
|
}
|
||||||
if (typeof input === "string") {
|
if (typeof input === "string") {
|
||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
return input.url;
|
return input.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts a reset, causing all ongoing and future fetches to be rejected
|
* Starts a reset, causing all ongoing and future fetches to be rejected
|
||||||
* with a SyncResetError until finishReset is called.
|
* with a SyncResetError until finishReset is called.
|
||||||
*/
|
*/
|
||||||
public startReset(): void {
|
public startReset(): void {
|
||||||
this.isResetting = true;
|
this.isResetting = true;
|
||||||
this.rejectUntil(new SyncResetError());
|
this.rejectUntil(new SyncResetError());
|
||||||
// Catch unhandled rejection if no fetches are waiting
|
// Catch unhandled rejection if no fetches are waiting
|
||||||
this.until.catch(() => {
|
this.until.catch(() => {
|
||||||
// Intentionally ignore - this rejection is handled by waiting fetches
|
// Intentionally ignore - this rejection is handled by waiting fetches
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finishes a reset, allowing fetches to proceed or wait again depending on
|
* Finishes a reset, allowing fetches to proceed or wait again depending on
|
||||||
* the current sync settings.
|
* the current sync settings.
|
||||||
*/
|
*/
|
||||||
public finishReset(): void {
|
public finishReset(): void {
|
||||||
if (!this.isResetting) {
|
if (!this.isResetting) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isResetting = false;
|
this.isResetting = false;
|
||||||
[this.until, this.resolveUntil, this.rejectUntil] = createPromise();
|
[this.until, this.resolveUntil, this.rejectUntil] = createPromise();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* |------------------|---------------|-----------------------------------------------------|
|
* |------------------|---------------|-----------------------------------------------------|
|
||||||
* | | Sync enabled | Sync disabled |
|
* | | Sync enabled | Sync disabled |
|
||||||
* |------------------|-------------- |-----------------------------------------------------|
|
* |------------------|-------------- |-----------------------------------------------------|
|
||||||
* | During reset | Rejects with SyncResetError without sending request |
|
* | During reset | Rejects with SyncResetError without sending request |
|
||||||
* |------------------|-------------- |-----------------------------------------------------|
|
* |------------------|-------------- |-----------------------------------------------------|
|
||||||
* | Outside of reset | Same as fetch | Blocks until sync is enabled and then same as fetch |
|
* | Outside of reset | Same as fetch | Blocks until sync is enabled and then same as fetch |
|
||||||
* |------------------|---------------|-----------------------------------------------------|
|
* |------------------|---------------|-----------------------------------------------------|
|
||||||
*
|
*
|
||||||
* @param logger for errors
|
* @param logger for errors
|
||||||
* @param fetch to wrap
|
* @param fetch to wrap
|
||||||
* @returns a wrapped fetch implementation affected by the FetchController state
|
* @returns a wrapped fetch implementation affected by the FetchController state
|
||||||
*/
|
*/
|
||||||
public getControlledFetchImplementation(
|
public getControlledFetchImplementation(
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
fetch: typeof globalThis.fetch = globalThis.fetch
|
fetch: typeof globalThis.fetch = globalThis.fetch
|
||||||
): typeof globalThis.fetch {
|
): typeof globalThis.fetch {
|
||||||
return async (
|
return async (
|
||||||
input: RequestInfo | URL,
|
input: RequestInfo | URL,
|
||||||
init?: RequestInit
|
init?: RequestInit
|
||||||
): Promise<Response> => {
|
): Promise<Response> => {
|
||||||
while (!this.canFetch || this.isResetting) {
|
while (!this.canFetch || this.isResetting) {
|
||||||
await this.until;
|
await this.until;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// https://github.com/jonbern/fetch-retry/blob/8684ef4e688375f623bd76f13add76dbc1d67cfb/index.js#L67C1-L70C21
|
// https://github.com/jonbern/fetch-retry/blob/8684ef4e688375f623bd76f13add76dbc1d67cfb/index.js#L67C1-L70C21
|
||||||
const _input =
|
const _input =
|
||||||
typeof Request !== "undefined" && input instanceof Request
|
typeof Request !== "undefined" && input instanceof Request
|
||||||
? input.clone()
|
? input.clone()
|
||||||
: input;
|
: input;
|
||||||
|
|
||||||
const fetchPromise = fetch(_input, init);
|
const fetchPromise = fetch(_input, init);
|
||||||
|
|
||||||
// We only want to catch rejections from `this.until`
|
// We only want to catch rejections from `this.until`
|
||||||
let result: symbol | Response | undefined = undefined;
|
let result: symbol | Response | undefined = undefined;
|
||||||
do {
|
do {
|
||||||
result = await Promise.race([this.until, fetchPromise]);
|
result = await Promise.race([this.until, fetchPromise]);
|
||||||
} while (result === FetchController.UNTIL_RESOLUTION);
|
} while (result === FetchController.UNTIL_RESOLUTION);
|
||||||
|
|
||||||
const fetchResult: Response = result as Response; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
const fetchResult: Response = result as Response; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
|
||||||
if (!fetchResult.ok) {
|
if (!fetchResult.ok) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Fetch for ${FetchController.getUrlFromInput(
|
`Fetch for ${FetchController.getUrlFromInput(
|
||||||
input
|
input
|
||||||
)}, got status ${fetchResult.status}`
|
)}, got status ${fetchResult.status}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetchResult;
|
return fetchResult;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Fetch for ${FetchController.getUrlFromInput(
|
`Fetch for ${FetchController.getUrlFromInput(
|
||||||
input
|
input
|
||||||
)}, got error: ${error}`
|
)}, got error: ${error}`
|
||||||
);
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,83 +5,83 @@ import type { SyncService } from "./sync-service";
|
||||||
import type { PingResponse } from "./types/PingResponse";
|
import type { PingResponse } from "./types/PingResponse";
|
||||||
|
|
||||||
export interface ServerConfigData {
|
export interface ServerConfigData {
|
||||||
mergeableFileExtensions: string[];
|
mergeableFileExtensions: string[];
|
||||||
supportedApiVersion: number;
|
supportedApiVersion: number;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ServerConfig {
|
export class ServerConfig {
|
||||||
private response: Promise<PingResponse> | undefined;
|
private response: Promise<PingResponse> | undefined;
|
||||||
private config: ServerConfigData | undefined;
|
private config: ServerConfigData | undefined;
|
||||||
|
|
||||||
public constructor(private readonly syncService: SyncService) {}
|
public constructor(private readonly syncService: SyncService) {}
|
||||||
|
|
||||||
public async initialize(): Promise<void> {
|
public async initialize(): Promise<void> {
|
||||||
this.response = this.syncService.ping();
|
this.response = this.syncService.ping();
|
||||||
this.config = await this.response;
|
this.config = await this.response;
|
||||||
|
|
||||||
if (this.config.supportedApiVersion !== SUPPORTED_API_VERSION) {
|
if (this.config.supportedApiVersion !== SUPPORTED_API_VERSION) {
|
||||||
const shouldUpgradeClient =
|
const shouldUpgradeClient =
|
||||||
this.config.supportedApiVersion > SUPPORTED_API_VERSION;
|
this.config.supportedApiVersion > SUPPORTED_API_VERSION;
|
||||||
throw new ServerVersionMismatchError(
|
throw new ServerVersionMismatchError(
|
||||||
`Unsupported API version: ${this.config.supportedApiVersion}. Consider upgrading the ${
|
`Unsupported API version: ${this.config.supportedApiVersion}. Consider upgrading the ${
|
||||||
shouldUpgradeClient ? "client" : "sync-server"
|
shouldUpgradeClient ? "client" : "sync-server"
|
||||||
} to ensure compatibility.`
|
} to ensure compatibility.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.config.isAuthenticated) {
|
if (!this.config.isAuthenticated) {
|
||||||
throw new AuthenticationError(
|
throw new AuthenticationError(
|
||||||
"Failed to authenticate with the sync-server."
|
"Failed to authenticate with the sync-server."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async checkConnection(forceUpdate = false): Promise<{
|
public async checkConnection(forceUpdate = false): Promise<{
|
||||||
isSuccessful: boolean;
|
isSuccessful: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
let { response } = this;
|
let { response } = this;
|
||||||
if (!response && !forceUpdate) {
|
if (!response && !forceUpdate) {
|
||||||
throw new Error("ServerConfig not initialized");
|
throw new Error("ServerConfig not initialized");
|
||||||
} else if (forceUpdate) {
|
} else if (forceUpdate) {
|
||||||
response = this.response = this.syncService.ping();
|
response = this.response = this.syncService.ping();
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const result: PingResponse = (await response)!; // it must be defined, otherwise we would have thrown above
|
const result: PingResponse = (await response)!; // it must be defined, otherwise we would have thrown above
|
||||||
this.config = result;
|
this.config = result;
|
||||||
|
|
||||||
if (result.isAuthenticated) {
|
if (result.isAuthenticated) {
|
||||||
return {
|
return {
|
||||||
isSuccessful: true,
|
isSuccessful: true,
|
||||||
message: `Successfully connected to server (version: ${result.serverVersion}) and authenticated`
|
message: `Successfully connected to server (version: ${result.serverVersion}) and authenticated`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isSuccessful: false,
|
isSuccessful: false,
|
||||||
message: `Successfully connected to server (version: ${result.serverVersion}) but failed to authenticate`
|
message: `Successfully connected to server (version: ${result.serverVersion}) but failed to authenticate`
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
return {
|
||||||
isSuccessful: false,
|
isSuccessful: false,
|
||||||
message: `Failed to connect to server: ${e}`
|
message: `Failed to connect to server: ${e}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public getConfig(): ServerConfigData {
|
public getConfig(): ServerConfigData {
|
||||||
if (!this.config) {
|
if (!this.config) {
|
||||||
throw new Error("ServerConfig not initialized");
|
throw new Error("ServerConfig not initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.config;
|
return this.config;
|
||||||
}
|
}
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this.response = undefined;
|
this.response = undefined;
|
||||||
this.config = undefined;
|
this.config = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export class ServerVersionMismatchError extends Error {
|
export class ServerVersionMismatchError extends Error {
|
||||||
public constructor(message: string) {
|
public constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "ServerVersionMismatchError";
|
this.name = "ServerVersionMismatchError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export class SyncResetError extends Error {
|
export class SyncResetError extends Error {
|
||||||
public constructor() {
|
public constructor() {
|
||||||
super("SyncClient has been reset, cleaning up");
|
super("SyncClient has been reset, cleaning up");
|
||||||
this.name = "SyncResetError";
|
this.name = "SyncResetError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type {
|
import type {
|
||||||
DocumentId,
|
DocumentId,
|
||||||
RelativePath,
|
RelativePath,
|
||||||
VaultUpdateId
|
VaultUpdateId
|
||||||
} from "../persistence/database";
|
} from "../persistence/database";
|
||||||
|
|
||||||
import type { Logger } from "../tracing/logger";
|
import type { Logger } from "../tracing/logger";
|
||||||
|
|
@ -19,416 +19,416 @@ import type { DeleteDocumentVersion } from "./types/DeleteDocumentVersion";
|
||||||
import type { UpdateTextDocumentVersion } from "./types/UpdateTextDocumentVersion";
|
import type { UpdateTextDocumentVersion } from "./types/UpdateTextDocumentVersion";
|
||||||
|
|
||||||
export class SyncService {
|
export class SyncService {
|
||||||
private readonly client: typeof globalThis.fetch;
|
private readonly client: typeof globalThis.fetch;
|
||||||
private readonly pingClient: typeof globalThis.fetch;
|
private readonly pingClient: typeof globalThis.fetch;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly deviceId: string,
|
private readonly deviceId: string,
|
||||||
private readonly fetchController: FetchController,
|
private readonly fetchController: FetchController,
|
||||||
private readonly settings: Settings,
|
private readonly settings: Settings,
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
fetchImplementation: typeof globalThis.fetch = globalThis.fetch
|
fetchImplementation: typeof globalThis.fetch = globalThis.fetch
|
||||||
) {
|
) {
|
||||||
// ensure that if it's called a method, `this` won't be bound to the instance
|
// ensure that if it's called a method, `this` won't be bound to the instance
|
||||||
const unboundFetch: typeof globalThis.fetch = async (...args) =>
|
const unboundFetch: typeof globalThis.fetch = async (...args) =>
|
||||||
fetchImplementation(...args);
|
fetchImplementation(...args);
|
||||||
|
|
||||||
this.client = this.fetchController.getControlledFetchImplementation(
|
this.client = this.fetchController.getControlledFetchImplementation(
|
||||||
this.logger,
|
this.logger,
|
||||||
unboundFetch
|
unboundFetch
|
||||||
);
|
);
|
||||||
this.pingClient = unboundFetch;
|
this.pingClient = unboundFetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async errorFromResponse(
|
private static async errorFromResponse(
|
||||||
response: Response
|
response: Response
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (
|
if (
|
||||||
response.headers
|
response.headers
|
||||||
.get("Content-Type")
|
.get("Content-Type")
|
||||||
?.includes("application/json") == true
|
?.includes("application/json") == true
|
||||||
) {
|
) {
|
||||||
const result: SerializedError =
|
const result: SerializedError =
|
||||||
(await response.json()) as SerializedError; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
(await response.json()) as SerializedError; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
return SyncService.formatError(result);
|
return SyncService.formatError(result);
|
||||||
}
|
}
|
||||||
return `HTTP ${response.status}: ${response.statusText}`;
|
return `HTTP ${response.status}: ${response.statusText}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static formatError(error: SerializedError): string {
|
private static formatError(error: SerializedError): string {
|
||||||
let result = error.message;
|
let result = error.message;
|
||||||
if (error.causes.length > 0) {
|
if (error.causes.length > 0) {
|
||||||
const causes = error.causes.join(", ");
|
const causes = error.causes.join(", ");
|
||||||
result += ` caused by: ${causes}`;
|
result += ` caused by: ${causes}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async create({
|
public async create({
|
||||||
documentId,
|
documentId,
|
||||||
relativePath,
|
relativePath,
|
||||||
contentBytes
|
contentBytes
|
||||||
}: {
|
}: {
|
||||||
documentId?: DocumentId;
|
documentId?: DocumentId;
|
||||||
relativePath: RelativePath;
|
relativePath: RelativePath;
|
||||||
contentBytes: Uint8Array;
|
contentBytes: Uint8Array;
|
||||||
}): Promise<DocumentVersionWithoutContent> {
|
}): Promise<DocumentVersionWithoutContent> {
|
||||||
return this.retryForever(async () => {
|
return this.retryForever(async () => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
if (documentId !== undefined) {
|
if (documentId !== undefined) {
|
||||||
formData.append("document_id", documentId);
|
formData.append("document_id", documentId);
|
||||||
}
|
}
|
||||||
formData.append("relative_path", relativePath);
|
formData.append("relative_path", relativePath);
|
||||||
formData.append(
|
formData.append(
|
||||||
"content",
|
"content",
|
||||||
new Blob([new Uint8Array(contentBytes)])
|
new Blob([new Uint8Array(contentBytes)])
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Creating document with id ${documentId} and relative path ${relativePath}`
|
`Creating document with id ${documentId} and relative path ${relativePath}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await this.client(this.getUrl("/documents"), {
|
const response = await this.client(this.getUrl("/documents"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
headers: this.getDefaultHeaders()
|
headers: this.getDefaultHeaders()
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to create document: ${await SyncService.errorFromResponse(
|
`Failed to create document: ${await SyncService.errorFromResponse(
|
||||||
response
|
response
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: DocumentVersionWithoutContent =
|
const result: DocumentVersionWithoutContent =
|
||||||
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
|
||||||
this.logger.debug(`Created document ${JSON.stringify(result)}`);
|
this.logger.debug(`Created document ${JSON.stringify(result)}`);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async putText({
|
public async putText({
|
||||||
parentVersionId,
|
parentVersionId,
|
||||||
documentId,
|
documentId,
|
||||||
relativePath,
|
relativePath,
|
||||||
content
|
content
|
||||||
}: {
|
}: {
|
||||||
parentVersionId: VaultUpdateId;
|
parentVersionId: VaultUpdateId;
|
||||||
documentId: DocumentId;
|
documentId: DocumentId;
|
||||||
relativePath: RelativePath;
|
relativePath: RelativePath;
|
||||||
content: (number | string)[];
|
content: (number | string)[];
|
||||||
}): Promise<DocumentUpdateResponse> {
|
}): Promise<DocumentUpdateResponse> {
|
||||||
return this.retryForever(async () => {
|
return this.retryForever(async () => {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Updating text document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}, content [${content.join(", ")}]`
|
`Updating text document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}, content [${content.join(", ")}]`
|
||||||
);
|
);
|
||||||
|
|
||||||
const request: UpdateTextDocumentVersion = {
|
const request: UpdateTextDocumentVersion = {
|
||||||
parentVersionId,
|
parentVersionId,
|
||||||
relativePath,
|
relativePath,
|
||||||
content
|
content
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await this.client(
|
const response = await this.client(
|
||||||
this.getUrl(`/documents/${documentId}/text`),
|
this.getUrl(`/documents/${documentId}/text`),
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(request),
|
body: JSON.stringify(request),
|
||||||
headers: this.getDefaultHeaders({ type: "json" })
|
headers: this.getDefaultHeaders({ type: "json" })
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to update document: ${await SyncService.errorFromResponse(
|
`Failed to update document: ${await SyncService.errorFromResponse(
|
||||||
response
|
response
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: DocumentUpdateResponse =
|
const result: DocumentUpdateResponse =
|
||||||
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Updated document ${JSON.stringify(result)} with id ${
|
`Updated document ${JSON.stringify(result)} with id ${
|
||||||
result.documentId
|
result.documentId
|
||||||
}}`
|
}}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async putBinary({
|
public async putBinary({
|
||||||
parentVersionId,
|
parentVersionId,
|
||||||
documentId,
|
documentId,
|
||||||
relativePath,
|
relativePath,
|
||||||
contentBytes
|
contentBytes
|
||||||
}: {
|
}: {
|
||||||
parentVersionId: VaultUpdateId;
|
parentVersionId: VaultUpdateId;
|
||||||
documentId: DocumentId;
|
documentId: DocumentId;
|
||||||
relativePath: RelativePath;
|
relativePath: RelativePath;
|
||||||
contentBytes: Uint8Array;
|
contentBytes: Uint8Array;
|
||||||
}): Promise<DocumentUpdateResponse> {
|
}): Promise<DocumentUpdateResponse> {
|
||||||
return this.retryForever(async () => {
|
return this.retryForever(async () => {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Updating binary document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}`
|
`Updating binary document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}`
|
||||||
);
|
);
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("parent_version_id", parentVersionId.toString());
|
formData.append("parent_version_id", parentVersionId.toString());
|
||||||
formData.append("relative_path", relativePath);
|
formData.append("relative_path", relativePath);
|
||||||
formData.append(
|
formData.append(
|
||||||
"content",
|
"content",
|
||||||
new Blob([new Uint8Array(contentBytes)])
|
new Blob([new Uint8Array(contentBytes)])
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await this.client(
|
const response = await this.client(
|
||||||
this.getUrl(`/documents/${documentId}/binary`),
|
this.getUrl(`/documents/${documentId}/binary`),
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: formData,
|
body: formData,
|
||||||
headers: this.getDefaultHeaders()
|
headers: this.getDefaultHeaders()
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to update document: ${await SyncService.errorFromResponse(
|
`Failed to update document: ${await SyncService.errorFromResponse(
|
||||||
response
|
response
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: DocumentUpdateResponse =
|
const result: DocumentUpdateResponse =
|
||||||
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Updated document ${JSON.stringify(result)} with id ${
|
`Updated document ${JSON.stringify(result)} with id ${
|
||||||
result.documentId
|
result.documentId
|
||||||
}}`
|
}}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete({
|
public async delete({
|
||||||
documentId,
|
documentId,
|
||||||
relativePath
|
relativePath
|
||||||
}: {
|
}: {
|
||||||
documentId: DocumentId;
|
documentId: DocumentId;
|
||||||
relativePath: RelativePath;
|
relativePath: RelativePath;
|
||||||
}): Promise<DocumentVersionWithoutContent> {
|
}): Promise<DocumentVersionWithoutContent> {
|
||||||
return this.retryForever(async () => {
|
return this.retryForever(async () => {
|
||||||
const request: DeleteDocumentVersion = {
|
const request: DeleteDocumentVersion = {
|
||||||
relativePath
|
relativePath
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Delete document with id ${documentId} and relative path ${relativePath}`
|
`Delete document with id ${documentId} and relative path ${relativePath}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await this.client(
|
const response = await this.client(
|
||||||
this.getUrl(`/documents/${documentId}`),
|
this.getUrl(`/documents/${documentId}`),
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
body: JSON.stringify(request),
|
body: JSON.stringify(request),
|
||||||
headers: this.getDefaultHeaders({ type: "json" })
|
headers: this.getDefaultHeaders({ type: "json" })
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to delete document: ${await SyncService.errorFromResponse(
|
`Failed to delete document: ${await SyncService.errorFromResponse(
|
||||||
response
|
response
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: DocumentVersionWithoutContent =
|
const result: DocumentVersionWithoutContent =
|
||||||
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Deleted document ${relativePath} with id ${documentId}`
|
`Deleted document ${relativePath} with id ${documentId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get({
|
public async get({
|
||||||
documentId
|
documentId
|
||||||
}: {
|
}: {
|
||||||
documentId: DocumentId;
|
documentId: DocumentId;
|
||||||
}): Promise<DocumentVersion> {
|
}): Promise<DocumentVersion> {
|
||||||
return this.retryForever(async () => {
|
return this.retryForever(async () => {
|
||||||
this.logger.debug(`Getting document with id ${documentId}`);
|
this.logger.debug(`Getting document with id ${documentId}`);
|
||||||
|
|
||||||
const response = await this.client(
|
const response = await this.client(
|
||||||
this.getUrl(`/documents/${documentId}`),
|
this.getUrl(`/documents/${documentId}`),
|
||||||
{
|
{
|
||||||
headers: this.getDefaultHeaders()
|
headers: this.getDefaultHeaders()
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to get document: ${await SyncService.errorFromResponse(
|
`Failed to get document: ${await SyncService.errorFromResponse(
|
||||||
response
|
response
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: DocumentVersion =
|
const result: DocumentVersion =
|
||||||
(await response.json()) as DocumentVersion; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
(await response.json()) as DocumentVersion; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
|
||||||
this.logger.debug(`Got document ${JSON.stringify(result)}`);
|
this.logger.debug(`Got document ${JSON.stringify(result)}`);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getDocumentVersionContent({
|
public async getDocumentVersionContent({
|
||||||
documentId,
|
documentId,
|
||||||
vaultUpdateId
|
vaultUpdateId
|
||||||
}: {
|
}: {
|
||||||
documentId: DocumentId;
|
documentId: DocumentId;
|
||||||
vaultUpdateId: VaultUpdateId;
|
vaultUpdateId: VaultUpdateId;
|
||||||
}): Promise<Uint8Array> {
|
}): Promise<Uint8Array> {
|
||||||
return this.retryForever(async () => {
|
return this.retryForever(async () => {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Getting document with id ${documentId} and version ${vaultUpdateId}`
|
`Getting document with id ${documentId} and version ${vaultUpdateId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await this.client(
|
const response = await this.client(
|
||||||
this.getUrl(
|
this.getUrl(
|
||||||
`/documents/${documentId}/versions/${vaultUpdateId}/content`
|
`/documents/${documentId}/versions/${vaultUpdateId}/content`
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
headers: this.getDefaultHeaders()
|
headers: this.getDefaultHeaders()
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to get document: ${await SyncService.errorFromResponse(
|
`Failed to get document: ${await SyncService.errorFromResponse(
|
||||||
response
|
response
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.bytes();
|
const result = await response.bytes();
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Got document version content for document ${documentId} version ${vaultUpdateId}`
|
`Got document version content for document ${documentId} version ${vaultUpdateId}`
|
||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAll(
|
public async getAll(
|
||||||
since?: VaultUpdateId
|
since?: VaultUpdateId
|
||||||
): Promise<FetchLatestDocumentsResponse> {
|
): Promise<FetchLatestDocumentsResponse> {
|
||||||
return this.retryForever(async () => {
|
return this.retryForever(async () => {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
"Getting all documents" +
|
"Getting all documents" +
|
||||||
(since != null ? ` since ${since}` : "")
|
(since != null ? ` since ${since}` : "")
|
||||||
);
|
);
|
||||||
|
|
||||||
const url = new URL(this.getUrl("/documents"));
|
const url = new URL(this.getUrl("/documents"));
|
||||||
if (since !== undefined) {
|
if (since !== undefined) {
|
||||||
url.searchParams.append("since", since.toString());
|
url.searchParams.append("since", since.toString());
|
||||||
}
|
}
|
||||||
const response = await this.client(url.toString(), {
|
const response = await this.client(url.toString(), {
|
||||||
headers: this.getDefaultHeaders()
|
headers: this.getDefaultHeaders()
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to get documents: ${await SyncService.errorFromResponse(
|
`Failed to get documents: ${await SyncService.errorFromResponse(
|
||||||
response
|
response
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: FetchLatestDocumentsResponse =
|
const result: FetchLatestDocumentsResponse =
|
||||||
(await response.json()) as FetchLatestDocumentsResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
(await response.json()) as FetchLatestDocumentsResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Got ${result.latestDocuments.length} document metadata`
|
`Got ${result.latestDocuments.length} document metadata`
|
||||||
);
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ping(): Promise<PingResponse> {
|
public async ping(): Promise<PingResponse> {
|
||||||
this.logger.debug("Pinging server");
|
this.logger.debug("Pinging server");
|
||||||
const response = await this.pingClient(this.getUrl("/ping"), {
|
const response = await this.pingClient(this.getUrl("/ping"), {
|
||||||
headers: this.getDefaultHeaders()
|
headers: this.getDefaultHeaders()
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to ping server: ${await SyncService.errorFromResponse(
|
`Failed to ping server: ${await SyncService.errorFromResponse(
|
||||||
response
|
response
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: PingResponse = (await response.json()) as PingResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
const result: PingResponse = (await response.json()) as PingResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Pinged server, got response: ${JSON.stringify(result)}`
|
`Pinged server, got response: ${JSON.stringify(result)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getUrl(path: string): string {
|
private getUrl(path: string): string {
|
||||||
const { vaultName, remoteUri } = this.settings.getSettings();
|
const { vaultName, remoteUri } = this.settings.getSettings();
|
||||||
const remoteUriWithoutTrailingSlash = remoteUri.replace(/\/+$/, "");
|
const remoteUriWithoutTrailingSlash = remoteUri.replace(/\/+$/, "");
|
||||||
const encodedVaultName = encodeURIComponent(vaultName.trim());
|
const encodedVaultName = encodeURIComponent(vaultName.trim());
|
||||||
return `${remoteUriWithoutTrailingSlash}/vaults/${encodedVaultName}${path}`;
|
return `${remoteUriWithoutTrailingSlash}/vaults/${encodedVaultName}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDefaultHeaders(
|
private getDefaultHeaders(
|
||||||
{ type }: { type?: "json" } = { type: undefined }
|
{ type }: { type?: "json" } = { type: undefined }
|
||||||
): Record<string, string> {
|
): Record<string, string> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"device-id": this.deviceId,
|
"device-id": this.deviceId,
|
||||||
authorization: `Bearer ${this.settings.getSettings().token}`
|
authorization: `Bearer ${this.settings.getSettings().token}`
|
||||||
};
|
};
|
||||||
|
|
||||||
if (type === "json") {
|
if (type === "json") {
|
||||||
headers["Content-Type"] = "application/json";
|
headers["Content-Type"] = "application/json";
|
||||||
}
|
}
|
||||||
|
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async retryForever<T>(fn: () => Promise<T>): Promise<T> {
|
private async retryForever<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// We must not retry errors coming from reset
|
// We must not retry errors coming from reset
|
||||||
if (e instanceof SyncResetError) {
|
if (e instanceof SyncResetError) {
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
const retryInterval =
|
const retryInterval =
|
||||||
this.settings.getSettings().networkRetryIntervalMs;
|
this.settings.getSettings().networkRetryIntervalMs;
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed network call (${e}), retrying in ${retryInterval}ms`
|
`Failed network call (${e}), retrying in ${retryInterval}ms`
|
||||||
);
|
);
|
||||||
await sleep(retryInterval);
|
await sleep(retryInterval);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
||||||
|
|
||||||
export interface ClientCursors {
|
export interface ClientCursors {
|
||||||
userName: string;
|
userName: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
documentsWithCursors: DocumentWithCursors[];
|
documentsWithCursors: DocumentWithCursors[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface CreateDocumentVersion {
|
export interface CreateDocumentVersion {
|
||||||
/**
|
/**
|
||||||
* The client can decide the document id (if it wishes to) in order
|
* The client can decide the document id (if it wishes to) in order
|
||||||
* to help with syncing. If the client does not provide a document id,
|
* to help with syncing. If the client does not provide a document id,
|
||||||
* the server will generate one. If the client provides a document id
|
* the server will generate one. If the client provides a document id
|
||||||
* it must not already exist in the database.
|
* it must not already exist in the database.
|
||||||
*/
|
*/
|
||||||
document_id: string | null;
|
document_id: string | null;
|
||||||
relative_path: string;
|
relative_path: string;
|
||||||
content: number[];
|
content: number[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,5 @@
|
||||||
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
||||||
|
|
||||||
export interface CursorPositionFromClient {
|
export interface CursorPositionFromClient {
|
||||||
documentsWithCursors: DocumentWithCursors[];
|
documentsWithCursors: DocumentWithCursors[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,5 @@
|
||||||
import type { ClientCursors } from "./ClientCursors";
|
import type { ClientCursors } from "./ClientCursors";
|
||||||
|
|
||||||
export interface CursorPositionFromServer {
|
export interface CursorPositionFromServer {
|
||||||
clients: ClientCursors[];
|
clients: ClientCursors[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface CursorSpan {
|
export interface CursorSpan {
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface DeleteDocumentVersion {
|
export interface DeleteDocumentVersion {
|
||||||
relativePath: string;
|
relativePath: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,5 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
|
||||||
* Response to an update document request.
|
* Response to an update document request.
|
||||||
*/
|
*/
|
||||||
export type DocumentUpdateResponse =
|
export type DocumentUpdateResponse =
|
||||||
| ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent)
|
| ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent)
|
||||||
| ({ type: "MergingUpdate" } & DocumentVersion);
|
| ({ type: "MergingUpdate" } & DocumentVersion);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface DocumentVersion {
|
export interface DocumentVersion {
|
||||||
vaultUpdateId: number;
|
vaultUpdateId: number;
|
||||||
documentId: string;
|
documentId: string;
|
||||||
relativePath: string;
|
relativePath: string;
|
||||||
updatedDate: string;
|
updatedDate: string;
|
||||||
contentBase64: string;
|
contentBase64: string;
|
||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
userId: string;
|
userId: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface DocumentVersionWithoutContent {
|
export interface DocumentVersionWithoutContent {
|
||||||
vaultUpdateId: number;
|
vaultUpdateId: number;
|
||||||
documentId: string;
|
documentId: string;
|
||||||
relativePath: string;
|
relativePath: string;
|
||||||
updatedDate: string;
|
updatedDate: string;
|
||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
userId: string;
|
userId: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
contentSize: number;
|
contentSize: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
import type { CursorSpan } from "./CursorSpan";
|
import type { CursorSpan } from "./CursorSpan";
|
||||||
|
|
||||||
export interface DocumentWithCursors {
|
export interface DocumentWithCursors {
|
||||||
vault_update_id: number | null;
|
vault_update_id: number | null;
|
||||||
document_id: string;
|
document_id: string;
|
||||||
relative_path: string;
|
relative_path: string;
|
||||||
cursors: CursorSpan[];
|
cursors: CursorSpan[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
|
||||||
* Response to a fetch latest documents request.
|
* Response to a fetch latest documents request.
|
||||||
*/
|
*/
|
||||||
export interface FetchLatestDocumentsResponse {
|
export interface FetchLatestDocumentsResponse {
|
||||||
latestDocuments: DocumentVersionWithoutContent[];
|
latestDocuments: DocumentVersionWithoutContent[];
|
||||||
/**
|
/**
|
||||||
* The update ID of the latest document in the response.
|
* The update ID of the latest document in the response.
|
||||||
*/
|
*/
|
||||||
lastUpdateId: bigint;
|
lastUpdateId: bigint;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,22 +4,22 @@
|
||||||
* Response to a ping request.
|
* Response to a ping request.
|
||||||
*/
|
*/
|
||||||
export interface PingResponse {
|
export interface PingResponse {
|
||||||
/**
|
/**
|
||||||
* Semantic version of the server.
|
* Semantic version of the server.
|
||||||
*/
|
*/
|
||||||
serverVersion: string;
|
serverVersion: string;
|
||||||
/**
|
/**
|
||||||
* Whether the client is authenticated based on the sent Authorization
|
* Whether the client is authenticated based on the sent Authorization
|
||||||
* header.
|
* header.
|
||||||
*/
|
*/
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
/**
|
/**
|
||||||
* List of file extensions that are allowed to be merged.
|
* List of file extensions that are allowed to be merged.
|
||||||
*/
|
*/
|
||||||
mergeableFileExtensions: string[];
|
mergeableFileExtensions: string[];
|
||||||
/**
|
/**
|
||||||
* API version ensuring backwards & forwards compatibility between the client
|
* API version ensuring backwards & forwards compatibility between the client
|
||||||
* and server.
|
* and server.
|
||||||
*/
|
*/
|
||||||
supportedApiVersion: number;
|
supportedApiVersion: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface SerializedError {
|
export interface SerializedError {
|
||||||
errorType: string;
|
errorType: string;
|
||||||
message: string;
|
message: string;
|
||||||
causes: string[];
|
causes: string[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface UpdateDocumentVersion {
|
export interface UpdateDocumentVersion {
|
||||||
parent_version_id: bigint;
|
parent_version_id: bigint;
|
||||||
relative_path: string;
|
relative_path: string;
|
||||||
content: number[];
|
content: number[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface UpdateTextDocumentVersion {
|
export interface UpdateTextDocumentVersion {
|
||||||
parentVersionId: number;
|
parentVersionId: number;
|
||||||
relativePath: string;
|
relativePath: string;
|
||||||
content: (number | string)[];
|
content: (number | string)[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,5 @@ import type { CursorPositionFromClient } from "./CursorPositionFromClient";
|
||||||
import type { WebSocketHandshake } from "./WebSocketHandshake";
|
import type { WebSocketHandshake } from "./WebSocketHandshake";
|
||||||
|
|
||||||
export type WebSocketClientMessage =
|
export type WebSocketClientMessage =
|
||||||
| ({ type: "handshake" } & WebSocketHandshake)
|
| ({ type: "handshake" } & WebSocketHandshake)
|
||||||
| ({ type: "cursorPositions" } & CursorPositionFromClient);
|
| ({ type: "cursorPositions" } & CursorPositionFromClient);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface WebSocketHandshake {
|
export interface WebSocketHandshake {
|
||||||
token: string;
|
token: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
lastSeenVaultUpdateId: number | null;
|
lastSeenVaultUpdateId: number | null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,5 @@ import type { CursorPositionFromServer } from "./CursorPositionFromServer";
|
||||||
import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate";
|
import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate";
|
||||||
|
|
||||||
export type WebSocketServerMessage =
|
export type WebSocketServerMessage =
|
||||||
| ({ type: "vaultUpdate" } & WebSocketVaultUpdate)
|
| ({ type: "vaultUpdate" } & WebSocketVaultUpdate)
|
||||||
| ({ type: "cursorPositions" } & CursorPositionFromServer);
|
| ({ type: "cursorPositions" } & CursorPositionFromServer);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,6 @@
|
||||||
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
|
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
|
||||||
|
|
||||||
export interface WebSocketVaultUpdate {
|
export interface WebSocketVaultUpdate {
|
||||||
documents: DocumentVersionWithoutContent[];
|
documents: DocumentVersionWithoutContent[];
|
||||||
isInitialSync: boolean;
|
isInitialSync: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,291 +8,291 @@ import type { Settings } from "../persistence/settings";
|
||||||
const WebSocket = require("ws") as typeof globalThis.WebSocket;
|
const WebSocket = require("ws") as typeof globalThis.WebSocket;
|
||||||
|
|
||||||
class MockCloseEvent extends Event {
|
class MockCloseEvent extends Event {
|
||||||
public code: number;
|
public code: number;
|
||||||
public reason: string;
|
public reason: string;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
type: string,
|
type: string,
|
||||||
options: { code: number; reason: string }
|
options: { code: number; reason: string }
|
||||||
) {
|
) {
|
||||||
super(type);
|
super(type);
|
||||||
this.code = options.code;
|
this.code = options.code;
|
||||||
this.reason = options.reason;
|
this.reason = options.reason;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MockMessageEvent extends Event {
|
class MockMessageEvent extends Event {
|
||||||
public data: string;
|
public data: string;
|
||||||
|
|
||||||
public constructor(type: string, options: { data: string }) {
|
public constructor(type: string, options: { data: string }) {
|
||||||
super(type);
|
super(type);
|
||||||
this.data = options.data;
|
this.data = options.data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MockWebSocket {
|
class MockWebSocket {
|
||||||
public readyState: number = WebSocket.CONNECTING;
|
public readyState: number = WebSocket.CONNECTING;
|
||||||
public onopen: ((event: Event) => void) | null = null;
|
public onopen: ((event: Event) => void) | null = null;
|
||||||
public onclose: ((event: MockCloseEvent) => void) | null = null;
|
public onclose: ((event: MockCloseEvent) => void) | null = null;
|
||||||
public onmessage: ((event: MockMessageEvent) => void) | null = null;
|
public onmessage: ((event: MockMessageEvent) => void) | null = null;
|
||||||
public onerror: ((event: Event) => void) | null = null;
|
public onerror: ((event: Event) => void) | null = null;
|
||||||
|
|
||||||
public sentMessages: string[] = [];
|
public sentMessages: string[] = [];
|
||||||
|
|
||||||
public constructor(public url: string) {
|
public constructor(public url: string) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.readyState === WebSocket.CONNECTING) {
|
if (this.readyState === WebSocket.CONNECTING) {
|
||||||
this.readyState = WebSocket.OPEN;
|
this.readyState = WebSocket.OPEN;
|
||||||
this.onopen?.(new Event("open"));
|
this.onopen?.(new Event("open"));
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public send(data: string): void {
|
public send(data: string): void {
|
||||||
if (this.readyState !== WebSocket.OPEN) {
|
if (this.readyState !== WebSocket.OPEN) {
|
||||||
throw new Error("WebSocket is not open");
|
throw new Error("WebSocket is not open");
|
||||||
}
|
}
|
||||||
this.sentMessages.push(data);
|
this.sentMessages.push(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
public close(code?: number, reason?: string): void {
|
public close(code?: number, reason?: string): void {
|
||||||
this.readyState = WebSocket.CLOSED;
|
this.readyState = WebSocket.CLOSED;
|
||||||
this.onclose?.(
|
this.onclose?.(
|
||||||
new MockCloseEvent("close", {
|
new MockCloseEvent("close", {
|
||||||
code: code ?? 1000,
|
code: code ?? 1000,
|
||||||
reason: reason ?? ""
|
reason: reason ?? ""
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public simulateMessage(data: unknown): void {
|
public simulateMessage(data: unknown): void {
|
||||||
this.onmessage?.(
|
this.onmessage?.(
|
||||||
new MockMessageEvent("message", { data: JSON.stringify(data) })
|
new MockMessageEvent("message", { data: JSON.stringify(data) })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type MockFn<T extends (...args: unknown[]) => unknown> = T & {
|
type MockFn<T extends (...args: unknown[]) => unknown> = T & {
|
||||||
calls: Parameters<T>[];
|
calls: Parameters<T>[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function createMockFn<T extends (...args: unknown[]) => unknown>(
|
function createMockFn<T extends (...args: unknown[]) => unknown>(
|
||||||
implementation?: T
|
implementation?: T
|
||||||
): MockFn<T> {
|
): MockFn<T> {
|
||||||
const calls: Parameters<T>[] = [];
|
const calls: Parameters<T>[] = [];
|
||||||
const mockFn = ((...args: Parameters<T>) => {
|
const mockFn = ((...args: Parameters<T>) => {
|
||||||
calls.push(args);
|
calls.push(args);
|
||||||
return implementation?.(...args);
|
return implementation?.(...args);
|
||||||
}) as unknown as MockFn<T>;
|
}) as unknown as MockFn<T>;
|
||||||
mockFn.calls = calls;
|
mockFn.calls = calls;
|
||||||
return mockFn;
|
return mockFn;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("WebSocketManager", () => {
|
describe("WebSocketManager", () => {
|
||||||
let mockLogger: Logger = undefined as unknown as Logger;
|
let mockLogger: Logger = undefined as unknown as Logger;
|
||||||
let mockSettings: Settings = undefined as unknown as Settings;
|
let mockSettings: Settings = undefined as unknown as Settings;
|
||||||
let deviceId = "test-device-123";
|
let deviceId = "test-device-123";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
deviceId = "test-device-123";
|
deviceId = "test-device-123";
|
||||||
const noop = (): void => {
|
const noop = (): void => {
|
||||||
// Intentionally empty for mock
|
// Intentionally empty for mock
|
||||||
};
|
};
|
||||||
mockLogger = {
|
mockLogger = {
|
||||||
info: createMockFn(noop),
|
info: createMockFn(noop),
|
||||||
warn: createMockFn(noop),
|
warn: createMockFn(noop),
|
||||||
error: createMockFn(noop),
|
error: createMockFn(noop),
|
||||||
debug: createMockFn(noop)
|
debug: createMockFn(noop)
|
||||||
} as unknown as Logger;
|
} as unknown as Logger;
|
||||||
|
|
||||||
mockSettings = {
|
mockSettings = {
|
||||||
getSettings: () => ({
|
getSettings: () => ({
|
||||||
remoteUri: "https://example.com",
|
remoteUri: "https://example.com",
|
||||||
vaultName: "test-vault",
|
vaultName: "test-vault",
|
||||||
webSocketRetryIntervalMs: 1000
|
webSocketRetryIntervalMs: 1000
|
||||||
})
|
})
|
||||||
} as unknown as Settings;
|
} as unknown as Settings;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cleans up promises after message handling", async () => {
|
it("cleans up promises after message handling", async () => {
|
||||||
const manager = new WebSocketManager(
|
const manager = new WebSocketManager(
|
||||||
deviceId,
|
deviceId,
|
||||||
mockLogger,
|
mockLogger,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
MockWebSocket as unknown as typeof WebSocket
|
MockWebSocket as unknown as typeof WebSocket
|
||||||
);
|
);
|
||||||
|
|
||||||
manager.onRemoteVaultUpdateReceived.add(async () => {
|
manager.onRemoteVaultUpdateReceived.add(async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
});
|
});
|
||||||
manager.start();
|
manager.start();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
const { outstandingPromises } = manager as unknown as {
|
const { outstandingPromises } = manager as unknown as {
|
||||||
outstandingPromises: Promise<unknown>[];
|
outstandingPromises: Promise<unknown>[];
|
||||||
};
|
};
|
||||||
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
||||||
.webSocket;
|
.webSocket;
|
||||||
|
|
||||||
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
|
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
|
||||||
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
|
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
|
||||||
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
|
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
assert.strictEqual(outstandingPromises.length, 0);
|
assert.strictEqual(outstandingPromises.length, 0);
|
||||||
await manager.stop();
|
await manager.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cleans up cursor position promises", async () => {
|
it("cleans up cursor position promises", async () => {
|
||||||
const manager = new WebSocketManager(
|
const manager = new WebSocketManager(
|
||||||
deviceId,
|
deviceId,
|
||||||
mockLogger,
|
mockLogger,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
MockWebSocket as unknown as typeof WebSocket
|
MockWebSocket as unknown as typeof WebSocket
|
||||||
);
|
);
|
||||||
|
|
||||||
manager.onRemoteCursorsUpdateReceived.add(async () => {
|
manager.onRemoteCursorsUpdateReceived.add(async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
});
|
});
|
||||||
manager.start();
|
manager.start();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
const { outstandingPromises } = manager as unknown as {
|
const { outstandingPromises } = manager as unknown as {
|
||||||
outstandingPromises: Promise<unknown>[];
|
outstandingPromises: Promise<unknown>[];
|
||||||
};
|
};
|
||||||
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
||||||
.webSocket;
|
.webSocket;
|
||||||
|
|
||||||
mockWs.simulateMessage({
|
mockWs.simulateMessage({
|
||||||
type: "cursorPositions",
|
type: "cursorPositions",
|
||||||
clients: [{ deviceId: "other-device", cursors: [] }]
|
clients: [{ deviceId: "other-device", cursors: [] }]
|
||||||
});
|
});
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
assert.strictEqual(outstandingPromises.length, 0);
|
assert.strictEqual(outstandingPromises.length, 0);
|
||||||
await manager.stop();
|
await manager.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs handshake send errors", async () => {
|
it("logs handshake send errors", async () => {
|
||||||
const manager = new WebSocketManager(
|
const manager = new WebSocketManager(
|
||||||
deviceId,
|
deviceId,
|
||||||
mockLogger,
|
mockLogger,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
MockWebSocket as unknown as typeof WebSocket
|
MockWebSocket as unknown as typeof WebSocket
|
||||||
);
|
);
|
||||||
|
|
||||||
manager.start();
|
manager.start();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
||||||
.webSocket;
|
.webSocket;
|
||||||
mockWs.send = (): void => {
|
mockWs.send = (): void => {
|
||||||
throw new Error("Buffer full");
|
throw new Error("Buffer full");
|
||||||
};
|
};
|
||||||
|
|
||||||
assert.throws(() => {
|
assert.throws(() => {
|
||||||
manager.sendHandshakeMessage({
|
manager.sendHandshakeMessage({
|
||||||
type: "handshake",
|
type: "handshake",
|
||||||
token: "test",
|
token: "test",
|
||||||
deviceId: "test",
|
deviceId: "test",
|
||||||
lastSeenVaultUpdateId: null
|
lastSeenVaultUpdateId: null
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await manager.stop();
|
await manager.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("completes stop with timeout protection", async () => {
|
it("completes stop with timeout protection", async () => {
|
||||||
const manager = new WebSocketManager(
|
const manager = new WebSocketManager(
|
||||||
deviceId,
|
deviceId,
|
||||||
mockLogger,
|
mockLogger,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
MockWebSocket as unknown as typeof WebSocket
|
MockWebSocket as unknown as typeof WebSocket
|
||||||
);
|
);
|
||||||
|
|
||||||
manager.start();
|
manager.start();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
await manager.stop();
|
await manager.stop();
|
||||||
assert.ok(true);
|
assert.ok(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears old handlers on reconnection", async () => {
|
it("clears old handlers on reconnection", async () => {
|
||||||
const manager = new WebSocketManager(
|
const manager = new WebSocketManager(
|
||||||
deviceId,
|
deviceId,
|
||||||
mockLogger,
|
mockLogger,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
MockWebSocket as unknown as typeof WebSocket
|
MockWebSocket as unknown as typeof WebSocket
|
||||||
);
|
);
|
||||||
|
|
||||||
let statusChangeCount = 0;
|
let statusChangeCount = 0;
|
||||||
manager.onWebSocketStatusChanged.add(() => {
|
manager.onWebSocketStatusChanged.add(() => {
|
||||||
statusChangeCount++;
|
statusChangeCount++;
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.start();
|
manager.start();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
const firstWs = (manager as unknown as { webSocket: MockWebSocket })
|
const firstWs = (manager as unknown as { webSocket: MockWebSocket })
|
||||||
.webSocket;
|
.webSocket;
|
||||||
|
|
||||||
statusChangeCount = 0;
|
statusChangeCount = 0;
|
||||||
|
|
||||||
(
|
(
|
||||||
manager as unknown as { initializeWebSocket: () => void }
|
manager as unknown as { initializeWebSocket: () => void }
|
||||||
).initializeWebSocket();
|
).initializeWebSocket();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
statusChangeCount = 0;
|
statusChangeCount = 0;
|
||||||
|
|
||||||
// Old handler should be cleared
|
// Old handler should be cleared
|
||||||
firstWs.onclose?.(
|
firstWs.onclose?.(
|
||||||
new MockCloseEvent("close", { code: 1000, reason: "test" })
|
new MockCloseEvent("close", { code: 1000, reason: "test" })
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.strictEqual(statusChangeCount, 0);
|
assert.strictEqual(statusChangeCount, 0);
|
||||||
await manager.stop();
|
await manager.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tracks message handling promises", async () => {
|
it("tracks message handling promises", async () => {
|
||||||
const manager = new WebSocketManager(
|
const manager = new WebSocketManager(
|
||||||
deviceId,
|
deviceId,
|
||||||
mockLogger,
|
mockLogger,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
MockWebSocket as unknown as typeof WebSocket
|
MockWebSocket as unknown as typeof WebSocket
|
||||||
);
|
);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/init-declarations
|
// eslint-disable-next-line @typescript-eslint/init-declarations
|
||||||
let resolveListener: () => void;
|
let resolveListener: () => void;
|
||||||
const listenerPromise = new Promise<void>((resolve) => {
|
const listenerPromise = new Promise<void>((resolve) => {
|
||||||
resolveListener = resolve;
|
resolveListener = resolve;
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.onRemoteVaultUpdateReceived.add(async () => {
|
manager.onRemoteVaultUpdateReceived.add(async () => {
|
||||||
await listenerPromise;
|
await listenerPromise;
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.start();
|
manager.start();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
||||||
.webSocket;
|
.webSocket;
|
||||||
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
|
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
const { outstandingPromises } = manager as unknown as {
|
const { outstandingPromises } = manager as unknown as {
|
||||||
outstandingPromises: Promise<unknown>[];
|
outstandingPromises: Promise<unknown>[];
|
||||||
};
|
};
|
||||||
|
|
||||||
assert.ok(outstandingPromises.length > 0);
|
assert.ok(outstandingPromises.length > 0);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
resolveListener!();
|
resolveListener!();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
assert.strictEqual(outstandingPromises.length, 0);
|
assert.strictEqual(outstandingPromises.length, 0);
|
||||||
await manager.stop();
|
await manager.stop();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -240,10 +240,10 @@ export class SyncClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reload settings from disk overriding current in-memory settings.
|
* Reload settings from disk overriding current in-memory settings.
|
||||||
* Missing values will be filled in from DEFAULT_SETTINGS rather than
|
* Missing values will be filled in from DEFAULT_SETTINGS rather than
|
||||||
* retaining current in-memory settings.
|
* retaining current in-memory settings.
|
||||||
*/
|
*/
|
||||||
public async reloadSettings(): Promise<void> {
|
public async reloadSettings(): Promise<void> {
|
||||||
this.checkIfDestroyed("reloadSettings");
|
this.checkIfDestroyed("reloadSettings");
|
||||||
|
|
||||||
|
|
@ -275,10 +275,10 @@ export class SyncClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for the in-flight operations to finish, reset all tracking,
|
* Wait for the in-flight operations to finish, reset all tracking,
|
||||||
* and the local database but retain the settings.
|
* and the local database but retain the settings.
|
||||||
* The SyncClient can be used again after calling this method.
|
* The SyncClient can be used again after calling this method.
|
||||||
*/
|
*/
|
||||||
public async reset(): Promise<void> {
|
public async reset(): Promise<void> {
|
||||||
this.checkIfDestroyed("reset");
|
this.checkIfDestroyed("reset");
|
||||||
|
|
||||||
|
|
@ -430,9 +430,9 @@ export class SyncClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Completely destroy the SyncClient, cancelling all in-progress operations.
|
* Completely destroy the SyncClient, cancelling all in-progress operations.
|
||||||
* After calling this method, the SyncClient cannot be used again.
|
* After calling this method, the SyncClient cannot be used again.
|
||||||
*/
|
*/
|
||||||
public async destroy(): Promise<void> {
|
public async destroy(): Promise<void> {
|
||||||
this.checkIfDestroyed("destroy");
|
this.checkIfDestroyed("destroy");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -479,10 +479,10 @@ export class Syncer {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create fake documents in the database for all files that are present locally
|
* Create fake documents in the database for all files that are present locally
|
||||||
* and also exist remotely. This will stop the subequent syncs from duplicating
|
* and also exist remotely. This will stop the subequent syncs from duplicating
|
||||||
* the documents by creating the same documents from multiple clients.
|
* the documents by creating the same documents from multiple clients.
|
||||||
*/
|
*/
|
||||||
private async createFakeDocumentsFromRemoteState(): Promise<void> {
|
private async createFakeDocumentsFromRemoteState(): Promise<void> {
|
||||||
if (this.database.getHasInitialSyncCompleted()) {
|
if (this.database.getHasInitialSyncCompleted()) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -88,11 +88,11 @@ export class SyncHistory {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert the entry at the beginning of the history list. If the entry
|
* Insert the entry at the beginning of the history list. If the entry
|
||||||
* already in the list, it will get moved to the beginning and updated.
|
* already in the list, it will get moved to the beginning and updated.
|
||||||
*
|
*
|
||||||
* If the entry list is too long, the oldest entry will be removed.
|
* If the entry list is too long, the oldest entry will be removed.
|
||||||
*/
|
*/
|
||||||
public addHistoryEntry(entry: CommonHistoryEntry): void {
|
public addHistoryEntry(entry: CommonHistoryEntry): void {
|
||||||
const historyEntry = {
|
const historyEntry = {
|
||||||
...entry,
|
...entry,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export enum DocumentSyncStatus {
|
export enum DocumentSyncStatus {
|
||||||
UP_TO_DATE = "UP_TO_DATE",
|
UP_TO_DATE = "UP_TO_DATE",
|
||||||
SYNCING = "SYNCING",
|
SYNCING = "SYNCING",
|
||||||
SYNCING_IS_DISABLED = "SYNCING_IS_DISABLED"
|
SYNCING_IS_DISABLED = "SYNCING_IS_DISABLED"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export enum DocumentUpToDateness {
|
export enum DocumentUpToDateness {
|
||||||
UpToDate = "UpToDate", // easiest case, the client can just show the cursors as-is
|
UpToDate = "UpToDate", // easiest case, the client can just show the cursors as-is
|
||||||
Prior = "Prior", // The cursors are outdated, so the client has to guess the cursor positions based on local updates. This is only possible if this client's cursor has once been up-to-date in a given document.
|
Prior = "Prior", // The cursors are outdated, so the client has to guess the cursor positions based on local updates. This is only possible if this client's cursor has once been up-to-date in a given document.
|
||||||
Later = "Later" // The cursors are from a future version of a document, there's no way we can accuratly show them locally.
|
Later = "Later" // The cursors are from a future version of a document, there's no way we can accuratly show them locally.
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { ClientCursors } from "../services/types/ClientCursors";
|
import type { ClientCursors } from "../services/types/ClientCursors";
|
||||||
|
|
||||||
export interface MaybeOutdatedClientCursors extends ClientCursors {
|
export interface MaybeOutdatedClientCursors extends ClientCursors {
|
||||||
isOutdated: boolean;
|
isOutdated: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export interface NetworkConnectionStatus {
|
export interface NetworkConnectionStatus {
|
||||||
isSuccessful: boolean;
|
isSuccessful: boolean;
|
||||||
serverMessage: string;
|
serverMessage: string;
|
||||||
isWebSocketConnected: boolean;
|
isWebSocketConnected: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import assert from "node:assert";
|
import assert from "node:assert";
|
||||||
|
|
||||||
export function assertSetContainsExactly<T>(set: Set<T>, ...values: T[]): void {
|
export function assertSetContainsExactly<T>(set: Set<T>, ...values: T[]): void {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
set.size === values.length &&
|
set.size === values.length &&
|
||||||
Array.from(set).every((value) => values.includes(value)),
|
Array.from(set).every((value) => values.includes(value)),
|
||||||
`Expected set to contain only ${values.map((v) => '"' + v + '"').join(", ")}, but it contained ${Array.from(
|
`Expected set to contain only ${values.map((v) => '"' + v + '"').join(", ")}, but it contained ${Array.from(
|
||||||
set
|
set
|
||||||
)
|
)
|
||||||
.map((v) => '"' + v + '"')
|
.map((v) => '"' + v + '"')
|
||||||
.join(", ")}`
|
.join(", ")}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,54 +3,54 @@ import assert from "node:assert";
|
||||||
import { awaitAll } from "./await-all";
|
import { awaitAll } from "./await-all";
|
||||||
|
|
||||||
void test("awaitAll resolves promises of the same type", async () => {
|
void test("awaitAll resolves promises of the same type", async () => {
|
||||||
const promises = [
|
const promises = [
|
||||||
Promise.resolve(1),
|
Promise.resolve(1),
|
||||||
Promise.resolve(2),
|
Promise.resolve(2),
|
||||||
Promise.resolve(3)
|
Promise.resolve(3)
|
||||||
];
|
];
|
||||||
|
|
||||||
const results = await awaitAll(promises);
|
const results = await awaitAll(promises);
|
||||||
assert.deepStrictEqual(results, [1, 2, 3]);
|
assert.deepStrictEqual(results, [1, 2, 3]);
|
||||||
});
|
});
|
||||||
|
|
||||||
void test("awaitAll resolves promises of different types", async () => {
|
void test("awaitAll resolves promises of different types", async () => {
|
||||||
const promises = [
|
const promises = [
|
||||||
Promise.resolve("hello"),
|
Promise.resolve("hello"),
|
||||||
Promise.resolve(42),
|
Promise.resolve(42),
|
||||||
Promise.resolve(true)
|
Promise.resolve(true)
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const results = await awaitAll(promises);
|
const results = await awaitAll(promises);
|
||||||
|
|
||||||
// Type assertions to verify type inference
|
// Type assertions to verify type inference
|
||||||
const str: string = results[0];
|
const str: string = results[0];
|
||||||
const num: number = results[1];
|
const num: number = results[1];
|
||||||
const bool: boolean = results[2];
|
const bool: boolean = results[2];
|
||||||
|
|
||||||
assert.strictEqual(str, "hello");
|
assert.strictEqual(str, "hello");
|
||||||
assert.strictEqual(num, 42);
|
assert.strictEqual(num, 42);
|
||||||
assert.strictEqual(bool, true);
|
assert.strictEqual(bool, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
void test("awaitAll throws on first rejection", async () => {
|
void test("awaitAll throws on first rejection", async () => {
|
||||||
const error = new Error("Test error");
|
const error = new Error("Test error");
|
||||||
const promises = [
|
const promises = [
|
||||||
Promise.resolve(1),
|
Promise.resolve(1),
|
||||||
Promise.reject(error),
|
Promise.reject(error),
|
||||||
Promise.resolve(3)
|
Promise.resolve(3)
|
||||||
];
|
];
|
||||||
|
|
||||||
await assert.rejects(async () => {
|
await assert.rejects(async () => {
|
||||||
await awaitAll(promises);
|
await awaitAll(promises);
|
||||||
}, error);
|
}, error);
|
||||||
});
|
});
|
||||||
|
|
||||||
void test("awaitAll works with async functions", async () => {
|
void test("awaitAll works with async functions", async () => {
|
||||||
const asyncString = async (): Promise<string> => "async";
|
const asyncString = async (): Promise<string> => "async";
|
||||||
const asyncNumber = async (): Promise<number> => 123;
|
const asyncNumber = async (): Promise<number> => 123;
|
||||||
|
|
||||||
const results = await awaitAll([asyncString(), asyncNumber()]);
|
const results = await awaitAll([asyncString(), asyncNumber()]);
|
||||||
|
|
||||||
assert.strictEqual(results[0], "async");
|
assert.strictEqual(results[0], "async");
|
||||||
assert.strictEqual(results[1], 123);
|
assert.strictEqual(results[1], 123);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,25 @@
|
||||||
type PromiseTuple<T extends readonly unknown[]> = readonly [
|
type PromiseTuple<T extends readonly unknown[]> = readonly [
|
||||||
...{ [K in keyof T]: Promise<T[K]> }
|
...{ [K in keyof T]: Promise<T[K]> }
|
||||||
];
|
];
|
||||||
|
|
||||||
type ResolvedTuple<T extends readonly unknown[]> = {
|
type ResolvedTuple<T extends readonly unknown[]> = {
|
||||||
[K in keyof T]: T[K];
|
[K in keyof T]: T[K];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const awaitAll = async <T extends readonly unknown[]>(
|
export const awaitAll = async <T extends readonly unknown[]>(
|
||||||
promises: PromiseTuple<T>
|
promises: PromiseTuple<T>
|
||||||
): Promise<ResolvedTuple<T>> => {
|
): Promise<ResolvedTuple<T>> => {
|
||||||
// eslint-disable-next-line no-restricted-properties
|
// eslint-disable-next-line no-restricted-properties
|
||||||
const result = await Promise.allSettled(promises);
|
const result = await Promise.allSettled(promises);
|
||||||
for (const res of result) {
|
for (const res of result) {
|
||||||
if (res.status === "rejected") {
|
if (res.status === "rejected") {
|
||||||
throw res.reason;
|
throw res.reason;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
return result.map(
|
return result.map(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
(res) => (res as PromiseFulfilledResult<unknown>).value
|
(res) => (res as PromiseFulfilledResult<unknown>).value
|
||||||
) as ResolvedTuple<T>;
|
) as ResolvedTuple<T>;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
export function createClientId(): string {
|
export function createClientId(): string {
|
||||||
// @ts-expect-error, injected by webpack
|
// @ts-expect-error, injected by webpack
|
||||||
const packageVersion = __CURRENT_VERSION__; // eslint-disable-line
|
const packageVersion = __CURRENT_VERSION__; // eslint-disable-line
|
||||||
|
|
||||||
const platform =
|
const platform =
|
||||||
typeof navigator !== "undefined"
|
typeof navigator !== "undefined"
|
||||||
? navigator.platform // eslint-disable-line @typescript-eslint/no-deprecated
|
? navigator.platform // eslint-disable-line @typescript-eslint/no-deprecated
|
||||||
: typeof process !== "undefined"
|
: typeof process !== "undefined"
|
||||||
? process.platform
|
? process.platform
|
||||||
: "unknown";
|
: "unknown";
|
||||||
|
|
||||||
return `vault-link/${packageVersion} (${uuidv4()}; ${platform})`;
|
return `vault-link/${packageVersion} (${uuidv4()}; ${platform})`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,25 @@
|
||||||
type ResolveFunction<T> = undefined extends T
|
type ResolveFunction<T> = undefined extends T
|
||||||
? (value?: T) => unknown
|
? (value?: T) => unknown
|
||||||
: (value: T) => unknown;
|
: (value: T) => unknown;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A type-safe utility function to create a Promise with resolve and reject functions.
|
* A type-safe utility function to create a Promise with resolve and reject functions.
|
||||||
* @returns A tuple containing a Promise, a resolve function, and a reject function.
|
* @returns A tuple containing a Promise, a resolve function, and a reject function.
|
||||||
*/
|
*/
|
||||||
export function createPromise<T = unknown>(): [
|
export function createPromise<T = unknown>(): [
|
||||||
Promise<T>,
|
Promise<T>,
|
||||||
ResolveFunction<T>,
|
ResolveFunction<T>,
|
||||||
(error: unknown) => unknown
|
(error: unknown) => unknown
|
||||||
] {
|
] {
|
||||||
let resolve: undefined | ResolveFunction<T> = undefined;
|
let resolve: undefined | ResolveFunction<T> = undefined;
|
||||||
let reject: undefined | ((error: unknown) => unknown) = undefined;
|
let reject: undefined | ((error: unknown) => unknown) = undefined;
|
||||||
|
|
||||||
const creationPromise = new Promise<T>(
|
const creationPromise = new Promise<T>(
|
||||||
(resolve_, reject_) =>
|
(resolve_, reject_) =>
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
((resolve = resolve_ as ResolveFunction<T>), (reject = reject_))
|
((resolve = resolve_ as ResolveFunction<T>), (reject = reject_))
|
||||||
);
|
);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
return [creationPromise, resolve!, reject!];
|
return [creationPromise, resolve!, reject!];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,32 +8,32 @@ export class EventListeners<TListener extends (...args: any[]) => any> {
|
||||||
private readonly listeners: TListener[] = [];
|
private readonly listeners: TListener[] = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a new listener to the collection.
|
* Adds a new listener to the collection.
|
||||||
*
|
*
|
||||||
* @param listener The listener callback to add
|
* @param listener The listener callback to add
|
||||||
* @returns An unsubscribe function that removes this listener when called
|
* @returns An unsubscribe function that removes this listener when called
|
||||||
*/
|
*/
|
||||||
public add(listener: TListener): () => void {
|
public add(listener: TListener): () => void {
|
||||||
this.listeners.push(listener);
|
this.listeners.push(listener);
|
||||||
return () => this.remove(listener);
|
return () => this.remove(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes a listener from the collection.
|
* Removes a listener from the collection.
|
||||||
*
|
*
|
||||||
* @param listener The listener callback to remove
|
* @param listener The listener callback to remove
|
||||||
* @returns true if the listener was found and removed, false otherwise
|
* @returns true if the listener was found and removed, false otherwise
|
||||||
*/
|
*/
|
||||||
public remove(listener: TListener): boolean {
|
public remove(listener: TListener): boolean {
|
||||||
return removeFromArray(this.listeners, listener);
|
return removeFromArray(this.listeners, listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Triggers all listeners synchronously with the provided arguments.
|
* Triggers all listeners synchronously with the provided arguments.
|
||||||
* Any returned promises are ignored. Use triggerAsync() to await them.
|
* Any returned promises are ignored. Use triggerAsync() to await them.
|
||||||
*
|
*
|
||||||
* @param args The arguments to pass to each listener
|
* @param args The arguments to pass to each listener
|
||||||
*/
|
*/
|
||||||
public trigger(...args: Parameters<TListener>): void {
|
public trigger(...args: Parameters<TListener>): void {
|
||||||
this.listeners.forEach((listener) => {
|
this.listeners.forEach((listener) => {
|
||||||
listener(...args);
|
listener(...args);
|
||||||
|
|
@ -41,12 +41,12 @@ export class EventListeners<TListener extends (...args: any[]) => any> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Triggers all listeners and awaits any promises they return.
|
* Triggers all listeners and awaits any promises they return.
|
||||||
* Synchronous listeners are called immediately, and any async listeners
|
* Synchronous listeners are called immediately, and any async listeners
|
||||||
* are awaited in parallel.
|
* are awaited in parallel.
|
||||||
*
|
*
|
||||||
* @param args The arguments to pass to each listener
|
* @param args The arguments to pass to each listener
|
||||||
*/
|
*/
|
||||||
public async triggerAsync(...args: Parameters<TListener>): Promise<void> {
|
public async triggerAsync(...args: Parameters<TListener>): Promise<void> {
|
||||||
await awaitAll(
|
await awaitAll(
|
||||||
this.listeners
|
this.listeners
|
||||||
|
|
|
||||||
|
|
@ -3,273 +3,273 @@ import assert from "node:assert";
|
||||||
import { FixedSizeDocumentCache } from "./fix-sized-cache";
|
import { FixedSizeDocumentCache } from "./fix-sized-cache";
|
||||||
|
|
||||||
describe("fixedSizeDocumentCache", () => {
|
describe("fixedSizeDocumentCache", () => {
|
||||||
it("happyPath", async () => {
|
it("happyPath", async () => {
|
||||||
const cache = new FixedSizeDocumentCache(4);
|
const cache = new FixedSizeDocumentCache(4);
|
||||||
const doc1 = new Uint8Array([1, 2]);
|
const doc1 = new Uint8Array([1, 2]);
|
||||||
const doc2 = new Uint8Array([3, 4]);
|
const doc2 = new Uint8Array([3, 4]);
|
||||||
const doc3 = new Uint8Array([5, 6]);
|
const doc3 = new Uint8Array([5, 6]);
|
||||||
|
|
||||||
cache.put(1, doc1);
|
cache.put(1, doc1);
|
||||||
assert.equal(cache.get(1), doc1);
|
assert.equal(cache.get(1), doc1);
|
||||||
cache.put(2, doc2);
|
cache.put(2, doc2);
|
||||||
assert.equal(cache.get(1), doc1);
|
assert.equal(cache.get(1), doc1);
|
||||||
assert.equal(cache.get(2), doc2);
|
assert.equal(cache.get(2), doc2);
|
||||||
cache.put(3, doc3);
|
cache.put(3, doc3);
|
||||||
assert.equal(cache.get(1), undefined);
|
assert.equal(cache.get(1), undefined);
|
||||||
assert.equal(cache.get(2), doc2);
|
assert.equal(cache.get(2), doc2);
|
||||||
assert.equal(cache.get(3), doc3);
|
assert.equal(cache.get(3), doc3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updateExistingEntry", async () => {
|
it("updateExistingEntry", async () => {
|
||||||
const cache = new FixedSizeDocumentCache(4);
|
const cache = new FixedSizeDocumentCache(4);
|
||||||
const doc1_v1 = new Uint8Array([1, 2]);
|
const doc1_v1 = new Uint8Array([1, 2]);
|
||||||
const doc1_v2 = new Uint8Array([3, 4]);
|
const doc1_v2 = new Uint8Array([3, 4]);
|
||||||
const doc2 = new Uint8Array([5, 6]);
|
const doc2 = new Uint8Array([5, 6]);
|
||||||
|
|
||||||
cache.put(1, doc1_v1);
|
cache.put(1, doc1_v1);
|
||||||
assert.equal(cache.get(1), doc1_v1);
|
assert.equal(cache.get(1), doc1_v1);
|
||||||
cache.put(2, doc2);
|
cache.put(2, doc2);
|
||||||
assert.equal(cache.get(1), doc1_v1);
|
assert.equal(cache.get(1), doc1_v1);
|
||||||
assert.equal(cache.get(2), doc2);
|
assert.equal(cache.get(2), doc2);
|
||||||
cache.put(1, doc1_v2); // Update doc1
|
cache.put(1, doc1_v2); // Update doc1
|
||||||
assert.equal(cache.get(1), doc1_v2);
|
assert.equal(cache.get(1), doc1_v2);
|
||||||
assert.equal(cache.get(2), doc2);
|
assert.equal(cache.get(2), doc2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("evictOldestEntry", async () => {
|
it("evictOldestEntry", async () => {
|
||||||
const cache = new FixedSizeDocumentCache(4);
|
const cache = new FixedSizeDocumentCache(4);
|
||||||
const doc1 = new Uint8Array([1, 2]);
|
const doc1 = new Uint8Array([1, 2]);
|
||||||
const doc2 = new Uint8Array([3, 4]);
|
const doc2 = new Uint8Array([3, 4]);
|
||||||
const doc3 = new Uint8Array([5, 6]);
|
const doc3 = new Uint8Array([5, 6]);
|
||||||
|
|
||||||
cache.put(1, doc1);
|
cache.put(1, doc1);
|
||||||
cache.put(2, doc2);
|
cache.put(2, doc2);
|
||||||
assert.equal(cache.get(2), doc2);
|
assert.equal(cache.get(2), doc2);
|
||||||
assert.equal(cache.get(1), doc1);
|
assert.equal(cache.get(1), doc1);
|
||||||
cache.put(3, doc3);
|
cache.put(3, doc3);
|
||||||
assert.equal(cache.get(1), doc1);
|
assert.equal(cache.get(1), doc1);
|
||||||
assert.equal(cache.get(2), undefined);
|
assert.equal(cache.get(2), undefined);
|
||||||
assert.equal(cache.get(3), doc3);
|
assert.equal(cache.get(3), doc3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tooLargeEntry", async () => {
|
it("tooLargeEntry", async () => {
|
||||||
const cache = new FixedSizeDocumentCache(2);
|
const cache = new FixedSizeDocumentCache(2);
|
||||||
const doc1 = new Uint8Array([1, 2, 3]);
|
const doc1 = new Uint8Array([1, 2, 3]);
|
||||||
|
|
||||||
cache.put(1, doc1);
|
cache.put(1, doc1);
|
||||||
assert.equal(cache.get(1), undefined);
|
assert.equal(cache.get(1), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("multipleEvictionsInSinglePut", async () => {
|
it("multipleEvictionsInSinglePut", async () => {
|
||||||
const cache = new FixedSizeDocumentCache(10);
|
const cache = new FixedSizeDocumentCache(10);
|
||||||
const doc1 = new Uint8Array([1, 2]);
|
const doc1 = new Uint8Array([1, 2]);
|
||||||
const doc2 = new Uint8Array([3, 4]);
|
const doc2 = new Uint8Array([3, 4]);
|
||||||
const doc3 = new Uint8Array([5, 6]);
|
const doc3 = new Uint8Array([5, 6]);
|
||||||
const doc4 = new Uint8Array([7, 8, 9, 10, 11, 12, 13, 14]); // 8 bytes
|
const doc4 = new Uint8Array([7, 8, 9, 10, 11, 12, 13, 14]); // 8 bytes
|
||||||
|
|
||||||
cache.put(1, doc1);
|
cache.put(1, doc1);
|
||||||
cache.put(2, doc2);
|
cache.put(2, doc2);
|
||||||
cache.put(3, doc3);
|
cache.put(3, doc3);
|
||||||
// Cache now has 6 bytes total
|
// Cache now has 6 bytes total
|
||||||
|
|
||||||
cache.put(4, doc4); // Should evict doc1 and doc2 to make room (total: 2+8=10)
|
cache.put(4, doc4); // Should evict doc1 and doc2 to make room (total: 2+8=10)
|
||||||
assert.equal(cache.get(1), undefined); // Evicted
|
assert.equal(cache.get(1), undefined); // Evicted
|
||||||
assert.equal(cache.get(2), undefined); // Evicted
|
assert.equal(cache.get(2), undefined); // Evicted
|
||||||
assert.equal(cache.get(3), doc3); // Still present
|
assert.equal(cache.get(3), doc3); // Still present
|
||||||
assert.equal(cache.get(4), doc4);
|
assert.equal(cache.get(4), doc4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clearCache", async () => {
|
it("clearCache", async () => {
|
||||||
const cache = new FixedSizeDocumentCache(10);
|
const cache = new FixedSizeDocumentCache(10);
|
||||||
const doc1 = new Uint8Array([1, 2]);
|
const doc1 = new Uint8Array([1, 2]);
|
||||||
const doc2 = new Uint8Array([3, 4]);
|
const doc2 = new Uint8Array([3, 4]);
|
||||||
|
|
||||||
cache.put(1, doc1);
|
cache.put(1, doc1);
|
||||||
cache.put(2, doc2);
|
cache.put(2, doc2);
|
||||||
assert.equal(cache.get(1), doc1);
|
assert.equal(cache.get(1), doc1);
|
||||||
assert.equal(cache.get(2), doc2);
|
assert.equal(cache.get(2), doc2);
|
||||||
|
|
||||||
cache.reset();
|
cache.reset();
|
||||||
assert.equal(cache.get(1), undefined);
|
assert.equal(cache.get(1), undefined);
|
||||||
assert.equal(cache.get(2), undefined);
|
assert.equal(cache.get(2), undefined);
|
||||||
|
|
||||||
// Should be able to add entries after clear
|
// Should be able to add entries after clear
|
||||||
cache.put(3, doc1);
|
cache.put(3, doc1);
|
||||||
assert.equal(cache.get(3), doc1);
|
assert.equal(cache.get(3), doc1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("getNonExistentKey", async () => {
|
it("getNonExistentKey", async () => {
|
||||||
const cache = new FixedSizeDocumentCache(10);
|
const cache = new FixedSizeDocumentCache(10);
|
||||||
const doc1 = new Uint8Array([1, 2]);
|
const doc1 = new Uint8Array([1, 2]);
|
||||||
cache.put(1, doc1);
|
cache.put(1, doc1);
|
||||||
assert.equal(cache.get(999), undefined);
|
assert.equal(cache.get(999), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updateEntryWithDifferentSizeTriggeringEviction", async () => {
|
it("updateEntryWithDifferentSizeTriggeringEviction", async () => {
|
||||||
const cache = new FixedSizeDocumentCache(6);
|
const cache = new FixedSizeDocumentCache(6);
|
||||||
const doc1_v1 = new Uint8Array([1, 2]);
|
const doc1_v1 = new Uint8Array([1, 2]);
|
||||||
const doc1_v2 = new Uint8Array([1, 2, 3, 4]); // Larger version
|
const doc1_v2 = new Uint8Array([1, 2, 3, 4]); // Larger version
|
||||||
const doc2 = new Uint8Array([5, 6]);
|
const doc2 = new Uint8Array([5, 6]);
|
||||||
const doc3 = new Uint8Array([7, 8]);
|
const doc3 = new Uint8Array([7, 8]);
|
||||||
|
|
||||||
cache.put(1, doc1_v1);
|
cache.put(1, doc1_v1);
|
||||||
cache.put(2, doc2);
|
cache.put(2, doc2);
|
||||||
cache.put(3, doc3);
|
cache.put(3, doc3);
|
||||||
|
|
||||||
// Update doc1 with larger version, should evict doc2
|
// Update doc1 with larger version, should evict doc2
|
||||||
cache.put(1, doc1_v2);
|
cache.put(1, doc1_v2);
|
||||||
|
|
||||||
assert.equal(cache.get(1), doc1_v2);
|
assert.equal(cache.get(1), doc1_v2);
|
||||||
assert.equal(cache.get(2), undefined); // Evicted
|
assert.equal(cache.get(2), undefined); // Evicted
|
||||||
assert.equal(cache.get(3), doc3);
|
assert.equal(cache.get(3), doc3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("singleItemCache", async () => {
|
it("singleItemCache", async () => {
|
||||||
const cache = new FixedSizeDocumentCache(2);
|
const cache = new FixedSizeDocumentCache(2);
|
||||||
const doc1 = new Uint8Array([1, 2]);
|
const doc1 = new Uint8Array([1, 2]);
|
||||||
const doc2 = new Uint8Array([3, 4]);
|
const doc2 = new Uint8Array([3, 4]);
|
||||||
|
|
||||||
cache.put(1, doc1);
|
cache.put(1, doc1);
|
||||||
assert.equal(cache.get(1), doc1);
|
assert.equal(cache.get(1), doc1);
|
||||||
|
|
||||||
cache.put(2, doc2);
|
cache.put(2, doc2);
|
||||||
assert.equal(cache.get(1), undefined); // Evicted
|
assert.equal(cache.get(1), undefined); // Evicted
|
||||||
assert.equal(cache.get(2), doc2);
|
assert.equal(cache.get(2), doc2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("multipleGetsOnSameEntry", async () => {
|
it("multipleGetsOnSameEntry", async () => {
|
||||||
const cache = new FixedSizeDocumentCache(4);
|
const cache = new FixedSizeDocumentCache(4);
|
||||||
const doc1 = new Uint8Array([1, 2]);
|
const doc1 = new Uint8Array([1, 2]);
|
||||||
const doc2 = new Uint8Array([3, 4]);
|
const doc2 = new Uint8Array([3, 4]);
|
||||||
const doc3 = new Uint8Array([5, 6]);
|
const doc3 = new Uint8Array([5, 6]);
|
||||||
|
|
||||||
cache.put(1, doc1);
|
cache.put(1, doc1);
|
||||||
cache.put(2, doc2);
|
cache.put(2, doc2);
|
||||||
|
|
||||||
// Multiple gets on doc1
|
// Multiple gets on doc1
|
||||||
cache.get(1);
|
cache.get(1);
|
||||||
cache.get(1);
|
cache.get(1);
|
||||||
cache.get(1);
|
cache.get(1);
|
||||||
|
|
||||||
// Order should be: 2 (LRU), 1 (MRU)
|
// Order should be: 2 (LRU), 1 (MRU)
|
||||||
cache.put(3, doc3);
|
cache.put(3, doc3);
|
||||||
|
|
||||||
assert.equal(cache.get(1), doc1);
|
assert.equal(cache.get(1), doc1);
|
||||||
assert.equal(cache.get(2), undefined); // Evicted
|
assert.equal(cache.get(2), undefined); // Evicted
|
||||||
assert.equal(cache.get(3), doc3);
|
assert.equal(cache.get(3), doc3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exactlySizedEntry", async () => {
|
it("exactlySizedEntry", async () => {
|
||||||
const cache = new FixedSizeDocumentCache(4);
|
const cache = new FixedSizeDocumentCache(4);
|
||||||
const doc1 = new Uint8Array([1, 2, 3, 4]); // Exactly cache size
|
const doc1 = new Uint8Array([1, 2, 3, 4]); // Exactly cache size
|
||||||
|
|
||||||
cache.put(1, doc1);
|
cache.put(1, doc1);
|
||||||
assert.equal(cache.get(1), doc1);
|
assert.equal(cache.get(1), doc1);
|
||||||
|
|
||||||
const doc2 = new Uint8Array([5, 6]);
|
const doc2 = new Uint8Array([5, 6]);
|
||||||
cache.put(2, doc2);
|
cache.put(2, doc2);
|
||||||
|
|
||||||
// doc1 should be evicted to make room for doc2
|
// doc1 should be evicted to make room for doc2
|
||||||
assert.equal(cache.get(1), undefined);
|
assert.equal(cache.get(1), undefined);
|
||||||
assert.equal(cache.get(2), doc2);
|
assert.equal(cache.get(2), doc2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updateEntryMakesItMostRecent", async () => {
|
it("updateEntryMakesItMostRecent", async () => {
|
||||||
const cache = new FixedSizeDocumentCache(6);
|
const cache = new FixedSizeDocumentCache(6);
|
||||||
const doc1_v1 = new Uint8Array([1, 2]);
|
const doc1_v1 = new Uint8Array([1, 2]);
|
||||||
const doc1_v2 = new Uint8Array([3, 4]);
|
const doc1_v2 = new Uint8Array([3, 4]);
|
||||||
const doc2 = new Uint8Array([5, 6]);
|
const doc2 = new Uint8Array([5, 6]);
|
||||||
const doc3 = new Uint8Array([7, 8]);
|
const doc3 = new Uint8Array([7, 8]);
|
||||||
const doc4 = new Uint8Array([9, 10]);
|
const doc4 = new Uint8Array([9, 10]);
|
||||||
|
|
||||||
cache.put(1, doc1_v1);
|
cache.put(1, doc1_v1);
|
||||||
cache.put(2, doc2);
|
cache.put(2, doc2);
|
||||||
cache.put(3, doc3);
|
cache.put(3, doc3);
|
||||||
|
|
||||||
// Update doc1 (should move it to most recent)
|
// Update doc1 (should move it to most recent)
|
||||||
cache.put(1, doc1_v2);
|
cache.put(1, doc1_v2);
|
||||||
|
|
||||||
// Order should be: 2 (LRU), 3, 1 (MRU)
|
// Order should be: 2 (LRU), 3, 1 (MRU)
|
||||||
// Adding doc4 should evict doc2
|
// Adding doc4 should evict doc2
|
||||||
cache.put(4, doc4);
|
cache.put(4, doc4);
|
||||||
|
|
||||||
assert.equal(cache.get(1), doc1_v2);
|
assert.equal(cache.get(1), doc1_v2);
|
||||||
assert.equal(cache.get(2), undefined); // Evicted
|
assert.equal(cache.get(2), undefined); // Evicted
|
||||||
assert.equal(cache.get(3), doc3);
|
assert.equal(cache.get(3), doc3);
|
||||||
assert.equal(cache.get(4), doc4);
|
assert.equal(cache.get(4), doc4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("alternatingAccessPattern", async () => {
|
it("alternatingAccessPattern", async () => {
|
||||||
const cache = new FixedSizeDocumentCache(4);
|
const cache = new FixedSizeDocumentCache(4);
|
||||||
const doc1 = new Uint8Array([1, 2]);
|
const doc1 = new Uint8Array([1, 2]);
|
||||||
const doc2 = new Uint8Array([3, 4]);
|
const doc2 = new Uint8Array([3, 4]);
|
||||||
const doc3 = new Uint8Array([5, 6]);
|
const doc3 = new Uint8Array([5, 6]);
|
||||||
|
|
||||||
cache.put(1, doc1);
|
cache.put(1, doc1);
|
||||||
cache.put(2, doc2);
|
cache.put(2, doc2);
|
||||||
|
|
||||||
// Alternate access between doc1 and doc2
|
// Alternate access between doc1 and doc2
|
||||||
cache.get(1);
|
cache.get(1);
|
||||||
cache.get(2);
|
cache.get(2);
|
||||||
cache.get(1);
|
cache.get(1);
|
||||||
cache.get(2);
|
cache.get(2);
|
||||||
|
|
||||||
// Order should be: 1, 2 (MRU)
|
// Order should be: 1, 2 (MRU)
|
||||||
cache.put(3, doc3);
|
cache.put(3, doc3);
|
||||||
|
|
||||||
assert.equal(cache.get(1), undefined); // Evicted
|
assert.equal(cache.get(1), undefined); // Evicted
|
||||||
assert.equal(cache.get(2), doc2);
|
assert.equal(cache.get(2), doc2);
|
||||||
assert.equal(cache.get(3), doc3);
|
assert.equal(cache.get(3), doc3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("zeroByteDocs", async () => {
|
it("zeroByteDocs", async () => {
|
||||||
const cache = new FixedSizeDocumentCache(2);
|
const cache = new FixedSizeDocumentCache(2);
|
||||||
const doc1 = new Uint8Array([]);
|
const doc1 = new Uint8Array([]);
|
||||||
const doc2 = new Uint8Array([]);
|
const doc2 = new Uint8Array([]);
|
||||||
const doc3 = new Uint8Array([1, 2]);
|
const doc3 = new Uint8Array([1, 2]);
|
||||||
|
|
||||||
cache.put(1, doc1);
|
cache.put(1, doc1);
|
||||||
cache.put(2, doc2);
|
cache.put(2, doc2);
|
||||||
cache.put(3, doc3);
|
cache.put(3, doc3);
|
||||||
|
|
||||||
assert.equal(cache.get(1), doc1);
|
assert.equal(cache.get(1), doc1);
|
||||||
assert.equal(cache.get(2), doc2);
|
assert.equal(cache.get(2), doc2);
|
||||||
assert.equal(cache.get(3), doc3);
|
assert.equal(cache.get(3), doc3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resizeToLargerSizeNoEviction", async () => {
|
it("resizeToLargerSizeNoEviction", async () => {
|
||||||
const cache = new FixedSizeDocumentCache(4);
|
const cache = new FixedSizeDocumentCache(4);
|
||||||
const doc1 = new Uint8Array([1, 2]);
|
const doc1 = new Uint8Array([1, 2]);
|
||||||
const doc2 = new Uint8Array([3, 4]);
|
const doc2 = new Uint8Array([3, 4]);
|
||||||
|
|
||||||
cache.put(1, doc1);
|
cache.put(1, doc1);
|
||||||
cache.put(2, doc2);
|
cache.put(2, doc2);
|
||||||
|
|
||||||
cache.resize(10);
|
cache.resize(10);
|
||||||
|
|
||||||
assert.equal(cache.get(1), doc1);
|
assert.equal(cache.get(1), doc1);
|
||||||
assert.equal(cache.get(2), doc2);
|
assert.equal(cache.get(2), doc2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resizeCausesMultipleEvictions", async () => {
|
it("resizeCausesMultipleEvictions", async () => {
|
||||||
const cache = new FixedSizeDocumentCache(10);
|
const cache = new FixedSizeDocumentCache(10);
|
||||||
const doc1 = new Uint8Array([1, 2]);
|
const doc1 = new Uint8Array([1, 2]);
|
||||||
const doc2 = new Uint8Array([3, 4]);
|
const doc2 = new Uint8Array([3, 4]);
|
||||||
const doc3 = new Uint8Array([5, 6]);
|
const doc3 = new Uint8Array([5, 6]);
|
||||||
const doc4 = new Uint8Array([7, 8]);
|
const doc4 = new Uint8Array([7, 8]);
|
||||||
|
|
||||||
cache.put(1, doc1);
|
cache.put(1, doc1);
|
||||||
cache.put(2, doc2);
|
cache.put(2, doc2);
|
||||||
cache.put(3, doc3);
|
cache.put(3, doc3);
|
||||||
cache.put(4, doc4);
|
cache.put(4, doc4);
|
||||||
// Cache has 8 bytes total
|
// Cache has 8 bytes total
|
||||||
|
|
||||||
cache.resize(2);
|
cache.resize(2);
|
||||||
|
|
||||||
// Should evict doc1, doc2, doc3 to get down to 2 bytes
|
// Should evict doc1, doc2, doc3 to get down to 2 bytes
|
||||||
assert.equal(cache.get(1), undefined);
|
assert.equal(cache.get(1), undefined);
|
||||||
assert.equal(cache.get(2), undefined);
|
assert.equal(cache.get(2), undefined);
|
||||||
assert.equal(cache.get(3), undefined);
|
assert.equal(cache.get(3), undefined);
|
||||||
assert.equal(cache.get(4), doc4);
|
assert.equal(cache.get(4), doc4);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,116 +4,116 @@ import type { VaultUpdateId } from "../../persistence/database";
|
||||||
|
|
||||||
// Doubly-linked list node for O(1) LRU operations
|
// Doubly-linked list node for O(1) LRU operations
|
||||||
class LRUNode {
|
class LRUNode {
|
||||||
public constructor(
|
public constructor(
|
||||||
public key: VaultUpdateId,
|
public key: VaultUpdateId,
|
||||||
public value: Uint8Array,
|
public value: Uint8Array,
|
||||||
public prev: LRUNode | null = null,
|
public prev: LRUNode | null = null,
|
||||||
public next: LRUNode | null = null
|
public next: LRUNode | null = null
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// evicting the least recently used documents when the size limit is exceeded.
|
// evicting the least recently used documents when the size limit is exceeded.
|
||||||
export class FixedSizeDocumentCache {
|
export class FixedSizeDocumentCache {
|
||||||
private currentSizeInBytes: number;
|
private currentSizeInBytes: number;
|
||||||
private readonly cache: Map<VaultUpdateId, LRUNode>;
|
private readonly cache: Map<VaultUpdateId, LRUNode>;
|
||||||
private head: LRUNode | null; // Least recently used
|
private head: LRUNode | null; // Least recently used
|
||||||
private tail: LRUNode | null; // Most recently used
|
private tail: LRUNode | null; // Most recently used
|
||||||
|
|
||||||
public constructor(private maxSizeInBytes: number) {
|
public constructor(private maxSizeInBytes: number) {
|
||||||
this.currentSizeInBytes = 0;
|
this.currentSizeInBytes = 0;
|
||||||
this.cache = new Map();
|
this.cache = new Map();
|
||||||
this.head = null;
|
this.head = null;
|
||||||
this.tail = null;
|
this.tail = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get(updateId: VaultUpdateId): Uint8Array | undefined {
|
public get(updateId: VaultUpdateId): Uint8Array | undefined {
|
||||||
const node = this.cache.get(updateId);
|
const node = this.cache.get(updateId);
|
||||||
if (node) {
|
if (node) {
|
||||||
this.moveToTail(node);
|
this.moveToTail(node);
|
||||||
return node.value;
|
return node.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
public put(updateId: VaultUpdateId, content: Uint8Array): void {
|
public put(updateId: VaultUpdateId, content: Uint8Array): void {
|
||||||
if (content.byteLength > this.maxSizeInBytes) {
|
if (content.byteLength > this.maxSizeInBytes) {
|
||||||
// Document is too large to fit in the cache
|
// Document is too large to fit in the cache
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the document is already in the cache, update it
|
// If the document is already in the cache, update it
|
||||||
const existingNode = this.cache.get(updateId);
|
const existingNode = this.cache.get(updateId);
|
||||||
if (existingNode != null) {
|
if (existingNode != null) {
|
||||||
this.currentSizeInBytes -= existingNode.value.byteLength;
|
this.currentSizeInBytes -= existingNode.value.byteLength;
|
||||||
this.removeNode(existingNode);
|
this.removeNode(existingNode);
|
||||||
this.cache.delete(updateId);
|
this.cache.delete(updateId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newNode = new LRUNode(updateId, content);
|
const newNode = new LRUNode(updateId, content);
|
||||||
this.cache.set(updateId, newNode);
|
this.cache.set(updateId, newNode);
|
||||||
this.addToTail(newNode);
|
this.addToTail(newNode);
|
||||||
this.currentSizeInBytes += content.byteLength;
|
this.currentSizeInBytes += content.byteLength;
|
||||||
this.fitBelowMaxSize();
|
this.fitBelowMaxSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this.cache.clear();
|
this.cache.clear();
|
||||||
this.head = null;
|
this.head = null;
|
||||||
this.tail = null;
|
this.tail = null;
|
||||||
this.currentSizeInBytes = 0;
|
this.currentSizeInBytes = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public resize(newMaxSizeInBytes: number): void {
|
public resize(newMaxSizeInBytes: number): void {
|
||||||
this.maxSizeInBytes = newMaxSizeInBytes;
|
this.maxSizeInBytes = newMaxSizeInBytes;
|
||||||
this.fitBelowMaxSize();
|
this.fitBelowMaxSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
private fitBelowMaxSize(): void {
|
private fitBelowMaxSize(): void {
|
||||||
// Evict least recently used documents if over size limit
|
// Evict least recently used documents if over size limit
|
||||||
while (this.currentSizeInBytes > this.maxSizeInBytes && this.head) {
|
while (this.currentSizeInBytes > this.maxSizeInBytes && this.head) {
|
||||||
const lruNode = this.head;
|
const lruNode = this.head;
|
||||||
this.removeNode(lruNode);
|
this.removeNode(lruNode);
|
||||||
this.cache.delete(lruNode.key);
|
this.cache.delete(lruNode.key);
|
||||||
this.currentSizeInBytes -= lruNode.value.byteLength;
|
this.currentSizeInBytes -= lruNode.value.byteLength;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private removeNode(node: LRUNode): void {
|
private removeNode(node: LRUNode): void {
|
||||||
if (node.prev) {
|
if (node.prev) {
|
||||||
node.prev.next = node.next;
|
node.prev.next = node.next;
|
||||||
} else {
|
} else {
|
||||||
this.head = node.next;
|
this.head = node.next;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.next) {
|
if (node.next) {
|
||||||
node.next.prev = node.prev;
|
node.next.prev = node.prev;
|
||||||
} else {
|
} else {
|
||||||
this.tail = node.prev;
|
this.tail = node.prev;
|
||||||
}
|
}
|
||||||
|
|
||||||
node.prev = null;
|
node.prev = null;
|
||||||
node.next = null;
|
node.next = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private addToTail(node: LRUNode): void {
|
private addToTail(node: LRUNode): void {
|
||||||
node.prev = this.tail;
|
node.prev = this.tail;
|
||||||
node.next = null;
|
node.next = null;
|
||||||
|
|
||||||
if (this.tail) {
|
if (this.tail) {
|
||||||
this.tail.next = node;
|
this.tail.next = node;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.tail = node;
|
this.tail = node;
|
||||||
|
|
||||||
this.head ??= node;
|
this.head ??= node;
|
||||||
}
|
}
|
||||||
|
|
||||||
private moveToTail(node: LRUNode): void {
|
private moveToTail(node: LRUNode): void {
|
||||||
if (node === this.tail) {
|
if (node === this.tail) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.removeNode(node);
|
this.removeNode(node);
|
||||||
this.addToTail(node);
|
this.addToTail(node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,226 +7,226 @@ import { awaitAll } from "../await-all";
|
||||||
import { sleep } from "../sleep";
|
import { sleep } from "../sleep";
|
||||||
|
|
||||||
describe("withLock", () => {
|
describe("withLock", () => {
|
||||||
const testPath: RelativePath = "test/document/path";
|
const testPath: RelativePath = "test/document/path";
|
||||||
const testPath2: RelativePath = "test/document/path2";
|
const testPath2: RelativePath = "test/document/path2";
|
||||||
const logger = new Logger();
|
const logger = new Logger();
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/init-declarations
|
// eslint-disable-next-line @typescript-eslint/init-declarations
|
||||||
let locks: Locks<RelativePath>;
|
let locks: Locks<RelativePath>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
locks = new Locks<RelativePath>(logger);
|
locks = new Locks<RelativePath>(logger);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should execute function with single key lock", async () => {
|
it("should execute function with single key lock", async () => {
|
||||||
let executionCount = 0;
|
let executionCount = 0;
|
||||||
const result = await locks.withLock(testPath, () => {
|
const result = await locks.withLock(testPath, () => {
|
||||||
executionCount++;
|
executionCount++;
|
||||||
return "success";
|
return "success";
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.strictEqual(result, "success");
|
assert.strictEqual(result, "success");
|
||||||
assert.strictEqual(executionCount, 1);
|
assert.strictEqual(executionCount, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should execute async function with single key lock", async () => {
|
it("should execute async function with single key lock", async () => {
|
||||||
let executionCount = 0;
|
let executionCount = 0;
|
||||||
const result = await locks.withLock(testPath, async () => {
|
const result = await locks.withLock(testPath, async () => {
|
||||||
executionCount++;
|
executionCount++;
|
||||||
await sleep(10);
|
await sleep(10);
|
||||||
return "async-success";
|
return "async-success";
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.strictEqual(result, "async-success");
|
assert.strictEqual(result, "async-success");
|
||||||
assert.strictEqual(executionCount, 1);
|
assert.strictEqual(executionCount, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should execute function with multiple key locks", async () => {
|
it("should execute function with multiple key locks", async () => {
|
||||||
let executionCount = 0;
|
let executionCount = 0;
|
||||||
const result = await locks.withLock([testPath, testPath2], () => {
|
const result = await locks.withLock([testPath, testPath2], () => {
|
||||||
executionCount++;
|
executionCount++;
|
||||||
return "multi-success";
|
return "multi-success";
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.strictEqual(result, "multi-success");
|
assert.strictEqual(result, "multi-success");
|
||||||
assert.strictEqual(executionCount, 1);
|
assert.strictEqual(executionCount, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should sort multiple keys to prevent deadlocks", async () => {
|
it("should sort multiple keys to prevent deadlocks", async () => {
|
||||||
const executionOrder: string[] = [];
|
const executionOrder: string[] = [];
|
||||||
|
|
||||||
// Start two concurrent operations with keys in different orders
|
// Start two concurrent operations with keys in different orders
|
||||||
const promise1 = locks.withLock([testPath2, testPath], async () => {
|
const promise1 = locks.withLock([testPath2, testPath], async () => {
|
||||||
executionOrder.push("operation1-start");
|
executionOrder.push("operation1-start");
|
||||||
await sleep(50);
|
await sleep(50);
|
||||||
executionOrder.push("operation1-end");
|
executionOrder.push("operation1-end");
|
||||||
return "result1";
|
return "result1";
|
||||||
});
|
});
|
||||||
|
|
||||||
const promise2 = locks.withLock([testPath, testPath2], async () => {
|
const promise2 = locks.withLock([testPath, testPath2], async () => {
|
||||||
executionOrder.push("operation2-start");
|
executionOrder.push("operation2-start");
|
||||||
await sleep(50);
|
await sleep(50);
|
||||||
executionOrder.push("operation2-end");
|
executionOrder.push("operation2-end");
|
||||||
return "result2";
|
return "result2";
|
||||||
});
|
});
|
||||||
|
|
||||||
const [result1, result2] = await awaitAll([promise1, promise2]);
|
const [result1, result2] = await awaitAll([promise1, promise2]);
|
||||||
|
|
||||||
assert.strictEqual(result1, "result1");
|
assert.strictEqual(result1, "result1");
|
||||||
assert.strictEqual(result2, "result2");
|
assert.strictEqual(result2, "result2");
|
||||||
// One operation should complete entirely before the other starts
|
// One operation should complete entirely before the other starts
|
||||||
assert.deepStrictEqual(executionOrder, [
|
assert.deepStrictEqual(executionOrder, [
|
||||||
"operation1-start",
|
"operation1-start",
|
||||||
"operation1-end",
|
"operation1-end",
|
||||||
"operation2-start",
|
"operation2-start",
|
||||||
"operation2-end"
|
"operation2-end"
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should serialize access to same key", async () => {
|
it("should serialize access to same key", async () => {
|
||||||
const executionOrder: string[] = [];
|
const executionOrder: string[] = [];
|
||||||
|
|
||||||
const promise1 = locks.withLock(testPath, async () => {
|
const promise1 = locks.withLock(testPath, async () => {
|
||||||
executionOrder.push("operation1-start");
|
executionOrder.push("operation1-start");
|
||||||
await sleep(50);
|
await sleep(50);
|
||||||
executionOrder.push("operation1-end");
|
executionOrder.push("operation1-end");
|
||||||
return "result1";
|
return "result1";
|
||||||
});
|
});
|
||||||
|
|
||||||
const promise2 = locks.withLock(testPath, async () => {
|
const promise2 = locks.withLock(testPath, async () => {
|
||||||
executionOrder.push("operation2-start");
|
executionOrder.push("operation2-start");
|
||||||
await sleep(30);
|
await sleep(30);
|
||||||
executionOrder.push("operation2-end");
|
executionOrder.push("operation2-end");
|
||||||
return "result2";
|
return "result2";
|
||||||
});
|
});
|
||||||
|
|
||||||
const [result1, result2] = await awaitAll([promise1, promise2]);
|
const [result1, result2] = await awaitAll([promise1, promise2]);
|
||||||
|
|
||||||
assert.strictEqual(result1, "result1");
|
assert.strictEqual(result1, "result1");
|
||||||
assert.strictEqual(result2, "result2");
|
assert.strictEqual(result2, "result2");
|
||||||
assert.deepStrictEqual(executionOrder, [
|
assert.deepStrictEqual(executionOrder, [
|
||||||
"operation1-start",
|
"operation1-start",
|
||||||
"operation1-end",
|
"operation1-end",
|
||||||
"operation2-start",
|
"operation2-start",
|
||||||
"operation2-end"
|
"operation2-end"
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should allow concurrent access to different keys", async () => {
|
it("should allow concurrent access to different keys", async () => {
|
||||||
const executionOrder: string[] = [];
|
const executionOrder: string[] = [];
|
||||||
|
|
||||||
const promise1 = locks.withLock(testPath, async () => {
|
const promise1 = locks.withLock(testPath, async () => {
|
||||||
executionOrder.push("operation1-start");
|
executionOrder.push("operation1-start");
|
||||||
await sleep(50);
|
await sleep(50);
|
||||||
|
|
||||||
executionOrder.push("operation1-end");
|
executionOrder.push("operation1-end");
|
||||||
return "result1";
|
return "result1";
|
||||||
});
|
});
|
||||||
|
|
||||||
const promise2 = locks.withLock(testPath2, async () => {
|
const promise2 = locks.withLock(testPath2, async () => {
|
||||||
executionOrder.push("operation2-start");
|
executionOrder.push("operation2-start");
|
||||||
await sleep(30);
|
await sleep(30);
|
||||||
executionOrder.push("operation2-end");
|
executionOrder.push("operation2-end");
|
||||||
return "result2";
|
return "result2";
|
||||||
});
|
});
|
||||||
|
|
||||||
const [result1, result2] = await awaitAll([promise1, promise2]);
|
const [result1, result2] = await awaitAll([promise1, promise2]);
|
||||||
|
|
||||||
assert.strictEqual(result1, "result1");
|
assert.strictEqual(result1, "result1");
|
||||||
assert.strictEqual(result2, "result2");
|
assert.strictEqual(result2, "result2");
|
||||||
// Both operations should run concurrently
|
// Both operations should run concurrently
|
||||||
assert.strictEqual(executionOrder[0], "operation1-start");
|
assert.strictEqual(executionOrder[0], "operation1-start");
|
||||||
assert.strictEqual(executionOrder[1], "operation2-start");
|
assert.strictEqual(executionOrder[1], "operation2-start");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should release locks even if function throws", async () => {
|
it("should release locks even if function throws", async () => {
|
||||||
const error = new Error("test error");
|
const error = new Error("test error");
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
locks.withLock(testPath, () => {
|
locks.withLock(testPath, () => {
|
||||||
throw error;
|
throw error;
|
||||||
}),
|
}),
|
||||||
{ message: "test error" }
|
{ message: "test error" }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Lock should be released, allowing another operation
|
// Lock should be released, allowing another operation
|
||||||
const result = await locks.withLock(
|
const result = await locks.withLock(
|
||||||
testPath,
|
testPath,
|
||||||
() => "success-after-error"
|
() => "success-after-error"
|
||||||
);
|
);
|
||||||
assert.strictEqual(result, "success-after-error");
|
assert.strictEqual(result, "success-after-error");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should release locks even if async function throws", async () => {
|
it("should release locks even if async function throws", async () => {
|
||||||
const error = new Error("async test error");
|
const error = new Error("async test error");
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
locks.withLock(testPath, async () => {
|
locks.withLock(testPath, async () => {
|
||||||
await sleep(10);
|
await sleep(10);
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}),
|
}),
|
||||||
{ message: "async test error" }
|
{ message: "async test error" }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Lock should be released, allowing another operation
|
// Lock should be released, allowing another operation
|
||||||
const result = await locks.withLock(
|
const result = await locks.withLock(
|
||||||
testPath,
|
testPath,
|
||||||
() => "success-after-async-error"
|
() => "success-after-async-error"
|
||||||
);
|
);
|
||||||
assert.strictEqual(result, "success-after-async-error");
|
assert.strictEqual(result, "success-after-async-error");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle empty array of keys", async () => {
|
it("should handle empty array of keys", async () => {
|
||||||
const result = await locks.withLock([], () => "empty-keys");
|
const result = await locks.withLock([], () => "empty-keys");
|
||||||
assert.strictEqual(result, "empty-keys");
|
assert.strictEqual(result, "empty-keys");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should maintain FIFO order for multiple waiters", async () => {
|
it("should maintain FIFO order for multiple waiters", async () => {
|
||||||
const executionOrder: string[] = [];
|
const executionOrder: string[] = [];
|
||||||
|
|
||||||
// Start first operation that holds the lock
|
// Start first operation that holds the lock
|
||||||
const firstPromise = locks.withLock(testPath, async () => {
|
const firstPromise = locks.withLock(testPath, async () => {
|
||||||
executionOrder.push("first-start");
|
executionOrder.push("first-start");
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
executionOrder.push("first-end");
|
executionOrder.push("first-end");
|
||||||
return "first";
|
return "first";
|
||||||
});
|
});
|
||||||
|
|
||||||
// Small delay to ensure first operation starts
|
// Small delay to ensure first operation starts
|
||||||
await sleep(10);
|
await sleep(10);
|
||||||
|
|
||||||
// Queue second and third operations
|
// Queue second and third operations
|
||||||
const secondPromise = locks.withLock(testPath, async () => {
|
const secondPromise = locks.withLock(testPath, async () => {
|
||||||
executionOrder.push("second-start");
|
executionOrder.push("second-start");
|
||||||
await sleep(50);
|
await sleep(50);
|
||||||
executionOrder.push("second-end");
|
executionOrder.push("second-end");
|
||||||
return "second";
|
return "second";
|
||||||
});
|
});
|
||||||
|
|
||||||
const thirdPromise = locks.withLock(testPath, async () => {
|
const thirdPromise = locks.withLock(testPath, async () => {
|
||||||
executionOrder.push("third-start");
|
executionOrder.push("third-start");
|
||||||
await sleep(20);
|
await sleep(20);
|
||||||
executionOrder.push("third-end");
|
executionOrder.push("third-end");
|
||||||
return "third";
|
return "third";
|
||||||
});
|
});
|
||||||
|
|
||||||
const [first, second, third] = await awaitAll([
|
const [first, second, third] = await awaitAll([
|
||||||
firstPromise,
|
firstPromise,
|
||||||
secondPromise,
|
secondPromise,
|
||||||
thirdPromise
|
thirdPromise
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.strictEqual(first, "first");
|
assert.strictEqual(first, "first");
|
||||||
assert.strictEqual(second, "second");
|
assert.strictEqual(second, "second");
|
||||||
assert.strictEqual(third, "third");
|
assert.strictEqual(third, "third");
|
||||||
assert.deepStrictEqual(executionOrder, [
|
assert.deepStrictEqual(executionOrder, [
|
||||||
"first-start",
|
"first-start",
|
||||||
"first-end",
|
"first-end",
|
||||||
"second-start",
|
"second-start",
|
||||||
"second-end",
|
"second-end",
|
||||||
"third-start",
|
"third-start",
|
||||||
"third-end"
|
"third-end"
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,148 +8,148 @@ import { awaitAll } from "../await-all";
|
||||||
* @template T The type of the key used for locking
|
* @template T The type of the key used for locking
|
||||||
*/
|
*/
|
||||||
export class Locks<T> {
|
export class Locks<T> {
|
||||||
/** Currently locked keys */
|
/** Currently locked keys */
|
||||||
private readonly locked = new Set<T>();
|
private readonly locked = new Set<T>();
|
||||||
|
|
||||||
/** Queue of resolve functions waiting for each key */
|
/** Queue of resolve functions waiting for each key */
|
||||||
private readonly waiters = new Map<T, (() => unknown)[]>();
|
private readonly waiters = new Map<T, (() => unknown)[]>();
|
||||||
|
|
||||||
public constructor(private readonly logger?: Logger) {}
|
public constructor(private readonly logger?: Logger) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes a function while holding exclusive locks on one or more keys.
|
* Executes a function while holding exclusive locks on one or more keys.
|
||||||
*
|
*
|
||||||
* This method ensures that the provided function runs with exclusive access to the
|
* This method ensures that the provided function runs with exclusive access to the
|
||||||
* specified key(s). Multiple keys are sorted to prevent deadlocks when different
|
* specified key(s). Multiple keys are sorted to prevent deadlocks when different
|
||||||
* operations request the same keys in different orders.
|
* operations request the same keys in different orders.
|
||||||
*
|
*
|
||||||
* @template R The return type of the function to execute
|
* @template R The return type of the function to execute
|
||||||
* @param keyOrKeys A single key or array of keys to lock during function execution
|
* @param keyOrKeys A single key or array of keys to lock during function execution
|
||||||
* @param fn The function to execute while holding the lock(s). Can be sync or async.
|
* @param fn The function to execute while holding the lock(s). Can be sync or async.
|
||||||
* @returns A Promise that resolves to the return value of the executed function
|
* @returns A Promise that resolves to the return value of the executed function
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* // Lock a single key
|
* // Lock a single key
|
||||||
* const result = await locks.withLock('file1', () => {
|
* const result = await locks.withLock('file1', () => {
|
||||||
* // Critical section - only one operation can access 'file1' at a time
|
* // Critical section - only one operation can access 'file1' at a time
|
||||||
* return processFile('file1');
|
* return processFile('file1');
|
||||||
* });
|
* });
|
||||||
*
|
*
|
||||||
* // Lock multiple keys (prevents deadlocks through consistent ordering)
|
* // Lock multiple keys (prevents deadlocks through consistent ordering)
|
||||||
* await locks.withLock(['file1', 'file2'], async () => {
|
* await locks.withLock(['file1', 'file2'], async () => {
|
||||||
* // Critical section - exclusive access to both files
|
* // Critical section - exclusive access to both files
|
||||||
* await moveFile('file1', 'file2');
|
* await moveFile('file1', 'file2');
|
||||||
* });
|
* });
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @throws Any error thrown by the provided function will be propagated after locks are released
|
* @throws Any error thrown by the provided function will be propagated after locks are released
|
||||||
*/
|
*/
|
||||||
public async withLock<R>(
|
public async withLock<R>(
|
||||||
keyOrKeys: T | T[],
|
keyOrKeys: T | T[],
|
||||||
fn: () => R | Promise<R>
|
fn: () => R | Promise<R>
|
||||||
): Promise<R> {
|
): Promise<R> {
|
||||||
const keys = Array.isArray(keyOrKeys) ? keyOrKeys : [keyOrKeys];
|
const keys = Array.isArray(keyOrKeys) ? keyOrKeys : [keyOrKeys];
|
||||||
|
|
||||||
// Deduplicate keys to prevent deadlock from acquiring same lock twice
|
// Deduplicate keys to prevent deadlock from acquiring same lock twice
|
||||||
const uniqueKeys = Array.from(new Set(keys));
|
const uniqueKeys = Array.from(new Set(keys));
|
||||||
uniqueKeys.sort((a, b) => String(a).localeCompare(String(b))); // Ensure consistent order to prevent deadlocks
|
uniqueKeys.sort((a, b) => String(a).localeCompare(String(b))); // Ensure consistent order to prevent deadlocks
|
||||||
|
|
||||||
await awaitAll(uniqueKeys.map(async (key) => this.waitForLock(key)));
|
await awaitAll(uniqueKeys.map(async (key) => this.waitForLock(key)));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} finally {
|
} finally {
|
||||||
uniqueKeys.forEach((key) => {
|
uniqueKeys.forEach((key) => {
|
||||||
this.unlock(key);
|
this.unlock(key);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this.locked.clear();
|
this.locked.clear();
|
||||||
this.waiters.clear();
|
this.waiters.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempts to acquire a lock immediately without waiting.
|
* Attempts to acquire a lock immediately without waiting.
|
||||||
* Must call `unlock()` if successful.
|
* Must call `unlock()` if successful.
|
||||||
*
|
*
|
||||||
* @param key The key to lock
|
* @param key The key to lock
|
||||||
* @returns `true` if lock acquired, `false` if already locked
|
* @returns `true` if lock acquired, `false` if already locked
|
||||||
*/
|
*/
|
||||||
public tryLock(key: T): boolean {
|
public tryLock(key: T): boolean {
|
||||||
if (this.locked.has(key)) {
|
if (this.locked.has(key)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.locked.add(key);
|
this.locked.add(key);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Waits to acquire a lock, blocking until available.
|
* Waits to acquire a lock, blocking until available.
|
||||||
* Operations are queued in FIFO order. Must call `unlock()` when done.
|
* Operations are queued in FIFO order. Must call `unlock()` when done.
|
||||||
*
|
*
|
||||||
* @param key The key to wait for and lock
|
* @param key The key to wait for and lock
|
||||||
* @returns Promise that resolves when lock is acquired
|
* @returns Promise that resolves when lock is acquired
|
||||||
*/
|
*/
|
||||||
public async waitForLock(key: T): Promise<void> {
|
public async waitForLock(key: T): Promise<void> {
|
||||||
if (this.tryLock(key)) {
|
if (this.tryLock(key)) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger?.debug(`Waiting for lock on ${key}`);
|
this.logger?.debug(`Waiting for lock on ${key}`);
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
// DefaultDict behavior
|
// DefaultDict behavior
|
||||||
let waiting = this.waiters.get(key);
|
let waiting = this.waiters.get(key);
|
||||||
if (!waiting) {
|
if (!waiting) {
|
||||||
waiting = [];
|
waiting = [];
|
||||||
this.waiters.set(key, waiting);
|
this.waiters.set(key, waiting);
|
||||||
}
|
}
|
||||||
|
|
||||||
waiting.push(resolve);
|
waiting.push(resolve);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Releases a lock and grants access to the next waiting operation in FIFO order.
|
* Releases a lock and grants access to the next waiting operation in FIFO order.
|
||||||
* Removes the key from locked set if no waiters.
|
* Removes the key from locked set if no waiters.
|
||||||
*
|
*
|
||||||
* @param key The key to unlock
|
* @param key The key to unlock
|
||||||
* @throws {Error} If key is not currently locked
|
* @throws {Error} If key is not currently locked
|
||||||
*/
|
*/
|
||||||
public unlock(key: T): void {
|
public unlock(key: T): void {
|
||||||
if (!this.locked.has(key)) {
|
if (!this.locked.has(key)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove first waiter to ensure FIFO order
|
// Remove first waiter to ensure FIFO order
|
||||||
const nextWaiting = this.waiters.get(key)?.shift();
|
const nextWaiting = this.waiters.get(key)?.shift();
|
||||||
|
|
||||||
if (nextWaiting) {
|
if (nextWaiting) {
|
||||||
this.logger?.debug(`Granted lock on ${key}`);
|
this.logger?.debug(`Granted lock on ${key}`);
|
||||||
nextWaiting();
|
nextWaiting();
|
||||||
} else {
|
} else {
|
||||||
this.locked.delete(key);
|
this.locked.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Lock {
|
export class Lock {
|
||||||
private readonly locks: Locks<boolean>;
|
private readonly locks: Locks<boolean>;
|
||||||
|
|
||||||
public constructor(logger?: Logger) {
|
public constructor(logger?: Logger) {
|
||||||
this.locks = new Locks(logger);
|
this.locks = new Locks(logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async withLock<R>(fn: () => R | Promise<R>): Promise<R> {
|
public async withLock<R>(fn: () => R | Promise<R>): Promise<R> {
|
||||||
return this.locks.withLock(true, fn);
|
return this.locks.withLock(true, fn);
|
||||||
}
|
}
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this.locks.reset();
|
this.locks.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,74 +3,74 @@ import assert from "node:assert";
|
||||||
import { CoveredValues } from "./min-covered";
|
import { CoveredValues } from "./min-covered";
|
||||||
|
|
||||||
describe("CoveredValues", () => {
|
describe("CoveredValues", () => {
|
||||||
it("should initialize with the given min value", () => {
|
it("should initialize with the given min value", () => {
|
||||||
const covered = new CoveredValues(5);
|
const covered = new CoveredValues(5);
|
||||||
assert.strictEqual(covered.min, 5);
|
assert.strictEqual(covered.min, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should add values greater than min", () => {
|
it("should add values greater than min", () => {
|
||||||
const covered = new CoveredValues(0);
|
const covered = new CoveredValues(0);
|
||||||
covered.add(3);
|
covered.add(3);
|
||||||
assert.strictEqual(covered.min, 0);
|
assert.strictEqual(covered.min, 0);
|
||||||
covered.add(1);
|
covered.add(1);
|
||||||
assert.strictEqual(covered.min, 1);
|
assert.strictEqual(covered.min, 1);
|
||||||
covered.add(4);
|
covered.add(4);
|
||||||
assert.strictEqual(covered.min, 1);
|
assert.strictEqual(covered.min, 1);
|
||||||
covered.add(2);
|
covered.add(2);
|
||||||
assert.strictEqual(covered.min, 4);
|
assert.strictEqual(covered.min, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should ignore duplicate values", () => {
|
it("should ignore duplicate values", () => {
|
||||||
const covered = new CoveredValues(0);
|
const covered = new CoveredValues(0);
|
||||||
covered.add(3);
|
covered.add(3);
|
||||||
covered.add(3);
|
covered.add(3);
|
||||||
covered.add(3);
|
covered.add(3);
|
||||||
assert.strictEqual(covered.min, 0);
|
assert.strictEqual(covered.min, 0);
|
||||||
covered.add(1);
|
covered.add(1);
|
||||||
covered.add(2);
|
covered.add(2);
|
||||||
assert.strictEqual(covered.min, 3);
|
assert.strictEqual(covered.min, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle multiple consecutive values", () => {
|
it("should handle multiple consecutive values", () => {
|
||||||
const covered = new CoveredValues(132);
|
const covered = new CoveredValues(132);
|
||||||
for (let i = 250; i > 132; i--) {
|
for (let i = 250; i > 132; i--) {
|
||||||
assert.strictEqual(covered.min, 132);
|
assert.strictEqual(covered.min, 132);
|
||||||
covered.add(i);
|
covered.add(i);
|
||||||
}
|
}
|
||||||
assert.strictEqual(covered.min, 250);
|
assert.strictEqual(covered.min, 250);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle adding values lower than current min", () => {
|
it("should handle adding values lower than current min", () => {
|
||||||
const covered = new CoveredValues(5);
|
const covered = new CoveredValues(5);
|
||||||
covered.add(3);
|
covered.add(3);
|
||||||
assert.strictEqual(covered.min, 5);
|
assert.strictEqual(covered.min, 5);
|
||||||
covered.add(6);
|
covered.add(6);
|
||||||
assert.strictEqual(covered.min, 6);
|
assert.strictEqual(covered.min, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should auto-advance when setting min value", () => {
|
it("should auto-advance when setting min value", () => {
|
||||||
const covered = new CoveredValues(5);
|
const covered = new CoveredValues(5);
|
||||||
covered.add(7);
|
covered.add(7);
|
||||||
covered.add(8);
|
covered.add(8);
|
||||||
covered.add(9);
|
covered.add(9);
|
||||||
assert.strictEqual(covered.min, 5);
|
assert.strictEqual(covered.min, 5);
|
||||||
// Setting min to 6 should auto-advance through 7, 8, 9
|
// Setting min to 6 should auto-advance through 7, 8, 9
|
||||||
covered.min = 6;
|
covered.min = 6;
|
||||||
assert.strictEqual(covered.min, 9);
|
assert.strictEqual(covered.min, 9);
|
||||||
covered.add(10);
|
covered.add(10);
|
||||||
assert.strictEqual(covered.min, 10);
|
assert.strictEqual(covered.min, 10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle setting min value with no consecutive values", () => {
|
it("should handle setting min value with no consecutive values", () => {
|
||||||
const covered = new CoveredValues(5);
|
const covered = new CoveredValues(5);
|
||||||
covered.add(10);
|
covered.add(10);
|
||||||
covered.add(15);
|
covered.add(15);
|
||||||
assert.strictEqual(covered.min, 5);
|
assert.strictEqual(covered.min, 5);
|
||||||
// Setting min to 8 should not auto-advance (no consecutive values)
|
// Setting min to 8 should not auto-advance (no consecutive values)
|
||||||
covered.min = 8;
|
covered.min = 8;
|
||||||
assert.strictEqual(covered.min, 8);
|
assert.strictEqual(covered.min, 8);
|
||||||
// Add 9 to trigger auto-advance to 10
|
// Add 9 to trigger auto-advance to 10
|
||||||
covered.add(9);
|
covered.add(9);
|
||||||
assert.strictEqual(covered.min, 10);
|
assert.strictEqual(covered.min, 10);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,48 +14,48 @@
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export class CoveredValues {
|
export class CoveredValues {
|
||||||
private seenValues: number[] = [];
|
private seenValues: number[] = [];
|
||||||
|
|
||||||
public constructor(private minValue: number) {}
|
public constructor(private minValue: number) {}
|
||||||
|
|
||||||
public get min(): number {
|
public get min(): number {
|
||||||
return this.minValue;
|
return this.minValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
public set min(value: number) {
|
public set min(value: number) {
|
||||||
this.minValue = Math.max(value, this.minValue);
|
this.minValue = Math.max(value, this.minValue);
|
||||||
this.seenValues = this.seenValues.filter((v) => v > this.minValue);
|
this.seenValues = this.seenValues.filter((v) => v > this.minValue);
|
||||||
this.advanceMinWhilePossible();
|
this.advanceMinWhilePossible();
|
||||||
}
|
}
|
||||||
|
|
||||||
public add(value: number | undefined): void {
|
public add(value: number | undefined): void {
|
||||||
if (value === undefined || value < this.minValue) {
|
if (value === undefined || value < this.minValue) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let i = 0;
|
let i = 0;
|
||||||
while (i < this.seenValues.length && this.seenValues[i] < value) {
|
while (i < this.seenValues.length && this.seenValues[i] < value) {
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (i === this.seenValues.length) {
|
if (i === this.seenValues.length) {
|
||||||
this.seenValues.push(value);
|
this.seenValues.push(value);
|
||||||
} else if (this.seenValues[i] === value) {
|
} else if (this.seenValues[i] === value) {
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
this.seenValues.splice(i, 0, value);
|
this.seenValues.splice(i, 0, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.advanceMinWhilePossible();
|
this.advanceMinWhilePossible();
|
||||||
}
|
}
|
||||||
|
|
||||||
private advanceMinWhilePossible(): void {
|
private advanceMinWhilePossible(): void {
|
||||||
while (
|
while (
|
||||||
this.seenValues.length > 0 &&
|
this.seenValues.length > 0 &&
|
||||||
this.seenValues[0] === this.minValue + 1
|
this.seenValues[0] === this.minValue + 1
|
||||||
) {
|
) {
|
||||||
this.seenValues.shift();
|
this.seenValues.shift();
|
||||||
this.minValue++;
|
this.minValue++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,22 +3,22 @@ import type { LogLine } from "../../tracing/logger";
|
||||||
import { LogLevel } from "../../tracing/logger";
|
import { LogLevel } from "../../tracing/logger";
|
||||||
|
|
||||||
export function logToConsole(client: SyncClient): void {
|
export function logToConsole(client: SyncClient): void {
|
||||||
client.logger.onLogEmitted.add((logLine: LogLine) => {
|
client.logger.onLogEmitted.add((logLine: LogLine) => {
|
||||||
const formatted = `${logLine.timestamp.toISOString()} ${logLine.level} ${logLine.message}`;
|
const formatted = `${logLine.timestamp.toISOString()} ${logLine.level} ${logLine.message}`;
|
||||||
|
|
||||||
switch (logLine.level) {
|
switch (logLine.level) {
|
||||||
case LogLevel.ERROR:
|
case LogLevel.ERROR:
|
||||||
console.error(formatted);
|
console.error(formatted);
|
||||||
break;
|
break;
|
||||||
case LogLevel.WARNING:
|
case LogLevel.WARNING:
|
||||||
console.warn(formatted);
|
console.warn(formatted);
|
||||||
break;
|
break;
|
||||||
case LogLevel.INFO:
|
case LogLevel.INFO:
|
||||||
console.info(formatted);
|
console.info(formatted);
|
||||||
break;
|
break;
|
||||||
case LogLevel.DEBUG:
|
case LogLevel.DEBUG:
|
||||||
console.debug(formatted);
|
console.debug(formatted);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
import { sleep } from "../sleep";
|
import { sleep } from "../sleep";
|
||||||
|
|
||||||
export const slowFetchFactory =
|
export const slowFetchFactory =
|
||||||
(jitterScaleInSeconds: number) =>
|
(jitterScaleInSeconds: number) =>
|
||||||
async (
|
async (
|
||||||
input: string | URL | globalThis.Request,
|
input: string | URL | globalThis.Request,
|
||||||
init?: RequestInit
|
init?: RequestInit
|
||||||
): Promise<Response> => {
|
): Promise<Response> => {
|
||||||
if (jitterScaleInSeconds > 0) {
|
if (jitterScaleInSeconds > 0) {
|
||||||
await sleep(((Math.random() * jitterScaleInSeconds) / 2) * 1000);
|
await sleep(((Math.random() * jitterScaleInSeconds) / 2) * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(input, init);
|
const response = await fetch(input, init);
|
||||||
|
|
||||||
if (jitterScaleInSeconds > 0) {
|
if (jitterScaleInSeconds > 0) {
|
||||||
await sleep(((Math.random() * jitterScaleInSeconds) / 2) * 1000);
|
await sleep(((Math.random() * jitterScaleInSeconds) / 2) * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,79 +3,79 @@ import { Locks } from "../data-structures/locks";
|
||||||
import type { Logger } from "../../tracing/logger";
|
import type { Logger } from "../../tracing/logger";
|
||||||
|
|
||||||
export function slowWebSocketFactory(
|
export function slowWebSocketFactory(
|
||||||
jitterScaleInSeconds: number,
|
jitterScaleInSeconds: number,
|
||||||
logger: Logger
|
logger: Logger
|
||||||
): typeof WebSocket {
|
): typeof WebSocket {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
return class FlakyWebSocket extends WebSocket {
|
return class FlakyWebSocket extends WebSocket {
|
||||||
private static readonly RECEIVE_KEY = "websocket-receive";
|
private static readonly RECEIVE_KEY = "websocket-receive";
|
||||||
private static readonly SEND_KEY = "websocket-send";
|
private static readonly SEND_KEY = "websocket-send";
|
||||||
|
|
||||||
private readonly locks = new Locks(logger);
|
private readonly locks = new Locks(logger);
|
||||||
|
|
||||||
public set onopen(callback: ((event: Event) => void) | null) {
|
public set onopen(callback: ((event: Event) => void) | null) {
|
||||||
super.onopen = async (event: Event): Promise<void> => {
|
super.onopen = async (event: Event): Promise<void> => {
|
||||||
if (jitterScaleInSeconds > 0) {
|
if (jitterScaleInSeconds > 0) {
|
||||||
await sleep(Math.random() * jitterScaleInSeconds * 1000);
|
await sleep(Math.random() * jitterScaleInSeconds * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
callback?.(event);
|
callback?.(event);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public set onmessage(callback: ((event: MessageEvent) => void) | null) {
|
public set onmessage(callback: ((event: MessageEvent) => void) | null) {
|
||||||
super.onmessage = async (event: MessageEvent): Promise<void> => {
|
super.onmessage = async (event: MessageEvent): Promise<void> => {
|
||||||
await this.locks.withLock(
|
await this.locks.withLock(
|
||||||
FlakyWebSocket.RECEIVE_KEY,
|
FlakyWebSocket.RECEIVE_KEY,
|
||||||
async () => {
|
async () => {
|
||||||
if (jitterScaleInSeconds > 0) {
|
if (jitterScaleInSeconds > 0) {
|
||||||
await sleep(
|
await sleep(
|
||||||
Math.random() * jitterScaleInSeconds * 1000
|
Math.random() * jitterScaleInSeconds * 1000
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
callback?.(event);
|
callback?.(event);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public set onclose(callback: ((event: CloseEvent) => void) | null) {
|
public set onclose(callback: ((event: CloseEvent) => void) | null) {
|
||||||
super.onclose = async (event: CloseEvent): Promise<void> => {
|
super.onclose = async (event: CloseEvent): Promise<void> => {
|
||||||
if (jitterScaleInSeconds > 0) {
|
if (jitterScaleInSeconds > 0) {
|
||||||
await sleep(Math.random() * jitterScaleInSeconds * 1000);
|
await sleep(Math.random() * jitterScaleInSeconds * 1000);
|
||||||
}
|
}
|
||||||
callback?.(event);
|
callback?.(event);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public set onerror(callback: ((event: Event) => void) | null) {
|
public set onerror(callback: ((event: Event) => void) | null) {
|
||||||
super.onerror = async (event: Event): Promise<void> => {
|
super.onerror = async (event: Event): Promise<void> => {
|
||||||
if (jitterScaleInSeconds > 0) {
|
if (jitterScaleInSeconds > 0) {
|
||||||
await sleep(Math.random() * jitterScaleInSeconds * 1000);
|
await sleep(Math.random() * jitterScaleInSeconds * 1000);
|
||||||
}
|
}
|
||||||
callback?.(event);
|
callback?.(event);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public send(
|
public send(
|
||||||
data: string | ArrayBufferLike | Blob | ArrayBufferView
|
data: string | ArrayBufferLike | Blob | ArrayBufferView
|
||||||
): void {
|
): void {
|
||||||
this.waitingSend(data).catch((error: unknown) => {
|
this.waitingSend(data).catch((error: unknown) => {
|
||||||
logger.error(`Error sending WebSocket message: ${error}`);
|
logger.error(`Error sending WebSocket message: ${error}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async waitingSend(
|
private async waitingSend(
|
||||||
data: string | ArrayBufferLike | Blob | ArrayBufferView
|
data: string | ArrayBufferLike | Blob | ArrayBufferView
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// maintain message order
|
// maintain message order
|
||||||
await this.locks.withLock(FlakyWebSocket.SEND_KEY, async () => {
|
await this.locks.withLock(FlakyWebSocket.SEND_KEY, async () => {
|
||||||
if (jitterScaleInSeconds > 0) {
|
if (jitterScaleInSeconds > 0) {
|
||||||
await sleep(Math.random() * jitterScaleInSeconds * 1000);
|
await sleep(Math.random() * jitterScaleInSeconds * 1000);
|
||||||
}
|
}
|
||||||
super.send(data);
|
super.send(data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} as unknown as typeof WebSocket;
|
} as unknown as typeof WebSocket;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,12 @@ import { EMPTY_HASH } from "./hash";
|
||||||
|
|
||||||
// TODO: make this smarter so that offline files can be renamed & edited at the same time
|
// TODO: make this smarter so that offline files can be renamed & edited at the same time
|
||||||
export function findMatchingFile(
|
export function findMatchingFile(
|
||||||
contentHash: string,
|
contentHash: string,
|
||||||
candidates: DocumentRecord[]
|
candidates: DocumentRecord[]
|
||||||
): DocumentRecord | undefined {
|
): DocumentRecord | undefined {
|
||||||
if (contentHash === EMPTY_HASH) {
|
if (contentHash === EMPTY_HASH) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return candidates.find(({ metadata }) => metadata?.hash === contentHash);
|
return candidates.find(({ metadata }) => metadata?.hash === contentHash);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
export function getRandomColor(name: string): string {
|
export function getRandomColor(name: string): string {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < name.length; i++) {
|
for (let i = 0; i < name.length; i++) {
|
||||||
hash = (hash << 5) - hash + name.charCodeAt(i);
|
hash = (hash << 5) - hash + name.charCodeAt(i);
|
||||||
hash |= 0; // Convert to 32bit integer
|
hash |= 0; // Convert to 32bit integer
|
||||||
}
|
}
|
||||||
const normalised = hash / 0x7fffffff;
|
const normalised = hash / 0x7fffffff;
|
||||||
return `oklch(0.58 0.15 ${Math.round(Math.abs(normalised * 360))})`;
|
return `oklch(0.58 0.15 ${Math.round(Math.abs(normalised * 360))})`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,10 @@ import { Logger } from "../tracing/logger";
|
||||||
import { globsToRegexes } from "./globs-to-regexes";
|
import { globsToRegexes } from "./globs-to-regexes";
|
||||||
|
|
||||||
describe("globsToRegexes", () => {
|
describe("globsToRegexes", () => {
|
||||||
it("basicExample", async () => {
|
it("basicExample", async () => {
|
||||||
const [regex] = globsToRegexes([".git/**"], new Logger());
|
const [regex] = globsToRegexes([".git/**"], new Logger());
|
||||||
|
|
||||||
assert.ok(regex.test(".git/objects/object"));
|
assert.ok(regex.test(".git/objects/object"));
|
||||||
assert.ok(regex.test(".git/objects/.object"));
|
assert.ok(regex.test(".git/objects/.object"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,20 @@ import { makeRe } from "minimatch";
|
||||||
import type { Logger } from "../tracing/logger";
|
import type { Logger } from "../tracing/logger";
|
||||||
|
|
||||||
export function globsToRegexes(globs: string[], logger: Logger): RegExp[] {
|
export function globsToRegexes(globs: string[], logger: Logger): RegExp[] {
|
||||||
return (
|
return (
|
||||||
globs
|
globs
|
||||||
.map((pattern) => {
|
.map((pattern) => {
|
||||||
const result = makeRe(pattern, {
|
const result = makeRe(pattern, {
|
||||||
dot: true
|
dot: true
|
||||||
});
|
});
|
||||||
if (result === false) {
|
if (result === false) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Failed to parse ${pattern}' as a glob pattern, skipping it`
|
`Failed to parse ${pattern}' as a glob pattern, skipping it`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
})
|
})
|
||||||
// eslint-disable-next-line no-restricted-syntax -- Filtering out false values, not removing a specific item
|
// eslint-disable-next-line no-restricted-syntax -- Filtering out false values, not removing a specific item
|
||||||
.filter((pattern) => pattern !== false)
|
.filter((pattern) => pattern !== false)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
// https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript
|
// https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript
|
||||||
export function hash(content: Uint8Array): string {
|
export function hash(content: Uint8Array): string {
|
||||||
let result = 0;
|
let result = 0;
|
||||||
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
||||||
for (let i = 0; i < content.length; i++) {
|
for (let i = 0; i < content.length; i++) {
|
||||||
result = (result << 5) - result + content[i];
|
result = (result << 5) - result + content[i];
|
||||||
result |= 0; // Convert to 32bit integer
|
result |= 0; // Convert to 32bit integer
|
||||||
}
|
}
|
||||||
return Math.abs(result).toString(16).padStart(8, "0");
|
return Math.abs(result).toString(16).padStart(8, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EMPTY_HASH = hash(new Uint8Array(0));
|
export const EMPTY_HASH = hash(new Uint8Array(0));
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue