@shivaedev/form 0.13.1 → 0.15.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +83 -10
  3. package/dist/adapters/browserDraftStorage.d.ts.map +1 -1
  4. package/dist/adapters/browserDraftStorage.js +27 -2
  5. package/dist/adapters/browserDraftStorage.js.map +1 -1
  6. package/dist/adapters/draftCodec.d.ts +7 -0
  7. package/dist/adapters/draftCodec.d.ts.map +1 -0
  8. package/dist/adapters/draftCodec.js +40 -0
  9. package/dist/adapters/draftCodec.js.map +1 -0
  10. package/dist/adapters/numberInput.d.ts +15 -0
  11. package/dist/adapters/numberInput.d.ts.map +1 -0
  12. package/dist/adapters/numberInput.js +11 -0
  13. package/dist/adapters/numberInput.js.map +1 -0
  14. package/dist/adapters/persistence.d.ts +1 -1
  15. package/dist/adapters/persistence.d.ts.map +1 -1
  16. package/dist/adapters/persistence.js +91 -61
  17. package/dist/adapters/persistence.js.map +1 -1
  18. package/dist/adapters/react.d.ts.map +1 -1
  19. package/dist/adapters/react.js +3 -1
  20. package/dist/adapters/react.js.map +1 -1
  21. package/dist/adapters/useList.d.ts +23 -0
  22. package/dist/adapters/useList.d.ts.map +1 -0
  23. package/dist/adapters/useList.js +27 -0
  24. package/dist/adapters/useList.js.map +1 -0
  25. package/dist/draft.d.ts +2 -2
  26. package/dist/draft.d.ts.map +1 -1
  27. package/dist/draft.js +67 -45
  28. package/dist/draft.js.map +1 -1
  29. package/dist/draftValues.d.ts +13 -0
  30. package/dist/draftValues.d.ts.map +1 -0
  31. package/dist/draftValues.js +47 -0
  32. package/dist/draftValues.js.map +1 -0
  33. package/dist/field.d.ts +17 -0
  34. package/dist/field.d.ts.map +1 -0
  35. package/dist/field.js +20 -0
  36. package/dist/field.js.map +1 -0
  37. package/dist/form.d.ts.map +1 -1
  38. package/dist/form.js +17 -9
  39. package/dist/form.js.map +1 -1
  40. package/dist/persistence.d.ts +15 -11
  41. package/dist/persistence.d.ts.map +1 -1
  42. package/dist/persistence.js.map +1 -1
  43. package/package.json +4 -4
  44. package/src/adapters/browserDraftStorage.ts +30 -2
  45. package/src/adapters/draftCodec.ts +43 -0
  46. package/src/adapters/numberInput.ts +17 -0
  47. package/src/adapters/persistence.ts +97 -63
  48. package/src/adapters/react.ts +3 -1
  49. package/src/adapters/useList.ts +39 -0
  50. package/src/draft.ts +70 -62
  51. package/src/draftValues.ts +65 -0
  52. package/src/field.ts +34 -0
  53. package/src/form.ts +12 -6
  54. package/src/persistence.ts +17 -7
@@ -1,87 +1,121 @@
1
- import { Schema, SchemaParser } from "effect";
2
- import type { PersistedDraftConfig, Persistence } from "#persistence.ts";
1
+ import { Data, Effect, type Schema, Semaphore } from "effect";
2
+ import type { DraftOperation, PersistedDraftConfig, Persistence } from "#persistence.ts";
3
+ import { draftCodec } from "./draftCodec.ts";
4
+
5
+ class DraftStorageUnavailable extends Data.TaggedError("DraftStorageUnavailable")<{ readonly cause: unknown }> {}
6
+
7
+ function operation<A>(run: () => DraftOperation<A>): Effect.Effect<A, DraftStorageUnavailable> {
8
+ return Effect.suspend(() => {
9
+ const value = run();
10
+ return Effect.isEffect(value) ? value : Effect.succeed(value);
11
+ }).pipe(Effect.catchCause((cause) => Effect.fail(new DraftStorageUnavailable({ cause }))));
12
+ }
3
13
 
4
14
  export function persistence<TSchema extends Schema.Constraint>(schema: TSchema, config: PersistedDraftConfig): Persistence<TSchema["Encoded"]> {
5
- const encoded = Schema.toEncoded(schema);
6
- const StoredDraft = Schema.Struct({
7
- baseline: Schema.Unknown,
8
- edited: Schema.Array(Schema.String),
9
- session: Schema.String,
10
- updatedAt: Schema.Number,
11
- values: Schema.Unknown,
12
- version: Schema.Literal(1),
13
- });
14
- const valid = Schema.is(StoredDraft);
15
- const decode = SchemaParser.decodeUnknownSync(encoded, { disableChecks: true });
16
- const maxAgeMs = config.maxAgeMs ?? 7 * 24 * 60 * 60 * 1000;
17
- const maxBytes = config.maxBytes ?? 256 * 1024;
18
- function bytes(value: string) {
19
- return new TextEncoder().encode(value).byteLength;
20
- }
21
- const storage = config.storage;
22
- const session = config.session?.id ?? "";
15
+ const codec = draftCodec(schema, config);
16
+ const serial = Semaphore.makeUnsafe(1);
17
+ let revision: { value: string | undefined } = { value: undefined };
23
18
  function active() {
24
19
  return config.session?.active !== false;
25
20
  }
26
- function attempt<A>(run: () => A, failed?: () => void): A | undefined {
21
+ function run(effect: Effect.Effect<unknown, DraftStorageUnavailable>) {
22
+ return Effect.runCallback(serial.withPermit(effect).pipe(Effect.catchTag("DraftStorageUnavailable", () => Effect.void)));
23
+ }
24
+ function clear(expected = revision.value) {
25
+ if (expected !== undefined) {
26
+ run(operation(() => config.storage().removeItem(config.key, expected)));
27
+ }
28
+ }
29
+ function read(raw: string | null) {
27
30
  try {
28
- return run();
31
+ return { values: raw === null ? undefined : codec.read(raw) };
29
32
  } catch {
30
- failed?.();
33
+ if (raw !== null) {
34
+ clear(raw);
35
+ }
31
36
  return undefined;
32
37
  }
33
38
  }
34
- function clear() {
35
- attempt(() => storage().removeItem(config.key));
36
- }
37
- config.session?.track(config.key, clear);
39
+ config.session?.track(config.key, () =>
40
+ run(
41
+ Effect.gen(function* () {
42
+ const storage = yield* operation(config.storage);
43
+ const raw = yield* operation(() => storage.getItem(config.key));
44
+ if (raw !== null) {
45
+ yield* operation(() => codec.read(raw));
46
+ yield* operation(() => storage.removeItem(config.key, raw));
47
+ }
48
+ }),
49
+ ),
50
+ );
38
51
  return {
39
- clear: () => {
52
+ clear: (expected = revision.value) => {
40
53
  if (active()) {
41
- clear();
54
+ clear(expected);
42
55
  }
43
56
  },
44
- read: () =>
45
- attempt(() => {
46
- if (!active()) {
47
- return undefined;
57
+ observe: (receive) => {
58
+ let stopped = false;
59
+ let changes = 0;
60
+ function changed(raw: string | null, loaded = false) {
61
+ if (stopped || !active()) {
62
+ return;
48
63
  }
49
- const raw = storage().getItem(config.key);
50
- if (raw === null) {
51
- return undefined;
64
+ if (!loaded && revision.value === (raw ?? undefined)) {
65
+ return;
52
66
  }
53
- const value: unknown = bytes(raw) <= maxBytes ? JSON.parse(raw) : undefined;
54
- const now = config.now();
55
- if (
56
- valid(value)
57
- && value.session === session
58
- && Number.isFinite(maxAgeMs)
59
- && Number.isFinite(maxBytes)
60
- && maxAgeMs > 0
61
- && maxBytes > 0
62
- && now - value.updatedAt <= maxAgeMs
63
- && value.updatedAt <= now
64
- ) {
65
- return { baseline: decode(value.baseline), edited: value.edited, values: decode(value.values) };
67
+ const parsed = read(raw);
68
+ if (parsed && receive(parsed.values)) {
69
+ revision = { value: raw ?? undefined };
66
70
  }
67
- clear();
68
- return undefined;
69
- }, clear),
70
- write: (baseline, values, edited) => {
71
+ }
72
+ let unsubscribe: (() => void) | undefined;
73
+ const stop = Effect.runCallback(
74
+ Effect.gen(function* () {
75
+ if (!active()) {
76
+ return;
77
+ }
78
+ const storage = yield* operation(config.storage);
79
+ unsubscribe = yield* operation(() =>
80
+ storage.subscribe?.(config.key, (incoming) => {
81
+ changes += 1;
82
+ changed(incoming);
83
+ }),
84
+ );
85
+ const before = changes;
86
+ const captured = revision;
87
+ const raw = yield* operation(() => storage.getItem(config.key));
88
+ if (before === changes && captured === revision) {
89
+ changed(raw, true);
90
+ }
91
+ }).pipe(Effect.catchTag("DraftStorageUnavailable", () => Effect.void)),
92
+ );
93
+ return () => {
94
+ stopped = true;
95
+ unsubscribe?.();
96
+ stop();
97
+ };
98
+ },
99
+ revision: () => {
100
+ const captured = revision;
101
+ return () => captured.value;
102
+ },
103
+ write: (baseline, values, edited, sameInput) => {
71
104
  if (!active()) {
72
105
  return;
73
106
  }
74
- attempt(() => {
75
- const raw = JSON.stringify({ baseline, edited, session, updatedAt: config.now(), values, version: 1 });
76
- const parsed: unknown = JSON.parse(raw);
77
- if (!(maxAgeMs > 0 && Number.isFinite(maxAgeMs) && Number.isFinite(maxBytes) && bytes(raw) <= maxBytes && valid(parsed))) {
78
- clear();
79
- return;
107
+ try {
108
+ const expected = sameInput ? revision.value : undefined;
109
+ const raw = codec.write(baseline, values, edited);
110
+ if (sameInput) {
111
+ revision.value = raw;
112
+ } else {
113
+ revision = { value: raw };
80
114
  }
81
- decode(parsed.baseline);
82
- decode(parsed.values);
83
- storage().setItem(config.key, raw);
84
- }, clear);
115
+ run(operation(() => (active() ? config.storage().setItem(config.key, raw, expected) : undefined)));
116
+ } catch {
117
+ clear();
118
+ }
85
119
  },
86
120
  };
87
121
  }
@@ -1,4 +1,4 @@
1
- import { useAtomRef, useAtomSet, useAtomValue } from "@effect/atom-react";
1
+ import { useAtomMount, useAtomRef, useAtomSet, useAtomValue } from "@effect/atom-react";
2
2
  import type * as AsyncResult from "effect/reactivity/AsyncResult";
3
3
  import { createContext, useCallback, useContext, useMemo } from "react";
4
4
  import type { DraftSession } from "#draftSession.ts";
@@ -23,6 +23,7 @@ export function useField<TFields extends Fields, A, E, TRuntimeError, TKey exten
23
23
  form: Form<TFields, A, E, TRuntimeError>,
24
24
  name: TKey,
25
25
  ): Field<TFields, TKey> {
26
+ useAtomMount(form.dirty);
26
27
  const value = useAtomRef(form.field(name));
27
28
  const error = useAtomValue(form.error(name));
28
29
  const onChange = useCallback((next: Encoded<TFields>[TKey]) => form.change(name, next), [form, name]);
@@ -38,6 +39,7 @@ export interface Submit<A, E> {
38
39
  export function useSubmit<TFields extends Fields, A, E, TRuntimeError>(
39
40
  form: Form<TFields, A, E, TRuntimeError>,
40
41
  ): Submit<A, E | TRuntimeError | FieldFailure | Invalid> {
42
+ useAtomMount(form.dirty);
41
43
  const result = useAtomValue(form.submit);
42
44
  const set = useAtomSet(form.submit);
43
45
  const run = useCallback(() => set(), [set]);
@@ -0,0 +1,39 @@
1
+ import { useAtomMount, useAtomRef, useAtomValue } from "@effect/atom-react";
2
+ import * as Atom from "effect/reactivity/Atom";
3
+ import type * as AtomRef from "effect/reactivity/AtomRef";
4
+ import { useMemo } from "react";
5
+
6
+ interface ListForm<TKey extends string, TItem> {
7
+ readonly blur: (name: TKey) => void;
8
+ readonly change: (name: TKey, value: readonly TItem[]) => void;
9
+ readonly dirty: Atom.Atom<boolean>;
10
+ readonly error: (name: TKey) => Atom.Atom<string | undefined>;
11
+ readonly errorAt: (path: readonly [TKey, ...PropertyKey[]]) => Atom.Atom<string | undefined>;
12
+ readonly field: (name: TKey) => AtomRef.ReadonlyRef<readonly TItem[]>;
13
+ }
14
+
15
+ export function useList<TKey extends string, TItem>(form: ListForm<NoInfer<TKey>, TItem>, name: TKey) {
16
+ useAtomMount(form.dirty);
17
+ const values = useAtomRef(form.field(name));
18
+ const error = useAtomValue(form.error(name));
19
+ const errors = useAtomValue(
20
+ useMemo(() => Atom.readable((get) => values.map((_, index) => get(form.errorAt([name, index])))), [form, name, values]),
21
+ );
22
+ function change(next: readonly TItem[]) {
23
+ form.change(name, next);
24
+ }
25
+ function current() {
26
+ return form.field(name).value;
27
+ }
28
+ return {
29
+ add: (value: TItem) => change([...current(), value]),
30
+ error,
31
+ items: values.map((value, index) => ({
32
+ error: errors[index],
33
+ onChange: (next: TItem) => change(current().map((item, at) => (at === index ? next : item))),
34
+ onRemove: () => change(current().filter((_, at) => at !== index)),
35
+ value,
36
+ })),
37
+ onBlur: () => form.blur(name),
38
+ };
39
+ }
package/src/draft.ts CHANGED
@@ -1,63 +1,66 @@
1
- import { Equal } from "effect";
2
1
  import * as Atom from "effect/reactivity/Atom";
3
2
  import * as AtomRef from "effect/reactivity/AtomRef";
4
- import type { Persistence } from "./persistence.ts";
5
-
6
- type Entries = Readonly<Record<string, unknown>>;
7
-
8
- interface Draft<TValues> {
9
- readonly baseline: TValues;
10
- readonly current: TValues;
11
- readonly settled: Entries;
12
- }
13
-
14
- const none: Entries = {};
15
-
16
- function untouched({ baseline, current, settled }: Draft<Entries>, name: string): boolean {
17
- return Equal.equals(current[name], baseline[name]) || (Object.hasOwn(settled, name) && Equal.equals(current[name], settled[name]));
18
- }
19
-
20
- function changed(held: Draft<Entries>): boolean {
21
- return Object.keys({ ...held.baseline, ...held.current }).some((name) => !untouched(held, name));
22
- }
23
-
24
- function merged<TValues>(held: Draft<TValues>, incoming: TValues): TValues;
25
- function merged(held: Draft<Entries>, incoming: Entries): unknown {
26
- const result: Record<string, unknown> = {};
27
- for (const name of Object.keys({ ...held.current, ...incoming })) {
28
- const source = untouched(held, name) ? incoming : held.current;
29
- if (Object.hasOwn(source, name)) {
30
- result[name] = source[name];
31
- }
32
- }
33
- return result;
34
- }
35
-
36
- function matching(current: Entries, submitted: Entries): Entries {
37
- return Object.fromEntries(
38
- Object.keys({ ...current, ...submitted })
39
- .filter((name) => Equal.equals(current[name], submitted[name]))
40
- .map((name) => [name, submitted[name]]),
41
- );
42
- }
3
+ import { accepted, changed, changedFields, type Draft, type Entries, merged, mergeObserved, untouched } from "./draftValues.ts";
4
+ import type { Persistence, StoredValues } from "./persistence.ts";
43
5
 
44
6
  export function draft<TValues extends Entries>(initial: TValues, persistence?: Persistence<TValues>) {
45
- const restored = persistence?.read();
46
- const loaded = {
47
- baseline: restored?.baseline ?? initial,
48
- current: restored?.values ?? initial,
49
- settled: restored ? Object.fromEntries(Object.entries(restored.values).filter(([name]) => !restored.edited.includes(name))) : none,
50
- };
51
- const state = AtomRef.make<Draft<TValues>>({
52
- baseline: initial,
53
- current: restored ? merged(loaded, initial) : initial,
54
- settled: none,
7
+ const none: Entries = {};
8
+ const state = AtomRef.make<Draft<TValues>>({ baseline: initial, current: initial, settled: none });
9
+ const local = new Set<string>();
10
+ let previous = initial;
11
+ let receiving = false;
12
+ let refreshing = false;
13
+ function withoutWriting(run: () => void) {
14
+ receiving = true;
15
+ run();
16
+ receiving = false;
17
+ }
18
+ function restore(restored: StoredValues<TValues> | undefined) {
19
+ const held = state.value;
20
+ const incoming = restored
21
+ ? merged(
22
+ {
23
+ baseline: restored.baseline,
24
+ current: restored.values,
25
+ settled: Object.fromEntries(Object.entries(restored.values).filter(([name]) => !restored.edited.includes(name))),
26
+ },
27
+ held.baseline,
28
+ )
29
+ : held.baseline;
30
+ withoutWriting(() => state.update((value) => ({ ...value, current: mergeObserved(value.current, incoming, local) })));
31
+ return local.size === 0;
32
+ }
33
+ const mounted = Atom.readable((get) => {
34
+ if (persistence) {
35
+ get.addFinalizer(persistence.observe(restore));
36
+ }
55
37
  });
38
+ function releaseCleanFields() {
39
+ for (const name of local) {
40
+ if (untouched(state.value, name)) {
41
+ local.delete(name);
42
+ }
43
+ }
44
+ }
45
+ function store(value: Draft<TValues>, sameInput = refreshing) {
46
+ const edited = Object.keys({ ...value.baseline, ...value.current }).filter((name) => !untouched(value, name));
47
+ persistence?.write({ ...value.baseline, ...value.settled }, value.current, edited, sameInput);
48
+ }
56
49
  if (persistence) {
57
50
  state.subscribe((value) => {
51
+ const before = previous;
52
+ previous = value.current;
53
+ if (receiving) {
54
+ return;
55
+ }
56
+ if (!refreshing) {
57
+ for (const name of changedFields(before, value.current)) {
58
+ local.add(name);
59
+ }
60
+ }
61
+ releaseCleanFields();
58
62
  if (changed(value)) {
59
- const edited = Object.keys({ ...value.baseline, ...value.current }).filter((name) => !untouched(value, name));
60
- persistence.write({ ...value.baseline, ...value.settled }, value.current, edited);
63
+ store(value);
61
64
  } else {
62
65
  persistence.clear();
63
66
  }
@@ -65,15 +68,20 @@ export function draft<TValues extends Entries>(initial: TValues, persistence?: P
65
68
  }
66
69
  return {
67
70
  dirty: Atom.readable((get) => {
71
+ get(mounted);
68
72
  get.addFinalizer(state.subscribe((value) => get.setSelf(changed(value))));
69
73
  return changed(state.value);
70
74
  }),
75
+ mounted,
71
76
  receive: (incoming: TValues): void => {
77
+ refreshing = true;
72
78
  state.update((held) => ({
73
79
  baseline: incoming,
74
80
  current: changed(held) ? merged(held, incoming) : incoming,
75
81
  settled: none,
76
82
  }));
83
+ refreshing = false;
84
+ releaseCleanFields();
77
85
  },
78
86
  revert: (): void => {
79
87
  state.update((held) => ({
@@ -84,19 +92,19 @@ export function draft<TValues extends Entries>(initial: TValues, persistence?: P
84
92
  },
85
93
  submission: () => {
86
94
  const { baseline, current: submitted } = state.value;
95
+ const revision = persistence?.revision();
87
96
  return {
88
97
  accept: (incoming?: TValues): void => {
89
- state.update((held) => {
90
- if (held.baseline !== baseline) {
91
- return { ...held, settled: matching(held.current, submitted) };
98
+ withoutWriting(() => state.update((held) => accepted(held, baseline, submitted, incoming)));
99
+ releaseCleanFields();
100
+ if (changed(state.value)) {
101
+ store(state.value, true);
102
+ } else {
103
+ const expected = revision?.();
104
+ if (expected !== undefined) {
105
+ persistence?.clear(expected);
92
106
  }
93
- return {
94
- ...held,
95
- baseline: incoming ?? submitted,
96
- current: incoming === undefined ? held.current : merged({ ...held, baseline: submitted, settled: none }, incoming),
97
- settled: none,
98
- };
99
- });
107
+ }
100
108
  },
101
109
  values: submitted,
102
110
  };
@@ -0,0 +1,65 @@
1
+ import { Equal } from "effect";
2
+
3
+ export type Entries = Readonly<Record<string, unknown>>;
4
+
5
+ export interface Draft<TValues> {
6
+ readonly baseline: TValues;
7
+ readonly current: TValues;
8
+ readonly settled: Entries;
9
+ }
10
+
11
+ export function untouched({ baseline, current, settled }: Draft<Entries>, name: string): boolean {
12
+ return Equal.equals(current[name], baseline[name]) || (Object.hasOwn(settled, name) && Equal.equals(current[name], settled[name]));
13
+ }
14
+
15
+ export function changed(held: Draft<Entries>): boolean {
16
+ return Object.keys({ ...held.baseline, ...held.current }).some((name) => !untouched(held, name));
17
+ }
18
+
19
+ export function merged<TValues>(held: Draft<TValues>, incoming: TValues): TValues;
20
+ export function merged(held: Draft<Entries>, incoming: Entries): unknown {
21
+ const result: Record<string, unknown> = {};
22
+ for (const name of Object.keys({ ...held.current, ...incoming })) {
23
+ const source = untouched(held, name) ? incoming : held.current;
24
+ if (Object.hasOwn(source, name)) {
25
+ result[name] = source[name];
26
+ }
27
+ }
28
+ return result;
29
+ }
30
+
31
+ function matching(current: Entries, submitted: Entries): Entries {
32
+ return Object.fromEntries(
33
+ Object.keys({ ...current, ...submitted })
34
+ .filter((name) => Equal.equals(current[name], submitted[name]))
35
+ .map((name) => [name, submitted[name]]),
36
+ );
37
+ }
38
+
39
+ export function mergeObserved<TValues>(current: TValues, incoming: TValues, local: ReadonlySet<string>): TValues;
40
+ export function mergeObserved(current: Entries, incoming: Entries, local: ReadonlySet<string>): unknown {
41
+ const result: Record<string, unknown> = {};
42
+ for (const name of Object.keys({ ...current, ...incoming })) {
43
+ const source = local.has(name) ? current : incoming;
44
+ if (Object.hasOwn(source, name)) {
45
+ result[name] = source[name];
46
+ }
47
+ }
48
+ return result;
49
+ }
50
+
51
+ export function changedFields(previous: Entries, current: Entries): readonly string[] {
52
+ return Object.keys({ ...previous, ...current }).filter((name) => !Equal.equals(previous[name], current[name]));
53
+ }
54
+
55
+ export function accepted<TValues extends Entries>(held: Draft<TValues>, baseline: TValues, submitted: TValues, incoming?: TValues): Draft<TValues> {
56
+ if (held.baseline !== baseline) {
57
+ return { ...held, settled: matching(held.current, submitted) };
58
+ }
59
+ return {
60
+ ...held,
61
+ baseline: incoming ?? submitted,
62
+ current: incoming === undefined ? held.current : merged({ ...held, baseline: submitted, settled: {} }, incoming),
63
+ settled: {},
64
+ };
65
+ }
package/src/field.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { Schema } from "effect";
2
+ import { emptyAsNull } from "./emptyAsNull.ts";
3
+
4
+ export interface NumberOptions {
5
+ readonly integer?: boolean;
6
+ readonly max?: number;
7
+ readonly min?: number;
8
+ readonly optional?: boolean;
9
+ }
10
+ export function number(options?: NumberOptions & { readonly optional?: false }): Schema.Codec<number, string>;
11
+ export function number(options: NumberOptions): Schema.Codec<number | null, string>;
12
+ export function number(options: NumberOptions = {}): Schema.Codec<number | null, string> {
13
+ let populated = Schema.NonEmptyString.pipe(Schema.decodeTo(Schema.FiniteFromString));
14
+ if (options.min !== undefined) {
15
+ populated = populated.check(Schema.isGreaterThanOrEqualTo(options.min));
16
+ }
17
+ if (options.max !== undefined) {
18
+ populated = populated.check(Schema.isLessThanOrEqualTo(options.max));
19
+ }
20
+ if (options.integer) {
21
+ populated = populated.check(Schema.isInt());
22
+ }
23
+ return Schema.Trim.pipe(Schema.decodeTo(options.optional ? emptyAsNull(populated) : populated));
24
+ }
25
+
26
+ export interface ListOptions {
27
+ readonly max: number;
28
+ readonly min?: number;
29
+ }
30
+
31
+ export function list<TItem extends Schema.Constraint>(item: TItem, options: ListOptions) {
32
+ const values = Schema.Array(item).check(Schema.isMaxLength(options.max));
33
+ return options.min === undefined ? values : values.check(Schema.isMinLength(options.min));
34
+ }
package/src/form.ts CHANGED
@@ -49,7 +49,11 @@ export function form<TFields extends Fields, TValue, TError, TServices = never,
49
49
  return made;
50
50
  }
51
51
 
52
- const valuesAtom = fromRef(values);
52
+ const fromValues = fromRef(values);
53
+ const valuesAtom = Atom.readable((get) => {
54
+ get(editing.mounted);
55
+ return get(fromValues);
56
+ });
53
57
 
54
58
  const decoded = runtime.atom((get) =>
55
59
  decode(get(valuesAtom)).pipe(
@@ -77,13 +81,15 @@ export function form<TFields extends Fields, TValue, TError, TServices = never,
77
81
  fail: (path, message) => Effect.fail(new FieldFailure(typeof path === "string" ? { message, path } : { message, path: path[0], segments: path })),
78
82
  };
79
83
 
80
- const validate = runtime.fn<void>()(() => {
84
+ const validate = runtime.fn<void>()((_, get) => {
85
+ get.mount(editing.mounted);
81
86
  status.attempt();
82
87
  return snapshot(values.value);
83
88
  });
84
89
 
85
- const submit = runtime.fn<void>()(() =>
86
- Effect.gen(function* () {
90
+ const submit = runtime.fn<void>()((_, get) => {
91
+ get.mount(editing.mounted);
92
+ return Effect.gen(function* () {
87
93
  status.attempt();
88
94
  const submission = editing.submission();
89
95
  const submitted = submission.values;
@@ -92,8 +98,8 @@ export function form<TFields extends Fields, TValue, TError, TServices = never,
92
98
  Effect.tapError(noted(submitted)),
93
99
  Effect.tap((saved) => Effect.sync(() => submission.accept(config.savedValues?.(saved)))),
94
100
  );
95
- }),
96
- );
101
+ });
102
+ });
97
103
 
98
104
  return {
99
105
  blur: status.touch,
@@ -1,10 +1,13 @@
1
- import { Context } from "effect";
1
+ import { Context, type Effect } from "effect";
2
2
  import type { DraftSession } from "./draftSession.ts";
3
3
 
4
+ export type DraftOperation<A> = A | Effect.Effect<A, unknown>;
5
+
4
6
  export interface DraftStorage {
5
- readonly getItem: (key: string) => string | null;
6
- readonly removeItem: (key: string) => void;
7
- readonly setItem: (key: string, value: string) => void;
7
+ readonly getItem: (key: string) => DraftOperation<string | null>;
8
+ readonly removeItem: (key: string, expected: string) => DraftOperation<void>;
9
+ readonly setItem: (key: string, value: string, expected?: string) => DraftOperation<void>;
10
+ readonly subscribe?: (key: string, listener: (value: string | null) => void) => () => void;
8
11
  }
9
12
 
10
13
  export class DraftStorageProvider extends Context.Service<DraftStorageProvider, () => DraftStorage>()("@shivaedev/form/DraftStorageProvider") {}
@@ -18,8 +21,15 @@ export interface PersistedDraftConfig {
18
21
  readonly storage: () => DraftStorage;
19
22
  }
20
23
 
24
+ export interface StoredValues<TValues> {
25
+ readonly baseline: TValues;
26
+ readonly edited: readonly string[];
27
+ readonly values: TValues;
28
+ }
29
+
21
30
  export interface Persistence<TValues> {
22
- readonly clear: () => void;
23
- readonly read: () => { readonly baseline: TValues; readonly edited: readonly string[]; readonly values: TValues } | undefined;
24
- readonly write: (baseline: TValues, values: TValues, edited: readonly string[]) => void;
31
+ readonly clear: (revision?: string) => void;
32
+ readonly observe: (receive: (values: StoredValues<TValues> | undefined) => boolean) => () => void;
33
+ readonly revision: () => () => string | undefined;
34
+ readonly write: (baseline: TValues, values: TValues, edited: readonly string[], sameInput?: boolean) => void;
25
35
  }