nucleus-core-ts 0.9.848 → 0.9.850

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.
@@ -54,11 +54,14 @@ export function ConnectionForm({ actions, source, loading = false, onSaved }) {
54
54
  store.setError('The connection was not saved. The server did not return the record.');
55
55
  return;
56
56
  }
57
+ // Told about the record BEFORE it is selected. The caller is what puts
58
+ // it into the list, and selecting an id the list does not hold is the
59
+ // state every card below renders a skeleton for.
60
+ onSaved?.(saved);
57
61
  // Selecting resets everything hanging off a connection, so it is only
58
62
  // right for a NEW one — doing it on an edit would empty the endpoint,
59
63
  // mapping and run lists the operator is working in.
60
64
  if (!source) store.selectSource(saved.id);
61
- onSaved?.(saved);
62
65
  }).catch((reason)=>{
63
66
  setSaving(false);
64
67
  store.setError(errorText(reason, 'The connection could not be saved.'));
@@ -59,17 +59,31 @@ export function ConnectionList({ actions, onNewConnection, reloadToken = 0 }) {
59
59
  const publishLoading = useEffectEvent(()=>{
60
60
  store.setSourcesLoading(list.loading);
61
61
  });
62
+ // Only what the SERVER said. A reset empties the hook's items before the
63
+ // first page is asked for, and publishing that emptied the shared store —
64
+ // which is where the editor looks for the record it is editing. Saving
65
+ // triggers a reset, so saving is when the record vanished.
62
66
  useEffect(()=>{
63
- publishItems();
67
+ if (list.settled) publishItems();
64
68
  }, [
65
69
  list.items,
66
- list.total
70
+ list.total,
71
+ list.settled
67
72
  ]);
68
73
  useEffect(()=>{
69
74
  publishLoading();
70
75
  }, [
71
76
  list.loading
72
77
  ]);
78
+ // Handed back on the way out. This flag has exactly one writer — this
79
+ // component — and unmounting mid-request left it true with nobody able to
80
+ // clear it, so whatever else reads it showed a skeleton for a request that
81
+ // had already finished.
82
+ useEffect(()=>{
83
+ return ()=>{
84
+ store.setSourcesLoading(false);
85
+ };
86
+ }, []);
73
87
  const handleNew = ()=>{
74
88
  store.selectSource(null);
75
89
  onNewConnection?.();
@@ -22,27 +22,35 @@ import { CredentialFields } from './CredentialFields';
22
22
  */ export function ConnectionSettingsTab({ actions, onSaved, onDeleted }) {
23
23
  const store = useIntegrationsStore();
24
24
  const source = store.sources.find((candidate)=>candidate.id === store.selectedSourceId) ?? null;
25
- // The list holds only the current page, so a connection selected before a
26
- // later page loaded may not be in it yet. Saying so beats an empty form that
27
- // looks like the record was lost.
28
- const missingFromPage = store.selectedSourceId !== null && source === null;
25
+ /**
26
+ * Waiting for THIS record, and nothing else.
27
+ *
28
+ * It used to be `store.sourcesLoading || missingFromPage`, and sourcesLoading
29
+ * is true whenever the rail is fetching ANY page — including a page fetched
30
+ * because somebody typed a letter into the search box. With no connection
31
+ * chosen this tab is the blank "new connection" form, and every keystroke in
32
+ * the rail beside it replaced that form with skeletons.
33
+ *
34
+ * A form that is not editing a record is never loading: there is nothing to
35
+ * wait for. A form that IS editing one waits only until that record is here.
36
+ */ const waitingForRecord = store.selectedSourceId !== null && source === null;
29
37
  return /*#__PURE__*/ _jsxs("div", {
30
38
  className: theme.layout.main,
31
39
  children: [
32
40
  /*#__PURE__*/ _jsx(ConnectionForm, {
33
41
  actions: actions,
34
- loading: store.sourcesLoading || missingFromPage,
42
+ loading: waitingForRecord,
35
43
  onSaved: onSaved,
36
44
  source: source
37
45
  }),
38
46
  /*#__PURE__*/ _jsx(CredentialFields, {
39
47
  actions: actions,
40
- loading: store.sourcesLoading || missingFromPage,
48
+ loading: waitingForRecord,
41
49
  source: source
42
50
  }),
43
51
  /*#__PURE__*/ _jsx(ConnectionTest, {
44
52
  actions: actions,
45
- loading: store.sourcesLoading || missingFromPage,
53
+ loading: waitingForRecord,
46
54
  source: source
47
55
  }),
48
56
  /*#__PURE__*/ _jsx(ConnectionDelete, {
@@ -72,17 +72,31 @@ export function EndpointList({ sourceId, actions, onNewEndpoint, onSelect, reloa
72
72
  const publishLoading = useEffectEvent(()=>{
73
73
  store.setEndpointsLoading(list.loading);
74
74
  });
75
+ // Only what the SERVER said. A reset empties the hook's items before the
76
+ // first page is asked for, and publishing that emptied the shared store —
77
+ // which is where the editor looks for the record it is editing. Saving
78
+ // triggers a reset, so saving is when the record vanished.
75
79
  useEffect(()=>{
76
- publishItems();
80
+ if (list.settled) publishItems();
77
81
  }, [
78
82
  list.items,
79
- list.total
83
+ list.total,
84
+ list.settled
80
85
  ]);
81
86
  useEffect(()=>{
82
87
  publishLoading();
83
88
  }, [
84
89
  list.loading
85
90
  ]);
91
+ // Handed back on the way out. This flag has exactly one writer — this
92
+ // component — and unmounting mid-request left it true with nobody able to
93
+ // clear it, so whatever else reads it showed a skeleton for a request that
94
+ // had already finished.
95
+ useEffect(()=>{
96
+ return ()=>{
97
+ store.setEndpointsLoading(false);
98
+ };
99
+ }, []);
86
100
  const handleNew = ()=>{
87
101
  store.selectEndpoint(null);
88
102
  onNewEndpoint?.();
@@ -7,6 +7,7 @@ import { integrationsPageTheme } from '../theme';
7
7
  import { Badge, CodeBlock, Field, Notice, Stat } from './Primitives';
8
8
  import { pathParamNames } from './pathParams';
9
9
  import { CardSkeleton, StatGridSkeleton } from './Skeletons';
10
+ import { settlementOf } from './settleOwnership';
10
11
  /**
11
12
  * One real request, shown in full.
12
13
  *
@@ -21,6 +22,7 @@ export function EndpointSample({ endpoint, actions }) {
21
22
  const [values, setValues] = useState({});
22
23
  // Kept in step with the render so an in-flight reply can ask what is selected
23
24
  // NOW, not what was selected when it was sent.
25
+ const generationRef = useRef(0);
24
26
  const selectedRef = useRef(store.selectedEndpointId);
25
27
  selectedRef.current = store.selectedEndpointId;
26
28
  const params = pathParamNames(endpoint.path);
@@ -29,20 +31,39 @@ export function EndpointSample({ endpoint, actions }) {
29
31
  for (const name of params){
30
32
  if (!(values[name] ?? '').trim()) unfilled += 1;
31
33
  }
34
+ /**
35
+ * Discard a late answer, but hand the busy flag back on the way out.
36
+ *
37
+ * The discard itself is right — the operator has moved on and the field list
38
+ * must describe the endpoint they are looking at. It used to be a bare
39
+ * `return`, which skipped the clearing too: `sampling` stayed set with nobody
40
+ * left to unset it, and the sample card showed a skeleton until the page was
41
+ * reloaded. See settleOwnership.ts for who owns the flag.
42
+ */ const settleElsewhere = (mine, requestedFor)=>{
43
+ const { discard, releaseFlag } = settlementOf({
44
+ mine,
45
+ latest: generationRef.current,
46
+ requestedFor,
47
+ selectedNow: selectedRef.current
48
+ });
49
+ if (releaseFlag) store.setSampling(false);
50
+ return discard;
51
+ };
32
52
  const run = ()=>{
33
53
  // The endpoint this request is FOR. A sample can take seconds against a slow
34
54
  // source, and an operator who clicks another endpoint meanwhile would
35
55
  // otherwise get the first one's answer written in as the second one's field
36
56
  // list — and then map against fields the endpoint does not have.
37
57
  const requestedFor = endpoint.id;
58
+ const mine = ++generationRef.current;
38
59
  store.setSampling(true);
39
60
  store.setError(null);
40
61
  actions.sampleEndpoint(requestedFor, params.length > 0 ? values : undefined).then((result)=>{
41
- if (selectedRef.current !== requestedFor) return;
62
+ if (settleElsewhere(mine, requestedFor)) return;
42
63
  store.setSample(result);
43
64
  if (!result.ok && result.error) store.setError(result.error);
44
65
  }).catch((error)=>{
45
- if (selectedRef.current !== requestedFor) return;
66
+ if (settleElsewhere(mine, requestedFor)) return;
46
67
  store.setSample(null);
47
68
  store.setError(error instanceof Error ? error.message : 'Sampling the endpoint failed.');
48
69
  });
@@ -83,17 +83,31 @@ export function MappingList({ actions, sourceId, selectedId, reloadToken, onSele
83
83
  const publishLoading = useEffectEvent(()=>{
84
84
  store.setMappingsLoading(list.loading);
85
85
  });
86
+ // Only what the SERVER said. A reset empties the hook's items before the
87
+ // first page is asked for, and publishing that emptied the shared store —
88
+ // which is where the editor looks for the record it is editing. Saving
89
+ // triggers a reset, so saving is when the record vanished.
86
90
  useEffect(()=>{
87
- publishItems();
91
+ if (list.settled) publishItems();
88
92
  }, [
89
93
  list.items,
90
- list.total
94
+ list.total,
95
+ list.settled
91
96
  ]);
92
97
  useEffect(()=>{
93
98
  publishLoading();
94
99
  }, [
95
100
  list.loading
96
101
  ]);
102
+ // Handed back on the way out. This flag has exactly one writer — this
103
+ // component — and unmounting mid-request left it true with nobody able to
104
+ // clear it, so whatever else reads it showed a skeleton for a request that
105
+ // had already finished.
106
+ useEffect(()=>{
107
+ return ()=>{
108
+ store.setMappingsLoading(false);
109
+ };
110
+ }, []);
97
111
  const showSkeleton = list.loading && list.items.length === 0;
98
112
  return /*#__PURE__*/ _jsxs("section", {
99
113
  className: "flex flex-col gap-3",
@@ -51,17 +51,30 @@ export function RunHistory({ actions, mappingId, refreshKey, onRefresh }) {
51
51
  const publishLoading = useEffectEvent(()=>{
52
52
  store.setRunsLoading(list.loading);
53
53
  });
54
+ // Only what the SERVER said — see the same guard on the other three lists: a
55
+ // reset empties the hook's items before the first page is asked for, and
56
+ // publishing that emptied the shared store.
54
57
  useEffect(()=>{
55
- publishRuns();
58
+ if (list.settled) publishRuns();
56
59
  }, [
57
60
  list.items,
58
- list.total
61
+ list.total,
62
+ list.settled
59
63
  ]);
60
64
  useEffect(()=>{
61
65
  publishLoading();
62
66
  }, [
63
67
  list.loading
64
68
  ]);
69
+ // Handed back on the way out. This flag has exactly one writer — this
70
+ // component — and unmounting mid-request left it true with nobody able to
71
+ // clear it, so whatever else reads it showed a skeleton for a request that
72
+ // had already finished.
73
+ useEffect(()=>{
74
+ return ()=>{
75
+ store.setRunsLoading(false);
76
+ };
77
+ }, []);
65
78
  const firstPagePending = list.loading && list.items.length === 0;
66
79
  return /*#__PURE__*/ _jsxs("section", {
67
80
  className: theme.card.wrapper,
@@ -66,11 +66,16 @@ export function RunPanel({ mapping, actions, onSubscribeProgress, onRunSettled }
66
66
  setConfirming(true);
67
67
  return;
68
68
  }
69
+ // A refusal ENDS the confirmation. Left standing, the panel kept asking
70
+ // "are you sure?" about an import the server had already turned down,
71
+ // and pressing the confirm button again just collected the same refusal.
69
72
  if (result.error) {
73
+ setConfirming(false);
70
74
  store.setError(result.error);
71
75
  return;
72
76
  }
73
77
  if (!result.runId) {
78
+ setConfirming(false);
74
79
  store.setError('The import was accepted but no run id came back, so it cannot be tracked.');
75
80
  return;
76
81
  }
@@ -80,6 +85,7 @@ export function RunPanel({ mapping, actions, onSubscribeProgress, onRunSettled }
80
85
  store.setActiveRun(result.runId);
81
86
  }).catch((error)=>{
82
87
  setStarting(false);
88
+ setConfirming(false);
83
89
  store.setError(errorText(error, 'The import could not be started.'));
84
90
  });
85
91
  };
@@ -120,10 +126,21 @@ export function RunPanel({ mapping, actions, onSubscribeProgress, onRunSettled }
120
126
  */ const pollActiveRun = useEffectEvent(()=>{
121
127
  const runId = store.activeRunId;
122
128
  if (!runId) return;
123
- actions.listRuns({
129
+ actions// NEWEST first, explicitly. Without a sort the server answers in its own
130
+ // order — which is the order the rows were written — so from the sixth
131
+ // import of a mapping onwards the run being watched was not among the
132
+ // five that came back. The tick below then found nothing, returned, and
133
+ // left the progress bar running for an import that had already finished.
134
+ .listRuns({
124
135
  mappingId: mapping.id,
125
136
  page: 1,
126
- limit: 5
137
+ limit: 5,
138
+ sort: [
139
+ {
140
+ field: 'createdAt',
141
+ direction: 'desc'
142
+ }
143
+ ]
127
144
  }).then((result)=>{
128
145
  if (store.activeRunId !== runId) return;
129
146
  const run = result.items.find((item)=>item.id === runId);
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Who owns a busy flag when a late answer arrives.
3
+ *
4
+ * Every request in this panel sets a shared "in progress" flag on the way in and
5
+ * clears it on the way out. That breaks the moment an answer is DISCARDED —
6
+ * because the operator moved on, or because a newer request replaced this one —
7
+ * since a bare `return` skips the clearing too and the flag stays set with
8
+ * nobody left to unset it. The panel then shows a skeleton for a request that
9
+ * has already finished and will never be written.
10
+ *
11
+ * Two questions, and they are not the same question:
12
+ *
13
+ * superseded — a NEWER request of the same kind has started. It set the flag
14
+ * after this one did and will clear it when it settles, so this
15
+ * answer must leave the flag strictly alone.
16
+ * movedOn — the operator is looking at something else. Nobody else is
17
+ * coming: this request is the last one out and must clear the
18
+ * flag before dropping its answer.
19
+ */
20
+ export type Settlement = {
21
+ /** True when this answer must not be written. */
22
+ discard: boolean;
23
+ /** True when this request is responsible for clearing the busy flag. */
24
+ releaseFlag: boolean;
25
+ };
26
+ export declare function settlementOf(args: {
27
+ /** The generation this request was given when it started. */
28
+ mine: number;
29
+ /** The latest generation handed out. */
30
+ latest: number;
31
+ /** What this request was for. */
32
+ requestedFor: string;
33
+ /** What is selected right now. */
34
+ selectedNow: string | null;
35
+ }): Settlement;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Who owns a busy flag when a late answer arrives.
3
+ *
4
+ * Every request in this panel sets a shared "in progress" flag on the way in and
5
+ * clears it on the way out. That breaks the moment an answer is DISCARDED —
6
+ * because the operator moved on, or because a newer request replaced this one —
7
+ * since a bare `return` skips the clearing too and the flag stays set with
8
+ * nobody left to unset it. The panel then shows a skeleton for a request that
9
+ * has already finished and will never be written.
10
+ *
11
+ * Two questions, and they are not the same question:
12
+ *
13
+ * superseded — a NEWER request of the same kind has started. It set the flag
14
+ * after this one did and will clear it when it settles, so this
15
+ * answer must leave the flag strictly alone.
16
+ * movedOn — the operator is looking at something else. Nobody else is
17
+ * coming: this request is the last one out and must clear the
18
+ * flag before dropping its answer.
19
+ */ export function settlementOf(args) {
20
+ const superseded = args.latest !== args.mine;
21
+ const movedOn = args.selectedNow !== args.requestedFor;
22
+ return {
23
+ discard: superseded || movedOn,
24
+ // Only the request that still holds the flag may clear it — otherwise a
25
+ // slow first answer would switch off the light on a second request that is
26
+ // still running, and the operator would watch a finished-looking panel fill
27
+ // in by itself a moment later.
28
+ releaseFlag: !superseded && movedOn
29
+ };
30
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { settlementOf } from './settleOwnership';
3
+ const forEndpoint = (over = {})=>settlementOf({
4
+ mine: 1,
5
+ latest: 1,
6
+ requestedFor: 'a',
7
+ selectedNow: 'a',
8
+ ...over
9
+ });
10
+ describe('settlementOf', ()=>{
11
+ it('writes the answer when nothing has changed', ()=>{
12
+ expect(forEndpoint()).toEqual({
13
+ discard: false,
14
+ releaseFlag: false
15
+ });
16
+ });
17
+ /**
18
+ * The bug this exists for: the operator clicked another endpoint while a
19
+ * sample was in flight, the answer was dropped, and the busy flag was left on
20
+ * with nobody coming to clear it.
21
+ */ it('drops the answer AND clears the flag when the operator moved on', ()=>{
22
+ expect(forEndpoint({
23
+ selectedNow: 'b'
24
+ })).toEqual({
25
+ discard: true,
26
+ releaseFlag: true
27
+ });
28
+ });
29
+ it('leaves the flag alone when a newer request has taken over', ()=>{
30
+ expect(forEndpoint({
31
+ latest: 2
32
+ })).toEqual({
33
+ discard: true,
34
+ releaseFlag: false
35
+ });
36
+ });
37
+ it('never clears the flag for a superseded request, even one that also moved on', ()=>{
38
+ // Both conditions at once: the newer request owns the flag, so this one must
39
+ // not switch it off while that one is still running.
40
+ expect(forEndpoint({
41
+ latest: 2,
42
+ selectedNow: 'b'
43
+ })).toEqual({
44
+ discard: true,
45
+ releaseFlag: false
46
+ });
47
+ });
48
+ it('treats nothing-selected as having moved on', ()=>{
49
+ expect(forEndpoint({
50
+ selectedNow: null
51
+ })).toEqual({
52
+ discard: true,
53
+ releaseFlag: true
54
+ });
55
+ });
56
+ });
@@ -33,6 +33,19 @@ export type UseInfiniteListResult<T> = {
33
33
  * hook reports the failure and the caller must show it.
34
34
  */
35
35
  error: string | null;
36
+ /**
37
+ * Whether what `items` holds has actually come back from the server.
38
+ *
39
+ * A reset empties `items` BEFORE the first page is asked for, and callers
40
+ * mirror `items` into the shared store — so every refetch published an empty
41
+ * page, and whatever was reading that store lost the record it was showing.
42
+ * Saving is what triggers a refetch, which is why saving is when it was seen:
43
+ * the editor asked the store for the record it had just saved and the store
44
+ * had been emptied a moment earlier.
45
+ *
46
+ * False from a reset until the first page settles, success or failure.
47
+ */
48
+ settled: boolean;
36
49
  /** Put this on an element after the last row; seeing it asks for the next page. */
37
50
  sentinelRef: RefObject<HTMLDivElement | null>;
38
51
  reload: () => void;
@@ -17,6 +17,7 @@ export function useInfiniteList(options) {
17
17
  const [loading, setLoading] = useState(true);
18
18
  const [error, setError] = useState(null);
19
19
  const [reloadToken, setReloadToken] = useState(0);
20
+ const [settled, setSettled] = useState(false);
20
21
  const sentinelRef = useRef(null);
21
22
  const nextPageRef = useRef(1);
22
23
  const loadedCountRef = useRef(0);
@@ -32,6 +33,7 @@ export function useInfiniteList(options) {
32
33
  if (generation !== generationRef.current) return;
33
34
  inFlightRef.current = false;
34
35
  setLoading(false);
36
+ setSettled(true);
35
37
  setTotal(result.total);
36
38
  setItems((previous)=>page === 1 ? result.items : [
37
39
  ...previous,
@@ -47,6 +49,9 @@ export function useInfiniteList(options) {
47
49
  if (generation !== generationRef.current) return;
48
50
  inFlightRef.current = false;
49
51
  setLoading(false);
52
+ // Settled, even though it failed: the caller must be free to show the
53
+ // error instead of waiting on a page that is never coming.
54
+ setSettled(true);
50
55
  setError(reason instanceof Error ? reason.message : String(reason));
51
56
  // Stop asking. The sentinel stays on screen, so retrying on failure
52
57
  // would hammer a broken endpoint for as long as the panel is open;
@@ -67,6 +72,7 @@ export function useInfiniteList(options) {
67
72
  setItems([]);
68
73
  setTotal(0);
69
74
  setLoading(true);
75
+ setSettled(false);
70
76
  setError(null);
71
77
  fetchPage(1);
72
78
  return ()=>{
@@ -103,6 +109,7 @@ export function useInfiniteList(options) {
103
109
  items,
104
110
  total,
105
111
  loading,
112
+ settled,
106
113
  error,
107
114
  sentinelRef,
108
115
  reload
@@ -39,13 +39,24 @@ export const { useStore: useIntegrationsStore } = createStore(initial, {
39
39
  store.tab = 'connection';
40
40
  store.endpoints = [];
41
41
  store.endpointsTotal = 0;
42
- store.endpointsPage = 1;
43
42
  store.endpointsSearch = '';
44
43
  store.selectedEndpointId = null;
45
44
  store.mappings = [];
46
45
  store.mappingsTotal = 0;
47
- store.mappingsPage = 1;
48
46
  store.mappingsSearch = '';
47
+ // Every WAIT below a connection ends with it too. These are the flags the
48
+ // cards read to decide between a skeleton and their content, and their
49
+ // only writers live under the connection being left — so leaving one set
50
+ // meant the next connection opened onto a skeleton with nothing running.
51
+ store.endpointsLoading = false;
52
+ store.mappingsLoading = false;
53
+ store.runsLoading = false;
54
+ store.sample = null;
55
+ store.sampling = false;
56
+ store.plan = null;
57
+ store.planning = false;
58
+ store.activeRunId = null;
59
+ store.progress = null;
49
60
  store.selectedMappingId = null;
50
61
  store.runs = [];
51
62
  store.runsTotal = 0;
@@ -89,6 +100,12 @@ export const { useStore: useIntegrationsStore } = createStore(initial, {
89
100
  // The sample describes ONE endpoint; carrying it over would offer the
90
101
  // wrong field list to the next mapping.
91
102
  store.sample = null;
103
+ // And the WAIT for a sample belongs to the endpoint that was left, not to
104
+ // the one arrived at. Clearing the record without clearing the flag left
105
+ // the next endpoint showing a skeleton for a request that was never going
106
+ // to be written — permanently, because the answer, when it came, saw the
107
+ // selection had moved and returned without touching anything.
108
+ store.sampling = false;
92
109
  }),
93
110
  setMappings: (store)=>(items, total)=>batch(()=>{
94
111
  store.mappings = items;
@@ -121,6 +138,10 @@ export const { useStore: useIntegrationsStore } = createStore(initial, {
121
138
  // A plan belongs to the mapping it was computed for. Showing it against a
122
139
  // different one would have an operator approve counts from another import.
123
140
  store.plan = null;
141
+ // And the WAIT for a plan goes with it. Discarding the plan while leaving
142
+ // this set put the next mapping's panel on a counting skeleton for a
143
+ // preview that had been computed for a different mapping and thrown away.
144
+ store.planning = false;
124
145
  // And so does a RUN, which says something stronger than a plan does: that
125
146
  // this import happened. Seen live — the Ünvanlar run stayed on screen
126
147
  // under a header reading "Writes into locations", for a mapping that had
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nucleus-core-ts",
3
- "version": "0.9.848",
3
+ "version": "0.9.850",
4
4
  "description": "Production-ready, enterprise-grade TypeScript framework for building multi-tenant APIs",
5
5
  "author": "Hidayet Can Özcan <hidayetcan@gmail.com>",
6
6
  "license": "SEE LICENSE IN LICENSE",