@frontera-sdk/cli 1.45.7 → 1.45.9

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.
@@ -121,6 +121,61 @@ export function summarizeUploads(
121
121
  * the count of what did not land has to be the first thing on the page rather
122
122
  * than a column someone has to notice.
123
123
  */
124
+ /**
125
+ * How many source rows already carry these filenames.
126
+ *
127
+ * A failed upload does NOT mean nothing was recorded: the service registers the
128
+ * source and then fails downstream — ingestion, embedding — so `all 1
129
+ * upload(s) failed` was printed while a `pending` row sat in the base. Retrying
130
+ * added a second row for the same file, which is the opposite of the "a retry
131
+ * duplicates nothing" the hint used to promise.
132
+ *
133
+ * Counted rather than guessed, and matched on the filename because that is all
134
+ * a failed upload leaves behind — no id comes back. Names are not unique, so
135
+ * this is reported as "rows carrying this name", never as "your upload".
136
+ */
137
+ export async function countExistingSources(
138
+ api: { knowledgeSources(id: string): Promise<unknown[]> },
139
+ baseId: string,
140
+ filenames: string[],
141
+ ): Promise<Map<string, number>> {
142
+ const wanted = new Set(filenames)
143
+ const counts = new Map<string, number>()
144
+ // Best effort: this runs on a path that is already failing, and a second
145
+ // failure here must not replace the cause the caller needs to see.
146
+ const rows = await api.knowledgeSources(baseId).catch(() => [] as unknown[])
147
+ for (const row of rows as Array<{ fileName?: string }>) {
148
+ const name = row?.fileName
149
+ if (!name || !wanted.has(name)) continue
150
+ counts.set(name, (counts.get(name) ?? 0) + 1)
151
+ }
152
+ return counts
153
+ }
154
+
155
+ /**
156
+ * What to say about rows a failed upload may have left behind.
157
+ *
158
+ * Returns `undefined` when there is nothing to warn about, so a clean failure
159
+ * keeps a clean message.
160
+ */
161
+ export function describeExistingSources(counts: Map<string, number>): string | undefined {
162
+ const present = [...counts.entries()].filter(([, n]) => n > 0)
163
+ if (present.length === 0) return undefined
164
+
165
+ const total = present.reduce((sum, [, n]) => sum + n, 0)
166
+ const duplicated = present.filter(([, n]) => n > 1)
167
+ return (
168
+ `${total} source row${total === 1 ? '' : 's'} already `
169
+ + `${total === 1 ? 'carries' : 'carry'} `
170
+ + `${present.length === 1 ? 'that filename' : 'those filenames'}`
171
+ + (duplicated.length > 0
172
+ ? `, and ${duplicated.length} filename${duplicated.length === 1 ? '' : 's'} `
173
+ + `${duplicated.length === 1 ? 'has' : 'have'} more than one — `
174
+ + 'a failed upload still registers a source, so re-running adds another'
175
+ : ' — a failed upload still registers a source')
176
+ )
177
+ }
178
+
124
179
  export function renderUploadSummary(summary: UploadSummary): string {
125
180
  const lines: string[] = []
126
181
  const total = summary.sources.length
@@ -172,7 +172,85 @@ function requireConfig(doc: SourceFile, path: string): Record<string, unknown> {
172
172
  hint: 'config: { kind: "postgres", host, port, database, username, sslMode, connectTimeoutMs }',
173
173
  })
174
174
  }
175
- return doc.config as Record<string, unknown>
175
+ const config = doc.config as Record<string, unknown>
176
+ validateConnectionConfig(config, path)
177
+ return config
178
+ }
179
+
180
+ const SSL_MODES = ['require', 'verify-ca', 'verify-full', 'disable']
181
+
182
+ /**
183
+ * The connection block, checked here for the reason `validateDatasets` gives.
184
+ *
185
+ * That function exists because the service's refusal for this body names the
186
+ * wrong field — `strictJsonBody` substitutes a sentinel that omits `mode`, so
187
+ * ANY failure anywhere in the document comes back as "Expected 'virtual' at
188
+ * /datasets/0/mode". It pre-empted that for `datasets` and not for `config`,
189
+ * which left the commonest mistakes unreachable: a `config` missing `kind` or
190
+ * `connectTimeoutMs` was reported as a bad `mode` on a dataset whose `mode` was
191
+ * already `"virtual"`.
192
+ *
193
+ * That is how `source create` came to look like a write path with no passing
194
+ * document. It has one — the errors were describing the wrong half of the file.
195
+ *
196
+ * `password` is deliberately absent: it never travels in the file, and a file
197
+ * carrying one is refused rather than stripped, above.
198
+ */
199
+ export function validateConnectionConfig(config: Record<string, unknown>, path: string): void {
200
+ const where = `${path}: config`
201
+
202
+ if (config.kind !== 'postgres') {
203
+ throw new CliError(`${where} has kind ${JSON.stringify(config.kind)}.`, {
204
+ code: 'USAGE',
205
+ hint: 'kind must be "postgres" — it is the only connector the service accepts today',
206
+ })
207
+ }
208
+
209
+ for (const field of ['host', 'database', 'username'] as const) {
210
+ const value = config[field]
211
+ if (typeof value !== 'string' || value.length === 0) {
212
+ throw new CliError(`${where}.${field} is ${JSON.stringify(value)}.`, {
213
+ code: 'USAGE',
214
+ hint: `${field} must be a non-empty string`,
215
+ })
216
+ }
217
+ }
218
+
219
+ const port = config.port
220
+ if (!Number.isInteger(port) || (port as number) < 1 || (port as number) > 65_535) {
221
+ throw new CliError(`${where}.port is ${JSON.stringify(port)}.`, {
222
+ code: 'USAGE',
223
+ hint: 'port must be a whole number between 1 and 65535 — 5432 for a default PostgreSQL',
224
+ })
225
+ }
226
+
227
+ if (typeof config.sslMode !== 'string' || !SSL_MODES.includes(config.sslMode)) {
228
+ throw new CliError(`${where}.sslMode is ${JSON.stringify(config.sslMode)}.`, {
229
+ code: 'USAGE',
230
+ hint: `sslMode must be one of: ${SSL_MODES.join(', ')}`,
231
+ })
232
+ }
233
+
234
+ const timeout = config.connectTimeoutMs
235
+ if (!Number.isInteger(timeout) || (timeout as number) < 1_000 || (timeout as number) > 15_000) {
236
+ throw new CliError(`${where}.connectTimeoutMs is ${JSON.stringify(timeout)}.`, {
237
+ code: 'USAGE',
238
+ // Easily missed: it has no sensible default the CLI could supply, because
239
+ // the right value depends on where the database is.
240
+ hint: 'connectTimeoutMs must be a whole number of milliseconds between 1000 and 15000',
241
+ })
242
+ }
243
+
244
+ // The service sets `additionalProperties: false`, so an extra key fails the
245
+ // whole document — and fails it by naming a dataset's `mode`.
246
+ const KNOWN = new Set(['kind', 'host', 'port', 'database', 'username', 'sslMode', 'connectTimeoutMs'])
247
+ const unknown = Object.keys(config).filter((key) => !KNOWN.has(key))
248
+ if (unknown.length > 0) {
249
+ throw new CliError(`${where} carries unknown field(s): ${unknown.join(', ')}.`, {
250
+ code: 'USAGE',
251
+ hint: `config accepts exactly: ${[...KNOWN].join(', ')}`,
252
+ })
253
+ }
176
254
  }
177
255
 
178
256
  const list: Command = {