mirror of
https://github.com/supabase/supabase.git
synced 2026-10-07 18:35:07 +03:00
feat(storage): add archived objects data layer
On a versioned bucket a delete is a soft delete, and the file preview panel already calls that action Archive and promises the versions stay recoverable. Nothing in the dashboard lets a user see or restore an archived file yet. This is the data layer for that, with no UI: query and mutation shapes written to the studio conventions, endpoints stubbed, returning empty. - `archived-objects-query.ts` — `ArchivedObject` / `ArchivedObjectVersion` types and `archivedObjectsQueryOptions` - `archived-object-restore-mutation.ts` — bring an archived object back - `archived-object-purge-mutation.ts` — delete it and every version, permanently - `archived-object-version-delete-mutation.ts` — remove one retained version - `archivedOverlay.utils.ts` — synthesizes the explorer rows for one folder from the archived list - `archivedVersions.utils.ts` — an archived object's history as one flat list Two prototype problems fixed rather than carried over: The prototype identified the "was current when archived" row by the sentinel `versionId === objectId`, which was load-bearing across three files and would break the moment real version ids arrived. `ArchivedObject` now carries a `currentVersion` record, so merging invents no fields and the distinction is an explicit `wasCurrentAtArchive` flag. The prototype's object had both `name` and `originalPath`, inconsistently — the path normalization existed mostly to strip a duplicated leaf folder that inconsistency produced. There is now a single `path`, and `getArchivedSegments` is the only function that interprets it, so a different API shape is a one-place change. Also drops `deletedBy` and `expiresAt`, which the prototype never rendered. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e6b4495c04
commit
44ff5525dc
10 files changed
+497
No files matched your search
@@ -40,6 +40,11 @@ export interface StorageItem {
|
||||
// UI specific properties, not from API
|
||||
isCorrupted: boolean
|
||||
path?: string
|
||||
/**
|
||||
* Set on a row synthesized from the archived list. `archivedObjectId` is absent
|
||||
* on a folder that exists only because something archived sits inside it.
|
||||
*/
|
||||
archived?: { archivedObjectId?: string }
|
||||
}
|
||||
|
||||
export type StorageItemWithColumn = StorageItem & { columnIndex: number }
|
||||
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { STORAGE_ROW_TYPES } from '../Storage.constants'
|
||||
import { getArchivedOverlayItems, getArchivedSegments } from './archivedOverlay.utils'
|
||||
import type { ArchivedObject } from '@/data/storage/versioning/archived-objects-query'
|
||||
|
||||
const archived = (path: string, overrides: Partial<ArchivedObject> = {}): ArchivedObject => ({
|
||||
id: `id-${path}`,
|
||||
path,
|
||||
archivedAt: '2026-08-01T00:00:00Z',
|
||||
currentVersion: {
|
||||
versionId: `v-${path}`,
|
||||
size: 1024,
|
||||
createdAt: '2026-07-01T00:00:00Z',
|
||||
action: 'overwrite',
|
||||
},
|
||||
noncurrentVersions: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const overlay = (
|
||||
folderSegments: string[],
|
||||
archivedObjects: ArchivedObject[],
|
||||
existingItemNames: string[] = []
|
||||
) =>
|
||||
getArchivedOverlayItems({
|
||||
folderSegments,
|
||||
archivedObjects,
|
||||
existingItemNames: new Set(existingItemNames),
|
||||
})
|
||||
|
||||
describe('getArchivedSegments', () => {
|
||||
it('splits a nested path into segments', () => {
|
||||
expect(getArchivedSegments(archived('matches/round-3/final.png'))).toEqual([
|
||||
'matches',
|
||||
'round-3',
|
||||
'final.png',
|
||||
])
|
||||
})
|
||||
|
||||
it('tolerates leading, trailing and repeated slashes', () => {
|
||||
expect(getArchivedSegments(archived('/matches//round-3/final.png/'))).toEqual([
|
||||
'matches',
|
||||
'round-3',
|
||||
'final.png',
|
||||
])
|
||||
})
|
||||
|
||||
it('handles a file at the bucket root', () => {
|
||||
expect(getArchivedSegments(archived('logo.svg'))).toEqual(['logo.svg'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getArchivedOverlayItems', () => {
|
||||
it('returns nothing when there is nothing archived', () => {
|
||||
expect(overlay([], [])).toEqual([])
|
||||
})
|
||||
|
||||
it('surfaces a root-level object as a file row at the root', () => {
|
||||
const [row] = overlay([], [archived('logo.svg')])
|
||||
expect(row.name).toBe('logo.svg')
|
||||
expect(row.type).toBe(STORAGE_ROW_TYPES.FILE)
|
||||
expect(row.archived).toEqual({ archivedObjectId: 'id-logo.svg' })
|
||||
expect(row.path).toBe('logo.svg')
|
||||
})
|
||||
|
||||
it('carries the archived size and timestamp onto the row', () => {
|
||||
const [row] = overlay([], [archived('logo.svg')])
|
||||
expect(row.metadata?.size).toBe(1024)
|
||||
expect(row.updated_at).toBe('2026-08-01T00:00:00Z')
|
||||
})
|
||||
|
||||
it('does not surface a root object when viewing a subfolder', () => {
|
||||
expect(overlay(['matches'], [archived('logo.svg')])).toEqual([])
|
||||
})
|
||||
|
||||
it('surfaces a nested object as a folder row one level up', () => {
|
||||
const rows = overlay([], [archived('matches/final.png')])
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].name).toBe('matches')
|
||||
expect(rows[0].type).toBe(STORAGE_ROW_TYPES.FOLDER)
|
||||
expect(rows[0].archived).toEqual({})
|
||||
})
|
||||
|
||||
it('surfaces the file itself once inside that folder', () => {
|
||||
const rows = overlay(['matches'], [archived('matches/final.png')])
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].name).toBe('final.png')
|
||||
expect(rows[0].type).toBe(STORAGE_ROW_TYPES.FILE)
|
||||
})
|
||||
|
||||
it('coalesces several objects under one subfolder into a single row', () => {
|
||||
const rows = overlay(
|
||||
[],
|
||||
[archived('matches/a.png'), archived('matches/b.png'), archived('matches/deep/c.png')]
|
||||
)
|
||||
expect(rows.map((r) => r.name)).toEqual(['matches'])
|
||||
})
|
||||
|
||||
it('skips a folder that already exists in the live listing', () => {
|
||||
expect(overlay([], [archived('matches/final.png')], ['matches'])).toEqual([])
|
||||
})
|
||||
|
||||
it('skips a file that already exists in the live listing', () => {
|
||||
expect(overlay([], [archived('logo.svg')], ['logo.svg'])).toEqual([])
|
||||
})
|
||||
|
||||
it('orders folders before files', () => {
|
||||
const rows = overlay([], [archived('logo.svg'), archived('matches/final.png')])
|
||||
expect(rows.map((r) => r.type)).toEqual([STORAGE_ROW_TYPES.FOLDER, STORAGE_ROW_TYPES.FILE])
|
||||
})
|
||||
|
||||
it('only descends one level at a time', () => {
|
||||
const rows = overlay([], [archived('a/b/c/d.png')])
|
||||
expect(rows.map((r) => r.name)).toEqual(['a'])
|
||||
expect(overlay(['a'], [archived('a/b/c/d.png')]).map((r) => r.name)).toEqual(['b'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES } from '../Storage.constants'
|
||||
import type { StorageItem } from '../Storage.types'
|
||||
import type { ArchivedObject } from '@/data/storage/versioning/archived-objects-query'
|
||||
|
||||
const splitPath = (path: string): string[] => path.split('/').filter((segment) => segment !== '')
|
||||
|
||||
/**
|
||||
* The only place that interprets `ArchivedObject.path`, so a different API shape
|
||||
* is a one-function change.
|
||||
*/
|
||||
export const getArchivedSegments = (object: ArchivedObject): string[] => splitPath(object.path)
|
||||
|
||||
const isUnderFolder = (folderSegments: string[], objectSegments: string[]): boolean => {
|
||||
if (objectSegments.length <= folderSegments.length) return false
|
||||
return folderSegments.every((segment, index) => segment === objectSegments[index])
|
||||
}
|
||||
|
||||
export interface ArchivedOverlayInput {
|
||||
/** Empty at the bucket root. */
|
||||
folderSegments: string[]
|
||||
archivedObjects: ArchivedObject[]
|
||||
/** Names from the live listing; a live item of the same name always wins. */
|
||||
existingItemNames: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Objects deeper down are coalesced into one folder row per next segment, so
|
||||
* drilling in runs this again a level lower.
|
||||
*/
|
||||
export const getArchivedOverlayItems = ({
|
||||
folderSegments,
|
||||
archivedObjects,
|
||||
existingItemNames,
|
||||
}: ArchivedOverlayInput): StorageItem[] => {
|
||||
const files: StorageItem[] = []
|
||||
const folderNames = new Set<string>()
|
||||
|
||||
for (const object of archivedObjects) {
|
||||
const segments = getArchivedSegments(object)
|
||||
if (!isUnderFolder(folderSegments, segments)) continue
|
||||
|
||||
const nextSegment = segments[folderSegments.length]
|
||||
const isDirectChild = segments.length === folderSegments.length + 1
|
||||
|
||||
if (!isDirectChild) {
|
||||
folderNames.add(nextSegment)
|
||||
continue
|
||||
}
|
||||
|
||||
if (existingItemNames.has(nextSegment)) continue
|
||||
|
||||
files.push({
|
||||
id: object.id,
|
||||
name: nextSegment,
|
||||
type: STORAGE_ROW_TYPES.FILE,
|
||||
status: STORAGE_ROW_STATUS.READY,
|
||||
metadata: {
|
||||
size: object.currentVersion.size,
|
||||
mimetype: '',
|
||||
cacheControl: '',
|
||||
contentLength: object.currentVersion.size,
|
||||
httpStatusCode: 0,
|
||||
eTag: '',
|
||||
lastModified: object.archivedAt,
|
||||
},
|
||||
created_at: null,
|
||||
updated_at: object.archivedAt,
|
||||
last_accessed_at: null,
|
||||
isCorrupted: false,
|
||||
path: [...folderSegments, nextSegment].join('/'),
|
||||
archived: { archivedObjectId: object.id },
|
||||
})
|
||||
}
|
||||
|
||||
const folders: StorageItem[] = [...folderNames]
|
||||
.filter((folderName) => !existingItemNames.has(folderName))
|
||||
.map((folderName) => ({
|
||||
id: null,
|
||||
name: folderName,
|
||||
type: STORAGE_ROW_TYPES.FOLDER,
|
||||
status: STORAGE_ROW_STATUS.READY,
|
||||
metadata: null,
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
last_accessed_at: null,
|
||||
isCorrupted: false,
|
||||
archived: {},
|
||||
}))
|
||||
|
||||
return [...folders, ...files]
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getMergedArchivedVersions } from './archivedVersions.utils'
|
||||
import type { ArchivedObject } from '@/data/storage/versioning/archived-objects-query'
|
||||
|
||||
const object: ArchivedObject = {
|
||||
id: 'obj-1',
|
||||
path: 'matches/final.png',
|
||||
archivedAt: '2026-08-10T00:00:00Z',
|
||||
currentVersion: {
|
||||
versionId: 'v-current',
|
||||
size: 900,
|
||||
createdAt: '2026-08-01T00:00:00Z',
|
||||
action: 'overwrite',
|
||||
},
|
||||
noncurrentVersions: [
|
||||
{ versionId: 'v-2', size: 800, createdAt: '2026-07-20T00:00:00Z', action: 'overwrite' },
|
||||
{ versionId: 'v-1', size: 700, createdAt: '2026-07-01T00:00:00Z', action: 'initial upload' },
|
||||
],
|
||||
}
|
||||
|
||||
describe('getMergedArchivedVersions', () => {
|
||||
it('puts the version that was live at archive time first', () => {
|
||||
const [first] = getMergedArchivedVersions(object)
|
||||
expect(first.versionId).toBe('v-current')
|
||||
expect(first.wasCurrentAtArchive).toBe(true)
|
||||
})
|
||||
|
||||
it('follows it with the retained noncurrent versions, newest first', () => {
|
||||
expect(getMergedArchivedVersions(object).map((v) => v.versionId)).toEqual([
|
||||
'v-current',
|
||||
'v-2',
|
||||
'v-1',
|
||||
])
|
||||
})
|
||||
|
||||
it('flags only the first row as having been current', () => {
|
||||
const rows = getMergedArchivedVersions(object)
|
||||
expect(rows.filter((v) => v.wasCurrentAtArchive)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves each version’s own size and action rather than inventing them', () => {
|
||||
const rows = getMergedArchivedVersions(object)
|
||||
expect(rows.map((v) => v.size)).toEqual([900, 800, 700])
|
||||
expect(rows.map((v) => v.action)).toEqual(['overwrite', 'overwrite', 'initial upload'])
|
||||
})
|
||||
|
||||
it('returns just the archived version when nothing else was retained', () => {
|
||||
const rows = getMergedArchivedVersions({ ...object, noncurrentVersions: [] })
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].wasCurrentAtArchive).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import type {
|
||||
ArchivedObject,
|
||||
ArchivedObjectVersion,
|
||||
} from '@/data/storage/versioning/archived-objects-query'
|
||||
|
||||
export interface ArchivedVersionRow extends ArchivedObjectVersion {
|
||||
/** Deleting this one promotes the next version behind it, so actions differ. */
|
||||
wasCurrentAtArchive: boolean
|
||||
}
|
||||
|
||||
/** Newest first: the version live at archive time, then the rest. */
|
||||
export const getMergedArchivedVersions = (object: ArchivedObject): ArchivedVersionRow[] => [
|
||||
{ ...object.currentVersion, wasCurrentAtArchive: true },
|
||||
...object.noncurrentVersions.map((version) => ({ ...version, wasCurrentAtArchive: false })),
|
||||
]
|
||||
@@ -97,6 +97,8 @@ export const storageKeys = {
|
||||
path,
|
||||
...(versionId === undefined ? [] : [versionId]),
|
||||
] as const,
|
||||
archivedObjects: (projectRef: string | undefined, bucketId: string | undefined) =>
|
||||
['projects', projectRef, 'buckets', bucketId, 'archived-objects'] as const,
|
||||
retentionUsage: (orgSlug: string | undefined) =>
|
||||
['organizations', orgSlug, 'storage-retention-usage'] as const,
|
||||
icebergNamespaces: ({ projectRef, warehouse }: { projectRef?: string; warehouse?: string }) =>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { storageKeys } from '../keys'
|
||||
import type { ResponseError } from '@/types'
|
||||
|
||||
export type ArchivedObjectPurgeVariables = {
|
||||
projectRef: string
|
||||
bucketId: string
|
||||
archivedObjectId: string
|
||||
}
|
||||
|
||||
async function purgeArchivedObject({
|
||||
projectRef,
|
||||
bucketId,
|
||||
archivedObjectId,
|
||||
}: ArchivedObjectPurgeVariables) {
|
||||
if (!projectRef) throw new Error('projectRef is required')
|
||||
if (!bucketId) throw new Error('bucketId is required')
|
||||
if (!archivedObjectId) throw new Error('archivedObjectId is required')
|
||||
|
||||
// TODO(storage-versioning): real endpoint once Storage exposes it.
|
||||
throw new Error('Permanently deleting an archived file is not available yet')
|
||||
}
|
||||
|
||||
export const useArchivedObjectPurgeMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<void, ResponseError, ArchivedObjectPurgeVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<void, ResponseError, ArchivedObjectPurgeVariables>({
|
||||
mutationFn: purgeArchivedObject,
|
||||
async onSuccess(data, variables, context) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: storageKeys.archivedObjects(variables.projectRef, variables.bucketId),
|
||||
})
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(error, variables, context) {
|
||||
if (onError === undefined) toast.error(`Failed to delete file: ${error.message}`)
|
||||
else onError(error, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { storageKeys } from '../keys'
|
||||
import type { ResponseError } from '@/types'
|
||||
|
||||
export type ArchivedObjectRestoreVariables = {
|
||||
projectRef: string
|
||||
bucketId: string
|
||||
archivedObjectId: string
|
||||
}
|
||||
|
||||
async function restoreArchivedObject({
|
||||
projectRef,
|
||||
bucketId,
|
||||
archivedObjectId,
|
||||
}: ArchivedObjectRestoreVariables) {
|
||||
if (!projectRef) throw new Error('projectRef is required')
|
||||
if (!bucketId) throw new Error('bucketId is required')
|
||||
if (!archivedObjectId) throw new Error('archivedObjectId is required')
|
||||
|
||||
// TODO(storage-versioning): real endpoint once Storage exposes it.
|
||||
throw new Error('Restoring an archived file is not available yet')
|
||||
}
|
||||
|
||||
export const useArchivedObjectRestoreMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<void, ResponseError, ArchivedObjectRestoreVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<void, ResponseError, ArchivedObjectRestoreVariables>({
|
||||
mutationFn: restoreArchivedObject,
|
||||
async onSuccess(data, variables, context) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: storageKeys.archivedObjects(variables.projectRef, variables.bucketId),
|
||||
})
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(error, variables, context) {
|
||||
if (onError === undefined) toast.error(`Failed to restore file: ${error.message}`)
|
||||
else onError(error, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { storageKeys } from '../keys'
|
||||
import type { ResponseError } from '@/types'
|
||||
|
||||
export type ArchivedObjectVersionDeleteVariables = {
|
||||
projectRef: string
|
||||
bucketId: string
|
||||
archivedObjectId: string
|
||||
versionId: string
|
||||
/**
|
||||
* Deleting the version that was live at archive time promotes the next one
|
||||
* behind it, or removes the object when it was the last — hence the flag.
|
||||
*/
|
||||
wasCurrentAtArchive: boolean
|
||||
}
|
||||
|
||||
async function deleteArchivedObjectVersion({
|
||||
projectRef,
|
||||
bucketId,
|
||||
archivedObjectId,
|
||||
versionId,
|
||||
}: ArchivedObjectVersionDeleteVariables) {
|
||||
if (!projectRef) throw new Error('projectRef is required')
|
||||
if (!bucketId) throw new Error('bucketId is required')
|
||||
if (!archivedObjectId) throw new Error('archivedObjectId is required')
|
||||
if (!versionId) throw new Error('versionId is required')
|
||||
|
||||
// TODO(storage-versioning): real endpoint once Storage exposes it.
|
||||
throw new Error('Deleting an archived version is not available yet')
|
||||
}
|
||||
|
||||
export const useArchivedObjectVersionDeleteMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<void, ResponseError, ArchivedObjectVersionDeleteVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<void, ResponseError, ArchivedObjectVersionDeleteVariables>({
|
||||
mutationFn: deleteArchivedObjectVersion,
|
||||
async onSuccess(data, variables, context) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: storageKeys.archivedObjects(variables.projectRef, variables.bucketId),
|
||||
})
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(error, variables, context) {
|
||||
if (onError === undefined) toast.error(`Failed to delete version: ${error.message}`)
|
||||
else onError(error, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
|
||||
import { storageKeys } from '../keys'
|
||||
import type { ObjectVersionAction } from './object-versions-query'
|
||||
import { IS_PLATFORM } from '@/lib/constants'
|
||||
import type { ResponseError } from '@/types'
|
||||
|
||||
export interface ArchivedObjectVersion {
|
||||
versionId: string
|
||||
size: number
|
||||
createdAt: string
|
||||
action: ObjectVersionAction
|
||||
}
|
||||
|
||||
export interface ArchivedObject {
|
||||
id: string
|
||||
/** Full path including the file name. Only `getArchivedSegments` splits it. */
|
||||
path: string
|
||||
archivedAt: string
|
||||
/**
|
||||
* The version that was live when the object was archived. S3 puts a delete
|
||||
* marker on top of the stack; this UI hides that and surfaces the version
|
||||
* underneath as an ordinary history entry.
|
||||
*/
|
||||
currentVersion: ArchivedObjectVersion
|
||||
/** Newest first. */
|
||||
noncurrentVersions: ArchivedObjectVersion[]
|
||||
}
|
||||
|
||||
export type ArchivedObjectsVariables = {
|
||||
projectRef?: string
|
||||
bucketId?: string
|
||||
}
|
||||
|
||||
export type ArchivedObjectsError = ResponseError
|
||||
|
||||
async function getArchivedObjects(
|
||||
{ projectRef, bucketId }: ArchivedObjectsVariables,
|
||||
_signal?: AbortSignal
|
||||
): Promise<ArchivedObject[]> {
|
||||
if (!projectRef) throw new Error('projectRef is required')
|
||||
if (!bucketId) throw new Error('bucketId is required')
|
||||
|
||||
// TODO(storage-versioning): real endpoint once Storage exposes it.
|
||||
return []
|
||||
}
|
||||
|
||||
export type ArchivedObjectsData = Awaited<ReturnType<typeof getArchivedObjects>>
|
||||
|
||||
export const archivedObjectsQueryOptions = ({ projectRef, bucketId }: ArchivedObjectsVariables) =>
|
||||
queryOptions({
|
||||
queryKey: storageKeys.archivedObjects(projectRef, bucketId),
|
||||
queryFn: ({ signal }) => getArchivedObjects({ projectRef, bucketId }, signal),
|
||||
enabled: IS_PLATFORM && typeof projectRef !== 'undefined' && typeof bucketId !== 'undefined',
|
||||
})
|
||||
Reference in new issue
Block a user