@pyric/ui 0.1.0-alpha.11 → 0.1.0-alpha.13

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 (152) hide show
  1. package/package.json +5 -4
  2. package/src/agents/ContextWindowUsage.tsx +514 -0
  3. package/src/agents/EmptyState.tsx +27 -0
  4. package/src/agents/Fold.tsx +64 -0
  5. package/src/agents/Modal.tsx +65 -0
  6. package/src/agents/PulsingDot.tsx +28 -0
  7. package/src/agents/inbrowser-agent-usage.d.ts +141 -0
  8. package/src/agents/index.ts +28 -0
  9. package/src/auth/authApi.ts +72 -0
  10. package/src/auth/claims.ts +63 -0
  11. package/src/auth/components/AuthProviderToggles.tsx +147 -0
  12. package/src/auth/components/AuthSignInHelper.tsx +197 -0
  13. package/src/auth/components/AuthUserForm.tsx +328 -0
  14. package/src/auth/components/AuthUserList.tsx +219 -0
  15. package/src/auth/components/ClaimsField.tsx +50 -0
  16. package/src/auth/components/confirmActions.tsx +114 -0
  17. package/src/auth/controller.ts +173 -0
  18. package/src/auth/hooks/index.ts +36 -0
  19. package/src/auth/hooks/useAuthFlowHelper.ts +55 -0
  20. package/src/auth/hooks/useAuthProviderConfig.ts +139 -0
  21. package/src/auth/hooks/useAuthUserEditor.ts +76 -0
  22. package/src/auth/hooks/useAuthUsers.ts +154 -0
  23. package/src/auth/index.ts +42 -0
  24. package/src/auth/providers.ts +28 -0
  25. package/src/auth/reducers/userEditor.ts +186 -0
  26. package/src/events/components/ActivityActionItems.tsx +136 -0
  27. package/src/events/components/ActivityGrid.tsx +197 -0
  28. package/src/events/components/ActivityGridRow.tsx +65 -0
  29. package/src/events/components/ProposedChangeDiff.tsx +175 -0
  30. package/src/events/components/format.ts +21 -0
  31. package/src/events/components/index.ts +17 -0
  32. package/src/events/digest.ts +630 -0
  33. package/src/events/hooks/index.ts +9 -0
  34. package/src/events/hooks/useActivityDigest.ts +42 -0
  35. package/src/events/hooks/useActivityStream.ts +86 -0
  36. package/src/events/index.ts +39 -0
  37. package/src/events/types.ts +152 -0
  38. package/src/firestore/components/CollectionList.tsx +78 -0
  39. package/src/firestore/components/DeleteWithConfirm.tsx +88 -0
  40. package/src/firestore/components/DocumentEditor.tsx +350 -0
  41. package/src/firestore/components/DocumentList.tsx +217 -0
  42. package/src/firestore/components/DocumentPreview.tsx +265 -0
  43. package/src/firestore/components/FieldRenderer.tsx +25 -0
  44. package/src/firestore/components/QueryBuilder.tsx +181 -0
  45. package/src/firestore/components/ReferencePicker.tsx +212 -0
  46. package/src/firestore/components/TreeEntry.tsx +58 -0
  47. package/src/firestore/components/context.ts +25 -0
  48. package/src/firestore/fieldEditors/array.tsx +30 -0
  49. package/src/firestore/fieldEditors/boolean.tsx +39 -0
  50. package/src/firestore/fieldEditors/bytes.tsx +53 -0
  51. package/src/firestore/fieldEditors/geopoint.tsx +71 -0
  52. package/src/firestore/fieldEditors/map.tsx +33 -0
  53. package/src/firestore/fieldEditors/null.tsx +27 -0
  54. package/src/firestore/fieldEditors/number.tsx +43 -0
  55. package/src/firestore/fieldEditors/reference.tsx +87 -0
  56. package/src/firestore/fieldEditors/registry.ts +45 -0
  57. package/src/firestore/fieldEditors/string.tsx +33 -0
  58. package/src/firestore/fieldEditors/timestamp.tsx +75 -0
  59. package/src/firestore/fieldEditors/types.ts +68 -0
  60. package/src/firestore/fieldEditors/vector.tsx +142 -0
  61. package/src/firestore/firestoreApi.ts +86 -0
  62. package/src/firestore/hooks/coerceError.ts +34 -0
  63. package/src/firestore/hooks/index.ts +46 -0
  64. package/src/firestore/hooks/useCollectionList.ts +102 -0
  65. package/src/firestore/hooks/useDocumentEditor.ts +161 -0
  66. package/src/firestore/hooks/useDocumentList.ts +242 -0
  67. package/src/firestore/hooks/useDocumentSubcollections.ts +86 -0
  68. package/src/firestore/hooks/useFirestoreCollection.ts +51 -0
  69. package/src/firestore/hooks/useFirestoreDoc.ts +55 -0
  70. package/src/firestore/hooks/useQueryBuilder.ts +188 -0
  71. package/src/firestore/hooks/useRecursiveDelete.ts +77 -0
  72. package/src/firestore/hooks/useReferencePicker.ts +228 -0
  73. package/src/firestore/import/parseImport.ts +137 -0
  74. package/src/firestore/index.ts +87 -0
  75. package/src/firestore/reducers/defaults.ts +38 -0
  76. package/src/firestore/reducers/documentEditor.ts +239 -0
  77. package/src/firestore/reducers/tree.ts +140 -0
  78. package/src/firestore/reducers/types.ts +92 -0
  79. package/src/firestore/reducers/validation.ts +129 -0
  80. package/src/firestore/types.ts +231 -0
  81. package/src/firestore/validation/ids.ts +45 -0
  82. package/src/firestore/valueEquality.ts +43 -0
  83. package/src/index.ts +10 -0
  84. package/src/primitives/Badge.tsx +40 -0
  85. package/src/primitives/ConfirmDialog.tsx +137 -0
  86. package/src/primitives/CopyButton.tsx +62 -0
  87. package/src/primitives/JsonView.tsx +151 -0
  88. package/src/primitives/SegmentedControl.tsx +72 -0
  89. package/src/primitives/Toast.tsx +161 -0
  90. package/src/primitives/VirtualList.tsx +104 -0
  91. package/src/primitives/hooks/useContainerSize.ts +53 -0
  92. package/src/primitives/hooks/useUpdateHighlights.ts +109 -0
  93. package/src/primitives/index.ts +39 -0
  94. package/src/primitives/useConfirm.tsx +113 -0
  95. package/src/rtdb/components/RtdbPathBar.tsx +135 -0
  96. package/src/rtdb/components/RtdbTree.tsx +409 -0
  97. package/src/rtdb/editor.ts +79 -0
  98. package/src/rtdb/hooks/useRtdbTree.ts +137 -0
  99. package/src/rtdb/index.ts +66 -0
  100. package/src/rtdb/pathInput.ts +47 -0
  101. package/src/rtdb/reducers/tree.ts +191 -0
  102. package/src/rtdb/rtdbApi.ts +23 -0
  103. package/src/rtdb/values.ts +188 -0
  104. package/src/rules/components/DenialInspector.tsx +227 -0
  105. package/src/rules/components/format.ts +171 -0
  106. package/src/rules/components/index.ts +12 -0
  107. package/src/rules/components/scope.ts +75 -0
  108. package/src/rules/hooks/index.ts +5 -0
  109. package/src/rules/hooks/useDenialTrace.ts +100 -0
  110. package/src/rules/index.ts +24 -0
  111. package/src/rules/types.ts +91 -0
  112. package/src/storage/collisionRename.ts +114 -0
  113. package/src/storage/components/DeleteSelectionWithConfirm.tsx +193 -0
  114. package/src/storage/components/ObjectBrowser.tsx +219 -0
  115. package/src/storage/components/ObjectInspector.tsx +169 -0
  116. package/src/storage/components/PathBreadcrumb.tsx +84 -0
  117. package/src/storage/components/UploadDropzone.tsx +182 -0
  118. package/src/storage/folderPlaceholder.ts +40 -0
  119. package/src/storage/hooks/index.ts +59 -0
  120. package/src/storage/hooks/useMetadataEditor.ts +329 -0
  121. package/src/storage/hooks/useObjectUpload.ts +262 -0
  122. package/src/storage/hooks/usePathState.ts +94 -0
  123. package/src/storage/hooks/useStorageDelete.ts +195 -0
  124. package/src/storage/hooks/useStorageList.ts +261 -0
  125. package/src/storage/hooks/useStorageObject.ts +162 -0
  126. package/src/storage/hooks/useStorageRulesGate.ts +270 -0
  127. package/src/storage/hooks/useStorageSelection.ts +90 -0
  128. package/src/storage/index.ts +59 -0
  129. package/src/storage/pendingPrefixes.ts +125 -0
  130. package/src/storage/previews.tsx +120 -0
  131. package/src/storage/storageApi.ts +54 -0
  132. package/src/traffic/components/RuleHeatmap.tsx +97 -0
  133. package/src/traffic/components/TrafficDetail.tsx +160 -0
  134. package/src/traffic/components/TrafficGroupRow.tsx +91 -0
  135. package/src/traffic/components/TrafficLineChart.tsx +139 -0
  136. package/src/traffic/components/TrafficLog.tsx +175 -0
  137. package/src/traffic/components/TrafficMetricCards.tsx +77 -0
  138. package/src/traffic/components/TrafficRow.tsx +69 -0
  139. package/src/traffic/components/TrafficStats.tsx +73 -0
  140. package/src/traffic/components/TrafficTimeline.tsx +289 -0
  141. package/src/traffic/components/format.ts +22 -0
  142. package/src/traffic/components/index.ts +22 -0
  143. package/src/traffic/hooks/index.ts +60 -0
  144. package/src/traffic/hooks/useRuleHeatmap.ts +92 -0
  145. package/src/traffic/hooks/useTrafficBuckets.ts +146 -0
  146. package/src/traffic/hooks/useTrafficFilter.ts +74 -0
  147. package/src/traffic/hooks/useTrafficGroups.ts +126 -0
  148. package/src/traffic/hooks/useTrafficMetrics.ts +250 -0
  149. package/src/traffic/hooks/useTrafficMonitor.ts +111 -0
  150. package/src/traffic/hooks/useTrafficStats.ts +77 -0
  151. package/src/traffic/index.ts +13 -0
  152. package/src/traffic/types.ts +85 -0
@@ -0,0 +1,242 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import type {
3
+ CollectionReference,
4
+ DocumentReference,
5
+ Query,
6
+ QueryDocumentSnapshot,
7
+ } from 'pyric/firestore';
8
+ import { useFirestoreApi } from '../firestoreApi.js';
9
+
10
+ export interface UseDocumentListOptions {
11
+ collection: CollectionReference;
12
+ /** Optional filter / sort. If omitted, the raw collection is used. */
13
+ query?: Query;
14
+ /** Page size for cursor-based pagination. Default 50. */
15
+ pageSize?: number;
16
+ /**
17
+ * `paged` preserves the historical get-based cursor behavior. `live` keeps
18
+ * the currently requested window under an `onSnapshot` subscription and
19
+ * grows that window when `loadMore` is requested. Default `paged`.
20
+ */
21
+ mode?: 'paged' | 'live';
22
+ }
23
+
24
+ export interface UseDocumentListResult {
25
+ documents: QueryDocumentSnapshot[];
26
+ isLoading: boolean;
27
+ error: Error | undefined;
28
+ /** Identifies the active live subscription. Consumers that diff result
29
+ * snapshots can include this in their scope so a re-subscription (including
30
+ * load-more) establishes a silent baseline instead of looking like writes. */
31
+ subscriptionGeneration: number;
32
+ /** True if there might be another page. The hook tracks this via
33
+ * the last fetch's length === pageSize. */
34
+ hasMore: boolean;
35
+ /** Fetch the next page; live mode grows and re-establishes its window. */
36
+ loadMore: () => void;
37
+ /** Create a document. If `id` is null, Firestore generates one
38
+ * via `addDoc`. With `onExisting: 'fail'` (CREATE semantics, the
39
+ * admin `create()` analog) an id that already exists rejects with
40
+ * `code: 'already-exists'` instead of silently overwriting —
41
+ * checked against the BACKEND (a `getDoc` probe), not any loaded
42
+ * page, so it is honest beyond pagination. Default: 'overwrite'
43
+ * (plain `setDoc`, the historical behavior). */
44
+ createDocument: (
45
+ id: string | null,
46
+ data: Record<string, unknown>,
47
+ opts?: { onExisting?: 'overwrite' | 'fail' },
48
+ ) => Promise<DocumentReference>;
49
+ deleteDocument: (ref: DocumentReference) => Promise<void>;
50
+ /** Re-establish the active read/subscription. Useful after the consumer
51
+ * mutates data outside this hook. */
52
+ refresh: () => void;
53
+ }
54
+
55
+ /**
56
+ * Paginated document list with two acquisition strategies. The default
57
+ * `paged` mode uses `startAfter` and accumulates one-shot reads. `live` keeps
58
+ * the requested prefix under one `onSnapshot` listener; loading more grows
59
+ * that prefix and establishes a new subscription baseline.
60
+ */
61
+ export function useDocumentList({
62
+ collection,
63
+ query,
64
+ pageSize = 50,
65
+ mode = 'paged',
66
+ }: UseDocumentListOptions): UseDocumentListResult {
67
+ const [documents, setDocuments] = useState<QueryDocumentSnapshot[]>([]);
68
+ const [isLoading, setIsLoading] = useState(true);
69
+ const [error, setError] = useState<Error | undefined>(undefined);
70
+ const [hasMore, setHasMore] = useState(false);
71
+ const [tick, setTick] = useState(0);
72
+ const [requestedCount, setRequestedCount] = useState(pageSize);
73
+ const nextSubscriptionGeneration = useRef(0);
74
+ const [subscriptionGeneration, setSubscriptionGeneration] = useState(0);
75
+
76
+ // The modular fns, injected: in-process `pyric/firestore` by default, or the
77
+ // SharedWorker client bundle when a consumer (Pyric Studio served mode) wraps
78
+ // the tree in a `FirestoreApiProvider`. Stable across renders per provider.
79
+ const {
80
+ addDoc,
81
+ deleteDoc,
82
+ doc: docFn,
83
+ getDoc,
84
+ getDocs,
85
+ limit: limitFn,
86
+ onSnapshot,
87
+ query: queryFn,
88
+ setDoc,
89
+ startAfter: startAfterFn,
90
+ } = useFirestoreApi();
91
+
92
+ useEffect(() => {
93
+ setRequestedCount(pageSize);
94
+ }, [collection, mode, pageSize, query]);
95
+
96
+ useEffect(() => {
97
+ let cancelled = false;
98
+ if (mode === 'paged') setDocuments([]);
99
+ setHasMore(false);
100
+ setIsLoading(true);
101
+ setError(undefined);
102
+ const baseQuery = query ?? collection;
103
+ const fetchCount = mode === 'live' ? requestedCount : pageSize;
104
+ const pagedQuery = queryFn(baseQuery, limitFn(fetchCount));
105
+ const accept = (snap: { readonly docs: readonly QueryDocumentSnapshot[] }) => {
106
+ if (cancelled) return;
107
+ setDocuments([...snap.docs]);
108
+ setHasMore(snap.docs.length === fetchCount);
109
+ setIsLoading(false);
110
+ };
111
+ const reject = (e: unknown) => {
112
+ if (cancelled) return;
113
+ setError(e instanceof Error ? e : new Error(String(e)));
114
+ setIsLoading(false);
115
+ };
116
+
117
+ if (mode === 'live') {
118
+ const generation = ++nextSubscriptionGeneration.current;
119
+ const unsubscribe = onSnapshot(
120
+ pagedQuery,
121
+ (snap: unknown) => {
122
+ setSubscriptionGeneration(generation);
123
+ accept(snap as { readonly docs: readonly QueryDocumentSnapshot[] });
124
+ },
125
+ reject,
126
+ );
127
+ return () => {
128
+ cancelled = true;
129
+ unsubscribe();
130
+ };
131
+ }
132
+
133
+ getDocs(pagedQuery)
134
+ .then((snap) => {
135
+ accept(snap);
136
+ })
137
+ .catch(reject);
138
+ return () => {
139
+ cancelled = true;
140
+ };
141
+ }, [
142
+ collection,
143
+ getDocs,
144
+ limitFn,
145
+ mode,
146
+ onSnapshot,
147
+ pageSize,
148
+ query,
149
+ queryFn,
150
+ requestedCount,
151
+ tick,
152
+ ]);
153
+
154
+ const loadMore = useCallback(() => {
155
+ if (!hasMore || isLoading) return;
156
+ if (mode === 'live') {
157
+ setIsLoading(true);
158
+ setRequestedCount((count) => count + pageSize);
159
+ return;
160
+ }
161
+ const last = documents[documents.length - 1];
162
+ if (!last) return;
163
+ setIsLoading(true);
164
+ const baseQuery = query ?? collection;
165
+ const pagedQuery = queryFn(baseQuery, startAfterFn(last), limitFn(pageSize));
166
+ getDocs(pagedQuery)
167
+ .then((snap) => {
168
+ setDocuments((prev) => [...prev, ...snap.docs]);
169
+ setHasMore(snap.docs.length === pageSize);
170
+ setIsLoading(false);
171
+ })
172
+ .catch((e) => {
173
+ setError(e instanceof Error ? e : new Error(String(e)));
174
+ setIsLoading(false);
175
+ });
176
+ }, [
177
+ collection,
178
+ documents,
179
+ getDocs,
180
+ hasMore,
181
+ isLoading,
182
+ limitFn,
183
+ mode,
184
+ pageSize,
185
+ query,
186
+ queryFn,
187
+ startAfterFn,
188
+ ]);
189
+
190
+ const createDocument = useCallback<UseDocumentListResult['createDocument']>(
191
+ async (id, data, opts) => {
192
+ if (id == null) {
193
+ const ref = await addDoc(collection, data);
194
+ if (mode === 'paged') setTick((n) => n + 1);
195
+ return ref;
196
+ }
197
+ const ref = docFn(collection, id);
198
+ if (opts?.onExisting === 'fail') {
199
+ // CREATE semantics without a batch/create primitive on FirestoreApi:
200
+ // probe the backend, then write. The probe is authoritative for the
201
+ // whole collection (not just a loaded page); the tiny read-then-write
202
+ // window is acceptable for the dev-sandbox surfaces this backs.
203
+ const existing = await getDoc(ref);
204
+ // `exists` is a method on the modular SDK snapshot but a boolean on
205
+ // some compat-shaped snapshots this bundle may be adapted over.
206
+ const exists =
207
+ typeof existing.exists === 'function' ? existing.exists() : Boolean(existing.exists);
208
+ if (exists) {
209
+ const err = new Error(`Document "${id}" already exists.`) as Error & { code: string };
210
+ err.code = 'already-exists';
211
+ throw err;
212
+ }
213
+ }
214
+ await setDoc(ref, data);
215
+ if (mode === 'paged') setTick((n) => n + 1);
216
+ return ref;
217
+ },
218
+ [addDoc, collection, docFn, getDoc, mode, setDoc],
219
+ );
220
+
221
+ const deleteDocument = useCallback<UseDocumentListResult['deleteDocument']>(
222
+ async (ref) => {
223
+ await deleteDoc(ref);
224
+ if (mode === 'paged') setTick((n) => n + 1);
225
+ },
226
+ [deleteDoc, mode],
227
+ );
228
+
229
+ const refresh = useCallback(() => setTick((n) => n + 1), []);
230
+
231
+ return {
232
+ documents,
233
+ isLoading,
234
+ error,
235
+ subscriptionGeneration,
236
+ hasMore,
237
+ loadMore,
238
+ createDocument,
239
+ deleteDocument,
240
+ refresh,
241
+ };
242
+ }
@@ -0,0 +1,86 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import type {
3
+ CollectionReference,
4
+ DocumentReference,
5
+ Firestore,
6
+ } from 'pyric/firestore';
7
+
8
+ /**
9
+ * Lister for a document's own subcollections. Same injected-lister
10
+ * shape as `useCollectionList` / `ReferencePicker` use — the modular
11
+ * Web SDK doesn't expose a native `listCollections` on the client, so
12
+ * the caller wires it (sandbox in-process listing, a server proxy, or
13
+ * a known schema list).
14
+ */
15
+ export type ListSubcollections = (
16
+ firestore: Firestore,
17
+ parent: DocumentReference,
18
+ ) => Promise<CollectionReference[]>;
19
+
20
+ export interface UseDocumentSubcollectionsOptions {
21
+ firestore: Firestore;
22
+ /** The document whose subcollections to list. When `null`/`undefined`
23
+ * the hook stays idle (empty, not loading) — used when the preview
24
+ * has no ref to drill from. */
25
+ documentRef: DocumentReference | null | undefined;
26
+ listSubcollections: ListSubcollections;
27
+ }
28
+
29
+ export interface UseDocumentSubcollectionsResult {
30
+ subcollections: CollectionReference[];
31
+ isLoading: boolean;
32
+ error: Error | undefined;
33
+ }
34
+
35
+ /**
36
+ * Read a single document's subcollection list. A thin specialization of
37
+ * the `useCollectionList` pattern, scoped to one parent document and
38
+ * read-only (no create — that lives in `useCollectionList`).
39
+ */
40
+ export function useDocumentSubcollections({
41
+ firestore,
42
+ documentRef,
43
+ listSubcollections,
44
+ }: UseDocumentSubcollectionsOptions): UseDocumentSubcollectionsResult {
45
+ const [subcollections, setSubcollections] = useState<CollectionReference[]>([]);
46
+ const [isLoading, setIsLoading] = useState(false);
47
+ const [error, setError] = useState<Error | undefined>(undefined);
48
+
49
+ // Stable-ref the injected lister so a fresh closure identity each
50
+ // render doesn't loop the effect — same pattern as useCollectionList.
51
+ const listRef = useRef(listSubcollections);
52
+ listRef.current = listSubcollections;
53
+
54
+ // Key the effect on the ref's path (stable across snapshot identity
55
+ // churn) rather than the ref object, which the SDK may re-create.
56
+ const path = documentRef?.path;
57
+
58
+ useEffect(() => {
59
+ if (!documentRef) {
60
+ setSubcollections([]);
61
+ setIsLoading(false);
62
+ setError(undefined);
63
+ return;
64
+ }
65
+ let cancelled = false;
66
+ setIsLoading(true);
67
+ setError(undefined);
68
+ listRef.current(firestore, documentRef)
69
+ .then((list) => {
70
+ if (cancelled) return;
71
+ setSubcollections(list);
72
+ setIsLoading(false);
73
+ })
74
+ .catch((e) => {
75
+ if (cancelled) return;
76
+ setError(e instanceof Error ? e : new Error(String(e)));
77
+ setIsLoading(false);
78
+ });
79
+ return () => {
80
+ cancelled = true;
81
+ };
82
+ // eslint-disable-next-line react-hooks/exhaustive-deps
83
+ }, [firestore, path]);
84
+
85
+ return { subcollections, isLoading, error };
86
+ }
@@ -0,0 +1,51 @@
1
+ import { useEffect, useState } from 'react';
2
+ import type { Query, QuerySnapshot } from 'pyric/firestore';
3
+ import { coerceError } from './coerceError.js';
4
+ import type { SubscriptionState } from './useFirestoreDoc.js';
5
+ import { useFirestoreApi } from '../firestoreApi.js';
6
+
7
+ /**
8
+ * Subscribe to a Firestore query (a `Query` from `pyric/firestore`'s
9
+ * modular surface, including any `CollectionReference`, which extends
10
+ * `Query`). Returns `{ data, error, isLoading }`.
11
+ *
12
+ * Null/undefined query short-circuits to idle. Cleanup is automatic
13
+ * on unmount or query change. `Query` objects don't have a stable
14
+ * structural identity, so the consumer must memoize at the call site
15
+ * — pass the same instance across renders to avoid re-subscribing.
16
+ */
17
+ export function useFirestoreCollection(
18
+ query: Query | null | undefined,
19
+ ): SubscriptionState<QuerySnapshot> {
20
+ const { onSnapshot } = useFirestoreApi();
21
+ const [state, setState] = useState<SubscriptionState<QuerySnapshot>>(() => ({
22
+ data: undefined,
23
+ error: undefined,
24
+ isLoading: query != null,
25
+ }));
26
+
27
+ useEffect(() => {
28
+ if (!query) {
29
+ setState({ data: undefined, error: undefined, isLoading: false });
30
+ return;
31
+ }
32
+
33
+ setState((prev) => ({ data: prev.data, error: undefined, isLoading: true }));
34
+
35
+ const unsubscribe = onSnapshot(
36
+ query,
37
+ (snap) =>
38
+ setState({ data: snap as QuerySnapshot, error: undefined, isLoading: false }),
39
+ (err) =>
40
+ setState({
41
+ data: undefined,
42
+ error: coerceError(err),
43
+ isLoading: false,
44
+ }),
45
+ );
46
+
47
+ return unsubscribe;
48
+ }, [onSnapshot, query]);
49
+
50
+ return state;
51
+ }
@@ -0,0 +1,55 @@
1
+ import { useEffect, useState } from 'react';
2
+ import type { DocumentReference, DocumentSnapshot } from 'pyric/firestore';
3
+ import { coerceError } from './coerceError.js';
4
+ import { useFirestoreApi } from '../firestoreApi.js';
5
+
6
+ export interface SubscriptionState<T> {
7
+ data: T | undefined;
8
+ error: Error | undefined;
9
+ isLoading: boolean;
10
+ }
11
+
12
+ /**
13
+ * Subscribe to a single Firestore document. Returns `{ data, error,
14
+ * isLoading }`. Null/undefined ref short-circuits to an idle state
15
+ * (`data: undefined, error: undefined, isLoading: false`) — useful
16
+ * for conditional rendering before a ref is known.
17
+ *
18
+ * Cleanup is automatic on unmount or ref change. Memoize the ref at
19
+ * the call site; this hook's effect re-runs on identity change.
20
+ */
21
+ export function useFirestoreDoc(
22
+ ref: DocumentReference | null | undefined,
23
+ ): SubscriptionState<DocumentSnapshot> {
24
+ const { onSnapshot } = useFirestoreApi();
25
+ const [state, setState] = useState<SubscriptionState<DocumentSnapshot>>(() => ({
26
+ data: undefined,
27
+ error: undefined,
28
+ isLoading: ref != null,
29
+ }));
30
+
31
+ useEffect(() => {
32
+ if (!ref) {
33
+ setState({ data: undefined, error: undefined, isLoading: false });
34
+ return;
35
+ }
36
+
37
+ setState((prev) => ({ data: prev.data, error: undefined, isLoading: true }));
38
+
39
+ const unsubscribe = onSnapshot(
40
+ ref,
41
+ (snap) =>
42
+ setState({ data: snap as DocumentSnapshot, error: undefined, isLoading: false }),
43
+ (err) =>
44
+ setState({
45
+ data: undefined,
46
+ error: coerceError(err),
47
+ isLoading: false,
48
+ }),
49
+ );
50
+
51
+ return unsubscribe;
52
+ }, [onSnapshot, ref]);
53
+
54
+ return state;
55
+ }
@@ -0,0 +1,188 @@
1
+ import { useCallback, useMemo, useState } from 'react';
2
+ import {
3
+ limit as limitFn,
4
+ orderBy as orderByFn,
5
+ query as queryFn,
6
+ where as whereFn,
7
+ type CollectionReference,
8
+ type OrderDirection,
9
+ type Query,
10
+ type WhereFilterOp,
11
+ } from 'pyric/firestore';
12
+
13
+ export type QueryOp = WhereFilterOp;
14
+
15
+ export const QUERY_OPS: readonly QueryOp[] = [
16
+ '==',
17
+ '!=',
18
+ '<',
19
+ '<=',
20
+ '>',
21
+ '>=',
22
+ 'in',
23
+ 'not-in',
24
+ 'array-contains',
25
+ 'array-contains-any',
26
+ ] as const;
27
+
28
+ /** Ops that accept an array of values. The value editor in the
29
+ * bundled <QueryBuilder> parses the input as JSON for these. */
30
+ export const MULTI_VALUE_OPS: ReadonlySet<QueryOp> = new Set<QueryOp>([
31
+ 'in',
32
+ 'not-in',
33
+ 'array-contains-any',
34
+ ]);
35
+
36
+ export interface QueryCondition {
37
+ id: string;
38
+ field: string;
39
+ op: QueryOp;
40
+ value: unknown;
41
+ }
42
+
43
+ export interface QueryBuilderState {
44
+ conditions: QueryCondition[];
45
+ orderBy?: { field: string; direction: OrderDirection };
46
+ limit?: number;
47
+ }
48
+
49
+ export interface QueryBuilderActions {
50
+ addCondition: (c?: Partial<Omit<QueryCondition, 'id'>>) => void;
51
+ updateCondition: (
52
+ id: string,
53
+ patch: Partial<Omit<QueryCondition, 'id'>>,
54
+ ) => void;
55
+ removeCondition: (id: string) => void;
56
+ setOrderBy: (orderBy?: { field: string; direction: OrderDirection }) => void;
57
+ setLimit: (limit?: number) => void;
58
+ reset: () => void;
59
+ /**
60
+ * Compose the state into a Firestore `Query`. Returns the base
61
+ * collection when there are no conditions / orderBy / limit.
62
+ * Conditions with empty `field` are skipped — the builder UI
63
+ * lets users add a row before they've filled it in.
64
+ */
65
+ buildQuery: (base: CollectionReference | Query) => Query;
66
+ }
67
+
68
+ export type UseQueryBuilderResult = QueryBuilderState & QueryBuilderActions;
69
+
70
+ const EMPTY_STATE: QueryBuilderState = { conditions: [] };
71
+
72
+ export interface UseQueryBuilderOptions {
73
+ /** Pre-populate the builder. */
74
+ initial?: Partial<QueryBuilderState>;
75
+ }
76
+
77
+ /**
78
+ * Headless query-builder state machine. Single-level — no nested
79
+ * `and()`/`or()` groups in v1. Consumers compose the state into a
80
+ * Firestore `Query` via `buildQuery(base)` and feed that into
81
+ * `useDocumentList` / `useFirestoreCollection`.
82
+ */
83
+ export function useQueryBuilder(
84
+ options: UseQueryBuilderOptions = {},
85
+ ): UseQueryBuilderResult {
86
+ const [state, setState] = useState<QueryBuilderState>(() => ({
87
+ ...EMPTY_STATE,
88
+ ...options.initial,
89
+ conditions: options.initial?.conditions ?? [],
90
+ }));
91
+
92
+ const addCondition = useCallback<QueryBuilderActions['addCondition']>((c) => {
93
+ setState((prev) => ({
94
+ ...prev,
95
+ conditions: [
96
+ ...prev.conditions,
97
+ {
98
+ id: crypto.randomUUID(),
99
+ field: c?.field ?? '',
100
+ op: c?.op ?? '==',
101
+ value: c?.value ?? '',
102
+ },
103
+ ],
104
+ }));
105
+ }, []);
106
+
107
+ const updateCondition = useCallback<QueryBuilderActions['updateCondition']>(
108
+ (id, patch) => {
109
+ setState((prev) => ({
110
+ ...prev,
111
+ conditions: prev.conditions.map((c) =>
112
+ c.id === id ? { ...c, ...patch } : c,
113
+ ),
114
+ }));
115
+ },
116
+ [],
117
+ );
118
+
119
+ const removeCondition = useCallback<QueryBuilderActions['removeCondition']>(
120
+ (id) => {
121
+ setState((prev) => ({
122
+ ...prev,
123
+ conditions: prev.conditions.filter((c) => c.id !== id),
124
+ }));
125
+ },
126
+ [],
127
+ );
128
+
129
+ const setOrderBy = useCallback<QueryBuilderActions['setOrderBy']>((next) => {
130
+ setState((prev) => ({ ...prev, orderBy: next }));
131
+ }, []);
132
+
133
+ const setLimit = useCallback<QueryBuilderActions['setLimit']>((next) => {
134
+ setState((prev) => ({ ...prev, limit: next }));
135
+ }, []);
136
+
137
+ const reset = useCallback(() => {
138
+ setState(EMPTY_STATE);
139
+ }, []);
140
+
141
+ const buildQuery = useCallback<QueryBuilderActions['buildQuery']>(
142
+ (base) => {
143
+ const constraints = [] as ReturnType<typeof whereFn>[];
144
+ const orderByConstraints = [] as ReturnType<typeof orderByFn>[];
145
+ const limitConstraints = [] as ReturnType<typeof limitFn>[];
146
+
147
+ for (const cond of state.conditions) {
148
+ if (!cond.field) continue;
149
+ constraints.push(whereFn(cond.field, cond.op, cond.value));
150
+ }
151
+ if (state.orderBy && state.orderBy.field) {
152
+ orderByConstraints.push(
153
+ orderByFn(state.orderBy.field, state.orderBy.direction),
154
+ );
155
+ }
156
+ if (typeof state.limit === 'number' && state.limit > 0) {
157
+ limitConstraints.push(limitFn(state.limit));
158
+ }
159
+ const all = [...constraints, ...orderByConstraints, ...limitConstraints];
160
+ if (all.length === 0) return base as Query;
161
+ return queryFn(base, ...all);
162
+ },
163
+ [state],
164
+ );
165
+
166
+ return useMemo(
167
+ () => ({
168
+ ...state,
169
+ addCondition,
170
+ updateCondition,
171
+ removeCondition,
172
+ setOrderBy,
173
+ setLimit,
174
+ reset,
175
+ buildQuery,
176
+ }),
177
+ [
178
+ state,
179
+ addCondition,
180
+ updateCondition,
181
+ removeCondition,
182
+ setOrderBy,
183
+ setLimit,
184
+ reset,
185
+ buildQuery,
186
+ ],
187
+ );
188
+ }
@@ -0,0 +1,77 @@
1
+ import { useCallback, useRef, useState } from 'react';
2
+ import type { CollectionReference, DocumentReference } from 'pyric/firestore';
3
+
4
+ export interface RecursiveDeleteProgress {
5
+ /** Total nodes deleted so far. */
6
+ deletedCount: number;
7
+ /** True for the final emission. */
8
+ done: boolean;
9
+ }
10
+
11
+ /**
12
+ * Implementation injected by the consumer. The library doesn't ship
13
+ * one — sandbox-backed apps usually walk `pyric/sandbox`'s in-process
14
+ * tree; production apps usually call a Cloud Function. Either way,
15
+ * `start` returns an async iterator emitting progress.
16
+ */
17
+ export interface RecursiveDeleteImpl {
18
+ start: (
19
+ target: DocumentReference | CollectionReference,
20
+ ) => AsyncIterableIterator<RecursiveDeleteProgress>;
21
+ }
22
+
23
+ export interface UseRecursiveDeleteResult {
24
+ /** Run the delete. Resolves when the iterator signals `done`. */
25
+ delete: (target: DocumentReference | CollectionReference) => Promise<void>;
26
+ /** Number of nodes deleted in the current/last run. */
27
+ progress: number;
28
+ /** True while an iteration is in flight. */
29
+ isRunning: boolean;
30
+ /** Error thrown by the iterator, if any. Cleared at the start of
31
+ * the next call. */
32
+ error: Error | undefined;
33
+ }
34
+
35
+ /**
36
+ * Drive a {@link RecursiveDeleteImpl} from a React component. Tracks
37
+ * progress + running state so the consumer can render a progress
38
+ * indicator. Errors are caught and surfaced via the returned state,
39
+ * not thrown.
40
+ *
41
+ * Stale-run protection: if the component remounts (or the user
42
+ * cancels and starts a new run) before a previous iteration
43
+ * finishes, the older run's progress updates are dropped via a
44
+ * generation token.
45
+ */
46
+ export function useRecursiveDelete(
47
+ impl: RecursiveDeleteImpl,
48
+ ): UseRecursiveDeleteResult {
49
+ const [progress, setProgress] = useState(0);
50
+ const [isRunning, setIsRunning] = useState(false);
51
+ const [error, setError] = useState<Error | undefined>(undefined);
52
+ const generationRef = useRef(0);
53
+
54
+ const del = useCallback(
55
+ async (target: DocumentReference | CollectionReference) => {
56
+ const myGen = ++generationRef.current;
57
+ setProgress(0);
58
+ setError(undefined);
59
+ setIsRunning(true);
60
+ try {
61
+ for await (const evt of impl.start(target)) {
62
+ if (myGen !== generationRef.current) return;
63
+ setProgress(evt.deletedCount);
64
+ if (evt.done) break;
65
+ }
66
+ } catch (e) {
67
+ if (myGen !== generationRef.current) return;
68
+ setError(e instanceof Error ? e : new Error(String(e)));
69
+ } finally {
70
+ if (myGen === generationRef.current) setIsRunning(false);
71
+ }
72
+ },
73
+ [impl],
74
+ );
75
+
76
+ return { delete: del, progress, isRunning, error };
77
+ }