diff --git a/src/SDK/Language/Android.php b/src/SDK/Language/Android.php index 295f63268a..55d91d5c57 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 f4b64f291d..108598369f 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 aa819f2800..a17254a332 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', @@ -295,6 +300,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 a7b61efa51..3d3bfb2320 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 4d3de52489..5666520018 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 0000000000..94b3426cbf --- /dev/null +++ b/templates/android/library/src/main/java/io/package/Channel.kt.twig @@ -0,0 +1,261 @@ +package {{ sdk.namespace | caseDot }} + +// Marker interfaces for type safety +public interface _Root +public interface _Database +public interface _Collection +public interface _Document +public interface _TablesDB +public interface _Table +public interface _Row +public interface _Bucket +public interface _File +public interface _Func +public interface _Execution +public interface _Team +public interface _Membership +public interface _Resolved + +// Union type for actionable channels +typealias Actionable = _Document + +/** + * Helper for normalizing IDs + */ +private fun normalize(id: String): String { + val trimmed = id.trim() + return if (trimmed.isEmpty()) "*" else trimmed +} + +/** + * Channel class with generic type parameter for type-safe method chaining + */ +class Channel private constructor( + private val segments: List +) { + /** + * Internal helper to transition to next state with segment and ID + * Public for extension function access + */ + fun next(segment: String, id: String = "*"): Channel { + return Channel(segments + listOf(segment, normalize(id))) + } + + /** + * Internal helper for terminal actions (no ID segment) + * Public for extension function access + */ + fun resolve(action: String): Channel<_Resolved> { + return Channel(segments + listOf(action)) + } + + /** + * Convert channel to string representation + */ + override fun toString(): String = segments.joinToString(".") + + companion object { + fun database(id: String = "*"): Channel<_Database> = + Channel(listOf("databases", normalize(id))) + + fun tablesdb(id: String = "*"): Channel<_TablesDB> = + Channel(listOf("tablesdb", normalize(id))) + + fun bucket(id: String = "*"): Channel<_Bucket> = + Channel(listOf("buckets", normalize(id))) + + fun function(id: String = "*"): Channel<_Func> = + Channel(listOf("functions", normalize(id))) + + fun team(id: String = "*"): Channel<_Team> = + Channel(listOf("teams", normalize(id))) + + fun membership(id: String = "*"): Channel<_Membership> = + Channel(listOf("memberships", normalize(id))) + + fun account(userId: String = ""): String { + val id = normalize(userId) + return if (id == "*") "account" else "account.$id" + } + + // Global events + const val documents = "documents" + const val rows = "rows" + const val files = "files" + const val executions = "executions" + } +} + +// --- DATABASE ROUTE --- +// Extension functions restricted by receiver type + +/** + * Only available on Channel<_Database> + */ +fun Channel<_Database>.collection(id: String = "*"): Channel<_Collection> = + this.next("collections", id) + +/** + * Only available on Channel<_Collection> + */ +fun Channel<_Collection>.document(id: String = "*"): Channel<_Document> = + this.next("documents", id) + +// --- TABLESDB ROUTE --- + +/** + * Only available on Channel<_TablesDB> + */ +fun Channel<_TablesDB>.table(id: String = "*"): Channel<_Table> = + this.next("tables", id) + +/** + * Only available on Channel<_Table> + */ +fun Channel<_Table>.row(id: String = "*"): Channel<_Row> = + this.next("rows", id) + +// --- BUCKET ROUTE --- + +/** + * Only available on Channel<_Bucket> + */ +fun Channel<_Bucket>.file(id: String = "*"): Channel<_File> = + this.next("files", id) + +// --- FUNCTION ROUTE --- + +/** + * Only available on Channel<_Func> + */ +fun Channel<_Func>.execution(id: String = "*"): Channel<_Execution> = + this.next("executions", id) + +// --- TERMINAL ACTIONS --- +// Restricted to Actionable types (_Document, _Row, _File, _Execution, _Team, _Membership) + +/** + * Only available on Channel<_Document> + */ +@JvmName("createDocument") +fun Channel<_Document>.create(): Channel<_Resolved> = + this.resolve("create") + +/** + * Only available on Channel<_Document> + */ +@JvmName("updateDocument") +fun Channel<_Document>.update(): Channel<_Resolved> = + this.resolve("update") + +/** + * Only available on Channel<_Document> + */ +@JvmName("deleteDocument") +fun Channel<_Document>.delete(): Channel<_Resolved> = + this.resolve("delete") + +/** + * Only available on Channel<_Row> + */ +@JvmName("createRow") +fun Channel<_Row>.create(): Channel<_Resolved> = + this.resolve("create") + +/** + * Only available on Channel<_Row> + */ +@JvmName("updateRow") +fun Channel<_Row>.update(): Channel<_Resolved> = + this.resolve("update") + +/** + * Only available on Channel<_Row> + */ +@JvmName("deleteRow") +fun Channel<_Row>.delete(): Channel<_Resolved> = + this.resolve("delete") + +/** + * Only available on Channel<_File> + */ +@JvmName("createFile") +fun Channel<_File>.create(): Channel<_Resolved> = + this.resolve("create") + +/** + * Only available on Channel<_File> + */ +@JvmName("updateFile") +fun Channel<_File>.update(): Channel<_Resolved> = + this.resolve("update") + +/** + * Only available on Channel<_File> + */ +@JvmName("deleteFile") +fun Channel<_File>.delete(): Channel<_Resolved> = + this.resolve("delete") + +/** + * Only available on Channel<_Execution> + */ +@JvmName("createExecution") +fun Channel<_Execution>.create(): Channel<_Resolved> = + this.resolve("create") + +/** + * Only available on Channel<_Execution> + */ +@JvmName("updateExecution") +fun Channel<_Execution>.update(): Channel<_Resolved> = + this.resolve("update") + +/** + * Only available on Channel<_Execution> + */ +@JvmName("deleteExecution") +fun Channel<_Execution>.delete(): Channel<_Resolved> = + this.resolve("delete") + +/** + * Only available on Channel<_Team> + */ +@JvmName("createTeam") +fun Channel<_Team>.create(): Channel<_Resolved> = + this.resolve("create") + +/** + * Only available on Channel<_Team> + */ +@JvmName("updateTeam") +fun Channel<_Team>.update(): Channel<_Resolved> = + this.resolve("update") + +/** + * Only available on Channel<_Team> + */ +@JvmName("deleteTeam") +fun Channel<_Team>.delete(): Channel<_Resolved> = + this.resolve("delete") + +/** + * Only available on Channel<_Membership> + */ +@JvmName("createMembership") +fun Channel<_Membership>.create(): Channel<_Resolved> = + this.resolve("create") + +/** + * Only available on Channel<_Membership> + */ +@JvmName("updateMembership") +fun Channel<_Membership>.update(): Channel<_Resolved> = + this.resolve("update") + +/** + * Only available on Channel<_Membership> + */ +@JvmName("deleteMembership") +fun Channel<_Membership>.delete(): Channel<_Resolved> = + this.resolve("delete") 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 26de08d0ee..a6f500f2ef 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 @@ -2,6 +2,7 @@ package {{ sdk.namespace | caseDot }}.services import {{ sdk.namespace | caseDot }}.Service import {{ sdk.namespace | caseDot }}.Client +import {{ sdk.namespace | caseDot }}.Channel import {{ sdk.namespace | caseDot }}.exceptions.{{ spec.title | caseUcfirst }}Exception import {{ sdk.namespace | caseDot }}.extensions.forEachAsync import {{ sdk.namespace | caseDot }}.extensions.fromJson @@ -109,6 +110,27 @@ class Realtime(client: Client) : Service(client), CoroutineScope { else -> 60000L } + /** + * Convert channel value to string + * All Channel instances have toString() method + */ + private fun channelToString(channel: Any): String { + return when { + channel is String -> channel + channel is Channel<*> -> channel.toString() + else -> channel.toString() + } + } + + fun subscribe( + vararg channels: Channel<*>, + callback: (RealtimeResponseEvent) -> Unit, + ) = subscribe( + channels = channels.map { channelToString(it) }.toTypedArray(), + Any::class.java, + callback + ) + fun subscribe( vararg channels: String, callback: (RealtimeResponseEvent) -> Unit, @@ -118,6 +140,18 @@ class Realtime(client: Client) : Service(client), CoroutineScope { callback ) + fun subscribe( + vararg channels: Channel<*>, + 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 0000000000..f81d8dc5b1 --- /dev/null +++ b/templates/apple/Sources/Channel.swift.twig @@ -0,0 +1,249 @@ +import Foundation + +// Marker structs for type safety (structs work with == constraints in extensions) +public struct _Root {} +public struct _Database {} +public struct _Collection {} +public struct _Document {} +public struct _TablesDB {} +public struct _Table {} +public struct _Row {} +public struct _Bucket {} +public struct _File {} +public struct _Func {} +public struct _Execution {} +public struct _Team {} +public struct _Membership {} +public struct _Resolved {} + +// Helper for normalizing IDs +private func normalize(_ id: String) -> String { + let trimmed = id.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "*" : trimmed +} + +/// Channel class with generic type parameter for type-safe method chaining +public class RealtimeChannel { + private let segments: [String] + + internal init(_ segments: [String]) { + self.segments = segments + } + + /// Internal helper to transition to next state with segment and ID + internal func next(_ segment: String, _ id: String = "*") -> RealtimeChannel { + return RealtimeChannel(segments + [segment, normalize(id)]) + } + + /// Internal helper for terminal actions (no ID segment) + internal func resolve(_ action: String) -> RealtimeChannel<_Resolved> { + return RealtimeChannel<_Resolved>(segments + [action]) + } + + /// Convert channel to string representation + public func toString() -> String { + return segments.joined(separator: ".") + } +} + +/// Non-generic Channel enum for static factory methods +public enum Channel { + + // MARK: - Root Factories + + public static func database(_ id: String = "*") -> RealtimeChannel<_Database> { + return RealtimeChannel<_Database>(["databases", normalize(id)]) + } + + public static func tablesdb(_ id: String = "*") -> RealtimeChannel<_TablesDB> { + return RealtimeChannel<_TablesDB>(["tablesdb", normalize(id)]) + } + + public static func bucket(_ id: String = "*") -> RealtimeChannel<_Bucket> { + return RealtimeChannel<_Bucket>(["buckets", normalize(id)]) + } + + public static func function(_ id: String = "*") -> RealtimeChannel<_Func> { + return RealtimeChannel<_Func>(["functions", normalize(id)]) + } + + public static func team(_ id: String = "*") -> RealtimeChannel<_Team> { + return RealtimeChannel<_Team>(["teams", normalize(id)]) + } + + public static func membership(_ id: String = "*") -> RealtimeChannel<_Membership> { + return RealtimeChannel<_Membership>(["memberships", normalize(id)]) + } + + public static func account(_ userId: String = "") -> String { + let id = normalize(userId) + return id == "*" ? "account" : "account.\(id)" + } + + // Global events + public static var documents: String { "documents" } + public static var rows: String { "rows" } + public static var files: String { "files" } + public static var executions: String { "executions" } +} + +// MARK: - DATABASE ROUTE +// Protocol extensions restricted by receiver type + +/// Only available on RealtimeChannel<_Database> +extension RealtimeChannel where T == _Database { + public func collection(_ id: String = "*") -> RealtimeChannel<_Collection> { + return self.next("collections", id) + } +} + +/// Only available on RealtimeChannel<_Collection> +extension RealtimeChannel where T == _Collection { + public func document(_ id: String = "*") -> RealtimeChannel<_Document> { + return self.next("documents", id) + } +} + +// MARK: - TABLESDB ROUTE + +/// Only available on RealtimeChannel<_TablesDB> +extension RealtimeChannel where T == _TablesDB { + public func table(_ id: String = "*") -> RealtimeChannel<_Table> { + return self.next("tables", id) + } +} + +/// Only available on RealtimeChannel<_Table> +extension RealtimeChannel where T == _Table { + public func row(_ id: String = "*") -> RealtimeChannel<_Row> { + return self.next("rows", id) + } +} + +// MARK: - BUCKET ROUTE + +/// Only available on RealtimeChannel<_Bucket> +extension RealtimeChannel where T == _Bucket { + public func file(_ id: String = "*") -> RealtimeChannel<_File> { + return self.next("files", id) + } +} + +// MARK: - FUNCTION ROUTE + +/// Only available on RealtimeChannel<_Func> +extension RealtimeChannel where T == _Func { + public func execution(_ id: String = "*") -> RealtimeChannel<_Execution> { + return self.next("executions", id) + } +} + +// MARK: - TERMINAL ACTIONS +// Restricted to actionable types (_Document, _Row, _File, _Execution, _Team, _Membership) + +/// Only available on RealtimeChannel<_Document> +extension RealtimeChannel where T == _Document { + public func create() -> RealtimeChannel<_Resolved> { + return self.resolve("create") + } + + public func update() -> RealtimeChannel<_Resolved> { + return self.resolve("update") + } + + public func delete() -> RealtimeChannel<_Resolved> { + return self.resolve("delete") + } +} + +/// Only available on RealtimeChannel<_Row> +extension RealtimeChannel where T == _Row { + public func create() -> RealtimeChannel<_Resolved> { + return self.resolve("create") + } + + public func update() -> RealtimeChannel<_Resolved> { + return self.resolve("update") + } + + public func delete() -> RealtimeChannel<_Resolved> { + return self.resolve("delete") + } +} + +/// Only available on RealtimeChannel<_File> +extension RealtimeChannel where T == _File { + public func create() -> RealtimeChannel<_Resolved> { + return self.resolve("create") + } + + public func update() -> RealtimeChannel<_Resolved> { + return self.resolve("update") + } + + public func delete() -> RealtimeChannel<_Resolved> { + return self.resolve("delete") + } +} + +/// Only available on RealtimeChannel<_Execution> +extension RealtimeChannel where T == _Execution { + public func create() -> RealtimeChannel<_Resolved> { + return self.resolve("create") + } + + public func update() -> RealtimeChannel<_Resolved> { + return self.resolve("update") + } + + public func delete() -> RealtimeChannel<_Resolved> { + return self.resolve("delete") + } +} + +/// Only available on RealtimeChannel<_Team> +extension RealtimeChannel where T == _Team { + public func create() -> RealtimeChannel<_Resolved> { + return self.resolve("create") + } + + public func update() -> RealtimeChannel<_Resolved> { + return self.resolve("update") + } + + public func delete() -> RealtimeChannel<_Resolved> { + return self.resolve("delete") + } +} + +/// Only available on RealtimeChannel<_Membership> +extension RealtimeChannel where T == _Membership { + public func create() -> RealtimeChannel<_Resolved> { + return self.resolve("create") + } + + public func update() -> RealtimeChannel<_Resolved> { + return self.resolve("update") + } + + public func delete() -> RealtimeChannel<_Resolved> { + return self.resolve("delete") + } +} + +// MARK: - Protocol for backward compatibility + +/// Protocol for channel values that can be converted to strings +public protocol ChannelValue { + func toString() -> String +} + +// Make String conform to ChannelValue +extension String: ChannelValue { + public func toString() -> String { + return self + } +} + +// Make RealtimeChannel conform to ChannelValue +extension RealtimeChannel: ChannelValue {} diff --git a/templates/apple/Sources/Services/Realtime.swift.twig b/templates/apple/Sources/Services/Realtime.swift.twig index 5c2c2c401b..992af98fba 100644 --- a/templates/apple/Sources/Services/Realtime.swift.twig +++ b/templates/apple/Sources/Services/Realtime.swift.twig @@ -117,8 +117,16 @@ open class Realtime : Service { } } + /** + * Convert channel value to string + * All Channel instances and String conform to ChannelValue + */ + 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 +137,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 ) @@ -205,7 +225,7 @@ open class Realtime : Service { extension Realtime: WebSocketClientDelegate { - public func onOpen(channel: Channel) { + public func onOpen(channel: NIO.Channel) { self.reconnectAttempts = 0 onOpenCallbacks.forEach { $0() } startHeartbeat() @@ -225,7 +245,7 @@ extension Realtime: WebSocketClientDelegate { } } - public func onClose(channel: Channel, data: Data) async throws { + public func onClose(channel: NIO.Channel, data: Data) async throws { stopHeartbeat() onCloseCallbacks.forEach { $0() } diff --git a/templates/dart/test/channel_test.dart.twig b/templates/dart/test/channel_test.dart.twig new file mode 100644 index 0000000000..0e67e33923 --- /dev/null +++ b/templates/dart/test/channel_test.dart.twig @@ -0,0 +1,158 @@ +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 builder', () { + final builder = Channel.database(); + expect(builder.channel, 'databases.*'); + }); + + test('returns database channel with specific ID', () { + final builder = Channel.database('db1'); + expect(builder.channel, 'databases.db1'); + }); + + test('returns database channel with collection and document', () { + final channel = Channel.database('db1').collection('col1').document('doc1'); + expect(channel.channel, 'databases.db1.collections.col1.documents.doc1'); + }); + + test('returns database channel with action', () { + final channel = Channel.database('db1').collection('col1').document('doc1').create(); + expect(channel.toString(), 'databases.db1.collections.col1.documents.doc1.create'); + }); + }); + + group('tablesdb()', () { + test('returns tablesdb channel builder', () { + final builder = Channel.tablesdb(); + expect(builder.channel, 'tablesdb.*'); + }); + + test('returns tablesdb channel with specific ID', () { + final builder = Channel.tablesdb('db1'); + expect(builder.channel, 'tablesdb.db1'); + }); + + test('returns tablesdb channel with table and row', () { + final channel = Channel.tablesdb('db1').table('table1').row('row1'); + expect(channel.channel, 'tablesdb.db1.tables.table1.rows.row1'); + }); + + test('returns tablesdb channel with action', () { + final channel = Channel.tablesdb('db1').table('table1').row('row1').update(); + expect(channel.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('bucket()', () { + test('returns buckets channel builder', () { + final builder = Channel.bucket(); + expect(builder.channel, 'buckets.*'); + }); + + test('returns buckets channel with specific ID', () { + final builder = Channel.bucket('bucket1'); + expect(builder.channel, 'buckets.bucket1'); + }); + + test('returns buckets channel with file', () { + final channel = Channel.bucket('bucket1').file('file1'); + expect(channel.channel, 'buckets.bucket1.files.file1'); + }); + + test('returns buckets channel with action', () { + final channel = Channel.bucket('bucket1').file('file1').delete(); + expect(channel.toString(), 'buckets.bucket1.files.file1.delete'); + }); + }); + + group('functions()', () { + test('returns functions channel builder', () { + final builder = Channel.function(); + expect(builder.channel, 'functions.*'); + }); + + test('returns functions channel with specific ID', () { + final builder = Channel.function('func1'); + expect(builder.channel, 'functions.func1'); + }); + + test('returns functions channel with execution', () { + final channel = Channel.function('func1').execution('exec1'); + expect(channel.channel, 'functions.func1.executions.exec1'); + }); + + test('returns functions channel with action', () { + final channel = Channel.function('func1').execution('exec1').create(); + expect(channel.toString(), 'functions.func1.executions.exec1.create'); + }); + }); + + group('teams()', () { + test('returns teams channel builder', () { + final builder = Channel.team(); + expect(builder.channel, 'teams.*'); + }); + + test('returns teams channel with specific team ID', () { + final builder = Channel.team('team1'); + expect(builder.channel, 'teams.team1'); + }); + + test('returns teams channel with action', () { + final channel = Channel.team('team1').create(); + expect(channel.toString(), 'teams.team1.create'); + }); + }); + + group('memberships()', () { + test('returns memberships channel builder', () { + final builder = Channel.membership(); + expect(builder.channel, 'memberships.*'); + }); + + test('returns memberships channel with specific membership ID', () { + final builder = Channel.membership('membership1'); + expect(builder.channel, 'memberships.membership1'); + }); + + test('returns memberships channel with action', () { + final channel = Channel.membership('membership1').update(); + expect(channel.toString(), 'memberships.membership1.update'); + }); + }); + + group('global events', () { + test('returns documents channel', () { + expect(Channel.documents, 'documents'); + }); + + test('returns rows channel', () { + expect(Channel.rows, 'rows'); + }); + + test('returns files channel', () { + expect(Channel.files, 'files'); + }); + + test('returns executions channel', () { + expect(Channel.executions, 'executions'); + }); + }); +} \ 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 0000000000..7f62ad6063 --- /dev/null +++ b/templates/flutter/lib/channel.dart.twig @@ -0,0 +1,154 @@ +part of {{ language.params.packageName }}; + +// Marker classes for type safety +class _Root {} +class _Database {} +class _Collection {} +class _Document {} +class _TablesDB {} +class _Table {} +class _Row {} +class _Bucket {} +class _File {} +class _Func {} +class _Execution {} +class _Team {} +class _Membership {} +class _Resolved {} + +// Helper function for normalizing ID +String _normalize(String id) => id.trim().isEmpty ? '*' : id.trim(); + +/// Channel class with generic type parameter for type-safe method chaining +class Channel { + final List _segments; + + Channel._(this._segments); + + /// Internal helper to transition to next state with segment and ID + Channel _next(String segment, [String id = '*']) { + return Channel._([..._segments, segment, _normalize(id)]); + } + + /// Internal helper for terminal actions (no ID segment) + Channel<_Resolved> _resolve(String action) { + return Channel<_Resolved>._([..._segments, action]); + } + + @override + String toString() => _segments.join('.'); + + // --- ROOT FACTORIES --- + static Channel<_Database> database([String id = '*']) => + Channel<_Database>._(['databases', _normalize(id)]); + + static Channel<_TablesDB> tablesdb([String id = '*']) => + Channel<_TablesDB>._(['tablesdb', _normalize(id)]); + + static Channel<_Bucket> bucket([String id = '*']) => + Channel<_Bucket>._(['buckets', _normalize(id)]); + + static Channel<_Func> function([String id = '*']) => + Channel<_Func>._(['functions', _normalize(id)]); + + static Channel<_Team> team([String id = '*']) => + Channel<_Team>._(['teams', _normalize(id)]); + + static Channel<_Membership> membership([String id = '*']) => + Channel<_Membership>._(['memberships', _normalize(id)]); + + static String account([String userId = '']) { + final id = _normalize(userId); + return id == '*' ? 'account' : 'account.$id'; + } + + // Global events + static String get documents => 'documents'; + static String get rows => 'rows'; + static String get files => 'files'; + static String get executions => 'executions'; +} + +// --- DATABASE ROUTE --- +// Extension methods restricted by receiver type + +/// Only available on Channel<_Database> +extension DatabaseChannel on Channel<_Database> { + Channel<_Collection> collection([String id = '*']) => _next<_Collection>('collections', id); +} + +/// Only available on Channel<_Collection> +extension CollectionChannel on Channel<_Collection> { + Channel<_Document> document([String id = '*']) => _next<_Document>('documents', id); +} + +// --- TABLESDB ROUTE --- + +/// Only available on Channel<_TablesDB> +extension TablesDBChannel on Channel<_TablesDB> { + Channel<_Table> table([String id = '*']) => _next<_Table>('tables', id); +} + +/// Only available on Channel<_Table> +extension TableChannel on Channel<_Table> { + Channel<_Row> row([String id = '*']) => _next<_Row>('rows', id); +} + +// --- BUCKET ROUTE --- + +/// Only available on Channel<_Bucket> +extension BucketChannel on Channel<_Bucket> { + Channel<_File> file([String id = '*']) => _next<_File>('files', id); +} + +// --- FUNCTION ROUTE --- + +/// Only available on Channel<_Func> +extension FuncChannel on Channel<_Func> { + Channel<_Execution> execution([String id = '*']) => _next<_Execution>('executions', id); +} + +// --- TERMINAL ACTIONS --- +// Restricted to actionable types (_Document, _Row, _File, _Execution, _Team, _Membership) + +/// Only available on Channel<_Document> +extension DocumentChannel on Channel<_Document> { + Channel<_Resolved> create() => _resolve('create'); + Channel<_Resolved> update() => _resolve('update'); + Channel<_Resolved> delete() => _resolve('delete'); +} + +/// Only available on Channel<_Row> +extension RowChannel on Channel<_Row> { + Channel<_Resolved> create() => _resolve('create'); + Channel<_Resolved> update() => _resolve('update'); + Channel<_Resolved> delete() => _resolve('delete'); +} + +/// Only available on Channel<_File> +extension FileChannel on Channel<_File> { + Channel<_Resolved> create() => _resolve('create'); + Channel<_Resolved> update() => _resolve('update'); + Channel<_Resolved> delete() => _resolve('delete'); +} + +/// Only available on Channel<_Execution> +extension ExecutionChannel on Channel<_Execution> { + Channel<_Resolved> create() => _resolve('create'); + Channel<_Resolved> update() => _resolve('update'); + Channel<_Resolved> delete() => _resolve('delete'); +} + +/// Only available on Channel<_Team> +extension TeamChannel on Channel<_Team> { + Channel<_Resolved> create() => _resolve('create'); + Channel<_Resolved> update() => _resolve('update'); + Channel<_Resolved> delete() => _resolve('delete'); +} + +/// Only available on Channel<_Membership> +extension MembershipChannel on Channel<_Membership> { + Channel<_Resolved> create() => _resolve('create'); + Channel<_Resolved> update() => _resolve('update'); + Channel<_Resolved> delete() => _resolve('delete'); +} diff --git a/templates/flutter/lib/package.dart.twig b/templates/flutter/lib/package.dart.twig index 51965ccb99..515452fa64 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 e02d89a563..9b1f62e05d 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.bucket('bucket').file('file').update(), + /// 'account.*' + /// ]); + /// ``` + RealtimeSubscription subscribe(List channels); // Object can be String or Channel /// 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 f9fe5e44bc..0b48c6bfc6 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); // Object can be String or Channel } diff --git a/templates/flutter/lib/src/realtime_browser.dart.twig b/templates/flutter/lib/src/realtime_browser.dart.twig index aa6a3ad149..5d08890664 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 27539b251c..dd881725b0 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 96e30699b0..7919e97a17 100644 --- a/templates/flutter/lib/src/realtime_mixin.dart.twig +++ b/templates/flutter/lib/src/realtime_mixin.dart.twig @@ -168,18 +168,25 @@ mixin RealtimeMixin { ); } - RealtimeSubscription subscribeTo(List channels) { + /// Convert channel value to string + /// Handles String and Channel instances (which have toString()) + String _channelToString(Object channel) { + return channel is String ? channel : 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 0000000000..968b67a827 --- /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('bucket()', () { + test('returns buckets channel with defaults', () { + expect(Channel.bucket().file().toString(), 'buckets.*.files.*'); + }); + + test('returns buckets channel with specific IDs', () { + expect(Channel.bucket('bucket1').file('file1').toString(), + 'buckets.bucket1.files.file1'); + }); + + test('returns buckets channel with action', () { + expect(Channel.bucket('bucket1').file('file1').delete().toString(), + 'buckets.bucket1.files.file1.delete'); + }); + }); + + group('functions()', () { + test('returns functions channel with defaults', () { + expect(Channel.function().execution().toString(), 'functions.*.executions.*'); + }); + + test('returns functions channel with specific IDs', () { + expect(Channel.function('func1').execution('exec1').toString(), + 'functions.func1.executions.exec1'); + }); + + test('returns functions channel with action', () { + expect(Channel.function('func1').execution('exec1').create().toString(), + 'functions.func1.executions.exec1.create'); + }); + }); + + group('teams()', () { + test('returns teams channel with default', () { + expect(Channel.team().toString(), 'teams.*'); + }); + + test('returns teams channel with specific team ID', () { + expect(Channel.team('team1').toString(), 'teams.team1'); + }); + + test('returns teams channel with action', () { + expect(Channel.team('team1').create().toString(), 'teams.team1.create'); + }); + }); + + group('memberships()', () { + test('returns memberships channel with default', () { + expect(Channel.membership().toString(), 'memberships.*'); + }); + + test('returns memberships channel with specific membership ID', () { + expect(Channel.membership('membership1').toString(), 'memberships.membership1'); + }); + + test('returns memberships channel with action', () { + expect(Channel.membership('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 0000000000..1d68ded770 --- /dev/null +++ b/templates/react-native/src/channel.ts.twig @@ -0,0 +1,134 @@ +interface Root {} +interface Database { _db: any } +interface Collection { _coll: any } +interface Document { _doc: any } +interface TablesDB { _tdb: any } +interface Table { _tbl: any } +interface Row { _row: any } +interface Bucket { _bkt: any } +interface File { _file: any } +interface Func { _fn: any } +interface Execution { _exec: any } +interface Team { _team: any } +interface Membership { _mem: any } +interface Resolved { _res: any } + +type Actionable = Document | Row | File | Execution | Team | Membership; + +function normalize(id: string): string { + const trimmed = id.trim(); + return trimmed === "" ? "*" : trimmed; +} + +export class Channel { + declare _type: T; + + private constructor(private readonly segments: string[]) {} + + private next(segment: string, id: string = "*"): Channel { + return new Channel([...this.segments, segment, normalize(id)]) as any; + } + + private resolve(action: string): Channel { + return new Channel([...this.segments, action]) as any; + } + + toString(): string { + return this.segments.join("."); + } + + // --- DATABASE ROUTE --- + // Only available on Channel + collection(this: Channel, id: string = "*"): Channel { + return this.next("collections", id); + } + + // Only available on Channel + document(this: Channel, id: string = "*"): Channel { + return this.next("documents", id); + } + + // --- TABLESDB ROUTE --- + table(this: Channel, id: string = "*"): Channel { + return this.next
("tables", id); + } + + row(this: Channel
, id: string = "*"): Channel { + return this.next("rows", id); + } + + // --- BUCKET ROUTE --- + file(this: Channel, id: string = "*"): Channel { + return this.next("files", id); + } + + // --- FUNCTION ROUTE --- + execution(this: Channel, id: string = "*"): Channel { + return this.next("executions", id); + } + + // --- TERMINAL ACTIONS --- + // Restricted to the Actionable union + create(this: Channel): Channel { + return this.resolve("create"); + } + + update(this: Channel): Channel { + return this.resolve("update"); + } + + delete(this: Channel): Channel { + return this.resolve("delete"); + } + + // --- ROOT FACTORIES --- + static database(id: string = "*") { + return new Channel(["databases", normalize(id)]); + } + + static tablesdb(id: string = "*") { + return new Channel(["tablesdb", normalize(id)]); + } + + static bucket(id: string = "*") { + return new Channel(["buckets", normalize(id)]); + } + + static function(id: string = "*") { + return new Channel(["functions", normalize(id)]); + } + + static team(id: string = "*") { + return new Channel(["teams", normalize(id)]); + } + + static membership(id: string = "*") { + return new Channel(["memberships", normalize(id)]); + } + + static account(userId: string = ""): string { + const id = normalize(userId); + return id === "*" ? "account" : `account.${id}`; + } + + // Global events + static get documents(): string { + return "documents"; + } + + static get rows(): string { + return "rows"; + } + + static get files(): string { + return "files"; + } + + static get executions(): string { + return "executions"; + } +} + +// Export types for backward compatibility with realtime +export type ActionableChannel = Channel | Channel | Channel | Channel | Channel | Channel; +export type ResolvedChannel = Channel; diff --git a/templates/react-native/src/index.ts.twig b/templates/react-native/src/index.ts.twig index 8a9b5aa947..e27f296499 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 0e6c3e7ee6..7c004d295c 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 d1556acb99..d6327e0d97 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 0000000000..1d68ded770 --- /dev/null +++ b/templates/web/src/channel.ts.twig @@ -0,0 +1,134 @@ +interface Root {} +interface Database { _db: any } +interface Collection { _coll: any } +interface Document { _doc: any } +interface TablesDB { _tdb: any } +interface Table { _tbl: any } +interface Row { _row: any } +interface Bucket { _bkt: any } +interface File { _file: any } +interface Func { _fn: any } +interface Execution { _exec: any } +interface Team { _team: any } +interface Membership { _mem: any } +interface Resolved { _res: any } + +type Actionable = Document | Row | File | Execution | Team | Membership; + +function normalize(id: string): string { + const trimmed = id.trim(); + return trimmed === "" ? "*" : trimmed; +} + +export class Channel { + declare _type: T; + + private constructor(private readonly segments: string[]) {} + + private next(segment: string, id: string = "*"): Channel { + return new Channel([...this.segments, segment, normalize(id)]) as any; + } + + private resolve(action: string): Channel { + return new Channel([...this.segments, action]) as any; + } + + toString(): string { + return this.segments.join("."); + } + + // --- DATABASE ROUTE --- + // Only available on Channel + collection(this: Channel, id: string = "*"): Channel { + return this.next("collections", id); + } + + // Only available on Channel + document(this: Channel, id: string = "*"): Channel { + return this.next("documents", id); + } + + // --- TABLESDB ROUTE --- + table(this: Channel, id: string = "*"): Channel
{ + return this.next
("tables", id); + } + + row(this: Channel
, id: string = "*"): Channel { + return this.next("rows", id); + } + + // --- BUCKET ROUTE --- + file(this: Channel, id: string = "*"): Channel { + return this.next("files", id); + } + + // --- FUNCTION ROUTE --- + execution(this: Channel, id: string = "*"): Channel { + return this.next("executions", id); + } + + // --- TERMINAL ACTIONS --- + // Restricted to the Actionable union + create(this: Channel): Channel { + return this.resolve("create"); + } + + update(this: Channel): Channel { + return this.resolve("update"); + } + + delete(this: Channel): Channel { + return this.resolve("delete"); + } + + // --- ROOT FACTORIES --- + static database(id: string = "*") { + return new Channel(["databases", normalize(id)]); + } + + static tablesdb(id: string = "*") { + return new Channel(["tablesdb", normalize(id)]); + } + + static bucket(id: string = "*") { + return new Channel(["buckets", normalize(id)]); + } + + static function(id: string = "*") { + return new Channel(["functions", normalize(id)]); + } + + static team(id: string = "*") { + return new Channel(["teams", normalize(id)]); + } + + static membership(id: string = "*") { + return new Channel(["memberships", normalize(id)]); + } + + static account(userId: string = ""): string { + const id = normalize(userId); + return id === "*" ? "account" : `account.${id}`; + } + + // Global events + static get documents(): string { + return "documents"; + } + + static get rows(): string { + return "rows"; + } + + static get files(): string { + return "files"; + } + + static get executions(): string { + return "executions"; + } +} + +// Export types for backward compatibility with realtime +export type ActionableChannel = Channel | Channel | Channel | Channel | Channel | Channel; +export type ResolvedChannel = Channel; diff --git a/templates/web/src/client.ts.twig b/templates/web/src/client.ts.twig index 06bb37d35b..645f42f59a 100644 --- a/templates/web/src/client.ts.twig +++ b/templates/web/src/client.ts.twig @@ -1,4 +1,5 @@ import { Models } from './models'; +import { Channel, ActionableChannel, ResolvedChannel } from './channel'; import JSONbigModule from 'json-bigint'; const JSONbig = JSONbigModule({ useNativeBigInt: true }); @@ -578,8 +579,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[]|Channel|ActionableChannel|ResolvedChannel|(Channel|ActionableChannel|ResolvedChannel)[]} channels + * Channel to subscribe - pass a single channel as a string or Channel builder instance, or multiple with an array. * * Possible channels are: * - account @@ -597,16 +598,35 @@ class Client { * - teams.[ID] * - memberships * - memberships.[ID] + * + * You can also use Channel builders: + * - Channel.database('db').collection('col').document('doc').create() + * - Channel.bucket('bucket').file('file').update() + * - Channel.function('func').execution('exec').delete() + * - Channel.team('team').create() + * - Channel.membership('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[] | Channel | ActionableChannel | ResolvedChannel | (Channel | ActionableChannel | ResolvedChannel)[], 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; + } + // All Channel instances have toString() method + if (ch && typeof (ch as Channel).toString === 'function') { + return (ch as Channel).toString(); + } + // Fallback to generic string conversion + 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 }); @@ -614,7 +634,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 c9aba90fc7..54ad22739d 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 d30e716ea1..009f274d8e 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 { Channel, ActionableChannel, ResolvedChannel } 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 | Channel | ActionableChannel | ResolvedChannel} channel - Channel value (string or Channel builder instance) + * @returns {string} Channel string representation + */ + private channelToString(channel: string | Channel | ActionableChannel | ResolvedChannel): string { + if (typeof channel === 'string') { + return channel; + } + // All Channel instances have toString() method + if (channel && typeof (channel as Channel).toString === 'function') { + return (channel as Channel).toString(); + } + return String(channel); + } + /** * Subscribe to a single channel * - * @param {string} channel - Channel name to subscribe to + * @param {string | Channel | ActionableChannel | ResolvedChannel} 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: string | Channel | ActionableChannel | ResolvedChannel, callback: (event: RealtimeResponseEvent) => void ): Promise; /** * Subscribe to multiple channels * - * @param {string[]} channels - Array of channel names to subscribe to + * @param {(string | Channel | ActionableChannel | ResolvedChannel)[]} 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 | Channel | ActionableChannel | ResolvedChannel)[], callback: (event: RealtimeResponseEvent) => void ): Promise; /** * Subscribe to a single channel with typed payload * - * @param {string} channel - Channel name to subscribe to + * @param {string | Channel | ActionableChannel | ResolvedChannel} 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: string | Channel | ActionableChannel | ResolvedChannel, callback: (event: RealtimeResponseEvent) => void ): Promise; /** * Subscribe to multiple channels with typed payload * - * @param {string[]} channels - Array of channel names to subscribe to + * @param {(string | Channel | ActionableChannel | ResolvedChannel)[]} 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 | Channel | ActionableChannel | ResolvedChannel)[], callback: (event: RealtimeResponseEvent) => void ): Promise; public async subscribe( - channelsOrChannel: string | string[], + channelsOrChannel: string | Channel | ActionableChannel | ResolvedChannel | (string | Channel | ActionableChannel | ResolvedChannel)[], 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 66d5e7248a..0be9d0edca 100644 --- a/tests/Android14Java11Test.php +++ b/tests/Android14Java11Test.php @@ -34,6 +34,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 c842146fbd..08369666ef 100644 --- a/tests/Android14Java17Test.php +++ b/tests/Android14Java17Test.php @@ -33,6 +33,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 4cd92630c5..4eea753c2f 100644 --- a/tests/Android14Java8Test.php +++ b/tests/Android14Java8Test.php @@ -34,6 +34,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 eeebbe0ca7..f9199b1477 100644 --- a/tests/Android5Java17Test.php +++ b/tests/Android5Java17Test.php @@ -33,6 +33,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 b46cd3f841..cc062dba11 100644 --- a/tests/AppleSwift56Test.php +++ b/tests/AppleSwift56Test.php @@ -32,6 +32,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 90ca9d2412..85e7df4c6f 100644 --- a/tests/Base.php +++ b/tests/Base.php @@ -178,6 +178,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 9942202300..d7288b922a 100644 --- a/tests/FlutterBetaTest.php +++ b/tests/FlutterBetaTest.php @@ -32,6 +32,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 8454754837..6c4f9a0ef7 100644 --- a/tests/FlutterStableTest.php +++ b/tests/FlutterStableTest.php @@ -32,6 +32,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 095d44b2de..fb62a336b4 100644 --- a/tests/WebChromiumTest.php +++ b/tests/WebChromiumTest.php @@ -37,6 +37,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 d2930d1d3e..d0a99179e3 100644 --- a/tests/WebNodeTest.php +++ b/tests/WebNodeTest.php @@ -37,6 +37,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 0e18a17359..221cf13458 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 @@ -280,6 +281,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.bucket().file().toString()) + writeToFile(Channel.bucket("bucket1").file("file1").toString()) + writeToFile(Channel.bucket("bucket1").file("file1").delete().toString()) + writeToFile(Channel.function().execution().toString()) + writeToFile(Channel.function("func1").execution("exec1").toString()) + writeToFile(Channel.function("func1").execution("exec1").create().toString()) + writeToFile(Channel.team().toString()) + writeToFile(Channel.team("team1").toString()) + writeToFile(Channel.team("team1").create().toString()) + writeToFile(Channel.membership().toString()) + writeToFile(Channel.membership("membership1").toString()) + writeToFile(Channel.membership("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 35e956fa2f..70a1ed4404 100644 --- a/tests/languages/apple/Tests.swift +++ b/tests/languages/apple/Tests.swift @@ -258,6 +258,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.bucket().file().toString()) + print(Channel.bucket("bucket1").file("file1").toString()) + print(Channel.bucket("bucket1").file("file1").delete().toString()) + print(Channel.function().execution().toString()) + print(Channel.function("func1").execution("exec1").toString()) + print(Channel.function("func1").execution("exec1").create().toString()) + print(Channel.team().toString()) + print(Channel.team("team1").toString()) + print(Channel.team("team1").create().toString()) + print(Channel.membership().toString()) + print(Channel.membership("membership1").toString()) + print(Channel.membership("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 cedd68bc4c..be36802e57 100644 --- a/tests/languages/flutter/tests.dart +++ b/tests/languages/flutter/tests.dart @@ -257,6 +257,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.bucket().file().toString()); + print(Channel.bucket('bucket1').file('file1').toString()); + print(Channel.bucket('bucket1').file('file1').delete().toString()); + print(Channel.function().execution().toString()); + print(Channel.function('func1').execution('exec1').toString()); + print(Channel.function('func1').execution('exec1').create().toString()); + print(Channel.team().toString()); + print(Channel.team('team1').toString()); + print(Channel.team('team1').create().toString()); + print(Channel.membership().toString()); + print(Channel.membership('membership1').toString()); + print(Channel.membership('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 b69adca0fc..1d9a80bab0 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); @@ -344,6 +344,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.bucket().file().toString()); + console.log(Channel.bucket('bucket1').file('file1').toString()); + console.log(Channel.bucket('bucket1').file('file1').delete().toString()); + console.log(Channel.function().execution().toString()); + console.log(Channel.function('func1').execution('exec1').toString()); + console.log(Channel.function('func1').execution('exec1').create().toString()); + console.log(Channel.team().toString()); + console.log(Channel.team('team1').toString()); + console.log(Channel.team('team1').create().toString()); + console.log(Channel.membership().toString()); + console.log(Channel.membership('membership1').toString()); + console.log(Channel.membership('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 6c6b1aaa1f..018092e000 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; @@ -275,6 +275,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.bucket().file().toString()); + console.log(Channel.bucket('bucket1').file('file1').toString()); + console.log(Channel.bucket('bucket1').file('file1').delete().toString()); + console.log(Channel.function().execution().toString()); + console.log(Channel.function('func1').execution('exec1').toString()); + console.log(Channel.function('func1').execution('exec1').create().toString()); + console.log(Channel.team().toString()); + console.log(Channel.team('team1').toString()); + console.log(Channel.team('team1').create().toString()); + console.log(Channel.membership().toString()); + console.log(Channel.membership('membership1').toString()); + console.log(Channel.membership('membership1').update().toString()); + // Operator helper tests console.log(Operator.increment(1)); console.log(Operator.increment(5, 100));