snowpipe-streaming 1.6.1 → 1.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "snowpipe-streaming",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
4
4
  "description": "Snowflake Streaming Ingest SDK for Node.js",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
@@ -27,11 +27,11 @@
27
27
  "typedoc": "^0.28.19"
28
28
  },
29
29
  "optionalDependencies": {
30
- "snowpipe-streaming-linux-x64-gnu": "1.6.1",
31
- "snowpipe-streaming-linux-x64-musl": "1.6.1",
32
- "snowpipe-streaming-linux-arm64-gnu": "1.6.1",
33
- "snowpipe-streaming-linux-arm64-musl": "1.6.1",
34
- "snowpipe-streaming-darwin-arm64": "1.6.1",
35
- "snowpipe-streaming-win32-x64-msvc": "1.6.1"
30
+ "snowpipe-streaming-linux-x64-gnu": "1.7.0",
31
+ "snowpipe-streaming-linux-x64-musl": "1.7.0",
32
+ "snowpipe-streaming-linux-arm64-gnu": "1.7.0",
33
+ "snowpipe-streaming-linux-arm64-musl": "1.7.0",
34
+ "snowpipe-streaming-darwin-arm64": "1.7.0",
35
+ "snowpipe-streaming-win32-x64-msvc": "1.7.0"
36
36
  }
37
37
  }
package/src/channel.js CHANGED
@@ -17,6 +17,7 @@
17
17
 
18
18
  const { callFFI, StreamingIngestError } = require("./errors.js");
19
19
  const { ChannelStatus } = require("./channel_status.js");
20
+ const { serializeRowsToNdjson } = require("./row_serializer.js");
20
21
 
21
22
  const WAIT_FOR_COMMIT_CHECK_INTERVAL_MS = 1000;
22
23
 
@@ -71,35 +72,6 @@ class StreamingIngestChannel {
71
72
  return this._native.binaryInputFormat;
72
73
  }
73
74
 
74
- /**
75
- * Convert Buffer/Uint8Array values in a row according to binaryInputFormat.
76
- * For HEX, binary values become hex-encoded strings.
77
- * For UTF-8, binary values are decoded as UTF-8 strings.
78
- * Only top-level values are converted; nested structures are left as-is.
79
- * @param {Object} row - Row object
80
- * @returns {Object} Row with binary values converted
81
- * @private
82
- */
83
- _convertBytesInRow(row) {
84
- const result = {};
85
- for (const [key, value] of Object.entries(row)) {
86
- if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
87
- const buf = Buffer.isBuffer(value) ? value : Buffer.from(value);
88
- if (this.binaryInputFormat === "HEX") {
89
- result[key] = buf.toString("hex");
90
- } else if (this.binaryInputFormat === "UTF-8") {
91
- result[key] = buf.toString("utf8");
92
- } else {
93
- // BASE64: convert to base64 string for JSON serialization
94
- result[key] = buf.toString("base64");
95
- }
96
- } else {
97
- result[key] = value;
98
- }
99
- }
100
- return result;
101
- }
102
-
103
75
  /**
104
76
  * Append a single row. Convenience wrapper for appendRows.
105
77
  * @param {Object} row - Row data as key-value pairs
@@ -108,6 +80,9 @@ class StreamingIngestChannel {
108
80
  * @throws {StreamingIngestError} If the row appending fails
109
81
  */
110
82
  appendRow(row, offsetToken) {
83
+ if (row == null) {
84
+ throw new TypeError("row must not be null");
85
+ }
111
86
  this.appendRows([row], offsetToken, offsetToken);
112
87
  }
113
88
 
@@ -122,17 +97,15 @@ class StreamingIngestChannel {
122
97
  * @throws {StreamingIngestError} If the rows appending fails
123
98
  */
124
99
  appendRows(rows, startOffsetToken, endOffsetToken) {
100
+ if (rows == null) {
101
+ throw new TypeError("rows must not be null");
102
+ }
125
103
  if (!Array.isArray(rows)) {
126
104
  throw new TypeError("rows must be an array");
127
105
  }
128
106
  // Empty arrays are handled by the Rust core, which returns a proper InvalidRequest error.
129
107
  const buffer =
130
- rows.length === 0
131
- ? Buffer.alloc(0)
132
- : Buffer.from(
133
- rows.map((r) => JSON.stringify(this._convertBytesInRow(r))).join("\n"),
134
- "utf8",
135
- );
108
+ rows.length === 0 ? Buffer.alloc(0) : serializeRowsToNdjson(rows, this.binaryInputFormat);
136
109
  try {
137
110
  this._native.appendRows(buffer, rows.length, startOffsetToken, endOffsetToken);
138
111
  } catch (e) {
package/src/client.js CHANGED
@@ -18,6 +18,8 @@
18
18
  const { callFFI, StreamingIngestError } = require("./errors.js");
19
19
  const { ChannelStatus } = require("./channel_status.js");
20
20
  const { StreamingIngestChannel } = require("./channel.js");
21
+ const { StreamingIngestElasticChannel } = require("./elastic_channel.js");
22
+ const { ElasticChannelAckState } = require("./mark_complete_callback.js");
21
23
 
22
24
  /**
23
25
  * Streaming ingest client for creating channels to ingest data into Snowflake.
@@ -45,6 +47,8 @@ class StreamingIngestClient {
45
47
  /** @param {object} nativeClient - Internal JsClient from FFI */
46
48
  constructor(nativeClient) {
47
49
  this._native = nativeClient;
50
+ /** @type {Promise<StreamingIngestElasticChannel>|null} Cached elastic-channel promise (singleton). */
51
+ this._elasticChannelPromise = null;
48
52
  }
49
53
 
50
54
  /** @returns {string} The client name */
@@ -108,6 +112,37 @@ class StreamingIngestClient {
108
112
  };
109
113
  }
110
114
 
115
+ /**
116
+ * Get the elastic channel for this client. Elastic channels have no offset
117
+ * tokens and their lifecycle is tied to the client (no close method). The same
118
+ * instance is returned on repeated calls (singleton).
119
+ *
120
+ * @returns {Promise<StreamingIngestElasticChannel>}
121
+ */
122
+ async getElasticChannel() {
123
+ if (!this._elasticChannelPromise) {
124
+ // Cache the in-flight promise so concurrent callers share one open and one
125
+ // instance. The pending-futures map is shared between the FFI callbacks and
126
+ // the channel wrapper (the JS side owns per-message-ack promise resolution).
127
+ this._elasticChannelPromise = (async () => {
128
+ const pendingFutures = new Map();
129
+ const ackState = new ElasticChannelAckState(pendingFutures);
130
+ const nativeChannel = await callFFI(() =>
131
+ this._native.getElasticChannel(ackState.markComplete, ackState.invalidateAll),
132
+ );
133
+ // Plug the native handle in before exposing the channel so the ack
134
+ // state can drive `invalidateChannel` from JS on detected anomalies.
135
+ ackState.setNativeChannel(nativeChannel);
136
+ return new StreamingIngestElasticChannel(nativeChannel, pendingFutures);
137
+ })().catch((e) => {
138
+ // Don't cache failures (e.g. closed client, transient open error) so a retry can re-open.
139
+ this._elasticChannelPromise = null;
140
+ throw e;
141
+ });
142
+ }
143
+ return this._elasticChannelPromise;
144
+ }
145
+
111
146
  /**
112
147
  * Drop a channel by name (server-side removal).
113
148
  * @param {string} channelName
@@ -175,15 +210,16 @@ class StreamingIngestClient {
175
210
  }
176
211
 
177
212
  /**
178
- * Create a new streaming ingest client.
179
- *
180
- * @param {object} native - The native addon instance (provided by index.js)
181
- * @param {{clientName: string, dbName: string, schemaName: string, pipeName: string, profilePath?: string, properties?: Object.<string, string>}} options
213
+ * Shared client construction: coerce property values to strings, apply the
214
+ * Windows workaround, call the native factory, and wrap the result.
215
+ * @param {object} native - The native addon instance
216
+ * @param {{clientName: string, dbName: string, schemaName: string, pipeName: string, profilePath?: string, properties?: Object.<string, string>, isTableMode: boolean}} args
182
217
  * @returns {Promise<StreamingIngestClient>}
218
+ * @private
183
219
  */
184
- async function createClient(native, options) {
220
+ async function _createNativeClient(native, args) {
185
221
  // Coerce property values to strings (matching Python)
186
- let properties = options.properties;
222
+ let properties = args.properties;
187
223
  if (properties) {
188
224
  properties = Object.fromEntries(Object.entries(properties).map(([k, v]) => [k, String(v)]));
189
225
  }
@@ -198,19 +234,60 @@ async function createClient(native, options) {
198
234
 
199
235
  const nativeClient = await callFFI(() =>
200
236
  native.createClient({
201
- clientName: options.clientName,
202
- dbName: options.dbName,
203
- schemaName: options.schemaName,
204
- pipeName: options.pipeName,
205
- profilePath: options.profilePath,
237
+ clientName: args.clientName,
238
+ dbName: args.dbName,
239
+ schemaName: args.schemaName,
240
+ pipeName: args.pipeName,
241
+ profilePath: args.profilePath,
206
242
  properties,
243
+ isTableMode: args.isTableMode,
207
244
  }),
208
245
  );
209
246
 
210
247
  return new StreamingIngestClient(nativeClient);
211
248
  }
212
249
 
250
+ /**
251
+ * Create a new streaming ingest client.
252
+ *
253
+ * @param {object} native - The native addon instance (provided by index.js)
254
+ * @param {{clientName: string, dbName: string, schemaName: string, pipeName: string, profilePath?: string, properties?: Object.<string, string>}} options
255
+ * @returns {Promise<StreamingIngestClient>}
256
+ */
257
+ async function createClient(native, options) {
258
+ return _createNativeClient(native, {
259
+ clientName: options.clientName,
260
+ dbName: options.dbName,
261
+ schemaName: options.schemaName,
262
+ pipeName: options.pipeName,
263
+ profilePath: options.profilePath,
264
+ properties: options.properties,
265
+ isTableMode: false,
266
+ });
267
+ }
268
+
269
+ /**
270
+ * Create a new streaming ingest client in table mode. The pipe name is derived
271
+ * from the table name by appending "-STREAMING".
272
+ *
273
+ * @param {object} native - The native addon instance (provided by index.js)
274
+ * @param {{clientName: string, dbName: string, schemaName: string, tableName: string, profilePath?: string, properties?: Object.<string, string>}} options
275
+ * @returns {Promise<StreamingIngestClient>}
276
+ */
277
+ async function createTableClient(native, options) {
278
+ return _createNativeClient(native, {
279
+ clientName: options.clientName,
280
+ dbName: options.dbName,
281
+ schemaName: options.schemaName,
282
+ pipeName: `${options.tableName}-STREAMING`,
283
+ profilePath: options.profilePath,
284
+ properties: options.properties,
285
+ isTableMode: true,
286
+ });
287
+ }
288
+
213
289
  module.exports = {
214
290
  StreamingIngestClient,
215
291
  createClient,
292
+ createTableClient,
216
293
  };
@@ -0,0 +1,175 @@
1
+ // Copyright 2026 Snowflake Inc.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ //
4
+ // Licensed under the Apache License, Version 2.0 (the "License");
5
+ // you may not use this file except in compliance with the License.
6
+ // You may obtain a copy of the License at
7
+ //
8
+ // http://www.apache.org/licenses/LICENSE-2.0
9
+ //
10
+ // Unless required by applicable law or agreed to in writing, software
11
+ // distributed under the License is distributed on an "AS IS" BASIS,
12
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ // See the License for the specific language governing permissions and
14
+ // limitations under the License.
15
+
16
+ "use strict";
17
+
18
+ const { callFFI, StreamingIngestError } = require("./errors.js");
19
+ const { ChannelStatus } = require("./channel_status.js");
20
+ const { serializeRowsToNdjson } = require("./row_serializer.js");
21
+
22
+ /**
23
+ * Elastic channel for streaming data into a Snowflake table.
24
+ *
25
+ * Unlike a regular channel, an elastic channel has no offset tokens and its
26
+ * lifecycle is tied to the client (no close method). appendRow/appendRows return
27
+ * a Promise that resolves when Snowflake acknowledges the rows.
28
+ *
29
+ * Created via {@link StreamingIngestClient#getElasticChannel}, not directly.
30
+ */
31
+ class StreamingIngestElasticChannel {
32
+ /**
33
+ * @param {object} nativeChannel - Internal JsElasticChannel from FFI
34
+ * @param {Map<number, {resolve: Function, reject: Function}>} pendingFutures
35
+ * Pending-promise map shared with the mark-complete callback wired into FFI.
36
+ */
37
+ constructor(nativeChannel, pendingFutures) {
38
+ if (!(nativeChannel && typeof nativeChannel === "object")) {
39
+ throw new TypeError(
40
+ "StreamingIngestElasticChannel cannot be instantiated directly. " +
41
+ "Use client.getElasticChannel() instead.",
42
+ );
43
+ }
44
+ this._native = nativeChannel;
45
+ this._pendingFutures = pendingFutures;
46
+ }
47
+
48
+ /** @returns {string} The channel name (always "ELASTIC") */
49
+ get channelName() {
50
+ return this._native.channelName;
51
+ }
52
+
53
+ /** @returns {string} The database name */
54
+ get dbName() {
55
+ return this._native.dbName;
56
+ }
57
+
58
+ /** @returns {string} The schema name */
59
+ get schemaName() {
60
+ return this._native.schemaName;
61
+ }
62
+
63
+ /** @returns {string} The pipe name */
64
+ get pipeName() {
65
+ return this._native.pipeName;
66
+ }
67
+
68
+ /** @returns {boolean} True if the channel is closed (because the client was closed) */
69
+ get isClosed() {
70
+ return this._native.isClosed;
71
+ }
72
+
73
+ /**
74
+ * Get the binary input format for this channel.
75
+ * @returns {string} One of: "BASE64", "HEX", "UTF-8"
76
+ */
77
+ get binaryInputFormat() {
78
+ return this._native.binaryInputFormat;
79
+ }
80
+
81
+ /**
82
+ * Append a single row into the elastic channel.
83
+ * @param {Object} row - Row data as column-name to value pairs
84
+ * @returns {Promise<void>} Resolves when Snowflake acknowledges the row
85
+ */
86
+ async appendRow(row) {
87
+ if (row == null) {
88
+ throw new TypeError("row must not be null");
89
+ }
90
+ return this.appendRows([row]);
91
+ }
92
+
93
+ /**
94
+ * Append multiple rows into the elastic channel.
95
+ *
96
+ * The native call is synchronous: it buffers the rows and returns a future id,
97
+ * which is registered in the shared pending map before returning to the event
98
+ * loop. The Promise resolves later when the mark-complete callback fires with
99
+ * the server acknowledgement (or rejects on error / channel invalidation).
100
+ *
101
+ * @param {Object[]} rows - Array of row objects
102
+ * @returns {Promise<void>} Resolves when Snowflake acknowledges the batch
103
+ */
104
+ async appendRows(rows) {
105
+ if (rows == null) {
106
+ throw new TypeError("rows must not be null");
107
+ }
108
+ if (!Array.isArray(rows)) {
109
+ throw new TypeError("rows must be an array");
110
+ }
111
+ // Empty arrays are validated by the Rust core, which throws a proper error.
112
+ const buffer =
113
+ rows.length === 0 ? Buffer.alloc(0) : serializeRowsToNdjson(rows, this.binaryInputFormat);
114
+ let futureId;
115
+ try {
116
+ futureId = Number(this._native.appendRows(buffer, rows.length));
117
+ } catch (e) {
118
+ throw StreamingIngestError.from(e);
119
+ }
120
+
121
+ // Build the promise out-of-line so we can stash {resolve, reject} into the
122
+ // shared map synchronously — the single-threaded event loop guarantees this
123
+ // happens-before any queued threadsafe-function callback runs. Map and
124
+ // resolve are then visible to mark_complete_callback.js (the JS owner of
125
+ // resolution) and the invalidate-all path on channel teardown.
126
+ let resolveFn;
127
+ let rejectFn;
128
+ const promise = new Promise((resolve, reject) => {
129
+ resolveFn = resolve;
130
+ rejectFn = reject;
131
+ });
132
+ const previous = this._pendingFutures.get(futureId);
133
+ this._pendingFutures.set(futureId, { resolve: resolveFn, reject: rejectFn });
134
+ if (previous !== undefined) {
135
+ // Mirrors Java/Python: a duplicate future id from Rust means the SDK
136
+ // and the core have drifted, which is fatal. Reject the displaced
137
+ // promise, invalidate the channel, and throw to the caller so the
138
+ // new promise we just stashed isn't left hanging either.
139
+ const errMsg = `Duplicate future_id=${futureId} in pendingFutures`;
140
+ const err = new StreamingIngestError("Fatal", errMsg, 500, "Internal Server Error");
141
+ previous.reject(err);
142
+ try {
143
+ this._native.invalidateChannel(errMsg);
144
+ } catch (invErr) {
145
+ // eslint-disable-next-line no-console
146
+ console.error("Elastic channel invalidate_channel FFI call failed:", invErr);
147
+ }
148
+ throw err;
149
+ }
150
+ return promise;
151
+ }
152
+
153
+ /**
154
+ * Trigger a flush of this channel (non-blocking).
155
+ * @throws {StreamingIngestError} If the flush cannot be initiated
156
+ */
157
+ initiateFlush() {
158
+ try {
159
+ this._native.initiateFlush();
160
+ } catch (e) {
161
+ throw StreamingIngestError.from(e);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Get the current status of this channel.
167
+ * @returns {Promise<ChannelStatus>}
168
+ */
169
+ async getChannelStatus() {
170
+ const raw = await callFFI(() => this._native.getChannelStatus());
171
+ return new ChannelStatus(raw);
172
+ }
173
+ }
174
+
175
+ module.exports = { StreamingIngestElasticChannel };
package/src/index.d.ts CHANGED
@@ -55,6 +55,32 @@ export function createClient(options: {
55
55
  properties?: Record<string, string>;
56
56
  }): Promise<StreamingIngestClient>;
57
57
 
58
+ /**
59
+ * Create a new streaming ingest client in table mode. The pipe name is derived
60
+ * from the table name by appending "-STREAMING". Each client is tied to a single
61
+ * database, schema, and (derived) pipe.
62
+ *
63
+ * Either `profilePath` or `properties` must be provided for authentication.
64
+ * If both are provided, `properties` takes precedence for conflicting keys.
65
+ *
66
+ * @param options - Client configuration
67
+ * @throws StreamingIngestError If client creation fails
68
+ */
69
+ export function createTableClient(options: {
70
+ /** A unique name to identify this client instance, used for tracking and debugging purposes. */
71
+ clientName: string;
72
+ /** The name of the Snowflake database where data will be ingested. */
73
+ dbName: string;
74
+ /** The name of the schema within the database. */
75
+ schemaName: string;
76
+ /** The table name. The pipe name is derived as `${tableName}-STREAMING`. */
77
+ tableName: string;
78
+ /** Path to a JSON file containing connection properties and authentication information. */
79
+ profilePath?: string;
80
+ /** Connection properties and authentication information. */
81
+ properties?: Record<string, string>;
82
+ }): Promise<StreamingIngestClient>;
83
+
58
84
  /**
59
85
  * A client that is the starting point for using the Streaming Ingest client APIs.
60
86
  *
@@ -116,6 +142,15 @@ export class StreamingIngestClient {
116
142
  status: ChannelStatus | null;
117
143
  }>;
118
144
 
145
+ /**
146
+ * Get the elastic channel for this client. Elastic channels have no offset
147
+ * tokens and their lifecycle is tied to the client (there is no close method).
148
+ * The same instance is returned on repeated calls (singleton).
149
+ *
150
+ * @throws StreamingIngestError If the client is closed or the channel cannot be opened
151
+ */
152
+ getElasticChannel(): Promise<StreamingIngestElasticChannel>;
153
+
119
154
  /**
120
155
  * Drop a channel by name (server-side removal).
121
156
  *
@@ -292,6 +327,67 @@ export class StreamingIngestChannel {
292
327
  }): Promise<void>;
293
328
  }
294
329
 
330
+ /**
331
+ * An elastic channel for streaming data into a Snowflake table.
332
+ *
333
+ * Unlike a regular channel, an elastic channel has no offset tokens and its
334
+ * lifecycle is tied to the client — there is no close method, and the same
335
+ * instance is returned on repeated calls to `client.getElasticChannel()`.
336
+ * `appendRow`/`appendRows` resolve when Snowflake acknowledges the rows.
337
+ *
338
+ * Create instances using `client.getElasticChannel()`.
339
+ */
340
+ export class StreamingIngestElasticChannel {
341
+ private constructor();
342
+
343
+ /** The channel name (always "ELASTIC"). */
344
+ readonly channelName: string;
345
+ /** The database name. */
346
+ readonly dbName: string;
347
+ /** The schema name. */
348
+ readonly schemaName: string;
349
+ /** The pipe name. */
350
+ readonly pipeName: string;
351
+ /** Whether the channel is closed (because the client was closed). */
352
+ readonly isClosed: boolean;
353
+ /** The binary input format. One of: "BASE64", "HEX", "UTF-8". */
354
+ readonly binaryInputFormat: string;
355
+
356
+ /**
357
+ * Append a single row into the elastic channel. The returned promise resolves
358
+ * when Snowflake acknowledges the row.
359
+ *
360
+ * @param row - Row data as column-name to value pairs
361
+ * @throws StreamingIngestError If the row appending fails
362
+ */
363
+ appendRow(row: Record<string, any>): Promise<void>;
364
+
365
+ /**
366
+ * Append multiple rows into the elastic channel. The returned promise resolves
367
+ * when Snowflake acknowledges the batch.
368
+ *
369
+ * @param rows - Array of row objects (column-name to value pairs)
370
+ * @throws TypeError If rows is not an array
371
+ * @throws StreamingIngestError If the rows appending fails
372
+ */
373
+ appendRows(rows: Record<string, any>[]): Promise<void>;
374
+
375
+ /**
376
+ * Initiate a flush of all buffered data for this channel but do not wait for
377
+ * the flush to complete.
378
+ *
379
+ * @throws StreamingIngestError If the flush cannot be initiated
380
+ */
381
+ initiateFlush(): void;
382
+
383
+ /**
384
+ * Get the current status of this channel.
385
+ *
386
+ * @throws StreamingIngestError If getting the channel status fails
387
+ */
388
+ getChannelStatus(): Promise<ChannelStatus>;
389
+ }
390
+
295
391
  /**
296
392
  * Status information for a streaming ingest channel.
297
393
  *
@@ -344,6 +440,8 @@ export class ChannelStatus {
344
440
  export class StreamingIngestError extends Error {
345
441
  /** The error code (for example, "ConfigError", "ChannelNotFound"). */
346
442
  readonly errorCode: string;
443
+ /** The error message. Inherited from `Error`; redeclared for autodoc visibility. */
444
+ readonly message: string;
347
445
  /** HTTP status code indicating error category. */
348
446
  readonly httpStatusCode: number;
349
447
  /** HTTP status name (for example, "Bad Request"). */
package/src/index.js CHANGED
@@ -20,8 +20,13 @@
20
20
  const { loadNativeAddon } = require("./loader.js");
21
21
  const { StreamingIngestError } = require("./errors.js");
22
22
  const { ChannelStatus } = require("./channel_status.js");
23
- const { StreamingIngestClient, createClient: _createClient } = require("./client.js");
23
+ const {
24
+ StreamingIngestClient,
25
+ createClient: _createClient,
26
+ createTableClient: _createTableClient,
27
+ } = require("./client.js");
24
28
  const { StreamingIngestChannel } = require("./channel.js");
29
+ const { StreamingIngestElasticChannel } = require("./elastic_channel.js");
25
30
 
26
31
  // Load native addon and bootstrap the Rust runtime
27
32
  const native = loadNativeAddon();
@@ -41,8 +46,10 @@ const StreamingIngestErrorCode = Object.freeze(
41
46
 
42
47
  module.exports = {
43
48
  createClient: (options) => _createClient(native, options),
49
+ createTableClient: (options) => _createTableClient(native, options),
44
50
  StreamingIngestClient,
45
51
  StreamingIngestChannel,
52
+ StreamingIngestElasticChannel,
46
53
  StreamingIngestError,
47
54
  StreamingIngestErrorCode,
48
55
  ChannelStatus,
@@ -0,0 +1,157 @@
1
+ // Copyright 2026 Snowflake Inc.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ //
4
+ // Licensed under the Apache License, Version 2.0 (the "License");
5
+ // you may not use this file except in compliance with the License.
6
+ // You may obtain a copy of the License at
7
+ //
8
+ // http://www.apache.org/licenses/LICENSE-2.0
9
+ //
10
+ // Unless required by applicable law or agreed to in writing, software
11
+ // distributed under the License is distributed on an "AS IS" BASIS,
12
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ // See the License for the specific language governing permissions and
14
+ // limitations under the License.
15
+
16
+ "use strict";
17
+
18
+ // Per-elastic-channel acknowledgement bridge. Port of Python's
19
+ // _mark_complete_callback.py and Java's MarkCompleteCallback. The Rust
20
+ // threadsafe-function callbacks fire into `markComplete` (one call per
21
+ // server-ack batch) and `invalidateAll` (once on channel teardown); the JS
22
+ // side owns the pending-promise map keyed by future id and resolves or
23
+ // rejects accordingly.
24
+ //
25
+ // Java/Python express the same protocol slightly differently: their
26
+ // markComplete *throws* on a missing future id or an odd-length range, and
27
+ // the host (JNI/PyO3) propagates that exception back to Rust which then
28
+ // invalidates the channel. The napi threadsafe-function bridge is
29
+ // NonBlocking and has no return channel, so we can't ride the exception
30
+ // back. Instead the JS side detects the inconsistency, rejects every
31
+ // in-flight promise, and calls `invalidateChannel` on the native handle
32
+ // directly — same end state, driven from JS.
33
+
34
+ const { StreamingIngestError } = require("./errors.js");
35
+
36
+ function buildError(success, errorCode, message, httpStatusCode, httpStatusName) {
37
+ if (success) {
38
+ return null;
39
+ }
40
+ return new StreamingIngestError(
41
+ errorCode || "Fatal",
42
+ message || "Unknown error",
43
+ httpStatusCode != null ? Number(httpStatusCode) : 409,
44
+ httpStatusName || "Conflict",
45
+ );
46
+ }
47
+
48
+ /**
49
+ * Acknowledgement state for one elastic channel. Owns the pending-futures map
50
+ * shared with `StreamingIngestElasticChannel.appendRows`, the `invalidated`
51
+ * gate that mirrors Java/Python's flag, and a reference to the native channel
52
+ * for JS-driven invalidation. The native reference is plugged in after the
53
+ * channel is opened (chicken-and-egg: these callbacks are passed *into* the
54
+ * open call, so they exist before the native handle does).
55
+ */
56
+ class ElasticChannelAckState {
57
+ constructor(pendingFutures) {
58
+ this._pendingFutures = pendingFutures;
59
+ this._invalidated = false;
60
+ this._nativeChannel = null;
61
+ this.markComplete = this._markComplete.bind(this);
62
+ this.invalidateAll = this._invalidateAll.bind(this);
63
+ }
64
+
65
+ /** Plug in the native channel after `getElasticChannel` resolves. */
66
+ setNativeChannel(nativeChannel) {
67
+ this._nativeChannel = nativeChannel;
68
+ }
69
+
70
+ _markComplete(payload) {
71
+ const { futureIdRanges, success, errorCode, message, httpStatusCode, httpStatusName } = payload;
72
+
73
+ if (futureIdRanges.length % 2 !== 0) {
74
+ const errMsg = `future_id_ranges has odd length ${futureIdRanges.length}, expected even (flattened [min, max] pairs)`;
75
+ this._invalidateLocally(
76
+ new StreamingIngestError("Fatal", errMsg, 500, "Internal Server Error"),
77
+ );
78
+ return;
79
+ }
80
+
81
+ const error = buildError(success, errorCode, message, httpStatusCode, httpStatusName);
82
+
83
+ const missingIds = [];
84
+ for (let i = 0; i < futureIdRanges.length; i += 2) {
85
+ const min = Number(futureIdRanges[i]);
86
+ const max = Number(futureIdRanges[i + 1]);
87
+ for (let fid = min; fid <= max; fid++) {
88
+ const pending = this._pendingFutures.get(fid);
89
+ if (pending === undefined) {
90
+ missingIds.push(fid);
91
+ continue;
92
+ }
93
+ this._pendingFutures.delete(fid);
94
+ if (error) {
95
+ pending.reject(error);
96
+ } else {
97
+ pending.resolve();
98
+ }
99
+ }
100
+ }
101
+
102
+ // `_invalidated` gate matches Java/Python: after invalidateAll has run,
103
+ // the map is empty and further mark-complete callbacks for in-flight
104
+ // batches are expected to find nothing — don't escalate that to fatal.
105
+ if (missingIds.length > 0 && !this._invalidated) {
106
+ const errMsg = `Future ids [${missingIds.join(", ")}] not found in pending futures`;
107
+ this._invalidateLocally(
108
+ new StreamingIngestError("Fatal", errMsg, 500, "Internal Server Error"),
109
+ );
110
+ }
111
+ }
112
+
113
+ _invalidateAll(payload) {
114
+ this._invalidated = true;
115
+ const error =
116
+ buildError(
117
+ payload.success,
118
+ payload.errorCode,
119
+ payload.message,
120
+ payload.httpStatusCode,
121
+ payload.httpStatusName,
122
+ ) || new StreamingIngestError("Fatal", "Elastic channel invalidated", 409, "Conflict");
123
+ for (const pending of this._pendingFutures.values()) {
124
+ pending.reject(error);
125
+ }
126
+ this._pendingFutures.clear();
127
+ }
128
+
129
+ /**
130
+ * JS-driven invalidation when we detect state Rust isn't aware of yet
131
+ * (odd-length range, missing future id, duplicate future id). Idempotent
132
+ * via `_invalidated`. Promises are rejected first so the user observation
133
+ * doesn't depend on the FFI call succeeding; the native invalidate is
134
+ * best-effort — Rust will fire `invalidateAll` on its next informer
135
+ * tick, which finds an empty map and is a no-op.
136
+ */
137
+ _invalidateLocally(error) {
138
+ if (this._invalidated) {
139
+ return;
140
+ }
141
+ this._invalidated = true;
142
+ for (const pending of this._pendingFutures.values()) {
143
+ pending.reject(error);
144
+ }
145
+ this._pendingFutures.clear();
146
+ if (this._nativeChannel) {
147
+ try {
148
+ this._nativeChannel.invalidateChannel(error.message);
149
+ } catch (invErr) {
150
+ // eslint-disable-next-line no-console
151
+ console.error("Elastic channel invalidate_channel FFI call failed:", invErr);
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ module.exports = { ElasticChannelAckState };
@@ -0,0 +1,62 @@
1
+ // Copyright 2026 Snowflake Inc.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ //
4
+ // Licensed under the Apache License, Version 2.0 (the "License");
5
+ // you may not use this file except in compliance with the License.
6
+ // You may obtain a copy of the License at
7
+ //
8
+ // http://www.apache.org/licenses/LICENSE-2.0
9
+ //
10
+ // Unless required by applicable law or agreed to in writing, software
11
+ // distributed under the License is distributed on an "AS IS" BASIS,
12
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ // See the License for the specific language governing permissions and
14
+ // limitations under the License.
15
+
16
+ "use strict";
17
+
18
+ // Shared row serialization for regular and elastic channels. Kept in one place
19
+ // because the NDJSON byte-count behavior is a cross-language gotcha (Rust appends
20
+ // a trailing newline unconditionally) and the two channel classes must not drift.
21
+
22
+ /**
23
+ * Convert Buffer/Uint8Array values in a row according to binaryInputFormat.
24
+ * For HEX, binary values become hex-encoded strings. For UTF-8, decoded as UTF-8.
25
+ * For BASE64 (the default), base64-encoded for JSON serialization. Only top-level
26
+ * values are converted; nested structures are left as-is.
27
+ * @param {Object} row - Row object
28
+ * @param {string} binaryInputFormat - One of "BASE64", "HEX", "UTF-8"
29
+ * @returns {Object} Row with binary values converted
30
+ */
31
+ function convertBytesInRow(row, binaryInputFormat) {
32
+ // Resolve the format-specific encoder once per row instead of branching per cell
33
+ // — appendRows is the hot path and rows are dominated by columns, not formats.
34
+ const encoding =
35
+ binaryInputFormat === "HEX" ? "hex" : binaryInputFormat === "UTF-8" ? "utf8" : "base64";
36
+ const result = {};
37
+ for (const [key, value] of Object.entries(row)) {
38
+ if (Buffer.isBuffer(value)) {
39
+ result[key] = value.toString(encoding);
40
+ } else if (value instanceof Uint8Array) {
41
+ result[key] = Buffer.from(value).toString(encoding);
42
+ } else {
43
+ result[key] = value;
44
+ }
45
+ }
46
+ return result;
47
+ }
48
+
49
+ /**
50
+ * Serialize rows to an NDJSON Buffer, converting binary column values per binaryInputFormat.
51
+ * @param {Object[]} rows - Array of row objects
52
+ * @param {string} binaryInputFormat - One of "BASE64", "HEX", "UTF-8"
53
+ * @returns {Buffer} UTF-8 NDJSON buffer (no trailing newline; Rust appends one per row)
54
+ */
55
+ function serializeRowsToNdjson(rows, binaryInputFormat) {
56
+ const ndjson = rows
57
+ .map((r) => JSON.stringify(convertBytesInRow(r, binaryInputFormat)))
58
+ .join("\n");
59
+ return Buffer.from(ndjson, "utf8");
60
+ }
61
+
62
+ module.exports = { convertBytesInRow, serializeRowsToNdjson };