diff --git a/src/SDK/Language/Android.php b/src/SDK/Language/Android.php index 19a22f01a..64242bd12 100644 --- a/src/SDK/Language/Android.php +++ b/src/SDK/Language/Android.php @@ -120,6 +120,11 @@ public function getFiles(): array 'destination' => '/library/src/main/java/{{ sdk.namespace | caseSlash }}/ID.kt', 'template' => '/android/library/src/main/java/io/package/ID.kt.twig', ], + [ + 'scope' => 'default', + 'destination' => '/library/src/main/java/{{ sdk.namespace | caseSlash }}/Channel.kt', + 'template' => '/android/library/src/main/java/io/package/Channel.kt.twig', + ], [ 'scope' => 'default', 'destination' => '/library/src/main/java/{{ sdk.namespace | caseSlash }}/Query.kt', diff --git a/src/SDK/Language/Apple.php b/src/SDK/Language/Apple.php index 4313f9d34..08f45a604 100644 --- a/src/SDK/Language/Apple.php +++ b/src/SDK/Language/Apple.php @@ -70,6 +70,11 @@ public function getFiles(): array 'destination' => '/Sources/{{ spec.title | caseUcfirst}}/ID.swift', 'template' => 'swift/Sources/ID.swift.twig', ], + [ + 'scope' => 'default', + 'destination' => '/Sources/{{ spec.title | caseUcfirst}}/Channel.swift', + 'template' => 'apple/Sources/Channel.swift.twig', + ], [ 'scope' => 'default', 'destination' => '/Sources/{{ spec.title | caseUcfirst}}/Query.swift', diff --git a/src/SDK/Language/Flutter.php b/src/SDK/Language/Flutter.php index ab0c5ec3a..cfb6933cc 100644 --- a/src/SDK/Language/Flutter.php +++ b/src/SDK/Language/Flutter.php @@ -80,6 +80,11 @@ public function getFiles(): array 'destination' => '/lib/id.dart', 'template' => 'dart/lib/id.dart.twig', ], + [ + 'scope' => 'default', + 'destination' => '/lib/channel.dart', + 'template' => 'flutter/lib/channel.dart.twig', + ], [ 'scope' => 'default', 'destination' => '/lib/query.dart', @@ -290,6 +295,11 @@ public function getFiles(): array 'destination' => '/test/role_test.dart', 'template' => 'dart/test/role_test.dart.twig', ], + [ + 'scope' => 'default', + 'destination' => '/test/channel_test.dart', + 'template' => 'flutter/test/src/channel_test.dart.twig', + ], [ 'scope' => 'default', 'destination' => '/test/src/cookie_manager_test.dart', diff --git a/src/SDK/Language/ReactNative.php b/src/SDK/Language/ReactNative.php index 81748709c..f0ccd54c2 100644 --- a/src/SDK/Language/ReactNative.php +++ b/src/SDK/Language/ReactNative.php @@ -60,6 +60,11 @@ public function getFiles(): array 'destination' => 'src/id.ts', 'template' => 'react-native/src/id.ts.twig', ], + [ + 'scope' => 'default', + 'destination' => 'src/channel.ts', + 'template' => 'react-native/src/channel.ts.twig', + ], [ 'scope' => 'default', 'destination' => 'src/query.ts', diff --git a/src/SDK/Language/Web.php b/src/SDK/Language/Web.php index 2490f833f..4a94da3a9 100644 --- a/src/SDK/Language/Web.php +++ b/src/SDK/Language/Web.php @@ -80,6 +80,11 @@ public function getFiles(): array 'destination' => 'src/id.ts', 'template' => 'web/src/id.ts.twig', ], + [ + 'scope' => 'default', + 'destination' => 'src/channel.ts', + 'template' => 'web/src/channel.ts.twig', + ], [ 'scope' => 'default', 'destination' => 'src/query.ts', diff --git a/templates/android/library/src/main/java/io/package/Channel.kt.twig b/templates/android/library/src/main/java/io/package/Channel.kt.twig new file mode 100644 index 000000000..4fe27e432 --- /dev/null +++ b/templates/android/library/src/main/java/io/package/Channel.kt.twig @@ -0,0 +1,190 @@ +package {{ sdk.namespace | caseDot }} + +class ResolvedChannel(private val value: String) { + override fun toString(): String { + return value + } +} + +abstract class ActionChannel(protected val base: String) { + fun create(): ResolvedChannel { + return ResolvedChannel("$base.create") + } + + fun update(): ResolvedChannel { + return ResolvedChannel("$base.update") + } + + fun delete(): ResolvedChannel { + return ResolvedChannel("$base.delete") + } + + override fun toString(): String { + return base + } +} + +class DocumentChannel(base: String) : ActionChannel(base) + +class CollectionChannel( + private val databaseId: String, + private val collectionId: String +) { + private val base = "databases.$databaseId.collections.$collectionId" + + fun document(documentId: String = "*"): DocumentChannel { + return DocumentChannel("$base.documents.$documentId") + } + + override fun toString(): String { + return base + } +} + +class DatabaseChannel(private val databaseId: String) { + private val base = "databases.$databaseId" + + fun collection(collectionId: String = "*"): CollectionChannel { + return CollectionChannel(databaseId, collectionId) + } + + override fun toString(): String { + return base + } +} + +class RowChannel(base: String) : ActionChannel(base) + +class TableChannel( + private val databaseId: String, + private val tableId: String +) { + private val base = "tablesdb.$databaseId.tables.$tableId" + + fun row(rowId: String = "*"): RowChannel { + return RowChannel("$base.rows.$rowId") + } + + override fun toString(): String { + return base + } +} + +class TablesDBChannel(private val databaseId: String) { + private val base = "tablesdb.$databaseId" + + fun table(tableId: String = "*"): TableChannel { + return TableChannel(databaseId, tableId) + } + + override fun toString(): String { + return base + } +} + +class FileChannel(base: String) : ActionChannel(base) + +class BucketChannel(private val bucketId: String) { + private val base = "buckets.$bucketId" + + fun file(fileId: String = "*"): FileChannel { + return FileChannel("$base.files.$fileId") + } + + override fun toString(): String { + return base + } +} + +class ExecutionChannel(base: String) : ActionChannel(base) + +class FunctionChannel(private val functionId: String) { + private val base = "functions.$functionId" + + fun execution(executionId: String = "*"): ExecutionChannel { + return ExecutionChannel("$base.executions.$executionId") + } + + override fun toString(): String { + return base + } +} + +class TeamChannel(teamId: String = "*") : ActionChannel("teams.$teamId") + +class MembershipChannel(membershipId: String = "*") : ActionChannel("memberships.$membershipId") + +class Channel { + companion object { + /** + * Generate a database channel builder. + * + * @param databaseId The database ID (default: "*") + * @returns DatabaseChannel + */ + fun database(databaseId: String = "*"): DatabaseChannel { + return DatabaseChannel(databaseId) + } + + /** + * Generate a tables database channel builder. + * + * @param databaseId The database ID (default: "*") + * @returns TablesDBChannel + */ + fun tablesdb(databaseId: String = "*"): TablesDBChannel { + return TablesDBChannel(databaseId) + } + + /** + * Generate a buckets channel builder. + * + * @param bucketId The bucket ID (default: "*") + * @returns BucketChannel + */ + fun buckets(bucketId: String = "*"): BucketChannel { + return BucketChannel(bucketId) + } + + /** + * Generate a functions channel builder. + * + * @param functionId The function ID (default: "*") + * @returns FunctionChannel + */ + fun functions(functionId: String = "*"): FunctionChannel { + return FunctionChannel(functionId) + } + + /** + * Generate a teams channel builder. + * + * @param teamId The team ID (default: "*") + * @returns TeamChannel + */ + fun teams(teamId: String = "*"): TeamChannel { + return TeamChannel(teamId) + } + + /** + * Generate a memberships channel builder. + * + * @param membershipId The membership ID (default: "*") + * @returns MembershipChannel + */ + fun memberships(membershipId: String = "*"): MembershipChannel { + return MembershipChannel(membershipId) + } + + /** + * Generate an account channel string. + * + * @param userId The user ID (default: "*") + * @returns The channel string + */ + fun account(userId: String = "*"): String { + return "account.$userId" + } + } +} + diff --git a/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig b/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig index 26de08d0e..2e887dfe3 100644 --- a/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig +++ b/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig @@ -109,6 +109,25 @@ class Realtime(client: Client) : Service(client), CoroutineScope { else -> 60000L } + /** + * Convert channel value to string + */ + private fun channelToString(channel: Any): String { + return when (channel) { + is String -> channel + else -> channel.toString() + } + } + + fun subscribe( + vararg channels: Any, + callback: (RealtimeResponseEvent) -> Unit, + ) = subscribe( + channels = channels.map { channelToString(it) }.toTypedArray(), + Any::class.java, + callback + ) + fun subscribe( vararg channels: String, callback: (RealtimeResponseEvent) -> Unit, @@ -118,6 +137,18 @@ class Realtime(client: Client) : Service(client), CoroutineScope { callback ) + fun subscribe( + vararg channels: Any, + payloadType: Class, + callback: (RealtimeResponseEvent) -> Unit, + ): RealtimeSubscription { + return subscribe( + channels = channels.map { channelToString(it) }.toTypedArray(), + payloadType = payloadType, + callback = callback + ) + } + fun subscribe( vararg channels: String, payloadType: Class, diff --git a/templates/apple/Sources/Channel.swift.twig b/templates/apple/Sources/Channel.swift.twig new file mode 100644 index 000000000..599d9d595 --- /dev/null +++ b/templates/apple/Sources/Channel.swift.twig @@ -0,0 +1,261 @@ +import Foundation + +public class ResolvedChannel { + private let value: String + + init(value: String) { + self.value = value + } + + public func toString() -> String { + return value + } +} + +open class ActionChannel { + let base: String + + init(base: String) { + self.base = base + } + + public func create() -> ResolvedChannel { + return ResolvedChannel(value: "\(base).create") + } + + public func update() -> ResolvedChannel { + return ResolvedChannel(value: "\(base).update") + } + + public func delete() -> ResolvedChannel { + return ResolvedChannel(value: "\(base).delete") + } + + public func toString() -> String { + return base + } +} + +/** + * ---------------------------- + * Database → Collection → Document + * ---------------------------- + */ +public class DocumentChannel: ActionChannel {} + +public class CollectionChannel { + private let base: String + private let databaseId: String + private let collectionId: String + + init(databaseId: String, collectionId: String) { + self.databaseId = databaseId + self.collectionId = collectionId + self.base = "databases.\(databaseId).collections.\(collectionId)" + } + + public func document(_ documentId: String = "*") -> DocumentChannel { + return DocumentChannel(base: "\(base).documents.\(documentId)") + } + + public func toString() -> String { + return base + } +} + +public class DatabaseChannel { + private let base: String + private let databaseId: String + + init(databaseId: String) { + self.databaseId = databaseId + self.base = "databases.\(databaseId)" + } + + public func collection(_ collectionId: String = "*") -> CollectionChannel { + return CollectionChannel(databaseId: databaseId, collectionId: collectionId) + } + + public func toString() -> String { + return base + } +} + +/** + * ---------------------------- + * TablesDB → Table → Row + * ---------------------------- + */ +public class RowChannel: ActionChannel {} + +public class TableChannel { + private let base: String + private let databaseId: String + private let tableId: String + + init(databaseId: String, tableId: String) { + self.databaseId = databaseId + self.tableId = tableId + self.base = "tablesdb.\(databaseId).tables.\(tableId)" + } + + public func row(_ rowId: String = "*") -> RowChannel { + return RowChannel(base: "\(base).rows.\(rowId)") + } + + public func toString() -> String { + return base + } +} + +public class TablesDBChannel { + private let base: String + private let databaseId: String + + init(databaseId: String) { + self.databaseId = databaseId + self.base = "tablesdb.\(databaseId)" + } + + public func table(_ tableId: String = "*") -> TableChannel { + return TableChannel(databaseId: databaseId, tableId: tableId) + } + + public func toString() -> String { + return base + } +} + +/** + * ---------------------------- + * Buckets → File + * ---------------------------- + */ +public class FileChannel: ActionChannel {} + +public class BucketChannel { + private let base: String + private let bucketId: String + + init(bucketId: String) { + self.bucketId = bucketId + self.base = "buckets.\(bucketId)" + } + + public func file(_ fileId: String = "*") -> FileChannel { + return FileChannel(base: "\(base).files.\(fileId)") + } + + public func toString() -> String { + return base + } +} + +/** + * ---------------------------- + * Functions → Execution + * ---------------------------- + */ +public class ExecutionChannel: ActionChannel {} + +public class FunctionChannel { + private let base: String + private let functionId: String + + init(functionId: String) { + self.functionId = functionId + self.base = "functions.\(functionId)" + } + + public func execution(_ executionId: String = "*") -> ExecutionChannel { + return ExecutionChannel(base: "\(base).executions.\(executionId)") + } + + public func toString() -> String { + return base + } +} + +public class TeamChannel: ActionChannel { + init(teamId: String = "*") { + super.init(base: "teams.\(teamId)") + } +} + +public class MembershipChannel: ActionChannel { + init(membershipId: String = "*") { + super.init(base: "memberships.\(membershipId)") + } +} + +public class Channel { + /** + * Generate a database channel builder. + * + * @param databaseId The database ID (default: "*") + * @returns DatabaseChannel + */ + public static func database(_ databaseId: String = "*") -> DatabaseChannel { + return DatabaseChannel(databaseId: databaseId) + } + + /** + * Generate a tables database channel builder. + * + * @param databaseId The database ID (default: "*") + * @returns TablesDBChannel + */ + public static func tablesdb(_ databaseId: String = "*") -> TablesDBChannel { + return TablesDBChannel(databaseId: databaseId) + } + + /** + * Generate a buckets channel builder. + * + * @param bucketId The bucket ID (default: "*") + * @returns BucketChannel + */ + public static func buckets(_ bucketId: String = "*") -> BucketChannel { + return BucketChannel(bucketId: bucketId) + } + + /** + * Generate a functions channel builder. + * + * @param functionId The function ID (default: "*") + * @returns FunctionChannel + */ + public static func functions(_ functionId: String = "*") -> FunctionChannel { + return FunctionChannel(functionId: functionId) + } + + /** + * Generate a teams channel builder. + * + * @param teamId The team ID (default: "*") + * @returns TeamChannel + */ + public static func teams(_ teamId: String = "*") -> TeamChannel { + return TeamChannel(teamId: teamId) + } + + /** + * Generate a memberships channel builder. + * + * @param membershipId The membership ID (default: "*") + * @returns MembershipChannel + */ + public static func memberships(_ membershipId: String = "*") -> MembershipChannel { + return MembershipChannel(membershipId: membershipId) + } + + /** + * Generate an account channel string. + * + * @param userId The user ID (default: "*") + * @returns The channel string + */ + public static func account(_ userId: String = "*") -> String { + return "account.\(userId)" + } +} diff --git a/templates/apple/Sources/Services/Realtime.swift.twig b/templates/apple/Sources/Services/Realtime.swift.twig index 5c2c2c401..a66f00959 100644 --- a/templates/apple/Sources/Services/Realtime.swift.twig +++ b/templates/apple/Sources/Services/Realtime.swift.twig @@ -3,6 +3,33 @@ import AsyncHTTPClient import NIO import NIOHTTP1 +/** + * Protocol for channel values that can be converted to strings + */ +public protocol ChannelValue { + func toString() -> String +} + +extension String: ChannelValue { + public func toString() -> String { + return self + } +} + +extension ResolvedChannel: ChannelValue {} +extension DatabaseChannel: ChannelValue {} +extension CollectionChannel: ChannelValue {} +extension DocumentChannel: ChannelValue {} +extension TablesDBChannel: ChannelValue {} +extension TableChannel: ChannelValue {} +extension RowChannel: ChannelValue {} +extension BucketChannel: ChannelValue {} +extension FileChannel: ChannelValue {} +extension FunctionChannel: ChannelValue {} +extension ExecutionChannel: ChannelValue {} +extension TeamChannel: ChannelValue {} +extension MembershipChannel: ChannelValue {} + open class Realtime : Service { private let TYPE_ERROR = "error" @@ -117,8 +144,15 @@ open class Realtime : Service { } } + /** + * Convert channel value to string + */ + private func channelToString(_ channel: ChannelValue) -> String { + return channel.toString() + } + public func subscribe( - channel: String, + channel: ChannelValue, callback: @escaping (RealtimeResponseEvent) -> Void ) async throws -> RealtimeSubscription { return try await subscribe( @@ -129,23 +163,35 @@ open class Realtime : Service { } public func subscribe( - channels: Set, + channels: [ChannelValue], callback: @escaping (RealtimeResponseEvent) -> Void ) async throws -> RealtimeSubscription { return try await subscribe( - channels: channels, + channels: Set(channels.map { channelToString($0) }), payloadType: String.self, callback: callback ) } public func subscribe( - channel: String, + channel: ChannelValue, payloadType: T.Type, callback: @escaping (RealtimeResponseEvent) -> Void ) async throws -> RealtimeSubscription { return try await subscribe( - channels: [channel], + channels: Set([channelToString(channel)]), + payloadType: T.self, + callback: callback + ) + } + + public func subscribe( + channels: [ChannelValue], + payloadType: T.Type, + callback: @escaping (RealtimeResponseEvent) -> Void + ) async throws -> RealtimeSubscription { + return try await subscribe( + channels: Set(channels.map { channelToString($0) }), payloadType: T.self, callback: callback ) diff --git a/templates/dart/test/channel_test.dart.twig b/templates/dart/test/channel_test.dart.twig new file mode 100644 index 000000000..db3cf04fe --- /dev/null +++ b/templates/dart/test/channel_test.dart.twig @@ -0,0 +1,111 @@ +import 'package:{{ language.params.packageName }}/{{ language.params.packageName }}.dart'; +{% if 'dart' in language.params.packageName %} +import 'package:test/test.dart'; +{% else %} +import 'package:flutter_test/flutter_test.dart'; +{% endif %} + +void main() { + group('database()', () { + test('returns database channel with defaults', () { + expect(Channel.database(), 'databases.*.collections.*.documents.*'); + }); + + test('returns database channel with specific IDs', () { + expect(Channel.database(databaseId: 'db1', collectionId: 'col1', documentId: 'doc1'), + 'databases.db1.collections.col1.documents.doc1'); + }); + + test('returns database channel with action', () { + expect(Channel.database(databaseId: 'db1', collectionId: 'col1', documentId: 'doc1', action: 'create'), + 'databases.db1.collections.col1.documents.doc1.create'); + }); + }); + + group('tablesdb()', () { + test('returns tablesdb channel with defaults', () { + expect(Channel.tablesdb(), 'tablesdb.*.tables.*.rows.*'); + }); + + test('returns tablesdb channel with specific IDs', () { + expect(Channel.tablesdb(databaseId: 'db1', tableId: 'table1', rowId: 'row1'), + 'tablesdb.db1.tables.table1.rows.row1'); + }); + + test('returns tablesdb channel with action', () { + expect(Channel.tablesdb(databaseId: 'db1', tableId: 'table1', rowId: 'row1', action: 'update'), + 'tablesdb.db1.tables.table1.rows.row1.update'); + }); + }); + + group('account()', () { + test('returns account channel with default', () { + expect(Channel.account(), 'account.*'); + }); + + test('returns account channel with specific user ID', () { + expect(Channel.account(userId: 'user123'), 'account.user123'); + }); + }); + + group('files()', () { + test('returns files channel with defaults', () { + expect(Channel.files(), 'buckets.*.files.*'); + }); + + test('returns files channel with specific IDs', () { + expect(Channel.files(bucketId: 'bucket1', fileId: 'file1'), + 'buckets.bucket1.files.file1'); + }); + + test('returns files channel with action', () { + expect(Channel.files(bucketId: 'bucket1', fileId: 'file1', action: 'delete'), + 'buckets.bucket1.files.file1.delete'); + }); + }); + + group('executions()', () { + test('returns executions channel with defaults', () { + expect(Channel.executions(), 'functions.*.executions.*'); + }); + + test('returns executions channel with specific IDs', () { + expect(Channel.executions(functionId: 'func1', executionId: 'exec1'), + 'functions.func1.executions.exec1'); + }); + + test('returns executions channel with action', () { + expect(Channel.executions(functionId: 'func1', executionId: 'exec1', action: 'create'), + 'functions.func1.executions.exec1.create'); + }); + }); + + group('teams()', () { + test('returns teams channel with default', () { + expect(Channel.teams(), 'teams.*'); + }); + + test('returns teams channel with specific team ID', () { + expect(Channel.teams(teamId: 'team1'), 'teams.team1'); + }); + + test('returns teams channel with action', () { + expect(Channel.teams(teamId: 'team1', action: 'create'), 'teams.team1.create'); + }); + }); + + group('memberships()', () { + test('returns memberships channel with default', () { + expect(Channel.memberships(), 'memberships.*'); + }); + + test('returns memberships channel with specific membership ID', () { + expect(Channel.memberships(membershipId: 'membership1'), 'memberships.membership1'); + }); + + test('returns memberships channel with action', () { + expect(Channel.memberships(membershipId: 'membership1', action: 'update'), + 'memberships.membership1.update'); + }); + }); +} \ No newline at end of file diff --git a/templates/flutter/lib/channel.dart.twig b/templates/flutter/lib/channel.dart.twig new file mode 100644 index 000000000..1515f01f3 --- /dev/null +++ b/templates/flutter/lib/channel.dart.twig @@ -0,0 +1,239 @@ +part of '{{ language.params.packageName }}.dart'; + +class ResolvedChannel { + final String _value; + + ResolvedChannel(this._value); + + String toString() { + return _value; + } +} + +abstract class ActionChannel { + final String base; + + ActionChannel(this.base); + + ResolvedChannel create() { + return ResolvedChannel('$base.create'); + } + + ResolvedChannel update() { + return ResolvedChannel('$base.update'); + } + + ResolvedChannel delete() { + return ResolvedChannel('$base.delete'); + } + + @override + String toString() { + return base; + } +} + +/** + * ---------------------------- + * Database → Collection → Document + * ---------------------------- + */ +class DocumentChannel extends ActionChannel { + DocumentChannel(String base) : super(base); +} + +class CollectionChannel { + final String base; + final String databaseId; + final String collectionId; + + CollectionChannel(this.databaseId, this.collectionId) + : base = 'databases.$databaseId.collections.$collectionId'; + + DocumentChannel document([String documentId = '*']) { + return DocumentChannel('$base.documents.$documentId'); + } + + @override + String toString() { + return base; + } +} + +class DatabaseChannel { + final String base; + final String databaseId; + + DatabaseChannel(this.databaseId) : base = 'databases.$databaseId'; + + CollectionChannel collection([String collectionId = '*']) { + return CollectionChannel(databaseId, collectionId); + } + + @override + String toString() { + return base; + } +} + +/** + * ---------------------------- + * TablesDB → Table → Row + * ---------------------------- + */ +class RowChannel extends ActionChannel { + RowChannel(String base) : super(base); +} + +class TableChannel { + final String base; + final String databaseId; + final String tableId; + + TableChannel(this.databaseId, this.tableId) + : base = 'tablesdb.$databaseId.tables.$tableId'; + + RowChannel row([String rowId = '*']) { + return RowChannel('$base.rows.$rowId'); + } + + @override + String toString() { + return base; + } +} + +class TablesDBChannel { + final String base; + final String databaseId; + + TablesDBChannel(this.databaseId) : base = 'tablesdb.$databaseId'; + + TableChannel table([String tableId = '*']) { + return TableChannel(databaseId, tableId); + } + + @override + String toString() { + return base; + } +} + +/** + * ---------------------------- + * Buckets → File + * ---------------------------- + */ +class FileChannel extends ActionChannel { + FileChannel(String base) : super(base); +} + +class BucketChannel { + final String base; + final String bucketId; + + BucketChannel(this.bucketId) : base = 'buckets.$bucketId'; + + FileChannel file([String fileId = '*']) { + return FileChannel('$base.files.$fileId'); + } + + @override + String toString() { + return base; + } +} + +/** + * ---------------------------- + * Functions → Execution + * ---------------------------- + */ +class ExecutionChannel extends ActionChannel { + ExecutionChannel(String base) : super(base); +} + +class FunctionChannel { + final String base; + final String functionId; + + FunctionChannel(this.functionId) : base = 'functions.$functionId'; + + ExecutionChannel execution([String executionId = '*']) { + return ExecutionChannel('$base.executions.$executionId'); + } + + @override + String toString() { + return base; + } +} + +class TeamChannel extends ActionChannel { + TeamChannel([String teamId = '*']) : super('teams.$teamId'); +} + +class MembershipChannel extends ActionChannel { + MembershipChannel([String membershipId = '*']) + : super('memberships.$membershipId'); +} + +typedef ChannelValue = Object; + +class Channel { + /// Generate a database channel builder. + /// + /// [databaseId] The database ID (default: "*") + /// Returns [DatabaseChannel] + static DatabaseChannel database([String databaseId = '*']) { + return DatabaseChannel(databaseId); + } + + /// Generate a tables database channel builder. + /// + /// [databaseId] The database ID (default: "*") + /// Returns [TablesDBChannel] + static TablesDBChannel tablesdb([String databaseId = '*']) { + return TablesDBChannel(databaseId); + } + + /// Generate a buckets channel builder. + /// + /// [bucketId] The bucket ID (default: "*") + /// Returns [BucketChannel] + static BucketChannel buckets([String bucketId = '*']) { + return BucketChannel(bucketId); + } + + /// Generate a functions channel builder. + /// + /// [functionId] The function ID (default: "*") + /// Returns [FunctionChannel] + static FunctionChannel functions([String functionId = '*']) { + return FunctionChannel(functionId); + } + + /// Generate a teams channel builder. + /// + /// [teamId] The team ID (default: "*") + /// Returns [TeamChannel] + static TeamChannel teams([String teamId = '*']) { + return TeamChannel(teamId); + } + + /// Generate a memberships channel builder. + /// + /// [membershipId] The membership ID (default: "*") + /// Returns [MembershipChannel] + static MembershipChannel memberships([String membershipId = '*']) { + return MembershipChannel(membershipId); + } + + /// Generate an account channel string. + /// + /// [userId] The user ID (default: "*") + /// Returns The channel string + static String account([String userId = '*']) { + return 'account.$userId'; + } +} \ No newline at end of file diff --git a/templates/flutter/lib/package.dart.twig b/templates/flutter/lib/package.dart.twig index 51965ccb9..515452fa6 100644 --- a/templates/flutter/lib/package.dart.twig +++ b/templates/flutter/lib/package.dart.twig @@ -30,6 +30,7 @@ part 'query.dart'; part 'permission.dart'; part 'role.dart'; part 'id.dart'; +part 'channel.dart'; part 'operator.dart'; {% for service in spec.services %} part 'services/{{service.name | caseSnake}}.dart'; diff --git a/templates/flutter/lib/src/realtime.dart.twig b/templates/flutter/lib/src/realtime.dart.twig index e02d89a56..5cf072a16 100644 --- a/templates/flutter/lib/src/realtime.dart.twig +++ b/templates/flutter/lib/src/realtime.dart.twig @@ -42,7 +42,15 @@ abstract class Realtime extends Service { /// subscription.close(); /// ``` /// - RealtimeSubscription subscribe(List channels); + /// You can also use Channel builders: + /// ```dart + /// final subscription = realtime.subscribe([ + /// Channel.database('db').collection('col').document('doc').create(), + /// Channel.buckets('bucket').file('file').update(), + /// 'account.*' + /// ]); + /// ``` + RealtimeSubscription subscribe(List channels); /// The [close code](https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.5) set when the WebSocket connection is closed. /// diff --git a/templates/flutter/lib/src/realtime_base.dart.twig b/templates/flutter/lib/src/realtime_base.dart.twig index f9fe5e44b..479311074 100644 --- a/templates/flutter/lib/src/realtime_base.dart.twig +++ b/templates/flutter/lib/src/realtime_base.dart.twig @@ -3,5 +3,5 @@ import 'realtime.dart'; abstract class RealtimeBase implements Realtime { @override - RealtimeSubscription subscribe(List channels); + RealtimeSubscription subscribe(List channels); } diff --git a/templates/flutter/lib/src/realtime_browser.dart.twig b/templates/flutter/lib/src/realtime_browser.dart.twig index aa6a3ad14..5d0889066 100644 --- a/templates/flutter/lib/src/realtime_browser.dart.twig +++ b/templates/flutter/lib/src/realtime_browser.dart.twig @@ -35,7 +35,7 @@ class RealtimeBrowser extends RealtimeBase with RealtimeMixin { } @override - RealtimeSubscription subscribe(List channels) { + RealtimeSubscription subscribe(List channels) { return subscribeTo(channels); } } diff --git a/templates/flutter/lib/src/realtime_io.dart.twig b/templates/flutter/lib/src/realtime_io.dart.twig index 27539b251..dd881725b 100644 --- a/templates/flutter/lib/src/realtime_io.dart.twig +++ b/templates/flutter/lib/src/realtime_io.dart.twig @@ -43,7 +43,7 @@ class RealtimeIO extends RealtimeBase with RealtimeMixin { /// Use this method to subscribe to a channels and listen to /// realtime events on those channels @override - RealtimeSubscription subscribe(List channels) { + RealtimeSubscription subscribe(List channels) { return subscribeTo(channels); } diff --git a/templates/flutter/lib/src/realtime_mixin.dart.twig b/templates/flutter/lib/src/realtime_mixin.dart.twig index 96e30699b..e34bc1a9c 100644 --- a/templates/flutter/lib/src/realtime_mixin.dart.twig +++ b/templates/flutter/lib/src/realtime_mixin.dart.twig @@ -168,18 +168,27 @@ mixin RealtimeMixin { ); } - RealtimeSubscription subscribeTo(List channels) { + /// Convert channel value to string + String _channelToString(Object channel) { + if (channel is String) { + return channel; + } + return channel.toString(); + } + + RealtimeSubscription subscribeTo(List channels) { StreamController controller = StreamController.broadcast(); - _channels.addAll(channels); + final channelStrings = channels.map((ch) => _channelToString(ch)).toList().cast(); + _channels.addAll(channelStrings); Future.delayed(Duration.zero, () => _createSocket()); int id = DateTime.now().microsecondsSinceEpoch; RealtimeSubscription subscription = RealtimeSubscription( controller: controller, - channels: channels, + channels: channelStrings, close: () async { _subscriptions.remove(id); controller.close(); - _cleanup(channels); + _cleanup(channelStrings); if (_channels.isNotEmpty) { await Future.delayed(Duration.zero, () => _createSocket()); diff --git a/templates/flutter/test/src/channel_test.dart.twig b/templates/flutter/test/src/channel_test.dart.twig new file mode 100644 index 000000000..f531049ec --- /dev/null +++ b/templates/flutter/test/src/channel_test.dart.twig @@ -0,0 +1,108 @@ +import 'package:{{ language.params.packageName }}/{{ language.params.packageName }}.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('database()', () { + test('returns database channel with defaults', () { + expect(Channel.database().collection().document().toString(), 'databases.*.collections.*.documents.*'); + }); + + test('returns database channel with specific IDs', () { + expect(Channel.database('db1').collection('col1').document('doc1').toString(), + 'databases.db1.collections.col1.documents.doc1'); + }); + + test('returns database channel with action', () { + expect(Channel.database('db1').collection('col1').document('doc1').create().toString(), + 'databases.db1.collections.col1.documents.doc1.create'); + }); + }); + + group('tablesdb()', () { + test('returns tablesdb channel with defaults', () { + expect(Channel.tablesdb().table().row().toString(), 'tablesdb.*.tables.*.rows.*'); + }); + + test('returns tablesdb channel with specific IDs', () { + expect(Channel.tablesdb('db1').table('table1').row('row1').toString(), + 'tablesdb.db1.tables.table1.rows.row1'); + }); + + test('returns tablesdb channel with action', () { + expect(Channel.tablesdb('db1').table('table1').row('row1').update().toString(), + 'tablesdb.db1.tables.table1.rows.row1.update'); + }); + }); + + group('account()', () { + test('returns account channel with default', () { + expect(Channel.account(), 'account.*'); + }); + + test('returns account channel with specific user ID', () { + expect(Channel.account('user123'), 'account.user123'); + }); + }); + + group('buckets()', () { + test('returns buckets channel with defaults', () { + expect(Channel.buckets().file().toString(), 'buckets.*.files.*'); + }); + + test('returns buckets channel with specific IDs', () { + expect(Channel.buckets('bucket1').file('file1').toString(), + 'buckets.bucket1.files.file1'); + }); + + test('returns buckets channel with action', () { + expect(Channel.buckets('bucket1').file('file1').delete().toString(), + 'buckets.bucket1.files.file1.delete'); + }); + }); + + group('functions()', () { + test('returns functions channel with defaults', () { + expect(Channel.functions().execution().toString(), 'functions.*.executions.*'); + }); + + test('returns functions channel with specific IDs', () { + expect(Channel.functions('func1').execution('exec1').toString(), + 'functions.func1.executions.exec1'); + }); + + test('returns functions channel with action', () { + expect(Channel.functions('func1').execution('exec1').create().toString(), + 'functions.func1.executions.exec1.create'); + }); + }); + + group('teams()', () { + test('returns teams channel with default', () { + expect(Channel.teams().toString(), 'teams.*'); + }); + + test('returns teams channel with specific team ID', () { + expect(Channel.teams('team1').toString(), 'teams.team1'); + }); + + test('returns teams channel with action', () { + expect(Channel.teams('team1').create().toString(), 'teams.team1.create'); + }); + }); + + group('memberships()', () { + test('returns memberships channel with default', () { + expect(Channel.memberships().toString(), 'memberships.*'); + }); + + test('returns memberships channel with specific membership ID', () { + expect(Channel.memberships('membership1').toString(), 'memberships.membership1'); + }); + + test('returns memberships channel with action', () { + expect(Channel.memberships('membership1').update().toString(), + 'memberships.membership1.update'); + }); + }); +} + diff --git a/templates/react-native/src/channel.ts.twig b/templates/react-native/src/channel.ts.twig new file mode 100644 index 000000000..fe2cada9e --- /dev/null +++ b/templates/react-native/src/channel.ts.twig @@ -0,0 +1,111 @@ +/** + * Helper class to generate channel strings for realtime subscriptions. + */ +export class Channel { + /** + * Generate a database channel string. + * + * @param {string} databaseId + * @param {string} collectionId + * @param {string} documentId + * @param {'create'|'update'|'delete'|null} action + * @returns {string} + */ + static database(databaseId: string = '*', collectionId: string = '*', documentId: string = '*', action: 'create' | 'update' | 'delete' | null = null): string { + let channel = `databases.${databaseId}.collections.${collectionId}.documents.${documentId}`; + if (action) { + channel += `.${action}`; + } + return channel; + } + + /** + * Generate a tables database channel string. + * + * @param {string} databaseId + * @param {string} tableId + * @param {string} rowId + * @param {'create'|'update'|'delete'|null} action + * @returns {string} + */ + static tablesdb(databaseId: string = '*', tableId: string = '*', rowId: string = '*', action: 'create' | 'update' | 'delete' | null = null): string { + let channel = `tablesdb.${databaseId}.tables.${tableId}.rows.${rowId}`; + if (action) { + channel += `.${action}`; + } + return channel; + } + + /** + * Generate an account channel string. + * + * @param {string} userId + * @returns {string} + */ + static account(userId: string = '*'): string { + return `account.${userId}`; + } + + /** + * Generate a files channel string. + * + * @param {string} bucketId + * @param {string} fileId + * @param {'create'|'update'|'delete'|null} action + * @returns {string} + */ + static files(bucketId: string = '*', fileId: string = '*', action: 'create' | 'update' | 'delete' | null = null): string { + let channel = `buckets.${bucketId}.files.${fileId}`; + if (action) { + channel += `.${action}`; + } + return channel; + } + + /** + * Generate an executions channel string. + * + * @param {string} functionId + * @param {string} executionId + * @param {'create'|'update'|'delete'|null} action + * @returns {string} + */ + static executions(functionId: string = '*', executionId: string = '*', action: 'create' | 'update' | 'delete' | null = null): string { + let channel = `functions.${functionId}.executions.${executionId}`; + if (action) { + channel += `.${action}`; + } + return channel; + } + + /** + * Generate a teams channel string. + * + * @param {string} teamId + * @param {'create'|'update'|'delete'|null} action + * @returns {string} + */ + static teams(teamId: string = '*', action: 'create' | 'update' | 'delete' | null = null): string { + let channel = `teams.${teamId}`; + if (action) { + channel += `.${action}`; + } + return channel; + } + + /** + * Generate a memberships channel string. + * + * @param {string} membershipId + * @param {'create'|'update'|'delete'|null} action + * @returns {string} + */ + static memberships(membershipId: string = '*', action: 'create' | 'update' | 'delete' | null = null): string { + let channel = `memberships.${membershipId}`; + if (action) { + channel += `.${action}`; + } + return channel; + } +} + diff --git a/templates/react-native/src/index.ts.twig b/templates/react-native/src/index.ts.twig index 8a9b5aa94..e27f29649 100644 --- a/templates/react-native/src/index.ts.twig +++ b/templates/react-native/src/index.ts.twig @@ -8,6 +8,7 @@ export { Query } from './query'; export { Permission } from './permission'; export { Role } from './role'; export { ID } from './id'; +export { Channel } from './channel'; export { Operator, Condition } from './operator'; {% for enum in spec.allEnums %} export { {{ enum.name | caseUcfirst }} } from './enums/{{enum.name | caseKebab}}'; diff --git a/templates/swift/Sources/WebSockets/WebSocketClient.swift.twig b/templates/swift/Sources/WebSockets/WebSocketClient.swift.twig index 0e6c3e7ee..7c004d295 100644 --- a/templates/swift/Sources/WebSockets/WebSocketClient.swift.twig +++ b/templates/swift/Sources/WebSockets/WebSocketClient.swift.twig @@ -32,7 +32,7 @@ public class WebSocketClient { private let channelQueue = DispatchQueue(label: WEBSOCKET_CHANNEL_QUEUE) private let threadGroupQueue = DispatchQueue(label: WEBSOCKET_THREAD_QUEUE) - var channel: Channel? { + var channel: NIOCore.Channel? { get { return channelQueue.sync { _channel } } @@ -40,7 +40,7 @@ public class WebSocketClient { channelQueue.sync { _channel = newValue } } } - private var _channel: Channel? = nil + private var _channel: NIOCore.Channel? = nil var threadGroup: MultiThreadedEventLoopGroup? { get { @@ -61,8 +61,8 @@ public class WebSocketClient { // MARK: - Stored callbacks - private var _openCallback: (Channel) -> Void = { _ in } - var onOpen: (Channel) -> Void { + private var _openCallback: (NIOCore.Channel) -> Void = { _ in } + var onOpen: (NIOCore.Channel) -> Void { get { return locker.sync { return _openCallback @@ -75,8 +75,8 @@ public class WebSocketClient { } } - private var _closeCallback: (Channel, Data) -> Void = { _,_ in } - var onClose: (Channel, Data) -> Void { + private var _closeCallback: (NIOCore.Channel, Data) -> Void = { _,_ in } + var onClose: (NIOCore.Channel, Data) -> Void { get { return locker.sync { return _closeCallback @@ -137,7 +137,7 @@ public class WebSocketClient { /// /// - parameters: /// - callback: Callback to fie when a WebSocket connection is opened - public func onOpen(_ callback: @escaping (Channel) -> Void) { + public func onOpen(_ callback: @escaping (NIOCore.Channel) -> Void) { onOpen = callback } @@ -161,7 +161,7 @@ public class WebSocketClient { /// /// - parameters: /// - callback: Callback to fie when a WebSocket close message is received - public func onClose(_ callback: @escaping (Channel, Data) -> Void) { + public func onClose(_ callback: @escaping (NIOCore.Channel, Data) -> Void) { onClose = callback } @@ -262,7 +262,7 @@ public class WebSocketClient { .get() } - private func openChannel(channel: Channel) -> EventLoopFuture { + private func openChannel(channel: NIOCore.Channel) -> EventLoopFuture { let httpHandler = HTTPHandler(client: self, headers: headers) let basicUpgrader = NIOWebSocketClientUpgrader( @@ -290,7 +290,7 @@ public class WebSocketClient { } } - private func upgradePipelineHandler(channel: Channel, response: HTTPResponseHead) -> EventLoopFuture { + private func upgradePipelineHandler(channel: NIOCore.Channel, response: HTTPResponseHead) -> EventLoopFuture { let handler = MessageHandler(client: self) if response.status == .switchingProtocols { diff --git a/templates/swift/Sources/WebSockets/WebSocketClientDelegate.swift.twig b/templates/swift/Sources/WebSockets/WebSocketClientDelegate.swift.twig index d1556acb9..d6327e0d9 100644 --- a/templates/swift/Sources/WebSockets/WebSocketClientDelegate.swift.twig +++ b/templates/swift/Sources/WebSockets/WebSocketClientDelegate.swift.twig @@ -4,22 +4,22 @@ import NIOHTTP1 /// Handles messages received by a connected WebSocket server. public protocol WebSocketClientDelegate : AnyObject { - func onOpen(channel: Channel) + func onOpen(channel: NIOCore.Channel) func onMessage(text: String) throws func onMessage(data: Data) throws - func onClose(channel: Channel, data: Data) + func onClose(channel: NIOCore.Channel, data: Data) func onError(error: Swift.Error?, status: HTTPResponseStatus?) throws } // Add empty default implementations extension WebSocketClientDelegate { - public func onOpen(channel: Channel) { + public func onOpen(channel: NIOCore.Channel) { } public func onMessage(text: String) { } public func onMessage(data: Data) { } - public func onClose(channel: Channel, data: Data) { + public func onClose(channel: NIOCore.Channel, data: Data) { } public func onError(error: Swift.Error?, status: HTTPResponseStatus?) throws { } diff --git a/templates/web/src/channel.ts.twig b/templates/web/src/channel.ts.twig new file mode 100644 index 000000000..ac14ebdf8 --- /dev/null +++ b/templates/web/src/channel.ts.twig @@ -0,0 +1,266 @@ + + +export class ResolvedChannel { + private value: string; + + constructor(value: string) { + this.value = value; + } + + toString(): string { + return this.value; + } +} + +abstract class ActionChannel { + protected base: string; + + constructor(base: string) { + this.base = base; + } + + create(): ResolvedChannel { + return new ResolvedChannel(`${this.base}.create`); + } + + update(): ResolvedChannel { + return new ResolvedChannel(`${this.base}.update`); + } + + delete(): ResolvedChannel { + return new ResolvedChannel(`${this.base}.delete`); + } + + toString(): string { + return this.base; + } +} + +/** + * ---------------------------- + * Database → Collection → Document + * ---------------------------- + */ +class DocumentChannel extends ActionChannel {} + +class CollectionChannel { + private base: string; + + constructor( + private databaseId: string, + private collectionId: string + ) { + this.base = `databases.${databaseId}.collections.${collectionId}`; + } + + document(documentId: string = '*'): DocumentChannel { + return new DocumentChannel( + `${this.base}.documents.${documentId}` + ); + } + + toString(): string { + return this.base; + } +} + +class DatabaseChannel { + private base: string; + + constructor(private databaseId: string) { + this.base = `databases.${databaseId}`; + } + + collection(collectionId: string = '*'): CollectionChannel { + return new CollectionChannel(this.databaseId, collectionId); + } + + toString(): string { + return this.base; + } +} + +/** + * ---------------------------- + * TablesDB → Table → Row + * ---------------------------- + */ +class RowChannel extends ActionChannel {} + +class TableChannel { + private base: string; + + constructor( + private databaseId: string, + private tableId: string + ) { + this.base = `tablesdb.${databaseId}.tables.${tableId}`; + } + + row(rowId: string = '*'): RowChannel { + return new RowChannel( + `${this.base}.rows.${rowId}` + ); + } + + toString(): string { + return this.base; + } +} + +class TablesDBChannel { + private base: string; + + constructor(private databaseId: string) { + this.base = `tablesdb.${databaseId}`; + } + + table(tableId: string = '*'): TableChannel { + return new TableChannel(this.databaseId, tableId); + } + + toString(): string { + return this.base; + } +} + +/** + * ---------------------------- + * Buckets → File + * ---------------------------- + */ +class FileChannel extends ActionChannel {} + +class BucketChannel { + private base: string; + + constructor(private bucketId: string) { + this.base = `buckets.${bucketId}`; + } + + file(fileId: string = '*'): FileChannel { + return new FileChannel( + `${this.base}.files.${fileId}` + ); + } + + toString(): string { + return this.base; + } +} + +/** + * ---------------------------- + * Functions → Execution + * ---------------------------- + */ +class ExecutionChannel extends ActionChannel {} + +class FunctionChannel { + private base: string; + + constructor(private functionId: string) { + this.base = `functions.${functionId}`; + } + + execution(executionId: string = '*'): ExecutionChannel { + return new ExecutionChannel( + `${this.base}.executions.${executionId}` + ); + } + + toString(): string { + return this.base; + } +} + +class TeamChannel extends ActionChannel { + constructor(teamId: string = '*') { + super(`teams.${teamId}`); + } +} + +class MembershipChannel extends ActionChannel { + constructor(membershipId: string = '*') { + super(`memberships.${membershipId}`); + } +} + +/** + * ---------------------------- + * Channel Type (for type checking) + * ---------------------------- + */ +export type ChannelValue = string | ResolvedChannel | DatabaseChannel | CollectionChannel | DocumentChannel | TablesDBChannel | TableChannel | RowChannel | BucketChannel | FileChannel | FunctionChannel | ExecutionChannel | TeamChannel | MembershipChannel; + +export class Channel { + /** + * Generate a database channel builder. + * + * @param {string} databaseId + * @returns {DatabaseChannel} + */ + static database(databaseId: string = '*'): DatabaseChannel { + return new DatabaseChannel(databaseId); + } + + /** + * Generate a tables database channel builder. + * + * @param {string} databaseId + * @returns {TablesDBChannel} + */ + static tablesdb(databaseId: string = '*'): TablesDBChannel { + return new TablesDBChannel(databaseId); + } + + /** + * Generate a buckets channel builder. + * + * @param {string} bucketId + * @returns {BucketChannel} + */ + static buckets(bucketId: string = '*'): BucketChannel { + return new BucketChannel(bucketId); + } + + /** + * Generate a functions channel builder. + * + * @param {string} functionId + * @returns {FunctionChannel} + */ + static functions(functionId: string = '*'): FunctionChannel { + return new FunctionChannel(functionId); + } + + /** + * Generate a teams channel builder. + * + * @param {string} teamId + * @returns {TeamChannel} + */ + static teams(teamId: string = '*'): TeamChannel { + return new TeamChannel(teamId); + } + + /** + * Generate a memberships channel builder. + * + * @param {string} membershipId + * @returns {MembershipChannel} + */ + static memberships(membershipId: string = '*'): MembershipChannel { + return new MembershipChannel(membershipId); + } + + /** + * Generate an account channel string. + * + * @param {string} userId + * @returns {string} + */ + static account(userId: string = '*'): string { + return `account.${userId}`; + } +} diff --git a/templates/web/src/client.ts.twig b/templates/web/src/client.ts.twig index 358e30bfb..639507d29 100644 --- a/templates/web/src/client.ts.twig +++ b/templates/web/src/client.ts.twig @@ -1,4 +1,5 @@ import { Models } from './models'; +import { ChannelValue } from './channel'; /** * Payload type representing a key-value pair with string keys and any values. @@ -552,8 +553,8 @@ class Client { * @deprecated Use the Realtime service instead. * @see Realtime * - * @param {string|string[]} channels - * Channel to subscribe - pass a single channel as a string or multiple with an array of strings. + * @param {string|string[]|ChannelValue|ChannelValue[]} channels + * Channel to subscribe - pass a single channel as a string or Channel builder instance, or multiple with an array. * * Possible channels are: * - account @@ -571,16 +572,34 @@ class Client { * - teams.[ID] * - memberships * - memberships.[ID] + * + * You can also use Channel builders: + * - Channel.database('db').collection('col').document('doc').create() + * - Channel.buckets('bucket').file('file').update() + * - Channel.functions('func').execution('exec').delete() + * - Channel.teams('team').create() + * - Channel.memberships('membership').update() * @param {(payload: RealtimeMessage) => void} callback Is called on every realtime update. * @returns {() => void} Unsubscribes from events. */ - subscribe(channels: string | string[], callback: (payload: RealtimeResponseEvent) => void): () => void { - let channelArray = typeof channels === 'string' ? [channels] : channels; - channelArray.forEach(channel => this.realtime.channels.add(channel)); + subscribe(channels: string | string[] | ChannelValue | ChannelValue[], callback: (payload: RealtimeResponseEvent) => void): () => void { + const channelArray = Array.isArray(channels) ? channels : [channels]; + // Convert Channel instances to strings + const channelStrings = channelArray.map(ch => { + if (typeof ch === 'string') { + return ch; + } + // Channel builder instances have toString() method + if (ch && typeof ch.toString === 'function') { + return ch.toString(); + } + return String(ch); + }); + channelStrings.forEach(channel => this.realtime.channels.add(channel)); const counter = this.realtime.subscriptionsCounter++; this.realtime.subscriptions.set(counter, { - channels: channelArray, + channels: channelStrings, callback }); @@ -588,7 +607,7 @@ class Client { return () => { this.realtime.subscriptions.delete(counter); - this.realtime.cleanUp(channelArray); + this.realtime.cleanUp(channelStrings); this.realtime.connect(); } } diff --git a/templates/web/src/index.ts.twig b/templates/web/src/index.ts.twig index c9aba90fc..54ad22739 100644 --- a/templates/web/src/index.ts.twig +++ b/templates/web/src/index.ts.twig @@ -15,6 +15,7 @@ export type { QueryTypes, QueryTypesList } from './query'; export { Permission } from './permission'; export { Role } from './role'; export { ID } from './id'; +export { Channel } from './channel'; export { Operator, Condition } from './operator'; {% for enum in spec.allEnums %} export { {{ enum.name | caseUcfirst }} } from './enums/{{enum.name | caseKebab}}'; diff --git a/templates/web/src/services/realtime.ts.twig b/templates/web/src/services/realtime.ts.twig index d30e716ea..d03e93903 100644 --- a/templates/web/src/services/realtime.ts.twig +++ b/templates/web/src/services/realtime.ts.twig @@ -1,4 +1,5 @@ import { {{ spec.title | caseUcfirst}}Exception, Client } from '../client'; +import { ChannelValue } from '../channel'; export type RealtimeSubscription = { close: () => Promise; @@ -237,61 +238,83 @@ export class Realtime { return new Promise(resolve => setTimeout(resolve, ms)); } + /** + * Convert a channel value to a string + * + * @private + * @param {string | ChannelValue} channel - Channel value (string or Channel builder instance) + * @returns {string} Channel string representation + */ + private channelToString(channel: string | ChannelValue): string { + if (typeof channel === 'string') { + return channel; + } + // Channel builder instances have toString() method + if (channel && typeof channel.toString === 'function') { + return channel.toString(); + } + return String(channel); + } + /** * Subscribe to a single channel * - * @param {string} channel - Channel name to subscribe to + * @param {string | ChannelValue} channel - Channel name to subscribe to (string or Channel builder instance) * @param {Function} callback - Callback function to handle events * @returns {Promise} Subscription object with close method */ public async subscribe( - channel: string, + channel: ChannelValue, callback: (event: RealtimeResponseEvent) => void ): Promise; /** * Subscribe to multiple channels * - * @param {string[]} channels - Array of channel names to subscribe to + * @param {(string | ChannelValue)[]} channels - Array of channel names to subscribe to (strings or Channel builder instances) * @param {Function} callback - Callback function to handle events * @returns {Promise} Subscription object with close method */ public async subscribe( - channels: string[], + channels: (string | ChannelValue)[], callback: (event: RealtimeResponseEvent) => void ): Promise; /** * Subscribe to a single channel with typed payload * - * @param {string} channel - Channel name to subscribe to + * @param {string | ChannelValue} channel - Channel name to subscribe to (string or Channel builder instance) * @param {Function} callback - Callback function to handle events with typed payload * @returns {Promise} Subscription object with close method */ public async subscribe( - channel: string, + channel: ChannelValue, callback: (event: RealtimeResponseEvent) => void ): Promise; /** * Subscribe to multiple channels with typed payload * - * @param {string[]} channels - Array of channel names to subscribe to + * @param {(string | ChannelValue)[]} channels - Array of channel names to subscribe to (strings or Channel builder instances) * @param {Function} callback - Callback function to handle events with typed payload * @returns {Promise} Subscription object with close method */ public async subscribe( - channels: string[], + channels: (string | ChannelValue)[], callback: (event: RealtimeResponseEvent) => void ): Promise; public async subscribe( - channelsOrChannel: string | string[], + channelsOrChannel: ChannelValue | (string | ChannelValue)[], callback: (event: RealtimeResponseEvent) => void ): Promise { - const channels = Array.isArray(channelsOrChannel) - ? new Set(channelsOrChannel) - : new Set([channelsOrChannel]); + const channelArray = Array.isArray(channelsOrChannel) + ? channelsOrChannel + : [channelsOrChannel]; + + // Convert all channels to strings + const channelStrings = channelArray.map(ch => this.channelToString(ch)); + const channels = new Set(channelStrings); this.subscriptionsCounter++; const count = this.subscriptionsCounter; diff --git a/tests/Android14Java11Test.php b/tests/Android14Java11Test.php index b6e114f40..b01e4ec93 100644 --- a/tests/Android14Java11Test.php +++ b/tests/Android14Java11Test.php @@ -33,6 +33,7 @@ class Android14Java11Test extends Base ...Base::QUERY_HELPER_RESPONSES, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, + ...Base::CHANNEL_HELPER_RESPONSES, ...Base::OPERATOR_HELPER_RESPONSES ]; } diff --git a/tests/Android14Java17Test.php b/tests/Android14Java17Test.php index aede9c7af..e6e1b7c45 100644 --- a/tests/Android14Java17Test.php +++ b/tests/Android14Java17Test.php @@ -32,6 +32,7 @@ class Android14Java17Test extends Base ...Base::QUERY_HELPER_RESPONSES, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, + ...Base::CHANNEL_HELPER_RESPONSES, ...Base::OPERATOR_HELPER_RESPONSES ]; } diff --git a/tests/Android14Java8Test.php b/tests/Android14Java8Test.php index 0355e77b0..4bb086e25 100644 --- a/tests/Android14Java8Test.php +++ b/tests/Android14Java8Test.php @@ -33,6 +33,7 @@ class Android14Java8Test extends Base ...Base::QUERY_HELPER_RESPONSES, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, + ...Base::CHANNEL_HELPER_RESPONSES, ...Base::OPERATOR_HELPER_RESPONSES ]; } diff --git a/tests/Android5Java17Test.php b/tests/Android5Java17Test.php index 9677e0f00..1a58bb327 100644 --- a/tests/Android5Java17Test.php +++ b/tests/Android5Java17Test.php @@ -32,6 +32,7 @@ class Android5Java17Test extends Base ...Base::QUERY_HELPER_RESPONSES, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, + ...Base::CHANNEL_HELPER_RESPONSES, ...Base::OPERATOR_HELPER_RESPONSES ]; } diff --git a/tests/AppleSwift56Test.php b/tests/AppleSwift56Test.php index b4a6709f1..36852cae0 100644 --- a/tests/AppleSwift56Test.php +++ b/tests/AppleSwift56Test.php @@ -31,6 +31,7 @@ class AppleSwift56Test extends Base ...Base::QUERY_HELPER_RESPONSES, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, + ...Base::CHANNEL_HELPER_RESPONSES, ...Base::OPERATOR_HELPER_RESPONSES ]; } diff --git a/tests/Base.php b/tests/Base.php index 4917f2d9a..2a2b3e17e 100644 --- a/tests/Base.php +++ b/tests/Base.php @@ -163,6 +163,29 @@ abstract class Base extends TestCase 'custom_id' ]; + protected const CHANNEL_HELPER_RESPONSES = [ + 'databases.*.collections.*.documents.*', + 'databases.db1.collections.col1.documents.doc1', + 'databases.db1.collections.col1.documents.doc1.create', + 'tablesdb.*.tables.*.rows.*', + 'tablesdb.db1.tables.table1.rows.row1', + 'tablesdb.db1.tables.table1.rows.row1.update', + 'account.*', + 'account.user123', + 'buckets.*.files.*', + 'buckets.bucket1.files.file1', + 'buckets.bucket1.files.file1.delete', + 'functions.*.executions.*', + 'functions.func1.executions.exec1', + 'functions.func1.executions.exec1.create', + 'teams.*', + 'teams.team1', + 'teams.team1.create', + 'memberships.*', + 'memberships.membership1', + 'memberships.membership1.update', + ]; + protected const OPERATOR_HELPER_RESPONSES = [ '{"method":"increment","values":[1]}', '{"method":"increment","values":[5,100]}', diff --git a/tests/FlutterBetaTest.php b/tests/FlutterBetaTest.php index 366f3d132..32b99cf55 100644 --- a/tests/FlutterBetaTest.php +++ b/tests/FlutterBetaTest.php @@ -31,6 +31,7 @@ class FlutterBetaTest extends Base ...Base::QUERY_HELPER_RESPONSES, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, + ...Base::CHANNEL_HELPER_RESPONSES, ...Base::OPERATOR_HELPER_RESPONSES ]; } diff --git a/tests/FlutterStableTest.php b/tests/FlutterStableTest.php index 62521f780..1c98f9ae3 100644 --- a/tests/FlutterStableTest.php +++ b/tests/FlutterStableTest.php @@ -31,6 +31,7 @@ class FlutterStableTest extends Base ...Base::QUERY_HELPER_RESPONSES, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, + ...Base::CHANNEL_HELPER_RESPONSES, ...Base::OPERATOR_HELPER_RESPONSES ]; } diff --git a/tests/WebChromiumTest.php b/tests/WebChromiumTest.php index 8e609e383..9a85edb8d 100644 --- a/tests/WebChromiumTest.php +++ b/tests/WebChromiumTest.php @@ -35,6 +35,7 @@ class WebChromiumTest extends Base ...Base::QUERY_HELPER_RESPONSES, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, + ...Base::CHANNEL_HELPER_RESPONSES, ...Base::OPERATOR_HELPER_RESPONSES ]; } diff --git a/tests/WebNodeTest.php b/tests/WebNodeTest.php index dc8e48275..8e09dcdf6 100644 --- a/tests/WebNodeTest.php +++ b/tests/WebNodeTest.php @@ -35,6 +35,7 @@ class WebNodeTest extends Base ...Base::QUERY_HELPER_RESPONSES, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, + ...Base::CHANNEL_HELPER_RESPONSES, ...Base::OPERATOR_HELPER_RESPONSES ]; } diff --git a/tests/languages/android/Tests.kt b/tests/languages/android/Tests.kt index aa8a083ef..58013f647 100644 --- a/tests/languages/android/Tests.kt +++ b/tests/languages/android/Tests.kt @@ -7,6 +7,7 @@ import io.appwrite.exceptions.AppwriteException import io.appwrite.Permission import io.appwrite.Role import io.appwrite.ID +import io.appwrite.Channel import io.appwrite.Query import io.appwrite.Operator import io.appwrite.Condition @@ -269,6 +270,28 @@ class ServiceTest { writeToFile(ID.unique()) writeToFile(ID.custom("custom_id")) + // Channel helper tests + writeToFile(Channel.database().collection().document().toString()) + writeToFile(Channel.database("db1").collection("col1").document("doc1").toString()) + writeToFile(Channel.database("db1").collection("col1").document("doc1").create().toString()) + writeToFile(Channel.tablesdb().table().row().toString()) + writeToFile(Channel.tablesdb("db1").table("table1").row("row1").toString()) + writeToFile(Channel.tablesdb("db1").table("table1").row("row1").update().toString()) + writeToFile(Channel.account()) + writeToFile(Channel.account("user123")) + writeToFile(Channel.buckets().file().toString()) + writeToFile(Channel.buckets("bucket1").file("file1").toString()) + writeToFile(Channel.buckets("bucket1").file("file1").delete().toString()) + writeToFile(Channel.functions().execution().toString()) + writeToFile(Channel.functions("func1").execution("exec1").toString()) + writeToFile(Channel.functions("func1").execution("exec1").create().toString()) + writeToFile(Channel.teams().toString()) + writeToFile(Channel.teams("team1").toString()) + writeToFile(Channel.teams("team1").create().toString()) + writeToFile(Channel.memberships().toString()) + writeToFile(Channel.memberships("membership1").toString()) + writeToFile(Channel.memberships("membership1").update().toString()) + // Operator helper tests writeToFile(Operator.increment(1)) writeToFile(Operator.increment(5, 100)) diff --git a/tests/languages/apple/Tests.swift b/tests/languages/apple/Tests.swift index ae8b55b92..50bde946c 100644 --- a/tests/languages/apple/Tests.swift +++ b/tests/languages/apple/Tests.swift @@ -248,6 +248,28 @@ class Tests: XCTestCase { print(ID.unique()) print(ID.custom("custom_id")) + // Channel helper tests + print(Channel.database().collection().document().toString()) + print(Channel.database("db1").collection("col1").document("doc1").toString()) + print(Channel.database("db1").collection("col1").document("doc1").create().toString()) + print(Channel.tablesdb().table().row().toString()) + print(Channel.tablesdb("db1").table("table1").row("row1").toString()) + print(Channel.tablesdb("db1").table("table1").row("row1").update().toString()) + print(Channel.account()) + print(Channel.account("user123")) + print(Channel.buckets().file().toString()) + print(Channel.buckets("bucket1").file("file1").toString()) + print(Channel.buckets("bucket1").file("file1").delete().toString()) + print(Channel.functions().execution().toString()) + print(Channel.functions("func1").execution("exec1").toString()) + print(Channel.functions("func1").execution("exec1").create().toString()) + print(Channel.teams().toString()) + print(Channel.teams("team1").toString()) + print(Channel.teams("team1").create().toString()) + print(Channel.memberships().toString()) + print(Channel.memberships("membership1").toString()) + print(Channel.memberships("membership1").update().toString()) + // Operator helper tests print(Operator.increment(1)) print(Operator.increment(5, max: 100)) diff --git a/tests/languages/flutter/tests.dart b/tests/languages/flutter/tests.dart index 939c0ff14..7647041c6 100644 --- a/tests/languages/flutter/tests.dart +++ b/tests/languages/flutter/tests.dart @@ -247,6 +247,28 @@ void main() async { print(ID.unique()); print(ID.custom('custom_id')); + // Channel helper tests + print(Channel.database().collection().document().toString()); + print(Channel.database('db1').collection('col1').document('doc1').toString()); + print(Channel.database('db1').collection('col1').document('doc1').create().toString()); + print(Channel.tablesdb().table().row().toString()); + print(Channel.tablesdb('db1').table('table1').row('row1').toString()); + print(Channel.tablesdb('db1').table('table1').row('row1').update().toString()); + print(Channel.account()); + print(Channel.account('user123')); + print(Channel.buckets().file().toString()); + print(Channel.buckets('bucket1').file('file1').toString()); + print(Channel.buckets('bucket1').file('file1').delete().toString()); + print(Channel.functions().execution().toString()); + print(Channel.functions('func1').execution('exec1').toString()); + print(Channel.functions('func1').execution('exec1').create().toString()); + print(Channel.teams().toString()); + print(Channel.teams('team1').toString()); + print(Channel.teams('team1').create().toString()); + print(Channel.memberships().toString()); + print(Channel.memberships('membership1').toString()); + print(Channel.memberships('membership1').update().toString()); + // Operator helper tests print(Operator.increment(1)); print(Operator.increment(5, 100)); diff --git a/tests/languages/web/index.html b/tests/languages/web/index.html index 7e81de9c5..e8b4d48fe 100644 --- a/tests/languages/web/index.html +++ b/tests/languages/web/index.html @@ -21,7 +21,7 @@ let responseRealtime = 'Realtime failed!'; // Init SDK - const { Client, Foo, Bar, General, Realtime, Query, Permission, Role, ID, Operator, Condition, MockType } = Appwrite; + const { Client, Foo, Bar, General, Realtime, Query, Permission, Role, ID, Channel, Operator, Condition, MockType } = Appwrite; const client = new Client(); const foo = new Foo(client); @@ -319,6 +319,28 @@ console.log(ID.unique()); console.log(ID.custom('custom_id')); + // Channel helper tests + console.log(Channel.database().collection().document().toString()); + console.log(Channel.database('db1').collection('col1').document('doc1').toString()); + console.log(Channel.database('db1').collection('col1').document('doc1').create().toString()); + console.log(Channel.tablesdb().table().row().toString()); + console.log(Channel.tablesdb('db1').table('table1').row('row1').toString()); + console.log(Channel.tablesdb('db1').table('table1').row('row1').update().toString()); + console.log(Channel.account()); + console.log(Channel.account('user123')); + console.log(Channel.buckets().file().toString()); + console.log(Channel.buckets('bucket1').file('file1').toString()); + console.log(Channel.buckets('bucket1').file('file1').delete().toString()); + console.log(Channel.functions().execution().toString()); + console.log(Channel.functions('func1').execution('exec1').toString()); + console.log(Channel.functions('func1').execution('exec1').create().toString()); + console.log(Channel.teams().toString()); + console.log(Channel.teams('team1').toString()); + console.log(Channel.teams('team1').create().toString()); + console.log(Channel.memberships().toString()); + console.log(Channel.memberships('membership1').toString()); + console.log(Channel.memberships('membership1').update().toString()); + // Operator helper tests console.log(Operator.increment(1)); console.log(Operator.increment(5, 100)); diff --git a/tests/languages/web/node.js b/tests/languages/web/node.js index f4c7bed43..8e6ebd824 100644 --- a/tests/languages/web/node.js +++ b/tests/languages/web/node.js @@ -1,4 +1,4 @@ -const { Client, Foo, Bar, General, Query, Permission, Role, ID, Operator, Condition, MockType } = require('./dist/cjs/sdk.js'); +const { Client, Foo, Bar, General, Query, Permission, Role, ID, Channel, Operator, Condition, MockType } = require('./dist/cjs/sdk.js'); async function start() { let response; @@ -250,6 +250,28 @@ async function start() { console.log(ID.unique()); console.log(ID.custom('custom_id')); + // Channel helper tests + console.log(Channel.database().collection().document().toString()); + console.log(Channel.database('db1').collection('col1').document('doc1').toString()); + console.log(Channel.database('db1').collection('col1').document('doc1').create().toString()); + console.log(Channel.tablesdb().table().row().toString()); + console.log(Channel.tablesdb('db1').table('table1').row('row1').toString()); + console.log(Channel.tablesdb('db1').table('table1').row('row1').update().toString()); + console.log(Channel.account()); + console.log(Channel.account('user123')); + console.log(Channel.buckets().file().toString()); + console.log(Channel.buckets('bucket1').file('file1').toString()); + console.log(Channel.buckets('bucket1').file('file1').delete().toString()); + console.log(Channel.functions().execution().toString()); + console.log(Channel.functions('func1').execution('exec1').toString()); + console.log(Channel.functions('func1').execution('exec1').create().toString()); + console.log(Channel.teams().toString()); + console.log(Channel.teams('team1').toString()); + console.log(Channel.teams('team1').create().toString()); + console.log(Channel.memberships().toString()); + console.log(Channel.memberships('membership1').toString()); + console.log(Channel.memberships('membership1').update().toString()); + // Operator helper tests console.log(Operator.increment(1)); console.log(Operator.increment(5, 100));