diff --git a/apple/Inline.xcodeproj/project.pbxproj b/apple/Inline.xcodeproj/project.pbxproj index 16285f6b6..4ac6ac35a 100644 --- a/apple/Inline.xcodeproj/project.pbxproj +++ b/apple/Inline.xcodeproj/project.pbxproj @@ -85,6 +85,9 @@ E609060C2FA1000100000001 /* InlineSyntaxHighlighting in Frameworks */ = {isa = PBXBuildFile; productRef = E5A100022FA0000100000001 /* InlineSyntaxHighlighting */; }; E95DD2F1FC76F473B6D31EB6 /* InlineMacWindow in Frameworks */ = {isa = PBXBuildFile; productRef = 7BABF642C785B35DB0C34BF2 /* InlineMacWindow */; }; F17C11012F9A000100000001 /* InlineCLIInstaller in Frameworks */ = {isa = PBXBuildFile; productRef = F17C11002F9A000100000000 /* InlineCLIInstaller */; }; + F00200022FA2000100000001 /* InlineGrid in Frameworks */ = {isa = PBXBuildFile; productRef = F00200012FA2000100000001 /* InlineGrid */; }; + F00200042FA2000100000001 /* InlineGrid in Frameworks */ = {isa = PBXBuildFile; productRef = F00200032FA2000100000001 /* InlineGrid */; }; + F00200062FA2000100000001 /* InlineRTC in Frameworks */ = {isa = PBXBuildFile; productRef = F00200052FA2000100000001 /* InlineRTC */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -281,6 +284,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + F00200022FA2000100000001 /* InlineGrid in Frameworks */, + F00200062FA2000100000001 /* InlineRTC in Frameworks */, E12407240000000100000003 /* InlineIntents in Frameworks */, CD735FF02E493C8E00F7BFFE /* Translation in Frameworks */, CD4E8E2C2E088F6B00CE6BC0 /* TextProcessing in Frameworks */, @@ -312,6 +317,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + F00200042FA2000100000001 /* InlineGrid in Frameworks */, A83010022F52000100000001 /* InlineAppIntents in Frameworks */, A9E100042F50000100000001 /* MemojiKit in Frameworks */, A9E100052F50000100000001 /* MemojiPickerUI in Frameworks */, @@ -490,6 +496,8 @@ ); name = InlineIOS; packageProductDependencies = ( + F00200012FA2000100000001 /* InlineGrid */, + F00200052FA2000100000001 /* InlineRTC */, E12407240000000100000002 /* InlineIntents */, C35E950D2CAC497700D569AF /* GRDB */, C3E6D5CA2CAE82B00048D2D8 /* GRDB */, @@ -536,6 +544,7 @@ ); name = InlineMac; packageProductDependencies = ( + F00200032FA2000100000001 /* InlineGrid */, A83010002F52000100000001 /* InlineAppIntents */, A9E100022F50000100000001 /* MemojiKit */, A9E100032F50000100000001 /* MemojiPickerUI */, @@ -2349,6 +2358,9 @@ isa = XCSwiftPackageProductDependency; productName = InlineCLIInstaller; }; + F00200012FA2000100000001 /* InlineGrid */ = {isa = XCSwiftPackageProductDependency; productName = InlineGrid; }; + F00200032FA2000100000001 /* InlineGrid */ = {isa = XCSwiftPackageProductDependency; productName = InlineGrid; }; + F00200052FA2000100000001 /* InlineRTC */ = {isa = XCSwiftPackageProductDependency; productName = InlineRTC; }; /* End XCSwiftPackageProductDependency section */ }; rootObject = CD2660FC2CA01CAE00EB186F /* Project object */; diff --git a/apple/InlineIOS/ContentView.swift b/apple/InlineIOS/ContentView.swift index a7f593644..5e48eafee 100644 --- a/apple/InlineIOS/ContentView.swift +++ b/apple/InlineIOS/ContentView.swift @@ -177,6 +177,8 @@ private struct AuthedAppRoot: View { .id(peer) case let .chatInfo(chatItem): ChatInfoView(chatItem: chatItem) + case let .grid(initialSpaceID): + GridDestinationView(initialSpaceID: initialSpaceID) case let .spaceSettings(spaceId): SpaceSettingsView(spaceId: spaceId) case let .spaceIntegrations(spaceId): diff --git a/apple/InlineIOS/ExperimentalRootView.swift b/apple/InlineIOS/ExperimentalRootView.swift index c634a5b5c..fdfedd27e 100644 --- a/apple/InlineIOS/ExperimentalRootView.swift +++ b/apple/InlineIOS/ExperimentalRootView.swift @@ -1,4 +1,5 @@ import Auth +import InlineGrid import InlineKit import InlineUI import Logger @@ -132,6 +133,7 @@ private struct ExperimentalAuthedRootView: View { @State private var lastContentRootTab: RootTab = .allChats @State private var pendingSearchExit: PendingSearchExit? @State private var isCreatingThread = false + @AppStorage(ExperimentalFeatureFlags.gridIOSKey) private var gridIOSEnabled = false @State private var isCleaningOpenChats = false @State private var iPadCommandRequest: IPadCommandRequest? @State private var homeBootstrapTask: Task? @@ -456,6 +458,7 @@ private struct ExperimentalAuthedRootView: View { .toolbar { experimentalToolbarContent() } + .gridHomeEntry(onOpen: openGrid) .safeAreaInset(edge: .bottom, spacing: 0) { IPadSidebarSwitcher(selection: iPadSidebarScopeBinding) } @@ -476,6 +479,7 @@ private struct ExperimentalAuthedRootView: View { .toolbar { experimentalToolbarContent() } + .gridHomeEntry(isVisible: !isSearchActivePresentation, onOpen: openGrid) .navigationDestination(for: Destination.self) { destination in ExperimentalDestinationView( nav: bindableNav, @@ -892,6 +896,12 @@ private struct ExperimentalAuthedRootView: View { ) } + private func openGrid() { + guard gridIOSEnabled, + GridHomeEntryProjection(store: GridRuntime.shared.rooms, spaceID: nil).isEligible else { return } + openHomeDestination(.grid(initialSpaceID: nav.activeSpaceId)) + } + private func openHomeDestination(_ destination: Destination) { if usesIPadSplitView { router[IPadNavigationLane.canonicalTab] = [destination] @@ -1191,7 +1201,11 @@ private struct ExperimentalAuthedRootView: View { }, onManage: activeSpace.map { space in { openHomeDestination(.spaceSettings(spaceId: space.id)) } - } + }, + onGrid: gridIOSEnabled + && GridHomeEntryProjection(store: GridRuntime.shared.rooms, spaceID: nil).showsMenuEntry + ? openGrid + : nil ) // Fill the iPad toolbar target without changing the phone toolbar layout. .frame(width: usesIPadSplitView ? 44 : 28, height: usesIPadSplitView ? 44 : 28) @@ -1404,6 +1418,7 @@ private struct ExperimentalOverflowMenuButton: UIViewRepresentable { let onInvite: () -> Void let onMembers: (() -> Void)? let onManage: (() -> Void)? + let onGrid: (() -> Void)? func makeUIView(context: Context) -> UIButton { let button = UIButton(type: .system) @@ -1422,6 +1437,11 @@ private struct ExperimentalOverflowMenuButton: UIViewRepresentable { } private func makeMenu() -> UIMenu { + let grid = onGrid.map { onGrid in + UIAction(title: String(localized: "Grid"), image: UIImage(systemName: "square.grid.2x2")) { _ in + onGrid() + } + } let archivedChats = UIAction( title: "Archived Chats", image: UIImage(systemName: "archivebox") @@ -1537,7 +1557,7 @@ private struct ExperimentalOverflowMenuButton: UIViewRepresentable { } return UIMenu( - children: [viewSection] + (cleanupSection.map { [$0] } ?? []) + [spaceSection] + children: (grid.map { [$0] } ?? []) + [viewSection] + (cleanupSection.map { [$0] } ?? []) + [spaceSection] ) } } diff --git a/apple/InlineIOS/Features/Experimental/ExperimentalNavigation.swift b/apple/InlineIOS/Features/Experimental/ExperimentalNavigation.swift index 0779fb3df..7538bfa61 100644 --- a/apple/InlineIOS/Features/Experimental/ExperimentalNavigation.swift +++ b/apple/InlineIOS/Features/Experimental/ExperimentalNavigation.swift @@ -221,6 +221,8 @@ struct ExperimentalDestinationView: View { } case let .chatInfo(chatItem): ChatInfoView(chatItem: chatItem) + case let .grid(initialSpaceID): + GridDestinationView(initialSpaceID: initialSpaceID) case let .spaceSettings(spaceId): SpaceSettingsView(spaceId: spaceId, usesRouterNavigation: usesRouterNavigation) case let .spaceIntegrations(spaceId): diff --git a/apple/InlineIOS/Features/Grid/GridHomeEntry.swift b/apple/InlineIOS/Features/Grid/GridHomeEntry.swift new file mode 100644 index 000000000..a6c28eb2f --- /dev/null +++ b/apple/InlineIOS/Features/Grid/GridHomeEntry.swift @@ -0,0 +1,135 @@ +import InlineGrid +import InlineKit +import InlineProtocol +import InlineUI +import SwiftUI + +/// Discovery is deliberately separate from call ownership. A bounded avatar +/// preview must never decide which device owns the user's current call. +@MainActor +struct GridHomeEntryProjection { + let isEligible: Bool + let avatars: [GridAvatar] + let activeCount: Int + + init(store: GridRoomService, spaceID: Int64?) { + let homes = spaceID.map { selectedID in + store.orderedHomeSpaces.filter { $0.spaceID == selectedID } + } ?? store.orderedHomeSpaces + isEligible = !homes.isEmpty + activeCount = homes.reduce(0) { $0 + Int($1.activeAvatarCount) } + var seen = Set() + var preview: [GridAvatar] = [] + for home in homes { + for avatar in home.recentAvatars where seen.insert(avatar.user.id).inserted { + preview.append(avatar) + if preview.count == 4 { + break + } + } + if preview.count == 4 { + break + } + } + avatars = preview + } + + var showsPill: Bool { + isEligible && activeCount > 0 && !avatars.isEmpty + } + + var showsMenuEntry: Bool { + isEligible + } +} + +extension View { + func gridHomeEntry(isVisible: Bool = true, onOpen: @escaping () -> Void) -> some View { + modifier(GridHomeEntryModifier(isVisible: isVisible, onOpen: onOpen)) + } +} + +private struct GridHomeEntryModifier: ViewModifier { + let isVisible: Bool + let onOpen: () -> Void + @AppStorage(ExperimentalFeatureFlags.gridIOSKey) private var isEnabled = false + + func body(content: Content) -> some View { + if isEnabled { + content.modifier(GridEnabledHomeEntryModifier(isVisible: isVisible, onOpen: onOpen)) + } else { + content + } + } +} + +private struct GridEnabledHomeEntryModifier: ViewModifier { + let isVisible: Bool + let onOpen: () -> Void + @Environment(\.scenePhase) private var scenePhase + private let store = GridRuntime.shared.rooms + + func body(content: Content) -> some View { + let projection = GridHomeEntryProjection(store: store, spaceID: nil) + let showsPill = isVisible && projection.showsPill + + Group { + if #available(iOS 26.0, *) { + content.safeAreaBar(edge: .top, spacing: 0) { + GridHomePill( + avatars: projection.avatars, + activeCount: projection.activeCount, + isVisible: showsPill, + onOpen: onOpen + ) + } + } else { + content.safeAreaInset(edge: .top, spacing: 0) { + GridHomePill( + avatars: projection.avatars, + activeCount: projection.activeCount, + isVisible: showsPill, + onOpen: onOpen + ) + } + } + } + .task { await store.loadHome() } + .onChange(of: scenePhase) { _, phase in + if phase == .active { + Task { await store.loadHome() } + } + } + } +} + +private struct GridHomePill: View { + let avatars: [GridAvatar] + let activeCount: Int + let isVisible: Bool + let onOpen: () -> Void + + var body: some View { + if isVisible { + Button(action: onOpen) { + HStack(spacing: -6) { + ForEach(avatars, id: \.user.id) { avatar in + UserAvatar(user: InlineKit.User(from: avatar.user), size: 22) + .overlay { Circle().stroke(.background, lineWidth: 1.5) } + } + } + .padding(.horizontal, 7) + .padding(.vertical, 4) + .background(.thinMaterial, in: Capsule()) + .contentShape(Capsule()) + .frame(minHeight: 44) + } + .buttonStyle(.plain) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Grid, \(activeCount) people present") + .accessibilityHint("Open voice rooms") + .accessibilityIdentifier("gridHomePill") + .frame(maxWidth: .infinity) + } + } +} diff --git a/apple/InlineIOS/Features/Grid/GridRoomsView.swift b/apple/InlineIOS/Features/Grid/GridRoomsView.swift new file mode 100644 index 000000000..74410fcc4 --- /dev/null +++ b/apple/InlineIOS/Features/Grid/GridRoomsView.swift @@ -0,0 +1,677 @@ +import AVKit +import InlineAudioPlayback +import InlineGrid +import InlineKit +import InlineProtocol +import InlineRTC +import InlineUI +import SwiftUI +import UIKit + +/// Uses the same process-owned room service as Home and the active call. +struct GridDestinationView: View { + let initialSpaceID: Int64? + @AppStorage(ExperimentalFeatureFlags.gridIOSKey) private var isEnabled = false + @EnvironmentObject private var spaceList: CompactSpaceList + @Environment(\.dismiss) private var dismiss + + var body: some View { + Group { + if isEnabled { + GridRoomsView( + store: GridRuntime.shared.rooms, + spaces: spaceList.spaces, + initialSpaceID: initialSpaceID + ) + } else { + ContentUnavailableView("Grid is unavailable", systemImage: "square.grid.2x2") + .navigationTitle("Grid") + } + } + .onChange(of: isEnabled) { _, enabled in + if !enabled { + dismiss() + } + } + } +} + +struct GridRoomsView: View { + let store: GridRoomService + let spaces: [InlineKit.Space] + @State private var selectedSpaceID: Int64? + @State private var selectedShare: GridScreenShareSelection? + @State private var presentedFailure: GridActionFailure? + @Environment(\.dismiss) private var dismiss + + init(store: GridRoomService, spaces: [InlineKit.Space], initialSpaceID: Int64?) { + self.store = store + self.spaces = spaces + _selectedSpaceID = State(initialValue: initialSpaceID) + } + + private var effectiveSpaceID: Int64? { + if let selectedSpaceID, store.isEnabled(spaceID: selectedSpaceID) { + return selectedSpaceID + } + if let call = store.currentCall, store.isEnabled(spaceID: call.spaceID) { + return call.spaceID + } + return store.orderedHomeSpaces.first?.spaceID + } + + private var actionFailure: GridActionFailure? { + let hasConnectionFailure = store.media.connectionState == .failed + let connectionError = hasConnectionFailure ? store.media.lastConnectionError : nil + let audioError: String? = if case let .failed(message) = store.media.audioState { + message + } else { + nil + } + let storeError = store.membershipMutationInFlight ? nil : store.lastError + let errors = [storeError, connectionError, audioError].compactMap(\.self) + let hasMediaFailure = hasConnectionFailure || audioError != nil || store.media.providerCircuitOpen + let audio = InlineAudioSession.shared + if audio.isQuarantined && (store.hasLocalAdmission || !errors.isEmpty) + || errors.contains(InlineAudioSessionError.quarantined.localizedDescription) + { + return .restartRequired + } + guard !errors.isEmpty || hasMediaFailure else { return nil } + if errors.contains(InlineAudioSessionError.recordingOwnsAudio.localizedDescription) { + return .recordingConflict + } + if errors.contains(InlineAudioSessionError.retirementPending.localizedDescription) { + return .retirementPending + } + return hasMediaFailure ? .audio : .update + } + + var body: some View { + VStack(spacing: 16) { + if let spaceID = effectiveSpaceID { + GridRoomCollection(store: store, spaceID: spaceID, onViewShare: { avatar, share in + guard let identity = store.mediaSessionIdentity else { return } + selectedShare = GridScreenShareSelection( + mediaSessionIdentity: identity, + participantIdentity: share.participantIdentity, + publicationID: share.publicationID, + displayName: InlineKit.User(from: avatar.user).displayName + ) + }) + + GridCurrentRoomControls(store: store, spaceID: store.currentCall?.spaceID ?? spaceID) + if !store.callTransferEnabled { + Text("Grid is unavailable") + .font(.footnote) + .foregroundStyle(.secondary) + } + } else { + ContentUnavailableView("Grid is unavailable", systemImage: "square.grid.2x2") + } + } + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .navigationTitle("Grid") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + GridSpacePicker( + homeSpaces: store.orderedHomeSpaces, + spaces: spaces, + selectedSpaceID: effectiveSpaceID, + onSelect: { selectedSpaceID = $0 } + ) + } + } + .task { await store.loadHome() } + .task(id: effectiveSpaceID) { + if let spaceID = effectiveSpaceID { + await store.load(spaceID: spaceID) + } + } + .onChange(of: actionFailure, initial: true) { _, failure in + if let failure { + presentedFailure = failure + store.clearLastError() + } + } + .alert(presentedFailure?.title ?? "Unable to update Grid", isPresented: Binding( + get: { presentedFailure != nil }, + set: { + if !$0 { + presentedFailure = nil + } + } + ), presenting: presentedFailure) { failure in + if failure.canRetryAudio && store.hasLocalAdmission { + Button("Retry Audio") { + guard store.hasLocalAdmission, store.currentCall?.ownedByCurrentSession == true, + !InlineAudioSession.shared.isQuarantined else { return } + store.retryAudio() + } + .disabled(store.membershipMutationInFlight) + } else if failure == .update { + Button("Refresh") { + Task { + await store.loadHome() + if let spaceID = effectiveSpaceID { + await store.load(spaceID: spaceID) + } + } + } + } + if let call = store.currentCall, call.ownedByCurrentSession { + Button("Leave Grid", role: .destructive) { + if let current = store.currentCall, current.ownedByCurrentSession { + store.leaveCurrentRoom(spaceID: current.spaceID) + } + } + } + if failure == .recordingConflict || failure == .restartRequired { + Button("Close Grid", role: .cancel) { dismiss() } + } else { + Button("Cancel", role: .cancel) {} + } + } message: { failure in + Text(failure.message) + } + .fullScreenCover(item: $selectedShare) { selection in + GridScreenShareViewer(store: store, selection: selection) + } + } +} + +/// Only known recovery messages reach the native surface. SDK and RPC error +/// descriptions can contain diagnostics that do not belong in the interface. +private enum GridActionFailure: Equatable { + case recordingConflict + case restartRequired + case retirementPending + case audio + case update + + var title: LocalizedStringKey { + self == .update ? "Unable to update Grid" : "Unable to start Grid audio" + } + + var message: String { + switch self { + case .recordingConflict: InlineAudioSessionError.recordingOwnsAudio.localizedDescription + case .restartRequired: InlineAudioSessionError.quarantined.localizedDescription + case .retirementPending: InlineAudioSessionError.retirementPending.localizedDescription + case .audio: String(localized: "Grid audio could not start. Try again.") + case .update: String(localized: "Grid could not be updated. Refresh and try again.") + } + } + + var canRetryAudio: Bool { + self == .audio || self == .retirementPending + } +} + +private struct GridSpacePicker: View { + let homeSpaces: [GridHomeSpace] + let spaces: [InlineKit.Space] + let selectedSpaceID: Int64? + let onSelect: (Int64) -> Void + + private func title(for spaceID: Int64) -> String { + spaces.first { $0.id == spaceID }?.displayName ?? String(localized: "Grid") + } + + var body: some View { + if homeSpaces.count > 1 { + Menu { + ForEach(homeSpaces, id: \.spaceID) { home in + Button { + onSelect(home.spaceID) + } label: { + if home.spaceID == selectedSpaceID { + Label(title(for: home.spaceID), systemImage: "checkmark") + } else { + Text(title(for: home.spaceID)) + } + } + } + } label: { + HStack(spacing: 4) { + Text(selectedSpaceID.map(title(for:)) ?? String(localized: "Grid")) + .lineLimit(1) + Image(systemName: "chevron.down").font(.caption2) + } + .font(.headline) + .foregroundStyle(.primary) + } + .accessibilityLabel("Choose a space for Grid") + } else { + Text(selectedSpaceID.map(title(for:)) ?? String(localized: "Grid")) + .font(.headline) + .lineLimit(1) + } + } +} + +private struct GridRoomCollection: View { + let store: GridRoomService + let spaceID: Int64 + let onViewShare: (GridAvatar, InlineRTCScreenShare) -> Void + + var body: some View { + ScrollView { + if let grid = store.grid(spaceID: spaceID) { + GridRoomFlowLayout(spacing: 12) { + ForEach(grid.rooms, id: \.id) { room in + GridRoomCapsule(store: store, room: room, onViewShare: onViewShare) + } + Button { + store.createAndJoin(spaceID: spaceID) + } label: { + Image(systemName: "plus") + .font(.body.weight(.medium)) + .foregroundStyle(.secondary) + .frame(width: 62, height: 62) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 16)) + } + .buttonStyle(.plain) + .disabled(store.membershipMutationInFlight || !store.callTransferEnabled + || store.currentCall?.ownedByCurrentSession == false) + .accessibilityLabel("Create and join a Grid room") + } + .padding(.top, 8) + } else if store.failedLoadSpaceIDs.contains(spaceID) { + Button("Try Again") { Task { await store.load(spaceID: spaceID) } } + .frame(maxWidth: .infinity, minHeight: 80) + } else { + ProgressView().frame(maxWidth: .infinity, minHeight: 80) + } + } + } +} + +private struct GridRoomCapsule: View { + let store: GridRoomService + let room: GridRoom + let onViewShare: (GridAvatar, InlineRTCScreenShare) -> Void + + private var isCurrent: Bool { + store.currentCall?.roomID == room.id && store.currentCall?.ownedByCurrentSession == true + && store.hasLocalAdmission + } + + var body: some View { + Group { + if isCurrent { + GridRoomAvatars(store: store, avatars: room.avatars, isCurrent: true, onViewShare: onViewShare) + } else { + Button { + if store.currentCall?.roomID == room.id { + store.moveCallHere() + } else { + store.join(roomID: room.id) + } + } label: { + GridRoomAvatars(store: store, avatars: room.avatars, isCurrent: false, onViewShare: onViewShare) + } + .buttonStyle(.plain) + .disabled(store.membershipMutationInFlight || !store.callTransferEnabled + || (room.locked && store.currentCall?.roomID != room.id) + || (store.currentCall?.ownedByCurrentSession == false && store.currentCall?.roomID != room.id)) + .accessibilityLabel(joinAccessibilityLabel) + } + } + .padding(8) + .frame(minWidth: 62, minHeight: 62) + .background( + isCurrent ? Color.accentColor.opacity(0.12) : Color.primary.opacity(0.045), + in: RoundedRectangle(cornerRadius: 16) + ) + .overlay { + RoundedRectangle(cornerRadius: 16) + .stroke(isCurrent ? Color.accentColor.opacity(0.55) : Color.primary.opacity(0.07), lineWidth: 1) + } + .overlay(alignment: .top) { + if room.hasTitle { + Text(room.title) + .font(.caption2) + .lineLimit(1) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.regularMaterial, in: Capsule()) + .offset(y: -8) + } + } + .overlay(alignment: .bottomTrailing) { + if room.locked { + Image(systemName: "lock.fill") + .font(.caption2) + .foregroundStyle(.secondary) + .padding(5) + .accessibilityLabel("Locked room") + } + } + } + + private var joinAccessibilityLabel: String { + if let call = store.currentCall, call.roomID == room.id { + return call.ownedByCurrentSession ? String(localized: "Resume Grid here") : String(localized: "Move Grid here") + } + return room.hasTitle ? String(localized: "Join \(room.title)") : String(localized: "Join Grid room") + } +} + +private struct GridRoomAvatars: View { + let store: GridRoomService + let avatars: [GridAvatar] + let isCurrent: Bool + let onViewShare: (GridAvatar, InlineRTCScreenShare) -> Void + + var body: some View { + HStack(spacing: 4) { + ForEach(avatars.prefix(4), id: \.user.id) { avatar in + GridRoomAvatar(store: store, avatar: avatar, isCurrent: isCurrent, onViewShare: onViewShare) + } + if avatars.count > 4 { + GridRoomAvatarOverflow( + store: store, + avatars: Array(avatars.dropFirst(4)), + isCurrent: isCurrent, + onViewShare: onViewShare + ) + } + } + .frame(minWidth: 44, minHeight: 44) + } +} + +private struct GridRoomAvatarOverflow: View { + let store: GridRoomService + let avatars: [GridAvatar] + let isCurrent: Bool + let onViewShare: (GridAvatar, InlineRTCScreenShare) -> Void + + var body: some View { + Menu { + ForEach(avatars, id: \.user.id) { avatar in + if isCurrent { + ForEach(store.screenShares(avatar: avatar)) { share in + Button { + onViewShare(avatar, share) + } label: { + Label( + "View \(InlineKit.User(from: avatar.user).displayName)’s screen", + systemImage: "rectangle.on.rectangle" + ) + } + } + } + } + } label: { + Text("+\(avatars.count)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 30, height: 44) + } + .disabled(!isCurrent || !avatars.contains { !store.screenShares(avatar: $0).isEmpty }) + .accessibilityLabel("\(avatars.count) more people") + } +} + +private struct GridRoomAvatar: View { + let store: GridRoomService + let avatar: GridAvatar + let isCurrent: Bool + let onViewShare: (GridAvatar, InlineRTCScreenShare) -> Void + + var body: some View { + let level = isCurrent ? store.audioLevel(userID: avatar.user.id) : 0 + let shares = isCurrent ? store.screenShares(avatar: avatar) : [] + + UserAvatar(user: InlineKit.User(from: avatar.user), size: 42) + .padding(2) + .overlay { + Circle().stroke(level > 0.01 ? Color.green : .clear, lineWidth: 1.5) + } + .overlay(alignment: .bottomTrailing) { + if !shares.isEmpty { + Menu { + ForEach(shares) { share in + Button("View Screen", systemImage: "rectangle.on.rectangle") { + onViewShare(avatar, share) + } + } + } label: { + Image(systemName: "rectangle.on.rectangle.fill") + .font(.caption2) + .padding(4) + .background(.regularMaterial, in: Circle()) + .frame(minWidth: 44, minHeight: 44, alignment: .bottomTrailing) + } + .accessibilityLabel("View \(InlineKit.User(from: avatar.user).displayName)’s screen") + } + } + .accessibilityLabel(InlineKit.User(from: avatar.user).displayName) + } +} + +struct GridCurrentRoomControls: View { + let store: GridRoomService + let spaceID: Int64 + + var body: some View { + if let call = store.currentCall, call.spaceID == spaceID { + if call.ownedByCurrentSession, store.hasLocalAdmission { + GridLocalAudioControls(store: store, spaceID: spaceID) + } else { + HStack(spacing: 16) { + Button(call.ownedByCurrentSession ? "Resume here" : "Move here", systemImage: "arrow.down.to.line") { + store.moveCallHere() + } + .buttonStyle(.bordered) + .disabled(store.membershipMutationInFlight || !store.callTransferEnabled) + .accessibilityHint("Move your active Grid call to this device") + + if store.membershipMutationInFlight { + Button("Cancel") { store.withdrawLocalAdmission() } + } else if call.ownedByCurrentSession { + Button(role: .destructive) { + store.leaveCurrentRoom(spaceID: spaceID) + } label: { + Image(systemName: "phone.down.fill").frame(width: 44, height: 44) + } + .accessibilityLabel("Leave Grid room") + } + } + } + } else if store.membershipMutationInFlight { + Button("Cancel") { store.withdrawLocalAdmission() } + .accessibilityLabel("Cancel joining Grid") + } + } +} + +private struct GridLocalAudioControls: View { + private enum AudioAlert: Equatable { + case microphonePermission + case outputRoute + } + + let store: GridRoomService + let spaceID: Int64 + private let audio = InlineAudioSession.shared + @State private var requestedUnmute = false + @State private var audioAlert: AudioAlert? + @Environment(\.openURL) private var openURL + + private var canUnmute: Bool { + guard !store.media.isAudioSafetyPaused, + let roomID = store.currentCall?.roomID, + let room = store.grid(spaceID: spaceID)?.rooms.first(where: { $0.id == roomID }) + else { return false } + return room.avatars.count > 1 + } + + var body: some View { + VStack(spacing: 8) { + if store.media.isAudioSafetyPaused { + Button("Resume Audio", systemImage: "play.fill") { store.retryAudio() } + .buttonStyle(.bordered) + .disabled(store.membershipMutationInFlight) + } + + audioControls + } + .frame(maxWidth: .infinity) + .onChange(of: store.media.microphonePermission) { _, permission in + if requestedUnmute, permission == .denied || permission == .restricted { + audioAlert = .microphonePermission + requestedUnmute = false + } else if permission == .authorized { + requestedUnmute = false + } + } + .alert(alertTitle, isPresented: Binding( + get: { audioAlert != nil }, + set: { + if !$0 { + audioAlert = nil + } + } + )) { + if audioAlert == .microphonePermission { + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + openURL(url) + } + } + Button("Cancel", role: .cancel) {} + } else { + Button("OK", role: .cancel) {} + } + } + } + + private var audioControls: some View { + HStack(spacing: 16) { + Button { + if !store.media.isMicrophoneEnabled { + if store.media.microphonePermission == .denied || store.media.microphonePermission == .restricted { + audioAlert = .microphonePermission + return + } + requestedUnmute = true + } else { + requestedUnmute = false + } + store.toggleMicrophone(spaceID: spaceID) + } label: { + Image(systemName: store.media.isMicrophoneEnabled ? "mic.fill" : "mic.slash.fill") + .frame(width: 44, height: 44) + .background(.thinMaterial, in: Circle()) + } + .disabled((!canUnmute || store.membershipMutationInFlight) && !store.media.isMicrophoneEnabled) + .accessibilityLabel(store.media.isMicrophoneEnabled + ? String(localized: "Mute microphone") + : String(localized: "Unmute microphone")) + + if audio.supportsReceiverRouting { + Button { + let preferred = !audio.isSpeakerOutputActive + Task { @MainActor in + do { + try await audio.setSpeakerPreferred(preferred) + } catch { + if !(error is CancellationError) { + audioAlert = .outputRoute + } + } + } + } label: { + Image(systemName: audio.isSpeakerOutputActive ? "speaker.wave.2.fill" : "speaker.wave.2") + .frame(width: 44, height: 44) + .background(.thinMaterial, in: Circle()) + } + .accessibilityLabel(audio.isSpeakerOutputActive + ? String(localized: "Turn off speaker") + : String(localized: "Use speaker")) + .accessibilityValue(audio.currentRouteName) + } + + GridNativeAudioRoutePicker(routeName: audio.currentRouteName) + .frame(width: 44, height: 44) + + Button(role: .destructive) { + store.leaveCurrentRoom(spaceID: spaceID) + } label: { + Image(systemName: "phone.down.fill") + .frame(width: 44, height: 44) + .background(.thinMaterial, in: Circle()) + } + .accessibilityLabel("Leave Grid room") + } + .buttonStyle(.plain) + } + + private var alertTitle: LocalizedStringKey { + audioAlert == .microphonePermission ? "Microphone access is off" : "Unable to change audio output" + } +} + +private struct GridNativeAudioRoutePicker: UIViewRepresentable { + let routeName: String + + func makeUIView(context: Context) -> AVRoutePickerView { + let view = AVRoutePickerView() + view.prioritizesVideoDevices = false + view.tintColor = .label + view.activeTintColor = UIColor(Color.accentColor) + view.accessibilityLabel = String(localized: "Audio output") + view.accessibilityValue = routeName + return view + } + + func updateUIView(_ uiView: AVRoutePickerView, context: Context) { + uiView.accessibilityValue = routeName + } +} + +private struct GridRoomFlowLayout: Layout { + let spacing: CGFloat + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let width = proposal.width ?? .infinity + var x: CGFloat = 0 + var y: CGFloat = 0 + var rowHeight: CGFloat = 0 + var usedWidth: CGFloat = 0 + for view in subviews { + let size = view.sizeThatFits(.unspecified) + if x > 0, x + size.width > width { + y += rowHeight + spacing + x = 0 + rowHeight = 0 + } + usedWidth = max(usedWidth, x + size.width) + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + return CGSize(width: proposal.width ?? usedWidth, height: y + rowHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var x = bounds.minX + var y = bounds.minY + var rowHeight: CGFloat = 0 + for view in subviews { + let size = view.sizeThatFits(.unspecified) + if x > bounds.minX, x + size.width > bounds.maxX { + y += rowHeight + spacing + x = bounds.minX + rowHeight = 0 + } + view.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + } +} diff --git a/apple/InlineIOS/Features/Grid/GridScreenShareViewer.swift b/apple/InlineIOS/Features/Grid/GridScreenShareViewer.swift new file mode 100644 index 000000000..a73ff00cb --- /dev/null +++ b/apple/InlineIOS/Features/Grid/GridScreenShareViewer.swift @@ -0,0 +1,119 @@ +import InlineGrid +import InlineRTC +import SwiftUI +import UIKit + +struct GridScreenShareSelection: Identifiable { + let mediaSessionIdentity: String + let participantIdentity: String + let publicationID: String + let displayName: String + var id: String { "\(mediaSessionIdentity):\(publicationID)" } +} + +struct GridScreenShareViewer: View { + let store: GridRoomService + let selection: GridScreenShareSelection + @Environment(\.dismiss) private var dismiss + @Environment(\.scenePhase) private var scenePhase + + private var selectedShare: InlineRTCScreenShare? { + guard store.mediaSessionIdentity == selection.mediaSessionIdentity, + let share = store.screenShare(publicationID: selection.publicationID), + share.participantIdentity == selection.participantIdentity + else { return nil } + return share + } + + var body: some View { + NavigationStack { + GridVideoRenderer(screenShare: selectedShare, isVideoEnabled: scenePhase == .active) + .background(.black) + .navigationTitle(selection.displayName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done", systemImage: "xmark") { dismiss() } + .labelStyle(.iconOnly) + .accessibilityLabel("Close shared screen") + } + } + .safeAreaInset(edge: .bottom) { + if let call = store.currentCall { + GridCurrentRoomControls(store: store, spaceID: call.spaceID) + .padding(.vertical, 8) + .background(.regularMaterial) + } + } + .onChange(of: selectedShare?.publicationID) { _, publicationID in + if publicationID == nil { dismiss() } + } + .onAppear { + if selectedShare == nil { dismiss() } + } + .onChange(of: store.mediaSessionIdentity) { _, identity in + if identity != selection.mediaSessionIdentity { dismiss() } + } + } + } +} + +private struct GridVideoRenderer: UIViewRepresentable { + let screenShare: InlineRTCScreenShare? + let isVideoEnabled: Bool + + func makeUIView(context: Context) -> GridVideoScrollView { + let view = GridVideoScrollView() + view.videoView.screenShare = screenShare + view.videoView.isVideoEnabled = isVideoEnabled + return view + } + + func updateUIView(_ uiView: GridVideoScrollView, context: Context) { + if uiView.videoView.screenShare?.publicationID != screenShare?.publicationID { + uiView.setZoomScale(1, animated: false) + } + uiView.videoView.screenShare = screenShare + uiView.videoView.isVideoEnabled = isVideoEnabled + } + + static func dismantleUIView(_ uiView: GridVideoScrollView, coordinator: ()) { + uiView.videoView.isVideoEnabled = false + uiView.videoView.screenShare = nil + } +} + +private final class GridVideoScrollView: UIScrollView, UIScrollViewDelegate { + let videoView = InlineRTCVideoView(frame: .zero) + private var viewportSize: CGSize = .zero + + init() { + super.init(frame: .zero) + delegate = self + minimumZoomScale = 1 + maximumZoomScale = 4 + showsHorizontalScrollIndicator = false + showsVerticalScrollIndicator = false + contentInsetAdjustmentBehavior = .never + backgroundColor = .black + addSubview(videoView) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + guard bounds.size != viewportSize else { return } + viewportSize = bounds.size + setZoomScale(1, animated: false) + videoView.frame = CGRect(origin: .zero, size: viewportSize) + contentSize = viewportSize + } + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + videoView + } +} diff --git a/apple/InlineIOS/Features/Message/UIMessageView.swift b/apple/InlineIOS/Features/Message/UIMessageView.swift index 29e52375d..eaea29480 100644 --- a/apple/InlineIOS/Features/Message/UIMessageView.swift +++ b/apple/InlineIOS/Features/Message/UIMessageView.swift @@ -683,6 +683,8 @@ class UIMessageView: UIView { private func serviceTextColor(for tone: MessageServiceDisplaySegment.Tone) -> UIColor { switch tone { + case .primary: + return .label case .secondary: return theme.incomingSecondaryText.uiColor case .tertiary: diff --git a/apple/InlineIOS/Features/Settings/ExperimentalView.swift b/apple/InlineIOS/Features/Settings/ExperimentalView.swift index 7e52480a9..46c6dc785 100644 --- a/apple/InlineIOS/Features/Settings/ExperimentalView.swift +++ b/apple/InlineIOS/Features/Settings/ExperimentalView.swift @@ -8,6 +8,9 @@ struct ExperimentalView: View { @AppStorage(ExperimentalFeatureFlags.agentActivityKey) private var agentActivityEnabled = false + @AppStorage(ExperimentalFeatureFlags.gridIOSKey) + private var gridIOSEnabled = false + @AppStorage(ExperimentalFeatureFlags.nativeFileDownloadsKey) private var nativeFileDownloadsEnabled = false @@ -19,6 +22,17 @@ struct ExperimentalView: View { var body: some View { List { + Section { + SettingsItem(icon: "waveform", iconColor: .green, title: "Grid") { + Toggle("Grid", isOn: $gridIOSEnabled) + .labelsHidden() + } + } header: { + Text("Audio") + } footer: { + Text("Join voice rooms and view shared screens in spaces with Grid enabled.") + } + // Use the same purpose-based groups as macOS; see AGENTS.md when adding experiments. Section { Toggle("Agent Activity", isOn: $agentActivityEnabled) diff --git a/apple/InlineIOS/Info.plist b/apple/InlineIOS/Info.plist index c632c5c78..f86c84eb1 100644 --- a/apple/InlineIOS/Info.plist +++ b/apple/InlineIOS/Info.plist @@ -55,7 +55,7 @@ NSDocumentsFolderUsageDescription Inline needs access to documents to share files in your conversations. NSMicrophoneUsageDescription - Inline needs access to your microphone to record audio messages and videos. + Inline needs access to your microphone to speak in Grid and record audio messages and videos. NSPhotoLibraryUsageDescription Inline needs access to your photo library to share photos and videos in your conversations. PHPhotoLibraryPreventAutomaticLimitedAccessAlert @@ -66,6 +66,7 @@ UIBackgroundModes + audio remote-notification audio diff --git a/apple/InlineIOS/InlineApp.swift b/apple/InlineIOS/InlineApp.swift index 99c633b46..d448f55a6 100644 --- a/apple/InlineIOS/InlineApp.swift +++ b/apple/InlineIOS/InlineApp.swift @@ -1,5 +1,6 @@ import Auth import Foundation +import InlineGrid import InlineKit import Sentry import SwiftUI @@ -39,6 +40,7 @@ private struct InlineSceneRoot: View { @State private var sceneID = UUID() @State private var didRestoreScene = false @ObservedObject private var auth = Auth.shared + @AppStorage(ExperimentalFeatureFlags.gridIOSKey) private var gridEnabled = false @SceneStorage("ios.navigation.routerState.v1") private var routerState: Data? @Environment(\.scenePhase) private var scenePhase @@ -110,6 +112,11 @@ private struct InlineSceneRoot: View { appDelegate.resumePendingSpaceJoin(router: router) } } + .onChange(of: gridEnabled) { _, enabled in + if !enabled { + GridRuntime.existing?.rooms.withdrawLocalAdmission() + } + } .onChange(of: router.persistenceRevision) { _, _ in guard didRestoreScene else { return } routerState = router.encodedPersistentState() diff --git a/apple/InlineIOS/MainViews/HomeToolbarContent.swift b/apple/InlineIOS/MainViews/HomeToolbarContent.swift index aab76d0ec..86c653664 100644 --- a/apple/InlineIOS/MainViews/HomeToolbarContent.swift +++ b/apple/InlineIOS/MainViews/HomeToolbarContent.swift @@ -1,3 +1,4 @@ +import InlineGrid import InlineKit import InlineUI import RealtimeV2 @@ -8,15 +9,19 @@ struct HomeToolbarContent: ToolbarContent { private let router: Router @ObservedObject private var realtimeState: RealtimeState @ObservedObject private var notificationSettings: NotificationSettingsManager + @AppStorage(ExperimentalFeatureFlags.gridIOSKey) private var gridIOSEnabled = false + private let onOpenGrid: () -> Void init( router: Router, realtimeState: RealtimeState, - notificationSettings: NotificationSettingsManager + notificationSettings: NotificationSettingsManager, + onOpenGrid: @escaping () -> Void ) { self.router = router _realtimeState = ObservedObject(wrappedValue: realtimeState) _notificationSettings = ObservedObject(wrappedValue: notificationSettings) + self.onOpenGrid = onOpenGrid } var body: some ToolbarContent { @@ -64,6 +69,11 @@ struct HomeToolbarContent: ToolbarContent { @ViewBuilder private var dotsButton: some View { Menu { + if gridIOSEnabled, + GridHomeEntryProjection(store: GridRuntime.shared.rooms, spaceID: nil).showsMenuEntry { + Button("Grid", systemImage: "square.grid.2x2", action: onOpenGrid) + } + Button { router.presentSheet(.inviteToInline) } label: { diff --git a/apple/InlineIOS/MainViews/HomeView.swift b/apple/InlineIOS/MainViews/HomeView.swift index 413e856c2..c5cff1f15 100644 --- a/apple/InlineIOS/MainViews/HomeView.swift +++ b/apple/InlineIOS/MainViews/HomeView.swift @@ -1,3 +1,4 @@ +import InlineGrid import InlineKit import InlineSearch import InlineUI @@ -40,9 +41,13 @@ struct HomeView: View { HomeToolbarContent( router: router, realtimeState: realtimeState, - notificationSettings: notificationSettings + notificationSettings: notificationSettings, + onOpenGrid: { router.push(.grid(initialSpaceID: nil)) } ) } + .gridHomeEntry(isVisible: text.isEmpty) { + router.push(.grid(initialSpaceID: nil)) + } .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden() .onAppear { diff --git a/apple/InlineIOS/Navigation/AppContent.swift b/apple/InlineIOS/Navigation/AppContent.swift index 4aca72379..96404bf03 100644 --- a/apple/InlineIOS/Navigation/AppContent.swift +++ b/apple/InlineIOS/Navigation/AppContent.swift @@ -269,6 +269,7 @@ enum Destination: DestinationType, Codable { case chatMessage(peer: Peer, messageID: Int64) case voiceMessage(peer: Peer, messageID: Int64) case chatInfo(chatItem: SpaceChatItem) + case grid(initialSpaceID: Int64?) case spaceSettings(spaceId: Int64) case spaceIntegrations(spaceId: Int64) case integrationOptions(spaceId: Int64, provider: String) @@ -419,7 +420,7 @@ extension Destination { switch self { case let .chat(peer), let .externalChat(peer, _, _), let .chatMessage(peer, _), let .voiceMessage(peer, _): peer - case .chats, .archived, .spaces, .space, .chatInfo, .spaceSettings, + case .chats, .archived, .spaces, .space, .chatInfo, .grid, .spaceSettings, .spaceIntegrations, .integrationOptions, .createSpaceChat, .createThread, .createSpace: nil } diff --git a/apple/InlineIOS/Shared/VoiceInputPicker.swift b/apple/InlineIOS/Shared/VoiceInputPicker.swift index 53e4e3841..dbf974d95 100644 --- a/apple/InlineIOS/Shared/VoiceInputPicker.swift +++ b/apple/InlineIOS/Shared/VoiceInputPicker.swift @@ -1,5 +1,6 @@ import AVFoundation import Combine +import InlineAudioPlayback import Logger import SwiftUI @@ -130,6 +131,7 @@ final class VoiceInputController: ObservableObject { @discardableResult func applyPreferredInput(to session: AVAudioSession = .sharedInstance()) throws -> VoiceInputState { + guard !InlineAudioSession.shared.isGridActive else { throw InlineAudioSessionError.gridOwnsAudio } guard let preferredDeviceId = activePreferredDeviceId else { log.debug("apply preferred=nil before \(routeSnapshot(for: session))") try session.setPreferredInput(nil) @@ -155,6 +157,7 @@ final class VoiceInputController: ObservableObject { persistence: VoiceInputPreferencePersistence = .persistent, in session: AVAudioSession = .sharedInstance() ) throws -> VoiceInputState { + guard !InlineAudioSession.shared.isGridActive else { throw InlineAudioSessionError.gridOwnsAudio } let unchanged: Bool switch persistence { case .persistent: @@ -205,6 +208,7 @@ final class VoiceInputController: ObservableObject { persistence: VoiceInputPreferencePersistence = .persistent, in session: AVAudioSession = .sharedInstance() ) throws -> VoiceInputState { + guard !InlineAudioSession.shared.isGridActive else { throw InlineAudioSessionError.gridOwnsAudio } let currentState = inputState(for: session) if deviceId == pendingDeviceId, currentState.selectedDeviceId != deviceId { diff --git a/apple/InlineIOS/Utils/LogoutPerformer.swift b/apple/InlineIOS/Utils/LogoutPerformer.swift index e21b2c6c5..55a87047b 100644 --- a/apple/InlineIOS/Utils/LogoutPerformer.swift +++ b/apple/InlineIOS/Utils/LogoutPerformer.swift @@ -1,4 +1,5 @@ import Auth +import InlineGrid import InlineKit import Logger import UIKit @@ -29,6 +30,17 @@ enum LogoutPerformer { // Fence playback and pending download intent before the first suspension. SharedAudioPlayer.shared.stop() + // Close media admission before logout's first suspension. Account fencing + // alone does not stop a sender already running in the RTC engine. + if let grid = GridRuntime.existing { + grid.rooms.withdrawLocalAdmission() + let mediaShutdown = await grid.prepareForLogout() + guard mediaShutdown.isLocallyQuiescent else { + Log.shared.error("iOS logout stopped: Grid local media shutdown could not be proven") + return + } + } + (UIApplication.shared.delegate as? AppDelegate)?.cancelPendingSpaceJoin() ProviderSignInCoordinator.shared.cancelPendingAttempt() diff --git a/apple/InlineKit/Package.swift b/apple/InlineKit/Package.swift index 9fa36e150..155904c1e 100644 --- a/apple/InlineKit/Package.swift +++ b/apple/InlineKit/Package.swift @@ -56,6 +56,10 @@ let package = Package( name: "InlineAudioPlayback", targets: ["InlineAudioPlayback"] ), + .library( + name: "InlineGrid", + targets: ["InlineGrid"] + ), .library( name: "InlineRTC", targets: ["InlineRTC"] @@ -133,12 +137,19 @@ let package = Package( swiftSettings: swiftSettings ), + .target( + name: "InlineGrid", + dependencies: ["InlineKit", "InlineRTC", "InlineAudioPlayback", "InlineProtocol", "RealtimeV2", "Auth", "Logger"], + swiftSettings: swiftSettings + ), + .target( name: "InlineRTC", dependencies: [ .product(name: "Atomics", package: "swift-atomics"), .product(name: "LiveKit", package: "client-sdk-swift"), "Logger", + "InlineAudioPlayback", ], // Retain failed and superseded audio backends as source-only archives. // The compiled macOS path is the directional AUHAL RTCAudioDevice. @@ -263,6 +274,12 @@ let package = Package( swiftSettings: swiftSettings ), + .testTarget( + name: "InlineGridTests", + dependencies: ["InlineGrid", "InlineProtocol", "InlineRTC", "Auth", "RealtimeV2"], + swiftSettings: swiftSettings + ), + .testTarget( name: "InlineRTCTests", dependencies: ["InlineRTC"], diff --git a/apple/InlineKit/Sources/Auth/Auth2Types.swift b/apple/InlineKit/Sources/Auth/Auth2Types.swift index 6c3764fc4..622599c04 100644 --- a/apple/InlineKit/Sources/Auth/Auth2Types.swift +++ b/apple/InlineKit/Sources/Auth/Auth2Types.swift @@ -10,6 +10,45 @@ public extension Notification.Name { ) } +extension Notification.Name { + /// Posted on MainActor after staging closes mutation admission, before replacement writes. + /// The object identifies the exact Auth cache and the payload carries its retirement request. + static let authAccountAuthorityWillChange = Notification.Name("inline.auth.accountAuthorityWillChange") +} + +/// A single staged authority transition. Process owners withdraw synchronously, then attach +/// their local retirement acknowledgement; Auth awaits it before replacing credentials. +@MainActor +public final class AuthAccountAuthorityRetirement { + public let previousAccount: AuthAccountMutationToken + private let stagingIsCurrent: @Sendable () -> Bool + private var retirement: Task? + + init(previousAccount: AuthAccountMutationToken, isCurrent: @escaping @Sendable () -> Bool) { + self.previousAccount = previousAccount + stagingIsCurrent = isCurrent + } + + public var isCurrent: Bool { stagingIsCurrent() } + + public func requireRetirement(_ task: Task) { + if let previous = retirement { + retirement = Task { + let previousStopped = await previous.value + let stopped = await task.value + return previousStopped && stopped + } + } else { + retirement = task + } + } + + func waitForRetirement() async -> Bool { + guard let retirement else { return true } + return await retirement.value + } +} + public struct InlineProtocolSessionCredentials: Sendable, Codable, Equatable { public var userId: Int64 public var accountSessionId: Int64 @@ -343,6 +382,24 @@ public struct AuthHandle: Sendable { try cache.validateAccountMutationToken(token) } + /// Stops process-owned work before a login or credential write replaces server authority. + /// Delivery is synchronous on MainActor and scoped to this handle's Auth store. The handler + /// must attach any required retirement task before returning. + @MainActor + public func observeAccountAuthorityWillChange( + _ handler: @escaping @MainActor @Sendable (AuthAccountAuthorityRetirement) -> Void + ) -> NSObjectProtocol { + NotificationCenter.default.addObserver( + forName: .authAccountAuthorityWillChange, object: cache, queue: .main + ) { notification in + guard let retirement = notification.userInfo?["retirement"] as? AuthAccountAuthorityRetirement else { return } + MainActor.assumeIsolated { + guard retirement.isCurrent else { return } + handler(retirement) + } + } + } + @discardableResult public func cancelLoginAttempt(_ attempt: AuthLoginAttempt) -> Bool { cache.invalidateLoginAttempt(attempt) diff --git a/apple/InlineKit/Sources/Auth/AuthStore.swift b/apple/InlineKit/Sources/Auth/AuthStore.swift index 8825c147d..421139678 100644 --- a/apple/InlineKit/Sources/Auth/AuthStore.swift +++ b/apple/InlineKit/Sources/Auth/AuthStore.swift @@ -294,7 +294,7 @@ actor AuthStore { status: .authenticated(record), didHydrate: true ) - try persistCredentialAuthority(snapshot: snapshot, loginAttempt: loginAttempt) { + try await persistCredentialAuthority(snapshot: snapshot, loginAttempt: loginAttempt) { try self.replaceStoredAuthority( with: [ Self.legacyTokenKey: Data(token.utf8), @@ -365,7 +365,7 @@ actor AuthStore { log.info("AUTH2_TEMPORARY_ROTATION persisted") return } - try persistCredentialAuthority(snapshot: snapshot, loginAttempt: loginAttempt) { + try await persistCredentialAuthority(snapshot: snapshot, loginAttempt: loginAttempt) { try self.replaceStoredAuthority( with: [Self.inlineProtocolCredentialsKey: data], replacing: [Self.legacyTokenKey, Self.credentialsV2Key], @@ -696,12 +696,25 @@ actor AuthStore { snapshot: AuthSnapshot, loginAttempt: AuthLoginAttempt?, writeAuthority: () throws -> Void - ) throws { + ) async throws { let finalizeImmediately = loginAttempt == nil let currentSnapshot = cache.snapshot() let preservesAccountMutationGeneration = finalizeImmediately && currentSnapshot.isLoggedIn && currentSnapshot.currentUserId == snapshot.currentUserId + let identicalImplicitBearer: Bool + if loginAttempt == nil, + case let .authenticated(current) = currentSnapshot.status, + case let .authenticated(next) = snapshot.status + { + identicalImplicitBearer = current.userId == next.userId && current.token == next.token + } else { + identicalImplicitBearer = false + } + // Preserve the existing mutation-generation policy, but require a physical retirement + // whenever server authority may change. Only an identical implicit bearer is provably + // unchanged here; same-session native temporary-key renewal bypasses this helper above. + let previousAccount = identicalImplicitBearer ? nil : try? cache.makeAccountMutationToken() let authorityAttempt: AuthLoginAttempt if let loginAttempt { authorityAttempt = loginAttempt @@ -741,6 +754,28 @@ actor AuthStore { stagedAuthorityBaseline = baseline do { + if let previousAccount { + // An already-running media engine does not perform account mutations on every frame. + // Retire its old authority before storage/projection work can suspend a native login. + let retirement = await MainActor.run { + let retirement = AuthAccountAuthorityRetirement(previousAccount: previousAccount) { + self.cache.isStagedAuthorityOwned(by: authorityAttempt) + && self.cache.isLoginAttemptCurrent(authorityAttempt) + } + NotificationCenter.default.post( + name: .authAccountAuthorityWillChange, + object: self.cache, + userInfo: ["retirement": retirement] + ) + return retirement + } + guard await retirement.waitForRetirement() else { throw AuthStorageError.loginUnavailable } + } + // The MainActor delivery can let logout or a newer login win on this actor. + try validateLoginAttempt(authorityAttempt) + guard cache.isStagedAuthorityOwned(by: authorityAttempt) else { + throw credentialCommitFenceError(for: authorityAttempt) + } try writeAuthority() if let userID = snapshot.currentUserId { UserDefaults.standard.set(NSNumber(value: userID), forKey: userDefaultsKey) @@ -757,7 +792,7 @@ actor AuthStore { _ = try finalizeCredentialsCommittedByLoginAttempt(authorityAttempt) } } catch { - if hasPendingLogout() == false { + if hasPendingLogout() == false, cache.isStagedAuthorityOwned(by: authorityAttempt) { do { try restoreStoredAuthority(baseline) UserDefaults.standard.removeObject(forKey: loginCommitPendingKey) @@ -773,7 +808,9 @@ actor AuthStore { "AUTH2_LOGIN failed to restore prior authority; retaining transition fence", error: error ) - promoteFailedLoginToRecovery(correlationID: authorityAttempt.correlationID, reason: "authority_restore_failed") + if hasPendingLogout() == false, cache.isStagedAuthorityOwned(by: authorityAttempt) { + promoteFailedLoginToRecovery(correlationID: authorityAttempt.correlationID, reason: "authority_restore_failed") + } } } throw error diff --git a/apple/InlineKit/Sources/InlineAudioPlayback/InlineAudioSession.swift b/apple/InlineKit/Sources/InlineAudioPlayback/InlineAudioSession.swift index cd676435c..fcfc9a9e0 100644 --- a/apple/InlineKit/Sources/InlineAudioPlayback/InlineAudioSession.swift +++ b/apple/InlineKit/Sources/InlineAudioPlayback/InlineAudioSession.swift @@ -1,4 +1,5 @@ import Foundation +import Observation #if os(iOS) import AVFAudio #endif @@ -6,6 +7,7 @@ import AVFAudio /// A generation fence for the few Inline features that share the process audio session. /// It does not own players or decide when they should resume. @MainActor +@Observable public final class InlineAudioSession { public static let shared = InlineAudioSession() @@ -19,23 +21,60 @@ public final class InlineAudioSession { } public private(set) var current: Token? - public var willAcquire: ((Owner) -> Void)? - private var generation: UInt64 = 0 - private let deactivate: () -> Void - private let backend: (any InlineAudioSessionBackend)? - private var transitionTail: Task? + @ObservationIgnored public var willAcquire: ((Owner) -> Void)? + @ObservationIgnored private var generation: UInt64 = 0 + @ObservationIgnored private let deactivate: () -> Void + @ObservationIgnored private let backend: (any InlineAudioSessionBackend)? + @ObservationIgnored private var transitionTail: Task? + + public private(set) var isQuarantined = false + public private(set) var isSpeakerPreferred = false + public private(set) var isSpeakerOutputActive = false + public private(set) var currentRouteName = "Audio" + public private(set) var supportsReceiverRouting = false + public var isGridActive: Bool { + gridToken != nil + } + + @ObservationIgnored private var gridToken: Token? + @ObservationIgnored private var gridEpoch: UInt64 = 0 + @ObservationIgnored private var gridRequestedEpoch: UInt64 = 0 + @ObservationIgnored private var gridDemandActive = false + @ObservationIgnored private var gridMediaQuiescent = true + @ObservationIgnored private var gridDirectionsIdle = true + @ObservationIgnored private var gridNativeActive = false + @ObservationIgnored private var gridTransitions = 0 + @ObservationIgnored private var gridRetiring = false + @ObservationIgnored private var gridRetirementTask: Task? + @ObservationIgnored private var gridRestartRequired = false struct Configuration: Sendable { let category: String let mode: String let options: UInt + var preferredIOBufferDuration: TimeInterval? + var usesAutomaticInput = false static let voicePlayback = Configuration( category: "AVAudioSessionCategoryPlayback", mode: "AVAudioSessionModeSpokenAudio", options: 0 ) + + static let gridVoice: Configuration = { + #if os(iOS) + let options = AVAudioSession.CategoryOptions.allowBluetoothHFP.rawValue + #else + let options: UInt = 0 + #endif + return Configuration( + category: "AVAudioSessionCategoryPlayAndRecord", mode: "AVAudioSessionModeVoiceChat", + options: options, preferredIOBufferDuration: 0.02, usesAutomaticInput: true + ) + }() } - var requiresNativeTransition: Bool { backend != nil } + var requiresNativeTransition: Bool { + backend != nil + } public init(deactivate: @escaping () -> Void = {}) { self.deactivate = deactivate @@ -52,34 +91,51 @@ public final class InlineAudioSession { } public var voiceDenial: AudioPlaybackError? { - switch current?.owner { - case .recording: .audioInUse("Finish recording to play this message") - case .draftPreview: .audioInUse("Pause the recording preview to play this message") - case .video: .audioInUse("Pause the video to play this message") - case .call: .audioInUse("Finish the call to play this message") - case .voice, nil: nil + if isQuarantined { + return .audioInUse(InlineAudioSessionError.quarantined.localizedDescription) + } + return switch current?.owner { + case .recording: .audioInUse("Finish recording to play this message") + case .draftPreview: .audioInUse("Pause the recording preview to play this message") + case .video: .audioInUse("Pause the video to play this message") + case .call: .audioInUse("Finish the call to play this message") + case .voice, nil: nil } } public func acquire(_ owner: Owner) throws -> Token { - if owner == .voice, let current, current.owner == .voice { return current } - if owner == .voice, let denial = voiceDenial { throw denial } + guard !isQuarantined else { throw InlineAudioSessionError.quarantined } + if owner == .voice, let current, current.owner == .voice { + return current + } + if owner == .voice, let denial = voiceDenial { + throw denial + } if let current, current.owner != .voice { throw AudioPlaybackError.audioInUse("Other Inline audio is playing") } // A non-voice client fences pending requests even while no engine is playing. willAcquire?(owner) + // A takeover callback can synchronously change ownership itself. + if let current, current.owner != .voice { + throw AudioPlaybackError.audioInUse("Other Inline audio is playing") + } generation &+= 1 let token = Token(owner: owner, generation: generation) current = token return token } - public func owns(_ token: Token) -> Bool { current == token } + public func owns(_ token: Token) -> Bool { + current == token + } /// Revokes the logical lease immediately. Its native teardown is ordered before the next activation. @discardableResult public func release(_ token: Token) -> Task? { + // Grid's reservation survives logical withdrawal until provider/native idle + // and the matching native teardown both succeed. + guard token != gridToken else { return nil } guard owns(token) else { return nil } current = nil deactivate() @@ -88,6 +144,10 @@ public final class InlineAudioSession { } public func invalidate() { + if gridToken != nil { + quarantineAfterMediaServicesReset() + return + } generation &+= 1 current = nil if let backend { @@ -96,6 +156,7 @@ public final class InlineAudioSession { } func activate(_ token: Token, configuration: Configuration) async throws { + guard token != gridToken else { throw InlineAudioSessionError.retirementPending } guard owns(token) else { throw CancellationError() } guard let backend else { return } let transition = enqueue { [weak self] in @@ -111,6 +172,189 @@ public final class InlineAudioSession { } } + /// Read-only preflight; actual token acquisition repeats it after the claim. + public func validateGridAdmission() throws { + try validateGridAdmission(reusingReservation: false) + } + + private func validateGridAdmission(reusingReservation: Bool) throws { + guard !isQuarantined, !gridRestartRequired else { throw InlineAudioSessionError.quarantined } + guard !gridRetiring, gridToken == nil || gridDemandActive || reusingReservation else { + throw InlineAudioSessionError.retirementPending + } + switch current?.owner { + case .recording: throw InlineAudioSessionError.recordingOwnsAudio + case .draftPreview: throw AudioPlaybackError.audioInUse("Pause the recording preview before joining Grid.") + case .video: throw AudioPlaybackError.audioInUse("Pause the video before joining Grid.") + case .call where current != gridToken: throw AudioPlaybackError.audioInUse("Other Inline audio is playing") + default: break + } + } + + public func setGridDemandActive(_ active: Bool, epoch: UInt64) async throws { + guard epoch >= gridRequestedEpoch else { return } + gridRequestedEpoch = epoch + if let retirement = gridRetirementTask { + // Once native release has been committed, wait for its token cleanup as + // well. A successor cannot reuse a session that is being deactivated. + await retirement.value + guard epoch == gridRequestedEpoch else { return } + try Task.checkCancellation() + } + guard epoch >= gridEpoch else { return } + if active { + let reusesReservation = epoch > gridEpoch && gridToken.map(owns) == true + // A replacement room inherits the still-owned reservation while old + // provider work retires; its stale receipts must not release the new room. + try validateGridAdmission(reusingReservation: reusesReservation) + if gridToken == nil { + gridToken = try acquire(.call) + } + } + if epoch != gridEpoch || active != gridDemandActive { + gridMediaQuiescent = false + gridDirectionsIdle = false + } + gridEpoch = epoch + gridDemandActive = active + guard active else { + await finishGridRetirementIfReady() + return + } + try await performGridTransition(epoch: epoch) { [weak self] token in + guard let self, !gridNativeActive else { return } + try await activateGridNative(token, epoch: epoch) + } + } + + public func gridMediaDidQuiesce(epoch: UInt64) async { + guard epoch == gridEpoch, !gridDemandActive else { return } + gridMediaQuiescent = true + await finishGridRetirementIfReady() + } + + public func observeGridDirections( + recording: Bool, playing: Bool, nativeOperationsQuiescent: Bool, epoch: UInt64 + ) async { + guard epoch == gridEpoch else { return } + gridDirectionsIdle = nativeOperationsQuiescent && !recording && !playing + refreshGridRoute() + await finishGridRetirementIfReady() + } + + /// Driver keeps ADM input/output closed across this receive-only recovery. + public func resumeGridSession(epoch: UInt64) async throws { + try await performGridTransition(epoch: epoch) { [weak self] token in + guard let self else { throw CancellationError() } + try await activateGridNative(token, epoch: epoch) + } + } + + private func activateGridNative(_ token: Token, epoch: UInt64) async throws { + do { + try await backend?.activate(token: token, configuration: .gridVoice) + guard gridToken == token, owns(token), gridEpoch == epoch, gridDemandActive, + !isQuarantined else { throw CancellationError() } + if let backend { + try await backend.setSpeakerPreferred(isSpeakerPreferred, token: token) + } + } catch { + // Readiness includes the requested route. Only an entered native failure + // can change it; rejected stale recovery never reaches this operation. + if gridToken == token, owns(token) { + gridNativeActive = false + } + throw error + } + if gridToken == token, owns(token) { + gridNativeActive = true + } + } + + public func quarantineAfterMediaServicesReset() { + gridRestartRequired = true + // The persistent Grid observer must not change standalone reset behavior. + if gridToken != nil { + isQuarantined = true + } + } + + public func setSpeakerPreferred(_ preferred: Bool) async throws { + let epoch = gridEpoch + try await performGridTransition(epoch: epoch) { [weak self] token in + guard let self, gridNativeActive else { throw InlineAudioSessionError.routeUnavailable } + try await backend?.setSpeakerPreferred(preferred, token: token) + } + isSpeakerPreferred = preferred + refreshGridRoute() + } + + private func performGridTransition( + epoch: UInt64, _ operation: @escaping @MainActor (Token) async throws -> Void + ) async throws { + guard let token = gridToken, owns(token), gridDemandActive, epoch == gridEpoch, !gridRetiring, + !isQuarantined else { throw InlineAudioSessionError.retirementPending } + gridTransitions += 1 + let transition = enqueue { [weak self] in + guard let self, owns(token), gridToken == token, epoch == gridEpoch, + gridDemandActive, !gridRetiring, !isQuarantined else { throw CancellationError() } + try await operation(token) + } + let result: Result + do { try await transition.value + result = .success(()) + } catch { result = .failure(error) } + gridTransitions -= 1 + await finishGridRetirementIfReady() + try result.get() + guard gridToken == token, owns(token), epoch == gridEpoch, gridDemandActive, + !isQuarantined, !Task.isCancelled else { throw CancellationError() } + refreshGridRoute() + } + + private func finishGridRetirementIfReady() async { + guard let token = gridToken, owns(token), !gridDemandActive, gridMediaQuiescent, + gridDirectionsIdle, gridTransitions == 0, !gridRetiring, !isQuarantined else { return } + gridRetiring = true + let epoch = gridEpoch + let retirement = Task { [weak self] in + await self?.retireGridToken(token, epoch: epoch) + } + gridRetirementTask = retirement + await retirement.value + } + + private func retireGridToken(_ token: Token, epoch: UInt64) async { + defer { gridRetirementTask = nil } + let transition = enqueue { [weak self] in + guard let self, gridToken == token, owns(token), gridEpoch == epoch, + !gridDemandActive, gridMediaQuiescent, gridDirectionsIdle else { throw CancellationError() } + try await backend?.release(token: token) + } + do { + try await transition.value + guard gridToken == token, owns(token) else { return } + current = nil + gridToken = nil + gridNativeActive = false + gridRetiring = false + deactivate() + refreshGridRoute() + } catch { + isQuarantined = true + gridRetiring = false + } + } + + private func refreshGridRoute() { + #if os(iOS) + let session = AVAudioSession.sharedInstance() + currentRouteName = session.currentRoute.outputs.first?.portName ?? "Audio" + isSpeakerOutputActive = session.currentRoute.outputs.contains { $0.portType == .builtInSpeaker } + supportsReceiverRouting = isGridActive && session.category == .playAndRecord + #endif + } + /// Only this chain may enter the native backend. Actor reentrancy alone does not order async I/O. private func enqueue(_ operation: @escaping @MainActor () async throws -> Void) -> Task { let previous = transitionTail @@ -141,6 +385,25 @@ protocol InlineAudioSessionBackend: Sendable { func activate(token: InlineAudioSession.Token, configuration: InlineAudioSession.Configuration) async throws func release(token: InlineAudioSession.Token) async throws func invalidate() async + func setSpeakerPreferred(_ preferred: Bool, token: InlineAudioSession.Token) async throws +} + +extension InlineAudioSessionBackend { + func setSpeakerPreferred(_: Bool, token _: InlineAudioSession.Token) async throws {} +} + +public enum InlineAudioSessionError: LocalizedError { + case gridOwnsAudio, recordingOwnsAudio, quarantined, retirementPending, routeUnavailable + + public var errorDescription: String? { + switch self { + case .gridOwnsAudio: "Leave Grid to record a voice message." + case .recordingOwnsAudio: "Finish recording your voice message before joining Grid." + case .quarantined: "Audio needs to restart. Quit and reopen Inline before starting audio again." + case .retirementPending: "Audio is still stopping. Try again shortly." + case .routeUnavailable: "Audio output is unavailable. Resume Grid audio and try again." + } + } } #if os(iOS) @@ -150,31 +413,56 @@ private actor NativeAudioSessionBackend: InlineAudioSessionBackend { let category: AVAudioSession.Category let mode: AVAudioSession.Mode let options: AVAudioSession.CategoryOptions + let preferredIOBufferDuration: TimeInterval? } + private var token: InlineAudioSession.Token? private var snapshot: Snapshot? func activate(token: InlineAudioSession.Token, configuration: InlineAudioSession.Configuration) async throws { // A failed release keeps its snapshot. Retry it before capturing or configuring a successor. - if let previous = self.token { try await release(token: previous) } + let recoveringCall = self.token == token && token.owner == .call + if !recoveringCall, let previous = self.token { + try await release(token: previous) + } let session = AVAudioSession.sharedInstance() - snapshot = Snapshot(category: session.category, mode: session.mode, options: session.categoryOptions) + if !recoveringCall { + snapshot = Snapshot( + category: session.category, + mode: session.mode, + options: session.categoryOptions, + preferredIOBufferDuration: configuration.preferredIOBufferDuration == nil + ? nil : session.preferredIOBufferDuration + ) + } self.token = token do { - try session.setCategory(AVAudioSession.Category(rawValue: configuration.category), - mode: AVAudioSession.Mode(rawValue: configuration.mode), - options: AVAudioSession.CategoryOptions(rawValue: configuration.options)) + try session.setCategory( + AVAudioSession.Category(rawValue: configuration.category), + mode: AVAudioSession.Mode(rawValue: configuration.mode), + options: AVAudioSession.CategoryOptions(rawValue: configuration.options) + ) + if let duration = configuration.preferredIOBufferDuration { + try session.setPreferredIOBufferDuration(duration) + } if #available(iOS 27.0, *) { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in session.activate(options: []) { activated, error in - if let error { continuation.resume(throwing: error) } - else if activated { continuation.resume() } - else { continuation.resume(throwing: AudioPlaybackError.playbackUnavailable) } + if let error { + continuation.resume(throwing: error) + } else if activated { + continuation.resume() + } else { + continuation.resume(throwing: AudioPlaybackError.playbackUnavailable) + } } } } else { try session.setActive(true) } + if configuration.usesAutomaticInput { + try session.setPreferredInput(nil) + } } catch { // Preserve cleanup state if teardown fails; a later activation retries it in this same lane. try? await release(token: token) @@ -188,9 +476,13 @@ private actor NativeAudioSessionBackend: InlineAudioSessionBackend { if #available(iOS 27.0, *) { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in session.deactivate(options: [.notifyOthersOnDeactivation]) { deactivated, error in - if let error { continuation.resume(throwing: error) } - else if deactivated { continuation.resume() } - else { continuation.resume(throwing: AudioPlaybackError.playbackUnavailable) } + if let error { + continuation.resume(throwing: error) + } else if deactivated { + continuation.resume() + } else { + continuation.resume(throwing: AudioPlaybackError.playbackUnavailable) + } } } } else { @@ -198,16 +490,26 @@ private actor NativeAudioSessionBackend: InlineAudioSessionBackend { } if let snapshot { try session.setCategory(snapshot.category, mode: snapshot.mode, options: snapshot.options) + if let duration = snapshot.preferredIOBufferDuration { + try session.setPreferredIOBufferDuration(duration) + } } - self.snapshot = nil + snapshot = nil self.token = nil } + func setSpeakerPreferred(_ preferred: Bool, token: InlineAudioSession.Token) async throws { + guard self.token == token, token.owner == .call else { throw CancellationError() } + try AVAudioSession.sharedInstance().overrideOutputAudioPort(preferred ? .speaker : .none) + } + func invalidate() async { // A reset invalidates restoration, but an in-flight activation may have just completed. // Retire that native lease before allowing any successor to snapshot/configure the session. snapshot = nil - if let token { try? await release(token: token) } + if let token { + try? await release(token: token) + } } } #endif diff --git a/apple/InlineKit/Sources/InlineGrid/GridAvatarStateSync.swift b/apple/InlineKit/Sources/InlineGrid/GridAvatarStateSync.swift new file mode 100644 index 000000000..1f3f20767 --- /dev/null +++ b/apple/InlineKit/Sources/InlineGrid/GridAvatarStateSync.swift @@ -0,0 +1,117 @@ +import Auth +import Logger + +/// Serializes the transient state owned by the current Grid avatar. +/// +/// Rapid UI changes are coalesced to the latest value while one RPC is in +/// flight, so task scheduling cannot reorder mute/unmute updates on the wire. +/// Failed latest state is retained for a network/wake retry, but ownership +/// changes immediately discard work for a room this process no longer owns. +@MainActor +final class GridAvatarStateSync { + private struct Request: Equatable { + let roomID: Int64 + let membershipID: String + let accountToken: AuthAccountMutationToken + let microphoneEnabled: Bool + } + + private let api: GridRoomAPI + private let log = Log.scoped("GridAvatarStateSync") + private var ownedRoomID: Int64? + private var ownedMembershipID: String? + private var ownedAccountToken: AuthAccountMutationToken? + private var queued: Request? + private var failed: Request? + private var generation = 0 + private var syncTask: Task? + private var workerGeneration: Int? + + init(api: GridRoomAPI) { + self.api = api + } + + deinit { + syncTask?.cancel() + } + + func setOwnedRoom(_ roomID: Int64?, membershipID: String? = nil, accountToken: AuthAccountMutationToken? = nil) { + guard ownedRoomID != roomID || ownedMembershipID != membershipID || ownedAccountToken != accountToken + else { return } + generation &+= 1 + ownedRoomID = roomID + ownedMembershipID = membershipID + ownedAccountToken = accountToken + if queued?.roomID != roomID || queued?.membershipID != membershipID { + queued = nil + } + if failed?.roomID != roomID || failed?.membershipID != membershipID { + failed = nil + } + syncTask?.cancel() + syncTask = nil + workerGeneration = nil + startIfNeeded() + } + + func submitMicrophoneState(roomID: Int64, enabled: Bool) { + guard ownedRoomID == roomID, let membershipID = ownedMembershipID, let accountToken = ownedAccountToken, + !membershipID.isEmpty else { return } + queued = Request(roomID: roomID, membershipID: membershipID, accountToken: accountToken, microphoneEnabled: enabled) + failed = nil + startIfNeeded() + } + + func retryLatestFailure() { + guard queued == nil, let failed, failed.roomID == ownedRoomID, + failed.membershipID == ownedMembershipID else { return } + queued = failed + self.failed = nil + startIfNeeded() + } + + private func startIfNeeded() { + guard syncTask == nil, queued != nil else { return } + let workerGeneration = generation + self.workerGeneration = workerGeneration + syncTask = Task { [weak self] in + await self?.run(workerGeneration: workerGeneration) + } + } + + private func run(workerGeneration: Int) async { + while !Task.isCancelled, generation == workerGeneration, let request = queued { + queued = nil + do { + try await api.setMicrophoneEnabled( + request.microphoneEnabled, + roomID: request.roomID, + expectedMembershipID: request.membershipID, + accountToken: request.accountToken + ) + log.debug( + "GRID_TRACE phase=avatar_microphone_sent room=\(request.roomID) enabled=\(request.microphoneEnabled)" + ) + } catch { + guard !Task.isCancelled, generation == workerGeneration else { continue } + // Preserve only the latest still-relevant request. A newer UI intent + // already waiting in `queued` supersedes the failed value. + if ownedRoomID == request.roomID, ownedMembershipID == request.membershipID, queued == nil { + failed = request + } + log.warning( + "GRID_TRACE phase=avatar_microphone_send_failed room=\(request.roomID) enabled=\(request.microphoneEnabled)" + ) + PerformanceTrace.breadcrumb( + "Grid avatar microphone sync failed", + category: "Grid.State", + level: .warning + ) + } + } + guard self.workerGeneration == workerGeneration else { return } + syncTask = nil + self.workerGeneration = nil + startIfNeeded() + } +} diff --git a/apple/InlineKit/Sources/InlineGrid/GridCallClaimAdmission.swift b/apple/InlineKit/Sources/InlineGrid/GridCallClaimAdmission.swift new file mode 100644 index 000000000..019f675f7 --- /dev/null +++ b/apple/InlineKit/Sources/InlineGrid/GridCallClaimAdmission.swift @@ -0,0 +1,35 @@ +import InlineProtocol + +/// Validates the committed claim against the latest projections already +/// accepted by this process. A successful RPC alone cannot authorize audio. +enum GridCallClaimAdmission { + static func accepts( + committedCall: GridCurrentCall, + newestGrid: InlineProtocol.Grid?, + acceptedAuthority: GridOwnedRoomAuthority? + ) -> Bool { + guard committedCall.ownedByCurrentSession, + !committedCall.callID.isEmpty, + !committedCall.membershipID.isEmpty, + acceptedAuthority?.matches(committedCall) == true, + newestGrid?.enabled == true, + newestGrid?.spaceID == committedCall.spaceID, + newestGrid?.rooms.first(where: { $0.id == committedCall.roomID })?.avatars.contains(where: { + $0.ownedByCurrentSession && $0.membershipID == committedCall.membershipID + }) == true + else { return false } + return true + } +} + +/// Only this small self tuple is retained before optimistic UI overlays. +/// It prevents a pending Join's avatar projection from granting media access. +struct GridOwnedRoomAuthority: Equatable, Sendable { + let spaceID: Int64 + let roomID: Int64 + let membershipID: String + + func matches(_ call: GridCurrentCall) -> Bool { + spaceID == call.spaceID && roomID == call.roomID && membershipID == call.membershipID + } +} diff --git a/apple/InlineKit/Sources/InlineGrid/GridCredentialBinding.swift b/apple/InlineKit/Sources/InlineGrid/GridCredentialBinding.swift new file mode 100644 index 000000000..eaae166e5 --- /dev/null +++ b/apple/InlineKit/Sources/InlineGrid/GridCredentialBinding.swift @@ -0,0 +1,26 @@ +import InlineProtocol + +/// New credentials bind to the stable call and rotating owner. Legacy +/// compatibility is limited to an explicit macOS admission while capability is +/// OFF, with the existing user/membership participant identity still exact. +enum GridCredentialBinding { + static func accepts( + credentials: GridConnectionCredentials, + target: GridMediaTarget, + userID: Int64, + callTransferEnabled: Bool + ) -> Bool { + guard credentials.hasConnection, + credentials.connection.roomID == target.roomID, + credentials.connection.generation == target.generation, + !target.membershipID.isEmpty, + credentials.participantIdentity == "inline-grid-user-\(userID)-\(target.membershipID)" + else { return false } + if callTransferEnabled { + return !target.callID.isEmpty && credentials.callID == target.callID + && credentials.membershipID == target.membershipID + } + return (credentials.callID.isEmpty || credentials.callID == target.callID) + && (credentials.membershipID.isEmpty || credentials.membershipID == target.membershipID) + } +} diff --git a/apple/InlineKit/Sources/InlineGrid/GridHomePreferences.swift b/apple/InlineKit/Sources/InlineGrid/GridHomePreferences.swift new file mode 100644 index 000000000..b7b7dc160 --- /dev/null +++ b/apple/InlineKit/Sources/InlineGrid/GridHomePreferences.swift @@ -0,0 +1,40 @@ +import Foundation +import InlineProtocol + +/// Local navigation history used only as a stable tie-breaker after live Grid +/// activity. It does not own eligibility or presence; the server summary does. +@MainActor +final class GridHomePreferences { + private let defaults: UserDefaults + private let key = "grid.home.lastOpenedSpaces" + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func recordOpened(spaceID: Int64) { + var values = timestamps + values[String(spaceID)] = Date().timeIntervalSince1970 + defaults.set(values, forKey: key) + } + + func ordered(_ spaces: [GridHomeSpace]) -> [GridHomeSpace] { + let timestamps = timestamps + return spaces.sorted { lhs, rhs in + let lhsActive = lhs.activeAvatarCount > 0 + let rhsActive = rhs.activeAvatarCount > 0 + if lhsActive != rhsActive { return lhsActive } + if lhs.latestActivityAt != rhs.latestActivityAt { + return lhs.latestActivityAt > rhs.latestActivityAt + } + let lhsOpened = timestamps[String(lhs.spaceID)] ?? 0 + let rhsOpened = timestamps[String(rhs.spaceID)] ?? 0 + if lhsOpened != rhsOpened { return lhsOpened > rhsOpened } + return lhs.spaceID < rhs.spaceID + } + } + + private var timestamps: [String: Double] { + defaults.dictionary(forKey: key) as? [String: Double] ?? [:] + } +} diff --git a/apple/InlineKit/Sources/InlineGrid/GridMediaCoordinator.swift b/apple/InlineKit/Sources/InlineGrid/GridMediaCoordinator.swift new file mode 100644 index 000000000..3bb9d51ec --- /dev/null +++ b/apple/InlineKit/Sources/InlineGrid/GridMediaCoordinator.swift @@ -0,0 +1,597 @@ +import Foundation +import InlineKit +import InlineRTC + +enum GridMediaCoordinatorEvent: Sendable { + case credentialsNeeded(GridMediaTarget) + case connected(GridMediaTarget, rtcConnectMilliseconds: Int?) + case screenShareContextChanged + case microphoneSafetyPaused + case audioEligibilityChanged(Bool) +} + +struct GridAutomaticMicrophoneChange: Equatable, Sendable { + let revision: Int + let previousEnabled: Bool + let appliedEnabled: Bool +} + +/// Main-actor bridge between room product state and the process-wide media +/// engine. This owns media intent, preference persistence, credentials, and +/// snapshot projection; `GridRoomService` never reaches into audio or RTC. +@MainActor +final class GridMediaCoordinator { + let presentation: GridMediaPresentation + + private var platformEffects = GridPlatformEffects() + private let engine: InlineRTCSession + private let presentationController: GridMediaPresentationController + private let inputPreferences: AudioInputPreferenceStore + private let outputPreferences: AudioOutputPreferenceStore + private let defaults: UserDefaults + private var microphoneEnabled: Bool + private var microphoneCapturePrepared = false + private var lastSafetyPauseRevision: UInt64 = 0 + private var lastAudioEligibility = false + private var microphoneIntentRevision = 0 + private var activeAutomaticMicrophoneChange: GridAutomaticMicrophoneChange? + private var autoUnmuteOnJoin: Bool + private var autoMuteWhenAlone: Bool + private var inputSelection: AudioInputSelection + private var outputSelection: AudioOutputSelection + private var outputVolume: Float = 1 + private var screenCaptureSource: InlineRTCScreenCaptureSource? + private var screenShareQualityProfile: InlineRTCScreenShareQualityProfile + private var screenCaptureSourceRefresh: GridScreenCaptureSourceRefresh? + private var target: GridMediaTarget? + private var connectedTarget: GridMediaTarget? + private var credentials: InlineRTCCredentials? + private var lastDemand: InlineRTCDemand? + private var playedConnectionSoundKeys = Set() + private var engineTask: Task? + private var subscribers: [UUID: AsyncStream.Continuation] = [:] + + private static let microphoneEnabledKey = "grid.microphoneEnabled" + private static let autoUnmuteOnJoinKey = "grid.autoUnmuteOnJoin" + private static let autoMuteWhenAloneKey = "grid.autoMuteWhenAlone" + private static let screenShareQualityProfileKey = "grid.screenShareQualityProfile" + + init( + engine: InlineRTCSession, + defaults: UserDefaults, + inputPreferences: AudioInputPreferenceStore, + outputPreferences: AudioOutputPreferenceStore + ) { + self.engine = engine + self.defaults = defaults + self.inputPreferences = inputPreferences + self.outputPreferences = outputPreferences + #if os(iOS) + inputSelection = .automatic + outputSelection = .automatic + microphoneEnabled = false + autoUnmuteOnJoin = false + autoMuteWhenAlone = false + #else + inputSelection = inputPreferences.selection + outputSelection = outputPreferences.selection + microphoneEnabled = defaults.bool(forKey: Self.microphoneEnabledKey) + autoUnmuteOnJoin = defaults.bool(forKey: Self.autoUnmuteOnJoinKey) + autoMuteWhenAlone = defaults.bool(forKey: Self.autoMuteWhenAloneKey) + #endif + screenShareQualityProfile = defaults.string(forKey: Self.screenShareQualityProfileKey) + .flatMap(InlineRTCScreenShareQualityProfile.init(rawValue:)) + ?? .automatic + let controller = GridMediaPresentationController( + microphoneEnabled: microphoneEnabled, + autoUnmuteOnJoin: autoUnmuteOnJoin, + autoMuteWhenAlone: autoMuteWhenAlone, + inputSelection: inputSelection, + screenShareQualityProfile: screenShareQualityProfile, + outputSelection: outputSelection + ) + presentationController = controller + presentation = controller.presentation + + engineTask = Task { [weak self, engine] in + await engine.start() + guard !Task.isCancelled else { return } + engine.refreshDevices() + self?.submitDemand() + let snapshots = await engine.subscribe() + for await snapshot in snapshots { + guard !Task.isCancelled else { return } + self?.apply(snapshot) + } + } + } + + deinit { + engineTask?.cancel() + subscribers.values.forEach { $0.finish() } + engine.requestShutdown() + } + + func subscribe() -> AsyncStream { + let id = UUID() + let stream = AsyncStream.makeStream( + of: GridMediaCoordinatorEvent.self, + // These are edge notifications backed by complete engine snapshots and + // target-scoped credential retry. A stalled observer must stay bounded. + bufferingPolicy: .bufferingNewest(16) + ) + stream.continuation.onTermination = { [weak self] _ in + Task { @MainActor in self?.subscribers.removeValue(forKey: id) } + } + subscribers[id] = stream.continuation + return stream.stream + } + + func configurePlatformEffects(_ effects: GridPlatformEffects) { + platformEffects = effects + } + + func setTarget(_ target: GridMediaTarget?) { + #if os(iOS) + if target == nil || self.target?.callID != target?.callID + || self.target?.membershipID != target?.membershipID + { + microphoneCapturePrepared = false + setMicrophoneEnabled(false) + } + #endif + guard self.target != target else { + submitDemand() + return + } + screenCaptureSource = nil + presentationController.setSelectedScreenCaptureSource(nil) + presentationController.clearScreenCaptureError() + platformEffects.updateScreenShareOutline?(nil) + platformEffects.closeScreenShares?() + self.target = target + if credentials?.target != target?.rtcSessionID { + credentials = nil + } + submitDemand() + } + + func accept(_ credentials: InlineRTCCredentials) -> Bool { + guard credentials.target == target?.rtcSessionID else { return false } + self.credentials = credentials + submitDemand() + return true + } + + func hasUsableCredentials(for target: GridMediaTarget) -> Bool { + credentials?.target == target.rtcSessionID + && (credentials?.expiresAt.timeIntervalSinceNow ?? 0) > 5 + } + + func isConnected(to target: GridMediaTarget) -> Bool { + connectedTarget == target + } + + func invalidateCredentials(for target: GridMediaTarget) { + guard credentials?.target == target.rtcSessionID else { return } + credentials = nil + submitDemand() + } + + func clearCredentials() { + guard credentials != nil else { return } + credentials = nil + submitDemand() + } + + @discardableResult + func withdrawLocalMedia() -> Task { + microphoneCapturePrepared = false + #if os(iOS) + setMicrophoneEnabled(false) + #endif + clearCredentials() + setTarget(nil) + lastDemand = nil + return engine.requestShutdownReceipt() + } + + func toggleMicrophone() -> Bool { + setMicrophoneEnabled(!microphoneEnabled) + } + + @discardableResult + func setMicrophoneEnabled(_ enabled: Bool) -> Bool { + #if os(iOS) + if enabled, target == nil { + return false + } + if enabled { + microphoneCapturePrepared = true + } + #endif + guard microphoneEnabled != enabled else { + submitDemand() + return enabled + } + if enabled, presentation.microphonePermission != .authorized { + engine.requestMicrophonePermission() + } + activeAutomaticMicrophoneChange = nil + microphoneIntentRevision &+= 1 + microphoneEnabled = enabled + presentationController.setMicrophoneEnabled(enabled) + defaults.set(enabled, forKey: Self.microphoneEnabledKey) + submitDemand() + return enabled + } + + func applyAutoUnmuteOnJoin() -> GridAutomaticMicrophoneChange? { + guard autoUnmuteOnJoin else { return nil } + if microphoneEnabled { + return activeAutomaticMicrophoneChange + } + let previousEnabled = microphoneEnabled + setMicrophoneEnabled(true) + let change = GridAutomaticMicrophoneChange( + revision: microphoneIntentRevision, + previousEnabled: previousEnabled, + appliedEnabled: true + ) + activeAutomaticMicrophoneChange = change + return change + } + + @discardableResult + func restoreAutomaticMicrophoneChangeIfCurrent( + _ change: GridAutomaticMicrophoneChange? + ) -> Bool { + guard let change, + activeAutomaticMicrophoneChange == change, + microphoneIntentRevision == change.revision, + microphoneEnabled == change.appliedEnabled + else { return false } + setMicrophoneEnabled(change.previousEnabled) + return true + } + + func commitAutomaticMicrophoneChange(_ change: GridAutomaticMicrophoneChange?) { + guard activeAutomaticMicrophoneChange == change else { return } + activeAutomaticMicrophoneChange = nil + } + + func setAutoUnmuteOnJoin(_ enabled: Bool) { + guard autoUnmuteOnJoin != enabled else { return } + autoUnmuteOnJoin = enabled + defaults.set(enabled, forKey: Self.autoUnmuteOnJoinKey) + presentationController.setAutoUnmuteOnJoin(enabled) + } + + func setAutoMuteWhenAlone(_ enabled: Bool) { + guard autoMuteWhenAlone != enabled else { return } + autoMuteWhenAlone = enabled + defaults.set(enabled, forKey: Self.autoMuteWhenAloneKey) + presentationController.setAutoMuteWhenAlone(enabled) + } + + func requestMicrophonePermission() { + engine.requestMicrophonePermission() + } + + func setInput(_ selection: AudioInputSelection) { + if inputSelection == selection { + if presentation.isFallingBackToAutomaticInput || presentation.audioState.isFailed { + engine.retryInput(selection) + } + return + } + inputSelection = selection + inputPreferences.setSelection(selection) + presentationController.setDesiredInputSelection(selection) + submitDemand() + } + + func setOutput(_ selection: AudioOutputSelection) { + if outputSelection == selection { + if presentation.isFallingBackToAutomaticOutput || presentation.audioState.isFailed { + engine.retryOutput(selection) + } + return + } + outputSelection = selection + outputPreferences.setSelection(selection) + presentationController.setDesiredOutputSelection(selection) + submitDemand() + } + + func setOutputVolume(_ volume: Float) { + outputVolume = min(max(volume, 0), 1) + presentationController.setDesiredOutputVolume(outputVolume) + submitDemand() + } + + func refreshInputDevices() { + engine.refreshDevices() + } + + func refreshOutputDevices() { + engine.refreshDevices() + } + + @discardableResult + func refreshScreenCaptureSources() async -> [InlineRTCScreenCaptureSource] { + if let refresh = screenCaptureSourceRefresh { + return await finishScreenCaptureSourceRefresh( + refresh.task.value, + id: refresh.id + ) + } + + let refresh = GridScreenCaptureSourceRefresh( + id: UUID(), + task: Task { [engine] in + do { + let sources = try await engine.screenCaptureSources() + guard !sources.isEmpty else { + return .failure("No displays are available") + } + return .success(sources) + } catch { + return .failure(error.localizedDescription) + } + } + ) + screenCaptureSourceRefresh = refresh + presentationController.setRefreshingScreenCaptureSources(true) + return await finishScreenCaptureSourceRefresh( + refresh.task.value, + id: refresh.id + ) + } + + func startScreenSharing(displayID: UInt32?) async { + guard let expectedTarget = target else { return } + let sources = await refreshScreenCaptureSources() + guard target == expectedTarget else { return } + let source = displayID.flatMap { displayID in + sources.first { $0.displayID == displayID } + } ?? sources.first + guard let source else { return } + startScreenSharing(source) + } + + func startScreenSharing(_ source: InlineRTCScreenCaptureSource) { + guard target != nil else { return } + screenCaptureSource = source + presentationController.setSelectedScreenCaptureSource(source) + presentationController.clearScreenCaptureError() + submitDemand() + } + + func stopScreenSharing() { + screenCaptureSource = nil + presentationController.setSelectedScreenCaptureSource(nil) + presentationController.clearScreenCaptureError() + platformEffects.updateScreenShareOutline?(nil) + submitDemand() + } + + func setScreenShareQualityProfile(_ profile: InlineRTCScreenShareQualityProfile) { + guard screenShareQualityProfile != profile else { return } + screenShareQualityProfile = profile + defaults.set(profile.rawValue, forKey: Self.screenShareQualityProfileKey) + presentationController.setScreenShareQualityProfile(profile) + submitDemand() + } + + func retryAudio() { + engine.retryAudio() + } + + func networkBecameAvailable() { + engine.networkBecameAvailable() + } + + func applicationDidWake() { + engine.applicationDidWake() + } + + @discardableResult + func shutdown() async -> GridMediaShutdownReceipt { + target = nil + connectedTarget = nil + credentials = nil + screenCaptureSource = nil + screenCaptureSourceRefresh?.task.cancel() + screenCaptureSourceRefresh = nil + presentationController.setRefreshingScreenCaptureSources(false) + presentationController.setSelectedScreenCaptureSource(nil) + presentationController.clearScreenCaptureError() + platformEffects.updateScreenShareOutline?(nil) + platformEffects.closeScreenShares?() + lastDemand = nil + engine.setDemand(InlineRTCDemand()) + return await engine.requestShutdownReceipt().value + } + + var isMicrophoneEnabled: Bool { + microphoneEnabled + } + + var shouldAutoMuteWhenAlone: Bool { + autoMuteWhenAlone + } + + private var microphoneCapturePreparedForDemand: Bool { + #if os(iOS) + microphoneCapturePrepared && target != nil + #else + target != nil + #endif + } + + private func submitDemand() { + let rtcTarget = target?.rtcSessionID + if credentials?.target != rtcTarget { + credentials = nil + } + let demand = InlineRTCDemand( + target: rtcTarget, + credentials: credentials, + microphoneEnabled: microphoneEnabled, + microphoneCapturePrepared: microphoneCapturePreparedForDemand, + screenCaptureSource: screenCaptureSource, + screenShareQualityProfile: screenShareQualityProfile, + input: inputSelection, + output: outputSelection, + outputVolume: outputVolume + ) + guard demand != lastDemand else { return } + lastDemand = demand + engine.setDemand(demand) + } + + private func finishScreenCaptureSourceRefresh( + _ result: GridScreenCaptureSourceRefreshResult, + id: UUID + ) -> [InlineRTCScreenCaptureSource] { + let sources = result.sources + guard screenCaptureSourceRefresh?.id == id else { return sources } + screenCaptureSourceRefresh = nil + presentationController.setRefreshingScreenCaptureSources(false) + switch result { + case let .success(sources): + presentationController.applyScreenCaptureSources(.success(sources)) + case let .failure(message): + presentationController.applyScreenCaptureSources( + .failure(GridScreenCaptureSourceRefreshError(message: message)) + ) + } + reconcileScreenShareOutline() + return sources + } + + private func apply(_ snapshot: InlineRTCState) { + #if os(iOS) + if snapshot.audio.safetyPauseRevision != lastSafetyPauseRevision { + lastSafetyPauseRevision = snapshot.audio.safetyPauseRevision + microphoneCapturePrepared = false + setMicrophoneEnabled(false) + broadcast(.microphoneSafetyPaused) + } + if snapshot.audio.microphonePermission == .denied || snapshot.audio.microphonePermission == .restricted, + microphoneEnabled + { + microphoneCapturePrepared = false + setMicrophoneEnabled(false) + broadcast(.microphoneSafetyPaused) + } + #endif + presentationController.apply(audio: snapshot.audio) + if let devices = snapshot.devices { + presentationController.apply(devices: devices) + } + if let outputDevices = snapshot.outputDevices { + presentationController.apply(outputDevices: outputDevices) + } + + let wasConnected = presentation.connectionState == .connected + presentationController.apply(rtc: snapshot.rtc) + reconcileScreenShareOutline() + broadcast(.screenShareContextChanged) + if case .failed = snapshot.rtc.screenShareState, screenCaptureSource != nil { + screenCaptureSource = nil + presentationController.setSelectedScreenCaptureSource(nil) + submitDemand() + } + if case let .connected(sessionID) = snapshot.rtc.state, + let target, + target.rtcSessionID == sessionID + { + connectedTarget = target + } else { + connectedTarget = nil + } + let audioEligible = target != nil && connectedTarget != nil + && (snapshot.audio.isCaptureHealthy || snapshot.audio.isPlayoutHealthy) + if audioEligible != lastAudioEligibility { + lastAudioEligibility = audioEligible + broadcast(.audioEligibilityChanged(audioEligible)) + } + if !wasConnected, + presentation.connectionState == .connected, + let sessionID = snapshot.rtc.target, + let target, + target.rtcSessionID == sessionID + { + let soundKey = sessionID.rawValue + if playedConnectionSoundKeys.count >= 128 { + playedConnectionSoundKeys.removeAll(keepingCapacity: true) + } + if playedConnectionSoundKeys.insert(soundKey).inserted { + platformEffects.playSound?(.connected) + } + broadcast(.connected(target, rtcConnectMilliseconds: snapshot.rtc.lastConnectMilliseconds)) + } + if case let .waitingForCredentials(sessionID) = snapshot.rtc.state, + let target, + target.rtcSessionID == sessionID + { + broadcast(.credentialsNeeded(target)) + } + } + + private func broadcast(_ event: GridMediaCoordinatorEvent) { + subscribers.values.forEach { $0.yield(event) } + } + + private func reconcileScreenShareOutline() { + guard let localShare = presentation.screenShares.first(where: \.isLocal), + let sourceID = localShare.captureSourceID + else { + platformEffects.updateScreenShareOutline?(nil) + return + } + let sources = presentation.screenCaptureSources + + [presentation.selectedScreenCaptureSource].compactMap(\.self) + guard let source = sources.first(where: { $0.id == sourceID }) else { + platformEffects.updateScreenShareOutline?(nil) + return + } + platformEffects.updateScreenShareOutline?(source) + } +} + +private struct GridScreenCaptureSourceRefresh { + let id: UUID + let task: Task +} + +private enum GridScreenCaptureSourceRefreshResult: Sendable { + case success([InlineRTCScreenCaptureSource]) + case failure(String) + + var sources: [InlineRTCScreenCaptureSource] { + switch self { + case let .success(sources): sources + case .failure: [] + } + } +} + +private struct GridScreenCaptureSourceRefreshError: LocalizedError, Sendable { + let message: String + + var errorDescription: String? { + message + } +} + +private extension InlineRTCAudioState { + var isFailed: Bool { + if case .failed = self { + return true + } + return false + } +} diff --git a/apple/InlineKit/Sources/InlineGrid/GridMediaPresentation.swift b/apple/InlineKit/Sources/InlineGrid/GridMediaPresentation.swift new file mode 100644 index 000000000..31a83edfc --- /dev/null +++ b/apple/InlineKit/Sources/InlineGrid/GridMediaPresentation.swift @@ -0,0 +1,272 @@ +import Foundation +import InlineKit +import InlineRTC +import Observation + +public enum GridMediaConnectionStatus: Equatable, Sendable { + case disconnected + case connecting + case connected + case failed +} + +public enum GridScreenCaptureIssue: Equatable, Sendable { + case sourceDiscovery + case sharing +} + +@MainActor +@Observable +public final class GridMediaPresentation { + public fileprivate(set) var connectionState: GridMediaConnectionStatus = .disconnected + public fileprivate(set) var audioState: InlineRTCAudioState = .cold + public fileprivate(set) var isAudioSafetyPaused = false + public fileprivate(set) var isMicrophoneEnabled: Bool + public fileprivate(set) var autoUnmuteOnJoin: Bool + public fileprivate(set) var autoMuteWhenAlone: Bool + public fileprivate(set) var inputDevices: [AudioInputDeviceDescriptor] = [] + public fileprivate(set) var automaticInputDeviceName = "System Default" + public fileprivate(set) var activeInputDeviceID: String? + public fileprivate(set) var inputSelection: AudioInputSelection + public fileprivate(set) var isFallingBackToAutomaticInput = false + public fileprivate(set) var outputDevices: [AudioOutputDeviceDescriptor] = [] + public fileprivate(set) var automaticOutputDeviceName = "System Default" + public fileprivate(set) var activeOutputDeviceID: String? + public fileprivate(set) var outputSelection: AudioOutputSelection + public fileprivate(set) var isFallingBackToAutomaticOutput = false + public fileprivate(set) var participantAudioLevels: [String: Float] = [:] + public fileprivate(set) var participantScreenShareIntents: [String: Bool] = [:] + public fileprivate(set) var connectedParticipantIdentities = Set() + public fileprivate(set) var hasParticipantMediaSnapshot = false + public fileprivate(set) var reconnectCount = 0 + public fileprivate(set) var recoveryAttempt = 0 + public fileprivate(set) var lastConnectDurationMilliseconds: Int? + public fileprivate(set) var lastDisconnectDurationMilliseconds: Int? + public fileprivate(set) var lastConnectionError: String? + public fileprivate(set) var isMicrophoneCapturePrepared = false + public fileprivate(set) var microphonePermission: InlineRTCMicrophonePermission = .notDetermined + public fileprivate(set) var microphonePublicationState: InlineRTCMicrophoneState = .notRequested + public fileprivate(set) var localAudioFlowState: InlineRTCAudioFlowState = .unknown + public fileprivate(set) var remoteAudioFlowStates: [String: InlineRTCAudioFlowState] = [:] + public fileprivate(set) var abandonedProviderOperationCount = 0 + public fileprivate(set) var providerCircuitOpen = false + public fileprivate(set) var outputVolume: Float + public fileprivate(set) var screenCaptureSources: [InlineRTCScreenCaptureSource] = [] + public fileprivate(set) var selectedScreenCaptureSource: InlineRTCScreenCaptureSource? + public fileprivate(set) var screenShareQualityProfile: InlineRTCScreenShareQualityProfile + public fileprivate(set) var screenShareEpisodeID: UInt64 = 0 + public fileprivate(set) var screenShareState: InlineRTCScreenShareState = .off + public fileprivate(set) var screenShares: [InlineRTCScreenShare] = [] + public fileprivate(set) var isRefreshingScreenCaptureSources = false + public fileprivate(set) var screenCaptureError: String? + public fileprivate(set) var screenCaptureIssue: GridScreenCaptureIssue? + + fileprivate init( + microphoneEnabled: Bool, + autoUnmuteOnJoin: Bool, + autoMuteWhenAlone: Bool, + inputSelection: AudioInputSelection, + screenShareQualityProfile: InlineRTCScreenShareQualityProfile = .automatic, + outputSelection: AudioOutputSelection = .automatic, + outputVolume: Float = 1 + ) { + isMicrophoneEnabled = microphoneEnabled + self.autoUnmuteOnJoin = autoUnmuteOnJoin + self.autoMuteWhenAlone = autoMuteWhenAlone + self.inputSelection = inputSelection + self.screenShareQualityProfile = screenShareQualityProfile + self.outputSelection = outputSelection + self.outputVolume = min(max(outputVolume, 0), 1) + } + + public var isUsingAutomaticInput: Bool { + inputSelection == .automatic + } + + public var hasRemoteAudioFlowFailure: Bool { + remoteAudioFlowStates.values.contains(.missing) + } + + public var isScreenSharing: Bool { + screenShares.contains(where: \.isLocal) + } + + public var isScreenShareRequested: Bool { + selectedScreenCaptureSource != nil + } + + public func prefersInputDevice(_ device: AudioInputDeviceDescriptor) -> Bool { + inputSelection.matches(device, among: inputDevices) + } + + public func prefersOutputDevice(_ device: AudioOutputDeviceDescriptor) -> Bool { + outputSelection.matches(device, among: outputDevices) + } + +} + +/// The sole writer for `GridMediaPresentation`. Views only receive the +/// presentation reference and therefore cannot feed state back into its owner. +@MainActor +final class GridMediaPresentationController { + let presentation: GridMediaPresentation + + init( + microphoneEnabled: Bool, + autoUnmuteOnJoin: Bool, + autoMuteWhenAlone: Bool, + inputSelection: AudioInputSelection, + screenShareQualityProfile: InlineRTCScreenShareQualityProfile = .automatic, + outputSelection: AudioOutputSelection = .automatic, + outputVolume: Float = 1 + ) { + presentation = GridMediaPresentation( + microphoneEnabled: microphoneEnabled, + autoUnmuteOnJoin: autoUnmuteOnJoin, + autoMuteWhenAlone: autoMuteWhenAlone, + inputSelection: inputSelection, + screenShareQualityProfile: screenShareQualityProfile, + outputSelection: outputSelection, + outputVolume: outputVolume + ) + } + + func setMicrophoneEnabled(_ enabled: Bool) { + presentation.isMicrophoneEnabled = enabled + } + + func setAutoUnmuteOnJoin(_ enabled: Bool) { + presentation.autoUnmuteOnJoin = enabled + } + + func setAutoMuteWhenAlone(_ enabled: Bool) { + presentation.autoMuteWhenAlone = enabled + } + + func setDesiredInputSelection(_ selection: AudioInputSelection) { + presentation.inputSelection = selection + } + + func setDesiredOutputSelection(_ selection: AudioOutputSelection) { + presentation.outputSelection = selection + } + + func setDesiredOutputVolume(_ volume: Float) { + presentation.outputVolume = min(max(volume, 0), 1) + } + + func setSelectedScreenCaptureSource(_ source: InlineRTCScreenCaptureSource?) { + if presentation.selectedScreenCaptureSource == nil, source != nil { + presentation.screenShareEpisodeID &+= 1 + } + presentation.selectedScreenCaptureSource = source + } + + func setScreenShareQualityProfile(_ profile: InlineRTCScreenShareQualityProfile) { + presentation.screenShareQualityProfile = profile + } + + func clearScreenCaptureError() { + presentation.screenCaptureError = nil + presentation.screenCaptureIssue = nil + } + + func setRefreshingScreenCaptureSources(_ isRefreshing: Bool) { + presentation.isRefreshingScreenCaptureSources = isRefreshing + } + + func applyScreenCaptureSources( + _ result: Result<[InlineRTCScreenCaptureSource], Error> + ) { + switch result { + case let .success(sources): + presentation.screenCaptureSources = sources + presentation.screenCaptureError = nil + presentation.screenCaptureIssue = nil + case let .failure(error): + presentation.screenCaptureSources = [] + presentation.screenCaptureError = error.localizedDescription + presentation.screenCaptureIssue = .sourceDiscovery + } + } + + func apply(audio snapshot: InlineRTCAudioSnapshot) { + presentation.audioState = snapshot.state + presentation.isAudioSafetyPaused = snapshot.isSafetyPaused + presentation.isMicrophoneCapturePrepared = snapshot.isPrepared + presentation.microphonePermission = snapshot.microphonePermission + if let resolvedInput = snapshot.input { + presentation.activeInputDeviceID = resolvedInput.activeDeviceID + presentation.isFallingBackToAutomaticInput = resolvedInput.isFallingBackToAutomatic + } + if let resolvedOutput = snapshot.output { + presentation.activeOutputDeviceID = resolvedOutput.activeDeviceID + presentation.isFallingBackToAutomaticOutput = resolvedOutput.isFallingBackToAutomatic + } + } + + func apply(rtc snapshot: InlineRTCConnectionSnapshot) { + presentation.connectionState = switch snapshot.state { + case .idle: .disconnected + case .connected: .connected + case .failed: .failed + default: .connecting + } + presentation.participantAudioLevels = Dictionary( + uniqueKeysWithValues: snapshot.participants.map { + ($0.identity, $0.isSpeaking ? $0.audioLevel : 0) + } + ) + presentation.participantScreenShareIntents = Dictionary( + uniqueKeysWithValues: snapshot.participants.compactMap { participant in + participant.screenShareIntent.map { (participant.identity, $0) } + } + ) + presentation.connectedParticipantIdentities = Set(snapshot.participants.map(\.identity)) + presentation.hasParticipantMediaSnapshot = snapshot.hasParticipantMediaSnapshot + presentation.reconnectCount = snapshot.reconnectCount + presentation.recoveryAttempt = snapshot.recoveryAttempt + presentation.lastConnectDurationMilliseconds = snapshot.lastConnectMilliseconds + presentation.lastDisconnectDurationMilliseconds = snapshot.lastDisconnectMilliseconds + presentation.lastConnectionError = snapshot.lastError + presentation.microphonePublicationState = snapshot.microphonePublicationState + presentation.localAudioFlowState = snapshot.localAudioFlowState + presentation.remoteAudioFlowStates = snapshot.remoteAudioFlowStates + presentation.screenShareState = snapshot.screenShareState + presentation.screenShares = snapshot.screenShares + if case let .failed(message) = snapshot.screenShareState, + message != "Screen sharing stopped" { + presentation.screenCaptureError = message + presentation.screenCaptureIssue = .sharing + } else if case let .failed(message) = snapshot.screenShareState, + message == "Screen sharing stopped" { + presentation.screenCaptureError = nil + presentation.screenCaptureIssue = nil + } else if snapshot.screenShareState == .off, + presentation.connectionState == .connected, + presentation.screenCaptureError == "Screen sharing did not stop" { + // A failed Stop is recoverable through room reconstruction. Keep the + // warning visible until the fresh connected projection proves that no + // local publication remains, then retire the transient recovery notice. + presentation.screenCaptureError = nil + presentation.screenCaptureIssue = nil + } + presentation.abandonedProviderOperationCount = snapshot.abandonedProviderOperationCount + presentation.providerCircuitOpen = snapshot.providerCircuitOpen + } + + func apply(devices snapshot: AudioInputDeviceSnapshot) { + presentation.automaticInputDeviceName = snapshot.automaticDeviceName + presentation.inputDevices = snapshot.devices + presentation.activeInputDeviceID = snapshot.resolvedInput.activeDeviceID + presentation.isFallingBackToAutomaticInput = snapshot.resolvedInput.isFallingBackToAutomatic + } + + func apply(outputDevices snapshot: AudioOutputDeviceSnapshot) { + presentation.automaticOutputDeviceName = snapshot.automaticDeviceName + presentation.outputDevices = snapshot.devices + presentation.activeOutputDeviceID = snapshot.resolvedOutput.activeDeviceID + presentation.isFallingBackToAutomaticOutput = + snapshot.resolvedOutput.isFallingBackToAutomatic + } +} diff --git a/apple/InlineKit/Sources/InlineGrid/GridMediaTarget.swift b/apple/InlineKit/Sources/InlineGrid/GridMediaTarget.swift new file mode 100644 index 000000000..74d364a05 --- /dev/null +++ b/apple/InlineKit/Sources/InlineGrid/GridMediaTarget.swift @@ -0,0 +1,18 @@ +import Foundation +import InlineRTC + +/// Grid's product identity for its current media session. +/// +/// Space, room, and generation remain app-owned. InlineRTC receives only the +/// stable opaque identity produced below and never interprets these fields. +struct GridMediaTarget: Equatable, Hashable, Sendable { + let callID: String + let membershipID: String + let spaceID: Int64 + let roomID: Int64 + let generation: Int32 + + var rtcSessionID: InlineRTCSessionID { + InlineRTCSessionID("grid:\(callID):\(membershipID):\(spaceID):\(roomID):\(generation)") + } +} diff --git a/apple/InlineKit/Sources/InlineGrid/GridMembershipSync.swift b/apple/InlineKit/Sources/InlineGrid/GridMembershipSync.swift new file mode 100644 index 000000000..303912132 --- /dev/null +++ b/apple/InlineKit/Sources/InlineGrid/GridMembershipSync.swift @@ -0,0 +1,173 @@ +import Auth +import Foundation +import InlineProtocol +import Logger + +struct GridMembershipOperation: Equatable, Sendable { + enum Kind: Equatable, Sendable { + case create + case join(roomID: Int64) + case leave(roomID: Int64) + case move(callID: String, roomID: Int64) + } + + let kind: Kind + let spaceID: Int64 + let expectedMembershipID: String + let intentRevision: Int + let accountToken: AuthAccountMutationToken + let microphoneEnabled: Bool + let automaticMicrophoneChange: GridAutomaticMicrophoneChange? + let accessRevision: Int + let revision: Int + let startedAt: Date +} + +enum GridMembershipSyncEvent: Sendable { + case created(GridMembershipOperation, GridRoomMutationResult) + case joined(GridMembershipOperation, GridRoomMutationResult) + case left(GridMembershipOperation, GridRoomMutationResult) + case moved(GridMembershipOperation, GridRoomMutationResult) + case failed(GridMembershipOperation, message: String) +} + +/// The sole remote executor for Grid avatar membership changes. +/// +/// The UI applies product state optimistically, then submits operations here. +/// A single retained worker preserves create/join/leave order at the server even +/// during rapid interaction. Events carry revisions so the room service can +/// ignore responses superseded by newer local intent without losing wire order. +@MainActor +final class GridMembershipSync { + private struct QueuedOperation { + let generation: Int + let operation: GridMembershipOperation + } + + private let api: GridRoomAPI + private let auth: AuthHandle + private let log = Log.scoped("GridMembershipSync") + private var generation = 0 + private var queue: [QueuedOperation] = [] + private var workerTask: Task? + private var workerGeneration: Int? + private var subscribers: [UUID: AsyncStream.Continuation] = [:] + + init(api: GridRoomAPI, auth: AuthHandle) { + self.api = api + self.auth = auth + } + + deinit { + workerTask?.cancel() + subscribers.values.forEach { $0.finish() } + } + + func subscribe() -> AsyncStream { + let id = UUID() + let stream = AsyncStream.makeStream( + of: GridMembershipSyncEvent.self, + // Results are revisioned and every success carries authoritative Grid + // state. A newer result supersedes an older event for a stalled consumer. + bufferingPolicy: .bufferingNewest(64) + ) + stream.continuation.onTermination = { [weak self] _ in + Task { @MainActor in self?.subscribers.removeValue(forKey: id) } + } + subscribers[id] = stream.continuation + return stream.stream + } + + func submit(_ operation: GridMembershipOperation) { + queue.append(QueuedOperation(generation: generation, operation: operation)) + startIfNeeded() + } + + /// Invalidates queued work during logout. An in-flight RPC may still finish + /// at the transport, but its event is discarded and it cannot retain the + /// worker slot needed by a later authenticated generation. + func reset() { + generation &+= 1 + queue.removeAll() + workerTask?.cancel() + workerTask = nil + workerGeneration = nil + } + + private func startIfNeeded() { + guard workerTask == nil, !queue.isEmpty else { return } + let workerGeneration = generation + self.workerGeneration = workerGeneration + workerTask = Task { [weak self] in + await self?.run(workerGeneration: workerGeneration) + } + } + + private func run(workerGeneration: Int) async { + while !Task.isCancelled, generation == workerGeneration, !queue.isEmpty { + let queued = queue.removeFirst() + guard queued.generation == workerGeneration else { continue } + let operation = queued.operation + do { + try auth.validateAccountMutation(operation.accountToken) + let event: GridMembershipSyncEvent = switch operation.kind { + case .create: + try await .created( + operation, + api.createRoom( + spaceID: operation.spaceID, + microphoneEnabled: operation.microphoneEnabled, + expectedMembershipID: operation.expectedMembershipID, + accountToken: operation.accountToken + ) + ) + case let .join(roomID): + try await .joined( + operation, + api.joinRoom( + roomID: roomID, + microphoneEnabled: operation.microphoneEnabled, + expectedMembershipID: operation.expectedMembershipID, + accountToken: operation.accountToken + ) + ) + case let .leave(roomID): + try await .left( + operation, + api.leaveRoom( + roomID: roomID, + expectedMembershipID: operation.expectedMembershipID, + accountToken: operation.accountToken + ) + ) + case let .move(callID, _): + try await .moved( + operation, + api.moveCallHere( + callID: callID, + expectedMembershipID: operation.expectedMembershipID, + accountToken: operation.accountToken + ) + ) + } + guard !Task.isCancelled, workerGeneration == generation else { continue } + broadcast(event) + } catch { + guard !Task.isCancelled, workerGeneration == generation else { continue } + log.error( + "GRID_TRACE phase=membership_rpc_failed revision=\(operation.revision)", + error: error + ) + broadcast(.failed(operation, message: String(describing: error))) + } + } + guard self.workerGeneration == workerGeneration else { return } + workerTask = nil + self.workerGeneration = nil + startIfNeeded() + } + + private func broadcast(_ event: GridMembershipSyncEvent) { + subscribers.values.forEach { $0.yield(event) } + } +} diff --git a/apple/InlineKit/Sources/InlineGrid/GridNetworkMonitor.swift b/apple/InlineKit/Sources/InlineGrid/GridNetworkMonitor.swift new file mode 100644 index 000000000..cd9bbbc1b --- /dev/null +++ b/apple/InlineKit/Sources/InlineGrid/GridNetworkMonitor.swift @@ -0,0 +1,78 @@ +import Foundation +import Network + +struct GridNetworkPathSnapshot: Equatable, Sendable { + let available: Bool + let usesWiFi: Bool + let usesWiredEthernet: Bool + let usesCellular: Bool + let constrained: Bool + let expensive: Bool + + init(path: NWPath) { + available = path.status == .satisfied + usesWiFi = path.usesInterfaceType(.wifi) + usesWiredEthernet = path.usesInterfaceType(.wiredEthernet) + usesCellular = path.usesInterfaceType(.cellular) + constrained = path.isConstrained + expensive = path.isExpensive + } + + var interfaceDescription: String { + if usesWiredEthernet { return "ethernet" } + if usesWiFi { return "wifi" } + if usesCellular { return "cellular" } + return "other" + } +} + +/// Owns `NWPathMonitor` and vends independent watch streams. Consumers never +/// compete for values from one shared `AsyncStream` iterator. +@MainActor +final class GridNetworkMonitor { + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "Grid.NetworkMonitor") + private var subscribers: [UUID: AsyncStream.Continuation] = [:] + private var latest: GridNetworkPathSnapshot? + + init() { + monitor.pathUpdateHandler = { [weak self] path in + let snapshot = GridNetworkPathSnapshot(path: path) + Task { @MainActor [weak self] in + self?.broadcast(snapshot) + } + } + monitor.start(queue: queue) + } + + deinit { + monitor.cancel() + subscribers.values.forEach { $0.finish() } + } + + func subscribe() -> AsyncStream { + let id = UUID() + let stream = AsyncStream.makeStream( + of: GridNetworkPathSnapshot.self, + bufferingPolicy: .bufferingNewest(1) + ) + stream.continuation.onTermination = { [weak self] _ in + Task { @MainActor in self?.subscribers.removeValue(forKey: id) } + } + subscribers[id] = stream.continuation + if let latest { + stream.continuation.yield(latest) + } + return stream.stream + } + + private func broadcast(_ snapshot: GridNetworkPathSnapshot) { + // Realtime and LiveKit observe their own socket/path transitions, including + // same-interface Wi-Fi changes. Grid only needs a separate recovery epoch + // when this coarse availability/interface state actually changes; emitting + // identical callbacks can otherwise create a refresh/retry feedback loop. + guard latest != snapshot else { return } + latest = snapshot + subscribers.values.forEach { $0.yield(snapshot) } + } +} diff --git a/apple/InlineKit/Sources/InlineGrid/GridPlatformEffects.swift b/apple/InlineKit/Sources/InlineGrid/GridPlatformEffects.swift new file mode 100644 index 000000000..6866bd63c --- /dev/null +++ b/apple/InlineKit/Sources/InlineGrid/GridPlatformEffects.swift @@ -0,0 +1,31 @@ +import InlineProtocol +import InlineRTC + +/// Presentation effects supplied by the host app. Room authority, media +/// admission, and transport ownership remain in the shared Grid service. +@MainActor +public struct GridPlatformEffects { + public enum Sound { + case join + case leave + case connected + } + + public var playSound: ((Sound) -> Void)? + public var displayIDUnderPointer: (() -> UInt32?)? + public var updateScreenShareOutline: ((InlineRTCScreenCaptureSource?) -> Void)? + public var closeScreenShares: (() -> Void)? + public var openScreenShare: ((InlineProtocol.User, InlineRTCScreenShare, GridRoomService) -> Void)? + public var presentScreenShareChange: ((GridScreenShareChange, GridRoomService) -> Void)? + public var showError: ((String) -> Void)? + + public init() {} +} + +public struct GridScreenShareChange { + public let spaceID: Int64 + public let roomID: Int64 + public let user: InlineProtocol.User + public let participantIdentity: String + public let started: Bool +} diff --git a/apple/InlineKit/Sources/InlineGrid/GridRoomAPI.swift b/apple/InlineKit/Sources/InlineGrid/GridRoomAPI.swift new file mode 100644 index 000000000..1b7ee9477 --- /dev/null +++ b/apple/InlineKit/Sources/InlineGrid/GridRoomAPI.swift @@ -0,0 +1,256 @@ +import Auth +import InlineKit +import InlineProtocol +import RealtimeV2 + +struct GridRoomMutationResult: Sendable { + let grids: [InlineProtocol.Grid] + let credentials: GridConnectionCredentials? + let currentCall: GridCurrentCall? + var moved = true +} + +struct GridReadResult { + let grid: InlineProtocol.Grid + let currentCall: GridCurrentCall? +} + +struct GridHomeResult { + let spaces: [GridHomeSpace] + let currentCall: GridCurrentCall? + let callTransferEnabled: Bool +} + +/// The typed server boundary for Grid rooms. +/// +/// Product state and optimistic UI never inspect RPC oneofs directly. Keeping +/// validation here makes every room operation return one predictable shape. +@MainActor +final class GridRoomAPI { + typealias Send = @MainActor @Sendable (any Transaction2, AuthAccountMutationToken) async throws -> RpcResult + .OneOf_Result? + private let sendTransaction: Send + private let auth: AuthHandle + + init(realtime: RealtimeV2, auth: AuthHandle, send: Send? = nil) { + sendTransaction = send ?? { transaction, token in + try await realtime.send(transaction, expectedAccount: token) + } + self.auth = auth + } + + func settings(spaceID: Int64) async throws -> SpaceSettings { + let result = try await send(.getSpaceSettings(spaceID: spaceID)) + guard case let .getSpaceSettings(response)? = result, response.hasSettings else { + throw GridRoomAPIError.invalidResponse + } + return response.settings + } + + func setEnabled(_ enabled: Bool, spaceID: Int64) async throws -> SpaceSettings { + let result = try await send(.toggleSpaceGrid(spaceID: spaceID, enabled: enabled)) + guard case let .toggleSpaceGrid(response)? = result, response.hasSettings else { + throw GridRoomAPIError.invalidResponse + } + return response.settings + } + + func grid( + spaceID: Int64, + expectedMembershipID: String? = nil, + accountToken: AuthAccountMutationToken + ) async throws -> GridReadResult { + let result = try await send( + .getGrid(spaceID: spaceID, expectedMembershipID: expectedMembershipID), + accountToken: accountToken + ) + guard case let .getGrid(response)? = result, response.hasGrid else { + throw GridRoomAPIError.invalidResponse + } + return GridReadResult(grid: response.grid, currentCall: response.hasCurrentCall ? response.currentCall : nil) + } + + func home(accountToken: AuthAccountMutationToken) async throws -> GridHomeResult { + let result = try await send(.getGridHome(), accountToken: accountToken) + guard case let .getGridHome(response)? = result else { + throw GridRoomAPIError.invalidResponse + } + return GridHomeResult( + spaces: response.spaces, + currentCall: response.hasCurrentCall ? response.currentCall : nil, + callTransferEnabled: response.callTransferEnabled + ) + } + + func openRoomThread(roomID: Int64, membershipID: String) async throws -> Int64 { + let result = try await send(.openGridThread(roomID: roomID, membershipID: membershipID)) + guard case let .openGridThread(response)? = result, response.chatID > 0 else { + throw GridRoomAPIError.invalidResponse + } + return response.chatID + } + + func setTranscription(request: GridTranscriptionRequest) async throws -> InlineProtocol.Grid { + let result = try await send(.setGridTranscription(request: request)) + guard case let .setGridTranscription(response)? = result, response.hasGrid else { + throw GridRoomAPIError.invalidResponse + } + return response.grid + } + + func listTranscripts(roomID: Int64) async throws -> [GridTranscriptDestinationInfo] { + let result = try await send(.listGridTranscripts(roomID: roomID)) + guard case let .listGridTranscripts(response)? = result else { + throw GridRoomAPIError.invalidResponse + } + return Array(response.transcripts.prefix(20)) + } + + func createRoom( + spaceID: Int64, + microphoneEnabled: Bool, + expectedMembershipID: String, + accountToken: AuthAccountMutationToken + ) async throws -> GridRoomMutationResult { + let result = try await send( + .createGridRoom( + spaceID: spaceID, + microphoneEnabled: microphoneEnabled, + expectedMembershipID: expectedMembershipID + ), + accountToken: accountToken + ) + guard case let .createGridRoom(response)? = result else { + throw GridRoomAPIError.invalidResponse + } + return GridRoomMutationResult( + grids: response.grids, + credentials: response.hasConnection ? response.connection : nil, + currentCall: response.hasCurrentCall ? response.currentCall : nil + ) + } + + func joinRoom( + roomID: Int64, + microphoneEnabled: Bool, + expectedMembershipID: String, + accountToken: AuthAccountMutationToken + ) async throws -> GridRoomMutationResult { + let result = try await send( + .joinGridRoom(roomID: roomID, microphoneEnabled: microphoneEnabled, expectedMembershipID: expectedMembershipID), + accountToken: accountToken + ) + guard case let .joinGridRoom(response)? = result else { + throw GridRoomAPIError.invalidResponse + } + return GridRoomMutationResult( + grids: response.grids, + credentials: response.hasConnection ? response.connection : nil, + currentCall: response.hasCurrentCall ? response.currentCall : nil + ) + } + + func leaveRoom( + roomID: Int64, + expectedMembershipID: String, + accountToken: AuthAccountMutationToken + ) async throws -> GridRoomMutationResult { + let result = try await send( + .leaveGridRoom(roomID: roomID, expectedMembershipID: expectedMembershipID), + accountToken: accountToken + ) + guard case let .leaveGridRoom(response)? = result else { + throw GridRoomAPIError.invalidResponse + } + return GridRoomMutationResult( + grids: response.grids, + credentials: nil, + currentCall: response.hasCurrentCall ? response.currentCall : nil + ) + } + + func moveCallHere( + callID: String, + expectedMembershipID: String, + accountToken: AuthAccountMutationToken + ) async throws -> GridRoomMutationResult { + let result = try await send( + .moveGridCallHere(callID: callID, expectedMembershipID: expectedMembershipID), + accountToken: accountToken + ) + guard case let .moveGridCallHere(response)? = result else { throw GridRoomAPIError.invalidResponse } + return GridRoomMutationResult( + grids: response.grids, + credentials: response.hasConnection ? response.connection : nil, + currentCall: response.hasCurrentCall ? response.currentCall : nil, + moved: response.moved + ) + } + + func setLocked(_ locked: Bool, roomID: Int64) async throws -> InlineProtocol.Grid { + let result = try await send(.setGridRoomLocked(roomID: roomID, locked: locked)) + guard case let .setGridRoomLocked(response)? = result, response.hasGrid else { + throw GridRoomAPIError.invalidResponse + } + return response.grid + } + + func setTitle(_ title: String, roomID: Int64) async throws -> InlineProtocol.Grid { + let result = try await send(.setGridRoomTitle(roomID: roomID, title: title)) + guard case let .setGridRoomTitle(response)? = result, response.hasGrid else { + throw GridRoomAPIError.invalidResponse + } + return response.grid + } + + func deleteRoom(roomID: Int64) async throws -> InlineProtocol.Grid { + let result = try await send(.deleteGridRoom(roomID: roomID)) + guard case let .deleteGridRoom(response)? = result, response.hasGrid else { + throw GridRoomAPIError.invalidResponse + } + return response.grid + } + + func prepareConnection( + roomID: Int64, + generation: Int32, + expectedMembershipID: String, + accountToken: AuthAccountMutationToken + ) async throws -> GridConnectionCredentials? { + let result = try await send( + .prepareGridConnection(roomID: roomID, generation: generation, expectedMembershipID: expectedMembershipID), + accountToken: accountToken + ) + guard case let .prepareGridConnection(response)? = result else { + throw GridRoomAPIError.invalidResponse + } + return response.hasConnection ? response.connection : nil + } + + func setMicrophoneEnabled( + _ enabled: Bool, + roomID: Int64, + expectedMembershipID: String, + accountToken: AuthAccountMutationToken + ) async throws { + let result = try await send( + .setGridAvatarMicrophoneEnabled(roomID: roomID, enabled: enabled, expectedMembershipID: expectedMembershipID), + accountToken: accountToken + ) + guard case .setGridAvatarMicrophoneEnabled? = result else { + throw GridRoomAPIError.invalidResponse + } + } + + private func send( + _ transaction: any Transaction2, + accountToken: AuthAccountMutationToken? = nil + ) async throws -> RpcResult.OneOf_Result? { + let token = try accountToken ?? auth.beginAccountMutation() + return try await sendTransaction(transaction, token) + } +} + +enum GridRoomAPIError: Error { + case invalidResponse +} diff --git a/apple/InlineKit/Sources/InlineGrid/GridRoomLifecycle.swift b/apple/InlineKit/Sources/InlineGrid/GridRoomLifecycle.swift new file mode 100644 index 000000000..c7f326b1b --- /dev/null +++ b/apple/InlineKit/Sources/InlineGrid/GridRoomLifecycle.swift @@ -0,0 +1,83 @@ +import Foundation +import InlineProtocol +import RealtimeV2 + +enum GridRoomLifecycleEvent: Sendable { + case grid(GridEvent) + case realtimeConnection(RealtimeConnectionState) + case network(GridNetworkPathSnapshot) + case heartbeat +} + +/// Collects external lifecycle sources without owning Grid product state. +/// Every subscriber receives its own watch/event stream. +@MainActor +final class GridRoomLifecycle { + private let realtime: RealtimeV2 + private let network: GridNetworkMonitor + private var sourceTasks: [Task] = [] + private var subscribers: [UUID: AsyncStream.Continuation] = [:] + private var started = false + + init(realtime: RealtimeV2, network: GridNetworkMonitor) { + self.realtime = realtime + self.network = network + } + + deinit { + sourceTasks.forEach { $0.cancel() } + subscribers.values.forEach { $0.finish() } + } + + func subscribe() -> AsyncStream { + startIfNeeded() + let id = UUID() + let stream = AsyncStream.makeStream( + of: GridRoomLifecycleEvent.self, + // Lifecycle edges only trigger authoritative refetch/reconciliation; + // retain a bounded recent window for a temporarily suspended observer. + bufferingPolicy: .bufferingNewest(64) + ) + stream.continuation.onTermination = { [weak self] _ in + Task { @MainActor in self?.subscribers.removeValue(forKey: id) } + } + subscribers[id] = stream.continuation + return stream.stream + } + + private func startIfNeeded() { + guard !started else { return } + started = true + sourceTasks = [ + Task { [weak self, realtime] in + for await event in await realtime.gridEvents() { + guard !Task.isCancelled else { return } + self?.broadcast(.grid(event)) + } + }, + Task { [weak self, realtime] in + for await state in await realtime.connectionStates() { + guard !Task.isCancelled else { return } + self?.broadcast(.realtimeConnection(state)) + } + }, + Task { [weak self, network] in + for await snapshot in network.subscribe() { + guard !Task.isCancelled else { return } + self?.broadcast(.network(snapshot)) + } + }, + Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(30)) + guard !Task.isCancelled else { return } + self?.broadcast(.heartbeat) + } + }, + ] + } + + private func broadcast(_ event: GridRoomLifecycleEvent) { + subscribers.values.forEach { $0.yield(event) } + } +} diff --git a/apple/InlineKit/Sources/InlineGrid/GridRuntime.swift b/apple/InlineKit/Sources/InlineGrid/GridRuntime.swift new file mode 100644 index 000000000..cae9ec691 --- /dev/null +++ b/apple/InlineKit/Sources/InlineGrid/GridRuntime.swift @@ -0,0 +1,55 @@ +import Foundation +import InlineKit +import InlineRTC +import RealtimeV2 + +/// One authenticated-process owner for Grid's product, RTC, and audio domains. +/// `AppDependencies` is copied per window, but this reference is shared. +@MainActor +public final class GridRuntime { + private static var cachedRuntime: GridRuntime? + + public static var shared: GridRuntime { + if let cachedRuntime { + return cachedRuntime + } + let runtime = GridRuntime() + cachedRuntime = runtime + return runtime + } + + /// Feature-off and logout teardown can inspect the owner without creating + /// media engines for a process that has never opened Grid. + public static var existing: GridRuntime? { + cachedRuntime + } + + private let engine: InlineRTCSession + public let rooms: GridRoomService + + private init( + realtime: RealtimeV2 = Api.realtime, + userDefaults: UserDefaults = .standard + ) { + let engine = InlineRTCSession() + self.engine = engine + rooms = GridRoomService( + realtime: realtime, + userDefaults: userDefaults, + engine: engine + ) + } + + public func configurePlatformEffects(_ effects: GridPlatformEffects) { + rooms.configurePlatformEffects(effects) + } + + public func prepareForLogout() async -> GridMediaShutdownReceipt { + await rooms.prepareForLogout() + return await engine.shutdown() + } + + public func applicationDidWake() async { + await rooms.applicationDidWake() + } +} diff --git a/apple/InlineKit/Sources/InlineGrid/GridStore.swift b/apple/InlineKit/Sources/InlineGrid/GridStore.swift new file mode 100644 index 000000000..5ed230ad2 --- /dev/null +++ b/apple/InlineKit/Sources/InlineGrid/GridStore.swift @@ -0,0 +1,2236 @@ +import Auth +import Foundation +import InlineKit +#if os(iOS) +import InlineAudioPlayback +#endif +import InlineProtocol +import InlineRTC +import Logger +import Observation +import RealtimeV2 + +@MainActor +@Observable +public final class GridRoomService { + public private(set) var grids: [Int64: InlineProtocol.Grid] = [:] + public private(set) var enabledSpaceIDs = Set() + public private(set) var homeSpaces: [GridHomeSpace] = [] + public private(set) var currentCall: GridCurrentCall? + public private(set) var callTransferEnabled = false + public private(set) var membershipMutationInFlight = false + private var localAdmission: GridLocalAdmission? + public var hasLocalAdmission: Bool { + localAdmission != nil + } + + public var mediaSessionIdentity: String? { + currentMediaTarget()?.rtcSessionID.rawValue + } + + public private(set) var loadingSpaceIDs = Set() + public private(set) var failedLoadSpaceIDs = Set() + public private(set) var lastError: String? + public private(set) var networkRefreshRevision = 0 + public private(set) var networkAvailable = true + public private(set) var pendingTranscriptionRequestIDs: [Int64: String] = [:] + public var connectionRecoveryAttempt: Int { + media.recoveryAttempt + } + + public let media: GridMediaPresentation + + public var inputSelection: AudioInputSelection { + get { media.inputSelection } + set { mediaCoordinator.setInput(newValue) } + } + + public var outputSelection: AudioOutputSelection { + get { media.outputSelection } + set { mediaCoordinator.setOutput(newValue) } + } + + public var autoUnmuteOnJoin: Bool { + get { media.autoUnmuteOnJoin } + set { mediaCoordinator.setAutoUnmuteOnJoin(newValue) } + } + + public var autoMuteWhenAlone: Bool { + get { media.autoMuteWhenAlone } + set { + mediaCoordinator.setAutoMuteWhenAlone(newValue) + reconcileAloneAutoMute() + } + } + + @ObservationIgnored private var platformEffects = GridPlatformEffects() + @ObservationIgnored private let realtime: RealtimeV2 + @ObservationIgnored private let auth: AuthHandle + @ObservationIgnored private var authAuthorityObserver: NSObjectProtocol? + @ObservationIgnored private var localIntentRevision = 0 + @ObservationIgnored private var selfReadEpoch = 0 + @ObservationIgnored private var selfReadSequence = 0 + @ObservationIgnored private var lastAcceptedSelfReadSequence = 0 + @ObservationIgnored private var cancelPendingMembershipClaim = false + @ObservationIgnored private var retentionTask: Task? + @ObservationIgnored private var pendingRetention: (eligible: Bool, accountToken: AuthAccountMutationToken)? + @ObservationIgnored private var lastRetainedMediaEligible = false + @ObservationIgnored private let api: GridRoomAPI + @ObservationIgnored private let avatarStateSync: GridAvatarStateSync + @ObservationIgnored private let membershipSync: GridMembershipSync + @ObservationIgnored private let mediaCoordinator: GridMediaCoordinator + @ObservationIgnored private let homePreferences: GridHomePreferences + @ObservationIgnored private var lifecycleTask: Task? + @ObservationIgnored private var mediaEventsTask: Task? + @ObservationIgnored private var membershipEventsTask: Task? + @ObservationIgnored private var lastNetworkPathSnapshot: GridNetworkPathSnapshot? + @ObservationIgnored private let lifecycle: GridRoomLifecycle + @ObservationIgnored private var pendingReloadSpaceIDs = Set() + @ObservationIgnored private var pendingConnectionSpaceIDs = Set() + @ObservationIgnored private var roomMutationRevisions: [OptimisticRoomMutationKey: Int] = [:] + @ObservationIgnored private var pendingRoomMutations: [OptimisticRoomMutationKey: OptimisticRoomMutation] = [:] + @ObservationIgnored private var membershipMutationRevision = 0 + @ObservationIgnored private var retiredMembershipRevision = 0 + @ObservationIgnored private var pendingMembershipMutations: [Int: OptimisticMembershipMutation] = [:] + @ObservationIgnored private var membershipRollbackBaseline = + GridOptimisticState.MembershipRollbackBaseline() + @ObservationIgnored private var acceptedOwnedRoom: GridOwnedRoomAuthority? + @ObservationIgnored private var lastAcceptedSnapshotRevisions: [Int64: Int64] = [:] + @ObservationIgnored private var spaceAccessRevisions: [Int64: Int] = [:] + @ObservationIgnored private var homeLoadRevision = 0 + @ObservationIgnored private var lastRealtimeConnectionState: RealtimeConnectionState? + @ObservationIgnored private var pendingCredentialTarget: GridMediaTarget? + @ObservationIgnored private var credentialRetryTask: Task? + @ObservationIgnored private var credentialRetryTarget: GridMediaTarget? + @ObservationIgnored private var credentialRetryAttempt = 0 + @ObservationIgnored private var aloneAutoMuteTask: Task? + @ObservationIgnored private var aloneAutoMuteTarget: GridAloneAutoMuteTarget? + @ObservationIgnored private var screenShareAloneTask: Task? + @ObservationIgnored private var screenShareAloneTarget: GridScreenShareAloneTarget? + @ObservationIgnored private var screenShareAloneGrace = GridScreenShareAloneGraceState() + @ObservationIgnored private var screenShareNotificationTarget: GridMediaTarget? + @ObservationIgnored private var observedRemoteScreenShares: [String: GridScreenShareNotice] = [:] + @ObservationIgnored private var hasPrimedScreenSharePresentationEdges = false + @ObservationIgnored private var pendingScreenShareOpenTasks: + [String: GridPendingScreenShareOpen] = [:] + @ObservationIgnored private var mediaInteractionStartedAt: Date? + @ObservationIgnored private let log = Log.scoped("GridRoomService") + private static let aloneMediaGraceSeconds = 5 + + init( + realtime: RealtimeV2 = Api.realtime, + userDefaults: UserDefaults = .standard, + engine: InlineRTCSession, + auth: AuthHandle = Auth.shared.handle, + sendGridTransaction: GridRoomAPI.Send? = nil + ) { + self.realtime = realtime + self.auth = auth + let api = GridRoomAPI(realtime: realtime, auth: auth, send: sendGridTransaction) + self.api = api + avatarStateSync = GridAvatarStateSync(api: api) + let membershipSync = GridMembershipSync(api: api, auth: auth) + self.membershipSync = membershipSync + let networkMonitor = GridNetworkMonitor() + lifecycle = GridRoomLifecycle(realtime: realtime, network: networkMonitor) + let inputPreferences = AudioInputPreferenceStore( + defaults: userDefaults, + deviceIDKey: "grid.preferredInputDeviceID", + deviceNameKey: "grid.preferredInputDeviceName" + ) + let outputPreferences = AudioOutputPreferenceStore( + defaults: userDefaults, + deviceIDKey: "grid.preferredOutputDeviceID", + deviceNameKey: "grid.preferredOutputDeviceName" + ) + homePreferences = GridHomePreferences(defaults: userDefaults) + let mediaCoordinator = GridMediaCoordinator( + engine: engine, + defaults: userDefaults, + inputPreferences: inputPreferences, + outputPreferences: outputPreferences + ) + self.mediaCoordinator = mediaCoordinator + media = mediaCoordinator.presentation + authAuthorityObserver = auth.observeAccountAuthorityWillChange { [weak self] retirement in + self?.retireAdmission(for: retirement) + } + lifecycleTask = Task { [weak self, lifecycle] in + for await event in lifecycle.subscribe() { + guard !Task.isCancelled else { return } + await self?.handle(event) + } + } + mediaEventsTask = Task { [weak self, mediaCoordinator] in + for await event in mediaCoordinator.subscribe() { + guard !Task.isCancelled else { return } + await self?.handle(event) + } + } + membershipEventsTask = Task { [weak self, membershipSync] in + for await event in membershipSync.subscribe() { + guard !Task.isCancelled else { return } + await self?.handle(event) + } + } + } + + isolated deinit { + if let authAuthorityObserver { + NotificationCenter.default.removeObserver(authAuthorityObserver) + } + lifecycleTask?.cancel() + mediaEventsTask?.cancel() + membershipEventsTask?.cancel() + credentialRetryTask?.cancel() + aloneAutoMuteTask?.cancel() + screenShareAloneTask?.cancel() + retentionTask?.cancel() + pendingScreenShareOpenTasks.values.forEach { $0.task.cancel() } + } + + public func configurePlatformEffects(_ effects: GridPlatformEffects) { + platformEffects = effects + mediaCoordinator.configurePlatformEffects(effects) + } + + public func clearLastError() { + lastError = nil + } + + public func isEnabled(spaceID: Int64) -> Bool { + enabledSpaceIDs.contains(spaceID) + } + + /// Ends this process's media authority immediately. It does not move or + /// remove a call owned by another device. + @discardableResult + public func withdrawLocalAdmission() -> Task { + localIntentRevision &+= 1 + selfReadEpoch &+= 1 + if membershipMutationInFlight { + cancelPendingMembershipClaim = true + } + clearLocalAdmission() + return mediaCoordinator.withdrawLocalMedia() + } + + /// An older authority transition cannot withdraw a call admitted by a newer login. + private func retireAdmission(for retirement: AuthAccountAuthorityRetirement) { + guard retirement.isCurrent, + localAdmission == nil || localAdmission?.accountToken == retirement.previousAccount + else { return } + // Submission is synchronous. Waiting for this receipt cannot enqueue a second shutdown + // after logout and a successor admission have overtaken this authority transition. + let receipt = withdrawLocalAdmission() + // A held RPC cannot keep the old account's worker slot or claim gate. Restore only the + // confirmed display baseline; neither rollback nor a failed login readmits local media. + if let pending = pendingMembershipMutations[membershipMutationRevision] { + rollbackMembershipMutation(pending) + } + membershipMutationRevision &+= 1 + retiredMembershipRevision = membershipMutationRevision + membershipMutationInFlight = false + cancelPendingMembershipClaim = false + pendingMembershipMutations.removeAll() + membershipRollbackBaseline.clear() + membershipSync.reset() + retirement.requireRetirement(Task { await receipt.value.isLocallyQuiescent }) + } + + public func prepareForLogout() async { + withdrawLocalAdmission() + currentCall = nil + callTransferEnabled = false + membershipMutationInFlight = false + cancelPendingMembershipClaim = false + let invalidatedSpaceIDs = Set(spaceAccessRevisions.keys) + .union(grids.keys) + .union(enabledSpaceIDs) + .union(loadingSpaceIDs) + .union(pendingReloadSpaceIDs) + .union(pendingConnectionSpaceIDs) + pendingCredentialTarget = nil + resetCredentialRetry() + cancelAloneAutoMute() + resetScreenShareAloneGrace() + resetScreenSharePresentationEdges() + grids.removeAll() + homeSpaces.removeAll() + enabledSpaceIDs.removeAll() + loadingSpaceIDs.removeAll() + failedLoadSpaceIDs.removeAll() + pendingReloadSpaceIDs.removeAll() + pendingConnectionSpaceIDs.removeAll() + for spaceID in invalidatedSpaceIDs { + spaceAccessRevisions[spaceID, default: 0] &+= 1 + } + homeLoadRevision &+= 1 + roomMutationRevisions.removeAll() + pendingRoomMutations.removeAll() + pendingTranscriptionRequestIDs.removeAll() + membershipMutationRevision &+= 1 + retiredMembershipRevision = membershipMutationRevision + pendingMembershipMutations.removeAll() + membershipRollbackBaseline.clear() + lastAcceptedSnapshotRevisions.removeAll() + acceptedOwnedRoom = nil + membershipSync.reset() + avatarStateSync.setOwnedRoom(nil) + await mediaCoordinator.shutdown() + } + + public func grid(spaceID: Int64) -> InlineProtocol.Grid? { + grids[spaceID] + } + + public var activeTranscriptionRoom: GridRoom? { + grids.values.lazy.compactMap { grid in + guard grid.hasCurrentRoomID, + let room = grid.rooms.first(where: { $0.id == grid.currentRoomID }), + room.transcriptionIsRunning, + room.avatars.contains(where: { $0.ownedByCurrentSession && !$0.membershipID.isEmpty }) + else { return nil } + return room + }.first + } + + public func recentAvatars(spaceID: Int64, limit: Int = 4) -> [GridAvatar] { + guard let grid = grids[spaceID] else { return [] } + return Array( + grid.rooms + .flatMap(\.avatars) + .sorted { $0.joinedAt > $1.joinedAt } + .prefix(limit) + ) + } + + public func loadHome() async { + guard realtimeReady else { + log.debug("GRID_TRACE phase=home_load_deferred reason=realtime_not_ready") + return + } + homeLoadRevision &+= 1 + let revision = homeLoadRevision + do { + guard let read = beginSelfRead() else { return } + let response = try await api.home(accountToken: read.accountToken) + guard revision == homeLoadRevision, isCurrent(read) else { return } + applySelfRead(response.currentCall, capability: response.callTransferEnabled, fence: read) + let nextHomeSpaces = response.spaces + let previouslyLoadedSpaceIDs = Set(grids.keys) + homeSpaces = nextHomeSpaces + let eligibleSpaceIDs = Set(homeSpaces.map(\.spaceID)) + enabledSpaceIDs = eligibleSpaceIDs + for spaceID in previouslyLoadedSpaceIDs.subtracting(eligibleSpaceIDs) { + clearLocalGrid(spaceID: spaceID, reason: "home_reconciliation") + } + lastError = nil + } catch { + guard revision == homeLoadRevision else { return } + guard realtimeReady, networkAvailable else { + log.debug("GRID_TRACE phase=home_load_deferred reason=connection_lost") + return + } + lastError = String(describing: error) + log.warning("GRID_TRACE phase=home_load_failed") + PerformanceTrace.breadcrumb( + "Grid Home discovery failed", + category: "Grid.Home", + level: .warning + ) + } + } + + public var orderedHomeSpaces: [GridHomeSpace] { + homePreferences.ordered(homeSpaces) + } + + public func recordGridOpened(spaceID: Int64) { + homePreferences.recordOpened(spaceID: spaceID) + } + + public func audioLevel(userID: Int64) -> Float { + let legacyIdentity = Self.liveKitIdentityPrefix(userID: userID) + return media.participantAudioLevels + .filter { $0.key == legacyIdentity || $0.key.hasPrefix("\(legacyIdentity)-") } + .map(\.value) + .max() ?? 0 + } + + public func isScreenSharing(avatar: GridAvatar) -> Bool { + if avatar.ownedByCurrentSession { + return media.isScreenShareRequested + } + let identities = participantIdentities(for: avatar) + let knownIntents = identities.compactMap { media.participantScreenShareIntents[$0] } + if !knownIntents.isEmpty { + return knownIntents.contains(true) + } + let shares = screenShares(avatar: avatar) + return !shares.isEmpty + } + + public func isScreenShareIntended(participantIdentity: String) -> Bool { + if let intent = media.participantScreenShareIntents[participantIdentity] { + return intent + } + return media.screenShares.contains { $0.participantIdentity == participantIdentity } + } + + public func isScreenSharing(userID: Int64) -> Bool { + let shares = screenShares(userID: userID) + if isOwnedUser(userID) { + return shares.contains(where: \.isLocal) + } + return !shares.isEmpty + } + + public func screenShare(userID: Int64) -> InlineRTCScreenShare? { + let shares = screenShares(userID: userID) + if isOwnedUser(userID) { + return shares.first(where: \.isLocal) + } + return shares.first + } + + public func screenShare(avatar: GridAvatar) -> InlineRTCScreenShare? { + let shares = screenShares(avatar: avatar) + return avatar.ownedByCurrentSession + ? shares.first(where: \.isLocal) + : shares.first + } + + public func screenShares(avatar: GridAvatar) -> [InlineRTCScreenShare] { + let identities = participantIdentities(for: avatar) + return media.screenShares.filter { identities.contains($0.participantIdentity) } + } + + public func screenShares(userID: Int64) -> [InlineRTCScreenShare] { + media.screenShares.filter { + Self.identity($0.participantIdentity, matchesUserID: userID) + } + } + + public func screenShares(participantIdentity: String) -> [InlineRTCScreenShare] { + media.screenShares.filter { $0.participantIdentity == participantIdentity } + } + + public func screenShare(publicationID: String) -> InlineRTCScreenShare? { + media.screenShares.first { $0.publicationID == publicationID } + } + + public func load(spaceID: Int64) async { + guard realtimeReady else { + failedLoadSpaceIDs.remove(spaceID) + pendingConnectionSpaceIDs.insert(spaceID) + log.debug( + "GRID_TRACE phase=load_deferred space=\(spaceID) reason=realtime_not_ready" + ) + return + } + pendingConnectionSpaceIDs.remove(spaceID) + let accessRevision = spaceAccessRevisions[spaceID, default: 0] + guard loadingSpaceIDs.insert(spaceID).inserted else { + pendingReloadSpaceIDs.insert(spaceID) + return + } + failedLoadSpaceIDs.remove(spaceID) + defer { + loadingSpaceIDs.remove(spaceID) + if pendingReloadSpaceIDs.remove(spaceID) != nil { + Task { [weak self] in await self?.load(spaceID: spaceID) } + } + } + + do { + let settings = try await api.settings(spaceID: spaceID) + guard accessRevision == spaceAccessRevisions[spaceID, default: 0] else { return } + applyEnabled(settings.gridEnabled, spaceID: spaceID) + lastError = nil + guard settings.gridEnabled else { + reconcileMediaDemand() + return + } + try await reloadGrid(spaceID: spaceID) + } catch { + guard accessRevision == spaceAccessRevisions[spaceID, default: 0] else { return } + guard realtimeReady, networkAvailable else { + pendingConnectionSpaceIDs.insert(spaceID) + log.debug( + "GRID_TRACE phase=load_deferred space=\(spaceID) reason=connection_lost" + ) + return + } + failedLoadSpaceIDs.insert(spaceID) + lastError = String(describing: error) + log.error("GRID_TRACE phase=load_failed space=\(spaceID)", error: error) + PerformanceTrace.breadcrumb( + "Grid snapshot failed", + category: "Grid.Snapshot", + level: .error, + data: ["space_id": spaceID] + ) + } + } + + public func setEnabled(_ enabled: Bool, spaceID: Int64) async throws { + let accessRevision = spaceAccessRevisions[spaceID, default: 0] + let settings = try await api.setEnabled(enabled, spaceID: spaceID) + guard accessRevision == spaceAccessRevisions[spaceID, default: 0] else { return } + applyEnabled(settings.gridEnabled, spaceID: spaceID) + if enabled { + try await reloadGrid(spaceID: spaceID) + } else { + reconcileMediaDemand() + } + await loadHome() + } + + public func createAndJoin(spaceID: Int64) { + guard let accountToken = beginMembershipClaim() else { return } + let expectedMembershipID = observedMembershipFence() + let startedAt = Date() + #if os(iOS) + mediaCoordinator.setMicrophoneEnabled(false) + #endif + let automaticMicrophoneChange = mediaCoordinator.applyAutoUnmuteOnJoin() + #if os(macOS) + mediaCoordinator.requestMicrophonePermission() + #endif + platformEffects.playSound?(.join) + membershipMutationRevision &+= 1 + membershipSync.submit(GridMembershipOperation( + kind: .create, + spaceID: spaceID, + expectedMembershipID: expectedMembershipID, + intentRevision: localIntentRevision, + accountToken: accountToken, + microphoneEnabled: mediaCoordinator.isMicrophoneEnabled, + automaticMicrophoneChange: automaticMicrophoneChange, + accessRevision: spaceAccessRevisions[spaceID, default: 0], + revision: membershipMutationRevision, + startedAt: startedAt + )) + } + + public func join(roomID: Int64) { + guard let destination = grids.values.first(where: { $0.rooms.contains(where: { $0.id == roomID }) }), + let accountToken = beginMembershipClaim() + else { return } + let expectedMembershipID = observedMembershipFence() + let startedAt = Date() + mediaInteractionStartedAt = destination.rooms.first(where: { $0.id == roomID })?.avatars.isEmpty == false + ? startedAt : nil + #if os(iOS) + mediaCoordinator.setMicrophoneEnabled(false) + #endif + let automaticMicrophoneChange = mediaCoordinator.applyAutoUnmuteOnJoin() + guard let mutation = optimisticallyJoin(roomID: roomID) else { + membershipMutationInFlight = false + return + } + #if os(macOS) + mediaCoordinator.requestMicrophonePermission() + #endif + platformEffects.playSound?(.join) + pendingMembershipMutations[mutation.revision] = mutation + membershipSync.submit(GridMembershipOperation( + kind: .join(roomID: roomID), + spaceID: mutation.spaceID, + expectedMembershipID: expectedMembershipID, + intentRevision: localIntentRevision, + accountToken: accountToken, + microphoneEnabled: mediaCoordinator.isMicrophoneEnabled, + automaticMicrophoneChange: automaticMicrophoneChange, + accessRevision: spaceAccessRevisions[mutation.spaceID, default: 0], + revision: mutation.revision, + startedAt: startedAt + )) + } + + /// Explicitly resumes a cold process or moves the same call from another + /// device. Server ownership alone never admits media after browsing. + public func moveCallHere() { + guard callTransferEnabled, let call = currentCall, + !membershipMutationInFlight, + let accountToken = try? auth.beginAccountMutation() + else { return } + guard validateNativeAdmission() else { return } + localIntentRevision &+= 1 + selfReadEpoch &+= 1 + clearLocalAdmission() + membershipMutationInFlight = true + cancelPendingMembershipClaim = false + membershipMutationRevision &+= 1 + mediaCoordinator.setMicrophoneEnabled(false) + membershipSync.submit(GridMembershipOperation( + kind: .move(callID: call.callID, roomID: call.roomID), + spaceID: call.spaceID, + expectedMembershipID: call.membershipID, + intentRevision: localIntentRevision, + accountToken: accountToken, + microphoneEnabled: false, + automaticMicrophoneChange: nil, + accessRevision: spaceAccessRevisions[call.spaceID, default: 0], + revision: membershipMutationRevision, + startedAt: Date() + )) + } + + public func leaveCurrentRoom(spaceID: Int64) { + // A hanging claim must never make Leave or Cancel unable to stop locally. + if membershipMutationInFlight { + withdrawLocalAdmission() + for spaceID in Array(grids.keys) { + _ = optimisticallyLeave(spaceID: spaceID) + } + pendingMembershipMutations.removeAll() + return + } + let membershipID = observedMembershipFence() + let roomID = currentCall?.ownedByCurrentSession == true + ? currentCall?.roomID : grids[spaceID]?.currentRoomID + let isOwned = currentCall?.ownedByCurrentSession == true + || grids[spaceID]?.rooms + .contains(where: { $0.id == roomID && $0.avatars.contains(where: \.ownedByCurrentSession) }) == true + guard isOwned, let roomID, !membershipID.isEmpty, + let accountToken = try? auth.beginAccountMutation() + else { + withdrawLocalAdmission() + return + } + withdrawLocalAdmission() + cancelPendingMembershipClaim = false + let mutation = optimisticallyLeave(spaceID: spaceID) + if let mutation { + pendingMembershipMutations[mutation.revision] = mutation + } + if mutation == nil { + membershipMutationRevision &+= 1 + } + membershipMutationInFlight = true + platformEffects.playSound?(.leave) + membershipSync.submit(GridMembershipOperation( + kind: .leave(roomID: roomID), + spaceID: spaceID, + expectedMembershipID: membershipID, + intentRevision: localIntentRevision, + accountToken: accountToken, + microphoneEnabled: false, + automaticMicrophoneChange: nil, + accessRevision: spaceAccessRevisions[spaceID, default: 0], + revision: membershipMutationRevision, + startedAt: Date() + )) + } + + private func beginMembershipClaim() -> AuthAccountMutationToken? { + guard !membershipMutationInFlight, + let token = try? auth.beginAccountMutation() + else { return nil } + #if os(iOS) + guard callTransferEnabled else { return nil } + #endif + guard !callTransferEnabled || currentCall?.ownedByCurrentSession != false else { + lastError = "Move your call here first" + return nil + } + guard validateNativeAdmission() else { return nil } + localIntentRevision &+= 1 + selfReadEpoch &+= 1 + clearLocalAdmission() + membershipMutationInFlight = true + cancelPendingMembershipClaim = false + return token + } + + private func validateNativeAdmission() -> Bool { + #if os(iOS) + do { + try InlineAudioSession.shared.validateGridAdmission() + } catch { + lastError = error.localizedDescription + return false + } + #endif + lastError = nil + return true + } + + private func observedMembershipFence() -> String { + if let currentCall { + return currentCall.membershipID + } + return grids.values.lazy.flatMap(\.rooms).flatMap(\.avatars) + .first(where: \.ownedByCurrentSession)?.membershipID ?? "" + } + + public func toggleMicrophone(spaceID: Int64) { + guard hasLocalAdmission, let grid = grids[spaceID], grid.hasCurrentRoomID, + let room = grid.rooms.first(where: { $0.id == grid.currentRoomID }), + room.avatars.contains(where: \.ownedByCurrentSession) + else { return } + let roomID = grid.currentRoomID + let enabled = mediaCoordinator.toggleMicrophone() + applyAvatarMicrophoneState( + spaceID: spaceID, + roomID: roomID, + userID: nil, + enabled: enabled + ) + avatarStateSync.submitMicrophoneState(roomID: roomID, enabled: enabled) + reconcileMediaDemand() + } + + public func openRoomThread(roomID: Int64) async throws -> Int64 { + let room = try transcriptionRoom(roomID: roomID) + guard let avatar = room.avatars.first(where: \.ownedByCurrentSession), !avatar.membershipID.isEmpty else { + throw GridRoomAPIError.invalidResponse + } + let chatID = try await api.openRoomThread(roomID: roomID, membershipID: avatar.membershipID) + let latestRoom = try transcriptionRoom(roomID: roomID) + guard latestRoom.avatars.contains(where: { $0.ownedByCurrentSession && $0.membershipID == avatar.membershipID }) + else { + throw GridRoomAPIError.invalidResponse + } + // Thread sidecars are applied by the transaction before navigation. + return chatID + } + + public func setTranscription( + room: GridRoom, + enabled: Bool, + destination: GridTranscriptionRequest.Destination = .last + ) async throws { + let roomID = room.id + guard pendingTranscriptionRequestIDs[roomID] == nil else { throw GridRoomAPIError.invalidResponse } + let latestRoom = try transcriptionRoom(roomID: roomID) + guard let request = GridTranscriptionRequest(room: room, enabled: enabled, destination: destination) else { + throw GridRoomAPIError.invalidResponse + } + guard latestRoom.avatars.contains(where: { $0.ownedByCurrentSession && $0.membershipID == request.membershipID }), + latestRoom.hasConnection, latestRoom.connection.generation == request.generation + else { throw GridRoomAPIError.invalidResponse } + let accessRevision = spaceAccessRevisions[room.spaceID, default: 0] + pendingTranscriptionRequestIDs[roomID] = request.requestID + defer { + if pendingTranscriptionRequestIDs[roomID] == request.requestID { + pendingTranscriptionRequestIDs.removeValue(forKey: roomID) + } + } + do { + let grid = try await api.setTranscription(request: request) + guard accessRevision == spaceAccessRevisions[room.spaceID, default: 0] else { return } + applySnapshot([grid]) + } catch { + // A rejected fence or uncertain response must repair from the authority, + // never synthesize an active/stopped state in the client. + if accessRevision == spaceAccessRevisions[room.spaceID, default: 0] { + try? await reloadGrid(spaceID: room.spaceID) + } + throw error + } + } + + public func listTranscripts(roomID: Int64) async throws -> [GridTranscriptDestinationInfo] { + let room = try transcriptionRoom(roomID: roomID) + let membershipID = room.avatars.first(where: \.ownedByCurrentSession)?.membershipID + let transcripts = try await api.listTranscripts(roomID: roomID) + let latestRoom = try transcriptionRoom(roomID: roomID) + guard latestRoom.avatars.contains(where: { $0.ownedByCurrentSession && $0.membershipID == membershipID }) else { + throw GridRoomAPIError.invalidResponse + } + return transcripts + } + + private func transcriptionRoom(roomID: Int64) throws -> GridRoom { + guard let grid = grids.values.first(where: { $0.hasCurrentRoomID && $0.currentRoomID == roomID }), + let room = grid.rooms.first(where: { $0.id == roomID }), + room.avatars.contains(where: { $0.ownedByCurrentSession && !$0.membershipID.isEmpty }) + else { throw GridRoomAPIError.invalidResponse } + return room + } + + public func toggleCurrentMicrophone() { + guard let grid = grids.values.first(where: { grid in + guard grid.hasCurrentRoomID, + let room = grid.rooms.first(where: { $0.id == grid.currentRoomID }) + else { return false } + return room.avatars.contains(where: \.ownedByCurrentSession) + }) else { return } + + toggleMicrophone(spaceID: grid.spaceID) + } + + public func toggleScreenShare() { + guard currentMediaTarget() != nil else { return } + if media.isScreenShareRequested || media.isScreenSharing { + mediaCoordinator.stopScreenSharing() + resetScreenShareAloneGrace() + return + } + let displayID = platformEffects.displayIDUnderPointer?() + Task { [weak self] in + guard let self else { return } + await mediaCoordinator.startScreenSharing(displayID: displayID) + reconcileScreenShareAloneGrace() + } + } + + public func startScreenSharing(_ source: InlineRTCScreenCaptureSource) { + guard currentMediaTarget() != nil else { return } + mediaCoordinator.startScreenSharing(source) + reconcileScreenShareAloneGrace() + } + + public func setScreenShareQualityProfile(_ profile: InlineRTCScreenShareQualityProfile) { + mediaCoordinator.setScreenShareQualityProfile(profile) + } + + public func stopScreenSharing() { + mediaCoordinator.stopScreenSharing() + resetScreenShareAloneGrace() + } + + public func refreshScreenCaptureSources() { + Task { [weak self] in + await self?.mediaCoordinator.refreshScreenCaptureSources() + } + } + + public func openScreenShare(for avatar: GridAvatar) { + guard let participantIdentity = screenShareParticipantIdentity(for: avatar) else { return } + openScreenShare( + user: avatar.user, + participantIdentity: participantIdentity + ) + } + + public func openScreenShareFromNotification( + spaceID: Int64, + roomID: Int64, + userID: Int64, + participantIdentity: String + ) { + Task { [weak self] in + guard let self else { return } + await load(spaceID: spaceID) + guard let grid = grids[spaceID], + let room = grid.rooms.first(where: { $0.id == roomID }), + let avatar = room.avatars.first(where: { $0.user.id == userID }) + else { return } + openScreenShare( + user: avatar.user, + participantIdentity: participantIdentity + ) + } + } + + public func toggleRoomLock(roomID: Int64, locked: Bool) async { + guard let mutation = optimisticallyUpdateRoom( + roomID: roomID, + field: .locked, + update: { $0.locked = locked } + ) else { return } + do { + let grid = try await api.setLocked(locked, roomID: roomID) + guard isCurrent(mutation) else { return } + pendingRoomMutations.removeValue(forKey: mutation.key) + applySnapshot([grid]) + } catch { + guard isCurrent(mutation) else { return } + rollbackRoomMutation(mutation) + lastError = String(describing: error) + if networkAvailable { + try? await reloadGrid(spaceID: mutation.change.spaceID) + } + } + } + + public func setRoomTitle(roomID: Int64, title: String) async { + let normalizedTitle = title.split(whereSeparator: \Character.isWhitespace).joined(separator: " ") + guard let mutation = optimisticallyUpdateRoom( + roomID: roomID, + field: .title, + update: { room in + if normalizedTitle.isEmpty { + room.clearTitle() + } else { + room.title = normalizedTitle + } + } + ) else { return } + do { + let grid = try await api.setTitle(title, roomID: roomID) + guard isCurrent(mutation) else { return } + pendingRoomMutations.removeValue(forKey: mutation.key) + applySnapshot([grid]) + } catch { + guard isCurrent(mutation) else { return } + rollbackRoomMutation(mutation) + lastError = String(describing: error) + if networkAvailable { + try? await reloadGrid(spaceID: mutation.change.spaceID) + } + } + } + + public func deleteRoom(roomID: Int64) async { + guard let spaceID = grids.first(where: { _, grid in + grid.rooms.contains(where: { $0.id == roomID }) + })?.key else { return } + let accessRevision = spaceAccessRevisions[spaceID, default: 0] + do { + let grid = try await api.deleteRoom(roomID: roomID) + guard accessRevision == spaceAccessRevisions[spaceID, default: 0] else { return } + applySnapshot([grid]) + await loadHome() + } catch { + lastError = String(describing: error) + } + } + + private func reloadGrid(spaceID: Int64, renewingMembershipID: String? = nil) async throws { + let accessRevision = spaceAccessRevisions[spaceID, default: 0] + guard let read = beginSelfRead() else { return } + let response = try await api.grid( + spaceID: spaceID, + expectedMembershipID: renewingMembershipID, + accountToken: read.accountToken + ) + guard accessRevision == spaceAccessRevisions[spaceID, default: 0], isCurrent(read) else { return } + applySnapshot([response.grid]) + applySelfRead(response.currentCall, capability: response.grid.callTransferEnabled, fence: read) + syncMicrophoneStateIfNeeded(grid: response.grid) + await prepareConnectionIfNeeded(grid: response.grid) + } + + private func apply(grids nextGrids: [InlineProtocol.Grid], credentials: GridConnectionCredentials? = nil) async { + applySnapshot(nextGrids) + nextGrids.forEach(syncMicrophoneStateIfNeeded) + if let credentials { + await accept(credentials) + } + } + + private func applySnapshot(_ nextGrids: [InlineProtocol.Grid]) { + for incomingGrid in nextGrids { + let previousRevision = lastAcceptedSnapshotRevisions[incomingGrid.spaceID] + if let previousRevision, incomingGrid.revision < previousRevision { + log.debug( + "GRID_TRACE phase=snapshot_rejected_stale space=\(incomingGrid.spaceID) incoming_revision=\(incomingGrid.revision) current_revision=\(previousRevision)" + ) + continue + } + lastAcceptedSnapshotRevisions[incomingGrid.spaceID] = incomingGrid.revision + // Record ownership before pending room/membership UI overlays. + if incomingGrid.enabled, + let room = incomingGrid.rooms.first(where: { $0.avatars.contains(where: \.ownedByCurrentSession) }), + let avatar = room.avatars.first(where: \.ownedByCurrentSession), !avatar.membershipID.isEmpty + { + acceptedOwnedRoom = GridOwnedRoomAuthority( + spaceID: incomingGrid.spaceID, roomID: room.id, membershipID: avatar.membershipID + ) + } else if acceptedOwnedRoom?.spaceID == incomingGrid.spaceID { + acceptedOwnedRoom = nil + } + var grid = incomingGrid + for mutation in pendingRoomMutations.values where mutation.change.spaceID == grid.spaceID { + grid = GridOptimisticState.applyingRoomIntent(mutation.change, to: grid) + } + grids[grid.spaceID] = grid + failedLoadSpaceIDs.remove(grid.spaceID) + pendingConnectionSpaceIDs.remove(grid.spaceID) + applyEnabled(grid.enabled, spaceID: grid.spaceID) + } + applyPendingMembershipIntent() + + reconcileLocalAdmission() + reconcileAvatarStateOwnership() + reconcileMediaDemand() + } + + private func reconcileMediaDemand() { + let target = currentMediaTarget() + if credentialRetryTarget != target { + resetCredentialRetry() + } + if screenShareNotificationTarget != target { + resetScreenSharePresentationEdges(target: target) + } + mediaCoordinator.setTarget(target) + reconcileAloneAutoMute() + reconcileScreenShareAloneGrace() + } + + private func resetScreenSharePresentationEdges(target: GridMediaTarget? = nil) { + pendingScreenShareOpenTasks.values.forEach { $0.task.cancel() } + pendingScreenShareOpenTasks.removeAll() + screenShareNotificationTarget = target + observedRemoteScreenShares.removeAll() + hasPrimedScreenSharePresentationEdges = false + } + + private func reconcileScreenSharePresentationEdges() { + guard let target = currentMediaTarget(), + screenShareNotificationTarget == target, + media.hasParticipantMediaSnapshot, + let grid = grids[target.spaceID], + let room = grid.rooms.first(where: { $0.id == target.roomID }) + else { return } + + var next: [String: GridScreenShareNotice] = [:] + for avatar in room.avatars where !avatar.ownedByCurrentSession && isScreenSharing(avatar: avatar) { + guard let participantIdentity = screenShareParticipantIdentity(for: avatar) else { continue } + next[participantIdentity] = GridScreenShareNotice( + target: target, + user: avatar.user, + participantIdentity: participantIdentity + ) + } + + guard hasPrimedScreenSharePresentationEdges else { + observedRemoteScreenShares = next + hasPrimedScreenSharePresentationEdges = true + return + } + + let previous = observedRemoteScreenShares + observedRemoteScreenShares = next + for (identity, notice) in next where previous[identity] == nil { + presentScreenShareChange(notice, started: true) + } + for (identity, notice) in previous where next[identity] == nil { + pendingScreenShareOpenTasks.removeValue(forKey: identity)?.task.cancel() + presentScreenShareChange(notice, started: false) + } + } + + private func presentScreenShareChange( + _ notice: GridScreenShareNotice, + started: Bool + ) { + platformEffects.presentScreenShareChange?(GridScreenShareChange( + spaceID: notice.target.spaceID, + roomID: notice.target.roomID, + user: notice.user, + participantIdentity: notice.participantIdentity, + started: started + ), self) + } + + private func reconcileAloneAutoMute() { + guard mediaCoordinator.shouldAutoMuteWhenAlone else { + cancelAloneAutoMute() + return + } + let target = grids.values.lazy.compactMap { grid -> GridAloneAutoMuteTarget? in + guard grid.hasCurrentRoomID, + let room = grid.rooms.first(where: { $0.id == grid.currentRoomID }), + room.avatars.count == 1, + let avatar = room.avatars.first, + avatar.ownedByCurrentSession, + avatar.microphoneEnabled, + self.mediaCoordinator.isMicrophoneEnabled + else { return nil } + return GridAloneAutoMuteTarget( + spaceID: grid.spaceID, + roomID: room.id, + userID: avatar.user.id + ) + }.first + + guard target != aloneAutoMuteTarget else { return } + cancelAloneAutoMute() + guard let target else { return } + + aloneAutoMuteTarget = target + aloneAutoMuteTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(Self.aloneMediaGraceSeconds)) + guard !Task.isCancelled else { return } + self?.autoMuteIfStillAlone(target) + } + } + + private func autoMuteIfStillAlone(_ target: GridAloneAutoMuteTarget) { + guard aloneAutoMuteTarget == target else { return } + aloneAutoMuteTask = nil + aloneAutoMuteTarget = nil + + guard mediaCoordinator.shouldAutoMuteWhenAlone, + let grid = grids[target.spaceID], + grid.hasCurrentRoomID, + grid.currentRoomID == target.roomID, + let room = grid.rooms.first(where: { $0.id == target.roomID }), + room.avatars.count == 1, + let avatar = room.avatars.first, + avatar.ownedByCurrentSession, + avatar.user.id == target.userID, + avatar.microphoneEnabled, + mediaCoordinator.isMicrophoneEnabled + else { return } + + log.debug("GRID_TRACE phase=alone_auto_mute room=\(target.roomID)") + toggleMicrophone(spaceID: target.spaceID) + } + + private func cancelAloneAutoMute() { + aloneAutoMuteTask?.cancel() + aloneAutoMuteTask = nil + aloneAutoMuteTarget = nil + } + + private func reconcileScreenShareAloneGrace() { + let context = screenShareAloneContext() + let action = screenShareAloneGrace.reconcile(context) + + guard case let .scheduleStop(publicationIDs) = action, + let mediaTarget = currentMediaTarget() + else { + cancelScreenShareAloneTask() + return + } + + let target = GridScreenShareAloneTarget( + mediaTarget: mediaTarget, + episodeID: context.episodeID, + publicationIDs: publicationIDs + ) + guard target != screenShareAloneTarget else { return } + cancelScreenShareAloneTask() + screenShareAloneTarget = target + screenShareAloneTask = Task { [weak self] in + try? await Task.sleep(for: GridScreenShareAloneGraceState.graceDuration) + guard !Task.isCancelled else { return } + self?.stopScreenShareIfStillAlone(target) + } + } + + private func screenShareAloneContext() -> GridScreenShareAloneGraceState.Context { + let localShares = media.screenShares.filter(\.isLocal) + let localPublicationIDs = Set(localShares.map(\.publicationID)) + let localParticipantIdentities = Set(localShares.map(\.participantIdentity)) + let hasRemoteParticipant = media.connectedParticipantIdentities.contains { + !localParticipantIdentities.contains($0) + } + return .init( + episodeID: media.screenShareEpisodeID, + isConnected: media.connectionState == .connected, + isShareRequested: media.isScreenShareRequested, + localPublicationIDs: localPublicationIDs, + hasRemoteParticipant: hasRemoteParticipant + ) + } + + private func stopScreenShareIfStillAlone(_ target: GridScreenShareAloneTarget) { + guard screenShareAloneTarget == target else { return } + screenShareAloneTask = nil + screenShareAloneTarget = nil + guard currentMediaTarget() == target.mediaTarget, + GridScreenShareAloneGraceState.shouldCommitScheduledStop( + screenShareAloneContext(), + expectedEpisodeID: target.episodeID, + expectedPublicationIDs: target.publicationIDs + ) + else { return } + + log.debug( + "GRID_TRACE phase=alone_auto_stop_screen_share room=\(target.mediaTarget.roomID)" + ) + mediaCoordinator.stopScreenSharing() + resetScreenShareAloneGrace() + } + + private func cancelScreenShareAloneTask() { + screenShareAloneTask?.cancel() + screenShareAloneTask = nil + screenShareAloneTarget = nil + } + + private func resetScreenShareAloneGrace() { + cancelScreenShareAloneTask() + screenShareAloneGrace.reset() + } + + private func optimisticallyUpdateRoom( + roomID: Int64, + field: GridOptimisticState.RoomField, + update: (inout GridRoom) -> Void + ) -> OptimisticRoomMutation? { + guard let change = GridOptimisticState.updatingRoom( + in: grids, + roomID: roomID, + update: update + ) else { return nil } + + guard change.fields == Set([field]) else { return nil } + let key = OptimisticRoomMutationKey(roomID: roomID, field: field) + let revision = (roomMutationRevisions[key] ?? 0) + 1 + roomMutationRevisions[key] = revision + grids[change.spaceID] = change.nextGrid + let mutation = OptimisticRoomMutation( + key: key, + revision: revision, + accessRevision: spaceAccessRevisions[change.spaceID, default: 0], + change: change + ) + pendingRoomMutations[key] = mutation + return mutation + } + + private func rollbackRoomMutation(_ mutation: OptimisticRoomMutation) { + guard isCurrent(mutation) else { return } + pendingRoomMutations.removeValue(forKey: mutation.key) + guard let currentGrid = grids[mutation.change.spaceID] else { return } + grids[mutation.change.spaceID] = GridOptimisticState.rollingBackRoomIntent( + mutation.change, + in: currentGrid + ) + } + + private func isCurrent(_ mutation: OptimisticRoomMutation) -> Bool { + roomMutationRevisions[mutation.key] == mutation.revision + && spaceAccessRevisions[mutation.change.spaceID, default: 0] == mutation.accessRevision + } + + private func optimisticallyJoin(roomID: Int64) -> OptimisticMembershipMutation? { + let fallbackAvatar = makeCurrentUserAvatar() + let joinedAt = Int64(Date().timeIntervalSince1970) + guard let change = GridOptimisticState.joining( + roomID: roomID, + in: grids, + avatar: fallbackAvatar, + microphoneEnabled: mediaCoordinator.isMicrophoneEnabled, + joinedAt: joinedAt + ) else { return nil } + + membershipRollbackBaseline.beginIfNeeded(with: change.previousGrids) + + membershipMutationRevision &+= 1 + let mutation = OptimisticMembershipMutation( + roomID: roomID, + spaceID: change.spaceID, + revision: membershipMutationRevision, + previousGrids: change.previousGrids, + intent: .join(fallbackAvatar: fallbackAvatar, joinedAt: joinedAt) + ) + for (spaceID, grid) in change.nextGrids { + grids[spaceID] = grid + } + reconcileAvatarStateOwnership() + log.debug("GRID_TRACE phase=join_optimistic_applied room=\(roomID) revision=\(mutation.revision)") + return mutation + } + + private func optimisticallyLeave(spaceID: Int64) -> OptimisticMembershipMutation? { + guard let change = GridOptimisticState.leaving(spaceID: spaceID, in: grids) else { + return nil + } + + membershipRollbackBaseline.beginIfNeeded(with: change.previousGrids) + + membershipMutationRevision &+= 1 + let mutation = OptimisticMembershipMutation( + roomID: change.roomID, + spaceID: spaceID, + revision: membershipMutationRevision, + previousGrids: change.previousGrids, + intent: .leave + ) + for (changedSpaceID, grid) in change.nextGrids { + grids[changedSpaceID] = grid + } + reconcileAvatarStateOwnership() + log.debug("GRID_TRACE phase=leave_optimistic_applied room=\(change.roomID) revision=\(mutation.revision)") + return mutation + } + + private func rollbackMembershipMutation(_ mutation: OptimisticMembershipMutation) { + guard membershipMutationRevision == mutation.revision else { return } + grids = GridOptimisticState.rollingBackMembershipIntent( + .init( + roomID: mutation.roomID, + spaceID: mutation.spaceID, + previousGrids: membershipRollbackBaseline.grids ?? mutation.previousGrids, + nextGrids: [:] + ), + in: grids + ) + reconcileAvatarStateOwnership() + reconcileMediaDemand() + } + + private func makeCurrentUserAvatar() -> GridAvatar? { + guard let userID = auth.userId() else { return nil } + + var protocolUser = InlineProtocol.User() + protocolUser.id = userID + if let user = ObjectCache.shared.getUser(id: userID)?.user { + if let firstName = user.firstName { + protocolUser.firstName = firstName + } + if let lastName = user.lastName { + protocolUser.lastName = lastName + } + if let username = user.username { + protocolUser.username = username + } + if let profileCdnURL = user.profileCdnUrl { + protocolUser.profilePhoto = .with { + $0.cdnURL = profileCdnURL + if let uniqueID = user.profileFileUniqueId { + $0.fileUniqueID = uniqueID + } + } + } + } + + return .with { + $0.user = protocolUser + $0.joinedAt = Int64(Date().timeIntervalSince1970) + $0.ownedByCurrentSession = true + $0.microphoneEnabled = mediaCoordinator.isMicrophoneEnabled + } + } + + private func applyEnabled(_ enabled: Bool, spaceID: Int64) { + if enabled { + enabledSpaceIDs.insert(spaceID) + } else { + enabledSpaceIDs.remove(spaceID) + grids[spaceID] = .with { $0.spaceID = spaceID + $0.enabled = false + } + if acceptedOwnedRoom?.spaceID == spaceID { + acceptedOwnedRoom = nil + } + if localAdmission?.spaceID == spaceID { + clearLocalAdmission() + } + if currentCall?.spaceID == spaceID { + currentCall = nil + } + reconcileAvatarStateOwnership() + } + } + + func handle(_ event: GridRoomLifecycleEvent) async { + switch event { + case let .grid(event): + await handle(event) + case let .realtimeConnection(state): + await handleConnectionState(state) + case let .network(snapshot): + await handleNetworkPath(snapshot) + case .heartbeat: + await refreshOwnedPresence() + } + } + + private func handle(_ event: GridMediaCoordinatorEvent) async { + switch event { + case let .credentialsNeeded(target): + guard let grid = grids[target.spaceID] else { return } + await prepareConnectionIfNeeded(grid: grid) + case let .connected(_, rtcConnectMilliseconds): + guard let startedAt = mediaInteractionStartedAt else { return } + mediaInteractionStartedAt = nil + let totalMilliseconds = Self.elapsedMilliseconds(since: startedAt) + PerformanceTrace.breadcrumb( + "Grid connection ready", + category: "Grid.Connection", + level: totalMilliseconds >= 3_000 ? .warning : .info, + data: [ + "click_to_connected_ms": totalMilliseconds, + "rtc_connect_ms": rtcConnectMilliseconds ?? -1, + ] + ) + log.debug( + "GRID_TRACE phase=click_to_connected elapsed_ms=\(totalMilliseconds) rtc_ms=\(rtcConnectMilliseconds ?? -1)" + ) + case .microphoneSafetyPaused: + if let grid = grids.values.first(where: { $0.hasCurrentRoomID }) { + syncMicrophoneStateIfNeeded(grid: grid) + } + reconcileMediaDemand() + case let .audioEligibilityChanged(eligible): + publishMediaRetention(eligible && hasLocalAdmission) + case .screenShareContextChanged: + reconcileScreenSharePresentationEdges() + reconcileScreenShareAloneGrace() + } + } + + func handle(_ event: GridMembershipSyncEvent) async { + let operation: GridMembershipOperation = switch event { + case let .created(value, _), let .joined(value, _), let .moved(value, _), + let .left(value, _), let .failed(value, _): + value + } + // A reply can already be buffered when auth resets its executor. Implicit replacement + // may preserve the account token, so an auth-only revision fence must precede all edits. + guard operation.revision > retiredMembershipRevision, + (try? auth.validateAccountMutation(operation.accountToken)) != nil + else { return } + // Reads concurrent with the claim may linearize before its transaction. + // Their self projection must not be relabeled as a later ownership fact. + selfReadEpoch &+= 1 + let mutation = pendingMembershipMutations.removeValue(forKey: operation.revision) + let accessCurrent = operation.accessRevision == spaceAccessRevisions[operation.spaceID, default: 0] + let intentCurrent = operation.intentRevision == localIntentRevision + let wasCancelled = cancelPendingMembershipClaim || !intentCurrent + // The sole executor is done. A cancellation may enqueue one fenced cleanup + // only after this outcome identifies the claim that actually committed. + membershipMutationInFlight = false + cancelPendingMembershipClaim = false + + // A membership response can have committed before access was revoked. + // Its complete snapshot cannot restore that space after the revocation. + // Repair global call display through a fresh, fenced Home read instead. + guard accessCurrent else { + if pendingMembershipMutations.isEmpty { + membershipRollbackBaseline.clear() + } + await loadHome() + return + } + + switch event { + case let .created(_, response), let .joined(_, response), let .moved(_, response): + mediaCoordinator.commitAutomaticMicrophoneChange(operation.automaticMicrophoneChange) + updateMembershipRollbackBaseline(with: response.grids) + applySnapshot(response.grids) + if accessCurrent, intentCurrent, !wasCancelled, response.moved, + let admission = admission(from: response.currentCall, operation: operation, grids: response.grids) + { + currentCall = response.currentCall + localAdmission = admission + reconcileAvatarStateOwnership() + reconcileMediaDemand() + response.grids.forEach(syncMicrophoneStateIfNeeded) + if let credentials = response.credentials { + await accept(credentials) + } + if let grid = grids[admission.spaceID] { + await prepareConnectionIfNeeded(grid: grid) + } + } else { + clearLocalAdmission() + } + if wasCancelled, accessCurrent, response.moved, + let claim = admission(from: response.currentCall, operation: operation, grids: response.grids) + { + let call = response.currentCall ?? GridCurrentCall.with { + $0.callID = claim.callID + $0.membershipID = claim.membershipID + $0.spaceID = claim.spaceID + $0.roomID = claim.roomID + $0.ownedByCurrentSession = true + } + submitCancelledClaimCleanup(call, accountToken: operation.accountToken) + } else { + await loadHome() + } + case let .left(_, response): + updateMembershipRollbackBaseline(with: response.grids) + applySnapshot(response.grids) + clearLocalAdmission() + await loadHome() + case let .failed(_, message): + mediaInteractionStartedAt = nil + if intentCurrent, let mutation { + rollbackMembershipMutation(mutation) + } + // A failed leave or uncertain claim can restore display membership, but + // never restore local permission/capture/admission. + clearLocalAdmission() + mediaCoordinator.restoreAutomaticMicrophoneChangeIfCurrent(operation.automaticMicrophoneChange) + lastError = message + await loadHome() + if accessCurrent { + try? await reloadGrid(spaceID: operation.spaceID) + } + } + if pendingMembershipMutations.isEmpty { + membershipRollbackBaseline.clear() + } + } + + private func submitCancelledClaimCleanup(_ call: GridCurrentCall, accountToken: AuthAccountMutationToken) { + membershipMutationRevision &+= 1 + membershipMutationInFlight = true + membershipSync.submit(GridMembershipOperation( + kind: .leave(roomID: call.roomID), + spaceID: call.spaceID, + expectedMembershipID: call.membershipID, + intentRevision: localIntentRevision, + accountToken: accountToken, + microphoneEnabled: false, + automaticMicrophoneChange: nil, + accessRevision: spaceAccessRevisions[call.spaceID, default: 0], + revision: membershipMutationRevision, + startedAt: Date() + )) + } + + func handle(_ event: GridEvent) async { + switch event.event { + case let .changed(changed): + selfReadEpoch &+= 1 + if let target = aloneAutoMuteTarget, + changed.spaceIds.contains(target.spaceID) + || (changed.hasRoomID && changed.roomID == target.roomID) + { + cancelAloneAutoMute() + } + log.debug( + "GRID_TRACE phase=changed_event_received spaces=\(changed.spaceIds.map { String($0) }.joined(separator: ",")) room=\(changed.hasRoomID ? String(changed.roomID) : "none")" + ) + for spaceID in changed.spaceIds { + if enabledSpaceIDs.contains(spaceID) { + try? await reloadGrid(spaceID: spaceID) + } else { + // A Grid-changed event is also the cross-session enablement signal. + // Refresh settings when this process still believes Grid is disabled. + await load(spaceID: spaceID) + } + } + await loadHome() + case let .connectionReady(ready): + guard ready.hasCredentials else { return } + log.debug( + "GRID_TRACE phase=ready_event_received room=\(ready.credentials.connection.roomID) generation=\(ready.credentials.connection.generation)" + ) + await accept(ready.credentials) + case let .avatarStateChanged(changed): + log.debug( + "GRID_TRACE phase=avatar_state_event_received room=\(changed.roomID) user=\(changed.userID) microphone=\(changed.microphoneEnabled) revision=\(changed.microphoneRevision)" + ) + applyRemoteAvatarMicrophoneState( + spaceID: changed.spaceID, + roomID: changed.roomID, + userID: changed.userID, + enabled: changed.microphoneEnabled, + membershipID: changed.membershipID, + revision: changed.microphoneRevision + ) + case let .accessRevoked(revoked): + selfReadEpoch &+= 1 + clearLocalGrid(spaceID: revoked.spaceID, reason: "server_revoked") + case nil: + break + } + } + + private func clearLocalGrid(spaceID: Int64, reason: String) { + if acceptedOwnedRoom?.spaceID == spaceID { + acceptedOwnedRoom = nil + } + if currentCall?.spaceID == spaceID { + currentCall = nil + } + if localAdmission?.spaceID == spaceID { + clearLocalAdmission() + } + let hadMediaDemand = currentMediaTarget()?.spaceID == spaceID + spaceAccessRevisions[spaceID, default: 0] &+= 1 + homeLoadRevision &+= 1 + pendingMembershipMutations = pendingMembershipMutations.filter { _, mutation in + mutation.spaceID != spaceID + } + membershipRollbackBaseline.remove(spaceID: spaceID) + if pendingMembershipMutations.isEmpty { + membershipRollbackBaseline.clear() + } + roomMutationRevisions = roomMutationRevisions.filter { key, _ in + grids[spaceID]?.rooms.contains(where: { $0.id == key.roomID }) != true + } + pendingRoomMutations = pendingRoomMutations.filter { _, mutation in + mutation.change.spaceID != spaceID + } + grids.removeValue(forKey: spaceID) + enabledSpaceIDs.remove(spaceID) + homeSpaces.removeAll { $0.spaceID == spaceID } + pendingReloadSpaceIDs.remove(spaceID) + pendingConnectionSpaceIDs.remove(spaceID) + failedLoadSpaceIDs.remove(spaceID) + reconcileAvatarStateOwnership() + + if hadMediaDemand { + pendingCredentialTarget = nil + resetCredentialRetry() + mediaInteractionStartedAt = nil + mediaCoordinator.clearCredentials() + } + reconcileMediaDemand() + log.info("GRID_TRACE phase=local_grid_cleared space=\(spaceID) reason=\(reason)") + PerformanceTrace.breadcrumb( + "Local Grid access cleared", + category: "Grid.Access", + level: .info, + data: ["space_id": spaceID, "reason": reason] + ) + } + + private func prepareConnectionIfNeeded(grid: InlineProtocol.Grid) async { + guard let target = currentMediaTarget(), target.spaceID == grid.spaceID, + let room = grid.rooms.first(where: { $0.id == target.roomID }), + let admission = localAdmission + else { + reconcileMediaDemand() + return + } + reconcileMediaDemand() + // A LiveKit token is only admission material. Once this exact generation + // has an established transport, refreshing an expired token on every + // presence heartbeat adds server work without helping the active room. + // A later disconnect clears this state; the RTC engine then requests fresh + // credentials before its next connection attempt. + if mediaCoordinator.isConnected(to: target) { + resetCredentialRetry() + return + } + if mediaCoordinator.hasUsableCredentials(for: target) { + resetCredentialRetry() + return + } + mediaCoordinator.invalidateCredentials(for: target) + guard pendingCredentialTarget != target else { return } + + pendingCredentialTarget = target + defer { + if pendingCredentialTarget == target { + pendingCredentialTarget = nil + } + } + let startedAt = Date() + log.debug( + "GRID_ENGINE phase=credentials_fetch_start room=\(room.id) generation=\(room.connection.generation)" + ) + do { + guard let connection = try await api.prepareConnection( + roomID: room.id, + generation: room.connection.generation, + expectedMembershipID: target.membershipID, + accountToken: admission.accountToken + ) else { + log.debug( + "GRID_ENGINE phase=credentials_fetch_unavailable room=\(room.id) generation=\(room.connection.generation) elapsed_ms=\(Self.elapsedMilliseconds(since: startedAt))" + ) + scheduleCredentialRetry(for: target) + return + } + log.debug( + "GRID_ENGINE phase=credentials_fetch_done room=\(room.id) generation=\(room.connection.generation) elapsed_ms=\(Self.elapsedMilliseconds(since: startedAt))" + ) + await accept(connection) + } catch { + guard currentMediaTarget() == target, + (try? auth.validateAccountMutation(admission.accountToken)) != nil + else { return } + lastError = String(describing: error) + log.error( + "GRID_ENGINE phase=credentials_fetch_failed room=\(room.id) generation=\(room.connection.generation)", + error: error + ) + scheduleCredentialRetry(for: target) + } + } + + private func accept(_ credentials: GridConnectionCredentials) async { + guard let serverURL = URL(string: credentials.serverURL), + let target = currentMediaTarget(), + let admission = localAdmission, + GridCredentialBinding.accepts( + credentials: credentials, target: target, + userID: admission.accountToken.userID, + callTransferEnabled: callTransferEnabled + ) + else { + log.debug( + "GRID_ENGINE phase=credentials_ignored_stale room=\(credentials.connection.roomID) generation=\(credentials.connection.generation)" + ) + return + } + let accepted = mediaCoordinator.accept(InlineRTCCredentials( + target: target.rtcSessionID, + serverURL: serverURL, + participantIdentity: credentials.participantIdentity, + token: credentials.token, + expiresAt: Date(timeIntervalSince1970: TimeInterval(credentials.expiresAt)) + )) + guard accepted else { return } + resetCredentialRetry() + log.debug( + "GRID_ENGINE phase=credentials_accepted room=\(target.roomID) generation=\(target.generation)" + ) + } + + private func scheduleCredentialRetry(for target: GridMediaTarget) { + guard currentMediaTarget() == target, + credentialRetryTask == nil + else { return } + if credentialRetryTarget != target { + credentialRetryTarget = target + credentialRetryAttempt = 0 + } + credentialRetryAttempt &+= 1 + let attempt = credentialRetryAttempt + let delay = Self.credentialRetryDelay(attempt: attempt) + log.debug( + "GRID_ENGINE phase=credentials_retry_scheduled room=\(target.roomID) generation=\(target.generation) attempt=\(attempt) delay_s=\(delay)" + ) + credentialRetryTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled else { return } + await self?.credentialRetryFired(target: target, attempt: attempt) + } + } + + private func credentialRetryFired(target: GridMediaTarget, attempt: Int) async { + guard credentialRetryTarget == target, + credentialRetryAttempt == attempt + else { return } + credentialRetryTask = nil + guard currentMediaTarget() == target, + let grid = grids[target.spaceID] + else { + resetCredentialRetry() + return + } + await prepareConnectionIfNeeded(grid: grid) + } + + private func resetCredentialRetry() { + credentialRetryTask?.cancel() + credentialRetryTask = nil + credentialRetryTarget = nil + credentialRetryAttempt = 0 + } + + private func refreshOwnedPresence() async { + guard let admission = localAdmission else { return } + try? await reloadGrid(spaceID: admission.spaceID, renewingMembershipID: admission.membershipID) + } + + private func syncMicrophoneStateIfNeeded(grid: InlineProtocol.Grid) { + guard let admission = localAdmission, admission.spaceID == grid.spaceID, grid.hasCurrentRoomID, + let room = grid.rooms.first(where: { $0.id == grid.currentRoomID }), + let avatar = room.avatars.first(where: \.ownedByCurrentSession), + avatar.membershipID == admission.membershipID + else { return } + let microphoneEnabled = mediaCoordinator.isMicrophoneEnabled + guard avatar.microphoneEnabled != microphoneEnabled else { return } + applyAvatarMicrophoneState( + spaceID: grid.spaceID, + roomID: room.id, + userID: nil, + enabled: microphoneEnabled + ) + avatarStateSync.submitMicrophoneState(roomID: room.id, enabled: microphoneEnabled) + } + + private func applyAvatarMicrophoneState( + spaceID: Int64, + roomID: Int64, + userID: Int64?, + enabled: Bool, + revision: Int32? = nil + ) { + guard var grid = grids[spaceID], + let roomIndex = grid.rooms.firstIndex(where: { $0.id == roomID }), + let avatarIndex = grid.rooms[roomIndex].avatars.firstIndex(where: { avatar in + if let userID { + return avatar.user.id == userID + } + return avatar.ownedByCurrentSession + }) + else { return } + grid.rooms[roomIndex].avatars[avatarIndex].microphoneEnabled = enabled + if let revision { + grid.rooms[roomIndex].avatars[avatarIndex].microphoneRevision = revision + } + grids[spaceID] = grid + } + + /// Inline's local microphone intent remains authoritative for the avatar + /// owned by this process. A delayed echo from an earlier rapid toggle must + /// not repaint that avatar with stale server state; other avatars always use + /// the transient state sent by their owning session. + private func applyRemoteAvatarMicrophoneState( + spaceID: Int64, + roomID: Int64, + userID: Int64, + enabled: Bool, + membershipID: String, + revision: Int32 + ) { + guard let grid = grids[spaceID], + let room = grid.rooms.first(where: { $0.id == roomID }), + let avatar = room.avatars.first(where: { $0.user.id == userID }), + avatar.membershipID == membershipID, + revision >= avatar.microphoneRevision + else { return } + applyAvatarMicrophoneState( + spaceID: spaceID, + roomID: roomID, + userID: userID, + enabled: avatar.ownedByCurrentSession ? mediaCoordinator.isMicrophoneEnabled : enabled, + revision: revision + ) + } + + private func handleConnectionState(_ state: RealtimeConnectionState) async { + let previousState = lastRealtimeConnectionState + lastRealtimeConnectionState = state + switch state { + case .connecting: + // Inline realtime and LiveKit have independent transports. Keep media + // alive while the app server reconnects; LiveKit handles its own recovery. + break + case .connected, .updating: + if previousState == nil || previousState == .connecting { + selfReadEpoch &+= 1 + networkRefreshRevision &+= 1 + avatarStateSync.retryLatestFailure() + mediaCoordinator.networkBecameAvailable() + await loadHome() + await retryPendingConnectionLoads() + await refreshOwnedPresence() + } + } + } + + private var realtimeReady: Bool { + switch lastRealtimeConnectionState { + case .connected, .updating: + true + case .connecting, nil: + false + } + } + + private func handleNetworkPath(_ snapshot: GridNetworkPathSnapshot) async { + let previousSnapshot = lastNetworkPathSnapshot + lastNetworkPathSnapshot = snapshot + networkAvailable = snapshot.available + log.debug( + "GRID_TRACE phase=network_path_changed available=\(snapshot.available) interface=\(snapshot.interfaceDescription) constrained=\(snapshot.constrained) expensive=\(snapshot.expensive)" + ) + if previousSnapshot == nil { + return + } + if snapshot.available { + networkRefreshRevision &+= 1 + avatarStateSync.retryLatestFailure() + mediaCoordinator.networkBecameAvailable() + await loadHome() + await retryPendingConnectionLoads() + await refreshOwnedPresence() + } + } + + private func retryPendingConnectionLoads() async { + let deferredSpaceIDs = pendingConnectionSpaceIDs.sorted() + pendingConnectionSpaceIDs.removeAll() + for spaceID in deferredSpaceIDs { + await load(spaceID: spaceID) + } + } + + public func applicationDidWake() async { + avatarStateSync.retryLatestFailure() + mediaCoordinator.applicationDidWake() + await loadHome() + await refreshOwnedPresence() + } + + private func reconcileAvatarStateOwnership() { + guard let admission = localAdmission else { + avatarStateSync.setOwnedRoom(nil) + return + } + avatarStateSync.setOwnedRoom( + admission.roomID, + membershipID: admission.membershipID, + accountToken: admission.accountToken + ) + } + + private func currentMediaTarget() -> GridMediaTarget? { + guard let admission = localAdmission, + (try? auth.validateAccountMutation(admission.accountToken)) != nil, + acceptedOwnedRoom?.spaceID == admission.spaceID, + acceptedOwnedRoom?.roomID == admission.roomID, + acceptedOwnedRoom?.membershipID == admission.membershipID, + let grid = grids[admission.spaceID], grid.enabled, + let room = grid.rooms.first(where: { $0.id == admission.roomID }), room.hasConnection, + room.avatars.contains(where: { $0.ownedByCurrentSession && $0.membershipID == admission.membershipID }) + else { return nil } + if callTransferEnabled { + guard let call = currentCall, call.ownedByCurrentSession, + call.callID == admission.callID, call.membershipID == admission.membershipID + else { return nil } + } + return GridMediaTarget( + callID: admission.callID, + membershipID: admission.membershipID, + spaceID: admission.spaceID, + roomID: admission.roomID, + generation: room.connection.generation + ) + } + + private func isOwnedUser(_ userID: Int64) -> Bool { + grids.values.lazy + .flatMap(\.rooms) + .flatMap(\.avatars) + .contains { $0.ownedByCurrentSession && $0.user.id == userID } + } + + private static func liveKitIdentityPrefix(userID: Int64) -> String { + "inline-grid-user-\(userID)" + } + + private static func identity(_ identity: String, matchesUserID userID: Int64) -> Bool { + let prefix = liveKitIdentityPrefix(userID: userID) + return identity == prefix || identity.hasPrefix("\(prefix)-") + } + + private func participantIdentities(for avatar: GridAvatar) -> Set { + if !avatar.membershipID.isEmpty { + return [ + "\(Self.liveKitIdentityPrefix(userID: avatar.user.id))-\(avatar.membershipID)", + ] + } + return Set( + media.connectedParticipantIdentities.filter { + Self.identity($0, matchesUserID: avatar.user.id) + } + ) + } + + private func screenShareParticipantIdentity(for avatar: GridAvatar) -> String? { + let identities = participantIdentities(for: avatar) + if let identity = identities.sorted().first(where: { + media.participantScreenShareIntents[$0] == true + }) { + return identity + } + if let identity = screenShares(avatar: avatar).first?.participantIdentity { + return identity + } + return identities.sorted().first + } + + private func openScreenShare( + user: InlineProtocol.User, + participantIdentity: String + ) { + if let share = screenShares(participantIdentity: participantIdentity).first { + platformEffects.openScreenShare?(user, share, self) + return + } + guard isScreenShareIntended(participantIdentity: participantIdentity) else { return } + + pendingScreenShareOpenTasks.removeValue(forKey: participantIdentity)?.task.cancel() + let requestID = UUID() + let task = Task { [weak self] in + for _ in 0 ..< 80 { + guard !Task.isCancelled, let self else { return } + if let share = screenShares(participantIdentity: participantIdentity).first { + finishPendingScreenShareOpen( + requestID: requestID, + participantIdentity: participantIdentity + ) + platformEffects.openScreenShare?(user, share, self) + return + } + guard isScreenShareIntended(participantIdentity: participantIdentity) else { + finishPendingScreenShareOpen( + requestID: requestID, + participantIdentity: participantIdentity + ) + return + } + try? await Task.sleep(for: .milliseconds(100)) + } + guard !Task.isCancelled, let self else { return } + finishPendingScreenShareOpen( + requestID: requestID, + participantIdentity: participantIdentity + ) + if isScreenShareIntended(participantIdentity: participantIdentity) { + platformEffects.showError?("The shared screen hasn’t arrived yet") + } + } + pendingScreenShareOpenTasks[participantIdentity] = GridPendingScreenShareOpen( + id: requestID, + task: task + ) + } + + private func finishPendingScreenShareOpen( + requestID: UUID, + participantIdentity: String + ) { + guard pendingScreenShareOpenTasks[participantIdentity]?.id == requestID else { return } + pendingScreenShareOpenTasks[participantIdentity] = nil + } + + public func setInputSelection(_ selection: AudioInputSelection) { + mediaCoordinator.setInput(selection) + } + + public func setOutputSelection(_ selection: AudioOutputSelection) { + mediaCoordinator.setOutput(selection) + } + + public func setOutputVolume(_ volume: Float) { + mediaCoordinator.setOutputVolume(volume) + } + + public func refreshInputDevices() { + mediaCoordinator.refreshInputDevices() + } + + public func refreshOutputDevices() { + mediaCoordinator.refreshOutputDevices() + } + + public func retryAudio() { + mediaCoordinator.retryAudio() + } + + private static func elapsedMilliseconds(since date: Date) -> Int { + Int(Date().timeIntervalSince(date) * 1_000) + } + + private static func credentialRetryDelay(attempt: Int) -> Int { + switch attempt { + case 1: 1 + case 2: 2 + case 3: 4 + case 4: 8 + case 5: 15 + default: 30 + } + } + + private func operationRoomID(_ operation: GridMembershipOperation) -> Int64? { + switch operation.kind { + case .create: nil + case let .join(roomID), let .leave(roomID), let .move(_, roomID): roomID + } + } + + private func applyPendingMembershipIntent() { + guard let mutation = pendingMembershipMutations[membershipMutationRevision], + grids[mutation.spaceID] != nil + else { return } + + let change: GridOptimisticState.MembershipChange? = switch mutation.intent { + case let .join(fallbackAvatar, joinedAt): + GridOptimisticState.joining( + roomID: mutation.roomID, + in: grids, + avatar: fallbackAvatar, + microphoneEnabled: mediaCoordinator.isMicrophoneEnabled, + joinedAt: joinedAt + ) + case .leave: + GridOptimisticState.leaving(spaceID: mutation.spaceID, in: grids) + } + guard let change else { return } + for (spaceID, grid) in change.nextGrids { + grids[spaceID] = grid + } + } + + private func updateMembershipRollbackBaseline(with snapshots: [InlineProtocol.Grid]) { + let confirmedSnapshots = snapshots.filter { snapshot in + let previousRevision = lastAcceptedSnapshotRevisions[snapshot.spaceID] + if let previousRevision { + return snapshot.revision >= previousRevision + } + return true + } + membershipRollbackBaseline.mergeConfirmed(confirmedSnapshots) + } + + private func beginSelfRead() -> GridSelfReadFence? { + guard let token = try? auth.beginAccountMutation() else { return nil } + selfReadSequence &+= 1 + return GridSelfReadFence(epoch: selfReadEpoch, sequence: selfReadSequence, accountToken: token) + } + + private func isCurrent(_ read: GridSelfReadFence) -> Bool { + read.epoch == selfReadEpoch + && (try? auth.validateAccountMutation(read.accountToken)) != nil + } + + private func applySelfRead(_ call: GridCurrentCall?, capability: Bool, fence: GridSelfReadFence) { + guard isCurrent(fence), !membershipMutationInFlight, + fence.sequence >= lastAcceptedSelfReadSequence else { return } + lastAcceptedSelfReadSequence = fence.sequence + callTransferEnabled = capability + currentCall = call + #if os(iOS) + if !capability { + clearLocalAdmission() + } + #endif + reconcileLocalAdmission() + reconcileAvatarStateOwnership() + reconcileMediaDemand() + } + + private func admission( + from call: GridCurrentCall?, + operation: GridMembershipOperation, + grids snapshots: [InlineProtocol.Grid] + ) -> GridLocalAdmission? { + if let call, GridCallClaimAdmission.accepts( + committedCall: call, newestGrid: grids[call.spaceID], acceptedAuthority: acceptedOwnedRoom + ) { + return GridLocalAdmission( + callID: call.callID, membershipID: call.membershipID, + spaceID: call.spaceID, roomID: call.roomID, accountToken: operation.accountToken + ) + } + #if os(macOS) + // Legacy OFF mode remains usable by explicit Create/Join only. Browsing + // never creates an admission even if its auth session is still the owner. + if !callTransferEnabled { + for grid in snapshots where grid.hasCurrentRoomID { + if let room = grid.rooms.first(where: { $0.id == grid.currentRoomID }), + let avatar = room.avatars.first(where: \.ownedByCurrentSession), + !avatar.membershipID.isEmpty, + acceptedOwnedRoom?.spaceID == grid.spaceID, + acceptedOwnedRoom?.roomID == room.id, + acceptedOwnedRoom?.membershipID == avatar.membershipID, + self.grids[grid.spaceID]?.rooms.first(where: { $0.id == room.id })?.avatars.contains(where: { + $0.ownedByCurrentSession && $0.membershipID == avatar.membershipID + }) == true + { + return GridLocalAdmission( + callID: "", membershipID: avatar.membershipID, + spaceID: grid.spaceID, roomID: room.id, accountToken: operation.accountToken + ) + } + } + } + #endif + return nil + } + + private func reconcileLocalAdmission() { + guard let admission = localAdmission else { return } + let avatarMatches = grids[admission.spaceID]?.rooms.first(where: { $0.id == admission.roomID })?.avatars + .contains(where: { + $0.ownedByCurrentSession && $0.membershipID == admission.membershipID + }) == true + let callMatches = !callTransferEnabled || (currentCall?.ownedByCurrentSession == true + && currentCall?.callID == admission.callID && currentCall?.membershipID == admission.membershipID) + let authorityMatches = acceptedOwnedRoom?.spaceID == admission.spaceID + && acceptedOwnedRoom?.roomID == admission.roomID + && acceptedOwnedRoom?.membershipID == admission.membershipID + if !avatarMatches || !callMatches || !authorityMatches + || (try? auth.validateAccountMutation(admission.accountToken)) == nil + { + clearLocalAdmission() + } + } + + private func clearLocalAdmission() { + publishMediaRetention(false) + localAdmission = nil + pendingCredentialTarget = nil + resetCredentialRetry() + avatarStateSync.setOwnedRoom(nil) + mediaCoordinator.clearCredentials() + mediaCoordinator.setTarget(nil) + mediaInteractionStartedAt = nil + resetScreenSharePresentationEdges() + } + + private func publishMediaRetention(_ eligible: Bool) { + guard eligible != lastRetainedMediaEligible, + let accountToken = localAdmission?.accountToken ?? (try? auth.beginAccountMutation()) + else { return } + lastRetainedMediaEligible = eligible + pendingRetention = (eligible, accountToken) + guard retentionTask == nil else { return } + retentionTask = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled, let next = pendingRetention { + pendingRetention = nil + await realtime.setGridMediaRetention(eligible: next.eligible, accountToken: next.accountToken) + } + retentionTask = nil + } + } +} + +private struct OptimisticRoomMutationKey: Hashable { + let roomID: Int64 + let field: GridOptimisticState.RoomField +} + +private struct GridAloneAutoMuteTarget: Equatable { + let spaceID: Int64 + let roomID: Int64 + let userID: Int64 +} + +private struct GridScreenShareAloneTarget: Equatable { + let mediaTarget: GridMediaTarget + let episodeID: UInt64 + let publicationIDs: Set +} + +private struct GridScreenShareNotice { + let target: GridMediaTarget + let user: InlineProtocol.User + let participantIdentity: String +} + +private struct GridPendingScreenShareOpen { + let id: UUID + let task: Task +} + +private struct OptimisticRoomMutation { + let key: OptimisticRoomMutationKey + let revision: Int + let accessRevision: Int + let change: GridOptimisticState.RoomChange +} + +private struct OptimisticMembershipMutation { + enum Intent { + case join(fallbackAvatar: GridAvatar?, joinedAt: Int64) + case leave + } + + let roomID: Int64 + let spaceID: Int64 + let revision: Int + let previousGrids: [Int64: InlineProtocol.Grid] + let intent: Intent +} + +private struct GridLocalAdmission { + let callID: String + let membershipID: String + let spaceID: Int64 + let roomID: Int64 + let accountToken: AuthAccountMutationToken +} + +private struct GridSelfReadFence { + let epoch: Int + let sequence: Int + let accountToken: AuthAccountMutationToken +} diff --git a/apple/InlineKit/Sources/InlineKit/Experimental/ExperimentalFeatureFlags.swift b/apple/InlineKit/Sources/InlineKit/Experimental/ExperimentalFeatureFlags.swift index 3bc82c703..4dfaeecc7 100644 --- a/apple/InlineKit/Sources/InlineKit/Experimental/ExperimentalFeatureFlags.swift +++ b/apple/InlineKit/Sources/InlineKit/Experimental/ExperimentalFeatureFlags.swift @@ -6,6 +6,13 @@ public enum ExperimentalFeatureFlags { public static let agentActivityKey = AgentActivityFeature.preferenceKey public static var agentActivityEnabled: Bool { AgentActivityFeature.isEnabled } + public static let gridIOSKey = "experimental.gridIOS" + + /// Off until physical audio, background and device-transfer qualification is complete. + public static var gridIOSEnabled: Bool { + UserDefaults.standard.bool(forKey: gridIOSKey) + } + public static let newThreadAgentPickerKey = "experimental.newThreadAgentPicker" /// macOS-only, opt-in persistent agent selection for the new-thread composer. diff --git a/apple/InlineKit/Sources/InlineKit/Grid/GridTranscriptionControl.swift b/apple/InlineKit/Sources/InlineKit/Grid/GridTranscriptionControl.swift new file mode 100644 index 000000000..99fdcf590 --- /dev/null +++ b/apple/InlineKit/Sources/InlineKit/Grid/GridTranscriptionControl.swift @@ -0,0 +1,101 @@ +import Foundation +import InlineProtocol + +/// A single server snapshot fences a room action. A retry keeps this request ID +/// and never turns a stale stop into a stop for a replacement run. +public struct GridTranscriptionRequest: Codable, Sendable, Equatable { + public enum Destination: Codable, Sendable, Equatable { + case last + case new + case existing(Int64) + } + + public let roomID: Int64 + public let membershipID: String + public let generation: Int32 + public let runID: String? + public let revision: Int32 + public let enabled: Bool + public let destination: Destination + public let requestID: String + + public init?(room: GridRoom, enabled: Bool, destination: Destination = .last, requestID: String = UUID().uuidString) { + guard room.hasConnection, + let avatar = room.avatars.first(where: \.ownedByCurrentSession), + !avatar.membershipID.isEmpty, + room.connection.generation > 0, + enabled ? room.canStartTranscription : room.canStopTranscription + else { return nil } + if case let .existing(chatID) = destination, chatID <= 0 { + return nil + } + + roomID = room.id + membershipID = avatar.membershipID + generation = room.connection.generation + runID = room.hasTranscription && !room.transcription.runID.isEmpty ? room.transcription.runID : nil + revision = room.hasTranscription ? room.transcription.revision : 0 + self.enabled = enabled + self.destination = destination + self.requestID = requestID + } + + public var input: SetGridTranscriptionInput { + .with { + $0.roomID = roomID + $0.expectedMembershipID = membershipID + $0.expectedGeneration = generation + if let runID { + $0.expectedRunID = runID + } + $0.expectedRevision = revision + $0.enabled = enabled + switch destination { + case .last: $0.destination = .gridTranscriptLast + case .new: $0.destination = .gridTranscriptNew + case let .existing(chatID): + $0.destination = .gridTranscriptExisting + $0.transcriptChatID = chatID + } + $0.requestID = requestID + } + } +} + +public extension GridRoom { + var transcriptChatID: Int64? { + guard hasTranscription, transcription.hasTranscriptChatID, transcription.transcriptChatID > 0 else { return nil } + return transcription.transcriptChatID + } + + var transcriptionIsRunning: Bool { + hasTranscription && [.gridTranscriptionStarting, .gridTranscriptionActive, .gridTranscriptionStopping] + .contains(transcription.state) + } + + var transcriptionCanChange: Bool { + canStartTranscription || canStopTranscription + } + + var canStartTranscription: Bool { + guard transcriptionAvailable else { return false } + guard hasTranscription else { return true } + // Unknown future states are read-only until a client understands them. + return [.gridTranscriptionStopped, .gridTranscriptionInterrupted].contains(transcription.state) + } + + var canStopTranscription: Bool { + hasTranscription && [.gridTranscriptionStarting, .gridTranscriptionActive].contains(transcription.state) + } + + var transcriptionControlTitle: String { + guard hasTranscription else { return "Start transcription" } + switch transcription.state { + case .gridTranscriptionStarting: return "Cancel transcription" + case .gridTranscriptionActive: return "Stop transcription" + case .gridTranscriptionStopping: return "Stopping…" + case .gridTranscriptionStopped, .gridTranscriptionInterrupted: return "Start transcription" + default: return "Transcription unavailable" + } + } +} diff --git a/apple/InlineKit/Sources/InlineKit/MacNotifications/MacNotifications.swift b/apple/InlineKit/Sources/InlineKit/MacNotifications/MacNotifications.swift index 7984f60aa..5c3c88ee2 100644 --- a/apple/InlineKit/Sources/InlineKit/MacNotifications/MacNotifications.swift +++ b/apple/InlineKit/Sources/InlineKit/MacNotifications/MacNotifications.swift @@ -239,6 +239,8 @@ public actor MacNotifications { ) -> Bool { guard source == .newMessage else { return false } guard deliveryState.isNewlyInserted, deliveryState.isUnread else { return false } + guard !message.hasCountsAsUnread || message.countsAsUnread else { return false } + if case .gridTranscript = message.serviceMessage.event { return false } guard message.sendMode != .modeSilent else { return false } guard isFreshMessage(message, now: now) else { return false } diff --git a/apple/InlineKit/Sources/InlineKit/Models/Chat.swift b/apple/InlineKit/Sources/InlineKit/Models/Chat.swift index 01f846a5a..948522c1f 100644 --- a/apple/InlineKit/Sources/InlineKit/Models/Chat.swift +++ b/apple/InlineKit/Sources/InlineKit/Models/Chat.swift @@ -497,7 +497,7 @@ public extension Chat { static func updateLastMsgIds(_ db: Database, messages: [Message]) throws { var newestByChatId: [Int64: Message] = [:] - for message in messages { + for message in messages where !message.isGridTranscript { if let current = newestByChatId[message.chatId], !shouldAdvanceLastMessage( currentLastMsgId: current.messageId, diff --git a/apple/InlineKit/Sources/InlineKit/Models/Message.swift b/apple/InlineKit/Sources/InlineKit/Models/Message.swift index 8e2ea4b10..c3ae0d4b5 100644 --- a/apple/InlineKit/Sources/InlineKit/Models/Message.swift +++ b/apple/InlineKit/Sources/InlineKit/Models/Message.swift @@ -630,7 +630,7 @@ public struct Message: FetchableRecord, Identifiable, Codable, Hashable, Persist } private static func hasContentPayload(_ payload: Client_MessageContentPayload) -> Bool { - payload.hasVoice || payload.hasActions || payload.hasReplies || payload.hasServiceMessage || payload.hasSubthread + payload.hasVoice || payload.hasActions || payload.hasReplies || payload.hasServiceMessage || payload.hasSubthread || payload.hasCountsAsUnread } private static func contentPayload(from voice: InlineProtocol.Voice) -> Client_MessageContentPayload { @@ -661,6 +661,7 @@ public struct Message: FetchableRecord, Identifiable, Codable, Hashable, Persist if message.hasSubthread { payload.subthread = message.subthread } + if message.hasCountsAsUnread { payload.countsAsUnread = message.countsAsUnread } return hasContentPayload(payload) ? payload : nil } @@ -771,6 +772,12 @@ public struct Message: FetchableRecord, Identifiable, Codable, Hashable, Persist } } + if incoming.hasCountsAsUnread { + merged.countsAsUnread = incoming.countsAsUnread + } else if existing.hasCountsAsUnread { + merged.countsAsUnread = existing.countsAsUnread + } + return hasContentPayload(merged) ? merged : nil case let (incoming?, nil): @@ -828,6 +835,7 @@ public struct MessageServiceDisplaySegment: Equatable, Sendable { } public enum Tone: Equatable, Sendable { + case primary case secondary case tertiary } @@ -853,8 +861,26 @@ public extension Message { serviceMessage?.event != nil } + var isGridTranscript: Bool { + if case .gridTranscript = serviceMessage?.event { + return true + } + return false + } + + /// Missing fields preserve ordinary-message behavior. Typed transcripts are + /// also quiet if a transitional server omitted the explicit flag. + var countsAsUnread: Bool { + if isGridTranscript { + return false + } + guard let contentPayload, contentPayload.hasCountsAsUnread else { return true } + return contentPayload.countsAsUnread + } + var serviceFallbackText: String? { guard let serviceMessage else { return nil } + if isGridTranscript { return text } return serviceMessage.fallbackText } @@ -900,11 +926,48 @@ public extension Message { actorSegment, MessageServiceDisplaySegment(text: " pinned a message"), ] + case let .gridTranscript(transcript): + let body = text ?? "" + if transcript.kind == .gridTranscriptTurn { + let prefix = "Transcript · " + let speech = body.hasPrefix(prefix) ? String(body.dropFirst(prefix.count)) : body + return [ + MessageServiceDisplaySegment(text: prefix, tone: .tertiary), + MessageServiceDisplaySegment(text: speech, tone: .primary), + ] + } + // Status and continuation links retain the server's self-contained + // text. The sender is an attribution owner, not the speaker. + return transcriptStatusSegments(text: body) case nil: return nil } } + private func transcriptStatusSegments(text: String) -> [MessageServiceDisplaySegment] { + let source = text as NSString + let threadEntities = (entities?.entities ?? []).filter { + $0.type == .thread && $0.thread.chatID > 0 && $0.offset >= 0 && $0.length > 0 + }.sorted { $0.offset < $1.offset } + var cursor = 0 + var segments: [MessageServiceDisplaySegment] = [] + for entity in threadEntities { + guard entity.offset <= Int64(source.length), + entity.length <= Int64(source.length) - entity.offset else { continue } + let range = NSRange(location: Int(entity.offset), length: Int(entity.length)) + guard range.location >= cursor else { continue } + if range.location > cursor { + segments.append(.init(text: source.substring(with: NSRange(location: cursor, length: range.location - cursor)))) + } + segments.append(.init(text: source.substring(with: range), link: .thread(entity.thread.chatID))) + cursor = NSMaxRange(range) + } + if cursor < source.length { + segments.append(.init(text: source.substring(from: cursor))) + } + return segments.isEmpty ? [.init(text: text)] : segments + } + /// Returns a string representation of the message, including emojis for different media types. var stringRepresentationWithEmoji: String { if let serviceFallbackText { @@ -1028,6 +1091,9 @@ public extension MessageService { return "Linked from another thread" case .pinnedMessage: return "Pinned a message" + case .gridTranscript: + // Its meaningful fallback is the encrypted Message.message text. + return nil case nil: return nil } @@ -1822,16 +1888,18 @@ public extension Message { .filter(chunk.contains(Column("messageId"))).deleteAll(db) } if promotesLast { - let confirmed = try Message + let confirmedCursor = try Message .filter(Column("chatId") == chatId && Column("messageId") > 0) .filter(Column("status") == nil || Column("status") == MessageSendingStatus.sent) .order(Column("messageId").desc) - .fetchOne(db) - let optimistic = try Message + .fetchCursor(db) + let confirmed = try firstActivityMessage(in: confirmedCursor) + let optimisticCursor = try Message .filter(Column("chatId") == chatId) .filter(Column("messageId") <= 0 || [MessageSendingStatus.sending, .failed].contains(Column("status"))) .order(Column("date").desc, Column("messageId").desc) - .fetchOne(db) + .fetchCursor(db) + let optimistic = try firstActivityMessage(in: optimisticCursor) var survivor = confirmed if let optimistic, Chat.shouldAdvanceLastMessage( currentLastMsgId: confirmed?.messageId, @@ -1846,4 +1914,14 @@ public extension Message { // A delete of an uncached coordinate still fences every dispatched page. try HistoryPageAdmissionToken.advanceRevision(db, chatId: chatId) } + + /// Generated transcript rows are history, never sidebar activity. The + /// existing protobuf payload requires decoding; a cursor avoids loading a + /// whole transcript into memory when an ordinary chat-tail message is deleted. + private static func firstActivityMessage(in cursor: RecordCursor) throws -> Message? { + while let message = try cursor.next() { + if !message.isGridTranscript { return message } + } + return nil + } } diff --git a/apple/InlineKit/Sources/InlineKit/ProtocolHelpers/ProtocolMessageContentPayload.swift b/apple/InlineKit/Sources/InlineKit/ProtocolHelpers/ProtocolMessageContentPayload.swift index 8abe55ecf..d6800269e 100644 --- a/apple/InlineKit/Sources/InlineKit/ProtocolHelpers/ProtocolMessageContentPayload.swift +++ b/apple/InlineKit/Sources/InlineKit/ProtocolHelpers/ProtocolMessageContentPayload.swift @@ -10,6 +10,7 @@ extension Client_MessageContentPayload: Codable { case replies case serviceMessage case subthread + case countsAsUnread } public init(from decoder: Decoder) throws { @@ -19,6 +20,7 @@ extension Client_MessageContentPayload: Codable { let replies = try container.decodeIfPresent(MessageReplies.self, forKey: .replies) let serviceMessage = try container.decodeIfPresent(MessageService.self, forKey: .serviceMessage) let subthread = try container.decodeIfPresent(MessageSubthread.self, forKey: .subthread) + let countsAsUnread = try container.decodeIfPresent(Bool.self, forKey: .countsAsUnread) self.init() if let voice { @@ -36,6 +38,9 @@ extension Client_MessageContentPayload: Codable { if let subthread { self.subthread = subthread } + if let countsAsUnread { + self.countsAsUnread = countsAsUnread + } } public func encode(to encoder: Encoder) throws { @@ -55,6 +60,9 @@ extension Client_MessageContentPayload: Codable { if hasSubthread { try container.encode(subthread, forKey: .subthread) } + if hasCountsAsUnread { + try container.encode(countsAsUnread, forKey: .countsAsUnread) + } } } @@ -63,11 +71,13 @@ extension MessageService: Codable { case event case threadBacklink case pinnedMessage + case gridTranscript } private enum Event: String, Codable { case threadBacklink case pinnedMessage + case gridTranscript } public init(from decoder: Decoder) throws { @@ -89,6 +99,10 @@ extension MessageService: Codable { if let message = try container.decodeIfPresent(MessageServicePinnedMessage.self, forKey: .pinnedMessage) { pinnedMessage = message } + case .gridTranscript: + if let transcript = try container.decodeIfPresent(MessageServiceGridTranscript.self, forKey: .gridTranscript) { + gridTranscript = transcript + } } } @@ -101,12 +115,41 @@ extension MessageService: Codable { case .pinnedMessage: try container.encode(Event.pinnedMessage, forKey: .event) try container.encode(pinnedMessage, forKey: .pinnedMessage) + case .gridTranscript: + try container.encode(Event.gridTranscript, forKey: .event) + try container.encode(gridTranscript, forKey: .gridTranscript) case nil: break } } } +extension MessageServiceGridTranscript: Codable { + private enum CodingKeys: String, CodingKey { case runID, segmentID, speakerUserID, kind } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init() + runID = try container.decodeIfPresent(String.self, forKey: .runID) ?? "" + segmentID = try container.decodeIfPresent(String.self, forKey: .segmentID) ?? "" + if let speakerUserID = try container.decodeIfPresent(Int64.self, forKey: .speakerUserID) { + self.speakerUserID = speakerUserID + } + let rawKind = try container.decodeIfPresent(Int.self, forKey: .kind) ?? 0 + kind = GridTranscriptMessageKind(rawValue: rawKind) ?? .UNRECOGNIZED(rawKind) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(runID, forKey: .runID) + try container.encode(segmentID, forKey: .segmentID) + if hasSpeakerUserID { + try container.encode(speakerUserID, forKey: .speakerUserID) + } + try container.encode(kind.rawValue, forKey: .kind) + } +} + extension MessageServiceThreadBacklink: Codable { private enum CodingKeys: String, CodingKey { case sourceChatID @@ -200,7 +243,7 @@ extension MessageSubthread: Codable { self.init() chatID = try container.decodeIfPresent(Int64.self, forKey: .chatID) ?? 0 - kind = Kind(rawValue: try container.decodeIfPresent(Int.self, forKey: .kind) ?? 0) ?? .unspecified + kind = try Kind(rawValue: container.decodeIfPresent(Int.self, forKey: .kind) ?? 0) ?? .unspecified if let title = try container.decodeIfPresent(String.self, forKey: .title) { self.title = title } diff --git a/apple/InlineKit/Sources/InlineKit/RealtimeAPI/Updates.swift b/apple/InlineKit/Sources/InlineKit/RealtimeAPI/Updates.swift index 79fba5264..6dccb8aa1 100644 --- a/apple/InlineKit/Sources/InlineKit/RealtimeAPI/Updates.swift +++ b/apple/InlineKit/Sources/InlineKit/RealtimeAPI/Updates.swift @@ -2401,7 +2401,9 @@ extension InlineProtocol.UpdateNewMessage { materializeMissingReferences: materializeMissingReferences ) - try Chat.updateLastMsgId(db, chatId: message.chatID, lastMsgId: msg.messageId, date: msg.date) + if !msg.isGridTranscript { + try Chat.updateLastMsgId(db, chatId: message.chatID, lastMsgId: msg.messageId, date: msg.date) + } // Increase unread count only when this message is newly inserted, not ours, // and newer than the dialog's read cursor. Catch-up applies dialog sidecars @@ -2413,7 +2415,9 @@ extension InlineProtocol.UpdateNewMessage { var didIncrement = false var reason = "not_newer_than_read_max" - if !incrementUnreadCount { + if !msg.countsAsUnread { + reason = "quiet_message" + } else if !incrementUnreadCount { reason = "increment_disabled" } else if hadMessage { reason = "duplicate_message" @@ -2443,6 +2447,7 @@ extension InlineProtocol.UpdateNewMessage { if !suppressNotifications, !hadMessage, msg.out == false, + msg.countsAsUnread, MacNotifications.isFreshMessage(message, now: Date()), let currentUserID = Auth.shared.getCurrentUserId() { @@ -2517,7 +2522,9 @@ extension InlineProtocol.UpdateMessageId { publishChanges: true ) - try Chat.updateLastMsgId(db, chatId: message.chatId, lastMsgId: message.messageId, date: message.date) + if !message.isGridTranscript { + try Chat.updateLastMsgId(db, chatId: message.chatId, lastMsgId: message.messageId, date: message.date) + } } } } diff --git a/apple/InlineKit/Sources/InlineKit/Transactions2/GetChatsTransaction.swift b/apple/InlineKit/Sources/InlineKit/Transactions2/GetChatsTransaction.swift index 7f3dff9aa..5a5e1e341 100644 --- a/apple/InlineKit/Sources/InlineKit/Transactions2/GetChatsTransaction.swift +++ b/apple/InlineKit/Sources/InlineKit/Transactions2/GetChatsTransaction.swift @@ -386,12 +386,19 @@ public struct GetChatsTransaction: Transaction2 { attemptedCount += 1 if coldBootstrapChatIDs.contains(pending.chat.id) { - if let state = try applyColdChatSnapshot( + switch try applyColdChatSnapshot( pending, messages: messagesByChatID[pending.chat.id] ?? [], in: db, failures: &failures ) { - seededStates[pending.bucketKey] = state - continue + case let .seeded(state): + seededStates[pending.bucketKey] = state + continue + case .cursorRejected: + // Cursor admission owns the whole cold snapshot savepoint. + // A metadata retry must not resurrect its rolled-back rows. + continue + case .metadataOnly: + break } // A malformed/missing body must not certify a cursor. Metadata is // still useful, with unknown holes and no last-message pointer. @@ -460,18 +467,24 @@ public struct GetChatsTransaction: Transaction2 { ) } + private enum ColdChatSnapshotResult { + case seeded(BucketState) + case metadataOnly + case cursorRejected + } + private static func applyColdChatSnapshot( _ pending: PendingChat, messages: [InlineProtocol.Message], in db: Database, failures: inout SnapshotFailureAccumulator - ) throws -> BucketState? { + ) throws -> ColdChatSnapshotResult { guard let sequence = pending.bucketSequence, sequence >= 0, let lastMsgId = pending.lastMsgId, 1 ... MessageHistoryHole.positiveMessageIDMax ~= lastMsgId - else { return nil } + else { return .metadataOnly } let lastRows = messages.filter { $0.id == lastMsgId } guard lastRows.count == 1 else { failures.record(.lastMessages) - return nil + return .metadataOnly } do { try HistoryPageReducer.validateMessages( @@ -479,32 +492,38 @@ public struct GetChatsTransaction: Transaction2 { ) } catch { failures.record(.messages) - return nil + return .metadataOnly } var state: BucketState? + var phase = SnapshotImportPhase.chats do { try db.inSavepoint { var chat = pending.chat try chat.saveFull(db) // Catalog users/sidecars must already satisfy the full row. Do not // fabricate references for this one-message cold snapshot. + phase = .messages _ = try Message.save(db, protocolMessage: lastRows[0], authoritativeSnapshot: true) chat.lastMsgId = lastMsgId + phase = .lastMessages try chat.saveFull(db) + phase = .chats try Acknowledgement.save( db, cursors: pending.protocolChat.acknowledgements.cursors, chatId: chat.id, publishChanges: true ) + phase = .chatCursors state = try GRDBSyncStorage.seedSnapshotBucketState(for: pending.bucketKey, seq: sequence, in: db) // The last-message row supplies a preview, never range coverage. return .commit } } catch { guard isRecoverableRecordError(error) || error is HistoryPageAdmissionError else { throw error } - failures.record(.messages) - return nil + failures.record(phase) + return phase == .chatCursors ? .cursorRejected : .metadataOnly } - return state + guard let state else { return .metadataOnly } + return .seeded(state) } private static func applyPristineChatSnapshot( diff --git a/apple/InlineKit/Sources/InlineKit/Transactions2/GridTransactions.swift b/apple/InlineKit/Sources/InlineKit/Transactions2/GridTransactions.swift index 81c309193..b1fdcd1aa 100644 --- a/apple/InlineKit/Sources/InlineKit/Transactions2/GridTransactions.swift +++ b/apple/InlineKit/Sources/InlineKit/Transactions2/GridTransactions.swift @@ -9,10 +9,14 @@ public struct GetSpaceSettingsTransaction: Transaction2 { public struct Context: Sendable, Codable { let spaceID: Int64 } enum CodingKeys: String, CodingKey { case context } - public init(spaceID: Int64) { context = Context(spaceID: spaceID) } + public init(spaceID: Int64) { + context = Context(spaceID: spaceID) + } + public func input(from context: Context) -> RpcCall.OneOf_Input? { .getSpaceSettings(.with { $0.spaceID = context.spaceID }) } + public func apply(_ result: RpcResult.OneOf_Result?) async throws(TransactionExecutionError) { guard case .getSpaceSettings = result else { throw .invalid } } @@ -23,12 +27,21 @@ public struct ToggleSpaceGridTransaction: Transaction2 { public var context: Context public var type: TransactionKindType = .mutation(.init(transient: true)) - public struct Context: Sendable, Codable { let spaceID: Int64; let enabled: Bool } + public struct Context: Sendable, Codable { let spaceID: Int64 + let enabled: Bool + } + enum CodingKeys: String, CodingKey { case context } - public init(spaceID: Int64, enabled: Bool) { context = Context(spaceID: spaceID, enabled: enabled) } + public init(spaceID: Int64, enabled: Bool) { + context = Context(spaceID: spaceID, enabled: enabled) + } + public func input(from context: Context) -> RpcCall.OneOf_Input? { - .toggleSpaceGrid(.with { $0.spaceID = context.spaceID; $0.enabled = context.enabled }) + .toggleSpaceGrid(.with { $0.spaceID = context.spaceID + $0.enabled = context.enabled + }) } + public func apply(_ result: RpcResult.OneOf_Result?) async throws(TransactionExecutionError) { guard case .toggleSpaceGrid = result else { throw .invalid } } @@ -38,14 +51,29 @@ public struct GetGridTransaction: Transaction2 { public var method: InlineProtocol.Method = .getGrid public var context: Context public var type: TransactionKindType = .ephemeral() - public var ephemeralCoalescingKey: String? { "space:\(context.spaceID)" } + public var ephemeralCoalescingKey: String? { + "space:\(context.spaceID):request:\(context.requestID)" + } + + public struct Context: Sendable, Codable { let spaceID: Int64 + let expectedMembershipID: String? + let requestID: UUID + } - public struct Context: Sendable, Codable { let spaceID: Int64 } enum CodingKeys: String, CodingKey { case context } - public init(spaceID: Int64) { context = Context(spaceID: spaceID) } + public init(spaceID: Int64, expectedMembershipID: String? = nil) { + context = Context(spaceID: spaceID, expectedMembershipID: expectedMembershipID, requestID: UUID()) + } + public func input(from context: Context) -> RpcCall.OneOf_Input? { - .getGrid(.with { $0.spaceID = context.spaceID }) + .getGrid(.with { + $0.spaceID = context.spaceID + if let membershipID = context.expectedMembershipID { + $0.expectedMembershipID = membershipID + } + }) } + public func apply(_ result: RpcResult.OneOf_Result?) async throws(TransactionExecutionError) { guard case .getGrid = result else { throw .invalid } } @@ -53,14 +81,19 @@ public struct GetGridTransaction: Transaction2 { public struct GetGridHomeTransaction: Transaction2 { public var method: InlineProtocol.Method = .getGridHome - public var context = Context() + public var context = Context(requestID: UUID()) public var type: TransactionKindType = .ephemeral() - public var ephemeralCoalescingKey: String? { "home" } + public var ephemeralCoalescingKey: String? { + "home:\(context.requestID)" + } - public struct Context: Sendable, Codable {} + public struct Context: Sendable, Codable { let requestID: UUID } enum CodingKeys: String, CodingKey { case context } public init() {} - public func input(from _: Context) -> RpcCall.OneOf_Input? { .getGridHome(.init()) } + public func input(from _: Context) -> RpcCall.OneOf_Input? { + .getGridHome(.init()) + } + public func apply(_ result: RpcResult.OneOf_Result?) async throws(TransactionExecutionError) { guard case .getGridHome = result else { throw .invalid } } @@ -71,17 +104,30 @@ public struct CreateGridRoomTransaction: Transaction2 { public var context: Context public var type: TransactionKindType = .mutation(.init(transient: true)) - public struct Context: Sendable, Codable { let spaceID: Int64; let microphoneEnabled: Bool } + public struct Context: Sendable, Codable { let spaceID: Int64 + let microphoneEnabled: Bool + let expectedMembershipID: String? + } + enum CodingKeys: String, CodingKey { case context } - public init(spaceID: Int64, microphoneEnabled: Bool) { - context = Context(spaceID: spaceID, microphoneEnabled: microphoneEnabled) + public init(spaceID: Int64, microphoneEnabled: Bool, expectedMembershipID: String? = nil) { + context = Context( + spaceID: spaceID, + microphoneEnabled: microphoneEnabled, + expectedMembershipID: expectedMembershipID + ) } + public func input(from context: Context) -> RpcCall.OneOf_Input? { .createGridRoom(.with { $0.spaceID = context.spaceID $0.microphoneEnabled = context.microphoneEnabled + if let membershipID = context.expectedMembershipID { + $0.expectedMembershipID = membershipID + } }) } + public func apply(_ result: RpcResult.OneOf_Result?) async throws(TransactionExecutionError) { guard case .createGridRoom = result else { throw .invalid } } @@ -92,17 +138,26 @@ public struct JoinGridRoomTransaction: Transaction2 { public var context: Context public var type: TransactionKindType = .mutation(.init(transient: true)) - public struct Context: Sendable, Codable { let roomID: Int64; let microphoneEnabled: Bool } + public struct Context: Sendable, Codable { let roomID: Int64 + let microphoneEnabled: Bool + let expectedMembershipID: String? + } + enum CodingKeys: String, CodingKey { case context } - public init(roomID: Int64, microphoneEnabled: Bool) { - context = Context(roomID: roomID, microphoneEnabled: microphoneEnabled) + public init(roomID: Int64, microphoneEnabled: Bool, expectedMembershipID: String? = nil) { + context = Context(roomID: roomID, microphoneEnabled: microphoneEnabled, expectedMembershipID: expectedMembershipID) } + public func input(from context: Context) -> RpcCall.OneOf_Input? { .joinGridRoom(.with { $0.roomID = context.roomID $0.microphoneEnabled = context.microphoneEnabled + if let membershipID = context.expectedMembershipID { + $0.expectedMembershipID = membershipID + } }) } + public func apply(_ result: RpcResult.OneOf_Result?) async throws(TransactionExecutionError) { guard case .joinGridRoom = result else { throw .invalid } } @@ -113,12 +168,24 @@ public struct LeaveGridRoomTransaction: Transaction2 { public var context: Context public var type: TransactionKindType = .mutation(.init(transient: true)) - public struct Context: Sendable, Codable { let roomID: Int64 } + public struct Context: Sendable, Codable { let roomID: Int64 + let expectedMembershipID: String? + } + enum CodingKeys: String, CodingKey { case context } - public init(roomID: Int64) { context = Context(roomID: roomID) } + public init(roomID: Int64, expectedMembershipID: String? = nil) { + context = Context(roomID: roomID, expectedMembershipID: expectedMembershipID) + } + public func input(from context: Context) -> RpcCall.OneOf_Input? { - .leaveGridRoom(.with { $0.expectedRoomID = context.roomID }) + .leaveGridRoom(.with { + $0.expectedRoomID = context.roomID + if let membershipID = context.expectedMembershipID { + $0.expectedMembershipID = membershipID + } + }) } + public func apply(_ result: RpcResult.OneOf_Result?) async throws(TransactionExecutionError) { guard case .leaveGridRoom = result else { throw .invalid } } @@ -129,12 +196,21 @@ public struct SetGridRoomLockedTransaction: Transaction2 { public var context: Context public var type: TransactionKindType = .mutation(.init(transient: true)) - public struct Context: Sendable, Codable { let roomID: Int64; let locked: Bool } + public struct Context: Sendable, Codable { let roomID: Int64 + let locked: Bool + } + enum CodingKeys: String, CodingKey { case context } - public init(roomID: Int64, locked: Bool) { context = Context(roomID: roomID, locked: locked) } + public init(roomID: Int64, locked: Bool) { + context = Context(roomID: roomID, locked: locked) + } + public func input(from context: Context) -> RpcCall.OneOf_Input? { - .setGridRoomLocked(.with { $0.roomID = context.roomID; $0.locked = context.locked }) + .setGridRoomLocked(.with { $0.roomID = context.roomID + $0.locked = context.locked + }) } + public func apply(_ result: RpcResult.OneOf_Result?) async throws(TransactionExecutionError) { guard case .setGridRoomLocked = result else { throw .invalid } } @@ -145,12 +221,21 @@ public struct SetGridRoomTitleTransaction: Transaction2 { public var context: Context public var type: TransactionKindType = .mutation(.init(transient: true)) - public struct Context: Sendable, Codable { let roomID: Int64; let title: String } + public struct Context: Sendable, Codable { let roomID: Int64 + let title: String + } + enum CodingKeys: String, CodingKey { case context } - public init(roomID: Int64, title: String) { context = Context(roomID: roomID, title: title) } + public init(roomID: Int64, title: String) { + context = Context(roomID: roomID, title: title) + } + public func input(from context: Context) -> RpcCall.OneOf_Input? { - .setGridRoomTitle(.with { $0.roomID = context.roomID; $0.title = context.title }) + .setGridRoomTitle(.with { $0.roomID = context.roomID + $0.title = context.title + }) } + public func apply(_ result: RpcResult.OneOf_Result?) async throws(TransactionExecutionError) { guard case .setGridRoomTitle = result else { throw .invalid } } @@ -163,10 +248,14 @@ public struct DeleteGridRoomTransaction: Transaction2 { public struct Context: Sendable, Codable { let roomID: Int64 } enum CodingKeys: String, CodingKey { case context } - public init(roomID: Int64) { context = Context(roomID: roomID) } + public init(roomID: Int64) { + context = Context(roomID: roomID) + } + public func input(from context: Context) -> RpcCall.OneOf_Input? { .deleteGridRoom(.with { $0.roomID = context.roomID }) } + public func apply(_ result: RpcResult.OneOf_Result?) async throws(TransactionExecutionError) { guard case .deleteGridRoom = result else { throw .invalid } } @@ -176,16 +265,30 @@ public struct PrepareGridConnectionTransaction: Transaction2 { public var method: InlineProtocol.Method = .prepareGridConnection public var context: Context public var type: TransactionKindType = .ephemeral() - public var ephemeralCoalescingKey: String? { "room:\(context.roomID)" } + public var ephemeralCoalescingKey: String? { + "room:\(context.roomID):generation:\(context.generation):membership:\(context.expectedMembershipID ?? "legacy")" + } + + public struct Context: Sendable, Codable { let roomID: Int64 + let generation: Int32 + let expectedMembershipID: String? + } - public struct Context: Sendable, Codable { let roomID: Int64; let generation: Int32 } enum CodingKeys: String, CodingKey { case context } - public init(roomID: Int64, generation: Int32) { - context = Context(roomID: roomID, generation: generation) + public init(roomID: Int64, generation: Int32, expectedMembershipID: String? = nil) { + context = Context(roomID: roomID, generation: generation, expectedMembershipID: expectedMembershipID) } + public func input(from context: Context) -> RpcCall.OneOf_Input? { - .prepareGridConnection(.with { $0.roomID = context.roomID; $0.generation = context.generation }) + .prepareGridConnection(.with { + $0.roomID = context.roomID + $0.generation = context.generation + if let membershipID = context.expectedMembershipID { + $0.expectedMembershipID = membershipID + } + }) } + public func apply(_ result: RpcResult.OneOf_Result?) async throws(TransactionExecutionError) { guard case .prepareGridConnection = result else { throw .invalid } } @@ -196,72 +299,236 @@ public struct SetGridAvatarMicTransaction: Transaction2 { public var context: Context public var type: TransactionKindType = .mutation(.init(transient: true)) - public struct Context: Sendable, Codable { let roomID: Int64; let enabled: Bool } + public struct Context: Sendable, Codable { let roomID: Int64 + let enabled: Bool + let expectedMembershipID: String? + } + enum CodingKeys: String, CodingKey { case context } - public init(roomID: Int64, enabled: Bool) { context = Context(roomID: roomID, enabled: enabled) } + public init(roomID: Int64, enabled: Bool, expectedMembershipID: String? = nil) { + context = Context(roomID: roomID, enabled: enabled, expectedMembershipID: expectedMembershipID) + } + public func input(from context: Context) -> RpcCall.OneOf_Input? { .setGridAvatarMicrophoneEnabled(.with { $0.expectedRoomID = context.roomID $0.enabled = context.enabled + if let membershipID = context.expectedMembershipID { + $0.expectedMembershipID = membershipID + } }) } + public func apply(_ result: RpcResult.OneOf_Result?) async throws(TransactionExecutionError) { guard case .setGridAvatarMicrophoneEnabled = result else { throw .invalid } } } public extension Transaction2 where Self == GetSpaceSettingsTransaction { - static func getSpaceSettings(spaceID: Int64) -> Self { .init(spaceID: spaceID) } + static func getSpaceSettings(spaceID: Int64) -> Self { + .init(spaceID: spaceID) + } } public extension Transaction2 where Self == ToggleSpaceGridTransaction { - static func toggleSpaceGrid(spaceID: Int64, enabled: Bool) -> Self { .init(spaceID: spaceID, enabled: enabled) } + static func toggleSpaceGrid(spaceID: Int64, enabled: Bool) -> Self { + .init(spaceID: spaceID, enabled: enabled) + } } public extension Transaction2 where Self == GetGridTransaction { - static func getGrid(spaceID: Int64) -> Self { .init(spaceID: spaceID) } + static func getGrid(spaceID: Int64, expectedMembershipID: String? = nil) -> Self { + .init(spaceID: spaceID, expectedMembershipID: expectedMembershipID) + } } public extension Transaction2 where Self == GetGridHomeTransaction { - static func getGridHome() -> Self { .init() } + static func getGridHome() -> Self { + .init() + } } public extension Transaction2 where Self == CreateGridRoomTransaction { - static func createGridRoom(spaceID: Int64, microphoneEnabled: Bool) -> Self { - .init(spaceID: spaceID, microphoneEnabled: microphoneEnabled) + static func createGridRoom(spaceID: Int64, microphoneEnabled: Bool, expectedMembershipID: String? = nil) -> Self { + .init(spaceID: spaceID, microphoneEnabled: microphoneEnabled, expectedMembershipID: expectedMembershipID) } } public extension Transaction2 where Self == JoinGridRoomTransaction { - static func joinGridRoom(roomID: Int64, microphoneEnabled: Bool) -> Self { - .init(roomID: roomID, microphoneEnabled: microphoneEnabled) + static func joinGridRoom(roomID: Int64, microphoneEnabled: Bool, expectedMembershipID: String? = nil) -> Self { + .init(roomID: roomID, microphoneEnabled: microphoneEnabled, expectedMembershipID: expectedMembershipID) } } public extension Transaction2 where Self == LeaveGridRoomTransaction { - static func leaveGridRoom(roomID: Int64) -> Self { .init(roomID: roomID) } + static func leaveGridRoom(roomID: Int64, expectedMembershipID: String? = nil) -> Self { + .init(roomID: roomID, expectedMembershipID: expectedMembershipID) + } } public extension Transaction2 where Self == SetGridRoomLockedTransaction { - static func setGridRoomLocked(roomID: Int64, locked: Bool) -> Self { .init(roomID: roomID, locked: locked) } + static func setGridRoomLocked(roomID: Int64, locked: Bool) -> Self { + .init(roomID: roomID, locked: locked) + } } public extension Transaction2 where Self == SetGridRoomTitleTransaction { - static func setGridRoomTitle(roomID: Int64, title: String) -> Self { .init(roomID: roomID, title: title) } + static func setGridRoomTitle(roomID: Int64, title: String) -> Self { + .init(roomID: roomID, title: title) + } } public extension Transaction2 where Self == DeleteGridRoomTransaction { - static func deleteGridRoom(roomID: Int64) -> Self { .init(roomID: roomID) } + static func deleteGridRoom(roomID: Int64) -> Self { + .init(roomID: roomID) + } } public extension Transaction2 where Self == PrepareGridConnectionTransaction { - static func prepareGridConnection(roomID: Int64, generation: Int32) -> Self { - .init(roomID: roomID, generation: generation) + static func prepareGridConnection(roomID: Int64, generation: Int32, expectedMembershipID: String? = nil) -> Self { + .init(roomID: roomID, generation: generation, expectedMembershipID: expectedMembershipID) } } public extension Transaction2 where Self == SetGridAvatarMicTransaction { - static func setGridAvatarMicrophoneEnabled(roomID: Int64, enabled: Bool) -> Self { - .init(roomID: roomID, enabled: enabled) + static func setGridAvatarMicrophoneEnabled( + roomID: Int64, + enabled: Bool, + expectedMembershipID: String? = nil + ) -> Self { + .init(roomID: roomID, enabled: enabled, expectedMembershipID: expectedMembershipID) + } +} + +/// Explicit ownership claim. A retry must reuse the caller-observed fence; +/// a newer self snapshot is display evidence, never permission to retry a claim. +public struct MoveGridCallHereTransaction: Transaction2 { + public var method: InlineProtocol.Method = .moveGridCallHere + public var context: Context + public var type: TransactionKindType = .mutation(.init(transient: true)) + + public struct Context: Sendable, Codable { + let callID: String + let expectedMembershipID: String + } + + enum CodingKeys: String, CodingKey { case context } + public init(callID: String, expectedMembershipID: String) { + context = Context(callID: callID, expectedMembershipID: expectedMembershipID) + } + + public func input(from context: Context) -> RpcCall.OneOf_Input? { + .moveGridCallHere(.with { + $0.callID = context.callID + $0.expectedMembershipID = context.expectedMembershipID + }) + } + + public func apply(_ result: RpcResult.OneOf_Result?) async throws(TransactionExecutionError) { + guard case .moveGridCallHere = result else { throw .invalid } + } +} + +public extension Transaction2 where Self == MoveGridCallHereTransaction { + static func moveGridCallHere(callID: String, expectedMembershipID: String) -> Self { + .init(callID: callID, expectedMembershipID: expectedMembershipID) + } +} + +public struct OpenGridThreadTransaction: Transaction2 { + public var method: InlineProtocol.Method = .openGridThread + public var context: Context + public var type: TransactionKindType = .mutation(.init(transient: true)) + public var reconnectReplayPolicy: TransactionReconnectPolicy? { + .neverReplay + } + + public struct Context: Sendable, Codable { + let roomID: Int64 + let membershipID: String + } + + enum CodingKeys: String, CodingKey { case context } + public init(roomID: Int64, membershipID: String) { + context = Context(roomID: roomID, membershipID: membershipID) + } + + public func input(from context: Context) -> RpcCall.OneOf_Input? { + .openGridThread(.with { + $0.roomID = context.roomID + $0.expectedMembershipID = context.membershipID + }) + } + + public func apply(_ result: RpcResult.OneOf_Result?) async throws(TransactionExecutionError) { + guard case let .openGridThread(response) = result else { throw .invalid } + await Api.realtime.applyUpdatesAndWait(response.updates) + } +} + +public struct SetGridTranscriptionTransaction: Transaction2 { + public var method: InlineProtocol.Method = .setGridTranscription + public var context: GridTranscriptionRequest + public var type: TransactionKindType = .mutation(.init(transient: true)) + public var reconnectReplayPolicy: TransactionReconnectPolicy? { + .neverReplay + } + + enum CodingKeys: String, CodingKey { case context } + public init(request: GridTranscriptionRequest) { + context = request + } + + public func input(from context: GridTranscriptionRequest) -> RpcCall.OneOf_Input? { + .setGridTranscription(context.input) + } + + public func apply(_ result: RpcResult.OneOf_Result?) async throws(TransactionExecutionError) { + guard case let .setGridTranscription(response) = result else { throw .invalid } + await Api.realtime.applyUpdatesAndWait(response.updates) + } +} + +public struct ListGridTranscriptsTransaction: Transaction2 { + public var method: InlineProtocol.Method = .listGridTranscripts + public var context: Context + public var type: TransactionKindType = .ephemeral() + public var ephemeralCoalescingKey: String? { + "room:\(context.roomID)" + } + + public struct Context: Sendable, Codable { let roomID: Int64 } + enum CodingKeys: String, CodingKey { case context } + public init(roomID: Int64) { + context = Context(roomID: roomID) + } + + public func input(from context: Context) -> RpcCall.OneOf_Input? { + .listGridTranscripts(.with { $0.roomID = context.roomID }) + } + + public func apply(_ result: RpcResult.OneOf_Result?) async throws(TransactionExecutionError) { + guard case .listGridTranscripts = result else { throw .invalid } + } +} + +public extension Transaction2 where Self == OpenGridThreadTransaction { + static func openGridThread(roomID: Int64, membershipID: String) -> Self { + .init( + roomID: roomID, + membershipID: membershipID + ) + } +} + +public extension Transaction2 where Self == SetGridTranscriptionTransaction { + static func setGridTranscription(request: GridTranscriptionRequest) -> Self { + .init(request: request) + } +} + +public extension Transaction2 where Self == ListGridTranscriptsTransaction { + static func listGridTranscripts(roomID: Int64) -> Self { + .init(roomID: roomID) } } diff --git a/apple/InlineKit/Sources/InlineProtocol/client.pb.swift b/apple/InlineKit/Sources/InlineProtocol/client.pb.swift index 35f1d66c7..b15df44d2 100644 --- a/apple/InlineKit/Sources/InlineProtocol/client.pb.swift +++ b/apple/InlineKit/Sources/InlineProtocol/client.pb.swift @@ -75,6 +75,15 @@ public nonisolated struct Client_MessageContentPayload: @unchecked Sendable { /// Clears the value of `subthread`. Subsequent reads from it will return its default value. public mutating func clearSubthread() {_uniqueStorage()._subthread = nil} + public var countsAsUnread: Bool { + get {_storage._countsAsUnread ?? false} + set {_uniqueStorage()._countsAsUnread = newValue} + } + /// Returns true if `countsAsUnread` has been explicitly set. + public var hasCountsAsUnread: Bool {_storage._countsAsUnread != nil} + /// Clears the value of `countsAsUnread`. Subsequent reads from it will return its default value. + public mutating func clearCountsAsUnread() {_uniqueStorage()._countsAsUnread = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} @@ -139,7 +148,7 @@ fileprivate nonisolated let _protobuf_package = "client" nonisolated extension Client_MessageContentPayload: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".MessageContentPayload" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}voice\0\u{1}actions\0\u{1}replies\0\u{3}service_message\0\u{1}subthread\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}voice\0\u{1}actions\0\u{1}replies\0\u{3}service_message\0\u{1}subthread\0\u{3}counts_as_unread\0") fileprivate class _StorageClass { var _voice: Client_MessageVoiceContent? = nil @@ -147,6 +156,7 @@ nonisolated extension Client_MessageContentPayload: SwiftProtobuf.Message, Swift var _replies: MessageReplies? = nil var _serviceMessage: MessageService? = nil var _subthread: MessageSubthread? = nil + var _countsAsUnread: Bool? = nil // This property is used as the initial default value for new instances of the type. // The type itself is protecting the reference to its storage via CoW semantics. @@ -162,6 +172,7 @@ nonisolated extension Client_MessageContentPayload: SwiftProtobuf.Message, Swift _replies = source._replies _serviceMessage = source._serviceMessage _subthread = source._subthread + _countsAsUnread = source._countsAsUnread } } @@ -185,6 +196,7 @@ nonisolated extension Client_MessageContentPayload: SwiftProtobuf.Message, Swift case 3: try { try decoder.decodeSingularMessageField(value: &_storage._replies) }() case 4: try { try decoder.decodeSingularMessageField(value: &_storage._serviceMessage) }() case 5: try { try decoder.decodeSingularMessageField(value: &_storage._subthread) }() + case 6: try { try decoder.decodeSingularBoolField(value: &_storage._countsAsUnread) }() default: break } } @@ -212,6 +224,9 @@ nonisolated extension Client_MessageContentPayload: SwiftProtobuf.Message, Swift try { if let v = _storage._subthread { try visitor.visitSingularMessageField(value: v, fieldNumber: 5) } }() + try { if let v = _storage._countsAsUnread { + try visitor.visitSingularBoolField(value: v, fieldNumber: 6) + } }() } try unknownFields.traverse(visitor: &visitor) } @@ -226,6 +241,7 @@ nonisolated extension Client_MessageContentPayload: SwiftProtobuf.Message, Swift if _storage._replies != rhs_storage._replies {return false} if _storage._serviceMessage != rhs_storage._serviceMessage {return false} if _storage._subthread != rhs_storage._subthread {return false} + if _storage._countsAsUnread != rhs_storage._countsAsUnread {return false} return true } if !storagesAreEqual {return false} diff --git a/apple/InlineKit/Sources/InlineProtocol/core.pb.swift b/apple/InlineKit/Sources/InlineProtocol/core.pb.swift index 50cc03d61..e93d13879 100644 --- a/apple/InlineKit/Sources/InlineProtocol/core.pb.swift +++ b/apple/InlineKit/Sources/InlineProtocol/core.pb.swift @@ -70,6 +70,52 @@ public nonisolated enum DialogFollowMode: SwiftProtobuf.Enum, Swift.CaseIterable } +public nonisolated enum GridTranscriptMessageKind: SwiftProtobuf.Enum, Swift.CaseIterable { + public typealias RawValue = Int + case gridTranscriptTurn // = 0 + case gridTranscriptStarted // = 1 + case gridTranscriptStopped // = 2 + case gridTranscriptInterrupted // = 3 + case gridTranscriptLink // = 4 + case UNRECOGNIZED(Int) + + public init() { + self = .gridTranscriptTurn + } + + public init?(rawValue: Int) { + switch rawValue { + case 0: self = .gridTranscriptTurn + case 1: self = .gridTranscriptStarted + case 2: self = .gridTranscriptStopped + case 3: self = .gridTranscriptInterrupted + case 4: self = .gridTranscriptLink + default: self = .UNRECOGNIZED(rawValue) + } + } + + public var rawValue: Int { + switch self { + case .gridTranscriptTurn: return 0 + case .gridTranscriptStarted: return 1 + case .gridTranscriptStopped: return 2 + case .gridTranscriptInterrupted: return 3 + case .gridTranscriptLink: return 4 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + public static let allCases: [GridTranscriptMessageKind] = [ + .gridTranscriptTurn, + .gridTranscriptStarted, + .gridTranscriptStopped, + .gridTranscriptInterrupted, + .gridTranscriptLink, + ] + +} + /// Agent providers that implement Inline's session continuity contract. The /// numeric values are stored by the server; additions must be append-only. public nonisolated enum AgentSessionProvider: SwiftProtobuf.Enum, Swift.CaseIterable { @@ -525,6 +571,10 @@ public nonisolated enum Method: SwiftProtobuf.Enum, Swift.CaseIterable { case requestBotFilesystem // = 143 case answerBotFilesystem // = 144 case setSpacePhoto // = 145 + case moveGridCallHere // = 146 + case openGridThread // = 147 + case setGridTranscription // = 148 + case listGridTranscripts // = 149 case UNRECOGNIZED(Int) public init() { @@ -678,6 +728,10 @@ public nonisolated enum Method: SwiftProtobuf.Enum, Swift.CaseIterable { case 143: self = .requestBotFilesystem case 144: self = .answerBotFilesystem case 145: self = .setSpacePhoto + case 146: self = .moveGridCallHere + case 147: self = .openGridThread + case 148: self = .setGridTranscription + case 149: self = .listGridTranscripts default: self = .UNRECOGNIZED(rawValue) } } @@ -829,6 +883,10 @@ public nonisolated enum Method: SwiftProtobuf.Enum, Swift.CaseIterable { case .requestBotFilesystem: return 143 case .answerBotFilesystem: return 144 case .setSpacePhoto: return 145 + case .moveGridCallHere: return 146 + case .openGridThread: return 147 + case .setGridTranscription: return 148 + case .listGridTranscripts: return 149 case .UNRECOGNIZED(let i): return i } } @@ -980,6 +1038,98 @@ public nonisolated enum Method: SwiftProtobuf.Enum, Swift.CaseIterable { .requestBotFilesystem, .answerBotFilesystem, .setSpacePhoto, + .moveGridCallHere, + .openGridThread, + .setGridTranscription, + .listGridTranscripts, + ] + +} + +public nonisolated enum GridTranscriptionState: SwiftProtobuf.Enum, Swift.CaseIterable { + public typealias RawValue = Int + case gridTranscriptionUnspecified // = 0 + case gridTranscriptionStarting // = 1 + case gridTranscriptionActive // = 2 + case gridTranscriptionStopping // = 3 + case gridTranscriptionStopped // = 4 + case gridTranscriptionInterrupted // = 5 + case UNRECOGNIZED(Int) + + public init() { + self = .gridTranscriptionUnspecified + } + + public init?(rawValue: Int) { + switch rawValue { + case 0: self = .gridTranscriptionUnspecified + case 1: self = .gridTranscriptionStarting + case 2: self = .gridTranscriptionActive + case 3: self = .gridTranscriptionStopping + case 4: self = .gridTranscriptionStopped + case 5: self = .gridTranscriptionInterrupted + default: self = .UNRECOGNIZED(rawValue) + } + } + + public var rawValue: Int { + switch self { + case .gridTranscriptionUnspecified: return 0 + case .gridTranscriptionStarting: return 1 + case .gridTranscriptionActive: return 2 + case .gridTranscriptionStopping: return 3 + case .gridTranscriptionStopped: return 4 + case .gridTranscriptionInterrupted: return 5 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + public static let allCases: [GridTranscriptionState] = [ + .gridTranscriptionUnspecified, + .gridTranscriptionStarting, + .gridTranscriptionActive, + .gridTranscriptionStopping, + .gridTranscriptionStopped, + .gridTranscriptionInterrupted, + ] + +} + +public nonisolated enum GridTranscriptDestination: SwiftProtobuf.Enum, Swift.CaseIterable { + public typealias RawValue = Int + case gridTranscriptLast // = 0 + case gridTranscriptNew // = 1 + case gridTranscriptExisting // = 2 + case UNRECOGNIZED(Int) + + public init() { + self = .gridTranscriptLast + } + + public init?(rawValue: Int) { + switch rawValue { + case 0: self = .gridTranscriptLast + case 1: self = .gridTranscriptNew + case 2: self = .gridTranscriptExisting + default: self = .UNRECOGNIZED(rawValue) + } + } + + public var rawValue: Int { + switch self { + case .gridTranscriptLast: return 0 + case .gridTranscriptNew: return 1 + case .gridTranscriptExisting: return 2 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + public static let allCases: [GridTranscriptDestination] = [ + .gridTranscriptLast, + .gridTranscriptNew, + .gridTranscriptExisting, ] } @@ -3494,11 +3644,20 @@ public nonisolated struct MessageService: Sendable { set {event = .pinnedMessage(newValue)} } + public var gridTranscript: MessageServiceGridTranscript { + get { + if case .gridTranscript(let v)? = event {return v} + return MessageServiceGridTranscript() + } + set {event = .gridTranscript(newValue)} + } + public var unknownFields = SwiftProtobuf.UnknownStorage() public nonisolated enum OneOf_Event: Equatable, Sendable { case threadBacklink(MessageServiceThreadBacklink) case pinnedMessage(MessageServicePinnedMessage) + case gridTranscript(MessageServiceGridTranscript) } @@ -3558,6 +3717,33 @@ public nonisolated struct MessageServicePinnedMessage: Sendable { fileprivate var _messageID: Int64? = nil } +public nonisolated struct MessageServiceGridTranscript: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var runID: String = String() + + public var segmentID: String = String() + + public var speakerUserID: Int64 { + get {_speakerUserID ?? 0} + set {_speakerUserID = newValue} + } + /// Returns true if `speakerUserID` has been explicitly set. + public var hasSpeakerUserID: Bool {self._speakerUserID != nil} + /// Clears the value of `speakerUserID`. Subsequent reads from it will return its default value. + public mutating func clearSpeakerUserID() {self._speakerUserID = nil} + + public var kind: GridTranscriptMessageKind = .gridTranscriptTurn + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _speakerUserID: Int64? = nil +} + /// Structural rich content projected alongside Message.message and /// Message.entities. Text-bearing blocks use UTF-16 ranges into Message.message; /// inline entities remain owned by Message.entities so there is one text/entity @@ -4537,6 +4723,16 @@ public nonisolated struct Message: @unchecked Sendable { /// Clears the value of `subthread`. Subsequent reads from it will return its default value. public mutating func clearSubthread() {_uniqueStorage()._subthread = nil} + /// Explicit false suppresses unread for generated/historical rows. + public var countsAsUnread: Bool { + get {_storage._countsAsUnread ?? false} + set {_uniqueStorage()._countsAsUnread = newValue} + } + /// Returns true if `countsAsUnread` has been explicitly set. + public var hasCountsAsUnread: Bool {_storage._countsAsUnread != nil} + /// Clears the value of `countsAsUnread`. Subsequent reads from it will return its default value. + public mutating func clearCountsAsUnread() {_uniqueStorage()._countsAsUnread = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} @@ -8232,6 +8428,38 @@ public nonisolated struct RpcCall: Sendable { set {input = .setSpacePhoto(newValue)} } + public var moveGridCallHere: MoveGridCallHereInput { + get { + if case .moveGridCallHere(let v)? = input {return v} + return MoveGridCallHereInput() + } + set {input = .moveGridCallHere(newValue)} + } + + public var openGridThread: OpenGridThreadInput { + get { + if case .openGridThread(let v)? = input {return v} + return OpenGridThreadInput() + } + set {input = .openGridThread(newValue)} + } + + public var setGridTranscription: SetGridTranscriptionInput { + get { + if case .setGridTranscription(let v)? = input {return v} + return SetGridTranscriptionInput() + } + set {input = .setGridTranscription(newValue)} + } + + public var listGridTranscripts: ListGridTranscriptsInput { + get { + if case .listGridTranscripts(let v)? = input {return v} + return ListGridTranscriptsInput() + } + set {input = .listGridTranscripts(newValue)} + } + public var unknownFields = SwiftProtobuf.UnknownStorage() public nonisolated enum OneOf_Input: Equatable, Sendable { @@ -8379,6 +8607,10 @@ public nonisolated struct RpcCall: Sendable { case requestBotFilesystem(RequestBotFilesystemInput) case answerBotFilesystem(AnswerBotFilesystemInput) case setSpacePhoto(SetSpacePhotoInput) + case moveGridCallHere(MoveGridCallHereInput) + case openGridThread(OpenGridThreadInput) + case setGridTranscription(SetGridTranscriptionInput) + case listGridTranscripts(ListGridTranscriptsInput) } @@ -9552,6 +9784,38 @@ public nonisolated struct RpcResult: @unchecked Sendable { set {_uniqueStorage()._result = .setSpacePhoto(newValue)} } + public var moveGridCallHere: MoveGridCallHereResult { + get { + if case .moveGridCallHere(let v)? = _storage._result {return v} + return MoveGridCallHereResult() + } + set {_uniqueStorage()._result = .moveGridCallHere(newValue)} + } + + public var openGridThread: OpenGridThreadResult { + get { + if case .openGridThread(let v)? = _storage._result {return v} + return OpenGridThreadResult() + } + set {_uniqueStorage()._result = .openGridThread(newValue)} + } + + public var setGridTranscription: SetGridTranscriptionResult { + get { + if case .setGridTranscription(let v)? = _storage._result {return v} + return SetGridTranscriptionResult() + } + set {_uniqueStorage()._result = .setGridTranscription(newValue)} + } + + public var listGridTranscripts: ListGridTranscriptsResult { + get { + if case .listGridTranscripts(let v)? = _storage._result {return v} + return ListGridTranscriptsResult() + } + set {_uniqueStorage()._result = .listGridTranscripts(newValue)} + } + public var unknownFields = SwiftProtobuf.UnknownStorage() public nonisolated enum OneOf_Result: Equatable, Sendable { @@ -9699,6 +9963,10 @@ public nonisolated struct RpcResult: @unchecked Sendable { case requestBotFilesystem(RequestBotFilesystemResult) case answerBotFilesystem(AnswerBotFilesystemResult) case setSpacePhoto(SetSpacePhotoResult) + case moveGridCallHere(MoveGridCallHereResult) + case openGridThread(OpenGridThreadResult) + case setGridTranscription(SetGridTranscriptionResult) + case listGridTranscripts(ListGridTranscriptsResult) } @@ -10198,49 +10466,255 @@ public nonisolated struct GridConnection: Sendable { public init() {} } -public nonisolated struct GridRoom: Sendable { +public nonisolated struct GridRoom: @unchecked Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. - public var id: Int64 = 0 + public var id: Int64 { + get {_storage._id} + set {_uniqueStorage()._id = newValue} + } - public var spaceID: Int64 = 0 + public var spaceID: Int64 { + get {_storage._spaceID} + set {_uniqueStorage()._spaceID = newValue} + } - public var createdByUserID: Int64 = 0 + public var createdByUserID: Int64 { + get {_storage._createdByUserID} + set {_uniqueStorage()._createdByUserID = newValue} + } public var title: String { - get {_title ?? String()} - set {_title = newValue} + get {_storage._title ?? String()} + set {_uniqueStorage()._title = newValue} } /// Returns true if `title` has been explicitly set. - public var hasTitle: Bool {self._title != nil} + public var hasTitle: Bool {_storage._title != nil} /// Clears the value of `title`. Subsequent reads from it will return its default value. - public mutating func clearTitle() {self._title = nil} + public mutating func clearTitle() {_uniqueStorage()._title = nil} - public var locked: Bool = false + public var locked: Bool { + get {_storage._locked} + set {_uniqueStorage()._locked = newValue} + } - public var createdAt: Int64 = 0 + public var createdAt: Int64 { + get {_storage._createdAt} + set {_uniqueStorage()._createdAt = newValue} + } - public var updatedAt: Int64 = 0 + public var updatedAt: Int64 { + get {_storage._updatedAt} + set {_uniqueStorage()._updatedAt = newValue} + } - public var avatars: [GridAvatar] = [] + public var avatars: [GridAvatar] { + get {_storage._avatars} + set {_uniqueStorage()._avatars = newValue} + } public var connection: GridConnection { - get {_connection ?? GridConnection()} - set {_connection = newValue} + get {_storage._connection ?? GridConnection()} + set {_uniqueStorage()._connection = newValue} } /// Returns true if `connection` has been explicitly set. - public var hasConnection: Bool {self._connection != nil} + public var hasConnection: Bool {_storage._connection != nil} /// Clears the value of `connection`. Subsequent reads from it will return its default value. - public mutating func clearConnection() {self._connection = nil} + public mutating func clearConnection() {_uniqueStorage()._connection = nil} + + public var roomThreadID: Int64 { + get {_storage._roomThreadID ?? 0} + set {_uniqueStorage()._roomThreadID = newValue} + } + /// Returns true if `roomThreadID` has been explicitly set. + public var hasRoomThreadID: Bool {_storage._roomThreadID != nil} + /// Clears the value of `roomThreadID`. Subsequent reads from it will return its default value. + public mutating func clearRoomThreadID() {_uniqueStorage()._roomThreadID = nil} + + public var transcriptionAvailable: Bool { + get {_storage._transcriptionAvailable} + set {_uniqueStorage()._transcriptionAvailable = newValue} + } + + public var transcription: GridTranscription { + get {_storage._transcription ?? GridTranscription()} + set {_uniqueStorage()._transcription = newValue} + } + /// Returns true if `transcription` has been explicitly set. + public var hasTranscription: Bool {_storage._transcription != nil} + /// Clears the value of `transcription`. Subsequent reads from it will return its default value. + public mutating func clearTranscription() {_uniqueStorage()._transcription = nil} public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} - fileprivate var _title: String? = nil - fileprivate var _connection: GridConnection? = nil + fileprivate var _storage = _StorageClass.defaultInstance +} + +public nonisolated struct GridTranscription: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var runID: String = String() + + public var state: GridTranscriptionState = .gridTranscriptionUnspecified + + public var transcriptChatID: Int64 { + get {_transcriptChatID ?? 0} + set {_transcriptChatID = newValue} + } + /// Returns true if `transcriptChatID` has been explicitly set. + public var hasTranscriptChatID: Bool {self._transcriptChatID != nil} + /// Clears the value of `transcriptChatID`. Subsequent reads from it will return its default value. + public mutating func clearTranscriptChatID() {self._transcriptChatID = nil} + + public var revision: Int32 = 0 + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _transcriptChatID: Int64? = nil +} + +public nonisolated struct OpenGridThreadInput: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var roomID: Int64 = 0 + + public var expectedMembershipID: String = String() + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct OpenGridThreadResult: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var chatID: Int64 = 0 + + public var updates: [Update] = [] + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct SetGridTranscriptionInput: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var roomID: Int64 = 0 + + public var expectedMembershipID: String = String() + + public var expectedGeneration: Int32 = 0 + + public var expectedRunID: String { + get {_expectedRunID ?? String()} + set {_expectedRunID = newValue} + } + /// Returns true if `expectedRunID` has been explicitly set. + public var hasExpectedRunID: Bool {self._expectedRunID != nil} + /// Clears the value of `expectedRunID`. Subsequent reads from it will return its default value. + public mutating func clearExpectedRunID() {self._expectedRunID = nil} + + public var expectedRevision: Int32 = 0 + + public var enabled: Bool = false + + public var destination: GridTranscriptDestination = .gridTranscriptLast + + public var transcriptChatID: Int64 { + get {_transcriptChatID ?? 0} + set {_transcriptChatID = newValue} + } + /// Returns true if `transcriptChatID` has been explicitly set. + public var hasTranscriptChatID: Bool {self._transcriptChatID != nil} + /// Clears the value of `transcriptChatID`. Subsequent reads from it will return its default value. + public mutating func clearTranscriptChatID() {self._transcriptChatID = nil} + + public var requestID: String = String() + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _expectedRunID: String? = nil + fileprivate var _transcriptChatID: Int64? = nil +} + +public nonisolated struct SetGridTranscriptionResult: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var grid: Grid { + get {_grid ?? Grid()} + set {_grid = newValue} + } + /// Returns true if `grid` has been explicitly set. + public var hasGrid: Bool {self._grid != nil} + /// Clears the value of `grid`. Subsequent reads from it will return its default value. + public mutating func clearGrid() {self._grid = nil} + + public var updates: [Update] = [] + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _grid: Grid? = nil +} + +public nonisolated struct ListGridTranscriptsInput: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var roomID: Int64 = 0 + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct GridTranscriptDestinationInfo: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var transcriptChatID: Int64 = 0 + + public var title: String = String() + + public var busy: Bool = false + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct ListGridTranscriptsResult: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var transcripts: [GridTranscriptDestinationInfo] = [] + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} } /// Replaceable current state for one Space's implicit Grid. @@ -10268,6 +10742,10 @@ public nonisolated struct Grid: Sendable { /// Clients must not replace a newer snapshot with a lower revision. public var revision: Int64 = 0 + /// Effective server capability, including ownership fence enforcement. + /// New clients must not admit media through an older/disabled server. + public var callTransferEnabled: Bool = false + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} @@ -10275,6 +10753,32 @@ public nonisolated struct Grid: Sendable { fileprivate var _currentRoomID: Int64? = nil } +/// Personalized control ownership, disclosed only to the authenticated user. +/// call_id stays stable across an explicit device transfer; membership_id is +/// replaced on each transfer/resume and fences commands and media credentials. +public nonisolated struct GridCurrentCall: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var callID: String = String() + + public var spaceID: Int64 = 0 + + public var roomID: Int64 = 0 + + public var membershipID: String = String() + + public var ownedByCurrentSession: Bool = false + + /// Coarse existing session client kind (ios, macos, etc.), not a device ID. + public var ownerClientType: String = String() + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + /// Short-lived, session-targeted LiveKit credentials. Never persist or include /// this message in repairable Update buckets. public nonisolated struct GridConnectionCredentials: Sendable { @@ -10299,6 +10803,10 @@ public nonisolated struct GridConnectionCredentials: Sendable { public var expiresAt: Int64 = 0 + public var callID: String = String() + + public var membershipID: String = String() + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} @@ -10313,9 +10821,22 @@ public nonisolated struct GetGridInput: Sendable { public var spaceID: Int64 = 0 + /// Explicit renewal by the admitted owner. Omit for read-only browsing when + /// call-transfer enforcement is active. A stale fence never renews presence. + public var expectedMembershipID: String { + get {_expectedMembershipID ?? String()} + set {_expectedMembershipID = newValue} + } + /// Returns true if `expectedMembershipID` has been explicitly set. + public var hasExpectedMembershipID: Bool {self._expectedMembershipID != nil} + /// Clears the value of `expectedMembershipID`. Subsequent reads from it will return its default value. + public mutating func clearExpectedMembershipID() {self._expectedMembershipID = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} + + fileprivate var _expectedMembershipID: String? = nil } public nonisolated struct GetGridResult: Sendable { @@ -10332,11 +10853,21 @@ public nonisolated struct GetGridResult: Sendable { /// Clears the value of `grid`. Subsequent reads from it will return its default value. public mutating func clearGrid() {self._grid = nil} + public var currentCall: GridCurrentCall { + get {_currentCall ?? GridCurrentCall()} + set {_currentCall = newValue} + } + /// Returns true if `currentCall` has been explicitly set. + public var hasCurrentCall: Bool {self._currentCall != nil} + /// Clears the value of `currentCall`. Subsequent reads from it will return its default value. + public mutating func clearCurrentCall() {self._currentCall = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} fileprivate var _grid: Grid? = nil + fileprivate var _currentCall: GridCurrentCall? = nil } /// Home-level discovery for every Grid-enabled Space the current user can see. @@ -10376,9 +10907,22 @@ public nonisolated struct GetGridHomeResult: Sendable { public var spaces: [GridHomeSpace] = [] + public var currentCall: GridCurrentCall { + get {_currentCall ?? GridCurrentCall()} + set {_currentCall = newValue} + } + /// Returns true if `currentCall` has been explicitly set. + public var hasCurrentCall: Bool {self._currentCall != nil} + /// Clears the value of `currentCall`. Subsequent reads from it will return its default value. + public mutating func clearCurrentCall() {self._currentCall = nil} + + public var callTransferEnabled: Bool = false + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} + + fileprivate var _currentCall: GridCurrentCall? = nil } public nonisolated struct CreateGridRoomInput: Sendable { @@ -10397,11 +10941,23 @@ public nonisolated struct CreateGridRoomInput: Sendable { /// Clears the value of `microphoneEnabled`. Subsequent reads from it will return its default value. public mutating func clearMicrophoneEnabled() {self._microphoneEnabled = nil} + /// Required when call_transfer_enabled: empty expects no active presence; + /// nonempty must match the current session's live membership exactly. + public var expectedMembershipID: String { + get {_expectedMembershipID ?? String()} + set {_expectedMembershipID = newValue} + } + /// Returns true if `expectedMembershipID` has been explicitly set. + public var hasExpectedMembershipID: Bool {self._expectedMembershipID != nil} + /// Clears the value of `expectedMembershipID`. Subsequent reads from it will return its default value. + public mutating func clearExpectedMembershipID() {self._expectedMembershipID = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} fileprivate var _microphoneEnabled: Bool? = nil + fileprivate var _expectedMembershipID: String? = nil } public nonisolated struct CreateGridRoomResult: Sendable { @@ -10420,11 +10976,23 @@ public nonisolated struct CreateGridRoomResult: Sendable { /// Clears the value of `connection`. Subsequent reads from it will return its default value. public mutating func clearConnection() {self._connection = nil} + /// Successful claims report the projection captured at commit. A newer + /// accepted Grid/self state wins; this field alone cannot admit media. + public var currentCall: GridCurrentCall { + get {_currentCall ?? GridCurrentCall()} + set {_currentCall = newValue} + } + /// Returns true if `currentCall` has been explicitly set. + public var hasCurrentCall: Bool {self._currentCall != nil} + /// Clears the value of `currentCall`. Subsequent reads from it will return its default value. + public mutating func clearCurrentCall() {self._currentCall = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} fileprivate var _connection: GridConnectionCredentials? = nil + fileprivate var _currentCall: GridCurrentCall? = nil } public nonisolated struct JoinGridRoomInput: Sendable { @@ -10443,11 +11011,21 @@ public nonisolated struct JoinGridRoomInput: Sendable { /// Clears the value of `microphoneEnabled`. Subsequent reads from it will return its default value. public mutating func clearMicrophoneEnabled() {self._microphoneEnabled = nil} + public var expectedMembershipID: String { + get {_expectedMembershipID ?? String()} + set {_expectedMembershipID = newValue} + } + /// Returns true if `expectedMembershipID` has been explicitly set. + public var hasExpectedMembershipID: Bool {self._expectedMembershipID != nil} + /// Clears the value of `expectedMembershipID`. Subsequent reads from it will return its default value. + public mutating func clearExpectedMembershipID() {self._expectedMembershipID = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} fileprivate var _microphoneEnabled: Bool? = nil + fileprivate var _expectedMembershipID: String? = nil } public nonisolated struct JoinGridRoomResult: Sendable { @@ -10466,11 +11044,21 @@ public nonisolated struct JoinGridRoomResult: Sendable { /// Clears the value of `connection`. Subsequent reads from it will return its default value. public mutating func clearConnection() {self._connection = nil} + public var currentCall: GridCurrentCall { + get {_currentCall ?? GridCurrentCall()} + set {_currentCall = newValue} + } + /// Returns true if `currentCall` has been explicitly set. + public var hasCurrentCall: Bool {self._currentCall != nil} + /// Clears the value of `currentCall`. Subsequent reads from it will return its default value. + public mutating func clearCurrentCall() {self._currentCall = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} fileprivate var _connection: GridConnectionCredentials? = nil + fileprivate var _currentCall: GridCurrentCall? = nil } public nonisolated struct LeaveGridRoomInput: Sendable { @@ -10480,9 +11068,20 @@ public nonisolated struct LeaveGridRoomInput: Sendable { public var expectedRoomID: Int64 = 0 + public var expectedMembershipID: String { + get {_expectedMembershipID ?? String()} + set {_expectedMembershipID = newValue} + } + /// Returns true if `expectedMembershipID` has been explicitly set. + public var hasExpectedMembershipID: Bool {self._expectedMembershipID != nil} + /// Clears the value of `expectedMembershipID`. Subsequent reads from it will return its default value. + public mutating func clearExpectedMembershipID() {self._expectedMembershipID = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} + + fileprivate var _expectedMembershipID: String? = nil } public nonisolated struct LeaveGridRoomResult: Sendable { @@ -10492,11 +11091,81 @@ public nonisolated struct LeaveGridRoomResult: Sendable { public var grids: [Grid] = [] + /// Display/repair only; leaving never grants a replacement admission. + public var currentCall: GridCurrentCall { + get {_currentCall ?? GridCurrentCall()} + set {_currentCall = newValue} + } + /// Returns true if `currentCall` has been explicitly set. + public var hasCurrentCall: Bool {self._currentCall != nil} + /// Clears the value of `currentCall`. Subsequent reads from it will return its default value. + public mutating func clearCurrentCall() {self._currentCall = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _currentCall: GridCurrentCall? = nil +} + +/// Explicitly move this user's existing room participation to the authenticated +/// calling session (also used for a same-login cold-process Resume). +public nonisolated struct MoveGridCallHereInput: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var callID: String = String() + + public var expectedMembershipID: String = String() + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} } +public nonisolated struct MoveGridCallHereResult: @unchecked Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var grids: [Grid] { + get {_storage._grids} + set {_uniqueStorage()._grids = newValue} + } + + public var connection: GridConnectionCredentials { + get {_storage._connection ?? GridConnectionCredentials()} + set {_uniqueStorage()._connection = newValue} + } + /// Returns true if `connection` has been explicitly set. + public var hasConnection: Bool {_storage._connection != nil} + /// Clears the value of `connection`. Subsequent reads from it will return its default value. + public mutating func clearConnection() {_uniqueStorage()._connection = nil} + + /// Captured at the claim's commit when moved=true; display/repair otherwise. + public var currentCall: GridCurrentCall { + get {_storage._currentCall ?? GridCurrentCall()} + set {_uniqueStorage()._currentCall = newValue} + } + /// Returns true if `currentCall` has been explicitly set. + public var hasCurrentCall: Bool {_storage._currentCall != nil} + /// Clears the value of `currentCall`. Subsequent reads from it will return its default value. + public mutating func clearCurrentCall() {_uniqueStorage()._currentCall = nil} + + /// False means the expected call/membership changed; no claim was performed. + public var moved: Bool { + get {_storage._moved} + set {_uniqueStorage()._moved = newValue} + } + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _storage = _StorageClass.defaultInstance +} + public nonisolated struct SetGridRoomTitleInput: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for @@ -10610,9 +11279,20 @@ public nonisolated struct PrepareGridConnectionInput: Sendable { public var generation: Int32 = 0 + public var expectedMembershipID: String { + get {_expectedMembershipID ?? String()} + set {_expectedMembershipID = newValue} + } + /// Returns true if `expectedMembershipID` has been explicitly set. + public var hasExpectedMembershipID: Bool {self._expectedMembershipID != nil} + /// Clears the value of `expectedMembershipID`. Subsequent reads from it will return its default value. + public mutating func clearExpectedMembershipID() {self._expectedMembershipID = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} + + fileprivate var _expectedMembershipID: String? = nil } public nonisolated struct PrepareGridConnectionResult: Sendable { @@ -10647,9 +11327,20 @@ public nonisolated struct SetGridAvatarMicrophoneEnabledInput: Sendable { public var enabled: Bool = false + public var expectedMembershipID: String { + get {_expectedMembershipID ?? String()} + set {_expectedMembershipID = newValue} + } + /// Returns true if `expectedMembershipID` has been explicitly set. + public var hasExpectedMembershipID: Bool {self._expectedMembershipID != nil} + /// Clears the value of `expectedMembershipID`. Subsequent reads from it will return its default value. + public mutating func clearExpectedMembershipID() {self._expectedMembershipID = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} + + fileprivate var _expectedMembershipID: String? = nil } public nonisolated struct SetGridAvatarMicrophoneEnabledResult: Sendable { @@ -22134,6 +22825,10 @@ nonisolated extension DialogFollowMode: SwiftProtobuf._ProtoNameProviding { public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0DIALOG_FOLLOW_MODE_UNSPECIFIED\0\u{1}FOLLOWING\0\u{1}UNFOLLOWED\0") } +nonisolated extension GridTranscriptMessageKind: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0GRID_TRANSCRIPT_TURN\0\u{1}GRID_TRANSCRIPT_STARTED\0\u{1}GRID_TRANSCRIPT_STOPPED\0\u{1}GRID_TRANSCRIPT_INTERRUPTED\0\u{1}GRID_TRANSCRIPT_LINK\0") +} + nonisolated extension AgentSessionProvider: SwiftProtobuf._ProtoNameProviding { public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0AGENT_SESSION_PROVIDER_UNSPECIFIED\0\u{1}AGENT_SESSION_PROVIDER_CODEX\0\u{1}AGENT_SESSION_PROVIDER_CODEX_CLOUD\0\u{1}AGENT_SESSION_PROVIDER_CLAUDE\0\u{1}AGENT_SESSION_PROVIDER_OPEN_CODE\0\u{1}AGENT_SESSION_PROVIDER_AMP\0") } @@ -22163,7 +22858,15 @@ nonisolated extension AgentSessionMessageSyncState: SwiftProtobuf._ProtoNameProv } nonisolated extension Method: SwiftProtobuf._ProtoNameProviding { - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0UNSPECIFIED\0\u{1}GET_ME\0\u{1}SEND_MESSAGE\0\u{1}GET_PEER_PHOTO\0\u{1}DELETE_MESSAGES\0\u{1}GET_CHAT_HISTORY\0\u{1}ADD_REACTION\0\u{1}DELETE_REACTION\0\u{1}EDIT_MESSAGE\0\u{1}CREATE_CHAT\0\u{1}GET_SPACE_MEMBERS\0\u{1}DELETE_CHAT\0\u{1}INVITE_TO_SPACE\0\u{1}GET_CHAT_PARTICIPANTS\0\u{1}ADD_CHAT_PARTICIPANT\0\u{1}REMOVE_CHAT_PARTICIPANT\0\u{1}TRANSLATE_MESSAGES\0\u{1}GET_CHATS\0\u{1}UPDATE_USER_SETTINGS\0\u{1}GET_USER_SETTINGS\0\u{1}SEND_COMPOSE_ACTION\0\u{1}CREATE_BOT\0\u{1}DELETE_MEMBER\0\u{1}MARK_AS_UNREAD\0\u{1}GET_UPDATES_STATE\0\u{1}GET_CHAT\0\u{1}GET_UPDATES\0\u{1}UPDATE_MEMBER_ACCESS\0\u{1}SEARCH_MESSAGES\0\u{1}FORWARD_MESSAGES\0\u{1}UPDATE_CHAT_VISIBILITY\0\u{1}PIN_MESSAGE\0\u{1}UPDATE_CHAT_INFO\0\u{1}LIST_BOTS\0\u{1}REVEAL_BOT_TOKEN\0\u{1}MOVE_THREAD\0\u{1}ROTATE_BOT_TOKEN\0\u{1}UPDATE_BOT_PROFILE\0\u{1}GET_MESSAGES\0\u{1}UPDATE_DIALOG_NOTIFICATION_SETTINGS\0\u{1}READ_MESSAGES\0\u{1}REGISTER_DEVICE\0\u{1}CREATE_SUBTHREAD\0\u{1}GET_BOT_COMMANDS\0\u{1}SET_BOT_COMMANDS\0\u{1}GET_PEER_BOT_COMMANDS\0\u{1}SHOW_IN_CHAT_LIST\0\u{1}RESERVE_CHAT_IDS\0\u{1}INVOKE_MESSAGE_ACTION\0\u{1}ANSWER_MESSAGE_ACTION\0\u{1}REVOKE_SESSION\0\u{1}UPDATE_DIALOG_OPEN\0\u{1}UPDATE_DIALOG_ORDER\0\u{1}CLEAR_CHAT_HISTORY\0\u{1}DELETE_BOT\0\u{1}DELETE_MESSAGE_ATTACHMENT\0\u{1}SET_BOT_AVATAR\0\u{1}CLEAR_BOT_AVATAR\0\u{1}GET_BOT_PRESENCE\0\u{1}SET_BOT_PRESENCE_STATE\0\u{1}UPDATE_DIALOG_FOLLOW_MODE\0\u{1}GET_SESSIONS\0\u{1}CHECK_USERNAME\0\u{1}CHANGE_USERNAME\0\u{1}UPDATE_PROFILE\0\u{1}GET_SPACE_URL_PREVIEW_EXCLUSIONS\0\u{1}ADD_SPACE_URL_PREVIEW_EXCLUSION\0\u{1}REMOVE_SPACE_URL_PREVIEW_EXCLUSION\0\u{1}GET_USER_GROUPS\0\u{1}CREATE_USER_GROUP\0\u{1}UPDATE_USER_GROUP\0\u{1}DELETE_USER_GROUP\0\u{1}GET_SPACE_SETTINGS\0\u{1}TOGGLE_SPACE_GRID\0\u{1}GET_THREAD_REFERENCES\0\u{1}GET_THREAD_SUBTHREADS\0\u{1}GET_PEER_BOTS\0\u{1}GET_MY_BOT_CAPABILITIES\0\u{1}SET_MY_BOT_CAPABILITIES\0\u{1}REQUEST_BOT_CHAT_SETTINGS\0\u{1}INVOKE_BOT_CHAT_SETTINGS_ITEM\0\u{1}ANSWER_BOT_CHAT_SETTINGS\0\u{2}\u{2}GET_GRID\0\u{1}CREATE_GRID_ROOM\0\u{1}JOIN_GRID_ROOM\0\u{1}LEAVE_GRID_ROOM\0\u{1}SET_GRID_ROOM_TITLE\0\u{1}SET_GRID_ROOM_LOCKED\0\u{1}DELETE_GRID_ROOM\0\u{1}PREPARE_GRID_CONNECTION\0\u{1}SET_GRID_AVATAR_MICROPHONE_ENABLED\0\u{1}GET_GRID_HOME\0\u{1}CREATE_CLI_SESSION\0\u{1}SET_PROFILE_PHOTO\0\u{1}GET_EXTERNAL_PROFILE_PHOTO\0\u{1}GET_CHAT_TRANSCRIPT\0\u{1}SEARCH_EXTERNAL_RESOURCES\0\u{1}JOIN_PUBLIC_SPACE\0\u{1}COLLAPSE_HISTORY\0\u{1}LIST_CONNECTORS\0\u{1}PREPARE_CONNECTOR_OAUTH\0\u{1}DISCONNECT_CONNECTOR\0\u{1}SEARCH_USERS\0\u{1}INVITE_TO_INLINE\0\u{1}RESOLVE_URL_PREVIEW\0\u{1}CREATE_BOT_AGENT\0\u{1}GET_BOT_AGENT\0\u{1}LIST_BOT_AGENTS\0\u{1}CREATE_SPACE\0\u{1}DELETE_SPACE\0\u{1}LEAVE_SPACE\0\u{1}GET_CONNECTOR_CONFIG\0\u{1}SET_CONNECTOR_CONFIG\0\u{1}CREATE_EXTERNAL_TASK\0\u{1}UNREGISTER_DEVICE\0\u{1}LOG_OUT\0\u{1}CREATE_UPLOAD\0\u{1}SAVE_UPLOAD_PART\0\u{1}GET_UPLOAD_STATE\0\u{1}FINISH_UPLOAD\0\u{1}CANCEL_UPLOAD\0\u{1}UPDATE_SESSION\0\u{1}UPDATE_DIALOG_ARCHIVED\0\u{1}CREATE_DIALOG_FOLDER\0\u{1}UPDATE_DIALOG_FOLDER\0\u{1}DELETE_DIALOG_FOLDER\0\u{1}GET_SPACE\0\u{1}CONNECT_AGENT_SESSION\0\u{1}SYNC_AGENT_SESSION_MESSAGES\0\u{1}GET_AGENT_SESSION\0\u{1}UPDATE_BOT_AGENT\0\u{1}DELETE_BOT_AGENT\0\u{1}JOIN_SPACE_BY_INVITE_TOKEN\0\u{1}GET_SPACE_INVITE_LINK\0\u{1}SET_SPACE_INVITE_LINK_ENABLED\0\u{1}GET_FILE_PART\0\u{1}ACKNOWLEDGE_MESSAGES\0\u{1}GET_USERS\0\u{1}GET_BOT_SKILLS\0\u{1}GET_BOT_CONFIGURATION_CATALOG\0\u{1}UPDATE_DIALOG_TRANSLATION\0\u{1}TRANSCRIBE_VOICE_DRAFT\0\u{1}REQUEST_BOT_FILESYSTEM\0\u{1}ANSWER_BOT_FILESYSTEM\0\u{1}SET_SPACE_PHOTO\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0UNSPECIFIED\0\u{1}GET_ME\0\u{1}SEND_MESSAGE\0\u{1}GET_PEER_PHOTO\0\u{1}DELETE_MESSAGES\0\u{1}GET_CHAT_HISTORY\0\u{1}ADD_REACTION\0\u{1}DELETE_REACTION\0\u{1}EDIT_MESSAGE\0\u{1}CREATE_CHAT\0\u{1}GET_SPACE_MEMBERS\0\u{1}DELETE_CHAT\0\u{1}INVITE_TO_SPACE\0\u{1}GET_CHAT_PARTICIPANTS\0\u{1}ADD_CHAT_PARTICIPANT\0\u{1}REMOVE_CHAT_PARTICIPANT\0\u{1}TRANSLATE_MESSAGES\0\u{1}GET_CHATS\0\u{1}UPDATE_USER_SETTINGS\0\u{1}GET_USER_SETTINGS\0\u{1}SEND_COMPOSE_ACTION\0\u{1}CREATE_BOT\0\u{1}DELETE_MEMBER\0\u{1}MARK_AS_UNREAD\0\u{1}GET_UPDATES_STATE\0\u{1}GET_CHAT\0\u{1}GET_UPDATES\0\u{1}UPDATE_MEMBER_ACCESS\0\u{1}SEARCH_MESSAGES\0\u{1}FORWARD_MESSAGES\0\u{1}UPDATE_CHAT_VISIBILITY\0\u{1}PIN_MESSAGE\0\u{1}UPDATE_CHAT_INFO\0\u{1}LIST_BOTS\0\u{1}REVEAL_BOT_TOKEN\0\u{1}MOVE_THREAD\0\u{1}ROTATE_BOT_TOKEN\0\u{1}UPDATE_BOT_PROFILE\0\u{1}GET_MESSAGES\0\u{1}UPDATE_DIALOG_NOTIFICATION_SETTINGS\0\u{1}READ_MESSAGES\0\u{1}REGISTER_DEVICE\0\u{1}CREATE_SUBTHREAD\0\u{1}GET_BOT_COMMANDS\0\u{1}SET_BOT_COMMANDS\0\u{1}GET_PEER_BOT_COMMANDS\0\u{1}SHOW_IN_CHAT_LIST\0\u{1}RESERVE_CHAT_IDS\0\u{1}INVOKE_MESSAGE_ACTION\0\u{1}ANSWER_MESSAGE_ACTION\0\u{1}REVOKE_SESSION\0\u{1}UPDATE_DIALOG_OPEN\0\u{1}UPDATE_DIALOG_ORDER\0\u{1}CLEAR_CHAT_HISTORY\0\u{1}DELETE_BOT\0\u{1}DELETE_MESSAGE_ATTACHMENT\0\u{1}SET_BOT_AVATAR\0\u{1}CLEAR_BOT_AVATAR\0\u{1}GET_BOT_PRESENCE\0\u{1}SET_BOT_PRESENCE_STATE\0\u{1}UPDATE_DIALOG_FOLLOW_MODE\0\u{1}GET_SESSIONS\0\u{1}CHECK_USERNAME\0\u{1}CHANGE_USERNAME\0\u{1}UPDATE_PROFILE\0\u{1}GET_SPACE_URL_PREVIEW_EXCLUSIONS\0\u{1}ADD_SPACE_URL_PREVIEW_EXCLUSION\0\u{1}REMOVE_SPACE_URL_PREVIEW_EXCLUSION\0\u{1}GET_USER_GROUPS\0\u{1}CREATE_USER_GROUP\0\u{1}UPDATE_USER_GROUP\0\u{1}DELETE_USER_GROUP\0\u{1}GET_SPACE_SETTINGS\0\u{1}TOGGLE_SPACE_GRID\0\u{1}GET_THREAD_REFERENCES\0\u{1}GET_THREAD_SUBTHREADS\0\u{1}GET_PEER_BOTS\0\u{1}GET_MY_BOT_CAPABILITIES\0\u{1}SET_MY_BOT_CAPABILITIES\0\u{1}REQUEST_BOT_CHAT_SETTINGS\0\u{1}INVOKE_BOT_CHAT_SETTINGS_ITEM\0\u{1}ANSWER_BOT_CHAT_SETTINGS\0\u{2}\u{2}GET_GRID\0\u{1}CREATE_GRID_ROOM\0\u{1}JOIN_GRID_ROOM\0\u{1}LEAVE_GRID_ROOM\0\u{1}SET_GRID_ROOM_TITLE\0\u{1}SET_GRID_ROOM_LOCKED\0\u{1}DELETE_GRID_ROOM\0\u{1}PREPARE_GRID_CONNECTION\0\u{1}SET_GRID_AVATAR_MICROPHONE_ENABLED\0\u{1}GET_GRID_HOME\0\u{1}CREATE_CLI_SESSION\0\u{1}SET_PROFILE_PHOTO\0\u{1}GET_EXTERNAL_PROFILE_PHOTO\0\u{1}GET_CHAT_TRANSCRIPT\0\u{1}SEARCH_EXTERNAL_RESOURCES\0\u{1}JOIN_PUBLIC_SPACE\0\u{1}COLLAPSE_HISTORY\0\u{1}LIST_CONNECTORS\0\u{1}PREPARE_CONNECTOR_OAUTH\0\u{1}DISCONNECT_CONNECTOR\0\u{1}SEARCH_USERS\0\u{1}INVITE_TO_INLINE\0\u{1}RESOLVE_URL_PREVIEW\0\u{1}CREATE_BOT_AGENT\0\u{1}GET_BOT_AGENT\0\u{1}LIST_BOT_AGENTS\0\u{1}CREATE_SPACE\0\u{1}DELETE_SPACE\0\u{1}LEAVE_SPACE\0\u{1}GET_CONNECTOR_CONFIG\0\u{1}SET_CONNECTOR_CONFIG\0\u{1}CREATE_EXTERNAL_TASK\0\u{1}UNREGISTER_DEVICE\0\u{1}LOG_OUT\0\u{1}CREATE_UPLOAD\0\u{1}SAVE_UPLOAD_PART\0\u{1}GET_UPLOAD_STATE\0\u{1}FINISH_UPLOAD\0\u{1}CANCEL_UPLOAD\0\u{1}UPDATE_SESSION\0\u{1}UPDATE_DIALOG_ARCHIVED\0\u{1}CREATE_DIALOG_FOLDER\0\u{1}UPDATE_DIALOG_FOLDER\0\u{1}DELETE_DIALOG_FOLDER\0\u{1}GET_SPACE\0\u{1}CONNECT_AGENT_SESSION\0\u{1}SYNC_AGENT_SESSION_MESSAGES\0\u{1}GET_AGENT_SESSION\0\u{1}UPDATE_BOT_AGENT\0\u{1}DELETE_BOT_AGENT\0\u{1}JOIN_SPACE_BY_INVITE_TOKEN\0\u{1}GET_SPACE_INVITE_LINK\0\u{1}SET_SPACE_INVITE_LINK_ENABLED\0\u{1}GET_FILE_PART\0\u{1}ACKNOWLEDGE_MESSAGES\0\u{1}GET_USERS\0\u{1}GET_BOT_SKILLS\0\u{1}GET_BOT_CONFIGURATION_CATALOG\0\u{1}UPDATE_DIALOG_TRANSLATION\0\u{1}TRANSCRIBE_VOICE_DRAFT\0\u{1}REQUEST_BOT_FILESYSTEM\0\u{1}ANSWER_BOT_FILESYSTEM\0\u{1}SET_SPACE_PHOTO\0\u{1}MOVE_GRID_CALL_HERE\0\u{1}OPEN_GRID_THREAD\0\u{1}SET_GRID_TRANSCRIPTION\0\u{1}LIST_GRID_TRANSCRIPTS\0") +} + +nonisolated extension GridTranscriptionState: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0GRID_TRANSCRIPTION_UNSPECIFIED\0\u{1}GRID_TRANSCRIPTION_STARTING\0\u{1}GRID_TRANSCRIPTION_ACTIVE\0\u{1}GRID_TRANSCRIPTION_STOPPING\0\u{1}GRID_TRANSCRIPTION_STOPPED\0\u{1}GRID_TRANSCRIPTION_INTERRUPTED\0") +} + +nonisolated extension GridTranscriptDestination: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0GRID_TRANSCRIPT_LAST\0\u{1}GRID_TRANSCRIPT_NEW\0\u{1}GRID_TRANSCRIPT_EXISTING\0") } nonisolated extension GridConnectionUnavailableReason: SwiftProtobuf._ProtoNameProviding { @@ -24717,7 +25420,7 @@ nonisolated extension MessageActionToast: SwiftProtobuf.Message, SwiftProtobuf._ nonisolated extension MessageService: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = "MessageService" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}thread_backlink\0\u{3}pinned_message\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}thread_backlink\0\u{3}pinned_message\0\u{3}grid_transcript\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -24751,6 +25454,19 @@ nonisolated extension MessageService: SwiftProtobuf.Message, SwiftProtobuf._Mess self.event = .pinnedMessage(v) } }() + case 3: try { + var v: MessageServiceGridTranscript? + var hadOneofValue = false + if let current = self.event { + hadOneofValue = true + if case .gridTranscript(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.event = .gridTranscript(v) + } + }() default: break } } @@ -24770,6 +25486,10 @@ nonisolated extension MessageService: SwiftProtobuf.Message, SwiftProtobuf._Mess guard case .pinnedMessage(let v)? = self.event else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 2) }() + case .gridTranscript?: try { + guard case .gridTranscript(let v)? = self.event else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 3) + }() case nil: break } try unknownFields.traverse(visitor: &visitor) @@ -24855,6 +25575,55 @@ nonisolated extension MessageServicePinnedMessage: SwiftProtobuf.Message, SwiftP } } +nonisolated extension MessageServiceGridTranscript: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "MessageServiceGridTranscript" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}run_id\0\u{3}segment_id\0\u{3}speaker_user_id\0\u{1}kind\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.runID) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.segmentID) }() + case 3: try { try decoder.decodeSingularInt64Field(value: &self._speakerUserID) }() + case 4: try { try decoder.decodeSingularEnumField(value: &self.kind) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if !self.runID.isEmpty { + try visitor.visitSingularStringField(value: self.runID, fieldNumber: 1) + } + if !self.segmentID.isEmpty { + try visitor.visitSingularStringField(value: self.segmentID, fieldNumber: 2) + } + try { if let v = self._speakerUserID { + try visitor.visitSingularInt64Field(value: v, fieldNumber: 3) + } }() + if self.kind != .gridTranscriptTurn { + try visitor.visitSingularEnumField(value: self.kind, fieldNumber: 4) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: MessageServiceGridTranscript, rhs: MessageServiceGridTranscript) -> Bool { + if lhs.runID != rhs.runID {return false} + if lhs.segmentID != rhs.segmentID {return false} + if lhs._speakerUserID != rhs._speakerUserID {return false} + if lhs.kind != rhs.kind {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + nonisolated extension BlockContent: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = "BlockContent" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}blocks\0") @@ -25768,7 +26537,7 @@ nonisolated extension BlockTableRow: SwiftProtobuf.Message, SwiftProtobuf._Messa nonisolated extension Message: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = "Message" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{3}from_id\0\u{3}peer_id\0\u{3}chat_id\0\u{1}message\0\u{1}out\0\u{1}date\0\u{1}mentioned\0\u{3}reply_to_msg_id\0\u{1}media\0\u{3}edit_date\0\u{3}grouped_id\0\u{1}attachments\0\u{1}reactions\0\u{3}is_sticker\0\u{1}entities\0\u{3}send_mode\0\u{3}fwd_from\0\u{1}replies\0\u{1}actions\0\u{1}rev\0\u{3}service_message\0\u{3}block_content\0\u{3}agent_session\0\u{1}subthread\0\u{4}W]\u{1}has_link\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{3}from_id\0\u{3}peer_id\0\u{3}chat_id\0\u{1}message\0\u{1}out\0\u{1}date\0\u{1}mentioned\0\u{3}reply_to_msg_id\0\u{1}media\0\u{3}edit_date\0\u{3}grouped_id\0\u{1}attachments\0\u{1}reactions\0\u{3}is_sticker\0\u{1}entities\0\u{3}send_mode\0\u{3}fwd_from\0\u{1}replies\0\u{1}actions\0\u{1}rev\0\u{3}service_message\0\u{3}block_content\0\u{3}agent_session\0\u{1}subthread\0\u{3}counts_as_unread\0\u{4}V]\u{1}has_link\0") fileprivate class _StorageClass { var _id: Int64 = 0 @@ -25797,6 +26566,7 @@ nonisolated extension Message: SwiftProtobuf.Message, SwiftProtobuf._MessageImpl var _blockContent: BlockContent? = nil var _agentSession: AgentSessionMessageInfo? = nil var _subthread: MessageSubthread? = nil + var _countsAsUnread: Bool? = nil // This property is used as the initial default value for new instances of the type. // The type itself is protecting the reference to its storage via CoW semantics. @@ -25833,6 +26603,7 @@ nonisolated extension Message: SwiftProtobuf.Message, SwiftProtobuf._MessageImpl _blockContent = source._blockContent _agentSession = source._agentSession _subthread = source._subthread + _countsAsUnread = source._countsAsUnread } } @@ -25876,6 +26647,7 @@ nonisolated extension Message: SwiftProtobuf.Message, SwiftProtobuf._MessageImpl case 23: try { try decoder.decodeSingularMessageField(value: &_storage._blockContent) }() case 24: try { try decoder.decodeSingularMessageField(value: &_storage._agentSession) }() case 25: try { try decoder.decodeSingularMessageField(value: &_storage._subthread) }() + case 26: try { try decoder.decodeSingularBoolField(value: &_storage._countsAsUnread) }() case 6000: try { try decoder.decodeSingularBoolField(value: &_storage._hasLink_p) }() default: break } @@ -25964,6 +26736,9 @@ nonisolated extension Message: SwiftProtobuf.Message, SwiftProtobuf._MessageImpl try { if let v = _storage._subthread { try visitor.visitSingularMessageField(value: v, fieldNumber: 25) } }() + try { if let v = _storage._countsAsUnread { + try visitor.visitSingularBoolField(value: v, fieldNumber: 26) + } }() try { if let v = _storage._hasLink_p { try visitor.visitSingularBoolField(value: v, fieldNumber: 6000) } }() @@ -26002,6 +26777,7 @@ nonisolated extension Message: SwiftProtobuf.Message, SwiftProtobuf._MessageImpl if _storage._blockContent != rhs_storage._blockContent {return false} if _storage._agentSession != rhs_storage._agentSession {return false} if _storage._subthread != rhs_storage._subthread {return false} + if _storage._countsAsUnread != rhs_storage._countsAsUnread {return false} return true } if !storagesAreEqual {return false} @@ -29381,7 +30157,7 @@ nonisolated extension RpcError.Code: SwiftProtobuf._ProtoNameProviding { nonisolated extension RpcCall: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = "RpcCall" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}method\0\u{1}getMe\0\u{1}getPeerPhoto\0\u{1}deleteMessages\0\u{1}sendMessage\0\u{1}getChatHistory\0\u{1}addReaction\0\u{1}deleteReaction\0\u{1}editMessage\0\u{1}createChat\0\u{1}getSpaceMembers\0\u{1}deleteChat\0\u{1}inviteToSpace\0\u{1}getChatParticipants\0\u{1}addChatParticipant\0\u{1}removeChatParticipant\0\u{1}translateMessages\0\u{1}getChats\0\u{1}updateUserSettings\0\u{1}getUserSettings\0\u{1}sendComposeAction\0\u{1}createBot\0\u{1}deleteMember\0\u{1}markAsUnread\0\u{1}getUpdatesState\0\u{1}getChat\0\u{1}getUpdates\0\u{1}updateMemberAccess\0\u{1}searchMessages\0\u{1}forwardMessages\0\u{1}updateChatVisibility\0\u{1}pinMessage\0\u{1}updateChatInfo\0\u{1}listBots\0\u{1}revealBotToken\0\u{1}moveThread\0\u{1}rotateBotToken\0\u{1}updateBotProfile\0\u{1}getMessages\0\u{1}updateDialogNotificationSettings\0\u{1}readMessages\0\u{1}registerDevice\0\u{1}createSubthread\0\u{1}getBotCommands\0\u{1}setBotCommands\0\u{1}getPeerBotCommands\0\u{1}showInChatList\0\u{1}reserveChatIds\0\u{1}invokeMessageAction\0\u{1}answerMessageAction\0\u{1}revokeSession\0\u{1}updateDialogOpen\0\u{1}updateDialogOrder\0\u{1}clearChatHistory\0\u{1}deleteBot\0\u{1}deleteMessageAttachment\0\u{1}setBotAvatar\0\u{1}clearBotAvatar\0\u{1}getBotPresence\0\u{1}setBotPresenceState\0\u{1}updateDialogFollowMode\0\u{1}getSessions\0\u{1}checkUsername\0\u{1}changeUsername\0\u{1}updateProfile\0\u{1}getSpaceUrlPreviewExclusions\0\u{1}addSpaceUrlPreviewExclusion\0\u{1}removeSpaceUrlPreviewExclusion\0\u{1}getUserGroups\0\u{1}createUserGroup\0\u{1}updateUserGroup\0\u{1}deleteUserGroup\0\u{1}getSpaceSettings\0\u{1}toggleSpaceGrid\0\u{1}getThreadReferences\0\u{1}getThreadSubthreads\0\u{1}getPeerBots\0\u{1}getMyBotCapabilities\0\u{1}setMyBotCapabilities\0\u{1}requestBotChatSettings\0\u{1}invokeBotChatSettingsItem\0\u{1}answerBotChatSettings\0\u{2}\u{2}getGrid\0\u{1}createGridRoom\0\u{1}joinGridRoom\0\u{1}leaveGridRoom\0\u{1}setGridRoomTitle\0\u{1}setGridRoomLocked\0\u{1}deleteGridRoom\0\u{1}prepareGridConnection\0\u{1}setGridAvatarMicrophoneEnabled\0\u{1}getGridHome\0\u{1}createCliSession\0\u{1}setProfilePhoto\0\u{1}getExternalProfilePhoto\0\u{1}getChatTranscript\0\u{1}searchExternalResources\0\u{1}joinPublicSpace\0\u{1}collapseHistory\0\u{1}listConnectors\0\u{1}prepareConnectorOAuth\0\u{1}disconnectConnector\0\u{1}searchUsers\0\u{1}inviteToInline\0\u{1}resolveUrlPreview\0\u{1}createBotAgent\0\u{1}getBotAgent\0\u{1}listBotAgents\0\u{1}createSpace\0\u{1}deleteSpace\0\u{1}leaveSpace\0\u{1}getConnectorConfig\0\u{1}setConnectorConfig\0\u{1}createExternalTask\0\u{1}unregisterDevice\0\u{1}logOut\0\u{1}createUpload\0\u{1}saveUploadPart\0\u{1}getUploadState\0\u{1}finishUpload\0\u{1}cancelUpload\0\u{1}updateSession\0\u{1}updateDialogArchived\0\u{1}createDialogFolder\0\u{1}updateDialogFolder\0\u{1}deleteDialogFolder\0\u{1}getSpace\0\u{1}connectAgentSession\0\u{1}syncAgentSessionMessages\0\u{1}getAgentSession\0\u{1}updateBotAgent\0\u{1}deleteBotAgent\0\u{1}joinSpaceByInviteToken\0\u{1}getSpaceInviteLink\0\u{1}setSpaceInviteLinkEnabled\0\u{1}getFilePart\0\u{1}acknowledgeMessages\0\u{1}getUsers\0\u{1}getBotSkills\0\u{1}getBotConfigurationCatalog\0\u{1}updateDialogTranslation\0\u{1}transcribeVoiceDraft\0\u{1}requestBotFilesystem\0\u{1}answerBotFilesystem\0\u{1}setSpacePhoto\0\u{c}S\u{1}\u{1}") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}method\0\u{1}getMe\0\u{1}getPeerPhoto\0\u{1}deleteMessages\0\u{1}sendMessage\0\u{1}getChatHistory\0\u{1}addReaction\0\u{1}deleteReaction\0\u{1}editMessage\0\u{1}createChat\0\u{1}getSpaceMembers\0\u{1}deleteChat\0\u{1}inviteToSpace\0\u{1}getChatParticipants\0\u{1}addChatParticipant\0\u{1}removeChatParticipant\0\u{1}translateMessages\0\u{1}getChats\0\u{1}updateUserSettings\0\u{1}getUserSettings\0\u{1}sendComposeAction\0\u{1}createBot\0\u{1}deleteMember\0\u{1}markAsUnread\0\u{1}getUpdatesState\0\u{1}getChat\0\u{1}getUpdates\0\u{1}updateMemberAccess\0\u{1}searchMessages\0\u{1}forwardMessages\0\u{1}updateChatVisibility\0\u{1}pinMessage\0\u{1}updateChatInfo\0\u{1}listBots\0\u{1}revealBotToken\0\u{1}moveThread\0\u{1}rotateBotToken\0\u{1}updateBotProfile\0\u{1}getMessages\0\u{1}updateDialogNotificationSettings\0\u{1}readMessages\0\u{1}registerDevice\0\u{1}createSubthread\0\u{1}getBotCommands\0\u{1}setBotCommands\0\u{1}getPeerBotCommands\0\u{1}showInChatList\0\u{1}reserveChatIds\0\u{1}invokeMessageAction\0\u{1}answerMessageAction\0\u{1}revokeSession\0\u{1}updateDialogOpen\0\u{1}updateDialogOrder\0\u{1}clearChatHistory\0\u{1}deleteBot\0\u{1}deleteMessageAttachment\0\u{1}setBotAvatar\0\u{1}clearBotAvatar\0\u{1}getBotPresence\0\u{1}setBotPresenceState\0\u{1}updateDialogFollowMode\0\u{1}getSessions\0\u{1}checkUsername\0\u{1}changeUsername\0\u{1}updateProfile\0\u{1}getSpaceUrlPreviewExclusions\0\u{1}addSpaceUrlPreviewExclusion\0\u{1}removeSpaceUrlPreviewExclusion\0\u{1}getUserGroups\0\u{1}createUserGroup\0\u{1}updateUserGroup\0\u{1}deleteUserGroup\0\u{1}getSpaceSettings\0\u{1}toggleSpaceGrid\0\u{1}getThreadReferences\0\u{1}getThreadSubthreads\0\u{1}getPeerBots\0\u{1}getMyBotCapabilities\0\u{1}setMyBotCapabilities\0\u{1}requestBotChatSettings\0\u{1}invokeBotChatSettingsItem\0\u{1}answerBotChatSettings\0\u{2}\u{2}getGrid\0\u{1}createGridRoom\0\u{1}joinGridRoom\0\u{1}leaveGridRoom\0\u{1}setGridRoomTitle\0\u{1}setGridRoomLocked\0\u{1}deleteGridRoom\0\u{1}prepareGridConnection\0\u{1}setGridAvatarMicrophoneEnabled\0\u{1}getGridHome\0\u{1}createCliSession\0\u{1}setProfilePhoto\0\u{1}getExternalProfilePhoto\0\u{1}getChatTranscript\0\u{1}searchExternalResources\0\u{1}joinPublicSpace\0\u{1}collapseHistory\0\u{1}listConnectors\0\u{1}prepareConnectorOAuth\0\u{1}disconnectConnector\0\u{1}searchUsers\0\u{1}inviteToInline\0\u{1}resolveUrlPreview\0\u{1}createBotAgent\0\u{1}getBotAgent\0\u{1}listBotAgents\0\u{1}createSpace\0\u{1}deleteSpace\0\u{1}leaveSpace\0\u{1}getConnectorConfig\0\u{1}setConnectorConfig\0\u{1}createExternalTask\0\u{1}unregisterDevice\0\u{1}logOut\0\u{1}createUpload\0\u{1}saveUploadPart\0\u{1}getUploadState\0\u{1}finishUpload\0\u{1}cancelUpload\0\u{1}updateSession\0\u{1}updateDialogArchived\0\u{1}createDialogFolder\0\u{1}updateDialogFolder\0\u{1}deleteDialogFolder\0\u{1}getSpace\0\u{1}connectAgentSession\0\u{1}syncAgentSessionMessages\0\u{1}getAgentSession\0\u{1}updateBotAgent\0\u{1}deleteBotAgent\0\u{1}joinSpaceByInviteToken\0\u{1}getSpaceInviteLink\0\u{1}setSpaceInviteLinkEnabled\0\u{1}getFilePart\0\u{1}acknowledgeMessages\0\u{1}getUsers\0\u{1}getBotSkills\0\u{1}getBotConfigurationCatalog\0\u{1}updateDialogTranslation\0\u{1}transcribeVoiceDraft\0\u{1}requestBotFilesystem\0\u{1}answerBotFilesystem\0\u{1}setSpacePhoto\0\u{1}moveGridCallHere\0\u{1}openGridThread\0\u{1}setGridTranscription\0\u{1}listGridTranscripts\0\u{c}S\u{1}\u{1}") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -31262,6 +32038,58 @@ nonisolated extension RpcCall: SwiftProtobuf.Message, SwiftProtobuf._MessageImpl self.input = .setSpacePhoto(v) } }() + case 147: try { + var v: MoveGridCallHereInput? + var hadOneofValue = false + if let current = self.input { + hadOneofValue = true + if case .moveGridCallHere(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.input = .moveGridCallHere(v) + } + }() + case 148: try { + var v: OpenGridThreadInput? + var hadOneofValue = false + if let current = self.input { + hadOneofValue = true + if case .openGridThread(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.input = .openGridThread(v) + } + }() + case 149: try { + var v: SetGridTranscriptionInput? + var hadOneofValue = false + if let current = self.input { + hadOneofValue = true + if case .setGridTranscription(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.input = .setGridTranscription(v) + } + }() + case 150: try { + var v: ListGridTranscriptsInput? + var hadOneofValue = false + if let current = self.input { + hadOneofValue = true + if case .listGridTranscripts(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.input = .listGridTranscripts(v) + } + }() default: break } } @@ -31852,6 +32680,22 @@ nonisolated extension RpcCall: SwiftProtobuf.Message, SwiftProtobuf._MessageImpl guard case .setSpacePhoto(let v)? = self.input else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 146) }() + case .moveGridCallHere?: try { + guard case .moveGridCallHere(let v)? = self.input else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 147) + }() + case .openGridThread?: try { + guard case .openGridThread(let v)? = self.input else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 148) + }() + case .setGridTranscription?: try { + guard case .setGridTranscription(let v)? = self.input else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 149) + }() + case .listGridTranscripts?: try { + guard case .listGridTranscripts(let v)? = self.input else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 150) + }() case nil: break } try unknownFields.traverse(visitor: &visitor) @@ -31867,7 +32711,7 @@ nonisolated extension RpcCall: SwiftProtobuf.Message, SwiftProtobuf._MessageImpl nonisolated extension RpcResult: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = "RpcResult" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}req_msg_id\0\u{1}getMe\0\u{1}getPeerPhoto\0\u{1}deleteMessages\0\u{1}sendMessage\0\u{1}getChatHistory\0\u{1}addReaction\0\u{1}deleteReaction\0\u{1}editMessage\0\u{1}createChat\0\u{1}getSpaceMembers\0\u{1}deleteChat\0\u{1}inviteToSpace\0\u{1}getChatParticipants\0\u{1}addChatParticipant\0\u{1}removeChatParticipant\0\u{1}translateMessages\0\u{1}getChats\0\u{1}updateUserSettings\0\u{1}getUserSettings\0\u{1}sendComposeAction\0\u{1}createBot\0\u{1}deleteMember\0\u{1}markAsUnread\0\u{1}getUpdatesState\0\u{1}getChat\0\u{1}getUpdates\0\u{1}updateMemberAccess\0\u{1}searchMessages\0\u{1}forwardMessages\0\u{1}updateChatVisibility\0\u{1}pinMessage\0\u{1}updateChatInfo\0\u{1}listBots\0\u{1}revealBotToken\0\u{1}moveThread\0\u{1}rotateBotToken\0\u{1}updateBotProfile\0\u{1}getMessages\0\u{1}updateDialogNotificationSettings\0\u{1}readMessages\0\u{1}registerDevice\0\u{1}createSubthread\0\u{1}getBotCommands\0\u{1}setBotCommands\0\u{1}getPeerBotCommands\0\u{1}showInChatList\0\u{1}reserveChatIds\0\u{1}invokeMessageAction\0\u{1}answerMessageAction\0\u{1}revokeSession\0\u{1}updateDialogOpen\0\u{1}updateDialogOrder\0\u{1}clearChatHistory\0\u{1}deleteBot\0\u{1}deleteMessageAttachment\0\u{1}setBotAvatar\0\u{1}clearBotAvatar\0\u{1}getBotPresence\0\u{1}setBotPresenceState\0\u{1}updateDialogFollowMode\0\u{1}getSessions\0\u{1}checkUsername\0\u{1}changeUsername\0\u{1}updateProfile\0\u{1}getSpaceUrlPreviewExclusions\0\u{1}addSpaceUrlPreviewExclusion\0\u{1}removeSpaceUrlPreviewExclusion\0\u{1}getUserGroups\0\u{1}createUserGroup\0\u{1}updateUserGroup\0\u{1}deleteUserGroup\0\u{1}getSpaceSettings\0\u{1}toggleSpaceGrid\0\u{1}getThreadReferences\0\u{1}getThreadSubthreads\0\u{1}getPeerBots\0\u{1}getMyBotCapabilities\0\u{1}setMyBotCapabilities\0\u{1}requestBotChatSettings\0\u{1}invokeBotChatSettingsItem\0\u{1}answerBotChatSettings\0\u{2}\u{2}getGrid\0\u{1}createGridRoom\0\u{1}joinGridRoom\0\u{1}leaveGridRoom\0\u{1}setGridRoomTitle\0\u{1}setGridRoomLocked\0\u{1}deleteGridRoom\0\u{1}prepareGridConnection\0\u{1}setGridAvatarMicrophoneEnabled\0\u{1}getGridHome\0\u{1}createCliSession\0\u{1}setProfilePhoto\0\u{1}getExternalProfilePhoto\0\u{1}getChatTranscript\0\u{1}searchExternalResources\0\u{1}joinPublicSpace\0\u{1}collapseHistory\0\u{1}listConnectors\0\u{1}prepareConnectorOAuth\0\u{1}disconnectConnector\0\u{1}searchUsers\0\u{1}inviteToInline\0\u{1}resolveUrlPreview\0\u{1}createBotAgent\0\u{1}getBotAgent\0\u{1}listBotAgents\0\u{1}createSpace\0\u{1}deleteSpace\0\u{1}leaveSpace\0\u{1}getConnectorConfig\0\u{1}setConnectorConfig\0\u{1}createExternalTask\0\u{1}unregisterDevice\0\u{1}logOut\0\u{1}createUpload\0\u{1}saveUploadPart\0\u{1}getUploadState\0\u{1}finishUpload\0\u{1}cancelUpload\0\u{1}updateSession\0\u{1}updateDialogArchived\0\u{1}createDialogFolder\0\u{1}updateDialogFolder\0\u{1}deleteDialogFolder\0\u{1}getSpace\0\u{1}connectAgentSession\0\u{1}syncAgentSessionMessages\0\u{1}getAgentSession\0\u{1}updateBotAgent\0\u{1}deleteBotAgent\0\u{1}joinSpaceByInviteToken\0\u{1}getSpaceInviteLink\0\u{1}setSpaceInviteLinkEnabled\0\u{1}getFilePart\0\u{1}acknowledgeMessages\0\u{1}getUsers\0\u{1}getBotSkills\0\u{1}getBotConfigurationCatalog\0\u{1}updateDialogTranslation\0\u{1}transcribeVoiceDraft\0\u{1}requestBotFilesystem\0\u{1}answerBotFilesystem\0\u{1}setSpacePhoto\0\u{c}S\u{1}\u{1}") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}req_msg_id\0\u{1}getMe\0\u{1}getPeerPhoto\0\u{1}deleteMessages\0\u{1}sendMessage\0\u{1}getChatHistory\0\u{1}addReaction\0\u{1}deleteReaction\0\u{1}editMessage\0\u{1}createChat\0\u{1}getSpaceMembers\0\u{1}deleteChat\0\u{1}inviteToSpace\0\u{1}getChatParticipants\0\u{1}addChatParticipant\0\u{1}removeChatParticipant\0\u{1}translateMessages\0\u{1}getChats\0\u{1}updateUserSettings\0\u{1}getUserSettings\0\u{1}sendComposeAction\0\u{1}createBot\0\u{1}deleteMember\0\u{1}markAsUnread\0\u{1}getUpdatesState\0\u{1}getChat\0\u{1}getUpdates\0\u{1}updateMemberAccess\0\u{1}searchMessages\0\u{1}forwardMessages\0\u{1}updateChatVisibility\0\u{1}pinMessage\0\u{1}updateChatInfo\0\u{1}listBots\0\u{1}revealBotToken\0\u{1}moveThread\0\u{1}rotateBotToken\0\u{1}updateBotProfile\0\u{1}getMessages\0\u{1}updateDialogNotificationSettings\0\u{1}readMessages\0\u{1}registerDevice\0\u{1}createSubthread\0\u{1}getBotCommands\0\u{1}setBotCommands\0\u{1}getPeerBotCommands\0\u{1}showInChatList\0\u{1}reserveChatIds\0\u{1}invokeMessageAction\0\u{1}answerMessageAction\0\u{1}revokeSession\0\u{1}updateDialogOpen\0\u{1}updateDialogOrder\0\u{1}clearChatHistory\0\u{1}deleteBot\0\u{1}deleteMessageAttachment\0\u{1}setBotAvatar\0\u{1}clearBotAvatar\0\u{1}getBotPresence\0\u{1}setBotPresenceState\0\u{1}updateDialogFollowMode\0\u{1}getSessions\0\u{1}checkUsername\0\u{1}changeUsername\0\u{1}updateProfile\0\u{1}getSpaceUrlPreviewExclusions\0\u{1}addSpaceUrlPreviewExclusion\0\u{1}removeSpaceUrlPreviewExclusion\0\u{1}getUserGroups\0\u{1}createUserGroup\0\u{1}updateUserGroup\0\u{1}deleteUserGroup\0\u{1}getSpaceSettings\0\u{1}toggleSpaceGrid\0\u{1}getThreadReferences\0\u{1}getThreadSubthreads\0\u{1}getPeerBots\0\u{1}getMyBotCapabilities\0\u{1}setMyBotCapabilities\0\u{1}requestBotChatSettings\0\u{1}invokeBotChatSettingsItem\0\u{1}answerBotChatSettings\0\u{2}\u{2}getGrid\0\u{1}createGridRoom\0\u{1}joinGridRoom\0\u{1}leaveGridRoom\0\u{1}setGridRoomTitle\0\u{1}setGridRoomLocked\0\u{1}deleteGridRoom\0\u{1}prepareGridConnection\0\u{1}setGridAvatarMicrophoneEnabled\0\u{1}getGridHome\0\u{1}createCliSession\0\u{1}setProfilePhoto\0\u{1}getExternalProfilePhoto\0\u{1}getChatTranscript\0\u{1}searchExternalResources\0\u{1}joinPublicSpace\0\u{1}collapseHistory\0\u{1}listConnectors\0\u{1}prepareConnectorOAuth\0\u{1}disconnectConnector\0\u{1}searchUsers\0\u{1}inviteToInline\0\u{1}resolveUrlPreview\0\u{1}createBotAgent\0\u{1}getBotAgent\0\u{1}listBotAgents\0\u{1}createSpace\0\u{1}deleteSpace\0\u{1}leaveSpace\0\u{1}getConnectorConfig\0\u{1}setConnectorConfig\0\u{1}createExternalTask\0\u{1}unregisterDevice\0\u{1}logOut\0\u{1}createUpload\0\u{1}saveUploadPart\0\u{1}getUploadState\0\u{1}finishUpload\0\u{1}cancelUpload\0\u{1}updateSession\0\u{1}updateDialogArchived\0\u{1}createDialogFolder\0\u{1}updateDialogFolder\0\u{1}deleteDialogFolder\0\u{1}getSpace\0\u{1}connectAgentSession\0\u{1}syncAgentSessionMessages\0\u{1}getAgentSession\0\u{1}updateBotAgent\0\u{1}deleteBotAgent\0\u{1}joinSpaceByInviteToken\0\u{1}getSpaceInviteLink\0\u{1}setSpaceInviteLinkEnabled\0\u{1}getFilePart\0\u{1}acknowledgeMessages\0\u{1}getUsers\0\u{1}getBotSkills\0\u{1}getBotConfigurationCatalog\0\u{1}updateDialogTranslation\0\u{1}transcribeVoiceDraft\0\u{1}requestBotFilesystem\0\u{1}answerBotFilesystem\0\u{1}setSpacePhoto\0\u{1}moveGridCallHere\0\u{1}openGridThread\0\u{1}setGridTranscription\0\u{1}listGridTranscripts\0\u{c}S\u{1}\u{1}") fileprivate class _StorageClass { var _reqMsgID: UInt64 = 0 @@ -33775,6 +34619,58 @@ nonisolated extension RpcResult: SwiftProtobuf.Message, SwiftProtobuf._MessageIm _storage._result = .setSpacePhoto(v) } }() + case 147: try { + var v: MoveGridCallHereResult? + var hadOneofValue = false + if let current = _storage._result { + hadOneofValue = true + if case .moveGridCallHere(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._result = .moveGridCallHere(v) + } + }() + case 148: try { + var v: OpenGridThreadResult? + var hadOneofValue = false + if let current = _storage._result { + hadOneofValue = true + if case .openGridThread(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._result = .openGridThread(v) + } + }() + case 149: try { + var v: SetGridTranscriptionResult? + var hadOneofValue = false + if let current = _storage._result { + hadOneofValue = true + if case .setGridTranscription(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._result = .setGridTranscription(v) + } + }() + case 150: try { + var v: ListGridTranscriptsResult? + var hadOneofValue = false + if let current = _storage._result { + hadOneofValue = true + if case .listGridTranscripts(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._result = .listGridTranscripts(v) + } + }() default: break } } @@ -34367,6 +35263,22 @@ nonisolated extension RpcResult: SwiftProtobuf.Message, SwiftProtobuf._MessageIm guard case .setSpacePhoto(let v)? = _storage._result else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 146) }() + case .moveGridCallHere?: try { + guard case .moveGridCallHere(let v)? = _storage._result else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 147) + }() + case .openGridThread?: try { + guard case .openGridThread(let v)? = _storage._result else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 148) + }() + case .setGridTranscription?: try { + guard case .setGridTranscription(let v)? = _storage._result else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 149) + }() + case .listGridTranscripts?: try { + guard case .listGridTranscripts(let v)? = _storage._result else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 150) + }() case nil: break } } @@ -35185,7 +36097,154 @@ nonisolated extension GridConnection: SwiftProtobuf.Message, SwiftProtobuf._Mess nonisolated extension GridRoom: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = "GridRoom" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{3}space_id\0\u{3}created_by_user_id\0\u{1}title\0\u{1}locked\0\u{3}created_at\0\u{3}updated_at\0\u{1}avatars\0\u{1}connection\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{3}space_id\0\u{3}created_by_user_id\0\u{1}title\0\u{1}locked\0\u{3}created_at\0\u{3}updated_at\0\u{1}avatars\0\u{1}connection\0\u{3}room_thread_id\0\u{3}transcription_available\0\u{1}transcription\0") + + fileprivate class _StorageClass { + var _id: Int64 = 0 + var _spaceID: Int64 = 0 + var _createdByUserID: Int64 = 0 + var _title: String? = nil + var _locked: Bool = false + var _createdAt: Int64 = 0 + var _updatedAt: Int64 = 0 + var _avatars: [GridAvatar] = [] + var _connection: GridConnection? = nil + var _roomThreadID: Int64? = nil + var _transcriptionAvailable: Bool = false + var _transcription: GridTranscription? = nil + + // This property is used as the initial default value for new instances of the type. + // The type itself is protecting the reference to its storage via CoW semantics. + // This will force a copy to be made of this reference when the first mutation occurs; + // hence, it is safe to mark this as `nonisolated(unsafe)`. + static nonisolated(unsafe) let defaultInstance = _StorageClass() + + private init() {} + + init(copying source: _StorageClass) { + _id = source._id + _spaceID = source._spaceID + _createdByUserID = source._createdByUserID + _title = source._title + _locked = source._locked + _createdAt = source._createdAt + _updatedAt = source._updatedAt + _avatars = source._avatars + _connection = source._connection + _roomThreadID = source._roomThreadID + _transcriptionAvailable = source._transcriptionAvailable + _transcription = source._transcription + } + } + + fileprivate mutating func _uniqueStorage() -> _StorageClass { + if !isKnownUniquelyReferenced(&_storage) { + _storage = _StorageClass(copying: _storage) + } + return _storage + } + + public mutating func decodeMessage(decoder: inout D) throws { + _ = _uniqueStorage() + try withExtendedLifetime(_storage) { (_storage: _StorageClass) in + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularInt64Field(value: &_storage._id) }() + case 2: try { try decoder.decodeSingularInt64Field(value: &_storage._spaceID) }() + case 3: try { try decoder.decodeSingularInt64Field(value: &_storage._createdByUserID) }() + case 4: try { try decoder.decodeSingularStringField(value: &_storage._title) }() + case 5: try { try decoder.decodeSingularBoolField(value: &_storage._locked) }() + case 6: try { try decoder.decodeSingularInt64Field(value: &_storage._createdAt) }() + case 7: try { try decoder.decodeSingularInt64Field(value: &_storage._updatedAt) }() + case 8: try { try decoder.decodeRepeatedMessageField(value: &_storage._avatars) }() + case 9: try { try decoder.decodeSingularMessageField(value: &_storage._connection) }() + case 10: try { try decoder.decodeSingularInt64Field(value: &_storage._roomThreadID) }() + case 11: try { try decoder.decodeSingularBoolField(value: &_storage._transcriptionAvailable) }() + case 12: try { try decoder.decodeSingularMessageField(value: &_storage._transcription) }() + default: break + } + } + } + } + + public func traverse(visitor: inout V) throws { + try withExtendedLifetime(_storage) { (_storage: _StorageClass) in + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if _storage._id != 0 { + try visitor.visitSingularInt64Field(value: _storage._id, fieldNumber: 1) + } + if _storage._spaceID != 0 { + try visitor.visitSingularInt64Field(value: _storage._spaceID, fieldNumber: 2) + } + if _storage._createdByUserID != 0 { + try visitor.visitSingularInt64Field(value: _storage._createdByUserID, fieldNumber: 3) + } + try { if let v = _storage._title { + try visitor.visitSingularStringField(value: v, fieldNumber: 4) + } }() + if _storage._locked != false { + try visitor.visitSingularBoolField(value: _storage._locked, fieldNumber: 5) + } + if _storage._createdAt != 0 { + try visitor.visitSingularInt64Field(value: _storage._createdAt, fieldNumber: 6) + } + if _storage._updatedAt != 0 { + try visitor.visitSingularInt64Field(value: _storage._updatedAt, fieldNumber: 7) + } + if !_storage._avatars.isEmpty { + try visitor.visitRepeatedMessageField(value: _storage._avatars, fieldNumber: 8) + } + try { if let v = _storage._connection { + try visitor.visitSingularMessageField(value: v, fieldNumber: 9) + } }() + try { if let v = _storage._roomThreadID { + try visitor.visitSingularInt64Field(value: v, fieldNumber: 10) + } }() + if _storage._transcriptionAvailable != false { + try visitor.visitSingularBoolField(value: _storage._transcriptionAvailable, fieldNumber: 11) + } + try { if let v = _storage._transcription { + try visitor.visitSingularMessageField(value: v, fieldNumber: 12) + } }() + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: GridRoom, rhs: GridRoom) -> Bool { + if lhs._storage !== rhs._storage { + let storagesAreEqual: Bool = withExtendedLifetime((lhs._storage, rhs._storage)) { (_args: (_StorageClass, _StorageClass)) in + let _storage = _args.0 + let rhs_storage = _args.1 + if _storage._id != rhs_storage._id {return false} + if _storage._spaceID != rhs_storage._spaceID {return false} + if _storage._createdByUserID != rhs_storage._createdByUserID {return false} + if _storage._title != rhs_storage._title {return false} + if _storage._locked != rhs_storage._locked {return false} + if _storage._createdAt != rhs_storage._createdAt {return false} + if _storage._updatedAt != rhs_storage._updatedAt {return false} + if _storage._avatars != rhs_storage._avatars {return false} + if _storage._connection != rhs_storage._connection {return false} + if _storage._roomThreadID != rhs_storage._roomThreadID {return false} + if _storage._transcriptionAvailable != rhs_storage._transcriptionAvailable {return false} + if _storage._transcription != rhs_storage._transcription {return false} + return true + } + if !storagesAreEqual {return false} + } + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension GridTranscription: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "GridTranscription" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}run_id\0\u{1}state\0\u{3}transcript_chat_id\0\u{1}revision\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -35193,15 +36252,10 @@ nonisolated extension GridRoom: SwiftProtobuf.Message, SwiftProtobuf._MessageImp // allocates stack space for every case branch when no optimizations are // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { - case 1: try { try decoder.decodeSingularInt64Field(value: &self.id) }() - case 2: try { try decoder.decodeSingularInt64Field(value: &self.spaceID) }() - case 3: try { try decoder.decodeSingularInt64Field(value: &self.createdByUserID) }() - case 4: try { try decoder.decodeSingularStringField(value: &self._title) }() - case 5: try { try decoder.decodeSingularBoolField(value: &self.locked) }() - case 6: try { try decoder.decodeSingularInt64Field(value: &self.createdAt) }() - case 7: try { try decoder.decodeSingularInt64Field(value: &self.updatedAt) }() - case 8: try { try decoder.decodeRepeatedMessageField(value: &self.avatars) }() - case 9: try { try decoder.decodeSingularMessageField(value: &self._connection) }() + case 1: try { try decoder.decodeSingularStringField(value: &self.runID) }() + case 2: try { try decoder.decodeSingularEnumField(value: &self.state) }() + case 3: try { try decoder.decodeSingularInt64Field(value: &self._transcriptChatID) }() + case 4: try { try decoder.decodeSingularInt32Field(value: &self.revision) }() default: break } } @@ -35212,46 +36266,309 @@ nonisolated extension GridRoom: SwiftProtobuf.Message, SwiftProtobuf._MessageImp // allocates stack space for every if/case branch local when no optimizations // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and // https://github.com/apple/swift-protobuf/issues/1182 - if self.id != 0 { - try visitor.visitSingularInt64Field(value: self.id, fieldNumber: 1) + if !self.runID.isEmpty { + try visitor.visitSingularStringField(value: self.runID, fieldNumber: 1) } - if self.spaceID != 0 { - try visitor.visitSingularInt64Field(value: self.spaceID, fieldNumber: 2) + if self.state != .gridTranscriptionUnspecified { + try visitor.visitSingularEnumField(value: self.state, fieldNumber: 2) } - if self.createdByUserID != 0 { - try visitor.visitSingularInt64Field(value: self.createdByUserID, fieldNumber: 3) + try { if let v = self._transcriptChatID { + try visitor.visitSingularInt64Field(value: v, fieldNumber: 3) + } }() + if self.revision != 0 { + try visitor.visitSingularInt32Field(value: self.revision, fieldNumber: 4) } - try { if let v = self._title { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: GridTranscription, rhs: GridTranscription) -> Bool { + if lhs.runID != rhs.runID {return false} + if lhs.state != rhs.state {return false} + if lhs._transcriptChatID != rhs._transcriptChatID {return false} + if lhs.revision != rhs.revision {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension OpenGridThreadInput: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "OpenGridThreadInput" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}room_id\0\u{3}expected_membership_id\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularInt64Field(value: &self.roomID) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.expectedMembershipID) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.roomID != 0 { + try visitor.visitSingularInt64Field(value: self.roomID, fieldNumber: 1) + } + if !self.expectedMembershipID.isEmpty { + try visitor.visitSingularStringField(value: self.expectedMembershipID, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: OpenGridThreadInput, rhs: OpenGridThreadInput) -> Bool { + if lhs.roomID != rhs.roomID {return false} + if lhs.expectedMembershipID != rhs.expectedMembershipID {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension OpenGridThreadResult: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "OpenGridThreadResult" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}chat_id\0\u{1}updates\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularInt64Field(value: &self.chatID) }() + case 2: try { try decoder.decodeRepeatedMessageField(value: &self.updates) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.chatID != 0 { + try visitor.visitSingularInt64Field(value: self.chatID, fieldNumber: 1) + } + if !self.updates.isEmpty { + try visitor.visitRepeatedMessageField(value: self.updates, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: OpenGridThreadResult, rhs: OpenGridThreadResult) -> Bool { + if lhs.chatID != rhs.chatID {return false} + if lhs.updates != rhs.updates {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension SetGridTranscriptionInput: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "SetGridTranscriptionInput" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}room_id\0\u{3}expected_membership_id\0\u{3}expected_generation\0\u{3}expected_run_id\0\u{3}expected_revision\0\u{1}enabled\0\u{1}destination\0\u{3}transcript_chat_id\0\u{3}request_id\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularInt64Field(value: &self.roomID) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.expectedMembershipID) }() + case 3: try { try decoder.decodeSingularInt32Field(value: &self.expectedGeneration) }() + case 4: try { try decoder.decodeSingularStringField(value: &self._expectedRunID) }() + case 5: try { try decoder.decodeSingularInt32Field(value: &self.expectedRevision) }() + case 6: try { try decoder.decodeSingularBoolField(value: &self.enabled) }() + case 7: try { try decoder.decodeSingularEnumField(value: &self.destination) }() + case 8: try { try decoder.decodeSingularInt64Field(value: &self._transcriptChatID) }() + case 9: try { try decoder.decodeSingularStringField(value: &self.requestID) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if self.roomID != 0 { + try visitor.visitSingularInt64Field(value: self.roomID, fieldNumber: 1) + } + if !self.expectedMembershipID.isEmpty { + try visitor.visitSingularStringField(value: self.expectedMembershipID, fieldNumber: 2) + } + if self.expectedGeneration != 0 { + try visitor.visitSingularInt32Field(value: self.expectedGeneration, fieldNumber: 3) + } + try { if let v = self._expectedRunID { try visitor.visitSingularStringField(value: v, fieldNumber: 4) } }() - if self.locked != false { - try visitor.visitSingularBoolField(value: self.locked, fieldNumber: 5) + if self.expectedRevision != 0 { + try visitor.visitSingularInt32Field(value: self.expectedRevision, fieldNumber: 5) } - if self.createdAt != 0 { - try visitor.visitSingularInt64Field(value: self.createdAt, fieldNumber: 6) + if self.enabled != false { + try visitor.visitSingularBoolField(value: self.enabled, fieldNumber: 6) } - if self.updatedAt != 0 { - try visitor.visitSingularInt64Field(value: self.updatedAt, fieldNumber: 7) + if self.destination != .gridTranscriptLast { + try visitor.visitSingularEnumField(value: self.destination, fieldNumber: 7) } - if !self.avatars.isEmpty { - try visitor.visitRepeatedMessageField(value: self.avatars, fieldNumber: 8) + try { if let v = self._transcriptChatID { + try visitor.visitSingularInt64Field(value: v, fieldNumber: 8) + } }() + if !self.requestID.isEmpty { + try visitor.visitSingularStringField(value: self.requestID, fieldNumber: 9) } - try { if let v = self._connection { - try visitor.visitSingularMessageField(value: v, fieldNumber: 9) + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: SetGridTranscriptionInput, rhs: SetGridTranscriptionInput) -> Bool { + if lhs.roomID != rhs.roomID {return false} + if lhs.expectedMembershipID != rhs.expectedMembershipID {return false} + if lhs.expectedGeneration != rhs.expectedGeneration {return false} + if lhs._expectedRunID != rhs._expectedRunID {return false} + if lhs.expectedRevision != rhs.expectedRevision {return false} + if lhs.enabled != rhs.enabled {return false} + if lhs.destination != rhs.destination {return false} + if lhs._transcriptChatID != rhs._transcriptChatID {return false} + if lhs.requestID != rhs.requestID {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension SetGridTranscriptionResult: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "SetGridTranscriptionResult" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}grid\0\u{1}updates\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularMessageField(value: &self._grid) }() + case 2: try { try decoder.decodeRepeatedMessageField(value: &self.updates) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._grid { + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) } }() + if !self.updates.isEmpty { + try visitor.visitRepeatedMessageField(value: self.updates, fieldNumber: 2) + } try unknownFields.traverse(visitor: &visitor) } - public static func ==(lhs: GridRoom, rhs: GridRoom) -> Bool { - if lhs.id != rhs.id {return false} - if lhs.spaceID != rhs.spaceID {return false} - if lhs.createdByUserID != rhs.createdByUserID {return false} - if lhs._title != rhs._title {return false} - if lhs.locked != rhs.locked {return false} - if lhs.createdAt != rhs.createdAt {return false} - if lhs.updatedAt != rhs.updatedAt {return false} - if lhs.avatars != rhs.avatars {return false} - if lhs._connection != rhs._connection {return false} + public static func ==(lhs: SetGridTranscriptionResult, rhs: SetGridTranscriptionResult) -> Bool { + if lhs._grid != rhs._grid {return false} + if lhs.updates != rhs.updates {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension ListGridTranscriptsInput: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "ListGridTranscriptsInput" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}room_id\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularInt64Field(value: &self.roomID) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.roomID != 0 { + try visitor.visitSingularInt64Field(value: self.roomID, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: ListGridTranscriptsInput, rhs: ListGridTranscriptsInput) -> Bool { + if lhs.roomID != rhs.roomID {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension GridTranscriptDestinationInfo: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "GridTranscriptDestinationInfo" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}transcript_chat_id\0\u{1}title\0\u{1}busy\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularInt64Field(value: &self.transcriptChatID) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.title) }() + case 3: try { try decoder.decodeSingularBoolField(value: &self.busy) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.transcriptChatID != 0 { + try visitor.visitSingularInt64Field(value: self.transcriptChatID, fieldNumber: 1) + } + if !self.title.isEmpty { + try visitor.visitSingularStringField(value: self.title, fieldNumber: 2) + } + if self.busy != false { + try visitor.visitSingularBoolField(value: self.busy, fieldNumber: 3) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: GridTranscriptDestinationInfo, rhs: GridTranscriptDestinationInfo) -> Bool { + if lhs.transcriptChatID != rhs.transcriptChatID {return false} + if lhs.title != rhs.title {return false} + if lhs.busy != rhs.busy {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension ListGridTranscriptsResult: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "ListGridTranscriptsResult" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}transcripts\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeRepeatedMessageField(value: &self.transcripts) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.transcripts.isEmpty { + try visitor.visitRepeatedMessageField(value: self.transcripts, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: ListGridTranscriptsResult, rhs: ListGridTranscriptsResult) -> Bool { + if lhs.transcripts != rhs.transcripts {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -35259,7 +36576,7 @@ nonisolated extension GridRoom: SwiftProtobuf.Message, SwiftProtobuf._MessageImp nonisolated extension Grid: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = "Grid" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}space_id\0\u{1}enabled\0\u{1}rooms\0\u{3}current_room_id\0\u{1}revision\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}space_id\0\u{1}enabled\0\u{1}rooms\0\u{3}current_room_id\0\u{1}revision\0\u{3}call_transfer_enabled\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -35272,6 +36589,7 @@ nonisolated extension Grid: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme case 3: try { try decoder.decodeRepeatedMessageField(value: &self.rooms) }() case 4: try { try decoder.decodeSingularInt64Field(value: &self._currentRoomID) }() case 5: try { try decoder.decodeSingularInt64Field(value: &self.revision) }() + case 6: try { try decoder.decodeSingularBoolField(value: &self.callTransferEnabled) }() default: break } } @@ -35297,6 +36615,9 @@ nonisolated extension Grid: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme if self.revision != 0 { try visitor.visitSingularInt64Field(value: self.revision, fieldNumber: 5) } + if self.callTransferEnabled != false { + try visitor.visitSingularBoolField(value: self.callTransferEnabled, fieldNumber: 6) + } try unknownFields.traverse(visitor: &visitor) } @@ -35306,6 +36627,62 @@ nonisolated extension Grid: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme if lhs.rooms != rhs.rooms {return false} if lhs._currentRoomID != rhs._currentRoomID {return false} if lhs.revision != rhs.revision {return false} + if lhs.callTransferEnabled != rhs.callTransferEnabled {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension GridCurrentCall: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "GridCurrentCall" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}call_id\0\u{3}space_id\0\u{3}room_id\0\u{3}membership_id\0\u{3}owned_by_current_session\0\u{3}owner_client_type\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.callID) }() + case 2: try { try decoder.decodeSingularInt64Field(value: &self.spaceID) }() + case 3: try { try decoder.decodeSingularInt64Field(value: &self.roomID) }() + case 4: try { try decoder.decodeSingularStringField(value: &self.membershipID) }() + case 5: try { try decoder.decodeSingularBoolField(value: &self.ownedByCurrentSession) }() + case 6: try { try decoder.decodeSingularStringField(value: &self.ownerClientType) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.callID.isEmpty { + try visitor.visitSingularStringField(value: self.callID, fieldNumber: 1) + } + if self.spaceID != 0 { + try visitor.visitSingularInt64Field(value: self.spaceID, fieldNumber: 2) + } + if self.roomID != 0 { + try visitor.visitSingularInt64Field(value: self.roomID, fieldNumber: 3) + } + if !self.membershipID.isEmpty { + try visitor.visitSingularStringField(value: self.membershipID, fieldNumber: 4) + } + if self.ownedByCurrentSession != false { + try visitor.visitSingularBoolField(value: self.ownedByCurrentSession, fieldNumber: 5) + } + if !self.ownerClientType.isEmpty { + try visitor.visitSingularStringField(value: self.ownerClientType, fieldNumber: 6) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: GridCurrentCall, rhs: GridCurrentCall) -> Bool { + if lhs.callID != rhs.callID {return false} + if lhs.spaceID != rhs.spaceID {return false} + if lhs.roomID != rhs.roomID {return false} + if lhs.membershipID != rhs.membershipID {return false} + if lhs.ownedByCurrentSession != rhs.ownedByCurrentSession {return false} + if lhs.ownerClientType != rhs.ownerClientType {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -35313,7 +36690,7 @@ nonisolated extension Grid: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme nonisolated extension GridConnectionCredentials: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = "GridConnectionCredentials" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}connection\0\u{3}server_url\0\u{3}participant_identity\0\u{1}token\0\u{3}expires_at\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}connection\0\u{3}server_url\0\u{3}participant_identity\0\u{1}token\0\u{3}expires_at\0\u{3}call_id\0\u{3}membership_id\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -35326,6 +36703,8 @@ nonisolated extension GridConnectionCredentials: SwiftProtobuf.Message, SwiftPro case 3: try { try decoder.decodeSingularStringField(value: &self.participantIdentity) }() case 4: try { try decoder.decodeSingularStringField(value: &self.token) }() case 5: try { try decoder.decodeSingularInt64Field(value: &self.expiresAt) }() + case 6: try { try decoder.decodeSingularStringField(value: &self.callID) }() + case 7: try { try decoder.decodeSingularStringField(value: &self.membershipID) }() default: break } } @@ -35351,6 +36730,12 @@ nonisolated extension GridConnectionCredentials: SwiftProtobuf.Message, SwiftPro if self.expiresAt != 0 { try visitor.visitSingularInt64Field(value: self.expiresAt, fieldNumber: 5) } + if !self.callID.isEmpty { + try visitor.visitSingularStringField(value: self.callID, fieldNumber: 6) + } + if !self.membershipID.isEmpty { + try visitor.visitSingularStringField(value: self.membershipID, fieldNumber: 7) + } try unknownFields.traverse(visitor: &visitor) } @@ -35360,6 +36745,8 @@ nonisolated extension GridConnectionCredentials: SwiftProtobuf.Message, SwiftPro if lhs.participantIdentity != rhs.participantIdentity {return false} if lhs.token != rhs.token {return false} if lhs.expiresAt != rhs.expiresAt {return false} + if lhs.callID != rhs.callID {return false} + if lhs.membershipID != rhs.membershipID {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -35367,7 +36754,7 @@ nonisolated extension GridConnectionCredentials: SwiftProtobuf.Message, SwiftPro nonisolated extension GetGridInput: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = "GetGridInput" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}space_id\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}space_id\0\u{3}expected_membership_id\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -35376,20 +36763,29 @@ nonisolated extension GetGridInput: SwiftProtobuf.Message, SwiftProtobuf._Messag // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeSingularInt64Field(value: &self.spaceID) }() + case 2: try { try decoder.decodeSingularStringField(value: &self._expectedMembershipID) }() default: break } } } public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 if self.spaceID != 0 { try visitor.visitSingularInt64Field(value: self.spaceID, fieldNumber: 1) } + try { if let v = self._expectedMembershipID { + try visitor.visitSingularStringField(value: v, fieldNumber: 2) + } }() try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: GetGridInput, rhs: GetGridInput) -> Bool { if lhs.spaceID != rhs.spaceID {return false} + if lhs._expectedMembershipID != rhs._expectedMembershipID {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -35397,7 +36793,7 @@ nonisolated extension GetGridInput: SwiftProtobuf.Message, SwiftProtobuf._Messag nonisolated extension GetGridResult: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = "GetGridResult" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}grid\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}grid\0\u{3}current_call\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -35406,6 +36802,7 @@ nonisolated extension GetGridResult: SwiftProtobuf.Message, SwiftProtobuf._Messa // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeSingularMessageField(value: &self._grid) }() + case 2: try { try decoder.decodeSingularMessageField(value: &self._currentCall) }() default: break } } @@ -35419,11 +36816,15 @@ nonisolated extension GetGridResult: SwiftProtobuf.Message, SwiftProtobuf._Messa try { if let v = self._grid { try visitor.visitSingularMessageField(value: v, fieldNumber: 1) } }() + try { if let v = self._currentCall { + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + } }() try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: GetGridResult, rhs: GetGridResult) -> Bool { if lhs._grid != rhs._grid {return false} + if lhs._currentCall != rhs._currentCall {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -35495,7 +36896,7 @@ nonisolated extension GetGridHomeInput: SwiftProtobuf.Message, SwiftProtobuf._Me nonisolated extension GetGridHomeResult: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = "GetGridHomeResult" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}spaces\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}spaces\0\u{3}current_call\0\u{3}call_transfer_enabled\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -35504,20 +36905,34 @@ nonisolated extension GetGridHomeResult: SwiftProtobuf.Message, SwiftProtobuf._M // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeRepeatedMessageField(value: &self.spaces) }() + case 2: try { try decoder.decodeSingularMessageField(value: &self._currentCall) }() + case 3: try { try decoder.decodeSingularBoolField(value: &self.callTransferEnabled) }() default: break } } } public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 if !self.spaces.isEmpty { try visitor.visitRepeatedMessageField(value: self.spaces, fieldNumber: 1) } + try { if let v = self._currentCall { + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + } }() + if self.callTransferEnabled != false { + try visitor.visitSingularBoolField(value: self.callTransferEnabled, fieldNumber: 3) + } try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: GetGridHomeResult, rhs: GetGridHomeResult) -> Bool { if lhs.spaces != rhs.spaces {return false} + if lhs._currentCall != rhs._currentCall {return false} + if lhs.callTransferEnabled != rhs.callTransferEnabled {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -35525,7 +36940,7 @@ nonisolated extension GetGridHomeResult: SwiftProtobuf.Message, SwiftProtobuf._M nonisolated extension CreateGridRoomInput: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = "CreateGridRoomInput" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}space_id\0\u{3}microphone_enabled\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}space_id\0\u{3}microphone_enabled\0\u{3}expected_membership_id\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -35535,6 +36950,7 @@ nonisolated extension CreateGridRoomInput: SwiftProtobuf.Message, SwiftProtobuf. switch fieldNumber { case 1: try { try decoder.decodeSingularInt64Field(value: &self.spaceID) }() case 2: try { try decoder.decodeSingularBoolField(value: &self._microphoneEnabled) }() + case 3: try { try decoder.decodeSingularStringField(value: &self._expectedMembershipID) }() default: break } } @@ -35551,12 +36967,16 @@ nonisolated extension CreateGridRoomInput: SwiftProtobuf.Message, SwiftProtobuf. try { if let v = self._microphoneEnabled { try visitor.visitSingularBoolField(value: v, fieldNumber: 2) } }() + try { if let v = self._expectedMembershipID { + try visitor.visitSingularStringField(value: v, fieldNumber: 3) + } }() try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: CreateGridRoomInput, rhs: CreateGridRoomInput) -> Bool { if lhs.spaceID != rhs.spaceID {return false} if lhs._microphoneEnabled != rhs._microphoneEnabled {return false} + if lhs._expectedMembershipID != rhs._expectedMembershipID {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -35564,7 +36984,7 @@ nonisolated extension CreateGridRoomInput: SwiftProtobuf.Message, SwiftProtobuf. nonisolated extension CreateGridRoomResult: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = "CreateGridRoomResult" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}grids\0\u{1}connection\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}grids\0\u{1}connection\0\u{3}current_call\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -35574,6 +36994,7 @@ nonisolated extension CreateGridRoomResult: SwiftProtobuf.Message, SwiftProtobuf switch fieldNumber { case 1: try { try decoder.decodeRepeatedMessageField(value: &self.grids) }() case 2: try { try decoder.decodeSingularMessageField(value: &self._connection) }() + case 3: try { try decoder.decodeSingularMessageField(value: &self._currentCall) }() default: break } } @@ -35590,12 +37011,16 @@ nonisolated extension CreateGridRoomResult: SwiftProtobuf.Message, SwiftProtobuf try { if let v = self._connection { try visitor.visitSingularMessageField(value: v, fieldNumber: 2) } }() + try { if let v = self._currentCall { + try visitor.visitSingularMessageField(value: v, fieldNumber: 3) + } }() try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: CreateGridRoomResult, rhs: CreateGridRoomResult) -> Bool { if lhs.grids != rhs.grids {return false} if lhs._connection != rhs._connection {return false} + if lhs._currentCall != rhs._currentCall {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -35603,7 +37028,7 @@ nonisolated extension CreateGridRoomResult: SwiftProtobuf.Message, SwiftProtobuf nonisolated extension JoinGridRoomInput: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = "JoinGridRoomInput" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}room_id\0\u{3}microphone_enabled\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}room_id\0\u{3}microphone_enabled\0\u{3}expected_membership_id\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -35613,6 +37038,7 @@ nonisolated extension JoinGridRoomInput: SwiftProtobuf.Message, SwiftProtobuf._M switch fieldNumber { case 1: try { try decoder.decodeSingularInt64Field(value: &self.roomID) }() case 2: try { try decoder.decodeSingularBoolField(value: &self._microphoneEnabled) }() + case 3: try { try decoder.decodeSingularStringField(value: &self._expectedMembershipID) }() default: break } } @@ -35629,12 +37055,16 @@ nonisolated extension JoinGridRoomInput: SwiftProtobuf.Message, SwiftProtobuf._M try { if let v = self._microphoneEnabled { try visitor.visitSingularBoolField(value: v, fieldNumber: 2) } }() + try { if let v = self._expectedMembershipID { + try visitor.visitSingularStringField(value: v, fieldNumber: 3) + } }() try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: JoinGridRoomInput, rhs: JoinGridRoomInput) -> Bool { if lhs.roomID != rhs.roomID {return false} if lhs._microphoneEnabled != rhs._microphoneEnabled {return false} + if lhs._expectedMembershipID != rhs._expectedMembershipID {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -35642,7 +37072,7 @@ nonisolated extension JoinGridRoomInput: SwiftProtobuf.Message, SwiftProtobuf._M nonisolated extension JoinGridRoomResult: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = "JoinGridRoomResult" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}grids\0\u{1}connection\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}grids\0\u{1}connection\0\u{3}current_call\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -35652,6 +37082,7 @@ nonisolated extension JoinGridRoomResult: SwiftProtobuf.Message, SwiftProtobuf._ switch fieldNumber { case 1: try { try decoder.decodeRepeatedMessageField(value: &self.grids) }() case 2: try { try decoder.decodeSingularMessageField(value: &self._connection) }() + case 3: try { try decoder.decodeSingularMessageField(value: &self._currentCall) }() default: break } } @@ -35668,12 +37099,16 @@ nonisolated extension JoinGridRoomResult: SwiftProtobuf.Message, SwiftProtobuf._ try { if let v = self._connection { try visitor.visitSingularMessageField(value: v, fieldNumber: 2) } }() + try { if let v = self._currentCall { + try visitor.visitSingularMessageField(value: v, fieldNumber: 3) + } }() try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: JoinGridRoomResult, rhs: JoinGridRoomResult) -> Bool { if lhs.grids != rhs.grids {return false} if lhs._connection != rhs._connection {return false} + if lhs._currentCall != rhs._currentCall {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -35681,7 +37116,7 @@ nonisolated extension JoinGridRoomResult: SwiftProtobuf.Message, SwiftProtobuf._ nonisolated extension LeaveGridRoomInput: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = "LeaveGridRoomInput" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}expected_room_id\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}expected_room_id\0\u{3}expected_membership_id\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -35690,20 +37125,29 @@ nonisolated extension LeaveGridRoomInput: SwiftProtobuf.Message, SwiftProtobuf._ // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeSingularInt64Field(value: &self.expectedRoomID) }() + case 2: try { try decoder.decodeSingularStringField(value: &self._expectedMembershipID) }() default: break } } } public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 if self.expectedRoomID != 0 { try visitor.visitSingularInt64Field(value: self.expectedRoomID, fieldNumber: 1) } + try { if let v = self._expectedMembershipID { + try visitor.visitSingularStringField(value: v, fieldNumber: 2) + } }() try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: LeaveGridRoomInput, rhs: LeaveGridRoomInput) -> Bool { if lhs.expectedRoomID != rhs.expectedRoomID {return false} + if lhs._expectedMembershipID != rhs._expectedMembershipID {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -35711,7 +37155,7 @@ nonisolated extension LeaveGridRoomInput: SwiftProtobuf.Message, SwiftProtobuf._ nonisolated extension LeaveGridRoomResult: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = "LeaveGridRoomResult" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}grids\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}grids\0\u{3}current_call\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -35720,20 +37164,155 @@ nonisolated extension LeaveGridRoomResult: SwiftProtobuf.Message, SwiftProtobuf. // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeRepeatedMessageField(value: &self.grids) }() + case 2: try { try decoder.decodeSingularMessageField(value: &self._currentCall) }() default: break } } } public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 if !self.grids.isEmpty { try visitor.visitRepeatedMessageField(value: self.grids, fieldNumber: 1) } + try { if let v = self._currentCall { + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + } }() try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: LeaveGridRoomResult, rhs: LeaveGridRoomResult) -> Bool { if lhs.grids != rhs.grids {return false} + if lhs._currentCall != rhs._currentCall {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension MoveGridCallHereInput: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "MoveGridCallHereInput" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}call_id\0\u{3}expected_membership_id\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.callID) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.expectedMembershipID) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.callID.isEmpty { + try visitor.visitSingularStringField(value: self.callID, fieldNumber: 1) + } + if !self.expectedMembershipID.isEmpty { + try visitor.visitSingularStringField(value: self.expectedMembershipID, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: MoveGridCallHereInput, rhs: MoveGridCallHereInput) -> Bool { + if lhs.callID != rhs.callID {return false} + if lhs.expectedMembershipID != rhs.expectedMembershipID {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension MoveGridCallHereResult: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "MoveGridCallHereResult" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}grids\0\u{1}connection\0\u{3}current_call\0\u{1}moved\0") + + fileprivate class _StorageClass { + var _grids: [Grid] = [] + var _connection: GridConnectionCredentials? = nil + var _currentCall: GridCurrentCall? = nil + var _moved: Bool = false + + // This property is used as the initial default value for new instances of the type. + // The type itself is protecting the reference to its storage via CoW semantics. + // This will force a copy to be made of this reference when the first mutation occurs; + // hence, it is safe to mark this as `nonisolated(unsafe)`. + static nonisolated(unsafe) let defaultInstance = _StorageClass() + + private init() {} + + init(copying source: _StorageClass) { + _grids = source._grids + _connection = source._connection + _currentCall = source._currentCall + _moved = source._moved + } + } + + fileprivate mutating func _uniqueStorage() -> _StorageClass { + if !isKnownUniquelyReferenced(&_storage) { + _storage = _StorageClass(copying: _storage) + } + return _storage + } + + public mutating func decodeMessage(decoder: inout D) throws { + _ = _uniqueStorage() + try withExtendedLifetime(_storage) { (_storage: _StorageClass) in + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeRepeatedMessageField(value: &_storage._grids) }() + case 2: try { try decoder.decodeSingularMessageField(value: &_storage._connection) }() + case 3: try { try decoder.decodeSingularMessageField(value: &_storage._currentCall) }() + case 4: try { try decoder.decodeSingularBoolField(value: &_storage._moved) }() + default: break + } + } + } + } + + public func traverse(visitor: inout V) throws { + try withExtendedLifetime(_storage) { (_storage: _StorageClass) in + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if !_storage._grids.isEmpty { + try visitor.visitRepeatedMessageField(value: _storage._grids, fieldNumber: 1) + } + try { if let v = _storage._connection { + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + } }() + try { if let v = _storage._currentCall { + try visitor.visitSingularMessageField(value: v, fieldNumber: 3) + } }() + if _storage._moved != false { + try visitor.visitSingularBoolField(value: _storage._moved, fieldNumber: 4) + } + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: MoveGridCallHereResult, rhs: MoveGridCallHereResult) -> Bool { + if lhs._storage !== rhs._storage { + let storagesAreEqual: Bool = withExtendedLifetime((lhs._storage, rhs._storage)) { (_args: (_StorageClass, _StorageClass)) in + let _storage = _args.0 + let rhs_storage = _args.1 + if _storage._grids != rhs_storage._grids {return false} + if _storage._connection != rhs_storage._connection {return false} + if _storage._currentCall != rhs_storage._currentCall {return false} + if _storage._moved != rhs_storage._moved {return false} + return true + } + if !storagesAreEqual {return false} + } if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -35943,7 +37522,7 @@ nonisolated extension DeleteGridRoomResult: SwiftProtobuf.Message, SwiftProtobuf nonisolated extension PrepareGridConnectionInput: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = "PrepareGridConnectionInput" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}room_id\0\u{1}generation\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}room_id\0\u{1}generation\0\u{3}expected_membership_id\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -35953,24 +37532,33 @@ nonisolated extension PrepareGridConnectionInput: SwiftProtobuf.Message, SwiftPr switch fieldNumber { case 1: try { try decoder.decodeSingularInt64Field(value: &self.roomID) }() case 2: try { try decoder.decodeSingularInt32Field(value: &self.generation) }() + case 3: try { try decoder.decodeSingularStringField(value: &self._expectedMembershipID) }() default: break } } } public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 if self.roomID != 0 { try visitor.visitSingularInt64Field(value: self.roomID, fieldNumber: 1) } if self.generation != 0 { try visitor.visitSingularInt32Field(value: self.generation, fieldNumber: 2) } + try { if let v = self._expectedMembershipID { + try visitor.visitSingularStringField(value: v, fieldNumber: 3) + } }() try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: PrepareGridConnectionInput, rhs: PrepareGridConnectionInput) -> Bool { if lhs.roomID != rhs.roomID {return false} if lhs.generation != rhs.generation {return false} + if lhs._expectedMembershipID != rhs._expectedMembershipID {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -36017,7 +37605,7 @@ nonisolated extension PrepareGridConnectionResult: SwiftProtobuf.Message, SwiftP nonisolated extension SetGridAvatarMicrophoneEnabledInput: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = "SetGridAvatarMicrophoneEnabledInput" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}expected_room_id\0\u{1}enabled\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}expected_room_id\0\u{1}enabled\0\u{3}expected_membership_id\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -36027,24 +37615,33 @@ nonisolated extension SetGridAvatarMicrophoneEnabledInput: SwiftProtobuf.Message switch fieldNumber { case 1: try { try decoder.decodeSingularInt64Field(value: &self.expectedRoomID) }() case 2: try { try decoder.decodeSingularBoolField(value: &self.enabled) }() + case 3: try { try decoder.decodeSingularStringField(value: &self._expectedMembershipID) }() default: break } } } public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 if self.expectedRoomID != 0 { try visitor.visitSingularInt64Field(value: self.expectedRoomID, fieldNumber: 1) } if self.enabled != false { try visitor.visitSingularBoolField(value: self.enabled, fieldNumber: 2) } + try { if let v = self._expectedMembershipID { + try visitor.visitSingularStringField(value: v, fieldNumber: 3) + } }() try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: SetGridAvatarMicrophoneEnabledInput, rhs: SetGridAvatarMicrophoneEnabledInput) -> Bool { if lhs.expectedRoomID != rhs.expectedRoomID {return false} if lhs.enabled != rhs.enabled {return false} + if lhs._expectedMembershipID != rhs._expectedMembershipID {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/apple/InlineKit/Sources/InlineRTC/Engine/GridAudioEngine.swift b/apple/InlineKit/Sources/InlineRTC/Engine/GridAudioEngine.swift index 372187012..16b4eaf00 100644 --- a/apple/InlineKit/Sources/InlineRTC/Engine/GridAudioEngine.swift +++ b/apple/InlineKit/Sources/InlineRTC/Engine/GridAudioEngine.swift @@ -12,6 +12,11 @@ protocol GridAudioDriver: Sendable { var recordingStartsWithMicrophoneSender: Bool { get } func configure(_ configuration: InlineRTCConfiguration) async throws + func setMediaDemandActive(_ active: Bool, epoch: UInt64) async throws + /// Provider operations and room retirements have all returned. Direction + /// idle alone cannot transfer AVAudioSession writer authority on iOS. + func mediaDidQuiesce(epoch: UInt64) async + func resumeListeningAfterSafetyPause() async throws /// Opens a new media epoch after a completed terminal barrier. This is not /// capture demand; it only allows the next WebRTC transport to invoke the /// custom device lifecycle. @@ -63,6 +68,9 @@ enum GridAudioPlayoutRecoveryDeferral: String, Equatable, Sendable { } extension GridAudioDriver { + func setMediaDemandActive(_: Bool, epoch _: UInt64) async throws {} + func mediaDidQuiesce(epoch _: UInt64) async {} + func resumeListeningAfterSafetyPause() async throws {} var preparationRequiresRTCTransport: Bool { false } var recordingStartsWithMicrophoneSender: Bool { false } @@ -184,6 +192,8 @@ actor GridAudioEngine { private var desiredOutputVolume: Float = 1 private var lastTransitionMilliseconds: Int? private var microphonePermission: InlineRTCMicrophonePermission = .notDetermined + private var safetyPauseRevision: UInt64 = 0 + private var isSafetyPaused = false /// Configuration/prepare failures require an explicit retry. Input-route /// failures have their own narrower suspension and never block leave/stop. private var engineReconcileSuspended = false @@ -507,10 +517,22 @@ actor GridAudioEngine { !rtcTransportReady } + func setMediaDemandActive(_ active: Bool, epoch: UInt64) async throws { + try await driver.setMediaDemandActive(active, epoch: epoch) + } + + func mediaDidQuiesce(epoch: UInt64) async { + await driver.mediaDidQuiesce(epoch: epoch) + } + func rtcTransportPreparationGeneration() -> UInt64 { rtcTransportGeneration } + func rtcTransportRequiresPreparation() -> Bool { + driver.preparationRequiresRTCTransport + } + /// Closes capture preparation while a fresh provider room is being created. /// A custom M144 ADM can legitimately terminate after the previous room /// releases its last media channel; only the matching new connect may reopen @@ -650,6 +672,14 @@ actor GridAudioEngine { } func retry() async { + let outputResume = await mutateDriver("resume-listening") { [driver] in + try await driver.resumeListeningAfterSafetyPause() + } + if case let .failure(error) = outputResume { + state = .failed(error.localizedDescription) + emitSnapshot() + return + } microphonePermission = await permissionDriver.status() engineReconcileSuspended = false cancelInputRouteRetry(resetAttempt: true) @@ -1785,6 +1815,36 @@ actor GridAudioEngine { switch event { case .devicesChanged: deviceListChanged() + case .safetyPaused: + isSafetyPaused = true + safetyPauseRevision &+= 1 + captureLeases.removeAll() + keepPreparedThroughCooldown = false + cooldownGeneration &+= 1 + recoveryDemandGeneration &+= 1 + engineRecoveryTask?.cancel() + engineRecoveryTask = nil + engineRecoveryPending = false + lifetimeHealthTask?.cancel() + lifetimeHealthTask = nil + engineReconcileSuspended = true + let health = await driver.runtimeHealth() + observeRuntimeHealth(health) + appliedPrepared = health.isRecording + captureEngineHealthy = false + state = health.isRecording + ? .failed("Audio could not pause safely. Leave Grid and try again.") + : .cold + emitSnapshot() + case .listeningResumed: + isSafetyPaused = false + engineReconcileSuspended = false + observeRuntimeHealth(await driver.runtimeHealth()) + appliedPrepared = false + captureEngineHealthy = false + updateStableState() + emitSnapshot() + scheduleReconcile() case let .engineStarting(playout, recording): if !engineRecoveryInFlight { engineRecoveryTask?.cancel() @@ -1796,12 +1856,16 @@ actor GridAudioEngine { scheduleEngineHealthCheck() case let .engineStopped(playout, recording), let .engineDisabled(playout, recording): + observeRuntimeHealth(await driver.runtimeHealth()) + emitSnapshot() await audioEngineStopped(playout: playout, recording: recording) case let .expectedEngineStop(playout, recording), let .expectedEngineDisable(playout, recording): log.debug( "GRID_ENGINE phase=audio_engine_transition_expected playout=\(playout) recording=\(recording)" ) + observeRuntimeHealth(await driver.runtimeHealth()) + emitSnapshot() } } @@ -1825,7 +1889,9 @@ actor GridAudioEngine { }, outputVolume: desiredOutputVolume, lastTransitionMilliseconds: lastTransitionMilliseconds, - microphonePermission: microphonePermission + microphonePermission: microphonePermission, + safetyPauseRevision: safetyPauseRevision, + isSafetyPaused: isSafetyPaused ) } diff --git a/apple/InlineKit/Sources/InlineRTC/Engine/GridEngine.swift b/apple/InlineKit/Sources/InlineRTC/Engine/GridEngine.swift index 653930818..05fa2f695 100644 --- a/apple/InlineKit/Sources/InlineRTC/Engine/GridEngine.swift +++ b/apple/InlineKit/Sources/InlineRTC/Engine/GridEngine.swift @@ -22,9 +22,8 @@ public actor InlineRTCSession { private var eventTasks: [Task] = [] private var commandTask: Task? private var permissionRequestTask: Task? - private var shutdownWaiters: [UUID: CheckedContinuation] = [:] - public init(captureCooldown: Duration = .seconds(2)) { + public init(captureCooldown: Duration = InlineRTCSession.defaultCaptureCooldown) { self.init( configuration: .voice, audioDriver: LiveKitGridAUHALAudioDriver(), @@ -34,6 +33,15 @@ public actor InlineRTCSession { ) } + public static var defaultCaptureCooldown: Duration { + #if os(iOS) + // End capture immediately when the authorized multiparty demand ends. + .zero + #else + .seconds(2) + #endif + } + init( configuration: InlineRTCConfiguration = .voice, audioDriver: any GridAudioDriver, @@ -57,19 +65,6 @@ public actor InlineRTCSession { commandTask?.cancel() permissionRequestTask?.cancel() eventTasks.forEach { $0.cancel() } - let receipt = GridMediaShutdownReceipt( - audio: GridAudioShutdownReceipt( - recordingStopped: false, - playoutStopped: false, - mutationReleased: false, - failures: ["InlineRTCSession deinitialized before shutdown completed."] - ), - rtc: GridRTCShutdownReceipt( - locallyActiveRoomCount: 1, - failures: [] - ) - ) - shutdownWaiters.values.forEach { $0.resume(returning: receipt) } subscribers.values.forEach { $0.finish() } commandMailbox.finish() } @@ -118,6 +113,21 @@ public actor InlineRTCSession { enqueue(.shutdown(requestID: UUID())) } + /// Enqueues the exact retirement boundary before returning to its caller. Awaiting the + /// receipt later cannot submit an old shutdown behind a newer account's media demand. + public nonisolated func requestShutdownReceipt() -> Task { + let completion = GridShutdownCompletion() + enqueue(.shutdown(requestID: UUID(), completion: completion)) + return Task { [weak self] in + if let self { + await self.start() + } else { + completion.complete(.unproven) + } + return await completion.value() + } + } + /// Creates an independent watch-style subscription to the process-wide /// media runtime. Every call owns a distinct AsyncStream; Swift consumers /// never compete for elements from a shared iterator. @@ -161,8 +171,11 @@ public actor InlineRTCSession { for await _ in commandMailbox.signals { guard !Task.isCancelled else { return } while let command = commandMailbox.dequeue() { - guard !Task.isCancelled else { return } - await self?.handle(command) + guard !Task.isCancelled, let self else { + command.failUnfinishedShutdown() + return + } + await self.handle(command) } } } @@ -174,13 +187,8 @@ public actor InlineRTCSession { /// followed by an explicit empty demand before media is disconnected. The /// mailbox remains alive so the same authenticated-process runtime can be /// reused if application dependencies survive a logout/login transition. - public func shutdown() async -> GridMediaShutdownReceipt { - await start() - let requestID = UUID() - return await withCheckedContinuation { continuation in - shutdownWaiters[requestID] = continuation - enqueue(.shutdown(requestID: requestID)) - } + public nonisolated func shutdown() async -> GridMediaShutdownReceipt { + await requestShutdownReceipt().value } private func handle(_ command: GridEngineCommand) async { @@ -205,7 +213,7 @@ public actor InlineRTCSession { case .applicationDidWake: await audio.checkRuntimeHealthAfterInterruption() await rtc.applicationDidWake() - case let .shutdown(requestID): + case let .shutdown(_, completion): let rtcReceipt = await rtc.shutdown() let audioReceipt = await audio.shutdown() let receipt = GridMediaShutdownReceipt(audio: audioReceipt, rtc: rtcReceipt) @@ -216,7 +224,7 @@ public actor InlineRTCSession { "GRID_ENGINE phase=engine_shutdown_incomplete locally_quiescent=false active_rooms=\(receipt.locallyActiveRoomCount) rtc_media_mutations=\(receipt.rtcLocalMediaMutationCount) microphone_publications=\(receipt.microphonePublicationCount) screen_publications=\(receipt.screenSharePublicationCount) audio_mutation_released=\(receipt.audioMutationReleased) failures=\(receipt.failures.joined(separator: ","))" ) } - shutdownWaiters.removeValue(forKey: requestID)?.resume(returning: receipt) + completion?.complete(receipt) } } diff --git a/apple/InlineKit/Sources/InlineRTC/Engine/GridEngineCommandMailbox.swift b/apple/InlineKit/Sources/InlineRTC/Engine/GridEngineCommandMailbox.swift index fbb3a5c34..afd62d0e3 100644 --- a/apple/InlineKit/Sources/InlineRTC/Engine/GridEngineCommandMailbox.swift +++ b/apple/InlineKit/Sources/InlineRTC/Engine/GridEngineCommandMailbox.swift @@ -25,7 +25,10 @@ final class GridEngineCommandMailbox: @unchecked Sendable { func enqueue(_ command: GridEngineCommand) { let shouldSignal = lock.withLock { - guard !finished else { return false } + guard !finished else { + command.failUnfinishedShutdown() + return false + } if case .setDemand = command, let last = queue.indices.last, case .setDemand = queue[last] { @@ -53,6 +56,7 @@ final class GridEngineCommandMailbox: @unchecked Sendable { let shouldFinish = lock.withLock { guard !finished else { return false } finished = true + queue.forEach { $0.failUnfinishedShutdown() } queue.removeAll() return true } diff --git a/apple/InlineKit/Sources/InlineRTC/Engine/GridMediaTypes.swift b/apple/InlineKit/Sources/InlineRTC/Engine/GridMediaTypes.swift index 67e4cfc04..c661a7337 100644 --- a/apple/InlineKit/Sources/InlineRTC/Engine/GridMediaTypes.swift +++ b/apple/InlineKit/Sources/InlineRTC/Engine/GridMediaTypes.swift @@ -48,6 +48,9 @@ public struct InlineRTCDemand: Equatable, Sendable { public var target: InlineRTCSessionID? public var credentials: InlineRTCCredentials? public var microphoneEnabled: Bool + /// Explicit capture preparation for this locally admitted call. iOS keeps + /// this false until the user unmutes; a prior OS permission is not intent. + public var microphoneCapturePrepared: Bool public var screenCaptureSource: InlineRTCScreenCaptureSource? public var screenShareQualityProfile: InlineRTCScreenShareQualityProfile public var input: AudioInputSelection @@ -58,6 +61,7 @@ public struct InlineRTCDemand: Equatable, Sendable { target: InlineRTCSessionID? = nil, credentials: InlineRTCCredentials? = nil, microphoneEnabled: Bool = false, + microphoneCapturePrepared: Bool = false, screenCaptureSource: InlineRTCScreenCaptureSource? = nil, screenShareQualityProfile: InlineRTCScreenShareQualityProfile = .automatic, input: AudioInputSelection = .automatic, @@ -67,6 +71,7 @@ public struct InlineRTCDemand: Equatable, Sendable { self.target = target self.credentials = credentials self.microphoneEnabled = microphoneEnabled + self.microphoneCapturePrepared = microphoneCapturePrepared self.screenCaptureSource = screenCaptureSource self.screenShareQualityProfile = screenShareQualityProfile self.input = input @@ -77,6 +82,8 @@ public struct InlineRTCDemand: Equatable, Sendable { enum GridAudioDriverEvent: Equatable, Sendable { case devicesChanged + case safetyPaused + case listeningResumed case engineStarting(playout: Bool, recording: Bool) case engineStopped(playout: Bool, recording: Bool) case engineDisabled(playout: Bool, recording: Bool) @@ -401,6 +408,47 @@ public struct GridMediaShutdownReceipt: Equatable, Sendable { screenSharePublicationCount = rtc.screenSharePublicationCount failures = rtc.failures + audio.failures } + + static let unproven = GridMediaShutdownReceipt( + audio: GridAudioShutdownReceipt( + recordingStopped: false, playoutStopped: false, mutationReleased: false, + failures: ["InlineRTCSession ended before shutdown completed."] + ), + rtc: GridRTCShutdownReceipt(locallyActiveRoomCount: 1, failures: []) + ) +} + +/// One shutdown command owns its acknowledgement from synchronous submission to completion. +/// Completion may precede its waiter; no actor-owned request registry or enqueue hop is needed. +final class GridShutdownCompletion: @unchecked Sendable, Equatable { + private let lock = NSLock() + private var receipt: GridMediaShutdownReceipt? + private var waiter: CheckedContinuation? + + static func == (lhs: GridShutdownCompletion, rhs: GridShutdownCompletion) -> Bool { lhs === rhs } + + func value() async -> GridMediaShutdownReceipt { + await withCheckedContinuation { continuation in + let completed = lock.withLock { () -> GridMediaShutdownReceipt? in + if let receipt { return receipt } + precondition(waiter == nil) + waiter = continuation + return nil as GridMediaShutdownReceipt? + } + if let completed { continuation.resume(returning: completed) } + } + } + + func complete(_ receipt: GridMediaShutdownReceipt) { + let continuation = lock.withLock { + guard self.receipt == nil else { return nil as CheckedContinuation? } + self.receipt = receipt + let continuation = waiter + waiter = nil + return continuation + } + continuation?.resume(returning: receipt) + } } enum GridAudioPlayoutFailureDisposition: Equatable, Sendable { @@ -431,6 +479,8 @@ public struct InlineRTCAudioSnapshot: Equatable, Sendable { public let outputVolume: Float public let lastTransitionMilliseconds: Int? public let microphonePermission: InlineRTCMicrophonePermission + public let safetyPauseRevision: UInt64 + public let isSafetyPaused: Bool public init( state: InlineRTCAudioState, @@ -449,7 +499,9 @@ public struct InlineRTCAudioSnapshot: Equatable, Sendable { currentMutationMilliseconds: Int? = nil, outputVolume: Float, lastTransitionMilliseconds: Int?, - microphonePermission: InlineRTCMicrophonePermission + microphonePermission: InlineRTCMicrophonePermission, + safetyPauseRevision: UInt64 = 0, + isSafetyPaused: Bool = false ) { self.state = state self.isConfigured = isConfigured @@ -468,6 +520,8 @@ public struct InlineRTCAudioSnapshot: Equatable, Sendable { self.outputVolume = outputVolume self.lastTransitionMilliseconds = lastTransitionMilliseconds self.microphonePermission = microphonePermission + self.safetyPauseRevision = safetyPauseRevision + self.isSafetyPaused = isSafetyPaused } } @@ -648,7 +702,11 @@ enum GridEngineCommand: Equatable, Sendable { case retryAudio case networkBecameAvailable case applicationDidWake - case shutdown(requestID: UUID) + case shutdown(requestID: UUID, completion: GridShutdownCompletion? = nil) + + func failUnfinishedShutdown() { + if case let .shutdown(_, completion) = self { completion?.complete(.unproven) } + } } /// A complete immutable projection of the process-wide InlineRTC runtime. diff --git a/apple/InlineKit/Sources/InlineRTC/Engine/GridRTCEngine.swift b/apple/InlineKit/Sources/InlineRTC/Engine/GridRTCEngine.swift index 5656658e0..e13f9dab3 100644 --- a/apple/InlineKit/Sources/InlineRTC/Engine/GridRTCEngine.swift +++ b/apple/InlineKit/Sources/InlineRTC/Engine/GridRTCEngine.swift @@ -43,6 +43,7 @@ actor GridRTCEngine { private var demand = InlineRTCDemand() private var demandRevision = 0 + private var mediaDemandEpoch: UInt64 = 0 private var demandAudioLease: GridAudioLease? private var lifecycleAudioLeases: [GridRTCRoomHandle: GridAudioLease] = [:] private var reconnectsAwaitingMicrophonePublication = Set() @@ -81,6 +82,9 @@ actor GridRTCEngine { private var lastError: String? private var reconcileTask: Task? private var reconcileTaskID: UUID? + /// Cancellation can detach the task handle while an SDK call still owns + /// native work. Keep every actual task until its completion returns. + private var runningReconcileTaskIDs = Set() private var reconcileRequested = false private var backoffTask: Task? private var microphoneRetryTask: Task? @@ -134,9 +138,12 @@ actor GridRTCEngine { if nextDemand.credentials?.target != nextDemand.target { nextDemand.credentials = nil } + if !nextDemand.microphoneCapturePrepared { nextDemand.microphoneEnabled = false } guard nextDemand != demand else { return } let previousTarget = demand.target + if previousTarget != nextDemand.target { mediaDemandEpoch &+= 1 } demandRevision &+= 1 + let admissionRevision = demandRevision demand = nextDemand backoffTask?.cancel() backoffTask = nil @@ -152,10 +159,38 @@ actor GridRTCEngine { startAudioPreparationWait(for: nextDemand.target) } + do { + if let target = nextDemand.target { + // A retained process reservation is not proof that the previous room + // released its local tracks. Do not reopen the native audio engine or + // prepare the replacement microphone until that receipt is complete. + try await waitForRetirementsBeforeConnect(target: target) + guard demandRevision == admissionRevision else { return } + } + try await audio.setMediaDemandActive(nextDemand.target != nil, epoch: mediaDemandEpoch) + } catch { + guard !Task.isCancelled else { return } + guard demandRevision == admissionRevision else { return } + lastError = error.localizedDescription + state = .failed(nextDemand.target, error.localizedDescription) + emitSnapshot() + // Native ownership conflicts require the user or owner to resolve them. + // Keep desired admission and a stable failure; Retry/audio availability + // or a network wake can re-run the admission check without a new claim. + return + } + // Joining a previous native teardown may suspend admission long enough for + // another demand to replace this one. Only its current revision may apply. + guard demandRevision == admissionRevision else { return } + await audio.setInput(nextDemand.input) + guard demandRevision == admissionRevision, !Task.isCancelled else { return } await audio.setOutput(nextDemand.output) + guard demandRevision == admissionRevision, !Task.isCancelled else { return } await audio.setOutputVolume(nextDemand.outputVolume) + guard demandRevision == admissionRevision, !Task.isCancelled else { return } await replaceDemandAudioLease(for: nextDemand.target) + guard demandRevision == admissionRevision, !Task.isCancelled else { return } log.debug( "GRID_ENGINE phase=rtc_demand_replaced revision=\(demandRevision) room=\(nextDemand.target.map(\.rawValue) ?? "none") microphone=\(nextDemand.microphoneEnabled) screen_share=\(nextDemand.screenCaptureSource != nil)" @@ -197,7 +232,9 @@ actor GridRTCEngine { func shutdown() async -> GridRTCShutdownReceipt { demandRevision &+= 1 + mediaDemandEpoch &+= 1 demand = InlineRTCDemand() + try? await audio.setMediaDemandActive(false, epoch: mediaDemandEpoch) backoffTask?.cancel() backoffTask = nil microphoneRetryTask?.cancel() @@ -212,13 +249,14 @@ actor GridRTCEngine { let deadline = Date().addingTimeInterval(configuration.connection.providerTeardownTimeout) while Date() < deadline { if retirementTasks.isEmpty { - guard !failedLocalQuiescence.isEmpty else { break } + guard !failedLocalQuiescence.isEmpty || !runningReconcileTaskIDs.isEmpty else { break } retryFailedLocalQuiescence() } try? await Task.sleep(for: .milliseconds(10)) } let activeRooms = Set(retirementTasks.keys).union(failedLocalQuiescence.keys) let activeCount = activeRooms.count + let providerMutationCount = runningReconcileTaskIDs.count let localMediaMutationCount = failedLocalQuiescence.values .reduce(0) { $0 + $1.localMediaMutationCount } let microphonePublicationCount = failedLocalQuiescence.values @@ -232,12 +270,19 @@ actor GridRTCEngine { + (retirementTasks.isEmpty ? [] : ["Timed out waiting for \(retirementTasks.count) room(s) to release local media."]) - state = activeCount == 0 + + (providerMutationCount == 0 + ? [] + : ["Timed out waiting for \(providerMutationCount) provider operation(s) to return."]) + state = activeCount == 0 && providerMutationCount == 0 ? .idle : .failed(nil, failures.first ?? "Local room quiescence could not be proven.") emitSnapshot() + await reportMediaQuiescenceIfReady() return GridRTCShutdownReceipt( locallyActiveRoomCount: activeCount, + // A retained publication is also owned by its suspended reconcile task; + // count the concrete mutation once. Pending provider work remains an + // explicit failure, so an idle room receipt cannot imply quiescence. localMediaMutationCount: localMediaMutationCount, microphonePublicationCount: microphonePublicationCount, screenSharePublicationCount: screenSharePublicationCount, @@ -246,17 +291,38 @@ actor GridRTCEngine { } private func replaceDemandAudioLease(for target: InlineRTCSessionID?) async { - let nextLease = target.map(GridAudioLease.connectionDemand) + guard target == demand.target else { return } + let revision = demandRevision + let nextLease = demand.microphoneCapturePrepared + ? target.map(GridAudioLease.connectionDemand) + : nil guard nextLease != demandAudioLease else { return } if let nextLease { await audio.acquireCaptureLease(nextLease) } + guard demandRevision == revision else { + await releaseSupersededDemandAudioLease(nextLease) + return + } if let demandAudioLease { await audio.releaseCaptureLease(demandAudioLease) } + guard demandRevision == revision else { + await releaseSupersededDemandAudioLease(nextLease) + return + } demandAudioLease = nextLease } + private func releaseSupersededDemandAudioLease(_ lease: GridAudioLease?) async { + let currentIntent = demand.microphoneCapturePrepared + ? demand.target.map(GridAudioLease.connectionDemand) + : nil + if let lease, lease != currentIntent { + await audio.releaseCaptureLease(lease) + } + } + private func scheduleReconcile() { guard backoffTask == nil else { return } guard reconcileTask == nil else { @@ -274,6 +340,7 @@ actor GridRTCEngine { return } let taskID = UUID() + runningReconcileTaskIDs.insert(taskID) reconcileTaskID = taskID reconcileTask = Task { [weak self] in await self?.runReconcileLoop() @@ -322,6 +389,31 @@ actor GridRTCEngine { return } + let admissionRevision = demandRevision + do { + try await waitForRetirementsBeforeConnect(target: target) + guard !Task.isCancelled else { return } + guard demandRevision == admissionRevision else { continue } + try await audio.setMediaDemandActive(true, epoch: mediaDemandEpoch) + } catch { + guard !Task.isCancelled else { return } + guard demandRevision == admissionRevision else { continue } + lastError = error.localizedDescription + state = .failed(target, error.localizedDescription) + emitSnapshot() + return + } + guard !Task.isCancelled else { return } + guard demandRevision == admissionRevision else { continue } + await audio.setInput(demand.input) + guard demandRevision == admissionRevision, !Task.isCancelled else { continue } + await audio.setOutput(demand.output) + guard demandRevision == admissionRevision, !Task.isCancelled else { continue } + await audio.setOutputVolume(demand.outputVolume) + guard demandRevision == admissionRevision, !Task.isCancelled else { continue } + await replaceDemandAudioLease(for: target) + guard demandRevision == admissionRevision, !Task.isCancelled else { continue } + if room == nil, pendingAudioTransportGeneration == nil { pendingAudioTransportGeneration = await audio.rtcTransportWillInitialize() } @@ -352,6 +444,11 @@ actor GridRTCEngine { continue } if !microphonePublished { + guard demand.microphoneCapturePrepared else { + microphonePublicationState = .notRequested + emitSnapshot() + return + } guard microphoneRetryTask == nil else { emitSnapshot() return @@ -446,6 +543,7 @@ actor GridRTCEngine { } let audioWaitsForRTCTransport = await audio.isWaitingForRTCTransport() if !audioWaitsForRTCTransport, + demand.microphoneCapturePrepared, !audioSnapshot.isPrepared, audioSnapshot.microphonePermission.permitsCapture { if shouldWaitForAudioPreparation(target: target) { @@ -485,7 +583,11 @@ actor GridRTCEngine { let attemptID = UUID() let lifecycleLease = GridAudioLease.rtcLifecycle(attemptID) - await audio.acquireCaptureLease(lifecycleLease) + #if !os(iOS) + if demand.microphoneCapturePrepared { + await audio.acquireCaptureLease(lifecycleLease) + } + #endif let room: GridRTCRoomHandle do { room = try await driver.makeRoom(configuration: configuration) @@ -607,6 +709,7 @@ actor GridRTCEngine { /// audio churn. Wait for the fast path, but preserve bounded forward progress /// when a provider call is genuinely stuck. private func waitForRetirementsBeforeConnect(target: InlineRTCSessionID) async throws { + try Task.checkCancellation() let timeout = configuration.connection.retirementBarrierTimeout guard !retirementTasks.isEmpty || !failedLocalQuiescence.isEmpty else { return } guard timeout > 0 else { @@ -623,6 +726,7 @@ actor GridRTCEngine { } let elapsed = elapsedMilliseconds(since: startedAt) + try Task.checkCancellation() if retirementTasks.isEmpty, failedLocalQuiescence.isEmpty { log.debug( "GRID_ENGINE phase=rtc_retirement_barrier_finished session=\(target.rawValue) elapsed_ms=\(elapsed)" @@ -1092,6 +1196,7 @@ actor GridRTCEngine { "GRID_ENGINE phase=rtc_retired session=\(target.rawValue) elapsed_ms=\(lastDisconnectMilliseconds ?? 0) locally_quiescent=\(receipt.isQuiescent)" ) emitSnapshot() + await reportMediaQuiescenceIfReady() } private func detach(_ detachedRoom: GridRTCRoomHandle) { @@ -1138,6 +1243,9 @@ actor GridRTCEngine { guard retirementsRequiringAudioTransportFence.remove(retiredRoom) != nil, room == nil else { return } + // Only the custom ADM has a transport-generation fence to close. The iOS + // SDK path would merely reopen native playout while old tracks still exist. + guard await audio.rtcTransportRequiresPreparation() else { return } let generation = await audio.rtcTransportWillInitialize() if demand.target != nil, pendingAudioTransportGeneration == nil { pendingAudioTransportGeneration = generation @@ -1812,18 +1920,35 @@ actor GridRTCEngine { reconcileRequested = false } - private func reconcileFinished(taskID: UUID) { + private func reconcileFinished(taskID: UUID) async { + runningReconcileTaskIDs.remove(taskID) if providerCircuitBreaker.contains(id: taskID) { retiredProviderOperationReturned(id: taskID) } - guard reconcileTaskID == taskID else { return } + guard reconcileTaskID == taskID else { + await reportMediaQuiescenceIfReady() + return + } reconcileTask = nil reconcileTaskID = nil - guard reconcileRequested else { return } + guard reconcileRequested else { + await reportMediaQuiescenceIfReady() + return + } reconcileRequested = false scheduleReconcile() } + private func reportMediaQuiescenceIfReady() async { + guard demand.target == nil, + room == nil, + retirementTasks.isEmpty, + failedLocalQuiescence.isEmpty, + runningReconcileTaskIDs.isEmpty, + providerCircuitBreaker.abandonedOperationCount == 0 else { return } + await audio.mediaDidQuiesce(epoch: mediaDemandEpoch) + } + private func startDriverEventsIfNeeded() { if lifecycleEventsTask == nil { lifecycleEventsTask = Task { [weak self, events = driver.lifecycleEvents] in diff --git a/apple/InlineKit/Sources/InlineRTC/Engine/GridScreenShare.swift b/apple/InlineKit/Sources/InlineRTC/Engine/GridScreenShare.swift index d6340185e..b2bfee168 100644 --- a/apple/InlineKit/Sources/InlineRTC/Engine/GridScreenShare.swift +++ b/apple/InlineKit/Sources/InlineRTC/Engine/GridScreenShare.swift @@ -270,7 +270,7 @@ public enum InlineRTCScreenShareWindowSizePolicy { } public final class InlineRTCVideoTrack: @unchecked Sendable, Equatable { - fileprivate let liveKitTrack: VideoTrack + let liveKitTrack: VideoTrack init(_ liveKitTrack: VideoTrack) { self.liveKitTrack = liveKitTrack diff --git a/apple/InlineKit/Sources/InlineRTC/Engine/InlineRTCVideoView+iOS.swift b/apple/InlineKit/Sources/InlineRTC/Engine/InlineRTCVideoView+iOS.swift new file mode 100644 index 000000000..269e7cd1a --- /dev/null +++ b/apple/InlineKit/Sources/InlineRTC/Engine/InlineRTCVideoView+iOS.swift @@ -0,0 +1,82 @@ +#if os(iOS) +import Foundation +import LiveKit +import UIKit + +/// The same renderer contract used by the Mac viewer, backed by UIKit on iOS. +@MainActor +public final class InlineRTCVideoView: UIView { + private let renderer = VideoView() + private let dimensionObserver = IOSGridVideoDimensionObserver() + + public var onVideoDimensionsChanged: ((InlineRTCVideoDimensions) -> Void)? { + didSet { + dimensionObserver.onDimensionsChanged = { [weak self] dimensions in + self?.onVideoDimensionsChanged?(dimensions) + } + reportCurrentDimensions() + } + } + + public var screenShare: InlineRTCScreenShare? { + didSet { + let oldTrack = oldValue?.videoTrack + let newTrack = screenShare?.videoTrack + if oldTrack != newTrack { + oldTrack?.liveKitTrack.remove(delegate: dimensionObserver) + newTrack?.liveKitTrack.add(delegate: dimensionObserver) + } + renderer.track = isVideoEnabled ? newTrack?.liveKitTrack : nil + reportCurrentDimensions() + } + } + + public var isVideoEnabled: Bool { + get { renderer.isEnabled } + set { + guard renderer.isEnabled != newValue else { return } + if newValue { + renderer.isEnabled = true + renderer.track = screenShare?.videoTrack?.liveKitTrack + } else { + renderer.track = nil + renderer.isEnabled = false + } + } + } + + public override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + renderer.backgroundColor = .clear + renderer.layoutMode = .fit + renderer.mirrorMode = .off + renderer.translatesAutoresizingMaskIntoConstraints = false + addSubview(renderer) + NSLayoutConstraint.activate([ + renderer.leadingAnchor.constraint(equalTo: leadingAnchor), + renderer.trailingAnchor.constraint(equalTo: trailingAnchor), + renderer.topAnchor.constraint(equalTo: topAnchor), + renderer.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") } + + private func reportCurrentDimensions() { + guard let dimensions = screenShare?.videoTrack?.dimensions else { return } + onVideoDimensionsChanged?(dimensions) + } +} + +private final class IOSGridVideoDimensionObserver: NSObject, TrackDelegate, @unchecked Sendable { + @MainActor var onDimensionsChanged: ((InlineRTCVideoDimensions) -> Void)? + + nonisolated func track(_ track: VideoTrack, didUpdateDimensions dimensions: Dimensions?) { + guard let dimensions else { return } + let value = InlineRTCVideoDimensions(width: Int(dimensions.width), height: Int(dimensions.height)) + Task { @MainActor [weak self] in self?.onDimensionsChanged?(value) } + } +} +#endif diff --git a/apple/InlineKit/Sources/InlineRTC/Engine/LiveKitGridAUHALAudioDriver.swift b/apple/InlineKit/Sources/InlineRTC/Engine/LiveKitGridAUHALAudioDriver.swift index ba39d1e4a..d6f87dc41 100644 --- a/apple/InlineKit/Sources/InlineRTC/Engine/LiveKitGridAUHALAudioDriver.swift +++ b/apple/InlineKit/Sources/InlineRTC/Engine/LiveKitGridAUHALAudioDriver.swift @@ -943,160 +943,6 @@ private enum LiveKitGridAUHALDriverError: LocalizedError { } } } -#else -actor LiveKitGridAUHALAudioDriver: GridAudioDriver { - nonisolated let events: AsyncStream - - private nonisolated let eventContinuation: AsyncStream.Continuation - private nonisolated let expectedEngineTransition = ManagedAtomic(false) - private var engineObserver: GridLiveKitAudioEngineObserver? - private var configured = false - private var prepared = false - - init() { - let stream = AsyncStream.makeStream( - of: GridAudioDriverEvent.self, - bufferingPolicy: .bufferingNewest(16) - ) - events = stream.stream - eventContinuation = stream.continuation - } - - deinit { eventContinuation.finish() } - - func configure(_ configuration: InlineRTCConfiguration) async throws { - guard !configured else { return } - try AudioManager.set(audioDeviceModuleType: .audioEngine) - AudioManager.prepare() - try configuration.applyVoiceProcessing() - let observer = GridLiveKitAudioEngineObserver( - eventContinuation: eventContinuation, - expectedEngineTransition: expectedEngineTransition - ) - AudioManager.shared.set(engineObservers: [observer, AudioManager.shared.mixer]) - engineObserver = observer - let eventContinuation = eventContinuation - AudioManager.shared.onDeviceUpdate = { _ in - eventContinuation.yield(.devicesChanged) - } - configured = true - } - - func setPrepared(_ prepared: Bool) async throws { - guard self.prepared != prepared else { return } - expectedEngineTransition.store(true, ordering: .releasing) - defer { expectedEngineTransition.store(false, ordering: .releasing) } - try await AudioManager.shared.setRecordingAlwaysPreparedMode(prepared) - self.prepared = prepared - } - - func recoverPreparedAudio(preserving _: AudioInputRouteTarget?) async throws { - guard prepared else { return } - expectedEngineTransition.store(true, ordering: .releasing) - defer { expectedEngineTransition.store(false, ordering: .releasing) } - try await AudioManager.shared.setRecordingAlwaysPreparedMode(false) - try await AudioManager.shared.setRecordingAlwaysPreparedMode(true) - } - - func isAudioEngineRunning() async -> Bool { AudioManager.shared.isEngineRunning } - - func runtimeHealth() async -> GridAudioRuntimeHealth { - let isRunning = AudioManager.shared.isEngineRunning - return GridAudioRuntimeHealth( - isEngineRunning: isRunning, - isRecording: isRunning, - isPlaying: isRunning, - route: nil - ) - } - - func applyInputRoute( - _ target: AudioInputRouteTarget, - restartPreparedAudio _: Bool - ) async throws -> GridAudioInputRouteApplication { - guard case .automatic = target else { - throw LiveKitGridAudioDriverError.explicitInputRoutingUnsupported - } - return .committed - } - - func inputDeviceInventory() async -> AudioInputDeviceInventory { - AudioInputDeviceInventory( - automaticDeviceID: nil, - automaticDeviceName: "System Default", - devices: [], - routeEpoch: 0 - ) - } -} - -private enum LiveKitGridAudioDriverError: LocalizedError { - case explicitInputRoutingUnsupported - - var errorDescription: String? { - "Explicit microphone selection is not supported on this platform." - } -} - -private final class GridLiveKitAudioEngineObserver: AudioEngineObserver, @unchecked Sendable { - var next: (any AudioEngineObserver)? - - private let eventContinuation: AsyncStream.Continuation - private let expectedEngineTransition: ManagedAtomic - - init( - eventContinuation: AsyncStream.Continuation, - expectedEngineTransition: ManagedAtomic - ) { - self.eventContinuation = eventContinuation - self.expectedEngineTransition = expectedEngineTransition - } - - func engineWillStart( - _ engine: AVAudioEngine, - isPlayoutEnabled: Bool, - isRecordingEnabled: Bool - ) -> Int { - eventContinuation.yield( - .engineStarting(playout: isPlayoutEnabled, recording: isRecordingEnabled) - ) - return next?.engineWillStart( - engine, - isPlayoutEnabled: isPlayoutEnabled, - isRecordingEnabled: isRecordingEnabled - ) ?? 0 - } - - func engineDidStop( - _ engine: AVAudioEngine, - isPlayoutEnabled: Bool, - isRecordingEnabled: Bool - ) -> Int { - let event: GridAudioDriverEvent = expectedEngineTransition.load(ordering: .acquiring) - ? .expectedEngineStop(playout: isPlayoutEnabled, recording: isRecordingEnabled) - : .engineStopped(playout: isPlayoutEnabled, recording: isRecordingEnabled) - eventContinuation.yield(event) - return next?.engineDidStop( - engine, - isPlayoutEnabled: isPlayoutEnabled, - isRecordingEnabled: isRecordingEnabled - ) ?? 0 - } - - func engineDidDisable( - _ engine: AVAudioEngine, - isPlayoutEnabled: Bool, - isRecordingEnabled: Bool - ) -> Int { - let event: GridAudioDriverEvent = expectedEngineTransition.load(ordering: .acquiring) - ? .expectedEngineDisable(playout: isPlayoutEnabled, recording: isRecordingEnabled) - : .engineDisabled(playout: isPlayoutEnabled, recording: isRecordingEnabled) - eventContinuation.yield(event) - return next?.engineDidDisable( - engine, - isPlayoutEnabled: isPlayoutEnabled, - isRecordingEnabled: isRecordingEnabled - ) ?? 0 - } -} +#elseif os(iOS) +typealias LiveKitGridAUHALAudioDriver = LiveKitGridIOSAudioDriver #endif diff --git a/apple/InlineKit/Sources/InlineRTC/Engine/LiveKitGridIOSAudioDriver.swift b/apple/InlineKit/Sources/InlineRTC/Engine/LiveKitGridIOSAudioDriver.swift new file mode 100644 index 000000000..b2632234f --- /dev/null +++ b/apple/InlineKit/Sources/InlineRTC/Engine/LiveKitGridIOSAudioDriver.swift @@ -0,0 +1,521 @@ +#if os(iOS) +import AVFoundation +import Atomics +import Foundation +import InlineAudioPlayback +import LiveKit + +/// iOS uses the SDK AudioEngine ADM and its supported session observer chain. +/// Capture availability is a physical fence: retired room callbacks cannot +/// reopen the microphone after product capture intent has been withdrawn. +actor LiveKitGridIOSAudioDriver: GridAudioDriver { + nonisolated let events: AsyncStream + private nonisolated let continuation: AsyncStream.Continuation + private nonisolated let expectedTransition = ManagedAtomic(false) + private let directions = IOSGridAudioDirections() + private let inputProbe = IOSGridAudioCallbackProbe() + private let outputProbe = IOSGridAudioCallbackProbe() + private var observer: IOSGridAudioEngineObserver? + private let systemObservers = IOSGridSystemObservers() + private var configured = false + private var prepared = false + private var safetyPaused = false + private var interruptionActive = false + private var requiresExplicitOutputResume = false + private var requiresProcessRestart = false + private var mediaDemandActive = false + /// Invalidates suspended warm preparation before any safety or admission + /// change can be undone by its completion. + private var lifecycleEpoch: UInt64 = 0 + private var mediaDemandEpoch: UInt64 = 0 + private var expectedTransitionCount = 0 + private var warmModeMayBeEnabled = false + private var audioProcessingOptions = AudioProcessingOptions() + private let bootstrapError: String? + private let backend: any IOSGridAudioBackend + private let hasNativeEngine: Bool + + init() { + backend = LiveKitIOSGridAudioBackend() + hasNativeEngine = true + // Select the process ADM before any Room can initialize its factory. + do { + try AudioManager.set(audioDeviceModuleType: .audioEngine) + bootstrapError = nil + } catch { + bootstrapError = error.localizedDescription + } + let stream = AsyncStream.makeStream(of: GridAudioDriverEvent.self, bufferingPolicy: .bufferingNewest(16)) + events = stream.stream + continuation = stream.continuation + } + + /// Exercises the real actor lifecycle against suspended provider operations + /// without initializing an ADM or touching the device audio session in tests. + init(testingBackend backend: any IOSGridAudioBackend) { + self.backend = backend + hasNativeEngine = false + bootstrapError = nil + configured = true + let stream = AsyncStream.makeStream(of: GridAudioDriverEvent.self, bufferingPolicy: .bufferingNewest(16)) + events = stream.stream + continuation = stream.continuation + } + + deinit { + continuation.finish() + } + + func configure(_ configuration: InlineRTCConfiguration) async throws { + guard !configured else { return } + if let bootstrapError { throw IOSGridAudioError.bootstrap(bootstrapError) } + // The process owner supplies category and activation through its native FIFO. + // Keep the SDK observer in the chain, without a second session writer. + AudioManager.shared.audioSession.isAutomaticConfigurationEnabled = false + AudioManager.prepare() + try configuration.applyVoiceProcessing() + audioProcessingOptions = configuration.makeAudioProcessingOptions() + let observer = IOSGridAudioEngineObserver( + continuation: continuation, + expectedTransition: expectedTransition, + directions: directions + ) + // Manual configuration retains the stock requirement/observer forwarding chain. + AudioManager.shared.set(engineObservers: [AudioManager.shared.audioSession, observer, AudioManager.shared.mixer]) + self.observer = observer + AudioManager.shared.add(localAudioRenderer: inputProbe) + AudioManager.shared.add(remoteAudioRenderer: outputProbe) + try backend.setEngineAvailability(mediaDemandActive && !safetyPaused ? Self.listenOnlyAvailability : .none) + installSystemObservers() + configured = true + } + + func resumeAfterTerminalShutdown() async { + let epoch = lifecycleEpoch + guard !(await backend.isQuarantined()) else { return } + guard epoch == lifecycleEpoch, mediaDemandActive, !safetyPaused, !interruptionActive else { return } + try? backend.setEngineAvailability(Self.listenOnlyAvailability) + } + + func setMediaDemandActive(_ active: Bool, epoch: UInt64) async throws { + guard epoch >= mediaDemandEpoch else { return } + if mediaDemandActive != active || epoch != mediaDemandEpoch { + lifecycleEpoch &+= 1 + mediaDemandEpoch = epoch + mediaDemandActive = active + if !active { + prepared = false + // Withdraw physical input/output before crossing to the session owner. + try backend.setEngineAvailability(.none) + } + } + do { + try await backend.setGridDemandActive(active, epoch: epoch) + } catch { + if active, epoch == mediaDemandEpoch { + mediaDemandActive = false + lifecycleEpoch &+= 1 + try? backend.setEngineAvailability(.none) + } + throw error + } + } + + func mediaDidQuiesce(epoch: UInt64) async { + guard epoch == mediaDemandEpoch, !mediaDemandActive else { return } + await backend.gridMediaDidQuiesce(epoch: epoch) + guard epoch == mediaDemandEpoch, !mediaDemandActive else { return } + if hasNativeEngine { _ = await runtimeHealth() } + } + + func setPrepared(_ value: Bool) async throws { + guard configured else { throw IOSGridAudioError.notConfigured } + if !value { + lifecycleEpoch &+= 1 + prepared = false + } + let epoch = lifecycleEpoch + guard !(await backend.isQuarantined()) else { + throw InlineAudioSessionError.quarantined + } + guard epoch == lifecycleEpoch else { throw IOSGridAudioError.superseded } + beginExpectedTransition() + defer { endExpectedTransition() } + if value { + guard !requiresProcessRestart else { throw InlineAudioSessionError.quarantined } + guard mediaDemandActive, !safetyPaused, !interruptionActive else { throw IOSGridAudioError.safetyPaused } + warmModeMayBeEnabled = true + do { + try await backend.setRecordingAlwaysPreparedMode(true, audioProcessingOptions: audioProcessingOptions) + } catch { + try? await stopWarmMode() + throw error + } + guard epoch == lifecycleEpoch, mediaDemandActive, !safetyPaused, !interruptionActive else { + // A cancelled SDK await may still have enabled its persistent warm + // demand. Retire that concrete obligation before releasing the writer. + try? await stopWarmMode() + throw IOSGridAudioError.superseded + } + try backend.setEngineAvailability(.default) + } else { + // Highest-priority ADM gate precedes warm-mode release. A late room or + // publication completion can express demand but cannot reopen input. + try backend.setEngineAvailability(mediaDemandActive && !safetyPaused && !interruptionActive ? Self.listenOnlyAvailability : .none) + try await stopWarmMode() + guard epoch == lifecycleEpoch else { throw IOSGridAudioError.superseded } + } + prepared = value + } + + func stopForShutdown() async -> GridAudioDriverShutdownReceipt { + lifecycleEpoch &+= 1 + let epoch = lifecycleEpoch + prepared = false + var failures: [String] = [] + beginExpectedTransition() + defer { endExpectedTransition() } + do { try backend.setEngineAvailability(.none) } + catch { failures.append("availability: \(error)") } + do { try await stopWarmMode() } + catch { failures.append("capture: \(error)") } + if epoch != lifecycleEpoch { failures.append("Audio shutdown was superseded by a new lifecycle transition.") } + let health = await runtimeHealth() + if await backend.isQuarantined() { + failures.append("An audio session requirement failed to release; process restart is required.") + } + return GridAudioDriverShutdownReceipt( + recordingStopped: !health.isRecording, + playoutStopped: !health.isPlaying, + failures: failures + ) + } + + func recoverPreparedAudio(preserving _: AudioInputRouteTarget?) async throws { + guard prepared, mediaDemandActive, !safetyPaused, !interruptionActive else { return } + try await setPrepared(false) + let epoch = lifecycleEpoch + guard mediaDemandActive, !safetyPaused, !interruptionActive else { return } + try await setPrepared(true) + guard epoch == lifecycleEpoch else { throw IOSGridAudioError.superseded } + } + + func resumeListeningAfterSafetyPause() async throws { + guard mediaDemandActive, safetyPaused else { return } + guard !requiresProcessRestart else { throw InlineAudioSessionError.quarantined } + guard !interruptionActive else { throw IOSGridAudioError.interrupted } + try await resumeListening() + } + + func isAudioEngineRunning() async -> Bool { AudioManager.shared.isEngineRunning } + + func runtimeHealth() async -> GridAudioRuntimeHealth { + let enabled = directions.snapshot() + let running = AudioManager.shared.isEngineRunning + let recording = running && enabled.recording + let playing = running && enabled.playing + let input = inputProbe.snapshot() + let output = outputProbe.snapshot() + let route = AVAudioSession.sharedInstance().currentRoute + await InlineAudioSession.shared.observeGridDirections( + recording: recording, + playing: playing, + nativeOperationsQuiescent: expectedTransitionCount == 0 && !warmModeMayBeEnabled, + epoch: mediaDemandEpoch + ) + return GridAudioRuntimeHealth( + isEngineRunning: running, + isRecording: recording, + isRecordingExpected: prepared && !safetyPaused, + isPlaying: playing, + route: InlineRTCAudioRoute( + currentInputID: route.inputs.first?.uid, + defaultInputID: route.inputs.first?.uid, + currentOutputID: route.outputs.first?.uid, + defaultOutputID: route.outputs.first?.uid, + inputDeviceCount: route.inputs.count, + outputDeviceCount: route.outputs.count, + isInputRouteValid: !recording || (!safetyPaused && !route.inputs.isEmpty && input.isFresh), + isOutputRouteValid: !playing || (!safetyPaused && !route.outputs.isEmpty && output.isFresh), + inputCallbackCount: input.count, + outputCallbackCount: output.count, + inputCallbackAgeMilliseconds: input.ageMilliseconds, + outputCallbackAgeMilliseconds: output.ageMilliseconds + ) + ) + } + + func applyInputRoute(_ target: AudioInputRouteTarget, restartPreparedAudio _: Bool) async throws -> GridAudioInputRouteApplication { + guard case .automatic = target else { throw IOSGridAudioError.explicitRoute } + return .committed + } + + func inputDeviceInventory() async -> AudioInputDeviceInventory { + AudioInputDeviceInventory(automaticDeviceID: nil, automaticDeviceName: "Automatic", devices: [], routeEpoch: 0) + } + + private static let listenOnlyAvailability = AudioEngineAvailability(isInputAvailable: false, isOutputAvailable: true) + + private func beginExpectedTransition() { + expectedTransitionCount += 1 + expectedTransition.store(true, ordering: .releasing) + } + + private func endExpectedTransition() { + expectedTransitionCount -= 1 + expectedTransition.store(expectedTransitionCount > 0, ordering: .releasing) + if expectedTransitionCount == 0, hasNativeEngine { + Task { _ = await self.runtimeHealth() } + } + } + + private func stopWarmMode() async throws { + try await backend.setRecordingAlwaysPreparedMode(false) + warmModeMayBeEnabled = false + } + + private func installSystemObservers() { + let center = NotificationCenter.default + systemObservers.tokens.append(center.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: nil) { [weak self, continuation] notification in + let rawReason = notification.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt + if rawReason == AVAudioSession.RouteChangeReason.oldDeviceUnavailable.rawValue { + Task { await self?.pauseForSafety(requiresExplicitOutputResume: true) } + } else { + continuation.yield(.devicesChanged) + } + }) + if #available(iOS 27.0, *) { + systemObservers.tokens.append(center.addObserver(forName: AVAudioSession.didBecomeInactiveNotification, object: nil, queue: nil) { [weak self] notification in + guard let context = notification.userInfo?[AVAudioSession.deactivationContextKey] as? AVAudioSession.DeactivationContext, + context.source == .system else { return } + Task { await self?.interruptionBegan() } + }) + systemObservers.tokens.append(center.addObserver(forName: AVAudioSession.resumptionRecommendationNotification, object: nil, queue: nil) { [weak self] notification in + guard let context = notification.userInfo?[AVAudioSession.resumptionContextKey] as? AVAudioSession.ResumptionContext else { return } + let shouldResume = context.recommendation == .shouldResume + Task { await self?.interruptionEnded(shouldResume: shouldResume) } + }) + } else { + systemObservers.tokens.append(center.addObserver(forName: AVAudioSession.interruptionNotification, object: nil, queue: nil) { [weak self] notification in + guard let rawType = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: rawType) else { return } + let rawOptions = notification.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0 + let shouldResume = AVAudioSession.InterruptionOptions(rawValue: rawOptions).contains(.shouldResume) + Task { + if type == .began { await self?.interruptionBegan() } + else if type == .ended { await self?.interruptionEnded(shouldResume: shouldResume) } + } + }) + } + systemObservers.tokens.append(center.addObserver(forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: nil) { [weak self] _ in + Task { await self?.mediaServicesWereReset() } + }) + } + + func interruptionBegan() async { + interruptionActive = true + await pauseForSafety(requiresExplicitOutputResume: false) + } + + func mediaServicesWereReset() async { + // Fence retry synchronously, before a suspended SDK stop can delay the + // process-owner quarantine notification. + requiresProcessRestart = true + await pauseForSafety(requiresExplicitOutputResume: true, quarantiningReset: true) + } + + func interruptionEnded(shouldResume: Bool) async { + interruptionActive = false + guard shouldResume, !requiresExplicitOutputResume, mediaDemandActive, safetyPaused else { return } + try? await resumeListening() + } + + private func resumeListening() async throws { + let epoch = lifecycleEpoch + guard !requiresProcessRestart else { throw InlineAudioSessionError.quarantined } + guard mediaDemandActive, !interruptionActive else { return } + guard !(await backend.isQuarantined()) else { + throw InlineAudioSessionError.quarantined + } + guard epoch == lifecycleEpoch, mediaDemandActive, !interruptionActive else { return } + beginExpectedTransition() + defer { endExpectedTransition() } + // A resumed call always listens first. Neither the OS recommendation nor + // Retry Audio restores a microphone lease withdrawn by the safety pause. + try backend.setEngineAvailability(.none) + try await stopWarmMode() + guard epoch == lifecycleEpoch, mediaDemandActive, !interruptionActive else { return } + try await backend.resumeGridSession(epoch: mediaDemandEpoch) + guard epoch == lifecycleEpoch, mediaDemandActive, !interruptionActive else { return } + prepared = false + try backend.setEngineAvailability(Self.listenOnlyAvailability) + safetyPaused = false + requiresExplicitOutputResume = false + continuation.yield(.listeningResumed) + } + + func pauseForSafety(requiresExplicitOutputResume: Bool, quarantiningReset: Bool = false) async { + lifecycleEpoch &+= 1 + let epoch = lifecycleEpoch + safetyPaused = true + self.requiresExplicitOutputResume = self.requiresExplicitOutputResume || requiresExplicitOutputResume + prepared = false + beginExpectedTransition() + defer { endExpectedTransition() } + try? backend.setEngineAvailability(.none) + // Publish withdrawal before suspending, so a quick end notification cannot + // resume output before the product has received its mute/capture fence. + continuation.yield(.safetyPaused) + if quarantiningReset { await backend.quarantineAfterMediaServicesReset() } + try? await stopWarmMode() + guard epoch == lifecycleEpoch else { return } + } +} + +/// The supported SDK calls form one narrow suspension boundary. The driver, +/// rather than this adapter, owns admission and safety epochs. +protocol IOSGridAudioBackend: Sendable { + func setEngineAvailability(_ availability: AudioEngineAvailability) throws + func setRecordingAlwaysPreparedMode(_ value: Bool, audioProcessingOptions: AudioProcessingOptions?) async throws + func isQuarantined() async -> Bool + func setGridDemandActive(_ active: Bool, epoch: UInt64) async throws + func gridMediaDidQuiesce(epoch: UInt64) async + func resumeGridSession(epoch: UInt64) async throws + func quarantineAfterMediaServicesReset() async +} + +extension IOSGridAudioBackend { + func setRecordingAlwaysPreparedMode(_ value: Bool) async throws { + try await setRecordingAlwaysPreparedMode(value, audioProcessingOptions: nil) + } +} + +private struct LiveKitIOSGridAudioBackend: IOSGridAudioBackend { + func setEngineAvailability(_ availability: AudioEngineAvailability) throws { + try AudioManager.shared.setEngineAvailability(availability) + } + + func setRecordingAlwaysPreparedMode(_ value: Bool, audioProcessingOptions: AudioProcessingOptions?) async throws { + try await AudioManager.shared.setRecordingAlwaysPreparedMode(value, audioProcessingOptions: audioProcessingOptions) + } + + func isQuarantined() async -> Bool { await InlineAudioSession.shared.isQuarantined } + + func setGridDemandActive(_ active: Bool, epoch: UInt64) async throws { + try await InlineAudioSession.shared.setGridDemandActive(active, epoch: epoch) + } + + func gridMediaDidQuiesce(epoch: UInt64) async { + await InlineAudioSession.shared.gridMediaDidQuiesce(epoch: epoch) + } + + func resumeGridSession(epoch: UInt64) async throws { + try await InlineAudioSession.shared.resumeGridSession(epoch: epoch) + } + + func quarantineAfterMediaServicesReset() async { + await InlineAudioSession.shared.quarantineAfterMediaServicesReset() + } +} + +/// Registration is driver-actor confined; destruction may happen on any thread. +private final class IOSGridSystemObservers: @unchecked Sendable { + var tokens: [NSObjectProtocol] = [] + deinit { + for token in tokens { NotificationCenter.default.removeObserver(token) } + } +} + +private final class IOSGridAudioDirections: @unchecked Sendable { + private let lock = NSLock() + private var playing = false + private var recording = false + + func update(playing: Bool, recording: Bool) { + lock.lock() + self.playing = playing + self.recording = recording + lock.unlock() + } + + func snapshot() -> (playing: Bool, recording: Bool) { + lock.lock() + defer { lock.unlock() } + return (playing, recording) + } +} + +private final class IOSGridAudioEngineObserver: AudioEngineObserver, @unchecked Sendable { + var next: (any AudioEngineObserver)? + private let continuation: AsyncStream.Continuation + private let expectedTransition: ManagedAtomic + private let directions: IOSGridAudioDirections + + init(continuation: AsyncStream.Continuation, expectedTransition: ManagedAtomic, directions: IOSGridAudioDirections) { + self.continuation = continuation + self.expectedTransition = expectedTransition + self.directions = directions + } + + func engineWillStart(_ engine: AVAudioEngine, isPlayoutEnabled: Bool, isRecordingEnabled: Bool) -> Int { + directions.update(playing: isPlayoutEnabled, recording: isRecordingEnabled) + continuation.yield(.engineStarting(playout: isPlayoutEnabled, recording: isRecordingEnabled)) + return next?.engineWillStart(engine, isPlayoutEnabled: isPlayoutEnabled, isRecordingEnabled: isRecordingEnabled) ?? 0 + } + + func engineDidStop(_ engine: AVAudioEngine, isPlayoutEnabled: Bool, isRecordingEnabled: Bool) -> Int { + continuation.yield(expectedTransition.load(ordering: .acquiring) + ? .expectedEngineStop(playout: isPlayoutEnabled, recording: isRecordingEnabled) + : .engineStopped(playout: isPlayoutEnabled, recording: isRecordingEnabled)) + return next?.engineDidStop(engine, isPlayoutEnabled: isPlayoutEnabled, isRecordingEnabled: isRecordingEnabled) ?? 0 + } + + func engineDidDisable(_ engine: AVAudioEngine, isPlayoutEnabled: Bool, isRecordingEnabled: Bool) -> Int { + directions.update(playing: isPlayoutEnabled, recording: isRecordingEnabled) + continuation.yield(expectedTransition.load(ordering: .acquiring) + ? .expectedEngineDisable(playout: isPlayoutEnabled, recording: isRecordingEnabled) + : .engineDisabled(playout: isPlayoutEnabled, recording: isRecordingEnabled)) + return next?.engineDidDisable(engine, isPlayoutEnabled: isPlayoutEnabled, isRecordingEnabled: isRecordingEnabled) ?? 0 + } +} + +private final class IOSGridAudioCallbackProbe: NSObject, AudioRenderer, @unchecked Sendable { + struct Snapshot { + let count: UInt64 + let ageMilliseconds: UInt64? + var isFresh: Bool { ageMilliseconds.map { $0 < 1_000 } ?? false } + } + private let count = ManagedAtomic(0) + private let lastCallback = ManagedAtomic(0) + + func render(pcmBuffer _: AVAudioPCMBuffer) { + count.wrappingIncrement(ordering: .relaxed) + lastCallback.store(DispatchTime.now().uptimeNanoseconds, ordering: .relaxed) + } + + func snapshot() -> Snapshot { + let last = lastCallback.load(ordering: .relaxed) + let now = DispatchTime.now().uptimeNanoseconds + return Snapshot(count: count.load(ordering: .relaxed), ageMilliseconds: last > 0 && now >= last ? (now - last) / 1_000_000 : nil) + } +} + +private enum IOSGridAudioError: LocalizedError { + case bootstrap(String) + case notConfigured + case explicitRoute + case superseded + case safetyPaused + case interrupted + var errorDescription: String? { + switch self { + case let .bootstrap(message): "Audio could not initialize. \(message)" + case .notConfigured: "Audio is not configured." + case .explicitRoute: "Choose the audio route using the system audio control." + case .superseded: "Audio preparation was superseded by a safety or call change." + case .safetyPaused: "Resume audio before unmuting." + case .interrupted: "Audio is interrupted. Try again when the interruption ends." + } + } +} +#endif diff --git a/apple/InlineKit/Sources/InlineRTC/GridLiveKitConfiguration.swift b/apple/InlineKit/Sources/InlineRTC/GridLiveKitConfiguration.swift index bee275f57..74ccac2e7 100644 --- a/apple/InlineKit/Sources/InlineRTC/GridLiveKitConfiguration.swift +++ b/apple/InlineKit/Sources/InlineRTC/GridLiveKitConfiguration.swift @@ -150,7 +150,12 @@ struct InlineRTCConfiguration: Equatable, Sendable { public var highPassFilter = false /// Keep the high-pass filter provider-selected when it is enabled. public var highPassFilterMode: HighpassFilterMode = .automatic - public init() {} + public init() { + #if os(iOS) + echoCancellationMode = .platform + noiseSuppressionMode = .platform + #endif + } } public struct Publishing: Equatable, Sendable { @@ -198,7 +203,13 @@ struct InlineRTCConfiguration: Equatable, Sendable { /// How much non-Grid audio is reduced while voice processing is active. public var duckingLevel: DuckingLevel = .minimum - public init() {} + public init() { + #if os(iOS) + // Native voice processing and the system route work together on iPhone. + // The desktop AUHAL cold-start measurements do not apply to iOS. + platformVoiceProcessingAllowed = true + #endif + } public enum DuckingLevel: Equatable, Sendable { case systemDefault diff --git a/apple/InlineKit/Sources/RealtimeV2/Connection/ConnectionEvent.swift b/apple/InlineKit/Sources/RealtimeV2/Connection/ConnectionEvent.swift index 02d118240..425d0dbd2 100644 --- a/apple/InlineKit/Sources/RealtimeV2/Connection/ConnectionEvent.swift +++ b/apple/InlineKit/Sources/RealtimeV2/Connection/ConnectionEvent.swift @@ -4,6 +4,7 @@ public enum ConnectionEvent: Sendable { case connectNow case userInitiatedOperationStarted case userInitiatedOperationFinished + case mediaRetentionChanged(eligible: Bool, revision: UInt64) case authAvailable case authLost @@ -44,6 +45,7 @@ extension ConnectionEvent { case .connectNow: "connectNow" case .userInitiatedOperationStarted: "userInitiatedOperationStarted" case .userInitiatedOperationFinished: "userInitiatedOperationFinished" + case .mediaRetentionChanged: "mediaRetentionChanged" case .authAvailable: "authAvailable" case .authLost: "authLost" case .networkAvailable: "networkAvailable" diff --git a/apple/InlineKit/Sources/RealtimeV2/Connection/ConnectionManager.swift b/apple/InlineKit/Sources/RealtimeV2/Connection/ConnectionManager.swift index 75e5f31d7..6a743a3fc 100644 --- a/apple/InlineKit/Sources/RealtimeV2/Connection/ConnectionManager.swift +++ b/apple/InlineKit/Sources/RealtimeV2/Connection/ConnectionManager.swift @@ -71,6 +71,8 @@ actor ConnectionManager { private var probeNonce: UInt64? private var pendingPingNonce: UInt64? private var backgroundConnectionRetained = false + private var mediaConnectionRetained = false + private var mediaRetentionRevision: UInt64 = 0 // System-invoked work may run while UIKit is inactive. Keep lifecycle state truthful. private var userInitiatedOperations = 0 @@ -131,6 +133,10 @@ actor ConnectionManager { await enqueue(.userInitiatedOperationFinished) } + func setMediaRetention(eligible: Bool, revision: UInt64) async { + await enqueue(.mediaRetentionChanged(eligible: eligible, revision: revision)) + } + func setAuthAvailable(_ available: Bool) async { await enqueue(available ? .authAvailable : .authLost) } @@ -218,6 +224,7 @@ actor ConnectionManager { func shutdownForTesting() async { cancelAllTimers() backgroundConnectionRetained = false + mediaConnectionRetained = false pendingPingNonce = nil commandTask?.cancel() sessionTask?.cancel() @@ -244,7 +251,8 @@ actor ConnectionManager { case .userInitiatedOperationFinished: userInitiatedOperations = max(0, userInitiatedOperations - 1) - if userInitiatedOperations == 0, !constraints.appActive, !backgroundConnectionRetained { + if userInitiatedOperations == 0, !constraints.appActive, !backgroundConnectionRetained, + !mediaConnectionRetained { // Preserve auth loss, explicit stop, and network failure as the stronger constraints. if constraints.authAvailable, constraints.networkAvailable, constraints.userWantsConnection { await transition(to: .backgroundSuspended, reason: .backgroundSuspended) @@ -252,11 +260,25 @@ actor ConnectionManager { } } + case let .mediaRetentionChanged(eligible, revision): + guard revision > mediaRetentionRevision else { return } + mediaRetentionRevision = revision + mediaConnectionRetained = eligible && constraints.authAvailable && constraints.userWantsConnection + if !constraints.appActive, !backgroundConnectionRetained, !mediaConnectionRetained, + userInitiatedOperations == 0, constraints.authAvailable, + constraints.networkAvailable, constraints.userWantsConnection { + await transition(to: .backgroundSuspended, reason: .backgroundSuspended) + await stopTransportAndReset() + } else { + await evaluateConstraints(resetBackoff: false) + } + case .start: constraints.userWantsConnection = true await evaluateConstraints(resetBackoff: false) case .stop: + mediaConnectionRetained = false constraints.userWantsConnection = false await transition(to: .stopped, reason: .userStop) await stopTransportAndReset() @@ -275,6 +297,7 @@ actor ConnectionManager { await evaluateConstraints(resetBackoff: true) case .authLost: + mediaConnectionRetained = false constraints.authAvailable = false log.info("Realtime auth constraint applied available=0 state=\(state) session=\(sessionID)") await handleConstraintLoss(reason: .authLost) @@ -332,7 +355,7 @@ actor ConnectionManager { attempt = 0 backgroundConnectionRetained = false cancelBackoff() - if !transportWasRetained, userInitiatedOperations == 0, state == .open { + if !transportWasRetained, userInitiatedOperations == 0, !mediaConnectionRetained, state == .open { await forceReconnect(reason: .none) } else { await evaluateConstraints(resetBackoff: true) @@ -345,7 +368,7 @@ actor ConnectionManager { ) constraints.appActive = false backgroundConnectionRetained = keepConnection - if !keepConnection, userInitiatedOperations == 0 { + if !keepConnection, userInitiatedOperations == 0, !mediaConnectionRetained { await transition(to: .backgroundSuspended, reason: .backgroundSuspended) await stopTransportAndReset() } @@ -458,7 +481,7 @@ actor ConnectionManager { case .backgroundGraceExpired: guard !constraints.appActive else { return } backgroundConnectionRetained = false - guard userInitiatedOperations == 0 else { return } + guard userInitiatedOperations == 0, !mediaConnectionRetained else { return } await transition(to: .backgroundSuspended, reason: .backgroundSuspended) await stopTransportAndReset() @@ -724,7 +747,8 @@ actor ConnectionManager { } private func constraintsSatisfied() -> Bool { - let appActiveEffective = constraints.appActive || backgroundConnectionRetained || userInitiatedOperations > 0 + let appActiveEffective = constraints.appActive || backgroundConnectionRetained + || mediaConnectionRetained || userInitiatedOperations > 0 return constraints.authAvailable && constraints.networkAvailable && appActiveEffective && constraints.userWantsConnection } diff --git a/apple/InlineKit/Sources/RealtimeV2/Realtime/Realtime.swift b/apple/InlineKit/Sources/RealtimeV2/Realtime/Realtime.swift index 428f28ada..92cc7bf39 100644 --- a/apple/InlineKit/Sources/RealtimeV2/Realtime/Realtime.swift +++ b/apple/InlineKit/Sources/RealtimeV2/Realtime/Realtime.swift @@ -152,6 +152,7 @@ public actor RealtimeV2 { private var transactionOwnerTransitionID: UUID? private var acceptsTransactions: Bool private var isPreparingForTermination = false + private var mediaRetentionRevision: UInt64 = 0 private var transactionRetryTask: Task? private struct HistoryCompletion { let transactionID: TransactionId @@ -323,6 +324,8 @@ public actor RealtimeV2 { /// Stop transport. But do not kill the listeners and tasks. This is state is recoverable via a transport start. public func loggedOut() async { log.info("Stopping realtime account generation") + mediaRetentionRevision &+= 1 + await connectionManager.setMediaRetention(eligible: false, revision: mediaRetentionRevision) acceptsTransactions = false authRecoveryTask?.cancel() authRecoveryTask = nil @@ -1755,6 +1758,18 @@ public actor RealtimeV2 { } } + /// Keeps the existing transport eligible while an admitted media owner actually plays/captures. + /// This neither changes UIKit lifecycle state nor grants background execution. + public func setGridMediaRetention( + eligible: Bool, + accountToken: AuthAccountMutationToken + ) async { + guard !isPreparingForTermination, + (try? auth.validateAccountMutation(accountToken)) != nil else { return } + mediaRetentionRevision &+= 1 + await connectionManager.setMediaRetention(eligible: eligible, revision: mediaRetentionRevision) + } + /// Bounded user-requested work (for example system automation) on the existing account owner. /// This does not claim the app is foreground, create a second connection, or enqueue offline work. public func withUserInitiatedConnection( diff --git a/apple/InlineKit/Tests/InlineAudioPlaybackTests/InlineAudioSessionGridTests.swift b/apple/InlineKit/Tests/InlineAudioPlaybackTests/InlineAudioSessionGridTests.swift new file mode 100644 index 000000000..6138e0dcd --- /dev/null +++ b/apple/InlineKit/Tests/InlineAudioPlaybackTests/InlineAudioSessionGridTests.swift @@ -0,0 +1,631 @@ +import Foundation +@testable import InlineAudioPlayback +import Testing + +@MainActor +@Suite("Grid process audio ownership", .timeLimit(.minutes(1))) +struct InlineAudioSessionGridTests { + @Test( + "Grid preflight and admission preserve an active recording, preview, or movie", + arguments: [InlineAudioSession.Owner.recording, .draftPreview, .video] + ) + func competingConsumer(owner: InlineAudioSession.Owner) async throws { + let backend = GridSessionTestBackend() + let session = InlineAudioSession(backend: backend) + defer { Task { await backend.drain() } } + let token = try session.acquire(owner) + + #expect(throws: (any Error).self) { try session.validateGridAdmission() } + await #expect(throws: (any Error).self) { + try await session.setGridDemandActive(true, epoch: 1) + } + #expect(session.owns(token)) + #expect(!session.isGridActive) + #expect(await backend.operations.isEmpty) + let release = try #require(session.release(token)) + try await expectOperation(.release(token), at: 0, backend: backend) + try await backend.completeNext() + try await finishOperation(release, backend: backend) + } + + @Test("Grid takeover waits for suspended voice activation and its native release") + func takeoverWaitsForOrdinaryAudio() async throws { + let backend = GridSessionTestBackend() + let session = InlineAudioSession(backend: backend) + defer { Task { await backend.drain() } } + let voice = try session.acquire(.voice) + session.willAcquire = { owner in + if owner == .call { + session.release(voice) + } + } + let voiceStart = try await beginOperation { + try await session.activate(voice, configuration: .voicePlayback) + } + try await expectOperation(.activate(voice), at: 0, backend: backend) + + let gridStart = try await beginOperation { + try await session.setGridDemandActive(true, epoch: 1) + } + let call = try #require(session.current) + #expect(call.owner == .call) + #expect(!session.owns(voice)) + #expect(await backend.operations == [.activate(voice)]) + + try await backend.completeNext() + try await expectOperation(.release(voice), at: 1, backend: backend) + #expect(await backend.operations == [.activate(voice), .release(voice)]) + try await backend.completeNext() + try await expectOperation(.activate(call), at: 2, backend: backend) + try await backend.completeNext() + try await expectOperation(.speaker(false, call), at: 3, backend: backend) + try await backend.completeNext() + try await finishOperation(gridStart, backend: backend) + await #expect(throws: CancellationError.self) { try await finishOperation(voiceStart, backend: backend) } + #expect(session.owns(call)) + #expect(session.release(voice) == nil) + try await retireGrid(session, backend: backend, epoch: 2) + } + + @Test("withdrawal cancels queued Grid activation before it can touch native audio") + func withdrawalBeforeActivation() async throws { + let backend = GridSessionTestBackend() + let session = InlineAudioSession(backend: backend) + defer { Task { await backend.drain() } } + let voice = try session.acquire(.voice) + session.willAcquire = { owner in + if owner == .call { + session.release(voice) + } + } + let voiceStart = try await beginOperation { + try await session.activate(voice, configuration: .voicePlayback) + } + try await expectOperation(.activate(voice), at: 0, backend: backend) + let gridStart = try await beginOperation { + try await session.setGridDemandActive(true, epoch: 1) + } + let call = try #require(session.current) + try await session.setGridDemandActive(false, epoch: 2) + await session.gridMediaDidQuiesce(epoch: 2) + await session.observeGridDirections( + recording: false, + playing: false, + nativeOperationsQuiescent: true, + epoch: 2 + ) + #expect(session.owns(call)) + + try await backend.completeNext() + try await expectOperation(.release(voice), at: 1, backend: backend) + try await backend.completeNext() + try await expectOperation(.release(call), at: 2, backend: backend) + try await backend.completeNext() + await #expect(throws: CancellationError.self) { try await finishOperation(voiceStart, backend: backend) } + await #expect(throws: CancellationError.self) { try await finishOperation(gridStart, backend: backend) } + #expect(session.current == nil) + #expect(await backend.operations == [.activate(voice), .release(voice), .release(call)]) + } + + @Test( + "generic release cannot bypass matching provider and native idle receipts", + arguments: [false, true] + ) + func callReleaseNeedsBothReceipts(providerFirst: Bool) async throws { + let backend = GridSessionTestBackend() + let session = InlineAudioSession(backend: backend) + defer { Task { await backend.drain() } } + let call = try await startGrid(session, backend: backend) + #expect(session.release(call) == nil) + try await session.setGridDemandActive(false, epoch: 2) + #expect(session.release(call) == nil) + let operationCount = await backend.operations.count + + if providerFirst { + await session.gridMediaDidQuiesce(epoch: 2) + await session.observeGridDirections( + recording: false, + playing: false, + nativeOperationsQuiescent: true, + epoch: 1 + ) + await session.observeGridDirections( + recording: false, + playing: false, + nativeOperationsQuiescent: false, + epoch: 2 + ) + await session.observeGridDirections( + recording: true, + playing: false, + nativeOperationsQuiescent: true, + epoch: 2 + ) + await session.observeGridDirections( + recording: false, + playing: true, + nativeOperationsQuiescent: true, + epoch: 2 + ) + } else { + await session.observeGridDirections( + recording: false, + playing: false, + nativeOperationsQuiescent: true, + epoch: 2 + ) + await session.gridMediaDidQuiesce(epoch: 1) + } + #expect(session.owns(call)) + #expect(await backend.operations.count == operationCount) + + let retirement = try await beginOperation { + if providerFirst { + await session.observeGridDirections( + recording: false, + playing: false, + nativeOperationsQuiescent: true, + epoch: 2 + ) + } else { + await session.gridMediaDidQuiesce(epoch: 2) + } + } + try await expectOperation(.release(call), at: operationCount, backend: backend) + #expect(session.owns(call)) + try await backend.completeNext() + try await finishOperation(retirement, backend: backend) + #expect(session.current == nil) + #expect(!session.isGridActive) + } + + @Test( + "a delayed or failed call teardown cannot admit an ordinary successor", + arguments: [false, true] + ) + func retirementBlocksSuccessor(releaseFails: Bool) async throws { + let backend = GridSessionTestBackend() + let session = InlineAudioSession(backend: backend) + defer { Task { await backend.drain() } } + let call = try await startGrid(session, backend: backend) + try await session.setGridDemandActive(false, epoch: 2) + await session.gridMediaDidQuiesce(epoch: 2) + let index = await backend.operations.count + let retirement = try await beginOperation { + await session.observeGridDirections( + recording: false, + playing: false, + nativeOperationsQuiescent: true, + epoch: 2 + ) + } + try await expectOperation(.release(call), at: index, backend: backend) + for owner in [InlineAudioSession.Owner.voice, .recording, .draftPreview, .video] { + #expect(throws: AudioPlaybackError.self) { try session.acquire(owner) } + } + #expect(session.owns(call)) + try await backend.completeNext(failing: releaseFails) + try await finishOperation(retirement, backend: backend) + + if releaseFails { + #expect(session.isQuarantined) + #expect(session.voiceDenial?.localizedDescription == InlineAudioSessionError.quarantined.localizedDescription) + #expect(session.owns(call)) + #expect(throws: InlineAudioSessionError.self) { try session.acquire(.voice) } + #expect(throws: InlineAudioSessionError.self) { try session.acquire(.recording) } + #expect(await backend.operations.count == index + 1) + } else { + #expect(!session.isQuarantined) + #expect(session.current == nil) + try await exerciseOrdinarySuccessor(session, backend: backend) + } + } + + @Test("failed activation and failed cleanup retain the call reservation") + func activationFailureWithFailedCleanup() async throws { + let backend = GridSessionTestBackend() + let session = InlineAudioSession(backend: backend) + defer { Task { await backend.drain() } } + let start = try await beginOperation { try await session.setGridDemandActive(true, epoch: 1) } + let call = try #require(session.current) + try await expectOperation(.activate(call), at: 0, backend: backend) + // A native activation error can include failed cleanup of partially owned + // hardware. It is not evidence that the call's obligation is already idle. + try await backend.completeNext(failing: true) + await #expect(throws: (any Error).self) { try await finishOperation(start, backend: backend) } + #expect(session.owns(call)) + #expect(!session.isQuarantined) + #expect(session.release(call) == nil) + #expect(throws: AudioPlaybackError.self) { try session.acquire(.voice) } + #expect(throws: AudioPlaybackError.self) { try session.acquire(.recording) } + #expect(await backend.operations == [.activate(call)]) + + try await session.setGridDemandActive(false, epoch: 2) + await session.gridMediaDidQuiesce(epoch: 2) + let cleanup = try await beginOperation { + await session.observeGridDirections( + recording: false, + playing: false, + nativeOperationsQuiescent: true, + epoch: 2 + ) + } + try await expectOperation(.release(call), at: 1, backend: backend) + try await backend.completeNext(failing: true) + try await finishOperation(cleanup, backend: backend) + #expect(session.isQuarantined) + #expect(session.owns(call)) + #expect(session.release(call) == nil) + #expect(session.voiceDenial?.localizedDescription == InlineAudioSessionError.quarantined.localizedDescription) + #expect(throws: InlineAudioSessionError.self) { try session.acquire(.voice) } + #expect(throws: InlineAudioSessionError.self) { try session.validateGridAdmission() } + #expect(await backend.operations == [.activate(call), .release(call)]) + } + + @Test("initial route failure requires complete activation and routing on same-epoch retry") + func routeFailureRetriesActivation() async throws { + let backend = GridSessionTestBackend() + let session = InlineAudioSession(backend: backend) + defer { Task { await backend.drain() } } + let firstStart = try await beginOperation { + try await session.setGridDemandActive(true, epoch: 1) + } + let call = try #require(session.current) + try await expectOperation(.activate(call), at: 0, backend: backend) + try await backend.completeNext() + try await expectOperation(.speaker(false, call), at: 1, backend: backend) + try await backend.completeNext(failing: true) + await #expect(throws: (any Error).self) { try await finishOperation(firstStart, backend: backend) } + #expect(session.owns(call)) + #expect(!session.isQuarantined) + + let retry = try await beginOperation { + try await session.setGridDemandActive(true, epoch: 1) + } + try await expectOperation(.activate(call), at: 2, backend: backend) + try await backend.completeNext() + try await expectOperation(.speaker(false, call), at: 3, backend: backend) + try await backend.completeNext() + try await finishOperation(retry, backend: backend) + #expect(session.owns(call)) + #expect(await backend.operations == [ + .activate(call), + .speaker(false, call), + .activate(call), + .speaker(false, call), + ]) + try await retireGrid(session, backend: backend, epoch: 2) + } + + @Test( + "voice and Grid reset notification order cannot revoke an unretired call", + arguments: [false, true] + ) + func resetRetainsCall(gridNotificationFirst: Bool) async throws { + let backend = GridSessionTestBackend() + let session = InlineAudioSession(backend: backend) + defer { Task { await backend.drain() } } + let call = try await startGrid(session, backend: backend) + let operationCount = await backend.operations.count + if gridNotificationFirst { + session.quarantineAfterMediaServicesReset() + session.invalidate() + } else { + session.invalidate() + session.quarantineAfterMediaServicesReset() + } + #expect(session.isQuarantined) + #expect(session.owns(call)) + #expect(session.release(call) == nil) + #expect(throws: InlineAudioSessionError.self) { try session.acquire(.voice) } + #expect(throws: InlineAudioSessionError.self) { try session.acquire(.recording) } + try await session.setGridDemandActive(false, epoch: 2) + await session.gridMediaDidQuiesce(epoch: 2) + await session.observeGridDirections( + recording: false, + playing: false, + nativeOperationsQuiescent: true, + epoch: 2 + ) + await #expect(throws: InlineAudioSessionError.self) { + try await session.resumeGridSession(epoch: 2) + } + #expect(session.owns(call)) + #expect(await backend.operations.count == operationCount) + } + + @Test( + "reset after Grid retirement preserves ordinary invalidation and ordered recovery", + arguments: [false, true] + ) + func resetAfterRetirement(gridNotificationFirst: Bool) async throws { + let backend = GridSessionTestBackend() + let session = InlineAudioSession(backend: backend) + defer { Task { await backend.drain() } } + _ = try await startGrid(session, backend: backend) + try await retireGrid(session, backend: backend, epoch: 2) + let voice = try session.acquire(.voice) + let index = await backend.operations.count + let voiceStart = try await beginOperation { + try await session.activate(voice, configuration: .voicePlayback) + } + try await expectOperation(.activate(voice), at: index, backend: backend) + if gridNotificationFirst { + session.quarantineAfterMediaServicesReset() + session.invalidate() + } else { + session.invalidate() + session.quarantineAfterMediaServicesReset() + } + #expect(!session.isQuarantined) + #expect(session.current == nil) + #expect(throws: InlineAudioSessionError.self) { try session.validateGridAdmission() } + let successor = try session.acquire(.video) + let successorStart = try await beginOperation { + try await session.activate(successor, configuration: .voicePlayback) + } + #expect(await backend.operations.count == index + 1) + + try await backend.completeNext() + try await expectOperation(.invalidate, at: index + 1, backend: backend) + #expect(session.owns(successor)) + try await backend.completeNext() + try await expectOperation(.activate(successor), at: index + 2, backend: backend) + try await backend.completeNext() + try await finishOperation(successorStart, backend: backend) + await #expect(throws: CancellationError.self) { try await finishOperation(voiceStart, backend: backend) } + #expect(session.owns(successor)) + let release = try #require(session.release(successor)) + try await expectOperation(.release(successor), at: index + 3, backend: backend) + try await backend.completeNext() + try await finishOperation(release, backend: backend) + } + + @Test( + "queued speaker and recovery work from a withdrawn epoch cannot reach a successor", + arguments: [QueuedGridOperation.speaker, .recovery] + ) + func staleGridOperation(operation: QueuedGridOperation) async throws { + let backend = GridSessionTestBackend() + let session = InlineAudioSession(backend: backend) + defer { Task { await backend.drain() } } + let call = try await startGrid(session, backend: backend) + let index = await backend.operations.count + let blockedSpeaker = try await beginOperation { try await session.setSpeakerPreferred(true) } + try await expectOperation(.speaker(true, call), at: index, backend: backend) + let stale = try await beginOperation { + switch operation { + case .speaker: try await session.setSpeakerPreferred(false) + case .recovery: try await session.resumeGridSession(epoch: 1) + } + } + let replacement = try await beginOperation { + try await session.setGridDemandActive(true, epoch: 2) + } + #expect(session.owns(call)) + #expect(await backend.operations.count == index + 1) + + try await backend.completeNext() + // After the controlled boundary, allow any unexpected I/O to finish and + // remain in the trace: a regression should fail an assertion, not hang. + await backend.drain() + await #expect(throws: CancellationError.self) { try await finishOperation(blockedSpeaker, backend: backend) } + await #expect(throws: CancellationError.self) { try await finishOperation(stale, backend: backend) } + try await finishOperation(replacement, backend: backend) + // Reapplying the admitted replacement must still reuse native activation. + // A stale recovery catch must not clear its active state after it completes. + try await session.setGridDemandActive(true, epoch: 2) + #expect(await backend.operations == [.activate(call), .speaker(false, call), .speaker(true, call)]) + #expect(!session.isSpeakerPreferred) + try await session.setGridDemandActive(false, epoch: 3) + await session.gridMediaDidQuiesce(epoch: 3) + await session.observeGridDirections( + recording: false, + playing: false, + nativeOperationsQuiescent: true, + epoch: 3 + ) + #expect(session.current == nil) + let successor = try session.acquire(.video) + try await session.activate(successor, configuration: .voicePlayback) + let finalOperationCount = await backend.operations.count + await #expect(throws: InlineAudioSessionError.self) { + try await session.resumeGridSession(epoch: 1) + } + await #expect(throws: InlineAudioSessionError.self) { + try await session.setSpeakerPreferred(true) + } + #expect(await backend.operations.count == finalOperationCount) + #expect(session.owns(successor)) + let release = try #require(session.release(successor)) + try await finishOperation(release, backend: backend) + } + + enum QueuedGridOperation: Sendable { case speaker, recovery } +} + +@MainActor +private func beginOperation( + _ operation: @escaping @MainActor () async throws -> Void +) async throws -> Task { + let started = AsyncStream.makeStream(of: Bool.self) + let task = Task { @MainActor in + started.continuation.yield(true) + started.continuation.finish() + try await operation() + } + var iterator = started.stream.makeAsyncIterator() + // Both tasks run on MainActor: observing this signal means operation reached + // its first suspension (or completed), rather than relying on Task.yield. + try #require(await iterator.next() == true) + return task +} + +@MainActor +private func startGrid( + _ session: InlineAudioSession, backend: GridSessionTestBackend +) async throws -> InlineAudioSession.Token { + let index = await backend.operations.count + let start = try await beginOperation { try await session.setGridDemandActive(true, epoch: 1) } + let token = try #require(session.current) + #expect(token.owner == .call) + try await expectOperation(.activate(token), at: index, backend: backend) + try await backend.completeNext() + try await expectOperation(.speaker(false, token), at: index + 1, backend: backend) + try await backend.completeNext() + try await finishOperation(start, backend: backend) + return token +} + +@MainActor +private func retireGrid( + _ session: InlineAudioSession, backend: GridSessionTestBackend, epoch: UInt64 +) async throws { + let token = try #require(session.current) + let index = await backend.operations.count + try await session.setGridDemandActive(false, epoch: epoch) + await session.gridMediaDidQuiesce(epoch: epoch) + let retirement = try await beginOperation { + await session.observeGridDirections( + recording: false, + playing: false, + nativeOperationsQuiescent: true, + epoch: epoch + ) + } + try await expectOperation(.release(token), at: index, backend: backend) + try await backend.completeNext() + try await finishOperation(retirement, backend: backend) + #expect(session.current == nil) +} + +@MainActor +private func exerciseOrdinarySuccessor( + _ session: InlineAudioSession, backend: GridSessionTestBackend +) async throws { + let token = try session.acquire(.video) + let index = await backend.operations.count + let start = try await beginOperation { + try await session.activate(token, configuration: .voicePlayback) + } + try await expectOperation(.activate(token), at: index, backend: backend) + try await backend.completeNext() + try await finishOperation(start, backend: backend) + #expect(session.owns(token)) + let release = try #require(session.release(token)) + try await expectOperation(.release(token), at: index + 1, backend: backend) + try await backend.completeNext() + try await finishOperation(release, backend: backend) +} + +@MainActor +private func finishOperation( + _ task: Task, backend: GridSessionTestBackend +) async throws { + try await withTaskCancellationHandler { + try await task.value + try Task.checkCancellation() + } onCancel: { + task.cancel() + Task { await backend.drain() } + } +} + +private func expectOperation( + _ expected: GridSessionTestBackend.Operation, at index: Int, backend: GridSessionTestBackend +) async throws { + let actual = await backend.operation(at: index) + try #require(actual == expected) +} + +/// Every native boundary is explicitly released by its test. The production +/// InlineAudioSession FIFO is exercised unchanged; no AVAudioSession writes occur. +private actor GridSessionTestBackend: InlineAudioSessionBackend { + enum Operation: Equatable, Sendable { + case activate(InlineAudioSession.Token) + case release(InlineAudioSession.Token) + case speaker(Bool, InlineAudioSession.Token) + case invalidate + case drained + } + + private enum Failure: Error { case missingOperation, nativeFailure } + private var pending: [CheckedContinuation] = [] + private var observers: [Int: [UUID: CheckedContinuation]] = [:] + private var isDraining = false + private(set) var operations: [Operation] = [] + + func activate(token: InlineAudioSession.Token, configuration _: InlineAudioSession.Configuration) async throws { + try await suspend(.activate(token)) + } + + func release(token: InlineAudioSession.Token) async throws { + try await suspend(.release(token)) + } + + func setSpeakerPreferred(_ preferred: Bool, token: InlineAudioSession.Token) async throws { + try await suspend(.speaker(preferred, token)) + } + + func invalidate() async { + try? await suspend(.invalidate) + } + + func operation(at index: Int) async -> Operation { + let id = UUID() + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + if isDraining || Task.isCancelled { + continuation.resume(returning: .drained) + } else if index < operations.count { + continuation.resume(returning: operations[index]) + } else { + observers[index, default: [:]][id] = continuation + } + } + } onCancel: { + Task { await self.cancelObservation(at: index, id: id) } + } + } + + func completeNext(failing: Bool = false) throws { + guard !pending.isEmpty else { throw Failure.missingOperation } + let continuation = pending.removeFirst() + if failing { + continuation.resume(throwing: Failure.nativeFailure) + } else { + continuation.resume() + } + } + + func drain() { + isDraining = true + let continuations = pending + pending.removeAll() + for continuation in continuations { + continuation.resume() + } + let waiting = observers.values.flatMap { Array($0.values) } + observers.removeAll() + for observer in waiting { + observer.resume(returning: .drained) + } + } + + private func suspend(_ operation: Operation) async throws { + let index = operations.count + operations.append(operation) + for observer in (observers.removeValue(forKey: index) ?? [:]).values { + observer.resume(returning: operation) + } + guard !isDraining else { return } + try await withCheckedThrowingContinuation { pending.append($0) } + } + + private func cancelObservation(at index: Int, id: UUID) { + observers[index]?.removeValue(forKey: id)?.resume(returning: .drained) + if observers[index]?.isEmpty == true { + observers[index] = nil + } + } +} diff --git a/apple/InlineKit/Tests/InlineGridTests/GridAuthAuthorityRetirementTests.swift b/apple/InlineKit/Tests/InlineGridTests/GridAuthAuthorityRetirementTests.swift new file mode 100644 index 000000000..25265c7fc --- /dev/null +++ b/apple/InlineKit/Tests/InlineGridTests/GridAuthAuthorityRetirementTests.swift @@ -0,0 +1,284 @@ +import Foundation +import InlineProtocol +import Testing + +@testable import Auth +@testable import InlineGrid +@testable import InlineRTC + +@Suite("Grid auth authority retirement", .serialized, .timeLimit(.minutes(1))) +@MainActor +struct GridAuthAuthorityRetirementTests { + enum ReplacementKind: CaseIterable, Sendable, Equatable { + case explicitNative, implicitNative, changedBearer + } + + @Test("same-account replacement waits for physical retirement before writing or publishing", + arguments: ReplacementKind.allCases) + func replacementWaitsForPhysicalRetirement(kind: ReplacementKind) async throws { + let fixture = GridAuthorityTestFixture() + do { + if kind == .changedBearer { + try await fixture.store.saveCredentials(token: "1:original", userId: 1) + } else { + try await fixture.seedAccount() + } + let oldAccount = try fixture.auth.beginAccountMutation() + try await fixture.admit() + fixture.service.toggleMicrophone(spaceID: 42) + try await eventuallyGridAuthority { + let recording = await fixture.audio.runtimeHealth().isRecording + let muted = await fixture.rtc.microphoneMuted + return recording && !muted + } + await fixture.audio.blockShutdown() + let previousWrites = fixture.writes.value + let attempt = kind == .explicitNative + ? try await fixture.auth.beginLoginAttempt(allowAuthenticated: true) : nil + let replacement = Task { + if kind == .changedBearer { + try await fixture.store.saveCredentials(token: "1:replacement", userId: 1) + } else { + try await fixture.auth.saveInlineProtocolCredentials( + fixture.credentials(sessionID: 11), loginAttempt: attempt + ) + } + if let attempt { return try await fixture.auth.finalizeCredentialsCommittedByLoginAttempt(attempt) } + return try fixture.auth.beginAccountMutation() + } + try await eventuallyGridAuthority { await fixture.audio.shutdownCalls > 0 } + #expect(!fixture.service.hasLocalAdmission) + #expect(fixture.service.mediaSessionIdentity == nil) + #expect(fixture.writes.value == previousWrites) + if kind == .changedBearer { + #expect(fixture.auth.token() == "1:original") + } else { + #expect(fixture.auth.inlineProtocolCredentials()?.accountSessionId == 10) + } + #expect(fixture.auth.hasPendingAccountTransition()) + await fixture.audio.releaseShutdown(succeeded: true) + let newAccount = try await replacement.value + #expect(newAccount.userID == oldAccount.userID) + #expect((newAccount != oldAccount) == (kind == .explicitNative)) + #expect(fixture.writes.value == previousWrites + 1) + if kind == .changedBearer { + #expect(fixture.auth.token() == "1:replacement") + } else { + #expect(fixture.auth.inlineProtocolCredentials()?.accountSessionId == 11) + } + #expect(!(await fixture.audio.runtimeHealth().isRecording)) + #expect(!fixture.service.hasLocalAdmission) + if kind == .explicitNative { + #expect((try? fixture.auth.validateAccountMutation(oldAccount)) == nil) + } else { + try fixture.auth.validateAccountMutation(oldAccount) + } + } catch { + await fixture.close() + throw error + } + await fixture.close() + } + + @Test("a failed local retirement rejects replacement and preserves prior authority without media readmission", + arguments: ReplacementKind.allCases) + func failedRetirementDoesNotWriteAuthority(kind: ReplacementKind) async throws { + let fixture = GridAuthorityTestFixture() + do { + if kind == .changedBearer { + try await fixture.store.saveCredentials(token: "1:original", userId: 1) + } else { + try await fixture.seedAccount() + } + let oldAccount = try fixture.auth.beginAccountMutation() + try await fixture.admit() + await fixture.audio.blockShutdown() + let previousWrites = fixture.writes.value + let attempt = kind == .explicitNative + ? try await fixture.auth.beginLoginAttempt(allowAuthenticated: true) : nil + let replacement = Task { + if kind == .changedBearer { + try await fixture.store.saveCredentials(token: "1:replacement", userId: 1) + } else { + try await fixture.auth.saveInlineProtocolCredentials( + fixture.credentials(sessionID: 11), loginAttempt: attempt + ) + } + } + try await eventuallyGridAuthority { await fixture.audio.shutdownCalls > 0 } + await fixture.audio.releaseShutdown(succeeded: false) + await #expect(throws: AuthStorageError.loginUnavailable) { try await replacement.value } + #expect(fixture.writes.value == previousWrites) + if kind == .changedBearer { + #expect(fixture.auth.token() == "1:original") + } else { + #expect(fixture.auth.inlineProtocolCredentials()?.accountSessionId == 10) + } + #expect(!fixture.auth.hasPendingAccountTransition()) + try fixture.auth.validateAccountMutation(oldAccount) + #expect(!fixture.service.hasLocalAdmission) + #expect(fixture.service.mediaSessionIdentity == nil) + } catch { + await fixture.close() + throw error + } + await fixture.close() + } + + enum UnchangedKind: CaseIterable, Sendable, Equatable { case temporaryKey, identicalBearer } + + @Test("temporary native renewal and identical implicit bearer keep the admitted call uninterrupted", + arguments: UnchangedKind.allCases) + func unchangedAuthorityDoesNotRetire(kind: UnchangedKind) async throws { + let fixture = GridAuthorityTestFixture() + do { + if kind == .identicalBearer { + try await fixture.store.saveCredentials(token: "1:original", userId: 1) + } else { + try await fixture.seedAccount() + } + let account = try fixture.auth.beginAccountMutation() + try await fixture.admit() + let identity = fixture.service.mediaSessionIdentity + let shutdownCalls = await fixture.audio.shutdownCalls + let oldWrites = fixture.writes.value + await fixture.audio.blockShutdown() + let renewal = Task { + if kind == .identicalBearer { + try await fixture.store.saveCredentials(token: "1:original", userId: 1) + } else { + var credentials = try fixture.credentials(sessionID: 10) + let key = Array((UInt8.min...UInt8.max).reversed()) + credentials.temporary = try InlineProtocolAuthorization( + key: key, keyID: InlineSecureTransport.authKeyID(key), serverSalt: 8, + temporary: true, expiresAt: Int32(Date().addingTimeInterval(3600).timeIntervalSince1970) + ) + try await fixture.auth.saveInlineProtocolCredentials(credentials) + } + } + try await eventuallyGridAuthority { + kind == .temporaryKey + ? fixture.auth.inlineProtocolCredentials()?.temporary != nil + : fixture.writes.value == oldWrites + 1 + } + try await renewal.value + try fixture.auth.validateAccountMutation(account) + #expect(fixture.service.hasLocalAdmission) + #expect(fixture.service.mediaSessionIdentity == identity) + #expect(await fixture.audio.shutdownCalls == shutdownCalls) + } catch { + await fixture.close() + throw error + } + await fixture.close() + } + + @Test("replacement waits for prior retirement even after Leave cleared admission") + func replacementWaitsAfterAdmissionCleared() async throws { + let fixture = GridAuthorityTestFixture() + do { + try await fixture.seedAccount() + try await fixture.admit() + await fixture.audio.blockShutdown() + let leaving = fixture.service.withdrawLocalAdmission() + #expect(!fixture.service.hasLocalAdmission) + let previousWrites = fixture.writes.value + let attempt = try await fixture.auth.beginLoginAttempt(allowAuthenticated: true) + let replacement = Task { + try await fixture.auth.saveInlineProtocolCredentials( + fixture.credentials(sessionID: 11), loginAttempt: attempt + ) + return try await fixture.auth.finalizeCredentialsCommittedByLoginAttempt(attempt) + } + try await eventuallyGridAuthority { + await fixture.audio.shutdownCalls > 0 && fixture.auth.hasPendingAccountTransition() + } + #expect(fixture.writes.value == previousWrites) + await fixture.audio.releaseShutdown(succeeded: true) + #expect(await leaving.value.isLocallyQuiescent) + _ = try await replacement.value + #expect(fixture.writes.value == previousWrites + 1) + #expect(!fixture.service.hasLocalAdmission) + } catch { + await fixture.close() + throw error + } + await fixture.close() + } + + @Test("replaying a completed authority request cannot retire a successor admission") + func staleAuthorityRequestCannotRetireSuccessor() async throws { + let fixture = GridAuthorityTestFixture() + var request: AuthAccountAuthorityRetirement? + let observation = fixture.auth.observeAccountAuthorityWillChange { request = $0 } + defer { NotificationCenter.default.removeObserver(observation) } + do { + try await fixture.seedAccount() + try await fixture.admit() + let attempt = try await fixture.auth.beginLoginAttempt(allowAuthenticated: true) + try await fixture.auth.saveInlineProtocolCredentials(fixture.credentials(sessionID: 11), loginAttempt: attempt) + _ = try await fixture.auth.finalizeCredentialsCommittedByLoginAttempt(attempt) + let oldRequest = try #require(request) + #expect(!oldRequest.isCurrent) + try await fixture.admit(membershipID: "membership-b", intentRevision: 1) + let connectedIdentity = fixture.service.mediaSessionIdentity + let shutdownCalls = await fixture.audio.shutdownCalls + NotificationCenter.default.post( + name: .authAccountAuthorityWillChange, object: fixture.cache, + userInfo: ["retirement": oldRequest] + ) + #expect(fixture.service.hasLocalAdmission) + #expect(fixture.service.mediaSessionIdentity == connectedIdentity) + #expect(fixture.service.currentCall?.membershipID == "membership-b") + #expect(await fixture.audio.shutdownCalls == shutdownCalls) + } catch { + await fixture.close() + throw error + } + await fixture.close() + } + + @Test("receipt submission precedes successor demand while engine startup is suspended") + func receiptDoesNotEnqueueBehindSuccessor() async throws { + let audio = GridAuthorityAudioDriver(configurationBlocked: true) + let rtc = GridAuthorityRTCDriver() + let engine = InlineRTCSession( + audioDriver: audio, permissionDriver: GridAuthorityPermission(), rtcDriver: rtc, + captureCooldown: .zero + ) + let oldRetirement = engine.requestShutdownReceipt() + let target = InlineRTCSessionID("grid-test:1:7:3") + engine.setDemand(InlineRTCDemand( + target: target, + credentials: .init( + target: target, serverURL: URL(string: "wss://grid.invalid")!, participantIdentity: "successor", + token: "fixture-only", expiresAt: Date().addingTimeInterval(120) + ), + microphoneEnabled: false, microphoneCapturePrepared: false + )) + try await eventuallyGridAuthority { await audio.configurationCalls > 0 } + await audio.allowConfiguration() + #expect(await oldRetirement.value.isLocallyQuiescent) + try await eventuallyGridAuthority { await rtc.connectedIdentities == ["successor"] } + let snapshots = await engine.subscribe() + var iterator = snapshots.makeAsyncIterator() + var connected = false + while let snapshot = await iterator.next() { + if snapshot.rtc.state == .connected(target) { connected = true; break } + } + #expect(connected) + #expect(await engine.shutdown().isLocallyQuiescent) + } + + @Test("unfinished and rejected mailbox shutdown commands return unproven receipts") + func unfinishedReceiptFailsClosed() async { + let mailbox = GridEngineCommandMailbox() + let queued = GridShutdownCompletion() + mailbox.enqueue(.shutdown(requestID: UUID(), completion: queued)) + mailbox.finish() + #expect(!(await queued.value().isLocallyQuiescent)) + let rejected = GridShutdownCompletion() + mailbox.enqueue(.shutdown(requestID: UUID(), completion: rejected)) + #expect(!(await rejected.value().isLocallyQuiescent)) + } +} diff --git a/apple/InlineKit/Tests/InlineGridTests/GridAuthorityTestSupport.swift b/apple/InlineKit/Tests/InlineGridTests/GridAuthorityTestSupport.swift new file mode 100644 index 000000000..348424419 --- /dev/null +++ b/apple/InlineKit/Tests/InlineGridTests/GridAuthorityTestSupport.swift @@ -0,0 +1,450 @@ +@testable import Auth +import Foundation +@testable import InlineGrid +import InlineProtocol +@testable import InlineRTC +@testable import RealtimeV2 +import Testing + +/// The production product/command/auth boundary, with only native I/O and network replaced. +/// Control storage stays unavailable so no Home/GetGrid response can repair a failed fence. +@MainActor +final class GridAuthorityTestFixture { + private let namespace = UUID().uuidString + private let defaults: UserDefaults + let cache: AuthSnapshotCache + let writes = GridAuthorityWriteProbe() + let store: AuthStore + let auth: AuthHandle + let audio: GridAuthorityAudioDriver + let rtc = GridAuthorityRTCDriver() + let engine: InlineRTCSession + let realtime: RealtimeV2 + let service: GridRoomService + private let control: GridAuthorityMembershipControl? + + init(configurationBlocked: Bool = false, control: GridAuthorityMembershipControl? = nil) { + self.control = control + defaults = UserDefaults(suiteName: "grid-authority-test-\(namespace)")! + cache = AuthSnapshotCache(initial: AuthSnapshot(status: .hydrating, didHydrate: false)) + let writes = writes + store = AuthStore( + cache: cache, mocked: true, namespace: namespace, + credentialWriteInterleavingHook: { writes.record() } + ) + auth = AuthHandle(cache: cache, store: store) + audio = GridAuthorityAudioDriver(configurationBlocked: configurationBlocked) + engine = InlineRTCSession( + audioDriver: audio, permissionDriver: GridAuthorityPermission(), rtcDriver: rtc, + captureCooldown: .zero + ) + realtime = RealtimeV2( + transport: MockTransport(), auth: auth, + applyUpdates: GridAuthorityNoUpdates(), syncStorage: GridAuthoritySyncStorage(), + storageIsReady: { false } + ) + let send: GridRoomAPI.Send? = if let control { + { transaction, token in try await control.send(transaction, account: token) } + } else { + nil + } + service = GridRoomService( + realtime: realtime, userDefaults: defaults, engine: engine, auth: auth, + sendGridTransaction: send + ) + } + + func credentials(sessionID: Int64) throws -> InlineProtocolSessionCredentials { + let key = Array(UInt8.min ... UInt8.max) + return try InlineProtocolSessionCredentials( + userId: 1, accountSessionId: sessionID, + permanent: InlineProtocolAuthorization( + key: key, keyID: InlineSecureTransport.authKeyID(key), serverSalt: 7, + temporary: false, expiresAt: nil + ) + ) + } + + @discardableResult + func seedAccount() async throws -> AuthAccountMutationToken { + try await auth.saveInlineProtocolCredentials(credentials(sessionID: 10)) + return try auth.beginAccountMutation() + } + + func admit(membershipID: String = "membership-a", intentRevision: Int = 0) async throws { + let token = try auth.beginAccountMutation() + let response = claimResult(membershipID: membershipID) + let operation = GridMembershipOperation( + kind: .join(roomID: 7), spaceID: 42, expectedMembershipID: "", + intentRevision: intentRevision, accountToken: token, microphoneEnabled: false, + automaticMicrophoneChange: nil, accessRevision: 0, revision: intentRevision + 1, + startedAt: Date() + ) + await service.handle(.joined(operation, response)) + try await eventuallyGridAuthority { self.service.media.connectionState == .connected } + #expect(service.hasLocalAdmission) + } + + func claimResult(membershipID: String, revision: Int64 = 1) -> GridRoomMutationResult { + let call = GridCurrentCall.with { + $0.callID = "call-\(membershipID)" + $0.membershipID = membershipID + $0.spaceID = 42 + $0.roomID = 7 + $0.ownedByCurrentSession = true + } + let grid = InlineProtocol.Grid.with { + $0.spaceID = 42 + $0.enabled = true + $0.currentRoomID = 7 + $0.revision = revision + $0.rooms = [.with { + $0.id = 7 + $0.spaceID = 42 + $0.connection = .with { $0.roomID = 7 + $0.generation = 3 + } + $0.avatars = [ + .with { + $0.user = .with { $0.id = 1 } + $0.membershipID = membershipID + $0.ownedByCurrentSession = true + }, + .with { $0.user = .with { $0.id = 2 } + $0.membershipID = "remote" + }, + ] + }] + } + let credentials = GridConnectionCredentials.with { + $0.connection = grid.rooms[0].connection + $0.callID = call.callID + $0.membershipID = membershipID + $0.serverURL = "wss://grid.invalid" + $0.participantIdentity = "inline-grid-user-1-\(membershipID)" + $0.token = "fixture-only" + $0.expiresAt = Int64(Date().addingTimeInterval(120).timeIntervalSince1970) + } + return GridRoomMutationResult(grids: [grid], credentials: credentials, currentCall: call) + } + + func enableTransferForPublicActions() async throws { + guard let control, let call = service.currentCall else { throw GridRoomAPIError.invalidResponse } + control.setupHome = .with { + $0.spaces = [.with { $0.spaceID = 42 + $0.activeAvatarCount = 2 + }] + $0.currentCall = call + $0.callTransferEnabled = true + } + await service.handle(GridRoomLifecycleEvent.realtimeConnection(.connected)) + control.setupHome = nil + #expect(service.callTransferEnabled) + } + + func close() async { + control?.failAll() + await audio.allowConfiguration() + await audio.releaseShutdown(succeeded: true) + await service.prepareForLogout() + await realtime.loggedOut() + AuthKeychainConfig.mockDelete("token", namespace: namespace) + AuthKeychainConfig.mockDelete("credentials_v2", namespace: namespace) + AuthKeychainConfig.mockDelete("inline_protocol_credentials_v1", namespace: namespace) + let prefix = AuthKeychainConfig.userDefaultsPrefix(mocked: true, namespace: namespace) + for suffix in ["userId", "logoutPending", "logoutAttemptID", "loginCommitPendingAttemptID"] { + UserDefaults.standard.removeObject(forKey: "\(prefix)\(suffix)") + } + } +} + +/// The typed API's actual transaction send boundary. It deliberately ignores task cancellation +/// while a reply is held, reproducing a provider completion after its worker was invalidated. +@MainActor +final class GridAuthorityMembershipControl { + struct Submission { + let method: InlineProtocol.Method + let account: AuthAccountMutationToken + } + + var setupHome: GetGridHomeResult? + private(set) var submissions: [Submission] = [] + private(set) var completed = Set() + private var pending: [Int: CheckedContinuation] = [:] + + func send(_ transaction: any Transaction2, account: AuthAccountMutationToken) async throws -> RpcResult + .OneOf_Result? + { + switch transaction.method { + case .getGridHome: + guard let setupHome else { throw GridRoomAPIError.invalidResponse } + return .getGridHome(setupHome) + case .createGridRoom, .joinGridRoom, .leaveGridRoom, .moveGridCallHere: + let index = submissions.count + submissions.append(Submission(method: transaction.method, account: account)) + defer { completed.insert(index) } + return try await withCheckedThrowingContinuation { pending[index] = $0 } + default: + // GetGrid/Home repair remains unavailable throughout the auth transition and late reply. + throw GridRoomAPIError.invalidResponse + } + } + + func succeed(_ index: Int, with response: GridRoomMutationResult) { + guard let continuation = pending.removeValue(forKey: index) else { return } + let result: RpcResult.OneOf_Result + switch submissions[index].method { + case .createGridRoom: + result = .createGridRoom(.with { + $0.grids = response.grids + if let call = response.currentCall { + $0.currentCall = call + } + if let credentials = response.credentials { + $0.connection = credentials + } + }) + case .joinGridRoom: + result = .joinGridRoom(.with { + $0.grids = response.grids + if let call = response.currentCall { + $0.currentCall = call + } + if let credentials = response.credentials { + $0.connection = credentials + } + }) + case .leaveGridRoom: + result = .leaveGridRoom(.with { + $0.grids = response.grids + if let call = response.currentCall { + $0.currentCall = call + } + }) + case .moveGridCallHere: + result = .moveGridCallHere(.with { + $0.grids = response.grids + $0.moved = response.moved + if let call = response.currentCall { + $0.currentCall = call + } + if let credentials = response.credentials { + $0.connection = credentials + } + }) + default: + continuation.resume(throwing: GridRoomAPIError.invalidResponse) + return + } + continuation.resume(returning: result) + } + + func fail(_ index: Int) { + pending.removeValue(forKey: index)?.resume(throwing: GridRoomAPIError.invalidResponse) + } + + func failAll() { + let continuations = Array(pending.values) + pending.removeAll() + continuations.forEach { $0.resume(throwing: CancellationError()) } + } +} + +@MainActor +func eventuallyGridAuthority(_ predicate: @escaping @MainActor () async -> Bool) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(3)) + while await !predicate() { + guard ContinuousClock.now < deadline else { + throw GridAuthorityTestTimeout() + } + try await Task.sleep(for: .milliseconds(5)) + } +} + +private struct GridAuthorityTestTimeout: Error {} + +actor GridAuthorityAudioDriver: GridAudioDriver { + private var configurationBlocked: Bool + private var configurationWaiters: [CheckedContinuation] = [] + private var shutdownBlocked = false + private var shutdownSucceeds = true + private var shutdownWaiters: [CheckedContinuation] = [] + private(set) var shutdownCalls = 0 + private(set) var configurationCalls = 0 + private var recording = false + private var playing = false + + init(configurationBlocked: Bool) { + self.configurationBlocked = configurationBlocked + } + + func configure(_: InlineRTCConfiguration) async throws { + configurationCalls += 1 + if configurationBlocked { + await withCheckedContinuation { configurationWaiters.append($0) } + } + } + + func allowConfiguration() { + configurationBlocked = false + let waiters = configurationWaiters + configurationWaiters.removeAll() + waiters.forEach { $0.resume() } + } + + func setMediaDemandActive(_ active: Bool, epoch _: UInt64) async throws { + playing = active + } + + func setPrepared(_ prepared: Bool) async throws { + if prepared || !shutdownBlocked { + recording = prepared + } + } + + func blockShutdown() { + shutdownBlocked = true + } + + func releaseShutdown(succeeded: Bool) { + shutdownBlocked = false + shutdownSucceeds = succeeded + let waiters = shutdownWaiters + shutdownWaiters.removeAll() + waiters.forEach { $0.resume() } + } + + func stopForShutdown() async -> GridAudioDriverShutdownReceipt { + shutdownCalls += 1 + if shutdownBlocked { + await withCheckedContinuation { shutdownWaiters.append($0) } + } + if shutdownSucceeds { + recording = false + playing = false + } + return .init( + recordingStopped: shutdownSucceeds, playoutStopped: shutdownSucceeds, + failures: shutdownSucceeds ? [] : ["Fixture native teardown denied"] + ) + } + + func runtimeHealth() async -> GridAudioRuntimeHealth { + .init(isEngineRunning: true, isRecording: recording, isPlaying: playing, route: nil) + } + + func applyInputRoute( + _: AudioInputRouteTarget, + restartPreparedAudio _: Bool + ) async throws -> GridAudioInputRouteApplication { + .committed + } + + func inputDeviceInventory() async -> AudioInputDeviceInventory { + .init(automaticDeviceID: "fixture", automaticDeviceName: "Fixture", devices: [], routeEpoch: 0) + } +} + +actor GridAuthorityRTCDriver: GridRTCDriver { + private(set) var connectedIdentities: [String] = [] + private(set) var microphoneMuted = true + func makeRoom(configuration _: InlineRTCConfiguration) async throws -> GridRTCRoomHandle { + .init() + } + + func connect(_: GridRTCRoomHandle, credentials: InlineRTCCredentials) async throws { + connectedIdentities.append(credentials.participantIdentity) + } + + func publishPreparedMicrophone(_: GridRTCRoomHandle, initiallyMuted: Bool) async throws { + microphoneMuted = initiallyMuted + } + + func setMicrophoneMuted(_ muted: Bool, in _: GridRTCRoomHandle) async throws { + microphoneMuted = muted + } + + func screenCaptureSources() async throws -> [InlineRTCScreenCaptureSource] { + [] + } + + func setScreenShare( + _: InlineRTCScreenCaptureSource?, + qualityProfile _: InlineRTCScreenShareQualityProfile, + in _: GridRTCRoomHandle + ) async throws {} + func quiesceLocally(_ room: GridRTCRoomHandle) async -> GridLocalRoomQuiescenceReceipt { + microphoneMuted = true + return .init(room: room, localResourcesReleased: true, failures: []) + } +} + +struct GridAuthorityPermission: GridMicrophonePermissionDriver { + func status() async -> InlineRTCMicrophonePermission { + .authorized + } + + func request() async -> InlineRTCMicrophonePermission { + .authorized + } +} + +final class GridAuthorityWriteProbe: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + var value: Int { + lock.withLock { count } + } + + func record() { + lock.withLock { count += 1 } + } +} + +private struct GridAuthorityNoUpdates: ApplyUpdates { + func apply( + updates: [InlineProtocol.Update], + source _: UpdateApplySource, + sidecars _: InlineProtocol.UpdateSidecars? + ) async -> UpdateApplyResult { + .success(count: updates.count) + } +} + +private struct GridAuthoritySyncStorage: SyncStorage { + func canonicalPeer(forChatID _: Int64) async throws -> Peer? { + nil + } + + func getState() async throws -> SyncState { + .init(lastSyncDate: 0) + } + + func setState(_: SyncState) async -> Bool { + true + } + + func getBucketState(for _: BucketKey) async throws -> BucketState { + .init(date: 0, seq: 0) + } + + func setBucketState(for _: BucketKey, state _: BucketState) async -> Bool { + true + } + + func advanceBucketState(for _: BucketKey, state: BucketState) async -> BucketState? { + state + } + + func removeBucketState(for _: BucketKey) async -> Bool { + true + } + + func setBucketStates(states _: [BucketKey: BucketState]) async -> Bool { + true + } + + func clearSyncState() async -> Bool { + true + } +} diff --git a/apple/InlineKit/Tests/InlineGridTests/GridCallClaimAdmissionTests.swift b/apple/InlineKit/Tests/InlineGridTests/GridCallClaimAdmissionTests.swift new file mode 100644 index 000000000..826693420 --- /dev/null +++ b/apple/InlineKit/Tests/InlineGridTests/GridCallClaimAdmissionTests.swift @@ -0,0 +1,85 @@ +@testable import InlineGrid +import InlineProtocol +import Testing + +@Suite("Grid committed call admission") +struct GridCallClaimAdmissionTests { + private func call(membershipID: String = "membership-a", owned: Bool = true) -> GridCurrentCall { + .with { + $0.callID = "stable-call" + $0.membershipID = membershipID + $0.spaceID = 42 + $0.roomID = 7 + $0.ownedByCurrentSession = owned + } + } + + private func authority(membershipID: String = "membership-a") -> GridOwnedRoomAuthority { + .init(spaceID: 42, roomID: 7, membershipID: membershipID) + } + + private func grid(membershipID: String = "membership-a", owned: Bool = true) -> InlineProtocol.Grid { + .with { + $0.spaceID = 42 + $0.enabled = true + $0.rooms = [.with { + $0.id = 7 + $0.avatars = [.with { + $0.membershipID = membershipID + $0.ownedByCurrentSession = owned + }] + }] + } + } + + @Test("the claim survives its own changed-event repair when exact membership agrees") + func ownChangedEventDoesNotLoseClaim() { + let claim = call() + #expect(GridCallClaimAdmission.accepts( + committedCall: claim, newestGrid: grid(), acceptedAuthority: authority() + )) + } + + @Test("an A to B to A move cannot admit the first A response") + func oldOwnerIncarnationCannotRevive() { + #expect(!GridCallClaimAdmission.accepts( + committedCall: call(membershipID: "a-first"), + newestGrid: grid(membershipID: "a-second"), + acceptedAuthority: authority(membershipID: "a-second") + )) + } + + @Test("a later raw Leave prevents an old claim from admitting even if UI still shows its avatar") + func laterLeaveWins() { + #expect(!GridCallClaimAdmission.accepts( + committedCall: call(), newestGrid: grid(), acceptedAuthority: nil + )) + } + + @Test("a claimed owner loses admission if the newest Grid shows takeover") + func newerGridOwnershipWinsWithoutSelfDelivery() { + #expect(!GridCallClaimAdmission.accepts( + committedCall: call(), newestGrid: grid(owned: false), acceptedAuthority: nil + )) + } + + @Test("missing Grid or disabled space cannot grant media access") + func missingAuthorizationFailsClosed() { + #expect(!GridCallClaimAdmission.accepts( + committedCall: call(), newestGrid: nil, acceptedAuthority: nil + )) + var disabled = grid() + disabled.enabled = false + #expect(!GridCallClaimAdmission.accepts( + committedCall: call(), newestGrid: disabled, acceptedAuthority: authority() + )) + } + + @Test("an optimistic owned avatar cannot override a raw takeover snapshot") + func optimisticAvatarCannotGrantAuthority() { + #expect(!GridCallClaimAdmission.accepts( + committedCall: call(), newestGrid: grid(), + acceptedAuthority: authority(membershipID: "newer-owner-membership") + )) + } +} diff --git a/apple/InlineKit/Tests/InlineGridTests/GridCredentialBindingTests.swift b/apple/InlineKit/Tests/InlineGridTests/GridCredentialBindingTests.swift new file mode 100644 index 000000000..32d69da58 --- /dev/null +++ b/apple/InlineKit/Tests/InlineGridTests/GridCredentialBindingTests.swift @@ -0,0 +1,80 @@ +@testable import InlineGrid +import InlineProtocol +import Testing + +@Suite("Grid credential authority binding") +struct GridCredentialBindingTests { + private var target: GridMediaTarget { + .init(callID: "call-a", membershipID: "owner-a", spaceID: 42, roomID: 7, generation: 3) + } + + private func credentials( + callID: String = "call-a", + membershipID: String = "owner-a", + participantIdentity: String = "inline-grid-user-11-owner-a" + ) -> GridConnectionCredentials { + .with { + $0.connection = .with { $0.roomID = 7 + $0.generation = 3 + } + $0.callID = callID + $0.membershipID = membershipID + $0.participantIdentity = participantIdentity + } + } + + @Test("active transfer contract requires stable call and current owner together") + func strictContractBindsBothIdentities() { + #expect(GridCredentialBinding.accepts( + credentials: credentials(), target: target, userID: 11, callTransferEnabled: true + )) + #expect(!GridCredentialBinding.accepts( + credentials: credentials(callID: "previous-call"), target: target, userID: 11, callTransferEnabled: true + )) + #expect(!GridCredentialBinding.accepts( + credentials: credentials(membershipID: "previous-owner"), target: target, userID: 11, callTransferEnabled: true + )) + } + + @Test("old server's absent binding fields remain compatible only capability OFF") + func oldServerCompatibilityStaysOffOnly() { + let legacy = credentials(callID: "", membershipID: "") + #expect(GridCredentialBinding.accepts( + credentials: legacy, target: target, userID: 11, callTransferEnabled: false + )) + #expect(!GridCredentialBinding.accepts( + credentials: legacy, target: target, userID: 11, callTransferEnabled: true + )) + } + + @Test("legacy compatibility never accepts another account or owner incarnation") + func legacyIdentityStillHasExactAuthority() { + #expect(!GridCredentialBinding.accepts( + credentials: credentials(callID: "", membershipID: "", participantIdentity: "inline-grid-user-12-owner-a"), + target: target, userID: 11, callTransferEnabled: false + )) + #expect(!GridCredentialBinding.accepts( + credentials: credentials(callID: "", membershipID: "", participantIdentity: "inline-grid-user-11-previous-owner"), + target: target, userID: 11, callTransferEnabled: false + )) + } + + @Test("supplied binding fields are never ignored in OFF compatibility mode") + func nonemptyBindingsStayStrictWhenOff() { + #expect(!GridCredentialBinding.accepts( + credentials: credentials(callID: "previous-call"), target: target, userID: 11, callTransferEnabled: false + )) + #expect(!GridCredentialBinding.accepts( + credentials: credentials(membershipID: "previous-owner"), target: target, userID: 11, callTransferEnabled: false + )) + } + + @Test("same owner credentials cannot cross a room generation") + func generationIsAlsoRequired() { + var stale = credentials() + stale.connection.generation = 2 + #expect(!GridCredentialBinding.accepts( + credentials: stale, target: target, userID: 11, callTransferEnabled: false + )) + } +} diff --git a/apple/InlineKit/Tests/InlineGridTests/GridMembershipRevocationTests.swift b/apple/InlineKit/Tests/InlineGridTests/GridMembershipRevocationTests.swift new file mode 100644 index 000000000..e7b40220d --- /dev/null +++ b/apple/InlineKit/Tests/InlineGridTests/GridMembershipRevocationTests.swift @@ -0,0 +1,89 @@ +import Auth +import Foundation +import InlineProtocol +import Testing + +@testable import InlineGrid + +@Suite("Grid revoked membership responses", .serialized, .timeLimit(.minutes(1))) +@MainActor +struct GridMembershipRevocationTests { + enum ResponseKind: CaseIterable, Sendable { + case create, join, move, leave + } + + @Test( + "a held membership result cannot restore a space after its access is revoked", + arguments: ResponseKind.allCases) + func revokedSpaceCannotReturn(kind: ResponseKind) async throws { + let fixture = GridAuthorityTestFixture() + do { + try await exerciseRevokedResponse(kind: kind, fixture: fixture) + } catch { + await fixture.close() + throw error + } + await fixture.close() + } + + private func exerciseRevokedResponse(kind: ResponseKind, fixture: GridAuthorityTestFixture) + async throws + { + let token = try await fixture.seedAccount() + try await fixture.admit() + let call = try #require(fixture.service.currentCall) + var heldGrid = try #require(fixture.service.grid(spaceID: call.spaceID)) + heldGrid.revision += 1 + #expect(fixture.service.isEnabled(spaceID: call.spaceID)) + #expect(fixture.service.hasLocalAdmission) + + await fixture.service.handle( + GridEvent.with { + $0.accessRevoked = .with { $0.spaceID = call.spaceID } + }) + #expect(fixture.service.grid(spaceID: call.spaceID) == nil) + #expect(!fixture.service.isEnabled(spaceID: call.spaceID)) + #expect(!fixture.service.hasLocalAdmission) + + let operationKind: GridMembershipOperation.Kind = + switch kind { + case .create: .create + case .join: .join(roomID: call.roomID) + case .move: .move(callID: call.callID, roomID: call.roomID) + case .leave: .leave(roomID: call.roomID) + } + let operation = GridMembershipOperation( + kind: operationKind, + spaceID: call.spaceID, + expectedMembershipID: call.membershipID, + intentRevision: 0, + accountToken: token, + microphoneEnabled: false, + automaticMicrophoneChange: nil, + accessRevision: 0, + revision: 2, + startedAt: Date() + ) + let response = GridRoomMutationResult( + grids: [heldGrid], credentials: nil, currentCall: call + ) + let event: GridMembershipSyncEvent = + switch kind { + case .create: .created(operation, response) + case .join: .joined(operation, response) + case .move: .moved(operation, response) + case .leave: .left(operation, response) + } + // The real event handler attempts Home repair through the unavailable + // control connection. Rejection must hold without a successful refetch. + await fixture.service.handle(event) + #expect(fixture.service.grid(spaceID: call.spaceID) == nil) + #expect(!fixture.service.isEnabled(spaceID: call.spaceID)) + #expect(fixture.service.recentAvatars(spaceID: call.spaceID).isEmpty) + #expect(!fixture.service.homeSpaces.contains { $0.spaceID == call.spaceID }) + #expect(fixture.service.currentCall == nil) + #expect(!fixture.service.hasLocalAdmission) + #expect(fixture.service.mediaSessionIdentity == nil) + #expect(!fixture.service.membershipMutationInFlight) + } +} diff --git a/apple/InlineKit/Tests/InlineGridTests/GridPendingAuthorityRetirementTests.swift b/apple/InlineKit/Tests/InlineGridTests/GridPendingAuthorityRetirementTests.swift new file mode 100644 index 000000000..1ffcecad0 --- /dev/null +++ b/apple/InlineKit/Tests/InlineGridTests/GridPendingAuthorityRetirementTests.swift @@ -0,0 +1,170 @@ +import Auth +import Foundation +@testable import InlineGrid +import InlineProtocol +import Testing + +@Suite("Grid pending membership auth retirement", .serialized, .timeLimit(.minutes(1))) +@MainActor +struct GridPendingAuthorityRetirementTests { + enum PendingKind: CaseIterable, Sendable { + case create, join, move, leave + + var method: InlineProtocol.Method { + switch self { + case .create: .createGridRoom + case .join: .joinGridRoom + case .move: .moveGridCallHere + case .leave: .leaveGridRoom + } + } + + @MainActor + func submit(to service: GridRoomService) { + switch self { + case .create: service.createAndJoin(spaceID: 42) + case .join: service.join(roomID: 7) + case .move: service.moveCallHere() + case .leave: service.leaveCurrentRoom(spaceID: 42) + } + } + + var operationKind: GridMembershipOperation.Kind { + switch self { + case .create: .create + case .join: .join(roomID: 7) + case .move: .move(callID: "call-membership-a", roomID: 7) + case .leave: .leave(roomID: 7) + } + } + + func event(operation: GridMembershipOperation, result: GridRoomMutationResult) -> GridMembershipSyncEvent { + switch self { + case .create: .created(operation, result) + case .join: .joined(operation, result) + case .move: .moved(operation, result) + case .leave: .left(operation, result) + } + } + } + + enum Replacement: CaseIterable, Sendable, Equatable { + case explicit, implicit, denied + } + + @Test( + "held public membership work cannot block or clear a successor claim across auth replacement", + arguments: PendingKind.allCases, + Replacement.allCases + ) + func pendingOperationCannotOwnSuccessor(kind: PendingKind, replacement outcome: Replacement) async throws { + let control = GridAuthorityMembershipControl() + let fixture = GridAuthorityTestFixture(control: control) + do { + try await exercise(kind: kind, replacement: outcome, fixture: fixture, control: control) + } catch { + await fixture.close() + throw error + } + await fixture.close() + } + + private func exercise( + kind: PendingKind, replacement outcome: Replacement, + fixture: GridAuthorityTestFixture, control: GridAuthorityMembershipControl + ) async throws { + let oldAccount = try await fixture.seedAccount() + try await fixture.admit() + try await fixture.enableTransferForPublicActions() + let confirmed = try #require(fixture.service.grid(spaceID: 42)) + await fixture.audio.blockShutdown() + kind.submit(to: fixture.service) + #expect(fixture.service.membershipMutationInFlight) + try await eventuallyGridAuthority { control.submissions.count == 1 } + #expect(control.submissions[0].method == kind.method) + #expect(control.submissions[0].account == oldAccount) + + let attempt = outcome == .implicit + ? nil : try await fixture.auth.beginLoginAttempt(allowAuthenticated: true) + let replacing = Task { + try await fixture.auth.saveInlineProtocolCredentials( + fixture.credentials(sessionID: 11), loginAttempt: attempt + ) + if let attempt { + return try await fixture.auth.finalizeCredentialsCommittedByLoginAttempt(attempt) + } + return try fixture.auth.beginAccountMutation() + } + // The old sender is still suspended and deliberately ignores cancellation. Claim bookkeeping + // must be released by the synchronous auth boundary, before its physical barrier completes. + try await eventuallyGridAuthority { + fixture.auth.hasPendingAccountTransition() && !fixture.service.membershipMutationInFlight + } + #expect(!control.completed.contains(0)) + #expect(!fixture.service.hasLocalAdmission) + #expect(fixture.service.currentCall?.membershipID == "membership-a") + #expect(fixture.service.grid(spaceID: 42)?.currentRoomID == confirmed.currentRoomID) + #expect(fixture.service.grid(spaceID: 42)?.rooms[0].avatars.contains { + $0.ownedByCurrentSession && $0.membershipID == "membership-a" + } == true) + + await fixture.audio.releaseShutdown(succeeded: outcome != .denied) + if outcome == .denied { + await #expect(throws: AuthStorageError.loginUnavailable) { try await replacing.value } + try fixture.auth.validateAccountMutation(oldAccount) + #expect(fixture.auth.inlineProtocolCredentials()?.accountSessionId == 10) + // Physical recovery is explicit. The original account may try again once stop is proven. + await fixture.audio.releaseShutdown(succeeded: true) + #expect(await fixture.engine.requestShutdownReceipt().value.isLocallyQuiescent) + } else { + let newAccount = try await replacing.value + #expect((newAccount == oldAccount) == (outcome == .implicit)) + } + + fixture.service.createAndJoin(spaceID: 42) + #expect(fixture.service.membershipMutationInFlight) + try await eventuallyGridAuthority { control.submissions.count == 2 } + #expect(control.submissions[1].method == .createGridRoom) + #expect(!control.completed.contains(0)) + + let oldResponse = fixture.claimResult(membershipID: "old-held", revision: 4) + if kind == .leave { + control.fail(0) + } else { + control.succeed(0, with: oldResponse) + } + try await eventuallyGridAuthority { control.completed.contains(0) } + // A real worker can also have broadcast its event just before reset. Replay that already- + // buffered result through the production handler while the new public claim remains pending. + let oldOperation = GridMembershipOperation( + kind: kind.operationKind, spaceID: 42, expectedMembershipID: "membership-a", + intentRevision: 1, accountToken: oldAccount, microphoneEnabled: false, + automaticMicrophoneChange: nil, accessRevision: 0, revision: 1, startedAt: Date() + ) + let oldEvent = kind.event(operation: oldOperation, result: oldResponse) + await fixture.service.handle(oldEvent) + await fixture.service.handle(.failed(oldOperation, message: "old queued failure")) + #expect(fixture.service.membershipMutationInFlight) + #expect(fixture.service.grid(spaceID: 42)?.revision == confirmed.revision) + #expect(fixture.service.currentCall?.membershipID == "membership-a") + #expect(!fixture.service.hasLocalAdmission) + #expect(fixture.service.lastError != "old queued failure") + + control.succeed(1, with: fixture.claimResult(membershipID: "successor", revision: 3)) + try await eventuallyGridAuthority { + fixture.service.hasLocalAdmission && !fixture.service.membershipMutationInFlight + && fixture.service.media.connectionState == .connected + } + #expect(fixture.service.currentCall?.membershipID == "successor") + let successorIdentity = fixture.service.mediaSessionIdentity + #expect(successorIdentity != nil) + // Normal responses above really admitted media. An older success or failure may neither + // replace its snapshot nor tear it down after the original sender finally returns. + await fixture.service.handle(oldEvent) + await fixture.service.handle(.failed(oldOperation, message: "old queued failure")) + #expect(fixture.service.hasLocalAdmission) + #expect(fixture.service.mediaSessionIdentity == successorIdentity) + #expect(fixture.service.currentCall?.membershipID == "successor") + #expect(fixture.service.grid(spaceID: 42)?.revision == 3) + } +} diff --git a/apple/InlineKit/Tests/InlineKitTests/AcknowledgementTests.swift b/apple/InlineKit/Tests/InlineKitTests/AcknowledgementTests.swift index 16ba624de..767a9ebe3 100644 --- a/apple/InlineKit/Tests/InlineKitTests/AcknowledgementTests.swift +++ b/apple/InlineKit/Tests/InlineKitTests/AcknowledgementTests.swift @@ -215,11 +215,15 @@ struct AcknowledgementTests { } } - @Test func coldThreadHistoryHydratesCursorBeforeFirstProjection() throws { + @Test func admittedThreadHistoryHydratesCursorBeforeFirstProjection() throws { let queue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration(passphrase: "123")) _ = try AppDatabase(queue) try queue.write { db in + try InlineKit.Chat(id: 100, date: Date(), type: .thread, title: "Cold thread", spaceId: nil).insert(db) + var transaction = GetChatHistoryTransaction(peer: .thread(id: 100)) + transaction.context.admissionToken = try HistoryPageAdmissionToken.capture(db, chatId: 100) var response = InlineProtocol.GetChatHistoryResult() + response.seq = 0 response.messages = [.with { $0.id = 10 $0.chatID = 100 @@ -232,10 +236,10 @@ struct AcknowledgementTests { $0.chatID = 100; $0.userID = 1; $0.maxID = 10; $0.revision = 7 $0.user = .with { $0.id = 1; $0.firstName = "Actor" } }] - #expect(try InlineKit.Chat.fetchCount(db) == 0) + #expect(try InlineKit.Chat.fetchCount(db) == 1) try GetChatHistoryTransaction.apply( response, - context: GetChatHistoryTransaction(peer: .thread(id: 100)).context, + context: transaction.context, db: db ) let firstFrame = try #require(try FullMessage.queryRequest(currentUserId: 1).fetchOne(db)) @@ -258,6 +262,7 @@ struct AcknowledgementTests { ) try message.saveMessage(db) } + try MessageHistoryCoverageStore.subtract(db, chatId: 100, lowerId: 10, upperId: 14) for cleared in [false, true] { let cursor = InlineProtocol.ChatAcknowledgement.with { $0.chatID = 100; $0.userID = 1; $0.maxID = 12 diff --git a/apple/InlineKit/Tests/InlineKitTests/AnchoredMessageWindowTests.swift b/apple/InlineKit/Tests/InlineKitTests/AnchoredMessageWindowTests.swift index 7379285c1..3a1db5961 100644 --- a/apple/InlineKit/Tests/InlineKitTests/AnchoredMessageWindowTests.swift +++ b/apple/InlineKit/Tests/InlineKitTests/AnchoredMessageWindowTests.swift @@ -20,12 +20,21 @@ struct AnchoredMessageWindowTests { let fixture = try await Fixture() let model = fixture.model(rows: Array(fixture.messages[9 ... 19]), limit: nil) defer { model.dispose() } - model.setHistoryAnchor(15) // ignored without the experimental window limit fixture.publisher.publisher.send(.add(.init(messages: [fixture.messages[99]], peer: .thread(id: 1)))) #expect(model.messages.last?.message.messageId == 100) #expect(model.messages.count == 12) } + @Test @MainActor + func unboundedHistoricalWindowDoesNotAppendTheLiveTail() async throws { + let fixture = try await Fixture() + let model = fixture.model(rows: Array(fixture.messages[9 ... 19]), limit: nil) + defer { model.dispose() } + model.setHistoryAnchor(15) + fixture.publisher.publisher.send(.add(.init(messages: [fixture.messages[99]], peer: .thread(id: 1)))) + #expect(model.messages.map(\.message.messageId) == Array(Int64(10) ... 20)) + } + @Test @MainActor func aroundAndLatestReplacementKeepPreparedThreadContext() async throws { let fixture = try await Fixture() diff --git a/apple/InlineKit/Tests/InlineKitTests/ChatRepairReplacementTests.swift b/apple/InlineKit/Tests/InlineKitTests/ChatRepairReplacementTests.swift index 6dcdcc29b..c9654729f 100644 --- a/apple/InlineKit/Tests/InlineKitTests/ChatRepairReplacementTests.swift +++ b/apple/InlineKit/Tests/InlineKitTests/ChatRepairReplacementTests.swift @@ -210,9 +210,11 @@ struct ChatRepairReplacementTests { #expect(dialog.readInboxMaxId == 3) #expect(dialog.unreadCount == 9) #expect(dialog.open) - #expect(try MessageHistoryCoverageStore.holes(db, chatId: 7) == [ - MessageHistoryHole(chatId: 7, lowerId: 1, upperId: 10), - ]) + for scope in MessageHistoryScope.allCases { + #expect(try MessageHistoryCoverageStore.holes(db, chatId: 7, scope: scope) == [ + MessageHistoryHole(chatId: 7, scope: scope, lowerId: 1, upperId: MessageHistoryHole.positiveMessageIDMax), + ]) + } } } @@ -345,8 +347,8 @@ struct ChatRepairReplacementTests { } } - @Test("a parent reference requires the exact parent message row") - func missingParentMessageDoesNotAdvance() async throws { + @Test("valid parent coordinates survive an unavailable parent message body") + func parentCoordinatesOutliveMissingMessage() async throws { let (queue, engine) = try makeRepairDatabase(cursor: 1, title: "Stale") try await queue.write { (db: Database) throws in try Chat( @@ -361,7 +363,7 @@ struct ChatRepairReplacementTests { snapshot.chat.parentChatID = 8 snapshot.chat.parentMessageID = 55 - let missing = await engine.applyChatRepair(ChatRepairSnapshot( + let withoutBody = await engine.applyChatRepair(ChatRepairSnapshot( peer: chatPeer(7), chat: snapshot, pinnedMessages: [], @@ -369,12 +371,16 @@ struct ChatRepairReplacementTests { mutationToken: accountToken(), reason: "missing-parent-message" )) - #expect(missing == nil) + #expect(withoutBody?.seq == 5) try await queue.read { (db: Database) throws in - #expect(try Chat.fetchOne(db, id: 7)?.title == "Stale") + let repaired = try #require(try Chat.fetchOne(db, id: 7)) + #expect(repaired.title == "Repaired") + #expect(repaired.parentChatId == 8) + #expect(repaired.parentMessageId == 55) + #expect(try Message.filter(Message.Columns.chatId == 8).fetchCount(db) == 0) #expect(try DbBucketState .filter(DbBucketState.Columns.entityId == BucketKey.chat(peer: chatPeer(7)).getEntityId()) - .fetchOne(db)?.seq == 1) + .fetchOne(db)?.seq == 5) } try await queue.write { (db: Database) throws in @@ -406,24 +412,56 @@ struct ChatRepairReplacementTests { } } - @Test("an exact authoritative anchor resolves the parent dependency") - func anchorMessageResolvesParentReference() async throws { + @Test("child parent coordinates never authorize an anchor body or overlay", arguments: [false, true]) + func childSnapshotCannotAuthorizeParentAnchor(anchorMatches: Bool) async throws { let (queue, engine) = try makeRepairDatabase(cursor: 1, title: "Stale") var snapshot = repairedChatResult() snapshot.chat.parentChatID = 8 snapshot.chat.parentMessageID = 55 - snapshot.anchorMessage = protocolMessage(id: 55, chatID: 8, fromID: 1) + snapshot.anchorMessage = protocolMessage(id: anchorMatches ? 55 : 54, chatID: 8, fromID: 1) + + try await queue.write { (db: Database) throws in + try Chat(id: 8, date: Date(timeIntervalSince1970: 10), type: .thread, title: "Parent", spaceId: nil).insert(db) + _ = try GRDBSyncStorage.seedSnapshotBucketState(for: .chat(peer: chatPeer(8)), seq: 9, in: db) + } + let withoutParentAdmission = await engine.applyChatRepair(ChatRepairSnapshot( + peer: chatPeer(7), chat: snapshot, pinnedMessages: [], + targetState: BucketState(date: 20, seq: 5), mutationToken: accountToken(), + reason: "unadmitted-parent-anchor" + )) + #expect(withoutParentAdmission?.seq == 5) + try await queue.read { (db: Database) throws in + #expect(try Chat.fetchOne(db, id: 7)?.title == "Repaired") + #expect(try Chat.fetchOne(db, id: 7)?.parentMessageId == 55) + #expect(try Message.filter(Message.Columns.chatId == 8).fetchCount(db) == 0) + #expect(try DbBucketState + .filter(DbBucketState.Columns.entityId == BucketKey.chat(peer: chatPeer(7)).getEntityId()) + .fetchOne(db)?.seq == 5) + } + try await queue.write { (db: Database) throws in + let token = try HistoryPageAdmissionToken.capture(db, chatId: 8) + var parentResponse = InlineProtocol.GetMessagesResult() + parentResponse.seq = 9 + parentResponse.messages = [protocolMessage(id: 55, chatID: 8, fromID: 1)] + try GetMessagesTransaction.apply( + parentResponse, + context: .init(peer: .thread(id: 8), messageIds: [55], admissionToken: token), + db: db + ) + } + snapshot.anchorMessage.message = "An unadmitted parent overlay" + snapshot.chat.seq = 6 let committed = await engine.applyChatRepair(ChatRepairSnapshot( peer: chatPeer(7), chat: snapshot, pinnedMessages: [], - targetState: BucketState(date: 20, seq: 5), + targetState: BucketState(date: 20, seq: 6), mutationToken: accountToken(), reason: "anchor-resolves-parent" )) - #expect(committed?.seq == 5) + #expect(committed?.seq == 6) try await queue.read { (db: Database) throws in let repaired = try #require(try Chat.fetchOne(db, id: 7)) #expect(repaired.parentChatId == 8) @@ -431,6 +469,7 @@ struct ChatRepairReplacementTests { #expect(try Message .filter(Message.Columns.chatId == 8 && Message.Columns.messageId == 55) .fetchCount(db) == 1) + #expect(try Message.fetchOne(db, key: ["chatId": 8, "messageId": 55])?.text == "pinned") } } diff --git a/apple/InlineKit/Tests/InlineKitTests/DialogTranslationMigrationTests.swift b/apple/InlineKit/Tests/InlineKitTests/DialogTranslationMigrationTests.swift index 1907ed6c0..80e32069e 100644 --- a/apple/InlineKit/Tests/InlineKitTests/DialogTranslationMigrationTests.swift +++ b/apple/InlineKit/Tests/InlineKitTests/DialogTranslationMigrationTests.swift @@ -23,7 +23,7 @@ struct DialogTranslationMigrationTests { INSERT INTO dialog (id, peerUserId, translationEnabled) VALUES (8002, 8002, 0); INSERT INTO dialog (id, peerUserId, translationEnabled) VALUES (8003, 8003, NULL); INSERT INTO chat (id, date) VALUES (9001, '2026-09-05 00:00:00'); - INSERT INTO message (messageId, chatId, date, text) VALUES (1, 9001, '2026-09-05 00:00:00', 'Original'); + INSERT INTO message (messageId, chatId, fromId, date, text) VALUES (1, 9001, 8001, '2026-09-05 00:00:00', 'Original'); INSERT INTO translation (messageId, chatId, date, language, translation) VALUES (1, 9001, '2026-09-05 00:00:00', 'en', 'Preserved translation'); """) diff --git a/apple/InlineKit/Tests/InlineKitTests/GetChatsHistoryFenceTests.swift b/apple/InlineKit/Tests/InlineKitTests/GetChatsHistoryFenceTests.swift index c91f6258f..d79f3d570 100644 --- a/apple/InlineKit/Tests/InlineKitTests/GetChatsHistoryFenceTests.swift +++ b/apple/InlineKit/Tests/InlineKitTests/GetChatsHistoryFenceTests.swift @@ -115,6 +115,35 @@ struct GetChatsHistoryFenceTests { } } + @Test("cold cursor rejection rolls back its model preview and acknowledgements") + func coldCatalogCursorFailureIsAtomic() throws { + let queue = try makeDatabase() + try queue.write { (db: Database) throws in + let context = try GetChatsTransaction.Context(expectedRemovalRevision: SyncRemovalRevision.read(db)) + try db.execute(sql: """ + CREATE TRIGGER reject_cold_cursor BEFORE INSERT ON sync_bucket_state + WHEN NEW.bucketType = 1 AND NEW.entityId = -10 + BEGIN SELECT RAISE(ABORT, 'reject cold cursor'); END; + """) + var result = page() + result.chats[0].acknowledgements.cursors = [.with { + $0.chatID = 10 + $0.userID = 1 + $0.maxID = 50 + $0.revision = 1 + }] + let imported = try GetChatsTransaction.applyCatalog( + result, context: context, allowedChatIDs: [10], in: db + ) + #expect(imported.failures == [.init(phase: .chatCursors, count: 1)]) + #expect(imported.seededStates.isEmpty) + #expect(try Chat.fetchOne(db, id: 10) == nil) + #expect(try Message.fetchCount(db) == 0) + #expect(try Acknowledgement.fetchCount(db) == 0) + #expect(try DbBucketState.fetchCount(db) == 0) + } + } + @Test("cold bootstrap requires both its dispatch removal fence and history permission", arguments: [false, true]) func coldCatalogAdmissionFence(removalChanged: Bool) throws { let queue = try makeDatabase() diff --git a/apple/InlineKit/Tests/InlineKitTests/GetChatsSnapshotTests.swift b/apple/InlineKit/Tests/InlineKitTests/GetChatsSnapshotTests.swift index 7d2123c80..2eb211397 100644 --- a/apple/InlineKit/Tests/InlineKitTests/GetChatsSnapshotTests.swift +++ b/apple/InlineKit/Tests/InlineKitTests/GetChatsSnapshotTests.swift @@ -29,7 +29,8 @@ struct GetChatsSnapshotTests { ] try queue.write { (db: Database) throws in - let imported = try GetChatsTransaction.applySnapshot(result, in: db) + addColdPreviews(to: &result, chatIDs: [10, 11]) + let imported = try GetChatsTransaction.applySnapshot(result, coldBootstrapChatIDs: [10, 11], in: db) #expect(imported.failures.isEmpty) #expect(imported.seededStates.count == 2) @@ -53,7 +54,8 @@ struct GetChatsSnapshotTests { ] try queue.write { (db: Database) throws in - let imported = try GetChatsTransaction.applySnapshot(result, in: db) + addColdPreviews(to: &result, chatIDs: [10]) + let imported = try GetChatsTransaction.applySnapshot(result, coldBootstrapChatIDs: [10], in: db) #expect(failureCount(in: imported, phase: .chats) == 4) #expect(try Chat.fetchAll(db).map(\.id) == [10]) @@ -73,6 +75,8 @@ struct GetChatsSnapshotTests { result.spaces = [makeSpace(id: 1, seq: 7), makeSpace(id: 2, seq: 8)] result.users = [makeUser(id: 1), makeUser(id: 2)] result.chats = [makeChat(id: 10, seq: 10), makeChat(id: 20, seq: 20)] + result.chats[0].lastMsgID = 1 + result.chats[1].lastMsgID = 2 result.messages = [ makeMessage(id: 1, chatID: 10, fromID: 1), makeMessage(id: 2, chatID: 20, fromID: 1), @@ -83,6 +87,7 @@ struct GetChatsSnapshotTests { let imported = try GetChatsTransaction.applySnapshot( result, userProjectionAdmission: .alreadyValidated, + coldBootstrapChatIDs: [10, 20], in: db ) @@ -114,7 +119,7 @@ struct GetChatsSnapshotTests { result.messages = [makeMessage(id: 1, chatID: 10, fromID: 1)] try queue.write { (db: Database) throws in - let imported = try GetChatsTransaction.applySnapshot(result, in: db) + let imported = try GetChatsTransaction.applySnapshot(result, coldBootstrapChatIDs: [10, 20], in: db) #expect(failureCount(in: imported, phase: .lastMessages) == 1) #expect(try Chat.fetchOne(db, key: 10)?.lastMsgId == 1) @@ -137,6 +142,7 @@ struct GetChatsSnapshotTests { var result = InlineProtocol.GetChatsResult() result.chats = [makeChat(id: 10, seq: 10), invalidChat] + result.chats[0].lastMsgID = 1 result.messages = [invalidMessage] result.dialogs = [makeDialog(chatID: 10), InlineProtocol.Dialog()] @@ -144,6 +150,7 @@ struct GetChatsSnapshotTests { let imported = try GetChatsTransaction.applySnapshot( result, userProjectionAdmission: .alreadyValidated, + coldBootstrapChatIDs: [10], in: db ) @@ -180,7 +187,8 @@ struct GetChatsSnapshotTests { result.chats = [makeChat(id: 2, spaceID: 1, seq: 9)] try queue.write { (db: Database) throws in - let imported = try GetChatsTransaction.applySnapshot(result, in: db) + addColdPreviews(to: &result, chatIDs: [2]) + let imported = try GetChatsTransaction.applySnapshot(result, coldBootstrapChatIDs: [2], in: db) let spaceKey = BucketKey.space(id: 1) let chatKey = BucketKey.chat(peer: makeChatPeer(id: 2)) @@ -247,7 +255,7 @@ struct GetChatsSnapshotTests { #expect(try Chat.fetchOne(db, key: 2)?.title == "Local Chat") #expect(try Chat.fetchOne(db, key: 2)?.lastMsgId == nil) - #expect(try Message.fetchOne(db, key: ["chatId": 2, "messageId": 1]) != nil) + #expect(try Message.fetchOne(db, key: ["chatId": 2, "messageId": 1]) == nil) #expect(try Dialog.fetchCount(db) == 1) #expect(try bucketState(for: chatKey, in: db)?.seq == 10) #expect(imported.seededStates.isEmpty) @@ -255,7 +263,7 @@ struct GetChatsSnapshotTests { } } - @Test("ordinary catalogs expose only pristine seeds to sync actors") + @Test("cold catalogs expose only coherent pristine seeds to sync actors") func ordinaryCatalogActorStatesExcludeRepairTargets() throws { let queue = try makeInMemoryDB() let populatedKey = BucketKey.chat(peer: makeChatPeer(id: 2)) @@ -270,7 +278,8 @@ struct GetChatsSnapshotTests { makeChat(id: 2, seq: 30, title: "Repair Target"), makeChat(id: 3, seq: 20, title: "Pristine Seed"), ] - let imported = try GetChatsTransaction.applySnapshot(result, in: db) + addColdPreviews(to: &result, chatIDs: [3]) + let imported = try GetChatsTransaction.applySnapshot(result, coldBootstrapChatIDs: [3], in: db) #expect(imported.catchUpTargets[populatedKey] == 30) #expect(imported.seededStates[pristineKey]?.seq == 20) @@ -339,7 +348,8 @@ struct GetChatsSnapshotTests { ] try queue.write { (db: Database) throws in - let imported = try GetChatsTransaction.applySnapshot(result, in: db) + addColdPreviews(to: &result, chatIDs: [2]) + let imported = try GetChatsTransaction.applySnapshot(result, coldBootstrapChatIDs: [2], in: db) #expect(imported.failures.isEmpty) #expect(try Space.fetchOne(db, key: 1)?.name == "Fresh Space") @@ -413,7 +423,8 @@ struct GetChatsSnapshotTests { ] try queue.write { (db: Database) throws in - let imported = try GetChatsTransaction.applySnapshot(result, in: db) + addColdPreviews(to: &result, chatIDs: [2]) + let imported = try GetChatsTransaction.applySnapshot(result, coldBootstrapChatIDs: [2], in: db) #expect(failureCount(in: imported, phase: .spaceCursors) == 1) #expect(failureCount(in: imported, phase: .chatCursors) == 1) @@ -539,9 +550,11 @@ struct GetChatsSnapshotTests { result.dialogs = [makeDialog(chatID: 20)] try queue.write { (db: Database) throws in + addColdPreviews(to: &result, chatIDs: [20]) let imported = try GetChatsTransaction.applySnapshot( result, userProjectionAdmission: .alreadyValidated, + coldBootstrapChatIDs: [20], in: db ) @@ -690,7 +703,8 @@ struct GetChatsSnapshotTests { result.messages = [makeMessage(id: 1, chatID: 20, fromID: 1)] try queue.write { (db: Database) throws in - let imported = try GetChatsTransaction.applySnapshot(result, in: db) + addColdPreviews(to: &result, chatIDs: [10, 20]) + let imported = try GetChatsTransaction.applySnapshot(result, coldBootstrapChatIDs: [10, 20], in: db) #expect(failureCount(in: imported, phase: .chatCursors) == 1) #expect(try Chat.fetchOne(db, key: 20) == nil) @@ -904,7 +918,7 @@ struct GetChatsSnapshotTests { #expect(imported.failures.isEmpty) #expect(imported.catchUpTargets.isEmpty) #expect(imported.seededStates[.space(id: 1)]?.seq == 6) - #expect(imported.seededStates[.chat(peer: makeChatPeer(id: 10))]?.seq == 6) + #expect(imported.seededStates[.chat(peer: makeChatPeer(id: 10))] == nil) #expect(imported.retiredBucketKeys == Set([ .space(id: 2), .chat(peer: makeChatPeer(id: 20)), @@ -915,7 +929,7 @@ struct GetChatsSnapshotTests { #expect(try Space.fetchOne(db, key: 1)?.name == "Rebased Space") #expect(try Chat.fetchOne(db, key: 10)?.title == "Rebased Chat") #expect(try bucketState(for: .space(id: 1), in: db)?.seq == 6) - #expect(try bucketState(for: .chat(peer: makeChatPeer(id: 10)), in: db)?.seq == 6) + #expect(try bucketState(for: .chat(peer: makeChatPeer(id: 10)), in: db)?.seq == 5) #expect(try bucketState(for: .space(id: 2), in: db)?.seq == 5) #expect(try bucketState(for: .chat(peer: makeChatPeer(id: 20)), in: db)?.seq == 5) #expect(try Space.catalogActive().fetchAll(db).map(\.id) == [1]) @@ -932,7 +946,7 @@ struct GetChatsSnapshotTests { #expect(try DialogFolder.fetchOne(db, key: 1) != nil) #expect(try DialogFolder.fetchOne(db, key: 2) == nil) #expect(try MessageHistoryCoverageStore.holes(db, chatId: 10) == [ - MessageHistoryHole(chatId: 10, lowerId: 1, upperId: 4), + MessageHistoryHole(chatId: 10, lowerId: 1, upperId: MessageHistoryHole.positiveMessageIDMax), ]) } } @@ -984,6 +998,22 @@ struct GetChatsSnapshotTests { } } + /// Fixtures that exercise cold seeding must supply the exact last-message + /// preview; ordinary metadata catalogs deliberately leave this opt-in absent. + private func addColdPreviews(to result: inout InlineProtocol.GetChatsResult, chatIDs: Set) { + if !result.users.contains(where: { $0.id == 1 }) { + result.users.append(makeUser(id: 1)) + } + for index in result.chats.indices where chatIDs.contains(result.chats[index].id) { + if !result.chats[index].hasLastMsgID { result.chats[index].lastMsgID = 1 } + let chatID = result.chats[index].id + let lastID = result.chats[index].lastMsgID + if !result.messages.contains(where: { $0.chatID == chatID && $0.id == lastID }) { + result.messages.append(makeMessage(id: lastID, chatID: chatID, fromID: 1)) + } + } + } + private func installRejectingTriggers(_ db: Database) throws { try db.execute(sql: """ CREATE TRIGGER reject_test_space BEFORE INSERT ON space diff --git a/apple/InlineKit/Tests/InlineKitTests/Grid/GridTransactionLifetimeTests.swift b/apple/InlineKit/Tests/InlineKitTests/Grid/GridTransactionLifetimeTests.swift index 8f183d3ec..762cc1841 100644 --- a/apple/InlineKit/Tests/InlineKitTests/Grid/GridTransactionLifetimeTests.swift +++ b/apple/InlineKit/Tests/InlineKitTests/Grid/GridTransactionLifetimeTests.swift @@ -1,12 +1,11 @@ -import Testing -import RealtimeV2 - @testable import InlineKit +import RealtimeV2 +import Testing @Suite("Grid transaction lifetime") struct GridTransactionLifetimeTests { - @Test("snapshot requests are ephemeral and coalesce by resource") - func snapshotRequestsAreEphemeral() throws { + @Test("snapshot requests are ephemeral and preserve read and ownership fences") + func snapshotRequestsAreEphemeral() { let grid = GetGridTransaction(spaceID: 42) let home = GetGridHomeTransaction() let connection = PrepareGridConnectionTransaction(roomID: 7, generation: 3) @@ -22,9 +21,12 @@ struct GridTransactionLifetimeTests { #expect(gridConfig.maxQueueAge == 5) #expect(homeConfig.maxQueueAge == 5) #expect(connectionConfig.maxQueueAge == 5) - #expect(grid.ephemeralCoalescingKey == "space:42") - #expect(home.ephemeralCoalescingKey == "home") - #expect(connection.ephemeralCoalescingKey == "room:7") + #expect(grid.ephemeralCoalescingKey != GetGridTransaction(spaceID: 42).ephemeralCoalescingKey) + #expect(home.ephemeralCoalescingKey != GetGridHomeTransaction().ephemeralCoalescingKey) + #expect(connection.ephemeralCoalescingKey == "room:7:generation:3:membership:legacy") + #expect(connection.ephemeralCoalescingKey != PrepareGridConnectionTransaction( + roomID: 7, generation: 4, expectedMembershipID: "new-owner" + ).ephemeralCoalescingKey) #expect(grid.effectiveReconnectReplayPolicy == .neverReplay) #expect(home.effectiveReconnectReplayPolicy == .neverReplay) #expect(connection.effectiveReconnectReplayPolicy == .neverReplay) @@ -49,3 +51,45 @@ struct GridTransactionLifetimeTests { #expect(joinInput.microphoneEnabled == false) } } + +@Suite("Grid ownership request fences") +struct GridOwnershipRequestFenceTests { + @Test("empty create fence means no presence, while omitted fence stays distinguishable") + func explicitEmptyFenceIsPresent() { + let fenced = CreateGridRoomTransaction(spaceID: 42, microphoneEnabled: false, expectedMembershipID: "") + let legacy = CreateGridRoomTransaction(spaceID: 42, microphoneEnabled: false) + guard case let .createGridRoom(fencedInput)? = fenced.input(from: fenced.context), + case let .createGridRoom(legacyInput)? = legacy.input(from: legacy.context) + else { Issue.record("Expected create input") + return + } + #expect(fencedInput.hasExpectedMembershipID) + #expect(fencedInput.expectedMembershipID.isEmpty) + #expect(!legacyInput.hasExpectedMembershipID) + } + + @Test("browsing never renews, while admitted heartbeat carries exact ownership") + func snapshotAndRenewalStayDistinct() { + let read = GetGridTransaction(spaceID: 42) + let renewal = GetGridTransaction(spaceID: 42, expectedMembershipID: "admitted-owner") + guard case let .getGrid(readInput)? = read.input(from: read.context), + case let .getGrid(renewInput)? = renewal.input(from: renewal.context) + else { Issue.record("Expected GetGrid input") + return + } + #expect(!readInput.hasExpectedMembershipID) + #expect(renewInput.hasExpectedMembershipID) + #expect(renewInput.expectedMembershipID == "admitted-owner") + } + + @Test("move here preserves stable call and caller-observed ownership fence") + func moveCarriesExactClaim() { + let move = MoveGridCallHereTransaction(callID: "stable-call", expectedMembershipID: "observed-owner") + guard case let .moveGridCallHere(input)? = move.input(from: move.context) else { + Issue.record("Expected MoveGridCallHere input") + return + } + #expect(input.callID == "stable-call") + #expect(input.expectedMembershipID == "observed-owner") + } +} diff --git a/apple/InlineKit/Tests/InlineKitTests/Grid/GridTranscriptionControlTests.swift b/apple/InlineKit/Tests/InlineKitTests/Grid/GridTranscriptionControlTests.swift new file mode 100644 index 000000000..2311214de --- /dev/null +++ b/apple/InlineKit/Tests/InlineKitTests/Grid/GridTranscriptionControlTests.swift @@ -0,0 +1,87 @@ +import Foundation +@testable import InlineKit +import InlineProtocol +import Testing + +@Suite("Grid transcription control") +struct GridTranscriptionControlTests { + private func room(state: Int? = nil) -> GridRoom { + .with { + $0.id = 7 + $0.spaceID = 42 + $0.transcriptionAvailable = true + $0.connection = .with { $0.generation = 3 } + $0.avatars = [.with { $0.ownedByCurrentSession = true + $0.membershipID = "member-1" + }] + if let state { + $0.transcription = .with { + $0.runID = "run-1" + $0.revision = 8 + $0.state = GridTranscriptionState(rawValue: state)! + $0.transcriptChatID = 999 + } + } + } + } + + @Test("the action captures membership, generation, run and revision in one snapshot") + func fencedSnapshotSurvivesRetryEncoding() throws { + let request = try #require(GridTranscriptionRequest(room: room(state: 2), enabled: false, requestID: "intent-1")) + let encoded = try JSONEncoder().encode(request) + let decoded = try JSONDecoder().decode(GridTranscriptionRequest.self, from: encoded) + let input = decoded.input + #expect(decoded == request) + #expect(input.roomID == 7) + #expect(input.expectedMembershipID == "member-1") + #expect(input.expectedGeneration == 3) + #expect(input.expectedRunID == "run-1") + #expect(input.expectedRevision == 8) + #expect(input.requestID == "intent-1") + #expect(!input.enabled) + #expect(SetGridTranscriptionTransaction(request: request).effectiveReconnectReplayPolicy == .neverReplay) + } + + @Test("new destination and existing destination stay explicit without model UI fields") + func explicitDestination() throws { + let fresh = try #require(GridTranscriptionRequest(room: room(), enabled: true, destination: .new)) + #expect(fresh.input.destination.rawValue == 1) + #expect(!fresh.input.hasExpectedRunID) + #expect(!fresh.input.hasTranscriptChatID) + let continued = try #require(GridTranscriptionRequest(room: room(), enabled: true, destination: .existing(99))) + #expect(continued.input.destination.rawValue == 2) + #expect(continued.input.transcriptChatID == 99) + #expect(GridTranscriptionRequest(room: room(), enabled: true, destination: .existing(0)) == nil) + } + + @Test("unavailable, unowned, pending and future states cannot advertise an action") + func noInertOrUnfencedControl() { + var unavailable = room() + unavailable.transcriptionAvailable = false + #expect(GridTranscriptionRequest(room: unavailable, enabled: true) == nil) + var observer = room() + observer.avatars[0].ownedByCurrentSession = false + #expect(GridTranscriptionRequest(room: observer, enabled: true) == nil) + var noGeneration = room() + noGeneration.clearConnection() + #expect(GridTranscriptionRequest(room: noGeneration, enabled: true) == nil) + #expect(GridTranscriptionRequest(room: room(state: 1), enabled: true) == nil) + #expect(GridTranscriptionRequest(room: room(state: 1), enabled: false) != nil) + #expect(GridTranscriptionRequest(room: room(state: 3), enabled: false) == nil) + #expect(GridTranscriptionRequest(room: room(state: 99), enabled: true) == nil) + #expect(room(state: 1).transcriptionControlTitle == "Cancel transcription") + #expect(room(state: 3).transcriptionControlTitle == "Stopping…") + #expect(room(state: 5).transcriptionControlTitle == "Start transcription") + } + + @Test("worker availability cannot strand an existing capture without stop") + func stopRemainsAvailableWhenWorkerDrops() { + var active = room(state: 2) + active.transcriptionAvailable = false + #expect(GridTranscriptionRequest(room: active, enabled: false) != nil) + #expect(GridTranscriptionRequest(room: active, enabled: true) == nil) + var starting = room(state: 1) + starting.transcriptionAvailable = false + #expect(GridTranscriptionRequest(room: starting, enabled: false) != nil) + } +} diff --git a/apple/InlineKit/Tests/InlineKitTests/MacNotificationsTests.swift b/apple/InlineKit/Tests/InlineKitTests/MacNotificationsTests.swift index 863725d90..2a3d12b95 100644 --- a/apple/InlineKit/Tests/InlineKitTests/MacNotificationsTests.swift +++ b/apple/InlineKit/Tests/InlineKitTests/MacNotificationsTests.swift @@ -10,6 +10,21 @@ import Testing @Suite("Mac notifications") struct MacNotificationsTests { + @Test("quiet generated transcripts cannot notify even when personally addressed") + func transcriptsNeverNotify() { + var message = makeMessage(mentioned: true) + message.countsAsUnread = false + message.serviceMessage.gridTranscript = .init() + #expect(!shouldSchedule(message, mode: .all, isPersonallyAddressed: true)) + message.clearCountsAsUnread() + #expect(!shouldSchedule(message, mode: .all, isPersonallyAddressed: true)) + message.clearServiceMessage() + message.countsAsUnread = false + #expect(!shouldSchedule(message, mode: .all, isPersonallyAddressed: true)) + message.clearCountsAsUnread() + #expect(shouldSchedule(message, mode: .all, isPersonallyAddressed: true)) + } + private let now = Date(timeIntervalSince1970: 1_000) private func makeMessage( diff --git a/apple/InlineKit/Tests/InlineKitTests/MessageServicePersistenceTests.swift b/apple/InlineKit/Tests/InlineKitTests/MessageServicePersistenceTests.swift index 59788b5f2..25ce7a92f 100644 --- a/apple/InlineKit/Tests/InlineKitTests/MessageServicePersistenceTests.swift +++ b/apple/InlineKit/Tests/InlineKitTests/MessageServicePersistenceTests.swift @@ -6,6 +6,72 @@ import Testing @Suite("Message Service Persistence") struct MessageServicePersistenceTests { + @Test("transcript metadata and quietness survive durable JSON before row construction") + func transcriptPersistenceAndProvenance() throws { + var proto = InlineProtocol.Message.with { + $0.id = 101 + $0.chatID = 44 + $0.fromID = 9 + $0.date = 1 + $0.out = true + $0.peerID.chat.chatID = 44 + $0.message = "Transcript · Ada: Let’s ship the small version." + $0.countsAsUnread = false + $0.serviceMessage.gridTranscript = .with { + $0.runID = "run-1" + $0.segmentID = "segment-2" + $0.speakerUserID = 10 + $0.kind = GridTranscriptMessageKind(rawValue: 0)! + } + } + let message = Message(from: proto) + let payload = try #require(message.contentPayload) + let restored = try JSONDecoder().decode(Client_MessageContentPayload.self, from: JSONEncoder().encode(payload)) + #expect(restored.hasCountsAsUnread) + #expect(!restored.countsAsUnread) + #expect(restored.serviceMessage.gridTranscript.runID == "run-1") + #expect(restored.serviceMessage.gridTranscript.speakerUserID == 10) + #expect(!message.countsAsUnread) + #expect(message.serviceDisplayText(actorName: "Creator") == "Transcript · Ada: Let’s ship the small version.") + #expect(message.stringRepresentationPlain == proto.message) + #expect(try !(#require(message.serviceDisplayText(actorName: "Creator")?.contains("You")))) + proto.clearCountsAsUnread() + #expect(!Message(from: proto).countsAsUnread) + proto.clearServiceMessage() + #expect(Message(from: proto).countsAsUnread) + } + + @Test("original anchors and continuation links retain validated UTF16 thread ranges", arguments: [ + GridTranscriptMessageKind.gridTranscriptStarted, .gridTranscriptLink, + ]) + func continuationNavigation(kind: GridTranscriptMessageKind) { + let isAnchor = kind == .gridTranscriptStarted + let body = isAnchor ? "Grid transcription" : "Continue in Planning" + let proto = InlineProtocol.Message.with { + $0.message = body + $0.serviceMessage.gridTranscript.kind = kind + $0.entities.entities = [.with { + $0.type = .thread + $0.offset = isAnchor ? 0 : 12 + $0.length = isAnchor ? 18 : 8 + $0.thread.chatID = 77 + }, .with { + $0.type = .thread + $0.offset = Int64.max + $0.length = 8 + $0.thread.chatID = 88 + }] + } + let message = Message(from: proto) + let expected: [MessageServiceDisplaySegment] = isAnchor ? [ + .init(text: body, link: .thread(77)), + ] : [ + .init(text: "Continue in "), + .init(text: "Planning", link: .thread(77)), + ] + #expect(message.serviceDisplaySegments(actorName: "Creator") == expected) + } + @Test("protocol service payload is persisted and exposed via helpers") func protocolServicePayloadPersistedAndReadable() { var proto = InlineProtocol.Message() diff --git a/apple/InlineKit/Tests/InlineKitTests/MessagesSectionedViewModelTests.swift b/apple/InlineKit/Tests/InlineKitTests/MessagesSectionedViewModelTests.swift index 1dd1fe31f..ab2d14a70 100644 --- a/apple/InlineKit/Tests/InlineKitTests/MessagesSectionedViewModelTests.swift +++ b/apple/InlineKit/Tests/InlineKitTests/MessagesSectionedViewModelTests.swift @@ -5,7 +5,7 @@ import Testing @Suite("MessagesSectionedViewModel Ordering Tests") struct MessagesSectionedViewModelOrderingTests { - @Test("sectioned projection exposes the immutable history coverage snapshot") + @Test("sectioned projection exposes the contiguous window and its immutable coverage") @MainActor func projectsHistoryCoverage() { let date = Date(timeIntervalSince1970: 1_700_000_000) @@ -23,9 +23,9 @@ struct MessagesSectionedViewModelOrderingTests { peerUserId: peer.id ) let coverage = MessageHistoryCoverageProjection( - messages: [older, newer], + messages: [newer], holes: [MessageHistoryHole(chatId: 1, lowerId: 11, upperId: 19)], - olderCandidateMessageID: nil, + olderCandidateMessageID: 10, newerCandidateMessageID: nil ) let viewModel = MessagesSectionedViewModel( @@ -41,6 +41,7 @@ struct MessagesSectionedViewModelOrderingTests { ) #expect(viewModel.historyCoverage == coverage) + #expect(viewModel.messages.map(\.message.messageId) == [20]) #expect(!viewModel.isCertifiedHistoryContinuation(between: 10, and: 20)) } @@ -61,7 +62,7 @@ struct MessagesSectionedViewModelOrderingTests { ) let initialState = MessagesProgressiveViewModel.InitialState( messages: [old, boundary, visible, pending], - loadedWindowMetadata: testSectionLoadedWindowMetadata(messages: [old, boundary, visible, pending]) + loadedWindowMetadata: .init(messages: [old, boundary, visible, pending], holes: []) ) let viewModel = MessagesSectionedViewModel( diff --git a/apple/InlineKit/Tests/InlineKitTests/RealtimeV2/SyncTests.swift b/apple/InlineKit/Tests/InlineKitTests/RealtimeV2/SyncTests.swift index 544751dbe..1c8a8d41f 100644 --- a/apple/InlineKit/Tests/InlineKitTests/RealtimeV2/SyncTests.swift +++ b/apple/InlineKit/Tests/InlineKitTests/RealtimeV2/SyncTests.swift @@ -2837,8 +2837,18 @@ final class SyncTests { chat.title = "Recovered chat" chat.peerID = makeChatPeer(chatId: 8) chat.seq = 7 + chat.lastMsgID = 1 var chats = InlineProtocol.GetChatsResult() chats.chats = [chat] + chats.users = [me.user] + chats.messages = [.with { + $0.id = 1 + $0.chatID = 8 + $0.peerID = makeChatPeer(chatId: 8) + $0.fromID = 1 + $0.date = 90 + $0.message = "Recovered preview" + }] var settings = InlineProtocol.GetUserSettingsResult() settings.userSettings = .init() var updatedUser = InlineProtocol.UpdateUpdatedUser() diff --git a/apple/InlineKit/Tests/InlineKitTests/UnreadReplayGuardTests.swift b/apple/InlineKit/Tests/InlineKitTests/UnreadReplayGuardTests.swift index eb11d18ce..33a84f378 100644 --- a/apple/InlineKit/Tests/InlineKitTests/UnreadReplayGuardTests.swift +++ b/apple/InlineKit/Tests/InlineKitTests/UnreadReplayGuardTests.swift @@ -1,13 +1,110 @@ import Foundation import GRDB +@testable import InlineKit import InlineProtocol import RealtimeV2 import Testing -@testable import InlineKit - @Suite("Unread Replay Guard") struct UnreadReplayGuardTests { + @Test("quiet transcript rows persist and advance history without changing unread") + func quietTranscriptDoesNotIncrementUnread() throws { + let dbQueue = try makeInMemoryDB() + try dbQueue.write { (db: Database) throws in + try seedDialog(db, readInboxMaxId: 20, unreadCount: 2) + var update = makeNewMessageUpdate(messageId: 21) + update.message.countsAsUnread = false + update.message.serviceMessage.gridTranscript = .with { + $0.runID = "run-1" + $0.segmentID = "segment-1" + } + try update.apply(db, publishChanges: false, suppressNotifications: true) + #expect(try Dialog.get(peerId: .thread(id: chatId)).fetchOne(db)?.unreadCount == 2) + let fetched = try Message.fetchOne(db, key: ["messageId": 21, "chatId": chatId]) + let saved = try #require(fetched) + #expect(saved.isGridTranscript) + #expect(!saved.countsAsUnread) + #expect(try Chat.fetchOne(db, key: chatId)?.lastMsgId == nil) + // A replay must preserve durable quietness as well as deduplication. + try update.apply(db, publishChanges: false, suppressNotifications: true) + #expect(try Dialog.get(peerId: .thread(id: chatId)).fetchOne(db)?.unreadCount == 2) + } + } + + @Test("transcripts stay out of sidebar activity during history and chat-tail deletion") + func transcriptActivityHistoryAndDeletion() throws { + let queue = try makeInMemoryDB() + try queue.write { (db: Database) throws in + try seedDialog(db, readInboxMaxId: 20, unreadCount: 0) + let earlier = makeNewMessageUpdate(messageId: 1) + try earlier.apply(db, publishChanges: false, suppressNotifications: true) + var transcript = makeNewMessageUpdate(messageId: 2) + transcript.message.date = 5 + transcript.message.serviceMessage.gridTranscript = .init() + transcript.message.countsAsUnread = false + try transcript.apply(db, publishChanges: false, suppressNotifications: true) + #expect(try Chat.fetchOne(db, key: chatId)?.lastMsgId == 1) + var later = makeNewMessageUpdate(messageId: 3) + later.message.date = 4 + try later.apply(db, publishChanges: false, suppressNotifications: true) + try Chat.updateLastMsgIds(db, messages: [Message(from: earlier.message), Message(from: transcript.message)]) + #expect(try Chat.fetchOne(db, key: chatId)?.lastMsgId == 3) + + let deletion = InlineProtocol.UpdateDeleteMessages.with { + $0.peerID.chat.chatID = chatId + $0.messageIds = [3] + } + try deletion.apply(db, publishChanges: false) + #expect(try Chat.fetchOne(db, key: chatId)?.lastMsgId == 1) + #expect(try Message.filter(Message.Columns.chatId == chatId && Message.Columns.messageId == 1) + .fetchOne(db)?.date == Date(timeIntervalSince1970: 2)) + try Message.deleteMessages(db, messageIds: [1], chatId: chatId) + #expect(try Chat.fetchOne(db, key: chatId)?.lastMsgId == nil) + #expect(try Message.fetchCount(db) == 1) + } + } + + @Test("batched tail deletion keeps quiet confirmed and optimistic rows out of activity and fences history") + func quietBatchTailPromotion() throws { + let queue = try makeInMemoryDB() + try queue.write { db in + try seedDialog(db, readInboxMaxId: 1_000, unreadCount: 0) + for (id, date) in [(10, 300), (20, 100), (30, 400), (40, 700)] { + var update = makeNewMessageUpdate(messageId: Int64(id)) + update.message.date = Int64(date) + if id == 30 { + update.message.serviceMessage.gridTranscript = .init() + update.message.countsAsUnread = false + } + try update.apply(db, publishChanges: false, suppressNotifications: true) + } + var pending = Message(from: makeNewMessageUpdate(messageId: -1).message) + pending.date = Date(timeIntervalSince1970: 500) + pending.status = .sending + try pending.saveMessage(db) + var quietPending = makeNewMessageUpdate(messageId: -2).message + quietPending.date = 600 + quietPending.serviceMessage.gridTranscript = .init() + quietPending.countsAsUnread = false + var quietRow = Message(from: quietPending) + quietRow.status = .sending + try quietRow.saveMessage(db) + try Chat.filter(Chat.Columns.id == chatId).updateAll(db, Chat.Columns.lastMsgId.set(to: 40)) + let token = try HistoryPageAdmissionToken.capture(db, chatId: chatId) + // Cross the delete chunk boundary, including uncached coordinates. + try Message.deleteMessages(db, messageIds: [40] + Array(100 ... 600), chatId: chatId) + #expect(try Chat.fetchCount(db) == 1) + #expect(try Chat.fetchOne(db, id: chatId)?.lastMsgId == -1) + #expect(try HistoryPageAdmissionToken.capture(db, chatId: chatId).revision == token.revision + 1) + try Message.deleteMessages(db, messageIds: [-1], chatId: chatId) + // Confirmed message IDs still win over backdated timestamps. + #expect(try Chat.fetchOne(db, id: chatId)?.lastMsgId == 20) + #expect(try Message.filter(Message.Columns.chatId == chatId && Message.Columns.messageId == 30).fetchOne(db)? + .isGridTranscript == true) + #expect(try Dialog.get(peerId: .thread(id: chatId)).fetchOne(db)?.unreadCount == 0) + } + } + private let chatId: Int64 = 1_000 private let senderId: Int64 = 99 @@ -121,7 +218,7 @@ struct UnreadReplayGuardTests { @Test("sync catch-up message does not increment sidecar unread total") func catchupMessageDoesNotIncrementSidecarUnreadTotal() async throws { let dbQueue = try makeInMemoryDB() - let engine = UpdatesEngine(database: try AppDatabase(dbQueue)) + let engine = try UpdatesEngine(database: AppDatabase(dbQueue)) try await dbQueue.write { (db: Database) throws in try seedDialog(db, readInboxMaxId: nil, unreadCount: 1) @@ -192,7 +289,7 @@ struct UnreadReplayGuardTests { @Test("updates engine live message materializes missing chat references") func updatesEngineLiveMessageMaterializesMissingChatReferences() async throws { let dbQueue = try makeInMemoryDB() - let engine = UpdatesEngine(database: try AppDatabase(dbQueue)) + let engine = try UpdatesEngine(database: AppDatabase(dbQueue)) var update = InlineProtocol.Update() update.update = .newMessage(makeNewMessageUpdate(messageId: 1)) @@ -276,7 +373,7 @@ struct UnreadReplayGuardTests { @Test("chatSkipPts applies as a catch-up no-op") func chatSkipPtsAppliesAsCatchupNoop() async throws { let dbQueue = try makeInMemoryDB() - let engine = UpdatesEngine(database: try AppDatabase(dbQueue)) + let engine = try UpdatesEngine(database: AppDatabase(dbQueue)) var payload = InlineProtocol.UpdateChatSkipPts() payload.chatID = chatId @@ -288,7 +385,7 @@ struct UnreadReplayGuardTests { #expect(applied.succeeded) #expect(applied.appliedCount == 1) - try await dbQueue.read { (db: Database) throws -> Void in + try await dbQueue.read { (db: Database) throws in #expect(try Message.fetchCount(db) == 0) } } diff --git a/apple/InlineKit/Tests/InlineKitTests/UserRepairSnapshotTests.swift b/apple/InlineKit/Tests/InlineKitTests/UserRepairSnapshotTests.swift index d2da939e0..82b4e89d8 100644 --- a/apple/InlineKit/Tests/InlineKitTests/UserRepairSnapshotTests.swift +++ b/apple/InlineKit/Tests/InlineKitTests/UserRepairSnapshotTests.swift @@ -137,8 +137,12 @@ struct UserRepairSnapshotTests { firstChat.title = "First import" firstChat.peerID = chatPeer(8) firstChat.seq = 10 + firstChat.lastMsgID = 1 var firstChats = InlineProtocol.GetChatsResult() firstChats.chats = [firstChat] + firstChats.users = [firstMe.user] + firstChats.messages = [coldPreview(chatID: 8)] + let removalRevision = try await queue.read { db in try SyncRemovalRevision.read(db) } #expect(await engine.persistUserBootstrapProjection(.init( projection: .me(firstMe), @@ -154,7 +158,8 @@ struct UserRepairSnapshotTests { guard case let .chats(persistence)? = await engine.persistUserBootstrapProjection(.init( projection: .chats(firstChats), checkpointState: checkpoint, - mutationToken: accountToken() + mutationToken: accountToken(), + catalogHistoryAdmission: .init(expectedRemovalRevision: removalRevision, allowedChatIDs: [8]) )) else { Issue.record("Expected the catalog projection to persist") return @@ -845,6 +850,7 @@ struct UserRepairSnapshotTests { validChat.title = "Healthy" validChat.peerID = chatPeer(8) validChat.seq = 10 + validChat.lastMsgID = 1 var chats = InlineProtocol.GetChatsResult() chats.chats = [invalidChat, validChat] @@ -854,6 +860,9 @@ struct UserRepairSnapshotTests { var me = InlineProtocol.GetMeResult() me.user = user + chats.users = [user] + chats.messages = [coldPreview(chatID: 8)] + let removalRevision = try await queue.read { db in try SyncRemovalRevision.read(db) } let committed = await engine.applyUserRepair(UserRepairSnapshot( chats: chats, me: me, @@ -862,6 +871,7 @@ struct UserRepairSnapshotTests { replayThroughState: BucketState(date: 200, seq: 50), targetState: BucketState(date: 200, seq: 50), mutationToken: accountToken(), + catalogHistoryAdmission: .init(expectedRemovalRevision: removalRevision, allowedChatIDs: [8]), replacesActiveCatalog: true, reason: "invalid-snapshot-test" )) @@ -1042,6 +1052,17 @@ struct UserRepairSnapshotTests { } } + private func coldPreview(chatID: Int64) -> InlineProtocol.Message { + .with { + $0.id = 1 + $0.chatID = chatID + $0.peerID = chatPeer(chatID) + $0.fromID = 42 + $0.date = 20 + $0.message = "Cold preview" + } + } + private func userSettingsResult() -> InlineProtocol.GetUserSettingsResult { var result = InlineProtocol.GetUserSettingsResult() result.userSettings = .init() diff --git a/apple/InlineKit/Tests/InlineRTCTests/Engine/GridAudioEngineTests.swift b/apple/InlineKit/Tests/InlineRTCTests/Engine/GridAudioEngineTests.swift index 395421a98..bd5d6fae0 100644 --- a/apple/InlineKit/Tests/InlineRTCTests/Engine/GridAudioEngineTests.swift +++ b/apple/InlineKit/Tests/InlineRTCTests/Engine/GridAudioEngineTests.swift @@ -5,6 +5,50 @@ import Testing @Suite("Grid audio engine", .serialized) struct GridAudioEngineTests { + @Test("retry after a safety pause restores listening without restoring a microphone lease") + func safetyRetryRestoresListeningOnly() async throws { + let driver = FakeGridAudioDriver() + let engine = GridAudioEngine(driver: driver, permissionDriver: TestGridMicrophonePermissionDriver()) + let lease = GridAudioLease.connectionDemand(.init("grid-test:1:76:1")) + await engine.setInput(.automatic) + await engine.acquireCaptureLease(lease) + try await eventually { await engine.currentSnapshot().isPrepared } + let prepareCount = await driver.operations().filter { $0 == "prepared:true" }.count + + await driver.emit(.safetyPaused) + try await eventually { + let snapshot = await engine.currentSnapshot() + return snapshot.isSafetyPaused && snapshot.captureLeaseCount == 0 + } + await engine.retry() + try await eventually { + let snapshot = await engine.currentSnapshot() + return !snapshot.isSafetyPaused && snapshot.isPlaying && !snapshot.isRecording + } + #expect(await engine.currentSnapshot().captureLeaseCount == 0) + #expect(await driver.operations().filter { $0 == "prepared:true" }.count == prepareCount) + _ = await engine.shutdown() + } + + @Test("a denied microphone does not prevent listening-only safety recovery") + func safetyRetryWithoutMicrophonePermission() async throws { + let driver = FakeGridAudioDriver() + let engine = GridAudioEngine( + driver: driver, + permissionDriver: TestGridMicrophonePermissionDriver(current: .denied, requested: .denied) + ) + await engine.start() + await driver.emit(.safetyPaused) + try await eventually { await engine.currentSnapshot().isSafetyPaused } + await engine.retry() + try await eventually { + let snapshot = await engine.currentSnapshot() + return !snapshot.isSafetyPaused && snapshot.isPlaying && !snapshot.isRecording + } + #expect(!(await driver.operations().contains("prepared:true"))) + _ = await engine.shutdown() + } + @Test("permission gates the physical input transaction") func permissionGatesInputRoute() async throws { let driver = FakeGridAudioDriver() @@ -1835,6 +1879,7 @@ private actor FakeGridAudioDriver: GridAudioDriver { nonisolated let recordingStartsWithMicrophoneSender: Bool private nonisolated let eventContinuation: AsyncStream.Continuation private var log: [String] = [] + private var safetyPaused = false private var availableDeviceIDs: Set private var availableOutputDeviceIDs: Set private let blockConfigure: Bool @@ -1949,6 +1994,12 @@ private actor FakeGridAudioDriver: GridAudioDriver { log.append("resume-terminal-gate") } + func resumeListeningAfterSafetyPause() async throws { + guard safetyPaused else { return } + log.append("resume-listening") + emit(.listeningResumed) + } + func setPrepared(_ prepared: Bool) async throws { log.append("prepared:\(prepared)") if prepared, blockPrepare { @@ -2203,6 +2254,16 @@ private actor FakeGridAudioDriver: GridAudioDriver { engineRunning = false recordingActive = false playingActive = false + case .safetyPaused: + safetyPaused = true + engineRunning = false + recordingActive = false + playingActive = false + case .listeningResumed: + safetyPaused = false + engineRunning = true + recordingActive = false + playingActive = true case .devicesChanged: break } diff --git a/apple/InlineKit/Tests/InlineRTCTests/Engine/GridRTCEngineTests.swift b/apple/InlineKit/Tests/InlineRTCTests/Engine/GridRTCEngineTests.swift index 2ae2508c5..532d28250 100644 --- a/apple/InlineKit/Tests/InlineRTCTests/Engine/GridRTCEngineTests.swift +++ b/apple/InlineKit/Tests/InlineRTCTests/Engine/GridRTCEngineTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import InlineRTC +@testable import InlineAudioPlayback @Suite("Grid RTC engine", .serialized) struct GridRTCEngineTests { @@ -1534,13 +1535,17 @@ struct GridRTCEngineTests { await rtc.setDemand(InlineRTCDemand()) let receipt = await rtc.shutdown() - #expect(receipt.isQuiescent) + #expect(!receipt.isQuiescent) + #expect(receipt.failures.contains { $0.contains("provider operation") }) #expect(receipt.microphonePublicationCount == 0) #expect(receipt.screenSharePublicationCount == 0) #expect(await audio.currentSnapshot().captureLeaseCount == 0) #expect(await driver.operations().filter { $0 == "quiesce-done:26" }.count >= 2) await driver.releaseBlockedConnect() + try await eventuallyRTC { await rtc.currentSnapshot().abandonedProviderOperationCount == 0 } + let releasedReceipt = await rtc.shutdown() + #expect(releasedReceipt.isQuiescent) } @Test("a hung microphone publication fences replacement until mutation ownership returns") @@ -1722,6 +1727,249 @@ struct GridRTCEngineTests { #expect(retiredIndex < replacementIndex) } + @MainActor + @Test("a replacement room reuses the process call reservation while old connect retires", .timeLimit(.minutes(1))) + func rapidRoomReplacementKeepsProcessReservation() async throws { + let backend = RTCProcessAudioBackend() + try await withRTCNativeCancellationDrain(backend: backend) { + let session = InlineAudioSession(backend: backend) + let audioDriver = RTCFakeAudioDriver(initiallyRecordingActive: false, audioSession: session) + let audio = GridAudioEngine(driver: audioDriver, permissionDriver: TestGridMicrophonePermissionDriver()) + let driver = FakeGridRTCDriver(blockedRoomID: 76) + let rtc = GridRTCEngine(audio: audio, driver: driver) + defer { + Task { + await backend.drain() + await driver.releaseBlockedConnect() + _ = await rtc.shutdown() + _ = await audio.shutdown() + } + } + let first = InlineRTCSessionID("grid-test:1:76:1") + let second = InlineRTCSessionID("grid-test:1:77:1") + await rtc.setDemand(demand(target: first, microphoneEnabled: false, microphoneCapturePrepared: false)) + try await eventuallyRTC { await driver.operations().contains("connect:76") } + let reservation = try #require(session.current) + + await rtc.setDemand(InlineRTCDemand()) + try await eventuallyRTC { await driver.operations().contains("quiesce-done:76") } + #expect(session.owns(reservation)) + #expect(throws: InlineAudioSessionError.self) { try session.validateGridAdmission() } + await rtc.setDemand(demand(target: second, microphoneEnabled: false, microphoneCapturePrepared: false)) + #expect(session.owns(reservation)) + #expect(!session.isQuarantined) + await driver.releaseBlockedConnect() + try await eventuallyRTC { await rtc.currentSnapshot().state == .connected(second) } + #expect(await backend.operations() == [.activate(reservation)]) + #expect(session.owns(reservation)) + + // A stale A withdrawal receipt cannot release B, nor can generic release. + await session.gridMediaDidQuiesce(epoch: 2) + await session.observeGridDirections(recording: false, playing: false, + nativeOperationsQuiescent: true, epoch: 2) + #expect(session.release(reservation) == nil) + #expect(session.owns(reservation)) + await rtc.setDemand(InlineRTCDemand()) + try await eventuallyRTC { await session.current == nil } + #expect(await backend.operations() == [.activate(reservation), .release(reservation)]) + try session.validateGridAdmission() + let recorder = try session.acquire(.recording) + let cleanup = session.release(recorder) + try await cleanup?.value + } + } + + @MainActor + @Test("an unmuted replacement cannot reopen native audio before old local teardown completes", + .timeLimit(.minutes(1))) + func replacementAudioWaitsForHeldLocalQuiescence() async throws { + let backend = RTCProcessAudioBackend() + try await withRTCNativeCancellationDrain(backend: backend) { + let session = InlineAudioSession(backend: backend) + let audioDriver = RTCFakeAudioDriver( + initiallyRecordingActive: false, audioSession: session, + nativeAvailabilityTracksMediaDemand: true + ) + let audio = GridAudioEngine(driver: audioDriver, permissionDriver: TestGridMicrophonePermissionDriver(), + captureCooldown: .zero) + let driver = FakeGridRTCDriver(blockLocalQuiescenceRoomID: 83) + var configuration = InlineRTCConfiguration.voice + configuration.connection.retirementBarrierTimeout = 0.05 + let rtc = GridRTCEngine(audio: audio, driver: driver, configuration: configuration) + defer { + Task { + await driver.releaseBlockedLocalQuiescence() + await backend.drain() + _ = await rtc.shutdown() + _ = await audio.shutdown() + } + } + let first = InlineRTCSessionID("grid-test:1:83:1") + let second = InlineRTCSessionID("grid-test:1:84:1") + await rtc.setDemand(demand(target: first, microphoneEnabled: true)) + try await eventuallyRTC { + let snapshot = await rtc.currentSnapshot() + return snapshot.microphonePublished && !snapshot.microphoneMuted + } + #expect(await audioDriver.operations().contains("capture-open:1")) + let reservation = try #require(session.current) + await rtc.setDemand(InlineRTCDemand()) + try await eventuallyRTC { await driver.localQuiescenceIsBlocked() } + #expect(await driver.currentMutedState(roomID: 83) == false) + #expect(!(await audioDriver.recordingIsActive())) + let retainedLeaseCount = await audio.currentSnapshot().captureLeaseCount + + // The old unmuted track still exists while B requests explicit Unmute. + // Admission returns a bounded failure, not permission to reopen its ADM. + await rtc.setDemand(demand(target: second, microphoneEnabled: true)) + if case .failed(second, _) = await rtc.currentSnapshot().state {} else { + Issue.record("replacement should fail closed while local teardown remains held") + } + #expect(session.owns(reservation)) + #expect(await audio.currentSnapshot().captureLeaseCount == retainedLeaseCount) + #expect(!(await audioDriver.recordingIsActive())) + #expect(!(await audioDriver.operations().contains("resume:3"))) + #expect(!(await audioDriver.operations().contains("capture-open:3"))) + #expect(!(await driver.operations().contains("connect:84"))) + #expect(throws: AudioPlaybackError.self) { try session.acquire(.recording) } + + await driver.releaseBlockedLocalQuiescence() + try await eventuallyRTC { + let snapshot = await rtc.currentSnapshot() + return snapshot.state == .connected(second) && snapshot.microphonePublished && !snapshot.microphoneMuted + } + #expect(await audioDriver.operations().contains("resume:3")) + #expect(await audioDriver.operations().contains("capture-open:3")) + let operations = await driver.operations() + let quiet = try #require(operations.firstIndex(of: "quiesce-done:83")) + let replacement = try #require(operations.firstIndex(of: "connect:84")) + #expect(quiet < replacement) + await rtc.setDemand(InlineRTCDemand()) + try await eventuallyRTC { await session.current == nil } + #expect(!session.isQuarantined) + } + } + + @MainActor + @Test("replacement joins committed native retirement before activating its new call", + .timeLimit(.minutes(1)), + arguments: [false, true]) + func replacementWaitsForNativeRetirement(releaseFails: Bool) async throws { + let backend = RTCProcessAudioBackend() + try await withRTCNativeCancellationDrain(backend: backend) { + let session = InlineAudioSession(backend: backend) + let audioDriver = RTCFakeAudioDriver(initiallyRecordingActive: false, audioSession: session) + let audio = GridAudioEngine(driver: audioDriver, permissionDriver: TestGridMicrophonePermissionDriver()) + let driver = FakeGridRTCDriver() + let rtc = GridRTCEngine(audio: audio, driver: driver) + defer { + Task { + await backend.drain() + _ = await rtc.shutdown() + _ = await audio.shutdown() + } + } + let first = InlineRTCSessionID("grid-test:1:78:1") + let second = InlineRTCSessionID("grid-test:1:79:1") + await rtc.setDemand(demand(target: first, microphoneEnabled: false, microphoneCapturePrepared: false)) + try await eventuallyRTC { await rtc.currentSnapshot().state == .connected(first) } + let retired = try #require(session.current) + await backend.blockNextRelease() + await rtc.setDemand(InlineRTCDemand()) + try await eventuallyRTC { await backend.releaseIsBlocked() } + let replacement = Task { + await rtc.setDemand(demand(target: second, microphoneEnabled: false, microphoneCapturePrepared: false)) + } + try await eventuallyRTC { await audioDriver.operations().contains("media:true:3") } + #expect(session.owns(retired)) + #expect(throws: AudioPlaybackError.self) { try session.acquire(.recording) } + #expect(await backend.operations() == [.activate(retired), .release(retired)]) + #expect(!(await driver.operations().contains("connect:79"))) + await backend.completeRelease(failing: releaseFails) + await replacement.value + + if releaseFails { + #expect(session.isQuarantined) + #expect(session.owns(retired)) + #expect(throws: InlineAudioSessionError.self) { try session.acquire(.recording) } + #expect(!(await driver.operations().contains("connect:79"))) + #expect(await backend.operations() == [.activate(retired), .release(retired)]) + } else { + try await eventuallyRTC { await rtc.currentSnapshot().state == .connected(second) } + let admitted = try #require(session.current) + #expect(admitted != retired) + #expect(await backend.operations() == [.activate(retired), .release(retired), .activate(admitted)]) + await rtc.setDemand(InlineRTCDemand()) + try await eventuallyRTC { await session.current == nil } + #expect(!session.isQuarantined) + try session.validateGridAdmission() + } + } + } + + @MainActor + @Test("a replacement waiting for teardown cannot override a newer room or withdrawal", + .timeLimit(.minutes(1)), + arguments: [false, true]) + func waitingReplacementCannotSupersedeNewerDemand(withdraw: Bool) async throws { + let backend = RTCProcessAudioBackend() + try await withRTCNativeCancellationDrain(backend: backend) { + let session = InlineAudioSession(backend: backend) + let audioDriver = RTCFakeAudioDriver(initiallyRecordingActive: false, audioSession: session) + let audio = GridAudioEngine(driver: audioDriver, permissionDriver: TestGridMicrophonePermissionDriver()) + let driver = FakeGridRTCDriver() + let rtc = GridRTCEngine(audio: audio, driver: driver) + defer { + Task { + await backend.drain() + _ = await rtc.shutdown() + _ = await audio.shutdown() + } + } + let first = InlineRTCSessionID("grid-test:1:80:1") + let second = InlineRTCSessionID("grid-test:1:81:1") + let third = InlineRTCSessionID("grid-test:1:82:1") + await rtc.setDemand(demand(target: first, microphoneEnabled: false, microphoneCapturePrepared: false)) + try await eventuallyRTC { await rtc.currentSnapshot().state == .connected(first) } + let retired = try #require(session.current) + await backend.blockNextRelease() + await rtc.setDemand(InlineRTCDemand()) + try await eventuallyRTC { await backend.releaseIsBlocked() } + let waitingReplacement = Task { + await rtc.setDemand(demand(target: second, microphoneEnabled: false, + microphoneCapturePrepared: false, outputVolume: 0.2)) + } + try await eventuallyRTC { await audioDriver.operations().contains("media:true:3") } + let latestDemand = withdraw ? InlineRTCDemand() : demand( + target: third, microphoneEnabled: false, microphoneCapturePrepared: false, outputVolume: 0.8 + ) + let latest = Task { await rtc.setDemand(latestDemand) } + try await eventuallyRTC { await audioDriver.operations().contains("media:\(!withdraw):4") } + #expect(session.owns(retired)) + await backend.completeRelease() + await waitingReplacement.value + await latest.value + #expect(!(await driver.operations().contains("connect:81"))) + + if withdraw { + try await eventuallyRTC { await rtc.currentSnapshot().state == .idle } + #expect(session.current == nil) + #expect(await audio.currentSnapshot().outputVolume == 1) + #expect(await backend.operations() == [.activate(retired), .release(retired)]) + try session.validateGridAdmission() + } else { + try await eventuallyRTC { await rtc.currentSnapshot().state == .connected(third) } + let admitted = try #require(session.current) + #expect(admitted != retired) + #expect(await audio.currentSnapshot().outputVolume == 0.8) + #expect(await backend.operations() == [.activate(retired), .release(retired), .activate(admitted)]) + await rtc.setDemand(InlineRTCDemand()) + try await eventuallyRTC { await session.current == nil } + } + #expect(!session.isQuarantined) + } + } + @Test("room replacement rearms the custom ADM transport fence") func roomReplacementRearmsAudioTransportFence() async throws { let audioDriver = RTCFakeAudioDriver( @@ -2305,9 +2553,146 @@ struct GridRTCEngineTests { } } + @Test("a recording conflict preserves desired Grid admission for a later retry") + func mediaOwnershipConflictRetriesDesiredAdmission() async throws { + let audioDriver = RTCFakeAudioDriver(initiallyRecordingActive: false, mediaAdmissionBlocked: true) + let audio = GridAudioEngine(driver: audioDriver, permissionDriver: TestGridMicrophonePermissionDriver()) + let driver = FakeGridRTCDriver() + let rtc = GridRTCEngine(audio: audio, driver: driver) + let target = InlineRTCSessionID("grid-test:1:70:1") + await rtc.setDemand(demand(target: target, microphoneEnabled: false, microphoneCapturePrepared: false)) + try await eventuallyRTC { + if case .failed(target, _) = await rtc.currentSnapshot().state { return true } + return false + } + #expect(await driver.operations().contains("connect:70") == false) + #expect(await audioDriver.operations().contains("prepared:true") == false) + await audioDriver.allowMediaAdmission() + await rtc.networkBecameAvailable() + try await eventuallyRTC { await rtc.currentSnapshot().state == .connected(target) } + #expect(await audio.currentSnapshot().captureLeaseCount == 0) + _ = await rtc.shutdown() + _ = await audio.shutdown() + } + + @Test("an authorized listen-only admission neither prepares nor publishes a microphone") + func listenOnlyDoesNotCapture() async throws { + let audioDriver = RTCFakeAudioDriver(initiallyRecordingActive: false) + let audio = GridAudioEngine(driver: audioDriver, permissionDriver: TestGridMicrophonePermissionDriver()) + let driver = FakeGridRTCDriver() + let rtc = GridRTCEngine(audio: audio, driver: driver) + let target = InlineRTCSessionID("grid-test:1:71:1") + + await rtc.setDemand(demand(target: target, microphoneEnabled: false, microphoneCapturePrepared: false)) + try await eventuallyRTC { await rtc.currentSnapshot().state == .connected(target) } + #expect(await audio.currentSnapshot().captureLeaseCount == 0) + #expect(await audioDriver.operations().contains("prepared:true") == false) + #expect(await driver.operations().contains("publish:71:muted=true") == false) + #expect(await rtc.currentSnapshot().microphonePublished == false) + _ = await rtc.shutdown() + _ = await audio.shutdown() + } + + @Test("ordinary mute preserves explicit capture preparation until media demand ends") + func warmMuteEndsWithDemand() async throws { + let audioDriver = RTCFakeAudioDriver(initiallyRecordingActive: false) + let audio = GridAudioEngine( + driver: audioDriver, + permissionDriver: TestGridMicrophonePermissionDriver(), + captureCooldown: .zero + ) + let driver = FakeGridRTCDriver() + let rtc = GridRTCEngine(audio: audio, driver: driver) + let target = InlineRTCSessionID("grid-test:1:72:1") + + await rtc.setDemand(demand(target: target, microphoneEnabled: true, microphoneCapturePrepared: true)) + try await eventuallyRTC { + let snapshot = await rtc.currentSnapshot() + return snapshot.microphonePublished && !snapshot.microphoneMuted + } + let prepares = await audioDriver.operations().filter { $0 == "prepared:true" }.count + await rtc.setDemand(demand(target: target, microphoneEnabled: false, microphoneCapturePrepared: true)) + try await eventuallyRTC { await rtc.currentSnapshot().microphoneMuted } + #expect(await audio.currentSnapshot().isPrepared) + #expect(await audioDriver.operations().filter { $0 == "prepared:true" }.count == prepares) + + await rtc.setDemand(InlineRTCDemand()) + try await eventuallyRTC { + let snapshot = await audio.currentSnapshot() + return snapshot.captureLeaseCount == 0 && !snapshot.isRecording + } + #expect(await audioDriver.operations().contains("prepared:false")) + _ = await rtc.shutdown() + _ = await audio.shutdown() + } + + @Test("permission completion after withdrawing admission cannot start capture") + func permissionCompletionAfterWithdrawal() async throws { + let permission = BlockingGridMicrophonePermissionDriver() + let audioDriver = RTCFakeAudioDriver(initiallyRecordingActive: false) + let audio = GridAudioEngine(driver: audioDriver, permissionDriver: permission, captureCooldown: .zero) + let driver = FakeGridRTCDriver() + let rtc = GridRTCEngine(audio: audio, driver: driver) + let target = InlineRTCSessionID("grid-test:1:73:1") + let request = Task { await audio.requestMicrophonePermission() } + try await eventuallyRTC { await permission.hasStartedRequest() } + await rtc.setDemand(demand(target: target, microphoneEnabled: true, microphoneCapturePrepared: true)) + try await eventuallyRTC { await rtc.currentSnapshot().state == .connected(target) } + await rtc.setDemand(InlineRTCDemand()) + await permission.resolve(.authorized) + await request.value + #expect(await audio.currentSnapshot().captureLeaseCount == 0) + #expect(await audioDriver.operations().contains("prepared:true") == false) + #expect(await driver.operations().contains("publish:73:muted=true") == false) + _ = await rtc.shutdown() + _ = await audio.shutdown() + } + + @Test("an idle ADM cannot hand the session to a recorder while connect still owns provider work") + func standaloneHandoffWaitsForSuspendedConnect() async throws { + let audioDriver = RTCFakeAudioDriver(initiallyRecordingActive: false) + let audio = GridAudioEngine(driver: audioDriver, permissionDriver: TestGridMicrophonePermissionDriver()) + let driver = FakeGridRTCDriver(blockedRoomID: 74) + let rtc = GridRTCEngine(audio: audio, driver: driver) + let target = InlineRTCSessionID("grid-test:1:74:1") + + await rtc.setDemand(demand(target: target, microphoneEnabled: false, microphoneCapturePrepared: false)) + try await eventuallyRTC { await driver.operations().contains("connect:74") } + await rtc.setDemand(InlineRTCDemand()) + try await eventuallyRTC { await driver.operations().contains("quiesce-done:74") } + #expect(!(await audioDriver.canAdmitStandaloneRecorder())) + + await driver.releaseBlockedConnect() + try await eventuallyRTC { await audioDriver.canAdmitStandaloneRecorder() } + #expect(!(await driver.operations().contains("publish:74:muted=true"))) + _ = await rtc.shutdown() + _ = await audio.shutdown() + } + + @Test("a retired microphone publication must return before standalone writer admission") + func standaloneHandoffWaitsForSuspendedPublication() async throws { + let audioDriver = RTCFakeAudioDriver(initiallyRecordingActive: false) + let audio = GridAudioEngine(driver: audioDriver, permissionDriver: TestGridMicrophonePermissionDriver()) + let driver = FakeGridRTCDriver(blockedPublishRoomID: 75) + let rtc = GridRTCEngine(audio: audio, driver: driver) + let target = InlineRTCSessionID("grid-test:1:75:1") + + await rtc.setDemand(demand(target: target, microphoneEnabled: true)) + try await eventuallyRTC { await driver.operations().contains("publish:75:muted=true") } + await rtc.setDemand(InlineRTCDemand()) + try await eventuallyRTC { await driver.operations().contains("quiesce:75") } + #expect(!(await audioDriver.canAdmitStandaloneRecorder())) + + await driver.releaseBlockedPublish() + try await eventuallyRTC { await audioDriver.canAdmitStandaloneRecorder() } + _ = await rtc.shutdown() + _ = await audio.shutdown() + } + private func demand( target: InlineRTCSessionID, microphoneEnabled: Bool, + microphoneCapturePrepared: Bool = true, screenCaptureSource: InlineRTCScreenCaptureSource? = nil, screenShareQualityProfile: InlineRTCScreenShareQualityProfile = .automatic, outputVolume: Float = 1 @@ -2322,6 +2707,7 @@ struct GridRTCEngineTests { expiresAt: Date().addingTimeInterval(60) ), microphoneEnabled: microphoneEnabled, + microphoneCapturePrepared: microphoneCapturePrepared, screenCaptureSource: screenCaptureSource, screenShareQualityProfile: screenShareQualityProfile, outputVolume: outputVolume @@ -2334,7 +2720,13 @@ private actor RTCFakeAudioDriver: GridAudioDriver { nonisolated let recordingStartsWithMicrophoneSender: Bool private var log: [String] = [] private var playingActive = true + private var mediaAdmissionBlocked: Bool + private var mediaActive = false + private var mediaEpoch: UInt64 = 0 + private var mediaQuiescent = true private var recordingActive: Bool? + private let audioSession: InlineAudioSession? + private let nativeAvailabilityTracksMediaDemand: Bool private let inputRouteStartsRecording: Bool private var configureFailuresRemaining: Int @@ -2343,9 +2735,16 @@ private actor RTCFakeAudioDriver: GridAudioDriver { initiallyRecordingActive: Bool? = nil, inputRouteStartsRecording: Bool = false, configureFailures: Int = 0, - recordingStartsWithMicrophoneSender: Bool = false + recordingStartsWithMicrophoneSender: Bool = false, + mediaAdmissionBlocked: Bool = false, + audioSession: InlineAudioSession? = nil, + nativeAvailabilityTracksMediaDemand: Bool = false ) { self.preparationRequiresRTCTransport = preparationRequiresRTCTransport + self.mediaAdmissionBlocked = mediaAdmissionBlocked + self.audioSession = audioSession + self.nativeAvailabilityTracksMediaDemand = nativeAvailabilityTracksMediaDemand + if nativeAvailabilityTracksMediaDemand { playingActive = false } recordingActive = initiallyRecordingActive self.inputRouteStartsRecording = inputRouteStartsRecording configureFailuresRemaining = max(configureFailures, 0) @@ -2364,27 +2763,74 @@ private actor RTCFakeAudioDriver: GridAudioDriver { configureFailuresRemaining = 0 } + func setMediaDemandActive(_ active: Bool, epoch: UInt64) async throws { + if active, mediaAdmissionBlocked { throw FakeGridRTCDriverError.audioConfigureFailed } + if epoch != mediaEpoch { + mediaEpoch = epoch + mediaQuiescent = false + } + mediaActive = active + if nativeAvailabilityTracksMediaDemand, !active { + playingActive = false + recordingActive = false + } + log.append("media:\(active):\(epoch)") + try await audioSession?.setGridDemandActive(active, epoch: epoch) + } + + func mediaDidQuiesce(epoch: UInt64) async { + guard epoch == mediaEpoch, !mediaActive else { return } + mediaQuiescent = true + log.append("quiescent:\(epoch)") + await audioSession?.gridMediaDidQuiesce(epoch: epoch) + if audioSession != nil { _ = await runtimeHealth() } + } + + func canAdmitStandaloneRecorder() -> Bool { !mediaActive && mediaQuiescent } + + func allowMediaAdmission() { mediaAdmissionBlocked = false } + func setPrepared(_ prepared: Bool) async throws { log.append("prepared:\(prepared)") + if nativeAvailabilityTracksMediaDemand, prepared { + guard mediaActive else { return } + log.append("capture-open:\(mediaEpoch)") + playingActive = true + } if recordingActive != nil { recordingActive = prepared } } + func resumeAfterTerminalShutdown() async { + guard nativeAvailabilityTracksMediaDemand, mediaActive else { return } + log.append("resume:\(mediaEpoch)") + playingActive = true + } + + func recordingIsActive() -> Bool { recordingActive ?? false } + func recoverPreparedAudio(preserving _: AudioInputRouteTarget?) async throws { log.append("recover") + if nativeAvailabilityTracksMediaDemand, !mediaActive { return } } func recoverPlayout(preserving _: AudioOutputRouteTarget?) async throws { log.append("recover-playout") + if nativeAvailabilityTracksMediaDemand, !mediaActive { return } playingActive = true } func runtimeHealth() async -> GridAudioRuntimeHealth { - GridAudioRuntimeHealth( + if let audioSession { + await audioSession.observeGridDirections(recording: recordingActive ?? false, + playing: nativeAvailabilityTracksMediaDemand ? playingActive : mediaActive, + nativeOperationsQuiescent: true, epoch: mediaEpoch) + } + return GridAudioRuntimeHealth( isEngineRunning: true, isRecording: recordingActive ?? true, - isPlaying: playingActive, + isPlaying: nativeAvailabilityTracksMediaDemand || audioSession == nil ? playingActive : mediaActive, route: InlineRTCAudioRoute( currentInputID: "default", defaultInputID: "default", @@ -2427,6 +2873,58 @@ private actor RTCFakeAudioDriver: GridAudioDriver { } } +/// Controlled native I/O beneath the real process authority. Room/provider +/// ownership and epoch arbitration stay in the production RTC/audio engines. +@MainActor +private func withRTCNativeCancellationDrain( + backend: RTCProcessAudioBackend, _ operation: @MainActor () async throws -> Void +) async throws { + try await withTaskCancellationHandler { + try await operation() + try Task.checkCancellation() + } onCancel: { + Task { await backend.drain() } + } +} + +private actor RTCProcessAudioBackend: InlineAudioSessionBackend { + enum Operation: Equatable, Sendable { + case activate(InlineAudioSession.Token), release(InlineAudioSession.Token) + } + private struct ReleaseFailure: Error {} + private var log: [Operation] = [] + private var blocksRelease = false + private var releaseContinuation: CheckedContinuation? + + func activate(token: InlineAudioSession.Token, configuration _: InlineAudioSession.Configuration) async throws { + log.append(.activate(token)) + } + + func release(token: InlineAudioSession.Token) async throws { + log.append(.release(token)) + guard blocksRelease else { return } + blocksRelease = false + try await withCheckedThrowingContinuation { releaseContinuation = $0 } + } + + func invalidate() async {} + func operations() -> [Operation] { log } + func blockNextRelease() { blocksRelease = true } + func releaseIsBlocked() -> Bool { releaseContinuation != nil } + + func completeRelease(failing: Bool = false) { + let continuation = releaseContinuation + releaseContinuation = nil + if failing { continuation?.resume(throwing: ReleaseFailure()) } + else { continuation?.resume() } + } + + func drain() { + blocksRelease = false + completeRelease() + } +} + private actor FakeGridRTCDriver: GridRTCDriver { nonisolated let lifecycleEvents: AsyncStream nonisolated let participantSnapshots: AsyncStream @@ -2446,6 +2944,7 @@ private actor FakeGridRTCDriver: GridRTCDriver { private let failScreenShareStopRoomID: Int64? private let blockSilenceRoomID: Int64? private let blockDisconnectRoomID: Int64? + private let blockLocalQuiescenceRoomID: Int64? private let localQuiescenceFailureRoomID: Int64? private let disconnectDelay: Duration private var blockedConnectContinuations: [CheckedContinuation] = [] @@ -2459,6 +2958,8 @@ private actor FakeGridRTCDriver: GridRTCDriver { private var blockedScreenShareContinuations: [CheckedContinuation] = [] private var blockedSilenceContinuations: [CheckedContinuation] = [] private var blockedDisconnectContinuations: [Int64: [CheckedContinuation]] = [:] + private var blockedLocalQuiescenceContinuations: [CheckedContinuation] = [] + private var localQuiescenceGateOpen = false private var publishFailuresRemaining: Int private var screenShareFailuresRemaining: Int private var activeScreenShareRoomIDs = Set() @@ -2476,6 +2977,7 @@ private actor FakeGridRTCDriver: GridRTCDriver { failScreenShareStopRoomID: Int64? = nil, blockSilenceRoomID: Int64? = nil, blockDisconnectRoomID: Int64? = nil, + blockLocalQuiescenceRoomID: Int64? = nil, localQuiescenceFailureRoomID: Int64? = nil, publishFailures: Int = 0, screenShareFailures: Int = 0, @@ -2502,6 +3004,7 @@ private actor FakeGridRTCDriver: GridRTCDriver { self.failScreenShareStopRoomID = failScreenShareStopRoomID self.blockSilenceRoomID = blockSilenceRoomID self.blockDisconnectRoomID = blockDisconnectRoomID + self.blockLocalQuiescenceRoomID = blockLocalQuiescenceRoomID self.localQuiescenceFailureRoomID = localQuiescenceFailureRoomID blockedConnectCallsRemaining = blockedRoomID == nil ? 0 : max(blockedConnectCalls, 0) blockedPublishCallsRemaining = blockedPublishRoomID == nil ? 0 : 1 @@ -2647,6 +3150,11 @@ private actor FakeGridRTCDriver: GridRTCDriver { // Keep disconnect vocabulary for ordering assertions while avoiding the // provider-facing silence path that local quiescence intentionally bypasses. log.append("disconnect:\(roomID)") + if blockLocalQuiescenceRoomID == roomID, !localQuiescenceGateOpen { + await withCheckedContinuation { continuation in + blockedLocalQuiescenceContinuations.append(continuation) + } + } try? await Task.sleep(for: disconnectDelay) if localQuiescenceFailureRoomID == roomID { return GridLocalRoomQuiescenceReceipt( @@ -2713,6 +3221,15 @@ private actor FakeGridRTCDriver: GridRTCDriver { continuations.forEach { $0.resume() } } + func localQuiescenceIsBlocked() -> Bool { !blockedLocalQuiescenceContinuations.isEmpty } + + func releaseBlockedLocalQuiescence() { + localQuiescenceGateOpen = true + let continuations = blockedLocalQuiescenceContinuations + blockedLocalQuiescenceContinuations.removeAll() + continuations.forEach { $0.resume() } + } + func operations() -> [String] { log } diff --git a/apple/InlineKit/Tests/InlineRTCTests/Engine/LiveKitGridIOSAudioDriverTests.swift b/apple/InlineKit/Tests/InlineRTCTests/Engine/LiveKitGridIOSAudioDriverTests.swift new file mode 100644 index 000000000..2443aab26 --- /dev/null +++ b/apple/InlineKit/Tests/InlineRTCTests/Engine/LiveKitGridIOSAudioDriverTests.swift @@ -0,0 +1,247 @@ +#if os(iOS) +import Foundation +import LiveKit +import Testing + +@testable import InlineRTC + +@Suite("iOS Grid audio safety", .serialized) +struct LiveKitGridIOSAudioDriverTests { + @Test("an interruption fencing suspended preparation prevents its late input opening") + func interruptionFencesSuspendedPreparation() async throws { + let backend = BlockingIOSGridAudioBackend(blockPreparation: true) + let driver = LiveKitGridIOSAudioDriver(testingBackend: backend) + try await driver.setMediaDemandActive(true, epoch: 1) + let preparation = Task { try await driver.setPrepared(true) } + try await waitUntil { backend.hasBlockedOperation } + + await driver.interruptionBegan() + backend.releaseBlockedOperation() + do { + try await preparation.value + Issue.record("The interrupted preparation must be superseded") + } catch {} + #expect(backend.inputOpeningCount == 0) + #expect(!backend.outputAvailable) + + await driver.interruptionEnded(shouldResume: true) + #expect(backend.activationCount == 1) + #expect(backend.outputAvailable) + #expect(backend.inputOpeningCount == 0) + } + + @Test("admission withdrawal prevents suspended preparation from reopening either direction") + func withdrawalFencesSuspendedPreparation() async throws { + let backend = BlockingIOSGridAudioBackend(blockPreparation: true) + let driver = LiveKitGridIOSAudioDriver(testingBackend: backend) + try await driver.setMediaDemandActive(true, epoch: 1) + let preparation = Task { try await driver.setPrepared(true) } + try await waitUntil { backend.hasBlockedOperation } + + try await driver.setMediaDemandActive(false, epoch: 2) + backend.releaseBlockedOperation() + do { + try await preparation.value + Issue.record("The withdrawn preparation must be superseded") + } catch {} + #expect(backend.inputOpeningCount == 0) + #expect(!backend.outputAvailable) + try await driver.resumeListeningAfterSafetyPause() + #expect(!backend.outputAvailable) + } + + @Test("explicit retry cannot resume listening while a system interruption is active") + func retryCannotResumeAnActiveInterruption() async throws { + let backend = BlockingIOSGridAudioBackend() + let driver = LiveKitGridIOSAudioDriver(testingBackend: backend) + try await driver.setMediaDemandActive(true, epoch: 1) + await driver.interruptionBegan() + do { + try await driver.resumeListeningAfterSafetyPause() + Issue.record("Retry must be rejected while interrupted") + } catch {} + #expect(!backend.outputAvailable) + #expect(backend.inputOpeningCount == 0) + + await driver.interruptionEnded(shouldResume: false) + #expect(!backend.outputAvailable) + try await driver.resumeListeningAfterSafetyPause() + #expect(backend.outputAvailable) + #expect(backend.inputOpeningCount == 0) + } + + @Test("an unplugged private output requires explicit retry even after another interruption ends") + func privateOutputDoesNotAutomaticallyResume() async throws { + let backend = BlockingIOSGridAudioBackend() + let driver = LiveKitGridIOSAudioDriver(testingBackend: backend) + try await driver.setMediaDemandActive(true, epoch: 1) + await driver.pauseForSafety(requiresExplicitOutputResume: true) + await driver.interruptionBegan() + await driver.interruptionEnded(shouldResume: true) + #expect(!backend.outputAvailable) + try await driver.resumeListeningAfterSafetyPause() + #expect(backend.outputAvailable) + #expect(backend.inputOpeningCount == 0) + } + + @Test("withdrawing admission during a suspended listening retry prevents late output reopening") + func withdrawalFencesSuspendedListeningResume() async throws { + let backend = BlockingIOSGridAudioBackend() + let driver = LiveKitGridIOSAudioDriver(testingBackend: backend) + try await driver.setMediaDemandActive(true, epoch: 1) + await driver.pauseForSafety(requiresExplicitOutputResume: true) + backend.blockNextStop() + let retry = Task { try await driver.resumeListeningAfterSafetyPause() } + try await waitUntil { backend.hasBlockedOperation } + + try await driver.setMediaDemandActive(false, epoch: 2) + backend.releaseBlockedOperation() + try await retry.value + #expect(!backend.outputAvailable) + #expect(backend.inputOpeningCount == 0) + } + + @Test("an audio services reset keeps both directions closed until process restart") + func resetDoesNotPromiseUnsupportedRecovery() async throws { + let backend = BlockingIOSGridAudioBackend() + let driver = LiveKitGridIOSAudioDriver(testingBackend: backend) + try await driver.setMediaDemandActive(true, epoch: 1) + await driver.mediaServicesWereReset() + do { + try await driver.resumeListeningAfterSafetyPause() + Issue.record("A reset requires process restart") + } catch {} + #expect(!backend.outputAvailable) + #expect(backend.activationCount == 0) + #expect(backend.inputOpeningCount == 0) + } + + @Test("an interruption during suspended recovery stop prevents its old capture restart") + func interruptionFencesSuspendedRecovery() async throws { + let backend = BlockingIOSGridAudioBackend() + let driver = LiveKitGridIOSAudioDriver(testingBackend: backend) + try await driver.setMediaDemandActive(true, epoch: 1) + try await driver.setPrepared(true) + let openings = backend.inputOpeningCount + backend.blockNextStop() + let recovery = Task { try await driver.recoverPreparedAudio(preserving: nil) } + try await waitUntil { backend.hasBlockedOperation } + + await driver.interruptionBegan() + backend.releaseBlockedOperation() + do { + try await recovery.value + Issue.record("Interrupted recovery must be superseded") + } catch {} + #expect(backend.inputOpeningCount == openings) + #expect(!backend.outputAvailable) + } + + @Test("the process is quarantined before an audio reset waits for native warm teardown") + func resetQuarantinesBeforeSuspendedStop() async throws { + let backend = BlockingIOSGridAudioBackend() + let driver = LiveKitGridIOSAudioDriver(testingBackend: backend) + try await driver.setMediaDemandActive(true, epoch: 1) + backend.blockNextStop() + let reset = Task { await driver.mediaServicesWereReset() } + try await waitUntil { backend.hasBlockedOperation } + #expect(await backend.isQuarantined()) + do { + try await driver.resumeListeningAfterSafetyPause() + Issue.record("Retry must be fenced before reset teardown returns") + } catch {} + #expect(!backend.outputAvailable) + backend.releaseBlockedOperation() + await reset.value + } + + @Test("a receipt from an older call epoch cannot release the current retired writer") + func quiescenceReceiptMatchesRetiredEpoch() async throws { + let backend = BlockingIOSGridAudioBackend() + let driver = LiveKitGridIOSAudioDriver(testingBackend: backend) + try await driver.setMediaDemandActive(true, epoch: 1) + try await driver.setMediaDemandActive(false, epoch: 2) + try await driver.setMediaDemandActive(true, epoch: 3) + try await driver.setMediaDemandActive(false, epoch: 4) + await driver.mediaDidQuiesce(epoch: 2) + #expect(backend.quiescenceEpochs.isEmpty) + await driver.mediaDidQuiesce(epoch: 4) + #expect(backend.quiescenceEpochs == [4]) + } +} + +/// A suspended SDK operation, not a model of the driver's epoch state. +private final class BlockingIOSGridAudioBackend: IOSGridAudioBackend, @unchecked Sendable { + private let lock = NSLock() + private var blocksPreparation: Bool + private var blocksStop = false + private var continuation: CheckedContinuation? + private var inputOpenings = 0 + private var output = false + private var activations = 0 + private var quarantined = false + private var reportedQuiescenceEpochs: [UInt64] = [] + + init(blockPreparation: Bool = false) { blocksPreparation = blockPreparation } + + var hasBlockedOperation: Bool { lock.withLock { continuation != nil } } + var inputOpeningCount: Int { lock.withLock { inputOpenings } } + var outputAvailable: Bool { lock.withLock { output } } + var activationCount: Int { lock.withLock { activations } } + var quiescenceEpochs: [UInt64] { lock.withLock { reportedQuiescenceEpochs } } + + func setEngineAvailability(_ availability: AudioEngineAvailability) throws { + lock.withLock { + if availability.isInputAvailable { inputOpenings += 1 } + output = availability.isOutputAvailable + } + } + + func setRecordingAlwaysPreparedMode(_ value: Bool, audioProcessingOptions _: AudioProcessingOptions?) async throws { + let shouldBlock = lock.withLock { + if value, blocksPreparation { + blocksPreparation = false + return true + } + if !value, blocksStop { + blocksStop = false + return true + } + return false + } + if shouldBlock { + await withCheckedContinuation { continuation in + lock.withLock { self.continuation = continuation } + } + } + } + + func blockNextStop() { lock.withLock { blocksStop = true } } + + func releaseBlockedOperation() { + let continuation = lock.withLock { + let result = self.continuation + self.continuation = nil + return result + } + continuation?.resume() + } + + func isQuarantined() async -> Bool { lock.withLock { quarantined } } + func setGridDemandActive(_: Bool, epoch _: UInt64) async throws {} + func gridMediaDidQuiesce(epoch: UInt64) async { lock.withLock { reportedQuiescenceEpochs.append(epoch) } } + func resumeGridSession(epoch _: UInt64) async throws { lock.withLock { activations += 1 } } + func quarantineAfterMediaServicesReset() async { lock.withLock { quarantined = true } } +} + +private func waitUntil(_ condition: @Sendable () -> Bool) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + while !condition() { + if clock.now >= deadline { throw IOSAudioSafetyTestTimeout() } + try await Task.sleep(for: .milliseconds(5)) + } +} + +private struct IOSAudioSafetyTestTimeout: Error {} +#endif diff --git a/apple/InlineKit/Tests/RealtimeV2Tests/UserInitiatedConnectionTests.swift b/apple/InlineKit/Tests/RealtimeV2Tests/UserInitiatedConnectionTests.swift index fe97b012e..e7b1067bf 100644 --- a/apple/InlineKit/Tests/RealtimeV2Tests/UserInitiatedConnectionTests.swift +++ b/apple/InlineKit/Tests/RealtimeV2Tests/UserInitiatedConnectionTests.swift @@ -71,6 +71,52 @@ struct UserInitiatedConnectionTests { } } + @Test("admitted media survives UIKit grace without claiming foreground and ends on release") + func mediaRetentionPastGrace() async { + let (manager, session) = fixture() + await manager.setMediaRetention(eligible: true, revision: 1) + #expect(await opens(manager)) + await manager.applicationBecameInactive(keepConnection: true) + let id = await manager.currentSnapshot().sessionID + let before = await session.stops + await manager.scheduledEventDidFire(.backgroundGraceExpired, sessionID: id) + #expect(await manager.currentSnapshot().state == .open) + #expect(await manager.currentSnapshot().constraints.appActive == false) + #expect(await session.stops == before) + await manager.setMediaRetention(eligible: false, revision: 2) + #expect(await manager.currentSnapshot().state == .backgroundSuspended) + #expect(await session.stops == before + 1) + await manager.shutdownForTesting() + } + + @Test("stale media eligibility cannot reopen a withdrawn media owner") + func staleMediaRetention() async { + let (manager, session) = fixture() + await manager.setMediaRetention(eligible: true, revision: 1) + #expect(await opens(manager)) + await manager.setMediaRetention(eligible: false, revision: 3) + #expect(await manager.currentSnapshot().state == .backgroundSuspended) + let starts = await session.starts + await manager.setMediaRetention(eligible: true, revision: 2) + #expect(await manager.currentSnapshot().state == .backgroundSuspended) + #expect(await session.starts == starts) + await manager.shutdownForTesting() + } + + @Test("auth loss clears media retention before a replacement authentication") + func authLossRetiresMedia() async { + let (manager, session) = fixture() + await manager.setMediaRetention(eligible: true, revision: 1) + #expect(await opens(manager)) + await manager.setAuthAvailable(false) + #expect(await manager.currentSnapshot().state != .open) + let starts = await session.starts + await manager.setAuthAvailable(true) + #expect(await manager.currentSnapshot().state != .open) + #expect(await session.starts == starts) + await manager.shutdownForTesting() + } + private func fixture() -> (ConnectionManager, IntentActivitySession) { let session = IntentActivitySession() return (ConnectionManager(session: session, constraints: .init( diff --git a/apple/InlineMac/App/AppDependencies.swift b/apple/InlineMac/App/AppDependencies.swift index 40dd061a8..fc50cd719 100644 --- a/apple/InlineMac/App/AppDependencies.swift +++ b/apple/InlineMac/App/AppDependencies.swift @@ -1,3 +1,4 @@ +import InlineGrid import Auth import Foundation import GRDB @@ -40,7 +41,11 @@ public struct AppDependencies { let session = tracedLaunchDependency("DependencySessionRefresher") { MainWindowSessionRefresher() } let unreadCounts = tracedLaunchDependency("DependencyUnreadCounts") { UnreadCountsModel.shared } let userSettings = tracedLaunchDependency("DependencyUserSettings") { INUserSettings.current } - let gridRuntime = tracedLaunchDependency("DependencyGridRuntime") { GridRuntime.shared } + let gridRuntime = tracedLaunchDependency("DependencyGridRuntime") { + let runtime = GridRuntime.shared + runtime.configurePlatformEffects(.macOS) + return runtime + } let commandBarCatalog = tracedLaunchDependency("DependencyCommandCatalog") { CommandBarCatalogService(database: AppDatabase.shared) } diff --git a/apple/InlineMac/Features/Grid/GridAvatarStateSync.swift b/apple/InlineMac/Features/Grid/GridAvatarStateSync.swift index 0018c68c7..c7a3d8f7a 100644 --- a/apple/InlineMac/Features/Grid/GridAvatarStateSync.swift +++ b/apple/InlineMac/Features/Grid/GridAvatarStateSync.swift @@ -1,101 +1,2 @@ -import Logger - -/// Serializes the transient state owned by the current Grid avatar. -/// -/// Rapid UI changes are coalesced to the latest value while one RPC is in -/// flight, so task scheduling cannot reorder mute/unmute updates on the wire. -/// Failed latest state is retained for a network/wake retry, but ownership -/// changes immediately discard work for a room this process no longer owns. -@MainActor -final class GridAvatarStateSync { - private struct Request: Equatable { - let roomID: Int64 - let microphoneEnabled: Bool - } - - private let api: GridRoomAPI - private let log = Log.scoped("GridAvatarStateSync") - private var ownedRoomID: Int64? - private var queued: Request? - private var failed: Request? - private var generation = 0 - private var syncTask: Task? - private var workerGeneration: Int? - - init(api: GridRoomAPI) { - self.api = api - } - - deinit { - syncTask?.cancel() - } - - func setOwnedRoom(_ roomID: Int64?) { - guard ownedRoomID != roomID else { return } - generation &+= 1 - ownedRoomID = roomID - if queued?.roomID != roomID { queued = nil } - if failed?.roomID != roomID { failed = nil } - syncTask?.cancel() - syncTask = nil - workerGeneration = nil - startIfNeeded() - } - - func submitMicrophoneState(roomID: Int64, enabled: Bool) { - guard ownedRoomID == roomID else { return } - queued = Request(roomID: roomID, microphoneEnabled: enabled) - failed = nil - startIfNeeded() - } - - func retryLatestFailure() { - guard queued == nil, let failed, failed.roomID == ownedRoomID else { return } - queued = failed - self.failed = nil - startIfNeeded() - } - - private func startIfNeeded() { - guard syncTask == nil, queued != nil else { return } - let workerGeneration = generation - self.workerGeneration = workerGeneration - syncTask = Task { [weak self] in - await self?.run(workerGeneration: workerGeneration) - } - } - - private func run(workerGeneration: Int) async { - while !Task.isCancelled, generation == workerGeneration, let request = queued { - queued = nil - do { - try await api.setMicrophoneEnabled( - request.microphoneEnabled, - roomID: request.roomID - ) - log.debug( - "GRID_TRACE phase=avatar_microphone_sent room=\(request.roomID) enabled=\(request.microphoneEnabled)" - ) - } catch { - guard !Task.isCancelled, generation == workerGeneration else { continue } - // Preserve only the latest still-relevant request. A newer UI intent - // already waiting in `queued` supersedes the failed value. - if ownedRoomID == request.roomID, queued == nil { - failed = request - } - log.warning( - "GRID_TRACE phase=avatar_microphone_send_failed room=\(request.roomID) enabled=\(request.microphoneEnabled)" - ) - PerformanceTrace.breadcrumb( - "Grid avatar microphone sync failed", - category: "Grid.State", - level: .warning - ) - } - } - guard self.workerGeneration == workerGeneration else { return } - syncTask = nil - self.workerGeneration = nil - startIfNeeded() - } -} +// Grid product state is shared by the iOS and macOS clients. +import InlineGrid diff --git a/apple/InlineMac/Features/Grid/GridControlsView.swift b/apple/InlineMac/Features/Grid/GridControlsView.swift index c1e154247..c306dd188 100644 --- a/apple/InlineMac/Features/Grid/GridControlsView.swift +++ b/apple/InlineMac/Features/Grid/GridControlsView.swift @@ -1,3 +1,4 @@ +import InlineGrid import AppKit import CoreGraphics import InlineKit diff --git a/apple/InlineMac/Features/Grid/GridHomePreferences.swift b/apple/InlineMac/Features/Grid/GridHomePreferences.swift index b7b7dc160..c7a3d8f7a 100644 --- a/apple/InlineMac/Features/Grid/GridHomePreferences.swift +++ b/apple/InlineMac/Features/Grid/GridHomePreferences.swift @@ -1,40 +1,2 @@ -import Foundation -import InlineProtocol - -/// Local navigation history used only as a stable tie-breaker after live Grid -/// activity. It does not own eligibility or presence; the server summary does. -@MainActor -final class GridHomePreferences { - private let defaults: UserDefaults - private let key = "grid.home.lastOpenedSpaces" - - init(defaults: UserDefaults = .standard) { - self.defaults = defaults - } - - func recordOpened(spaceID: Int64) { - var values = timestamps - values[String(spaceID)] = Date().timeIntervalSince1970 - defaults.set(values, forKey: key) - } - - func ordered(_ spaces: [GridHomeSpace]) -> [GridHomeSpace] { - let timestamps = timestamps - return spaces.sorted { lhs, rhs in - let lhsActive = lhs.activeAvatarCount > 0 - let rhsActive = rhs.activeAvatarCount > 0 - if lhsActive != rhsActive { return lhsActive } - if lhs.latestActivityAt != rhs.latestActivityAt { - return lhs.latestActivityAt > rhs.latestActivityAt - } - let lhsOpened = timestamps[String(lhs.spaceID)] ?? 0 - let rhsOpened = timestamps[String(rhs.spaceID)] ?? 0 - if lhsOpened != rhsOpened { return lhsOpened > rhsOpened } - return lhs.spaceID < rhs.spaceID - } - } - - private var timestamps: [String: Double] { - defaults.dictionary(forKey: key) as? [String: Double] ?? [:] - } -} +// Grid product state is shared by the iOS and macOS clients. +import InlineGrid diff --git a/apple/InlineMac/Features/Grid/GridMediaCoordinator.swift b/apple/InlineMac/Features/Grid/GridMediaCoordinator.swift index 802d68c52..c7a3d8f7a 100644 --- a/apple/InlineMac/Features/Grid/GridMediaCoordinator.swift +++ b/apple/InlineMac/Features/Grid/GridMediaCoordinator.swift @@ -1,505 +1,2 @@ -import Foundation -import InlineKit -import InlineRTC - -enum GridMediaCoordinatorEvent: Sendable { - case credentialsNeeded(GridMediaTarget) - case connected(GridMediaTarget, rtcConnectMilliseconds: Int?) - case screenShareContextChanged -} - -struct GridAutomaticMicrophoneChange: Equatable, Sendable { - let revision: Int - let previousEnabled: Bool - let appliedEnabled: Bool -} - -/// Main-actor bridge between room product state and the process-wide media -/// engine. This owns media intent, preference persistence, credentials, and -/// snapshot projection; `GridRoomService` never reaches into audio or RTC. -@MainActor -final class GridMediaCoordinator { - let presentation: GridMediaPresentation - - private let engine: InlineRTCSession - private let presentationController: GridMediaPresentationController - private let inputPreferences: AudioInputPreferenceStore - private let outputPreferences: AudioOutputPreferenceStore - private let defaults: UserDefaults - private var microphoneEnabled: Bool - private var microphoneIntentRevision = 0 - private var activeAutomaticMicrophoneChange: GridAutomaticMicrophoneChange? - private var autoUnmuteOnJoin: Bool - private var autoMuteWhenAlone: Bool - private var inputSelection: AudioInputSelection - private var outputSelection: AudioOutputSelection - private var outputVolume: Float = 1 - private var screenCaptureSource: InlineRTCScreenCaptureSource? - private var screenShareQualityProfile: InlineRTCScreenShareQualityProfile - private var screenCaptureSourceRefresh: GridScreenCaptureSourceRefresh? - private var target: GridMediaTarget? - private var connectedTarget: GridMediaTarget? - private var credentials: InlineRTCCredentials? - private var lastDemand: InlineRTCDemand? - private var playedConnectionSoundKeys = Set() - private var engineTask: Task? - private var subscribers: [UUID: AsyncStream.Continuation] = [:] - - private static let microphoneEnabledKey = "grid.microphoneEnabled" - private static let autoUnmuteOnJoinKey = "grid.autoUnmuteOnJoin" - private static let autoMuteWhenAloneKey = "grid.autoMuteWhenAlone" - private static let screenShareQualityProfileKey = "grid.screenShareQualityProfile" - - init( - engine: InlineRTCSession, - defaults: UserDefaults, - inputPreferences: AudioInputPreferenceStore, - outputPreferences: AudioOutputPreferenceStore - ) { - self.engine = engine - self.defaults = defaults - self.inputPreferences = inputPreferences - self.outputPreferences = outputPreferences - inputSelection = inputPreferences.selection - outputSelection = outputPreferences.selection - microphoneEnabled = defaults.bool(forKey: Self.microphoneEnabledKey) - autoUnmuteOnJoin = defaults.bool(forKey: Self.autoUnmuteOnJoinKey) - autoMuteWhenAlone = defaults.bool(forKey: Self.autoMuteWhenAloneKey) - screenShareQualityProfile = defaults.string(forKey: Self.screenShareQualityProfileKey) - .flatMap(InlineRTCScreenShareQualityProfile.init(rawValue:)) - ?? .automatic - let controller = GridMediaPresentationController( - microphoneEnabled: microphoneEnabled, - autoUnmuteOnJoin: autoUnmuteOnJoin, - autoMuteWhenAlone: autoMuteWhenAlone, - inputSelection: inputSelection, - screenShareQualityProfile: screenShareQualityProfile, - outputSelection: outputSelection - ) - presentationController = controller - presentation = controller.presentation - - engineTask = Task { [weak self, engine] in - await engine.start() - guard !Task.isCancelled else { return } - engine.refreshDevices() - self?.submitDemand() - let snapshots = await engine.subscribe() - for await snapshot in snapshots { - guard !Task.isCancelled else { return } - self?.apply(snapshot) - } - } - } - - deinit { - engineTask?.cancel() - subscribers.values.forEach { $0.finish() } - engine.requestShutdown() - } - - func subscribe() -> AsyncStream { - let id = UUID() - let stream = AsyncStream.makeStream( - of: GridMediaCoordinatorEvent.self, - // These are edge notifications backed by complete engine snapshots and - // target-scoped credential retry. A stalled observer must stay bounded. - bufferingPolicy: .bufferingNewest(16) - ) - stream.continuation.onTermination = { [weak self] _ in - Task { @MainActor in self?.subscribers.removeValue(forKey: id) } - } - subscribers[id] = stream.continuation - return stream.stream - } - - func setTarget(_ target: GridMediaTarget?) { - guard self.target != target else { - submitDemand() - return - } - screenCaptureSource = nil - presentationController.setSelectedScreenCaptureSource(nil) - presentationController.clearScreenCaptureError() - GridScreenShareOutlineCoordinator.shared.hide() - GridScreenShareWindowCoordinator.shared.closeAll() - self.target = target - if credentials?.target != target?.rtcSessionID { - credentials = nil - } - submitDemand() - } - - func accept(_ credentials: InlineRTCCredentials) -> Bool { - guard credentials.target == target?.rtcSessionID else { return false } - self.credentials = credentials - submitDemand() - return true - } - - func hasUsableCredentials(for target: GridMediaTarget) -> Bool { - credentials?.target == target.rtcSessionID - && (credentials?.expiresAt.timeIntervalSinceNow ?? 0) > 5 - } - - func isConnected(to target: GridMediaTarget) -> Bool { - connectedTarget == target - } - - func invalidateCredentials(for target: GridMediaTarget) { - guard credentials?.target == target.rtcSessionID else { return } - credentials = nil - submitDemand() - } - - func clearCredentials() { - guard credentials != nil else { return } - credentials = nil - submitDemand() - } - - func toggleMicrophone() -> Bool { - setMicrophoneEnabled(!microphoneEnabled) - } - - @discardableResult - func setMicrophoneEnabled(_ enabled: Bool) -> Bool { - guard microphoneEnabled != enabled else { return enabled } - if enabled, presentation.microphonePermission != .authorized { - engine.requestMicrophonePermission() - } - activeAutomaticMicrophoneChange = nil - microphoneIntentRevision &+= 1 - microphoneEnabled = enabled - presentationController.setMicrophoneEnabled(enabled) - defaults.set(enabled, forKey: Self.microphoneEnabledKey) - submitDemand() - return enabled - } - - func applyAutoUnmuteOnJoin() -> GridAutomaticMicrophoneChange? { - guard autoUnmuteOnJoin else { return nil } - if microphoneEnabled { - return activeAutomaticMicrophoneChange - } - let previousEnabled = microphoneEnabled - setMicrophoneEnabled(true) - let change = GridAutomaticMicrophoneChange( - revision: microphoneIntentRevision, - previousEnabled: previousEnabled, - appliedEnabled: true - ) - activeAutomaticMicrophoneChange = change - return change - } - - @discardableResult - func restoreAutomaticMicrophoneChangeIfCurrent( - _ change: GridAutomaticMicrophoneChange? - ) -> Bool { - guard let change, - activeAutomaticMicrophoneChange == change, - microphoneIntentRevision == change.revision, - microphoneEnabled == change.appliedEnabled - else { return false } - setMicrophoneEnabled(change.previousEnabled) - return true - } - - func commitAutomaticMicrophoneChange(_ change: GridAutomaticMicrophoneChange?) { - guard activeAutomaticMicrophoneChange == change else { return } - activeAutomaticMicrophoneChange = nil - } - - func setAutoUnmuteOnJoin(_ enabled: Bool) { - guard autoUnmuteOnJoin != enabled else { return } - autoUnmuteOnJoin = enabled - defaults.set(enabled, forKey: Self.autoUnmuteOnJoinKey) - presentationController.setAutoUnmuteOnJoin(enabled) - } - - func setAutoMuteWhenAlone(_ enabled: Bool) { - guard autoMuteWhenAlone != enabled else { return } - autoMuteWhenAlone = enabled - defaults.set(enabled, forKey: Self.autoMuteWhenAloneKey) - presentationController.setAutoMuteWhenAlone(enabled) - } - - func requestMicrophonePermission() { - engine.requestMicrophonePermission() - } - - func setInput(_ selection: AudioInputSelection) { - if inputSelection == selection { - if presentation.isFallingBackToAutomaticInput || presentation.audioState.isFailed { - engine.retryInput(selection) - } - return - } - inputSelection = selection - inputPreferences.setSelection(selection) - presentationController.setDesiredInputSelection(selection) - submitDemand() - } - - func setOutput(_ selection: AudioOutputSelection) { - if outputSelection == selection { - if presentation.isFallingBackToAutomaticOutput || presentation.audioState.isFailed { - engine.retryOutput(selection) - } - return - } - outputSelection = selection - outputPreferences.setSelection(selection) - presentationController.setDesiredOutputSelection(selection) - submitDemand() - } - - func setOutputVolume(_ volume: Float) { - outputVolume = min(max(volume, 0), 1) - presentationController.setDesiredOutputVolume(outputVolume) - submitDemand() - } - - func refreshInputDevices() { - engine.refreshDevices() - } - - func refreshOutputDevices() { - engine.refreshDevices() - } - - @discardableResult - func refreshScreenCaptureSources() async -> [InlineRTCScreenCaptureSource] { - if let refresh = screenCaptureSourceRefresh { - return finishScreenCaptureSourceRefresh( - await refresh.task.value, - id: refresh.id - ) - } - - let refresh = GridScreenCaptureSourceRefresh( - id: UUID(), - task: Task { [engine] in - do { - let sources = try await engine.screenCaptureSources() - guard !sources.isEmpty else { - return .failure("No displays are available") - } - return .success(sources) - } catch { - return .failure(error.localizedDescription) - } - } - ) - screenCaptureSourceRefresh = refresh - presentationController.setRefreshingScreenCaptureSources(true) - return finishScreenCaptureSourceRefresh( - await refresh.task.value, - id: refresh.id - ) - } - - func startScreenSharing(displayID: UInt32?) async { - guard let expectedTarget = target else { return } - let sources = await refreshScreenCaptureSources() - guard target == expectedTarget else { return } - let source = displayID.flatMap { displayID in - sources.first { $0.displayID == displayID } - } ?? sources.first - guard let source else { return } - startScreenSharing(source) - } - - func startScreenSharing(_ source: InlineRTCScreenCaptureSource) { - guard target != nil else { return } - screenCaptureSource = source - presentationController.setSelectedScreenCaptureSource(source) - presentationController.clearScreenCaptureError() - submitDemand() - } - - func stopScreenSharing() { - screenCaptureSource = nil - presentationController.setSelectedScreenCaptureSource(nil) - presentationController.clearScreenCaptureError() - GridScreenShareOutlineCoordinator.shared.hide() - submitDemand() - } - - func setScreenShareQualityProfile(_ profile: InlineRTCScreenShareQualityProfile) { - guard screenShareQualityProfile != profile else { return } - screenShareQualityProfile = profile - defaults.set(profile.rawValue, forKey: Self.screenShareQualityProfileKey) - presentationController.setScreenShareQualityProfile(profile) - submitDemand() - } - - func retryAudio() { - engine.retryAudio() - } - - func networkBecameAvailable() { - engine.networkBecameAvailable() - } - - func applicationDidWake() { - engine.applicationDidWake() - } - - func shutdown() async { - target = nil - connectedTarget = nil - credentials = nil - screenCaptureSource = nil - screenCaptureSourceRefresh?.task.cancel() - screenCaptureSourceRefresh = nil - presentationController.setRefreshingScreenCaptureSources(false) - presentationController.setSelectedScreenCaptureSource(nil) - presentationController.clearScreenCaptureError() - GridScreenShareOutlineCoordinator.shared.hide() - GridScreenShareWindowCoordinator.shared.closeAll() - lastDemand = nil - engine.setDemand(InlineRTCDemand()) - _ = await engine.shutdown() - } - - var isMicrophoneEnabled: Bool { microphoneEnabled } - var shouldAutoMuteWhenAlone: Bool { autoMuteWhenAlone } - - private func submitDemand() { - let rtcTarget = target?.rtcSessionID - if credentials?.target != rtcTarget { - credentials = nil - } - let demand = InlineRTCDemand( - target: rtcTarget, - credentials: credentials, - microphoneEnabled: microphoneEnabled, - screenCaptureSource: screenCaptureSource, - screenShareQualityProfile: screenShareQualityProfile, - input: inputSelection, - output: outputSelection, - outputVolume: outputVolume - ) - guard demand != lastDemand else { return } - lastDemand = demand - engine.setDemand(demand) - } - - private func finishScreenCaptureSourceRefresh( - _ result: GridScreenCaptureSourceRefreshResult, - id: UUID - ) -> [InlineRTCScreenCaptureSource] { - let sources = result.sources - guard screenCaptureSourceRefresh?.id == id else { return sources } - screenCaptureSourceRefresh = nil - presentationController.setRefreshingScreenCaptureSources(false) - switch result { - case let .success(sources): - presentationController.applyScreenCaptureSources(.success(sources)) - case let .failure(message): - presentationController.applyScreenCaptureSources( - .failure(GridScreenCaptureSourceRefreshError(message: message)) - ) - } - reconcileScreenShareOutline() - return sources - } - - private func apply(_ snapshot: InlineRTCState) { - presentationController.apply(audio: snapshot.audio) - if let devices = snapshot.devices { - presentationController.apply(devices: devices) - } - if let outputDevices = snapshot.outputDevices { - presentationController.apply(outputDevices: outputDevices) - } - - let wasConnected = presentation.connectionState == .connected - presentationController.apply(rtc: snapshot.rtc) - reconcileScreenShareOutline() - broadcast(.screenShareContextChanged) - if case .failed = snapshot.rtc.screenShareState, screenCaptureSource != nil { - screenCaptureSource = nil - presentationController.setSelectedScreenCaptureSource(nil) - submitDemand() - } - if case let .connected(sessionID) = snapshot.rtc.state, - let target, - target.rtcSessionID == sessionID { - connectedTarget = target - } else { - connectedTarget = nil - } - if !wasConnected, - presentation.connectionState == .connected, - let sessionID = snapshot.rtc.target, - let target, - target.rtcSessionID == sessionID { - let soundKey = sessionID.rawValue - if playedConnectionSoundKeys.count >= 128 { - playedConnectionSoundKeys.removeAll(keepingCapacity: true) - } - if playedConnectionSoundKeys.insert(soundKey).inserted { - GridSoundEffects.shared.play(.connected) - } - broadcast(.connected(target, rtcConnectMilliseconds: snapshot.rtc.lastConnectMilliseconds)) - } - if case let .waitingForCredentials(sessionID) = snapshot.rtc.state, - let target, - target.rtcSessionID == sessionID { - broadcast(.credentialsNeeded(target)) - } - } - - private func broadcast(_ event: GridMediaCoordinatorEvent) { - subscribers.values.forEach { $0.yield(event) } - } - - private func reconcileScreenShareOutline() { - guard let localShare = presentation.screenShares.first(where: \.isLocal), - let sourceID = localShare.captureSourceID - else { - GridScreenShareOutlineCoordinator.shared.hide() - return - } - let sources = presentation.screenCaptureSources - + [presentation.selectedScreenCaptureSource].compactMap { $0 } - guard let source = sources.first(where: { $0.id == sourceID }) else { - GridScreenShareOutlineCoordinator.shared.hide() - return - } - GridScreenShareOutlineCoordinator.shared.show(for: source) - } -} - -private struct GridScreenCaptureSourceRefresh { - let id: UUID - let task: Task -} - -private enum GridScreenCaptureSourceRefreshResult: Sendable { - case success([InlineRTCScreenCaptureSource]) - case failure(String) - - var sources: [InlineRTCScreenCaptureSource] { - switch self { - case let .success(sources): sources - case .failure: [] - } - } -} - -private struct GridScreenCaptureSourceRefreshError: LocalizedError, Sendable { - let message: String - - var errorDescription: String? { message } -} - -private extension InlineRTCAudioState { - var isFailed: Bool { - if case .failed = self { return true } - return false - } -} +// Grid product state is shared by the iOS and macOS clients. +import InlineGrid diff --git a/apple/InlineMac/Features/Grid/GridMediaPresentation.swift b/apple/InlineMac/Features/Grid/GridMediaPresentation.swift index 7097a7b1f..c7a3d8f7a 100644 --- a/apple/InlineMac/Features/Grid/GridMediaPresentation.swift +++ b/apple/InlineMac/Features/Grid/GridMediaPresentation.swift @@ -1,270 +1,2 @@ -import Foundation -import InlineKit -import InlineRTC -import Observation - -enum GridMediaConnectionStatus: Equatable, Sendable { - case disconnected - case connecting - case connected - case failed -} - -enum GridScreenCaptureIssue: Equatable, Sendable { - case sourceDiscovery - case sharing -} - -@MainActor -@Observable -final class GridMediaPresentation { - fileprivate(set) var connectionState: GridMediaConnectionStatus = .disconnected - fileprivate(set) var audioState: InlineRTCAudioState = .cold - fileprivate(set) var isMicrophoneEnabled: Bool - fileprivate(set) var autoUnmuteOnJoin: Bool - fileprivate(set) var autoMuteWhenAlone: Bool - fileprivate(set) var inputDevices: [AudioInputDeviceDescriptor] = [] - fileprivate(set) var automaticInputDeviceName = "System Default" - fileprivate(set) var activeInputDeviceID: String? - fileprivate(set) var inputSelection: AudioInputSelection - fileprivate(set) var isFallingBackToAutomaticInput = false - fileprivate(set) var outputDevices: [AudioOutputDeviceDescriptor] = [] - fileprivate(set) var automaticOutputDeviceName = "System Default" - fileprivate(set) var activeOutputDeviceID: String? - fileprivate(set) var outputSelection: AudioOutputSelection - fileprivate(set) var isFallingBackToAutomaticOutput = false - fileprivate(set) var participantAudioLevels: [String: Float] = [:] - fileprivate(set) var participantScreenShareIntents: [String: Bool] = [:] - fileprivate(set) var connectedParticipantIdentities = Set() - fileprivate(set) var hasParticipantMediaSnapshot = false - fileprivate(set) var reconnectCount = 0 - fileprivate(set) var recoveryAttempt = 0 - fileprivate(set) var lastConnectDurationMilliseconds: Int? - fileprivate(set) var lastDisconnectDurationMilliseconds: Int? - fileprivate(set) var lastConnectionError: String? - fileprivate(set) var isMicrophoneCapturePrepared = false - fileprivate(set) var microphonePermission: InlineRTCMicrophonePermission = .notDetermined - fileprivate(set) var microphonePublicationState: InlineRTCMicrophoneState = .notRequested - fileprivate(set) var localAudioFlowState: InlineRTCAudioFlowState = .unknown - fileprivate(set) var remoteAudioFlowStates: [String: InlineRTCAudioFlowState] = [:] - fileprivate(set) var abandonedProviderOperationCount = 0 - fileprivate(set) var providerCircuitOpen = false - fileprivate(set) var outputVolume: Float - fileprivate(set) var screenCaptureSources: [InlineRTCScreenCaptureSource] = [] - fileprivate(set) var selectedScreenCaptureSource: InlineRTCScreenCaptureSource? - fileprivate(set) var screenShareQualityProfile: InlineRTCScreenShareQualityProfile - fileprivate(set) var screenShareEpisodeID: UInt64 = 0 - fileprivate(set) var screenShareState: InlineRTCScreenShareState = .off - fileprivate(set) var screenShares: [InlineRTCScreenShare] = [] - fileprivate(set) var isRefreshingScreenCaptureSources = false - fileprivate(set) var screenCaptureError: String? - fileprivate(set) var screenCaptureIssue: GridScreenCaptureIssue? - - fileprivate init( - microphoneEnabled: Bool, - autoUnmuteOnJoin: Bool, - autoMuteWhenAlone: Bool, - inputSelection: AudioInputSelection, - screenShareQualityProfile: InlineRTCScreenShareQualityProfile = .automatic, - outputSelection: AudioOutputSelection = .automatic, - outputVolume: Float = 1 - ) { - isMicrophoneEnabled = microphoneEnabled - self.autoUnmuteOnJoin = autoUnmuteOnJoin - self.autoMuteWhenAlone = autoMuteWhenAlone - self.inputSelection = inputSelection - self.screenShareQualityProfile = screenShareQualityProfile - self.outputSelection = outputSelection - self.outputVolume = min(max(outputVolume, 0), 1) - } - - var isUsingAutomaticInput: Bool { - inputSelection == .automatic - } - - var hasRemoteAudioFlowFailure: Bool { - remoteAudioFlowStates.values.contains(.missing) - } - - var isScreenSharing: Bool { - screenShares.contains(where: \.isLocal) - } - - var isScreenShareRequested: Bool { - selectedScreenCaptureSource != nil - } - - func prefersInputDevice(_ device: AudioInputDeviceDescriptor) -> Bool { - inputSelection.matches(device, among: inputDevices) - } - - func prefersOutputDevice(_ device: AudioOutputDeviceDescriptor) -> Bool { - outputSelection.matches(device, among: outputDevices) - } - -} - -/// The sole writer for `GridMediaPresentation`. Views only receive the -/// presentation reference and therefore cannot feed state back into its owner. -@MainActor -final class GridMediaPresentationController { - let presentation: GridMediaPresentation - - init( - microphoneEnabled: Bool, - autoUnmuteOnJoin: Bool, - autoMuteWhenAlone: Bool, - inputSelection: AudioInputSelection, - screenShareQualityProfile: InlineRTCScreenShareQualityProfile = .automatic, - outputSelection: AudioOutputSelection = .automatic, - outputVolume: Float = 1 - ) { - presentation = GridMediaPresentation( - microphoneEnabled: microphoneEnabled, - autoUnmuteOnJoin: autoUnmuteOnJoin, - autoMuteWhenAlone: autoMuteWhenAlone, - inputSelection: inputSelection, - screenShareQualityProfile: screenShareQualityProfile, - outputSelection: outputSelection, - outputVolume: outputVolume - ) - } - - func setMicrophoneEnabled(_ enabled: Bool) { - presentation.isMicrophoneEnabled = enabled - } - - func setAutoUnmuteOnJoin(_ enabled: Bool) { - presentation.autoUnmuteOnJoin = enabled - } - - func setAutoMuteWhenAlone(_ enabled: Bool) { - presentation.autoMuteWhenAlone = enabled - } - - func setDesiredInputSelection(_ selection: AudioInputSelection) { - presentation.inputSelection = selection - } - - func setDesiredOutputSelection(_ selection: AudioOutputSelection) { - presentation.outputSelection = selection - } - - func setDesiredOutputVolume(_ volume: Float) { - presentation.outputVolume = min(max(volume, 0), 1) - } - - func setSelectedScreenCaptureSource(_ source: InlineRTCScreenCaptureSource?) { - if presentation.selectedScreenCaptureSource == nil, source != nil { - presentation.screenShareEpisodeID &+= 1 - } - presentation.selectedScreenCaptureSource = source - } - - func setScreenShareQualityProfile(_ profile: InlineRTCScreenShareQualityProfile) { - presentation.screenShareQualityProfile = profile - } - - func clearScreenCaptureError() { - presentation.screenCaptureError = nil - presentation.screenCaptureIssue = nil - } - - func setRefreshingScreenCaptureSources(_ isRefreshing: Bool) { - presentation.isRefreshingScreenCaptureSources = isRefreshing - } - - func applyScreenCaptureSources( - _ result: Result<[InlineRTCScreenCaptureSource], Error> - ) { - switch result { - case let .success(sources): - presentation.screenCaptureSources = sources - presentation.screenCaptureError = nil - presentation.screenCaptureIssue = nil - case let .failure(error): - presentation.screenCaptureSources = [] - presentation.screenCaptureError = error.localizedDescription - presentation.screenCaptureIssue = .sourceDiscovery - } - } - - func apply(audio snapshot: InlineRTCAudioSnapshot) { - presentation.audioState = snapshot.state - presentation.isMicrophoneCapturePrepared = snapshot.isPrepared - presentation.microphonePermission = snapshot.microphonePermission - if let resolvedInput = snapshot.input { - presentation.activeInputDeviceID = resolvedInput.activeDeviceID - presentation.isFallingBackToAutomaticInput = resolvedInput.isFallingBackToAutomatic - } - if let resolvedOutput = snapshot.output { - presentation.activeOutputDeviceID = resolvedOutput.activeDeviceID - presentation.isFallingBackToAutomaticOutput = resolvedOutput.isFallingBackToAutomatic - } - } - - func apply(rtc snapshot: InlineRTCConnectionSnapshot) { - presentation.connectionState = switch snapshot.state { - case .idle: .disconnected - case .connected: .connected - case .failed: .failed - default: .connecting - } - presentation.participantAudioLevels = Dictionary( - uniqueKeysWithValues: snapshot.participants.map { - ($0.identity, $0.isSpeaking ? $0.audioLevel : 0) - } - ) - presentation.participantScreenShareIntents = Dictionary( - uniqueKeysWithValues: snapshot.participants.compactMap { participant in - participant.screenShareIntent.map { (participant.identity, $0) } - } - ) - presentation.connectedParticipantIdentities = Set(snapshot.participants.map(\.identity)) - presentation.hasParticipantMediaSnapshot = snapshot.hasParticipantMediaSnapshot - presentation.reconnectCount = snapshot.reconnectCount - presentation.recoveryAttempt = snapshot.recoveryAttempt - presentation.lastConnectDurationMilliseconds = snapshot.lastConnectMilliseconds - presentation.lastDisconnectDurationMilliseconds = snapshot.lastDisconnectMilliseconds - presentation.lastConnectionError = snapshot.lastError - presentation.microphonePublicationState = snapshot.microphonePublicationState - presentation.localAudioFlowState = snapshot.localAudioFlowState - presentation.remoteAudioFlowStates = snapshot.remoteAudioFlowStates - presentation.screenShareState = snapshot.screenShareState - presentation.screenShares = snapshot.screenShares - if case let .failed(message) = snapshot.screenShareState, - message != "Screen sharing stopped" { - presentation.screenCaptureError = message - presentation.screenCaptureIssue = .sharing - } else if case let .failed(message) = snapshot.screenShareState, - message == "Screen sharing stopped" { - presentation.screenCaptureError = nil - presentation.screenCaptureIssue = nil - } else if snapshot.screenShareState == .off, - presentation.connectionState == .connected, - presentation.screenCaptureError == "Screen sharing did not stop" { - // A failed Stop is recoverable through room reconstruction. Keep the - // warning visible until the fresh connected projection proves that no - // local publication remains, then retire the transient recovery notice. - presentation.screenCaptureError = nil - presentation.screenCaptureIssue = nil - } - presentation.abandonedProviderOperationCount = snapshot.abandonedProviderOperationCount - presentation.providerCircuitOpen = snapshot.providerCircuitOpen - } - - func apply(devices snapshot: AudioInputDeviceSnapshot) { - presentation.automaticInputDeviceName = snapshot.automaticDeviceName - presentation.inputDevices = snapshot.devices - presentation.activeInputDeviceID = snapshot.resolvedInput.activeDeviceID - presentation.isFallingBackToAutomaticInput = snapshot.resolvedInput.isFallingBackToAutomatic - } - - func apply(outputDevices snapshot: AudioOutputDeviceSnapshot) { - presentation.automaticOutputDeviceName = snapshot.automaticDeviceName - presentation.outputDevices = snapshot.devices - presentation.activeOutputDeviceID = snapshot.resolvedOutput.activeDeviceID - presentation.isFallingBackToAutomaticOutput = - snapshot.resolvedOutput.isFallingBackToAutomatic - } -} +// Grid product state is shared by the iOS and macOS clients. +import InlineGrid diff --git a/apple/InlineMac/Features/Grid/GridMediaTarget.swift b/apple/InlineMac/Features/Grid/GridMediaTarget.swift index c22d334c3..c7a3d8f7a 100644 --- a/apple/InlineMac/Features/Grid/GridMediaTarget.swift +++ b/apple/InlineMac/Features/Grid/GridMediaTarget.swift @@ -1,16 +1,2 @@ -import Foundation -import InlineRTC - -/// Grid's product identity for its current media session. -/// -/// Space, room, and generation remain app-owned. InlineRTC receives only the -/// stable opaque identity produced below and never interprets these fields. -struct GridMediaTarget: Equatable, Hashable, Sendable { - let spaceID: Int64 - let roomID: Int64 - let generation: Int32 - - var rtcSessionID: InlineRTCSessionID { - InlineRTCSessionID("grid:\(spaceID):\(roomID):\(generation)") - } -} +// Grid product state is shared by the iOS and macOS clients. +import InlineGrid diff --git a/apple/InlineMac/Features/Grid/GridMembershipSync.swift b/apple/InlineMac/Features/Grid/GridMembershipSync.swift index b5ec8f894..c7a3d8f7a 100644 --- a/apple/InlineMac/Features/Grid/GridMembershipSync.swift +++ b/apple/InlineMac/Features/Grid/GridMembershipSync.swift @@ -1,144 +1,2 @@ -import Foundation -import InlineProtocol -import Logger - -struct GridMembershipOperation: Equatable, Sendable { - enum Kind: Equatable, Sendable { - case create - case join(roomID: Int64) - case leave(roomID: Int64) - } - - let kind: Kind - let spaceID: Int64 - let microphoneEnabled: Bool - let automaticMicrophoneChange: GridAutomaticMicrophoneChange? - let accessRevision: Int - let revision: Int - let startedAt: Date -} - -enum GridMembershipSyncEvent: Sendable { - case created(GridMembershipOperation, GridRoomMutationResult) - case joined(GridMembershipOperation, GridRoomMutationResult) - case left(GridMembershipOperation, [InlineProtocol.Grid]) - case failed(GridMembershipOperation, message: String) -} - -/// The sole remote executor for Grid avatar membership changes. -/// -/// The UI applies product state optimistically, then submits operations here. -/// A single retained worker preserves create/join/leave order at the server even -/// during rapid interaction. Events carry revisions so the room service can -/// ignore responses superseded by newer local intent without losing wire order. -@MainActor -final class GridMembershipSync { - private struct QueuedOperation { - let generation: Int - let operation: GridMembershipOperation - } - - private let api: GridRoomAPI - private let log = Log.scoped("GridMembershipSync") - private var generation = 0 - private var queue: [QueuedOperation] = [] - private var workerTask: Task? - private var workerGeneration: Int? - private var subscribers: [UUID: AsyncStream.Continuation] = [:] - - init(api: GridRoomAPI) { - self.api = api - } - - deinit { - workerTask?.cancel() - subscribers.values.forEach { $0.finish() } - } - - func subscribe() -> AsyncStream { - let id = UUID() - let stream = AsyncStream.makeStream( - of: GridMembershipSyncEvent.self, - // Results are revisioned and every success carries authoritative Grid - // state. A newer result supersedes an older event for a stalled consumer. - bufferingPolicy: .bufferingNewest(64) - ) - stream.continuation.onTermination = { [weak self] _ in - Task { @MainActor in self?.subscribers.removeValue(forKey: id) } - } - subscribers[id] = stream.continuation - return stream.stream - } - - func submit(_ operation: GridMembershipOperation) { - queue.append(QueuedOperation(generation: generation, operation: operation)) - startIfNeeded() - } - - /// Invalidates queued work during logout. An in-flight RPC may still finish - /// at the transport, but its event is discarded and it cannot retain the - /// worker slot needed by a later authenticated generation. - func reset() { - generation &+= 1 - queue.removeAll() - workerTask?.cancel() - workerTask = nil - workerGeneration = nil - } - - private func startIfNeeded() { - guard workerTask == nil, !queue.isEmpty else { return } - let workerGeneration = generation - self.workerGeneration = workerGeneration - workerTask = Task { [weak self] in - await self?.run(workerGeneration: workerGeneration) - } - } - - private func run(workerGeneration: Int) async { - while !Task.isCancelled, generation == workerGeneration, !queue.isEmpty { - let queued = queue.removeFirst() - guard queued.generation == workerGeneration else { continue } - let operation = queued.operation - do { - let event: GridMembershipSyncEvent = switch operation.kind { - case .create: - .created( - operation, - try await api.createRoom( - spaceID: operation.spaceID, - microphoneEnabled: operation.microphoneEnabled - ) - ) - case let .join(roomID): - .joined( - operation, - try await api.joinRoom( - roomID: roomID, - microphoneEnabled: operation.microphoneEnabled - ) - ) - case let .leave(roomID): - .left(operation, try await api.leaveRoom(roomID: roomID)) - } - guard !Task.isCancelled, workerGeneration == generation else { continue } - broadcast(event) - } catch { - guard !Task.isCancelled, workerGeneration == generation else { continue } - log.error( - "GRID_TRACE phase=membership_rpc_failed revision=\(operation.revision)", - error: error - ) - broadcast(.failed(operation, message: String(describing: error))) - } - } - guard self.workerGeneration == workerGeneration else { return } - workerTask = nil - self.workerGeneration = nil - startIfNeeded() - } - - private func broadcast(_ event: GridMembershipSyncEvent) { - subscribers.values.forEach { $0.yield(event) } - } -} +// Grid product state is shared by the iOS and macOS clients. +import InlineGrid diff --git a/apple/InlineMac/Features/Grid/GridNetworkMonitor.swift b/apple/InlineMac/Features/Grid/GridNetworkMonitor.swift index cd9bbbc1b..c7a3d8f7a 100644 --- a/apple/InlineMac/Features/Grid/GridNetworkMonitor.swift +++ b/apple/InlineMac/Features/Grid/GridNetworkMonitor.swift @@ -1,78 +1,2 @@ -import Foundation -import Network - -struct GridNetworkPathSnapshot: Equatable, Sendable { - let available: Bool - let usesWiFi: Bool - let usesWiredEthernet: Bool - let usesCellular: Bool - let constrained: Bool - let expensive: Bool - - init(path: NWPath) { - available = path.status == .satisfied - usesWiFi = path.usesInterfaceType(.wifi) - usesWiredEthernet = path.usesInterfaceType(.wiredEthernet) - usesCellular = path.usesInterfaceType(.cellular) - constrained = path.isConstrained - expensive = path.isExpensive - } - - var interfaceDescription: String { - if usesWiredEthernet { return "ethernet" } - if usesWiFi { return "wifi" } - if usesCellular { return "cellular" } - return "other" - } -} - -/// Owns `NWPathMonitor` and vends independent watch streams. Consumers never -/// compete for values from one shared `AsyncStream` iterator. -@MainActor -final class GridNetworkMonitor { - private let monitor = NWPathMonitor() - private let queue = DispatchQueue(label: "Grid.NetworkMonitor") - private var subscribers: [UUID: AsyncStream.Continuation] = [:] - private var latest: GridNetworkPathSnapshot? - - init() { - monitor.pathUpdateHandler = { [weak self] path in - let snapshot = GridNetworkPathSnapshot(path: path) - Task { @MainActor [weak self] in - self?.broadcast(snapshot) - } - } - monitor.start(queue: queue) - } - - deinit { - monitor.cancel() - subscribers.values.forEach { $0.finish() } - } - - func subscribe() -> AsyncStream { - let id = UUID() - let stream = AsyncStream.makeStream( - of: GridNetworkPathSnapshot.self, - bufferingPolicy: .bufferingNewest(1) - ) - stream.continuation.onTermination = { [weak self] _ in - Task { @MainActor in self?.subscribers.removeValue(forKey: id) } - } - subscribers[id] = stream.continuation - if let latest { - stream.continuation.yield(latest) - } - return stream.stream - } - - private func broadcast(_ snapshot: GridNetworkPathSnapshot) { - // Realtime and LiveKit observe their own socket/path transitions, including - // same-interface Wi-Fi changes. Grid only needs a separate recovery epoch - // when this coarse availability/interface state actually changes; emitting - // identical callbacks can otherwise create a refresh/retry feedback loop. - guard latest != snapshot else { return } - latest = snapshot - subscribers.values.forEach { $0.yield(snapshot) } - } -} +// Grid product state is shared by the iOS and macOS clients. +import InlineGrid diff --git a/apple/InlineMac/Features/Grid/GridPlatformEffects.swift b/apple/InlineMac/Features/Grid/GridPlatformEffects.swift new file mode 100644 index 000000000..62a30ba5d --- /dev/null +++ b/apple/InlineMac/Features/Grid/GridPlatformEffects.swift @@ -0,0 +1,70 @@ +import AppKit +import CoreGraphics +import InlineGrid +import InlineKit + +extension GridPlatformEffects { + @MainActor + static var macOS: GridPlatformEffects { + var effects = GridPlatformEffects() + effects.playSound = { sound in + switch sound { + case .join: GridSoundEffects.shared.play(.join) + case .leave: GridSoundEffects.shared.play(.leave) + case .connected: GridSoundEffects.shared.play(.connected) + } + } + effects.displayIDUnderPointer = { + let mouseLocation = NSEvent.mouseLocation + guard let screen = NSScreen.screens.first(where: { + NSMouseInRect(mouseLocation, $0.frame, false) + }), + let screenNumber = screen.deviceDescription[ + NSDeviceDescriptionKey("NSScreenNumber") + ] as? NSNumber + else { return CGMainDisplayID() } + return screenNumber.uint32Value + } + effects.updateScreenShareOutline = { source in + if let source { + GridScreenShareOutlineCoordinator.shared.show(for: source) + } else { + GridScreenShareOutlineCoordinator.shared.hide() + } + } + effects.closeScreenShares = { GridScreenShareWindowCoordinator.shared.closeAll() } + effects.openScreenShare = { user, share, service in + GridScreenShareWindowCoordinator.shared.open(user: user, share: share, store: service) + } + effects.presentScreenShareChange = { notice, service in + let displayName = InlineKit.User(from: notice.user).displayName + if notice.started { + ToastCenter.shared.showSuccess( + "\(displayName) started sharing their screen", + actionTitle: "View" + ) { [weak service] in + guard let service, + let grid = service.grid(spaceID: notice.spaceID), + let room = grid.rooms.first(where: { $0.id == notice.roomID }), + let avatar = room.avatars.first(where: { $0.user.id == notice.user.id }) + else { return } + service.openScreenShare(for: avatar) + } + } else { + ToastCenter.shared.showInfo("\(displayName) stopped sharing their screen") + } + Task { + await MacNotifications.shared.showGridScreenShareNotification( + displayName: displayName, + started: notice.started, + spaceID: notice.spaceID, + roomID: notice.roomID, + userID: notice.user.id, + participantIdentity: notice.participantIdentity + ) + } + } + effects.showError = { ToastCenter.shared.showError($0) } + return effects + } +} diff --git a/apple/InlineMac/Features/Grid/GridRoomAPI.swift b/apple/InlineMac/Features/Grid/GridRoomAPI.swift index 565fd3958..c7a3d8f7a 100644 --- a/apple/InlineMac/Features/Grid/GridRoomAPI.swift +++ b/apple/InlineMac/Features/Grid/GridRoomAPI.swift @@ -1,140 +1,2 @@ -import InlineKit -import InlineProtocol -import RealtimeV2 - -struct GridRoomMutationResult: Sendable { - let grids: [InlineProtocol.Grid] - let credentials: GridConnectionCredentials? -} - -/// The typed server boundary for Grid rooms. -/// -/// Product state and optimistic UI never inspect RPC oneofs directly. Keeping -/// validation here makes every room operation return one predictable shape. -@MainActor -final class GridRoomAPI { - private let realtime: RealtimeV2 - - init(realtime: RealtimeV2) { - self.realtime = realtime - } - - func settings(spaceID: Int64) async throws -> SpaceSettings { - let result = try await realtime.send(.getSpaceSettings(spaceID: spaceID)) - guard case let .getSpaceSettings(response)? = result, response.hasSettings else { - throw GridRoomAPIError.invalidResponse - } - return response.settings - } - - func setEnabled(_ enabled: Bool, spaceID: Int64) async throws -> SpaceSettings { - let result = try await realtime.send(.toggleSpaceGrid(spaceID: spaceID, enabled: enabled)) - guard case let .toggleSpaceGrid(response)? = result, response.hasSettings else { - throw GridRoomAPIError.invalidResponse - } - return response.settings - } - - func grid(spaceID: Int64) async throws -> InlineProtocol.Grid { - let result = try await realtime.send(.getGrid(spaceID: spaceID)) - guard case let .getGrid(response)? = result, response.hasGrid else { - throw GridRoomAPIError.invalidResponse - } - return response.grid - } - - func home() async throws -> [GridHomeSpace] { - let result = try await realtime.send(.getGridHome()) - guard case let .getGridHome(response)? = result else { - throw GridRoomAPIError.invalidResponse - } - return response.spaces - } - - func createRoom( - spaceID: Int64, - microphoneEnabled: Bool - ) async throws -> GridRoomMutationResult { - let result = try await realtime.send( - .createGridRoom(spaceID: spaceID, microphoneEnabled: microphoneEnabled) - ) - guard case let .createGridRoom(response)? = result else { - throw GridRoomAPIError.invalidResponse - } - return GridRoomMutationResult( - grids: response.grids, - credentials: response.hasConnection ? response.connection : nil - ) - } - - func joinRoom( - roomID: Int64, - microphoneEnabled: Bool - ) async throws -> GridRoomMutationResult { - let result = try await realtime.send( - .joinGridRoom(roomID: roomID, microphoneEnabled: microphoneEnabled) - ) - guard case let .joinGridRoom(response)? = result else { - throw GridRoomAPIError.invalidResponse - } - return GridRoomMutationResult( - grids: response.grids, - credentials: response.hasConnection ? response.connection : nil - ) - } - - func leaveRoom(roomID: Int64) async throws -> [InlineProtocol.Grid] { - let result = try await realtime.send(.leaveGridRoom(roomID: roomID)) - guard case let .leaveGridRoom(response)? = result else { - throw GridRoomAPIError.invalidResponse - } - return response.grids - } - - func setLocked(_ locked: Bool, roomID: Int64) async throws -> InlineProtocol.Grid { - let result = try await realtime.send(.setGridRoomLocked(roomID: roomID, locked: locked)) - guard case let .setGridRoomLocked(response)? = result, response.hasGrid else { - throw GridRoomAPIError.invalidResponse - } - return response.grid - } - - func setTitle(_ title: String, roomID: Int64) async throws -> InlineProtocol.Grid { - let result = try await realtime.send(.setGridRoomTitle(roomID: roomID, title: title)) - guard case let .setGridRoomTitle(response)? = result, response.hasGrid else { - throw GridRoomAPIError.invalidResponse - } - return response.grid - } - - func deleteRoom(roomID: Int64) async throws -> InlineProtocol.Grid { - let result = try await realtime.send(.deleteGridRoom(roomID: roomID)) - guard case let .deleteGridRoom(response)? = result, response.hasGrid else { - throw GridRoomAPIError.invalidResponse - } - return response.grid - } - - func prepareConnection(roomID: Int64, generation: Int32) async throws -> GridConnectionCredentials? { - let result = try await realtime.send( - .prepareGridConnection(roomID: roomID, generation: generation) - ) - guard case let .prepareGridConnection(response)? = result else { - throw GridRoomAPIError.invalidResponse - } - return response.hasConnection ? response.connection : nil - } - - func setMicrophoneEnabled(_ enabled: Bool, roomID: Int64) async throws { - let result = try await realtime.send( - .setGridAvatarMicrophoneEnabled(roomID: roomID, enabled: enabled) - ) - guard case .setGridAvatarMicrophoneEnabled? = result else { - throw GridRoomAPIError.invalidResponse - } - } -} - -enum GridRoomAPIError: Error { - case invalidResponse -} +// Grid product state is shared by the iOS and macOS clients. +import InlineGrid diff --git a/apple/InlineMac/Features/Grid/GridRoomLifecycle.swift b/apple/InlineMac/Features/Grid/GridRoomLifecycle.swift index c7f326b1b..c7a3d8f7a 100644 --- a/apple/InlineMac/Features/Grid/GridRoomLifecycle.swift +++ b/apple/InlineMac/Features/Grid/GridRoomLifecycle.swift @@ -1,83 +1,2 @@ -import Foundation -import InlineProtocol -import RealtimeV2 - -enum GridRoomLifecycleEvent: Sendable { - case grid(GridEvent) - case realtimeConnection(RealtimeConnectionState) - case network(GridNetworkPathSnapshot) - case heartbeat -} - -/// Collects external lifecycle sources without owning Grid product state. -/// Every subscriber receives its own watch/event stream. -@MainActor -final class GridRoomLifecycle { - private let realtime: RealtimeV2 - private let network: GridNetworkMonitor - private var sourceTasks: [Task] = [] - private var subscribers: [UUID: AsyncStream.Continuation] = [:] - private var started = false - - init(realtime: RealtimeV2, network: GridNetworkMonitor) { - self.realtime = realtime - self.network = network - } - - deinit { - sourceTasks.forEach { $0.cancel() } - subscribers.values.forEach { $0.finish() } - } - - func subscribe() -> AsyncStream { - startIfNeeded() - let id = UUID() - let stream = AsyncStream.makeStream( - of: GridRoomLifecycleEvent.self, - // Lifecycle edges only trigger authoritative refetch/reconciliation; - // retain a bounded recent window for a temporarily suspended observer. - bufferingPolicy: .bufferingNewest(64) - ) - stream.continuation.onTermination = { [weak self] _ in - Task { @MainActor in self?.subscribers.removeValue(forKey: id) } - } - subscribers[id] = stream.continuation - return stream.stream - } - - private func startIfNeeded() { - guard !started else { return } - started = true - sourceTasks = [ - Task { [weak self, realtime] in - for await event in await realtime.gridEvents() { - guard !Task.isCancelled else { return } - self?.broadcast(.grid(event)) - } - }, - Task { [weak self, realtime] in - for await state in await realtime.connectionStates() { - guard !Task.isCancelled else { return } - self?.broadcast(.realtimeConnection(state)) - } - }, - Task { [weak self, network] in - for await snapshot in network.subscribe() { - guard !Task.isCancelled else { return } - self?.broadcast(.network(snapshot)) - } - }, - Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(30)) - guard !Task.isCancelled else { return } - self?.broadcast(.heartbeat) - } - }, - ] - } - - private func broadcast(_ event: GridRoomLifecycleEvent) { - subscribers.values.forEach { $0.yield(event) } - } -} +// Grid product state is shared by the iOS and macOS clients. +import InlineGrid diff --git a/apple/InlineMac/Features/Grid/GridRoomView.swift b/apple/InlineMac/Features/Grid/GridRoomView.swift index 646830a5c..8261c48ab 100644 --- a/apple/InlineMac/Features/Grid/GridRoomView.swift +++ b/apple/InlineMac/Features/Grid/GridRoomView.swift @@ -1,3 +1,4 @@ +import InlineGrid import InlineKit import InlineProtocol import InlineRTC @@ -68,6 +69,8 @@ struct GridContent: View { let grid: InlineProtocol.Grid? let isLoading: Bool let didFailLoading: Bool + let hasLocalAdmission: Bool + let isJoiningDisabled: Bool let audioLevel: (Int64) -> Float let isScreenSharing: (GridAvatar) -> Bool let connectionState: GridMediaConnectionStatus @@ -89,7 +92,8 @@ struct GridContent: View { ForEach(grid.rooms, id: \.id) { room in GridRoomCard( room: room, - isCurrent: grid.hasCurrentRoomID && grid.currentRoomID == room.id, + isCurrent: hasLocalAdmission && grid.hasCurrentRoomID && grid.currentRoomID == room.id, + isJoiningDisabled: isJoiningDisabled, audioLevel: audioLevel, isScreenSharing: isScreenSharing, connectionState: connectionState, @@ -104,6 +108,7 @@ struct GridContent: View { ) } GridCreateRoomCard(action: onCreate) + .disabled(isJoiningDisabled) } .animation(.smoothSnappy, value: grid.rooms.map(\.id)) } else if isLoading { @@ -164,6 +169,7 @@ private struct GridCreateRoomCard: View { private struct GridRoomCard: View { let room: GridRoom let isCurrent: Bool + let isJoiningDisabled: Bool let audioLevel: (Int64) -> Float let isScreenSharing: (GridAvatar) -> Bool let connectionState: GridMediaConnectionStatus @@ -209,7 +215,7 @@ private struct GridRoomCard: View { ) } .buttonStyle(.plain) - .disabled(room.locked) + .disabled(room.locked || isJoiningDisabled) } GridRoomOptionsMenu( @@ -287,6 +293,7 @@ private struct GridRoomSurface: View { .overlay { GridRoomAvatars( avatars: room.avatars, + allowsLocalControls: isCurrent, audioLevel: audioLevel, isScreenSharing: isScreenSharing, showsLocalConnectingIndicator: isCurrent && connectionState == .connecting, @@ -362,6 +369,7 @@ private struct GridRoomAvatars: View { static let maximumVisibleAvatarCount = 5 let avatars: [GridAvatar] + let allowsLocalControls: Bool let audioLevel: (Int64) -> Float let isScreenSharing: (GridAvatar) -> Bool let showsLocalConnectingIndicator: Bool @@ -375,6 +383,7 @@ private struct GridRoomAvatars: View { ForEach(visibleAvatars, id: \.user.id) { avatar in GridSpeakingAvatar( avatar: avatar, + allowsLocalControls: allowsLocalControls, audioLevel: audioLevel(avatar.user.id), isScreenSharing: isScreenSharing(avatar), showsConnectingIndicator: showsLocalConnectingIndicator && avatar.ownedByCurrentSession, @@ -445,6 +454,7 @@ private struct GridRoomAvatars: View { private struct GridSpeakingAvatar: View { let avatar: GridAvatar + let allowsLocalControls: Bool let audioLevel: Float let isScreenSharing: Bool let showsConnectingIndicator: Bool @@ -457,7 +467,7 @@ private struct GridSpeakingAvatar: View { var body: some View { ZStack(alignment: .topLeading) { - if avatar.ownedByCurrentSession { + if avatar.ownedByCurrentSession && allowsLocalControls { Button(action: onToggleMicrophone) { GridAvatarImage( user: avatar.user, @@ -473,7 +483,7 @@ private struct GridSpeakingAvatar: View { GridAvatarImage( user: avatar.user, audioLevel: audioLevel, - microphoneEnabled: avatar.microphoneEnabled, + microphoneEnabled: avatar.ownedByCurrentSession && !allowsLocalControls ? false : avatar.microphoneEnabled, showsConnectingIndicator: false, size: 45 ) @@ -482,7 +492,7 @@ private struct GridSpeakingAvatar: View { if isScreenSharing { GridAvatarScreenShareControl( - isLocal: avatar.ownedByCurrentSession, + isLocal: avatar.ownedByCurrentSession && allowsLocalControls, displayName: InlineKit.User(from: avatar.user).displayName, onOpen: onOpenScreenShare, onStop: onStopScreenShare @@ -490,7 +500,7 @@ private struct GridSpeakingAvatar: View { .offset(x: 23, y: 23) } - if avatar.ownedByCurrentSession, isHovered { + if avatar.ownedByCurrentSession && allowsLocalControls, isHovered { GridAvatarLeaveButton(action: onLeave) .offset(x: -3, y: -3) .transition(.scale(scale: 0.72).combined(with: .opacity)) @@ -501,7 +511,7 @@ private struct GridSpeakingAvatar: View { } private var helpText: String { - if avatar.ownedByCurrentSession { + if avatar.ownedByCurrentSession && allowsLocalControls { return isScreenSharing ? "You’re sharing · mute or unmute" : "Mute or unmute" } if isScreenSharing { return "Open screen" } diff --git a/apple/InlineMac/Features/Grid/GridRuntime.swift b/apple/InlineMac/Features/Grid/GridRuntime.swift index df226b41f..c7a3d8f7a 100644 --- a/apple/InlineMac/Features/Grid/GridRuntime.swift +++ b/apple/InlineMac/Features/Grid/GridRuntime.swift @@ -1,36 +1,2 @@ -import Foundation -import InlineKit -import InlineRTC -import RealtimeV2 - -/// One authenticated-process owner for Grid's product, RTC, and audio domains. -/// `AppDependencies` is copied per window, but this reference is shared. -@MainActor -final class GridRuntime { - static let shared = GridRuntime() - - private let engine: InlineRTCSession - let rooms: GridRoomService - - private init( - realtime: RealtimeV2 = Api.realtime, - userDefaults: UserDefaults = .standard - ) { - let engine = InlineRTCSession() - self.engine = engine - rooms = GridRoomService( - realtime: realtime, - userDefaults: userDefaults, - engine: engine - ) - } - - func prepareForLogout() async -> GridMediaShutdownReceipt { - await rooms.prepareForLogout() - return await engine.shutdown() - } - - func applicationDidWake() async { - await rooms.applicationDidWake() - } -} +// Grid product state is shared by the iOS and macOS clients. +import InlineGrid diff --git a/apple/InlineMac/Features/Grid/GridScreenShareWindow.swift b/apple/InlineMac/Features/Grid/GridScreenShareWindow.swift index dd5fc5542..4fb63415f 100644 --- a/apple/InlineMac/Features/Grid/GridScreenShareWindow.swift +++ b/apple/InlineMac/Features/Grid/GridScreenShareWindow.swift @@ -1,3 +1,4 @@ +import InlineGrid import AppKit import Foundation import InlineKit diff --git a/apple/InlineMac/Features/Grid/GridStore.swift b/apple/InlineMac/Features/Grid/GridStore.swift index 245ad65eb..c7a3d8f7a 100644 --- a/apple/InlineMac/Features/Grid/GridStore.swift +++ b/apple/InlineMac/Features/Grid/GridStore.swift @@ -1,1738 +1,2 @@ -import AppKit -import Auth -import CoreGraphics -import Foundation -import InlineKit -import InlineRTC -import InlineProtocol -import Logger -import Observation -import RealtimeV2 - -@MainActor -@Observable -final class GridRoomService { - private(set) var grids: [Int64: InlineProtocol.Grid] = [:] - private(set) var enabledSpaceIDs = Set() - private(set) var homeSpaces: [GridHomeSpace] = [] - private(set) var loadingSpaceIDs = Set() - private(set) var failedLoadSpaceIDs = Set() - private(set) var lastError: String? - private(set) var networkRefreshRevision = 0 - private(set) var networkAvailable = true - var connectionRecoveryAttempt: Int { media.recoveryAttempt } - let media: GridMediaPresentation - - var inputSelection: AudioInputSelection { - get { media.inputSelection } - set { mediaCoordinator.setInput(newValue) } - } - - var outputSelection: AudioOutputSelection { - get { media.outputSelection } - set { mediaCoordinator.setOutput(newValue) } - } - - var autoUnmuteOnJoin: Bool { - get { media.autoUnmuteOnJoin } - set { mediaCoordinator.setAutoUnmuteOnJoin(newValue) } - } - - var autoMuteWhenAlone: Bool { - get { media.autoMuteWhenAlone } - set { - mediaCoordinator.setAutoMuteWhenAlone(newValue) - reconcileAloneAutoMute() - } - } - - @ObservationIgnored private let api: GridRoomAPI - @ObservationIgnored private let avatarStateSync: GridAvatarStateSync - @ObservationIgnored private let membershipSync: GridMembershipSync - @ObservationIgnored private let mediaCoordinator: GridMediaCoordinator - @ObservationIgnored private let homePreferences: GridHomePreferences - @ObservationIgnored private var lifecycleTask: Task? - @ObservationIgnored private var mediaEventsTask: Task? - @ObservationIgnored private var membershipEventsTask: Task? - @ObservationIgnored private var lastNetworkPathSnapshot: GridNetworkPathSnapshot? - @ObservationIgnored private let lifecycle: GridRoomLifecycle - @ObservationIgnored private var pendingReloadSpaceIDs = Set() - @ObservationIgnored private var pendingConnectionSpaceIDs = Set() - @ObservationIgnored private var roomMutationRevisions: [OptimisticRoomMutationKey: Int] = [:] - @ObservationIgnored private var pendingRoomMutations: [OptimisticRoomMutationKey: OptimisticRoomMutation] = [:] - @ObservationIgnored private var membershipMutationRevision = 0 - @ObservationIgnored private var pendingMembershipMutations: [Int: OptimisticMembershipMutation] = [:] - @ObservationIgnored private var membershipRollbackBaseline = - GridOptimisticState.MembershipRollbackBaseline() - @ObservationIgnored private var lastAcceptedSnapshotRevisions: [Int64: Int64] = [:] - @ObservationIgnored private var spaceAccessRevisions: [Int64: Int] = [:] - @ObservationIgnored private var homeLoadRevision = 0 - @ObservationIgnored private var lastRealtimeConnectionState: RealtimeConnectionState? - @ObservationIgnored private var pendingCredentialTarget: GridMediaTarget? - @ObservationIgnored private var credentialRetryTask: Task? - @ObservationIgnored private var credentialRetryTarget: GridMediaTarget? - @ObservationIgnored private var credentialRetryAttempt = 0 - @ObservationIgnored private var aloneAutoMuteTask: Task? - @ObservationIgnored private var aloneAutoMuteTarget: GridAloneAutoMuteTarget? - @ObservationIgnored private var screenShareAloneTask: Task? - @ObservationIgnored private var screenShareAloneTarget: GridScreenShareAloneTarget? - @ObservationIgnored private var screenShareAloneGrace = GridScreenShareAloneGraceState() - @ObservationIgnored private var screenShareNotificationTarget: GridMediaTarget? - @ObservationIgnored private var observedRemoteScreenShares: [String: GridScreenShareNotice] = [:] - @ObservationIgnored private var hasPrimedScreenSharePresentationEdges = false - @ObservationIgnored private var pendingScreenShareOpenTasks: - [String: GridPendingScreenShareOpen] = [:] - @ObservationIgnored private var mediaInteractionStartedAt: Date? - @ObservationIgnored private let log = Log.scoped("GridRoomService") - private static let aloneMediaGraceSeconds = 5 - - init( - realtime: RealtimeV2 = Api.realtime, - userDefaults: UserDefaults = .standard, - engine: InlineRTCSession - ) { - let api = GridRoomAPI(realtime: realtime) - self.api = api - avatarStateSync = GridAvatarStateSync(api: api) - let membershipSync = GridMembershipSync(api: api) - self.membershipSync = membershipSync - let networkMonitor = GridNetworkMonitor() - lifecycle = GridRoomLifecycle(realtime: realtime, network: networkMonitor) - let inputPreferences = AudioInputPreferenceStore( - defaults: userDefaults, - deviceIDKey: "grid.preferredInputDeviceID", - deviceNameKey: "grid.preferredInputDeviceName" - ) - let outputPreferences = AudioOutputPreferenceStore( - defaults: userDefaults, - deviceIDKey: "grid.preferredOutputDeviceID", - deviceNameKey: "grid.preferredOutputDeviceName" - ) - homePreferences = GridHomePreferences(defaults: userDefaults) - let mediaCoordinator = GridMediaCoordinator( - engine: engine, - defaults: userDefaults, - inputPreferences: inputPreferences, - outputPreferences: outputPreferences - ) - self.mediaCoordinator = mediaCoordinator - media = mediaCoordinator.presentation - lifecycleTask = Task { [weak self, lifecycle] in - for await event in lifecycle.subscribe() { - guard !Task.isCancelled else { return } - await self?.handle(event) - } - } - mediaEventsTask = Task { [weak self, mediaCoordinator] in - for await event in mediaCoordinator.subscribe() { - guard !Task.isCancelled else { return } - await self?.handle(event) - } - } - membershipEventsTask = Task { [weak self, membershipSync] in - for await event in membershipSync.subscribe() { - guard !Task.isCancelled else { return } - await self?.handle(event) - } - } - } - - deinit { - lifecycleTask?.cancel() - mediaEventsTask?.cancel() - membershipEventsTask?.cancel() - credentialRetryTask?.cancel() - aloneAutoMuteTask?.cancel() - screenShareAloneTask?.cancel() - pendingScreenShareOpenTasks.values.forEach { $0.task.cancel() } - } - - func isEnabled(spaceID: Int64) -> Bool { - enabledSpaceIDs.contains(spaceID) - } - - func prepareForLogout() async { - let invalidatedSpaceIDs = Set(spaceAccessRevisions.keys) - .union(grids.keys) - .union(enabledSpaceIDs) - .union(loadingSpaceIDs) - .union(pendingReloadSpaceIDs) - .union(pendingConnectionSpaceIDs) - pendingCredentialTarget = nil - resetCredentialRetry() - cancelAloneAutoMute() - resetScreenShareAloneGrace() - resetScreenSharePresentationEdges() - grids.removeAll() - homeSpaces.removeAll() - enabledSpaceIDs.removeAll() - loadingSpaceIDs.removeAll() - failedLoadSpaceIDs.removeAll() - pendingReloadSpaceIDs.removeAll() - pendingConnectionSpaceIDs.removeAll() - for spaceID in invalidatedSpaceIDs { - spaceAccessRevisions[spaceID, default: 0] &+= 1 - } - homeLoadRevision &+= 1 - roomMutationRevisions.removeAll() - pendingRoomMutations.removeAll() - membershipMutationRevision &+= 1 - pendingMembershipMutations.removeAll() - membershipRollbackBaseline.clear() - lastAcceptedSnapshotRevisions.removeAll() - membershipSync.reset() - avatarStateSync.setOwnedRoom(nil) - await mediaCoordinator.shutdown() - } - - func grid(spaceID: Int64) -> InlineProtocol.Grid? { - grids[spaceID] - } - - func recentAvatars(spaceID: Int64, limit: Int = 4) -> [GridAvatar] { - guard let grid = grids[spaceID] else { return [] } - return Array( - grid.rooms - .flatMap(\.avatars) - .sorted { $0.joinedAt > $1.joinedAt } - .prefix(limit) - ) - } - - func loadHome() async { - guard realtimeReady else { - log.debug("GRID_TRACE phase=home_load_deferred reason=realtime_not_ready") - return - } - homeLoadRevision &+= 1 - let revision = homeLoadRevision - do { - let nextHomeSpaces = try await api.home() - guard revision == homeLoadRevision else { return } - let previouslyLoadedSpaceIDs = Set(grids.keys) - homeSpaces = nextHomeSpaces - let eligibleSpaceIDs = Set(homeSpaces.map(\.spaceID)) - enabledSpaceIDs = eligibleSpaceIDs - for spaceID in previouslyLoadedSpaceIDs.subtracting(eligibleSpaceIDs) { - clearLocalGrid(spaceID: spaceID, reason: "home_reconciliation") - } - lastError = nil - } catch { - guard revision == homeLoadRevision else { return } - guard realtimeReady, networkAvailable else { - log.debug("GRID_TRACE phase=home_load_deferred reason=connection_lost") - return - } - lastError = String(describing: error) - log.warning("GRID_TRACE phase=home_load_failed") - PerformanceTrace.breadcrumb( - "Grid Home discovery failed", - category: "Grid.Home", - level: .warning - ) - } - } - - var orderedHomeSpaces: [GridHomeSpace] { - homePreferences.ordered(homeSpaces) - } - - func recordGridOpened(spaceID: Int64) { - homePreferences.recordOpened(spaceID: spaceID) - } - - func audioLevel(userID: Int64) -> Float { - let legacyIdentity = Self.liveKitIdentityPrefix(userID: userID) - return media.participantAudioLevels - .filter { $0.key == legacyIdentity || $0.key.hasPrefix("\(legacyIdentity)-") } - .map(\.value) - .max() ?? 0 - } - - func isScreenSharing(avatar: GridAvatar) -> Bool { - if avatar.ownedByCurrentSession { - return media.isScreenShareRequested - } - let identities = participantIdentities(for: avatar) - let knownIntents = identities.compactMap { media.participantScreenShareIntents[$0] } - if !knownIntents.isEmpty { - return knownIntents.contains(true) - } - let shares = screenShares(avatar: avatar) - return !shares.isEmpty - } - - func isScreenShareIntended(participantIdentity: String) -> Bool { - if let intent = media.participantScreenShareIntents[participantIdentity] { - return intent - } - return media.screenShares.contains { $0.participantIdentity == participantIdentity } - } - - func isScreenSharing(userID: Int64) -> Bool { - let shares = screenShares(userID: userID) - if isOwnedUser(userID) { - return shares.contains(where: \.isLocal) - } - return !shares.isEmpty - } - - func screenShare(userID: Int64) -> InlineRTCScreenShare? { - let shares = screenShares(userID: userID) - if isOwnedUser(userID) { - return shares.first(where: \.isLocal) - } - return shares.first - } - - func screenShare(avatar: GridAvatar) -> InlineRTCScreenShare? { - let shares = screenShares(avatar: avatar) - return avatar.ownedByCurrentSession - ? shares.first(where: \.isLocal) - : shares.first - } - - func screenShares(avatar: GridAvatar) -> [InlineRTCScreenShare] { - let identities = participantIdentities(for: avatar) - return media.screenShares.filter { identities.contains($0.participantIdentity) } - } - - func screenShares(userID: Int64) -> [InlineRTCScreenShare] { - media.screenShares.filter { - Self.identity($0.participantIdentity, matchesUserID: userID) - } - } - - func screenShares(participantIdentity: String) -> [InlineRTCScreenShare] { - media.screenShares.filter { $0.participantIdentity == participantIdentity } - } - - func screenShare(publicationID: String) -> InlineRTCScreenShare? { - media.screenShares.first { $0.publicationID == publicationID } - } - - func load(spaceID: Int64) async { - guard realtimeReady else { - failedLoadSpaceIDs.remove(spaceID) - pendingConnectionSpaceIDs.insert(spaceID) - log.debug( - "GRID_TRACE phase=load_deferred space=\(spaceID) reason=realtime_not_ready" - ) - return - } - pendingConnectionSpaceIDs.remove(spaceID) - let accessRevision = spaceAccessRevisions[spaceID, default: 0] - guard loadingSpaceIDs.insert(spaceID).inserted else { - pendingReloadSpaceIDs.insert(spaceID) - return - } - failedLoadSpaceIDs.remove(spaceID) - defer { - loadingSpaceIDs.remove(spaceID) - if pendingReloadSpaceIDs.remove(spaceID) != nil { - Task { [weak self] in await self?.load(spaceID: spaceID) } - } - } - - do { - let settings = try await api.settings(spaceID: spaceID) - guard accessRevision == spaceAccessRevisions[spaceID, default: 0] else { return } - applyEnabled(settings.gridEnabled, spaceID: spaceID) - lastError = nil - guard settings.gridEnabled else { - reconcileMediaDemand() - return - } - try await reloadGrid(spaceID: spaceID) - } catch { - guard accessRevision == spaceAccessRevisions[spaceID, default: 0] else { return } - guard realtimeReady, networkAvailable else { - pendingConnectionSpaceIDs.insert(spaceID) - log.debug( - "GRID_TRACE phase=load_deferred space=\(spaceID) reason=connection_lost" - ) - return - } - failedLoadSpaceIDs.insert(spaceID) - lastError = String(describing: error) - log.error("GRID_TRACE phase=load_failed space=\(spaceID)", error: error) - PerformanceTrace.breadcrumb( - "Grid snapshot failed", - category: "Grid.Snapshot", - level: .error, - data: ["space_id": spaceID] - ) - } - } - - func setEnabled(_ enabled: Bool, spaceID: Int64) async throws { - let accessRevision = spaceAccessRevisions[spaceID, default: 0] - let settings = try await api.setEnabled(enabled, spaceID: spaceID) - guard accessRevision == spaceAccessRevisions[spaceID, default: 0] else { return } - applyEnabled(settings.gridEnabled, spaceID: spaceID) - if enabled { - try await reloadGrid(spaceID: spaceID) - } else { - reconcileMediaDemand() - } - await loadHome() - } - - func createAndJoin(spaceID: Int64) { - let startedAt = Date() - let automaticMicrophoneChange = mediaCoordinator.applyAutoUnmuteOnJoin() - mediaCoordinator.requestMicrophonePermission() - GridSoundEffects.shared.play(.join) - log.debug("GRID_TRACE phase=create_client_start space=\(spaceID)") - membershipMutationRevision &+= 1 - membershipSync.submit(GridMembershipOperation( - kind: .create, - spaceID: spaceID, - microphoneEnabled: mediaCoordinator.isMicrophoneEnabled, - automaticMicrophoneChange: automaticMicrophoneChange, - accessRevision: spaceAccessRevisions[spaceID, default: 0], - revision: membershipMutationRevision, - startedAt: startedAt - )) - } - - func join(roomID: Int64) { - let startedAt = Date() - if let room = grids.values.lazy.flatMap(\.rooms).first(where: { $0.id == roomID }), - room.avatars.isEmpty == false { - mediaInteractionStartedAt = startedAt - } else { - mediaInteractionStartedAt = nil - } - GridSoundEffects.shared.play(.join) - log.debug("GRID_TRACE phase=join_client_start room=\(roomID)") - guard let mutation = optimisticallyJoin(roomID: roomID) else { return } - let automaticMicrophoneChange = mediaCoordinator.applyAutoUnmuteOnJoin() - if automaticMicrophoneChange != nil { - applyAvatarMicrophoneState( - spaceID: mutation.spaceID, - roomID: mutation.roomID, - userID: nil, - enabled: true - ) - } - mediaCoordinator.requestMicrophonePermission() - reconcileMediaDemand() - - pendingMembershipMutations[mutation.revision] = mutation - membershipSync.submit(GridMembershipOperation( - kind: .join(roomID: roomID), - spaceID: mutation.spaceID, - microphoneEnabled: mediaCoordinator.isMicrophoneEnabled, - automaticMicrophoneChange: automaticMicrophoneChange, - accessRevision: spaceAccessRevisions[mutation.spaceID, default: 0], - revision: mutation.revision, - startedAt: startedAt - )) - } - - func leaveCurrentRoom(spaceID: Int64) { - guard let mutation = optimisticallyLeave(spaceID: spaceID) else { return } - GridSoundEffects.shared.play(.leave) - mediaCoordinator.clearCredentials() - mediaInteractionStartedAt = nil - pendingCredentialTarget = nil - reconcileMediaDemand() - - pendingMembershipMutations[mutation.revision] = mutation - membershipSync.submit(GridMembershipOperation( - kind: .leave(roomID: mutation.roomID), - spaceID: spaceID, - microphoneEnabled: mediaCoordinator.isMicrophoneEnabled, - automaticMicrophoneChange: nil, - accessRevision: spaceAccessRevisions[spaceID, default: 0], - revision: mutation.revision, - startedAt: Date() - )) - } - - func toggleMicrophone(spaceID: Int64) { - guard let grid = grids[spaceID], grid.hasCurrentRoomID, - let room = grid.rooms.first(where: { $0.id == grid.currentRoomID }), - room.avatars.contains(where: \.ownedByCurrentSession) - else { return } - let roomID = grid.currentRoomID - let enabled = mediaCoordinator.toggleMicrophone() - applyAvatarMicrophoneState( - spaceID: spaceID, - roomID: roomID, - userID: nil, - enabled: enabled - ) - avatarStateSync.submitMicrophoneState(roomID: roomID, enabled: enabled) - reconcileMediaDemand() - } - - func toggleCurrentMicrophone() { - guard let grid = grids.values.first(where: { grid in - guard grid.hasCurrentRoomID, - let room = grid.rooms.first(where: { $0.id == grid.currentRoomID }) - else { return false } - return room.avatars.contains(where: \.ownedByCurrentSession) - }) else { return } - - toggleMicrophone(spaceID: grid.spaceID) - } - - func toggleScreenShare() { - guard currentMediaTarget() != nil else { return } - if media.isScreenShareRequested || media.isScreenSharing { - mediaCoordinator.stopScreenSharing() - resetScreenShareAloneGrace() - return - } - let displayID = Self.displayIDUnderPointer() - Task { [weak self] in - guard let self else { return } - await mediaCoordinator.startScreenSharing(displayID: displayID) - reconcileScreenShareAloneGrace() - } - } - - func startScreenSharing(_ source: InlineRTCScreenCaptureSource) { - guard currentMediaTarget() != nil else { return } - mediaCoordinator.startScreenSharing(source) - reconcileScreenShareAloneGrace() - } - - func setScreenShareQualityProfile(_ profile: InlineRTCScreenShareQualityProfile) { - mediaCoordinator.setScreenShareQualityProfile(profile) - } - - func stopScreenSharing() { - mediaCoordinator.stopScreenSharing() - resetScreenShareAloneGrace() - } - - func refreshScreenCaptureSources() { - Task { [weak self] in - await self?.mediaCoordinator.refreshScreenCaptureSources() - } - } - - func openScreenShare(for avatar: GridAvatar) { - guard let participantIdentity = screenShareParticipantIdentity(for: avatar) else { return } - openScreenShare( - user: avatar.user, - participantIdentity: participantIdentity - ) - } - - func openScreenShareFromNotification( - spaceID: Int64, - roomID: Int64, - userID: Int64, - participantIdentity: String - ) { - Task { [weak self] in - guard let self else { return } - await load(spaceID: spaceID) - guard let grid = grids[spaceID], - let room = grid.rooms.first(where: { $0.id == roomID }), - let avatar = room.avatars.first(where: { $0.user.id == userID }) - else { return } - openScreenShare( - user: avatar.user, - participantIdentity: participantIdentity - ) - } - } - - func toggleRoomLock(roomID: Int64, locked: Bool) async { - guard let mutation = optimisticallyUpdateRoom( - roomID: roomID, - field: .locked, - update: { $0.locked = locked } - ) else { return } - do { - let grid = try await api.setLocked(locked, roomID: roomID) - guard isCurrent(mutation) else { return } - pendingRoomMutations.removeValue(forKey: mutation.key) - applySnapshot([grid]) - } catch { - guard isCurrent(mutation) else { return } - rollbackRoomMutation(mutation) - lastError = String(describing: error) - if networkAvailable { try? await reloadGrid(spaceID: mutation.change.spaceID) } - } - } - - func setRoomTitle(roomID: Int64, title: String) async { - let normalizedTitle = title.split(whereSeparator: \Character.isWhitespace).joined(separator: " ") - guard let mutation = optimisticallyUpdateRoom( - roomID: roomID, - field: .title, - update: { room in - if normalizedTitle.isEmpty { - room.clearTitle() - } else { - room.title = normalizedTitle - } - } - ) else { return } - do { - let grid = try await api.setTitle(title, roomID: roomID) - guard isCurrent(mutation) else { return } - pendingRoomMutations.removeValue(forKey: mutation.key) - applySnapshot([grid]) - } catch { - guard isCurrent(mutation) else { return } - rollbackRoomMutation(mutation) - lastError = String(describing: error) - if networkAvailable { try? await reloadGrid(spaceID: mutation.change.spaceID) } - } - } - - func deleteRoom(roomID: Int64) async { - guard let spaceID = grids.first(where: { _, grid in - grid.rooms.contains(where: { $0.id == roomID }) - })?.key else { return } - let accessRevision = spaceAccessRevisions[spaceID, default: 0] - do { - let grid = try await api.deleteRoom(roomID: roomID) - guard accessRevision == spaceAccessRevisions[spaceID, default: 0] else { return } - applySnapshot([grid]) - } catch { - lastError = String(describing: error) - } - } - - private func reloadGrid(spaceID: Int64) async throws { - let accessRevision = spaceAccessRevisions[spaceID, default: 0] - let grid = try await api.grid(spaceID: spaceID) - guard accessRevision == spaceAccessRevisions[spaceID, default: 0] else { return } - applySnapshot([grid]) - syncMicrophoneStateIfNeeded(grid: grid) - await prepareConnectionIfNeeded(grid: grid) - } - - private func apply(grids nextGrids: [InlineProtocol.Grid], credentials: GridConnectionCredentials? = nil) async { - applySnapshot(nextGrids) - nextGrids.forEach(syncMicrophoneStateIfNeeded) - if let credentials { - await accept(credentials) - } - } - - private func applySnapshot(_ nextGrids: [InlineProtocol.Grid]) { - for incomingGrid in nextGrids { - let previousRevision = lastAcceptedSnapshotRevisions[incomingGrid.spaceID] - if let previousRevision, incomingGrid.revision < previousRevision { - log.debug( - "GRID_TRACE phase=snapshot_rejected_stale space=\(incomingGrid.spaceID) incoming_revision=\(incomingGrid.revision) current_revision=\(previousRevision)" - ) - continue - } - lastAcceptedSnapshotRevisions[incomingGrid.spaceID] = incomingGrid.revision - var grid = incomingGrid - for mutation in pendingRoomMutations.values where mutation.change.spaceID == grid.spaceID { - grid = GridOptimisticState.applyingRoomIntent(mutation.change, to: grid) - } - grids[grid.spaceID] = grid - failedLoadSpaceIDs.remove(grid.spaceID) - pendingConnectionSpaceIDs.remove(grid.spaceID) - applyEnabled(grid.enabled, spaceID: grid.spaceID) - } - applyPendingMembershipIntent() - - reconcileAvatarStateOwnership() - reconcileMediaDemand() - } - - private func reconcileMediaDemand() { - let target = currentMediaTarget() - if credentialRetryTarget != target { resetCredentialRetry() } - if screenShareNotificationTarget != target { - resetScreenSharePresentationEdges(target: target) - } - mediaCoordinator.setTarget(target) - reconcileAloneAutoMute() - reconcileScreenShareAloneGrace() - } - - private func resetScreenSharePresentationEdges(target: GridMediaTarget? = nil) { - pendingScreenShareOpenTasks.values.forEach { $0.task.cancel() } - pendingScreenShareOpenTasks.removeAll() - screenShareNotificationTarget = target - observedRemoteScreenShares.removeAll() - hasPrimedScreenSharePresentationEdges = false - } - - private func reconcileScreenSharePresentationEdges() { - guard let target = currentMediaTarget(), - screenShareNotificationTarget == target, - media.hasParticipantMediaSnapshot, - let grid = grids[target.spaceID], - let room = grid.rooms.first(where: { $0.id == target.roomID }) - else { return } - - var next: [String: GridScreenShareNotice] = [:] - for avatar in room.avatars where !avatar.ownedByCurrentSession && isScreenSharing(avatar: avatar) { - guard let participantIdentity = screenShareParticipantIdentity(for: avatar) else { continue } - next[participantIdentity] = GridScreenShareNotice( - target: target, - user: avatar.user, - participantIdentity: participantIdentity - ) - } - - guard hasPrimedScreenSharePresentationEdges else { - observedRemoteScreenShares = next - hasPrimedScreenSharePresentationEdges = true - return - } - - let previous = observedRemoteScreenShares - observedRemoteScreenShares = next - for (identity, notice) in next where previous[identity] == nil { - presentScreenShareChange(notice, started: true) - } - for (identity, notice) in previous where next[identity] == nil { - pendingScreenShareOpenTasks.removeValue(forKey: identity)?.task.cancel() - presentScreenShareChange(notice, started: false) - } - } - - private func presentScreenShareChange( - _ notice: GridScreenShareNotice, - started: Bool - ) { - let displayName = InlineKit.User(from: notice.user).displayName - if started { - ToastCenter.shared.showSuccess( - "\(displayName) started sharing their screen", - actionTitle: "View" - ) { [weak self] in - self?.openScreenShare( - user: notice.user, - participantIdentity: notice.participantIdentity - ) - } - } else { - ToastCenter.shared.showInfo("\(displayName) stopped sharing their screen") - } - - Task { - await MacNotifications.shared.showGridScreenShareNotification( - displayName: displayName, - started: started, - spaceID: notice.target.spaceID, - roomID: notice.target.roomID, - userID: notice.user.id, - participantIdentity: notice.participantIdentity - ) - } - } - - private func reconcileAloneAutoMute() { - guard mediaCoordinator.shouldAutoMuteWhenAlone else { - cancelAloneAutoMute() - return - } - let target = grids.values.lazy.compactMap { grid -> GridAloneAutoMuteTarget? in - guard grid.hasCurrentRoomID, - let room = grid.rooms.first(where: { $0.id == grid.currentRoomID }), - room.avatars.count == 1, - let avatar = room.avatars.first, - avatar.ownedByCurrentSession, - avatar.microphoneEnabled, - self.mediaCoordinator.isMicrophoneEnabled - else { return nil } - return GridAloneAutoMuteTarget( - spaceID: grid.spaceID, - roomID: room.id, - userID: avatar.user.id - ) - }.first - - guard target != aloneAutoMuteTarget else { return } - cancelAloneAutoMute() - guard let target else { return } - - aloneAutoMuteTarget = target - aloneAutoMuteTask = Task { [weak self] in - try? await Task.sleep(for: .seconds(Self.aloneMediaGraceSeconds)) - guard !Task.isCancelled else { return } - self?.autoMuteIfStillAlone(target) - } - } - - private func autoMuteIfStillAlone(_ target: GridAloneAutoMuteTarget) { - guard aloneAutoMuteTarget == target else { return } - aloneAutoMuteTask = nil - aloneAutoMuteTarget = nil - - guard mediaCoordinator.shouldAutoMuteWhenAlone, - let grid = grids[target.spaceID], - grid.hasCurrentRoomID, - grid.currentRoomID == target.roomID, - let room = grid.rooms.first(where: { $0.id == target.roomID }), - room.avatars.count == 1, - let avatar = room.avatars.first, - avatar.ownedByCurrentSession, - avatar.user.id == target.userID, - avatar.microphoneEnabled, - mediaCoordinator.isMicrophoneEnabled - else { return } - - log.debug("GRID_TRACE phase=alone_auto_mute room=\(target.roomID)") - toggleMicrophone(spaceID: target.spaceID) - } - - private func cancelAloneAutoMute() { - aloneAutoMuteTask?.cancel() - aloneAutoMuteTask = nil - aloneAutoMuteTarget = nil - } - - private func reconcileScreenShareAloneGrace() { - let context = screenShareAloneContext() - let action = screenShareAloneGrace.reconcile(context) - - guard case let .scheduleStop(publicationIDs) = action, - let mediaTarget = currentMediaTarget() - else { - cancelScreenShareAloneTask() - return - } - - let target = GridScreenShareAloneTarget( - mediaTarget: mediaTarget, - episodeID: context.episodeID, - publicationIDs: publicationIDs - ) - guard target != screenShareAloneTarget else { return } - cancelScreenShareAloneTask() - screenShareAloneTarget = target - screenShareAloneTask = Task { [weak self] in - try? await Task.sleep(for: GridScreenShareAloneGraceState.graceDuration) - guard !Task.isCancelled else { return } - self?.stopScreenShareIfStillAlone(target) - } - } - - private func screenShareAloneContext() -> GridScreenShareAloneGraceState.Context { - let localShares = media.screenShares.filter(\.isLocal) - let localPublicationIDs = Set(localShares.map(\.publicationID)) - let localParticipantIdentities = Set(localShares.map(\.participantIdentity)) - let hasRemoteParticipant = media.connectedParticipantIdentities.contains { - !localParticipantIdentities.contains($0) - } - return .init( - episodeID: media.screenShareEpisodeID, - isConnected: media.connectionState == .connected, - isShareRequested: media.isScreenShareRequested, - localPublicationIDs: localPublicationIDs, - hasRemoteParticipant: hasRemoteParticipant - ) - } - - private func stopScreenShareIfStillAlone(_ target: GridScreenShareAloneTarget) { - guard screenShareAloneTarget == target else { return } - screenShareAloneTask = nil - screenShareAloneTarget = nil - guard currentMediaTarget() == target.mediaTarget, - GridScreenShareAloneGraceState.shouldCommitScheduledStop( - screenShareAloneContext(), - expectedEpisodeID: target.episodeID, - expectedPublicationIDs: target.publicationIDs - ) - else { return } - - log.debug( - "GRID_TRACE phase=alone_auto_stop_screen_share room=\(target.mediaTarget.roomID)" - ) - mediaCoordinator.stopScreenSharing() - resetScreenShareAloneGrace() - } - - private func cancelScreenShareAloneTask() { - screenShareAloneTask?.cancel() - screenShareAloneTask = nil - screenShareAloneTarget = nil - } - - private func resetScreenShareAloneGrace() { - cancelScreenShareAloneTask() - screenShareAloneGrace.reset() - } - - private func optimisticallyUpdateRoom( - roomID: Int64, - field: GridOptimisticState.RoomField, - update: (inout GridRoom) -> Void - ) -> OptimisticRoomMutation? { - guard let change = GridOptimisticState.updatingRoom( - in: grids, - roomID: roomID, - update: update - ) else { return nil } - - guard change.fields == Set([field]) else { return nil } - let key = OptimisticRoomMutationKey(roomID: roomID, field: field) - let revision = (roomMutationRevisions[key] ?? 0) + 1 - roomMutationRevisions[key] = revision - grids[change.spaceID] = change.nextGrid - let mutation = OptimisticRoomMutation( - key: key, - revision: revision, - accessRevision: spaceAccessRevisions[change.spaceID, default: 0], - change: change - ) - pendingRoomMutations[key] = mutation - return mutation - } - - private func rollbackRoomMutation(_ mutation: OptimisticRoomMutation) { - guard isCurrent(mutation) else { return } - pendingRoomMutations.removeValue(forKey: mutation.key) - guard let currentGrid = grids[mutation.change.spaceID] else { return } - grids[mutation.change.spaceID] = GridOptimisticState.rollingBackRoomIntent( - mutation.change, - in: currentGrid - ) - } - - private func isCurrent(_ mutation: OptimisticRoomMutation) -> Bool { - roomMutationRevisions[mutation.key] == mutation.revision - && spaceAccessRevisions[mutation.change.spaceID, default: 0] == mutation.accessRevision - } - - private func optimisticallyJoin(roomID: Int64) -> OptimisticMembershipMutation? { - let fallbackAvatar = makeCurrentUserAvatar() - let joinedAt = Int64(Date().timeIntervalSince1970) - guard let change = GridOptimisticState.joining( - roomID: roomID, - in: grids, - avatar: fallbackAvatar, - microphoneEnabled: mediaCoordinator.isMicrophoneEnabled, - joinedAt: joinedAt - ) else { return nil } - - membershipRollbackBaseline.beginIfNeeded(with: change.previousGrids) - - membershipMutationRevision &+= 1 - let mutation = OptimisticMembershipMutation( - roomID: roomID, - spaceID: change.spaceID, - revision: membershipMutationRevision, - previousGrids: change.previousGrids, - intent: .join(fallbackAvatar: fallbackAvatar, joinedAt: joinedAt) - ) - for (spaceID, grid) in change.nextGrids { grids[spaceID] = grid } - reconcileAvatarStateOwnership() - log.debug("GRID_TRACE phase=join_optimistic_applied room=\(roomID) revision=\(mutation.revision)") - return mutation - } - - private func optimisticallyLeave(spaceID: Int64) -> OptimisticMembershipMutation? { - guard let change = GridOptimisticState.leaving(spaceID: spaceID, in: grids) else { - return nil - } - - membershipRollbackBaseline.beginIfNeeded(with: change.previousGrids) - - membershipMutationRevision &+= 1 - let mutation = OptimisticMembershipMutation( - roomID: change.roomID, - spaceID: spaceID, - revision: membershipMutationRevision, - previousGrids: change.previousGrids, - intent: .leave - ) - for (changedSpaceID, grid) in change.nextGrids { grids[changedSpaceID] = grid } - reconcileAvatarStateOwnership() - log.debug("GRID_TRACE phase=leave_optimistic_applied room=\(change.roomID) revision=\(mutation.revision)") - return mutation - } - - private func rollbackMembershipMutation(_ mutation: OptimisticMembershipMutation) { - guard membershipMutationRevision == mutation.revision else { return } - grids = GridOptimisticState.rollingBackMembershipIntent( - .init( - roomID: mutation.roomID, - spaceID: mutation.spaceID, - previousGrids: membershipRollbackBaseline.grids ?? mutation.previousGrids, - nextGrids: [:] - ), - in: grids - ) - reconcileAvatarStateOwnership() - reconcileMediaDemand() - } - - private func makeCurrentUserAvatar() -> GridAvatar? { - guard let userID = Auth.shared.getCurrentUserId() else { return nil } - - var protocolUser = InlineProtocol.User() - protocolUser.id = userID - if let user = ObjectCache.shared.getUser(id: userID)?.user { - if let firstName = user.firstName { protocolUser.firstName = firstName } - if let lastName = user.lastName { protocolUser.lastName = lastName } - if let username = user.username { protocolUser.username = username } - if let profileCdnURL = user.profileCdnUrl { - protocolUser.profilePhoto = .with { - $0.cdnURL = profileCdnURL - if let uniqueID = user.profileFileUniqueId { $0.fileUniqueID = uniqueID } - } - } - } - - return .with { - $0.user = protocolUser - $0.joinedAt = Int64(Date().timeIntervalSince1970) - $0.ownedByCurrentSession = true - $0.microphoneEnabled = mediaCoordinator.isMicrophoneEnabled - } - } - - private func applyEnabled(_ enabled: Bool, spaceID: Int64) { - if enabled { - enabledSpaceIDs.insert(spaceID) - } else { - enabledSpaceIDs.remove(spaceID) - grids[spaceID] = .with { $0.spaceID = spaceID; $0.enabled = false } - reconcileAvatarStateOwnership() - } - } - - private func handle(_ event: GridRoomLifecycleEvent) async { - switch event { - case let .grid(event): - await handle(event) - case let .realtimeConnection(state): - await handleConnectionState(state) - case let .network(snapshot): - await handleNetworkPath(snapshot) - case .heartbeat: - await refreshOwnedPresence() - } - } - - private func handle(_ event: GridMediaCoordinatorEvent) async { - switch event { - case let .credentialsNeeded(target): - guard let grid = grids[target.spaceID] else { return } - await prepareConnectionIfNeeded(grid: grid) - case let .connected(_, rtcConnectMilliseconds): - guard let startedAt = mediaInteractionStartedAt else { return } - mediaInteractionStartedAt = nil - let totalMilliseconds = Self.elapsedMilliseconds(since: startedAt) - PerformanceTrace.breadcrumb( - "Grid connection ready", - category: "Grid.Connection", - level: totalMilliseconds >= 3_000 ? .warning : .info, - data: [ - "click_to_connected_ms": totalMilliseconds, - "rtc_connect_ms": rtcConnectMilliseconds ?? -1, - ] - ) - log.debug( - "GRID_TRACE phase=click_to_connected elapsed_ms=\(totalMilliseconds) rtc_ms=\(rtcConnectMilliseconds ?? -1)" - ) - case .screenShareContextChanged: - reconcileScreenSharePresentationEdges() - reconcileScreenShareAloneGrace() - } - } - - private func handle(_ event: GridMembershipSyncEvent) async { - let operation: GridMembershipOperation - switch event { - case let .created(value, _), let .joined(value, _), let .left(value, _), let .failed(value, _): - operation = value - } - - let mutation = pendingMembershipMutations.removeValue(forKey: operation.revision) - let isLatestIntent = operation.revision == membershipMutationRevision - guard operation.accessRevision == spaceAccessRevisions[operation.spaceID, default: 0] else { - if isLatestIntent { - mediaCoordinator.restoreAutomaticMicrophoneChangeIfCurrent( - operation.automaticMicrophoneChange - ) - } - return - } - - switch event { - case let .created(_, response): - mediaCoordinator.commitAutomaticMicrophoneChange(operation.automaticMicrophoneChange) - updateMembershipRollbackBaseline(with: response.grids) - await apply( - grids: response.grids, - credentials: isLatestIntent ? response.credentials : nil - ) - log.debug( - "GRID_TRACE phase=create_client_done space=\(operation.spaceID) elapsed_ms=\(Self.elapsedMilliseconds(since: operation.startedAt))" - ) - case let .joined(_, response): - mediaCoordinator.commitAutomaticMicrophoneChange(operation.automaticMicrophoneChange) - updateMembershipRollbackBaseline(with: response.grids) - await apply( - grids: response.grids, - credentials: isLatestIntent ? response.credentials : nil - ) - log.debug( - "GRID_TRACE phase=join_client_done room=\(operationRoomID(operation) ?? 0) elapsed_ms=\(Self.elapsedMilliseconds(since: operation.startedAt))" - ) - case let .left(_, nextGrids): - updateMembershipRollbackBaseline(with: nextGrids) - applySnapshot(nextGrids) - case let .failed(_, message): - mediaInteractionStartedAt = nil - if isLatestIntent { - if let mutation { - rollbackMembershipMutation(mutation) - } - if mediaCoordinator.restoreAutomaticMicrophoneChangeIfCurrent( - operation.automaticMicrophoneChange - ), let restoredGrid = grids.values.first(where: { $0.hasCurrentRoomID }) { - syncMicrophoneStateIfNeeded(grid: restoredGrid) - reconcileMediaDemand() - } - if case .leave = operation.kind, let restoredGrid = grids[operation.spaceID] { - await prepareConnectionIfNeeded(grid: restoredGrid) - } - } - lastError = message - log.warning( - "GRID_TRACE phase=membership_client_failed revision=\(operation.revision) elapsed_ms=\(Self.elapsedMilliseconds(since: operation.startedAt))" - ) - } - if pendingMembershipMutations.isEmpty { - membershipRollbackBaseline.clear() - } - } - - private func handle(_ event: GridEvent) async { - switch event.event { - case let .changed(changed): - if let target = aloneAutoMuteTarget, - changed.spaceIds.contains(target.spaceID) - || (changed.hasRoomID && changed.roomID == target.roomID) { - cancelAloneAutoMute() - } - log.debug( - "GRID_TRACE phase=changed_event_received spaces=\(changed.spaceIds.map { String($0) }.joined(separator: ",")) room=\(changed.hasRoomID ? String(changed.roomID) : "none")" - ) - for spaceID in changed.spaceIds { - if enabledSpaceIDs.contains(spaceID) { - try? await reloadGrid(spaceID: spaceID) - } else { - // A Grid-changed event is also the cross-session enablement signal. - // Refresh settings when this process still believes Grid is disabled. - await load(spaceID: spaceID) - } - } - await loadHome() - case let .connectionReady(ready): - guard ready.hasCredentials else { return } - log.debug( - "GRID_TRACE phase=ready_event_received room=\(ready.credentials.connection.roomID) generation=\(ready.credentials.connection.generation)" - ) - await accept(ready.credentials) - case let .avatarStateChanged(changed): - log.debug( - "GRID_TRACE phase=avatar_state_event_received room=\(changed.roomID) user=\(changed.userID) microphone=\(changed.microphoneEnabled) revision=\(changed.microphoneRevision)" - ) - applyRemoteAvatarMicrophoneState( - spaceID: changed.spaceID, - roomID: changed.roomID, - userID: changed.userID, - enabled: changed.microphoneEnabled, - membershipID: changed.membershipID, - revision: changed.microphoneRevision - ) - case let .accessRevoked(revoked): - clearLocalGrid(spaceID: revoked.spaceID, reason: "server_revoked") - case nil: - break - } - } - - private func clearLocalGrid(spaceID: Int64, reason: String) { - let hadMediaDemand = currentMediaTarget()?.spaceID == spaceID - spaceAccessRevisions[spaceID, default: 0] &+= 1 - homeLoadRevision &+= 1 - pendingMembershipMutations = pendingMembershipMutations.filter { _, mutation in - mutation.spaceID != spaceID - } - membershipRollbackBaseline.remove(spaceID: spaceID) - if pendingMembershipMutations.isEmpty { - membershipRollbackBaseline.clear() - } - roomMutationRevisions = roomMutationRevisions.filter { key, _ in - grids[spaceID]?.rooms.contains(where: { $0.id == key.roomID }) != true - } - pendingRoomMutations = pendingRoomMutations.filter { _, mutation in - mutation.change.spaceID != spaceID - } - grids.removeValue(forKey: spaceID) - enabledSpaceIDs.remove(spaceID) - homeSpaces.removeAll { $0.spaceID == spaceID } - pendingReloadSpaceIDs.remove(spaceID) - pendingConnectionSpaceIDs.remove(spaceID) - failedLoadSpaceIDs.remove(spaceID) - reconcileAvatarStateOwnership() - - if hadMediaDemand { - pendingCredentialTarget = nil - resetCredentialRetry() - mediaInteractionStartedAt = nil - mediaCoordinator.clearCredentials() - } - reconcileMediaDemand() - log.info("GRID_TRACE phase=local_grid_cleared space=\(spaceID) reason=\(reason)") - PerformanceTrace.breadcrumb( - "Local Grid access cleared", - category: "Grid.Access", - level: .info, - data: ["space_id": spaceID, "reason": reason] - ) - } - - private func prepareConnectionIfNeeded(grid: InlineProtocol.Grid) async { - guard grid.hasCurrentRoomID, - let room = grid.rooms.first(where: { $0.id == grid.currentRoomID }), - room.hasConnection, - room.avatars.contains(where: \.ownedByCurrentSession) - else { - reconcileMediaDemand() - return - } - let target = GridMediaTarget( - spaceID: grid.spaceID, - roomID: room.id, - generation: room.connection.generation - ) - reconcileMediaDemand() - // A LiveKit token is only admission material. Once this exact generation - // has an established transport, refreshing an expired token on every - // presence heartbeat adds server work without helping the active room. - // A later disconnect clears this state; the RTC engine then requests fresh - // credentials before its next connection attempt. - if mediaCoordinator.isConnected(to: target) { - resetCredentialRetry() - return - } - if mediaCoordinator.hasUsableCredentials(for: target) { - resetCredentialRetry() - return - } - mediaCoordinator.invalidateCredentials(for: target) - guard pendingCredentialTarget != target else { return } - - pendingCredentialTarget = target - defer { - if pendingCredentialTarget == target { - pendingCredentialTarget = nil - } - } - let startedAt = Date() - log.debug( - "GRID_ENGINE phase=credentials_fetch_start room=\(room.id) generation=\(room.connection.generation)" - ) - do { - guard let connection = try await api.prepareConnection( - roomID: room.id, - generation: room.connection.generation - ) else { - log.debug( - "GRID_ENGINE phase=credentials_fetch_unavailable room=\(room.id) generation=\(room.connection.generation) elapsed_ms=\(Self.elapsedMilliseconds(since: startedAt))" - ) - scheduleCredentialRetry(for: target) - return - } - log.debug( - "GRID_ENGINE phase=credentials_fetch_done room=\(room.id) generation=\(room.connection.generation) elapsed_ms=\(Self.elapsedMilliseconds(since: startedAt))" - ) - await accept(connection) - } catch { - lastError = String(describing: error) - log.error( - "GRID_ENGINE phase=credentials_fetch_failed room=\(room.id) generation=\(room.connection.generation)", - error: error - ) - scheduleCredentialRetry(for: target) - } - } - - private func accept(_ credentials: GridConnectionCredentials) async { - guard credentials.hasConnection, - let serverURL = URL(string: credentials.serverURL), - let target = currentMediaTarget(), - target.roomID == credentials.connection.roomID, - target.generation == credentials.connection.generation - else { - log.debug( - "GRID_ENGINE phase=credentials_ignored_stale room=\(credentials.connection.roomID) generation=\(credentials.connection.generation)" - ) - return - } - let accepted = mediaCoordinator.accept(InlineRTCCredentials( - target: target.rtcSessionID, - serverURL: serverURL, - participantIdentity: credentials.participantIdentity, - token: credentials.token, - expiresAt: Date(timeIntervalSince1970: TimeInterval(credentials.expiresAt)) - )) - guard accepted else { return } - resetCredentialRetry() - log.debug( - "GRID_ENGINE phase=credentials_accepted room=\(target.roomID) generation=\(target.generation)" - ) - } - - private func scheduleCredentialRetry(for target: GridMediaTarget) { - guard currentMediaTarget() == target, - credentialRetryTask == nil - else { return } - if credentialRetryTarget != target { - credentialRetryTarget = target - credentialRetryAttempt = 0 - } - credentialRetryAttempt &+= 1 - let attempt = credentialRetryAttempt - let delay = Self.credentialRetryDelay(attempt: attempt) - log.debug( - "GRID_ENGINE phase=credentials_retry_scheduled room=\(target.roomID) generation=\(target.generation) attempt=\(attempt) delay_s=\(delay)" - ) - credentialRetryTask = Task { [weak self] in - try? await Task.sleep(for: .seconds(delay)) - guard !Task.isCancelled else { return } - await self?.credentialRetryFired(target: target, attempt: attempt) - } - } - - private func credentialRetryFired(target: GridMediaTarget, attempt: Int) async { - guard credentialRetryTarget == target, - credentialRetryAttempt == attempt - else { return } - credentialRetryTask = nil - guard currentMediaTarget() == target, - let grid = grids[target.spaceID] - else { - resetCredentialRetry() - return - } - await prepareConnectionIfNeeded(grid: grid) - } - - private func resetCredentialRetry() { - credentialRetryTask?.cancel() - credentialRetryTask = nil - credentialRetryTarget = nil - credentialRetryAttempt = 0 - } - - private func refreshOwnedPresence() async { - for grid in grids.values where grid.hasCurrentRoomID { - try? await reloadGrid(spaceID: grid.spaceID) - return - } - } - - private func syncMicrophoneStateIfNeeded(grid: InlineProtocol.Grid) { - guard grid.hasCurrentRoomID, - let room = grid.rooms.first(where: { $0.id == grid.currentRoomID }), - let avatar = room.avatars.first(where: \.ownedByCurrentSession) - else { return } - let microphoneEnabled = mediaCoordinator.isMicrophoneEnabled - guard avatar.microphoneEnabled != microphoneEnabled else { return } - applyAvatarMicrophoneState( - spaceID: grid.spaceID, - roomID: room.id, - userID: nil, - enabled: microphoneEnabled - ) - avatarStateSync.submitMicrophoneState(roomID: room.id, enabled: microphoneEnabled) - } - - private func applyAvatarMicrophoneState( - spaceID: Int64, - roomID: Int64, - userID: Int64?, - enabled: Bool, - revision: Int32? = nil - ) { - guard var grid = grids[spaceID], - let roomIndex = grid.rooms.firstIndex(where: { $0.id == roomID }), - let avatarIndex = grid.rooms[roomIndex].avatars.firstIndex(where: { avatar in - if let userID { return avatar.user.id == userID } - return avatar.ownedByCurrentSession - }) - else { return } - grid.rooms[roomIndex].avatars[avatarIndex].microphoneEnabled = enabled - if let revision { - grid.rooms[roomIndex].avatars[avatarIndex].microphoneRevision = revision - } - grids[spaceID] = grid - } - - /// Inline's local microphone intent remains authoritative for the avatar - /// owned by this process. A delayed echo from an earlier rapid toggle must - /// not repaint that avatar with stale server state; other avatars always use - /// the transient state sent by their owning session. - private func applyRemoteAvatarMicrophoneState( - spaceID: Int64, - roomID: Int64, - userID: Int64, - enabled: Bool, - membershipID: String, - revision: Int32 - ) { - guard let grid = grids[spaceID], - let room = grid.rooms.first(where: { $0.id == roomID }), - let avatar = room.avatars.first(where: { $0.user.id == userID }), - avatar.membershipID == membershipID, - revision >= avatar.microphoneRevision - else { return } - applyAvatarMicrophoneState( - spaceID: spaceID, - roomID: roomID, - userID: userID, - enabled: avatar.ownedByCurrentSession ? mediaCoordinator.isMicrophoneEnabled : enabled, - revision: revision - ) - } - - private func handleConnectionState(_ state: RealtimeConnectionState) async { - let previousState = lastRealtimeConnectionState - lastRealtimeConnectionState = state - switch state { - case .connecting: - // Inline realtime and LiveKit have independent transports. Keep media - // alive while the app server reconnects; LiveKit handles its own recovery. - break - case .connected, .updating: - if previousState == nil || previousState == .connecting { - networkRefreshRevision &+= 1 - avatarStateSync.retryLatestFailure() - mediaCoordinator.networkBecameAvailable() - await loadHome() - await retryPendingConnectionLoads() - await refreshOwnedPresence() - } - } - } - - private var realtimeReady: Bool { - switch lastRealtimeConnectionState { - case .connected, .updating: - true - case .connecting, nil: - false - } - } - - private func handleNetworkPath(_ snapshot: GridNetworkPathSnapshot) async { - let previousSnapshot = lastNetworkPathSnapshot - lastNetworkPathSnapshot = snapshot - networkAvailable = snapshot.available - log.debug( - "GRID_TRACE phase=network_path_changed available=\(snapshot.available) interface=\(snapshot.interfaceDescription) constrained=\(snapshot.constrained) expensive=\(snapshot.expensive)" - ) - if previousSnapshot == nil { - return - } - if snapshot.available { - networkRefreshRevision &+= 1 - avatarStateSync.retryLatestFailure() - mediaCoordinator.networkBecameAvailable() - await loadHome() - await retryPendingConnectionLoads() - await refreshOwnedPresence() - } - } - - private func retryPendingConnectionLoads() async { - let deferredSpaceIDs = pendingConnectionSpaceIDs.sorted() - pendingConnectionSpaceIDs.removeAll() - for spaceID in deferredSpaceIDs { - await load(spaceID: spaceID) - } - } - - func applicationDidWake() async { - avatarStateSync.retryLatestFailure() - mediaCoordinator.applicationDidWake() - await loadHome() - await refreshOwnedPresence() - } - - private func reconcileAvatarStateOwnership() { - let ownedRoomID = grids.values.lazy - .filter(\.hasCurrentRoomID) - .compactMap { grid -> Int64? in - guard let room = grid.rooms.first(where: { $0.id == grid.currentRoomID }), - room.avatars.contains(where: \.ownedByCurrentSession) - else { return nil } - return room.id - } - .first - avatarStateSync.setOwnedRoom(ownedRoomID) - } - - private func currentMediaTarget() -> GridMediaTarget? { - for grid in grids.values where grid.hasCurrentRoomID { - guard let room = grid.rooms.first(where: { $0.id == grid.currentRoomID }), - room.hasConnection, - room.avatars.contains(where: \.ownedByCurrentSession) - else { continue } - return GridMediaTarget( - spaceID: grid.spaceID, - roomID: room.id, - generation: room.connection.generation - ) - } - return nil - } - - private func isOwnedUser(_ userID: Int64) -> Bool { - grids.values.lazy - .flatMap(\.rooms) - .flatMap(\.avatars) - .contains { $0.ownedByCurrentSession && $0.user.id == userID } - } - - private static func liveKitIdentityPrefix(userID: Int64) -> String { - "inline-grid-user-\(userID)" - } - - private static func identity(_ identity: String, matchesUserID userID: Int64) -> Bool { - let prefix = liveKitIdentityPrefix(userID: userID) - return identity == prefix || identity.hasPrefix("\(prefix)-") - } - - private func participantIdentities(for avatar: GridAvatar) -> Set { - if !avatar.membershipID.isEmpty { - return [ - "\(Self.liveKitIdentityPrefix(userID: avatar.user.id))-\(avatar.membershipID)", - ] - } - return Set( - media.connectedParticipantIdentities.filter { - Self.identity($0, matchesUserID: avatar.user.id) - } - ) - } - - private func screenShareParticipantIdentity(for avatar: GridAvatar) -> String? { - let identities = participantIdentities(for: avatar) - if let identity = identities.sorted().first(where: { - media.participantScreenShareIntents[$0] == true - }) { - return identity - } - if let identity = screenShares(avatar: avatar).first?.participantIdentity { - return identity - } - return identities.sorted().first - } - - private func openScreenShare( - user: InlineProtocol.User, - participantIdentity: String - ) { - if let share = screenShares(participantIdentity: participantIdentity).first { - GridScreenShareWindowCoordinator.shared.open( - user: user, - share: share, - store: self - ) - return - } - guard isScreenShareIntended(participantIdentity: participantIdentity) else { return } - - pendingScreenShareOpenTasks.removeValue(forKey: participantIdentity)?.task.cancel() - let requestID = UUID() - let task = Task { [weak self] in - for _ in 0 ..< 80 { - guard !Task.isCancelled, let self else { return } - if let share = screenShares(participantIdentity: participantIdentity).first { - finishPendingScreenShareOpen( - requestID: requestID, - participantIdentity: participantIdentity - ) - GridScreenShareWindowCoordinator.shared.open( - user: user, - share: share, - store: self - ) - return - } - guard isScreenShareIntended(participantIdentity: participantIdentity) else { - finishPendingScreenShareOpen( - requestID: requestID, - participantIdentity: participantIdentity - ) - return - } - try? await Task.sleep(for: .milliseconds(100)) - } - guard !Task.isCancelled, let self else { return } - finishPendingScreenShareOpen( - requestID: requestID, - participantIdentity: participantIdentity - ) - if isScreenShareIntended(participantIdentity: participantIdentity) { - ToastCenter.shared.showError("The shared screen hasn’t arrived yet") - } - } - pendingScreenShareOpenTasks[participantIdentity] = GridPendingScreenShareOpen( - id: requestID, - task: task - ) - } - - private func finishPendingScreenShareOpen( - requestID: UUID, - participantIdentity: String - ) { - guard pendingScreenShareOpenTasks[participantIdentity]?.id == requestID else { return } - pendingScreenShareOpenTasks[participantIdentity] = nil - } - - private static func displayIDUnderPointer() -> UInt32? { - let mouseLocation = NSEvent.mouseLocation - guard let screen = NSScreen.screens.first(where: { - NSMouseInRect(mouseLocation, $0.frame, false) - }), - let screenNumber = screen.deviceDescription[ - NSDeviceDescriptionKey("NSScreenNumber") - ] as? NSNumber - else { return CGMainDisplayID() } - return screenNumber.uint32Value - } - - func setInputSelection(_ selection: AudioInputSelection) { - mediaCoordinator.setInput(selection) - } - - func setOutputSelection(_ selection: AudioOutputSelection) { - mediaCoordinator.setOutput(selection) - } - - func setOutputVolume(_ volume: Float) { - mediaCoordinator.setOutputVolume(volume) - } - - func refreshInputDevices() { - mediaCoordinator.refreshInputDevices() - } - - func refreshOutputDevices() { - mediaCoordinator.refreshOutputDevices() - } - - func retryAudio() { - mediaCoordinator.retryAudio() - } - - private static func elapsedMilliseconds(since date: Date) -> Int { - Int(Date().timeIntervalSince(date) * 1_000) - } - - private static func credentialRetryDelay(attempt: Int) -> Int { - switch attempt { - case 1: 1 - case 2: 2 - case 3: 4 - case 4: 8 - case 5: 15 - default: 30 - } - } - - private func operationRoomID(_ operation: GridMembershipOperation) -> Int64? { - switch operation.kind { - case .create: nil - case let .join(roomID), let .leave(roomID): roomID - } - } - - private func applyPendingMembershipIntent() { - guard let mutation = pendingMembershipMutations[membershipMutationRevision], - grids[mutation.spaceID] != nil - else { return } - - let change: GridOptimisticState.MembershipChange? - switch mutation.intent { - case let .join(fallbackAvatar, joinedAt): - change = GridOptimisticState.joining( - roomID: mutation.roomID, - in: grids, - avatar: fallbackAvatar, - microphoneEnabled: mediaCoordinator.isMicrophoneEnabled, - joinedAt: joinedAt - ) - case .leave: - change = GridOptimisticState.leaving(spaceID: mutation.spaceID, in: grids) - } - guard let change else { return } - for (spaceID, grid) in change.nextGrids { grids[spaceID] = grid } - } - - private func updateMembershipRollbackBaseline(with snapshots: [InlineProtocol.Grid]) { - let confirmedSnapshots = snapshots.filter { snapshot in - let previousRevision = lastAcceptedSnapshotRevisions[snapshot.spaceID] - if let previousRevision { return snapshot.revision >= previousRevision } - return true - } - membershipRollbackBaseline.mergeConfirmed(confirmedSnapshots) - } - -} - -private struct OptimisticRoomMutationKey: Hashable { - let roomID: Int64 - let field: GridOptimisticState.RoomField -} - -private struct GridAloneAutoMuteTarget: Equatable { - let spaceID: Int64 - let roomID: Int64 - let userID: Int64 -} - -private struct GridScreenShareAloneTarget: Equatable { - let mediaTarget: GridMediaTarget - let episodeID: UInt64 - let publicationIDs: Set -} - -private struct GridScreenShareNotice { - let target: GridMediaTarget - let user: InlineProtocol.User - let participantIdentity: String -} - -private struct GridPendingScreenShareOpen { - let id: UUID - let task: Task -} - -private struct OptimisticRoomMutation { - let key: OptimisticRoomMutationKey - let revision: Int - let accessRevision: Int - let change: GridOptimisticState.RoomChange -} - -private struct OptimisticMembershipMutation { - enum Intent { - case join(fallbackAvatar: GridAvatar?, joinedAt: Int64) - case leave - } - - let roomID: Int64 - let spaceID: Int64 - let revision: Int - let previousGrids: [Int64: InlineProtocol.Grid] - let intent: Intent -} +// Grid product state is shared by the iOS and macOS clients. +import InlineGrid diff --git a/apple/InlineMac/Features/Grid/GridTranscriptionControls.swift b/apple/InlineMac/Features/Grid/GridTranscriptionControls.swift new file mode 100644 index 000000000..650d880af --- /dev/null +++ b/apple/InlineMac/Features/Grid/GridTranscriptionControls.swift @@ -0,0 +1,212 @@ +import InlineKit +import InlineProtocol +import SwiftUI + +/// Room snapshots own capture state. View state is limited to an open picker +/// and presentation of a failed request. +struct GridTranscriptionControls: View { + let room: GridRoom + let isBusy: Bool + let onSetTranscription: (Bool, GridTranscriptionRequest.Destination) async throws -> Void + let onOpenRoomThread: () async throws -> Void + let onOpenTranscript: () -> Void + let onListTranscripts: () async throws -> [GridTranscriptDestinationInfo] + + @State private var showsPicker = false + @State private var isOpeningRoomThread = false + @State private var errorText: String? + + var body: some View { + VStack(spacing: 6) { + if let errorText { + Text(errorText) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + if room.hasTranscription, room.transcription.state == .gridTranscriptionStarting { + Text("Starting transcription…").font(.caption).foregroundStyle(.secondary) + } + HStack(spacing: 8) { + if room.transcriptionAvailable || room.transcriptionIsRunning { + Button { + setTranscription(enabled: !room.transcriptionIsRunning, destination: .last) + } label: { + Label( + room.transcriptionControlTitle, + systemImage: room.transcriptionIsRunning ? "stop.circle" : "text.bubble" + ) + } + .disabled(isBusy || !canControl) + .help(room.transcriptionControlTitle) + } + + if room.hasTranscription, room.transcription.state == .gridTranscriptionActive { + Circle().fill(.green).frame(width: 6, height: 6) + .accessibilityLabel("Transcription is active") + } + + if room.transcriptionAvailable { + Menu { + Button("Start a new transcript") { + setTranscription(enabled: true, destination: .new) + } + Button("Continue earlier transcript…") { showsPicker = true } + } label: { + Label("Transcript options", systemImage: "ellipsis") + .labelStyle(.iconOnly) + } + .menuIndicator(.hidden) + .disabled(isBusy || !canControl || !room.canStartTranscription) + .help("Transcript options") + .popover(isPresented: $showsPicker) { + GridTranscriptPicker( + isBusy: isBusy, + onLoad: onListTranscripts, + onSelect: { chatID in + try await onSetTranscription(true, .existing(chatID)) + showsPicker = false + } + ) + .id(room.id) + } + } + + Divider().frame(height: 16) + + if room.transcriptChatID != nil { + Button("Open transcript", action: onOpenTranscript) + } + Button { + isOpeningRoomThread = true + errorText = nil + Task { + defer { isOpeningRoomThread = false } + do { try await onOpenRoomThread() } + catch { errorText = "Couldn’t open the room thread. Try again." } + } + } label: { + Label("Room thread", systemImage: "bubble.left.and.bubble.right") + } + .disabled(isOpeningRoomThread || !ownsMembership) + } + .buttonStyle(.plain) + .font(.system(size: 12, weight: .medium)) + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background(.regularMaterial, in: Capsule()) + + if room.hasTranscription, room.transcription.state == .gridTranscriptionInterrupted { + Text("Transcription was interrupted. Start again to continue.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .onChange(of: room.id) { + showsPicker = false + errorText = nil + } + } + + private var ownsMembership: Bool { + room.avatars.contains { $0.ownedByCurrentSession && !$0.membershipID.isEmpty } + } + + private var canControl: Bool { + ownsMembership && room.hasConnection && room.connection.generation > 0 && room.transcriptionCanChange + } + + private func setTranscription(enabled: Bool, destination: GridTranscriptionRequest.Destination) { + errorText = nil + Task { + do { try await onSetTranscription(enabled, destination) } + catch { errorText = "Couldn’t confirm the change. Check transcription and try again." } + } + } +} + +private struct GridTranscriptPicker: View { + let isBusy: Bool + let onLoad: () async throws -> [GridTranscriptDestinationInfo] + let onSelect: (Int64) async throws -> Void + + @State private var transcripts: [GridTranscriptDestinationInfo] = [] + @State private var isLoading = true + @State private var errorText: String? + @State private var loadRevision = 0 + @State private var selectedChatID: Int64? + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Continue earlier transcript").font(.headline) + Text("Current and future participants get access to the earlier room’s full thread history.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if isLoading { + ProgressView().frame(maxWidth: .infinity).padding(.vertical, 18) + } else if transcripts.isEmpty, errorText == nil { + Text("No earlier transcripts are available to share.") + .foregroundStyle(.secondary) + .padding(.vertical, 12) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 4) { + ForEach(transcripts, id: \.transcriptChatID) { transcript in + Button { + selectedChatID = transcript.transcriptChatID + errorText = nil + Task { + defer { selectedChatID = nil } + do { try await onSelect(transcript.transcriptChatID) } + catch { errorText = "Couldn’t continue this transcript. Try again." } + } + } label: { + HStack { + Text(transcript.title.isEmpty ? "Transcript" : transcript.title) + .lineLimit(2) + .multilineTextAlignment(.leading) + Spacer(minLength: 8) + if transcript.busy { + Text("In use").font(.caption).foregroundStyle(.secondary) + } else if selectedChatID == transcript.transcriptChatID { + ProgressView().controlSize(.small) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 7) + .padding(.horizontal, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(transcript.busy || isBusy || selectedChatID != nil) + } + } + } + .frame(maxHeight: 270) + } + if let errorText { + Text(errorText).font(.caption).foregroundStyle(.secondary) + if selectedChatID == nil { + Button("Refresh") { loadRevision += 1 } + } + } + } + .padding(16) + .frame(width: 330) + .task(id: loadRevision) { + isLoading = true + errorText = nil + do { + let result = try await onLoad() + guard !Task.isCancelled else { return } + transcripts = Array(result.prefix(20)) + } catch { + guard !Task.isCancelled else { return } + errorText = "Couldn’t load earlier transcripts." + } + isLoading = false + } + } +} diff --git a/apple/InlineMac/Features/Grid/GridTranscriptionSidebarIndicator.swift b/apple/InlineMac/Features/Grid/GridTranscriptionSidebarIndicator.swift new file mode 100644 index 000000000..9cbf6ba01 --- /dev/null +++ b/apple/InlineMac/Features/Grid/GridTranscriptionSidebarIndicator.swift @@ -0,0 +1,59 @@ +import InlineKit +import InlineProtocol +import SwiftUI + +/// Persistent disclosure in the existing sidebar footer, including while the +/// participant reads another chat. It projects the shared room snapshot. +struct GridTranscriptionSidebarIndicator: View { + let room: GridRoom + let isBusy: Bool + let onOpenGrid: () -> Void + let onOpenTranscript: () -> Void + let onStop: () async throws -> Void + + @State private var errorText: String? + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + Button(action: onOpenGrid) { + Label(statusTitle, systemImage: "text.bubble") + .frame(maxWidth: .infinity, alignment: .leading) + } + .font(.system(size: 12, weight: .medium)) + .help("Return to Grid") + + HStack(spacing: 10) { + if room.transcriptChatID != nil { + Button("Open transcript", action: onOpenTranscript) + } + Button(room.transcription.state == .gridTranscriptionStarting ? "Cancel" : "Stop") { + errorText = nil + Task { + do { try await onStop() } + catch { errorText = "Couldn’t confirm the stop. Check transcription and try again." } + } + } + .disabled(isBusy || !room.canStopTranscription) + .accessibilityLabel("Stop transcription") + } + .font(.system(size: 11)) + + if let errorText { + Text(errorText).font(.caption).foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + .padding(.horizontal, 12) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + } + + private var statusTitle: String { + switch room.transcription.state { + case .gridTranscriptionStarting: "Starting transcription…" + case .gridTranscriptionStopping: "Stopping transcription…" + default: "Transcribing" + } + } +} diff --git a/apple/InlineMac/Features/Grid/GridView.swift b/apple/InlineMac/Features/Grid/GridView.swift index 8a323659b..0fe93401a 100644 --- a/apple/InlineMac/Features/Grid/GridView.swift +++ b/apple/InlineMac/Features/Grid/GridView.swift @@ -1,3 +1,4 @@ +import InlineGrid import InlineKit import InlineProtocol import SwiftUI @@ -16,12 +17,21 @@ struct GridView: View { grid: store.grid(spaceID: spaceID), isLoading: store.loadingSpaceIDs.contains(spaceID), didFailLoading: store.failedLoadSpaceIDs.contains(spaceID), + hasLocalAdmission: hasLocalAdmissionInSpace, + isJoiningDisabled: store.membershipMutationInFlight + || (store.callTransferEnabled && store.currentCall?.ownedByCurrentSession == false), audioLevel: store.audioLevel, isScreenSharing: { store.isScreenSharing(avatar: $0) }, connectionState: store.connectionRecoveryAttempt > 0 ? .connecting : store.media.connectionState, onCreate: { store.createAndJoin(spaceID: spaceID) }, onRetry: { Task { await store.load(spaceID: spaceID) } }, - onJoin: { roomID in store.join(roomID: roomID) }, + onJoin: { roomID in + if store.callTransferEnabled, store.currentCall?.roomID == roomID { + store.moveCallHere() + } else { + store.join(roomID: roomID) + } + }, onLeave: { store.leaveCurrentRoom(spaceID: spaceID) }, onToggleMicrophone: { store.toggleMicrophone(spaceID: spaceID) }, onOpenScreenShare: { store.openScreenShare(for: $0) }, @@ -66,25 +76,69 @@ struct GridView: View { } } .safeAreaInset(edge: .bottom) { - if currentRoom != nil { - GridControlPill( - media: store.media, - onToggleMicrophone: { store.toggleMicrophone(spaceID: spaceID) }, - onLeave: { store.leaveCurrentRoom(spaceID: spaceID) }, - onSelectInput: store.setInputSelection, - onRefreshInputDevices: store.refreshInputDevices, - onSelectOutput: store.setOutputSelection, - onRefreshOutputDevices: store.refreshOutputDevices, - onToggleScreenShare: store.toggleScreenShare, - onSelectScreenCaptureSource: store.startScreenSharing, - onSelectScreenShareQualityProfile: store.setScreenShareQualityProfile, - onRefreshScreenCaptureSources: store.refreshScreenCaptureSources, - onStopScreenShare: store.stopScreenSharing, - onSetOutputVolume: store.setOutputVolume, - onRetryAudio: store.retryAudio - ) + if let currentRoom, hasLocalAdmissionInSpace { + VStack(spacing: 8) { + if currentRoom.transcriptionAvailable || currentRoom.hasTranscription || currentRoom.hasRoomThreadID { + GridTranscriptionControls( + room: currentRoom, + isBusy: store.pendingTranscriptionRequestIDs[currentRoom.id] != nil, + onSetTranscription: { enabled, destination in + try await store.setTranscription(room: currentRoom, enabled: enabled, destination: destination) + }, + onOpenRoomThread: { + let chatID = try await store.openRoomThread(roomID: currentRoom.id) + dependencies?.requestOpenChat(peer: .thread(id: chatID)) + }, + onOpenTranscript: { + guard let chatID = currentRoom.transcriptChatID else { return } + dependencies?.requestOpenChat(peer: .thread(id: chatID)) + }, + onListTranscripts: { try await store.listTranscripts(roomID: currentRoom.id) } + ) + .id(currentRoom.id) + } + GridControlPill( + media: store.media, + onToggleMicrophone: { store.toggleMicrophone(spaceID: spaceID) }, + onLeave: { store.leaveCurrentRoom(spaceID: spaceID) }, + onSelectInput: store.setInputSelection, + onRefreshInputDevices: store.refreshInputDevices, + onSelectOutput: store.setOutputSelection, + onRefreshOutputDevices: store.refreshOutputDevices, + onToggleScreenShare: store.toggleScreenShare, + onSelectScreenCaptureSource: store.startScreenSharing, + onSelectScreenShareQualityProfile: store.setScreenShareQualityProfile, + onRefreshScreenCaptureSources: store.refreshScreenCaptureSources, + onStopScreenShare: store.stopScreenSharing, + onSetOutputVolume: store.setOutputVolume, + onRetryAudio: store.retryAudio + ) + } .padding(.bottom, 18) .transition(.move(edge: .bottom).combined(with: .opacity)) + } else if store.callTransferEnabled, let call = store.currentCall, call.spaceID == spaceID { + HStack(spacing: 12) { + Button(call.ownedByCurrentSession ? "Resume here" : "Move here", systemImage: "arrow.down.to.line") { + store.moveCallHere() + } + .disabled(store.membershipMutationInFlight) + if store.membershipMutationInFlight { + Button("Cancel") { store.withdrawLocalAdmission() } + } + } + .padding(.bottom, 18) + } else if let room = currentRoom { + HStack(spacing: 12) { + Button("Resume here", systemImage: "arrow.down.to.line") { store.join(roomID: room.id) } + .disabled(store.membershipMutationInFlight) + if store.membershipMutationInFlight { + Button("Cancel") { store.withdrawLocalAdmission() } + } + } + .padding(.bottom, 18) + } else if store.membershipMutationInFlight { + Button("Cancel") { store.withdrawLocalAdmission() } + .padding(.bottom, 18) } } .animation(.smoothSnappy, value: currentRoom?.id) @@ -94,10 +148,21 @@ struct GridView: View { } private var currentRoom: GridRoom? { - guard let grid = store.grid(spaceID: spaceID), grid.hasCurrentRoomID else { return nil } + guard let grid = store.grid(spaceID: spaceID) else { return nil } + if store.callTransferEnabled { + guard let call = store.currentCall, call.spaceID == spaceID else { return nil } + return grid.rooms.first { $0.id == call.roomID } + } + guard grid.hasCurrentRoomID else { return nil } return grid.rooms.first { $0.id == grid.currentRoomID } } + private var hasLocalAdmissionInSpace: Bool { + guard store.hasLocalAdmission else { return false } + guard store.callTransferEnabled else { return true } + return store.currentCall?.ownedByCurrentSession == true && store.currentCall?.spaceID == spaceID + } + private var pageTitle: String { guard let spaceName = sidebar.space(id: spaceID)?.displayName, spaceName.isEmpty == false diff --git a/apple/InlineMac/Features/Sidebar/SidebarView.swift b/apple/InlineMac/Features/Sidebar/SidebarView.swift index 3c6d0abe1..588f10715 100644 --- a/apple/InlineMac/Features/Sidebar/SidebarView.swift +++ b/apple/InlineMac/Features/Sidebar/SidebarView.swift @@ -1,3 +1,4 @@ +import InlineGrid import AppKit import Auth import Foundation @@ -1462,6 +1463,21 @@ struct SidebarView: View { @ViewBuilder private func bottomBar(scrollProxy: ScrollViewProxy) -> some View { VStack(spacing: 3) { + if let room = gridStore.activeTranscriptionRoom { + GridTranscriptionSidebarIndicator( + room: room, + isBusy: gridStore.pendingTranscriptionRequestIDs[room.id] != nil, + onOpenGrid: { openGrid(spaceID: room.spaceID) }, + onOpenTranscript: { + guard let chatID = room.transcriptChatID else { return } + dependencies?.requestOpenChat(peer: .thread(id: chatID)) + }, + onStop: { try await gridStore.setTranscription(room: room, enabled: false) } + ) + .id(room.id) + .padding(.horizontal, Theme.sidebarItemOuterSpacing + 4) + } + // Temporarily hide the sidebar connection indicator. // if let state = sidebarConnectionState { // SidebarConnectionStatePill(state: state) diff --git a/apple/InlineMac/Views/MessageList/ServiceMessageRowView.swift b/apple/InlineMac/Views/MessageList/ServiceMessageRowView.swift index 2b01972ea..02fc2da67 100644 --- a/apple/InlineMac/Views/MessageList/ServiceMessageRowView.swift +++ b/apple/InlineMac/Views/MessageList/ServiceMessageRowView.swift @@ -1,5 +1,6 @@ import AppKit import InlineKit +import RealtimeV2 enum ServiceMessageRowLayout { struct Plan: Equatable { @@ -39,7 +40,7 @@ enum ServiceMessageRowLayout { static func plan(for message: FullMessage, rowSize: NSSize) -> Plan { let textSize = textSize(for: message, rowWidth: rowSize.width) - return plan(textSize: textSize, rowSize: rowSize) + return plan(textSize: textSize, rowSize: rowSize, leading: message.message.isGridTranscript) } static func calculateSize( @@ -48,7 +49,7 @@ enum ServiceMessageRowLayout { ) -> (size: NSSize, textSize: NSSize, layout: MessageSizeCalculator.LayoutPlans) { let textSize = textSize(for: message, rowWidth: width) let rowSize = NSSize(width: width, height: rowHeight(textHeight: textSize.height)) - let plan = plan(textSize: textSize, rowSize: rowSize) + let plan = plan(textSize: textSize, rowSize: rowSize, leading: message.message.isGridTranscript) let wrapperPlan = MessageSizeCalculator.LayoutPlan(size: plan.rowSize, spacing: .zero) let zeroPlan = MessageSizeCalculator.LayoutPlan(size: .zero, spacing: .zero) let layout = MessageSizeCalculator.LayoutPlans( @@ -83,13 +84,14 @@ enum ServiceMessageRowLayout { return (plan.rowSize, plan.textSize, layout) } - private static func plan(textSize: NSSize, rowSize: NSSize) -> Plan { + private static func plan(textSize: NSSize, rowSize: NSSize, leading: Bool) -> Plan { let textWidth = min(maxTextWidth(rowWidth: rowSize.width), max(1, textSize.width)) let textHeight = max(1, textSize.height) let containerWidth = textWidth + horizontalInset * 2 let containerHeight = textHeight + verticalInset * 2 let containerFrame = NSRect( - x: floor((rowSize.width - containerWidth) / 2), + x: leading ? floor((rowSize.width - maxContainerWidth(rowWidth: rowSize.width)) / 2) : + floor((rowSize.width - containerWidth) / 2), y: floor((rowSize.height - containerHeight) / 2), width: ceil(containerWidth), height: ceil(containerHeight) @@ -133,11 +135,12 @@ enum ServiceMessageRowLayout { } } -final class ServiceMessageViewAppKit: NSView, MessageTableRenderableView { +final class ServiceMessageViewAppKit: NSView, MessageTableRenderableView, NSMenuItemValidation { private let containerView = NSView() private lazy var textView = ServiceMessageTextView { [weak self] link in self?.open(link) } + private var fullMessage: FullMessage private var dependencies: AppDependencies? @@ -166,11 +169,12 @@ final class ServiceMessageViewAppKit: NSView, MessageTableRenderableView { } override func hitTest(_ point: NSPoint) -> NSView? { - let textPoint = convert(point, to: textView) + let localPoint = convert(point, from: superview) + let textPoint = containerView.convert(localPoint, from: self) if let hit = textView.hitTest(textPoint) { return hit } - if containerView.frame.contains(point) { + if containerView.frame.contains(localPoint) { return self } return nil @@ -214,7 +218,10 @@ final class ServiceMessageViewAppKit: NSView, MessageTableRenderableView { } private func updateText() { - textView.setSegments(ServiceMessageRowLayout.segments(for: fullMessage)) + textView.setSegments( + ServiceMessageRowLayout.segments(for: fullMessage), + leading: fullMessage.message.isGridTranscript + ) needsLayout = true needsDisplay = true } @@ -233,15 +240,20 @@ final class ServiceMessageViewAppKit: NSView, MessageTableRenderableView { private func open(_ link: MessageServiceDisplaySegment.Link) { switch link { - case let .user(userId): - dependencies?.requestOpenChat(peer: .user(id: userId)) - case let .thread(chatId): - dependencies?.requestOpenChat(peer: .thread(id: chatId)) + case let .user(userId): + dependencies?.requestOpenChat(peer: .user(id: userId)) + case let .thread(chatId): + dependencies?.requestOpenChat(peer: .thread(id: chatId)) } } private func makeContextMenu() -> NSMenu { let menu = NSMenu() + if fullMessage.message.isGridTranscript { + let copyItem = NSMenuItem(title: "Copy", action: #selector(copyTranscript), keyEquivalent: "") + copyItem.target = self + menu.addItem(copyItem) + } let deleteItem = NSMenuItem(title: "Delete", action: #selector(deleteMessage), keyEquivalent: "delete") deleteItem.target = self deleteItem.image = NSImage(systemSymbolName: "trash", accessibilityDescription: "Delete") @@ -249,23 +261,53 @@ final class ServiceMessageViewAppKit: NSView, MessageTableRenderableView { return menu } + @objc private func copyTranscript() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(fullMessage.message.text ?? "", forType: .string) + } + + func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + guard menuItem.action == #selector(deleteMessage) else { return true } + // This row has no Space role projection. Keep server authority for signed-in + // readers rather than fetching membership while constructing a menu. + return dependencies?.auth.getCurrentUserId() != nil + } + @objc private func deleteMessage() { let message = fullMessage.message Task(priority: .userInitiated) { @MainActor in - try await Api.realtime.send(.deleteMessages( - messageIds: [message.messageId], - peerId: message.peerId, - chatId: message.chatId - )) + do { + try await Api.realtime.send(DeleteMessageTransaction( + messageIds: [message.messageId], + peerId: message.peerId, + chatId: message.chatId, + deferLocalDeletion: message.isGridTranscript + )) + } catch { + let notice: String + if let failure = error as? TransactionError, + case let .rpcError(rpc) = failure, + rpc.errorCode == .spaceAdminRequired + { + notice = message.isGridTranscript + ? "Only the person who started transcription or a space admin can delete this message." + : "Only the author or a space admin can delete this message." + } else { + notice = "Could not delete message. Please try again." + } + ToastCenter.shared.showError(notice) + } } } fileprivate static func textColor(for tone: MessageServiceDisplaySegment.Tone) -> NSColor { switch tone { - case .secondary: - return .secondaryLabelColor - case .tertiary: - return .tertiaryLabelColor + case .primary: + .labelColor + case .secondary: + .secondaryLabelColor + case .tertiary: + .tertiaryLabelColor } } @@ -284,7 +326,9 @@ private final class ServiceMessageTextView: NSView { private let onOpen: (MessageServiceDisplaySegment.Link) -> Void private var linkRuns: [LinkRun] = [] - override var isFlipped: Bool { true } + override var isFlipped: Bool { + true + } init(onOpen: @escaping (MessageServiceDisplaySegment.Link) -> Void) { self.onOpen = onOpen @@ -304,13 +348,16 @@ private final class ServiceMessageTextView: NSView { fatalError("init(coder:) has not been implemented") } - func setSegments(_ segments: [MessageServiceDisplaySegment]) { + func setSegments(_ segments: [MessageServiceDisplaySegment], leading: Bool) { let text = NSMutableAttributedString() var runs: [LinkRun] = [] for segment in segments where segment.text.isEmpty == false { let start = text.length - text.append(NSAttributedString(string: segment.text, attributes: Self.attributes(for: segment.tone))) + text.append(NSAttributedString( + string: segment.text, + attributes: Self.attributes(for: segment.tone, leading: leading) + )) if let link = segment.link { let range = NSRange(location: start, length: text.length - start) @@ -332,7 +379,8 @@ private final class ServiceMessageTextView: NSView { } override func hitTest(_ point: NSPoint) -> NSView? { - guard bounds.contains(point), link(at: point) != nil else { return nil } + let localPoint = convert(point, from: superview) + guard bounds.contains(localPoint), link(at: localPoint) != nil else { return nil } return self } @@ -392,9 +440,12 @@ private final class ServiceMessageTextView: NSView { return linkRuns.first { NSLocationInRange(charIndex, $0.range) }?.link } - private static func attributes(for tone: MessageServiceDisplaySegment.Tone) -> [NSAttributedString.Key: Any] { + private static func attributes( + for tone: MessageServiceDisplaySegment.Tone, + leading: Bool + ) -> [NSAttributedString.Key: Any] { let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.alignment = .center + paragraphStyle.alignment = leading ? .left : .center paragraphStyle.lineBreakMode = .byCharWrapping return [ diff --git a/apple/InlineMac/Views/Settings/Views/AudioAndVideoSettingsDetailView.swift b/apple/InlineMac/Views/Settings/Views/AudioAndVideoSettingsDetailView.swift index 6db66795f..aa53321a1 100644 --- a/apple/InlineMac/Views/Settings/Views/AudioAndVideoSettingsDetailView.swift +++ b/apple/InlineMac/Views/Settings/Views/AudioAndVideoSettingsDetailView.swift @@ -1,3 +1,4 @@ +import InlineGrid import InlineRTC import SwiftUI diff --git a/apple/InlineMac/Views/SpaceSettings/SpaceSettingsView.swift b/apple/InlineMac/Views/SpaceSettings/SpaceSettingsView.swift index 87b300684..ce1779ea1 100644 --- a/apple/InlineMac/Views/SpaceSettings/SpaceSettingsView.swift +++ b/apple/InlineMac/Views/SpaceSettings/SpaceSettingsView.swift @@ -1,3 +1,4 @@ +import InlineGrid import Foundation import InlineKit import InlineUI diff --git a/bun.lock b/bun.lock index a77e408df..611b2bf06 100644 --- a/bun.lock +++ b/bun.lock @@ -393,6 +393,21 @@ "typescript": "catalog:", }, }, + "server/packages/grid-transcription": { + "name": "@inline-chat/grid-transcription", + "version": "0.0.1", + "dependencies": { + "@livekit/rtc-node": "1.1.0", + "onnxruntime-node": "1.24.3", + "ws": "8.21.3", + }, + "devDependencies": { + "@types/node": "catalog:", + "@types/ws": "8.18.1", + "oxlint": "catalog:", + "typescript": "catalog:", + }, + }, "server/packages/url-preview": { "name": "@inline-chat/url-preview", "version": "0.0.1", @@ -583,6 +598,8 @@ "@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="], + "@datastructures-js/deque": ["@datastructures-js/deque@1.0.8", "", {}, "sha512-PSBhJ2/SmeRPRHuBv7i/fHWIdSC3JTyq56qb+Rq0wjOagi0/fdV5/B/3Md5zFZus/W6OkSPMaxMKKMNMrSmubg=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@dual-bundle/import-meta-resolve": ["@dual-bundle/import-meta-resolve@4.2.1", "", {}, "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg=="], @@ -755,6 +772,8 @@ "@inline-chat/email-templates": ["@inline-chat/email-templates@workspace:server/packages/email-templates"], + "@inline-chat/grid-transcription": ["@inline-chat/grid-transcription@workspace:server/packages/grid-transcription"], + "@inline-chat/hermes-agent-adapter": ["@inline-chat/hermes-agent-adapter@workspace:plugins/hermes-agent"], "@inline-chat/landing": ["@inline-chat/landing@workspace:landing"], @@ -835,8 +854,26 @@ "@linear/sdk": ["@linear/sdk@38.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.0", "graphql": "^15.4.0", "isomorphic-unfetch": "^3.1.0" } }, "sha512-1Z1F/rGjQqXlvk9Y0optAw3mq0OAvY+R5ahPCE3zRnZmALRnQFsmlX7A/oVqIq2bAH/GHKDg447+3CyirqURrA=="], + "@livekit/mutex": ["@livekit/mutex@1.1.1", "", {}, "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw=="], + "@livekit/protocol": ["@livekit/protocol@1.49.0", "", { "dependencies": { "@bufbuild/protobuf": "^1.10.0" } }, "sha512-TCmihwjHBWAUdNrqvmrN/K59r7vDnPIy3OEaE3p0g4FvdcQTiLynmFC3igR25Q2fnifahhAJjnnfSCtWUK7kYg=="], + "@livekit/rtc-ffi-bindings": ["@livekit/rtc-ffi-bindings@0.12.73", "", { "dependencies": { "@bufbuild/protobuf": "^1.10.1" }, "optionalDependencies": { "@livekit/rtc-ffi-bindings-darwin-arm64": "0.12.73", "@livekit/rtc-ffi-bindings-darwin-x64": "0.12.73", "@livekit/rtc-ffi-bindings-linux-arm64-gnu": "0.12.73", "@livekit/rtc-ffi-bindings-linux-x64-gnu": "0.12.73", "@livekit/rtc-ffi-bindings-win32-x64-msvc": "0.12.73" } }, "sha512-ZxkCQlsfTG7Eqi6KBXulEWJBUxeRVRYqCc795CsmKsGfqmBX5gE5CnTiynN8fFdzSGwApaVogeGSB6JszCf/Xw=="], + + "@livekit/rtc-ffi-bindings-darwin-arm64": ["@livekit/rtc-ffi-bindings-darwin-arm64@0.12.73", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gIGftVyO1Jl266AZPRzoL6dxck969dmIiaut3+D1aCZRkDb/gLz98BEGRfOSU3X0LS4QBkmiaYWsjN9ot/zmwg=="], + + "@livekit/rtc-ffi-bindings-darwin-x64": ["@livekit/rtc-ffi-bindings-darwin-x64@0.12.73", "", { "os": "darwin", "cpu": "x64" }, "sha512-esPwYCbUnDD/KxBo0YU8awTFglRG7SvOT/sfiP19KChS5SH8TgXc7JwfSvnjRx1PndUqHFzQAbQDcYaeL9PFTg=="], + + "@livekit/rtc-ffi-bindings-linux-arm64-gnu": ["@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.73", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YBatXjuaumnaNVqRC63flX5XZTvHcQWRsABw6e2yp+Hlxl3ocjVVyk5PrAsRzCdeyxbHLduqV9ctqGbpTA27Q=="], + + "@livekit/rtc-ffi-bindings-linux-x64-gnu": ["@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.73", "", { "os": "linux", "cpu": "x64" }, "sha512-BrP3IyNkcjldeO+SbnF0hX2mipst8aHiBIiGJcQCd8HEjsaabfkYk4Y5uoNsTVMrUSyrNrJte2itAhuxP9FCSA=="], + + "@livekit/rtc-ffi-bindings-win32-x64-msvc": ["@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.73", "", { "os": "win32", "cpu": "x64" }, "sha512-2RnKXMTnNfvjJ3kbuhE1jqFNAQNmqSrxy3GlEnByY61tbtY14QPFn8X/0lh+Mjst8pZdfxkXvtbRyPt5KYHvMA=="], + + "@livekit/rtc-node": ["@livekit/rtc-node@1.1.0", "", { "dependencies": { "@datastructures-js/deque": "1.0.8", "@livekit/mutex": "^1.0.0", "@livekit/rtc-ffi-bindings": "0.12.73", "@livekit/typed-emitter": "^3.0.0", "pino": "^9.0.0", "pino-pretty": "^13.0.0" } }, "sha512-KFNvPz7qQSJBTttDukanhMJ+bCNxj6O3A3fXl6GJvOEamJJk7GqKCWgAdLW3JLjuT1pPQDyQPJJnYdR25N+MXA=="], + + "@livekit/typed-emitter": ["@livekit/typed-emitter@3.0.0", "", {}, "sha512-9bl0k4MgBPZu3Qu3R3xy12rmbW17e3bE9yf4YY85gJIQ3ezLEj/uzpKHWBsLaDoL5Mozz8QCgggwIBudYQWeQg=="], + "@lydell/node-pty": ["@lydell/node-pty@1.2.0-beta.15", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.2.0-beta.15", "@lydell/node-pty-darwin-x64": "1.2.0-beta.15", "@lydell/node-pty-linux-arm64": "1.2.0-beta.15", "@lydell/node-pty-linux-x64": "1.2.0-beta.15", "@lydell/node-pty-win32-arm64": "1.2.0-beta.15", "@lydell/node-pty-win32-x64": "1.2.0-beta.15" } }, "sha512-Br8wBxzbxFwdWgk9uQ+rdzE0xfoxOK4QuGH54swhRwc5IxP6H9Y1/bcyazRGvNUs6XkB5qNVkezuKSRxUwZe7A=="], "@lydell/node-pty-darwin-arm64": ["@lydell/node-pty-darwin-arm64@1.2.0-beta.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6TSBbzdcLiNTHl1mTuzflqXrkmcC36USVGvERoDgvHk2ItEDaMaFZuAJ1CqPmwYj0DyhCS16TVS8OGK9xZnjyQ=="], @@ -1021,6 +1058,8 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.74.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VTC9IYTIMrVUk/i6Ms1ohzzDKZFkWn0KU2OBbPBzgmVZ2V30165T/zK4LztTr0Xgp9fZ1qQZ1rsZAu/rEmySlA=="], + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@playwright/test": ["@playwright/test@1.63.0", "", { "dependencies": { "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ=="], @@ -1551,6 +1590,8 @@ "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], + "adm-zip": ["adm-zip@0.5.18", "", {}, "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], @@ -1581,6 +1622,8 @@ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + "autoprefixer": ["autoprefixer@10.4.20", "", { "dependencies": { "browserslist": "^4.23.3", "caniuse-lite": "^1.0.30001646", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.0.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g=="], "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], @@ -1601,6 +1644,8 @@ "boolbase": ["boolbase@2.0.0", "", {}, "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA=="], + "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], + "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], "bowser": ["bowser@2.11.0", "", {}, "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA=="], @@ -1657,6 +1702,8 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], @@ -1699,6 +1746,8 @@ "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], + "db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="], "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], @@ -1709,6 +1758,10 @@ "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], @@ -1717,6 +1770,8 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], @@ -1759,6 +1814,8 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], @@ -1777,6 +1834,8 @@ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], @@ -1785,7 +1844,7 @@ "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], @@ -1821,10 +1880,14 @@ "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], + "fast-copy": ["fast-copy@4.1.1", "", {}, "sha512-A4QTJmuiztpGtr6AMeJts9R4hbj2ZBUwtOaKrG6rw2y7t6+IaJKjz5M3XDs8BUznxDH43FVc6A0y/gWlMl4UtA=="], + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], @@ -1897,6 +1960,10 @@ "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], "google-auth-library": ["google-auth-library@10.6.2", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw=="], @@ -1919,6 +1986,8 @@ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], @@ -1929,6 +1998,8 @@ "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + "help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], + "highlight.js": ["highlight.js@11.12.0", "", {}, "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg=="], "hono": ["hono@4.12.25", "", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="], @@ -2021,6 +2092,8 @@ "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], @@ -2035,6 +2108,8 @@ "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], @@ -2119,6 +2194,8 @@ "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], @@ -2279,6 +2356,8 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], "ocache": ["ocache@0.1.5", "", { "dependencies": { "ohash": "^2.0.11" } }, "sha512-kNNnkkVQup/QDvmTz8Q84wc2ntiyoVHDxa6eHWKt5qdGAmFRBIxy83rxgCYEjW0x06UJ9E3P6VgM2yY4rOBH4w=="], @@ -2287,10 +2366,16 @@ "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "onnxruntime-common": ["onnxruntime-common@1.24.3", "", {}, "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA=="], + + "onnxruntime-node": ["onnxruntime-node@1.24.3", "", { "dependencies": { "adm-zip": "^0.5.16", "global-agent": "^3.0.0", "onnxruntime-common": "1.24.3" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg=="], + "openai": ["openai@7.1.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "ws", "zod"] }, "sha512-7xWJ9iO5z5u1dnIUGwoUmZHkSyrUYXX2cUxo2E/26iKFrSC8IdEak7z94d5UntU7z+S/Cid33hYymwMSab2fZQ=="], "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], @@ -2347,6 +2432,14 @@ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="], + + "pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="], + + "pino-pretty": ["pino-pretty@13.2.0", "", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^4.0.0", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.2", "pino-abstract-transport": "^3.0.0", "pump": "^3.0.3", "secure-json-parse": "^4.1.0", "sonic-boom": "^5.0.0", "strip-json-comments": "^5.0.3" }, "bin": { "pino-pretty": "bin.js" } }, "sha512-jjghD1ML+Di/WxjJDz6j6Y4FT7hIXOlb8h27AyJ+ohtAXJXH4PS/xkv7si+RCCIW59HMgZsMKTDgzdr0RhuRrg=="], + + "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], "playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="], @@ -2379,6 +2472,8 @@ "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "process-warning": ["process-warning@5.1.0", "", {}, "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw=="], + "promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="], "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], @@ -2391,12 +2486,16 @@ "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], "qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="], + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + "quickjs-wasi": ["quickjs-wasi@3.6.0", "", {}, "sha512-/CNzMq42B8ThzUpzjLSF8K50ntVLV3RREyT8zr4wHaP2CZSHFhPkqg3E1GULmTerehf8Bo+UguQ+4LoOMCTC6A=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], @@ -2417,6 +2516,8 @@ "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], @@ -2447,6 +2548,8 @@ "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], + "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], + "rolldown": ["rolldown@1.2.0", "", { "dependencies": { "@oxc-project/types": "=0.140.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.0", "@rolldown/binding-darwin-arm64": "1.2.0", "@rolldown/binding-darwin-x64": "1.2.0", "@rolldown/binding-freebsd-x64": "1.2.0", "@rolldown/binding-linux-arm-gnueabihf": "1.2.0", "@rolldown/binding-linux-arm64-gnu": "1.2.0", "@rolldown/binding-linux-arm64-musl": "1.2.0", "@rolldown/binding-linux-ppc64-gnu": "1.2.0", "@rolldown/binding-linux-s390x-gnu": "1.2.0", "@rolldown/binding-linux-x64-gnu": "1.2.0", "@rolldown/binding-linux-x64-musl": "1.2.0", "@rolldown/binding-openharmony-arm64": "1.2.0", "@rolldown/binding-wasm32-wasi": "1.2.0", "@rolldown/binding-win32-arm64-msvc": "1.2.0", "@rolldown/binding-win32-x64-msvc": "1.2.0" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-u7tgm5l4Yw1iTqUL4EcYOAt7fFvCgQMLeidrnD4GALlC6aOznCjezYajgxeyKw27u0Q5N7fwgCzjVyPIWzwuBA=="], "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], @@ -2457,16 +2560,24 @@ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], + "selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="], "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], + "seroval": ["seroval@1.5.6", "", {}, "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA=="], "seroval-plugins": ["seroval-plugins@1.5.6", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ=="], @@ -2501,6 +2612,8 @@ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -2509,6 +2622,10 @@ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + "sqlite-vec": ["sqlite-vec@0.1.9", "", { "optionalDependencies": { "sqlite-vec-darwin-arm64": "0.1.9", "sqlite-vec-darwin-x64": "0.1.9", "sqlite-vec-linux-arm64": "0.1.9", "sqlite-vec-linux-x64": "0.1.9", "sqlite-vec-windows-x64": "0.1.9" } }, "sha512-L7XJWRIBNvR9O5+vh1FQ+IGkh/3D2AzVksW5gdtk28m78Hy8skFD0pqReKH1Yp0/BUKRGcffgKvyO/EON5JXpA=="], "sqlite-vec-darwin-arm64": ["sqlite-vec-darwin-arm64@0.1.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jSsZpE42OfBkGL/ItyJTVCUwl6o6Ka3U5rc4j+UBDIQzC1ulSSKMEhQLthsOnF/MdAf1MuAkYhkdKmmcjaIZQg=="], @@ -2545,6 +2662,8 @@ "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], + "strnum": ["strnum@1.0.5", "", {}, "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA=="], "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], @@ -2563,6 +2682,8 @@ "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], + "thread-stream": ["thread-stream@3.2.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], @@ -2595,6 +2716,8 @@ "tslog": ["tslog@4.11.0", "", {}, "sha512-gBe0FTKkRpOv3DenGipjQWQe073jNd1cOLj3nYfVM0/NP29sQzAIx6kfyfTTsQOiWMC+B+tSr289lNXtu7HCmQ=="], + "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], "typebox": ["typebox@1.3.18", "", {}, "sha512-/wYPoDqxWZSxV/XD8Eskzr3YluXC9CaWJOuUYMkj+lLVLkyeEIQKzHvMuS/IRc3OLTIBC32LAtHgXo/WFEOMHQ=="], @@ -2823,6 +2946,8 @@ "@livekit/protocol/@bufbuild/protobuf": ["@bufbuild/protobuf@1.10.1", "", {}, "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ=="], + "@livekit/rtc-ffi-bindings/@bufbuild/protobuf": ["@bufbuild/protobuf@1.10.1", "", {}, "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ=="], + "@mistralai/mistralai/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], "@openclaw/ai/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.120.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-ZlvmNFT/iIF6JD13rxbbMWD8nvGR0RaUp6yMQnoc+4Af0YjVVe/bIdW1XSQQsoxXAtg1NaT6Vak0LKFlJ4d37Q=="], @@ -3037,6 +3162,8 @@ "make-dir/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "minizlib/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], "motion/framer-motion": ["framer-motion@12.23.22", "", { "dependencies": { "motion-dom": "^12.23.21", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-ZgGvdxXCw55ZYvhoZChTlG6pUuehecgvEAJz0BHoC5pQKW1EC5xf1Mul1ej5+ai+pVY0pylyFfdl45qnM1/GsA=="], @@ -3065,6 +3192,10 @@ "path-scurry/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "pino-pretty/pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], + + "pino-pretty/sonic-boom": ["sonic-boom@5.0.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-I1E9tHuKe6SdS/Uab4bl2o1ZUBm2SCTDFF9eQ66ehyVcFIBixfecy9DlwXCtnRF8CUzPw7Q4Dn6JQE/8iVl/FQ=="], + "playwright/playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], "postcss/nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], diff --git a/crates/client/src/sdk_backend.rs b/crates/client/src/sdk_backend.rs index 602ce5e59..208ebaccc 100644 --- a/crates/client/src/sdk_backend.rs +++ b/crates/client/src/sdk_backend.rs @@ -9615,6 +9615,7 @@ mod tests { block_content: None, agent_session: None, subthread: None, + counts_as_unread: None, }), })), }], diff --git a/crates/protocol/proto/core.proto b/crates/protocol/proto/core.proto index de615d967..03a50db04 100644 --- a/crates/protocol/proto/core.proto +++ b/crates/protocol/proto/core.proto @@ -439,6 +439,7 @@ message MessageService { oneof event { MessageServiceThreadBacklink thread_backlink = 1; MessageServicePinnedMessage pinned_message = 2; + MessageServiceGridTranscript grid_transcript = 3; } } @@ -452,6 +453,21 @@ message MessageServicePinnedMessage { optional int64 message_id = 1; } +enum GridTranscriptMessageKind { + GRID_TRANSCRIPT_TURN = 0; + GRID_TRANSCRIPT_STARTED = 1; + GRID_TRANSCRIPT_STOPPED = 2; + GRID_TRANSCRIPT_INTERRUPTED = 3; + GRID_TRANSCRIPT_LINK = 4; +} + +message MessageServiceGridTranscript { + string run_id = 1; + string segment_id = 2; + optional int64 speaker_user_id = 3; + GridTranscriptMessageKind kind = 4; +} + // Structural rich content projected alongside Message.message and // Message.entities. Text-bearing blocks use UTF-16 ranges into Message.message; // inline entities remain owned by Message.entities so there is one text/entity @@ -677,6 +693,9 @@ message Message { // Canonical card projection for anchored replies and materialized subthreads. optional MessageSubthread subthread = 25; + + // Explicit false suppresses unread for generated/historical rows. + optional bool counts_as_unread = 26; } // Agent providers that implement Inline's session continuity contract. The @@ -1602,6 +1621,10 @@ enum Method { REQUEST_BOT_FILESYSTEM = 143; ANSWER_BOT_FILESYSTEM = 144; SET_SPACE_PHOTO = 145; + MOVE_GRID_CALL_HERE = 146; + OPEN_GRID_THREAD = 147; + SET_GRID_TRANSCRIPTION = 148; + LIST_GRID_TRANSCRIPTS = 149; } message RpcCall { @@ -1752,6 +1775,10 @@ message RpcCall { RequestBotFilesystemInput requestBotFilesystem = 144; AnswerBotFilesystemInput answerBotFilesystem = 145; SetSpacePhotoInput setSpacePhoto = 146; + MoveGridCallHereInput moveGridCallHere = 147; + OpenGridThreadInput openGridThread = 148; + SetGridTranscriptionInput setGridTranscription = 149; + ListGridTranscriptsInput listGridTranscripts = 150; } } @@ -1904,6 +1931,10 @@ message RpcResult { RequestBotFilesystemResult requestBotFilesystem = 144; AnswerBotFilesystemResult answerBotFilesystem = 145; SetSpacePhotoResult setSpacePhoto = 146; + MoveGridCallHereResult moveGridCallHere = 147; + OpenGridThreadResult openGridThread = 148; + SetGridTranscriptionResult setGridTranscription = 149; + ListGridTranscriptsResult listGridTranscripts = 150; } } @@ -2078,7 +2109,57 @@ message GridRoom { int64 updated_at = 7; repeated GridAvatar avatars = 8; optional GridConnection connection = 9; + optional int64 room_thread_id = 10; + bool transcription_available = 11; + optional GridTranscription transcription = 12; +} + +enum GridTranscriptionState { + GRID_TRANSCRIPTION_UNSPECIFIED = 0; + GRID_TRANSCRIPTION_STARTING = 1; + GRID_TRANSCRIPTION_ACTIVE = 2; + GRID_TRANSCRIPTION_STOPPING = 3; + GRID_TRANSCRIPTION_STOPPED = 4; + GRID_TRANSCRIPTION_INTERRUPTED = 5; +} + +message GridTranscription { + string run_id = 1; + GridTranscriptionState state = 2; + optional int64 transcript_chat_id = 3; + int32 revision = 4; +} + +enum GridTranscriptDestination { + GRID_TRANSCRIPT_LAST = 0; + GRID_TRANSCRIPT_NEW = 1; + GRID_TRANSCRIPT_EXISTING = 2; +} + +message OpenGridThreadInput { + int64 room_id = 1; + string expected_membership_id = 2; +} +message OpenGridThreadResult { int64 chat_id = 1; repeated Update updates = 2; } +message SetGridTranscriptionInput { + int64 room_id = 1; + string expected_membership_id = 2; + int32 expected_generation = 3; + optional string expected_run_id = 4; + int32 expected_revision = 5; + bool enabled = 6; + GridTranscriptDestination destination = 7; + optional int64 transcript_chat_id = 8; + string request_id = 9; +} +message SetGridTranscriptionResult { Grid grid = 1; repeated Update updates = 2; } +message ListGridTranscriptsInput { int64 room_id = 1; } +message GridTranscriptDestinationInfo { + int64 transcript_chat_id = 1; + string title = 2; + bool busy = 3; } +message ListGridTranscriptsResult { repeated GridTranscriptDestinationInfo transcripts = 1; } // Replaceable current state for one Space's implicit Grid. message Grid { @@ -2089,6 +2170,22 @@ message Grid { // Server-owned monotonic revision for this Space's complete Grid snapshot. // Clients must not replace a newer snapshot with a lower revision. int64 revision = 5; + // Effective server capability, including ownership fence enforcement. + // New clients must not admit media through an older/disabled server. + bool call_transfer_enabled = 6; +} + +// Personalized control ownership, disclosed only to the authenticated user. +// call_id stays stable across an explicit device transfer; membership_id is +// replaced on each transfer/resume and fences commands and media credentials. +message GridCurrentCall { + string call_id = 1; + int64 space_id = 2; + int64 room_id = 3; + string membership_id = 4; + bool owned_by_current_session = 5; + // Coarse existing session client kind (ios, macos, etc.), not a device ID. + string owner_client_type = 6; } // Short-lived, session-targeted LiveKit credentials. Never persist or include @@ -2099,11 +2196,21 @@ message GridConnectionCredentials { string participant_identity = 3; string token = 4; int64 expires_at = 5; + string call_id = 6; + string membership_id = 7; } -message GetGridInput { int64 space_id = 1; } +message GetGridInput { + int64 space_id = 1; + // Explicit renewal by the admitted owner. Omit for read-only browsing when + // call-transfer enforcement is active. A stale fence never renews presence. + optional string expected_membership_id = 2; +} -message GetGridResult { Grid grid = 1; } +message GetGridResult { + Grid grid = 1; + optional GridCurrentCall current_call = 2; +} // Home-level discovery for every Grid-enabled Space the current user can see. // This is a bounded summary: full rooms remain available only through GetGrid. @@ -2116,31 +2223,66 @@ message GridHomeSpace { message GetGridHomeInput {} -message GetGridHomeResult { repeated GridHomeSpace spaces = 1; } +message GetGridHomeResult { + repeated GridHomeSpace spaces = 1; + optional GridCurrentCall current_call = 2; + bool call_transfer_enabled = 3; +} message CreateGridRoomInput { int64 space_id = 1; optional bool microphone_enabled = 2; + // Required when call_transfer_enabled: empty expects no active presence; + // nonempty must match the current session's live membership exactly. + optional string expected_membership_id = 3; } message CreateGridRoomResult { repeated Grid grids = 1; optional GridConnectionCredentials connection = 2; + // Successful claims report the projection captured at commit. A newer + // accepted Grid/self state wins; this field alone cannot admit media. + optional GridCurrentCall current_call = 3; } message JoinGridRoomInput { int64 room_id = 1; optional bool microphone_enabled = 2; + optional string expected_membership_id = 3; } message JoinGridRoomResult { repeated Grid grids = 1; optional GridConnectionCredentials connection = 2; + optional GridCurrentCall current_call = 3; } -message LeaveGridRoomInput { int64 expected_room_id = 1; } +message LeaveGridRoomInput { + int64 expected_room_id = 1; + optional string expected_membership_id = 2; +} -message LeaveGridRoomResult { repeated Grid grids = 1; } +message LeaveGridRoomResult { + repeated Grid grids = 1; + // Display/repair only; leaving never grants a replacement admission. + optional GridCurrentCall current_call = 2; +} + +// Explicitly move this user's existing room participation to the authenticated +// calling session (also used for a same-login cold-process Resume). +message MoveGridCallHereInput { + string call_id = 1; + string expected_membership_id = 2; +} + +message MoveGridCallHereResult { + repeated Grid grids = 1; + optional GridConnectionCredentials connection = 2; + // Captured at the claim's commit when moved=true; display/repair otherwise. + optional GridCurrentCall current_call = 3; + // False means the expected call/membership changed; no claim was performed. + bool moved = 4; +} message SetGridRoomTitleInput { int64 room_id = 1; @@ -2164,6 +2306,7 @@ message DeleteGridRoomResult { Grid grid = 1; } message PrepareGridConnectionInput { int64 room_id = 1; int32 generation = 2; + optional string expected_membership_id = 3; } enum GridConnectionUnavailableReason { @@ -2180,6 +2323,7 @@ message PrepareGridConnectionResult { message SetGridAvatarMicrophoneEnabledInput { int64 expected_room_id = 1; bool enabled = 2; + optional string expected_membership_id = 3; } message SetGridAvatarMicrophoneEnabledResult { bool enabled = 1; } diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 1e66d47a7..eb5c409a7 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -39,6 +39,10 @@ export interface MessageContentPayload { * @generated from protobuf field: MessageSubthread subthread = 5; */ subthread?: MessageSubthread; + /** + * @generated from protobuf field: optional bool counts_as_unread = 6; + */ + countsAsUnread?: boolean; } /** * @generated from protobuf message client.MessageVoiceContent @@ -102,7 +106,8 @@ class MessageContentPayload$Type extends MessageType { { no: 2, name: "actions", kind: "message", T: () => MessageActions }, { no: 3, name: "replies", kind: "message", T: () => MessageReplies }, { no: 4, name: "service_message", kind: "message", T: () => MessageService }, - { no: 5, name: "subthread", kind: "message", T: () => MessageSubthread } + { no: 5, name: "subthread", kind: "message", T: () => MessageSubthread }, + { no: 6, name: "counts_as_unread", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ } ]); } create(value?: PartialMessage): MessageContentPayload { @@ -131,6 +136,9 @@ class MessageContentPayload$Type extends MessageType { case /* MessageSubthread subthread */ 5: message.subthread = MessageSubthread.internalBinaryRead(reader, reader.uint32(), options, message.subthread); break; + case /* optional bool counts_as_unread */ 6: + message.countsAsUnread = reader.bool(); + break; default: let u = options.readUnknownField; if (u === "throw") @@ -158,6 +166,9 @@ class MessageContentPayload$Type extends MessageType { /* MessageSubthread subthread = 5; */ if (message.subthread) MessageSubthread.internalBinaryWrite(message.subthread, writer.tag(5, WireType.LengthDelimited).fork(), options).join(); + /* optional bool counts_as_unread = 6; */ + if (message.countsAsUnread !== undefined) + writer.tag(6, WireType.Varint).bool(message.countsAsUnread); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); diff --git a/packages/protocol/src/core.ts b/packages/protocol/src/core.ts index 40783e92b..1674b4e65 100644 --- a/packages/protocol/src/core.ts +++ b/packages/protocol/src/core.ts @@ -1183,6 +1183,12 @@ export interface MessageService { * @generated from protobuf field: MessageServicePinnedMessage pinned_message = 2; */ pinnedMessage: MessageServicePinnedMessage; + } | { + oneofKind: "gridTranscript"; + /** + * @generated from protobuf field: MessageServiceGridTranscript grid_transcript = 3; + */ + gridTranscript: MessageServiceGridTranscript; } | { oneofKind: undefined; }; @@ -1211,6 +1217,27 @@ export interface MessageServicePinnedMessage { */ messageId?: bigint; } +/** + * @generated from protobuf message MessageServiceGridTranscript + */ +export interface MessageServiceGridTranscript { + /** + * @generated from protobuf field: string run_id = 1; + */ + runId: string; + /** + * @generated from protobuf field: string segment_id = 2; + */ + segmentId: string; + /** + * @generated from protobuf field: optional int64 speaker_user_id = 3; + */ + speakerUserId?: bigint; + /** + * @generated from protobuf field: GridTranscriptMessageKind kind = 4; + */ + kind: GridTranscriptMessageKind; +} /** * Structural rich content projected alongside Message.message and * Message.entities. Text-bearing blocks use UTF-16 ranges into Message.message; @@ -1822,6 +1849,12 @@ export interface Message { * @generated from protobuf field: optional MessageSubthread subthread = 25; */ subthread?: MessageSubthread; + /** + * Explicit false suppresses unread for generated/historical rows. + * + * @generated from protobuf field: optional bool counts_as_unread = 26; + */ + countsAsUnread?: boolean; } /** * @generated from protobuf message AgentSessionMessageInfo @@ -4573,6 +4606,30 @@ export interface RpcCall { * @generated from protobuf field: SetSpacePhotoInput setSpacePhoto = 146; */ setSpacePhoto: SetSpacePhotoInput; + } | { + oneofKind: "moveGridCallHere"; + /** + * @generated from protobuf field: MoveGridCallHereInput moveGridCallHere = 147; + */ + moveGridCallHere: MoveGridCallHereInput; + } | { + oneofKind: "openGridThread"; + /** + * @generated from protobuf field: OpenGridThreadInput openGridThread = 148; + */ + openGridThread: OpenGridThreadInput; + } | { + oneofKind: "setGridTranscription"; + /** + * @generated from protobuf field: SetGridTranscriptionInput setGridTranscription = 149; + */ + setGridTranscription: SetGridTranscriptionInput; + } | { + oneofKind: "listGridTranscripts"; + /** + * @generated from protobuf field: ListGridTranscriptsInput listGridTranscripts = 150; + */ + listGridTranscripts: ListGridTranscriptsInput; } | { oneofKind: undefined; }; @@ -5452,6 +5509,30 @@ export interface RpcResult { * @generated from protobuf field: SetSpacePhotoResult setSpacePhoto = 146; */ setSpacePhoto: SetSpacePhotoResult; + } | { + oneofKind: "moveGridCallHere"; + /** + * @generated from protobuf field: MoveGridCallHereResult moveGridCallHere = 147; + */ + moveGridCallHere: MoveGridCallHereResult; + } | { + oneofKind: "openGridThread"; + /** + * @generated from protobuf field: OpenGridThreadResult openGridThread = 148; + */ + openGridThread: OpenGridThreadResult; + } | { + oneofKind: "setGridTranscription"; + /** + * @generated from protobuf field: SetGridTranscriptionResult setGridTranscription = 149; + */ + setGridTranscription: SetGridTranscriptionResult; + } | { + oneofKind: "listGridTranscripts"; + /** + * @generated from protobuf field: ListGridTranscriptsResult listGridTranscripts = 150; + */ + listGridTranscripts: ListGridTranscriptsResult; } | { oneofKind: undefined; }; @@ -5885,6 +5966,154 @@ export interface GridRoom { * @generated from protobuf field: optional GridConnection connection = 9; */ connection?: GridConnection; + /** + * @generated from protobuf field: optional int64 room_thread_id = 10; + */ + roomThreadId?: bigint; + /** + * @generated from protobuf field: bool transcription_available = 11; + */ + transcriptionAvailable: boolean; + /** + * @generated from protobuf field: optional GridTranscription transcription = 12; + */ + transcription?: GridTranscription; +} +/** + * @generated from protobuf message GridTranscription + */ +export interface GridTranscription { + /** + * @generated from protobuf field: string run_id = 1; + */ + runId: string; + /** + * @generated from protobuf field: GridTranscriptionState state = 2; + */ + state: GridTranscriptionState; + /** + * @generated from protobuf field: optional int64 transcript_chat_id = 3; + */ + transcriptChatId?: bigint; + /** + * @generated from protobuf field: int32 revision = 4; + */ + revision: number; +} +/** + * @generated from protobuf message OpenGridThreadInput + */ +export interface OpenGridThreadInput { + /** + * @generated from protobuf field: int64 room_id = 1; + */ + roomId: bigint; + /** + * @generated from protobuf field: string expected_membership_id = 2; + */ + expectedMembershipId: string; +} +/** + * @generated from protobuf message OpenGridThreadResult + */ +export interface OpenGridThreadResult { + /** + * @generated from protobuf field: int64 chat_id = 1; + */ + chatId: bigint; + /** + * @generated from protobuf field: repeated Update updates = 2; + */ + updates: Update[]; +} +/** + * @generated from protobuf message SetGridTranscriptionInput + */ +export interface SetGridTranscriptionInput { + /** + * @generated from protobuf field: int64 room_id = 1; + */ + roomId: bigint; + /** + * @generated from protobuf field: string expected_membership_id = 2; + */ + expectedMembershipId: string; + /** + * @generated from protobuf field: int32 expected_generation = 3; + */ + expectedGeneration: number; + /** + * @generated from protobuf field: optional string expected_run_id = 4; + */ + expectedRunId?: string; + /** + * @generated from protobuf field: int32 expected_revision = 5; + */ + expectedRevision: number; + /** + * @generated from protobuf field: bool enabled = 6; + */ + enabled: boolean; + /** + * @generated from protobuf field: GridTranscriptDestination destination = 7; + */ + destination: GridTranscriptDestination; + /** + * @generated from protobuf field: optional int64 transcript_chat_id = 8; + */ + transcriptChatId?: bigint; + /** + * @generated from protobuf field: string request_id = 9; + */ + requestId: string; +} +/** + * @generated from protobuf message SetGridTranscriptionResult + */ +export interface SetGridTranscriptionResult { + /** + * @generated from protobuf field: Grid grid = 1; + */ + grid?: Grid; + /** + * @generated from protobuf field: repeated Update updates = 2; + */ + updates: Update[]; +} +/** + * @generated from protobuf message ListGridTranscriptsInput + */ +export interface ListGridTranscriptsInput { + /** + * @generated from protobuf field: int64 room_id = 1; + */ + roomId: bigint; +} +/** + * @generated from protobuf message GridTranscriptDestinationInfo + */ +export interface GridTranscriptDestinationInfo { + /** + * @generated from protobuf field: int64 transcript_chat_id = 1; + */ + transcriptChatId: bigint; + /** + * @generated from protobuf field: string title = 2; + */ + title: string; + /** + * @generated from protobuf field: bool busy = 3; + */ + busy: boolean; +} +/** + * @generated from protobuf message ListGridTranscriptsResult + */ +export interface ListGridTranscriptsResult { + /** + * @generated from protobuf field: repeated GridTranscriptDestinationInfo transcripts = 1; + */ + transcripts: GridTranscriptDestinationInfo[]; } /** * Replaceable current state for one Space's implicit Grid. @@ -5915,6 +6144,48 @@ export interface Grid { * @generated from protobuf field: int64 revision = 5; */ revision: bigint; + /** + * Effective server capability, including ownership fence enforcement. + * New clients must not admit media through an older/disabled server. + * + * @generated from protobuf field: bool call_transfer_enabled = 6; + */ + callTransferEnabled: boolean; +} +/** + * Personalized control ownership, disclosed only to the authenticated user. + * call_id stays stable across an explicit device transfer; membership_id is + * replaced on each transfer/resume and fences commands and media credentials. + * + * @generated from protobuf message GridCurrentCall + */ +export interface GridCurrentCall { + /** + * @generated from protobuf field: string call_id = 1; + */ + callId: string; + /** + * @generated from protobuf field: int64 space_id = 2; + */ + spaceId: bigint; + /** + * @generated from protobuf field: int64 room_id = 3; + */ + roomId: bigint; + /** + * @generated from protobuf field: string membership_id = 4; + */ + membershipId: string; + /** + * @generated from protobuf field: bool owned_by_current_session = 5; + */ + ownedByCurrentSession: boolean; + /** + * Coarse existing session client kind (ios, macos, etc.), not a device ID. + * + * @generated from protobuf field: string owner_client_type = 6; + */ + ownerClientType: string; } /** * Short-lived, session-targeted LiveKit credentials. Never persist or include @@ -5943,6 +6214,14 @@ export interface GridConnectionCredentials { * @generated from protobuf field: int64 expires_at = 5; */ expiresAt: bigint; + /** + * @generated from protobuf field: string call_id = 6; + */ + callId: string; + /** + * @generated from protobuf field: string membership_id = 7; + */ + membershipId: string; } /** * @generated from protobuf message GetGridInput @@ -5952,6 +6231,13 @@ export interface GetGridInput { * @generated from protobuf field: int64 space_id = 1; */ spaceId: bigint; + /** + * Explicit renewal by the admitted owner. Omit for read-only browsing when + * call-transfer enforcement is active. A stale fence never renews presence. + * + * @generated from protobuf field: optional string expected_membership_id = 2; + */ + expectedMembershipId?: string; } /** * @generated from protobuf message GetGridResult @@ -5961,6 +6247,10 @@ export interface GetGridResult { * @generated from protobuf field: Grid grid = 1; */ grid?: Grid; + /** + * @generated from protobuf field: optional GridCurrentCall current_call = 2; + */ + currentCall?: GridCurrentCall; } /** * Home-level discovery for every Grid-enabled Space the current user can see. @@ -5999,6 +6289,14 @@ export interface GetGridHomeResult { * @generated from protobuf field: repeated GridHomeSpace spaces = 1; */ spaces: GridHomeSpace[]; + /** + * @generated from protobuf field: optional GridCurrentCall current_call = 2; + */ + currentCall?: GridCurrentCall; + /** + * @generated from protobuf field: bool call_transfer_enabled = 3; + */ + callTransferEnabled: boolean; } /** * @generated from protobuf message CreateGridRoomInput @@ -6012,6 +6310,13 @@ export interface CreateGridRoomInput { * @generated from protobuf field: optional bool microphone_enabled = 2; */ microphoneEnabled?: boolean; + /** + * Required when call_transfer_enabled: empty expects no active presence; + * nonempty must match the current session's live membership exactly. + * + * @generated from protobuf field: optional string expected_membership_id = 3; + */ + expectedMembershipId?: string; } /** * @generated from protobuf message CreateGridRoomResult @@ -6025,6 +6330,13 @@ export interface CreateGridRoomResult { * @generated from protobuf field: optional GridConnectionCredentials connection = 2; */ connection?: GridConnectionCredentials; + /** + * Successful claims report the projection captured at commit. A newer + * accepted Grid/self state wins; this field alone cannot admit media. + * + * @generated from protobuf field: optional GridCurrentCall current_call = 3; + */ + currentCall?: GridCurrentCall; } /** * @generated from protobuf message JoinGridRoomInput @@ -6038,6 +6350,10 @@ export interface JoinGridRoomInput { * @generated from protobuf field: optional bool microphone_enabled = 2; */ microphoneEnabled?: boolean; + /** + * @generated from protobuf field: optional string expected_membership_id = 3; + */ + expectedMembershipId?: string; } /** * @generated from protobuf message JoinGridRoomResult @@ -6051,6 +6367,10 @@ export interface JoinGridRoomResult { * @generated from protobuf field: optional GridConnectionCredentials connection = 2; */ connection?: GridConnectionCredentials; + /** + * @generated from protobuf field: optional GridCurrentCall current_call = 3; + */ + currentCall?: GridCurrentCall; } /** * @generated from protobuf message LeaveGridRoomInput @@ -6060,6 +6380,10 @@ export interface LeaveGridRoomInput { * @generated from protobuf field: int64 expected_room_id = 1; */ expectedRoomId: bigint; + /** + * @generated from protobuf field: optional string expected_membership_id = 2; + */ + expectedMembershipId?: string; } /** * @generated from protobuf message LeaveGridRoomResult @@ -6069,6 +6393,53 @@ export interface LeaveGridRoomResult { * @generated from protobuf field: repeated Grid grids = 1; */ grids: Grid[]; + /** + * Display/repair only; leaving never grants a replacement admission. + * + * @generated from protobuf field: optional GridCurrentCall current_call = 2; + */ + currentCall?: GridCurrentCall; +} +/** + * Explicitly move this user's existing room participation to the authenticated + * calling session (also used for a same-login cold-process Resume). + * + * @generated from protobuf message MoveGridCallHereInput + */ +export interface MoveGridCallHereInput { + /** + * @generated from protobuf field: string call_id = 1; + */ + callId: string; + /** + * @generated from protobuf field: string expected_membership_id = 2; + */ + expectedMembershipId: string; +} +/** + * @generated from protobuf message MoveGridCallHereResult + */ +export interface MoveGridCallHereResult { + /** + * @generated from protobuf field: repeated Grid grids = 1; + */ + grids: Grid[]; + /** + * @generated from protobuf field: optional GridConnectionCredentials connection = 2; + */ + connection?: GridConnectionCredentials; + /** + * Captured at the claim's commit when moved=true; display/repair otherwise. + * + * @generated from protobuf field: optional GridCurrentCall current_call = 3; + */ + currentCall?: GridCurrentCall; + /** + * False means the expected call/membership changed; no claim was performed. + * + * @generated from protobuf field: bool moved = 4; + */ + moved: boolean; } /** * @generated from protobuf message SetGridRoomTitleInput @@ -6146,6 +6517,10 @@ export interface PrepareGridConnectionInput { * @generated from protobuf field: int32 generation = 2; */ generation: number; + /** + * @generated from protobuf field: optional string expected_membership_id = 3; + */ + expectedMembershipId?: string; } /** * @generated from protobuf message PrepareGridConnectionResult @@ -6172,6 +6547,10 @@ export interface SetGridAvatarMicrophoneEnabledInput { * @generated from protobuf field: bool enabled = 2; */ enabled: boolean; + /** + * @generated from protobuf field: optional string expected_membership_id = 3; + */ + expectedMembershipId?: string; } /** * @generated from protobuf message SetGridAvatarMicrophoneEnabledResult @@ -13592,6 +13971,31 @@ export enum DialogFollowMode { */ UNFOLLOWED = 2 } +/** + * @generated from protobuf enum GridTranscriptMessageKind + */ +export enum GridTranscriptMessageKind { + /** + * @generated from protobuf enum value: GRID_TRANSCRIPT_TURN = 0; + */ + GRID_TRANSCRIPT_TURN = 0, + /** + * @generated from protobuf enum value: GRID_TRANSCRIPT_STARTED = 1; + */ + GRID_TRANSCRIPT_STARTED = 1, + /** + * @generated from protobuf enum value: GRID_TRANSCRIPT_STOPPED = 2; + */ + GRID_TRANSCRIPT_STOPPED = 2, + /** + * @generated from protobuf enum value: GRID_TRANSCRIPT_INTERRUPTED = 3; + */ + GRID_TRANSCRIPT_INTERRUPTED = 3, + /** + * @generated from protobuf enum value: GRID_TRANSCRIPT_LINK = 4; + */ + GRID_TRANSCRIPT_LINK = 4 +} /** * Agent providers that implement Inline's session continuity contract. The * numeric values are stored by the server; additions must be append-only. @@ -14337,47 +14741,109 @@ export enum Method { /** * @generated from protobuf enum value: SET_SPACE_PHOTO = 145; */ - SET_SPACE_PHOTO = 145 -} -/** - * @generated from protobuf enum GridConnectionUnavailableReason - */ -export enum GridConnectionUnavailableReason { + SET_SPACE_PHOTO = 145, /** - * @generated from protobuf enum value: GRID_CONNECTION_UNAVAILABLE_REASON_UNSPECIFIED = 0; + * @generated from protobuf enum value: MOVE_GRID_CALL_HERE = 146; */ - UNSPECIFIED = 0, + MOVE_GRID_CALL_HERE = 146, /** - * @generated from protobuf enum value: GRID_CONNECTION_UNAVAILABLE_REASON_NOT_ACTIVE = 1; + * @generated from protobuf enum value: OPEN_GRID_THREAD = 147; */ - NOT_ACTIVE = 1, + OPEN_GRID_THREAD = 147, /** - * @generated from protobuf enum value: GRID_CONNECTION_UNAVAILABLE_REASON_PROVIDER_UNAVAILABLE = 2; + * @generated from protobuf enum value: SET_GRID_TRANSCRIPTION = 148; */ - PROVIDER_UNAVAILABLE = 2 + SET_GRID_TRANSCRIPTION = 148, + /** + * @generated from protobuf enum value: LIST_GRID_TRANSCRIPTS = 149; + */ + LIST_GRID_TRANSCRIPTS = 149 } /** - * @generated from protobuf enum UsernameAvailability + * @generated from protobuf enum GridTranscriptionState */ -export enum UsernameAvailability { +export enum GridTranscriptionState { /** - * @generated from protobuf enum value: USERNAME_AVAILABILITY_UNSPECIFIED = 0; + * @generated from protobuf enum value: GRID_TRANSCRIPTION_UNSPECIFIED = 0; */ - USERNAME_AVAILABILITY_UNSPECIFIED = 0, + GRID_TRANSCRIPTION_UNSPECIFIED = 0, /** - * @generated from protobuf enum value: USERNAME_AVAILABLE = 1; + * @generated from protobuf enum value: GRID_TRANSCRIPTION_STARTING = 1; */ - USERNAME_AVAILABLE = 1, + GRID_TRANSCRIPTION_STARTING = 1, /** - * @generated from protobuf enum value: USERNAME_CURRENT = 2; + * @generated from protobuf enum value: GRID_TRANSCRIPTION_ACTIVE = 2; */ - USERNAME_CURRENT = 2, + GRID_TRANSCRIPTION_ACTIVE = 2, /** - * @generated from protobuf enum value: USERNAME_TAKEN = 3; + * @generated from protobuf enum value: GRID_TRANSCRIPTION_STOPPING = 3; */ - USERNAME_TAKEN = 3, + GRID_TRANSCRIPTION_STOPPING = 3, /** - * @generated from protobuf enum value: USERNAME_RESERVED = 4; + * @generated from protobuf enum value: GRID_TRANSCRIPTION_STOPPED = 4; + */ + GRID_TRANSCRIPTION_STOPPED = 4, + /** + * @generated from protobuf enum value: GRID_TRANSCRIPTION_INTERRUPTED = 5; + */ + GRID_TRANSCRIPTION_INTERRUPTED = 5 +} +/** + * @generated from protobuf enum GridTranscriptDestination + */ +export enum GridTranscriptDestination { + /** + * @generated from protobuf enum value: GRID_TRANSCRIPT_LAST = 0; + */ + GRID_TRANSCRIPT_LAST = 0, + /** + * @generated from protobuf enum value: GRID_TRANSCRIPT_NEW = 1; + */ + GRID_TRANSCRIPT_NEW = 1, + /** + * @generated from protobuf enum value: GRID_TRANSCRIPT_EXISTING = 2; + */ + GRID_TRANSCRIPT_EXISTING = 2 +} +/** + * @generated from protobuf enum GridConnectionUnavailableReason + */ +export enum GridConnectionUnavailableReason { + /** + * @generated from protobuf enum value: GRID_CONNECTION_UNAVAILABLE_REASON_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + /** + * @generated from protobuf enum value: GRID_CONNECTION_UNAVAILABLE_REASON_NOT_ACTIVE = 1; + */ + NOT_ACTIVE = 1, + /** + * @generated from protobuf enum value: GRID_CONNECTION_UNAVAILABLE_REASON_PROVIDER_UNAVAILABLE = 2; + */ + PROVIDER_UNAVAILABLE = 2 +} +/** + * @generated from protobuf enum UsernameAvailability + */ +export enum UsernameAvailability { + /** + * @generated from protobuf enum value: USERNAME_AVAILABILITY_UNSPECIFIED = 0; + */ + USERNAME_AVAILABILITY_UNSPECIFIED = 0, + /** + * @generated from protobuf enum value: USERNAME_AVAILABLE = 1; + */ + USERNAME_AVAILABLE = 1, + /** + * @generated from protobuf enum value: USERNAME_CURRENT = 2; + */ + USERNAME_CURRENT = 2, + /** + * @generated from protobuf enum value: USERNAME_TAKEN = 3; + */ + USERNAME_TAKEN = 3, + /** + * @generated from protobuf enum value: USERNAME_RESERVED = 4; */ USERNAME_RESERVED = 4, /** @@ -17519,7 +17985,8 @@ class MessageService$Type extends MessageType { constructor() { super("MessageService", [ { no: 1, name: "thread_backlink", kind: "message", oneof: "event", T: () => MessageServiceThreadBacklink }, - { no: 2, name: "pinned_message", kind: "message", oneof: "event", T: () => MessageServicePinnedMessage } + { no: 2, name: "pinned_message", kind: "message", oneof: "event", T: () => MessageServicePinnedMessage }, + { no: 3, name: "grid_transcript", kind: "message", oneof: "event", T: () => MessageServiceGridTranscript } ]); } create(value?: PartialMessage): MessageService { @@ -17546,6 +18013,12 @@ class MessageService$Type extends MessageType { pinnedMessage: MessageServicePinnedMessage.internalBinaryRead(reader, reader.uint32(), options, (message.event as any).pinnedMessage) }; break; + case /* MessageServiceGridTranscript grid_transcript */ 3: + message.event = { + oneofKind: "gridTranscript", + gridTranscript: MessageServiceGridTranscript.internalBinaryRead(reader, reader.uint32(), options, (message.event as any).gridTranscript) + }; + break; default: let u = options.readUnknownField; if (u === "throw") @@ -17564,6 +18037,9 @@ class MessageService$Type extends MessageType { /* MessageServicePinnedMessage pinned_message = 2; */ if (message.event.oneofKind === "pinnedMessage") MessageServicePinnedMessage.internalBinaryWrite(message.event.pinnedMessage, writer.tag(2, WireType.LengthDelimited).fork(), options).join(); + /* MessageServiceGridTranscript grid_transcript = 3; */ + if (message.event.oneofKind === "gridTranscript") + MessageServiceGridTranscript.internalBinaryWrite(message.event.gridTranscript, writer.tag(3, WireType.LengthDelimited).fork(), options).join(); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -17674,6 +18150,76 @@ class MessageServicePinnedMessage$Type extends MessageType { + constructor() { + super("MessageServiceGridTranscript", [ + { no: 1, name: "run_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "segment_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 3, name: "speaker_user_id", kind: "scalar", opt: true, T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }, + { no: 4, name: "kind", kind: "enum", T: () => ["GridTranscriptMessageKind", GridTranscriptMessageKind] } + ]); + } + create(value?: PartialMessage): MessageServiceGridTranscript { + const message = globalThis.Object.create((this.messagePrototype!)); + message.runId = ""; + message.segmentId = ""; + message.kind = 0; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: MessageServiceGridTranscript): MessageServiceGridTranscript { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* string run_id */ 1: + message.runId = reader.string(); + break; + case /* string segment_id */ 2: + message.segmentId = reader.string(); + break; + case /* optional int64 speaker_user_id */ 3: + message.speakerUserId = reader.int64().toBigInt(); + break; + case /* GridTranscriptMessageKind kind */ 4: + message.kind = reader.int32(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: MessageServiceGridTranscript, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* string run_id = 1; */ + if (message.runId !== "") + writer.tag(1, WireType.LengthDelimited).string(message.runId); + /* string segment_id = 2; */ + if (message.segmentId !== "") + writer.tag(2, WireType.LengthDelimited).string(message.segmentId); + /* optional int64 speaker_user_id = 3; */ + if (message.speakerUserId !== undefined) + writer.tag(3, WireType.Varint).int64(message.speakerUserId); + /* GridTranscriptMessageKind kind = 4; */ + if (message.kind !== 0) + writer.tag(4, WireType.Varint).int32(message.kind); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message MessageServiceGridTranscript + */ +export const MessageServiceGridTranscript = new MessageServiceGridTranscript$Type(); +// @generated message type with reflection information, may provide speed optimized methods class BlockContent$Type extends MessageType { constructor() { super("BlockContent", [ @@ -18759,7 +19305,8 @@ class Message$Type extends MessageType { { no: 22, name: "service_message", kind: "message", T: () => MessageService }, { no: 23, name: "block_content", kind: "message", T: () => BlockContent }, { no: 24, name: "agent_session", kind: "message", T: () => AgentSessionMessageInfo }, - { no: 25, name: "subthread", kind: "message", T: () => MessageSubthread } + { no: 25, name: "subthread", kind: "message", T: () => MessageSubthread }, + { no: 26, name: "counts_as_unread", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ } ]); } create(value?: PartialMessage): Message { @@ -18856,6 +19403,9 @@ class Message$Type extends MessageType { case /* optional MessageSubthread subthread */ 25: message.subthread = MessageSubthread.internalBinaryRead(reader, reader.uint32(), options, message.subthread); break; + case /* optional bool counts_as_unread */ 26: + message.countsAsUnread = reader.bool(); + break; default: let u = options.readUnknownField; if (u === "throw") @@ -18946,6 +19496,9 @@ class Message$Type extends MessageType { /* optional MessageSubthread subthread = 25; */ if (message.subthread) MessageSubthread.internalBinaryWrite(message.subthread, writer.tag(25, WireType.LengthDelimited).fork(), options).join(); + /* optional bool counts_as_unread = 26; */ + if (message.countsAsUnread !== undefined) + writer.tag(26, WireType.Varint).bool(message.countsAsUnread); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -23369,7 +23922,11 @@ class RpcCall$Type extends MessageType { { no: 143, name: "transcribeVoiceDraft", kind: "message", oneof: "input", T: () => TranscribeVoiceDraftInput }, { no: 144, name: "requestBotFilesystem", kind: "message", oneof: "input", T: () => RequestBotFilesystemInput }, { no: 145, name: "answerBotFilesystem", kind: "message", oneof: "input", T: () => AnswerBotFilesystemInput }, - { no: 146, name: "setSpacePhoto", kind: "message", oneof: "input", T: () => SetSpacePhotoInput } + { no: 146, name: "setSpacePhoto", kind: "message", oneof: "input", T: () => SetSpacePhotoInput }, + { no: 147, name: "moveGridCallHere", kind: "message", oneof: "input", T: () => MoveGridCallHereInput }, + { no: 148, name: "openGridThread", kind: "message", oneof: "input", T: () => OpenGridThreadInput }, + { no: 149, name: "setGridTranscription", kind: "message", oneof: "input", T: () => SetGridTranscriptionInput }, + { no: 150, name: "listGridTranscripts", kind: "message", oneof: "input", T: () => ListGridTranscriptsInput } ]); } create(value?: PartialMessage): RpcCall { @@ -24252,6 +24809,30 @@ class RpcCall$Type extends MessageType { setSpacePhoto: SetSpacePhotoInput.internalBinaryRead(reader, reader.uint32(), options, (message.input as any).setSpacePhoto) }; break; + case /* MoveGridCallHereInput moveGridCallHere */ 147: + message.input = { + oneofKind: "moveGridCallHere", + moveGridCallHere: MoveGridCallHereInput.internalBinaryRead(reader, reader.uint32(), options, (message.input as any).moveGridCallHere) + }; + break; + case /* OpenGridThreadInput openGridThread */ 148: + message.input = { + oneofKind: "openGridThread", + openGridThread: OpenGridThreadInput.internalBinaryRead(reader, reader.uint32(), options, (message.input as any).openGridThread) + }; + break; + case /* SetGridTranscriptionInput setGridTranscription */ 149: + message.input = { + oneofKind: "setGridTranscription", + setGridTranscription: SetGridTranscriptionInput.internalBinaryRead(reader, reader.uint32(), options, (message.input as any).setGridTranscription) + }; + break; + case /* ListGridTranscriptsInput listGridTranscripts */ 150: + message.input = { + oneofKind: "listGridTranscripts", + listGridTranscripts: ListGridTranscriptsInput.internalBinaryRead(reader, reader.uint32(), options, (message.input as any).listGridTranscripts) + }; + break; default: let u = options.readUnknownField; if (u === "throw") @@ -24699,6 +25280,18 @@ class RpcCall$Type extends MessageType { /* SetSpacePhotoInput setSpacePhoto = 146; */ if (message.input.oneofKind === "setSpacePhoto") SetSpacePhotoInput.internalBinaryWrite(message.input.setSpacePhoto, writer.tag(146, WireType.LengthDelimited).fork(), options).join(); + /* MoveGridCallHereInput moveGridCallHere = 147; */ + if (message.input.oneofKind === "moveGridCallHere") + MoveGridCallHereInput.internalBinaryWrite(message.input.moveGridCallHere, writer.tag(147, WireType.LengthDelimited).fork(), options).join(); + /* OpenGridThreadInput openGridThread = 148; */ + if (message.input.oneofKind === "openGridThread") + OpenGridThreadInput.internalBinaryWrite(message.input.openGridThread, writer.tag(148, WireType.LengthDelimited).fork(), options).join(); + /* SetGridTranscriptionInput setGridTranscription = 149; */ + if (message.input.oneofKind === "setGridTranscription") + SetGridTranscriptionInput.internalBinaryWrite(message.input.setGridTranscription, writer.tag(149, WireType.LengthDelimited).fork(), options).join(); + /* ListGridTranscriptsInput listGridTranscripts = 150; */ + if (message.input.oneofKind === "listGridTranscripts") + ListGridTranscriptsInput.internalBinaryWrite(message.input.listGridTranscripts, writer.tag(150, WireType.LengthDelimited).fork(), options).join(); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -24857,7 +25450,11 @@ class RpcResult$Type extends MessageType { { no: 143, name: "transcribeVoiceDraft", kind: "message", oneof: "result", T: () => TranscribeVoiceDraftResult }, { no: 144, name: "requestBotFilesystem", kind: "message", oneof: "result", T: () => RequestBotFilesystemResult }, { no: 145, name: "answerBotFilesystem", kind: "message", oneof: "result", T: () => AnswerBotFilesystemResult }, - { no: 146, name: "setSpacePhoto", kind: "message", oneof: "result", T: () => SetSpacePhotoResult } + { no: 146, name: "setSpacePhoto", kind: "message", oneof: "result", T: () => SetSpacePhotoResult }, + { no: 147, name: "moveGridCallHere", kind: "message", oneof: "result", T: () => MoveGridCallHereResult }, + { no: 148, name: "openGridThread", kind: "message", oneof: "result", T: () => OpenGridThreadResult }, + { no: 149, name: "setGridTranscription", kind: "message", oneof: "result", T: () => SetGridTranscriptionResult }, + { no: 150, name: "listGridTranscripts", kind: "message", oneof: "result", T: () => ListGridTranscriptsResult } ]); } create(value?: PartialMessage): RpcResult { @@ -25740,6 +26337,30 @@ class RpcResult$Type extends MessageType { setSpacePhoto: SetSpacePhotoResult.internalBinaryRead(reader, reader.uint32(), options, (message.result as any).setSpacePhoto) }; break; + case /* MoveGridCallHereResult moveGridCallHere */ 147: + message.result = { + oneofKind: "moveGridCallHere", + moveGridCallHere: MoveGridCallHereResult.internalBinaryRead(reader, reader.uint32(), options, (message.result as any).moveGridCallHere) + }; + break; + case /* OpenGridThreadResult openGridThread */ 148: + message.result = { + oneofKind: "openGridThread", + openGridThread: OpenGridThreadResult.internalBinaryRead(reader, reader.uint32(), options, (message.result as any).openGridThread) + }; + break; + case /* SetGridTranscriptionResult setGridTranscription */ 149: + message.result = { + oneofKind: "setGridTranscription", + setGridTranscription: SetGridTranscriptionResult.internalBinaryRead(reader, reader.uint32(), options, (message.result as any).setGridTranscription) + }; + break; + case /* ListGridTranscriptsResult listGridTranscripts */ 150: + message.result = { + oneofKind: "listGridTranscripts", + listGridTranscripts: ListGridTranscriptsResult.internalBinaryRead(reader, reader.uint32(), options, (message.result as any).listGridTranscripts) + }; + break; default: let u = options.readUnknownField; if (u === "throw") @@ -26187,6 +26808,18 @@ class RpcResult$Type extends MessageType { /* SetSpacePhotoResult setSpacePhoto = 146; */ if (message.result.oneofKind === "setSpacePhoto") SetSpacePhotoResult.internalBinaryWrite(message.result.setSpacePhoto, writer.tag(146, WireType.LengthDelimited).fork(), options).join(); + /* MoveGridCallHereResult moveGridCallHere = 147; */ + if (message.result.oneofKind === "moveGridCallHere") + MoveGridCallHereResult.internalBinaryWrite(message.result.moveGridCallHere, writer.tag(147, WireType.LengthDelimited).fork(), options).join(); + /* OpenGridThreadResult openGridThread = 148; */ + if (message.result.oneofKind === "openGridThread") + OpenGridThreadResult.internalBinaryWrite(message.result.openGridThread, writer.tag(148, WireType.LengthDelimited).fork(), options).join(); + /* SetGridTranscriptionResult setGridTranscription = 149; */ + if (message.result.oneofKind === "setGridTranscription") + SetGridTranscriptionResult.internalBinaryWrite(message.result.setGridTranscription, writer.tag(149, WireType.LengthDelimited).fork(), options).join(); + /* ListGridTranscriptsResult listGridTranscripts = 150; */ + if (message.result.oneofKind === "listGridTranscripts") + ListGridTranscriptsResult.internalBinaryWrite(message.result.listGridTranscripts, writer.tag(150, WireType.LengthDelimited).fork(), options).join(); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -27327,7 +27960,10 @@ class GridRoom$Type extends MessageType { { no: 6, name: "created_at", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }, { no: 7, name: "updated_at", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }, { no: 8, name: "avatars", kind: "message", repeat: 1 /*RepeatType.PACKED*/, T: () => GridAvatar }, - { no: 9, name: "connection", kind: "message", T: () => GridConnection } + { no: 9, name: "connection", kind: "message", T: () => GridConnection }, + { no: 10, name: "room_thread_id", kind: "scalar", opt: true, T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }, + { no: 11, name: "transcription_available", kind: "scalar", T: 8 /*ScalarType.BOOL*/ }, + { no: 12, name: "transcription", kind: "message", T: () => GridTranscription } ]); } create(value?: PartialMessage): GridRoom { @@ -27339,6 +27975,7 @@ class GridRoom$Type extends MessageType { message.createdAt = 0n; message.updatedAt = 0n; message.avatars = []; + message.transcriptionAvailable = false; if (value !== undefined) reflectionMergePartial(this, message, value); return message; @@ -27375,6 +28012,15 @@ class GridRoom$Type extends MessageType { case /* optional GridConnection connection */ 9: message.connection = GridConnection.internalBinaryRead(reader, reader.uint32(), options, message.connection); break; + case /* optional int64 room_thread_id */ 10: + message.roomThreadId = reader.int64().toBigInt(); + break; + case /* bool transcription_available */ 11: + message.transcriptionAvailable = reader.bool(); + break; + case /* optional GridTranscription transcription */ 12: + message.transcription = GridTranscription.internalBinaryRead(reader, reader.uint32(), options, message.transcription); + break; default: let u = options.readUnknownField; if (u === "throw") @@ -27414,6 +28060,15 @@ class GridRoom$Type extends MessageType { /* optional GridConnection connection = 9; */ if (message.connection) GridConnection.internalBinaryWrite(message.connection, writer.tag(9, WireType.LengthDelimited).fork(), options).join(); + /* optional int64 room_thread_id = 10; */ + if (message.roomThreadId !== undefined) + writer.tag(10, WireType.Varint).int64(message.roomThreadId); + /* bool transcription_available = 11; */ + if (message.transcriptionAvailable !== false) + writer.tag(11, WireType.Varint).bool(message.transcriptionAvailable); + /* optional GridTranscription transcription = 12; */ + if (message.transcription) + GridTranscription.internalBinaryWrite(message.transcription, writer.tag(12, WireType.LengthDelimited).fork(), options).join(); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -27425,45 +28080,40 @@ class GridRoom$Type extends MessageType { */ export const GridRoom = new GridRoom$Type(); // @generated message type with reflection information, may provide speed optimized methods -class Grid$Type extends MessageType { +class GridTranscription$Type extends MessageType { constructor() { - super("Grid", [ - { no: 1, name: "space_id", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }, - { no: 2, name: "enabled", kind: "scalar", T: 8 /*ScalarType.BOOL*/ }, - { no: 3, name: "rooms", kind: "message", repeat: 1 /*RepeatType.PACKED*/, T: () => GridRoom }, - { no: 4, name: "current_room_id", kind: "scalar", opt: true, T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }, - { no: 5, name: "revision", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ } + super("GridTranscription", [ + { no: 1, name: "run_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "state", kind: "enum", T: () => ["GridTranscriptionState", GridTranscriptionState] }, + { no: 3, name: "transcript_chat_id", kind: "scalar", opt: true, T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }, + { no: 4, name: "revision", kind: "scalar", T: 5 /*ScalarType.INT32*/ } ]); } - create(value?: PartialMessage): Grid { + create(value?: PartialMessage): GridTranscription { const message = globalThis.Object.create((this.messagePrototype!)); - message.spaceId = 0n; - message.enabled = false; - message.rooms = []; - message.revision = 0n; + message.runId = ""; + message.state = 0; + message.revision = 0; if (value !== undefined) - reflectionMergePartial(this, message, value); + reflectionMergePartial(this, message, value); return message; } - internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: Grid): Grid { + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: GridTranscription): GridTranscription { let message = target ?? this.create(), end = reader.pos + length; while (reader.pos < end) { let [fieldNo, wireType] = reader.tag(); switch (fieldNo) { - case /* int64 space_id */ 1: - message.spaceId = reader.int64().toBigInt(); - break; - case /* bool enabled */ 2: - message.enabled = reader.bool(); + case /* string run_id */ 1: + message.runId = reader.string(); break; - case /* repeated GridRoom rooms */ 3: - message.rooms.push(GridRoom.internalBinaryRead(reader, reader.uint32(), options)); + case /* GridTranscriptionState state */ 2: + message.state = reader.int32(); break; - case /* optional int64 current_room_id */ 4: - message.currentRoomId = reader.int64().toBigInt(); + case /* optional int64 transcript_chat_id */ 3: + message.transcriptChatId = reader.int64().toBigInt(); break; - case /* int64 revision */ 5: - message.revision = reader.int64().toBigInt(); + case /* int32 revision */ 4: + message.revision = reader.int32(); break; default: let u = options.readUnknownField; @@ -27476,22 +28126,19 @@ class Grid$Type extends MessageType { } return message; } - internalBinaryWrite(message: Grid, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { - /* int64 space_id = 1; */ - if (message.spaceId !== 0n) - writer.tag(1, WireType.Varint).int64(message.spaceId); - /* bool enabled = 2; */ - if (message.enabled !== false) - writer.tag(2, WireType.Varint).bool(message.enabled); - /* repeated GridRoom rooms = 3; */ - for (let i = 0; i < message.rooms.length; i++) - GridRoom.internalBinaryWrite(message.rooms[i], writer.tag(3, WireType.LengthDelimited).fork(), options).join(); - /* optional int64 current_room_id = 4; */ - if (message.currentRoomId !== undefined) - writer.tag(4, WireType.Varint).int64(message.currentRoomId); - /* int64 revision = 5; */ - if (message.revision !== 0n) - writer.tag(5, WireType.Varint).int64(message.revision); + internalBinaryWrite(message: GridTranscription, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* string run_id = 1; */ + if (message.runId !== "") + writer.tag(1, WireType.LengthDelimited).string(message.runId); + /* GridTranscriptionState state = 2; */ + if (message.state !== 0) + writer.tag(2, WireType.Varint).int32(message.state); + /* optional int64 transcript_chat_id = 3; */ + if (message.transcriptChatId !== undefined) + writer.tag(3, WireType.Varint).int64(message.transcriptChatId); + /* int32 revision = 4; */ + if (message.revision !== 0) + writer.tag(4, WireType.Varint).int32(message.revision); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -27499,49 +28146,35 @@ class Grid$Type extends MessageType { } } /** - * @generated MessageType for protobuf message Grid + * @generated MessageType for protobuf message GridTranscription */ -export const Grid = new Grid$Type(); +export const GridTranscription = new GridTranscription$Type(); // @generated message type with reflection information, may provide speed optimized methods -class GridConnectionCredentials$Type extends MessageType { +class OpenGridThreadInput$Type extends MessageType { constructor() { - super("GridConnectionCredentials", [ - { no: 1, name: "connection", kind: "message", T: () => GridConnection }, - { no: 2, name: "server_url", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, - { no: 3, name: "participant_identity", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, - { no: 4, name: "token", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, - { no: 5, name: "expires_at", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ } + super("OpenGridThreadInput", [ + { no: 1, name: "room_id", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }, + { no: 2, name: "expected_membership_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ } ]); } - create(value?: PartialMessage): GridConnectionCredentials { + create(value?: PartialMessage): OpenGridThreadInput { const message = globalThis.Object.create((this.messagePrototype!)); - message.serverUrl = ""; - message.participantIdentity = ""; - message.token = ""; - message.expiresAt = 0n; + message.roomId = 0n; + message.expectedMembershipId = ""; if (value !== undefined) - reflectionMergePartial(this, message, value); + reflectionMergePartial(this, message, value); return message; } - internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: GridConnectionCredentials): GridConnectionCredentials { + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: OpenGridThreadInput): OpenGridThreadInput { let message = target ?? this.create(), end = reader.pos + length; while (reader.pos < end) { let [fieldNo, wireType] = reader.tag(); switch (fieldNo) { - case /* GridConnection connection */ 1: - message.connection = GridConnection.internalBinaryRead(reader, reader.uint32(), options, message.connection); - break; - case /* string server_url */ 2: - message.serverUrl = reader.string(); - break; - case /* string participant_identity */ 3: - message.participantIdentity = reader.string(); - break; - case /* string token */ 4: - message.token = reader.string(); + case /* int64 room_id */ 1: + message.roomId = reader.int64().toBigInt(); break; - case /* int64 expires_at */ 5: - message.expiresAt = reader.int64().toBigInt(); + case /* string expected_membership_id */ 2: + message.expectedMembershipId = reader.string(); break; default: let u = options.readUnknownField; @@ -27554,22 +28187,13 @@ class GridConnectionCredentials$Type extends MessageType { +class OpenGridThreadResult$Type extends MessageType { constructor() { - super("GetGridInput", [ - { no: 1, name: "space_id", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ } + super("OpenGridThreadResult", [ + { no: 1, name: "chat_id", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }, + { no: 2, name: "updates", kind: "message", repeat: 1 /*RepeatType.PACKED*/, T: () => Update } ]); } - create(value?: PartialMessage): GetGridInput { + create(value?: PartialMessage): OpenGridThreadResult { const message = globalThis.Object.create((this.messagePrototype!)); - message.spaceId = 0n; + message.chatId = 0n; + message.updates = []; if (value !== undefined) - reflectionMergePartial(this, message, value); + reflectionMergePartial(this, message, value); return message; } - internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: GetGridInput): GetGridInput { + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: OpenGridThreadResult): OpenGridThreadResult { let message = target ?? this.create(), end = reader.pos + length; while (reader.pos < end) { let [fieldNo, wireType] = reader.tag(); switch (fieldNo) { - case /* int64 space_id */ 1: - message.spaceId = reader.int64().toBigInt(); + case /* int64 chat_id */ 1: + message.chatId = reader.int64().toBigInt(); + break; + case /* repeated Update updates */ 2: + message.updates.push(Update.internalBinaryRead(reader, reader.uint32(), options)); break; default: let u = options.readUnknownField; @@ -27613,10 +28242,13 @@ class GetGridInput$Type extends MessageType { } return message; } - internalBinaryWrite(message: GetGridInput, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { - /* int64 space_id = 1; */ - if (message.spaceId !== 0n) - writer.tag(1, WireType.Varint).int64(message.spaceId); + internalBinaryWrite(message: OpenGridThreadResult, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* int64 chat_id = 1; */ + if (message.chatId !== 0n) + writer.tag(1, WireType.Varint).int64(message.chatId); + /* repeated Update updates = 2; */ + for (let i = 0; i < message.updates.length; i++) + Update.internalBinaryWrite(message.updates[i], writer.tag(2, WireType.LengthDelimited).fork(), options).join(); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -27624,20 +28256,662 @@ class GetGridInput$Type extends MessageType { } } /** - * @generated MessageType for protobuf message GetGridInput + * @generated MessageType for protobuf message OpenGridThreadResult */ -export const GetGridInput = new GetGridInput$Type(); +export const OpenGridThreadResult = new OpenGridThreadResult$Type(); // @generated message type with reflection information, may provide speed optimized methods -class GetGridResult$Type extends MessageType { +class SetGridTranscriptionInput$Type extends MessageType { constructor() { - super("GetGridResult", [ - { no: 1, name: "grid", kind: "message", T: () => Grid } + super("SetGridTranscriptionInput", [ + { no: 1, name: "room_id", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }, + { no: 2, name: "expected_membership_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 3, name: "expected_generation", kind: "scalar", T: 5 /*ScalarType.INT32*/ }, + { no: 4, name: "expected_run_id", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 5, name: "expected_revision", kind: "scalar", T: 5 /*ScalarType.INT32*/ }, + { no: 6, name: "enabled", kind: "scalar", T: 8 /*ScalarType.BOOL*/ }, + { no: 7, name: "destination", kind: "enum", T: () => ["GridTranscriptDestination", GridTranscriptDestination] }, + { no: 8, name: "transcript_chat_id", kind: "scalar", opt: true, T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }, + { no: 9, name: "request_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ } ]); } - create(value?: PartialMessage): GetGridResult { + create(value?: PartialMessage): SetGridTranscriptionInput { const message = globalThis.Object.create((this.messagePrototype!)); + message.roomId = 0n; + message.expectedMembershipId = ""; + message.expectedGeneration = 0; + message.expectedRevision = 0; + message.enabled = false; + message.destination = 0; + message.requestId = ""; if (value !== undefined) - reflectionMergePartial(this, message, value); + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: SetGridTranscriptionInput): SetGridTranscriptionInput { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* int64 room_id */ 1: + message.roomId = reader.int64().toBigInt(); + break; + case /* string expected_membership_id */ 2: + message.expectedMembershipId = reader.string(); + break; + case /* int32 expected_generation */ 3: + message.expectedGeneration = reader.int32(); + break; + case /* optional string expected_run_id */ 4: + message.expectedRunId = reader.string(); + break; + case /* int32 expected_revision */ 5: + message.expectedRevision = reader.int32(); + break; + case /* bool enabled */ 6: + message.enabled = reader.bool(); + break; + case /* GridTranscriptDestination destination */ 7: + message.destination = reader.int32(); + break; + case /* optional int64 transcript_chat_id */ 8: + message.transcriptChatId = reader.int64().toBigInt(); + break; + case /* string request_id */ 9: + message.requestId = reader.string(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: SetGridTranscriptionInput, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* int64 room_id = 1; */ + if (message.roomId !== 0n) + writer.tag(1, WireType.Varint).int64(message.roomId); + /* string expected_membership_id = 2; */ + if (message.expectedMembershipId !== "") + writer.tag(2, WireType.LengthDelimited).string(message.expectedMembershipId); + /* int32 expected_generation = 3; */ + if (message.expectedGeneration !== 0) + writer.tag(3, WireType.Varint).int32(message.expectedGeneration); + /* optional string expected_run_id = 4; */ + if (message.expectedRunId !== undefined) + writer.tag(4, WireType.LengthDelimited).string(message.expectedRunId); + /* int32 expected_revision = 5; */ + if (message.expectedRevision !== 0) + writer.tag(5, WireType.Varint).int32(message.expectedRevision); + /* bool enabled = 6; */ + if (message.enabled !== false) + writer.tag(6, WireType.Varint).bool(message.enabled); + /* GridTranscriptDestination destination = 7; */ + if (message.destination !== 0) + writer.tag(7, WireType.Varint).int32(message.destination); + /* optional int64 transcript_chat_id = 8; */ + if (message.transcriptChatId !== undefined) + writer.tag(8, WireType.Varint).int64(message.transcriptChatId); + /* string request_id = 9; */ + if (message.requestId !== "") + writer.tag(9, WireType.LengthDelimited).string(message.requestId); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message SetGridTranscriptionInput + */ +export const SetGridTranscriptionInput = new SetGridTranscriptionInput$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class SetGridTranscriptionResult$Type extends MessageType { + constructor() { + super("SetGridTranscriptionResult", [ + { no: 1, name: "grid", kind: "message", T: () => Grid }, + { no: 2, name: "updates", kind: "message", repeat: 1 /*RepeatType.PACKED*/, T: () => Update } + ]); + } + create(value?: PartialMessage): SetGridTranscriptionResult { + const message = globalThis.Object.create((this.messagePrototype!)); + message.updates = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: SetGridTranscriptionResult): SetGridTranscriptionResult { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* Grid grid */ 1: + message.grid = Grid.internalBinaryRead(reader, reader.uint32(), options, message.grid); + break; + case /* repeated Update updates */ 2: + message.updates.push(Update.internalBinaryRead(reader, reader.uint32(), options)); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: SetGridTranscriptionResult, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* Grid grid = 1; */ + if (message.grid) + Grid.internalBinaryWrite(message.grid, writer.tag(1, WireType.LengthDelimited).fork(), options).join(); + /* repeated Update updates = 2; */ + for (let i = 0; i < message.updates.length; i++) + Update.internalBinaryWrite(message.updates[i], writer.tag(2, WireType.LengthDelimited).fork(), options).join(); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message SetGridTranscriptionResult + */ +export const SetGridTranscriptionResult = new SetGridTranscriptionResult$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class ListGridTranscriptsInput$Type extends MessageType { + constructor() { + super("ListGridTranscriptsInput", [ + { no: 1, name: "room_id", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ } + ]); + } + create(value?: PartialMessage): ListGridTranscriptsInput { + const message = globalThis.Object.create((this.messagePrototype!)); + message.roomId = 0n; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: ListGridTranscriptsInput): ListGridTranscriptsInput { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* int64 room_id */ 1: + message.roomId = reader.int64().toBigInt(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: ListGridTranscriptsInput, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* int64 room_id = 1; */ + if (message.roomId !== 0n) + writer.tag(1, WireType.Varint).int64(message.roomId); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message ListGridTranscriptsInput + */ +export const ListGridTranscriptsInput = new ListGridTranscriptsInput$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class GridTranscriptDestinationInfo$Type extends MessageType { + constructor() { + super("GridTranscriptDestinationInfo", [ + { no: 1, name: "transcript_chat_id", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }, + { no: 2, name: "title", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 3, name: "busy", kind: "scalar", T: 8 /*ScalarType.BOOL*/ } + ]); + } + create(value?: PartialMessage): GridTranscriptDestinationInfo { + const message = globalThis.Object.create((this.messagePrototype!)); + message.transcriptChatId = 0n; + message.title = ""; + message.busy = false; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: GridTranscriptDestinationInfo): GridTranscriptDestinationInfo { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* int64 transcript_chat_id */ 1: + message.transcriptChatId = reader.int64().toBigInt(); + break; + case /* string title */ 2: + message.title = reader.string(); + break; + case /* bool busy */ 3: + message.busy = reader.bool(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: GridTranscriptDestinationInfo, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* int64 transcript_chat_id = 1; */ + if (message.transcriptChatId !== 0n) + writer.tag(1, WireType.Varint).int64(message.transcriptChatId); + /* string title = 2; */ + if (message.title !== "") + writer.tag(2, WireType.LengthDelimited).string(message.title); + /* bool busy = 3; */ + if (message.busy !== false) + writer.tag(3, WireType.Varint).bool(message.busy); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message GridTranscriptDestinationInfo + */ +export const GridTranscriptDestinationInfo = new GridTranscriptDestinationInfo$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class ListGridTranscriptsResult$Type extends MessageType { + constructor() { + super("ListGridTranscriptsResult", [ + { no: 1, name: "transcripts", kind: "message", repeat: 1 /*RepeatType.PACKED*/, T: () => GridTranscriptDestinationInfo } + ]); + } + create(value?: PartialMessage): ListGridTranscriptsResult { + const message = globalThis.Object.create((this.messagePrototype!)); + message.transcripts = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: ListGridTranscriptsResult): ListGridTranscriptsResult { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* repeated GridTranscriptDestinationInfo transcripts */ 1: + message.transcripts.push(GridTranscriptDestinationInfo.internalBinaryRead(reader, reader.uint32(), options)); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: ListGridTranscriptsResult, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* repeated GridTranscriptDestinationInfo transcripts = 1; */ + for (let i = 0; i < message.transcripts.length; i++) + GridTranscriptDestinationInfo.internalBinaryWrite(message.transcripts[i], writer.tag(1, WireType.LengthDelimited).fork(), options).join(); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message ListGridTranscriptsResult + */ +export const ListGridTranscriptsResult = new ListGridTranscriptsResult$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class Grid$Type extends MessageType { + constructor() { + super("Grid", [ + { no: 1, name: "space_id", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }, + { no: 2, name: "enabled", kind: "scalar", T: 8 /*ScalarType.BOOL*/ }, + { no: 3, name: "rooms", kind: "message", repeat: 1 /*RepeatType.PACKED*/, T: () => GridRoom }, + { no: 4, name: "current_room_id", kind: "scalar", opt: true, T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }, + { no: 5, name: "revision", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }, + { no: 6, name: "call_transfer_enabled", kind: "scalar", T: 8 /*ScalarType.BOOL*/ } + ]); + } + create(value?: PartialMessage): Grid { + const message = globalThis.Object.create((this.messagePrototype!)); + message.spaceId = 0n; + message.enabled = false; + message.rooms = []; + message.revision = 0n; + message.callTransferEnabled = false; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: Grid): Grid { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* int64 space_id */ 1: + message.spaceId = reader.int64().toBigInt(); + break; + case /* bool enabled */ 2: + message.enabled = reader.bool(); + break; + case /* repeated GridRoom rooms */ 3: + message.rooms.push(GridRoom.internalBinaryRead(reader, reader.uint32(), options)); + break; + case /* optional int64 current_room_id */ 4: + message.currentRoomId = reader.int64().toBigInt(); + break; + case /* int64 revision */ 5: + message.revision = reader.int64().toBigInt(); + break; + case /* bool call_transfer_enabled */ 6: + message.callTransferEnabled = reader.bool(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: Grid, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* int64 space_id = 1; */ + if (message.spaceId !== 0n) + writer.tag(1, WireType.Varint).int64(message.spaceId); + /* bool enabled = 2; */ + if (message.enabled !== false) + writer.tag(2, WireType.Varint).bool(message.enabled); + /* repeated GridRoom rooms = 3; */ + for (let i = 0; i < message.rooms.length; i++) + GridRoom.internalBinaryWrite(message.rooms[i], writer.tag(3, WireType.LengthDelimited).fork(), options).join(); + /* optional int64 current_room_id = 4; */ + if (message.currentRoomId !== undefined) + writer.tag(4, WireType.Varint).int64(message.currentRoomId); + /* int64 revision = 5; */ + if (message.revision !== 0n) + writer.tag(5, WireType.Varint).int64(message.revision); + /* bool call_transfer_enabled = 6; */ + if (message.callTransferEnabled !== false) + writer.tag(6, WireType.Varint).bool(message.callTransferEnabled); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message Grid + */ +export const Grid = new Grid$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class GridCurrentCall$Type extends MessageType { + constructor() { + super("GridCurrentCall", [ + { no: 1, name: "call_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "space_id", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }, + { no: 3, name: "room_id", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }, + { no: 4, name: "membership_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 5, name: "owned_by_current_session", kind: "scalar", T: 8 /*ScalarType.BOOL*/ }, + { no: 6, name: "owner_client_type", kind: "scalar", T: 9 /*ScalarType.STRING*/ } + ]); + } + create(value?: PartialMessage): GridCurrentCall { + const message = globalThis.Object.create((this.messagePrototype!)); + message.callId = ""; + message.spaceId = 0n; + message.roomId = 0n; + message.membershipId = ""; + message.ownedByCurrentSession = false; + message.ownerClientType = ""; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: GridCurrentCall): GridCurrentCall { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* string call_id */ 1: + message.callId = reader.string(); + break; + case /* int64 space_id */ 2: + message.spaceId = reader.int64().toBigInt(); + break; + case /* int64 room_id */ 3: + message.roomId = reader.int64().toBigInt(); + break; + case /* string membership_id */ 4: + message.membershipId = reader.string(); + break; + case /* bool owned_by_current_session */ 5: + message.ownedByCurrentSession = reader.bool(); + break; + case /* string owner_client_type */ 6: + message.ownerClientType = reader.string(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: GridCurrentCall, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* string call_id = 1; */ + if (message.callId !== "") + writer.tag(1, WireType.LengthDelimited).string(message.callId); + /* int64 space_id = 2; */ + if (message.spaceId !== 0n) + writer.tag(2, WireType.Varint).int64(message.spaceId); + /* int64 room_id = 3; */ + if (message.roomId !== 0n) + writer.tag(3, WireType.Varint).int64(message.roomId); + /* string membership_id = 4; */ + if (message.membershipId !== "") + writer.tag(4, WireType.LengthDelimited).string(message.membershipId); + /* bool owned_by_current_session = 5; */ + if (message.ownedByCurrentSession !== false) + writer.tag(5, WireType.Varint).bool(message.ownedByCurrentSession); + /* string owner_client_type = 6; */ + if (message.ownerClientType !== "") + writer.tag(6, WireType.LengthDelimited).string(message.ownerClientType); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message GridCurrentCall + */ +export const GridCurrentCall = new GridCurrentCall$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class GridConnectionCredentials$Type extends MessageType { + constructor() { + super("GridConnectionCredentials", [ + { no: 1, name: "connection", kind: "message", T: () => GridConnection }, + { no: 2, name: "server_url", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 3, name: "participant_identity", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 4, name: "token", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 5, name: "expires_at", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }, + { no: 6, name: "call_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 7, name: "membership_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ } + ]); + } + create(value?: PartialMessage): GridConnectionCredentials { + const message = globalThis.Object.create((this.messagePrototype!)); + message.serverUrl = ""; + message.participantIdentity = ""; + message.token = ""; + message.expiresAt = 0n; + message.callId = ""; + message.membershipId = ""; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: GridConnectionCredentials): GridConnectionCredentials { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* GridConnection connection */ 1: + message.connection = GridConnection.internalBinaryRead(reader, reader.uint32(), options, message.connection); + break; + case /* string server_url */ 2: + message.serverUrl = reader.string(); + break; + case /* string participant_identity */ 3: + message.participantIdentity = reader.string(); + break; + case /* string token */ 4: + message.token = reader.string(); + break; + case /* int64 expires_at */ 5: + message.expiresAt = reader.int64().toBigInt(); + break; + case /* string call_id */ 6: + message.callId = reader.string(); + break; + case /* string membership_id */ 7: + message.membershipId = reader.string(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: GridConnectionCredentials, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* GridConnection connection = 1; */ + if (message.connection) + GridConnection.internalBinaryWrite(message.connection, writer.tag(1, WireType.LengthDelimited).fork(), options).join(); + /* string server_url = 2; */ + if (message.serverUrl !== "") + writer.tag(2, WireType.LengthDelimited).string(message.serverUrl); + /* string participant_identity = 3; */ + if (message.participantIdentity !== "") + writer.tag(3, WireType.LengthDelimited).string(message.participantIdentity); + /* string token = 4; */ + if (message.token !== "") + writer.tag(4, WireType.LengthDelimited).string(message.token); + /* int64 expires_at = 5; */ + if (message.expiresAt !== 0n) + writer.tag(5, WireType.Varint).int64(message.expiresAt); + /* string call_id = 6; */ + if (message.callId !== "") + writer.tag(6, WireType.LengthDelimited).string(message.callId); + /* string membership_id = 7; */ + if (message.membershipId !== "") + writer.tag(7, WireType.LengthDelimited).string(message.membershipId); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message GridConnectionCredentials + */ +export const GridConnectionCredentials = new GridConnectionCredentials$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class GetGridInput$Type extends MessageType { + constructor() { + super("GetGridInput", [ + { no: 1, name: "space_id", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }, + { no: 2, name: "expected_membership_id", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ } + ]); + } + create(value?: PartialMessage): GetGridInput { + const message = globalThis.Object.create((this.messagePrototype!)); + message.spaceId = 0n; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: GetGridInput): GetGridInput { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* int64 space_id */ 1: + message.spaceId = reader.int64().toBigInt(); + break; + case /* optional string expected_membership_id */ 2: + message.expectedMembershipId = reader.string(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: GetGridInput, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* int64 space_id = 1; */ + if (message.spaceId !== 0n) + writer.tag(1, WireType.Varint).int64(message.spaceId); + /* optional string expected_membership_id = 2; */ + if (message.expectedMembershipId !== undefined) + writer.tag(2, WireType.LengthDelimited).string(message.expectedMembershipId); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message GetGridInput + */ +export const GetGridInput = new GetGridInput$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class GetGridResult$Type extends MessageType { + constructor() { + super("GetGridResult", [ + { no: 1, name: "grid", kind: "message", T: () => Grid }, + { no: 2, name: "current_call", kind: "message", T: () => GridCurrentCall } + ]); + } + create(value?: PartialMessage): GetGridResult { + const message = globalThis.Object.create((this.messagePrototype!)); + if (value !== undefined) + reflectionMergePartial(this, message, value); return message; } internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: GetGridResult): GetGridResult { @@ -27648,6 +28922,9 @@ class GetGridResult$Type extends MessageType { case /* Grid grid */ 1: message.grid = Grid.internalBinaryRead(reader, reader.uint32(), options, message.grid); break; + case /* optional GridCurrentCall current_call */ 2: + message.currentCall = GridCurrentCall.internalBinaryRead(reader, reader.uint32(), options, message.currentCall); + break; default: let u = options.readUnknownField; if (u === "throw") @@ -27663,6 +28940,9 @@ class GetGridResult$Type extends MessageType { /* Grid grid = 1; */ if (message.grid) Grid.internalBinaryWrite(message.grid, writer.tag(1, WireType.LengthDelimited).fork(), options).join(); + /* optional GridCurrentCall current_call = 2; */ + if (message.currentCall) + GridCurrentCall.internalBinaryWrite(message.currentCall, writer.tag(2, WireType.LengthDelimited).fork(), options).join(); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -27773,12 +29053,15 @@ export const GetGridHomeInput = new GetGridHomeInput$Type(); class GetGridHomeResult$Type extends MessageType { constructor() { super("GetGridHomeResult", [ - { no: 1, name: "spaces", kind: "message", repeat: 1 /*RepeatType.PACKED*/, T: () => GridHomeSpace } + { no: 1, name: "spaces", kind: "message", repeat: 1 /*RepeatType.PACKED*/, T: () => GridHomeSpace }, + { no: 2, name: "current_call", kind: "message", T: () => GridCurrentCall }, + { no: 3, name: "call_transfer_enabled", kind: "scalar", T: 8 /*ScalarType.BOOL*/ } ]); } create(value?: PartialMessage): GetGridHomeResult { const message = globalThis.Object.create((this.messagePrototype!)); message.spaces = []; + message.callTransferEnabled = false; if (value !== undefined) reflectionMergePartial(this, message, value); return message; @@ -27791,6 +29074,12 @@ class GetGridHomeResult$Type extends MessageType { case /* repeated GridHomeSpace spaces */ 1: message.spaces.push(GridHomeSpace.internalBinaryRead(reader, reader.uint32(), options)); break; + case /* optional GridCurrentCall current_call */ 2: + message.currentCall = GridCurrentCall.internalBinaryRead(reader, reader.uint32(), options, message.currentCall); + break; + case /* bool call_transfer_enabled */ 3: + message.callTransferEnabled = reader.bool(); + break; default: let u = options.readUnknownField; if (u === "throw") @@ -27806,6 +29095,12 @@ class GetGridHomeResult$Type extends MessageType { /* repeated GridHomeSpace spaces = 1; */ for (let i = 0; i < message.spaces.length; i++) GridHomeSpace.internalBinaryWrite(message.spaces[i], writer.tag(1, WireType.LengthDelimited).fork(), options).join(); + /* optional GridCurrentCall current_call = 2; */ + if (message.currentCall) + GridCurrentCall.internalBinaryWrite(message.currentCall, writer.tag(2, WireType.LengthDelimited).fork(), options).join(); + /* bool call_transfer_enabled = 3; */ + if (message.callTransferEnabled !== false) + writer.tag(3, WireType.Varint).bool(message.callTransferEnabled); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -27821,7 +29116,8 @@ class CreateGridRoomInput$Type extends MessageType { constructor() { super("CreateGridRoomInput", [ { no: 1, name: "space_id", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }, - { no: 2, name: "microphone_enabled", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ } + { no: 2, name: "microphone_enabled", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 3, name: "expected_membership_id", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ } ]); } create(value?: PartialMessage): CreateGridRoomInput { @@ -27842,6 +29138,9 @@ class CreateGridRoomInput$Type extends MessageType { case /* optional bool microphone_enabled */ 2: message.microphoneEnabled = reader.bool(); break; + case /* optional string expected_membership_id */ 3: + message.expectedMembershipId = reader.string(); + break; default: let u = options.readUnknownField; if (u === "throw") @@ -27860,6 +29159,9 @@ class CreateGridRoomInput$Type extends MessageType { /* optional bool microphone_enabled = 2; */ if (message.microphoneEnabled !== undefined) writer.tag(2, WireType.Varint).bool(message.microphoneEnabled); + /* optional string expected_membership_id = 3; */ + if (message.expectedMembershipId !== undefined) + writer.tag(3, WireType.LengthDelimited).string(message.expectedMembershipId); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -27875,7 +29177,8 @@ class CreateGridRoomResult$Type extends MessageType { constructor() { super("CreateGridRoomResult", [ { no: 1, name: "grids", kind: "message", repeat: 1 /*RepeatType.PACKED*/, T: () => Grid }, - { no: 2, name: "connection", kind: "message", T: () => GridConnectionCredentials } + { no: 2, name: "connection", kind: "message", T: () => GridConnectionCredentials }, + { no: 3, name: "current_call", kind: "message", T: () => GridCurrentCall } ]); } create(value?: PartialMessage): CreateGridRoomResult { @@ -27896,6 +29199,9 @@ class CreateGridRoomResult$Type extends MessageType { case /* optional GridConnectionCredentials connection */ 2: message.connection = GridConnectionCredentials.internalBinaryRead(reader, reader.uint32(), options, message.connection); break; + case /* optional GridCurrentCall current_call */ 3: + message.currentCall = GridCurrentCall.internalBinaryRead(reader, reader.uint32(), options, message.currentCall); + break; default: let u = options.readUnknownField; if (u === "throw") @@ -27914,6 +29220,9 @@ class CreateGridRoomResult$Type extends MessageType { /* optional GridConnectionCredentials connection = 2; */ if (message.connection) GridConnectionCredentials.internalBinaryWrite(message.connection, writer.tag(2, WireType.LengthDelimited).fork(), options).join(); + /* optional GridCurrentCall current_call = 3; */ + if (message.currentCall) + GridCurrentCall.internalBinaryWrite(message.currentCall, writer.tag(3, WireType.LengthDelimited).fork(), options).join(); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -27929,7 +29238,8 @@ class JoinGridRoomInput$Type extends MessageType { constructor() { super("JoinGridRoomInput", [ { no: 1, name: "room_id", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }, - { no: 2, name: "microphone_enabled", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ } + { no: 2, name: "microphone_enabled", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 3, name: "expected_membership_id", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ } ]); } create(value?: PartialMessage): JoinGridRoomInput { @@ -27950,6 +29260,9 @@ class JoinGridRoomInput$Type extends MessageType { case /* optional bool microphone_enabled */ 2: message.microphoneEnabled = reader.bool(); break; + case /* optional string expected_membership_id */ 3: + message.expectedMembershipId = reader.string(); + break; default: let u = options.readUnknownField; if (u === "throw") @@ -27968,6 +29281,9 @@ class JoinGridRoomInput$Type extends MessageType { /* optional bool microphone_enabled = 2; */ if (message.microphoneEnabled !== undefined) writer.tag(2, WireType.Varint).bool(message.microphoneEnabled); + /* optional string expected_membership_id = 3; */ + if (message.expectedMembershipId !== undefined) + writer.tag(3, WireType.LengthDelimited).string(message.expectedMembershipId); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -27983,7 +29299,8 @@ class JoinGridRoomResult$Type extends MessageType { constructor() { super("JoinGridRoomResult", [ { no: 1, name: "grids", kind: "message", repeat: 1 /*RepeatType.PACKED*/, T: () => Grid }, - { no: 2, name: "connection", kind: "message", T: () => GridConnectionCredentials } + { no: 2, name: "connection", kind: "message", T: () => GridConnectionCredentials }, + { no: 3, name: "current_call", kind: "message", T: () => GridCurrentCall } ]); } create(value?: PartialMessage): JoinGridRoomResult { @@ -28004,6 +29321,9 @@ class JoinGridRoomResult$Type extends MessageType { case /* optional GridConnectionCredentials connection */ 2: message.connection = GridConnectionCredentials.internalBinaryRead(reader, reader.uint32(), options, message.connection); break; + case /* optional GridCurrentCall current_call */ 3: + message.currentCall = GridCurrentCall.internalBinaryRead(reader, reader.uint32(), options, message.currentCall); + break; default: let u = options.readUnknownField; if (u === "throw") @@ -28022,6 +29342,9 @@ class JoinGridRoomResult$Type extends MessageType { /* optional GridConnectionCredentials connection = 2; */ if (message.connection) GridConnectionCredentials.internalBinaryWrite(message.connection, writer.tag(2, WireType.LengthDelimited).fork(), options).join(); + /* optional GridCurrentCall current_call = 3; */ + if (message.currentCall) + GridCurrentCall.internalBinaryWrite(message.currentCall, writer.tag(3, WireType.LengthDelimited).fork(), options).join(); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -28036,7 +29359,8 @@ export const JoinGridRoomResult = new JoinGridRoomResult$Type(); class LeaveGridRoomInput$Type extends MessageType { constructor() { super("LeaveGridRoomInput", [ - { no: 1, name: "expected_room_id", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ } + { no: 1, name: "expected_room_id", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }, + { no: 2, name: "expected_membership_id", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ } ]); } create(value?: PartialMessage): LeaveGridRoomInput { @@ -28054,6 +29378,9 @@ class LeaveGridRoomInput$Type extends MessageType { case /* int64 expected_room_id */ 1: message.expectedRoomId = reader.int64().toBigInt(); break; + case /* optional string expected_membership_id */ 2: + message.expectedMembershipId = reader.string(); + break; default: let u = options.readUnknownField; if (u === "throw") @@ -28069,6 +29396,9 @@ class LeaveGridRoomInput$Type extends MessageType { /* int64 expected_room_id = 1; */ if (message.expectedRoomId !== 0n) writer.tag(1, WireType.Varint).int64(message.expectedRoomId); + /* optional string expected_membership_id = 2; */ + if (message.expectedMembershipId !== undefined) + writer.tag(2, WireType.LengthDelimited).string(message.expectedMembershipId); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -28083,7 +29413,8 @@ export const LeaveGridRoomInput = new LeaveGridRoomInput$Type(); class LeaveGridRoomResult$Type extends MessageType { constructor() { super("LeaveGridRoomResult", [ - { no: 1, name: "grids", kind: "message", repeat: 1 /*RepeatType.PACKED*/, T: () => Grid } + { no: 1, name: "grids", kind: "message", repeat: 1 /*RepeatType.PACKED*/, T: () => Grid }, + { no: 2, name: "current_call", kind: "message", T: () => GridCurrentCall } ]); } create(value?: PartialMessage): LeaveGridRoomResult { @@ -28101,6 +29432,9 @@ class LeaveGridRoomResult$Type extends MessageType { case /* repeated Grid grids */ 1: message.grids.push(Grid.internalBinaryRead(reader, reader.uint32(), options)); break; + case /* optional GridCurrentCall current_call */ 2: + message.currentCall = GridCurrentCall.internalBinaryRead(reader, reader.uint32(), options, message.currentCall); + break; default: let u = options.readUnknownField; if (u === "throw") @@ -28116,6 +29450,9 @@ class LeaveGridRoomResult$Type extends MessageType { /* repeated Grid grids = 1; */ for (let i = 0; i < message.grids.length; i++) Grid.internalBinaryWrite(message.grids[i], writer.tag(1, WireType.LengthDelimited).fork(), options).join(); + /* optional GridCurrentCall current_call = 2; */ + if (message.currentCall) + GridCurrentCall.internalBinaryWrite(message.currentCall, writer.tag(2, WireType.LengthDelimited).fork(), options).join(); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -28127,6 +29464,130 @@ class LeaveGridRoomResult$Type extends MessageType { */ export const LeaveGridRoomResult = new LeaveGridRoomResult$Type(); // @generated message type with reflection information, may provide speed optimized methods +class MoveGridCallHereInput$Type extends MessageType { + constructor() { + super("MoveGridCallHereInput", [ + { no: 1, name: "call_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "expected_membership_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ } + ]); + } + create(value?: PartialMessage): MoveGridCallHereInput { + const message = globalThis.Object.create((this.messagePrototype!)); + message.callId = ""; + message.expectedMembershipId = ""; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: MoveGridCallHereInput): MoveGridCallHereInput { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* string call_id */ 1: + message.callId = reader.string(); + break; + case /* string expected_membership_id */ 2: + message.expectedMembershipId = reader.string(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: MoveGridCallHereInput, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* string call_id = 1; */ + if (message.callId !== "") + writer.tag(1, WireType.LengthDelimited).string(message.callId); + /* string expected_membership_id = 2; */ + if (message.expectedMembershipId !== "") + writer.tag(2, WireType.LengthDelimited).string(message.expectedMembershipId); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message MoveGridCallHereInput + */ +export const MoveGridCallHereInput = new MoveGridCallHereInput$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class MoveGridCallHereResult$Type extends MessageType { + constructor() { + super("MoveGridCallHereResult", [ + { no: 1, name: "grids", kind: "message", repeat: 1 /*RepeatType.PACKED*/, T: () => Grid }, + { no: 2, name: "connection", kind: "message", T: () => GridConnectionCredentials }, + { no: 3, name: "current_call", kind: "message", T: () => GridCurrentCall }, + { no: 4, name: "moved", kind: "scalar", T: 8 /*ScalarType.BOOL*/ } + ]); + } + create(value?: PartialMessage): MoveGridCallHereResult { + const message = globalThis.Object.create((this.messagePrototype!)); + message.grids = []; + message.moved = false; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: MoveGridCallHereResult): MoveGridCallHereResult { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* repeated Grid grids */ 1: + message.grids.push(Grid.internalBinaryRead(reader, reader.uint32(), options)); + break; + case /* optional GridConnectionCredentials connection */ 2: + message.connection = GridConnectionCredentials.internalBinaryRead(reader, reader.uint32(), options, message.connection); + break; + case /* optional GridCurrentCall current_call */ 3: + message.currentCall = GridCurrentCall.internalBinaryRead(reader, reader.uint32(), options, message.currentCall); + break; + case /* bool moved */ 4: + message.moved = reader.bool(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: MoveGridCallHereResult, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* repeated Grid grids = 1; */ + for (let i = 0; i < message.grids.length; i++) + Grid.internalBinaryWrite(message.grids[i], writer.tag(1, WireType.LengthDelimited).fork(), options).join(); + /* optional GridConnectionCredentials connection = 2; */ + if (message.connection) + GridConnectionCredentials.internalBinaryWrite(message.connection, writer.tag(2, WireType.LengthDelimited).fork(), options).join(); + /* optional GridCurrentCall current_call = 3; */ + if (message.currentCall) + GridCurrentCall.internalBinaryWrite(message.currentCall, writer.tag(3, WireType.LengthDelimited).fork(), options).join(); + /* bool moved = 4; */ + if (message.moved !== false) + writer.tag(4, WireType.Varint).bool(message.moved); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message MoveGridCallHereResult + */ +export const MoveGridCallHereResult = new MoveGridCallHereResult$Type(); +// @generated message type with reflection information, may provide speed optimized methods class SetGridRoomTitleInput$Type extends MessageType { constructor() { super("SetGridRoomTitleInput", [ @@ -28426,7 +29887,8 @@ class PrepareGridConnectionInput$Type extends MessageType): PrepareGridConnectionInput { @@ -28448,6 +29910,9 @@ class PrepareGridConnectionInput$Type extends MessageType): SetGridAvatarMicrophoneEnabledInput { @@ -28557,6 +30026,9 @@ class SetGridAvatarMicrophoneEnabledInput$Type extends MessageType { + test("Move keeps its allocation and exact ownership fence through binary encoding", () => { + expect(P.Method.MOVE_GRID_CALL_HERE).toBe(146) + expect(P.RpcCall.fields.find((field) => field.localName === "moveGridCallHere")?.no).toBe(147) + expect(P.RpcResult.fields.find((field) => field.localName === "moveGridCallHere")?.no).toBe(147) + + const call = P.RpcCall.fromJson({ + method: "MOVE_GRID_CALL_HERE", + moveGridCallHere: { callId, expectedMembershipId: membershipId }, + }) + expect(P.RpcCall.fromBinary(P.RpcCall.toBinary(call))).toEqual(call) + + for (const moved of [false, true]) { + const result = P.RpcResult.fromJson({ + reqMsgId: "9007199254740993", + moveGridCallHere: { + moved, + grids: [{ spaceId: "9007199254740995", callTransferEnabled: true }], + currentCall: { + callId, + membershipId, + spaceId: "9007199254740995", + roomId: "9007199254740997", + ownedByCurrentSession: moved, + ownerClientType: "ios", + }, + ...(moved ? { connection: { + serverUrl: "wss://fixture.invalid", + participantIdentity: "fixture-participant", + token: "public-fixture-token", + expiresAt: "9007199254740999", + callId, + membershipId, + } } : {}), + }, + }) + expect(P.RpcResult.fromBinary(P.RpcResult.toBinary(result))).toEqual(result) + expect(result.result.oneofKind).toBe("moveGridCallHere") + } + }) + + test("an omitted renewal fence remains distinct from explicit no-presence", () => { + const legacy = P.GetGridInput.fromJson({ spaceId: "9007199254740995" }) + const expectNoPresence = P.GetGridInput.fromJson({ + spaceId: "9007199254740995", + expectedMembershipId: "", + }) + const decodedLegacy = P.GetGridInput.fromBinary(P.GetGridInput.toBinary(legacy)) + const decodedFenced = P.GetGridInput.fromBinary(P.GetGridInput.toBinary(expectNoPresence)) + expect(decodedLegacy.expectedMembershipId).toBeUndefined() + expect(decodedFenced.expectedMembershipId).toBe("") + expect(P.GetGridInput.toBinary(legacy)).not.toEqual(P.GetGridInput.toBinary(expectNoPresence)) + }) +}) diff --git a/packages/protocol/tests/grid-transcription.test.ts b/packages/protocol/tests/grid-transcription.test.ts new file mode 100644 index 000000000..3081e448b --- /dev/null +++ b/packages/protocol/tests/grid-transcription.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "bun:test" +import * as P from "../src/core.js" + +// Distinct payloads protect field numbers, oneof dispatch, nested values, +// optional false and int64 values beyond JavaScript number precision. +const cases = [ + { + method: "OPEN_GRID_THREAD", + field: "openGridThread", + value: 147, + tag: 148, + request: { + roomId: "9007199254740995", + expectedMembershipId: "66666666-7777-4888-8999-aaaaaaaaaaaa" + }, + result: { + chatId: "9007199254740997" + } + }, + { + method: "SET_GRID_TRANSCRIPTION", + field: "setGridTranscription", + value: 148, + tag: 149, + request: { + roomId: "9007199254740995", + expectedMembershipId: "66666666-7777-4888-8999-aaaaaaaaaaaa", + expectedGeneration: 23, + expectedRunId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", + expectedRevision: 37, + enabled: true, + destination: "GRID_TRANSCRIPT_EXISTING", + transcriptChatId: "9007199254740997", + requestId: "12121212-3434-4567-89ab-cdcdcdcdcdcd" + }, + result: { + grid: { + spaceId: "9007199254740993", + enabled: true, + currentRoomId: "9007199254740995", + revision: "9007199254740999", + rooms: [ + { + id: "9007199254740995", + spaceId: "9007199254740993", + roomThreadId: "9007199254741001", + transcriptionAvailable: true, + transcription: { + runId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", + state: "GRID_TRANSCRIPTION_STOPPING", + transcriptChatId: "9007199254740997", + revision: 37 + } + } + ] + } + } + }, + { + method: "LIST_GRID_TRANSCRIPTS", + field: "listGridTranscripts", + value: 149, + tag: 150, + request: { + roomId: "9007199254740995" + }, + result: { + transcripts: [ + { + transcriptChatId: "9007199254740997", + title: "Planning discussion", + busy: true + }, + { + transcriptChatId: "9007199254741003", + title: "Earlier session", + busy: false + } + ] + } + } +] as const + +describe("grid transcription allocation", () => { + for (const { method, field, value, tag, request, result } of cases) { + test(`${method} request and result binary round trip`, () => { + expect(P.Method[method]).toBe(value) + expect(P.RpcCall.fields.find((f) => f.localName === field)?.no).toBe(tag) + expect(P.RpcResult.fields.find((f) => f.localName === field)?.no).toBe(tag) + const call = P.RpcCall.fromJson({ method, [field]: request }) + expect(call.input.oneofKind).toBe(field) + const decodedCall = P.RpcCall.fromBinary(P.RpcCall.toBinary(call)) + expect(decodedCall).toEqual(call) + expect(P.RpcCall.toJsonString(decodedCall)).toEqual(P.RpcCall.toJsonString(call)) + const response = P.RpcResult.fromJson({ reqMsgId: "9007199254740993", [field]: result }) + expect(response.result.oneofKind).toBe(field) + const decodedResult = P.RpcResult.fromBinary(P.RpcResult.toBinary(response)) + expect(decodedResult).toEqual(response) + expect(P.RpcResult.toJsonString(decodedResult)).toEqual(P.RpcResult.toJsonString(response)) + }) + } + test("both namespaces stay unique and previous methods survive", () => { + for (const type of [P.RpcCall, P.RpcResult]) { + const tags = type.fields.map((f) => f.no) + expect(new Set(tags).size).toBe(tags.length) + } + expect(P.Method.GET_GRID).toBe(83) + expect(P.Method.SEARCH_MESSAGES).toBe(28) + expect(P.ConnectionInit.fields.some((f) => f.localName === "supportsWorking")).toBe(true) + }) +}) diff --git a/proto/client.proto b/proto/client.proto index 1bfd9f851..c834cec95 100644 --- a/proto/client.proto +++ b/proto/client.proto @@ -10,6 +10,7 @@ message MessageContentPayload { MessageReplies replies = 3; MessageService service_message = 4; MessageSubthread subthread = 5; + optional bool counts_as_unread = 6; } message MessageVoiceContent { diff --git a/proto/core.proto b/proto/core.proto index de615d967..03a50db04 100644 --- a/proto/core.proto +++ b/proto/core.proto @@ -439,6 +439,7 @@ message MessageService { oneof event { MessageServiceThreadBacklink thread_backlink = 1; MessageServicePinnedMessage pinned_message = 2; + MessageServiceGridTranscript grid_transcript = 3; } } @@ -452,6 +453,21 @@ message MessageServicePinnedMessage { optional int64 message_id = 1; } +enum GridTranscriptMessageKind { + GRID_TRANSCRIPT_TURN = 0; + GRID_TRANSCRIPT_STARTED = 1; + GRID_TRANSCRIPT_STOPPED = 2; + GRID_TRANSCRIPT_INTERRUPTED = 3; + GRID_TRANSCRIPT_LINK = 4; +} + +message MessageServiceGridTranscript { + string run_id = 1; + string segment_id = 2; + optional int64 speaker_user_id = 3; + GridTranscriptMessageKind kind = 4; +} + // Structural rich content projected alongside Message.message and // Message.entities. Text-bearing blocks use UTF-16 ranges into Message.message; // inline entities remain owned by Message.entities so there is one text/entity @@ -677,6 +693,9 @@ message Message { // Canonical card projection for anchored replies and materialized subthreads. optional MessageSubthread subthread = 25; + + // Explicit false suppresses unread for generated/historical rows. + optional bool counts_as_unread = 26; } // Agent providers that implement Inline's session continuity contract. The @@ -1602,6 +1621,10 @@ enum Method { REQUEST_BOT_FILESYSTEM = 143; ANSWER_BOT_FILESYSTEM = 144; SET_SPACE_PHOTO = 145; + MOVE_GRID_CALL_HERE = 146; + OPEN_GRID_THREAD = 147; + SET_GRID_TRANSCRIPTION = 148; + LIST_GRID_TRANSCRIPTS = 149; } message RpcCall { @@ -1752,6 +1775,10 @@ message RpcCall { RequestBotFilesystemInput requestBotFilesystem = 144; AnswerBotFilesystemInput answerBotFilesystem = 145; SetSpacePhotoInput setSpacePhoto = 146; + MoveGridCallHereInput moveGridCallHere = 147; + OpenGridThreadInput openGridThread = 148; + SetGridTranscriptionInput setGridTranscription = 149; + ListGridTranscriptsInput listGridTranscripts = 150; } } @@ -1904,6 +1931,10 @@ message RpcResult { RequestBotFilesystemResult requestBotFilesystem = 144; AnswerBotFilesystemResult answerBotFilesystem = 145; SetSpacePhotoResult setSpacePhoto = 146; + MoveGridCallHereResult moveGridCallHere = 147; + OpenGridThreadResult openGridThread = 148; + SetGridTranscriptionResult setGridTranscription = 149; + ListGridTranscriptsResult listGridTranscripts = 150; } } @@ -2078,7 +2109,57 @@ message GridRoom { int64 updated_at = 7; repeated GridAvatar avatars = 8; optional GridConnection connection = 9; + optional int64 room_thread_id = 10; + bool transcription_available = 11; + optional GridTranscription transcription = 12; +} + +enum GridTranscriptionState { + GRID_TRANSCRIPTION_UNSPECIFIED = 0; + GRID_TRANSCRIPTION_STARTING = 1; + GRID_TRANSCRIPTION_ACTIVE = 2; + GRID_TRANSCRIPTION_STOPPING = 3; + GRID_TRANSCRIPTION_STOPPED = 4; + GRID_TRANSCRIPTION_INTERRUPTED = 5; +} + +message GridTranscription { + string run_id = 1; + GridTranscriptionState state = 2; + optional int64 transcript_chat_id = 3; + int32 revision = 4; +} + +enum GridTranscriptDestination { + GRID_TRANSCRIPT_LAST = 0; + GRID_TRANSCRIPT_NEW = 1; + GRID_TRANSCRIPT_EXISTING = 2; +} + +message OpenGridThreadInput { + int64 room_id = 1; + string expected_membership_id = 2; +} +message OpenGridThreadResult { int64 chat_id = 1; repeated Update updates = 2; } +message SetGridTranscriptionInput { + int64 room_id = 1; + string expected_membership_id = 2; + int32 expected_generation = 3; + optional string expected_run_id = 4; + int32 expected_revision = 5; + bool enabled = 6; + GridTranscriptDestination destination = 7; + optional int64 transcript_chat_id = 8; + string request_id = 9; +} +message SetGridTranscriptionResult { Grid grid = 1; repeated Update updates = 2; } +message ListGridTranscriptsInput { int64 room_id = 1; } +message GridTranscriptDestinationInfo { + int64 transcript_chat_id = 1; + string title = 2; + bool busy = 3; } +message ListGridTranscriptsResult { repeated GridTranscriptDestinationInfo transcripts = 1; } // Replaceable current state for one Space's implicit Grid. message Grid { @@ -2089,6 +2170,22 @@ message Grid { // Server-owned monotonic revision for this Space's complete Grid snapshot. // Clients must not replace a newer snapshot with a lower revision. int64 revision = 5; + // Effective server capability, including ownership fence enforcement. + // New clients must not admit media through an older/disabled server. + bool call_transfer_enabled = 6; +} + +// Personalized control ownership, disclosed only to the authenticated user. +// call_id stays stable across an explicit device transfer; membership_id is +// replaced on each transfer/resume and fences commands and media credentials. +message GridCurrentCall { + string call_id = 1; + int64 space_id = 2; + int64 room_id = 3; + string membership_id = 4; + bool owned_by_current_session = 5; + // Coarse existing session client kind (ios, macos, etc.), not a device ID. + string owner_client_type = 6; } // Short-lived, session-targeted LiveKit credentials. Never persist or include @@ -2099,11 +2196,21 @@ message GridConnectionCredentials { string participant_identity = 3; string token = 4; int64 expires_at = 5; + string call_id = 6; + string membership_id = 7; } -message GetGridInput { int64 space_id = 1; } +message GetGridInput { + int64 space_id = 1; + // Explicit renewal by the admitted owner. Omit for read-only browsing when + // call-transfer enforcement is active. A stale fence never renews presence. + optional string expected_membership_id = 2; +} -message GetGridResult { Grid grid = 1; } +message GetGridResult { + Grid grid = 1; + optional GridCurrentCall current_call = 2; +} // Home-level discovery for every Grid-enabled Space the current user can see. // This is a bounded summary: full rooms remain available only through GetGrid. @@ -2116,31 +2223,66 @@ message GridHomeSpace { message GetGridHomeInput {} -message GetGridHomeResult { repeated GridHomeSpace spaces = 1; } +message GetGridHomeResult { + repeated GridHomeSpace spaces = 1; + optional GridCurrentCall current_call = 2; + bool call_transfer_enabled = 3; +} message CreateGridRoomInput { int64 space_id = 1; optional bool microphone_enabled = 2; + // Required when call_transfer_enabled: empty expects no active presence; + // nonempty must match the current session's live membership exactly. + optional string expected_membership_id = 3; } message CreateGridRoomResult { repeated Grid grids = 1; optional GridConnectionCredentials connection = 2; + // Successful claims report the projection captured at commit. A newer + // accepted Grid/self state wins; this field alone cannot admit media. + optional GridCurrentCall current_call = 3; } message JoinGridRoomInput { int64 room_id = 1; optional bool microphone_enabled = 2; + optional string expected_membership_id = 3; } message JoinGridRoomResult { repeated Grid grids = 1; optional GridConnectionCredentials connection = 2; + optional GridCurrentCall current_call = 3; } -message LeaveGridRoomInput { int64 expected_room_id = 1; } +message LeaveGridRoomInput { + int64 expected_room_id = 1; + optional string expected_membership_id = 2; +} -message LeaveGridRoomResult { repeated Grid grids = 1; } +message LeaveGridRoomResult { + repeated Grid grids = 1; + // Display/repair only; leaving never grants a replacement admission. + optional GridCurrentCall current_call = 2; +} + +// Explicitly move this user's existing room participation to the authenticated +// calling session (also used for a same-login cold-process Resume). +message MoveGridCallHereInput { + string call_id = 1; + string expected_membership_id = 2; +} + +message MoveGridCallHereResult { + repeated Grid grids = 1; + optional GridConnectionCredentials connection = 2; + // Captured at the claim's commit when moved=true; display/repair otherwise. + optional GridCurrentCall current_call = 3; + // False means the expected call/membership changed; no claim was performed. + bool moved = 4; +} message SetGridRoomTitleInput { int64 room_id = 1; @@ -2164,6 +2306,7 @@ message DeleteGridRoomResult { Grid grid = 1; } message PrepareGridConnectionInput { int64 room_id = 1; int32 generation = 2; + optional string expected_membership_id = 3; } enum GridConnectionUnavailableReason { @@ -2180,6 +2323,7 @@ message PrepareGridConnectionResult { message SetGridAvatarMicrophoneEnabledInput { int64 expected_room_id = 1; bool enabled = 2; + optional string expected_membership_id = 3; } message SetGridAvatarMicrophoneEnabledResult { bool enabled = 1; } diff --git a/proto/server.proto b/proto/server.proto index f6c1220d8..72cf8fe73 100644 --- a/proto/server.proto +++ b/proto/server.proto @@ -376,6 +376,7 @@ message SystemMessage { oneof event { ThreadBacklinkSystemMessage thread_backlink = 1; PinnedMessageSystemMessage pinned_message = 2; + MessageServiceGridTranscript grid_transcript = 3; } } diff --git a/server/drizzle/0154_grid-call-identity.sql b/server/drizzle/0154_grid-call-identity.sql new file mode 100644 index 000000000..f7c608991 --- /dev/null +++ b/server/drizzle/0154_grid-call-identity.sql @@ -0,0 +1 @@ +ALTER TABLE "grid_presence" ADD COLUMN "call_id" uuid DEFAULT gen_random_uuid() NOT NULL; \ No newline at end of file diff --git a/server/drizzle/0155_grid-transcription.sql b/server/drizzle/0155_grid-transcription.sql new file mode 100644 index 000000000..9d5386cd8 --- /dev/null +++ b/server/drizzle/0155_grid-transcription.sql @@ -0,0 +1,72 @@ +CREATE TABLE "grid_transcription_runs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "source_room_id" integer NOT NULL, + "space_id" integer NOT NULL, + "room_chat_id" integer NOT NULL, + "transcript_chat_id" integer NOT NULL, + "destination_parent_chat_id" integer NOT NULL, + "original_anchor_id" integer NOT NULL, + "room_link_message_id" integer, + "actor_user_id" integer NOT NULL, + "request_id" uuid NOT NULL, + "model" varchar(32) NOT NULL, + "state" varchar(16) NOT NULL, + "generation" integer NOT NULL, + "provider_target" varchar(255) NOT NULL, + "revision" integer DEFAULT 1 NOT NULL, + "claim_epoch" integer DEFAULT 0 NOT NULL, + "worker_id" varchar(80), + "lease_expires_at" timestamp (3), + "expires_at" timestamp (3) NOT NULL, + "stop_requested_at" timestamp (3), + "interruption_reason" varchar(80), + "created_at" timestamp (3) DEFAULT now() NOT NULL, + CONSTRAINT "grid_transcription_state_check" CHECK ("grid_transcription_runs"."state" in ('starting','active','stopping','stopped','interrupted')) +); +--> statement-breakpoint +CREATE TABLE "grid_transcription_segments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "run_id" uuid NOT NULL, + "claim_epoch" integer NOT NULL, + "speaker_user_id" integer NOT NULL, + "membership_id" uuid NOT NULL, + "track_sid" varchar(128) NOT NULL, + "source_turn_key" varchar(128) NOT NULL, + "state" varchar(16) DEFAULT 'admitted' NOT NULL, + "message_id" integer, + "admitted_at" timestamp (3) DEFAULT now() NOT NULL, + CONSTRAINT "grid_transcription_segment_state_check" CHECK ("grid_transcription_segments"."state" in ('admitted','finalized','discarded')) +); +--> statement-breakpoint +CREATE TABLE "grid_transcription_space_revisions" ( + "space_id" integer PRIMARY KEY NOT NULL, + "revision" bigint DEFAULT 0 NOT NULL +); +--> statement-breakpoint +CREATE TABLE "grid_transcription_worker" ( + "id" integer PRIMARY KEY NOT NULL, + "worker_id" varchar(80) NOT NULL, + "model" varchar(32) NOT NULL, + "heartbeat_at" timestamp (3) NOT NULL +); +--> statement-breakpoint +ALTER TABLE "grid_rooms" ADD COLUMN "room_thread_id" integer;--> statement-breakpoint +ALTER TABLE "grid_transcription_segments" ADD CONSTRAINT "grid_transcription_segments_run_id_grid_transcription_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."grid_transcription_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "grid_transcription_request_unique" ON "grid_transcription_runs" USING btree ("actor_user_id","request_id");--> statement-breakpoint +CREATE UNIQUE INDEX "grid_transcription_active_room_unique" ON "grid_transcription_runs" USING btree ("source_room_id") WHERE "grid_transcription_runs"."state" in ('starting','active','stopping');--> statement-breakpoint +CREATE UNIQUE INDEX "grid_transcription_active_destination_unique" ON "grid_transcription_runs" USING btree ("transcript_chat_id") WHERE "grid_transcription_runs"."state" in ('starting','active','stopping');--> statement-breakpoint +CREATE INDEX "grid_transcription_space_recent_idx" ON "grid_transcription_runs" USING btree ("space_id","created_at");--> statement-breakpoint +CREATE INDEX "grid_transcription_source_room_idx" ON "grid_transcription_runs" USING btree ("source_room_id");--> statement-breakpoint +CREATE UNIQUE INDEX "grid_transcription_segment_source_unique" ON "grid_transcription_segments" USING btree ("run_id","track_sid","source_turn_key");--> statement-breakpoint +CREATE FUNCTION "bump_grid_transcription_revision"() RETURNS trigger AS $$ +BEGIN + INSERT INTO "grid_transcription_space_revisions" ("space_id", "revision") VALUES (NEW."space_id", 1) + ON CONFLICT ("space_id") DO UPDATE + SET "revision" = "grid_transcription_space_revisions"."revision" + 1; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +--> statement-breakpoint +CREATE TRIGGER "grid_transcription_revision_changed" +AFTER INSERT OR UPDATE OF "state", "revision" ON "grid_transcription_runs" +FOR EACH ROW EXECUTE FUNCTION "bump_grid_transcription_revision"(); diff --git a/server/drizzle/meta/0154_snapshot.json b/server/drizzle/meta/0154_snapshot.json new file mode 100644 index 000000000..885acf6ab --- /dev/null +++ b/server/drizzle/meta/0154_snapshot.json @@ -0,0 +1,12710 @@ +{ + "id": "c0fd85d8-6fc2-45d5-9db9-f630a77657fe", + "prevId": "f092d048-4f44-45b4-9f34-913ad0a74c2b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_zone": { + "name": "time_zone", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.there_users": { + "name": "there_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "time_zone": { + "name": "time_zone", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "there_users_email_unique": { + "name": "there_users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": "nextval('user_id')" + }, + "email": { + "name": "email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "phone_number": { + "name": "phone_number", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "phone_verified": { + "name": "phone_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "online": { + "name": "online", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_online": { + "name": "last_online", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "photo_file_id": { + "name": "photo_file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pending_setup": { + "name": "pending_setup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "time_zone": { + "name": "time_zone", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "share_time_zone": { + "name": "share_time_zone", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "appear_in_global_search": { + "name": "appear_in_global_search", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_thread_number": { + "name": "next_thread_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "bot": { + "name": "bot", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "bot_creator_id": { + "name": "bot_creator_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "update_seq": { + "name": "update_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_update_date": { + "name": "last_update_date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_photo_file_id_files_id_fk": { + "name": "users_photo_file_id_files_id_fk", + "tableFrom": "users", + "tableTo": "files", + "columnsFrom": [ + "photo_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "users_bot_creator_id_users_id_fk": { + "name": "users_bot_creator_id_users_id_fk", + "tableFrom": "users", + "tableTo": "users", + "columnsFrom": [ + "bot_creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_phone_number_unique": { + "name": "users_phone_number_unique", + "nullsNotDistinct": false, + "columns": [ + "phone_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "last_active": { + "name": "last_active", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "personal_data_encrypted": { + "name": "personal_data_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "personal_data_iv": { + "name": "personal_data_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "personal_data_tag": { + "name": "personal_data_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "applePushToken": { + "name": "applePushToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "apple_push_token_encrypted": { + "name": "apple_push_token_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "apple_push_token_iv": { + "name": "apple_push_token_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "apple_push_token_tag": { + "name": "apple_push_token_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "push_notification_provider": { + "name": "push_notification_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "push_content_key_public": { + "name": "push_content_key_public", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "push_content_key_id": { + "name": "push_content_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "push_content_key_algorithm": { + "name": "push_content_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "push_content_version": { + "name": "push_content_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_type": { + "name": "client_type", + "type": "client_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "clientVersion": { + "name": "clientVersion", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "osVersion": { + "name": "osVersion", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_token_hash_idx": { + "name": "sessions_token_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "device_id_user_unique": { + "name": "device_id_user_unique", + "nullsNotDistinct": false, + "columns": [ + "device_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.superadmin_sessions": { + "name": "superadmin_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "step_up_at": { + "name": "step_up_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "idle_expires_at": { + "name": "idle_expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent_hash": { + "name": "user_agent_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "superadmin_sessions_user_id_users_id_fk": { + "name": "superadmin_sessions_user_id_users_id_fk", + "tableFrom": "superadmin_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "superadmin_sessions_token_hash_unique": { + "name": "superadmin_sessions_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.login_codes": { + "name": "login_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "phone_number": { + "name": "phone_number", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false + }, + "challenge_id": { + "name": "challenge_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "login_codes_challenge_id_unique": { + "name": "login_codes_challenge_id_unique", + "columns": [ + { + "expression": "challenge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "login_codes_email_expires_idx": { + "name": "login_codes_email_expires_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "login_codes_phone_expires_idx": { + "name": "login_codes_phone_expires_idx", + "columns": [ + { + "expression": "phone_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "login_codes_code_or_hash_present": { + "name": "login_codes_code_or_hash_present", + "value": "\"login_codes\".\"code\" is not null or \"login_codes\".\"code_hash\" is not null" + } + }, + "isRLSEnabled": false + }, + "public.invite_codes": { + "name": "invite_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "redeemed_by_user_id": { + "name": "redeemed_by_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "invite_codes_code_unique": { + "name": "invite_codes_code_unique", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_codes_owner_user_id_idx": { + "name": "invite_codes_owner_user_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_codes_redeemed_by_user_id_idx": { + "name": "invite_codes_redeemed_by_user_id_idx", + "columns": [ + { + "expression": "redeemed_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invite_codes_owner_user_id_users_id_fk": { + "name": "invite_codes_owner_user_id_users_id_fk", + "tableFrom": "invite_codes", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "invite_codes_created_by_user_id_users_id_fk": { + "name": "invite_codes_created_by_user_id_users_id_fk", + "tableFrom": "invite_codes", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "invite_codes_redeemed_by_user_id_users_id_fk": { + "name": "invite_codes_redeemed_by_user_id_users_id_fk", + "tableFrom": "invite_codes", + "tableTo": "users", + "columnsFrom": [ + "redeemed_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.superadmin_users": { + "name": "superadmin_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_set_at": { + "name": "password_set_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "totp_secret_encrypted": { + "name": "totp_secret_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "totp_secret_iv": { + "name": "totp_secret_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "totp_secret_tag": { + "name": "totp_secret_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "totp_enabled_at": { + "name": "totp_enabled_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "totp_last_used_at": { + "name": "totp_last_used_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "failed_login_attempts": { + "name": "failed_login_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_login_attempt_at": { + "name": "last_login_attempt_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "login_locked_until": { + "name": "login_locked_until", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "superadmin_users_user_id_users_id_fk": { + "name": "superadmin_users_user_id_users_id_fk", + "tableFrom": "superadmin_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "superadmin_users_email_unique": { + "name": "superadmin_users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "superadmin_users_user_id_unique": { + "name": "superadmin_users_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spaces": { + "name": "spaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "photo_file_unique_id": { + "name": "photo_file_unique_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "is_pro": { + "name": "is_pro", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "handle": { + "name": "handle", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "creatorId": { + "name": "creatorId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "can_public_join": { + "name": "can_public_join", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted": { + "name": "deleted", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "next_thread_number": { + "name": "next_thread_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "update_seq": { + "name": "update_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_update_date": { + "name": "last_update_date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "grid_revision": { + "name": "grid_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "spaces_handle_unique": { + "name": "spaces_handle_unique", + "columns": [ + { + "expression": "lower(\"handle\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spaces_creatorId_users_id_fk": { + "name": "spaces_creatorId_users_id_fk", + "tableFrom": "spaces", + "tableTo": "users", + "columnsFrom": [ + "creatorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "spaces_photo_file_unique_id_fkey": { + "name": "spaces_photo_file_unique_id_fkey", + "tableFrom": "spaces", + "tableTo": "files", + "columnsFrom": [ + "photo_file_unique_id" + ], + "columnsTo": [ + "file_unique_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "spaces_public_join_handle_check": { + "name": "spaces_public_join_handle_check", + "value": "not \"spaces\".\"can_public_join\" or (\"spaces\".\"is_public\" and \"spaces\".\"handle\" is not null and \"spaces\".\"handle\" ~ '^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$')" + } + }, + "isRLSEnabled": false + }, + "public.space_invite_links": { + "name": "space_invite_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "token_encrypted": { + "name": "token_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "space_invite_links_token_hash_unique": { + "name": "space_invite_links_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "space_invite_links_active_space_unique": { + "name": "space_invite_links_active_space_unique", + "columns": [ + { + "expression": "space_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"space_invite_links\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "space_invite_links_space_id_idx": { + "name": "space_invite_links_space_id_idx", + "columns": [ + { + "expression": "space_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "space_invite_links_created_by_user_id_idx": { + "name": "space_invite_links_created_by_user_id_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "space_invite_links_space_id_spaces_id_fk": { + "name": "space_invite_links_space_id_spaces_id_fk", + "tableFrom": "space_invite_links", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "space_invite_links_created_by_user_id_users_id_fk": { + "name": "space_invite_links_created_by_user_id_users_id_fk", + "tableFrom": "space_invite_links", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "space_invite_links_token_hash_length": { + "name": "space_invite_links_token_hash_length", + "value": "octet_length(\"space_invite_links\".\"token_hash\") = 32" + }, + "space_invite_links_expiry_check": { + "name": "space_invite_links_expiry_check", + "value": "\"space_invite_links\".\"expires_at\" > \"space_invite_links\".\"created_at\"" + } + }, + "isRLSEnabled": false + }, + "public.space_join_blocks": { + "name": "space_join_blocks", + "schema": "", + "columns": { + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "space_join_blocks_user_id_idx": { + "name": "space_join_blocks_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "space_join_blocks_space_id_spaces_id_fk": { + "name": "space_join_blocks_space_id_spaces_id_fk", + "tableFrom": "space_join_blocks", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "space_join_blocks_user_id_users_id_fk": { + "name": "space_join_blocks_user_id_users_id_fk", + "tableFrom": "space_join_blocks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "space_join_blocks_pkey": { + "name": "space_join_blocks_pkey", + "columns": [ + "space_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "member_roles", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'member'" + }, + "can_access_public_chats": { + "name": "can_access_public_chats", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "invited_by": { + "name": "invited_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_space_id_spaces_id_fk": { + "name": "members_space_id_spaces_id_fk", + "tableFrom": "members", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_invited_by_users_id_fk": { + "name": "members_invited_by_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "members_user_id_space_id_unique": { + "name": "members_user_id_space_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "space_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_participants": { + "name": "chat_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "chat_participants_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_participants_chat_id_chats_id_fk": { + "name": "chat_participants_chat_id_chats_id_fk", + "tableFrom": "chat_participants", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "chat_participants_user_id_users_id_fk": { + "name": "chat_participants_user_id_users_id_fk", + "tableFrom": "chat_participants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_participant": { + "name": "unique_participant", + "nullsNotDistinct": false, + "columns": [ + "chat_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chats": { + "name": "chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "chats_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "type": { + "name": "type", + "type": "chat_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title_hash": { + "name": "title_hash", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "auto_title_generated": { + "name": "auto_title_generated", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_msg_id": { + "name": "last_msg_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "message_id_counter": { + "name": "message_id_counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "public_thread": { + "name": "public_thread", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "thread_number": { + "name": "thread_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_untitled": { + "name": "is_untitled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "parent_chat_id": { + "name": "parent_chat_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_user_id": { + "name": "min_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_user_id": { + "name": "max_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_context": { + "name": "agent_context", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "update_seq": { + "name": "update_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_update_date": { + "name": "last_update_date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chats_title_hash_idx": { + "name": "chats_title_hash_idx", + "columns": [ + { + "expression": "title_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "space_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_thread_number_unique": { + "name": "user_thread_number_unique", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chats\".\"space_id\" is null and \"chats\".\"thread_number\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chats_parent_chat_id_idx": { + "name": "chats_parent_chat_id_idx", + "columns": [ + { + "expression": "parent_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chats_space_id_spaces_id_fk": { + "name": "chats_space_id_spaces_id_fk", + "tableFrom": "chats", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "chats_created_by_users_id_fk": { + "name": "chats_created_by_users_id_fk", + "tableFrom": "chats", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "chats_parent_chat_id_chats_id_fk": { + "name": "chats_parent_chat_id_chats_id_fk", + "tableFrom": "chats", + "tableTo": "chats", + "columnsFrom": [ + "parent_chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "chats_min_user_id_users_id_fk": { + "name": "chats_min_user_id_users_id_fk", + "tableFrom": "chats", + "tableTo": "users", + "columnsFrom": [ + "min_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "chats_max_user_id_users_id_fk": { + "name": "chats_max_user_id_users_id_fk", + "tableFrom": "chats", + "tableTo": "users", + "columnsFrom": [ + "max_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "last_msg_id_fk": { + "name": "last_msg_id_fk", + "tableFrom": "chats", + "tableTo": "messages", + "columnsFrom": [ + "id", + "last_msg_id" + ], + "columnsTo": [ + "chat_id", + "message_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "parent_message_id_fk": { + "name": "parent_message_id_fk", + "tableFrom": "chats", + "tableTo": "messages", + "columnsFrom": [ + "parent_chat_id", + "parent_message_id" + ], + "columnsTo": [ + "chat_id", + "message_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_ids_unique": { + "name": "user_ids_unique", + "nullsNotDistinct": false, + "columns": [ + "min_user_id", + "max_user_id" + ] + }, + "space_thread_number_unique": { + "name": "space_thread_number_unique", + "nullsNotDistinct": false, + "columns": [ + "space_id", + "thread_number" + ] + }, + "reply_thread_parent_unique": { + "name": "reply_thread_parent_unique", + "nullsNotDistinct": false, + "columns": [ + "parent_chat_id", + "parent_message_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "user_ids_check": { + "name": "user_ids_check", + "value": "\"chats\".\"min_user_id\" <= \"chats\".\"max_user_id\"" + }, + "parent_message_requires_parent_chat": { + "name": "parent_message_requires_parent_chat", + "value": "\"chats\".\"parent_message_id\" is null or \"chats\".\"parent_chat_id\" is not null" + } + }, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "global_id": { + "name": "global_id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "random_id": { + "name": "random_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text_encrypted": { + "name": "text_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "text_iv": { + "name": "text_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "text_tag": { + "name": "text_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "entities_encrypted": { + "name": "entities_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "entities_iv": { + "name": "entities_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "entities_tag": { + "name": "entities_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "actions_encrypted": { + "name": "actions_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "actions_iv": { + "name": "actions_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "actions_tag": { + "name": "actions_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "system_message_encrypted": { + "name": "system_message_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "system_message_iv": { + "name": "system_message_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "system_message_tag": { + "name": "system_message_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "from_id": { + "name": "from_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "edit_date": { + "name": "edit_date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "rev": { + "name": "rev", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "block_content_id": { + "name": "block_content_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "reply_to_msg_id": { + "name": "reply_to_msg_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fwd_from_peer_user_id": { + "name": "fwd_from_peer_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fwd_from_peer_chat_id": { + "name": "fwd_from_peer_chat_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fwd_from_message_id": { + "name": "fwd_from_message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fwd_from_sender_id": { + "name": "fwd_from_sender_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "grouped_id": { + "name": "grouped_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "photo_id": { + "name": "photo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "video_id": { + "name": "video_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "document_id": { + "name": "document_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "voice_id": { + "name": "voice_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_sticker": { + "name": "is_sticker", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "has_link": { + "name": "has_link", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "counts_as_unread": { + "name": "counts_as_unread", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "msg_id_per_chat_index": { + "name": "msg_id_per_chat_index", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_chat_id_message_id_desc_idx": { + "name": "messages_chat_id_message_id_desc_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unread_count_index": { + "name": "unread_count_index", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "from_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_block_content_id_idx": { + "name": "messages_block_content_id_idx", + "columns": [ + { + "expression": "block_content_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_activity_date_from_id_idx": { + "name": "messages_activity_date_from_id_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "from_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"messages\".\"system_message_encrypted\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_chat_id_chats_id_fk": { + "name": "messages_chat_id_chats_id_fk", + "tableFrom": "messages", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_from_id_users_id_fk": { + "name": "messages_from_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "from_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_block_content_id_block_contents_id_fk": { + "name": "messages_block_content_id_block_contents_id_fk", + "tableFrom": "messages", + "tableTo": "block_contents", + "columnsFrom": [ + "block_content_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_photo_id_photos_id_fk": { + "name": "messages_photo_id_photos_id_fk", + "tableFrom": "messages", + "tableTo": "photos", + "columnsFrom": [ + "photo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_video_id_videos_id_fk": { + "name": "messages_video_id_videos_id_fk", + "tableFrom": "messages", + "tableTo": "videos", + "columnsFrom": [ + "video_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_document_id_documents_id_fk": { + "name": "messages_document_id_documents_id_fk", + "tableFrom": "messages", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_voice_id_voices_id_fk": { + "name": "messages_voice_id_voices_id_fk", + "tableFrom": "messages", + "tableTo": "voices", + "columnsFrom": [ + "voice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_file_id_files_id_fk": { + "name": "messages_file_id_files_id_fk", + "tableFrom": "messages", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "msg_id_per_chat_unique": { + "name": "msg_id_per_chat_unique", + "nullsNotDistinct": false, + "columns": [ + "message_id", + "chat_id" + ] + }, + "random_id_per_sender_unique": { + "name": "random_id_per_sender_unique", + "nullsNotDistinct": false, + "columns": [ + "random_id", + "from_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.block_content_image_jobs": { + "name": "block_content_image_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "block_content_image_jobs_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "content_id": { + "name": "content_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "expected_revision": { + "name": "expected_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "block_path": { + "name": "block_path", + "type": "integer[]", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "hash_version": { + "name": "hash_version", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_encrypted": { + "name": "source_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_iv": { + "name": "source_iv", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_tag": { + "name": "source_tag", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "available_at": { + "name": "available_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lease_token": { + "name": "lease_token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "staged_object_path_encrypted": { + "name": "staged_object_path_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "staged_object_path_iv": { + "name": "staged_object_path_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "staged_object_path_tag": { + "name": "staged_object_path_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "photo_id": { + "name": "photo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "block_content_image_jobs_ready_idx": { + "name": "block_content_image_jobs_ready_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "block_content_image_jobs_revision_idx": { + "name": "block_content_image_jobs_revision_idx", + "columns": [ + { + "expression": "content_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expected_revision", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "block_content_image_jobs_path_idx": { + "name": "block_content_image_jobs_path_idx", + "columns": [ + { + "expression": "content_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "block_content_image_jobs_content_id_block_contents_id_fk": { + "name": "block_content_image_jobs_content_id_block_contents_id_fk", + "tableFrom": "block_content_image_jobs", + "tableTo": "block_contents", + "columnsFrom": [ + "content_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "block_content_image_jobs_photo_id_photos_id_fk": { + "name": "block_content_image_jobs_photo_id_photos_id_fk", + "tableFrom": "block_content_image_jobs", + "tableTo": "photos", + "columnsFrom": [ + "photo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "block_content_image_jobs_state_check": { + "name": "block_content_image_jobs_state_check", + "value": "\"block_content_image_jobs\".\"state\" in ('pending', 'processing', 'ready', 'failed', 'canceled')" + }, + "block_content_image_jobs_attempts_check": { + "name": "block_content_image_jobs_attempts_check", + "value": "\"block_content_image_jobs\".\"attempts\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.block_contents": { + "name": "block_contents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "block_contents_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "payload_encrypted": { + "name": "payload_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "payload_iv": { + "name": "payload_iv", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "payload_tag": { + "name": "payload_tag", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.thread_graph_links": { + "name": "thread_graph_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "thread_graph_link_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "scope_type": { + "name": "scope_type", + "type": "thread_graph_scope_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "from_chat_id": { + "name": "from_chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "from_message_global_id": { + "name": "from_message_global_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "from_message_id": { + "name": "from_message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "from_message_revision": { + "name": "from_message_revision", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "entity_index": { + "name": "entity_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "to_chat_id": { + "name": "to_chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "backlink_message_global_id": { + "name": "backlink_message_global_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "link_text_hmac": { + "name": "link_text_hmac", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "target_title_hmac": { + "name": "target_title_hmac", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "thread_graph_links_scope_to_chat_idx": { + "name": "thread_graph_links_scope_to_chat_idx", + "columns": [ + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "to_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "thread_graph_links_scope_from_chat_idx": { + "name": "thread_graph_links_scope_from_chat_idx", + "columns": [ + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "from_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "thread_graph_links_from_message_idx": { + "name": "thread_graph_links_from_message_idx", + "columns": [ + { + "expression": "from_message_global_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "thread_graph_links_backlink_message_idx": { + "name": "thread_graph_links_backlink_message_idx", + "columns": [ + { + "expression": "backlink_message_global_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "thread_graph_links_active_idx": { + "name": "thread_graph_links_active_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "to_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"thread_graph_links\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tgl_from_chat_fk": { + "name": "tgl_from_chat_fk", + "tableFrom": "thread_graph_links", + "tableTo": "chats", + "columnsFrom": [ + "from_chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tgl_from_message_fk": { + "name": "tgl_from_message_fk", + "tableFrom": "thread_graph_links", + "tableTo": "messages", + "columnsFrom": [ + "from_message_global_id" + ], + "columnsTo": [ + "global_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tgl_to_chat_fk": { + "name": "tgl_to_chat_fk", + "tableFrom": "thread_graph_links", + "tableTo": "chats", + "columnsFrom": [ + "to_chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tgl_backlink_message_fk": { + "name": "tgl_backlink_message_fk", + "tableFrom": "thread_graph_links", + "tableTo": "messages", + "columnsFrom": [ + "backlink_message_global_id" + ], + "columnsTo": [ + "global_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "thread_graph_links_dedupe_key_unique": { + "name": "thread_graph_links_dedupe_key_unique", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subthread_parent_messages": { + "name": "subthread_parent_messages", + "schema": "", + "columns": { + "child_chat_id": { + "name": "child_chat_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "parent_message_global_id": { + "name": "parent_message_global_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "subthread_parent_messages_child_chat_fk": { + "name": "subthread_parent_messages_child_chat_fk", + "tableFrom": "subthread_parent_messages", + "tableTo": "chats", + "columnsFrom": [ + "child_chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "subthread_parent_messages_parent_message_fk": { + "name": "subthread_parent_messages_parent_message_fk", + "tableFrom": "subthread_parent_messages", + "tableTo": "messages", + "columnsFrom": [ + "parent_message_global_id" + ], + "columnsTo": [ + "global_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subthread_parent_messages_parent_message_unique": { + "name": "subthread_parent_messages_parent_message_unique", + "nullsNotDistinct": false, + "columns": [ + "parent_message_global_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dialogs": { + "name": "dialogs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "peer_user_id": { + "name": "peer_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "read_inbox_max_id": { + "name": "read_inbox_max_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "read_outbox_max_id": { + "name": "read_outbox_max_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "draft": { + "name": "draft", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "sidebar_visible": { + "name": "sidebar_visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "chat_list_hidden": { + "name": "chat_list_hidden", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "open": { + "name": "open", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "opened_date": { + "name": "opened_date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pinned_order": { + "name": "pinned_order", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unread_mark": { + "name": "unread_mark", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "notification_settings": { + "name": "notification_settings", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "follow_mode": { + "name": "follow_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "collapsed_max_id": { + "name": "collapsed_max_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "translation_enabled": { + "name": "translation_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "dialogs_user_id_chat_id_idx": { + "name": "dialogs_user_id_chat_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dialogs_user_id_peer_user_id_idx": { + "name": "dialogs_user_id_peer_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "peer_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dialogs_user_id_order_idx": { + "name": "dialogs_user_id_order_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dialogs_user_id_pinned_order_idx": { + "name": "dialogs_user_id_pinned_order_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pinned_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dialogs_user_id_folder_id_order_idx": { + "name": "dialogs_user_id_folder_id_order_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dialogs_user_id_users_id_fk": { + "name": "dialogs_user_id_users_id_fk", + "tableFrom": "dialogs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "dialogs_chat_id_chats_id_fk": { + "name": "dialogs_chat_id_chats_id_fk", + "tableFrom": "dialogs", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dialogs_peer_user_id_users_id_fk": { + "name": "dialogs_peer_user_id_users_id_fk", + "tableFrom": "dialogs", + "tableTo": "users", + "columnsFrom": [ + "peer_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "dialogs_space_id_spaces_id_fk": { + "name": "dialogs_space_id_spaces_id_fk", + "tableFrom": "dialogs", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "dialogs_folder_id_user_id_dialog_folders_id_user_id_fk": { + "name": "dialogs_folder_id_user_id_dialog_folders_id_user_id_fk", + "tableFrom": "dialogs", + "tableTo": "dialog_folders", + "columnsFrom": [ + "folder_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_id_user_id_unique": { + "name": "chat_id_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "chat_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_acknowledgements": { + "name": "chat_acknowledgements", + "schema": "", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "max_id": { + "name": "max_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cleared": { + "name": "cleared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "chat_acknowledgements_user_id_idx": { + "name": "chat_acknowledgements_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_acknowledgements_chat_id_chats_id_fk": { + "name": "chat_acknowledgements_chat_id_chats_id_fk", + "tableFrom": "chat_acknowledgements", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_acknowledgements_user_id_users_id_fk": { + "name": "chat_acknowledgements_user_id_users_id_fk", + "tableFrom": "chat_acknowledgements", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "chat_acknowledgements_chat_id_user_id_pk": { + "name": "chat_acknowledgements_chat_id_user_id_pk", + "columns": [ + "chat_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dialog_folders": { + "name": "dialog_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_order": { + "name": "pinned_order", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dialog_folders_user_id_order_idx": { + "name": "dialog_folders_user_id_order_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dialog_folders_user_id_pinned_order_idx": { + "name": "dialog_folders_user_id_pinned_order_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pinned_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dialog_folders_user_id_users_id_fk": { + "name": "dialog_folders_user_id_users_id_fk", + "tableFrom": "dialog_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dialog_folders_id_user_id_unique": { + "name": "dialog_folders_id_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_id_reservation": { + "name": "chat_id_reservation", + "schema": "", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_id_reservation_user_id_users_id_fk": { + "name": "chat_id_reservation_user_id_users_id_fk", + "tableFrom": "chat_id_reservation", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reactions": { + "name": "reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "reactions_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emoji_hash": { + "name": "emoji_hash", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "reactions_chat_id_chats_id_fk": { + "name": "reactions_chat_id_chats_id_fk", + "tableFrom": "reactions", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reactions_user_id_users_id_fk": { + "name": "reactions_user_id_users_id_fk", + "tableFrom": "reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_id_fk": { + "name": "message_id_fk", + "tableFrom": "reactions", + "tableTo": "messages", + "columnsFrom": [ + "chat_id", + "message_id" + ], + "columnsTo": [ + "chat_id", + "message_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reactions_identity_emoji_hash_unique": { + "name": "reactions_identity_emoji_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "chat_id", + "message_id", + "user_id", + "emoji_hash" + ] + }, + "unique_reaction_per_emoji": { + "name": "unique_reaction_per_emoji", + "nullsNotDistinct": false, + "columns": [ + "chat_id", + "message_id", + "user_id", + "emoji" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "file_unique_id": { + "name": "file_unique_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path_encrypted": { + "name": "path_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "path_iv": { + "name": "path_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "path_tag": { + "name": "path_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cdn": { + "name": "cdn", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "file_type": { + "name": "file_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "video_duration": { + "name": "video_duration", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "thumb_size": { + "name": "thumb_size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumb_for": { + "name": "thumb_for", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "bytes_encrypted": { + "name": "bytes_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "bytes_iv": { + "name": "bytes_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "bytes_tag": { + "name": "bytes_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "name_encrypted": { + "name": "name_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "name_iv": { + "name": "name_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "name_tag": { + "name": "name_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "files_user_id_users_id_fk": { + "name": "files_user_id_users_id_fk", + "tableFrom": "files", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "files_thumb_for_files_id_fk": { + "name": "files_thumb_for_files_id_fk", + "tableFrom": "files", + "tableTo": "files", + "columnsFrom": [ + "thumb_for" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "files_file_unique_id_unique": { + "name": "files_file_unique_id_unique", + "nullsNotDistinct": false, + "columns": [ + "file_unique_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_oauth_states": { + "name": "integration_oauth_states", + "schema": "", + "columns": { + "state_hash": { + "name": "state_hash", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "callback_scheme": { + "name": "callback_scheme", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_oauth_states_user": { + "name": "integration_oauth_states_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_oauth_states_expires": { + "name": "integration_oauth_states_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_oauth_states_user_id_users_id_fk": { + "name": "integration_oauth_states_user_id_users_id_fk", + "tableFrom": "integration_oauth_states", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_oauth_states_space_id_spaces_id_fk": { + "name": "integration_oauth_states_space_id_spaces_id_fk", + "tableFrom": "integration_oauth_states", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integrations": { + "name": "integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_encrypted": { + "name": "access_token_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "access_token_iv": { + "name": "access_token_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "access_token_tag": { + "name": "access_token_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "notion_database_id": { + "name": "notion_database_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_team_id": { + "name": "linear_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integrations_space_provider_unique": { + "name": "integrations_space_provider_unique", + "columns": [ + { + "expression": "space_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integrations_user_id_users_id_fk": { + "name": "integrations_user_id_users_id_fk", + "tableFrom": "integrations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "integrations_space_id_spaces_id_fk": { + "name": "integrations_space_id_spaces_id_fk", + "tableFrom": "integrations", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_tokens": { + "name": "bot_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_encrypted": { + "name": "token_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "token_iv": { + "name": "token_iv", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "token_tag": { + "name": "token_tag", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bot_tokens_bot_user_id_unique": { + "name": "bot_tokens_bot_user_id_unique", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_tokens_bot_user_id_users_id_fk": { + "name": "bot_tokens_bot_user_id_users_id_fk", + "tableFrom": "bot_tokens", + "tableTo": "users", + "columnsFrom": [ + "bot_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "bot_tokens_session_id_sessions_id_fk": { + "name": "bot_tokens_session_id_sessions_id_fk", + "tableFrom": "bot_tokens", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_commands": { + "name": "bot_commands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bot_commands_bot_user_id_command_unique": { + "name": "bot_commands_bot_user_id_command_unique", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "command", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_commands_bot_user_id_users_id_fk": { + "name": "bot_commands_bot_user_id_users_id_fk", + "tableFrom": "bot_commands", + "tableTo": "users", + "columnsFrom": [ + "bot_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_skills": { + "name": "bot_skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bot_skills_bot_user_id_key_unique": { + "name": "bot_skills_bot_user_id_key_unique", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_skills_bot_user_id_users_id_fk": { + "name": "bot_skills_bot_user_id_users_id_fk", + "tableFrom": "bot_skills", + "tableTo": "users", + "columnsFrom": [ + "bot_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_agents": { + "name": "bot_agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "emoji": { + "name": "emoji", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "skill_key": { + "name": "skill_key", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "instructions_encrypted": { + "name": "instructions_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bot_agents_bot_user_id_idx": { + "name": "bot_agents_bot_user_id_idx", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_agents_bot_user_id_users_id_fk": { + "name": "bot_agents_bot_user_id_users_id_fk", + "tableFrom": "bot_agents", + "tableTo": "users", + "columnsFrom": [ + "bot_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_capabilities": { + "name": "bot_capabilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bot_capabilities_bot_user_id_kind_unique": { + "name": "bot_capabilities_bot_user_id_kind_unique", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_capabilities_bot_user_id_users_id_fk": { + "name": "bot_capabilities_bot_user_id_users_id_fk", + "tableFrom": "bot_capabilities", + "tableTo": "users", + "columnsFrom": [ + "bot_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_avatar_assets": { + "name": "bot_avatar_assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_avatar_assets_bot_user_id_users_id_fk": { + "name": "bot_avatar_assets_bot_user_id_users_id_fk", + "tableFrom": "bot_avatar_assets", + "tableTo": "users", + "columnsFrom": [ + "bot_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "bot_avatar_assets_file_id_files_id_fk": { + "name": "bot_avatar_assets_file_id_files_id_fk", + "tableFrom": "bot_avatar_assets", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "bot_avatar_assets_bot_user_id_unique": { + "name": "bot_avatar_assets_bot_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "bot_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_message_routes": { + "name": "bot_message_routes", + "schema": "", + "columns": { + "bot_user_id": { + "name": "bot_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "activation_reason": { + "name": "activation_reason", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "bot_message_routes_identity_unique": { + "name": "bot_message_routes_identity_unique", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_message_routes_message_idx": { + "name": "bot_message_routes_message_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_message_routes_expiry_idx": { + "name": "bot_message_routes_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_message_routes_bot_user_id_users_id_fk": { + "name": "bot_message_routes_bot_user_id_users_id_fk", + "tableFrom": "bot_message_routes", + "tableTo": "users", + "columnsFrom": [ + "bot_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_update_streams": { + "name": "bot_update_streams", + "schema": "", + "columns": { + "bot_user_id": { + "name": "bot_user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "next_update_id": { + "name": "next_update_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "acknowledged_update_id": { + "name": "acknowledged_update_id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allowed_updates": { + "name": "allowed_updates", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "message_trigger": { + "name": "message_trigger", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'mentions'" + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_secret_encrypted": { + "name": "webhook_secret_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "poll_lease_token": { + "name": "poll_lease_token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "poll_lease_expires_at": { + "name": "poll_lease_expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "config_generation": { + "name": "config_generation", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "pending_update_count": { + "name": "pending_update_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "pending_payload_bytes": { + "name": "pending_payload_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "delivery_locked_at": { + "name": "delivery_locked_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dropped_update_count": { + "name": "dropped_update_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_update_streams_bot_user_id_users_id_fk": { + "name": "bot_update_streams_bot_user_id_users_id_fk", + "tableFrom": "bot_update_streams", + "tableTo": "users", + "columnsFrom": [ + "bot_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_updates": { + "name": "bot_updates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "bot_updates_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "update_id": { + "name": "update_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "update_type": { + "name": "update_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "payload_encrypted": { + "name": "payload_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "payload_byte_count": { + "name": "payload_byte_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_event_id": { + "name": "source_event_id", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "claim_token": { + "name": "claim_token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "claim_generation": { + "name": "claim_generation", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bot_updates_bot_update_id_unique": { + "name": "bot_updates_bot_update_id_unique", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_updates_bot_source_event_unique": { + "name": "bot_updates_bot_source_event_unique", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_updates_pending_idx": { + "name": "bot_updates_pending_idx", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_updates_expiry_idx": { + "name": "bot_updates_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_updates_delivery_idx": { + "name": "bot_updates_delivery_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_updates_bot_user_id_users_id_fk": { + "name": "bot_updates_bot_user_id_users_id_fk", + "tableFrom": "bot_updates", + "tableTo": "users", + "columnsFrom": [ + "bot_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "documents_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "file_id": { + "name": "file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "file_name": { + "name": "file_name", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "file_name_iv": { + "name": "file_name_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "file_name_tag": { + "name": "file_name_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "photo_id": { + "name": "photo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "documents_file_id_files_id_fk": { + "name": "documents_file_id_files_id_fk", + "tableFrom": "documents", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "documents_photo_id_photos_id_fk": { + "name": "documents_photo_id_photos_id_fk", + "tableFrom": "documents", + "tableTo": "photos", + "columnsFrom": [ + "photo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.photo_sizes": { + "name": "photo_sizes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "photo_sizes_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "file_id": { + "name": "file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photo_id": { + "name": "photo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "photo_sizes_photo_id_idx": { + "name": "photo_sizes_photo_id_idx", + "columns": [ + { + "expression": "photo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "photo_sizes_file_id_files_id_fk": { + "name": "photo_sizes_file_id_files_id_fk", + "tableFrom": "photo_sizes", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "photo_sizes_photo_id_photos_id_fk": { + "name": "photo_sizes_photo_id_photos_id_fk", + "tableFrom": "photo_sizes", + "tableTo": "photos", + "columnsFrom": [ + "photo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.photos": { + "name": "photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "photos_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "stripped": { + "name": "stripped", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "stripped_iv": { + "name": "stripped_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "stripped_tag": { + "name": "stripped_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.videos": { + "name": "videos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "videos_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "file_id": { + "name": "file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photo_id": { + "name": "photo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "is_animated": { + "name": "is_animated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "has_audio": { + "name": "has_audio", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "videos_file_id_files_id_fk": { + "name": "videos_file_id_files_id_fk", + "tableFrom": "videos", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "videos_photo_id_photos_id_fk": { + "name": "videos_photo_id_photos_id_fk", + "tableFrom": "videos", + "tableTo": "photos", + "columnsFrom": [ + "photo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voices": { + "name": "voices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "voices_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "file_id": { + "name": "file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "waveform": { + "name": "waveform", + "type": "bytea", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "voices_file_id_files_id_fk": { + "name": "voices_file_id_files_id_fk", + "tableFrom": "voices", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.external_tasks": { + "name": "external_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "external_tasks_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "application": { + "name": "application", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_user_id": { + "name": "assigned_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "connector_space_id": { + "name": "connector_space_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source_message_id": { + "name": "source_message_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "title_iv": { + "name": "title_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "title_tag": { + "name": "title_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "external_tasks_provider_user_source_space_unique": { + "name": "external_tasks_provider_user_source_space_unique", + "columns": [ + { + "expression": "source_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assigned_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "application", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connector_space_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "external_tasks_assigned_user_id_users_id_fk": { + "name": "external_tasks_assigned_user_id_users_id_fk", + "tableFrom": "external_tasks", + "tableTo": "users", + "columnsFrom": [ + "assigned_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "external_tasks_connector_space_id_spaces_id_fk": { + "name": "external_tasks_connector_space_id_spaces_id_fk", + "tableFrom": "external_tasks", + "tableTo": "spaces", + "columnsFrom": [ + "connector_space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "external_tasks_source_message_id_messages_global_id_fk": { + "name": "external_tasks_source_message_id_messages_global_id_fk", + "tableFrom": "external_tasks", + "tableTo": "messages", + "columnsFrom": [ + "source_message_id" + ], + "columnsTo": [ + "global_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_attachments": { + "name": "message_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "message_attachments_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "message_id": { + "name": "message_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "external_task_id": { + "name": "external_task_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "url_preview_id": { + "name": "url_preview_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "message_attachments_message_id_idx": { + "name": "message_attachments_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_attachments_message_id_messages_global_id_fk": { + "name": "message_attachments_message_id_messages_global_id_fk", + "tableFrom": "message_attachments", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "global_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_attachments_external_task_id_external_tasks_id_fk": { + "name": "message_attachments_external_task_id_external_tasks_id_fk", + "tableFrom": "message_attachments", + "tableTo": "external_tasks", + "columnsFrom": [ + "external_task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "message_attachments_url_preview_id_url_preview_id_fk": { + "name": "message_attachments_url_preview_id_url_preview_id_fk", + "tableFrom": "message_attachments", + "tableTo": "url_preview", + "columnsFrom": [ + "url_preview_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.url_preview": { + "name": "url_preview", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "url_preview_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "url": { + "name": "url", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "url_iv": { + "name": "url_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "url_tag": { + "name": "url_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "site_name": { + "name": "site_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'generic'" + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "title_iv": { + "name": "title_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "title_tag": { + "name": "title_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "description_iv": { + "name": "description_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "description_tag": { + "name": "description_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "author_iv": { + "name": "author_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "author_tag": { + "name": "author_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "media_kind": { + "name": "media_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "photo_id": { + "name": "photo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "author_photo_id": { + "name": "author_photo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "video_id": { + "name": "video_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "document_id": { + "name": "document_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_id": { + "name": "cache_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "external_url_iv": { + "name": "external_url_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "external_url_tag": { + "name": "external_url_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "external_mime_type": { + "name": "external_mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_width": { + "name": "external_width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "external_height": { + "name": "external_height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "external_duration": { + "name": "external_duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "embed_url": { + "name": "embed_url", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "embed_url_iv": { + "name": "embed_url_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "embed_url_tag": { + "name": "embed_url_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "embed_type": { + "name": "embed_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "embed_width": { + "name": "embed_width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "embed_height": { + "name": "embed_height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "embed_duration": { + "name": "embed_duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_large_media": { + "name": "has_large_media", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "show_large_media": { + "name": "show_large_media", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "url_preview_photo_id_photos_id_fk": { + "name": "url_preview_photo_id_photos_id_fk", + "tableFrom": "url_preview", + "tableTo": "photos", + "columnsFrom": [ + "photo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "url_preview_author_photo_id_photos_id_fk": { + "name": "url_preview_author_photo_id_photos_id_fk", + "tableFrom": "url_preview", + "tableTo": "photos", + "columnsFrom": [ + "author_photo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "url_preview_video_id_videos_id_fk": { + "name": "url_preview_video_id_videos_id_fk", + "tableFrom": "url_preview", + "tableTo": "videos", + "columnsFrom": [ + "video_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "url_preview_document_id_documents_id_fk": { + "name": "url_preview_document_id_documents_id_fk", + "tableFrom": "url_preview", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "url_preview_cache_id_url_preview_cache_id_fk": { + "name": "url_preview_cache_id_url_preview_cache_id_fk", + "tableFrom": "url_preview", + "tableTo": "url_preview_cache", + "columnsFrom": [ + "cache_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.url_preview_cache": { + "name": "url_preview_cache", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "url_preview_cache_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "url_hash": { + "name": "url_hash", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "hash_version": { + "name": "hash_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "url": { + "name": "url", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "url_iv": { + "name": "url_iv", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "url_tag": { + "name": "url_tag", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "final_url": { + "name": "final_url", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "final_url_iv": { + "name": "final_url_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "final_url_tag": { + "name": "final_url_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'generic'" + }, + "site_name": { + "name": "site_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "title_iv": { + "name": "title_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "title_tag": { + "name": "title_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "description_iv": { + "name": "description_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "description_tag": { + "name": "description_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "author_iv": { + "name": "author_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "author_tag": { + "name": "author_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "image_url_hash": { + "name": "image_url_hash", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "image_url_iv": { + "name": "image_url_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "image_url_tag": { + "name": "image_url_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "author_image_url_hash": { + "name": "author_image_url_hash", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "author_image_url": { + "name": "author_image_url", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "author_image_url_iv": { + "name": "author_image_url_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "author_image_url_tag": { + "name": "author_image_url_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "media_kind": { + "name": "media_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "photo_id": { + "name": "photo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "author_photo_id": { + "name": "author_photo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "video_id": { + "name": "video_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "document_id": { + "name": "document_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "external_url_iv": { + "name": "external_url_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "external_url_tag": { + "name": "external_url_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "external_mime_type": { + "name": "external_mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_width": { + "name": "external_width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "external_height": { + "name": "external_height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "external_duration": { + "name": "external_duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "embed_url": { + "name": "embed_url", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "embed_url_iv": { + "name": "embed_url_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "embed_url_tag": { + "name": "embed_url_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "embed_type": { + "name": "embed_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "embed_width": { + "name": "embed_width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "embed_height": { + "name": "embed_height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "embed_duration": { + "name": "embed_duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_large_media": { + "name": "has_large_media", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "show_large_media": { + "name": "show_large_media", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "url_preview_cache_url_hash_unique": { + "name": "url_preview_cache_url_hash_unique", + "columns": [ + { + "expression": "url_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "url_preview_cache_image_url_hash_idx": { + "name": "url_preview_cache_image_url_hash_idx", + "columns": [ + { + "expression": "image_url_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "url_preview_cache_author_image_url_hash_idx": { + "name": "url_preview_cache_author_image_url_hash_idx", + "columns": [ + { + "expression": "author_image_url_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "url_preview_cache_expires_at_idx": { + "name": "url_preview_cache_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "url_preview_cache_last_used_at_idx": { + "name": "url_preview_cache_last_used_at_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "url_preview_cache_photo_id_photos_id_fk": { + "name": "url_preview_cache_photo_id_photos_id_fk", + "tableFrom": "url_preview_cache", + "tableTo": "photos", + "columnsFrom": [ + "photo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "url_preview_cache_author_photo_id_photos_id_fk": { + "name": "url_preview_cache_author_photo_id_photos_id_fk", + "tableFrom": "url_preview_cache", + "tableTo": "photos", + "columnsFrom": [ + "author_photo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "url_preview_cache_video_id_videos_id_fk": { + "name": "url_preview_cache_video_id_videos_id_fk", + "tableFrom": "url_preview_cache", + "tableTo": "videos", + "columnsFrom": [ + "video_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "url_preview_cache_document_id_documents_id_fk": { + "name": "url_preview_cache_document_id_documents_id_fk", + "tableFrom": "url_preview_cache", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.space_url_preview_exclusions": { + "name": "space_url_preview_exclusions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "space_url_preview_exclusions_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path_prefix": { + "name": "path_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "space_url_preview_exclusions_space_host_path_unique": { + "name": "space_url_preview_exclusions_space_host_path_unique", + "columns": [ + { + "expression": "space_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "space_url_preview_exclusions_space_host_idx": { + "name": "space_url_preview_exclusions_space_host_idx", + "columns": [ + { + "expression": "space_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "space_url_preview_exclusions_created_by_idx": { + "name": "space_url_preview_exclusions_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "space_url_preview_exclusions_space_id_spaces_id_fk": { + "name": "space_url_preview_exclusions_space_id_spaces_id_fk", + "tableFrom": "space_url_preview_exclusions", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "space_url_preview_exclusions_created_by_users_id_fk": { + "name": "space_url_preview_exclusions_created_by_users_id_fk", + "tableFrom": "space_url_preview_exclusions", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_participant_groups": { + "name": "chat_participant_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_participant_groups_chat_group_unique": { + "name": "chat_participant_groups_chat_group_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_participant_groups_chat_id_idx": { + "name": "chat_participant_groups_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_participant_groups_group_id_idx": { + "name": "chat_participant_groups_group_id_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_participant_groups_chat_id_chats_id_fk": { + "name": "chat_participant_groups_chat_id_chats_id_fk", + "tableFrom": "chat_participant_groups", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_participant_groups_group_id_user_groups_id_fk": { + "name": "chat_participant_groups_group_id_user_groups_id_fk", + "tableFrom": "chat_participant_groups", + "tableTo": "user_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_group_members": { + "name": "user_group_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_group_members_group_user_unique": { + "name": "user_group_members_group_user_unique", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_group_members_group_id_idx": { + "name": "user_group_members_group_id_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_group_members_user_id_idx": { + "name": "user_group_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_group_members_group_id_user_groups_id_fk": { + "name": "user_group_members_group_id_user_groups_id_fk", + "tableFrom": "user_group_members", + "tableTo": "user_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_group_members_user_id_users_id_fk": { + "name": "user_group_members_user_id_users_id_fk", + "tableFrom": "user_group_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_groups": { + "name": "user_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_groups_space_name_unique": { + "name": "user_groups_space_name_unique", + "columns": [ + { + "expression": "space_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_groups_space_id_idx": { + "name": "user_groups_space_id_idx", + "columns": [ + { + "expression": "space_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_groups_created_by_idx": { + "name": "user_groups_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_groups_space_id_spaces_id_fk": { + "name": "user_groups_space_id_spaces_id_fk", + "tableFrom": "user_groups", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_groups_created_by_users_id_fk": { + "name": "user_groups_created_by_users_id_fk", + "tableFrom": "user_groups", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_translations": { + "name": "message_translations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "message_translations_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "msg_rev": { + "name": "msg_rev", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "message_id": { + "name": "message_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "translation": { + "name": "translation", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "translation_iv": { + "name": "translation_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "translation_tag": { + "name": "translation_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "entities": { + "name": "entities", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "message_translations_chat_id_chats_id_fk": { + "name": "message_translations_chat_id_chats_id_fk", + "tableFrom": "message_translations", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "chat_id_message_id_fk": { + "name": "chat_id_message_id_fk", + "tableFrom": "message_translations", + "tableTo": "messages", + "columnsFrom": [ + "chat_id", + "message_id" + ], + "columnsTo": [ + "chat_id", + "message_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_message_chat_language": { + "name": "unique_message_chat_language", + "nullsNotDistinct": false, + "columns": [ + "message_id", + "chat_id", + "language" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_settings": { + "name": "user_settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "general_encrypted": { + "name": "general_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "general_iv": { + "name": "general_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "general_tag": { + "name": "general_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_settings_user_id_users_id_fk": { + "name": "user_settings_user_id_users_id_fk", + "tableFrom": "user_settings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.space_settings": { + "name": "space_settings", + "schema": "", + "columns": { + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "space_settings_space_id_spaces_id_fk": { + "name": "space_settings_space_id_spaces_id_fk", + "tableFrom": "space_settings", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grid_presence": { + "name": "grid_presence", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "room_id": { + "name": "room_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_session_id": { + "name": "owner_session_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "microphone_enabled": { + "name": "microphone_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "microphone_revision": { + "name": "microphone_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "media_membership_id": { + "name": "media_membership_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "call_id": { + "name": "call_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "grid_presence_room_id_idx": { + "name": "grid_presence_room_id_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "grid_presence_owner_session_id_idx": { + "name": "grid_presence_owner_session_id_idx", + "columns": [ + { + "expression": "owner_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "grid_presence_lease_expires_at_idx": { + "name": "grid_presence_lease_expires_at_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "grid_presence_user_id_users_id_fk": { + "name": "grid_presence_user_id_users_id_fk", + "tableFrom": "grid_presence", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grid_presence_room_id_grid_rooms_id_fk": { + "name": "grid_presence_room_id_grid_rooms_id_fk", + "tableFrom": "grid_presence", + "tableTo": "grid_rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grid_presence_owner_session_id_sessions_id_fk": { + "name": "grid_presence_owner_session_id_sessions_id_fk", + "tableFrom": "grid_presence", + "tableTo": "sessions", + "columnsFrom": [ + "owner_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "grid_presence_microphone_revision_check": { + "name": "grid_presence_microphone_revision_check", + "value": "\"grid_presence\".\"microphone_revision\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.grid_provider_effects": { + "name": "grid_provider_effects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "deduplication_key": { + "name": "deduplication_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "room_id": { + "name": "room_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "connection_generation": { + "name": "connection_generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_target": { + "name": "provider_target", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "participant_identity": { + "name": "participant_identity", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "available_at": { + "name": "available_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "claim_token": { + "name": "claim_token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "grid_provider_effects_deduplication_key_unique": { + "name": "grid_provider_effects_deduplication_key_unique", + "columns": [ + { + "expression": "deduplication_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "grid_provider_effects_ready_idx": { + "name": "grid_provider_effects_ready_idx", + "columns": [ + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "grid_provider_effects_kind_check": { + "name": "grid_provider_effects_kind_check", + "value": "\"grid_provider_effects\".\"kind\" in ('close_connection', 'revoke_participant')" + }, + "grid_provider_effects_connection_generation_check": { + "name": "grid_provider_effects_connection_generation_check", + "value": "\"grid_provider_effects\".\"connection_generation\" >= 0" + }, + "grid_provider_effects_attempts_check": { + "name": "grid_provider_effects_attempts_check", + "value": "\"grid_provider_effects\".\"attempts\" >= 0" + }, + "grid_provider_effects_participant_check": { + "name": "grid_provider_effects_participant_check", + "value": "(\"grid_provider_effects\".\"kind\" = 'revoke_participant' and \"grid_provider_effects\".\"user_id\" is not null) or (\"grid_provider_effects\".\"kind\" = 'close_connection' and \"grid_provider_effects\".\"user_id\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.grid_rooms": { + "name": "grid_rooms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "connection_generation": { + "name": "connection_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "connection_started_at": { + "name": "connection_started_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "grid_rooms_space_id_idx": { + "name": "grid_rooms_space_id_idx", + "columns": [ + { + "expression": "space_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "grid_rooms_created_by_user_id_idx": { + "name": "grid_rooms_created_by_user_id_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "grid_rooms_space_title_unique": { + "name": "grid_rooms_space_title_unique", + "columns": [ + { + "expression": "space_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"title\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"grid_rooms\".\"title\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "grid_rooms_space_id_spaces_id_fk": { + "name": "grid_rooms_space_id_spaces_id_fk", + "tableFrom": "grid_rooms", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grid_rooms_created_by_user_id_users_id_fk": { + "name": "grid_rooms_created_by_user_id_users_id_fk", + "tableFrom": "grid_rooms", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "grid_rooms_connection_generation_check": { + "name": "grid_rooms_connection_generation_check", + "value": "\"grid_rooms\".\"connection_generation\" >= 0" + }, + "grid_rooms_title_not_empty_check": { + "name": "grid_rooms_title_not_empty_check", + "value": "\"grid_rooms\".\"title\" is null or length(\"grid_rooms\".\"title\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.updates": { + "name": "updates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "updates_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "bucket": { + "name": "bucket", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "bytea", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "updates_bucket_idx": { + "name": "updates_bucket_idx", + "columns": [ + { + "expression": "bucket", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "updates_date_idx": { + "name": "updates_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "updates_unique": { + "name": "updates_unique", + "nullsNotDistinct": false, + "columns": [ + "bucket", + "entity_id", + "seq" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_tokens": { + "name": "oauth_access_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_access_tokens_grant_idx": { + "name": "oauth_access_tokens_grant_idx", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_tokens_expiry_idx": { + "name": "oauth_access_tokens_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_tokens_grant_id_oauth_grants_id_fk": { + "name": "oauth_access_tokens_grant_id_oauth_grants_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_auth_codes": { + "name": "oauth_auth_codes", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_auth_codes_expiry_idx": { + "name": "oauth_auth_codes_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_auth_codes_grant_idx": { + "name": "oauth_auth_codes_grant_idx", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_auth_codes_used_idx": { + "name": "oauth_auth_codes_used_idx", + "columns": [ + { + "expression": "used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_auth_codes_grant_id_oauth_grants_id_fk": { + "name": "oauth_auth_codes_grant_id_oauth_grants_id_fk", + "tableFrom": "oauth_auth_codes", + "tableTo": "oauth_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "oauth_auth_codes_client_id_oauth_clients_client_id_fk": { + "name": "oauth_auth_codes_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_auth_codes", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_auth_requests": { + "name": "oauth_auth_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'https://mcp.inline.chat'" + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "csrf_token": { + "name": "csrf_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "phone_number": { + "name": "phone_number", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false + }, + "challenge_token": { + "name": "challenge_token", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "inline_user_id": { + "name": "inline_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auth_method": { + "name": "auth_method", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "inline_token_encrypted": { + "name": "inline_token_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_auth_requests_expiry_idx": { + "name": "oauth_auth_requests_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_auth_requests_client_idx": { + "name": "oauth_auth_requests_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_auth_requests_challenge_unique": { + "name": "oauth_auth_requests_challenge_unique", + "columns": [ + { + "expression": "challenge_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_auth_requests_client_id_oauth_clients_client_id_fk": { + "name": "oauth_auth_requests_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_auth_requests", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "oauth_auth_requests_inline_user_id_users_id_fk": { + "name": "oauth_auth_requests_inline_user_id_users_id_fk", + "tableFrom": "oauth_auth_requests", + "tableTo": "users", + "columnsFrom": [ + "inline_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_clients": { + "name": "oauth_clients", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "varchar(128)", + "primaryKey": true, + "notNull": true + }, + "redirect_uris_json": { + "name": "redirect_uris_json", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_clients_date_idx": { + "name": "oauth_clients_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_grants": { + "name": "oauth_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "inline_user_id": { + "name": "inline_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'https://mcp.inline.chat'" + }, + "space_ids_json": { + "name": "space_ids_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "allow_dms": { + "name": "allow_dms", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allow_home_threads": { + "name": "allow_home_threads", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inline_token_encrypted": { + "name": "inline_token_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_grants_client_idx": { + "name": "oauth_grants_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_grants_inline_user_idx": { + "name": "oauth_grants_inline_user_idx", + "columns": [ + { + "expression": "inline_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_grants_revoked_idx": { + "name": "oauth_grants_revoked_idx", + "columns": [ + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_grants_client_id_oauth_clients_client_id_fk": { + "name": "oauth_grants_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_grants", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "oauth_grants_inline_user_id_users_id_fk": { + "name": "oauth_grants_inline_user_id_users_id_fk", + "tableFrom": "oauth_grants", + "tableTo": "users", + "columnsFrom": [ + "inline_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_tokens": { + "name": "oauth_refresh_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "replaced_by_hash": { + "name": "replaced_by_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_refresh_tokens_grant_idx": { + "name": "oauth_refresh_tokens_grant_idx", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_tokens_expiry_idx": { + "name": "oauth_refresh_tokens_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_tokens_revoked_idx": { + "name": "oauth_refresh_tokens_revoked_idx", + "columns": [ + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_tokens_grant_id_oauth_grants_id_fk": { + "name": "oauth_refresh_tokens_grant_id_oauth_grants_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "oauth_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_event_subscriptions": { + "name": "mcp_event_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(80)", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "selector": { + "name": "selector", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "callback_url": { + "name": "callback_url", + "type": "varchar(4096)", + "primaryKey": false, + "notNull": true + }, + "secret_encrypted": { + "name": "secret_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "previous_secret_encrypted": { + "name": "previous_secret_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "previous_secret_until": { + "name": "previous_secret_until", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "cursor_seq": { + "name": "cursor_seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "gap_seq": { + "name": "gap_seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pending_encrypted": { + "name": "pending_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "pending_seq": { + "name": "pending_seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lease_owner": { + "name": "lease_owner", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stopped": { + "name": "stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_events_due_idx": { + "name": "mcp_events_due_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_event_subscriptions\".\"stopped\" = false AND \"mcp_event_subscriptions\".\"gap_seq\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_events_grant_idx": { + "name": "mcp_events_grant_idx", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_events_expiry_idx": { + "name": "mcp_events_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_event_subscriptions_grant_id_oauth_grants_id_fk": { + "name": "mcp_event_subscriptions_grant_id_oauth_grants_id_fk", + "tableFrom": "mcp_event_subscriptions", + "tableTo": "oauth_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_events_cursor_nonnegative": { + "name": "mcp_events_cursor_nonnegative", + "value": "\"mcp_event_subscriptions\".\"cursor_seq\" >= 0 AND (\"mcp_event_subscriptions\".\"gap_seq\" IS NULL OR \"mcp_event_subscriptions\".\"gap_seq\" >= \"mcp_event_subscriptions\".\"cursor_seq\")" + }, + "mcp_events_pending_pair": { + "name": "mcp_events_pending_pair", + "value": "(\"mcp_event_subscriptions\".\"pending_encrypted\" IS NULL) = (\"mcp_event_subscriptions\".\"pending_seq\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.account_identities": { + "name": "account_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "account_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "subject_hash": { + "name": "subject_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_identities_provider_subject_unique": { + "name": "account_identities_provider_subject_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_identities_user_idx": { + "name": "account_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_identities_user_id_users_id_fk": { + "name": "account_identities_user_id_users_id_fk", + "tableFrom": "account_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_auth_attempts": { + "name": "provider_auth_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "account_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "provider_auth_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "provider_auth_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending_provider'" + }, + "state_hash": { + "name": "state_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "nonce_hash": { + "name": "nonce_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "nonce_encrypted": { + "name": "nonce_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier_encrypted": { + "name": "pkce_verifier_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "continuation_hash": { + "name": "continuation_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "subject_hash": { + "name": "subject_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "pending_profile_encrypted": { + "name": "pending_profile_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "confirmation_email": { + "name": "confirmation_email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "challenge_token": { + "name": "challenge_token", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "app_callback_scheme": { + "name": "app_callback_scheme", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "app_code_challenge": { + "name": "app_code_challenge", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "oauth_auth_request_id": { + "name": "oauth_auth_request_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "login_transaction_id": { + "name": "login_transaction_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "client": { + "name": "client", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "inline_user_id": { + "name": "inline_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "inline_token_encrypted": { + "name": "inline_token_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "ticket_hash": { + "name": "ticket_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "provider_auth_attempts_state_unique": { + "name": "provider_auth_attempts_state_unique", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_auth_attempts_ticket_unique": { + "name": "provider_auth_attempts_ticket_unique", + "columns": [ + { + "expression": "ticket_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_auth_attempts_expiry_idx": { + "name": "provider_auth_attempts_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_auth_attempts_oauth_request_idx": { + "name": "provider_auth_attempts_oauth_request_idx", + "columns": [ + { + "expression": "oauth_auth_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_auth_attempts_inline_user_idx": { + "name": "provider_auth_attempts_inline_user_idx", + "columns": [ + { + "expression": "inline_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_auth_attempts_login_transaction_idx": { + "name": "provider_auth_attempts_login_transaction_idx", + "columns": [ + { + "expression": "login_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_auth_attempts_inline_user_id_users_id_fk": { + "name": "provider_auth_attempts_inline_user_id_users_id_fk", + "tableFrom": "provider_auth_attempts", + "tableTo": "users", + "columnsFrom": [ + "inline_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "provider_auth_attempts_oauth_request_fk": { + "name": "provider_auth_attempts_oauth_request_fk", + "tableFrom": "provider_auth_attempts", + "tableTo": "oauth_auth_requests", + "columnsFrom": [ + "oauth_auth_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "provider_auth_attempts_login_transaction_fk": { + "name": "provider_auth_attempts_login_transaction_fk", + "tableFrom": "provider_auth_attempts", + "tableTo": "login_transactions", + "columnsFrom": [ + "login_transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.login_transactions": { + "name": "login_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": true, + "notNull": true + }, + "capability_hash": { + "name": "capability_hash", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "browser_csrf_hash": { + "name": "browser_csrf_hash", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "target_kind": { + "name": "target_kind", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "inline_protocol_auth_key_id": { + "name": "inline_protocol_auth_key_id", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "oauth_auth_request_id": { + "name": "oauth_auth_request_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "native_app_auth_request_id": { + "name": "native_app_auth_request_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "verification_code": { + "name": "verification_code", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true + }, + "pending_identifier": { + "name": "pending_identifier", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false + }, + "challenge_token": { + "name": "challenge_token", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "client": { + "name": "client", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auth_method": { + "name": "auth_method", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "login_transactions_capability_unique": { + "name": "login_transactions_capability_unique", + "columns": [ + { + "expression": "capability_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "login_transactions_expiry_idx": { + "name": "login_transactions_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "login_transactions_user_idx": { + "name": "login_transactions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "login_transactions_inline_protocol_key_unique": { + "name": "login_transactions_inline_protocol_key_unique", + "columns": [ + { + "expression": "inline_protocol_auth_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"login_transactions\".\"status\" in ('pending', 'claimed')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "login_transactions_oauth_request_unique": { + "name": "login_transactions_oauth_request_unique", + "columns": [ + { + "expression": "oauth_auth_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"login_transactions\".\"status\" in ('pending', 'claimed')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "login_transactions_native_app_request_unique": { + "name": "login_transactions_native_app_request_unique", + "columns": [ + { + "expression": "native_app_auth_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"login_transactions\".\"status\" in ('pending', 'claimed')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "login_transactions_inline_protocol_auth_key_id_inline_protocol_auth_keys_auth_key_id_fk": { + "name": "login_transactions_inline_protocol_auth_key_id_inline_protocol_auth_keys_auth_key_id_fk", + "tableFrom": "login_transactions", + "tableTo": "inline_protocol_auth_keys", + "columnsFrom": [ + "inline_protocol_auth_key_id" + ], + "columnsTo": [ + "auth_key_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "login_transactions_oauth_auth_request_id_oauth_auth_requests_id_fk": { + "name": "login_transactions_oauth_auth_request_id_oauth_auth_requests_id_fk", + "tableFrom": "login_transactions", + "tableTo": "oauth_auth_requests", + "columnsFrom": [ + "oauth_auth_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "login_transactions_native_app_auth_request_id_native_app_auth_requests_id_fk": { + "name": "login_transactions_native_app_auth_request_id_native_app_auth_requests_id_fk", + "tableFrom": "login_transactions", + "tableTo": "native_app_auth_requests", + "columnsFrom": [ + "native_app_auth_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "login_transactions_user_id_users_id_fk": { + "name": "login_transactions_user_id_users_id_fk", + "tableFrom": "login_transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "login_transactions_capability_hash_length": { + "name": "login_transactions_capability_hash_length", + "value": "octet_length(\"login_transactions\".\"capability_hash\") = 32" + }, + "login_transactions_browser_csrf_hash_length": { + "name": "login_transactions_browser_csrf_hash_length", + "value": "\"login_transactions\".\"browser_csrf_hash\" is null or octet_length(\"login_transactions\".\"browser_csrf_hash\") = 32" + }, + "login_transactions_status_valid": { + "name": "login_transactions_status_valid", + "value": "\"login_transactions\".\"status\" in ('pending', 'claimed', 'complete', 'cancelled')" + }, + "login_transactions_method_valid": { + "name": "login_transactions_method_valid", + "value": "\"login_transactions\".\"auth_method\" is null or \"login_transactions\".\"auth_method\" in ('email', 'phone', 'google', 'apple')" + }, + "login_transactions_typed_target": { + "name": "login_transactions_typed_target", + "value": "(\n (\"login_transactions\".\"target_kind\" = 'inline_protocol_key')::int +\n (\"login_transactions\".\"target_kind\" = 'oauth_authorization')::int +\n (\"login_transactions\".\"target_kind\" = 'native_app')::int\n ) = 1 and\n (\"login_transactions\".\"inline_protocol_auth_key_id\" is not null)::int +\n (\"login_transactions\".\"oauth_auth_request_id\" is not null)::int +\n (\"login_transactions\".\"native_app_auth_request_id\" is not null)::int = 1 and\n (\"login_transactions\".\"target_kind\" = 'inline_protocol_key') = (\"login_transactions\".\"inline_protocol_auth_key_id\" is not null) and\n (\"login_transactions\".\"target_kind\" = 'oauth_authorization') = (\"login_transactions\".\"oauth_auth_request_id\" is not null) and\n (\"login_transactions\".\"target_kind\" = 'native_app') = (\"login_transactions\".\"native_app_auth_request_id\" is not null)\n " + } + }, + "isRLSEnabled": false + }, + "public.native_app_auth_requests": { + "name": "native_app_auth_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": true, + "notNull": true + }, + "callback_scheme": { + "name": "callback_scheme", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "code_challenge": { + "name": "code_challenge", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "client": { + "name": "client", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auth_method": { + "name": "auth_method", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "ticket_hash": { + "name": "ticket_hash", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "proven_at": { + "name": "proven_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "native_app_auth_requests_expiry_idx": { + "name": "native_app_auth_requests_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "native_app_auth_requests_ticket_unique": { + "name": "native_app_auth_requests_ticket_unique", + "columns": [ + { + "expression": "ticket_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "native_app_auth_requests_user_id_users_id_fk": { + "name": "native_app_auth_requests_user_id_users_id_fk", + "tableFrom": "native_app_auth_requests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "native_app_auth_requests_method_valid": { + "name": "native_app_auth_requests_method_valid", + "value": "\"native_app_auth_requests\".\"auth_method\" is null or \"native_app_auth_requests\".\"auth_method\" in ('email', 'phone', 'google', 'apple')" + } + }, + "isRLSEnabled": false + }, + "public.email_campaign_recipients": { + "name": "email_campaign_recipients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "campaign_id": { + "name": "campaign_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "email_key": { + "name": "email_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "email_encrypted": { + "name": "email_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "name_encrypted": { + "name": "name_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "unsubscribe_token_hash": { + "name": "unsubscribe_token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "unsubscribe_token_encrypted": { + "name": "unsubscribe_token_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "sources": { + "name": "sources", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "contacted_at": { + "name": "contacted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_campaign_recipients_campaign_email_unique": { + "name": "email_campaign_recipients_campaign_email_unique", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_campaign_recipients_unsubscribe_token_unique": { + "name": "email_campaign_recipients_unsubscribe_token_unique", + "columns": [ + { + "expression": "unsubscribe_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_campaign_recipients_campaign_status_idx": { + "name": "email_campaign_recipients_campaign_status_idx", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_campaign_recipients_email_key_idx": { + "name": "email_campaign_recipients_email_key_idx", + "columns": [ + { + "expression": "email_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_campaign_recipients_campaign_id_email_campaigns_id_fk": { + "name": "email_campaign_recipients_campaign_id_email_campaigns_id_fk", + "tableFrom": "email_campaign_recipients", + "tableTo": "email_campaigns", + "columnsFrom": [ + "campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_campaign_recipients_status_check": { + "name": "email_campaign_recipients_status_check", + "value": "\"email_campaign_recipients\".\"status\" in ('pending', 'provider_synced', 'sending', 'provider_accepted', 'unknown', 'suppressed')" + } + }, + "isRLSEnabled": false + }, + "public.email_campaigns": { + "name": "email_campaigns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "series_key": { + "name": "series_key", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "varchar(240)", + "primaryKey": false, + "notNull": true + }, + "preview_text": { + "name": "preview_text", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "from_address": { + "name": "from_address", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true, + "default": "'team@inline.chat'" + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'frozen'" + }, + "recipient_count": { + "name": "recipient_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider_segment_id": { + "name": "provider_segment_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "provider_campaign_id": { + "name": "provider_campaign_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "visible_unsubscribe": { + "name": "visible_unsubscribe", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "unsubscribe_override_reason": { + "name": "unsubscribe_override_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "test_sent_at": { + "name": "test_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "frozen_at": { + "name": "frozen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "email_campaigns_name_unique": { + "name": "email_campaigns_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_campaigns_created_at_idx": { + "name": "email_campaigns_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_campaigns_created_by_user_id_users_id_fk": { + "name": "email_campaigns_created_by_user_id_users_id_fk", + "tableFrom": "email_campaigns", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_campaigns_provider_check": { + "name": "email_campaigns_provider_check", + "value": "\"email_campaigns\".\"provider\" is null or \"email_campaigns\".\"provider\" in ('resend', 'ses')" + }, + "email_campaigns_from_address_check": { + "name": "email_campaigns_from_address_check", + "value": "\"email_campaigns\".\"from_address\" in ('team@inline.chat', 'founders@inline.chat', 'mo@inline.chat')" + }, + "email_campaigns_status_check": { + "name": "email_campaigns_status_check", + "value": "\"email_campaigns\".\"status\" in ('frozen', 'sending', 'paused', 'completed')" + } + }, + "isRLSEnabled": false + }, + "public.email_suppressions": { + "name": "email_suppressions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email_key": { + "name": "email_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "email_encrypted": { + "name": "email_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_suppressions_email_key_unique": { + "name": "email_suppressions_email_key_unique", + "columns": [ + { + "expression": "email_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_suppressions_reason_check": { + "name": "email_suppressions_reason_check", + "value": "\"email_suppressions\".\"reason\" in ('unsubscribe', 'manual', 'bounce', 'complaint', 'invalid')" + } + }, + "isRLSEnabled": false + }, + "public.server_config": { + "name": "server_config", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(160)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "server_config_updated_by_user_id_users_id_fk": { + "name": "server_config_updated_by_user_id_users_id_fk", + "tableFrom": "server_config", + "tableTo": "users", + "columnsFrom": [ + "updated_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "server_config_version_check": { + "name": "server_config_version_check", + "value": "\"server_config\".\"version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.reserved_usernames": { + "name": "reserved_usernames", + "schema": "", + "columns": { + "username": { + "name": "username", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inline_protocol_auth_challenges": { + "name": "inline_protocol_auth_challenges", + "schema": "", + "columns": { + "challenge_id": { + "name": "challenge_id", + "type": "bytea", + "primaryKey": true, + "notNull": true + }, + "auth_key_id": { + "name": "auth_key_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "identifier_encrypted": { + "name": "identifier_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "identifier_hash": { + "name": "identifier_hash", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "code_mac": { + "name": "code_mac", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "pepper_key_id": { + "name": "pepper_key_id", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "delivery": { + "name": "delivery", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "client": { + "name": "client", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "network_hash": { + "name": "network_hash", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "device_hash": { + "name": "device_hash", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inline_protocol_auth_challenges_key_created_idx": { + "name": "inline_protocol_auth_challenges_key_created_idx", + "columns": [ + { + "expression": "auth_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inline_protocol_auth_challenges_identifier_created_idx": { + "name": "inline_protocol_auth_challenges_identifier_created_idx", + "columns": [ + { + "expression": "identifier_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inline_protocol_auth_challenges_expiry_idx": { + "name": "inline_protocol_auth_challenges_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inline_protocol_auth_challenges_auth_key_id_inline_protocol_auth_keys_auth_key_id_fk": { + "name": "inline_protocol_auth_challenges_auth_key_id_inline_protocol_auth_keys_auth_key_id_fk", + "tableFrom": "inline_protocol_auth_challenges", + "tableTo": "inline_protocol_auth_keys", + "columnsFrom": [ + "auth_key_id" + ], + "columnsTo": [ + "auth_key_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "inline_protocol_auth_challenges_id_length": { + "name": "inline_protocol_auth_challenges_id_length", + "value": "octet_length(\"inline_protocol_auth_challenges\".\"challenge_id\") = 32" + }, + "inline_protocol_auth_challenges_key_id_length": { + "name": "inline_protocol_auth_challenges_key_id_length", + "value": "octet_length(\"inline_protocol_auth_challenges\".\"auth_key_id\") = 8" + }, + "inline_protocol_auth_challenges_identifier_hash_length": { + "name": "inline_protocol_auth_challenges_identifier_hash_length", + "value": "octet_length(\"inline_protocol_auth_challenges\".\"identifier_hash\") = 32" + }, + "inline_protocol_auth_challenges_code_mac_length": { + "name": "inline_protocol_auth_challenges_code_mac_length", + "value": "octet_length(\"inline_protocol_auth_challenges\".\"code_mac\") = 32" + } + }, + "isRLSEnabled": false + }, + "public.inline_protocol_auth_keys": { + "name": "inline_protocol_auth_keys", + "schema": "", + "columns": { + "auth_key_id": { + "name": "auth_key_id", + "type": "bytea", + "primaryKey": true, + "notNull": true + }, + "auth_key_encrypted": { + "name": "auth_key_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "key_encryption_key_id": { + "name": "key_encryption_key_id", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "current_server_salt": { + "name": "current_server_salt", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "previous_server_salt": { + "name": "previous_server_salt", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "server_salt_updated_at": { + "name": "server_salt_updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "account_session_id": { + "name": "account_session_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "authorized_at": { + "name": "authorized_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inline_protocol_auth_keys_user_idx": { + "name": "inline_protocol_auth_keys_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inline_protocol_auth_keys_session_idx": { + "name": "inline_protocol_auth_keys_session_idx", + "columns": [ + { + "expression": "account_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inline_protocol_auth_keys_expiry_idx": { + "name": "inline_protocol_auth_keys_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inline_protocol_auth_keys_user_id_users_id_fk": { + "name": "inline_protocol_auth_keys_user_id_users_id_fk", + "tableFrom": "inline_protocol_auth_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "inline_protocol_auth_keys_account_session_id_sessions_id_fk": { + "name": "inline_protocol_auth_keys_account_session_id_sessions_id_fk", + "tableFrom": "inline_protocol_auth_keys", + "tableTo": "sessions", + "columnsFrom": [ + "account_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "inline_protocol_auth_keys_id_length": { + "name": "inline_protocol_auth_keys_id_length", + "value": "octet_length(\"inline_protocol_auth_keys\".\"auth_key_id\") = 8" + }, + "inline_protocol_auth_keys_encrypted_length": { + "name": "inline_protocol_auth_keys_encrypted_length", + "value": "octet_length(\"inline_protocol_auth_keys\".\"auth_key_encrypted\") = 284" + } + }, + "isRLSEnabled": false + }, + "public.inline_protocol_requests": { + "name": "inline_protocol_requests", + "schema": "", + "columns": { + "auth_key_id": { + "name": "auth_key_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "protocol_session_id": { + "name": "protocol_session_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "request_digest": { + "name": "request_digest", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "result_body": { + "name": "result_body", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "result_format": { + "name": "result_format", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "inline_protocol_requests_expiry_idx": { + "name": "inline_protocol_requests_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "inline_protocol_requests_pk": { + "name": "inline_protocol_requests_pk", + "columns": [ + "auth_key_id", + "protocol_session_id", + "message_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "inline_protocol_requests_key_id_length": { + "name": "inline_protocol_requests_key_id_length", + "value": "octet_length(\"inline_protocol_requests\".\"auth_key_id\") = 8" + }, + "inline_protocol_requests_digest_length": { + "name": "inline_protocol_requests_digest_length", + "value": "octet_length(\"inline_protocol_requests\".\"request_digest\") = 32" + }, + "inline_protocol_requests_result_length": { + "name": "inline_protocol_requests_result_length", + "value": "\"inline_protocol_requests\".\"result_body\" is null or octet_length(\"inline_protocol_requests\".\"result_body\") <= 16777278" + }, + "inline_protocol_requests_result_format": { + "name": "inline_protocol_requests_result_format", + "value": "(\"inline_protocol_requests\".\"result_format\" = 0 and (\"inline_protocol_requests\".\"result_body\" is null or octet_length(\"inline_protocol_requests\".\"result_body\") <= 16777216)) or (\"inline_protocol_requests\".\"result_format\" = 1 and \"inline_protocol_requests\".\"result_body\" is not null and octet_length(\"inline_protocol_requests\".\"result_body\") >= 31)" + } + }, + "isRLSEnabled": false + }, + "public.inline_protocol_uploads": { + "name": "inline_protocol_uploads", + "schema": "", + "columns": { + "upload_id": { + "name": "upload_id", + "type": "bytea", + "primaryKey": true, + "notNull": true + }, + "capability_hash": { + "name": "capability_hash", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "permanent_auth_key_id": { + "name": "permanent_auth_key_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "issuing_temporary_auth_key_id": { + "name": "issuing_temporary_auth_key_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "account_session_id": { + "name": "account_session_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "byte_count": { + "name": "byte_count", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "lock_token": { + "name": "lock_token", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "file_unique_id": { + "name": "file_unique_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inline_protocol_uploads_capability_unique": { + "name": "inline_protocol_uploads_capability_unique", + "columns": [ + { + "expression": "capability_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inline_protocol_uploads_session_idx": { + "name": "inline_protocol_uploads_session_idx", + "columns": [ + { + "expression": "account_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inline_protocol_uploads_expiry_idx": { + "name": "inline_protocol_uploads_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inline_protocol_uploads_permanent_auth_key_id_inline_protocol_auth_keys_auth_key_id_fk": { + "name": "inline_protocol_uploads_permanent_auth_key_id_inline_protocol_auth_keys_auth_key_id_fk", + "tableFrom": "inline_protocol_uploads", + "tableTo": "inline_protocol_auth_keys", + "columnsFrom": [ + "permanent_auth_key_id" + ], + "columnsTo": [ + "auth_key_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inline_protocol_uploads_user_id_users_id_fk": { + "name": "inline_protocol_uploads_user_id_users_id_fk", + "tableFrom": "inline_protocol_uploads", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inline_protocol_uploads_account_session_id_sessions_id_fk": { + "name": "inline_protocol_uploads_account_session_id_sessions_id_fk", + "tableFrom": "inline_protocol_uploads", + "tableTo": "sessions", + "columnsFrom": [ + "account_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "inline_protocol_uploads_id_length": { + "name": "inline_protocol_uploads_id_length", + "value": "octet_length(\"inline_protocol_uploads\".\"upload_id\") = 16" + }, + "inline_protocol_uploads_capability_hash_length": { + "name": "inline_protocol_uploads_capability_hash_length", + "value": "octet_length(\"inline_protocol_uploads\".\"capability_hash\") = 32" + }, + "inline_protocol_uploads_permanent_key_length": { + "name": "inline_protocol_uploads_permanent_key_length", + "value": "octet_length(\"inline_protocol_uploads\".\"permanent_auth_key_id\") = 8" + }, + "inline_protocol_uploads_temporary_key_length": { + "name": "inline_protocol_uploads_temporary_key_length", + "value": "octet_length(\"inline_protocol_uploads\".\"issuing_temporary_auth_key_id\") = 8" + }, + "inline_protocol_uploads_sha_length": { + "name": "inline_protocol_uploads_sha_length", + "value": "octet_length(\"inline_protocol_uploads\".\"sha256\") = 32" + }, + "inline_protocol_uploads_byte_count_positive": { + "name": "inline_protocol_uploads_byte_count_positive", + "value": "\"inline_protocol_uploads\".\"byte_count\" > 0" + }, + "inline_protocol_uploads_status_valid": { + "name": "inline_protocol_uploads_status_valid", + "value": "\"inline_protocol_uploads\".\"status\" in ('pending', 'uploading', 'complete', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.inline_upload_parts": { + "name": "inline_upload_parts", + "schema": "", + "columns": { + "upload_id": { + "name": "upload_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "part_index": { + "name": "part_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "byte_count": { + "name": "byte_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "stored_byte_count": { + "name": "stored_byte_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "stored_sha256": { + "name": "stored_sha256", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "inline_upload_parts_upload_id_inline_uploads_id_fk": { + "name": "inline_upload_parts_upload_id_inline_uploads_id_fk", + "tableFrom": "inline_upload_parts", + "tableTo": "inline_uploads", + "columnsFrom": [ + "upload_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "inline_upload_parts_pk": { + "name": "inline_upload_parts_pk", + "columns": [ + "upload_id", + "part_index" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "inline_upload_parts_index_valid": { + "name": "inline_upload_parts_index_valid", + "value": "\"inline_upload_parts\".\"part_index\" >= 0" + }, + "inline_upload_parts_byte_count_valid": { + "name": "inline_upload_parts_byte_count_valid", + "value": "\"inline_upload_parts\".\"byte_count\" between 1 and 524288" + }, + "inline_upload_parts_sha_length": { + "name": "inline_upload_parts_sha_length", + "value": "octet_length(\"inline_upload_parts\".\"sha256\") = 32" + }, + "inline_upload_parts_stored_byte_count_valid": { + "name": "inline_upload_parts_stored_byte_count_valid", + "value": "\"inline_upload_parts\".\"stored_byte_count\" > 0" + }, + "inline_upload_parts_stored_sha_length": { + "name": "inline_upload_parts_stored_sha_length", + "value": "octet_length(\"inline_upload_parts\".\"stored_sha256\") = 32" + } + }, + "isRLSEnabled": false + }, + "public.inline_upload_storage_parts": { + "name": "inline_upload_storage_parts", + "schema": "", + "columns": { + "upload_id": { + "name": "upload_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "storage_upload_id": { + "name": "storage_upload_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "part_number": { + "name": "part_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "stored_byte_count": { + "name": "stored_byte_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "stored_sha256": { + "name": "stored_sha256", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "inline_upload_storage_parts_upload_id_inline_uploads_id_fk": { + "name": "inline_upload_storage_parts_upload_id_inline_uploads_id_fk", + "tableFrom": "inline_upload_storage_parts", + "tableTo": "inline_uploads", + "columnsFrom": [ + "upload_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "inline_upload_storage_parts_pk": { + "name": "inline_upload_storage_parts_pk", + "columns": [ + "upload_id", + "part_number" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "inline_upload_storage_parts_number_valid": { + "name": "inline_upload_storage_parts_number_valid", + "value": "\"inline_upload_storage_parts\".\"part_number\" between 1 and 1000" + }, + "inline_upload_storage_parts_byte_count_valid": { + "name": "inline_upload_storage_parts_byte_count_valid", + "value": "\"inline_upload_storage_parts\".\"stored_byte_count\" > 0" + }, + "inline_upload_storage_parts_sha_length": { + "name": "inline_upload_storage_parts_sha_length", + "value": "octet_length(\"inline_upload_storage_parts\".\"stored_sha256\") = 32" + }, + "inline_upload_storage_parts_etag_valid": { + "name": "inline_upload_storage_parts_etag_valid", + "value": "length(\"inline_upload_storage_parts\".\"etag\") between 1 and 128" + } + }, + "isRLSEnabled": false + }, + "public.inline_uploads": { + "name": "inline_uploads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "inline_uploads_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "upload_id": { + "name": "upload_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "client_upload_id": { + "name": "client_upload_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "permanent_auth_key_id": { + "name": "permanent_auth_key_id", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "account_session_id": { + "name": "account_session_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "byte_count": { + "name": "byte_count", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "thumbnail_file_unique_id": { + "name": "thumbnail_file_unique_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "video_width": { + "name": "video_width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "video_height": { + "name": "video_height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_animated": { + "name": "is_animated", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_audio": { + "name": "has_audio", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "waveform": { + "name": "waveform", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "failure_code": { + "name": "failure_code", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "failure_retryable": { + "name": "failure_retryable", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "lock_token": { + "name": "lock_token", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "storage_format": { + "name": "storage_format", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "storage_upload_id": { + "name": "storage_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retry_at": { + "name": "retry_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "result_file_unique_id": { + "name": "result_file_unique_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "result_media_id": { + "name": "result_media_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_part_at": { + "name": "last_part_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "hard_expires_at": { + "name": "hard_expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inline_uploads_upload_id_unique": { + "name": "inline_uploads_upload_id_unique", + "columns": [ + { + "expression": "upload_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inline_uploads_session_client_id_unique": { + "name": "inline_uploads_session_client_id_unique", + "columns": [ + { + "expression": "account_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_upload_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inline_uploads_permanent_key_idx": { + "name": "inline_uploads_permanent_key_idx", + "columns": [ + { + "expression": "permanent_auth_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inline_uploads_user_status_idx": { + "name": "inline_uploads_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inline_uploads_session_created_idx": { + "name": "inline_uploads_session_created_idx", + "columns": [ + { + "expression": "account_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inline_uploads_expiry_idx": { + "name": "inline_uploads_expiry_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inline_uploads_processing_retry_idx": { + "name": "inline_uploads_processing_retry_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "retry_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inline_uploads_permanent_auth_key_id_inline_protocol_auth_keys_auth_key_id_fk": { + "name": "inline_uploads_permanent_auth_key_id_inline_protocol_auth_keys_auth_key_id_fk", + "tableFrom": "inline_uploads", + "tableTo": "inline_protocol_auth_keys", + "columnsFrom": [ + "permanent_auth_key_id" + ], + "columnsTo": [ + "auth_key_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inline_uploads_user_id_users_id_fk": { + "name": "inline_uploads_user_id_users_id_fk", + "tableFrom": "inline_uploads", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inline_uploads_account_session_id_sessions_id_fk": { + "name": "inline_uploads_account_session_id_sessions_id_fk", + "tableFrom": "inline_uploads", + "tableTo": "sessions", + "columnsFrom": [ + "account_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "inline_uploads_id_length": { + "name": "inline_uploads_id_length", + "value": "octet_length(\"inline_uploads\".\"upload_id\") = 16" + }, + "inline_uploads_client_id_length": { + "name": "inline_uploads_client_id_length", + "value": "octet_length(\"inline_uploads\".\"client_upload_id\") = 16" + }, + "inline_uploads_permanent_key_length": { + "name": "inline_uploads_permanent_key_length", + "value": "octet_length(\"inline_uploads\".\"permanent_auth_key_id\") = 8" + }, + "inline_uploads_sha_length": { + "name": "inline_uploads_sha_length", + "value": "octet_length(\"inline_uploads\".\"sha256\") = 32" + }, + "inline_uploads_byte_count_positive": { + "name": "inline_uploads_byte_count_positive", + "value": "\"inline_uploads\".\"byte_count\" > 0" + }, + "inline_uploads_part_size_valid": { + "name": "inline_uploads_part_size_valid", + "value": "\"inline_uploads\".\"part_size\" = 524288" + }, + "inline_uploads_part_count_valid": { + "name": "inline_uploads_part_count_valid", + "value": "\"inline_uploads\".\"part_count\" between 1 and 1000" + }, + "inline_uploads_kind_valid": { + "name": "inline_uploads_kind_valid", + "value": "\"inline_uploads\".\"kind\" in ('photo', 'video', 'document', 'voice')" + }, + "inline_uploads_status_valid": { + "name": "inline_uploads_status_valid", + "value": "\"inline_uploads\".\"status\" in ('uploading', 'processing', 'complete', 'failed', 'canceled')" + }, + "inline_uploads_storage_format_valid": { + "name": "inline_uploads_storage_format_valid", + "value": "\"inline_uploads\".\"storage_format\" is null or \"inline_uploads\".\"storage_format\" = 'identity_v1'" + }, + "inline_uploads_storage_session_valid": { + "name": "inline_uploads_storage_session_valid", + "value": "\"inline_uploads\".\"storage_upload_id\" is null or \"inline_uploads\".\"storage_format\" is not null" + }, + "inline_uploads_attempts_valid": { + "name": "inline_uploads_attempts_valid", + "value": "\"inline_uploads\".\"attempts\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.agent_session_messages": { + "name": "agent_session_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "agent_session_id": { + "name": "agent_session_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "source_key_hash": { + "name": "source_key_hash", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "item_key_hash": { + "name": "item_key_hash", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "source_ref_encrypted": { + "name": "source_ref_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "revision_ref_encrypted": { + "name": "revision_ref_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "message_global_id": { + "name": "message_global_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "relation": { + "name": "relation", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "complete": { + "name": "complete", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_session_messages_source_unique": { + "name": "agent_session_messages_source_unique", + "columns": [ + { + "expression": "agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_session_messages_item_unique": { + "name": "agent_session_messages_item_unique", + "columns": [ + { + "expression": "agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_session_messages\".\"item_key_hash\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_session_messages_message_unique": { + "name": "agent_session_messages_message_unique", + "columns": [ + { + "expression": "agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_global_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_session_messages\".\"message_global_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_session_messages_session_idx": { + "name": "agent_session_messages_session_idx", + "columns": [ + { + "expression": "agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_session_messages_agent_session_id_agent_sessions_id_fk": { + "name": "agent_session_messages_agent_session_id_agent_sessions_id_fk", + "tableFrom": "agent_session_messages", + "tableTo": "agent_sessions", + "columnsFrom": [ + "agent_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_session_messages_message_global_id_messages_global_id_fk": { + "name": "agent_session_messages_message_global_id_messages_global_id_fk", + "tableFrom": "agent_session_messages", + "tableTo": "messages", + "columnsFrom": [ + "message_global_id" + ], + "columnsTo": [ + "global_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_sessions": { + "name": "agent_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "session_key_hash": { + "name": "session_key_hash", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "instance_ref_encrypted": { + "name": "instance_ref_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "session_ref_encrypted": { + "name": "session_ref_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "project_ref_encrypted": { + "name": "project_ref_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "status_message_global_id": { + "name": "status_message_global_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_sessions_external_unique": { + "name": "agent_sessions_external_unique", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "session_key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_sessions_chat_bot_unique": { + "name": "agent_sessions_chat_bot_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_sessions_owner_idx": { + "name": "agent_sessions_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_sessions_status_message_unique": { + "name": "agent_sessions_status_message_unique", + "columns": [ + { + "expression": "status_message_global_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_sessions\".\"status_message_global_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_sessions_chat_id_chats_id_fk": { + "name": "agent_sessions_chat_id_chats_id_fk", + "tableFrom": "agent_sessions", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_sessions_bot_user_id_users_id_fk": { + "name": "agent_sessions_bot_user_id_users_id_fk", + "tableFrom": "agent_sessions", + "tableTo": "users", + "columnsFrom": [ + "bot_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_sessions_owner_user_id_users_id_fk": { + "name": "agent_sessions_owner_user_id_users_id_fk", + "tableFrom": "agent_sessions", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_sessions_status_message_global_id_messages_global_id_fk": { + "name": "agent_sessions_status_message_global_id_messages_global_id_fk", + "tableFrom": "agent_sessions", + "tableTo": "messages", + "columnsFrom": [ + "status_message_global_id" + ], + "columnsTo": [ + "global_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_delivery_budgets": { + "name": "auth_delivery_budgets", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(96)", + "primaryKey": true, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "auth_delivery_budgets_expiry_idx": { + "name": "auth_delivery_budgets_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "auth_delivery_budgets_attempts_positive": { + "name": "auth_delivery_budgets_attempts_positive", + "value": "\"auth_delivery_budgets\".\"attempts\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.recent_realtime_buckets": { + "name": "recent_realtime_buckets", + "schema": "", + "columns": { + "bucket": { + "name": "bucket", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "recent_realtime_buckets_expiry_idx": { + "name": "recent_realtime_buckets_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "recent_realtime_buckets_bucket_entity_id_pk": { + "name": "recent_realtime_buckets_bucket_entity_id_pk", + "columns": [ + "bucket", + "entity_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "recent_realtime_buckets_bucket_valid": { + "name": "recent_realtime_buckets_bucket_valid", + "value": "\"recent_realtime_buckets\".\"bucket\" in (1, 2, 3)" + }, + "recent_realtime_buckets_entity_positive": { + "name": "recent_realtime_buckets_entity_positive", + "value": "\"recent_realtime_buckets\".\"entity_id\" > 0" + }, + "recent_realtime_buckets_seq_positive": { + "name": "recent_realtime_buckets_seq_positive", + "value": "\"recent_realtime_buckets\".\"seq\" > 0" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.client_type": { + "name": "client_type", + "schema": "public", + "values": [ + "ios", + "macos", + "web", + "api", + "android", + "windows", + "linux", + "cli" + ] + }, + "public.member_roles": { + "name": "member_roles", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + }, + "public.chat_types": { + "name": "chat_types", + "schema": "public", + "values": [ + "private", + "thread" + ] + }, + "public.thread_graph_link_kind": { + "name": "thread_graph_link_kind", + "schema": "public", + "values": [ + "thread_link", + "reply_thread" + ] + }, + "public.thread_graph_scope_type": { + "name": "thread_graph_scope_type", + "schema": "public", + "values": [ + "user", + "space" + ] + }, + "public.account_provider": { + "name": "account_provider", + "schema": "public", + "values": [ + "google", + "apple" + ] + }, + "public.provider_auth_purpose": { + "name": "provider_auth_purpose", + "schema": "public", + "values": [ + "app", + "mcp_oauth", + "hosted_login" + ] + }, + "public.provider_auth_status": { + "name": "provider_auth_status", + "schema": "public", + "values": [ + "pending_provider", + "pending_invite", + "pending_email", + "complete", + "used" + ] + } + }, + "schemas": {}, + "sequences": { + "public.user_id": { + "name": "user_id", + "schema": "public", + "increment": "3", + "startWith": "1000", + "minValue": "1000", + "maxValue": "9223372036854775807", + "cache": "100", + "cycle": false + } + }, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/server/drizzle/meta/0155_snapshot.json b/server/drizzle/meta/0155_snapshot.json new file mode 100644 index 000000000..079c31e0f --- /dev/null +++ b/server/drizzle/meta/0155_snapshot.json @@ -0,0 +1,13146 @@ +{ + "id": "7ff6e5b3-b5de-4524-8496-b95b209fba36", + "prevId": "c0fd85d8-6fc2-45d5-9db9-f630a77657fe", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_zone": { + "name": "time_zone", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.there_users": { + "name": "there_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "time_zone": { + "name": "time_zone", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "there_users_email_unique": { + "name": "there_users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": "nextval('user_id')" + }, + "email": { + "name": "email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "phone_number": { + "name": "phone_number", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "phone_verified": { + "name": "phone_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "online": { + "name": "online", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_online": { + "name": "last_online", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "photo_file_id": { + "name": "photo_file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pending_setup": { + "name": "pending_setup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "time_zone": { + "name": "time_zone", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "share_time_zone": { + "name": "share_time_zone", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "appear_in_global_search": { + "name": "appear_in_global_search", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_thread_number": { + "name": "next_thread_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "bot": { + "name": "bot", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "bot_creator_id": { + "name": "bot_creator_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "update_seq": { + "name": "update_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_update_date": { + "name": "last_update_date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_photo_file_id_files_id_fk": { + "name": "users_photo_file_id_files_id_fk", + "tableFrom": "users", + "tableTo": "files", + "columnsFrom": [ + "photo_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "users_bot_creator_id_users_id_fk": { + "name": "users_bot_creator_id_users_id_fk", + "tableFrom": "users", + "tableTo": "users", + "columnsFrom": [ + "bot_creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_phone_number_unique": { + "name": "users_phone_number_unique", + "nullsNotDistinct": false, + "columns": [ + "phone_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "last_active": { + "name": "last_active", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "personal_data_encrypted": { + "name": "personal_data_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "personal_data_iv": { + "name": "personal_data_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "personal_data_tag": { + "name": "personal_data_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "applePushToken": { + "name": "applePushToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "apple_push_token_encrypted": { + "name": "apple_push_token_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "apple_push_token_iv": { + "name": "apple_push_token_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "apple_push_token_tag": { + "name": "apple_push_token_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "push_notification_provider": { + "name": "push_notification_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "push_content_key_public": { + "name": "push_content_key_public", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "push_content_key_id": { + "name": "push_content_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "push_content_key_algorithm": { + "name": "push_content_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "push_content_version": { + "name": "push_content_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_type": { + "name": "client_type", + "type": "client_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "clientVersion": { + "name": "clientVersion", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "osVersion": { + "name": "osVersion", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_token_hash_idx": { + "name": "sessions_token_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "device_id_user_unique": { + "name": "device_id_user_unique", + "nullsNotDistinct": false, + "columns": [ + "device_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.superadmin_sessions": { + "name": "superadmin_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "step_up_at": { + "name": "step_up_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "idle_expires_at": { + "name": "idle_expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent_hash": { + "name": "user_agent_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "superadmin_sessions_user_id_users_id_fk": { + "name": "superadmin_sessions_user_id_users_id_fk", + "tableFrom": "superadmin_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "superadmin_sessions_token_hash_unique": { + "name": "superadmin_sessions_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.login_codes": { + "name": "login_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "phone_number": { + "name": "phone_number", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false + }, + "challenge_id": { + "name": "challenge_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "login_codes_challenge_id_unique": { + "name": "login_codes_challenge_id_unique", + "columns": [ + { + "expression": "challenge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "login_codes_email_expires_idx": { + "name": "login_codes_email_expires_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "login_codes_phone_expires_idx": { + "name": "login_codes_phone_expires_idx", + "columns": [ + { + "expression": "phone_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "login_codes_code_or_hash_present": { + "name": "login_codes_code_or_hash_present", + "value": "\"login_codes\".\"code\" is not null or \"login_codes\".\"code_hash\" is not null" + } + }, + "isRLSEnabled": false + }, + "public.invite_codes": { + "name": "invite_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "redeemed_by_user_id": { + "name": "redeemed_by_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "invite_codes_code_unique": { + "name": "invite_codes_code_unique", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_codes_owner_user_id_idx": { + "name": "invite_codes_owner_user_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_codes_redeemed_by_user_id_idx": { + "name": "invite_codes_redeemed_by_user_id_idx", + "columns": [ + { + "expression": "redeemed_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invite_codes_owner_user_id_users_id_fk": { + "name": "invite_codes_owner_user_id_users_id_fk", + "tableFrom": "invite_codes", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "invite_codes_created_by_user_id_users_id_fk": { + "name": "invite_codes_created_by_user_id_users_id_fk", + "tableFrom": "invite_codes", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "invite_codes_redeemed_by_user_id_users_id_fk": { + "name": "invite_codes_redeemed_by_user_id_users_id_fk", + "tableFrom": "invite_codes", + "tableTo": "users", + "columnsFrom": [ + "redeemed_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.superadmin_users": { + "name": "superadmin_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_set_at": { + "name": "password_set_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "totp_secret_encrypted": { + "name": "totp_secret_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "totp_secret_iv": { + "name": "totp_secret_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "totp_secret_tag": { + "name": "totp_secret_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "totp_enabled_at": { + "name": "totp_enabled_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "totp_last_used_at": { + "name": "totp_last_used_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "failed_login_attempts": { + "name": "failed_login_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_login_attempt_at": { + "name": "last_login_attempt_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "login_locked_until": { + "name": "login_locked_until", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "superadmin_users_user_id_users_id_fk": { + "name": "superadmin_users_user_id_users_id_fk", + "tableFrom": "superadmin_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "superadmin_users_email_unique": { + "name": "superadmin_users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "superadmin_users_user_id_unique": { + "name": "superadmin_users_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spaces": { + "name": "spaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "photo_file_unique_id": { + "name": "photo_file_unique_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "is_pro": { + "name": "is_pro", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "handle": { + "name": "handle", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "creatorId": { + "name": "creatorId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "can_public_join": { + "name": "can_public_join", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted": { + "name": "deleted", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "next_thread_number": { + "name": "next_thread_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "update_seq": { + "name": "update_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_update_date": { + "name": "last_update_date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "grid_revision": { + "name": "grid_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "spaces_handle_unique": { + "name": "spaces_handle_unique", + "columns": [ + { + "expression": "lower(\"handle\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spaces_creatorId_users_id_fk": { + "name": "spaces_creatorId_users_id_fk", + "tableFrom": "spaces", + "tableTo": "users", + "columnsFrom": [ + "creatorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "spaces_photo_file_unique_id_fkey": { + "name": "spaces_photo_file_unique_id_fkey", + "tableFrom": "spaces", + "tableTo": "files", + "columnsFrom": [ + "photo_file_unique_id" + ], + "columnsTo": [ + "file_unique_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "spaces_public_join_handle_check": { + "name": "spaces_public_join_handle_check", + "value": "not \"spaces\".\"can_public_join\" or (\"spaces\".\"is_public\" and \"spaces\".\"handle\" is not null and \"spaces\".\"handle\" ~ '^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$')" + } + }, + "isRLSEnabled": false + }, + "public.space_invite_links": { + "name": "space_invite_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "token_encrypted": { + "name": "token_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "space_invite_links_token_hash_unique": { + "name": "space_invite_links_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "space_invite_links_active_space_unique": { + "name": "space_invite_links_active_space_unique", + "columns": [ + { + "expression": "space_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"space_invite_links\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "space_invite_links_space_id_idx": { + "name": "space_invite_links_space_id_idx", + "columns": [ + { + "expression": "space_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "space_invite_links_created_by_user_id_idx": { + "name": "space_invite_links_created_by_user_id_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "space_invite_links_space_id_spaces_id_fk": { + "name": "space_invite_links_space_id_spaces_id_fk", + "tableFrom": "space_invite_links", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "space_invite_links_created_by_user_id_users_id_fk": { + "name": "space_invite_links_created_by_user_id_users_id_fk", + "tableFrom": "space_invite_links", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "space_invite_links_token_hash_length": { + "name": "space_invite_links_token_hash_length", + "value": "octet_length(\"space_invite_links\".\"token_hash\") = 32" + }, + "space_invite_links_expiry_check": { + "name": "space_invite_links_expiry_check", + "value": "\"space_invite_links\".\"expires_at\" > \"space_invite_links\".\"created_at\"" + } + }, + "isRLSEnabled": false + }, + "public.space_join_blocks": { + "name": "space_join_blocks", + "schema": "", + "columns": { + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "space_join_blocks_user_id_idx": { + "name": "space_join_blocks_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "space_join_blocks_space_id_spaces_id_fk": { + "name": "space_join_blocks_space_id_spaces_id_fk", + "tableFrom": "space_join_blocks", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "space_join_blocks_user_id_users_id_fk": { + "name": "space_join_blocks_user_id_users_id_fk", + "tableFrom": "space_join_blocks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "space_join_blocks_pkey": { + "name": "space_join_blocks_pkey", + "columns": [ + "space_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "member_roles", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'member'" + }, + "can_access_public_chats": { + "name": "can_access_public_chats", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "invited_by": { + "name": "invited_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_space_id_spaces_id_fk": { + "name": "members_space_id_spaces_id_fk", + "tableFrom": "members", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_invited_by_users_id_fk": { + "name": "members_invited_by_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "members_user_id_space_id_unique": { + "name": "members_user_id_space_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "space_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_participants": { + "name": "chat_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "chat_participants_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_participants_chat_id_chats_id_fk": { + "name": "chat_participants_chat_id_chats_id_fk", + "tableFrom": "chat_participants", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "chat_participants_user_id_users_id_fk": { + "name": "chat_participants_user_id_users_id_fk", + "tableFrom": "chat_participants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_participant": { + "name": "unique_participant", + "nullsNotDistinct": false, + "columns": [ + "chat_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chats": { + "name": "chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "chats_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "type": { + "name": "type", + "type": "chat_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title_hash": { + "name": "title_hash", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "auto_title_generated": { + "name": "auto_title_generated", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_msg_id": { + "name": "last_msg_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "message_id_counter": { + "name": "message_id_counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "public_thread": { + "name": "public_thread", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "thread_number": { + "name": "thread_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_untitled": { + "name": "is_untitled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "parent_chat_id": { + "name": "parent_chat_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_user_id": { + "name": "min_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_user_id": { + "name": "max_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_context": { + "name": "agent_context", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "update_seq": { + "name": "update_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_update_date": { + "name": "last_update_date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chats_title_hash_idx": { + "name": "chats_title_hash_idx", + "columns": [ + { + "expression": "title_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "space_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_thread_number_unique": { + "name": "user_thread_number_unique", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chats\".\"space_id\" is null and \"chats\".\"thread_number\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chats_parent_chat_id_idx": { + "name": "chats_parent_chat_id_idx", + "columns": [ + { + "expression": "parent_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chats_space_id_spaces_id_fk": { + "name": "chats_space_id_spaces_id_fk", + "tableFrom": "chats", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "chats_created_by_users_id_fk": { + "name": "chats_created_by_users_id_fk", + "tableFrom": "chats", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "chats_parent_chat_id_chats_id_fk": { + "name": "chats_parent_chat_id_chats_id_fk", + "tableFrom": "chats", + "tableTo": "chats", + "columnsFrom": [ + "parent_chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "chats_min_user_id_users_id_fk": { + "name": "chats_min_user_id_users_id_fk", + "tableFrom": "chats", + "tableTo": "users", + "columnsFrom": [ + "min_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "chats_max_user_id_users_id_fk": { + "name": "chats_max_user_id_users_id_fk", + "tableFrom": "chats", + "tableTo": "users", + "columnsFrom": [ + "max_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "last_msg_id_fk": { + "name": "last_msg_id_fk", + "tableFrom": "chats", + "tableTo": "messages", + "columnsFrom": [ + "id", + "last_msg_id" + ], + "columnsTo": [ + "chat_id", + "message_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "parent_message_id_fk": { + "name": "parent_message_id_fk", + "tableFrom": "chats", + "tableTo": "messages", + "columnsFrom": [ + "parent_chat_id", + "parent_message_id" + ], + "columnsTo": [ + "chat_id", + "message_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_ids_unique": { + "name": "user_ids_unique", + "nullsNotDistinct": false, + "columns": [ + "min_user_id", + "max_user_id" + ] + }, + "space_thread_number_unique": { + "name": "space_thread_number_unique", + "nullsNotDistinct": false, + "columns": [ + "space_id", + "thread_number" + ] + }, + "reply_thread_parent_unique": { + "name": "reply_thread_parent_unique", + "nullsNotDistinct": false, + "columns": [ + "parent_chat_id", + "parent_message_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "user_ids_check": { + "name": "user_ids_check", + "value": "\"chats\".\"min_user_id\" <= \"chats\".\"max_user_id\"" + }, + "parent_message_requires_parent_chat": { + "name": "parent_message_requires_parent_chat", + "value": "\"chats\".\"parent_message_id\" is null or \"chats\".\"parent_chat_id\" is not null" + } + }, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "global_id": { + "name": "global_id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "random_id": { + "name": "random_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text_encrypted": { + "name": "text_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "text_iv": { + "name": "text_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "text_tag": { + "name": "text_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "entities_encrypted": { + "name": "entities_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "entities_iv": { + "name": "entities_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "entities_tag": { + "name": "entities_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "actions_encrypted": { + "name": "actions_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "actions_iv": { + "name": "actions_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "actions_tag": { + "name": "actions_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "system_message_encrypted": { + "name": "system_message_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "system_message_iv": { + "name": "system_message_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "system_message_tag": { + "name": "system_message_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "from_id": { + "name": "from_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "edit_date": { + "name": "edit_date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "rev": { + "name": "rev", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "block_content_id": { + "name": "block_content_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "reply_to_msg_id": { + "name": "reply_to_msg_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fwd_from_peer_user_id": { + "name": "fwd_from_peer_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fwd_from_peer_chat_id": { + "name": "fwd_from_peer_chat_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fwd_from_message_id": { + "name": "fwd_from_message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fwd_from_sender_id": { + "name": "fwd_from_sender_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "grouped_id": { + "name": "grouped_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "photo_id": { + "name": "photo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "video_id": { + "name": "video_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "document_id": { + "name": "document_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "voice_id": { + "name": "voice_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_sticker": { + "name": "is_sticker", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "has_link": { + "name": "has_link", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "counts_as_unread": { + "name": "counts_as_unread", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "msg_id_per_chat_index": { + "name": "msg_id_per_chat_index", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_chat_id_message_id_desc_idx": { + "name": "messages_chat_id_message_id_desc_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unread_count_index": { + "name": "unread_count_index", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "from_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_block_content_id_idx": { + "name": "messages_block_content_id_idx", + "columns": [ + { + "expression": "block_content_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_activity_date_from_id_idx": { + "name": "messages_activity_date_from_id_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "from_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"messages\".\"system_message_encrypted\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_chat_id_chats_id_fk": { + "name": "messages_chat_id_chats_id_fk", + "tableFrom": "messages", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_from_id_users_id_fk": { + "name": "messages_from_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "from_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_block_content_id_block_contents_id_fk": { + "name": "messages_block_content_id_block_contents_id_fk", + "tableFrom": "messages", + "tableTo": "block_contents", + "columnsFrom": [ + "block_content_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_photo_id_photos_id_fk": { + "name": "messages_photo_id_photos_id_fk", + "tableFrom": "messages", + "tableTo": "photos", + "columnsFrom": [ + "photo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_video_id_videos_id_fk": { + "name": "messages_video_id_videos_id_fk", + "tableFrom": "messages", + "tableTo": "videos", + "columnsFrom": [ + "video_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_document_id_documents_id_fk": { + "name": "messages_document_id_documents_id_fk", + "tableFrom": "messages", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_voice_id_voices_id_fk": { + "name": "messages_voice_id_voices_id_fk", + "tableFrom": "messages", + "tableTo": "voices", + "columnsFrom": [ + "voice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_file_id_files_id_fk": { + "name": "messages_file_id_files_id_fk", + "tableFrom": "messages", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "msg_id_per_chat_unique": { + "name": "msg_id_per_chat_unique", + "nullsNotDistinct": false, + "columns": [ + "message_id", + "chat_id" + ] + }, + "random_id_per_sender_unique": { + "name": "random_id_per_sender_unique", + "nullsNotDistinct": false, + "columns": [ + "random_id", + "from_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.block_content_image_jobs": { + "name": "block_content_image_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "block_content_image_jobs_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "content_id": { + "name": "content_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "expected_revision": { + "name": "expected_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "block_path": { + "name": "block_path", + "type": "integer[]", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "hash_version": { + "name": "hash_version", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_encrypted": { + "name": "source_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_iv": { + "name": "source_iv", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_tag": { + "name": "source_tag", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "available_at": { + "name": "available_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lease_token": { + "name": "lease_token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "staged_object_path_encrypted": { + "name": "staged_object_path_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "staged_object_path_iv": { + "name": "staged_object_path_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "staged_object_path_tag": { + "name": "staged_object_path_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "photo_id": { + "name": "photo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "block_content_image_jobs_ready_idx": { + "name": "block_content_image_jobs_ready_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "block_content_image_jobs_revision_idx": { + "name": "block_content_image_jobs_revision_idx", + "columns": [ + { + "expression": "content_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expected_revision", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "block_content_image_jobs_path_idx": { + "name": "block_content_image_jobs_path_idx", + "columns": [ + { + "expression": "content_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "block_content_image_jobs_content_id_block_contents_id_fk": { + "name": "block_content_image_jobs_content_id_block_contents_id_fk", + "tableFrom": "block_content_image_jobs", + "tableTo": "block_contents", + "columnsFrom": [ + "content_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "block_content_image_jobs_photo_id_photos_id_fk": { + "name": "block_content_image_jobs_photo_id_photos_id_fk", + "tableFrom": "block_content_image_jobs", + "tableTo": "photos", + "columnsFrom": [ + "photo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "block_content_image_jobs_state_check": { + "name": "block_content_image_jobs_state_check", + "value": "\"block_content_image_jobs\".\"state\" in ('pending', 'processing', 'ready', 'failed', 'canceled')" + }, + "block_content_image_jobs_attempts_check": { + "name": "block_content_image_jobs_attempts_check", + "value": "\"block_content_image_jobs\".\"attempts\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.block_contents": { + "name": "block_contents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "block_contents_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "payload_encrypted": { + "name": "payload_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "payload_iv": { + "name": "payload_iv", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "payload_tag": { + "name": "payload_tag", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.thread_graph_links": { + "name": "thread_graph_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "thread_graph_link_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "scope_type": { + "name": "scope_type", + "type": "thread_graph_scope_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "from_chat_id": { + "name": "from_chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "from_message_global_id": { + "name": "from_message_global_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "from_message_id": { + "name": "from_message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "from_message_revision": { + "name": "from_message_revision", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "entity_index": { + "name": "entity_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "to_chat_id": { + "name": "to_chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "backlink_message_global_id": { + "name": "backlink_message_global_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "link_text_hmac": { + "name": "link_text_hmac", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "target_title_hmac": { + "name": "target_title_hmac", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "thread_graph_links_scope_to_chat_idx": { + "name": "thread_graph_links_scope_to_chat_idx", + "columns": [ + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "to_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "thread_graph_links_scope_from_chat_idx": { + "name": "thread_graph_links_scope_from_chat_idx", + "columns": [ + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "from_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "thread_graph_links_from_message_idx": { + "name": "thread_graph_links_from_message_idx", + "columns": [ + { + "expression": "from_message_global_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "thread_graph_links_backlink_message_idx": { + "name": "thread_graph_links_backlink_message_idx", + "columns": [ + { + "expression": "backlink_message_global_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "thread_graph_links_active_idx": { + "name": "thread_graph_links_active_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "to_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"thread_graph_links\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tgl_from_chat_fk": { + "name": "tgl_from_chat_fk", + "tableFrom": "thread_graph_links", + "tableTo": "chats", + "columnsFrom": [ + "from_chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tgl_from_message_fk": { + "name": "tgl_from_message_fk", + "tableFrom": "thread_graph_links", + "tableTo": "messages", + "columnsFrom": [ + "from_message_global_id" + ], + "columnsTo": [ + "global_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tgl_to_chat_fk": { + "name": "tgl_to_chat_fk", + "tableFrom": "thread_graph_links", + "tableTo": "chats", + "columnsFrom": [ + "to_chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tgl_backlink_message_fk": { + "name": "tgl_backlink_message_fk", + "tableFrom": "thread_graph_links", + "tableTo": "messages", + "columnsFrom": [ + "backlink_message_global_id" + ], + "columnsTo": [ + "global_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "thread_graph_links_dedupe_key_unique": { + "name": "thread_graph_links_dedupe_key_unique", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subthread_parent_messages": { + "name": "subthread_parent_messages", + "schema": "", + "columns": { + "child_chat_id": { + "name": "child_chat_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "parent_message_global_id": { + "name": "parent_message_global_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "subthread_parent_messages_child_chat_fk": { + "name": "subthread_parent_messages_child_chat_fk", + "tableFrom": "subthread_parent_messages", + "tableTo": "chats", + "columnsFrom": [ + "child_chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "subthread_parent_messages_parent_message_fk": { + "name": "subthread_parent_messages_parent_message_fk", + "tableFrom": "subthread_parent_messages", + "tableTo": "messages", + "columnsFrom": [ + "parent_message_global_id" + ], + "columnsTo": [ + "global_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subthread_parent_messages_parent_message_unique": { + "name": "subthread_parent_messages_parent_message_unique", + "nullsNotDistinct": false, + "columns": [ + "parent_message_global_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dialogs": { + "name": "dialogs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "peer_user_id": { + "name": "peer_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "read_inbox_max_id": { + "name": "read_inbox_max_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "read_outbox_max_id": { + "name": "read_outbox_max_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "draft": { + "name": "draft", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "sidebar_visible": { + "name": "sidebar_visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "chat_list_hidden": { + "name": "chat_list_hidden", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "open": { + "name": "open", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "opened_date": { + "name": "opened_date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pinned_order": { + "name": "pinned_order", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unread_mark": { + "name": "unread_mark", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "notification_settings": { + "name": "notification_settings", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "follow_mode": { + "name": "follow_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "collapsed_max_id": { + "name": "collapsed_max_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "translation_enabled": { + "name": "translation_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "dialogs_user_id_chat_id_idx": { + "name": "dialogs_user_id_chat_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dialogs_user_id_peer_user_id_idx": { + "name": "dialogs_user_id_peer_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "peer_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dialogs_user_id_order_idx": { + "name": "dialogs_user_id_order_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dialogs_user_id_pinned_order_idx": { + "name": "dialogs_user_id_pinned_order_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pinned_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dialogs_user_id_folder_id_order_idx": { + "name": "dialogs_user_id_folder_id_order_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dialogs_user_id_users_id_fk": { + "name": "dialogs_user_id_users_id_fk", + "tableFrom": "dialogs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "dialogs_chat_id_chats_id_fk": { + "name": "dialogs_chat_id_chats_id_fk", + "tableFrom": "dialogs", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dialogs_peer_user_id_users_id_fk": { + "name": "dialogs_peer_user_id_users_id_fk", + "tableFrom": "dialogs", + "tableTo": "users", + "columnsFrom": [ + "peer_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "dialogs_space_id_spaces_id_fk": { + "name": "dialogs_space_id_spaces_id_fk", + "tableFrom": "dialogs", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "dialogs_folder_id_user_id_dialog_folders_id_user_id_fk": { + "name": "dialogs_folder_id_user_id_dialog_folders_id_user_id_fk", + "tableFrom": "dialogs", + "tableTo": "dialog_folders", + "columnsFrom": [ + "folder_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_id_user_id_unique": { + "name": "chat_id_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "chat_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_acknowledgements": { + "name": "chat_acknowledgements", + "schema": "", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "max_id": { + "name": "max_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cleared": { + "name": "cleared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "chat_acknowledgements_user_id_idx": { + "name": "chat_acknowledgements_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_acknowledgements_chat_id_chats_id_fk": { + "name": "chat_acknowledgements_chat_id_chats_id_fk", + "tableFrom": "chat_acknowledgements", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_acknowledgements_user_id_users_id_fk": { + "name": "chat_acknowledgements_user_id_users_id_fk", + "tableFrom": "chat_acknowledgements", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "chat_acknowledgements_chat_id_user_id_pk": { + "name": "chat_acknowledgements_chat_id_user_id_pk", + "columns": [ + "chat_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dialog_folders": { + "name": "dialog_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_order": { + "name": "pinned_order", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dialog_folders_user_id_order_idx": { + "name": "dialog_folders_user_id_order_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dialog_folders_user_id_pinned_order_idx": { + "name": "dialog_folders_user_id_pinned_order_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pinned_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dialog_folders_user_id_users_id_fk": { + "name": "dialog_folders_user_id_users_id_fk", + "tableFrom": "dialog_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dialog_folders_id_user_id_unique": { + "name": "dialog_folders_id_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_id_reservation": { + "name": "chat_id_reservation", + "schema": "", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_id_reservation_user_id_users_id_fk": { + "name": "chat_id_reservation_user_id_users_id_fk", + "tableFrom": "chat_id_reservation", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reactions": { + "name": "reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "reactions_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emoji_hash": { + "name": "emoji_hash", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "reactions_chat_id_chats_id_fk": { + "name": "reactions_chat_id_chats_id_fk", + "tableFrom": "reactions", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reactions_user_id_users_id_fk": { + "name": "reactions_user_id_users_id_fk", + "tableFrom": "reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_id_fk": { + "name": "message_id_fk", + "tableFrom": "reactions", + "tableTo": "messages", + "columnsFrom": [ + "chat_id", + "message_id" + ], + "columnsTo": [ + "chat_id", + "message_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reactions_identity_emoji_hash_unique": { + "name": "reactions_identity_emoji_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "chat_id", + "message_id", + "user_id", + "emoji_hash" + ] + }, + "unique_reaction_per_emoji": { + "name": "unique_reaction_per_emoji", + "nullsNotDistinct": false, + "columns": [ + "chat_id", + "message_id", + "user_id", + "emoji" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "file_unique_id": { + "name": "file_unique_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path_encrypted": { + "name": "path_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "path_iv": { + "name": "path_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "path_tag": { + "name": "path_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cdn": { + "name": "cdn", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "file_type": { + "name": "file_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "video_duration": { + "name": "video_duration", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "thumb_size": { + "name": "thumb_size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumb_for": { + "name": "thumb_for", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "bytes_encrypted": { + "name": "bytes_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "bytes_iv": { + "name": "bytes_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "bytes_tag": { + "name": "bytes_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "name_encrypted": { + "name": "name_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "name_iv": { + "name": "name_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "name_tag": { + "name": "name_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "files_user_id_users_id_fk": { + "name": "files_user_id_users_id_fk", + "tableFrom": "files", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "files_thumb_for_files_id_fk": { + "name": "files_thumb_for_files_id_fk", + "tableFrom": "files", + "tableTo": "files", + "columnsFrom": [ + "thumb_for" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "files_file_unique_id_unique": { + "name": "files_file_unique_id_unique", + "nullsNotDistinct": false, + "columns": [ + "file_unique_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_oauth_states": { + "name": "integration_oauth_states", + "schema": "", + "columns": { + "state_hash": { + "name": "state_hash", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "callback_scheme": { + "name": "callback_scheme", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_oauth_states_user": { + "name": "integration_oauth_states_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_oauth_states_expires": { + "name": "integration_oauth_states_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_oauth_states_user_id_users_id_fk": { + "name": "integration_oauth_states_user_id_users_id_fk", + "tableFrom": "integration_oauth_states", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_oauth_states_space_id_spaces_id_fk": { + "name": "integration_oauth_states_space_id_spaces_id_fk", + "tableFrom": "integration_oauth_states", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integrations": { + "name": "integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_encrypted": { + "name": "access_token_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "access_token_iv": { + "name": "access_token_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "access_token_tag": { + "name": "access_token_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "notion_database_id": { + "name": "notion_database_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_team_id": { + "name": "linear_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integrations_space_provider_unique": { + "name": "integrations_space_provider_unique", + "columns": [ + { + "expression": "space_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integrations_user_id_users_id_fk": { + "name": "integrations_user_id_users_id_fk", + "tableFrom": "integrations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "integrations_space_id_spaces_id_fk": { + "name": "integrations_space_id_spaces_id_fk", + "tableFrom": "integrations", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_tokens": { + "name": "bot_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_encrypted": { + "name": "token_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "token_iv": { + "name": "token_iv", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "token_tag": { + "name": "token_tag", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bot_tokens_bot_user_id_unique": { + "name": "bot_tokens_bot_user_id_unique", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_tokens_bot_user_id_users_id_fk": { + "name": "bot_tokens_bot_user_id_users_id_fk", + "tableFrom": "bot_tokens", + "tableTo": "users", + "columnsFrom": [ + "bot_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "bot_tokens_session_id_sessions_id_fk": { + "name": "bot_tokens_session_id_sessions_id_fk", + "tableFrom": "bot_tokens", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_commands": { + "name": "bot_commands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bot_commands_bot_user_id_command_unique": { + "name": "bot_commands_bot_user_id_command_unique", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "command", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_commands_bot_user_id_users_id_fk": { + "name": "bot_commands_bot_user_id_users_id_fk", + "tableFrom": "bot_commands", + "tableTo": "users", + "columnsFrom": [ + "bot_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_skills": { + "name": "bot_skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bot_skills_bot_user_id_key_unique": { + "name": "bot_skills_bot_user_id_key_unique", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_skills_bot_user_id_users_id_fk": { + "name": "bot_skills_bot_user_id_users_id_fk", + "tableFrom": "bot_skills", + "tableTo": "users", + "columnsFrom": [ + "bot_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_agents": { + "name": "bot_agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "emoji": { + "name": "emoji", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "skill_key": { + "name": "skill_key", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "instructions_encrypted": { + "name": "instructions_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bot_agents_bot_user_id_idx": { + "name": "bot_agents_bot_user_id_idx", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_agents_bot_user_id_users_id_fk": { + "name": "bot_agents_bot_user_id_users_id_fk", + "tableFrom": "bot_agents", + "tableTo": "users", + "columnsFrom": [ + "bot_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_capabilities": { + "name": "bot_capabilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bot_capabilities_bot_user_id_kind_unique": { + "name": "bot_capabilities_bot_user_id_kind_unique", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_capabilities_bot_user_id_users_id_fk": { + "name": "bot_capabilities_bot_user_id_users_id_fk", + "tableFrom": "bot_capabilities", + "tableTo": "users", + "columnsFrom": [ + "bot_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_avatar_assets": { + "name": "bot_avatar_assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_avatar_assets_bot_user_id_users_id_fk": { + "name": "bot_avatar_assets_bot_user_id_users_id_fk", + "tableFrom": "bot_avatar_assets", + "tableTo": "users", + "columnsFrom": [ + "bot_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "bot_avatar_assets_file_id_files_id_fk": { + "name": "bot_avatar_assets_file_id_files_id_fk", + "tableFrom": "bot_avatar_assets", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "bot_avatar_assets_bot_user_id_unique": { + "name": "bot_avatar_assets_bot_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "bot_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_message_routes": { + "name": "bot_message_routes", + "schema": "", + "columns": { + "bot_user_id": { + "name": "bot_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "activation_reason": { + "name": "activation_reason", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "bot_message_routes_identity_unique": { + "name": "bot_message_routes_identity_unique", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_message_routes_message_idx": { + "name": "bot_message_routes_message_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_message_routes_expiry_idx": { + "name": "bot_message_routes_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_message_routes_bot_user_id_users_id_fk": { + "name": "bot_message_routes_bot_user_id_users_id_fk", + "tableFrom": "bot_message_routes", + "tableTo": "users", + "columnsFrom": [ + "bot_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_update_streams": { + "name": "bot_update_streams", + "schema": "", + "columns": { + "bot_user_id": { + "name": "bot_user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "next_update_id": { + "name": "next_update_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "acknowledged_update_id": { + "name": "acknowledged_update_id", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allowed_updates": { + "name": "allowed_updates", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "message_trigger": { + "name": "message_trigger", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'mentions'" + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_secret_encrypted": { + "name": "webhook_secret_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "poll_lease_token": { + "name": "poll_lease_token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "poll_lease_expires_at": { + "name": "poll_lease_expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "config_generation": { + "name": "config_generation", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "pending_update_count": { + "name": "pending_update_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "pending_payload_bytes": { + "name": "pending_payload_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "delivery_locked_at": { + "name": "delivery_locked_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dropped_update_count": { + "name": "dropped_update_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_update_streams_bot_user_id_users_id_fk": { + "name": "bot_update_streams_bot_user_id_users_id_fk", + "tableFrom": "bot_update_streams", + "tableTo": "users", + "columnsFrom": [ + "bot_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_updates": { + "name": "bot_updates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "bot_updates_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "update_id": { + "name": "update_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "update_type": { + "name": "update_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "payload_encrypted": { + "name": "payload_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "payload_byte_count": { + "name": "payload_byte_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_event_id": { + "name": "source_event_id", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "claim_token": { + "name": "claim_token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "claim_generation": { + "name": "claim_generation", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bot_updates_bot_update_id_unique": { + "name": "bot_updates_bot_update_id_unique", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_updates_bot_source_event_unique": { + "name": "bot_updates_bot_source_event_unique", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_updates_pending_idx": { + "name": "bot_updates_pending_idx", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_updates_expiry_idx": { + "name": "bot_updates_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_updates_delivery_idx": { + "name": "bot_updates_delivery_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_updates_bot_user_id_users_id_fk": { + "name": "bot_updates_bot_user_id_users_id_fk", + "tableFrom": "bot_updates", + "tableTo": "users", + "columnsFrom": [ + "bot_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "documents_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "file_id": { + "name": "file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "file_name": { + "name": "file_name", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "file_name_iv": { + "name": "file_name_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "file_name_tag": { + "name": "file_name_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "photo_id": { + "name": "photo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "documents_file_id_files_id_fk": { + "name": "documents_file_id_files_id_fk", + "tableFrom": "documents", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "documents_photo_id_photos_id_fk": { + "name": "documents_photo_id_photos_id_fk", + "tableFrom": "documents", + "tableTo": "photos", + "columnsFrom": [ + "photo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.photo_sizes": { + "name": "photo_sizes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "photo_sizes_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "file_id": { + "name": "file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photo_id": { + "name": "photo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "photo_sizes_photo_id_idx": { + "name": "photo_sizes_photo_id_idx", + "columns": [ + { + "expression": "photo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "photo_sizes_file_id_files_id_fk": { + "name": "photo_sizes_file_id_files_id_fk", + "tableFrom": "photo_sizes", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "photo_sizes_photo_id_photos_id_fk": { + "name": "photo_sizes_photo_id_photos_id_fk", + "tableFrom": "photo_sizes", + "tableTo": "photos", + "columnsFrom": [ + "photo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.photos": { + "name": "photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "photos_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "stripped": { + "name": "stripped", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "stripped_iv": { + "name": "stripped_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "stripped_tag": { + "name": "stripped_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.videos": { + "name": "videos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "videos_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "file_id": { + "name": "file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photo_id": { + "name": "photo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "is_animated": { + "name": "is_animated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "has_audio": { + "name": "has_audio", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "videos_file_id_files_id_fk": { + "name": "videos_file_id_files_id_fk", + "tableFrom": "videos", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "videos_photo_id_photos_id_fk": { + "name": "videos_photo_id_photos_id_fk", + "tableFrom": "videos", + "tableTo": "photos", + "columnsFrom": [ + "photo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voices": { + "name": "voices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "voices_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "file_id": { + "name": "file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "waveform": { + "name": "waveform", + "type": "bytea", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "voices_file_id_files_id_fk": { + "name": "voices_file_id_files_id_fk", + "tableFrom": "voices", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.external_tasks": { + "name": "external_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "external_tasks_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "application": { + "name": "application", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_user_id": { + "name": "assigned_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "connector_space_id": { + "name": "connector_space_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source_message_id": { + "name": "source_message_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "title_iv": { + "name": "title_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "title_tag": { + "name": "title_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "external_tasks_provider_user_source_space_unique": { + "name": "external_tasks_provider_user_source_space_unique", + "columns": [ + { + "expression": "source_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assigned_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "application", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connector_space_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "external_tasks_assigned_user_id_users_id_fk": { + "name": "external_tasks_assigned_user_id_users_id_fk", + "tableFrom": "external_tasks", + "tableTo": "users", + "columnsFrom": [ + "assigned_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "external_tasks_connector_space_id_spaces_id_fk": { + "name": "external_tasks_connector_space_id_spaces_id_fk", + "tableFrom": "external_tasks", + "tableTo": "spaces", + "columnsFrom": [ + "connector_space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "external_tasks_source_message_id_messages_global_id_fk": { + "name": "external_tasks_source_message_id_messages_global_id_fk", + "tableFrom": "external_tasks", + "tableTo": "messages", + "columnsFrom": [ + "source_message_id" + ], + "columnsTo": [ + "global_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_attachments": { + "name": "message_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "message_attachments_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "message_id": { + "name": "message_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "external_task_id": { + "name": "external_task_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "url_preview_id": { + "name": "url_preview_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "message_attachments_message_id_idx": { + "name": "message_attachments_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_attachments_message_id_messages_global_id_fk": { + "name": "message_attachments_message_id_messages_global_id_fk", + "tableFrom": "message_attachments", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "global_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_attachments_external_task_id_external_tasks_id_fk": { + "name": "message_attachments_external_task_id_external_tasks_id_fk", + "tableFrom": "message_attachments", + "tableTo": "external_tasks", + "columnsFrom": [ + "external_task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "message_attachments_url_preview_id_url_preview_id_fk": { + "name": "message_attachments_url_preview_id_url_preview_id_fk", + "tableFrom": "message_attachments", + "tableTo": "url_preview", + "columnsFrom": [ + "url_preview_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.url_preview": { + "name": "url_preview", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "url_preview_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "url": { + "name": "url", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "url_iv": { + "name": "url_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "url_tag": { + "name": "url_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "site_name": { + "name": "site_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'generic'" + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "title_iv": { + "name": "title_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "title_tag": { + "name": "title_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "description_iv": { + "name": "description_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "description_tag": { + "name": "description_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "author_iv": { + "name": "author_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "author_tag": { + "name": "author_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "media_kind": { + "name": "media_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "photo_id": { + "name": "photo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "author_photo_id": { + "name": "author_photo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "video_id": { + "name": "video_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "document_id": { + "name": "document_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_id": { + "name": "cache_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "external_url_iv": { + "name": "external_url_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "external_url_tag": { + "name": "external_url_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "external_mime_type": { + "name": "external_mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_width": { + "name": "external_width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "external_height": { + "name": "external_height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "external_duration": { + "name": "external_duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "embed_url": { + "name": "embed_url", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "embed_url_iv": { + "name": "embed_url_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "embed_url_tag": { + "name": "embed_url_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "embed_type": { + "name": "embed_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "embed_width": { + "name": "embed_width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "embed_height": { + "name": "embed_height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "embed_duration": { + "name": "embed_duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_large_media": { + "name": "has_large_media", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "show_large_media": { + "name": "show_large_media", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "url_preview_photo_id_photos_id_fk": { + "name": "url_preview_photo_id_photos_id_fk", + "tableFrom": "url_preview", + "tableTo": "photos", + "columnsFrom": [ + "photo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "url_preview_author_photo_id_photos_id_fk": { + "name": "url_preview_author_photo_id_photos_id_fk", + "tableFrom": "url_preview", + "tableTo": "photos", + "columnsFrom": [ + "author_photo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "url_preview_video_id_videos_id_fk": { + "name": "url_preview_video_id_videos_id_fk", + "tableFrom": "url_preview", + "tableTo": "videos", + "columnsFrom": [ + "video_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "url_preview_document_id_documents_id_fk": { + "name": "url_preview_document_id_documents_id_fk", + "tableFrom": "url_preview", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "url_preview_cache_id_url_preview_cache_id_fk": { + "name": "url_preview_cache_id_url_preview_cache_id_fk", + "tableFrom": "url_preview", + "tableTo": "url_preview_cache", + "columnsFrom": [ + "cache_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.url_preview_cache": { + "name": "url_preview_cache", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "url_preview_cache_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "url_hash": { + "name": "url_hash", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "hash_version": { + "name": "hash_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "url": { + "name": "url", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "url_iv": { + "name": "url_iv", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "url_tag": { + "name": "url_tag", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "final_url": { + "name": "final_url", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "final_url_iv": { + "name": "final_url_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "final_url_tag": { + "name": "final_url_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'generic'" + }, + "site_name": { + "name": "site_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "title_iv": { + "name": "title_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "title_tag": { + "name": "title_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "description_iv": { + "name": "description_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "description_tag": { + "name": "description_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "author_iv": { + "name": "author_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "author_tag": { + "name": "author_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "image_url_hash": { + "name": "image_url_hash", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "image_url_iv": { + "name": "image_url_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "image_url_tag": { + "name": "image_url_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "author_image_url_hash": { + "name": "author_image_url_hash", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "author_image_url": { + "name": "author_image_url", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "author_image_url_iv": { + "name": "author_image_url_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "author_image_url_tag": { + "name": "author_image_url_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "media_kind": { + "name": "media_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "photo_id": { + "name": "photo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "author_photo_id": { + "name": "author_photo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "video_id": { + "name": "video_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "document_id": { + "name": "document_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "external_url_iv": { + "name": "external_url_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "external_url_tag": { + "name": "external_url_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "external_mime_type": { + "name": "external_mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_width": { + "name": "external_width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "external_height": { + "name": "external_height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "external_duration": { + "name": "external_duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "embed_url": { + "name": "embed_url", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "embed_url_iv": { + "name": "embed_url_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "embed_url_tag": { + "name": "embed_url_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "embed_type": { + "name": "embed_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "embed_width": { + "name": "embed_width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "embed_height": { + "name": "embed_height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "embed_duration": { + "name": "embed_duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_large_media": { + "name": "has_large_media", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "show_large_media": { + "name": "show_large_media", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "url_preview_cache_url_hash_unique": { + "name": "url_preview_cache_url_hash_unique", + "columns": [ + { + "expression": "url_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "url_preview_cache_image_url_hash_idx": { + "name": "url_preview_cache_image_url_hash_idx", + "columns": [ + { + "expression": "image_url_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "url_preview_cache_author_image_url_hash_idx": { + "name": "url_preview_cache_author_image_url_hash_idx", + "columns": [ + { + "expression": "author_image_url_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "url_preview_cache_expires_at_idx": { + "name": "url_preview_cache_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "url_preview_cache_last_used_at_idx": { + "name": "url_preview_cache_last_used_at_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "url_preview_cache_photo_id_photos_id_fk": { + "name": "url_preview_cache_photo_id_photos_id_fk", + "tableFrom": "url_preview_cache", + "tableTo": "photos", + "columnsFrom": [ + "photo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "url_preview_cache_author_photo_id_photos_id_fk": { + "name": "url_preview_cache_author_photo_id_photos_id_fk", + "tableFrom": "url_preview_cache", + "tableTo": "photos", + "columnsFrom": [ + "author_photo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "url_preview_cache_video_id_videos_id_fk": { + "name": "url_preview_cache_video_id_videos_id_fk", + "tableFrom": "url_preview_cache", + "tableTo": "videos", + "columnsFrom": [ + "video_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "url_preview_cache_document_id_documents_id_fk": { + "name": "url_preview_cache_document_id_documents_id_fk", + "tableFrom": "url_preview_cache", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.space_url_preview_exclusions": { + "name": "space_url_preview_exclusions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "space_url_preview_exclusions_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path_prefix": { + "name": "path_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "space_url_preview_exclusions_space_host_path_unique": { + "name": "space_url_preview_exclusions_space_host_path_unique", + "columns": [ + { + "expression": "space_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "space_url_preview_exclusions_space_host_idx": { + "name": "space_url_preview_exclusions_space_host_idx", + "columns": [ + { + "expression": "space_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "space_url_preview_exclusions_created_by_idx": { + "name": "space_url_preview_exclusions_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "space_url_preview_exclusions_space_id_spaces_id_fk": { + "name": "space_url_preview_exclusions_space_id_spaces_id_fk", + "tableFrom": "space_url_preview_exclusions", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "space_url_preview_exclusions_created_by_users_id_fk": { + "name": "space_url_preview_exclusions_created_by_users_id_fk", + "tableFrom": "space_url_preview_exclusions", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_participant_groups": { + "name": "chat_participant_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_participant_groups_chat_group_unique": { + "name": "chat_participant_groups_chat_group_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_participant_groups_chat_id_idx": { + "name": "chat_participant_groups_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_participant_groups_group_id_idx": { + "name": "chat_participant_groups_group_id_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_participant_groups_chat_id_chats_id_fk": { + "name": "chat_participant_groups_chat_id_chats_id_fk", + "tableFrom": "chat_participant_groups", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_participant_groups_group_id_user_groups_id_fk": { + "name": "chat_participant_groups_group_id_user_groups_id_fk", + "tableFrom": "chat_participant_groups", + "tableTo": "user_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_group_members": { + "name": "user_group_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_group_members_group_user_unique": { + "name": "user_group_members_group_user_unique", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_group_members_group_id_idx": { + "name": "user_group_members_group_id_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_group_members_user_id_idx": { + "name": "user_group_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_group_members_group_id_user_groups_id_fk": { + "name": "user_group_members_group_id_user_groups_id_fk", + "tableFrom": "user_group_members", + "tableTo": "user_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_group_members_user_id_users_id_fk": { + "name": "user_group_members_user_id_users_id_fk", + "tableFrom": "user_group_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_groups": { + "name": "user_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_groups_space_name_unique": { + "name": "user_groups_space_name_unique", + "columns": [ + { + "expression": "space_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_groups_space_id_idx": { + "name": "user_groups_space_id_idx", + "columns": [ + { + "expression": "space_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_groups_created_by_idx": { + "name": "user_groups_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_groups_space_id_spaces_id_fk": { + "name": "user_groups_space_id_spaces_id_fk", + "tableFrom": "user_groups", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_groups_created_by_users_id_fk": { + "name": "user_groups_created_by_users_id_fk", + "tableFrom": "user_groups", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_translations": { + "name": "message_translations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "message_translations_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "msg_rev": { + "name": "msg_rev", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "message_id": { + "name": "message_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "translation": { + "name": "translation", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "translation_iv": { + "name": "translation_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "translation_tag": { + "name": "translation_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "entities": { + "name": "entities", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "message_translations_chat_id_chats_id_fk": { + "name": "message_translations_chat_id_chats_id_fk", + "tableFrom": "message_translations", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "chat_id_message_id_fk": { + "name": "chat_id_message_id_fk", + "tableFrom": "message_translations", + "tableTo": "messages", + "columnsFrom": [ + "chat_id", + "message_id" + ], + "columnsTo": [ + "chat_id", + "message_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_message_chat_language": { + "name": "unique_message_chat_language", + "nullsNotDistinct": false, + "columns": [ + "message_id", + "chat_id", + "language" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_settings": { + "name": "user_settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "general_encrypted": { + "name": "general_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "general_iv": { + "name": "general_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "general_tag": { + "name": "general_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_settings_user_id_users_id_fk": { + "name": "user_settings_user_id_users_id_fk", + "tableFrom": "user_settings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.space_settings": { + "name": "space_settings", + "schema": "", + "columns": { + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "space_settings_space_id_spaces_id_fk": { + "name": "space_settings_space_id_spaces_id_fk", + "tableFrom": "space_settings", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grid_presence": { + "name": "grid_presence", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "room_id": { + "name": "room_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_session_id": { + "name": "owner_session_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "microphone_enabled": { + "name": "microphone_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "microphone_revision": { + "name": "microphone_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "media_membership_id": { + "name": "media_membership_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "call_id": { + "name": "call_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "grid_presence_room_id_idx": { + "name": "grid_presence_room_id_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "grid_presence_owner_session_id_idx": { + "name": "grid_presence_owner_session_id_idx", + "columns": [ + { + "expression": "owner_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "grid_presence_lease_expires_at_idx": { + "name": "grid_presence_lease_expires_at_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "grid_presence_user_id_users_id_fk": { + "name": "grid_presence_user_id_users_id_fk", + "tableFrom": "grid_presence", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grid_presence_room_id_grid_rooms_id_fk": { + "name": "grid_presence_room_id_grid_rooms_id_fk", + "tableFrom": "grid_presence", + "tableTo": "grid_rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grid_presence_owner_session_id_sessions_id_fk": { + "name": "grid_presence_owner_session_id_sessions_id_fk", + "tableFrom": "grid_presence", + "tableTo": "sessions", + "columnsFrom": [ + "owner_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "grid_presence_microphone_revision_check": { + "name": "grid_presence_microphone_revision_check", + "value": "\"grid_presence\".\"microphone_revision\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.grid_provider_effects": { + "name": "grid_provider_effects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "deduplication_key": { + "name": "deduplication_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "room_id": { + "name": "room_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "connection_generation": { + "name": "connection_generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_target": { + "name": "provider_target", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "participant_identity": { + "name": "participant_identity", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "available_at": { + "name": "available_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "claim_token": { + "name": "claim_token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "grid_provider_effects_deduplication_key_unique": { + "name": "grid_provider_effects_deduplication_key_unique", + "columns": [ + { + "expression": "deduplication_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "grid_provider_effects_ready_idx": { + "name": "grid_provider_effects_ready_idx", + "columns": [ + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "grid_provider_effects_kind_check": { + "name": "grid_provider_effects_kind_check", + "value": "\"grid_provider_effects\".\"kind\" in ('close_connection', 'revoke_participant')" + }, + "grid_provider_effects_connection_generation_check": { + "name": "grid_provider_effects_connection_generation_check", + "value": "\"grid_provider_effects\".\"connection_generation\" >= 0" + }, + "grid_provider_effects_attempts_check": { + "name": "grid_provider_effects_attempts_check", + "value": "\"grid_provider_effects\".\"attempts\" >= 0" + }, + "grid_provider_effects_participant_check": { + "name": "grid_provider_effects_participant_check", + "value": "(\"grid_provider_effects\".\"kind\" = 'revoke_participant' and \"grid_provider_effects\".\"user_id\" is not null) or (\"grid_provider_effects\".\"kind\" = 'close_connection' and \"grid_provider_effects\".\"user_id\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.grid_rooms": { + "name": "grid_rooms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "room_thread_id": { + "name": "room_thread_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "connection_generation": { + "name": "connection_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "connection_started_at": { + "name": "connection_started_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "grid_rooms_space_id_idx": { + "name": "grid_rooms_space_id_idx", + "columns": [ + { + "expression": "space_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "grid_rooms_created_by_user_id_idx": { + "name": "grid_rooms_created_by_user_id_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "grid_rooms_space_title_unique": { + "name": "grid_rooms_space_title_unique", + "columns": [ + { + "expression": "space_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"title\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"grid_rooms\".\"title\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "grid_rooms_space_id_spaces_id_fk": { + "name": "grid_rooms_space_id_spaces_id_fk", + "tableFrom": "grid_rooms", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grid_rooms_created_by_user_id_users_id_fk": { + "name": "grid_rooms_created_by_user_id_users_id_fk", + "tableFrom": "grid_rooms", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "grid_rooms_connection_generation_check": { + "name": "grid_rooms_connection_generation_check", + "value": "\"grid_rooms\".\"connection_generation\" >= 0" + }, + "grid_rooms_title_not_empty_check": { + "name": "grid_rooms_title_not_empty_check", + "value": "\"grid_rooms\".\"title\" is null or length(\"grid_rooms\".\"title\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.updates": { + "name": "updates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "updates_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "bucket": { + "name": "bucket", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "bytea", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "updates_bucket_idx": { + "name": "updates_bucket_idx", + "columns": [ + { + "expression": "bucket", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "updates_date_idx": { + "name": "updates_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "updates_unique": { + "name": "updates_unique", + "nullsNotDistinct": false, + "columns": [ + "bucket", + "entity_id", + "seq" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_tokens": { + "name": "oauth_access_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_access_tokens_grant_idx": { + "name": "oauth_access_tokens_grant_idx", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_tokens_expiry_idx": { + "name": "oauth_access_tokens_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_tokens_grant_id_oauth_grants_id_fk": { + "name": "oauth_access_tokens_grant_id_oauth_grants_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_auth_codes": { + "name": "oauth_auth_codes", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_auth_codes_expiry_idx": { + "name": "oauth_auth_codes_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_auth_codes_grant_idx": { + "name": "oauth_auth_codes_grant_idx", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_auth_codes_used_idx": { + "name": "oauth_auth_codes_used_idx", + "columns": [ + { + "expression": "used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_auth_codes_grant_id_oauth_grants_id_fk": { + "name": "oauth_auth_codes_grant_id_oauth_grants_id_fk", + "tableFrom": "oauth_auth_codes", + "tableTo": "oauth_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "oauth_auth_codes_client_id_oauth_clients_client_id_fk": { + "name": "oauth_auth_codes_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_auth_codes", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_auth_requests": { + "name": "oauth_auth_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'https://mcp.inline.chat'" + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "csrf_token": { + "name": "csrf_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "phone_number": { + "name": "phone_number", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false + }, + "challenge_token": { + "name": "challenge_token", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "inline_user_id": { + "name": "inline_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auth_method": { + "name": "auth_method", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "inline_token_encrypted": { + "name": "inline_token_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_auth_requests_expiry_idx": { + "name": "oauth_auth_requests_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_auth_requests_client_idx": { + "name": "oauth_auth_requests_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_auth_requests_challenge_unique": { + "name": "oauth_auth_requests_challenge_unique", + "columns": [ + { + "expression": "challenge_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_auth_requests_client_id_oauth_clients_client_id_fk": { + "name": "oauth_auth_requests_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_auth_requests", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "oauth_auth_requests_inline_user_id_users_id_fk": { + "name": "oauth_auth_requests_inline_user_id_users_id_fk", + "tableFrom": "oauth_auth_requests", + "tableTo": "users", + "columnsFrom": [ + "inline_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_clients": { + "name": "oauth_clients", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "varchar(128)", + "primaryKey": true, + "notNull": true + }, + "redirect_uris_json": { + "name": "redirect_uris_json", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_clients_date_idx": { + "name": "oauth_clients_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_grants": { + "name": "oauth_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "inline_user_id": { + "name": "inline_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'https://mcp.inline.chat'" + }, + "space_ids_json": { + "name": "space_ids_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "allow_dms": { + "name": "allow_dms", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allow_home_threads": { + "name": "allow_home_threads", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inline_token_encrypted": { + "name": "inline_token_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_grants_client_idx": { + "name": "oauth_grants_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_grants_inline_user_idx": { + "name": "oauth_grants_inline_user_idx", + "columns": [ + { + "expression": "inline_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_grants_revoked_idx": { + "name": "oauth_grants_revoked_idx", + "columns": [ + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_grants_client_id_oauth_clients_client_id_fk": { + "name": "oauth_grants_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_grants", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "oauth_grants_inline_user_id_users_id_fk": { + "name": "oauth_grants_inline_user_id_users_id_fk", + "tableFrom": "oauth_grants", + "tableTo": "users", + "columnsFrom": [ + "inline_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_tokens": { + "name": "oauth_refresh_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "replaced_by_hash": { + "name": "replaced_by_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_refresh_tokens_grant_idx": { + "name": "oauth_refresh_tokens_grant_idx", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_tokens_expiry_idx": { + "name": "oauth_refresh_tokens_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_tokens_revoked_idx": { + "name": "oauth_refresh_tokens_revoked_idx", + "columns": [ + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_tokens_grant_id_oauth_grants_id_fk": { + "name": "oauth_refresh_tokens_grant_id_oauth_grants_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "oauth_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_event_subscriptions": { + "name": "mcp_event_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(80)", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "selector": { + "name": "selector", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "callback_url": { + "name": "callback_url", + "type": "varchar(4096)", + "primaryKey": false, + "notNull": true + }, + "secret_encrypted": { + "name": "secret_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "previous_secret_encrypted": { + "name": "previous_secret_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "previous_secret_until": { + "name": "previous_secret_until", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "cursor_seq": { + "name": "cursor_seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "gap_seq": { + "name": "gap_seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pending_encrypted": { + "name": "pending_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "pending_seq": { + "name": "pending_seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lease_owner": { + "name": "lease_owner", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stopped": { + "name": "stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_events_due_idx": { + "name": "mcp_events_due_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_event_subscriptions\".\"stopped\" = false AND \"mcp_event_subscriptions\".\"gap_seq\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_events_grant_idx": { + "name": "mcp_events_grant_idx", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_events_expiry_idx": { + "name": "mcp_events_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_event_subscriptions_grant_id_oauth_grants_id_fk": { + "name": "mcp_event_subscriptions_grant_id_oauth_grants_id_fk", + "tableFrom": "mcp_event_subscriptions", + "tableTo": "oauth_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_events_cursor_nonnegative": { + "name": "mcp_events_cursor_nonnegative", + "value": "\"mcp_event_subscriptions\".\"cursor_seq\" >= 0 AND (\"mcp_event_subscriptions\".\"gap_seq\" IS NULL OR \"mcp_event_subscriptions\".\"gap_seq\" >= \"mcp_event_subscriptions\".\"cursor_seq\")" + }, + "mcp_events_pending_pair": { + "name": "mcp_events_pending_pair", + "value": "(\"mcp_event_subscriptions\".\"pending_encrypted\" IS NULL) = (\"mcp_event_subscriptions\".\"pending_seq\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.account_identities": { + "name": "account_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "account_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "subject_hash": { + "name": "subject_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_identities_provider_subject_unique": { + "name": "account_identities_provider_subject_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_identities_user_idx": { + "name": "account_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_identities_user_id_users_id_fk": { + "name": "account_identities_user_id_users_id_fk", + "tableFrom": "account_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_auth_attempts": { + "name": "provider_auth_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "account_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "provider_auth_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "provider_auth_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending_provider'" + }, + "state_hash": { + "name": "state_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "nonce_hash": { + "name": "nonce_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "nonce_encrypted": { + "name": "nonce_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier_encrypted": { + "name": "pkce_verifier_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "continuation_hash": { + "name": "continuation_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "subject_hash": { + "name": "subject_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "pending_profile_encrypted": { + "name": "pending_profile_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "confirmation_email": { + "name": "confirmation_email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "challenge_token": { + "name": "challenge_token", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "app_callback_scheme": { + "name": "app_callback_scheme", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "app_code_challenge": { + "name": "app_code_challenge", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "oauth_auth_request_id": { + "name": "oauth_auth_request_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "login_transaction_id": { + "name": "login_transaction_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "client": { + "name": "client", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "inline_user_id": { + "name": "inline_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "inline_token_encrypted": { + "name": "inline_token_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "ticket_hash": { + "name": "ticket_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "provider_auth_attempts_state_unique": { + "name": "provider_auth_attempts_state_unique", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_auth_attempts_ticket_unique": { + "name": "provider_auth_attempts_ticket_unique", + "columns": [ + { + "expression": "ticket_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_auth_attempts_expiry_idx": { + "name": "provider_auth_attempts_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_auth_attempts_oauth_request_idx": { + "name": "provider_auth_attempts_oauth_request_idx", + "columns": [ + { + "expression": "oauth_auth_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_auth_attempts_inline_user_idx": { + "name": "provider_auth_attempts_inline_user_idx", + "columns": [ + { + "expression": "inline_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_auth_attempts_login_transaction_idx": { + "name": "provider_auth_attempts_login_transaction_idx", + "columns": [ + { + "expression": "login_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_auth_attempts_inline_user_id_users_id_fk": { + "name": "provider_auth_attempts_inline_user_id_users_id_fk", + "tableFrom": "provider_auth_attempts", + "tableTo": "users", + "columnsFrom": [ + "inline_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "provider_auth_attempts_oauth_request_fk": { + "name": "provider_auth_attempts_oauth_request_fk", + "tableFrom": "provider_auth_attempts", + "tableTo": "oauth_auth_requests", + "columnsFrom": [ + "oauth_auth_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "provider_auth_attempts_login_transaction_fk": { + "name": "provider_auth_attempts_login_transaction_fk", + "tableFrom": "provider_auth_attempts", + "tableTo": "login_transactions", + "columnsFrom": [ + "login_transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.login_transactions": { + "name": "login_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": true, + "notNull": true + }, + "capability_hash": { + "name": "capability_hash", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "browser_csrf_hash": { + "name": "browser_csrf_hash", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "target_kind": { + "name": "target_kind", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "inline_protocol_auth_key_id": { + "name": "inline_protocol_auth_key_id", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "oauth_auth_request_id": { + "name": "oauth_auth_request_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "native_app_auth_request_id": { + "name": "native_app_auth_request_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "verification_code": { + "name": "verification_code", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true + }, + "pending_identifier": { + "name": "pending_identifier", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false + }, + "challenge_token": { + "name": "challenge_token", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "client": { + "name": "client", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auth_method": { + "name": "auth_method", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "login_transactions_capability_unique": { + "name": "login_transactions_capability_unique", + "columns": [ + { + "expression": "capability_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "login_transactions_expiry_idx": { + "name": "login_transactions_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "login_transactions_user_idx": { + "name": "login_transactions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "login_transactions_inline_protocol_key_unique": { + "name": "login_transactions_inline_protocol_key_unique", + "columns": [ + { + "expression": "inline_protocol_auth_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"login_transactions\".\"status\" in ('pending', 'claimed')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "login_transactions_oauth_request_unique": { + "name": "login_transactions_oauth_request_unique", + "columns": [ + { + "expression": "oauth_auth_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"login_transactions\".\"status\" in ('pending', 'claimed')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "login_transactions_native_app_request_unique": { + "name": "login_transactions_native_app_request_unique", + "columns": [ + { + "expression": "native_app_auth_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"login_transactions\".\"status\" in ('pending', 'claimed')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "login_transactions_inline_protocol_auth_key_id_inline_protocol_auth_keys_auth_key_id_fk": { + "name": "login_transactions_inline_protocol_auth_key_id_inline_protocol_auth_keys_auth_key_id_fk", + "tableFrom": "login_transactions", + "tableTo": "inline_protocol_auth_keys", + "columnsFrom": [ + "inline_protocol_auth_key_id" + ], + "columnsTo": [ + "auth_key_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "login_transactions_oauth_auth_request_id_oauth_auth_requests_id_fk": { + "name": "login_transactions_oauth_auth_request_id_oauth_auth_requests_id_fk", + "tableFrom": "login_transactions", + "tableTo": "oauth_auth_requests", + "columnsFrom": [ + "oauth_auth_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "login_transactions_native_app_auth_request_id_native_app_auth_requests_id_fk": { + "name": "login_transactions_native_app_auth_request_id_native_app_auth_requests_id_fk", + "tableFrom": "login_transactions", + "tableTo": "native_app_auth_requests", + "columnsFrom": [ + "native_app_auth_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "login_transactions_user_id_users_id_fk": { + "name": "login_transactions_user_id_users_id_fk", + "tableFrom": "login_transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "login_transactions_capability_hash_length": { + "name": "login_transactions_capability_hash_length", + "value": "octet_length(\"login_transactions\".\"capability_hash\") = 32" + }, + "login_transactions_browser_csrf_hash_length": { + "name": "login_transactions_browser_csrf_hash_length", + "value": "\"login_transactions\".\"browser_csrf_hash\" is null or octet_length(\"login_transactions\".\"browser_csrf_hash\") = 32" + }, + "login_transactions_status_valid": { + "name": "login_transactions_status_valid", + "value": "\"login_transactions\".\"status\" in ('pending', 'claimed', 'complete', 'cancelled')" + }, + "login_transactions_method_valid": { + "name": "login_transactions_method_valid", + "value": "\"login_transactions\".\"auth_method\" is null or \"login_transactions\".\"auth_method\" in ('email', 'phone', 'google', 'apple')" + }, + "login_transactions_typed_target": { + "name": "login_transactions_typed_target", + "value": "(\n (\"login_transactions\".\"target_kind\" = 'inline_protocol_key')::int +\n (\"login_transactions\".\"target_kind\" = 'oauth_authorization')::int +\n (\"login_transactions\".\"target_kind\" = 'native_app')::int\n ) = 1 and\n (\"login_transactions\".\"inline_protocol_auth_key_id\" is not null)::int +\n (\"login_transactions\".\"oauth_auth_request_id\" is not null)::int +\n (\"login_transactions\".\"native_app_auth_request_id\" is not null)::int = 1 and\n (\"login_transactions\".\"target_kind\" = 'inline_protocol_key') = (\"login_transactions\".\"inline_protocol_auth_key_id\" is not null) and\n (\"login_transactions\".\"target_kind\" = 'oauth_authorization') = (\"login_transactions\".\"oauth_auth_request_id\" is not null) and\n (\"login_transactions\".\"target_kind\" = 'native_app') = (\"login_transactions\".\"native_app_auth_request_id\" is not null)\n " + } + }, + "isRLSEnabled": false + }, + "public.native_app_auth_requests": { + "name": "native_app_auth_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": true, + "notNull": true + }, + "callback_scheme": { + "name": "callback_scheme", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "code_challenge": { + "name": "code_challenge", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "client": { + "name": "client", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auth_method": { + "name": "auth_method", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "ticket_hash": { + "name": "ticket_hash", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "proven_at": { + "name": "proven_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "native_app_auth_requests_expiry_idx": { + "name": "native_app_auth_requests_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "native_app_auth_requests_ticket_unique": { + "name": "native_app_auth_requests_ticket_unique", + "columns": [ + { + "expression": "ticket_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "native_app_auth_requests_user_id_users_id_fk": { + "name": "native_app_auth_requests_user_id_users_id_fk", + "tableFrom": "native_app_auth_requests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "native_app_auth_requests_method_valid": { + "name": "native_app_auth_requests_method_valid", + "value": "\"native_app_auth_requests\".\"auth_method\" is null or \"native_app_auth_requests\".\"auth_method\" in ('email', 'phone', 'google', 'apple')" + } + }, + "isRLSEnabled": false + }, + "public.email_campaign_recipients": { + "name": "email_campaign_recipients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "campaign_id": { + "name": "campaign_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "email_key": { + "name": "email_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "email_encrypted": { + "name": "email_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "name_encrypted": { + "name": "name_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "unsubscribe_token_hash": { + "name": "unsubscribe_token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "unsubscribe_token_encrypted": { + "name": "unsubscribe_token_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "sources": { + "name": "sources", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "contacted_at": { + "name": "contacted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_campaign_recipients_campaign_email_unique": { + "name": "email_campaign_recipients_campaign_email_unique", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_campaign_recipients_unsubscribe_token_unique": { + "name": "email_campaign_recipients_unsubscribe_token_unique", + "columns": [ + { + "expression": "unsubscribe_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_campaign_recipients_campaign_status_idx": { + "name": "email_campaign_recipients_campaign_status_idx", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_campaign_recipients_email_key_idx": { + "name": "email_campaign_recipients_email_key_idx", + "columns": [ + { + "expression": "email_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_campaign_recipients_campaign_id_email_campaigns_id_fk": { + "name": "email_campaign_recipients_campaign_id_email_campaigns_id_fk", + "tableFrom": "email_campaign_recipients", + "tableTo": "email_campaigns", + "columnsFrom": [ + "campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_campaign_recipients_status_check": { + "name": "email_campaign_recipients_status_check", + "value": "\"email_campaign_recipients\".\"status\" in ('pending', 'provider_synced', 'sending', 'provider_accepted', 'unknown', 'suppressed')" + } + }, + "isRLSEnabled": false + }, + "public.email_campaigns": { + "name": "email_campaigns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "series_key": { + "name": "series_key", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "varchar(240)", + "primaryKey": false, + "notNull": true + }, + "preview_text": { + "name": "preview_text", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "from_address": { + "name": "from_address", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true, + "default": "'team@inline.chat'" + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'frozen'" + }, + "recipient_count": { + "name": "recipient_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider_segment_id": { + "name": "provider_segment_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "provider_campaign_id": { + "name": "provider_campaign_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "visible_unsubscribe": { + "name": "visible_unsubscribe", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "unsubscribe_override_reason": { + "name": "unsubscribe_override_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "test_sent_at": { + "name": "test_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "frozen_at": { + "name": "frozen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "email_campaigns_name_unique": { + "name": "email_campaigns_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_campaigns_created_at_idx": { + "name": "email_campaigns_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_campaigns_created_by_user_id_users_id_fk": { + "name": "email_campaigns_created_by_user_id_users_id_fk", + "tableFrom": "email_campaigns", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_campaigns_provider_check": { + "name": "email_campaigns_provider_check", + "value": "\"email_campaigns\".\"provider\" is null or \"email_campaigns\".\"provider\" in ('resend', 'ses')" + }, + "email_campaigns_from_address_check": { + "name": "email_campaigns_from_address_check", + "value": "\"email_campaigns\".\"from_address\" in ('team@inline.chat', 'founders@inline.chat', 'mo@inline.chat')" + }, + "email_campaigns_status_check": { + "name": "email_campaigns_status_check", + "value": "\"email_campaigns\".\"status\" in ('frozen', 'sending', 'paused', 'completed')" + } + }, + "isRLSEnabled": false + }, + "public.email_suppressions": { + "name": "email_suppressions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email_key": { + "name": "email_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "email_encrypted": { + "name": "email_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_suppressions_email_key_unique": { + "name": "email_suppressions_email_key_unique", + "columns": [ + { + "expression": "email_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_suppressions_reason_check": { + "name": "email_suppressions_reason_check", + "value": "\"email_suppressions\".\"reason\" in ('unsubscribe', 'manual', 'bounce', 'complaint', 'invalid')" + } + }, + "isRLSEnabled": false + }, + "public.server_config": { + "name": "server_config", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(160)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "server_config_updated_by_user_id_users_id_fk": { + "name": "server_config_updated_by_user_id_users_id_fk", + "tableFrom": "server_config", + "tableTo": "users", + "columnsFrom": [ + "updated_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "server_config_version_check": { + "name": "server_config_version_check", + "value": "\"server_config\".\"version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.reserved_usernames": { + "name": "reserved_usernames", + "schema": "", + "columns": { + "username": { + "name": "username", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inline_protocol_auth_challenges": { + "name": "inline_protocol_auth_challenges", + "schema": "", + "columns": { + "challenge_id": { + "name": "challenge_id", + "type": "bytea", + "primaryKey": true, + "notNull": true + }, + "auth_key_id": { + "name": "auth_key_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "identifier_encrypted": { + "name": "identifier_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "identifier_hash": { + "name": "identifier_hash", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "code_mac": { + "name": "code_mac", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "pepper_key_id": { + "name": "pepper_key_id", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "delivery": { + "name": "delivery", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "client": { + "name": "client", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "network_hash": { + "name": "network_hash", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "device_hash": { + "name": "device_hash", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inline_protocol_auth_challenges_key_created_idx": { + "name": "inline_protocol_auth_challenges_key_created_idx", + "columns": [ + { + "expression": "auth_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inline_protocol_auth_challenges_identifier_created_idx": { + "name": "inline_protocol_auth_challenges_identifier_created_idx", + "columns": [ + { + "expression": "identifier_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inline_protocol_auth_challenges_expiry_idx": { + "name": "inline_protocol_auth_challenges_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inline_protocol_auth_challenges_auth_key_id_inline_protocol_auth_keys_auth_key_id_fk": { + "name": "inline_protocol_auth_challenges_auth_key_id_inline_protocol_auth_keys_auth_key_id_fk", + "tableFrom": "inline_protocol_auth_challenges", + "tableTo": "inline_protocol_auth_keys", + "columnsFrom": [ + "auth_key_id" + ], + "columnsTo": [ + "auth_key_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "inline_protocol_auth_challenges_id_length": { + "name": "inline_protocol_auth_challenges_id_length", + "value": "octet_length(\"inline_protocol_auth_challenges\".\"challenge_id\") = 32" + }, + "inline_protocol_auth_challenges_key_id_length": { + "name": "inline_protocol_auth_challenges_key_id_length", + "value": "octet_length(\"inline_protocol_auth_challenges\".\"auth_key_id\") = 8" + }, + "inline_protocol_auth_challenges_identifier_hash_length": { + "name": "inline_protocol_auth_challenges_identifier_hash_length", + "value": "octet_length(\"inline_protocol_auth_challenges\".\"identifier_hash\") = 32" + }, + "inline_protocol_auth_challenges_code_mac_length": { + "name": "inline_protocol_auth_challenges_code_mac_length", + "value": "octet_length(\"inline_protocol_auth_challenges\".\"code_mac\") = 32" + } + }, + "isRLSEnabled": false + }, + "public.inline_protocol_auth_keys": { + "name": "inline_protocol_auth_keys", + "schema": "", + "columns": { + "auth_key_id": { + "name": "auth_key_id", + "type": "bytea", + "primaryKey": true, + "notNull": true + }, + "auth_key_encrypted": { + "name": "auth_key_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "key_encryption_key_id": { + "name": "key_encryption_key_id", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "current_server_salt": { + "name": "current_server_salt", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "previous_server_salt": { + "name": "previous_server_salt", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "server_salt_updated_at": { + "name": "server_salt_updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "account_session_id": { + "name": "account_session_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "authorized_at": { + "name": "authorized_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inline_protocol_auth_keys_user_idx": { + "name": "inline_protocol_auth_keys_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inline_protocol_auth_keys_session_idx": { + "name": "inline_protocol_auth_keys_session_idx", + "columns": [ + { + "expression": "account_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inline_protocol_auth_keys_expiry_idx": { + "name": "inline_protocol_auth_keys_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inline_protocol_auth_keys_user_id_users_id_fk": { + "name": "inline_protocol_auth_keys_user_id_users_id_fk", + "tableFrom": "inline_protocol_auth_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "inline_protocol_auth_keys_account_session_id_sessions_id_fk": { + "name": "inline_protocol_auth_keys_account_session_id_sessions_id_fk", + "tableFrom": "inline_protocol_auth_keys", + "tableTo": "sessions", + "columnsFrom": [ + "account_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "inline_protocol_auth_keys_id_length": { + "name": "inline_protocol_auth_keys_id_length", + "value": "octet_length(\"inline_protocol_auth_keys\".\"auth_key_id\") = 8" + }, + "inline_protocol_auth_keys_encrypted_length": { + "name": "inline_protocol_auth_keys_encrypted_length", + "value": "octet_length(\"inline_protocol_auth_keys\".\"auth_key_encrypted\") = 284" + } + }, + "isRLSEnabled": false + }, + "public.inline_protocol_requests": { + "name": "inline_protocol_requests", + "schema": "", + "columns": { + "auth_key_id": { + "name": "auth_key_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "protocol_session_id": { + "name": "protocol_session_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "request_digest": { + "name": "request_digest", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "result_body": { + "name": "result_body", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "result_format": { + "name": "result_format", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "inline_protocol_requests_expiry_idx": { + "name": "inline_protocol_requests_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "inline_protocol_requests_pk": { + "name": "inline_protocol_requests_pk", + "columns": [ + "auth_key_id", + "protocol_session_id", + "message_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "inline_protocol_requests_key_id_length": { + "name": "inline_protocol_requests_key_id_length", + "value": "octet_length(\"inline_protocol_requests\".\"auth_key_id\") = 8" + }, + "inline_protocol_requests_digest_length": { + "name": "inline_protocol_requests_digest_length", + "value": "octet_length(\"inline_protocol_requests\".\"request_digest\") = 32" + }, + "inline_protocol_requests_result_length": { + "name": "inline_protocol_requests_result_length", + "value": "\"inline_protocol_requests\".\"result_body\" is null or octet_length(\"inline_protocol_requests\".\"result_body\") <= 16777278" + }, + "inline_protocol_requests_result_format": { + "name": "inline_protocol_requests_result_format", + "value": "(\"inline_protocol_requests\".\"result_format\" = 0 and (\"inline_protocol_requests\".\"result_body\" is null or octet_length(\"inline_protocol_requests\".\"result_body\") <= 16777216)) or (\"inline_protocol_requests\".\"result_format\" = 1 and \"inline_protocol_requests\".\"result_body\" is not null and octet_length(\"inline_protocol_requests\".\"result_body\") >= 31)" + } + }, + "isRLSEnabled": false + }, + "public.inline_protocol_uploads": { + "name": "inline_protocol_uploads", + "schema": "", + "columns": { + "upload_id": { + "name": "upload_id", + "type": "bytea", + "primaryKey": true, + "notNull": true + }, + "capability_hash": { + "name": "capability_hash", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "permanent_auth_key_id": { + "name": "permanent_auth_key_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "issuing_temporary_auth_key_id": { + "name": "issuing_temporary_auth_key_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "account_session_id": { + "name": "account_session_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "byte_count": { + "name": "byte_count", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "lock_token": { + "name": "lock_token", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "file_unique_id": { + "name": "file_unique_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inline_protocol_uploads_capability_unique": { + "name": "inline_protocol_uploads_capability_unique", + "columns": [ + { + "expression": "capability_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inline_protocol_uploads_session_idx": { + "name": "inline_protocol_uploads_session_idx", + "columns": [ + { + "expression": "account_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inline_protocol_uploads_expiry_idx": { + "name": "inline_protocol_uploads_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inline_protocol_uploads_permanent_auth_key_id_inline_protocol_auth_keys_auth_key_id_fk": { + "name": "inline_protocol_uploads_permanent_auth_key_id_inline_protocol_auth_keys_auth_key_id_fk", + "tableFrom": "inline_protocol_uploads", + "tableTo": "inline_protocol_auth_keys", + "columnsFrom": [ + "permanent_auth_key_id" + ], + "columnsTo": [ + "auth_key_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inline_protocol_uploads_user_id_users_id_fk": { + "name": "inline_protocol_uploads_user_id_users_id_fk", + "tableFrom": "inline_protocol_uploads", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inline_protocol_uploads_account_session_id_sessions_id_fk": { + "name": "inline_protocol_uploads_account_session_id_sessions_id_fk", + "tableFrom": "inline_protocol_uploads", + "tableTo": "sessions", + "columnsFrom": [ + "account_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "inline_protocol_uploads_id_length": { + "name": "inline_protocol_uploads_id_length", + "value": "octet_length(\"inline_protocol_uploads\".\"upload_id\") = 16" + }, + "inline_protocol_uploads_capability_hash_length": { + "name": "inline_protocol_uploads_capability_hash_length", + "value": "octet_length(\"inline_protocol_uploads\".\"capability_hash\") = 32" + }, + "inline_protocol_uploads_permanent_key_length": { + "name": "inline_protocol_uploads_permanent_key_length", + "value": "octet_length(\"inline_protocol_uploads\".\"permanent_auth_key_id\") = 8" + }, + "inline_protocol_uploads_temporary_key_length": { + "name": "inline_protocol_uploads_temporary_key_length", + "value": "octet_length(\"inline_protocol_uploads\".\"issuing_temporary_auth_key_id\") = 8" + }, + "inline_protocol_uploads_sha_length": { + "name": "inline_protocol_uploads_sha_length", + "value": "octet_length(\"inline_protocol_uploads\".\"sha256\") = 32" + }, + "inline_protocol_uploads_byte_count_positive": { + "name": "inline_protocol_uploads_byte_count_positive", + "value": "\"inline_protocol_uploads\".\"byte_count\" > 0" + }, + "inline_protocol_uploads_status_valid": { + "name": "inline_protocol_uploads_status_valid", + "value": "\"inline_protocol_uploads\".\"status\" in ('pending', 'uploading', 'complete', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.inline_upload_parts": { + "name": "inline_upload_parts", + "schema": "", + "columns": { + "upload_id": { + "name": "upload_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "part_index": { + "name": "part_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "byte_count": { + "name": "byte_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "stored_byte_count": { + "name": "stored_byte_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "stored_sha256": { + "name": "stored_sha256", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "inline_upload_parts_upload_id_inline_uploads_id_fk": { + "name": "inline_upload_parts_upload_id_inline_uploads_id_fk", + "tableFrom": "inline_upload_parts", + "tableTo": "inline_uploads", + "columnsFrom": [ + "upload_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "inline_upload_parts_pk": { + "name": "inline_upload_parts_pk", + "columns": [ + "upload_id", + "part_index" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "inline_upload_parts_index_valid": { + "name": "inline_upload_parts_index_valid", + "value": "\"inline_upload_parts\".\"part_index\" >= 0" + }, + "inline_upload_parts_byte_count_valid": { + "name": "inline_upload_parts_byte_count_valid", + "value": "\"inline_upload_parts\".\"byte_count\" between 1 and 524288" + }, + "inline_upload_parts_sha_length": { + "name": "inline_upload_parts_sha_length", + "value": "octet_length(\"inline_upload_parts\".\"sha256\") = 32" + }, + "inline_upload_parts_stored_byte_count_valid": { + "name": "inline_upload_parts_stored_byte_count_valid", + "value": "\"inline_upload_parts\".\"stored_byte_count\" > 0" + }, + "inline_upload_parts_stored_sha_length": { + "name": "inline_upload_parts_stored_sha_length", + "value": "octet_length(\"inline_upload_parts\".\"stored_sha256\") = 32" + } + }, + "isRLSEnabled": false + }, + "public.inline_upload_storage_parts": { + "name": "inline_upload_storage_parts", + "schema": "", + "columns": { + "upload_id": { + "name": "upload_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "storage_upload_id": { + "name": "storage_upload_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "part_number": { + "name": "part_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "stored_byte_count": { + "name": "stored_byte_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "stored_sha256": { + "name": "stored_sha256", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "inline_upload_storage_parts_upload_id_inline_uploads_id_fk": { + "name": "inline_upload_storage_parts_upload_id_inline_uploads_id_fk", + "tableFrom": "inline_upload_storage_parts", + "tableTo": "inline_uploads", + "columnsFrom": [ + "upload_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "inline_upload_storage_parts_pk": { + "name": "inline_upload_storage_parts_pk", + "columns": [ + "upload_id", + "part_number" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "inline_upload_storage_parts_number_valid": { + "name": "inline_upload_storage_parts_number_valid", + "value": "\"inline_upload_storage_parts\".\"part_number\" between 1 and 1000" + }, + "inline_upload_storage_parts_byte_count_valid": { + "name": "inline_upload_storage_parts_byte_count_valid", + "value": "\"inline_upload_storage_parts\".\"stored_byte_count\" > 0" + }, + "inline_upload_storage_parts_sha_length": { + "name": "inline_upload_storage_parts_sha_length", + "value": "octet_length(\"inline_upload_storage_parts\".\"stored_sha256\") = 32" + }, + "inline_upload_storage_parts_etag_valid": { + "name": "inline_upload_storage_parts_etag_valid", + "value": "length(\"inline_upload_storage_parts\".\"etag\") between 1 and 128" + } + }, + "isRLSEnabled": false + }, + "public.inline_uploads": { + "name": "inline_uploads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "inline_uploads_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "upload_id": { + "name": "upload_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "client_upload_id": { + "name": "client_upload_id", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "permanent_auth_key_id": { + "name": "permanent_auth_key_id", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "account_session_id": { + "name": "account_session_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "byte_count": { + "name": "byte_count", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "thumbnail_file_unique_id": { + "name": "thumbnail_file_unique_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "video_width": { + "name": "video_width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "video_height": { + "name": "video_height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_animated": { + "name": "is_animated", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_audio": { + "name": "has_audio", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "waveform": { + "name": "waveform", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "failure_code": { + "name": "failure_code", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "failure_retryable": { + "name": "failure_retryable", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "lock_token": { + "name": "lock_token", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "storage_format": { + "name": "storage_format", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "storage_upload_id": { + "name": "storage_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retry_at": { + "name": "retry_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "result_file_unique_id": { + "name": "result_file_unique_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "result_media_id": { + "name": "result_media_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_part_at": { + "name": "last_part_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "hard_expires_at": { + "name": "hard_expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inline_uploads_upload_id_unique": { + "name": "inline_uploads_upload_id_unique", + "columns": [ + { + "expression": "upload_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inline_uploads_session_client_id_unique": { + "name": "inline_uploads_session_client_id_unique", + "columns": [ + { + "expression": "account_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_upload_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inline_uploads_permanent_key_idx": { + "name": "inline_uploads_permanent_key_idx", + "columns": [ + { + "expression": "permanent_auth_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inline_uploads_user_status_idx": { + "name": "inline_uploads_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inline_uploads_session_created_idx": { + "name": "inline_uploads_session_created_idx", + "columns": [ + { + "expression": "account_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inline_uploads_expiry_idx": { + "name": "inline_uploads_expiry_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inline_uploads_processing_retry_idx": { + "name": "inline_uploads_processing_retry_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "retry_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inline_uploads_permanent_auth_key_id_inline_protocol_auth_keys_auth_key_id_fk": { + "name": "inline_uploads_permanent_auth_key_id_inline_protocol_auth_keys_auth_key_id_fk", + "tableFrom": "inline_uploads", + "tableTo": "inline_protocol_auth_keys", + "columnsFrom": [ + "permanent_auth_key_id" + ], + "columnsTo": [ + "auth_key_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inline_uploads_user_id_users_id_fk": { + "name": "inline_uploads_user_id_users_id_fk", + "tableFrom": "inline_uploads", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inline_uploads_account_session_id_sessions_id_fk": { + "name": "inline_uploads_account_session_id_sessions_id_fk", + "tableFrom": "inline_uploads", + "tableTo": "sessions", + "columnsFrom": [ + "account_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "inline_uploads_id_length": { + "name": "inline_uploads_id_length", + "value": "octet_length(\"inline_uploads\".\"upload_id\") = 16" + }, + "inline_uploads_client_id_length": { + "name": "inline_uploads_client_id_length", + "value": "octet_length(\"inline_uploads\".\"client_upload_id\") = 16" + }, + "inline_uploads_permanent_key_length": { + "name": "inline_uploads_permanent_key_length", + "value": "octet_length(\"inline_uploads\".\"permanent_auth_key_id\") = 8" + }, + "inline_uploads_sha_length": { + "name": "inline_uploads_sha_length", + "value": "octet_length(\"inline_uploads\".\"sha256\") = 32" + }, + "inline_uploads_byte_count_positive": { + "name": "inline_uploads_byte_count_positive", + "value": "\"inline_uploads\".\"byte_count\" > 0" + }, + "inline_uploads_part_size_valid": { + "name": "inline_uploads_part_size_valid", + "value": "\"inline_uploads\".\"part_size\" = 524288" + }, + "inline_uploads_part_count_valid": { + "name": "inline_uploads_part_count_valid", + "value": "\"inline_uploads\".\"part_count\" between 1 and 1000" + }, + "inline_uploads_kind_valid": { + "name": "inline_uploads_kind_valid", + "value": "\"inline_uploads\".\"kind\" in ('photo', 'video', 'document', 'voice')" + }, + "inline_uploads_status_valid": { + "name": "inline_uploads_status_valid", + "value": "\"inline_uploads\".\"status\" in ('uploading', 'processing', 'complete', 'failed', 'canceled')" + }, + "inline_uploads_storage_format_valid": { + "name": "inline_uploads_storage_format_valid", + "value": "\"inline_uploads\".\"storage_format\" is null or \"inline_uploads\".\"storage_format\" = 'identity_v1'" + }, + "inline_uploads_storage_session_valid": { + "name": "inline_uploads_storage_session_valid", + "value": "\"inline_uploads\".\"storage_upload_id\" is null or \"inline_uploads\".\"storage_format\" is not null" + }, + "inline_uploads_attempts_valid": { + "name": "inline_uploads_attempts_valid", + "value": "\"inline_uploads\".\"attempts\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.agent_session_messages": { + "name": "agent_session_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "agent_session_id": { + "name": "agent_session_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "source_key_hash": { + "name": "source_key_hash", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "item_key_hash": { + "name": "item_key_hash", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "source_ref_encrypted": { + "name": "source_ref_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "revision_ref_encrypted": { + "name": "revision_ref_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "message_global_id": { + "name": "message_global_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "relation": { + "name": "relation", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "complete": { + "name": "complete", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_session_messages_source_unique": { + "name": "agent_session_messages_source_unique", + "columns": [ + { + "expression": "agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_session_messages_item_unique": { + "name": "agent_session_messages_item_unique", + "columns": [ + { + "expression": "agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_session_messages\".\"item_key_hash\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_session_messages_message_unique": { + "name": "agent_session_messages_message_unique", + "columns": [ + { + "expression": "agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_global_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_session_messages\".\"message_global_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_session_messages_session_idx": { + "name": "agent_session_messages_session_idx", + "columns": [ + { + "expression": "agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_session_messages_agent_session_id_agent_sessions_id_fk": { + "name": "agent_session_messages_agent_session_id_agent_sessions_id_fk", + "tableFrom": "agent_session_messages", + "tableTo": "agent_sessions", + "columnsFrom": [ + "agent_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_session_messages_message_global_id_messages_global_id_fk": { + "name": "agent_session_messages_message_global_id_messages_global_id_fk", + "tableFrom": "agent_session_messages", + "tableTo": "messages", + "columnsFrom": [ + "message_global_id" + ], + "columnsTo": [ + "global_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_sessions": { + "name": "agent_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "session_key_hash": { + "name": "session_key_hash", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "instance_ref_encrypted": { + "name": "instance_ref_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "session_ref_encrypted": { + "name": "session_ref_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "project_ref_encrypted": { + "name": "project_ref_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "status_message_global_id": { + "name": "status_message_global_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_sessions_external_unique": { + "name": "agent_sessions_external_unique", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "session_key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_sessions_chat_bot_unique": { + "name": "agent_sessions_chat_bot_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_sessions_owner_idx": { + "name": "agent_sessions_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_sessions_status_message_unique": { + "name": "agent_sessions_status_message_unique", + "columns": [ + { + "expression": "status_message_global_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_sessions\".\"status_message_global_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_sessions_chat_id_chats_id_fk": { + "name": "agent_sessions_chat_id_chats_id_fk", + "tableFrom": "agent_sessions", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_sessions_bot_user_id_users_id_fk": { + "name": "agent_sessions_bot_user_id_users_id_fk", + "tableFrom": "agent_sessions", + "tableTo": "users", + "columnsFrom": [ + "bot_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_sessions_owner_user_id_users_id_fk": { + "name": "agent_sessions_owner_user_id_users_id_fk", + "tableFrom": "agent_sessions", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_sessions_status_message_global_id_messages_global_id_fk": { + "name": "agent_sessions_status_message_global_id_messages_global_id_fk", + "tableFrom": "agent_sessions", + "tableTo": "messages", + "columnsFrom": [ + "status_message_global_id" + ], + "columnsTo": [ + "global_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_delivery_budgets": { + "name": "auth_delivery_budgets", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(96)", + "primaryKey": true, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "auth_delivery_budgets_expiry_idx": { + "name": "auth_delivery_budgets_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "auth_delivery_budgets_attempts_positive": { + "name": "auth_delivery_budgets_attempts_positive", + "value": "\"auth_delivery_budgets\".\"attempts\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.recent_realtime_buckets": { + "name": "recent_realtime_buckets", + "schema": "", + "columns": { + "bucket": { + "name": "bucket", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "recent_realtime_buckets_expiry_idx": { + "name": "recent_realtime_buckets_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "recent_realtime_buckets_bucket_entity_id_pk": { + "name": "recent_realtime_buckets_bucket_entity_id_pk", + "columns": [ + "bucket", + "entity_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "recent_realtime_buckets_bucket_valid": { + "name": "recent_realtime_buckets_bucket_valid", + "value": "\"recent_realtime_buckets\".\"bucket\" in (1, 2, 3)" + }, + "recent_realtime_buckets_entity_positive": { + "name": "recent_realtime_buckets_entity_positive", + "value": "\"recent_realtime_buckets\".\"entity_id\" > 0" + }, + "recent_realtime_buckets_seq_positive": { + "name": "recent_realtime_buckets_seq_positive", + "value": "\"recent_realtime_buckets\".\"seq\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.grid_transcription_runs": { + "name": "grid_transcription_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_room_id": { + "name": "source_room_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "room_chat_id": { + "name": "room_chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "transcript_chat_id": { + "name": "transcript_chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "destination_parent_chat_id": { + "name": "destination_parent_chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "original_anchor_id": { + "name": "original_anchor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "room_link_message_id": { + "name": "room_link_message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_target": { + "name": "provider_target", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "claim_epoch": { + "name": "claim_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "worker_id": { + "name": "worker_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "stop_requested_at": { + "name": "stop_requested_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "interruption_reason": { + "name": "interruption_reason", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "grid_transcription_request_unique": { + "name": "grid_transcription_request_unique", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "grid_transcription_active_room_unique": { + "name": "grid_transcription_active_room_unique", + "columns": [ + { + "expression": "source_room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"grid_transcription_runs\".\"state\" in ('starting','active','stopping')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "grid_transcription_active_destination_unique": { + "name": "grid_transcription_active_destination_unique", + "columns": [ + { + "expression": "transcript_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"grid_transcription_runs\".\"state\" in ('starting','active','stopping')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "grid_transcription_space_recent_idx": { + "name": "grid_transcription_space_recent_idx", + "columns": [ + { + "expression": "space_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "grid_transcription_source_room_idx": { + "name": "grid_transcription_source_room_idx", + "columns": [ + { + "expression": "source_room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "grid_transcription_state_check": { + "name": "grid_transcription_state_check", + "value": "\"grid_transcription_runs\".\"state\" in ('starting','active','stopping','stopped','interrupted')" + } + }, + "isRLSEnabled": false + }, + "public.grid_transcription_worker": { + "name": "grid_transcription_worker", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "worker_id": { + "name": "worker_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grid_transcription_segments": { + "name": "grid_transcription_segments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "claim_epoch": { + "name": "claim_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "speaker_user_id": { + "name": "speaker_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "membership_id": { + "name": "membership_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "track_sid": { + "name": "track_sid", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "source_turn_key": { + "name": "source_turn_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'admitted'" + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "admitted_at": { + "name": "admitted_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "grid_transcription_segment_source_unique": { + "name": "grid_transcription_segment_source_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "track_sid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_turn_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "grid_transcription_segments_run_id_grid_transcription_runs_id_fk": { + "name": "grid_transcription_segments_run_id_grid_transcription_runs_id_fk", + "tableFrom": "grid_transcription_segments", + "tableTo": "grid_transcription_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "grid_transcription_segment_state_check": { + "name": "grid_transcription_segment_state_check", + "value": "\"grid_transcription_segments\".\"state\" in ('admitted','finalized','discarded')" + } + }, + "isRLSEnabled": false + }, + "public.grid_transcription_space_revisions": { + "name": "grid_transcription_space_revisions", + "schema": "", + "columns": { + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.client_type": { + "name": "client_type", + "schema": "public", + "values": [ + "ios", + "macos", + "web", + "api", + "android", + "windows", + "linux", + "cli" + ] + }, + "public.member_roles": { + "name": "member_roles", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + }, + "public.chat_types": { + "name": "chat_types", + "schema": "public", + "values": [ + "private", + "thread" + ] + }, + "public.thread_graph_link_kind": { + "name": "thread_graph_link_kind", + "schema": "public", + "values": [ + "thread_link", + "reply_thread" + ] + }, + "public.thread_graph_scope_type": { + "name": "thread_graph_scope_type", + "schema": "public", + "values": [ + "user", + "space" + ] + }, + "public.account_provider": { + "name": "account_provider", + "schema": "public", + "values": [ + "google", + "apple" + ] + }, + "public.provider_auth_purpose": { + "name": "provider_auth_purpose", + "schema": "public", + "values": [ + "app", + "mcp_oauth", + "hosted_login" + ] + }, + "public.provider_auth_status": { + "name": "provider_auth_status", + "schema": "public", + "values": [ + "pending_provider", + "pending_invite", + "pending_email", + "complete", + "used" + ] + } + }, + "schemas": {}, + "sequences": { + "public.user_id": { + "name": "user_id", + "schema": "public", + "increment": "3", + "startWith": "1000", + "minValue": "1000", + "maxValue": "9223372036854775807", + "cache": "100", + "cycle": false + } + }, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 7e3b202e1..d1cceba42 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -1079,6 +1079,20 @@ "when": 1790951215908, "tag": "0153_mcp-events", "breakpoints": true + }, + { + "idx": 154, + "version": "7", + "when": 1790958621324, + "tag": "0154_grid-call-identity", + "breakpoints": true + }, + { + "idx": 155, + "version": "7", + "when": 1791141418996, + "tag": "0155_grid-transcription", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/server/packages/grid-transcription/Dockerfile b/server/packages/grid-transcription/Dockerfile new file mode 100644 index 000000000..b8732197b --- /dev/null +++ b/server/packages/grid-transcription/Dockerfile @@ -0,0 +1,24 @@ +# Build from the repository root. The worker is separate from the Bun API. +FROM oven/bun:1.4.0 AS build +WORKDIR /app +COPY package.json bun.lock bunfig.toml ./ +COPY cli/package.json ./cli/package.json +COPY server ./server +COPY landing ./landing +COPY packages ./packages +COPY plugins ./plugins +COPY scripts ./scripts +RUN bun --no-env-file scripts/docker/prune-workspace.ts @inline-chat/grid-transcription out + +FROM node:24-bookworm-slim AS runtime +WORKDIR /app +COPY --from=build /app/out/json/ ./ +# Install with Bun using the exact workspace lock, but execute native RTC on Node. +COPY --from=build /usr/local/bin/bun /usr/local/bin/bun +RUN bun --no-env-file install --frozen-lockfile --filter @inline-chat/grid-transcription --linker hoisted +COPY --from=build /app/out/full/ ./ +RUN cd server/packages/grid-transcription && bun --no-env-file run build +ENV NODE_ENV=production +USER node +STOPSIGNAL SIGTERM +CMD ["node", "server/packages/grid-transcription/dist/daemon.js"] diff --git a/server/packages/grid-transcription/README.md b/server/packages/grid-transcription/README.md new file mode 100644 index 000000000..fa0afe0e3 --- /dev/null +++ b/server/packages/grid-transcription/README.md @@ -0,0 +1,65 @@ +# Grid transcription worker + +A separate Node process captures authorized LiveKit microphone tracks, segments each speaker's audio with Silero VAD, and posts finalized turns through the API's ordinary Grid transcription thread. The Bun API does not import the native RTC runtime. The feature remains gated; this package does not activate capture or change infrastructure by itself. + +The internal `meeting` preset selects Soniox `stt-rt-v5` at signed16LE mono 16 kHz. `standard` selects OpenAI `gpt-transcribe` at signed16LE mono 24 kHz. Languages are automatic; translation and provider diarization are disabled. LiveKit microphone identity/SID joined with the API membership manifest supplies the speaker. There is no public model selector or provisional transcript feed. The meeting default is a product-fit choice pending real account/audio qualification, not a measured accuracy claim. + +## Runtime boundaries + +- `daemon.ts` advertises configured-provider availability, claims one room with a boot-unique worker ID, and renews its 15-second authority every second from monotonic request start. Availability heartbeats remain every five seconds. Fixed capture expiry is capped at two hours. A delayed response cannot grant a fresh lease or resurrect an expired owner. In-flight and delivery-unknown claims are reconciled on shutdown using the same boot identity; stop-only recovery cannot allocate RTC. +- `supervisor.ts` and the independent `watchdog.ts` own one native room child. Parent and child each send monotonic heartbeats. A stalled/disconnected parent, stalled child, expired lease, or failed startup closes admissions and escalates TERM to KILL. A child `stopped` message does not prove transport containment: the watchdog must observe actual OS exit, and the API must acknowledge that receipt before another claim. Known exit receipts retry on the existing heartbeat cadence after API recovery. Unknown native exit keeps the slot fenced. +- `room-worker.ts` registers callbacks before connecting with `autoSubscribe:false`, then sweeps existing publications. Only exact current API membership, participant object, microphone source, audio kind, publication object, track object and SID are eligible. Every queue write and captured PCM send rechecks them. Muted/departed/replaced tracks stop intake immediately. Retiring tracks remain bounded; a ninth owned microphone ends capture visibly rather than silently dropping a speaker. Native readers are cancelled and their locks released. +- `track-worker.ts` owns each speaker's serial VAD/admission/commit order. Admission happens before that turn's PCM reaches a provider. Native readers copy promptly into a two-second queue; they never await inference or network work. Queue overflow, seven-second stuck processing, provider failure, or unresolved-final timeout ends capture. The owner discards late admission/connect/inference results after stopping. +- `silero.ts` verifies the vendored official model SHA256 before single-thread CPU inference. Each track has its own recurrent state; 24 kHz is resampled to 16 kHz for VAD. `segmenter.ts` retains 300 ms preroll, ends after 550 ms silence (or 200 ms silence after six seconds), and bounds continuous speech to ten seconds at the production 20 ms frame size. A finite 200 ms zero tail precedes manual provider finalization. This boundary quality still needs real multilingual meeting qualification. +- `socket.ts` bounds provider/event buffers, verifies OpenAI's effective configuration before audio, and emits only finalized local turns. Empty completions still retire their admissions; the API suppresses empty message rows. There are at most two pending turns, a five-second final deadline, and 32 KiB UTF8 text per turn. Sockets rotate at drained boundaries before nine minutes or 900 turns; a rollover with undrained work interrupts capture rather than guessing/replaying. Decoders retain at most 1,024 identifiers per socket and fail closed on overflow. + +Manual Stop closes new API admission immediately. The daemon observes Stop on its next active-run renewal (normally within one second), then its child synchronously closes PCM intake. An already-admitted turn can include audio collected during that notification delay; this is not a guarantee of pre-button audio only. Completion is accepted only within the API's fixed five-second deadline from the Stop request, and the UI stays Stopping until actual native exit. Ordinary voice leave may finish an already-admitted turn with its immutable speaker attribution. Destructive Space/history/destination invalidation or lost run authority discards immediately. Provider or native failure requires a fresh explicit Start; there is no automatic capture takeover, transcript fallback, or raw-audio persistence. + +## Build and environment + +Exact native dependencies are `@livekit/rtc-node` 1.1.0 (FFI 0.12.73) and `onnxruntime-node` 1.24.3. The model is copied into `dist` at build time with its license notice; no runtime model download is needed. The Dockerfile uses Node 24 Bookworm/glibc and the committed workspace lock. Linux ARM64 native packages exist; actual deployment-host import, resampling, microphone and shutdown behavior still require qualification. Alpine/musl is not supported by this plan. + +Set only these named values through the deployment secret/config consumer; the worker does not load environment files or print credentials: + +| Variable | Meaning | +| --- | --- | +| `GRID_TRANSCRIPTION_API_URL` | API HTTPS origin; loopback HTTP is allowed for controlled labs. URL path prefixes are rejected. | +| `GRID_TRANSCRIPTION_WORKER_SECRET` | Shared worker-control bearer secret, matching the API. | +| `GRID_TRANSCRIPTION_MODEL` | Internal `meeting` default or `standard`; must match API configuration. | +| `GRID_TRANSCRIPTION_WORKER_ID` | Optional 1–40 character boot-label (`a-z`, `A-Z`, digits, `_`, `-`); a fresh UUID is always appended. | +| `SONIOX_API_KEY` | Required to advertise readiness for `meeting`. | +| `OPENAI_API_KEY` | Required to advertise readiness for `standard`. | + +A missing selected key advertises `ready:false` and never claims work. Readiness validates runtime/model/key presence; it does not establish paid account access, provider authentication, quota or model quality. Provider keys remain in the worker, passed to its native child over IPC rather than command-line arguments or inherited environment. Child stdout/stderr are suppressed; daemon logs contain only coarse availability state. + +The worker calls `POST /_internal/grid-transcription/{heartbeat,claim,renew,admit,final,stopped}`. Heartbeat/claim use the shared secret; remaining calls use the run token. A normal claim supplies LiveKit credentials and the full membership manifest. A stop-only recovery claim supplies just run ID, epoch and token. Renewals fully replace the membership manifest and explicitly distinguish an allowed manual flush from destructive stopping. Admission returns the immutable segment ID; final persistence is idempotent for that ID. The API independently checks current Space, destination, speaker and run authority at persistence. + +```sh +bun --no-env-file run build +node dist/daemon.js +``` + +Use a separate bounded service on the existing Finland ARM64 host; keep the SFU and API process independent. Do not deploy or activate from this package's local checks. Give service shutdown enough time for native exit and the final bounded API acknowledgement; an unconfirmed exit or receipt during API outage must remain unavailable until reconciled. + +## Validation and qualification + +```sh +bun --no-env-file run typecheck +bun --no-env-file run test +bun --no-env-file run lint +bun --no-env-file run build +``` + +Tests use public synthetic wire fixtures and actual compiled Node loopback HTTP/WebSocket/process fixtures. `GRID_TRANSCRIPTION_TEST_NODE=/absolute/path/to/node` selects the Node executable for process tests. Node 22.23.3 macOS ARM64 has exercised real parent/child SIGSTOP, parent SIGKILL, TERM/KILL escalation, held exit acknowledgements, delayed/lost claims, and stopped-receipt API recovery. The pinned Silero model has performed compiled Node 16/24 kHz inference. A local source-built LiveKit 1.13.7 lab with two synthetic publishers has exercised existing/new microphone subscription, real RTC PCM, per-speaker VAD, admission and final delivery through loopback provider wire; this is not proof of the deployed SFU version or paid provider behavior. + +Still required before activation: intended Linux ARM64/glibc service and real SFU/token qualification; selected Soniox provider authentication, quota, final ordering/latency and multilingual/code-switching/overlap acceptance; real macOS button/Stop/late-join/history behavior; and load/billing measurement. Qualify the OpenAI alternative before configuring it for live capture. A two-provider benchmark is optional and is required only for a measured superiority claim. The bounded real-account attempt returned no result, so paid provider readiness remains unknown. The Grid iOS gate remains independently off pending physical-device acceptance. + +The compiled `qualify` command consumes an intentionally supplied signed16LE mono `.pcm` file at the selected provider's exact rate (100 ms–12 seconds), sends it at realtime pace and commits once. It is a single-provider boundary smoke, not the meeting/VAD comparison. Inject `GRID_TRANSCRIPTION_OPENAI_API_KEY` or `GRID_TRANSCRIPTION_SONIOX_API_KEY` through the approved secret consumer. These qualification-only names differ from daemon keys so no ordinary credential inheritance is assumed. + +```sh +node dist/qualify.js --help +node dist/qualify.js --model standard --pcm /absolute/path/short-24k-mono.pcm +node dist/qualify.js --model meeting --pcm /absolute/path/short-16k-mono.pcm --out /absolute/path/fresh-lab-result.json +``` + +Default stdout contains only model, duration, finalization latency and character count. Optional `--out` explicitly creates a local transcript artifact with mode 0600 and never overwrites a file. Use synthetic speech or supplied recordings from willing participants. Soniox's ordinary routing is US; EU requires an enabled regional project/key and a qualified endpoint change before deployment. diff --git a/server/packages/grid-transcription/package.json b/server/packages/grid-transcription/package.json new file mode 100644 index 000000000..55f89594b --- /dev/null +++ b/server/packages/grid-transcription/package.json @@ -0,0 +1,35 @@ +{ + "name": "@inline-chat/grid-transcription", + "version": "0.0.1", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json && node dist/copy-model.js", + "qualify": "node dist/qualify.js", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "bun --no-env-file test src", + "lint": "oxlint --config ../../../.oxlintrc.json --ignore-path ../../../.oxlintignore src", + "start": "node dist/daemon.js" + }, + "dependencies": { + "ws": "8.21.3", + "@livekit/rtc-node": "1.1.0", + "onnxruntime-node": "1.24.3" + }, + "devDependencies": { + "@types/node": "catalog:", + "@types/ws": "8.18.1", + "typescript": "catalog:", + "oxlint": "catalog:" + }, + "license": "AGPL-3.0-only" +} diff --git a/server/packages/grid-transcription/src/api.test.ts b/server/packages/grid-transcription/src/api.test.ts new file mode 100644 index 000000000..6c628d7db --- /dev/null +++ b/server/packages/grid-transcription/src/api.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from "bun:test" +import { execFile, execFileSync } from "node:child_process" +import { mkdtempSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" +import { claimedRun, claimResponse, renewedRun, RunControlClient, WorkerControlClient } from "./api.js" +import { MAX_TURN_TEXT_BYTES, TranscriptionError } from "./protocol.js" + +// Current claim wire shape comes from server/src/modules/grid/transcription/worker.ts: +// its claim response uses run.sourceRoomId and participant.userId as database numbers. +const claimFixture = { + runId: "4fef7f47-4d65-4b71-8efb-1eab3185ab05", claimEpoch: 1, roomId: 42, generation: 3, + runToken: "fixture-run-token", providerTarget: "inline-grid-room-42-generation-3", + livekit: { serverUrl: "wss://media.example.test", token: "fixture-livekit-token" }, + model: "meeting", leaseMs: 15_000, expiresAt: "2030-01-01T02:00:00.000Z", + participants: [{ identity: "inline-grid-user-7-member", userId: 7, membershipId: "member" }], +} +const renewalFixture = { + state: "stopping", allowFinalFlush: true, leaseExpiresAt: "2030-01-01T00:00:15.000Z", + participants: claimFixture.participants, spaceId: 9, +} +const segmentId = "78190417-5200-43ee-ae16-c891fd067d7e" + +function expectFailure(action: () => unknown, code: TranscriptionError["code"] = "protocol") { + try { action(); throw new Error("Expected safe failure") } + catch (error) { + expect(error).toBeInstanceOf(TranscriptionError) + if (!(error instanceof TranscriptionError)) throw error + expect(error.code).toBe(code) + expect(error.message).toBe(`Grid transcription ${code}`) + } +} + +describe("API wire parsing", () => { + test("lost claim recovery returns only stop authority and cannot supply microphone credentials", () => { + const stopped = claimResponse({ runId: claimFixture.runId, claimEpoch: 2, runToken: "fixture-recovery-token", stopImmediately: true }) + expect(stopped).toEqual({ runId: claimFixture.runId, claimEpoch: 2, runToken: "fixture-recovery-token", stopImmediately: true }) + expect("livekit" in stopped).toBe(false) + expectFailure(() => claimResponse({ runId: claimFixture.runId, claimEpoch: 0, runToken: "fixture-recovery-token", stopImmediately: true })) + }) + test("accepts the server's numeric room and participant IDs as exact positive decimal IDs", () => { + const claim = claimedRun(claimFixture) + expect(claim.roomId).toBe("42") + expect(claim.participants[0]?.userId).toBe("7") + expect(claim.livekit).toEqual(claimFixture.livekit) + expect(Object.isFrozen(claim)).toBe(true) + expect(Object.isFrozen(claim.participants[0])).toBe(true) + expect(claimedRun({ ...claimFixture, roomId: "42" }).roomId).toBe("42") + for (const roomId of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, "0", "01", "-2", "1e3", ""]) { + expectFailure(() => claimedRun({ ...claimFixture, roomId })) + } + }) + + test("rejects invalid media URLs, ID bounds and leases using safe errors", () => { + for (const serverUrl of ["private malformed URL", "https://media.example.test"]) { + expectFailure(() => claimedRun({ ...claimFixture, livekit: { ...claimFixture.livekit, serverUrl } })) + } + for (const leaseMs of [0, 15_001, Infinity]) expectFailure(() => claimedRun({ ...claimFixture, leaseMs })) + expectFailure(() => claimedRun({ ...claimFixture, participants: [ + ...claimFixture.participants, ...claimFixture.participants, + ] })) + expectFailure(() => claimedRun({ ...claimFixture, participants: [{ ...claimFixture.participants[0], identity: "a".repeat(129) }] })) + }) + + test("defaults an omitted final-flush grant to false and accepts only explicit boolean grants", () => { + expect(renewedRun(renewalFixture).allowFinalFlush).toBe(true) + expect(renewedRun({ ...renewalFixture, allowFinalFlush: undefined }).allowFinalFlush).toBe(false) + expect(renewedRun({ ...renewalFixture, allowFinalFlush: false }).allowFinalFlush).toBe(false) + for (const allowFinalFlush of [1, "true", null]) expectFailure(() => renewedRun({ ...renewalFixture, allowFinalFlush })) + expectFailure(() => renewedRun({ ...renewalFixture, state: "interrupted" })) + }) + + test("URL constructors reject malformed URLs, credentials and unsupported path prefixes safely", () => { + for (const baseUrl of ["private malformed URL", "http://example.test", "https://user:private@example.test", + "https://example.test/prefix", "https://example.test/?secret=private", "https://example.test/#private"]) { + expectFailure(() => new WorkerControlClient(baseUrl, "fixture-secret", "worker")) + expectFailure(() => new RunControlClient(baseUrl, "fixture-token")) + } + expectFailure(() => new WorkerControlClient("https://example.test", "fixture-secret", "w".repeat(81))) + }) + + test("rejects over-limit admission IDs and UTF8 final text before making requests", async () => { + const client = new RunControlClient("http://127.0.0.1:1", "fixture-token") + for (const values of [["a".repeat(129), "track", "turn"], ["identity", "a".repeat(129), "turn"], + ["identity", "track", "a".repeat(129)]]) { + await expect(client.admit(values[0]!, values[1]!, values[2]!)).rejects.toThrow("Grid transcription protocol") + } + await expect(client.final(segmentId, "a".repeat(MAX_TURN_TEXT_BYTES + 1))).rejects.toThrow("Grid transcription overflow") + await expect(client.final(segmentId, "界".repeat(Math.ceil(MAX_TURN_TEXT_BYTES / 3)))).rejects.toThrow("Grid transcription overflow") + await expect(client.stopped("r".repeat(81))).rejects.toThrow("Grid transcription protocol") + }) +}) + +// The HTTP tests execute compiled production code on Node, rather than relying on Bun fetch behavior. +const fixtureDirectory = mkdtempSync(join(tmpdir(), "grid-api-test-")) +const nodeExecutable = execFileSync(process.env.GRID_TRANSCRIPTION_TEST_NODE ?? "node", ["--print", "process.execPath"], { encoding: "utf8" }).trim() +writeFileSync(join(fixtureDirectory, "package.json"), '{"type":"module"}') +execFileSync(nodeExecutable, [fileURLToPath(new URL("../node_modules/typescript/bin/tsc", import.meta.url)), + "--ignoreConfig", "--target", "ES2022", "--module", "NodeNext", "--moduleResolution", "NodeNext", "--strict", + "--noUncheckedIndexedAccess", "--skipLibCheck", "--types", "node", "--verbatimModuleSyntax", "--outDir", fixtureDirectory, + fileURLToPath(new URL("./api.ts", import.meta.url)), +], { cwd: fileURLToPath(new URL("..", import.meta.url)), env: {}, encoding: "utf8" }) +const harness = ` +import assert from 'node:assert/strict'; +import {createServer} from 'node:http'; +import {WorkerControlClient,RunControlClient} from ${JSON.stringify(pathToFileURL(join(fixtureDirectory, "api.js")).href)}; +const claim=${JSON.stringify(claimFixture)},renewal=${JSON.stringify(renewalFixture)},segmentId=${JSON.stringify(segmentId)}; +const safe=code=>error=>error.name==='TranscriptionError'&&error.code===code&&error.message==='Grid transcription '+code; +const listen=async handler=>{const server=createServer(handler);await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)); +return {server,url:'http://127.0.0.1:'+server.address().port,close:async()=>{server.closeAllConnections();await new Promise(resolve=>server.close(resolve));}};}; +const read=async request=>{const chunks=[];for await(const chunk of request)chunks.push(chunk);return JSON.parse(Buffer.concat(chunks).toString());}; +const json=(response,value)=>{response.setHeader('Content-Type','application/json');response.end(JSON.stringify(value));}; +` +function runNode(script: string): Promise { + return new Promise((resolve, reject) => { + execFile(nodeExecutable, ["--input-type=module", "--eval", harness + script], { env: {}, timeout: 10_000, maxBuffer: 8000 }, + (error, stdout, stderr) => { + if (error) { reject(new Error(`Native API fixture failed: ${stderr.slice(0, 4000)}`)); return } + try { resolve(JSON.parse(stdout)) } catch { reject(new Error("Native API fixture result protocol")) } + }) + }) +} + +describe("control API over native loopback HTTP", () => { + test("uses exact internal root paths, separate bearer credentials and server-shaped replies", async () => { + expect(await runNode(` +const requests=[];let claims=0; +const fixture=await listen(async(request,response)=>{const body=await read(request);requests.push({path:request.url,method:request.method,auth:request.headers.authorization,body}); +switch(request.url){ +case '/_internal/grid-transcription/heartbeat':json(response,{ready:body.ready});break; +case '/_internal/grid-transcription/claim':if(claims++===0){response.writeHead(204);response.end();}else json(response,claim);break; +case '/_internal/grid-transcription/renew':json(response,renewal);break; +case '/_internal/grid-transcription/admit':json(response,{segmentId});break; +case '/_internal/grid-transcription/final':json(response,{messageId:12});break; +case '/_internal/grid-transcription/stopped':json(response,{stopped:true});break; +default:response.writeHead(404);response.end();}}); +try{const worker=new WorkerControlClient(fixture.url,'worker-fixture-secret','worker-1'); +await worker.heartbeat('meeting',true);assert.equal(await worker.claim(),undefined);assert.equal((await worker.claim()).roomId,'42'); +const run=new RunControlClient(fixture.url,'run-fixture-token');assert.equal((await run.renew()).allowFinalFlush,true); +assert.equal(await run.admit('identity','track','turn'),segmentId);await run.final(segmentId,'a'.repeat(32768));await run.stopped('user_stop'); +assert.equal(requests.length,7);assert(requests.every(value=>value.method==='POST')); +assert(requests.slice(0,3).every(value=>value.auth==='Bearer worker-fixture-secret')); +assert(requests.slice(3).every(value=>value.auth==='Bearer run-fixture-token')); +assert.deepEqual(requests[0].body,{workerId:'worker-1',model:'meeting',ready:true});assert.deepEqual(requests[3].body,{}); +assert.deepEqual(requests[4].body,{participantIdentity:'identity',trackSid:'track',sourceTurnKey:'turn'}); +assert.equal(requests[5].body.text.length,32768);console.log(JSON.stringify({requests:requests.length})); +}finally{await fixture.close();} +`)).toEqual({ requests: 7 }) + }, 15_000) + + test("maps HTTP rejections to safe codes without returning private error bodies", async () => { + expect(await runNode(` +const statuses=[401,403,404,409,410,500];let index=0; +const fixture=await listen((_request,response)=>{response.writeHead(statuses[index++]);response.end('PRIVATE error detail '.repeat(10000));}); +try{const client=new WorkerControlClient(fixture.url,'fixture-secret','worker'); +for(const code of ['stopped','stopped','stopped','stopped','expired','provider'])await assert.rejects(()=>client.claim(),safe(code)); +console.log(JSON.stringify({rejections:index}));}finally{await fixture.close();} +`)).toEqual({ rejections: 6 }) + }, 15_000) + + test("caps streamed success responses and fails safely on malformed JSON", async () => { + expect(await runNode(` +let requests=0;const fixture=await listen((_request,response)=>{requests++;if(requests===1){response.write('"');response.end('a'.repeat(65536)+'"');}else response.end('PRIVATE malformed JSON');}); +try{const client=new WorkerControlClient(fixture.url,'fixture-secret','worker'); +await assert.rejects(()=>client.claim(),safe('overflow'));await assert.rejects(()=>client.claim(),safe('provider')); +console.log(JSON.stringify({requests}));}finally{await fixture.close();} +`)).toEqual({ requests: 2 }) + }, 15_000) + + test("times out both stalled headers and a stalled response body", async () => { + expect(await runNode(` +let requests=0;const fixture=await listen((_request,response)=>{requests++;if(requests===2){response.writeHead(200,{'Content-Type':'application/json'});response.write('{');}}); +try{const client=new WorkerControlClient(fixture.url,'fixture-secret','worker',120); +await assert.rejects(()=>client.claim(),safe('provider'));await assert.rejects(()=>client.claim(),safe('provider')); +console.log(JSON.stringify({requests}));}finally{await fixture.close();} +`)).toEqual({ requests: 2 }) + }, 15_000) +}) diff --git a/server/packages/grid-transcription/src/api.ts b/server/packages/grid-transcription/src/api.ts new file mode 100644 index 000000000..bd883afe5 --- /dev/null +++ b/server/packages/grid-transcription/src/api.ts @@ -0,0 +1,213 @@ +import { Buffer } from "node:buffer" +import { MAX_TURN_TEXT_BYTES, TranscriptionError, nonemptyString, record, type Model } from "./protocol.js" + +export const RUN_DURATION_MS = 2 * 60 * 60 * 1000 +export const SPEAKER_LIMIT = 8 +export const LEASE_MS = 15_000 +const MAX_RESPONSE_BYTES = 64 * 1024 + +export type ParticipantGrant = Readonly<{ identity: string; userId: string; membershipId: string }> +export type ClaimedRun = Readonly<{ + runId: string; claimEpoch: number; roomId: string; generation: number; runToken: string; + providerTarget: string; livekit: Readonly<{ serverUrl: string; token: string }>; + model: Model; leaseMs: number; expiresAt: string; participants: readonly ParticipantGrant[]; +}> +export type StoppedClaim = Readonly<{ runId: string; claimEpoch: number; runToken: string; stopImmediately: true }> +export type RenewedRun = Readonly<{ + state: "active" | "stopping" | "stopped"; allowFinalFlush: boolean; leaseExpiresAt: string; + participants: readonly ParticipantGrant[]; +}> + +function integer(value: unknown, minimum = 0): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) { + throw new TranscriptionError("protocol") + } + return value +} + +function secret(value: unknown): string { + if (typeof value !== "string" || value.length === 0 || value.length > 16_384 || /[\r\n]/.test(value)) { + throw new TranscriptionError("protocol") + } + return value +} + +function boundedString(value: unknown, maximum: number): string { + const result = nonemptyString(value) + if (result.length > maximum) throw new TranscriptionError("protocol") + return result +} + +function decimalId(value: unknown): string { + const result = typeof value === "number" ? String(integer(value, 1)) : boundedString(value, 128) + if (!/^[1-9][0-9]*$/.test(result)) throw new TranscriptionError("protocol") + return result +} + +function parseUrl(value: string): URL { + try { return new URL(value) } + catch { throw new TranscriptionError("protocol") } +} + +function date(value: unknown): string { + const result = nonemptyString(value) + if (!Number.isFinite(Date.parse(result))) throw new TranscriptionError("protocol") + return result +} + +export function participants(value: unknown): readonly ParticipantGrant[] { + // Manifest may include muted/trackless occupants; the microphone cap is checked separately. + if (!Array.isArray(value) || value.length > 100) throw new TranscriptionError("overflow") + const identities = new Set() + const result: ParticipantGrant[] = [] + for (const entry of value) { + const item = record(entry) + const identity = boundedString(item.identity, 128) + const membershipId = boundedString(item.membershipId, 128) + const userId = decimalId(item.userId) + if (identities.has(identity)) throw new TranscriptionError("protocol") + identities.add(identity) + result.push(Object.freeze({ identity, membershipId, userId })) + } + return Object.freeze(result) +} + +export function claimedRun(value: unknown): ClaimedRun { + const item = record(value) + const media = record(item.livekit) + if (item.model !== "standard" && item.model !== "meeting") throw new TranscriptionError("protocol") + const leaseMs = integer(item.leaseMs, 1) + if (leaseMs > LEASE_MS) throw new TranscriptionError("protocol") + const serverUrl = nonemptyString(media.serverUrl) + const url = parseUrl(serverUrl) + if (url.protocol !== "wss:" && url.protocol !== "ws:") throw new TranscriptionError("protocol") + return Object.freeze({ + runId: boundedString(item.runId, 128), claimEpoch: integer(item.claimEpoch, 1), + roomId: decimalId(item.roomId), generation: integer(item.generation), + runToken: secret(item.runToken), providerTarget: nonemptyString(item.providerTarget), + livekit: Object.freeze({ serverUrl, token: secret(media.token) }), model: item.model, + leaseMs, expiresAt: date(item.expiresAt), participants: participants(item.participants), + }) +} +export function claimResponse(value: unknown): ClaimedRun | StoppedClaim { + const item = record(value) + if (item.stopImmediately !== undefined && typeof item.stopImmediately !== "boolean") throw new TranscriptionError("protocol") + if (item.stopImmediately !== true) return claimedRun(value) + return Object.freeze({ runId: boundedString(item.runId, 128), claimEpoch: integer(item.claimEpoch, 1), + runToken: secret(item.runToken), stopImmediately: true }) +} + +export function renewedRun(value: unknown): RenewedRun { + const item = record(value) + if (item.state !== "active" && item.state !== "stopping" && item.state !== "stopped") { + throw new TranscriptionError("protocol") + } + if (item.allowFinalFlush !== undefined && typeof item.allowFinalFlush !== "boolean") { + throw new TranscriptionError("protocol") + } + return Object.freeze({ + state: item.state, allowFinalFlush: item.allowFinalFlush ?? false, + leaseExpiresAt: date(item.leaseExpiresAt), participants: participants(item.participants), + }) +} + +class ControlHttp { + private readonly base: URL + + constructor(baseUrl: string, private readonly bearer: string, private readonly timeoutMs = 2500) { + this.base = parseUrl(baseUrl) + if (this.base.protocol !== "https:" && !(this.base.protocol === "http:" + && ["127.0.0.1", "localhost", "[::1]"].includes(this.base.hostname))) { + throw new TranscriptionError("protocol") + } + if (this.base.username || this.base.password || this.base.search || this.base.hash || this.base.pathname !== "/" + || !Number.isFinite(timeoutMs) || timeoutMs < 1 || timeoutMs > 5000) { + throw new TranscriptionError("protocol") + } + secret(bearer) + } + + async post(path: string, body: unknown): Promise { + try { + const url = new URL(`/_internal/grid-transcription/${path}`, this.base) + const response = await fetch(url, { + method: "POST", redirect: "error", signal: AbortSignal.timeout(this.timeoutMs), + headers: { Authorization: `Bearer ${this.bearer}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + if (response.status === 204) return undefined + if (!response.ok) { + await response.body?.cancel() + throw new TranscriptionError(response.status === 410 ? "expired" + : [401, 403, 404, 409].includes(response.status) ? "stopped" : "provider") + } + const reader = response.body?.getReader() + if (!reader) throw new TranscriptionError("protocol") + const chunks: Uint8Array[] = [] + let size = 0 + try { + while (true) { + const part = await reader.read() + if (part.done) break + size += part.value.byteLength + if (size > MAX_RESPONSE_BYTES) { + await reader.cancel() + throw new TranscriptionError("overflow") + } + chunks.push(part.value) + } + } finally { reader.releaseLock() } + const bytes = Buffer.concat(chunks, size) + return JSON.parse(bytes.toString("utf8")) as unknown + } catch (error) { + throw error instanceof TranscriptionError ? error : new TranscriptionError("provider") + } + } +} + +export class WorkerControlClient { + private readonly http: ControlHttp + + constructor(baseUrl: string, sharedSecret: string, readonly workerId: string, timeoutMs?: number) { + boundedString(workerId, 80) + this.http = new ControlHttp(baseUrl, sharedSecret, timeoutMs) + } + + async heartbeat(model: Model, ready: boolean): Promise { + await this.http.post("heartbeat", { workerId: this.workerId, model, ready }) + } + + async claim(): Promise { + const value = await this.http.post("claim", { workerId: this.workerId }) + return value === undefined ? undefined : claimResponse(value) + } +} + +export class RunControlClient { + private readonly http: ControlHttp + + constructor(baseUrl: string, runToken: string, timeoutMs?: number) { + this.http = new ControlHttp(baseUrl, runToken, timeoutMs) + } + + async renew(): Promise { return renewedRun(await this.http.post("renew", {})) } + + async admit(participantIdentity: string, trackSid: string, sourceTurnKey: string): Promise { + const value = record(await this.http.post("admit", { + participantIdentity: boundedString(participantIdentity, 128), trackSid: boundedString(trackSid, 128), + sourceTurnKey: boundedString(sourceTurnKey, 128), + })) + return boundedString(value.segmentId, 128) + } + + async final(segmentId: string, text: string): Promise { + if (typeof text !== "string") throw new TranscriptionError("protocol") + if (text.length > MAX_TURN_TEXT_BYTES || Buffer.byteLength(text) > MAX_TURN_TEXT_BYTES) { + throw new TranscriptionError("overflow") + } + const value = record(await this.http.post("final", { segmentId: boundedString(segmentId, 128), text })) + if (value.messageId !== null) decimalId(value.messageId) + } + + async stopped(reason: string): Promise { await this.http.post("stopped", { reason: boundedString(reason, 80) }) } +} diff --git a/server/packages/grid-transcription/src/authority.test.ts b/server/packages/grid-transcription/src/authority.test.ts new file mode 100644 index 000000000..ca6bc548a --- /dev/null +++ b/server/packages/grid-transcription/src/authority.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test" +import { CaptureAuthority } from "./authority.js" + +const identity = { runId: "run", claimEpoch: 1, generation: 3 } +const grant = { identity: "inline-grid-user-7-membership", trackSid: "TR-mic", speakerUserId: "7", membershipId: "membership" } +const microphone = { identity: grant.identity, trackSid: grant.trackSid, kind: "audio", source: "microphone" } as const + +function setup(expiresAtMs = 100_000) { + let clock = 1_000 + const authority = new CaptureAuthority({ identity, requestedAtMs: 1_000, leaseMs: 5_000, expiresAtMs, + microphones: [grant], now: () => clock }) + return { authority, advance: (time: number) => { clock = time } } +} + +describe("verified capture authority", () => { + test("initial connection authority grants no microphone until the API/publication join is installed", () => { + const authority = new CaptureAuthority({ identity, requestedAtMs: 1000, leaseMs: 5000, expiresAtMs: 100_000, + microphones: [], now: () => 1000 }) + expect(authority.microphone(identity, microphone)).toBeUndefined() + authority.replaceMicrophones(identity, [grant]) + expect(authority.microphone(identity, microphone)?.membershipId).toBe("membership") + authority.replaceMicrophones(identity, []) + expect(authority.microphone(identity, microphone)).toBeUndefined() + }) + test("invalid replacement is atomic and ninth microphone is rejected", () => { + const { authority } = setup() + expect(() => authority.replaceMicrophones(identity, [grant, grant])).toThrow("protocol") + expect(authority.microphone(identity, microphone)?.speakerUserId).toBe("7") + expect(() => authority.replaceMicrophones(identity, Array.from({ length: 9 }, (_, index) => ({ ...grant, trackSid: `TR-${index}` })))).toThrow("overflow") + expect(authority.microphone(identity, microphone)?.speakerUserId).toBe("7") + }) + test("exact microphone identity and track only; screen, prefix impersonation and stale claim denied", () => { + const { authority } = setup() + expect(authority.microphone(identity, microphone)?.speakerUserId).toBe("7") + expect(authority.microphone(identity, { ...microphone, source: "screen" })).toBeUndefined() + expect(authority.microphone(identity, { ...microphone, identity: "inline-grid-user-7-other" })).toBeUndefined() + expect(authority.microphone(identity, { ...microphone, trackSid: "TR-old" })).toBeUndefined() + expect(() => authority.microphone({ ...identity, claimEpoch: 2 }, microphone)).toThrow("protocol") + authority.revoke(grant.trackSid) + expect(authority.microphone(identity, microphone)).toBeUndefined() + }) + test("delayed lease response counts from request start, and expiry cannot be resurrected", () => { + const { authority, advance } = setup() + advance(5_900) + authority.renew(identity, 2_000, 5_000) + advance(7_000) + expect(() => authority.assertCurrent(identity)).toThrow("expired") + expect(() => authority.renew(identity, 6_900, 5_000)).toThrow("stopped") + }) + test("out-of-order renewals cannot extend a newer accepted deadline", () => { + const { authority, advance } = setup() + advance(3_000) + authority.renew(identity, 3_000, 4_000) + authority.renew(identity, 2_000, 20_000) + advance(7_000) + expect(() => authority.assertCurrent(identity)).toThrow("expired") + }) + test("fixed run deadline is never extended by lease renewals", () => { + const { authority, advance } = setup(6_000) + advance(5_000) + authority.renew(identity, 5_000, 5_000) + advance(6_000) + expect(() => authority.assertCurrent(identity)).toThrow("expired") + }) + test("stop invalidates buffered frame authority permanently", () => { + const { authority } = setup() + authority.stop() + expect(() => authority.microphone(identity, microphone)).toThrow("stopped") + }) +}) diff --git a/server/packages/grid-transcription/src/authority.ts b/server/packages/grid-transcription/src/authority.ts new file mode 100644 index 000000000..5763ef350 --- /dev/null +++ b/server/packages/grid-transcription/src/authority.ts @@ -0,0 +1,94 @@ +import { TranscriptionError } from "./protocol.js" + +export type CaptureIdentity = Readonly<{ runId: string; claimEpoch: number; generation: number }> +export type MicrophoneGrant = Readonly<{ identity: string; trackSid: string; speakerUserId: string; membershipId: string }> +export type Publication = Readonly<{ identity: string; trackSid: string; kind: "audio" | "video"; source: "microphone" | "screen" | "other" }> + +// Construct only from verified API authority. Mutable room metadata is never a grant. +export class CaptureAuthority { + private stopped = false + private deadlineMs: number + private lastRenewRequestMs: number + private readonly tracks = new Map() + readonly identity: CaptureIdentity + + constructor(options: { + identity: CaptureIdentity; requestedAtMs: number; leaseMs: number; expiresAtMs: number; + microphones: readonly MicrophoneGrant[]; now?: () => number; + }) { + this.identity = Object.freeze({ ...options.identity }) + this.now = options.now ?? (() => performance.now()) + this.expiresAtMs = options.expiresAtMs + this.lastRenewRequestMs = options.requestedAtMs + this.deadlineMs = this.deadline(options.requestedAtMs, options.leaseMs) + if (!options.identity.runId || !Number.isSafeInteger(options.identity.claimEpoch) || options.identity.claimEpoch <= 0 + || !Number.isSafeInteger(options.identity.generation) || options.identity.generation < 0) throw new TranscriptionError("protocol") + if (options.microphones.length > 8) throw new TranscriptionError("overflow") + for (const grant of options.microphones) { + if (!grant.identity || !grant.trackSid || !grant.speakerUserId || !grant.membershipId || this.tracks.has(grant.trackSid)) { + throw new TranscriptionError("protocol") + } + this.tracks.set(grant.trackSid, Object.freeze({ ...grant })) + } + this.assertCurrent(this.identity) + } + + private readonly now: () => number + private readonly expiresAtMs: number + + private deadline(requestedAtMs: number, leaseMs: number): number { + if (!Number.isFinite(requestedAtMs) || !Number.isFinite(this.expiresAtMs) + || !Number.isFinite(leaseMs) || leaseMs <= 0 || leaseMs > 30_000 || requestedAtMs > this.now()) { + throw new TranscriptionError("protocol") + } + return Math.min(requestedAtMs + leaseMs, this.expiresAtMs) + } + + assertCurrent(identity: CaptureIdentity): void { + if (this.stopped) throw new TranscriptionError("stopped") + if (this.now() >= this.deadlineMs) { + this.stop() + throw new TranscriptionError("expired") + } + if (identity.runId !== this.identity.runId || identity.claimEpoch !== this.identity.claimEpoch + || identity.generation !== this.identity.generation) throw new TranscriptionError("protocol") + } + + // A delayed response counts from request start and cannot resurrect expired authority. + renew(identity: CaptureIdentity, requestedAtMs: number, leaseMs: number): void { + this.assertCurrent(identity) + const deadline = this.deadline(requestedAtMs, leaseMs) + if (requestedAtMs <= this.lastRenewRequestMs) return + if (deadline <= this.now()) { + this.stop() + throw new TranscriptionError("expired") + } + this.lastRenewRequestMs = requestedAtMs + this.deadlineMs = deadline + } + + microphone(identity: CaptureIdentity, publication: Publication): MicrophoneGrant | undefined { + this.assertCurrent(identity) + const grant = this.tracks.get(publication.trackSid) + if (publication.kind !== "audio" || publication.source !== "microphone" || !grant + || grant.identity !== publication.identity) return undefined + return grant + } + + revoke(trackSid: string): void { this.tracks.delete(trackSid) } + // Atomically join the verified API membership manifest with current microphone publications. + replaceMicrophones(identity: CaptureIdentity, grants: readonly MicrophoneGrant[]): void { + this.assertCurrent(identity) + if (grants.length > 8) throw new TranscriptionError("overflow") + const next = new Map() + for (const grant of grants) { + if (!grant.identity || !grant.trackSid || !grant.speakerUserId || !grant.membershipId || next.has(grant.trackSid)) { + throw new TranscriptionError("protocol") + } + next.set(grant.trackSid, Object.freeze({ ...grant })) + } + this.tracks.clear() + for (const [sid, grant] of next) this.tracks.set(sid, grant) + } + stop(): void { this.stopped = true; this.tracks.clear() } +} diff --git a/server/packages/grid-transcription/src/copy-model.ts b/server/packages/grid-transcription/src/copy-model.ts new file mode 100644 index 000000000..a7661ed0e --- /dev/null +++ b/server/packages/grid-transcription/src/copy-model.ts @@ -0,0 +1,4 @@ +import { copyFile } from "node:fs/promises" + +await copyFile(new URL("../src/silero_vad.onnx", import.meta.url), new URL("./silero_vad.onnx", import.meta.url)) +await copyFile(new URL("../src/silero-model-license.txt", import.meta.url), new URL("./silero-model-license.txt", import.meta.url)) diff --git a/server/packages/grid-transcription/src/daemon.test.ts b/server/packages/grid-transcription/src/daemon.test.ts new file mode 100644 index 000000000..6f61a7c31 --- /dev/null +++ b/server/packages/grid-transcription/src/daemon.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, test } from "bun:test" +import { execFileSync, fork } from "node:child_process" +import { createServer } from "node:http" +import { fileURLToPath } from "node:url" +import { workerConfig } from "./daemon.js" + +const nodeExecutable = execFileSync(process.env.GRID_TRANSCRIPTION_TEST_NODE ?? "node", ["--print", "process.execPath"], { encoding: "utf8" }).trim() +let compiled = false +function ensureCompiled(): void { + if (compiled) return + execFileSync(nodeExecutable, [fileURLToPath(new URL("../node_modules/typescript/bin/tsc", import.meta.url)), "-p", "tsconfig.json"], { + cwd: fileURLToPath(new URL("..", import.meta.url)), env: {}, encoding: "utf8", + }) + execFileSync(nodeExecutable, [fileURLToPath(new URL("../dist/copy-model.js", import.meta.url))], { env: {}, encoding: "utf8" }) + compiled = true +} +async function noChildClaimFixture(mode: "delayed" | "lost" | "retry_stopped") { + ensureCompiled() + let daemon: ReturnType | undefined + let claims = 0 + let stops = 0 + let acknowledged = false + let prematureExit = false + let readyFalse = false + const workers: string[] = [] + const server = createServer(async (request, response) => { + let body = "" + for await (const chunk of request) body += chunk + const value = JSON.parse(body) as Record + response.setHeader("Content-Type", "application/json") + if (request.url?.endsWith("/heartbeat")) { + if (value.ready === false) readyFalse = true + response.end("{}") + } else if (request.url?.endsWith("/claim")) { + claims++ + workers.push(String(value.workerId)) + if (mode === "lost" && claims === 1) { daemon?.kill("SIGTERM"); response.destroy(); return } + const payload = { runId: "fixture-no-child", claimEpoch: 1, runToken: "fixture-stop-token", stopImmediately: true } + if (mode === "delayed") { + daemon?.kill("SIGTERM") + setTimeout(() => response.end(JSON.stringify({ ...payload, stopImmediately: undefined, roomId: 1, generation: 1, + providerTarget: "http://127.0.0.1:9", livekit: { serverUrl: "ws://127.0.0.1:9", token: "fixture-token" }, + model: "meeting", leaseMs: 15000, expiresAt: new Date(Date.now() + 60_000).toISOString(), participants: [] })), 300) + } else response.end(JSON.stringify(payload)) + } else if (request.url?.endsWith("/stopped")) { + stops++ + if (mode === "retry_stopped" && stops <= 2) { response.writeHead(503).end("{}"); return } + setTimeout(() => { + acknowledged = true + response.end("{}") + if (mode === "retry_stopped") setTimeout(() => daemon?.kill("SIGTERM"), 100) + }, 300) + } else response.writeHead(404).end("{}") + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + const address = server.address() + if (!address || typeof address === "string") throw new Error("loopback fixture failed") + try { + daemon = fork(fileURLToPath(new URL("../dist/daemon.js", import.meta.url)), [], { + execPath: nodeExecutable, execArgv: [], env: { GRID_TRANSCRIPTION_API_URL: `http://127.0.0.1:${address.port}`, + GRID_TRANSCRIPTION_WORKER_SECRET: "fixture-secret", SONIOX_API_KEY: "fixture-key" }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }) + const child = daemon + const code = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { child.kill("SIGKILL"); reject(new Error("claim fixture deadline")) }, 15_000) + child.once("error", (error) => { clearTimeout(timeout); reject(error) }) + child.once("exit", (exitCode) => { clearTimeout(timeout); prematureExit = !acknowledged; resolve(exitCode) }) + }) + return { code, claims, stops, acknowledged, prematureExit, readyFalse, sameBoot: new Set(workers).size === 1 } + } finally { + if (daemon && daemon.exitCode === null && daemon.signalCode === null) daemon.kill("SIGKILL") + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + } +} + +describe("daemon configuration and real shutdown", () => { + test("SIGTERM waits for a delayed claim response before acknowledging no child was allocated", async () => { + expect(await noChildClaimFixture("delayed")).toEqual({ code: 0, claims: 1, stops: 1, + acknowledged: true, prematureExit: false, readyFalse: true, sameBoot: true }) + }, 20_000) + test("SIGTERM recovers the same boot's lost claim as stop-only authority", async () => { + expect(await noChildClaimFixture("lost")).toEqual({ code: 0, claims: 2, stops: 1, + acknowledged: true, prematureExit: false, readyFalse: true, sameBoot: true }) + }, 20_000) + test("known stopped receipt retries after API recovery before another claim", async () => { + expect(await noChildClaimFixture("retry_stopped")).toEqual({ code: 0, claims: 1, stops: 3, + acknowledged: true, prematureExit: false, readyFalse: true, sameBoot: true }) + }, 20_000) + test("the configured provider is internal, default meeting, and every boot has distinct authority", () => { + const source = { GRID_TRANSCRIPTION_API_URL: "https://api.example.test", GRID_TRANSCRIPTION_WORKER_SECRET: "test-secret" } + const first = workerConfig(source) + const second = workerConfig(source) + expect(first.model).toBe("meeting") + expect(first.apiKey).toBeUndefined() + expect(first.workerId).not.toBe(second.workerId) + expect(first.workerId.length).toBeLessThanOrEqual(80) + const standard = workerConfig({ ...source, GRID_TRANSCRIPTION_MODEL: "standard", OPENAI_API_KEY: "fixture-key" }) + expect(standard.model).toBe("standard") + expect(standard.apiKey).toBe("fixture-key") + expect(() => workerConfig({ ...source, GRID_TRANSCRIPTION_MODEL: "client-selected-model" })).toThrow("protocol") + }) + + test("compiled Node daemon SIGTERM waits for its stopped HTTP acknowledgement after actual native exit", async () => { + // No real credentials, provider socket or remote room is contacted. The real + // native child joins a refused loopback endpoint, exits, and reports through its watchdog. + ensureCompiled() + let claimed = false + let stoppedRequested = false + let stoppedAcknowledged = false + let prematureExit = false + let daemon: ReturnType | undefined + const server = createServer((request, response) => { + response.setHeader("Content-Type", "application/json") + if (request.url === "/_internal/grid-transcription/claim") { + if (claimed) { response.writeHead(204).end(); return } + claimed = true + response.end(JSON.stringify({ runId: "fixture-run", claimEpoch: 1, roomId: 1, generation: 1, + providerTarget: "http://127.0.0.1:9", livekit: { serverUrl: "ws://127.0.0.1:9", token: "not-a-real-token" }, + runToken: "test-run-token", model: "meeting", leaseMs: 15000, + expiresAt: new Date(Date.now() + 60_000).toISOString(), participants: [] })) + } else if (request.url === "/_internal/grid-transcription/stopped") { + stoppedRequested = true + daemon?.kill("SIGTERM") + setTimeout(() => { stoppedAcknowledged = true; response.end("{}") }, 500) + } else if (request.url === "/_internal/grid-transcription/renew") { + response.end(JSON.stringify({ state: "active", allowFinalFlush: false, + leaseExpiresAt: new Date(Date.now() + 15_000).toISOString(), participants: [] })) + } else if (request.url === "/_internal/grid-transcription/heartbeat") response.end("{}") + else response.writeHead(404).end("{}") + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + const address = server.address() + if (!address || typeof address === "string") throw new Error("loopback fixture failed") + try { + daemon = fork(fileURLToPath(new URL("../dist/daemon.js", import.meta.url)), [], { + execPath: nodeExecutable, execArgv: [], env: { GRID_TRANSCRIPTION_API_URL: `http://127.0.0.1:${address.port}`, + GRID_TRANSCRIPTION_WORKER_SECRET: "test-secret", SONIOX_API_KEY: "fixture-key", NODE_ENV: "test" }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }) + const child = daemon + const code = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { child.kill("SIGKILL"); reject(new Error("daemon shutdown fixture deadline")) }, 25_000) + child.once("error", (error) => { clearTimeout(timeout); reject(error) }) + child.once("exit", (exitCode) => { + clearTimeout(timeout) + prematureExit = stoppedRequested && !stoppedAcknowledged + resolve(exitCode) + }) + }) + expect(code).toBe(0) + expect(stoppedRequested).toBe(true) + expect(stoppedAcknowledged).toBe(true) + expect(prematureExit).toBe(false) + } finally { + if (daemon && daemon.exitCode === null && daemon.signalCode === null) daemon.kill("SIGKILL") + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + } + }, 30_000) +}) diff --git a/server/packages/grid-transcription/src/daemon.ts b/server/packages/grid-transcription/src/daemon.ts new file mode 100644 index 000000000..ab1dda958 --- /dev/null +++ b/server/packages/grid-transcription/src/daemon.ts @@ -0,0 +1,231 @@ +import { randomUUID } from "node:crypto" +import { fileURLToPath } from "node:url" +import { LEASE_MS, RUN_DURATION_MS, WorkerControlClient, RunControlClient, type ClaimedRun } from "./api.js" +import { TranscriptionError, type Model } from "./protocol.js" +import { SileroModel } from "./silero.js" +import { NativeRoomSupervisor, type NativeExitReceipt, type StopReason } from "./supervisor.js" +import { monotonicMilliseconds } from "./watchdog.js" + +export type WorkerConfig = Readonly<{ apiBaseUrl: string; sharedSecret: string; workerId: string; model: Model; apiKey?: string }> +export function workerConfig(environment: Readonly>): WorkerConfig { + const model = environment.GRID_TRANSCRIPTION_MODEL ?? "meeting" + if (model !== "meeting" && model !== "standard") throw new TranscriptionError("protocol") + const apiBaseUrl = environment.GRID_TRANSCRIPTION_API_URL + const sharedSecret = environment.GRID_TRANSCRIPTION_WORKER_SECRET + const label = environment.GRID_TRANSCRIPTION_WORKER_ID ?? "grid-worker" + const apiKey = model === "meeting" ? environment.SONIOX_API_KEY : environment.OPENAI_API_KEY + if (!apiBaseUrl || !sharedSecret || sharedSecret.length > 4096 || /[\r\n]/.test(sharedSecret) + || !/^[a-zA-Z0-9_-]{1,40}$/.test(label) + || (apiKey !== undefined && (apiKey.length > 4096 || /[\r\n]/.test(apiKey)))) throw new TranscriptionError("protocol") + // Every boot gets distinct authority. Only transport retries within this boot may reclaim its lost claim response. + return Object.freeze({ apiBaseUrl, sharedSecret, workerId: `${label}-${randomUUID()}`, model, + ...(apiKey ? { apiKey } : {}) }) +} + +type OwnedRun = { run?: ClaimedRun; api: RunControlClient; expiresAtMs: number; exit?: Promise; + reported: Promise; stopReason?: string; acknowledging?: Promise; renewing: boolean } +export class WorkerDaemon { + private readonly control: WorkerControlClient + private readonly supervisor: NativeRoomSupervisor + private current: OwnedRun | undefined + private polling = false + private claimPromise: Promise | undefined + private claimUncertain = false + private heartbeating = false + private closing = false + private ready = false + private claimTimer: NodeJS.Timeout | undefined + private heartbeatTimer: NodeJS.Timeout | undefined + private renewTimer: NodeJS.Timeout | undefined + private shutdownPromise: Promise | undefined + + constructor(private readonly config: WorkerConfig) { + this.control = new WorkerControlClient(config.apiBaseUrl, config.sharedSecret, config.workerId) + this.supervisor = new NativeRoomSupervisor({ roomChildPath: new URL("./room-child.js", import.meta.url), + // Secrets arrive only over IPC. Avoid inherited debug flags, NODE_OPTIONS and credential environment. + environment: { NODE_ENV: "production" } }) + } + async start(): Promise { + // Verify the vendored tensor contract and native runtime before advertising capture availability. + const model = await SileroModel.load() + await model.close() + if (this.closing) return + this.ready = !!this.config.apiKey + await this.heartbeat() + this.claimTimer = setInterval(() => { void this.claim() }, 2000) + this.heartbeatTimer = setInterval(() => { void this.heartbeat() }, 5000) + this.renewTimer = setInterval(() => { void this.renew() }, 1000) + await this.claim() + } + private async heartbeat(): Promise { + if (this.heartbeating) return + this.heartbeating = true + try { await this.control.heartbeat(this.config.model, this.ready && !this.closing) } + catch { + // API loss removes availability and revokes any native owner immediately. + if (this.current && this.supervisor.hasActiveChild) void this.stopCurrent("authority").catch(() => undefined) + } finally { + const owned = this.current + if (owned?.stopReason) await this.acknowledgeStopped(owned) + this.heartbeating = false + } + } + private async claim(): Promise { + if (this.closing || !this.ready || this.polling || this.current || this.supervisor.hasActiveChild) return + this.polling = true + const operation = this.performClaim() + this.claimPromise = operation + try { await operation } finally { + if (this.claimPromise === operation) this.claimPromise = undefined + this.polling = false + } + } + private async performClaim(): Promise { + try { + const requestedAtMs = monotonicMilliseconds() + const requestedAtWallMs = Date.now() + this.claimUncertain = true + const run = await this.control.claim() + this.claimUncertain = false + if (!run) return + const api = new RunControlClient(this.config.apiBaseUrl, run.runToken) + if ("stopImmediately" in run) { + const owned: OwnedRun = { api, expiresAtMs: 0, reported: Promise.resolve(), renewing: false, stopReason: "authority" } + this.current = owned + owned.reported = this.acknowledgeStopped(owned).then(() => undefined) + await owned.reported + return + } + const expiresAtMs = Math.min(requestedAtMs + RUN_DURATION_MS, + requestedAtMs + Date.parse(run.expiresAt) - requestedAtWallMs) + const owned: OwnedRun = { run, api, expiresAtMs, reported: Promise.resolve(), renewing: false } + this.current = owned + if (this.closing || run.model !== this.config.model || !this.config.apiKey) { + // No native transport has been allocated. This is a truthful no-child receipt. + owned.stopReason = "configuration" + owned.reported = this.acknowledgeStopped(owned).then(() => undefined) + await owned.reported + return + } + const identity = { runId: run.runId, claimEpoch: run.claimEpoch, generation: run.generation } + const ready = this.supervisor.start({ identity, requestedAtMs, leaseMs: run.leaseMs, expiresAtMs, + payload: { run, apiBaseUrl: this.config.apiBaseUrl, apiKey: this.config.apiKey, expiresAtMs } }) + const exit = this.supervisor.exitReceipt + if (!exit) throw new TranscriptionError("protocol") + owned.exit = exit + owned.reported = this.observeExit(owned) + await ready + } catch { + if (this.supervisor.hasActiveChild) void this.stopCurrent("authority").catch(() => undefined) + else if (this.current && !this.current.exit) { + const owned = this.current + owned.stopReason = "startup" + owned.reported = this.acknowledgeStopped(owned).then(() => undefined) + await owned.reported + } + } + } + private async renew(): Promise { + const owned = this.current + const run = owned?.run + if (!owned || !run || owned.renewing || !this.supervisor.admitting || this.closing) return + owned.renewing = true + try { + const requestedAtMs = monotonicMilliseconds() + const requestedAtWallMs = Date.now() + const renewed = await owned.api.renew() + if (this.current !== owned || !this.supervisor.admitting || this.closing) return + if (renewed.state !== "active") { + await this.stopCurrent(renewed.state === "stopping" && renewed.allowFinalFlush ? "stopping" : "authority") + return + } + const leaseMs = Math.min(LEASE_MS, Date.parse(renewed.leaseExpiresAt) - requestedAtWallMs) + if (!Number.isSafeInteger(leaseMs) || leaseMs <= 0) throw new TranscriptionError("expired") + this.supervisor.renew({ runId: run.runId, claimEpoch: run.claimEpoch, generation: run.generation }, + requestedAtMs, leaseMs, { participants: renewed.participants }) + } catch { if (this.current === owned) await this.stopCurrent("authority").catch(() => undefined) } + finally { owned.renewing = false } + } + private async stopCurrent(reason: StopReason): Promise { + if (!this.current || !this.supervisor.hasActiveChild) return + // This call closes the synchronous supervisor admission fence before waiting. + await this.supervisor.stop(reason) + } + private async observeExit(owned: OwnedRun): Promise { + try { + const receipt = await owned.exit + if (!receipt) throw new TranscriptionError("protocol") + // An OS receipt from the watchdog proves real native transport containment. + // Server acknowledgement is idempotent and is required before this daemon claims another run. + owned.stopReason = receipt.reason + await this.acknowledgeStopped(owned) + } catch { + // Unknown exit keeps both the process slot and this run quarantined. + this.ready = false + await this.heartbeat() + } + } + private async acknowledgeStopped(owned: OwnedRun): Promise { + if (!owned.stopReason) return false + if (owned.acknowledging) return owned.acknowledging + const operation = (async () => { + for (let attempt = 0; attempt < 2; attempt++) { + try { + await owned.api.stopped(owned.stopReason!) + if (this.current === owned) this.current = undefined + this.ready = !!this.config.apiKey && !this.closing + return true + } catch { /* Retain the same known receipt for the existing heartbeat cadence. */ } + } + this.ready = false + return false + })() + owned.acknowledging = operation + try { return await operation } finally { if (owned.acknowledging === operation) owned.acknowledging = undefined } + } + shutdown(): Promise { + if (this.shutdownPromise) return this.shutdownPromise + this.closing = true + this.ready = false + clearInterval(this.claimTimer) + clearInterval(this.heartbeatTimer) + clearInterval(this.renewTimer) + this.shutdownPromise = this.finishShutdown() + return this.shutdownPromise + } + private async finishShutdown(): Promise { + await this.heartbeat() + // A claim already sent can allocate server authority before its response arrives. + // Stop an already-known native owner now, then wait for the claim's late no-child receipt. + if (this.current && this.supervisor.hasActiveChild) void this.stopCurrent("shutdown").catch(() => undefined) + await this.claimPromise + if (this.claimUncertain && !this.current && !this.supervisor.hasActiveChild) { + // Only this boot's existing claim can be recovered after ready=false. + // The API refuses new allocation to an unavailable worker. + for (let attempt = 0; attempt < 2 && this.claimUncertain && !this.current; attempt++) await this.performClaim() + if (this.claimUncertain) throw new TranscriptionError("provider") + } + const owned = this.current + if (owned && this.supervisor.hasActiveChild) await this.stopCurrent("shutdown") + if (owned) { + await owned.exit + await owned.reported + if (this.current === owned) throw new TranscriptionError("provider") + } + } +} + +export async function runDaemon(): Promise { + try { + const daemon = new WorkerDaemon(workerConfig(process.env)) + process.on("SIGTERM", () => { void daemon.shutdown().then(() => process.exit(0)).catch(() => process.exit(1)) }) + process.on("SIGINT", () => { void daemon.shutdown().then(() => process.exit(0)).catch(() => process.exit(1)) }) + await daemon.start() + // Coarse state only. No provider errors, room metadata, audio, text or credentials are logged. + process.stdout.write("Grid transcription worker initialized\n") + } catch { + process.stderr.write("Grid transcription worker unavailable\n") + process.exitCode = 1 + } +} +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) void runDaemon() diff --git a/server/packages/grid-transcription/src/index.ts b/server/packages/grid-transcription/src/index.ts new file mode 100644 index 000000000..c5101dc97 --- /dev/null +++ b/server/packages/grid-transcription/src/index.ts @@ -0,0 +1,4 @@ +export { CaptureAuthority, type CaptureIdentity, type MicrophoneGrant, type Publication } from "./authority.js" +export { BoundedPcmQueue, pcm16le } from "./pcm.js" +export { ProviderConnection, type ProviderOptions } from "./socket.js" +export { TranscriptionError, type FinalTurn, type Model } from "./protocol.js" diff --git a/server/packages/grid-transcription/src/pcm.test.ts b/server/packages/grid-transcription/src/pcm.test.ts new file mode 100644 index 000000000..7432840c4 --- /dev/null +++ b/server/packages/grid-transcription/src/pcm.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test" +import { BoundedPcmQueue, pcm16le } from "./pcm.js" + +describe("bounded native audio handoff", () => { + test("PCM serializes signed samples in little endian and respects an offset view", () => { + const backing = new Int16Array([99, -1, 0x1234, -32768, 99]) + expect([...pcm16le(backing.subarray(1, 4))]).toEqual([255, 255, 52, 18, 0, 128]) + }) + test("producer buffers are copied and a slow consumer has a finite failure bound", async () => { + const queue = new BoundedPcmQueue(4) + const producer = new Int16Array([1, 2]) + queue.push(producer) + producer.fill(9) + expect([...(await queue.take())!]).toEqual([1, 2]) + queue.push(new Int16Array([3, 4, 5])) + expect(() => queue.push(new Int16Array([6, 7]))).toThrow("overflow") + expect(queue.bufferedSamples).toBe(0) + await expect(queue.take()).rejects.toThrow("overflow") + }) + test("stop discards queued PCM and wakes an idle reader", async () => { + const queue = new BoundedPcmQueue(4) + const waiting = queue.take() + queue.close() + expect(await waiting).toBeUndefined() + expect(() => queue.push(new Int16Array([1]))).toThrow("stopped") + const queued = new BoundedPcmQueue(4) + queued.push(new Int16Array([1, 2])) + queued.close() + expect(await queued.take()).toBeUndefined() + }) + test("one consumer receives frames immediately without awaiting provider work", async () => { + const queue = new BoundedPcmQueue(4) + const waiting = queue.take() + queue.push(new Int16Array([4, 5])) + expect([...(await waiting)!]).toEqual([4, 5]) + expect(queue.bufferedSamples).toBe(0) + }) +}) diff --git a/server/packages/grid-transcription/src/pcm.ts b/server/packages/grid-transcription/src/pcm.ts new file mode 100644 index 000000000..4cde77bbb --- /dev/null +++ b/server/packages/grid-transcription/src/pcm.ts @@ -0,0 +1,74 @@ +import { TranscriptionError } from "./protocol.js" + +export function pcm16le(samples: Int16Array): Uint8Array { + if (samples.length === 0 || samples.length > 48_000) throw new TranscriptionError("audio") + const bytes = new Uint8Array(samples.byteLength) + const view = new DataView(bytes.buffer) + for (let i = 0; i < samples.length; i++) view.setInt16(i * 2, samples[i]!, true) + return bytes +} + +// Read native frames promptly into this queue. Provider I/O belongs to a separate consumer. +export class BoundedPcmQueue { + private frames: Int16Array[] = [] + private queuedSamples = 0 + private waiter: { resolve: (frame: Int16Array | undefined) => void; reject: (error: Error) => void } | undefined + private closed = false + private failure: Error | undefined + + constructor(readonly maxSamples: number) { + if (!Number.isSafeInteger(maxSamples) || maxSamples <= 0 || maxSamples > 48_000 * 10) { + throw new TranscriptionError("audio") + } + } + + push(frame: Int16Array): void { + if (this.closed) throw this.failure ?? new TranscriptionError("stopped") + if (frame.length === 0 || frame.length > this.maxSamples) { + const error = new TranscriptionError("overflow") + this.close(error) + throw error + } + if (this.queuedSamples + frame.length > this.maxSamples) { + const error = new TranscriptionError("overflow") + this.close(error) + throw error + } + const owned = frame.slice() + if (this.waiter) { + const waiter = this.waiter + this.waiter = undefined + waiter.resolve(owned) + } else { + this.frames.push(owned) + this.queuedSamples += owned.length + } + } + + take(): Promise { + if (this.failure) return Promise.reject(this.failure) + const frame = this.frames.shift() + if (frame) { + this.queuedSamples -= frame.length + return Promise.resolve(frame) + } + if (this.closed) return Promise.resolve(undefined) + if (this.waiter) return Promise.reject(new TranscriptionError("protocol")) + return new Promise((resolve, reject) => { this.waiter = { resolve, reject } }) + } + + // Stop discards unsent audio; accepted provider speech is flushed by its own bounded owner. + close(failure?: Error): void { + if (this.closed) return + this.closed = true + this.failure = failure + this.frames = [] + this.queuedSamples = 0 + const waiter = this.waiter + this.waiter = undefined + if (failure) waiter?.reject(failure) + else waiter?.resolve(undefined) + } + + get bufferedSamples(): number { return this.queuedSamples } +} diff --git a/server/packages/grid-transcription/src/protocol.ts b/server/packages/grid-transcription/src/protocol.ts new file mode 100644 index 000000000..90e0089ff --- /dev/null +++ b/server/packages/grid-transcription/src/protocol.ts @@ -0,0 +1,34 @@ +export type Model = "standard" | "meeting" + +export type FinalTurn = Readonly<{ turnId: string; text: string }> + +export class TranscriptionError extends Error { + constructor(readonly code: "protocol" | "provider" | "overflow" | "stopped" | "expired" | "audio") { + super(`Grid transcription ${code}`) + this.name = "TranscriptionError" + } +} + +// A decoder is track/socket scoped. IDs are local admitted turns, never model speaker IDs. +export interface TurnDecoder { + beginTurn(turnId: string): void + accept(event: unknown): readonly FinalTurn[] + close(): void +} + +export function record(value: unknown): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new TranscriptionError("protocol") + } + return value as Record +} + +export function nonemptyString(value: unknown): string { + if (typeof value !== "string" || value.length === 0 || value.length > 256) { + throw new TranscriptionError("protocol") + } + return value +} + +export const MAX_PENDING_TURNS = 2 +export const MAX_TURN_TEXT_BYTES = 32 * 1024 diff --git a/server/packages/grid-transcription/src/providers/openai.test.ts b/server/packages/grid-transcription/src/providers/openai.test.ts new file mode 100644 index 000000000..631c04696 --- /dev/null +++ b/server/packages/grid-transcription/src/providers/openai.test.ts @@ -0,0 +1,203 @@ +import { Buffer } from "node:buffer" +import { describe, expect, test } from "bun:test" +import { MAX_PENDING_TURNS, MAX_TURN_TEXT_BYTES, TranscriptionError } from "../protocol.js" +import { + OPENAI_MAX_SOCKET_TURNS, + OpenAITurnDecoder, + openAIAppendAudio, + openAICommit, + openAIConfiguration, + openAIConfigurationAccepted, +} from "./openai.js" + +// Public event shapes: https://developers.openai.com/api/reference/resources/realtime/server-events +const committed = (itemId: string, previousItemId: string | null = null) => ({ + type: "input_audio_buffer.committed", + event_id: `ack_${itemId}`, + item_id: itemId, + previous_item_id: previousItemId, +}) +const completed = (itemId: string, transcript: string) => ({ + type: "conversation.item.input_audio_transcription.completed", + event_id: `final_${itemId}`, + item_id: itemId, + content_index: 0, + transcript, + usage: { type: "duration", seconds: 1.6 }, +}) + +function expectFailure(action: () => unknown, code: TranscriptionError["code"]) { + let caught: unknown + try { + action() + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(TranscriptionError) + if (!(caught instanceof TranscriptionError)) throw new Error("Expected safe transcription failure") + expect(caught.code).toBe(code) + expect(caught.message).toBe(`Grid transcription ${code}`) +} + +describe("OpenAI committed-turn wire", () => { + test("uses dedicated transcription, manual commits, automatic languages and PCM24k", () => { + expect(openAIConfiguration()).toEqual({ + type: "session.update", + session: { + type: "transcription", + audio: { + input: { + format: { type: "audio/pcm", rate: 24_000 }, + transcription: { model: "gpt-transcribe" }, + turn_detection: null, + }, + }, + }, + }) + const pcm = Uint8Array.of(0, 128, 255, 127) + const append = openAIAppendAudio(pcm) + expect(append.type).toBe("input_audio_buffer.append") + expect(Buffer.from(append.audio, "base64")).toEqual(Buffer.from(pcm)) + expect(openAICommit()).toEqual({ type: "input_audio_buffer.commit" }) + expectFailure(() => openAIAppendAudio(Uint8Array.of(1)), "audio") + expectFailure(() => openAIAppendAudio(new Uint8Array()), "audio") + expectFailure(() => openAIAppendAudio(new Uint8Array(65_538)), "audio") + }) + + test("requires the matching effective configuration before accepting session readiness", () => { + const requested = openAIConfiguration().session + const echo = { type: "session.updated", session: { ...requested, id: "sess_public_fixture" } } + expect(openAIConfigurationAccepted(echo)).toBe(true) + expect(openAIConfigurationAccepted({ type: "session.created", session: requested })).toBe(false) + const input = requested.audio.input + const invalidSessions = [ + { ...requested, type: "realtime" }, + { ...requested, audio: { input: { ...input, format: { type: "audio/pcmu", rate: 24_000 } } } }, + { ...requested, audio: { input: { ...input, format: { type: "audio/pcm", rate: 16_000 } } } }, + { ...requested, audio: { input: { ...input, transcription: { model: "gpt-live-transcribe" } } } }, + { ...requested, audio: { input: { ...input, turn_detection: { type: "server_vad" } } } }, + { ...requested, audio: { input: { ...input, turn_detection: undefined } } }, + { ...requested, audio: {} }, + { ...requested, audio: { input: { ...input, transcription: null } } }, + ] + for (const session of invalidSessions) { + expectFailure(() => openAIConfigurationAccepted({ type: "session.updated", session }), "protocol") + } + expectFailure(() => openAIConfigurationAccepted({ type: "session.updated" }), "protocol") + }) + + test("ignores partial text and preserves local order when provider finals arrive out of order", () => { + const decoder = new OpenAITurnDecoder() + decoder.beginTurn("local_1") + decoder.beginTurn("local_2") + expect(decoder.accept(committed("item_1"))).toEqual([]) + expect(decoder.accept(committed("item_2", "item_1"))).toEqual([]) + expect(decoder.accept({ + type: "conversation.item.input_audio_transcription.delta", + item_id: "item_1", + content_index: 0, + delta: "wrong provisional text", + })).toEqual([]) + expect(decoder.accept(completed("item_2", "second"))).toEqual([]) + expect(decoder.accept(completed("item_1", "first"))).toEqual([ + { turnId: "local_1", text: "first" }, + { turnId: "local_2", text: "second" }, + ]) + decoder.beginTurn("local_3") + expect(decoder.accept(completed("item_1", "stale duplicate"))).toEqual([]) + expect(decoder.accept(committed("item_2", "item_1"))).toEqual([]) + expect(decoder.accept(committed("item_3", "item_2"))).toEqual([]) + expect(decoder.accept(completed("item_3", "third"))).toEqual([{ turnId: "local_3", text: "third" }]) + }) + + test("buffers a final that precedes its commit acknowledgement without guessing identity", () => { + const decoder = new OpenAITurnDecoder() + decoder.beginTurn("first") + decoder.beginTurn("second") + expect(decoder.accept(completed("item_2", "second text"))).toEqual([]) + expect(decoder.accept(completed("item_1", "first text"))).toEqual([]) + expect(decoder.accept(committed("item_1"))).toEqual([{ turnId: "first", text: "first text" }]) + expect(decoder.accept(committed("item_2", "item_1"))).toEqual([{ turnId: "second", text: "second text" }]) + }) + + test("does not revise a final blocked behind another turn", () => { + const decoder = new OpenAITurnDecoder() + decoder.beginTurn("first") + decoder.beginTurn("second") + decoder.accept(committed("item_1")) + decoder.accept(committed("item_2", "item_1")) + decoder.accept(completed("item_2", "original")) + decoder.accept(completed("item_2", "repeated or stale")) + expect(decoder.accept(completed("item_1", "first"))).toEqual([ + { turnId: "first", text: "first" }, + { turnId: "second", text: "original" }, + ]) + }) + + test("accepts optional predecessors but rejects a conflicting acknowledged chain", () => { + const decoder = new OpenAITurnDecoder() + decoder.beginTurn("one") + expect(decoder.accept({ type: "input_audio_buffer.committed", item_id: "one" })).toEqual([]) + decoder.beginTurn("two") + expectFailure(() => decoder.accept(committed("two", "unknown")), "protocol") + expectFailure(() => decoder.accept(completed("one", "late")), "stopped") + }) + + test("safe provider errors stop decoding without leaking raw error text", () => { + for (const event of [ + { type: "error", error: { message: "SECRET audio and provider detail" } }, + { type: "conversation.item.input_audio_transcription.failed", error: { message: "SECRET" } }, + ]) { + const decoder = new OpenAITurnDecoder() + decoder.beginTurn("turn") + expectFailure(() => decoder.accept(event), "provider") + expectFailure(() => decoder.beginTurn("next"), "stopped") + } + }) + + test("rejects unadmitted/malformed finals and acknowledgement reordering", () => { + for (const event of [null, [], { type: "unexpected" }, completed("unknown", "text")]) { + const decoder = new OpenAITurnDecoder() + expectFailure(() => decoder.accept(event), "protocol") + } + const decoder = new OpenAITurnDecoder() + decoder.beginTurn("local") + expectFailure(() => decoder.accept({ ...completed("item", "text"), content_index: 1 }), "protocol") + const reordered = new OpenAITurnDecoder() + reordered.beginTurn("first") + reordered.beginTurn("second") + expectFailure(() => reordered.accept(committed("item_2", "item_1")), "protocol") + }) + + test("bounds pending turns, early finals, UTF8 text and duplicate history without eviction", () => { + const pending = new OpenAITurnDecoder() + for (let index = 0; index < MAX_PENDING_TURNS; index++) pending.beginTurn(`turn_${index}`) + expectFailure(() => pending.beginTurn("overflow"), "overflow") + const early = new OpenAITurnDecoder() + early.beginTurn("only") + early.accept(completed("item_a", "first")) + expectFailure(() => early.accept(completed("item_b", "second")), "overflow") + const text = new OpenAITurnDecoder() + text.beginTurn("text") + expectFailure(() => text.accept(completed("text", "界".repeat(Math.ceil(MAX_TURN_TEXT_BYTES / 3)))), "overflow") + const history = new OpenAITurnDecoder() + for (let index = 0; index < OPENAI_MAX_SOCKET_TURNS; index++) { + history.beginTurn(`local_${index}`) + history.accept(committed(`item_${index}`, index === 0 ? null : `item_${index - 1}`)) + history.accept(completed(`item_${index}`, "text")) + } + expect(history.accept(completed("item_0", "old duplicate"))).toEqual([]) + expectFailure(() => history.beginTurn("history_overflow"), "overflow") + }) + + test("retires empty transcripts and close fences all later input", () => { + const decoder = new OpenAITurnDecoder() + decoder.beginTurn("silent") + decoder.accept(committed("silent")) + expect(decoder.accept(completed("silent", " \n"))).toEqual([{ turnId: "silent", text: "" }]) + expectFailure(() => decoder.beginTurn("silent"), "protocol") + decoder.close() + decoder.close() + expectFailure(() => decoder.accept(completed("silent", "late")), "stopped") + }) +}) diff --git a/server/packages/grid-transcription/src/providers/openai.ts b/server/packages/grid-transcription/src/providers/openai.ts new file mode 100644 index 000000000..127a6c678 --- /dev/null +++ b/server/packages/grid-transcription/src/providers/openai.ts @@ -0,0 +1,196 @@ +import { Buffer } from "node:buffer" +import { + MAX_PENDING_TURNS, + MAX_TURN_TEXT_BYTES, + TranscriptionError, + nonemptyString, + record, + type FinalTurn, + type TurnDecoder, +} from "../protocol.js" + +export const OPENAI_URL = "wss://api.openai.com/v1/realtime?intent=transcription" +export const OPENAI_SAMPLE_RATE = 24_000 +// Retain IDs for the entire socket; eviction could mistake an old final for a new turn. +export const OPENAI_MAX_SOCKET_TURNS = 1024 +const MAX_AUDIO_FRAME_BYTES = 64 * 1024 + +// https://developers.openai.com/api/docs/guides/realtime-transcription#transcribe-a-committed-turn +export function openAIConfiguration() { + return { + type: "session.update", + session: { + type: "transcription", + audio: { + input: { + format: { type: "audio/pcm", rate: OPENAI_SAMPLE_RATE }, + transcription: { model: "gpt-transcribe" }, + turn_detection: null, + }, + }, + }, + } +} + +// Only a matching effective configuration makes the socket ready for microphone audio. +export function openAIConfigurationAccepted(value: unknown): boolean { + const event = record(value) + if (event.type !== "session.updated") return false + const session = record(event.session) + const input = record(record(session.audio).input) + const format = record(input.format) + const transcription = record(input.transcription) + if (session.type !== "transcription" || format.type !== "audio/pcm" + || format.rate !== OPENAI_SAMPLE_RATE || transcription.model !== "gpt-transcribe" + || input.turn_detection !== null) { + throw new TranscriptionError("protocol") + } + return true +} + +export function openAIAppendAudio(pcm: Uint8Array) { + if (pcm.byteLength === 0 || pcm.byteLength % 2 !== 0 || pcm.byteLength > MAX_AUDIO_FRAME_BYTES) { + throw new TranscriptionError("audio") + } + return { type: "input_audio_buffer.append", audio: Buffer.from(pcm).toString("base64") } +} + +export function openAICommit() { + return { type: "input_audio_buffer.commit" } +} + +type PendingTurn = { turnId: string; itemId?: string; text?: string } + +export class OpenAITurnDecoder implements TurnDecoder { + private closed = false + private readonly turns: PendingTurn[] = [] + private readonly localIds = new Set() + private readonly itemIds = new Set() + private readonly earlyFinals = new Map() + private lastItemId: string | null = null + + beginTurn(turnId: string): void { + this.assertOpen() + try { + nonemptyString(turnId) + if (this.localIds.has(turnId)) throw new TranscriptionError("protocol") + if (this.turns.length >= MAX_PENDING_TURNS || this.localIds.size >= OPENAI_MAX_SOCKET_TURNS) { + throw new TranscriptionError("overflow") + } + this.localIds.add(turnId) + this.turns.push({ turnId }) + } catch (error) { + this.fail(error) + } + } + + accept(value: unknown): readonly FinalTurn[] { + this.assertOpen() + try { + const event = record(value) + const type = nonemptyString(event.type) + switch (type) { + case "error": + case "conversation.item.input_audio_transcription.failed": + throw new TranscriptionError("provider") + case "input_audio_buffer.committed": + return this.committed(event) + case "conversation.item.input_audio_transcription.completed": + return this.completed(event) + case "session.created": + case "session.updated": + case "transcription_session.created": + case "transcription_session.updated": + case "conversation.created": + case "conversation.item.created": + case "conversation.item.added": + case "conversation.item.done": + case "conversation.item.input_audio_transcription.delta": + case "conversation.item.input_audio_transcription.segment": + case "rate_limits.updated": + return [] + default: + throw new TranscriptionError("protocol") + } + } catch (error) { + return this.fail(error) + } + } + + close(): void { + this.closed = true + this.turns.length = 0 + this.localIds.clear() + this.itemIds.clear() + this.earlyFinals.clear() + this.lastItemId = null + } + + private committed(event: Record): readonly FinalTurn[] { + const itemId = nonemptyString(event.item_id) + const previous = event.previous_item_id + if (previous !== undefined && previous !== null) nonemptyString(previous) + if (this.itemIds.has(itemId)) return [] + // Manual commits are admitted FIFO. A conflicting predecessor makes that mapping unsafe. + if (previous !== undefined && previous !== this.lastItemId) throw new TranscriptionError("protocol") + const turn = this.turns.find((candidate) => candidate.itemId === undefined) + if (!turn) throw new TranscriptionError("protocol") + if (this.itemIds.size >= OPENAI_MAX_SOCKET_TURNS) throw new TranscriptionError("overflow") + turn.itemId = itemId + this.itemIds.add(itemId) + this.lastItemId = itemId + const early = this.earlyFinals.get(itemId) + if (early !== undefined) { + turn.text = early + this.earlyFinals.delete(itemId) + } + if (this.earlyFinals.size > this.turns.filter((candidate) => candidate.itemId === undefined).length) { + throw new TranscriptionError("protocol") + } + return this.drain() + } + + private completed(event: Record): readonly FinalTurn[] { + const itemId = nonemptyString(event.item_id) + if (event.content_index !== 0 || typeof event.transcript !== "string") { + throw new TranscriptionError("protocol") + } + if (event.transcript.length > MAX_TURN_TEXT_BYTES || Buffer.byteLength(event.transcript) > MAX_TURN_TEXT_BYTES) { + throw new TranscriptionError("overflow") + } + const turn = this.turns.find((candidate) => candidate.itemId === itemId) + if (turn) { + // A duplicate final cannot revise an already accepted final, even while blocked behind an earlier turn. + if (turn.text === undefined) turn.text = event.transcript + return this.drain() + } + if (this.itemIds.has(itemId) || this.earlyFinals.has(itemId)) return [] + const unacknowledged = this.turns.filter((candidate) => candidate.itemId === undefined).length + if (unacknowledged === 0) throw new TranscriptionError("protocol") + if (this.earlyFinals.size >= unacknowledged || this.earlyFinals.size >= MAX_PENDING_TURNS) { + throw new TranscriptionError("overflow") + } + this.earlyFinals.set(itemId, event.transcript) + return [] + } + + private drain(): readonly FinalTurn[] { + const result: FinalTurn[] = [] + while (this.turns[0]?.text !== undefined) { + const turn = this.turns.shift() + if (!turn || turn.text === undefined) throw new TranscriptionError("protocol") + const text = turn.text.trim() + result.push({ turnId: turn.turnId, text }) + } + return result + } + + private assertOpen(): void { + if (this.closed) throw new TranscriptionError("stopped") + } + + private fail(error: unknown): never { + this.close() + throw error instanceof TranscriptionError ? error : new TranscriptionError("protocol") + } +} diff --git a/server/packages/grid-transcription/src/providers/soniox.test.ts b/server/packages/grid-transcription/src/providers/soniox.test.ts new file mode 100644 index 000000000..aac984080 --- /dev/null +++ b/server/packages/grid-transcription/src/providers/soniox.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from "bun:test" +import { MAX_PENDING_TURNS, MAX_TURN_TEXT_BYTES, TranscriptionError } from "../protocol.js" +import { + SONIOX_MAX_SOCKET_TURNS, + SonioxTurnDecoder, + sonioxAudio, + sonioxConfiguration, + sonioxFinalize, + sonioxKeepalive, +} from "./soniox.js" + +// Public token contract: https://soniox.com/docs/stt/rt/real-time-transcription +const token = (text: string, is_final = true) => ({ text, is_final }) +const response = (...tokens: Readonly<{ text: string; is_final: boolean }>[]) => ({ tokens }) + +function expectFailure(action: () => unknown, code: TranscriptionError["code"]) { + let caught: unknown + try { + action() + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(TranscriptionError) + if (!(caught instanceof TranscriptionError)) throw new Error("Expected safe transcription failure") + expect(caught.code).toBe(code) + expect(caught.message).toBe(`Grid transcription ${code}`) +} + +describe("Soniox manually finalized wire", () => { + test("uses automatic languages, raw PCM16k and application boundaries", () => { + expect(sonioxConfiguration("test-key")).toEqual({ + api_key: "test-key", + model: "stt-rt-v5", + audio_format: "pcm_s16le", + sample_rate: 16_000, + num_channels: 1, + enable_speaker_diarization: false, + enable_endpoint_detection: false, + }) + expect(sonioxFinalize()).toEqual({ type: "finalize" }) + expect(sonioxKeepalive()).toEqual({ type: "keepalive" }) + const pcm = Uint8Array.of(0, 128, 255, 127) + const sent = sonioxAudio(pcm) + pcm.fill(0) + expect(sent).toEqual(Uint8Array.of(0, 128, 255, 127)) + expectFailure(() => sonioxAudio(Uint8Array.of(1)), "audio") + expectFailure(() => sonioxAudio(new Uint8Array()), "audio") + expectFailure(() => sonioxAudio(new Uint8Array(65_538)), "audio") + }) + + test("ignores revised provisional text and emits stable tokens only on the manual marker", () => { + const decoder = new SonioxTurnDecoder() + expect(decoder.accept(response(token("How're", false)))).toEqual([]) + expect(decoder.accept(response(token("How", false), token(" are", false)))).toEqual([]) + // The provider may finalize tokens before the application sends its manual finalize command. + expect(decoder.accept(response(token("How"), token(" are", false)))).toEqual([]) + decoder.beginTurn("local_1") + expect(decoder.accept(response(token(" are"), token(" you", false)))).toEqual([]) + expect(decoder.accept(response(token(" you"), token(" doing?"), token("")))).toEqual([ + { turnId: "local_1", text: "How are you doing?" }, + ]) + }) + + test("multiple finalization markers partition one event in admitted FIFO order", () => { + const decoder = new SonioxTurnDecoder() + decoder.beginTurn("one") + decoder.beginTurn("two") + expect(decoder.accept(response( + token("first"), token(""), token("دوم"), token(""), + ))).toEqual([ + { turnId: "one", text: "first" }, + { turnId: "two", text: "دوم" }, + ]) + expectFailure(() => decoder.beginTurn("one"), "protocol") + }) + + test("rejects unsolicited markers and semantic endpoints that contradict manual configuration", () => { + for (const marker of ["", ""]) { + const decoder = new SonioxTurnDecoder() + expectFailure(() => decoder.accept(response(token(marker))), "protocol") + expectFailure(() => decoder.beginTurn("later"), "stopped") + } + }) + + test("safe provider failures expose no error detail or buffered transcript", () => { + const decoder = new SonioxTurnDecoder() + decoder.accept(response(token("private transcript"))) + expectFailure(() => decoder.accept({ + tokens: [], error_code: 503, error_type: "service_unavailable", error_message: "SECRET detail", + }), "provider") + expectFailure(() => decoder.accept(response(token(""))), "stopped") + }) + + test("validates malformed input and discards all state on failure", () => { + for (const event of [ + null, [], {}, { tokens: "text" }, { tokens: [null] }, + { tokens: [{ text: "text", is_final: 1 }] }, { tokens: [{ text: 42, is_final: true }] }, + { tokens: [], finished: "true" }, + ]) { + const decoder = new SonioxTurnDecoder() + expectFailure(() => decoder.accept(event), "protocol") + expectFailure(() => decoder.accept(response()), "stopped") + } + }) + + test("bounds pending turns, cumulative UTF8 text, token arrays and socket ID retention", () => { + const pending = new SonioxTurnDecoder() + for (let index = 0; index < MAX_PENDING_TURNS; index++) pending.beginTurn(`turn_${index}`) + expectFailure(() => pending.beginTurn("overflow"), "overflow") + const text = new SonioxTurnDecoder() + text.accept(response(token("a".repeat(MAX_TURN_TEXT_BYTES - 2)))) + expectFailure(() => text.accept(response(token("界"))), "overflow") + const tokens = new SonioxTurnDecoder() + expectFailure(() => tokens.accept({ tokens: Array.from({ length: MAX_TURN_TEXT_BYTES + 1 }, () => token("")) }), "overflow") + const history = new SonioxTurnDecoder() + for (let index = 0; index < SONIOX_MAX_SOCKET_TURNS; index++) { + history.beginTurn(`local_${index}`) + history.accept(response(token("text"), token(""))) + } + expectFailure(() => history.beginTurn("history_overflow"), "overflow") + }) + + test("emits empty terminal turns and accepts clean end-of-stream only after the final flush", () => { + const decoder = new SonioxTurnDecoder() + decoder.beginTurn("silence") + expect(decoder.accept(response(token(" \n"), token("")))).toEqual([{ turnId: "silence", text: "" }]) + expect(decoder.accept({ tokens: [], final_audio_proc_ms: 1560, total_audio_proc_ms: 1680, finished: true })).toEqual([]) + expectFailure(() => decoder.accept(response(token("late"))), "stopped") + const pending = new SonioxTurnDecoder() + pending.beginTurn("missing_flush") + expectFailure(() => pending.accept({ tokens: [], finished: true }), "protocol") + }) + + test("explicit close is idempotent and fences delayed provider output", () => { + const decoder = new SonioxTurnDecoder() + decoder.accept(response(token("buffered"))) + decoder.close() + decoder.close() + expectFailure(() => decoder.beginTurn("late"), "stopped") + expectFailure(() => decoder.accept(response(token(""))), "stopped") + }) +}) diff --git a/server/packages/grid-transcription/src/providers/soniox.ts b/server/packages/grid-transcription/src/providers/soniox.ts new file mode 100644 index 000000000..e919af6e6 --- /dev/null +++ b/server/packages/grid-transcription/src/providers/soniox.ts @@ -0,0 +1,133 @@ +import { Buffer } from "node:buffer" +import { + MAX_PENDING_TURNS, + MAX_TURN_TEXT_BYTES, + TranscriptionError, + nonemptyString, + record, + type FinalTurn, + type TurnDecoder, +} from "../protocol.js" + +export const SONIOX_URL = "wss://stt-rt.soniox.com/transcribe-websocket" +export const SONIOX_SAMPLE_RATE = 16_000 +export const SONIOX_MAX_SOCKET_TURNS = 1024 +const MAX_AUDIO_FRAME_BYTES = 64 * 1024 + +// https://soniox.com/docs/api-reference/stt/websocket-api +// This initial wire message contains a secret; the transport must never log it. +export function sonioxConfiguration(apiKey: string) { + nonemptyString(apiKey) + return { + api_key: apiKey, + model: "stt-rt-v5", + audio_format: "pcm_s16le", + sample_rate: SONIOX_SAMPLE_RATE, + num_channels: 1, + enable_speaker_diarization: false, + enable_endpoint_detection: false, + } +} + +export function sonioxAudio(pcm: Uint8Array): Uint8Array { + if (pcm.byteLength === 0 || pcm.byteLength % 2 !== 0 || pcm.byteLength > MAX_AUDIO_FRAME_BYTES) { + throw new TranscriptionError("audio") + } + return pcm.slice() +} + +export function sonioxFinalize() { + return { type: "finalize" } +} + +export function sonioxKeepalive() { + return { type: "keepalive" } +} + +export class SonioxTurnDecoder implements TurnDecoder { + private closed = false + private readonly turns: string[] = [] + private readonly localIds = new Set() + private text = "" + private textBytes = 0 + + beginTurn(turnId: string): void { + this.assertOpen() + try { + nonemptyString(turnId) + if (this.localIds.has(turnId)) throw new TranscriptionError("protocol") + if (this.turns.length >= MAX_PENDING_TURNS || this.localIds.size >= SONIOX_MAX_SOCKET_TURNS) { + throw new TranscriptionError("overflow") + } + this.localIds.add(turnId) + this.turns.push(turnId) + } catch (error) { + this.fail(error) + } + } + + // Final tokens can precede beginTurn: admission must occur immediately before sending finalize. + // The provider promises ordered, unrepeated final tokens; real socket qualification remains required. + accept(value: unknown): readonly FinalTurn[] { + this.assertOpen() + try { + const event = record(value) + if (event.error_code !== undefined || event.error_type !== undefined) { + throw new TranscriptionError("provider") + } + if (!Array.isArray(event.tokens) || event.tokens.length > MAX_TURN_TEXT_BYTES) { + throw new TranscriptionError(Array.isArray(event.tokens) ? "overflow" : "protocol") + } + if (event.finished !== undefined && typeof event.finished !== "boolean") { + throw new TranscriptionError("protocol") + } + const result: FinalTurn[] = [] + for (const value of event.tokens) { + const token = record(value) + if (typeof token.is_final !== "boolean" || typeof token.text !== "string") { + throw new TranscriptionError("protocol") + } + if (!token.is_final) continue + if (token.text === "") throw new TranscriptionError("protocol") + if (token.text === "") { + const turnId = this.turns.shift() + if (turnId === undefined) throw new TranscriptionError("protocol") + const text = this.text.trim() + result.push({ turnId, text }) + this.text = "" + this.textBytes = 0 + continue + } + if (token.text.length > MAX_TURN_TEXT_BYTES) throw new TranscriptionError("overflow") + const bytes = Buffer.byteLength(token.text) + if (this.textBytes + bytes > MAX_TURN_TEXT_BYTES) throw new TranscriptionError("overflow") + this.text += token.text + this.textBytes += bytes + } + if (event.finished === true) { + if (this.turns.length > 0 || this.textBytes > 0) throw new TranscriptionError("protocol") + this.close() + } + return result + } catch (error) { + return this.fail(error) + } + } + + close(): void { + this.closed = true + this.turns.length = 0 + this.localIds.clear() + this.text = "" + this.textBytes = 0 + } + + private assertOpen(): void { + if (this.closed) throw new TranscriptionError("stopped") + } + + private fail(error: unknown): never { + this.close() + throw error instanceof TranscriptionError ? error : new TranscriptionError("protocol") + } +} diff --git a/server/packages/grid-transcription/src/qualify.ts b/server/packages/grid-transcription/src/qualify.ts new file mode 100644 index 000000000..3d53efc49 --- /dev/null +++ b/server/packages/grid-transcription/src/qualify.ts @@ -0,0 +1,74 @@ +import { readFile, realpath, stat, writeFile } from "node:fs/promises" +import { basename } from "node:path" +import { setTimeout as pause } from "node:timers/promises" +import { ProviderConnection } from "./socket.js" +import { TranscriptionError, type FinalTurn } from "./protocol.js" + +// Controlled short-file smoke only. No RTC connection, Inline posting, VAD or automatic dotenv load. +async function main(): Promise { + const args = process.argv.slice(2) + if (args.length === 1 && args[0] === "--help") { + process.stdout.write("qualify --model standard|meeting --pcm audio.pcm [--out fresh-result.json]\nPCM must be signed16LE mono at 24k Standard / 16k Meeting, 100ms–12s.\n") + return + } + if (args.length !== 4 && args.length !== 6) throw new TranscriptionError("protocol") + const flags = new Map() + for (let i = 0; i < args.length; i += 2) { + const name = args[i]! + const value = args[i + 1]! + if (!["--model", "--pcm", "--out"].includes(name) || flags.has(name)) throw new TranscriptionError("protocol") + flags.set(name, value) + } + const model = flags.get("--model") + if (model !== "standard" && model !== "meeting") throw new TranscriptionError("protocol") + const out = flags.get("--out") + if (out && (basename(out).startsWith(".env") || !out.endsWith(".json"))) throw new TranscriptionError("protocol") + const pcmPath = flags.get("--pcm") + if (!pcmPath || basename(pcmPath).startsWith(".env") || !pcmPath.endsWith(".pcm")) throw new TranscriptionError("audio") + const resolved = await realpath(pcmPath) + if (basename(resolved).startsWith(".env") || !resolved.endsWith(".pcm")) throw new TranscriptionError("audio") + const sampleRate = model === "standard" ? 24_000 : 16_000 + const size = (await stat(resolved)).size + if (size % 2 !== 0 || size < sampleRate * 2 / 10 || size > sampleRate * 2 * 12) throw new TranscriptionError("audio") + const bytes = await readFile(resolved) + // Recheck after the read in case the caller replaced/resized the lab file. + if (bytes.length !== size) throw new TranscriptionError("audio") + const samples = new Int16Array(bytes.length / 2) + for (let i = 0; i < samples.length; i++) samples[i] = bytes.readInt16LE(i * 2) + const key = process.env[model === "standard" ? "GRID_TRANSCRIPTION_OPENAI_API_KEY" : "GRID_TRANSCRIPTION_SONIOX_API_KEY"] + if (!key) throw new TranscriptionError("provider") + const deadline = performance.now() + 40_000 + let resolveFinal!: (turn: FinalTurn) => void + let rejectFinal!: (error: Error) => void + const final = new Promise((resolve, reject) => { resolveFinal = resolve; rejectFinal = reject }) + // A connection failure may arrive before the caller starts awaiting the final. + void final.catch(() => {}) + let connection: ProviderConnection | undefined + let timer: ReturnType | undefined + try { + connection = await ProviderConnection.connect({ model, apiKey: key, + assertAuthority: () => { if (performance.now() >= deadline) throw new TranscriptionError("expired") }, + onFinal: resolveFinal, onFailure: rejectFinal }) + const frameSamples = sampleRate / 50 + for (let offset = 0; offset < samples.length; offset += frameSamples) { + connection.appendAudio(samples.subarray(offset, offset + frameSamples)) + await pause(20) + } + const committedAt = performance.now() + connection.commitTurn("controlled-file") + timer = setTimeout(() => { connection?.close(); rejectFinal(new TranscriptionError("provider")) }, 15_000) + const turn = await final + if (out) { + // Explicit optional local lab artifact only; never overwrite an existing file or log text. + await writeFile(out, JSON.stringify({ model, text: turn.text }, null, 2) + "\n", { flag: "wx", mode: 0o600 }) + } + process.stdout.write(JSON.stringify({ ok: true, model, audioMs: samples.length * 1000 / sampleRate, + finalizeMs: Math.round(performance.now() - committedAt), characters: turn.text.length }) + "\n") + } finally { clearTimeout(timer); connection?.close() } +} + +void main().catch((error: unknown) => { + const code = error instanceof TranscriptionError ? error.code : "qualification" + process.stderr.write(JSON.stringify({ ok: false, error: code }) + "\n") + process.exitCode = 1 +}) diff --git a/server/packages/grid-transcription/src/room-child.ts b/server/packages/grid-transcription/src/room-child.ts new file mode 100644 index 000000000..60c94ca2a --- /dev/null +++ b/server/packages/grid-transcription/src/room-child.ts @@ -0,0 +1,61 @@ +import { fileURLToPath } from "node:url" +import { claimedRun, participants } from "./api.js" +import { TranscriptionError, record } from "./protocol.js" +import { RoomWorker } from "./room-worker.js" +import { monotonicMilliseconds, sameIdentity, validIdentity, validStopReason, type RoomChildMessage } from "./watchdog.js" + +export function runRoomChild(): void { + let worker: RoomWorker | undefined + let started = false + let stopping = false + const send = (message: RoomChildMessage): void => { + if (!process.connected || !process.send) return + try { process.send(message, () => undefined) } catch { /* Watchdog owns final process containment. */ } + } + const stop = (graceful: boolean): void => { + stopping = true + if (!worker) { process.exit(0); return } + void worker.stop(graceful).then(() => { + send({ type: "stopped" }) + process.exit(0) + }).catch(() => { send({ type: "error", code: "native" }); process.exit(1) }) + } + const fail = (error: unknown): void => { + send({ type: "error", code: error instanceof TranscriptionError ? error.code : "internal" }) + stop(false) + } + process.on("message", (value: unknown) => { + try { + const message = record(value) + if (message.type === "stop" && validStopReason(message.reason)) { stop(message.reason === "stopping"); return } + if (stopping) return + if (message.type === "start" && !started && validIdentity(message.identity)) { + started = true + setInterval(() => send({ type: "heartbeat", sentAtMs: monotonicMilliseconds() }), 250) + const payload = record(message.payload) + const run = claimedRun(payload.run) + if (!sameIdentity(message.identity, { runId: run.runId, claimEpoch: run.claimEpoch, generation: run.generation }) + || typeof message.requestedAtMs !== "number" || typeof message.leaseMs !== "number" + || typeof payload.expiresAtMs !== "number" || typeof payload.apiKey !== "string" || !payload.apiKey + || payload.apiKey.length > 4096 || typeof payload.apiBaseUrl !== "string" || !payload.apiBaseUrl) { + throw new TranscriptionError("protocol") + } + if (payload.apiTimeoutMs !== undefined && typeof payload.apiTimeoutMs !== "number") throw new TranscriptionError("protocol") + worker = new RoomWorker({ run, identity: message.identity, requestedAtMs: message.requestedAtMs, + leaseMs: message.leaseMs, expiresAtMs: payload.expiresAtMs, apiBaseUrl: payload.apiBaseUrl, apiKey: payload.apiKey, + apiTimeoutMs: payload.apiTimeoutMs, onFailure: fail }) + void worker.start().then(() => { if (!stopping) send({ type: "ready" }) }).catch((error: unknown) => { + if (!stopping) fail(error) + }) + } else if (message.type === "renew" && worker && validIdentity(message.identity)) { + if (typeof message.requestedAtMs !== "number" || typeof message.leaseMs !== "number") throw new TranscriptionError("protocol") + const payload = record(message.payload) + worker.renew(message.identity, message.requestedAtMs, message.leaseMs, participants(payload.participants)) + } else throw new TranscriptionError("protocol") + } catch (error) { fail(error) } + }) + process.on("disconnect", () => stop(false)) + process.on("SIGTERM", () => stop(false)) + process.on("SIGINT", () => stop(false)) +} +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) runRoomChild() diff --git a/server/packages/grid-transcription/src/room-worker.ts b/server/packages/grid-transcription/src/room-worker.ts new file mode 100644 index 000000000..d3f72e990 --- /dev/null +++ b/server/packages/grid-transcription/src/room-worker.ts @@ -0,0 +1,225 @@ +import { AudioStream, Room, RoomEvent, TrackKind, TrackSource, dispose, + type AudioFrame, type RemoteParticipant, type RemoteTrackPublication, type RemoteTrack } from "@livekit/rtc-node" +import { CaptureAuthority, type CaptureIdentity, type MicrophoneGrant } from "./authority.js" +import { SPEAKER_LIMIT, RunControlClient, type ClaimedRun, type ParticipantGrant } from "./api.js" +import { TranscriptionError } from "./protocol.js" +import { SileroModel } from "./silero.js" +import { TrackWorker, type SpeechConnection } from "./track-worker.js" +import type { ProviderOptions } from "./socket.js" +import { monotonicMilliseconds } from "./watchdog.js" + +type OwnedMicrophone = { + participant: RemoteParticipant; publication: RemoteTrackPublication; track: RemoteTrack; + grant: MicrophoneGrant; worker: TrackWorker; reader: ReadableStreamDefaultReader; + reading: Promise; retirement?: Promise; +} +export type RoomWorkerOptions = Readonly<{ + run: ClaimedRun; identity: CaptureIdentity; requestedAtMs: number; leaseMs: number; expiresAtMs: number; + apiBaseUrl: string; apiKey: string; apiTimeoutMs?: number; + onFailure: (error: TranscriptionError) => void; + // Trusted local qualification injection; the daemon never accepts endpoints over IPC. + connectProvider?: (options: ProviderOptions) => Promise; +}> + +// One native process owns one room. An uncancellable native connect/disconnect is +// contained by the independent watchdog; it is never claimed stopped by a raced timeout. +export class RoomWorker { + private readonly room = new Room() + private readonly authority: CaptureAuthority + private readonly api: RunControlClient + private manifest = new Map() + private readonly microphones = new Map() + private model: SileroModel | undefined + private accepting = true + private stopped = false + private connected: Promise | undefined + private stopPromise: Promise | undefined + + constructor(private readonly options: RoomWorkerOptions) { + this.authority = new CaptureAuthority({ + identity: options.identity, requestedAtMs: options.requestedAtMs, leaseMs: options.leaseMs, + expiresAtMs: options.expiresAtMs, microphones: [], now: monotonicMilliseconds, + }) + this.api = new RunControlClient(options.apiBaseUrl, options.run.runToken, options.apiTimeoutMs) + this.replaceManifest(options.run.participants) + // These handlers must exist before connect can deliver its buffered room events. + this.room.on(RoomEvent.ParticipantConnected, () => this.reconcileSafely()) + this.room.on(RoomEvent.ParticipantDisconnected, () => this.reconcileSafely()) + this.room.on(RoomEvent.TrackPublished, () => this.reconcileSafely()) + this.room.on(RoomEvent.TrackUnpublished, () => this.reconcileSafely()) + this.room.on(RoomEvent.TrackMuted, () => this.reconcileSafely()) + this.room.on(RoomEvent.TrackUnmuted, () => this.reconcileSafely()) + this.room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => { + if (!this.accepting) { publication.setSubscribed(false); return } + try { this.reconcile(); this.attach(track, publication, participant) } catch (error) { this.fail(error) } + }) + this.room.on(RoomEvent.TrackUnsubscribed, (_track, publication) => { + const owned = publication.sid ? this.microphones.get(publication.sid) : undefined + if (owned) this.retire(owned) + }) + this.room.on(RoomEvent.TrackSubscriptionFailed, () => this.fail(new TranscriptionError("audio"))) + // v1 ends capture on a media gap. Voice remains independently owned by clients. + this.room.on(RoomEvent.Reconnecting, () => this.fail(new TranscriptionError("audio"))) + this.room.on(RoomEvent.Disconnected, () => { if (this.accepting) this.fail(new TranscriptionError("audio")) }) + } + + async start(): Promise { + this.authority.assertCurrent(this.options.identity) + const model = await SileroModel.load() + if (!this.accepting) { await model.close(); throw new TranscriptionError("stopped") } + this.model = model + this.authority.assertCurrent(this.options.identity) + this.connected = this.room.connect(this.options.run.livekit.serverUrl, this.options.run.livekit.token, { + autoSubscribe: false, dynacast: false, dataStream: { maxPayloadByteLength: 1024 }, + }) + try { await this.connected } catch { throw new TranscriptionError("audio") } + if (!this.accepting) throw new TranscriptionError("stopped") + this.authority.assertCurrent(this.options.identity) + // Events and this sweep cover both already-published and subsequently-published microphones. + this.reconcile() + } + + renew(identity: CaptureIdentity, requestedAtMs: number, leaseMs: number, manifest: readonly ParticipantGrant[]): void { + if (!this.accepting) throw new TranscriptionError("stopped") + this.authority.renew(identity, requestedAtMs, leaseMs) + this.replaceManifest(manifest) + this.reconcile() + } + private replaceManifest(participants: readonly ParticipantGrant[]): void { + this.manifest = new Map(participants.map((participant) => [participant.identity, participant])) + } + private eligible(publication: RemoteTrackPublication, participant: RemoteParticipant): MicrophoneGrant | undefined { + this.authority.assertCurrent(this.options.identity) + const membership = this.manifest.get(participant.identity) + const sid = publication.sid + if (!membership || !sid || this.room.remoteParticipants.get(participant.identity) !== participant + || participant.trackPublications.get(sid) !== publication || publication.kind !== TrackKind.KIND_AUDIO + || publication.source !== TrackSource.SOURCE_MICROPHONE || publication.muted !== false) return undefined + return { identity: membership.identity, trackSid: sid, speakerUserId: membership.userId, membershipId: membership.membershipId } + } + private reconcileSafely(): void { + if (!this.accepting) return + try { this.reconcile() } catch (error) { this.fail(error) } + } + private reconcile(): void { + if (!this.accepting) return + const publications: { participant: RemoteParticipant; publication: RemoteTrackPublication; grant: MicrophoneGrant }[] = [] + for (const participant of this.room.remoteParticipants.values()) { + for (const publication of participant.trackPublications.values()) { + const grant = this.eligible(publication, participant) + if (grant) publications.push({ participant, publication, grant }) + else if (publication.subscribed) publication.setSubscribed(false) + } + } + if (publications.length > SPEAKER_LIMIT) throw new TranscriptionError("overflow") + this.authority.replaceMicrophones(this.options.identity, publications.map((entry) => entry.grant)) + for (const owned of this.microphones.values()) { + if (!this.isCurrent(owned)) this.retire(owned) + } + for (const entry of publications) { + if (!entry.publication.subscribed) entry.publication.setSubscribed(true) + if (entry.publication.track) this.attach(entry.publication.track, entry.publication, entry.participant) + } + } + private isCurrent(owned: OwnedMicrophone): boolean { + const grant = this.eligible(owned.publication, owned.participant) + return !owned.retirement && !!grant && grant.membershipId === owned.grant.membershipId + && grant.speakerUserId === owned.grant.speakerUserId && owned.publication.track === owned.track + } + private attach(track: RemoteTrack, publication: RemoteTrackPublication, participant: RemoteParticipant): void { + if (!this.accepting || !this.model) return + const grant = this.eligible(publication, participant) + if (!grant || publication.track !== track) { publication.setSubscribed(false); return } + const existing = this.microphones.get(grant.trackSid) + if (existing) { + if (existing.track !== track || existing.publication !== publication) throw new TranscriptionError("protocol") + return + } + // Retiring tracks count until their provider work is drained, bounding track churn too. + if (this.microphones.size >= SPEAKER_LIMIT) throw new TranscriptionError("overflow") + const sampleRate = this.options.run.model === "meeting" ? 16_000 : 24_000 + const reader = new AudioStream(track, { sampleRate, numChannels: 1, frameSizeMs: 20 }).getReader() + let owned: OwnedMicrophone | undefined + const worker = new TrackWorker({ + model: this.options.run.model, apiKey: this.options.apiKey, + participantIdentity: grant.identity, trackSid: grant.trackSid, api: this.api, + detector: this.model.detector(sampleRate), + ...(this.options.connectProvider ? { connect: this.options.connectProvider } : {}), + assertRunAuthority: () => this.authority.assertCurrent(this.options.identity), + assertMicrophone: () => { + const current = this.eligible(publication, participant) + if (!this.accepting || owned?.retirement || !current || publication.track !== track + || current.membershipId !== grant.membershipId || current.speakerUserId !== grant.speakerUserId) { + throw new TranscriptionError("stopped") + } + const verified = this.authority.microphone(this.options.identity, { + identity: participant.identity, trackSid: grant.trackSid, kind: "audio", source: "microphone", + }) + if (!verified || verified.membershipId !== grant.membershipId) throw new TranscriptionError("stopped") + }, + onFailure: (error) => this.fail(error), + }) + owned = { participant, publication, track, grant, worker, reader, reading: Promise.resolve() } + this.microphones.set(grant.trackSid, owned) + owned.reading = this.read(owned) + } + private async read(owned: OwnedMicrophone): Promise { + try { + while (this.accepting && !owned.retirement) { + const result = await owned.reader.read() + if (result.done) { if (this.accepting && !owned.retirement) this.retire(owned); return } + if (!this.accepting || owned.retirement || !this.isCurrent(owned)) return + if (result.value.sampleRate !== owned.worker.sampleRate || result.value.channels !== 1 + || result.value.data.length !== result.value.samplesPerChannel) throw new TranscriptionError("audio") + // No await between this authority check and the owned bounded queue write. + owned.worker.push(result.value.data) + } + } catch (error) { if (this.accepting && !owned.retirement) this.fail(error) } + finally { try { owned.reader.releaseLock() } catch { /* Pending cancellation owns the lock until it completes. */ } } + } + private retire(owned: OwnedMicrophone): void { + if (owned.retirement) return + this.authority.revoke(owned.grant.trackSid) + owned.publication.setSubscribed(false) + const flushing = owned.worker.stop(true) + const cancellation = owned.reader.cancel().catch(() => undefined) + owned.retirement = Promise.all([flushing, cancellation, owned.reading]).then(() => { + if (this.microphones.get(owned.grant.trackSid) === owned) this.microphones.delete(owned.grant.trackSid) + }).catch((error: unknown) => this.fail(error)) + } + private fail(error: unknown): void { + if (!this.accepting) return + // Close every queue immediately; teardown continues only in the native child. + void this.stop(false).catch(() => undefined) + this.options.onFailure(error instanceof TranscriptionError ? error : new TranscriptionError("audio")) + } + stop(graceful: boolean): Promise { + this.accepting = false + if (!graceful) { + this.authority.stop() + for (const owned of this.microphones.values()) void owned.worker.stop(false) + } + if (this.stopPromise) return this.stopPromise + const closing: Promise[] = [] + for (const owned of this.microphones.values()) { + owned.publication.setSubscribed(false) + closing.push(owned.worker.stop(graceful), owned.reader.cancel().catch(() => undefined), owned.reading) + } + this.stopPromise = this.finishStop(closing) + return this.stopPromise + } + private async finishStop(closing: readonly Promise[]): Promise { + // Waiting for the real native operation is intentional. Watchdog escalation + // kills this process if native teardown or a pending connect never settles. + await Promise.allSettled(closing) + if (this.connected) { try { await this.connected } catch { /* Failed connect has no connected transport. */ } } + await this.room.disconnect() + this.room.removeAllListeners() + await this.model?.close() + this.authority.stop() + this.microphones.clear() + dispose() + this.stopped = true + } + get isStopped(): boolean { return this.stopped } +} diff --git a/server/packages/grid-transcription/src/segmenter.test.ts b/server/packages/grid-transcription/src/segmenter.test.ts new file mode 100644 index 000000000..7970b7977 --- /dev/null +++ b/server/packages/grid-transcription/src/segmenter.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test" +import { SpeechSegmenter, type SpeechAction } from "./segmenter.js" + +function frame(rate: number, id: number, milliseconds = 20): Int16Array { + return new Int16Array(rate * milliseconds / 1000).fill(id) +} +function emittedFrames(actions: readonly SpeechAction[]): Int16Array[] { + return actions.flatMap((action) => action.kind === "start" ? [...action.frames] + : action.kind === "audio" ? [action.frame] : []) +} + +describe("bounded speech segmentation", () => { + for (const rate of [16_000, 24_000]) { + test(`${rate}Hz silence retains only 300ms of pre-roll and threshold starts one turn`, () => { + const segmenter = new SpeechSegmenter(rate) + for (let id = 1; id <= 1000; id++) expect(segmenter.accept(frame(rate, id), 0.49)).toEqual([]) + const actions = segmenter.accept(frame(rate, 1001), 0.5) + expect(actions.map((action) => action.kind)).toEqual(["start"]) + const audio = emittedFrames(actions) + expect(audio.reduce((sum, value) => sum + value.length, 0)).toBe(rate * 0.3) + expect(audio.map((value) => value[0])).toEqual(Array.from({ length: 15 }, (_, index) => 987 + index)) + }) + + test(`${rate}Hz trailing silence ends at its frame boundary and speech resets the silence count`, () => { + const segmenter = new SpeechSegmenter(rate) + segmenter.accept(frame(rate, 1), 1) + for (let id = 2; id <= 28; id++) expect(segmenter.accept(frame(rate, id), 0).map((action) => action.kind)).toEqual(["audio"]) + expect(segmenter.accept(frame(rate, 29), 1).map((action) => action.kind)).toEqual(["audio"]) + for (let id = 30; id <= 56; id++) expect(segmenter.accept(frame(rate, id), 0).map((action) => action.kind)).toEqual(["audio"]) + expect(segmenter.accept(frame(rate, 57), 0).map((action) => action.kind)).toEqual(["audio", "end"]) + expect(segmenter.accept(frame(rate, 58), 0)).toEqual([]) + expect(segmenter.stop()).toBe(false) + }) + + test(`${rate}Hz a soft turn waits for 200ms of silence`, () => { + const segmenter = new SpeechSegmenter(rate) + for (let id = 1; id <= 300; id++) expect(segmenter.accept(frame(rate, id), 1).some((action) => action.kind === "end")).toBe(false) + for (let id = 301; id <= 309; id++) expect(segmenter.accept(frame(rate, id), 0).map((action) => action.kind)).toEqual(["audio"]) + expect(segmenter.accept(frame(rate, 310), 0).map((action) => action.kind)).toEqual(["audio", "end"]) + }) + + test(`${rate}Hz continuous speech splits at 10 seconds with every captured frame emitted once`, () => { + const segmenter = new SpeechSegmenter(rate) + const ids: number[] = [] + const lengths: number[] = [] + let turnSamples = 0 + let starts = 0 + for (let id = 1; id <= 1100; id++) { + const actions = segmenter.accept(frame(rate, id), 1) + starts += actions.filter((action) => action.kind === "start").length + for (const audio of emittedFrames(actions)) { ids.push(audio[0]!); turnSamples += audio.length } + if (actions.some((action) => action.kind === "end")) { lengths.push(turnSamples); turnSamples = 0 } + } + expect(ids).toEqual(Array.from({ length: 1100 }, (_, index) => index + 1)) + expect(starts).toBe(3) + expect(lengths).toEqual([rate * 10, rate * 10]) + expect(turnSamples).toBe(rate * 2) + expect(segmenter.stop()).toBe(true) + expect(segmenter.stop()).toBe(false) + expect(() => segmenter.accept(frame(rate, 1101), 1)).toThrow("Grid transcription stopped") + }) + + test(`${rate}Hz variable frames overshoot the hard boundary by less than one bounded frame`, () => { + const segmenter = new SpeechSegmenter(rate) + let samples = 0 + let ended = false + while (!ended) { + const actions = segmenter.accept(new Int16Array(511), 1) + samples += emittedFrames(actions).reduce((sum, value) => sum + value.length, 0) + ended = actions.some((action) => action.kind === "end") + } + expect(samples).toBeGreaterThanOrEqual(rate * 10) + expect(samples).toBeLessThan(rate * 10 + 511) + }) + } + + test("pre-roll owns samples and settings cannot be changed after construction", () => { + const settings = { prerollMs: 300, silenceMs: 550, softTurnMs: 6000, hardTurnMs: 10_000, threshold: 0.5 } + const segmenter = new SpeechSegmenter(16_000, settings) + const reusable = frame(16_000, 7) + segmenter.accept(reusable, 0) + reusable.fill(9) + settings.threshold = 0 + expect(segmenter.accept(reusable, 0)).toEqual([]) + const audio = emittedFrames(segmenter.accept(frame(16_000, 11), 1)) + expect(audio.map((value) => value[0])).toEqual([7, 9, 11]) + }) + + test("validates rates, setting bounds, frames and probabilities", () => { + expect(() => new SpeechSegmenter(44_100)).toThrow("Grid transcription audio") + const valid = { prerollMs: 300, silenceMs: 550, softTurnMs: 6000, hardTurnMs: 10_000, threshold: 0.5 } + for (const settings of [{ ...valid, prerollMs: Infinity }, { ...valid, prerollMs: 301 }, + { ...valid, silenceMs: 0 }, { ...valid, softTurnMs: 10_001 }, { ...valid, hardTurnMs: 10_001 }, + { ...valid, threshold: -0.1 }, { ...valid, threshold: 1.1 }]) { + expect(() => new SpeechSegmenter(16_000, settings)).toThrow("Grid transcription audio") + } + const segmenter = new SpeechSegmenter(16_000) + for (const probability of [-1, 1.1, NaN, Infinity]) { + expect(() => segmenter.accept(frame(16_000, 1), probability)).toThrow("Grid transcription audio") + } + expect(() => segmenter.accept(new Int16Array(), 0)).toThrow("Grid transcription audio") + expect(() => segmenter.accept(new Int16Array(1601), 0)).toThrow("Grid transcription audio") + expect(segmenter.stop()).toBe(false) + }) +}) diff --git a/server/packages/grid-transcription/src/segmenter.ts b/server/packages/grid-transcription/src/segmenter.ts new file mode 100644 index 000000000..9968bd8f2 --- /dev/null +++ b/server/packages/grid-transcription/src/segmenter.ts @@ -0,0 +1,73 @@ +import { TranscriptionError } from "./protocol.js" + +export type SpeechAction = Readonly<{ kind: "start"; frames: readonly Int16Array[] }> + | Readonly<{ kind: "audio"; frame: Int16Array }> + | Readonly<{ kind: "end" }> + +// Only finite preroll is retained. Continuous speech is split without repeating captured samples. +export class SpeechSegmenter { + private readonly settings: Readonly<{ prerollMs: number; silenceMs: number; softTurnMs: number; hardTurnMs: number; threshold: number }> + private preroll: Int16Array[] = [] + private prerollSamples = 0 + private active = false + private turnSamples = 0 + private silenceSamples = 0 + private closed = false + + constructor(readonly sampleRate: number, settings = { + prerollMs: 300, silenceMs: 550, softTurnMs: 6000, hardTurnMs: 10_000, threshold: 0.5, + }) { + if (![16_000, 24_000].includes(sampleRate) || !Object.values(settings).every(Number.isFinite) + || settings.prerollMs < 0 || settings.prerollMs > 300 || settings.silenceMs <= 0 + || settings.softTurnMs <= 0 || settings.hardTurnMs <= 0 || settings.hardTurnMs > 10_000 + || settings.softTurnMs > settings.hardTurnMs || settings.silenceMs > settings.hardTurnMs + || settings.threshold < 0 || settings.threshold > 1) throw new TranscriptionError("audio") + this.settings = Object.freeze({ ...settings }) + } + + accept(frame: Int16Array, probability: number): readonly SpeechAction[] { + if (this.closed) throw new TranscriptionError("stopped") + if (!(frame instanceof Int16Array) || frame.length === 0 || frame.length > this.sampleRate / 10 || !Number.isFinite(probability) + || probability < 0 || probability > 1) throw new TranscriptionError("audio") + const speech = probability >= this.settings.threshold + const actions: SpeechAction[] = [] + if (!this.active) { + this.preroll.push(frame.slice()) + this.prerollSamples += frame.length + while (this.preroll.length > 1 && this.prerollSamples > this.sampleRate * this.settings.prerollMs / 1000) { + this.prerollSamples -= this.preroll.shift()!.length + } + if (!speech) return [] + this.active = true + this.turnSamples = this.prerollSamples + this.silenceSamples = 0 + actions.push({ kind: "start", frames: this.preroll }) + this.preroll = [] + this.prerollSamples = 0 + } else { + this.turnSamples += frame.length + this.silenceSamples = speech ? 0 : this.silenceSamples + frame.length + actions.push({ kind: "audio", frame }) + } + const silenceBoundary = this.silenceSamples >= this.sampleRate * this.settings.silenceMs / 1000 + || (this.turnSamples >= this.sampleRate * this.settings.softTurnMs / 1000 + && this.silenceSamples >= this.sampleRate * 0.2) + if (silenceBoundary || this.turnSamples >= this.sampleRate * this.settings.hardTurnMs / 1000) { + this.active = false + this.turnSamples = 0 + this.silenceSamples = 0 + actions.push({ kind: "end" }) + } + return actions + } + + stop(): boolean { + if (this.closed) return false + this.closed = true + this.preroll = [] + this.prerollSamples = 0 + const unfinished = this.active + this.active = false + return unfinished + } +} diff --git a/server/packages/grid-transcription/src/silero-model-license.txt b/server/packages/grid-transcription/src/silero-model-license.txt new file mode 100644 index 000000000..042865069 --- /dev/null +++ b/server/packages/grid-transcription/src/silero-model-license.txt @@ -0,0 +1,242 @@ +Silero VAD ONNX model +Vendored from official @livekit/agents-plugin-silero 1.9.1 npm artifact, package/dist/silero_vad.onnx. +https://github.com/livekit/agents-js/tree/main/plugins/silero +Upstream model: https://github.com/snakers4/silero-vad +SHA256: 1a153a22f4509e292a94e67d6f9b85e8deb25b4988682b7e174c65279d8788e3 + +MIT License + +Copyright (c) 2020-present Silero Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +Inference tensor-contract adaptation +Copyright (c) 2024 LiveKit, Inc. +Adapted from onnx_model.ts in @livekit/agents-plugin-silero 1.9.1. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this portion except in compliance with the License. +You may obtain a copy of the License at +https://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +Full Apache License 2.0 for the inference-contract adaptation: + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/server/packages/grid-transcription/src/silero.ts b/server/packages/grid-transcription/src/silero.ts new file mode 100644 index 000000000..2e9e883b1 --- /dev/null +++ b/server/packages/grid-transcription/src/silero.ts @@ -0,0 +1,101 @@ +import { createHash } from "node:crypto" +import { readFile } from "node:fs/promises" +import { AudioFrame, AudioResampler, AudioResamplerQuality } from "@livekit/rtc-node" +import { InferenceSession, Tensor } from "onnxruntime-node" +import { TranscriptionError } from "./protocol.js" + +export const SILERO_MODEL_SHA256 = "1a153a22f4509e292a94e67d6f9b85e8deb25b4988682b7e174c65279d8788e3" +export interface SpeechDetector { probability(samples: Int16Array): Promise; close(): void } + +// Model tensor contract follows LiveKit's Apache-2.0 onnx_model.ts, pinned plugin 1.9.1. +// The upstream Silero model and its MIT license are vendored alongside this file. +export class SileroModel { + private constructor(private readonly session: InferenceSession) {} + + static async load(): Promise { + try { + const bytes = await readFile(new URL("./silero_vad.onnx", import.meta.url)) + if (createHash("sha256").update(bytes).digest("hex") !== SILERO_MODEL_SHA256) { + throw new TranscriptionError("protocol") + } + const session = await InferenceSession.create(bytes, { + interOpNumThreads: 1, intraOpNumThreads: 1, executionMode: "sequential", + executionProviders: ["cpu"], + }) + return new SileroModel(session) + } catch (error) { + throw error instanceof TranscriptionError ? error : new TranscriptionError("audio") + } + } + + detector(sampleRate: number): SpeechDetector { return new SileroDetector(this.session, sampleRate) } + async close(): Promise { await this.session.release() } +} + +class SileroDetector implements SpeechDetector { + private readonly resampler: AudioResampler | undefined + private readonly context = new Float32Array(64) + private readonly state = new Float32Array(256) + private readonly window = new Float32Array(512) + private readonly input = new Float32Array(576) + private windowLength = 0 + private lastProbability = 0 + private closed = false + private busy = false + + constructor(private readonly session: InferenceSession, private readonly sampleRate: number) { + if (sampleRate !== 16_000 && sampleRate !== 24_000) throw new TranscriptionError("audio") + if (sampleRate !== 16_000) this.resampler = new AudioResampler(sampleRate, 16_000, 1, AudioResamplerQuality.QUICK) + } + + async probability(samples: Int16Array): Promise { + if (this.closed) throw new TranscriptionError("stopped") + if (this.busy || samples.length === 0 || samples.length > this.sampleRate / 10) { + throw new TranscriptionError("audio") + } + this.busy = true + try { + const frames = this.resampler + ? this.resampler.push(new AudioFrame(samples, this.sampleRate, 1, samples.length)) + : [new AudioFrame(samples, 16_000, 1, samples.length)] + for (const frame of frames) { + for (const sample of frame.data) { + this.window[this.windowLength++] = sample / 32768 + if (this.windowLength !== 512) continue + this.input.set(this.context) + this.input.set(this.window, 64) + const output = await this.session.run({ + input: new Tensor("float32", this.input, [1, 576]), + state: new Tensor("float32", this.state, [2, 1, 128]), + sr: new Tensor("int64", BigInt64Array.of(16_000n)), + }) + if (this.closed) throw new TranscriptionError("stopped") + const next = output.stateN?.data + const probability = output.output?.data + if (!(next instanceof Float32Array) || next.length !== 256 + || !(probability instanceof Float32Array) || probability.length !== 1 + || !Number.isFinite(probability[0]) || probability[0]! < 0 || probability[0]! > 1) { + throw new TranscriptionError("audio") + } + this.state.set(next) + this.context.set(this.input.subarray(512)) + this.lastProbability = probability[0]! + this.windowLength = 0 + } + } + return this.lastProbability + } catch (error) { + throw error instanceof TranscriptionError ? error : new TranscriptionError("audio") + } finally { this.busy = false } + } + + close(): void { + if (this.closed) return + this.closed = true + this.resampler?.close() + this.context.fill(0) + this.state.fill(0) + this.window.fill(0) + this.input.fill(0) + } +} diff --git a/server/packages/grid-transcription/src/silero_vad.onnx b/server/packages/grid-transcription/src/silero_vad.onnx new file mode 100644 index 000000000..80c5592ef Binary files /dev/null and b/server/packages/grid-transcription/src/silero_vad.onnx differ diff --git a/server/packages/grid-transcription/src/socket.test.ts b/server/packages/grid-transcription/src/socket.test.ts new file mode 100644 index 000000000..198d1aaf6 --- /dev/null +++ b/server/packages/grid-transcription/src/socket.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test } from "bun:test" +import WebSocket, { WebSocketServer } from "ws" +import type { AddressInfo } from "node:net" +import { ProviderConnection, type SocketFactory } from "./socket.js" +import { openAIConfiguration } from "./providers/openai.js" +import { TranscriptionError, type FinalTurn } from "./protocol.js" + +async function server() { + const ws = new WebSocketServer({ host: "127.0.0.1", port: 0 }) + await new Promise((resolve) => ws.once("listening", resolve)) + const endpoint = `ws://127.0.0.1:${(ws.address() as AddressInfo).port}` + const factory: SocketFactory = (_url, headers) => new WebSocket(endpoint, { headers }) + const close = () => { for (const client of ws.clients) client.terminate(); ws.close() } + return { ws, factory, close } +} + +describe("provider wire qualification over actual local WebSockets", () => { + test("Standard config, exact PCM, ack and final reach the local admitted turn", async () => { + const fixture = await server() + const packets: Record[] = [] + let authorization: string | undefined + let resolveTurn!: (turn: FinalTurn) => void + const completed = new Promise((resolve) => { resolveTurn = resolve }) + fixture.ws.on("connection", (socket, request) => { + authorization = request.headers.authorization + socket.on("message", (data) => { + const event = JSON.parse(data.toString()) as Record + packets.push(event) + if (event.type === "session.update") socket.send(JSON.stringify({ type: "session.updated", session: openAIConfiguration().session })) + if (event.type === "input_audio_buffer.commit") { + socket.send(JSON.stringify({ type: "input_audio_buffer.committed", item_id: "item", previous_item_id: null })) + socket.send(JSON.stringify({ type: "conversation.item.input_audio_transcription.delta", item_id: "item", delta: "wrong temporary" })) + socket.send(JSON.stringify({ type: "conversation.item.input_audio_transcription.completed", item_id: "item", content_index: 0, transcript: "Ship it." })) + } + }) + }) + const failures: string[] = [] + let connection: ProviderConnection | undefined + try { + connection = await ProviderConnection.connect({ model: "standard", apiKey: "test-key", assertAuthority: () => {}, + onFinal: resolveTurn, onFailure: (error) => failures.push(error.code), socketFactory: fixture.factory }) + connection.appendAudio(new Int16Array([-1, 0x1234])) + connection.commitTurn("admitted-turn") + expect(await completed).toEqual({ turnId: "admitted-turn", text: "Ship it." }) + expect(authorization).toBe("Bearer test-key") + const audio = packets.find((packet) => packet.type === "input_audio_buffer.append")?.audio + expect([...Buffer.from(audio as string, "base64")]).toEqual([255, 255, 52, 18]) + expect(failures).toEqual([]) + } finally { connection?.close(); fixture.close() } + }) + + test("Meeting sends binary PCM and publishes final tokens only after its flush marker", async () => { + const fixture = await server() + const frames: Uint8Array[] = [] + let config: Record | undefined + let resolveTurn!: (turn: FinalTurn) => void + const completed = new Promise((resolve) => { resolveTurn = resolve }) + fixture.ws.on("connection", (socket) => socket.on("message", (data, binary) => { + if (binary) { frames.push(new Uint8Array(Buffer.from(data as Buffer))); return } + const event = JSON.parse(data.toString()) as Record + if (event.model) config = event + if (event.type === "finalize") { + socket.send(JSON.stringify({ tokens: [{ text: "wrong temporary", is_final: false }] })) + socket.send(JSON.stringify({ tokens: [{ text: "Ready.", is_final: true }, { text: "", is_final: true }] })) + } + })) + let connection: ProviderConnection | undefined + try { + connection = await ProviderConnection.connect({ model: "meeting", apiKey: "test-key", assertAuthority: () => {}, + onFinal: resolveTurn, onFailure: () => {}, socketFactory: fixture.factory }) + expect(connection.sampleRate).toBe(16_000) + connection.appendAudio(new Int16Array([-1, 0x1234])) + connection.commitTurn("meeting-turn") + expect(await completed).toEqual({ turnId: "meeting-turn", text: "Ready." }) + expect(config?.model).toBe("stt-rt-v5") + expect(config?.enable_speaker_diarization).toBe(false) + expect(config?.language_hints).toBeUndefined() + expect([...frames[0]!]).toEqual([255, 255, 52, 18]) + } finally { connection?.close(); fixture.close() } + }) + + test("expired authority tears down the actual socket and rejects further writes", async () => { + const fixture = await server() + let expired = false + let resolveClosed!: () => void + const closed = new Promise((resolve) => { resolveClosed = resolve }) + const failures: TranscriptionError[] = [] + fixture.ws.on("connection", (socket) => { + socket.on("close", resolveClosed) + socket.on("message", () => socket.send(JSON.stringify({ type: "session.updated", session: openAIConfiguration().session }))) + }) + let connection: ProviderConnection | undefined + try { + connection = await ProviderConnection.connect({ model: "standard", apiKey: "test-key", + assertAuthority: () => { if (expired) throw new TranscriptionError("expired") }, + onFinal: () => {}, onFailure: (error) => failures.push(error), socketFactory: fixture.factory }) + expired = true + expect(() => connection.appendAudio(new Int16Array([1]))).toThrow("expired") + await closed + expect(failures.map((error) => error.code)).toEqual(["expired"]) + expect(() => connection.commitTurn("late")).toThrow("expired") + } finally { connection?.close(); fixture.close() } + }) + + test("provider error contents never enter surfaced error messages", async () => { + const fixture = await server() + fixture.ws.on("connection", (socket) => socket.on("message", () => socket.send(JSON.stringify({ + type: "error", error: { message: "private transcript or secret", code: "bad_key" }, + })))) + const failures: TranscriptionError[] = [] + try { + await expect(ProviderConnection.connect({ model: "standard", apiKey: "test-key", assertAuthority: () => {}, + onFinal: () => {}, onFailure: (error) => failures.push(error), socketFactory: fixture.factory })).rejects.toThrow("provider") + expect(failures[0]?.message).toBe("Grid transcription provider") + } finally { fixture.close() } + }) + + test("closing on the first turn prevents delivery of a later final in the same event", async () => { + const fixture = await server() + let commits = 0 + fixture.ws.on("connection", (socket) => socket.on("message", (data, binary) => { + if (binary) return + const event = JSON.parse(data.toString()) as Record + if (event.type === "finalize" && ++commits === 2) socket.send(JSON.stringify({ tokens: [ + { text: "one", is_final: true }, { text: "", is_final: true }, + { text: "two", is_final: true }, { text: "", is_final: true }, + ] })) + })) + const delivered: string[] = [] + let resolveDone!: () => void + const done = new Promise((resolve) => { resolveDone = resolve }) + let connection: ProviderConnection | undefined + try { + connection = await ProviderConnection.connect({ model: "meeting", apiKey: "test-key", assertAuthority: () => {}, + onFinal: (turn) => { delivered.push(turn.turnId); connection?.close(); resolveDone() }, + onFailure: () => {}, socketFactory: fixture.factory }) + connection.commitTurn("one") + connection.commitTurn("two") + await done + expect(delivered).toEqual(["one"]) + } finally { connection?.close(); fixture.close() } + }) + + test("unexpected Meeting EOS closes transport and forbids later audio", async () => { + const fixture = await server() + let frames = 0 + fixture.ws.on("connection", (socket) => socket.on("message", (_data, binary) => { + if (binary) frames++ + else socket.send(JSON.stringify({ tokens: [], finished: true })) + })) + let resolveFailure!: () => void + const failed = new Promise((resolve) => { resolveFailure = resolve }) + let connection: ProviderConnection | undefined + try { + connection = await ProviderConnection.connect({ model: "meeting", apiKey: "test-key", assertAuthority: () => {}, + onFinal: () => {}, onFailure: resolveFailure, socketFactory: fixture.factory }) + await failed + expect(() => connection?.appendAudio(new Int16Array([1]))).toThrow("provider") + expect(frames).toBe(0) + } finally { connection?.close(); fixture.close() } + }) + + test("invalid connect options allocate no socket and factory errors stay coarse", async () => { + let allocations = 0 + const options = { model: "standard" as const, apiKey: "test-key", assertAuthority: () => {}, + onFinal: () => {}, onFailure: () => {}, socketFactory: () => { allocations++; throw new Error("private factory detail") } } + await expect(ProviderConnection.connect({ ...options, connectTimeoutMs: 0 })).rejects.toThrow("protocol") + expect(allocations).toBe(0) + await expect(ProviderConnection.connect(options)).rejects.toThrow("Grid transcription provider") + expect(allocations).toBe(1) + }) + + test("missing configuration acknowledgement times out and closes instead of hanging", async () => { + const fixture = await server() + const failures: TranscriptionError[] = [] + try { + await expect(ProviderConnection.connect({ model: "standard", apiKey: "test-key", assertAuthority: () => {}, + onFinal: () => {}, onFailure: (error) => failures.push(error), socketFactory: fixture.factory, connectTimeoutMs: 30 })).rejects.toThrow("provider") + expect(failures.map((error) => error.code)).toEqual(["provider"]) + } finally { fixture.close() } + }) +}) diff --git a/server/packages/grid-transcription/src/socket.ts b/server/packages/grid-transcription/src/socket.ts new file mode 100644 index 000000000..da0d2cc33 --- /dev/null +++ b/server/packages/grid-transcription/src/socket.ts @@ -0,0 +1,193 @@ +import WebSocket from "ws" +import { pcm16le } from "./pcm.js" +import { TranscriptionError, type FinalTurn, type Model, type TurnDecoder } from "./protocol.js" +import { OPENAI_URL, OPENAI_SAMPLE_RATE, OpenAITurnDecoder, openAIConfiguration, openAIConfigurationAccepted, openAIAppendAudio, openAICommit } from "./providers/openai.js" +import { SONIOX_URL, SONIOX_SAMPLE_RATE, SonioxTurnDecoder, sonioxConfiguration, sonioxAudio, sonioxFinalize, sonioxKeepalive } from "./providers/soniox.js" + +const MAX_SOCKET_BYTES = 128 * 1024 +const MAX_EVENT_BYTES = 64 * 1024 + +export type SocketFactory = (url: string, headers: Readonly>) => WebSocket +export type ProviderOptions = { + model: Model; apiKey: string; + assertAuthority: () => void; + onFinal: (turn: FinalTurn) => void; + onFailure: (error: TranscriptionError) => void; + connectTimeoutMs?: number; + // Trusted dependency injection for controlled local qualification, never client-supplied. + socketFactory?: SocketFactory; +} + +export class ProviderConnection { + readonly sampleRate: number + private readonly socket: WebSocket + private readonly decoder: TurnDecoder + private closed = false + private ready = false + private keepalive: ReturnType | undefined + private failure: TranscriptionError | undefined + + private constructor(private readonly options: ProviderOptions) { + options.assertAuthority() + if (options.model !== "standard" && options.model !== "meeting") throw new TranscriptionError("protocol") + if (typeof options.apiKey !== "string" || !options.apiKey || options.apiKey.length > 4096 || /[\r\n]/.test(options.apiKey)) { + throw new TranscriptionError("protocol") + } + const timeoutMs = options.connectTimeoutMs ?? 5_000 + if (!Number.isFinite(timeoutMs) || timeoutMs < 1 || timeoutMs > 30_000) throw new TranscriptionError("protocol") + this.sampleRate = options.model === "standard" ? OPENAI_SAMPLE_RATE : SONIOX_SAMPLE_RATE + this.decoder = options.model === "standard" ? new OpenAITurnDecoder() : new SonioxTurnDecoder() + const headers: Record = options.model === "standard" ? { Authorization: `Bearer ${options.apiKey}` } : {} + const factory = options.socketFactory ?? ((url, values) => new WebSocket(url, { headers: values, maxPayload: MAX_EVENT_BYTES })) + this.socket = factory(options.model === "standard" ? OPENAI_URL : SONIOX_URL, headers) + } + + static async connect(options: ProviderOptions): Promise { + let connection: ProviderConnection | undefined + try { + connection = new ProviderConnection(options) + await connection.open() + return connection + } catch (error) { + connection?.close() + throw error instanceof TranscriptionError ? error : new TranscriptionError("provider") + } + } + + private open(): Promise { + const timeoutMs = this.options.connectTimeoutMs ?? 5_000 + return new Promise((resolve, reject) => { + let settled = false + const finish = (error?: TranscriptionError) => { + if (settled) return + settled = true + clearTimeout(timer) + if (error) reject(error) + else { this.ready = true; resolve() } + } + const timer = setTimeout(() => { + const error = new TranscriptionError("provider") + finish(error) + this.fail(error) + }, timeoutMs) + this.socket.on("error", () => { + const error = new TranscriptionError("provider") + finish(error) + this.fail(error) + }) + this.socket.on("close", () => { + if (this.closed) return + const error = new TranscriptionError("provider") + finish(error) + this.fail(error) + }) + this.socket.on("message", (data, isBinary) => { + if (this.closed) return + try { + this.options.assertAuthority() + const bytes = Array.isArray(data) ? Buffer.concat(data) : data instanceof ArrayBuffer ? Buffer.from(data) : data + if (isBinary || bytes.byteLength > MAX_EVENT_BYTES) throw new TranscriptionError("protocol") + const event: unknown = JSON.parse(bytes.toString("utf8")) + const finals = this.decoder.accept(event) + if (this.options.model === "standard" && openAIConfigurationAccepted(event)) finish() + for (const turn of finals) { + if (this.closed) break + this.options.assertAuthority() + this.options.onFinal(turn) + } + if (this.options.model === "meeting" && typeof event === "object" && event !== null + && "finished" in event && event.finished === true && !this.closed) { + // No owner-requested end-input exists here: unexpected EOS retires this socket. + this.fail(new TranscriptionError("provider")) + } + } catch (error) { + const safe = error instanceof TranscriptionError ? error : new TranscriptionError("protocol") + finish(safe) + this.fail(safe) + } + }) + this.socket.once("open", () => { + if (this.closed) return + try { + this.write(JSON.stringify(this.options.model === "standard" + ? openAIConfiguration() : sonioxConfiguration(this.options.apiKey)), false) + if (this.options.model === "meeting") { + // Soniox has no distinct configuration-ack event. This is transport-ready, not audio proof. + finish() + this.keepalive = setInterval(() => { + try { this.write(JSON.stringify(sonioxKeepalive())) } + catch (error) { this.fail(error instanceof TranscriptionError ? error : new TranscriptionError("provider")) } + }, 10_000) + } + } catch (error) { + const safe = error instanceof TranscriptionError ? error : new TranscriptionError("provider") + finish(safe) + this.fail(safe) + } + }) + }) + } + + appendAudio(samples: Int16Array): void { + this.perform(() => { + this.assertWritable() + const bytes = pcm16le(samples) + this.write(this.options.model === "standard" ? JSON.stringify(openAIAppendAudio(bytes)) : sonioxAudio(bytes)) + }) + } + + // Call only after API admission, immediately before wire commit in this same track actor. + commitTurn(turnId: string): void { + this.perform(() => { + this.assertWritable() + this.decoder.beginTurn(turnId) + this.write(JSON.stringify(this.options.model === "standard" ? openAICommit() : sonioxFinalize())) + }) + } + + private perform(operation: () => void): void { + try { operation() } + catch (error) { + const safe = error instanceof TranscriptionError ? error : new TranscriptionError("protocol") + this.fail(safe) + throw safe + } + } + + private assertWritable(requireReady = true): void { + if (this.closed) throw this.failure ?? new TranscriptionError("stopped") + this.options.assertAuthority() + if ((requireReady && !this.ready) || this.socket.readyState !== WebSocket.OPEN) throw new TranscriptionError("provider") + if (this.socket.bufferedAmount > MAX_SOCKET_BYTES) { + const error = new TranscriptionError("overflow") + this.fail(error) + throw error + } + } + + private write(data: string | Uint8Array, requireReady = true): void { + this.assertWritable(requireReady) + if (this.socket.bufferedAmount + Buffer.byteLength(data) > MAX_SOCKET_BYTES) { + const error = new TranscriptionError("overflow") + this.fail(error) + throw error + } + this.socket.send(data, (error) => { if (error) this.fail(new TranscriptionError("provider")) }) + } + + private fail(error: TranscriptionError): void { + if (this.closed) return + this.failure = error + this.close() + this.options.onFailure(error) + } + + // No admission/queue flush occurs here. Its owner completes accepted turns before this deadline. + close(): void { + if (this.closed) return + this.closed = true + clearInterval(this.keepalive) + this.decoder.close() + this.socket.terminate() + } +} diff --git a/server/packages/grid-transcription/src/supervisor.test.ts b/server/packages/grid-transcription/src/supervisor.test.ts new file mode 100644 index 000000000..a209be744 --- /dev/null +++ b/server/packages/grid-transcription/src/supervisor.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, test } from "bun:test" +import { execFile, execFileSync } from "node:child_process" +import { mkdtempSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" +import { WatchdogLease } from "./watchdog.js" + +const identity = { runId: "fixture-run", claimEpoch: 1, generation: 2 } +const fixtureDirectory = mkdtempSync(join(tmpdir(), "grid-supervisor-test-")) +const nodeExecutable = execFileSync(process.env.GRID_TRANSCRIPTION_TEST_NODE ?? "node", ["--print", "process.execPath"], { encoding: "utf8" }).trim() +const nodeVersion = execFileSync(nodeExecutable, ["--version"], { encoding: "utf8" }).trim() +// Keep fixtures as bounded diagnostic evidence; no workspace or environment files are read. +writeFileSync(join(fixtureDirectory, "package.json"), '{"type":"module"}') +execFileSync(nodeExecutable, [fileURLToPath(new URL("../node_modules/typescript/bin/tsc", import.meta.url)), + "--ignoreConfig", "--target", "ES2022", "--module", "NodeNext", "--moduleResolution", "NodeNext", "--strict", "--noUncheckedIndexedAccess", + "--skipLibCheck", "--types", "node", "--verbatimModuleSyntax", "--outDir", fixtureDirectory, + fileURLToPath(new URL("./supervisor.ts", import.meta.url)), fileURLToPath(new URL("./watchdog.ts", import.meta.url)), +], { cwd: fileURLToPath(new URL("..", import.meta.url)), env: {}, encoding: "utf8" }) +const roomChildPath = join(fixtureDirectory, "room-fixture.js") +writeFileSync(roomChildPath, ` +import { appendFileSync } from 'node:fs'; +let config; +const record = (event) => appendFileSync(config.events, JSON.stringify({event, at:Number(process.hrtime.bigint()/1000000n)})+'\\n'); +process.on('SIGTERM', () => { record('term'); if(config.mode !== 'stubborn') process.exit(0); }); +process.on('disconnect', () => process.exit(0)); +process.on('message', (message) => { + if(message.type === 'start') { + config=message.payload; + setInterval(() => process.send?.({type:'heartbeat',sentAtMs:Number(process.hrtime.bigint()/1000000n)}),100); + record(process.env.GRID_SUPERVISOR_INHERITED_FIXTURE === undefined ? 'environment-clean' : 'environment-leaked'); + record(process.env.EXPECTED_CREDENTIAL === 'fixture-value' ? 'credential-present' : 'credential-missing'); + if(config.mode !== 'never-ready') process.send({type:'ready'}); + } else if(message.type === 'stop') { + record('stop-'+message.reason); + process.send({type:'stopped'}); + if(config.mode === 'graceful') setTimeout(() => process.exit(0), 80); + } else if(message.type === 'renew') record('renew-'+message.payload?.marker); +}); +`) +const moduleUrl = pathToFileURL(join(fixtureDirectory, "supervisor.js")).href +const watchdogUrl = pathToFileURL(join(fixtureDirectory, "watchdog.js")).href +const standardHarness = ` +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {setTimeout as delay} from 'node:timers/promises'; +import {NativeRoomSupervisor} from ${JSON.stringify(moduleUrl)}; +import {monotonicMilliseconds as now} from ${JSON.stringify(watchdogUrl)}; +process.env.GRID_SUPERVISOR_INHERITED_FIXTURE='must-not-leak'; +const events=${JSON.stringify(join(fixtureDirectory, "events-"))}+process.pid+'.jsonl'; +const identity={runId:'fixture-run',claimEpoch:1,generation:2}; +const supervisor=new NativeRoomSupervisor({roomChildPath:${JSON.stringify(roomChildPath)},environment:{EXPECTED_CREDENTIAL:'fixture-value'}, + heartbeatIntervalMs:100,heartbeatTimeoutMs:2500,stoppingGraceMs:5000,authorityGraceMs:10,termGraceMs:100,killExitTimeoutMs:2000,startupTimeoutMs:10000}); +const start=(mode,leaseMs=15000)=>supervisor.start({identity,requestedAtMs:now(),leaseMs,expiresAtMs:now()+30000,payload:{mode,events}}); +const records=()=>readFileSync(events,'utf8').trim().split('\\n').map(line=>JSON.parse(line)); +const absent=pid=>{try{process.kill(pid,0);return false;}catch(error){return error.code==='ESRCH';}}; +` +const crashParentPath = join(fixtureDirectory, "crash-parent.js") +writeFileSync(crashParentPath, standardHarness + "const ready=await start('stubborn');process.send({type:'ready',pid:ready.pid});") +const brokenWatchdogPath = join(fixtureDirectory, "broken-watchdog.js") +writeFileSync(brokenWatchdogPath, "process.disconnect();setTimeout(()=>process.exit(0),50);") +function runNode(script: string): Promise { + return new Promise((resolve, reject) => { + execFile(nodeExecutable, ["--input-type=module", "--eval", standardHarness + script], { + timeout: 25_000, maxBuffer: 16 * 1024, env: {}, + }, (error, stdout, stderr) => { + if (error) { reject(new Error(`Compiled ${nodeVersion} fixture failed: ${stderr.slice(0, 4_000)}`)); return } + try { resolve(JSON.parse(stdout)) } catch { reject(new Error("Fixture result protocol")) } + }) + }) +} + +describe("native room supervisor on compiled Node", () => { + test("a stopped native child is killed while parent and API lease stay healthy", async () => { + const result = await runNode(` +const ready=await start('stubborn'); +const renew=setInterval(()=>{try{supervisor.renew(identity,now(),15000);}catch{}},250); +process.kill(ready.pid,'SIGSTOP'); +const receipt=await supervisor.exitReceipt; +clearInterval(renew); +assert.equal(receipt.reason,'child_stalled'); +assert.equal(receipt.signal,'SIGKILL'); +assert.equal(absent(ready.pid),true); +console.log(JSON.stringify({reason:receipt.reason,parentHealthy:true})); +`) + expect(result).toEqual({ reason: "child_stalled", parentHealthy: true }) + }, 30_000) + test("stop closes admissions synchronously; stopped IPC cannot unlock replacement before SIGKILL exit", async () => { + const result = await runNode(` +const ready=await start('stubborn'); +const exit=supervisor.stop('authority'); +assert.equal(supervisor.admitting,false); +assert.throws(()=>start('stubborn'),/stopped/); +await delay(30); +assert.equal(supervisor.hasActiveChild,true); +const receipt=await exit; +assert.equal(receipt.pid,ready.pid); +assert.equal(receipt.signal,'SIGKILL'); +assert.equal(receipt.hardKilled,true); +assert.equal(absent(ready.pid),true); +assert.equal(supervisor.hasActiveChild,false); +const second=await start('graceful'); +const secondExit=await supervisor.stop('stopping'); +assert.equal(secondExit.pid,second.pid); +assert.equal(secondExit.code,0); +const list=records(); +assert(list.some(entry=>entry.event==='stop-authority')); +assert(list.some(entry=>entry.event==='term')); +assert(!list.some(entry=>entry.event==='environment-leaked')); +assert(list.some(entry=>entry.event==='credential-present')); +console.log(JSON.stringify({signal:receipt.signal,secondCode:secondExit.code,environmentClean:true})); +`) + expect(result).toEqual({ signal: "SIGKILL", secondCode: 0, environmentClean: true }) + }, 30_000) + test("watchdog kills its owned native child while the parent event loop is stalled", async () => { + const result = await runNode(` +const ready=await start('stubborn'); +const exit=supervisor.exitReceipt; +assert(exit); +const blockedAt=now(); +while(now()-blockedAt<5000) {} +assert.equal(absent(ready.pid),true); +const receipt=await exit; +assert.equal(receipt.reason,'parent_stalled'); +assert.equal(receipt.signal,'SIGKILL'); +assert(receipt.exitedAtMs { + const result = await runNode(` +const ready=await start('stubborn'); +await delay(5); +supervisor.renew(identity,now(),750); +const receipt=await supervisor.exitReceipt; +assert(receipt); +assert.equal(receipt.reason,'expired'); +assert.equal(receipt.signal,'SIGKILL'); +assert.equal(absent(ready.pid),true); +console.log(JSON.stringify({reason:receipt.reason})); +`) + expect(result).toEqual({ reason: "expired" }) + }, 30_000) + test("parent stall also ends the graceful stopping window", async () => { + const result = await runNode(` +const ready=await start('stubborn'); +const exit=supervisor.stop('stopping'); +await delay(40); +const blockedAt=now(); +while(now()-blockedAt<5000) {} +assert.equal(absent(ready.pid),true); +const receipt=await exit; +assert.equal(receipt.reason,'parent_stalled'); +assert.equal(receipt.signal,'SIGKILL'); +console.log(JSON.stringify({reason:receipt.reason})); +`) + expect(result).toEqual({ reason: "parent_stalled" }) + }, 30_000) + test("parent SIGKILL disconnect lets the independent watchdog reap the native child", async () => { + const result = await runNode(` +import {fork} from 'node:child_process'; +const parent=fork(${JSON.stringify(crashParentPath)},[],{execPath:process.execPath,execArgv:[],env:{},stdio:['ignore','ignore','ignore','ipc']}); +const nativePid=await new Promise((resolve,reject)=>{parent.once('message',message=>resolve(message.pid));parent.once('error',reject);}); +const parentExit=new Promise(resolve=>parent.once('exit',resolve)); +parent.kill('SIGKILL'); +await parentExit; +await delay(1000); +assert.equal(absent(nativePid),true); +console.log(JSON.stringify({parentCrashReapedChild:true})); +`) + expect(result).toEqual({ parentCrashReapedChild: true }) + }, 30_000) + test("a queued start after a terminal pre-start stop cannot create an unsupervised child", async () => { + const result = await runNode(` +import {fork} from 'node:child_process'; +import {existsSync} from 'node:fs'; +const watchdog=fork(${JSON.stringify(join(fixtureDirectory, "watchdog.js"))},[],{execPath:process.execPath,execArgv:[],env:{},stdio:['ignore','ignore','ignore','ipc']}); +const watchdogExit=new Promise(resolve=>watchdog.once('exit',resolve)); +let rejectedBeforeStart=false; +watchdog.on('message',message=>{if(message.type==='error'&&message.code==='protocol')rejectedBeforeStart=true;}); +watchdog.send({type:'stop',reason:'authority'},()=>{}); +watchdog.send({type:'start',childPath:${JSON.stringify(roomChildPath)},environment:{EXPECTED_CREDENTIAL:'fixture-value'},identity, + requestedAtMs:now(),leaseMs:3000,expiresAtMs:now()+10000,payload:{mode:'stubborn',events}, + timings:{heartbeatTimeoutMs:1000,stoppingGraceMs:1500,authorityGraceMs:10,termGraceMs:100,killExitTimeoutMs:1000,startupTimeoutMs:2000}},()=>{}); +await watchdogExit; +assert.equal(rejectedBeforeStart,true); +assert.equal(existsSync(events),false); +console.log(JSON.stringify({terminalStartRejected:true})); +`) + expect(result).toEqual({ terminalStartRejected: true }) + }, 30_000) + test("lost watchdog IPC rejects an unconfirmed exit and leaves replacement fenced", async () => { + const result = await runNode(` +const broken=new NativeRoomSupervisor({roomChildPath:${JSON.stringify(roomChildPath)},watchdogPath:${JSON.stringify(brokenWatchdogPath)},environment:{}}); +const pending=broken.start({identity,requestedAtMs:now(),leaseMs:15000,expiresAtMs:now()+30000,payload:{}}); +const exit=broken.exitReceipt; +await assert.rejects(pending,/watchdog|exit_unconfirmed/); +await assert.rejects(exit,/exit_unconfirmed/); +assert.equal(broken.admitting,false); +assert.equal(broken.hasActiveChild,true); +assert.throws(()=>broken.start({identity,requestedAtMs:now(),leaseMs:15000,expiresAtMs:now()+30000,payload:{}}),/stopped/); +console.log(JSON.stringify({unconfirmedFenced:true})); +`) + expect(result).toEqual({ unconfirmedFenced: true }) + }, 30_000) + test("ordinary stop allows admitted work to flush, then authority loss shortens that grace", async () => { + const result = await runNode(` +const ready=await start('stubborn'); +const stoppingAt=now(); +const exit=supervisor.stop('stopping'); +await delay(80); +assert.equal(absent(ready.pid),false); +supervisor.stop('authority'); +const receipt=await exit; +assert.equal(receipt.reason,'authority'); +assert(receipt.exitedAtMs-stoppingAt<1000); +assert(records().some(entry=>entry.event==='stop-stopping')); +assert(records().some(entry=>entry.event==='stop-authority')); +console.log(JSON.stringify({reason:receipt.reason})); +`) + expect(result).toEqual({ reason: "authority" }) + }, 30_000) + test("ready-less child startup is terminated, and replacement waits for its exit", async () => { + const result = await runNode(` +const ready=start('never-ready'); +const exit=supervisor.exitReceipt; +await assert.rejects(ready,/stopped/); +const receipt=await exit; +assert(receipt); +assert.equal(receipt.reason,'startup'); +assert.equal(absent(receipt.pid),true); +console.log(JSON.stringify({reason:receipt.reason})); +`) + expect(result).toEqual({ reason: "startup" }) + }, 30_000) + test("renewal forwards participant replacement only while the original authority is current", async () => { + const result = await runNode(` +await start('graceful'); +await delay(5); +supervisor.renew(identity,now(),3000,{marker:'participants-replaced'}); +await delay(30); +await supervisor.stop('stopping'); +assert(records().some(entry=>entry.event==='renew-participants-replaced')); +console.log(JSON.stringify({renewed:true})); +`) + expect(result).toEqual({ renewed: true }) + }, 30_000) +}) + +describe("watchdog lease monotonic fencing", () => { + test("expiry is irreversible, delayed request start never grants a fresh duration", () => { + let clock = 1_000 + const lease = new WatchdogLease(identity, clock, 1_000, 10_000, () => clock) + clock = 1_900 + lease.renew(identity, 1_100, 1_000) + expect(lease.deadline).toBe(2_100) + clock = 2_100 + expect(() => lease.renew(identity, clock, 1_000)).toThrow("expired") + clock = 2_200 + expect(() => lease.renew(identity, clock, 1_000)).toThrow("stopped") + }) + test("out of order and stale identity renewals cannot extend the accepted authority", () => { + let clock = 1_000 + const lease = new WatchdogLease(identity, clock, 1_000, 4_000, () => clock) + clock = 1_100 + expect(lease.renew(identity, clock, 2_000)).toBe(true) + expect(lease.renew(identity, 1_050, 30_000)).toBe(false) + expect(lease.deadline).toBe(3_100) + expect(() => lease.renew({ ...identity, claimEpoch: 2 }, clock, 2_000)).toThrow("protocol") + clock = 2_000 + lease.renew(identity, clock, 30_000) + expect(lease.deadline).toBe(4_000) + }) +}) diff --git a/server/packages/grid-transcription/src/supervisor.ts b/server/packages/grid-transcription/src/supervisor.ts new file mode 100644 index 000000000..b28ac64a7 --- /dev/null +++ b/server/packages/grid-transcription/src/supervisor.ts @@ -0,0 +1,197 @@ +import { fork, type ChildProcess } from "node:child_process" +import { fileURLToPath } from "node:url" +import type { CaptureIdentity } from "./authority.js" +import { monotonicMilliseconds, sameIdentity, validIdentity, validStopReason, WatchdogLease } from "./watchdog.js" +import type { ChildErrorCode, NativeExitReceipt, StopReason, WatchdogCommand, WatchdogMessage, WatchdogTimings } from "./watchdog.js" + +export type { RoomChildCommand, RoomChildMessage, NativeExitReceipt, StopReason } from "./watchdog.js" +export type NativeRoomStart = Readonly<{ + identity: CaptureIdentity; requestedAtMs: number; leaseMs: number; expiresAtMs: number; payload: unknown; +}> +export type NativeReadyReceipt = Readonly<{ identity: CaptureIdentity; pid: number }> +export type NativeRoomSupervisorOptions = Readonly<{ + roomChildPath: string | URL; environment: Readonly>; watchdogPath?: string | URL; + heartbeatIntervalMs?: number; heartbeatTimeoutMs?: number; stoppingGraceMs?: number; authorityGraceMs?: number; + termGraceMs?: number; killExitTimeoutMs?: number; startupTimeoutMs?: number; +}> +export class SupervisionError extends Error { + constructor(readonly code: ChildErrorCode | "exit_unconfirmed" | "watchdog") { + super(`Grid transcription supervision ${code}`) + this.name = "SupervisionError" + } +} +type Deferred = Readonly<{ promise: Promise; resolve: (value: T) => void; reject: (error: SupervisionError) => void }> +function deferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (error: SupervisionError) => void + const promise = new Promise((yes, no) => { resolve = yes; reject = no }) + // Exit may be observed after spontaneous failure without a caller awaiting stop. + void promise.catch(() => undefined) + return { promise, resolve, reject } +} +type ActiveRoom = { + identity: CaptureIdentity; watchdog: ChildProcess; lease: WatchdogLease; + ready: Deferred; exit: Deferred; + timer?: NodeJS.Timeout; readyReceived: boolean; admission: boolean; receipt?: NativeExitReceipt; +} +const defaults: WatchdogTimings = { + heartbeatTimeoutMs: 1_500, stoppingGraceMs: 5_000, authorityGraceMs: 50, + termGraceMs: 250, killExitTimeoutMs: 1_000, startupTimeoutMs: 10_000, +} +function path(value: string | URL): string { return typeof value === "string" ? value : fileURLToPath(value) } +function object(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined +} +function parseMessage(value: unknown, identity: CaptureIdentity): WatchdogMessage | undefined { + const message = object(value) + if (message?.type === "ready" && Number.isSafeInteger(message.pid) && Number(message.pid) > 0) { + return { type: "ready", pid: Number(message.pid) } + } + if (message?.type === "error" && typeof message.code === "string" + && ["protocol", "provider", "overflow", "stopped", "expired", "audio", "native", "api", "internal", "exit_unconfirmed", "watchdog"].includes(message.code)) { + return { type: "error", code: message.code as SupervisionError["code"] } + } + const receipt = object(message?.receipt) + if (message?.type !== "exit" || !receipt || !validIdentity(receipt.identity) || !sameIdentity(identity, receipt.identity) + || !Number.isSafeInteger(receipt.pid) || Number(receipt.pid) <= 0 + || !(receipt.code === null || Number.isInteger(receipt.code)) + || !(receipt.signal === null || typeof receipt.signal === "string") + || !(receipt.reason === "exited" || validStopReason(receipt.reason)) + || typeof receipt.hardKilled !== "boolean" || !Number.isSafeInteger(receipt.exitedAtMs)) return undefined + return { type: "exit", receipt: { identity: receipt.identity, pid: Number(receipt.pid), + code: receipt.code as number | null, signal: receipt.signal as NodeJS.Signals | null, + reason: receipt.reason, hardKilled: receipt.hardKilled, exitedAtMs: Number(receipt.exitedAtMs) } } +} +function validatePayload(payload: unknown): void { + // Validate JSON IPC before allocating a child; never log or echo the serialized value. + try { const encoded = JSON.stringify(payload); if (encoded !== undefined && Buffer.byteLength(encoded) > 256 * 1024) throw new Error() } + catch { throw new SupervisionError("protocol") } +} + +export class NativeRoomSupervisor { + private active: ActiveRoom | undefined + private readonly timings: WatchdogTimings + private readonly heartbeatIntervalMs: number + constructor(private readonly options: NativeRoomSupervisorOptions) { + this.timings = { heartbeatTimeoutMs: options.heartbeatTimeoutMs ?? defaults.heartbeatTimeoutMs, + stoppingGraceMs: options.stoppingGraceMs ?? defaults.stoppingGraceMs, + authorityGraceMs: options.authorityGraceMs ?? defaults.authorityGraceMs, + termGraceMs: options.termGraceMs ?? defaults.termGraceMs, + killExitTimeoutMs: options.killExitTimeoutMs ?? defaults.killExitTimeoutMs, + startupTimeoutMs: options.startupTimeoutMs ?? defaults.startupTimeoutMs } + this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? 250 + if (!Object.values(this.timings).every((value) => Number.isSafeInteger(value) && value >= 0 && value <= 30_000) + || this.timings.heartbeatTimeoutMs <= 0 || this.timings.killExitTimeoutMs <= 0 || this.timings.startupTimeoutMs <= 0 + || !Number.isSafeInteger(this.heartbeatIntervalMs) || this.heartbeatIntervalMs <= 0 + || this.heartbeatIntervalMs >= this.timings.heartbeatTimeoutMs + || !Object.entries(options.environment).every(([key, value]) => /^[A-Z_][A-Z0-9_]*$/.test(key) && typeof value === "string" + && key !== "NODE_OPTIONS" && key !== "NODE_PATH")) throw new SupervisionError("protocol") + } + get hasActiveChild(): boolean { return this.active !== undefined } + get admitting(): boolean { return this.active?.admission ?? false } + get exitReceipt(): Promise | undefined { return this.active?.exit.promise } + + start(input: NativeRoomStart): Promise { + if (this.active) throw new SupervisionError("stopped") + validatePayload(input.payload) + let lease: WatchdogLease + try { lease = new WatchdogLease(Object.freeze({ ...input.identity }), input.requestedAtMs, input.leaseMs, input.expiresAtMs) } + catch { throw new SupervisionError("expired") } + const watchdog = fork(path(this.options.watchdogPath ?? new URL("./watchdog.js", import.meta.url)), [], { + execPath: process.execPath, execArgv: [], env: {}, stdio: ["ignore", "ignore", "ignore", "ipc"], serialization: "json", + }) + const active: ActiveRoom = { identity: lease.identity, watchdog, lease, ready: deferred(), exit: deferred(), + readyReceived: false, admission: true } + this.active = active + watchdog.on("message", (value: unknown) => { + const message = parseMessage(value, active.identity) + if (!message) { this.fail(active, "protocol"); return } + if (message.type === "ready") { + if (!active.admission || active.readyReceived) return + try { active.lease.assertCurrent() } catch { this.fail(active, "expired"); return } + active.readyReceived = true + active.ready.resolve({ identity: active.identity, pid: message.pid }) + } else if (message.type === "error") this.fail(active, message.code) + else { + active.receipt = message.receipt + this.send(active, { type: "ack_exit", identity: active.identity, pid: message.receipt.pid }) + active.admission = false + active.lease.stop() + clearInterval(active.timer) + if (!active.readyReceived) active.ready.reject(new SupervisionError("stopped")) + active.exit.resolve(message.receipt) + if (this.active === active) this.active = undefined + } + }) + watchdog.on("error", () => { + this.fail(active, "watchdog") + clearInterval(active.timer) + active.exit.reject(new SupervisionError("exit_unconfirmed")) + // Spawn failure can emit error without exit. Keep the slot fenced, but never hang its caller. + }) + watchdog.once("disconnect", () => { + // Let already-buffered IPC receipts drain before classifying a bare channel closure. + setImmediate(() => { if (!active.receipt) this.transportFailed(active) }) + }) + watchdog.once("close", () => { + clearInterval(active.timer) + if (active.receipt) return + active.admission = false + active.lease.stop() + const failure = new SupervisionError("exit_unconfirmed") + active.ready.reject(failure) + active.exit.reject(failure) + // Keep the occupied slot: watchdog death alone does not prove native exit. + }) + active.timer = setInterval(() => { + if (active.receipt) return + this.send(active, { type: "heartbeat", sentAtMs: monotonicMilliseconds() }) + }, this.heartbeatIntervalMs) + this.send(active, { type: "start", childPath: path(this.options.roomChildPath), environment: this.options.environment, + ...input, identity: active.identity, timings: this.timings }) + return active.ready.promise + } + renew(identity: CaptureIdentity, requestedAtMs: number, leaseMs: number, payload?: unknown): void { + const active = this.active + if (!active || !active.admission) throw new SupervisionError("stopped") + validatePayload(payload) + try { + if (!active.lease.renew(identity, requestedAtMs, leaseMs)) return + } catch { this.fail(active, "expired"); throw new SupervisionError("expired") } + this.send(active, { type: "renew", identity, requestedAtMs, leaseMs, payload }) + } + stop(reason: StopReason = "authority"): Promise { + const active = this.active + if (!active) return Promise.reject(new SupervisionError("stopped")) + if (!validStopReason(reason)) return Promise.reject(new SupervisionError("protocol")) + // Synchronous parent admission barrier, followed by the child's IPC barrier. + active.admission = false + active.lease.stop() + this.send(active, { type: "stop", reason }) + return active.exit.promise + } + private fail(active: ActiveRoom, code: SupervisionError["code"]): void { + if (active.receipt) return + active.admission = false + active.lease.stop() + active.ready.reject(new SupervisionError(code)) + if (code === "exit_unconfirmed") active.exit.reject(new SupervisionError(code)) + this.send(active, { type: "stop", reason: "authority" }) + } + private send(active: ActiveRoom, message: WatchdogCommand): void { + if (!active.watchdog.connected) { if (!active.receipt) this.transportFailed(active); return } + try { active.watchdog.send(message, (error) => { if (error && !active.receipt) this.transportFailed(active) }) } + catch { this.transportFailed(active) } + } + private transportFailed(active: ActiveRoom): void { + if (active.receipt) return + active.admission = false + active.lease.stop() + active.ready.reject(new SupervisionError("watchdog")) + active.exit.reject(new SupervisionError("exit_unconfirmed")) + clearInterval(active.timer) + // Closing the control channel makes the independent watchdog stop its owned child. + // Without that channel, its later OS exit cannot substitute for a native exit receipt. + if (active.watchdog.connected) { try { active.watchdog.disconnect() } catch { /* Already disconnected. */ } } + } +} diff --git a/server/packages/grid-transcription/src/track-worker.test.ts b/server/packages/grid-transcription/src/track-worker.test.ts new file mode 100644 index 000000000..d7f9bc004 --- /dev/null +++ b/server/packages/grid-transcription/src/track-worker.test.ts @@ -0,0 +1,360 @@ +import { describe, expect, test } from "bun:test" +import { TrackWorker, type SpeechConnection, type TrackWorkerOptions } from "./track-worker.js" +import { TranscriptionError, type FinalTurn } from "./protocol.js" +import type { ProviderOptions } from "./socket.js" + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} +const tick = () => new Promise((resolve) => setImmediate(resolve)) +async function until(predicate: () => boolean): Promise { + for (let count = 0; count < 100; count++) { if (predicate()) return; await tick() } + throw new Error("test event did not occur") +} +function fixture(overrides: Partial = {}) { + let microphone = true + let authority = true + let options: ProviderOptions | undefined + let nextId = 0 + const events: string[] = [] + const audio: number[][] = [] + const commits: string[] = [] + const finals: FinalTurn[] = [] + const failures: TranscriptionError[] = [] + const connection: SpeechConnection = { + appendAudio(frame) { audio.push([...frame]); events.push("audio") }, + commitTurn(id) { commits.push(id); events.push("commit") }, close() { events.push("close") }, + } + const worker = new TrackWorker({ model: "meeting", apiKey: "test-key", participantIdentity: "speaker-1", trackSid: "TR_test", + api: { async admit() { events.push("admit"); return `segment-${++nextId}` }, + async final(turnId, text) { finals.push({ turnId, text }); events.push("final") } }, + detector: { async probability(frame) { return frame[0] === 1 ? 1 : 0 }, close() { events.push("detector-close") } }, + assertRunAuthority() { if (!authority) throw new TranscriptionError("expired") }, + assertMicrophone() { if (!microphone) throw new TranscriptionError("stopped") }, + onFailure(error) { failures.push(error) }, + async connect(value) { options = value; events.push("connect"); return connection }, ...overrides, + }) + return { worker, connection, events, audio, commits, finals, failures, + final(turnId: string, text: string) { options?.onFinal({ turnId, text }) }, + revokeMicrophone() { microphone = false }, revokeRun() { authority = false } } +} +function frame(speech: boolean): Int16Array { const data = new Int16Array(320); data[0] = speech ? 1 : 0; return data } +async function utterance(value: ReturnType): Promise { + const committedBefore = value.commits.length + value.worker.push(frame(true)) + for (let index = 0; index < 28; index++) value.worker.push(frame(false)) + await until(() => value.commits.length > committedBefore || value.failures.length > 0) + await tick() +} + +describe("per microphone finite turn owner", () => { + test("a drained socket rotates at its age bound without replaying an admitted turn", async () => { + let clock = 0 + const value = fixture({ now: () => clock }) + await utterance(value) + clock = 9 * 60 * 1000 + value.final("segment-1", "First completed turn.") + await until(() => value.events.includes("close")) + await utterance(value) + await until(() => value.commits.length === 2) + expect(value.events.filter((event) => event === "connect")).toHaveLength(2) + expect(value.commits).toEqual(["segment-1", "segment-2"]) + await value.worker.stop(false) + }) + test("a non-cancellable inference promise fails boundedly even without more room audio", async () => { + const failed = deferred() + const inference = deferred() + const value = fixture({ detector: { probability: () => inference.promise, close() {} }, onFailure: failed.resolve }) + value.worker.push(frame(true)) + expect((await failed.promise).code).toBe("audio") + expect(value.audio).toHaveLength(0) + expect(value.events).not.toContain("admit") + await value.worker.stop(false) + }, 12_000) + test("API admission precedes every first provider byte; finals retain admitted ID", async () => { + const value = fixture() + await utterance(value) + expect(value.events.slice(0, 3)).toEqual(["admit", "connect", "audio"]) + expect(value.commits).toEqual(["segment-1"]) + value.final("segment-1", "Meeting sentence.") + await until(() => value.finals.length === 1) + expect(value.finals).toEqual([{ turnId: "segment-1", text: "Meeting sentence." }]) + await value.worker.stop(false) + expect(value.failures).toEqual([]) + }) + + test("capture continues while final persistence waits; graceful Stop drains finals in order", async () => { + const first = deferred() + const attempts: string[] = [] + const saved: FinalTurn[] = [] + let id = 0 + const value = fixture({ api: { + async admit() { return `segment-${++id}` }, + async final(turnId, text) { + attempts.push(turnId) + if (turnId === "segment-1") await first.promise + saved.push({ turnId, text }) + }, + } }) + await utterance(value) + value.final("segment-1", "First phrase.") + await utterance(value) + expect(value.commits).toEqual(["segment-1", "segment-2"]) + value.final("segment-2", "Second phrase.") + await tick() + expect(attempts).toEqual(["segment-1"]) + expect(saved).toEqual([]) + let drained = false + const stopping = value.worker.stop(true).then(() => { drained = true }) + await tick() + expect(drained).toBe(false) + first.resolve() + await stopping + expect(saved).toEqual([ + { turnId: "segment-1", text: "First phrase." }, + { turnId: "segment-2", text: "Second phrase." }, + ]) + expect(value.events).toContain("close") + expect(value.failures).toEqual([]) + }) + + test("destructive Stop interrupts graceful draining before a queued successor reaches the API", async () => { + const first = deferred() + const attempts: string[] = [] + let id = 0 + const value = fixture({ api: { + async admit() { return `segment-${++id}` }, + async final(turnId) { attempts.push(turnId); await first.promise }, + } }) + await utterance(value) + await utterance(value) + value.final("segment-1", "First phrase.") + value.final("segment-2", "Discard this queued phrase.") + const stopping = value.worker.stop(true) + await tick() + await value.worker.stop(false) + first.resolve() + await stopping + await tick(); await tick() + expect(attempts).toEqual(["segment-1"]) + expect(value.failures).toEqual([]) + }) + + test("a lost first response retries the same final before posting its successor", async () => { + const first = deferred() + const attempts: FinalTurn[] = [] + const saved: FinalTurn[] = [] + let id = 0 + const value = fixture({ api: { + async admit() { return `segment-${++id}` }, + async final(turnId, text) { + attempts.push({ turnId, text }) + if (attempts.length === 1) { await first.promise; throw new TranscriptionError("provider") } + saved.push({ turnId, text }) + }, + } }) + await utterance(value) + await utterance(value) + value.final("segment-1", "First phrase.") + value.final("segment-2", "Second phrase.") + await tick() + expect(attempts).toEqual([{ turnId: "segment-1", text: "First phrase." }]) + const stopping = value.worker.stop(true) + first.resolve() + await stopping + expect(attempts).toEqual([ + { turnId: "segment-1", text: "First phrase." }, + { turnId: "segment-1", text: "First phrase." }, + { turnId: "segment-2", text: "Second phrase." }, + ]) + expect(saved.map((turn) => turn.turnId)).toEqual(["segment-1", "segment-2"]) + expect(value.failures).toEqual([]) + }) + + test("exhausted final retries discard the queued successor", async () => { + const first = deferred() + const attempts: string[] = [] + let id = 0 + const value = fixture({ api: { + async admit() { return `segment-${++id}` }, + async final(turnId) { attempts.push(turnId); await first.promise; throw new TranscriptionError("provider") }, + } }) + await utterance(value) + await utterance(value) + value.final("segment-1", "First phrase.") + value.final("segment-2", "Must not pass the failed predecessor.") + first.resolve() + await until(() => value.failures.length === 1) + await value.worker.stop(true) + expect(attempts).toEqual(["segment-1", "segment-1"]) + expect(value.failures.map((failure) => failure.code)).toEqual(["provider"]) + }) + + test("run authority is rechecked before allowing a queued successor", async () => { + const first = deferred() + const attempts: string[] = [] + let id = 0 + const value = fixture({ api: { + async admit() { return `segment-${++id}` }, + async final(turnId) { attempts.push(turnId); await first.promise }, + } }) + await utterance(value) + await utterance(value) + value.final("segment-1", "First phrase.") + value.final("segment-2", "Must not persist after authority loss.") + value.revokeRun() + first.resolve() + await until(() => value.failures.length === 1) + expect(attempts).toEqual(["segment-1"]) + expect(value.failures[0]?.code).toBe("expired") + await value.worker.stop(false) + }) + + test("graceful Stop times out a held post and discards its queued successor", async () => { + const first = deferred() + const attempts: string[] = [] + let id = 0 + const value = fixture({ api: { + async admit() { return `segment-${++id}` }, + async final(turnId) { attempts.push(turnId); await first.promise }, + } }) + await utterance(value) + await utterance(value) + value.final("segment-1", "First phrase.") + value.final("segment-2", "Must not persist after the drain deadline.") + const began = performance.now() + await value.worker.stop(true) + const elapsed = performance.now() - began + expect(elapsed).toBeGreaterThanOrEqual(4_900) + expect(elapsed).toBeLessThan(6_000) + expect(value.events).toContain("close") + first.resolve() + await tick(); await tick() + expect(attempts).toEqual(["segment-1"]) + expect(value.failures).toEqual([]) + }, 7_000) + + test("queued samples are discarded if membership changes before inference", async () => { + const value = fixture() + value.worker.push(frame(true)) + value.revokeMicrophone() + await until(() => value.failures.length === 1) + expect(value.events).not.toContain("admit") + expect(value.audio).toHaveLength(0) + expect(value.failures[0]?.code).toBe("stopped") + }) + + test("late admission after Stop retires empty without opening the provider", async () => { + const admitted = deferred() + const value = fixture({ api: { admit() { return admitted.promise }, async final(turnId, text) { + value.finals.push({ turnId, text }) + } } }) + value.worker.push(frame(true)) + await tick(); await tick() + const stopped = value.worker.stop(true) + admitted.resolve("late-segment") + await stopped + expect(value.finals).toEqual([{ turnId: "late-segment", text: "" }]) + expect(value.events).not.toContain("connect") + expect(value.audio).toHaveLength(0) + }) + + test("late admission retirement waits behind an earlier final during graceful Stop", async () => { + const first = deferred() + const admitted = deferred() + const attempts: string[] = [] + const saved: FinalTurn[] = [] + let admissions = 0 + const value = fixture({ api: { + async admit() { return ++admissions === 1 ? "segment-1" : admitted.promise }, + async final(turnId, text) { + attempts.push(turnId) + if (turnId === "segment-1") await first.promise + saved.push({ turnId, text }) + }, + } }) + await utterance(value) + value.final("segment-1", "First phrase.") + value.worker.push(frame(true)) + await until(() => admissions === 2) + const stopping = value.worker.stop(true) + admitted.resolve("segment-2") + await tick() + expect(attempts).toEqual(["segment-1"]) + first.resolve() + await stopping + expect(saved).toEqual([ + { turnId: "segment-1", text: "First phrase." }, + { turnId: "segment-2", text: "" }, + ]) + expect(value.commits).toEqual(["segment-1"]) + expect(value.failures).toEqual([]) + }) + + test("late provider connect is closed after destructive stop and never sends PCM", async () => { + const connected = deferred() + let started = false + const value = fixture({ connect() { started = true; return connected.promise } }) + value.worker.push(frame(true)) + await until(() => started) + await value.worker.stop(false) + connected.resolve(value.connection) + await tick(); await tick() + expect(value.audio).toHaveLength(0) + expect(value.events).toContain("close") + expect(value.finals).toHaveLength(0) + }) + + test("ordinary voice leave accepts only the already admitted final", async () => { + const value = fixture() + await utterance(value) + const capturedBeforeLeave = value.audio.length + value.revokeMicrophone() + const stopping = value.worker.stop(true) + expect(() => value.worker.push(frame(true))).toThrow("stopped") + value.final("segment-1", "Finished before leaving.") + await stopping + expect(value.audio).toHaveLength(capturedBeforeLeave) + expect(value.finals).toEqual([{ turnId: "segment-1", text: "Finished before leaving." }]) + expect(value.failures).toHaveLength(0) + }) + + test("loss of run authority discards provider finals before API persistence", async () => { + const value = fixture() + await utterance(value) + value.revokeRun() + expect(() => value.final("segment-1", "Must not persist.")).toThrow("expired") + expect(value.finals).toHaveLength(0) + await value.worker.stop(false) + }) + + test("a slow provider cannot create an unbounded native PCM queue", async () => { + const connected = deferred() + let started = false + const value = fixture({ connect() { started = true; return connected.promise } }) + value.worker.push(frame(true)) + await until(() => started) + for (let index = 0; index < 100; index++) value.worker.push(frame(true)) + expect(() => value.worker.push(frame(true))).toThrow("overflow") + connected.resolve(value.connection) + await tick(); await tick() + expect(value.failures[0]?.code).toBe("overflow") + expect(value.audio).toHaveLength(0) + }) + + test("two unresolved commits bound provider and server admission backlog", async () => { + const value = fixture() + await utterance(value) + const before = value.commits.length + value.worker.push(frame(true)) + for (let index = 0; index < 28; index++) value.worker.push(frame(false)) + await until(() => value.commits.length > before) + value.worker.push(frame(true)) + await until(() => value.failures.length > 0) + expect(value.commits).toEqual(["segment-1", "segment-2"]) + expect(value.events.filter((event) => event === "admit")).toHaveLength(2) + expect(value.failures[0]?.code).toBe("overflow") + }) +}) diff --git a/server/packages/grid-transcription/src/track-worker.ts b/server/packages/grid-transcription/src/track-worker.ts new file mode 100644 index 000000000..fe961f232 --- /dev/null +++ b/server/packages/grid-transcription/src/track-worker.ts @@ -0,0 +1,271 @@ +import { randomUUID } from "node:crypto" +import { BoundedPcmQueue } from "./pcm.js" +import { MAX_PENDING_TURNS, TranscriptionError, type FinalTurn, type Model } from "./protocol.js" +import { SpeechSegmenter } from "./segmenter.js" +import type { SpeechDetector } from "./silero.js" +import { ProviderConnection, type ProviderOptions } from "./socket.js" +import { monotonicMilliseconds } from "./watchdog.js" + +export interface TurnApi { + admit(participantIdentity: string, trackSid: string, sourceTurnKey: string): Promise + final(segmentId: string, text: string): Promise +} +export interface SpeechConnection { appendAudio(samples: Int16Array): void; commitTurn(id: string): void; close(): void } +export type TrackWorkerOptions = Readonly<{ + model: Model; apiKey: string; participantIdentity: string; trackSid: string; + api: TurnApi; detector: SpeechDetector; + assertRunAuthority: () => void; assertMicrophone: () => void; + onFailure: (error: TranscriptionError) => void; + connect?: (options: ProviderOptions) => Promise; + now?: () => number; +}> +type PendingTurn = { timer: NodeJS.Timeout; posting: boolean } +const FINAL_TIMEOUT_MS = 5_000 +const SOCKET_MAX_AGE_MS = 9 * 60 * 1000 +const SOCKET_MAX_TURNS = 900 + +// Exactly one consumer owns each speaker's VAD, admission and provider commit order. +// Native RTC readers only push owned copies and never wait for inference or network I/O. +export class TrackWorker { + readonly sampleRate: number + private readonly queue: BoundedPcmQueue + private readonly segmenter: SpeechSegmenter + private readonly now: () => number + private readonly consumer: Promise + private accepting = true + private closed = false + private graceful = false + private connection: SpeechConnection | undefined + private connectionStartedAt = 0 + private socketTurns = 0 + private rotationTimer: NodeJS.Timeout | undefined + private current: { id: string; samples: number } | undefined + private readonly pending = new Map() + private readonly posting = new Set>() + private closePromise: Promise | undefined + private drainResolve: (() => void) | undefined + private drainTimer: NodeJS.Timeout | undefined + private processingTimer: NodeJS.Timeout | undefined + + constructor(private readonly options: TrackWorkerOptions) { + this.sampleRate = options.model === "meeting" ? 16_000 : 24_000 + this.queue = new BoundedPcmQueue(this.sampleRate * 2) + this.segmenter = new SpeechSegmenter(this.sampleRate) + this.now = options.now ?? monotonicMilliseconds + options.assertRunAuthority() + options.assertMicrophone() + this.consumer = this.consume().catch((error: unknown) => { + if (this.accepting) this.fail(error) + }) + } + + push(samples: Int16Array): void { + if (!this.accepting || this.closed) throw new TranscriptionError("stopped") + try { + this.options.assertRunAuthority() + this.options.assertMicrophone() + if (samples.length > this.sampleRate / 10) throw new TranscriptionError("audio") + this.queue.push(samples) + } catch (error) { this.fail(error); throw this.safe(error) } + } + + private async consume(): Promise { + while (this.accepting) { + const frame = await this.queue.take() + if (!frame || !this.accepting) return + // Native inference may not support cancellation. Failure closes the queue; + // room/process supervision contains a late or permanently blocked operation. + this.processingTimer = setTimeout(() => this.fail(new TranscriptionError("audio")), 7_000) + try { + this.options.assertRunAuthority() + this.options.assertMicrophone() + const probability = await this.options.detector.probability(frame) + if (!this.accepting) return + this.options.assertRunAuthority() + this.options.assertMicrophone() + for (const action of this.segmenter.accept(frame, probability)) { + if (!this.accepting) return + if (action.kind === "start") await this.startTurn(action.frames) + else if (action.kind === "audio") this.append(action.frame) + else this.commit() + } + } finally { clearTimeout(this.processingTimer); this.processingTimer = undefined } + } + } + + private async startTurn(frames: readonly Int16Array[]): Promise { + if (this.current || this.pending.size >= MAX_PENDING_TURNS) throw new TranscriptionError("overflow") + this.options.assertRunAuthority() + this.options.assertMicrophone() + const id = await this.options.api.admit(this.options.participantIdentity, this.options.trackSid, randomUUID()) + // A late admission never grants permission to resume ingestion after a stop or leave. + if (!this.accepting) { + if (this.graceful && !this.closed) this.trackPost(() => this.postEmpty(id)) + return + } + this.options.assertRunAuthority() + this.options.assertMicrophone() + this.current = { id, samples: 0 } + if (this.connection && (this.now() - this.connectionStartedAt >= SOCKET_MAX_AGE_MS || this.socketTurns >= SOCKET_MAX_TURNS)) { + // No unbounded completion history: rotate only with every old commit acknowledged. + if (this.pending.size !== 0) throw new TranscriptionError("overflow") + this.connection.close() + this.connection = undefined + clearTimeout(this.rotationTimer) + } + if (!this.connection) { + const connection = await (this.options.connect ?? ProviderConnection.connect)({ + model: this.options.model, apiKey: this.options.apiKey, + assertAuthority: () => this.assertFinalAuthority(), + onFinal: (turn) => this.receiveFinal(turn), + onFailure: (error) => this.fail(error), + }) + if (!this.accepting || this.closed) { connection.close(); return } + this.options.assertRunAuthority() + this.options.assertMicrophone() + this.connection = connection + this.connectionStartedAt = this.now() + this.socketTurns = 0 + this.rotationTimer = setTimeout(() => this.rotateIfIdle(), SOCKET_MAX_AGE_MS) + } + for (const frame of frames) this.append(frame) + } + + private append(frame: Int16Array): void { + if (!this.accepting || this.closed || !this.current || !this.connection) throw new TranscriptionError("stopped") + this.options.assertRunAuthority() + this.options.assertMicrophone() + this.connection.appendAudio(frame) + this.current.samples += frame.length + } + + private commit(): void { + const current = this.current + this.current = undefined + if (!current) return + this.assertFinalAuthority() + if (!this.connection || current.samples === 0) { + this.trackPost(() => this.postEmpty(current.id)) + return + } + if (this.pending.size >= MAX_PENDING_TURNS) throw new TranscriptionError("overflow") + // Manual finalize works best with real trailing silence; hard speech boundaries and + // manual Stop add a finite 200ms zero tail, never more captured room audio. + this.connection.appendAudio(new Int16Array(this.sampleRate / 5)) + const timer = setTimeout(() => this.fail(new TranscriptionError("provider")), FINAL_TIMEOUT_MS) + this.pending.set(current.id, { timer, posting: false }) + this.socketTurns++ + this.connection.commitTurn(current.id) + } + + private assertFinalAuthority(): void { + if (this.closed) throw new TranscriptionError("stopped") + this.options.assertRunAuthority() + // A pre-admitted speaker can leave voice while its immutable turn completes. + // The API independently fences current Space, destination and admitted attribution. + } + + private receiveFinal(turn: FinalTurn): void { + this.assertFinalAuthority() + const pending = this.pending.get(turn.turnId) + if (!pending || pending.posting) throw new TranscriptionError("protocol") + pending.posting = true + clearTimeout(pending.timer) + this.trackPost(() => this.postFinal(turn).finally(() => { + this.pending.delete(turn.turnId) + this.maybeDrained() + })) + } + + private async postFinal(turn: FinalTurn): Promise { + // Idempotent delivery may retry a lost response once; no other turn or text is substituted. + for (let attempt = 0; attempt < 2; attempt++) { + this.assertFinalAuthority() + try { + await this.options.api.final(turn.turnId, turn.text) + this.assertFinalAuthority() + return + } catch (error) { + if (attempt === 1 || (error instanceof TranscriptionError && error.code !== "provider")) throw error + } + } + } + private async postEmpty(id: string): Promise { await this.postFinal({ turnId: id, text: "" }) } + private trackPost(post: () => Promise): void { + // Provider finals arrive in commit order. Retain that order through API retries + // using the existing bounded posting ownership, while capture keeps consuming. + const predecessor = [...this.posting].at(-1) + const operation = predecessor ? predecessor.then(post) : post() + this.posting.add(operation) + void operation.catch((error: unknown) => { if (!this.closed) this.fail(error) }).finally(() => { + this.posting.delete(operation) + this.maybeDrained() + }) + } + private maybeDrained(): void { + this.rotateIfIdle() + if (!this.accepting && !this.current && this.pending.size === 0 && this.posting.size === 0) this.drainResolve?.() + } + private rotateIfIdle(): void { + if (!this.accepting || !this.connection || this.current || this.pending.size || this.posting.size + || (this.now() - this.connectionStartedAt < SOCKET_MAX_AGE_MS && this.socketTurns < SOCKET_MAX_TURNS)) return + this.connection.close() + this.connection = undefined + clearTimeout(this.rotationTimer) + this.rotationTimer = undefined + } + private safe(error: unknown): TranscriptionError { + return error instanceof TranscriptionError ? error : new TranscriptionError("provider") + } + private fail(error: unknown): void { + if (this.closed) return + this.discard() + this.options.onFailure(this.safe(error)) + } + + // This barrier is synchronous. Its promise represents bounded completion of accepted work. + stop(graceful: boolean): Promise { + if (this.closePromise) { + if (!graceful) this.discard() + return this.closePromise + } + this.accepting = false + this.graceful = graceful + this.queue.close() + this.segmenter.stop() + if (!graceful) { this.discard(); this.closePromise = Promise.resolve(); return this.closePromise } + this.closePromise = this.finishGraceful() + return this.closePromise + } + private async finishGraceful(): Promise { + // Inference/admit/connect can be pending; late results are checked before all writes. + await this.consumer + if (this.closed) return + this.commit() + if (this.pending.size || this.posting.size) { + await new Promise((resolve) => { + this.drainResolve = resolve + this.drainTimer = setTimeout(resolve, FINAL_TIMEOUT_MS) + this.maybeDrained() + }) + } + this.discard() + } + private discard(): void { + if (this.closed) return + this.closed = true + this.accepting = false + this.queue.close() + this.segmenter.stop() + this.connection?.close() + this.connection = undefined + this.current = undefined + for (const pending of this.pending.values()) clearTimeout(pending.timer) + this.pending.clear() + clearTimeout(this.drainTimer) + clearTimeout(this.processingTimer) + clearTimeout(this.rotationTimer) + this.options.detector.close() + this.drainResolve?.() + } +} diff --git a/server/packages/grid-transcription/src/watchdog.ts b/server/packages/grid-transcription/src/watchdog.ts new file mode 100644 index 000000000..cdbe622b8 --- /dev/null +++ b/server/packages/grid-transcription/src/watchdog.ts @@ -0,0 +1,284 @@ +import { fork, type ChildProcess } from "node:child_process" +import { realpathSync } from "node:fs" +import { fileURLToPath } from "node:url" +import type { CaptureIdentity } from "./authority.js" + +// hrtime is the system monotonic clock, shared by processes on Linux and macOS. +export function monotonicMilliseconds(): number { return Number(process.hrtime.bigint() / 1_000_000n) } + +export type StopReason = "stopping" | "authority" | "shutdown" | "startup" | "child_error" + | "parent_stalled" | "parent_disconnected" | "child_stalled" | "expired" +export type ChildErrorCode = "protocol" | "provider" | "overflow" | "stopped" | "expired" | "audio" | "native" | "api" | "internal" +export type RoomChildCommand = + | Readonly<{ type: "start"; identity: CaptureIdentity; payload: unknown; requestedAtMs: number; leaseMs: number; leaseDeadlineMs: number }> + | Readonly<{ type: "renew"; identity: CaptureIdentity; payload?: unknown; requestedAtMs: number; leaseMs: number; leaseDeadlineMs: number }> + | Readonly<{ type: "stop"; reason: StopReason }> +export type RoomChildMessage = Readonly<{ type: "ready" }> | Readonly<{ type: "stopped" }> + | Readonly<{ type: "heartbeat"; sentAtMs: number }> + | Readonly<{ type: "error"; code: ChildErrorCode }> +export type NativeExitReceipt = Readonly<{ + identity: CaptureIdentity; pid: number; code: number | null; signal: NodeJS.Signals | null; + reason: StopReason | "exited"; hardKilled: boolean; exitedAtMs: number; +}> +export type WatchdogTimings = Readonly<{ + heartbeatTimeoutMs: number; stoppingGraceMs: number; authorityGraceMs: number; + termGraceMs: number; killExitTimeoutMs: number; startupTimeoutMs: number; +}> +export type WatchdogStart = Readonly<{ + type: "start"; childPath: string; environment: Readonly>; + identity: CaptureIdentity; requestedAtMs: number; leaseMs: number; expiresAtMs: number; + payload: unknown; timings: WatchdogTimings; +}> +export type WatchdogCommand = WatchdogStart + | Readonly<{ type: "heartbeat"; sentAtMs: number }> + | Readonly<{ type: "renew"; identity: CaptureIdentity; requestedAtMs: number; leaseMs: number; payload?: unknown }> + | Readonly<{ type: "ack_exit"; identity: CaptureIdentity; pid: number }> + | Readonly<{ type: "stop"; reason: StopReason }> +export type WatchdogMessage = Readonly<{ type: "ready"; pid: number }> + | Readonly<{ type: "exit"; receipt: NativeExitReceipt }> + | Readonly<{ type: "error"; code: ChildErrorCode | "exit_unconfirmed" | "watchdog" }> + +export function sameIdentity(left: CaptureIdentity, right: CaptureIdentity): boolean { + return left.runId === right.runId && left.claimEpoch === right.claimEpoch && left.generation === right.generation +} +function object(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined +} +export function validIdentity(value: unknown): value is CaptureIdentity { + const identity = object(value) + return !!identity && typeof identity.runId === "string" && identity.runId.length > 0 && identity.runId.length <= 256 + && Number.isSafeInteger(identity.claimEpoch) && Number(identity.claimEpoch) > 0 + && Number.isSafeInteger(identity.generation) && Number(identity.generation) >= 0 +} +const stopReasons: readonly StopReason[] = ["stopping", "authority", "shutdown", "startup", "child_error", "parent_stalled", "parent_disconnected", "child_stalled", "expired"] +const errorCodes: readonly ChildErrorCode[] = ["protocol", "provider", "overflow", "stopped", "expired", "audio", "native", "api", "internal"] +export function validStopReason(value: unknown): value is StopReason { + return typeof value === "string" && stopReasons.some((reason) => reason === value) +} +export function parseChildMessage(value: unknown): RoomChildMessage | undefined { + const message = object(value) + if (message?.type === "ready" || message?.type === "stopped") return { type: message.type } + if (message?.type === "heartbeat" && Number.isSafeInteger(message.sentAtMs) && Number(message.sentAtMs) >= 0) { + return { type: "heartbeat", sentAtMs: Number(message.sentAtMs) } + } + if (message?.type === "error" && errorCodes.some((code) => code === message.code)) { + return { type: "error", code: message.code as ChildErrorCode } + } + return undefined +} + +// An expired or stopped lease is permanent; delayed responses cannot resurrect it. +export class WatchdogLease { + private stopped = false + private lastRequestMs: number + private deadlineMs: number + constructor(readonly identity: CaptureIdentity, requestedAtMs: number, leaseMs: number, + private readonly expiresAtMs: number, private readonly now = monotonicMilliseconds) { + if (!validIdentity(identity) || !Number.isFinite(expiresAtMs)) throw new Error("Grid watchdog protocol") + this.lastRequestMs = requestedAtMs + this.deadlineMs = this.calculateDeadline(requestedAtMs, leaseMs) + this.assertCurrent() + } + private calculateDeadline(requestedAtMs: number, leaseMs: number): number { + if (!Number.isSafeInteger(requestedAtMs) || requestedAtMs > this.now() + || !Number.isSafeInteger(leaseMs) || leaseMs <= 0 || leaseMs > 30_000) throw new Error("Grid watchdog protocol") + return Math.min(requestedAtMs + leaseMs, this.expiresAtMs) + } + get deadline(): number { return this.deadlineMs } + assertCurrent(): void { + if (this.stopped) throw new Error("Grid watchdog stopped") + if (this.now() >= this.deadlineMs) { this.stopped = true; throw new Error("Grid watchdog expired") } + } + renew(identity: CaptureIdentity, requestedAtMs: number, leaseMs: number): boolean { + this.assertCurrent() + if (!sameIdentity(identity, this.identity)) throw new Error("Grid watchdog protocol") + const deadline = this.calculateDeadline(requestedAtMs, leaseMs) + if (requestedAtMs <= this.lastRequestMs) return false + if (deadline <= this.now()) { this.stopped = true; throw new Error("Grid watchdog expired") } + this.lastRequestMs = requestedAtMs + this.deadlineMs = deadline + return true + } + stop(): void { this.stopped = true } +} + +function validTimings(value: unknown): value is WatchdogTimings { + const timings = object(value) + return !!timings && ["heartbeatTimeoutMs", "stoppingGraceMs", "authorityGraceMs", "termGraceMs", "killExitTimeoutMs", "startupTimeoutMs"] + .every((key) => Number.isSafeInteger(timings[key]) && Number(timings[key]) >= 0 && Number(timings[key]) <= 30_000) + && Number(timings.heartbeatTimeoutMs) > 0 && Number(timings.killExitTimeoutMs) > 0 && Number(timings.startupTimeoutMs) > 0 +} +function parseStart(value: unknown): WatchdogStart | undefined { + const message = object(value) + const environment = object(message?.environment) + if (message?.type !== "start" || typeof message.childPath !== "string" || !message.childPath + || !validIdentity(message.identity) || !validTimings(message.timings) || !environment + || !Object.entries(environment).every(([key, entry]) => /^[A-Z_][A-Z0-9_]*$/.test(key) && typeof entry === "string" + && key !== "NODE_OPTIONS" && key !== "NODE_PATH")) return undefined + if (typeof message.requestedAtMs !== "number" || typeof message.leaseMs !== "number" || typeof message.expiresAtMs !== "number") return undefined + return { type: "start", childPath: message.childPath, environment: environment as Record, + identity: message.identity, requestedAtMs: message.requestedAtMs, leaseMs: message.leaseMs, + expiresAtMs: message.expiresAtMs, payload: message.payload, timings: message.timings } +} + +// The watchdog is the child's actual parent. It never signals a caller-supplied PID. +// It remains alive if SIGKILL has no bounded exit receipt, keeping replacement fenced. +export function runWatchdog(): void { + let child: ChildProcess | undefined + let start: WatchdogStart | undefined + let lease: WatchdogLease | undefined + let heartbeatDeadlineMs = 0 + let childHeartbeatDeadlineMs = 0 + let startupDeadlineMs = 0 + let ready = false + let stopped: StopReason | undefined + let hardKilled = false + let exited = false + let exitReceipt: NativeExitReceipt | undefined + let supervisionTimer: NodeJS.Timeout | undefined + let escalationTimer: NodeJS.Timeout | undefined + const send = (message: WatchdogMessage, done?: () => void): void => { + if (!process.connected || !process.send) { done?.(); return } + try { process.send(message, (error) => { if (error) stop("parent_disconnected"); done?.() }) } + catch { stop("parent_disconnected"); done?.() } + } + const sendChild = (message: RoomChildCommand): void => { + if (!child?.connected) return + try { child.send(message, (error) => { if (error && !exited) stop("authority") }) } + catch { stop("authority") } + } + const kill = (signal: NodeJS.Signals): void => { + if (!child || exited) return + try { child.kill(signal) } catch { send({ type: "error", code: "watchdog" }) } + } + const escalate = (): void => { + kill("SIGTERM") + escalationTimer = setTimeout(() => { + if (exited) return + hardKilled = true + kill("SIGKILL") + escalationTimer = setTimeout(() => { + if (!exited) send({ type: "error", code: "exit_unconfirmed" }) + }, start?.timings.killExitTimeoutMs ?? 1_000) + }, start?.timings.termGraceMs ?? 250) + } + const stop = (reason: StopReason): void => { + if (exited) return + // Authority loss overrides an existing graceful stop immediately. + const shorten = stopped === "stopping" && reason !== "stopping" + if (stopped && !shorten) return + stopped = reason + lease?.stop() + clearTimeout(supervisionTimer) + clearTimeout(escalationTimer) + sendChild({ type: "stop", reason }) + if (!child) { process.exitCode = 1; process.disconnect?.(); return } + const grace = reason === "stopping" ? start?.timings.stoppingGraceMs : start?.timings.authorityGraceMs + escalationTimer = setTimeout(escalate, grace ?? 50) + if (reason === "stopping") supervise() + } + const supervise = (): void => { + clearTimeout(supervisionTimer) + if (!lease || exited || (stopped !== undefined && stopped !== "stopping")) return + const now = monotonicMilliseconds() + if (!stopped && now >= lease.deadline) { stop("expired"); return } + if (now >= heartbeatDeadlineMs) { stop("parent_stalled"); return } + if (ready && now >= childHeartbeatDeadlineMs) { stop("child_stalled"); return } + if (!stopped && !ready && now >= startupDeadlineMs) { stop("startup"); return } + const deadline = stopped === "stopping" ? Math.min(heartbeatDeadlineMs, ready ? childHeartbeatDeadlineMs : Infinity) + : Math.min(lease.deadline, heartbeatDeadlineMs, ready ? childHeartbeatDeadlineMs : startupDeadlineMs) + supervisionTimer = setTimeout(supervise, Math.max(1, deadline - now)) + } + process.on("message", (value: unknown) => { + const message = object(value) + if (exited) { + if (message?.type === "ack_exit" && exitReceipt && validIdentity(message.identity) + && sameIdentity(message.identity, exitReceipt.identity) && message.pid === exitReceipt.pid) { + if (process.connected) process.disconnect() + } + return + } + if (!start) { + // Signals or queued IPC can arrive between rejection/disconnect and process exit. + // A terminal pre-start stop must never allocate a child afterward. + if (stopped || exited) return + const input = parseStart(value) + if (!input) { send({ type: "error", code: "protocol" }); stop("authority"); return } + try { lease = new WatchdogLease(input.identity, input.requestedAtMs, input.leaseMs, input.expiresAtMs) } + catch { send({ type: "error", code: "expired" }); stop("expired"); return } + start = input + const now = monotonicMilliseconds() + heartbeatDeadlineMs = now + input.timings.heartbeatTimeoutMs + startupDeadlineMs = now + input.timings.startupTimeoutMs + try { child = fork(input.childPath, [], { execPath: process.execPath, execArgv: [], env: { ...input.environment }, + stdio: ["ignore", "ignore", "ignore", "ipc"], serialization: "json" }) } + catch { send({ type: "error", code: "native" }); stop("authority"); return } + const ownedChild = child + ownedChild.on("error", () => { send({ type: "error", code: "native" }); stop("child_error") }) + ownedChild.on("message", (event: unknown) => { + const native = parseChildMessage(event) + if (!native) { send({ type: "error", code: "protocol" }); stop("child_error"); return } + if (native.type === "ready" && !ready && !stopped) { + supervise() + if (stopped) return + ready = true + childHeartbeatDeadlineMs = monotonicMilliseconds() + input.timings.heartbeatTimeoutMs + if (ownedChild.pid !== undefined) send({ type: "ready", pid: ownedChild.pid }) + supervise() + } else if (native.type === "heartbeat") { + const now = monotonicMilliseconds() + if (native.sentAtMs > now || (ready && now >= childHeartbeatDeadlineMs)) { stop("child_stalled"); return } + childHeartbeatDeadlineMs = Math.max(childHeartbeatDeadlineMs, native.sentAtMs + input.timings.heartbeatTimeoutMs) + supervise() + } else if (native.type === "error") { send(native); stop("child_error") } + }) + ownedChild.once("exit", (code, signal) => { + exited = true + clearTimeout(supervisionTimer) + clearTimeout(escalationTimer) + const pid = ownedChild.pid + if (pid === undefined) { send({ type: "error", code: "native" }); process.disconnect?.(); return } + exitReceipt = { identity: input.identity, pid, code, signal, + reason: stopped ?? "exited", hardKilled, exitedAtMs: monotonicMilliseconds() } + // Keep the IPC channel alive until the parent observes this receipt. A stalled + // parent's queued heartbeat must not hit EPIPE before its buffered receipt. + send({ type: "exit", receipt: exitReceipt }) + }) + sendChild({ type: "start", identity: input.identity, payload: input.payload, requestedAtMs: input.requestedAtMs, + leaseMs: input.leaseMs, leaseDeadlineMs: lease.deadline }) + supervise() + return + } + if (message?.type === "heartbeat" && typeof message.sentAtMs === "number") { + const now = monotonicMilliseconds() + if (message.sentAtMs > now || !Number.isSafeInteger(message.sentAtMs)) { stop("authority"); return } + if ((stopped === undefined || stopped === "stopping") && now >= heartbeatDeadlineMs) { stop("parent_stalled"); return } + heartbeatDeadlineMs = Math.max(heartbeatDeadlineMs, message.sentAtMs + start.timings.heartbeatTimeoutMs) + supervise() + } else if (message?.type === "renew" && validIdentity(message.identity) + && typeof message.requestedAtMs === "number" && typeof message.leaseMs === "number") { + supervise() + if (stopped || !lease) return + try { + if (lease.renew(message.identity, message.requestedAtMs, message.leaseMs)) { + sendChild({ type: "renew", identity: message.identity, payload: message.payload, + requestedAtMs: message.requestedAtMs, leaseMs: message.leaseMs, leaseDeadlineMs: lease.deadline }) + } + supervise() + } catch { stop("authority") } + } else if (message?.type === "stop" && validStopReason(message.reason)) stop(message.reason) + else stop("authority") + }) + process.on("disconnect", () => stop("parent_disconnected")) + process.on("SIGTERM", () => stop("shutdown")) + process.on("SIGINT", () => stop("shutdown")) + // A parent that never delivers the initial start cannot leave a watchdog behind. + supervisionTimer = setTimeout(() => stop("startup"), 10_000) +} + +function isEntrypoint(): boolean { + if (!process.argv[1]) return false + try { return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]) } + catch { return false } +} +if (isEntrypoint()) runWatchdog() diff --git a/server/packages/grid-transcription/tsconfig.json b/server/packages/grid-transcription/tsconfig.json new file mode 100644 index 000000000..dbca062cd --- /dev/null +++ b/server/packages/grid-transcription/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", + "strict": true, "noUncheckedIndexedAccess": true, "skipLibCheck": true, + "rootDir": "src", "outDir": "dist", "declaration": true, + "types": ["node"], "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts"], "exclude": ["src/**/*.test.ts"] +} diff --git a/server/scripts/contracts/route-manifest.ts b/server/scripts/contracts/route-manifest.ts index e868a9b49..c78623dd9 100644 --- a/server/scripts/contracts/route-manifest.ts +++ b/server/scripts/contracts/route-manifest.ts @@ -3,6 +3,7 @@ import { mkdir, readFile } from "node:fs/promises" import { dirname, resolve } from "node:path" import { app } from "../../src/legacyServer" import { makeCandidateHttpApplication } from "../../src/core/http/candidateApplication" +import { gridTranscriptionWorkerPaths } from "../../src/core/http/gridTranscription.effect" import { startCoreHttpServer, type CoreHttpServerHandle, @@ -142,6 +143,7 @@ const routesFromOpenApi = ( } const candidateRawRoutes: ReadonlyArray = [ + ...gridTranscriptionWorkerPaths.map((path) => ({ method: "POST", path })), { method: "GET", path: "//" }, { method: "GET", @@ -206,6 +208,7 @@ const comparableLegacyRoutes = const intentionalCandidateOnly = new Set([ + ...gridTranscriptionWorkerPaths.map((path) => `POST\t${path}`), "DELETE\t/admin/reserved-usernames", "GET\t/", "GET\t/admin/email-campaigns", diff --git a/server/src/__tests__/contracts/fixtures/effect-route-manifest.json b/server/src/__tests__/contracts/fixtures/effect-route-manifest.json index 9ee103434..6a200197f 100644 --- a/server/src/__tests__/contracts/fixtures/effect-route-manifest.json +++ b/server/src/__tests__/contracts/fixtures/effect-route-manifest.json @@ -1,14 +1,14 @@ { "version": 1, - "count": 313, + "count": 319, "methodCounts": { "DELETE": 1, "GET": 153, - "POST": 157, + "POST": 163, "PUT": 1, "WS": 1 }, - "sha256": "907ff942ca7f0e8b9a3c87276faff8ef708516360e8cf2b4c2d7afc725e98777", + "sha256": "9cc716886cf0f2bb569141bc7fe1fcc6558c5953dbb4d13dcd891d891f720a66", "routes": [ { "method": "DELETE", @@ -626,6 +626,30 @@ "method": "GET", "path": "/waitlist/super_secret_sub_count" }, + { + "method": "POST", + "path": "/_internal/grid-transcription/admit" + }, + { + "method": "POST", + "path": "/_internal/grid-transcription/claim" + }, + { + "method": "POST", + "path": "/_internal/grid-transcription/final" + }, + { + "method": "POST", + "path": "/_internal/grid-transcription/heartbeat" + }, + { + "method": "POST", + "path": "/_internal/grid-transcription/renew" + }, + { + "method": "POST", + "path": "/_internal/grid-transcription/stopped" + }, { "method": "POST", "path": "/admin/auth/dev-login" diff --git a/server/src/__tests__/functions/grid.callTransfer.test.ts b/server/src/__tests__/functions/grid.callTransfer.test.ts new file mode 100644 index 000000000..8234504c9 --- /dev/null +++ b/server/src/__tests__/functions/grid.callTransfer.test.ts @@ -0,0 +1,656 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { randomUUID } from "node:crypto" +import { db } from "@in/server/db" +import { + gridPresence, + gridProviderEffects, + gridTranscriptionRuns, + gridTranscriptionSegments, + members, + messages, +} from "@in/server/db/schema" +import { waitForPostCommitHooks } from "@in/server/db/commitHooks" +import { + createGridRoom, + getGrid, + getGridHome, + joinGridRoom, + leaveGridRoom, + moveGridCallHere, + prepareGridConnection, + setGridAvatarMicrophoneEnabled, + setGridRoomTitle, +} from "@in/server/functions/grid" +import { toggleSpaceGrid } from "@in/server/functions/space.settings" +import { setGridTranscription } from "@in/server/functions/gridTranscription" +import * as livekit from "@in/server/modules/grid/livekit" +import { handleGridTranscriptionWorkerRequest } from "@in/server/modules/grid/transcription/worker" +import { GridConnectionUnavailableReason, GridTranscriptDestination } from "@inline-chat/protocol/core" +import { eq } from "drizzle-orm" +import { setupTestLifecycle, testUtils } from "../setup" + +describe("Grid call ownership", () => { + setupTestLifecycle() + const originalActivation = process.env["GRID_CALL_TRANSFER_ENABLED"] + beforeEach(() => { + process.env["GRID_CALL_TRANSFER_ENABLED"] = "true" + }) + afterEach(() => { + process.env["GRID_CALL_TRANSFER_ENABLED"] = originalActivation ?? "" + }) + + test("defaults off without changing legacy ownership or renewal, and checks supplied fences", async () => { + process.env["GRID_CALL_TRANSFER_ENABLED"] = "" + const { space, contexts } = await fixture(1) + const created = await createGridRoom({ spaceId: BigInt(space.id) }, contexts[0]!) + expect(created.grids[0]?.callTransferEnabled).toBe(false) + expect((await getGridHome({}, contexts[0]!)).callTransferEnabled).toBe(false) + const call = created.currentCall! + await expect( + moveGridCallHere({ callId: call.callId, expectedMembershipId: call.membershipId }, contexts[0]!), + ).rejects.toThrow() + await expect( + setGridAvatarMicrophoneEnabled( + { + expectedRoomId: call.roomId, + expectedMembershipId: randomUUID(), + enabled: true, + }, + contexts[0]!, + ), + ).rejects.toThrow() + const shortenedLease = new Date(Date.now() + 60_000) + await db + .update(gridPresence) + .set({ leaseExpiresAt: shortenedLease }) + .where(eq(gridPresence.userId, contexts[0]!.currentUserId)) + await getGrid({ spaceId: BigInt(space.id) }, contexts[0]!) + expect((await ownPresence(contexts[0]!)).leaseExpiresAt.getTime()).toBeGreaterThan(shortenedLease.getTime()) + }) + + test("activation rejects legacy mutations and Prepare, including an unfenced initial join", async () => { + const { space, contexts } = await fixture(2) + await expect(createGridRoom({ spaceId: BigInt(space.id) }, contexts[0]!)).rejects.toThrow() + const created = await createGridRoom({ spaceId: BigInt(space.id), expectedMembershipId: "" }, contexts[0]!) + const call = created.currentCall! + await expect(joinGridRoom({ roomId: call.roomId }, contexts[1]!)).rejects.toThrow() + const joined = await joinGridRoom({ roomId: call.roomId, expectedMembershipId: "" }, contexts[1]!) + const generation = joined.grids[0]!.rooms[0]!.connection!.generation + await expect(joinGridRoom({ roomId: call.roomId }, contexts[0]!)).rejects.toThrow() + await expect(createGridRoom({ spaceId: BigInt(space.id) }, contexts[0]!)).rejects.toThrow() + await expect( + setGridAvatarMicrophoneEnabled({ expectedRoomId: call.roomId, enabled: true }, contexts[0]!), + ).rejects.toThrow() + await expect(leaveGridRoom({ expectedRoomId: call.roomId }, contexts[0]!)).rejects.toThrow() + await expect(prepareGridConnection({ roomId: call.roomId, generation }, contexts[0]!)).rejects.toThrow() + expect((await ownPresence(contexts[0]!)).mediaMembershipId).toBe(call.membershipId) + }) + + test("OFF exact-fenced legacy Join can replace a foreign device while stale Join/Create remain rejected", async () => { + const { space, contexts, created, targetContext, generation } = await activeCall() + process.env["GRID_CALL_TRANSFER_ENABLED"] = "" + const old = created.currentCall! + const joined = await joinGridRoom( + { roomId: old.roomId, expectedMembershipId: old.membershipId }, + targetContext, + ) + const current = joined.currentCall! + expect((await ownPresence(contexts[0]!)).ownerSessionId).toBe(targetContext.currentSessionId) + expect(current.membershipId).not.toBe(old.membershipId) + // OFF retains legacy Join semantics; stable call identity belongs to Move. + expect(current.callId).not.toBe(old.callId) + expect(joined.grids[0]!.rooms[0]!.connection!.generation).not.toBe(generation) + const effects = await db.select().from(gridProviderEffects) + expect( + effects.some( + (effect) => effect.kind === "revoke_participant" && + effect.participantIdentity === `inline-grid-user-${contexts[0]!.currentUserId}-${old.membershipId}`, + ), + ).toBe(true) + expect(effects.some((effect) => effect.kind === "close_connection" && effect.connectionGeneration === generation)).toBe(true) + await expect(joinGridRoom({ roomId: old.roomId, expectedMembershipId: old.membershipId }, contexts[0]!)).rejects.toThrow() + await expect(createGridRoom({ spaceId: BigInt(space.id), expectedMembershipId: old.membershipId }, contexts[0]!)).rejects.toThrow() + expect((await ownPresence(contexts[0]!)).mediaMembershipId).toBe(current.membershipId) + }) + + test("OFF exact-fenced legacy Create can replace a foreign device without weakening its CAS", async () => { + const { space, contexts, created, targetContext } = await activeCall() + process.env["GRID_CALL_TRANSFER_ENABLED"] = "" + const old = created.currentCall! + const claimed = await createGridRoom( + { spaceId: BigInt(space.id), expectedMembershipId: old.membershipId }, + targetContext, + ) + const current = claimed.currentCall! + expect((await ownPresence(contexts[0]!)).ownerSessionId).toBe(targetContext.currentSessionId) + expect(current.roomId).not.toBe(old.roomId) + expect(current.membershipId).not.toBe(old.membershipId) + await expect(createGridRoom({ spaceId: BigInt(space.id), expectedMembershipId: old.membershipId }, contexts[0]!)).rejects.toThrow() + await expect(joinGridRoom({ roomId: old.roomId, expectedMembershipId: old.membershipId }, contexts[0]!)).rejects.toThrow() + expect((await ownPresence(contexts[0]!)).mediaMembershipId).toBe(current.membershipId) + }) + + test("OFF foreign exact fences cannot Leave, mute, Prepare, or renew; ON claims still require the owning device", async () => { + const { space, contexts, created, targetContext, generation } = await activeCall() + process.env["GRID_CALL_TRANSFER_ENABLED"] = "" + const call = created.currentCall! + const foreignInput = { expectedRoomId: call.roomId, expectedMembershipId: call.membershipId } + expect((await leaveGridRoom(foreignInput, targetContext)).grids).toEqual([]) + await expect(setGridAvatarMicrophoneEnabled({ ...foreignInput, enabled: true }, targetContext)).rejects.toThrow() + const prepared = await prepareGridConnection( + { roomId: call.roomId, generation, expectedMembershipId: call.membershipId }, + targetContext, + ) + expect(prepared.connection).toBeUndefined() + expect(prepared.unavailableReason).toBe(GridConnectionUnavailableReason.NOT_ACTIVE) + const shortenedLease = new Date(Date.now() + 60_000) + await db.update(gridPresence).set({ leaseExpiresAt: shortenedLease }).where(eq(gridPresence.userId, contexts[0]!.currentUserId)) + await getGrid({ spaceId: BigInt(space.id), expectedMembershipId: call.membershipId }, targetContext) + expect((await ownPresence(contexts[0]!)).leaseExpiresAt).toEqual(shortenedLease) + process.env["GRID_CALL_TRANSFER_ENABLED"] = "true" + await expect(joinGridRoom({ roomId: call.roomId, expectedMembershipId: call.membershipId }, targetContext)).rejects.toThrow() + await expect(createGridRoom({ spaceId: BigInt(space.id), expectedMembershipId: call.membershipId }, targetContext)).rejects.toThrow() + const unchanged = await ownPresence(contexts[0]!) + expect(unchanged.ownerSessionId).toBe(contexts[0]!.currentSessionId) + expect(unchanged.mediaMembershipId).toBe(call.membershipId) + }) + + test("browsing is read-only; only an exact admitted owner heartbeat renews", async () => { + const { space, contexts } = await fixture(1) + const created = await createGridRoom({ spaceId: BigInt(space.id), expectedMembershipId: "" }, contexts[0]!) + const shortenedLease = new Date(Date.now() + 60_000) + await db + .update(gridPresence) + .set({ leaseExpiresAt: shortenedLease }) + .where(eq(gridPresence.userId, contexts[0]!.currentUserId)) + await getGrid({ spaceId: BigInt(space.id) }, contexts[0]!) + await getGridHome({}, contexts[0]!) + await getGrid({ spaceId: BigInt(space.id), expectedMembershipId: randomUUID() }, contexts[0]!) + expect((await ownPresence(contexts[0]!)).leaseExpiresAt).toEqual(shortenedLease) + await getGrid({ spaceId: BigInt(space.id), expectedMembershipId: created.currentCall!.membershipId }, contexts[0]!) + expect((await ownPresence(contexts[0]!)).leaseExpiresAt.getTime()).toBeGreaterThan(shortenedLease.getTime()) + }) + + test("moves one stable presence to another device and isolates self-hosted media generations", async () => { + const { contexts, created, generation, targetContext } = await activeCall() + const before = await ownPresence(contexts[0]!) + await setGridAvatarMicrophoneEnabled( + { + expectedRoomId: created.currentCall!.roomId, + expectedMembershipId: before.mediaMembershipId, + enabled: true, + }, + contexts[0]!, + ) + const moved = await moveGridCallHere( + { callId: before.callId, expectedMembershipId: before.mediaMembershipId }, + targetContext, + ) + const after = await ownPresence(contexts[0]!) + expect(moved.moved).toBe(true) + expect(after.callId).toBe(before.callId) + expect(after.roomId).toBe(before.roomId) + expect(after.joinedAt).toEqual(before.joinedAt) + expect(after.ownerSessionId).toBe(targetContext.currentSessionId) + expect(after.mediaMembershipId).not.toBe(before.mediaMembershipId) + expect(after.microphoneEnabled).toBe(false) + expect(after.microphoneRevision).toBe(0) + expect(moved.currentCall).toMatchObject({ + callId: before.callId, + membershipId: after.mediaMembershipId, + ownedByCurrentSession: true, + ownerClientType: "ios", + }) + expect(moved.grids[0]!.rooms[0]!.avatars).toHaveLength(2) + expect(moved.grids[0]!.rooms[0]!.connection!.generation).toBe(generation + 1) + const oldView = await getGridHome({}, contexts[0]!) + expect(oldView.currentCall?.ownedByCurrentSession).toBe(false) + expect(oldView.currentCall?.membershipId).toBe(after.mediaMembershipId) + const effects = await db.select().from(gridProviderEffects) + expect( + effects.some( + (effect) => + effect.kind === "revoke_participant" && + effect.participantIdentity === `inline-grid-user-${contexts[0]!.currentUserId}-${before.mediaMembershipId}`, + ), + ).toBe(true) + expect( + effects.some((effect) => effect.kind === "close_connection" && effect.connectionGeneration === generation), + ).toBe(true) + }) + + test("concurrent transfer intents claim the observed membership only once", async () => { + const { contexts, created, targetContext } = await activeCall() + const otherSession = await testUtils.createSessionForUser(contexts[0]!.currentUserId, { clientType: "macos" }) + const otherContext = testUtils.functionContext({ + userId: contexts[0]!.currentUserId, + sessionId: otherSession.session.id, + }) + const input = { callId: created.currentCall!.callId, expectedMembershipId: created.currentCall!.membershipId } + const results = await Promise.all([moveGridCallHere(input, targetContext), moveGridCallHere(input, otherContext)]) + expect(results.filter((result) => result.moved)).toHaveLength(1) + const presence = await ownPresence(contexts[0]!) + const winnerContext = results[0]!.moved ? targetContext : otherContext + expect(presence.ownerSessionId).toBe(winnerContext.currentSessionId) + expect(presence.callId).toBe(input.callId) + expect(await db.select().from(gridPresence)).toHaveLength(2) + }) + + test("A to B to A cannot apply A's old Leave, mute, join, create, renew, Prepare, or move", async () => { + const { space, contexts, created, targetContext } = await activeCall() + const old = created.currentCall! + const toPhone = await moveGridCallHere( + { callId: old.callId, expectedMembershipId: old.membershipId }, + targetContext, + ) + const back = await moveGridCallHere( + { callId: old.callId, expectedMembershipId: toPhone.currentCall!.membershipId }, + contexts[0]!, + ) + const current = back.currentCall! + const generation = back.grids[0]!.rooms[0]!.connection!.generation + expect(current.membershipId).not.toBe(old.membershipId) + expect( + (await leaveGridRoom({ expectedRoomId: old.roomId, expectedMembershipId: old.membershipId }, contexts[0]!)).grids, + ).toEqual([]) + await expect( + setGridAvatarMicrophoneEnabled( + { expectedRoomId: old.roomId, expectedMembershipId: old.membershipId, enabled: true }, + contexts[0]!, + ), + ).rejects.toThrow() + await expect( + joinGridRoom({ roomId: old.roomId, expectedMembershipId: old.membershipId }, contexts[0]!), + ).rejects.toThrow() + await expect( + createGridRoom({ spaceId: BigInt(space.id), expectedMembershipId: old.membershipId }, contexts[0]!), + ).rejects.toThrow() + const prepared = await prepareGridConnection( + { roomId: old.roomId, generation, expectedMembershipId: old.membershipId }, + contexts[0]!, + ) + expect(prepared.connection).toBeUndefined() + expect(prepared.unavailableReason).toBe(GridConnectionUnavailableReason.NOT_ACTIVE) + const shortenedLease = new Date(Date.now() + 60_000) + await db + .update(gridPresence) + .set({ leaseExpiresAt: shortenedLease }) + .where(eq(gridPresence.userId, contexts[0]!.currentUserId)) + await getGrid({ spaceId: BigInt(space.id), expectedMembershipId: old.membershipId }, contexts[0]!) + expect((await ownPresence(contexts[0]!)).leaseExpiresAt).toEqual(shortenedLease) + const staleMove = await moveGridCallHere( + { callId: old.callId, expectedMembershipId: old.membershipId }, + targetContext, + ) + expect(staleMove.moved).toBe(false) + expect(staleMove.connection).toBeUndefined() + expect((await ownPresence(contexts[0]!)).mediaMembershipId).toBe(current.membershipId) + }) + + test("explicit same-login cold Resume replaces ownership; retrying its old CAS does not replace it again", async () => { + const { contexts, created } = await activeCall() + const before = await ownPresence(contexts[0]!) + const input = { callId: created.currentCall!.callId, expectedMembershipId: before.mediaMembershipId } + const resumed = await moveGridCallHere(input, contexts[0]!) + expect(resumed.moved).toBe(true) + expect(resumed.currentCall!.callId).toBe(before.callId) + expect(resumed.currentCall!.membershipId).not.toBe(before.mediaMembershipId) + expect((await ownPresence(contexts[0]!)).joinedAt).toEqual(before.joinedAt) + const retried = await moveGridCallHere(input, contexts[0]!) + expect(retried.moved).toBe(false) + expect(retried.currentCall!.membershipId).toBe(resumed.currentCall!.membershipId) + }) + + test("Leave ends the call episode even when the named room is reused", async () => { + const { space, contexts } = await fixture(1) + const created = await createGridRoom({ spaceId: BigInt(space.id), expectedMembershipId: "" }, contexts[0]!) + const old = created.currentCall! + await setGridRoomTitle({ roomId: old.roomId, title: "Persistent room" }, contexts[0]!) + const left = await leaveGridRoom({ expectedRoomId: old.roomId, expectedMembershipId: old.membershipId }, contexts[0]!) + expect(left.currentCall).toBeUndefined() + const rejoined = await joinGridRoom({ roomId: old.roomId, expectedMembershipId: "" }, contexts[0]!) + expect(rejoined.currentCall!.callId).not.toBe(old.callId) + const staleMove = await moveGridCallHere({ callId: old.callId, expectedMembershipId: rejoined.currentCall!.membershipId }, contexts[0]!) + expect(staleMove.moved).toBe(false) + expect((await ownPresence(contexts[0]!)).callId).toBe(rejoined.currentCall!.callId) + }) + + test("an expired call cannot be moved or renewed back into existence", async () => { + const { space, contexts, created, targetContext } = await activeCall() + const old = created.currentCall! + await db.update(gridPresence).set({ leaseExpiresAt: new Date(Date.now() - 1) }).where(eq(gridPresence.userId, contexts[0]!.currentUserId)) + const moved = await moveGridCallHere({ callId: old.callId, expectedMembershipId: old.membershipId }, targetContext) + expect(moved.moved).toBe(false) + expect(moved.currentCall).toBeUndefined() + expect(moved.connection).toBeUndefined() + await getGrid({ spaceId: BigInt(space.id), expectedMembershipId: old.membershipId }, contexts[0]!) + expect(await db.select().from(gridPresence).where(eq(gridPresence.userId, contexts[0]!.currentUserId))).toEqual([]) + }) + + test("Home returns self ownership independently of its capped public avatars", async () => { + const { space, contexts } = await fixture(5) + const created = await createGridRoom({ spaceId: BigInt(space.id), expectedMembershipId: "" }, contexts[0]!) + for (const context of contexts.slice(1)) { + await joinGridRoom({ roomId: created.currentCall!.roomId, expectedMembershipId: "" }, context) + } + const home = await getGridHome({}, contexts[0]!) + expect(home.spaces[0]!.recentAvatars).toHaveLength(4) + expect(home.currentCall?.callId).toBe(created.currentCall!.callId) + expect(home.currentCall?.ownerClientType).toBe("macos") + const otherHome = await getGridHome({}, contexts[1]!) + expect(otherHome.currentCall?.callId).not.toBe(created.currentCall!.callId) + expect(otherHome.currentCall?.ownedByCurrentSession).toBe(true) + expect(otherHome.currentCall).not.toHaveProperty("ownerSessionId") + expect(otherHome.currentCall).not.toHaveProperty("deviceId") + }) + + test("a delayed mint cannot return credentials for newer ownership on the same login", async () => { + const { contexts, created, targetContext } = await activeCall() + const enteredMint = Promise.withResolvers() + const releaseMint = Promise.withResolvers() + const pending = moveGridCallHere( + { + callId: created.currentCall!.callId, + expectedMembershipId: created.currentCall!.membershipId, + }, + targetContext, + async (input) => { + enteredMint.resolve() + await releaseMint.promise + return { + connection: input.connection, + serverUrl: "wss://grid.example.test", + participantIdentity: input.participantIdentity!, + token: "test-only-credential", + expiresAt: BigInt(Math.floor(Date.now() / 1000) + 300), + callId: input.callId!, + membershipId: input.mediaMembershipId!, + } + }, + ) + await enteredMint.promise + const firstPhoneMembership = (await ownPresence(contexts[0]!)).mediaMembershipId + let latestPhoneMembership = firstPhoneMembership + try { + const toMac = await moveGridCallHere( + { callId: created.currentCall!.callId, expectedMembershipId: firstPhoneMembership }, + contexts[0]!, + ) + const toPhoneAgain = await moveGridCallHere( + { callId: created.currentCall!.callId, expectedMembershipId: toMac.currentCall!.membershipId }, + targetContext, + ) + latestPhoneMembership = toPhoneAgain.currentCall!.membershipId + } finally { + releaseMint.resolve() + } + const oldResult = await pending + expect(oldResult.moved).toBe(true) + expect(oldResult.currentCall!.membershipId).toBe(firstPhoneMembership) + expect(oldResult.currentCall!.membershipId).not.toBe(latestPhoneMembership) + expect(oldResult.connection).toBeUndefined() + expect((await ownPresence(targetContext)).mediaMembershipId).toBe(latestPhoneMembership) + }) +}) + +describe("Grid call transfer transcription authority", () => { + setupTestLifecycle() + const secret = "grid-transfer-test-secret-0123456789012345" + const original = new Map() + let providerSpy: ReturnType> | undefined + + beforeEach(() => { + for (const [key, value] of Object.entries({ + GRID_CALL_TRANSFER_ENABLED: "true", + GRID_TRANSCRIPTION_ENABLED: "true", + GRID_TRANSCRIPTION_WORKER_SECRET: secret, + GRID_TRANSCRIPTION_MODEL: "meeting", + GRID_TRANSCRIPTION_ALLOWED_CLIENT_VERSIONS: "qualified-transfer-test", + })) { + original.set(key, process.env[key]) + process.env[key] = value + } + }) + afterEach(async () => { + await waitForPostCommitHooks() + providerSpy?.mockRestore() + providerSpy = undefined + for (const [key, value] of original) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + original.clear() + }) + + async function workerApi(action: string, body: unknown, token = secret) { + return handleGridTranscriptionWorkerRequest( + new Request(`http://localhost/_internal/grid-transcription/${action}`, { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ) + } + + for (const provider of ["self_hosted", "cloud"] as const) { + for (const sameLogin of [false, true]) { + const replacement = sameLogin ? "same-login Resume" : "device Move" + test(`${provider} ${replacement} discards admitted speech before ownership replacement`, async () => { + providerSpy = spyOn(livekit, "getLiveKitGridConfig").mockReturnValue({ + serverUrl: "wss://grid-transfer.example.test", + apiKey: "transfer-test-key", + apiSecret: "transfer-test-secret-0123456789", + provider, + }) + const { space, contexts } = await fixture(2, "qualified-transfer-test") + const owner = contexts[0]! + const created = await createGridRoom( + { spaceId: BigInt(space.id), expectedMembershipId: "", microphoneEnabled: true }, + owner, + ) + const roomId = created.currentCall!.roomId + await joinGridRoom({ roomId, expectedMembershipId: "", microphoneEnabled: true }, contexts[1]!) + const before = await ownPresence(owner) + const target = sameLogin + ? owner + : testUtils.functionContext({ + userId: owner.currentUserId, + sessionId: ( + await testUtils.createSessionForUser(owner.currentUserId, { + clientType: "macos", + clientVersion: "qualified-transfer-test", + }) + ).session.id, + }) + const currentRoom = async (context: typeof owner) => { + const result = await getGrid({ spaceId: BigInt(space.id) }, context) + return result.grid!.rooms.find((room) => room.id === roomId)! + } + const start = async (context: typeof owner) => { + const room = await currentRoom(context) + const presence = await ownPresence(context) + return setGridTranscription( + { + roomId, + enabled: true, + requestId: randomUUID(), + expectedMembershipId: presence.mediaMembershipId, + expectedGeneration: room.connection!.generation, + expectedRunId: room.transcription?.runId, + expectedRevision: room.transcription?.revision ?? 0, + destination: GridTranscriptDestination.GRID_TRANSCRIPT_NEW, + }, + context, + ) + } + const generation = (await currentRoom(owner)).connection!.generation + const heartbeat = await workerApi("heartbeat", { + workerId: "grid-transfer-test", + model: "meeting", + ready: true, + }) + expect(heartbeat.status).toBe(200) + await start(owner) + const claimed = await workerApi("claim", { workerId: "grid-transfer-test" }) + expect(claimed.status).toBe(200) + const claim = (await claimed.json()) as { + runId: string + runToken: string + participants: { identity: string; userId: number; membershipId: string }[] + } + const participant = claim.participants.find((speaker) => speaker.userId === owner.currentUserId)! + expect(participant.membershipId).toBe(before.mediaMembershipId) + const admission = { + participantIdentity: participant.identity, + trackSid: "TR-transfer-old", + sourceTurnKey: "pending-before-move", + } + const admitted = await workerApi("admit", admission, claim.runToken) + expect(admitted.status).toBe(200) + const ticket = (await admitted.json()) as { segmentId: string } + const peer = claim.participants.find((speaker) => speaker.userId === contexts[1]!.currentUserId)! + const admittedPeer = await workerApi( + "admit", + { participantIdentity: peer.identity, trackSid: "TR-transfer-peer", sourceTurnKey: "peer-before-move" }, + claim.runToken, + ) + expect(admittedPeer.status).toBe(200) + const peerTicket = (await admittedPeer.json()) as { segmentId: string } + const run = ( + await db.select().from(gridTranscriptionRuns).where(eq(gridTranscriptionRuns.id, claim.runId)) + )[0]! + const priorMessages = await db.select().from(messages).where(eq(messages.chatId, run.transcriptChatId)) + + const moved = await moveGridCallHere( + { callId: before.callId, expectedMembershipId: before.mediaMembershipId }, + target, + ) + const after = await ownPresence(target) + expect(moved.moved).toBe(true) + expect(after.callId).toBe(before.callId) + expect(after.roomId).toBe(before.roomId) + expect(after.joinedAt).toEqual(before.joinedAt) + expect(after.ownerSessionId).toBe(target.currentSessionId) + expect(after.mediaMembershipId).not.toBe(before.mediaMembershipId) + expect(after.microphoneEnabled).toBe(false) + expect((await currentRoom(target)).connection!.generation).toBe( + generation + (provider === "self_hosted" ? 1 : 0), + ) + const stoppedRun = ( + await db.select().from(gridTranscriptionRuns).where(eq(gridTranscriptionRuns.id, claim.runId)) + )[0] + expect(stoppedRun).toMatchObject({ + state: "stopping", + interruptionReason: provider === "self_hosted" ? "generation_changed" : "authority_lost", + }) + const oldSegment = ( + await db.select().from(gridTranscriptionSegments).where(eq(gridTranscriptionSegments.id, ticket.segmentId)) + )[0] + expect(oldSegment?.state).toBe("discarded") + expect( + (await db.select().from(gridTranscriptionSegments).where(eq(gridTranscriptionSegments.id, peerTicket.segmentId)))[0]?.state, + ).toBe("discarded") + expect((await workerApi("admit", { ...admission, sourceTurnKey: "after-move" }, claim.runToken)).status).toBe(409) + const final = await workerApi( + "final", + { segmentId: ticket.segmentId, text: "Late old membership" }, + claim.runToken, + ) + expect(final.status).toBe(200) + expect(await final.json()).toEqual({ messageId: null }) + expect( + await (await workerApi("final", { segmentId: peerTicket.segmentId, text: "Late peer turn" }, claim.runToken)).json(), + ).toEqual({ messageId: null }) + expect(await db.select().from(messages).where(eq(messages.chatId, run.transcriptChatId))).toEqual(priorMessages) + expect(await (await workerApi("renew", {}, claim.runToken)).json()).toMatchObject({ + state: "stopping", + allowFinalFlush: false, + }) + const staleCredentials = await prepareGridConnection( + { roomId, generation, expectedMembershipId: before.mediaMembershipId }, + owner, + ) + expect(staleCredentials.connection).toBeUndefined() + expect(staleCredentials.unavailableReason).toBe(GridConnectionUnavailableReason.NOT_ACTIVE) + const freshCredentials = await prepareGridConnection( + { + roomId, + generation: (await currentRoom(target)).connection!.generation, + expectedMembershipId: after.mediaMembershipId, + }, + target, + ) + expect(freshCredentials.connection).toMatchObject({ + callId: before.callId, + membershipId: after.mediaMembershipId, + connection: { generation: generation + (provider === "self_hosted" ? 1 : 0) }, + }) + await expect(start(target)).rejects.toThrow() + expect((await workerApi("stopped", {}, claim.runToken)).status).toBe(200) + await start(target) + const newClaimResponse = await workerApi("claim", { workerId: "grid-transfer-test" }) + expect(newClaimResponse.status).toBe(200) + const newClaim = (await newClaimResponse.json()) as typeof claim + expect(newClaim.runId).not.toBe(claim.runId) + const newParticipant = newClaim.participants.find((speaker) => speaker.userId === owner.currentUserId)! + expect(newParticipant.membershipId).toBe(after.mediaMembershipId) + const newAdmission = await workerApi( + "admit", + { participantIdentity: newParticipant.identity, trackSid: "TR-transfer-new", sourceTurnKey: "new-turn" }, + newClaim.runToken, + ) + expect(newAdmission.status).toBe(200) + const newTicket = (await newAdmission.json()) as { segmentId: string } + const newFinal = await workerApi("final", { segmentId: newTicket.segmentId, text: "New owner turn" }, newClaim.runToken) + expect(newFinal.status).toBe(200) + const committed = (await newFinal.json()) as { messageId: number | null } + expect(committed.messageId).not.toBeNull() + const newRun = ( + await db.select().from(gridTranscriptionRuns).where(eq(gridTranscriptionRuns.id, newClaim.runId)) + )[0]! + expect( + (await db.select().from(messages).where(eq(messages.chatId, newRun.transcriptChatId))) + .some((message) => message.messageId === committed.messageId), + ).toBe(true) + }) + } + } +}) + +async function fixture(count: number, clientVersion?: string) { + const space = await testUtils.createSpace("Grid call transfer") + if (!space) throw new Error("Expected fixture Space") + const users = await Promise.all( + Array.from({ length: count }, () => testUtils.createUser(`${randomUUID()}@example.test`)), + ) + await db.insert(members).values( + users.map((user, index) => ({ + spaceId: space.id, + userId: user.id, + role: index === 0 ? ("owner" as const) : ("member" as const), + })), + ) + const sessions = await Promise.all( + users.map((user) => testUtils.createSessionForUser(user.id, { clientType: "macos", clientVersion })), + ) + const contexts = users.map((user, index) => + testUtils.functionContext({ userId: user.id, sessionId: sessions[index]!.session.id }), + ) + await toggleSpaceGrid({ spaceId: BigInt(space.id), enabled: true }, contexts[0]!) + return { space, users, contexts } +} + +async function activeCall() { + const { space, users, contexts } = await fixture(2) + const created = await createGridRoom({ spaceId: BigInt(space.id), expectedMembershipId: "" }, contexts[0]!) + const joined = await joinGridRoom({ roomId: created.currentCall!.roomId, expectedMembershipId: "" }, contexts[1]!) + const target = await testUtils.createSessionForUser(users[0]!.id, { clientType: "ios" }) + const targetContext = testUtils.functionContext({ userId: users[0]!.id, sessionId: target.session.id }) + return { space, contexts, created, generation: joined.grids[0]!.rooms[0]!.connection!.generation, targetContext } +} + +async function ownPresence(context: ReturnType) { + const [presence] = await db.select().from(gridPresence).where(eq(gridPresence.userId, context.currentUserId)) + if (!presence) throw new Error("Expected live fixture presence") + return presence +} diff --git a/server/src/__tests__/functions/grid.test.ts b/server/src/__tests__/functions/grid.test.ts index 12bb32e4e..a28a3c19f 100644 --- a/server/src/__tests__/functions/grid.test.ts +++ b/server/src/__tests__/functions/grid.test.ts @@ -38,6 +38,7 @@ describe("grid", () => { enabled: false, rooms: [], revision: 0n, + callTransferEnabled: false, }) await expect(createGridRoom({ spaceId: BigInt(space.id) }, contexts[0]!)).rejects.toThrow() diff --git a/server/src/__tests__/modules/grid.livekit.test.ts b/server/src/__tests__/modules/grid.livekit.test.ts index cb5121296..9dafece8d 100644 --- a/server/src/__tests__/modules/grid.livekit.test.ts +++ b/server/src/__tests__/modules/grid.livekit.test.ts @@ -176,6 +176,31 @@ describe("Grid LiveKit credentials", () => { expect(oldClaims.video?.room).not.toBe(currentClaims.video?.room) }) + test("binds private credentials to ownership without disclosing the stable call in room metadata", async () => { + const config = { + serverUrl: "wss://grid.example.test", + apiKey: "test-key", + apiSecret: "test-secret-that-is-long-enough-for-hmac", + } + const callId = "00000000-0000-4000-8000-000000000001" + const mediaMembershipId = "00000000-0000-4000-8000-000000000002" + const credentials = await createGridConnectionCredentials({ + connection: { roomId: 42n, generation: 3, startedAt: 100n }, + userId: 7, + callId, + mediaMembershipId, + participantIdentity: `inline-grid-user-7-${mediaMembershipId}`, + }, config) + expect(credentials?.callId).toBe(callId) + expect(credentials?.membershipId).toBe(mediaMembershipId) + const claims = await new TokenVerifier(config.apiKey, config.apiSecret).verify(credentials!.token) + const metadata = JSON.parse(claims.metadata!) + expect(metadata).not.toHaveProperty("callId") + expect(metadata).not.toHaveProperty("membershipId") + expect(metadata).toEqual({ inlineUserId: 7, gridRoomId: "42", generation: 3 }) + expect(claims.sub).toBe(`inline-grid-user-7-${mediaMembershipId}`) + }) + test("returns unavailable when the provider is not configured", async () => { await expect( createGridConnectionCredentials( diff --git a/server/src/core/http/candidateApplication.ts b/server/src/core/http/candidateApplication.ts index fc3bf7aa7..e443a0101 100644 --- a/server/src/core/http/candidateApplication.ts +++ b/server/src/core/http/candidateApplication.ts @@ -1,4 +1,5 @@ import { Layer } from "effect" +import { GridTranscriptionWorkerRoutesLive } from "./gridTranscription.effect" import { AuthApiGroup, } from "../../controllers/auth.effect" @@ -70,29 +71,32 @@ export const makeCandidateHttpApplication = ({ const botApi = makeBotApiBase(apiBaseUrl) .add(BotApiGroup) - return makeHttpApplication({ - platform: defineExecutableHttpApi({ - ...defineOpenApiDocument({ - api: platformApi, - jsonPath: "/v1/reference/json", - swaggerPath: "/v1/reference", + return Layer.merge( + makeHttpApplication({ + platform: defineExecutableHttpApi({ + ...defineOpenApiDocument({ + api: platformApi, + jsonPath: "/v1/reference/json", + swaggerPath: "/v1/reference", + }), + handlers: Layer.mergeAll( + AuthRouteGroupLive, + V1IdentitySpacesRouteGroupLive, + V1MessagingProvidersRouteGroupLive, + AuxiliaryRouteGroupLive, + AdminRouteGroupLive, + ), }), - handlers: Layer.mergeAll( - AuthRouteGroupLive, - V1IdentitySpacesRouteGroupLive, - V1MessagingProvidersRouteGroupLive, - AuxiliaryRouteGroupLive, - AdminRouteGroupLive, - ), - }), - bot: defineExecutableHttpApi({ - ...defineOpenApiDocument({ - api: botApi, - jsonPath: "/bot-api-reference/json", - swaggerPath: "/bot-api-reference", + bot: defineExecutableHttpApi({ + ...defineOpenApiDocument({ + api: botApi, + jsonPath: "/bot-api-reference/json", + swaggerPath: "/bot-api-reference", + }), + handlers: BotRouteGroupLive, }), - handlers: BotRouteGroupLive, + middleware, }), - middleware, - }) + GridTranscriptionWorkerRoutesLive, + ) } diff --git a/server/src/core/http/gridTranscription.effect.bun.test.ts b/server/src/core/http/gridTranscription.effect.bun.test.ts new file mode 100644 index 000000000..21fa739ae --- /dev/null +++ b/server/src/core/http/gridTranscription.effect.bun.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test" +import { makeCandidateHttpApplication } from "./candidateApplication" +import { gridTranscriptionWorkerPaths } from "./gridTranscription.effect" +import { startCoreHttpServer } from "./host" + +test("production application mounts every internal transcription action with a closed default gate", async () => { + const host = await startCoreHttpServer({ + application: makeCandidateHttpApplication({ middleware: { isProduction: false } }), + hostname: "127.0.0.1", + installSignalHandlers: false, + }) + try { + for (const path of gridTranscriptionWorkerPaths) { + const response = await fetch(`http://127.0.0.1:${host.port}${path}`, { + method: "POST", + body: JSON.stringify({ workerId: "untrusted-worker" }), + headers: { "content-type": "application/json" }, + }) + expect(response.status).toBe(503) + expect(response.headers.get("cache-control")).toBe("no-store") + expect(await response.json()).toEqual({ error: "unavailable" }) + } + } finally { + await host.shutdown() + } +}) diff --git a/server/src/core/http/gridTranscription.effect.ts b/server/src/core/http/gridTranscription.effect.ts new file mode 100644 index 000000000..66c25475f --- /dev/null +++ b/server/src/core/http/gridTranscription.effect.ts @@ -0,0 +1,33 @@ +import { Data, Effect, Layer } from "effect" +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { handleGridTranscriptionWorkerRequest } from "../../modules/grid/transcription/worker" +import { webResponseToHttpServerResponse } from "./webResponse" + +export const gridTranscriptionWorkerPaths = ["heartbeat", "claim", "renew", "admit", "final", "stopped"].map( + (action) => `/_internal/grid-transcription/${action}` as const, +) + +class GridTranscriptionTransportError extends Data.TaggedError("GridTranscriptionTransportError") {} + +const workerRequest = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + const webRequest = yield* HttpServerRequest.toWeb(request).pipe( + Effect.mapError(() => new GridTranscriptionTransportError()), + ) + const response = yield* Effect.tryPromise({ + try: () => handleGridTranscriptionWorkerRequest(webRequest), + catch: () => new GridTranscriptionTransportError(), + }) + return webResponseToHttpServerResponse(response) +}).pipe( + Effect.catchTag("GridTranscriptionTransportError", () => + Effect.succeed(HttpServerResponse.jsonUnsafe({ error: "unavailable" }, { status: 503 })), + ), +) + +/** Internal worker authentication stays inside the bounded request handler. */ +export const GridTranscriptionWorkerRoutesLive = Layer.effectDiscard( + HttpRouter.HttpRouter.use((router) => + Effect.forEach(gridTranscriptionWorkerPaths, (path) => router.add("POST", path, workerRequest)), + ), +) diff --git a/server/src/db/models/chats.ts b/server/src/db/models/chats.ts index fdcdbfe25..db53226b5 100644 --- a/server/src/db/models/chats.ts +++ b/server/src/db/models/chats.ts @@ -25,6 +25,7 @@ import { dialogOpenDefaultsForChat, dialogOrderForPlacement, } from "@in/server/modules/dialogOpen" +import { messageActivityPredicate } from "@in/server/modules/message/activity" const log = new Log("chats") @@ -291,7 +292,7 @@ async function refreshLastMessageId(chatId: number) { let [message] = await tx .select() .from(messages) - .where(eq(messages.chatId, chatId)) + .where(and(eq(messages.chatId, chatId), messageActivityPredicate())) .orderBy(desc(messages.messageId)) .limit(1) @@ -319,7 +320,7 @@ async function refreshLastMessageIdTransaction(chatId: number, transaction: (tx: let [message] = await tx .select() .from(messages) - .where(eq(messages.chatId, chatId)) + .where(and(eq(messages.chatId, chatId), messageActivityPredicate())) .orderBy(desc(messages.messageId)) .limit(1) diff --git a/server/src/db/models/messages.ts b/server/src/db/models/messages.ts index 095f1b968..0ebfaaea1 100644 --- a/server/src/db/models/messages.ts +++ b/server/src/db/models/messages.ts @@ -57,6 +57,8 @@ import { import { decryptStoredBlockContent } from "@in/server/modules/message/blockContentPayload" import { collectReadyBlockPhotoIds, validateBlockContent } from "@in/server/modules/message/blockContent" import { decryptAgentRef } from "@in/server/modules/agentSessions/crypto" +import { messageActivityPredicate } from "@in/server/modules/message/activity" +import { invalidateGridTranscriptionForHistory } from "@in/server/modules/grid/transcription/state" const log = new Log("MessageModel", LogLevel.INFO) @@ -880,6 +882,19 @@ async function deleteMessages( const messageIdsNum = messageIds.map((id) => Number(id)) + // Orphaning anchored children also mutates their chat rows. Own these + // before the run fence, matching final writers' chat -> run order. + await tx.select({ id: chats.id }).from(chats) + .where(and(eq(chats.parentChatId, chatId), inArray(chats.parentMessageId, messageIdsNum))) + .orderBy(chats.id).for("update") + + // Deleting a visible anchor/link retires capture and future enrollment. + // A finalized turn deletion leaves its admitted/final dedup tombstone. + await invalidateGridTranscriptionForHistory(tx, { + messageRefs: messageIdsNum.map((messageId) => ({ chatId, messageId })), + reason: "message_deleted", + }) + // Clear first to allow for deleting await tx.update(chats).set({ lastMsgId: null }).where(eq(chats.id, chatId)) @@ -904,7 +919,7 @@ async function deleteMessages( let [message] = await tx .select({ messageId: messages.messageId }) .from(messages) - .where(eq(messages.chatId, chatId)) + .where(and(eq(messages.chatId, chatId), messageActivityPredicate())) .orderBy(desc(messages.messageId)) .limit(1) @@ -1004,7 +1019,12 @@ async function editMessage(input: EditMessageInput): Promise<{ } const [currentMessage] = await tx - .select({ blockContentId: messages.blockContentId, rev: messages.rev, voiceId: messages.voiceId }) + .select({ + blockContentId: messages.blockContentId, rev: messages.rev, voiceId: messages.voiceId, + systemMessageEncrypted: messages.systemMessageEncrypted, + systemMessageIv: messages.systemMessageIv, + systemMessageTag: messages.systemMessageTag, + }) .from(messages) .where(and(eq(messages.chatId, chatId), eq(messages.messageId, messageId))) .for("update") @@ -1016,6 +1036,16 @@ async function editMessage(input: EditMessageInput): Promise<{ } throw ModelError.MessageInvalid } + if ( + currentMessage.systemMessageEncrypted && currentMessage.systemMessageIv && currentMessage.systemMessageTag && + decryptSystemMessagePayload({ + encrypted: currentMessage.systemMessageEncrypted, + iv: currentMessage.systemMessageIv, + authTag: currentMessage.systemMessageTag, + }).event.oneofKind === "gridTranscript" + ) { + throw ModelError.MessageInvalid + } if ( (input.expectedRevision !== undefined && (currentMessage.rev ?? 0) !== input.expectedRevision) || (input.expectedVoiceId !== undefined && currentMessage.voiceId !== input.expectedVoiceId) diff --git a/server/src/db/schema/grid.ts b/server/src/db/schema/grid.ts index 06a4abd87..c8acdc92e 100644 --- a/server/src/db/schema/grid.ts +++ b/server/src/db/schema/grid.ts @@ -2,7 +2,19 @@ import { sessions } from "@in/server/db/schema/sessions" import { spaces } from "@in/server/db/schema/spaces" import { lower, users } from "@in/server/db/schema/users" import { sql } from "drizzle-orm" -import { boolean, check, index, integer, pgTable, serial, timestamp, uniqueIndex, uuid, varchar } from "drizzle-orm/pg-core" +import { + bigint, + boolean, + check, + index, + integer, + pgTable, + serial, + timestamp, + uniqueIndex, + uuid, + varchar, +} from "drizzle-orm/pg-core" export const gridRooms = pgTable( "grid_rooms", @@ -16,6 +28,9 @@ export const gridRooms = pgTable( .references(() => users.id, { onDelete: "restrict" }), title: varchar("title", { length: 80 }), locked: boolean("locked").default(false).notNull(), + // Validated against the room Space/private ancestry at use time. No FK: + // ordinary chat deletion must not invert Grid room -> chat lock order. + roomThreadId: integer("room_thread_id"), connectionGeneration: integer("connection_generation").default(0).notNull(), connectionStartedAt: timestamp("connection_started_at", { mode: "date", precision: 3 }), createdAt: timestamp("created_at", { mode: "date", precision: 3 }) @@ -31,11 +46,11 @@ export const gridRooms = pgTable( namedRoomUnique: uniqueIndex("grid_rooms_space_title_unique") .on(table.spaceId, lower(table.title)) .where(sql`${table.title} is not null`), - connectionGenerationCheck: check( - "grid_rooms_connection_generation_check", - sql`${table.connectionGeneration} >= 0`, + connectionGenerationCheck: check("grid_rooms_connection_generation_check", sql`${table.connectionGeneration} >= 0`), + titleNotEmptyCheck: check( + "grid_rooms_title_not_empty_check", + sql`${table.title} is null or length(${table.title}) > 0`, ), - titleNotEmptyCheck: check("grid_rooms_title_not_empty_check", sql`${table.title} is null or length(${table.title}) > 0`), }), ) @@ -57,16 +72,14 @@ export const gridPresence = pgTable( microphoneEnabled: boolean("microphone_enabled").default(false).notNull(), microphoneRevision: integer("microphone_revision").default(0).notNull(), mediaMembershipId: uuid("media_membership_id").defaultRandom().notNull(), + callId: uuid("call_id").defaultRandom().notNull(), leaseExpiresAt: timestamp("lease_expires_at", { mode: "date", precision: 3 }).notNull(), }, (table) => ({ roomIndex: index("grid_presence_room_id_idx").on(table.roomId), ownerSessionIndex: index("grid_presence_owner_session_id_idx").on(table.ownerSessionId), leaseExpiryIndex: index("grid_presence_lease_expires_at_idx").on(table.leaseExpiresAt), - microphoneRevisionCheck: check( - "grid_presence_microphone_revision_check", - sql`${table.microphoneRevision} >= 0`, - ), + microphoneRevisionCheck: check("grid_presence_microphone_revision_check", sql`${table.microphoneRevision} >= 0`), }), ) @@ -126,3 +139,94 @@ export type DbGridPresence = typeof gridPresence.$inferSelect export type DbNewGridPresence = typeof gridPresence.$inferInsert export type DbGridProviderEffect = typeof gridProviderEffects.$inferSelect export type DbNewGridProviderEffect = typeof gridProviderEffects.$inferInsert + +// Source room/chat/message IDs deliberately have no foreign keys: provenance +// and final dedup tombstones survive ordinary room/history deletion. +export const gridTranscriptionRuns = pgTable( + "grid_transcription_runs", + { + id: uuid().primaryKey().defaultRandom(), + sourceRoomId: integer("source_room_id").notNull(), + spaceId: integer("space_id").notNull(), + roomChatId: integer("room_chat_id").notNull(), + transcriptChatId: integer("transcript_chat_id").notNull(), + destinationParentChatId: integer("destination_parent_chat_id").notNull(), + originalAnchorId: integer("original_anchor_id").notNull(), + roomLinkMessageId: integer("room_link_message_id"), + actorUserId: integer("actor_user_id").notNull(), + requestId: uuid("request_id").notNull(), + model: varchar({ length: 32 }).notNull(), + state: varchar({ length: 16 }).notNull(), + generation: integer().notNull(), + providerTarget: varchar("provider_target", { length: 255 }).notNull(), + revision: integer().default(1).notNull(), + claimEpoch: integer("claim_epoch").default(0).notNull(), + workerId: varchar("worker_id", { length: 80 }), + leaseExpiresAt: timestamp("lease_expires_at", { mode: "date", precision: 3 }), + expiresAt: timestamp("expires_at", { mode: "date", precision: 3 }).notNull(), + stopRequestedAt: timestamp("stop_requested_at", { mode: "date", precision: 3 }), + interruptionReason: varchar("interruption_reason", { length: 80 }), + createdAt: timestamp("created_at", { mode: "date", precision: 3 }).defaultNow().notNull(), + }, + (table) => ({ + requestUnique: uniqueIndex("grid_transcription_request_unique").on(table.actorUserId, table.requestId), + activeRoomUnique: uniqueIndex("grid_transcription_active_room_unique") + .on(table.sourceRoomId) + .where(sql`${table.state} in ('starting','active','stopping')`), + activeDestinationUnique: uniqueIndex("grid_transcription_active_destination_unique") + .on(table.transcriptChatId) + .where(sql`${table.state} in ('starting','active','stopping')`), + recentIndex: index("grid_transcription_space_recent_idx").on(table.spaceId, table.createdAt), + roomIndex: index("grid_transcription_source_room_idx").on(table.sourceRoomId), + stateCheck: check( + "grid_transcription_state_check", + sql`${table.state} in ('starting','active','stopping','stopped','interrupted')`, + ), + }), +) + +export const gridTranscriptionSegments = pgTable( + "grid_transcription_segments", + { + id: uuid().primaryKey().defaultRandom(), + runId: uuid("run_id") + .notNull() + .references(() => gridTranscriptionRuns.id), + claimEpoch: integer("claim_epoch").notNull(), + speakerUserId: integer("speaker_user_id").notNull(), + membershipId: uuid("membership_id").notNull(), + trackSid: varchar("track_sid", { length: 128 }).notNull(), + sourceTurnKey: varchar("source_turn_key", { length: 128 }).notNull(), + state: varchar({ length: 16 }).default("admitted").notNull(), + messageId: integer("message_id"), + admittedAt: timestamp("admitted_at", { mode: "date", precision: 3 }).defaultNow().notNull(), + }, + (table) => ({ + sourceUnique: uniqueIndex("grid_transcription_segment_source_unique").on( + table.runId, + table.trackSid, + table.sourceTurnKey, + ), + stateCheck: check( + "grid_transcription_segment_state_check", + sql`${table.state} in ('admitted','finalized','discarded')`, + ), + }), +) + +export const gridTranscriptionWorker = pgTable("grid_transcription_worker", { + id: integer().primaryKey(), + workerId: varchar("worker_id", { length: 80 }).notNull(), + model: varchar({ length: 32 }).notNull(), + heartbeatAt: timestamp("heartbeat_at", { mode: "date", precision: 3 }).notNull(), +}) + +export type DbGridTranscriptionRun = typeof gridTranscriptionRuns.$inferSelect + +/** Separate revision owner avoids Space -> chat / chat -> Space trigger deadlocks. */ +export const gridTranscriptionSpaceRevisions = pgTable("grid_transcription_space_revisions", { + spaceId: integer("space_id").primaryKey(), + revision: bigint({ mode: "bigint" }) + .notNull() + .default(sql`0`), +}) diff --git a/server/src/env.ts b/server/src/env.ts index a14cec6bc..de4ecbc59 100644 --- a/server/src/env.ts +++ b/server/src/env.ts @@ -113,6 +113,13 @@ export const LIVEKIT_URL = process.env["LIVEKIT_URL"] export const LIVEKIT_API_KEY = process.env["LIVEKIT_API_KEY"] export const LIVEKIT_API_SECRET = process.env["LIVEKIT_API_SECRET"] export const LIVEKIT_PROVIDER = process.env["LIVEKIT_PROVIDER"] ?? "self_hosted" +// Coordinate activation across the server fleet and upgraded Apple clients. +// Do not turn this off with live transferred calls: doing so restores legacy +// unfenced behavior. Production process environment remains immutable. +export const isGridCallTransferEnabled = () => { + const value = process.env["GRID_CALL_TRANSFER_ENABLED"]?.trim().toLowerCase() + return value === "true" || value === "1" +} export const LIVEKIT_CLOUD_URL = process.env["LIVEKIT_CLOUD_URL"] export const LIVEKIT_CLOUD_API_KEY = process.env["LIVEKIT_CLOUD_API_KEY"] export const LIVEKIT_CLOUD_API_SECRET = process.env["LIVEKIT_CLOUD_API_SECRET"] diff --git a/server/src/functions/_functions.ts b/server/src/functions/_functions.ts index 7e799c753..592c88b02 100644 --- a/server/src/functions/_functions.ts +++ b/server/src/functions/_functions.ts @@ -1,3 +1,4 @@ +import { openGridThread, setGridTranscription, listGridTranscripts } from "./gridTranscription" import { deleteMessage } from "@in/server/functions/messages.deleteMessage" import { deleteMessageAttachment } from "@in/server/functions/messages.deleteMessageAttachment" import { clearChatHistory } from "@in/server/functions/messages.clearChatHistory" @@ -86,6 +87,7 @@ import { getGrid, getGridHome, joinGridRoom, + moveGridCallHere, leaveGridRoom, prepareGridConnection, setGridAvatarMicrophoneEnabled, @@ -193,10 +195,14 @@ export const Functions = { getUpdates: getUpdates, }, grid: { + openThread: openGridThread, + setTranscription: setGridTranscription, + listTranscripts: listGridTranscripts, get: getGrid, getHome: getGridHome, createRoom: createGridRoom, joinRoom: joinGridRoom, + moveCallHere: moveGridCallHere, leaveRoom: leaveGridRoom, setRoomTitle: setGridRoomTitle, setRoomLocked: setGridRoomLocked, diff --git a/server/src/functions/grid.ts b/server/src/functions/grid.ts index 0614be2be..58fd1448a 100644 --- a/server/src/functions/grid.ts +++ b/server/src/functions/grid.ts @@ -10,11 +10,14 @@ import type { GridHomeSpace, Grid, GridConnection, + GridCurrentCall, GridRoom, JoinGridRoomInput, JoinGridRoomResult, LeaveGridRoomInput, LeaveGridRoomResult, + MoveGridCallHereInput, + MoveGridCallHereResult, PrepareGridConnectionInput, PrepareGridConnectionResult, SetGridAvatarMicrophoneEnabledInput, @@ -31,7 +34,20 @@ import { SessionId, SpaceId, UserId } from "@in/server/core/schema/identifiers" import { db } from "@in/server/db" import { SpaceSettingsModel } from "@in/server/db/models/spaceSettings" import { UsersModel } from "@in/server/db/models/users" -import { gridPresence, gridRooms, members, sessions, spaces, users, type DbGridRoom } from "@in/server/db/schema" +import { + chats, + gridPresence, + gridRooms, + gridTranscriptionRuns, + gridTranscriptionSpaceRevisions, + members, + sessions, + spaces, + users, + type DbGridPresence, + type DbGridRoom, +} from "@in/server/db/schema" +import { isGridCallTransferEnabled } from "@in/server/env" import type { Transaction } from "@in/server/db/types" import type { FunctionContext } from "@in/server/functions/_types" import { AccessGuards } from "@in/server/modules/authorization/accessGuards" @@ -46,6 +62,14 @@ import { } from "@in/server/modules/grid/livekit" import { setGridAvatarMicrophoneState } from "@in/server/modules/grid/avatarState" import { notifyGridChanged } from "@in/server/modules/grid/realtime" +import { enrollGridRoomHistory } from "./gridTranscription" +import { + encodeGridTranscription, + stopGridTranscription, + transcriptionAvailable, + retireUnclaimedGridTranscriptions, + supportedGridTranscriptionSession, +} from "@in/server/modules/grid/transcription/state" import { enqueueGridParticipantRevocation } from "@in/server/modules/grid/providerEffects" import { activeGridConnection, @@ -66,12 +90,28 @@ const MAX_NAMED_ROOMS_PER_SPACE = 50 const MAX_ROOM_TITLE_LENGTH = 80 const log = new Log("grid") +type GridClaim = { + roomId: number + callId: string + mediaMembershipId: string + currentCall: GridCurrentCall +} + +type GridClaimMutationState = GridMutationState & { claim?: GridClaim } + +type GridCredentialIssuer = typeof createGridConnectionCredentials + +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + export async function getGrid(input: GetGridInput, context: FunctionContext): Promise { const spaceId = positiveId(input.spaceId, RealtimeRpcError.SpaceIdInvalid) await AccessGuards.ensureSpaceMember(spaceId, context.currentUserId) - await refreshPresenceLeases({ spaceId, context }) - return { grid: await buildGrid(spaceId, context) } + const expectedMembershipId = + input.expectedMembershipId === undefined ? undefined : membershipFence(input.expectedMembershipId) + await refreshPresenceLeases({ spaceId, context, expectedMembershipId }) + const [grid, currentCall] = await Promise.all([buildGrid(spaceId, context), buildCurrentCall(context)]) + return { grid, currentCall } } export async function getGridHome( @@ -138,12 +178,16 @@ export async function getGridHome( } summaries.sort((a, b) => { - if ((a.activeAvatarCount > 0) !== (b.activeAvatarCount > 0)) { + if (a.activeAvatarCount > 0 !== b.activeAvatarCount > 0) { return a.activeAvatarCount > 0 ? -1 : 1 } return Number(b.latestActivityAt - a.latestActivityAt) }) - return { spaces: summaries } + return { + spaces: summaries, + currentCall: await buildCurrentCall(context), + callTransferEnabled: isGridCallTransferEnabled(), + } } export async function createGridRoom( @@ -166,11 +210,12 @@ export async function createGridRoom( // The preflight can race a Space leave or deletion before this lock. await ensureGridAvailable(spaceId, context.currentUserId, tx) - const state: GridMutationState = { + const state: GridClaimMutationState = { affectedSpaceIds: new Set([spaceId]), endedConnections: [], } const existing = await getActivePresenceWithRoom(tx, context.currentUserId, state) + assertPresenceFence(existing, context, input.expectedMembershipId, { allowLegacyTakeover: true }) if (existing && existing.room.spaceId === spaceId && existing.room.title === null) { const [occupancy] = await tx .select({ value: count() }) @@ -198,9 +243,7 @@ export async function createGridRoom( await notifyGridChanged(state) const [grids, connection] = await Promise.all([ buildGrids(state.affectedSpaceIds, context), - state.changedRoomId - ? credentialsForParticipant(state.changedRoomId, context.currentUserId, context.currentSessionId) - : undefined, + state.claim ? credentialsForParticipant(state.claim, context) : undefined, state.changedRoomId ? notifyConnectionReady(state.changedRoomId) : undefined, ]) log.debug("GRID_TRACE phase=create_rpc_done", { @@ -212,6 +255,7 @@ export async function createGridRoom( return { grids, connection, + currentCall: state.claim?.currentCall, } } @@ -236,12 +280,13 @@ export async function joinGridRoom( if (!room) throw RealtimeRpcError.BadRequest() await ensureGridAvailable(room.spaceId, context.currentUserId, tx) - const state: GridMutationState = { + const state: GridClaimMutationState = { affectedSpaceIds: new Set([room.spaceId]), changedRoomId: room.id, endedConnections: [], } const existing = await getActivePresenceWithRoom(tx, context.currentUserId, state) + assertPresenceFence(existing, context, input.expectedMembershipId, { allowLegacyTakeover: true }) if (existing?.room.id === room.id) { await movePresence(tx, context, room, existing, state, input.microphoneEnabled) return state @@ -267,7 +312,7 @@ export async function joinGridRoom( await notifyGridChanged(state) const [grids, connection] = await Promise.all([ buildGrids(state.affectedSpaceIds, context), - credentialsForParticipant(roomId, context.currentUserId, context.currentSessionId), + state.claim ? credentialsForParticipant(state.claim, context) : undefined, notifyConnectionReady(roomId), ]) log.debug("GRID_TRACE phase=join_rpc_done", { @@ -279,6 +324,7 @@ export async function joinGridRoom( return { grids, connection, + currentCall: state.claim?.currentCall, } } @@ -287,12 +333,19 @@ export async function leaveGridRoom( context: FunctionContext, ): Promise { const expectedRoomId = positiveId(input.expectedRoomId, RealtimeRpcError.BadRequest) + const expectedMembershipId = commandMembershipFence(input.expectedMembershipId) const state = await db.transaction(async (tx) => { await lockGridMutations(tx) + await ensureCurrentSession(tx, context) await lockUser(tx, context.currentUserId) const existing = await getPresenceWithRoom(tx, context.currentUserId) - if (!existing || existing.room.id !== expectedRoomId || existing.presence.ownerSessionId !== context.currentSessionId) { + if ( + !existing || + existing.room.id !== expectedRoomId || + existing.presence.ownerSessionId !== context.currentSessionId || + (expectedMembershipId !== undefined && existing.presence.mediaMembershipId !== expectedMembershipId) + ) { return { affectedSpaceIds: new Set(), endedConnections: [] } } @@ -317,7 +370,51 @@ export async function leaveGridRoom( }) await notifyGridChanged(state) - return { grids: await buildGrids(state.affectedSpaceIds, context) } + const [grids, currentCall] = await Promise.all([ + buildGrids(state.affectedSpaceIds, context), + buildCurrentCall(context), + ]) + return { grids, currentCall } +} + +/** Atomic control transfer. Media cleanup remains asynchronous and provider-specific. */ +export async function moveGridCallHere( + input: MoveGridCallHereInput, + context: FunctionContext, + issueCredentials: GridCredentialIssuer = createGridConnectionCredentials, +): Promise { + if (!isGridCallTransferEnabled()) throw gridClientUpgradeRequired() + const callId = membershipFence(input.callId) + const expectedMembershipId = membershipFence(input.expectedMembershipId) + const state = await db.transaction(async (tx) => { + await lockGridMutations(tx) + await ensureCurrentSession(tx, context) + await lockUser(tx, context.currentUserId) + const state: GridClaimMutationState = { affectedSpaceIds: new Set(), endedConnections: [] } + const existing = await getActivePresenceWithRoom(tx, context.currentUserId, state) + if ( + !existing || + existing.presence.callId !== callId || + existing.presence.mediaMembershipId !== expectedMembershipId + ) { + return state + } + await ensureGridAvailable(existing.room.spaceId, context.currentUserId, tx) + state.changedRoomId = existing.room.id + await movePresence(tx, context, existing.room, existing, state, false, { + preserveCallId: callId, + replaceMembership: true, + }) + return state + }) + await notifyGridChanged(state) + const [grids, connection, currentCall] = await Promise.all([ + buildGrids(state.affectedSpaceIds, context), + state.claim ? credentialsForParticipant(state.claim, context, issueCredentials) : undefined, + state.claim?.currentCall ?? buildCurrentCall(context), + state.changedRoomId ? notifyConnectionReady(state.changedRoomId) : undefined, + ]) + return { grids, connection, currentCall, moved: state.claim !== undefined } } export async function setGridRoomTitle( @@ -442,6 +539,7 @@ export async function prepareGridConnection( ): Promise { const startedAt = Date.now() const roomId = positiveId(input.roomId, RealtimeRpcError.BadRequest) + const expectedMembershipId = commandMembershipFence(input.expectedMembershipId) log.debug("GRID_TRACE phase=prepare_rpc_start", { roomId, generation: input.generation, @@ -465,6 +563,7 @@ export async function prepareGridConnection( eq(gridRooms.id, roomId), eq(gridRooms.connectionGeneration, input.generation), eq(gridPresence.ownerSessionId, context.currentSessionId), + expectedMembershipId === undefined ? undefined : eq(gridPresence.mediaMembershipId, expectedMembershipId), gt(gridPresence.leaseExpiresAt, new Date()), ), ) @@ -482,6 +581,7 @@ export async function prepareGridConnection( displayName: displayName(row.user), participantIdentity: gridParticipantIdentity(row.user.id, row.presence.mediaMembershipId), mediaMembershipId: row.presence.mediaMembershipId, + callId: row.presence.callId, spaceId: row.room.spaceId, } }) @@ -501,6 +601,8 @@ export async function prepareGridConnection( userId: preparation.userId, displayName: preparation.displayName, participantIdentity: preparation.participantIdentity, + callId: preparation.callId, + mediaMembershipId: preparation.mediaMembershipId, }) } catch (error) { Log.shared.warn("Failed to mint prepared Grid connection credentials", { @@ -519,6 +621,7 @@ export async function prepareGridConnection( userId: context.currentUserId, sessionId: context.currentSessionId, mediaMembershipId: preparation.mediaMembershipId, + callId: preparation.callId, }) if (!stillActive) { log.debug("GRID_TRACE phase=prepare_rpc_stale_after_mint", { @@ -547,6 +650,7 @@ async function gridCredentialAuthorityIsActive(input: { userId: number sessionId: number mediaMembershipId: string + callId: string }): Promise { return db.transaction(async (tx) => { await lockGridMutations(tx) @@ -561,6 +665,7 @@ async function gridCredentialAuthorityIsActive(input: { eq(gridPresence.userId, input.userId), eq(gridPresence.ownerSessionId, input.sessionId), eq(gridPresence.mediaMembershipId, input.mediaMembershipId), + eq(gridPresence.callId, input.callId), gt(gridPresence.leaseExpiresAt, new Date()), ), ) @@ -598,8 +703,10 @@ export async function setGridAvatarMicrophoneEnabled( context: FunctionContext, ): Promise { const roomId = positiveId(input.expectedRoomId, RealtimeRpcError.BadRequest) + const expectedMembershipId = commandMembershipFence(input.expectedMembershipId) const stateUpdate = await db.transaction(async (tx) => { await lockGridMutations(tx) + await ensureCurrentSession(tx, context) const [row] = await tx .select({ spaceId: gridRooms.spaceId }) .from(gridPresence) @@ -609,6 +716,7 @@ export async function setGridAvatarMicrophoneEnabled( eq(gridPresence.userId, context.currentUserId), eq(gridPresence.ownerSessionId, context.currentSessionId), eq(gridPresence.roomId, roomId), + expectedMembershipId === undefined ? undefined : eq(gridPresence.mediaMembershipId, expectedMembershipId), gt(gridPresence.leaseExpiresAt, new Date()), ), ) @@ -620,6 +728,7 @@ export async function setGridAvatarMicrophoneEnabled( ownerSessionId: context.currentSessionId, roomId, enabled: input.enabled, + expectedMembershipId, }) if (!updated) throw RealtimeRpcError.BadRequest() return { spaceId: row.spaceId, ...updated } @@ -655,16 +764,27 @@ async function movePresence( context: FunctionContext, targetRoom: DbGridRoom, existing: Awaited>, - state: GridMutationState, + state: GridClaimMutationState, microphoneEnabled?: boolean, + options: { preserveCallId?: string; replaceMembership?: boolean } = {}, ) { if (existing) { state.affectedSpaceIds.add(existing.room.spaceId) } - const ownerChanged = existing !== undefined && existing.presence.ownerSessionId !== context.currentSessionId - const rotateForAccessRevocation = ownerChanged && liveKitRequiresGenerationRotation() - if (existing && (existing.room.id !== targetRoom.id || ownerChanged)) { + const ownershipReplaced = + existing !== undefined && + (existing.presence.ownerSessionId !== context.currentSessionId || options.replaceMembership === true) + const rotateForAccessRevocation = ownershipReplaced && liveKitRequiresGenerationRotation() + if (existing && ownershipReplaced) { + await stopGridTranscription( + tx, + existing.room.id, + rotateForAccessRevocation ? "generation_changed" : "authority_lost", + true, + ) + } + if (existing && (existing.room.id !== targetRoom.id || ownershipReplaced)) { const connection = activeGridConnection(existing.room) if (connection) { await recordGridParticipantRevocation( @@ -677,7 +797,26 @@ async function movePresence( } } - await claimPresence(tx, context, targetRoom.id, microphoneEnabled) + const presence = await claimPresence(tx, context, targetRoom.id, microphoneEnabled, options) + await enrollGridRoomHistory(tx, targetRoom.id, [context.currentUserId]) + const roster = await tx + .select({ userId: gridPresence.userId }) + .from(gridPresence) + .where(and(eq(gridPresence.roomId, targetRoom.id), gt(gridPresence.leaseExpiresAt, new Date()))) + if (roster.length > 8) await stopGridTranscription(tx, targetRoom.id, "speaker_capacity", true) + const [ownerSession] = await tx + .select({ clientType: sessions.clientType, clientVersion: sessions.clientVersion }) + .from(sessions) + .where(eq(sessions.id, context.currentSessionId)) + .limit(1) + if (!ownerSession || !supportedGridTranscriptionSession(ownerSession)) + await stopGridTranscription(tx, targetRoom.id, "unsupported_client", true) + state.claim = { + roomId: presence.roomId, + callId: presence.callId, + mediaMembershipId: presence.mediaMembershipId, + currentCall: encodeCurrentCall(presence, targetRoom, context, ownerSession?.clientType), + } if (existing && existing.room.id !== targetRoom.id) { const endedConnection = await reconcileGridRoom(tx, existing.room.id, { rotateForAccessRevocation }) @@ -694,14 +833,16 @@ async function claimPresence( context: FunctionContext, roomId: number, microphoneEnabled?: boolean, + options: { preserveCallId?: string; replaceMembership?: boolean } = {}, ) { const now = new Date() - await tx + const [presence] = await tx .insert(gridPresence) .values({ userId: context.currentUserId, roomId, ownerSessionId: context.currentSessionId, + callId: options.preserveCallId, microphoneEnabled: microphoneEnabled ?? false, joinedAt: now, leaseExpiresAt: new Date(now.getTime() + PRESENCE_LEASE_MS), @@ -711,22 +852,38 @@ async function claimPresence( set: { roomId, ownerSessionId: context.currentSessionId, - joinedAt: sql`case when ${gridPresence.roomId} = ${roomId} and ${gridPresence.ownerSessionId} = ${context.currentSessionId} then ${gridPresence.joinedAt} else now() end`, - mediaMembershipId: sql`case when ${gridPresence.roomId} = ${roomId} and ${gridPresence.ownerSessionId} = ${context.currentSessionId} then ${gridPresence.mediaMembershipId} else gen_random_uuid() end`, + joinedAt: + options.preserveCallId !== undefined + ? gridPresence.joinedAt + : sql`case when ${gridPresence.roomId} = ${roomId} and ${gridPresence.ownerSessionId} = ${context.currentSessionId} then ${gridPresence.joinedAt} else now() end`, + callId: + options.preserveCallId ?? + sql`case when ${gridPresence.roomId} = ${roomId} and ${gridPresence.ownerSessionId} = ${context.currentSessionId} then ${gridPresence.callId} else gen_random_uuid() end`, + mediaMembershipId: options.replaceMembership + ? sql`gen_random_uuid()` + : sql`case when ${gridPresence.roomId} = ${roomId} and ${gridPresence.ownerSessionId} = ${context.currentSessionId} then ${gridPresence.mediaMembershipId} else gen_random_uuid() end`, microphoneEnabled: microphoneEnabled === undefined ? sql`case when ${gridPresence.ownerSessionId} = ${context.currentSessionId} then ${gridPresence.microphoneEnabled} else false end` : microphoneEnabled, - microphoneRevision: - microphoneEnabled === undefined - ? sql`case when ${gridPresence.roomId} = ${roomId} and ${gridPresence.ownerSessionId} = ${context.currentSessionId} then ${gridPresence.microphoneRevision} else 0 end` - : sql`case when ${gridPresence.roomId} = ${roomId} and ${gridPresence.ownerSessionId} = ${context.currentSessionId} and ${gridPresence.microphoneEnabled} != ${microphoneEnabled} then ${gridPresence.microphoneRevision} + 1 when ${gridPresence.roomId} = ${roomId} and ${gridPresence.ownerSessionId} = ${context.currentSessionId} then ${gridPresence.microphoneRevision} else 0 end`, + microphoneRevision: options.replaceMembership + ? 0 + : microphoneEnabled === undefined + ? sql`case when ${gridPresence.roomId} = ${roomId} and ${gridPresence.ownerSessionId} = ${context.currentSessionId} then ${gridPresence.microphoneRevision} else 0 end` + : sql`case when ${gridPresence.roomId} = ${roomId} and ${gridPresence.ownerSessionId} = ${context.currentSessionId} and ${gridPresence.microphoneEnabled} != ${microphoneEnabled} then ${gridPresence.microphoneRevision} + 1 when ${gridPresence.roomId} = ${roomId} and ${gridPresence.ownerSessionId} = ${context.currentSessionId} then ${gridPresence.microphoneRevision} else 0 end`, leaseExpiresAt: new Date(now.getTime() + PRESENCE_LEASE_MS), }, }) + .returning() + if (!presence) throw RealtimeRpcError.InternalError() + return presence } -async function refreshPresenceLeases(input?: { spaceId: number; context: FunctionContext }) { +async function refreshPresenceLeases(input?: { + spaceId: number + context: FunctionContext + expectedMembershipId?: string +}) { const affected = await db.transaction(async (tx) => { await lockGridMutations(tx) @@ -750,7 +907,9 @@ async function refreshPresenceLeases(input?: { spaceId: number; context: Functio const endedConnection = await reconcileGridRoom(tx, roomId) if (endedConnection) endedConnections.push(endedConnection) } - if (input) await renewOwnedPresence(tx, input.spaceId, input.context, cutoff) + if (input && (input.expectedMembershipId !== undefined || !isGridCallTransferEnabled())) { + await renewOwnedPresence(tx, input.spaceId, input.context, cutoff, input.expectedMembershipId) + } const state: GridMutationState = { affectedSpaceIds: new Set(expired.map(({ room }) => room.spaceId)), endedConnections, @@ -771,6 +930,7 @@ async function renewOwnedPresence( spaceId: number, context: FunctionContext, cutoff: Date, + expectedMembershipId?: string, ) { const [presence] = await tx .select({ roomId: gridPresence.roomId }) @@ -780,6 +940,7 @@ async function renewOwnedPresence( and( eq(gridPresence.userId, context.currentUserId), eq(gridPresence.ownerSessionId, context.currentSessionId), + expectedMembershipId === undefined ? undefined : eq(gridPresence.mediaMembershipId, expectedMembershipId), gt(gridPresence.leaseExpiresAt, cutoff), ), ) @@ -794,6 +955,7 @@ async function renewOwnedPresence( eq(gridPresence.userId, context.currentUserId), eq(gridPresence.ownerSessionId, context.currentSessionId), eq(gridPresence.roomId, presence.roomId), + expectedMembershipId === undefined ? undefined : eq(gridPresence.mediaMembershipId, expectedMembershipId), gt(gridPresence.leaseExpiresAt, cutoff), ), ) @@ -803,15 +965,112 @@ async function buildGrids(spaceIds: Set, context: FunctionContext): Prom return Promise.all([...spaceIds].sort((a, b) => a - b).map((spaceId) => buildGrid(spaceId, context))) } +async function buildCurrentCall(context: FunctionContext): Promise { + return db.transaction(async (tx) => { + await lockGridMutations(tx) + const [row] = await tx + .select({ presence: gridPresence, room: gridRooms, ownerClientType: sessions.clientType }) + .from(gridPresence) + .innerJoin(gridRooms, eq(gridRooms.id, gridPresence.roomId)) + .innerJoin(spaces, and(eq(spaces.id, gridRooms.spaceId), isNull(spaces.deleted))) + .innerJoin(members, and(eq(members.spaceId, gridRooms.spaceId), eq(members.userId, gridPresence.userId))) + .innerJoin( + sessions, + and( + eq(sessions.id, gridPresence.ownerSessionId), + eq(sessions.userId, gridPresence.userId), + isNull(sessions.revoked), + ), + ) + .where(and(eq(gridPresence.userId, context.currentUserId), gt(gridPresence.leaseExpiresAt, new Date()))) + .limit(1) + if (!row || !(await SpaceSettingsModel.getStored(row.room.spaceId, tx)).gridEnabled) return undefined + return encodeCurrentCall(row.presence, row.room, context, row.ownerClientType) + }) +} + +function encodeCurrentCall( + presence: DbGridPresence, + room: DbGridRoom, + context: FunctionContext, + ownerClientType?: string | null, +): GridCurrentCall { + return { + callId: presence.callId, + spaceId: BigInt(room.spaceId), + roomId: BigInt(room.id), + membershipId: presence.mediaMembershipId, + ownedByCurrentSession: presence.ownerSessionId === context.currentSessionId, + ownerClientType: ownerClientType ?? "", + } +} + +function membershipFence(value: string, allowEmpty = false): string { + if (allowEmpty && value === "") return value + if (!uuidPattern.test(value)) throw RealtimeRpcError.BadRequest() + return value.toLowerCase() +} + +function gridClientUpgradeRequired(): RealtimeRpcError { + return new RealtimeRpcError( + RealtimeRpcError.Code.BAD_REQUEST, + "Grid call ownership requires an updated client and enabled server.", + 400, + ) +} + +function commandMembershipFence(value: string | undefined): string | undefined { + if (value === undefined) { + if (isGridCallTransferEnabled()) throw gridClientUpgradeRequired() + return undefined + } + return membershipFence(value) +} + +function assertPresenceFence( + existing: Awaited>, + context: FunctionContext, + value: string | undefined, + options: { allowLegacyTakeover?: boolean } = {}, +): void { + if (value === undefined) { + if (isGridCallTransferEnabled()) throw gridClientUpgradeRequired() + return + } + const expected = membershipFence(value, true) + if (!existing && expected === "") return + if ( + existing && + expected === existing.presence.mediaMembershipId && + (existing.presence.ownerSessionId === context.currentSessionId || + (options.allowLegacyTakeover === true && !isGridCallTransferEnabled())) + ) + return + throw new RealtimeRpcError( + RealtimeRpcError.Code.BAD_REQUEST, + "The Grid call changed. Refresh before trying again.", + 400, + ) +} + async function buildGrid(spaceId: number, context: FunctionContext): Promise { // Snapshot content and its revision are read under the same lock used by // every Grid mutation. A response can therefore be older than a later // response, but it can never label old content with a newer revision. const snapshot = await db.transaction(async (tx) => { await lockGridMutations(tx) + await retireUnclaimedGridTranscriptions(tx) const settings = await SpaceSettingsModel.getStored(spaceId, tx) const [space] = await tx - .select({ revision: spaces.gridRevision }) + .select({ + revision: spaces.gridRevision, + // Hold the isolated counter stable while reading run projections. No + // capture/history writer can commit newer state with an older label. + transcriptionRevision: + sql`coalesce((select "revision" from ${gridTranscriptionSpaceRevisions} where "space_id" = ${spaces.id} for share), 0)`.mapWith( + BigInt, + ), + }) .from(spaces) .where(and(eq(spaces.id, spaceId), isNull(spaces.deleted))) .limit(1) @@ -828,7 +1087,109 @@ async function buildGrid(spaceId: number, context: FunctionContext): Promise + transcriptionAvailable: boolean + } + >() + const boundRoomIds = roomRows.filter((row) => available || row.room.roomThreadId !== null).map((row) => row.room.id) + const latestRuns = boundRoomIds.length + ? await tx + .selectDistinctOn([gridTranscriptionRuns.sourceRoomId]) + .from(gridTranscriptionRuns) + .where(inArray(gridTranscriptionRuns.sourceRoomId, [...new Set(boundRoomIds)])) + .orderBy(gridTranscriptionRuns.sourceRoomId, desc(gridTranscriptionRuns.createdAt)) + : [] + const runByRoom = new Map(latestRuns.map((run) => [run.sourceRoomId, run])) + const projectionChatIds = [ + ...new Set([ + ...roomRows.flatMap((row) => (row.room.roomThreadId === null ? [] : [row.room.roomThreadId])), + ...latestRuns.map((run) => run.transcriptChatId), + ]), + ] + const projectionChats = projectionChatIds.length + ? await tx.select().from(chats).where(inArray(chats.id, projectionChatIds)) + : [] + const chatById = new Map(projectionChats.map((chat) => [chat.id, chat])) + const ownerSessionIds = available + ? [...new Set(roomRows.flatMap((row) => (row.presence ? [row.presence.ownerSessionId] : [])))] + : [] + const ownerSessions = ownerSessionIds.length + ? await tx + .select({ + id: sessions.id, + clientType: sessions.clientType, + clientVersion: sessions.clientVersion, + revoked: sessions.revoked, + }) + .from(sessions) + .where(inArray(sessions.id, ownerSessionIds)) + : [] + const qualifiedOwners = new Set( + ownerSessions + .filter((session) => session.revoked === null && supportedGridTranscriptionSession(session)) + .map((session) => session.id), + ) + for (const room of new Map(roomRows.map((row) => [row.room.id, row.room])).values()) { + if (!available && room.roomThreadId === null) { + projected.set(room.id, { transcriptionAvailable: false }) + continue + } + let roomThreadId: bigint | undefined + if (room.roomThreadId) { + const chat = chatById.get(room.roomThreadId) + if ( + chat && + chat.type === "thread" && + chat.spaceId === room.spaceId && + chat.publicThread === false && + chat.parentChatId === null + ) { + try { + await AccessGuards.ensureChatAccess(chat, context.currentUserId, tx) + roomThreadId = BigInt(chat.id) + } catch (error) { + if (!RealtimeRpcError.is(error)) throw error + } + } + } + const run = runByRoom.get(room.id) + let canRead = false + if (run) { + const chat = chatById.get(run.transcriptChatId) + if (chat) { + try { + await AccessGuards.ensureChatAccess(chat, context.currentUserId, tx) + canRead = true + } catch (error) { + if (!RealtimeRpcError.is(error)) throw error + } + } + } + const roster = roomRows.filter((row) => row.room.id === room.id && row.presence).map((row) => row.presence!) + const roomAvailable = + available && + room.connectionStartedAt !== null && + roster.length >= 2 && + roster.length <= 8 && + roster.every((presence) => qualifiedOwners.has(presence.ownerSessionId)) + projected.set(room.id, { + roomThreadId, + transcription: run ? encodeGridTranscription(run, canRead) : undefined, + transcriptionAvailable: roomAvailable, + }) + } + return { + settings, + revision: BigInt(space.revision) + (space.transcriptionRevision ?? 0n), + roomRows, + projected, + available, + } }) if (!snapshot.settings.gridEnabled) { @@ -837,6 +1198,7 @@ async function buildGrid(spaceId: number, context: FunctionContext): Promise internalMessaging.publish({ @@ -1182,8 +1563,19 @@ async function notifyConnectionReadyUnchecked(roomId: number) { export function subscribeGridCredentialHints(): () => void { return internalMessaging.on("SessionRealtime", async ({ target, event }) => { if (event.payload.kind !== "gridCredentials") return - const { roomId, spaceId, generation, mediaMembershipId, encodedPayload } = event.payload - if (!(await gridCredentialAuthorityIsActive({ roomId, spaceId, generation, mediaMembershipId, userId: target.userId, sessionId: target.sessionId }))) return + const { roomId, spaceId, generation, mediaMembershipId, callId, encodedPayload } = event.payload + if ( + !(await gridCredentialAuthorityIsActive({ + roomId, + spaceId, + generation, + mediaMembershipId, + callId, + userId: target.userId, + sessionId: target.sessionId, + })) + ) + return let payload: ServerMessage["payload"] try { payload = ServerMessage.fromBinary(Buffer.from(encodedPayload, "base64")).payload diff --git a/server/src/functions/gridTranscription.ts b/server/src/functions/gridTranscription.ts new file mode 100644 index 000000000..451df4806 --- /dev/null +++ b/server/src/functions/gridTranscription.ts @@ -0,0 +1,445 @@ +import { randomUUID } from "node:crypto" +import { + GridTranscriptDestination, + type OpenGridThreadInput, + type OpenGridThreadResult, + type SetGridTranscriptionInput, + type SetGridTranscriptionResult, + type ListGridTranscriptsInput, + type ListGridTranscriptsResult, +} from "@inline-chat/protocol/core" +import { db } from "@in/server/db" +import { + chats, + members, + sessions, + spaces, + users, + gridPresence, + gridRooms, + gridTranscriptionRuns, + type DbGridTranscriptionRun, +} from "@in/server/db/schema" +import type { Transaction } from "@in/server/db/types" +import type { FunctionContext } from "./_types" +import { getGrid } from "./grid" +import { AccessGuards } from "@in/server/modules/authorization/accessGuards" +import { lockGridMutations } from "@in/server/modules/grid/roomLifecycle" +import { getLiveKitGridConfig, durableLiveKitProviderTarget } from "@in/server/modules/grid/livekit" +import { notifyGridSpaceChanged } from "@in/server/modules/grid/realtime" +import { SpaceSettingsModel } from "@in/server/db/models/spaceSettings" +import { + captureStates, + gridTranscriptionRetirementReasons, + GRID_TRANSCRIPTION_MAX_MS, + GRID_TRANSCRIPTION_MAX_SPEAKERS, + intactDestination, + latestRoomTranscription, + stopGridTranscription, + transcriptionAvailable, + transcriptionConfig, + supportedGridTranscriptionSession, +} from "@in/server/modules/grid/transcription/state" +import { + ensureGridRoomThread, + createGridTranscriptDestination, + grantGridTranscriptUsers, + insertGridTranscriptContinuationLink, +} from "@in/server/modules/grid/transcription/threads" +import { insertGridTranscriptMessage } from "@in/server/modules/grid/transcription/messages" +import { RealtimeRpcError } from "@in/server/realtime/errors" +import { and, eq, gt, inArray, isNull, desc, or, notInArray } from "drizzle-orm" +import { alias } from "drizzle-orm/pg-core" + +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i +function id(value: bigint) { + const n = Number(value) + if (!Number.isSafeInteger(n) || n <= 0) throw RealtimeRpcError.BadRequest() + return n +} +function uuid(value: string) { + if (!uuidPattern.test(value)) throw RealtimeRpcError.BadRequest() + return value.toLowerCase() +} +async function roomAuthority(tx: Transaction, roomId: number, context: FunctionContext, membershipId?: string) { + const [row] = await tx + .select({ room: gridRooms, presence: gridPresence, session: sessions }) + .from(gridRooms) + .innerJoin( + gridPresence, + and( + eq(gridPresence.roomId, gridRooms.id), + eq(gridPresence.userId, context.currentUserId), + eq(gridPresence.ownerSessionId, context.currentSessionId), + gt(gridPresence.leaseExpiresAt, new Date()), + ), + ) + .innerJoin(members, and(eq(members.spaceId, gridRooms.spaceId), eq(members.userId, context.currentUserId))) + .innerJoin(spaces, and(eq(spaces.id, gridRooms.spaceId), isNull(spaces.deleted))) + .innerJoin( + sessions, + and( + eq(sessions.id, context.currentSessionId), + eq(sessions.userId, context.currentUserId), + isNull(sessions.revoked), + ), + ) + .where( + and(eq(gridRooms.id, roomId), membershipId ? eq(gridPresence.mediaMembershipId, uuid(membershipId)) : undefined), + ) + .limit(1) + if (!row || !(await SpaceSettingsModel.getStored(row.room.spaceId, tx)).gridEnabled) + throw RealtimeRpcError.BadRequest() + return row +} +async function occupants(tx: Transaction, roomId: number) { + return tx + .select({ userId: gridPresence.userId, clientType: sessions.clientType, clientVersion: sessions.clientVersion }) + .from(gridPresence) + .innerJoin(users, and(eq(users.id, gridPresence.userId), eq(users.bot, false))) + .innerJoin(sessions, and(eq(sessions.id, gridPresence.ownerSessionId), isNull(sessions.revoked))) + .where(and(eq(gridPresence.roomId, roomId), gt(gridPresence.leaseExpiresAt, new Date()))) +} +/** Lock every associated chat before grants/runs, including cross-room continuation targets. */ +export async function enrollGridRoomHistory( + tx: Transaction, + roomId: number, + userIds: number[], + extraChatIds: number[] = [], +) { + const [room] = await tx.select().from(gridRooms).where(eq(gridRooms.id, roomId)).limit(1) + if (!room) return + const candidates = + room.roomThreadId === null + ? [] + : await tx.select().from(gridTranscriptionRuns).where(eq(gridTranscriptionRuns.sourceRoomId, roomId)) + const chatIds = [ + ...new Set([ + ...extraChatIds, + ...(room.roomThreadId === null ? [] : [room.roomThreadId]), + ...candidates.flatMap((run) => [run.roomChatId, run.destinationParentChatId, run.transcriptChatId]), + ]), + ].sort((a, b) => a - b) + const locked = chatIds.length + ? await tx.select().from(chats).where(inArray(chats.id, chatIds)).orderBy(chats.id).for("update") + : [] + const boundParent = locked.find((chat) => chat.id === room.roomThreadId) + const parents = new Set() + if ( + boundParent && + boundParent.type === "thread" && + boundParent.spaceId === room.spaceId && + boundParent.publicThread === false && + boundParent.parentChatId === null + ) + parents.add(boundParent.id) + else if (room.roomThreadId !== null) + await tx.update(gridRooms).set({ roomThreadId: null }).where(eq(gridRooms.id, room.id)) + // History may have retired while waiting for chat ownership; refresh its durable reasons. + const runs = candidates.length + ? await tx.select().from(gridTranscriptionRuns).where(eq(gridTranscriptionRuns.sourceRoomId, roomId)) + : [] + for (const run of runs) if (await intactDestination(tx, run)) parents.add(run.destinationParentChatId) + await grantGridTranscriptUsers(tx, { chatIds: [...parents], userIds }) +} +export async function openGridThread( + input: OpenGridThreadInput, + context: FunctionContext, +): Promise { + const result = await db.transaction(async (tx) => { + await lockGridMutations(tx) + const { room } = await roomAuthority(tx, id(input.roomId), context, uuid(input.expectedMembershipId)) + const userIds = (await occupants(tx, room.id)).map((r) => r.userId) + await enrollGridRoomHistory(tx, room.id, userIds) + const chatId = await ensureGridRoomThread(tx, { + room, + actorUserId: context.currentUserId, + participantUserIds: userIds, + }) + await enrollGridRoomHistory(tx, room.id, userIds) + return { chatId, spaceId: room.spaceId } + }) + await notifyGridSpaceChanged(result.spaceId) + return { chatId: BigInt(result.chatId), updates: [] } +} +async function canChooseDestination( + tx: Transaction, + run: DbGridTranscriptionRun, + userId: number, + requireSharing: boolean, +) { + if (!(await intactDestination(tx, run))) return false + const [child] = await tx.select().from(chats).where(eq(chats.id, run.transcriptChatId)).limit(1) + const [parent] = await tx.select().from(chats).where(eq(chats.id, run.destinationParentChatId)).limit(1) + if (!child || !parent) return false + try { + await AccessGuards.ensureChatAccess(child, userId, tx) + } catch (error) { + if (RealtimeRpcError.is(error)) return false + throw error + } + if (!requireSharing || parent.createdBy === userId) return true + const [member] = await tx + .select() + .from(members) + .where(and(eq(members.spaceId, run.spaceId), eq(members.userId, userId))) + .limit(1) + return member?.role === "owner" || member?.role === "admin" +} +export async function listGridTranscripts( + input: ListGridTranscriptsInput, + context: FunctionContext, +): Promise { + return db.transaction(async (tx) => { + await lockGridMutations(tx) + const { room } = await roomAuthority(tx, id(input.roomId), context) + const retained = or( + isNull(gridTranscriptionRuns.interruptionReason), + notInArray(gridTranscriptionRuns.interruptionReason, [...gridTranscriptionRetirementReasons]), + ) + const latest = tx.$with("grid_transcript_candidates").as( + tx + .selectDistinctOn([gridTranscriptionRuns.transcriptChatId]) + .from(gridTranscriptionRuns) + .where(and(eq(gridTranscriptionRuns.spaceId, room.spaceId), retained)) + .orderBy(gridTranscriptionRuns.transcriptChatId, desc(gridTranscriptionRuns.createdAt)), + ) + // Bound work to the latest 100 distinct retained transcripts, then return + // at most 20 that the actor can read and legitimately share/continue. + const rows = await tx.with(latest).select().from(latest).orderBy(desc(latest.createdAt)).limit(100) + const acceptedRows = rows.length + ? await tx + .selectDistinctOn([gridTranscriptionRuns.transcriptChatId]) + .from(gridTranscriptionRuns) + .where( + and( + eq(gridTranscriptionRuns.sourceRoomId, room.id), + inArray( + gridTranscriptionRuns.transcriptChatId, + rows.map((run) => run.transcriptChatId), + ), + retained, + ), + ) + .orderBy(gridTranscriptionRuns.transcriptChatId, desc(gridTranscriptionRuns.createdAt)) + : [] + const acceptedByChat = new Map(acceptedRows.map((run) => [run.transcriptChatId, run])) + const parent = alias(chats, "grid_transcript_parent") + const seen = new Set() + const transcripts: ListGridTranscriptsResult["transcripts"] = [] + for (const run of rows) { + const accepted = acceptedByChat.get(run.transcriptChatId) + const representative = + accepted && (await canChooseDestination(tx, accepted, context.currentUserId, false)) ? accepted : run + if ( + seen.has(run.transcriptChatId) || + !(await canChooseDestination( + tx, + representative, + context.currentUserId, + representative.sourceRoomId !== room.id, + )) + ) + continue + seen.add(run.transcriptChatId) + const [destination] = await tx + .select({ title: chats.title, number: chats.threadNumber, parentTitle: parent.title }) + .from(chats) + .innerJoin(parent, eq(parent.id, chats.parentChatId)) + .where(eq(chats.id, run.transcriptChatId)) + .limit(1) + if (!destination) continue + const [active] = await tx + .select({ id: gridTranscriptionRuns.id }) + .from(gridTranscriptionRuns) + .where( + and( + eq(gridTranscriptionRuns.transcriptChatId, run.transcriptChatId), + inArray(gridTranscriptionRuns.state, [...captureStates]), + ), + ) + .limit(1) + transcripts.push({ + transcriptChatId: BigInt(run.transcriptChatId), + title: `${destination.parentTitle?.trim() || "Grid room"} · ${destination.title?.trim() || "Transcript"}${destination.number === null ? "" : ` #${destination.number}`}`, + busy: !!active, + }) + if (transcripts.length === 20) break + } + return { transcripts } + }) +} +export async function setGridTranscription( + input: SetGridTranscriptionInput, + context: FunctionContext, +): Promise { + const requestId = uuid(input.requestId) + const spaceId = await db.transaction(async (tx) => { + await lockGridMutations(tx) + const { room, session } = await roomAuthority(tx, id(input.roomId), context, uuid(input.expectedMembershipId)) + const [duplicate] = await tx + .select() + .from(gridTranscriptionRuns) + .where( + and( + eq(gridTranscriptionRuns.actorUserId, context.currentUserId), + eq(gridTranscriptionRuns.requestId, requestId), + ), + ) + .limit(1) + if (duplicate) { + if (duplicate.sourceRoomId !== room.id) throw RealtimeRpcError.BadRequest() + return room.spaceId + } + const previous = await latestRoomTranscription(tx, room.id) + if ( + !input.enabled && + previous && + previous.id === input.expectedRunId && + (previous.state === "stopped" || previous.state === "interrupted" || previous.state === "stopping") + ) + return room.spaceId + if ((previous?.id ?? undefined) !== input.expectedRunId || (previous?.revision ?? 0) !== input.expectedRevision) + throw RealtimeRpcError.BadRequest() + if (!input.enabled) { + await stopGridTranscription(tx, room.id, "user_stop") + return room.spaceId + } + if ( + !supportedGridTranscriptionSession(session) || + !(await transcriptionAvailable(tx)) || + !getLiveKitGridConfig() || + !room.connectionStartedAt || + room.connectionGeneration !== input.expectedGeneration + ) + throw RealtimeRpcError.BadRequest() + const busy = await tx + .select({ id: gridTranscriptionRuns.id }) + .from(gridTranscriptionRuns) + .where(inArray(gridTranscriptionRuns.state, [...captureStates])) + .limit(1) + if (busy.length) throw RealtimeRpcError.BadRequest() + const roster = await occupants(tx, room.id) + if (!roster.every(supportedGridTranscriptionSession)) throw RealtimeRpcError.BadRequest() + const userIds = roster.map((r) => r.userId) + if (userIds.length < 2 || userIds.length > GRID_TRANSCRIPTION_MAX_SPEAKERS) throw RealtimeRpcError.BadRequest() + const runId = randomUUID() + let destination: { transcriptChatId: number; destinationParentChatId: number; originalAnchorId: number } + let roomLinkMessageId: number | undefined + let chosen = input.destination === GridTranscriptDestination.GRID_TRANSCRIPT_LAST ? previous : undefined + let requireSharing = chosen ? chosen.sourceRoomId !== room.id : false + if (input.destination === GridTranscriptDestination.GRID_TRANSCRIPT_EXISTING) { + if (!input.transcriptChatId) throw RealtimeRpcError.BadRequest() + const candidates = await tx + .select() + .from(gridTranscriptionRuns) + .where( + and( + eq(gridTranscriptionRuns.spaceId, room.spaceId), + eq(gridTranscriptionRuns.transcriptChatId, id(input.transcriptChatId)), + ), + ) + .orderBy(desc(gridTranscriptionRuns.createdAt)) + // An intact accepted room link carries continuing authority, even if a newer run came from another room. + chosen = undefined + for (const candidate of candidates) + if ( + candidate.sourceRoomId === room.id && + (await canChooseDestination(tx, candidate, context.currentUserId, false)) + ) { + chosen = candidate + break + } + requireSharing = !chosen + if (!chosen) + for (const candidate of candidates) + if (await canChooseDestination(tx, candidate, context.currentUserId, true)) { + chosen = candidate + break + } + if (!chosen) throw RealtimeRpcError.BadRequest() + } + await enrollGridRoomHistory( + tx, + room.id, + userIds, + chosen ? [chosen.roomChatId, chosen.destinationParentChatId, chosen.transcriptChatId] : [], + ) + const refreshedPrevious = await latestRoomTranscription(tx, room.id) + if ( + (refreshedPrevious?.id ?? undefined) !== input.expectedRunId || + (refreshedPrevious?.revision ?? 0) !== input.expectedRevision + ) + throw RealtimeRpcError.BadRequest() + if (chosen) { + const [fresh] = await tx + .select() + .from(gridTranscriptionRuns) + .where(eq(gridTranscriptionRuns.id, chosen.id)) + .limit(1) + chosen = fresh + } + const roomChatId = await ensureGridRoomThread(tx, { + room, + actorUserId: context.currentUserId, + participantUserIds: userIds, + }) + if ( + input.destination === GridTranscriptDestination.GRID_TRANSCRIPT_EXISTING && + (!chosen || !(await canChooseDestination(tx, chosen, context.currentUserId, requireSharing))) + ) + throw RealtimeRpcError.BadRequest() + if (chosen && (await canChooseDestination(tx, chosen, context.currentUserId, requireSharing))) { + destination = { + transcriptChatId: chosen.transcriptChatId, + destinationParentChatId: chosen.destinationParentChatId, + originalAnchorId: chosen.originalAnchorId, + } + if (requireSharing) + roomLinkMessageId = await insertGridTranscriptContinuationLink(tx, { + roomChatId, + transcriptChatId: chosen.transcriptChatId, + actorUserId: context.currentUserId, + runId, + title: "Transcript", + }) + else roomLinkMessageId = chosen.roomLinkMessageId ?? undefined + await grantGridTranscriptUsers(tx, { chatIds: [chosen.destinationParentChatId], userIds }) + } else + destination = await createGridTranscriptDestination(tx, { + roomChatId, + spaceId: room.spaceId, + actorUserId: context.currentUserId, + participantUserIds: userIds, + title: "Transcript", + runId, + }) + if (chosen) + await insertGridTranscriptMessage(tx, { + chatId: destination.transcriptChatId, + actorUserId: context.currentUserId, + runId, + segmentId: "started", + kind: "started", + text: "Transcription started", + }) + await tx.insert(gridTranscriptionRuns).values({ + id: runId, + sourceRoomId: room.id, + spaceId: room.spaceId, + roomChatId, + ...destination, + roomLinkMessageId, + actorUserId: context.currentUserId, + requestId, + model: transcriptionConfig().model, + state: "starting", + generation: room.connectionGeneration, + providerTarget: durableLiveKitProviderTarget(), + expiresAt: new Date(Date.now() + GRID_TRANSCRIPTION_MAX_MS), + }) + return room.spaceId + }) + await notifyGridSpaceChanged(spaceId) + return { grid: (await getGrid({ spaceId: BigInt(spaceId) }, context)).grid, updates: [] } +} diff --git a/server/src/functions/messages.deleteChat.ts b/server/src/functions/messages.deleteChat.ts index f041c85b5..b882cc973 100644 --- a/server/src/functions/messages.deleteChat.ts +++ b/server/src/functions/messages.deleteChat.ts @@ -23,6 +23,7 @@ import { getEffectiveChatAccessUserIds, } from "@in/server/modules/authorization/chatAccessProjection" import { deleteSubthreadParentPlacement } from "@in/server/functions/messages.deleteMessage" +import { invalidateGridTranscriptionForHistory } from "@in/server/modules/grid/transcription/state" const log = new Log("functions.deleteChat") /** @@ -173,6 +174,8 @@ async function deleteChatWithOptions( } } + await invalidateGridTranscriptionForHistory(tx, { chatIds: [lockedChat.id], reason: "chat_deleted" }) + peerId = Encoders.peerFromChat(lockedChat, { currentUserId }) const recipientAccess = await getEffectiveChatAccessUserIds(tx, [lockedChat.id]) recipientIds = Array.from(recipientAccess.get(lockedChat.id) ?? []) diff --git a/server/src/functions/messages.deleteMessage.ts b/server/src/functions/messages.deleteMessage.ts index 8dc25e386..3306a6720 100644 --- a/server/src/functions/messages.deleteMessage.ts +++ b/server/src/functions/messages.deleteMessage.ts @@ -26,6 +26,7 @@ import { and, eq, inArray } from "drizzle-orm" import { RealtimeRpcError } from "@in/server/realtime/errors" import { BotUpdateProjector } from "@in/server/modules/botUpdates/projector" import { hasImportedAgentMessages } from "@in/server/modules/agentSessions/service" +import { decryptSystemMessagePayload } from "@in/server/modules/systemMessages/payload" type Input = { messageIds: bigint[] @@ -99,9 +100,22 @@ async function deleteMessageWithOptions( messageIds: input.messageIds, }) + const selectedRows = numericMessageIds.length === 0 ? [] : await db.select({ + messageId: messages.messageId, + countsAsUnread: messages.countsAsUnread, + systemMessageEncrypted: messages.systemMessageEncrypted, + systemMessageIv: messages.systemMessageIv, + systemMessageTag: messages.systemMessageTag, + }).from(messages).where(and(eq(messages.chatId, chat.id), inArray(messages.messageId, numericMessageIds))) + const quietGeneratedIds = new Set(selectedRows + .filter((row) => row.countsAsUnread === false && row.systemMessageEncrypted !== null && row.systemMessageIv !== null && row.systemMessageTag !== null && decryptSystemMessagePayload({ encrypted: row.systemMessageEncrypted, iv: row.systemMessageIv, authTag: row.systemMessageTag }).event?.oneofKind === "gridTranscript") + .map((row) => row.messageId)) + const ordinaryMessageIds = input.messageIds.filter((id) => !quietGeneratedIds.has(Number(id))) + const onlyQuietGenerated = selectedRows.length > 0 && ordinaryMessageIds.length === 0 + let { update, metadataChatUpdates } = await MessageModel.deleteMessages(input.messageIds, chat.id) - if (!options.trustedPlacementCleanup) { - BotUpdateProjector.messageRoutesDeleted({ chatId: chat.id, messageIds: input.messageIds }) + if (!options.trustedPlacementCleanup && ordinaryMessageIds.length > 0) { + BotUpdateProjector.messageRoutesDeleted({ chatId: chat.id, messageIds: ordinaryMessageIds }) } const backlinkSelfUpdates = await deleteBacklinkMessages(backlinkMessages, { currentUserId: context.currentUserId, @@ -119,12 +133,12 @@ async function deleteMessageWithOptions( chatUpdates: metadataChatUpdates, }) - if (isReplyThread(chat)) { + if (isReplyThread(chat) && !onlyQuietGenerated) { await emitMessageSubthreadUpdateIfNeeded({ chatId: chat.id, currentUserId: context.currentUserId, }) - } else if (isLinkedSubthread(chat)) { + } else if (isLinkedSubthread(chat) && !onlyQuietGenerated) { queueSubthreadParentUpdate({ chatId: chat.id, currentUserId: context.currentUserId, @@ -132,7 +146,7 @@ async function deleteMessageWithOptions( }) } - if (!options.trustedPlacementCleanup) { + if (!options.trustedPlacementCleanup && ordinaryMessageIds.length > 0) { await Promise.all( updateGroup.userIds.map(async (userId) => { await Notifications.sendToUser({ @@ -140,7 +154,7 @@ async function deleteMessageWithOptions( payload: { kind: "message_deleted", threadId: `chat_${chat.id}`, - messageIds: input.messageIds.map((id) => id.toString()), + messageIds: ordinaryMessageIds.map((id) => id.toString()), }, }) }), diff --git a/server/src/functions/messages.editMessage.ts b/server/src/functions/messages.editMessage.ts index 89ab09823..84fe4a1c8 100644 --- a/server/src/functions/messages.editMessage.ts +++ b/server/src/functions/messages.editMessage.ts @@ -64,6 +64,9 @@ export const editMessage = async (input: Input, context: FunctionContext): Promi if (fullMessage && await isSubthreadParentMessage(fullMessage.globalId)) { throw RealtimeRpcError.BadRequest() } + if (fullMessage?.systemMessage?.event.oneofKind === "gridTranscript") { + throw RealtimeRpcError.BadRequest() + } if (await isImportedAgentMessage(chatId, Number(input.messageId))) { throw RealtimeRpcError.AgentSessionMessageImmutable() } diff --git a/server/src/functions/messages.moveThread.ts b/server/src/functions/messages.moveThread.ts index a007834da..50fa76aa9 100644 --- a/server/src/functions/messages.moveThread.ts +++ b/server/src/functions/messages.moveThread.ts @@ -13,6 +13,7 @@ import { Encoders } from "@in/server/realtime/encoders/encoders" import type { ServerUpdate } from "@in/server/protocol/server" import type { FunctionContext } from "@in/server/functions/_types" import { allocateThreadNumber } from "@in/server/modules/threadNumbers" +import { invalidateGridTranscriptionForHistory } from "@in/server/modules/grid/transcription/state" const log = new Log("functions.moveThread") @@ -186,6 +187,8 @@ export async function moveThread( }, } + await invalidateGridTranscriptionForHistory(tx, { chatIds: [chat.id], reason: "chat_moved" }) + const update = await UpdatesModel.insertUpdate(tx, { update: updatePayload, bucket: UpdateBucket.Chat, diff --git a/server/src/functions/messages.removeChatParticipant.ts b/server/src/functions/messages.removeChatParticipant.ts index b3faf6f98..5fe479733 100644 --- a/server/src/functions/messages.removeChatParticipant.ts +++ b/server/src/functions/messages.removeChatParticipant.ts @@ -28,6 +28,7 @@ import { encodeDateStrict } from "@in/server/realtime/encoders/helpers" import { ensureCanManageChatParticipants } from "@in/server/modules/authorization/spaceThreadGuards" import { ensureGroupCanParticipateInChat, loadActiveGroupMemberIds } from "@in/server/modules/userGroups" import { BotUpdateProjector } from "@in/server/modules/botUpdates/projector" +import { invalidateGridTranscriptionForHistory } from "@in/server/modules/grid/transcription/state" import { getRootChatIdsForAccessEvents, getEffectiveChatAccessUserIds, @@ -91,6 +92,8 @@ export async function removeChatParticipant( const accessEventChatIds = await getRootChatIdsForAccessEvents(tx, [input.chatId]) const accessBefore = await getEffectiveChatAccessUserIds(tx, accessEventChatIds) + await invalidateGridTranscriptionForHistory(tx, { chatIds: [chat.id], reason: "access_revoked" }) + await tx .delete(chatParticipants) .where(and(eq(chatParticipants.chatId, input.chatId), eq(chatParticipants.userId, userId))) @@ -205,6 +208,8 @@ async function removeChatParticipantGroup( const accessEventChatIds = await getRootChatIdsForAccessEvents(tx, [input.chatId]) const accessBefore = await getEffectiveChatAccessUserIds(tx, accessEventChatIds) + await invalidateGridTranscriptionForHistory(tx, { chatIds: [chat.id], reason: "access_revoked" }) + await tx .delete(chatParticipantGroups) .where(and(eq(chatParticipantGroups.chatId, input.chatId), eq(chatParticipantGroups.groupId, input.groupId))) diff --git a/server/src/functions/messages.updateChatVisibility.ts b/server/src/functions/messages.updateChatVisibility.ts index a6d7a99b1..6658e60b4 100644 --- a/server/src/functions/messages.updateChatVisibility.ts +++ b/server/src/functions/messages.updateChatVisibility.ts @@ -38,6 +38,7 @@ import { getEffectiveChatAccessUserIds, removedAccessUserIds, } from "@in/server/modules/authorization/chatAccessProjection" +import { invalidateGridTranscriptionForHistory } from "@in/server/modules/grid/transcription/state" const log = new Log("functions.updateChatVisibility") @@ -136,6 +137,10 @@ export async function updateChatVisibility( existingGroupGrants.map((grant) => grant.groupId), ) + // This RPC replaces grants even when visibility remains private. Fence + // admitted speech before changing who can read its destination. + await invalidateGridTranscriptionForHistory(tx, { chatIds: [chat.id], reason: "access_revoked" }) + if (isPublic) { if (input.participants && input.participants.length > 0) { throw new RealtimeRpcError( diff --git a/server/src/methods/deleteSpace.ts b/server/src/methods/deleteSpace.ts index 101dcd6ee..0ded7d9f2 100644 --- a/server/src/methods/deleteSpace.ts +++ b/server/src/methods/deleteSpace.ts @@ -1,6 +1,6 @@ import { db } from "@in/server/db" import { eq, inArray } from "drizzle-orm" -import { members, spaces, users } from "@in/server/db/schema" +import { chats, members, spaces, users } from "@in/server/db/schema" import { type Static, Type } from "@sinclair/typebox" import type { HandlerContext } from "@in/server/controllers/helpers" import { normalizeId, TInputId } from "@in/server/types/methods" @@ -14,6 +14,7 @@ import type { Update } from "@inline-chat/protocol/core" import { InlineError } from "@in/server/types/errors" import { deactivateCommittedSpaceMembership } from "@in/server/modules/authorization/spaceMembershipLifecycle" import { Log } from "@in/server/utils/log" +import { invalidateGridTranscriptionForHistory } from "@in/server/modules/grid/transcription/state" const log = new Log("space.deleteSpace") @@ -95,6 +96,10 @@ const deleteSpace = async (spaceId: number, currentUserId: number) => { throw new SpaceMemberSetChanged(lockedMemberUserIds) } + const spaceChats = await tx.select({ id: chats.id }).from(chats) + .where(eq(chats.spaceId, spaceId)).orderBy(chats.id).for("update") + await invalidateGridTranscriptionForHistory(tx, { chatIds: spaceChats.map((chat) => chat.id), reason: "space_deleted" }) + await UserBucketUpdates.enqueueMany( lockedMemberUserIds.map((userId) => ({ userId, diff --git a/server/src/modules/grid/avatarState.ts b/server/src/modules/grid/avatarState.ts index d04736d54..f24cd42b4 100644 --- a/server/src/modules/grid/avatarState.ts +++ b/server/src/modules/grid/avatarState.ts @@ -17,6 +17,7 @@ export async function setGridAvatarMicrophoneState( ownerSessionId: number roomId: number enabled: boolean + expectedMembershipId?: string }, ): Promise<{ membershipId: string; revision: number } | undefined> { const updated = await tx @@ -30,6 +31,9 @@ export async function setGridAvatarMicrophoneState( eq(gridPresence.userId, input.userId), eq(gridPresence.ownerSessionId, input.ownerSessionId), eq(gridPresence.roomId, input.roomId), + input.expectedMembershipId === undefined + ? undefined + : eq(gridPresence.mediaMembershipId, input.expectedMembershipId), gt(gridPresence.leaseExpiresAt, new Date()), ), ) diff --git a/server/src/modules/grid/livekit.ts b/server/src/modules/grid/livekit.ts index 63b233b76..72458418c 100644 --- a/server/src/modules/grid/livekit.ts +++ b/server/src/modules/grid/livekit.ts @@ -63,6 +63,8 @@ export async function createGridConnectionCredentials( userId: number displayName?: string participantIdentity?: string + callId?: string + mediaMembershipId?: string }, config: LiveKitGridConfig | null | undefined = getLiveKitGridConfig(), ): Promise { @@ -71,6 +73,8 @@ export async function createGridConnectionCredentials( log.debug("GRID_TRACE phase=credentials_config_unavailable", { roomId: input.connection.roomId.toString(), generation: input.connection.generation, + callId: input.callId, + membershipId: input.mediaMembershipId, userId: input.userId, }) return undefined @@ -111,6 +115,8 @@ export async function createGridConnectionCredentials( participantIdentity, token: jwt, expiresAt: BigInt(Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS), + callId: input.callId ?? "", + membershipId: input.mediaMembershipId ?? "", } } diff --git a/server/src/modules/grid/roomLifecycle.ts b/server/src/modules/grid/roomLifecycle.ts index ecbf4aa01..db98b87ce 100644 --- a/server/src/modules/grid/roomLifecycle.ts +++ b/server/src/modules/grid/roomLifecycle.ts @@ -13,6 +13,7 @@ import { import { encodeDateStrict } from "@in/server/realtime/encoders/helpers" import { Log } from "@in/server/utils/log" import { and, count, eq, gt, inArray, sql } from "drizzle-orm" +import { stopGridTranscription, invalidateGridTranscriptionForSpeaker } from "./transcription/state" const log = new Log("grid.roomLifecycle") @@ -62,6 +63,15 @@ export async function reconcileGridRoom( : room.connectionGeneration const endedConnection = activeGridConnection(room) + if (occupantCount < 2 || options.rotateForAccessRevocation) { + await stopGridTranscription( + tx, + roomId, + options.rotateForAccessRevocation ? "generation_changed" : "media_ended", + true, + ) + } + if (occupantCount === 0 && room.title === null) { await tx.delete(gridRooms).where(eq(gridRooms.id, roomId)) if (endedConnection) { @@ -227,9 +237,6 @@ async function removeGridPresenceInTransaction( ): Promise { const conditions = [eq(gridPresence.userId, input.userId)] if (input.spaceId !== undefined) conditions.push(eq(gridRooms.spaceId, input.spaceId)) - if (input.ownerSessionId !== undefined) { - conditions.push(eq(gridPresence.ownerSessionId, input.ownerSessionId)) - } const [row] = await tx .select({ room: gridRooms, presence: gridPresence }) .from(gridPresence) @@ -237,7 +244,10 @@ async function removeGridPresenceInTransaction( .where(and(...conditions)) .limit(1) - if (!row) { + if (!row || (input.ownerSessionId !== undefined && row.presence.ownerSessionId !== input.ownerSessionId)) { + // Revoking an older session must not discard the replacement owner's speech. + // With no current presence, revocation still retires the speaker's pending turns. + if (!row) await invalidateGridTranscriptionForSpeaker(tx, input.userId, input.spaceId) return { affectedSpaceIds: new Set(), changedRoomId: undefined, @@ -247,6 +257,13 @@ async function removeGridPresenceInTransaction( } } + await invalidateGridTranscriptionForSpeaker( + tx, + input.userId, + input.spaceId, + input.ownerSessionId === undefined ? undefined : row.presence.mediaMembershipId, + ) + const deleteConditions = [ eq(gridPresence.userId, input.userId), eq(gridPresence.roomId, row.room.id), diff --git a/server/src/modules/grid/transcription/control.test.ts b/server/src/modules/grid/transcription/control.test.ts new file mode 100644 index 000000000..3fe9ce3c9 --- /dev/null +++ b/server/src/modules/grid/transcription/control.test.ts @@ -0,0 +1,825 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test" +import { randomUUID } from "node:crypto" +import { and, eq, sql } from "drizzle-orm" +import { GridTranscriptDestination, GridTranscriptionState } from "@inline-chat/protocol/core" +import { setupTestLifecycle, testUtils } from "@in/server/__tests__/setup" +import { db } from "@in/server/db" +import { waitForPostCommitHooks } from "@in/server/db/commitHooks" +import { + chatParticipants, + chats, + gridPresence, + gridRooms, + gridTranscriptionRuns, + gridTranscriptionSegments, + members, + messages, + sessions, + spaces, + gridTranscriptionSpaceRevisions, +} from "@in/server/db/schema" +import { createGridRoom, getGrid, joinGridRoom, leaveGridRoom } from "@in/server/functions/grid" +import { + openGridThread, + setGridTranscription, + enrollGridRoomHistory, + listGridTranscripts, +} from "@in/server/functions/gridTranscription" +import { toggleSpaceGrid } from "@in/server/functions/space.settings" +import * as livekit from "@in/server/modules/grid/livekit" +import * as gridRealtime from "@in/server/modules/grid/realtime" +import { clearChatHistory } from "@in/server/modules/historyClear" +import { deleteChat } from "@in/server/functions/messages.deleteChat" +import { handler as leaveSpace } from "@in/server/methods/leaveSpace" +import { revokeSession } from "@in/server/modules/sessions/revokeSession" +import { ensureGridRoomThread, insertGridTranscriptContinuationLink } from "./threads" +import { invalidateGridTranscriptionForHistory } from "./state" +import { handleGridTranscriptionWorkerRequest } from "./worker" + +setupTestLifecycle() +const secret = "grid-control-test-secret-0123456789012345" +const original = new Map() +let providerSpy: ReturnType> | undefined +function configure() { + for (const [key, value] of Object.entries({ + GRID_TRANSCRIPTION_ENABLED: "true", + GRID_TRANSCRIPTION_WORKER_SECRET: secret, + GRID_TRANSCRIPTION_MODEL: "meeting", + GRID_TRANSCRIPTION_ALLOWED_CLIENT_VERSIONS: "qualified-test", + })) { + if (!original.has(key)) original.set(key, process.env[key]) + process.env[key] = value + } + providerSpy = spyOn(livekit, "getLiveKitGridConfig").mockReturnValue({ + serverUrl: "wss://local.test", + apiKey: "local-test-key", + apiSecret: "local-test-secret-0123456789", + provider: "self_hosted", + }) +} +afterEach(async () => { + await waitForPostCommitHooks() + providerSpy?.mockRestore() + providerSpy = undefined + for (const [key, value] of original) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + original.clear() +}) +async function api(action: string, body: unknown, token = secret) { + return handleGridTranscriptionWorkerRequest( + new Request(`http://localhost/_internal/grid-transcription/${action}`, { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ) +} +interface Claimed { + runId: string + claimEpoch: number + runToken: string + roomId: number + model: string + livekit: { serverUrl: string; token: string } + participants: { identity: string; userId: number; membershipId: string }[] +} +async function fixture(label: string) { + configure() + const { space, users } = await testUtils.createSpaceWithMembers(label, [ + `${label}-owner@example.test`, + `${label}-peer@example.test`, + `${label}-future@example.test`, + ] as const) + await db + .update(members) + .set({ role: "owner" }) + .where(and(eq(members.spaceId, space.id), eq(members.userId, users[0].id))) + const auth = await Promise.all( + users.map((user) => + testUtils.createSessionForUser(user.id, { clientType: "macos", clientVersion: "qualified-test" }), + ), + ) + const contexts = users.map((user, i) => + testUtils.functionContext({ userId: user.id, sessionId: auth[i]!.session.id }), + ) + await toggleSpaceGrid({ spaceId: BigInt(space.id), enabled: true }, contexts[0]!) + const created = await createGridRoom({ spaceId: BigInt(space.id), microphoneEnabled: true }, contexts[0]!) + const roomId = created.grids[0]!.rooms[0]!.id + await joinGridRoom({ roomId, microphoneEnabled: true }, contexts[1]!) + await api("heartbeat", { workerId: "control-test", model: "meeting", ready: true }) + const current = async () => + (await getGrid({ spaceId: BigInt(space.id) }, contexts[0]!)).grid!.rooms.find((room) => room.id === roomId)! + const control = async ( + enabled: boolean, + destination = GridTranscriptDestination.GRID_TRANSCRIPT_LAST, + transcriptChatId?: bigint, + actor = 0, + requestId = randomUUID(), + controlledRoomId = roomId, + ) => { + const room = (await getGrid({ spaceId: BigInt(space.id) }, contexts[actor]!)).grid!.rooms.find( + (room) => room.id === controlledRoomId, + )! + const avatar = room.avatars.find((avatar) => avatar.user?.id === BigInt(users[actor]!.id))! + return setGridTranscription( + { + roomId: controlledRoomId, + enabled, + requestId, + expectedMembershipId: avatar.membershipId, + expectedGeneration: room.connection!.generation, + expectedRunId: room.transcription?.runId, + expectedRevision: room.transcription?.revision ?? 0, + destination, + transcriptChatId, + }, + contexts[actor]!, + ) + } + const claim = async () => { + const response = await api("claim", { workerId: "control-test" }) + expect(response.status).toBe(200) + return (await response.json()) as Claimed + } + return { space, users, contexts, auth, roomId, current, control, claim } +} + +async function admittedReplacement(label: string) { + const f = await fixture(label) + await joinGridRoom({ roomId: f.roomId, microphoneEnabled: true }, f.contexts[2]!) + // Keep two occupants active after Leave/revocation so a room-wide stop cannot + // mask whether session cleanup invalidates the correct admitted membership. + providerSpy!.mockReturnValue({ + serverUrl: "wss://local.test", + apiKey: "local-test-key", + apiSecret: "local-test-secret-0123456789", + provider: "cloud", + }) + const [oldPresence] = await db.select().from(gridPresence).where(eq(gridPresence.userId, f.users[1].id)) + const replacement = await testUtils.createSessionForUser(f.users[1].id, { + clientType: "macos", + clientVersion: "qualified-test", + }) + const context = testUtils.functionContext({ userId: f.users[1].id, sessionId: replacement.session.id }) + await joinGridRoom({ roomId: f.roomId, microphoneEnabled: true }, context) + const [presence] = await db.select().from(gridPresence).where(eq(gridPresence.userId, f.users[1].id)) + expect(presence!.ownerSessionId).toBe(replacement.session.id) + expect(presence!.mediaMembershipId).not.toBe(oldPresence!.mediaMembershipId) + await f.control(true) + const claim = await f.claim() + const speaker = claim.participants.find((participant) => participant.userId === f.users[1].id)! + expect(speaker.membershipId).toBe(presence!.mediaMembershipId) + let turn = 0 + const admit = async (participantIdentity: string) => { + const response = await api("admit", { + participantIdentity, + trackSid: `TR-session-${++turn}`, + sourceTurnKey: `session-turn-${turn}`, + }, claim.runToken) + expect(response.status).toBe(200) + return ((await response.json()) as { segmentId: string }).segmentId + } + const segmentId = await admit(speaker.identity) + const segment = async (id = segmentId) => + (await db.select().from(gridTranscriptionSegments).where(eq(gridTranscriptionSegments.id, id)))[0]! + const final = async (id = segmentId) => { + const response = await api("final", { segmentId: id, text: "An admitted turn" }, claim.runToken) + expect(response.status).toBe(200) + return (await response.json()) as { messageId: number | null } + } + const revoke = (sessionId: number) => revokeSession({ + actor: "user", + actorUserId: f.users[1].id, + targetUserId: f.users[1].id, + sessionId, + }) + return { ...f, context, replacement, presence: presence!, claim, admit, segment, final, revoke } +} + +async function admitRejoinedMembership(f: Awaited>) { + await leaveGridRoom({ expectedRoomId: f.roomId }, f.context) + expect((await f.segment()).state).toBe("admitted") + await joinGridRoom({ roomId: f.roomId, microphoneEnabled: true }, f.context) + const renewed = (await (await api("renew", {}, f.claim.runToken)).json()) as { + participants: Claimed["participants"] + } + const speaker = renewed.participants.find((participant) => participant.userId === f.users[1].id)! + expect(speaker.membershipId).not.toBe(f.presence.mediaMembershipId) + return f.admit(speaker.identity) +} + +describe("Grid transcription control and worker authorization", () => { + test("stale session cleanup preserves the replacement owner's admitted final", async () => { + const f = await admittedReplacement("stale-session-transcript") + await f.revoke(f.auth[1]!.session.id) + const [presence] = await db.select().from(gridPresence).where(eq(gridPresence.userId, f.users[1].id)) + expect(presence).toMatchObject({ + ownerSessionId: f.replacement.session.id, + mediaMembershipId: f.presence.mediaMembershipId, + }) + expect((await f.segment()).state).toBe("admitted") + expect((await f.current()).transcription?.state).toBe(GridTranscriptionState.GRID_TRANSCRIPTION_ACTIVE) + expect((await f.final()).messageId).toBeGreaterThan(0) + expect((await f.segment()).state).toBe("finalized") + }) + + test("cloud owner takeover stops admitted capture without requiring a media generation change", async () => { + const f = await fixture("cloud-capture-takeover") + providerSpy!.mockReturnValue({ + serverUrl: "wss://local.test", + apiKey: "local-test-key", + apiSecret: "local-test-secret-0123456789", + provider: "cloud", + }) + await f.control(true) + const claim = await f.claim() + const generation = (await f.current()).connection!.generation + const oldSpeaker = claim.participants.find((participant) => participant.userId === f.users[1].id)! + const admission = { participantIdentity: oldSpeaker.identity, trackSid: "TR-cloud-takeover", sourceTurnKey: "old-turn" } + const admitted = await api("admit", admission, claim.runToken) + expect(admitted.status).toBe(200) + const { segmentId } = (await admitted.json()) as { segmentId: string } + const replacement = await testUtils.createSessionForUser(f.users[1].id, { + clientType: "macos", + clientVersion: "qualified-test", + }) + const context = testUtils.functionContext({ userId: f.users[1].id, sessionId: replacement.session.id }) + const joined = await joinGridRoom({ roomId: f.roomId, microphoneEnabled: true }, context) + const room = joined.grids[0]!.rooms.find((value) => value.id === f.roomId)! + expect(room.connection!.generation).toBe(generation) + const [run] = await db.select().from(gridTranscriptionRuns).where(eq(gridTranscriptionRuns.id, claim.runId)) + expect(run).toMatchObject({ state: "stopping", interruptionReason: "authority_lost" }) + const [segment] = await db.select().from(gridTranscriptionSegments).where(eq(gridTranscriptionSegments.id, segmentId)) + expect(segment!.state).toBe("discarded") + expect((await api("admit", { ...admission, sourceTurnKey: "late-old-turn" }, claim.runToken)).status).toBe(409) + const [presence] = await db.select().from(gridPresence).where(eq(gridPresence.userId, f.users[1].id)) + expect(presence!.mediaMembershipId).not.toBe(oldSpeaker.membershipId) + const identity = livekit.gridParticipantIdentity(f.users[1].id, presence!.mediaMembershipId) + expect((await api("admit", { ...admission, participantIdentity: identity, sourceTurnKey: "new-turn" }, claim.runToken)).status).toBe(409) + const final = await api("final", { segmentId, text: "Must not survive ownership replacement" }, claim.runToken) + expect(final.status).toBe(200) + expect(await final.json()).toEqual({ messageId: null }) + expect(await db.select().from(messages).where(eq(messages.chatId, run!.transcriptChatId))).toHaveLength(0) + expect((await (await api("renew", {}, claim.runToken)).json()) as object).toMatchObject({ + state: "stopping", + allowFinalFlush: false, + }) + }) + + test("matching session cleanup discards its membership and preserves an earlier ordinary Leave final", async () => { + const f = await admittedReplacement("matching-session-transcript") + const currentSegmentId = await admitRejoinedMembership(f) + await f.revoke(f.replacement.session.id) + expect(await db.select().from(gridPresence).where(eq(gridPresence.userId, f.users[1].id))).toHaveLength(0) + expect((await f.segment(currentSegmentId)).state).toBe("discarded") + expect((await f.final(currentSegmentId)).messageId).toBeNull() + expect((await f.segment()).state).toBe("admitted") + expect((await f.final()).messageId).toBeGreaterThan(0) + }) + + test("current-presence Space revocation discards speech from earlier and current memberships", async () => { + const f = await admittedReplacement("current-space-transcript") + const currentSegmentId = await admitRejoinedMembership(f) + await leaveSpace({ spaceId: f.space.id }, { ...f.context, ip: undefined }) + expect((await f.segment()).state).toBe("discarded") + expect((await f.segment(currentSegmentId)).state).toBe("discarded") + expect((await f.final()).messageId).toBeNull() + expect((await f.final(currentSegmentId)).messageId).toBeNull() + }) + + for (const kind of ["session", "Space"] as const) { + test(`absent-presence ${kind} revocation discards previously admitted speech`, async () => { + const f = await admittedReplacement(`absent-${kind.toLowerCase()}-transcript`) + await leaveGridRoom({ expectedRoomId: f.roomId }, f.context) + expect((await f.segment()).state).toBe("admitted") + if (kind === "session") await f.revoke(f.replacement.session.id) + else await leaveSpace({ spaceId: f.space.id }, { ...f.context, ip: undefined }) + expect((await f.segment()).state).toBe("discarded") + expect((await f.final()).messageId).toBeNull() + const [run] = await db.select().from(gridTranscriptionRuns).where(eq(gridTranscriptionRuns.id, f.claim.runId)) + expect(await db.select().from(messages).where(eq(messages.chatId, run!.transcriptChatId))).toHaveLength(0) + }) + } + + test("lazy private parent grants current and later room occupants while empty unclaimed Stop is terminal", async () => { + const f = await fixture("lazy-control") + expect((await f.current()).roomThreadId).toBeUndefined() + const presence = (await db.select().from(gridPresence).where(eq(gridPresence.userId, f.users[0].id)))[0]! + const opened = await openGridThread( + { roomId: f.roomId, expectedMembershipId: presence.mediaMembershipId }, + f.contexts[0]!, + ) + const parent = ( + await db + .select() + .from(chats) + .where(eq(chats.id, Number(opened.chatId))) + )[0]! + expect(parent).toMatchObject({ publicThread: false, parentChatId: null }) + expect( + (await db.select().from(chatParticipants).where(eq(chatParticipants.chatId, parent.id))) + .map((p) => p.userId) + .sort(), + ).toEqual([f.users[0].id, f.users[1].id].sort()) + await f.control(true) + const first = (await f.current()).transcription! + expect(first.state).toBe(GridTranscriptionState.GRID_TRANSCRIPTION_STARTING) + await f.control(false) + expect((await f.current()).transcription?.state).toBe(GridTranscriptionState.GRID_TRANSCRIPTION_STOPPED) + await joinGridRoom({ roomId: f.roomId }, f.contexts[2]!) + expect( + await db + .select() + .from(chatParticipants) + .where(and(eq(chatParticipants.chatId, parent.id), eq(chatParticipants.userId, f.users[2].id))), + ).toHaveLength(1) + await f.control(true) + expect((await f.current()).transcription?.runId).not.toBe(first.runId) + expect((await f.current()).transcription?.transcriptChatId).toBe(first.transcriptChatId) + }) + + test("claim wire binds exact membership, rejects unknown admission and bounds retries, then final is idempotent", async () => { + const f = await fixture("claim-control") + const requestId = randomUUID() + await f.control(true, GridTranscriptDestination.GRID_TRANSCRIPT_NEW, undefined, 0, requestId) + await f.control(true, GridTranscriptDestination.GRID_TRANSCRIPT_NEW, undefined, 0, requestId) + expect(await db.select().from(gridTranscriptionRuns)).toHaveLength(1) + const claim = await f.claim() + expect(claim).toMatchObject({ roomId: Number(f.roomId), model: "meeting", claimEpoch: 1 }) + expect(claim.participants).toHaveLength(2) + const jwt = JSON.parse(Buffer.from(claim.livekit.token.split(".")[1]!, "base64url").toString("utf8")) as { + video: { canPublish: boolean; canSubscribe: boolean; room: string } + } + expect(jwt.video).toMatchObject({ canPublish: false, canSubscribe: true }) + expect(jwt.video.room).toBe(`inline-grid-${f.roomId}-1`) + expect( + (await api("admit", { participantIdentity: "unknown", trackSid: "TR-one", sourceTurnKey: "one" }, claim.runToken)) + .status, + ).toBe(409) + const input = { participantIdentity: claim.participants[0]!.identity, trackSid: "TR-one", sourceTurnKey: "one" } + const admitted = await api("admit", input, claim.runToken) + expect(admitted.status).toBe(200) + const ticket = (await admitted.json()) as { segmentId: string } + expect(await (await api("admit", input, claim.runToken)).json()).toEqual(ticket) + expect((await api("admit", { ...input, sourceTurnKey: "two" }, claim.runToken)).status).toBe(200) + expect((await api("admit", { ...input, sourceTurnKey: "three" }, claim.runToken)).status).toBe(409) + const finish = () => api("final", { segmentId: ticket.segmentId, text: "A verified turn" }, claim.runToken) + const responses = await Promise.all([finish(), finish()]) + expect(responses.map((response) => response.status)).toEqual([200, 200]) + const finals = await Promise.all(responses.map((response) => response.json())) + expect(finals[0]).toEqual(finals[1]) + const run = (await db.select().from(gridTranscriptionRuns))[0]! + expect(await db.select().from(messages).where(eq(messages.chatId, run.transcriptChatId))).toHaveLength(1) + expect((await api("admit", input, claim.runToken)).status).toBe(409) + await f.control(false) + expect((await (await api("renew", {}, claim.runToken)).json()) as object).toMatchObject({ + state: "stopping", + allowFinalFlush: true, + }) + await leaveGridRoom({ expectedRoomId: f.roomId }, f.contexts[1]!) + expect((await (await api("renew", {}, claim.runToken)).json()) as object).toMatchObject({ + state: "stopping", + allowFinalFlush: false, + }) + expect( + ( + await db + .select() + .from(gridTranscriptionSegments) + .where(and(eq(gridTranscriptionSegments.runId, run.id), eq(gridTranscriptionSegments.sourceTurnKey, "two"))) + )[0]?.state, + ).toBe("discarded") + expect((await api("stopped", {}, claim.runToken)).status).toBe(200) + expect((await db.select().from(gridTranscriptionRuns))[0]?.state).toBe("interrupted") + }) + + test("legacy participant blocks disclosure-qualified availability and cannot start", async () => { + const f = await fixture("cohort-control") + expect((await f.current()).transcriptionAvailable).toBe(true) + await db.update(sessions).set({ clientVersion: "legacy" }).where(eq(sessions.id, f.auth[1]!.session.id)) + expect((await f.current()).transcriptionAvailable).toBe(false) + await expect(f.control(true)).rejects.toThrow() + expect(await db.select().from(gridTranscriptionRuns)).toHaveLength(0) + }) + + test("picker counts distinct transcripts and retains an intact origin after a newer cross-room link is retired", async () => { + const f = await fixture("picker-distinct") + await f.control(true) + await f.control(false) + const origin = (await db.select().from(gridTranscriptionRuns))[0]! + await f.control(true, GridTranscriptDestination.GRID_TRANSCRIPT_NEW) + await f.control(false) + const repeated = (await db.select().from(gridTranscriptionRuns)).find((run) => run.id !== origin.id)! + const now = Date.now() + await db.insert(gridTranscriptionRuns).values( + Array.from({ length: 105 }, (_, index) => ({ + ...repeated, + id: randomUUID(), + requestId: randomUUID(), + createdAt: new Date(now + index), + })), + ) + const [otherRoom] = await db + .insert(gridRooms) + .values({ spaceId: f.space.id, createdByUserId: f.users[0].id, title: "Earlier transcript source" }) + .returning() + await db.insert(gridTranscriptionRuns).values({ + ...origin, + id: randomUUID(), + requestId: randomUUID(), + sourceRoomId: otherRoom!.id, + roomLinkMessageId: 999999, + interruptionReason: "message_deleted", + createdAt: new Date(now + 1_000), + }) + const listed = await listGridTranscripts({ roomId: f.roomId }, f.contexts[0]!) + expect(listed.transcripts.map((entry) => Number(entry.transcriptChatId)).sort()).toEqual( + [origin.transcriptChatId, repeated.transcriptChatId].sort(), + ) + expect(listed.transcripts.every((entry) => !entry.busy)).toBe(true) + }) + + test("picker identifies source parents and distinct transcripts without changing or sharing history", async () => { + const f = await fixture("picker-labels") + await db.update(gridRooms).set({ title: "Product planning" }).where(eq(gridRooms.id, Number(f.roomId))) + await f.control(true) + await f.control(false) + await f.control(true, GridTranscriptDestination.GRID_TRANSCRIPT_NEW) + await f.control(false) + const created = await createGridRoom({ spaceId: BigInt(f.space.id), microphoneEnabled: true }, f.contexts[0]!) + const otherRoomId = created.grids[0]!.rooms.find((room) => room.avatars.some((avatar) => avatar.ownedByCurrentSession))!.id + await db.update(gridRooms).set({ title: "Design review" }).where(eq(gridRooms.id, Number(otherRoomId))) + await joinGridRoom({ roomId: otherRoomId, microphoneEnabled: true }, f.contexts[1]!) + await f.control(true, GridTranscriptDestination.GRID_TRANSCRIPT_NEW, undefined, 0, randomUUID(), otherRoomId) + await f.control(false, GridTranscriptDestination.GRID_TRANSCRIPT_LAST, undefined, 0, randomUUID(), otherRoomId) + const runs = await db.select().from(gridTranscriptionRuns) + expect(runs).toHaveLength(3) + const before = await db.select().from(chatParticipants) + const listed = await listGridTranscripts({ roomId: otherRoomId }, f.contexts[0]!) + const labels = new Map(listed.transcripts.map((entry) => [Number(entry.transcriptChatId), entry.title])) + for (const run of runs) { + const [child] = await db.select().from(chats).where(eq(chats.id, run.transcriptChatId)) + expect(child!.title).toBe("Transcript") + const source = run.sourceRoomId === Number(f.roomId) ? "Product planning" : "Design review" + expect(labels.get(run.transcriptChatId)).toBe(`${source} · Transcript #${child!.threadNumber}`) + } + expect(new Set(labels.values()).size).toBe(3) + expect(await db.select().from(chatParticipants)).toEqual(before) + }) + + test("lease-only renewals keep Grid revision and hints quiet while authority loss publishes once", async () => { + const f = await fixture("lease-only-renewal") + await f.control(true) + const claim = await f.claim() + const notify = spyOn(gridRealtime, "notifyGridSpaceChanged").mockResolvedValue(undefined) + try { + const before = (await db.select().from(gridTranscriptionSpaceRevisions))[0]!.revision + for (let index = 0; index < 3; index++) expect((await api("renew", {}, claim.runToken)).status).toBe(200) + expect(notify).not.toHaveBeenCalled() + expect((await db.select().from(gridTranscriptionSpaceRevisions))[0]!.revision).toBe(before) + await db + .update(gridTranscriptionRuns) + .set({ leaseExpiresAt: new Date(Date.now() - 1000) }) + .where(eq(gridTranscriptionRuns.id, claim.runId)) + expect(await (await api("renew", {}, claim.runToken)).json()).toMatchObject({ + state: "stopping", + allowFinalFlush: false, + }) + expect(notify).toHaveBeenCalledTimes(1) + expect((await db.select().from(gridTranscriptionSpaceRevisions))[0]!.revision).toBe(before + 1n) + expect((await api("renew", {}, claim.runToken)).status).toBe(200) + expect(notify).toHaveBeenCalledTimes(1) + } finally { + notify.mockRestore() + } + }) + + test("clear of a stopped child permanently retires continuation while preserving readable ordinary history access", async () => { + const f = await fixture("retired-control") + await f.control(true) + const before = (await f.current()).transcription! + await f.control(false) + await clearChatHistory( + { + peer: { type: { oneofKind: "chat", chat: { chatId: before.transcriptChatId! } } }, + keepLastDays: 0, + deleteReplyThreads: false, + }, + { currentUserId: f.users[0].id }, + ) + await expect( + f.control(true, GridTranscriptDestination.GRID_TRANSCRIPT_EXISTING, before.transcriptChatId), + ).rejects.toThrow() + await f.control(true) + expect((await f.current()).transcription?.transcriptChatId).not.toBe(before.transcriptChatId) + }) + + test("clear then media generation loss cannot restore a retired association", async () => { + const f = await fixture("sticky-retirement") + await f.control(true) + const first = (await f.current()).transcription! + const claim = await f.claim() + await clearChatHistory( + { + peer: { type: { oneofKind: "chat", chat: { chatId: first.transcriptChatId! } } }, + keepLastDays: 0, + deleteReplyThreads: false, + }, + { currentUserId: f.users[0].id }, + ) + await leaveGridRoom({ expectedRoomId: f.roomId }, f.contexts[1]!) + expect( + (await db.select().from(gridTranscriptionRuns).where(eq(gridTranscriptionRuns.id, first.runId)))[0], + ).toMatchObject({ state: "stopping", interruptionReason: "history_cleared" }) + expect((await api("stopped", {}, claim.runToken)).status).toBe(200) + await joinGridRoom({ roomId: f.roomId, microphoneEnabled: true }, f.contexts[1]!) + await f.control(true) + expect((await f.current()).transcription?.transcriptChatId).not.toBe(first.transcriptChatId) + }) + + test("lost claim response can retry within one boot and disabled gate still accepts actual containment", async () => { + const f = await fixture("claim-retry") + await f.control(true) + const first = await f.claim() + const replay = await f.claim() + expect(replay).toMatchObject({ runId: first.runId, claimEpoch: first.claimEpoch }) + expect((await api("claim", { workerId: "other-boot" })).status).toBe(204) + await f.control(false) + process.env["GRID_TRANSCRIPTION_ENABLED"] = "false" + expect((await api("renew", {}, first.runToken)).status).toBe(503) + expect((await api("stopped", {}, first.runToken)).status).toBe(200) + expect( + (await db.select().from(gridTranscriptionRuns).where(eq(gridTranscriptionRuns.id, first.runId)))[0]?.state, + ).toBe("stopped") + }) + + test("an expired owned claim or false readiness yields only no-child containment authority", async () => { + const f = await fixture("expired-owned-claim") + await f.control(true) + const claimed = await f.claim() + await db + .update(gridTranscriptionRuns) + .set({ leaseExpiresAt: new Date(Date.now() - 1) }) + .where(eq(gridTranscriptionRuns.id, claimed.runId)) + const response = await api("claim", { workerId: "control-test" }) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + runId: claimed.runId, + claimEpoch: claimed.claimEpoch, + stopImmediately: true, + }) + expect( + (await db.select().from(gridTranscriptionRuns).where(eq(gridTranscriptionRuns.id, claimed.runId)))[0]?.state, + ).toBe("stopping") + expect((await api("stopped", {}, claimed.runToken)).status).toBe(200) + await f.control(true) + const next = await f.claim() + await api("heartbeat", { workerId: "control-test", model: "meeting", ready: false }) + expect(await (await api("claim", { workerId: "control-test" })).json()).toMatchObject({ + runId: next.runId, + stopImmediately: true, + }) + expect((await api("stopped", {}, next.runToken)).status).toBe(200) + }) + + test("presence Space ownership and parent-chat run retirement do not deadlock", async () => { + const f = await fixture("counter-lock-order") + await f.control(true) + const run = (await db.select().from(gridTranscriptionRuns))[0]! + const parentHeld = Promise.withResolvers() + const spaceHeld = Promise.withResolvers() + const before = (await f.current()).transcription! + const retire = db.transaction(async (tx) => { + await tx.execute(sql`set local statement_timeout = '5s'`) + await tx.select({ id: chats.id }).from(chats).where(eq(chats.id, run.destinationParentChatId)).for("update") + parentHeld.resolve() + await spaceHeld.promise + // The real revision trigger must complete without requesting the Space + // row held by the presence transaction waiting for this parent. + await tx + .update(gridTranscriptionRuns) + .set({ state: "stopped", revision: sql`${gridTranscriptionRuns.revision} + 1` }) + .where(eq(gridTranscriptionRuns.id, run.id)) + }) + const join = db.transaction(async (tx) => { + try { + await parentHeld.promise + await tx.execute(sql`set local statement_timeout = '5s'`) + await tx + .update(gridPresence) + .set({ microphoneRevision: sql`${gridPresence.microphoneRevision} + 1` }) + .where(eq(gridPresence.userId, f.users[1].id)) + spaceHeld.resolve() + await enrollGridRoomHistory(tx, Number(f.roomId), [f.users[2].id]) + } finally { + spaceHeld.resolve() + } + }) + await Promise.all([retire, join]) + expect( + ( + await db + .select() + .from(gridTranscriptionSpaceRevisions) + .where(eq(gridTranscriptionSpaceRevisions.spaceId, f.space.id)) + )[0]?.revision, + ).toBeGreaterThan(1n) + expect((await f.current()).transcription?.revision).toBeGreaterThan(before.revision) + expect( + await db + .select() + .from(chatParticipants) + .where( + and(eq(chatParticipants.chatId, run.destinationParentChatId), eq(chatParticipants.userId, f.users[2].id)), + ), + ).toHaveLength(1) + // Retained counters have no Space FK: normal Space ownership is separate. + expect((await db.select().from(spaces).where(eq(spaces.id, f.space.id)))[0]).toBeDefined() + }) + + test("all historical runs are owned before taking a shared counter during cross-room clear", async () => { + const f = await fixture("multi-run-counter-order") + await f.control(true) + await f.control(false) + const originalRun = (await db.select().from(gridTranscriptionRuns))[0]! + const [sourceRoom] = await db + .insert(gridRooms) + .values({ spaceId: f.space.id, createdByUserId: f.users[0].id, title: "Other source" }) + .returning() + if (!sourceRoom) throw new Error("Source room missing") + const historicalId = "00000000-0000-4000-8000-000000000001" + const activeId = "00000000-0000-4000-8000-000000000002" + const sourceParent = await db.transaction(async (tx) => { + const roomChatId = await ensureGridRoomThread(tx, { + room: sourceRoom, + actorUserId: f.users[0].id, + participantUserIds: [f.users[0].id, f.users[1].id], + }) + const linkId = await insertGridTranscriptContinuationLink(tx, { + roomChatId, + transcriptChatId: originalRun.transcriptChatId, + actorUserId: f.users[0].id, + runId: historicalId, + title: "Earlier transcript", + }) + const base = { + sourceRoomId: sourceRoom.id, + spaceId: f.space.id, + roomChatId, + transcriptChatId: originalRun.transcriptChatId, + destinationParentChatId: originalRun.destinationParentChatId, + originalAnchorId: originalRun.originalAnchorId, + roomLinkMessageId: linkId, + actorUserId: f.users[0].id, + model: "meeting", + generation: 1, + providerTarget: livekit.durableLiveKitProviderTarget(), + expiresAt: new Date(Date.now() + 60_000), + } + await tx + .insert(gridTranscriptionRuns) + .values({ ...base, id: historicalId, requestId: randomUUID(), state: "stopped" }) + await tx.insert(gridTranscriptionRuns).values({ + ...base, + id: activeId, + requestId: randomUUID(), + state: "active", + workerId: "existing-worker", + claimEpoch: 1, + leaseExpiresAt: new Date(Date.now() + 60_000), + }) + return roomChatId + }) + const activeOwned = Promise.withResolvers() + const finishActive = Promise.withResolvers() + let workerPid = 0 + const stopped = db.transaction(async (tx) => { + const [backend] = await tx.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`) + workerPid = backend!.pid + await tx.execute(sql`set local statement_timeout = '5s'`) + await tx + .select({ id: chats.id }) + .from(chats) + .where(eq(chats.id, originalRun.destinationParentChatId)) + .for("update") + await tx + .select({ id: gridTranscriptionRuns.id }) + .from(gridTranscriptionRuns) + .where(eq(gridTranscriptionRuns.id, activeId)) + .for("update") + activeOwned.resolve() + await finishActive.promise + await tx + .update(gridTranscriptionRuns) + .set({ state: "stopped", revision: sql`${gridTranscriptionRuns.revision} + 1` }) + .where(eq(gridTranscriptionRuns.id, activeId)) + }) + await activeOwned.promise + const clearing = db.transaction(async (tx) => { + await tx.execute(sql`set local statement_timeout = '5s'`) + await tx.select({ id: chats.id }).from(chats).where(eq(chats.id, sourceParent)).for("update") + await invalidateGridTranscriptionForHistory(tx, { chatIds: [sourceParent], reason: "history_cleared" }) + }) + try { + let blocked = false + const deadline = performance.now() + 3_000 + while (performance.now() < deadline) { + const [row] = await db.execute<{ blocked: boolean }>( + sql`select exists(select 1 from pg_stat_activity where ${workerPid} = any(pg_blocking_pids(pid))) as blocked`, + ) + if (row?.blocked) { + blocked = true + break + } + await Bun.sleep(10) + } + expect(blocked).toBe(true) + finishActive.resolve() + await Promise.all([stopped, clearing]) + } finally { + finishActive.resolve() + await Promise.allSettled([stopped, clearing]) + } + expect( + ( + await db.select().from(gridTranscriptionRuns).where(eq(gridTranscriptionRuns.sourceRoomId, sourceRoom.id)) + ).every((run) => run.interruptionReason === "history_cleared"), + ).toBe(true) + }) + + test("deleting the retained room parent recreates a fresh private binding on explicit Open and Start", async () => { + const f = await fixture("deleted-room-parent") + await f.control(true) + await f.control(false) + const run = (await db.select().from(gridTranscriptionRuns))[0]! + const peer = { type: { oneofKind: "chat" as const, chat: { chatId: BigInt(run.roomChatId) } } } + await clearChatHistory({ peer, keepLastDays: 0, deleteReplyThreads: true }, { currentUserId: f.users[0].id }) + await deleteChat({ peer }, f.contexts[0]!) + expect((await f.current()).roomThreadId).toBeUndefined() + const presence = (await db.select().from(gridPresence).where(eq(gridPresence.userId, f.users[0].id)))[0]! + const reopened = await openGridThread( + { roomId: f.roomId, expectedMembershipId: presence.mediaMembershipId }, + f.contexts[0]!, + ) + expect(reopened.chatId).not.toBe(BigInt(run.roomChatId)) + expect( + ( + await db + .select() + .from(chats) + .where(eq(chats.id, Number(reopened.chatId))) + )[0], + ).toMatchObject({ publicThread: false, spaceId: f.space.id, parentChatId: null }) + await f.control(true) + expect((await f.current()).transcription?.transcriptChatId).not.toBe(BigInt(run.transcriptChatId)) + }) + + test("empty and stale membership fences cannot open or control a replacement membership", async () => { + const f = await fixture("member-fence") + const old = (await db.select().from(gridPresence).where(eq(gridPresence.userId, f.users[0].id)))[0]! + await expect(openGridThread({ roomId: f.roomId, expectedMembershipId: "" }, f.contexts[0]!)).rejects.toThrow() + await db.update(gridPresence).set({ mediaMembershipId: randomUUID() }).where(eq(gridPresence.userId, f.users[0].id)) + await expect( + openGridThread({ roomId: f.roomId, expectedMembershipId: old.mediaMembershipId }, f.contexts[0]!), + ).rejects.toThrow() + await expect( + setGridTranscription( + { + roomId: f.roomId, + enabled: true, + requestId: randomUUID(), + expectedMembershipId: "", + expectedGeneration: 1, + expectedRevision: 0, + destination: GridTranscriptDestination.GRID_TRANSCRIPT_NEW, + }, + f.contexts[0]!, + ), + ).rejects.toThrow() + expect(await db.select().from(gridTranscriptionRuns)).toHaveLength(0) + }) + + test("worker request authentication and chunk body limits reject before mutating", async () => { + configure() + expect((await api("heartbeat", { workerId: "test", model: "meeting", ready: true }, "wrong-secret")).status).toBe( + 401, + ) + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(150_000)) + controller.enqueue(new Uint8Array(150_000)) + controller.close() + }, + }) + const request = new Request("http://localhost/_internal/grid-transcription/heartbeat", { + method: "POST", + headers: { authorization: `Bearer ${secret}` }, + body, + duplex: "half", + } as RequestInit) + expect((await handleGridTranscriptionWorkerRequest(request)).status).toBe(413) + expect((await api("heartbeat", { workerId: "test", model: "unknown", ready: true })).status).toBe(400) + }) +}) diff --git a/server/src/modules/grid/transcription/messages.test.ts b/server/src/modules/grid/transcription/messages.test.ts new file mode 100644 index 000000000..b854f69ed --- /dev/null +++ b/server/src/modules/grid/transcription/messages.test.ts @@ -0,0 +1,276 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test" +import { createHmac, randomUUID } from "node:crypto" +import { and, eq, sql } from "drizzle-orm" +import { setupTestLifecycle, testUtils } from "@in/server/__tests__/setup" +import { db } from "@in/server/db" +import { waitForPostCommitHooks } from "@in/server/db/commitHooks" +import { ChatModel } from "@in/server/db/models/chats" +import { DialogsModel } from "@in/server/db/models/dialogs" +import { MessageModel } from "@in/server/db/models/messages" +import { SpaceSettingsModel } from "@in/server/db/models/spaceSettings" +import { chats, dialogs, gridPresence, gridRooms, gridTranscriptionRuns, gridTranscriptionSegments, members, messages, updates, users, UpdateBucket } from "@in/server/db/schema" +import { deleteMessage } from "@in/server/functions/messages.deleteMessage" +import { deleteChat } from "@in/server/functions/messages.deleteChat" +import { editMessage } from "@in/server/functions/messages.editMessage" +import { getChatHistory } from "@in/server/functions/messages.getChatHistory" +import { updateChatVisibility } from "@in/server/functions/messages.updateChatVisibility" +import { clearChatHistory } from "@in/server/modules/historyClear" +import { durableLiveKitProviderTarget } from "@in/server/modules/grid/livekit" +import { encryptMessage } from "@in/server/modules/encryption/encryptMessage" +import { Notifications } from "@in/server/modules/notifications/notifications" +import { Sync } from "@in/server/modules/updates/sync" +import { RealtimeUpdates } from "@in/server/realtime/message" +import { GridTranscriptMessageKind, MessageEntity_Type, MessageSendMode, type MessageEntity, type Update } from "@inline-chat/protocol/core" +import { insertGridTranscriptMessage } from "./messages" +import { createGridTranscriptDestination, ensureGridRoomThread, grantGridTranscriptUsers } from "./threads" +import { handleGridTranscriptionWorkerRequest } from "./worker" +import { intactDestination } from "./state" + +setupTestLifecycle() +const peer = (chatId: number) => ({ type: { oneofKind: "chat" as const, chat: { chatId: BigInt(chatId) } } }) +const secret = "grid-transcript-test-secret-0123456789" +const previousConfig = new Map() +function configureWorker() { + for (const [key, value] of Object.entries({ GRID_TRANSCRIPTION_ENABLED: "true", GRID_TRANSCRIPTION_WORKER_SECRET: secret, GRID_TRANSCRIPTION_MODEL: "meeting", GRID_TRANSCRIPTION_ALLOWED_CLIENT_VERSIONS: "grid-transcript-qualified-test" })) { + if (!previousConfig.has(key)) previousConfig.set(key, process.env[key]) + process.env[key] = value + } +} +afterEach(async () => { + await waitForPostCommitHooks() + for (const [key, value] of previousConfig) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + previousConfig.clear() +}) + +async function fixture(label: string) { + configureWorker() + const { space, users: people } = await testUtils.createSpaceWithMembers(label, [`${label}-actor@example.test`, `${label}-speaker@example.test`] as const) + const [actor, speaker] = people + await db.update(members).set({ role: "owner" }).where(and(eq(members.spaceId, space.id), eq(members.userId, actor.id))) + await db.update(users).set({ firstName: "Ben" }).where(eq(users.id, speaker.id)) + const [room] = await db.insert(gridRooms).values({ spaceId: space.id, createdByUserId: actor.id, title: label, connectionGeneration: 1, connectionStartedAt: new Date() }).returning() + if (!room) throw new Error("Room fixture missing") + await db.transaction((tx) => SpaceSettingsModel.updateGrid(space.id, true, tx)) + const speakerMembershipId = randomUUID() + for (const user of [actor, speaker]) { + const { session } = await testUtils.createSessionForUser(user.id, { clientType: "macos", clientVersion: "grid-transcript-qualified-test" }) + await db.insert(gridPresence).values({ userId: user.id, roomId: room.id, ownerSessionId: session.id, mediaMembershipId: user.id === speaker.id ? speakerMembershipId : randomUUID(), microphoneEnabled: true, leaseExpiresAt: new Date(Date.now() + 60_000) }) + } + const runId = randomUUID() + const destination = await db.transaction(async (tx) => { + const roomChatId = await ensureGridRoomThread(tx, { room, actorUserId: actor.id, participantUserIds: [actor.id, speaker.id] }) + const created = await createGridTranscriptDestination(tx, { roomChatId, spaceId: space.id, actorUserId: actor.id, participantUserIds: [actor.id, speaker.id], title: "Transcript", runId }) + return { ...created, roomChatId } + }) + const [run] = await db.insert(gridTranscriptionRuns).values({ + id: runId, sourceRoomId: room.id, spaceId: space.id, roomChatId: destination.roomChatId, + transcriptChatId: destination.transcriptChatId, destinationParentChatId: destination.destinationParentChatId, + originalAnchorId: destination.originalAnchorId, actorUserId: actor.id, requestId: randomUUID(), + model: "meeting", state: "active", generation: 1, providerTarget: durableLiveKitProviderTarget(), + claimEpoch: 1, leaseExpiresAt: new Date(Date.now() + 60_000), expiresAt: new Date(Date.now() + 60_000), + }).returning() + if (!run) throw new Error("Run fixture missing") + const [segment] = await db.insert(gridTranscriptionSegments).values({ runId, claimEpoch: 1, speakerUserId: speaker.id, membershipId: speakerMembershipId, trackSid: "TR-test-microphone", sourceTurnKey: "turn-1" }).returning() + if (!segment) throw new Error("Segment fixture missing") + await waitForPostCommitHooks() + const body = Buffer.from(JSON.stringify({ runId, epoch: 1, expiresAt: Date.now() + 60_000 })).toString("base64url") + const token = `${body}.${createHmac("sha256", secret).update(body).digest("base64url")}` + const final = () => handleGridTranscriptionWorkerRequest(new Request("http://localhost/internal/grid-transcription/final", { + method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ segmentId: segment.id, text: "@all /deploy https://example.test should remain speech" }), + })) + return { actor, speaker, space, room, run, segment, destination, final } +} + +describe("Grid transcript ordinary encrypted history", () => { + test("original anchor links to its selected child in first live delivery and history", async () => { + const live: Update[] = [] + const delivery = spyOn(RealtimeUpdates, "pushToUser").mockImplementation(async (_userId, events) => { live.push(...events) }) + try { + const s = await fixture("linked-grid-anchor") + const expectedEntity: MessageEntity = { + type: MessageEntity_Type.THREAD, offset: 0n, length: BigInt("Grid transcription".length), + entity: { oneofKind: "thread", thread: { chatId: BigInt(s.destination.transcriptChatId) } }, + } + const [stored] = await db.select().from(messages).where(and(eq(messages.chatId, s.destination.destinationParentChatId), eq(messages.messageId, s.destination.originalAnchorId))) + expect(stored?.entitiesEncrypted).not.toBeNull() + const history = await getChatHistory({ peerId: peer(s.destination.destinationParentChatId) }, testUtils.functionContext({ userId: s.speaker.id })) + expect(history.messages[0]?.entities?.entities).toEqual([expectedEntity]) + expect(history.messages[0]?.serviceMessage?.event).toMatchObject({ oneofKind: "gridTranscript", gridTranscript: { kind: GridTranscriptMessageKind.GRID_TRANSCRIPT_STARTED } }) + const liveAnchors = live.flatMap((event) => event.update.oneofKind === "newMessage" && event.update.newMessage.message?.id === BigInt(s.destination.originalAnchorId) ? [event.update.newMessage.message] : []) + expect(liveAnchors).toHaveLength(2) + for (const anchor of liveAnchors) expect(anchor.entities?.entities).toEqual([expectedEntity]) + } finally { delivery.mockRestore() } + }) + + test("actual final is encrypted, attributed and quiet in live delivery, history and replay", async () => { + configureWorker() + const s = await fixture("quiet-grid-turn") + await db.insert(dialogs).values({ chatId: s.destination.transcriptChatId, userId: s.speaker.id, spaceId: s.space.id, open: false, archived: true }) + const beforeDialogs = await db.select().from(dialogs).where(eq(dialogs.chatId, s.destination.transcriptChatId)) + const live: Update[] = [] + const delivery = spyOn(RealtimeUpdates, "pushToUser").mockImplementation(async (_userId, events) => { live.push(...events) }) + const push = spyOn(Notifications, "sendToUser").mockResolvedValue(undefined) + try { + const response = await s.final() + expect(response.status).toBe(200) + const result = await response.json() as { messageId: number } + await waitForPostCommitHooks() + const [stored] = await db.select().from(messages).where(and(eq(messages.chatId, s.destination.transcriptChatId), eq(messages.messageId, result.messageId))) + expect(stored).toMatchObject({ fromId: s.actor.id, text: null, countsAsUnread: false, hasLink: false, entitiesEncrypted: null, actionsEncrypted: null }) + expect(stored?.textEncrypted).not.toBeNull() + expect(stored?.systemMessageEncrypted).not.toBeNull() + const history = await getChatHistory({ peerId: peer(s.destination.transcriptChatId) }, testUtils.functionContext({ userId: s.speaker.id })) + expect(history.messages[0]?.message).toBe("Transcript · Ben: @all /deploy https://example.test should remain speech") + expect(history.messages[0]).toMatchObject({ countsAsUnread: false, sendMode: MessageSendMode.MODE_SILENT, mentioned: false }) + expect(history.messages[0]?.serviceMessage?.event).toMatchObject({ oneofKind: "gridTranscript", gridTranscript: { runId: s.run.id, segmentId: s.segment.id, speakerUserId: BigInt(s.speaker.id) } }) + const rows = await db.select().from(updates).where(and(eq(updates.bucket, UpdateBucket.Chat), eq(updates.entityId, s.destination.transcriptChatId))) + const replay = await Sync.processChatUpdates({ chatId: s.destination.transcriptChatId, peerId: peer(s.destination.transcriptChatId), userId: s.speaker.id, updates: rows }) + const replayed = replay.updates.find((update) => update.update.oneofKind === "newMessage") + expect(replayed?.update).toMatchObject({ oneofKind: "newMessage", newMessage: { message: { countsAsUnread: false, sendMode: MessageSendMode.MODE_SILENT } } }) + const liveMessages = live.filter((update) => update.update.oneofKind === "newMessage") + expect(liveMessages).toHaveLength(2) + expect(liveMessages.every((update) => + update.update.oneofKind === "newMessage" && update.update.newMessage.message?.countsAsUnread === false, + )).toBe(true) + expect(push).not.toHaveBeenCalled() + expect(await DialogsModel.getUnreadCount(s.destination.transcriptChatId, s.speaker.id)).toBe(0) + expect(await db.select().from(dialogs).where(eq(dialogs.chatId, s.destination.transcriptChatId))).toEqual(beforeDialogs) + const [chat] = await db.select().from(chats).where(eq(chats.id, s.destination.transcriptChatId)) + expect(chat?.lastMsgId).toBeNull() + expect(chat?.messageIdCounter).toBe(1) + await ChatModel.refreshLastMessageId(s.destination.transcriptChatId) + expect((await db.select().from(chats).where(eq(chats.id, s.destination.transcriptChatId)))[0]?.lastMsgId).toBeNull() + } finally { delivery.mockRestore(); push.mockRestore() } + }) + + test("generated rows reject public and direct model edits", async () => { + const s = await fixture("immutable-grid-turn") + const inserted = await db.transaction((tx) => insertGridTranscriptMessage(tx, { chatId: s.destination.transcriptChatId, actorUserId: s.actor.id, runId: s.run.id, segmentId: s.segment.id, speakerUserId: s.speaker.id, kind: "turn", text: "original" })) + await expect(editMessage({ peer: peer(s.destination.transcriptChatId), messageId: BigInt(inserted.message.messageId), text: "changed" }, testUtils.functionContext({ userId: s.actor.id }))).rejects.toThrow() + await expect(MessageModel.editMessage({ chatId: s.destination.transcriptChatId, messageId: inserted.message.messageId, text: "changed" })).rejects.toThrow() + expect((await MessageModel.getMessage(inserted.message.messageId, s.destination.transcriptChatId)).text).toBe("Transcript · Ben: original") + }) + + test("a finalized deleted turn keeps its dedup tombstone and does not stop capture or push", async () => { + configureWorker() + const s = await fixture("deleted-grid-turn") + const encryptedHuman = encryptMessage("human discussion") + const human = await MessageModel.insertMessage({ chatId: s.destination.transcriptChatId, fromId: s.actor.id, date: new Date(), textEncrypted: encryptedHuman.encrypted, textIv: encryptedHuman.iv, textTag: encryptedHuman.authTag }) + const response = await s.final() + const result = await response.json() as { messageId: number } + await waitForPostCommitHooks() + expect((await db.select().from(chats).where(eq(chats.id, s.destination.transcriptChatId)))[0]?.lastMsgId).toBe(human.message.messageId) + await MessageModel.deleteMessages([BigInt(human.message.messageId)], s.destination.transcriptChatId) + expect((await db.select().from(chats).where(eq(chats.id, s.destination.transcriptChatId)))[0]?.lastMsgId).toBeNull() + const push = spyOn(Notifications, "sendToUser").mockResolvedValue(undefined) + try { + await deleteMessage({ peer: peer(s.destination.transcriptChatId), messageIds: [BigInt(result.messageId)] }, testUtils.functionContext({ userId: s.actor.id })) + expect(push).not.toHaveBeenCalled() + expect((await db.select().from(gridTranscriptionRuns).where(eq(gridTranscriptionRuns.id, s.run.id)))[0]?.state).toBe("active") + expect((await db.select().from(gridTranscriptionSegments).where(eq(gridTranscriptionSegments.id, s.segment.id)))[0]).toMatchObject({ state: "finalized", messageId: result.messageId }) + expect((await s.final()).status).toBe(200) + expect(await db.select().from(messages).where(eq(messages.chatId, s.destination.transcriptChatId))).toHaveLength(0) + } finally { push.mockRestore() } + }) + + test("destructive clear discards pending speech even after ordinary Stop, and retry cannot refill history", async () => { + configureWorker() + const s = await fixture("cleared-grid-turn") + await db.update(gridTranscriptionRuns).set({ state: "stopping", interruptionReason: "user_stop", stopRequestedAt: new Date() }).where(eq(gridTranscriptionRuns.id, s.run.id)) + await clearChatHistory({ peer: peer(s.destination.transcriptChatId), keepLastDays: 0, deleteReplyThreads: false }, { currentUserId: s.actor.id }) + expect((await db.select().from(gridTranscriptionSegments).where(eq(gridTranscriptionSegments.id, s.segment.id)))[0]?.state).toBe("discarded") + expect((await s.final()).status).toBe(200) + expect(await db.select().from(messages).where(eq(messages.chatId, s.destination.transcriptChatId))).toHaveLength(0) + }) + + test("clearing stopped history permanently retires the destination even when its anchor survives", async () => { + const s = await fixture("retired-grid-history") + await db.update(gridTranscriptionRuns).set({ state: "stopped", interruptionReason: "user_stop" }).where(eq(gridTranscriptionRuns.id, s.run.id)) + expect(await db.transaction((tx) => intactDestination(tx, { ...s.run, state: "stopped", interruptionReason: "user_stop" }))).toBe(true) + await clearChatHistory({ peer: peer(s.destination.transcriptChatId), keepLastDays: 0, deleteReplyThreads: false }, { currentUserId: s.actor.id }) + const [run] = await db.select().from(gridTranscriptionRuns).where(eq(gridTranscriptionRuns.id, s.run.id)) + expect(run).toMatchObject({ state: "stopped", interruptionReason: "history_cleared" }) + expect(await db.select().from(messages).where(and(eq(messages.chatId, s.destination.destinationParentChatId), eq(messages.messageId, s.destination.originalAnchorId)))).toHaveLength(1) + expect(await db.transaction((tx) => intactDestination(tx, run!))).toBe(false) + }) + + test("same-private participant replacement atomically retires capture and prevents a pending final", async () => { + const s = await fixture("replaced-grid-access") + await updateChatVisibility({ chatId: s.destination.destinationParentChatId, isPublic: false, participants: [s.actor.id] }, testUtils.functionContext({ userId: s.actor.id })) + const [run] = await db.select().from(gridTranscriptionRuns).where(eq(gridTranscriptionRuns.id, s.run.id)) + expect(run?.interruptionReason).toBe("access_revoked") + expect((await db.select().from(gridTranscriptionSegments).where(eq(gridTranscriptionSegments.id, s.segment.id)))[0]?.state).toBe("discarded") + expect(await db.transaction((tx) => intactDestination(tx, run!))).toBe(false) + await expect(getChatHistory({ peerId: peer(s.destination.transcriptChatId) }, testUtils.functionContext({ userId: s.speaker.id }))).rejects.toThrow() + expect((await s.final()).status).toBe(200) + expect(await db.select().from(messages).where(eq(messages.chatId, s.destination.transcriptChatId))).toHaveLength(0) + }) + + test("missing room parent recreates a fresh private thread while retired history stays invalid", async () => { + const s = await fixture("recreated-grid-parent") + await clearChatHistory({ peer: peer(s.destination.destinationParentChatId), keepLastDays: 0, deleteReplyThreads: true }, { currentUserId: s.actor.id }) + await deleteChat({ peer: peer(s.destination.destinationParentChatId) }, testUtils.functionContext({ userId: s.actor.id })) + expect((await db.select().from(gridRooms).where(eq(gridRooms.id, s.room.id)))[0]?.roomThreadId).toBe(s.destination.destinationParentChatId) + const recreated = await db.transaction((tx) => ensureGridRoomThread(tx, { room: s.room, actorUserId: s.actor.id, participantUserIds: [s.actor.id, s.speaker.id] })) + expect(recreated).not.toBe(s.destination.destinationParentChatId) + expect((await db.select().from(chats).where(eq(chats.id, recreated)))[0]).toMatchObject({ spaceId: s.space.id, publicThread: false, parentChatId: null }) + expect((await db.select().from(gridRooms).where(eq(gridRooms.id, s.room.id)))[0]?.roomThreadId).toBe(recreated) + const [retired] = await db.select().from(gridTranscriptionRuns).where(eq(gridTranscriptionRuns.id, s.run.id)) + expect(await db.transaction((tx) => intactDestination(tx, retired!))).toBe(false) + }) + + test("existing public room parent is rejected instead of being enrolled", async () => { + const s = await fixture("public-grid-parent") + await updateChatVisibility({ chatId: s.destination.destinationParentChatId, isPublic: true }, testUtils.functionContext({ userId: s.actor.id })) + await expect(db.transaction((tx) => ensureGridRoomThread(tx, { room: s.room, actorUserId: s.actor.id, participantUserIds: [s.actor.id, s.speaker.id] }))).rejects.toThrow() + expect((await db.select().from(gridRooms).where(eq(gridRooms.id, s.room.id)))[0]?.roomThreadId).toBe(s.destination.destinationParentChatId) + }) + + test("anchor deletion waits for child mutation before run fence instead of deadlocking a final", async () => { + configureWorker() + const s = await fixture("anchor-grid-turn") + const childLocked = Promise.withResolvers() + const release = Promise.withResolvers() + const childOwner = db.transaction(async (tx) => { + await tx.select().from(chats).where(eq(chats.id, s.destination.transcriptChatId)).for("update") + childLocked.resolve() + await release.promise + await tx.execute(sql`set local lock_timeout = '1s'`) + await tx.select().from(gridTranscriptionRuns).where(eq(gridTranscriptionRuns.id, s.run.id)).for("update") + }) + await childLocked.promise + const deletion = MessageModel.deleteMessages([BigInt(s.destination.originalAnchorId)], s.destination.destinationParentChatId) + try { + // The parent lock proves deletion reached its transaction before allowing + // the child owner to request the run. A NOWAIT probe avoids timed sleeps. + for (let attempt = 0; attempt < 100; attempt += 1) { + let held = false + try { await db.transaction((tx) => tx.select().from(chats).where(eq(chats.id, s.destination.destinationParentChatId)).for("update", { noWait: true })) } + catch { held = true } + if (held) break + if (attempt === 99) throw new Error("Deletion did not acquire parent chat") + } + release.resolve() + await childOwner + await deletion + expect((await db.select().from(gridTranscriptionSegments).where(eq(gridTranscriptionSegments.id, s.segment.id)))[0]?.state).toBe("discarded") + expect((await s.final()).status).toBe(200) + expect(await db.select().from(messages).where(eq(messages.chatId, s.destination.transcriptChatId))).toHaveLength(0) + } finally { release.resolve(); await Promise.allSettled([childOwner, deletion]) } + }) + + test("later occupants inherit earlier transcript history through ordinary cumulative parent grants", async () => { + const s = await fixture("late-grid-reader") + const reader = await testUtils.createUser("late-grid-reader@example.test") + await db.insert(members).values({ spaceId: s.space.id, userId: reader.id, role: "member" }) + await db.transaction((tx) => insertGridTranscriptMessage(tx, { chatId: s.destination.transcriptChatId, actorUserId: s.actor.id, runId: s.run.id, segmentId: s.segment.id, speakerUserId: s.speaker.id, kind: "turn", text: "earlier conversation" })) + await expect(getChatHistory({ peerId: peer(s.destination.transcriptChatId) }, testUtils.functionContext({ userId: reader.id }))).rejects.toThrow() + await db.transaction((tx) => grantGridTranscriptUsers(tx, { chatIds: [s.destination.destinationParentChatId], userIds: [reader.id] })) + const history = await getChatHistory({ peerId: peer(s.destination.transcriptChatId) }, testUtils.functionContext({ userId: reader.id })) + expect(history.messages[0]?.message).toBe("Transcript · Ben: earlier conversation") + }) +}) diff --git a/server/src/modules/grid/transcription/messages.ts b/server/src/modules/grid/transcription/messages.ts new file mode 100644 index 000000000..701d4e32e --- /dev/null +++ b/server/src/modules/grid/transcription/messages.ts @@ -0,0 +1,147 @@ +import { db } from "@in/server/db" +import { registerPostCommitHook } from "@in/server/db/commitHooks" +import { ChatModel } from "@in/server/db/models/chats" +import { ModelError } from "@in/server/db/models/_errors" +import { UpdatesModel, type UpdateSeqAndDate } from "@in/server/db/models/updates" +import { chats, messages, users, UpdateBucket, type DbMessage } from "@in/server/db/schema" +import type { Transaction } from "@in/server/db/types" +import { getEffectiveChatAccessUserIds } from "@in/server/modules/authorization/chatAccessProjection" +import { encryptBinary } from "@in/server/modules/encryption/encryption" +import { encryptMessage } from "@in/server/modules/encryption/encryptMessage" +import { publishDurableReference } from "@in/server/modules/internalMessaging/durable" +import { encryptSystemMessagePayload, type SystemMessage } from "@in/server/modules/systemMessages/payload" +import { Encoders } from "@in/server/realtime/encoders/encoders" +import { encodeDateStrict } from "@in/server/realtime/encoders/helpers" +import { RealtimeUpdates } from "@in/server/realtime/message" +import { GridTranscriptMessageKind, MessageEntities } from "@inline-chat/protocol/core" +import { eq } from "drizzle-orm" +import { getMessageThreadProjectionsMap } from "@in/server/modules/subthreads" + +export type InsertGridTranscriptMessageInput = { + chatId: number + actorUserId: number + runId: string + segmentId: string + speakerUserId?: number + kind: GridTranscriptMessageKind | "turn" | "started" | "stopped" | "interrupted" | "link" + /** Speech content for turns; lifecycle/link fallback content otherwise. */ + text: string + entities?: MessageEntities + date?: Date +} + +export type InsertGridTranscriptMessageResult = { + message: DbMessage & { systemMessage: SystemMessage } + update: UpdateSeqAndDate +} + +/** + * Insert into ordinary encrypted history in the caller's transaction. The run + * owner validates admission and deduplicates before calling this helper. + * Generated content advances history, never sidebar activity or normal sends. + */ +export async function insertGridTranscriptMessage( + tx: Transaction, + input: InsertGridTranscriptMessageInput, +): Promise { + const [chat] = await tx.select().from(chats).where(eq(chats.id, input.chatId)).for("update").limit(1) + if (!chat) throw ModelError.ChatInvalid + const kind = typeof input.kind === "number" ? input.kind : { + turn: GridTranscriptMessageKind.GRID_TRANSCRIPT_TURN, + started: GridTranscriptMessageKind.GRID_TRANSCRIPT_STARTED, + stopped: GridTranscriptMessageKind.GRID_TRANSCRIPT_STOPPED, + interrupted: GridTranscriptMessageKind.GRID_TRANSCRIPT_INTERRUPTED, + link: GridTranscriptMessageKind.GRID_TRANSCRIPT_LINK, + }[input.kind] + const systemMessage: SystemMessage = { + event: { + oneofKind: "gridTranscript", + gridTranscript: { + runId: input.runId, + segmentId: input.segmentId, + speakerUserId: input.speakerUserId === undefined ? undefined : BigInt(input.speakerUserId), + kind, + }, + }, + } + const text = kind === GridTranscriptMessageKind.GRID_TRANSCRIPT_TURN + ? `Transcript · ${await microphoneOwnerName(tx, input.speakerUserId)}: ${input.text}` + : input.text + const encryptedText = encryptMessage(text) + const encryptedSystem = encryptSystemMessagePayload(systemMessage) + const encryptedEntities = input.entities ? encryptBinary(MessageEntities.toBinary(input.entities)) : undefined + const messageId = ChatModel.nextMessageId(chat) + const [message] = await tx.insert(messages).values({ + chatId: chat.id, + messageId, + fromId: input.actorUserId, + date: input.date ?? new Date(), + textEncrypted: encryptedText.encrypted, + textIv: encryptedText.iv, + textTag: encryptedText.authTag, + systemMessageEncrypted: encryptedSystem.encrypted, + systemMessageIv: encryptedSystem.iv, + systemMessageTag: encryptedSystem.authTag, + entitiesEncrypted: encryptedEntities?.encrypted ?? null, + entitiesIv: encryptedEntities?.iv ?? null, + entitiesTag: encryptedEntities?.authTag ?? null, + countsAsUnread: false, + hasLink: false, + }).returning() + if (!message) throw ModelError.Failed + const update = await UpdatesModel.insertUpdate(tx, { + update: { oneofKind: "newMessage", newMessage: { chatId: BigInt(chat.id), msgId: BigInt(messageId) } }, + bucket: UpdateBucket.Chat, + entity: chat, + }) + await tx.update(chats).set({ + messageIdCounter: messageId, + updateSeq: update.seq, + lastUpdateDate: update.date, + }).where(eq(chats.id, chat.id)) + const result = { message: { ...message, systemMessage }, update } + registerPostCommitHook(tx, Symbol("grid-transcript-message"), { + run: async () => { await publishGridTranscriptMessage(result) }, + }) + return result +} + +/** Publish only after commit. Durable replay covers dropped best-effort hints. */ +async function publishGridTranscriptMessage(input: InsertGridTranscriptMessageResult): Promise { + publishDurableReference({ bucket: { kind: "chat", chatId: input.message.chatId }, frontier: input.update.seq }) + // A delayed post-commit hint must not resurrect a row already cleared or + // deleted. Replay represents that old sequence as a skipped message. + const [current] = await db.select({ globalId: messages.globalId }).from(messages) + .where(eq(messages.globalId, input.message.globalId)).limit(1) + if (!current) return + const access = await getEffectiveChatAccessUserIds(db, [input.message.chatId]) + await Promise.all(Array.from(access.get(input.message.chatId) ?? []).map(async (userId) => { + const projection = (await getMessageThreadProjectionsMap({ + parentChatId: input.message.chatId, parentMessageIds: [input.message.messageId], userId, + })).get(input.message.messageId) + await RealtimeUpdates.pushToUser(userId, [{ + seq: input.update.seq, + date: encodeDateStrict(input.update.date), + update: { + oneofKind: "newMessage", + newMessage: { + message: Encoders.message({ + message: input.message, + encodingForUserId: userId, + encodingForPeer: { peer: { type: { oneofKind: "chat", chat: { chatId: BigInt(input.message.chatId) } } } }, + replies: projection?.replies, + subthread: projection?.subthread, + }), + }, + }, + }]) + })) +} + +async function microphoneOwnerName(tx: Transaction, userId: number | undefined): Promise { + if (userId === undefined) throw new Error("Transcript turn requires a microphone owner") + const [user] = await tx.select({ firstName: users.firstName, lastName: users.lastName, username: users.username }) + .from(users).where(eq(users.id, userId)).limit(1) + if (!user) throw ModelError.Failed + return [user.firstName, user.lastName].filter(Boolean).join(" ").trim() || user.username || "Participant" +} diff --git a/server/src/modules/grid/transcription/service.ts b/server/src/modules/grid/transcription/service.ts new file mode 100644 index 000000000..dbe25d777 --- /dev/null +++ b/server/src/modules/grid/transcription/service.ts @@ -0,0 +1,5 @@ +export { + invalidateGridTranscriptionForHistory, + invalidateGridTranscriptionForSpeaker, + stopGridTranscription, +} from "./state" diff --git a/server/src/modules/grid/transcription/state.ts b/server/src/modules/grid/transcription/state.ts new file mode 100644 index 000000000..c0eef93b6 --- /dev/null +++ b/server/src/modules/grid/transcription/state.ts @@ -0,0 +1,363 @@ +import { GridTranscriptionState, type GridTranscription } from "@inline-chat/protocol/core" +import { + gridTranscriptionRuns, + gridTranscriptionSegments, + gridTranscriptionWorker, + chats, + messages, + type DbGridTranscriptionRun, +} from "@in/server/db/schema" +import { getLiveKitGridConfig } from "@in/server/modules/grid/livekit" +import { insertGridTranscriptMessage } from "./messages" +import type { Transaction } from "@in/server/db/types" +import { and, eq, inArray, gt, desc, or, sql, lt, isNull, lte } from "drizzle-orm" + +export const gridTranscriptionRetirementReasons = [ + "history_changed", + "history_cleared", + "message_deleted", + "chat_deleted", + "chat_moved", + "access_revoked", + "space_deleted", +] as const +export const captureStates = ["starting", "active", "stopping"] as const +export const GRID_TRANSCRIPTION_LEASE_MS = 15_000 +export const GRID_TRANSCRIPTION_MAX_MS = 2 * 60 * 60_000 +export const GRID_TRANSCRIPTION_MAX_SPEAKERS = 8 +export function transcriptionConfig() { + const secret = process.env["GRID_TRANSCRIPTION_WORKER_SECRET"] + const model = process.env["GRID_TRANSCRIPTION_MODEL"] ?? "meeting" + const allowedClientVersions = (process.env["GRID_TRANSCRIPTION_ALLOWED_CLIENT_VERSIONS"] ?? "") + .split(",") + .map((v) => v.trim()) + .filter(Boolean) + return { + enabled: + process.env["GRID_TRANSCRIPTION_ENABLED"] === "true" && + !!secret && + secret.length >= 32 && + (model === "meeting" || model === "standard"), + secret, + model, + allowedClientVersions, + } +} +export async function transcriptionAvailable(tx: Transaction) { + if (!transcriptionConfig().enabled || !transcriptionConfig().allowedClientVersions.length || !getLiveKitGridConfig()) + return false + const [worker] = await tx + .select() + .from(gridTranscriptionWorker) + .where( + and( + eq(gridTranscriptionWorker.model, transcriptionConfig().model), + gt(gridTranscriptionWorker.heartbeatAt, new Date(Date.now() - GRID_TRANSCRIPTION_LEASE_MS)), + ), + ) + .limit(1) + return !!worker +} +export async function latestRoomTranscription(tx: Transaction, roomId: number) { + const [run] = await tx + .select() + .from(gridTranscriptionRuns) + .where(eq(gridTranscriptionRuns.sourceRoomId, roomId)) + .orderBy(desc(gridTranscriptionRuns.createdAt)) + .limit(1) + return run +} +/** Lock chats, runs, then segments consistently with final and history mutations. */ +export async function invalidateGridTranscriptionForSpeaker( + tx: Transaction, + userId: number, + spaceId?: number, + membershipId?: string, +) { + const runs = await tx + .select() + .from(gridTranscriptionRuns) + .where( + and( + spaceId === undefined ? undefined : eq(gridTranscriptionRuns.spaceId, spaceId), + inArray(gridTranscriptionRuns.state, [...captureStates]), + ), + ) + const chatIds = [...new Set(runs.flatMap((r) => [r.destinationParentChatId, r.transcriptChatId]))].sort( + (a, b) => a - b, + ) + if (chatIds.length) + await tx.select({ id: chats.id }).from(chats).where(inArray(chats.id, chatIds)).orderBy(chats.id).for("update") + if (runs.length) { + await tx + .select({ id: gridTranscriptionRuns.id }) + .from(gridTranscriptionRuns) + .where( + inArray( + gridTranscriptionRuns.id, + runs.map((r) => r.id), + ), + ) + .orderBy(gridTranscriptionRuns.id) + .for("update") + await tx + .update(gridTranscriptionSegments) + .set({ state: "discarded" }) + .where( + and( + eq(gridTranscriptionSegments.speakerUserId, userId), + membershipId === undefined ? undefined : eq(gridTranscriptionSegments.membershipId, membershipId), + inArray( + gridTranscriptionSegments.runId, + runs.map((r) => r.id), + ), + eq(gridTranscriptionSegments.state, "admitted"), + ), + ) + } +} +export function supportedGridTranscriptionSession(session: { + clientType: string | null + clientVersion: string | null +}) { + return ( + session.clientType === "macos" && + session.clientVersion !== null && + transcriptionConfig().allowedClientVersions.includes(session.clientVersion) + ) +} +/** Unclaimed runs can retire safely; claimed unknown capture retains its global slot. */ +export async function retireUnclaimedGridTranscriptions(tx: Transaction) { + if (!transcriptionConfig().enabled) return + const overdue = await tx + .select() + .from(gridTranscriptionRuns) + .where( + and( + inArray(gridTranscriptionRuns.state, ["starting", "active"]), + or( + and( + isNull(gridTranscriptionRuns.workerId), + lt(gridTranscriptionRuns.createdAt, new Date(Date.now() - 30_000)), + ), + lte(gridTranscriptionRuns.expiresAt, new Date()), + lte(gridTranscriptionRuns.leaseExpiresAt, new Date()), + ), + ), + ) + const chatIds = [...new Set(overdue.flatMap((run) => [run.destinationParentChatId, run.transcriptChatId]))].sort( + (a, b) => a - b, + ) + if (chatIds.length) + await tx.select({ id: chats.id }).from(chats).where(inArray(chats.id, chatIds)).orderBy(chats.id).for("update") + if (overdue.length) + await tx + .select({ id: gridTranscriptionRuns.id }) + .from(gridTranscriptionRuns) + .where( + inArray( + gridTranscriptionRuns.id, + overdue.map((run) => run.id), + ), + ) + .orderBy(gridTranscriptionRuns.id) + .for("update") + for (const run of overdue) { + const state = run.workerId === null ? "interrupted" : "stopping" + const reason = run.workerId === null ? "start_timeout" : "capture_timeout" + const changed = await tx + .update(gridTranscriptionRuns) + .set({ + state, + interruptionReason: reason, + stopRequestedAt: new Date(), + revision: sql`${gridTranscriptionRuns.revision} + 1`, + }) + .where( + and( + eq(gridTranscriptionRuns.id, run.id), + inArray(gridTranscriptionRuns.state, ["starting", "active"]), + or( + and( + isNull(gridTranscriptionRuns.workerId), + lt(gridTranscriptionRuns.createdAt, new Date(Date.now() - 30_000)), + ), + lte(gridTranscriptionRuns.expiresAt, new Date()), + lte(gridTranscriptionRuns.leaseExpiresAt, new Date()), + ), + ), + ) + .returning() + if (!changed.length) continue + await tx + .update(gridTranscriptionSegments) + .set({ state: "discarded" }) + .where(and(eq(gridTranscriptionSegments.runId, run.id), eq(gridTranscriptionSegments.state, "admitted"))) + if (state === "interrupted" && (await intactDestination(tx, run))) + await insertGridTranscriptMessage(tx, { + chatId: run.transcriptChatId, + actorUserId: run.actorUserId, + runId: run.id, + segmentId: "interrupted", + kind: "interrupted", + text: "Transcription interrupted", + }) + } +} +export function encodeGridTranscription(run: DbGridTranscriptionRun, canRead: boolean): GridTranscription { + const state = + { + starting: GridTranscriptionState.GRID_TRANSCRIPTION_STARTING, + active: GridTranscriptionState.GRID_TRANSCRIPTION_ACTIVE, + stopping: GridTranscriptionState.GRID_TRANSCRIPTION_STOPPING, + stopped: GridTranscriptionState.GRID_TRANSCRIPTION_STOPPED, + interrupted: GridTranscriptionState.GRID_TRANSCRIPTION_INTERRUPTED, + }[run.state] ?? GridTranscriptionState.GRID_TRANSCRIPTION_INTERRUPTED + return { + runId: run.id, + state, + transcriptChatId: canRead ? BigInt(run.transcriptChatId) : undefined, + revision: run.revision, + } +} +export async function stopGridTranscription(tx: Transaction, roomId: number, reason: string, discard = false) { + const runs = await tx + .select({ parentId: gridTranscriptionRuns.destinationParentChatId, chatId: gridTranscriptionRuns.transcriptChatId }) + .from(gridTranscriptionRuns) + .where( + and(eq(gridTranscriptionRuns.sourceRoomId, roomId), inArray(gridTranscriptionRuns.state, [...captureStates])), + ) + const chatIds = [...new Set(runs.flatMap((r) => [r.parentId, r.chatId]))].sort((a, b) => a - b) + if (chatIds.length) + await tx.select({ id: chats.id }).from(chats).where(inArray(chats.id, chatIds)).orderBy(chats.id).for("update") + const changed = await tx + .update(gridTranscriptionRuns) + .set({ + state: sql`CASE WHEN ${gridTranscriptionRuns.workerId} IS NULL THEN ${reason === "user_stop" ? "stopped" : "interrupted"} ELSE 'stopping' END`, + stopRequestedAt: sql`COALESCE(${gridTranscriptionRuns.stopRequestedAt}, ${new Date().toISOString()}::timestamp)`, + interruptionReason: sql`CASE WHEN ${inArray(gridTranscriptionRuns.interruptionReason, [...gridTranscriptionRetirementReasons])} THEN ${gridTranscriptionRuns.interruptionReason} ELSE ${reason} END`, + revision: sql`${gridTranscriptionRuns.revision} + 1`, + }) + .where( + and( + eq(gridTranscriptionRuns.sourceRoomId, roomId), + inArray(gridTranscriptionRuns.state, discard ? [...captureStates] : ["starting", "active"]), + ), + ) + .returning() + for (const run of changed) + if (run.workerId === null && (await intactDestination(tx, run))) + await insertGridTranscriptMessage(tx, { + chatId: run.transcriptChatId, + actorUserId: run.actorUserId, + runId: run.id, + segmentId: "stopped", + kind: reason === "user_stop" ? "stopped" : "interrupted", + text: reason === "user_stop" ? "Transcription stopped" : "Transcription interrupted", + }) + if (discard && changed.length) + await tx + .update(gridTranscriptionSegments) + .set({ state: "discarded" }) + .where( + and( + inArray( + gridTranscriptionSegments.runId, + changed.map((r) => r.id), + ), + eq(gridTranscriptionSegments.state, "admitted"), + ), + ) +} +/** Caller holds ordinary chat locks before changing runs; prevents late finals refilling cleared history. */ +export async function invalidateGridTranscriptionForHistory( + tx: Transaction, + input: { chatIds?: number[]; messageRefs?: { chatId: number; messageId: number }[]; reason?: string }, +) { + const conditions = [] + if (input.chatIds?.length) + conditions.push( + inArray(gridTranscriptionRuns.transcriptChatId, input.chatIds), + inArray(gridTranscriptionRuns.destinationParentChatId, input.chatIds), + inArray(gridTranscriptionRuns.roomChatId, input.chatIds), + ) + for (const ref of input.messageRefs ?? []) + conditions.push( + and( + eq(gridTranscriptionRuns.destinationParentChatId, ref.chatId), + eq(gridTranscriptionRuns.originalAnchorId, ref.messageId), + ), + and(eq(gridTranscriptionRuns.roomChatId, ref.chatId), eq(gridTranscriptionRuns.roomLinkMessageId, ref.messageId)), + ) + if (!conditions.length) return + await tx + .select({ id: gridTranscriptionRuns.id }) + .from(gridTranscriptionRuns) + .where(or(...conditions)) + .orderBy(gridTranscriptionRuns.id) + .for("update") + const changed = await tx + .update(gridTranscriptionRuns) + .set({ + state: sql`CASE WHEN ${gridTranscriptionRuns.state} IN ('starting', 'active', 'stopping') THEN CASE WHEN ${gridTranscriptionRuns.workerId} IS NULL THEN 'interrupted' ELSE 'stopping' END ELSE ${gridTranscriptionRuns.state} END`, + stopRequestedAt: sql`COALESCE(${gridTranscriptionRuns.stopRequestedAt}, ${new Date().toISOString()}::timestamp)`, + interruptionReason: input.reason ?? "history_changed", + revision: sql`${gridTranscriptionRuns.revision} + 1`, + }) + .where(or(...conditions)) + .returning({ id: gridTranscriptionRuns.id }) + // Historical associations are checked against their visible links, not hidden grants. + if (changed.length) + await tx + .update(gridTranscriptionSegments) + .set({ state: "discarded" }) + .where( + and( + inArray( + gridTranscriptionSegments.runId, + changed.map((r) => r.id), + ), + eq(gridTranscriptionSegments.state, "admitted"), + ), + ) +} +export async function intactDestination(tx: Transaction, run: DbGridTranscriptionRun) { + if (gridTranscriptionRetirementReasons.some((reason) => reason === run.interruptionReason)) return false + const [parent] = await tx.select().from(chats).where(eq(chats.id, run.destinationParentChatId)).limit(1) + const [source] = await tx.select().from(chats).where(eq(chats.id, run.roomChatId)).limit(1) + if ( + !source || + source.type !== "thread" || + source.spaceId !== run.spaceId || + source.publicThread !== false || + source.parentChatId !== null + ) + return false + const [child] = await tx.select().from(chats).where(eq(chats.id, run.transcriptChatId)).limit(1) + const [anchor] = await tx + .select({ id: messages.messageId }) + .from(messages) + .where(and(eq(messages.chatId, run.destinationParentChatId), eq(messages.messageId, run.originalAnchorId))) + .limit(1) + if ( + !parent || + !child || + !anchor || + parent.spaceId !== run.spaceId || + parent.publicThread !== false || + parent.parentChatId !== null || + child.spaceId !== run.spaceId || + child.parentChatId !== parent.id || + child.parentMessageId !== run.originalAnchorId + ) + return false + if (run.roomLinkMessageId !== null) { + const [link] = await tx + .select({ id: messages.messageId }) + .from(messages) + .where(and(eq(messages.chatId, run.roomChatId), eq(messages.messageId, run.roomLinkMessageId))) + .limit(1) + if (!link) return false + } + return true +} diff --git a/server/src/modules/grid/transcription/threads.ts b/server/src/modules/grid/transcription/threads.ts new file mode 100644 index 000000000..1edb96c3b --- /dev/null +++ b/server/src/modules/grid/transcription/threads.ts @@ -0,0 +1,220 @@ +import { registerPostCommitHook } from "@in/server/db/commitHooks" +import { UpdatesModel } from "@in/server/db/models/updates" +import { chats, chatParticipants, dialogs, gridRooms, members, messages, users, userNotDeleted, UpdateBucket, type DbChat } from "@in/server/db/schema" +import type { Transaction } from "@in/server/db/types" +import { AccessGuardsCache } from "@in/server/modules/authorization/accessGuardsCache" +import { getEffectiveChatAccessUserIds, getRootChatIdsForAccessEvents } from "@in/server/modules/authorization/chatAccessProjection" +import { publishAccessChanged } from "@in/server/modules/cache/cluster" +import { chatTitleFields } from "@in/server/modules/encryption/chatTitleStorage" +import { encryptBinary } from "@in/server/modules/encryption/encryption" +import { publishDurableReference } from "@in/server/modules/internalMessaging/durable" +import { allocateThreadNumber } from "@in/server/modules/threadNumbers" +import { UserBucketUpdates } from "@in/server/modules/updates/userBucketUpdates" +import { pushChatCatchupHintsBestEffort, pushParticipantUserUpdateBestEffort } from "@in/server/modules/updates/participantLiveUpdates" +import { RealtimeRpcError } from "@in/server/realtime/errors" +import { encodeDateStrict } from "@in/server/realtime/encoders/helpers" +import { GridTranscriptMessageKind, MessageEntities, MessageEntity_Type } from "@inline-chat/protocol/core" +import { and, eq, inArray } from "drizzle-orm" +import { insertGridTranscriptMessage } from "./messages" + +/** Called under the Grid mutation/Space owner; caller supplies current roster. */ +export async function ensureGridRoomThread(tx: Transaction, input: { + room: { id: number; spaceId: number; title: string | null; roomThreadId?: number | null } + actorUserId: number + participantUserIds: number[] +}): Promise { + const [room] = await tx.select().from(gridRooms).where(eq(gridRooms.id, input.room.id)).for("update").limit(1) + if (!room || room.spaceId !== input.room.spaceId) throw RealtimeRpcError.BadRequest() + let chatId = room.roomThreadId + if (chatId !== null) { + const [existing] = await tx.select().from(chats).where(eq(chats.id, chatId)).for("update").limit(1) + if (existing && !isGridPrivateParent(existing, room.spaceId)) throw RealtimeRpcError.BadRequest() + if (!existing) chatId = null + } + if (chatId === null) { + const chat = await createPrivateGridChat(tx, { + spaceId: room.spaceId, + actorUserId: input.actorUserId, + title: room.title ?? "Grid room", + }) + chatId = chat.id + await tx.update(gridRooms).set({ roomThreadId: chatId }).where(eq(gridRooms.id, room.id)) + } + await grantGridTranscriptUsers(tx, { chatIds: [chatId], userIds: input.participantUserIds }) + return chatId +} + +export async function createGridTranscriptDestination(tx: Transaction, input: { + roomChatId: number + spaceId: number + actorUserId: number + participantUserIds: number[] + title: string + runId: string +}): Promise<{ transcriptChatId: number; destinationParentChatId: number; originalAnchorId: number }> { + const [parent] = await tx.select().from(chats).where(eq(chats.id, input.roomChatId)).for("update").limit(1) + if (!parent || !isGridPrivateParent(parent, input.spaceId)) throw RealtimeRpcError.BadRequest() + await grantGridTranscriptUsers(tx, { chatIds: [parent.id], userIds: input.participantUserIds }) + const anchorText = "Grid transcription" + const anchor = await insertGridTranscriptMessage(tx, { + chatId: parent.id, + actorUserId: input.actorUserId, + runId: input.runId, + segmentId: "anchor", + kind: GridTranscriptMessageKind.GRID_TRANSCRIPT_STARTED, + text: anchorText, + }) + const child = await createPrivateGridChat(tx, { + spaceId: input.spaceId, + actorUserId: input.actorUserId, + title: input.title, + parentChatId: parent.id, + parentMessageId: anchor.message.messageId, + }) + const encryptedEntities = encryptBinary(MessageEntities.toBinary({ entities: [{ + type: MessageEntity_Type.THREAD, + offset: 0n, + length: BigInt(anchorText.length), + entity: { oneofKind: "thread", thread: { chatId: BigInt(child.id) } }, + }] })) + const entitiesFields = { + entitiesEncrypted: encryptedEntities.encrypted, + entitiesIv: encryptedEntities.iv, + entitiesTag: encryptedEntities.authTag, + } + await tx.update(messages).set(entitiesFields).where(eq(messages.globalId, anchor.message.globalId)) + // The newMessage commit hook captures this result. Fill navigation before + // commit so live delivery and history both have the child on their first row. + Object.assign(anchor.message, entitiesFields) + // Parent membership supplies cumulative history. Keep only the initiator's + // closed, hidden child dialog; generated turns cannot open anyone's inbox. + await tx.insert(dialogs).values({ + chatId: child.id, + userId: input.actorUserId, + spaceId: input.spaceId, + open: false, + chatListHidden: true, + }).onConflictDoNothing() + return { transcriptChatId: child.id, destinationParentChatId: parent.id, originalAnchorId: anchor.message.messageId } +} + +export async function insertGridTranscriptContinuationLink(tx: Transaction, input: { + roomChatId: number + transcriptChatId: number + actorUserId: number + runId: string + title: string +}): Promise { + const title = input.title.trim() || "Grid transcription" + const prefix = "Continue transcript: " + const result = await insertGridTranscriptMessage(tx, { + chatId: input.roomChatId, + actorUserId: input.actorUserId, + runId: input.runId, + segmentId: "link", + kind: GridTranscriptMessageKind.GRID_TRANSCRIPT_LINK, + text: `${prefix}${title}`, + entities: { entities: [{ + type: MessageEntity_Type.THREAD, + offset: BigInt(prefix.length), + length: BigInt(title.length), + entity: { oneofKind: "thread", thread: { chatId: BigInt(input.transcriptChatId) } }, + }] }, + }) + return result.message.messageId +} + +/** + * Ordinary cumulative private-parent enrollment; existing dialogs stay closed + * or archived. Only committed grants update caches/live discovery. Grid/Space + * authorization is owned by the caller, and membership is rechecked here. + */ +export async function grantGridTranscriptUsers(tx: Transaction, input: { chatIds: number[]; userIds: number[] }): Promise { + const chatIds = Array.from(new Set(input.chatIds)).sort((a, b) => a - b) + const userIds = Array.from(new Set(input.userIds)).sort((a, b) => a - b) + if (chatIds.length === 0 || userIds.length === 0) return + const chatRows = await tx.select().from(chats).where(inArray(chats.id, chatIds)).orderBy(chats.id).for("update") + if (chatRows.length !== chatIds.length) throw RealtimeRpcError.ChatIdInvalid() + const rootIds = await getRootChatIdsForAccessEvents(tx, chatIds) + const accessBefore = await getEffectiveChatAccessUserIds(tx, rootIds, { userIds }) + for (const chat of chatRows) { + if (chat.spaceId === null || !isGridPrivateParent(chat, chat.spaceId)) throw RealtimeRpcError.BadRequest() + const eligible = await tx.select({ userId: members.userId }).from(members) + .innerJoin(users, eq(users.id, members.userId)) + .where(and(eq(members.spaceId, chat.spaceId), inArray(members.userId, userIds), userNotDeleted())) + if (eligible.length !== userIds.length) throw RealtimeRpcError.BadRequest() + const inserted = await tx.insert(chatParticipants).values(userIds.map((userId) => ({ chatId: chat.id, userId }))) + .onConflictDoNothing().returning() + await tx.insert(dialogs).values(userIds.map((userId) => ({ + chatId: chat.id, userId, spaceId: chat.spaceId, open: false, + }))).onConflictDoNothing() + let frontier = chat.updateSeq ?? 0 + for (const participant of inserted) { + const update = await UpdatesModel.insertUpdate(tx, { + update: { oneofKind: "participantAdd", participantAdd: { + chatId: BigInt(chat.id), + participant: { userId: BigInt(participant.userId), date: encodeDateStrict(participant.date) }, + } }, + bucket: UpdateBucket.Chat, + entity: { id: chat.id, updateSeq: frontier }, + }) + frontier = update.seq + await tx.update(chats).set({ updateSeq: update.seq, lastUpdateDate: update.date }).where(eq(chats.id, chat.id)) + } + if (inserted.length > 0) { + registerPostCommitHook(tx, Symbol("grid-thread-access"), { run: async () => { + publishAccessChanged({ kind: "chat", chatId: chat.id }) + for (const participant of inserted) AccessGuardsCache.setChatParticipant(chat.id, participant.userId) + publishDurableReference({ bucket: { kind: "chat", chatId: chat.id }, frontier }) + await pushChatCatchupHintsBestEffort({ chatId: chat.id, currentUserId: inserted[0]!.userId, updateSeq: frontier }) + } }) + } + } + const accessAfter = await getEffectiveChatAccessUserIds(tx, rootIds, { userIds }) + const gained = rootIds.flatMap((chatId) => userIds.filter((userId) => + !accessBefore.get(chatId)?.has(userId) && accessAfter.get(chatId)?.has(userId), + ).map((userId) => ({ chatId, userId }))) + const accessUpdates = await UserBucketUpdates.enqueueMany(gained.map(({ chatId, userId }) => ({ + userId, update: { oneofKind: "userAddedToChat", userAddedToChat: { chatId: BigInt(chatId) } }, + })), { tx }) + if (gained.length > 0) registerPostCommitHook(tx, Symbol("grid-thread-discovery"), { run: async () => { + await Promise.all(gained.map(({ chatId, userId }, index) => { + const update = accessUpdates[index]! + return pushParticipantUserUpdateBestEffort(userId, { + seq: update.seq, date: encodeDateStrict(update.date), + update: { oneofKind: "userAddedToChat", userAddedToChat: { chatId: BigInt(chatId) } }, + }) + })) + } }) +} + +export function isGridPrivateParent(chat: DbChat, spaceId: number): boolean { + return chat.type === "thread" && chat.spaceId === spaceId && chat.publicThread === false && chat.parentChatId === null +} + +async function createPrivateGridChat(tx: Transaction, input: { + spaceId: number + actorUserId: number + title: string + parentChatId?: number + parentMessageId?: number +}): Promise { + const threadNumber = await allocateThreadNumber(tx, { type: "space", id: input.spaceId }) + const [chat] = await tx.insert(chats).values({ + type: "thread", spaceId: input.spaceId, createdBy: input.actorUserId, publicThread: false, + ...chatTitleFields(input.title, { spaceId: input.spaceId, createdBy: input.actorUserId }), + isUntitled: false, threadNumber, + parentChatId: input.parentChatId ?? null, parentMessageId: input.parentMessageId ?? null, + }).returning() + if (!chat) throw RealtimeRpcError.InternalError() + const update = await UpdatesModel.insertUpdate(tx, { + update: { oneofKind: "newChat", newChat: { chatId: BigInt(chat.id) } }, bucket: UpdateBucket.Chat, entity: chat, + }) + const [updated] = await tx.update(chats).set({ updateSeq: update.seq, lastUpdateDate: update.date }) + .where(eq(chats.id, chat.id)).returning() + if (!updated) throw RealtimeRpcError.InternalError() + registerPostCommitHook(tx, Symbol("grid-thread-created"), { run: async () => { + publishDurableReference({ bucket: { kind: "chat", chatId: chat.id }, frontier: update.seq }) + } }) + return updated +} diff --git a/server/src/modules/grid/transcription/worker.ts b/server/src/modules/grid/transcription/worker.ts new file mode 100644 index 000000000..b11bde485 --- /dev/null +++ b/server/src/modules/grid/transcription/worker.ts @@ -0,0 +1,564 @@ +import { createHmac, timingSafeEqual } from "node:crypto" +import { Schema } from "effect" +import { AccessToken } from "livekit-server-sdk" +import { db } from "@in/server/db" +import { + chats, + gridPresence, + gridRooms, + gridTranscriptionRuns, + gridTranscriptionSegments, + gridTranscriptionWorker, + members, + sessions, + spaces, + users, + type DbGridTranscriptionRun, +} from "@in/server/db/schema" +import type { Transaction } from "@in/server/db/types" +import { + getLiveKitGridConfig, + gridParticipantIdentity, + providerRoomName, + durableLiveKitProviderTarget, +} from "@in/server/modules/grid/livekit" +import { lockGridMutations } from "@in/server/modules/grid/roomLifecycle" +import { notifyGridSpaceChanged } from "@in/server/modules/grid/realtime" +import { SpaceSettingsModel } from "@in/server/db/models/spaceSettings" +import { + GRID_TRANSCRIPTION_LEASE_MS, + GRID_TRANSCRIPTION_MAX_SPEAKERS, + intactDestination, + transcriptionConfig, + supportedGridTranscriptionSession, +} from "./state" +import { insertGridTranscriptMessage } from "./messages" +import { and, asc, eq, gt, inArray, isNull, sql, count, or } from "drizzle-orm" + +const boundedString = (max: number) => Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(max)) +const identitySchema = Schema.Struct({ workerId: boundedString(80) }) +const heartbeatSchema = Schema.Struct({ + workerId: boundedString(80), + model: Schema.Literals(["meeting", "standard"]), + ready: Schema.Boolean, +}) +const admissionSchema = Schema.Struct({ + participantIdentity: boundedString(128), + trackSid: boundedString(128), + sourceTurnKey: boundedString(128), +}) +const finalSchema = Schema.Struct({ + segmentId: Schema.String.check(Schema.isUUID()), + text: Schema.String.check(Schema.isMaxLength(32768)), +}) +const stoppedSchema = Schema.Struct({ reason: Schema.optional(boundedString(80)) }) +const tokenSchema = Schema.Struct({ + runId: Schema.String.check(Schema.isUUID()), + epoch: Schema.Int.check(Schema.isGreaterThan(0)), + expiresAt: Schema.Int, +}) +class WorkerRequestError extends Error { + constructor(readonly status: number) { + super("Grid transcription request rejected") + } +} +function equal(a: string, b: string) { + const aa = Buffer.from(a) + const bb = Buffer.from(b) + return aa.length === bb.length && timingSafeEqual(aa, bb) +} +function sign(body: string, secret: string) { + return createHmac("sha256", secret).update(body).digest("base64url") +} +function runToken(run: DbGridTranscriptionRun, secret: string) { + const body = Buffer.from( + JSON.stringify({ runId: run.id, epoch: run.claimEpoch, expiresAt: run.expiresAt.getTime() + 30_000 }), + ).toString("base64url") + return `${body}.${sign(body, secret)}` +} +function decodeRunToken(token: string, secret: string, allowExpired = false) { + const [body, signature] = token.split(".") + if (!body || !signature || !equal(signature, sign(body, secret))) throw new WorkerRequestError(401) + const authority = Schema.decodeUnknownSync(tokenSchema)(JSON.parse(Buffer.from(body, "base64url").toString("utf8"))) + if (!allowExpired && authority.expiresAt < Date.now()) throw new WorkerRequestError(401) + return authority +} +async function currentParticipants(tx: Transaction, run: DbGridTranscriptionRun) { + const rows = await tx + .select({ + userId: gridPresence.userId, + membershipId: gridPresence.mediaMembershipId, + clientType: sessions.clientType, + clientVersion: sessions.clientVersion, + }) + .from(gridPresence) + .innerJoin(members, and(eq(members.userId, gridPresence.userId), eq(members.spaceId, run.spaceId))) + .innerJoin(users, and(eq(users.id, gridPresence.userId), eq(users.bot, false))) + .innerJoin(sessions, and(eq(sessions.id, gridPresence.ownerSessionId), isNull(sessions.revoked))) + .where(and(eq(gridPresence.roomId, run.sourceRoomId), gt(gridPresence.leaseExpiresAt, new Date()))) + return rows.map((row) => ({ + userId: row.userId, + membershipId: row.membershipId, + identity: gridParticipantIdentity(row.userId, row.membershipId), + qualified: supportedGridTranscriptionSession(row), + })) +} +async function captureAuthority(tx: Transaction, run: DbGridTranscriptionRun) { + const [room] = await tx.select().from(gridRooms).where(eq(gridRooms.id, run.sourceRoomId)).limit(1) + const [space] = await tx + .select({ id: spaces.id }) + .from(spaces) + .where(and(eq(spaces.id, run.spaceId), isNull(spaces.deleted))) + .limit(1) + const participants = await currentParticipants(tx, run) + return { + participants, + valid: + !!room && + !!space && + room.connectionStartedAt !== null && + room.connectionGeneration === run.generation && + run.providerTarget === durableLiveKitProviderTarget() && + participants.length >= 2 && + participants.length <= GRID_TRANSCRIPTION_MAX_SPEAKERS && + participants.every((p) => p.qualified) && + run.expiresAt.getTime() > Date.now() && + (await SpaceSettingsModel.getStored(run.spaceId, tx)).gridEnabled && + (await intactDestination(tx, run)), + } +} +function wireParticipants(participants: Awaited>) { + return participants.map(({ identity, userId, membershipId }) => ({ identity, userId, membershipId })) +} +async function lockedRun(tx: Transaction, authority: { runId: string; epoch: number }) { + const [run] = await tx + .select() + .from(gridTranscriptionRuns) + .where(and(eq(gridTranscriptionRuns.id, authority.runId), eq(gridTranscriptionRuns.claimEpoch, authority.epoch))) + .for("update") + .limit(1) + if (!run) throw new WorkerRequestError(409) + return run +} +async function readBody(request: Request): Promise { + const reader = request.body?.getReader() + if (!reader) throw new WorkerRequestError(400) + const chunks: Uint8Array[] = [] + let size = 0 + try { + while (true) { + const next = await reader.read() + if (next.done) break + size += next.value.byteLength + if (size > 262_144) { + await reader.cancel() + throw new WorkerRequestError(413) + } + chunks.push(next.value) + } + } finally { + reader.releaseLock() + } + const bytes = new Uint8Array(size) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + return JSON.parse(new TextDecoder().decode(bytes)) +} +/** Authenticated internal route only. Never logs body, credentials, audio or text. */ +export async function handleGridTranscriptionWorkerRequest(request: Request): Promise { + const config = transcriptionConfig() + const action = new URL(request.url).pathname.split("/").at(-1) + if (!config.secret || config.secret.length < 32 || (!config.enabled && action !== "stopped" && action !== "claim")) + return Response.json({ error: "unavailable" }, { status: 503 }) + if (request.method !== "POST") return new Response(null, { status: 405 }) + if (Number(request.headers.get("content-length") ?? 0) > 262144) return new Response(null, { status: 413 }) + try { + const token = request.headers.get("authorization")?.replace(/^Bearer /, "") ?? "" + if (action === "heartbeat" || action === "claim") { + if (!equal(token, config.secret)) throw new WorkerRequestError(401) + const raw = await readBody(request) + const { workerId } = Schema.decodeUnknownSync(identitySchema)(raw) + if (action === "heartbeat") { + const heartbeat = Schema.decodeUnknownSync(heartbeatSchema)(raw) + const heartbeatAt = heartbeat.ready ? new Date() : new Date(0) + await db + .insert(gridTranscriptionWorker) + .values({ id: 1, workerId, model: heartbeat.model, heartbeatAt }) + .onConflictDoUpdate({ + target: gridTranscriptionWorker.id, + set: { workerId, model: heartbeat.model, heartbeatAt }, + }) + return Response.json({ ready: heartbeat.ready }) + } + const livekit = getLiveKitGridConfig() + const claimed = await db.transaction(async (tx) => { + await lockGridMutations(tx) + const [run] = await tx + .select() + .from(gridTranscriptionRuns) + .where( + and( + or( + and(eq(gridTranscriptionRuns.state, "starting"), isNull(gridTranscriptionRuns.workerId)), + and( + inArray(gridTranscriptionRuns.state, ["starting", "stopping"]), + eq(gridTranscriptionRuns.workerId, workerId), + ), + ), + ), + ) + .orderBy(asc(gridTranscriptionRuns.createdAt)) + .for("update") + .limit(1) + if (!run) return undefined + const [readyWorker] = await tx + .select() + .from(gridTranscriptionWorker) + .where( + and( + eq(gridTranscriptionWorker.id, 1), + eq(gridTranscriptionWorker.workerId, workerId), + eq(gridTranscriptionWorker.model, run.model), + gt(gridTranscriptionWorker.heartbeatAt, new Date(Date.now() - GRID_TRANSCRIPTION_LEASE_MS)), + ), + ) + .limit(1) + const owned = run.workerId === workerId + if (!owned && (!config.enabled || !livekit || !readyWorker)) throw new WorkerRequestError(409) + const authority = await captureAuthority(tx, run) + if (owned && run.state === "stopping") return { run, stopImmediately: true as const } + if ( + owned && + (!config.enabled || + !livekit || + !readyWorker || + !authority.valid || + !run.leaseExpiresAt || + run.leaseExpiresAt.getTime() <= Date.now()) + ) { + const [stopping] = await tx + .update(gridTranscriptionRuns) + .set({ + state: "stopping", + stopRequestedAt: run.stopRequestedAt ?? new Date(), + interruptionReason: run.interruptionReason ?? "start_unavailable", + revision: sql`${gridTranscriptionRuns.revision} + 1`, + }) + .where(eq(gridTranscriptionRuns.id, run.id)) + .returning() + if (!stopping) throw new WorkerRequestError(409) + return { run: stopping, stopImmediately: true as const } + } + if (!livekit) throw new WorkerRequestError(503) + if (!authority.valid || (!owned && run.createdAt.getTime() < Date.now() - 30_000)) { + await tx + .update(gridTranscriptionRuns) + .set({ + state: run.workerId === null ? "interrupted" : "stopping", + stopRequestedAt: new Date(), + interruptionReason: run.interruptionReason ?? "start_unavailable", + revision: sql`${gridTranscriptionRuns.revision} + 1`, + }) + .where(eq(gridTranscriptionRuns.id, run.id)) + return undefined + } + const [updated] = await tx + .update(gridTranscriptionRuns) + .set({ + workerId, + claimEpoch: run.claimEpoch || 1, + leaseExpiresAt: new Date(Date.now() + GRID_TRANSCRIPTION_LEASE_MS), + revision: sql`${gridTranscriptionRuns.revision} + 1`, + }) + .where(eq(gridTranscriptionRuns.id, run.id)) + .returning() + if (!updated) return undefined + const credential = new AccessToken(livekit.apiKey, livekit.apiSecret, { + identity: `inline-grid-transcription-${updated.id}-${updated.claimEpoch}`, + name: "Inline transcription", + ttl: 60, + }) + credential.addGrant({ + roomJoin: true, + room: providerRoomName({ roomId: BigInt(updated.sourceRoomId), generation: updated.generation }), + canPublish: false, + canSubscribe: true, + canPublishData: false, + canUpdateOwnMetadata: false, + }) + return { + run: updated, + participants: authority.participants, + livekitToken: await credential.toJwt(), + serverUrl: livekit.serverUrl, + stopImmediately: false as const, + } + }) + if (!claimed) return new Response(null, { status: 204 }) + await notifyGridSpaceChanged(claimed.run.spaceId) + if (claimed.stopImmediately) + return Response.json({ + runId: claimed.run.id, + claimEpoch: claimed.run.claimEpoch, + runToken: runToken(claimed.run, config.secret), + stopImmediately: true, + }) + return Response.json({ + runId: claimed.run.id, + claimEpoch: claimed.run.claimEpoch, + runToken: runToken(claimed.run, config.secret), + roomId: claimed.run.sourceRoomId, + generation: claimed.run.generation, + providerTarget: claimed.run.providerTarget, + livekit: { serverUrl: claimed.serverUrl, token: claimed.livekitToken }, + model: claimed.run.model, + leaseMs: GRID_TRANSCRIPTION_LEASE_MS, + expiresAt: claimed.run.expiresAt.toISOString(), + participants: wireParticipants(claimed.participants), + }) + } + const authority = decodeRunToken(token, config.secret, action === "stopped") + if (action === "renew") { + const result = await db.transaction(async (tx) => { + const run = await lockedRun(tx, authority) + const active = await captureAuthority(tx, run) + const canRenew = + (run.state === "active" || run.state === "starting") && + !!run.leaseExpiresAt && + run.leaseExpiresAt.getTime() > Date.now() && + active.valid + const leaseExpiresAt = new Date(Date.now() + GRID_TRANSCRIPTION_LEASE_MS) + const stateChanged = !canRenew && (run.state === "active" || run.state === "starting") + if (canRenew) + await tx.update(gridTranscriptionRuns).set({ leaseExpiresAt }).where(eq(gridTranscriptionRuns.id, run.id)) + else if (stateChanged) + await tx + .update(gridTranscriptionRuns) + .set({ + state: "stopping", + stopRequestedAt: new Date(), + interruptionReason: "authority_lost", + revision: sql`${gridTranscriptionRuns.revision} + 1`, + }) + .where(eq(gridTranscriptionRuns.id, run.id)) + return { + state: canRenew ? "active" : run.state === "stopped" || run.state === "interrupted" ? "stopped" : "stopping", + allowFinalFlush: + run.state === "stopping" && + run.interruptionReason === "user_stop" && + active.valid && + !!run.stopRequestedAt && + Date.now() - run.stopRequestedAt.getTime() <= 5000 && + !!run.leaseExpiresAt && + run.leaseExpiresAt.getTime() > Date.now(), + leaseExpiresAt: (canRenew ? leaseExpiresAt : (run.leaseExpiresAt ?? new Date())).toISOString(), + participants: wireParticipants(active.participants), + spaceId: run.spaceId, + stateChanged, + } + }) + const { spaceId, stateChanged, ...response } = result + if (stateChanged) await notifyGridSpaceChanged(spaceId) + return Response.json(response) + } + if (action === "admit") { + const input = Schema.decodeUnknownSync(admissionSchema)(await readBody(request)) + const result = await db.transaction(async (tx) => { + const run = await lockedRun(tx, authority) + const active = await captureAuthority(tx, run) + const speaker = active.participants.find((p) => p.identity === input.participantIdentity) + if ( + !active.valid || + !speaker || + !run.leaseExpiresAt || + run.leaseExpiresAt.getTime() <= Date.now() || + (run.state !== "starting" && run.state !== "active") + ) + throw new WorkerRequestError(409) + const [existing] = await tx + .select() + .from(gridTranscriptionSegments) + .where( + and( + eq(gridTranscriptionSegments.runId, run.id), + eq(gridTranscriptionSegments.trackSid, input.trackSid), + eq(gridTranscriptionSegments.sourceTurnKey, input.sourceTurnKey), + ), + ) + .limit(1) + if (existing) { + if ( + existing.claimEpoch !== run.claimEpoch || + existing.speakerUserId !== speaker.userId || + existing.membershipId !== speaker.membershipId || + existing.state !== "admitted" + ) + throw new WorkerRequestError(409) + return { segmentId: existing.id, spaceId: run.spaceId } + } + const pending = await tx + .select({ trackSid: gridTranscriptionSegments.trackSid, value: count() }) + .from(gridTranscriptionSegments) + .where(and(eq(gridTranscriptionSegments.runId, run.id), eq(gridTranscriptionSegments.state, "admitted"))) + .groupBy(gridTranscriptionSegments.trackSid) + if ( + pending.reduce((sum, p) => sum + Number(p.value), 0) >= 64 || + Number(pending.find((p) => p.trackSid === input.trackSid)?.value ?? 0) >= 2 + ) + throw new WorkerRequestError(409) + const [segment] = await tx + .insert(gridTranscriptionSegments) + .values({ + runId: run.id, + claimEpoch: run.claimEpoch, + speakerUserId: speaker.userId, + membershipId: speaker.membershipId, + trackSid: input.trackSid, + sourceTurnKey: input.sourceTurnKey, + }) + .returning({ id: gridTranscriptionSegments.id }) + if (!segment) throw new WorkerRequestError(409) + if (run.state === "starting") + await tx + .update(gridTranscriptionRuns) + .set({ state: "active", revision: sql`${gridTranscriptionRuns.revision} + 1` }) + .where(eq(gridTranscriptionRuns.id, run.id)) + return { segmentId: segment.id, spaceId: run.spaceId } + }) + await notifyGridSpaceChanged(result.spaceId) + return Response.json({ segmentId: result.segmentId }) + } + if (action === "final") { + const input = Schema.decodeUnknownSync(finalSchema)(await readBody(request)) + if (Buffer.byteLength(input.text, "utf8") > 32768) throw new WorkerRequestError(413) + const saved = await db.transaction(async (tx) => { + // History mutations lock chats before runs. Do the same here. + const [target] = await tx + .select({ + chatId: gridTranscriptionRuns.transcriptChatId, + parentId: gridTranscriptionRuns.destinationParentChatId, + }) + .from(gridTranscriptionRuns) + .where(eq(gridTranscriptionRuns.id, authority.runId)) + .limit(1) + if (!target) throw new WorkerRequestError(409) + await tx + .select({ id: chats.id }) + .from(chats) + .where(inArray(chats.id, [target.parentId, target.chatId])) + .orderBy(asc(chats.id)) + .for("update") + const run = await lockedRun(tx, authority) + const [segment] = await tx + .select() + .from(gridTranscriptionSegments) + .where( + and( + eq(gridTranscriptionSegments.id, input.segmentId), + eq(gridTranscriptionSegments.runId, run.id), + eq(gridTranscriptionSegments.claimEpoch, run.claimEpoch), + ), + ) + .for("update") + .limit(1) + if (!segment) throw new WorkerRequestError(409) + if (segment.state !== "admitted") return { messageId: segment.messageId, inserted: undefined } + const [member] = await tx + .select({ id: members.id }) + .from(members) + .where(and(eq(members.spaceId, run.spaceId), eq(members.userId, segment.speakerUserId))) + .limit(1) + const current = await captureAuthority(tx, run) + const allowed = + !!member && + current.valid && + !!run.leaseExpiresAt && + run.leaseExpiresAt.getTime() > Date.now() && + run.expiresAt.getTime() > Date.now() && + (run.state === "active" || + run.state === "starting" || + (run.state === "stopping" && + run.interruptionReason === "user_stop" && + !!run.stopRequestedAt && + Date.now() - run.stopRequestedAt.getTime() <= 5000)) + if (!allowed || !input.text.trim()) { + await tx + .update(gridTranscriptionSegments) + .set({ state: "discarded" }) + .where(eq(gridTranscriptionSegments.id, segment.id)) + return { messageId: null, inserted: undefined } + } + const inserted = await insertGridTranscriptMessage(tx, { + chatId: run.transcriptChatId, + actorUserId: run.actorUserId, + runId: run.id, + segmentId: segment.id, + speakerUserId: segment.speakerUserId, + kind: "turn", + text: input.text.trim(), + }) + await tx + .update(gridTranscriptionSegments) + .set({ state: "finalized", messageId: inserted.message.messageId }) + .where(eq(gridTranscriptionSegments.id, segment.id)) + return { messageId: inserted.message.messageId, inserted } + }) + return Response.json({ messageId: saved.messageId }) + } + if (action === "stopped") { + Schema.decodeUnknownSync(stoppedSchema)(await readBody(request)) + const spaceId = await db.transaction(async (tx) => { + const [target] = await tx + .select({ + chatId: gridTranscriptionRuns.transcriptChatId, + parentId: gridTranscriptionRuns.destinationParentChatId, + }) + .from(gridTranscriptionRuns) + .where(eq(gridTranscriptionRuns.id, authority.runId)) + .limit(1) + if (!target) throw new WorkerRequestError(409) + await tx + .select({ id: chats.id }) + .from(chats) + .where(inArray(chats.id, [target.parentId, target.chatId])) + .orderBy(asc(chats.id)) + .for("update") + const run = await lockedRun(tx, authority) + if (run.state !== "stopped" && run.state !== "interrupted") { + if (await intactDestination(tx, run)) + await insertGridTranscriptMessage(tx, { + chatId: run.transcriptChatId, + actorUserId: run.actorUserId, + runId: run.id, + segmentId: "stopped", + kind: run.interruptionReason === "user_stop" ? "stopped" : "interrupted", + text: run.interruptionReason === "user_stop" ? "Transcription stopped" : "Transcription interrupted", + }) + await tx + .update(gridTranscriptionRuns) + .set({ + state: run.interruptionReason === "user_stop" ? "stopped" : "interrupted", + revision: sql`${gridTranscriptionRuns.revision} + 1`, + }) + .where(eq(gridTranscriptionRuns.id, run.id)) + await tx + .update(gridTranscriptionSegments) + .set({ state: "discarded" }) + .where(and(eq(gridTranscriptionSegments.runId, run.id), eq(gridTranscriptionSegments.state, "admitted"))) + } + return run.spaceId + }) + await notifyGridSpaceChanged(spaceId) + return Response.json({ stopped: true }) + } + return new Response(null, { status: 404 }) + } catch (error) { + if (error instanceof WorkerRequestError) return Response.json({ error: "rejected" }, { status: error.status }) + if (Schema.isSchemaError(error) || error instanceof SyntaxError) + return Response.json({ error: "invalid_request" }, { status: 400 }) + return Response.json({ error: "request_failed" }, { status: 500 }) + } +} diff --git a/server/src/modules/historyClear/data.ts b/server/src/modules/historyClear/data.ts index b25c0d673..1fb880997 100644 --- a/server/src/modules/historyClear/data.ts +++ b/server/src/modules/historyClear/data.ts @@ -4,6 +4,8 @@ import type { Transaction } from "@in/server/db/types" import { and, desc, eq, inArray, isNull, lt, ne, or, sql } from "drizzle-orm" import { deleteUnreferencedBlockContents } from "@in/server/modules/message/blockContentStorage" import { getEffectiveChatAccessUserIds } from "@in/server/modules/authorization/chatAccessProjection" +import { messageActivityPredicate } from "@in/server/modules/message/activity" +import { invalidateGridTranscriptionForHistory } from "@in/server/modules/grid/transcription/state" export type ClearHistoryOptions = { beforeDate?: Date @@ -144,6 +146,9 @@ export async function clearChatHistoryData( input: ClearChatHistoryInput, hooks?: ClearHistoryHooks, ): Promise { + // Even a dated clear conservatively ends affected capture. Never flush + // already admitted speech back into the history the user just removed. + await invalidateGridTranscriptionForHistory(tx, { chatIds: [input.chatId], reason: "history_cleared" }) let deletedChatIds: number[] = [] let deletedChats: ClearHistoryDeletedChat[] = [] let orphanedChatIds: number[] = [] @@ -178,6 +183,8 @@ export async function clearSpaceHistoryData( input: ClearSpaceHistoryInput, hooks?: ClearHistoryHooks, ): Promise { + const spaceChatRows = await tx.select({ id: chats.id }).from(chats).where(eq(chats.spaceId, input.spaceId)) + await invalidateGridTranscriptionForHistory(tx, { chatIds: spaceChatRows.map((chat) => chat.id), reason: "history_cleared" }) const detachedExternalChats = await detachExternalReplyThreadsForClearedSpaceMessages( tx, input.spaceId, @@ -228,7 +235,7 @@ async function refreshChatLastMsgId(tx: Transaction, chatId: number): Promise { const unsubscribeSession = b.on("SessionRealtime", ({ target }) => receivedSession.resolve(target.bootId)) const directed = await a.publish({ target: { kind: "session", bootId: b.bootId, userId: UserId.make(9001), sessionId: SessionId.make(9002) }, - event: { kind: "SessionRealtime", payload: { kind: "gridCredentials", roomId: 1, - spaceId: SpaceId.make(2), generation: 3, mediaMembershipId: randomUUID(), encodedPayload: "e30=" } }, + event: { + kind: "SessionRealtime", + payload: { + kind: "gridCredentials", + roomId: 1, + spaceId: SpaceId.make(2), + generation: 3, + mediaMembershipId: randomUUID(), + callId: randomUUID(), + encodedPayload: "e30=", + }, + }, }) expect(directed).toEqual({ status: "published", subscribers: 1 }) expect(await waitFor(receivedSession.promise)).toBe(b.bootId) diff --git a/server/src/modules/internalMessaging/schemas.test.ts b/server/src/modules/internalMessaging/schemas.test.ts index bfd8cd666..3aab1a951 100644 --- a/server/src/modules/internalMessaging/schemas.test.ts +++ b/server/src/modules/internalMessaging/schemas.test.ts @@ -86,8 +86,18 @@ describe("internal messaging contracts", () => { const envelope = { version: 1 as const, eventId: randomUUID(), originBootId: randomUUID(), target: { kind: "session" as const, bootId: randomUUID(), userId: UserId.make(1), sessionId: SessionId.make(2) }, - event: { kind: "SessionRealtime" as const, payload: { kind: "gridCredentials" as const, - roomId: 3, spaceId: SpaceId.make(4), generation: 5, mediaMembershipId: randomUUID(), encodedPayload: "e30=" } }, + event: { + kind: "SessionRealtime" as const, + payload: { + kind: "gridCredentials" as const, + roomId: 3, + spaceId: SpaceId.make(4), + generation: 5, + mediaMembershipId: randomUUID(), + callId: randomUUID(), + encodedPayload: "e30=", + }, + }, } expect(decodeEnvelope(encodeEnvelope(envelope))).toEqual(envelope) expect(() => decodeEnvelope(JSON.stringify({ ...envelope, target: { kind: "session", userId: 1, sessionId: 2 } }))).toThrow() diff --git a/server/src/modules/internalMessaging/schemas.ts b/server/src/modules/internalMessaging/schemas.ts index f5b002a1a..f4a53988d 100644 --- a/server/src/modules/internalMessaging/schemas.ts +++ b/server/src/modules/internalMessaging/schemas.ts @@ -92,7 +92,15 @@ const PrivateReply = Schema.Struct({ const SessionRealtime = Schema.Struct({ kind: Schema.Literal("SessionRealtime"), payload: Schema.Union([ - Schema.Struct({ kind: Schema.Literal("gridCredentials"), roomId: WirePositiveInteger, spaceId: SpaceId, generation: WireNonNegativeInteger, mediaMembershipId: Uuid, encodedPayload: Schema.String.check(Schema.isMaxLength(32768)) }), + Schema.Struct({ + kind: Schema.Literal("gridCredentials"), + roomId: WirePositiveInteger, + spaceId: SpaceId, + generation: WireNonNegativeInteger, + mediaMembershipId: Uuid, + callId: Uuid, + encodedPayload: Schema.String.check(Schema.isMaxLength(32768)), + }), ]), }) diff --git a/server/src/modules/message/activity.ts b/server/src/modules/message/activity.ts new file mode 100644 index 000000000..12c9f5bea --- /dev/null +++ b/server/src/modules/message/activity.ts @@ -0,0 +1,7 @@ +import { messages } from "@in/server/db/schema/messages" +import { eq, isNull, or } from "drizzle-orm" + +/** Quiet generated service rows remain in history without becoming previews. */ +export function messageActivityPredicate() { + return or(eq(messages.countsAsUnread, true), isNull(messages.systemMessageEncrypted)) +} diff --git a/server/src/protocol/server.ts b/server/src/protocol/server.ts index e78bc2b86..be22705b0 100644 --- a/server/src/protocol/server.ts +++ b/server/src/protocol/server.ts @@ -13,6 +13,7 @@ import { reflectionMergePartial } from "@protobuf-ts/runtime"; import { MessageType } from "@protobuf-ts/runtime"; import { MessageActionResponseUi } from "@inline-chat/protocol/core"; import { Chat } from "@inline-chat/protocol/core"; +import { MessageServiceGridTranscript } from "@inline-chat/protocol/core"; import { BlockContent } from "@inline-chat/protocol/core"; import { MessageEntities } from "@inline-chat/protocol/core"; import { Dialog } from "@inline-chat/protocol/core"; @@ -1122,6 +1123,12 @@ export interface SystemMessage { * @generated from protobuf field: server.PinnedMessageSystemMessage pinned_message = 2; */ pinnedMessage: PinnedMessageSystemMessage; + } | { + oneofKind: "gridTranscript"; + /** + * @generated from protobuf field: MessageServiceGridTranscript grid_transcript = 3; + */ + gridTranscript: MessageServiceGridTranscript; } | { oneofKind: undefined; }; @@ -4284,7 +4291,8 @@ class SystemMessage$Type extends MessageType { constructor() { super("server.SystemMessage", [ { no: 1, name: "thread_backlink", kind: "message", oneof: "event", T: () => ThreadBacklinkSystemMessage }, - { no: 2, name: "pinned_message", kind: "message", oneof: "event", T: () => PinnedMessageSystemMessage } + { no: 2, name: "pinned_message", kind: "message", oneof: "event", T: () => PinnedMessageSystemMessage }, + { no: 3, name: "grid_transcript", kind: "message", oneof: "event", T: () => MessageServiceGridTranscript } ]); } create(value?: PartialMessage): SystemMessage { @@ -4311,6 +4319,12 @@ class SystemMessage$Type extends MessageType { pinnedMessage: PinnedMessageSystemMessage.internalBinaryRead(reader, reader.uint32(), options, (message.event as any).pinnedMessage) }; break; + case /* MessageServiceGridTranscript grid_transcript */ 3: + message.event = { + oneofKind: "gridTranscript", + gridTranscript: MessageServiceGridTranscript.internalBinaryRead(reader, reader.uint32(), options, (message.event as any).gridTranscript) + }; + break; default: let u = options.readUnknownField; if (u === "throw") @@ -4329,6 +4343,9 @@ class SystemMessage$Type extends MessageType { /* server.PinnedMessageSystemMessage pinned_message = 2; */ if (message.event.oneofKind === "pinnedMessage") PinnedMessageSystemMessage.internalBinaryWrite(message.event.pinnedMessage, writer.tag(2, WireType.LengthDelimited).fork(), options).join(); + /* MessageServiceGridTranscript grid_transcript = 3; */ + if (message.event.oneofKind === "gridTranscript") + MessageServiceGridTranscript.internalBinaryWrite(message.event.gridTranscript, writer.tag(3, WireType.LengthDelimited).fork(), options).join(); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); diff --git a/server/src/realtime/encoders/encodeMessage.test.ts b/server/src/realtime/encoders/encodeMessage.test.ts index 721a26b7c..fc4da3df9 100644 --- a/server/src/realtime/encoders/encodeMessage.test.ts +++ b/server/src/realtime/encoders/encodeMessage.test.ts @@ -8,6 +8,7 @@ import { BlockDisclosure_Kind, MessageEntities, MessageEntity_Type, + MessageSendMode, Photo_Format, type Peer, } from "@inline-chat/protocol/core" @@ -466,6 +467,31 @@ describe("mentioned", () => { }) describe("service messages", () => { + it("keeps generated transcription quiet in direct and history projections", () => { + const service = { + event: { + oneofKind: "gridTranscript" as const, + gridTranscript: { runId: "run", segmentId: "segment", speakerUserId: 200n, kind: 0 }, + }, + } + const direct = encodeMessage({ + message: { ...buildMessage({ text: "Transcript · Ben: Hello", countsAsUnread: false }), systemMessage: service }, + encodingForUserId: 100, + encodingForPeer: { peer }, + }) + const history = encodeFullMessage({ + message: buildFullMessage({ text: "Transcript · Ben: Hello", countsAsUnread: false, systemMessage: service }), + encodingForUserId: 100, + encodingForPeer: { peer }, + }) + for (const result of [direct, history]) { + expect(result.countsAsUnread).toBe(false) + expect(result.sendMode).toBe(MessageSendMode.MODE_SILENT) + expect(result.mentioned).toBe(false) + expect(result.serviceMessage?.event).toEqual(service.event) + } + }) + it("encodes thread backlink service metadata on full messages", () => { const result = encodeFullMessage({ message: buildFullMessage({ diff --git a/server/src/realtime/encoders/encodeMessage.ts b/server/src/realtime/encoders/encodeMessage.ts index 630c25dfd..8187f3f91 100644 --- a/server/src/realtime/encoders/encodeMessage.ts +++ b/server/src/realtime/encoders/encodeMessage.ts @@ -66,6 +66,14 @@ function encodeServiceMessage(systemMessage: SystemMessage | null | undefined): }, } + case "gridTranscript": + return { + event: { + oneofKind: "gridTranscript", + gridTranscript: event.gridTranscript, + }, + } + case undefined: return undefined } @@ -226,17 +234,20 @@ export const encodeMessage = ({ date: encodeDateStrict(message.date), editDate: message.editDate ? encodeDateStrict(message.editDate) : undefined, rev: BigInt(message.rev ?? 0), - mentioned: entities ? isUserMentioned(entities, encodingForUserId, mentionedUserIds) : false, + mentioned: message.systemMessage?.event.oneofKind === "gridTranscript" + ? false + : entities ? isUserMentioned(entities, encodingForUserId, mentionedUserIds) : false, replyToMsgId: message.replyToMsgId ? BigInt(message.replyToMsgId) : undefined, media: media, isSticker: message.isSticker || undefined, hasLink: hasLink, entities: entities, - sendMode: sendMode ?? undefined, + sendMode: message.countsAsUnread === false ? MessageSendMode.MODE_SILENT : sendMode ?? undefined, + countsAsUnread: message.countsAsUnread === false ? false : undefined, fwdFrom: fwdFrom, replies, subthread, - actions, + actions: message.systemMessage?.event.oneofKind === "gridTranscript" ? undefined : actions, serviceMessage: encodeServiceMessage(message.systemMessage), blockContent: message.blockContent ?? undefined, agentSession: message.agentSession, @@ -370,7 +381,9 @@ export const encodeFullMessage = ({ date: encodeDateStrict(message.date), editDate: message.editDate ? encodeDateStrict(message.editDate) : undefined, rev: BigInt(message.rev ?? 0), - mentioned: message.entities ? isUserMentioned(message.entities, encodingForUserId) : false, + mentioned: message.systemMessage?.event.oneofKind === "gridTranscript" + ? false + : message.entities ? isUserMentioned(message.entities, encodingForUserId) : false, replyToMsgId: message.replyToMsgId ? BigInt(message.replyToMsgId) : undefined, media: media, isSticker: message.isSticker ?? false, @@ -382,10 +395,12 @@ export const encodeFullMessage = ({ } : undefined, entities: message.entities ?? undefined, + sendMode: message.countsAsUnread === false ? MessageSendMode.MODE_SILENT : undefined, + countsAsUnread: message.countsAsUnread === false ? false : undefined, fwdFrom: fwdFrom, replies, subthread, - actions: message.actions ?? undefined, + actions: message.systemMessage?.event.oneofKind === "gridTranscript" ? undefined : message.actions ?? undefined, serviceMessage: encodeServiceMessage(message.systemMessage), blockContent: blockContent ?? undefined, agentSession: message.agentSession, diff --git a/server/src/realtime/handlers/_rpc.ts b/server/src/realtime/handlers/_rpc.ts index b76fa5fc9..e0a952f6c 100644 --- a/server/src/realtime/handlers/_rpc.ts +++ b/server/src/realtime/handlers/_rpc.ts @@ -105,12 +105,16 @@ import { import { setSpacePhotoHandler } from "@in/server/realtime/handlers/space.profile" import { getSpaceSettingsHandler, toggleSpaceGridHandler } from "@in/server/realtime/handlers/space.settings" import { + openGridThreadHandler, + setGridTranscriptionHandler, + listGridTranscriptsHandler, createGridRoomHandler, deleteGridRoomHandler, getGridHandler, getGridHomeHandler, joinGridRoomHandler, leaveGridRoomHandler, + moveGridCallHereHandler, prepareGridConnectionHandler, setGridAvatarMicrophoneEnabledHandler, setGridRoomLockedHandler, @@ -467,6 +471,24 @@ export const handleRpcCall = async (call: RpcCall, handlerContext: HandlerContex return { oneofKind: "toggleSpaceGrid", toggleSpaceGrid: result } } + case Method.OPEN_GRID_THREAD: { + if (call.input.oneofKind !== "openGridThread") throw RealtimeRpcError.BadRequest() + const result = await openGridThreadHandler(call.input.openGridThread, handlerContext) + return { oneofKind: "openGridThread", openGridThread: result } + } + + case Method.SET_GRID_TRANSCRIPTION: { + if (call.input.oneofKind !== "setGridTranscription") throw RealtimeRpcError.BadRequest() + const result = await setGridTranscriptionHandler(call.input.setGridTranscription, handlerContext) + return { oneofKind: "setGridTranscription", setGridTranscription: result } + } + + case Method.LIST_GRID_TRANSCRIPTS: { + if (call.input.oneofKind !== "listGridTranscripts") throw RealtimeRpcError.BadRequest() + const result = await listGridTranscriptsHandler(call.input.listGridTranscripts, handlerContext) + return { oneofKind: "listGridTranscripts", listGridTranscripts: result } + } + case Method.GET_GRID: { if (call.input.oneofKind !== "getGrid") throw RealtimeRpcError.BadRequest() const result = await getGridHandler(call.input.getGrid, handlerContext) @@ -497,6 +519,12 @@ export const handleRpcCall = async (call: RpcCall, handlerContext: HandlerContex return { oneofKind: "leaveGridRoom", leaveGridRoom: result } } + case Method.MOVE_GRID_CALL_HERE: { + if (call.input.oneofKind !== "moveGridCallHere") throw RealtimeRpcError.BadRequest() + const result = await moveGridCallHereHandler(call.input.moveGridCallHere, handlerContext) + return { oneofKind: "moveGridCallHere", moveGridCallHere: result } + } + case Method.SET_GRID_ROOM_TITLE: { if (call.input.oneofKind !== "setGridRoomTitle") throw RealtimeRpcError.BadRequest() const result = await setGridRoomTitleHandler(call.input.setGridRoomTitle, handlerContext) diff --git a/server/src/realtime/handlers/grid.ts b/server/src/realtime/handlers/grid.ts index bff21114d..13a2de28d 100644 --- a/server/src/realtime/handlers/grid.ts +++ b/server/src/realtime/handlers/grid.ts @@ -1,4 +1,10 @@ import type { + OpenGridThreadInput, + OpenGridThreadResult, + SetGridTranscriptionInput, + SetGridTranscriptionResult, + ListGridTranscriptsInput, + ListGridTranscriptsResult, CreateGridRoomInput, CreateGridRoomResult, DeleteGridRoomInput, @@ -11,6 +17,8 @@ import type { JoinGridRoomResult, LeaveGridRoomInput, LeaveGridRoomResult, + MoveGridCallHereInput, + MoveGridCallHereResult, PrepareGridConnectionInput, PrepareGridConnectionResult, SetGridAvatarMicrophoneEnabledInput, @@ -51,6 +59,11 @@ export const leaveGridRoomHandler = ( context: HandlerContext, ): Promise => Functions.grid.leaveRoom(input, functionContext(context)) +export const moveGridCallHereHandler = ( + input: MoveGridCallHereInput, + context: HandlerContext, +): Promise => Functions.grid.moveCallHere(input, functionContext(context)) + export const setGridRoomTitleHandler = ( input: SetGridRoomTitleInput, context: HandlerContext, @@ -76,3 +89,12 @@ export const setGridAvatarMicrophoneEnabledHandler = ( context: HandlerContext, ): Promise => Functions.grid.setAvatarMicrophoneEnabled(input, functionContext(context)) + +export const openGridThreadHandler = (input: OpenGridThreadInput, context: HandlerContext): Promise => + Functions.grid.openThread(input, functionContext(context)) + +export const setGridTranscriptionHandler = (input: SetGridTranscriptionInput, context: HandlerContext): Promise => + Functions.grid.setTranscription(input, functionContext(context)) + +export const listGridTranscriptsHandler = (input: ListGridTranscriptsInput, context: HandlerContext): Promise => + Functions.grid.listTranscripts(input, functionContext(context)) diff --git a/server/src/utils/httpPrivacy.test.ts b/server/src/utils/httpPrivacy.test.ts index d926b5a3a..610dc890f 100644 --- a/server/src/utils/httpPrivacy.test.ts +++ b/server/src/utils/httpPrivacy.test.ts @@ -14,6 +14,7 @@ test("preserves ordinary diagnostic paths and excludes public Bot documentation } expect(requiresPrivateResponse("/health")).toBeFalse() expect(requiresPrivateResponse("/bot-api-reference/json")).toBeFalse() + expect(requiresPrivateResponse("/_internal/grid-transcription/claim")).toBeTrue() }) test("legacy HTTP setup adds no-store without changing GET dispatch", async () => { diff --git a/server/src/utils/httpPrivacy.ts b/server/src/utils/httpPrivacy.ts index 19457da3c..31821463c 100644 --- a/server/src/utils/httpPrivacy.ts +++ b/server/src/utils/httpPrivacy.ts @@ -5,5 +5,6 @@ export const redactCredentialPath = (path: string): string => path /** These APIs can return credentials or private content, including on GET/error paths. */ export const requiresPrivateResponse = (path: string): boolean => + /^\/_internal\/grid-transcription(?:\/|$)/i.test(path) || /^\/v1(?:\/|$)/i.test(path) || (/^\/bot(?:[^/]*)(?:\/|$)/i.test(path) && !/^\/bot-api-reference(?:\/|$)/i.test(path))