snowpipe-streaming 1.4.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/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # Snowflake Streaming Ingest Node.js SDK
2
+
3
+ A high-performance Node.js SDK for streaming data ingestion into Snowflake, built with Rust for optimal performance and reliability.
4
+
5
+ - [Overview](#overview)
6
+ - [Supported Platforms](#supported-platforms)
7
+ - [Installation](#installation)
8
+ - [Quick Start](#quick-start)
9
+ - [Features](#features)
10
+ - [Architecture](#architecture)
11
+ - [Dependencies](#dependencies)
12
+ - [Support](#support)
13
+ - [License](#license)
14
+
15
+ ## Overview
16
+
17
+ The Snowflake Streaming Ingest Node.js SDK provides a Node.js interface for real-time data streaming into Snowflake tables. It leverages a Rust core for high performance while providing a familiar JavaScript API for easy integration.
18
+
19
+ ## Supported Platforms
20
+
21
+ | OS | Architecture | C Library | Notes |
22
+ |----|-------------|-----------|-------|
23
+ | Linux | x64 | glibc (>= 2.26) | |
24
+ | Linux | x64 | musl | Alpine Linux, etc. |
25
+ | Linux | arm64 | glibc (>= 2.26) | |
26
+ | Linux | arm64 | musl | Alpine Linux, etc. |
27
+ | macOS | arm64 | — | Apple Silicon, macOS 11.0+ |
28
+ | Windows | x64 | MSVC | Windows 10+ |
29
+
30
+ ### Node.js
31
+ - **Node.js 20** or later
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ npm install snowpipe-streaming
37
+ ```
38
+
39
+ The correct native binary for your platform is installed automatically via `optionalDependencies`.
40
+
41
+ ## Quick Start
42
+
43
+ ```javascript
44
+ const { createClient } = require("snowpipe-streaming");
45
+
46
+ // Create a client
47
+ const client = await createClient({
48
+ clientName: "my-client",
49
+ dbName: "MY_DB",
50
+ schemaName: "MY_SCHEMA",
51
+ pipeName: "MY_PIPE",
52
+ properties: {
53
+ account: "your_account",
54
+ user: "your_user",
55
+ private_key: "-----BEGIN PRIVATE KEY-----\n...",
56
+ },
57
+ });
58
+
59
+ // Open a channel
60
+ const { channel } = await client.openChannel({ name: "my_channel" });
61
+
62
+ // Insert data
63
+ await channel.insertRows([
64
+ { id: 1, name: "John Doe", timestamp: "2024-01-01T00:00:00Z" },
65
+ { id: 2, name: "Jane Smith", timestamp: "2024-01-02T00:00:00Z" },
66
+ ]);
67
+
68
+ // Wait for data to be committed to Snowflake
69
+ await channel.waitForCommit();
70
+
71
+ // Close resources
72
+ await channel.close();
73
+ await client.close();
74
+ ```
75
+
76
+ ## Features
77
+
78
+ - **High Performance**: Rust-based core for optimal throughput and low latency
79
+ - **Memory Efficient**: Minimal memory footprint with efficient data handling
80
+ - **Automatic Retries**: Built-in retry logic for transient failures
81
+ - **Backpressure Handling**: Intelligent backpressure management
82
+ - **Comprehensive Logging**: Detailed logging for debugging and monitoring
83
+ - **Cross-Platform**: Native support for Linux, macOS, and Windows
84
+ - **Zero Dependencies**: No JavaScript runtime dependencies
85
+
86
+ ## Architecture
87
+
88
+ The SDK uses a hybrid Rust-Node.js architecture:
89
+ - **Rust Core**: High-performance data processing, networking, and Snowflake communication
90
+ - **Node.js Bindings**: JavaScript API using napi-rs for seamless integration
91
+ - **Platform Packages**: Architecture-specific native binaries distributed as npm optional dependencies
92
+
93
+ ## Dependencies
94
+
95
+ - **None**: The SDK has zero JavaScript runtime dependencies
96
+ - **Native Extensions**: Rust-based binary modules (automatically installed for your platform)
97
+
98
+ ## Support
99
+
100
+ - **Documentation**: [Snowpipe Streaming Overview](https://docs.snowflake.com/en/user-guide/snowpipe-streaming/data-load-snowpipe-streaming-overview)
101
+
102
+ ## License
103
+
104
+ This project is licensed under the Apache License 2.0.
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "snowpipe-streaming",
3
+ "version": "1.4.0",
4
+ "description": "Snowflake Streaming Ingest SDK for Node.js",
5
+ "main": "src/index.js",
6
+ "types": "src/index.d.ts",
7
+ "files": [
8
+ "src/"
9
+ ],
10
+ "keywords": [
11
+ "snowflake",
12
+ "snowpipe",
13
+ "streaming",
14
+ "ingest"
15
+ ],
16
+ "author": {
17
+ "name": "Snowflake Computing, Inc.",
18
+ "email": "support@snowflake.com",
19
+ "url": "https://www.snowflake.com/"
20
+ },
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "license": "Apache-2.0",
25
+ "devDependencies": {
26
+ "@types/node": "^25.6.0",
27
+ "typedoc": "^0.28.19"
28
+ },
29
+ "optionalDependencies": {
30
+ "snowpipe-streaming-linux-x64-gnu": "1.4.0",
31
+ "snowpipe-streaming-linux-x64-musl": "1.4.0",
32
+ "snowpipe-streaming-linux-arm64-gnu": "1.4.0",
33
+ "snowpipe-streaming-linux-arm64-musl": "1.4.0",
34
+ "snowpipe-streaming-darwin-arm64": "1.4.0",
35
+ "snowpipe-streaming-win32-x64-msvc": "1.4.0"
36
+ }
37
+ }
package/src/channel.js ADDED
@@ -0,0 +1,241 @@
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
+
21
+ const WAIT_FOR_COMMIT_CHECK_INTERVAL_MS = 1000;
22
+
23
+ /**
24
+ * Streaming ingest channel for ingesting data into a Snowflake table.
25
+ *
26
+ * Channels are created via {@link StreamingIngestClient#openChannel},
27
+ * not directly.
28
+ */
29
+ class StreamingIngestChannel {
30
+ /** @param {object} nativeChannel - Internal JsChannel from FFI */
31
+ constructor(nativeChannel) {
32
+ if (!(nativeChannel && typeof nativeChannel === "object")) {
33
+ throw new TypeError(
34
+ "StreamingIngestChannel cannot be instantiated directly. " +
35
+ "Use client.openChannel() instead.",
36
+ );
37
+ }
38
+ this._native = nativeChannel;
39
+ }
40
+
41
+ /** @returns {string} The channel name */
42
+ get channelName() {
43
+ return this._native.channelName;
44
+ }
45
+
46
+ /** @returns {string} The database name */
47
+ get dbName() {
48
+ return this._native.dbName;
49
+ }
50
+
51
+ /** @returns {string} The schema name */
52
+ get schemaName() {
53
+ return this._native.schemaName;
54
+ }
55
+
56
+ /** @returns {string} The pipe name */
57
+ get pipeName() {
58
+ return this._native.pipeName;
59
+ }
60
+
61
+ /** @returns {boolean} True if the channel is closed */
62
+ get isClosed() {
63
+ return this._native.isClosed;
64
+ }
65
+
66
+ /**
67
+ * Get the binary input format for this channel.
68
+ * @returns {string} One of: "BASE64", "HEX", "UTF-8"
69
+ */
70
+ get binaryInputFormat() {
71
+ return this._native.binaryInputFormat;
72
+ }
73
+
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
+ /**
104
+ * Append a single row. Convenience wrapper for appendRows.
105
+ * @param {Object} row - Row data as key-value pairs
106
+ * @param {string} [offsetToken] - Offset token for this row
107
+ * @returns {Promise<void>}
108
+ */
109
+ async appendRow(row, offsetToken) {
110
+ await this.appendRows([row], offsetToken, offsetToken);
111
+ }
112
+
113
+ /**
114
+ * Append multiple rows to the channel.
115
+ * Rows are serialized to NDJSON (newline-delimited JSON) on the JS side.
116
+ * @param {Object[]} rows - Array of row objects
117
+ * @param {string} [startOffsetToken] - Start offset token for this batch
118
+ * @param {string} [endOffsetToken] - End offset token for this batch
119
+ * @returns {Promise<void>}
120
+ */
121
+ async appendRows(rows, startOffsetToken, endOffsetToken) {
122
+ if (!Array.isArray(rows)) {
123
+ throw new TypeError("rows must be an array");
124
+ }
125
+ // Empty arrays are handled by the Rust core, which returns a proper InvalidRequest error
126
+ if (rows.length === 0) {
127
+ // Pass empty buffer to Rust core to get proper error message
128
+ return callFFI(() =>
129
+ this._native.appendRows(Buffer.alloc(0), 0, startOffsetToken, endOffsetToken),
130
+ );
131
+ }
132
+ // Convert binary values according to binaryInputFormat before JSON serialization
133
+ const ndjson = rows.map((r) => JSON.stringify(this._convertBytesInRow(r))).join("\n");
134
+ const buffer = Buffer.from(ndjson, "utf8");
135
+ return callFFI(() =>
136
+ this._native.appendRows(buffer, rows.length, startOffsetToken, endOffsetToken),
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Close the channel. Idempotent — safe to call multiple times.
142
+ * @param {{drop?: boolean, waitForFlush?: boolean, timeoutMs?: number}} [options]
143
+ * @returns {Promise<void>}
144
+ */
145
+ async close(options) {
146
+ const nativeOpts = options
147
+ ? { drop: options.drop, waitForFlush: options.waitForFlush, timeoutMs: options.timeoutMs }
148
+ : undefined;
149
+ return callFFI(() => this._native.close(nativeOpts));
150
+ }
151
+
152
+ /**
153
+ * Trigger a flush of this channel (non-blocking).
154
+ * @throws {StreamingIngestError} If the flush cannot be initiated
155
+ */
156
+ initiateFlush() {
157
+ try {
158
+ this._native.initiateFlush();
159
+ } catch (e) {
160
+ throw StreamingIngestError.from(e);
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Wait for this channel to flush.
166
+ * @param {{timeoutMs?: number}} [options]
167
+ * @returns {Promise<void>}
168
+ */
169
+ async waitForFlush(options) {
170
+ return callFFI(() => this._native.waitForFlush(options?.timeoutMs));
171
+ }
172
+
173
+ /**
174
+ * Get the current status of this channel.
175
+ * @returns {Promise<ChannelStatus>}
176
+ */
177
+ async getChannelStatus() {
178
+ const raw = await callFFI(() => this._native.getChannelStatus());
179
+ return new ChannelStatus(raw);
180
+ }
181
+
182
+ /**
183
+ * Get the latest committed offset token.
184
+ * @returns {Promise<string|null>}
185
+ */
186
+ async getLatestCommittedOffsetToken() {
187
+ const status = await this.getChannelStatus();
188
+ return status.latestCommittedOffsetToken;
189
+ }
190
+
191
+ /**
192
+ * Wait for a specific offset token to be committed.
193
+ * Polls getChannelStatus() every 1 second until tokenChecker returns true.
194
+ * @param {function(string|null): boolean} tokenChecker - Returns true when the desired token is committed
195
+ * @param {{timeoutMs?: number}} [options]
196
+ * @returns {Promise<void>}
197
+ */
198
+ async waitForCommit(tokenChecker, options) {
199
+ if (typeof tokenChecker !== "function") {
200
+ throw new TypeError("tokenChecker must be a function");
201
+ }
202
+ const timeoutMs = options?.timeoutMs;
203
+ if (timeoutMs !== undefined && timeoutMs < 0) {
204
+ throw new RangeError("timeoutMs cannot be negative");
205
+ }
206
+
207
+ const startTime = Date.now();
208
+
209
+ while (true) {
210
+ const status = await this.getChannelStatus();
211
+
212
+ if (tokenChecker(status.latestCommittedOffsetToken)) {
213
+ return;
214
+ }
215
+
216
+ if (status.statusCode.toUpperCase() !== "SUCCESS") {
217
+ throw new StreamingIngestError(
218
+ "InvalidChannelError",
219
+ `Channel is invalid in the remote with status: ${status.statusCode}`,
220
+ 409,
221
+ "Conflict",
222
+ );
223
+ }
224
+
225
+ if (timeoutMs !== undefined && Date.now() - startTime >= timeoutMs) {
226
+ throw new Error("Wait for commit timed out");
227
+ }
228
+
229
+ await new Promise((resolve) => setTimeout(resolve, WAIT_FOR_COMMIT_CHECK_INTERVAL_MS));
230
+ }
231
+ }
232
+
233
+ /** Support `await using channel = ...` (Node 20+) */
234
+ async [Symbol.asyncDispose]() {
235
+ if (!this.isClosed) {
236
+ await this.close();
237
+ }
238
+ }
239
+ }
240
+
241
+ module.exports = { StreamingIngestChannel };
@@ -0,0 +1,179 @@
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
+ /**
19
+ * Channel status information from Snowflake server.
20
+ *
21
+ * This class wraps the raw status data returned from the Rust FFI layer
22
+ * and provides a clean JavaScript API with proper getters.
23
+ */
24
+ class ChannelStatus {
25
+ /**
26
+ * Create a ChannelStatus from raw FFI data.
27
+ * @param {Object} data - Raw status data from FFI (NodejsChannelStatus struct)
28
+ */
29
+ constructor(data) {
30
+ if (!data || typeof data !== "object") {
31
+ throw new TypeError("ChannelStatus requires a data object");
32
+ }
33
+ this._databaseName = data.databaseName;
34
+ this._schemaName = data.schemaName;
35
+ this._pipeName = data.pipeName;
36
+ this._channelName = data.channelName;
37
+ this._statusCode = data.statusCode;
38
+ this._latestCommittedOffsetToken = data.latestCommittedOffsetToken;
39
+ this._createdOnMs = data.createdOnMs;
40
+ this._rowsInserted = data.rowsInserted;
41
+ this._rowsParsed = data.rowsParsed;
42
+ this._rowsErrorCount = data.rowsErrorCount;
43
+ this._lastErrorOffsetUpperBound = data.lastErrorOffsetUpperBound;
44
+ this._lastErrorMessage = data.lastErrorMessage;
45
+ this._lastErrorTimestampMs = data.lastErrorTimestampMs;
46
+ this._snowflakeAvgProcessingLatencyMs = data.snowflakeAvgProcessingLatencyMs;
47
+ this._lastRefreshedOnMs = data.lastRefreshedOnMs;
48
+ }
49
+
50
+ /**
51
+ * Get the database name.
52
+ * @returns {string}
53
+ */
54
+ get databaseName() {
55
+ return this._databaseName;
56
+ }
57
+
58
+ /**
59
+ * Get the schema name.
60
+ * @returns {string}
61
+ */
62
+ get schemaName() {
63
+ return this._schemaName;
64
+ }
65
+
66
+ /**
67
+ * Get the pipe name.
68
+ * @returns {string}
69
+ */
70
+ get pipeName() {
71
+ return this._pipeName;
72
+ }
73
+
74
+ /**
75
+ * Get the channel name.
76
+ * @returns {string}
77
+ */
78
+ get channelName() {
79
+ return this._channelName;
80
+ }
81
+
82
+ /**
83
+ * Get the status code from Snowflake server.
84
+ * @returns {string}
85
+ */
86
+ get statusCode() {
87
+ return this._statusCode;
88
+ }
89
+
90
+ /**
91
+ * Get the latest committed offset token.
92
+ * @returns {string|null}
93
+ */
94
+ get latestCommittedOffsetToken() {
95
+ return this._latestCommittedOffsetToken;
96
+ }
97
+
98
+ /**
99
+ * Get the created on timestamp as a Date.
100
+ * @returns {Date}
101
+ */
102
+ get createdOn() {
103
+ return this._createdOnMs != null ? new Date(this._createdOnMs) : null;
104
+ }
105
+
106
+ /**
107
+ * Get the number of rows inserted.
108
+ * @returns {number}
109
+ */
110
+ get rowsInsertedCount() {
111
+ return this._rowsInserted;
112
+ }
113
+
114
+ /**
115
+ * Get the number of rows parsed.
116
+ * @returns {number}
117
+ */
118
+ get rowsParsedCount() {
119
+ return this._rowsParsed;
120
+ }
121
+
122
+ /**
123
+ * Get the number of rows with errors.
124
+ * @returns {number}
125
+ */
126
+ get rowsErrorCount() {
127
+ return this._rowsErrorCount;
128
+ }
129
+
130
+ /**
131
+ * Get the last error offset token upper bound.
132
+ * @returns {string|null}
133
+ */
134
+ get lastErrorOffsetTokenUpperBound() {
135
+ return this._lastErrorOffsetUpperBound;
136
+ }
137
+
138
+ /**
139
+ * Get the last error message.
140
+ * @returns {string|null}
141
+ */
142
+ get lastErrorMessage() {
143
+ return this._lastErrorMessage;
144
+ }
145
+
146
+ /**
147
+ * Get the last error timestamp as a Date.
148
+ * @returns {Date|null}
149
+ */
150
+ get lastErrorTimestamp() {
151
+ return this._lastErrorTimestampMs != null ? new Date(this._lastErrorTimestampMs) : null;
152
+ }
153
+
154
+ /**
155
+ * Get the Snowflake average processing latency in milliseconds.
156
+ * @returns {number|null}
157
+ */
158
+ get serverAvgProcessingLatencyMs() {
159
+ return this._snowflakeAvgProcessingLatencyMs;
160
+ }
161
+
162
+ /**
163
+ * Get the last refreshed on timestamp as a Date.
164
+ * @returns {Date}
165
+ */
166
+ get lastRefreshedOn() {
167
+ return this._lastRefreshedOnMs != null ? new Date(this._lastRefreshedOnMs) : null;
168
+ }
169
+
170
+ /**
171
+ * Returns a string representation of the channel status.
172
+ * @returns {string}
173
+ */
174
+ toString() {
175
+ return `Channel '${this._channelName}': ${this._statusCode}`;
176
+ }
177
+ }
178
+
179
+ module.exports = { ChannelStatus };