mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-10 05:35:45 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bb50c1580 | ||
|
|
d17330f3c7 | ||
|
|
bd45924649 | ||
|
|
b1ad3e0653 | ||
|
|
6b94f0ce47 | ||
|
|
08b55b7677 | ||
|
|
21d904cfd6 | ||
|
|
00de35e5d4 | ||
|
|
f2976bc89a | ||
|
|
b65deede79 | ||
|
|
9203bdd40e | ||
|
|
fcd30d07be | ||
|
|
cfb75a05db | ||
|
|
a3a09df3c8 | ||
|
|
4393a49cba |
@@ -0,0 +1,78 @@
|
||||
# Architectural Decision Record: Fast Fetch Transport Eligibility
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Fast Fetch accelerates Fast Setup (Simple Fetch) by reading bounded pages from
|
||||
CouchDB's continuous changes feed. It consumes each response incrementally,
|
||||
persists documents while the page is still arriving, and cancels the underlying
|
||||
request when the page completes or fails.
|
||||
|
||||
The CouchDB setting `useRequestAPI`, labelled 'Use Internal API', routes ordinary
|
||||
replication through Obsidian's `requestUrl` API to avoid browser CORS
|
||||
restrictions. This API exposes a completed response as text, JSON, or an
|
||||
`ArrayBuffer`; it does not expose the network response progressively or accept
|
||||
the Fetch API's `AbortSignal`. Wrapping its result in a `Response` does not
|
||||
restore those transport properties.
|
||||
|
||||
Custom headers can cause a browser preflight, and an authenticating proxy may
|
||||
reject that preflight before the requested header values are sent. Custom
|
||||
headers do not, however, make Fast Fetch intrinsically incompatible. A server
|
||||
with correctly configured CORS can accept the same headers through the ordinary
|
||||
Fetch API and retain streaming behaviour.
|
||||
|
||||
Ordinary PouchDB replication has a different response contract. Standard Fetch
|
||||
uses finite batches, while LiveSync uses long-poll responses whose change
|
||||
payload is bounded by the replication batch size. Both can process each response
|
||||
after it has completed and do not depend on progressively reading a
|
||||
document-bearing continuous feed.
|
||||
|
||||
## Decision
|
||||
|
||||
Fast Fetch requires a Fetch-compatible transport which exposes the response
|
||||
body progressively and honours request cancellation.
|
||||
|
||||
When `useRequestAPI` is enabled for a CouchDB remote, Fast Fetch falls back to
|
||||
Standard Fetch before entering the Fast Fetch activity or resetting the local
|
||||
database through the Fast Fetch path. The presence of custom headers alone does
|
||||
not disable Fast Fetch. Once Standard Fetch resets the local database, it
|
||||
invalidates any retained Fast Fetch checkpoint for that database.
|
||||
|
||||
Commonlib's Rebuilder owns this eligibility decision because it owns both Fast
|
||||
Fetch and the existing Standard Fetch fallback. The streaming implementation
|
||||
does not receive Obsidian's buffered request adapter, and LiveSync does not add
|
||||
proxy-specific or Cloudflare-specific policy.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Initial retrieval through Standard Fetch may be slower and issue more HTTP
|
||||
requests because PouchDB uses the configured batch size, document retrieval,
|
||||
and checkpoint operations. The decision does not assume that `requestUrl` is
|
||||
faster; its benefit here is compatibility with connections which browser CORS
|
||||
would otherwise reject.
|
||||
- LiveSync remains supported with `useRequestAPI`. Its HTTP adapter uses
|
||||
long-poll responses whose change payload is bounded by the replication batch
|
||||
size, rather than the document-bearing stream required by Fast Fetch.
|
||||
- A user whose server accepts the configured custom headers through correct
|
||||
CORS handling can leave `useRequestAPI` disabled and continue to use Fast
|
||||
Fetch.
|
||||
- The decision can be revisited if Obsidian provides a progressively readable,
|
||||
cancellable internal request API, or if a separately designed buffered
|
||||
transport establishes explicit payload bounds and equivalent cancellation
|
||||
semantics.
|
||||
|
||||
## Verification
|
||||
|
||||
Commonlib unit tests verify that `useRequestAPI` selects only the existing
|
||||
Standard Fetch activity, does not invoke Streaming Fetch, and invalidates any
|
||||
retained Fast Fetch checkpoint after the local database is reset. Existing
|
||||
tests continue to verify that custom headers are passed to Fast Fetch when
|
||||
`useRequestAPI` is disabled.
|
||||
|
||||
## References
|
||||
|
||||
- [Fast Fetch Persistence and Completion Semantics](2026_08_fast_fetch_persistence_and_completion.md)
|
||||
- [Apache CouchDB changes-feed API](https://docs.couchdb.org/en/stable/api/database/changes.html)
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.11",
|
||||
"minAppVersion": "1.7.2",
|
||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"author": "vorotamoroz",
|
||||
|
||||
Generated
+9
-9
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.11",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"src/apps/cli",
|
||||
@@ -23,7 +23,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.8",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.10",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
@@ -4775,9 +4775,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.8.tgz",
|
||||
"integrity": "sha512-Kn1AF41h2Dog37ThU7KgLcKxItCCerLEBWg1eSGAUoTk3TyPBYynvtmVFwWhy6LCePcuwB/+x7EQNTL3Sgzkyg==",
|
||||
"version": "0.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.10.tgz",
|
||||
"integrity": "sha512-1t1e8EPM2fuIbC107jJQXbSivWXcspD7kR/3AOgT29O9E5UbGFZR6vj1f84D6vOe8BiHhv8Ng9GvrCV6U+gGXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.808.0",
|
||||
@@ -15924,7 +15924,7 @@
|
||||
},
|
||||
"src/apps/cli": {
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"version": "1.0.9-cli",
|
||||
"version": "1.0.11-cli",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"minimatch": "^10.2.5",
|
||||
@@ -15949,7 +15949,7 @@
|
||||
},
|
||||
"src/apps/webapp": {
|
||||
"name": "livesync-webapp",
|
||||
"version": "1.0.9-webapp",
|
||||
"version": "1.0.11-webapp",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
@@ -15961,7 +15961,7 @@
|
||||
}
|
||||
},
|
||||
"src/apps/webpeer": {
|
||||
"version": "1.0.9-webpeer",
|
||||
"version": "1.0.11-webpeer",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.11",
|
||||
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
@@ -177,7 +177,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.8",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.10",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
|
||||
@@ -121,8 +121,10 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
|
||||
return {
|
||||
path: path.relative(this.basePath, filePath).replace(/\\/g, "/") as FilePath,
|
||||
stat: {
|
||||
ctime: stats?.ctimeMs ?? Date.now(),
|
||||
mtime: stats?.mtimeMs ?? Date.now(),
|
||||
// Floor to integer milliseconds; Linux fs.Stats.*Ms carry sub-millisecond
|
||||
// precision, and timestamps are stored as integer ms everywhere else.
|
||||
ctime: Math.floor(stats?.ctimeMs ?? Date.now()),
|
||||
mtime: Math.floor(stats?.mtimeMs ?? Date.now()),
|
||||
size: stats?.size ?? 0,
|
||||
type: "file",
|
||||
},
|
||||
|
||||
@@ -84,6 +84,27 @@ describe("CLIStorageEventManagerAdapter", () => {
|
||||
expect(created.stat?.size).toBe(42);
|
||||
});
|
||||
|
||||
it("floors sub-millisecond stat timestamps so mobile clients do not receive floats", async () => {
|
||||
const basePath = "/vault/base";
|
||||
const adapter = new CLIStorageEventManagerAdapter(basePath, undefined, true);
|
||||
const handlers = makeHandlers();
|
||||
|
||||
await adapter.watch.beginWatch(handlers);
|
||||
|
||||
const addCallback = mockWatcher.on.mock.calls.find(([event]) => event === "add")![1] as (
|
||||
filePath: string,
|
||||
stats: any
|
||||
) => void;
|
||||
|
||||
// Linux fs.Stats carry nanosecond-derived sub-millisecond precision.
|
||||
const floatStats = { ctimeMs: 1778511180024.462, mtimeMs: 1778511180999.913, size: 7 };
|
||||
addCallback(`${basePath}/note.md`, floatStats);
|
||||
|
||||
const created = (handlers.onCreate as ReturnType<typeof vi.fn>).mock.calls[0][0] as NodeFile;
|
||||
expect(created.stat?.ctime).toBe(1778511180024);
|
||||
expect(created.stat?.mtime).toBe(1778511180999);
|
||||
});
|
||||
|
||||
it("close() calls watcher.close()", async () => {
|
||||
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true);
|
||||
const handlers = makeHandlers();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "1.0.9-cli",
|
||||
"version": "1.0.11-cli",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "livesync-webapp",
|
||||
"private": true,
|
||||
"version": "1.0.9-webapp",
|
||||
"version": "1.0.11-webapp",
|
||||
"type": "module",
|
||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webpeer",
|
||||
"private": true,
|
||||
"version": "1.0.9-webpeer",
|
||||
"version": "1.0.11-webpeer",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/commo
|
||||
import type { IStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters";
|
||||
import { toArrayBuffer } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase";
|
||||
import type { Stat, App } from "obsidian";
|
||||
import { toIntegerTimestamps } from "./sanitizeWriteOptions";
|
||||
|
||||
/**
|
||||
* Storage adapter implementation for Obsidian
|
||||
@@ -40,15 +41,15 @@ export class ObsidianStorageAdapter implements IStorageAdapter<Stat> {
|
||||
}
|
||||
|
||||
async write(path: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||
return await this.app.vault.adapter.write(path, data, options);
|
||||
return await this.app.vault.adapter.write(path, data, toIntegerTimestamps(options));
|
||||
}
|
||||
|
||||
async writeBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
||||
return await this.app.vault.adapter.writeBinary(path, toArrayBuffer(data), options);
|
||||
return await this.app.vault.adapter.writeBinary(path, toArrayBuffer(data), toIntegerTimestamps(options));
|
||||
}
|
||||
|
||||
async append(path: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||
return await this.app.vault.adapter.append(path, data, options);
|
||||
return await this.app.vault.adapter.append(path, data, toIntegerTimestamps(options));
|
||||
}
|
||||
|
||||
list(basePath: string): Promise<{ files: string[]; folders: string[] }> {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { App } from "obsidian";
|
||||
import { ObsidianStorageAdapter } from "./ObsidianStorageAdapter";
|
||||
|
||||
describe("ObsidianStorageAdapter", () => {
|
||||
it("floors write-option timestamps before calling Obsidian storage methods", async () => {
|
||||
const write = vi.fn().mockResolvedValue(undefined);
|
||||
const writeBinary = vi.fn().mockResolvedValue(undefined);
|
||||
const append = vi.fn().mockResolvedValue(undefined);
|
||||
const app = {
|
||||
vault: {
|
||||
adapter: {
|
||||
write,
|
||||
writeBinary,
|
||||
append,
|
||||
},
|
||||
},
|
||||
} as unknown as App;
|
||||
const adapter = new ObsidianStorageAdapter(app);
|
||||
const options = { ctime: 1778511180024.462, mtime: 1778511180999.913 };
|
||||
const expectedOptions = { ctime: 1778511180024, mtime: 1778511180999 };
|
||||
|
||||
await adapter.write("note.md", "text", options);
|
||||
await adapter.writeBinary("image.bin", new ArrayBuffer(0), options);
|
||||
await adapter.append("log.md", "text", options);
|
||||
|
||||
expect(write).toHaveBeenCalledWith("note.md", "text", expectedOptions);
|
||||
expect(writeBinary).toHaveBeenCalledWith("image.bin", expect.any(ArrayBuffer), expectedOptions);
|
||||
expect(append).toHaveBeenCalledWith("log.md", "text", expectedOptions);
|
||||
expect(options).toEqual({ ctime: 1778511180024.462, mtime: 1778511180999.913 });
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/commo
|
||||
import type { IVaultAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters";
|
||||
import { toArrayBuffer } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase";
|
||||
import type { TFile, App, TFolder } from "obsidian";
|
||||
import { toIntegerTimestamps } from "./sanitizeWriteOptions";
|
||||
|
||||
/**
|
||||
* Vault adapter implementation for Obsidian
|
||||
@@ -23,19 +24,19 @@ export class ObsidianVaultAdapter implements IVaultAdapter<TFile, TFolder> {
|
||||
}
|
||||
|
||||
async modify(file: TFile, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||
return await this.app.vault.modify(file, data, options);
|
||||
return await this.app.vault.modify(file, data, toIntegerTimestamps(options));
|
||||
}
|
||||
|
||||
async modifyBinary(file: TFile, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
||||
return await this.app.vault.modifyBinary(file, toArrayBuffer(data), options);
|
||||
return await this.app.vault.modifyBinary(file, toArrayBuffer(data), toIntegerTimestamps(options));
|
||||
}
|
||||
|
||||
async create(path: string, data: string, options?: UXDataWriteOptions): Promise<TFile> {
|
||||
return await this.app.vault.create(path, data, options);
|
||||
return await this.app.vault.create(path, data, toIntegerTimestamps(options));
|
||||
}
|
||||
|
||||
async createBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<TFile> {
|
||||
return await this.app.vault.createBinary(path, toArrayBuffer(data), options);
|
||||
return await this.app.vault.createBinary(path, toArrayBuffer(data), toIntegerTimestamps(options));
|
||||
}
|
||||
|
||||
async rename(file: TFile, newPath: string): Promise<void> {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import type { App, TFile } from "obsidian";
|
||||
import { ObsidianVaultAdapter } from "./ObsidianVaultAdapter";
|
||||
|
||||
describe("ObsidianVaultAdapter.read", () => {
|
||||
describe("ObsidianVaultAdapter", () => {
|
||||
it("preserves a UTF-8 BOM so the content size matches the file stat", async () => {
|
||||
const path = "Transcripts/字幕.md";
|
||||
const contentWithoutBom = "字幕の検証行です。\n";
|
||||
@@ -34,4 +34,34 @@ describe("ObsidianVaultAdapter.read", () => {
|
||||
expect(adapterRead).toHaveBeenCalledWith(path);
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("floors write-option timestamps before calling Obsidian vault methods", async () => {
|
||||
const modify = vi.fn().mockResolvedValue(undefined);
|
||||
const modifyBinary = vi.fn().mockResolvedValue(undefined);
|
||||
const create = vi.fn().mockResolvedValue({});
|
||||
const createBinary = vi.fn().mockResolvedValue({});
|
||||
const app = {
|
||||
vault: {
|
||||
modify,
|
||||
modifyBinary,
|
||||
create,
|
||||
createBinary,
|
||||
},
|
||||
} as unknown as App;
|
||||
const file = { path: "note.md" } as TFile;
|
||||
const adapter = new ObsidianVaultAdapter(app);
|
||||
const options = { ctime: 1778511180024.462, mtime: 1778511180999.913 };
|
||||
const expectedOptions = { ctime: 1778511180024, mtime: 1778511180999 };
|
||||
|
||||
await adapter.modify(file, "text", options);
|
||||
await adapter.modifyBinary(file, new ArrayBuffer(0), options);
|
||||
await adapter.create("created.md", "text", options);
|
||||
await adapter.createBinary("created.bin", new ArrayBuffer(0), options);
|
||||
|
||||
expect(modify).toHaveBeenCalledWith(file, "text", expectedOptions);
|
||||
expect(modifyBinary).toHaveBeenCalledWith(file, expect.any(ArrayBuffer), expectedOptions);
|
||||
expect(create).toHaveBeenCalledWith("created.md", "text", expectedOptions);
|
||||
expect(createBinary).toHaveBeenCalledWith("created.bin", expect.any(ArrayBuffer), expectedOptions);
|
||||
expect(options).toEqual({ ctime: 1778511180024.462, mtime: 1778511180999.913 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
/**
|
||||
* Coerce the timestamp fields of a write-options object to integer milliseconds.
|
||||
*
|
||||
* On mobile, Obsidian forwards `mtime`/`ctime` to Capacitor's
|
||||
* Filesystem.setTimes, whose native binding casts the value to a Java `Long`.
|
||||
* A non-integer (float) timestamp makes that cast throw
|
||||
* `ClassCastException: Double cannot be cast to Long`, which crashes the app on
|
||||
* launch as soon as such a document is replicated in. Float timestamps can
|
||||
* enter the database from any client that stores `fs.Stats.mtimeMs` without
|
||||
* flooring. Flooring at the storage boundary guarantees every Obsidian write
|
||||
* carries an integer, so a float timestamp already present in the mesh cannot
|
||||
* brick the app.
|
||||
*
|
||||
* Returns a shallow copy so the caller's options object is not mutated; passes
|
||||
* `undefined` through unchanged.
|
||||
*/
|
||||
export function toIntegerTimestamps(options?: UXDataWriteOptions): UXDataWriteOptions | undefined {
|
||||
if (!options) return options;
|
||||
const sanitized: UXDataWriteOptions = { ...options };
|
||||
if (typeof sanitized.mtime === "number") sanitized.mtime = Math.floor(sanitized.mtime);
|
||||
if (typeof sanitized.ctime === "number") sanitized.ctime = Math.floor(sanitized.ctime);
|
||||
return sanitized;
|
||||
}
|
||||
+28
@@ -12,6 +12,34 @@ Earlier releases remain available in the 0.25 release history and the legacy rel
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 1.0.11
|
||||
|
||||
9th August, 2026
|
||||
|
||||
### Setup and compatibility
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Fast Setup now uses Standard Fetch when CouchDB's 'Use Internal API' setting is enabled, avoiding a streaming request path which Obsidian's buffered API cannot support (#1020).
|
||||
- Custom headers alone continue to use Fast Fetch when browser CORS permits them; Standard Fetch clears any obsolete Fast Fetch checkpoint after resetting the local database.
|
||||
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Fractional file timestamps no longer cause affected mobile clients to crash after synchronisation (#1087, PR #1039). Thank you to @andrewleech for the contribution!
|
||||
- Timestamps are now normalised in the command-line tool and before Obsidian's native file-system writes.
|
||||
|
||||
## 1.0.10
|
||||
|
||||
9th August, 2026
|
||||
|
||||
### Setup and compatibility
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Fast Setup now sends configured CouchDB custom headers with every changes-feed request, allowing reverse proxies such as Cloudflare Access to authenticate initial setup in the same way as ordinary synchronisation ([Commonlib PR #82](https://github.com/vrtmrz/livesync-commonlib/pull/82)). Thank you to @nimula for the contribution!
|
||||
|
||||
## 1.0.9
|
||||
|
||||
8th August, 2026
|
||||
|
||||
+3
-1
@@ -21,5 +21,7 @@
|
||||
"1.0.6": "1.7.2",
|
||||
"1.0.7": "1.7.2",
|
||||
"1.0.8": "1.7.2",
|
||||
"1.0.9": "1.7.2"
|
||||
"1.0.9": "1.7.2",
|
||||
"1.0.10": "1.7.2",
|
||||
"1.0.11": "1.7.2"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user