Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ jobs:
- name: Run tests
run: pnpm test

- name: Discover SDK contract test lane
run: pnpm test:contract --listTests

- name: Build SDK
run: pnpm build

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
node_modules/
dist/
coverage/
reports/
.env
.env.*
!.env.example
Expand Down
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
coverage
dist
node_modules
openapi
src/generated
src/generated-runtime
examples/nextjs-notes-app/.next
examples/nextjs-notes-app/node_modules
*.tgz
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ npm install @volcano.dev/sdk
## Quick Start

```javascript
import { VolcanoAuth } from '@volcano.dev/sdk';
import { VolcanoClient } from '@volcano.dev/sdk';

const volcano = new VolcanoAuth({
const volcano = new VolcanoClient({
apiUrl: 'https://api.yourproject.volcano.dev',
anonKey: 'your-anon-key',
});
Expand Down Expand Up @@ -62,6 +62,9 @@ channel.onPostgresChanges('INSERT', 'public', 'posts', (change) => {
await channel.subscribe();
```

`VolcanoClient` is the preferred name for new code. `VolcanoAuth` remains a
compatible alias, so existing applications do not need to change imports.

For browser realtime connections, make sure the browser app's origin is allowed in your project's auth CORS settings. The anonymous key is used to identify the project before the WebSocket upgrade completes.

## Documentation
Expand Down
183 changes: 183 additions & 0 deletions __tests__/contract/sdk-contract.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
const { readFileSync } = require('node:fs');
const path = require('node:path');

const { autoBindSteps, loadFeatures } = require('jest-cucumber');

const { ContractWorld, recordOutcome } = require('./world.js');

function absoluteEnvironmentPath(name) {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required`);
}
if (!path.isAbsolute(value)) {
throw new Error(`${name} must be an absolute path`);
}
return value;
}

const featuresPath = absoluteEnvironmentPath('VOLCANO_SDK_CONTRACT_FEATURES');
const fixturePath = absoluteEnvironmentPath('VOLCANO_SDK_CONTRACT_FIXTURE');
const fixture = JSON.parse(readFileSync(fixturePath, 'utf8'));
const features = loadFeatures(path.join(featuresPath, '*.feature'));

let activeWorld;

function startScenario(context) {
activeWorld = new ContractWorld(fixture);
context.world = activeWorld;
return activeWorld;
}

async function authenticate(world) {
await world.authenticate();
world.client.database(world.fixture.database_name);
}

afterEach(async () => {
const world = activeWorld;
activeWorld = undefined;
await world?.cleanup();
});

autoBindSteps(features, [
({ given, when, then, context }) => {
given('the confirmed contract user', () => {
startScenario(context);
});

when("the client signs in with the contract user's credentials", async () => {
const result = await context.world.client.auth.signIn({
email: context.world.fixture.user_email,
password: context.world.fixture.user_password,
});
recordOutcome(context.world, result, result.error);
});

then('the SDK operation succeeds', () => {
expect(context.world.lastOutcome).toMatchObject({ ok: true });
});

then('the current session belongs to the contract user', () => {
expect(context.world.lastOutcome.value.user.id).toBe(context.world.fixture.user_id);
expect(context.world.client.currentUser.id).toBe(context.world.fixture.user_id);
});

then('the current session exposes access and refresh tokens', () => {
const session = context.world.lastOutcome.value.session;
expect(session.access_token).toEqual(expect.any(String));
expect(session.access_token).not.toHaveLength(0);
expect(session.refresh_token).toEqual(expect.any(String));
expect(session.refresh_token).not.toHaveLength(0);
expect(context.world.client.accessToken).toBe(session.access_token);
expect(context.world.client.refreshToken).toBe(session.refresh_token);
});

given('an authenticated client', async () => {
const world = startScenario(context);
await authenticate(world);
});

when('the client selects the contract table where "slug" equals the fixture slug', async () => {
const result = await context.world.client
.from(context.world.fixture.table_name)
.select('*')
.eq('slug', context.world.fixture.fixture_row.slug);
recordOutcome(context.world, result.data, result.error);
});

then('exactly the fixture row is returned', () => {
expect(context.world.lastOutcome.value).toEqual([context.world.fixture.fixture_row]);
});

when('the client uploads and downloads the contract object', async () => {
const bucket = context.world.client.storage.from(context.world.fixture.bucket_name);
const upload = await bucket.upload(
context.world.storagePath,
new Blob([context.world.storageBytes], { type: 'application/octet-stream' }),
);
if (upload.error) {
recordOutcome(context.world, null, upload.error);
return;
}
context.world.cleanupCallbacks.push(async () => {
const removed = await bucket.remove([context.world.storagePath]);
if (removed.error) {
throw removed.error;
}
});
const download = await bucket.download(context.world.storagePath);
if (download.error) {
recordOutcome(context.world, null, download.error);
return;
}
const bytes = Buffer.from(await download.data.arrayBuffer());
recordOutcome(context.world, { bytes, path: upload.data.name }, null);
});

then('the downloaded bytes equal the uploaded bytes', () => {
expect(context.world.lastOutcome.value.bytes).toEqual(context.world.storageBytes);
});

then('the stored object path equals the contract path', () => {
expect(context.world.lastOutcome.value.path).toBe(context.world.storagePath);
});

given('a service-role client', () => {
startScenario(context);
});

when('the client acquires and releases the contract lock', async () => {
const acquired = await context.world.serviceClient.locks.acquire(context.world.lockKey, {
ttl: 10,
});
if (acquired.error || !acquired.acquired) {
recordOutcome(context.world, null, acquired.error || new Error('Lock was not acquired'));
return;
}
const cleanup = context.world.registerLockCleanup(context.world.lockKey, acquired.lease);
const released = await context.world.serviceClient.locks.release(
context.world.lockKey,
acquired.lease,
);
if (released.error) {
recordOutcome(context.world, null, released.error);
return;
}
context.world.cleanupCallbacks = context.world.cleanupCallbacks.filter(
(callback) => callback !== cleanup,
);
const state = await context.world.serviceClient.locks.get(context.world.lockKey);
recordOutcome(context.world, { lease: acquired.lease, state: state.state }, state.error);
});

then('the released lease is no longer held', () => {
expect(context.world.lastOutcome.value.state.held).toBe(false);
});

given('two authenticated realtime clients', async () => {
const world = startScenario(context);
await world.createRealtimeClients();
});

when('one client subscribes and the other publishes the contract message', async () => {
let timeout;
const received = new Promise((resolve, reject) => {
timeout = setTimeout(() => reject(new Error('Realtime message timed out')), 10000);
context.world.subscriber.on('message', resolve);
});
try {
await context.world.publisher.send(context.world.realtimeMessage);
recordOutcome(context.world, await received, null);
} catch (error) {
recordOutcome(context.world, null, error);
} finally {
clearTimeout(timeout);
}
});

then('the subscriber receives the contract message within 10 seconds', () => {
expect(context.world.lastOutcome.value).toEqual(context.world.realtimeMessage);
});
},
]);
120 changes: 120 additions & 0 deletions __tests__/contract/world.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
const { randomBytes } = require('node:crypto');

const { VolcanoClient } = require('../../src/index.js');
const { VolcanoRealtime } = require('../../src/realtime.js');

function classifyError(error) {
const status = error?.status ?? error?.response?.status;
if (status === 401 || status === 403) {
return 'authentication error';
}
if (status === 400 || status === 422) {
return 'validation error';
}
if (status === 404) {
return 'not found';
}
if (status === 409) {
return 'conflict';
}
if (status === 429) {
return 'rate limited';
}
if (status >= 500 && status <= 599) {
return 'server error';
}
return 'transport error';
}

function recordOutcome(world, data, error) {
world.lastOutcome = error
? { ok: false, category: classifyError(error) }
: { ok: true, value: data };
return world.lastOutcome;
}

class ContractWorld {
constructor(fixture) {
this.fixture = fixture;
this.lastOutcome = null;
this.client = new VolcanoClient({
apiUrl: fixture.api_url,
anonKey: fixture.anon_key,
});
this.serviceClient = new VolcanoClient({
apiUrl: fixture.api_url,
anonKey: fixture.service_key,
accessToken: fixture.service_key,
});
this.realtimeClients = [];
this.cleanupCallbacks = [];

const suffix = `js-${process.pid}-${randomBytes(5).toString('hex')}`;
this.storagePath = `${fixture.storage_path}.${suffix}`;
this.realtimeChannel = `${fixture.realtime_channel}-${suffix}`;
this.lockKey = `${fixture.lock_key}-${suffix}`;
this.storageBytes = Buffer.from(`volcano-sdk-contract-${suffix}`, 'utf8');
this.realtimeMessage = { event: 'message', value: `volcano-sdk-contract-${suffix}` };
}

async authenticate() {
const result = await this.client.auth.signIn({
email: this.fixture.user_email,
password: this.fixture.user_password,
});
if (result.error) {
throw result.error;
}
return result;
}

async createRealtimeClients() {
const signedIn = await this.authenticate();
const config = {
apiUrl: this.fixture.api_url,
anonKey: this.fixture.anon_key,
accessToken: signedIn.session.access_token,
};
this.realtimeClients = [new VolcanoRealtime(config), new VolcanoRealtime(config)];
await Promise.all(this.realtimeClients.map((client) => client.connect()));
this.subscriber = this.realtimeClients[0].channel(this.realtimeChannel);
this.publisher = this.realtimeClients[1].channel(this.realtimeChannel);
await Promise.all([this.subscriber.subscribe(), this.publisher.subscribe()]);
}

registerLockCleanup(key, lease) {
const cleanup = async () => {
const released = await this.serviceClient.locks.release(key, lease);
if (released.error) {
throw released.error;
}
};
this.cleanupCallbacks.push(cleanup);
return cleanup;
}

async cleanup() {
const failures = [];
for (const callback of this.cleanupCallbacks.slice().reverse()) {
try {
await callback();
} catch (error) {
failures.push(error);
}
}
for (const client of this.realtimeClients) {
try {
client.disconnect();
} catch (error) {
failures.push(error);
}
}
this.cleanupCallbacks = [];
this.realtimeClients = [];
if (failures.length > 0) {
throw new AggregateError(failures, 'JavaScript contract cleanup failed');
}
}
}

module.exports = { classifyError, ContractWorld, recordOutcome };
24 changes: 24 additions & 0 deletions __tests__/contract/world.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const { ContractWorld } = require('./world.js');

describe('contract world cleanup', () => {
test('reports lock release error envelopes as cleanup failures', async () => {
const world = new ContractWorld({
api_url: 'https://api.test.com',
anon_key: 'ak-contract',
service_key: 'sk-contract',
});
const releaseError = Object.assign(new Error('lock release failed'), { status: 503 });
world.serviceClient.locks.release = async () => ({ error: releaseError });
world.registerLockCleanup('contract-lock', {
key: 'contract-lock',
token: '00000000-0000-4000-8000-000000000001',
expiresAt: '2026-08-26T12:00:10Z',
fencingToken: 1,
});

await expect(world.cleanup()).rejects.toMatchObject({
errors: [releaseError],
message: 'JavaScript contract cleanup failed',
});
});
});
Loading
Loading