Merge pull request #58611 from nextcloud/fix/files-snowflake
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Integration sqlite / changes (push) Waiting to run
Integration sqlite / integration-sqlite (master, 8.4, main, --tags ~@large files_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, capabilities_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, collaboration_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, comments_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, dav_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, federation_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, file_conversions) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, files_reminders) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, filesdrop_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, ldap_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, openldap_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, openldap_numerical_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, remoteapi_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, routing_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, setup_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, sharees_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, sharing_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, theming_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, videoverification_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite-summary (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis (push) Waiting to run
Psalm static code analysis / static-code-analysis-security (push) Waiting to run
Psalm static code analysis / static-code-analysis-ocp (push) Waiting to run
Psalm static code analysis / static-code-analysis-ncu (push) Waiting to run
Psalm static code analysis / static-code-analysis-strict (push) Waiting to run

fix(files): correctly handle nodes with snowflake ids
This commit is contained in:
Ferdinand Thiessen 2026-03-17 12:54:09 +01:00 committed by GitHub
commit 5a1c233de9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 261 additions and 202 deletions

View file

@ -3,203 +3,259 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Folder, Node } from '@nextcloud/files'
import type { FileSource, FilesState, FilesStore, RootOptions, RootsStore, Service } from '../types.ts'
import type { IFolder, INode } from '@nextcloud/files'
import type { FileSource, FilesStore, RootOptions, RootsStore, Service } from '../types.ts'
import { subscribe } from '@nextcloud/event-bus'
import { defineStore } from 'pinia'
import Vue from 'vue'
import Vue, { ref } from 'vue'
import logger from '../logger.ts'
import { fetchNode } from '../services/WebdavClient.ts'
import { usePathsStore } from './paths.ts'
/**
*
* @param args
* Store for files and folders in the files app.
*/
export function useFilesStore(...args) {
const store = defineStore('files', {
state: (): FilesState => ({
files: {} as FilesStore,
roots: {} as RootsStore,
}),
export const useFilesStore = defineStore('files', () => {
const files = ref<FilesStore>({})
const roots = ref<RootsStore>({})
getters: {
/**
* Get a file or folder by its source
*
* @param state
*/
getNode: (state) => (source: FileSource): Node | undefined => state.files[source],
// initialize the store once its used first time
initalizeStore()
/**
* Get a list of files or folders by their IDs
* Note: does not return undefined values
*
* @param state
*/
getNodes: (state) => (sources: FileSource[]): Node[] => sources
.map((source) => state.files[source])
.filter(Boolean),
/**
* Get files or folders by their file ID
* Multiple nodes can have the same file ID but different sources
* (e.g. in a shared context)
*
* @param state
*/
getNodesById: (state) => (fileId: number): Node[] => Object.values(state.files).filter((node) => node.fileid === fileId),
/**
* Get the root folder of a service
*
* @param state
*/
getRoot: (state) => (service: Service): Folder | undefined => state.roots[service],
},
actions: {
/**
* Get cached directory matching a given path
*
* @param service - The service (files view)
* @param path - The path relative within the service
* @return The folder if found
*/
getDirectoryByPath(service: string, path?: string): Folder | undefined {
const pathsStore = usePathsStore()
let folder: Folder | undefined
// Get the containing folder from path store
if (!path || path === '/') {
folder = this.getRoot(service)
} else {
const source = pathsStore.getPath(service, path)
if (source) {
folder = this.getNode(source) as Folder | undefined
}
}
return folder
},
/**
* Get cached child nodes within a given path
*
* @param service - The service (files view)
* @param path - The path relative within the service
* @return Array of cached nodes within the path
*/
getNodesByPath(service: string, path?: string): Node[] {
const folder = this.getDirectoryByPath(service, path)
// If we found a cache entry and the cache entry was already loaded (has children) then use it
return (folder?._children ?? [])
.map((source: string) => this.getNode(source))
.filter(Boolean)
},
updateNodes(nodes: Node[]) {
// Update the store all at once
const files = nodes.reduce((acc, node) => {
if (!node.fileid) {
logger.error('Trying to update/set a node without fileid', { node })
return acc
}
acc[node.source] = node
return acc
}, {} as FilesStore)
Vue.set(this, 'files', { ...this.files, ...files })
},
deleteNodes(nodes: Node[]) {
nodes.forEach((node) => {
if (node.source) {
Vue.delete(this.files, node.source)
}
})
},
setRoot({ service, root }: RootOptions) {
Vue.set(this.roots, service, root)
},
onDeletedNode(node: Node) {
this.deleteNodes([node])
},
onCreatedNode(node: Node) {
this.updateNodes([node])
},
onMovedNode({ node, oldSource }: { node: Node, oldSource: string }) {
if (!node.fileid) {
logger.error('Trying to update/set a node without fileid', { node })
return
}
// Update the path of the node
Vue.delete(this.files, oldSource)
this.updateNodes([node])
},
async onUpdatedNode(node: Node) {
if (!node.fileid) {
logger.error('Trying to update/set a node without fileid', { node })
return
}
// If we have multiple nodes with the same file ID, we need to update all of them
const nodes = this.getNodesById(node.fileid)
if (nodes.length > 1) {
await Promise.all(nodes.map((node) => fetchNode(node.path))).then(this.updateNodes)
logger.debug(nodes.length + ' nodes updated in store', { fileid: node.fileid })
return
}
// If we have only one node with the file ID, we can update it directly
if (nodes.length === 1 && node.source === nodes[0].source) {
this.updateNodes([node])
return
}
// Otherwise, it means we receive an event for a node that is not in the store
fetchNode(node.path).then((n) => this.updateNodes([n]))
},
// Handlers for legacy sidebar (no real nodes support)
onAddFavorite(node: Node) {
const ourNode = this.getNode(node.source)
if (ourNode) {
Vue.set(ourNode.attributes, 'favorite', 1)
}
},
onRemoveFavorite(node: Node) {
const ourNode = this.getNode(node.source)
if (ourNode) {
Vue.set(ourNode.attributes, 'favorite', 0)
}
},
},
})
const fileStore = store(...args)
// Make sure we only register the listeners once
if (!fileStore._initialized) {
subscribe('files:node:created', fileStore.onCreatedNode)
subscribe('files:node:deleted', fileStore.onDeletedNode)
subscribe('files:node:updated', fileStore.onUpdatedNode)
subscribe('files:node:moved', fileStore.onMovedNode)
// legacy sidebar
subscribe('files:favorites:added', fileStore.onAddFavorite)
subscribe('files:favorites:removed', fileStore.onRemoveFavorite)
fileStore._initialized = true
/**
* Get a file or folder by its source
*
* @param source - The file source
*/
function getNode(source: FileSource): INode | undefined {
return files.value[source]
}
return fileStore
}
/**
* Get a list of files or folders by their IDs
* Note: does not return undefined values
*
* @param sources - The file sources
*/
function getNodes(sources: FileSource[]): INode[] {
return sources
.map((source) => files.value[source])
.filter(Boolean) as INode[]
}
/**
* Get files or folders by their ID
* Multiple nodes can have the same ID but different sources
* (e.g. in a shared context)
*
* @param id - The file ID
*/
function getNodesById(id: string): INode[] {
return Object.values(files.value)
.filter((node) => node.id === id)
}
/**
* Get the root folder of a service
*
* @param service - The service (files view)
* @return The root folder if set
*/
function getRoot(service: Service): IFolder | undefined {
return roots.value[service]
}
/**
* Get cached directory matching a given path
*
* @param service - The service (files view)
* @param path - The path relative within the service
* @return The folder if found
*/
function getDirectoryByPath(service: string, path?: string): IFolder | undefined {
const pathsStore = usePathsStore()
let folder: IFolder | undefined
// Get the containing folder from path store
if (!path || path === '/') {
folder = getRoot(service)
} else {
const source = pathsStore.getPath(service, path)
if (source) {
folder = getNode(source) as IFolder | undefined
}
}
return folder
}
/**
* Get cached child nodes within a given path
*
* @param service - The service (files view)
* @param path - The path relative within the service
* @return Array of cached nodes within the path
*/
function getNodesByPath(service: string, path?: string): INode[] {
const folder = getDirectoryByPath(service, path)
// If we found a cache entry and the cache entry was already loaded (has children) then use it
return ((folder as { _children?: string[] })?._children ?? [])
.map((source: string) => getNode(source))
.filter(Boolean) as INode[]
}
/**
* Update or set nodes in the store
*
* @param nodes - The nodes to update or set
*/
function updateNodes(nodes: INode[]) {
// Update the store all at once
const newNodes = nodes.reduce((acc, node) => {
if (files.value[node.source]?.id && !node.id) {
logger.error('Trying to update/set a node without id', { node })
return acc
}
acc[node.source] = node
return acc
}, {} as FilesStore)
files.value = { ...files.value, ...newNodes }
}
/**
* Delete nodes from the store
*
* @param nodes - The nodes to delete
*/
function deleteNodes(nodes: INode[]) {
const entries = Object.entries(files.value)
.filter(([, node]) => !nodes.some((n) => n.source === node.source))
files.value = Object.fromEntries(entries)
}
/**
* Set the root folder for a service
*
* @param options - The options for setting the root
* @param options.service - The service (files view)
* @param options.root - The root folder
*/
function setRoot({ service, root }: RootOptions) {
roots.value = { ...roots.value, [service]: root }
}
return {
files,
roots,
deleteNodes,
getDirectoryByPath,
getNode,
getNodes,
getNodesById,
getNodesByPath,
getRoot,
setRoot,
updateNodes,
}
// Internal helper functions
/**
* Initialize the store by subscribing to events
*/
function initalizeStore() {
subscribe('files:node:created', onCreatedNode)
subscribe('files:node:deleted', onDeletedNode)
subscribe('files:node:updated', onUpdatedNode)
subscribe('files:node:moved', onMovedNode)
// legacy sidebar
subscribe('files:favorites:added', onAddFavorite)
subscribe('files:favorites:removed', onRemoveFavorite)
}
/**
* Called when a node is deleted, removes the node from the store
*
* @param node - The deleted node
*/
function onDeletedNode(node: INode) {
deleteNodes([node])
}
/**
* Handler for when a node is created
*
* @param node - The created node
*/
function onCreatedNode(node: INode) {
updateNodes([node])
}
/**
* Handler for when a node is moved, updates the path of the node in the store
*
* @param context - The context of the moved node
* @param context.node - The moved node
* @param context.oldSource - The old source of the node before it was moved
*/
function onMovedNode({ node, oldSource }: { node: INode, oldSource: string }) {
// Update the path of the node
delete files.value[oldSource]
updateNodes([node])
}
/**
* Handler for when a node is updated, updates the node in the store
*
* @param node - The updated node
*/
async function onUpdatedNode(node: INode) {
// If we have multiple nodes with the same file ID, we need to update all of them
const nodes = node.id
? getNodesById(node.id)
: getNodes([node.source])
if (nodes.length > 1) {
await Promise.all(nodes.map((node) => fetchNode(node.path))).then(updateNodes)
logger.debug(nodes.length + ' nodes updated in store', { fileid: node.id, source: node.source })
return
}
// If we have only one node with the file ID, we can update it directly
if (nodes.length === 1 && node.source === nodes[0]!.source) {
updateNodes([node])
return
}
// Otherwise, it means we receive an event for a node that is not in the store
fetchNode(node.path).then((n) => updateNodes([n]))
}
/**
* Handlers for legacy sidebar (no real nodes support)
*
* @param node - The node that was added to favorites
*/
function onAddFavorite(node: INode) {
const ourNode = getNode(node.source)
if (ourNode) {
Vue.set(ourNode.attributes, 'favorite', 1)
}
}
/**
* Handler for when a node is removed from favorites
*
* @param node - The removed favorite
*/
function onRemoveFavorite(node: INode) {
const ourNode = getNode(node.source)
if (ourNode) {
Vue.set(ourNode.attributes, 'favorite', 0)
}
}
})

View file

@ -11,14 +11,12 @@ import { AuthBackend, createStorageWithConfig, StorageBackend } from './StorageU
describe('Files user credentials', { testIsolation: true }, () => {
let currentUser: User
beforeEach(() => {
})
before(() => {
cy.runOccCommand('app:enable files_external')
cy.createRandomUser().then((user) => {
currentUser = user
})
cy.runCommand('php ./cron.php')
})
afterEach(() => {
@ -42,6 +40,11 @@ describe('Files user credentials', { testIsolation: true }, () => {
cy.login(currentUser)
cy.visit('/apps/files')
// TODO: Why does the first PROPFIND does not return it?
getRowForFile('Storage1')
.if('not.exist')
.reload()
// Ensure the row is visible and marked as unavailable
getRowForFile('Storage1').as('row').should('be.visible')
cy.get('@row').find('[data-cy-files-list-row-name-link]')

4
dist/files-init.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/files-main.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long