@opfs-vfs/effect 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +73 -0
- package/README.md +211 -0
- package/dist/coordinator.d.ts +50 -0
- package/dist/errors.d.ts +76 -0
- package/dist/errors.js +159 -0
- package/dist/filesystem.d.ts +9 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +1031 -0
- package/dist/subscriptions-internal.d.ts +11 -0
- package/dist/subscriptions.d.ts +24 -0
- package/dist/volume-BIqpe5Vt.js +357 -0
- package/dist/volume.d.ts +52 -0
- package/dist/volume.js +3 -0
- package/examples/direct.ts +8 -0
- package/examples/encrypted-session.ts +347 -0
- package/examples/encrypted-session.worker.ts +5 -0
- package/examples/filesystem-save.ts +30 -0
- package/examples/filesystem-stream.ts +20 -0
- package/examples/reconciled-view.ts +74 -0
- package/examples/subscriptions.ts +20 -0
- package/examples/worker-session.ts +32 -0
- package/package.json +64 -0
package/dist/volume.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Scope } from 'effect';
|
|
2
|
+
import { OpfsVfs, type LocalPersistenceState, type OpfsVfsOptions, type VolumePeek } from '@opfs-vfs/opfs-vfs';
|
|
3
|
+
import type { OpenOpfsVfsWorkerOptions } from '@opfs-vfs/opfs-vfs/worker';
|
|
4
|
+
import type { OpfsVfsWorkerClient } from '@opfs-vfs/opfs-vfs/worker-client';
|
|
5
|
+
import type { ConfiguredVfsPlugin, VfsPluginRequest } from '@opfs-vfs/opfs-vfs/plugins';
|
|
6
|
+
import { EncryptionError, SubscriptionError, VolumeError, type MountError, type RemoteErrorDetails } from './errors.js';
|
|
7
|
+
export interface DirectMountOptions extends Omit<OpfsVfsOptions, 'plugins'> {
|
|
8
|
+
readonly fileName: string;
|
|
9
|
+
readonly plugins?: () => readonly ConfiguredVfsPlugin[];
|
|
10
|
+
}
|
|
11
|
+
export interface WorkerMountOptions extends Omit<OpenOpfsVfsWorkerOptions, 'signal' | 'plugins'> {
|
|
12
|
+
readonly fileName: string;
|
|
13
|
+
readonly plugins?: readonly VfsPluginRequest[] | (() => readonly VfsPluginRequest[]);
|
|
14
|
+
}
|
|
15
|
+
export interface PersistenceSnapshot {
|
|
16
|
+
/** Current backend telemetry; `clean` does not prove an earlier logical save survived, so await `sync`. */
|
|
17
|
+
readonly state: LocalPersistenceState | 'unknown';
|
|
18
|
+
readonly error: RemoteErrorDetails | null;
|
|
19
|
+
}
|
|
20
|
+
export interface VolumeService {
|
|
21
|
+
readonly fileName: string;
|
|
22
|
+
readonly sync: Effect.Effect<void, MountError>;
|
|
23
|
+
readonly persistence: Effect.Effect<PersistenceSnapshot, VolumeError>;
|
|
24
|
+
readonly acknowledgeOwnerChange: Effect.Effect<void, VolumeError>;
|
|
25
|
+
}
|
|
26
|
+
type Input<E, R> = DirectMountOptions | Effect.Effect<DirectMountOptions, E, R>;
|
|
27
|
+
declare const Volume_base: Context.ServiceClass<Volume, "@opfs-vfs/effect/Volume", VolumeService>;
|
|
28
|
+
export declare class Volume extends Volume_base {
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Runs an effect, then syncs the current volume on success, preserving its result.
|
|
32
|
+
* The effect's filesystem must use this same volume. Failure or interruption can
|
|
33
|
+
* leave writes applied without this sync completing. This provides no rollback
|
|
34
|
+
* or isolation; nested wrappers each sync, and concurrent writes may also flush.
|
|
35
|
+
*/
|
|
36
|
+
export declare const withSync: <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E | MountError, R | Volume>;
|
|
37
|
+
export declare const inspect: (fileName: string) => Effect.Effect<VolumePeek, VolumeError>;
|
|
38
|
+
export declare function makeDirect(input: DirectMountOptions): Effect.Effect<VolumeService, MountError, Scope.Scope>;
|
|
39
|
+
export declare function makeDirect<E, R>(input: Effect.Effect<DirectMountOptions, E, R>): Effect.Effect<VolumeService, MountError | E, Scope.Scope | R>;
|
|
40
|
+
export declare function makeDirect<E = never, R = never>(input: Input<E, R>): Effect.Effect<VolumeService, MountError | E, Scope.Scope | R>;
|
|
41
|
+
export declare function layerDirect(input: DirectMountOptions): Layer.Layer<Volume, MountError>;
|
|
42
|
+
export declare function layerDirect<E, R>(input: Effect.Effect<DirectMountOptions, E, R>): Layer.Layer<Volume, MountError | E, R>;
|
|
43
|
+
export declare function layerDirect<E = never, R = never>(input: Input<E, R>): Layer.Layer<Volume, MountError | E, R>;
|
|
44
|
+
export declare function make<E = never, R = never>(input: WorkerMountOptions | Effect.Effect<WorkerMountOptions, E, R>): Effect.Effect<VolumeService, MountError | E, Scope.Scope | R>;
|
|
45
|
+
export declare function layer<E = never, R = never>(input: WorkerMountOptions | Effect.Effect<WorkerMountOptions, E, R>): Layer.Layer<Volume, MountError | E, R>;
|
|
46
|
+
export declare const unsafeBackend: (volume: VolumeService) => OpfsVfs | OpfsVfsWorkerClient;
|
|
47
|
+
export declare const errorOf: (error: {
|
|
48
|
+
readonly reason?: {
|
|
49
|
+
readonly cause?: unknown;
|
|
50
|
+
};
|
|
51
|
+
}) => VolumeError | EncryptionError | SubscriptionError | undefined;
|
|
52
|
+
export {};
|
package/dist/volume.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { a as e, c as t, i as n, n as r, o as i, r as a, s as o, t as s, u as c } from "./volume-BIqpe5Vt.js";
|
|
2
|
+
import "./errors.js";
|
|
3
|
+
export { s as Volume, r as errorOf, a as inspect, n as layer, e as layerDirect, i as make, o as makeDirect, t as unsafeBackend, c as withSync };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import { Volume } from '@opfs-vfs/effect';
|
|
3
|
+
|
|
4
|
+
export const save = Effect.gen(function* () {
|
|
5
|
+
const volume = yield* Volume.makeDirect({ fileName: 'app.bin' });
|
|
6
|
+
yield* volume.sync;
|
|
7
|
+
return yield* volume.persistence;
|
|
8
|
+
}).pipe(Effect.scoped);
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Context,
|
|
3
|
+
Effect,
|
|
4
|
+
Exit,
|
|
5
|
+
FileSystem,
|
|
6
|
+
Layer,
|
|
7
|
+
ManagedRuntime,
|
|
8
|
+
PlatformError,
|
|
9
|
+
Redacted,
|
|
10
|
+
Schema,
|
|
11
|
+
Stream,
|
|
12
|
+
} from 'effect';
|
|
13
|
+
import { OpfsFileSystem, Subscriptions, Volume, VolumeError, EncryptionError } from '@opfs-vfs/effect';
|
|
14
|
+
import { encryptionRequest } from '@opfs-vfs/plugin-encryption/config';
|
|
15
|
+
import { subscriptionsRequest } from '@opfs-vfs/plugin-subscriptions/config';
|
|
16
|
+
|
|
17
|
+
export { Context, Effect, Exit, FileSystem, Layer, ManagedRuntime, PlatformError, Redacted, Schema, Stream };
|
|
18
|
+
export { EncryptionError, OpfsFileSystem, Subscriptions, Volume, VolumeError };
|
|
19
|
+
|
|
20
|
+
export type PluginProfile = 'plain' | 'subscriptions' | 'encrypted' | 'combined';
|
|
21
|
+
export type CombinedRequestOrder = 'encryption-first' | 'subscriptions-first';
|
|
22
|
+
export type Secret = Redacted.Redacted<string | Uint8Array>;
|
|
23
|
+
|
|
24
|
+
const worker = () => new Worker(new URL('./encrypted-session.worker.ts', import.meta.url), { type: 'module' });
|
|
25
|
+
|
|
26
|
+
const requests = (config: SessionConfig) => {
|
|
27
|
+
const encryption =
|
|
28
|
+
config.profile === 'encrypted' || config.profile === 'combined'
|
|
29
|
+
? encryptionRequest({
|
|
30
|
+
secret: Redacted.value(config.secret),
|
|
31
|
+
...(config.openMode === 'create-new' && config.initialPasskey
|
|
32
|
+
? { initialPasskey: { secret: config.initialPasskey } }
|
|
33
|
+
: {}),
|
|
34
|
+
})
|
|
35
|
+
: undefined;
|
|
36
|
+
const subscriptions =
|
|
37
|
+
config.profile === 'subscriptions' || config.profile === 'combined' ? subscriptionsRequest() : undefined;
|
|
38
|
+
const order = config.order ?? 'encryption-first';
|
|
39
|
+
if (order === 'subscriptions-first') return [subscriptions, encryption].filter((item) => item !== undefined);
|
|
40
|
+
return [encryption, subscriptions].filter((item) => item !== undefined);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
interface SessionConfigBase {
|
|
44
|
+
readonly fileName: string;
|
|
45
|
+
readonly order?: CombinedRequestOrder;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type SessionConfig = SessionConfigBase &
|
|
49
|
+
(
|
|
50
|
+
| { readonly profile: 'plain' | 'subscriptions'; readonly openMode: 'create-new' | 'open-existing' }
|
|
51
|
+
| {
|
|
52
|
+
readonly profile: 'encrypted' | 'combined';
|
|
53
|
+
readonly openMode: 'create-new';
|
|
54
|
+
readonly secret: Secret;
|
|
55
|
+
readonly initialPasskey?: Uint8Array;
|
|
56
|
+
}
|
|
57
|
+
| {
|
|
58
|
+
readonly profile: 'encrypted' | 'combined';
|
|
59
|
+
readonly openMode: 'open-existing';
|
|
60
|
+
readonly secret: Secret;
|
|
61
|
+
readonly initialPasskey?: never;
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const encrypted = (profile: PluginProfile) => profile === 'encrypted' || profile === 'combined';
|
|
66
|
+
const observed = (profile: PluginProfile) => profile === 'subscriptions' || profile === 'combined';
|
|
67
|
+
|
|
68
|
+
const missingVolume = (fileName: string) =>
|
|
69
|
+
new VolumeError({
|
|
70
|
+
kind: 'filesystem',
|
|
71
|
+
fileName,
|
|
72
|
+
operation: 'open-existing',
|
|
73
|
+
code: 'ENOENT',
|
|
74
|
+
outcome: 'not-applied',
|
|
75
|
+
details: { message: 'The requested existing volume does not exist', code: 'ENOENT' },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const inspectionPolicyFailure = (fileName: string, code: string, message: string, kind: VolumeError['kind']) =>
|
|
79
|
+
new VolumeError({
|
|
80
|
+
kind,
|
|
81
|
+
fileName,
|
|
82
|
+
operation: 'inspect',
|
|
83
|
+
code,
|
|
84
|
+
outcome: 'not-applied',
|
|
85
|
+
details: { message, code },
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const volumeLayer = (config: SessionConfig) => {
|
|
89
|
+
const options = {
|
|
90
|
+
fileName: config.fileName,
|
|
91
|
+
openMode: config.openMode,
|
|
92
|
+
transport: 'dedicated' as const,
|
|
93
|
+
...(encrypted(config.profile) ? { worker } : {}),
|
|
94
|
+
plugins: () => requests(config),
|
|
95
|
+
};
|
|
96
|
+
if (config.openMode === 'create-new') return Volume.layer(options);
|
|
97
|
+
return Layer.effect(
|
|
98
|
+
Volume.Volume,
|
|
99
|
+
Effect.gen(function* () {
|
|
100
|
+
const info = yield* Volume.inspect(config.fileName);
|
|
101
|
+
if (!info.exists) return yield* Effect.fail(missingVolume(config.fileName));
|
|
102
|
+
if (info.importing)
|
|
103
|
+
return yield* Effect.fail(
|
|
104
|
+
inspectionPolicyFailure(config.fileName, 'VOLUME_IMPORTING', 'Volume import is incomplete', 'lifecycle'),
|
|
105
|
+
);
|
|
106
|
+
if (info.compatible === false)
|
|
107
|
+
return yield* Effect.fail(
|
|
108
|
+
inspectionPolicyFailure(
|
|
109
|
+
config.fileName,
|
|
110
|
+
'VFS_INCOMPATIBLE_VOLUME',
|
|
111
|
+
'Volume metadata is incompatible with this build',
|
|
112
|
+
'unsupported',
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
return yield* Volume.make(options);
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const makeSessionLayer = (config: SessionConfig) => {
|
|
121
|
+
const volume = volumeLayer(config);
|
|
122
|
+
return observed(config.profile)
|
|
123
|
+
? Layer.merge(OpfsFileSystem.layer, Subscriptions.layer).pipe(Layer.provideMerge(volume))
|
|
124
|
+
: OpfsFileSystem.layer.pipe(Layer.provideMerge(volume));
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const createEncryptedLayer = (
|
|
128
|
+
fileName: string,
|
|
129
|
+
secret: Secret,
|
|
130
|
+
initialPasskey?: Uint8Array,
|
|
131
|
+
order: CombinedRequestOrder = 'encryption-first',
|
|
132
|
+
) =>
|
|
133
|
+
makeSessionLayer({
|
|
134
|
+
fileName,
|
|
135
|
+
profile: 'encrypted',
|
|
136
|
+
openMode: 'create-new',
|
|
137
|
+
secret,
|
|
138
|
+
order,
|
|
139
|
+
initialPasskey,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
export const makeSessionRuntime = (config: SessionConfig) => ManagedRuntime.make(makeSessionLayer(config));
|
|
143
|
+
|
|
144
|
+
export type SessionRuntime = ReturnType<typeof makeSessionRuntime>;
|
|
145
|
+
export type SessionExit<A = unknown> = Exit.Exit<A, unknown>;
|
|
146
|
+
|
|
147
|
+
export const startSessionRuntime = async (runtime: SessionRuntime) => {
|
|
148
|
+
const startExit: SessionExit<void> = await runtime.runPromiseExit(Effect.void);
|
|
149
|
+
if (Exit.isSuccess(startExit)) return { _tag: 'Started' as const, runtime, startExit };
|
|
150
|
+
const disposeExit = await Effect.runPromiseExit(runtime.disposeEffect);
|
|
151
|
+
return { _tag: 'StartFailed' as const, runtime, startExit, disposeExit };
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const credentialsFailure = (exit: SessionExit): EncryptionError | undefined => {
|
|
155
|
+
if (Exit.isSuccess(exit) || exit.cause.reasons.length !== 1) return undefined;
|
|
156
|
+
const [reason] = exit.cause.reasons;
|
|
157
|
+
if (reason?._tag !== 'Fail') return undefined;
|
|
158
|
+
const error = reason.error;
|
|
159
|
+
if (Schema.is(EncryptionError)(error) && error.reason === 'CredentialsRejected') return error;
|
|
160
|
+
if (!PlatformError.isPlatformError(error)) return undefined;
|
|
161
|
+
const wrapper = error.reason.cause;
|
|
162
|
+
if (
|
|
163
|
+
Schema.is(VolumeError)(wrapper) &&
|
|
164
|
+
wrapper.kind === 'lifecycle' &&
|
|
165
|
+
Schema.is(EncryptionError)(wrapper.cause) &&
|
|
166
|
+
wrapper.cause.reason === 'CredentialsRejected'
|
|
167
|
+
)
|
|
168
|
+
return wrapper.cause;
|
|
169
|
+
return undefined;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const saveOnce = (contents: string) =>
|
|
173
|
+
Effect.gen(function* () {
|
|
174
|
+
const fs = yield* FileSystem.FileSystem;
|
|
175
|
+
const volume = yield* Volume.Volume;
|
|
176
|
+
yield* fs.writeFileString('/session-save.txt', contents);
|
|
177
|
+
yield* volume.sync;
|
|
178
|
+
return 'saved' as const;
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
export type SessionSaveResult =
|
|
182
|
+
| { readonly _tag: 'Saved'; readonly useExit: SessionExit<'saved'> }
|
|
183
|
+
| {
|
|
184
|
+
readonly _tag: 'Replaced';
|
|
185
|
+
readonly useExit: SessionExit<'saved'>;
|
|
186
|
+
readonly disposeExit: Exit.Exit<void, never>;
|
|
187
|
+
readonly startExit: SessionExit<void>;
|
|
188
|
+
}
|
|
189
|
+
| {
|
|
190
|
+
readonly _tag: 'ReplacementBlocked';
|
|
191
|
+
readonly useExit: SessionExit<'saved'>;
|
|
192
|
+
readonly disposeExit: Exit.Exit<void, never>;
|
|
193
|
+
}
|
|
194
|
+
| {
|
|
195
|
+
readonly _tag: 'ReplacementCancelled';
|
|
196
|
+
readonly useExit: SessionExit<'saved'>;
|
|
197
|
+
readonly disposeExit: Exit.Exit<void, never>;
|
|
198
|
+
}
|
|
199
|
+
| {
|
|
200
|
+
readonly _tag: 'ReplacementFailed';
|
|
201
|
+
readonly useExit: SessionExit<'saved'>;
|
|
202
|
+
readonly disposeExit: Exit.Exit<void, never>;
|
|
203
|
+
readonly startExit: SessionExit<void>;
|
|
204
|
+
readonly replacementDisposeExit: Exit.Exit<void, never>;
|
|
205
|
+
}
|
|
206
|
+
| { readonly _tag: 'Failed'; readonly useExit: SessionExit<'saved'> }
|
|
207
|
+
| { readonly _tag: 'Unavailable' };
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* `config` must match `initial`'s fileName, profile, and plugin request order. A replacement supplies a new secret only.
|
|
211
|
+
*/
|
|
212
|
+
export const makeSessionController = (
|
|
213
|
+
initial: SessionRuntime,
|
|
214
|
+
config: SessionConfigBase & { readonly profile: 'encrypted' | 'combined' },
|
|
215
|
+
requestCredentials: (error: EncryptionError) => Promise<Secret | undefined>,
|
|
216
|
+
) => {
|
|
217
|
+
let current: SessionRuntime | undefined = initial;
|
|
218
|
+
let accepting = true;
|
|
219
|
+
let closed = false;
|
|
220
|
+
const closeRequested = Promise.withResolvers<undefined>();
|
|
221
|
+
let serial = Promise.resolve();
|
|
222
|
+
const ordered = <A>(work: () => Promise<A>): Promise<A> => {
|
|
223
|
+
const result = serial.then(work, work);
|
|
224
|
+
serial = result.then(
|
|
225
|
+
() => undefined,
|
|
226
|
+
() => undefined,
|
|
227
|
+
);
|
|
228
|
+
return result;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const save = (contents: string): Promise<SessionSaveResult> => {
|
|
232
|
+
const admitted = accepting ? current : undefined;
|
|
233
|
+
return ordered(async () => {
|
|
234
|
+
if (!admitted || !accepting || current !== admitted) return { _tag: 'Unavailable' };
|
|
235
|
+
const previous = current;
|
|
236
|
+
if (!previous) return { _tag: 'Unavailable' };
|
|
237
|
+
const useExit: SessionExit<'saved'> = await previous.runPromiseExit(saveOnce(contents));
|
|
238
|
+
if (Exit.isSuccess(useExit)) return { _tag: 'Saved', useExit };
|
|
239
|
+
const credentials = credentialsFailure(useExit);
|
|
240
|
+
if (!credentials) return { _tag: 'Failed', useExit };
|
|
241
|
+
|
|
242
|
+
accepting = false;
|
|
243
|
+
current = undefined;
|
|
244
|
+
// The use fiber has exited. Disposal runs outside that runtime and its full Exit is retained.
|
|
245
|
+
const disposeExit = await Effect.runPromiseExit(previous.disposeEffect);
|
|
246
|
+
if (Exit.isFailure(disposeExit)) return { _tag: 'ReplacementBlocked', useExit, disposeExit };
|
|
247
|
+
|
|
248
|
+
if (closed) return { _tag: 'ReplacementCancelled', useExit, disposeExit };
|
|
249
|
+
|
|
250
|
+
let secret: Secret | undefined;
|
|
251
|
+
try {
|
|
252
|
+
secret = await Promise.race([requestCredentials(credentials), closeRequested.promise]);
|
|
253
|
+
} catch {
|
|
254
|
+
return { _tag: 'ReplacementCancelled', useExit, disposeExit };
|
|
255
|
+
}
|
|
256
|
+
if (closed || !secret) return { _tag: 'ReplacementCancelled', useExit, disposeExit };
|
|
257
|
+
|
|
258
|
+
const replacement = makeSessionRuntime({ ...config, openMode: 'open-existing', secret });
|
|
259
|
+
const startExit: SessionExit<void> = await replacement.runPromiseExit(Effect.void);
|
|
260
|
+
if (Exit.isFailure(startExit)) {
|
|
261
|
+
const replacementDisposeExit = await Effect.runPromiseExit(replacement.disposeEffect);
|
|
262
|
+
return { _tag: 'ReplacementFailed', useExit, disposeExit, startExit, replacementDisposeExit };
|
|
263
|
+
}
|
|
264
|
+
current = replacement;
|
|
265
|
+
accepting = !closed;
|
|
266
|
+
return { _tag: 'Replaced', useExit, disposeExit, startExit };
|
|
267
|
+
});
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const runtime = () => current;
|
|
271
|
+
const close = () => {
|
|
272
|
+
closed = true;
|
|
273
|
+
accepting = false;
|
|
274
|
+
closeRequested.resolve(undefined);
|
|
275
|
+
return ordered(async () => {
|
|
276
|
+
if (!current) return Exit.succeed(undefined);
|
|
277
|
+
const closing = current;
|
|
278
|
+
current = undefined;
|
|
279
|
+
return Effect.runPromiseExit(closing.disposeEffect);
|
|
280
|
+
});
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
return { save, runtime, close };
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
export const acceptReconciledSave = Effect.gen(function* () {
|
|
287
|
+
const volume = yield* Volume.Volume;
|
|
288
|
+
yield* volume.acknowledgeOwnerChange;
|
|
289
|
+
yield* volume.sync;
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
export class DocumentsFs extends Context.Service<
|
|
293
|
+
DocumentsFs,
|
|
294
|
+
{ readonly fs: FileSystem.FileSystem; readonly volume: Volume.VolumeService }
|
|
295
|
+
>()('app/DocumentsFs') {}
|
|
296
|
+
export class CacheFs extends Context.Service<
|
|
297
|
+
CacheFs,
|
|
298
|
+
{ readonly fs: FileSystem.FileSystem; readonly volume: Volume.VolumeService }
|
|
299
|
+
>()('app/CacheFs') {}
|
|
300
|
+
|
|
301
|
+
export const makeSharedNamedFileSystems = (fileName: string) => {
|
|
302
|
+
const volume = Volume.layer({ fileName, plugins: [subscriptionsRequest()] });
|
|
303
|
+
const services = Layer.merge(OpfsFileSystem.layer, Subscriptions.layer).pipe(Layer.provideMerge(volume));
|
|
304
|
+
const consumers = Layer.mergeAll(
|
|
305
|
+
Layer.effect(
|
|
306
|
+
DocumentsFs,
|
|
307
|
+
Effect.gen(function* () {
|
|
308
|
+
return { fs: yield* FileSystem.FileSystem, volume: yield* Volume.Volume };
|
|
309
|
+
}),
|
|
310
|
+
),
|
|
311
|
+
Layer.effect(
|
|
312
|
+
CacheFs,
|
|
313
|
+
Effect.gen(function* () {
|
|
314
|
+
return { fs: yield* FileSystem.FileSystem, volume: yield* Volume.Volume };
|
|
315
|
+
}),
|
|
316
|
+
),
|
|
317
|
+
);
|
|
318
|
+
return consumers.pipe(Layer.provideMerge(services));
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
export const makeDocumentsFileSystem = (fileName: string) =>
|
|
322
|
+
Layer.effect(
|
|
323
|
+
DocumentsFs,
|
|
324
|
+
Effect.gen(function* () {
|
|
325
|
+
return { fs: yield* FileSystem.FileSystem, volume: yield* Volume.Volume };
|
|
326
|
+
}),
|
|
327
|
+
).pipe(
|
|
328
|
+
Layer.provideMerge(
|
|
329
|
+
Layer.merge(OpfsFileSystem.layer, Subscriptions.layer).pipe(
|
|
330
|
+
Layer.provideMerge(Volume.layer({ fileName, plugins: [subscriptionsRequest()] })),
|
|
331
|
+
),
|
|
332
|
+
),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
export const makeCacheFileSystem = (fileName: string) =>
|
|
336
|
+
Layer.effect(
|
|
337
|
+
CacheFs,
|
|
338
|
+
Effect.gen(function* () {
|
|
339
|
+
return { fs: yield* FileSystem.FileSystem, volume: yield* Volume.Volume };
|
|
340
|
+
}),
|
|
341
|
+
).pipe(
|
|
342
|
+
Layer.provideMerge(
|
|
343
|
+
Layer.merge(OpfsFileSystem.layer, Subscriptions.layer).pipe(
|
|
344
|
+
Layer.provideMerge(Volume.layer({ fileName, plugins: [subscriptionsRequest()] })),
|
|
345
|
+
),
|
|
346
|
+
),
|
|
347
|
+
);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import { OpfsFileSystem, Volume } from '@opfs-vfs/effect';
|
|
3
|
+
import { subscriptionsRequest } from '@opfs-vfs/plugin-subscriptions/config';
|
|
4
|
+
|
|
5
|
+
// fallow-ignore-next-line unused-export
|
|
6
|
+
export const saveNote = (fileName = `effect-save-${crypto.randomUUID()}.bin`) =>
|
|
7
|
+
Effect.runPromise(
|
|
8
|
+
Effect.scoped(
|
|
9
|
+
Effect.gen(function* () {
|
|
10
|
+
const volume = yield* Volume.make({
|
|
11
|
+
fileName,
|
|
12
|
+
transport: 'dedicated',
|
|
13
|
+
plugins: () => [subscriptionsRequest()],
|
|
14
|
+
});
|
|
15
|
+
const fs = OpfsFileSystem.make(volume);
|
|
16
|
+
yield* fs.writeFileString('/note.txt', 'saved through FileSystem');
|
|
17
|
+
yield* volume.sync.pipe(
|
|
18
|
+
Effect.catchTag('VolumeError', (error) => {
|
|
19
|
+
if (error.code === 'VFS_SYNC_OWNER_CHANGED') {
|
|
20
|
+
return Effect.logWarning(
|
|
21
|
+
'Save status is uncertain; reconcile the current volume before acknowledging it.',
|
|
22
|
+
).pipe(Effect.andThen(Effect.fail(error)));
|
|
23
|
+
}
|
|
24
|
+
return Effect.fail(error);
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
return yield* fs.readFileString('/note.txt');
|
|
28
|
+
}),
|
|
29
|
+
),
|
|
30
|
+
);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ByteSize, Effect, Stream } from 'effect';
|
|
2
|
+
import { OpfsFileSystem, Volume } from '@opfs-vfs/effect';
|
|
3
|
+
|
|
4
|
+
export const streamFile = (fileName = `effect-stream-${crypto.randomUUID()}.bin`) =>
|
|
5
|
+
Effect.runPromise(
|
|
6
|
+
Effect.scoped(
|
|
7
|
+
Effect.gen(function* () {
|
|
8
|
+
const volume = yield* Volume.make({ fileName, transport: 'dedicated' });
|
|
9
|
+
const fs = OpfsFileSystem.make(volume);
|
|
10
|
+
const source = new Uint8Array(16 * 1024 * 1024 + 1);
|
|
11
|
+
source.fill(7);
|
|
12
|
+
yield* fs.writeFile('/source.bin', source);
|
|
13
|
+
const sink = fs.sink('/copy.bin', { flag: 'w' });
|
|
14
|
+
yield* Stream.run(fs.stream('/source.bin', { chunkSize: 64 * 1024 }), sink);
|
|
15
|
+
yield* volume.sync;
|
|
16
|
+
const copy = yield* fs.open('/copy.bin', { flag: 'r' });
|
|
17
|
+
return ByteSize.toBigInt((yield* copy.stat).size);
|
|
18
|
+
}),
|
|
19
|
+
),
|
|
20
|
+
);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Cause, Effect, Fiber, FileSystem, Layer, Queue, Schedule, Schema, Stream } from 'effect';
|
|
2
|
+
import { OpfsFileSystem, Subscriptions, SubscriptionError, Volume } from '@opfs-vfs/effect';
|
|
3
|
+
import { subscriptionsRequest } from '@opfs-vfs/plugin-subscriptions/config';
|
|
4
|
+
|
|
5
|
+
export { Effect, Fiber, FileSystem, Queue, Volume };
|
|
6
|
+
|
|
7
|
+
export const makeLayer = (fileName: string) => {
|
|
8
|
+
const volume = Volume.layer({ fileName, plugins: [subscriptionsRequest()] });
|
|
9
|
+
return Layer.merge(
|
|
10
|
+
volume,
|
|
11
|
+
Layer.merge(Layer.provide(Subscriptions.layer, volume), Layer.provide(OpfsFileSystem.layer, volume)),
|
|
12
|
+
);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export interface ReconciledViewOptions {
|
|
16
|
+
readonly path: string;
|
|
17
|
+
readonly publish: (paths: ReadonlyArray<string>) => Effect.Effect<void>;
|
|
18
|
+
readonly markStale: () => Effect.Effect<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const recoverable = new Set(['SUBSCRIPTION_INTERRUPTED', 'SUBSCRIPTION_OVERFLOW', 'SUBSCRIPTION_RESYNC_REQUIRED']);
|
|
22
|
+
|
|
23
|
+
const isRecoverableCause = (cause: Cause.Cause<unknown>): boolean => {
|
|
24
|
+
const [reason] = cause.reasons;
|
|
25
|
+
return (
|
|
26
|
+
cause.reasons.length === 1 &&
|
|
27
|
+
reason?._tag === 'Fail' &&
|
|
28
|
+
Schema.is(SubscriptionError)(reason.error) &&
|
|
29
|
+
recoverable.has(reason.error.code)
|
|
30
|
+
);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const keepViewCurrent = (options: ReconciledViewOptions) =>
|
|
34
|
+
Effect.gen(function* () {
|
|
35
|
+
const fs = yield* FileSystem.FileSystem;
|
|
36
|
+
const subscriptions = yield* Subscriptions.Subscriptions;
|
|
37
|
+
|
|
38
|
+
let retries = 0;
|
|
39
|
+
const attempt = Effect.gen(function* () {
|
|
40
|
+
let retired: Effect.Effect<Subscriptions.SubscriptionRetirement> | undefined;
|
|
41
|
+
const exit = yield* Effect.exit(
|
|
42
|
+
Effect.scoped(
|
|
43
|
+
Effect.gen(function* () {
|
|
44
|
+
const sub = yield* subscriptions.subscribe({
|
|
45
|
+
path: options.path,
|
|
46
|
+
scope: 'directory',
|
|
47
|
+
recursive: true,
|
|
48
|
+
content: false,
|
|
49
|
+
});
|
|
50
|
+
retired = sub.retired;
|
|
51
|
+
yield* options.publish(yield* fs.readDirectory(options.path, { recursive: true }));
|
|
52
|
+
retries = 0;
|
|
53
|
+
yield* sub.changes.pipe(
|
|
54
|
+
Stream.runForEach(() =>
|
|
55
|
+
fs.readDirectory(options.path, { recursive: true }).pipe(Effect.flatMap(options.publish)),
|
|
56
|
+
),
|
|
57
|
+
);
|
|
58
|
+
}),
|
|
59
|
+
).pipe(Effect.onExit((result) => (result._tag === 'Failure' ? options.markStale() : Effect.void))),
|
|
60
|
+
);
|
|
61
|
+
if (retired) yield* retired;
|
|
62
|
+
return yield* exit;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return yield* attempt.pipe(
|
|
66
|
+
Effect.catchCause((cause) => Effect.fail(cause)),
|
|
67
|
+
Effect.retry(($) =>
|
|
68
|
+
$(Schedule.spaced('250 millis')).pipe(
|
|
69
|
+
Schedule.while(({ input }) => isRecoverableCause(input) && ++retries <= 3),
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
Effect.catch((cause) => Effect.failCause(cause)),
|
|
73
|
+
);
|
|
74
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Effect, FileSystem, Layer, Stream } from 'effect';
|
|
2
|
+
import { OpfsFileSystem, Subscriptions, Volume } from '@opfs-vfs/effect';
|
|
3
|
+
import { subscriptionsRequest } from '@opfs-vfs/plugin-subscriptions/config';
|
|
4
|
+
|
|
5
|
+
export const observeOneChange = (fileName = `effect-subscriptions-${crypto.randomUUID()}.bin`) => {
|
|
6
|
+
const volume = Volume.layer({ fileName, plugins: () => [subscriptionsRequest()] });
|
|
7
|
+
const live = Layer.merge(Layer.provide(Subscriptions.layer, volume), Layer.provide(OpfsFileSystem.layer, volume));
|
|
8
|
+
return Effect.runPromise(
|
|
9
|
+
Effect.scoped(
|
|
10
|
+
Effect.gen(function* () {
|
|
11
|
+
const subscriptions = yield* Subscriptions.Subscriptions;
|
|
12
|
+
const fs = yield* FileSystem.FileSystem;
|
|
13
|
+
const subscription = yield* subscriptions.subscribe({ path: '/', scope: 'directory', recursive: true });
|
|
14
|
+
yield* fs.writeFileString('/observed.txt', 'subscription example');
|
|
15
|
+
const change = yield* Stream.runHead(subscription.changes);
|
|
16
|
+
return { change, retired: yield* subscription.retired };
|
|
17
|
+
}),
|
|
18
|
+
).pipe(Effect.provide(live)),
|
|
19
|
+
);
|
|
20
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import { Volume } from '@opfs-vfs/effect';
|
|
3
|
+
import type { OpfsVfsWorkerClient } from '@opfs-vfs/opfs-vfs/worker-client';
|
|
4
|
+
import { subscriptionsRequest } from '@opfs-vfs/plugin-subscriptions/config';
|
|
5
|
+
|
|
6
|
+
export const runWorkerSession = (fileName = `effect-worker-${crypto.randomUUID()}.bin`) =>
|
|
7
|
+
Effect.runPromise(
|
|
8
|
+
Effect.scoped(
|
|
9
|
+
Effect.gen(function* () {
|
|
10
|
+
const volume = yield* Volume.make({
|
|
11
|
+
fileName,
|
|
12
|
+
transport: 'dedicated',
|
|
13
|
+
plugins: () => [subscriptionsRequest()],
|
|
14
|
+
});
|
|
15
|
+
const backend = Volume.unsafeBackend(volume) as OpfsVfsWorkerClient;
|
|
16
|
+
yield* Effect.tryPromise({
|
|
17
|
+
try: () => backend.writeFileBuffer('/session.txt', new TextEncoder().encode('saved by a worker')),
|
|
18
|
+
catch: (error) => error,
|
|
19
|
+
});
|
|
20
|
+
yield* volume.sync;
|
|
21
|
+
return {
|
|
22
|
+
content: new TextDecoder().decode(
|
|
23
|
+
yield* Effect.tryPromise({
|
|
24
|
+
try: () => backend.readFileBuffer('/session.txt'),
|
|
25
|
+
catch: (error) => error,
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
28
|
+
persistence: yield* volume.persistence,
|
|
29
|
+
};
|
|
30
|
+
}),
|
|
31
|
+
),
|
|
32
|
+
);
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@opfs-vfs/effect",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Effect v4 adapter for OPFS VFS.",
|
|
5
|
+
"license": "PolyForm-Noncommercial-1.0.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/opfs-vfs/opfs-vfs.git",
|
|
9
|
+
"directory": "packages/effect"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"examples",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE.md"
|
|
16
|
+
],
|
|
17
|
+
"type": "module",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./volume": {
|
|
24
|
+
"types": "./dist/volume.d.ts",
|
|
25
|
+
"import": "./dist/volume.js"
|
|
26
|
+
},
|
|
27
|
+
"./errors": {
|
|
28
|
+
"types": "./dist/errors.d.ts",
|
|
29
|
+
"import": "./dist/errors.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public",
|
|
34
|
+
"registry": "https://registry.npmjs.org/"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@effect/platform-node": "4.0.0-rc.118",
|
|
38
|
+
"@opfs-vfs/opfs-vfs": "2.1.0",
|
|
39
|
+
"@opfs-vfs/plugin-subscriptions": "1.2.0",
|
|
40
|
+
"@types/node": "22.20.3",
|
|
41
|
+
"@vitest/browser": "4.1.11",
|
|
42
|
+
"@vitest/browser-playwright": "4.1.11",
|
|
43
|
+
"effect": "4.0.0-rc.118",
|
|
44
|
+
"playwright": "1.59.1",
|
|
45
|
+
"vite": "8.2.2",
|
|
46
|
+
"vitest": "4.1.11"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@opfs-vfs/opfs-vfs": "^2.1.0",
|
|
50
|
+
"@opfs-vfs/plugin-subscriptions": "^1.2.0",
|
|
51
|
+
"effect": "4.0.0-rc.118"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=22.12"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "vite build --config vite.lib.config.ts && tsc -p tsconfig.lib.json",
|
|
58
|
+
"test": "vitest run",
|
|
59
|
+
"test:conformance": "vitest run --config vitest.config.ts src/filesystem-contract.test.ts && vitest run --config vitest.node.config.ts",
|
|
60
|
+
"test:packed": "node scripts/test-packed.mjs",
|
|
61
|
+
"test:encrypted-packed": "node scripts/test-packed.mjs --encrypted",
|
|
62
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
63
|
+
}
|
|
64
|
+
}
|