nucleus-core-ts 0.9.864 → 0.9.866

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.
@@ -6,7 +6,7 @@ import { useLabels } from '../text/context';
6
6
  import { integrationsPageTheme } from '../theme';
7
7
  import { ConnectionAuthFields } from './ConnectionAuthFields';
8
8
  import { ConnectionBasicFields } from './ConnectionBasicFields';
9
- import { draftFromSource, draftToSource, emptyConnectionDraft, validateConnectionDraft } from './connectionDraft';
9
+ import { draftFromSource, draftPatchFromSource, draftToSource, emptyConnectionDraft, validateConnectionDraft } from './connectionDraft';
10
10
  import { errorText } from './formSupport';
11
11
  import { Badge } from './Primitives';
12
12
  import { CardSkeleton } from './Skeletons';
@@ -58,6 +58,26 @@ export function ConnectionForm({ actions, source, loading = false, onSaved }) {
58
58
  // it into the list, and selecting an id the list does not hold is the
59
59
  // state every card below renders a skeleton for.
60
60
  onSaved?.(saved);
61
+ /**
62
+ * The boxes are re-derived from what was actually STORED.
63
+ *
64
+ * The effect above resets them only when the id changes, so a create
65
+ * showed the truth and an edit never did. Two ways that was visible:
66
+ * emptying "Timeout" and saving left the box empty while the connection
67
+ * went on timing out at the stored value — the draft omits a blank
68
+ * timeout, so the patch never mentions it and the server rightly keeps
69
+ * what it had; and a trailing space in the name was trimmed on the way
70
+ * out, so the switcher above showed "Acme" while the box below still
71
+ * read "Acme ". One screen, disagreeing with itself, until F5.
72
+ *
73
+ * MERGED rather than replaced: a field-scoped patch claim can strip
74
+ * columns out of the reply, and re-deriving wholesale from a partial
75
+ * record would blank the boxes those columns fill. Nothing can have been
76
+ * typed in the meantime — every field is disabled while `saving`.
77
+ */ if (source) setDraft((current)=>({
78
+ ...current,
79
+ ...draftPatchFromSource(saved)
80
+ }));
61
81
  // Selecting resets everything hanging off a connection, so it is only
62
82
  // right for a NEW one — doing it on an edit would empty the endpoint,
63
83
  // mapping and run lists the operator is working in.
@@ -174,6 +174,21 @@ export function MappingsTab({ actions }) {
174
174
  // second earlier landed on "choose a mapping".
175
175
  store.upsertMapping(saved);
176
176
  store.selectMapping(saved.id);
177
+ /**
178
+ * The plan described the mapping as it WAS.
179
+ *
180
+ * A preview is an answer about one particular mapping, and editing that
181
+ * mapping makes the answer wrong — silently, because the import step
182
+ * prints its own heading from the saved record and the counts from the
183
+ * plan. Switching the write mode to `replace` after previewing gave a
184
+ * screen that said `replace` above numbers computed for `upsert`, and
185
+ * the destructive confirmation then combined the new mode with the old
186
+ * count: "1240 rows will be deleted", from a plan that never considered
187
+ * deleting anything.
188
+ *
189
+ * `selectMapping` cannot do this — it returns early when the id has not
190
+ * changed, and on a save it never does.
191
+ */ store.setPlan(null, null);
177
192
  setReloadToken((token)=>token + 1);
178
193
  then?.(saved);
179
194
  }).catch((error)=>{
@@ -42,6 +42,11 @@ export function RunHistoryRow({ run, actions, onReverted }) {
42
42
  setReverting(false);
43
43
  if (result.ok) {
44
44
  setConflicts(null);
45
+ // An undo moves the rows a plan was counted against, so the plan on
46
+ // the import step above is now describing a database that no longer
47
+ // exists. Leaving it up invited a re-import against numbers that were
48
+ // true two states ago.
49
+ store.setPlan(null, null);
45
50
  onReverted();
46
51
  return;
47
52
  }
@@ -111,11 +111,23 @@ export function RunPanel({ mapping, actions, onSubscribeProgress, onRunSettled }
111
111
  };
112
112
  // Progress for SOMEONE ELSE'S run is dropped: the topic is shared, and a
113
113
  // second operator's import must not drive this panel's bar.
114
+ /**
115
+ * A plan describes a database that no longer exists once it has been run.
116
+ *
117
+ * The plan is what the buttons act on and what the destructive confirmation
118
+ * quotes. Left standing after the import, it went on saying "880 to create"
119
+ * next to a history entry reporting 880 created — and the buttons came back
120
+ * enabled, so a second press started a second full import against numbers
121
+ * that were no longer true, with the confirmation showing those same stale
122
+ * numbers. Cleared here, the operator has to preview again, which is the only
123
+ * honest answer to "what would this do now".
124
+ */ const forgetPlan = ()=>store.setPlan(null, null);
114
125
  const applyProgress = useEffectEvent((progress)=>{
115
126
  if (store.activeRunId !== progress.runId) return;
116
127
  store.setProgress(progress);
117
128
  if (progress.phase === 'done') {
118
129
  store.setActiveRun(null);
130
+ forgetPlan();
119
131
  onRunSettled?.();
120
132
  }
121
133
  });
@@ -155,6 +167,9 @@ export function RunPanel({ mapping, actions, onSubscribeProgress, onRunSettled }
155
167
  if (!run || run.status === 'running' || run.status === 'queued') return;
156
168
  store.setActiveRun(null);
157
169
  store.setProgress(null);
170
+ // The other place a run ends. Both have to forget the plan, or the
171
+ // realtime path and the polling path disagree about what is on screen.
172
+ forgetPlan();
158
173
  if (run.status === 'failed') {
159
174
  store.setError(run.errors?.[0] ?? 'The import failed. Its history entry has the detail.');
160
175
  }
@@ -41,6 +41,21 @@ export type ConnectionFieldsProps = {
41
41
  };
42
42
  export declare const emptyConnectionDraft: ConnectionDraft;
43
43
  export declare function draftFromSource(source: IntegrationSource): ConnectionDraft;
44
+ /**
45
+ * The boxes a SAVED record should correct, and only those.
46
+ *
47
+ * After an edit the form has to show what was actually stored — the server
48
+ * trims, defaults and ignores things, and the boxes went on showing what was
49
+ * typed. But re-deriving the whole draft from the reply is not safe: a
50
+ * field-scoped patch claim strips columns out of the response, and
51
+ * {@link draftFromSource} substitutes a default for every column it does not
52
+ * find. A reply that legitimately omits `name` would therefore blank the name
53
+ * box — trading a stale screen for a destroyed one.
54
+ *
55
+ * So this reads the reply key by key and says nothing about a key that is not
56
+ * there. What it returns is a patch, applied over the draft the operator has.
57
+ */
58
+ export declare function draftPatchFromSource(source: Partial<IntegrationSource>): Partial<ConnectionDraft>;
44
59
  export declare function usesHeaderName(authType: IntegrationAuthType): boolean;
45
60
  export declare function usesQueryParam(authType: IntegrationAuthType): boolean;
46
61
  export declare function usesOauth(authType: IntegrationAuthType): boolean;
@@ -45,6 +45,47 @@ export function draftFromSource(source) {
45
45
  timeoutMs: source.timeoutMs == null ? '' : String(source.timeoutMs)
46
46
  };
47
47
  }
48
+ /**
49
+ * The boxes a SAVED record should correct, and only those.
50
+ *
51
+ * After an edit the form has to show what was actually stored — the server
52
+ * trims, defaults and ignores things, and the boxes went on showing what was
53
+ * typed. But re-deriving the whole draft from the reply is not safe: a
54
+ * field-scoped patch claim strips columns out of the response, and
55
+ * {@link draftFromSource} substitutes a default for every column it does not
56
+ * find. A reply that legitimately omits `name` would therefore blank the name
57
+ * box — trading a stale screen for a destroyed one.
58
+ *
59
+ * So this reads the reply key by key and says nothing about a key that is not
60
+ * there. What it returns is a patch, applied over the draft the operator has.
61
+ */ export function draftPatchFromSource(source) {
62
+ const full = draftFromSource(source);
63
+ const patch = {};
64
+ const carry = (key, column)=>{
65
+ if (column in source && source[column] !== undefined) patch[key] = full[key];
66
+ };
67
+ carry('name', 'name');
68
+ carry('slug', 'slug');
69
+ carry('baseUrl', 'baseUrl');
70
+ carry('description', 'description');
71
+ carry('enabled', 'enabled');
72
+ carry('authType', 'authType');
73
+ carry('authHeaderName', 'authHeaderName');
74
+ carry('authQueryParam', 'authQueryParam');
75
+ carry('tokenUrl', 'tokenUrl');
76
+ carry('oauthGrantType', 'oauthGrantType');
77
+ carry('oauthClientId', 'oauthClientId');
78
+ carry('oauthUsername', 'oauthUsername');
79
+ carry('oauthScope', 'oauthScope');
80
+ carry('oauthClientAuth', 'oauthClientAuth');
81
+ carry('tlsVerify', 'tlsVerify');
82
+ // `timeoutMs` is the reason this exists at all: emptying the box omits it
83
+ // from the patch, the server keeps its stored value, and the box has to be
84
+ // told. `null` is a real answer here — it means "no timeout set" — so the
85
+ // presence of the KEY is what is checked, not its truthiness.
86
+ if ('timeoutMs' in source) patch.timeoutMs = full.timeoutMs;
87
+ return patch;
88
+ }
48
89
  // ── which extras an auth type actually uses ─────────────────────────────────
49
90
  export function usesHeaderName(authType) {
50
91
  return authType === 'bearer' || authType === 'api_key';
@@ -1,5 +1,5 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import { draftFromSource, draftToSource, emptyConnectionDraft, validateConnectionDraft } from './connectionDraft';
1
+ import { describe, expect, it, test } from 'bun:test';
2
+ import { draftFromSource, draftPatchFromSource, draftToSource, emptyConnectionDraft, validateConnectionDraft } from './connectionDraft';
3
3
  /**
4
4
  * Found in production, creating the first connection: the form said "Empty
5
5
  * means the server default" under the timeout, and saving with it empty
@@ -131,3 +131,68 @@ describe('draftFromSource', ()=>{
131
131
  expect(draftToSource(draftFromSource(source), 'a').timeoutMs).toBe(30000);
132
132
  });
133
133
  });
134
+ /**
135
+ * What a save is allowed to correct in the boxes.
136
+ *
137
+ * After an edit the form showed what was TYPED, not what was stored: emptying
138
+ * the timeout left the box empty while the connection kept timing out at the
139
+ * stored value, and a trailing space in the name was trimmed on the way out so
140
+ * the switcher above and the box below disagreed until F5. The reply has to be
141
+ * read back — but only the parts of it that are actually there, because a
142
+ * field-scoped patch claim strips columns out, and `draftFromSource` fills a
143
+ * default for every column it does not find. Blanking a required box is worse
144
+ * than leaving it stale.
145
+ */ describe('the boxes a saved record corrects', ()=>{
146
+ const stored = {
147
+ id: 's1',
148
+ name: 'Acme',
149
+ slug: 'acme',
150
+ baseUrl: 'https://acme.test',
151
+ timeoutMs: 30000
152
+ };
153
+ it('takes the server’s value for a field it answered with', ()=>{
154
+ // Typed "Acme " with a trailing space; stored trimmed.
155
+ expect(draftPatchFromSource(stored).name).toBe('Acme');
156
+ });
157
+ /** The case this exists for: the box says empty, the server says 30000. */ it('corrects a timeout the server kept when the box was emptied', ()=>{
158
+ expect(draftPatchFromSource(stored).timeoutMs).toBe('30000');
159
+ });
160
+ it('says nothing at all about a field the reply left out', ()=>{
161
+ const patch = draftPatchFromSource({
162
+ id: 's1',
163
+ name: 'Acme'
164
+ });
165
+ expect(patch).toEqual({
166
+ name: 'Acme'
167
+ });
168
+ expect('slug' in patch).toBe(false);
169
+ expect('timeoutMs' in patch).toBe(false);
170
+ });
171
+ /**
172
+ * The failure this shape prevents: applied over a real draft, a partial reply
173
+ * must correct what it mentions and leave everything else alone.
174
+ */ it('leaves the rest of the draft untouched when applied over it', ()=>{
175
+ const draft = {
176
+ ...emptyConnectionDraft,
177
+ name: 'Acme ',
178
+ slug: 'acme',
179
+ baseUrl: 'https://a.test'
180
+ };
181
+ const next = {
182
+ ...draft,
183
+ ...draftPatchFromSource({
184
+ id: 's1',
185
+ name: 'Acme'
186
+ })
187
+ };
188
+ expect(next.name).toBe('Acme');
189
+ expect(next.slug).toBe('acme');
190
+ expect(next.baseUrl).toBe('https://a.test');
191
+ });
192
+ /** null is an answer — "no timeout" — not an absence. */ it('treats a null the server returned as a value, not as silence', ()=>{
193
+ expect(draftPatchFromSource({
194
+ id: 's1',
195
+ timeoutMs: null
196
+ }).timeoutMs).toBe('');
197
+ });
198
+ });