@ossy/resources 1.12.2 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/package.json +16 -4
  2. package/src/AudioResource.jsx +2 -2
  3. package/src/CreateDirectory.jsx +2 -10
  4. package/src/CreateDirectory.page.jsx +1 -1
  5. package/src/CreateDocument.jsx +2 -2
  6. package/src/CreateDocument.page.jsx +1 -1
  7. package/src/Definition.js +3 -2
  8. package/src/DocumentEdit.jsx +10 -38
  9. package/src/DocumentView.jsx +11 -22
  10. package/src/GenericResourceCard.jsx +25 -7
  11. package/src/GenericResourceDetail.jsx +6 -60
  12. package/src/GenericResourceForm.jsx +171 -37
  13. package/src/ImageResource.jsx +1 -1
  14. package/src/PDFResource.jsx +1 -1
  15. package/src/PlatformFileField.jsx +239 -0
  16. package/src/ResourceContentPage.jsx +5 -5
  17. package/src/ResourceDescription.jsx +1 -1
  18. package/src/ResourceDetails.jsx +3 -4
  19. package/src/ResourceDialogMove.jsx +3 -3
  20. package/src/ResourceFactory.jsx +11 -64
  21. package/src/ResourceGenericView.jsx +1 -1
  22. package/src/ResourceList.jsx +89 -106
  23. package/src/ResourcePanel.jsx +40 -57
  24. package/src/ResourceTags.jsx +1 -1
  25. package/src/ResourcesPage.jsx +26 -35
  26. package/src/SchemaDetailView.jsx +97 -0
  27. package/src/SchemaFormDefault.jsx +86 -0
  28. package/src/SchemaPresenter.jsx +207 -0
  29. package/src/Upload.jsx +6 -25
  30. package/src/Upload.page.jsx +1 -1
  31. package/src/UploadResources.jsx +2 -2
  32. package/src/VideoResource.jsx +1 -1
  33. package/src/access-filter.spec.js +1 -1
  34. package/src/create-page-view.action.js +1 -1
  35. package/src/create-page-view.task.js +10 -9
  36. package/src/create.action.js +1 -1
  37. package/src/create.task.js +27 -29
  38. package/src/delete.action.js +1 -1
  39. package/src/delete.task.js +9 -13
  40. package/src/en.translations.json +25 -24
  41. package/src/get-page-view-stats.action.js +1 -1
  42. package/src/get-page-view-stats.task.js +3 -4
  43. package/src/get-resource-alias.api.js +8 -0
  44. package/src/get-resource-info-alias.api.js +8 -0
  45. package/src/get-resource-info.api.js +36 -0
  46. package/src/get-resource-variant.api.js +8 -0
  47. package/src/get-resource.api.js +51 -0
  48. package/src/get.action.js +1 -1
  49. package/src/get.task.js +6 -6
  50. package/src/index.js +10 -0
  51. package/src/list.action.js +1 -1
  52. package/src/list.task.js +1 -1
  53. package/src/platform-file-field.component.jsx +5 -0
  54. package/src/platform-image-field.component.jsx +5 -0
  55. package/src/resource-create.page.jsx +5 -5
  56. package/src/resource-detail.page.jsx +1 -1
  57. package/src/resource-read.helpers.js +151 -0
  58. package/src/resource-stream.helpers.js +20 -0
  59. package/src/resource-stream.js +108 -0
  60. package/src/resource.helpers.js +9 -9
  61. package/src/resources.attach-media-urls.js +9 -3
  62. package/src/resources.events.js +42 -81
  63. package/src/{resources.spec.js → resources.integration.spec.js} +19 -19
  64. package/src/resources.queries.js +5 -2
  65. package/src/schema-detail.component.jsx +24 -0
  66. package/src/schema-form-default.component.jsx +14 -0
  67. package/src/schemaFormSlots.js +6 -0
  68. package/src/schemaViewSlots.js +6 -0
  69. package/src/search.action.js +1 -1
  70. package/src/search.task.js +1 -1
  71. package/src/server.js +2 -1
  72. package/src/sv.translations.json +25 -24
  73. package/src/update-access.action.js +1 -1
  74. package/src/update-access.task.js +9 -8
  75. package/src/update-content.action.js +1 -1
  76. package/src/update-content.task.js +25 -27
  77. package/src/update-location.action.js +1 -1
  78. package/src/update-location.task.js +9 -8
  79. package/src/update-name.action.js +1 -1
  80. package/src/update-name.task.js +9 -8
  81. package/src/upload-named-version.action.js +1 -1
  82. package/src/upload-named-version.task.js +16 -22
  83. package/src/useActivePath.jsx +3 -8
  84. package/src/useDocumentValidator.js +16 -0
  85. package/src/useSchemaEngine.js +9 -0
  86. package/src/useSchemas.js +29 -0
  87. package/src/useSelectedResourceId.jsx +36 -0
  88. package/src/ResourceContentPage.stories.jsx +0 -19
  89. package/src/resource.aggregate.js +0 -86
@@ -7,30 +7,31 @@
7
7
  "resources.form.selectType": "Select a resource type to continue.",
8
8
  "resources.form.createFailed": "Create failed",
9
9
  "resources.form.createResource": "Create resource",
10
- "resources/create.label": "Create resource",
11
- "resources/create.description": "Create a new resource in the workspace",
12
- "resources/list.label": "List resources",
13
- "resources/list.description": "List resources in a workspace location",
14
- "resources/get.label": "Get resource",
15
- "resources/get.description": "Fetch a single resource by id",
16
- "resources/search.label": "Search resources",
17
- "resources/search.description": "Search resources in a workspace",
18
- "resources/delete.label": "Delete resource",
19
- "resources/delete.description": "Delete a resource from the workspace",
20
- "resources/update-name.label": "Update resource name",
21
- "resources/update-name.description": "Rename a workspace resource",
22
- "resources/update-content.label": "Update resource content",
23
- "resources/update-content.description": "Update the content of a workspace resource",
24
- "resources/update-location.label": "Update resource location",
25
- "resources/update-location.description": "Move a resource to a new location",
26
- "resources/update-access.label": "Update resource access",
27
- "resources/update-access.description": "Change access settings for a resource",
28
- "resources/upload-named-version.label": "Upload named version",
29
- "resources/upload-named-version.description": "Upload a named version of a resource file",
30
- "resources/create-page-view.label": "Create page view",
31
- "resources/create-page-view.description": "Record a page view analytics event",
32
- "resources/get-page-view-stats.label": "Get page view stats",
33
- "resources/get-page-view-stats.description": "Fetch aggregated page view statistics",
10
+ "resources.form.typeRemoved": "This document type has been removed and can't be edited.",
11
+ "@ossy/resources/actions/create.label": "Create resource",
12
+ "@ossy/resources/actions/create.description": "Create a new resource in the workspace",
13
+ "@ossy/resources/actions/list.label": "List resources",
14
+ "@ossy/resources/actions/list.description": "List resources in a workspace location",
15
+ "@ossy/resources/actions/get.label": "Get resource",
16
+ "@ossy/resources/actions/get.description": "Fetch a single resource by id",
17
+ "@ossy/resources/actions/search.label": "Search resources",
18
+ "@ossy/resources/actions/search.description": "Search resources in a workspace",
19
+ "@ossy/resources/actions/delete.label": "Delete resource",
20
+ "@ossy/resources/actions/delete.description": "Delete a resource from the workspace",
21
+ "@ossy/resources/actions/update-name.label": "Update resource name",
22
+ "@ossy/resources/actions/update-name.description": "Rename a workspace resource",
23
+ "@ossy/resources/actions/update-content.label": "Update resource content",
24
+ "@ossy/resources/actions/update-content.description": "Update the content of a workspace resource",
25
+ "@ossy/resources/actions/update-location.label": "Update resource location",
26
+ "@ossy/resources/actions/update-location.description": "Move a resource to a new location",
27
+ "@ossy/resources/actions/update-access.label": "Update resource access",
28
+ "@ossy/resources/actions/update-access.description": "Change access settings for a resource",
29
+ "@ossy/resources/actions/upload-named-version.label": "Upload named version",
30
+ "@ossy/resources/actions/upload-named-version.description": "Upload a named version of a resource file",
31
+ "@ossy/resources/actions/create-page-view.label": "Create page view",
32
+ "@ossy/resources/actions/create-page-view.description": "Record a page view analytics event",
33
+ "@ossy/resources/actions/get-page-view-stats.label": "Get page view stats",
34
+ "@ossy/resources/actions/get-page-view-stats.description": "Fetch aggregated page view statistics",
34
35
  "resources/create/generic.documentTitle": "Create resource",
35
36
  "create-directory.documentTitle": "Create directory",
36
37
  "create-document.documentTitle": "Create document",
@@ -1 +1 @@
1
- export const metadata = { id: 'resources/get-page-view-stats', access: 'workspace' }
1
+ export const metadata = { id: '@ossy/resources/actions/get-page-view-stats', access: 'workspace' }
@@ -1,9 +1,9 @@
1
1
  import { Aggregate } from '@ossy/event-store'
2
2
  import { Workspace } from '@ossy/workspaces/server'
3
3
 
4
- export const metadata = { id: 'resources/get-page-view-stats' }
4
+ export const metadata = { id: '@ossy/resources/tasks/get-page-view-stats' }
5
5
 
6
- const PAGE_VIEW_TEMPLATE_ID = '@ossy/web/page-view'
6
+ const PAGE_VIEW_SCHEMA_ID = '@ossy/web/schema/page-view'
7
7
  const PAGE_VIEW_LOCATION = '/@ossy/analytics/page-views/'
8
8
 
9
9
  function parseMillis(input, fallback) {
@@ -26,10 +26,9 @@ export async function run({ payload, req }) {
26
26
  const topLimit = Math.min(Math.max(Number(payload?.limit ?? req?.query?.limit ?? 10), 1), 50)
27
27
 
28
28
  const baseMatch = {
29
- type: 'Resource',
29
+ type: PAGE_VIEW_SCHEMA_ID,
30
30
  'state.status': { $ne: 'removed' },
31
31
  'state.belongsTo': workspace.id,
32
- 'state.type': PAGE_VIEW_TEMPLATE_ID,
33
32
  'state.location': PAGE_VIEW_LOCATION,
34
33
  'state.content.eventAt': { $gte: from, $lte: to },
35
34
  }
@@ -0,0 +1,8 @@
1
+ import handle from './get-resource.api.js'
2
+
3
+ export const metadata = {
4
+ id: 'resources/read-alias',
5
+ path: '/resources/:resourceId',
6
+ }
7
+
8
+ export { handle as default }
@@ -0,0 +1,8 @@
1
+ import handle from './get-resource-info.api.js'
2
+
3
+ export const metadata = {
4
+ id: 'resources/read-info-alias',
5
+ path: '/resources/:resourceId/info',
6
+ }
7
+
8
+ export { handle as default }
@@ -0,0 +1,36 @@
1
+ import {
2
+ loadResourceForRead,
3
+ parseResourceIdFromPath,
4
+ resourceInfoPayload,
5
+ } from './resource-read.helpers.js'
6
+
7
+ export const metadata = {
8
+ id: 'resources/read-info',
9
+ path: '/r/:resourceId/info',
10
+ }
11
+
12
+ async function handleInfo(req, res) {
13
+ const pathname = req.path || req.url?.split('?')[0] || '/'
14
+ const resourceId = parseResourceIdFromPath(pathname, 'info')
15
+ if (!resourceId) {
16
+ res.status(400).json({ error: 'Invalid resource path' })
17
+ return
18
+ }
19
+
20
+ try {
21
+ const resource = await loadResourceForRead(resourceId, req)
22
+ res.status(200).json(resourceInfoPayload(resource))
23
+ } catch (err) {
24
+ res.status(err?.status ?? 500).json({ error: err?.message ?? 'Internal error' })
25
+ }
26
+ }
27
+
28
+ export default async function handle(req, res) {
29
+ if (req.method !== 'GET') {
30
+ res.setHeader('Allow', 'GET')
31
+ res.status(405).json({ error: 'Method Not Allowed' })
32
+ return
33
+ }
34
+
35
+ await handleInfo(req, res)
36
+ }
@@ -0,0 +1,8 @@
1
+ import handle from './get-resource.api.js'
2
+
3
+ export { handle as default }
4
+
5
+ export const metadata = {
6
+ id: 'resources/read-variant',
7
+ path: '/r/:resourceId/:variant',
8
+ }
@@ -0,0 +1,51 @@
1
+ import {
2
+ loadResourceForRead,
3
+ parseResourceReadTarget,
4
+ readResourceFileBytes,
5
+ } from './resource-read.helpers.js'
6
+
7
+ export const metadata = {
8
+ id: 'resources/read',
9
+ path: '/r/:resourceId',
10
+ }
11
+
12
+ async function handleRead(req, res) {
13
+ const pathname = req.path || req.url?.split('?')[0] || '/'
14
+ const target = parseResourceReadTarget(pathname)
15
+ if (!target?.resourceId) {
16
+ res.status(400).json({ error: 'Invalid resource path' })
17
+ return
18
+ }
19
+
20
+ try {
21
+ const resource = await loadResourceForRead(target.resourceId, req)
22
+ const filePayload = await readResourceFileBytes(resource, { variant: target.variant })
23
+
24
+ if (filePayload?.buffer) {
25
+ if (filePayload.contentType) {
26
+ res.setHeader('Content-Type', filePayload.contentType)
27
+ }
28
+ res.status(200).send(filePayload.buffer)
29
+ return
30
+ }
31
+
32
+ if (resource.content && typeof resource.content === 'object' && !resource.content.Key) {
33
+ res.status(200).json(resource.content)
34
+ return
35
+ }
36
+
37
+ res.status(404).json({ error: 'Resource body not found' })
38
+ } catch (err) {
39
+ res.status(err?.status ?? 500).json({ error: err?.message ?? 'Internal error' })
40
+ }
41
+ }
42
+
43
+ export default async function handle(req, res) {
44
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
45
+ res.setHeader('Allow', 'GET, HEAD')
46
+ res.status(405).json({ error: 'Method Not Allowed' })
47
+ return
48
+ }
49
+
50
+ await handleRead(req, res)
51
+ }
package/src/get.action.js CHANGED
@@ -1 +1 @@
1
- export const metadata = { id: 'resources/get', access: 'workspace' }
1
+ export const metadata = { id: '@ossy/resources/actions/get', access: 'workspace' }
package/src/get.task.js CHANGED
@@ -1,14 +1,14 @@
1
- import { Aggregate } from '@ossy/event-store'
2
- import { Resource } from './resource.aggregate.js'
1
+ import { viewResource } from './resource-stream.helpers.js'
3
2
  import { mediaContextFromRun, withResourceMedia } from './resources.action-media.js'
3
+ import { resolveResourceId } from './resource-read.helpers.js'
4
4
 
5
- export const metadata = { id: 'resources/get' }
5
+ export const metadata = { id: '@ossy/resources/tasks/get' }
6
6
 
7
- export async function run({ payload, req }) {
8
- const resourceId = payload?.resourceId ?? req?.params?.resourceId
7
+ export async function run ({ payload, req }) {
8
+ const resourceId = resolveResourceId({ payload, req })
9
9
  if (!resourceId) throw Object.assign(new Error('resourceId is required'), { status: 400 })
10
10
 
11
- const resource = await Aggregate.Of(Resource, resourceId).then(Aggregate.View())
11
+ const resource = await viewResource(resourceId)
12
12
  if (!resource?.id) throw Object.assign(new Error('Resource not found'), { status: 404 })
13
13
  return withResourceMedia(resource, mediaContextFromRun({ payload, req }))
14
14
  }
package/src/index.js CHANGED
@@ -32,9 +32,19 @@ export * from './ResourcePage.jsx'
32
32
  export * from './ResourcePanel.jsx'
33
33
  export * from './ResourceTags.jsx'
34
34
  export * from './ResourcesPage.jsx'
35
+ export * from './PlatformFileField.jsx'
35
36
  export * from './Upload.jsx'
36
37
  export * from './UploadResources.jsx'
37
38
  export * from './VideoResource.jsx'
38
39
  export * from './useActivePath.jsx'
40
+ export * from './useSelectedResourceId.jsx'
41
+ export * from './SchemaPresenter.jsx'
42
+ export * from './SchemaDetailView.jsx'
43
+ export * from './SchemaFormDefault.jsx'
44
+ export { DEFAULT_SCHEMA_VIEW_SLOTS } from './schemaViewSlots.js'
45
+ export { DEFAULT_SCHEMA_FORM_SLOTS } from './schemaFormSlots.js'
46
+ export * from './useSchemas.js'
47
+ export * from './useSchemaEngine.js'
48
+ export * from './useDocumentValidator.js'
39
49
  export * from './useForm.js'
40
50
  export * from './resource.helpers.js'
@@ -1 +1 @@
1
- export const metadata = { id: 'resources/list', access: 'workspace' }
1
+ export const metadata = { id: '@ossy/resources/actions/list', access: 'workspace' }
package/src/list.task.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { ResourcesQueries } from './resources.queries.js'
2
2
  import { mediaContextFromRun, withResourceMedia } from './resources.action-media.js'
3
3
 
4
- export const metadata = { id: 'resources/list' }
4
+ export const metadata = { id: '@ossy/resources/tasks/list' }
5
5
 
6
6
  export async function run({ payload, req }) {
7
7
  const query = { ...(payload?.query ?? {}) }
@@ -0,0 +1,5 @@
1
+ import { PlatformFileField } from './PlatformFileField.jsx'
2
+
3
+ export const metadata = { id: '@ossy/design-system/input/file' }
4
+
5
+ export default PlatformFileField
@@ -0,0 +1,5 @@
1
+ import { PlatformFileField } from './PlatformFileField.jsx'
2
+
3
+ export const metadata = { id: '@ossy/design-system/input/image' }
4
+
5
+ export default PlatformFileField
@@ -8,8 +8,8 @@ import { GenericResourceForm } from './GenericResourceForm.jsx'
8
8
  export const metadata = {
9
9
  id: 'resources/create/generic',
10
10
  path: {
11
- en: '/resources/create/:templateId',
12
- sv: '/resources/skapa/:templateId',
11
+ en: '/resources/create/:schemaId',
12
+ sv: '/resources/skapa/:schemaId',
13
13
  },
14
14
  }
15
15
 
@@ -17,7 +17,7 @@ export const metadata = {
17
17
  export default function GenericResourceCreatePage () {
18
18
  const router = useRouter()
19
19
  const { t } = useLocale()
20
- const templateId = decodeURIComponent(router.params.templateId || '')
20
+ const schemaId = decodeURIComponent(router.params.schemaId || '')
21
21
  const location = router.searchParams.location
22
22
  const sdk = useSdk()
23
23
  const [error, setError] = useState()
@@ -35,9 +35,9 @@ export default function GenericResourceCreatePage () {
35
35
 
36
36
  return (
37
37
  <View layout="off-center-m" style={{ height: '100%' }}>
38
- <View slot="content" surface="primary" roundness="m" inset="m">
38
+ <View data-region="content" surface="primary" roundness="m" inset="m">
39
39
  <GenericResourceForm
40
- templateId={templateId}
40
+ schemaId={schemaId}
41
41
  mode="create"
42
42
  onSubmit={onSubmit}
43
43
  onCancel={onCancel}
@@ -17,7 +17,7 @@ export default function ResourceDetailPage () {
17
17
 
18
18
  return (
19
19
  <View layout="off-center-m" style={{ height: '100%' }}>
20
- <View slot="content" surface="primary" roundness="m" inset="m">
20
+ <View data-region="content" surface="primary" roundness="m" inset="m">
21
21
  <GenericResourceDetail resourceId={resourceId} />
22
22
  </View>
23
23
  </View>
@@ -0,0 +1,151 @@
1
+ import { Aggregate } from '@ossy/event-store'
2
+ import { StorageClient } from '@ossy/platform'
3
+ import { derivativeObjectKey } from '@ossy/platform/storage-keys'
4
+ import { Workspace } from '@ossy/workspaces/server'
5
+ import { attachResourceMediaUrls } from './resources.attach-media-urls.js'
6
+ import { viewResource } from './resource-stream.helpers.js'
7
+
8
+ export function resolveResourceId({ payload, req } = {}) {
9
+ return payload?.resourceId ?? payload?.id ?? req?.params?.resourceId ?? req?.params?.id
10
+ }
11
+
12
+ /**
13
+ * Parse resource read target from `/r/{id}` or `/r/{id}/{variant}`.
14
+ *
15
+ * @param {string} pathname
16
+ * @returns {{ resourceId: string, variant: string | null } | null}
17
+ */
18
+ export function parseResourceReadTarget(pathname) {
19
+ const parts = pathname.split('/').filter(Boolean)
20
+ if (parts.length < 2) return null
21
+ if (parts[0] !== 'r' && parts[0] !== 'resources') return null
22
+ if (parts.length === 2) return { resourceId: parts[1], variant: null }
23
+ if (parts.length === 3 && parts[2] !== 'info') {
24
+ return { resourceId: parts[1], variant: parts[2] }
25
+ }
26
+ return null
27
+ }
28
+
29
+ /**
30
+ * Parse `resourceId` from a resource read URL path.
31
+ *
32
+ * @param {string} pathname
33
+ * @param {'content' | 'info'} mode
34
+ * @returns {string | null}
35
+ */
36
+ export function parseResourceIdFromPath(pathname, mode = 'content') {
37
+ if (mode === 'info') {
38
+ const parts = pathname.split('/').filter(Boolean)
39
+ if (parts.length === 3 && (parts[0] === 'r' || parts[0] === 'resources') && parts[2] === 'info') {
40
+ return parts[1]
41
+ }
42
+ return null
43
+ }
44
+
45
+ return parseResourceReadTarget(pathname)?.resourceId ?? null
46
+ }
47
+
48
+ async function assertResourceAccess(resource, req) {
49
+ const access = resource.access || 'restricted'
50
+ if (access === 'public') return
51
+
52
+ const userId = req?.userId
53
+ const workspaceId = req?.workspaceId
54
+
55
+ if (!workspaceId) {
56
+ throw Object.assign(new Error('Forbidden'), { status: 403 })
57
+ }
58
+
59
+ if (resource.belongsTo !== workspaceId) {
60
+ throw Object.assign(new Error('Forbidden'), { status: 403 })
61
+ }
62
+
63
+ if (!userId || userId === 'anonymous') {
64
+ throw Object.assign(new Error('Unauthorized'), { status: 401 })
65
+ }
66
+
67
+ const workspace = await Aggregate.Of(Workspace, workspaceId).then(Aggregate.View())
68
+ if (!workspace?.users?.includes(userId)) {
69
+ throw Object.assign(new Error('Forbidden'), { status: 403 })
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Load resource view with media URLs (same auth as get action).
75
+ *
76
+ * @param {string} resourceId
77
+ * @param {import('express').Request} req
78
+ */
79
+ export async function loadResourceForRead(resourceId, req) {
80
+ if (!resourceId) {
81
+ throw Object.assign(new Error('resourceId is required'), { status: 400 })
82
+ }
83
+
84
+ const resource = await viewResource(resourceId)
85
+ if (!resource?.id) {
86
+ throw Object.assign(new Error('Resource not found'), { status: 404 })
87
+ }
88
+
89
+ await assertResourceAccess(resource, req)
90
+
91
+ return attachResourceMediaUrls(resource, {
92
+ userId: req?.userId,
93
+ workspaceId: req?.workspaceId,
94
+ })
95
+ }
96
+
97
+ /**
98
+ * @param {object} resource
99
+ * @returns {Promise<{ buffer: Buffer, contentType?: string } | null>}
100
+ */
101
+ export async function readResourceFileBytes(resource, { variant } = {}) {
102
+ let key = resource?.content?.Key
103
+ if (variant) {
104
+ key = derivativeObjectKey(resource.id, variant)
105
+ }
106
+ if (!key) return null
107
+
108
+ if (typeof StorageClient.load === 'function') {
109
+ const { buffer, exists } = await StorageClient.load(key)
110
+ if (!exists || !buffer) return null
111
+ return {
112
+ buffer,
113
+ contentType: resource.content?.ContentType,
114
+ }
115
+ }
116
+
117
+ const url = resource.content?.src
118
+ if (!url) return null
119
+ const response = await fetch(url)
120
+ if (!response.ok) return null
121
+ const arrayBuffer = await response.arrayBuffer()
122
+ return {
123
+ buffer: Buffer.from(arrayBuffer),
124
+ contentType: resource.content?.ContentType || response.headers.get('content-type') || undefined,
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Metadata-only shape for `/info` responses (no streamed file body).
130
+ *
131
+ * @param {object} resource
132
+ */
133
+ export function resourceInfoPayload(resource) {
134
+ const { content, ...rest } = resource
135
+ const infoContent = content ? { ...content } : {}
136
+ delete infoContent.Key
137
+ delete infoContent.src
138
+ if (infoContent.sizes) {
139
+ infoContent.sizes = Object.fromEntries(
140
+ Object.keys(infoContent.sizes).map(name => [name, { configured: true }]),
141
+ )
142
+ }
143
+ return {
144
+ id: rest.id,
145
+ name: rest.name,
146
+ type: rest.type,
147
+ access: rest.access,
148
+ location: rest.location,
149
+ content: infoContent,
150
+ }
151
+ }
@@ -0,0 +1,20 @@
1
+ import { ResourceStream } from './resource-stream.js'
2
+
3
+ export async function viewResource (resourceId) {
4
+ const stream = await ResourceStream.Of(resourceId)
5
+ return ResourceStream.View()(stream)
6
+ }
7
+
8
+ export async function commitResource (event) {
9
+ const stream = await ResourceStream.Of(event)
10
+ await ResourceStream.Add(event)(stream)
11
+ await ResourceStream.Save()(stream)
12
+ return ResourceStream.View()(stream)
13
+ }
14
+
15
+ export async function mutateResource (resourceId, event) {
16
+ const stream = await ResourceStream.Of(resourceId)
17
+ await ResourceStream.Add(event)(stream)
18
+ await ResourceStream.Save()(stream)
19
+ return ResourceStream.View()(stream)
20
+ }
@@ -0,0 +1,108 @@
1
+ import { nanoid } from 'nanoid'
2
+ import { EventStore, Mongo, withMongoReconnect } from '@ossy/event-store'
3
+ import { createDefaultReducer } from '@ossy/fold'
4
+ import { createLogger } from '@ossy/observability'
5
+
6
+ const log = createLogger('@ossy/resources')
7
+
8
+ const reduce = createDefaultReducer()
9
+
10
+ /**
11
+ * ADR 0008 resource stream — keyed by `resourceId`, events use `{ type, event, version, payload }`.
12
+ */
13
+ export class ResourceStream {
14
+
15
+ static get Collection () {
16
+ return Mongo.db.collection('aggregates')
17
+ }
18
+
19
+ /**
20
+ * @param {string | object} identifier resourceId or a Created event partial
21
+ */
22
+ static Of (identifier) {
23
+ if (typeof identifier === 'object' && identifier !== null) {
24
+ const resourceId = identifier.resourceId ?? nanoid()
25
+ return Promise.resolve(new ResourceStream(resourceId, [], null))
26
+ }
27
+
28
+ return ResourceStream.Find(identifier)
29
+ .then(snapshot =>
30
+ EventStore.GetResourceStream({ resourceId: identifier, fromVersion: snapshot?.version ?? 0 })
31
+ .then(events => new ResourceStream(identifier, events, snapshot)),
32
+ )
33
+ }
34
+
35
+ static Find (resourceId) {
36
+ return withMongoReconnect(() => ResourceStream.Collection.findOne({ id: resourceId }))
37
+ }
38
+
39
+ /**
40
+ * @param {object} event partial resource event (without id, created, version)
41
+ */
42
+ static Add (event) {
43
+ return stream => {
44
+ const version = stream.version + 1
45
+ const saved = {
46
+ ...event,
47
+ type: event.type ?? stream.events[0]?.type ?? stream.state?.type,
48
+ id: nanoid(),
49
+ created: Date.now(),
50
+ resourceId: stream.id,
51
+ version,
52
+ }
53
+ return EventStore.AppendResourceEvent(saved)
54
+ .then(() => {
55
+ stream.version = version
56
+ stream.events = [...stream.events, saved]
57
+ return stream
58
+ })
59
+ }
60
+ }
61
+
62
+ static Save () {
63
+ return stream => {
64
+ if (!stream.events?.length) return Promise.resolve()
65
+
66
+ const latestVersion = stream.events[stream.events.length - 1]?.version ?? stream.version
67
+ const state = reduce(stream.events, stream.state)
68
+ const schemaId = state.type ?? stream.events[0]?.type
69
+
70
+ return withMongoReconnect(() =>
71
+ ResourceStream.Collection.updateOne(
72
+ { id: stream.id },
73
+ {
74
+ $set: {
75
+ id: stream.id,
76
+ version: latestVersion,
77
+ type: schemaId,
78
+ state,
79
+ },
80
+ },
81
+ { upsert: true },
82
+ ),
83
+ ).catch(err => {
84
+ log.error(`[ResourceStream] save failed for ${stream.id}`, undefined, err)
85
+ throw err
86
+ })
87
+ }
88
+ }
89
+
90
+ static View () {
91
+ return stream => reduce(stream.events, stream.state)
92
+ }
93
+
94
+ /**
95
+ * @param {string} id resourceId
96
+ * @param {object[]} events
97
+ * @param {object} [snapshot]
98
+ */
99
+ constructor (id, events, snapshot) {
100
+ this.id = id
101
+ this.version = snapshot?.version ?? 0
102
+ this.state = snapshot?.state
103
+ this.events = events ?? []
104
+ this.View = reduce
105
+ }
106
+ }
107
+
108
+ export { ResourceStream as Aggregate }
@@ -48,36 +48,36 @@ export async function uploadFile(sdk, uploadLocation, file) {
48
48
  }
49
49
 
50
50
  export async function removeResource(sdk, id, location) {
51
- await sdk.invoke(DeleteResource, { id })
51
+ await sdk.invoke(DeleteResource, { resourceId: id })
52
52
  if (location) invalidateLocation(sdk, location)
53
- sdk.invalidate(cacheKey({ id: 'resources/get' }, { id }))
53
+ sdk.invalidate(cacheKey({ id: '@ossy/resources/actions/get' }, { resourceId: id }))
54
54
  }
55
55
 
56
56
  export async function updateResourceContent(sdk, id, content) {
57
- const resource = await sdk.invoke(UpdateResourceContent, { id, content })
57
+ const resource = await sdk.invoke(UpdateResourceContent, { resourceId: id, content })
58
58
  invalidateLocation(sdk, resource.location)
59
- sdk.invalidate(cacheKey({ id: 'resources/get' }, { id }))
59
+ sdk.invalidate(cacheKey({ id: '@ossy/resources/actions/get' }, { resourceId: id }))
60
60
  return resource
61
61
  }
62
62
 
63
63
  export async function moveResource(sdk, id, target, sourceLocation) {
64
- const resource = await sdk.invoke(UpdateResourceLocation, { id, target })
64
+ const resource = await sdk.invoke(UpdateResourceLocation, { resourceId: id, target })
65
65
  invalidateLocation(sdk, resource.location)
66
66
  if (sourceLocation) invalidateLocation(sdk, sourceLocation)
67
- sdk.invalidate(cacheKey({ id: 'resources/get' }, { id }))
67
+ sdk.invalidate(cacheKey({ id: '@ossy/resources/actions/get' }, { resourceId: id }))
68
68
  return resource
69
69
  }
70
70
 
71
71
  export async function renameResource(sdk, id, name) {
72
- const resource = await sdk.invoke(UpdateResourceName, { id, name })
72
+ const resource = await sdk.invoke(UpdateResourceName, { resourceId: id, name })
73
73
  invalidateLocation(sdk, resource.location)
74
- sdk.invalidate(cacheKey({ id: 'resources/get' }, { id }))
74
+ sdk.invalidate(cacheKey({ id: '@ossy/resources/actions/get' }, { resourceId: id }))
75
75
  return resource
76
76
  }
77
77
 
78
78
  export async function updateResourceAccess(sdk, params) {
79
79
  const resource = await sdk.invoke(UpdateResourceAccess, params)
80
80
  invalidateLocation(sdk, resource.location)
81
- sdk.invalidate(cacheKey({ id: 'resources/get' }, { id: params.id }))
81
+ sdk.invalidate(cacheKey({ id: '@ossy/resources/actions/get' }, { resourceId: params.resourceId ?? params.id }))
82
82
  return resource
83
83
  }