Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions documentation/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@ await configure({
});
```

If your application runs entirely synchronously (for example, during early Node.js bootstrap), you can use `configureSync` instead. It follows the same shape as `configure`, but it throws if any backend requires asynchronous initialization.

```ts
import { configureSync, InMemory } from '@zenfs/core';

configureSync({
mounts: {
'/tmp': { backend: InMemory, label: 'temp-storage' },
},
});
```

Backends that do all their work eagerly, such as `InMemory` and `SingleBuffer`, are designed to work with these synchronous configuration helpers.

For single-mount scenarios there are matching helpers: use `configureSingle` (or `configureSingleSync`) to replace the root mount without providing a full configuration object.

## Mounting File Systems and `resolveMountConfig`

Mounting file systems in ZenFS is handled dynamically. When a mount configuration is provided, it is processed using `resolveMountConfig`, which determines how the backend should be initialized and mounted.
Expand All @@ -39,6 +55,8 @@ const tmpfs = await resolveMountConfig({
mount('/mnt/tmp', tmpfs);
```

When dealing exclusively with synchronous backends, you can call `resolveMountConfigSync`. It performs the same validation, but it throws if a backend performs any asynchronous work during creation or readiness.

### Dynamic Mounting

Mounts can be resolved dynamically at runtime, allowing flexibility when configuring storage. This is especially useful for:
Expand Down
13 changes: 9 additions & 4 deletions src/backends/cow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { withErrno } from 'kerium';
import { debug, err, warn } from 'kerium/log';
import { canary } from 'utilium';
import { resolveMountConfig, type MountConfiguration } from '../config.js';
import { FileSystem } from '../internal/filesystem.js';
import { FileSystem, ensureReadySync } from '../internal/filesystem.js';
import { isDirectory } from '../internal/inode.js';
import { dirname, join } from '../path.js';

Expand All @@ -18,10 +18,10 @@ import { dirname, join } from '../path.js';
*/
export interface CopyOnWriteOptions {
/** The file system that initially populates this file system. */
readable: MountConfiguration<any>;
readable: MountConfiguration<Backend>;

/** The file system to write modified files to. */
writable: MountConfiguration<any>;
writable: MountConfiguration<Backend>;

/** @see {@link Journal} */
journal?: Journal;
Expand All @@ -37,7 +37,7 @@ export type JournalOperation = (typeof journalOperations)[number];

/** Because TS doesn't work right w/o it */
function isJournalOp(op: string): op is JournalOperation {
return journalOperations.includes(op as any);
return journalOperations.some(operation => operation === op);
}

const maxOpLength = Math.max(...journalOperations.map(op => op.length));
Expand Down Expand Up @@ -129,6 +129,11 @@ export class CopyOnWriteFS extends FileSystem {
await this.writable.ready();
}

public readySync(): void {
ensureReadySync(this.readable);
ensureReadySync(this.writable);
}

public constructor(
/** The file system that initially populates this file system. */
public readonly readable: FileSystem,
Expand Down
80 changes: 80 additions & 0 deletions src/backends/store/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,18 @@ export class StoreFS<T extends Store = Store> extends FileSystem {
this._initialized = true;
}

public readySync(): void {
if (this._initialized) return;

if (!this.attributes.has('no_async_preload')) {
this.checkRootSync();
}

this.checkRootSync();
this._populateSync();
this._initialized = true;
}

public constructor(protected readonly store: T) {
super(store.type ?? 0x6b766673, store.name);
store.fs = this;
Expand Down Expand Up @@ -617,6 +629,74 @@ export class StoreFS<T extends Store = Store> extends FileSystem {
debug(`Added ${i} existing inode(s) from store`);
}

private _populateSync(): void {
if (this._initialized) {
warn('Attempted to populate tables after initialization');
return;
}
debug('Populating tables with existing store metadata');
using tx = this.transaction();

const rootData = tx.getSync(rootIno);
if (!rootData) {
notice('Store does not have a root inode');
const inode = new Inode({ ino: rootIno, data: 1, mode: 0o777 | S_IFDIR });
tx.setSync(inode.data, encodeUTF8('{}'));
this._add(rootIno, '/');
tx.setSync(rootIno, inode);
tx.commitSync();
return;
}

if (rootData.length < sizeof(Inode)) {
crit('Store contains an invalid root inode. Refusing to populate tables');
return;
}

const visitedDirectories = new Set<number>();
let i = 0;
const queue: Array<[path: string, ino: number]> = [['/', rootIno]];

while (queue.length > 0) {
i++;
const [path, ino] = queue.shift()!;

this._add(ino, path);

const inodeData = tx.getSync(ino);
if (!inodeData) {
warn('Store is missing data for inode: ' + ino);
continue;
}

if (inodeData.length < sizeof(Inode)) {
warn(`Invalid inode size for ino ${ino}: ${inodeData.length}`);
continue;
}

const inode = new Inode(inodeData);

if ((inode.mode & S_IFDIR) != S_IFDIR || visitedDirectories.has(ino)) {
continue;
}

visitedDirectories.add(ino);

const dirData = tx.getSync(inode.data);
if (!dirData) {
warn('Store is missing directory data: ' + inode.data);
continue;
}
const dirListing = decodeDirListing(dirData);

for (const [entryName, childIno] of Object.entries(dirListing)) {
queue.push([join(path, entryName), childIno]);
}
}

debug(`Added ${i} existing inode(s) from store`);
}

/**
* Find an inode without using the ID tables
*/
Expand Down
144 changes: 143 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
import { defaultContext } from './internal/contexts.js';
import { createCredentials } from './internal/credentials.js';
import { DeviceFS } from './internal/devices.js';
import { FileSystem } from './internal/filesystem.js';
import { FileSystem, ensureReadySync } from './internal/filesystem.js';
import { exists, mkdir, stat } from './node/promises.js';
import { existsSync, mkdirSync, statSync } from './node/sync.js';
import { _setAccessChecks } from './vfs/config.js';
import { mount, mounts, umount } from './vfs/shared.js';

Expand All @@ -31,6 +32,10 @@
return isBackendConfig(arg) || isBackend(arg) || arg instanceof FileSystem;
}

function isThenable(value: unknown): value is PromiseLike<unknown> {
return typeof (value as PromiseLike<unknown>)?.then == 'function';
}

/**
* Retrieve a file system with `configuration`.
* @category Backends and Configuration
Expand Down Expand Up @@ -80,6 +85,61 @@
return mount;
}

export function resolveMountConfigSync<T extends Backend>(configuration: MountConfiguration<T>, _depth = 0): FilesystemOf<T> {
if (typeof configuration !== 'object' || configuration == null) {
throw log.err(withErrno('EINVAL', 'Invalid options on mount configuration'));
}

if (!isMountConfig(configuration)) {
throw log.err(withErrno('EINVAL', 'Invalid mount configuration'));
}

if (configuration instanceof FileSystem) {
ensureReadySync(configuration);
return configuration as FilesystemOf<T>;
}

if (isBackend(configuration)) {
configuration = { backend: configuration } as BackendConfiguration<T>;
}

for (const [key, value] of Object.entries(configuration)) {
if (key == 'backend') continue;
if (!isMountConfig(value)) continue;

log.info('Resolving nested mount configuration: ' + key);

if (_depth > 10) {
throw log.err(withErrno('EINVAL', 'Invalid configuration, too deep and possibly infinite'));
}

(configuration as Record<string, FileSystem>)[key] = resolveMountConfigSync(value, ++_depth);
}

const backendConfig = configuration as BackendConfiguration<T>;

Check warning on line 119 in src/config.ts

View workflow job for this annotation

GitHub Actions / Continuous Integration

This assertion is unnecessary since it does not change the type of the expression

Check warning on line 119 in src/config.ts

View workflow job for this annotation

GitHub Actions / Continuous Integration

This assertion is unnecessary since it does not change the type of the expression
const { backend } = backendConfig;

if (typeof backend.isAvailable == 'function') {
const available = backend.isAvailable(backendConfig);
if (isThenable(available)) {
throw log.err(withErrno('ENOTSUP', 'Backend availability check is asynchronous: ' + backend.name));
}
if (!available) {
throw log.err(withErrno('EPERM', 'Backend not available: ' + backend.name));
}
}

checkOptions(backend, backendConfig);
const mountFs = backend.create(backendConfig);
if (isThenable(mountFs)) {
throw log.err(withErrno('ENOTSUP', 'Backend requires asynchronous initialization: ' + backend.name));
}
const resolved = mountFs as FilesystemOf<T>;
configureFileSystem(resolved, backendConfig);
ensureReadySync(resolved);
return resolved;
}

/**
* An object mapping mount points to backends
* @category Backends and Configuration
Expand Down Expand Up @@ -160,6 +220,16 @@
mount('/', resolved);
}

export function configureSingleSync<T extends Backend>(configuration: MountConfiguration<T>): void {
if (!isMountConfig(configuration)) {
throw new TypeError('Invalid single mount point configuration');
}

const resolved = resolveMountConfigSync(configuration);
umount('/');
mount('/', resolved);
}

/**
* Like `fs.mount`, but it also creates missing directories.
* @privateRemarks
Expand All @@ -181,6 +251,27 @@
mount(path, fs);
}

function mountWithMkdirSync(path: string, fs: FileSystem): void {
if (path == '/') {
mount(path, fs);
return;
}

let stats: { isDirectory(): boolean } | null = null;
try {
stats = statSync(path);
} catch (error: any) {

Check warning on line 263 in src/config.ts

View workflow job for this annotation

GitHub Actions / Continuous Integration

Unexpected any. Specify a different type

Check warning on line 263 in src/config.ts

View workflow job for this annotation

GitHub Actions / Continuous Integration

Unexpected any. Specify a different type
if (error?.code != 'ENOENT') throw error;
}

if (!stats) {
mkdirSync(path, { recursive: true });
} else if (!stats.isDirectory()) {
throw withErrno('ENOTDIR', 'Missing directory at mount point: ' + path);
}
mount(path, fs);
}

/**
* @category Backends and Configuration
*/
Expand Down Expand Up @@ -247,6 +338,57 @@
}
}

export function configureSync<T extends ConfigMounts>(configuration: Partial<Configuration<T>>): void {
Object.assign(
defaultContext.credentials,
createCredentials({
uid: configuration.uid || 0,
gid: configuration.gid || 0,
})
);

_setAccessChecks(!configuration.disableAccessChecks);

if (configuration.log) log.configure(configuration.log);

if (configuration.mounts) {
for (const [_point, mountConfig] of Object.entries(configuration.mounts).sort(([a], [b]) => (a.length > b.length ? 1 : -1))) {
const point = _point.startsWith('/') ? _point : '/' + _point;

if (isBackendConfig(mountConfig)) {
mountConfig.disableAsyncCache ??= configuration.disableAsyncCache || false;
mountConfig.caseFold ??= configuration.caseFold;
}

if (point == '/') umount('/');

mountWithMkdirSync(point, resolveMountConfigSync(mountConfig));
}
}

for (const fs of mounts.values()) {
configureFileSystem(fs, configuration);
}

if (configuration.addDevices && !mounts.has('/dev')) {
const devfs = new DeviceFS();
devfs.addDefaults();
ensureReadySync(devfs);
mountWithMkdirSync('/dev', devfs);
}

if (configuration.defaultDirectories) {
for (const dir of _defaultDirectories) {
if (existsSync(dir)) {
const stats = statSync(dir);
if (!stats.isDirectory()) log.warn('Default directory exists but is not a directory: ' + dir);
continue;
}
mkdirSync(dir);
}
}
}

export async function sync(): Promise<void> {
for (const fs of mounts.values()) await fs.sync();
}
14 changes: 14 additions & 0 deletions src/internal/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { UUID } from 'node:crypto';
import type { ConstMap } from 'utilium';
import type { InodeLike } from './inode.js';

import { withErrno } from 'kerium';

/**
* Usage information about a file system
* @category Internals
Expand Down Expand Up @@ -349,3 +351,15 @@ export abstract class FileSystem {
});
}
}

export function ensureReadySync(fs: FileSystem): void {
const readySync = (fs as FileSystem & { readySync?: () => void }).readySync;
if (typeof readySync == 'function') {
readySync.call(fs);
return;
}

if (fs.ready === FileSystem.prototype.ready) return;

throw withErrno('ENOTSUP', 'Synchronous initialization is not supported by ' + fs.name);
}
5 changes: 5 additions & 0 deletions src/mixins/mutexed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import type { UUID } from 'node:crypto';
import type { Concrete } from 'utilium';
import type { CreationOptions, FileSystem, StreamOptions, UsageInfo } from '../internal/filesystem.js';
import { ensureReadySync } from '../internal/filesystem.js';
import type { InodeLike } from '../internal/inode.js';

import { withErrno } from 'kerium';
Expand Down Expand Up @@ -83,6 +84,10 @@ export class _MutexedFS<T extends FileSystem> implements FileSystem {
return await this._fs.ready();
}

public readySync(): void {
ensureReadySync(this._fs);
}

public usage(): UsageInfo {
return this._fs.usage();
}
Expand Down
Loading
Loading